diff --git a/docs/repo b/docs/repo index 84749c218..51714d253 160000 --- a/docs/repo +++ b/docs/repo @@ -1 +1 @@ -Subproject commit 84749c218ac777a3beb6706b37d2c2d25d689cd0 +Subproject commit 51714d25321c8d82cd96b45d57ef02818af8a444 diff --git a/download/qa-01-home.png b/download/qa-01-home.png new file mode 100644 index 000000000..b88943fa6 Binary files /dev/null and b/download/qa-01-home.png differ diff --git a/download/qa-02-decode-t2.png b/download/qa-02-decode-t2.png new file mode 100644 index 000000000..0f0810afe Binary files /dev/null and b/download/qa-02-decode-t2.png differ diff --git a/download/qa-03-decode-solved.png b/download/qa-03-decode-solved.png new file mode 100644 index 000000000..9623a0f83 Binary files /dev/null and b/download/qa-03-decode-solved.png differ diff --git a/download/qa-04-decode-success.png b/download/qa-04-decode-success.png new file mode 100644 index 000000000..4bd18c8e1 Binary files /dev/null and b/download/qa-04-decode-success.png differ diff --git a/download/qa-achievements.png b/download/qa-achievements.png new file mode 100644 index 000000000..f61698488 Binary files /dev/null and b/download/qa-achievements.png differ diff --git a/download/qa-achievements2.png b/download/qa-achievements2.png new file mode 100644 index 000000000..4a2d58387 Binary files /dev/null and b/download/qa-achievements2.png differ diff --git a/download/qa-decode-solved.png b/download/qa-decode-solved.png new file mode 100644 index 000000000..d6b2c6a2b Binary files /dev/null and b/download/qa-decode-solved.png differ diff --git a/download/qa-decode.png b/download/qa-decode.png new file mode 100644 index 000000000..991673f04 Binary files /dev/null and b/download/qa-decode.png differ diff --git a/download/qa-expedition-explored.png b/download/qa-expedition-explored.png new file mode 100644 index 000000000..40858eeb5 Binary files /dev/null and b/download/qa-expedition-explored.png differ diff --git a/download/qa-expedition-start.png b/download/qa-expedition-start.png new file mode 100644 index 000000000..ce92df6ae Binary files /dev/null and b/download/qa-expedition-start.png differ diff --git a/download/qa-expedition.png b/download/qa-expedition.png new file mode 100644 index 000000000..6e89649fa Binary files /dev/null and b/download/qa-expedition.png differ diff --git a/download/qa-expedition2.png b/download/qa-expedition2.png new file mode 100644 index 000000000..9003a7550 Binary files /dev/null and b/download/qa-expedition2.png differ diff --git a/download/qa-final-v021.png b/download/qa-final-v021.png new file mode 100644 index 000000000..c5d093961 Binary files /dev/null and b/download/qa-final-v021.png differ diff --git a/download/qa-final.png b/download/qa-final.png new file mode 100644 index 000000000..f6544934b Binary files /dev/null and b/download/qa-final.png differ diff --git a/download/qa-home.png b/download/qa-home.png new file mode 100644 index 000000000..590522485 Binary files /dev/null and b/download/qa-home.png differ diff --git a/download/qa-pulse-particles.png b/download/qa-pulse-particles.png new file mode 100644 index 000000000..9f2e16934 Binary files /dev/null and b/download/qa-pulse-particles.png differ diff --git a/download/qa-v02-home.png b/download/qa-v02-home.png new file mode 100644 index 000000000..02c0e302e Binary files /dev/null and b/download/qa-v02-home.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index cbcadcb0e..3ce122e09 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,7 +10,10 @@ import { Codex } from "@/components/game/Codex"; import { PrestigeDialog } from "@/components/game/PrestigeDialog"; import { SettingsDialog } from "@/components/game/SettingsDialog"; import { ExpeditionPanel } from "@/components/game/ExpeditionPanel"; +import { AchievementsPanel } from "@/components/game/AchievementsPanel"; +import { AchievementNotifier } from "@/components/game/AchievementNotifier"; import { useGameLoop } from "@/hooks/useGameLoop"; +import { useAudioSync } from "@/hooks/useAudio"; import { useGameStore } from "@/store/gameStore"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -23,11 +26,14 @@ import { BarChart3, Rocket, Github, + Trophy, } from "lucide-react"; import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config"; +import { ACHIEVEMENTS } from "@/lib/game/achievements"; export default function Page() { useGameLoop(); + useAudioSync(); const [prestigeOpen, setPrestigeOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -38,6 +44,7 @@ export default function Page() { const ascensions = useGameStore((s) => s.ascensions); const ownedTech = useGameStore((s) => s.tech); const ownedFragments = useGameStore((s) => s.fragments); + const ownedAchievements = useGameStore((s) => s.achievements); const crystals = useGameStore((s) => s.crystals); const crystalCap = useGameStore((s) => s.crystalCap); const energy = useGameStore((s) => s.energy); @@ -60,6 +67,7 @@ export default function Page() { const ownedTechCount = Object.values(ownedTech).filter((v) => v > 0).length; const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length; + const ownedAchCount = Object.values(ownedAchievements).filter(Boolean).length; const canPrestige = contact >= 100; // 仓库满仓警告 @@ -100,7 +108,7 @@ export default function Page() {

回响星核

- ECHO NEXUS · v0.1 + ECHO NEXUS · v0.2.1

