Files
nissefolk/src/StateManager.ts

260 lines
8.2 KiB
TypeScript
Raw Normal View History

import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
2026-03-20 08:11:31 +00:00
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
import { TileType } from './types'
2026-03-20 08:11:31 +00:00
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, tree_seed: 5 },
treeSeedlings: {},
tileRecovery: {},
2026-03-20 08:11:31 +00:00
}
}
function makeDefaultState(): GameStateData {
return {
version: 5,
2026-03-20 08:11:31 +00:00
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
}
case 'PLANT_TREE_SEED': {
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
// Cancel any tile recovery on this tile
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
break
}
case 'REMOVE_TREE_SEEDLING':
delete w.treeSeedlings[action.seedlingId]
break
case 'SPAWN_RESOURCE':
w.resources[action.resource.id] = { ...action.resource }
break
case 'TILE_RECOVERY_START':
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
break
2026-03-20 08:11:31 +00:00
}
}
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
}
/**
* Advances all tree-seedling growth timers.
* Returns IDs of seedlings that have reached stage 2 (ready to mature into a tree).
* @param delta - Frame delta in milliseconds
* @returns Array of seedling IDs that are now mature
*/
tickSeedlings(delta: number): string[] {
const advanced: string[] = []
for (const s of Object.values(this.state.world.treeSeedlings)) {
s.stageTimerMs -= delta
if (s.stageTimerMs <= 0) {
s.stage = Math.min(s.stage + 1, 2)
s.stageTimerMs = TREE_SEEDLING_STAGE_MS
advanced.push(s.id)
}
}
return advanced
}
/**
* Ticks tile-recovery timers.
* Returns keys ("tileX,tileY") of tiles that have now recovered back to GRASS.
* @param delta - Frame delta in milliseconds
* @returns Array of recovered tile keys
*/
tickTileRecovery(delta: number): string[] {
const recovered: string[] = []
const rec = this.state.world.tileRecovery
for (const key of Object.keys(rec)) {
rec[key] -= delta
if (rec[key] <= 0) {
delete rec[key]
recovered.push(key)
// Update tiles array directly (DARK_GRASS → GRASS)
const [tx, ty] = key.split(',').map(Number)
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
}
}
return recovered
}
2026-03-20 08:11:31 +00:00
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 !== 5) return null
if (!p.world.crops) p.world.crops = {}
if (!p.world.villagers) p.world.villagers = {}
if (!p.world.stockpile) p.world.stockpile = {}
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
if (!p.world.tileRecovery) p.world.tileRecovery = {}
// Reset in-flight AI states to idle on load so runtime timers start fresh
2026-03-20 08:11:31 +00:00
for (const v of Object.values(p.world.villagers)) {
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
2026-03-20 08:11:31 +00:00
}
return p
} catch (_) { return null }
}
reset(): void {
localStorage.removeItem(SAVE_KEY)
this.state = makeDefaultState()
}
}
export const stateManager = new StateManager()