d9404a31-1970-4e79-ac9a-f4f9db60350b
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user