Files
ChronicleOfTheImmortalClan/src/renderer/ui/store.ts
T
thzxx adb22f0558 v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
2026-08-23 00:04:16 +08:00

293 lines
9.0 KiB
TypeScript

import { create } from 'zustand'
import { World, WorldEventBus } from '../game/engine/world'
import { EventDef } from '../game/data/events'
import { findEvent, applyEventChoice } from '../game/engine/systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta } from '../game/types/domain'
import { getSlotManager, getSaveSlot } from '../game/storage/db'
import { metaFromState, updateSlotMeta } from './storeHelper'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
export interface GameStore {
screen: Screen
world: World | null
slot: number
slots: (SaveMeta | null)[]
speed: number
timer: ReturnType<typeof setInterval> | null
logFeed: LogItem[]
pendingEventId?: string
pendingEventDef?: EventDef
battleView?: BattleLog
panel: PanelId
selectedMemberId?: string
selectedMissionDef?: string
revision: number
toast?: string
gameOverReason?: string
init: () => Promise<void>
go: (screen: Screen) => void
startNewGame: (opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }, slot: number) => Promise<void>
continueGame: (slot: number) => Promise<void>
loadSnapshot: (slot: number, snapshotId: string) => Promise<void>
advance: () => Promise<void>
addLog: (item: LogItem) => void
onChronicle: (e: ChronicleEntry, important: boolean) => void
onBattle: (log: BattleLog) => void
onPendingEvent: (id: string) => void
onGameOver: (reason: string) => void
saveNow: (label?: string) => Promise<void>
bump: () => void
setSpeed: (v: number) => void
setPanel: (p: PanelId) => void
setSelected: (id?: string) => void
setMissionDef: (id?: string) => void
exportSave: () => Promise<void>
importSave: (slot: number) => Promise<void>
deleteSave: (slot: number) => Promise<void>
refreshSlots: () => Promise<void>
}
const LOG_CAP = 260
let logSeq = 1
export const useGameStore = create<GameStore>((set, get) => ({
screen: 'boot',
world: null,
slot: 1,
slots: [],
speed: 0,
timer: null,
logFeed: [],
pendingEventId: undefined,
pendingEventDef: undefined,
battleView: undefined,
panel: 'family',
selectedMemberId: undefined,
selectedMissionDef: undefined,
revision: 0,
toast: undefined,
gameOverReason: undefined,
init: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__cotycBootReady = true
const hack = window as unknown as Record<string, unknown>
hack.__cotycDebug = {
startNewGame: () => get().startNewGame({ seed: 'smoke-' + Date.now(), surname: '林', familyName: '林氏', motto: 'm', difficulty: 'normal' }, 1),
advance: () => get().advance(),
resolvePending: () => {
const st = get()
if (st.pendingEventId && st.world) {
applyEventChoice(st.world, st.pendingEventId, 0)
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
st.bump()
}
},
getYear: () => get().world?.state.year ?? 0,
openPanel: (p: string) => {
useGameStore.setState({ panel: p as never })
},
closeBattles: () => {
useGameStore.setState({ battleView: undefined })
},
getChronicle: () => get().world?.state.chronicle?.slice().reverse() ?? [],
getFeed: () => get().logFeed.slice()
}
}
},
go: (screen) => set({ screen }),
startNewGame: async (opts, slot) => {
const world = World.create(opts)
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 })
const st = get()
st.world?.out.push(makeBus(st))
await st.saveNow('开局')
},
continueGame: async (slot) => {
const manager = getSlotManager()
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState()
if (!state) throw new Error('找不到存档数据')
openState(state, slot)
await manager.updateSlotMeta(slot, metaFromState(state, slot))
},
loadSnapshot: async (slot, snapshotId) => {
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState(snapshotId)
if (!state) throw new Error('找不到快照')
openState(state, slot)
},
advance: async () => {
const st = get()
const w = st.world
if (!w || st.pendingEventId || w.state.gameOver) return
try {
w.advanceMonth()
w.syncRng()
set((s) => ({ revision: s.revision + 1 }))
await Stash.save(st, '自动')
} catch (e) {
console.error(e)
}
},
addLog: (item) => {
set((s) => {
const next = (s.logFeed.length >= LOG_CAP ? s.logFeed.slice(s.logFeed.length - LOG_CAP + 1) : s.logFeed).concat(item)
return { logFeed: next }
})
},
onChronicle: (e, important) => {
if (important) {
set((s) => ({ revision: s.revision + 1 }))
}
},
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
onPendingEvent: (id) => {
const def = findEvent(id)
set({ pendingEventId: id, pendingEventDef: def })
},
onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }),
saveNow: async (label = '手动') => {
await Stash.save(get(), label)
},
bump: () => set((s) => ({ revision: s.revision + 1 })),
setSpeed: (v) => {
const st = get()
if (st.timer) clearInterval(st.timer)
if (v <= 0) {
set({ speed: v, timer: null })
return
}
const ms = v === 1 ? 3200 : v === 2 ? 1800 : 900
if (v >= 3) v = 3
const timer = setInterval(() => {
const cur = get()
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) {
void cur.advance()
}
}, ms)
set({ speed: v, timer })
},
setPanel: (p) => set({ panel: p }),
setSelected: (id) => set({ selectedMemberId: id }),
setMissionDef: (id) => set({ selectedMissionDef: id }),
exportSave: async () => {
const st = get()
if (!st.world || !window.api) return
const file = await getSaveSlot(st.slot)
const meta0 = metaFromState(st.world.state, st.slot)
st.world.syncRng()
await file.saveState(st.world.state, '导出前')
const json = await file.exportAll()
const meta = metaFromState(st.world.state, st.slot)
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}年`)
if (r.ok) set({ toast: '存档已导出。' })
else set({ toast: `导出失败:${r.error ?? ''}` })
},
importSave: async (slot) => {
if (!window.api) return
const r = await window.api.importSave()
if (!r.ok || !r.text) {
set({ toast: `导入失败:${r.error ?? ''}` })
return
}
const file = await getSaveSlot(slot)
await file.open()
const ok = await file.importAll(r.text)
if (ok) {
set({ toast: '导入成功,重新读取存档槽。' })
await get().refreshSlots()
} else {
set({ toast: '导入失败:文件格式不正确。' })
}
},
deleteSave: async (slot) => {
const file = await getSaveSlot(slot)
await file.open()
await file.wipe()
const manager = getSlotManager()
await manager.removeSlotMeta(slot)
await get().refreshSlots()
},
refreshSlots: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
}
}))
function openState(state: GameState, slot: number): void {
const world = new World(state)
const st = useGameStore.getState()
world.out.push(makeBus(st))
useGameStore.setState({
world,
slot,
screen: 'game',
panel: 'family',
logFeed: [],
battleView: undefined,
pendingEventId: undefined,
pendingEventDef: undefined,
revision: 1,
speed: 0,
gameOverReason: state.gameOver?.reason
})
}
function makeBus(st: GameStore): WorldEventBus {
return {
onLog: (kind, text) => {
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
onChronicle: (e, important) => {
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}${e.month}${e.text}`, year: e.year, month: e.month })
if (important) st.onChronicle(e, important)
},
onBattle: (log) => st.onBattle(log),
onPendingEvent: (id) => st.onPendingEvent(id),
onGameOver: (reason) => st.onGameOver(reason)
}
}
const Stash = {
async save(st: GameStore, label: string): Promise<void> {
if (!st.world) return
const file = await getSaveSlot(st.slot)
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
await file.saveChronicle(st.world.state.chronicle)
const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta)
await updateSlotMeta(st.slot, meta)
}
}