Files
echo-nexus/src/store/gameStore.ts
T

374 lines
12 KiB
TypeScript

"use client";
// 回响星核 / Echo Nexus — Zustand 游戏状态管理
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type {
GameState,
Crystal,
CrystalTier,
DecodePuzzle,
} from "@/lib/game/types";
import {
INITIAL_STATE,
TECH_TREE,
CRYSTAL_VALUE,
CONTACT,
CRYSTAL_SPAWN,
FRAGMENTS,
PRESTIGE,
} from "@/lib/game/config";
import {
createInitialState,
recomputeStats,
decodeRewards,
rollCrystalTier,
computeNewBlueprints,
performPrestige,
} from "@/lib/game/engine";
import {
generatePuzzle,
tryClickNode,
isSolvable,
resetPuzzle as resetPuz,
} from "@/lib/game/decode";
interface GameActions {
// 生命周期
init: () => void;
loadOnline: () => void;
hardReset: () => void;
// 主循环
tick: (now: number) => void;
pulse: () => { gain: number; combo: number } | null;
// 解码
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;
// 技术
buyTech: (techId: string) => boolean;
// 飞升
doPrestige: () => { newBp: number } | null;
// 设置
toggleTheme: () => void;
toggleSound: () => void;
// 派生
canPrestige: () => boolean;
}
type Store = GameState & GameActions & {
_lastAutoDecode: number;
_lastSpawn: number;
_combo: number;
_lastPulse: number;
};
/** 计算并写回产能字段 */
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,
};
}
/** 检查并解锁叙事碎片 */
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,
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),
});
}
// 首次进入:补发离线收益
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;
set({
crystals: Math.min(s.crystalCap, s.crystals + gain),
lastTick: now,
activePuzzle,
...syncStats({ tech: s.tech, blueprints: s.blueprints }),
});
} else {
set({ lastTick: now, activePuzzle, ...syncStats({ tech: s.tech, blueprints: s.blueprints }) });
}
},
loadOnline: () => {
const s = get();
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints }) });
},
hardReset: () => {
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0 });
},
tick: (now) => {
const s = get();
const dt = Math.max(0, (now - s.lastTick) / 1000);
if (dt <= 0) return;
// 产能累加(受仓库上限)
const newCrystals = Math.min(
s.crystalCap,
s.crystals + s.crystalsPerSec * 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
) {
const tier = rollCrystalTier();
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;
}
set({
crystals: newCrystals,
lastTick: now,
pendingCrystals: pending,
_lastSpawn: lastSpawn,
});
},
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 gain = s.pulsePower * mult;
set({
crystals: Math.min(s.crystalCap, s.crystals + gain),
_combo: combo,
_lastPulse: now,
});
return { gain, combo };
},
startDecode: (crystalId) => {
const s = get();
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
if (!crystal) return;
const puzzle = generatePuzzle(crystal.tier);
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 rewards = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
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);
set({
activePuzzle: null,
crystals: newCrystals,
insights: newInsights,
contact: newContact,
totalDecoded: newTotal,
fragments: tentative.fragments,
});
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();
if (now - s._lastAutoDecode < 12000) return;
// 找一颗 T1 晶体自动解码
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
if (idx < 0) return;
const crystal = s.pendingCrystals[idx];
const rewards = decodeRewards(1, s.insightMult, s.contactRateMult);
const newTotal = s.totalDecoded + 1;
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
checkFragments(tentative);
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,
});
},
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 }),
});
return true;
},
doPrestige: () => {
const s = get();
if (s.contact < CONTACT.prestigeMin) return null;
const newBp = computeNewBlueprints(s);
const next = performPrestige(s);
set({
...next,
...syncStats({ tech: next.tech, blueprints: next.blueprints }),
_lastAutoDecode: Date.now(),
_lastSpawn: Date.now(),
_combo: 0,
_lastPulse: 0,
});
return { newBp };
},
canPrestige: () => get().contact >= CONTACT.prestigeMin,
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
toggleSound: () => set({ soundOn: !get().soundOn }),
}),
{
name: "echo-nexus-save-v1",
storage: createJSONStorage(() => localStorage),
// 不持久化临时字段
partialize: (s) => {
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse;
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 };