Fixes #34. Alle Object.values()-Aufrufe werden einmal am Anfang von pickJob() extrahiert und in allen Branches wiederverwendet. Der Forester-Loop rief zuvor fuer jedes Zone-Tile 4x Object.values() auf. JOB_ICONS als Modul-Konstante, Math.min-spread durch Schleife ersetzt.
708 lines
29 KiB
TypeScript
708 lines
29 KiB
TypeScript
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
|
||
|
||
/** 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 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
|
||
}
|
||
|
||
// 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.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)
|
||
|
||
// 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.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 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 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 zones) {
|
||
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 — 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.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 {
|
||
// 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 (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.
|
||
*/
|
||
/**
|
||
* 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()
|
||
}
|
||
}
|