1332 lines
63 KiB
Plaintext
1332 lines
63 KiB
Plaintext
1→"use client";
|
||
2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
|
||
3→import { create } from "zustand";
|
||
4→import { persist, createJSONStorage } from "zustand/middleware";
|
||
5→import type {
|
||
6→ GameState,
|
||
7→ Crystal,
|
||
8→ CrystalTier,
|
||
9→ DecodePuzzle,
|
||
10→ ExpeditionResult,
|
||
11→} from "@/lib/game/types";
|
||
12→import {
|
||
13→ INITIAL_STATE,
|
||
14→ TECH_TREE,
|
||
15→ CRYSTAL_VALUE,
|
||
16→ CONTACT,
|
||
17→ CRYSTAL_SPAWN,
|
||
18→ FRAGMENTS,
|
||
19→ PRESTIGE,
|
||
20→} from "@/lib/game/config";
|
||
21→import {
|
||
22→ createInitialState,
|
||
23→ recomputeStats,
|
||
24→ decodeRewards,
|
||
25→ rollCrystalTierWithBonus,
|
||
26→ computeNewBlueprints,
|
||
27→ performPrestige,
|
||
28→} from "@/lib/game/engine";
|
||
29→import {
|
||
30→ generatePuzzle,
|
||
31→ tryClickNode,
|
||
32→ isSolvable,
|
||
33→ resetPuzzle as resetPuz,
|
||
34→} from "@/lib/game/decode";
|
||
35→import {
|
||
36→ generateExpedition,
|
||
37→ resolveNode,
|
||
38→ advanceExpedition,
|
||
39→ computeExpeditionPower,
|
||
40→ computeExpeditionHp,
|
||
41→ computeEnergyRegen,
|
||
42→ computeEnergyRegenInterval,
|
||
43→ EXPEDITION_CONFIG,
|
||
44→} from "@/lib/game/expedition";
|
||
45→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||
46→import {
|
||
47→ TIDE_CONFIG,
|
||
48→ rollTide,
|
||
49→ getTideModifiers,
|
||
50→ computeSilenceCompensation,
|
||
51→ type StarTide,
|
||
52→ type TideType,
|
||
53→} from "@/lib/game/starTide";
|
||
54→import {
|
||
55→ getPerk,
|
||
56→ constellationBonuses,
|
||
57→ rollPerkChoices,
|
||
58→} from "@/lib/game/constellation";
|
||
59→import {
|
||
60→ migrateChronicleFields,
|
||
61→ withPerks,
|
||
62→} from "@/lib/game/chronicle";
|
||
63→import {
|
||
64→ generateDailyChallenge,
|
||
65→ generateWeeklyChallenge,
|
||
66→ generateTimedChallenge,
|
||
67→ loadDailyProgress,
|
||
68→ loadWeeklyProgress,
|
||
69→ loadTimedProgress,
|
||
70→ loadChainState,
|
||
71→ addBeaconProgress,
|
||
72→ addWeeklyProgress,
|
||
73→ addTimedProgress,
|
||
74→ recordChainCompletion,
|
||
75→ claimWeeklyReward,
|
||
76→ claimTimedReward,
|
||
77→ claimChainMilestone,
|
||
78→ submitCloudScore,
|
||
79→ getTodayKey,
|
||
80→ type BeaconDailyChallenge,
|
||
81→ type BeaconDailyProgress,
|
||
82→ type BeaconWeeklyChallenge,
|
||
83→ type BeaconWeeklyProgress,
|
||
84→ type BeaconTimedChallenge,
|
||
85→ type BeaconTimedProgress,
|
||
86→} from "@/lib/game/beacon";
|
||
87→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||
88→import {
|
||
89→ ATTRIBUTE_HARD_CAP,
|
||
90→ migrateAttributes,
|
||
91→ levelUpCheck,
|
||
92→ getAllBonuses,
|
||
93→ createInitialAttributes,
|
||
94→ createInitialAttributeProgress,
|
||
95→ type AttributeKey,
|
||
96→ type CharacterAttributes,
|
||
97→ type AttributeProgress,
|
||
98→} from "@/lib/game/attributes";
|
||
99→
|
||
100→interface GameActions {
|
||
101→ // 生命周期
|
||
102→ init: () => void;
|
||
103→ loadOnline: () => void;
|
||
104→ hardReset: () => void;
|
||
105→
|
||
106→ // 主循环
|
||
107→ tick: (now: number) => void;
|
||
108→ pulse: () => { gain: number; combo: number } | null;
|
||
109→
|
||
110→ // 星潮
|
||
111→ tickTide: (now: number) => TideEvent | null;
|
||
112→ consumeTideEvents: () => TideEvent[];
|
||
113→ /** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
|
||
114→ triggerGlobalTide: (type: TideType) => void;
|
||
115→
|
||
116→ // 解码
|
||
117→ startDecode: (crystalId: string) => void;
|
||
118→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||
119→ undoStep: () => void;
|
||
120→ retryPuzzle: () => void;
|
||
121→ abandonPuzzle: () => void;
|
||
122→ /** 自动解码 T1(技术解锁后由 tick 调用) */
|
||
123→ autoDecodeTick: () => void;
|
||
124→
|
||
125→ // 探险
|
||
126→ startExpedition: () => { ok: boolean; reason?: string };
|
||
127→ resolveCurrentNode: () => ExpeditionResult | null;
|
||
128→ advanceNode: () => void;
|
||
129→ abortExpedition: () => void;
|
||
130→
|
||
131→ // 技术
|
||
132→ buyTech: (techId: string) => boolean;
|
||
133→
|
||
134→ // 飞升
|
||
135→ doPrestige: () => { newBp: number } | null;
|
||
136→
|
||
137→ // 星图天文台
|
||
138→ chooseConstellationPerk: (perkId: string) => boolean;
|
||
139→ rerollPerkChoices: () => void;
|
||
140→
|
||
141→ // 成就
|
||
142→ checkAchievements: () => Achievement[];
|
||
143→ consumeAchievementQueue: () => Achievement[];
|
||
144→
|
||
145→ // 设置
|
||
146→ toggleTheme: () => void;
|
||
147→ toggleSound: () => void;
|
||
148→
|
||
149→ // 深空信标奖励发放(v0.5)
|
||
150→ grantBeaconReward: (insights: number, contact: number) => void;
|
||
151→
|
||
152→ // 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2)
|
||
153→ claimWeeklyBeacon: () => {
|
||
154→ rewardInsight: number;
|
||
155→ rewardContact: number;
|
||
156→ score: number;
|
||
157→ };
|
||
158→ claimTimedBeacon: () => {
|
||
159→ rewardInsight: number;
|
||
160→ rewardContact: number;
|
||
161→ score: number;
|
||
162→ };
|
||
163→ claimChainReward: (milestone: number) => {
|
||
164→ rewardInsight: number;
|
||
165→ rewardContact: number;
|
||
166→ label: string;
|
||
167→ ok: boolean;
|
||
168→ };
|
||
169→
|
||
170→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||
171→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||
172→
|
||
173→ // 角色属性(v0.7 P1)
|
||
174→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
|
||
175→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
|
||
176→
|
||
177→ // 派生
|
||
178→ canPrestige: () => boolean;
|
||
179→}
|
||
180→
|
||
181→type Store = GameState & GameActions & {
|
||
182→ _lastAutoDecode: number;
|
||
183→ _lastSpawn: number;
|
||
184→ _combo: number;
|
||
185→ _lastPulse: number;
|
||
186→ _achievementQueue: Achievement[];
|
||
187→ _tideEvents: TideEvent[];
|
||
188→ /** P3 全球星潮状态(ephemeral,不持久化) */
|
||
189→ globalTide: GlobalTideState | null;
|
||
190→};
|
||
191→
|
||
192→/** 星潮事件(UI 消费用) */
|
||
193→type TideEvent = {
|
||
194→ started?: TideType;
|
||
195→ ended?: TideType;
|
||
196→ silenceCompensation?: number;
|
||
197→ /** 是否来自全球星潮(P3 socket 同步) */
|
||
198→ isGlobal?: boolean;
|
||
199→};
|
||
200→
|
||
201→/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
|
||
202→type GlobalTideState = {
|
||
203→ type: TideType;
|
||
204→ startedAt: number;
|
||
205→ endsAt: number;
|
||
206→ id: string;
|
||
207→};
|
||
208→
|
||
209→/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
|
||
210→const GLOBAL_TIDE_DURATION_MS = 60_000;
|
||
211→
|
||
212→/** 计算并写回产能字段 */
|
||
213→function syncStats(state: Partial<GameState>) {
|
||
214→ const s = recomputeStats(state);
|
||
215→ return {
|
||
216→ crystalsPerSec: s.crystalsPerSec,
|
||
217→ crystalCap: s.crystalCap,
|
||
218→ pulsePower: s.pulsePower,
|
||
219→ offlineEff: s.offlineEff,
|
||
220→ insightMult: s.insightMult,
|
||
221→ contactRateMult: s.contactRateMult,
|
||
222→ autoDecode: s.autoDecode,
|
||
223→ decodeStepsBonus: s.decodeStepsBonus,
|
||
224→ };
|
||
225→}
|
||
226→
|
||
227→/**
|
||
228→ * 深空信标进度追踪(v0.5 → v0.8 → v0.8.2 扩展)。
|
||
229→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
|
||
230→ * 1. 日挑战进度(按今日挑战类型增量)
|
||
231→ * 2. 限时挑战进度(按当前 4 小时时段挑战类型增量)
|
||
232→ * 3. 周挑战进度(按本周挑战类型增量)
|
||
233→ * 4. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||
234→ * 进度独立存储于 localStorage,不污染 GameState。
|
||
235→ * @returns 四类状态变更供 UI 触发通知
|
||
236→ */
|
||
237→function trackBeacon(
|
||
238→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||
239→ delta: number
|
||
240→): {
|
||
241→ dailyJustCompleted: boolean;
|
||
242→ timedJustCompleted: boolean;
|
||
243→ weeklyJustCompleted: boolean;
|
||
244→ newChainMilestones: number[];
|
||
245→} {
|
||
246→ const result = {
|
||
247→ dailyJustCompleted: false,
|
||
248→ timedJustCompleted: false,
|
||
249→ weeklyJustCompleted: false,
|
||
250→ newChainMilestones: [] as number[],
|
||
251→ };
|
||
252→ if (typeof window === "undefined") return result;
|
||
253→ try {
|
||
254→ // ---- 日挑战 ----
|
||
255→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||
256→ if (challenge.type === type) {
|
||
257→ const current: BeaconDailyProgress = loadDailyProgress();
|
||
258→ if (current.completedAt === null) {
|
||
259→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||
260→ result.dailyJustCompleted = justCompleted;
|
||
261→ // 日挑战刚完成 → 更新信标链
|
||
262→ if (justCompleted) {
|
||
263→ const { newMilestones } = recordChainCompletion(getTodayKey());
|
||
264→ result.newChainMilestones = newMilestones;
|
||
265→ }
|
||
266→ }
|
||
267→ }
|
||
268→
|
||
269→ // ---- 限时挑战(v0.8.2)----
|
||
270→ const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
|
||
271→ if (tChallenge.type === type) {
|
||
272→ const tCurrent: BeaconTimedProgress = loadTimedProgress();
|
||
273→ if (tCurrent.completedAt === null) {
|
||
274→ const { justCompleted } = addTimedProgress(
|
||
275→ tCurrent,
|
||
276→ tChallenge,
|
||
277→ delta
|
||
278→ );
|
||
279→ result.timedJustCompleted = justCompleted;
|
||
280→ }
|
||
281→ }
|
||
282→
|
||
283→ // ---- 周挑战 ----
|
||
284→ const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
|
||
285→ if (wChallenge.type === type) {
|
||
286→ const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress();
|
||
287→ if (wCurrent.completedAt === null) {
|
||
288→ const { justCompleted } = addWeeklyProgress(
|
||
289→ wCurrent,
|
||
290→ wChallenge,
|
||
291→ delta
|
||
292→ );
|
||
293→ result.weeklyJustCompleted = justCompleted;
|
||
294→ }
|
||
295→ }
|
||
296→
|
||
297→ return result;
|
||
298→ } catch {
|
||
299→ return result;
|
||
300→ }
|
||
301→}
|
||
302→
|
||
303→/** 检查并解锁叙事碎片 */
|
||
304→function checkFragments(state: GameState): string[] {
|
||
305→ const unlocked: string[] = [];
|
||
306→ for (const f of FRAGMENTS) {
|
||
307→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
|
||
308→ state.fragments[f.id] = true;
|
||
309→ unlocked.push(f.id);
|
||
310→ }
|
||
311→ }
|
||
312→ return unlocked;
|
||
313→}
|
||
314→
|
||
315→export const useGameStore = create<Store>()(
|
||
316→ persist(
|
||
317→ (set, get) => ({
|
||
318→ ...createInitialState(),
|
||
319→ _lastAutoDecode: Date.now(),
|
||
320→ _lastSpawn: Date.now(),
|
||
321→ _combo: 0,
|
||
322→ _lastPulse: 0,
|
||
323→ _achievementQueue: [],
|
||
324→ _tideEvents: [],
|
||
325→ globalTide: null,
|
||
326→
|
||
327→ init: () => {
|
||
328→ const s = get();
|
||
329→ const now = Date.now();
|
||
330→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
|
||
331→ let activePuzzle = s.activePuzzle;
|
||
332→ if (activePuzzle && !isSolvable(activePuzzle)) {
|
||
333→ // 把晶体放回队列,避免玩家卡死
|
||
334→ const crystal: Crystal = {
|
||
335→ id: `c_${now}_rec`,
|
||
336→ tier: activePuzzle.tier,
|
||
337→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
|
||
338→ createdAt: now,
|
||
339→ };
|
||
340→ activePuzzle = null;
|
||
341→ set({
|
||
342→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
343→ });
|
||
344→ }
|
||
345→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||
346→ const achievements = s.achievements ?? {};
|
||
347→ const activeTide = s.activeTide ?? null;
|
||
348→ const constellation = s.constellation ?? [];
|
||
349→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||
350→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||
351→ const migrated = migrateChronicleFields(s);
|
||
352→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints
|
||
353→ const attrMigrated = migrateAttributes(s);
|
||
354→ // 星图「能量共振」天赋 +1 能量上限
|
||
355→ const cm = constellationBonuses(constellation);
|
||
356→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||
357→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||
358→ const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||
359→ // 若旧存档有已过期的星潮,清掉
|
||
360→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||
361→ // 首次进入:补发离线收益
|
||
362→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||
363→ if (elapsed > 5) {
|
||
364→ const cap = 8 * 3600;
|
||
365→ const secs = Math.min(elapsed, cap);
|
||
366→ const gain = s.crystalsPerSec * secs * s.offlineEff;
|
||
367→ const crystalsBefore = s.crystals;
|
||
368→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
|
||
369→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
|
||
370→ setPendingOfflineReport({
|
||
371→ elapsedSec: secs,
|
||
372→ rawElapsedSec: elapsed,
|
||
373→ gain: crystalsAfter - crystalsBefore,
|
||
374→ rate: s.crystalsPerSec,
|
||
375→ eff: s.offlineEff,
|
||
376→ capped: elapsed > cap,
|
||
377→ crystalsBefore,
|
||
378→ crystalsAfter,
|
||
379→ crystalCap: s.crystalCap,
|
||
380→ });
|
||
381→ set({
|
||
382→ crystals: crystalsAfter,
|
||
383→ lastTick: now,
|
||
384→ activePuzzle,
|
||
385→ achievements,
|
||
386→ activeTide: tide,
|
||
387→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||
388→ constellation,
|
||
389→ pendingPerkChoices,
|
||
390→ energyMax,
|
||
391→ chronicle: migrated.chronicle,
|
||
392→ runStart: migrated.runStart,
|
||
393→ bossKills: migrated.bossKills,
|
||
394→ starTidesEncountered: migrated.starTidesEncountered,
|
||
395→ attributes: attrMigrated.attributes,
|
||
396→ attributeProgress: attrMigrated.attributeProgress,
|
||
397→ pendingAttrPoints: attrMigrated.pendingAttrPoints,
|
||
398→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
|
||
399→ });
|
||
400→ } else {
|
||
401→ 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, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) });
|
||
402→ }
|
||
403→ },
|
||
404→
|
||
405→ loadOnline: () => {
|
||
406→ const s = get();
|
||
407→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
|
||
408→ },
|
||
409→
|
||
410→ hardReset: () => {
|
||
411→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
|
||
412→ },
|
||
413→
|
||
414→ tickTide: (now) => {
|
||
415→ const s = get();
|
||
416→ // === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
|
||
417→ if (s.globalTide) {
|
||
418→ // 1a) 检查全球星潮是否结束
|
||
419→ if (now >= s.globalTide.endsAt) {
|
||
420→ const endedType = s.globalTide.type;
|
||
421→ let silenceCompensation = 0;
|
||
422→ if (endedType === "silence") {
|
||
423→ silenceCompensation = computeSilenceCompensation({
|
||
424→ type: endedType,
|
||
425→ startedAt: s.globalTide.startedAt,
|
||
426→ endsAt: s.globalTide.endsAt,
|
||
427→ id: s.globalTide.id,
|
||
428→ });
|
||
429→ }
|
||
430→ const event: TideEvent = { ended: endedType, isGlobal: true };
|
||
431→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||
432→ set({
|
||
433→ globalTide: null,
|
||
434→ activeTide: null,
|
||
435→ lastTideEnd: now,
|
||
436→ insights: s.insights + silenceCompensation,
|
||
437→ // 星潮结束后重算 stats
|
||
438→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||
439→ _tideEvents: [...s._tideEvents, event],
|
||
440→ });
|
||
441→ return event;
|
||
442→ }
|
||
443→ // 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
|
||
444→ return null;
|
||
445→ }
|
||
446→
|
||
447→ const tide = s.activeTide;
|
||
448→ // 星图「星潮引导」减少间隙
|
||
449→ const cm = constellationBonuses(s.constellation ?? []);
|
||
450→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙)
|
||
451→ const am = getAllBonuses(s.attributes ?? {});
|
||
452→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
|
||
453→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
|
||
454→ // 2) 检查当前本地星潮是否结束
|
||
455→ if (tide && now >= tide.endsAt) {
|
||
456→ const endedType = tide.type;
|
||
457→ // 寂静期补偿洞见
|
||
458→ let silenceCompensation = 0;
|
||
459→ if (tide.type === "silence") {
|
||
460→ silenceCompensation = computeSilenceCompensation(tide);
|
||
461→ }
|
||
462→ const newInsights = s.insights + silenceCompensation;
|
||
463→ const event: TideEvent = { ended: endedType };
|
||
464→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||
465→ set({
|
||
466→ activeTide: null,
|
||
467→ lastTideEnd: now,
|
||
468→ insights: newInsights,
|
||
469→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||
470→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||
471→ _tideEvents: [...s._tideEvents, event],
|
||
472→ });
|
||
473→ return event;
|
||
474→ }
|
||
475→ // 3) 检查是否该触发新星潮(间隙已过)
|
||
476→ if (!tide) {
|
||
477→ const since = now - s.lastTideEnd;
|
||
478→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||
479→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||
480→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||
481→ if (since >= need) {
|
||
482→ const type = rollTide();
|
||
483→ const newTide: StarTide = {
|
||
484→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
485→ type,
|
||
486→ startedAt: now,
|
||
487→ endsAt: now + TIDE_CONFIG.duration,
|
||
488→ };
|
||
489→ const event: TideEvent = { started: type };
|
||
490→ // v0.4 编年史:累计遇到的星潮 ID(去重)
|
||
491→ const tidesAll = s.starTidesEncountered ?? [];
|
||
492→ const tideId = `tide_${type}`;
|
||
493→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||
494→ set({
|
||
495→ activeTide: newTide,
|
||
496→ starTidesEncountered: newTidesAll,
|
||
497→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||
498→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||
499→ _tideEvents: [...s._tideEvents, event],
|
||
500→ });
|
||
501→ return event;
|
||
502→ }
|
||
503→ }
|
||
504→ return null;
|
||
505→ },
|
||
506→
|
||
507→ consumeTideEvents: () => {
|
||
508→ const s = get();
|
||
509→ if (s._tideEvents.length === 0) return [];
|
||
510→ const items = s._tideEvents;
|
||
511→ set({ _tideEvents: [] });
|
||
512→ return items;
|
||
513→ },
|
||
514→
|
||
515→ // P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
|
||
516→ triggerGlobalTide: (type) => {
|
||
517→ const s = get();
|
||
518→ const now = Date.now();
|
||
519→ const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
|
||
520→ const newTide: StarTide = {
|
||
521→ id,
|
||
522→ type,
|
||
523→ startedAt: now,
|
||
524→ endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||
525→ };
|
||
526→ const newGlobalTide: GlobalTideState = {
|
||
527→ type,
|
||
528→ startedAt: now,
|
||
529→ endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||
530→ id,
|
||
531→ };
|
||
532→ // 编年史:累计遇到的星潮 ID(去重)
|
||
533→ const tidesAll = s.starTidesEncountered ?? [];
|
||
534→ const tideId = `tide_${type}`;
|
||
535→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||
536→ const event: TideEvent = { started: type, isGlobal: true };
|
||
537→ set({
|
||
538→ globalTide: newGlobalTide,
|
||
539→ activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
|
||
540→ starTidesEncountered: newTidesAll,
|
||
541→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||
542→ _tideEvents: [...s._tideEvents, event],
|
||
543→ });
|
||
544→ },
|
||
545→
|
||
546→ tick: (now) => {
|
||
547→ const s = get();
|
||
548→ const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||
549→ if (dt <= 0) return;
|
||
550→
|
||
551→ // 星潮产能修饰(即时乘)
|
||
552→ const tideMod = getTideModifiers(s.activeTide);
|
||
553→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||
554→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||
555→ const newCrystals =
|
||
556→ s.crystals >= s.crystalCap
|
||
557→ ? s.crystals // 已达/超上限,不再自动产出
|
||
558→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||
559→
|
||
560→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||
561→ const bpBoost = 1 + s.blueprints.length * 0.03;
|
||
562→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
|
||
563→ let pending = s.pendingCrystals;
|
||
564→ let lastSpawn = s._lastSpawn;
|
||
565→ if (
|
||
566→ now - lastSpawn > spawnInterval &&
|
||
567→ pending.length < CRYSTAL_SPAWN.maxPending
|
||
568→ ) {
|
||
569→ // 星图「晶体富集」提升 T2/T3 概率
|
||
570→ const cm = constellationBonuses(s.constellation ?? []);
|
||
571→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||
572→ const crystal: Crystal = {
|
||
573→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
574→ tier,
|
||
575→ value: CRYSTAL_VALUE[tier].crystals,
|
||
576→ createdAt: now,
|
||
577→ };
|
||
578→ pending = [...pending, crystal];
|
||
579→ lastSpawn = now;
|
||
580→ }
|
||
581→
|
||
582→ // 能量恢复(探险系统,v0.8.1 动态间隔)
|
||
583→ let energy = s.energy;
|
||
584→ let lastEnergyTick = s.lastEnergyTick;
|
||
585→ if (energy < s.energyMax) {
|
||
586→ const intervalSec = computeEnergyRegenInterval(s);
|
||
587→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec);
|
||
588→ energy = regen.energy;
|
||
589→ lastEnergyTick = regen.lastTick;
|
||
590→ } else {
|
||
591→ lastEnergyTick = now;
|
||
592→ }
|
||
593→
|
||
594→ set({
|
||
595→ crystals: newCrystals,
|
||
596→ lastTick: now,
|
||
597→ pendingCrystals: pending,
|
||
598→ _lastSpawn: lastSpawn,
|
||
599→ energy,
|
||
600→ lastEnergyTick,
|
||
601→ });
|
||
602→ },
|
||
603→
|
||
604→ pulse: () => {
|
||
605→ const s = get();
|
||
606→ const now = Date.now();
|
||
607→ // 连击
|
||
608→ let combo = 1;
|
||
609→ if (now - s._lastPulse < 1500) {
|
||
610→ combo = Math.min(10, s._combo + 1);
|
||
611→ }
|
||
612→ const mult = 1 + (combo - 1) * 0.15;
|
||
613→ // 星潮脉冲威力修饰
|
||
614→ const tideMod = getTideModifiers(s.activeTide);
|
||
615→ // v0.7 灵感:脉冲连击加成 +X%
|
||
616→ const am = getAllBonuses(s.attributes ?? {});
|
||
617→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1);
|
||
618→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult;
|
||
619→ set({
|
||
620→ crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||
621→ _combo: combo,
|
||
622→ _lastPulse: now,
|
||
623→ });
|
||
624→ // 深空信标:脉冲任务进度 +1
|
||
625→ trackBeacon("pulse", 1);
|
||
626→ // v0.7 角色属性:连击 ≥3 给灵感经验
|
||
627→ if (combo >= 3) {
|
||
628→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6
|
||
629→ // 内联经验获取(避免递归调用 set)
|
||
630→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 };
|
||
631→ const nextExp = prog.exp + expGain;
|
||
632→ const lvlResult = levelUpCheck(
|
||
633→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 },
|
||
634→ ATTRIBUTE_HARD_CAP
|
||
635→ );
|
||
636→ const newAttributes: CharacterAttributes = {
|
||
637→ ...(s.attributes ?? createInitialAttributes()),
|
||
638→ inspiration: lvlResult.newProgress.level,
|
||
639→ };
|
||
640→ const newProgress: AttributeProgress = {
|
||
641→ ...(s.attributeProgress ?? createInitialAttributeProgress()),
|
||
642→ inspiration: lvlResult.newProgress,
|
||
643→ };
|
||
644→ set({
|
||
645→ attributes: newAttributes,
|
||
646→ attributeProgress: newProgress,
|
||
647→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
648→ });
|
||
649→ }
|
||
650→ return { gain, combo };
|
||
651→ },
|
||
652→
|
||
653→ startDecode: (crystalId) => {
|
||
654→ const s = get();
|
||
655→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||
656→ if (!crystal) return;
|
||
657→ // v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
|
||
658→ const tideMod = getTideModifiers(s.activeTide);
|
||
659→ const targetLenBonus = tideMod.targetLenBonus ?? 0;
|
||
660→ const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
|
||
661→ set({
|
||
662→ activePuzzle: puzzle,
|
||
663→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||
664→ });
|
||
665→ },
|
||
666→
|
||
667→ clickNode: (nodeId) => {
|
||
668→ const s = get();
|
||
669→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
|
||
670→ // 深拷贝谜题
|
||
671→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
672→ const res = tryClickNode(puzzle, nodeId);
|
||
673→ if (res.ok) {
|
||
674→ if (res.finished) {
|
||
675→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||
676→ const tideMod = getTideModifiers(s.activeTide);
|
||
677→ const cm = constellationBonuses(s.constellation ?? []);
|
||
678→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||
679→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
680→ const rewards = {
|
||
681→ crystals: Math.round(base.crystals * finalMult),
|
||
682→ insights: Math.round(base.insights * finalMult),
|
||
683→ contact: +(base.contact * finalMult).toFixed(2),
|
||
684→ };
|
||
685→ const newTotal = s.totalDecoded + 1;
|
||
686→ const newContact = Math.min(100, s.contact + rewards.contact);
|
||
687→ const newInsights = s.insights + rewards.insights;
|
||
688→ const newCrystals = s.crystals + rewards.crystals;
|
||
689→ // 解锁碎片
|
||
690→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
691→ const unlocked = checkFragments(tentative);
|
||
692→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多)
|
||
693→ const wisdomExpGain = puzzle.tier * 2;
|
||
694→ const curAttrs = s.attributes ?? createInitialAttributes();
|
||
695→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
696→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||
697→ const wisdomLvl = levelUpCheck(
|
||
698→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom },
|
||
699→ ATTRIBUTE_HARD_CAP
|
||
700→ );
|
||
701→ const newAttributes: CharacterAttributes = {
|
||
702→ ...curAttrs,
|
||
703→ wisdom: wisdomLvl.newProgress.level,
|
||
704→ };
|
||
705→ const newProgress: AttributeProgress = {
|
||
706→ ...curProg,
|
||
707→ wisdom: wisdomLvl.newProgress,
|
||
708→ };
|
||
709→ set({
|
||
710→ activePuzzle: null,
|
||
711→ crystals: newCrystals,
|
||
712→ insights: newInsights,
|
||
713→ contact: newContact,
|
||
714→ totalDecoded: newTotal,
|
||
715→ fragments: tentative.fragments,
|
||
716→ attributes: newAttributes,
|
||
717→ attributeProgress: newProgress,
|
||
718→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
719→ });
|
||
720→ // 深空信标:解码 +1,洞见累计
|
||
721→ trackBeacon("decode", 1);
|
||
722→ trackBeacon("insight", rewards.insights);
|
||
723→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||
724→ }
|
||
725→ // 点击成功但未完成:检测当前局面是否仍可解
|
||
726→ const solvable = isSolvable(puzzle);
|
||
727→ set({ activePuzzle: puzzle });
|
||
728→ return { ok: true, finished: false, solvable };
|
||
729→ }
|
||
730→ return res;
|
||
731→ },
|
||
732→
|
||
733→ undoStep: () => {
|
||
734→ const s = get();
|
||
735→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
|
||
736→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
737→ const lastId = puzzle.path.pop();
|
||
738→ if (lastId !== undefined) {
|
||
739→ const node = puzzle.grid.find((n) => n.id === lastId);
|
||
740→ if (node) node.used = false;
|
||
741→ }
|
||
742→ set({ activePuzzle: puzzle });
|
||
743→ },
|
||
744→
|
||
745→ retryPuzzle: () => {
|
||
746→ const s = get();
|
||
747→ if (!s.activePuzzle) return;
|
||
748→ set({ activePuzzle: resetPuz(s.activePuzzle) });
|
||
749→ },
|
||
750→
|
||
751→ abandonPuzzle: () => {
|
||
752→ const s = get();
|
||
753→ if (!s.activePuzzle) return;
|
||
754→ // 晶体放回队列末尾
|
||
755→ const crystal: Crystal = {
|
||
756→ id: `c_${Date.now()}_ret`,
|
||
757→ tier: s.activePuzzle.tier,
|
||
758→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
|
||
759→ createdAt: Date.now(),
|
||
760→ };
|
||
761→ set({
|
||
762→ activePuzzle: null,
|
||
763→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
764→ });
|
||
765→ },
|
||
766→
|
||
767→ autoDecodeTick: () => {
|
||
768→ const s = get();
|
||
769→ if (!s.autoDecode) return;
|
||
770→ const now = Date.now();
|
||
771→ // 星图「自动校准」减少自动解码周期
|
||
772→ const cm = constellationBonuses(s.constellation ?? []);
|
||
773→ // v0.7 智慧:自动解码周期 -X%
|
||
774→ const am = getAllBonuses(s.attributes ?? {});
|
||
775→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
|
||
776→ // v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
|
||
777→ const tideModEarly = getTideModifiers(s.activeTide);
|
||
778→ const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
|
||
779→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
|
||
780→ if (now - s._lastAutoDecode < interval) return;
|
||
781→ // 找一颗 T1 晶体自动解码
|
||
782→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||
783→ if (idx < 0) return;
|
||
784→ const crystal = s.pendingCrystals[idx];
|
||
785→ const tideMod = getTideModifiers(s.activeTide);
|
||
786→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||
787→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
788→ const rewards = {
|
||
789→ crystals: Math.round(base.crystals * finalMult),
|
||
790→ insights: Math.round(base.insights * finalMult),
|
||
791→ contact: +(base.contact * finalMult).toFixed(2),
|
||
792→ };
|
||
793→ const newTotal = s.totalDecoded + 1;
|
||
794→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
795→ checkFragments(tentative);
|
||
796→ // v0.7 角色属性:自动解码给智慧经验(少量)
|
||
797→ const curAttrs = s.attributes ?? createInitialAttributes();
|
||
798→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
799→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||
800→ const wisdomLvl = levelUpCheck(
|
||
801→ { exp: progEntry.exp + 1, level: curAttrs.wisdom },
|
||
802→ ATTRIBUTE_HARD_CAP
|
||
803→ );
|
||
804→ const newAttributes: CharacterAttributes = {
|
||
805→ ...curAttrs,
|
||
806→ wisdom: wisdomLvl.newProgress.level,
|
||
807→ };
|
||
808→ const newProgress: AttributeProgress = {
|
||
809→ ...curProg,
|
||
810→ wisdom: wisdomLvl.newProgress,
|
||
811→ };
|
||
812→ set({
|
||
813→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||
814→ crystals: s.crystals + rewards.crystals,
|
||
815→ insights: s.insights + rewards.insights,
|
||
816→ contact: Math.min(100, s.contact + rewards.contact),
|
||
817→ totalDecoded: newTotal,
|
||
818→ fragments: tentative.fragments,
|
||
819→ _lastAutoDecode: now,
|
||
820→ attributes: newAttributes,
|
||
821→ attributeProgress: newProgress,
|
||
822→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
823→ });
|
||
824→ // 深空信标:自动解码也算进度
|
||
825→ trackBeacon("decode", 1);
|
||
826→ trackBeacon("insight", rewards.insights);
|
||
827→ },
|
||
828→
|
||
829→ buyTech: (techId) => {
|
||
830→ const s = get();
|
||
831→ const node = TECH_TREE.find((t) => t.id === techId);
|
||
832→ if (!node) return false;
|
||
833→ const cur = s.tech[techId] ?? 0;
|
||
834→ if (cur >= 1) return false; // v0.1 每节点 1 级
|
||
835→ if (s.insights < node.cost) return false;
|
||
836→ const newTech = { ...s.tech, [techId]: 1 };
|
||
837→ set({
|
||
838→ insights: s.insights - node.cost,
|
||
839→ tech: newTech,
|
||
840→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
|
||
841→ });
|
||
842→ return true;
|
||
843→ },
|
||
844→
|
||
845→ // ============ 探险系统 ============
|
||
846→ startExpedition: () => {
|
||
847→ const s = get();
|
||
848→ if (s.activeExpedition && !s.activeExpedition.finished) {
|
||
849→ return { ok: false, reason: "已有进行中的探险" };
|
||
850→ }
|
||
851→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||
852→ return { ok: false, reason: "能量不足" };
|
||
853→ }
|
||
854→ const tideMod = getTideModifiers(s.activeTide);
|
||
855→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命
|
||
856→ const am = getAllBonuses(s.attributes ?? {});
|
||
857→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||
858→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||
859→ const power = Math.round(basePower * am.expeditionPowerMult);
|
||
860→ const hp = baseHp + am.expeditionHpBonus;
|
||
861→ const seed = Math.floor(Math.random() * 1e9);
|
||
862→ const expedition = generateExpedition(seed, power, hp);
|
||
863→ set({
|
||
864→ activeExpedition: expedition,
|
||
865→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||
866→ totalExpeditions: s.totalExpeditions + 1,
|
||
867→ });
|
||
868→ return { ok: true };
|
||
869→ },
|
||
870→
|
||
871→ resolveCurrentNode: () => {
|
||
872→ const s = get();
|
||
873→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||
874→ // 深拷贝
|
||
875→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
876→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
|
||
877→ // v0.8.2 蚀相星潮:BOSS 胜率 +20%
|
||
878→ const nodeBefore = exp.nodes[exp.currentNode];
|
||
879→ const isBossNode = nodeBefore?.type === "boss";
|
||
880→ const am = getAllBonuses(s.attributes ?? {});
|
||
881→ const tideMod = getTideModifiers(s.activeTide);
|
||
882→ const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
|
||
883→ const result = isBossNode
|
||
884→ ? resolveNode(exp, () => {
|
||
885→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
|
||
886→ const r = Math.random();
|
||
887→ const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
|
||
888→ return r < b ? 0 : Math.min(1, r - b);
|
||
889→ })
|
||
890→ : resolveNode(exp);
|
||
891→ // 累计奖励
|
||
892→ if (result.crystals) exp.rewards.crystals += result.crystals;
|
||
893→ if (result.insights) exp.rewards.insights += result.insights;
|
||
894→ if (result.contact) exp.rewards.contact += result.contact;
|
||
895→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||
896→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||
897→ // 实时入账(玩家立即获得)
|
||
898→ const newCrystals = s.crystals + (result.crystals || 0);
|
||
899→ const newInsights = s.insights + (result.insights || 0);
|
||
900→ const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||
901→ // 碎片解锁
|
||
902→ const newFragments = { ...s.fragments };
|
||
903→ if (result.fragments) {
|
||
904→ for (const fid of result.fragments) newFragments[fid] = true;
|
||
905→ }
|
||
906→ // 日志
|
||
907→ const logEntry = {
|
||
908→ expeditionId: exp.id,
|
||
909→ nodeType: nodeBefore?.type || "combat",
|
||
910→ result: result.log,
|
||
911→ rewards: [
|
||
912→ result.crystals ? `+${result.crystals}晶体` : "",
|
||
913→ result.insights ? `+${result.insights}洞见` : "",
|
||
914→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||
915→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||
916→ ].filter(Boolean).join(" "),
|
||
917→ timestamp: Date.now(),
|
||
918→ };
|
||
919→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||
920→
|
||
921→ if (result.ended) {
|
||
922→ // 探险结束(胜利或失败)
|
||
923→ exp.finished = true;
|
||
924→ }
|
||
925→
|
||
926→ // v0.4 编年史:击破 BOSS 时累计计数
|
||
927→ let bossKills = s.bossKills ?? 0;
|
||
928→ let bossKilledThisNode = false;
|
||
929→ if (
|
||
930→ result.ended &&
|
||
931→ result.endReason === "victory" &&
|
||
932→ nodeBefore?.type === "boss"
|
||
933→ ) {
|
||
934→ bossKills = bossKills + 1;
|
||
935→ bossKilledThisNode = true;
|
||
936→ }
|
||
937→
|
||
938→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励
|
||
939→ let newAttributes = s.attributes ?? createInitialAttributes();
|
||
940→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress();
|
||
941→ let statsNeedResync = false;
|
||
942→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验
|
||
943→ const isCombatVictory =
|
||
944→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") &&
|
||
945→ !result.ended; // 中途战斗胜利(未结束探险)
|
||
946→ const isExpeditionVictory =
|
||
947→ result.ended && result.endReason === "victory";
|
||
948→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) {
|
||
949→ const courageGain = bossKilledThisNode ? 8 : 2;
|
||
950→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1;
|
||
951→ const courageLvl = levelUpCheck(
|
||
952→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage },
|
||
953→ ATTRIBUTE_HARD_CAP
|
||
954→ );
|
||
955→ const explLvl = levelUpCheck(
|
||
956→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration },
|
||
957→ ATTRIBUTE_HARD_CAP
|
||
958→ );
|
||
959→ newAttributes = {
|
||
960→ ...newAttributes,
|
||
961→ courage: courageLvl.newProgress.level,
|
||
962→ exploration: explLvl.newProgress.level,
|
||
963→ };
|
||
964→ newProgress = {
|
||
965→ ...newProgress,
|
||
966→ courage: courageLvl.newProgress,
|
||
967→ exploration: explLvl.newProgress,
|
||
968→ };
|
||
969→ statsNeedResync = true;
|
||
970→ }
|
||
971→
|
||
972→ set({
|
||
973→ activeExpedition: exp,
|
||
974→ crystals: newCrystals,
|
||
975→ insights: newInsights,
|
||
976→ contact: newContact,
|
||
977→ fragments: newFragments,
|
||
978→ expeditionLog: newLog,
|
||
979→ bossKills,
|
||
980→ attributes: newAttributes,
|
||
981→ attributeProgress: newProgress,
|
||
982→ ...(statsNeedResync
|
||
983→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
|
||
984→ : {}),
|
||
985→ });
|
||
986→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||
987→ if (result.ended) {
|
||
988→ trackBeacon("expedition", 1);
|
||
989→ if (bossKilledThisNode) {
|
||
990→ trackBeacon("boss", 1);
|
||
991→ }
|
||
992→ }
|
||
993→ if (result.insights) trackBeacon("insight", result.insights);
|
||
994→ return result;
|
||
995→ },
|
||
996→
|
||
997→ advanceNode: () => {
|
||
998→ const s = get();
|
||
999→ if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||
1000→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
1001→ const node = exp.nodes[exp.currentNode];
|
||
1002→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||
1003→ if (exp.currentNode >= exp.nodes.length - 1) return;
|
||
1004→ exp.currentNode++;
|
||
1005→ set({ activeExpedition: exp });
|
||
1006→ },
|
||
1007→
|
||
1008→ abortExpedition: () => {
|
||
1009→ const s = get();
|
||
1010→ if (!s.activeExpedition) return;
|
||
1011→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
1012→ exp.finished = true;
|
||
1013→ const logEntry = {
|
||
1014→ expeditionId: exp.id,
|
||
1015→ nodeType: "rest" as const,
|
||
1016→ result: "探险队主动撤退,保留已获奖励。",
|
||
1017→ rewards: "",
|
||
1018→ timestamp: Date.now(),
|
||
1019→ };
|
||
1020→ set({
|
||
1021→ activeExpedition: exp,
|
||
1022→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||
1023→ });
|
||
1024→ },
|
||
1025→
|
||
1026→ doPrestige: () => {
|
||
1027→ const s = get();
|
||
1028→ if (s.contact < CONTACT.prestigeMin) return null;
|
||
1029→ const newBp = computeNewBlueprints(s);
|
||
1030→ const next = performPrestige(s);
|
||
1031→ // 星图「能量共振」提升上限
|
||
1032→ const cm = constellationBonuses(next.constellation ?? []);
|
||
1033→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
1034→ set({
|
||
1035→ ...next,
|
||
1036→ energyMax,
|
||
1037→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
|
||
1038→ _lastAutoDecode: Date.now(),
|
||
1039→ _lastSpawn: Date.now(),
|
||
1040→ _combo: 0,
|
||
1041→ _lastPulse: 0,
|
||
1042→ _tideEvents: [],
|
||
1043→ });
|
||
1044→ return { newBp };
|
||
1045→ },
|
||
1046→
|
||
1047→ chooseConstellationPerk: (perkId) => {
|
||
1048→ const s = get();
|
||
1049→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||
1050→ const perk = getPerk(perkId);
|
||
1051→ if (!perk) return false;
|
||
1052→ if (s.constellation?.includes(perkId)) return false;
|
||
1053→ const newConstellation = [...(s.constellation ?? []), perkId];
|
||
1054→ const cm = constellationBonuses(newConstellation);
|
||
1055→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
1056→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||
1057→ const chronicle = s.chronicle ?? [];
|
||
1058→ let newChronicle = chronicle;
|
||
1059→ if (chronicle.length > 0) {
|
||
1060→ const lastEntry = chronicle[chronicle.length - 1];
|
||
1061→ const updatedLast = withPerks(lastEntry, [perkId]);
|
||
1062→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||
1063→ }
|
||
1064→ set({
|
||
1065→ constellation: newConstellation,
|
||
1066→ pendingPerkChoices: null,
|
||
1067→ energyMax,
|
||
1068→ chronicle: newChronicle,
|
||
1069→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
|
||
1070→ });
|
||
1071→ return true;
|
||
1072→ },
|
||
1073→
|
||
1074→ rerollPerkChoices: () => {
|
||
1075→ const s = get();
|
||
1076→ if (!s.pendingPerkChoices) return;
|
||
1077→ const choices = rollPerkChoices(s.constellation ?? []);
|
||
1078→ if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||
1079→ },
|
||
1080→
|
||
1081→ checkAchievements: () => {
|
||
1082→ const s = get();
|
||
1083→ const newlyUnlocked: Achievement[] = [];
|
||
1084→ const updated = { ...s.achievements };
|
||
1085→ let crystals = s.crystals;
|
||
1086→ let insights = s.insights;
|
||
1087→ let contact = s.contact;
|
||
1088→ let statsDirty = false;
|
||
1089→ for (const a of ACHIEVEMENTS) {
|
||
1090→ if (updated[a.id]) continue;
|
||
1091→ if (a.check(s)) {
|
||
1092→ updated[a.id] = true;
|
||
1093→ newlyUnlocked.push(a);
|
||
1094→ // 发放即时奖励
|
||
1095→ if (a.reward.crystals) crystals += a.reward.crystals;
|
||
1096→ if (a.reward.insights) insights += a.reward.insights;
|
||
1097→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
|
||
1098→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
|
||
1099→ }
|
||
1100→ }
|
||
1101→ if (newlyUnlocked.length === 0) return [];
|
||
1102→ set({
|
||
1103→ achievements: updated,
|
||
1104→ crystals,
|
||
1105→ insights,
|
||
1106→ contact,
|
||
1107→ ...(statsDirty
|
||
1108→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
|
||
1109→ : {}),
|
||
1110→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||
1111→ });
|
||
1112→ return newlyUnlocked;
|
||
1113→ },
|
||
1114→
|
||
1115→ consumeAchievementQueue: () => {
|
||
1116→ const s = get();
|
||
1117→ if (s._achievementQueue.length === 0) return [];
|
||
1118→ const items = s._achievementQueue;
|
||
1119→ set({ _achievementQueue: [] });
|
||
1120→ return items;
|
||
1121→ },
|
||
1122→
|
||
1123→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||
1124→
|
||
1125→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||
1126→ toggleSound: () => set({ soundOn: !get().soundOn }),
|
||
1127→
|
||
1128→ // 深空信标:发放每日挑战奖励(v0.5)
|
||
1129→ grantBeaconReward: (insights, contact) => {
|
||
1130→ const s = get();
|
||
1131→ set({
|
||
1132→ insights: s.insights + Math.round(insights),
|
||
1133→ contact: Math.min(100, s.contact + contact),
|
||
1134→ });
|
||
1135→ },
|
||
1136→
|
||
1137→ // 深空信标:领取周挑战奖励(v0.8)
|
||
1138→ claimWeeklyBeacon: () => {
|
||
1139→ const challenge = generateWeeklyChallenge();
|
||
1140→ const progress = loadWeeklyProgress();
|
||
1141→ const res = claimWeeklyReward(challenge, progress);
|
||
1142→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
1143→ const s = get();
|
||
1144→ set({
|
||
1145→ insights: s.insights + Math.round(res.rewardInsight),
|
||
1146→ contact: Math.min(100, s.contact + res.rewardContact),
|
||
1147→ });
|
||
1148→ // 异步提交到云排行榜(失败静默,不影响本地)
|
||
1149→ if (res.entry) {
|
||
1150→ void submitCloudScore(res.entry);
|
||
1151→ }
|
||
1152→ }
|
||
1153→ return {
|
||
1154→ rewardInsight: res.rewardInsight,
|
||
1155→ rewardContact: res.rewardContact,
|
||
1156→ score: res.score,
|
||
1157→ };
|
||
1158→ },
|
||
1159→
|
||
1160→ // 深空信标:领取限时挑战奖励(v0.8.2)
|
||
1161→ claimTimedBeacon: () => {
|
||
1162→ const challenge = generateTimedChallenge();
|
||
1163→ const progress = loadTimedProgress();
|
||
1164→ const res = claimTimedReward(challenge, progress);
|
||
1165→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
1166→ const s = get();
|
||
1167→ set({
|
||
1168→ insights: s.insights + Math.round(res.rewardInsight),
|
||
1169→ contact: Math.min(100, s.contact + res.rewardContact),
|
||
1170→ });
|
||
1171→ // 异步提交到云排行榜(失败静默,不影响本地)
|
||
1172→ if (res.entry) {
|
||
1173→ void submitCloudScore(res.entry);
|
||
1174→ }
|
||
1175→ }
|
||
1176→ return {
|
||
1177→ rewardInsight: res.rewardInsight,
|
||
1178→ rewardContact: res.rewardContact,
|
||
1179→ score: res.score,
|
||
1180→ };
|
||
1181→ },
|
||
1182→
|
||
1183→ // 深空信标:领取信标链里程碑奖励(v0.8)
|
||
1184→ claimChainReward: (milestone) => {
|
||
1185→ // loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成
|
||
1186→ const pre = loadChainState();
|
||
1187→ if (
|
||
1188→ pre.currentStreak < milestone ||
|
||
1189→ pre.milestonesClaimed.includes(milestone)
|
||
1190→ ) {
|
||
1191→ return {
|
||
1192→ rewardInsight: 0,
|
||
1193→ rewardContact: 0,
|
||
1194→ label: "",
|
||
1195→ ok: false,
|
||
1196→ };
|
||
1197→ }
|
||
1198→ const res = claimChainMilestone(milestone);
|
||
1199→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
1200→ const s = get();
|
||
1201→ set({
|
||
1202→ insights: s.insights + Math.round(res.rewardInsight),
|
||
1203→ contact: Math.min(100, s.contact + res.rewardContact),
|
||
1204→ });
|
||
1205→ }
|
||
1206→ return {
|
||
1207→ rewardInsight: res.rewardInsight,
|
||
1208→ rewardContact: res.rewardContact,
|
||
1209→ label: res.label,
|
||
1210→ ok: res.rewardInsight > 0 || res.rewardContact > 0,
|
||
1211→ };
|
||
1212→ },
|
||
1213→
|
||
1214→ // 深空巡航:发放实时玩法奖励(v0.6)
|
||
1215→ grantCruiseReward: (rewards) => {
|
||
1216→ const s = get();
|
||
1217→ const addCrystals = rewards.crystals ?? 0;
|
||
1218→ const addInsights = rewards.insights ?? 0;
|
||
1219→ const addContact = rewards.contact ?? 0;
|
||
1220→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放)
|
||
1221→ const totalReward = addCrystals + addInsights * 10 + addContact * 10;
|
||
1222→ const expBase = Math.max(2, Math.floor(totalReward / 30));
|
||
1223→ const curAttrs = s.attributes ?? createInitialAttributes();
|
||
1224→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
1225→ const explLvl = levelUpCheck(
|
||
1226→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration },
|
||
1227→ ATTRIBUTE_HARD_CAP
|
||
1228→ );
|
||
1229→ const courageLvl = levelUpCheck(
|
||
1230→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage },
|
||
1231→ ATTRIBUTE_HARD_CAP
|
||
1232→ );
|
||
1233→ const newAttributes: CharacterAttributes = {
|
||
1234→ ...curAttrs,
|
||
1235→ exploration: explLvl.newProgress.level,
|
||
1236→ courage: courageLvl.newProgress.level,
|
||
1237→ };
|
||
1238→ const newProgress: AttributeProgress = {
|
||
1239→ ...curProg,
|
||
1240→ exploration: explLvl.newProgress,
|
||
1241→ courage: courageLvl.newProgress,
|
||
1242→ };
|
||
1243→ set({
|
||
1244→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||
1245→ insights: s.insights + Math.round(addInsights),
|
||
1246→ contact: Math.min(100, s.contact + addContact),
|
||
1247→ attributes: newAttributes,
|
||
1248→ attributeProgress: newProgress,
|
||
1249→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
1250→ });
|
||
1251→ },
|
||
1252→
|
||
1253→ // ============ 角色属性系统(v0.7 P1) ============
|
||
1254→ allocateAttribute: (attr, points = 1) => {
|
||
1255→ const s = get();
|
||
1256→ const cur = s.attributes ?? createInitialAttributes();
|
||
1257→ const curVal = cur[attr] ?? 0;
|
||
1258→ if (curVal >= ATTRIBUTE_HARD_CAP) {
|
||
1259→ return { ok: false, leveledUp: 0 };
|
||
1260→ }
|
||
1261→ if ((s.pendingAttrPoints ?? 0) < points) {
|
||
1262→ return { ok: false, leveledUp: 0 };
|
||
1263→ }
|
||
1264→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints);
|
||
1265→ const newVal = curVal + alloc;
|
||
1266→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal };
|
||
1267→ // 同步经验进度 level 字段(保持一致)
|
||
1268→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
1269→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal };
|
||
1270→ const newProgress: AttributeProgress = {
|
||
1271→ ...curProg,
|
||
1272→ [attr]: { exp: oldProg.exp, level: newVal },
|
||
1273→ };
|
||
1274→ set({
|
||
1275→ attributes: newAttributes,
|
||
1276→ attributeProgress: newProgress,
|
||
1277→ pendingAttrPoints: s.pendingAttrPoints - alloc,
|
||
1278→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
1279→ });
|
||
1280→ return { ok: true, leveledUp: alloc };
|
||
1281→ },
|
||
1282→
|
||
1283→ gainAttributeExp: (attr, amount) => {
|
||
1284→ const s = get();
|
||
1285→ const cur = s.attributes ?? createInitialAttributes();
|
||
1286→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
1287→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] };
|
||
1288→ const result = levelUpCheck(
|
||
1289→ { exp: progEntry.exp + amount, level: cur[attr] },
|
||
1290→ ATTRIBUTE_HARD_CAP
|
||
1291→ );
|
||
1292→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level };
|
||
1293→ const newProgress: AttributeProgress = {
|
||
1294→ ...curProg,
|
||
1295→ [attr]: result.newProgress,
|
||
1296→ };
|
||
1297→ set({
|
||
1298→ attributes: newAttributes,
|
||
1299→ attributeProgress: newProgress,
|
||
1300→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
1301→ });
|
||
1302→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
|
||
1303→ },
|
||
1304→ }),
|
||
1305→ {
|
||
1306→ name: "echo-nexus-save-v1",
|
||
1307→ storage: createJSONStorage(() => localStorage),
|
||
1308→ // 不持久化临时字段
|
||
1309→ partialize: (s) => {
|
||
1310→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
|
||
1311→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
|
||
1312→ return rest as GameState;
|
||
1313→ },
|
||
1314→ }
|
||
1315→ )
|
||
1316→);
|
||
1317→
|
||
1318→/** 选择器:未解锁碎片中下一个门槛 */
|
||
1319→export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||
1320→ for (const f of FRAGMENTS) {
|
||
1321→ if (totalDecoded < f.threshold) return f.threshold;
|
||
1322→ }
|
||
1323→ return null;
|
||
1324→}
|
||
1325→
|
||
1326→export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||
1327→
|
||
1328→// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||
1329→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||
1330→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||
1331→}
|
||
1332→ |