diff --git a/docs/repo b/docs/repo index 27bb1555e..433c19cc7 160000 --- a/docs/repo +++ b/docs/repo @@ -1 +1 @@ -Subproject commit 27bb1555e5a2b7915b253cb1cab4aeb799627a07 +Subproject commit 433c19cc7350885278263f8a2a11ab251e86a455 diff --git a/download/qa-v03-home.png b/download/qa-v03-home.png new file mode 100644 index 000000000..7469eb245 Binary files /dev/null and b/download/qa-v03-home.png differ diff --git a/download/qa-v031-constellation.png b/download/qa-v031-constellation.png new file mode 100644 index 000000000..a5a8f580f Binary files /dev/null and b/download/qa-v031-constellation.png differ diff --git a/download/qa-v031-dialog.png b/download/qa-v031-dialog.png new file mode 100644 index 000000000..314ab986c Binary files /dev/null and b/download/qa-v031-dialog.png differ diff --git a/download/qa-v031-starmap-unlocked.png b/download/qa-v031-starmap-unlocked.png new file mode 100644 index 000000000..898a544e5 Binary files /dev/null and b/download/qa-v031-starmap-unlocked.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index bc10d7600..47e0fe89c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {

回响星核

- ECHO NEXUS · v0.3 + ECHO NEXUS · v0.3.1

+ {hasPendingPerk && ( + + )} {canPrestige && (
- {/* 标签面板:探险 / 技术 / 图谱 / 成就 / 统计 */} + {/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 统计 */}
- - + + 探险 @@ -187,6 +210,13 @@ export default function Page() { 技术 + + + 星图 + {hasPendingPerk && ( + + )} + 图谱 @@ -209,6 +239,9 @@ export default function Page() { + + + @@ -272,6 +305,7 @@ export default function Page() { + @@ -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)}` }, diff --git a/src/components/game/ConstellationDialog.tsx b/src/components/game/ConstellationDialog.tsx new file mode 100644 index 000000000..3cb5d6a00 --- /dev/null +++ b/src/components/game/ConstellationDialog.tsx @@ -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(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 ( + + + + + + 星图觉醒 + + + 飞升带来的回响在星图上凝聚。从 3 道星辉中选择 1 道,永久获得其加成。 +
+ 当前已觉醒 {progress.unlocked}/{progress.total} +
+
+ +
+ {pendingChoices.map((perkId) => { + const perk = getPerk(perkId); + if (!perk) return null; + const meta = CONSTELLATION_CATEGORY_META[perk.category]; + const isPicked = picked === perkId; + return ( + + ); + })} +
+ +
+ + + ✦ 选择后永久保留,跨周目生效 + +
+
+
+ ); +} diff --git a/src/components/game/ConstellationPanel.tsx b/src/components/game/ConstellationPanel.tsx new file mode 100644 index 000000000..116c996fe --- /dev/null +++ b/src/components/game/ConstellationPanel.tsx @@ -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(null); + const containerRef = useRef(null); + const [size, setSize] = useState({ w: 360, h: 360 }); + const [hover, setHover] = useState(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) => { + 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 ( +
+ {/* 顶部进度 */} +
+
+ + 星图天赋 + + {progress.unlocked}/{progress.total} + +
+ 每次飞升可解锁 1 个 +
+ + {/* Canvas 星图 */} +
+ + {/* Hover tooltip */} + {hover && ( +
+
+ {hover.unlocked ? "★ " : "☆ "} + {hover.perk.name} +
+
{hover.perk.desc}
+
+ {CONSTELLATION_CATEGORY_META[hover.perk.category].name} · 序{hover.perk.order} +
+
+ )} +
+ + {/* 类别图例 + 当前总修饰 */} +
+ {(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 ( +
0 ? { borderColor: `${meta.hex}40` } : undefined} + > + 0 ? `0 0 4px ${meta.hex}` : "none" }} + /> + {meta.name} + 0 ? meta.hex : "rgba(148,163,184,0.5)" }}> + {cnt}/{total} + +
+ ); + })} +
+ + {/* 已解锁天赋列表 */} +
+ {constellation.length === 0 ? ( +
+
+ 尚未解锁任何天赋。
+ 累积接触进度至 100%,发起「飞升」即可在 3 个随机天赋中选 1。 +
+ ) : ( +
+ {constellation.map((id) => { + const perk = getPerk(id); + if (!perk) return null; + const meta = CONSTELLATION_CATEGORY_META[perk.category]; + return ( +
+ +
+
{perk.name}
+
{perk.desc}
+
+ {meta.name} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/game/PrestigeDialog.tsx b/src/components/game/PrestigeDialog.tsx index 2d2857201..7f5b1ef9f 100644 --- a/src/components/game/PrestigeDialog.tsx +++ b/src/components/game/PrestigeDialog.tsx @@ -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 ( @@ -83,13 +85,32 @@ export function PrestigeDialog({
- 保留:记忆图谱、累计解码数、飞升次数、蓝图 + 保留:记忆图谱、累计解码数、飞升次数、蓝图、星图天赋
重置:晶体、洞见、技术树、接触进度、待解码晶体
+ + {/* 星图觉醒预告 */} +
+
+ +
+
星图觉醒预告
+
+ 飞升后将从 3 道随机天赋中选 1,永久获得加成 + {cProgress.unlocked < cProgress.total && ( + · 当前 {cProgress.unlocked}/{cProgress.total} + )} +
+
+
+ {cProgress.unlocked < cProgress.total ? "✦ 可觉醒" : "✧ 已圆满"} +
+
+
diff --git a/src/lib/game/achievements.ts b/src/lib/game/achievements.ts index 9b9954e2d..edc759917 100644 --- a/src/lib/game/achievements.ts +++ b/src/lib/game/achievements.ts @@ -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%", + }, ]; /** 计算成就提供的永久加成(跨周目保留) */ diff --git a/src/lib/game/audio.ts b/src/lib/game/audio.ts index 1ce92a5e9..76d9bf2e3 100644 --- a/src/lib/game/audio.ts +++ b/src/lib/game/audio.ts @@ -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; diff --git a/src/lib/game/config.ts b/src/lib/game/config.ts index a460e4658..7f5b8f00e 100644 --- a/src/lib/game/config.ts +++ b/src/lib/game/config.ts @@ -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, }; diff --git a/src/lib/game/constellation.ts b/src/lib/game/constellation.ts new file mode 100644 index 000000000..19ba8886c --- /dev/null +++ b/src/lib/game/constellation.ts @@ -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(); + // 第一轮:每类最多 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, + }; + }); +} diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index 5c3aa9f9e..fabadc703 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -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): { crystalsPerSec: number; crystalCap: number; @@ -25,6 +26,7 @@ export function recomputeStats(state: Partial): { 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): { } } - // 飞升蓝图加成 - 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 }; diff --git a/src/lib/game/expedition.ts b/src/lib/game/expedition.ts index 57f12d2ef..ae529f906 100644 --- a/src/lib/game/expedition.ts +++ b/src/lib/game/expedition.ts @@ -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; } diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index db31c7331..d8a13d46c 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -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; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index a7acae38f..bfd8aca3c 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -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) { insightMult: s.insightMult, contactRateMult: s.contactRateMult, autoDecode: s.autoDecode, + decodeStepsBonus: s.decodeStepsBonus, }; } @@ -162,9 +172,14 @@ export const useGameStore = create()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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; +} diff --git a/worklog.md b/worklog.md index b480e0e55..7ee3d5130 100644 --- a/worklog.md +++ b/worklog.md @@ -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 多人同步星潮(后续)