Deploy: v0.5.1 static export

- New: Tutorial system (7-step onboarding with spotlight + action detection)
- UI: ResourceBar spacing optimization (gap, dividers, tabular-nums)
- UI: SettingsDialog adds tutorial replay + online play link
- Anim: tutorial pulse + pop-in keyframes
This commit is contained in:
2026-06-23 16:34:00 +00:00
parent 084387174b
commit 00a46a0bf9
12 changed files with 531 additions and 40 deletions
+15 -1
View File
@@ -1,7 +1,21 @@
import type { NextConfig } from "next";
// 静态导出开关:BUILD_EXPORT=true 时启用 output:export + basePath
// 这样 dev 服务器不受影响,仅构建静态资源时切换配置
const isExport = process.env.BUILD_EXPORT === "true";
const nextConfig: NextConfig = {
output: "standalone",
output: isExport ? "export" : "standalone",
// 部署到 https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus 时需要 basePath
...(isExport
? {
basePath: "/Super_Z/echo-nexus",
trailingSlash: true,
}
: {}),
images: {
unoptimized: true,
},
/* config options here */
typescript: {
ignoreBuildErrors: true,
+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev -p 3000 2>&1 | tee dev.log",
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
"build:static": "BUILD_EXPORT=true next build",
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
"lint": "eslint .",
"db:push": "prisma db push",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

-5
View File
@@ -1,5 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
+16
View File
@@ -149,3 +149,19 @@
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* 教程系统动画 */
@keyframes tut-pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(232,121,249,0.9), 0 0 24px 4px rgba(232,121,249,0.4);
}
50% {
box-shadow: 0 0 0 3px rgba(232,121,249,1), 0 0 32px 8px rgba(232,121,249,0.7);
}
}
/* 教程提示气泡入场 */
@keyframes tut-pop-in {
0% { opacity: 0; transform: scale(0.92) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
+9 -4
View File
@@ -16,6 +16,7 @@ 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 {
StarTideNotifier,
StarTideIndicator,
@@ -201,6 +202,7 @@ export default function Page() {
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" />
@@ -229,20 +231,22 @@ export default function Page() {
{/* 装饰光圈 */}
<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" />
<CrystalOrb />
<div data-tut="crystal-orb" className="contents">
<CrystalOrb />
</div>
</section>
{/* 右侧:解码 + 标签面板 */}
<section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */}
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-7 h-8 bg-black/30">
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-0.5">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-0.5 relative px-0.5">
<Rocket className="h-3 w-3" />
{energy >= 1 && !hasActiveExpedition && (
@@ -252,7 +256,7 @@ export default function Page() {
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="tech" className="text-[11px] gap-0.5 px-0.5">
<TabsTrigger value="tech" data-tut="tab-tech" className="text-[11px] gap-0.5 px-0.5">
<Cpu className="h-3 w-3" />
</TabsTrigger>
@@ -366,6 +370,7 @@ export default function Page() {
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<AchievementNotifier />
<StarTideNotifier />
<TutorialOverlay />
</div>
);
}
+22 -19
View File
@@ -14,7 +14,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
const blueprints = useGameStore((s) => s.blueprints);
return (
<div className="flex flex-wrap items-center gap-2 sm:gap-3 px-3 py-2.5 rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4 px-3.5 py-2.5 rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md">
{/* 晶体 */}
<Stat
icon={<Gem className="h-4 w-4 text-emerald-400" />}
@@ -23,7 +23,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
sub={`+${crystalsPerSec.toFixed(1)}/s`}
glow="rgba(52,211,153,0.4)"
/>
<div className="h-8 w-px bg-white/10 hidden sm:block" />
<div className="h-9 w-px bg-gradient-to-b from-transparent via-white/15 to-transparent hidden sm:block" />
{/* 洞见 */}
<Stat
icon={<Lightbulb className="h-4 w-4 text-amber-400" />}
@@ -32,19 +32,19 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
sub="解码产出"
glow="rgba(251,191,36,0.4)"
/>
<div className="h-8 w-px bg-white/10 hidden sm:block" />
<div className="h-9 w-px bg-gradient-to-b from-transparent via-white/15 to-transparent hidden sm:block" />
{/* 接触进度 */}
<div className="flex-1 min-w-[180px] flex flex-col gap-1">
<div className="flex-1 min-w-[200px] flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-fuchsia-400" />
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono font-semibold text-fuchsia-300 ml-auto">
<Waves className="h-4 w-4 text-fuchsia-400 shrink-0" />
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<span className="text-xs font-mono font-semibold text-fuchsia-300 ml-auto tabular-nums">
{contact.toFixed(1)}%
</span>
{contact >= 100 && (
<button
onClick={onPrestige}
className="text-[10px] px-2 py-0.5 rounded-full bg-fuchsia-500/20 border border-fuchsia-400/50 text-fuchsia-200 hover:bg-fuchsia-500/30 transition animate-pulse"
className="text-[10px] px-2 py-0.5 rounded-full bg-fuchsia-500/20 border border-fuchsia-400/50 text-fuchsia-200 hover:bg-fuchsia-500/30 transition animate-pulse whitespace-nowrap ml-1"
>
</button>
@@ -55,15 +55,18 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
className="h-1.5 bg-white/10 [&>div]:bg-gradient-to-r [&>div]:from-fuchsia-500 [&>div]:to-rose-400"
/>
</div>
<div className="h-8 w-px bg-white/10 hidden sm:block" />
<div className="h-9 w-px bg-gradient-to-b from-transparent via-white/15 to-transparent hidden sm:block" />
{/* 飞升 */}
<div className="flex items-center gap-2">
<RotateCcw className="h-4 w-4 text-rose-400" />
<div className="flex flex-col">
<div className="flex items-center gap-2.5">
<RotateCcw className="h-4 w-4 text-rose-400 shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground leading-none"></span>
<span className="text-sm font-mono font-semibold leading-none mt-0.5">
{ascensions} <span className="text-amber-300 text-xs">×{blueprints.length}</span>
</span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-mono font-semibold leading-none">
{ascensions}
</span>
<span className="text-amber-300 text-xs leading-none">×{blueprints.length}</span>
</div>
</div>
</div>
</div>
@@ -84,13 +87,13 @@ function Stat({
glow: string;
}) {
return (
<div className="flex items-center gap-2" style={{ filter: `drop-shadow(0 0 6px ${glow})` }}>
<div className="flex items-center gap-2.5" style={{ filter: `drop-shadow(0 0 6px ${glow})` }}>
{icon}
<div className="flex flex-col">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground leading-none">{label}</span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-mono font-semibold leading-none mt-0.5">{value}</span>
<span className="text-[10px] text-muted-foreground/80">{sub}</span>
<span className="text-sm font-mono font-semibold leading-none tabular-nums">{value}</span>
<span className="text-[10px] text-muted-foreground/80 whitespace-nowrap">{sub}</span>
</div>
</div>
</div>
+45 -11
View File
@@ -12,10 +12,11 @@ import {
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Trash2, Github, AlertTriangle, Volume2 } from "lucide-react";
import { Trash2, Github, AlertTriangle, Volume2, GraduationCap, ExternalLink } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { restartTutorial } from "@/lib/game/tutorial";
export function SettingsDialog({
open,
@@ -63,6 +64,28 @@ export function SettingsDialog({
</div>
</div>
{/* 教程 */}
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-black/30 p-3">
<div>
<Label className="text-sm"></Label>
<p className="text-[11px] text-muted-foreground mt-0.5">
5
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[11px] border-fuchsia-400/40 text-fuchsia-200 hover:bg-fuchsia-500/10"
onClick={() => {
restartTutorial();
onOpenChange(false);
}}
>
<GraduationCap className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{/* 存档信息 */}
<div className="rounded-lg border border-white/10 bg-black/30 p-3 text-xs space-y-1.5">
<div className="flex justify-between">
@@ -108,16 +131,27 @@ export function SettingsDialog({
</Button>
</div>
{/* 仓库链接 */}
<a
href="https://git.atdunbg.xyz/Super_Z/echo-nexus"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition px-1"
>
<Github className="h-3.5 w-3.5" />
git.atdunbg.xyz/Super_Z/echo-nexus
</a>
{/* 仓库链接 + 在线游玩 */}
<div className="space-y-1.5">
<a
href="https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs text-emerald-300/80 hover:text-emerald-200 transition px-1 py-1 rounded hover:bg-emerald-500/5"
>
<ExternalLink className="h-3.5 w-3.5" />
线gitea-pages
</a>
<a
href="https://git.atdunbg.xyz/Super_Z/echo-nexus"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition px-1 py-1 rounded hover:bg-white/5"
>
<Github className="h-3.5 w-3.5" />
git.atdunbg.xyz/Super_Z/echo-nexus
</a>
</div>
</div>
<DialogFooter>
+320
View File
@@ -0,0 +1,320 @@
"use client";
// 回响星核 / Echo Nexus — 新手教程覆盖层
import { useState, useEffect, useCallback, useRef } from "react";
import { TUTORIAL_STEPS, hasSeenTutorial, markTutorialSeen } from "@/lib/game/tutorial";
import { useGameStore } from "@/store/gameStore";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, X, Sparkles } from "lucide-react";
interface Rect {
left: number;
top: number;
width: number;
height: number;
}
export function TutorialOverlay() {
const [open, setOpen] = useState(false);
const [stepIdx, setStepIdx] = useState(0);
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [actionDone, setActionDone] = useState(false);
const rafRef = useRef<number | null>(null);
// 首次访问自动打开
useEffect(() => {
if (!hasSeenTutorial()) {
// 等待游戏 mount
const t = setTimeout(() => setOpen(true), 600);
return () => clearTimeout(t);
}
}, []);
// 监听「重新查看教程」事件
useEffect(() => {
const handler = () => {
setStepIdx(0);
setActionDone(false);
setOpen(true);
};
window.addEventListener("echo-nexus-tutorial-restart", handler);
return () => window.removeEventListener("echo-nexus-tutorial-restart", handler);
}, []);
// 监听预期动作(用现有 store 状态推断,避免改 schema
const crystals = useGameStore((s) => s.crystals);
const pulsePower = useGameStore((s) => s.pulsePower);
const ownedTech = useGameStore((s) => s.tech);
const activePuzzle = useGameStore((s) => s.activePuzzle);
const hasActiveExpedition = useGameStore(
(s) => !!s.activeExpedition && !s.activeExpedition.finished
);
// 记录进入 pulse 步骤时的晶体基线
const pulseBaseRef = useRef<number | null>(null);
// 动作完成检测:用 rAF 异步轮询,避免 effect 中同步 setState
useEffect(() => {
if (!open) return;
const step = TUTORIAL_STEPS[stepIdx];
if (!step?.expectAction) {
return;
}
let raf2 = 0;
const check = () => {
const s = useGameStore.getState();
let done = false;
if (step.expectAction === "pulse") {
if (pulseBaseRef.current === null) pulseBaseRef.current = s.crystals;
done = s.crystals >= (pulseBaseRef.current ?? 0) + Math.max(0.5, s.pulsePower * 0.5);
} else if (step.expectAction === "decode-start") {
done = !!s.activePuzzle;
} else if (step.expectAction === "tech-buy") {
done = Object.values(s.tech).some((v) => v > 0);
} else if (step.expectAction === "expedition-start") {
done = !!s.activeExpedition && !s.activeExpedition.finished;
}
setActionDone((prev) => (prev !== done ? done : prev));
raf2 = requestAnimationFrame(check);
};
raf2 = requestAnimationFrame(check);
return () => cancelAnimationFrame(raf2);
}, [open, stepIdx]);
// 跟踪目标元素位置
const updateTargetRect = useCallback(() => {
const step = TUTORIAL_STEPS[stepIdx];
if (!step?.target) {
setTargetRect(null);
return;
}
const el = document.querySelector(`[data-tut="${step.target}"]`) as HTMLElement | null;
if (!el) {
setTargetRect(null);
return;
}
const r = el.getBoundingClientRect();
setTargetRect({
left: r.left,
top: r.top,
width: r.width,
height: r.height,
});
}, [stepIdx]);
useEffect(() => {
if (!open) return;
// 首帧不直接 setState,统一交给 rAF 循环检测
const loop = () => {
updateTargetRect();
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [open, stepIdx, updateTargetRect]);
const close = useCallback(() => {
setOpen(false);
markTutorialSeen();
}, []);
const next = useCallback(() => {
if (stepIdx >= TUTORIAL_STEPS.length - 1) {
close();
return;
}
setStepIdx((i) => i + 1);
setActionDone(false);
}, [stepIdx, close]);
const prev = useCallback(() => {
if (stepIdx <= 0) return;
setStepIdx((i) => i - 1);
setActionDone(false);
}, [stepIdx]);
if (!open) return null;
const step = TUTORIAL_STEPS[stepIdx];
const isLast = stepIdx >= TUTORIAL_STEPS.length - 1;
// 计算提示气泡位置
const placement = step.placement ?? "auto";
let tooltipStyle: React.CSSProperties = {};
if (targetRect) {
const pad = 16;
if (placement === "right") {
tooltipStyle = {
left: targetRect.left + targetRect.width + pad,
top: targetRect.top + targetRect.height / 2,
transform: "translateY(-50%)",
};
} else if (placement === "left") {
tooltipStyle = {
left: Math.max(16, targetRect.left - 360 - pad),
top: targetRect.top + targetRect.height / 2,
transform: "translateY(-50%)",
maxWidth: 340,
};
} else if (placement === "top") {
tooltipStyle = {
left: Math.max(16, Math.min(targetRect.left, window.innerWidth - 360 - 16)),
top: Math.max(16, targetRect.top - 220),
maxWidth: 340,
};
} else {
// bottom / auto
tooltipStyle = {
left: Math.max(16, Math.min(window.innerWidth / 2 - 170, window.innerWidth - 360 - 16)),
top: Math.min(window.innerHeight - 260, (targetRect?.bottom ?? window.innerHeight / 2) + pad),
maxWidth: 340,
};
}
} else {
// 无目标 → 居中
tooltipStyle = {
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
maxWidth: 440,
};
}
// 聚光灯遮罩:用 4 块 div 挖洞
const renderMask = () => {
if (!targetRect) {
// 全屏半透明
return <div className="fixed inset-0 bg-black/70 z-[80] pointer-events-auto" />;
}
const { left, top, width, height } = targetRect;
const pad = 6;
const l = left - pad;
const t = top - pad;
const w = width + pad * 2;
const h = height + pad * 2;
return (
<>
{/* 上 */}
<div className="fixed bg-black/70 z-[80] pointer-events-auto" style={{ left: 0, top: 0, right: 0, height: Math.max(0, t) }} />
{/* 下 */}
<div className="fixed bg-black/70 z-[80] pointer-events-auto" style={{ left: 0, top: t + h, right: 0, bottom: 0 }} />
{/* 左 */}
<div className="fixed bg-black/70 z-[80] pointer-events-auto" style={{ left: 0, top: t, width: Math.max(0, l), height: h }} />
{/* 右 */}
<div className="fixed bg-black/70 z-[80] pointer-events-auto" style={{ left: l + w, top: t, right: 0, height: h }} />
{/* 高亮边框 + 呼吸光 */}
<div
className="fixed z-[81] pointer-events-none rounded-lg"
style={{
left: l,
top: t,
width: w,
height: h,
boxShadow: `0 0 0 2px rgba(232,121,249,0.9), 0 0 24px 4px rgba(232,121,249,0.5)`,
animation: "tut-pulse 1.6s ease-in-out infinite",
}}
/>
</>
);
};
return (
<div className="fixed inset-0 z-[79]">
{/* 遮罩(允许 pointer-events 透过到非高亮区,但拦截点击避免误操作) */}
{renderMask()}
{/* 提示气泡 */}
<div
className="fixed z-[82] w-[340px] sm:w-[360px] max-w-[calc(100vw-32px)]"
style={tooltipStyle}
>
<div className="relative rounded-2xl border border-fuchsia-400/40 bg-gradient-to-br from-[#1a0a2e]/95 to-[#0a0a1e]/95 backdrop-blur-xl shadow-2xl shadow-fuchsia-500/20 overflow-hidden">
{/* 顶部装饰条 */}
<div className="h-1 w-full bg-gradient-to-r from-emerald-400 via-fuchsia-500 to-rose-400" />
{/* 关闭 */}
<button
onClick={close}
className="absolute top-2 right-2 h-7 w-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-white/10 transition z-10"
aria-label="关闭教程"
>
<X className="h-3.5 w-3.5" />
</button>
<div className="p-4 pt-3.5">
{/* 步骤指示 */}
<div className="flex items-center gap-1.5 mb-2.5">
{TUTORIAL_STEPS.map((s, i) => (
<div
key={s.id}
className={`h-1 rounded-full transition-all ${
i === stepIdx
? "w-6 bg-fuchsia-400"
: i < stepIdx
? "w-3 bg-fuchsia-400/50"
: "w-3 bg-white/15"
}`}
/>
))}
</div>
<h3 className="text-sm font-bold text-fuchsia-100 mb-1.5 flex items-center gap-1.5 pr-6">
<Sparkles className="h-3.5 w-3.5 text-fuchsia-300 shrink-0" />
{step.title}
</h3>
<p className="text-[12.5px] text-muted-foreground leading-relaxed whitespace-pre-line">
{step.body}
</p>
{/* 动作提示 */}
{step.expectAction && !actionDone && (
<div className="mt-3 px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-400/30 text-[11px] text-amber-200 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
</div>
)}
{step.expectAction && actionDone && (
<div className="mt-3 px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-400/30 text-[11px] text-emerald-200 flex items-center gap-1.5">
</div>
)}
{/* 按钮 */}
<div className="flex items-center gap-2 mt-4">
<Button
size="sm"
variant="ghost"
onClick={prev}
disabled={stepIdx === 0}
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={close}
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground"
>
</Button>
<Button
size="sm"
onClick={next}
className="h-7 px-3 text-[11px] bg-fuchsia-500 hover:bg-fuchsia-600 text-white border-fuchsia-400/50"
>
{isLast ? "完成" : "下一步"}
<ChevronRight className="h-3.5 w-3.5 ml-0.5" />
</Button>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 text-center mt-2">
{stepIdx + 1} / {TUTORIAL_STEPS.length}
</div>
</div>
</div>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
// 回响星核 / Echo Nexus — 新手教程系统
// 首次访问引导,分步骤高亮核心元素,可跳过/重看
export interface TutorialStep {
id: string;
// 目标元素的 data-tut 属性值(用于高亮定位)
target?: string;
// 提示放置位置:auto | top | bottom | left | right
placement?: "auto" | "top" | "bottom" | "left" | "right";
title: string;
body: string;
// 完成此步骤的预期动作(可选,用于"做了才下一步")
// 不设则点"下一步"即可
expectAction?: "pulse" | "decode-start" | "tech-buy" | "expedition-start";
}
export const TUTORIAL_STEPS: TutorialStep[] = [
{
id: "welcome",
placement: "bottom",
title: "✦ 欢迎来到回响星核",
body: "你是一台苏醒在深空遗迹中的自治无人机。在这里,你需要采矿、解码以太文明遗留的记忆晶体,逐步拼凑出一段跨越维度的叙事。\n\n本引导将带你认识 5 个核心玩法,约 1 分钟。可随时跳过。",
},
{
id: "crystal",
target: "crystal-orb",
placement: "right",
title: "① 脉冲扫描 · 采矿",
body: "点击中央的「记忆晶体」发起脉冲扫描,获得记忆晶体。\n\n连续点击可触发连击,连击越高单次产出越多。无人机也会每秒自动采集(+0.4/s 起)。",
expectAction: "pulse",
},
{
id: "decode",
target: "decode-panel",
placement: "left",
title: "② 谐振序列 · 解码",
body: "记忆晶体存满后,点击右侧「待解码晶体」开启谐振谜题:按目标色序依次点击相邻同色节点重建回路。\n\n解码成功 → 获得技术洞见 + 记忆碎片(叙事片段)。这是游戏的核心解谜层。",
expectAction: "decode-start",
},
{
id: "tech",
target: "tab-tech",
placement: "top",
title: "③ 技术树 · 成长",
body: "用「技术洞见」在「技术」标签升级 4 大分支:产能 / 解码 / 仓库 / 探险。\n\n每个分支 3 级,策略性地分配洞见是关键。",
expectAction: "tech-buy",
},
{
id: "expedition",
target: "tab-expedition",
placement: "top",
title: "④ 遗迹探险 · 肉鸽",
body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。",
expectAction: "expedition-start",
},
{
id: "prestige",
target: "prestige-btn",
placement: "bottom",
title: "⑤ 飞升 · 多周目",
body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。",
},
{
id: "done",
placement: "bottom",
title: "✦ 回响已唤醒",
body: "教程结束!后续可在「设置 → 重新查看教程」再次查看。\n\n深空之中,回响永续。祝你考古愉快。",
},
];
const STORAGE_KEY = "echo-nexus-tutorial-v1";
export function hasSeenTutorial(): boolean {
if (typeof window === "undefined") return true;
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch {
return true;
}
}
export function markTutorialSeen() {
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch {
/* ignore */
}
}
export function resetTutorial() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
export function restartTutorial() {
resetTutorial();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("echo-nexus-tutorial-restart"));
}
}