Compare commits
31 Commits
49fae62f27
...
fix/change
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
game-test.log
|
||||||
|
.claude/
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -7,6 +7,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
|
||||||
|
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
|
||||||
|
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
|
||||||
|
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
|
||||||
|
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **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
|
### Fixed
|
||||||
- Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources
|
- 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
|
- Nisse no longer get stuck idle after depositing items at the stockpile
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -1,5 +1,16 @@
|
|||||||
# CLAUDE.md — Game Project
|
# CLAUDE.md — Game Project
|
||||||
|
|
||||||
|
## ⚠️ Important: Session Start Location
|
||||||
|
|
||||||
|
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
|
||||||
|
|
||||||
|
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
|
||||||
|
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
|
||||||
|
|
||||||
|
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
||||||
|
|||||||
@@ -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}}}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config'
|
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
|
||||||
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||||
|
import { TileType } from './types'
|
||||||
|
|
||||||
const DEFAULT_PLAYER: PlayerState = {
|
const DEFAULT_PLAYER: PlayerState = {
|
||||||
id: 'player1',
|
id: 'player1',
|
||||||
@@ -15,13 +16,15 @@ function makeEmptyWorld(seed: number): WorldState {
|
|||||||
buildings: {},
|
buildings: {},
|
||||||
crops: {},
|
crops: {},
|
||||||
villagers: {},
|
villagers: {},
|
||||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 },
|
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
||||||
|
treeSeedlings: {},
|
||||||
|
tileRecovery: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDefaultState(): GameStateData {
|
function makeDefaultState(): GameStateData {
|
||||||
return {
|
return {
|
||||||
version: 4,
|
version: 5,
|
||||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||||
}
|
}
|
||||||
@@ -146,6 +149,26 @@ class StateManager {
|
|||||||
if (v) v.priorities = { ...action.priorities }
|
if (v) v.priorities = { ...action.priorities }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'PLANT_TREE_SEED': {
|
||||||
|
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||||
|
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
||||||
|
// Cancel any tile recovery on this tile
|
||||||
|
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'REMOVE_TREE_SEEDLING':
|
||||||
|
delete w.treeSeedlings[action.seedlingId]
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'SPAWN_RESOURCE':
|
||||||
|
w.resources[action.resource.id] = { ...action.resource }
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'TILE_RECOVERY_START':
|
||||||
|
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +186,47 @@ class StateManager {
|
|||||||
return advanced
|
return advanced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances all tree-seedling growth timers.
|
||||||
|
* Returns IDs of seedlings that have reached stage 2 (ready to mature into a tree).
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
* @returns Array of seedling IDs that are now mature
|
||||||
|
*/
|
||||||
|
tickSeedlings(delta: number): string[] {
|
||||||
|
const advanced: string[] = []
|
||||||
|
for (const s of Object.values(this.state.world.treeSeedlings)) {
|
||||||
|
s.stageTimerMs -= delta
|
||||||
|
if (s.stageTimerMs <= 0) {
|
||||||
|
s.stage = Math.min(s.stage + 1, 2)
|
||||||
|
s.stageTimerMs = TREE_SEEDLING_STAGE_MS
|
||||||
|
advanced.push(s.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return advanced
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks tile-recovery timers.
|
||||||
|
* Returns keys ("tileX,tileY") of tiles that have now recovered back to GRASS.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
* @returns Array of recovered tile keys
|
||||||
|
*/
|
||||||
|
tickTileRecovery(delta: number): string[] {
|
||||||
|
const recovered: string[] = []
|
||||||
|
const rec = this.state.world.tileRecovery
|
||||||
|
for (const key of Object.keys(rec)) {
|
||||||
|
rec[key] -= delta
|
||||||
|
if (rec[key] <= 0) {
|
||||||
|
delete rec[key]
|
||||||
|
recovered.push(key)
|
||||||
|
// Update tiles array directly (DARK_GRASS → GRASS)
|
||||||
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
|
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -172,10 +236,12 @@ class StateManager {
|
|||||||
const raw = localStorage.getItem(SAVE_KEY)
|
const raw = localStorage.getItem(SAVE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const p = JSON.parse(raw) as GameStateData
|
const p = JSON.parse(raw) as GameStateData
|
||||||
if (p.version !== 4) return null
|
if (p.version !== 5) return null
|
||||||
if (!p.world.crops) p.world.crops = {}
|
if (!p.world.crops) p.world.crops = {}
|
||||||
if (!p.world.villagers) p.world.villagers = {}
|
if (!p.world.villagers) p.world.villagers = {}
|
||||||
if (!p.world.stockpile) p.world.stockpile = {}
|
if (!p.world.stockpile) p.world.stockpile = {}
|
||||||
|
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
||||||
|
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
||||||
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
||||||
for (const v of Object.values(p.world.villagers)) {
|
for (const v of Object.values(p.world.villagers)) {
|
||||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||||
|
|||||||
@@ -46,5 +46,14 @@ export const VILLAGER_NAMES = [
|
|||||||
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const SAVE_KEY = 'tg_save_v4'
|
export const SAVE_KEY = 'tg_save_v5'
|
||||||
export const AUTOSAVE_INTERVAL = 30_000
|
export const AUTOSAVE_INTERVAL = 30_000
|
||||||
|
|
||||||
|
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
|
||||||
|
export const UI_SETTINGS_KEY = 'tg_ui_settings'
|
||||||
|
|
||||||
|
/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */
|
||||||
|
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
|
||||||
|
|
||||||
|
/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */
|
||||||
|
export const TILE_RECOVERY_MS = 300_000 // 5 minutes
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class BootScene extends Phaser.Scene {
|
|||||||
this.buildResourceTextures()
|
this.buildResourceTextures()
|
||||||
this.buildPlayerTexture()
|
this.buildPlayerTexture()
|
||||||
this.buildCropTextures()
|
this.buildCropTextures()
|
||||||
|
this.buildSeedlingTextures()
|
||||||
this.buildUITextures()
|
this.buildUITextures()
|
||||||
this.buildVillagerAndBuildingTextures()
|
this.buildVillagerAndBuildingTextures()
|
||||||
this.generateWorldIfNeeded()
|
this.generateWorldIfNeeded()
|
||||||
@@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene {
|
|||||||
g3.generateTexture('crop_carrot_3', W, H); g3.destroy()
|
g3.generateTexture('crop_carrot_3', W, H); g3.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tree seedling textures (3 growth stages) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates textures for the three tree-seedling growth stages:
|
||||||
|
* seedling_0 – small sprout
|
||||||
|
* seedling_1 – sapling with leaves
|
||||||
|
* seedling_2 – young tree (about to mature into a FOREST tile)
|
||||||
|
*/
|
||||||
|
private buildSeedlingTextures(): void {
|
||||||
|
// Stage 0: tiny sprout
|
||||||
|
const g0 = this.add.graphics()
|
||||||
|
g0.fillStyle(0x6D4C41); g0.fillRect(10, 20, 4, 10)
|
||||||
|
g0.fillStyle(0x66BB6A); g0.fillEllipse(12, 16, 12, 8)
|
||||||
|
g0.fillStyle(0x4CAF50); g0.fillEllipse(12, 13, 8, 6)
|
||||||
|
g0.generateTexture('seedling_0', 24, 32); g0.destroy()
|
||||||
|
|
||||||
|
// Stage 1: sapling
|
||||||
|
const g1 = this.add.graphics()
|
||||||
|
g1.fillStyle(0x6D4C41); g1.fillRect(9, 15, 5, 16)
|
||||||
|
g1.fillStyle(0x4CAF50); g1.fillCircle(12, 12, 8)
|
||||||
|
g1.fillStyle(0x66BB6A, 0.7); g1.fillCircle(7, 16, 5); g1.fillCircle(17, 16, 5)
|
||||||
|
g1.fillStyle(0x81C784); g1.fillCircle(12, 8, 5)
|
||||||
|
g1.generateTexture('seedling_1', 24, 32); g1.destroy()
|
||||||
|
|
||||||
|
// Stage 2: young tree (mature, ready to become a resource)
|
||||||
|
const g2 = this.add.graphics()
|
||||||
|
g2.fillStyle(0x000000, 0.15); g2.fillEllipse(12, 28, 16, 6)
|
||||||
|
g2.fillStyle(0x6D4C41); g2.fillRect(9, 14, 6, 14)
|
||||||
|
g2.fillStyle(0x2E7D32); g2.fillCircle(12, 9, 10)
|
||||||
|
g2.fillStyle(0x388E3C); g2.fillCircle(7, 13, 7); g2.fillCircle(17, 13, 7)
|
||||||
|
g2.fillStyle(0x43A047); g2.fillCircle(12, 6, 7)
|
||||||
|
g2.generateTexture('seedling_2', 24, 32); g2.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── UI panel texture ─────────────────────────────────────────────────────
|
// ─── UI panel texture ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private buildUITextures(): void {
|
private buildUITextures(): void {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
|
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
|
||||||
|
import { TileType } from '../types'
|
||||||
import type { BuildingType } from '../types'
|
import type { BuildingType } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import { LocalAdapter } from '../NetworkAdapter'
|
import { LocalAdapter } from '../NetworkAdapter'
|
||||||
@@ -9,6 +10,8 @@ import { ResourceSystem } from '../systems/ResourceSystem'
|
|||||||
import { BuildingSystem } from '../systems/BuildingSystem'
|
import { BuildingSystem } from '../systems/BuildingSystem'
|
||||||
import { FarmingSystem } from '../systems/FarmingSystem'
|
import { FarmingSystem } from '../systems/FarmingSystem'
|
||||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||||
|
import { DebugSystem } from '../systems/DebugSystem'
|
||||||
|
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private adapter!: LocalAdapter
|
private adapter!: LocalAdapter
|
||||||
@@ -18,11 +21,17 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private buildingSystem!: BuildingSystem
|
private buildingSystem!: BuildingSystem
|
||||||
private farmingSystem!: FarmingSystem
|
private farmingSystem!: FarmingSystem
|
||||||
villagerSystem!: VillagerSystem
|
villagerSystem!: VillagerSystem
|
||||||
|
debugSystem!: DebugSystem
|
||||||
|
private treeSeedlingSystem!: TreeSeedlingSystem
|
||||||
private autosaveTimer = 0
|
private autosaveTimer = 0
|
||||||
private menuOpen = false
|
private menuOpen = false
|
||||||
|
|
||||||
constructor() { super({ key: 'Game' }) }
|
constructor() { super({ key: 'Game' }) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises all game systems, wires up inter-system events,
|
||||||
|
* launches the UI scene overlay, and starts the autosave timer.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
this.adapter = new LocalAdapter()
|
this.adapter = new LocalAdapter()
|
||||||
|
|
||||||
@@ -33,6 +42,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
||||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||||
|
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||||
|
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
||||||
|
|
||||||
this.worldSystem.create()
|
this.worldSystem.create()
|
||||||
this.renderPersistentObjects()
|
this.renderPersistentObjects()
|
||||||
@@ -52,14 +63,25 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.farmingSystem.create()
|
this.farmingSystem.create()
|
||||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||||
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
|
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
|
||||||
|
this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) =>
|
||||||
|
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||||
|
|
||||||
|
this.treeSeedlingSystem.create()
|
||||||
|
|
||||||
this.villagerSystem.create()
|
this.villagerSystem.create()
|
||||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||||
|
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||||
|
|
||||||
|
this.debugSystem.create()
|
||||||
|
|
||||||
// Sync tile changes and building visuals through adapter
|
// Sync tile changes and building visuals through adapter
|
||||||
this.adapter.onAction = (action) => {
|
this.adapter.onAction = (action) => {
|
||||||
if (action.type === 'CHANGE_TILE') {
|
if (action.type === 'CHANGE_TILE') {
|
||||||
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
||||||
|
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
|
||||||
|
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||||
|
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||||
|
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +96,17 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
||||||
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
||||||
})
|
})
|
||||||
|
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
||||||
|
|
||||||
this.autosaveTimer = AUTOSAVE_INTERVAL
|
this.autosaveTimer = AUTOSAVE_INTERVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main game loop: updates all systems and emits the cameraMoved event for the UI.
|
||||||
|
* Skips system updates while a menu is open.
|
||||||
|
* @param _time - Total elapsed time (unused)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
if (this.menuOpen) return
|
if (this.menuOpen) return
|
||||||
|
|
||||||
@@ -85,7 +114,16 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.resourceSystem.update(delta)
|
this.resourceSystem.update(delta)
|
||||||
this.farmingSystem.update(delta)
|
this.farmingSystem.update(delta)
|
||||||
|
this.treeSeedlingSystem.update(delta)
|
||||||
this.villagerSystem.update(delta)
|
this.villagerSystem.update(delta)
|
||||||
|
this.debugSystem.update()
|
||||||
|
|
||||||
|
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
|
||||||
|
const recovered = stateManager.tickTileRecovery(delta)
|
||||||
|
for (const key of recovered) {
|
||||||
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
|
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||||
|
}
|
||||||
|
|
||||||
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
||||||
this.buildingSystem.update()
|
this.buildingSystem.update()
|
||||||
@@ -119,12 +157,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Saves game state and destroys all systems cleanly on scene shutdown. */
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
stateManager.save()
|
stateManager.save()
|
||||||
this.worldSystem.destroy()
|
this.worldSystem.destroy()
|
||||||
this.resourceSystem.destroy()
|
this.resourceSystem.destroy()
|
||||||
this.buildingSystem.destroy()
|
this.buildingSystem.destroy()
|
||||||
this.farmingSystem.destroy()
|
this.farmingSystem.destroy()
|
||||||
|
this.treeSeedlingSystem.destroy()
|
||||||
this.villagerSystem.destroy()
|
this.villagerSystem.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import type { BuildingType, JobPriorities } from '../types'
|
import type { BuildingType, JobPriorities } from '../types'
|
||||||
import type { FarmingTool } from '../systems/FarmingSystem'
|
import type { FarmingTool } from '../systems/FarmingSystem'
|
||||||
|
import type { DebugData } from '../systems/DebugSystem'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
import { UI_SETTINGS_KEY } from '../config'
|
||||||
|
|
||||||
const ITEM_ICONS: Record<string, string> = {
|
const ITEM_ICONS: Record<string, string> = {
|
||||||
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
|
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
|
||||||
wheat: '🌾', carrot: '🧡',
|
wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UIScene extends Phaser.Scene {
|
export class UIScene extends Phaser.Scene {
|
||||||
@@ -28,10 +30,36 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private contextMenuVisible = false
|
private contextMenuVisible = false
|
||||||
private inBuildMode = false
|
private inBuildMode = false
|
||||||
private inFarmMode = false
|
private inFarmMode = false
|
||||||
|
private debugPanelText!: Phaser.GameObjects.Text
|
||||||
|
private debugActive = false
|
||||||
|
private escMenuGroup!: Phaser.GameObjects.Group
|
||||||
|
private escMenuVisible = false
|
||||||
|
private confirmGroup!: Phaser.GameObjects.Group
|
||||||
|
private confirmVisible = false
|
||||||
|
private nisseInfoGroup!: Phaser.GameObjects.Group
|
||||||
|
private nisseInfoVisible = false
|
||||||
|
private nisseInfoId: string | null = null
|
||||||
|
private nisseInfoDynamic: {
|
||||||
|
statusText: Phaser.GameObjects.Text
|
||||||
|
energyBar: Phaser.GameObjects.Graphics
|
||||||
|
energyPct: Phaser.GameObjects.Text
|
||||||
|
jobText: Phaser.GameObjects.Text
|
||||||
|
logTexts: Phaser.GameObjects.Text[]
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
/** Current overlay background opacity (0.4–1.0, default 0.8). Persisted in localStorage. */
|
||||||
|
private uiOpacity = 0.8
|
||||||
|
private settingsGroup!: Phaser.GameObjects.Group
|
||||||
|
private settingsVisible = false
|
||||||
|
|
||||||
constructor() { super({ key: 'UI' }) }
|
constructor() { super({ key: 'UI' }) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates all HUD elements, wires up game scene events, and registers
|
||||||
|
* keyboard shortcuts (B, V, F3, ESC).
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
|
this.loadUISettings()
|
||||||
this.createStockpilePanel()
|
this.createStockpilePanel()
|
||||||
this.createHintText()
|
this.createHintText()
|
||||||
this.createToast()
|
this.createToast()
|
||||||
@@ -39,6 +67,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.createBuildModeIndicator()
|
this.createBuildModeIndicator()
|
||||||
this.createFarmToolIndicator()
|
this.createFarmToolIndicator()
|
||||||
this.createCoordsDisplay()
|
this.createCoordsDisplay()
|
||||||
|
this.createDebugPanel()
|
||||||
|
|
||||||
const gameScene = this.scene.get('Game')
|
const gameScene = this.scene.get('Game')
|
||||||
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
||||||
@@ -51,11 +80,19 @@ export class UIScene extends Phaser.Scene {
|
|||||||
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
|
||||||
.on('down', () => this.toggleVillagerPanel())
|
.on('down', () => this.toggleVillagerPanel())
|
||||||
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F3)
|
||||||
|
.on('down', () => this.toggleDebugPanel())
|
||||||
|
|
||||||
this.scale.on('resize', () => this.repositionUI())
|
this.scale.on('resize', () => this.repositionUI())
|
||||||
|
|
||||||
|
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
|
||||||
|
|
||||||
this.input.mouse!.disableContextMenu()
|
this.input.mouse!.disableContextMenu()
|
||||||
this.contextMenuGroup = this.add.group()
|
this.contextMenuGroup = this.add.group()
|
||||||
|
this.escMenuGroup = this.add.group()
|
||||||
|
this.confirmGroup = this.add.group()
|
||||||
|
this.nisseInfoGroup = this.add.group()
|
||||||
|
this.settingsGroup = this.add.group()
|
||||||
|
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown()) {
|
if (ptr.rightButtonDown()) {
|
||||||
@@ -68,29 +105,41 @@ export class UIScene extends Phaser.Scene {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||||
.on('down', () => this.hideContextMenu())
|
.on('down', () => this.handleEsc())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stockpile display, toast fade timer, population count,
|
||||||
|
* and the debug panel each frame.
|
||||||
|
* @param _t - Total elapsed time (unused)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(_t: number, delta: number): void {
|
update(_t: number, delta: number): void {
|
||||||
this.updateStockpile()
|
this.updateStockpile()
|
||||||
this.updateToast(delta)
|
this.updateToast(delta)
|
||||||
this.updatePopText()
|
this.updatePopText()
|
||||||
|
if (this.debugActive) this.updateDebugPanel()
|
||||||
|
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stockpile ────────────────────────────────────────────────────────────
|
// ─── Stockpile ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
|
||||||
private createStockpilePanel(): void {
|
private createStockpilePanel(): void {
|
||||||
const x = this.scale.width - 178, y = 10
|
const x = this.scale.width - 178, y = 10
|
||||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
// 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
|
||||||
|
this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
||||||
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
|
const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
this.stockpileTexts.set(item, t)
|
this.stockpileTexts.set(item, t)
|
||||||
})
|
})
|
||||||
this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
// last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
|
||||||
|
this.popText = this.add.text(x + 10, y + 192, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refreshes all item quantities and colors in the stockpile panel. */
|
||||||
private updateStockpile(): void {
|
private updateStockpile(): void {
|
||||||
const sp = stateManager.getState().world.stockpile
|
const sp = stateManager.getState().world.stockpile
|
||||||
for (const [item, t] of this.stockpileTexts) {
|
for (const [item, t] of this.stockpileTexts) {
|
||||||
@@ -100,6 +149,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Updates the Nisse population / bed capacity counter. */
|
||||||
private updatePopText(): void {
|
private updatePopText(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
|
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
|
||||||
@@ -109,6 +159,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Hint ─────────────────────────────────────────────────────────────────
|
// ─── Hint ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the centered hint text element near the bottom of the screen. */
|
||||||
private createHintText(): void {
|
private createHintText(): void {
|
||||||
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
|
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
|
||||||
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
|
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
|
||||||
@@ -118,6 +169,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Toast ────────────────────────────────────────────────────────────────
|
// ─── Toast ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the toast notification text element (top center, initially hidden). */
|
||||||
private createToast(): void {
|
private createToast(): void {
|
||||||
this.toastText = this.add.text(this.scale.width / 2, 60, '', {
|
this.toastText = this.add.text(this.scale.width / 2, 60, '', {
|
||||||
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
|
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
|
||||||
@@ -125,8 +177,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a toast message for 2.2 seconds then fades it out.
|
||||||
|
* @param msg - Message to display
|
||||||
|
*/
|
||||||
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
|
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts down the toast timer and triggers the fade-out tween when it expires.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private updateToast(delta: number): void {
|
private updateToast(delta: number): void {
|
||||||
if (this.toastTimer <= 0) return
|
if (this.toastTimer <= 0) return
|
||||||
this.toastTimer -= delta
|
this.toastTimer -= delta
|
||||||
@@ -135,6 +195,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Build Menu ───────────────────────────────────────────────────────────
|
// ─── Build Menu ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates and hides the build menu with buttons for each available building type. */
|
||||||
private createBuildMenu(): void {
|
private createBuildMenu(): void {
|
||||||
this.buildMenuGroup = this.add.group()
|
this.buildMenuGroup = this.add.group()
|
||||||
const buildings: { kind: BuildingType; label: string; cost: string }[] = [
|
const buildings: { kind: BuildingType; label: string; cost: string }[] = [
|
||||||
@@ -145,7 +206,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
||||||
]
|
]
|
||||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
||||||
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||||
this.buildMenuGroup.add(bg)
|
this.buildMenuGroup.add(bg)
|
||||||
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
||||||
|
|
||||||
@@ -162,12 +223,18 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.buildMenuGroup.setVisible(false)
|
this.buildMenuGroup.setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggles the build menu open or closed. */
|
||||||
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
|
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
|
||||||
|
|
||||||
|
/** Opens the build menu and notifies GameScene that a menu is active. */
|
||||||
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
|
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
|
||||||
|
|
||||||
|
/** Closes the build menu and notifies GameScene that no menu is active. */
|
||||||
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
|
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
|
||||||
|
|
||||||
// ─── Villager Panel (V key) ───────────────────────────────────────────────
|
// ─── Villager Panel (V key) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Toggles the Nisse management panel open or closed. */
|
||||||
private toggleVillagerPanel(): void {
|
private toggleVillagerPanel(): void {
|
||||||
if (this.villagerPanelVisible) {
|
if (this.villagerPanelVisible) {
|
||||||
this.closeVillagerPanel()
|
this.closeVillagerPanel()
|
||||||
@@ -176,18 +243,24 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the Nisse panel, builds its contents, and notifies GameScene. */
|
||||||
private openVillagerPanel(): void {
|
private openVillagerPanel(): void {
|
||||||
this.villagerPanelVisible = true
|
this.villagerPanelVisible = true
|
||||||
this.buildVillagerPanel()
|
this.buildVillagerPanel()
|
||||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the Nisse panel and notifies GameScene. */
|
||||||
private closeVillagerPanel(): void {
|
private closeVillagerPanel(): void {
|
||||||
this.villagerPanelVisible = false
|
this.villagerPanelVisible = false
|
||||||
this.villagerPanelGroup?.destroy(true)
|
this.villagerPanelGroup?.destroy(true)
|
||||||
this.scene.get('Game').events.emit('uiMenuClose')
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys and rebuilds the Nisse panel from current state.
|
||||||
|
* Shows name, status, energy bar, and job priority buttons per Nisse.
|
||||||
|
*/
|
||||||
private buildVillagerPanel(): void {
|
private buildVillagerPanel(): void {
|
||||||
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
|
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
|
||||||
this.villagerPanelGroup = this.add.group()
|
this.villagerPanelGroup = this.add.group()
|
||||||
@@ -200,7 +273,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
const px = this.scale.width / 2 - panelW / 2
|
const px = this.scale.width / 2 - panelW / 2
|
||||||
const py = this.scale.height / 2 - panelH / 2
|
const py = this.scale.height / 2 - panelH / 2
|
||||||
|
|
||||||
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210)
|
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
|
||||||
this.villagerPanelGroup.add(bg)
|
this.villagerPanelGroup.add(bg)
|
||||||
|
|
||||||
this.villagerPanelGroup.add(
|
this.villagerPanelGroup.add(
|
||||||
@@ -266,9 +339,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Build mode indicator ─────────────────────────────────────────────────
|
// ─── Build mode indicator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the build-mode indicator text in the top-left corner (initially hidden). */
|
||||||
private createBuildModeIndicator(): void {
|
private createBuildModeIndicator(): void {
|
||||||
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the build-mode indicator based on whether build mode is active.
|
||||||
|
* @param active - Whether build mode is currently active
|
||||||
|
* @param building - The selected building type
|
||||||
|
*/
|
||||||
private onBuildModeChanged(active: boolean, building: BuildingType): void {
|
private onBuildModeChanged(active: boolean, building: BuildingType): void {
|
||||||
this.inBuildMode = active
|
this.inBuildMode = active
|
||||||
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
|
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
|
||||||
@@ -276,9 +356,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Farm tool indicator ──────────────────────────────────────────────────
|
// ─── Farm tool indicator ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */
|
||||||
private createFarmToolIndicator(): void {
|
private createFarmToolIndicator(): void {
|
||||||
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the farm-tool indicator and updates the active tool label.
|
||||||
|
* @param tool - Currently selected farm tool
|
||||||
|
* @param label - Human-readable label for the tool
|
||||||
|
*/
|
||||||
private onFarmToolChanged(tool: FarmingTool, label: string): void {
|
private onFarmToolChanged(tool: FarmingTool, label: string): void {
|
||||||
this.inFarmMode = tool !== 'none'
|
this.inFarmMode = tool !== 'none'
|
||||||
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
||||||
@@ -286,16 +373,89 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ─── Coords + controls ────────────────────────────────────────────────────
|
// ─── Coords + controls ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
|
||||||
private createCoordsDisplay(): void {
|
private createCoordsDisplay(): void {
|
||||||
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
||||||
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse', {
|
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
|
||||||
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
||||||
}).setScrollFactor(0).setDepth(100)
|
}).setScrollFactor(0).setDepth(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the tile-coordinate display when the camera moves.
|
||||||
|
* @param pos - Tile position of the camera center
|
||||||
|
*/
|
||||||
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
|
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
|
||||||
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
|
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Creates the debug panel text object (initially hidden). */
|
||||||
|
private createDebugPanel(): void {
|
||||||
|
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||||
|
this.debugPanelText = this.add.text(10, 80, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#cccccc',
|
||||||
|
backgroundColor: `#000000${hexAlpha}`,
|
||||||
|
padding: { x: 8, y: 6 },
|
||||||
|
lineSpacing: 2,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(150).setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the debug panel and notifies GameScene to toggle the pathfinding overlay. */
|
||||||
|
private toggleDebugPanel(): void {
|
||||||
|
this.debugActive = !this.debugActive
|
||||||
|
this.debugPanelText.setVisible(this.debugActive)
|
||||||
|
this.scene.get('Game').events.emit('debugToggle')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads current debug data from DebugSystem and updates the panel text.
|
||||||
|
* Called every frame while debug mode is active.
|
||||||
|
*/
|
||||||
|
private updateDebugPanel(): void {
|
||||||
|
const gameScene = this.scene.get('Game') as any
|
||||||
|
const debugSystem = gameScene.debugSystem
|
||||||
|
if (!debugSystem?.isActive()) return
|
||||||
|
|
||||||
|
const ptr = this.input.activePointer
|
||||||
|
const data = debugSystem.getDebugData(ptr) as DebugData
|
||||||
|
|
||||||
|
const resLine = data.resourcesOnTile.length > 0
|
||||||
|
? data.resourcesOnTile.map(r => `${r.kind} (hp:${r.hp})`).join(', ')
|
||||||
|
: '—'
|
||||||
|
const bldLine = data.buildingsOnTile.length > 0 ? data.buildingsOnTile.join(', ') : '—'
|
||||||
|
const cropLine = data.cropsOnTile.length > 0
|
||||||
|
? data.cropsOnTile.map(c => `${c.kind} (${c.stage}/${c.maxStage})`).join(', ')
|
||||||
|
: '—'
|
||||||
|
const { idle, walking, working, sleeping } = data.nisseByState
|
||||||
|
const { chop, mine, farm } = data.jobsByType
|
||||||
|
|
||||||
|
this.debugPanelText.setText([
|
||||||
|
'── F3 DEBUG ──────────────────',
|
||||||
|
`FPS: ${data.fps}`,
|
||||||
|
'',
|
||||||
|
`Mouse world: ${data.mouseWorld.x.toFixed(1)}, ${data.mouseWorld.y.toFixed(1)}`,
|
||||||
|
`Mouse tile: ${data.mouseTile.tileX}, ${data.mouseTile.tileY}`,
|
||||||
|
`Tile type: ${data.tileType}`,
|
||||||
|
`Resources: ${resLine}`,
|
||||||
|
`Buildings: ${bldLine}`,
|
||||||
|
`Crops: ${cropLine}`,
|
||||||
|
'',
|
||||||
|
`Nisse: ${data.nisseTotal} total`,
|
||||||
|
` idle: ${idle} walking: ${walking} working: ${working} sleeping: ${sleeping}`,
|
||||||
|
'',
|
||||||
|
`Jobs active:`,
|
||||||
|
` chop: ${chop} mine: ${mine} farm: ${farm}`,
|
||||||
|
'',
|
||||||
|
`Paths: ${data.activePaths} (cyan lines in world)`,
|
||||||
|
'',
|
||||||
|
'[F3] close',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Context Menu ─────────────────────────────────────────────────────────
|
// ─── Context Menu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,7 +474,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
const mx = Math.min(x, this.scale.width - menuW - 4)
|
const mx = Math.min(x, this.scale.width - menuW - 4)
|
||||||
const my = Math.min(y, this.scale.height - menuH - 4)
|
const my = Math.min(y, this.scale.height - menuH - 4)
|
||||||
|
|
||||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88)
|
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||||
this.contextMenuGroup.add(bg)
|
this.contextMenuGroup.add(bg)
|
||||||
|
|
||||||
@@ -359,6 +519,495 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.scene.get('Game').events.emit('uiMenuClose')
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ESC key handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles ESC key presses with a priority stack:
|
||||||
|
* confirm dialog → context menu → build menu → villager panel →
|
||||||
|
* esc menu → build/farm mode (handled by their own systems) → open ESC menu.
|
||||||
|
*/
|
||||||
|
private handleEsc(): void {
|
||||||
|
if (this.confirmVisible) { this.hideConfirm(); return }
|
||||||
|
if (this.contextMenuVisible) { this.hideContextMenu(); return }
|
||||||
|
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||||
|
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
|
||||||
|
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||||
|
if (this.settingsVisible) { this.closeSettings(); return }
|
||||||
|
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||||
|
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
||||||
|
// We only skip opening the ESC menu while those modes are active.
|
||||||
|
if (this.inBuildMode || this.inFarmMode) return
|
||||||
|
this.openEscMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ESC Menu ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Opens the ESC pause menu (New Game / Save / Load / Settings). */
|
||||||
|
private openEscMenu(): void {
|
||||||
|
if (this.escMenuVisible) return
|
||||||
|
this.escMenuVisible = true
|
||||||
|
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||||
|
this.buildEscMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the ESC menu. */
|
||||||
|
private closeEscMenu(): void {
|
||||||
|
if (!this.escMenuVisible) return
|
||||||
|
this.escMenuVisible = false
|
||||||
|
this.escMenuGroup.destroy(true)
|
||||||
|
this.escMenuGroup = this.add.group()
|
||||||
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the ESC menu UI elements. */
|
||||||
|
private buildEscMenu(): void {
|
||||||
|
if (this.escMenuGroup) this.escMenuGroup.destroy(true)
|
||||||
|
this.escMenuGroup = this.add.group()
|
||||||
|
|
||||||
|
const menuW = 240
|
||||||
|
const btnH = 40
|
||||||
|
const entries: { label: string; action: () => void }[] = [
|
||||||
|
{ label: '💾 Save Game', action: () => this.doSaveGame() },
|
||||||
|
{ label: '📂 Load Game', action: () => this.doLoadGame() },
|
||||||
|
{ label: '⚙️ Settings', action: () => this.doSettings() },
|
||||||
|
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
||||||
|
]
|
||||||
|
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
|
||||||
|
const menuH = 32 + entries.length * (btnH + 8) + 8
|
||||||
|
const mx = this.scale.width / 2 - menuW / 2
|
||||||
|
const my = this.scale.height / 2 - menuH / 2
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
|
||||||
|
this.escMenuGroup.add(bg)
|
||||||
|
this.escMenuGroup.add(
|
||||||
|
this.add.text(mx + menuW / 2, my + 12, 'MENU [ESC] close', {
|
||||||
|
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.forEach((entry, i) => {
|
||||||
|
const by = my + 32 + i * (btnH + 8)
|
||||||
|
const btn = this.add.rectangle(mx + 12, by, menuW - 24, btnH, 0x1a1a2e, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(401).setInteractive()
|
||||||
|
btn.on('pointerover', () => btn.setFillStyle(0x2a2a4e, 0.9))
|
||||||
|
btn.on('pointerout', () => btn.setFillStyle(0x1a1a2e, 0.9))
|
||||||
|
btn.on('pointerdown', entry.action)
|
||||||
|
this.escMenuGroup.add(btn)
|
||||||
|
this.escMenuGroup.add(
|
||||||
|
this.add.text(mx + 24, by + btnH / 2, entry.label, {
|
||||||
|
fontSize: '14px', color: '#dddddd', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the game and shows a toast confirmation. */
|
||||||
|
private doSaveGame(): void {
|
||||||
|
stateManager.save()
|
||||||
|
this.closeEscMenu()
|
||||||
|
this.showToast('Game saved!')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the page to load the last save from localStorage. */
|
||||||
|
private doLoadGame(): void {
|
||||||
|
this.closeEscMenu()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the Settings overlay. */
|
||||||
|
private doSettings(): void {
|
||||||
|
this.closeEscMenu()
|
||||||
|
this.openSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings overlay ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Opens the settings overlay if it is not already open. */
|
||||||
|
private openSettings(): void {
|
||||||
|
if (this.settingsVisible) return
|
||||||
|
this.settingsVisible = true
|
||||||
|
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||||
|
this.buildSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the settings overlay. */
|
||||||
|
private closeSettings(): void {
|
||||||
|
if (!this.settingsVisible) return
|
||||||
|
this.settingsVisible = false
|
||||||
|
this.settingsGroup.destroy(true)
|
||||||
|
this.settingsGroup = this.add.group()
|
||||||
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the settings overlay with an overlay-opacity row (step buttons).
|
||||||
|
* Destroying and recreating this method is used to refresh the displayed value.
|
||||||
|
*/
|
||||||
|
private buildSettings(): void {
|
||||||
|
if (this.settingsGroup) this.settingsGroup.destroy(true)
|
||||||
|
this.settingsGroup = this.add.group()
|
||||||
|
|
||||||
|
const panelW = 280
|
||||||
|
const panelH = 130
|
||||||
|
const px = this.scale.width / 2 - panelW / 2
|
||||||
|
const py = this.scale.height / 2 - panelH / 2
|
||||||
|
|
||||||
|
// Background
|
||||||
|
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
|
||||||
|
this.settingsGroup.add(bg)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
|
||||||
|
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Opacity label
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
|
||||||
|
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minus button
|
||||||
|
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||||
|
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
|
||||||
|
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
|
||||||
|
minusBtn.on('pointerdown', () => {
|
||||||
|
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
|
||||||
|
this.saveUISettings()
|
||||||
|
this.updateStaticPanelOpacity()
|
||||||
|
this.buildSettings()
|
||||||
|
})
|
||||||
|
this.settingsGroup.add(minusBtn)
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + 183, py + 58, '−', {
|
||||||
|
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Value display
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
|
||||||
|
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plus button
|
||||||
|
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||||
|
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
|
||||||
|
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
|
||||||
|
plusBtn.on('pointerdown', () => {
|
||||||
|
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
|
||||||
|
this.saveUISettings()
|
||||||
|
this.updateStaticPanelOpacity()
|
||||||
|
this.buildSettings()
|
||||||
|
})
|
||||||
|
this.settingsGroup.add(plusBtn)
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + 255, py + 58, '+', {
|
||||||
|
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||||
|
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
|
||||||
|
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
|
||||||
|
closeBtnRect.on('pointerdown', () => this.closeSettings())
|
||||||
|
this.settingsGroup.add(closeBtnRect)
|
||||||
|
this.settingsGroup.add(
|
||||||
|
this.add.text(px + panelW / 2, py + 106, 'Close', {
|
||||||
|
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads UI settings from localStorage and applies the stored opacity value.
|
||||||
|
* Falls back to the default (0.8) if no setting is found.
|
||||||
|
*/
|
||||||
|
private loadUISettings(): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(UI_SETTINGS_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { opacity?: number }
|
||||||
|
if (typeof parsed.opacity === 'number') {
|
||||||
|
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the current UI settings (opacity) to localStorage.
|
||||||
|
* Stored separately from the game save so New Game does not wipe it.
|
||||||
|
*/
|
||||||
|
private saveUISettings(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the current uiOpacity to all static UI elements that are not
|
||||||
|
* rebuilt on open (stockpile panel, debug panel background).
|
||||||
|
* Called whenever uiOpacity changes.
|
||||||
|
*/
|
||||||
|
private updateStaticPanelOpacity(): void {
|
||||||
|
this.stockpilePanel.setAlpha(this.uiOpacity)
|
||||||
|
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||||
|
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows a confirmation dialog before starting a new game. */
|
||||||
|
private doNewGame(): void {
|
||||||
|
this.closeEscMenu()
|
||||||
|
this.showConfirm(
|
||||||
|
'Start a new game?\nAll progress will be lost.',
|
||||||
|
() => { stateManager.reset(); window.location.reload() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Confirm dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal confirmation dialog with OK and Cancel buttons.
|
||||||
|
* @param message - Message to display (newlines supported)
|
||||||
|
* @param onConfirm - Callback invoked when the user confirms
|
||||||
|
*/
|
||||||
|
private showConfirm(message: string, onConfirm: () => void): void {
|
||||||
|
this.hideConfirm()
|
||||||
|
this.confirmVisible = true
|
||||||
|
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||||
|
|
||||||
|
const dialogW = 280
|
||||||
|
const dialogH = 130
|
||||||
|
const dx = this.scale.width / 2 - dialogW / 2
|
||||||
|
const dy = this.scale.height / 2 - dialogH / 2
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
|
||||||
|
this.confirmGroup.add(bg)
|
||||||
|
|
||||||
|
this.confirmGroup.add(
|
||||||
|
this.add.text(dx + dialogW / 2, dy + 20, message, {
|
||||||
|
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
align: 'center', wordWrap: { width: dialogW - 32 },
|
||||||
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(501)
|
||||||
|
)
|
||||||
|
|
||||||
|
const btnY = dy + dialogH - 44
|
||||||
|
// Cancel button
|
||||||
|
const cancelBtn = this.add.rectangle(dx + 16, btnY, 110, 30, 0x333333, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
|
||||||
|
cancelBtn.on('pointerover', () => cancelBtn.setFillStyle(0x555555, 0.9))
|
||||||
|
cancelBtn.on('pointerout', () => cancelBtn.setFillStyle(0x333333, 0.9))
|
||||||
|
cancelBtn.on('pointerdown', () => this.hideConfirm())
|
||||||
|
this.confirmGroup.add(cancelBtn)
|
||||||
|
this.confirmGroup.add(
|
||||||
|
this.add.text(dx + 71, btnY + 15, 'Cancel', {
|
||||||
|
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
|
||||||
|
)
|
||||||
|
|
||||||
|
// OK button
|
||||||
|
const okBtn = this.add.rectangle(dx + dialogW - 126, btnY, 110, 30, 0x4a1a1a, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
|
||||||
|
okBtn.on('pointerover', () => okBtn.setFillStyle(0x8a2a2a, 0.9))
|
||||||
|
okBtn.on('pointerout', () => okBtn.setFillStyle(0x4a1a1a, 0.9))
|
||||||
|
okBtn.on('pointerdown', () => { this.hideConfirm(); onConfirm() })
|
||||||
|
this.confirmGroup.add(okBtn)
|
||||||
|
this.confirmGroup.add(
|
||||||
|
this.add.text(dx + dialogW - 71, btnY + 15, 'OK', {
|
||||||
|
fontSize: '13px', color: '#ff8888', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the confirmation dialog. */
|
||||||
|
private hideConfirm(): void {
|
||||||
|
if (!this.confirmVisible) return
|
||||||
|
this.confirmVisible = false
|
||||||
|
this.confirmGroup.destroy(true)
|
||||||
|
this.confirmGroup = this.add.group()
|
||||||
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
|
||||||
|
* If another Nisse's panel is already open, it is replaced.
|
||||||
|
* @param villagerId - ID of the Nisse to display
|
||||||
|
*/
|
||||||
|
private openNisseInfoPanel(villagerId: string): void {
|
||||||
|
this.nisseInfoId = villagerId
|
||||||
|
this.nisseInfoVisible = true
|
||||||
|
this.buildNisseInfoPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the Nisse info panel. */
|
||||||
|
private closeNisseInfoPanel(): void {
|
||||||
|
if (!this.nisseInfoVisible) return
|
||||||
|
this.nisseInfoVisible = false
|
||||||
|
this.nisseInfoId = null
|
||||||
|
this.nisseInfoGroup.destroy(true)
|
||||||
|
this.nisseInfoGroup = this.add.group()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the static skeleton of the Nisse info panel (background, name, close
|
||||||
|
* button, labels, priority buttons) and stores references to the dynamic parts
|
||||||
|
* (status text, energy bar, job text, work log texts).
|
||||||
|
*/
|
||||||
|
private buildNisseInfoPanel(): void {
|
||||||
|
this.nisseInfoGroup.destroy(true)
|
||||||
|
this.nisseInfoGroup = this.add.group()
|
||||||
|
this.nisseInfoDynamic = null
|
||||||
|
|
||||||
|
const id = this.nisseInfoId
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const v = state.world.villagers[id]
|
||||||
|
if (!v) { this.closeNisseInfoPanel(); return }
|
||||||
|
|
||||||
|
const LOG_ROWS = 10
|
||||||
|
const panelW = 280
|
||||||
|
const panelH = 120 + LOG_ROWS * 14 + 16
|
||||||
|
const px = 10, py = 10
|
||||||
|
|
||||||
|
// Background
|
||||||
|
this.nisseInfoGroup.add(
|
||||||
|
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name
|
||||||
|
this.nisseInfoGroup.add(
|
||||||
|
this.add.text(px + 10, py + 10, v.name, {
|
||||||
|
fontSize: '14px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
|
||||||
|
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
|
||||||
|
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
|
||||||
|
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
|
||||||
|
closeBtn.on('pointerdown', () => this.closeNisseInfoPanel())
|
||||||
|
this.nisseInfoGroup.add(closeBtn)
|
||||||
|
|
||||||
|
// Dynamic: status text
|
||||||
|
const statusTxt = this.add.text(px + 10, py + 28, '', {
|
||||||
|
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
this.nisseInfoGroup.add(statusTxt)
|
||||||
|
|
||||||
|
// Dynamic: energy bar + pct
|
||||||
|
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
|
||||||
|
this.nisseInfoGroup.add(energyBar)
|
||||||
|
const energyPct = this.add.text(px + 136, py + 46, '', {
|
||||||
|
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
this.nisseInfoGroup.add(energyPct)
|
||||||
|
|
||||||
|
// Dynamic: job text
|
||||||
|
const jobTxt = this.add.text(px + 10, py + 60, '', {
|
||||||
|
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
this.nisseInfoGroup.add(jobTxt)
|
||||||
|
|
||||||
|
// Static: priority label + buttons
|
||||||
|
const jobKeys: Array<{ key: string; icon: string }> = [
|
||||||
|
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' },
|
||||||
|
]
|
||||||
|
jobKeys.forEach((j, i) => {
|
||||||
|
const pri = v.priorities[j.key as keyof typeof v.priorities]
|
||||||
|
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
|
||||||
|
const bx = px + 10 + i * 88
|
||||||
|
const btn = this.add.text(bx, py + 78, label, {
|
||||||
|
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
|
||||||
|
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
|
||||||
|
padding: { x: 5, y: 3 },
|
||||||
|
}).setScrollFactor(0).setDepth(252).setInteractive()
|
||||||
|
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
|
||||||
|
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' }))
|
||||||
|
btn.on('pointerdown', () => {
|
||||||
|
const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5
|
||||||
|
const newPriorities = { ...v.priorities, [j.key]: newPri }
|
||||||
|
this.scene.get('Game').events.emit('updatePriorities', id, newPriorities)
|
||||||
|
// Rebuild panel so priority buttons reflect the new values immediately
|
||||||
|
this.buildNisseInfoPanel()
|
||||||
|
})
|
||||||
|
this.nisseInfoGroup.add(btn)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Static: work log header
|
||||||
|
this.nisseInfoGroup.add(
|
||||||
|
this.add.text(px + 10, py + 98, '── Work Log ──────────────────', {
|
||||||
|
fontSize: '10px', color: '#555555', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dynamic: log text rows (pre-allocated)
|
||||||
|
const logTexts: Phaser.GameObjects.Text[] = []
|
||||||
|
for (let i = 0; i < LOG_ROWS; i++) {
|
||||||
|
const t = this.add.text(px + 10, py + 112 + i * 14, '', {
|
||||||
|
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
this.nisseInfoGroup.add(t)
|
||||||
|
logTexts.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts }
|
||||||
|
this.refreshNisseInfoPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates only the dynamic parts of the Nisse info panel (status, energy,
|
||||||
|
* job, work log) without destroying and recreating the full group.
|
||||||
|
* Called every frame while the panel is visible.
|
||||||
|
*/
|
||||||
|
private refreshNisseInfoPanel(): void {
|
||||||
|
const dyn = this.nisseInfoDynamic
|
||||||
|
if (!dyn || !this.nisseInfoId) return
|
||||||
|
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const v = state.world.villagers[this.nisseInfoId]
|
||||||
|
if (!v) { this.closeNisseInfoPanel(); return }
|
||||||
|
|
||||||
|
const gameScene = this.scene.get('Game') as any
|
||||||
|
const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[]
|
||||||
|
const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string
|
||||||
|
|
||||||
|
dyn.statusText.setText(statusStr)
|
||||||
|
|
||||||
|
// Energy bar
|
||||||
|
const px = 10, py = 10
|
||||||
|
dyn.energyBar.clear()
|
||||||
|
dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7)
|
||||||
|
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
|
||||||
|
dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7)
|
||||||
|
dyn.energyPct.setText(`${Math.round(v.energy)}%`)
|
||||||
|
|
||||||
|
// Job
|
||||||
|
dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`)
|
||||||
|
|
||||||
|
// Work log rows
|
||||||
|
dyn.logTexts.forEach((t, i) => {
|
||||||
|
t.setText(workLog[i] ?? '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -389,5 +1038,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.buildMenuVisible) this.closeBuildMenu()
|
if (this.buildMenuVisible) this.closeBuildMenu()
|
||||||
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
if (this.contextMenuVisible) this.hideContextMenu()
|
if (this.contextMenuVisible) this.hideContextMenu()
|
||||||
|
if (this.escMenuVisible) this.closeEscMenu()
|
||||||
|
if (this.settingsVisible) this.closeSettings()
|
||||||
|
if (this.confirmVisible) this.hideConfirm()
|
||||||
|
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
src/systems/DebugSystem.ts
Normal file
164
src/systems/DebugSystem.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE } from '../config'
|
||||||
|
import { TileType } from '../types'
|
||||||
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { VillagerSystem } from './VillagerSystem'
|
||||||
|
import type { WorldSystem } from './WorldSystem'
|
||||||
|
|
||||||
|
/** All data collected each frame for the debug panel. */
|
||||||
|
export interface DebugData {
|
||||||
|
fps: number
|
||||||
|
mouseWorld: { x: number; y: number }
|
||||||
|
mouseTile: { tileX: number; tileY: number }
|
||||||
|
tileType: string
|
||||||
|
resourcesOnTile: Array<{ kind: string; hp: number }>
|
||||||
|
buildingsOnTile: string[]
|
||||||
|
cropsOnTile: Array<{ kind: string; stage: number; maxStage: number }>
|
||||||
|
nisseTotal: number
|
||||||
|
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||||
|
jobsByType: { chop: number; mine: number; farm: number }
|
||||||
|
activePaths: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable names for TileType enum values. */
|
||||||
|
const TILE_NAMES: Record<number, string> = {
|
||||||
|
[TileType.DEEP_WATER]: 'DEEP_WATER',
|
||||||
|
[TileType.SHALLOW_WATER]: 'SHALLOW_WATER',
|
||||||
|
[TileType.SAND]: 'SAND',
|
||||||
|
[TileType.GRASS]: 'GRASS',
|
||||||
|
[TileType.DARK_GRASS]: 'DARK_GRASS',
|
||||||
|
[TileType.FOREST]: 'FOREST',
|
||||||
|
[TileType.ROCK]: 'ROCK',
|
||||||
|
[TileType.FLOOR]: 'FLOOR',
|
||||||
|
[TileType.WALL]: 'WALL',
|
||||||
|
[TileType.TILLED_SOIL]: 'TILLED_SOIL',
|
||||||
|
[TileType.WATERED_SOIL]: 'WATERED_SOIL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DebugSystem {
|
||||||
|
private scene: Phaser.Scene
|
||||||
|
private villagerSystem: VillagerSystem
|
||||||
|
private worldSystem: WorldSystem
|
||||||
|
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||||
|
private active = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param villagerSystem - Used to read active paths for visualization
|
||||||
|
* @param worldSystem - Used to read tile types under the mouse
|
||||||
|
*/
|
||||||
|
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
|
||||||
|
this.scene = scene
|
||||||
|
this.villagerSystem = villagerSystem
|
||||||
|
this.worldSystem = worldSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the world-space Graphics object used for pathfinding visualization.
|
||||||
|
* Starts hidden until toggled on.
|
||||||
|
*/
|
||||||
|
create(): void {
|
||||||
|
this.pathGraphics = this.scene.add.graphics().setDepth(50)
|
||||||
|
this.pathGraphics.setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles debug mode on or off.
|
||||||
|
* Shows or hides the pathfinding overlay graphics accordingly.
|
||||||
|
*/
|
||||||
|
toggle(): void {
|
||||||
|
this.active = !this.active
|
||||||
|
this.pathGraphics.setVisible(this.active)
|
||||||
|
if (!this.active) this.pathGraphics.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether debug mode is currently active. */
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws pathfinding lines for all currently walking Nisse.
|
||||||
|
* Should be called every frame while debug mode is active.
|
||||||
|
*/
|
||||||
|
update(): void {
|
||||||
|
if (!this.active) return
|
||||||
|
this.pathGraphics.clear()
|
||||||
|
|
||||||
|
const paths = this.villagerSystem.getActivePaths()
|
||||||
|
this.pathGraphics.lineStyle(1, 0x00ffff, 0.65)
|
||||||
|
|
||||||
|
for (const entry of paths) {
|
||||||
|
if (entry.path.length === 0) continue
|
||||||
|
this.pathGraphics.beginPath()
|
||||||
|
this.pathGraphics.moveTo(entry.x, entry.y)
|
||||||
|
for (const step of entry.path) {
|
||||||
|
this.pathGraphics.lineTo(
|
||||||
|
(step.tileX + 0.5) * TILE_SIZE,
|
||||||
|
(step.tileY + 0.5) * TILE_SIZE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.pathGraphics.strokePath()
|
||||||
|
|
||||||
|
// Mark the destination tile
|
||||||
|
const last = entry.path[entry.path.length - 1]
|
||||||
|
this.pathGraphics.fillStyle(0x00ffff, 0.4)
|
||||||
|
this.pathGraphics.fillRect(
|
||||||
|
last.tileX * TILE_SIZE,
|
||||||
|
last.tileY * TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects and returns all debug data for the current frame.
|
||||||
|
* Called by UIScene to populate the debug panel.
|
||||||
|
* @param ptr - The active pointer, used to resolve world position
|
||||||
|
* @returns Snapshot of game state for display
|
||||||
|
*/
|
||||||
|
getDebugData(ptr: Phaser.Input.Pointer): DebugData {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const villagers = Object.values(state.world.villagers)
|
||||||
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
const tileType = this.worldSystem.getTileType(tileX, tileY)
|
||||||
|
|
||||||
|
const nisseByState = { idle: 0, walking: 0, working: 0, sleeping: 0 }
|
||||||
|
const jobsByType = { chop: 0, mine: 0, farm: 0 }
|
||||||
|
|
||||||
|
for (const v of villagers) {
|
||||||
|
nisseByState[v.aiState as keyof typeof nisseByState]++
|
||||||
|
if (v.job && (v.aiState === 'working' || v.aiState === 'walking')) {
|
||||||
|
jobsByType[v.job.type as keyof typeof jobsByType]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcesOnTile = Object.values(state.world.resources)
|
||||||
|
.filter(r => r.tileX === tileX && r.tileY === tileY)
|
||||||
|
.map(r => ({ kind: r.kind, hp: r.hp }))
|
||||||
|
|
||||||
|
const buildingsOnTile = Object.values(state.world.buildings)
|
||||||
|
.filter(b => b.tileX === tileX && b.tileY === tileY)
|
||||||
|
.map(b => b.kind)
|
||||||
|
|
||||||
|
const cropsOnTile = Object.values(state.world.crops)
|
||||||
|
.filter(c => c.tileX === tileX && c.tileY === tileY)
|
||||||
|
.map(c => ({ kind: c.kind, stage: c.stage, maxStage: c.maxStage }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
fps: Math.round(this.scene.game.loop.actualFps),
|
||||||
|
mouseWorld: { x: ptr.worldX, y: ptr.worldY },
|
||||||
|
mouseTile: { tileX, tileY },
|
||||||
|
tileType: TILE_NAMES[tileType] ?? `UNKNOWN(${tileType})`,
|
||||||
|
resourcesOnTile,
|
||||||
|
buildingsOnTile,
|
||||||
|
cropsOnTile,
|
||||||
|
nisseTotal: villagers.length,
|
||||||
|
nisseByState,
|
||||||
|
jobsByType,
|
||||||
|
activePaths: this.villagerSystem.getActivePaths().length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,16 @@ import type { CropKind, CropState, ItemId } from '../types'
|
|||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import type { LocalAdapter } from '../NetworkAdapter'
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
|
|
||||||
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water'
|
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water'
|
||||||
|
|
||||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water']
|
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water']
|
||||||
|
|
||||||
const TOOL_LABELS: Record<FarmingTool, string> = {
|
const TOOL_LABELS: Record<FarmingTool, string> = {
|
||||||
none: '— None',
|
none: '— None',
|
||||||
hoe: '⛏ Hoe (till grass)',
|
hoe: '⛏ Hoe (till grass)',
|
||||||
wheat_seed: '🌾 Wheat Seeds',
|
wheat_seed: '🌾 Wheat Seeds',
|
||||||
carrot_seed: '🥕 Carrot Seeds',
|
carrot_seed: '🥕 Carrot Seeds',
|
||||||
|
tree_seed: '🌲 Tree Seeds (plant on grass)',
|
||||||
water: '💧 Watering Can',
|
water: '💧 Watering Can',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,14 @@ export class FarmingSystem {
|
|||||||
onToolChange?: (tool: FarmingTool, label: string) => void
|
onToolChange?: (tool: FarmingTool, label: string) => void
|
||||||
/** Emitted for toast notifications */
|
/** Emitted for toast notifications */
|
||||||
onMessage?: (msg: string) => void
|
onMessage?: (msg: string) => void
|
||||||
|
/**
|
||||||
|
* Called when the player uses the tree_seed tool on a tile.
|
||||||
|
* @param tileX - Target tile column
|
||||||
|
* @param tileY - Target tile row
|
||||||
|
* @param underlyingTile - The tile type at that position
|
||||||
|
* @returns true if planting succeeded, false if validation failed
|
||||||
|
*/
|
||||||
|
onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
@@ -89,9 +98,27 @@ export class FarmingSystem {
|
|||||||
|
|
||||||
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
|
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
|
||||||
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
|
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
|
||||||
|
else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile)
|
||||||
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
|
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem).
|
||||||
|
* Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure.
|
||||||
|
* @param tileX - Target tile column
|
||||||
|
* @param tileY - Target tile row
|
||||||
|
* @param tile - Current tile type at that position
|
||||||
|
*/
|
||||||
|
private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void {
|
||||||
|
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||||
|
this.onMessage?.('Plant tree seeds on grass!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ok = this.onPlantTreeSeed?.(tileX, tileY, tile)
|
||||||
|
if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!')
|
||||||
|
else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)')
|
||||||
|
}
|
||||||
|
|
||||||
private tillSoil(tileX: number, tileY: number, tile: TileType): void {
|
private tillSoil(tileX: number, tileY: number, tile: TileType): void {
|
||||||
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||||
this.onMessage?.('Hoe only works on grass!')
|
this.onMessage?.('Hoe only works on grass!')
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ export class ResourceSystem {
|
|||||||
this.removeSprite(id)
|
this.removeSprite(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a sprite for a resource that was created at runtime
|
||||||
|
* (e.g. a tree grown from a seedling). The resource must already be
|
||||||
|
* present in the game state when this is called.
|
||||||
|
* @param node - The resource node to render
|
||||||
|
*/
|
||||||
|
public spawnResourcePublic(node: ResourceNodeState): void {
|
||||||
|
this.spawnSprite(node)
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when WorldSystem changes a tile (e.g. after tree removed) */
|
/** Called when WorldSystem changes a tile (e.g. after tree removed) */
|
||||||
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
|
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|||||||
130
src/systems/TreeSeedlingSystem.ts
Normal file
130
src/systems/TreeSeedlingSystem.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE, TREE_SEEDLING_STAGE_MS } from '../config'
|
||||||
|
import { TileType, PLANTABLE_TILES } from '../types'
|
||||||
|
import type { TreeSeedlingState } from '../types'
|
||||||
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
|
import type { WorldSystem } from './WorldSystem'
|
||||||
|
|
||||||
|
export class TreeSeedlingSystem {
|
||||||
|
private scene: Phaser.Scene
|
||||||
|
private adapter: LocalAdapter
|
||||||
|
private worldSystem: WorldSystem
|
||||||
|
private sprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param adapter - Network adapter for dispatching state actions
|
||||||
|
* @param worldSystem - Used to refresh the terrain canvas when a seedling matures
|
||||||
|
*/
|
||||||
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||||
|
this.scene = scene
|
||||||
|
this.adapter = adapter
|
||||||
|
this.worldSystem = worldSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawns sprites for all seedlings that exist in the saved state. */
|
||||||
|
create(): void {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
for (const s of Object.values(state.world.treeSeedlings)) {
|
||||||
|
this.spawnSprite(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks all seedling growth timers and handles stage changes.
|
||||||
|
* Stage 0→1: updates the sprite to the sapling texture.
|
||||||
|
* Stage 1→2: removes the seedling, spawns a tree resource, and updates the terrain canvas.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
|
update(delta: number): void {
|
||||||
|
const advanced = stateManager.tickSeedlings(delta)
|
||||||
|
for (const id of advanced) {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const seedling = state.world.treeSeedlings[id]
|
||||||
|
if (!seedling) continue
|
||||||
|
|
||||||
|
if (seedling.stage === 2) {
|
||||||
|
// Fully mature: become a FOREST tile and a real tree resource
|
||||||
|
const { tileX, tileY } = seedling
|
||||||
|
this.removeSprite(id)
|
||||||
|
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
||||||
|
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
||||||
|
|
||||||
|
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
||||||
|
this.adapter.send({
|
||||||
|
type: 'SPAWN_RESOURCE',
|
||||||
|
resource: { id: resourceId, tileX, tileY, kind: 'tree', hp: 3 },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Stage 0→1: update sprite to sapling
|
||||||
|
const sprite = this.sprites.get(id)
|
||||||
|
if (sprite) sprite.setTexture(`seedling_${seedling.stage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to plant a tree seedling on a grass tile.
|
||||||
|
* Validates that the stockpile has at least one tree_seed, the tile type is
|
||||||
|
* plantable (GRASS or DARK_GRASS), and no other object occupies the tile.
|
||||||
|
* @param tileX - Target tile column
|
||||||
|
* @param tileY - Target tile row
|
||||||
|
* @param underlyingTile - The current tile type (stored on the seedling for later restoration)
|
||||||
|
* @returns true if the seedling was planted, false if validation failed
|
||||||
|
*/
|
||||||
|
plantSeedling(tileX: number, tileY: number, underlyingTile: TileType): boolean {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
|
||||||
|
if ((state.world.stockpile.tree_seed ?? 0) <= 0) return false
|
||||||
|
if (!PLANTABLE_TILES.has(underlyingTile)) return false
|
||||||
|
|
||||||
|
const occupied =
|
||||||
|
Object.values(state.world.resources).some(r => r.tileX === tileX && r.tileY === tileY) ||
|
||||||
|
Object.values(state.world.buildings).some(b => b.tileX === tileX && b.tileY === tileY) ||
|
||||||
|
Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY) ||
|
||||||
|
Object.values(state.world.treeSeedlings).some(s => s.tileX === tileX && s.tileY === tileY)
|
||||||
|
|
||||||
|
if (occupied) return false
|
||||||
|
|
||||||
|
const id = `seedling_${tileX}_${tileY}_${Date.now()}`
|
||||||
|
const seedling: TreeSeedlingState = {
|
||||||
|
id, tileX, tileY,
|
||||||
|
stage: 0,
|
||||||
|
stageTimerMs: TREE_SEEDLING_STAGE_MS,
|
||||||
|
underlyingTile,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.adapter.send({ type: 'PLANT_TREE_SEED', seedling })
|
||||||
|
this.spawnSprite(seedling)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and registers the sprite for a seedling.
|
||||||
|
* @param s - Seedling state to render
|
||||||
|
*/
|
||||||
|
private spawnSprite(s: TreeSeedlingState): void {
|
||||||
|
const x = (s.tileX + 0.5) * TILE_SIZE
|
||||||
|
const y = (s.tileY + 0.5) * TILE_SIZE
|
||||||
|
const key = `seedling_${Math.min(s.stage, 2)}`
|
||||||
|
const sprite = this.scene.add.image(x, y, key)
|
||||||
|
.setOrigin(0.5, 0.85)
|
||||||
|
.setDepth(5)
|
||||||
|
this.sprites.set(s.id, sprite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the sprite for a seedling and removes it from the registry.
|
||||||
|
* @param id - Seedling ID
|
||||||
|
*/
|
||||||
|
private removeSprite(id: string): void {
|
||||||
|
const s = this.sprites.get(id)
|
||||||
|
if (s) { s.destroy(); this.sprites.delete(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroys all seedling sprites and clears the registry. */
|
||||||
|
destroy(): void {
|
||||||
|
for (const id of [...this.sprites.keys()]) this.removeSprite(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import type { FarmingSystem } from './FarmingSystem'
|
|||||||
|
|
||||||
const ARRIVAL_PX = 3
|
const ARRIVAL_PX = 3
|
||||||
|
|
||||||
|
const WORK_LOG_MAX = 20
|
||||||
|
|
||||||
interface VillagerRuntime {
|
interface VillagerRuntime {
|
||||||
sprite: Phaser.GameObjects.Image
|
sprite: Phaser.GameObjects.Image
|
||||||
nameLabel: Phaser.GameObjects.Text
|
nameLabel: Phaser.GameObjects.Text
|
||||||
@@ -20,6 +22,8 @@ interface VillagerRuntime {
|
|||||||
destination: 'job' | 'stockpile' | 'bed' | null
|
destination: 'job' | 'stockpile' | 'bed' | null
|
||||||
workTimer: number
|
workTimer: number
|
||||||
idleScanTimer: number
|
idleScanTimer: number
|
||||||
|
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
|
||||||
|
workLog: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VillagerSystem {
|
export class VillagerSystem {
|
||||||
@@ -35,19 +39,34 @@ export class VillagerSystem {
|
|||||||
private nameIndex = 0
|
private nameIndex = 0
|
||||||
|
|
||||||
onMessage?: (msg: string) => void
|
onMessage?: (msg: string) => void
|
||||||
|
onNisseClick?: (villagerId: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param adapter - Network adapter for dispatching state actions
|
||||||
|
* @param worldSystem - Used for passability checks during pathfinding
|
||||||
|
*/
|
||||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
this.worldSystem = worldSystem
|
this.worldSystem = worldSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire in sibling systems after construction */
|
/**
|
||||||
|
* Wires in sibling systems that are not available at construction time.
|
||||||
|
* Must be called before create().
|
||||||
|
* @param resourceSystem - Used to remove harvested resource sprites
|
||||||
|
* @param farmingSystem - Used to remove harvested crop sprites
|
||||||
|
*/
|
||||||
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
||||||
this.resourceSystem = resourceSystem
|
this.resourceSystem = resourceSystem
|
||||||
this.farmingSystem = farmingSystem
|
this.farmingSystem = farmingSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns sprites for all Nisse that exist in the saved state
|
||||||
|
* and re-claims any active job targets.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
for (const v of Object.values(state.world.villagers)) {
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
@@ -57,6 +76,10 @@ export class VillagerSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the spawn timer and ticks every Nisse's AI.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
update(delta: number): void {
|
update(delta: number): void {
|
||||||
this.spawnTimer += delta
|
this.spawnTimer += delta
|
||||||
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
||||||
@@ -72,6 +95,12 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Per-villager tick ────────────────────────────────────────────────────
|
// ─── Per-villager tick ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the correct AI tick method based on the villager's current state,
|
||||||
|
* then syncs the sprite, name label, energy bar, and job icon to the state.
|
||||||
|
* @param v - Villager state from the store
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickVillager(v: VillagerState, delta: number): void {
|
private tickVillager(v: VillagerState, delta: number): void {
|
||||||
const rt = this.runtime.get(v.id)
|
const rt = this.runtime.get(v.id)
|
||||||
if (!rt) return
|
if (!rt) return
|
||||||
@@ -97,6 +126,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the idle AI state: hauls items to stockpile if carrying any,
|
||||||
|
* seeks a bed if energy is low, otherwise picks the next job and begins walking.
|
||||||
|
* Applies a cooldown before scanning again if no job is found.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime (sprites, path, timers)
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
// Decrement scan timer if cooling down
|
// Decrement scan timer if cooling down
|
||||||
if (rt.idleScanTimer > 0) {
|
if (rt.idleScanTimer > 0) {
|
||||||
@@ -107,13 +144,21 @@ export class VillagerSystem {
|
|||||||
// Carrying items? → find stockpile
|
// Carrying items? → find stockpile
|
||||||
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||||
const sp = this.nearestBuilding(v, 'stockpile_zone')
|
const sp = this.nearestBuilding(v, 'stockpile_zone')
|
||||||
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return }
|
if (sp) {
|
||||||
|
this.addLog(v.id, '→ Hauling to stockpile')
|
||||||
|
this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low energy → find bed
|
// Low energy → find bed
|
||||||
if (v.energy < 25) {
|
if (v.energy < 25) {
|
||||||
const bed = this.findBed(v)
|
const bed = this.findBed(v)
|
||||||
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return }
|
if (bed) {
|
||||||
|
this.addLog(v.id, '→ Going to sleep (low energy)')
|
||||||
|
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a job
|
// Find a job
|
||||||
@@ -124,6 +169,7 @@ export class VillagerSystem {
|
|||||||
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
||||||
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
||||||
})
|
})
|
||||||
|
this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`)
|
||||||
this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
|
this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
|
||||||
} else {
|
} else {
|
||||||
// No job available — wait before scanning again
|
// No job available — wait before scanning again
|
||||||
@@ -133,6 +179,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── WALKING ──────────────────────────────────────────────────────────────
|
// ─── WALKING ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the Nisse along its path toward the current destination.
|
||||||
|
* Calls onArrived when the path is exhausted.
|
||||||
|
* Drains energy slowly while walking.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
if (rt.path.length === 0) {
|
if (rt.path.length === 0) {
|
||||||
this.onArrived(v, rt)
|
this.onArrived(v, rt)
|
||||||
@@ -161,6 +215,12 @@ export class VillagerSystem {
|
|||||||
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a Nisse reaches its destination tile.
|
||||||
|
* Transitions to the appropriate next AI state based on destination type.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
*/
|
||||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||||
switch (rt.destination) {
|
switch (rt.destination) {
|
||||||
case 'job':
|
case 'job':
|
||||||
@@ -172,10 +232,12 @@ export class VillagerSystem {
|
|||||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||||
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
||||||
|
this.addLog(v.id, '✓ Deposited at stockpile')
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'bed':
|
case 'bed':
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
|
||||||
|
this.addLog(v.id, '💤 Sleeping...')
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -186,6 +248,14 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── WORKING ──────────────────────────────────────────────────────────────
|
// ─── WORKING ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts down the work timer and performs the harvest action on completion.
|
||||||
|
* Handles chop, mine, and farm job types.
|
||||||
|
* Returns the Nisse to idle when done.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
rt.workTimer -= delta
|
rt.workTimer -= delta
|
||||||
// Wobble while working
|
// Wobble while working
|
||||||
@@ -204,17 +274,20 @@ export class VillagerSystem {
|
|||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||||
|
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
||||||
|
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||||
this.resourceSystem.removeResource(job.targetId)
|
this.resourceSystem.removeResource(job.targetId)
|
||||||
|
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||||
}
|
}
|
||||||
} else if (job.type === 'mine') {
|
} else if (job.type === 'mine') {
|
||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
||||||
|
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||||
this.resourceSystem.removeResource(job.targetId)
|
this.resourceSystem.removeResource(job.targetId)
|
||||||
|
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||||
}
|
}
|
||||||
} else if (job.type === 'farm') {
|
} else if (job.type === 'farm') {
|
||||||
const crop = state.world.crops[job.targetId]
|
const crop = state.world.crops[job.targetId]
|
||||||
@@ -222,6 +295,7 @@ export class VillagerSystem {
|
|||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
|
||||||
this.farmingSystem.removeCropSpritePublic(job.targetId)
|
this.farmingSystem.removeCropSpritePublic(job.targetId)
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
||||||
|
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +311,12 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── SLEEPING ─────────────────────────────────────────────────────────────
|
// ─── SLEEPING ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores energy while sleeping. Returns to idle once energy is full.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||||
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
|
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
|
||||||
// Gentle bob while sleeping
|
// Gentle bob while sleeping
|
||||||
@@ -244,11 +324,19 @@ export class VillagerSystem {
|
|||||||
if (v.energy >= 100) {
|
if (v.energy >= 100) {
|
||||||
rt.sprite.setAngle(0)
|
rt.sprite.setAngle(0)
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||||
|
this.addLog(v.id, '✓ Woke up (energy full)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the best available job for a Nisse based on their priority settings.
|
||||||
|
* Among jobs at the same priority level, the closest one wins.
|
||||||
|
* Returns null if no unclaimed job is available.
|
||||||
|
* @param v - Villager state (used for position and priorities)
|
||||||
|
* @returns The chosen job candidate, or null
|
||||||
|
*/
|
||||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const p = v.priorities
|
const p = v.priorities
|
||||||
@@ -262,12 +350,17 @@ export class VillagerSystem {
|
|||||||
if (p.chop > 0) {
|
if (p.chop > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.resources)) {
|
||||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||||
|
// Skip trees with no reachable neighbour — A* cannot enter an impassable goal
|
||||||
|
// tile unless at least one passable neighbour exists to jump from.
|
||||||
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (p.mine > 0) {
|
if (p.mine > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.resources)) {
|
||||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||||
|
// Same reachability guard for rock tiles.
|
||||||
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,6 +382,15 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a path from the Nisse's current tile to the target tile and
|
||||||
|
* begins walking. If no path is found, the job is cleared and a cooldown applied.
|
||||||
|
* @param v - Villager state
|
||||||
|
* @param rt - Villager runtime
|
||||||
|
* @param tileX - Target tile X
|
||||||
|
* @param tileY - Target tile Y
|
||||||
|
* @param dest - Semantic destination type (used by onArrived)
|
||||||
|
*/
|
||||||
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
||||||
const sx = Math.floor(v.x / TILE_SIZE)
|
const sx = Math.floor(v.x / TILE_SIZE)
|
||||||
const sy = Math.floor(v.y / TILE_SIZE)
|
const sy = Math.floor(v.y / TILE_SIZE)
|
||||||
@@ -299,7 +401,7 @@ export class VillagerSystem {
|
|||||||
this.claimed.delete(v.job.targetId)
|
this.claimed.delete(v.job.targetId)
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||||
}
|
}
|
||||||
rt.idleScanTimer = 1500 // longer delay after failed pathfind
|
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +412,11 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Building finders ─────────────────────────────────────────────────────
|
// ─── Building finders ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the nearest building of the given kind to the Nisse, or null if none exist.
|
||||||
|
* @param v - Villager state (used as reference position)
|
||||||
|
* @param kind - Building kind to search for
|
||||||
|
*/
|
||||||
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
||||||
@@ -319,6 +426,11 @@ export class VillagerSystem {
|
|||||||
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Nisse's assigned bed if it still exists, otherwise the nearest bed.
|
||||||
|
* Returns null if no beds are placed.
|
||||||
|
* @param v - Villager state
|
||||||
|
*/
|
||||||
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
// Prefer assigned bed
|
// Prefer assigned bed
|
||||||
@@ -326,8 +438,28 @@ export class VillagerSystem {
|
|||||||
return this.nearestBuilding(v, 'bed') as any
|
return this.nearestBuilding(v, 'bed') as any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if at least one of the 8 neighbours of the given tile is passable.
|
||||||
|
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
|
||||||
|
* such as trees deep inside a dense forest cluster where A* can never reach the goal
|
||||||
|
* tile because no passable tile is adjacent to it.
|
||||||
|
* @param tileX - Target tile X
|
||||||
|
* @param tileY - Target tile Y
|
||||||
|
*/
|
||||||
|
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
|
||||||
|
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
|
||||||
|
for (const [dx, dy] of DIRS) {
|
||||||
|
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to spawn a new Nisse if a free bed is available and the
|
||||||
|
* current population is below the bed count.
|
||||||
|
*/
|
||||||
private trySpawn(): void {
|
private trySpawn(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
||||||
@@ -361,6 +493,11 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
// ─── Sprite management ────────────────────────────────────────────────────
|
// ─── Sprite management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and registers all runtime objects (sprite, label, energy bar, icon)
|
||||||
|
* for a newly added Nisse.
|
||||||
|
* @param v - Villager state to create sprites for
|
||||||
|
*/
|
||||||
private spawnSprite(v: VillagerState): void {
|
private spawnSprite(v: VillagerState): void {
|
||||||
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
|
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
|
||||||
|
|
||||||
@@ -372,9 +509,20 @@ export class VillagerSystem {
|
|||||||
const energyBar = this.scene.add.graphics().setDepth(12)
|
const energyBar = this.scene.add.graphics().setDepth(12)
|
||||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
||||||
|
|
||||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
sprite.setInteractive()
|
||||||
|
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
||||||
|
|
||||||
|
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the energy bar graphic for a Nisse at the given world position.
|
||||||
|
* Color transitions green → orange → red as energy decreases.
|
||||||
|
* @param g - Graphics object to draw into
|
||||||
|
* @param x - World X center of the Nisse
|
||||||
|
* @param y - World Y center of the Nisse
|
||||||
|
* @param energy - Current energy value (0–100)
|
||||||
|
*/
|
||||||
private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
|
private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
|
||||||
const W = 20, H = 3
|
const W = 20, H = 3
|
||||||
g.clear()
|
g.clear()
|
||||||
@@ -383,8 +531,29 @@ export class VillagerSystem {
|
|||||||
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
|
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Work log ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends a message to the runtime work log for the given Nisse.
|
||||||
|
* Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found.
|
||||||
|
* @param villagerId - Target Nisse ID
|
||||||
|
* @param msg - Log message to prepend
|
||||||
|
*/
|
||||||
|
private addLog(villagerId: string, msg: string): void {
|
||||||
|
const rt = this.runtime.get(villagerId)
|
||||||
|
if (!rt) return
|
||||||
|
rt.workLog.unshift(msg)
|
||||||
|
if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a short human-readable status string for the given Nisse,
|
||||||
|
* suitable for display in UI panels.
|
||||||
|
* @param villagerId - The Nisse's ID
|
||||||
|
* @returns Status string, or '—' if the Nisse is not found
|
||||||
|
*/
|
||||||
getStatusText(villagerId: string): string {
|
getStatusText(villagerId: string): string {
|
||||||
const v = stateManager.getState().world.villagers[villagerId]
|
const v = stateManager.getState().world.villagers[villagerId]
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
@@ -397,6 +566,37 @@ export class VillagerSystem {
|
|||||||
return '💭 Idle'
|
return '💭 Idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of the runtime work log for the given Nisse (newest first).
|
||||||
|
* @param villagerId - The Nisse's ID
|
||||||
|
* @returns Array of log strings, or empty array if not found
|
||||||
|
*/
|
||||||
|
getWorkLog(villagerId: string): string[] {
|
||||||
|
return [...(this.runtime.get(villagerId)?.workLog ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current world position and remaining path for every Nisse
|
||||||
|
* that is currently in the 'walking' state. Used by DebugSystem for
|
||||||
|
* pathfinding visualization.
|
||||||
|
* @returns Array of path entries, one per walking Nisse
|
||||||
|
*/
|
||||||
|
getActivePaths(): Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const result: Array<{ x: number; y: number; path: Array<{ tileX: number; tileY: number }> }> = []
|
||||||
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
|
if (v.aiState !== 'walking') continue
|
||||||
|
const rt = this.runtime.get(v.id)
|
||||||
|
if (!rt) continue
|
||||||
|
result.push({ x: v.x, y: v.y, path: [...rt.path] })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
|
* Should be called when the scene shuts down.
|
||||||
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
for (const rt of this.runtime.values()) {
|
for (const rt of this.runtime.values()) {
|
||||||
rt.sprite.destroy(); rt.nameLabel.destroy()
|
rt.sprite.destroy(); rt.nameLabel.destroy()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||||
import { TileType, IMPASSABLE } from '../types'
|
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
|
||||||
const BIOME_COLORS: Record<number, string> = {
|
const BIOME_COLORS: Record<number, string> = {
|
||||||
@@ -18,22 +18,32 @@ const BIOME_COLORS: Record<number, string> = {
|
|||||||
export class WorldSystem {
|
export class WorldSystem {
|
||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private map!: Phaser.Tilemaps.Tilemap
|
private map!: Phaser.Tilemaps.Tilemap
|
||||||
|
/**
|
||||||
|
* Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile
|
||||||
|
* that is currently occupied by a tree or rock resource.
|
||||||
|
* Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked.
|
||||||
|
*/
|
||||||
|
private resourceTiles = new Set<number>()
|
||||||
private tileset!: Phaser.Tilemaps.Tileset
|
private tileset!: Phaser.Tilemaps.Tileset
|
||||||
private bgImage!: Phaser.GameObjects.Image
|
private bgImage!: Phaser.GameObjects.Image
|
||||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||||
|
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
|
||||||
|
|
||||||
|
/** @param scene - The Phaser scene this system belongs to */
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the terrain background canvas from saved tile data,
|
||||||
|
* creates the built-tile tilemap layer, and sets camera bounds.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
|
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
|
||||||
const canvas = document.createElement('canvas')
|
const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
|
||||||
canvas.width = WORLD_TILES
|
const ctx = canvasTexture.context
|
||||||
canvas.height = WORLD_TILES
|
|
||||||
const ctx = canvas.getContext('2d')!
|
|
||||||
|
|
||||||
for (let y = 0; y < WORLD_TILES; y++) {
|
for (let y = 0; y < WORLD_TILES; y++) {
|
||||||
for (let x = 0; x < WORLD_TILES; x++) {
|
for (let x = 0; x < WORLD_TILES; x++) {
|
||||||
@@ -43,12 +53,14 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scene.textures.addCanvas('terrain_bg', canvas)
|
canvasTexture.refresh()
|
||||||
|
this.bgCanvasTexture = canvasTexture
|
||||||
|
|
||||||
this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
|
this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
|
||||||
.setOrigin(0, 0)
|
.setOrigin(0, 0)
|
||||||
.setScale(TILE_SIZE)
|
.setScale(TILE_SIZE)
|
||||||
.setDepth(0)
|
.setDepth(0)
|
||||||
this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR)
|
canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR)
|
||||||
|
|
||||||
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
|
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
|
||||||
this.map = this.scene.make.tilemap({
|
this.map = this.scene.make.tilemap({
|
||||||
@@ -79,12 +91,22 @@ export class WorldSystem {
|
|||||||
|
|
||||||
// Camera bounds
|
// Camera bounds
|
||||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||||
|
|
||||||
|
this.initResourceTiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||||
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
||||||
return this.builtLayer
|
return this.builtLayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places or removes a tile on the built layer.
|
||||||
|
* Built tile types are added; natural types remove the built-layer entry.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @param type - New tile type to apply
|
||||||
|
*/
|
||||||
setTile(tileX: number, tileY: number, type: TileType): void {
|
setTile(tileX: number, tileY: number, type: TileType): void {
|
||||||
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
||||||
if (BUILT_TILES.has(type)) {
|
if (BUILT_TILES.has(type)) {
|
||||||
@@ -95,13 +117,67 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the tile at the given coordinates can be walked on.
|
||||||
|
* Water and wall tiles are always impassable.
|
||||||
|
* Forest and rock terrain tiles are only impassable when a resource
|
||||||
|
* (tree or rock) currently occupies them — empty forest floor and bare
|
||||||
|
* rocky ground are walkable.
|
||||||
|
* Out-of-bounds tiles are treated as impassable.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
*/
|
||||||
isPassable(tileX: number, tileY: number): boolean {
|
isPassable(tileX: number, tileY: number): boolean {
|
||||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
||||||
return !IMPASSABLE.has(tile)
|
if (IMPASSABLE.has(tile)) return false
|
||||||
|
if (RESOURCE_TERRAIN.has(tile)) {
|
||||||
|
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the resource tile index from the current world state.
|
||||||
|
* Called once in create() so that isPassable() has an O(1) lookup.
|
||||||
|
*/
|
||||||
|
private initResourceTiles(): void {
|
||||||
|
this.resourceTiles.clear()
|
||||||
|
const state = stateManager.getState()
|
||||||
|
for (const res of Object.values(state.world.resources)) {
|
||||||
|
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a newly placed resource so isPassable() treats the tile as blocked.
|
||||||
|
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
|
||||||
|
* @param tileX - Resource tile column
|
||||||
|
* @param tileY - Resource tile row
|
||||||
|
*/
|
||||||
|
addResourceTile(tileX: number, tileY: number): void {
|
||||||
|
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a resource from the tile index so isPassable() treats the tile as free.
|
||||||
|
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
|
||||||
|
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
|
||||||
|
* but keeps the index clean for correctness.
|
||||||
|
* @param tileX - Resource tile column
|
||||||
|
* @param tileY - Resource tile row
|
||||||
|
*/
|
||||||
|
removeResourceTile(tileX: number, tileY: number): void {
|
||||||
|
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts world pixel coordinates to tile coordinates.
|
||||||
|
* @param worldX - World X in pixels
|
||||||
|
* @param worldY - World Y in pixels
|
||||||
|
* @returns Integer tile position
|
||||||
|
*/
|
||||||
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
||||||
return {
|
return {
|
||||||
tileX: Math.floor(worldX / TILE_SIZE),
|
tileX: Math.floor(worldX / TILE_SIZE),
|
||||||
@@ -109,6 +185,12 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts tile coordinates to the world pixel center of that tile.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @returns World pixel center position
|
||||||
|
*/
|
||||||
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
||||||
@@ -116,11 +198,32 @@ export class WorldSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tile type at the given tile coordinates from saved state.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
*/
|
||||||
getTileType(tileX: number, tileY: number): TileType {
|
getTileType(tileX: number, tileY: number): TileType {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single tile's pixel on the background canvas and refreshes the GPU texture.
|
||||||
|
* Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery,
|
||||||
|
* or GRASS → FOREST when a seedling matures).
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @param type - New tile type to reflect visually
|
||||||
|
*/
|
||||||
|
refreshTerrainTile(tileX: number, tileY: number, type: TileType): void {
|
||||||
|
const color = BIOME_COLORS[type] ?? '#0a2210'
|
||||||
|
this.bgCanvasTexture.context.fillStyle = color
|
||||||
|
this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1)
|
||||||
|
this.bgCanvasTexture.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroys the tilemap and background image. */
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.map.destroy()
|
this.map.destroy()
|
||||||
this.bgImage.destroy()
|
this.bgImage.destroy()
|
||||||
|
|||||||
39
src/types.ts
39
src/types.ts
@@ -12,15 +12,25 @@ export enum TileType {
|
|||||||
WATERED_SOIL = 10,
|
WATERED_SOIL = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tiles that are always impassable regardless of what is on them. */
|
||||||
export const IMPASSABLE = new Set<number>([
|
export const IMPASSABLE = new Set<number>([
|
||||||
TileType.DEEP_WATER,
|
TileType.DEEP_WATER,
|
||||||
TileType.SHALLOW_WATER,
|
TileType.SHALLOW_WATER,
|
||||||
TileType.FOREST,
|
|
||||||
TileType.ROCK,
|
|
||||||
TileType.WALL,
|
TileType.WALL,
|
||||||
])
|
])
|
||||||
|
|
||||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot'
|
/**
|
||||||
|
* Terrain tiles whose passability depends on whether a resource
|
||||||
|
* (tree or rock) is currently placed on them.
|
||||||
|
* An empty FOREST tile is walkable forest floor; a ROCK tile without a
|
||||||
|
* rock resource is just rocky ground.
|
||||||
|
*/
|
||||||
|
export const RESOURCE_TERRAIN = new Set<number>([TileType.FOREST, TileType.ROCK])
|
||||||
|
|
||||||
|
/** Tiles on which tree seedlings may be planted. */
|
||||||
|
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
||||||
|
|
||||||
|
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
||||||
|
|
||||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
|
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
|
||||||
|
|
||||||
@@ -90,6 +100,18 @@ export interface PlayerState {
|
|||||||
inventory: Partial<Record<ItemId, number>>
|
inventory: Partial<Record<ItemId, number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TreeSeedlingState {
|
||||||
|
id: string
|
||||||
|
tileX: number
|
||||||
|
tileY: number
|
||||||
|
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||||
|
stage: number
|
||||||
|
/** Time remaining until next stage advance, in milliseconds. */
|
||||||
|
stageTimerMs: number
|
||||||
|
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||||
|
underlyingTile: TileType
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorldState {
|
export interface WorldState {
|
||||||
seed: number
|
seed: number
|
||||||
tiles: number[]
|
tiles: number[]
|
||||||
@@ -98,6 +120,13 @@ export interface WorldState {
|
|||||||
crops: Record<string, CropState>
|
crops: Record<string, CropState>
|
||||||
villagers: Record<string, VillagerState>
|
villagers: Record<string, VillagerState>
|
||||||
stockpile: Partial<Record<ItemId, number>>
|
stockpile: Partial<Record<ItemId, number>>
|
||||||
|
/** Planted tree seedlings, keyed by ID. */
|
||||||
|
treeSeedlings: Record<string, TreeSeedlingState>
|
||||||
|
/**
|
||||||
|
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
|
||||||
|
* Value is remaining milliseconds until the tile reverts to GRASS.
|
||||||
|
*/
|
||||||
|
tileRecovery: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameStateData {
|
export interface GameStateData {
|
||||||
@@ -123,3 +152,7 @@ export type GameAction =
|
|||||||
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
|
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
|
||||||
| { type: 'VILLAGER_DEPOSIT'; villagerId: string }
|
| { type: 'VILLAGER_DEPOSIT'; villagerId: string }
|
||||||
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
|
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
|
||||||
|
| { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState }
|
||||||
|
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
||||||
|
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
||||||
|
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user