feat(v0.3.1): 星图天文台元进程天赋系统 — 18 天赋 / 3 选 1 draft / Canvas 动态星图

- 新增 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
This commit is contained in:
2026-06-23 14:12:37 +00:00
parent f3d2e53e4f
commit 9f4d839c6b
18 changed files with 1002 additions and 36 deletions
+179
View File
@@ -0,0 +1,179 @@
"use client";
// 回响星核 / Echo Nexus — 星图天赋选择对话框(飞升后弹出,3 选 1)
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useGameStore } from "@/store/gameStore";
import {
getPerk,
CONSTELLATION_CATEGORY_META,
constellationProgress,
} from "@/lib/game/constellation";
import { sfx } from "@/hooks/useAudio";
import { Sparkles, RefreshCw } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
}
export function ConstellationDialog({ open, onOpenChange }: Props) {
const pendingChoices = useGameStore((s) => s.pendingPerkChoices);
const constellation = useGameStore((s) => s.constellation ?? []);
const choosePerk = useGameStore((s) => s.chooseConstellationPerk);
const reroll = useGameStore((s) => s.rerollPerkChoices);
const [picked, setPicked] = useState<string | null>(null);
// 关闭时清理本地状态
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!open) setPicked(null);
}, [open]);
// 自动 open:当 pendingPerkChoices 存在且当前未 open 时
useEffect(() => {
if (pendingChoices && pendingChoices.length > 0 && !open) {
onOpenChange(true);
}
if ((!pendingChoices || pendingChoices.length === 0) && open) {
onOpenChange(false);
}
}, [pendingChoices, open, onOpenChange]);
const handlePick = (perkId: string) => {
setPicked(perkId);
const ok = choosePerk(perkId);
if (ok) {
sfx("constellation");
// 延迟关闭,让选中动画播放
setTimeout(() => onOpenChange(false), 500);
}
};
const handleReroll = () => {
reroll();
sfx("uiClick");
};
if (!pendingChoices || pendingChoices.length === 0) return null;
const progress = constellationProgress(constellation);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-fuchsia-400/30 bg-gradient-to-br from-[#0a0820]/95 to-[#1a0f30]/95 backdrop-blur-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-fuchsia-200">
<Sparkles className="h-4 w-4" />
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
3 1
<br />
<span className="text-fuchsia-300/80"> {progress.unlocked}/{progress.total}</span>
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-2 mt-1">
{pendingChoices.map((perkId) => {
const perk = getPerk(perkId);
if (!perk) return null;
const meta = CONSTELLATION_CATEGORY_META[perk.category];
const isPicked = picked === perkId;
return (
<button
key={perkId}
onClick={() => handlePick(perkId)}
disabled={!!picked}
className={`group relative w-full text-left rounded-lg border px-3 py-2.5 transition-all overflow-hidden ${
isPicked
? "border-fuchsia-400/80 bg-fuchsia-500/10 scale-[1.02]"
: picked
? "border-white/5 opacity-40"
: "border-white/10 hover:border-white/30 hover:bg-white/5"
}`}
style={{
boxShadow: isPicked ? `0 0 20px ${meta.glow}` : undefined,
}}
>
{/* 类别色条 */}
<div
className="absolute left-0 top-0 bottom-0 w-1"
style={{ background: meta.hex, boxShadow: `0 0 8px ${meta.hex}` }}
/>
<div className="pl-2 flex items-start gap-3">
{/* 类别图标 */}
<div
className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background: `${meta.hex}20`,
color: meta.hex,
border: `1px solid ${meta.hex}40`,
}}
>
{meta.name.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-bold text-foreground">{perk.name}</span>
<span
className="text-[9px] px-1 py-0.5 rounded font-mono"
style={{
background: `${meta.hex}20`,
color: meta.hex,
}}
>
{meta.name}
</span>
</div>
<div className="text-[11px] text-muted-foreground">{perk.desc}</div>
</div>
</div>
{/* 选中粒子动画 */}
{isPicked && (
<div className="absolute inset-0 pointer-events-none">
{[...Array(8)].map((_, i) => (
<span
key={i}
className="absolute h-1 w-1 rounded-full animate-ping"
style={{
background: meta.hex,
left: `${20 + i * 8}%`,
top: `${30 + (i % 3) * 20}%`,
animationDelay: `${i * 50}ms`,
animationDuration: "800ms",
}}
/>
))}
</div>
)}
</button>
);
})}
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-white/5">
<Button
variant="ghost"
size="sm"
onClick={handleReroll}
disabled={!!picked}
className="text-muted-foreground hover:text-foreground h-7 text-[11px]"
>
<RefreshCw className="h-3 w-3 mr-1" />
</Button>
<span className="text-[10px] text-muted-foreground/60">
</span>
</div>
</DialogContent>
</Dialog>
);
}
+337
View File
@@ -0,0 +1,337 @@
"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>
);
}
+23 -2
View File
@@ -10,9 +10,10 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { RotateCcw, Sparkles } from "lucide-react";
import { RotateCcw, Sparkles, Star } from "lucide-react";
import { PRESTIGE } from "@/lib/game/config";
import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine";
import { constellationProgress } from "@/lib/game/constellation";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
@@ -32,6 +33,7 @@ export function PrestigeDialog({
const newBp = computeNewBlueprints(state);
const bonus = computePrestigeBonus(state);
const totalBpAfter = Math.min(PRESTIGE.maxBlueprints, state.blueprints.length + newBp);
const cProgress = constellationProgress(state.constellation ?? []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -83,13 +85,32 @@ export function PrestigeDialog({
<div className="text-[11px] text-muted-foreground space-y-1 pt-1">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-fuchsia-400" />
</div>
<div className="flex items-center gap-1.5">
<RotateCcw className="h-3 w-3 text-rose-400" />
</div>
</div>
{/* 星图觉醒预告 */}
<div className="rounded-lg border border-fuchsia-400/30 bg-gradient-to-r from-fuchsia-950/40 to-purple-950/40 p-2.5">
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-fuchsia-300" />
<div className="flex-1">
<div className="text-[12px] text-fuchsia-200 font-bold"></div>
<div className="text-[10px] text-muted-foreground">
3 1
{cProgress.unlocked < cProgress.total && (
<span className="text-fuchsia-300/80"> · {cProgress.unlocked}/{cProgress.total}</span>
)}
</div>
</div>
<div className="text-[10px] text-fuchsia-300/70 font-mono">
{cProgress.unlocked < cProgress.total ? "✦ 可觉醒" : "✧ 已圆满"}
</div>
</div>
</div>
</div>
<DialogFooter>