3d8566bc-063f-41e8-8934-68c9ac5b8211
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 183 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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);
|
||||
// 点击后若不可解,提示玩家撤销
|
||||
|
||||
@@ -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" ? "✦ 探险胜利!" : "探险失败"
|
||||
|
||||
@@ -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} 周目。`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 存档信息 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -29,6 +29,7 @@ export const INITIAL_STATE = {
|
||||
energyMax: 5,
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
achievements: {},
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -125,6 +125,9 @@ export interface GameState {
|
||||
lastEnergyTick: number;
|
||||
totalExpeditions: number;
|
||||
|
||||
// 成就
|
||||
achievements: Record<string, boolean>; // achievementId -> unlocked
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||