Files
nissefolk/src/systems/VillagerSystem.ts
tekki mariani fc10201469 add demolish mode for buildings (Issue #50)
- New 💥 Demolish button in the action bar
- Red ghost highlights building footprint on hover
- Refund: 100% within 3 min, decays linearly to 0%
- Mine teardown unblocks passability tiles and removes status label
- Nisse inside demolished mine are rescued and reset to idle
- Floor/wall/chest tiles restored to GRASS on demolish
- Build error now shows missing resources instead of generic message
- BuildingState gains builtAt field; old saves default to 0 (no refund)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:18:49 +00:00

809 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES, MINE_CAPACITY, MINE_WORK_MS, MINE_STONE_YIELD } 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
/** Job-type → display icon mapping; defined once at module level to avoid per-frame allocation. */
const JOB_ICONS: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
interface VillagerRuntime {
sprite: 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<string, VillagerRuntime>()
private claimed = new Set<string>() // target IDs currently claimed (resources, crops, etc.)
private mineClaimsMap = new Map<string, number>() // mine building ID → number of claimed slots
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
}
// ─── Claim helpers ────────────────────────────────────────────────────────
/**
* Claims a job target. Mine buildings use a capacity counter (up to MINE_CAPACITY);
* all other targets use the exclusive claimed Set.
* @param targetId - The resource, crop, or building ID being claimed
*/
private claimTarget(targetId: string): void {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
this.mineClaimsMap.set(targetId, (this.mineClaimsMap.get(targetId) ?? 0) + 1)
} else {
this.claimed.add(targetId)
}
}
/**
* Releases a job target claim.
* @param targetId - The previously claimed target ID
*/
private releaseTarget(targetId: string): void {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
const n = this.mineClaimsMap.get(targetId) ?? 0
if (n <= 1) this.mineClaimsMap.delete(targetId)
else this.mineClaimsMap.set(targetId, n - 1)
} else {
this.claimed.delete(targetId)
}
}
/**
* Returns true if the given target is fully claimed.
* Mine buildings are claimed when their worker count reaches MINE_CAPACITY.
* All other targets are claimed exclusively.
* @param targetId - The target ID to check
*/
private isTargetClaimed(targetId: string): boolean {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
return (this.mineClaimsMap.get(targetId) ?? 0) >= MINE_CAPACITY
}
return this.claimed.has(targetId)
}
/**
* 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.claimTarget(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
}
// Nisse always render above world objects
rt.sprite.setPosition(v.x, v.y)
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 jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (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.claimTarget(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': {
// Mine buildings take longer than surface-rock mining and hide the Nisse sprite.
const isMineBuilding = v.job?.type === 'mine' &&
stateManager.getState().world.buildings[v.job.targetId]?.kind === 'mine'
rt.workTimer = isMineBuilding ? MINE_WORK_MS : (VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000)
if (isMineBuilding) {
rt.sprite.setVisible(false)
rt.nameLabel.setVisible(false)
rt.energyBar.setVisible(false)
rt.jobIcon.setVisible(false)
}
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.releaseTarget(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 12 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 building = state.world.buildings[job.targetId]
if (building?.kind === 'mine') {
// Mine building: yield stone directly into carrying, then show the Nisse again
const mutableJob = v.job as { carrying: Partial<Record<'stone', number>> }
mutableJob.carrying.stone = (mutableJob.carrying.stone ?? 0) + MINE_STONE_YIELD
rt.sprite.setVisible(true)
rt.nameLabel.setVisible(true)
rt.energyBar.setVisible(true)
rt.jobIcon.setVisible(true)
this.addLog(v.id, `✓ Mined (+${MINE_STONE_YIELD} stone) at mine`)
} else {
// Surface rock: ROCK tile stays ROCK after mining
const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
// and valid for mine building placement.
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)
// Extract state collections once — avoids repeated Object.values() allocation per branch/loop.
const resources = Object.values(state.world.resources)
const buildings = Object.values(state.world.buildings)
const crops = Object.values(state.world.crops)
const seedlings = Object.values(state.world.treeSeedlings)
const zones = Object.values(state.world.foresterZones)
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<string>()
for (const zone of zones) {
for (const key of zone.tiles) zoneTiles.add(key)
}
const zoneChop: C[] = []
const naturalChop: C[] = []
for (const res of resources) {
if (res.kind !== 'tree' || this.isTargetClaimed(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) {
// Mine buildings: walk to entrance tile (tileX+1, tileY+1) and work inside
for (const b of buildings) {
if (b.kind !== 'mine' || this.isTargetClaimed(b.id)) continue
const eTileX = b.tileX + 1, eTileY = b.tileY + 1
candidates.push({ type: 'mine', targetId: b.id, tileX: eTileX, tileY: eTileY, dist: dist(eTileX, eTileY), pri: p.mine })
}
// Surface rocks (still valid without a mine building)
for (const res of resources) {
if (res.kind !== 'rock' || this.isTargetClaimed(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 crops) {
if (crop.stage < crop.maxStage || this.isTargetClaimed(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 zones) {
for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number)
const targetId = `forester_tile_${tx}_${ty}`
if (this.isTargetClaimed(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 — reuse already-extracted arrays
const occupied =
resources.some(r => r.tileX === tx && r.tileY === ty) ||
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
crops.some(c => c.tileX === tx && c.tileY === ty) ||
seedlings.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 — avoid spread+map allocation
let bestPri = candidates[0].pri
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].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.releaseTarget(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 {
// Nisse always render above trees, buildings and other world objects.
const sprite = this.scene.add.image(v.x, v.y, 'villager')
.setDepth(900)
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, 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 (0100)
*/
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.
*/
/**
* Rescues all Nisse that were working inside a demolished building.
* Makes hidden sprites visible again, clears their jobs, and resets AI to idle.
* Also releases any mine-capacity claims for that building.
* @param buildingId - ID of the building that was demolished
*/
rescueNisseFromBuilding(buildingId: string): void {
this.mineClaimsMap.delete(buildingId)
const state = stateManager.getState()
for (const v of Object.values(state.world.villagers)) {
if (v.job?.targetId !== buildingId) continue
const rt = this.runtime.get(v.id)
if (!rt) continue
// Make sprite visible in case the Nisse was hidden inside the mine
rt.sprite.setVisible(true)
rt.nameLabel.setVisible(true)
rt.energyBar.setVisible(true)
rt.jobIcon.setVisible(true)
rt.workTimer = 0
rt.idleScanTimer = 0
this.claimed.delete(buildingId)
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
this.addLog(v.id, '! Building demolished — resuming')
}
}
/**
* 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.nameLabel.destroy()
rt.energyBar.destroy(); rt.jobIcon.destroy()
}
this.runtime.clear()
}
}