diff --git a/next.config.ts b/next.config.ts
index 0bd2f1124..26c62acc2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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,
diff --git a/package.json b/package.json
index faca77146..a3a673f4f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/qa-static-01.png b/qa-static-01.png
new file mode 100644
index 000000000..fcafd51ad
Binary files /dev/null and b/qa-static-01.png differ
diff --git a/qa-static-02.png b/qa-static-02.png
new file mode 100644
index 000000000..3374a6a02
Binary files /dev/null and b/qa-static-02.png differ
diff --git a/qa-tut-01.png b/qa-tut-01.png
new file mode 100644
index 000000000..15c5e9d1a
Binary files /dev/null and b/qa-tut-01.png differ
diff --git a/src/app/api/route.ts b/src/app/api/route.ts
deleted file mode 100644
index 0277710b3..000000000
--- a/src/app/api/route.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { NextResponse } from "next/server";
-
-export async function GET() {
- return NextResponse.json({ message: "Hello, world!" });
-}
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index 60c8e5b6e..4126c5311 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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); }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 1f58a8b1d..56029161c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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"
>
@@ -229,20 +231,22 @@ export default function Page() {
{/* 装饰光圈 */}
-
+
+
+
{/* 右侧:解码 + 标签面板 */}
{/* 解码面板 */}
-
+
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
-
+
探险
{energy >= 1 && !hasActiveExpedition && (
@@ -252,7 +256,7 @@ export default function Page() {
)}
-
+
技术
@@ -366,6 +370,7 @@ export default function Page() {
+
);
}
diff --git a/src/components/game/ResourceBar.tsx b/src/components/game/ResourceBar.tsx
index ce330495c..a38512656 100644
--- a/src/components/game/ResourceBar.tsx
+++ b/src/components/game/ResourceBar.tsx
@@ -14,7 +14,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
const blueprints = useGameStore((s) => s.blueprints);
return (
-
+
{/* 晶体 */}
}
@@ -23,7 +23,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
sub={`+${crystalsPerSec.toFixed(1)}/s`}
glow="rgba(52,211,153,0.4)"
/>
-
+
{/* 洞见 */}
}
@@ -32,19 +32,19 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
sub="解码产出"
glow="rgba(251,191,36,0.4)"
/>
-
+
{/* 接触进度 */}
-
+
-
- 接触进度
-
+
+ 接触进度
+
{contact.toFixed(1)}%
{contact >= 100 && (
@@ -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"
/>
-
+
{/* 飞升 */}
-
-
-
+
+
+
飞升
-
- {ascensions} ×{blueprints.length}
-
+
+
+ {ascensions}
+
+ ×{blueprints.length}
+
@@ -84,13 +87,13 @@ function Stat({
glow: string;
}) {
return (
-
+
{icon}
-
+
{label}
- {value}
- {sub}
+ {value}
+ {sub}
diff --git a/src/components/game/SettingsDialog.tsx b/src/components/game/SettingsDialog.tsx
index 5e6d95f39..e248c064e 100644
--- a/src/components/game/SettingsDialog.tsx
+++ b/src/components/game/SettingsDialog.tsx
@@ -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({
+ {/* 教程 */}
+
+
+
+
+ 重新查看 5 步玩法引导
+
+
+
+
+
{/* 存档信息 */}
diff --git a/src/components/game/TutorialOverlay.tsx b/src/components/game/TutorialOverlay.tsx
new file mode 100644
index 000000000..e56567d75
--- /dev/null
+++ b/src/components/game/TutorialOverlay.tsx
@@ -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(null);
+ const [actionDone, setActionDone] = useState(false);
+ const rafRef = useRef(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(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 ;
+ }
+ 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 (
+ <>
+ {/* 上 */}
+
+ {/* 下 */}
+
+ {/* 左 */}
+
+ {/* 右 */}
+
+ {/* 高亮边框 + 呼吸光 */}
+
+ >
+ );
+ };
+
+ return (
+
+ {/* 遮罩(允许 pointer-events 透过到非高亮区,但拦截点击避免误操作) */}
+ {renderMask()}
+
+ {/* 提示气泡 */}
+
+
+ {/* 顶部装饰条 */}
+
+ {/* 关闭 */}
+
+
+
+ {/* 步骤指示 */}
+
+ {TUTORIAL_STEPS.map((s, i) => (
+
+ ))}
+
+
+
+
+ {step.title}
+
+
+ {step.body}
+
+
+ {/* 动作提示 */}
+ {step.expectAction && !actionDone && (
+
+
+ 完成动作后自动进入下一步,或点跳过
+
+ )}
+ {step.expectAction && actionDone && (
+
+ ✓ 已检测到动作
+
+ )}
+
+ {/* 按钮 */}
+
+
+
+
+
+
+
+
+ {stepIdx + 1} / {TUTORIAL_STEPS.length}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/game/tutorial.ts b/src/lib/game/tutorial.ts
new file mode 100644
index 000000000..aea095caf
--- /dev/null
+++ b/src/lib/game/tutorial.ts
@@ -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"));
+ }
+}