Compare commits
55 Commits
0f411f0f34
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e099d92e2 | |||
| f78645bb79 | |||
| 84aa1a7ce5 | |||
| 24ee3257df | |||
| 78c184c560 | |||
| 7ff3d82e11 | |||
| 20858a1be1 | |||
| 3b021127a4 | |||
| 26c3807481 | |||
| d02ed33435 | |||
| c7cf971e54 | |||
| 08dffa135f | |||
| 4f2e9f73b6 | |||
| 84b6e51746 | |||
| 5f646d54ca | |||
| 94b2f7f457 | |||
| cd171c859c | |||
| d9ef57c6b0 | |||
| 87f69b4774 | |||
| 8d2c58cb5f | |||
| 986c2ea9eb | |||
| 1d8b2b2b9c | |||
| 969a82949e | |||
| d3696c6380 | |||
| b024cf36fb | |||
| 8197348cfc | |||
| 732d9100ab | |||
| f2a1811a36 | |||
| 774054db56 | |||
| 0ed3bfaea6 | |||
| 1d46446012 | |||
| a5c37f20f6 | |||
| 174db14c7a | |||
| c7ebf49bf2 | |||
| b259d966ee | |||
| 9b22f708a5 | |||
| a0e813e86b | |||
| 18c8ccb644 | |||
| bbbb3e1f58 | |||
| 822ca620d9 | |||
| 155a40f963 | |||
| 41097b4765 | |||
| 0c636ed5ec | |||
| 4c41dc9205 | |||
| 01e57df6a6 | |||
| 1feeff215d | |||
| 1ba38cc23e | |||
| 793ab430e4 | |||
| 6f0d8a866f | |||
| 71aee058b5 | |||
| 3fdf621966 | |||
| 7f0ef0554e | |||
| d83b97a447 | |||
| a93e8a2c5d | |||
| 7c130763b5 |
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl:)",
|
||||
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1/timeline\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(curl:*)",
|
||||
"Bash(python3 -c \":*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(/usr/local/bin/npm run:*)",
|
||||
"Bash(/home/tekki/.nvm/versions/node/v24.14.0/bin/npm run:*)",
|
||||
"Bash(export PATH=\"/home/tekki/.nvm/versions/node/v24.14.0/bin:$PATH\")",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''', d.get\\(''''message'''',''''''''\\)\\)\\)\")",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(for id:*)",
|
||||
"Bash(do echo:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\)\\)\")",
|
||||
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\" BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\" __NEW_LINE_2bc8ebfb809e4939__ for id in 5 6 7 9)",
|
||||
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\")",
|
||||
"Bash(__NEW_LINE_5d5fe245d6f316dc__ for:*)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\), d.get\\(''''number'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")",
|
||||
"Bash(git remote:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''login'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
game-test.log
|
||||
.claude/
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -7,6 +7,58 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [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
|
||||
- **Action log in F3 debug panel** (Issue #37): last 15 actions dispatched through the adapter are shown in the F3 overlay under "Last Actions"; ring buffer maintained in `LocalAdapter`
|
||||
|
||||
### Fixed
|
||||
- **Y-based depth sorting** (Issue #31): trees, rocks, seedlings and buildings now use `tileY + 5` as depth instead of fixed values — objects lower on screen always render in front of objects above them, regardless of spawn order; build ghost moved to depth 1000
|
||||
- **Nisse always visible** (Issue #33): Nisse sprites fixed at depth 900, always rendered above world objects
|
||||
|
||||
### Added
|
||||
- **Försterkreislauf** (Issue #25):
|
||||
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 1–2 `tree_seed` in den Stockpile
|
||||
- **Försterhaus** (`forester_hut`): Neues Gebäude im Build-Menü (Kosten: 50 wood); Log-Hütten-Grafik mit Baum-Symbol; Klick auf das Haus öffnet ein Info-Panel
|
||||
- **Zonenmarkierung**: Im Info-Panel öffnet „Edit Zone" den Zonen-Editor; innerhalb eines Radius von 5 Tiles können Tiles per Klick zur Pflanzzone hinzugefügt oder entfernt werden; markierte Tiles werden als halbtransparente grüne Fläche im Spiel angezeigt; Zone wird im Save gespeichert
|
||||
- **Förster-Job** (`forester`): Nisse mit `forester`-Priorität > 0 pflanzen automatisch `tree_seed` auf leeren Zonen-Tiles; erfordert `tree_seed` im Stockpile
|
||||
- **Chop-Priorisierung**: Beim Fällen werden Bäume innerhalb von Förster-Zonen bevorzugt; natürliche Bäume werden erst gefällt wenn keine Zonen-Bäume mehr vorhanden sind
|
||||
- Nisse-Info-Panel und Nisse-Panel (V) zeigen jetzt auch die `forester`-Priorität als Schaltfläche
|
||||
|
||||
### Fixed
|
||||
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
|
||||
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
|
||||
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
|
||||
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
|
||||
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
|
||||
|
||||
### Added
|
||||
- **Overlay Opacity Setting** (Issue #16): all UI overlay backgrounds (build menu, villager panel, context menu, ESC menu, confirm dialog, Nisse info panel, debug panel) now use a central `uiOpacity` value instead of hardcoded alphas
|
||||
- **Settings Screen**: ESC menu → Settings now opens a real overlay with an overlay-opacity row (− / value% / + step buttons, range 40 %–100 %, default 80 %); setting persisted in `localStorage` under `tg_ui_settings`, separate from game save so New Game does not wipe it
|
||||
|
||||
### Added
|
||||
- **Unified Tile System** (Issue #14):
|
||||
- Tree seedlings: player plants `tree_seed` on grass/dark-grass via the F-key farming tool; seedling grows through two stages (sprout → sapling → young tree, ~1 min each); on maturity it becomes a FOREST tile with a harvestable tree resource
|
||||
- Tile recovery: when a Nisse chops a tree, the resulting DARK_GRASS tile starts a 5-minute recovery timer and reverts to GRASS automatically, with the terrain canvas updated in real time
|
||||
- Three new procedural seedling textures (`seedling_0/1/2`) generated in BootScene
|
||||
- `tree_seed` added to stockpile display (default 5 at game start) and to the farming tool cycle
|
||||
- `WorldSystem.refreshTerrainTile()` updates the terrain canvas for a single tile without regenerating the full background
|
||||
- New `TreeSeedlingSystem` manages seedling sprites, growth ticking, and maturation
|
||||
|
||||
### Added
|
||||
- **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse
|
||||
- Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile
|
||||
- **ESC Menu** (Issue #7): pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save
|
||||
- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → Nisse info panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu
|
||||
|
||||
### Added
|
||||
- **F3 Debug View**: toggleable overlay showing FPS, tile type and contents under the cursor, Nisse count by AI state, active jobs by type, and pathfinding visualization (cyan lines in world space)
|
||||
|
||||
### Fixed
|
||||
- Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources
|
||||
- Nisse no longer get stuck idle after depositing items at the stockpile
|
||||
@@ -20,8 +72,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message)
|
||||
|
||||
### Added
|
||||
- Scroll wheel now zooms toward the mouse cursor position instead of the screen center
|
||||
- Scroll wheel zooms toward the mouse cursor position (zoom-to-mouse), correctly accounting for Phaser's center-based zoom model
|
||||
- Middle mouse button held: pan the camera by dragging
|
||||
- Test environment at `/test.html` with `ZoomTestScene` (Phaser default) and `ZoomMouseScene` (zoom-to-mouse) for camera behaviour analysis; file-logging via Vite middleware to `game-test.log`
|
||||
|
||||
### Fixed
|
||||
- `getCenterWorld()` in `CameraSystem` returned wrong world coordinates at zoom ≠ 1; corrected from `scrollX + width/(2·zoom)` to `scrollX + width/2`
|
||||
- Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world
|
||||
- Initial project setup: Phaser 3 + TypeScript + Vite
|
||||
- Core scenes: `BootScene`, `GameScene`, `UIScene`
|
||||
|
||||
90
CLAUDE.md
90
CLAUDE.md
@@ -1,5 +1,16 @@
|
||||
# CLAUDE.md — Game Project
|
||||
|
||||
## ⚠️ Important: Session Start Location
|
||||
|
||||
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
|
||||
|
||||
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
|
||||
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
|
||||
|
||||
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
||||
@@ -62,3 +73,82 @@ npm run preview # Preview production build locally
|
||||
- **Systems** read/write state and are updated each game tick via Phaser's `update()`
|
||||
- **Scenes** are thin orchestrators — logic belongs in systems, not scenes
|
||||
- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly
|
||||
|
||||
---
|
||||
|
||||
## Gitea Workflow (repo: tekki/nissefolk)
|
||||
|
||||
**Tool:** `tea` CLI (installed at `~/.local/bin/tea`, login `zally` configured).
|
||||
Never use raw `curl` with `${CLAUDE_GITEA_TOKEN}` for Gitea — use `tea` instead.
|
||||
All `tea` commands run from `~/game` (git remote `gitea` points to the repo).
|
||||
|
||||
**Git commands:** Always use `git -C ~/game <cmd>` — never `cd ~/game && git <cmd>` (triggers security prompt).
|
||||
|
||||
```bash
|
||||
# Create PR (always wait for user approval before merging)
|
||||
# Use ~/scripts/create-pr.sh — pass \n literally for newlines, the script expands them via printf.
|
||||
# Never use heredocs or $(cat file) — they trigger permission prompts.
|
||||
~/scripts/create-pr.sh "PR title" "Fixes #N.\n\n## What changed\n- item one\n- item two" feature/xyz
|
||||
|
||||
# List open PRs / issues
|
||||
tea pr list --login zally
|
||||
tea issue list --login zally
|
||||
|
||||
# View a single issue (body + comments)
|
||||
tea issue --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||
|
||||
# Merge PR — ONLY after explicit user says "merge it"
|
||||
tea pr merge --login zally --style merge <PR_NUMBER>
|
||||
|
||||
# Close issue
|
||||
tea issue close --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||
|
||||
# List labels
|
||||
tea labels list --login zally --repo tekki/nissefolk
|
||||
|
||||
# Set/remove labels on an issue (use label names, not IDs)
|
||||
tea issue edit --login zally --repo tekki/nissefolk --add-labels "status: done" <N>
|
||||
tea issue edit --login zally --repo tekki/nissefolk --remove-labels "status: in discussion" <N>
|
||||
|
||||
|
||||
# Both flags can be combined; --add-labels takes precedence over --remove-labels
|
||||
tea issue edit <N> --add-labels "status: done" --remove-labels "status: in progress" --repo tekki/nissefolk
|
||||
|
||||
# Note: "tea labels" manages label definitions in the repo — not issue assignments
|
||||
```
|
||||
|
||||
**Label IDs** (repo-specific, don't guess):
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| 1 | feature |
|
||||
| 2 | improvement |
|
||||
| 3 | bug |
|
||||
| 6 | status: backlog |
|
||||
| 8 | status: ready |
|
||||
| 9 | status: in progress |
|
||||
| 10 | status: review |
|
||||
| 11 | status: done |
|
||||
|
||||
**PR workflow rules:**
|
||||
1. Commit → push branch → `tea pr create` → **share URL, stop, wait for user approval**
|
||||
2. Only merge when user explicitly says so
|
||||
3. After merge: close issue + set label to `status: done`
|
||||
|
||||
**master branch is protected** — direct push is rejected. Always use PRs.
|
||||
|
||||
**Routine load issue**
|
||||
1. Load Issues
|
||||
if-> If the label is status: ready
|
||||
-> work as it says
|
||||
-> use a new branch for each issue
|
||||
-> test your code
|
||||
-> commit your code
|
||||
-> change the issue label
|
||||
-> do an pr to master
|
||||
|
||||
if-> If the label is status: discussion
|
||||
-> think if you need more information
|
||||
-> ask questions as comment in gitea
|
||||
|
||||
**Issue create**
|
||||
If i say something like "create an issue about..." you need to attach the labels to it to. Use status: discussion and feature/bug
|
||||
@@ -1,5 +0,0 @@
|
||||
{"t":1774091984264,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":0,"y":0},"world":{"x":0,"y":0}}}
|
||||
{"t":1774091986280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091988280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091990281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091992281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
@@ -8,12 +8,42 @@ export interface NetworkAdapter {
|
||||
onAction?: (action: GameAction) => void
|
||||
}
|
||||
|
||||
const ACTION_LOG_SIZE = 15
|
||||
|
||||
/** Singleplayer: apply actions immediately and synchronously */
|
||||
export class LocalAdapter implements NetworkAdapter {
|
||||
onAction?: (action: GameAction) => void
|
||||
|
||||
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
|
||||
private _actionLog: string[] = []
|
||||
|
||||
send(action: GameAction): void {
|
||||
stateManager.apply(action)
|
||||
this._recordAction(action)
|
||||
this.onAction?.(action)
|
||||
}
|
||||
|
||||
/** Returns a copy of the recent action log (oldest first). */
|
||||
getActionLog(): readonly string[] { return this._actionLog }
|
||||
|
||||
/**
|
||||
* Appends a short summary of the action to the ring-buffer.
|
||||
* @param action - The dispatched game action
|
||||
*/
|
||||
private _recordAction(action: GameAction): void {
|
||||
let entry = action.type
|
||||
if ('tileX' in action && 'tileY' in action)
|
||||
entry += ` (${(action as any).tileX},${(action as any).tileY})`
|
||||
else if ('villagerId' in action)
|
||||
entry += ` v=…${(action as any).villagerId.slice(-4)}`
|
||||
else if ('resourceId' in action)
|
||||
entry += ` r=…${(action as any).resourceId.slice(-4)}`
|
||||
else if ('cropId' in action)
|
||||
entry += ` c=…${(action as any).cropId.slice(-4)}`
|
||||
else if ('seedlingId' in action)
|
||||
entry += ` s=…${(action as any).seedlingId.slice(-4)}`
|
||||
|
||||
if (this._actionLog.length >= ACTION_LOG_SIZE) this._actionLog.shift()
|
||||
this._actionLog.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,215 @@
|
||||
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config'
|
||||
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
|
||||
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||
import { TileType } from './types'
|
||||
|
||||
// ─── Internal queue entry types ───────────────────────────────────────────────
|
||||
|
||||
/** Scheduled crop-growth entry. Two entries are created per stage (normal + watered path). */
|
||||
interface CropEntry {
|
||||
id: string
|
||||
fireAt: number
|
||||
expectedStage: number
|
||||
/** If true this entry only fires when crop.watered === true. */
|
||||
wateredPath: boolean
|
||||
}
|
||||
|
||||
/** Scheduled seedling-growth entry. One entry per stage. */
|
||||
interface SeedlingEntry {
|
||||
id: string
|
||||
fireAt: number
|
||||
expectedStage: number
|
||||
}
|
||||
|
||||
/** Scheduled tile-recovery entry. One entry per tile. */
|
||||
interface RecoveryEntry {
|
||||
key: string
|
||||
fireAt: number
|
||||
}
|
||||
|
||||
// ─── State factories ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PLAYER: PlayerState = {
|
||||
id: 'player1',
|
||||
x: 8192, y: 8192,
|
||||
inventory: {}, // empty — seeds now in stockpile
|
||||
inventory: {},
|
||||
}
|
||||
|
||||
function makeEmptyWorld(seed: number): WorldState {
|
||||
return {
|
||||
seed,
|
||||
gameTime: 0,
|
||||
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
||||
resources: {},
|
||||
buildings: {},
|
||||
crops: {},
|
||||
villagers: {},
|
||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 },
|
||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
||||
treeSeedlings: {},
|
||||
tileRecovery: {},
|
||||
foresterZones: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeDefaultState(): GameStateData {
|
||||
return {
|
||||
version: 4,
|
||||
version: 6,
|
||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── StateManager ─────────────────────────────────────────────────────────────
|
||||
|
||||
class StateManager {
|
||||
private state: GameStateData
|
||||
|
||||
// In-memory event queues (not persisted; rebuilt from state on load).
|
||||
private cropQueue: CropEntry[] = []
|
||||
private seedlingQueue: SeedlingEntry[] = []
|
||||
private recoveryQueue: RecoveryEntry[] = []
|
||||
|
||||
constructor() {
|
||||
this.state = this.load() ?? makeDefaultState()
|
||||
this.rebuildQueues()
|
||||
}
|
||||
|
||||
getState(): Readonly<GameStateData> { return this.state }
|
||||
|
||||
/** Returns the current accumulated in-game time in milliseconds. */
|
||||
getGameTime(): number { return this.state.world.gameTime }
|
||||
|
||||
/**
|
||||
* Advances the in-game clock by delta milliseconds.
|
||||
* Must be called once per frame before any tick methods.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
advanceTime(delta: number): void {
|
||||
this.state.world.gameTime += delta
|
||||
}
|
||||
|
||||
// ─── Queue helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inserts an entry into a sorted queue in ascending fireAt order.
|
||||
* Uses binary search for O(log n) position find; O(n) splice insert.
|
||||
*/
|
||||
private static insertSorted<T extends { fireAt: number }>(queue: T[], entry: T): void {
|
||||
let lo = 0, hi = queue.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
if (queue[mid].fireAt <= entry.fireAt) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
queue.splice(lo, 0, entry)
|
||||
}
|
||||
|
||||
/** Enqueues both growth entries (normal + watered path) for a crop's current stage. */
|
||||
private enqueueCropStage(id: string, expectedStage: number, growsAt: number, growsAtWatered: number): void {
|
||||
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAt, expectedStage, wateredPath: false })
|
||||
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAtWatered, expectedStage, wateredPath: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds all three event queues from the persisted state.
|
||||
* Called once after construction or load.
|
||||
*/
|
||||
private rebuildQueues(): void {
|
||||
this.cropQueue = []
|
||||
this.seedlingQueue = []
|
||||
this.recoveryQueue = []
|
||||
|
||||
for (const crop of Object.values(this.state.world.crops)) {
|
||||
if (crop.stage >= crop.maxStage) continue
|
||||
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||
}
|
||||
|
||||
for (const s of Object.values(this.state.world.treeSeedlings)) {
|
||||
if (s.stage < 2) {
|
||||
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, fireAt] of Object.entries(this.state.world.tileRecovery)) {
|
||||
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tick methods ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drains the crop queue up to the current gameTime.
|
||||
* Returns IDs of crops that advanced a stage this frame.
|
||||
*/
|
||||
tickCrops(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const advanced: string[] = []
|
||||
|
||||
while (this.cropQueue.length > 0 && this.cropQueue[0].fireAt <= now) {
|
||||
const entry = this.cropQueue.shift()!
|
||||
const crop = this.state.world.crops[entry.id]
|
||||
if (!crop || crop.stage !== entry.expectedStage) continue // already removed or stale stage
|
||||
if (entry.wateredPath && !crop.watered) continue // fast-path skipped: not watered
|
||||
|
||||
crop.stage++
|
||||
advanced.push(crop.id)
|
||||
|
||||
if (crop.stage < crop.maxStage) {
|
||||
const cfg = CROP_CONFIGS[crop.kind]
|
||||
crop.growsAt = now + cfg.stageTimeMs
|
||||
crop.growsAtWatered = now + cfg.stageTimeMs / 2
|
||||
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the seedling queue up to the current gameTime.
|
||||
* Returns IDs of seedlings that advanced a stage this frame.
|
||||
*/
|
||||
tickSeedlings(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const advanced: string[] = []
|
||||
|
||||
while (this.seedlingQueue.length > 0 && this.seedlingQueue[0].fireAt <= now) {
|
||||
const entry = this.seedlingQueue.shift()!
|
||||
const s = this.state.world.treeSeedlings[entry.id]
|
||||
if (!s || s.stage !== entry.expectedStage) continue // removed or stale
|
||||
|
||||
s.stage = Math.min(s.stage + 1, 2)
|
||||
advanced.push(s.id)
|
||||
|
||||
if (s.stage < 2) {
|
||||
s.growsAt = now + TREE_SEEDLING_STAGE_MS
|
||||
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the tile-recovery queue up to the current gameTime.
|
||||
* Returns keys ("tileX,tileY") of tiles that have reverted to GRASS.
|
||||
*/
|
||||
tickTileRecovery(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const recovered: string[] = []
|
||||
|
||||
while (this.recoveryQueue.length > 0 && this.recoveryQueue[0].fireAt <= now) {
|
||||
const entry = this.recoveryQueue.shift()!
|
||||
const fireAt = this.state.world.tileRecovery[entry.key]
|
||||
// Skip if the entry was superseded (tile re-planted, resetting its fireAt)
|
||||
if (fireAt === undefined || fireAt > now) continue
|
||||
delete this.state.world.tileRecovery[entry.key]
|
||||
recovered.push(entry.key)
|
||||
const [tx, ty] = entry.key.split(',').map(Number)
|
||||
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
// ─── State mutations ───────────────────────────────────────────────────────
|
||||
|
||||
apply(action: GameAction): void {
|
||||
const s = this.state
|
||||
const w = s.world
|
||||
@@ -62,11 +236,18 @@ class StateManager {
|
||||
w.buildings[action.building.id] = action.building
|
||||
for (const [k, v] of Object.entries(action.costs))
|
||||
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
|
||||
if (action.building.kind === 'forester_hut') {
|
||||
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'REMOVE_BUILDING':
|
||||
delete w.buildings[action.buildingId]; break
|
||||
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
||||
delete w.foresterZones[action.buildingId]
|
||||
}
|
||||
delete w.buildings[action.buildingId]
|
||||
break
|
||||
|
||||
case 'ADD_ITEMS':
|
||||
for (const [k, v] of Object.entries(action.items))
|
||||
@@ -77,22 +258,24 @@ class StateManager {
|
||||
w.crops[action.crop.id] = { ...action.crop }
|
||||
const have = w.stockpile[action.seedItem] ?? 0
|
||||
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
||||
// Enqueue growth timers for both normal and watered paths
|
||||
this.enqueueCropStage(action.crop.id, 0, action.crop.growsAt, action.crop.growsAtWatered)
|
||||
break
|
||||
}
|
||||
|
||||
case 'WATER_CROP': {
|
||||
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
||||
// No queue change needed — the wateredPath entry was enqueued at planting time
|
||||
}
|
||||
|
||||
case 'HARVEST_CROP': {
|
||||
delete w.crops[action.cropId]
|
||||
for (const [k, v] of Object.entries(action.rewards))
|
||||
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
||||
// Stale queue entries will be skipped automatically (crop no longer exists)
|
||||
break
|
||||
}
|
||||
|
||||
// ── Villager actions ──────────────────────────────────────────────────
|
||||
|
||||
case 'SPAWN_VILLAGER':
|
||||
w.villagers[action.villager.id] = { ...action.villager }; break
|
||||
|
||||
@@ -146,22 +329,44 @@ class StateManager {
|
||||
if (v) v.priorities = { ...action.priorities }
|
||||
break
|
||||
}
|
||||
|
||||
case 'PLANT_TREE_SEED': {
|
||||
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
||||
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
|
||||
// Enqueue growth timer
|
||||
StateManager.insertSorted(this.seedlingQueue, {
|
||||
id: action.seedling.id, fireAt: action.seedling.growsAt, expectedStage: 0
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'REMOVE_TREE_SEEDLING':
|
||||
delete w.treeSeedlings[action.seedlingId]
|
||||
// Stale queue entries will be skipped automatically
|
||||
break
|
||||
|
||||
case 'SPAWN_RESOURCE':
|
||||
w.resources[action.resource.id] = { ...action.resource }
|
||||
break
|
||||
|
||||
case 'TILE_RECOVERY_START': {
|
||||
const fireAt = w.gameTime + TILE_RECOVERY_MS
|
||||
const key = `${action.tileX},${action.tileY}`
|
||||
w.tileRecovery[key] = fireAt
|
||||
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||
break
|
||||
}
|
||||
|
||||
case 'FORESTER_ZONE_UPDATE': {
|
||||
const zone = w.foresterZones[action.buildingId]
|
||||
if (zone) zone.tiles = [...action.tiles]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tickCrops(delta: number): string[] {
|
||||
const advanced: string[] = []
|
||||
for (const crop of Object.values(this.state.world.crops)) {
|
||||
if (crop.stage >= crop.maxStage) continue
|
||||
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
|
||||
if (crop.stageTimerMs <= 0) {
|
||||
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
|
||||
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
|
||||
advanced.push(crop.id)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
// ─── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
save(): void {
|
||||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
||||
@@ -172,13 +377,44 @@ class StateManager {
|
||||
const raw = localStorage.getItem(SAVE_KEY)
|
||||
if (!raw) return null
|
||||
const p = JSON.parse(raw) as GameStateData
|
||||
if (p.version !== 4) return null
|
||||
|
||||
// ── Migrate v5 → v6: countdown timers → absolute gameTime timestamps ──
|
||||
if ((p.version as number) === 5) {
|
||||
p.world.gameTime = 0
|
||||
for (const crop of Object.values(p.world.crops)) {
|
||||
const old = crop as any
|
||||
const ms = old.stageTimerMs ?? CROP_CONFIGS[crop.kind]?.stageTimeMs ?? 20_000
|
||||
crop.growsAt = ms
|
||||
crop.growsAtWatered = ms / 2
|
||||
delete old.stageTimerMs
|
||||
}
|
||||
for (const s of Object.values(p.world.treeSeedlings)) {
|
||||
const old = s as any
|
||||
s.growsAt = old.stageTimerMs ?? TREE_SEEDLING_STAGE_MS
|
||||
delete old.stageTimerMs
|
||||
}
|
||||
// tileRecovery values were remaining-ms countdowns; with gameTime=0 they equal fireAt directly
|
||||
p.version = 6
|
||||
}
|
||||
|
||||
if (p.version !== 6) return null
|
||||
|
||||
if (!p.world.crops) p.world.crops = {}
|
||||
if (!p.world.villagers) p.world.villagers = {}
|
||||
if (!p.world.stockpile) p.world.stockpile = {}
|
||||
// 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)) {
|
||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
|
||||
}
|
||||
for (const b of Object.values(p.world.buildings)) {
|
||||
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
||||
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
||||
}
|
||||
}
|
||||
return p
|
||||
} catch (_) { return null }
|
||||
@@ -187,6 +423,7 @@ class StateManager {
|
||||
reset(): void {
|
||||
localStorage.removeItem(SAVE_KEY)
|
||||
this.state = makeDefaultState()
|
||||
this.rebuildQueues()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,12 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
|
||||
chest: { wood: 5, stone: 2 },
|
||||
bed: { wood: 6 },
|
||||
stockpile_zone:{ wood: 0 },
|
||||
forester_hut: { wood: 50 },
|
||||
}
|
||||
|
||||
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
|
||||
export const FORESTER_ZONE_RADIUS = 5
|
||||
|
||||
export interface CropConfig {
|
||||
stages: number
|
||||
stageTimeMs: number
|
||||
@@ -39,6 +43,7 @@ export const VILLAGER_WORK_TIMES: Record<string, number> = {
|
||||
chop: 3000,
|
||||
mine: 5000,
|
||||
farm: 1200,
|
||||
forester: 2000,
|
||||
}
|
||||
export const VILLAGER_NAMES = [
|
||||
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
|
||||
@@ -46,5 +51,14 @@ export const VILLAGER_NAMES = [
|
||||
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
||||
]
|
||||
|
||||
export const SAVE_KEY = 'tg_save_v4'
|
||||
export const SAVE_KEY = 'tg_save_v5'
|
||||
export const AUTOSAVE_INTERVAL = 30_000
|
||||
|
||||
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
|
||||
export const UI_SETTINGS_KEY = 'tg_ui_settings'
|
||||
|
||||
/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */
|
||||
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
|
||||
|
||||
/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */
|
||||
export const TILE_RECOVERY_MS = 300_000 // 5 minutes
|
||||
|
||||
@@ -20,6 +20,7 @@ export class BootScene extends Phaser.Scene {
|
||||
this.buildResourceTextures()
|
||||
this.buildPlayerTexture()
|
||||
this.buildCropTextures()
|
||||
this.buildSeedlingTextures()
|
||||
this.buildUITextures()
|
||||
this.buildVillagerAndBuildingTextures()
|
||||
this.generateWorldIfNeeded()
|
||||
@@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene {
|
||||
g3.generateTexture('crop_carrot_3', W, H); g3.destroy()
|
||||
}
|
||||
|
||||
// ─── Tree seedling textures (3 growth stages) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates textures for the three tree-seedling growth stages:
|
||||
* seedling_0 – small sprout
|
||||
* seedling_1 – sapling with leaves
|
||||
* seedling_2 – young tree (about to mature into a FOREST tile)
|
||||
*/
|
||||
private buildSeedlingTextures(): void {
|
||||
// Stage 0: tiny sprout
|
||||
const g0 = this.add.graphics()
|
||||
g0.fillStyle(0x6D4C41); g0.fillRect(10, 20, 4, 10)
|
||||
g0.fillStyle(0x66BB6A); g0.fillEllipse(12, 16, 12, 8)
|
||||
g0.fillStyle(0x4CAF50); g0.fillEllipse(12, 13, 8, 6)
|
||||
g0.generateTexture('seedling_0', 24, 32); g0.destroy()
|
||||
|
||||
// Stage 1: sapling
|
||||
const g1 = this.add.graphics()
|
||||
g1.fillStyle(0x6D4C41); g1.fillRect(9, 15, 5, 16)
|
||||
g1.fillStyle(0x4CAF50); g1.fillCircle(12, 12, 8)
|
||||
g1.fillStyle(0x66BB6A, 0.7); g1.fillCircle(7, 16, 5); g1.fillCircle(17, 16, 5)
|
||||
g1.fillStyle(0x81C784); g1.fillCircle(12, 8, 5)
|
||||
g1.generateTexture('seedling_1', 24, 32); g1.destroy()
|
||||
|
||||
// Stage 2: young tree (mature, ready to become a resource)
|
||||
const g2 = this.add.graphics()
|
||||
g2.fillStyle(0x000000, 0.15); g2.fillEllipse(12, 28, 16, 6)
|
||||
g2.fillStyle(0x6D4C41); g2.fillRect(9, 14, 6, 14)
|
||||
g2.fillStyle(0x2E7D32); g2.fillCircle(12, 9, 10)
|
||||
g2.fillStyle(0x388E3C); g2.fillCircle(7, 13, 7); g2.fillCircle(17, 13, 7)
|
||||
g2.fillStyle(0x43A047); g2.fillCircle(12, 6, 7)
|
||||
g2.generateTexture('seedling_2', 24, 32); g2.destroy()
|
||||
}
|
||||
|
||||
// ─── UI panel texture ─────────────────────────────────────────────────────
|
||||
|
||||
private buildUITextures(): void {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import type { BuildingType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { LocalAdapter } from '../NetworkAdapter'
|
||||
@@ -9,6 +10,9 @@ import { ResourceSystem } from '../systems/ResourceSystem'
|
||||
import { BuildingSystem } from '../systems/BuildingSystem'
|
||||
import { FarmingSystem } from '../systems/FarmingSystem'
|
||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||
import { DebugSystem } from '../systems/DebugSystem'
|
||||
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
||||
import { ForesterZoneSystem } from '../systems/ForesterZoneSystem'
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private adapter!: LocalAdapter
|
||||
@@ -18,11 +22,18 @@ export class GameScene extends Phaser.Scene {
|
||||
private buildingSystem!: BuildingSystem
|
||||
private farmingSystem!: FarmingSystem
|
||||
villagerSystem!: VillagerSystem
|
||||
debugSystem!: DebugSystem
|
||||
private treeSeedlingSystem!: TreeSeedlingSystem
|
||||
foresterZoneSystem!: ForesterZoneSystem
|
||||
private autosaveTimer = 0
|
||||
private menuOpen = false
|
||||
|
||||
constructor() { super({ key: 'Game' }) }
|
||||
|
||||
/**
|
||||
* Initialises all game systems, wires up inter-system events,
|
||||
* launches the UI scene overlay, and starts the autosave timer.
|
||||
*/
|
||||
create(): void {
|
||||
this.adapter = new LocalAdapter()
|
||||
|
||||
@@ -33,6 +44,9 @@ export class GameScene extends Phaser.Scene {
|
||||
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||
this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
|
||||
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem, this.adapter)
|
||||
|
||||
this.worldSystem.create()
|
||||
this.renderPersistentObjects()
|
||||
@@ -52,17 +66,52 @@ export class GameScene extends Phaser.Scene {
|
||||
this.farmingSystem.create()
|
||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
|
||||
this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) =>
|
||||
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||
|
||||
this.treeSeedlingSystem.create()
|
||||
|
||||
this.foresterZoneSystem.create()
|
||||
this.foresterZoneSystem.refreshOverlay()
|
||||
this.foresterZoneSystem.onEditEnded = () => this.events.emit('foresterZoneEditEnded')
|
||||
this.foresterZoneSystem.onZoneChanged = (id, tiles) => this.events.emit('foresterZoneChanged', id, tiles)
|
||||
|
||||
this.villagerSystem.create()
|
||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||
this.villagerSystem.onPlantSeedling = (tileX, tileY, tile) =>
|
||||
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||
|
||||
this.debugSystem.create()
|
||||
|
||||
// Sync tile changes and building visuals through adapter
|
||||
this.adapter.onAction = (action) => {
|
||||
if (action.type === 'CHANGE_TILE') {
|
||||
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
||||
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
|
||||
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||
} else if (action.type === 'FORESTER_ZONE_UPDATE') {
|
||||
this.foresterZoneSystem.refreshOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Detect left-clicks on forester huts to open the zone panel
|
||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.rightButtonDown() || this.menuOpen) return
|
||||
if (this.buildingSystem.isActive()) return
|
||||
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||
const state = stateManager.getState()
|
||||
const hut = Object.values(state.world.buildings).find(
|
||||
b => b.kind === 'forester_hut' && b.tileX === tileX && b.tileY === tileY
|
||||
)
|
||||
if (hut) {
|
||||
this.events.emit('foresterHutClicked', hut.id)
|
||||
}
|
||||
})
|
||||
|
||||
this.scene.launch('UI')
|
||||
|
||||
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
||||
@@ -71,21 +120,48 @@ export class GameScene extends Phaser.Scene {
|
||||
this.events.on('uiRequestBuildMenu', () => {
|
||||
if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu')
|
||||
})
|
||||
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
||||
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number; forester: number }) => {
|
||||
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
||||
})
|
||||
|
||||
this.events.on('foresterZoneEditStart', (buildingId: string) => {
|
||||
this.foresterZoneSystem.startEditMode(buildingId)
|
||||
this.menuOpen = false // keep game ticking while zone editor is open
|
||||
})
|
||||
this.events.on('foresterZoneEditStop', () => {
|
||||
this.foresterZoneSystem.exitEditMode()
|
||||
})
|
||||
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
||||
|
||||
this.autosaveTimer = AUTOSAVE_INTERVAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game loop: updates all systems and emits the cameraMoved event for the UI.
|
||||
* Skips system updates while a menu is open.
|
||||
* @param _time - Total elapsed time (unused)
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
update(_time: number, delta: number): void {
|
||||
if (this.menuOpen) return
|
||||
|
||||
// Advance the in-game clock first so all tick methods see the updated time
|
||||
stateManager.advanceTime(delta)
|
||||
|
||||
this.cameraSystem.update(delta)
|
||||
|
||||
this.resourceSystem.update(delta)
|
||||
this.farmingSystem.update(delta)
|
||||
this.treeSeedlingSystem.update(delta)
|
||||
this.villagerSystem.update(delta)
|
||||
this.debugSystem.update()
|
||||
|
||||
// Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS
|
||||
const recovered = stateManager.tickTileRecovery()
|
||||
for (const key of recovered) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||
}
|
||||
|
||||
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
||||
this.buildingSystem.update()
|
||||
@@ -106,25 +182,40 @@ export class GameScene extends Phaser.Scene {
|
||||
const name = `bobj_${building.id}`
|
||||
if (this.children.getByName(name)) continue
|
||||
|
||||
const worldDepth = building.tileY + 5
|
||||
if (building.kind === 'chest') {
|
||||
const g = this.add.graphics().setName(name).setDepth(8)
|
||||
const g = this.add.graphics().setName(name).setDepth(worldDepth)
|
||||
g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
|
||||
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
|
||||
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
|
||||
} else if (building.kind === 'bed') {
|
||||
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8)
|
||||
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(worldDepth)
|
||||
} else if (building.kind === 'stockpile_zone') {
|
||||
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
|
||||
} else if (building.kind === 'forester_hut') {
|
||||
// Draw a simple log-cabin silhouette for the forester hut
|
||||
const g = this.add.graphics().setName(name).setDepth(worldDepth)
|
||||
// Body
|
||||
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
|
||||
// Roof
|
||||
g.fillStyle(0x4a2800); g.fillTriangle(wx - 14, wy - 9, wx + 14, wy - 9, wx, wy - 22)
|
||||
// Door
|
||||
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
|
||||
// Tree symbol on the roof
|
||||
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves game state and destroys all systems cleanly on scene shutdown. */
|
||||
shutdown(): void {
|
||||
stateManager.save()
|
||||
this.worldSystem.destroy()
|
||||
this.resourceSystem.destroy()
|
||||
this.buildingSystem.destroy()
|
||||
this.farmingSystem.destroy()
|
||||
this.treeSeedlingSystem.destroy()
|
||||
this.foresterZoneSystem.destroy()
|
||||
this.villagerSystem.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ export class BuildingSystem {
|
||||
|
||||
create(): void {
|
||||
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
||||
this.ghost.setDepth(20)
|
||||
this.ghost.setDepth(1000)
|
||||
this.ghost.setVisible(false)
|
||||
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
||||
|
||||
@@ -40,7 +40,7 @@ export class BuildingSystem {
|
||||
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
||||
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
||||
})
|
||||
this.ghostLabel.setDepth(21)
|
||||
this.ghostLabel.setDepth(1001)
|
||||
this.ghostLabel.setVisible(false)
|
||||
this.ghostLabel.setOrigin(0.5, 1)
|
||||
|
||||
|
||||
171
src/systems/DebugSystem.ts
Normal file
171
src/systems/DebugSystem.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
import type { VillagerSystem } from './VillagerSystem'
|
||||
import type { WorldSystem } from './WorldSystem'
|
||||
|
||||
/** All data collected each frame for the debug panel. */
|
||||
export interface DebugData {
|
||||
fps: number
|
||||
mouseWorld: { x: number; y: number }
|
||||
mouseTile: { tileX: number; tileY: number }
|
||||
tileType: string
|
||||
resourcesOnTile: Array<{ kind: string; hp: number }>
|
||||
buildingsOnTile: string[]
|
||||
cropsOnTile: Array<{ kind: string; stage: number; maxStage: number }>
|
||||
nisseTotal: number
|
||||
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||
jobsByType: { chop: number; mine: number; farm: number }
|
||||
activePaths: number
|
||||
/** Recent actions dispatched through the adapter (newest last). */
|
||||
actionLog: readonly string[]
|
||||
}
|
||||
|
||||
/** Human-readable names for TileType enum values. */
|
||||
const TILE_NAMES: Record<number, string> = {
|
||||
[TileType.DEEP_WATER]: 'DEEP_WATER',
|
||||
[TileType.SHALLOW_WATER]: 'SHALLOW_WATER',
|
||||
[TileType.SAND]: 'SAND',
|
||||
[TileType.GRASS]: 'GRASS',
|
||||
[TileType.DARK_GRASS]: 'DARK_GRASS',
|
||||
[TileType.FOREST]: 'FOREST',
|
||||
[TileType.ROCK]: 'ROCK',
|
||||
[TileType.FLOOR]: 'FLOOR',
|
||||
[TileType.WALL]: 'WALL',
|
||||
[TileType.TILLED_SOIL]: 'TILLED_SOIL',
|
||||
[TileType.WATERED_SOIL]: 'WATERED_SOIL',
|
||||
}
|
||||
|
||||
export class DebugSystem {
|
||||
private scene: Phaser.Scene
|
||||
private villagerSystem: VillagerSystem
|
||||
private worldSystem: WorldSystem
|
||||
private adapter: LocalAdapter
|
||||
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||
private active = false
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param villagerSystem - Used to read active paths for visualization
|
||||
* @param worldSystem - Used to read tile types under the mouse
|
||||
* @param adapter - Used to read the recent action log
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.villagerSystem = villagerSystem
|
||||
this.worldSystem = worldSystem
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the world-space Graphics object used for pathfinding visualization.
|
||||
* Starts hidden until toggled on.
|
||||
*/
|
||||
create(): void {
|
||||
this.pathGraphics = this.scene.add.graphics().setDepth(50)
|
||||
this.pathGraphics.setVisible(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles debug mode on or off.
|
||||
* Shows or hides the pathfinding overlay graphics accordingly.
|
||||
*/
|
||||
toggle(): void {
|
||||
this.active = !this.active
|
||||
this.pathGraphics.setVisible(this.active)
|
||||
if (!this.active) this.pathGraphics.clear()
|
||||
}
|
||||
|
||||
/** Returns whether debug mode is currently active. */
|
||||
isActive(): boolean {
|
||||
return this.active
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws pathfinding lines for all currently walking Nisse.
|
||||
* Should be called every frame while debug mode is active.
|
||||
*/
|
||||
update(): void {
|
||||
if (!this.active) return
|
||||
this.pathGraphics.clear()
|
||||
|
||||
const paths = this.villagerSystem.getActivePaths()
|
||||
this.pathGraphics.lineStyle(1, 0x00ffff, 0.65)
|
||||
|
||||
for (const entry of paths) {
|
||||
if (entry.path.length === 0) continue
|
||||
this.pathGraphics.beginPath()
|
||||
this.pathGraphics.moveTo(entry.x, entry.y)
|
||||
for (const step of entry.path) {
|
||||
this.pathGraphics.lineTo(
|
||||
(step.tileX + 0.5) * TILE_SIZE,
|
||||
(step.tileY + 0.5) * TILE_SIZE,
|
||||
)
|
||||
}
|
||||
this.pathGraphics.strokePath()
|
||||
|
||||
// Mark the destination tile
|
||||
const last = entry.path[entry.path.length - 1]
|
||||
this.pathGraphics.fillStyle(0x00ffff, 0.4)
|
||||
this.pathGraphics.fillRect(
|
||||
last.tileX * TILE_SIZE,
|
||||
last.tileY * TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and returns all debug data for the current frame.
|
||||
* Called by UIScene to populate the debug panel.
|
||||
* @param ptr - The active pointer, used to resolve world position
|
||||
* @returns Snapshot of game state for display
|
||||
*/
|
||||
getDebugData(ptr: Phaser.Input.Pointer): DebugData {
|
||||
const state = stateManager.getState()
|
||||
const villagers = Object.values(state.world.villagers)
|
||||
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||
const tileType = this.worldSystem.getTileType(tileX, tileY)
|
||||
|
||||
const nisseByState = { idle: 0, walking: 0, working: 0, sleeping: 0 }
|
||||
const jobsByType = { chop: 0, mine: 0, farm: 0 }
|
||||
|
||||
for (const v of villagers) {
|
||||
nisseByState[v.aiState as keyof typeof nisseByState]++
|
||||
if (v.job && (v.aiState === 'working' || v.aiState === 'walking')) {
|
||||
jobsByType[v.job.type as keyof typeof jobsByType]++
|
||||
}
|
||||
}
|
||||
|
||||
const resourcesOnTile = Object.values(state.world.resources)
|
||||
.filter(r => r.tileX === tileX && r.tileY === tileY)
|
||||
.map(r => ({ kind: r.kind, hp: r.hp }))
|
||||
|
||||
const buildingsOnTile = Object.values(state.world.buildings)
|
||||
.filter(b => b.tileX === tileX && b.tileY === tileY)
|
||||
.map(b => b.kind)
|
||||
|
||||
const cropsOnTile = Object.values(state.world.crops)
|
||||
.filter(c => c.tileX === tileX && c.tileY === tileY)
|
||||
.map(c => ({ kind: c.kind, stage: c.stage, maxStage: c.maxStage }))
|
||||
|
||||
return {
|
||||
fps: Math.round(this.scene.game.loop.actualFps),
|
||||
mouseWorld: { x: ptr.worldX, y: ptr.worldY },
|
||||
mouseTile: { tileX, tileY },
|
||||
tileType: TILE_NAMES[tileType] ?? `UNKNOWN(${tileType})`,
|
||||
resourcesOnTile,
|
||||
buildingsOnTile,
|
||||
cropsOnTile,
|
||||
nisseTotal: villagers.length,
|
||||
nisseByState,
|
||||
jobsByType,
|
||||
activePaths: this.villagerSystem.getActivePaths().length,
|
||||
actionLog: this.adapter.getActionLog(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,16 @@ import type { CropKind, CropState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water'
|
||||
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water'
|
||||
|
||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water']
|
||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water']
|
||||
|
||||
const TOOL_LABELS: Record<FarmingTool, string> = {
|
||||
none: '— None',
|
||||
hoe: '⛏ Hoe (till grass)',
|
||||
wheat_seed: '🌾 Wheat Seeds',
|
||||
carrot_seed: '🥕 Carrot Seeds',
|
||||
tree_seed: '🌲 Tree Seeds (plant on grass)',
|
||||
water: '💧 Watering Can',
|
||||
}
|
||||
|
||||
@@ -30,6 +31,14 @@ export class FarmingSystem {
|
||||
onToolChange?: (tool: FarmingTool, label: string) => void
|
||||
/** Emitted for toast notifications */
|
||||
onMessage?: (msg: string) => void
|
||||
/**
|
||||
* Called when the player uses the tree_seed tool on a tile.
|
||||
* @param tileX - Target tile column
|
||||
* @param tileY - Target tile row
|
||||
* @param underlyingTile - The tile type at that position
|
||||
* @returns true if planting succeeded, false if validation failed
|
||||
*/
|
||||
onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
@@ -65,8 +74,8 @@ export class FarmingSystem {
|
||||
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
||||
}
|
||||
|
||||
// Tick crop growth
|
||||
const leveled = stateManager.tickCrops(delta)
|
||||
// Drain crop growth queue (no delta — gameTime is advanced by GameScene)
|
||||
const leveled = stateManager.tickCrops()
|
||||
for (const id of leveled) this.refreshCropSprite(id)
|
||||
}
|
||||
|
||||
@@ -89,9 +98,27 @@ export class FarmingSystem {
|
||||
|
||||
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
|
||||
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
|
||||
else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile)
|
||||
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem).
|
||||
* Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure.
|
||||
* @param tileX - Target tile column
|
||||
* @param tileY - Target tile row
|
||||
* @param tile - Current tile type at that position
|
||||
*/
|
||||
private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void {
|
||||
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||
this.onMessage?.('Plant tree seeds on grass!')
|
||||
return
|
||||
}
|
||||
const ok = this.onPlantTreeSeed?.(tileX, tileY, tile)
|
||||
if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!')
|
||||
else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)')
|
||||
}
|
||||
|
||||
private tillSoil(tileX: number, tileY: number, tile: TileType): void {
|
||||
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||
this.onMessage?.('Hoe only works on grass!')
|
||||
@@ -124,11 +151,13 @@ export class FarmingSystem {
|
||||
}
|
||||
|
||||
const cfg = CROP_CONFIGS[kind]
|
||||
const now = stateManager.getGameTime()
|
||||
const crop: CropState = {
|
||||
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
||||
tileX, tileY, kind,
|
||||
stage: 0, maxStage: cfg.stages,
|
||||
stageTimerMs: cfg.stageTimeMs,
|
||||
growsAt: now + cfg.stageTimeMs,
|
||||
growsAtWatered: now + cfg.stageTimeMs / 2,
|
||||
watered: tile === TileType.WATERED_SOIL,
|
||||
}
|
||||
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
||||
|
||||
194
src/systems/ForesterZoneSystem.ts
Normal file
194
src/systems/ForesterZoneSystem.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,10 @@ export class ResourceSystem {
|
||||
sprite.setOrigin(0.5, 0.75)
|
||||
}
|
||||
|
||||
sprite.setDepth(5)
|
||||
sprite.setDepth(node.tileY + 5)
|
||||
|
||||
const healthBar = this.scene.add.graphics()
|
||||
healthBar.setDepth(6)
|
||||
healthBar.setDepth(node.tileY + 6)
|
||||
healthBar.setVisible(false)
|
||||
|
||||
this.sprites.set(node.id, { sprite, node, healthBar })
|
||||
@@ -76,6 +76,16 @@ export class ResourceSystem {
|
||||
this.removeSprite(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a sprite for a resource that was created at runtime
|
||||
* (e.g. a tree grown from a seedling). The resource must already be
|
||||
* present in the game state when this is called.
|
||||
* @param node - The resource node to render
|
||||
*/
|
||||
public spawnResourcePublic(node: ResourceNodeState): void {
|
||||
this.spawnSprite(node)
|
||||
}
|
||||
|
||||
/** Called when WorldSystem changes a tile (e.g. after tree removed) */
|
||||
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
|
||||
const state = stateManager.getState()
|
||||
|
||||
131
src/systems/TreeSeedlingSystem.ts
Normal file
131
src/systems/TreeSeedlingSystem.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
|
||||
import { TileType, PLANTABLE_TILES } from '../types'
|
||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { findPath } from '../utils/pathfinding'
|
||||
@@ -11,6 +11,11 @@ import type { FarmingSystem } from './FarmingSystem'
|
||||
|
||||
const ARRIVAL_PX = 3
|
||||
|
||||
const WORK_LOG_MAX = 20
|
||||
|
||||
/** Job-type → display icon mapping; defined once at module level to avoid per-frame allocation. */
|
||||
const JOB_ICONS: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
|
||||
|
||||
interface VillagerRuntime {
|
||||
sprite: Phaser.GameObjects.Image
|
||||
nameLabel: Phaser.GameObjects.Text
|
||||
@@ -20,6 +25,8 @@ interface VillagerRuntime {
|
||||
destination: 'job' | 'stockpile' | 'bed' | null
|
||||
workTimer: number
|
||||
idleScanTimer: number
|
||||
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
|
||||
workLog: string[]
|
||||
}
|
||||
|
||||
export class VillagerSystem {
|
||||
@@ -35,19 +42,40 @@ export class VillagerSystem {
|
||||
private nameIndex = 0
|
||||
|
||||
onMessage?: (msg: string) => void
|
||||
onNisseClick?: (villagerId: string) => void
|
||||
/**
|
||||
* Called when a Nisse completes a forester planting job.
|
||||
* GameScene wires this to TreeSeedlingSystem.plantSeedling so that the
|
||||
* seedling sprite is spawned alongside the state action.
|
||||
*/
|
||||
onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param adapter - Network adapter for dispatching state actions
|
||||
* @param worldSystem - Used for passability checks during pathfinding
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
this.worldSystem = worldSystem
|
||||
}
|
||||
|
||||
/** Wire in sibling systems after construction */
|
||||
/**
|
||||
* Wires in sibling systems that are not available at construction time.
|
||||
* Must be called before create().
|
||||
* @param resourceSystem - Used to remove harvested resource sprites
|
||||
* @param farmingSystem - Used to remove harvested crop sprites
|
||||
*/
|
||||
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
||||
this.resourceSystem = resourceSystem
|
||||
this.farmingSystem = farmingSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns sprites for all Nisse that exist in the saved state
|
||||
* and re-claims any active job targets.
|
||||
*/
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
for (const v of Object.values(state.world.villagers)) {
|
||||
@@ -57,6 +85,10 @@ export class VillagerSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the spawn timer and ticks every Nisse's AI.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
update(delta: number): void {
|
||||
this.spawnTimer += delta
|
||||
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
||||
@@ -72,6 +104,12 @@ export class VillagerSystem {
|
||||
|
||||
// ─── Per-villager tick ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dispatches the correct AI tick method based on the villager's current state,
|
||||
* then syncs the sprite, name label, energy bar, and job icon to the state.
|
||||
* @param v - Villager state from the store
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private tickVillager(v: VillagerState, delta: number): void {
|
||||
const rt = this.runtime.get(v.id)
|
||||
if (!rt) return
|
||||
@@ -83,20 +121,28 @@ export class VillagerSystem {
|
||||
case 'sleeping':this.tickSleeping(v, rt, delta); break
|
||||
}
|
||||
|
||||
// Sync sprite to state position
|
||||
// Nisse always render above world objects
|
||||
rt.sprite.setPosition(v.x, v.y)
|
||||
|
||||
rt.nameLabel.setPosition(v.x, v.y - 22)
|
||||
rt.energyBar.setPosition(0, 0)
|
||||
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
||||
|
||||
// Job icon
|
||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
|
||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
|
||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
|
||||
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
||||
}
|
||||
|
||||
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles the idle AI state: hauls items to stockpile if carrying any,
|
||||
* seeks a bed if energy is low, otherwise picks the next job and begins walking.
|
||||
* Applies a cooldown before scanning again if no job is found.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime (sprites, path, timers)
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
// Decrement scan timer if cooling down
|
||||
if (rt.idleScanTimer > 0) {
|
||||
@@ -107,13 +153,21 @@ export class VillagerSystem {
|
||||
// Carrying items? → find stockpile
|
||||
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||
const sp = this.nearestBuilding(v, 'stockpile_zone')
|
||||
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return }
|
||||
if (sp) {
|
||||
this.addLog(v.id, '→ Hauling to stockpile')
|
||||
this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Low energy → find bed
|
||||
if (v.energy < 25) {
|
||||
const bed = this.findBed(v)
|
||||
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return }
|
||||
if (bed) {
|
||||
this.addLog(v.id, '→ Going to sleep (low energy)')
|
||||
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find a job
|
||||
@@ -124,6 +178,7 @@ export class VillagerSystem {
|
||||
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
||||
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
||||
})
|
||||
this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`)
|
||||
this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
|
||||
} else {
|
||||
// No job available — wait before scanning again
|
||||
@@ -133,6 +188,14 @@ export class VillagerSystem {
|
||||
|
||||
// ─── WALKING ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Advances the Nisse along its path toward the current destination.
|
||||
* Calls onArrived when the path is exhausted.
|
||||
* Drains energy slowly while walking.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
if (rt.path.length === 0) {
|
||||
this.onArrived(v, rt)
|
||||
@@ -161,6 +224,12 @@ export class VillagerSystem {
|
||||
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a Nisse reaches its destination tile.
|
||||
* Transitions to the appropriate next AI state based on destination type.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime
|
||||
*/
|
||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||
switch (rt.destination) {
|
||||
case 'job':
|
||||
@@ -172,10 +241,12 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
||||
this.addLog(v.id, '✓ Deposited at stockpile')
|
||||
break
|
||||
|
||||
case 'bed':
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
|
||||
this.addLog(v.id, '💤 Sleeping...')
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -186,6 +257,14 @@ export class VillagerSystem {
|
||||
|
||||
// ─── WORKING ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Counts down the work timer and performs the harvest action on completion.
|
||||
* Handles chop, mine, and farm job types.
|
||||
* Returns the Nisse to idle when done.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
rt.workTimer -= delta
|
||||
// Wobble while working
|
||||
@@ -204,17 +283,23 @@ export class VillagerSystem {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
// Chopping a tree yields 1–2 tree seeds in the stockpile
|
||||
const seeds = Math.random() < 0.5 ? 2 : 1
|
||||
this.adapter.send({ type: 'ADD_ITEMS', items: { tree_seed: seeds } })
|
||||
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||
}
|
||||
} else if (job.type === 'farm') {
|
||||
const crop = state.world.crops[job.targetId]
|
||||
@@ -222,6 +307,21 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
|
||||
this.farmingSystem.removeCropSpritePublic(job.targetId)
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
||||
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
|
||||
}
|
||||
} else if (job.type === 'forester') {
|
||||
// Verify the tile is still empty and the stockpile still has seeds
|
||||
const tileType = state.world.tiles[job.tileY * WORLD_TILES + job.tileX] as TileType
|
||||
const hasSeeds = (state.world.stockpile.tree_seed ?? 0) > 0
|
||||
const tileOccupied =
|
||||
Object.values(state.world.resources).some(r => r.tileX === job.tileX && r.tileY === job.tileY) ||
|
||||
Object.values(state.world.buildings).some(b => b.tileX === job.tileX && b.tileY === job.tileY) ||
|
||||
Object.values(state.world.crops).some(c => c.tileX === job.tileX && c.tileY === job.tileY) ||
|
||||
Object.values(state.world.treeSeedlings).some(s => s.tileX === job.tileX && s.tileY === job.tileY)
|
||||
|
||||
if (hasSeeds && PLANTABLE_TILES.has(tileType) && !tileOccupied) {
|
||||
this.onPlantSeedling?.(job.tileX, job.tileY, tileType)
|
||||
this.addLog(v.id, `🌱 Planted seedling at (${job.tileX}, ${job.tileY})`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +337,12 @@ export class VillagerSystem {
|
||||
|
||||
// ─── SLEEPING ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restores energy while sleeping. Returns to idle once energy is full.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
|
||||
// Gentle bob while sleeping
|
||||
@@ -244,11 +350,28 @@ export class VillagerSystem {
|
||||
if (v.energy >= 100) {
|
||||
rt.sprite.setAngle(0)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
this.addLog(v.id, '✓ Woke up (energy full)')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Selects the best available job for a Nisse based on their priority settings.
|
||||
* Among jobs at the same priority level, the closest one wins.
|
||||
* Returns null if no unclaimed job is available.
|
||||
* @param v - Villager state (used for position and priorities)
|
||||
* @returns The chosen job candidate, or null
|
||||
*/
|
||||
/**
|
||||
* Selects the best available job for a Nisse based on their priority settings.
|
||||
* Among jobs at the same priority level, the closest one wins.
|
||||
* For chop jobs, trees within a forester zone are preferred over natural trees —
|
||||
* natural trees are only offered when no forester-zone trees are available.
|
||||
* Returns null if no unclaimed job is available.
|
||||
* @param v - Villager state (used for position and priorities)
|
||||
* @returns The chosen job candidate, or null
|
||||
*/
|
||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
const p = v.priorities
|
||||
@@ -256,32 +379,84 @@ export class VillagerSystem {
|
||||
const vTY = Math.floor(v.y / TILE_SIZE)
|
||||
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
|
||||
|
||||
// Extract state collections once — avoids repeated Object.values() allocation per branch/loop.
|
||||
const resources = Object.values(state.world.resources)
|
||||
const buildings = Object.values(state.world.buildings)
|
||||
const crops = Object.values(state.world.crops)
|
||||
const seedlings = Object.values(state.world.treeSeedlings)
|
||||
const zones = Object.values(state.world.foresterZones)
|
||||
|
||||
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
|
||||
const candidates: C[] = []
|
||||
|
||||
if (p.chop > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
// Build the set of all tiles belonging to forester zones for chop priority
|
||||
const zoneTiles = new Set<string>()
|
||||
for (const zone of zones) {
|
||||
for (const key of zone.tiles) zoneTiles.add(key)
|
||||
}
|
||||
|
||||
const zoneChop: C[] = []
|
||||
const naturalChop: C[] = []
|
||||
|
||||
for (const res of resources) {
|
||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
||||
// Skip trees with no reachable neighbour — A* cannot reach them.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
||||
if (zoneTiles.has(`${res.tileX},${res.tileY}`)) {
|
||||
zoneChop.push(c)
|
||||
} else {
|
||||
naturalChop.push(c)
|
||||
}
|
||||
}
|
||||
// Prefer zone trees; fall back to natural only when no zone trees are reachable.
|
||||
candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop))
|
||||
}
|
||||
|
||||
if (p.mine > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
for (const res of resources) {
|
||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||
// Same reachability guard for rock tiles.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||
}
|
||||
}
|
||||
|
||||
if (p.farm > 0) {
|
||||
for (const crop of Object.values(state.world.crops)) {
|
||||
for (const crop of crops) {
|
||||
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
|
||||
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
||||
}
|
||||
}
|
||||
|
||||
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
|
||||
// Find empty plantable zone tiles to seed
|
||||
for (const zone of zones) {
|
||||
for (const key of zone.tiles) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
const targetId = `forester_tile_${tx}_${ty}`
|
||||
if (this.claimed.has(targetId)) continue
|
||||
// Skip if tile is not plantable
|
||||
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
||||
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||
// Skip if something occupies this tile — reuse already-extracted arrays
|
||||
const occupied =
|
||||
resources.some(r => r.tileX === tx && r.tileY === ty) ||
|
||||
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
|
||||
crops.some(c => c.tileX === tx && c.tileY === ty) ||
|
||||
seedlings.some(s => s.tileX === tx && s.tileY === ty)
|
||||
if (occupied) continue
|
||||
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
// Lowest priority number wins; ties broken by distance
|
||||
const bestPri = Math.min(...candidates.map(c => c.pri))
|
||||
// Lowest priority number wins; ties broken by distance — avoid spread+map allocation
|
||||
let bestPri = candidates[0].pri
|
||||
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
|
||||
return candidates
|
||||
.filter(c => c.pri === bestPri)
|
||||
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
||||
@@ -289,6 +464,15 @@ export class VillagerSystem {
|
||||
|
||||
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes a path from the Nisse's current tile to the target tile and
|
||||
* begins walking. If no path is found, the job is cleared and a cooldown applied.
|
||||
* @param v - Villager state
|
||||
* @param rt - Villager runtime
|
||||
* @param tileX - Target tile X
|
||||
* @param tileY - Target tile Y
|
||||
* @param dest - Semantic destination type (used by onArrived)
|
||||
*/
|
||||
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
||||
const sx = Math.floor(v.x / TILE_SIZE)
|
||||
const sy = Math.floor(v.y / TILE_SIZE)
|
||||
@@ -299,7 +483,7 @@ export class VillagerSystem {
|
||||
this.claimed.delete(v.job.targetId)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||
}
|
||||
rt.idleScanTimer = 1500 // longer delay after failed pathfind
|
||||
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||
return
|
||||
}
|
||||
|
||||
@@ -310,6 +494,11 @@ export class VillagerSystem {
|
||||
|
||||
// ─── Building finders ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the nearest building of the given kind to the Nisse, or null if none exist.
|
||||
* @param v - Villager state (used as reference position)
|
||||
* @param kind - Building kind to search for
|
||||
*/
|
||||
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
||||
@@ -319,6 +508,11 @@ export class VillagerSystem {
|
||||
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed.
|
||||
* Returns null if no beds are placed.
|
||||
* @param v - Villager state
|
||||
*/
|
||||
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
// Prefer assigned bed
|
||||
@@ -326,8 +520,28 @@ export class VillagerSystem {
|
||||
return this.nearestBuilding(v, 'bed') as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one of the 8 neighbours of the given tile is passable.
|
||||
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
|
||||
* such as trees deep inside a dense forest cluster where A* can never reach the goal
|
||||
* tile because no passable tile is adjacent to it.
|
||||
* @param tileX - Target tile X
|
||||
* @param tileY - Target tile Y
|
||||
*/
|
||||
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
|
||||
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
|
||||
for (const [dx, dy] of DIRS) {
|
||||
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempts to spawn a new Nisse if a free bed is available and the
|
||||
* current population is below the bed count.
|
||||
*/
|
||||
private trySpawn(): void {
|
||||
const state = stateManager.getState()
|
||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
||||
@@ -349,7 +563,7 @@ export class VillagerSystem {
|
||||
y: (freeBed.tileY + 0.5) * TILE_SIZE,
|
||||
bedId: freeBed.id,
|
||||
job: null,
|
||||
priorities: { chop: 1, mine: 2, farm: 3 },
|
||||
priorities: { chop: 1, mine: 2, farm: 3, forester: 4 },
|
||||
energy: 100,
|
||||
aiState: 'idle',
|
||||
}
|
||||
@@ -361,20 +575,43 @@ export class VillagerSystem {
|
||||
|
||||
// ─── Sprite management ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates and registers all runtime objects (sprite, label, energy bar, icon)
|
||||
* for a newly added Nisse.
|
||||
* @param v - Villager state to create sprites for
|
||||
*/
|
||||
/**
|
||||
* Creates and registers all runtime objects (sprite, outline, label, energy bar, icon)
|
||||
* for a newly added Nisse.
|
||||
* @param v - Villager state to create sprites for
|
||||
*/
|
||||
private spawnSprite(v: VillagerState): void {
|
||||
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
|
||||
// Nisse always render above trees, buildings and other world objects.
|
||||
const sprite = this.scene.add.image(v.x, v.y, 'villager')
|
||||
.setDepth(900)
|
||||
|
||||
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
|
||||
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
||||
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
||||
}).setOrigin(0.5, 1).setDepth(12)
|
||||
}).setOrigin(0.5, 1).setDepth(901)
|
||||
|
||||
const energyBar = this.scene.add.graphics().setDepth(12)
|
||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
||||
const energyBar = this.scene.add.graphics().setDepth(901)
|
||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902)
|
||||
|
||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
||||
sprite.setInteractive()
|
||||
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
||||
|
||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the energy bar graphic for a Nisse at the given world position.
|
||||
* Color transitions green → orange → red as energy decreases.
|
||||
* @param g - Graphics object to draw into
|
||||
* @param x - World X center of the Nisse
|
||||
* @param y - World Y center of the Nisse
|
||||
* @param energy - Current energy value (0–100)
|
||||
*/
|
||||
private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
|
||||
const W = 20, H = 3
|
||||
g.clear()
|
||||
@@ -383,13 +620,37 @@ export class VillagerSystem {
|
||||
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
|
||||
}
|
||||
|
||||
// ─── Work log ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Prepends a message to the runtime work log for the given Nisse.
|
||||
* Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found.
|
||||
* @param villagerId - Target Nisse ID
|
||||
* @param msg - Log message to prepend
|
||||
*/
|
||||
private addLog(villagerId: string, msg: string): void {
|
||||
const rt = this.runtime.get(villagerId)
|
||||
if (!rt) return
|
||||
rt.workLog.unshift(msg)
|
||||
if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a short human-readable status string for the given Nisse,
|
||||
* suitable for display in UI panels.
|
||||
* @param villagerId - The Nisse's ID
|
||||
* @returns Status string, or '—' if the Nisse is not found
|
||||
*/
|
||||
getStatusText(villagerId: string): string {
|
||||
const v = stateManager.getState().world.villagers[villagerId]
|
||||
if (!v) return '—'
|
||||
if (v.aiState === 'sleeping') return '💤 Sleeping'
|
||||
if (v.aiState === 'working' && v.job) return `⚒ ${v.job.type}ing`
|
||||
if (v.aiState === 'working' && v.job) {
|
||||
const label = v.job.type === 'forester' ? 'planting' : `${v.job.type}ing`
|
||||
return `⚒ ${label}`
|
||||
}
|
||||
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
|
||||
if (v.aiState === 'walking') return '🚶 Walking'
|
||||
const carrying = v.job?.carrying
|
||||
@@ -397,6 +658,45 @@ export class VillagerSystem {
|
||||
return '💭 Idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the runtime work log for the given Nisse (newest first).
|
||||
* @param villagerId - The Nisse's ID
|
||||
* @returns Array of log strings, or empty array if not found
|
||||
*/
|
||||
getWorkLog(villagerId: string): string[] {
|
||||
return [...(this.runtime.get(villagerId)?.workLog ?? [])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current world position and remaining path for every Nisse
|
||||
* that is currently in the 'walking' state. Used by DebugSystem for
|
||||
* pathfinding visualization.
|
||||
* @returns Array of path entries, one per walking Nisse
|
||||
*/
|
||||
getActivePaths(): Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> {
|
||||
const state = stateManager.getState()
|
||||
const result: Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> = []
|
||||
for (const v of Object.values(state.world.villagers)) {
|
||||
if (v.aiState !== 'walking') continue
|
||||
const rt = this.runtime.get(v.id)
|
||||
if (!rt) continue
|
||||
result.push({ x: v.x, y: v.y, path: [...rt.path] })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all Nisse sprites and clears the runtime map.
|
||||
* Should be called when the scene shuts down.
|
||||
*/
|
||||
/**
|
||||
* Destroys all Nisse sprites and clears the runtime map.
|
||||
* Should be called when the scene shuts down.
|
||||
*/
|
||||
/**
|
||||
* Destroys all Nisse sprites and clears the runtime map.
|
||||
* Should be called when the scene shuts down.
|
||||
*/
|
||||
destroy(): void {
|
||||
for (const rt of this.runtime.values()) {
|
||||
rt.sprite.destroy(); rt.nameLabel.destroy()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||
import { TileType, IMPASSABLE } from '../types'
|
||||
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
|
||||
const BIOME_COLORS: Record<number, string> = {
|
||||
@@ -18,22 +18,32 @@ const BIOME_COLORS: Record<number, string> = {
|
||||
export class WorldSystem {
|
||||
private scene: Phaser.Scene
|
||||
private map!: Phaser.Tilemaps.Tilemap
|
||||
/**
|
||||
* Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile
|
||||
* that is currently occupied by a tree or rock resource.
|
||||
* Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked.
|
||||
*/
|
||||
private resourceTiles = new Set<number>()
|
||||
private tileset!: Phaser.Tilemaps.Tileset
|
||||
private bgImage!: Phaser.GameObjects.Image
|
||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
|
||||
|
||||
/** @param scene - The Phaser scene this system belongs to */
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the terrain background canvas from saved tile data,
|
||||
* creates the built-tile tilemap layer, and sets camera bounds.
|
||||
*/
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
|
||||
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = WORLD_TILES
|
||||
canvas.height = WORLD_TILES
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
|
||||
const ctx = canvasTexture.context
|
||||
|
||||
for (let y = 0; y < WORLD_TILES; y++) {
|
||||
for (let x = 0; x < WORLD_TILES; x++) {
|
||||
@@ -43,12 +53,14 @@ export class WorldSystem {
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.textures.addCanvas('terrain_bg', canvas)
|
||||
canvasTexture.refresh()
|
||||
this.bgCanvasTexture = canvasTexture
|
||||
|
||||
this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
|
||||
.setOrigin(0, 0)
|
||||
.setScale(TILE_SIZE)
|
||||
.setDepth(0)
|
||||
this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR)
|
||||
canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR)
|
||||
|
||||
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
|
||||
this.map = this.scene.make.tilemap({
|
||||
@@ -79,12 +91,22 @@ export class WorldSystem {
|
||||
|
||||
// Camera bounds
|
||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||
|
||||
this.initResourceTiles()
|
||||
}
|
||||
|
||||
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
||||
return this.builtLayer
|
||||
}
|
||||
|
||||
/**
|
||||
* Places or removes a tile on the built layer.
|
||||
* Built tile types are added; natural types remove the built-layer entry.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
* @param type - New tile type to apply
|
||||
*/
|
||||
setTile(tileX: number, tileY: number, type: TileType): void {
|
||||
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
||||
if (BUILT_TILES.has(type)) {
|
||||
@@ -95,13 +117,77 @@ export class WorldSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the tile at the given coordinates can be walked on.
|
||||
* Water and wall tiles are always impassable.
|
||||
* Forest and rock terrain tiles are only impassable when a resource
|
||||
* (tree or rock) currently occupies them — empty forest floor and bare
|
||||
* rocky ground are walkable.
|
||||
* Out-of-bounds tiles are treated as impassable.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
*/
|
||||
isPassable(tileX: number, tileY: number): boolean {
|
||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||
const state = stateManager.getState()
|
||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
||||
return !IMPASSABLE.has(tile)
|
||||
if (IMPASSABLE.has(tile)) return false
|
||||
if (RESOURCE_TERRAIN.has(tile)) {
|
||||
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the resource tile index from the current world state.
|
||||
* Called once in create() so that isPassable() has an O(1) lookup.
|
||||
*/
|
||||
private initResourceTiles(): void {
|
||||
this.resourceTiles.clear()
|
||||
const state = stateManager.getState()
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a newly placed resource so isPassable() treats the tile as blocked.
|
||||
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
|
||||
* @param tileX - Resource tile column
|
||||
* @param tileY - Resource tile row
|
||||
*/
|
||||
addResourceTile(tileX: number, tileY: number): void {
|
||||
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a resource from the tile index so isPassable() treats the tile as free.
|
||||
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
|
||||
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
|
||||
* but keeps the index clean for correctness.
|
||||
* @param tileX - Resource tile column
|
||||
* @param tileY - Resource tile row
|
||||
*/
|
||||
removeResourceTile(tileX: number, tileY: number): void {
|
||||
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a resource (tree or rock) occupies the given tile.
|
||||
* Uses the O(1) resourceTiles index.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
*/
|
||||
hasResourceAt(tileX: number, tileY: number): boolean {
|
||||
return this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts world pixel coordinates to tile coordinates.
|
||||
* @param worldX - World X in pixels
|
||||
* @param worldY - World Y in pixels
|
||||
* @returns Integer tile position
|
||||
*/
|
||||
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
||||
return {
|
||||
tileX: Math.floor(worldX / TILE_SIZE),
|
||||
@@ -109,6 +195,12 @@ export class WorldSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tile coordinates to the world pixel center of that tile.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
* @returns World pixel center position
|
||||
*/
|
||||
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
||||
@@ -116,11 +208,32 @@ export class WorldSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile type at the given tile coordinates from saved state.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
*/
|
||||
getTileType(tileX: number, tileY: number): TileType {
|
||||
const state = stateManager.getState()
|
||||
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single tile's pixel on the background canvas and refreshes the GPU texture.
|
||||
* Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery,
|
||||
* or GRASS → FOREST when a seedling matures).
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
* @param type - New tile type to reflect visually
|
||||
*/
|
||||
refreshTerrainTile(tileX: number, tileY: number, type: TileType): void {
|
||||
const color = BIOME_COLORS[type] ?? '#0a2210'
|
||||
this.bgCanvasTexture.context.fillStyle = color
|
||||
this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1)
|
||||
this.bgCanvasTexture.refresh()
|
||||
}
|
||||
|
||||
/** Destroys the tilemap and background image. */
|
||||
destroy(): void {
|
||||
this.map.destroy()
|
||||
this.bgImage.destroy()
|
||||
|
||||
65
src/types.ts
65
src/types.ts
@@ -12,21 +12,31 @@ export enum TileType {
|
||||
WATERED_SOIL = 10,
|
||||
}
|
||||
|
||||
/** Tiles that are always impassable regardless of what is on them. */
|
||||
export const IMPASSABLE = new Set<number>([
|
||||
TileType.DEEP_WATER,
|
||||
TileType.SHALLOW_WATER,
|
||||
TileType.FOREST,
|
||||
TileType.ROCK,
|
||||
TileType.WALL,
|
||||
])
|
||||
|
||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot'
|
||||
/**
|
||||
* Terrain tiles whose passability depends on whether a resource
|
||||
* (tree or rock) is currently placed on them.
|
||||
* An empty FOREST tile is walkable forest floor; a ROCK tile without a
|
||||
* rock resource is just rocky ground.
|
||||
*/
|
||||
export const RESOURCE_TERRAIN = new Set<number>([TileType.FOREST, TileType.ROCK])
|
||||
|
||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
|
||||
/** Tiles on which tree seedlings may be planted. */
|
||||
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
||||
|
||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
||||
|
||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut'
|
||||
|
||||
export type CropKind = 'wheat' | 'carrot'
|
||||
|
||||
export type JobType = 'chop' | 'mine' | 'farm'
|
||||
export type JobType = 'chop' | 'mine' | 'farm' | 'forester'
|
||||
|
||||
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
|
||||
|
||||
@@ -34,6 +44,7 @@ export interface JobPriorities {
|
||||
chop: number // 0 = disabled, 1 = highest, 4 = lowest
|
||||
mine: number
|
||||
farm: number
|
||||
forester: number // plant tree seedlings in forester zones
|
||||
}
|
||||
|
||||
export interface VillagerJob {
|
||||
@@ -79,7 +90,11 @@ export interface CropState {
|
||||
kind: CropKind
|
||||
stage: number
|
||||
maxStage: number
|
||||
stageTimerMs: number
|
||||
/** gameTime (ms) when this stage fires at normal (unwatered) speed. */
|
||||
growsAt: number
|
||||
/** gameTime (ms) when this stage fires if the crop is watered (half normal time).
|
||||
* Both entries are enqueued at plant/stage-advance time; the stale one is skipped. */
|
||||
growsAtWatered: number
|
||||
watered: boolean
|
||||
}
|
||||
|
||||
@@ -90,14 +105,47 @@ export interface PlayerState {
|
||||
inventory: Partial<Record<ItemId, number>>
|
||||
}
|
||||
|
||||
export interface TreeSeedlingState {
|
||||
id: string
|
||||
tileX: number
|
||||
tileY: number
|
||||
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||
stage: number
|
||||
/** gameTime (ms) when this seedling advances to the next stage. */
|
||||
growsAt: number
|
||||
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||
underlyingTile: TileType
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of tiles assigned to one forester hut's planting zone.
|
||||
* Tiles are stored as "tileX,tileY" key strings.
|
||||
*/
|
||||
export interface ForesterZoneState {
|
||||
buildingId: string
|
||||
/** Tile keys "tileX,tileY" that the player has marked for planting. */
|
||||
tiles: string[]
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
seed: number
|
||||
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
|
||||
gameTime: number
|
||||
tiles: number[]
|
||||
resources: Record<string, ResourceNodeState>
|
||||
buildings: Record<string, BuildingState>
|
||||
crops: Record<string, CropState>
|
||||
villagers: Record<string, VillagerState>
|
||||
stockpile: Partial<Record<ItemId, number>>
|
||||
/** Planted tree seedlings, keyed by ID. */
|
||||
treeSeedlings: Record<string, TreeSeedlingState>
|
||||
/**
|
||||
* Tile recovery fire-times, keyed by "tileX,tileY".
|
||||
* Value is the gameTime (ms) at which the tile reverts to GRASS.
|
||||
*/
|
||||
tileRecovery: Record<string, number>
|
||||
/** Forester zone definitions, keyed by forester_hut building ID. */
|
||||
foresterZones: Record<string, ForesterZoneState>
|
||||
}
|
||||
|
||||
export interface GameStateData {
|
||||
@@ -123,3 +171,8 @@ export type GameAction =
|
||||
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
|
||||
| { type: 'VILLAGER_DEPOSIT'; villagerId: string }
|
||||
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
|
||||
| { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState }
|
||||
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
||||
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
||||
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
||||
| { type: 'FORESTER_ZONE_UPDATE'; buildingId: string; tiles: string[] }
|
||||
|
||||
Reference in New Issue
Block a user