diff --git a/CHANGELOG.md b/CHANGELOG.md index e26536e..21bfe94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### 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 - 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 diff --git a/CLAUDE.md b/CLAUDE.md index e09a9e8..a2c24cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,16 @@ # 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 A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**. diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index c9a221d..fb8b074 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -9,6 +9,7 @@ import { ResourceSystem } from '../systems/ResourceSystem' import { BuildingSystem } from '../systems/BuildingSystem' import { FarmingSystem } from '../systems/FarmingSystem' import { VillagerSystem } from '../systems/VillagerSystem' +import { DebugSystem } from '../systems/DebugSystem' export class GameScene extends Phaser.Scene { private adapter!: LocalAdapter @@ -18,11 +19,16 @@ export class GameScene extends Phaser.Scene { private buildingSystem!: BuildingSystem private farmingSystem!: FarmingSystem villagerSystem!: VillagerSystem + debugSystem!: DebugSystem private autosaveTimer = 0 private menuOpen = false 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 { this.adapter = new LocalAdapter() @@ -33,6 +39,7 @@ export class GameScene extends Phaser.Scene { this.farmingSystem = new FarmingSystem(this, this.adapter) this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) this.villagerSystem.init(this.resourceSystem, this.farmingSystem) + this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) this.worldSystem.create() this.renderPersistentObjects() @@ -56,6 +63,8 @@ export class GameScene extends Phaser.Scene { this.villagerSystem.create() this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) + this.debugSystem.create() + // Sync tile changes and building visuals through adapter this.adapter.onAction = (action) => { 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.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities }) }) + this.events.on('debugToggle', () => this.debugSystem.toggle()) 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 { if (this.menuOpen) return @@ -86,6 +102,7 @@ export class GameScene extends Phaser.Scene { this.resourceSystem.update(delta) this.farmingSystem.update(delta) this.villagerSystem.update(delta) + this.debugSystem.update() this.events.emit('cameraMoved', this.cameraSystem.getCenterTile()) 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 { stateManager.save() this.worldSystem.destroy() diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index fa94dd0..9b186d9 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser' import type { BuildingType, JobPriorities } from '../types' import type { FarmingTool } from '../systems/FarmingSystem' +import type { DebugData } from '../systems/DebugSystem' import { stateManager } from '../StateManager' const ITEM_ICONS: Record = { @@ -28,9 +29,15 @@ export class UIScene extends Phaser.Scene { private contextMenuVisible = false private inBuildMode = false private inFarmMode = false + private debugPanelText!: Phaser.GameObjects.Text + private debugActive = false constructor() { super({ key: 'UI' }) } + /** + * Creates all HUD elements, wires up game scene events, and registers + * keyboard shortcuts (B, V, F3, ESC). + */ create(): void { this.createStockpilePanel() this.createHintText() @@ -39,6 +46,7 @@ export class UIScene extends Phaser.Scene { this.createBuildModeIndicator() this.createFarmToolIndicator() this.createCoordsDisplay() + this.createDebugPanel() const gameScene = this.scene.get('Game') gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b)) @@ -51,6 +59,8 @@ export class UIScene extends Phaser.Scene { .on('down', () => gameScene.events.emit('uiRequestBuildMenu')) this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V) .on('down', () => this.toggleVillagerPanel()) + this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F3) + .on('down', () => this.toggleDebugPanel()) this.scale.on('resize', () => this.repositionUI()) @@ -71,14 +81,22 @@ export class UIScene extends Phaser.Scene { .on('down', () => this.hideContextMenu()) } + /** + * 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 { this.updateStockpile() this.updateToast(delta) this.updatePopText() + if (this.debugActive) this.updateDebugPanel() } // ─── Stockpile ──────────────────────────────────────────────────────────── + /** Creates the stockpile panel in the top-right corner with item rows and population count. */ private createStockpilePanel(): void { 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) @@ -91,6 +109,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) } + /** Refreshes all item quantities and colors in the stockpile panel. */ private updateStockpile(): void { const sp = stateManager.getState().world.stockpile for (const [item, t] of this.stockpileTexts) { @@ -100,6 +119,7 @@ export class UIScene extends Phaser.Scene { } } + /** Updates the Nisse population / bed capacity counter. */ private updatePopText(): void { const state = stateManager.getState() const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length @@ -109,6 +129,7 @@ export class UIScene extends Phaser.Scene { // ─── Hint ───────────────────────────────────────────────────────────────── + /** Creates the centered hint text element near the bottom of the screen. */ private createHintText(): void { this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', { fontSize: '14px', color: '#ffff88', fontFamily: 'monospace', @@ -118,6 +139,7 @@ export class UIScene extends Phaser.Scene { // ─── Toast ──────────────────────────────────────────────────────────────── + /** Creates the toast notification text element (top center, initially hidden). */ private createToast(): void { this.toastText = this.add.text(this.scale.width / 2, 60, '', { fontSize: '15px', color: '#88ff88', fontFamily: 'monospace', @@ -125,8 +147,16 @@ export class UIScene extends Phaser.Scene { }).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 } + /** + * 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 { if (this.toastTimer <= 0) return this.toastTimer -= delta @@ -135,6 +165,7 @@ export class UIScene extends Phaser.Scene { // ─── Build Menu ─────────────────────────────────────────────────────────── + /** Creates and hides the build menu with buttons for each available building type. */ private createBuildMenu(): void { this.buildMenuGroup = this.add.group() const buildings: { kind: BuildingType; label: string; cost: string }[] = [ @@ -162,12 +193,18 @@ export class UIScene extends Phaser.Scene { this.buildMenuGroup.setVisible(false) } + /** Toggles the build menu open or closed. */ 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') } + + /** 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') } // ─── Villager Panel (V key) ─────────────────────────────────────────────── + /** Toggles the Nisse management panel open or closed. */ private toggleVillagerPanel(): void { if (this.villagerPanelVisible) { this.closeVillagerPanel() @@ -176,18 +213,24 @@ export class UIScene extends Phaser.Scene { } } + /** Opens the Nisse panel, builds its contents, and notifies GameScene. */ private openVillagerPanel(): void { this.villagerPanelVisible = true this.buildVillagerPanel() this.scene.get('Game').events.emit('uiMenuOpen') } + /** Closes and destroys the Nisse panel and notifies GameScene. */ private closeVillagerPanel(): void { this.villagerPanelVisible = false this.villagerPanelGroup?.destroy(true) 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 { if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true) this.villagerPanelGroup = this.add.group() @@ -266,9 +309,16 @@ export class UIScene extends Phaser.Scene { // ─── Build mode indicator ───────────────────────────────────────────────── + /** Creates the build-mode indicator text in the top-left corner (initially hidden). */ 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) } + + /** + * 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 { this.inBuildMode = active this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active) @@ -276,9 +326,16 @@ export class UIScene extends Phaser.Scene { // ─── Farm tool indicator ────────────────────────────────────────────────── + /** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */ 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) } + + /** + * 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 { this.inFarmMode = tool !== 'none' this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none') @@ -286,16 +343,88 @@ export class UIScene extends Phaser.Scene { // ─── Coords + controls ──────────────────────────────────────────────────── + /** Creates the tile-coordinate display and controls hint at the bottom-left. */ private createCoordsDisplay(): void { 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 } }).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 { 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 ───────────────────────────────────────────────────────── /** diff --git a/src/systems/DebugSystem.ts b/src/systems/DebugSystem.ts new file mode 100644 index 0000000..9db6e7a --- /dev/null +++ b/src/systems/DebugSystem.ts @@ -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 = { + [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, + } + } +} diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 68efa98..45f82c2 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -36,18 +36,32 @@ export class VillagerSystem { 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) { this.scene = scene this.adapter = adapter 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 { this.resourceSystem = resourceSystem this.farmingSystem = farmingSystem } + /** + * Spawns sprites for all Nisse that exist in the saved state + * and re-claims any active job targets. + */ create(): void { const state = stateManager.getState() for (const v of Object.values(state.world.villagers)) { @@ -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 { this.spawnTimer += delta if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) { @@ -72,6 +90,12 @@ export class VillagerSystem { // ─── Per-villager tick ──────────────────────────────────────────────────── + /** + * Dispatches the correct AI tick method based on the villager's current state, + * then syncs the sprite, name label, energy bar, and job icon to the state. + * @param v - Villager state from the store + * @param delta - Frame delta in milliseconds + */ private tickVillager(v: VillagerState, delta: number): void { const rt = this.runtime.get(v.id) if (!rt) return @@ -97,6 +121,14 @@ export class VillagerSystem { // ─── IDLE ───────────────────────────────────────────────────────────────── + /** + * Handles the idle AI state: hauls items to stockpile if carrying any, + * seeks a bed if energy is low, otherwise picks the next job and begins walking. + * Applies a cooldown before scanning again if no job is found. + * @param v - Villager state + * @param rt - Villager runtime (sprites, path, timers) + * @param delta - Frame delta in milliseconds + */ private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void { // Decrement scan timer if cooling down if (rt.idleScanTimer > 0) { @@ -133,6 +165,14 @@ export class VillagerSystem { // ─── WALKING ────────────────────────────────────────────────────────────── + /** + * Advances the Nisse along its path toward the current destination. + * Calls onArrived when the path is exhausted. + * Drains energy slowly while walking. + * @param v - Villager state + * @param rt - Villager runtime + * @param delta - Frame delta in milliseconds + */ private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void { if (rt.path.length === 0) { this.onArrived(v, rt) @@ -161,6 +201,12 @@ export class VillagerSystem { ;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015) } + /** + * Called when a Nisse reaches its destination tile. + * Transitions to the appropriate next AI state based on destination type. + * @param v - Villager state + * @param rt - Villager runtime + */ private onArrived(v: VillagerState, rt: VillagerRuntime): void { switch (rt.destination) { case 'job': @@ -186,6 +232,14 @@ export class VillagerSystem { // ─── WORKING ────────────────────────────────────────────────────────────── + /** + * Counts down the work timer and performs the harvest action on completion. + * Handles chop, mine, and farm job types. + * Returns the Nisse to idle when done. + * @param v - Villager state + * @param rt - Villager runtime + * @param delta - Frame delta in milliseconds + */ private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void { rt.workTimer -= delta // Wobble while working @@ -237,6 +291,12 @@ export class VillagerSystem { // ─── SLEEPING ───────────────────────────────────────────────────────────── + /** + * Restores energy while sleeping. Returns to idle once energy is full. + * @param v - Villager state + * @param rt - Villager runtime + * @param delta - Frame delta in milliseconds + */ private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void { ;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04) // Gentle bob while sleeping @@ -249,6 +309,13 @@ export class VillagerSystem { // ─── 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 { const state = stateManager.getState() const p = v.priorities @@ -289,6 +356,15 @@ export class VillagerSystem { // ─── Pathfinding ────────────────────────────────────────────────────────── + /** + * Computes a path from the Nisse's current tile to the target tile and + * begins walking. If no path is found, the job is cleared and a cooldown applied. + * @param v - Villager state + * @param rt - Villager runtime + * @param tileX - Target tile X + * @param tileY - Target tile Y + * @param dest - Semantic destination type (used by onArrived) + */ private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void { const sx = Math.floor(v.x / TILE_SIZE) const sy = Math.floor(v.y / TILE_SIZE) @@ -310,6 +386,11 @@ export class VillagerSystem { // ─── Building finders ───────────────────────────────────────────────────── + /** + * Returns the nearest building of the given kind to the Nisse, or null if none exist. + * @param v - Villager state (used as reference position) + * @param kind - Building kind to search for + */ private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null { const state = stateManager.getState() const hits = Object.values(state.world.buildings).filter(b => b.kind === kind) @@ -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] } + /** + * Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed. + * Returns null if no beds are placed. + * @param v - Villager state + */ private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null { const state = stateManager.getState() // Prefer assigned bed @@ -328,6 +414,10 @@ export class VillagerSystem { // ─── Spawning ───────────────────────────────────────────────────────────── + /** + * Attempts to spawn a new Nisse if a free bed is available and the + * current population is below the bed count. + */ private trySpawn(): void { const state = stateManager.getState() const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed') @@ -361,6 +451,11 @@ export class VillagerSystem { // ─── 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 { 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 }) } + /** + * Redraws the energy bar graphic for a Nisse at the given world position. + * Color transitions green → orange → red as energy decreases. + * @param g - Graphics object to draw into + * @param x - World X center of the Nisse + * @param y - World Y center of the Nisse + * @param energy - Current energy value (0–100) + */ private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void { const W = 20, H = 3 g.clear() @@ -385,6 +488,12 @@ export class VillagerSystem { // ─── Public API ─────────────────────────────────────────────────────────── + /** + * Returns a short human-readable status string for the given Nisse, + * suitable for display in UI panels. + * @param villagerId - The Nisse's ID + * @returns Status string, or '—' if the Nisse is not found + */ getStatusText(villagerId: string): string { const v = stateManager.getState().world.villagers[villagerId] if (!v) return '—' @@ -397,6 +506,28 @@ export class VillagerSystem { 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 { for (const rt of this.runtime.values()) { rt.sprite.destroy(); rt.nameLabel.destroy() diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts index 26ea7b7..faff74f 100644 --- a/src/systems/WorldSystem.ts +++ b/src/systems/WorldSystem.ts @@ -22,10 +22,15 @@ export class WorldSystem { private bgImage!: Phaser.GameObjects.Image private builtLayer!: Phaser.Tilemaps.TilemapLayer + /** @param scene - The Phaser scene this system belongs to */ constructor(scene: Phaser.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 { 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) } + /** Returns the built-tile tilemap layer (floor, wall, soil). */ getLayer(): Phaser.Tilemaps.TilemapLayer { 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 { const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL]) 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 { if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false const state = stateManager.getState() @@ -102,6 +121,12 @@ export class WorldSystem { 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 } { return { 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 } { return { 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 { const state = stateManager.getState() return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType } + /** Destroys the tilemap and background image. */ destroy(): void { this.map.destroy() this.bgImage.destroy()