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
+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>
);
}