## 玩法拓展(回应用户: 玩法太单调/全是点点点) - 新增「深空巡航」Canvas 2D 全屏实时玩法 - 飞船操控(WASD/方向键/移动端虚拟摇杆) - 实时躲避陨石/虚空风暴,收集晶体/洞见/信标 - 到达星门通关,护盾归零失败 - 3层视差星空 + 粒子系统(尾焰/收集/碰撞/烟花) + 辉光 + 屏幕震动 - 奖励同步主游戏(grantCruiseReward) - 独立 localStorage 记录最高分/通关数 ## UI 修复(回应用户: 解析球左上偏移/成就小屏重叠) - DecodeArray 节点: 移除 Tailwind translate 与 inline transform 冲突 - AchievementsPanel: 移除固定 max-h, 改 flex-1 min-h-0 自适应 - page.tsx 布局: 小屏减小 min-h, 避免挤压重叠 ## 集成 - gameStore 新增 grantCruiseReward action - header 新增 amber 主题「巡航」按钮 + 全屏 CruiseMode 渲染 ## 文件 - src/lib/game/cruise.ts (新增, ~1008行) - src/components/game/CruiseMode.tsx (新增, ~1350行) - src/app/page.tsx (集成巡航入口) - src/store/gameStore.ts (grantCruiseReward action) - src/components/game/DecodeArray.tsx (节点偏移修复) - src/components/game/AchievementsPanel.tsx (小屏重叠修复) QA: lint 零错误; agent-browser+VLM 验证主界面无偏移+巡航玩法正常运行
843 lines
38 KiB
Plaintext
843 lines
38 KiB
Plaintext
1→"use client";
|
||
2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
|
||
3→import { create } from "zustand";
|
||
4→import { persist, createJSONStorage } from "zustand/middleware";
|
||
5→import type {
|
||
6→ GameState,
|
||
7→ Crystal,
|
||
8→ CrystalTier,
|
||
9→ DecodePuzzle,
|
||
10→ ExpeditionResult,
|
||
11→} from "@/lib/game/types";
|
||
12→import {
|
||
13→ INITIAL_STATE,
|
||
14→ TECH_TREE,
|
||
15→ CRYSTAL_VALUE,
|
||
16→ CONTACT,
|
||
17→ CRYSTAL_SPAWN,
|
||
18→ FRAGMENTS,
|
||
19→ PRESTIGE,
|
||
20→} from "@/lib/game/config";
|
||
21→import {
|
||
22→ createInitialState,
|
||
23→ recomputeStats,
|
||
24→ decodeRewards,
|
||
25→ rollCrystalTierWithBonus,
|
||
26→ computeNewBlueprints,
|
||
27→ performPrestige,
|
||
28→} from "@/lib/game/engine";
|
||
29→import {
|
||
30→ generatePuzzle,
|
||
31→ tryClickNode,
|
||
32→ isSolvable,
|
||
33→ resetPuzzle as resetPuz,
|
||
34→} from "@/lib/game/decode";
|
||
35→import {
|
||
36→ generateExpedition,
|
||
37→ resolveNode,
|
||
38→ advanceExpedition,
|
||
39→ computeExpeditionPower,
|
||
40→ computeExpeditionHp,
|
||
41→ computeEnergyRegen,
|
||
42→ EXPEDITION_CONFIG,
|
||
43→} from "@/lib/game/expedition";
|
||
44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||
45→import {
|
||
46→ TIDE_CONFIG,
|
||
47→ rollTide,
|
||
48→ getTideModifiers,
|
||
49→ computeSilenceCompensation,
|
||
50→ type StarTide,
|
||
51→ type TideType,
|
||
52→} from "@/lib/game/starTide";
|
||
53→import {
|
||
54→ getPerk,
|
||
55→ constellationBonuses,
|
||
56→ rollPerkChoices,
|
||
57→} from "@/lib/game/constellation";
|
||
58→import {
|
||
59→ migrateChronicleFields,
|
||
60→ withPerks,
|
||
61→} from "@/lib/game/chronicle";
|
||
62→import {
|
||
63→ generateDailyChallenge,
|
||
64→ loadDailyProgress,
|
||
65→ addBeaconProgress,
|
||
66→ type BeaconDailyChallenge,
|
||
67→ type BeaconDailyProgress,
|
||
68→} from "@/lib/game/beacon";
|
||
69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||
70→
|
||
71→interface GameActions {
|
||
72→ // 生命周期
|
||
73→ init: () => void;
|
||
74→ loadOnline: () => void;
|
||
75→ hardReset: () => void;
|
||
76→
|
||
77→ // 主循环
|
||
78→ tick: (now: number) => void;
|
||
79→ pulse: () => { gain: number; combo: number } | null;
|
||
80→
|
||
81→ // 星潮
|
||
82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||
83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||
84→
|
||
85→ // 解码
|
||
86→ startDecode: (crystalId: string) => void;
|
||
87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||
88→ undoStep: () => void;
|
||
89→ retryPuzzle: () => void;
|
||
90→ abandonPuzzle: () => void;
|
||
91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
|
||
92→ autoDecodeTick: () => void;
|
||
93→
|
||
94→ // 探险
|
||
95→ startExpedition: () => { ok: boolean; reason?: string };
|
||
96→ resolveCurrentNode: () => ExpeditionResult | null;
|
||
97→ advanceNode: () => void;
|
||
98→ abortExpedition: () => void;
|
||
99→
|
||
100→ // 技术
|
||
101→ buyTech: (techId: string) => boolean;
|
||
102→
|
||
103→ // 飞升
|
||
104→ doPrestige: () => { newBp: number } | null;
|
||
105→
|
||
106→ // 星图天文台
|
||
107→ chooseConstellationPerk: (perkId: string) => boolean;
|
||
108→ rerollPerkChoices: () => void;
|
||
109→
|
||
110→ // 成就
|
||
111→ checkAchievements: () => Achievement[];
|
||
112→ consumeAchievementQueue: () => Achievement[];
|
||
113→
|
||
114→ // 设置
|
||
115→ toggleTheme: () => void;
|
||
116→ toggleSound: () => void;
|
||
117→
|
||
118→ // 深空信标奖励发放(v0.5)
|
||
119→ grantBeaconReward: (insights: number, contact: number) => void;
|
||
120→
|
||
121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||
122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||
123→
|
||
124→ // 派生
|
||
125→ canPrestige: () => boolean;
|
||
126→}
|
||
127→
|
||
128→type Store = GameState & GameActions & {
|
||
129→ _lastAutoDecode: number;
|
||
130→ _lastSpawn: number;
|
||
131→ _combo: number;
|
||
132→ _lastPulse: number;
|
||
133→ _achievementQueue: Achievement[];
|
||
134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||
135→};
|
||
136→
|
||
137→/** 计算并写回产能字段 */
|
||
138→function syncStats(state: Partial<GameState>) {
|
||
139→ const s = recomputeStats(state);
|
||
140→ return {
|
||
141→ crystalsPerSec: s.crystalsPerSec,
|
||
142→ crystalCap: s.crystalCap,
|
||
143→ pulsePower: s.pulsePower,
|
||
144→ offlineEff: s.offlineEff,
|
||
145→ insightMult: s.insightMult,
|
||
146→ contactRateMult: s.contactRateMult,
|
||
147→ autoDecode: s.autoDecode,
|
||
148→ decodeStepsBonus: s.decodeStepsBonus,
|
||
149→ };
|
||
150→}
|
||
151→
|
||
152→/**
|
||
153→ * 深空信标进度追踪(v0.5)。
|
||
154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
|
||
155→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
|
||
156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
|
||
157→ */
|
||
158→function trackBeacon(
|
||
159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||
160→ delta: number
|
||
161→): boolean {
|
||
162→ if (typeof window === "undefined") return false;
|
||
163→ try {
|
||
164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||
165→ if (challenge.type !== type) return false;
|
||
166→ const current: BeaconDailyProgress = loadDailyProgress();
|
||
167→ if (current.completedAt !== null) return false; // 已完成不再累加
|
||
168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||
169→ return justCompleted;
|
||
170→ } catch {
|
||
171→ return false;
|
||
172→ }
|
||
173→}
|
||
174→
|
||
175→/** 检查并解锁叙事碎片 */
|
||
176→function checkFragments(state: GameState): string[] {
|
||
177→ const unlocked: string[] = [];
|
||
178→ for (const f of FRAGMENTS) {
|
||
179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
|
||
180→ state.fragments[f.id] = true;
|
||
181→ unlocked.push(f.id);
|
||
182→ }
|
||
183→ }
|
||
184→ return unlocked;
|
||
185→}
|
||
186→
|
||
187→export const useGameStore = create<Store>()(
|
||
188→ persist(
|
||
189→ (set, get) => ({
|
||
190→ ...createInitialState(),
|
||
191→ _lastAutoDecode: Date.now(),
|
||
192→ _lastSpawn: Date.now(),
|
||
193→ _combo: 0,
|
||
194→ _lastPulse: 0,
|
||
195→ _achievementQueue: [],
|
||
196→ _tideEvents: [],
|
||
197→
|
||
198→ init: () => {
|
||
199→ const s = get();
|
||
200→ const now = Date.now();
|
||
201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
|
||
202→ let activePuzzle = s.activePuzzle;
|
||
203→ if (activePuzzle && !isSolvable(activePuzzle)) {
|
||
204→ // 把晶体放回队列,避免玩家卡死
|
||
205→ const crystal: Crystal = {
|
||
206→ id: `c_${now}_rec`,
|
||
207→ tier: activePuzzle.tier,
|
||
208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
|
||
209→ createdAt: now,
|
||
210→ };
|
||
211→ activePuzzle = null;
|
||
212→ set({
|
||
213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
214→ });
|
||
215→ }
|
||
216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||
217→ const achievements = s.achievements ?? {};
|
||
218→ const activeTide = s.activeTide ?? null;
|
||
219→ const constellation = s.constellation ?? [];
|
||
220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||
221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||
222→ const migrated = migrateChronicleFields(s);
|
||
223→ // 星图「能量共振」天赋 +1 能量上限
|
||
224→ const cm = constellationBonuses(constellation);
|
||
225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||
226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||
227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||
228→ // 若旧存档有已过期的星潮,清掉
|
||
229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||
230→ // 首次进入:补发离线收益
|
||
231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||
232→ if (elapsed > 5) {
|
||
233→ const cap = 8 * 3600;
|
||
234→ const secs = Math.min(elapsed, cap);
|
||
235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
|
||
236→ const crystalsBefore = s.crystals;
|
||
237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
|
||
238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
|
||
239→ setPendingOfflineReport({
|
||
240→ elapsedSec: secs,
|
||
241→ rawElapsedSec: elapsed,
|
||
242→ gain: crystalsAfter - crystalsBefore,
|
||
243→ rate: s.crystalsPerSec,
|
||
244→ eff: s.offlineEff,
|
||
245→ capped: elapsed > cap,
|
||
246→ crystalsBefore,
|
||
247→ crystalsAfter,
|
||
248→ crystalCap: s.crystalCap,
|
||
249→ });
|
||
250→ set({
|
||
251→ crystals: crystalsAfter,
|
||
252→ lastTick: now,
|
||
253→ activePuzzle,
|
||
254→ achievements,
|
||
255→ activeTide: tide,
|
||
256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||
257→ constellation,
|
||
258→ pendingPerkChoices,
|
||
259→ energyMax,
|
||
260→ chronicle: migrated.chronicle,
|
||
261→ runStart: migrated.runStart,
|
||
262→ bossKills: migrated.bossKills,
|
||
263→ starTidesEncountered: migrated.starTidesEncountered,
|
||
264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||
265→ });
|
||
266→ } else {
|
||
267→ set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||
268→ }
|
||
269→ },
|
||
270→
|
||
271→ loadOnline: () => {
|
||
272→ const s = get();
|
||
273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
|
||
274→ },
|
||
275→
|
||
276→ hardReset: () => {
|
||
277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||
278→ },
|
||
279→
|
||
280→ tickTide: (now) => {
|
||
281→ const s = get();
|
||
282→ const tide = s.activeTide;
|
||
283→ // 星图「星潮引导」减少间隙
|
||
284→ const cm = constellationBonuses(s.constellation ?? []);
|
||
285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
|
||
286→ // 1) 检查当前星潮是否结束
|
||
287→ if (tide && now >= tide.endsAt) {
|
||
288→ const endedType = tide.type;
|
||
289→ // 寂静期补偿洞见
|
||
290→ let silenceCompensation = 0;
|
||
291→ if (tide.type === "silence") {
|
||
292→ silenceCompensation = computeSilenceCompensation(tide);
|
||
293→ }
|
||
294→ const newInsights = s.insights + silenceCompensation;
|
||
295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||
296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||
297→ set({
|
||
298→ activeTide: null,
|
||
299→ lastTideEnd: now,
|
||
300→ insights: newInsights,
|
||
301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||
302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
|
||
303→ _tideEvents: [...s._tideEvents, event],
|
||
304→ });
|
||
305→ return event;
|
||
306→ }
|
||
307→ // 2) 检查是否该触发新星潮(间隙已过)
|
||
308→ if (!tide) {
|
||
309→ const since = now - s.lastTideEnd;
|
||
310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||
311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||
312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||
313→ if (since >= need) {
|
||
314→ const type = rollTide();
|
||
315→ const newTide: StarTide = {
|
||
316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
317→ type,
|
||
318→ startedAt: now,
|
||
319→ endsAt: now + TIDE_CONFIG.duration,
|
||
320→ };
|
||
321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||
322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
|
||
323→ const tidesAll = s.starTidesEncountered ?? [];
|
||
324→ const tideId = `tide_${type}`;
|
||
325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||
326→ set({
|
||
327→ activeTide: newTide,
|
||
328→ starTidesEncountered: newTidesAll,
|
||
329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||
330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||
331→ _tideEvents: [...s._tideEvents, event],
|
||
332→ });
|
||
333→ return event;
|
||
334→ }
|
||
335→ }
|
||
336→ return null;
|
||
337→ },
|
||
338→
|
||
339→ consumeTideEvents: () => {
|
||
340→ const s = get();
|
||
341→ if (s._tideEvents.length === 0) return [];
|
||
342→ const items = s._tideEvents;
|
||
343→ set({ _tideEvents: [] });
|
||
344→ return items;
|
||
345→ },
|
||
346→
|
||
347→ tick: (now) => {
|
||
348→ const s = get();
|
||
349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||
350→ if (dt <= 0) return;
|
||
351→
|
||
352→ // 星潮产能修饰(即时乘)
|
||
353→ const tideMod = getTideModifiers(s.activeTide);
|
||
354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||
355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||
356→ const newCrystals =
|
||
357→ s.crystals >= s.crystalCap
|
||
358→ ? s.crystals // 已达/超上限,不再自动产出
|
||
359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||
360→
|
||
361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||
362→ const bpBoost = 1 + s.blueprints.length * 0.03;
|
||
363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
|
||
364→ let pending = s.pendingCrystals;
|
||
365→ let lastSpawn = s._lastSpawn;
|
||
366→ if (
|
||
367→ now - lastSpawn > spawnInterval &&
|
||
368→ pending.length < CRYSTAL_SPAWN.maxPending
|
||
369→ ) {
|
||
370→ // 星图「晶体富集」提升 T2/T3 概率
|
||
371→ const cm = constellationBonuses(s.constellation ?? []);
|
||
372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||
373→ const crystal: Crystal = {
|
||
374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
375→ tier,
|
||
376→ value: CRYSTAL_VALUE[tier].crystals,
|
||
377→ createdAt: now,
|
||
378→ };
|
||
379→ pending = [...pending, crystal];
|
||
380→ lastSpawn = now;
|
||
381→ }
|
||
382→
|
||
383→ // 能量恢复(探险系统)
|
||
384→ let energy = s.energy;
|
||
385→ let lastEnergyTick = s.lastEnergyTick;
|
||
386→ if (energy < s.energyMax) {
|
||
387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
|
||
388→ energy = regen.energy;
|
||
389→ lastEnergyTick = regen.lastTick;
|
||
390→ } else {
|
||
391→ lastEnergyTick = now;
|
||
392→ }
|
||
393→
|
||
394→ set({
|
||
395→ crystals: newCrystals,
|
||
396→ lastTick: now,
|
||
397→ pendingCrystals: pending,
|
||
398→ _lastSpawn: lastSpawn,
|
||
399→ energy,
|
||
400→ lastEnergyTick,
|
||
401→ });
|
||
402→ },
|
||
403→
|
||
404→ pulse: () => {
|
||
405→ const s = get();
|
||
406→ const now = Date.now();
|
||
407→ // 连击
|
||
408→ let combo = 1;
|
||
409→ if (now - s._lastPulse < 1500) {
|
||
410→ combo = Math.min(10, s._combo + 1);
|
||
411→ }
|
||
412→ const mult = 1 + (combo - 1) * 0.15;
|
||
413→ // 星潮脉冲威力修饰
|
||
414→ const tideMod = getTideModifiers(s.activeTide);
|
||
415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
|
||
416→ set({
|
||
417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||
418→ _combo: combo,
|
||
419→ _lastPulse: now,
|
||
420→ });
|
||
421→ // 深空信标:脉冲任务进度 +1
|
||
422→ trackBeacon("pulse", 1);
|
||
423→ return { gain, combo };
|
||
424→ },
|
||
425→
|
||
426→ startDecode: (crystalId) => {
|
||
427→ const s = get();
|
||
428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||
429→ if (!crystal) return;
|
||
430→ const puzzle = generatePuzzle(crystal.tier);
|
||
431→ set({
|
||
432→ activePuzzle: puzzle,
|
||
433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||
434→ });
|
||
435→ },
|
||
436→
|
||
437→ clickNode: (nodeId) => {
|
||
438→ const s = get();
|
||
439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
|
||
440→ // 深拷贝谜题
|
||
441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
442→ const res = tryClickNode(puzzle, nodeId);
|
||
443→ if (res.ok) {
|
||
444→ if (res.finished) {
|
||
445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||
446→ const tideMod = getTideModifiers(s.activeTide);
|
||
447→ const cm = constellationBonuses(s.constellation ?? []);
|
||
448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||
449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
450→ const rewards = {
|
||
451→ crystals: Math.round(base.crystals * finalMult),
|
||
452→ insights: Math.round(base.insights * finalMult),
|
||
453→ contact: +(base.contact * finalMult).toFixed(2),
|
||
454→ };
|
||
455→ const newTotal = s.totalDecoded + 1;
|
||
456→ const newContact = Math.min(100, s.contact + rewards.contact);
|
||
457→ const newInsights = s.insights + rewards.insights;
|
||
458→ const newCrystals = s.crystals + rewards.crystals;
|
||
459→ // 解锁碎片
|
||
460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
461→ const unlocked = checkFragments(tentative);
|
||
462→ set({
|
||
463→ activePuzzle: null,
|
||
464→ crystals: newCrystals,
|
||
465→ insights: newInsights,
|
||
466→ contact: newContact,
|
||
467→ totalDecoded: newTotal,
|
||
468→ fragments: tentative.fragments,
|
||
469→ });
|
||
470→ // 深空信标:解码 +1,洞见累计
|
||
471→ trackBeacon("decode", 1);
|
||
472→ trackBeacon("insight", rewards.insights);
|
||
473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||
474→ }
|
||
475→ // 点击成功但未完成:检测当前局面是否仍可解
|
||
476→ const solvable = isSolvable(puzzle);
|
||
477→ set({ activePuzzle: puzzle });
|
||
478→ return { ok: true, finished: false, solvable };
|
||
479→ }
|
||
480→ return res;
|
||
481→ },
|
||
482→
|
||
483→ undoStep: () => {
|
||
484→ const s = get();
|
||
485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
|
||
486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
487→ const lastId = puzzle.path.pop();
|
||
488→ if (lastId !== undefined) {
|
||
489→ const node = puzzle.grid.find((n) => n.id === lastId);
|
||
490→ if (node) node.used = false;
|
||
491→ }
|
||
492→ set({ activePuzzle: puzzle });
|
||
493→ },
|
||
494→
|
||
495→ retryPuzzle: () => {
|
||
496→ const s = get();
|
||
497→ if (!s.activePuzzle) return;
|
||
498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
|
||
499→ },
|
||
500→
|
||
501→ abandonPuzzle: () => {
|
||
502→ const s = get();
|
||
503→ if (!s.activePuzzle) return;
|
||
504→ // 晶体放回队列末尾
|
||
505→ const crystal: Crystal = {
|
||
506→ id: `c_${Date.now()}_ret`,
|
||
507→ tier: s.activePuzzle.tier,
|
||
508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
|
||
509→ createdAt: Date.now(),
|
||
510→ };
|
||
511→ set({
|
||
512→ activePuzzle: null,
|
||
513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
514→ });
|
||
515→ },
|
||
516→
|
||
517→ autoDecodeTick: () => {
|
||
518→ const s = get();
|
||
519→ if (!s.autoDecode) return;
|
||
520→ const now = Date.now();
|
||
521→ // 星图「自动校准」减少自动解码周期
|
||
522→ const cm = constellationBonuses(s.constellation ?? []);
|
||
523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
|
||
524→ if (now - s._lastAutoDecode < interval) return;
|
||
525→ // 找一颗 T1 晶体自动解码
|
||
526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||
527→ if (idx < 0) return;
|
||
528→ const crystal = s.pendingCrystals[idx];
|
||
529→ const tideMod = getTideModifiers(s.activeTide);
|
||
530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||
531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
532→ const rewards = {
|
||
533→ crystals: Math.round(base.crystals * finalMult),
|
||
534→ insights: Math.round(base.insights * finalMult),
|
||
535→ contact: +(base.contact * finalMult).toFixed(2),
|
||
536→ };
|
||
537→ const newTotal = s.totalDecoded + 1;
|
||
538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
539→ checkFragments(tentative);
|
||
540→ set({
|
||
541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||
542→ crystals: s.crystals + rewards.crystals,
|
||
543→ insights: s.insights + rewards.insights,
|
||
544→ contact: Math.min(100, s.contact + rewards.contact),
|
||
545→ totalDecoded: newTotal,
|
||
546→ fragments: tentative.fragments,
|
||
547→ _lastAutoDecode: now,
|
||
548→ });
|
||
549→ // 深空信标:自动解码也算进度
|
||
550→ trackBeacon("decode", 1);
|
||
551→ trackBeacon("insight", rewards.insights);
|
||
552→ },
|
||
553→
|
||
554→ buyTech: (techId) => {
|
||
555→ const s = get();
|
||
556→ const node = TECH_TREE.find((t) => t.id === techId);
|
||
557→ if (!node) return false;
|
||
558→ const cur = s.tech[techId] ?? 0;
|
||
559→ if (cur >= 1) return false; // v0.1 每节点 1 级
|
||
560→ if (s.insights < node.cost) return false;
|
||
561→ const newTech = { ...s.tech, [techId]: 1 };
|
||
562→ set({
|
||
563→ insights: s.insights - node.cost,
|
||
564→ tech: newTech,
|
||
565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
|
||
566→ });
|
||
567→ return true;
|
||
568→ },
|
||
569→
|
||
570→ // ============ 探险系统 ============
|
||
571→ startExpedition: () => {
|
||
572→ const s = get();
|
||
573→ if (s.activeExpedition && !s.activeExpedition.finished) {
|
||
574→ return { ok: false, reason: "已有进行中的探险" };
|
||
575→ }
|
||
576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||
577→ return { ok: false, reason: "能量不足" };
|
||
578→ }
|
||
579→ const tideMod = getTideModifiers(s.activeTide);
|
||
580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||
581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||
582→ const seed = Math.floor(Math.random() * 1e9);
|
||
583→ const expedition = generateExpedition(seed, power, hp);
|
||
584→ set({
|
||
585→ activeExpedition: expedition,
|
||
586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||
587→ totalExpeditions: s.totalExpeditions + 1,
|
||
588→ });
|
||
589→ return { ok: true };
|
||
590→ },
|
||
591→
|
||
592→ resolveCurrentNode: () => {
|
||
593→ const s = get();
|
||
594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||
595→ // 深拷贝
|
||
596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
597→ const result = resolveNode(exp);
|
||
598→ // 累计奖励
|
||
599→ if (result.crystals) exp.rewards.crystals += result.crystals;
|
||
600→ if (result.insights) exp.rewards.insights += result.insights;
|
||
601→ if (result.contact) exp.rewards.contact += result.contact;
|
||
602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||
603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||
604→ // 实时入账(玩家立即获得)
|
||
605→ const newCrystals = s.crystals + (result.crystals || 0);
|
||
606→ const newInsights = s.insights + (result.insights || 0);
|
||
607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||
608→ // 碎片解锁
|
||
609→ const newFragments = { ...s.fragments };
|
||
610→ if (result.fragments) {
|
||
611→ for (const fid of result.fragments) newFragments[fid] = true;
|
||
612→ }
|
||
613→ // 日志
|
||
614→ const logEntry = {
|
||
615→ expeditionId: exp.id,
|
||
616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
|
||
617→ result: result.log,
|
||
618→ rewards: [
|
||
619→ result.crystals ? `+${result.crystals}晶体` : "",
|
||
620→ result.insights ? `+${result.insights}洞见` : "",
|
||
621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||
622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||
623→ ].filter(Boolean).join(" "),
|
||
624→ timestamp: Date.now(),
|
||
625→ };
|
||
626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||
627→
|
||
628→ if (result.ended) {
|
||
629→ // 探险结束(胜利或失败)
|
||
630→ exp.finished = true;
|
||
631→ }
|
||
632→
|
||
633→ // v0.4 编年史:击破 BOSS 时累计计数
|
||
634→ let bossKills = s.bossKills ?? 0;
|
||
635→ if (
|
||
636→ result.ended &&
|
||
637→ result.endReason === "victory" &&
|
||
638→ exp.nodes[exp.currentNode]?.type === "boss"
|
||
639→ ) {
|
||
640→ bossKills = bossKills + 1;
|
||
641→ }
|
||
642→
|
||
643→ set({
|
||
644→ activeExpedition: exp,
|
||
645→ crystals: newCrystals,
|
||
646→ insights: newInsights,
|
||
647→ contact: newContact,
|
||
648→ fragments: newFragments,
|
||
649→ expeditionLog: newLog,
|
||
650→ bossKills,
|
||
651→ });
|
||
652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||
653→ if (result.ended) {
|
||
654→ trackBeacon("expedition", 1);
|
||
655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
|
||
656→ trackBeacon("boss", 1);
|
||
657→ }
|
||
658→ }
|
||
659→ if (result.insights) trackBeacon("insight", result.insights);
|
||
660→ return result;
|
||
661→ },
|
||
662→
|
||
663→ advanceNode: () => {
|
||
664→ const s = get();
|
||
665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||
666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
667→ const node = exp.nodes[exp.currentNode];
|
||
668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||
669→ if (exp.currentNode >= exp.nodes.length - 1) return;
|
||
670→ exp.currentNode++;
|
||
671→ set({ activeExpedition: exp });
|
||
672→ },
|
||
673→
|
||
674→ abortExpedition: () => {
|
||
675→ const s = get();
|
||
676→ if (!s.activeExpedition) return;
|
||
677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
678→ exp.finished = true;
|
||
679→ const logEntry = {
|
||
680→ expeditionId: exp.id,
|
||
681→ nodeType: "rest" as const,
|
||
682→ result: "探险队主动撤退,保留已获奖励。",
|
||
683→ rewards: "",
|
||
684→ timestamp: Date.now(),
|
||
685→ };
|
||
686→ set({
|
||
687→ activeExpedition: exp,
|
||
688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||
689→ });
|
||
690→ },
|
||
691→
|
||
692→ doPrestige: () => {
|
||
693→ const s = get();
|
||
694→ if (s.contact < CONTACT.prestigeMin) return null;
|
||
695→ const newBp = computeNewBlueprints(s);
|
||
696→ const next = performPrestige(s);
|
||
697→ // 星图「能量共振」提升上限
|
||
698→ const cm = constellationBonuses(next.constellation ?? []);
|
||
699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
700→ set({
|
||
701→ ...next,
|
||
702→ energyMax,
|
||
703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
|
||
704→ _lastAutoDecode: Date.now(),
|
||
705→ _lastSpawn: Date.now(),
|
||
706→ _combo: 0,
|
||
707→ _lastPulse: 0,
|
||
708→ _tideEvents: [],
|
||
709→ });
|
||
710→ return { newBp };
|
||
711→ },
|
||
712→
|
||
713→ chooseConstellationPerk: (perkId) => {
|
||
714→ const s = get();
|
||
715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||
716→ const perk = getPerk(perkId);
|
||
717→ if (!perk) return false;
|
||
718→ if (s.constellation?.includes(perkId)) return false;
|
||
719→ const newConstellation = [...(s.constellation ?? []), perkId];
|
||
720→ const cm = constellationBonuses(newConstellation);
|
||
721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||
723→ const chronicle = s.chronicle ?? [];
|
||
724→ let newChronicle = chronicle;
|
||
725→ if (chronicle.length > 0) {
|
||
726→ const lastEntry = chronicle[chronicle.length - 1];
|
||
727→ const updatedLast = withPerks(lastEntry, [perkId]);
|
||
728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||
729→ }
|
||
730→ set({
|
||
731→ constellation: newConstellation,
|
||
732→ pendingPerkChoices: null,
|
||
733→ energyMax,
|
||
734→ chronicle: newChronicle,
|
||
735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||
736→ });
|
||
737→ return true;
|
||
738→ },
|
||
739→
|
||
740→ rerollPerkChoices: () => {
|
||
741→ const s = get();
|
||
742→ if (!s.pendingPerkChoices) return;
|
||
743→ const choices = rollPerkChoices(s.constellation ?? []);
|
||
744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||
745→ },
|
||
746→
|
||
747→ checkAchievements: () => {
|
||
748→ const s = get();
|
||
749→ const newlyUnlocked: Achievement[] = [];
|
||
750→ const updated = { ...s.achievements };
|
||
751→ let crystals = s.crystals;
|
||
752→ let insights = s.insights;
|
||
753→ let contact = s.contact;
|
||
754→ let statsDirty = false;
|
||
755→ for (const a of ACHIEVEMENTS) {
|
||
756→ if (updated[a.id]) continue;
|
||
757→ if (a.check(s)) {
|
||
758→ updated[a.id] = true;
|
||
759→ newlyUnlocked.push(a);
|
||
760→ // 发放即时奖励
|
||
761→ if (a.reward.crystals) crystals += a.reward.crystals;
|
||
762→ if (a.reward.insights) insights += a.reward.insights;
|
||
763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
|
||
764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
|
||
765→ }
|
||
766→ }
|
||
767→ if (newlyUnlocked.length === 0) return [];
|
||
768→ set({
|
||
769→ achievements: updated,
|
||
770→ crystals,
|
||
771→ insights,
|
||
772→ contact,
|
||
773→ ...(statsDirty
|
||
774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
|
||
775→ : {}),
|
||
776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||
777→ });
|
||
778→ return newlyUnlocked;
|
||
779→ },
|
||
780→
|
||
781→ consumeAchievementQueue: () => {
|
||
782→ const s = get();
|
||
783→ if (s._achievementQueue.length === 0) return [];
|
||
784→ const items = s._achievementQueue;
|
||
785→ set({ _achievementQueue: [] });
|
||
786→ return items;
|
||
787→ },
|
||
788→
|
||
789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||
790→
|
||
791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||
792→ toggleSound: () => set({ soundOn: !get().soundOn }),
|
||
793→
|
||
794→ // 深空信标:发放每日挑战奖励(v0.5)
|
||
795→ grantBeaconReward: (insights, contact) => {
|
||
796→ const s = get();
|
||
797→ set({
|
||
798→ insights: s.insights + Math.round(insights),
|
||
799→ contact: Math.min(100, s.contact + contact),
|
||
800→ });
|
||
801→ },
|
||
802→
|
||
803→ // 深空巡航:发放实时玩法奖励(v0.6)
|
||
804→ grantCruiseReward: (rewards) => {
|
||
805→ const s = get();
|
||
806→ const addCrystals = rewards.crystals ?? 0;
|
||
807→ const addInsights = rewards.insights ?? 0;
|
||
808→ const addContact = rewards.contact ?? 0;
|
||
809→ set({
|
||
810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||
811→ insights: s.insights + Math.round(addInsights),
|
||
812→ contact: Math.min(100, s.contact + addContact),
|
||
813→ });
|
||
814→ },
|
||
815→ }),
|
||
816→ {
|
||
817→ name: "echo-nexus-save-v1",
|
||
818→ storage: createJSONStorage(() => localStorage),
|
||
819→ // 不持久化临时字段
|
||
820→ partialize: (s) => {
|
||
821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||
822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||
823→ return rest as GameState;
|
||
824→ },
|
||
825→ }
|
||
826→ )
|
||
827→);
|
||
828→
|
||
829→/** 选择器:未解锁碎片中下一个门槛 */
|
||
830→export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||
831→ for (const f of FRAGMENTS) {
|
||
832→ if (totalDecoded < f.threshold) return f.threshold;
|
||
833→ }
|
||
834→ return null;
|
||
835→}
|
||
836→
|
||
837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||
838→
|
||
839→// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||
840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||
841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||
842→}
|
||
843→ |