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:
+15
-1
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -1,5 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ message: "Hello, world!" });
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user