374 lines
12 KiB
TypeScript
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 };
|