Compare commits
34 Commits
feature/ri
...
feature/es
| Author | SHA1 | Date | |
|---|---|---|---|
| 41097b4765 | |||
| 0c636ed5ec | |||
| 4c41dc9205 | |||
| 01e57df6a6 | |||
| 1feeff215d | |||
| 1ba38cc23e | |||
| 793ab430e4 | |||
| 6f0d8a866f | |||
| 71aee058b5 | |||
| 3fdf621966 | |||
| 7f0ef0554e | |||
| d83b97a447 | |||
| a93e8a2c5d | |||
| 7c130763b5 | |||
| 007d5b3fee | |||
| 34220818b0 | |||
| 0011bc9877 | |||
| 6fa3ae4465 | |||
| 6de4c1cbb9 | |||
| d354a26a80 | |||
| fb4abb7256 | |||
| 0e4c7c96ee | |||
| cccfd9ba73 | |||
| 216c70dbd9 | |||
| b5130169bd | |||
| f0065a0cda | |||
| fa41075c55 | |||
| 715278ae78 | |||
| 2c949cc19e | |||
| 6385872dd1 | |||
| c9c8e45b0c | |||
| 787ada7cb4 | |||
| 8ed67313a8 | |||
| 5828f40497 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
game-test.log
|
||||||
|
.claude/
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -8,7 +8,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Right-click context menu: suppresses browser default, shows Build and Folks actions in the game world
|
- **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
|
||||||
|
- 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
|
||||||
|
- Working Nisse now reset to idle on game load (like walking ones), preventing stale AI state
|
||||||
|
- Stale jobs with empty carry are now cleared after work completes, avoiding a false "haul to stockpile" loop
|
||||||
|
- UI elements (stockpile panel, controls hint) now reposition correctly after window resize
|
||||||
|
- Centered overlay panels (build menu, villager panel) close on resize so they reopen at the correct position
|
||||||
|
- Mouse world coordinates now use `ptr.worldX`/`ptr.worldY` in BuildingSystem and FarmingSystem, fixing misalignment after resize or zoom
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Scroll wheel zooms toward the mouse cursor position (zoom-to-mouse), correctly accounting for Phaser's center-based zoom model
|
||||||
|
- Middle mouse button held: pan the camera by dragging
|
||||||
|
- Test environment at `/test.html` with `ZoomTestScene` (Phaser default) and `ZoomMouseScene` (zoom-to-mouse) for camera behaviour analysis; file-logging via Vite middleware to `game-test.log`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `getCenterWorld()` in `CameraSystem` returned wrong world coordinates at zoom ≠ 1; corrected from `scrollX + width/(2·zoom)` to `scrollX + width/2`
|
||||||
|
- Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world
|
||||||
- Initial project setup: Phaser 3 + TypeScript + Vite
|
- Initial project setup: Phaser 3 + TypeScript + Vite
|
||||||
- Core scenes: `BootScene`, `GameScene`, `UIScene`
|
- Core scenes: `BootScene`, `GameScene`, `UIScene`
|
||||||
- Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`,
|
- Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`,
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -1,5 +1,16 @@
|
|||||||
# CLAUDE.md — Game Project
|
# CLAUDE.md — Game Project
|
||||||
|
|
||||||
|
## ⚠️ Important: Session Start Location
|
||||||
|
|
||||||
|
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
|
||||||
|
|
||||||
|
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
|
||||||
|
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
|
||||||
|
|
||||||
|
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ class StateManager {
|
|||||||
if (!p.world.crops) p.world.crops = {}
|
if (!p.world.crops) p.world.crops = {}
|
||||||
if (!p.world.villagers) p.world.villagers = {}
|
if (!p.world.villagers) p.world.villagers = {}
|
||||||
if (!p.world.stockpile) p.world.stockpile = {}
|
if (!p.world.stockpile) p.world.stockpile = {}
|
||||||
// Reset walking villagers to idle on load
|
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
||||||
for (const v of Object.values(p.world.villagers)) {
|
for (const v of Object.values(p.world.villagers)) {
|
||||||
if (v.aiState === 'walking') v.aiState = 'idle'
|
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
} catch (_) { return null }
|
} catch (_) { return null }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ResourceSystem } from '../systems/ResourceSystem'
|
|||||||
import { BuildingSystem } from '../systems/BuildingSystem'
|
import { BuildingSystem } from '../systems/BuildingSystem'
|
||||||
import { FarmingSystem } from '../systems/FarmingSystem'
|
import { FarmingSystem } from '../systems/FarmingSystem'
|
||||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||||
|
import { DebugSystem } from '../systems/DebugSystem'
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private adapter!: LocalAdapter
|
private adapter!: LocalAdapter
|
||||||
@@ -18,11 +19,16 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private buildingSystem!: BuildingSystem
|
private buildingSystem!: BuildingSystem
|
||||||
private farmingSystem!: FarmingSystem
|
private farmingSystem!: FarmingSystem
|
||||||
villagerSystem!: VillagerSystem
|
villagerSystem!: VillagerSystem
|
||||||
|
debugSystem!: DebugSystem
|
||||||
private autosaveTimer = 0
|
private autosaveTimer = 0
|
||||||
private menuOpen = false
|
private menuOpen = false
|
||||||
|
|
||||||
constructor() { super({ key: 'Game' }) }
|
constructor() { super({ key: 'Game' }) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises all game systems, wires up inter-system events,
|
||||||
|
* launches the UI scene overlay, and starts the autosave timer.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
this.adapter = new LocalAdapter()
|
this.adapter = new LocalAdapter()
|
||||||
|
|
||||||
@@ -33,6 +39,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
||||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||||
|
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
||||||
|
|
||||||
this.worldSystem.create()
|
this.worldSystem.create()
|
||||||
this.renderPersistentObjects()
|
this.renderPersistentObjects()
|
||||||
@@ -56,6 +63,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.villagerSystem.create()
|
this.villagerSystem.create()
|
||||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||||
|
|
||||||
|
this.debugSystem.create()
|
||||||
|
|
||||||
// Sync tile changes and building visuals through adapter
|
// Sync tile changes and building visuals through adapter
|
||||||
this.adapter.onAction = (action) => {
|
this.adapter.onAction = (action) => {
|
||||||
if (action.type === 'CHANGE_TILE') {
|
if (action.type === 'CHANGE_TILE') {
|
||||||
@@ -74,10 +83,17 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
||||||
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
||||||
})
|
})
|
||||||
|
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
||||||
|
|
||||||
this.autosaveTimer = AUTOSAVE_INTERVAL
|
this.autosaveTimer = AUTOSAVE_INTERVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main game loop: updates all systems and emits the cameraMoved event for the UI.
|
||||||
|
* Skips system updates while a menu is open.
|
||||||
|
* @param _time - Total elapsed time (unused)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
if (this.menuOpen) return
|
if (this.menuOpen) return
|
||||||
|
|
||||||
@@ -86,6 +102,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.resourceSystem.update(delta)
|
this.resourceSystem.update(delta)
|
||||||
this.farmingSystem.update(delta)
|
this.farmingSystem.update(delta)
|
||||||
this.villagerSystem.update(delta)
|
this.villagerSystem.update(delta)
|
||||||
|
this.debugSystem.update()
|
||||||
|
|
||||||
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
||||||
this.buildingSystem.update()
|
this.buildingSystem.update()
|
||||||
@@ -119,6 +136,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Saves game state and destroys all systems cleanly on scene shutdown. */
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
stateManager.save()
|
stateManager.save()
|
||||||
this.worldSystem.destroy()
|
this.worldSystem.destroy()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import type { BuildingType, JobPriorities } from '../types'
|
import type { BuildingType, JobPriorities } from '../types'
|
||||||
import type { FarmingTool } from '../systems/FarmingSystem'
|
import type { FarmingTool } from '../systems/FarmingSystem'
|
||||||
|
import type { DebugData } from '../systems/DebugSystem'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
|
||||||
const ITEM_ICONS: Record<string, string> = {
|
const ITEM_ICONS: Record<string, string> = {
|
||||||
@@ -21,14 +22,26 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private buildModeText!: Phaser.GameObjects.Text
|
private buildModeText!: Phaser.GameObjects.Text
|
||||||
private farmToolText!: Phaser.GameObjects.Text
|
private farmToolText!: Phaser.GameObjects.Text
|
||||||
private coordsText!: Phaser.GameObjects.Text
|
private coordsText!: Phaser.GameObjects.Text
|
||||||
|
private controlsHintText!: Phaser.GameObjects.Text
|
||||||
private popText!: Phaser.GameObjects.Text
|
private popText!: Phaser.GameObjects.Text
|
||||||
|
private stockpileTitleText!: Phaser.GameObjects.Text
|
||||||
private contextMenuGroup!: Phaser.GameObjects.Group
|
private contextMenuGroup!: Phaser.GameObjects.Group
|
||||||
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()
|
||||||
@@ -37,6 +50,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.createBuildModeIndicator()
|
this.createBuildModeIndicator()
|
||||||
this.createFarmToolIndicator()
|
this.createFarmToolIndicator()
|
||||||
this.createCoordsDisplay()
|
this.createCoordsDisplay()
|
||||||
|
this.createDebugPanel()
|
||||||
|
|
||||||
const gameScene = this.scene.get('Game')
|
const gameScene = this.scene.get('Game')
|
||||||
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
||||||
@@ -49,11 +63,15 @@ export class UIScene extends Phaser.Scene {
|
|||||||
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
|
||||||
.on('down', () => this.toggleVillagerPanel())
|
.on('down', () => this.toggleVillagerPanel())
|
||||||
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F3)
|
||||||
|
.on('down', () => this.toggleDebugPanel())
|
||||||
|
|
||||||
this.scale.on('resize', () => this.repositionUI())
|
this.scale.on('resize', () => this.repositionUI())
|
||||||
|
|
||||||
this.input.mouse!.disableContextMenu()
|
this.input.mouse!.disableContextMenu()
|
||||||
this.contextMenuGroup = this.add.group()
|
this.contextMenuGroup = this.add.group()
|
||||||
|
this.escMenuGroup = this.add.group()
|
||||||
|
this.confirmGroup = this.add.group()
|
||||||
|
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown()) {
|
if (ptr.rightButtonDown()) {
|
||||||
@@ -66,29 +84,38 @@ export class UIScene extends Phaser.Scene {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||||
.on('down', () => this.hideContextMenu())
|
.on('down', () => this.handleEsc())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stockpile display, toast fade timer, population count,
|
||||||
|
* and the debug panel each frame.
|
||||||
|
* @param _t - Total elapsed time (unused)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(_t: number, delta: number): void {
|
update(_t: number, delta: number): void {
|
||||||
this.updateStockpile()
|
this.updateStockpile()
|
||||||
this.updateToast(delta)
|
this.updateToast(delta)
|
||||||
this.updatePopText()
|
this.updatePopText()
|
||||||
|
if (this.debugActive) this.updateDebugPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stockpile ────────────────────────────────────────────────────────────
|
// ─── Stockpile ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
|
||||||
private createStockpilePanel(): void {
|
private createStockpilePanel(): void {
|
||||||
const x = this.scale.width - 178, y = 10
|
const x = this.scale.width - 178, y = 10
|
||||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
||||||
this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
|
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
this.stockpileTexts.set(item, t)
|
this.stockpileTexts.set(item, t)
|
||||||
})
|
})
|
||||||
this.popText = this.add.text(x + 10, y + 145, '👥 Pop: 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) {
|
||||||
@@ -98,15 +125,17 @@ 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
|
||||||
const current = Object.keys(state.world.villagers).length
|
const current = Object.keys(state.world.villagers).length
|
||||||
this.popText?.setText(`👥 Pop: ${current} / ${beds} [V] manage`)
|
this.popText?.setText(`👥 Nisse: ${current} / ${beds} [V]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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',
|
||||||
@@ -116,6 +145,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Toast ────────────────────────────────────────────────────────────────
|
// ─── Toast ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the toast notification text element (top center, initially hidden). */
|
||||||
private createToast(): void {
|
private createToast(): void {
|
||||||
this.toastText = this.add.text(this.scale.width / 2, 60, '', {
|
this.toastText = this.add.text(this.scale.width / 2, 60, '', {
|
||||||
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
|
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
|
||||||
@@ -123,8 +153,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a toast message for 2.2 seconds then fades it out.
|
||||||
|
* @param msg - Message to display
|
||||||
|
*/
|
||||||
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
|
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts down the toast timer and triggers the fade-out tween when it expires.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private updateToast(delta: number): void {
|
private updateToast(delta: number): void {
|
||||||
if (this.toastTimer <= 0) return
|
if (this.toastTimer <= 0) return
|
||||||
this.toastTimer -= delta
|
this.toastTimer -= delta
|
||||||
@@ -133,6 +171,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Build Menu ───────────────────────────────────────────────────────────
|
// ─── Build Menu ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates and hides the build menu with buttons for each available building type. */
|
||||||
private createBuildMenu(): void {
|
private createBuildMenu(): void {
|
||||||
this.buildMenuGroup = this.add.group()
|
this.buildMenuGroup = this.add.group()
|
||||||
const buildings: { kind: BuildingType; label: string; cost: string }[] = [
|
const buildings: { kind: BuildingType; label: string; cost: string }[] = [
|
||||||
@@ -160,12 +199,18 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.buildMenuGroup.setVisible(false)
|
this.buildMenuGroup.setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggles the build menu open or closed. */
|
||||||
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
|
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
|
||||||
|
|
||||||
|
/** Opens the build menu and notifies GameScene that a menu is active. */
|
||||||
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
|
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
|
||||||
|
|
||||||
|
/** Closes the build menu and notifies GameScene that no menu is active. */
|
||||||
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
|
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
|
||||||
|
|
||||||
// ─── Villager Panel (V key) ───────────────────────────────────────────────
|
// ─── Villager Panel (V key) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Toggles the Nisse management panel open or closed. */
|
||||||
private toggleVillagerPanel(): void {
|
private toggleVillagerPanel(): void {
|
||||||
if (this.villagerPanelVisible) {
|
if (this.villagerPanelVisible) {
|
||||||
this.closeVillagerPanel()
|
this.closeVillagerPanel()
|
||||||
@@ -174,18 +219,24 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the Nisse panel, builds its contents, and notifies GameScene. */
|
||||||
private openVillagerPanel(): void {
|
private openVillagerPanel(): void {
|
||||||
this.villagerPanelVisible = true
|
this.villagerPanelVisible = true
|
||||||
this.buildVillagerPanel()
|
this.buildVillagerPanel()
|
||||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the Nisse panel and notifies GameScene. */
|
||||||
private closeVillagerPanel(): void {
|
private closeVillagerPanel(): void {
|
||||||
this.villagerPanelVisible = false
|
this.villagerPanelVisible = false
|
||||||
this.villagerPanelGroup?.destroy(true)
|
this.villagerPanelGroup?.destroy(true)
|
||||||
this.scene.get('Game').events.emit('uiMenuClose')
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys and rebuilds the Nisse panel from current state.
|
||||||
|
* Shows name, status, energy bar, and job priority buttons per Nisse.
|
||||||
|
*/
|
||||||
private buildVillagerPanel(): void {
|
private buildVillagerPanel(): void {
|
||||||
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
|
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
|
||||||
this.villagerPanelGroup = this.add.group()
|
this.villagerPanelGroup = this.add.group()
|
||||||
@@ -202,13 +253,13 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.villagerPanelGroup.add(bg)
|
this.villagerPanelGroup.add(bg)
|
||||||
|
|
||||||
this.villagerPanelGroup.add(
|
this.villagerPanelGroup.add(
|
||||||
this.add.text(px + panelW/2, py + 12, '👥 VILLAGERS [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
|
this.add.text(px + panelW/2, py + 12, '👥 NISSE [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
|
||||||
.setOrigin(0.5, 0).setScrollFactor(0).setDepth(211)
|
.setOrigin(0.5, 0).setScrollFactor(0).setDepth(211)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (villagers.length === 0) {
|
if (villagers.length === 0) {
|
||||||
this.villagerPanelGroup.add(
|
this.villagerPanelGroup.add(
|
||||||
this.add.text(px + panelW/2, py + panelH/2, 'No villagers yet.\nBuild a 🛏 Bed first!', {
|
this.add.text(px + panelW/2, py + panelH/2, 'No Nisse yet.\nBuild a 🛏 Bed first!', {
|
||||||
fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center'
|
fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center'
|
||||||
}).setOrigin(0.5).setScrollFactor(0).setDepth(211)
|
}).setOrigin(0.5).setScrollFactor(0).setDepth(211)
|
||||||
)
|
)
|
||||||
@@ -264,9 +315,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Build mode indicator ─────────────────────────────────────────────────
|
// ─── Build mode indicator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the build-mode indicator text in the top-left corner (initially hidden). */
|
||||||
private createBuildModeIndicator(): void {
|
private createBuildModeIndicator(): void {
|
||||||
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the build-mode indicator based on whether build mode is active.
|
||||||
|
* @param active - Whether build mode is currently active
|
||||||
|
* @param building - The selected building type
|
||||||
|
*/
|
||||||
private onBuildModeChanged(active: boolean, building: BuildingType): void {
|
private onBuildModeChanged(active: boolean, building: BuildingType): void {
|
||||||
this.inBuildMode = active
|
this.inBuildMode = active
|
||||||
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
|
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
|
||||||
@@ -274,9 +332,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Farm tool indicator ──────────────────────────────────────────────────
|
// ─── Farm tool indicator ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */
|
||||||
private createFarmToolIndicator(): void {
|
private createFarmToolIndicator(): void {
|
||||||
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the farm-tool indicator and updates the active tool label.
|
||||||
|
* @param tool - Currently selected farm tool
|
||||||
|
* @param label - Human-readable label for the tool
|
||||||
|
*/
|
||||||
private onFarmToolChanged(tool: FarmingTool, label: string): void {
|
private onFarmToolChanged(tool: FarmingTool, label: string): void {
|
||||||
this.inFarmMode = tool !== 'none'
|
this.inFarmMode = tool !== 'none'
|
||||||
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
||||||
@@ -284,16 +349,88 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Coords + controls ────────────────────────────────────────────────────
|
// ─── Coords + controls ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
|
||||||
private createCoordsDisplay(): void {
|
private createCoordsDisplay(): void {
|
||||||
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
||||||
this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Villagers', {
|
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
|
||||||
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
||||||
}).setScrollFactor(0).setDepth(100)
|
}).setScrollFactor(0).setDepth(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the tile-coordinate display when the camera moves.
|
||||||
|
* @param pos - Tile position of the camera center
|
||||||
|
*/
|
||||||
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
|
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
|
||||||
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
|
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the debug panel text object (initially hidden). */
|
||||||
|
private createDebugPanel(): void {
|
||||||
|
this.debugPanelText = this.add.text(10, 80, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#cccccc',
|
||||||
|
backgroundColor: '#000000cc',
|
||||||
|
padding: { x: 8, y: 6 },
|
||||||
|
lineSpacing: 2,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(150).setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the debug panel and notifies GameScene to toggle the pathfinding overlay. */
|
||||||
|
private toggleDebugPanel(): void {
|
||||||
|
this.debugActive = !this.debugActive
|
||||||
|
this.debugPanelText.setVisible(this.debugActive)
|
||||||
|
this.scene.get('Game').events.emit('debugToggle')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads current debug data from DebugSystem and updates the panel text.
|
||||||
|
* Called every frame while debug mode is active.
|
||||||
|
*/
|
||||||
|
private updateDebugPanel(): void {
|
||||||
|
const gameScene = this.scene.get('Game') as any
|
||||||
|
const debugSystem = gameScene.debugSystem
|
||||||
|
if (!debugSystem?.isActive()) return
|
||||||
|
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
const data = debugSystem.getDebugData(ptr) as DebugData
|
||||||
|
|
||||||
|
const resLine = data.resourcesOnTile.length > 0
|
||||||
|
? data.resourcesOnTile.map(r => `${r.kind} (hp:${r.hp})`).join(', ')
|
||||||
|
: '—'
|
||||||
|
const bldLine = data.buildingsOnTile.length > 0 ? data.buildingsOnTile.join(', ') : '—'
|
||||||
|
const cropLine = data.cropsOnTile.length > 0
|
||||||
|
? data.cropsOnTile.map(c => `${c.kind} (${c.stage}/${c.maxStage})`).join(', ')
|
||||||
|
: '—'
|
||||||
|
const { idle, walking, working, sleeping } = data.nisseByState
|
||||||
|
const { chop, mine, farm } = data.jobsByType
|
||||||
|
|
||||||
|
this.debugPanelText.setText([
|
||||||
|
'── F3 DEBUG ──────────────────',
|
||||||
|
`FPS: ${data.fps}`,
|
||||||
|
'',
|
||||||
|
`Mouse world: ${data.mouseWorld.x.toFixed(1)}, ${data.mouseWorld.y.toFixed(1)}`,
|
||||||
|
`Mouse tile: ${data.mouseTile.tileX}, ${data.mouseTile.tileY}`,
|
||||||
|
`Tile type: ${data.tileType}`,
|
||||||
|
`Resources: ${resLine}`,
|
||||||
|
`Buildings: ${bldLine}`,
|
||||||
|
`Crops: ${cropLine}`,
|
||||||
|
'',
|
||||||
|
`Nisse: ${data.nisseTotal} total`,
|
||||||
|
` idle: ${idle} walking: ${walking} working: ${working} sleeping: ${sleeping}`,
|
||||||
|
'',
|
||||||
|
`Jobs active:`,
|
||||||
|
` chop: ${chop} mine: ${mine} farm: ${farm}`,
|
||||||
|
'',
|
||||||
|
`Paths: ${data.activePaths} (cyan lines in world)`,
|
||||||
|
'',
|
||||||
|
'[F3] close',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Context Menu ─────────────────────────────────────────────────────────
|
// ─── Context Menu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,7 +459,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
action: () => { this.hideContextMenu(); this.scene.get('Game').events.emit('uiRequestBuildMenu') },
|
action: () => { this.hideContextMenu(); this.scene.get('Game').events.emit('uiRequestBuildMenu') },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '👥 Folks',
|
label: '👥 Nisse',
|
||||||
action: () => { this.hideContextMenu(); this.toggleVillagerPanel() },
|
action: () => { this.hideContextMenu(); this.toggleVillagerPanel() },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -357,12 +494,210 @@ 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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repositions all fixed UI elements after a canvas resize.
|
||||||
|
* Open overlay panels are closed so they reopen correctly centered.
|
||||||
|
*/
|
||||||
private repositionUI(): void {
|
private repositionUI(): void {
|
||||||
const { width, height } = this.scale
|
const { width, height } = this.scale
|
||||||
|
|
||||||
|
// Stockpile panel — anchored to top-right; move all elements by the delta
|
||||||
|
const newPanelX = width - 178
|
||||||
|
const deltaX = newPanelX - this.stockpilePanel.x
|
||||||
|
if (deltaX !== 0) {
|
||||||
|
this.stockpilePanel.setX(newPanelX)
|
||||||
|
this.stockpileTitleText.setX(this.stockpileTitleText.x + deltaX)
|
||||||
|
this.stockpileTexts.forEach(t => t.setX(t.x + deltaX))
|
||||||
|
this.popText.setX(this.popText.x + deltaX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom elements
|
||||||
this.hintText.setPosition(width / 2, height - 40)
|
this.hintText.setPosition(width / 2, height - 40)
|
||||||
this.toastText.setPosition(width / 2, 60)
|
this.toastText.setPosition(width / 2, 60)
|
||||||
this.coordsText.setPosition(10, height - 24)
|
this.coordsText.setPosition(10, height - 24)
|
||||||
|
this.controlsHintText.setPosition(10, height - 42)
|
||||||
|
|
||||||
|
// Close centered panels — their position is calculated on open, so they
|
||||||
|
// would be off-center if left open during a resize
|
||||||
|
if (this.buildMenuVisible) this.closeBuildMenu()
|
||||||
|
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
if (this.contextMenuVisible) this.hideContextMenu()
|
||||||
|
if (this.escMenuVisible) this.closeEscMenu()
|
||||||
|
if (this.confirmVisible) this.hideConfirm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
// Update ghost to follow mouse (snapped to tile grid)
|
// Update ghost to follow mouse (snapped to tile grid)
|
||||||
const ptr = this.scene.input.activePointer
|
const ptr = this.scene.input.activePointer
|
||||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
const worldX = ptr.worldX
|
||||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
const worldY = ptr.worldY
|
||||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
||||||
@@ -142,8 +142,8 @@ export class BuildingSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
||||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
const worldX = ptr.worldX
|
||||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
const worldY = ptr.worldY
|
||||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,23 @@ export class CameraSystem {
|
|||||||
}
|
}
|
||||||
private saveTimer = 0
|
private saveTimer = 0
|
||||||
private readonly SAVE_TICK = 2000
|
private readonly SAVE_TICK = 2000
|
||||||
|
private middlePanActive = false
|
||||||
|
private lastPanX = 0
|
||||||
|
private lastPanY = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param adapter - Network adapter used to persist camera position
|
||||||
|
*/
|
||||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the camera: restores saved position, registers keyboard keys,
|
||||||
|
* sets up scroll-wheel zoom-to-mouse, and middle-click pan.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const cam = this.scene.cameras.main
|
const cam = this.scene.cameras.main
|
||||||
@@ -49,13 +60,56 @@ export class CameraSystem {
|
|||||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll wheel zoom
|
// Scroll wheel: zoom-to-mouse.
|
||||||
this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
|
// Phaser zooms from the screen center, so the world point under the mouse
|
||||||
const zoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
// is corrected by shifting scroll by the mouse offset from center.
|
||||||
cam.setZoom(zoom)
|
this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
|
||||||
|
const zoomBefore = cam.zoom
|
||||||
|
const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
cam.setZoom(newZoom)
|
||||||
|
|
||||||
|
const factor = 1 / zoomBefore - 1 / newZoom
|
||||||
|
cam.scrollX += (ptr.x - cam.width / 2) * factor
|
||||||
|
cam.scrollY += (ptr.y - cam.height / 2) * factor
|
||||||
|
|
||||||
|
const worldW = WORLD_TILES * 32
|
||||||
|
const worldH = WORLD_TILES * 32
|
||||||
|
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldW - cam.width / newZoom)
|
||||||
|
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldH - cam.height / newZoom)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middle-click pan: start on button down
|
||||||
|
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
|
if (ptr.middleButtonDown()) {
|
||||||
|
this.middlePanActive = true
|
||||||
|
this.lastPanX = ptr.x
|
||||||
|
this.lastPanY = ptr.y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middle-click pan: move camera while held
|
||||||
|
this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => {
|
||||||
|
if (!this.middlePanActive) return
|
||||||
|
const dx = (ptr.x - this.lastPanX) / cam.zoom
|
||||||
|
const dy = (ptr.y - this.lastPanY) / cam.zoom
|
||||||
|
cam.scrollX -= dx
|
||||||
|
cam.scrollY -= dy
|
||||||
|
this.lastPanX = ptr.x
|
||||||
|
this.lastPanY = ptr.y
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middle-click pan: stop on button release
|
||||||
|
this.scene.input.on('pointerup', (ptr: Phaser.Input.Pointer) => {
|
||||||
|
if (this.middlePanActive && !ptr.middleButtonDown()) {
|
||||||
|
this.middlePanActive = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the camera via keyboard input and periodically saves the position.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(delta: number): void {
|
update(delta: number): void {
|
||||||
const cam = this.scene.cameras.main
|
const cam = this.scene.cameras.main
|
||||||
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||||
@@ -73,7 +127,7 @@ export class CameraSystem {
|
|||||||
|
|
||||||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||||
|
|
||||||
const worldW = WORLD_TILES * 32 // TILE_SIZE hardcoded since WORLD_PX may not exist
|
const worldW = WORLD_TILES * 32
|
||||||
const worldH = WORLD_TILES * 32
|
const worldH = WORLD_TILES * 32
|
||||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom)
|
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom)
|
||||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom)
|
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom)
|
||||||
@@ -90,14 +144,24 @@ export class CameraSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the world coordinates of the visual camera center.
|
||||||
|
* Phaser zooms from the screen center, so the center world point
|
||||||
|
* is scrollX + screenWidth/2 (independent of zoom level).
|
||||||
|
* @returns World position of the screen center
|
||||||
|
*/
|
||||||
getCenterWorld(): { x: number; y: number } {
|
getCenterWorld(): { x: number; y: number } {
|
||||||
const cam = this.scene.cameras.main
|
const cam = this.scene.cameras.main
|
||||||
return {
|
return {
|
||||||
x: cam.scrollX + cam.width / (2 * cam.zoom),
|
x: cam.scrollX + cam.width / 2,
|
||||||
y: cam.scrollY + cam.height / (2 * cam.zoom),
|
y: cam.scrollY + cam.height / 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tile coordinates of the visual camera center.
|
||||||
|
* @returns Tile position (integer) of the screen center
|
||||||
|
*/
|
||||||
getCenterTile(): { tileX: number; tileY: number } {
|
getCenterTile(): { tileX: number; tileY: number } {
|
||||||
const { x, y } = this.getCenterWorld()
|
const { x, y } = this.getCenterWorld()
|
||||||
return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) }
|
return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) }
|
||||||
|
|||||||
164
src/systems/DebugSystem.ts
Normal file
164
src/systems/DebugSystem.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE } from '../config'
|
||||||
|
import { TileType } from '../types'
|
||||||
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { VillagerSystem } from './VillagerSystem'
|
||||||
|
import type { WorldSystem } from './WorldSystem'
|
||||||
|
|
||||||
|
/** All data collected each frame for the debug panel. */
|
||||||
|
export interface DebugData {
|
||||||
|
fps: number
|
||||||
|
mouseWorld: { x: number; y: number }
|
||||||
|
mouseTile: { tileX: number; tileY: number }
|
||||||
|
tileType: string
|
||||||
|
resourcesOnTile: Array<{ kind: string; hp: number }>
|
||||||
|
buildingsOnTile: string[]
|
||||||
|
cropsOnTile: Array<{ kind: string; stage: number; maxStage: number }>
|
||||||
|
nisseTotal: number
|
||||||
|
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||||
|
jobsByType: { chop: number; mine: number; farm: number }
|
||||||
|
activePaths: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable names for TileType enum values. */
|
||||||
|
const TILE_NAMES: Record<number, string> = {
|
||||||
|
[TileType.DEEP_WATER]: 'DEEP_WATER',
|
||||||
|
[TileType.SHALLOW_WATER]: 'SHALLOW_WATER',
|
||||||
|
[TileType.SAND]: 'SAND',
|
||||||
|
[TileType.GRASS]: 'GRASS',
|
||||||
|
[TileType.DARK_GRASS]: 'DARK_GRASS',
|
||||||
|
[TileType.FOREST]: 'FOREST',
|
||||||
|
[TileType.ROCK]: 'ROCK',
|
||||||
|
[TileType.FLOOR]: 'FLOOR',
|
||||||
|
[TileType.WALL]: 'WALL',
|
||||||
|
[TileType.TILLED_SOIL]: 'TILLED_SOIL',
|
||||||
|
[TileType.WATERED_SOIL]: 'WATERED_SOIL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DebugSystem {
|
||||||
|
private scene: Phaser.Scene
|
||||||
|
private villagerSystem: VillagerSystem
|
||||||
|
private worldSystem: WorldSystem
|
||||||
|
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||||
|
private active = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param villagerSystem - Used to read active paths for visualization
|
||||||
|
* @param worldSystem - Used to read tile types under the mouse
|
||||||
|
*/
|
||||||
|
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
|
||||||
|
this.scene = scene
|
||||||
|
this.villagerSystem = villagerSystem
|
||||||
|
this.worldSystem = worldSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the world-space Graphics object used for pathfinding visualization.
|
||||||
|
* Starts hidden until toggled on.
|
||||||
|
*/
|
||||||
|
create(): void {
|
||||||
|
this.pathGraphics = this.scene.add.graphics().setDepth(50)
|
||||||
|
this.pathGraphics.setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles debug mode on or off.
|
||||||
|
* Shows or hides the pathfinding overlay graphics accordingly.
|
||||||
|
*/
|
||||||
|
toggle(): void {
|
||||||
|
this.active = !this.active
|
||||||
|
this.pathGraphics.setVisible(this.active)
|
||||||
|
if (!this.active) this.pathGraphics.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether debug mode is currently active. */
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws pathfinding lines for all currently walking Nisse.
|
||||||
|
* Should be called every frame while debug mode is active.
|
||||||
|
*/
|
||||||
|
update(): void {
|
||||||
|
if (!this.active) return
|
||||||
|
this.pathGraphics.clear()
|
||||||
|
|
||||||
|
const paths = this.villagerSystem.getActivePaths()
|
||||||
|
this.pathGraphics.lineStyle(1, 0x00ffff, 0.65)
|
||||||
|
|
||||||
|
for (const entry of paths) {
|
||||||
|
if (entry.path.length === 0) continue
|
||||||
|
this.pathGraphics.beginPath()
|
||||||
|
this.pathGraphics.moveTo(entry.x, entry.y)
|
||||||
|
for (const step of entry.path) {
|
||||||
|
this.pathGraphics.lineTo(
|
||||||
|
(step.tileX + 0.5) * TILE_SIZE,
|
||||||
|
(step.tileY + 0.5) * TILE_SIZE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.pathGraphics.strokePath()
|
||||||
|
|
||||||
|
// Mark the destination tile
|
||||||
|
const last = entry.path[entry.path.length - 1]
|
||||||
|
this.pathGraphics.fillStyle(0x00ffff, 0.4)
|
||||||
|
this.pathGraphics.fillRect(
|
||||||
|
last.tileX * TILE_SIZE,
|
||||||
|
last.tileY * TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects and returns all debug data for the current frame.
|
||||||
|
* Called by UIScene to populate the debug panel.
|
||||||
|
* @param ptr - The active pointer, used to resolve world position
|
||||||
|
* @returns Snapshot of game state for display
|
||||||
|
*/
|
||||||
|
getDebugData(ptr: Phaser.Input.Pointer): DebugData {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const villagers = Object.values(state.world.villagers)
|
||||||
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
const tileType = this.worldSystem.getTileType(tileX, tileY)
|
||||||
|
|
||||||
|
const nisseByState = { idle: 0, walking: 0, working: 0, sleeping: 0 }
|
||||||
|
const jobsByType = { chop: 0, mine: 0, farm: 0 }
|
||||||
|
|
||||||
|
for (const v of villagers) {
|
||||||
|
nisseByState[v.aiState as keyof typeof nisseByState]++
|
||||||
|
if (v.job && (v.aiState === 'working' || v.aiState === 'walking')) {
|
||||||
|
jobsByType[v.job.type as keyof typeof jobsByType]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcesOnTile = Object.values(state.world.resources)
|
||||||
|
.filter(r => r.tileX === tileX && r.tileY === tileY)
|
||||||
|
.map(r => ({ kind: r.kind, hp: r.hp }))
|
||||||
|
|
||||||
|
const buildingsOnTile = Object.values(state.world.buildings)
|
||||||
|
.filter(b => b.tileX === tileX && b.tileY === tileY)
|
||||||
|
.map(b => b.kind)
|
||||||
|
|
||||||
|
const cropsOnTile = Object.values(state.world.crops)
|
||||||
|
.filter(c => c.tileX === tileX && c.tileY === tileY)
|
||||||
|
.map(c => ({ kind: c.kind, stage: c.stage, maxStage: c.maxStage }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
fps: Math.round(this.scene.game.loop.actualFps),
|
||||||
|
mouseWorld: { x: ptr.worldX, y: ptr.worldY },
|
||||||
|
mouseTile: { tileX, tileY },
|
||||||
|
tileType: TILE_NAMES[tileType] ?? `UNKNOWN(${tileType})`,
|
||||||
|
resourcesOnTile,
|
||||||
|
buildingsOnTile,
|
||||||
|
cropsOnTile,
|
||||||
|
nisseTotal: villagers.length,
|
||||||
|
nisseByState,
|
||||||
|
jobsByType,
|
||||||
|
activePaths: this.villagerSystem.getActivePaths().length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,9 +80,8 @@ export class FarmingSystem {
|
|||||||
// ─── Tool actions ─────────────────────────────────────────────────────────
|
// ─── Tool actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private useToolAt(ptr: Phaser.Input.Pointer): void {
|
private useToolAt(ptr: Phaser.Input.Pointer): void {
|
||||||
const cam = this.scene.cameras.main
|
const worldX = ptr.worldX
|
||||||
const worldX = cam.scrollX + ptr.x
|
const worldY = ptr.worldY
|
||||||
const worldY = cam.scrollY + ptr.y
|
|
||||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
||||||
|
import { TileType } from '../types'
|
||||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import { findPath } from '../utils/pathfinding'
|
import { findPath } from '../utils/pathfinding'
|
||||||
@@ -35,18 +36,32 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
onMessage?: (msg: string) => void
|
onMessage?: (msg: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param adapter - Network adapter for dispatching state actions
|
||||||
|
* @param worldSystem - Used for passability checks during pathfinding
|
||||||
|
*/
|
||||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
this.worldSystem = worldSystem
|
this.worldSystem = worldSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire in sibling systems after construction */
|
/**
|
||||||
|
* Wires in sibling systems that are not available at construction time.
|
||||||
|
* Must be called before create().
|
||||||
|
* @param resourceSystem - Used to remove harvested resource sprites
|
||||||
|
* @param farmingSystem - Used to remove harvested crop sprites
|
||||||
|
*/
|
||||||
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
||||||
this.resourceSystem = resourceSystem
|
this.resourceSystem = resourceSystem
|
||||||
this.farmingSystem = farmingSystem
|
this.farmingSystem = farmingSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns sprites for all Nisse that exist in the saved state
|
||||||
|
* and re-claims any active job targets.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
for (const v of Object.values(state.world.villagers)) {
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
@@ -56,6 +71,10 @@ export class VillagerSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the spawn timer and ticks every Nisse's AI.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(delta: number): void {
|
update(delta: number): void {
|
||||||
this.spawnTimer += delta
|
this.spawnTimer += delta
|
||||||
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
||||||
@@ -71,6 +90,12 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Per-villager tick ────────────────────────────────────────────────────
|
// ─── Per-villager tick ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the correct AI tick method based on the villager's current state,
|
||||||
|
* then syncs the sprite, name label, energy bar, and job icon to the state.
|
||||||
|
* @param v - Villager state from the store
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickVillager(v: VillagerState, delta: number): void {
|
private tickVillager(v: VillagerState, delta: number): void {
|
||||||
const rt = this.runtime.get(v.id)
|
const rt = this.runtime.get(v.id)
|
||||||
if (!rt) return
|
if (!rt) return
|
||||||
@@ -96,6 +121,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the idle AI state: hauls items to stockpile if carrying any,
|
||||||
|
* seeks a bed if energy is low, otherwise picks the next job and begins walking.
|
||||||
|
* Applies a cooldown before scanning again if no job is found.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime (sprites, path, timers)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
// Decrement scan timer if cooling down
|
// Decrement scan timer if cooling down
|
||||||
if (rt.idleScanTimer > 0) {
|
if (rt.idleScanTimer > 0) {
|
||||||
@@ -132,6 +165,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── WALKING ──────────────────────────────────────────────────────────────
|
// ─── WALKING ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the Nisse along its path toward the current destination.
|
||||||
|
* Calls onArrived when the path is exhausted.
|
||||||
|
* Drains energy slowly while walking.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
if (rt.path.length === 0) {
|
if (rt.path.length === 0) {
|
||||||
this.onArrived(v, rt)
|
this.onArrived(v, rt)
|
||||||
@@ -160,6 +201,12 @@ export class VillagerSystem {
|
|||||||
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a Nisse reaches its destination tile.
|
||||||
|
* Transitions to the appropriate next AI state based on destination type.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
*/
|
||||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||||
switch (rt.destination) {
|
switch (rt.destination) {
|
||||||
case 'job':
|
case 'job':
|
||||||
@@ -170,6 +217,7 @@ export class VillagerSystem {
|
|||||||
case 'stockpile':
|
case 'stockpile':
|
||||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||||
|
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'bed':
|
case 'bed':
|
||||||
@@ -184,6 +232,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── WORKING ──────────────────────────────────────────────────────────────
|
// ─── WORKING ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts down the work timer and performs the harvest action on completion.
|
||||||
|
* Handles chop, mine, and farm job types.
|
||||||
|
* Returns the Nisse to idle when done.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
rt.workTimer -= delta
|
rt.workTimer -= delta
|
||||||
// Wobble while working
|
// Wobble while working
|
||||||
@@ -199,13 +255,19 @@ export class VillagerSystem {
|
|||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
if (job.type === 'chop') {
|
if (job.type === 'chop') {
|
||||||
if (state.world.resources[job.targetId]) {
|
const res = state.world.resources[job.targetId]
|
||||||
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
|
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
||||||
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||||
this.resourceSystem.removeResource(job.targetId)
|
this.resourceSystem.removeResource(job.targetId)
|
||||||
}
|
}
|
||||||
} else if (job.type === 'mine') {
|
} else if (job.type === 'mine') {
|
||||||
if (state.world.resources[job.targetId]) {
|
const res = state.world.resources[job.targetId]
|
||||||
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
|
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
||||||
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
||||||
this.resourceSystem.removeResource(job.targetId)
|
this.resourceSystem.removeResource(job.targetId)
|
||||||
}
|
}
|
||||||
} else if (job.type === 'farm') {
|
} else if (job.type === 'farm') {
|
||||||
@@ -217,12 +279,24 @@ export class VillagerSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back to idle so decideAction handles depositing
|
// If the harvest produced nothing (resource already gone), clear the stale job
|
||||||
|
// so tickIdle does not try to walk to a stockpile with nothing to deposit.
|
||||||
|
if (!v.job?.carrying || !Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||||
|
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to idle — tickIdle will handle hauling to stockpile if carrying items
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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
|
||||||
@@ -235,6 +309,13 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the best available job for a Nisse based on their priority settings.
|
||||||
|
* Among jobs at the same priority level, the closest one wins.
|
||||||
|
* Returns null if no unclaimed job is available.
|
||||||
|
* @param v - Villager state (used for position and priorities)
|
||||||
|
* @returns The chosen job candidate, or null
|
||||||
|
*/
|
||||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const p = v.priorities
|
const p = v.priorities
|
||||||
@@ -275,6 +356,15 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a path from the Nisse's current tile to the target tile and
|
||||||
|
* begins walking. If no path is found, the job is cleared and a cooldown applied.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param tileX - Target tile X
|
||||||
|
* @param tileY - Target tile Y
|
||||||
|
* @param dest - Semantic destination type (used by onArrived)
|
||||||
|
*/
|
||||||
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
||||||
const sx = Math.floor(v.x / TILE_SIZE)
|
const sx = Math.floor(v.x / TILE_SIZE)
|
||||||
const sy = Math.floor(v.y / TILE_SIZE)
|
const sy = Math.floor(v.y / TILE_SIZE)
|
||||||
@@ -296,6 +386,11 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Building finders ─────────────────────────────────────────────────────
|
// ─── Building finders ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the nearest building of the given kind to the Nisse, or null if none exist.
|
||||||
|
* @param v - Villager state (used as reference position)
|
||||||
|
* @param kind - Building kind to search for
|
||||||
|
*/
|
||||||
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
||||||
@@ -305,6 +400,11 @@ export class VillagerSystem {
|
|||||||
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed.
|
||||||
|
* Returns null if no beds are placed.
|
||||||
|
* @param v - Villager state
|
||||||
|
*/
|
||||||
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
// Prefer assigned bed
|
// Prefer assigned bed
|
||||||
@@ -314,6 +414,10 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to spawn a new Nisse if a free bed is available and the
|
||||||
|
* current population is below the bed count.
|
||||||
|
*/
|
||||||
private trySpawn(): void {
|
private trySpawn(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
||||||
@@ -342,11 +446,16 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
|
this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
|
||||||
this.spawnSprite(villager)
|
this.spawnSprite(villager)
|
||||||
this.onMessage?.(`${name} has joined the settlement! 🏘`)
|
this.onMessage?.(`${name} the Nisse has arrived! 🏘`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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)
|
||||||
|
|
||||||
@@ -361,6 +470,14 @@ export class VillagerSystem {
|
|||||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the energy bar graphic for a Nisse at the given world position.
|
||||||
|
* Color transitions green → orange → red as energy decreases.
|
||||||
|
* @param g - Graphics object to draw into
|
||||||
|
* @param x - World X center of the Nisse
|
||||||
|
* @param y - World Y center of the Nisse
|
||||||
|
* @param energy - Current energy value (0–100)
|
||||||
|
*/
|
||||||
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()
|
||||||
@@ -371,6 +488,12 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a short human-readable status string for the given Nisse,
|
||||||
|
* suitable for display in UI panels.
|
||||||
|
* @param villagerId - The Nisse's ID
|
||||||
|
* @returns Status string, or '—' if the Nisse is not found
|
||||||
|
*/
|
||||||
getStatusText(villagerId: string): string {
|
getStatusText(villagerId: string): string {
|
||||||
const v = stateManager.getState().world.villagers[villagerId]
|
const v = stateManager.getState().world.villagers[villagerId]
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
@@ -383,6 +506,28 @@ export class VillagerSystem {
|
|||||||
return '💭 Idle'
|
return '💭 Idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current world position and remaining path for every Nisse
|
||||||
|
* that is currently in the 'walking' state. Used by DebugSystem for
|
||||||
|
* pathfinding visualization.
|
||||||
|
* @returns Array of path entries, one per walking Nisse
|
||||||
|
*/
|
||||||
|
getActivePaths(): Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const result: Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> = []
|
||||||
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
|
if (v.aiState !== 'walking') continue
|
||||||
|
const rt = this.runtime.get(v.id)
|
||||||
|
if (!rt) continue
|
||||||
|
result.push({ x: v.x, y: v.y, path: [...rt.path] })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
|
* Should be called when the scene shuts down.
|
||||||
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
for (const rt of this.runtime.values()) {
|
for (const rt of this.runtime.values()) {
|
||||||
rt.sprite.destroy(); rt.nameLabel.destroy()
|
rt.sprite.destroy(); rt.nameLabel.destroy()
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ export class WorldSystem {
|
|||||||
private bgImage!: Phaser.GameObjects.Image
|
private bgImage!: Phaser.GameObjects.Image
|
||||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||||
|
|
||||||
|
/** @param scene - The Phaser scene this system belongs to */
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the terrain background canvas from saved tile data,
|
||||||
|
* creates the built-tile tilemap layer, and sets camera bounds.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
@@ -81,10 +86,18 @@ export class WorldSystem {
|
|||||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||||
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
||||||
return this.builtLayer
|
return this.builtLayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places or removes a tile on the built layer.
|
||||||
|
* Built tile types are added; natural types remove the built-layer entry.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @param type - New tile type to apply
|
||||||
|
*/
|
||||||
setTile(tileX: number, tileY: number, type: TileType): void {
|
setTile(tileX: number, tileY: number, type: TileType): void {
|
||||||
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
||||||
if (BUILT_TILES.has(type)) {
|
if (BUILT_TILES.has(type)) {
|
||||||
@@ -95,6 +108,12 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the tile at the given coordinates can be walked on.
|
||||||
|
* Out-of-bounds tiles are treated as impassable.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
*/
|
||||||
isPassable(tileX: number, tileY: number): boolean {
|
isPassable(tileX: number, tileY: number): boolean {
|
||||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
@@ -102,6 +121,12 @@ export class WorldSystem {
|
|||||||
return !IMPASSABLE.has(tile)
|
return !IMPASSABLE.has(tile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts world pixel coordinates to tile coordinates.
|
||||||
|
* @param worldX - World X in pixels
|
||||||
|
* @param worldY - World Y in pixels
|
||||||
|
* @returns Integer tile position
|
||||||
|
*/
|
||||||
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
||||||
return {
|
return {
|
||||||
tileX: Math.floor(worldX / TILE_SIZE),
|
tileX: Math.floor(worldX / TILE_SIZE),
|
||||||
@@ -109,6 +134,12 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts tile coordinates to the world pixel center of that tile.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @returns World pixel center position
|
||||||
|
*/
|
||||||
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
||||||
@@ -116,11 +147,17 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tile type at the given tile coordinates from saved state.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
*/
|
||||||
getTileType(tileX: number, tileY: number): TileType {
|
getTileType(tileX: number, tileY: number): TileType {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Destroys the tilemap and background image. */
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.map.destroy()
|
this.map.destroy()
|
||||||
this.bgImage.destroy()
|
this.bgImage.destroy()
|
||||||
|
|||||||
364
src/test/ZoomMouseScene.ts
Normal file
364
src/test/ZoomMouseScene.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE } from '../config'
|
||||||
|
|
||||||
|
const GRID_TILES = 500 // world size in tiles
|
||||||
|
const MIN_ZOOM = 0.25
|
||||||
|
const MAX_ZOOM = 4.0
|
||||||
|
const ZOOM_STEP = 0.1
|
||||||
|
const MARKER_EVERY = 10 // small crosshair every N tiles
|
||||||
|
const LABEL_EVERY = 50 // coordinate label every N tiles
|
||||||
|
const CAMERA_SPEED = 400 // px/s
|
||||||
|
const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second test scene: zoom-to-mouse behavior.
|
||||||
|
* After each zoom step, scrollX/Y is corrected so the world point
|
||||||
|
* under the mouse stays at the same screen position.
|
||||||
|
*
|
||||||
|
* Formula:
|
||||||
|
* newScrollX = scrollX + (mouseX - screenW/2) * (1/zoomBefore - 1/zoomAfter)
|
||||||
|
* newScrollY = scrollY + (mouseY - screenH/2) * (1/zoomBefore - 1/zoomAfter)
|
||||||
|
*
|
||||||
|
* Controls: Scroll wheel to zoom, WASD / Arrow keys to pan, Tab to switch scene.
|
||||||
|
*/
|
||||||
|
export class ZoomMouseScene extends Phaser.Scene {
|
||||||
|
private logText!: Phaser.GameObjects.Text
|
||||||
|
private hudCamera!: Phaser.Cameras.Scene2D.Camera
|
||||||
|
private worldObjects: Phaser.GameObjects.GameObject[] = []
|
||||||
|
private hudObjects: Phaser.GameObjects.GameObject[] = []
|
||||||
|
private keys!: {
|
||||||
|
up: Phaser.Input.Keyboard.Key
|
||||||
|
down: Phaser.Input.Keyboard.Key
|
||||||
|
left: Phaser.Input.Keyboard.Key
|
||||||
|
right: Phaser.Input.Keyboard.Key
|
||||||
|
w: Phaser.Input.Keyboard.Key
|
||||||
|
s: Phaser.Input.Keyboard.Key
|
||||||
|
a: Phaser.Input.Keyboard.Key
|
||||||
|
d: Phaser.Input.Keyboard.Key
|
||||||
|
tab: Phaser.Input.Keyboard.Key
|
||||||
|
}
|
||||||
|
private snapshotTimer = 0
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'ZoomMouse' })
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
fetch('/api/log', { method: 'DELETE' })
|
||||||
|
this.writeLog('scene_start', { scene: 'ZoomMouse', tileSize: TILE_SIZE, gridTiles: GRID_TILES })
|
||||||
|
|
||||||
|
this.drawGrid()
|
||||||
|
this.setupCamera()
|
||||||
|
this.setupInput()
|
||||||
|
this.createHUD()
|
||||||
|
this.setupCameras()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the static world grid into world space.
|
||||||
|
* All objects are registered in worldObjects for HUD-camera exclusion.
|
||||||
|
*/
|
||||||
|
private drawGrid(): void {
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
const g = this.add.graphics()
|
||||||
|
this.worldObjects.push(g)
|
||||||
|
|
||||||
|
g.fillStyle(0x111318)
|
||||||
|
g.fillRect(0, 0, worldPx, worldPx)
|
||||||
|
|
||||||
|
g.lineStyle(1, 0x222233, 0.5)
|
||||||
|
for (let i = 0; i <= GRID_TILES; i++) {
|
||||||
|
const p = i * TILE_SIZE
|
||||||
|
g.lineBetween(p, 0, p, worldPx)
|
||||||
|
g.lineBetween(0, p, worldPx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let tx = 0; tx <= GRID_TILES; tx += MARKER_EVERY) {
|
||||||
|
for (let ty = 0; ty <= GRID_TILES; ty += MARKER_EVERY) {
|
||||||
|
const px = tx * TILE_SIZE
|
||||||
|
const py = ty * TILE_SIZE
|
||||||
|
const isLabel = tx % LABEL_EVERY === 0 && ty % LABEL_EVERY === 0
|
||||||
|
const color = isLabel ? 0xffff00 : 0x44aaff
|
||||||
|
const arm = isLabel ? 10 : 6
|
||||||
|
|
||||||
|
g.lineStyle(1, color, isLabel ? 1.0 : 0.7)
|
||||||
|
g.lineBetween(px - arm, py, px + arm, py)
|
||||||
|
g.lineBetween(px, py - arm, px, py + arm)
|
||||||
|
|
||||||
|
g.fillStyle(color, 1.0)
|
||||||
|
g.fillCircle(px, py, isLabel ? 2.5 : 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) {
|
||||||
|
for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) {
|
||||||
|
const label = this.add.text(
|
||||||
|
tx * TILE_SIZE + 4,
|
||||||
|
ty * TILE_SIZE + 4,
|
||||||
|
`${tx},${ty}`,
|
||||||
|
{ fontSize: '9px', color: '#aaddff', fontFamily: 'monospace' }
|
||||||
|
).setDepth(1)
|
||||||
|
this.worldObjects.push(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.lineStyle(2, 0xff8844, 1.0)
|
||||||
|
g.strokeRect(0, 0, worldPx, worldPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets camera bounds and centers the view on the world.
|
||||||
|
*/
|
||||||
|
private setupCamera(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
cam.setBounds(0, 0, worldPx, worldPx)
|
||||||
|
cam.scrollX = worldPx / 2 - cam.width / 2
|
||||||
|
cam.scrollY = worldPx / 2 - cam.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers scroll wheel zoom with mouse-anchor correction and keyboard keys.
|
||||||
|
* After cam.setZoom(), scrollX/Y is adjusted so the world point under the
|
||||||
|
* mouse stays at the same screen position.
|
||||||
|
*/
|
||||||
|
private setupInput(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const kb = this.input.keyboard!
|
||||||
|
|
||||||
|
this.keys = {
|
||||||
|
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||||
|
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||||
|
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||||
|
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||||
|
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||||
|
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||||
|
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||||
|
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||||
|
tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent Tab from switching browser focus
|
||||||
|
;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true
|
||||||
|
|
||||||
|
this.keys.tab.on('down', () => {
|
||||||
|
this.scene.start('ZoomTest')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.input.on('wheel', (
|
||||||
|
ptr: Phaser.Input.Pointer,
|
||||||
|
_objs: unknown,
|
||||||
|
_dx: number,
|
||||||
|
dy: number
|
||||||
|
) => {
|
||||||
|
const zoomBefore = cam.zoom
|
||||||
|
const scrollXBefore = cam.scrollX
|
||||||
|
const scrollYBefore = cam.scrollY
|
||||||
|
|
||||||
|
const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
cam.setZoom(newZoom)
|
||||||
|
|
||||||
|
// Correct scroll so the world point under the mouse stays fixed.
|
||||||
|
// Phaser zooms from screen center, so the offset from center determines the shift.
|
||||||
|
const cw = cam.width
|
||||||
|
const ch = cam.height
|
||||||
|
const factor = 1 / zoomBefore - 1 / newZoom
|
||||||
|
cam.scrollX += (ptr.x - cw / 2) * factor
|
||||||
|
cam.scrollY += (ptr.y - ch / 2) * factor
|
||||||
|
|
||||||
|
// Clamp to world bounds
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldPx - cw / cam.zoom)
|
||||||
|
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldPx - ch / cam.zoom)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.writeLog('zoom', {
|
||||||
|
direction: dy > 0 ? 'out' : 'in',
|
||||||
|
zoomBefore: +zoomBefore.toFixed(4),
|
||||||
|
zoomAfter: +cam.zoom.toFixed(4),
|
||||||
|
scrollX_before: +scrollXBefore.toFixed(2),
|
||||||
|
scrollY_before: +scrollYBefore.toFixed(2),
|
||||||
|
scrollX_after: +cam.scrollX.toFixed(2),
|
||||||
|
scrollY_after: +cam.scrollY.toFixed(2),
|
||||||
|
scrollX_delta: +(cam.scrollX - scrollXBefore).toFixed(2),
|
||||||
|
scrollY_delta: +(cam.scrollY - scrollYBefore).toFixed(2),
|
||||||
|
mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||||
|
mouseWorld_before: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||||
|
centerWorld_after: {
|
||||||
|
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||||
|
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||||
|
},
|
||||||
|
vpTiles_after: {
|
||||||
|
w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||||
|
h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates all HUD elements: log overlay and screen-center crosshair.
|
||||||
|
* All objects are registered in hudObjects for main-camera exclusion.
|
||||||
|
*/
|
||||||
|
private createHUD(): void {
|
||||||
|
const w = this.scale.width
|
||||||
|
const h = this.scale.height
|
||||||
|
|
||||||
|
const cross = this.add.graphics()
|
||||||
|
const arm = 16
|
||||||
|
cross.lineStyle(1, 0xff2222, 0.9)
|
||||||
|
cross.lineBetween(w / 2 - arm, h / 2, w / 2 + arm, h / 2)
|
||||||
|
cross.lineBetween(w / 2, h / 2 - arm, w / 2, h / 2 + arm)
|
||||||
|
cross.fillStyle(0xff2222, 1.0)
|
||||||
|
cross.fillCircle(w / 2, h / 2, 2)
|
||||||
|
this.hudObjects.push(cross)
|
||||||
|
|
||||||
|
this.logText = this.add.text(10, 10, '', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#e8e8e8',
|
||||||
|
backgroundColor: '#000000bb',
|
||||||
|
padding: { x: 10, y: 8 },
|
||||||
|
lineSpacing: 3,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setDepth(100)
|
||||||
|
this.hudObjects.push(this.logText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a dedicated HUD camera (zoom=1, no scroll) and separates
|
||||||
|
* world objects from HUD objects so neither camera renders both layers.
|
||||||
|
*/
|
||||||
|
private setupCameras(): void {
|
||||||
|
this.hudCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height)
|
||||||
|
this.hudCamera.setScroll(0, 0)
|
||||||
|
this.hudCamera.setZoom(1)
|
||||||
|
|
||||||
|
this.cameras.main.ignore(this.hudObjects)
|
||||||
|
this.hudCamera.ignore(this.worldObjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
this.handleKeyboard(delta)
|
||||||
|
this.updateOverlay()
|
||||||
|
|
||||||
|
this.snapshotTimer += delta
|
||||||
|
if (this.snapshotTimer >= SNAPSHOT_EVERY) {
|
||||||
|
this.snapshotTimer = 0
|
||||||
|
this.writeSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves camera with WASD / arrow keys at CAMERA_SPEED px/s (world space).
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
|
private handleKeyboard(delta: number): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (this.keys.left.isDown || this.keys.a.isDown) dx -= speed
|
||||||
|
if (this.keys.right.isDown || this.keys.d.isDown) dx += speed
|
||||||
|
if (this.keys.up.isDown || this.keys.w.isDown) dy -= speed
|
||||||
|
if (this.keys.down.isDown || this.keys.s.isDown) dy += speed
|
||||||
|
|
||||||
|
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||||
|
|
||||||
|
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldPx - cam.width / cam.zoom)
|
||||||
|
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldPx - cam.height / cam.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes and renders all diagnostic values to the HUD overlay each frame.
|
||||||
|
* centerWorld uses the corrected formula: scrollX + screenWidth/2.
|
||||||
|
*/
|
||||||
|
private updateOverlay(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
|
||||||
|
const vpWidthPx = cam.width / cam.zoom
|
||||||
|
const vpHeightPx = cam.height / cam.zoom
|
||||||
|
const vpWidthTiles = vpWidthPx / TILE_SIZE
|
||||||
|
const vpHeightTiles = vpHeightPx / TILE_SIZE
|
||||||
|
|
||||||
|
// Phaser zooms from screen center, so visual center = scrollX + screenWidth/2
|
||||||
|
const centerWorldX = cam.scrollX + cam.width / 2
|
||||||
|
const centerWorldY = cam.scrollY + cam.height / 2
|
||||||
|
const centerTileX = Math.floor(centerWorldX / TILE_SIZE)
|
||||||
|
const centerTileY = Math.floor(centerWorldY / TILE_SIZE)
|
||||||
|
|
||||||
|
const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'── ZOOM TEST [Zoom-to-Mouse] ──',
|
||||||
|
'',
|
||||||
|
`Zoom: ${cam.zoom.toFixed(4)}`,
|
||||||
|
`scrollX / scrollY: ${cam.scrollX.toFixed(2)} / ${cam.scrollY.toFixed(2)}`,
|
||||||
|
'',
|
||||||
|
`Viewport (screen): ${cam.width} × ${cam.height} px`,
|
||||||
|
`Viewport (world): ${vpWidthPx.toFixed(2)} × ${vpHeightPx.toFixed(2)} px`,
|
||||||
|
`Viewport (tiles): ${vpWidthTiles.toFixed(3)} × ${vpHeightTiles.toFixed(3)}`,
|
||||||
|
'',
|
||||||
|
`Center world: ${centerWorldX.toFixed(2)}, ${centerWorldY.toFixed(2)}`,
|
||||||
|
`Center tile: ${centerTileX}, ${centerTileY}`,
|
||||||
|
'',
|
||||||
|
`Mouse screen: ${ptr.x.toFixed(1)}, ${ptr.y.toFixed(1)}`,
|
||||||
|
`Mouse world: ${ptr.worldX.toFixed(2)}, ${ptr.worldY.toFixed(2)}`,
|
||||||
|
`Mouse tile: ${mouseTileX}, ${mouseTileY}`,
|
||||||
|
'',
|
||||||
|
`Canvas: ${this.scale.width} × ${this.scale.height} px`,
|
||||||
|
`TILE_SIZE: ${TILE_SIZE} px`,
|
||||||
|
`roundPixels: ${(this.game.renderer.config as Record<string, unknown>)['roundPixels']}`,
|
||||||
|
`Renderer: ${renderer}`,
|
||||||
|
'',
|
||||||
|
'[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Default',
|
||||||
|
]
|
||||||
|
|
||||||
|
this.logText.setText(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a periodic full-state snapshot to the log.
|
||||||
|
*/
|
||||||
|
private writeSnapshot(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
const vpW = cam.width / cam.zoom
|
||||||
|
const vpH = cam.height / cam.zoom
|
||||||
|
|
||||||
|
this.writeLog('snapshot', {
|
||||||
|
zoom: +cam.zoom.toFixed(4),
|
||||||
|
scrollX: +cam.scrollX.toFixed(2),
|
||||||
|
scrollY: +cam.scrollY.toFixed(2),
|
||||||
|
vpScreen: { w: cam.width, h: cam.height },
|
||||||
|
vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) },
|
||||||
|
vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) },
|
||||||
|
centerWorld: {
|
||||||
|
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||||
|
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||||
|
},
|
||||||
|
mouse: {
|
||||||
|
screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||||
|
world: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSTs a structured log entry to the Vite dev server middleware.
|
||||||
|
* @param event - Event type label
|
||||||
|
* @param data - Payload object to serialize as JSON
|
||||||
|
*/
|
||||||
|
private writeLog(event: string, data: Record<string, unknown>): void {
|
||||||
|
const entry = JSON.stringify({ t: Date.now(), event, ...data })
|
||||||
|
fetch('/api/log', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: entry,
|
||||||
|
}).catch(() => { /* swallow if dev server not running */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/test/ZoomTestScene.ts
Normal file
352
src/test/ZoomTestScene.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE } from '../config'
|
||||||
|
|
||||||
|
const GRID_TILES = 500 // world size in tiles
|
||||||
|
const MIN_ZOOM = 0.25
|
||||||
|
const MAX_ZOOM = 4.0
|
||||||
|
const ZOOM_STEP = 0.1
|
||||||
|
const MARKER_EVERY = 10 // small crosshair every N tiles
|
||||||
|
const LABEL_EVERY = 50 // coordinate label every N tiles
|
||||||
|
const CAMERA_SPEED = 400 // px/s
|
||||||
|
const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First test scene: observes pure Phaser default zoom behavior.
|
||||||
|
* No custom scroll compensation — cam.setZoom() only, zoom anchors to camera center.
|
||||||
|
* Logs zoom events and periodic snapshots to /api/log (written to game-test.log).
|
||||||
|
*
|
||||||
|
* Controls: Scroll wheel to zoom, WASD / Arrow keys to pan.
|
||||||
|
*/
|
||||||
|
export class ZoomTestScene extends Phaser.Scene {
|
||||||
|
private logText!: Phaser.GameObjects.Text
|
||||||
|
private hudCamera!: Phaser.Cameras.Scene2D.Camera
|
||||||
|
private worldObjects: Phaser.GameObjects.GameObject[] = []
|
||||||
|
private hudObjects: Phaser.GameObjects.GameObject[] = []
|
||||||
|
private keys!: {
|
||||||
|
up: Phaser.Input.Keyboard.Key
|
||||||
|
down: Phaser.Input.Keyboard.Key
|
||||||
|
left: Phaser.Input.Keyboard.Key
|
||||||
|
right: Phaser.Input.Keyboard.Key
|
||||||
|
w: Phaser.Input.Keyboard.Key
|
||||||
|
s: Phaser.Input.Keyboard.Key
|
||||||
|
a: Phaser.Input.Keyboard.Key
|
||||||
|
d: Phaser.Input.Keyboard.Key
|
||||||
|
tab: Phaser.Input.Keyboard.Key
|
||||||
|
}
|
||||||
|
private snapshotTimer = 0
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'ZoomTest' })
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
// Clear log file at scene start
|
||||||
|
fetch('/api/log', { method: 'DELETE' })
|
||||||
|
this.writeLog('scene_start', { tileSize: TILE_SIZE, gridTiles: GRID_TILES })
|
||||||
|
|
||||||
|
this.drawGrid()
|
||||||
|
this.setupCamera()
|
||||||
|
this.setupInput()
|
||||||
|
this.createHUD()
|
||||||
|
this.setupCameras()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the static world grid into world space.
|
||||||
|
* All objects are registered in worldObjects for HUD-camera exclusion.
|
||||||
|
*/
|
||||||
|
private drawGrid(): void {
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
const g = this.add.graphics()
|
||||||
|
this.worldObjects.push(g)
|
||||||
|
|
||||||
|
// Background fill
|
||||||
|
g.fillStyle(0x111811)
|
||||||
|
g.fillRect(0, 0, worldPx, worldPx)
|
||||||
|
|
||||||
|
// Tile grid lines
|
||||||
|
g.lineStyle(1, 0x223322, 0.5)
|
||||||
|
for (let i = 0; i <= GRID_TILES; i++) {
|
||||||
|
const p = i * TILE_SIZE
|
||||||
|
g.lineBetween(p, 0, p, worldPx)
|
||||||
|
g.lineBetween(0, p, worldPx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crosshair markers
|
||||||
|
for (let tx = 0; tx <= GRID_TILES; tx += MARKER_EVERY) {
|
||||||
|
for (let ty = 0; ty <= GRID_TILES; ty += MARKER_EVERY) {
|
||||||
|
const px = tx * TILE_SIZE
|
||||||
|
const py = ty * TILE_SIZE
|
||||||
|
const isLabel = tx % LABEL_EVERY === 0 && ty % LABEL_EVERY === 0
|
||||||
|
const color = isLabel ? 0xffff00 : 0x00ff88
|
||||||
|
const arm = isLabel ? 10 : 6
|
||||||
|
|
||||||
|
g.lineStyle(1, color, isLabel ? 1.0 : 0.7)
|
||||||
|
g.lineBetween(px - arm, py, px + arm, py)
|
||||||
|
g.lineBetween(px, py - arm, px, py + arm)
|
||||||
|
|
||||||
|
g.fillStyle(color, 1.0)
|
||||||
|
g.fillCircle(px, py, isLabel ? 2.5 : 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinate labels at LABEL_EVERY intersections
|
||||||
|
for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) {
|
||||||
|
for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) {
|
||||||
|
const label = this.add.text(
|
||||||
|
tx * TILE_SIZE + 4,
|
||||||
|
ty * TILE_SIZE + 4,
|
||||||
|
`${tx},${ty}`,
|
||||||
|
{ fontSize: '9px', color: '#ffff88', fontFamily: 'monospace' }
|
||||||
|
).setDepth(1)
|
||||||
|
this.worldObjects.push(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// World border
|
||||||
|
g.lineStyle(2, 0xff4444, 1.0)
|
||||||
|
g.strokeRect(0, 0, worldPx, worldPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets camera bounds and centers the view on the world.
|
||||||
|
*/
|
||||||
|
private setupCamera(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
cam.setBounds(0, 0, worldPx, worldPx)
|
||||||
|
cam.scrollX = worldPx / 2 - cam.width / 2
|
||||||
|
cam.scrollY = worldPx / 2 - cam.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers scroll wheel zoom and stores keyboard key references.
|
||||||
|
* Zoom uses cam.setZoom() only — pure Phaser default, anchors to camera center.
|
||||||
|
* Each zoom event is logged immediately with before/after state.
|
||||||
|
*/
|
||||||
|
private setupInput(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const kb = this.input.keyboard!
|
||||||
|
|
||||||
|
this.keys = {
|
||||||
|
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||||
|
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||||
|
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||||
|
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||||
|
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||||
|
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||||
|
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||||
|
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||||
|
tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB),
|
||||||
|
}
|
||||||
|
|
||||||
|
;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true
|
||||||
|
this.keys.tab.on('down', () => {
|
||||||
|
this.scene.start('ZoomMouse')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.input.on('wheel', (
|
||||||
|
ptr: Phaser.Input.Pointer,
|
||||||
|
_objs: unknown,
|
||||||
|
_dx: number,
|
||||||
|
dy: number
|
||||||
|
) => {
|
||||||
|
const zoomBefore = cam.zoom
|
||||||
|
const scrollXBefore = cam.scrollX
|
||||||
|
const scrollYBefore = cam.scrollY
|
||||||
|
|
||||||
|
const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
cam.setZoom(newZoom)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.writeLog('zoom', {
|
||||||
|
direction: dy > 0 ? 'out' : 'in',
|
||||||
|
zoomBefore: +zoomBefore.toFixed(4),
|
||||||
|
zoomAfter: +cam.zoom.toFixed(4),
|
||||||
|
scrollX_before: +scrollXBefore.toFixed(2),
|
||||||
|
scrollY_before: +scrollYBefore.toFixed(2),
|
||||||
|
scrollX_after: +cam.scrollX.toFixed(2),
|
||||||
|
scrollY_after: +cam.scrollY.toFixed(2),
|
||||||
|
scrollX_delta: +(cam.scrollX - scrollXBefore).toFixed(2),
|
||||||
|
scrollY_delta: +(cam.scrollY - scrollYBefore).toFixed(2),
|
||||||
|
mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||||
|
mouseWorld: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||||
|
centerWorld_after: {
|
||||||
|
x: +(cam.scrollX + (cam.width / cam.zoom) / 2).toFixed(2),
|
||||||
|
y: +(cam.scrollY + (cam.height / cam.zoom) / 2).toFixed(2),
|
||||||
|
},
|
||||||
|
vpTiles_after: {
|
||||||
|
w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||||
|
h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates all HUD elements: log overlay and screen-center crosshair.
|
||||||
|
* All objects are registered in hudObjects for main-camera exclusion.
|
||||||
|
* Uses a dedicated HUD camera (zoom=1, fixed) so elements are never scaled.
|
||||||
|
*/
|
||||||
|
private createHUD(): void {
|
||||||
|
const w = this.scale.width
|
||||||
|
const h = this.scale.height
|
||||||
|
|
||||||
|
// Screen-center crosshair (red)
|
||||||
|
const cross = this.add.graphics()
|
||||||
|
const arm = 16
|
||||||
|
cross.lineStyle(1, 0xff2222, 0.9)
|
||||||
|
cross.lineBetween(w / 2 - arm, h / 2, w / 2 + arm, h / 2)
|
||||||
|
cross.lineBetween(w / 2, h / 2 - arm, w / 2, h / 2 + arm)
|
||||||
|
cross.fillStyle(0xff2222, 1.0)
|
||||||
|
cross.fillCircle(w / 2, h / 2, 2)
|
||||||
|
this.hudObjects.push(cross)
|
||||||
|
|
||||||
|
// Log text overlay
|
||||||
|
this.logText = this.add.text(10, 10, '', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#e8e8e8',
|
||||||
|
backgroundColor: '#000000bb',
|
||||||
|
padding: { x: 10, y: 8 },
|
||||||
|
lineSpacing: 3,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setDepth(100)
|
||||||
|
this.hudObjects.push(this.logText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a dedicated HUD camera (zoom=1, no scroll) and separates
|
||||||
|
* world objects from HUD objects so neither camera renders both layers.
|
||||||
|
*/
|
||||||
|
private setupCameras(): void {
|
||||||
|
this.hudCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height)
|
||||||
|
this.hudCamera.setScroll(0, 0)
|
||||||
|
this.hudCamera.setZoom(1)
|
||||||
|
|
||||||
|
this.cameras.main.ignore(this.hudObjects)
|
||||||
|
this.hudCamera.ignore(this.worldObjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
this.handleKeyboard(delta)
|
||||||
|
this.updateOverlay()
|
||||||
|
|
||||||
|
this.snapshotTimer += delta
|
||||||
|
if (this.snapshotTimer >= SNAPSHOT_EVERY) {
|
||||||
|
this.snapshotTimer = 0
|
||||||
|
this.writeSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves camera with WASD / arrow keys at CAMERA_SPEED px/s (world space).
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
|
private handleKeyboard(delta: number): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||||
|
const worldPx = GRID_TILES * TILE_SIZE
|
||||||
|
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (this.keys.left.isDown || this.keys.a.isDown) dx -= speed
|
||||||
|
if (this.keys.right.isDown || this.keys.d.isDown) dx += speed
|
||||||
|
if (this.keys.up.isDown || this.keys.w.isDown) dy -= speed
|
||||||
|
if (this.keys.down.isDown || this.keys.s.isDown) dy += speed
|
||||||
|
|
||||||
|
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||||
|
|
||||||
|
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldPx - cam.width / cam.zoom)
|
||||||
|
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldPx - cam.height / cam.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes and renders all diagnostic values to the HUD overlay each frame.
|
||||||
|
*/
|
||||||
|
private updateOverlay(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
|
||||||
|
const vpWidthPx = cam.width / cam.zoom
|
||||||
|
const vpHeightPx = cam.height / cam.zoom
|
||||||
|
const vpWidthTiles = vpWidthPx / TILE_SIZE
|
||||||
|
const vpHeightTiles = vpHeightPx / TILE_SIZE
|
||||||
|
// Phaser zooms from screen center: visual center = scrollX + screenWidth/2
|
||||||
|
const centerWorldX = cam.scrollX + cam.width / 2
|
||||||
|
const centerWorldY = cam.scrollY + cam.height / 2
|
||||||
|
const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
const centerTileX = Math.floor(centerWorldX / TILE_SIZE)
|
||||||
|
const centerTileY = Math.floor(centerWorldY / TILE_SIZE)
|
||||||
|
const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'── ZOOM TEST [Phaser default] ──',
|
||||||
|
'',
|
||||||
|
`Zoom: ${cam.zoom.toFixed(4)}`,
|
||||||
|
`scrollX / scrollY: ${cam.scrollX.toFixed(2)} / ${cam.scrollY.toFixed(2)}`,
|
||||||
|
'',
|
||||||
|
`Viewport (screen): ${cam.width} × ${cam.height} px`,
|
||||||
|
`Viewport (world): ${vpWidthPx.toFixed(2)} × ${vpHeightPx.toFixed(2)} px`,
|
||||||
|
`Viewport (tiles): ${vpWidthTiles.toFixed(3)} × ${vpHeightTiles.toFixed(3)}`,
|
||||||
|
'',
|
||||||
|
`Center world: ${centerWorldX.toFixed(2)}, ${centerWorldY.toFixed(2)}`,
|
||||||
|
`Center tile: ${centerTileX}, ${centerTileY}`,
|
||||||
|
'',
|
||||||
|
`Mouse screen: ${ptr.x.toFixed(1)}, ${ptr.y.toFixed(1)}`,
|
||||||
|
`Mouse world: ${ptr.worldX.toFixed(2)}, ${ptr.worldY.toFixed(2)}`,
|
||||||
|
`Mouse tile: ${mouseTileX}, ${mouseTileY}`,
|
||||||
|
'',
|
||||||
|
`Canvas: ${this.scale.width} × ${this.scale.height} px`,
|
||||||
|
`TILE_SIZE: ${TILE_SIZE} px`,
|
||||||
|
`roundPixels: ${(this.game.renderer.config as Record<string, unknown>)['roundPixels']}`,
|
||||||
|
`Renderer: ${renderer}`,
|
||||||
|
'',
|
||||||
|
'[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Mouse',
|
||||||
|
]
|
||||||
|
|
||||||
|
this.logText.setText(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a periodic full-state snapshot to the log.
|
||||||
|
*/
|
||||||
|
private writeSnapshot(): void {
|
||||||
|
const cam = this.cameras.main
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
const vpW = cam.width / cam.zoom
|
||||||
|
const vpH = cam.height / cam.zoom
|
||||||
|
|
||||||
|
this.writeLog('snapshot', {
|
||||||
|
zoom: +cam.zoom.toFixed(4),
|
||||||
|
scrollX: +cam.scrollX.toFixed(2),
|
||||||
|
scrollY: +cam.scrollY.toFixed(2),
|
||||||
|
vpScreen: { w: cam.width, h: cam.height },
|
||||||
|
vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) },
|
||||||
|
vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) },
|
||||||
|
centerWorld: {
|
||||||
|
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||||
|
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||||
|
},
|
||||||
|
mouse: {
|
||||||
|
screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||||
|
world: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSTs a structured log entry to the Vite dev server middleware.
|
||||||
|
* Written to game-test.log in the project root.
|
||||||
|
* @param event - Event type label
|
||||||
|
* @param data - Payload object to serialize as JSON
|
||||||
|
*/
|
||||||
|
private writeLog(event: string, data: Record<string, unknown>): void {
|
||||||
|
const entry = JSON.stringify({ t: Date.now(), event, ...data })
|
||||||
|
fetch('/api/log', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: entry,
|
||||||
|
}).catch(() => { /* swallow if dev server not running */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/test/main.ts
Normal file
22
src/test/main.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { ZoomTestScene } from './ZoomTestScene'
|
||||||
|
import { ZoomMouseScene } from './ZoomMouseScene'
|
||||||
|
|
||||||
|
const config: Phaser.Types.Core.GameConfig = {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
backgroundColor: '#0d1a0d',
|
||||||
|
scene: [ZoomTestScene, ZoomMouseScene],
|
||||||
|
scale: {
|
||||||
|
mode: Phaser.Scale.RESIZE,
|
||||||
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||||
|
},
|
||||||
|
render: {
|
||||||
|
pixelArt: false,
|
||||||
|
antialias: true,
|
||||||
|
roundPixels: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
new Phaser.Game(config)
|
||||||
16
test.html
Normal file
16
test.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Game — Test Scenes</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #000; overflow: hidden; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||||
|
canvas { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="/src/test/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
const LOG_FILE = resolve(__dirname, 'game-test.log')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true
|
host: true,
|
||||||
},
|
},
|
||||||
build: {
|
plugins: [
|
||||||
outDir: 'dist',
|
{
|
||||||
assetsInlineLimit: 0
|
name: 'game-logger',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/log', (req, res) => {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
let body = ''
|
||||||
|
req.on('data', chunk => { body += chunk })
|
||||||
|
req.on('end', () => {
|
||||||
|
fs.appendFileSync(LOG_FILE, body + '\n', 'utf8')
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end('ok')
|
||||||
|
})
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
fs.writeFileSync(LOG_FILE, '', 'utf8')
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end('cleared')
|
||||||
|
} else {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
test: resolve(__dirname, 'test.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user