59 Commits

Author SHA1 Message Date
26c3807481 Merge pull request 'Fix GC-Ruckler in pickJob und tickVillager' (#35) from fix/gc-alloc-picjob into master
Reviewed-on: #35
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 20:28:45 +00:00
d02ed33435 fix GC ruckler — Object.values() einmal pro pickJob-Aufruf
Fixes #34. Alle Object.values()-Aufrufe werden einmal am Anfang von
pickJob() extrahiert und in allen Branches wiederverwendet. Der
Forester-Loop rief zuvor fuer jedes Zone-Tile 4x Object.values() auf.
JOB_ICONS als Modul-Konstante, Math.min-spread durch Schleife ersetzt.
2026-03-23 20:18:00 +00:00
c7cf971e54 📝 update CHANGELOG for depth sorting (PR #32) 2026-03-23 20:08:12 +00:00
08dffa135f Merge pull request 'Fix Y-based depth sorting for world objects' (#32) from feature/depth-sorting into master
Reviewed-on: #32
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 20:07:28 +00:00
4f2e9f73b6 ♻️ remove silhouette — Nisse always render above world objects
Depth fixed at 900; isOccluded() and outlineSprite removed.
WorldSystem.hasResourceAt() stays as a useful utility.
2026-03-23 20:05:53 +00:00
84b6e51746 🐛 improve occlusion detection with wider tile check
Expands isOccluded() from same-column-only to a 3x4 tile window
(tileX+-1, tileY+1..4) to catch trees whose canopy extends sideways
and well above the trunk tile. Outline scale bumped to 1.15.
2026-03-23 20:02:40 +00:00
5f646d54ca 🐛 fix Nisse outline only shown when actually occluded
Silhouette now hidden by default and toggled on per frame only when
isOccluded() detects a tree, rock or building 1–3 tiles below the Nisse.
Adds WorldSystem.hasResourceAt() for O(1) tile lookup. Outline colour
changed to light blue (0xaaddff) at scale 1.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:52:37 +00:00
94b2f7f457 add Nisse silhouette outline for occlusion visibility
Fixes #33. Each Nisse now has a white filled outline sprite at depth 900
that is always visible above trees and buildings. The main sprite uses
Y-based depth (floor(y/TILE_SIZE)+5) so Nisse sort correctly with world
objects. Name label, energy bar and job icon moved to depth 901/902 so
they remain readable regardless of occlusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:47:51 +00:00
cd171c859c fix depth sorting for world objects by tileY
Fixes #31. All trees, rocks, seedlings and buildings now use
tileY+5 as depth instead of a fixed value, so objects further
down the screen always render in front of objects above them
regardless of spawn order. Build ghost moved to depth 1000/1001.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:40:27 +00:00
d9ef57c6b0 Merge pull request 'Add bottom action bar with Build and Nisse buttons' (#30) from feature/action-bar into master
Reviewed-on: #30
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 19:24:46 +00:00
87f69b4774 add bottom action bar with Build and Nisse category buttons (fixes #29)
- Persistent action bar at bottom of screen (48px high, full width)
- Build button: toggles a horizontal building tray above the bar
- Nisse button: opens the existing Nisse management panel
- Active category button is highlighted; ESC closes the tray
- hintText (farm tool indicator) repositioned above the action bar
- Bar and tray reposition correctly on canvas resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:10:41 +00:00
8d2c58cb5f Merge pull request 'Remove bottom HUD text, move keys to ESC menu' (#28) from fix/remove-bottom-hud-text into master 2026-03-23 16:35:59 +00:00
986c2ea9eb 🔥 remove bottom HUD text, move keys to ESC menu (fixes #27)
- Removed controls hint text and tile coordinate display from the screen
- Removed coordsText / controlsHintText fields and createCoordsDisplay / onCameraMoved methods
- Added keyboard shortcut reference block at the bottom of the ESC menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:32:45 +00:00
1d8b2b2b9c Merge pull request ' Försterkreislauf: Setzlinge, Försterhaus, Förster-Job' (#26) from feature/forester-cycle into master 2026-03-23 16:32:29 +00:00
969a82949e Försterkreislauf: Setzlinge beim Fällen, Försterhaus, Förster-Job
- Gefällter Baum → 1–2 tree_seed im Stockpile (zufällig)
- Neues Gebäude forester_hut (50 wood): Log-Hütten-Grafik, Klick öffnet Info-Panel
- Zonenmarkierung: Edit-Zone-Tool, Radius 5 Tiles, halbtransparente Overlay-Anzeige
- Neuer JobType 'forester': Nisse pflanzen Setzlinge auf markierten Zonen-Tiles
- Chop-Priorisierung: Zonen-Bäume werden vor natürlichen Bäumen gefällt
- Nisse-Panel & Info-Panel zeigen forester-Priorität-Button

Closes #25

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:07:36 +00:00
d3696c6380 📝 CHANGELOG update for Issue #22 (#24) 2026-03-23 12:33:19 +00:00
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
23 changed files with 3235 additions and 119 deletions

2
.gitignore vendored
View File

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

View File

@@ -7,6 +7,48 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- **Y-based depth sorting** (Issue #31): trees, rocks, seedlings and buildings now use `tileY + 5` as depth instead of fixed values — objects lower on screen always render in front of objects above them, regardless of spawn order; build ghost moved to depth 1000
- **Nisse always visible** (Issue #33): Nisse sprites fixed at depth 900, always rendered above world objects
### Added
- **Försterkreislauf** (Issue #25):
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 12 `tree_seed` in den Stockpile
- **Försterhaus** (`forester_hut`): Neues Gebäude im Build-Menü (Kosten: 50 wood); Log-Hütten-Grafik mit Baum-Symbol; Klick auf das Haus öffnet ein Info-Panel
- **Zonenmarkierung**: Im Info-Panel öffnet „Edit Zone" den Zonen-Editor; innerhalb eines Radius von 5 Tiles können Tiles per Klick zur Pflanzzone hinzugefügt oder entfernt werden; markierte Tiles werden als halbtransparente grüne Fläche im Spiel angezeigt; Zone wird im Save gespeichert
- **Förster-Job** (`forester`): Nisse mit `forester`-Priorität > 0 pflanzen automatisch `tree_seed` auf leeren Zonen-Tiles; erfordert `tree_seed` im Stockpile
- **Chop-Priorisierung**: Beim Fällen werden Bäume innerhalb von Förster-Zonen bevorzugt; natürliche Bäume werden erst gefällt wenn keine Zonen-Bäume mehr vorhanden sind
- Nisse-Info-Panel und Nisse-Panel (V) zeigen jetzt auch die `forester`-Priorität als Schaltfläche
### 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
- **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
@@ -20,8 +62,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message)
### Added
- Scroll wheel now zooms toward the mouse cursor position instead of the screen center
- 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
- Core scenes: `BootScene`, `GameScene`, `UIScene`

View File

@@ -1,5 +1,16 @@
# CLAUDE.md — Game Project
## ⚠️ Important: Session Start Location
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
---
## Project Overview
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
@@ -62,3 +73,82 @@ npm run preview # Preview production build locally
- **Systems** read/write state and are updated each game tick via Phaser's `update()`
- **Scenes** are thin orchestrators — logic belongs in systems, not scenes
- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly
---
## Gitea Workflow (repo: tekki/nissefolk)
**Tool:** `tea` CLI (installed at `~/.local/bin/tea`, login `zally` configured).
Never use raw `curl` with `${CLAUDE_GITEA_TOKEN}` for Gitea — use `tea` instead.
All `tea` commands run from `~/game` (git remote `gitea` points to the repo).
**Git commands:** Always use `git -C ~/game <cmd>` — never `cd ~/game && git <cmd>` (triggers security prompt).
```bash
# Create PR (always wait for user approval before merging)
# Use ~/scripts/create-pr.sh — pass \n literally for newlines, the script expands them via printf.
# Never use heredocs or $(cat file) — they trigger permission prompts.
~/scripts/create-pr.sh "PR title" "Fixes #N.\n\n## What changed\n- item one\n- item two" feature/xyz
# List open PRs / issues
tea pr list --login zally
tea issue list --login zally
# View a single issue (body + comments)
tea issue --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
# Merge PR — ONLY after explicit user says "merge it"
tea pr merge --login zally --style merge <PR_NUMBER>
# Close issue
tea issue close --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
# List labels
tea labels list --login zally --repo tekki/nissefolk
# Set/remove labels on an issue (use label names, not IDs)
tea issue edit --login zally --repo tekki/nissefolk --add-labels "status: done" <N>
tea issue edit --login zally --repo tekki/nissefolk --remove-labels "status: in discussion" <N>
# Both flags can be combined; --add-labels takes precedence over --remove-labels
tea issue edit <N> --add-labels "status: done" --remove-labels "status: in progress" --repo tekki/nissefolk
# Note: "tea labels" manages label definitions in the repo — not issue assignments
```
**Label IDs** (repo-specific, don't guess):
| ID | Name |
|----|------|
| 1 | feature |
| 2 | improvement |
| 3 | bug |
| 6 | status: backlog |
| 8 | status: ready |
| 9 | status: in progress |
| 10 | status: review |
| 11 | status: done |
**PR workflow rules:**
1. Commit → push branch → `tea pr create`**share URL, stop, wait for user approval**
2. Only merge when user explicitly says so
3. After merge: close issue + set label to `status: done`
**master branch is protected** — direct push is rejected. Always use PRs.
**Routine load issue**
1. Load Issues
if-> If the label is status: ready
-> work as it says
-> use a new branch for each issue
-> test your code
-> commit your code
-> change the issue label
-> do an pr to master
if-> If the label is status: discussion
-> think if you need more information
-> ask questions as comment in gitea
**Issue create**
If i say something like "create an issue about..." you need to attach the labels to it to. Use status: discussion and feature/bug

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 { TileType } from './types'
const DEFAULT_PLAYER: PlayerState = {
id: 'player1',
@@ -15,13 +16,16 @@ function makeEmptyWorld(seed: number): WorldState {
buildings: {},
crops: {},
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: {},
foresterZones: {},
}
}
function makeDefaultState(): GameStateData {
return {
version: 4,
version: 5,
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
}
@@ -62,11 +66,20 @@ class StateManager {
w.buildings[action.building.id] = action.building
for (const [k, v] of Object.entries(action.costs))
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
// Automatically create an empty forester zone when a forester hut is placed
if (action.building.kind === 'forester_hut') {
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
}
break
}
case 'REMOVE_BUILDING':
delete w.buildings[action.buildingId]; break
// Remove associated forester zone when the hut is demolished
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
delete w.foresterZones[action.buildingId]
}
delete w.buildings[action.buildingId]
break
case 'ADD_ITEMS':
for (const [k, v] of Object.entries(action.items))
@@ -146,6 +159,32 @@ class StateManager {
if (v) v.priorities = { ...action.priorities }
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
case 'FORESTER_ZONE_UPDATE': {
const zone = w.foresterZones[action.buildingId]
if (zone) zone.tiles = [...action.tiles]
break
}
}
}
@@ -163,6 +202,47 @@ class StateManager {
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 {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
}
@@ -172,13 +252,24 @@ class StateManager {
const raw = localStorage.getItem(SAVE_KEY)
if (!raw) return null
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.villagers) p.world.villagers = {}
if (!p.world.stockpile) p.world.stockpile = {}
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
if (!p.world.tileRecovery) p.world.tileRecovery = {}
if (!p.world.foresterZones) p.world.foresterZones = {}
// Reset in-flight AI states to idle on load so runtime timers start fresh
for (const v of Object.values(p.world.villagers)) {
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
// Migrate older saves that don't have the forester priority
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
}
// Rebuild forester zones for huts that predate the foresterZones field
for (const b of Object.values(p.world.buildings)) {
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
}
}
return p
} catch (_) { return null }

View File

@@ -19,8 +19,12 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
chest: { wood: 5, stone: 2 },
bed: { wood: 6 },
stockpile_zone:{ wood: 0 },
forester_hut: { wood: 50 },
}
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
export const FORESTER_ZONE_RADIUS = 5
export interface CropConfig {
stages: number
stageTimeMs: number
@@ -39,6 +43,7 @@ export const VILLAGER_WORK_TIMES: Record<string, number> = {
chop: 3000,
mine: 5000,
farm: 1200,
forester: 2000,
}
export const VILLAGER_NAMES = [
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
@@ -46,5 +51,14 @@ export const VILLAGER_NAMES = [
'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
/** 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.buildPlayerTexture()
this.buildCropTextures()
this.buildSeedlingTextures()
this.buildUITextures()
this.buildVillagerAndBuildingTextures()
this.generateWorldIfNeeded()
@@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene {
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 ─────────────────────────────────────────────────────
private buildUITextures(): void {

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser'
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
import { TileType } from '../types'
import type { BuildingType } from '../types'
import { stateManager } from '../StateManager'
import { LocalAdapter } from '../NetworkAdapter'
@@ -9,6 +10,9 @@ import { ResourceSystem } from '../systems/ResourceSystem'
import { BuildingSystem } from '../systems/BuildingSystem'
import { FarmingSystem } from '../systems/FarmingSystem'
import { VillagerSystem } from '../systems/VillagerSystem'
import { DebugSystem } from '../systems/DebugSystem'
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
import { ForesterZoneSystem } from '../systems/ForesterZoneSystem'
export class GameScene extends Phaser.Scene {
private adapter!: LocalAdapter
@@ -18,11 +22,18 @@ export class GameScene extends Phaser.Scene {
private buildingSystem!: BuildingSystem
private farmingSystem!: FarmingSystem
villagerSystem!: VillagerSystem
debugSystem!: DebugSystem
private treeSeedlingSystem!: TreeSeedlingSystem
foresterZoneSystem!: ForesterZoneSystem
private autosaveTimer = 0
private menuOpen = false
constructor() { super({ key: 'Game' }) }
/**
* Initialises all game systems, wires up inter-system events,
* launches the UI scene overlay, and starts the autosave timer.
*/
create(): void {
this.adapter = new LocalAdapter()
@@ -33,6 +44,9 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem = new FarmingSystem(this, this.adapter)
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
this.worldSystem.create()
this.renderPersistentObjects()
@@ -52,17 +66,52 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem.create()
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
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.foresterZoneSystem.create()
this.foresterZoneSystem.refreshOverlay()
this.foresterZoneSystem.onEditEnded = () => this.events.emit('foresterZoneEditEnded')
this.foresterZoneSystem.onZoneChanged = (id, tiles) => this.events.emit('foresterZoneChanged', id, tiles)
this.villagerSystem.create()
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
this.villagerSystem.onPlantSeedling = (tileX, tileY, tile) =>
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
this.debugSystem.create()
// Sync tile changes and building visuals through adapter
this.adapter.onAction = (action) => {
if (action.type === 'CHANGE_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)
} else if (action.type === 'FORESTER_ZONE_UPDATE') {
this.foresterZoneSystem.refreshOverlay()
}
}
// Detect left-clicks on forester huts to open the zone panel
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown() || this.menuOpen) return
if (this.buildingSystem.isActive()) return
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const state = stateManager.getState()
const hut = Object.values(state.world.buildings).find(
b => b.kind === 'forester_hut' && b.tileX === tileX && b.tileY === tileY
)
if (hut) {
this.events.emit('foresterHutClicked', hut.id)
}
})
this.scene.launch('UI')
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
@@ -71,13 +120,28 @@ export class GameScene extends Phaser.Scene {
this.events.on('uiRequestBuildMenu', () => {
if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu')
})
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; forester: number }) => {
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
})
this.events.on('foresterZoneEditStart', (buildingId: string) => {
this.foresterZoneSystem.startEditMode(buildingId)
this.menuOpen = false // keep game ticking while zone editor is open
})
this.events.on('foresterZoneEditStop', () => {
this.foresterZoneSystem.exitEditMode()
})
this.events.on('debugToggle', () => this.debugSystem.toggle())
this.autosaveTimer = AUTOSAVE_INTERVAL
}
/**
* Main game loop: updates all systems and emits the cameraMoved event for the UI.
* Skips system updates while a menu is open.
* @param _time - Total elapsed time (unused)
* @param delta - Frame delta in milliseconds
*/
update(_time: number, delta: number): void {
if (this.menuOpen) return
@@ -85,7 +149,16 @@ export class GameScene extends Phaser.Scene {
this.resourceSystem.update(delta)
this.farmingSystem.update(delta)
this.treeSeedlingSystem.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.buildingSystem.update()
@@ -106,25 +179,40 @@ export class GameScene extends Phaser.Scene {
const name = `bobj_${building.id}`
if (this.children.getByName(name)) continue
const worldDepth = building.tileY + 5
if (building.kind === 'chest') {
const g = this.add.graphics().setName(name).setDepth(8)
const g = this.add.graphics().setName(name).setDepth(worldDepth)
g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
} else if (building.kind === 'bed') {
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8)
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(worldDepth)
} else if (building.kind === 'stockpile_zone') {
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
} else if (building.kind === 'forester_hut') {
// Draw a simple log-cabin silhouette for the forester hut
const g = this.add.graphics().setName(name).setDepth(worldDepth)
// Body
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
// Roof
g.fillStyle(0x4a2800); g.fillTriangle(wx - 14, wy - 9, wx + 14, wy - 9, wx, wy - 22)
// Door
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
// Tree symbol on the roof
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
}
}
}
/** Saves game state and destroys all systems cleanly on scene shutdown. */
shutdown(): void {
stateManager.save()
this.worldSystem.destroy()
this.resourceSystem.destroy()
this.buildingSystem.destroy()
this.farmingSystem.destroy()
this.treeSeedlingSystem.destroy()
this.foresterZoneSystem.destroy()
this.villagerSystem.destroy()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ export class BuildingSystem {
create(): void {
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
this.ghost.setDepth(20)
this.ghost.setDepth(1000)
this.ghost.setVisible(false)
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
@@ -40,7 +40,7 @@ export class BuildingSystem {
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
})
this.ghostLabel.setDepth(21)
this.ghostLabel.setDepth(1001)
this.ghostLabel.setVisible(false)
this.ghostLabel.setOrigin(0.5, 1)

View File

@@ -27,11 +27,19 @@ export class CameraSystem {
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) {
this.scene = scene
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 {
const state = stateManager.getState()
const cam = this.scene.cameras.main
@@ -52,21 +60,22 @@ export class CameraSystem {
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
}
// Scroll wheel zoom — zoom toward mouse pointer position
// Scroll wheel: zoom-to-mouse.
// Phaser zooms from the screen center, so the world point under the mouse
// is corrected by shifting scroll by the mouse offset from center.
this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
const oldZoom = cam.zoom
const newZoom = Phaser.Math.Clamp(oldZoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
if (newZoom === oldZoom) return
// World point under mouse before zoom
const worldX = cam.scrollX + ptr.x / oldZoom
const worldY = cam.scrollY + ptr.y / oldZoom
const zoomBefore = cam.zoom
const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
cam.setZoom(newZoom)
// Adjust scroll so the same world point stays under the mouse
cam.scrollX = worldX - ptr.x / newZoom
cam.scrollY = worldY - ptr.y / 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
@@ -97,6 +106,10 @@ export class CameraSystem {
})
}
/**
* Moves the camera via keyboard input and periodically saves the position.
* @param delta - Frame delta in milliseconds
*/
update(delta: number): void {
const cam = this.scene.cameras.main
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
@@ -114,7 +127,7 @@ export class CameraSystem {
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
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)
@@ -131,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 } {
const cam = this.scene.cameras.main
return {
x: cam.scrollX + cam.width / (2 * cam.zoom),
y: cam.scrollY + cam.height / (2 * cam.zoom),
x: cam.scrollX + cam.width / 2,
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 } {
const { x, y } = this.getCenterWorld()
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 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> = {
none: '— None',
hoe: '⛏ Hoe (till grass)',
wheat_seed: '🌾 Wheat Seeds',
carrot_seed: '🥕 Carrot Seeds',
tree_seed: '🌲 Tree Seeds (plant on grass)',
water: '💧 Watering Can',
}
@@ -30,6 +31,14 @@ export class FarmingSystem {
onToolChange?: (tool: FarmingTool, label: string) => void
/** Emitted for toast notifications */
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) {
this.scene = scene
@@ -89,9 +98,27 @@ export class FarmingSystem {
if (this.currentTool === 'hoe') this.tillSoil(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)
}
/**
* 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 {
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
this.onMessage?.('Hoe only works on grass!')

View File

@@ -0,0 +1,194 @@
import Phaser from 'phaser'
import { TILE_SIZE, FORESTER_ZONE_RADIUS } from '../config'
import { PLANTABLE_TILES } from '../types'
import type { TileType } from '../types'
import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter'
/** Colors used for zone rendering. */
const COLOR_IN_RADIUS = 0x44aa44 // unselected tile within radius (edit mode only)
const COLOR_ZONE_TILE = 0x00ff44 // tile marked as part of the zone
const ALPHA_VIEW = 0.18 // always-on zone overlay
const ALPHA_RADIUS = 0.12 // in-radius tiles while editing
const ALPHA_ZONE_EDIT = 0.45 // zone tiles while editing
export class ForesterZoneSystem {
private scene: Phaser.Scene
private adapter: LocalAdapter
/** Graphics layer for the always-visible zone overlay. */
private zoneGraphics!: Phaser.GameObjects.Graphics
/** Graphics layer for the edit-mode radius/tile overlay. */
private editGraphics!: Phaser.GameObjects.Graphics
/** Building ID currently being edited, or null when not in edit mode. */
private editBuildingId: string | null = null
/**
* Callback invoked after a tile toggle so callers can react (e.g. refresh the panel).
* Receives the updated zone tiles array.
*/
onZoneChanged?: (buildingId: string, tiles: string[]) => void
/**
* Callback invoked when the user exits edit mode (right-click or programmatic close).
* UIScene listens to this to close the zone edit indicator.
*/
onEditEnded?: () => void
/**
* @param scene - The Phaser scene this system belongs to
* @param adapter - Network adapter for dispatching state actions
*/
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
this.scene = scene
this.adapter = adapter
}
/** Creates the graphics layers and registers the pointer listener. */
create(): void {
this.zoneGraphics = this.scene.add.graphics().setDepth(3)
this.editGraphics = this.scene.add.graphics().setDepth(4)
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (!this.editBuildingId) return
if (ptr.rightButtonDown()) {
this.exitEditMode()
return
}
this.handleTileClick(ptr.worldX, ptr.worldY)
})
}
/**
* Redraws all zone overlays for every forester hut in the current state.
* Should be called whenever the zone data changes.
*/
refreshOverlay(): void {
this.zoneGraphics.clear()
const state = stateManager.getState()
for (const zone of Object.values(state.world.foresterZones)) {
for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number)
this.zoneGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_VIEW)
this.zoneGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
}
}
}
/**
* Activates zone-editing mode for the given forester hut.
* Draws the radius indicator and zone tiles in edit colors.
* @param buildingId - ID of the forester_hut building to edit
*/
startEditMode(buildingId: string): void {
this.editBuildingId = buildingId
this.drawEditOverlay()
}
/**
* Deactivates zone-editing mode and clears the edit overlay.
* Triggers the onEditEnded callback.
*/
exitEditMode(): void {
if (!this.editBuildingId) return
this.editBuildingId = null
this.editGraphics.clear()
this.onEditEnded?.()
}
/** Returns true when the zone editor is currently active. */
isEditing(): boolean {
return this.editBuildingId !== null
}
/** Destroys all graphics objects. */
destroy(): void {
this.zoneGraphics.destroy()
this.editGraphics.destroy()
}
// ─── Private helpers ──────────────────────────────────────────────────────
/**
* Handles a left-click during edit mode.
* Toggles the clicked tile in the zone if it is within radius and plantable.
* @param worldX - World pixel X of the pointer
* @param worldY - World pixel Y of the pointer
*/
private handleTileClick(worldX: number, worldY: number): void {
const id = this.editBuildingId
if (!id) return
const state = stateManager.getState()
const building = state.world.buildings[id]
if (!building) { this.exitEditMode(); return }
const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE)
// Chebyshev distance — must be within radius
const dx = Math.abs(tileX - building.tileX)
const dy = Math.abs(tileY - building.tileY)
if (Math.max(dx, dy) > FORESTER_ZONE_RADIUS) return
const zone = state.world.foresterZones[id]
if (!zone) return
const key = `${tileX},${tileY}`
const idx = zone.tiles.indexOf(key)
const tiles = idx >= 0
? zone.tiles.filter(t => t !== key) // remove
: [...zone.tiles, key] // add
this.adapter.send({ type: 'FORESTER_ZONE_UPDATE', buildingId: id, tiles })
this.refreshOverlay()
this.drawEditOverlay()
this.onZoneChanged?.(id, tiles)
}
/**
* Redraws the edit-mode overlay showing the valid radius and current zone tiles.
* Only called while editBuildingId is set.
*/
private drawEditOverlay(): void {
this.editGraphics.clear()
const id = this.editBuildingId
if (!id) return
const state = stateManager.getState()
const building = state.world.buildings[id]
if (!building) return
const zone = state.world.foresterZones[id]
const zoneSet = new Set(zone?.tiles ?? [])
const r = FORESTER_ZONE_RADIUS
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const tx = building.tileX + dx
const ty = building.tileY + dy
const key = `${tx},${ty}`
// Only draw on plantable terrain
const tileType = state.world.tiles[ty * 512 + tx] as TileType
if (!PLANTABLE_TILES.has(tileType)) continue
if (zoneSet.has(key)) {
this.editGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_ZONE_EDIT)
this.editGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
} else {
this.editGraphics.fillStyle(COLOR_IN_RADIUS, ALPHA_RADIUS)
}
this.editGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
}
}
// Draw a subtle border around the entire radius square
const bx = (building.tileX - r) * TILE_SIZE
const by = (building.tileY - r) * TILE_SIZE
const bw = (2 * r + 1) * TILE_SIZE
this.editGraphics.lineStyle(1, COLOR_ZONE_TILE, 0.4)
this.editGraphics.strokeRect(bx, by, bw, bw)
}
}

View File

@@ -47,10 +47,10 @@ export class ResourceSystem {
sprite.setOrigin(0.5, 0.75)
}
sprite.setDepth(5)
sprite.setDepth(node.tileY + 5)
const healthBar = this.scene.add.graphics()
healthBar.setDepth(6)
healthBar.setDepth(node.tileY + 6)
healthBar.setVisible(false)
this.sprites.set(node.id, { sprite, node, healthBar })
@@ -76,6 +76,16 @@ export class ResourceSystem {
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) */
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
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(s.tileY + 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,6 +1,6 @@
import Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
import { TileType } from '../types'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
import { TileType, PLANTABLE_TILES } from '../types'
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
import { stateManager } from '../StateManager'
import { findPath } from '../utils/pathfinding'
@@ -11,6 +11,11 @@ import type { FarmingSystem } from './FarmingSystem'
const ARRIVAL_PX = 3
const WORK_LOG_MAX = 20
/** Job-type → display icon mapping; defined once at module level to avoid per-frame allocation. */
const JOB_ICONS: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
interface VillagerRuntime {
sprite: Phaser.GameObjects.Image
nameLabel: Phaser.GameObjects.Text
@@ -20,6 +25,8 @@ interface VillagerRuntime {
destination: 'job' | 'stockpile' | 'bed' | null
workTimer: number
idleScanTimer: number
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
workLog: string[]
}
export class VillagerSystem {
@@ -35,19 +42,40 @@ export class VillagerSystem {
private nameIndex = 0
onMessage?: (msg: string) => void
onNisseClick?: (villagerId: string) => void
/**
* Called when a Nisse completes a forester planting job.
* GameScene wires this to TreeSeedlingSystem.plantSeedling so that the
* seedling sprite is spawned alongside the state action.
*/
onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void
/**
* @param scene - The Phaser scene this system belongs to
* @param adapter - Network adapter for dispatching state actions
* @param worldSystem - Used for passability checks during pathfinding
*/
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
this.scene = scene
this.adapter = adapter
this.worldSystem = worldSystem
}
/** Wire in sibling systems after construction */
/**
* Wires in sibling systems that are not available at construction time.
* Must be called before create().
* @param resourceSystem - Used to remove harvested resource sprites
* @param farmingSystem - Used to remove harvested crop sprites
*/
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
this.resourceSystem = resourceSystem
this.farmingSystem = farmingSystem
}
/**
* Spawns sprites for all Nisse that exist in the saved state
* and re-claims any active job targets.
*/
create(): void {
const state = stateManager.getState()
for (const v of Object.values(state.world.villagers)) {
@@ -57,6 +85,10 @@ export class VillagerSystem {
}
}
/**
* Advances the spawn timer and ticks every Nisse's AI.
* @param delta - Frame delta in milliseconds
*/
update(delta: number): void {
this.spawnTimer += delta
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
@@ -72,6 +104,12 @@ export class VillagerSystem {
// ─── Per-villager tick ────────────────────────────────────────────────────
/**
* Dispatches the correct AI tick method based on the villager's current state,
* then syncs the sprite, name label, energy bar, and job icon to the state.
* @param v - Villager state from the store
* @param delta - Frame delta in milliseconds
*/
private tickVillager(v: VillagerState, delta: number): void {
const rt = this.runtime.get(v.id)
if (!rt) return
@@ -83,20 +121,28 @@ export class VillagerSystem {
case 'sleeping':this.tickSleeping(v, rt, delta); break
}
// Sync sprite to state position
// Nisse always render above world objects
rt.sprite.setPosition(v.x, v.y)
rt.nameLabel.setPosition(v.x, v.y - 22)
rt.energyBar.setPosition(0, 0)
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
// Job icon
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
}
// ─── IDLE ─────────────────────────────────────────────────────────────────
/**
* Handles the idle AI state: hauls items to stockpile if carrying any,
* seeks a bed if energy is low, otherwise picks the next job and begins walking.
* Applies a cooldown before scanning again if no job is found.
* @param v - Villager state
* @param rt - Villager runtime (sprites, path, timers)
* @param delta - Frame delta in milliseconds
*/
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
// Decrement scan timer if cooling down
if (rt.idleScanTimer > 0) {
@@ -107,13 +153,21 @@ export class VillagerSystem {
// Carrying items? → find stockpile
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
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
if (v.energy < 25) {
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
@@ -124,6 +178,7 @@ export class VillagerSystem {
type: 'VILLAGER_SET_JOB', villagerId: v.id,
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')
} else {
// No job available — wait before scanning again
@@ -133,6 +188,14 @@ export class VillagerSystem {
// ─── WALKING ──────────────────────────────────────────────────────────────
/**
* Advances the Nisse along its path toward the current destination.
* Calls onArrived when the path is exhausted.
* Drains energy slowly while walking.
* @param v - Villager state
* @param rt - Villager runtime
* @param delta - Frame delta in milliseconds
*/
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
if (rt.path.length === 0) {
this.onArrived(v, rt)
@@ -161,6 +224,12 @@ export class VillagerSystem {
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
}
/**
* Called when a Nisse reaches its destination tile.
* Transitions to the appropriate next AI state based on destination type.
* @param v - Villager state
* @param rt - Villager runtime
*/
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
switch (rt.destination) {
case 'job':
@@ -172,10 +241,12 @@ export class VillagerSystem {
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
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
case 'bed':
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
this.addLog(v.id, '💤 Sleeping...')
break
default:
@@ -186,6 +257,14 @@ export class VillagerSystem {
// ─── WORKING ──────────────────────────────────────────────────────────────
/**
* Counts down the work timer and performs the harvest action on completion.
* Handles chop, mine, and farm job types.
* Returns the Nisse to idle when done.
* @param v - Villager state
* @param rt - Villager runtime
* @param delta - Frame delta in milliseconds
*/
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
rt.workTimer -= delta
// Wobble while working
@@ -204,17 +283,23 @@ export class VillagerSystem {
const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the FOREST tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId)
// Chopping a tree yields 12 tree seeds in the stockpile
const seeds = Math.random() < 0.5 ? 2 : 1
this.adapter.send({ type: 'ADD_ITEMS', items: { tree_seed: seeds } })
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
}
} else if (job.type === 'mine') {
const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the ROCK tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Mined rock (+2 stone)')
}
} else if (job.type === 'farm') {
const crop = state.world.crops[job.targetId]
@@ -222,6 +307,21 @@ export class VillagerSystem {
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
this.farmingSystem.removeCropSpritePublic(job.targetId)
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
}
} else if (job.type === 'forester') {
// Verify the tile is still empty and the stockpile still has seeds
const tileType = state.world.tiles[job.tileY * WORLD_TILES + job.tileX] as TileType
const hasSeeds = (state.world.stockpile.tree_seed ?? 0) > 0
const tileOccupied =
Object.values(state.world.resources).some(r => r.tileX === job.tileX && r.tileY === job.tileY) ||
Object.values(state.world.buildings).some(b => b.tileX === job.tileX && b.tileY === job.tileY) ||
Object.values(state.world.crops).some(c => c.tileX === job.tileX && c.tileY === job.tileY) ||
Object.values(state.world.treeSeedlings).some(s => s.tileX === job.tileX && s.tileY === job.tileY)
if (hasSeeds && PLANTABLE_TILES.has(tileType) && !tileOccupied) {
this.onPlantSeedling?.(job.tileX, job.tileY, tileType)
this.addLog(v.id, `🌱 Planted seedling at (${job.tileX}, ${job.tileY})`)
}
}
@@ -237,6 +337,12 @@ export class VillagerSystem {
// ─── SLEEPING ─────────────────────────────────────────────────────────────
/**
* Restores energy while sleeping. Returns to idle once energy is full.
* @param v - Villager state
* @param rt - Villager runtime
* @param delta - Frame delta in milliseconds
*/
private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
// Gentle bob while sleeping
@@ -244,11 +350,28 @@ export class VillagerSystem {
if (v.energy >= 100) {
rt.sprite.setAngle(0)
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) ────────────────────────────────
/**
* 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
*/
/**
* Selects the best available job for a Nisse based on their priority settings.
* Among jobs at the same priority level, the closest one wins.
* For chop jobs, trees within a forester zone are preferred over natural trees —
* natural trees are only offered when no forester-zone trees are available.
* Returns null if no unclaimed job is available.
* @param v - Villager state (used for position and priorities)
* @returns The chosen job candidate, or null
*/
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
const state = stateManager.getState()
const p = v.priorities
@@ -256,32 +379,84 @@ export class VillagerSystem {
const vTY = Math.floor(v.y / TILE_SIZE)
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
// Extract state collections once — avoids repeated Object.values() allocation per branch/loop.
const resources = Object.values(state.world.resources)
const buildings = Object.values(state.world.buildings)
const crops = Object.values(state.world.crops)
const seedlings = Object.values(state.world.treeSeedlings)
const zones = Object.values(state.world.foresterZones)
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
const candidates: C[] = []
if (p.chop > 0) {
for (const res of Object.values(state.world.resources)) {
// Build the set of all tiles belonging to forester zones for chop priority
const zoneTiles = new Set<string>()
for (const zone of zones) {
for (const key of zone.tiles) zoneTiles.add(key)
}
const zoneChop: C[] = []
const naturalChop: C[] = []
for (const res of resources) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
// Skip trees with no reachable neighbour — A* cannot reach them.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
if (zoneTiles.has(`${res.tileX},${res.tileY}`)) {
zoneChop.push(c)
} else {
naturalChop.push(c)
}
}
// Prefer zone trees; fall back to natural only when no zone trees are reachable.
candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop))
}
if (p.mine > 0) {
for (const res of Object.values(state.world.resources)) {
for (const res of resources) {
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 })
}
}
if (p.farm > 0) {
for (const crop of Object.values(state.world.crops)) {
for (const crop of crops) {
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
}
}
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
// Find empty plantable zone tiles to seed
for (const zone of zones) {
for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number)
const targetId = `forester_tile_${tx}_${ty}`
if (this.claimed.has(targetId)) continue
// Skip if tile is not plantable
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
if (!PLANTABLE_TILES.has(tileType)) continue
// Skip if something occupies this tile — reuse already-extracted arrays
const occupied =
resources.some(r => r.tileX === tx && r.tileY === ty) ||
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
crops.some(c => c.tileX === tx && c.tileY === ty) ||
seedlings.some(s => s.tileX === tx && s.tileY === ty)
if (occupied) continue
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
}
}
}
if (candidates.length === 0) return null
// Lowest priority number wins; ties broken by distance
const bestPri = Math.min(...candidates.map(c => c.pri))
// Lowest priority number wins; ties broken by distance — avoid spread+map allocation
let bestPri = candidates[0].pri
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
return candidates
.filter(c => c.pri === bestPri)
.sort((a, b) => a.dist - b.dist)[0] ?? null
@@ -289,6 +464,15 @@ export class VillagerSystem {
// ─── Pathfinding ──────────────────────────────────────────────────────────
/**
* Computes a path from the Nisse's current tile to the target tile and
* begins walking. If no path is found, the job is cleared and a cooldown applied.
* @param v - Villager state
* @param rt - Villager runtime
* @param tileX - Target tile X
* @param tileY - Target tile Y
* @param dest - Semantic destination type (used by onArrived)
*/
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
const sx = Math.floor(v.x / TILE_SIZE)
const sy = Math.floor(v.y / TILE_SIZE)
@@ -299,7 +483,7 @@ export class VillagerSystem {
this.claimed.delete(v.job.targetId)
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
}
@@ -310,6 +494,11 @@ export class VillagerSystem {
// ─── Building finders ─────────────────────────────────────────────────────
/**
* Returns the nearest building of the given kind to the Nisse, or null if none exist.
* @param v - Villager state (used as reference position)
* @param kind - Building kind to search for
*/
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
const state = stateManager.getState()
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
@@ -319,6 +508,11 @@ export class VillagerSystem {
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
}
/**
* Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed.
* Returns null if no beds are placed.
* @param v - Villager state
*/
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
const state = stateManager.getState()
// Prefer assigned bed
@@ -326,8 +520,28 @@ export class VillagerSystem {
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 ─────────────────────────────────────────────────────────────
/**
* Attempts to spawn a new Nisse if a free bed is available and the
* current population is below the bed count.
*/
private trySpawn(): void {
const state = stateManager.getState()
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
@@ -349,7 +563,7 @@ export class VillagerSystem {
y: (freeBed.tileY + 0.5) * TILE_SIZE,
bedId: freeBed.id,
job: null,
priorities: { chop: 1, mine: 2, farm: 3 },
priorities: { chop: 1, mine: 2, farm: 3, forester: 4 },
energy: 100,
aiState: 'idle',
}
@@ -361,20 +575,43 @@ export class VillagerSystem {
// ─── Sprite management ────────────────────────────────────────────────────
/**
* Creates and registers all runtime objects (sprite, label, energy bar, icon)
* for a newly added Nisse.
* @param v - Villager state to create sprites for
*/
/**
* Creates and registers all runtime objects (sprite, outline, label, energy bar, icon)
* for a newly added Nisse.
* @param v - Villager state to create sprites for
*/
private spawnSprite(v: VillagerState): void {
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
// Nisse always render above trees, buildings and other world objects.
const sprite = this.scene.add.image(v.x, v.y, 'villager')
.setDepth(900)
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
}).setOrigin(0.5, 1).setDepth(12)
}).setOrigin(0.5, 1).setDepth(901)
const energyBar = this.scene.add.graphics().setDepth(12)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
const energyBar = this.scene.add.graphics().setDepth(901)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902)
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 {
const W = 20, H = 3
g.clear()
@@ -383,13 +620,37 @@ export class VillagerSystem {
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 ───────────────────────────────────────────────────────────
/**
* Returns a short human-readable status string for the given Nisse,
* suitable for display in UI panels.
* @param villagerId - The Nisse's ID
* @returns Status string, or '—' if the Nisse is not found
*/
getStatusText(villagerId: string): string {
const v = stateManager.getState().world.villagers[villagerId]
if (!v) return '—'
if (v.aiState === 'sleeping') return '💤 Sleeping'
if (v.aiState === 'working' && v.job) return `${v.job.type}ing`
if (v.aiState === 'working' && v.job) {
const label = v.job.type === 'forester' ? 'planting' : `${v.job.type}ing`
return `${label}`
}
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
if (v.aiState === 'walking') return '🚶 Walking'
const carrying = v.job?.carrying
@@ -397,6 +658,45 @@ export class VillagerSystem {
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.
*/
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
destroy(): void {
for (const rt of this.runtime.values()) {
rt.sprite.destroy(); rt.nameLabel.destroy()

View File

@@ -1,6 +1,6 @@
import Phaser from 'phaser'
import { TILE_SIZE, WORLD_TILES } from '../config'
import { TileType, IMPASSABLE } from '../types'
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
import { stateManager } from '../StateManager'
const BIOME_COLORS: Record<number, string> = {
@@ -18,22 +18,32 @@ const BIOME_COLORS: Record<number, string> = {
export class WorldSystem {
private scene: Phaser.Scene
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 bgImage!: Phaser.GameObjects.Image
private builtLayer!: Phaser.Tilemaps.TilemapLayer
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
/** @param scene - The Phaser scene this system belongs to */
constructor(scene: Phaser.Scene) {
this.scene = scene
}
/**
* Generates the terrain background canvas from saved tile data,
* creates the built-tile tilemap layer, and sets camera bounds.
*/
create(): void {
const state = stateManager.getState()
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
const canvas = document.createElement('canvas')
canvas.width = WORLD_TILES
canvas.height = WORLD_TILES
const ctx = canvas.getContext('2d')!
const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
const ctx = canvasTexture.context
for (let y = 0; y < WORLD_TILES; y++) {
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')
.setOrigin(0, 0)
.setScale(TILE_SIZE)
.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) ---
this.map = this.scene.make.tilemap({
@@ -79,12 +91,22 @@ export class WorldSystem {
// Camera bounds
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 {
return this.builtLayer
}
/**
* Places or removes a tile on the built layer.
* Built tile types are added; natural types remove the built-layer entry.
* @param tileX - Tile column
* @param tileY - Tile row
* @param type - New tile type to apply
*/
setTile(tileX: number, tileY: number, type: TileType): void {
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
if (BUILT_TILES.has(type)) {
@@ -95,13 +117,77 @@ 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 {
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
const state = stateManager.getState()
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)
}
/**
* Returns true if a resource (tree or rock) occupies the given tile.
* Uses the O(1) resourceTiles index.
* @param tileX - Tile column
* @param tileY - Tile row
*/
hasResourceAt(tileX: number, tileY: number): boolean {
return this.resourceTiles.has(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 } {
return {
tileX: Math.floor(worldX / TILE_SIZE),
@@ -109,6 +195,12 @@ export class WorldSystem {
}
}
/**
* Converts tile coordinates to the world pixel center of that tile.
* @param tileX - Tile column
* @param tileY - Tile row
* @returns World pixel center position
*/
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
return {
x: tileX * TILE_SIZE + TILE_SIZE / 2,
@@ -116,11 +208,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 {
const state = stateManager.getState()
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 {
this.map.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,21 +12,31 @@ export enum TileType {
WATERED_SOIL = 10,
}
/** Tiles that are always impassable regardless of what is on them. */
export const IMPASSABLE = new Set<number>([
TileType.DEEP_WATER,
TileType.SHALLOW_WATER,
TileType.FOREST,
TileType.ROCK,
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])
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
/** 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' | 'forester_hut'
export type CropKind = 'wheat' | 'carrot'
export type JobType = 'chop' | 'mine' | 'farm'
export type JobType = 'chop' | 'mine' | 'farm' | 'forester'
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
@@ -34,6 +44,7 @@ export interface JobPriorities {
chop: number // 0 = disabled, 1 = highest, 4 = lowest
mine: number
farm: number
forester: number // plant tree seedlings in forester zones
}
export interface VillagerJob {
@@ -90,6 +101,28 @@ export interface PlayerState {
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
}
/**
* The set of tiles assigned to one forester hut's planting zone.
* Tiles are stored as "tileX,tileY" key strings.
*/
export interface ForesterZoneState {
buildingId: string
/** Tile keys "tileX,tileY" that the player has marked for planting. */
tiles: string[]
}
export interface WorldState {
seed: number
tiles: number[]
@@ -98,6 +131,15 @@ export interface WorldState {
crops: Record<string, CropState>
villagers: Record<string, VillagerState>
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>
/** Forester zone definitions, keyed by forester_hut building ID. */
foresterZones: Record<string, ForesterZoneState>
}
export interface GameStateData {
@@ -123,3 +165,8 @@ export type GameAction =
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
| { type: 'VILLAGER_DEPOSIT'; villagerId: string }
| { 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 }
| { type: 'FORESTER_ZONE_UPDATE'; buildingId: string; tiles: string[] }

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 { resolve } from 'path'
import fs from 'fs'
const LOG_FILE = resolve(__dirname, 'game-test.log')
export default defineConfig({
server: {
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: {
outDir: 'dist',
assetsInlineLimit: 0
}
assetsInlineLimit: 0,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
test: resolve(__dirname, 'test.html'),
},
},
},
})