@@ -147,11 +155,11 @@ export default function Page() {
- {/* 标签面板:探险 / 技术 / 图谱 / 统计 */} + {/* 标签面板:探险 / 技术 / 图谱 / 成就 / 统计 */}
- - + + 探险 {energy >= 1 && !hasActiveExpedition && ( @@ -161,15 +169,22 @@ export default function Page() { )} - + 技术 - + 图谱 - + + + 成就 + {ownedAchCount < ACHIEVEMENTS.length && ( + + )} + + 统计 @@ -183,6 +198,9 @@ export default function Page() { + + + @@ -215,22 +233,27 @@ export default function Page() { +
); } function StatsPanel() { const s = useGameStore(); + const achCount = Object.values(s.achievements).filter(Boolean).length; const rows = [ { label: "累计解码晶体", value: `${s.totalDecoded} 颗` }, { label: "飞升周目", value: `${s.ascensions}` }, { label: "持有蓝图", value: `${s.blueprints.length} / 6` }, { label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` }, { label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` }, + { label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` }, { label: "接触进度", value: `${s.contact.toFixed(1)}%` }, { label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` }, { label: "仓库上限", value: `${formatNum(s.crystalCap)}` }, { label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` }, + { label: "累计探险", value: `${s.totalExpeditions} 次` }, + { label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` }, ]; return (
diff --git a/src/components/game/AchievementNotifier.tsx b/src/components/game/AchievementNotifier.tsx new file mode 100644 index 000000000..632ba5862 --- /dev/null +++ b/src/components/game/AchievementNotifier.tsx @@ -0,0 +1,26 @@ +"use client"; +// 回响星核 / Echo Nexus — 成就解锁通知消费者 +import { useEffect } from "react"; +import { useGameStore } from "@/store/gameStore"; +import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; + +export function AchievementNotifier() { + const queue = useGameStore((s) => s._achievementQueue); + const consume = useGameStore((s) => s.consumeAchievementQueue); + const { toast } = useToast(); + + useEffect(() => { + if (queue.length === 0) return; + const items = consume(); + for (const a of items) { + sfx("achievement"); + toast({ + title: `🏆 成就解锁:${a.name}`, + description: `${a.desc} 奖励:${a.rewardText}`, + }); + } + }, [queue, consume, toast]); + + return null; +} diff --git a/src/components/game/AchievementsPanel.tsx b/src/components/game/AchievementsPanel.tsx new file mode 100644 index 000000000..7430b3bd8 --- /dev/null +++ b/src/components/game/AchievementsPanel.tsx @@ -0,0 +1,107 @@ +"use client"; +// 回响星核 / Echo Nexus — 成就系统面板 +import { useGameStore } from "@/store/gameStore"; +import { ACHIEVEMENTS } from "@/lib/game/achievements"; +import { achievementBonuses } from "@/lib/game/achievements"; +import { Trophy, Lock } from "lucide-react"; + +export function AchievementsPanel() { + const achievements = useGameStore((s) => s.achievements); + const unlockedCount = Object.values(achievements).filter(Boolean).length; + const bonus = achievementBonuses(achievements); + + return ( +
+ {/* 顶部:进度 + 加成总览 */} +
+

+ + 成就 +

+ + {unlockedCount} / {ACHIEVEMENTS.length} + +
+ + {/* 永久加成条 */} +
+ 永久加成 + {bonus.crystalsPerSecPct > 0 && ( + 产能 +{bonus.crystalsPerSecPct}% + )} + {bonus.insightPct > 0 && ( + 洞见 +{bonus.insightPct}% + )} + {bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && ( + 暂无(达成成就解锁) + )} +
+ + {/* 成就网格 */} +
+ {ACHIEVEMENTS.map((a) => { + const unlocked = !!achievements[a.id]; + return ( +
+ {unlocked && ( +
+ )} +
+ {/* 图标徽章 */} +
+ {unlocked ? a.icon : } +
+ {/* 文本 */} +
+
+ {a.name} +
+
+ {a.desc} +
+ {unlocked && ( +
+ ✦ {a.rewardText} +
+ )} +
+
+
+ ); + })} +
+ + +
+ ); +} diff --git a/src/components/game/CrystalOrb.tsx b/src/components/game/CrystalOrb.tsx index d65629353..1af1a5efb 100644 --- a/src/components/game/CrystalOrb.tsx +++ b/src/components/game/CrystalOrb.tsx @@ -1,9 +1,10 @@ "use client"; // 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲 -import { useRef, useState, useCallback, useEffect } from "react"; +import { useRef, useState, useCallback } from "react"; import { useGameStore } from "@/store/gameStore"; import { formatNum } from "@/lib/game/config"; import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; interface FloatNum { id: number; @@ -11,6 +12,15 @@ interface FloatNum { y: number; text: string; born: number; + color: string; +} + +interface Particle { + id: number; + angle: number; + dist: number; + born: number; + color: string; } export function CrystalOrb() { @@ -21,6 +31,7 @@ export function CrystalOrb() { const combo = useGameStore((s) => s._combo); const { toast } = useToast(); const [floats, setFloats] = useState([]); + const [particles, setParticles] = useState([]); const [pulseAnim, setPulseAnim] = useState(0); const idRef = useRef(0); @@ -32,14 +43,33 @@ export function CrystalOrb() { const x = e.clientX - rect.left; const y = e.clientY - rect.top; const id = idRef.current++; + const floatColor = res.combo >= 5 ? "#fbbf24" : res.combo >= 3 ? "#e879f9" : "#34d399"; setFloats((f) => [ ...f, - { id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now() }, + { id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now(), color: floatColor }, ]); + // 粒子爆发 + const newParticles: Particle[] = []; + const pcount = 6 + Math.min(6, res.combo); + for (let i = 0; i < pcount; i++) { + newParticles.push({ + id: idRef.current++, + angle: (Math.PI * 2 * i) / pcount + Math.random() * 0.3, + dist: 0, + born: Date.now(), + color: floatColor, + }); + } + setParticles((p) => [...p, ...newParticles]); setPulseAnim((n) => n + 1); + // 音效 + sfx(res.combo >= 3 ? "pulseCombo" : "pulse", { combo: res.combo }); setTimeout(() => { setFloats((f) => f.filter((it) => it.id !== id)); }, 900); + setTimeout(() => { + setParticles((p) => p.filter((it) => !newParticles.includes(it))); + }, 700); if (res.combo >= 5 && res.combo % 5 === 0) { toast({ title: `×${res.combo} 连击!`, @@ -112,6 +142,12 @@ export function CrystalOrb() { + {/* 脉冲扩散环(点击时) */} +
{/* 中央晶体 */}
+ {/* 粒子爆发 */} + {particles.map((p) => ( + + ))} {/* 浮动数字 */} {floats.map((f) => ( {f.text} @@ -183,6 +235,20 @@ export function CrystalOrb() { 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -180%) scale(1.3); } } + @keyframes echo-ring { + 0% { transform: scale(0.8); opacity: 0.8; } + 100% { transform: scale(1.6); opacity: 0; } + } + @keyframes echo-burst { + 0% { + transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) rotate(calc(-1 * var(--angle))); + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) rotate(calc(-1 * var(--angle))); + opacity: 0; + } + } `}
); diff --git a/src/components/game/DecodeArray.tsx b/src/components/game/DecodeArray.tsx index e1f6a993e..c0f46e304 100644 --- a/src/components/game/DecodeArray.tsx +++ b/src/components/game/DecodeArray.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { useGameStore } from "@/store/gameStore"; import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; import { COLOR_VISUAL } from "@/lib/game/config"; import { isSolvable, canStartFrom } from "@/lib/game/decode"; import type { DecodeNode, ResonanceColor } from "@/lib/game/types"; @@ -35,9 +36,11 @@ export function DecodeArray() { const res = clickNode(node.id); if (res.ok && res.finished) { setWarned(false); + sfx("decodeSuccess"); if (res.failReason) { const ids = res.failReason.split(",").filter(Boolean); if (ids.length) { + sfx("fragmentUnlock"); toast({ title: "✦ 记忆碎片浮现", description: "新的回响被拼入图谱,前往「记忆图谱」查看。", @@ -51,9 +54,11 @@ export function DecodeArray() { return; } if (!res.ok) { + sfx("decodeFail"); setFlash({ id: node.id, ok: false }); setTimeout(() => setFlash(null), 280); } else { + sfx("decodeClick", { color: node.color }); setFlash({ id: node.id, ok: true }); setTimeout(() => setFlash(null), 280); // 点击后若不可解,提示玩家撤销 diff --git a/src/components/game/ExpeditionPanel.tsx b/src/components/game/ExpeditionPanel.tsx index e85a79139..e57c85e36 100644 --- a/src/components/game/ExpeditionPanel.tsx +++ b/src/components/game/ExpeditionPanel.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useGameStore } from "@/store/gameStore"; import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition"; import { formatNum } from "@/lib/game/config"; import { Button } from "@/components/ui/button"; @@ -58,6 +59,7 @@ export function ExpeditionPanel() { if (!res.ok) { toast({ title: "无法出发", description: res.reason, variant: "destructive" }); } else { + sfx("expeditionStart"); toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" }); setLastLog(null); } @@ -67,6 +69,12 @@ export function ExpeditionPanel() { const result = resolveCurrentNode(); if (!result) return; setLastLog(result.log); + if (result.ended) { + if (result.endReason === "victory") sfx("expeditionVictory"); + else sfx("expeditionDefeat"); + } else { + sfx("expeditionNode"); + } toast({ title: result.ended ? result.endReason === "victory" ? "✦ 探险胜利!" : "探险失败" diff --git a/src/components/game/PrestigeDialog.tsx b/src/components/game/PrestigeDialog.tsx index 0455ccfbe..2d2857201 100644 --- a/src/components/game/PrestigeDialog.tsx +++ b/src/components/game/PrestigeDialog.tsx @@ -15,6 +15,7 @@ import { PRESTIGE } from "@/lib/game/config"; import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; export function PrestigeDialog({ open, @@ -106,6 +107,7 @@ export function PrestigeDialog({ onOpenChange(false); setConfirming(false); if (res) { + sfx("prestige"); toast({ title: "✦ 飞升成功", description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。`, diff --git a/src/components/game/SettingsDialog.tsx b/src/components/game/SettingsDialog.tsx index 52fced0fb..5e6d95f39 100644 --- a/src/components/game/SettingsDialog.tsx +++ b/src/components/game/SettingsDialog.tsx @@ -12,9 +12,10 @@ import { import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { Trash2, Github, AlertTriangle } from "lucide-react"; +import { Trash2, Github, AlertTriangle, Volume2 } from "lucide-react"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; +import { sfx } from "@/hooks/useAudio"; export function SettingsDialog({ open, @@ -45,10 +46,21 @@ export function SettingsDialog({

- 解码成功、脉冲等音效(v0.3 实装) + 脉冲、解码、探险、飞升等程序化音效

- +
+ + +
{/* 存档信息 */} diff --git a/src/components/game/TechTree.tsx b/src/components/game/TechTree.tsx index e12063b65..3316413db 100644 --- a/src/components/game/TechTree.tsx +++ b/src/components/game/TechTree.tsx @@ -2,6 +2,7 @@ // 回响星核 / Echo Nexus — 技术树 import { useGameStore } from "@/store/gameStore"; import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config"; +import { sfx } from "@/hooks/useAudio"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -103,7 +104,9 @@ export function TechTree() { size="sm" variant={affordable ? "default" : "ghost"} disabled={!affordable} - onClick={() => buyTech(node.id)} + onClick={() => { + if (buyTech(node.id)) sfx("techBuy"); + }} className="h-7 px-2 text-[11px] shrink-0" style={ affordable diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts new file mode 100644 index 000000000..69b63ce55 --- /dev/null +++ b/src/hooks/useAudio.ts @@ -0,0 +1,21 @@ +"use client"; +// 回响星核 / Echo Nexus — 音频 React 适配 +import { useEffect } from "react"; +import { getAudio } from "@/lib/game/audio"; +import { useGameStore } from "@/store/gameStore"; + +/** 同步全局音频开关到音频引擎 */ +export function useAudioSync() { + const soundOn = useGameStore((s) => s.soundOn); + useEffect(() => { + getAudio().setEnabled(soundOn); + }, [soundOn]); +} + +/** 便捷播放函数(组件内调用) */ +export function sfx( + name: Parameters["play"]>[0], + opts?: { color?: string; combo?: number } +) { + getAudio().play(name, opts); +} diff --git a/src/hooks/useGameLoop.ts b/src/hooks/useGameLoop.ts index 94adf84e4..8fc185845 100644 --- a/src/hooks/useGameLoop.ts +++ b/src/hooks/useGameLoop.ts @@ -3,10 +3,11 @@ import { useEffect, useRef } from "react"; import { useGameStore } from "@/store/gameStore"; -/** 100ms tick:产能 + 自动产晶体 + 自动解码 */ +/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 成就检测 */ export function useGameLoop() { const tick = useGameStore((s) => s.tick); const autoDecodeTick = useGameStore((s) => s.autoDecodeTick); + const checkAchievements = useGameStore((s) => s.checkAchievements); const init = useGameStore((s) => s.init); const inited = useRef(false); @@ -15,22 +16,30 @@ export function useGameLoop() { init(); inited.current = true; } + let achCounter = 0; const id = setInterval(() => { const now = Date.now(); tick(now); autoDecodeTick(); + // 成就检测每秒执行一次(降低开销) + achCounter++; + if (achCounter % 4 === 0) { + checkAchievements(); + } }, 250); return () => clearInterval(id); - }, [tick, autoDecodeTick, init]); + }, [tick, autoDecodeTick, checkAchievements, init]); - // 页面可见性:切回时补 tick + // 页面可见性:切回时补 tick + 立即检测成就 useEffect(() => { const onVis = () => { if (document.visibilityState === "visible") { tick(Date.now()); + checkAchievements(); } }; document.addEventListener("visibilitychange", onVis); return () => document.removeEventListener("visibilitychange", onVis); - }, [tick]); + }, [tick, checkAchievements]); } + diff --git a/src/lib/game/achievements.ts b/src/lib/game/achievements.ts new file mode 100644 index 000000000..9b9954e2d --- /dev/null +++ b/src/lib/game/achievements.ts @@ -0,0 +1,185 @@ +// 回响星核 / Echo Nexus — 成就系统(数据驱动) +import type { GameState } from "./types"; + +/** 成就奖励类型 */ +export interface AchievementReward { + crystals?: number; + insights?: number; + contact?: number; + /** 永久产能加成(百分比,叠加到所有周目) */ + crystalsPerSecPct?: number; + insightPct?: number; +} + +export interface Achievement { + id: string; + name: string; + desc: string; + /** 图标 emoji(轻量,无需图标库) */ + icon: string; + /** 颜色(用于徽章) */ + color: string; + /** 判定函数:返回 true 表示达成 */ + check: (s: GameState) => boolean; + reward: AchievementReward; + /** 奖励文本(展示用) */ + rewardText: string; +} + +export const ACHIEVEMENTS: Achievement[] = [ + { + id: "ach_first_pulse", + name: "初次触碰", + desc: "发起第一次脉冲扫描", + icon: "✦", + color: "#34d399", + check: (s) => s.totalDecoded >= 0 || s.crystals > 0, + reward: { crystals: 5 }, + rewardText: "+5 晶体", + }, + { + id: "ach_first_decode", + name: "谐振初鸣", + desc: "成功解码第一颗记忆晶体", + icon: "◈", + color: "#fb7185", + check: (s) => s.totalDecoded >= 1, + reward: { insights: 3 }, + rewardText: "+3 洞见", + }, + { + id: "ach_decoded_10", + name: "回响解码者", + desc: "累计解码 10 颗晶体", + icon: "❖", + color: "#fbbf24", + check: (s) => s.totalDecoded >= 10, + reward: { insights: 15, crystalsPerSecPct: 5 }, + rewardText: "+15 洞见 · 产能 +5%", + }, + { + id: "ach_decoded_25", + name: "记忆织匠", + desc: "累计解码 25 颗晶体", + icon: "✺", + color: "#e879f9", + check: (s) => s.totalDecoded >= 25, + reward: { insights: 40, crystalsPerSecPct: 8 }, + rewardText: "+40 洞见 · 产能 +8%", + }, + { + id: "ach_decoded_50", + name: "星核解密师", + desc: "累计解码 50 颗晶体", + icon: "✷", + color: "#34d399", + check: (s) => s.totalDecoded >= 50, + reward: { insights: 100, crystalsPerSecPct: 12, insightPct: 10 }, + rewardText: "+100 洞见 · 产能 +12% · 洞见 +10%", + }, + { + id: "ach_tech_3", + name: "初窥门径", + desc: "解锁 3 项技术", + icon: "⚙", + color: "#fbbf24", + check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 3, + reward: { insights: 20 }, + rewardText: "+20 洞见", + }, + { + id: "ach_tech_all", + name: "全谱精通", + desc: "解锁全部 12 项技术", + icon: "⬡", + color: "#e879f9", + check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12, + reward: { crystalsPerSecPct: 15, insightPct: 15 }, + rewardText: "产能 +15% · 洞见 +15%", + }, + { + id: "ach_frag_4", + name: "残篇拾遗", + desc: "拼凑 4 段记忆碎片", + icon: "▤", + color: "#fb7185", + check: (s) => Object.values(s.fragments).filter(Boolean).length >= 4, + reward: { contact: 10, insights: 25 }, + rewardText: "+10 接触 · +25 洞见", + }, + { + id: "ach_frag_all", + name: "回响全谱", + desc: "拼凑全部首纪元记忆碎片", + icon: "▦", + color: "#34d399", + check: (s) => Object.values(s.fragments).filter(Boolean).length >= 8, + reward: { contact: 25, crystalsPerSecPct: 10 }, + rewardText: "+25 接触 · 产能 +10%", + }, + { + id: "ach_exp_1", + name: "初探遗迹", + desc: "完成第一次遗迹探险", + icon: "▲", + color: "#fbbf24", + check: (s) => s.totalExpeditions >= 1, + reward: { insights: 10 }, + rewardText: "+10 洞见", + }, + { + id: "ach_exp_5", + name: "遗迹猎手", + desc: "累计出发 5 次探险", + icon: "⬢", + color: "#fb7185", + check: (s) => s.totalExpeditions >= 5, + reward: { insights: 30, crystalsPerSecPct: 5 }, + rewardText: "+30 洞见 · 产能 +5%", + }, + { + id: "ach_prestige_1", + name: "初次接触", + desc: "完成第一次飞升", + icon: "✧", + color: "#e879f9", + check: (s) => s.ascensions >= 1, + reward: { crystalsPerSecPct: 10, insightPct: 10 }, + rewardText: "产能 +10% · 洞见 +10%", + }, + { + id: "ach_prestige_3", + name: "维度行者", + desc: "累计飞升 3 次", + icon: "✶", + color: "#34d399", + check: (s) => s.ascensions >= 3, + reward: { crystalsPerSecPct: 20, insightPct: 20 }, + rewardText: "产能 +20% · 洞见 +20%", + }, + { + id: "ach_warehouse", + name: "满仓时刻", + desc: "晶体储量达到仓库上限", + icon: "▣", + color: "#fbbf24", + check: (s) => s.crystals >= s.crystalCap, + reward: { insights: 8 }, + rewardText: "+8 洞见", + }, +]; + +/** 计算成就提供的永久加成(跨周目保留) */ +export function achievementBonuses(unlocked: Record): { + crystalsPerSecPct: number; + insightPct: number; +} { + let crystalsPerSecPct = 0; + let insightPct = 0; + for (const a of ACHIEVEMENTS) { + if (!unlocked[a.id]) continue; + crystalsPerSecPct += a.reward.crystalsPerSecPct ?? 0; + insightPct += a.reward.insightPct ?? 0; + } + return { crystalsPerSecPct, insightPct }; +} diff --git a/src/lib/game/audio.ts b/src/lib/game/audio.ts new file mode 100644 index 000000000..683e75749 --- /dev/null +++ b/src/lib/game/audio.ts @@ -0,0 +1,271 @@ +// 回响星核 / Echo Nexus — 程序化音频引擎(Web Audio API,零资源文件) +// +// 设计哲学:深空考古的氛围音效,全部由振荡器 + 噪声 + 包络合成, +// 无需任何外部音频文件。音色偏向"谐振/水晶/低频脉冲",契合全息晶体美学。 + +type SfxName = + | "pulse" // 主动脉冲扫描(短促上升音) + | "pulseCombo" // 连击脉冲(更高音 + 泛音) + | "decodeClick" // 解码点击节点(按颜色变调) + | "decodeFail" // 解码点击错误(低沉短音) + | "decodeSuccess" // 解码成功(和弦上扬) + | "fragmentUnlock" // 记忆碎片浮现(空灵长音) + | "techBuy" // 购买技术(确认音) + | "expeditionStart" // 探险出发(引擎启动) + | "expeditionNode" // 探险节点结算(中频) + | "expeditionVictory" // 探险胜利(凯旋和弦) + | "expeditionDefeat" // 探险失败(下行低音) + | "prestige" // 飞升(宏大扫频) + | "achievement" // 成就解锁(亮丽琶音) + | "uiHover" // 界面悬停(极轻) + | "uiClick"; // 界面点击(轻确认) + +const COLOR_FREQ: Record = { + emerald: 523.25, // C5 + rose: 587.33, // D5 + amber: 659.25, // E5 + fuchsia: 698.46, // F5 +}; + +class AudioEngine { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + private enabled = true; + private volume = 0.35; + + /** 首次用户交互后初始化(浏览器自动播放策略) */ + private ensure() { + if (typeof window === "undefined") return null; + if (!this.ctx) { + const AC = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + if (!AC) return null; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = this.volume; + this.master.connect(this.ctx.destination); + } + if (this.ctx.state === "suspended") { + void this.ctx.resume(); + } + return this.ctx; + } + + setEnabled(v: boolean) { + this.enabled = v; + } + + setVolume(v: number) { + this.volume = Math.max(0, Math.min(1, v)); + if (this.master) this.master.gain.value = this.volume; + } + + /** 简易正弦音 + ADSR 包络 */ + private tone( + freq: number, + dur: number, + type: OscillatorType = "sine", + gain = 0.3, + delay = 0, + detune = 0, + filterFreq?: number + ) { + const ctx = this.ensure(); + if (!ctx || !this.master) return; + const t0 = ctx.currentTime + delay; + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t0); + if (detune) osc.detune.setValueAtTime(detune, t0); + // ADSR + g.gain.setValueAtTime(0, t0); + g.gain.linearRampToValueAtTime(gain, t0 + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + let node: AudioNode = osc; + if (filterFreq) { + const f = ctx.createBiquadFilter(); + f.type = "lowpass"; + f.frequency.value = filterFreq; + osc.connect(f); + f.connect(g); + } else { + osc.connect(g); + } + g.connect(this.master); + osc.start(t0); + osc.stop(t0 + dur + 0.05); + void node; + } + + /** 频率扫描音(飞升/出发用) */ + private sweep( + fStart: number, + fEnd: number, + dur: number, + type: OscillatorType = "sine", + gain = 0.3, + delay = 0 + ) { + const ctx = this.ensure(); + if (!ctx || !this.master) return; + const t0 = ctx.currentTime + delay; + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(fStart, t0); + osc.frequency.exponentialRampToValueAtTime(Math.max(1, fEnd), t0 + dur); + g.gain.setValueAtTime(0, t0); + g.gain.linearRampToValueAtTime(gain, t0 + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); + g.connect(this.master); + osc.start(t0); + osc.stop(t0 + dur + 0.05); + } + + /** 噪声脉冲(解码失败/探险打击) */ + private noise(dur: number, gain = 0.2, delay = 0, filterFreq = 1200) { + const ctx = this.ensure(); + if (!ctx || !this.master) return; + const t0 = ctx.currentTime + delay; + const len = Math.floor(ctx.sampleRate * dur); + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < len; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / len); + } + const src = ctx.createBufferSource(); + src.buffer = buf; + const f = ctx.createBiquadFilter(); + f.type = "lowpass"; + f.frequency.value = filterFreq; + const g = ctx.createGain(); + g.gain.setValueAtTime(gain, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(f); + f.connect(g); + g.connect(this.master); + src.start(t0); + src.stop(t0 + dur + 0.02); + } + + play(name: SfxName, opts?: { color?: string; combo?: number }) { + if (!this.enabled) return; + const ctx = this.ensure(); + if (!ctx) return; + + switch (name) { + case "pulse": { + // 上升短音 + 轻泛音 + const base = 220 + (opts?.combo ? opts.combo * 18 : 0); + this.tone(base, 0.18, "sine", 0.28); + this.tone(base * 2, 0.14, "triangle", 0.1, 0.01); + break; + } + case "pulseCombo": { + const base = 440 + (opts?.combo ? opts.combo * 24 : 0); + this.tone(base, 0.2, "sine", 0.3); + this.tone(base * 1.5, 0.18, "triangle", 0.14, 0.01); + this.tone(base * 2, 0.16, "sine", 0.08, 0.02); + break; + } + case "decodeClick": { + const f = opts?.color ? COLOR_FREQ[opts.color] ?? 523 : 523; + this.tone(f, 0.16, "sine", 0.26); + this.tone(f * 2, 0.1, "triangle", 0.08, 0.005); + break; + } + case "decodeFail": { + this.tone(160, 0.16, "sawtooth", 0.18, 0, 0, 800); + this.noise(0.08, 0.1, 0, 600); + break; + } + case "decodeSuccess": { + // 上扬大三和弦琶音 + this.tone(523.25, 0.22, "sine", 0.24, 0); + this.tone(659.25, 0.22, "sine", 0.22, 0.08); + this.tone(783.99, 0.3, "sine", 0.24, 0.16); + this.tone(1046.5, 0.34, "triangle", 0.12, 0.2); + break; + } + case "fragmentUnlock": { + // 空灵长音 + this.tone(880, 0.7, "sine", 0.18, 0); + this.tone(1108.73, 0.7, "sine", 0.12, 0.04); + this.tone(1318.51, 0.8, "triangle", 0.08, 0.1); + this.sweep(440, 880, 0.6, "sine", 0.1, 0.05); + break; + } + case "techBuy": { + this.tone(587.33, 0.12, "sine", 0.22); + this.tone(880, 0.16, "triangle", 0.16, 0.06); + break; + } + case "expeditionStart": { + // 引擎启动:低频上升 + this.sweep(80, 240, 0.5, "sawtooth", 0.16); + this.sweep(120, 360, 0.5, "square", 0.06, 0.02); + this.noise(0.4, 0.08, 0, 400); + break; + } + case "expeditionNode": { + this.tone(440, 0.14, "triangle", 0.2); + this.tone(660, 0.12, "sine", 0.1, 0.04); + break; + } + case "expeditionVictory": { + // 凯旋上行 + this.tone(523.25, 0.18, "sine", 0.24, 0); + this.tone(659.25, 0.18, "sine", 0.24, 0.1); + this.tone(783.99, 0.18, "sine", 0.24, 0.2); + this.tone(1046.5, 0.4, "triangle", 0.2, 0.3); + break; + } + case "expeditionDefeat": { + // 下行低音 + this.tone(330, 0.3, "sawtooth", 0.2, 0, 0, 700); + this.tone(220, 0.4, "sine", 0.18, 0.12); + this.tone(146.83, 0.5, "sine", 0.16, 0.24); + break; + } + case "prestige": { + // 宏大扫频 + 和弦 + this.sweep(110, 880, 1.2, "sine", 0.2); + this.sweep(220, 1760, 1.2, "triangle", 0.1, 0.05); + this.tone(523.25, 0.6, "sine", 0.16, 0.3); + this.tone(659.25, 0.6, "sine", 0.16, 0.42); + this.tone(783.99, 0.8, "sine", 0.16, 0.54); + this.tone(1046.5, 1.0, "triangle", 0.12, 0.66); + break; + } + case "achievement": { + // 亮丽琶音 + this.tone(659.25, 0.16, "sine", 0.22, 0); + this.tone(880, 0.16, "sine", 0.22, 0.08); + this.tone(1046.5, 0.16, "sine", 0.22, 0.16); + this.tone(1318.51, 0.4, "triangle", 0.18, 0.24); + break; + } + case "uiHover": { + this.tone(880, 0.05, "sine", 0.05); + break; + } + case "uiClick": { + this.tone(660, 0.07, "sine", 0.1); + break; + } + } + } +} + +/** 全局单例(客户端) */ +let _engine: AudioEngine | null = null; +export function getAudio(): AudioEngine { + if (!_engine) _engine = new AudioEngine(); + return _engine; +} + +export type { SfxName }; diff --git a/src/lib/game/config.ts b/src/lib/game/config.ts index 0ce2140e9..9a126066d 100644 --- a/src/lib/game/config.ts +++ b/src/lib/game/config.ts @@ -29,6 +29,7 @@ export const INITIAL_STATE = { energyMax: 5, lastEnergyTick: Date.now(), totalExpeditions: 0, + achievements: {}, theme: "dark" as const, soundOn: true, }; diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index 2513a09ea..f6b5e261b 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -7,8 +7,9 @@ import { CRYSTAL_VALUE, CONTACT, } from "./config"; +import { achievementBonuses } from "./achievements"; -/** 由技术树 + 飞升蓝图聚合计算产能字段 */ +/** 由技术树 + 飞升蓝图 + 成就聚合计算产能字段 */ export function recomputeStats(state: Partial): { crystalsPerSec: number; crystalCap: number; @@ -21,6 +22,7 @@ export function recomputeStats(state: Partial): { } { const tech = state.tech ?? {}; const bp = state.blueprints?.length ?? 0; + const ach = achievementBonuses(state.achievements ?? {}); let crystalsPerSec = INITIAL_STATE.crystalsPerSec; let crystalCap = INITIAL_STATE.crystalCap; @@ -68,6 +70,10 @@ export function recomputeStats(state: Partial): { insightMult *= 1 + bp * PRESTIGE.perBlueprint.insightMult; contactRateMult *= 1 + bp * PRESTIGE.perBlueprint.contactRateMult; + // 成就永久加成(跨周目) + crystalsPerSec *= 1 + ach.crystalsPerSecPct / 100; + insightMult *= 1 + ach.insightPct / 100; + return { crystalsPerSec, crystalCap, @@ -96,7 +102,7 @@ export function computeNewBlueprints(state: GameState): number { return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length); } -/** 执行飞升:重置数值,保留蓝图与图谱与部分技术 */ +/** 执行飞升:重置数值,保留蓝图与图谱与成就与部分技术 */ export function performPrestige(state: GameState): GameState { const newBp = computeNewBlueprints(state); const blueprints = [ @@ -104,16 +110,17 @@ export function performPrestige(state: GameState): GameState { ...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`), ].slice(0, PRESTIGE.maxBlueprints); - // 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound, expeditionLog + // 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, theme/sound, expeditionLog // 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy const fresh = createInitialState(); - const stats = recomputeStats({ tech: {}, blueprints }); + const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements }); return { ...fresh, fragments: state.fragments, totalDecoded: state.totalDecoded, ascensions: state.ascensions + 1, blueprints, + achievements: state.achievements, theme: state.theme, soundOn: state.soundOn, createdAt: state.createdAt, @@ -131,6 +138,7 @@ export function createInitialState(): GameState { ...INITIAL_STATE, tech: {}, fragments: {}, + achievements: {}, pendingCrystals: [], activePuzzle: null, activeExpedition: null, diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index fd8fd6efa..38284bf38 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -125,6 +125,9 @@ export interface GameState { lastEnergyTick: number; totalExpeditions: number; + // 成就 + achievements: Record; // achievementId -> unlocked + // 元 lastTick: number; createdAt: number; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 6fd98209c..1ddd08e28 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -41,6 +41,7 @@ import { computeEnergyRegen, EXPEDITION_CONFIG, } from "@/lib/game/expedition"; +import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements"; interface GameActions { // 生命周期 @@ -73,6 +74,10 @@ interface GameActions { // 飞升 doPrestige: () => { newBp: number } | null; + // 成就 + checkAchievements: () => Achievement[]; + consumeAchievementQueue: () => Achievement[]; + // 设置 toggleTheme: () => void; toggleSound: () => void; @@ -86,6 +91,7 @@ type Store = GameState & GameActions & { _lastSpawn: number; _combo: number; _lastPulse: number; + _achievementQueue: Achievement[]; }; /** 计算并写回产能字段 */ @@ -122,6 +128,7 @@ export const useGameStore = create()( _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, + _achievementQueue: [], init: () => { const s = get(); @@ -141,6 +148,8 @@ export const useGameStore = create()( pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), }); } + // 兼容旧存档:补全 achievements 字段 + const achievements = s.achievements ?? {}; // 首次进入:补发离线收益 const elapsed = Math.max(0, (now - s.lastTick) / 1000); if (elapsed > 5) { @@ -151,20 +160,21 @@ export const useGameStore = create()( crystals: Math.min(s.crystalCap, s.crystals + gain), lastTick: now, activePuzzle, - ...syncStats({ tech: s.tech, blueprints: s.blueprints }), + achievements, + ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements }), }); } else { - set({ lastTick: now, activePuzzle, ...syncStats({ tech: s.tech, blueprints: s.blueprints }) }); + set({ lastTick: now, activePuzzle, achievements, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements }) }); } }, loadOnline: () => { const s = get(); - set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints }) }); + set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements }) }); }, hardReset: () => { - set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0 }); + set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [] }); }, tick: (now) => { @@ -466,7 +476,7 @@ export const useGameStore = create()( const next = performPrestige(s); set({ ...next, - ...syncStats({ tech: next.tech, blueprints: next.blueprints }), + ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements }), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, @@ -475,6 +485,48 @@ export const useGameStore = create()( return { newBp }; }, + checkAchievements: () => { + const s = get(); + const newlyUnlocked: Achievement[] = []; + const updated = { ...s.achievements }; + let crystals = s.crystals; + let insights = s.insights; + let contact = s.contact; + let statsDirty = false; + for (const a of ACHIEVEMENTS) { + if (updated[a.id]) continue; + if (a.check(s)) { + updated[a.id] = true; + newlyUnlocked.push(a); + // 发放即时奖励 + if (a.reward.crystals) crystals += a.reward.crystals; + if (a.reward.insights) insights += a.reward.insights; + if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact); + if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true; + } + } + if (newlyUnlocked.length === 0) return []; + set({ + achievements: updated, + crystals, + insights, + contact, + ...(statsDirty + ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated }) + : {}), + _achievementQueue: [...s._achievementQueue, ...newlyUnlocked], + }); + return newlyUnlocked; + }, + + consumeAchievementQueue: () => { + const s = get(); + if (s._achievementQueue.length === 0) return []; + const items = s._achievementQueue; + set({ _achievementQueue: [] }); + return items; + }, + canPrestige: () => get().contact >= CONTACT.prestigeMin, toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }), @@ -485,8 +537,8 @@ export const useGameStore = create()( storage: createJSONStorage(() => localStorage), // 不持久化临时字段 partialize: (s) => { - const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, ...rest } = s; - void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; + const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, ...rest } = s; + void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; return rest as GameState; }, } diff --git a/worklog.md b/worklog.md index 50e4eed5c..d7dc453a9 100644 --- a/worklog.md +++ b/worklog.md @@ -39,15 +39,28 @@ - **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50),HP clamp验证 - 详见 docs/repo/docs/05-遗迹探险系统-v0.2.md +### v0.2.1 程序化音频 + 成就系统 + 视觉打磨(本轮完成) +- **QA 结论**:v0.2 全系统稳定,解码/探险/技术树/通知均正常,无 bug,故推进新功能 +- **程序化音频系统**(`src/lib/game/audio.ts`):Web Audio API 零资源文件,15 种音效(脉冲/解码按色变调/探险/飞升/成就/界面),受 soundOn 开关控制,首次交互后初始化 AudioContext +- **成就系统**(`src/lib/game/achievements.ts`):14 项成就,跨周目永久产能/洞见加成,Toast+音效通知,每秒由主循环检测 + - `GameState.achievements` 新字段;`recomputeStats` 聚合三层加成(技术+蓝图+成就) + - 旧存档兼容:init() 补全 achievements={} + - UI:5 列标签栏新增「成就」页 + AchievementsPanel + AchievementNotifier +- **视觉打磨**:脉冲粒子爆发(6-12粒子径向发散) + 扩散环 + 浮动数字按连击变色(绿→紫→琥) +- **Gitea 工单**:处理 Issue #1「就一直连连看?」——回复说明 6 大玩法层 + 路线图 +- **验证**:agent-browser 全流程通过;成就 3/14 解锁;lint 零错误;HTTP 200 +- 详见 docs/repo/docs/06-音频与成就系统-v0.2.1.md + ### 进行中 -- [ ] 持续迭代:音频系统(v0.3)、socket 全局「星潮」事件(v0.3)、云存档+排行榜(v0.4)、全5纪元叙事(v0.5) +- [ ] 持续迭代:socket 全局「星潮」事件(v0.3 剩余)、云存档+排行榜(v0.4)、全5纪元叙事(v0.5) ## 未解决问题或风险 / 下一阶段优先事项 -- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术。后续可微调平衡。 +- Issue #1 玩家反馈「玩法单一」,后续可考虑:更多探险事件变体、限时星潮事件、成就奖励多样化 +- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术 - 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度 - 需持续关注 Gitea 工单(仓库 Issues)获取额外需求 -- 下一阶段优先:音频系统(解码/脉冲/探险音效)、socket 全局「星潮」事件、云存档 +- 下一阶段优先:socket 全局「星潮」实时事件、云存档+排行榜 ## 定时任务 - 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)