v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user