51 Commits

Author SHA1 Message Date
b024cf36fb 📝 update CHANGELOG for Issue #22 2026-03-23 12:30:09 +00:00
8197348cfc Merge pull request '🐛 Skip unreachable job targets in pickJob' (#23) from fix/unreachable-job-skip into master 2026-03-23 12:29:17 +00:00
732d9100ab 🐛 fix terrain canvas not updating after tile changes (Issue #22)
CHANGE_TILE only called worldSystem.setTile() (built-tile layer only),
never refreshTerrainTile() — so chopped trees stayed visually dark-green
(FOREST color) even though the tile type was already DARK_GRASS.

- adapter.onAction for CHANGE_TILE now also calls refreshTerrainTile()
  → all tile transitions (chop, mine, seedling maturation) update the
    canvas pixel immediately and consistently in one place
- Remove now-redundant explicit refreshTerrainTile() call in
  TreeSeedlingSystem (the adapter handler covers it)
- Tile-recovery path in GameScene (stateManager.tickTileRecovery) is
  NOT routed through the adapter, so its manual refreshTerrainTile()
  call is kept as-is

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:21:23 +00:00
f2a1811a36 ♻️ resource-based passability: FOREST/ROCK walkable without a resource (Issue #22)
Previously FOREST and ROCK tile types were always impassable, making 30 % of
forest floor and 50 % of rocky terrain permanently blocked even with no object
on them.

- Remove FOREST + ROCK from IMPASSABLE in types.ts
- Add RESOURCE_TERRAIN set (FOREST, ROCK) for tiles that need resource check
- WorldSystem: add resourceTiles Set<number> as O(1) spatial index
  - initResourceTiles() builds index from state on create()
  - addResourceTile() / removeResourceTile() keep it in sync at runtime
- isPassable() now: impassable tiles → false | RESOURCE_TERRAIN → check index | else → true
- GameScene: call addResourceTile() when SPAWN_RESOURCE fires (seedling matures)
- VillagerSystem: call removeResourceTile() after chop / mine completes

Side effect: trees fully enclosed by other trees are now reachable once an
adjacent tree is cleared; the hasAdjacentPassable() guard in pickJob still
correctly skips resources with zero passable neighbours.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:55:24 +00:00
774054db56 🐛 skip unreachable job targets in pickJob (Issue #22)
Trees/rocks fully enclosed by impassable tiles have no passable neighbour
for A* to jump from — pathfinding always returns null, causing a tight
1.5 s retry loop that fills the work log with identical entries.

- Add hasAdjacentPassable() helper: checks all 8 neighbours of a tile
- pickJob now skips chop/mine candidates with no passable neighbour
- idleScanTimer on pathfind failure raised 1500 → 4000 ms as safety net

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:43:00 +00:00
0ed3bfaea6 Merge pull request '🐛 Stockpile opacity + layout overlap + ESC menu padding' (#21) from fix/stockpile-layout-esc-margin into master 2026-03-23 11:28:28 +00:00
1d46446012 📝 update CHANGELOG for Issue #20 2026-03-23 10:48:52 +00:00
a5c37f20f6 🐛 fix stockpile opacity, popText overlap, ESC menu padding (Issue #20)
- Stockpile panel: use uiOpacity instead of hardcoded 0.72
- updateStaticPanelOpacity() replaces updateDebugPanelBackground() and also
  updates stockpilePanel.setAlpha() when opacity changes in Settings
- Stockpile panel height 187→210; popText y 167→192 (8px gap after carrot row)
- ESC menu menuH formula: 16+…+8 → 32+…+8 so last button has 16px bottom
  padding instead of 0px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 10:48:04 +00:00
174db14c7a 📝 update CHANGELOG for Issue #16 overlay opacity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:38:09 +00:00
c7ebf49bf2 overlay opacity: global setting + settings screen (Issue #16)
- Add UI_SETTINGS_KEY to config.ts for separate localStorage entry
- Add uiOpacity field (default 0.8, range 0.4–1.0, 10 % steps) to UIScene
- loadUISettings / saveUISettings persist opacity independently of game save
- Replace all hardcoded panel BG alphas with this.uiOpacity:
  build menu, villager panel, context menu, ESC menu, confirm dialog,
  nisse info panel
- Debug panel (F3) background synced via updateDebugPanelBackground()
- Replace Settings toast with real Settings overlay:
  title, opacity − / value / + buttons, Close button
- ESC key priority stack now includes settingsVisible
- repositionUI closes settings panel on window resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:36:42 +00:00
b259d966ee Merge pull request '🐛 Nisse info panel no longer pauses the game' (#18) from fix/nisse-info-panel-no-pause into master
Reviewed-on: #18
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-22 09:21:37 +00:00
9b22f708a5 🐛 fix nisse info panel no longer pauses the game
Removes uiMenuOpen/uiMenuClose calls from openNisseInfoPanel() and
closeNisseInfoPanel() — the info panel is an observational overlay and
should not interrupt the game loop. Closes #15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 06:55:11 +00:00
a0e813e86b Merge pull request 'Unified tile system (Issue #14)' (#17) from feature/tile-system into master 2026-03-21 16:27:11 +00:00
18c8ccb644 implement unified tile system (Issue #14)
- Tree seedlings: plant tree_seed on grass via farming tool; two-stage
  growth (sprout → sapling → young tree, ~1 min/stage); matures into
  a harvestable FOREST resource tile
- Tile recovery: Nisse chops start a 5-min DARK_GRASS→GRASS timer;
  terrain canvas updated live via WorldSystem.refreshTerrainTile()
- New TreeSeedlingSystem manages sprites, growth ticking, maturation
- BootScene generates seedling_0/1/2 textures procedurally
- FarmingSystem adds tree_seed to tool cycle (F key)
- Stockpile panel shows tree_seed (default: 5); panel height adjusted
- StateManager v5: treeSeedlings + tileRecovery in WorldState
- WorldSystem uses CanvasTexture for live single-pixel updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:15:21 +00:00
bbbb3e1f58 Merge pull request 'Issue #9: Nisse info panel with work log' (#13) from feature/nisse-info-panel into master 2026-03-21 14:22:56 +00:00
822ca620d9 Merge pull request 'Issue #7: ESC Menu' (#12) from feature/esc-menu into master 2026-03-21 14:22:19 +00:00
155a40f963 add Nisse info panel with work log (Issue #9)
Clicking a Nisse opens a top-left panel showing name, AI status,
energy bar, active job, job priority buttons, and a live work log
(last 10 of 20 runtime-only entries). Closes via ESC, ✕, or clicking
another Nisse. Dynamic parts (status/energy/job/log) refresh each
frame without rebuilding the full group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:21:12 +00:00
41097b4765 add ESC menu (Issue #7)
ESC key follows priority stack: confirm dialog → context menu →
build menu → villager panel → ESC menu → open ESC menu.
Menu items: Save Game, Load Game, Settings (placeholder), New Game
(with confirmation dialog).

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

Added DebugSystem to GameScene. VillagerSystem exposes
getActivePaths() for the path visualization. JSDoc added to all
previously undocumented methods in VillagerSystem, WorldSystem,
GameScene, and UIScene.
2026-03-21 12:11:54 +00:00
71aee058b5 📝 update CHANGELOG for Issue #5 zoom-to-mouse 2026-03-21 12:01:38 +00:00
3fdf621966 implement zoom-to-mouse in CameraSystem
Replaces plain cam.setZoom() with zoom-to-mouse: after each zoom step
the scroll is corrected by (mouseOffset from center) * (1/zBefore - 1/zAfter),
keeping the world point under the cursor fixed. Also fixes getCenterWorld()
which previously divided by zoom incorrectly. Added JSDoc to all methods.
2026-03-21 11:53:00 +00:00
7f0ef0554e add ZoomMouseScene with zoom-to-mouse correction
Implements scroll correction after cam.setZoom() so the world point
under the mouse stays fixed. Formula accounts for Phaser's
center-based zoom: scrollX += (mouseX - cw/2) * (1/zBefore - 1/zAfter).
Tab switches between the two test scenes in both directions.
Also fixes centerWorld formula in ZoomTestScene overlay and logs.
2026-03-21 11:49:39 +00:00
d83b97a447 ♻️ increase test world to 500×500 tiles, adjust marker intervals 2026-03-21 11:40:11 +00:00
a93e8a2c5d 🐛 fix HUD overlay zoom + add red center crosshair
Text overlay now uses a dedicated HUD camera (zoom=1, fixed scroll)
so it's never scaled by the world zoom. World objects and HUD objects
are separated via camera ignore lists. Added red screen-center
crosshair to HUD layer as a precise alignment reference.
2026-03-21 11:34:04 +00:00
7c130763b5 add file logging via Vite middleware to ZoomTestScene
Vite dev server gets a /api/log middleware (POST appends to
game-test.log, DELETE clears it). ZoomTestScene writes a zoom event
with before/after state on every scroll, plus a full snapshot every
2 seconds. Log entries are newline-delimited JSON.
2026-03-21 11:19:54 +00:00
007d5b3fee add ZoomTestScene with Phaser default zoom for analysis
Separate test environment at /test.html (own Vite entry, own Phaser
instance). ZoomTestScene renders a 50×50 tile grid with crosshair
markers and a live HUD overlay showing zoom, scroll, viewport in px
and tiles, mouse world/screen/tile coords, and renderer info.
Zoom uses plain cam.setZoom() — no mouse tracking — to observe
Phaser's default center-anchor behavior.
2026-03-21 11:16:39 +00:00
34220818b0 ♻️ revert zoom to center-only, keep middle-click pan 2026-03-20 21:09:13 +00:00
0011bc9877 🐛 fix debug cross: clear+redraw each frame at world-space center, no transforms 2026-03-20 21:06:14 +00:00
6fa3ae4465 🐛 fix debug cross: world-space position + counter-scale, tracks viewport center correctly 2026-03-20 20:57:46 +00:00
6de4c1cbb9 🐛 zoom-to-mouse: track world coords on pointermove, avoid ptr.worldX getter 2026-03-20 20:45:18 +00:00
d354a26a80 🐛 fix zoom-to-mouse: capture worldX/Y before setZoom 2026-03-20 20:39:53 +00:00
fb4abb7256 🐛 zoom-to-mouse: ptr.worldX/Y formula, debug log still active 2026-03-20 20:37:11 +00:00
0e4c7c96ee 🐛 debug: log mouse+center on zoom, draw red cross at viewport center 2026-03-20 20:34:32 +00:00
cccfd9ba73 ♻️ revert zoom to simple center zoom, remove mouse targeting 2026-03-20 20:21:49 +00:00
216c70dbd9 🐛 zoom-to-mouse: use ptr.worldX/Y + set scroll after setZoom 2026-03-20 20:15:13 +00:00
b5130169bd 🐛 fix zoom: center world point under mouse, then zoom to center 2026-03-20 19:39:15 +00:00
f0065a0cda 🐛 fix zoom-to-mouse using getWorldPoint diff instead of manual formula 2026-03-20 19:29:53 +00:00
fa41075c55 📝 update CHANGELOG for Issue #5 mouse handling 2026-03-20 19:19:53 +00:00
715278ae78 zoom to mouse pointer + middle-click pan
- Scroll wheel now zooms toward the mouse cursor instead of screen center
- Middle mouse button held: pan camera by dragging
- Both actions respect current zoom level
2026-03-20 19:19:44 +00:00
2c949cc19e Merge pull request '🐛 Fix resize, Nisse idle bug + rename Villager → Nisse' (#4) from feature/resize-fix into master
Reviewed-on: #4
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-20 18:58:42 +00:00
6385872dd1 📝 update changelog with tile-clearing fix 2026-03-20 18:54:58 +00:00
c9c8e45b0c 🐛 clear FOREST/ROCK tile after harvest so Nisse can access deeper resources 2026-03-20 17:39:25 +00:00
787ada7cb4 🐛 fix Nisse stuck idle after stockpile deposit; rename Villager → Nisse in UI 2026-03-20 17:07:34 +00:00
8ed67313a8 🐛 fix UI repositioning and mouse coords after window resize 2026-03-20 12:19:57 +00:00
5828f40497 Merge pull request ' Right-click context menu' (#2) from feature/right-click-context-menu into master
Reviewed-on: #2
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-20 12:01:54 +00:00
22 changed files with 2495 additions and 74 deletions

2
.gitignore vendored
View File

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

View File

@@ -7,8 +7,55 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
### Added ### Added
- Right-click context menu: suppresses browser default, shows Build and Folks actions in the game world - **Overlay Opacity Setting** (Issue #16): all UI overlay backgrounds (build menu, villager panel, context menu, ESC menu, confirm dialog, Nisse info panel, debug panel) now use a central `uiOpacity` value instead of hardcoded alphas
- **Settings Screen**: ESC menu → Settings now opens a real overlay with an overlay-opacity row ( / value% / + step buttons, range 40 %100 %, default 80 %); setting persisted in `localStorage` under `tg_ui_settings`, separate from game save so New Game does not wipe it
### Added
- **Unified Tile System** (Issue #14):
- Tree seedlings: player plants `tree_seed` on grass/dark-grass via the F-key farming tool; seedling grows through two stages (sprout → sapling → young tree, ~1 min each); on maturity it becomes a FOREST tile with a harvestable tree resource
- Tile recovery: when a Nisse chops a tree, the resulting DARK_GRASS tile starts a 5-minute recovery timer and reverts to GRASS automatically, with the terrain canvas updated in real time
- Three new procedural seedling textures (`seedling_0/1/2`) generated in BootScene
- `tree_seed` added to stockpile display (default 5 at game start) and to the farming tool cycle
- `WorldSystem.refreshTerrainTile()` updates the terrain canvas for a single tile without regenerating the full background
- New `TreeSeedlingSystem` manages seedling sprites, growth ticking, and maturation
### Added
- **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse
- Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile
- **ESC Menu** (Issue #7): pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save
- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → Nisse info panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu
### Added
- **F3 Debug View**: toggleable overlay showing FPS, tile type and contents under the cursor, Nisse count by AI state, active jobs by type, and pathfinding visualization (cyan lines in world space)
### 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`,

View File

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

View File

@@ -1,5 +1,6 @@
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config' import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types' import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
import { TileType } from './types'
const DEFAULT_PLAYER: PlayerState = { const DEFAULT_PLAYER: PlayerState = {
id: 'player1', id: 'player1',
@@ -15,13 +16,15 @@ function makeEmptyWorld(seed: number): WorldState {
buildings: {}, buildings: {},
crops: {}, crops: {},
villagers: {}, villagers: {},
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 }, stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
treeSeedlings: {},
tileRecovery: {},
} }
} }
function makeDefaultState(): GameStateData { function makeDefaultState(): GameStateData {
return { return {
version: 4, version: 5,
world: makeEmptyWorld(Math.floor(Math.random() * 999999)), world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
} }
@@ -146,6 +149,26 @@ class StateManager {
if (v) v.priorities = { ...action.priorities } if (v) v.priorities = { ...action.priorities }
break break
} }
case 'PLANT_TREE_SEED': {
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
// Cancel any tile recovery on this tile
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
break
}
case 'REMOVE_TREE_SEEDLING':
delete w.treeSeedlings[action.seedlingId]
break
case 'SPAWN_RESOURCE':
w.resources[action.resource.id] = { ...action.resource }
break
case 'TILE_RECOVERY_START':
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
break
} }
} }
@@ -163,6 +186,47 @@ class StateManager {
return advanced return advanced
} }
/**
* Advances all tree-seedling growth timers.
* Returns IDs of seedlings that have reached stage 2 (ready to mature into a tree).
* @param delta - Frame delta in milliseconds
* @returns Array of seedling IDs that are now mature
*/
tickSeedlings(delta: number): string[] {
const advanced: string[] = []
for (const s of Object.values(this.state.world.treeSeedlings)) {
s.stageTimerMs -= delta
if (s.stageTimerMs <= 0) {
s.stage = Math.min(s.stage + 1, 2)
s.stageTimerMs = TREE_SEEDLING_STAGE_MS
advanced.push(s.id)
}
}
return advanced
}
/**
* Ticks tile-recovery timers.
* Returns keys ("tileX,tileY") of tiles that have now recovered back to GRASS.
* @param delta - Frame delta in milliseconds
* @returns Array of recovered tile keys
*/
tickTileRecovery(delta: number): string[] {
const recovered: string[] = []
const rec = this.state.world.tileRecovery
for (const key of Object.keys(rec)) {
rec[key] -= delta
if (rec[key] <= 0) {
delete rec[key]
recovered.push(key)
// Update tiles array directly (DARK_GRASS → GRASS)
const [tx, ty] = key.split(',').map(Number)
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
}
}
return recovered
}
save(): void { save(): void {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
} }
@@ -172,13 +236,15 @@ class StateManager {
const raw = localStorage.getItem(SAVE_KEY) const raw = localStorage.getItem(SAVE_KEY)
if (!raw) return null if (!raw) return null
const p = JSON.parse(raw) as GameStateData const p = JSON.parse(raw) as GameStateData
if (p.version !== 4) return null if (p.version !== 5) return null
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 if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
if (!p.world.tileRecovery) p.world.tileRecovery = {}
// 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 }

View File

@@ -46,5 +46,14 @@ export const VILLAGER_NAMES = [
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex', 'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
] ]
export const SAVE_KEY = 'tg_save_v4' export const SAVE_KEY = 'tg_save_v5'
export const AUTOSAVE_INTERVAL = 30_000 export const AUTOSAVE_INTERVAL = 30_000
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
export const UI_SETTINGS_KEY = 'tg_ui_settings'
/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */
export const TILE_RECOVERY_MS = 300_000 // 5 minutes

View File

@@ -20,6 +20,7 @@ export class BootScene extends Phaser.Scene {
this.buildResourceTextures() this.buildResourceTextures()
this.buildPlayerTexture() this.buildPlayerTexture()
this.buildCropTextures() this.buildCropTextures()
this.buildSeedlingTextures()
this.buildUITextures() this.buildUITextures()
this.buildVillagerAndBuildingTextures() this.buildVillagerAndBuildingTextures()
this.generateWorldIfNeeded() this.generateWorldIfNeeded()
@@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene {
g3.generateTexture('crop_carrot_3', W, H); g3.destroy() g3.generateTexture('crop_carrot_3', W, H); g3.destroy()
} }
// ─── Tree seedling textures (3 growth stages) ────────────────────────────
/**
* Generates textures for the three tree-seedling growth stages:
* seedling_0 small sprout
* seedling_1 sapling with leaves
* seedling_2 young tree (about to mature into a FOREST tile)
*/
private buildSeedlingTextures(): void {
// Stage 0: tiny sprout
const g0 = this.add.graphics()
g0.fillStyle(0x6D4C41); g0.fillRect(10, 20, 4, 10)
g0.fillStyle(0x66BB6A); g0.fillEllipse(12, 16, 12, 8)
g0.fillStyle(0x4CAF50); g0.fillEllipse(12, 13, 8, 6)
g0.generateTexture('seedling_0', 24, 32); g0.destroy()
// Stage 1: sapling
const g1 = this.add.graphics()
g1.fillStyle(0x6D4C41); g1.fillRect(9, 15, 5, 16)
g1.fillStyle(0x4CAF50); g1.fillCircle(12, 12, 8)
g1.fillStyle(0x66BB6A, 0.7); g1.fillCircle(7, 16, 5); g1.fillCircle(17, 16, 5)
g1.fillStyle(0x81C784); g1.fillCircle(12, 8, 5)
g1.generateTexture('seedling_1', 24, 32); g1.destroy()
// Stage 2: young tree (mature, ready to become a resource)
const g2 = this.add.graphics()
g2.fillStyle(0x000000, 0.15); g2.fillEllipse(12, 28, 16, 6)
g2.fillStyle(0x6D4C41); g2.fillRect(9, 14, 6, 14)
g2.fillStyle(0x2E7D32); g2.fillCircle(12, 9, 10)
g2.fillStyle(0x388E3C); g2.fillCircle(7, 13, 7); g2.fillCircle(17, 13, 7)
g2.fillStyle(0x43A047); g2.fillCircle(12, 6, 7)
g2.generateTexture('seedling_2', 24, 32); g2.destroy()
}
// ─── UI panel texture ───────────────────────────────────────────────────── // ─── UI panel texture ─────────────────────────────────────────────────────
private buildUITextures(): void { private buildUITextures(): void {

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config' import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
import { TileType } from '../types'
import type { BuildingType } from '../types' import type { BuildingType } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import { LocalAdapter } from '../NetworkAdapter' import { LocalAdapter } from '../NetworkAdapter'
@@ -9,6 +10,8 @@ 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'
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
export class GameScene extends Phaser.Scene { export class GameScene extends Phaser.Scene {
private adapter!: LocalAdapter private adapter!: LocalAdapter
@@ -18,11 +21,17 @@ 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 treeSeedlingSystem!: TreeSeedlingSystem
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 +42,8 @@ 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.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
this.worldSystem.create() this.worldSystem.create()
this.renderPersistentObjects() this.renderPersistentObjects()
@@ -52,14 +63,25 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem.create() this.farmingSystem.create()
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg) this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) =>
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
this.treeSeedlingSystem.create()
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.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
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') {
this.worldSystem.setTile(action.tileX, action.tileY, action.tile) this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
} else if (action.type === 'SPAWN_RESOURCE') {
this.resourceSystem.spawnResourcePublic(action.resource)
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
} }
} }
@@ -74,10 +96,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
@@ -85,7 +114,16 @@ export class GameScene extends Phaser.Scene {
this.resourceSystem.update(delta) this.resourceSystem.update(delta)
this.farmingSystem.update(delta) this.farmingSystem.update(delta)
this.treeSeedlingSystem.update(delta)
this.villagerSystem.update(delta) this.villagerSystem.update(delta)
this.debugSystem.update()
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
const recovered = stateManager.tickTileRecovery(delta)
for (const key of recovered) {
const [tx, ty] = key.split(',').map(Number)
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
}
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile()) this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
this.buildingSystem.update() this.buildingSystem.update()
@@ -119,12 +157,14 @@ 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()
this.resourceSystem.destroy() this.resourceSystem.destroy()
this.buildingSystem.destroy() this.buildingSystem.destroy()
this.farmingSystem.destroy() this.farmingSystem.destroy()
this.treeSeedlingSystem.destroy()
this.villagerSystem.destroy() this.villagerSystem.destroy()
} }
} }

View File

@@ -1,11 +1,13 @@
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'
import { UI_SETTINGS_KEY } from '../config'
const ITEM_ICONS: Record<string, string> = { const ITEM_ICONS: Record<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕', wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
wheat: '🌾', carrot: '🧡', wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
} }
export class UIScene extends Phaser.Scene { export class UIScene extends Phaser.Scene {
@@ -21,15 +23,43 @@ 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
private nisseInfoGroup!: Phaser.GameObjects.Group
private nisseInfoVisible = false
private nisseInfoId: string | null = null
private nisseInfoDynamic: {
statusText: Phaser.GameObjects.Text
energyBar: Phaser.GameObjects.Graphics
energyPct: Phaser.GameObjects.Text
jobText: Phaser.GameObjects.Text
logTexts: Phaser.GameObjects.Text[]
} | null = null
/** Current overlay background opacity (0.41.0, default 0.8). Persisted in localStorage. */
private uiOpacity = 0.8
private settingsGroup!: Phaser.GameObjects.Group
private settingsVisible = 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.loadUISettings()
this.createStockpilePanel() this.createStockpilePanel()
this.createHintText() this.createHintText()
this.createToast() this.createToast()
@@ -37,6 +67,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 +80,19 @@ 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())
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
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.nisseInfoGroup = this.add.group()
this.settingsGroup = 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 +105,41 @@ 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()
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
} }
// ─── 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) // 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const 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','tree_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) // last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
this.popText = this.add.text(x + 10, y + 192, '👥 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 +149,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 +169,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 +177,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 +195,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 }[] = [
@@ -143,7 +206,7 @@ export class UIScene extends Phaser.Scene {
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' }, { kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
] ]
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140 const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200) const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
this.buildMenuGroup.add(bg) this.buildMenuGroup.add(bg)
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201)) this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
@@ -160,12 +223,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 +243,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()
@@ -198,17 +273,17 @@ export class UIScene extends Phaser.Scene {
const px = this.scale.width / 2 - panelW / 2 const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2 const py = this.scale.height / 2 - panelH / 2
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210) const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
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 +339,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 +356,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 +373,89 @@ 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 {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 80, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: `#000000${hexAlpha}`,
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 ─────────────────────────────────────────────────────────
/** /**
@@ -312,7 +474,7 @@ export class UIScene extends Phaser.Scene {
const mx = Math.min(x, this.scale.width - menuW - 4) const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4) const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88) const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300) .setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg) this.contextMenuGroup.add(bg)
@@ -322,7 +484,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 +519,528 @@ 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.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
if (this.settingsVisible) { this.closeSettings(); 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() },
]
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
const menuH = 32 + 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, this.uiOpacity)
.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 the Settings overlay. */
private doSettings(): void {
this.closeEscMenu()
this.openSettings()
}
// ─── Settings overlay ─────────────────────────────────────────────────────
/** Opens the settings overlay if it is not already open. */
private openSettings(): void {
if (this.settingsVisible) return
this.settingsVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildSettings()
}
/** Closes and destroys the settings overlay. */
private closeSettings(): void {
if (!this.settingsVisible) return
this.settingsVisible = false
this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
* Builds the settings overlay with an overlay-opacity row (step buttons).
* Destroying and recreating this method is used to refresh the displayed value.
*/
private buildSettings(): void {
if (this.settingsGroup) this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
const panelW = 280
const panelH = 130
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
// Background
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
this.settingsGroup.add(bg)
// Title
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
)
// Opacity label
this.settingsGroup.add(
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
)
// Minus button
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
minusBtn.on('pointerdown', () => {
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(minusBtn)
this.settingsGroup.add(
this.add.text(px + 183, py + 58, '', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Value display
this.settingsGroup.add(
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
)
// Plus button
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
plusBtn.on('pointerdown', () => {
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(plusBtn)
this.settingsGroup.add(
this.add.text(px + 255, py + 58, '+', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Close button
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
closeBtnRect.on('pointerdown', () => this.closeSettings())
this.settingsGroup.add(closeBtnRect)
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 106, 'Close', {
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
}
/**
* Loads UI settings from localStorage and applies the stored opacity value.
* Falls back to the default (0.8) if no setting is found.
*/
private loadUISettings(): void {
try {
const raw = localStorage.getItem(UI_SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { opacity?: number }
if (typeof parsed.opacity === 'number') {
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
}
}
} catch (_) {}
}
/**
* Persists the current UI settings (opacity) to localStorage.
* Stored separately from the game save so New Game does not wipe it.
*/
private saveUISettings(): void {
try {
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
} catch (_) {}
}
/**
* Applies the current uiOpacity to all static UI elements that are not
* rebuilt on open (stockpile panel, debug panel background).
* Called whenever uiOpacity changes.
*/
private updateStaticPanelOpacity(): void {
this.stockpilePanel.setAlpha(this.uiOpacity)
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
}
/** 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, this.uiOpacity)
.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')
}
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
/**
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
* If another Nisse's panel is already open, it is replaced.
* @param villagerId - ID of the Nisse to display
*/
private openNisseInfoPanel(villagerId: string): void {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.buildNisseInfoPanel()
}
/** Closes and destroys the Nisse info panel. */
private closeNisseInfoPanel(): void {
if (!this.nisseInfoVisible) return
this.nisseInfoVisible = false
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
}
/**
* Builds the static skeleton of the Nisse info panel (background, name, close
* button, labels, priority buttons) and stores references to the dynamic parts
* (status text, energy bar, job text, work log texts).
*/
private buildNisseInfoPanel(): void {
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.nisseInfoDynamic = null
const id = this.nisseInfoId
if (!id) return
const state = stateManager.getState()
const v = state.world.villagers[id]
if (!v) { this.closeNisseInfoPanel(); return }
const LOG_ROWS = 10
const panelW = 280
const panelH = 120 + LOG_ROWS * 14 + 16
const px = 10, py = 10
// Background
this.nisseInfoGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Name
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 10, v.name, {
fontSize: '14px', color: '#ffffff', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeNisseInfoPanel())
this.nisseInfoGroup.add(closeBtn)
// Dynamic: status text
const statusTxt = this.add.text(px + 10, py + 28, '', {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(statusTxt)
// Dynamic: energy bar + pct
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyBar)
const energyPct = this.add.text(px + 136, py + 46, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyPct)
// Dynamic: job text
const jobTxt = this.add.text(px + 10, py + 60, '', {
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(jobTxt)
// Static: priority label + buttons
const jobKeys: Array<{ key: string; icon: string }> = [
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' },
]
jobKeys.forEach((j, i) => {
const pri = v.priorities[j.key as keyof typeof v.priorities]
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
const bx = px + 10 + i * 88
const btn = this.add.text(bx, py + 78, label, {
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
padding: { x: 5, y: 3 },
}).setScrollFactor(0).setDepth(252).setInteractive()
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' }))
btn.on('pointerdown', () => {
const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5
const newPriorities = { ...v.priorities, [j.key]: newPri }
this.scene.get('Game').events.emit('updatePriorities', id, newPriorities)
// Rebuild panel so priority buttons reflect the new values immediately
this.buildNisseInfoPanel()
})
this.nisseInfoGroup.add(btn)
})
// Static: work log header
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 98, '── Work Log ──────────────────', {
fontSize: '10px', color: '#555555', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Dynamic: log text rows (pre-allocated)
const logTexts: Phaser.GameObjects.Text[] = []
for (let i = 0; i < LOG_ROWS; i++) {
const t = this.add.text(px + 10, py + 112 + i * 14, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(t)
logTexts.push(t)
}
this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts }
this.refreshNisseInfoPanel()
}
/**
* Updates only the dynamic parts of the Nisse info panel (status, energy,
* job, work log) without destroying and recreating the full group.
* Called every frame while the panel is visible.
*/
private refreshNisseInfoPanel(): void {
const dyn = this.nisseInfoDynamic
if (!dyn || !this.nisseInfoId) return
const state = stateManager.getState()
const v = state.world.villagers[this.nisseInfoId]
if (!v) { this.closeNisseInfoPanel(); return }
const gameScene = this.scene.get('Game') as any
const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[]
const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string
dyn.statusText.setText(statusStr)
// Energy bar
const px = 10, py = 10
dyn.energyBar.clear()
dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7)
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7)
dyn.energyPct.setText(`${Math.round(v.energy)}%`)
// Job
dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`)
// Work log rows
dyn.logTexts.forEach((t, i) => {
t.setText(workLog[i] ?? '')
})
}
// ─── Resize ─────────────────────────────────────────────────────────────── // ─── 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
this.hintText.setPosition(width/2, height - 40)
this.toastText.setPosition(width/2, 60) // 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.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.settingsVisible) this.closeSettings()
if (this.confirmVisible) this.hideConfirm()
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
} }
} }

View File

@@ -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)

View File

@@ -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
View File

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

View File

@@ -5,15 +5,16 @@ import type { CropKind, CropState, ItemId } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter' import type { LocalAdapter } from '../NetworkAdapter'
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water' export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water'
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water'] const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water']
const TOOL_LABELS: Record<FarmingTool, string> = { const TOOL_LABELS: Record<FarmingTool, string> = {
none: '— None', none: '— None',
hoe: '⛏ Hoe (till grass)', hoe: '⛏ Hoe (till grass)',
wheat_seed: '🌾 Wheat Seeds', wheat_seed: '🌾 Wheat Seeds',
carrot_seed: '🥕 Carrot Seeds', carrot_seed: '🥕 Carrot Seeds',
tree_seed: '🌲 Tree Seeds (plant on grass)',
water: '💧 Watering Can', water: '💧 Watering Can',
} }
@@ -30,6 +31,14 @@ export class FarmingSystem {
onToolChange?: (tool: FarmingTool, label: string) => void onToolChange?: (tool: FarmingTool, label: string) => void
/** Emitted for toast notifications */ /** Emitted for toast notifications */
onMessage?: (msg: string) => void onMessage?: (msg: string) => void
/**
* Called when the player uses the tree_seed tool on a tile.
* @param tileX - Target tile column
* @param tileY - Target tile row
* @param underlyingTile - The tile type at that position
* @returns true if planting succeeded, false if validation failed
*/
onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean
constructor(scene: Phaser.Scene, adapter: LocalAdapter) { constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
this.scene = scene this.scene = scene
@@ -80,9 +89,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()
@@ -90,9 +98,27 @@ export class FarmingSystem {
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile) if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile) else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile)
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind) else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
} }
/**
* Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem).
* Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure.
* @param tileX - Target tile column
* @param tileY - Target tile row
* @param tile - Current tile type at that position
*/
private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void {
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
this.onMessage?.('Plant tree seeds on grass!')
return
}
const ok = this.onPlantTreeSeed?.(tileX, tileY, tile)
if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!')
else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)')
}
private tillSoil(tileX: number, tileY: number, tile: TileType): void { private tillSoil(tileX: number, tileY: number, tile: TileType): void {
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) { if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
this.onMessage?.('Hoe only works on grass!') this.onMessage?.('Hoe only works on grass!')

View File

@@ -76,6 +76,16 @@ export class ResourceSystem {
this.removeSprite(id) this.removeSprite(id)
} }
/**
* Spawns a sprite for a resource that was created at runtime
* (e.g. a tree grown from a seedling). The resource must already be
* present in the game state when this is called.
* @param node - The resource node to render
*/
public spawnResourcePublic(node: ResourceNodeState): void {
this.spawnSprite(node)
}
/** Called when WorldSystem changes a tile (e.g. after tree removed) */ /** Called when WorldSystem changes a tile (e.g. after tree removed) */
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void { syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
const state = stateManager.getState() const state = stateManager.getState()

View File

@@ -0,0 +1,130 @@
import Phaser from 'phaser'
import { TILE_SIZE, TREE_SEEDLING_STAGE_MS } from '../config'
import { TileType, PLANTABLE_TILES } from '../types'
import type { TreeSeedlingState } from '../types'
import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter'
import type { WorldSystem } from './WorldSystem'
export class TreeSeedlingSystem {
private scene: Phaser.Scene
private adapter: LocalAdapter
private worldSystem: WorldSystem
private sprites = new Map<string, Phaser.GameObjects.Image>()
/**
* @param scene - The Phaser scene this system belongs to
* @param adapter - Network adapter for dispatching state actions
* @param worldSystem - Used to refresh the terrain canvas when a seedling matures
*/
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
this.scene = scene
this.adapter = adapter
this.worldSystem = worldSystem
}
/** Spawns sprites for all seedlings that exist in the saved state. */
create(): void {
const state = stateManager.getState()
for (const s of Object.values(state.world.treeSeedlings)) {
this.spawnSprite(s)
}
}
/**
* Ticks all seedling growth timers and handles stage changes.
* Stage 0→1: updates the sprite to the sapling texture.
* Stage 1→2: removes the seedling, spawns a tree resource, and updates the terrain canvas.
* @param delta - Frame delta in milliseconds
*/
update(delta: number): void {
const advanced = stateManager.tickSeedlings(delta)
for (const id of advanced) {
const state = stateManager.getState()
const seedling = state.world.treeSeedlings[id]
if (!seedling) continue
if (seedling.stage === 2) {
// Fully mature: become a FOREST tile and a real tree resource
const { tileX, tileY } = seedling
this.removeSprite(id)
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
this.adapter.send({
type: 'SPAWN_RESOURCE',
resource: { id: resourceId, tileX, tileY, kind: 'tree', hp: 3 },
})
} else {
// Stage 0→1: update sprite to sapling
const sprite = this.sprites.get(id)
if (sprite) sprite.setTexture(`seedling_${seedling.stage}`)
}
}
}
/**
* Attempts to plant a tree seedling on a grass tile.
* Validates that the stockpile has at least one tree_seed, the tile type is
* plantable (GRASS or DARK_GRASS), and no other object occupies the tile.
* @param tileX - Target tile column
* @param tileY - Target tile row
* @param underlyingTile - The current tile type (stored on the seedling for later restoration)
* @returns true if the seedling was planted, false if validation failed
*/
plantSeedling(tileX: number, tileY: number, underlyingTile: TileType): boolean {
const state = stateManager.getState()
if ((state.world.stockpile.tree_seed ?? 0) <= 0) return false
if (!PLANTABLE_TILES.has(underlyingTile)) return false
const occupied =
Object.values(state.world.resources).some(r => r.tileX === tileX && r.tileY === tileY) ||
Object.values(state.world.buildings).some(b => b.tileX === tileX && b.tileY === tileY) ||
Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY) ||
Object.values(state.world.treeSeedlings).some(s => s.tileX === tileX && s.tileY === tileY)
if (occupied) return false
const id = `seedling_${tileX}_${tileY}_${Date.now()}`
const seedling: TreeSeedlingState = {
id, tileX, tileY,
stage: 0,
stageTimerMs: TREE_SEEDLING_STAGE_MS,
underlyingTile,
}
this.adapter.send({ type: 'PLANT_TREE_SEED', seedling })
this.spawnSprite(seedling)
return true
}
/**
* Creates and registers the sprite for a seedling.
* @param s - Seedling state to render
*/
private spawnSprite(s: TreeSeedlingState): void {
const x = (s.tileX + 0.5) * TILE_SIZE
const y = (s.tileY + 0.5) * TILE_SIZE
const key = `seedling_${Math.min(s.stage, 2)}`
const sprite = this.scene.add.image(x, y, key)
.setOrigin(0.5, 0.85)
.setDepth(5)
this.sprites.set(s.id, sprite)
}
/**
* Destroys the sprite for a seedling and removes it from the registry.
* @param id - Seedling ID
*/
private removeSprite(id: string): void {
const s = this.sprites.get(id)
if (s) { s.destroy(); this.sprites.delete(id) }
}
/** Destroys all seedling sprites and clears the registry. */
destroy(): void {
for (const id of [...this.sprites.keys()]) this.removeSprite(id)
}
}

View File

@@ -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'
@@ -10,6 +11,8 @@ import type { FarmingSystem } from './FarmingSystem'
const ARRIVAL_PX = 3 const ARRIVAL_PX = 3
const WORK_LOG_MAX = 20
interface VillagerRuntime { interface VillagerRuntime {
sprite: Phaser.GameObjects.Image sprite: Phaser.GameObjects.Image
nameLabel: Phaser.GameObjects.Text nameLabel: Phaser.GameObjects.Text
@@ -19,6 +22,8 @@ interface VillagerRuntime {
destination: 'job' | 'stockpile' | 'bed' | null destination: 'job' | 'stockpile' | 'bed' | null
workTimer: number workTimer: number
idleScanTimer: number idleScanTimer: number
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
workLog: string[]
} }
export class VillagerSystem { export class VillagerSystem {
@@ -34,19 +39,34 @@ export class VillagerSystem {
private nameIndex = 0 private nameIndex = 0
onMessage?: (msg: string) => void onMessage?: (msg: string) => void
onNisseClick?: (villagerId: 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 +76,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 +95,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 +126,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) {
@@ -106,13 +144,21 @@ export class VillagerSystem {
// Carrying items? → find stockpile // Carrying items? → find stockpile
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) { if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
const sp = this.nearestBuilding(v, 'stockpile_zone') const sp = this.nearestBuilding(v, 'stockpile_zone')
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return } if (sp) {
this.addLog(v.id, '→ Hauling to stockpile')
this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile')
return
}
} }
// Low energy → find bed // Low energy → find bed
if (v.energy < 25) { if (v.energy < 25) {
const bed = this.findBed(v) const bed = this.findBed(v)
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return } if (bed) {
this.addLog(v.id, '→ Going to sleep (low energy)')
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
return
}
} }
// Find a job // Find a job
@@ -123,6 +169,7 @@ export class VillagerSystem {
type: 'VILLAGER_SET_JOB', villagerId: v.id, type: 'VILLAGER_SET_JOB', villagerId: v.id,
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} }, job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
}) })
this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`)
this.beginWalk(v, rt, job.tileX, job.tileY, 'job') this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
} else { } else {
// No job available — wait before scanning again // No job available — wait before scanning again
@@ -132,6 +179,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 +215,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,10 +231,13 @@ 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
this.addLog(v.id, '✓ Deposited at stockpile')
break break
case 'bed': case 'bed':
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
this.addLog(v.id, '💤 Sleeping...')
break break
default: default:
@@ -184,6 +248,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,14 +271,23 @@ 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 })
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
} }
} 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 })
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Mined rock (+2 stone)')
} }
} else if (job.type === 'farm') { } else if (job.type === 'farm') {
const crop = state.world.crops[job.targetId] const crop = state.world.crops[job.targetId]
@@ -214,15 +295,28 @@ export class VillagerSystem {
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
this.farmingSystem.removeCropSpritePublic(job.targetId) this.farmingSystem.removeCropSpritePublic(job.targetId)
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any }) this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
} }
} }
// 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
@@ -230,11 +324,19 @@ export class VillagerSystem {
if (v.energy >= 100) { if (v.energy >= 100) {
rt.sprite.setAngle(0) rt.sprite.setAngle(0)
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
this.addLog(v.id, '✓ Woke up (energy full)')
} }
} }
// ─── 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
@@ -248,12 +350,17 @@ export class VillagerSystem {
if (p.chop > 0) { if (p.chop > 0) {
for (const res of Object.values(state.world.resources)) { for (const res of Object.values(state.world.resources)) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
// Skip trees with no reachable neighbour — A* cannot enter an impassable goal
// tile unless at least one passable neighbour exists to jump from.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }) candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
} }
} }
if (p.mine > 0) { if (p.mine > 0) {
for (const res of Object.values(state.world.resources)) { for (const res of Object.values(state.world.resources)) {
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
// Same reachability guard for rock tiles.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
} }
} }
@@ -275,6 +382,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)
@@ -285,7 +401,7 @@ export class VillagerSystem {
this.claimed.delete(v.job.targetId) this.claimed.delete(v.job.targetId)
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
} }
rt.idleScanTimer = 1500 // longer delay after failed pathfind rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
return return
} }
@@ -296,6 +412,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 +426,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
@@ -312,8 +438,28 @@ export class VillagerSystem {
return this.nearestBuilding(v, 'bed') as any return this.nearestBuilding(v, 'bed') as any
} }
/**
* Returns true if at least one of the 8 neighbours of the given tile is passable.
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
* such as trees deep inside a dense forest cluster where A* can never reach the goal
* tile because no passable tile is adjacent to it.
* @param tileX - Target tile X
* @param tileY - Target tile Y
*/
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
for (const [dx, dy] of DIRS) {
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
}
return false
}
// ─── 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 +488,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)
@@ -358,9 +509,20 @@ export class VillagerSystem {
const energyBar = this.scene.add.graphics().setDepth(12) const energyBar = this.scene.add.graphics().setDepth(12)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13) const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 }) sprite.setInteractive()
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] })
} }
/**
* Redraws the energy bar graphic for a Nisse at the given world position.
* Color transitions green → orange → red as energy decreases.
* @param g - Graphics object to draw into
* @param x - World X center of the Nisse
* @param y - World Y center of the Nisse
* @param energy - Current energy value (0100)
*/
private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void { private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
const W = 20, H = 3 const W = 20, H = 3
g.clear() g.clear()
@@ -369,8 +531,29 @@ export class VillagerSystem {
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H) g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
} }
// ─── Work log ─────────────────────────────────────────────────────────────
/**
* Prepends a message to the runtime work log for the given Nisse.
* Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found.
* @param villagerId - Target Nisse ID
* @param msg - Log message to prepend
*/
private addLog(villagerId: string, msg: string): void {
const rt = this.runtime.get(villagerId)
if (!rt) return
rt.workLog.unshift(msg)
if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX
}
// ─── Public API ─────────────────────────────────────────────────────────── // ─── 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 +566,37 @@ export class VillagerSystem {
return '💭 Idle' return '💭 Idle'
} }
/**
* Returns a copy of the runtime work log for the given Nisse (newest first).
* @param villagerId - The Nisse's ID
* @returns Array of log strings, or empty array if not found
*/
getWorkLog(villagerId: string): string[] {
return [...(this.runtime.get(villagerId)?.workLog ?? [])]
}
/**
* Returns the current world position and remaining path for every Nisse
* that is currently in the 'walking' state. Used by DebugSystem for
* pathfinding visualization.
* @returns Array of path entries, one per walking Nisse
*/
getActivePaths(): Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> {
const state = stateManager.getState()
const result: Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> = []
for (const v of Object.values(state.world.villagers)) {
if (v.aiState !== 'walking') continue
const rt = this.runtime.get(v.id)
if (!rt) continue
result.push({ x: v.x, y: v.y, path: [...rt.path] })
}
return result
}
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
destroy(): void { destroy(): void {
for (const rt of this.runtime.values()) { for (const rt of this.runtime.values()) {
rt.sprite.destroy(); rt.nameLabel.destroy() rt.sprite.destroy(); rt.nameLabel.destroy()

View File

@@ -1,6 +1,6 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import { TILE_SIZE, WORLD_TILES } from '../config' import { TILE_SIZE, WORLD_TILES } from '../config'
import { TileType, IMPASSABLE } from '../types' import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
const BIOME_COLORS: Record<number, string> = { const BIOME_COLORS: Record<number, string> = {
@@ -18,22 +18,32 @@ const BIOME_COLORS: Record<number, string> = {
export class WorldSystem { export class WorldSystem {
private scene: Phaser.Scene private scene: Phaser.Scene
private map!: Phaser.Tilemaps.Tilemap private map!: Phaser.Tilemaps.Tilemap
/**
* Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile
* that is currently occupied by a tree or rock resource.
* Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked.
*/
private resourceTiles = new Set<number>()
private tileset!: Phaser.Tilemaps.Tileset private tileset!: Phaser.Tilemaps.Tileset
private bgImage!: Phaser.GameObjects.Image private bgImage!: Phaser.GameObjects.Image
private builtLayer!: Phaser.Tilemaps.TilemapLayer private builtLayer!: Phaser.Tilemaps.TilemapLayer
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
/** @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()
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) --- // --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
const canvas = document.createElement('canvas') const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
canvas.width = WORLD_TILES const ctx = canvasTexture.context
canvas.height = WORLD_TILES
const ctx = canvas.getContext('2d')!
for (let y = 0; y < WORLD_TILES; y++) { for (let y = 0; y < WORLD_TILES; y++) {
for (let x = 0; x < WORLD_TILES; x++) { for (let x = 0; x < WORLD_TILES; x++) {
@@ -43,12 +53,14 @@ export class WorldSystem {
} }
} }
this.scene.textures.addCanvas('terrain_bg', canvas) canvasTexture.refresh()
this.bgCanvasTexture = canvasTexture
this.bgImage = this.scene.add.image(0, 0, 'terrain_bg') this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
.setOrigin(0, 0) .setOrigin(0, 0)
.setScale(TILE_SIZE) .setScale(TILE_SIZE)
.setDepth(0) .setDepth(0)
this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR) canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR)
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) --- // --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
this.map = this.scene.make.tilemap({ this.map = this.scene.make.tilemap({
@@ -79,12 +91,22 @@ export class WorldSystem {
// Camera bounds // Camera bounds
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)
this.initResourceTiles()
} }
/** 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,13 +117,67 @@ export class WorldSystem {
} }
} }
/**
* Returns whether the tile at the given coordinates can be walked on.
* Water and wall tiles are always impassable.
* Forest and rock terrain tiles are only impassable when a resource
* (tree or rock) currently occupies them — empty forest floor and bare
* rocky ground are walkable.
* 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()
const tile = state.world.tiles[tileY * WORLD_TILES + tileX] const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
return !IMPASSABLE.has(tile) if (IMPASSABLE.has(tile)) return false
if (RESOURCE_TERRAIN.has(tile)) {
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
}
return true
} }
/**
* Builds the resource tile index from the current world state.
* Called once in create() so that isPassable() has an O(1) lookup.
*/
private initResourceTiles(): void {
this.resourceTiles.clear()
const state = stateManager.getState()
for (const res of Object.values(state.world.resources)) {
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
}
}
/**
* Registers a newly placed resource so isPassable() treats the tile as blocked.
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
* @param tileX - Resource tile column
* @param tileY - Resource tile row
*/
addResourceTile(tileX: number, tileY: number): void {
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
}
/**
* Removes a resource from the tile index so isPassable() treats the tile as free.
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
* but keeps the index clean for correctness.
* @param tileX - Resource tile column
* @param tileY - Resource tile row
*/
removeResourceTile(tileX: number, tileY: number): void {
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
}
/**
* 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 +185,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 +198,32 @@ 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
} }
/**
* Updates a single tile's pixel on the background canvas and refreshes the GPU texture.
* Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery,
* or GRASS → FOREST when a seedling matures).
* @param tileX - Tile column
* @param tileY - Tile row
* @param type - New tile type to reflect visually
*/
refreshTerrainTile(tileX: number, tileY: number, type: TileType): void {
const color = BIOME_COLORS[type] ?? '#0a2210'
this.bgCanvasTexture.context.fillStyle = color
this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1)
this.bgCanvasTexture.refresh()
}
/** 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
View 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
View 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
View 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)

View File

@@ -12,15 +12,25 @@ export enum TileType {
WATERED_SOIL = 10, WATERED_SOIL = 10,
} }
/** Tiles that are always impassable regardless of what is on them. */
export const IMPASSABLE = new Set<number>([ export const IMPASSABLE = new Set<number>([
TileType.DEEP_WATER, TileType.DEEP_WATER,
TileType.SHALLOW_WATER, TileType.SHALLOW_WATER,
TileType.FOREST,
TileType.ROCK,
TileType.WALL, TileType.WALL,
]) ])
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' /**
* Terrain tiles whose passability depends on whether a resource
* (tree or rock) is currently placed on them.
* An empty FOREST tile is walkable forest floor; a ROCK tile without a
* rock resource is just rocky ground.
*/
export const RESOURCE_TERRAIN = new Set<number>([TileType.FOREST, TileType.ROCK])
/** Tiles on which tree seedlings may be planted. */
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
@@ -90,6 +100,18 @@ export interface PlayerState {
inventory: Partial<Record<ItemId, number>> inventory: Partial<Record<ItemId, number>>
} }
export interface TreeSeedlingState {
id: string
tileX: number
tileY: number
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
stage: number
/** Time remaining until next stage advance, in milliseconds. */
stageTimerMs: number
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
underlyingTile: TileType
}
export interface WorldState { export interface WorldState {
seed: number seed: number
tiles: number[] tiles: number[]
@@ -98,6 +120,13 @@ export interface WorldState {
crops: Record<string, CropState> crops: Record<string, CropState>
villagers: Record<string, VillagerState> villagers: Record<string, VillagerState>
stockpile: Partial<Record<ItemId, number>> stockpile: Partial<Record<ItemId, number>>
/** Planted tree seedlings, keyed by ID. */
treeSeedlings: Record<string, TreeSeedlingState>
/**
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
* Value is remaining milliseconds until the tile reverts to GRASS.
*/
tileRecovery: Record<string, number>
} }
export interface GameStateData { export interface GameStateData {
@@ -123,3 +152,7 @@ export type GameAction =
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string } | { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
| { type: 'VILLAGER_DEPOSIT'; villagerId: string } | { type: 'VILLAGER_DEPOSIT'; villagerId: string }
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities } | { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
| { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState }
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }

16
test.html Normal file
View 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>

View File

@@ -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,
}, },
plugins: [
{
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: { build: {
outDir: 'dist', outDir: 'dist',
assetsInlineLimit: 0 assetsInlineLimit: 0,
} rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
test: resolve(__dirname, 'test.html'),
},
},
},
}) })