Files
echo-nexus/src/app/page.tsx
T

442 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// 回响星核 / Echo Nexus — 游戏主入口
import { useState, useEffect } from "react";
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
import { ResourceBar } from "@/components/game/ResourceBar";
import { CrystalOrb } from "@/components/game/CrystalOrb";
import { DecodePanel } from "@/components/game/DecodeArray";
import { TechTree } from "@/components/game/TechTree";
import { Codex } from "@/components/game/Codex";
import { PrestigeDialog } from "@/components/game/PrestigeDialog";
import { SettingsDialog } from "@/components/game/SettingsDialog";
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
import { AchievementsPanel } from "@/components/game/AchievementsPanel";
import { AchievementNotifier } from "@/components/game/AchievementNotifier";
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
import { BeaconPanel } from "@/components/game/BeaconPanel";
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import { CruiseMode } from "@/components/game/CruiseMode";
import {
StarTideNotifier,
StarTideIndicator,
StarTideOverlay,
} from "@/components/game/StarTide";
import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio";
import { useGameStore } from "@/store/gameStore";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Settings,
RotateCcw,
Sparkles,
Cpu,
BookOpen,
BarChart3,
Rocket,
Github,
Trophy,
Star,
Radio,
Navigation,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { TIDE_EVENTS } from "@/lib/game/starTide";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
export default function Page() {
useGameLoop();
useAudioSync();
const [prestigeOpen, setPrestigeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false);
const [chronicleOpen, setChronicleOpen] = useState(false);
const [cruiseOpen, setCruiseOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const contact = useGameStore((s) => s.contact);
const totalDecoded = useGameStore((s) => s.totalDecoded);
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const ascensions = useGameStore((s) => s.ascensions);
const ownedTech = useGameStore((s) => s.tech);
const ownedFragments = useGameStore((s) => s.fragments);
const ownedAchievements = useGameStore((s) => s.achievements);
const ownedConstellation = useGameStore((s) => s.constellation ?? []);
const pendingPerkChoices = useGameStore((s) => s.pendingPerkChoices);
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
const activeTide = useGameStore((s) => s.activeTide);
const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
// 深空信标:检测是否有可领取的奖励(独立 localStorage
const [beaconClaimable, setBeaconClaimable] = useState(false);
useEffect(() => {
let cancelled = false;
const check = () => {
try {
const c = generateDailyChallenge();
const p = loadDailyProgress();
if (!cancelled) {
setBeaconClaimable(p.completedAt !== null && !p.claimed && p.dateKey === c.dateKey);
}
} catch {
if (!cancelled) setBeaconClaimable(false);
}
};
check();
const id = setInterval(check, 2000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
// 防止 SSR/CSR 不一致
if (!mounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#050410] text-muted-foreground">
<div className="animate-pulse">唤醒回响中…</div>
</div>
);
}
const ownedTechCount = Object.values(ownedTech).filter((v) => v > 0).length;
const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length;
const ownedAchCount = Object.values(ownedAchievements).filter(Boolean).length;
const canPrestige = contact >= 100;
const hasPendingPerk = pendingPerkChoices && pendingPerkChoices.length > 0;
// 仓库满仓警告
const warehouseFull = crystals >= crystalCap * 0.98;
// 目标提示
let goal = "点击中央晶体发起脉冲,累积记忆晶体";
if (totalDecoded === 0 && crystals >= 5) {
goal = "右侧「待解码晶体」点击一颗晶体,开始解码";
} else if (totalDecoded > 0 && ownedTechCount === 0) {
goal = "用洞见解锁技术树,提升产能";
} else if (totalDecoded > 0 && ownedFragCount < FRAGMENTS.length) {
goal = `继续解码,拼凑记忆图谱(${ownedFragCount}/${FRAGMENTS.length}`;
}
if (energy >= 1 && totalDecoded >= 3) {
goal = "「探险」标签可深入遗迹,获取丰厚奖励";
}
if (ascensions >= 1 && ownedConstellation.length > 0) {
goal = `星图天赋已觉醒 ${ownedConstellation.length}/18,继续飞升获取更多`;
}
if (warehouseFull) {
goal = "⚠ 仓库已满,产能浪费中!请解码晶体或升级仓库";
}
if (hasPendingPerk) {
goal = "✦ 星图觉醒!点击顶部「星图」按钮选择一道天赋";
}
if (activeTide) {
const tm = TIDE_EVENTS[activeTide.type];
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
}
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
return (
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
{/* 星空背景 */}
<StarfieldCanvas className="fixed inset-0 w-full h-full -z-10" />
{/* 星潮背景叠层 */}
<StarTideOverlay />
{/* 顶部 Header */}
<header className="sticky top-0 z-30 px-3 sm:px-5 pt-3 pb-2">
<div className="flex items-center gap-3 mb-2.5">
<div className="flex items-center gap-2.5">
<div className="relative h-9 w-9">
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-emerald-400 via-fuchsia-500 to-rose-400 blur-md opacity-60" />
<div className="absolute inset-1 rounded-full bg-[#050410] flex items-center justify-center">
<Sparkles className="h-4 w-4 text-fuchsia-300" />
</div>
</div>
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.6
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
<StarTideIndicator />
<Button
size="sm"
variant="outline"
onClick={() => setCruiseOpen(true)}
className="border-amber-400/50 text-amber-200 hover:bg-amber-500/10 h-8 px-2.5"
aria-label="深空巡航"
title="深空巡航 · 实时玩法"
>
<Navigation className="h-3.5 w-3.5 mr-1" />
巡航
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => setChronicleOpen(true)}
className="h-8 w-8 relative group"
aria-label="编年史"
title="回响编年史"
>
<BookOpen className="h-4 w-4 group-hover:text-fuchsia-300 transition-colors" />
{chronicleCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-fuchsia-500 text-[9px] font-mono font-bold text-white flex items-center justify-center border border-fuchsia-300/50">
{chronicleCount}
</span>
)}
</Button>
{hasPendingPerk && (
<Button
size="sm"
variant="outline"
onClick={() => setConstellationOpen(true)}
className="border-fuchsia-400/60 text-fuchsia-200 hover:bg-fuchsia-500/15 h-8 px-2.5 animate-pulse"
>
<Star className="h-3.5 w-3.5 mr-1" />
觉醒
</Button>
)}
{canPrestige && (
<Button
size="sm"
variant="outline"
onClick={() => setPrestigeOpen(true)}
data-tut="prestige-btn"
className="border-fuchsia-400/50 text-fuchsia-200 hover:bg-fuchsia-500/10 h-8 px-2.5"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
飞升
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => setSettingsOpen(true)}
className="h-8 w-8"
aria-label="设置"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
</header>
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
{/* 左侧:脉冲晶体 */}
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
{/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
<div data-tut="crystal-orb" className="contents">
<CrystalOrb />
</div>
</section>
{/* 右侧:解码 + 标签面板 */}
<section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */}
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-7 h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none">探险</span>
{energy >= 1 && !hasActiveExpedition && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-400 animate-pulse ring-1 ring-black/50" />
)}
{hasActiveExpedition && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="tech" data-tut="tab-tech" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Cpu className="h-3.5 w-3.5" />
<span className="leading-none">技术</span>
</TabsTrigger>
<TabsTrigger value="constellation" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-fuchsia-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
<Star className="h-3.5 w-3.5" />
<span className="leading-none">星图</span>
{hasPendingPerk && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-fuchsia-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="codex" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-cyan-500/15 data-[state=active]:shadow-[0_0_12px_rgba(34,211,238,0.3)] transition-all">
<BookOpen className="h-3.5 w-3.5" />
<span className="leading-none">图谱</span>
</TabsTrigger>
<TabsTrigger value="ach" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Trophy className="h-3.5 w-3.5" />
<span className="leading-none">成就</span>
{ownedAchCount < ACHIEVEMENTS.length && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="beacon" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Radio className="h-3.5 w-3.5" />
<span className="leading-none">信标</span>
{beaconClaimable && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="stats" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-slate-500/15 data-[state=active]:shadow-[0_0_12px_rgba(148,163,184,0.3)] transition-all">
<BarChart3 className="h-3.5 w-3.5" />
<span className="leading-none">统计</span>
</TabsTrigger>
</TabsList>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ExpeditionPanel />
</TabsContent>
<TabsContent value="tech" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<TechTree />
</TabsContent>
<TabsContent value="constellation" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ConstellationPanel />
</TabsContent>
<TabsContent value="codex" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<Codex />
</TabsContent>
<TabsContent value="ach" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AchievementsPanel />
</TabsContent>
<TabsContent value="beacon" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<BeaconPanel />
</TabsContent>
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
<StatsPanel />
</TabsContent>
</Tabs>
</div>
</section>
</div>
</main>
{/* 底部 Footer */}
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
<div
className={`glass rounded-xl px-3 py-2 flex items-center gap-2 text-xs ${
warehouseFull ? "border-amber-400/40 animate-pulse" : ""
} ${activeTide ? "border-white/20" : ""}`}
style={
activeTide
? { boxShadow: `inset 0 0 16px ${TIDE_EVENTS[activeTide.type].glow}` }
: undefined
}
>
<span
className={
warehouseFull
? "text-amber-400"
: activeTide
? ""
: "text-fuchsia-300"
}
style={activeTide ? { color: TIDE_EVENTS[activeTide.type].color } : undefined}
>
</span>
<span
className={`flex-1 truncate ${
warehouseFull ? "text-amber-200" : "text-muted-foreground"
}`}
>
{goal}
</span>
<span className="hidden sm:inline text-muted-foreground/60">|</span>
<span className="hidden sm:inline text-muted-foreground/70">
产能 {formatNum(crystalsPerSec)}/s
</span>
<a
href="https://git.atdunbg.xyz/Super_Z/echo-nexus"
target="_blank"
rel="noreferrer"
className="text-muted-foreground/60 hover:text-foreground transition ml-1"
aria-label="仓库"
>
<Github className="h-3.5 w-3.5" />
</a>
</div>
</footer>
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
<ChronicleDialog open={chronicleOpen} onOpenChange={setChronicleOpen} />
<ConstellationDialog open={constellationOpen} onOpenChange={setConstellationOpen} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<AchievementNotifier />
<StarTideNotifier />
<TutorialOverlay />
<OfflineReportDialog />
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
</div>
);
}
function StatsPanel() {
const s = useGameStore();
const achCount = Object.values(s.achievements).filter(Boolean).length;
// 深空信标本地排行榜最高分(独立 localStorage
const [beaconBest, setBeaconBest] = useState<number | null>(null);
useEffect(() => {
try {
const lb = loadLeaderboard();
// eslint-disable-next-line react-hooks/set-state-in-effect
setBeaconBest(lb.length > 0 ? lb[0].score : null);
} catch {
setBeaconBest(null);
}
}, []);
const rows = [
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
{ label: "飞升周目", value: `${s.ascensions}` },
{ label: "持有蓝图", value: `${s.blueprints.length} / 6` },
{ label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` },
{ label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` },
{ label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` },
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
{ label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` },
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
];
return (
<div className="grid grid-cols-2 gap-1.5 text-xs">
{rows.map((r) => (
<div
key={r.label}
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
>
<span className="text-muted-foreground">{r.label}</span>
<span className="font-mono text-foreground">{r.value}</span>
</div>
))}
</div>
);
}