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 步玩法引导 +

+
+ +
+ {/* 存档信息 */}
@@ -108,16 +131,27 @@ export function SettingsDialog({
- {/* 仓库链接 */} - - - git.atdunbg.xyz/Super_Z/echo-nexus - + {/* 仓库链接 + 在线游玩 */} +
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")); + } +}