- 新增 src/lib/game/constellation.ts:6 类别 × 3 天赋 = 18 个永久天赋, constellationBonuses() 聚合 19 项修饰器,rollPerkChoices() 保证不同类别 - 状态扩展:GameState.constellation / pendingPerkChoices - engine.ts: recomputeStats 聚合三层加成(技术+蓝图+成就+星图), performPrestige 触发天赋选择 + 飞升礼包补偿,新增 rollCrystalTierWithBonus - expedition.ts: 探险力/生命应用星座修饰器 - gameStore.ts: 新增 chooseConstellationPerk + rerollPerkChoices 动作, 全链路接入(tickTide/autoDecodeTick/clickNode 等用点) - 新增 ConstellationPanel.tsx: Canvas 动态星图(六边形 6 星座 × 3 星点, 闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例 - 新增 ConstellationDialog.tsx: 飞升后自动弹出,3 卡片 draft + 重新抽取 - PrestigeDialog: 增加「星图觉醒预告」卡片 - page.tsx: 6 列标签栏 + 顶部「觉醒」按钮 + 统计面板加星图天赋 - audio.ts: 新增 constellation SFX(上升琶音 + 高频闪光) - achievements.ts: 新增「星图初绘」「六分星辉」2 项成就 - QA: agent-browser + VLM 全流程通过;lint 零错误;编译 < 200ms - 二次回应 Issue #1:从「连连看」扩展为 6 大玩法层 + 元进程 draft
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
"use client";
|
|
// 回响星核 / Echo Nexus — 星图天文台面板(飞升元进程可视化)
|
|
// 6 个星座(六边形布局)× 3 颗星 = 18 个天赋节点,已解锁的星点亮 + 连线
|
|
import { useEffect, useRef, useState, useMemo } from "react";
|
|
import { useGameStore } from "@/store/gameStore";
|
|
import {
|
|
CONSTELLATION_PERKS,
|
|
CONSTELLATION_CATEGORY_META,
|
|
getPerk,
|
|
constellationBonuses,
|
|
getConstellationLayout,
|
|
getStarsInCategory,
|
|
countUnlockedInCategory,
|
|
constellationProgress,
|
|
type ConstellationCategory,
|
|
} from "@/lib/game/constellation";
|
|
import type { ConstellationPerk } from "@/lib/game/constellation";
|
|
|
|
interface StarPoint {
|
|
perk: ConstellationPerk;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
interface HoverState {
|
|
perk: ConstellationPerk;
|
|
x: number;
|
|
y: number;
|
|
unlocked: boolean;
|
|
}
|
|
|
|
export function ConstellationPanel() {
|
|
const constellation = useGameStore((s) => s.constellation ?? []);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const [size, setSize] = useState({ w: 360, h: 360 });
|
|
const [hover, setHover] = useState<HoverState | null>(null);
|
|
const ownedSet = useMemo(() => new Set(constellation), [constellation]);
|
|
|
|
// 自适应尺寸
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(() => {
|
|
const r = el.getBoundingClientRect();
|
|
setSize({ w: Math.max(280, r.width), h: Math.max(280, Math.min(420, r.width)) });
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
// 计算布局(中心点 + 半径)
|
|
const layout = useMemo(() => {
|
|
const cx = size.w / 2;
|
|
const cy = size.h / 2;
|
|
const radius = Math.min(size.w, size.h) * 0.32;
|
|
return { cx, cy, radius };
|
|
}, [size]);
|
|
|
|
// 收集所有星点 + 每个星座的星点列表
|
|
const clusters = useMemo(() => {
|
|
const centers = getConstellationLayout(layout.cx, layout.cy, layout.radius);
|
|
return centers.map((c) => {
|
|
const stars = getStarsInCategory(c.category, c.cx, c.cy, 1) as StarPoint[];
|
|
return { ...c, stars };
|
|
});
|
|
}, [layout]);
|
|
|
|
// 绘制
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = size.w * dpr;
|
|
canvas.height = size.h * dpr;
|
|
canvas.style.width = `${size.w}px`;
|
|
canvas.style.height = `${size.h}px`;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
let rafId = 0;
|
|
const start = performance.now();
|
|
const draw = (now: number) => {
|
|
const t = (now - start) / 1000;
|
|
ctx.clearRect(0, 0, size.w, size.h);
|
|
// 背景径向暗光
|
|
const bg = ctx.createRadialGradient(layout.cx, layout.cy, 0, layout.cx, layout.cy, layout.radius * 1.6);
|
|
bg.addColorStop(0, "rgba(40,30,80,0.18)");
|
|
bg.addColorStop(1, "rgba(5,4,16,0)");
|
|
ctx.fillStyle = bg;
|
|
ctx.fillRect(0, 0, size.w, size.h);
|
|
|
|
// 中心装饰:星核
|
|
ctx.save();
|
|
ctx.translate(layout.cx, layout.cy);
|
|
const coreR = 6 + Math.sin(t * 1.5) * 1.5;
|
|
const coreGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, coreR * 3);
|
|
coreGrad.addColorStop(0, "rgba(232,121,249,0.9)");
|
|
coreGrad.addColorStop(0.4, "rgba(232,121,249,0.3)");
|
|
coreGrad.addColorStop(1, "rgba(232,121,249,0)");
|
|
ctx.fillStyle = coreGrad;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, coreR * 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = "#fce7ff";
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, coreR * 0.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// 连接星座到中心的虚线(已解锁 >=1 时)
|
|
for (const c of clusters) {
|
|
const cnt = countUnlockedInCategory(constellation, c.category);
|
|
if (cnt > 0) {
|
|
ctx.save();
|
|
ctx.strokeStyle = c.meta.glow;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 4]);
|
|
ctx.globalAlpha = 0.4 + Math.sin(t * 2 + c.cx) * 0.1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(layout.cx, layout.cy);
|
|
ctx.lineTo(c.cx, c.cy);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// 绘制每个星座
|
|
for (const c of clusters) {
|
|
const cnt = countUnlockedInCategory(constellation, c.category);
|
|
// 星座中心标签
|
|
ctx.save();
|
|
ctx.font = "10px ui-monospace, monospace";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = cnt > 0 ? c.meta.hex : "rgba(148,163,184,0.5)";
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.fillText(c.meta.name, c.cx, c.cy + 38);
|
|
ctx.restore();
|
|
|
|
// 同星座内已解锁星点之间的连线
|
|
const unlockedStars = c.stars.filter((s) => ownedSet.has(s.perk.id));
|
|
if (unlockedStars.length >= 2) {
|
|
ctx.save();
|
|
ctx.strokeStyle = c.meta.glow;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.globalAlpha = 0.6;
|
|
ctx.beginPath();
|
|
unlockedStars.forEach((s, i) => {
|
|
if (i === 0) ctx.moveTo(s.x, s.y);
|
|
else ctx.lineTo(s.x, s.y);
|
|
});
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// 绘制星点
|
|
for (const star of c.stars) {
|
|
const unlocked = ownedSet.has(star.perk.id);
|
|
ctx.save();
|
|
ctx.translate(star.x, star.y);
|
|
// 闪烁系数
|
|
const twinkle = unlocked ? 0.85 + Math.sin(t * 3 + star.x * 0.1) * 0.15 : 0.3;
|
|
// 外光晕
|
|
const haloR = unlocked ? 10 : 5;
|
|
const haloGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, haloR);
|
|
haloGrad.addColorStop(0, unlocked ? c.meta.glow : "rgba(100,116,139,0.2)");
|
|
haloGrad.addColorStop(1, "rgba(0,0,0,0)");
|
|
ctx.fillStyle = haloGrad;
|
|
ctx.globalAlpha = twinkle;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, haloR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// 核心
|
|
ctx.globalAlpha = unlocked ? 1 : 0.4;
|
|
ctx.fillStyle = unlocked ? c.meta.hex : "#475569";
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, unlocked ? 3.5 : 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// 解锁星点:4 道十字光线
|
|
if (unlocked) {
|
|
ctx.strokeStyle = c.meta.hex;
|
|
ctx.globalAlpha = 0.7 * twinkle;
|
|
ctx.lineWidth = 0.8;
|
|
const ray = 7 + Math.sin(t * 4 + star.y * 0.1) * 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-ray, 0); ctx.lineTo(ray, 0);
|
|
ctx.moveTo(0, -ray); ctx.lineTo(0, ray);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
rafId = requestAnimationFrame(draw);
|
|
};
|
|
rafId = requestAnimationFrame(draw);
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [clusters, layout, size, constellation, ownedSet]);
|
|
|
|
// 鼠标交互:检测 hover
|
|
const handleMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
let best: HoverState | null = null;
|
|
let bestDist = 14;
|
|
for (const c of clusters) {
|
|
for (const star of c.stars) {
|
|
const d = Math.hypot(star.x - mx, star.y - my);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
best = {
|
|
perk: star.perk,
|
|
x: star.x,
|
|
y: star.y,
|
|
unlocked: ownedSet.has(star.perk.id),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
setHover(best);
|
|
};
|
|
const handleLeave = () => setHover(null);
|
|
|
|
const progress = constellationProgress(constellation);
|
|
|
|
return (
|
|
<div ref={containerRef} className="flex flex-col gap-2 min-h-0 h-full">
|
|
{/* 顶部进度 */}
|
|
<div className="flex items-center justify-between text-[11px] px-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-fuchsia-300">✦</span>
|
|
<span className="text-muted-foreground">星图天赋</span>
|
|
<span className="font-mono text-foreground">
|
|
{progress.unlocked}/{progress.total}
|
|
</span>
|
|
</div>
|
|
<span className="text-muted-foreground/60 text-[10px]">每次飞升可解锁 1 个</span>
|
|
</div>
|
|
|
|
{/* Canvas 星图 */}
|
|
<div className="relative rounded-xl bg-black/30 border border-white/5 overflow-hidden flex-shrink-0">
|
|
<canvas
|
|
ref={canvasRef}
|
|
onMouseMove={handleMove}
|
|
onMouseLeave={handleLeave}
|
|
className="block cursor-pointer"
|
|
/>
|
|
{/* Hover tooltip */}
|
|
{hover && (
|
|
<div
|
|
className="pointer-events-none absolute z-10 max-w-[200px] rounded-md bg-black/85 border border-white/10 px-2 py-1.5 text-[10px] leading-tight shadow-lg backdrop-blur-sm"
|
|
style={{
|
|
left: Math.min(hover.x + 12, size.w - 200),
|
|
top: Math.max(0, hover.y - 40),
|
|
}}
|
|
>
|
|
<div
|
|
className="font-bold mb-0.5"
|
|
style={{ color: CONSTELLATION_CATEGORY_META[hover.perk.category].hex }}
|
|
>
|
|
{hover.unlocked ? "★ " : "☆ "}
|
|
{hover.perk.name}
|
|
</div>
|
|
<div className="text-muted-foreground">{hover.perk.desc}</div>
|
|
<div className="text-muted-foreground/60 mt-0.5">
|
|
{CONSTELLATION_CATEGORY_META[hover.perk.category].name} · 序{hover.perk.order}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 类别图例 + 当前总修饰 */}
|
|
<div className="grid grid-cols-3 gap-1 text-[10px]">
|
|
{(Object.keys(CONSTELLATION_CATEGORY_META) as ConstellationCategory[]).map((cat) => {
|
|
const meta = CONSTELLATION_CATEGORY_META[cat];
|
|
const cnt = countUnlockedInCategory(constellation, cat);
|
|
const total = CONSTELLATION_PERKS.filter((p) => p.category === cat).length;
|
|
return (
|
|
<div
|
|
key={cat}
|
|
className="rounded-md bg-black/25 border border-white/5 px-1.5 py-1 flex items-center gap-1"
|
|
style={cnt > 0 ? { borderColor: `${meta.hex}40` } : undefined}
|
|
>
|
|
<span
|
|
className="inline-block h-1.5 w-1.5 rounded-full flex-shrink-0"
|
|
style={{ background: meta.hex, boxShadow: cnt > 0 ? `0 0 4px ${meta.hex}` : "none" }}
|
|
/>
|
|
<span className="text-muted-foreground truncate">{meta.name}</span>
|
|
<span className="ml-auto font-mono" style={{ color: cnt > 0 ? meta.hex : "rgba(148,163,184,0.5)" }}>
|
|
{cnt}/{total}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 已解锁天赋列表 */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto pr-1 echo-scrollbar">
|
|
{constellation.length === 0 ? (
|
|
<div className="rounded-md bg-black/25 border border-dashed border-white/10 px-3 py-3 text-center text-[11px] text-muted-foreground/70">
|
|
<div className="mb-1 text-fuchsia-300/80">✦</div>
|
|
尚未解锁任何天赋。<br />
|
|
累积接触进度至 100%,发起「飞升」即可在 3 个随机天赋中选 1。
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{constellation.map((id) => {
|
|
const perk = getPerk(id);
|
|
if (!perk) return null;
|
|
const meta = CONSTELLATION_CATEGORY_META[perk.category];
|
|
return (
|
|
<div
|
|
key={id}
|
|
className="rounded-md bg-black/25 border px-2 py-1.5 flex items-center gap-2 text-[11px]"
|
|
style={{ borderColor: `${meta.hex}30` }}
|
|
>
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
|
style={{ background: meta.hex, boxShadow: `0 0 6px ${meta.hex}` }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-foreground truncate">{perk.name}</div>
|
|
<div className="text-muted-foreground/70 text-[10px] truncate">{perk.desc}</div>
|
|
</div>
|
|
<span className="text-[9px] text-muted-foreground/50 font-mono">{meta.name}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|