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 { PrestigeDialog } from "@/components/game/PrestigeDialog";
|
||||||
import { SettingsDialog } from "@/components/game/SettingsDialog";
|
import { SettingsDialog } from "@/components/game/SettingsDialog";
|
||||||
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
|
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
|
||||||
|
import { AchievementsPanel } from "@/components/game/AchievementsPanel";
|
||||||
|
import { AchievementNotifier } from "@/components/game/AchievementNotifier";
|
||||||
import { useGameLoop } from "@/hooks/useGameLoop";
|
import { useGameLoop } from "@/hooks/useGameLoop";
|
||||||
|
import { useAudioSync } from "@/hooks/useAudio";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,11 +26,14 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
Github,
|
Github,
|
||||||
|
Trophy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||||
|
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useGameLoop();
|
useGameLoop();
|
||||||
|
useAudioSync();
|
||||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -38,6 +44,7 @@ export default function Page() {
|
|||||||
const ascensions = useGameStore((s) => s.ascensions);
|
const ascensions = useGameStore((s) => s.ascensions);
|
||||||
const ownedTech = useGameStore((s) => s.tech);
|
const ownedTech = useGameStore((s) => s.tech);
|
||||||
const ownedFragments = useGameStore((s) => s.fragments);
|
const ownedFragments = useGameStore((s) => s.fragments);
|
||||||
|
const ownedAchievements = useGameStore((s) => s.achievements);
|
||||||
const crystals = useGameStore((s) => s.crystals);
|
const crystals = useGameStore((s) => s.crystals);
|
||||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||||
const energy = useGameStore((s) => s.energy);
|
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 ownedTechCount = Object.values(ownedTech).filter((v) => v > 0).length;
|
||||||
const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length;
|
const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length;
|
||||||
|
const ownedAchCount = Object.values(ownedAchievements).filter(Boolean).length;
|
||||||
const canPrestige = contact >= 100;
|
const canPrestige = contact >= 100;
|
||||||
|
|
||||||
// 仓库满仓警告
|
// 仓库满仓警告
|
||||||
@@ -100,7 +108,7 @@ export default function Page() {
|
|||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||||
<DecodePanel />
|
<DecodePanel />
|
||||||
</div>
|
</div>
|
||||||
{/* 标签面板:探险 / 技术 / 图谱 / 统计 */}
|
{/* 标签面板:探险 / 技术 / 图谱 / 成就 / 统计 */}
|
||||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
||||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : "tech"} className="h-full flex flex-col">
|
<Tabs defaultValue={hasActiveExpedition ? "expedition" : "tech"} className="h-full flex flex-col">
|
||||||
<TabsList className="grid grid-cols-4 h-8 bg-black/30">
|
<TabsList className="grid grid-cols-5 h-8 bg-black/30">
|
||||||
<TabsTrigger value="expedition" className="text-xs gap-1 relative">
|
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-1">
|
||||||
<Rocket className="h-3 w-3" />
|
<Rocket className="h-3 w-3" />
|
||||||
探险
|
探险
|
||||||
{energy >= 1 && !hasActiveExpedition && (
|
{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" />
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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" />
|
<Cpu className="h-3 w-3" />
|
||||||
技术
|
技术
|
||||||
</TabsTrigger>
|
</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" />
|
<BookOpen className="h-3 w-3" />
|
||||||
图谱
|
图谱
|
||||||
</TabsTrigger>
|
</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" />
|
<BarChart3 className="h-3 w-3" />
|
||||||
统计
|
统计
|
||||||
</TabsTrigger>
|
</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">
|
<TabsContent value="codex" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||||
<Codex />
|
<Codex />
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -215,22 +233,27 @@ export default function Page() {
|
|||||||
|
|
||||||
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
|
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
|
||||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||||
|
<AchievementNotifier />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsPanel() {
|
function StatsPanel() {
|
||||||
const s = useGameStore();
|
const s = useGameStore();
|
||||||
|
const achCount = Object.values(s.achievements).filter(Boolean).length;
|
||||||
const rows = [
|
const rows = [
|
||||||
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
||||||
{ label: "飞升周目", value: `${s.ascensions}` },
|
{ label: "飞升周目", value: `${s.ascensions}` },
|
||||||
{ label: "持有蓝图", value: `${s.blueprints.length} / 6` },
|
{ 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.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
|
||||||
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.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.contact.toFixed(1)}%` },
|
||||||
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
|
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
|
||||||
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
||||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||||
|
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||||
|
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
<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";
|
"use client";
|
||||||
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲
|
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲
|
||||||
import { useRef, useState, useCallback, useEffect } from "react";
|
import { useRef, useState, useCallback } from "react";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import { formatNum } from "@/lib/game/config";
|
import { formatNum } from "@/lib/game/config";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
|
|
||||||
interface FloatNum {
|
interface FloatNum {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -11,6 +12,15 @@ interface FloatNum {
|
|||||||
y: number;
|
y: number;
|
||||||
text: string;
|
text: string;
|
||||||
born: number;
|
born: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
id: number;
|
||||||
|
angle: number;
|
||||||
|
dist: number;
|
||||||
|
born: number;
|
||||||
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CrystalOrb() {
|
export function CrystalOrb() {
|
||||||
@@ -21,6 +31,7 @@ export function CrystalOrb() {
|
|||||||
const combo = useGameStore((s) => s._combo);
|
const combo = useGameStore((s) => s._combo);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [floats, setFloats] = useState<FloatNum[]>([]);
|
const [floats, setFloats] = useState<FloatNum[]>([]);
|
||||||
|
const [particles, setParticles] = useState<Particle[]>([]);
|
||||||
const [pulseAnim, setPulseAnim] = useState(0);
|
const [pulseAnim, setPulseAnim] = useState(0);
|
||||||
const idRef = useRef(0);
|
const idRef = useRef(0);
|
||||||
|
|
||||||
@@ -32,14 +43,33 @@ export function CrystalOrb() {
|
|||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
const id = idRef.current++;
|
const id = idRef.current++;
|
||||||
|
const floatColor = res.combo >= 5 ? "#fbbf24" : res.combo >= 3 ? "#e879f9" : "#34d399";
|
||||||
setFloats((f) => [
|
setFloats((f) => [
|
||||||
...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);
|
setPulseAnim((n) => n + 1);
|
||||||
|
// 音效
|
||||||
|
sfx(res.combo >= 3 ? "pulseCombo" : "pulse", { combo: res.combo });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setFloats((f) => f.filter((it) => it.id !== id));
|
setFloats((f) => f.filter((it) => it.id !== id));
|
||||||
}, 900);
|
}, 900);
|
||||||
|
setTimeout(() => {
|
||||||
|
setParticles((p) => p.filter((it) => !newParticles.includes(it)));
|
||||||
|
}, 700);
|
||||||
if (res.combo >= 5 && res.combo % 5 === 0) {
|
if (res.combo >= 5 && res.combo % 5 === 0) {
|
||||||
toast({
|
toast({
|
||||||
title: `×${res.combo} 连击!`,
|
title: `×${res.combo} 连击!`,
|
||||||
@@ -112,6 +142,12 @@ export function CrystalOrb() {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</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
|
<div
|
||||||
key={pulseAnim}
|
key={pulseAnim}
|
||||||
@@ -141,17 +177,33 @@ export function CrystalOrb() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{floats.map((f) => (
|
||||||
<span
|
<span
|
||||||
key={f.id}
|
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={{
|
style={{
|
||||||
left: f.x,
|
left: f.x,
|
||||||
top: f.y,
|
top: f.y,
|
||||||
|
color: f.color,
|
||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
animation: "echo-float 0.9s ease-out forwards",
|
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}
|
{f.text}
|
||||||
@@ -183,6 +235,20 @@ export function CrystalOrb() {
|
|||||||
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
100% { opacity: 0; transform: translate(-50%, -180%) scale(1.3); }
|
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>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
import { COLOR_VISUAL } from "@/lib/game/config";
|
import { COLOR_VISUAL } from "@/lib/game/config";
|
||||||
import { isSolvable, canStartFrom } from "@/lib/game/decode";
|
import { isSolvable, canStartFrom } from "@/lib/game/decode";
|
||||||
import type { DecodeNode, ResonanceColor } from "@/lib/game/types";
|
import type { DecodeNode, ResonanceColor } from "@/lib/game/types";
|
||||||
@@ -35,9 +36,11 @@ export function DecodeArray() {
|
|||||||
const res = clickNode(node.id);
|
const res = clickNode(node.id);
|
||||||
if (res.ok && res.finished) {
|
if (res.ok && res.finished) {
|
||||||
setWarned(false);
|
setWarned(false);
|
||||||
|
sfx("decodeSuccess");
|
||||||
if (res.failReason) {
|
if (res.failReason) {
|
||||||
const ids = res.failReason.split(",").filter(Boolean);
|
const ids = res.failReason.split(",").filter(Boolean);
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
|
sfx("fragmentUnlock");
|
||||||
toast({
|
toast({
|
||||||
title: "✦ 记忆碎片浮现",
|
title: "✦ 记忆碎片浮现",
|
||||||
description: "新的回响被拼入图谱,前往「记忆图谱」查看。",
|
description: "新的回响被拼入图谱,前往「记忆图谱」查看。",
|
||||||
@@ -51,9 +54,11 @@ export function DecodeArray() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
sfx("decodeFail");
|
||||||
setFlash({ id: node.id, ok: false });
|
setFlash({ id: node.id, ok: false });
|
||||||
setTimeout(() => setFlash(null), 280);
|
setTimeout(() => setFlash(null), 280);
|
||||||
} else {
|
} else {
|
||||||
|
sfx("decodeClick", { color: node.color });
|
||||||
setFlash({ id: node.id, ok: true });
|
setFlash({ id: node.id, ok: true });
|
||||||
setTimeout(() => setFlash(null), 280);
|
setTimeout(() => setFlash(null), 280);
|
||||||
// 点击后若不可解,提示玩家撤销
|
// 点击后若不可解,提示玩家撤销
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition";
|
import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition";
|
||||||
import { formatNum } from "@/lib/game/config";
|
import { formatNum } from "@/lib/game/config";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -58,6 +59,7 @@ export function ExpeditionPanel() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast({ title: "无法出发", description: res.reason, variant: "destructive" });
|
toast({ title: "无法出发", description: res.reason, variant: "destructive" });
|
||||||
} else {
|
} else {
|
||||||
|
sfx("expeditionStart");
|
||||||
toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" });
|
toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" });
|
||||||
setLastLog(null);
|
setLastLog(null);
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,12 @@ export function ExpeditionPanel() {
|
|||||||
const result = resolveCurrentNode();
|
const result = resolveCurrentNode();
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
setLastLog(result.log);
|
setLastLog(result.log);
|
||||||
|
if (result.ended) {
|
||||||
|
if (result.endReason === "victory") sfx("expeditionVictory");
|
||||||
|
else sfx("expeditionDefeat");
|
||||||
|
} else {
|
||||||
|
sfx("expeditionNode");
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: result.ended
|
title: result.ended
|
||||||
? result.endReason === "victory" ? "✦ 探险胜利!" : "探险失败"
|
? result.endReason === "victory" ? "✦ 探险胜利!" : "探险失败"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PRESTIGE } from "@/lib/game/config";
|
|||||||
import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine";
|
import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
|
|
||||||
export function PrestigeDialog({
|
export function PrestigeDialog({
|
||||||
open,
|
open,
|
||||||
@@ -106,6 +107,7 @@ export function PrestigeDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
if (res) {
|
if (res) {
|
||||||
|
sfx("prestige");
|
||||||
toast({
|
toast({
|
||||||
title: "✦ 飞升成功",
|
title: "✦ 飞升成功",
|
||||||
description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。`,
|
description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。`,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
|
|
||||||
export function SettingsDialog({
|
export function SettingsDialog({
|
||||||
open,
|
open,
|
||||||
@@ -45,11 +46,22 @@ export function SettingsDialog({
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-sm">音效反馈</Label>
|
<Label className="text-sm">音效反馈</Label>
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
解码成功、脉冲等音效(v0.3 实装)
|
脉冲、解码、探险、飞升等程序化音效
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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} />
|
<Switch checked={soundOn} onCheckedChange={toggleSound} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 存档信息 */}
|
{/* 存档信息 */}
|
||||||
<div className="rounded-lg border border-white/10 bg-black/30 p-3 text-xs space-y-1.5">
|
<div className="rounded-lg border border-white/10 bg-black/30 p-3 text-xs space-y-1.5">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 回响星核 / Echo Nexus — 技术树
|
// 回响星核 / Echo Nexus — 技术树
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
|
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
|
||||||
|
import { sfx } from "@/hooks/useAudio";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -103,7 +104,9 @@ export function TechTree() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={affordable ? "default" : "ghost"}
|
variant={affordable ? "default" : "ghost"}
|
||||||
disabled={!affordable}
|
disabled={!affordable}
|
||||||
onClick={() => buyTech(node.id)}
|
onClick={() => {
|
||||||
|
if (buyTech(node.id)) sfx("techBuy");
|
||||||
|
}}
|
||||||
className="h-7 px-2 text-[11px] shrink-0"
|
className="h-7 px-2 text-[11px] shrink-0"
|
||||||
style={
|
style={
|
||||||
affordable
|
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 { useEffect, useRef } from "react";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
|
|
||||||
/** 100ms tick:产能 + 自动产晶体 + 自动解码 */
|
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 成就检测 */
|
||||||
export function useGameLoop() {
|
export function useGameLoop() {
|
||||||
const tick = useGameStore((s) => s.tick);
|
const tick = useGameStore((s) => s.tick);
|
||||||
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
|
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
|
||||||
|
const checkAchievements = useGameStore((s) => s.checkAchievements);
|
||||||
const init = useGameStore((s) => s.init);
|
const init = useGameStore((s) => s.init);
|
||||||
const inited = useRef(false);
|
const inited = useRef(false);
|
||||||
|
|
||||||
@@ -15,22 +16,30 @@ export function useGameLoop() {
|
|||||||
init();
|
init();
|
||||||
inited.current = true;
|
inited.current = true;
|
||||||
}
|
}
|
||||||
|
let achCounter = 0;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
tick(now);
|
tick(now);
|
||||||
autoDecodeTick();
|
autoDecodeTick();
|
||||||
|
// 成就检测每秒执行一次(降低开销)
|
||||||
|
achCounter++;
|
||||||
|
if (achCounter % 4 === 0) {
|
||||||
|
checkAchievements();
|
||||||
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [tick, autoDecodeTick, init]);
|
}, [tick, autoDecodeTick, checkAchievements, init]);
|
||||||
|
|
||||||
// 页面可见性:切回时补 tick
|
// 页面可见性:切回时补 tick + 立即检测成就
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
tick(Date.now());
|
tick(Date.now());
|
||||||
|
checkAchievements();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", onVis);
|
document.addEventListener("visibilitychange", onVis);
|
||||||
return () => document.removeEventListener("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,
|
energyMax: 5,
|
||||||
lastEnergyTick: Date.now(),
|
lastEnergyTick: Date.now(),
|
||||||
totalExpeditions: 0,
|
totalExpeditions: 0,
|
||||||
|
achievements: {},
|
||||||
theme: "dark" as const,
|
theme: "dark" as const,
|
||||||
soundOn: true,
|
soundOn: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
CRYSTAL_VALUE,
|
CRYSTAL_VALUE,
|
||||||
CONTACT,
|
CONTACT,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
|
import { achievementBonuses } from "./achievements";
|
||||||
|
|
||||||
/** 由技术树 + 飞升蓝图聚合计算产能字段 */
|
/** 由技术树 + 飞升蓝图 + 成就聚合计算产能字段 */
|
||||||
export function recomputeStats(state: Partial<GameState>): {
|
export function recomputeStats(state: Partial<GameState>): {
|
||||||
crystalsPerSec: number;
|
crystalsPerSec: number;
|
||||||
crystalCap: number;
|
crystalCap: number;
|
||||||
@@ -21,6 +22,7 @@ export function recomputeStats(state: Partial<GameState>): {
|
|||||||
} {
|
} {
|
||||||
const tech = state.tech ?? {};
|
const tech = state.tech ?? {};
|
||||||
const bp = state.blueprints?.length ?? 0;
|
const bp = state.blueprints?.length ?? 0;
|
||||||
|
const ach = achievementBonuses(state.achievements ?? {});
|
||||||
|
|
||||||
let crystalsPerSec = INITIAL_STATE.crystalsPerSec;
|
let crystalsPerSec = INITIAL_STATE.crystalsPerSec;
|
||||||
let crystalCap = INITIAL_STATE.crystalCap;
|
let crystalCap = INITIAL_STATE.crystalCap;
|
||||||
@@ -68,6 +70,10 @@ export function recomputeStats(state: Partial<GameState>): {
|
|||||||
insightMult *= 1 + bp * PRESTIGE.perBlueprint.insightMult;
|
insightMult *= 1 + bp * PRESTIGE.perBlueprint.insightMult;
|
||||||
contactRateMult *= 1 + bp * PRESTIGE.perBlueprint.contactRateMult;
|
contactRateMult *= 1 + bp * PRESTIGE.perBlueprint.contactRateMult;
|
||||||
|
|
||||||
|
// 成就永久加成(跨周目)
|
||||||
|
crystalsPerSec *= 1 + ach.crystalsPerSecPct / 100;
|
||||||
|
insightMult *= 1 + ach.insightPct / 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
crystalsPerSec,
|
crystalsPerSec,
|
||||||
crystalCap,
|
crystalCap,
|
||||||
@@ -96,7 +102,7 @@ export function computeNewBlueprints(state: GameState): number {
|
|||||||
return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length);
|
return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行飞升:重置数值,保留蓝图与图谱与部分技术 */
|
/** 执行飞升:重置数值,保留蓝图与图谱与成就与部分技术 */
|
||||||
export function performPrestige(state: GameState): GameState {
|
export function performPrestige(state: GameState): GameState {
|
||||||
const newBp = computeNewBlueprints(state);
|
const newBp = computeNewBlueprints(state);
|
||||||
const blueprints = [
|
const blueprints = [
|
||||||
@@ -104,16 +110,17 @@ export function performPrestige(state: GameState): GameState {
|
|||||||
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
|
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
|
||||||
].slice(0, PRESTIGE.maxBlueprints);
|
].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
|
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy
|
||||||
const fresh = createInitialState();
|
const fresh = createInitialState();
|
||||||
const stats = recomputeStats({ tech: {}, blueprints });
|
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements });
|
||||||
return {
|
return {
|
||||||
...fresh,
|
...fresh,
|
||||||
fragments: state.fragments,
|
fragments: state.fragments,
|
||||||
totalDecoded: state.totalDecoded,
|
totalDecoded: state.totalDecoded,
|
||||||
ascensions: state.ascensions + 1,
|
ascensions: state.ascensions + 1,
|
||||||
blueprints,
|
blueprints,
|
||||||
|
achievements: state.achievements,
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
soundOn: state.soundOn,
|
soundOn: state.soundOn,
|
||||||
createdAt: state.createdAt,
|
createdAt: state.createdAt,
|
||||||
@@ -131,6 +138,7 @@ export function createInitialState(): GameState {
|
|||||||
...INITIAL_STATE,
|
...INITIAL_STATE,
|
||||||
tech: {},
|
tech: {},
|
||||||
fragments: {},
|
fragments: {},
|
||||||
|
achievements: {},
|
||||||
pendingCrystals: [],
|
pendingCrystals: [],
|
||||||
activePuzzle: null,
|
activePuzzle: null,
|
||||||
activeExpedition: null,
|
activeExpedition: null,
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export interface GameState {
|
|||||||
lastEnergyTick: number;
|
lastEnergyTick: number;
|
||||||
totalExpeditions: number;
|
totalExpeditions: number;
|
||||||
|
|
||||||
|
// 成就
|
||||||
|
achievements: Record<string, boolean>; // achievementId -> unlocked
|
||||||
|
|
||||||
// 元
|
// 元
|
||||||
lastTick: number;
|
lastTick: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
computeEnergyRegen,
|
computeEnergyRegen,
|
||||||
EXPEDITION_CONFIG,
|
EXPEDITION_CONFIG,
|
||||||
} from "@/lib/game/expedition";
|
} from "@/lib/game/expedition";
|
||||||
|
import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||||||
|
|
||||||
interface GameActions {
|
interface GameActions {
|
||||||
// 生命周期
|
// 生命周期
|
||||||
@@ -73,6 +74,10 @@ interface GameActions {
|
|||||||
// 飞升
|
// 飞升
|
||||||
doPrestige: () => { newBp: number } | null;
|
doPrestige: () => { newBp: number } | null;
|
||||||
|
|
||||||
|
// 成就
|
||||||
|
checkAchievements: () => Achievement[];
|
||||||
|
consumeAchievementQueue: () => Achievement[];
|
||||||
|
|
||||||
// 设置
|
// 设置
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
toggleSound: () => void;
|
toggleSound: () => void;
|
||||||
@@ -86,6 +91,7 @@ type Store = GameState & GameActions & {
|
|||||||
_lastSpawn: number;
|
_lastSpawn: number;
|
||||||
_combo: number;
|
_combo: number;
|
||||||
_lastPulse: number;
|
_lastPulse: number;
|
||||||
|
_achievementQueue: Achievement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 计算并写回产能字段 */
|
/** 计算并写回产能字段 */
|
||||||
@@ -122,6 +128,7 @@ export const useGameStore = create<Store>()(
|
|||||||
_lastSpawn: Date.now(),
|
_lastSpawn: Date.now(),
|
||||||
_combo: 0,
|
_combo: 0,
|
||||||
_lastPulse: 0,
|
_lastPulse: 0,
|
||||||
|
_achievementQueue: [],
|
||||||
|
|
||||||
init: () => {
|
init: () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
@@ -141,6 +148,8 @@ export const useGameStore = create<Store>()(
|
|||||||
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 兼容旧存档:补全 achievements 字段
|
||||||
|
const achievements = s.achievements ?? {};
|
||||||
// 首次进入:补发离线收益
|
// 首次进入:补发离线收益
|
||||||
const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||||||
if (elapsed > 5) {
|
if (elapsed > 5) {
|
||||||
@@ -151,20 +160,21 @@ export const useGameStore = create<Store>()(
|
|||||||
crystals: Math.min(s.crystalCap, s.crystals + gain),
|
crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||||||
lastTick: now,
|
lastTick: now,
|
||||||
activePuzzle,
|
activePuzzle,
|
||||||
...syncStats({ tech: s.tech, blueprints: s.blueprints }),
|
achievements,
|
||||||
|
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements }),
|
||||||
});
|
});
|
||||||
} else {
|
} 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: () => {
|
loadOnline: () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints }) });
|
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
hardReset: () => {
|
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) => {
|
tick: (now) => {
|
||||||
@@ -466,7 +476,7 @@ export const useGameStore = create<Store>()(
|
|||||||
const next = performPrestige(s);
|
const next = performPrestige(s);
|
||||||
set({
|
set({
|
||||||
...next,
|
...next,
|
||||||
...syncStats({ tech: next.tech, blueprints: next.blueprints }),
|
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements }),
|
||||||
_lastAutoDecode: Date.now(),
|
_lastAutoDecode: Date.now(),
|
||||||
_lastSpawn: Date.now(),
|
_lastSpawn: Date.now(),
|
||||||
_combo: 0,
|
_combo: 0,
|
||||||
@@ -475,6 +485,48 @@ export const useGameStore = create<Store>()(
|
|||||||
return { newBp };
|
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,
|
canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||||||
|
|
||||||
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||||||
@@ -485,8 +537,8 @@ export const useGameStore = create<Store>()(
|
|||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
// 不持久化临时字段
|
// 不持久化临时字段
|
||||||
partialize: (s) => {
|
partialize: (s) => {
|
||||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, ...rest } = s;
|
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, ...rest } = s;
|
||||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse;
|
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue;
|
||||||
return rest as GameState;
|
return rest as GameState;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,15 +39,28 @@
|
|||||||
- **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50),HP clamp验证
|
- **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50),HP clamp验证
|
||||||
- 详见 docs/repo/docs/05-遗迹探险系统-v0.2.md
|
- 详见 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/点),后续可加技术提升恢复速度
|
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||||
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
||||||
- 下一阶段优先:音频系统(解码/脉冲/探险音效)、socket 全局「星潮」事件、云存档
|
- 下一阶段优先:socket 全局「星潮」实时事件、云存档+排行榜
|
||||||
|
|
||||||
## 定时任务
|
## 定时任务
|
||||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
||||||
|
|||||||