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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user