From 155a40f96330c6315a60d65174d09b2bed97b702 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 14:21:12 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20Nisse=20info=20panel=20with?= =?UTF-8?q?=20work=20log=20(Issue=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 +- src/scenes/GameScene.ts | 3 +- src/scenes/UIScene.ts | 196 +++++++++++++++++++++++++++++++++- src/systems/VillagerSystem.ts | 53 ++++++++- 4 files changed, 247 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eee4719..d8bbe3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added -- **ESC Menu**: pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save -- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu +- **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse +- Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile +- **ESC Menu** (Issue #7): pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save +- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → Nisse info panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu ### Added - **F3 Debug View**: toggleable overlay showing FPS, tile type and contents under the cursor, Nisse count by AI state, active jobs by type, and pathfinding visualization (cyan lines in world space) diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index fb8b074..d0c2852 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -61,7 +61,8 @@ export class GameScene extends Phaser.Scene { this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) this.villagerSystem.create() - this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) + this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) + this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id) this.debugSystem.create() diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index 9ae4961..0974ab1 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -35,6 +35,16 @@ export class UIScene extends Phaser.Scene { private escMenuVisible = false private confirmGroup!: Phaser.GameObjects.Group private confirmVisible = false + private nisseInfoGroup!: Phaser.GameObjects.Group + private nisseInfoVisible = false + private nisseInfoId: string | null = null + private nisseInfoDynamic: { + statusText: Phaser.GameObjects.Text + energyBar: Phaser.GameObjects.Graphics + energyPct: Phaser.GameObjects.Text + jobText: Phaser.GameObjects.Text + logTexts: Phaser.GameObjects.Text[] + } | null = null constructor() { super({ key: 'UI' }) } @@ -68,10 +78,13 @@ export class UIScene extends Phaser.Scene { this.scale.on('resize', () => this.repositionUI()) + gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id)) + this.input.mouse!.disableContextMenu() this.contextMenuGroup = this.add.group() this.escMenuGroup = this.add.group() this.confirmGroup = this.add.group() + this.nisseInfoGroup = this.add.group() this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => { if (ptr.rightButtonDown()) { @@ -98,6 +111,7 @@ export class UIScene extends Phaser.Scene { this.updateToast(delta) this.updatePopText() if (this.debugActive) this.updateDebugPanel() + if (this.nisseInfoVisible) this.refreshNisseInfoPanel() } // ─── Stockpile ──────────────────────────────────────────────────────────── @@ -502,11 +516,12 @@ export class UIScene extends Phaser.Scene { * esc menu → build/farm mode (handled by their own systems) → open ESC menu. */ private handleEsc(): void { - if (this.confirmVisible) { this.hideConfirm(); return } - if (this.contextMenuVisible) { this.hideContextMenu(); return } - if (this.buildMenuVisible) { this.closeBuildMenu(); return } - if (this.villagerPanelVisible){ this.closeVillagerPanel(); return } - if (this.escMenuVisible) { this.closeEscMenu(); return } + if (this.confirmVisible) { this.hideConfirm(); return } + if (this.contextMenuVisible) { this.hideContextMenu(); return } + if (this.buildMenuVisible) { this.closeBuildMenu(); return } + if (this.villagerPanelVisible){ this.closeVillagerPanel(); return } + if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return } + if (this.escMenuVisible) { this.closeEscMenu(); return } // Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key. // We only skip opening the ESC menu while those modes are active. if (this.inBuildMode || this.inFarmMode) return @@ -667,6 +682,176 @@ export class UIScene extends Phaser.Scene { this.scene.get('Game').events.emit('uiMenuClose') } + // ─── Nisse Info Panel ───────────────────────────────────────────────────── + + /** + * Opens (or switches to) the Nisse info panel for the given Nisse ID. + * If another Nisse's panel is already open, it is replaced. + * @param villagerId - ID of the Nisse to display + */ + private openNisseInfoPanel(villagerId: string): void { + this.nisseInfoId = villagerId + this.nisseInfoVisible = true + this.scene.get('Game').events.emit('uiMenuOpen') + this.buildNisseInfoPanel() + } + + /** Closes and destroys the Nisse info panel. */ + private closeNisseInfoPanel(): void { + if (!this.nisseInfoVisible) return + this.nisseInfoVisible = false + this.nisseInfoId = null + this.nisseInfoGroup.destroy(true) + this.nisseInfoGroup = this.add.group() + this.scene.get('Game').events.emit('uiMenuClose') + } + + /** + * Builds the static skeleton of the Nisse info panel (background, name, close + * button, labels, priority buttons) and stores references to the dynamic parts + * (status text, energy bar, job text, work log texts). + */ + private buildNisseInfoPanel(): void { + this.nisseInfoGroup.destroy(true) + this.nisseInfoGroup = this.add.group() + this.nisseInfoDynamic = null + + const id = this.nisseInfoId + if (!id) return + + const state = stateManager.getState() + const v = state.world.villagers[id] + if (!v) { this.closeNisseInfoPanel(); return } + + const LOG_ROWS = 10 + const panelW = 280 + const panelH = 120 + LOG_ROWS * 14 + 16 + const px = 10, py = 10 + + // Background + this.nisseInfoGroup.add( + this.add.rectangle(px, py, panelW, panelH, 0x050510, 0.93) + .setOrigin(0, 0).setScrollFactor(0).setDepth(250) + ) + + // Name + this.nisseInfoGroup.add( + this.add.text(px + 10, py + 10, v.name, { + fontSize: '14px', color: '#ffffff', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + ) + + // Close button + const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', { + fontSize: '13px', color: '#888888', fontFamily: 'monospace', + }).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive() + closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' })) + closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' })) + closeBtn.on('pointerdown', () => this.closeNisseInfoPanel()) + this.nisseInfoGroup.add(closeBtn) + + // Dynamic: status text + const statusTxt = this.add.text(px + 10, py + 28, '', { + fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + this.nisseInfoGroup.add(statusTxt) + + // Dynamic: energy bar + pct + const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251) + this.nisseInfoGroup.add(energyBar) + const energyPct = this.add.text(px + 136, py + 46, '', { + fontSize: '10px', color: '#888888', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + this.nisseInfoGroup.add(energyPct) + + // Dynamic: job text + const jobTxt = this.add.text(px + 10, py + 60, '', { + fontSize: '11px', color: '#cccccc', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + this.nisseInfoGroup.add(jobTxt) + + // Static: priority label + buttons + const jobKeys: Array<{ key: string; icon: string }> = [ + { key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, + ] + jobKeys.forEach((j, i) => { + const pri = v.priorities[j.key as keyof typeof v.priorities] + const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}` + const bx = px + 10 + i * 88 + const btn = this.add.text(bx, py + 78, label, { + fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff', + fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a', + padding: { x: 5, y: 3 }, + }).setScrollFactor(0).setDepth(252).setInteractive() + btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' })) + btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' })) + btn.on('pointerdown', () => { + const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5 + const newPriorities = { ...v.priorities, [j.key]: newPri } + this.scene.get('Game').events.emit('updatePriorities', id, newPriorities) + // Rebuild panel so priority buttons reflect the new values immediately + this.buildNisseInfoPanel() + }) + this.nisseInfoGroup.add(btn) + }) + + // Static: work log header + this.nisseInfoGroup.add( + this.add.text(px + 10, py + 98, '── Work Log ──────────────────', { + fontSize: '10px', color: '#555555', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + ) + + // Dynamic: log text rows (pre-allocated) + const logTexts: Phaser.GameObjects.Text[] = [] + for (let i = 0; i < LOG_ROWS; i++) { + const t = this.add.text(px + 10, py + 112 + i * 14, '', { + fontSize: '10px', color: '#888888', fontFamily: 'monospace', + }).setScrollFactor(0).setDepth(251) + this.nisseInfoGroup.add(t) + logTexts.push(t) + } + + this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts } + this.refreshNisseInfoPanel() + } + + /** + * Updates only the dynamic parts of the Nisse info panel (status, energy, + * job, work log) without destroying and recreating the full group. + * Called every frame while the panel is visible. + */ + private refreshNisseInfoPanel(): void { + const dyn = this.nisseInfoDynamic + if (!dyn || !this.nisseInfoId) return + + const state = stateManager.getState() + const v = state.world.villagers[this.nisseInfoId] + if (!v) { this.closeNisseInfoPanel(); return } + + const gameScene = this.scene.get('Game') as any + const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[] + const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string + + dyn.statusText.setText(statusStr) + + // Energy bar + const px = 10, py = 10 + dyn.energyBar.clear() + dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7) + const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336 + dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7) + dyn.energyPct.setText(`${Math.round(v.energy)}%`) + + // Job + dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`) + + // Work log rows + dyn.logTexts.forEach((t, i) => { + t.setText(workLog[i] ?? '') + }) + } + // ─── Resize ─────────────────────────────────────────────────────────────── /** @@ -699,5 +884,6 @@ export class UIScene extends Phaser.Scene { if (this.contextMenuVisible) this.hideContextMenu() if (this.escMenuVisible) this.closeEscMenu() if (this.confirmVisible) this.hideConfirm() + if (this.nisseInfoVisible) this.closeNisseInfoPanel() } } diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 45f82c2..7b3f172 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -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 -- 2.49.1