8 Commits

Author SHA1 Message Date
822ca620d9 Merge pull request 'Issue #7: ESC Menu' (#12) from feature/esc-menu into master 2026-03-21 14:22:19 +00:00
41097b4765 add ESC menu (Issue #7)
ESC key follows priority stack: confirm dialog → context menu →
build menu → villager panel → ESC menu → open ESC menu.
Menu items: Save Game, Load Game, Settings (placeholder), New Game
(with confirmation dialog).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:13:53 +00:00
0c636ed5ec Merge pull request 'Issue #6: F3 Debug View' (#11) from feature/debug-view into master
Reviewed-on: #11
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-21 14:07:06 +00:00
4c41dc9205 Merge pull request 'Issue #5: Mouse handling — zoom-to-mouse + middle-click pan' (#10) from feature/mouse-handling into master
Reviewed-on: #10
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-21 14:06:45 +00:00
01e57df6a6 📝 add session-start warning to CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:56:19 +00:00
1feeff215d 🔒 ignore .claude/ dir and game-test.log 2026-03-21 12:37:51 +00:00
793ab430e4 📝 update CHANGELOG for Issue #6 debug view 2026-03-21 12:12:07 +00:00
6f0d8a866f add F3 debug view (Issue #6)
F3 toggles a debug overlay with:
- FPS
- Mouse world/tile coordinates
- Tile type under cursor
- Resources, buildings, crops on hovered tile
- Nisse count broken down by AI state (idle/walking/working/sleeping)
- Active jobs by type (chop/mine/farm)
- Pathfinding visualization: cyan lines + destination highlight
  drawn in world space via DebugSystem

Added DebugSystem to GameScene. VillagerSystem exposes
getActivePaths() for the path visualization. JSDoc added to all
previously undocumented methods in VillagerSystem, WorldSystem,
GameScene, and UIScene.
2026-03-21 12:11:54 +00:00
7 changed files with 684 additions and 6 deletions

View File

@@ -7,6 +7,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [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
### 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)
### Fixed ### Fixed
- Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources - Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources
- Nisse no longer get stuck idle after depositing items at the stockpile - Nisse no longer get stuck idle after depositing items at the stockpile

View File

@@ -1,5 +1,16 @@
# CLAUDE.md — Game Project # CLAUDE.md — Game Project
## ⚠️ Important: Session Start Location
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
---
## Project Overview ## Project Overview
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**. A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.

View File

@@ -9,6 +9,7 @@ import { ResourceSystem } from '../systems/ResourceSystem'
import { BuildingSystem } from '../systems/BuildingSystem' import { BuildingSystem } from '../systems/BuildingSystem'
import { FarmingSystem } from '../systems/FarmingSystem' import { FarmingSystem } from '../systems/FarmingSystem'
import { VillagerSystem } from '../systems/VillagerSystem' import { VillagerSystem } from '../systems/VillagerSystem'
import { DebugSystem } from '../systems/DebugSystem'
export class GameScene extends Phaser.Scene { export class GameScene extends Phaser.Scene {
private adapter!: LocalAdapter private adapter!: LocalAdapter
@@ -18,11 +19,16 @@ export class GameScene extends Phaser.Scene {
private buildingSystem!: BuildingSystem private buildingSystem!: BuildingSystem
private farmingSystem!: FarmingSystem private farmingSystem!: FarmingSystem
villagerSystem!: VillagerSystem villagerSystem!: VillagerSystem
debugSystem!: DebugSystem
private autosaveTimer = 0 private autosaveTimer = 0
private menuOpen = false private menuOpen = false
constructor() { super({ key: 'Game' }) } constructor() { super({ key: 'Game' }) }
/**
* Initialises all game systems, wires up inter-system events,
* launches the UI scene overlay, and starts the autosave timer.
*/
create(): void { create(): void {
this.adapter = new LocalAdapter() this.adapter = new LocalAdapter()
@@ -33,6 +39,7 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem = new FarmingSystem(this, this.adapter) this.farmingSystem = new FarmingSystem(this, this.adapter)
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
this.villagerSystem.init(this.resourceSystem, this.farmingSystem) this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
this.worldSystem.create() this.worldSystem.create()
this.renderPersistentObjects() this.renderPersistentObjects()
@@ -56,6 +63,8 @@ export class GameScene extends Phaser.Scene {
this.villagerSystem.create() this.villagerSystem.create()
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
this.debugSystem.create()
// Sync tile changes and building visuals through adapter // Sync tile changes and building visuals through adapter
this.adapter.onAction = (action) => { this.adapter.onAction = (action) => {
if (action.type === 'CHANGE_TILE') { if (action.type === 'CHANGE_TILE') {
@@ -74,10 +83,17 @@ export class GameScene extends Phaser.Scene {
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => { this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities }) this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
}) })
this.events.on('debugToggle', () => this.debugSystem.toggle())
this.autosaveTimer = AUTOSAVE_INTERVAL this.autosaveTimer = AUTOSAVE_INTERVAL
} }
/**
* Main game loop: updates all systems and emits the cameraMoved event for the UI.
* Skips system updates while a menu is open.
* @param _time - Total elapsed time (unused)
* @param delta - Frame delta in milliseconds
*/
update(_time: number, delta: number): void { update(_time: number, delta: number): void {
if (this.menuOpen) return if (this.menuOpen) return
@@ -86,6 +102,7 @@ export class GameScene extends Phaser.Scene {
this.resourceSystem.update(delta) this.resourceSystem.update(delta)
this.farmingSystem.update(delta) this.farmingSystem.update(delta)
this.villagerSystem.update(delta) this.villagerSystem.update(delta)
this.debugSystem.update()
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile()) this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
this.buildingSystem.update() this.buildingSystem.update()
@@ -119,6 +136,7 @@ export class GameScene extends Phaser.Scene {
} }
} }
/** Saves game state and destroys all systems cleanly on scene shutdown. */
shutdown(): void { shutdown(): void {
stateManager.save() stateManager.save()
this.worldSystem.destroy() this.worldSystem.destroy()

View File

@@ -1,6 +1,7 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import type { BuildingType, JobPriorities } from '../types' import type { BuildingType, JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem' import type { FarmingTool } from '../systems/FarmingSystem'
import type { DebugData } from '../systems/DebugSystem'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
const ITEM_ICONS: Record<string, string> = { const ITEM_ICONS: Record<string, string> = {
@@ -28,9 +29,19 @@ export class UIScene extends Phaser.Scene {
private contextMenuVisible = false private contextMenuVisible = false
private inBuildMode = false private inBuildMode = false
private inFarmMode = false private inFarmMode = false
private debugPanelText!: Phaser.GameObjects.Text
private debugActive = false
private escMenuGroup!: Phaser.GameObjects.Group
private escMenuVisible = false
private confirmGroup!: Phaser.GameObjects.Group
private confirmVisible = false
constructor() { super({ key: 'UI' }) } constructor() { super({ key: 'UI' }) }
/**
* Creates all HUD elements, wires up game scene events, and registers
* keyboard shortcuts (B, V, F3, ESC).
*/
create(): void { create(): void {
this.createStockpilePanel() this.createStockpilePanel()
this.createHintText() this.createHintText()
@@ -39,6 +50,7 @@ export class UIScene extends Phaser.Scene {
this.createBuildModeIndicator() this.createBuildModeIndicator()
this.createFarmToolIndicator() this.createFarmToolIndicator()
this.createCoordsDisplay() this.createCoordsDisplay()
this.createDebugPanel()
const gameScene = this.scene.get('Game') const gameScene = this.scene.get('Game')
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b)) gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
@@ -51,11 +63,15 @@ export class UIScene extends Phaser.Scene {
.on('down', () => gameScene.events.emit('uiRequestBuildMenu')) .on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V) this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
.on('down', () => this.toggleVillagerPanel()) .on('down', () => this.toggleVillagerPanel())
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F3)
.on('down', () => this.toggleDebugPanel())
this.scale.on('resize', () => this.repositionUI()) this.scale.on('resize', () => this.repositionUI())
this.input.mouse!.disableContextMenu() this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group() this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => { this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) { if (ptr.rightButtonDown()) {
@@ -68,17 +84,25 @@ export class UIScene extends Phaser.Scene {
}) })
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.hideContextMenu()) .on('down', () => this.handleEsc())
} }
/**
* Updates the stockpile display, toast fade timer, population count,
* and the debug panel each frame.
* @param _t - Total elapsed time (unused)
* @param delta - Frame delta in milliseconds
*/
update(_t: number, delta: number): void { update(_t: number, delta: number): void {
this.updateStockpile() this.updateStockpile()
this.updateToast(delta) this.updateToast(delta)
this.updatePopText() this.updatePopText()
if (this.debugActive) this.updateDebugPanel()
} }
// ─── Stockpile ──────────────────────────────────────────────────────────── // ─── Stockpile ────────────────────────────────────────────────────────────
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
private createStockpilePanel(): void { private createStockpilePanel(): void {
const x = this.scale.width - 178, y = 10 const x = this.scale.width - 178, y = 10
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
@@ -91,6 +115,7 @@ export class UIScene extends Phaser.Scene {
this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
} }
/** Refreshes all item quantities and colors in the stockpile panel. */
private updateStockpile(): void { private updateStockpile(): void {
const sp = stateManager.getState().world.stockpile const sp = stateManager.getState().world.stockpile
for (const [item, t] of this.stockpileTexts) { for (const [item, t] of this.stockpileTexts) {
@@ -100,6 +125,7 @@ export class UIScene extends Phaser.Scene {
} }
} }
/** Updates the Nisse population / bed capacity counter. */
private updatePopText(): void { private updatePopText(): void {
const state = stateManager.getState() const state = stateManager.getState()
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
@@ -109,6 +135,7 @@ export class UIScene extends Phaser.Scene {
// ─── Hint ───────────────────────────────────────────────────────────────── // ─── Hint ─────────────────────────────────────────────────────────────────
/** Creates the centered hint text element near the bottom of the screen. */
private createHintText(): void { private createHintText(): void {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', { this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace', fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
@@ -118,6 +145,7 @@ export class UIScene extends Phaser.Scene {
// ─── Toast ──────────────────────────────────────────────────────────────── // ─── Toast ────────────────────────────────────────────────────────────────
/** Creates the toast notification text element (top center, initially hidden). */
private createToast(): void { private createToast(): void {
this.toastText = this.add.text(this.scale.width / 2, 60, '', { this.toastText = this.add.text(this.scale.width / 2, 60, '', {
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace', fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
@@ -125,8 +153,16 @@ export class UIScene extends Phaser.Scene {
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0) }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
} }
/**
* Displays a toast message for 2.2 seconds then fades it out.
* @param msg - Message to display
*/
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 } showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
/**
* Counts down the toast timer and triggers the fade-out tween when it expires.
* @param delta - Frame delta in milliseconds
*/
private updateToast(delta: number): void { private updateToast(delta: number): void {
if (this.toastTimer <= 0) return if (this.toastTimer <= 0) return
this.toastTimer -= delta this.toastTimer -= delta
@@ -135,6 +171,7 @@ export class UIScene extends Phaser.Scene {
// ─── Build Menu ─────────────────────────────────────────────────────────── // ─── Build Menu ───────────────────────────────────────────────────────────
/** Creates and hides the build menu with buttons for each available building type. */
private createBuildMenu(): void { private createBuildMenu(): void {
this.buildMenuGroup = this.add.group() this.buildMenuGroup = this.add.group()
const buildings: { kind: BuildingType; label: string; cost: string }[] = [ const buildings: { kind: BuildingType; label: string; cost: string }[] = [
@@ -162,12 +199,18 @@ export class UIScene extends Phaser.Scene {
this.buildMenuGroup.setVisible(false) this.buildMenuGroup.setVisible(false)
} }
/** Toggles the build menu open or closed. */
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() } private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
/** Opens the build menu and notifies GameScene that a menu is active. */
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') } private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
/** Closes the build menu and notifies GameScene that no menu is active. */
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') } private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
// ─── Villager Panel (V key) ─────────────────────────────────────────────── // ─── Villager Panel (V key) ───────────────────────────────────────────────
/** Toggles the Nisse management panel open or closed. */
private toggleVillagerPanel(): void { private toggleVillagerPanel(): void {
if (this.villagerPanelVisible) { if (this.villagerPanelVisible) {
this.closeVillagerPanel() this.closeVillagerPanel()
@@ -176,18 +219,24 @@ export class UIScene extends Phaser.Scene {
} }
} }
/** Opens the Nisse panel, builds its contents, and notifies GameScene. */
private openVillagerPanel(): void { private openVillagerPanel(): void {
this.villagerPanelVisible = true this.villagerPanelVisible = true
this.buildVillagerPanel() this.buildVillagerPanel()
this.scene.get('Game').events.emit('uiMenuOpen') this.scene.get('Game').events.emit('uiMenuOpen')
} }
/** Closes and destroys the Nisse panel and notifies GameScene. */
private closeVillagerPanel(): void { private closeVillagerPanel(): void {
this.villagerPanelVisible = false this.villagerPanelVisible = false
this.villagerPanelGroup?.destroy(true) this.villagerPanelGroup?.destroy(true)
this.scene.get('Game').events.emit('uiMenuClose') this.scene.get('Game').events.emit('uiMenuClose')
} }
/**
* Destroys and rebuilds the Nisse panel from current state.
* Shows name, status, energy bar, and job priority buttons per Nisse.
*/
private buildVillagerPanel(): void { private buildVillagerPanel(): void {
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true) if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
this.villagerPanelGroup = this.add.group() this.villagerPanelGroup = this.add.group()
@@ -266,9 +315,16 @@ export class UIScene extends Phaser.Scene {
// ─── Build mode indicator ───────────────────────────────────────────────── // ─── Build mode indicator ─────────────────────────────────────────────────
/** Creates the build-mode indicator text in the top-left corner (initially hidden). */
private createBuildModeIndicator(): void { private createBuildModeIndicator(): void {
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
} }
/**
* Shows or hides the build-mode indicator based on whether build mode is active.
* @param active - Whether build mode is currently active
* @param building - The selected building type
*/
private onBuildModeChanged(active: boolean, building: BuildingType): void { private onBuildModeChanged(active: boolean, building: BuildingType): void {
this.inBuildMode = active this.inBuildMode = active
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active) this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
@@ -276,9 +332,16 @@ export class UIScene extends Phaser.Scene {
// ─── Farm tool indicator ────────────────────────────────────────────────── // ─── Farm tool indicator ──────────────────────────────────────────────────
/** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */
private createFarmToolIndicator(): void { private createFarmToolIndicator(): void {
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
} }
/**
* Shows or hides the farm-tool indicator and updates the active tool label.
* @param tool - Currently selected farm tool
* @param label - Human-readable label for the tool
*/
private onFarmToolChanged(tool: FarmingTool, label: string): void { private onFarmToolChanged(tool: FarmingTool, label: string): void {
this.inFarmMode = tool !== 'none' this.inFarmMode = tool !== 'none'
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none') this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
@@ -286,16 +349,88 @@ export class UIScene extends Phaser.Scene {
// ─── Coords + controls ──────────────────────────────────────────────────── // ─── Coords + controls ────────────────────────────────────────────────────
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
private createCoordsDisplay(): void { private createCoordsDisplay(): void {
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100) this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse', { this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 } fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
}).setScrollFactor(0).setDepth(100) }).setScrollFactor(0).setDepth(100)
} }
/**
* Updates the tile-coordinate display when the camera moves.
* @param pos - Tile position of the camera center
*/
private onCameraMoved(pos: { tileX: number; tileY: number }): void { private onCameraMoved(pos: { tileX: number; tileY: number }): void {
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`) this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
} }
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void {
this.debugPanelText = this.add.text(10, 80, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: '#000000cc',
padding: { x: 8, y: 6 },
lineSpacing: 2,
fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(150).setVisible(false)
}
/** Toggles the debug panel and notifies GameScene to toggle the pathfinding overlay. */
private toggleDebugPanel(): void {
this.debugActive = !this.debugActive
this.debugPanelText.setVisible(this.debugActive)
this.scene.get('Game').events.emit('debugToggle')
}
/**
* Reads current debug data from DebugSystem and updates the panel text.
* Called every frame while debug mode is active.
*/
private updateDebugPanel(): void {
const gameScene = this.scene.get('Game') as any
const debugSystem = gameScene.debugSystem
if (!debugSystem?.isActive()) return
const ptr = this.input.activePointer
const data = debugSystem.getDebugData(ptr) as DebugData
const resLine = data.resourcesOnTile.length > 0
? data.resourcesOnTile.map(r => `${r.kind} (hp:${r.hp})`).join(', ')
: '—'
const bldLine = data.buildingsOnTile.length > 0 ? data.buildingsOnTile.join(', ') : '—'
const cropLine = data.cropsOnTile.length > 0
? data.cropsOnTile.map(c => `${c.kind} (${c.stage}/${c.maxStage})`).join(', ')
: '—'
const { idle, walking, working, sleeping } = data.nisseByState
const { chop, mine, farm } = data.jobsByType
this.debugPanelText.setText([
'── F3 DEBUG ──────────────────',
`FPS: ${data.fps}`,
'',
`Mouse world: ${data.mouseWorld.x.toFixed(1)}, ${data.mouseWorld.y.toFixed(1)}`,
`Mouse tile: ${data.mouseTile.tileX}, ${data.mouseTile.tileY}`,
`Tile type: ${data.tileType}`,
`Resources: ${resLine}`,
`Buildings: ${bldLine}`,
`Crops: ${cropLine}`,
'',
`Nisse: ${data.nisseTotal} total`,
` idle: ${idle} walking: ${walking} working: ${working} sleeping: ${sleeping}`,
'',
`Jobs active:`,
` chop: ${chop} mine: ${mine} farm: ${farm}`,
'',
`Paths: ${data.activePaths} (cyan lines in world)`,
'',
'[F3] close',
])
}
// ─── Context Menu ───────────────────────────────────────────────────────── // ─── Context Menu ─────────────────────────────────────────────────────────
/** /**
@@ -359,6 +494,179 @@ export class UIScene extends Phaser.Scene {
this.scene.get('Game').events.emit('uiMenuClose') this.scene.get('Game').events.emit('uiMenuClose')
} }
// ─── ESC key handler ──────────────────────────────────────────────────────
/**
* Handles ESC key presses with a priority stack:
* confirm dialog → context menu → build menu → villager panel →
* 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 }
// 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
this.openEscMenu()
}
// ─── ESC Menu ─────────────────────────────────────────────────────────────
/** Opens the ESC pause menu (New Game / Save / Load / Settings). */
private openEscMenu(): void {
if (this.escMenuVisible) return
this.escMenuVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildEscMenu()
}
/** Closes and destroys the ESC menu. */
private closeEscMenu(): void {
if (!this.escMenuVisible) return
this.escMenuVisible = false
this.escMenuGroup.destroy(true)
this.escMenuGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/** Builds the ESC menu UI elements. */
private buildEscMenu(): void {
if (this.escMenuGroup) this.escMenuGroup.destroy(true)
this.escMenuGroup = this.add.group()
const menuW = 240
const btnH = 40
const entries: { label: string; action: () => void }[] = [
{ label: '💾 Save Game', action: () => this.doSaveGame() },
{ label: '📂 Load Game', action: () => this.doLoadGame() },
{ label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() },
]
const menuH = 16 + entries.length * (btnH + 8) + 8
const mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 2
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, 0.95)
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
this.escMenuGroup.add(bg)
this.escMenuGroup.add(
this.add.text(mx + menuW / 2, my + 12, 'MENU [ESC] close', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
)
entries.forEach((entry, i) => {
const by = my + 32 + i * (btnH + 8)
const btn = this.add.rectangle(mx + 12, by, menuW - 24, btnH, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(401).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2a2a4e, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a1a2e, 0.9))
btn.on('pointerdown', entry.action)
this.escMenuGroup.add(btn)
this.escMenuGroup.add(
this.add.text(mx + 24, by + btnH / 2, entry.label, {
fontSize: '14px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
)
})
}
/** Saves the game and shows a toast confirmation. */
private doSaveGame(): void {
stateManager.save()
this.closeEscMenu()
this.showToast('Game saved!')
}
/** Reloads the page to load the last save from localStorage. */
private doLoadGame(): void {
this.closeEscMenu()
window.location.reload()
}
/** Opens an empty Settings panel (placeholder). */
private doSettings(): void {
this.closeEscMenu()
this.showToast('Settings — coming soon')
}
/** Shows a confirmation dialog before starting a new game. */
private doNewGame(): void {
this.closeEscMenu()
this.showConfirm(
'Start a new game?\nAll progress will be lost.',
() => { stateManager.reset(); window.location.reload() },
)
}
// ─── Confirm dialog ───────────────────────────────────────────────────────
/**
* Shows a modal confirmation dialog with OK and Cancel buttons.
* @param message - Message to display (newlines supported)
* @param onConfirm - Callback invoked when the user confirms
*/
private showConfirm(message: string, onConfirm: () => void): void {
this.hideConfirm()
this.confirmVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
const dialogW = 280
const dialogH = 130
const dx = this.scale.width / 2 - dialogW / 2
const dy = this.scale.height / 2 - dialogH / 2
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, 0.97)
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
this.confirmGroup.add(bg)
this.confirmGroup.add(
this.add.text(dx + dialogW / 2, dy + 20, message, {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
align: 'center', wordWrap: { width: dialogW - 32 },
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(501)
)
const btnY = dy + dialogH - 44
// Cancel button
const cancelBtn = this.add.rectangle(dx + 16, btnY, 110, 30, 0x333333, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
cancelBtn.on('pointerover', () => cancelBtn.setFillStyle(0x555555, 0.9))
cancelBtn.on('pointerout', () => cancelBtn.setFillStyle(0x333333, 0.9))
cancelBtn.on('pointerdown', () => this.hideConfirm())
this.confirmGroup.add(cancelBtn)
this.confirmGroup.add(
this.add.text(dx + 71, btnY + 15, 'Cancel', {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
)
// OK button
const okBtn = this.add.rectangle(dx + dialogW - 126, btnY, 110, 30, 0x4a1a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
okBtn.on('pointerover', () => okBtn.setFillStyle(0x8a2a2a, 0.9))
okBtn.on('pointerout', () => okBtn.setFillStyle(0x4a1a1a, 0.9))
okBtn.on('pointerdown', () => { this.hideConfirm(); onConfirm() })
this.confirmGroup.add(okBtn)
this.confirmGroup.add(
this.add.text(dx + dialogW - 71, btnY + 15, 'OK', {
fontSize: '13px', color: '#ff8888', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
)
}
/** Closes and destroys the confirmation dialog. */
private hideConfirm(): void {
if (!this.confirmVisible) return
this.confirmVisible = false
this.confirmGroup.destroy(true)
this.confirmGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
// ─── Resize ─────────────────────────────────────────────────────────────── // ─── Resize ───────────────────────────────────────────────────────────────
/** /**
@@ -389,5 +697,7 @@ export class UIScene extends Phaser.Scene {
if (this.buildMenuVisible) this.closeBuildMenu() if (this.buildMenuVisible) this.closeBuildMenu()
if (this.villagerPanelVisible) this.closeVillagerPanel() if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu() if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu()
if (this.confirmVisible) this.hideConfirm()
} }
} }

164
src/systems/DebugSystem.ts Normal file
View File

@@ -0,0 +1,164 @@
import Phaser from 'phaser'
import { TILE_SIZE } from '../config'
import { TileType } from '../types'
import { stateManager } from '../StateManager'
import type { VillagerSystem } from './VillagerSystem'
import type { WorldSystem } from './WorldSystem'
/** All data collected each frame for the debug panel. */
export interface DebugData {
fps: number
mouseWorld: { x: number; y: number }
mouseTile: { tileX: number; tileY: number }
tileType: string
resourcesOnTile: Array<{ kind: string; hp: number }>
buildingsOnTile: string[]
cropsOnTile: Array<{ kind: string; stage: number; maxStage: number }>
nisseTotal: number
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
jobsByType: { chop: number; mine: number; farm: number }
activePaths: number
}
/** Human-readable names for TileType enum values. */
const TILE_NAMES: Record<number, string> = {
[TileType.DEEP_WATER]: 'DEEP_WATER',
[TileType.SHALLOW_WATER]: 'SHALLOW_WATER',
[TileType.SAND]: 'SAND',
[TileType.GRASS]: 'GRASS',
[TileType.DARK_GRASS]: 'DARK_GRASS',
[TileType.FOREST]: 'FOREST',
[TileType.ROCK]: 'ROCK',
[TileType.FLOOR]: 'FLOOR',
[TileType.WALL]: 'WALL',
[TileType.TILLED_SOIL]: 'TILLED_SOIL',
[TileType.WATERED_SOIL]: 'WATERED_SOIL',
}
export class DebugSystem {
private scene: Phaser.Scene
private villagerSystem: VillagerSystem
private worldSystem: WorldSystem
private pathGraphics!: Phaser.GameObjects.Graphics
private active = false
/**
* @param scene - The Phaser scene this system belongs to
* @param villagerSystem - Used to read active paths for visualization
* @param worldSystem - Used to read tile types under the mouse
*/
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
this.scene = scene
this.villagerSystem = villagerSystem
this.worldSystem = worldSystem
}
/**
* Creates the world-space Graphics object used for pathfinding visualization.
* Starts hidden until toggled on.
*/
create(): void {
this.pathGraphics = this.scene.add.graphics().setDepth(50)
this.pathGraphics.setVisible(false)
}
/**
* Toggles debug mode on or off.
* Shows or hides the pathfinding overlay graphics accordingly.
*/
toggle(): void {
this.active = !this.active
this.pathGraphics.setVisible(this.active)
if (!this.active) this.pathGraphics.clear()
}
/** Returns whether debug mode is currently active. */
isActive(): boolean {
return this.active
}
/**
* Redraws pathfinding lines for all currently walking Nisse.
* Should be called every frame while debug mode is active.
*/
update(): void {
if (!this.active) return
this.pathGraphics.clear()
const paths = this.villagerSystem.getActivePaths()
this.pathGraphics.lineStyle(1, 0x00ffff, 0.65)
for (const entry of paths) {
if (entry.path.length === 0) continue
this.pathGraphics.beginPath()
this.pathGraphics.moveTo(entry.x, entry.y)
for (const step of entry.path) {
this.pathGraphics.lineTo(
(step.tileX + 0.5) * TILE_SIZE,
(step.tileY + 0.5) * TILE_SIZE,
)
}
this.pathGraphics.strokePath()
// Mark the destination tile
const last = entry.path[entry.path.length - 1]
this.pathGraphics.fillStyle(0x00ffff, 0.4)
this.pathGraphics.fillRect(
last.tileX * TILE_SIZE,
last.tileY * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
)
}
}
/**
* Collects and returns all debug data for the current frame.
* Called by UIScene to populate the debug panel.
* @param ptr - The active pointer, used to resolve world position
* @returns Snapshot of game state for display
*/
getDebugData(ptr: Phaser.Input.Pointer): DebugData {
const state = stateManager.getState()
const villagers = Object.values(state.world.villagers)
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const tileType = this.worldSystem.getTileType(tileX, tileY)
const nisseByState = { idle: 0, walking: 0, working: 0, sleeping: 0 }
const jobsByType = { chop: 0, mine: 0, farm: 0 }
for (const v of villagers) {
nisseByState[v.aiState as keyof typeof nisseByState]++
if (v.job && (v.aiState === 'working' || v.aiState === 'walking')) {
jobsByType[v.job.type as keyof typeof jobsByType]++
}
}
const resourcesOnTile = Object.values(state.world.resources)
.filter(r => r.tileX === tileX && r.tileY === tileY)
.map(r => ({ kind: r.kind, hp: r.hp }))
const buildingsOnTile = Object.values(state.world.buildings)
.filter(b => b.tileX === tileX && b.tileY === tileY)
.map(b => b.kind)
const cropsOnTile = Object.values(state.world.crops)
.filter(c => c.tileX === tileX && c.tileY === tileY)
.map(c => ({ kind: c.kind, stage: c.stage, maxStage: c.maxStage }))
return {
fps: Math.round(this.scene.game.loop.actualFps),
mouseWorld: { x: ptr.worldX, y: ptr.worldY },
mouseTile: { tileX, tileY },
tileType: TILE_NAMES[tileType] ?? `UNKNOWN(${tileType})`,
resourcesOnTile,
buildingsOnTile,
cropsOnTile,
nisseTotal: villagers.length,
nisseByState,
jobsByType,
activePaths: this.villagerSystem.getActivePaths().length,
}
}
}

View File

@@ -36,18 +36,32 @@ export class VillagerSystem {
onMessage?: (msg: string) => void onMessage?: (msg: string) => 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) { constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
this.scene = scene this.scene = scene
this.adapter = adapter this.adapter = adapter
this.worldSystem = worldSystem this.worldSystem = worldSystem
} }
/** Wire in sibling systems after construction */ /**
* 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 { init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
this.resourceSystem = resourceSystem this.resourceSystem = resourceSystem
this.farmingSystem = farmingSystem this.farmingSystem = farmingSystem
} }
/**
* Spawns sprites for all Nisse that exist in the saved state
* and re-claims any active job targets.
*/
create(): void { create(): void {
const state = stateManager.getState() const state = stateManager.getState()
for (const v of Object.values(state.world.villagers)) { for (const v of Object.values(state.world.villagers)) {
@@ -57,6 +71,10 @@ export class VillagerSystem {
} }
} }
/**
* Advances the spawn timer and ticks every Nisse's AI.
* @param delta - Frame delta in milliseconds
*/
update(delta: number): void { update(delta: number): void {
this.spawnTimer += delta this.spawnTimer += delta
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) { if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
@@ -72,6 +90,12 @@ export class VillagerSystem {
// ─── Per-villager tick ──────────────────────────────────────────────────── // ─── 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 { private tickVillager(v: VillagerState, delta: number): void {
const rt = this.runtime.get(v.id) const rt = this.runtime.get(v.id)
if (!rt) return if (!rt) return
@@ -97,6 +121,14 @@ export class VillagerSystem {
// ─── IDLE ───────────────────────────────────────────────────────────────── // ─── 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 { private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
// Decrement scan timer if cooling down // Decrement scan timer if cooling down
if (rt.idleScanTimer > 0) { if (rt.idleScanTimer > 0) {
@@ -133,6 +165,14 @@ export class VillagerSystem {
// ─── WALKING ────────────────────────────────────────────────────────────── // ─── 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 { private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
if (rt.path.length === 0) { if (rt.path.length === 0) {
this.onArrived(v, rt) this.onArrived(v, rt)
@@ -161,6 +201,12 @@ export class VillagerSystem {
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015) ;(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 { private onArrived(v: VillagerState, rt: VillagerRuntime): void {
switch (rt.destination) { switch (rt.destination) {
case 'job': case 'job':
@@ -186,6 +232,14 @@ export class VillagerSystem {
// ─── WORKING ────────────────────────────────────────────────────────────── // ─── 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 { private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
rt.workTimer -= delta rt.workTimer -= delta
// Wobble while working // Wobble while working
@@ -237,6 +291,12 @@ export class VillagerSystem {
// ─── SLEEPING ───────────────────────────────────────────────────────────── // ─── 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 { private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04) ;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
// Gentle bob while sleeping // Gentle bob while sleeping
@@ -249,6 +309,13 @@ export class VillagerSystem {
// ─── Job picking (RimWorld-style priority) ──────────────────────────────── // ─── 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
*/
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null { private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
const state = stateManager.getState() const state = stateManager.getState()
const p = v.priorities const p = v.priorities
@@ -289,6 +356,15 @@ export class VillagerSystem {
// ─── Pathfinding ────────────────────────────────────────────────────────── // ─── 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 { private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
const sx = Math.floor(v.x / TILE_SIZE) const sx = Math.floor(v.x / TILE_SIZE)
const sy = Math.floor(v.y / TILE_SIZE) const sy = Math.floor(v.y / TILE_SIZE)
@@ -310,6 +386,11 @@ export class VillagerSystem {
// ─── Building finders ───────────────────────────────────────────────────── // ─── 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 { private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
const state = stateManager.getState() const state = stateManager.getState()
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind) const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
@@ -319,6 +400,11 @@ export class VillagerSystem {
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0] 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 { private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
const state = stateManager.getState() const state = stateManager.getState()
// Prefer assigned bed // Prefer assigned bed
@@ -328,6 +414,10 @@ export class VillagerSystem {
// ─── Spawning ───────────────────────────────────────────────────────────── // ─── 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 { private trySpawn(): void {
const state = stateManager.getState() const state = stateManager.getState()
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed') const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
@@ -361,6 +451,11 @@ export class VillagerSystem {
// ─── Sprite management ──────────────────────────────────────────────────── // ─── 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
*/
private spawnSprite(v: VillagerState): void { private spawnSprite(v: VillagerState): void {
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11) const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
@@ -375,6 +470,14 @@ export class VillagerSystem {
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 }) this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
} }
/**
* 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 { private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
const W = 20, H = 3 const W = 20, H = 3
g.clear() g.clear()
@@ -385,6 +488,12 @@ export class VillagerSystem {
// ─── Public API ─────────────────────────────────────────────────────────── // ─── 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 { getStatusText(villagerId: string): string {
const v = stateManager.getState().world.villagers[villagerId] const v = stateManager.getState().world.villagers[villagerId]
if (!v) return '—' if (!v) return '—'
@@ -397,6 +506,28 @@ export class VillagerSystem {
return '💭 Idle' return '💭 Idle'
} }
/**
* 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.
*/
destroy(): void { destroy(): void {
for (const rt of this.runtime.values()) { for (const rt of this.runtime.values()) {
rt.sprite.destroy(); rt.nameLabel.destroy() rt.sprite.destroy(); rt.nameLabel.destroy()

View File

@@ -22,10 +22,15 @@ export class WorldSystem {
private bgImage!: Phaser.GameObjects.Image private bgImage!: Phaser.GameObjects.Image
private builtLayer!: Phaser.Tilemaps.TilemapLayer private builtLayer!: Phaser.Tilemaps.TilemapLayer
/** @param scene - The Phaser scene this system belongs to */
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene this.scene = scene
} }
/**
* Generates the terrain background canvas from saved tile data,
* creates the built-tile tilemap layer, and sets camera bounds.
*/
create(): void { create(): void {
const state = stateManager.getState() const state = stateManager.getState()
@@ -81,10 +86,18 @@ export class WorldSystem {
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
} }
/** Returns the built-tile tilemap layer (floor, wall, soil). */
getLayer(): Phaser.Tilemaps.TilemapLayer { getLayer(): Phaser.Tilemaps.TilemapLayer {
return this.builtLayer return this.builtLayer
} }
/**
* Places or removes a tile on the built layer.
* Built tile types are added; natural types remove the built-layer entry.
* @param tileX - Tile column
* @param tileY - Tile row
* @param type - New tile type to apply
*/
setTile(tileX: number, tileY: number, type: TileType): void { setTile(tileX: number, tileY: number, type: TileType): void {
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL]) const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
if (BUILT_TILES.has(type)) { if (BUILT_TILES.has(type)) {
@@ -95,6 +108,12 @@ export class WorldSystem {
} }
} }
/**
* Returns whether the tile at the given coordinates can be walked on.
* Out-of-bounds tiles are treated as impassable.
* @param tileX - Tile column
* @param tileY - Tile row
*/
isPassable(tileX: number, tileY: number): boolean { isPassable(tileX: number, tileY: number): boolean {
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
const state = stateManager.getState() const state = stateManager.getState()
@@ -102,6 +121,12 @@ export class WorldSystem {
return !IMPASSABLE.has(tile) return !IMPASSABLE.has(tile)
} }
/**
* Converts world pixel coordinates to tile coordinates.
* @param worldX - World X in pixels
* @param worldY - World Y in pixels
* @returns Integer tile position
*/
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } { worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
return { return {
tileX: Math.floor(worldX / TILE_SIZE), tileX: Math.floor(worldX / TILE_SIZE),
@@ -109,6 +134,12 @@ export class WorldSystem {
} }
} }
/**
* Converts tile coordinates to the world pixel center of that tile.
* @param tileX - Tile column
* @param tileY - Tile row
* @returns World pixel center position
*/
tileToWorld(tileX: number, tileY: number): { x: number; y: number } { tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
return { return {
x: tileX * TILE_SIZE + TILE_SIZE / 2, x: tileX * TILE_SIZE + TILE_SIZE / 2,
@@ -116,11 +147,17 @@ export class WorldSystem {
} }
} }
/**
* Returns the tile type at the given tile coordinates from saved state.
* @param tileX - Tile column
* @param tileY - Tile row
*/
getTileType(tileX: number, tileY: number): TileType { getTileType(tileX: number, tileY: number): TileType {
const state = stateManager.getState() const state = stateManager.getState()
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
} }
/** Destroys the tilemap and background image. */
destroy(): void { destroy(): void {
this.map.destroy() this.map.destroy()
this.bgImage.destroy() this.bgImage.destroy()