6 Commits

Author SHA1 Message Date
49fae62f27 📝 update CHANGELOG for Issue #5 zoom-to-mouse 2026-03-21 12:01:38 +00:00
0f411f0f34 implement zoom-to-mouse in CameraSystem
Replaces plain cam.setZoom() with zoom-to-mouse: after each zoom step
the scroll is corrected by (mouseOffset from center) * (1/zBefore - 1/zAfter),
keeping the world point under the cursor fixed. Also fixes getCenterWorld()
which previously divided by zoom incorrectly. Added JSDoc to all methods.
2026-03-21 11:53:00 +00:00
fede13d64a add ZoomMouseScene with zoom-to-mouse correction
Implements scroll correction after cam.setZoom() so the world point
under the mouse stays fixed. Formula accounts for Phaser's
center-based zoom: scrollX += (mouseX - cw/2) * (1/zBefore - 1/zAfter).
Tab switches between the two test scenes in both directions.
Also fixes centerWorld formula in ZoomTestScene overlay and logs.
2026-03-21 11:49:39 +00:00
aefb67dba6 ♻️ increase test world to 500×500 tiles, adjust marker intervals 2026-03-21 11:40:11 +00:00
faa4deb0bf 🐛 fix HUD overlay zoom + add red center crosshair
Text overlay now uses a dedicated HUD camera (zoom=1, fixed scroll)
so it's never scaled by the world zoom. World objects and HUD objects
are separated via camera ignore lists. Added red screen-center
crosshair to HUD layer as a precise alignment reference.
2026-03-21 11:34:04 +00:00
9b6341fe46 add file logging via Vite middleware to ZoomTestScene
Vite dev server gets a /api/log middleware (POST appends to
game-test.log, DELETE clears it). ZoomTestScene writes a zoom event
with before/after state on every scroll, plus a full snapshot every
2 seconds. Log entries are newline-delimited JSON.
2026-03-21 11:19:54 +00:00
10 changed files with 46 additions and 686 deletions

View File

@@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"Bash(curl:)",
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
"Bash(python3 -m json.tool)",
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1/timeline\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
"Bash(curl:*)",
"Bash(python3 -c \":*)",
"Bash(git checkout:*)",
"Bash(npx tsc:*)",
"Bash(npm run:*)",
"Bash(/usr/local/bin/npm run:*)",
"Bash(/home/tekki/.nvm/versions/node/v24.14.0/bin/npm run:*)",
"Bash(export PATH=\"/home/tekki/.nvm/versions/node/v24.14.0/bin:$PATH\")",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''', d.get\\(''''message'''',''''''''\\)\\)\\)\")",
"Bash(git pull:*)",
"Bash(for id:*)",
"Bash(do echo:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\)\\)\")",
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\" BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\" __NEW_LINE_2bc8ebfb809e4939__ for id in 5 6 7 9)",
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
"Bash(BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\")",
"Bash(__NEW_LINE_5d5fe245d6f316dc__ for:*)",
"Bash(do)",
"Bash(done)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\), d.get\\(''''number'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")",
"Bash(git remote:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''login'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")"
]
}
}

2
.gitignore vendored
View File

@@ -1,4 +1,2 @@
node_modules/ node_modules/
dist/ dist/
game-test.log
.claude/

View File

@@ -7,13 +7,6 @@ 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,16 +1,5 @@
# 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**.

5
game-test.log Normal file
View File

@@ -0,0 +1,5 @@
{"t":1774091984264,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":0,"y":0},"world":{"x":0,"y":0}}}
{"t":1774091986280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
{"t":1774091988280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
{"t":1774091990281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
{"t":1774091992281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}

View File

@@ -9,7 +9,6 @@ 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
@@ -19,16 +18,11 @@ 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()
@@ -39,7 +33,6 @@ 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()
@@ -63,8 +56,6 @@ 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') {
@@ -83,17 +74,10 @@ 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
@@ -102,7 +86,6 @@ 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()
@@ -136,7 +119,6 @@ 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,7 +1,6 @@
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> = {
@@ -29,19 +28,9 @@ 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()
@@ -50,7 +39,6 @@ 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))
@@ -63,15 +51,11 @@ 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()) {
@@ -84,25 +68,17 @@ 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.handleEsc()) .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 { 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)
@@ -115,7 +91,6 @@ 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) {
@@ -125,7 +100,6 @@ 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
@@ -135,7 +109,6 @@ 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',
@@ -145,7 +118,6 @@ 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',
@@ -153,16 +125,8 @@ 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
@@ -171,7 +135,6 @@ 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 }[] = [
@@ -199,18 +162,12 @@ 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()
@@ -219,24 +176,18 @@ 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()
@@ -315,16 +266,9 @@ 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)
@@ -332,16 +276,9 @@ 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')
@@ -349,88 +286,16 @@ 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 [F3] Debug', { this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse', {
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 ─────────────────────────────────────────────────────────
/** /**
@@ -494,179 +359,6 @@ 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 ───────────────────────────────────────────────────────────────
/** /**
@@ -694,10 +386,8 @@ export class UIScene extends Phaser.Scene {
// Close centered panels — their position is calculated on open, so they // Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize // would be off-center if left open during a resize
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()
} }
} }

View File

@@ -1,164 +0,0 @@
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,32 +36,18 @@ 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)) {
@@ -71,10 +57,6 @@ 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) {
@@ -90,12 +72,6 @@ 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
@@ -121,14 +97,6 @@ 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) {
@@ -165,14 +133,6 @@ 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)
@@ -201,12 +161,6 @@ 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':
@@ -232,14 +186,6 @@ 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
@@ -291,12 +237,6 @@ 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
@@ -309,13 +249,6 @@ 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
@@ -356,15 +289,6 @@ 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)
@@ -386,11 +310,6 @@ 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)
@@ -400,11 +319,6 @@ 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
@@ -414,10 +328,6 @@ 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')
@@ -451,11 +361,6 @@ 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)
@@ -470,14 +375,6 @@ 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()
@@ -488,12 +385,6 @@ 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 '—'
@@ -506,28 +397,6 @@ 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,15 +22,10 @@ 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()
@@ -86,18 +81,10 @@ 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)) {
@@ -108,12 +95,6 @@ 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()
@@ -121,12 +102,6 @@ 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),
@@ -134,12 +109,6 @@ 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,
@@ -147,17 +116,11 @@ 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()