add Nisse info panel with work log (Issue #9)

Clicking a Nisse opens a top-left panel showing name, AI status,
energy bar, active job, job priority buttons, and a live work log
(last 10 of 20 runtime-only entries). Closes via ESC, ✕, or clicking
another Nisse. Dynamic parts (status/energy/job/log) refresh each
frame without rebuilding the full group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 14:21:12 +00:00
parent 41097b4765
commit 155a40f963
4 changed files with 247 additions and 11 deletions

View File

@@ -11,6 +11,8 @@ import type { FarmingSystem } from './FarmingSystem'
const ARRIVAL_PX = 3
const WORK_LOG_MAX = 20
interface VillagerRuntime {
sprite: Phaser.GameObjects.Image
nameLabel: Phaser.GameObjects.Text
@@ -20,6 +22,8 @@ interface VillagerRuntime {
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 {
@@ -35,6 +39,7 @@ export class VillagerSystem {
private nameIndex = 0
onMessage?: (msg: string) => void
onNisseClick?: (villagerId: string) => void
/**
* @param scene - The Phaser scene this system belongs to
@@ -139,13 +144,21 @@ export class VillagerSystem {
// 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 }
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.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return }
if (bed) {
this.addLog(v.id, '→ Going to sleep (low energy)')
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
return
}
}
// Find a job
@@ -156,6 +169,7 @@ export class VillagerSystem {
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
@@ -218,10 +232,12 @@ export class VillagerSystem {
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:
@@ -261,6 +277,7 @@ export class VillagerSystem {
// Clear the FOREST tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
}
} else if (job.type === 'mine') {
const res = state.world.resources[job.targetId]
@@ -269,6 +286,7 @@ export class VillagerSystem {
// Clear the ROCK tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
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]
@@ -276,6 +294,7 @@ export class VillagerSystem {
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}`)
}
}
@@ -304,6 +323,7 @@ export class VillagerSystem {
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)')
}
}
@@ -467,7 +487,10 @@ export class VillagerSystem {
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 })
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: [] })
}
/**
@@ -486,6 +509,21 @@ export class VillagerSystem {
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 ───────────────────────────────────────────────────────────
/**
@@ -506,6 +544,15 @@ export class VillagerSystem {
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