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

- 新增 src/lib/game/constellation.ts:6 类别 × 3 天赋 = 18 个永久天赋,
  constellationBonuses() 聚合 19 项修饰器,rollPerkChoices() 保证不同类别
- 状态扩展:GameState.constellation / pendingPerkChoices
- engine.ts: recomputeStats 聚合三层加成(技术+蓝图+成就+星图),
  performPrestige 触发天赋选择 + 飞升礼包补偿,新增 rollCrystalTierWithBonus
- expedition.ts: 探险力/生命应用星座修饰器
- gameStore.ts: 新增 chooseConstellationPerk + rerollPerkChoices 动作,
  全链路接入(tickTide/autoDecodeTick/clickNode 等用点)
- 新增 ConstellationPanel.tsx: Canvas 动态星图(六边形 6 星座 × 3 星点,
  闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例
- 新增 ConstellationDialog.tsx: 飞升后自动弹出,3 卡片 draft + 重新抽取
- PrestigeDialog: 增加「星图觉醒预告」卡片
- page.tsx: 6 列标签栏 + 顶部「觉醒」按钮 + 统计面板加星图天赋
- audio.ts: 新增 constellation SFX(上升琶音 + 高频闪光)
- achievements.ts: 新增「星图初绘」「六分星辉」2 项成就
- QA: agent-browser + VLM 全流程通过;lint 零错误;编译 < 200ms
- 二次回应 Issue #1:从「连连看」扩展为 6 大玩法层 + 元进程 draft
This commit is contained in:
2026-06-23 14:12:37 +00:00
parent f3d2e53e4f
commit 9f4d839c6b
18 changed files with 1002 additions and 36 deletions
+83 -20
View File
@@ -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;
}