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:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user