44 Commits

Author SHA1 Message Date
3bf143993e 🐛 keep ROCK tile type after surface rock mining
CHANGE_TILE ROCK→GRASS removed from the surface-rock harvest branch.
Empty ROCK tiles are now passable (no resource on them) but remain ROCK,
so the mine building can still be placed on harvested rock ground.

Fixes #48

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:28:57 +00:00
0a706b8def Merge pull request 'Fix: Debug-Panel (F3) weicht Nisse-Info-Panel aus (#41)' (#44) from fix/debug-panel-overlap-41 into master 2026-03-24 18:42:55 +00:00
ae6c14d9a1 🐛 reposition debug panel when Nisse info panel is open (#41)
Debug panel shifts below the Nisse info panel to avoid overlap.
repositionDebugPanel() is called on toggle, open, and close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:16:17 +00:00
3e099d92e2 Merge pull request 'Fix: uiOpacity auf Stockpile und Action Bar vereinheitlicht (#39, #40)' (#43) from fix/ui-opacity-panels into master 2026-03-24 17:14:50 +00:00
f78645bb79 🐛 fix seam between action tray and bar
Tray bg now covers bar area (TRAY_H + BAR_H), actionBarBg is hidden
while tray is open to avoid double-transparency artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:12:30 +00:00
84aa1a7ce5 🐛 fix build tray background and buttons ignoring uiOpacity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:07:54 +00:00
24ee3257df 🐛 fix action bar buttons also ignoring uiOpacity
Build, Nisse buttons and hover states all had hardcoded 0.9 alpha.
updateStaticPanelOpacity() now calls updateCategoryHighlights() so
live changes take effect immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:05:11 +00:00
78c184c560 🐛 fix uiOpacity on stockpile panel and action bar
Closes #39, closes #40.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:00:08 +00:00
7ff3d82e11 Merge pull request ' event-queue timers + action log (fixes #36, #37)' (#38) from feature/event-queue-and-action-log into master 2026-03-24 13:01:22 +00:00
20858a1be1 📝 update changelog for event-queue and action log 2026-03-24 08:08:18 +00:00
3b021127a4 replace polling timers with sorted event queues + action log
Crops, tree seedlings, and tile recovery no longer iterate all entries
every frame. Each event stores an absolute gameTime timestamp (growsAt).
A sorted priority queue is drained each tick — only due items are touched.

WorldState now tracks gameTime (ms); stateManager.advanceTime(delta)
increments it each frame. Save version bumped 5→6 with migration.

Action log ring buffer (15 entries) added to LocalAdapter; shown in
the F3 debug panel under "Last Actions".

Closes #36
Closes #37
2026-03-24 08:08:05 +00:00
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
17 changed files with 1992 additions and 145 deletions

View File

@@ -7,9 +7,55 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- **Debug-Panel überlagert Nisse-Info-Panel** (Issue #41): F3-Debug-Panel weicht dynamisch aus — wenn das Nisse-Info-Panel offen ist, erscheint das Debug-Panel unterhalb davon statt darüber
- **Stockpile-Overlay Transparenz** (Issue #39): `updateStaticPanelOpacity()` verwendete `setAlpha()` statt `setFillStyle()` — dadurch wurde die Opacity quadratisch statt linear angewendet; bei 100 % blieb das Panel sichtbar transparent
- **Action Bar Transparenz** (Issue #40): Action Bar ignorierte `uiOpacity` komplett — Hintergrund war hardcoded auf 0.92; wird jetzt korrekt mit `uiOpacity` erstellt und per `updateStaticPanelOpacity()` live aktualisiert
### Performance
- **Event-queue timers** (Issue #36): crops, tree seedlings, and tile-recovery events now use a sorted priority queue with absolute `gameTime` timestamps instead of per-frame countdown iteration — O(due items) per tick instead of O(total items); `WorldState.gameTime` tracks the in-game clock; save migrated from v5 to v6
### Added
- **ESC Menu**: pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save
- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu
- **Action log in F3 debug panel** (Issue #37): last 15 actions dispatched through the adapter are shown in the F3 overlay under "Last Actions"; ring buffer maintained in `LocalAdapter`
### 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)

View File

@@ -73,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

@@ -8,12 +8,42 @@ export interface NetworkAdapter {
onAction?: (action: GameAction) => void
}
const ACTION_LOG_SIZE = 15
/** Singleplayer: apply actions immediately and synchronously */
export class LocalAdapter implements NetworkAdapter {
onAction?: (action: GameAction) => void
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
private _actionLog: string[] = []
send(action: GameAction): void {
stateManager.apply(action)
this._recordAction(action)
this.onAction?.(action)
}
/** Returns a copy of the recent action log (oldest first). */
getActionLog(): readonly string[] { return this._actionLog }
/**
* Appends a short summary of the action to the ring-buffer.
* @param action - The dispatched game action
*/
private _recordAction(action: GameAction): void {
let entry = action.type
if ('tileX' in action && 'tileY' in action)
entry += ` (${(action as any).tileX},${(action as any).tileY})`
else if ('villagerId' in action)
entry += ` v=…${(action as any).villagerId.slice(-4)}`
else if ('resourceId' in action)
entry += ` r=…${(action as any).resourceId.slice(-4)}`
else if ('cropId' in action)
entry += ` c=…${(action as any).cropId.slice(-4)}`
else if ('seedlingId' in action)
entry += ` s=…${(action as any).seedlingId.slice(-4)}`
if (this._actionLog.length >= ACTION_LOG_SIZE) this._actionLog.shift()
this._actionLog.push(entry)
}
}

View File

@@ -1,41 +1,215 @@
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'
// ─── Internal queue entry types ───────────────────────────────────────────────
/** Scheduled crop-growth entry. Two entries are created per stage (normal + watered path). */
interface CropEntry {
id: string
fireAt: number
expectedStage: number
/** If true this entry only fires when crop.watered === true. */
wateredPath: boolean
}
/** Scheduled seedling-growth entry. One entry per stage. */
interface SeedlingEntry {
id: string
fireAt: number
expectedStage: number
}
/** Scheduled tile-recovery entry. One entry per tile. */
interface RecoveryEntry {
key: string
fireAt: number
}
// ─── State factories ───────────────────────────────────────────────────────────
const DEFAULT_PLAYER: PlayerState = {
id: 'player1',
x: 8192, y: 8192,
inventory: {}, // empty — seeds now in stockpile
inventory: {},
}
function makeEmptyWorld(seed: number): WorldState {
return {
seed,
gameTime: 0,
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
resources: {},
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: 6,
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
}
}
// ─── StateManager ─────────────────────────────────────────────────────────────
class StateManager {
private state: GameStateData
// In-memory event queues (not persisted; rebuilt from state on load).
private cropQueue: CropEntry[] = []
private seedlingQueue: SeedlingEntry[] = []
private recoveryQueue: RecoveryEntry[] = []
constructor() {
this.state = this.load() ?? makeDefaultState()
this.rebuildQueues()
}
getState(): Readonly<GameStateData> { return this.state }
/** Returns the current accumulated in-game time in milliseconds. */
getGameTime(): number { return this.state.world.gameTime }
/**
* Advances the in-game clock by delta milliseconds.
* Must be called once per frame before any tick methods.
* @param delta - Frame delta in milliseconds
*/
advanceTime(delta: number): void {
this.state.world.gameTime += delta
}
// ─── Queue helpers ──────────────────────────────────────────────────────────
/**
* Inserts an entry into a sorted queue in ascending fireAt order.
* Uses binary search for O(log n) position find; O(n) splice insert.
*/
private static insertSorted<T extends { fireAt: number }>(queue: T[], entry: T): void {
let lo = 0, hi = queue.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (queue[mid].fireAt <= entry.fireAt) lo = mid + 1
else hi = mid
}
queue.splice(lo, 0, entry)
}
/** Enqueues both growth entries (normal + watered path) for a crop's current stage. */
private enqueueCropStage(id: string, expectedStage: number, growsAt: number, growsAtWatered: number): void {
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAt, expectedStage, wateredPath: false })
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAtWatered, expectedStage, wateredPath: true })
}
/**
* Rebuilds all three event queues from the persisted state.
* Called once after construction or load.
*/
private rebuildQueues(): void {
this.cropQueue = []
this.seedlingQueue = []
this.recoveryQueue = []
for (const crop of Object.values(this.state.world.crops)) {
if (crop.stage >= crop.maxStage) continue
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
}
for (const s of Object.values(this.state.world.treeSeedlings)) {
if (s.stage < 2) {
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
}
}
for (const [key, fireAt] of Object.entries(this.state.world.tileRecovery)) {
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
}
}
// ─── Tick methods ──────────────────────────────────────────────────────────
/**
* Drains the crop queue up to the current gameTime.
* Returns IDs of crops that advanced a stage this frame.
*/
tickCrops(): string[] {
const now = this.state.world.gameTime
const advanced: string[] = []
while (this.cropQueue.length > 0 && this.cropQueue[0].fireAt <= now) {
const entry = this.cropQueue.shift()!
const crop = this.state.world.crops[entry.id]
if (!crop || crop.stage !== entry.expectedStage) continue // already removed or stale stage
if (entry.wateredPath && !crop.watered) continue // fast-path skipped: not watered
crop.stage++
advanced.push(crop.id)
if (crop.stage < crop.maxStage) {
const cfg = CROP_CONFIGS[crop.kind]
crop.growsAt = now + cfg.stageTimeMs
crop.growsAtWatered = now + cfg.stageTimeMs / 2
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
}
}
return advanced
}
/**
* Drains the seedling queue up to the current gameTime.
* Returns IDs of seedlings that advanced a stage this frame.
*/
tickSeedlings(): string[] {
const now = this.state.world.gameTime
const advanced: string[] = []
while (this.seedlingQueue.length > 0 && this.seedlingQueue[0].fireAt <= now) {
const entry = this.seedlingQueue.shift()!
const s = this.state.world.treeSeedlings[entry.id]
if (!s || s.stage !== entry.expectedStage) continue // removed or stale
s.stage = Math.min(s.stage + 1, 2)
advanced.push(s.id)
if (s.stage < 2) {
s.growsAt = now + TREE_SEEDLING_STAGE_MS
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
}
}
return advanced
}
/**
* Drains the tile-recovery queue up to the current gameTime.
* Returns keys ("tileX,tileY") of tiles that have reverted to GRASS.
*/
tickTileRecovery(): string[] {
const now = this.state.world.gameTime
const recovered: string[] = []
while (this.recoveryQueue.length > 0 && this.recoveryQueue[0].fireAt <= now) {
const entry = this.recoveryQueue.shift()!
const fireAt = this.state.world.tileRecovery[entry.key]
// Skip if the entry was superseded (tile re-planted, resetting its fireAt)
if (fireAt === undefined || fireAt > now) continue
delete this.state.world.tileRecovery[entry.key]
recovered.push(entry.key)
const [tx, ty] = entry.key.split(',').map(Number)
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
}
return recovered
}
// ─── State mutations ───────────────────────────────────────────────────────
apply(action: GameAction): void {
const s = this.state
const w = s.world
@@ -62,11 +236,18 @@ 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))
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
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))
@@ -77,22 +258,24 @@ class StateManager {
w.crops[action.crop.id] = { ...action.crop }
const have = w.stockpile[action.seedItem] ?? 0
w.stockpile[action.seedItem] = Math.max(0, have - 1)
// Enqueue growth timers for both normal and watered paths
this.enqueueCropStage(action.crop.id, 0, action.crop.growsAt, action.crop.growsAtWatered)
break
}
case 'WATER_CROP': {
const c = w.crops[action.cropId]; if (c) c.watered = true; break
// No queue change needed — the wateredPath entry was enqueued at planting time
}
case 'HARVEST_CROP': {
delete w.crops[action.cropId]
for (const [k, v] of Object.entries(action.rewards))
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
// Stale queue entries will be skipped automatically (crop no longer exists)
break
}
// ── Villager actions ──────────────────────────────────────────────────
case 'SPAWN_VILLAGER':
w.villagers[action.villager.id] = { ...action.villager }; break
@@ -146,22 +329,44 @@ 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)
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
// Enqueue growth timer
StateManager.insertSorted(this.seedlingQueue, {
id: action.seedling.id, fireAt: action.seedling.growsAt, expectedStage: 0
})
break
}
case 'REMOVE_TREE_SEEDLING':
delete w.treeSeedlings[action.seedlingId]
// Stale queue entries will be skipped automatically
break
case 'SPAWN_RESOURCE':
w.resources[action.resource.id] = { ...action.resource }
break
case 'TILE_RECOVERY_START': {
const fireAt = w.gameTime + TILE_RECOVERY_MS
const key = `${action.tileX},${action.tileY}`
w.tileRecovery[key] = fireAt
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
break
}
case 'FORESTER_ZONE_UPDATE': {
const zone = w.foresterZones[action.buildingId]
if (zone) zone.tiles = [...action.tiles]
break
}
}
}
tickCrops(delta: number): string[] {
const advanced: string[] = []
for (const crop of Object.values(this.state.world.crops)) {
if (crop.stage >= crop.maxStage) continue
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
if (crop.stageTimerMs <= 0) {
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
advanced.push(crop.id)
}
}
return advanced
}
// ─── Persistence ───────────────────────────────────────────────────────────
save(): void {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
@@ -172,13 +377,44 @@ 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.world.crops) p.world.crops = {}
if (!p.world.villagers) p.world.villagers = {}
if (!p.world.stockpile) p.world.stockpile = {}
// Reset in-flight AI states to idle on load so runtime timers start fresh
// ── Migrate v5 → v6: countdown timers → absolute gameTime timestamps ──
if ((p.version as number) === 5) {
p.world.gameTime = 0
for (const crop of Object.values(p.world.crops)) {
const old = crop as any
const ms = old.stageTimerMs ?? CROP_CONFIGS[crop.kind]?.stageTimeMs ?? 20_000
crop.growsAt = ms
crop.growsAtWatered = ms / 2
delete old.stageTimerMs
}
for (const s of Object.values(p.world.treeSeedlings)) {
const old = s as any
s.growsAt = old.stageTimerMs ?? TREE_SEEDLING_STAGE_MS
delete old.stageTimerMs
}
// tileRecovery values were remaining-ms countdowns; with gameTime=0 they equal fireAt directly
p.version = 6
}
if (p.version !== 6) 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 = {}
if (!p.world.gameTime) p.world.gameTime = 0
for (const v of Object.values(p.world.villagers)) {
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
}
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 }
@@ -187,6 +423,7 @@ class StateManager {
reset(): void {
localStorage.removeItem(SAVE_KEY)
this.state = makeDefaultState()
this.rebuildQueues()
}
}

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
@@ -36,9 +40,10 @@ export const CROP_CONFIGS: Record<CropKind, CropConfig> = {
export const VILLAGER_SPEED = 75 // px/s — slow and visible
export const VILLAGER_SPAWN_INTERVAL = 8_000 // ms between spawn checks
export const VILLAGER_WORK_TIMES: Record<string, number> = {
chop: 3000,
mine: 5000,
farm: 1200,
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'
@@ -10,6 +11,8 @@ 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
@@ -20,6 +23,8 @@ export class GameScene extends Phaser.Scene {
private farmingSystem!: FarmingSystem
villagerSystem!: VillagerSystem
debugSystem!: DebugSystem
private treeSeedlingSystem!: TreeSeedlingSystem
foresterZoneSystem!: ForesterZoneSystem
private autosaveTimer = 0
private menuOpen = false
@@ -37,9 +42,11 @@ export class GameScene extends Phaser.Scene {
this.resourceSystem = new ResourceSystem(this, this.adapter)
this.buildingSystem = new BuildingSystem(this, this.adapter)
this.farmingSystem = new FarmingSystem(this, this.adapter)
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
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.adapter)
this.worldSystem.create()
this.renderPersistentObjects()
@@ -57,11 +64,23 @@ 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.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.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()
@@ -69,9 +88,30 @@ export class GameScene extends Phaser.Scene {
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))
@@ -80,9 +120,17 @@ 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
@@ -97,13 +145,24 @@ export class GameScene extends Phaser.Scene {
update(_time: number, delta: number): void {
if (this.menuOpen) return
// Advance the in-game clock first so all tick methods see the updated time
stateManager.advanceTime(delta)
this.cameraSystem.update(delta)
this.resourceSystem.update(delta)
this.farmingSystem.update(delta)
this.treeSeedlingSystem.update(delta)
this.villagerSystem.update(delta)
this.debugSystem.update()
// Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS
const recovered = stateManager.tickTileRecovery()
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()
@@ -123,15 +182,27 @@ 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)
}
}
}
@@ -143,6 +214,8 @@ export class GameScene extends Phaser.Scene {
this.resourceSystem.destroy()
this.buildingSystem.destroy()
this.farmingSystem.destroy()
this.treeSeedlingSystem.destroy()
this.foresterZoneSystem.destroy()
this.villagerSystem.destroy()
}
}

