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)