Files
nissefolk/src/systems/VillagerSystem.ts

401 lines
16 KiB
TypeScript
Raw Normal View History

2026-03-20 08:11:31 +00:00
import Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
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
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
}
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
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
this.scene = scene
this.adapter = adapter
this.worldSystem = worldSystem
}
/** Wire in sibling systems after construction */
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
this.resourceSystem = resourceSystem
this.farmingSystem = farmingSystem
}
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)
}
}
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 ────────────────────────────────────────────────────
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
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 icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
}
// ─── IDLE ─────────────────────────────────────────────────────────────────
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.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.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.beginWalk(v, rt, job.tileX, job.tileY, 'job')
} else {
// No job available — wait before scanning again
rt.idleScanTimer = 800 + Math.random() * 600
}
}
// ─── WALKING ──────────────────────────────────────────────────────────────
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)
}
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
2026-03-20 08:11:31 +00:00
break
case 'bed':
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
break
default:
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
}
rt.destination = null
}
// ─── WORKING ──────────────────────────────────────────────────────────────
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') {
if (state.world.resources[job.targetId]) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
this.resourceSystem.removeResource(job.targetId)
}
} else if (job.type === 'mine') {
if (state.world.resources[job.targetId]) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
this.resourceSystem.removeResource(job.targetId)
}
} 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 })
}
}
// 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
2026-03-20 08:11:31 +00:00
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
}
// ─── SLEEPING ─────────────────────────────────────────────────────────────
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' })
}
}
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
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) {
for (const res of Object.values(state.world.resources)) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
}
}
if (p.mine > 0) {
for (const res of Object.values(state.world.resources)) {
if (res.kind !== 'rock' || this.claimed.has(res.id)) 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 (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 ──────────────────────────────────────────────────────────
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 = 1500 // longer delay after failed pathfind
return
}
rt.path = path
rt.destination = dest
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'walking' })
}
// ─── Building finders ─────────────────────────────────────────────────────
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]
}
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
}
// ─── Spawning ─────────────────────────────────────────────────────────────
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 },
energy: 100,
aiState: 'idle',
}
this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
this.spawnSprite(villager)
this.onMessage?.(`${name} the Nisse has arrived! 🏘`)
2026-03-20 08:11:31 +00:00
}
// ─── Sprite management ────────────────────────────────────────────────────
private spawnSprite(v: VillagerState): void {
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
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(12)
const energyBar = this.scene.add.graphics().setDepth(12)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
}
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)
}
// ─── Public API ───────────────────────────────────────────────────────────
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) return `${v.job.type}ing`
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'
}
destroy(): void {
for (const rt of this.runtime.values()) {
rt.sprite.destroy(); rt.nameLabel.destroy()
rt.energyBar.destroy(); rt.jobIcon.destroy()
}
this.runtime.clear()
}
}