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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// 静态导出开关:BUILD_EXPORT=true 时启用 output:export + basePath
|
||||||
|
// 这样 dev 服务器不受影响,仅构建静态资源时切换配置
|
||||||
|
const isExport = process.env.BUILD_EXPORT === "true";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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 */
|
/* config options here */
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
"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": "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",
|
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"db:push": "prisma db push",
|
"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; }
|
0%, 100% { opacity: 0.6; }
|
||||||
50% { opacity: 1; }
|
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 { ConstellationDialog } from "@/components/game/ConstellationDialog";
|
||||||
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
||||||
import { BeaconPanel } from "@/components/game/BeaconPanel";
|
import { BeaconPanel } from "@/components/game/BeaconPanel";
|
||||||
|
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
|
||||||
import {
|
import {
|
||||||
StarTideNotifier,
|
StarTideNotifier,
|
||||||
StarTideIndicator,
|
StarTideIndicator,
|
||||||
@@ -201,6 +202,7 @@ export default function Page() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setPrestigeOpen(true)}
|
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"
|
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" />
|
<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 -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 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>
|
||||||
|
|
||||||
{/* 右侧:解码 + 标签面板 */}
|
{/* 右侧:解码 + 标签面板 */}
|
||||||
<section className="flex flex-col gap-3 min-h-0">
|
<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 />
|
<DecodePanel />
|
||||||
</div>
|
</div>
|
||||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
|
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
|
||||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
<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">
|
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
|
||||||
<TabsList className="grid grid-cols-7 h-8 bg-black/30">
|
<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" />
|
<Rocket className="h-3 w-3" />
|
||||||
探险
|
探险
|
||||||
{energy >= 1 && !hasActiveExpedition && (
|
{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" />
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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" />
|
<Cpu className="h-3 w-3" />
|
||||||
技术
|
技术
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -366,6 +370,7 @@ export default function Page() {
|
|||||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||||
<AchievementNotifier />
|
<AchievementNotifier />
|
||||||
<StarTideNotifier />
|
<StarTideNotifier />
|
||||||
|
<TutorialOverlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
|
|||||||
const blueprints = useGameStore((s) => s.blueprints);
|
const blueprints = useGameStore((s) => s.blueprints);
|
||||||
|
|
||||||
return (
|
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
|
<Stat
|
||||||
icon={<Gem className="h-4 w-4 text-emerald-400" />}
|
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`}
|
sub={`+${crystalsPerSec.toFixed(1)}/s`}
|
||||||
glow="rgba(52,211,153,0.4)"
|
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
|
<Stat
|
||||||
icon={<Lightbulb className="h-4 w-4 text-amber-400" />}
|
icon={<Lightbulb className="h-4 w-4 text-amber-400" />}
|
||||||
@@ -32,19 +32,19 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
|
|||||||
sub="解码产出"
|
sub="解码产出"
|
||||||
glow="rgba(251,191,36,0.4)"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Waves className="h-4 w-4 text-fuchsia-400" />
|
<Waves className="h-4 w-4 text-fuchsia-400 shrink-0" />
|
||||||
<span className="text-xs text-muted-foreground">接触进度</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap">接触进度</span>
|
||||||
<span className="text-xs font-mono font-semibold text-fuchsia-300 ml-auto">
|
<span className="text-xs font-mono font-semibold text-fuchsia-300 ml-auto tabular-nums">
|
||||||
{contact.toFixed(1)}%
|
{contact.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{contact >= 100 && (
|
{contact >= 100 && (
|
||||||
<button
|
<button
|
||||||
onClick={onPrestige}
|
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>
|
</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"
|
className="h-1.5 bg-white/10 [&>div]:bg-gradient-to-r [&>div]:from-fuchsia-500 [&>div]:to-rose-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2.5">
|
||||||
<RotateCcw className="h-4 w-4 text-rose-400" />
|
<RotateCcw className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground leading-none">飞升</span>
|
<span className="text-[10px] text-muted-foreground leading-none">飞升</span>
|
||||||
<span className="text-sm font-mono font-semibold leading-none mt-0.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{ascensions} <span className="text-amber-300 text-xs">×{blueprints.length}</span>
|
<span className="text-sm font-mono font-semibold leading-none">
|
||||||
</span>
|
{ascensions}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-300 text-xs leading-none">×{blueprints.length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,13 +87,13 @@ function Stat({
|
|||||||
glow: string;
|
glow: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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}
|
{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>
|
<span className="text-[10px] text-muted-foreground leading-none">{label}</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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-sm font-mono font-semibold leading-none tabular-nums">{value}</span>
|
||||||
<span className="text-[10px] text-muted-foreground/80">{sub}</span>
|
<span className="text-[10px] text-muted-foreground/80 whitespace-nowrap">{sub}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { sfx } from "@/hooks/useAudio";
|
import { sfx } from "@/hooks/useAudio";
|
||||||
|
import { restartTutorial } from "@/lib/game/tutorial";
|
||||||
|
|
||||||
export function SettingsDialog({
|
export function SettingsDialog({
|
||||||
open,
|
open,
|
||||||
@@ -63,6 +64,28 @@ export function SettingsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</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="rounded-lg border border-white/10 bg-black/30 p-3 text-xs space-y-1.5">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -108,16 +131,27 @@ export function SettingsDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仓库链接 */}
|
{/* 仓库链接 + 在线游玩 */}
|
||||||
<a
|
<div className="space-y-1.5">
|
||||||
href="https://git.atdunbg.xyz/Super_Z/echo-nexus"
|
<a
|
||||||
target="_blank"
|
href="https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition px-1"
|
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"
|
||||||
<Github className="h-3.5 w-3.5" />
|
>
|
||||||
git.atdunbg.xyz/Super_Z/echo-nexus
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
在线游玩(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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<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