d9404a31-1970-4e79-ac9a-f4f9db60350b
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
"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 };
|
||||
Reference in New Issue
Block a user