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

This commit is contained in:
2026-06-23 13:38:23 +00:00
parent 674775be0e
commit da18f32df2
36 changed files with 849 additions and 34 deletions
@@ -0,0 +1,26 @@
"use client";
// 回响星核 / Echo Nexus — 成就解锁通知消费者
import { useEffect } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
export function AchievementNotifier() {
const queue = useGameStore((s) => s._achievementQueue);
const consume = useGameStore((s) => s.consumeAchievementQueue);
const { toast } = useToast();
useEffect(() => {
if (queue.length === 0) return;
const items = consume();
for (const a of items) {
sfx("achievement");
toast({
title: `🏆 成就解锁:${a.name}`,
description: `${a.desc} 奖励:${a.rewardText}`,
});
}
}, [queue, consume, toast]);
return null;
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
// 回响星核 / Echo Nexus — 成就系统面板
import { useGameStore } from "@/store/gameStore";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock } from "lucide-react";
export function AchievementsPanel() {
const achievements = useGameStore((s) => s.achievements);
const unlockedCount = Object.values(achievements).filter(Boolean).length;
const bonus = achievementBonuses(achievements);
return (
<div className="flex flex-col gap-2.5 h-full">
{/* 顶部:进度 + 加成总览 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-amber-400" />
</h3>
<span className="text-[10px] text-muted-foreground font-mono">
{unlockedCount} / {ACHIEVEMENTS.length}
</span>
</div>
{/* 永久加成条 */}
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-3 text-[10px]">
<span className="text-amber-300/80"></span>
{bonus.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span>
)}
{bonus.insightPct > 0 && (
<span className="text-fuchsia-300"> +{bonus.insightPct}%</span>
)}
{bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 成就网格 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1 max-h-[300px]">
{ACHIEVEMENTS.map((a) => {
const unlocked = !!achievements[a.id];
return (
<div
key={a.id}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
unlocked
? "border-white/15 bg-black/30"
: "border-white/5 bg-black/15 opacity-60"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 16px ${a.color}11` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
style={{ background: a.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标徽章 */}
<div
className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center text-base"
style={{
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? a.color : "#666",
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
}}
>
{unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
>
{a.name}
</div>
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
{a.desc}
</div>
{unlocked && (
<div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
{a.rewardText}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
`}</style>
</div>
);
}
+70 -4
View File
@@ -1,9 +1,10 @@
"use client";
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲
import { useRef, useState, useCallback, useEffect } from "react";
import { useRef, useState, useCallback } from "react";
import { useGameStore } from "@/store/gameStore";
import { formatNum } from "@/lib/game/config";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
interface FloatNum {
id: number;
@@ -11,6 +12,15 @@ interface FloatNum {
y: number;
text: string;
born: number;
color: string;
}
interface Particle {
id: number;
angle: number;
dist: number;
born: number;
color: string;
}
export function CrystalOrb() {
@@ -21,6 +31,7 @@ export function CrystalOrb() {
const combo = useGameStore((s) => s._combo);
const { toast } = useToast();
const [floats, setFloats] = useState<FloatNum[]>([]);
const [particles, setParticles] = useState<Particle[]>([]);
const [pulseAnim, setPulseAnim] = useState(0);
const idRef = useRef(0);
@@ -32,14 +43,33 @@ export function CrystalOrb() {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = idRef.current++;
const floatColor = res.combo >= 5 ? "#fbbf24" : res.combo >= 3 ? "#e879f9" : "#34d399";
setFloats((f) => [
...f,
{ id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now() },
{ id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now(), color: floatColor },
]);
// 粒子爆发
const newParticles: Particle[] = [];
const pcount = 6 + Math.min(6, res.combo);
for (let i = 0; i < pcount; i++) {
newParticles.push({
id: idRef.current++,
angle: (Math.PI * 2 * i) / pcount + Math.random() * 0.3,
dist: 0,
born: Date.now(),
color: floatColor,
});
}
setParticles((p) => [...p, ...newParticles]);
setPulseAnim((n) => n + 1);
// 音效
sfx(res.combo >= 3 ? "pulseCombo" : "pulse", { combo: res.combo });
setTimeout(() => {
setFloats((f) => f.filter((it) => it.id !== id));
}, 900);
setTimeout(() => {
setParticles((p) => p.filter((it) => !newParticles.includes(it)));
}, 700);
if (res.combo >= 5 && res.combo % 5 === 0) {
toast({
title: `×${res.combo} 连击!`,
@@ -112,6 +142,12 @@ export function CrystalOrb() {
</linearGradient>
</defs>
</svg>
{/* 脉冲扩散环(点击时) */}
<div
key={`ring-${pulseAnim}`}
className="absolute inset-8 rounded-full border-2 border-emerald-400/60 pointer-events-none"
style={{ animation: "echo-ring 0.7s ease-out forwards" }}
/>
{/* 中央晶体 */}
<div
key={pulseAnim}
@@ -141,17 +177,33 @@ export function CrystalOrb() {
/>
</div>
</div>
{/* 粒子爆发 */}
{particles.map((p) => (
<span
key={p.id}
className="absolute left-1/2 top-1/2 rounded-full pointer-events-none"
style={{
width: 4,
height: 4,
background: p.color,
boxShadow: `0 0 6px ${p.color}`,
["--angle" as string]: `${p.angle}rad`,
animation: "echo-burst 0.7s ease-out forwards",
}}
/>
))}
{/* 浮动数字 */}
{floats.map((f) => (
<span
key={f.id}
className="absolute pointer-events-none font-mono font-bold text-emerald-300 text-sm"
className="absolute pointer-events-none font-mono font-bold text-sm"
style={{
left: f.x,
top: f.y,
color: f.color,
transform: "translate(-50%, -50%)",
animation: "echo-float 0.9s ease-out forwards",
textShadow: "0 0 8px rgba(52,211,153,0.8)",
textShadow: `0 0 8px ${f.color}`,
}}
>
{f.text}
@@ -183,6 +235,20 @@ export function CrystalOrb() {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -180%) scale(1.3); }
}
@keyframes echo-ring {
0% { transform: scale(0.8); opacity: 0.8; }
100% { transform: scale(1.6); opacity: 0; }
}
@keyframes echo-burst {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) rotate(calc(-1 * var(--angle)));
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) rotate(calc(-1 * var(--angle)));
opacity: 0;
}
}
`}</style>
</div>
);
+5
View File
@@ -5,6 +5,7 @@
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { COLOR_VISUAL } from "@/lib/game/config";
import { isSolvable, canStartFrom } from "@/lib/game/decode";
import type { DecodeNode, ResonanceColor } from "@/lib/game/types";
@@ -35,9 +36,11 @@ export function DecodeArray() {
const res = clickNode(node.id);
if (res.ok && res.finished) {
setWarned(false);
sfx("decodeSuccess");
if (res.failReason) {
const ids = res.failReason.split(",").filter(Boolean);
if (ids.length) {
sfx("fragmentUnlock");
toast({
title: "✦ 记忆碎片浮现",
description: "新的回响被拼入图谱,前往「记忆图谱」查看。",
@@ -51,9 +54,11 @@ export function DecodeArray() {
return;
}
if (!res.ok) {
sfx("decodeFail");
setFlash({ id: node.id, ok: false });
setTimeout(() => setFlash(null), 280);
} else {
sfx("decodeClick", { color: node.color });
setFlash({ id: node.id, ok: true });
setTimeout(() => setFlash(null), 280);
// 点击后若不可解,提示玩家撤销
+8
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition";
import { formatNum } from "@/lib/game/config";
import { Button } from "@/components/ui/button";
@@ -58,6 +59,7 @@ export function ExpeditionPanel() {
if (!res.ok) {
toast({ title: "无法出发", description: res.reason, variant: "destructive" });
} else {
sfx("expeditionStart");
toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" });
setLastLog(null);
}
@@ -67,6 +69,12 @@ export function ExpeditionPanel() {
const result = resolveCurrentNode();
if (!result) return;
setLastLog(result.log);
if (result.ended) {
if (result.endReason === "victory") sfx("expeditionVictory");
else sfx("expeditionDefeat");
} else {
sfx("expeditionNode");
}
toast({
title: result.ended
? result.endReason === "victory" ? "✦ 探险胜利!" : "探险失败"
+2
View File
@@ -15,6 +15,7 @@ import { PRESTIGE } from "@/lib/game/config";
import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
export function PrestigeDialog({
open,
@@ -106,6 +107,7 @@ export function PrestigeDialog({
onOpenChange(false);
setConfirming(false);
if (res) {
sfx("prestige");
toast({
title: "✦ 飞升成功",
description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。`,
+15 -3
View File
@@ -12,9 +12,10 @@ import {
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Trash2, Github, AlertTriangle } from "lucide-react";
import { Trash2, Github, AlertTriangle, Volume2 } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
export function SettingsDialog({
open,
@@ -45,10 +46,21 @@ export function SettingsDialog({
<div>
<Label className="text-sm"></Label>
<p className="text-[11px] text-muted-foreground mt-0.5">
v0.3
</p>
</div>
<Switch checked={soundOn} onCheckedChange={toggleSound} />
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => sfx("decodeSuccess")}
aria-label="试听音效"
>
<Volume2 className="h-3.5 w-3.5" />
</Button>
<Switch checked={soundOn} onCheckedChange={toggleSound} />
</div>
</div>
{/* 存档信息 */}
+4 -1
View File
@@ -2,6 +2,7 @@
// 回响星核 / Echo Nexus — 技术树
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
import { sfx } from "@/hooks/useAudio";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -103,7 +104,9 @@ export function TechTree() {
size="sm"
variant={affordable ? "default" : "ghost"}
disabled={!affordable}
onClick={() => buyTech(node.id)}
onClick={() => {
if (buyTech(node.id)) sfx("techBuy");
}}
className="h-7 px-2 text-[11px] shrink-0"
style={
affordable