import Phaser from 'phaser' import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config' import { TileType, PLANTABLE_TILES } from '../types' import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types' import { stateManager } from '../StateManager' import { findPath } from '../utils/pathfinding' import type { LocalAdapter } from '../NetworkAdapter' import type { WorldSystem } from './WorldSystem' import type { ResourceSystem } from './ResourceSystem' import type { FarmingSystem } from './FarmingSystem' const ARRIVAL_PX = 3 const WORK_LOG_MAX = 20 interface VillagerRuntime { sprite: Phaser.GameObjects.Image /** White silhouette sprite rendered above all world objects so the Nisse is * always locatable even when occluded by trees or buildings. */ outlineSprite: Phaser.GameObjects.Image nameLabel: Phaser.GameObjects.Text energyBar: Phaser.GameObjects.Graphics jobIcon: Phaser.GameObjects.Text path: Array<{ tileX: number; tileY: number }> destination: 'job' | 'stockpile' | 'bed' | null workTimer: number idleScanTimer: number /** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */ workLog: string[] } export class VillagerSystem { private scene: Phaser.Scene private adapter: LocalAdapter private worldSystem: WorldSystem private resourceSystem!: ResourceSystem private farmingSystem!: FarmingSystem private runtime = new Map() private claimed = new Set() // target IDs currently claimed by a villager private spawnTimer = 0 private nameIndex = 0 onMessage?: (msg: string) => void onNisseClick?: (villagerId: string) => void /** * Called when a Nisse completes a forester planting job. * GameScene wires this to TreeSeedlingSystem.plantSeedling so that the * seedling sprite is spawned alongside the state action. */ onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void /** * @param scene - The Phaser scene this system belongs to * @param adapter - Network adapter for dispatching state actions * @param worldSystem - Used for passability checks during pathfinding */ constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) { this.scene = scene this.adapter = adapter this.worldSystem = worldSystem } /** * Wires in sibling systems that are not available at construction time. * Must be called before create(). * @param resourceSystem - Used to remove harvested resource sprites * @param farmingSystem - Used to remove harvested crop sprites */ init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void { this.resourceSystem = resourceSystem this.farmingSystem = farmingSystem } /** * Spawns sprites for all Nisse that exist in the saved state * and re-claims any active job targets. */ create(): void { const state = stateManager.getState() for (const v of Object.values(state.world.villagers)) { this.spawnSprite(v) // Re-claim any active job targets if (v.job) this.claimed.add(v.job.targetId) } } /** * Advances the spawn timer and ticks every Nisse's AI. * @param delta - Frame delta in milliseconds */ update(delta: number): void { this.spawnTimer += delta if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) { this.spawnTimer = 0 this.trySpawn() } const state = stateManager.getState() for (const v of Object.values(state.world.villagers)) { this.tickVillager(v, delta) } } // ─── Per-villager tick ──────────────────────────────────────────────────── /** * Dispatches the correct AI tick method based on the villager's current state, * then syncs the sprite, name label, energy bar, and job icon to the state. * @param v - Villager state from the store * @param delta - Frame delta in milliseconds */ private tickVillager(v: VillagerState, delta: number): void { const rt = this.runtime.get(v.id) if (!rt) return switch (v.aiState as AIState) { case 'idle': this.tickIdle(v, rt, delta); break case 'walking': this.tickWalking(v, rt, delta); break case 'working': this.tickWorking(v, rt, delta); break case 'sleeping':this.tickSleeping(v, rt, delta); break } // Sync sprite to state position; depth is Y-based so Nisse sort correctly with world objects const worldDepth = Math.floor(v.y / TILE_SIZE) + 5 rt.sprite.setPosition(v.x, v.y).setDepth(worldDepth) // Outline sprite mirrors position, flip, and angle so the silhouette matches exactly rt.outlineSprite.setPosition(v.x, v.y).setFlipX(rt.sprite.flipX).setAngle(rt.sprite.angle) rt.nameLabel.setPosition(v.x, v.y - 22) rt.energyBar.setPosition(0, 0) this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy) // Job icon const icons: Record = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' } const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '') rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18) } // ─── IDLE ───────────────────────────────────────────────────────────────── /** * Handles the idle AI state: hauls items to stockpile if carrying any, * seeks a bed if energy is low, otherwise picks the next job and begins walking. * Applies a cooldown before scanning again if no job is found. * @param v - Villager state * @param rt - Villager runtime (sprites, path, timers) * @param delta - Frame delta in milliseconds */ private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void { // Decrement scan timer if cooling down if (rt.idleScanTimer > 0) { rt.idleScanTimer -= delta return } // Carrying items? → find stockpile if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) { const sp = this.nearestBuilding(v, 'stockpile_zone') if (sp) { this.addLog(v.id, '→ Hauling to stockpile') this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile') return } } // Low energy → find bed if (v.energy < 25) { const bed = this.findBed(v) if (bed) { this.addLog(v.id, '→ Going to sleep (low energy)') this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed') return } } // Find a job const job = this.pickJob(v) if (job) { this.claimed.add(job.targetId) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} }, }) this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`) this.beginWalk(v, rt, job.tileX, job.tileY, 'job') } else { // No job available — wait before scanning again rt.idleScanTimer = 800 + Math.random() * 600 } } // ─── WALKING ────────────────────────────────────────────────────────────── /** * Advances the Nisse along its path toward the current destination. * Calls onArrived when the path is exhausted. * Drains energy slowly while walking. * @param v - Villager state * @param rt - Villager runtime * @param delta - Frame delta in milliseconds */ private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void { if (rt.path.length === 0) { this.onArrived(v, rt) return } const next = rt.path[0] const tx = (next.tileX + 0.5) * TILE_SIZE const ty = (next.tileY + 0.5) * TILE_SIZE const dx = tx - v.x const dy = ty - v.y const dist = Math.hypot(dx, dy) if (dist < ARRIVAL_PX) { ;(v as { x: number; y: number }).x = tx ;(v as { x: number; y: number }).y = ty rt.path.shift() } else { const step = Math.min(VILLAGER_SPEED * delta / 1000, dist) ;(v as { x: number; y: number }).x += (dx / dist) * step ;(v as { x: number; y: number }).y += (dy / dist) * step rt.sprite.setFlipX(dx < 0) } // Slowly drain energy while active ;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015) } /** * Called when a Nisse reaches its destination tile. * Transitions to the appropriate next AI state based on destination type. * @param v - Villager state * @param rt - Villager runtime */ private onArrived(v: VillagerState, rt: VillagerRuntime): void { switch (rt.destination) { case 'job': rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000 this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' }) break case 'stockpile': this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) rt.idleScanTimer = 0 // scan for a new job immediately after deposit this.addLog(v.id, '✓ Deposited at stockpile') break case 'bed': this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' }) this.addLog(v.id, '💤 Sleeping...') break default: this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) } rt.destination = null } // ─── WORKING ────────────────────────────────────────────────────────────── /** * Counts down the work timer and performs the harvest action on completion. * Handles chop, mine, and farm job types. * Returns the Nisse to idle when done. * @param v - Villager state * @param rt - Villager runtime * @param delta - Frame delta in milliseconds */ private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void { rt.workTimer -= delta // Wobble while working rt.sprite.setAngle(Math.sin(Date.now() / 120) * 8) if (rt.workTimer > 0) return rt.sprite.setAngle(0) const job = v.job if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return } this.claimed.delete(job.targetId) const state = stateManager.getState() if (job.type === 'chop') { const res = state.world.resources[job.targetId] if (res) { this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS }) this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY }) this.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) // Chopping a tree yields 1–2 tree seeds in the stockpile const seeds = Math.random() < 0.5 ? 2 : 1 this.adapter.send({ type: 'ADD_ITEMS', items: { tree_seed: seeds } }) this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`) } } else if (job.type === 'mine') { const res = state.world.resources[job.targetId] if (res) { this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS }) this.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Mined rock (+2 stone)') } } else if (job.type === 'farm') { const crop = state.world.crops[job.targetId] if (crop) { this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId }) this.farmingSystem.removeCropSpritePublic(job.targetId) this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any }) this.addLog(v.id, `✓ Farmed ${crop.kind}`) } } else if (job.type === 'forester') { // Verify the tile is still empty and the stockpile still has seeds const tileType = state.world.tiles[job.tileY * WORLD_TILES + job.tileX] as TileType const hasSeeds = (state.world.stockpile.tree_seed ?? 0) > 0 const tileOccupied = Object.values(state.world.resources).some(r => r.tileX === job.tileX && r.tileY === job.tileY) || Object.values(state.world.buildings).some(b => b.tileX === job.tileX && b.tileY === job.tileY) || Object.values(state.world.crops).some(c => c.tileX === job.tileX && c.tileY === job.tileY) || Object.values(state.world.treeSeedlings).some(s => s.tileX === job.tileX && s.tileY === job.tileY) if (hasSeeds && PLANTABLE_TILES.has(tileType) && !tileOccupied) { this.onPlantSeedling?.(job.tileX, job.tileY, tileType) this.addLog(v.id, `🌱 Planted seedling at (${job.tileX}, ${job.tileY})`) } } // If the harvest produced nothing (resource already gone), clear the stale job // so tickIdle does not try to walk to a stockpile with nothing to deposit. if (!v.job?.carrying || !Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) { this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) } // Back to idle — tickIdle will handle hauling to stockpile if carrying items this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) } // ─── SLEEPING ───────────────────────────────────────────────────────────── /** * Restores energy while sleeping. Returns to idle once energy is full. * @param v - Villager state * @param rt - Villager runtime * @param delta - Frame delta in milliseconds */ private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void { ;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04) // Gentle bob while sleeping rt.sprite.setAngle(Math.sin(Date.now() / 600) * 5) if (v.energy >= 100) { rt.sprite.setAngle(0) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.addLog(v.id, '✓ Woke up (energy full)') } } // ─── Job picking (RimWorld-style priority) ──────────────────────────────── /** * Selects the best available job for a Nisse based on their priority settings. * Among jobs at the same priority level, the closest one wins. * Returns null if no unclaimed job is available. * @param v - Villager state (used for position and priorities) * @returns The chosen job candidate, or null */ /** * Selects the best available job for a Nisse based on their priority settings. * Among jobs at the same priority level, the closest one wins. * For chop jobs, trees within a forester zone are preferred over natural trees — * natural trees are only offered when no forester-zone trees are available. * Returns null if no unclaimed job is available. * @param v - Villager state (used for position and priorities) * @returns The chosen job candidate, or null */ private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null { const state = stateManager.getState() const p = v.priorities const vTX = Math.floor(v.x / TILE_SIZE) const vTY = Math.floor(v.y / TILE_SIZE) const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY) type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number } const candidates: C[] = [] if (p.chop > 0) { // Build the set of all tiles belonging to forester zones for chop priority const zoneTiles = new Set() for (const zone of Object.values(state.world.foresterZones)) { for (const key of zone.tiles) zoneTiles.add(key) } const zoneChop: C[] = [] const naturalChop: C[] = [] for (const res of Object.values(state.world.resources)) { if (res.kind !== 'tree' || this.claimed.has(res.id)) continue // Skip trees with no reachable neighbour — A* cannot reach them. if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop } if (zoneTiles.has(`${res.tileX},${res.tileY}`)) { zoneChop.push(c) } else { naturalChop.push(c) } } // Prefer zone trees; fall back to natural only when no zone trees are reachable. candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop)) } if (p.mine > 0) { for (const res of Object.values(state.world.resources)) { if (res.kind !== 'rock' || this.claimed.has(res.id)) continue // Same reachability guard for rock tiles. if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) } } if (p.farm > 0) { for (const crop of Object.values(state.world.crops)) { if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm }) } } if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) { // Find empty plantable zone tiles to seed for (const zone of Object.values(state.world.foresterZones)) { for (const key of zone.tiles) { const [tx, ty] = key.split(',').map(Number) const targetId = `forester_tile_${tx}_${ty}` if (this.claimed.has(targetId)) continue // Skip if tile is not plantable const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType if (!PLANTABLE_TILES.has(tileType)) continue // Skip if something occupies this tile const occupied = Object.values(state.world.resources).some(r => r.tileX === tx && r.tileY === ty) || Object.values(state.world.buildings).some(b => b.tileX === tx && b.tileY === ty) || Object.values(state.world.crops).some(c => c.tileX === tx && c.tileY === ty) || Object.values(state.world.treeSeedlings).some(s => s.tileX === tx && s.tileY === ty) if (occupied) continue candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester }) } } } if (candidates.length === 0) return null // Lowest priority number wins; ties broken by distance const bestPri = Math.min(...candidates.map(c => c.pri)) return candidates .filter(c => c.pri === bestPri) .sort((a, b) => a.dist - b.dist)[0] ?? null } // ─── Pathfinding ────────────────────────────────────────────────────────── /** * Computes a path from the Nisse's current tile to the target tile and * begins walking. If no path is found, the job is cleared and a cooldown applied. * @param v - Villager state * @param rt - Villager runtime * @param tileX - Target tile X * @param tileY - Target tile Y * @param dest - Semantic destination type (used by onArrived) */ private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void { const sx = Math.floor(v.x / TILE_SIZE) const sy = Math.floor(v.y / TILE_SIZE) const path = findPath(sx, sy, tileX, tileY, (x, y) => this.worldSystem.isPassable(x, y), 700) if (!path) { if (v.job) { this.claimed.delete(v.job.targetId) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) } rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops return } rt.path = path rt.destination = dest this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'walking' }) } // ─── Building finders ───────────────────────────────────────────────────── /** * Returns the nearest building of the given kind to the Nisse, or null if none exist. * @param v - Villager state (used as reference position) * @param kind - Building kind to search for */ private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null { const state = stateManager.getState() const hits = Object.values(state.world.buildings).filter(b => b.kind === kind) if (hits.length === 0) return null const vx = v.x / TILE_SIZE const vy = v.y / TILE_SIZE return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0] } /** * Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed. * Returns null if no beds are placed. * @param v - Villager state */ private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null { const state = stateManager.getState() // Prefer assigned bed if (v.bedId && state.world.buildings[v.bedId]) return state.world.buildings[v.bedId] as any return this.nearestBuilding(v, 'bed') as any } /** * Returns true if at least one of the 8 neighbours of the given tile is passable. * Used to pre-filter job targets that are fully enclosed by impassable terrain — * such as trees deep inside a dense forest cluster where A* can never reach the goal * tile because no passable tile is adjacent to it. * @param tileX - Target tile X * @param tileY - Target tile Y */ private hasAdjacentPassable(tileX: number, tileY: number): boolean { const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const for (const [dx, dy] of DIRS) { if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true } return false } // ─── Spawning ───────────────────────────────────────────────────────────── /** * Attempts to spawn a new Nisse if a free bed is available and the * current population is below the bed count. */ private trySpawn(): void { const state = stateManager.getState() const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed') const current = Object.keys(state.world.villagers).length if (current >= beds.length || beds.length === 0) return // Find a free bed (not assigned to any existing villager) const taken = new Set(Object.values(state.world.villagers).map(v => v.bedId)) const freeBed = beds.find(b => !taken.has(b.id)) if (!freeBed) return const name = VILLAGER_NAMES[this.nameIndex % VILLAGER_NAMES.length] this.nameIndex++ const villager: VillagerState = { id: `villager_${Date.now()}`, name, x: (freeBed.tileX + 0.5) * TILE_SIZE, y: (freeBed.tileY + 0.5) * TILE_SIZE, bedId: freeBed.id, job: null, priorities: { chop: 1, mine: 2, farm: 3, forester: 4 }, energy: 100, aiState: 'idle', } this.adapter.send({ type: 'SPAWN_VILLAGER', villager }) this.spawnSprite(villager) this.onMessage?.(`${name} the Nisse has arrived! 🏘`) } // ─── Sprite management ──────────────────────────────────────────────────── /** * Creates and registers all runtime objects (sprite, label, energy bar, icon) * for a newly added Nisse. * @param v - Villager state to create sprites for */ /** * Creates and registers all runtime objects (sprite, outline, label, energy bar, icon) * for a newly added Nisse. * @param v - Villager state to create sprites for */ private spawnSprite(v: VillagerState): void { // Silhouette rendered above all world objects so the Nisse is visible even // when occluded by a tree or building. const outlineSprite = this.scene.add.image(v.x, v.y, 'villager') .setScale(1.3) .setTintFill(0xffffff) .setAlpha(0.7) .setDepth(900) // Main sprite depth is updated every frame based on Y position. const sprite = this.scene.add.image(v.x, v.y, 'villager') .setDepth(Math.floor(v.y / TILE_SIZE) + 5) const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, { fontSize: '8px', color: '#ffffff', fontFamily: 'monospace', backgroundColor: '#00000088', padding: { x: 2, y: 1 }, }).setOrigin(0.5, 1).setDepth(901) const energyBar = this.scene.add.graphics().setDepth(901) const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902) sprite.setInteractive() sprite.on('pointerdown', () => this.onNisseClick?.(v.id)) this.runtime.set(v.id, { sprite, outlineSprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] }) } /** * Redraws the energy bar graphic for a Nisse at the given world position. * Color transitions green → orange → red as energy decreases. * @param g - Graphics object to draw into * @param x - World X center of the Nisse * @param y - World Y center of the Nisse * @param energy - Current energy value (0–100) */ private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void { const W = 20, H = 3 g.clear() g.fillStyle(0x222222); g.fillRect(x - W/2, y - 28, W, H) const col = energy > 60 ? 0x4CAF50 : energy > 30 ? 0xFF9800 : 0xF44336 g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H) } // ─── Work log ───────────────────────────────────────────────────────────── /** * Prepends a message to the runtime work log for the given Nisse. * Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found. * @param villagerId - Target Nisse ID * @param msg - Log message to prepend */ private addLog(villagerId: string, msg: string): void { const rt = this.runtime.get(villagerId) if (!rt) return rt.workLog.unshift(msg) if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX } // ─── Public API ─────────────────────────────────────────────────────────── /** * Returns a short human-readable status string for the given Nisse, * suitable for display in UI panels. * @param villagerId - The Nisse's ID * @returns Status string, or '—' if the Nisse is not found */ getStatusText(villagerId: string): string { const v = stateManager.getState().world.villagers[villagerId] if (!v) return '—' if (v.aiState === 'sleeping') return '💤 Sleeping' if (v.aiState === 'working' && v.job) { const label = v.job.type === 'forester' ? 'planting' : `${v.job.type}ing` return `⚒ ${label}` } if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}` if (v.aiState === 'walking') return '🚶 Walking' const carrying = v.job?.carrying if (carrying && Object.values(carrying).some(n => (n ?? 0) > 0)) return '📦 Hauling' return '💭 Idle' } /** * Returns a copy of the runtime work log for the given Nisse (newest first). * @param villagerId - The Nisse's ID * @returns Array of log strings, or empty array if not found */ getWorkLog(villagerId: string): string[] { return [...(this.runtime.get(villagerId)?.workLog ?? [])] } /** * Returns the current world position and remaining path for every Nisse * that is currently in the 'walking' state. Used by DebugSystem for * pathfinding visualization. * @returns Array of path entries, one per walking Nisse */ getActivePaths(): Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> { const state = stateManager.getState() const result: Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> = [] for (const v of Object.values(state.world.villagers)) { if (v.aiState !== 'walking') continue const rt = this.runtime.get(v.id) if (!rt) continue result.push({ x: v.x, y: v.y, path: [...rt.path] }) } return result } /** * Destroys all Nisse sprites and clears the runtime map. * Should be called when the scene shuts down. */ /** * Destroys all Nisse sprites and clears the runtime map. * Should be called when the scene shuts down. */ destroy(): void { for (const rt of this.runtime.values()) { rt.sprite.destroy(); rt.outlineSprite.destroy() rt.nameLabel.destroy(); rt.energyBar.destroy(); rt.jobIcon.destroy() } this.runtime.clear() } }