40 Commits

Author SHA1 Message Date
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 1975 additions and 143 deletions

View File

@@ -7,9 +7,54 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **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 ### 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 - **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`
- 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
### 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 ### 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) - **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()` - **Systems** read/write state and are updated each game tick via Phaser's `update()`
- **Scenes** are thin orchestrators — logic belongs in systems, not scenes - **Scenes** are thin orchestrators — logic belongs in systems, not scenes
- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly - **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 onAction?: (action: GameAction) => void
} }
const ACTION_LOG_SIZE = 15
/** Singleplayer: apply actions immediately and synchronously */ /** Singleplayer: apply actions immediately and synchronously */
export class LocalAdapter implements NetworkAdapter { export class LocalAdapter implements NetworkAdapter {
onAction?: (action: GameAction) => void onAction?: (action: GameAction) => void
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
private _actionLog: string[] = []
send(action: GameAction): void { send(action: GameAction): void {
stateManager.apply(action) stateManager.apply(action)
this._recordAction(action)
this.onAction?.(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 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 = { const DEFAULT_PLAYER: PlayerState = {
id: 'player1', id: 'player1',
x: 8192, y: 8192, x: 8192, y: 8192,
inventory: {}, // empty — seeds now in stockpile inventory: {},
} }
function makeEmptyWorld(seed: number): WorldState { function makeEmptyWorld(seed: number): WorldState {
return { return {
seed, seed,
gameTime: 0,
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3), tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
resources: {}, resources: {},
buildings: {}, buildings: {},
crops: {}, crops: {},
villagers: {}, villagers: {},
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 }, stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
treeSeedlings: {},
tileRecovery: {},
foresterZones: {},
} }
} }
function makeDefaultState(): GameStateData { function makeDefaultState(): GameStateData {
return { return {
version: 4, version: 6,
world: makeEmptyWorld(Math.floor(Math.random() * 999999)), world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
} }
} }
// ─── StateManager ─────────────────────────────────────────────────────────────
class StateManager { class StateManager {
private state: GameStateData private state: GameStateData
// In-memory event queues (not persisted; rebuilt from state on load).
private cropQueue: CropEntry[] = []
private seedlingQueue: SeedlingEntry[] = []
private recoveryQueue: RecoveryEntry[] = []
constructor() { constructor() {
this.state = this.load() ?? makeDefaultState() this.state = this.load() ?? makeDefaultState()
this.rebuildQueues()
} }
getState(): Readonly<GameStateData> { return this.state } 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 { apply(action: GameAction): void {
const s = this.state const s = this.state
const w = s.world const w = s.world
@@ -62,11 +236,18 @@ class StateManager {
w.buildings[action.building.id] = action.building w.buildings[action.building.id] = action.building
for (const [k, v] of Object.entries(action.costs)) 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)) 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 break
} }
case 'REMOVE_BUILDING': 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': case 'ADD_ITEMS':
for (const [k, v] of Object.entries(action.items)) for (const [k, v] of Object.entries(action.items))
@@ -77,22 +258,24 @@ class StateManager {
w.crops[action.crop.id] = { ...action.crop } w.crops[action.crop.id] = { ...action.crop }
const have = w.stockpile[action.seedItem] ?? 0 const have = w.stockpile[action.seedItem] ?? 0
w.stockpile[action.seedItem] = Math.max(0, have - 1) 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 break
} }
case 'WATER_CROP': { case 'WATER_CROP': {
const c = w.crops[action.cropId]; if (c) c.watered = true; break 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': { case 'HARVEST_CROP': {
delete w.crops[action.cropId] delete w.crops[action.cropId]
for (const [k, v] of Object.entries(action.rewards)) for (const [k, v] of Object.entries(action.rewards))
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) 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 break
} }
// ── Villager actions ──────────────────────────────────────────────────
case 'SPAWN_VILLAGER': case 'SPAWN_VILLAGER':
w.villagers[action.villager.id] = { ...action.villager }; break w.villagers[action.villager.id] = { ...action.villager }; break
@@ -146,22 +329,44 @@ class StateManager {
if (v) v.priorities = { ...action.priorities } if (v) v.priorities = { ...action.priorities }
break break
} }
case 'PLANT_TREE_SEED': {
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
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[] { // ─── Persistence ───────────────────────────────────────────────────────────
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
}
save(): void { save(): void {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
@@ -172,13 +377,44 @@ class StateManager {
const raw = localStorage.getItem(SAVE_KEY) const raw = localStorage.getItem(SAVE_KEY)
if (!raw) return null if (!raw) return null
const p = JSON.parse(raw) as GameStateData const p = JSON.parse(raw) as GameStateData
if (p.version !== 4) return null
// ── 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.crops) p.world.crops = {}
if (!p.world.villagers) p.world.villagers = {} if (!p.world.villagers) p.world.villagers = {}
if (!p.world.stockpile) p.world.stockpile = {} if (!p.world.stockpile) p.world.stockpile = {}
// Reset in-flight AI states to idle on load so runtime timers start fresh 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)) { for (const v of Object.values(p.world.villagers)) {
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle' 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 return p
} catch (_) { return null } } catch (_) { return null }
@@ -187,6 +423,7 @@ class StateManager {
reset(): void { reset(): void {
localStorage.removeItem(SAVE_KEY) localStorage.removeItem(SAVE_KEY)
this.state = makeDefaultState() 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 }, chest: { wood: 5, stone: 2 },
bed: { wood: 6 }, bed: { wood: 6 },
stockpile_zone:{ wood: 0 }, 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 { export interface CropConfig {
stages: number stages: number
stageTimeMs: number stageTimeMs: number
@@ -39,6 +43,7 @@ export const VILLAGER_WORK_TIMES: Record<string, number> = {
chop: 3000, chop: 3000,
mine: 5000, mine: 5000,
farm: 1200, farm: 1200,
forester: 2000,
} }
export const VILLAGER_NAMES = [ export const VILLAGER_NAMES = [
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta', 'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
@@ -46,5 +51,14 @@ export const VILLAGER_NAMES = [
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex', 'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
] ]
export const SAVE_KEY = 'tg_save_v4' export const SAVE_KEY = 'tg_save_v5'
export const AUTOSAVE_INTERVAL = 30_000 export const AUTOSAVE_INTERVAL = 30_000
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
export const UI_SETTINGS_KEY = 'tg_ui_settings'
/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */
export const TILE_RECOVERY_MS = 300_000 // 5 minutes

View File

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

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config' import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
import { TileType } from '../types'
import type { BuildingType } from '../types' import type { BuildingType } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import { LocalAdapter } from '../NetworkAdapter' import { LocalAdapter } from '../NetworkAdapter'
@@ -10,6 +11,8 @@ import { BuildingSystem } from '../systems/BuildingSystem'
import { FarmingSystem } from '../systems/FarmingSystem' import { FarmingSystem } from '../systems/FarmingSystem'
import { VillagerSystem } from '../systems/VillagerSystem' import { VillagerSystem } from '../systems/VillagerSystem'
import { DebugSystem } from '../systems/DebugSystem' import { DebugSystem } from '../systems/DebugSystem'
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
import { ForesterZoneSystem } from '../systems/ForesterZoneSystem'
export class GameScene extends Phaser.Scene { export class GameScene extends Phaser.Scene {
private adapter!: LocalAdapter private adapter!: LocalAdapter
@@ -20,6 +23,8 @@ export class GameScene extends Phaser.Scene {
private farmingSystem!: FarmingSystem private farmingSystem!: FarmingSystem
villagerSystem!: VillagerSystem villagerSystem!: VillagerSystem
debugSystem!: DebugSystem debugSystem!: DebugSystem
private treeSeedlingSystem!: TreeSeedlingSystem
foresterZoneSystem!: ForesterZoneSystem
private autosaveTimer = 0 private autosaveTimer = 0
private menuOpen = false private menuOpen = false
@@ -39,7 +44,9 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem = new FarmingSystem(this, this.adapter) this.farmingSystem = new FarmingSystem(this, this.adapter)
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
this.villagerSystem.init(this.resourceSystem, this.farmingSystem) this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) this.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.worldSystem.create()
this.renderPersistentObjects() this.renderPersistentObjects()
@@ -59,9 +66,21 @@ export class GameScene extends Phaser.Scene {
this.farmingSystem.create() this.farmingSystem.create()
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg) this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) =>
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
this.treeSeedlingSystem.create()
this.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.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() this.debugSystem.create()
@@ -69,9 +88,30 @@ export class GameScene extends Phaser.Scene {
this.adapter.onAction = (action) => { this.adapter.onAction = (action) => {
if (action.type === 'CHANGE_TILE') { if (action.type === 'CHANGE_TILE') {
this.worldSystem.setTile(action.tileX, action.tileY, action.tile) this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
} else if (action.type === 'SPAWN_RESOURCE') {
this.resourceSystem.spawnResourcePublic(action.resource)
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
} 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.scene.launch('UI')
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind)) this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
@@ -80,9 +120,17 @@ export class GameScene extends Phaser.Scene {
this.events.on('uiRequestBuildMenu', () => { this.events.on('uiRequestBuildMenu', () => {
if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu') 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.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.events.on('debugToggle', () => this.debugSystem.toggle())
this.autosaveTimer = AUTOSAVE_INTERVAL this.autosaveTimer = AUTOSAVE_INTERVAL
@@ -97,13 +145,24 @@ export class GameScene extends Phaser.Scene {
update(_time: number, delta: number): void { update(_time: number, delta: number): void {
if (this.menuOpen) return 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.cameraSystem.update(delta)
this.resourceSystem.update(delta) this.resourceSystem.update(delta)
this.farmingSystem.update(delta) this.farmingSystem.update(delta)
this.treeSeedlingSystem.update(delta)
this.villagerSystem.update(delta) this.villagerSystem.update(delta)
this.debugSystem.update() 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.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
this.buildingSystem.update() this.buildingSystem.update()
@@ -123,15 +182,27 @@ export class GameScene extends Phaser.Scene {
const name = `bobj_${building.id}` const name = `bobj_${building.id}`
if (this.children.getByName(name)) continue if (this.children.getByName(name)) continue
const worldDepth = building.tileY + 5
if (building.kind === 'chest') { 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(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6) g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14) g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
} else if (building.kind === 'bed') { } 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') { } else if (building.kind === 'stockpile_zone') {
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8) 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.resourceSystem.destroy()
this.buildingSystem.destroy() this.buildingSystem.destroy()
this.farmingSystem.destroy() this.farmingSystem.destroy()
this.treeSeedlingSystem.destroy()
this.foresterZoneSystem.destroy()
this.villagerSystem.destroy() this.villagerSystem.destroy()
} }
} }

View File

@@ -3,10 +3,11 @@ import type { BuildingType, JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem' import type { FarmingTool } from '../systems/FarmingSystem'
import type { DebugData } from '../systems/DebugSystem' import type { DebugData } from '../systems/DebugSystem'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import { UI_SETTINGS_KEY } from '../config'
const ITEM_ICONS: Record<string, string> = { const ITEM_ICONS: Record<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕', wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
wheat: '🌾', carrot: '🧡', wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
} }
export class UIScene extends Phaser.Scene { export class UIScene extends Phaser.Scene {
@@ -21,8 +22,6 @@ export class UIScene extends Phaser.Scene {
private villagerPanelVisible = false private villagerPanelVisible = false
private buildModeText!: Phaser.GameObjects.Text private buildModeText!: Phaser.GameObjects.Text
private farmToolText!: Phaser.GameObjects.Text private farmToolText!: Phaser.GameObjects.Text
private coordsText!: Phaser.GameObjects.Text
private controlsHintText!: Phaser.GameObjects.Text
private popText!: Phaser.GameObjects.Text private popText!: Phaser.GameObjects.Text
private stockpileTitleText!: Phaser.GameObjects.Text private stockpileTitleText!: Phaser.GameObjects.Text
private contextMenuGroup!: Phaser.GameObjects.Group private contextMenuGroup!: Phaser.GameObjects.Group
@@ -35,6 +34,42 @@ export class UIScene extends Phaser.Scene {
private escMenuVisible = false private escMenuVisible = false
private confirmGroup!: Phaser.GameObjects.Group private confirmGroup!: Phaser.GameObjects.Group
private confirmVisible = false 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' }) } constructor() { super({ key: 'UI' }) }
@@ -43,21 +78,21 @@ export class UIScene extends Phaser.Scene {
* keyboard shortcuts (B, V, F3, ESC). * keyboard shortcuts (B, V, F3, ESC).
*/ */
create(): void { create(): void {
this.loadUISettings()
this.createStockpilePanel() this.createStockpilePanel()
this.createHintText() this.createHintText()
this.createToast() this.createToast()
this.createBuildMenu() this.createBuildMenu()
this.createBuildModeIndicator() this.createBuildModeIndicator()
this.createFarmToolIndicator() this.createFarmToolIndicator()
this.createCoordsDisplay()
this.createDebugPanel() this.createDebugPanel()
this.createActionBar()
const gameScene = this.scene.get('Game') const gameScene = this.scene.get('Game')
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b)) gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l)) gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
gameScene.events.on('toast', (m: string) => this.showToast(m)) gameScene.events.on('toast', (m: string) => this.showToast(m))
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu()) 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) this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
.on('down', () => gameScene.events.emit('uiRequestBuildMenu')) .on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
@@ -68,10 +103,20 @@ export class UIScene extends Phaser.Scene {
this.scale.on('resize', () => this.repositionUI()) this.scale.on('resize', () => this.repositionUI())
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
this.input.mouse!.disableContextMenu() this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group() this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group() this.escMenuGroup = this.add.group()
this.confirmGroup = 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) => { this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) { if (ptr.rightButtonDown()) {
@@ -98,6 +143,7 @@ export class UIScene extends Phaser.Scene {
this.updateToast(delta) this.updateToast(delta)
this.updatePopText() this.updatePopText()
if (this.debugActive) this.updateDebugPanel() if (this.debugActive) this.updateDebugPanel()
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
} }
// ─── Stockpile ──────────────────────────────────────────────────────────── // ─── 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. */ /** Creates the stockpile panel in the top-right corner with item rows and population count. */
private createStockpilePanel(): void { private createStockpilePanel(): void {
const x = this.scale.width - 178, y = 10 const x = this.scale.width - 178, y = 10
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) // 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
this.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) 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) => { items.forEach((item, i) => {
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
this.stockpileTexts.set(item, t) this.stockpileTexts.set(item, t)
}) })
this.popText = this.add.text(x + 10, y + 145, '👥 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. */ /** 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. */ /** Creates the centered hint text element near the bottom of the screen. */
private createHintText(): void { private createHintText(): void {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', { this.hintText = this.add.text(this.scale.width / 2, this.scale.height - UIScene.BAR_H - 24, '', {
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace', fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
backgroundColor: '#00000099', padding: { x: 10, y: 5 }, backgroundColor: '#00000099', padding: { x: 10, y: 5 },
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false) }).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: 'chest', label: 'Chest', cost: '5 wood + 2 stone' },
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' }, { kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' }, { 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 menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200) const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
this.buildMenuGroup.add(bg) this.buildMenuGroup.add(bg)
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201)) this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
@@ -231,6 +280,10 @@ export class UIScene extends Phaser.Scene {
this.villagerPanelVisible = false this.villagerPanelVisible = false
this.villagerPanelGroup?.destroy(true) this.villagerPanelGroup?.destroy(true)
this.scene.get('Game').events.emit('uiMenuClose') this.scene.get('Game').events.emit('uiMenuClose')
if (this.activeCategory === 'nisse') {
this.activeCategory = null
this.updateCategoryHighlights()
}
} }
/** /**
@@ -243,13 +296,13 @@ export class UIScene extends Phaser.Scene {
const state = stateManager.getState() const state = stateManager.getState()
const villagers = Object.values(state.world.villagers) const villagers = Object.values(state.world.villagers)
const panelW = 420 const panelW = 490
const rowH = 60 const rowH = 60
const panelH = Math.max(100, villagers.length * rowH + 50) const panelH = Math.max(100, villagers.length * rowH + 50)
const px = this.scale.width / 2 - panelW / 2 const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2 const py = this.scale.height / 2 - panelH / 2
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210) const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
this.villagerPanelGroup.add(bg) this.villagerPanelGroup.add(bg)
this.villagerPanelGroup.add( this.villagerPanelGroup.add(
@@ -285,12 +338,12 @@ export class UIScene extends Phaser.Scene {
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6) eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
this.villagerPanelGroup.add(eg) 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 }> = [ 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) => { jobs.forEach((job, ji) => {
const bx = px + 110 + ji * 100 const bx = px + 110 + ji * 76
const pri = v.priorities[job.key] const pri = v.priorities[job.key]
const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}` const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}`
const btn = this.add.text(bx, ry + 6, label, { 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') 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) ───────────────────────────────────────────────────── // ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */ /** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void { private createDebugPanel(): void {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 80, '', { this.debugPanelText = this.add.text(10, 80, '', {
fontSize: '12px', fontSize: '12px',
color: '#cccccc', color: '#cccccc',
backgroundColor: '#000000cc', backgroundColor: `#000000${hexAlpha}`,
padding: { x: 8, y: 6 }, padding: { x: 8, y: 6 },
lineSpacing: 2, lineSpacing: 2,
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -427,6 +463,9 @@ export class UIScene extends Phaser.Scene {
'', '',
`Paths: ${data.activePaths} (cyan lines in world)`, `Paths: ${data.activePaths} (cyan lines in world)`,
'', '',
'── Last Actions ───────────────',
...(data.actionLog.length > 0 ? data.actionLog : ['—']),
'',
'[F3] close', '[F3] close',
]) ])
} }
@@ -449,7 +488,7 @@ export class UIScene extends Phaser.Scene {
const mx = Math.min(x, this.scale.width - menuW - 4) const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4) const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88) const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300) .setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg) this.contextMenuGroup.add(bg)
@@ -503,9 +542,14 @@ export class UIScene extends Phaser.Scene {
*/ */
private handleEsc(): void { private handleEsc(): void {
if (this.confirmVisible) { this.hideConfirm(); 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.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return } if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.actionTrayVisible) { this.closeActionTray(); return }
if (this.villagerPanelVisible) { this.closeVillagerPanel(); 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 } if (this.escMenuVisible) { this.closeEscMenu(); return }
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key. // Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
// We only skip opening the ESC menu while those modes are active. // We only skip opening the ESC menu while those modes are active.
@@ -545,11 +589,13 @@ export class UIScene extends Phaser.Scene {
{ label: '⚙️ Settings', action: () => this.doSettings() }, { label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() }, { 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 mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 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) .setOrigin(0, 0).setScrollFactor(0).setDepth(400)
this.escMenuGroup.add(bg) this.escMenuGroup.add(bg)
this.escMenuGroup.add( this.escMenuGroup.add(
@@ -572,6 +618,14 @@ export class UIScene extends Phaser.Scene {
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402) }).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. */ /** Saves the game and shows a toast confirmation. */
@@ -587,10 +641,157 @@ export class UIScene extends Phaser.Scene {
window.location.reload() window.location.reload()
} }
/** Opens an empty Settings panel (placeholder). */ /** Opens the Settings overlay. */
private doSettings(): void { private doSettings(): void {
this.closeEscMenu() 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. */ /** Shows a confirmation dialog before starting a new game. */
@@ -619,7 +820,7 @@ export class UIScene extends Phaser.Scene {
const dx = this.scale.width / 2 - dialogW / 2 const dx = this.scale.width / 2 - dialogW / 2
const dy = this.scale.height / 2 - dialogH / 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) .setOrigin(0, 0).setScrollFactor(0).setDepth(500)
this.confirmGroup.add(bg) this.confirmGroup.add(bg)
@@ -667,6 +868,447 @@ export class UIScene extends Phaser.Scene {
this.scene.get('Game').events.emit('uiMenuClose') this.scene.get('Game').events.emit('uiMenuClose')
} }
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
/**
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
* If another Nisse's panel is already open, it is replaced.
* @param villagerId - ID of the Nisse to display
*/
private openNisseInfoPanel(villagerId: string): void {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.buildNisseInfoPanel()
}
/** Closes and destroys the Nisse info panel. */
private closeNisseInfoPanel(): void {
if (!this.nisseInfoVisible) return
this.nisseInfoVisible = false
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
}
/**
* Builds the static skeleton of the Nisse info panel (background, name, close
* button, labels, priority buttons) and stores references to the dynamic parts
* (status text, energy bar, job text, work log texts).
*/
private buildNisseInfoPanel(): void {
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.nisseInfoDynamic = null
const id = this.nisseInfoId
if (!id) return
const state = stateManager.getState()
const v = state.world.villagers[id]
if (!v) { this.closeNisseInfoPanel(); return }
const LOG_ROWS = 10
const panelW = 280
const panelH = 120 + LOG_ROWS * 14 + 16
const px = 10, py = 10
// Background
this.nisseInfoGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Name
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 10, v.name, {
fontSize: '14px', color: '#ffffff', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeNisseInfoPanel())
this.nisseInfoGroup.add(closeBtn)
// Dynamic: status text
const statusTxt = this.add.text(px + 10, py + 28, '', {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(statusTxt)
// Dynamic: energy bar + pct
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyBar)
const energyPct = this.add.text(px + 136, py + 46, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyPct)
// Dynamic: job text
const jobTxt = this.add.text(px + 10, py + 60, '', {
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(jobTxt)
// Static: priority label + buttons
const jobKeys: Array<{ key: string; icon: string }> = [
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, { 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 ─────────────────────────────────────────────────────────────── // ─── Resize ───────────────────────────────────────────────────────────────
/** /**
@@ -687,17 +1329,25 @@ export class UIScene extends Phaser.Scene {
} }
// Bottom elements // 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.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 // Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize // would be off-center if left open during a resize
if (this.buildMenuVisible) this.closeBuildMenu() if (this.buildMenuVisible) this.closeBuildMenu()
if (this.villagerPanelVisible) this.closeVillagerPanel() if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu() if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu() if (this.escMenuVisible) this.closeEscMenu()
if (this.settingsVisible) this.closeSettings()
if (this.confirmVisible) this.hideConfirm() 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 { create(): void {
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35) 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.setVisible(false)
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8) this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
@@ -40,7 +40,7 @@ export class BuildingSystem {
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace', fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#000000aa', padding: { x: 3, y: 2 } backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
}) })
this.ghostLabel.setDepth(21) this.ghostLabel.setDepth(1001)
this.ghostLabel.setVisible(false) this.ghostLabel.setVisible(false)
this.ghostLabel.setOrigin(0.5, 1) this.ghostLabel.setOrigin(0.5, 1)

View File

@@ -2,6 +2,7 @@ import Phaser from 'phaser'
import { TILE_SIZE } from '../config' import { TILE_SIZE } from '../config'
import { TileType } from '../types' import { TileType } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter'
import type { VillagerSystem } from './VillagerSystem' import type { VillagerSystem } from './VillagerSystem'
import type { WorldSystem } from './WorldSystem' import type { WorldSystem } from './WorldSystem'
@@ -18,6 +19,8 @@ export interface DebugData {
nisseByState: { idle: number; walking: number; working: number; sleeping: number } nisseByState: { idle: number; walking: number; working: number; sleeping: number }
jobsByType: { chop: number; mine: number; farm: number } jobsByType: { chop: number; mine: number; farm: number }
activePaths: number activePaths: number
/** Recent actions dispatched through the adapter (newest last). */
actionLog: readonly string[]
} }
/** Human-readable names for TileType enum values. */ /** Human-readable names for TileType enum values. */
@@ -39,6 +42,7 @@ export class DebugSystem {
private scene: Phaser.Scene private scene: Phaser.Scene
private villagerSystem: VillagerSystem private villagerSystem: VillagerSystem
private worldSystem: WorldSystem private worldSystem: WorldSystem
private adapter: LocalAdapter
private pathGraphics!: Phaser.GameObjects.Graphics private pathGraphics!: Phaser.GameObjects.Graphics
private active = false private active = false
@@ -46,11 +50,13 @@ export class DebugSystem {
* @param scene - The Phaser scene this system belongs to * @param scene - The Phaser scene this system belongs to
* @param villagerSystem - Used to read active paths for visualization * @param villagerSystem - Used to read active paths for visualization
* @param worldSystem - Used to read tile types under the mouse * @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.scene = scene
this.villagerSystem = villagerSystem this.villagerSystem = villagerSystem
this.worldSystem = worldSystem this.worldSystem = worldSystem
this.adapter = adapter
} }
/** /**
@@ -159,6 +165,7 @@ export class DebugSystem {
nisseByState, nisseByState,
jobsByType, jobsByType,
activePaths: this.villagerSystem.getActivePaths().length, 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 { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter' import type { LocalAdapter } from '../NetworkAdapter'
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water' export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water'
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water'] const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water']
const TOOL_LABELS: Record<FarmingTool, string> = { const TOOL_LABELS: Record<FarmingTool, string> = {
none: '— None', none: '— None',
hoe: '⛏ Hoe (till grass)', hoe: '⛏ Hoe (till grass)',
wheat_seed: '🌾 Wheat Seeds', wheat_seed: '🌾 Wheat Seeds',
carrot_seed: '🥕 Carrot Seeds', carrot_seed: '🥕 Carrot Seeds',
tree_seed: '🌲 Tree Seeds (plant on grass)',
water: '💧 Watering Can', water: '💧 Watering Can',
} }
@@ -30,6 +31,14 @@ export class FarmingSystem {
onToolChange?: (tool: FarmingTool, label: string) => void onToolChange?: (tool: FarmingTool, label: string) => void
/** Emitted for toast notifications */ /** Emitted for toast notifications */
onMessage?: (msg: string) => void onMessage?: (msg: string) => void
/**
* Called when the player uses the tree_seed tool on a tile.
* @param tileX - Target tile column
* @param tileY - Target tile row
* @param underlyingTile - The tile type at that position
* @returns true if planting succeeded, false if validation failed
*/
onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean
constructor(scene: Phaser.Scene, adapter: LocalAdapter) { constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
this.scene = scene this.scene = scene
@@ -65,8 +74,8 @@ export class FarmingSystem {
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length]) this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
} }
// Tick crop growth // Drain crop growth queue (no delta — gameTime is advanced by GameScene)
const leveled = stateManager.tickCrops(delta) const leveled = stateManager.tickCrops()
for (const id of leveled) this.refreshCropSprite(id) for (const id of leveled) this.refreshCropSprite(id)
} }
@@ -89,9 +98,27 @@ export class FarmingSystem {
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile) if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile) else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile)
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind) else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
} }
/**
* Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem).
* Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure.
* @param tileX - Target tile column
* @param tileY - Target tile row
* @param tile - Current tile type at that position
*/
private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void {
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
this.onMessage?.('Plant tree seeds on grass!')
return
}
const ok = this.onPlantTreeSeed?.(tileX, tileY, tile)
if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!')
else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)')
}
private tillSoil(tileX: number, tileY: number, tile: TileType): void { private tillSoil(tileX: number, tileY: number, tile: TileType): void {
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) { if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
this.onMessage?.('Hoe only works on grass!') this.onMessage?.('Hoe only works on grass!')
@@ -124,11 +151,13 @@ export class FarmingSystem {
} }
const cfg = CROP_CONFIGS[kind] const cfg = CROP_CONFIGS[kind]
const now = stateManager.getGameTime()
const crop: CropState = { const crop: CropState = {
id: `crop_${tileX}_${tileY}_${Date.now()}`, id: `crop_${tileX}_${tileY}_${Date.now()}`,
tileX, tileY, kind, tileX, tileY, kind,
stage: 0, maxStage: cfg.stages, stage: 0, maxStage: cfg.stages,
stageTimerMs: cfg.stageTimeMs, growsAt: now + cfg.stageTimeMs,
growsAtWatered: now + cfg.stageTimeMs / 2,
watered: tile === TileType.WATERED_SOIL, watered: tile === TileType.WATERED_SOIL,
} }
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem }) 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.setOrigin(0.5, 0.75)
} }
sprite.setDepth(5) sprite.setDepth(node.tileY + 5)
const healthBar = this.scene.add.graphics() const healthBar = this.scene.add.graphics()
healthBar.setDepth(6) healthBar.setDepth(node.tileY + 6)
healthBar.setVisible(false) healthBar.setVisible(false)
this.sprites.set(node.id, { sprite, node, healthBar }) this.sprites.set(node.id, { sprite, node, healthBar })
@@ -76,6 +76,16 @@ export class ResourceSystem {
this.removeSprite(id) this.removeSprite(id)
} }
/**
* Spawns a sprite for a resource that was created at runtime
* (e.g. a tree grown from a seedling). The resource must already be
* present in the game state when this is called.
* @param node - The resource node to render
*/
public spawnResourcePublic(node: ResourceNodeState): void {
this.spawnSprite(node)
}
/** Called when WorldSystem changes a tile (e.g. after tree removed) */ /** Called when WorldSystem changes a tile (e.g. after tree removed) */
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void { syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
const state = stateManager.getState() const state = stateManager.getState()

View File

@@ -0,0 +1,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 Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config' import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
import { TileType } from '../types' import { TileType, PLANTABLE_TILES } from '../types'
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types' import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import { findPath } from '../utils/pathfinding' import { findPath } from '../utils/pathfinding'
@@ -11,6 +11,11 @@ import type { FarmingSystem } from './FarmingSystem'
const ARRIVAL_PX = 3 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 { interface VillagerRuntime {
sprite: Phaser.GameObjects.Image sprite: Phaser.GameObjects.Image
nameLabel: Phaser.GameObjects.Text nameLabel: Phaser.GameObjects.Text
@@ -20,6 +25,8 @@ interface VillagerRuntime {
destination: 'job' | 'stockpile' | 'bed' | null destination: 'job' | 'stockpile' | 'bed' | null
workTimer: number workTimer: number
idleScanTimer: number idleScanTimer: number
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
workLog: string[]
} }
export class VillagerSystem { export class VillagerSystem {
@@ -35,6 +42,13 @@ export class VillagerSystem {
private nameIndex = 0 private nameIndex = 0
onMessage?: (msg: string) => void onMessage?: (msg: string) => void
onNisseClick?: (villagerId: string) => void
/**
* Called when a Nisse completes a forester planting job.
* GameScene wires this to TreeSeedlingSystem.plantSeedling so that the
* seedling sprite is spawned alongside the state action.
*/
onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void
/** /**
* @param scene - The Phaser scene this system belongs to * @param scene - The Phaser scene this system belongs to
@@ -107,15 +121,15 @@ export class VillagerSystem {
case 'sleeping':this.tickSleeping(v, rt, delta); break 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.sprite.setPosition(v.x, v.y)
rt.nameLabel.setPosition(v.x, v.y - 22) rt.nameLabel.setPosition(v.x, v.y - 22)
rt.energyBar.setPosition(0, 0) rt.energyBar.setPosition(0, 0)
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy) this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
// Job icon // Job icon
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' } const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18) rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
} }
@@ -139,13 +153,21 @@ export class VillagerSystem {
// Carrying items? → find stockpile // Carrying items? → find stockpile
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) { if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
const sp = this.nearestBuilding(v, 'stockpile_zone') const sp = this.nearestBuilding(v, 'stockpile_zone')
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return } if (sp) {
this.addLog(v.id, '→ Hauling to stockpile')
this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile')
return
}
} }
// Low energy → find bed // Low energy → find bed
if (v.energy < 25) { if (v.energy < 25) {
const bed = this.findBed(v) const bed = this.findBed(v)
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return } if (bed) {
this.addLog(v.id, '→ Going to sleep (low energy)')
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
return
}
} }
// Find a job // Find a job
@@ -156,6 +178,7 @@ export class VillagerSystem {
type: 'VILLAGER_SET_JOB', villagerId: v.id, type: 'VILLAGER_SET_JOB', villagerId: v.id,
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} }, job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
}) })
this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`)
this.beginWalk(v, rt, job.tileX, job.tileY, 'job') this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
} else { } else {
// No job available — wait before scanning again // No job available — wait before scanning again
@@ -218,10 +241,12 @@ export class VillagerSystem {
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id }) this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
rt.idleScanTimer = 0 // scan for a new job immediately after deposit rt.idleScanTimer = 0 // scan for a new job immediately after deposit
this.addLog(v.id, '✓ Deposited at stockpile')
break break
case 'bed': case 'bed':
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
this.addLog(v.id, '💤 Sleeping...')
break break
default: default:
@@ -258,17 +283,23 @@ export class VillagerSystem {
const res = state.world.resources[job.targetId] const res = state.world.resources[job.targetId]
if (res) { if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the FOREST tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS }) this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
// 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') { } else if (job.type === 'mine') {
const res = state.world.resources[job.targetId] const res = state.world.resources[job.targetId]
if (res) { if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the ROCK tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS }) this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Mined rock (+2 stone)')
} }
} else if (job.type === 'farm') { } else if (job.type === 'farm') {
const crop = state.world.crops[job.targetId] const crop = state.world.crops[job.targetId]
@@ -276,6 +307,21 @@ export class VillagerSystem {
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
this.farmingSystem.removeCropSpritePublic(job.targetId) this.farmingSystem.removeCropSpritePublic(job.targetId)
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any }) this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
}
} 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 +350,7 @@ export class VillagerSystem {
if (v.energy >= 100) { if (v.energy >= 100) {
rt.sprite.setAngle(0) rt.sprite.setAngle(0)
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
this.addLog(v.id, '✓ Woke up (energy full)')
} }
} }
@@ -316,6 +363,15 @@ export class VillagerSystem {
* @param v - Villager state (used for position and priorities) * @param v - Villager state (used for position and priorities)
* @returns The chosen job candidate, or null * @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 { private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
const state = stateManager.getState() const state = stateManager.getState()
const p = v.priorities const p = v.priorities
@@ -323,32 +379,84 @@ export class VillagerSystem {
const vTY = Math.floor(v.y / TILE_SIZE) const vTY = Math.floor(v.y / TILE_SIZE)
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY) 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 } type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
const candidates: C[] = [] const candidates: C[] = []
if (p.chop > 0) { if (p.chop > 0) {
for (const res of Object.values(state.world.resources)) { // Build the set of all tiles belonging to forester zones for chop priority
const zoneTiles = new Set<string>()
for (const zone of zones) {
for (const key of zone.tiles) zoneTiles.add(key)
}
const zoneChop: C[] = []
const naturalChop: C[] = []
for (const res of resources) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }) // Skip trees with no reachable neighbour — A* cannot reach them.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
if (zoneTiles.has(`${res.tileX},${res.tileY}`)) {
zoneChop.push(c)
} else {
naturalChop.push(c)
} }
} }
// Prefer zone trees; fall back to natural only when no zone trees are reachable.
candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop))
}
if (p.mine > 0) { 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 if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
// Same reachability guard for rock tiles.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
} }
} }
if (p.farm > 0) { 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 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 }) 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 if (candidates.length === 0) return null
// Lowest priority number wins; ties broken by distance // Lowest priority number wins; ties broken by distance — avoid spread+map allocation
const bestPri = Math.min(...candidates.map(c => c.pri)) let bestPri = candidates[0].pri
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
return candidates return candidates
.filter(c => c.pri === bestPri) .filter(c => c.pri === bestPri)
.sort((a, b) => a.dist - b.dist)[0] ?? null .sort((a, b) => a.dist - b.dist)[0] ?? null
@@ -375,7 +483,7 @@ export class VillagerSystem {
this.claimed.delete(v.job.targetId) this.claimed.delete(v.job.targetId)
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
} }
rt.idleScanTimer = 1500 // longer delay after failed pathfind rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
return return
} }
@@ -412,6 +520,22 @@ export class VillagerSystem {
return this.nearestBuilding(v, 'bed') as any return this.nearestBuilding(v, 'bed') as any
} }
/**
* Returns true if at least one of the 8 neighbours of the given tile is passable.
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
* such as trees deep inside a dense forest cluster where A* can never reach the goal
* tile because no passable tile is adjacent to it.
* @param tileX - Target tile X
* @param tileY - Target tile Y
*/
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
for (const [dx, dy] of DIRS) {
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
}
return false
}
// ─── Spawning ───────────────────────────────────────────────────────────── // ─── Spawning ─────────────────────────────────────────────────────────────
/** /**
@@ -439,7 +563,7 @@ export class VillagerSystem {
y: (freeBed.tileY + 0.5) * TILE_SIZE, y: (freeBed.tileY + 0.5) * TILE_SIZE,
bedId: freeBed.id, bedId: freeBed.id,
job: null, job: null,
priorities: { chop: 1, mine: 2, farm: 3 }, priorities: { chop: 1, mine: 2, farm: 3, forester: 4 },
energy: 100, energy: 100,
aiState: 'idle', aiState: 'idle',
} }
@@ -456,18 +580,28 @@ export class VillagerSystem {
* for a newly added Nisse. * for a newly added Nisse.
* @param v - Villager state to create sprites for * @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 { 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, { const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace', fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#00000088', padding: { x: 2, y: 1 }, 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 energyBar = this.scene.add.graphics().setDepth(901)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13) const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(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 +620,21 @@ export class VillagerSystem {
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H) g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
} }
// ─── Work log ─────────────────────────────────────────────────────────────
/**
* Prepends a message to the runtime work log for the given Nisse.
* Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found.
* @param villagerId - Target Nisse ID
* @param msg - Log message to prepend
*/
private addLog(villagerId: string, msg: string): void {
const rt = this.runtime.get(villagerId)
if (!rt) return
rt.workLog.unshift(msg)
if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX
}
// ─── Public API ─────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────
/** /**
@@ -498,7 +647,10 @@ export class VillagerSystem {
const v = stateManager.getState().world.villagers[villagerId] const v = stateManager.getState().world.villagers[villagerId]
if (!v) return '—' if (!v) return '—'
if (v.aiState === 'sleeping') return '💤 Sleeping' 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' && v.job) return `🚶 → ${v.job.type}`
if (v.aiState === 'walking') return '🚶 Walking' if (v.aiState === 'walking') return '🚶 Walking'
const carrying = v.job?.carrying const carrying = v.job?.carrying
@@ -506,6 +658,15 @@ export class VillagerSystem {
return '💭 Idle' return '💭 Idle'
} }
/**
* Returns a copy of the runtime work log for the given Nisse (newest first).
* @param villagerId - The Nisse's ID
* @returns Array of log strings, or empty array if not found
*/
getWorkLog(villagerId: string): string[] {
return [...(this.runtime.get(villagerId)?.workLog ?? [])]
}
/** /**
* Returns the current world position and remaining path for every Nisse * Returns the current world position and remaining path for every Nisse
* that is currently in the 'walking' state. Used by DebugSystem for * that is currently in the 'walking' state. Used by DebugSystem for
@@ -524,6 +685,14 @@ export class VillagerSystem {
return result 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. * Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down. * Should be called when the scene shuts down.

View File

@@ -1,6 +1,6 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import { TILE_SIZE, WORLD_TILES } from '../config' import { TILE_SIZE, WORLD_TILES } from '../config'
import { TileType, IMPASSABLE } from '../types' import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
const BIOME_COLORS: Record<number, string> = { const BIOME_COLORS: Record<number, string> = {
@@ -18,9 +18,16 @@ const BIOME_COLORS: Record<number, string> = {
export class WorldSystem { export class WorldSystem {
private scene: Phaser.Scene private scene: Phaser.Scene
private map!: Phaser.Tilemaps.Tilemap private map!: Phaser.Tilemaps.Tilemap
/**
* Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile
* that is currently occupied by a tree or rock resource.
* Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked.
*/
private resourceTiles = new Set<number>()
private tileset!: Phaser.Tilemaps.Tileset private tileset!: Phaser.Tilemaps.Tileset
private bgImage!: Phaser.GameObjects.Image private bgImage!: Phaser.GameObjects.Image
private builtLayer!: Phaser.Tilemaps.TilemapLayer private builtLayer!: Phaser.Tilemaps.TilemapLayer
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
/** @param scene - The Phaser scene this system belongs to */ /** @param scene - The Phaser scene this system belongs to */
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
@@ -35,10 +42,8 @@ export class WorldSystem {
const state = stateManager.getState() const state = stateManager.getState()
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) --- // --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
const canvas = document.createElement('canvas') const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
canvas.width = WORLD_TILES const ctx = canvasTexture.context
canvas.height = WORLD_TILES
const ctx = canvas.getContext('2d')!
for (let y = 0; y < WORLD_TILES; y++) { for (let y = 0; y < WORLD_TILES; y++) {
for (let x = 0; x < WORLD_TILES; x++) { for (let x = 0; x < WORLD_TILES; x++) {
@@ -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') this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
.setOrigin(0, 0) .setOrigin(0, 0)
.setScale(TILE_SIZE) .setScale(TILE_SIZE)
.setDepth(0) .setDepth(0)
this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR) canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR)
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) --- // --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
this.map = this.scene.make.tilemap({ this.map = this.scene.make.tilemap({
@@ -84,6 +91,8 @@ export class WorldSystem {
// Camera bounds // Camera bounds
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
this.initResourceTiles()
} }
/** Returns the built-tile tilemap layer (floor, wall, soil). */ /** 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. * 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. * Out-of-bounds tiles are treated as impassable.
* @param tileX - Tile column * @param tileX - Tile column
* @param tileY - Tile row * @param tileY - Tile row
@@ -118,7 +131,55 @@ export class WorldSystem {
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
const state = stateManager.getState() const state = stateManager.getState()
const tile = state.world.tiles[tileY * WORLD_TILES + tileX] const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
return !IMPASSABLE.has(tile) if (IMPASSABLE.has(tile)) return false
if (RESOURCE_TERRAIN.has(tile)) {
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
}
return true
}
/**
* Builds the resource tile index from the current world state.
* Called once in create() so that isPassable() has an O(1) lookup.
*/
private initResourceTiles(): void {
this.resourceTiles.clear()
const state = stateManager.getState()
for (const res of Object.values(state.world.resources)) {
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
}
}
/**
* Registers a newly placed resource so isPassable() treats the tile as blocked.
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
* @param tileX - Resource tile column
* @param tileY - Resource tile row
*/
addResourceTile(tileX: number, tileY: number): void {
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
}
/**
* Removes a resource from the tile index so isPassable() treats the tile as free.
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
* but keeps the index clean for correctness.
* @param tileX - Resource tile column
* @param tileY - Resource tile row
*/
removeResourceTile(tileX: number, tileY: number): void {
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
}
/**
* 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 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. */ /** Destroys the tilemap and background image. */
destroy(): void { destroy(): void {
this.map.destroy() this.map.destroy()

View File

@@ -12,21 +12,31 @@ export enum TileType {
WATERED_SOIL = 10, WATERED_SOIL = 10,
} }
/** Tiles that are always impassable regardless of what is on them. */
export const IMPASSABLE = new Set<number>([ export const IMPASSABLE = new Set<number>([
TileType.DEEP_WATER, TileType.DEEP_WATER,
TileType.SHALLOW_WATER, TileType.SHALLOW_WATER,
TileType.FOREST,
TileType.ROCK,
TileType.WALL, TileType.WALL,
]) ])
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' /**
* Terrain tiles whose passability depends on whether a resource
* (tree or rock) is currently placed on them.
* An empty FOREST tile is walkable forest floor; a ROCK tile without a
* rock resource is just rocky ground.
*/
export const RESOURCE_TERRAIN = new Set<number>([TileType.FOREST, TileType.ROCK])
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 CropKind = 'wheat' | 'carrot'
export type JobType = 'chop' | 'mine' | 'farm' export type JobType = 'chop' | 'mine' | 'farm' | 'forester'
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping' export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
@@ -34,6 +44,7 @@ export interface JobPriorities {
chop: number // 0 = disabled, 1 = highest, 4 = lowest chop: number // 0 = disabled, 1 = highest, 4 = lowest
mine: number mine: number
farm: number farm: number
forester: number // plant tree seedlings in forester zones
} }
export interface VillagerJob { export interface VillagerJob {
@@ -79,7 +90,11 @@ export interface CropState {
kind: CropKind kind: CropKind
stage: number stage: number
maxStage: 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 watered: boolean
} }
@@ -90,14 +105,47 @@ export interface PlayerState {
inventory: Partial<Record<ItemId, number>> inventory: Partial<Record<ItemId, number>>
} }
export interface TreeSeedlingState {
id: string
tileX: number
tileY: number
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
stage: number
/** 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 { export interface WorldState {
seed: number seed: number
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
gameTime: number
tiles: number[] tiles: number[]
resources: Record<string, ResourceNodeState> resources: Record<string, ResourceNodeState>
buildings: Record<string, BuildingState> buildings: Record<string, BuildingState>
crops: Record<string, CropState> crops: Record<string, CropState>
villagers: Record<string, VillagerState> villagers: Record<string, VillagerState>
stockpile: Partial<Record<ItemId, number>> stockpile: Partial<Record<ItemId, number>>
/** Planted tree seedlings, keyed by ID. */
treeSeedlings: Record<string, TreeSeedlingState>
/**
* 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 { export interface GameStateData {
@@ -123,3 +171,8 @@ export type GameAction =
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string } | { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
| { type: 'VILLAGER_DEPOSIT'; villagerId: string } | { type: 'VILLAGER_DEPOSIT'; villagerId: string }
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities } | { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
| { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState }
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
| { type: 'FORESTER_ZONE_UPDATE'; buildingId: string; tiles: string[] }