v0.8: 巡航玩法大增强 - BOSS 战 + 道具掉落 + 事件选择节点
- cruise.ts (+1487行): BossEntity/BossPhase/BulletEntity/PowerupEntity/UpgradeCard 类型 - BOSS 三阶段攻击(直射/散射/追踪弹+召唤), HP=100+lvl×20 - 飞船射击系统(空格/射击按钮, 能量消耗5/发, 150ms冷却) - 5种道具掉落(shield/energy/rapid/overdrive/magnet) - 10种升级牌(速度/护盾/能量/火力/吸引/无敌等), 每关3选1 - shootShip/applyUpgradeCard/rollUpgradeChoices 函数 - generateLevel: level%5===0 生成BOSS关 - CruiseMode.tsx (+858行): BOSS多层环+HP条+触手绘制, 子弹尾焰, 道具六边形+图标 - buff状态指示器, 选择升级阶段3张玻璃卡牌翻转 - 射击按钮(移动端右下), BOSS HP条(顶部), buff列表(左侧) - 修复 lint: isTouch/stats 改用 useState initializer - page.tsx: 版本号 v0.7 → v0.8 - worklog.md: 工单修正(标注v0.7已完成), cron job_id 228357重建
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
3832
|
||||
6708
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.7
|
||||
ECHO NEXUS · v0.8
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+775
-107
File diff suppressed because it is too large
Load Diff
+1275
-218
File diff suppressed because it is too large
Load Diff
@@ -1,843 +0,0 @@
|
||||
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→
|
||||
@@ -1,843 +0,0 @@
|
||||
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→
|
||||
@@ -1,843 +0,0 @@
|
||||
1→ 1→"use client";
|
||||
2→ 2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
|
||||
3→ 3→import { create } from "zustand";
|
||||
4→ 4→import { persist, createJSONStorage } from "zustand/middleware";
|
||||
5→ 5→import type {
|
||||
6→ 6→ GameState,
|
||||
7→ 7→ Crystal,
|
||||
8→ 8→ CrystalTier,
|
||||
9→ 9→ DecodePuzzle,
|
||||
10→ 10→ ExpeditionResult,
|
||||
11→ 11→} from "@/lib/game/types";
|
||||
12→ 12→import {
|
||||
13→ 13→ INITIAL_STATE,
|
||||
14→ 14→ TECH_TREE,
|
||||
15→ 15→ CRYSTAL_VALUE,
|
||||
16→ 16→ CONTACT,
|
||||
17→ 17→ CRYSTAL_SPAWN,
|
||||
18→ 18→ FRAGMENTS,
|
||||
19→ 19→ PRESTIGE,
|
||||
20→ 20→} from "@/lib/game/config";
|
||||
21→ 21→import {
|
||||
22→ 22→ createInitialState,
|
||||
23→ 23→ recomputeStats,
|
||||
24→ 24→ decodeRewards,
|
||||
25→ 25→ rollCrystalTierWithBonus,
|
||||
26→ 26→ computeNewBlueprints,
|
||||
27→ 27→ performPrestige,
|
||||
28→ 28→} from "@/lib/game/engine";
|
||||
29→ 29→import {
|
||||
30→ 30→ generatePuzzle,
|
||||
31→ 31→ tryClickNode,
|
||||
32→ 32→ isSolvable,
|
||||
33→ 33→ resetPuzzle as resetPuz,
|
||||
34→ 34→} from "@/lib/game/decode";
|
||||
35→ 35→import {
|
||||
36→ 36→ generateExpedition,
|
||||
37→ 37→ resolveNode,
|
||||
38→ 38→ advanceExpedition,
|
||||
39→ 39→ computeExpeditionPower,
|
||||
40→ 40→ computeExpeditionHp,
|
||||
41→ 41→ computeEnergyRegen,
|
||||
42→ 42→ EXPEDITION_CONFIG,
|
||||
43→ 43→} from "@/lib/game/expedition";
|
||||
44→ 44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||||
45→ 45→import {
|
||||
46→ 46→ TIDE_CONFIG,
|
||||
47→ 47→ rollTide,
|
||||
48→ 48→ getTideModifiers,
|
||||
49→ 49→ computeSilenceCompensation,
|
||||
50→ 50→ type StarTide,
|
||||
51→ 51→ type TideType,
|
||||
52→ 52→} from "@/lib/game/starTide";
|
||||
53→ 53→import {
|
||||
54→ 54→ getPerk,
|
||||
55→ 55→ constellationBonuses,
|
||||
56→ 56→ rollPerkChoices,
|
||||
57→ 57→} from "@/lib/game/constellation";
|
||||
58→ 58→import {
|
||||
59→ 59→ migrateChronicleFields,
|
||||
60→ 60→ withPerks,
|
||||
61→ 61→} from "@/lib/game/chronicle";
|
||||
62→ 62→import {
|
||||
63→ 63→ generateDailyChallenge,
|
||||
64→ 64→ loadDailyProgress,
|
||||
65→ 65→ addBeaconProgress,
|
||||
66→ 66→ type BeaconDailyChallenge,
|
||||
67→ 67→ type BeaconDailyProgress,
|
||||
68→ 68→} from "@/lib/game/beacon";
|
||||
69→ 69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
70→ 70→
|
||||
71→ 71→interface GameActions {
|
||||
72→ 72→ // 生命周期
|
||||
73→ 73→ init: () => void;
|
||||
74→ 74→ loadOnline: () => void;
|
||||
75→ 75→ hardReset: () => void;
|
||||
76→ 76→
|
||||
77→ 77→ // 主循环
|
||||
78→ 78→ tick: (now: number) => void;
|
||||
79→ 79→ pulse: () => { gain: number; combo: number } | null;
|
||||
80→ 80→
|
||||
81→ 81→ // 星潮
|
||||
82→ 82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||||
83→ 83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
84→ 84→
|
||||
85→ 85→ // 解码
|
||||
86→ 86→ startDecode: (crystalId: string) => void;
|
||||
87→ 87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||||
88→ 88→ undoStep: () => void;
|
||||
89→ 89→ retryPuzzle: () => void;
|
||||
90→ 90→ abandonPuzzle: () => void;
|
||||
91→ 91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
|
||||
92→ 92→ autoDecodeTick: () => void;
|
||||
93→ 93→
|
||||
94→ 94→ // 探险
|
||||
95→ 95→ startExpedition: () => { ok: boolean; reason?: string };
|
||||
96→ 96→ resolveCurrentNode: () => ExpeditionResult | null;
|
||||
97→ 97→ advanceNode: () => void;
|
||||
98→ 98→ abortExpedition: () => void;
|
||||
99→ 99→
|
||||
100→ 100→ // 技术
|
||||
101→ 101→ buyTech: (techId: string) => boolean;
|
||||
102→ 102→
|
||||
103→ 103→ // 飞升
|
||||
104→ 104→ doPrestige: () => { newBp: number } | null;
|
||||
105→ 105→
|
||||
106→ 106→ // 星图天文台
|
||||
107→ 107→ chooseConstellationPerk: (perkId: string) => boolean;
|
||||
108→ 108→ rerollPerkChoices: () => void;
|
||||
109→ 109→
|
||||
110→ 110→ // 成就
|
||||
111→ 111→ checkAchievements: () => Achievement[];
|
||||
112→ 112→ consumeAchievementQueue: () => Achievement[];
|
||||
113→ 113→
|
||||
114→ 114→ // 设置
|
||||
115→ 115→ toggleTheme: () => void;
|
||||
116→ 116→ toggleSound: () => void;
|
||||
117→ 117→
|
||||
118→ 118→ // 深空信标奖励发放(v0.5)
|
||||
119→ 119→ grantBeaconReward: (insights: number, contact: number) => void;
|
||||
120→ 120→
|
||||
121→ 121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||||
122→ 122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||||
123→ 123→
|
||||
124→ 124→ // 派生
|
||||
125→ 125→ canPrestige: () => boolean;
|
||||
126→ 126→}
|
||||
127→ 127→
|
||||
128→ 128→type Store = GameState & GameActions & {
|
||||
129→ 129→ _lastAutoDecode: number;
|
||||
130→ 130→ _lastSpawn: number;
|
||||
131→ 131→ _combo: number;
|
||||
132→ 132→ _lastPulse: number;
|
||||
133→ 133→ _achievementQueue: Achievement[];
|
||||
134→ 134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
135→ 135→};
|
||||
136→ 136→
|
||||
137→ 137→/** 计算并写回产能字段 */
|
||||
138→ 138→function syncStats(state: Partial<GameState>) {
|
||||
139→ 139→ const s = recomputeStats(state);
|
||||
140→ 140→ return {
|
||||
141→ 141→ crystalsPerSec: s.crystalsPerSec,
|
||||
142→ 142→ crystalCap: s.crystalCap,
|
||||
143→ 143→ pulsePower: s.pulsePower,
|
||||
144→ 144→ offlineEff: s.offlineEff,
|
||||
145→ 145→ insightMult: s.insightMult,
|
||||
146→ 146→ contactRateMult: s.contactRateMult,
|
||||
147→ 147→ autoDecode: s.autoDecode,
|
||||
148→ 148→ decodeStepsBonus: s.decodeStepsBonus,
|
||||
149→ 149→ };
|
||||
150→ 150→}
|
||||
151→ 151→
|
||||
152→ 152→/**
|
||||
153→ 153→ * 深空信标进度追踪(v0.5)。
|
||||
154→ 154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
|
||||
155→ 155→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
|
||||
156→ 156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
|
||||
157→ 157→ */
|
||||
158→ 158→function trackBeacon(
|
||||
159→ 159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
160→ 160→ delta: number
|
||||
161→ 161→): boolean {
|
||||
162→ 162→ if (typeof window === "undefined") return false;
|
||||
163→ 163→ try {
|
||||
164→ 164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||||
165→ 165→ if (challenge.type !== type) return false;
|
||||
166→ 166→ const current: BeaconDailyProgress = loadDailyProgress();
|
||||
167→ 167→ if (current.completedAt !== null) return false; // 已完成不再累加
|
||||
168→ 168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||||
169→ 169→ return justCompleted;
|
||||
170→ 170→ } catch {
|
||||
171→ 171→ return false;
|
||||
172→ 172→ }
|
||||
173→ 173→}
|
||||
174→ 174→
|
||||
175→ 175→/** 检查并解锁叙事碎片 */
|
||||
176→ 176→function checkFragments(state: GameState): string[] {
|
||||
177→ 177→ const unlocked: string[] = [];
|
||||
178→ 178→ for (const f of FRAGMENTS) {
|
||||
179→ 179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
|
||||
180→ 180→ state.fragments[f.id] = true;
|
||||
181→ 181→ unlocked.push(f.id);
|
||||
182→ 182→ }
|
||||
183→ 183→ }
|
||||
184→ 184→ return unlocked;
|
||||
185→ 185→}
|
||||
186→ 186→
|
||||
187→ 187→export const useGameStore = create<Store>()(
|
||||
188→ 188→ persist(
|
||||
189→ 189→ (set, get) => ({
|
||||
190→ 190→ ...createInitialState(),
|
||||
191→ 191→ _lastAutoDecode: Date.now(),
|
||||
192→ 192→ _lastSpawn: Date.now(),
|
||||
193→ 193→ _combo: 0,
|
||||
194→ 194→ _lastPulse: 0,
|
||||
195→ 195→ _achievementQueue: [],
|
||||
196→ 196→ _tideEvents: [],
|
||||
197→ 197→
|
||||
198→ 198→ init: () => {
|
||||
199→ 199→ const s = get();
|
||||
200→ 200→ const now = Date.now();
|
||||
201→ 201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
|
||||
202→ 202→ let activePuzzle = s.activePuzzle;
|
||||
203→ 203→ if (activePuzzle && !isSolvable(activePuzzle)) {
|
||||
204→ 204→ // 把晶体放回队列,避免玩家卡死
|
||||
205→ 205→ const crystal: Crystal = {
|
||||
206→ 206→ id: `c_${now}_rec`,
|
||||
207→ 207→ tier: activePuzzle.tier,
|
||||
208→ 208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
|
||||
209→ 209→ createdAt: now,
|
||||
210→ 210→ };
|
||||
211→ 211→ activePuzzle = null;
|
||||
212→ 212→ set({
|
||||
213→ 213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
214→ 214→ });
|
||||
215→ 215→ }
|
||||
216→ 216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||||
217→ 217→ const achievements = s.achievements ?? {};
|
||||
218→ 218→ const activeTide = s.activeTide ?? null;
|
||||
219→ 219→ const constellation = s.constellation ?? [];
|
||||
220→ 220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||||
221→ 221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||||
222→ 222→ const migrated = migrateChronicleFields(s);
|
||||
223→ 223→ // 星图「能量共振」天赋 +1 能量上限
|
||||
224→ 224→ const cm = constellationBonuses(constellation);
|
||||
225→ 225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||||
226→ 226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||||
227→ 227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||||
228→ 228→ // 若旧存档有已过期的星潮,清掉
|
||||
229→ 229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||||
230→ 230→ // 首次进入:补发离线收益
|
||||
231→ 231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||||
232→ 232→ if (elapsed > 5) {
|
||||
233→ 233→ const cap = 8 * 3600;
|
||||
234→ 234→ const secs = Math.min(elapsed, cap);
|
||||
235→ 235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
|
||||
236→ 236→ const crystalsBefore = s.crystals;
|
||||
237→ 237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
|
||||
238→ 238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
|
||||
239→ 239→ setPendingOfflineReport({
|
||||
240→ 240→ elapsedSec: secs,
|
||||
241→ 241→ rawElapsedSec: elapsed,
|
||||
242→ 242→ gain: crystalsAfter - crystalsBefore,
|
||||
243→ 243→ rate: s.crystalsPerSec,
|
||||
244→ 244→ eff: s.offlineEff,
|
||||
245→ 245→ capped: elapsed > cap,
|
||||
246→ 246→ crystalsBefore,
|
||||
247→ 247→ crystalsAfter,
|
||||
248→ 248→ crystalCap: s.crystalCap,
|
||||
249→ 249→ });
|
||||
250→ 250→ set({
|
||||
251→ 251→ crystals: crystalsAfter,
|
||||
252→ 252→ lastTick: now,
|
||||
253→ 253→ activePuzzle,
|
||||
254→ 254→ achievements,
|
||||
255→ 255→ activeTide: tide,
|
||||
256→ 256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||||
257→ 257→ constellation,
|
||||
258→ 258→ pendingPerkChoices,
|
||||
259→ 259→ energyMax,
|
||||
260→ 260→ chronicle: migrated.chronicle,
|
||||
261→ 261→ runStart: migrated.runStart,
|
||||
262→ 262→ bossKills: migrated.bossKills,
|
||||
263→ 263→ starTidesEncountered: migrated.starTidesEncountered,
|
||||
264→ 264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||||
265→ 265→ });
|
||||
266→ 266→ } else {
|
||||
267→ 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→ 268→ }
|
||||
269→ 269→ },
|
||||
270→ 270→
|
||||
271→ 271→ loadOnline: () => {
|
||||
272→ 272→ const s = get();
|
||||
273→ 273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
|
||||
274→ 274→ },
|
||||
275→ 275→
|
||||
276→ 276→ hardReset: () => {
|
||||
277→ 277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||||
278→ 278→ },
|
||||
279→ 279→
|
||||
280→ 280→ tickTide: (now) => {
|
||||
281→ 281→ const s = get();
|
||||
282→ 282→ const tide = s.activeTide;
|
||||
283→ 283→ // 星图「星潮引导」减少间隙
|
||||
284→ 284→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
285→ 285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
|
||||
286→ 286→ // 1) 检查当前星潮是否结束
|
||||
287→ 287→ if (tide && now >= tide.endsAt) {
|
||||
288→ 288→ const endedType = tide.type;
|
||||
289→ 289→ // 寂静期补偿洞见
|
||||
290→ 290→ let silenceCompensation = 0;
|
||||
291→ 291→ if (tide.type === "silence") {
|
||||
292→ 292→ silenceCompensation = computeSilenceCompensation(tide);
|
||||
293→ 293→ }
|
||||
294→ 294→ const newInsights = s.insights + silenceCompensation;
|
||||
295→ 295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||||
296→ 296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
297→ 297→ set({
|
||||
298→ 298→ activeTide: null,
|
||||
299→ 299→ lastTideEnd: now,
|
||||
300→ 300→ insights: newInsights,
|
||||
301→ 301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||||
302→ 302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
|
||||
303→ 303→ _tideEvents: [...s._tideEvents, event],
|
||||
304→ 304→ });
|
||||
305→ 305→ return event;
|
||||
306→ 306→ }
|
||||
307→ 307→ // 2) 检查是否该触发新星潮(间隙已过)
|
||||
308→ 308→ if (!tide) {
|
||||
309→ 309→ const since = now - s.lastTideEnd;
|
||||
310→ 310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
311→ 311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||||
312→ 312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||||
313→ 313→ if (since >= need) {
|
||||
314→ 314→ const type = rollTide();
|
||||
315→ 315→ const newTide: StarTide = {
|
||||
316→ 316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
317→ 317→ type,
|
||||
318→ 318→ startedAt: now,
|
||||
319→ 319→ endsAt: now + TIDE_CONFIG.duration,
|
||||
320→ 320→ };
|
||||
321→ 321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
322→ 322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
|
||||
323→ 323→ const tidesAll = s.starTidesEncountered ?? [];
|
||||
324→ 324→ const tideId = `tide_${type}`;
|
||||
325→ 325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||||
326→ 326→ set({
|
||||
327→ 327→ activeTide: newTide,
|
||||
328→ 328→ starTidesEncountered: newTidesAll,
|
||||
329→ 329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
330→ 330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||||
331→ 331→ _tideEvents: [...s._tideEvents, event],
|
||||
332→ 332→ });
|
||||
333→ 333→ return event;
|
||||
334→ 334→ }
|
||||
335→ 335→ }
|
||||
336→ 336→ return null;
|
||||
337→ 337→ },
|
||||
338→ 338→
|
||||
339→ 339→ consumeTideEvents: () => {
|
||||
340→ 340→ const s = get();
|
||||
341→ 341→ if (s._tideEvents.length === 0) return [];
|
||||
342→ 342→ const items = s._tideEvents;
|
||||
343→ 343→ set({ _tideEvents: [] });
|
||||
344→ 344→ return items;
|
||||
345→ 345→ },
|
||||
346→ 346→
|
||||
347→ 347→ tick: (now) => {
|
||||
348→ 348→ const s = get();
|
||||
349→ 349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
350→ 350→ if (dt <= 0) return;
|
||||
351→ 351→
|
||||
352→ 352→ // 星潮产能修饰(即时乘)
|
||||
353→ 353→ const tideMod = getTideModifiers(s.activeTide);
|
||||
354→ 354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||||
355→ 355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||||
356→ 356→ const newCrystals =
|
||||
357→ 357→ s.crystals >= s.crystalCap
|
||||
358→ 358→ ? s.crystals // 已达/超上限,不再自动产出
|
||||
359→ 359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||||
360→ 360→
|
||||
361→ 361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||||
362→ 362→ const bpBoost = 1 + s.blueprints.length * 0.03;
|
||||
363→ 363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
|
||||
364→ 364→ let pending = s.pendingCrystals;
|
||||
365→ 365→ let lastSpawn = s._lastSpawn;
|
||||
366→ 366→ if (
|
||||
367→ 367→ now - lastSpawn > spawnInterval &&
|
||||
368→ 368→ pending.length < CRYSTAL_SPAWN.maxPending
|
||||
369→ 369→ ) {
|
||||
370→ 370→ // 星图「晶体富集」提升 T2/T3 概率
|
||||
371→ 371→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
372→ 372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||||
373→ 373→ const crystal: Crystal = {
|
||||
374→ 374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
375→ 375→ tier,
|
||||
376→ 376→ value: CRYSTAL_VALUE[tier].crystals,
|
||||
377→ 377→ createdAt: now,
|
||||
378→ 378→ };
|
||||
379→ 379→ pending = [...pending, crystal];
|
||||
380→ 380→ lastSpawn = now;
|
||||
381→ 381→ }
|
||||
382→ 382→
|
||||
383→ 383→ // 能量恢复(探险系统)
|
||||
384→ 384→ let energy = s.energy;
|
||||
385→ 385→ let lastEnergyTick = s.lastEnergyTick;
|
||||
386→ 386→ if (energy < s.energyMax) {
|
||||
387→ 387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
|
||||
388→ 388→ energy = regen.energy;
|
||||
389→ 389→ lastEnergyTick = regen.lastTick;
|
||||
390→ 390→ } else {
|
||||
391→ 391→ lastEnergyTick = now;
|
||||
392→ 392→ }
|
||||
393→ 393→
|
||||
394→ 394→ set({
|
||||
395→ 395→ crystals: newCrystals,
|
||||
396→ 396→ lastTick: now,
|
||||
397→ 397→ pendingCrystals: pending,
|
||||
398→ 398→ _lastSpawn: lastSpawn,
|
||||
399→ 399→ energy,
|
||||
400→ 400→ lastEnergyTick,
|
||||
401→ 401→ });
|
||||
402→ 402→ },
|
||||
403→ 403→
|
||||
404→ 404→ pulse: () => {
|
||||
405→ 405→ const s = get();
|
||||
406→ 406→ const now = Date.now();
|
||||
407→ 407→ // 连击
|
||||
408→ 408→ let combo = 1;
|
||||
409→ 409→ if (now - s._lastPulse < 1500) {
|
||||
410→ 410→ combo = Math.min(10, s._combo + 1);
|
||||
411→ 411→ }
|
||||
412→ 412→ const mult = 1 + (combo - 1) * 0.15;
|
||||
413→ 413→ // 星潮脉冲威力修饰
|
||||
414→ 414→ const tideMod = getTideModifiers(s.activeTide);
|
||||
415→ 415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
|
||||
416→ 416→ set({
|
||||
417→ 417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||||
418→ 418→ _combo: combo,
|
||||
419→ 419→ _lastPulse: now,
|
||||
420→ 420→ });
|
||||
421→ 421→ // 深空信标:脉冲任务进度 +1
|
||||
422→ 422→ trackBeacon("pulse", 1);
|
||||
423→ 423→ return { gain, combo };
|
||||
424→ 424→ },
|
||||
425→ 425→
|
||||
426→ 426→ startDecode: (crystalId) => {
|
||||
427→ 427→ const s = get();
|
||||
428→ 428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||||
429→ 429→ if (!crystal) return;
|
||||
430→ 430→ const puzzle = generatePuzzle(crystal.tier);
|
||||
431→ 431→ set({
|
||||
432→ 432→ activePuzzle: puzzle,
|
||||
433→ 433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||||
434→ 434→ });
|
||||
435→ 435→ },
|
||||
436→ 436→
|
||||
437→ 437→ clickNode: (nodeId) => {
|
||||
438→ 438→ const s = get();
|
||||
439→ 439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
|
||||
440→ 440→ // 深拷贝谜题
|
||||
441→ 441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||||
442→ 442→ const res = tryClickNode(puzzle, nodeId);
|
||||
443→ 443→ if (res.ok) {
|
||||
444→ 444→ if (res.finished) {
|
||||
445→ 445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||||
446→ 446→ const tideMod = getTideModifiers(s.activeTide);
|
||||
447→ 447→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
448→ 448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||||
449→ 449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
450→ 450→ const rewards = {
|
||||
451→ 451→ crystals: Math.round(base.crystals * finalMult),
|
||||
452→ 452→ insights: Math.round(base.insights * finalMult),
|
||||
453→ 453→ contact: +(base.contact * finalMult).toFixed(2),
|
||||
454→ 454→ };
|
||||
455→ 455→ const newTotal = s.totalDecoded + 1;
|
||||
456→ 456→ const newContact = Math.min(100, s.contact + rewards.contact);
|
||||
457→ 457→ const newInsights = s.insights + rewards.insights;
|
||||
458→ 458→ const newCrystals = s.crystals + rewards.crystals;
|
||||
459→ 459→ // 解锁碎片
|
||||
460→ 460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
461→ 461→ const unlocked = checkFragments(tentative);
|
||||
462→ 462→ set({
|
||||
463→ 463→ activePuzzle: null,
|
||||
464→ 464→ crystals: newCrystals,
|
||||
465→ 465→ insights: newInsights,
|
||||
466→ 466→ contact: newContact,
|
||||
467→ 467→ totalDecoded: newTotal,
|
||||
468→ 468→ fragments: tentative.fragments,
|
||||
469→ 469→ });
|
||||
470→ 470→ // 深空信标:解码 +1,洞见累计
|
||||
471→ 471→ trackBeacon("decode", 1);
|
||||
472→ 472→ trackBeacon("insight", rewards.insights);
|
||||
473→ 473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||||
474→ 474→ }
|
||||
475→ 475→ // 点击成功但未完成:检测当前局面是否仍可解
|
||||
476→ 476→ const solvable = isSolvable(puzzle);
|
||||
477→ 477→ set({ activePuzzle: puzzle });
|
||||
478→ 478→ return { ok: true, finished: false, solvable };
|
||||
479→ 479→ }
|
||||
480→ 480→ return res;
|
||||
481→ 481→ },
|
||||
482→ 482→
|
||||
483→ 483→ undoStep: () => {
|
||||
484→ 484→ const s = get();
|
||||
485→ 485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
|
||||
486→ 486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||||
487→ 487→ const lastId = puzzle.path.pop();
|
||||
488→ 488→ if (lastId !== undefined) {
|
||||
489→ 489→ const node = puzzle.grid.find((n) => n.id === lastId);
|
||||
490→ 490→ if (node) node.used = false;
|
||||
491→ 491→ }
|
||||
492→ 492→ set({ activePuzzle: puzzle });
|
||||
493→ 493→ },
|
||||
494→ 494→
|
||||
495→ 495→ retryPuzzle: () => {
|
||||
496→ 496→ const s = get();
|
||||
497→ 497→ if (!s.activePuzzle) return;
|
||||
498→ 498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
|
||||
499→ 499→ },
|
||||
500→ 500→
|
||||
501→ 501→ abandonPuzzle: () => {
|
||||
502→ 502→ const s = get();
|
||||
503→ 503→ if (!s.activePuzzle) return;
|
||||
504→ 504→ // 晶体放回队列末尾
|
||||
505→ 505→ const crystal: Crystal = {
|
||||
506→ 506→ id: `c_${Date.now()}_ret`,
|
||||
507→ 507→ tier: s.activePuzzle.tier,
|
||||
508→ 508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
|
||||
509→ 509→ createdAt: Date.now(),
|
||||
510→ 510→ };
|
||||
511→ 511→ set({
|
||||
512→ 512→ activePuzzle: null,
|
||||
513→ 513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
514→ 514→ });
|
||||
515→ 515→ },
|
||||
516→ 516→
|
||||
517→ 517→ autoDecodeTick: () => {
|
||||
518→ 518→ const s = get();
|
||||
519→ 519→ if (!s.autoDecode) return;
|
||||
520→ 520→ const now = Date.now();
|
||||
521→ 521→ // 星图「自动校准」减少自动解码周期
|
||||
522→ 522→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
523→ 523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
|
||||
524→ 524→ if (now - s._lastAutoDecode < interval) return;
|
||||
525→ 525→ // 找一颗 T1 晶体自动解码
|
||||
526→ 526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
527→ 527→ if (idx < 0) return;
|
||||
528→ 528→ const crystal = s.pendingCrystals[idx];
|
||||
529→ 529→ const tideMod = getTideModifiers(s.activeTide);
|
||||
530→ 530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||||
531→ 531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
532→ 532→ const rewards = {
|
||||
533→ 533→ crystals: Math.round(base.crystals * finalMult),
|
||||
534→ 534→ insights: Math.round(base.insights * finalMult),
|
||||
535→ 535→ contact: +(base.contact * finalMult).toFixed(2),
|
||||
536→ 536→ };
|
||||
537→ 537→ const newTotal = s.totalDecoded + 1;
|
||||
538→ 538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
539→ 539→ checkFragments(tentative);
|
||||
540→ 540→ set({
|
||||
541→ 541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||||
542→ 542→ crystals: s.crystals + rewards.crystals,
|
||||
543→ 543→ insights: s.insights + rewards.insights,
|
||||
544→ 544→ contact: Math.min(100, s.contact + rewards.contact),
|
||||
545→ 545→ totalDecoded: newTotal,
|
||||
546→ 546→ fragments: tentative.fragments,
|
||||
547→ 547→ _lastAutoDecode: now,
|
||||
548→ 548→ });
|
||||
549→ 549→ // 深空信标:自动解码也算进度
|
||||
550→ 550→ trackBeacon("decode", 1);
|
||||
551→ 551→ trackBeacon("insight", rewards.insights);
|
||||
552→ 552→ },
|
||||
553→ 553→
|
||||
554→ 554→ buyTech: (techId) => {
|
||||
555→ 555→ const s = get();
|
||||
556→ 556→ const node = TECH_TREE.find((t) => t.id === techId);
|
||||
557→ 557→ if (!node) return false;
|
||||
558→ 558→ const cur = s.tech[techId] ?? 0;
|
||||
559→ 559→ if (cur >= 1) return false; // v0.1 每节点 1 级
|
||||
560→ 560→ if (s.insights < node.cost) return false;
|
||||
561→ 561→ const newTech = { ...s.tech, [techId]: 1 };
|
||||
562→ 562→ set({
|
||||
563→ 563→ insights: s.insights - node.cost,
|
||||
564→ 564→ tech: newTech,
|
||||
565→ 565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
|
||||
566→ 566→ });
|
||||
567→ 567→ return true;
|
||||
568→ 568→ },
|
||||
569→ 569→
|
||||
570→ 570→ // ============ 探险系统 ============
|
||||
571→ 571→ startExpedition: () => {
|
||||
572→ 572→ const s = get();
|
||||
573→ 573→ if (s.activeExpedition && !s.activeExpedition.finished) {
|
||||
574→ 574→ return { ok: false, reason: "已有进行中的探险" };
|
||||
575→ 575→ }
|
||||
576→ 576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||||
577→ 577→ return { ok: false, reason: "能量不足" };
|
||||
578→ 578→ }
|
||||
579→ 579→ const tideMod = getTideModifiers(s.activeTide);
|
||||
580→ 580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||||
581→ 581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||||
582→ 582→ const seed = Math.floor(Math.random() * 1e9);
|
||||
583→ 583→ const expedition = generateExpedition(seed, power, hp);
|
||||
584→ 584→ set({
|
||||
585→ 585→ activeExpedition: expedition,
|
||||
586→ 586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||||
587→ 587→ totalExpeditions: s.totalExpeditions + 1,
|
||||
588→ 588→ });
|
||||
589→ 589→ return { ok: true };
|
||||
590→ 590→ },
|
||||
591→ 591→
|
||||
592→ 592→ resolveCurrentNode: () => {
|
||||
593→ 593→ const s = get();
|
||||
594→ 594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||||
595→ 595→ // 深拷贝
|
||||
596→ 596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
597→ 597→ const result = resolveNode(exp);
|
||||
598→ 598→ // 累计奖励
|
||||
599→ 599→ if (result.crystals) exp.rewards.crystals += result.crystals;
|
||||
600→ 600→ if (result.insights) exp.rewards.insights += result.insights;
|
||||
601→ 601→ if (result.contact) exp.rewards.contact += result.contact;
|
||||
602→ 602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||||
603→ 603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||||
604→ 604→ // 实时入账(玩家立即获得)
|
||||
605→ 605→ const newCrystals = s.crystals + (result.crystals || 0);
|
||||
606→ 606→ const newInsights = s.insights + (result.insights || 0);
|
||||
607→ 607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||||
608→ 608→ // 碎片解锁
|
||||
609→ 609→ const newFragments = { ...s.fragments };
|
||||
610→ 610→ if (result.fragments) {
|
||||
611→ 611→ for (const fid of result.fragments) newFragments[fid] = true;
|
||||
612→ 612→ }
|
||||
613→ 613→ // 日志
|
||||
614→ 614→ const logEntry = {
|
||||
615→ 615→ expeditionId: exp.id,
|
||||
616→ 616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
|
||||
617→ 617→ result: result.log,
|
||||
618→ 618→ rewards: [
|
||||
619→ 619→ result.crystals ? `+${result.crystals}晶体` : "",
|
||||
620→ 620→ result.insights ? `+${result.insights}洞见` : "",
|
||||
621→ 621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||||
622→ 622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||||
623→ 623→ ].filter(Boolean).join(" "),
|
||||
624→ 624→ timestamp: Date.now(),
|
||||
625→ 625→ };
|
||||
626→ 626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||||
627→ 627→
|
||||
628→ 628→ if (result.ended) {
|
||||
629→ 629→ // 探险结束(胜利或失败)
|
||||
630→ 630→ exp.finished = true;
|
||||
631→ 631→ }
|
||||
632→ 632→
|
||||
633→ 633→ // v0.4 编年史:击破 BOSS 时累计计数
|
||||
634→ 634→ let bossKills = s.bossKills ?? 0;
|
||||
635→ 635→ if (
|
||||
636→ 636→ result.ended &&
|
||||
637→ 637→ result.endReason === "victory" &&
|
||||
638→ 638→ exp.nodes[exp.currentNode]?.type === "boss"
|
||||
639→ 639→ ) {
|
||||
640→ 640→ bossKills = bossKills + 1;
|
||||
641→ 641→ }
|
||||
642→ 642→
|
||||
643→ 643→ set({
|
||||
644→ 644→ activeExpedition: exp,
|
||||
645→ 645→ crystals: newCrystals,
|
||||
646→ 646→ insights: newInsights,
|
||||
647→ 647→ contact: newContact,
|
||||
648→ 648→ fragments: newFragments,
|
||||
649→ 649→ expeditionLog: newLog,
|
||||
650→ 650→ bossKills,
|
||||
651→ 651→ });
|
||||
652→ 652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||||
653→ 653→ if (result.ended) {
|
||||
654→ 654→ trackBeacon("expedition", 1);
|
||||
655→ 655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
|
||||
656→ 656→ trackBeacon("boss", 1);
|
||||
657→ 657→ }
|
||||
658→ 658→ }
|
||||
659→ 659→ if (result.insights) trackBeacon("insight", result.insights);
|
||||
660→ 660→ return result;
|
||||
661→ 661→ },
|
||||
662→ 662→
|
||||
663→ 663→ advanceNode: () => {
|
||||
664→ 664→ const s = get();
|
||||
665→ 665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||||
666→ 666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
667→ 667→ const node = exp.nodes[exp.currentNode];
|
||||
668→ 668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||||
669→ 669→ if (exp.currentNode >= exp.nodes.length - 1) return;
|
||||
670→ 670→ exp.currentNode++;
|
||||
671→ 671→ set({ activeExpedition: exp });
|
||||
672→ 672→ },
|
||||
673→ 673→
|
||||
674→ 674→ abortExpedition: () => {
|
||||
675→ 675→ const s = get();
|
||||
676→ 676→ if (!s.activeExpedition) return;
|
||||
677→ 677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
678→ 678→ exp.finished = true;
|
||||
679→ 679→ const logEntry = {
|
||||
680→ 680→ expeditionId: exp.id,
|
||||
681→ 681→ nodeType: "rest" as const,
|
||||
682→ 682→ result: "探险队主动撤退,保留已获奖励。",
|
||||
683→ 683→ rewards: "",
|
||||
684→ 684→ timestamp: Date.now(),
|
||||
685→ 685→ };
|
||||
686→ 686→ set({
|
||||
687→ 687→ activeExpedition: exp,
|
||||
688→ 688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||||
689→ 689→ });
|
||||
690→ 690→ },
|
||||
691→ 691→
|
||||
692→ 692→ doPrestige: () => {
|
||||
693→ 693→ const s = get();
|
||||
694→ 694→ if (s.contact < CONTACT.prestigeMin) return null;
|
||||
695→ 695→ const newBp = computeNewBlueprints(s);
|
||||
696→ 696→ const next = performPrestige(s);
|
||||
697→ 697→ // 星图「能量共振」提升上限
|
||||
698→ 698→ const cm = constellationBonuses(next.constellation ?? []);
|
||||
699→ 699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
700→ 700→ set({
|
||||
701→ 701→ ...next,
|
||||
702→ 702→ energyMax,
|
||||
703→ 703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
|
||||
704→ 704→ _lastAutoDecode: Date.now(),
|
||||
705→ 705→ _lastSpawn: Date.now(),
|
||||
706→ 706→ _combo: 0,
|
||||
707→ 707→ _lastPulse: 0,
|
||||
708→ 708→ _tideEvents: [],
|
||||
709→ 709→ });
|
||||
710→ 710→ return { newBp };
|
||||
711→ 711→ },
|
||||
712→ 712→
|
||||
713→ 713→ chooseConstellationPerk: (perkId) => {
|
||||
714→ 714→ const s = get();
|
||||
715→ 715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||||
716→ 716→ const perk = getPerk(perkId);
|
||||
717→ 717→ if (!perk) return false;
|
||||
718→ 718→ if (s.constellation?.includes(perkId)) return false;
|
||||
719→ 719→ const newConstellation = [...(s.constellation ?? []), perkId];
|
||||
720→ 720→ const cm = constellationBonuses(newConstellation);
|
||||
721→ 721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
722→ 722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||||
723→ 723→ const chronicle = s.chronicle ?? [];
|
||||
724→ 724→ let newChronicle = chronicle;
|
||||
725→ 725→ if (chronicle.length > 0) {
|
||||
726→ 726→ const lastEntry = chronicle[chronicle.length - 1];
|
||||
727→ 727→ const updatedLast = withPerks(lastEntry, [perkId]);
|
||||
728→ 728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||||
729→ 729→ }
|
||||
730→ 730→ set({
|
||||
731→ 731→ constellation: newConstellation,
|
||||
732→ 732→ pendingPerkChoices: null,
|
||||
733→ 733→ energyMax,
|
||||
734→ 734→ chronicle: newChronicle,
|
||||
735→ 735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||||
736→ 736→ });
|
||||
737→ 737→ return true;
|
||||
738→ 738→ },
|
||||
739→ 739→
|
||||
740→ 740→ rerollPerkChoices: () => {
|
||||
741→ 741→ const s = get();
|
||||
742→ 742→ if (!s.pendingPerkChoices) return;
|
||||
743→ 743→ const choices = rollPerkChoices(s.constellation ?? []);
|
||||
744→ 744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||||
745→ 745→ },
|
||||
746→ 746→
|
||||
747→ 747→ checkAchievements: () => {
|
||||
748→ 748→ const s = get();
|
||||
749→ 749→ const newlyUnlocked: Achievement[] = [];
|
||||
750→ 750→ const updated = { ...s.achievements };
|
||||
751→ 751→ let crystals = s.crystals;
|
||||
752→ 752→ let insights = s.insights;
|
||||
753→ 753→ let contact = s.contact;
|
||||
754→ 754→ let statsDirty = false;
|
||||
755→ 755→ for (const a of ACHIEVEMENTS) {
|
||||
756→ 756→ if (updated[a.id]) continue;
|
||||
757→ 757→ if (a.check(s)) {
|
||||
758→ 758→ updated[a.id] = true;
|
||||
759→ 759→ newlyUnlocked.push(a);
|
||||
760→ 760→ // 发放即时奖励
|
||||
761→ 761→ if (a.reward.crystals) crystals += a.reward.crystals;
|
||||
762→ 762→ if (a.reward.insights) insights += a.reward.insights;
|
||||
763→ 763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
|
||||
764→ 764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
|
||||
765→ 765→ }
|
||||
766→ 766→ }
|
||||
767→ 767→ if (newlyUnlocked.length === 0) return [];
|
||||
768→ 768→ set({
|
||||
769→ 769→ achievements: updated,
|
||||
770→ 770→ crystals,
|
||||
771→ 771→ insights,
|
||||
772→ 772→ contact,
|
||||
773→ 773→ ...(statsDirty
|
||||
774→ 774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
|
||||
775→ 775→ : {}),
|
||||
776→ 776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||||
777→ 777→ });
|
||||
778→ 778→ return newlyUnlocked;
|
||||
779→ 779→ },
|
||||
780→ 780→
|
||||
781→ 781→ consumeAchievementQueue: () => {
|
||||
782→ 782→ const s = get();
|
||||
783→ 783→ if (s._achievementQueue.length === 0) return [];
|
||||
784→ 784→ const items = s._achievementQueue;
|
||||
785→ 785→ set({ _achievementQueue: [] });
|
||||
786→ 786→ return items;
|
||||
787→ 787→ },
|
||||
788→ 788→
|
||||
789→ 789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||||
790→ 790→
|
||||
791→ 791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||||
792→ 792→ toggleSound: () => set({ soundOn: !get().soundOn }),
|
||||
793→ 793→
|
||||
794→ 794→ // 深空信标:发放每日挑战奖励(v0.5)
|
||||
795→ 795→ grantBeaconReward: (insights, contact) => {
|
||||
796→ 796→ const s = get();
|
||||
797→ 797→ set({
|
||||
798→ 798→ insights: s.insights + Math.round(insights),
|
||||
799→ 799→ contact: Math.min(100, s.contact + contact),
|
||||
800→ 800→ });
|
||||
801→ 801→ },
|
||||
802→ 802→
|
||||
803→ 803→ // 深空巡航:发放实时玩法奖励(v0.6)
|
||||
804→ 804→ grantCruiseReward: (rewards) => {
|
||||
805→ 805→ const s = get();
|
||||
806→ 806→ const addCrystals = rewards.crystals ?? 0;
|
||||
807→ 807→ const addInsights = rewards.insights ?? 0;
|
||||
808→ 808→ const addContact = rewards.contact ?? 0;
|
||||
809→ 809→ set({
|
||||
810→ 810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||||
811→ 811→ insights: s.insights + Math.round(addInsights),
|
||||
812→ 812→ contact: Math.min(100, s.contact + addContact),
|
||||
813→ 813→ });
|
||||
814→ 814→ },
|
||||
815→ 815→ }),
|
||||
816→ 816→ {
|
||||
817→ 817→ name: "echo-nexus-save-v1",
|
||||
818→ 818→ storage: createJSONStorage(() => localStorage),
|
||||
819→ 819→ // 不持久化临时字段
|
||||
820→ 820→ partialize: (s) => {
|
||||
821→ 821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||||
822→ 822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||||
823→ 823→ return rest as GameState;
|
||||
824→ 824→ },
|
||||
825→ 825→ }
|
||||
826→ 826→ )
|
||||
827→ 827→);
|
||||
828→ 828→
|
||||
829→ 829→/** 选择器:未解锁碎片中下一个门槛 */
|
||||
830→ 830→export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||||
831→ 831→ for (const f of FRAGMENTS) {
|
||||
832→ 832→ if (totalDecoded < f.threshold) return f.threshold;
|
||||
833→ 833→ }
|
||||
834→ 834→ return null;
|
||||
835→ 835→}
|
||||
836→ 836→
|
||||
837→ 837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||||
838→ 838→
|
||||
839→ 839→// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||||
840→ 840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
841→ 841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||||
842→ 842→}
|
||||
843→ 843→
|
||||
+42
-9
@@ -158,23 +158,26 @@
|
||||
7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
|
||||
8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
|
||||
9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
|
||||
10. **CrystalOrb 仍为 CSS 动画**:v0.6 未完成 Canvas 粒子系统升级(上下文截断),是下一阶段第一优先级。
|
||||
10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
|
||||
|
||||
### 下一阶段优先级
|
||||
1. **🔴 P0 — CrystalOrb Canvas 粒子系统升级**:中央晶体球从 CSS 动画升级为 Canvas 粒子视觉(能量流动/晶体共振/脉冲冲击波/环境粒子),提升视觉震撼。文件:`src/components/game/CrystalOrb.tsx`,保留对 `data-tut="crystal-orb"` 锚点。
|
||||
2. **🟠 P1 — 角色属性系统**:探索力/智慧/勇气/灵感四维属性,影响探险/解码/星潮/巡航,增加 RPG 深度。
|
||||
3. **🟠 P1 — 巡航玩法增强**:BOSS 战关卡 / 事件选择节点 / 更多关卡类型 / 道具掉落。
|
||||
4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化。
|
||||
### 下一阶段优先级(v0.7 后修正版)
|
||||
> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
|
||||
|
||||
1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
|
||||
2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
|
||||
3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
|
||||
4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
|
||||
5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
|
||||
6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
|
||||
7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
|
||||
|
||||
### 定时任务(fixed_rate · 900s)
|
||||
- **当前 job_id**: 228266(2026-06-24 04:31 创建)
|
||||
- **配置**: `fixed_rate` + `"900"`(15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`
|
||||
- **当前 job_id**: **228357**(2026-06-24 13:25 重建)
|
||||
- **配置**: `fixed_rate` + `"900"`(15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`,`tz=Asia/Shanghai`
|
||||
- **任务名**: Echo Nexus - 任务审查与持续迭代
|
||||
- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
|
||||
- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
|
||||
- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(当前,fixed_rate版)
|
||||
- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
|
||||
|
||||
---
|
||||
|
||||
@@ -269,3 +272,33 @@ Stage Summary:
|
||||
- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
|
||||
- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
|
||||
- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
|
||||
|
||||
---
|
||||
Task ID: 8-deploy
|
||||
Agent: main
|
||||
Task: 重建定时任务 + 更新 README + 部署 gh-pages(v0.7)
|
||||
|
||||
Work Log:
|
||||
- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
|
||||
- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329(tz=Asia/Shanghai)
|
||||
- 重写 README.md:
|
||||
- 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
|
||||
- 版本号 v0.1 → v0.7
|
||||
- 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
|
||||
- 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
|
||||
- 新增本地开发命令 + Gitea Pages 部署指南
|
||||
- 文档索引扩展至 10 个版本文档
|
||||
- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6M(HTTP 200 编译 4.3s)
|
||||
- 推送 gh-pages 分支:force push origin gh-pages(commit 8727fed "deploy: v0.7")
|
||||
- 提交 main:README 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新")
|
||||
- 验证 Gitea Pages:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
|
||||
- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polished,minor 空白)
|
||||
|
||||
Stage Summary:
|
||||
- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
|
||||
- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
|
||||
- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
|
||||
- ✅ main 分支已同步推送(README + worklog 更新)
|
||||
- ✅ dev 服务器健康(HTTP 200,编译 < 250ms)
|
||||
- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
|
||||
- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
|
||||
|
||||
Reference in New Issue
Block a user