改 key 名会导致旧用户存档丢失。保持 key 名不变, version 0→2, persist 自动检测 version 变化调用 migrate 合并字段。
1408 lines
55 KiB
TypeScript
Executable File
1408 lines
55 KiB
TypeScript
Executable File
"use client";
|
||
// 回响星核 / Echo Nexus — Zustand 游戏状态管理
|
||
import { create } from "zustand";
|
||
import { persist, createJSONStorage } from "zustand/middleware";
|
||
import type {
|
||
GameState,
|
||
Crystal,
|
||
CrystalTier,
|
||
DecodePuzzle,
|
||
ExpeditionResult,
|
||
} from "@/lib/game/types";
|
||
import {
|
||
INITIAL_STATE,
|
||
TECH_TREE,
|
||
CRYSTAL_VALUE,
|
||
CONTACT,
|
||
CRYSTAL_SPAWN,
|
||
FRAGMENTS,
|
||
PRESTIGE,
|
||
} from "@/lib/game/config";
|
||
import {
|
||
createInitialState,
|
||
recomputeStats,
|
||
decodeRewards,
|
||
rollCrystalTierWithBonus,
|
||
computeNewBlueprints,
|
||
performPrestige,
|
||
} from "@/lib/game/engine";
|
||
import {
|
||
generatePuzzle,
|
||
tryClickNode,
|
||
isSolvable,
|
||
resetPuzzle as resetPuz,
|
||
} from "@/lib/game/decode";
|
||
import {
|
||
generateExpedition,
|
||
resolveNode,
|
||
advanceExpedition,
|
||
computeExpeditionPower,
|
||
computeExpeditionHp,
|
||
computeEnergyRegen,
|
||
computeEnergyRegenInterval,
|
||
EXPEDITION_CONFIG,
|
||
} from "@/lib/game/expedition";
|
||
import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||
import {
|
||
TIDE_CONFIG,
|
||
rollTide,
|
||
getTideModifiers,
|
||
computeSilenceCompensation,
|
||
type StarTide,
|
||
type TideType,
|
||
} from "@/lib/game/starTide";
|
||
import {
|
||
getPerk,
|
||
constellationBonuses,
|
||
rollPerkChoices,
|
||
} from "@/lib/game/constellation";
|
||
import {
|
||
migrateChronicleFields,
|
||
withPerks,
|
||
} from "@/lib/game/chronicle";
|
||
import {
|
||
generateDailyChallenge,
|
||
generateWeeklyChallenge,
|
||
generateTimedChallenge,
|
||
loadDailyProgress,
|
||
loadWeeklyProgress,
|
||
loadTimedProgress,
|
||
loadChainState,
|
||
addBeaconProgress,
|
||
addWeeklyProgress,
|
||
addTimedProgress,
|
||
recordChainCompletion,
|
||
claimWeeklyReward,
|
||
claimTimedReward,
|
||
claimChainMilestone,
|
||
submitCloudScore,
|
||
getTodayKey,
|
||
type BeaconDailyChallenge,
|
||
type BeaconDailyProgress,
|
||
type BeaconWeeklyChallenge,
|
||
type BeaconWeeklyProgress,
|
||
type BeaconTimedChallenge,
|
||
type BeaconTimedProgress,
|
||
} from "@/lib/game/beacon";
|
||
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||
import {
|
||
ATTRIBUTE_HARD_CAP,
|
||
migrateAttributes,
|
||
levelUpCheck,
|
||
getAllBonuses,
|
||
createInitialAttributes,
|
||
createInitialAttributeProgress,
|
||
type AttributeKey,
|
||
type CharacterAttributes,
|
||
type AttributeProgress,
|
||
} from "@/lib/game/attributes";
|
||
|
||
interface GameActions {
|
||
// 生命周期
|
||
init: () => void;
|
||
loadOnline: () => void;
|
||
hardReset: () => void;
|
||
|
||
// 主循环
|
||
tick: (now: number) => void;
|
||
pulse: () => { gain: number; combo: number } | null;
|
||
|
||
// 星潮
|
||
tickTide: (now: number) => TideEvent | null;
|
||
consumeTideEvents: () => TideEvent[];
|
||
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
|
||
triggerGlobalTide: (type: TideType) => void;
|
||
|
||
// 解码
|
||
startDecode: (crystalId: string) => void;
|
||
clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||
undoStep: () => void;
|
||
retryPuzzle: () => void;
|
||
abandonPuzzle: () => void;
|
||
/** 自动解码 T1(技术解锁后由 tick 调用) */
|
||
autoDecodeTick: () => void;
|
||
|
||
// 探险
|
||
startExpedition: () => { ok: boolean; reason?: string };
|
||
resolveCurrentNode: () => ExpeditionResult | null;
|
||
advanceNode: () => void;
|
||
abortExpedition: () => void;
|
||
/** v0.9 工单修复:手动关闭已结束的探险,回到入口 */
|
||
dismissExpedition: () => void;
|
||
|
||
// 技术
|
||
buyTech: (techId: string) => boolean;
|
||
|
||
// 飞升
|
||
doPrestige: () => { newBp: number } | null;
|
||
|
||
// 星图天文台
|
||
chooseConstellationPerk: (perkId: string) => boolean;
|
||
rerollPerkChoices: () => void;
|
||
|
||
// 成就
|
||
checkAchievements: () => Achievement[];
|
||
consumeAchievementQueue: () => Achievement[];
|
||
|
||
// 设置
|
||
toggleTheme: () => void;
|
||
toggleSound: () => void;
|
||
|
||
// 深空信标奖励发放(v0.5)
|
||
grantBeaconReward: (insights: number, contact: number) => void;
|
||
|
||
// 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2)
|
||
claimWeeklyBeacon: () => {
|
||
rewardInsight: number;
|
||
rewardContact: number;
|
||
score: number;
|
||
};
|
||
claimTimedBeacon: () => {
|
||
rewardInsight: number;
|
||
rewardContact: number;
|
||
score: number;
|
||
};
|
||
claimChainReward: (milestone: number) => {
|
||
rewardInsight: number;
|
||
rewardContact: number;
|
||
label: string;
|
||
ok: boolean;
|
||
};
|
||
|
||
// 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||
grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||
|
||
// 角色属性(v0.7 P1)
|
||
allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
|
||
gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
|
||
|
||
// 派生
|
||
canPrestige: () => boolean;
|
||
}
|
||
|
||
type Store = GameState & GameActions & {
|
||
_lastAutoDecode: number;
|
||
_lastSpawn: number;
|
||
_combo: number;
|
||
_lastPulse: number;
|
||
_achievementQueue: Achievement[];
|
||
_tideEvents: TideEvent[];
|
||
/** P3 全球星潮状态(ephemeral,不持久化) */
|
||
globalTide: GlobalTideState | null;
|
||
};
|
||
|
||
/** 星潮事件(UI 消费用) */
|
||
type TideEvent = {
|
||
started?: TideType;
|
||
ended?: TideType;
|
||
silenceCompensation?: number;
|
||
/** 是否来自全球星潮(P3 socket 同步) */
|
||
isGlobal?: boolean;
|
||
};
|
||
|
||
/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
|
||
type GlobalTideState = {
|
||
type: TideType;
|
||
startedAt: number;
|
||
endsAt: number;
|
||
id: string;
|
||
};
|
||
|
||
/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
|
||
const GLOBAL_TIDE_DURATION_MS = 60_000;
|
||
|
||
/** 计算并写回产能字段 */
|
||
function syncStats(state: Partial<GameState>) {
|
||
const s = recomputeStats(state);
|
||
return {
|
||
crystalsPerSec: s.crystalsPerSec,
|
||
crystalCap: s.crystalCap,
|
||
pulsePower: s.pulsePower,
|
||
offlineEff: s.offlineEff,
|
||
insightMult: s.insightMult,
|
||
contactRateMult: s.contactRateMult,
|
||
autoDecode: s.autoDecode,
|
||
decodeStepsBonus: s.decodeStepsBonus,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 深空信标进度追踪(v0.5 → v0.8 → v0.8.2 扩展)。
|
||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
|
||
* 1. 日挑战进度(按今日挑战类型增量)
|
||
* 2. 限时挑战进度(按当前 4 小时时段挑战类型增量)
|
||
* 3. 周挑战进度(按本周挑战类型增量)
|
||
* 4. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||
* 进度独立存储于 localStorage,不污染 GameState。
|
||
* @returns 四类状态变更供 UI 触发通知
|
||
*/
|
||
function trackBeacon(
|
||
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||
delta: number
|
||
): {
|
||
dailyJustCompleted: boolean;
|
||
timedJustCompleted: boolean;
|
||
weeklyJustCompleted: boolean;
|
||
newChainMilestones: number[];
|
||
} {
|
||
const result = {
|
||
dailyJustCompleted: false,
|
||
timedJustCompleted: false,
|
||
weeklyJustCompleted: false,
|
||
newChainMilestones: [] as number[],
|
||
};
|
||
if (typeof window === "undefined") return result;
|
||
try {
|
||
// ---- 日挑战 ----
|
||
const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||
if (challenge.type === type) {
|
||
const current: BeaconDailyProgress = loadDailyProgress();
|
||
if (current.completedAt === null) {
|
||
const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||
result.dailyJustCompleted = justCompleted;
|
||
// 日挑战刚完成 → 更新信标链
|
||
if (justCompleted) {
|
||
const { newMilestones } = recordChainCompletion(getTodayKey());
|
||
result.newChainMilestones = newMilestones;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 限时挑战(v0.8.2)----
|
||
const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
|
||
if (tChallenge.type === type) {
|
||
const tCurrent: BeaconTimedProgress = loadTimedProgress();
|
||
if (tCurrent.completedAt === null) {
|
||
const { justCompleted } = addTimedProgress(
|
||
tCurrent,
|
||
tChallenge,
|
||
delta
|
||
);
|
||
result.timedJustCompleted = justCompleted;
|
||
}
|
||
}
|
||
|
||
// ---- 周挑战 ----
|
||
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
|
||
if (wChallenge.type === type) {
|
||
const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress();
|
||
if (wCurrent.completedAt === null) {
|
||
const { justCompleted } = addWeeklyProgress(
|
||
wCurrent,
|
||
wChallenge,
|
||
delta
|
||
);
|
||
result.weeklyJustCompleted = justCompleted;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
} catch {
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/** 检查并解锁叙事碎片 */
|
||
function checkFragments(state: GameState): string[] {
|
||
const unlocked: string[] = [];
|
||
for (const f of FRAGMENTS) {
|
||
if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
|
||
state.fragments[f.id] = true;
|
||
unlocked.push(f.id);
|
||
}
|
||
}
|
||
return unlocked;
|
||
}
|
||
|
||
export const useGameStore = create<Store>()(
|
||
persist(
|
||
(set, get) => ({
|
||
...createInitialState(),
|
||
_lastAutoDecode: Date.now(),
|
||
_lastSpawn: Date.now(),
|
||
_combo: 0,
|
||
_lastPulse: 0,
|
||
_achievementQueue: [],
|
||
_tideEvents: [],
|
||
globalTide: null,
|
||
|
||
init: () => {
|
||
const s = get();
|
||
const now = Date.now();
|
||
// 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
|
||
let activePuzzle = s.activePuzzle;
|
||
if (activePuzzle && !isSolvable(activePuzzle)) {
|
||
// 把晶体放回队列,避免玩家卡死
|
||
const crystal: Crystal = {
|
||
id: `c_${now}_rec`,
|
||
tier: activePuzzle.tier,
|
||
value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
|
||
createdAt: now,
|
||
};
|
||
activePuzzle = null;
|
||
set({
|
||
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
});
|
||
}
|
||
// 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||
const achievements = s.achievements ?? {};
|
||
const activeTide = s.activeTide ?? null;
|
||
const constellation = s.constellation ?? [];
|
||
const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||
// v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||
const migrated = migrateChronicleFields(s);
|
||
// v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints
|
||
const attrMigrated = migrateAttributes(s);
|
||
// 星图「能量共振」天赋 +1 能量上限
|
||
const cm = constellationBonuses(constellation);
|
||
const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||
// lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||
const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||
// 若旧存档有已过期的星潮,清掉
|
||
const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||
// 首次进入:补发离线收益
|
||
const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||
if (elapsed > 5) {
|
||
const cap = 8 * 3600;
|
||
const secs = Math.min(elapsed, cap);
|
||
const gain = s.crystalsPerSec * secs * s.offlineEff;
|
||
const crystalsBefore = s.crystals;
|
||
const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
|
||
// 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
|
||
setPendingOfflineReport({
|
||
elapsedSec: secs,
|
||
rawElapsedSec: elapsed,
|
||
gain: crystalsAfter - crystalsBefore,
|
||
rate: s.crystalsPerSec,
|
||
eff: s.offlineEff,
|
||
capped: elapsed > cap,
|
||
crystalsBefore,
|
||
crystalsAfter,
|
||
crystalCap: s.crystalCap,
|
||
});
|
||
set({
|
||
crystals: crystalsAfter,
|
||
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 }),
|
||
});
|
||
} else {
|
||
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 }) });
|
||
}
|
||
},
|
||
|
||
loadOnline: () => {
|
||
const s = get();
|
||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
|
||
},
|
||
|
||
hardReset: () => {
|
||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
|
||
},
|
||
|
||
tickTide: (now) => {
|
||
const s = get();
|
||
// === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
|
||
if (s.globalTide) {
|
||
// 1a) 检查全球星潮是否结束
|
||
if (now >= s.globalTide.endsAt) {
|
||
const endedType = s.globalTide.type;
|
||
let silenceCompensation = 0;
|
||
if (endedType === "silence") {
|
||
silenceCompensation = computeSilenceCompensation({
|
||
type: endedType,
|
||
startedAt: s.globalTide.startedAt,
|
||
endsAt: s.globalTide.endsAt,
|
||
id: s.globalTide.id,
|
||
});
|
||
}
|
||
const event: TideEvent = { ended: endedType, isGlobal: true };
|
||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||
set({
|
||
globalTide: null,
|
||
activeTide: null,
|
||
lastTideEnd: now,
|
||
insights: s.insights + silenceCompensation,
|
||
// 星潮结束后重算 stats
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||
_tideEvents: [...s._tideEvents, event],
|
||
});
|
||
return event;
|
||
}
|
||
// 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
|
||
return null;
|
||
}
|
||
|
||
const tide = s.activeTide;
|
||
// 星图「星潮引导」减少间隙
|
||
const cm = constellationBonuses(s.constellation ?? []);
|
||
// v0.7 灵感:星潮触发概率 +X%(缩短间隙)
|
||
const am = getAllBonuses(s.attributes ?? {});
|
||
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
|
||
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
|
||
// 2) 检查当前本地星潮是否结束
|
||
if (tide && now >= tide.endsAt) {
|
||
const endedType = tide.type;
|
||
// 寂静期补偿洞见
|
||
let silenceCompensation = 0;
|
||
if (tide.type === "silence") {
|
||
silenceCompensation = computeSilenceCompensation(tide);
|
||
}
|
||
const newInsights = s.insights + silenceCompensation;
|
||
const event: TideEvent = { ended: endedType };
|
||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||
set({
|
||
activeTide: null,
|
||
lastTideEnd: now,
|
||
insights: newInsights,
|
||
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||
_tideEvents: [...s._tideEvents, event],
|
||
});
|
||
return event;
|
||
}
|
||
// 3) 检查是否该触发新星潮(间隙已过)
|
||
if (!tide) {
|
||
const since = now - s.lastTideEnd;
|
||
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||
const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||
const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||
if (since >= need) {
|
||
const type = rollTide();
|
||
const newTide: StarTide = {
|
||
id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
type,
|
||
startedAt: now,
|
||
endsAt: now + TIDE_CONFIG.duration,
|
||
};
|
||
const event: TideEvent = { started: type };
|
||
// v0.4 编年史:累计遇到的星潮 ID(去重)
|
||
const tidesAll = s.starTidesEncountered ?? [];
|
||
const tideId = `tide_${type}`;
|
||
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||
set({
|
||
activeTide: newTide,
|
||
starTidesEncountered: newTidesAll,
|
||
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||
_tideEvents: [...s._tideEvents, event],
|
||
});
|
||
return event;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
consumeTideEvents: () => {
|
||
const s = get();
|
||
if (s._tideEvents.length === 0) return [];
|
||
const items = s._tideEvents;
|
||
set({ _tideEvents: [] });
|
||
return items;
|
||
},
|
||
|
||
// P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
|
||
triggerGlobalTide: (type) => {
|
||
const s = get();
|
||
const now = Date.now();
|
||
const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const newTide: StarTide = {
|
||
id,
|
||
type,
|
||
startedAt: now,
|
||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||
};
|
||
const newGlobalTide: GlobalTideState = {
|
||
type,
|
||
startedAt: now,
|
||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||
id,
|
||
};
|
||
// 编年史:累计遇到的星潮 ID(去重)
|
||
const tidesAll = s.starTidesEncountered ?? [];
|
||
const tideId = `tide_${type}`;
|
||
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||
const event: TideEvent = { started: type, isGlobal: true };
|
||
set({
|
||
globalTide: newGlobalTide,
|
||
activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
|
||
starTidesEncountered: newTidesAll,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||
_tideEvents: [...s._tideEvents, event],
|
||
});
|
||
},
|
||
|
||
tick: (now) => {
|
||
const s = get();
|
||
const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||
if (dt <= 0) return;
|
||
|
||
// 星潮产能修饰(即时乘)
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||
// 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||
const newCrystals =
|
||
s.crystals >= s.crystalCap
|
||
? s.crystals // 已达/超上限,不再自动产出
|
||
: Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||
|
||
// 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||
const bpBoost = 1 + s.blueprints.length * 0.03;
|
||
const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
|
||
let pending = s.pendingCrystals;
|
||
let lastSpawn = s._lastSpawn;
|
||
if (
|
||
now - lastSpawn > spawnInterval &&
|
||
pending.length < CRYSTAL_SPAWN.maxPending
|
||
) {
|
||
// 星图「晶体富集」提升 T2/T3 概率
|
||
const cm = constellationBonuses(s.constellation ?? []);
|
||
const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||
const crystal: Crystal = {
|
||
id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||
tier,
|
||
value: CRYSTAL_VALUE[tier].crystals,
|
||
createdAt: now,
|
||
};
|
||
pending = [...pending, crystal];
|
||
lastSpawn = now;
|
||
}
|
||
|
||
// 能量恢复(探险系统,v0.8.1 动态间隔)
|
||
let energy = s.energy;
|
||
let lastEnergyTick = s.lastEnergyTick;
|
||
if (energy < s.energyMax) {
|
||
const intervalSec = computeEnergyRegenInterval(s);
|
||
const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec);
|
||
energy = regen.energy;
|
||
lastEnergyTick = regen.lastTick;
|
||
} else {
|
||
lastEnergyTick = now;
|
||
}
|
||
|
||
set({
|
||
crystals: newCrystals,
|
||
lastTick: now,
|
||
pendingCrystals: pending,
|
||
_lastSpawn: lastSpawn,
|
||
energy,
|
||
lastEnergyTick,
|
||
});
|
||
},
|
||
|
||
pulse: () => {
|
||
const s = get();
|
||
const now = Date.now();
|
||
// 连击
|
||
let combo = 1;
|
||
if (now - s._lastPulse < 1500) {
|
||
combo = Math.min(10, s._combo + 1);
|
||
}
|
||
const mult = 1 + (combo - 1) * 0.15;
|
||
// 星潮脉冲威力修饰
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
// v0.7 灵感:脉冲连击加成 +X%
|
||
const am = getAllBonuses(s.attributes ?? {});
|
||
const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1);
|
||
const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult;
|
||
set({
|
||
crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||
_combo: combo,
|
||
_lastPulse: now,
|
||
});
|
||
// 深空信标:脉冲任务进度 +1
|
||
trackBeacon("pulse", 1);
|
||
// v0.7 角色属性:连击 ≥3 给灵感经验
|
||
if (combo >= 3) {
|
||
const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6
|
||
// 内联经验获取(避免递归调用 set)
|
||
const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 };
|
||
const nextExp = prog.exp + expGain;
|
||
const lvlResult = levelUpCheck(
|
||
{ exp: nextExp, level: s.attributes?.inspiration ?? 0 },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const newAttributes: CharacterAttributes = {
|
||
...(s.attributes ?? createInitialAttributes()),
|
||
inspiration: lvlResult.newProgress.level,
|
||
};
|
||
const newProgress: AttributeProgress = {
|
||
...(s.attributeProgress ?? createInitialAttributeProgress()),
|
||
inspiration: lvlResult.newProgress,
|
||
};
|
||
set({
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
}
|
||
return { gain, combo };
|
||
},
|
||
|
||
startDecode: (crystalId) => {
|
||
const s = get();
|
||
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||
if (!crystal) return;
|
||
// v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
const targetLenBonus = tideMod.targetLenBonus ?? 0;
|
||
const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
|
||
set({
|
||
activePuzzle: puzzle,
|
||
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||
});
|
||
},
|
||
|
||
clickNode: (nodeId) => {
|
||
const s = get();
|
||
if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
|
||
// 深拷贝谜题
|
||
const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
const res = tryClickNode(puzzle, nodeId);
|
||
if (res.ok) {
|
||
if (res.finished) {
|
||
// 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
const cm = constellationBonuses(s.constellation ?? []);
|
||
const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
const rewards = {
|
||
crystals: Math.round(base.crystals * finalMult),
|
||
insights: Math.round(base.insights * finalMult),
|
||
contact: +(base.contact * finalMult).toFixed(2),
|
||
};
|
||
const newTotal = s.totalDecoded + 1;
|
||
const newContact = Math.min(100, s.contact + rewards.contact);
|
||
const newInsights = s.insights + rewards.insights;
|
||
const newCrystals = s.crystals + rewards.crystals;
|
||
// 解锁碎片
|
||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
const unlocked = checkFragments(tentative);
|
||
// v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多)
|
||
const wisdomExpGain = puzzle.tier * 2;
|
||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||
const wisdomLvl = levelUpCheck(
|
||
{ exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const newAttributes: CharacterAttributes = {
|
||
...curAttrs,
|
||
wisdom: wisdomLvl.newProgress.level,
|
||
};
|
||
const newProgress: AttributeProgress = {
|
||
...curProg,
|
||
wisdom: wisdomLvl.newProgress,
|
||
};
|
||
set({
|
||
activePuzzle: null,
|
||
crystals: newCrystals,
|
||
insights: newInsights,
|
||
contact: newContact,
|
||
totalDecoded: newTotal,
|
||
fragments: tentative.fragments,
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
// 深空信标:解码 +1,洞见累计
|
||
trackBeacon("decode", 1);
|
||
trackBeacon("insight", rewards.insights);
|
||
return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||
}
|
||
// 点击成功但未完成:检测当前局面是否仍可解
|
||
const solvable = isSolvable(puzzle);
|
||
set({ activePuzzle: puzzle });
|
||
return { ok: true, finished: false, solvable };
|
||
}
|
||
return res;
|
||
},
|
||
|
||
undoStep: () => {
|
||
const s = get();
|
||
if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
|
||
const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||
const lastId = puzzle.path.pop();
|
||
if (lastId !== undefined) {
|
||
const node = puzzle.grid.find((n) => n.id === lastId);
|
||
if (node) node.used = false;
|
||
}
|
||
set({ activePuzzle: puzzle });
|
||
},
|
||
|
||
retryPuzzle: () => {
|
||
const s = get();
|
||
if (!s.activePuzzle) return;
|
||
set({ activePuzzle: resetPuz(s.activePuzzle) });
|
||
},
|
||
|
||
abandonPuzzle: () => {
|
||
const s = get();
|
||
if (!s.activePuzzle) return;
|
||
// 晶体放回队列末尾
|
||
const crystal: Crystal = {
|
||
id: `c_${Date.now()}_ret`,
|
||
tier: s.activePuzzle.tier,
|
||
value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
|
||
createdAt: Date.now(),
|
||
};
|
||
set({
|
||
activePuzzle: null,
|
||
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||
});
|
||
},
|
||
|
||
autoDecodeTick: () => {
|
||
const s = get();
|
||
if (!s.autoDecode) return;
|
||
const now = Date.now();
|
||
// 星图「自动校准」减少自动解码周期
|
||
const cm = constellationBonuses(s.constellation ?? []);
|
||
// v0.7 智慧:自动解码周期 -X%
|
||
const am = getAllBonuses(s.attributes ?? {});
|
||
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
|
||
// v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
|
||
const tideModEarly = getTideModifiers(s.activeTide);
|
||
const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
|
||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
|
||
if (now - s._lastAutoDecode < interval) return;
|
||
// 找一颗 T1 晶体自动解码;v0.9 dec_5「全息解码核心」解锁后可自动解码 T2
|
||
let autoTier: CrystalTier = 1;
|
||
let idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||
if (idx < 0 && s.tech?.dec_5) {
|
||
idx = s.pendingCrystals.findIndex((c) => c.tier === 2);
|
||
autoTier = 2;
|
||
}
|
||
if (idx < 0) return;
|
||
const crystal = s.pendingCrystals[idx];
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
const base = decodeRewards(autoTier, s.insightMult, s.contactRateMult);
|
||
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||
const rewards = {
|
||
crystals: Math.round(base.crystals * finalMult),
|
||
insights: Math.round(base.insights * finalMult),
|
||
contact: +(base.contact * finalMult).toFixed(2),
|
||
};
|
||
const newTotal = s.totalDecoded + 1;
|
||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||
checkFragments(tentative);
|
||
// v0.7 角色属性:自动解码给智慧经验(少量)
|
||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||
const wisdomLvl = levelUpCheck(
|
||
{ exp: progEntry.exp + 1, level: curAttrs.wisdom },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const newAttributes: CharacterAttributes = {
|
||
...curAttrs,
|
||
wisdom: wisdomLvl.newProgress.level,
|
||
};
|
||
const newProgress: AttributeProgress = {
|
||
...curProg,
|
||
wisdom: wisdomLvl.newProgress,
|
||
};
|
||
set({
|
||
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||
crystals: s.crystals + rewards.crystals,
|
||
insights: s.insights + rewards.insights,
|
||
contact: Math.min(100, s.contact + rewards.contact),
|
||
totalDecoded: newTotal,
|
||
fragments: tentative.fragments,
|
||
_lastAutoDecode: now,
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
// 深空信标:自动解码也算进度
|
||
trackBeacon("decode", 1);
|
||
trackBeacon("insight", rewards.insights);
|
||
},
|
||
|
||
buyTech: (techId) => {
|
||
const s = get();
|
||
const node = TECH_TREE.find((t) => t.id === techId);
|
||
if (!node) return false;
|
||
const cur = s.tech[techId] ?? 0;
|
||
if (cur >= 1) return false; // v0.1 每节点 1 级
|
||
if (s.insights < node.cost) return false;
|
||
const newTech = { ...s.tech, [techId]: 1 };
|
||
set({
|
||
insights: s.insights - node.cost,
|
||
tech: newTech,
|
||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
|
||
});
|
||
return true;
|
||
},
|
||
|
||
// ============ 探险系统 ============
|
||
startExpedition: () => {
|
||
const s = get();
|
||
if (s.activeExpedition && !s.activeExpedition.finished) {
|
||
return { ok: false, reason: "已有进行中的探险" };
|
||
}
|
||
if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||
return { ok: false, reason: "能量不足" };
|
||
}
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
// v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命
|
||
const am = getAllBonuses(s.attributes ?? {});
|
||
const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||
const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||
const power = Math.round(basePower * am.expeditionPowerMult);
|
||
const hp = baseHp + am.expeditionHpBonus;
|
||
const seed = Math.floor(Math.random() * 1e9);
|
||
const expedition = generateExpedition(seed, power, hp);
|
||
set({
|
||
activeExpedition: expedition,
|
||
energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||
totalExpeditions: s.totalExpeditions + 1,
|
||
});
|
||
return { ok: true };
|
||
},
|
||
|
||
resolveCurrentNode: () => {
|
||
const s = get();
|
||
if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||
// 深拷贝
|
||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
|
||
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
|
||
const nodeBefore = exp.nodes[exp.currentNode];
|
||
const isBossNode = nodeBefore?.type === "boss";
|
||
const am = getAllBonuses(s.attributes ?? {});
|
||
const tideMod = getTideModifiers(s.activeTide);
|
||
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
|
||
const result = isBossNode
|
||
? resolveNode(exp, () => {
|
||
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
|
||
const r = Math.random();
|
||
const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
|
||
return r < b ? 0 : Math.min(1, r - b);
|
||
})
|
||
: resolveNode(exp);
|
||
// 累计奖励
|
||
if (result.crystals) exp.rewards.crystals += result.crystals;
|
||
if (result.insights) exp.rewards.insights += result.insights;
|
||
if (result.contact) exp.rewards.contact += result.contact;
|
||
if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||
if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||
// 实时入账(玩家立即获得)
|
||
const newCrystals = s.crystals + (result.crystals || 0);
|
||
const newInsights = s.insights + (result.insights || 0);
|
||
const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||
// 碎片解锁
|
||
const newFragments = { ...s.fragments };
|
||
if (result.fragments) {
|
||
for (const fid of result.fragments) newFragments[fid] = true;
|
||
}
|
||
// 日志
|
||
const logEntry = {
|
||
expeditionId: exp.id,
|
||
nodeType: nodeBefore?.type || "combat",
|
||
result: result.log,
|
||
rewards: [
|
||
result.crystals ? `+${result.crystals}晶体` : "",
|
||
result.insights ? `+${result.insights}洞见` : "",
|
||
result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||
result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||
].filter(Boolean).join(" "),
|
||
timestamp: Date.now(),
|
||
};
|
||
const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||
|
||
// v0.10 工单 #2 修复:探险结束时立即清空 activeExpedition,避免 finished 状态卡死
|
||
// 保留 lastExpeditionSummary 用于入口展示「上次结果」
|
||
if (result.ended) {
|
||
exp.finished = true;
|
||
}
|
||
|
||
// v0.9 工单修复:探险结束时记录摘要,用于入口界面展示
|
||
let lastExpeditionSummary = s.lastExpeditionSummary ?? null;
|
||
if (result.ended && (result.endReason === "victory" || result.endReason === "defeat")) {
|
||
lastExpeditionSummary = {
|
||
result: result.endReason,
|
||
rewards: { ...exp.rewards },
|
||
nodeCount: exp.nodes.length,
|
||
finishedAt: Date.now(),
|
||
};
|
||
}
|
||
|
||
// v0.4 编年史:击破 BOSS 时累计计数
|
||
let bossKills = s.bossKills ?? 0;
|
||
let bossKilledThisNode = false;
|
||
if (
|
||
result.ended &&
|
||
result.endReason === "victory" &&
|
||
nodeBefore?.type === "boss"
|
||
) {
|
||
bossKills = bossKills + 1;
|
||
bossKilledThisNode = true;
|
||
}
|
||
|
||
// v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励
|
||
let newAttributes = s.attributes ?? createInitialAttributes();
|
||
let newProgress = s.attributeProgress ?? createInitialAttributeProgress();
|
||
let statsNeedResync = false;
|
||
// 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验
|
||
const isCombatVictory =
|
||
(nodeBefore?.type === "combat" || nodeBefore?.type === "boss") &&
|
||
!result.ended; // 中途战斗胜利(未结束探险)
|
||
const isExpeditionVictory =
|
||
result.ended && result.endReason === "victory";
|
||
if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) {
|
||
const courageGain = bossKilledThisNode ? 8 : 2;
|
||
const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1;
|
||
const courageLvl = levelUpCheck(
|
||
{ exp: newProgress.courage.exp + courageGain, level: newAttributes.courage },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const explLvl = levelUpCheck(
|
||
{ exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
newAttributes = {
|
||
...newAttributes,
|
||
courage: courageLvl.newProgress.level,
|
||
exploration: explLvl.newProgress.level,
|
||
};
|
||
newProgress = {
|
||
...newProgress,
|
||
courage: courageLvl.newProgress,
|
||
exploration: explLvl.newProgress,
|
||
};
|
||
statsNeedResync = true;
|
||
}
|
||
|
||
set({
|
||
// v0.10 工单 #2 修复:探险结束立即清空 activeExpedition,让玩家无障碍再出发
|
||
activeExpedition: result.ended ? null : exp,
|
||
crystals: newCrystals,
|
||
insights: newInsights,
|
||
contact: newContact,
|
||
fragments: newFragments,
|
||
expeditionLog: newLog,
|
||
bossKills,
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
lastExpeditionSummary,
|
||
...(statsNeedResync
|
||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
|
||
: {}),
|
||
});
|
||
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||
if (result.ended) {
|
||
trackBeacon("expedition", 1);
|
||
if (bossKilledThisNode) {
|
||
trackBeacon("boss", 1);
|
||
}
|
||
}
|
||
if (result.insights) trackBeacon("insight", result.insights);
|
||
return result;
|
||
},
|
||
|
||
advanceNode: () => {
|
||
const s = get();
|
||
if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
const node = exp.nodes[exp.currentNode];
|
||
if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||
if (exp.currentNode >= exp.nodes.length - 1) return;
|
||
exp.currentNode++;
|
||
set({ activeExpedition: exp });
|
||
},
|
||
|
||
abortExpedition: () => {
|
||
const s = get();
|
||
if (!s.activeExpedition) return;
|
||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||
const logEntry = {
|
||
expeditionId: exp.id,
|
||
nodeType: "rest" as const,
|
||
result: "探险队主动撤退,保留已获奖励。",
|
||
rewards: "",
|
||
timestamp: Date.now(),
|
||
};
|
||
// v0.9 工单修复:记录上次探险摘要,用于入口展示
|
||
const summary = {
|
||
result: "abort" as const,
|
||
rewards: { ...exp.rewards },
|
||
nodeCount: exp.nodes.length,
|
||
finishedAt: Date.now(),
|
||
};
|
||
set({
|
||
// v0.10 工单 #2 修复:撤退立即清空 activeExpedition,避免 finished 卡死
|
||
activeExpedition: null,
|
||
expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||
lastExpeditionSummary: summary,
|
||
});
|
||
},
|
||
|
||
// v0.9 工单修复:手动关闭已结束探险,回到入口
|
||
dismissExpedition: () => {
|
||
const s = get();
|
||
if (!s.activeExpedition || !s.activeExpedition.finished) return;
|
||
set({ activeExpedition: null });
|
||
},
|
||
|
||
doPrestige: () => {
|
||
const s = get();
|
||
if (s.contact < CONTACT.prestigeMin) return null;
|
||
const newBp = computeNewBlueprints(s);
|
||
const next = performPrestige(s);
|
||
// 星图「能量共振」提升上限
|
||
const cm = constellationBonuses(next.constellation ?? []);
|
||
const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
set({
|
||
...next,
|
||
energyMax,
|
||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
|
||
_lastAutoDecode: Date.now(),
|
||
_lastSpawn: Date.now(),
|
||
_combo: 0,
|
||
_lastPulse: 0,
|
||
_tideEvents: [],
|
||
});
|
||
return { newBp };
|
||
},
|
||
|
||
chooseConstellationPerk: (perkId) => {
|
||
const s = get();
|
||
if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||
const perk = getPerk(perkId);
|
||
if (!perk) return false;
|
||
if (s.constellation?.includes(perkId)) return false;
|
||
const newConstellation = [...(s.constellation ?? []), perkId];
|
||
const cm = constellationBonuses(newConstellation);
|
||
const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||
// v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||
const chronicle = s.chronicle ?? [];
|
||
let newChronicle = chronicle;
|
||
if (chronicle.length > 0) {
|
||
const lastEntry = chronicle[chronicle.length - 1];
|
||
const updatedLast = withPerks(lastEntry, [perkId]);
|
||
newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||
}
|
||
set({
|
||
constellation: newConstellation,
|
||
pendingPerkChoices: null,
|
||
energyMax,
|
||
chronicle: newChronicle,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
|
||
});
|
||
return true;
|
||
},
|
||
|
||
rerollPerkChoices: () => {
|
||
const s = get();
|
||
if (!s.pendingPerkChoices) return;
|
||
const choices = rollPerkChoices(s.constellation ?? []);
|
||
if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||
},
|
||
|
||
checkAchievements: () => {
|
||
const s = get();
|
||
const newlyUnlocked: Achievement[] = [];
|
||
const updated = { ...s.achievements };
|
||
let crystals = s.crystals;
|
||
let insights = s.insights;
|
||
let contact = s.contact;
|
||
let statsDirty = false;
|
||
for (const a of ACHIEVEMENTS) {
|
||
if (updated[a.id]) continue;
|
||
if (a.check(s)) {
|
||
updated[a.id] = true;
|
||
newlyUnlocked.push(a);
|
||
// 发放即时奖励
|
||
if (a.reward.crystals) crystals += a.reward.crystals;
|
||
if (a.reward.insights) insights += a.reward.insights;
|
||
if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
|
||
if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
|
||
}
|
||
}
|
||
if (newlyUnlocked.length === 0) return [];
|
||
set({
|
||
achievements: updated,
|
||
crystals,
|
||
insights,
|
||
contact,
|
||
...(statsDirty
|
||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
|
||
: {}),
|
||
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||
});
|
||
return newlyUnlocked;
|
||
},
|
||
|
||
consumeAchievementQueue: () => {
|
||
const s = get();
|
||
if (s._achievementQueue.length === 0) return [];
|
||
const items = s._achievementQueue;
|
||
set({ _achievementQueue: [] });
|
||
return items;
|
||
},
|
||
|
||
canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||
|
||
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||
toggleSound: () => set({ soundOn: !get().soundOn }),
|
||
|
||
// 深空信标:发放每日挑战奖励(v0.5)
|
||
grantBeaconReward: (insights, contact) => {
|
||
const s = get();
|
||
set({
|
||
insights: s.insights + Math.round(insights),
|
||
contact: Math.min(100, s.contact + contact),
|
||
});
|
||
},
|
||
|
||
// 深空信标:领取周挑战奖励(v0.8)
|
||
claimWeeklyBeacon: () => {
|
||
const challenge = generateWeeklyChallenge();
|
||
const progress = loadWeeklyProgress();
|
||
const res = claimWeeklyReward(challenge, progress);
|
||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
const s = get();
|
||
set({
|
||
insights: s.insights + Math.round(res.rewardInsight),
|
||
contact: Math.min(100, s.contact + res.rewardContact),
|
||
});
|
||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||
if (res.entry) {
|
||
void submitCloudScore(res.entry);
|
||
}
|
||
}
|
||
return {
|
||
rewardInsight: res.rewardInsight,
|
||
rewardContact: res.rewardContact,
|
||
score: res.score,
|
||
};
|
||
},
|
||
|
||
// 深空信标:领取限时挑战奖励(v0.8.2)
|
||
claimTimedBeacon: () => {
|
||
const challenge = generateTimedChallenge();
|
||
const progress = loadTimedProgress();
|
||
const res = claimTimedReward(challenge, progress);
|
||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
const s = get();
|
||
set({
|
||
insights: s.insights + Math.round(res.rewardInsight),
|
||
contact: Math.min(100, s.contact + res.rewardContact),
|
||
});
|
||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||
if (res.entry) {
|
||
void submitCloudScore(res.entry);
|
||
}
|
||
}
|
||
return {
|
||
rewardInsight: res.rewardInsight,
|
||
rewardContact: res.rewardContact,
|
||
score: res.score,
|
||
};
|
||
},
|
||
|
||
// 深空信标:领取信标链里程碑奖励(v0.8)
|
||
claimChainReward: (milestone) => {
|
||
// loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成
|
||
const pre = loadChainState();
|
||
if (
|
||
pre.currentStreak < milestone ||
|
||
pre.milestonesClaimed.includes(milestone)
|
||
) {
|
||
return {
|
||
rewardInsight: 0,
|
||
rewardContact: 0,
|
||
label: "",
|
||
ok: false,
|
||
};
|
||
}
|
||
const res = claimChainMilestone(milestone);
|
||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||
const s = get();
|
||
set({
|
||
insights: s.insights + Math.round(res.rewardInsight),
|
||
contact: Math.min(100, s.contact + res.rewardContact),
|
||
});
|
||
}
|
||
return {
|
||
rewardInsight: res.rewardInsight,
|
||
rewardContact: res.rewardContact,
|
||
label: res.label,
|
||
ok: res.rewardInsight > 0 || res.rewardContact > 0,
|
||
};
|
||
},
|
||
|
||
// 深空巡航:发放实时玩法奖励(v0.6)
|
||
grantCruiseReward: (rewards) => {
|
||
const s = get();
|
||
const addCrystals = rewards.crystals ?? 0;
|
||
const addInsights = rewards.insights ?? 0;
|
||
const addContact = rewards.contact ?? 0;
|
||
// v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放)
|
||
const totalReward = addCrystals + addInsights * 10 + addContact * 10;
|
||
const expBase = Math.max(2, Math.floor(totalReward / 30));
|
||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
const explLvl = levelUpCheck(
|
||
{ exp: curProg.exploration.exp + expBase, level: curAttrs.exploration },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const courageLvl = levelUpCheck(
|
||
{ exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const newAttributes: CharacterAttributes = {
|
||
...curAttrs,
|
||
exploration: explLvl.newProgress.level,
|
||
courage: courageLvl.newProgress.level,
|
||
};
|
||
const newProgress: AttributeProgress = {
|
||
...curProg,
|
||
exploration: explLvl.newProgress,
|
||
courage: courageLvl.newProgress,
|
||
};
|
||
set({
|
||
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||
insights: s.insights + Math.round(addInsights),
|
||
contact: Math.min(100, s.contact + addContact),
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
},
|
||
|
||
// ============ 角色属性系统(v0.7 P1) ============
|
||
allocateAttribute: (attr, points = 1) => {
|
||
const s = get();
|
||
const cur = s.attributes ?? createInitialAttributes();
|
||
const curVal = cur[attr] ?? 0;
|
||
if (curVal >= ATTRIBUTE_HARD_CAP) {
|
||
return { ok: false, leveledUp: 0 };
|
||
}
|
||
if ((s.pendingAttrPoints ?? 0) < points) {
|
||
return { ok: false, leveledUp: 0 };
|
||
}
|
||
const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints);
|
||
const newVal = curVal + alloc;
|
||
const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal };
|
||
// 同步经验进度 level 字段(保持一致)
|
||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
const oldProg = curProg[attr] ?? { exp: 0, level: curVal };
|
||
const newProgress: AttributeProgress = {
|
||
...curProg,
|
||
[attr]: { exp: oldProg.exp, level: newVal },
|
||
};
|
||
set({
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
pendingAttrPoints: s.pendingAttrPoints - alloc,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
return { ok: true, leveledUp: alloc };
|
||
},
|
||
|
||
gainAttributeExp: (attr, amount) => {
|
||
const s = get();
|
||
const cur = s.attributes ?? createInitialAttributes();
|
||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||
const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] };
|
||
const result = levelUpCheck(
|
||
{ exp: progEntry.exp + amount, level: cur[attr] },
|
||
ATTRIBUTE_HARD_CAP
|
||
);
|
||
const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level };
|
||
const newProgress: AttributeProgress = {
|
||
...curProg,
|
||
[attr]: result.newProgress,
|
||
};
|
||
set({
|
||
attributes: newAttributes,
|
||
attributeProgress: newProgress,
|
||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||
});
|
||
return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
|
||
},
|
||
}),
|
||
{
|
||
name: "echo-nexus-save-v1",
|
||
version: 2,
|
||
storage: createJSONStorage(() => localStorage),
|
||
// v0.12 存档迁移:旧存档(v0/v1,key=echo-nexus-save-v1)补全新增字段,防崩溃
|
||
migrate: (persistedState: unknown, version: number) => {
|
||
void version; // v0/v1 统一走合并迁移,无需分支
|
||
const s = (persistedState || {}) as Record<string, unknown>;
|
||
const base = createInitialState();
|
||
// 合并:以当前 INITIAL_STATE 为基底,旧存档字段覆盖,缺失字段用默认值
|
||
const migrated = {
|
||
...base,
|
||
...s,
|
||
// v0.8+ 新增字段兜底
|
||
bossKills: (s.bossKills as number) ?? 0,
|
||
starTidesEncountered: (s.starTidesEncountered as string[]) ?? [],
|
||
chronicle: (s.chronicle as unknown[]) ?? [],
|
||
runStart: (s.runStart as number) ?? Date.now(),
|
||
// v0.8.2 attributes 系统
|
||
attributes: (s.attributes as Record<string, number>) ?? base.attributes,
|
||
attributeProgress: (s.attributeProgress as unknown) ?? base.attributeProgress,
|
||
pendingAttrPoints: (s.pendingAttrPoints as number) ?? 0,
|
||
// v0.10 探险系统字段兜底
|
||
activeExpedition: (s.activeExpedition as unknown) ?? null,
|
||
totalExpeditions: (s.totalExpeditions as number) ?? 0,
|
||
lastExpeditionSummary: (s.lastExpeditionSummary as unknown) ?? null,
|
||
// 星图兜底
|
||
constellation: (s.constellation as string[]) ?? [],
|
||
pendingPerkChoices: (s.pendingPerkChoices as unknown[]) ?? [],
|
||
// 技术树/碎片/成就兜底(防旧存档为 undefined)
|
||
tech: (s.tech as Record<string, number>) ?? {},
|
||
fragments: (s.fragments as Record<string, boolean>) ?? {},
|
||
achievements: (s.achievements as Record<string, boolean>) ?? {},
|
||
} as GameState;
|
||
// 丢弃不可解的活跃谜题(旧算法生成)
|
||
if (migrated.activePuzzle && typeof migrated.activePuzzle === "object") {
|
||
const p = migrated.activePuzzle as { nodes?: unknown[] };
|
||
if (!p.nodes || !Array.isArray(p.nodes) || p.nodes.length === 0) {
|
||
migrated.activePuzzle = null;
|
||
}
|
||
}
|
||
return migrated;
|
||
},
|
||
// 不持久化临时字段
|
||
partialize: (s) => {
|
||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
|
||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
|
||
return rest as GameState;
|
||
},
|
||
}
|
||
)
|
||
);
|
||
|
||
/** 选择器:未解锁碎片中下一个门槛 */
|
||
export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||
for (const f of FRAGMENTS) {
|
||
if (totalDecoded < f.threshold) return f.threshold;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||
|
||
// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||
(window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||
}
|