194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config'
|
|
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
|
|
|
const DEFAULT_PLAYER: PlayerState = {
|
|
id: 'player1',
|
|
x: 8192, y: 8192,
|
|
inventory: {}, // empty — seeds now in stockpile
|
|
}
|
|
|
|
function makeEmptyWorld(seed: number): WorldState {
|
|
return {
|
|
seed,
|
|
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
|
resources: {},
|
|
buildings: {},
|
|
crops: {},
|
|
villagers: {},
|
|
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 },
|
|
}
|
|
}
|
|
|
|
function makeDefaultState(): GameStateData {
|
|
return {
|
|
version: 4,
|
|
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
|
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
|
}
|
|
}
|
|
|
|
class StateManager {
|
|
private state: GameStateData
|
|
|
|
constructor() {
|
|
this.state = this.load() ?? makeDefaultState()
|
|
}
|
|
|
|
getState(): Readonly<GameStateData> { return this.state }
|
|
|
|
apply(action: GameAction): void {
|
|
const s = this.state
|
|
const w = s.world
|
|
|
|
switch (action.type) {
|
|
|
|
case 'PLAYER_MOVE':
|
|
s.player.x = action.x; s.player.y = action.y; break
|
|
|
|
case 'HARVEST_RESOURCE': {
|
|
const res = w.resources[action.resourceId]
|
|
if (!res) break
|
|
res.hp -= 1
|
|
if (res.hp <= 0) delete w.resources[action.resourceId]
|
|
for (const [k, v] of Object.entries(action.rewards))
|
|
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
|
break
|
|
}
|
|
|
|
case 'CHANGE_TILE':
|
|
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
|
|
|
|
case 'PLACE_BUILDING': {
|
|
w.buildings[action.building.id] = action.building
|
|
for (const [k, v] of Object.entries(action.costs))
|
|
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
|
|
break
|
|
}
|
|
|
|
case 'REMOVE_BUILDING':
|
|
delete w.buildings[action.buildingId]; break
|
|
|
|
case 'ADD_ITEMS':
|
|
for (const [k, v] of Object.entries(action.items))
|
|
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
|
break
|
|
|
|
case 'PLANT_CROP': {
|
|
w.crops[action.crop.id] = { ...action.crop }
|
|
const have = w.stockpile[action.seedItem] ?? 0
|
|
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
|
break
|
|
}
|
|
|
|
case 'WATER_CROP': {
|
|
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
|
}
|
|
|
|
case 'HARVEST_CROP': {
|
|
delete w.crops[action.cropId]
|
|
for (const [k, v] of Object.entries(action.rewards))
|
|
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
|
break
|
|
}
|
|
|
|
// ── Villager actions ──────────────────────────────────────────────────
|
|
|
|
case 'SPAWN_VILLAGER':
|
|
w.villagers[action.villager.id] = { ...action.villager }; break
|
|
|
|
case 'VILLAGER_SET_JOB': {
|
|
const v = w.villagers[action.villagerId]; if (v) v.job = action.job; break
|
|
}
|
|
|
|
case 'VILLAGER_SET_AI': {
|
|
const v = w.villagers[action.villagerId]; if (v) v.aiState = action.aiState; break
|
|
}
|
|
|
|
case 'VILLAGER_HARVEST_RESOURCE': {
|
|
const v = w.villagers[action.villagerId]
|
|
const res = w.resources[action.resourceId]
|
|
if (!v || !res) break
|
|
delete w.resources[action.resourceId]
|
|
const reward = res.kind === 'tree' ? { wood: 2 } : { stone: 2 }
|
|
if (!v.job) break
|
|
for (const [k, qty] of Object.entries(reward)) {
|
|
v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + qty
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'VILLAGER_HARVEST_CROP': {
|
|
const v = w.villagers[action.villagerId]
|
|
const crop = w.crops[action.cropId]
|
|
if (!v || !crop) break
|
|
delete w.crops[action.cropId]
|
|
const cfg = CROP_CONFIGS[crop.kind]
|
|
if (!v.job) break
|
|
for (const [k, qty] of Object.entries(cfg.rewards)) {
|
|
v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + (qty ?? 0)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'VILLAGER_DEPOSIT': {
|
|
const v = w.villagers[action.villagerId]
|
|
if (!v?.job?.carrying) break
|
|
for (const [k, qty] of Object.entries(v.job.carrying)) {
|
|
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (qty ?? 0)
|
|
}
|
|
v.job.carrying = {}
|
|
v.job = null
|
|
break
|
|
}
|
|
|
|
case 'UPDATE_PRIORITIES': {
|
|
const v = w.villagers[action.villagerId]
|
|
if (v) v.priorities = { ...action.priorities }
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
tickCrops(delta: number): string[] {
|
|
const advanced: string[] = []
|
|
for (const crop of Object.values(this.state.world.crops)) {
|
|
if (crop.stage >= crop.maxStage) continue
|
|
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
|
|
if (crop.stageTimerMs <= 0) {
|
|
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
|
|
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
|
|
advanced.push(crop.id)
|
|
}
|
|
}
|
|
return advanced
|
|
}
|
|
|
|
save(): void {
|
|
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
|
}
|
|
|
|
private load(): GameStateData | null {
|
|
try {
|
|
const raw = localStorage.getItem(SAVE_KEY)
|
|
if (!raw) return null
|
|
const p = JSON.parse(raw) as GameStateData
|
|
if (p.version !== 4) return null
|
|
if (!p.world.crops) p.world.crops = {}
|
|
if (!p.world.villagers) p.world.villagers = {}
|
|
if (!p.world.stockpile) p.world.stockpile = {}
|
|
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
|
for (const v of Object.values(p.world.villagers)) {
|
|
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
|
}
|
|
return p
|
|
} catch (_) { return null }
|
|
}
|
|
|
|
reset(): void {
|
|
localStorage.removeItem(SAVE_KEY)
|
|
this.state = makeDefaultState()
|
|
}
|
|
}
|
|
|
|
export const stateManager = new StateManager()
|