d9404a31-1970-4e79-ac9a-f4f9db60350b

This commit is contained in:
2026-06-23 12:47:49 +00:00
parent e193530f24
commit f73b96026c
101 changed files with 12685 additions and 2 deletions
+439
View File
@@ -0,0 +1,439 @@
"use client";
// 回响星核 / Echo Nexus — 解码共振谜题(核心原创玩法)
//
// 谐振序列:按目标颜色顺序点击四邻接节点,重建回路。
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { COLOR_VISUAL } from "@/lib/game/config";
import { isSolvable, canStartFrom } from "@/lib/game/decode";
import type { DecodeNode, ResonanceColor } from "@/lib/game/types";
import { Button } from "@/components/ui/button";
import { RotateCcw, X, Sparkles, Check, Undo2, AlertTriangle } from "lucide-react";
export function DecodeArray() {
const activePuzzle = useGameStore((s) => s.activePuzzle);
const clickNode = useGameStore((s) => s.clickNode);
const undoStep = useGameStore((s) => s.undoStep);
const retryPuzzle = useGameStore((s) => s.retryPuzzle);
const abandonPuzzle = useGameStore((s) => s.abandonPuzzle);
const { toast } = useToast();
const [hoverId, setHoverId] = useState<number | null>(null);
const [flash, setFlash] = useState<{ id: number; ok: boolean } | null>(null);
const [warned, setWarned] = useState(false);
if (!activePuzzle) return null;
const { grid, rows, cols, target, path, stepLimit, tier } = activePuzzle;
const currentTargetIdx = path.length;
const currentColor: ResonanceColor | undefined = target[currentTargetIdx];
// 当前局面是否仍可解(精确判定)
const stuck = path.length > 0 && path.length < target.length && !isSolvable(activePuzzle);
const handleNodeClick = (node: DecodeNode) => {
const res = clickNode(node.id);
if (res.ok && res.finished) {
setWarned(false);
if (res.failReason) {
const ids = res.failReason.split(",").filter(Boolean);
if (ids.length) {
toast({
title: "✦ 记忆碎片浮现",
description: "新的回响被拼入图谱,前往「记忆图谱」查看。",
});
}
}
toast({
title: "谐振闭合!",
description: "晶体解码成功,记忆已释放。",
});
return;
}
if (!res.ok) {
setFlash({ id: node.id, ok: false });
setTimeout(() => setFlash(null), 280);
} else {
setFlash({ id: node.id, ok: true });
setTimeout(() => setFlash(null), 280);
// 点击后若不可解,提示玩家撤销
if (res.solvable === false && !warned) {
setWarned(true);
toast({
title: "谐振受阻",
description: "此路不通,建议撤销上一步或重排。",
});
}
if (res.solvable === true) setWarned(false);
}
};
const handleUndo = () => {
undoStep();
setWarned(false);
setFlash(null);
};
const handleRetry = () => {
retryPuzzle();
setWarned(false);
setFlash(null);
};
// 计算节点位置(百分比)
const cellW = 100 / cols;
const cellH = 100 / rows;
// 路径线段
const pathNodes = path
.map((id) => grid.find((n) => n.id === id)!)
.filter(Boolean);
return (
<div className="flex flex-col gap-4 h-full">
{/* 目标序列 */}
<div className="flex items-center justify-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground mr-1"></span>
{target.map((c, i) => {
const done = i < currentTargetIdx;
const active = i === currentTargetIdx;
const v = COLOR_VISUAL[c];
return (
<div
key={i}
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition-all duration-300 ${
done ? "opacity-30 scale-90" : ""
} ${active ? "scale-125 ring-2 ring-offset-2 ring-offset-background" : ""}`}
style={{
background: `radial-gradient(circle at 35% 30%, ${v.hex}, ${v.hex}aa 60%, ${v.hex}44)`,
boxShadow: active
? `0 0 18px ${v.glow}, 0 0 36px ${v.glow}`
: `0 0 8px ${v.glow}`,
["--tw-ring-color" as string]: v.hex,
}}
>
{done && <Check className="h-4 w-4 text-white" />}
{active && (
<span
className="absolute -bottom-5 text-[10px] font-medium"
style={{ color: v.hex }}
>
</span>
)}
</div>
);
})}
</div>
{/* 节点阵列 */}
<div className="relative flex-1 min-h-[260px] rounded-2xl border border-white/10 bg-black/30 backdrop-blur-sm overflow-hidden">
{/* 背景网格线 */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)",
backgroundSize: `${cellW}% ${cellH}%`,
}}
/>
{/* 路径线 SVG */}
<svg className="absolute inset-0 w-full h-full pointer-events-none">
{pathNodes.map((n, i) => {
if (i === 0) return null;
const prev = pathNodes[i - 1];
const x1 = (prev.col + 0.5) * cellW;
const y1 = (prev.row + 0.5) * cellH;
const x2 = (n.col + 0.5) * cellW;
const y2 = (n.row + 0.5) * cellH;
const v = COLOR_VISUAL[n.color];
return (
<line
key={i}
x1={`${x1}%`}
y1={`${y1}%`}
x2={`${x2}%`}
y2={`${y2}%`}
stroke={v.hex}
strokeWidth={2.5}
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 4px ${v.glow})` }}
className="echo-path-line"
/>
);
})}
</svg>
{/* 节点 */}
{grid.map((node) => {
const v = COLOR_VISUAL[node.color];
const used = node.used;
const isCurrentTarget =
currentColor !== undefined && node.color === currentColor && !used;
// 选起点阶段:标记「可行起点」(选了之后仍可解),避免玩家踩死起点
const isViableStart =
path.length === 0 && isCurrentTarget && canStartFrom(activePuzzle, node.id);
// 选起点阶段:是目标色但选了会死局的起点(淡化提示)
const isDeadStart =
path.length === 0 && isCurrentTarget && !isViableStart;
const isHover = hoverId === node.id;
const isFlash = flash?.id === node.id;
const cx = (node.col + 0.5) * cellW;
const cy = (node.row + 0.5) * cellH;
return (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
onMouseEnter={() => setHoverId(node.id)}
onMouseLeave={() => setHoverId(null)}
disabled={used}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-200 focus:outline-none disabled:cursor-not-allowed"
style={{
left: `${cx}%`,
top: `${cy}%`,
width: `min(${100 / cols * 0.62}%, 56px)`,
aspectRatio: "1",
background: used
? "rgba(255,255,255,0.04)"
: `radial-gradient(circle at 35% 30%, ${v.hex}, ${v.hex}cc 55%, ${v.hex}55)`,
boxShadow: used
? "inset 0 0 12px rgba(0,0,0,0.5)"
: isViableStart
? `0 0 16px ${v.glow}, 0 0 32px ${v.glow}, inset 0 0 10px rgba(255,255,255,0.5)`
: isCurrentTarget
? `0 0 10px ${v.glow}, 0 0 20px ${v.glow}, inset 0 0 6px rgba(255,255,255,0.3)`
: `0 0 6px ${v.glow}`,
border: isViableStart
? `2px solid ${v.hex}`
: isCurrentTarget
? `1.5px solid ${v.hex}aa`
: "1px solid rgba(255,255,255,0.12)",
opacity: used ? 0.35 : isDeadStart ? 0.5 : 1,
transform: `translate(-50%, -50%) scale(${
isHover && !used ? 1.12 : isFlash ? (flash?.ok ? 1.25 : 0.8) : 1
})`,
animation: isViableStart
? "echo-pulse 1.2s ease-in-out infinite"
: isCurrentTarget && !isDeadStart
? "echo-pulse 1.6s ease-in-out infinite"
: undefined,
}}
aria-label={`${v.label}色节点 ${node.row + 1}-${node.col + 1}`}
>
{!used && (
<span
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle at 35% 30%, rgba(255,255,255,0.5), transparent 45%)",
}}
/>
)}
</button>
);
})}
</div>
{/* 卡死提示横幅 */}
{stuck && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-rose-400/40 bg-rose-950/40 text-rose-200 text-xs animate-pulse">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span></span>
<Button
size="sm"
variant="outline"
onClick={handleUndo}
className="h-6 px-2 ml-auto border-rose-400/50 text-rose-100 hover:bg-rose-500/20 text-[11px]"
>
<Undo2 className="h-3 w-3 mr-1" />
</Button>
</div>
)}
{/* 底栏:步数 + 操作 */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground"></span>
<span
className={`font-mono font-semibold ${
path.length >= stepLimit ? "text-rose-400" : "text-foreground"
}`}
>
{path.length} / {stepLimit}
</span>
<span className="ml-2 text-muted-foreground">T{tier}</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={handleUndo}
disabled={path.length === 0}
className="h-7"
>
<Undo2 className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRetry}
className="h-7"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
abandonPuzzle();
setFlash(null);
setWarned(false);
}}
className="h-7"
>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</div>
{/* 规则提示 */}
<p className="text-[11px] text-muted-foreground/80 text-center leading-relaxed">
{path.length === 0
? <><span className="text-emerald-300"></span><span className="text-emerald-300"></span><span className="text-rose-300"></span></>
: <><span className="text-emerald-300"></span>使<span className="text-rose-300"></span></>}
</p>
<style jsx global>{`
@keyframes echo-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.4); }
}
@keyframes echo-line {
from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; }
}
.echo-path-line {
stroke-dasharray: 100;
animation: echo-line 0.4s ease-out forwards;
}
`}</style>
</div>
);
}
/** 解码面板的容器(带标题) */
export function DecodePanel() {
const activePuzzle = useGameStore((s) => s.activePuzzle);
const pendingCrystals = useGameStore((s) => s.pendingCrystals);
const startDecode = useGameStore((s) => s.startDecode);
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const crystalCap = useGameStore((s) => s.crystalCap);
const crystals = useGameStore((s) => s.crystals);
if (activePuzzle) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-rose-400" />
<h3 className="text-sm font-semibold"></h3>
</div>
<div className="flex-1 min-h-0">
<DecodeArray />
</div>
</div>
);
}
// 无活跃谜题:展示待解码晶体队列
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-rose-400" />
<h3 className="text-sm font-semibold"></h3>
<span className="text-xs text-muted-foreground ml-auto">
{crystalsPerSec.toFixed(1)}/s · {Math.floor(crystals)}/{Math.floor(crystalCap)}
</span>
</div>
{pendingCrystals.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 rounded-2xl border border-dashed border-white/10 bg-black/20 p-6">
<div className="relative h-16 w-16">
<div className="absolute inset-0 rounded-full bg-rose-500/20 blur-xl animate-pulse" />
<div className="absolute inset-2 rounded-full border-2 border-rose-400/40 rotate-45" />
<div className="absolute inset-4 rounded-full bg-rose-500/30" />
</div>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70 max-w-[220px]">
</p>
</div>
) : (
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 gap-3 content-start max-h-[420px] overflow-y-auto echo-scroll pr-1">
{pendingCrystals.map((c) => (
<CrystalCard key={c.id} crystal={c} onClick={() => startDecode(c.id)} />
))}
</div>
)}
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 6px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
`}</style>
</div>
);
}
function CrystalCard({
crystal,
onClick,
}: {
crystal: { id: string; tier: 1 | 2 | 3; value: number };
onClick: () => void;
}) {
const tierColor =
crystal.tier === 1 ? "#34d399" : crystal.tier === 2 ? "#fbbf24" : "#e879f9";
const tierLabel = crystal.tier === 1 ? "常见" : crystal.tier === 2 ? "稀有" : "史诗";
return (
<button
onClick={onClick}
className="group relative aspect-square rounded-2xl border border-white/10 bg-black/40 hover:border-white/30 transition-all duration-300 hover:scale-[1.03] focus:outline-none overflow-hidden"
style={{ boxShadow: `inset 0 0 24px ${tierColor}22` }}
>
{/* 晶体图形 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="relative h-12 w-12 transition-transform duration-500 group-hover:rotate-180"
style={{ filter: `drop-shadow(0 0 12px ${tierColor}88)` }}
>
<div
className="absolute inset-0 rotate-45 rounded-md"
style={{
background: `linear-gradient(135deg, ${tierColor}, ${tierColor}66)`,
border: `1px solid ${tierColor}`,
}}
/>
<div
className="absolute inset-2 rotate-45 rounded-sm"
style={{ background: `linear-gradient(135deg, rgba(255,255,255,0.5), transparent)` }}
/>
</div>
</div>
{/* 标签 */}
<div className="absolute top-1.5 left-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded bg-black/50" style={{ color: tierColor }}>
T{crystal.tier}
</div>
<div className="absolute bottom-1.5 right-1.5 text-[10px] text-white/70">
{tierLabel}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<span className="text-[11px] font-medium text-white"> </span>
</div>
</button>
);
}