View File

@@ -3,10 +3,11 @@ import type { BuildingType, JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem'
import type { DebugData } from '../systems/DebugSystem'
import { stateManager } from '../StateManager'
import { UI_SETTINGS_KEY } from '../config'
const ITEM_ICONS: Record<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
wheat: '🌾', carrot: '🧡',
wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
}
export class UIScene extends Phaser.Scene {
@@ -21,8 +22,6 @@ export class UIScene extends Phaser.Scene {
private villagerPanelVisible = false
private buildModeText!: Phaser.GameObjects.Text
private farmToolText!: Phaser.GameObjects.Text
private coordsText!: Phaser.GameObjects.Text
private controlsHintText!: Phaser.GameObjects.Text
private popText!: Phaser.GameObjects.Text
private stockpileTitleText!: Phaser.GameObjects.Text
private contextMenuGroup!: Phaser.GameObjects.Group
@@ -35,6 +34,42 @@ export class UIScene extends Phaser.Scene {
private escMenuVisible = false
private confirmGroup!: Phaser.GameObjects.Group
private confirmVisible = false
private nisseInfoGroup!: Phaser.GameObjects.Group
private nisseInfoVisible = false
private nisseInfoId: string | null = null
private nisseInfoDynamic: {
statusText: Phaser.GameObjects.Text
energyBar: Phaser.GameObjects.Graphics
energyPct: Phaser.GameObjects.Text
jobText: Phaser.GameObjects.Text
logTexts: Phaser.GameObjects.Text[]
} | null = null
/** Current overlay background opacity (0.41.0, default 0.8). Persisted in localStorage. */
private uiOpacity = 0.8
private settingsGroup!: Phaser.GameObjects.Group
private settingsVisible = false
// ── Forester Hut Panel ────────────────────────────────────────────────────
private foresterPanelGroup!: Phaser.GameObjects.Group
private foresterPanelVisible = false
private foresterPanelBuildingId: string | null = null
/** Tile-count text inside the forester panel, updated live when zone changes. */
private foresterTileCountText: Phaser.GameObjects.Text | null = null
/** True while the zone-edit tool is active (shown in ESC priority stack). */
private inForesterZoneEdit = false
// ── Action Bar ────────────────────────────────────────────────────────────
private static readonly BAR_H = 48
private static readonly TRAY_H = 68
private actionBarBg!: Phaser.GameObjects.Rectangle
private actionBuildBtn!: Phaser.GameObjects.Rectangle
private actionBuildLabel!: Phaser.GameObjects.Text
private actionNisseBtn!: Phaser.GameObjects.Rectangle
private actionNisseLabel!: Phaser.GameObjects.Text
private actionTrayGroup!: Phaser.GameObjects.Group
private actionTrayVisible = false
private activeCategory: 'build' | 'nisse' | null = null
constructor() { super({ key: 'UI' }) }
@@ -43,21 +78,21 @@ export class UIScene extends Phaser.Scene {
* keyboard shortcuts (B, V, F3, ESC).
*/
create(): void {
this.loadUISettings()
this.createStockpilePanel()
this.createHintText()
this.createToast()
this.createBuildMenu()
this.createBuildModeIndicator()
this.createFarmToolIndicator()
this.createCoordsDisplay()
this.createDebugPanel()
this.createActionBar()
const gameScene = this.scene.get('Game')
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
gameScene.events.on('toast', (m: string) => this.showToast(m))
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
gameScene.events.on('cameraMoved', (pos: { tileX: number; tileY: number }) => this.onCameraMoved(pos))
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
@@ -68,10 +103,20 @@ export class UIScene extends Phaser.Scene {
this.scale.on('resize', () => this.repositionUI())
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.nisseInfoGroup = this.add.group()
this.settingsGroup = this.add.group()
this.foresterPanelGroup = this.add.group()
this.actionTrayGroup = this.add.group()
gameScene.events.on('foresterHutClicked', (id: string) => this.openForesterPanel(id))
gameScene.events.on('foresterZoneEditEnded', () => this.onForesterEditEnded())
gameScene.events.on('foresterZoneChanged', (id: string, tiles: string[]) => this.onForesterZoneChanged(id, tiles))
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) {
@@ -98,6 +143,7 @@ export class UIScene extends Phaser.Scene {
this.updateToast(delta)
this.updatePopText()
if (this.debugActive) this.updateDebugPanel()
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
}
// ─── Stockpile ────────────────────────────────────────────────────────────
@@ -105,14 +151,16 @@ export class UIScene extends Phaser.Scene {
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
private createStockpilePanel(): void {
const x = this.scale.width - 178, y = 10
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
// 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const
items.forEach((item, i) => {
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
this.stockpileTexts.set(item, t)
})
this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
// last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
this.popText = this.add.text(x + 10, y + 192, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
}
/** Refreshes all item quantities and colors in the stockpile panel. */
@@ -137,7 +185,7 @@ export class UIScene extends Phaser.Scene {
/** Creates the centered hint text element near the bottom of the screen. */
private createHintText(): void {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - UIScene.BAR_H - 24, '', {
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
backgroundColor: '#00000099', padding: { x: 10, y: 5 },
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false)
@@ -180,9 +228,10 @@ export class UIScene extends Phaser.Scene {
{ kind: 'chest', label: 'Chest', cost: '5 wood + 2 stone' },
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
]
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200)
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
this.buildMenuGroup.add(bg)
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
@@ -231,6 +280,10 @@ export class UIScene extends Phaser.Scene {
this.villagerPanelVisible = false
this.villagerPanelGroup?.destroy(true)
this.scene.get('Game').events.emit('uiMenuClose')
if (this.activeCategory === 'nisse') {
this.activeCategory = null
this.updateCategoryHighlights()
}
}
/**
@@ -243,13 +296,13 @@ export class UIScene extends Phaser.Scene {
const state = stateManager.getState()
const villagers = Object.values(state.world.villagers)
const panelW = 420
const panelW = 490
const rowH = 60
const panelH = Math.max(100, villagers.length * rowH + 50)
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210)
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
this.villagerPanelGroup.add(bg)
this.villagerPanelGroup.add(
@@ -285,12 +338,12 @@ export class UIScene extends Phaser.Scene {
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
this.villagerPanelGroup.add(eg)
// Job priority buttons: chop / mine / farm
// Job priority buttons: chop / mine / farm / forester
const jobs: Array<{ key: keyof JobPriorities; label: string }> = [
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }, { key: 'forester', label: '🌲' }
]
jobs.forEach((job, ji) => {
const bx = px + 110 + ji * 100
const bx = px + 110 + ji * 76
const pri = v.priorities[job.key]
const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}`
const btn = this.add.text(bx, ry + 6, label, {
@@ -347,32 +400,15 @@ export class UIScene extends Phaser.Scene {
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
}
// ─── Coords + controls ────────────────────────────────────────────────────
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
private createCoordsDisplay(): void {
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
}).setScrollFactor(0).setDepth(100)
}
/**
* Updates the tile-coordinate display when the camera moves.
* @param pos - Tile position of the camera center
*/
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
}
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void {
this.debugPanelText = this.add.text(10, 80, '', {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: '#000000cc',
backgroundColor: `#000000${hexAlpha}`,
padding: { x: 8, y: 6 },
lineSpacing: 2,
fontFamily: 'monospace',
@@ -383,9 +419,20 @@ export class UIScene extends Phaser.Scene {
private toggleDebugPanel(): void {
this.debugActive = !this.debugActive
this.debugPanelText.setVisible(this.debugActive)
this.repositionDebugPanel()
this.scene.get('Game').events.emit('debugToggle')
}
/**
* Repositions the debug panel to avoid overlapping the Nisse info panel.
* When the Nisse info panel is open, the debug panel shifts below it.
*/
private repositionDebugPanel(): void {
const NISSE_PANEL_H = 120 + 10 * 14 + 16 // matches buildNisseInfoPanel: 276px
const debugY = this.nisseInfoVisible ? 10 + NISSE_PANEL_H + 10 : 10
this.debugPanelText.setY(debugY)
}
/**
* Reads current debug data from DebugSystem and updates the panel text.
* Called every frame while debug mode is active.
@@ -427,6 +474,9 @@ export class UIScene extends Phaser.Scene {
'',
`Paths: ${data.activePaths} (cyan lines in world)`,
'',
'── Last Actions ───────────────',
...(data.actionLog.length > 0 ? data.actionLog : ['—']),
'',
'[F3] close',
])
}
@@ -449,7 +499,7 @@ export class UIScene extends Phaser.Scene {
const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg)
@@ -502,11 +552,16 @@ export class UIScene extends Phaser.Scene {
* esc menu → build/farm mode (handled by their own systems) → open ESC menu.
*/
private handleEsc(): void {
if (this.confirmVisible) { this.hideConfirm(); return }
if (this.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
if (this.escMenuVisible) { this.closeEscMenu(); return }
if (this.confirmVisible) { this.hideConfirm(); return }
if (this.inForesterZoneEdit) { this.scene.get('Game').events.emit('foresterZoneEditStop'); return }
if (this.foresterPanelVisible) { this.closeForesterPanel(); return }
if (this.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.actionTrayVisible) { this.closeActionTray(); return }
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
if (this.settingsVisible) { this.closeSettings(); return }
if (this.escMenuVisible) { this.closeEscMenu(); return }
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
// We only skip opening the ESC menu while those modes are active.
if (this.inBuildMode || this.inFarmMode) return
@@ -545,11 +600,13 @@ export class UIScene extends Phaser.Scene {
{ label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() },
]
const menuH = 16 + entries.length * (btnH + 8) + 8
const keysBlock = '[WASD] Pan [Scroll] Zoom\n[F] Farm [B] Build [V] Nisse\n[F3] Debug [ESC] Menu'
// 32px header + entries × (btnH + 8px gap) + 8px sep + 46px keys block + 12px bottom padding
const menuH = 32 + entries.length * (btnH + 8) + 8 + 46 + 12
const mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 2
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, 0.95)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
this.escMenuGroup.add(bg)
this.escMenuGroup.add(
@@ -572,6 +629,14 @@ export class UIScene extends Phaser.Scene {
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
)
})
// Keyboard shortcuts reference at the bottom of the menu
const keysY = my + 32 + entries.length * (btnH + 8) + 8
this.escMenuGroup.add(
this.add.text(mx + menuW / 2, keysY, keysBlock, {
fontSize: '10px', color: '#555555', fontFamily: 'monospace', align: 'center',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
)
}
/** Saves the game and shows a toast confirmation. */
@@ -587,10 +652,157 @@ export class UIScene extends Phaser.Scene {
window.location.reload()
}
/** Opens an empty Settings panel (placeholder). */
/** Opens the Settings overlay. */
private doSettings(): void {
this.closeEscMenu()
this.showToast('Settings — coming soon')
this.openSettings()
}
// ─── Settings overlay ─────────────────────────────────────────────────────
/** Opens the settings overlay if it is not already open. */
private openSettings(): void {
if (this.settingsVisible) return
this.settingsVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildSettings()
}
/** Closes and destroys the settings overlay. */
private closeSettings(): void {
if (!this.settingsVisible) return
this.settingsVisible = false
this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
* Builds the settings overlay with an overlay-opacity row (step buttons).
* Destroying and recreating this method is used to refresh the displayed value.
*/
private buildSettings(): void {
if (this.settingsGroup) this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
const panelW = 280
const panelH = 130
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
// Background
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
this.settingsGroup.add(bg)
// Title
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
)
// Opacity label
this.settingsGroup.add(
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
)
// Minus button
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
minusBtn.on('pointerdown', () => {
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(minusBtn)
this.settingsGroup.add(
this.add.text(px + 183, py + 58, '', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Value display
this.settingsGroup.add(
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
)
// Plus button
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
plusBtn.on('pointerdown', () => {
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(plusBtn)
this.settingsGroup.add(
this.add.text(px + 255, py + 58, '+', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Close button
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
closeBtnRect.on('pointerdown', () => this.closeSettings())
this.settingsGroup.add(closeBtnRect)
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 106, 'Close', {
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
}
/**
* Loads UI settings from localStorage and applies the stored opacity value.
* Falls back to the default (0.8) if no setting is found.
*/
private loadUISettings(): void {
try {
const raw = localStorage.getItem(UI_SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { opacity?: number }
if (typeof parsed.opacity === 'number') {
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
}
}
} catch (_) {}
}
/**
* Persists the current UI settings (opacity) to localStorage.
* Stored separately from the game save so New Game does not wipe it.
*/
private saveUISettings(): void {
try {
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
} catch (_) {}
}
/**
* Applies the current uiOpacity to all static UI elements that are not
* rebuilt on open (stockpile panel, action bar, debug panel background).
* Called whenever uiOpacity changes.
*/
private updateStaticPanelOpacity(): void {
this.stockpilePanel.setFillStyle(0x000000, this.uiOpacity)
this.actionBarBg.setFillStyle(0x080808, this.uiOpacity)
this.updateCategoryHighlights()
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
}
/** Shows a confirmation dialog before starting a new game. */
@@ -619,7 +831,7 @@ export class UIScene extends Phaser.Scene {
const dx = this.scale.width / 2 - dialogW / 2
const dy = this.scale.height / 2 - dialogH / 2
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, 0.97)
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
this.confirmGroup.add(bg)
@@ -667,6 +879,449 @@ export class UIScene extends Phaser.Scene {
this.scene.get('Game').events.emit('uiMenuClose')
}
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
/**
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
* If another Nisse's panel is already open, it is replaced.
* @param villagerId - ID of the Nisse to display
*/
private openNisseInfoPanel(villagerId: string): void {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.buildNisseInfoPanel()
this.repositionDebugPanel()
}
/** Closes and destroys the Nisse info panel. */
private closeNisseInfoPanel(): void {
if (!this.nisseInfoVisible) return
this.nisseInfoVisible = false
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.repositionDebugPanel()
}
/**
* Builds the static skeleton of the Nisse info panel (background, name, close
* button, labels, priority buttons) and stores references to the dynamic parts
* (status text, energy bar, job text, work log texts).
*/
private buildNisseInfoPanel(): void {
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.nisseInfoDynamic = null
const id = this.nisseInfoId
if (!id) return
const state = stateManager.getState()
const v = state.world.villagers[id]
if (!v) { this.closeNisseInfoPanel(); return }
const LOG_ROWS = 10
const panelW = 280
const panelH = 120 + LOG_ROWS * 14 + 16
const px = 10, py = 10
// Background
this.nisseInfoGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Name
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 10, v.name, {
fontSize: '14px', color: '#ffffff', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeNisseInfoPanel())
this.nisseInfoGroup.add(closeBtn)
// Dynamic: status text
const statusTxt = this.add.text(px + 10, py + 28, '', {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(statusTxt)
// Dynamic: energy bar + pct
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyBar)
const energyPct = this.add.text(px + 136, py + 46, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyPct)
// Dynamic: job text
const jobTxt = this.add.text(px + 10, py + 60, '', {
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(jobTxt)
// Static: priority label + buttons
const jobKeys: Array<{ key: string; icon: string }> = [
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, { key: 'forester', icon: '🌲' },
]
jobKeys.forEach((j, i) => {
const pri = v.priorities[j.key as keyof typeof v.priorities]
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
const bx = px + 10 + i * 66
const btn = this.add.text(bx, py + 78, label, {
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
padding: { x: 5, y: 3 },
}).setScrollFactor(0).setDepth(252).setInteractive()
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' }))
btn.on('pointerdown', () => {
const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5
const newPriorities = { ...v.priorities, [j.key]: newPri }
this.scene.get('Game').events.emit('updatePriorities', id, newPriorities)
// Rebuild panel so priority buttons reflect the new values immediately
this.buildNisseInfoPanel()
})
this.nisseInfoGroup.add(btn)
})
// Static: work log header
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 98, '── Work Log ──────────────────', {
fontSize: '10px', color: '#555555', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Dynamic: log text rows (pre-allocated)
const logTexts: Phaser.GameObjects.Text[] = []
for (let i = 0; i < LOG_ROWS; i++) {
const t = this.add.text(px + 10, py + 112 + i * 14, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(t)
logTexts.push(t)
}
this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts }
this.refreshNisseInfoPanel()
}
/**
* Updates only the dynamic parts of the Nisse info panel (status, energy,
* job, work log) without destroying and recreating the full group.
* Called every frame while the panel is visible.
*/
private refreshNisseInfoPanel(): void {
const dyn = this.nisseInfoDynamic
if (!dyn || !this.nisseInfoId) return
const state = stateManager.getState()
const v = state.world.villagers[this.nisseInfoId]
if (!v) { this.closeNisseInfoPanel(); return }
const gameScene = this.scene.get('Game') as any
const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[]
const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string
dyn.statusText.setText(statusStr)
// Energy bar
const px = 10, py = 10
dyn.energyBar.clear()
dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7)
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7)
dyn.energyPct.setText(`${Math.round(v.energy)}%`)
// Job
dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`)
// Work log rows
dyn.logTexts.forEach((t, i) => {
t.setText(workLog[i] ?? '')
})
}
// ─── Forester Hut Panel ───────────────────────────────────────────────────
/**
* Opens the forester hut info panel for the given building.
* If another forester panel is open it is replaced.
* @param buildingId - ID of the clicked forester_hut
*/
private openForesterPanel(buildingId: string): void {
this.foresterPanelBuildingId = buildingId
this.foresterPanelVisible = true
this.buildForesterPanel()
}
/** Closes and destroys the forester hut panel and exits zone edit mode if active. */
private closeForesterPanel(): void {
if (!this.foresterPanelVisible) return
if (this.inForesterZoneEdit) {
this.scene.get('Game').events.emit('foresterZoneEditStop')
}
this.foresterPanelVisible = false
this.foresterPanelBuildingId = null
this.foresterTileCountText = null
this.foresterPanelGroup.destroy(true)
this.foresterPanelGroup = this.add.group()
}
/**
* Builds the forester hut panel showing zone tile count and an edit-zone button.
* Positioned in the top-left corner (similar to the Nisse info panel).
*/
private buildForesterPanel(): void {
this.foresterPanelGroup.destroy(true)
this.foresterPanelGroup = this.add.group()
this.foresterTileCountText = null
const id = this.foresterPanelBuildingId
if (!id) return
const state = stateManager.getState()
const building = state.world.buildings[id]
if (!building) { this.closeForesterPanel(); return }
const zone = state.world.foresterZones[id]
const tileCount = zone?.tiles.length ?? 0
const panelW = 240
const panelH = 100
const px = 10, py = 10
// Background
this.foresterPanelGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x030a03, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Title
this.foresterPanelGroup.add(
this.add.text(px + 10, py + 10, '🌲 FORESTER HUT', {
fontSize: '13px', color: '#88dd88', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeForesterPanel())
this.foresterPanelGroup.add(closeBtn)
// Zone tile count (dynamic — updated via onForesterZoneChanged)
const countTxt = this.add.text(px + 10, py + 32, `Zone: ${tileCount} tile${tileCount === 1 ? '' : 's'} marked`, {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.foresterPanelGroup.add(countTxt)
this.foresterTileCountText = countTxt
// Edit zone button
const editLabel = this.inForesterZoneEdit ? '✅ Done editing' : '✏️ Edit Zone'
const editBtn = this.add.rectangle(px + 10, py + 54, panelW - 20, 30, 0x1a3a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(251).setInteractive()
editBtn.on('pointerover', () => editBtn.setFillStyle(0x2d6a4f, 0.9))
editBtn.on('pointerout', () => editBtn.setFillStyle(0x1a3a1a, 0.9))
editBtn.on('pointerdown', () => {
if (this.inForesterZoneEdit) {
this.scene.get('Game').events.emit('foresterZoneEditStop')
} else {
this.inForesterZoneEdit = true
this.scene.get('Game').events.emit('foresterZoneEditStart', id)
// Rebuild panel to show "Done editing" button
this.buildForesterPanel()
}
})
this.foresterPanelGroup.add(editBtn)
this.foresterPanelGroup.add(
this.add.text(px + panelW / 2, py + 69, editLabel, {
fontSize: '12px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(252)
)
}
/**
* Called when the ForesterZoneSystem signals that zone editing ended
* (via right-click, ESC, or the "Done" button).
*/
private onForesterEditEnded(): void {
this.inForesterZoneEdit = false
// Rebuild panel to switch button back to "Edit Zone"
if (this.foresterPanelVisible) this.buildForesterPanel()
}
/**
* Called when the zone tiles change so we can update the tile-count text live.
* @param buildingId - Building whose zone changed
* @param tiles - Updated tile array
*/
private onForesterZoneChanged(buildingId: string, tiles: string[]): void {
if (buildingId !== this.foresterPanelBuildingId) return
if (this.foresterTileCountText) {
const n = tiles.length
this.foresterTileCountText.setText(`Zone: ${n} tile${n === 1 ? '' : 's'} marked`)
}
}
// ─── Action Bar ───────────────────────────────────────────────────────────
/**
* Creates the persistent bottom action bar with Build and Nisse category buttons.
* The bar is always visible; individual button highlights change with the active category.
*/
private createActionBar(): void {
const { width, height } = this.scale
const barY = height - UIScene.BAR_H
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionBuildBtn.on('pointerover', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, this.uiOpacity)
})
this.actionBuildBtn.on('pointerout', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, this.uiOpacity)
})
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
this.actionBuildLabel = this.add.text(52, barY + UIScene.BAR_H / 2, '🔨 Build', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionNisseBtn.on('pointerover', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, this.uiOpacity)
})
this.actionNisseBtn.on('pointerout', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, this.uiOpacity)
})
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
}
/**
* Toggles the given action bar category on or off.
* Selecting the active category deselects it; selecting a new one closes the previous.
* @param cat - The category to toggle ('build' or 'nisse')
*/
private toggleCategory(cat: 'build' | 'nisse'): void {
if (this.activeCategory === cat) {
this.deactivateCategory()
return
}
// Close whatever was open before
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
this.activeCategory = cat
this.updateCategoryHighlights()
if (cat === 'build') {
this.openActionTray()
} else {
this.openVillagerPanel()
}
}
/**
* Deactivates the currently active category, closing its associated panel or tray.
*/
private deactivateCategory(): void {
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
this.activeCategory = null
this.updateCategoryHighlights()
}
/**
* Updates the visual highlight of the Build and Nisse buttons
* to reflect the current active category.
*/
private updateCategoryHighlights(): void {
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, this.uiOpacity)
}
/**
* Builds and shows the building tool tray above the action bar.
* Each building is shown as a clickable tile with emoji and name.
*/
private openActionTray(): void {
if (this.actionTrayVisible) return
this.actionTrayVisible = true
this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group()
this.actionBarBg.setAlpha(0)
const { width, height } = this.scale
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H + UIScene.BAR_H, 0x080808, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(299)
this.actionTrayGroup.add(bg)
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
{ kind: 'floor', emoji: '🪵', label: 'Floor' },
{ kind: 'wall', emoji: '🧱', label: 'Wall' },
{ kind: 'chest', emoji: '📦', label: 'Chest' },
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
]
const itemW = 84
buildings.forEach((b, i) => {
const bx = 8 + i * (itemW + 4)
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, this.uiOpacity))
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, this.uiOpacity))
btn.on('pointerdown', () => {
this.closeActionTray()
this.deactivateCategory()
this.scene.get('Game').events.emit('selectBuilding', b.kind)
})
this.actionTrayGroup.add(btn)
this.actionTrayGroup.add(
this.add.text(bx + itemW / 2, trayY + 18, b.emoji, { fontSize: '18px' })
.setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
)
this.actionTrayGroup.add(
this.add.text(bx + itemW / 2, trayY + 44, b.label, {
fontSize: '10px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
)
})
}
/**
* Hides and destroys the building tool tray.
*/
private closeActionTray(): void {
if (!this.actionTrayVisible) return
this.actionTrayVisible = false
this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group()
this.actionBarBg.setAlpha(1)
}
// ─── Resize ───────────────────────────────────────────────────────────────
/**
@@ -687,17 +1342,25 @@ export class UIScene extends Phaser.Scene {
}
// Bottom elements
this.hintText.setPosition(width / 2, height - 40)
this.hintText.setPosition(width / 2, height - UIScene.BAR_H - 24)
this.toastText.setPosition(width / 2, 60)
this.coordsText.setPosition(10, height - 24)
this.controlsHintText.setPosition(10, height - 42)
// Action bar — reposition persistent elements
this.actionBarBg.setPosition(0, height - UIScene.BAR_H).setSize(width, UIScene.BAR_H)
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
this.actionNisseLabel.setPosition(144, height - UIScene.BAR_H + UIScene.BAR_H / 2)
if (this.actionTrayVisible) this.closeActionTray()
// Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize
if (this.buildMenuVisible) this.closeBuildMenu()
if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu()
if (this.settingsVisible) this.closeSettings()
if (this.confirmVisible) this.hideConfirm()
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
if (this.foresterPanelVisible) this.closeForesterPanel()
}
}

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

@@ -2,6 +2,7 @@ import Phaser from 'phaser'
import { TILE_SIZE } from '../config'
import { TileType } from '../types'
import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter'
import type { VillagerSystem } from './VillagerSystem'
import type { WorldSystem } from './WorldSystem'
@@ -18,6 +19,8 @@ export interface DebugData {
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
jobsByType: { chop: number; mine: number; farm: number }
activePaths: number
/** Recent actions dispatched through the adapter (newest last). */
actionLog: readonly string[]
}
/** Human-readable names for TileType enum values. */
@@ -39,6 +42,7 @@ export class DebugSystem {
private scene: Phaser.Scene
private villagerSystem: VillagerSystem
private worldSystem: WorldSystem
private adapter: LocalAdapter
private pathGraphics!: Phaser.GameObjects.Graphics
private active = false
@@ -46,11 +50,13 @@ export class DebugSystem {
* @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
* @param adapter - Used to read the recent action log
*/
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem, adapter: LocalAdapter) {
this.scene = scene
this.villagerSystem = villagerSystem
this.worldSystem = worldSystem
this.adapter = adapter
}
/**
@@ -159,6 +165,7 @@ export class DebugSystem {
nisseByState,
jobsByType,
activePaths: this.villagerSystem.getActivePaths().length,
actionLog: this.adapter.getActionLog(),
}
}
}

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
@@ -65,8 +74,8 @@ export class FarmingSystem {
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
}
// Tick crop growth
const leveled = stateManager.tickCrops(delta)
// Drain crop growth queue (no delta — gameTime is advanced by GameScene)
const leveled = stateManager.tickCrops()
for (const id of leveled) this.refreshCropSprite(id)
}
@@ -87,9 +96,27 @@ export class FarmingSystem {
const state = stateManager.getState()
const tile = state.world.tiles[tileY * 512 + tileX] as TileType
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
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 {
@@ -124,11 +151,13 @@ export class FarmingSystem {
}
const cfg = CROP_CONFIGS[kind]
const now = stateManager.getGameTime()
const crop: CropState = {
id: `crop_${tileX}_${tileY}_${Date.now()}`,
tileX, tileY, kind,
stage: 0, maxStage: cfg.stages,
stageTimerMs: cfg.stageTimeMs,
growsAt: now + cfg.stageTimeMs,
growsAtWatered: now + cfg.stageTimeMs / 2,
watered: tile === TileType.WATERED_SOIL,
}
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })

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,131 @@
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 {
// Drain seedling growth queue (no delta — gameTime is advanced by GameScene)
const advanced = stateManager.tickSeedlings()
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,
growsAt: stateManager.getGameTime() + 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,6 +42,13 @@ 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
@@ -107,15 +121,15 @@ 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)
}
@@ -139,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
@@ -156,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
@@ -218,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:
@@ -258,17 +283,24 @@ 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 })
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
// and valid for mine building placement.
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]
@@ -276,6 +308,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})`)
}
}
@@ -304,6 +351,7 @@ 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)')
}
}
@@ -316,6 +364,15 @@ export class VillagerSystem {
* @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
@@ -323,32 +380,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)) {
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 })
// 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
// 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
@@ -375,7 +484,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
}
@@ -412,6 +521,22 @@ 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 ─────────────────────────────────────────────────────────────
/**
@@ -439,7 +564,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',
}
@@ -456,18 +581,28 @@ export class VillagerSystem {
* 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: [] })
}
/**
@@ -486,6 +621,21 @@ 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 ───────────────────────────────────────────────────────────
/**
@@ -498,7 +648,10 @@ export class VillagerSystem {
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
@@ -506,6 +659,15 @@ 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
@@ -524,6 +686,14 @@ export class VillagerSystem {
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.

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,9 +18,16 @@ 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) {
@@ -35,10 +42,8 @@ export class WorldSystem {
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++) {
@@ -48,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({
@@ -84,6 +91,8 @@ 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). */
@@ -110,6 +119,10 @@ 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
@@ -118,7 +131,55 @@ export class WorldSystem {
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)
}
/**
@@ -157,6 +218,21 @@ export class WorldSystem {
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()

View File

@@ -12,28 +12,39 @@ 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'
export interface JobPriorities {
chop: number // 0 = disabled, 1 = highest, 4 = lowest
chop: number // 0 = disabled, 1 = highest, 4 = lowest
mine: number
farm: number
forester: number // plant tree seedlings in forester zones
}
export interface VillagerJob {
@@ -79,7 +90,11 @@ export interface CropState {
kind: CropKind
stage: number
maxStage: number
stageTimerMs: number
/** gameTime (ms) when this stage fires at normal (unwatered) speed. */
growsAt: number
/** gameTime (ms) when this stage fires if the crop is watered (half normal time).
* Both entries are enqueued at plant/stage-advance time; the stale one is skipped. */
growsAtWatered: number
watered: boolean
}
@@ -90,14 +105,47 @@ 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
/** gameTime (ms) when this seedling advances to the next stage. */
growsAt: 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
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
gameTime: number
tiles: number[]
resources: Record<string, ResourceNodeState>
buildings: Record<string, BuildingState>
crops: Record<string, CropState>
villagers: Record<string, VillagerState>
stockpile: Partial<Record<ItemId, number>>
/** Planted tree seedlings, keyed by ID. */
treeSeedlings: Record<string, TreeSeedlingState>
/**
* Tile recovery fire-times, keyed by "tileX,tileY".
* Value is the gameTime (ms) at which 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 +171,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[] }