3d8566bc-063f-41e8-8934-68c9ac5b8211

This commit is contained in:
2026-06-23 13:38:23 +00:00
parent 674775be0e
commit da18f32df2
36 changed files with 849 additions and 34 deletions
Submodule docs/repo updated: 84749c218a...51714d2532
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+30 -7
View File
@@ -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() {
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.1
ECHO NEXUS · v0.2.1
</p>
</div>
</div>
@@ -147,11 +155,11 @@ export default function Page() {
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 图谱 / 统计 */}
{/* 标签面板:探险 / 技术 / 图谱 / 成就 / 统计 */}
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-4 h-8 bg-black/30">
<TabsTrigger value="expedition" className="text-xs gap-1 relative">
<TabsList className="grid grid-cols-5 h-8 bg-black/30">
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-1">
<Rocket className="h-3 w-3" />
{energy >= 1 && !hasActiveExpedition && (
@@ -161,15 +169,22 @@ export default function Page() {
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="tech" className="text-xs gap-1">
<TabsTrigger value="tech" className="text-[11px] gap-0.5 px-1">
<Cpu className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="codex" className="text-xs gap-1">
<TabsTrigger value="codex" className="text-[11px] gap-0.5 px-1">
<BookOpen className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="stats" className="text-xs gap-1">
<TabsTrigger value="ach" className="text-[11px] gap-0.5 relative px-1">
<Trophy className="h-3 w-3" />
{ownedAchCount < ACHIEVEMENTS.length && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="stats" className="text-[11px] gap-0.5 px-1">
<BarChart3 className="h-3 w-3" />
</TabsTrigger>
@@ -183,6 +198,9 @@ export default function Page() {
<TabsContent value="codex" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<Codex />
</TabsContent>
<TabsContent value="ach" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AchievementsPanel />
</TabsContent>
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
<StatsPanel />
</TabsContent>
@@ -215,22 +233,27 @@ export default function Page() {
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<AchievementNotifier />
</div>
);
}
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 (
<div className="grid grid-cols-2 gap-1.5 text-xs">
@@ -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;
}
+107
View File
@@ -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 (
<div className="flex flex-col gap-2.5 h-full">
{/* 顶部:进度 + 加成总览 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-amber-400" />
</h3>
<span className="text-[10px] text-muted-foreground font-mono">
{unlockedCount} / {ACHIEVEMENTS.length}
</span>
</div>
{/* 永久加成条 */}
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-3 text-[10px]">
<span className="text-amber-300/80"></span>
{bonus.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span>
)}
{bonus.insightPct > 0 && (
<span className="text-fuchsia-300"> +{bonus.insightPct}%</span>
)}
{bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 成就网格 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1 max-h-[300px]">
{ACHIEVEMENTS.map((a) => {
const unlocked = !!achievements[a.id];
return (
<div
key={a.id}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
unlocked
? "border-white/15 bg-black/30"
: "border-white/5 bg-black/15 opacity-60"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 16px ${a.color}11` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
style={{ background: a.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标徽章 */}
<div
className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center text-base"
style={{
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? a.color : "#666",
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
}}
>
{unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
>
{a.name}
</div>
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
{a.desc}
</div>
{unlocked && (
<div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
{a.rewardText}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
`}</style>
</div>
);
}
+70 -4
View File
@@ -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<FloatNum[]>([]);
const [particles, setParticles] = useState<Particle[]>([]);
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() {
</linearGradient>
</defs>
</svg>
{/* 脉冲扩散环(点击时) */}
<div
key={`ring-${pulseAnim}`}
className="absolute inset-8 rounded-full border-2 border-emerald-400/60 pointer-events-none"
style={{ animation: "echo-ring 0.7s ease-out forwards" }}
/>
{/* 中央晶体 */}
<div
key={pulseAnim}
@@ -141,17 +177,33 @@ export function CrystalOrb() {
/>
</div>
</div>
{/* 粒子爆发 */}
{particles.map((p) => (
<span
key={p.id}
className="absolute left-1/2 top-1/2 rounded-full pointer-events-none"
style={{
width: 4,
height: 4,
background: p.color,
boxShadow: `0 0 6px ${p.color}`,
["--angle" as string]: `${p.angle}rad`,
animation: "echo-burst 0.7s ease-out forwards",
}}
/>
))}
{/* 浮动数字 */}
{floats.map((f) => (
<span
key={f.id}
className="absolute pointer-events-none font-mono font-bold text-emerald-300 text-sm"
className="absolute pointer-events-none font-mono font-bold text-sm"
style={{
left: f.x,
top: f.y,
color: f.color,
transform: "translate(-50%, -50%)",
animation: "echo-float 0.9s ease-out forwards",
textShadow: "0 0 8px rgba(52,211,153,0.8)",
textShadow: `0 0 8px ${f.color}`,
}}
>
{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;
}
}
`}</style>
</div>
);
+5
View File
@@ -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);
// 点击后若不可解,提示玩家撤销
+8
View File
@@ -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" ? "✦ 探险胜利!" : "探险失败"
+2
View File
@@ -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} 周目。`,
+15 -3
View File
@@ -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({
<div>
<Label className="text-sm"></Label>
<p className="text-[11px] text-muted-foreground mt-0.5">
v0.3
</p>
</div>
<Switch checked={soundOn} onCheckedChange={toggleSound} />
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => sfx("decodeSuccess")}
aria-label="试听音效"
>
<Volume2 className="h-3.5 w-3.5" />
</Button>
<Switch checked={soundOn} onCheckedChange={toggleSound} />
</div>
</div>
{/* 存档信息 */}
+4 -1
View File
@@ -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
+21
View File
@@ -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<ReturnType<typeof getAudio>["play"]>[0],
opts?: { color?: string; combo?: number }
) {
getAudio().play(name, opts);
}
+13 -4
View File
@@ -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]);
}
+185
View File
@@ -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<string, boolean>): {
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 };
}
+271
View File
@@ -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<string, number> = {
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 };
+1
View File
@@ -29,6 +29,7 @@ export const INITIAL_STATE = {
energyMax: 5,
lastEnergyTick: Date.now(),
totalExpeditions: 0,
achievements: {},
theme: "dark" as const,
soundOn: true,
};
+12 -4
View File
@@ -7,8 +7,9 @@ import {
CRYSTAL_VALUE,
CONTACT,
} from "./config";
import { achievementBonuses } from "./achievements";
/** 由技术树 + 飞升蓝图聚合计算产能字段 */
/** 由技术树 + 飞升蓝图 + 成就聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
crystalsPerSec: number;
crystalCap: number;
@@ -21,6 +22,7 @@ export function recomputeStats(state: Partial<GameState>): {
} {
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<GameState>): {
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,
+3
View File
@@ -125,6 +125,9 @@ export interface GameState {
lastEnergyTick: number;
totalExpeditions: number;
// 成就
achievements: Record<string, boolean>; // achievementId -> unlocked
// 元
lastTick: number;
createdAt: number;
+59 -7
View File
@@ -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<Store>()(
_lastSpawn: Date.now(),
_combo: 0,
_lastPulse: 0,
_achievementQueue: [],
init: () => {
const s = get();
@@ -141,6 +148,8 @@ export const useGameStore = create<Store>()(
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<Store>()(
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<Store>()(
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<Store>()(
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<Store>()(
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;
},
}
+16 -3
View File
@@ -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