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:
+1
-1
Submodule docs/repo updated: 27bb1555e5...433c19cc73
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
+39
-4
@@ -12,6 +12,8 @@ import { SettingsDialog } from "@/components/game/SettingsDialog";
|
||||
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
|
||||
import { AchievementsPanel } from "@/components/game/AchievementsPanel";
|
||||
import { AchievementNotifier } from "@/components/game/AchievementNotifier";
|
||||
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
|
||||
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -32,16 +34,19 @@ import {
|
||||
Rocket,
|
||||
Github,
|
||||
Trophy,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
||||
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
useAudioSync();
|
||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const contact = useGameStore((s) => s.contact);
|
||||
@@ -51,6 +56,8 @@ export default function Page() {
|
||||
const ownedTech = useGameStore((s) => s.tech);
|
||||
const ownedFragments = useGameStore((s) => s.fragments);
|
||||
const ownedAchievements = useGameStore((s) => s.achievements);
|
||||
const ownedConstellation = useGameStore((s) => s.constellation ?? []);
|
||||
const pendingPerkChoices = useGameStore((s) => s.pendingPerkChoices);
|
||||
const crystals = useGameStore((s) => s.crystals);
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
@@ -75,7 +82,9 @@ export default function Page() {
|
||||
const ownedTechCount = Object.values(ownedTech).filter((v) => v > 0).length;
|
||||
const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length;
|
||||
const ownedAchCount = Object.values(ownedAchievements).filter(Boolean).length;
|
||||
const ownedConstCount = ownedConstellation.length;
|
||||
const canPrestige = contact >= 100;
|
||||
const hasPendingPerk = pendingPerkChoices && pendingPerkChoices.length > 0;
|
||||
|
||||
// 仓库满仓警告
|
||||
const warehouseFull = crystals >= crystalCap * 0.98;
|
||||
@@ -95,6 +104,9 @@ export default function Page() {
|
||||
if (warehouseFull) {
|
||||
goal = "⚠ 仓库已满,产能浪费中!请解码晶体或升级仓库";
|
||||
}
|
||||
if (hasPendingPerk) {
|
||||
goal = "✦ 星图觉醒!点击顶部「星图」按钮选择一道天赋";
|
||||
}
|
||||
if (activeTide) {
|
||||
const tm = TIDE_EVENTS[activeTide.type];
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
@@ -121,12 +133,23 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.3
|
||||
ECHO NEXUS · v0.3.1
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
{hasPendingPerk && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConstellationOpen(true)}
|
||||
className="border-fuchsia-400/60 text-fuchsia-200 hover:bg-fuchsia-500/15 h-8 px-2.5 animate-pulse"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 mr-1" />
|
||||
觉醒
|
||||
</Button>
|
||||
)}
|
||||
{canPrestige && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -169,10 +192,10 @@ export default function Page() {
|
||||
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:探险 / 技术 / 图谱 / 成就 / 统计 */}
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 统计 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : "tech"} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-5 h-8 bg-black/30">
|
||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-6 h-8 bg-black/30">
|
||||
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-1">
|
||||
<Rocket className="h-3 w-3" />
|
||||
探险
|
||||
@@ -187,6 +210,13 @@ export default function Page() {
|
||||
<Cpu className="h-3 w-3" />
|
||||
技术
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="constellation" className="text-[11px] gap-0.5 relative px-1">
|
||||
<Star className="h-3 w-3" />
|
||||
星图
|
||||
{hasPendingPerk && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="text-[11px] gap-0.5 px-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
图谱
|
||||
@@ -209,6 +239,9 @@ export default function Page() {
|
||||
<TabsContent value="tech" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<TechTree />
|
||||
</TabsContent>
|
||||
<TabsContent value="constellation" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<ConstellationPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<Codex />
|
||||
</TabsContent>
|
||||
@@ -272,6 +305,7 @@ export default function Page() {
|
||||
</footer>
|
||||
|
||||
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
|
||||
<ConstellationDialog open={constellationOpen} onOpenChange={setConstellationOpen} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<AchievementNotifier />
|
||||
<StarTideNotifier />
|
||||
@@ -289,6 +323,7 @@ function StatsPanel() {
|
||||
{ label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
|
||||
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` },
|
||||
{ label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` },
|
||||
{ label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` },
|
||||
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
|
||||
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
|
||||
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -167,6 +167,30 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
reward: { insights: 8 },
|
||||
rewardText: "+8 洞见",
|
||||
},
|
||||
{
|
||||
id: "ach_constellation_1",
|
||||
name: "星图初绘",
|
||||
desc: "觉醒第 1 道星图天赋",
|
||||
icon: "✨",
|
||||
color: "#e879f9",
|
||||
check: (s) => (s.constellation?.length ?? 0) >= 1,
|
||||
reward: { insights: 30, crystalsPerSecPct: 5 },
|
||||
rewardText: "+30 洞见 · 产能 +5%",
|
||||
},
|
||||
{
|
||||
id: "ach_constellation_6",
|
||||
name: "六分星辉",
|
||||
desc: "觉醒 6 道星图天赋(每类各 1 道)",
|
||||
icon: "✺",
|
||||
color: "#34d399",
|
||||
check: (s) => {
|
||||
const c = s.constellation ?? [];
|
||||
const cats = new Set(c.map((id) => id.split("_")[1]));
|
||||
return cats.size >= 6 && c.length >= 6;
|
||||
},
|
||||
reward: { crystalsPerSecPct: 12, insightPct: 12 },
|
||||
rewardText: "产能 +12% · 洞见 +12%",
|
||||
},
|
||||
];
|
||||
|
||||
/** 计算成就提供的永久加成(跨周目保留) */
|
||||
|
||||
@@ -19,6 +19,7 @@ type SfxName =
|
||||
| "achievement" // 成就解锁(亮丽琶音)
|
||||
| "tideStart" // 星潮降临(神秘扫频)
|
||||
| "tideEnd" // 星潮结束(柔和消退)
|
||||
| "constellation" // 星图觉醒(空灵琶音 + 高频闪光)
|
||||
| "uiHover" // 界面悬停(极轻)
|
||||
| "uiClick"; // 界面点击(轻确认)
|
||||
|
||||
@@ -266,6 +267,16 @@ class AudioEngine {
|
||||
this.tone(392, 0.5, "triangle", 0.1, 0.22);
|
||||
break;
|
||||
}
|
||||
case "constellation": {
|
||||
// 星图觉醒:上升琶音 + 高频闪光
|
||||
this.tone(523.25, 0.18, "sine", 0.16, 0);
|
||||
this.tone(659.25, 0.2, "sine", 0.16, 0.08);
|
||||
this.tone(783.99, 0.22, "sine", 0.18, 0.16);
|
||||
this.tone(1046.5, 0.35, "triangle", 0.16, 0.24);
|
||||
// 高频闪光
|
||||
this.tone(2093, 0.15, "sine", 0.08, 0.3);
|
||||
break;
|
||||
}
|
||||
case "uiHover": {
|
||||
this.tone(880, 0.05, "sine", 0.05);
|
||||
break;
|
||||
|
||||
@@ -32,6 +32,8 @@ export const INITIAL_STATE = {
|
||||
achievements: {},
|
||||
activeTide: null,
|
||||
lastTideEnd: 0,
|
||||
constellation: [] as string[],
|
||||
pendingPerkChoices: null as string[] | null,
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// 回响星核 / Echo Nexus — 星图天文台:飞升后元进程(Meta-Progression)
|
||||
// 设计动机:回应 Issue #1「就一直连连看?」——飞升后玩家从 3 个随机星座天赋中选 1,
|
||||
// 跨周目永久生效。增加策略选择 + 视觉变化(动态星图)。
|
||||
import type { GameState } from "./types";
|
||||
|
||||
/** 星座天赋类别(6 类,每类 3 个天赋 = 18 个) */
|
||||
export type ConstellationCategory =
|
||||
| "mining" // 翠 - 采矿
|
||||
| "decoding" // 玫 - 解码
|
||||
| "expedition" // 琥 - 探险
|
||||
| "contact" // 紫 - 接触
|
||||
| "economy" // 灰 - 经济
|
||||
| "cosmic"; // 金 - 宇宙
|
||||
|
||||
/** 天赋效果(聚合到 recomputeStats 或具体动作处) */
|
||||
export interface ConstellationPerk {
|
||||
id: string;
|
||||
category: ConstellationCategory;
|
||||
name: string;
|
||||
desc: string;
|
||||
/** 顺序序号(同类别 1/2/3,决定星座内的位置) */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 类别可视化(用于 Canvas 与 CSS) */
|
||||
export const CONSTELLATION_CATEGORY_META: Record<
|
||||
ConstellationCategory,
|
||||
{ name: string; color: string; glow: string; hex: string; icon: string; desc: string }
|
||||
> = {
|
||||
mining: { name: "永动矿脉", color: "emerald", glow: "rgba(52,211,153,0.55)", hex: "#34d399", icon: "Pickaxe", desc: "晶体产能与仓储" },
|
||||
decoding: { name: "光谱矩阵", color: "rose", glow: "rgba(251,113,133,0.55)", hex: "#fb7185", icon: "ScanLine", desc: "解码效率与脉冲" },
|
||||
expedition: { name: "远征星图", color: "amber", glow: "rgba(251,191,36,0.55)", hex: "#fbbf24", icon: "Rocket", desc: "探险力与生命" },
|
||||
contact: { name: "接触回响", color: "fuchsia", glow: "rgba(232,121,249,0.55)", hex: "#e879f9", icon: "Sparkles", desc: "接触进度与飞升" },
|
||||
economy: { name: "虚空市场", color: "slate", glow: "rgba(148,163,184,0.55)", hex: "#94a3b8", icon: "Coins", desc: "经济与离线" },
|
||||
cosmic: { name: "宇宙回响", color: "amber", glow: "rgba(251,191,36,0.55)", hex: "#fcd34d", icon: "Star", desc: "全局综合增益" },
|
||||
};
|
||||
|
||||
/** 18 个星座天赋(6 类 × 3) */
|
||||
export const CONSTELLATION_PERKS: ConstellationPerk[] = [
|
||||
// 翠 · 永动矿脉
|
||||
{ id: "c_min_1", category: "mining", order: 1, name: "永动钻头", desc: "晶体/秒 +20%" },
|
||||
{ id: "c_min_2", category: "mining", order: 2, name: "深井网络", desc: "仓库上限 +50%" },
|
||||
{ id: "c_min_3", category: "mining", order: 3, name: "谐振熔炉", desc: "主动脉冲威力 +50%" },
|
||||
// 玫 · 光谱矩阵
|
||||
{ id: "c_dec_1", category: "decoding", order: 1, name: "光谱记忆", desc: "洞见倍率 +15%" },
|
||||
{ id: "c_dec_2", category: "decoding", order: 2, name: "步幅延展", desc: "解码步数上限 +1" },
|
||||
{ id: "c_dec_3", category: "decoding", order: 3, name: "自动校准", desc: "自动解码周期 -3s" },
|
||||
// 琥 · 远征星图
|
||||
{ id: "c_exp_1", category: "expedition", order: 1, name: "维生护盾", desc: "探险生命 +25%" },
|
||||
{ id: "c_exp_2", category: "expedition", order: 2, name: "信标矩阵", desc: "探险力 +20%" },
|
||||
{ id: "c_exp_3", category: "expedition", order: 3, name: "能量共振", desc: "能量上限 +1" },
|
||||
// 紫 · 接触回响
|
||||
{ id: "c_con_1", category: "contact", order: 1, name: "接触共鸣", desc: "接触进度率 +20%" },
|
||||
{ id: "c_con_2", category: "contact", order: 2, name: "飞升加速", desc: "飞升所需接触 -10" },
|
||||
{ id: "c_con_3", category: "contact", order: 3, name: "蓝图回响", desc: "每个蓝图额外 +2% 全属性" },
|
||||
// 灰 · 虚空市场
|
||||
{ id: "c_eco_1", category: "economy", order: 1, name: "离线缓存", desc: "离线效率 +15%" },
|
||||
{ id: "c_eco_2", category: "economy", order: 2, name: "晶体富集", desc: "T2/T3 晶体出现率 +8%" },
|
||||
{ id: "c_eco_3", category: "economy", order: 3, name: "星潮引导", desc: "星潮间隙 -10s" },
|
||||
// 金 · 宇宙回响
|
||||
{ id: "c_cos_1", category: "cosmic", order: 1, name: "飞升礼包", desc: "每次飞升后获得 +30 晶体 / +5 洞见" },
|
||||
{ id: "c_cos_2", category: "cosmic", order: 2, name: "二周目经验", desc: "解码奖励 +10%" },
|
||||
{ id: "c_cos_3", category: "cosmic", order: 3, name: "全息共振", desc: "所有产能与脉冲 +8%" },
|
||||
];
|
||||
|
||||
/** 由 ID 取天赋 */
|
||||
export function getPerk(id: string): ConstellationPerk | undefined {
|
||||
return CONSTELLATION_PERKS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/** 聚合玩家已解锁天赋的总修饰器(供 recomputeStats / 具体动作使用) */
|
||||
export interface ConstellationModifiers {
|
||||
crystalsPerSecMult: number;
|
||||
crystalCapMult: number;
|
||||
pulsePowerMult: number;
|
||||
insightMultAdd: number;
|
||||
decodeStepsBonus: number;
|
||||
autoDecodeIntervalDeltaSec: number;
|
||||
expeditionHpMult: number;
|
||||
expeditionPowerMult: number;
|
||||
energyMaxBonus: number;
|
||||
contactRateMult: number;
|
||||
prestigeContactMinDelta: number;
|
||||
perBlueprintAllStatsPct: number;
|
||||
offlineEffBonus: number;
|
||||
t2t3BonusRate: number;
|
||||
tideGapDeltaSec: number;
|
||||
prestigeStartCrystals: number;
|
||||
prestigeStartInsights: number;
|
||||
decodeRewardMult: number;
|
||||
allProductionMult: number;
|
||||
}
|
||||
|
||||
export function constellationBonuses(perks: string[]): ConstellationModifiers {
|
||||
const owned = new Set(perks);
|
||||
const m: ConstellationModifiers = {
|
||||
crystalsPerSecMult: 1,
|
||||
crystalCapMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
insightMultAdd: 0,
|
||||
decodeStepsBonus: 0,
|
||||
autoDecodeIntervalDeltaSec: 0,
|
||||
expeditionHpMult: 1,
|
||||
expeditionPowerMult: 1,
|
||||
energyMaxBonus: 0,
|
||||
contactRateMult: 1,
|
||||
prestigeContactMinDelta: 0,
|
||||
perBlueprintAllStatsPct: 0,
|
||||
offlineEffBonus: 0,
|
||||
t2t3BonusRate: 0,
|
||||
tideGapDeltaSec: 0,
|
||||
prestigeStartCrystals: 0,
|
||||
prestigeStartInsights: 0,
|
||||
decodeRewardMult: 1,
|
||||
allProductionMult: 1,
|
||||
};
|
||||
if (owned.has("c_min_1")) m.crystalsPerSecMult += 0.2;
|
||||
if (owned.has("c_min_2")) m.crystalCapMult += 0.5;
|
||||
if (owned.has("c_min_3")) m.pulsePowerMult += 0.5;
|
||||
if (owned.has("c_dec_1")) m.insightMultAdd += 0.15;
|
||||
if (owned.has("c_dec_2")) m.decodeStepsBonus += 1;
|
||||
if (owned.has("c_dec_3")) m.autoDecodeIntervalDeltaSec -= 3;
|
||||
if (owned.has("c_exp_1")) m.expeditionHpMult += 0.25;
|
||||
if (owned.has("c_exp_2")) m.expeditionPowerMult += 0.2;
|
||||
if (owned.has("c_exp_3")) m.energyMaxBonus += 1;
|
||||
if (owned.has("c_con_1")) m.contactRateMult += 0.2;
|
||||
if (owned.has("c_con_2")) m.prestigeContactMinDelta -= 10;
|
||||
if (owned.has("c_con_3")) m.perBlueprintAllStatsPct += 2;
|
||||
if (owned.has("c_eco_1")) m.offlineEffBonus += 0.15;
|
||||
if (owned.has("c_eco_2")) m.t2t3BonusRate += 0.08;
|
||||
if (owned.has("c_eco_3")) m.tideGapDeltaSec -= 10;
|
||||
if (owned.has("c_cos_1")) {
|
||||
m.prestigeStartCrystals += 30;
|
||||
m.prestigeStartInsights += 5;
|
||||
}
|
||||
if (owned.has("c_cos_2")) m.decodeRewardMult += 0.1;
|
||||
if (owned.has("c_cos_3")) m.allProductionMult += 0.08;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 生成 3 个可选天赋(从未拥有的中随机抽取,保证类别多样性) */
|
||||
export function rollPerkChoices(
|
||||
owned: string[],
|
||||
rng: () => number = Math.random
|
||||
): string[] {
|
||||
const ownedSet = new Set(owned);
|
||||
const pool = CONSTELLATION_PERKS.filter((p) => !ownedSet.has(p.id));
|
||||
if (pool.length === 0) return [];
|
||||
// 洗牌
|
||||
const shuffled = [...pool].sort(() => rng() - 0.5);
|
||||
// 取前 3 个,但尽量保证不同类别
|
||||
const picked: ConstellationPerk[] = [];
|
||||
const usedCats = new Set<ConstellationCategory>();
|
||||
// 第一轮:每类最多 1 个
|
||||
for (const p of shuffled) {
|
||||
if (picked.length >= 3) break;
|
||||
if (!usedCats.has(p.category)) {
|
||||
picked.push(p);
|
||||
usedCats.add(p.category);
|
||||
}
|
||||
}
|
||||
// 第二轮:补齐
|
||||
for (const p of shuffled) {
|
||||
if (picked.length >= 3) break;
|
||||
if (!picked.includes(p)) picked.push(p);
|
||||
}
|
||||
return picked.slice(0, 3).map((p) => p.id);
|
||||
}
|
||||
|
||||
/** 给定类别取该类别内已解锁的天赋 order 数 */
|
||||
export function countUnlockedInCategory(perks: string[], cat: ConstellationCategory): number {
|
||||
const owned = new Set(perks);
|
||||
return CONSTELLATION_PERKS.filter((p) => p.category === cat && owned.has(p.id)).length;
|
||||
}
|
||||
|
||||
/** 计算总进度(已解锁 / 18) */
|
||||
export function constellationProgress(perks: string[]): { unlocked: number; total: number } {
|
||||
return { unlocked: perks.length, total: CONSTELLATION_PERKS.length };
|
||||
}
|
||||
|
||||
/** 飞升所需接触进度(受天赋影响) */
|
||||
export function computePrestigeContactMin(state: GameState): number {
|
||||
const c = constellationBonuses(state.constellation ?? []);
|
||||
return Math.max(60, 100 + c.prestigeContactMinDelta);
|
||||
}
|
||||
|
||||
/** 星图布局:6 个星座中心点(极坐标转笛卡尔) */
|
||||
export function getConstellationLayout(centerX: number, centerY: number, radius: number) {
|
||||
const cats: ConstellationCategory[] = ["mining", "decoding", "expedition", "contact", "economy", "cosmic"];
|
||||
return cats.map((cat, i) => {
|
||||
// 六边形布局:每 60° 一个
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 2; // 顶部为第一个
|
||||
return {
|
||||
category: cat,
|
||||
cx: centerX + radius * Math.cos(angle),
|
||||
cy: centerY + radius * Math.sin(angle),
|
||||
meta: CONSTELLATION_CATEGORY_META[cat],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 取某类别内 3 个星点的局部坐标(相对星座中心) */
|
||||
export function getStarsInCategory(cat: ConstellationCategory, cx: number, cy: number, scale = 1) {
|
||||
const perks = CONSTELLATION_PERKS.filter((p) => p.category === cat).sort((a, b) => a.order - b.order);
|
||||
return perks.map((p, i) => {
|
||||
// 三角形排布:order 1 顶点,2/3 底部
|
||||
const localY = i === 0 ? -22 * scale : 14 * scale;
|
||||
const localX = i === 0 ? 0 : (i === 1 ? -18 * scale : 18 * scale);
|
||||
return {
|
||||
perk: p,
|
||||
x: cx + localX,
|
||||
y: cy + localY,
|
||||
};
|
||||
});
|
||||
}
|
||||
+44
-8
@@ -9,8 +9,9 @@ import {
|
||||
} from "./config";
|
||||
import { achievementBonuses } from "./achievements";
|
||||
import { getTideModifiers, type StarTide } from "./starTide";
|
||||
import { constellationBonuses, rollPerkChoices } from "./constellation";
|
||||
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星潮聚合计算产能字段 */
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
|
||||
export function recomputeStats(state: Partial<GameState>): {
|
||||
crystalsPerSec: number;
|
||||
crystalCap: number;
|
||||
@@ -25,6 +26,7 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
const bp = state.blueprints?.length ?? 0;
|
||||
const ach = achievementBonuses(state.achievements ?? {});
|
||||
const tideMod = getTideModifiers((state.activeTide as StarTide | null) ?? null);
|
||||
const cm = constellationBonuses(state.constellation ?? []);
|
||||
|
||||
let crystalsPerSec = INITIAL_STATE.crystalsPerSec;
|
||||
let crystalCap = INITIAL_STATE.crystalCap;
|
||||
@@ -67,15 +69,25 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
}
|
||||
}
|
||||
|
||||
// 飞升蓝图加成
|
||||
crystalsPerSec *= 1 + bp * PRESTIGE.perBlueprint.crystalsPerSecMult;
|
||||
insightMult *= 1 + bp * PRESTIGE.perBlueprint.insightMult;
|
||||
contactRateMult *= 1 + bp * PRESTIGE.perBlueprint.contactRateMult;
|
||||
// 飞升蓝图加成(+ 星图「蓝图回响」额外加成)
|
||||
const bpAllPct = cm.perBlueprintAllStatsPct / 100;
|
||||
crystalsPerSec *= 1 + (bp * (PRESTIGE.perBlueprint.crystalsPerSecMult + bpAllPct));
|
||||
insightMult *= 1 + (bp * (PRESTIGE.perBlueprint.insightMult + bpAllPct));
|
||||
contactRateMult *= 1 + (bp * (PRESTIGE.perBlueprint.contactRateMult + bpAllPct));
|
||||
|
||||
// 成就永久加成(跨周目)
|
||||
crystalsPerSec *= 1 + ach.crystalsPerSecPct / 100;
|
||||
insightMult *= 1 + ach.insightPct / 100;
|
||||
|
||||
// 星图天赋永久加成(跨周目)
|
||||
crystalsPerSec *= cm.crystalsPerSecMult * cm.allProductionMult;
|
||||
crystalCap *= cm.crystalCapMult;
|
||||
pulsePower *= cm.pulsePowerMult * cm.allProductionMult;
|
||||
offlineEff = Math.min(1, offlineEff + cm.offlineEffBonus);
|
||||
insightMult += cm.insightMultAdd;
|
||||
contactRateMult *= cm.contactRateMult;
|
||||
decodeStepsBonus += cm.decodeStepsBonus;
|
||||
|
||||
// 星潮瞬时修饰(contactRateMult 与 insightMult 进缓存;产能/脉冲/探险在用点处即时乘)
|
||||
insightMult += tideMod.insightMultAdd;
|
||||
contactRateMult *= tideMod.contactRateMult;
|
||||
@@ -108,7 +120,7 @@ export function computeNewBlueprints(state: GameState): number {
|
||||
return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length);
|
||||
}
|
||||
|
||||
/** 执行飞升:重置数值,保留蓝图与图谱与成就与部分技术 */
|
||||
/** 执行飞升:重置数值,保留蓝图/图谱/成就/星图天赋,并触发天赋选择 */
|
||||
export function performPrestige(state: GameState): GameState {
|
||||
const newBp = computeNewBlueprints(state);
|
||||
const blueprints = [
|
||||
@@ -116,10 +128,15 @@ export function performPrestige(state: GameState): GameState {
|
||||
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
|
||||
].slice(0, PRESTIGE.maxBlueprints);
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, theme/sound, expeditionLog
|
||||
// 星图天赋:飞升后获得一次 3 选 1 的机会
|
||||
const choices = rollPerkChoices(state.constellation ?? []);
|
||||
// 星图「飞升礼包」天赋的初始资源补偿
|
||||
const cm = constellationBonuses(state.constellation ?? []);
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, constellation, theme/sound, expeditionLog
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮
|
||||
const fresh = createInitialState();
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements });
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation });
|
||||
return {
|
||||
...fresh,
|
||||
fragments: state.fragments,
|
||||
@@ -127,6 +144,11 @@ export function performPrestige(state: GameState): GameState {
|
||||
ascensions: state.ascensions + 1,
|
||||
blueprints,
|
||||
achievements: state.achievements,
|
||||
constellation: state.constellation,
|
||||
pendingPerkChoices: choices.length > 0 ? choices : null,
|
||||
// 飞升礼包补偿
|
||||
crystals: cm.prestigeStartCrystals,
|
||||
insights: cm.prestigeStartInsights,
|
||||
theme: state.theme,
|
||||
soundOn: state.soundOn,
|
||||
createdAt: state.createdAt,
|
||||
@@ -194,4 +216,18 @@ export function rollCrystalTier(rng: () => number = Math.random): CrystalTier {
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** 生成一颗晶体(带 T2/T3 概率补偿,受星图「晶体富集」影响) */
|
||||
export function rollCrystalTierWithBonus(t2t3Bonus: number, rng: () => number = Math.random): CrystalTier {
|
||||
if (t2t3Bonus <= 0) return rollCrystalTier(rng);
|
||||
const r = rng();
|
||||
// 基础:T1=0.7, T2=0.25, T3=0.05
|
||||
// 补偿:从 T1 中按 bonus 比例挪到 T2/T3
|
||||
const shift = Math.min(0.5, t2t3Bonus); // 上限 50%
|
||||
const t1 = 0.7 - 0.7 * shift;
|
||||
const t2 = 0.25 + 0.25 * shift * 0.7;
|
||||
if (r < t1) return 1;
|
||||
if (r < t1 + t2) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export type { ResonanceColor };
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ExpeditionResult,
|
||||
GameState,
|
||||
} from "./types";
|
||||
import { constellationBonuses } from "./constellation";
|
||||
|
||||
/** 简单可复现随机(mulberry32) */
|
||||
function makeRng(seed: number) {
|
||||
@@ -173,7 +174,7 @@ export function generateExpedition(
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算探险力(由技术树 + 飞升蓝图) */
|
||||
/** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */
|
||||
export function computeExpeditionPower(state: GameState): number {
|
||||
let power = EXPEDITION_CONFIG.basePower;
|
||||
// 探险分支技术加成
|
||||
@@ -185,6 +186,9 @@ export function computeExpeditionPower(state: GameState): number {
|
||||
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
|
||||
// 飞升周目加成
|
||||
power *= 1 + (state.ascensions ?? 0) * 0.15;
|
||||
// 星图「信标矩阵」加成
|
||||
const cm = constellationBonuses(state.constellation ?? []);
|
||||
power *= cm.expeditionPowerMult;
|
||||
return Math.round(power);
|
||||
}
|
||||
|
||||
@@ -193,6 +197,9 @@ export function computeExpeditionHp(state: GameState): number {
|
||||
let hp = EXPEDITION_CONFIG.baseHp;
|
||||
hp += (state.tech?.exp_2 ?? 0) * 20;
|
||||
hp += (state.ascensions ?? 0) * 10;
|
||||
// 星图「维生护盾」加成
|
||||
const cm = constellationBonuses(state.constellation ?? []);
|
||||
hp = Math.round(hp * cm.expeditionHpMult);
|
||||
return hp;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ export interface GameState {
|
||||
activeTide: import("./starTide").StarTide | null;
|
||||
lastTideEnd: number;
|
||||
|
||||
// 星图天文台(v0.3.1 元进程)
|
||||
constellation: string[]; // 已解锁天赋 ID 列表
|
||||
pendingPerkChoices: string[] | null; // 飞升后待选择(3 选 1)
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
|
||||
+83
-20
@@ -22,7 +22,7 @@ import {
|
||||
createInitialState,
|
||||
recomputeStats,
|
||||
decodeRewards,
|
||||
rollCrystalTier,
|
||||
rollCrystalTierWithBonus,
|
||||
computeNewBlueprints,
|
||||
performPrestige,
|
||||
} from "@/lib/game/engine";
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
type StarTide,
|
||||
type TideType,
|
||||
} from "@/lib/game/starTide";
|
||||
import {
|
||||
getPerk,
|
||||
constellationBonuses,
|
||||
rollPerkChoices,
|
||||
} from "@/lib/game/constellation";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -86,6 +91,10 @@ interface GameActions {
|
||||
// 飞升
|
||||
doPrestige: () => { newBp: number } | null;
|
||||
|
||||
// 星图天文台
|
||||
chooseConstellationPerk: (perkId: string) => boolean;
|
||||
rerollPerkChoices: () => void;
|
||||
|
||||
// 成就
|
||||
checkAchievements: () => Achievement[];
|
||||
consumeAchievementQueue: () => Achievement[];
|
||||
@@ -118,6 +127,7 @@ function syncStats(state: Partial<GameState>) {
|
||||
insightMult: s.insightMult,
|
||||
contactRateMult: s.contactRateMult,
|
||||
autoDecode: s.autoDecode,
|
||||
decodeStepsBonus: s.decodeStepsBonus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,9 +172,14 @@ export const useGameStore = create<Store>()(
|
||||
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
});
|
||||
}
|
||||
// 兼容旧存档:补全 achievements / 星潮 字段
|
||||
// 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||||
const achievements = s.achievements ?? {};
|
||||
const activeTide = s.activeTide ?? null;
|
||||
const constellation = s.constellation ?? [];
|
||||
const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||||
// 星图「能量共振」天赋 +1 能量上限
|
||||
const cm = constellationBonuses(constellation);
|
||||
const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||||
// lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||||
const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||||
// 若旧存档有已过期的星潮,清掉
|
||||
@@ -182,16 +197,19 @@ export const useGameStore = create<Store>()(
|
||||
achievements,
|
||||
activeTide: tide,
|
||||
lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide }),
|
||||
constellation,
|
||||
pendingPerkChoices,
|
||||
energyMax,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||||
});
|
||||
} else {
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide }) });
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||||
}
|
||||
},
|
||||
|
||||
loadOnline: () => {
|
||||
const s = get();
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide }) });
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
|
||||
},
|
||||
|
||||
hardReset: () => {
|
||||
@@ -201,6 +219,9 @@ export const useGameStore = create<Store>()(
|
||||
tickTide: (now) => {
|
||||
const s = get();
|
||||
const tide = s.activeTide;
|
||||
// 星图「星潮引导」减少间隙
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
|
||||
// 1) 检查当前星潮是否结束
|
||||
if (tide && now >= tide.endsAt) {
|
||||
const endedType = tide.type;
|
||||
@@ -217,7 +238,7 @@ export const useGameStore = create<Store>()(
|
||||
lastTideEnd: now,
|
||||
insights: newInsights,
|
||||
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null }),
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
@@ -227,7 +248,7 @@ export const useGameStore = create<Store>()(
|
||||
const since = now - s.lastTideEnd;
|
||||
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||||
const need = firstStart ? TIDE_CONFIG.firstDelay : TIDE_CONFIG.gap;
|
||||
const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||||
if (since >= need) {
|
||||
const type = rollTide();
|
||||
const newTide: StarTide = {
|
||||
@@ -240,7 +261,7 @@ export const useGameStore = create<Store>()(
|
||||
set({
|
||||
activeTide: newTide,
|
||||
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide }),
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
@@ -280,7 +301,9 @@ export const useGameStore = create<Store>()(
|
||||
now - lastSpawn > spawnInterval &&
|
||||
pending.length < CRYSTAL_SPAWN.maxPending
|
||||
) {
|
||||
const tier = rollCrystalTier();
|
||||
// 星图「晶体富集」提升 T2/T3 概率
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||||
const crystal: Crystal = {
|
||||
id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
tier,
|
||||
@@ -351,13 +374,15 @@ export const useGameStore = create<Store>()(
|
||||
const res = tryClickNode(puzzle, nodeId);
|
||||
if (res.ok) {
|
||||
if (res.finished) {
|
||||
// 结算奖励(星潮解码奖励修饰)
|
||||
// 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||||
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
const rewards = {
|
||||
crystals: Math.round(base.crystals * tideMod.decodeRewardMult),
|
||||
insights: Math.round(base.insights * tideMod.decodeRewardMult),
|
||||
contact: +(base.contact * tideMod.decodeRewardMult).toFixed(2),
|
||||
crystals: Math.round(base.crystals * finalMult),
|
||||
insights: Math.round(base.insights * finalMult),
|
||||
contact: +(base.contact * finalMult).toFixed(2),
|
||||
};
|
||||
const newTotal = s.totalDecoded + 1;
|
||||
const newContact = Math.min(100, s.contact + rewards.contact);
|
||||
@@ -422,17 +447,21 @@ export const useGameStore = create<Store>()(
|
||||
const s = get();
|
||||
if (!s.autoDecode) return;
|
||||
const now = Date.now();
|
||||
if (now - s._lastAutoDecode < 12000) return;
|
||||
// 星图「自动校准」减少自动解码周期
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
|
||||
if (now - s._lastAutoDecode < interval) return;
|
||||
// 找一颗 T1 晶体自动解码
|
||||
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
if (idx < 0) return;
|
||||
const crystal = s.pendingCrystals[idx];
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||||
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
const rewards = {
|
||||
crystals: Math.round(base.crystals * tideMod.decodeRewardMult),
|
||||
insights: Math.round(base.insights * tideMod.decodeRewardMult),
|
||||
contact: +(base.contact * tideMod.decodeRewardMult).toFixed(2),
|
||||
crystals: Math.round(base.crystals * finalMult),
|
||||
insights: Math.round(base.insights * finalMult),
|
||||
contact: +(base.contact * finalMult).toFixed(2),
|
||||
};
|
||||
const newTotal = s.totalDecoded + 1;
|
||||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
@@ -459,7 +488,7 @@ export const useGameStore = create<Store>()(
|
||||
set({
|
||||
insights: s.insights - node.cost,
|
||||
tech: newTech,
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide }),
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
@@ -572,9 +601,13 @@ export const useGameStore = create<Store>()(
|
||||
if (s.contact < CONTACT.prestigeMin) return null;
|
||||
const newBp = computeNewBlueprints(s);
|
||||
const next = performPrestige(s);
|
||||
// 星图「能量共振」提升上限
|
||||
const cm = constellationBonuses(next.constellation ?? []);
|
||||
const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
set({
|
||||
...next,
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide }),
|
||||
energyMax,
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
|
||||
_lastAutoDecode: Date.now(),
|
||||
_lastSpawn: Date.now(),
|
||||
_combo: 0,
|
||||
@@ -584,6 +617,31 @@ export const useGameStore = create<Store>()(
|
||||
return { newBp };
|
||||
},
|
||||
|
||||
chooseConstellationPerk: (perkId) => {
|
||||
const s = get();
|
||||
if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||||
const perk = getPerk(perkId);
|
||||
if (!perk) return false;
|
||||
if (s.constellation?.includes(perkId)) return false;
|
||||
const newConstellation = [...(s.constellation ?? []), perkId];
|
||||
const cm = constellationBonuses(newConstellation);
|
||||
const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
set({
|
||||
constellation: newConstellation,
|
||||
pendingPerkChoices: null,
|
||||
energyMax,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
rerollPerkChoices: () => {
|
||||
const s = get();
|
||||
if (!s.pendingPerkChoices) return;
|
||||
const choices = rollPerkChoices(s.constellation ?? []);
|
||||
if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||||
},
|
||||
|
||||
checkAchievements: () => {
|
||||
const s = get();
|
||||
const newlyUnlocked: Achievement[] = [];
|
||||
@@ -611,7 +669,7 @@ export const useGameStore = create<Store>()(
|
||||
insights,
|
||||
contact,
|
||||
...(statsDirty
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide })
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
|
||||
: {}),
|
||||
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||||
});
|
||||
@@ -653,3 +711,8 @@ export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||||
}
|
||||
|
||||
export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||||
|
||||
// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
(window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||||
}
|
||||
|
||||
+32
@@ -61,6 +61,38 @@
|
||||
- **QA**:agent-browser 全流程通过(触发/指示器/叠层/寂静期补偿 50→81 洞见);VLM 视觉确认;lint 零错误
|
||||
- 详见 docs/repo/docs/07-星潮事件系统-v0.3.md
|
||||
|
||||
### v0.3.1 星图天文台 · 元进程天赋系统(本轮完成)
|
||||
- **动机**:再次回应 Issue #1「就一直连连看?」——加入 Slay the Spire 式飞升后 3 选 1 天赋 draft,跨周目永久生效,增加策略深度 + 视觉变化
|
||||
- **核心模块**(`src/lib/game/constellation.ts`):
|
||||
- 6 大星座 × 3 颗星 = **18 个天赋**:永动矿脉(翠)/光谱矩阵(玫)/远征星图(琥)/接触回响(紫)/虚空市场(灰)/宇宙回响(金)
|
||||
- `constellationBonuses()` 聚合 19 项修饰器(产能/上限/脉冲/洞见/解码步数/自动解码周期/探险生命/探险力/能量上限/接触率/飞升门槛/蓝图全属性/离线效率/T2T3概率/星潮间隙/飞升礼包/解码奖励/全局产能)
|
||||
- `rollPerkChoices()` 智能抽取:保证 3 个来自不同类别
|
||||
- `getConstellationLayout()` 六边形布局 + `getStarsInCategory()` 三角形排布
|
||||
- **状态扩展**(`types.ts` + `config.ts`):`GameState.constellation: string[]` + `pendingPerkChoices: string[] | null`
|
||||
- **引擎接入**(`engine.ts`):`recomputeStats` 聚合三层加成(技术+蓝图+成就+星图);`performPrestige` 触发天赋选择 + 飞升礼包补偿;新增 `rollCrystalTierWithBonus()`
|
||||
- **探险系统接入**(`expedition.ts`):`computeExpeditionPower` + `computeExpeditionHp` 应用星座修饰器
|
||||
- **Store 接入**(`gameStore.ts`):
|
||||
- 新增 `chooseConstellationPerk(perkId)` + `rerollPerkChoices()` 动作
|
||||
- `tickTide` 应用星潮间隙缩减;`autoDecodeTick` 应用周期缩减;`tick` 应用 T2/T3 概率补偿;`clickNode`/`autoDecodeTick` 应用解码奖励倍率
|
||||
- 旧存档兼容:init() 补全 constellation=[] / pendingPerkChoices=null / energyMax 加成
|
||||
- **UI 组件**:
|
||||
- `ConstellationPanel.tsx`:Canvas 动态星图(六边形 6 星座 × 3 星点,闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例 + 已解锁列表
|
||||
- `ConstellationDialog.tsx`:飞升后自动弹出,3 卡片 draft + 重新抽取 + 选中粒子动画
|
||||
- `PrestigeDialog.tsx`:增加「星图觉醒预告」卡片
|
||||
- `page.tsx`:6 列标签栏(探险/技术/星图/图谱/成就/统计)+ 顶部「觉醒」按钮(pendingPerkChoices 存在时脉冲提示)+ 统计面板加星图天赋条目
|
||||
- **音频**:新增 `constellation` SFX(上升琶音 C5→E5→G5→C6 + 高频闪光 2093Hz)
|
||||
- **成就**:新增 2 项 —— `ach_constellation_1`「星图初绘」(+30 洞见·产能+5%) / `ach_constellation_6`「六分星辉」(产能+12%·洞见+12%)
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 星图标签渲染正确,6 类别图例齐全
|
||||
- 设置 pendingPerkChoices 后对话框自动弹出,3 卡片显示不同类别
|
||||
- 点击天赋 → constellation 数组增加,crystalsPerSec 提升,pending 清空,对话框关闭
|
||||
- 重新抽取 → 3 张新卡片(不同类别)
|
||||
- 多天赋叠加:c_min_1 + c_con_1 → crystalsPerSec 1.416, contactRateMult 1.2
|
||||
- 6 类别各 1 天赋 → 自动解锁「六分星辉」成就(Toast 弹出)
|
||||
- VLM 视觉确认:星图 Canvas 渲染正确,单星点亮(绿色永动矿脉)
|
||||
- **lint 零错误;HTTP 200;编译 < 200ms**
|
||||
- 详见 docs/repo/docs/08-星图天文台系统-v0.3.1.md
|
||||
|
||||
### 进行中
|
||||
- [ ] 持续迭代:云存档+排行榜(v0.4)、全5纪元叙事(v0.5)、socket 多人同步星潮(后续)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user