Compare commits
22 Commits
feature/ov
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
| 20858a1be1 | |||
| 3b021127a4 | |||
| d02ed33435 | |||
| c7cf971e54 | |||
| 08dffa135f | |||
| 4f2e9f73b6 | |||
| 84b6e51746 | |||
| 5f646d54ca | |||
| 94b2f7f457 | |||
| cd171c859c | |||
| d9ef57c6b0 | |||
| 87f69b4774 | |||
| 8d2c58cb5f | |||
| 986c2ea9eb | |||
| 1d8b2b2b9c | |||
| 969a82949e | |||
| d3696c6380 | |||
| b024cf36fb | |||
| 8197348cfc | |||
| 732d9100ab | |||
| f2a1811a36 | |||
| 774054db56 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -7,7 +7,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Event-queue timers** (Issue #36): crops, tree seedlings, and tile-recovery events now use a sorted priority queue with absolute `gameTime` timestamps instead of per-frame countdown iteration — O(due items) per tick instead of O(total items); `WorldState.gameTime` tracks the in-game clock; save migrated from v5 to v6
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Action log in F3 debug panel** (Issue #37): last 15 actions dispatched through the adapter are shown in the F3 overlay under "Last Actions"; ring buffer maintained in `LocalAdapter`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Y-based depth sorting** (Issue #31): trees, rocks, seedlings and buildings now use `tileY + 5` as depth instead of fixed values — objects lower on screen always render in front of objects above them, regardless of spawn order; build ghost moved to depth 1000
|
||||||
|
- **Nisse always visible** (Issue #33): Nisse sprites fixed at depth 900, always rendered above world objects
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Försterkreislauf** (Issue #25):
|
||||||
|
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 1–2 `tree_seed` in den Stockpile
|
||||||
|
- **Försterhaus** (`forester_hut`): Neues Gebäude im Build-Menü (Kosten: 50 wood); Log-Hütten-Grafik mit Baum-Symbol; Klick auf das Haus öffnet ein Info-Panel
|
||||||
|
- **Zonenmarkierung**: Im Info-Panel öffnet „Edit Zone" den Zonen-Editor; innerhalb eines Radius von 5 Tiles können Tiles per Klick zur Pflanzzone hinzugefügt oder entfernt werden; markierte Tiles werden als halbtransparente grüne Fläche im Spiel angezeigt; Zone wird im Save gespeichert
|
||||||
|
- **Förster-Job** (`forester`): Nisse mit `forester`-Priorität > 0 pflanzen automatisch `tree_seed` auf leeren Zonen-Tiles; erfordert `tree_seed` im Stockpile
|
||||||
|
- **Chop-Priorisierung**: Beim Fällen werden Bäume innerhalb von Förster-Zonen bevorzugt; natürliche Bäume werden erst gefällt wenn keine Zonen-Bäume mehr vorhanden sind
|
||||||
|
- Nisse-Info-Panel und Nisse-Panel (V) zeigen jetzt auch die `forester`-Priorität als Schaltfläche
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
|
||||||
|
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
|
||||||
|
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
|
||||||
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
|
- **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
|
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
|
||||||
|
|
||||||
|
|||||||
79
CLAUDE.md
79
CLAUDE.md
@@ -73,3 +73,82 @@ npm run preview # Preview production build locally
|
|||||||
- **Systems** read/write state and are updated each game tick via Phaser's `update()`
|
- **Systems** read/write state and are updated each game tick via Phaser's `update()`
|
||||||
- **Scenes** are thin orchestrators — logic belongs in systems, not scenes
|
- **Scenes** are thin orchestrators — logic belongs in systems, not scenes
|
||||||
- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly
|
- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea Workflow (repo: tekki/nissefolk)
|
||||||
|
|
||||||
|
**Tool:** `tea` CLI (installed at `~/.local/bin/tea`, login `zally` configured).
|
||||||
|
Never use raw `curl` with `${CLAUDE_GITEA_TOKEN}` for Gitea — use `tea` instead.
|
||||||
|
All `tea` commands run from `~/game` (git remote `gitea` points to the repo).
|
||||||
|
|
||||||
|
**Git commands:** Always use `git -C ~/game <cmd>` — never `cd ~/game && git <cmd>` (triggers security prompt).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create PR (always wait for user approval before merging)
|
||||||
|
# Use ~/scripts/create-pr.sh — pass \n literally for newlines, the script expands them via printf.
|
||||||
|
# Never use heredocs or $(cat file) — they trigger permission prompts.
|
||||||
|
~/scripts/create-pr.sh "PR title" "Fixes #N.\n\n## What changed\n- item one\n- item two" feature/xyz
|
||||||
|
|
||||||
|
# List open PRs / issues
|
||||||
|
tea pr list --login zally
|
||||||
|
tea issue list --login zally
|
||||||
|
|
||||||
|
# View a single issue (body + comments)
|
||||||
|
tea issue --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||||
|
|
||||||
|
# Merge PR — ONLY after explicit user says "merge it"
|
||||||
|
tea pr merge --login zally --style merge <PR_NUMBER>
|
||||||
|
|
||||||
|
# Close issue
|
||||||
|
tea issue close --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||||
|
|
||||||
|
# List labels
|
||||||
|
tea labels list --login zally --repo tekki/nissefolk
|
||||||
|
|
||||||
|
# Set/remove labels on an issue (use label names, not IDs)
|
||||||
|
tea issue edit --login zally --repo tekki/nissefolk --add-labels "status: done" <N>
|
||||||
|
tea issue edit --login zally --repo tekki/nissefolk --remove-labels "status: in discussion" <N>
|
||||||
|
|
||||||
|
|
||||||
|
# Both flags can be combined; --add-labels takes precedence over --remove-labels
|
||||||
|
tea issue edit <N> --add-labels "status: done" --remove-labels "status: in progress" --repo tekki/nissefolk
|
||||||
|
|
||||||
|
# Note: "tea labels" manages label definitions in the repo — not issue assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
**Label IDs** (repo-specific, don't guess):
|
||||||
|
| ID | Name |
|
||||||
|
|----|------|
|
||||||
|
| 1 | feature |
|
||||||
|
| 2 | improvement |
|
||||||
|
| 3 | bug |
|
||||||
|
| 6 | status: backlog |
|
||||||
|
| 8 | status: ready |
|
||||||
|
| 9 | status: in progress |
|
||||||
|
| 10 | status: review |
|
||||||
|
| 11 | status: done |
|
||||||
|
|
||||||
|
**PR workflow rules:**
|
||||||
|
1. Commit → push branch → `tea pr create` → **share URL, stop, wait for user approval**
|
||||||
|
2. Only merge when user explicitly says so
|
||||||
|
3. After merge: close issue + set label to `status: done`
|
||||||
|
|
||||||
|
**master branch is protected** — direct push is rejected. Always use PRs.
|
||||||
|
|
||||||
|
**Routine load issue**
|
||||||
|
1. Load Issues
|
||||||
|
if-> If the label is status: ready
|
||||||
|
-> work as it says
|
||||||
|
-> use a new branch for each issue
|
||||||
|
-> test your code
|
||||||
|
-> commit your code
|
||||||
|
-> change the issue label
|
||||||
|
-> do an pr to master
|
||||||
|
|
||||||
|
if-> If the label is status: discussion
|
||||||
|
-> think if you need more information
|
||||||
|
-> ask questions as comment in gitea
|
||||||
|
|
||||||
|
**Issue create**
|
||||||
|
If i say something like "create an issue about..." you need to attach the labels to it to. Use status: discussion and feature/bug
|
||||||
@@ -8,12 +8,42 @@ export interface NetworkAdapter {
|
|||||||
onAction?: (action: GameAction) => void
|
onAction?: (action: GameAction) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_LOG_SIZE = 15
|
||||||
|
|
||||||
/** Singleplayer: apply actions immediately and synchronously */
|
/** Singleplayer: apply actions immediately and synchronously */
|
||||||
export class LocalAdapter implements NetworkAdapter {
|
export class LocalAdapter implements NetworkAdapter {
|
||||||
onAction?: (action: GameAction) => void
|
onAction?: (action: GameAction) => void
|
||||||
|
|
||||||
|
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
|
||||||
|
private _actionLog: string[] = []
|
||||||
|
|
||||||
send(action: GameAction): void {
|
send(action: GameAction): void {
|
||||||
stateManager.apply(action)
|
stateManager.apply(action)
|
||||||
|
this._recordAction(action)
|
||||||
this.onAction?.(action)
|
this.onAction?.(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a copy of the recent action log (oldest first). */
|
||||||
|
getActionLog(): readonly string[] { return this._actionLog }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a short summary of the action to the ring-buffer.
|
||||||
|
* @param action - The dispatched game action
|
||||||
|
*/
|
||||||
|
private _recordAction(action: GameAction): void {
|
||||||
|
let entry = action.type
|
||||||
|
if ('tileX' in action && 'tileY' in action)
|
||||||
|
entry += ` (${(action as any).tileX},${(action as any).tileY})`
|
||||||
|
else if ('villagerId' in action)
|
||||||
|
entry += ` v=…${(action as any).villagerId.slice(-4)}`
|
||||||
|
else if ('resourceId' in action)
|
||||||
|
entry += ` r=…${(action as any).resourceId.slice(-4)}`
|
||||||
|
else if ('cropId' in action)
|
||||||
|
entry += ` c=…${(action as any).cropId.slice(-4)}`
|
||||||
|
else if ('seedlingId' in action)
|
||||||
|
entry += ` s=…${(action as any).seedlingId.slice(-4)}`
|
||||||
|
|
||||||
|
if (this._actionLog.length >= ACTION_LOG_SIZE) this._actionLog.shift()
|
||||||
|
this._actionLog.push(entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,42 @@ import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOV
|
|||||||
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||||
import { TileType } from './types'
|
import { TileType } from './types'
|
||||||
|
|
||||||
|
// ─── Internal queue entry types ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Scheduled crop-growth entry. Two entries are created per stage (normal + watered path). */
|
||||||
|
interface CropEntry {
|
||||||
|
id: string
|
||||||
|
fireAt: number
|
||||||
|
expectedStage: number
|
||||||
|
/** If true this entry only fires when crop.watered === true. */
|
||||||
|
wateredPath: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled seedling-growth entry. One entry per stage. */
|
||||||
|
interface SeedlingEntry {
|
||||||
|
id: string
|
||||||
|
fireAt: number
|
||||||
|
expectedStage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled tile-recovery entry. One entry per tile. */
|
||||||
|
interface RecoveryEntry {
|
||||||
|
key: string
|
||||||
|
fireAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State factories ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_PLAYER: PlayerState = {
|
const DEFAULT_PLAYER: PlayerState = {
|
||||||
id: 'player1',
|
id: 'player1',
|
||||||
x: 8192, y: 8192,
|
x: 8192, y: 8192,
|
||||||
inventory: {}, // empty — seeds now in stockpile
|
inventory: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEmptyWorld(seed: number): WorldState {
|
function makeEmptyWorld(seed: number): WorldState {
|
||||||
return {
|
return {
|
||||||
seed,
|
seed,
|
||||||
|
gameTime: 0,
|
||||||
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
||||||
resources: {},
|
resources: {},
|
||||||
buildings: {},
|
buildings: {},
|
||||||
@@ -19,26 +46,170 @@ function makeEmptyWorld(seed: number): WorldState {
|
|||||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
||||||
treeSeedlings: {},
|
treeSeedlings: {},
|
||||||
tileRecovery: {},
|
tileRecovery: {},
|
||||||
|
foresterZones: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDefaultState(): GameStateData {
|
function makeDefaultState(): GameStateData {
|
||||||
return {
|
return {
|
||||||
version: 5,
|
version: 6,
|
||||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── StateManager ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class StateManager {
|
class StateManager {
|
||||||
private state: GameStateData
|
private state: GameStateData
|
||||||
|
|
||||||
|
// In-memory event queues (not persisted; rebuilt from state on load).
|
||||||
|
private cropQueue: CropEntry[] = []
|
||||||
|
private seedlingQueue: SeedlingEntry[] = []
|
||||||
|
private recoveryQueue: RecoveryEntry[] = []
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = this.load() ?? makeDefaultState()
|
this.state = this.load() ?? makeDefaultState()
|
||||||
|
this.rebuildQueues()
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): Readonly<GameStateData> { return this.state }
|
getState(): Readonly<GameStateData> { return this.state }
|
||||||
|
|
||||||
|
/** Returns the current accumulated in-game time in milliseconds. */
|
||||||
|
getGameTime(): number { return this.state.world.gameTime }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the in-game clock by delta milliseconds.
|
||||||
|
* Must be called once per frame before any tick methods.
|
||||||
|
* @param delta - Frame delta in milliseconds
|
||||||
|
*/
|
||||||
|
advanceTime(delta: number): void {
|
||||||
|
this.state.world.gameTime += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Queue helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts an entry into a sorted queue in ascending fireAt order.
|
||||||
|
* Uses binary search for O(log n) position find; O(n) splice insert.
|
||||||
|
*/
|
||||||
|
private static insertSorted<T extends { fireAt: number }>(queue: T[], entry: T): void {
|
||||||
|
let lo = 0, hi = queue.length
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1
|
||||||
|
if (queue[mid].fireAt <= entry.fireAt) lo = mid + 1
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
queue.splice(lo, 0, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enqueues both growth entries (normal + watered path) for a crop's current stage. */
|
||||||
|
private enqueueCropStage(id: string, expectedStage: number, growsAt: number, growsAtWatered: number): void {
|
||||||
|
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAt, expectedStage, wateredPath: false })
|
||||||
|
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAtWatered, expectedStage, wateredPath: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds all three event queues from the persisted state.
|
||||||
|
* Called once after construction or load.
|
||||||
|
*/
|
||||||
|
private rebuildQueues(): void {
|
||||||
|
this.cropQueue = []
|
||||||
|
this.seedlingQueue = []
|
||||||
|
this.recoveryQueue = []
|
||||||
|
|
||||||
|
for (const crop of Object.values(this.state.world.crops)) {
|
||||||
|
if (crop.stage >= crop.maxStage) continue
|
||||||
|
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of Object.values(this.state.world.treeSeedlings)) {
|
||||||
|
if (s.stage < 2) {
|
||||||
|
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, fireAt] of Object.entries(this.state.world.tileRecovery)) {
|
||||||
|
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tick methods ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains the crop queue up to the current gameTime.
|
||||||
|
* Returns IDs of crops that advanced a stage this frame.
|
||||||
|
*/
|
||||||
|
tickCrops(): string[] {
|
||||||
|
const now = this.state.world.gameTime
|
||||||
|
const advanced: string[] = []
|
||||||
|
|
||||||
|
while (this.cropQueue.length > 0 && this.cropQueue[0].fireAt <= now) {
|
||||||
|
const entry = this.cropQueue.shift()!
|
||||||
|
const crop = this.state.world.crops[entry.id]
|
||||||
|
if (!crop || crop.stage !== entry.expectedStage) continue // already removed or stale stage
|
||||||
|
if (entry.wateredPath && !crop.watered) continue // fast-path skipped: not watered
|
||||||
|
|
||||||
|
crop.stage++
|
||||||
|
advanced.push(crop.id)
|
||||||
|
|
||||||
|
if (crop.stage < crop.maxStage) {
|
||||||
|
const cfg = CROP_CONFIGS[crop.kind]
|
||||||
|
crop.growsAt = now + cfg.stageTimeMs
|
||||||
|
crop.growsAtWatered = now + cfg.stageTimeMs / 2
|
||||||
|
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return advanced
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains the seedling queue up to the current gameTime.
|
||||||
|
* Returns IDs of seedlings that advanced a stage this frame.
|
||||||
|
*/
|
||||||
|
tickSeedlings(): string[] {
|
||||||
|
const now = this.state.world.gameTime
|
||||||
|
const advanced: string[] = []
|
||||||
|
|
||||||
|
while (this.seedlingQueue.length > 0 && this.seedlingQueue[0].fireAt <= now) {
|
||||||
|
const entry = this.seedlingQueue.shift()!
|
||||||
|
const s = this.state.world.treeSeedlings[entry.id]
|
||||||
|
if (!s || s.stage !== entry.expectedStage) continue // removed or stale
|
||||||
|
|
||||||
|
s.stage = Math.min(s.stage + 1, 2)
|
||||||
|
advanced.push(s.id)
|
||||||
|
|
||||||
|
if (s.stage < 2) {
|
||||||
|
s.growsAt = now + TREE_SEEDLING_STAGE_MS
|
||||||
|
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return advanced
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains the tile-recovery queue up to the current gameTime.
|
||||||
|
* Returns keys ("tileX,tileY") of tiles that have reverted to GRASS.
|
||||||
|
*/
|
||||||
|
tickTileRecovery(): string[] {
|
||||||
|
const now = this.state.world.gameTime
|
||||||
|
const recovered: string[] = []
|
||||||
|
|
||||||
|
while (this.recoveryQueue.length > 0 && this.recoveryQueue[0].fireAt <= now) {
|
||||||
|
const entry = this.recoveryQueue.shift()!
|
||||||
|
const fireAt = this.state.world.tileRecovery[entry.key]
|
||||||
|
// Skip if the entry was superseded (tile re-planted, resetting its fireAt)
|
||||||
|
if (fireAt === undefined || fireAt > now) continue
|
||||||
|
delete this.state.world.tileRecovery[entry.key]
|
||||||
|
recovered.push(entry.key)
|
||||||
|
const [tx, ty] = entry.key.split(',').map(Number)
|
||||||
|
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State mutations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
apply(action: GameAction): void {
|
apply(action: GameAction): void {
|
||||||
const s = this.state
|
const s = this.state
|
||||||
const w = s.world
|
const w = s.world
|
||||||
@@ -65,11 +236,18 @@ class StateManager {
|
|||||||
w.buildings[action.building.id] = action.building
|
w.buildings[action.building.id] = action.building
|
||||||
for (const [k, v] of Object.entries(action.costs))
|
for (const [k, v] of Object.entries(action.costs))
|
||||||
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
|
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
|
||||||
|
if (action.building.kind === 'forester_hut') {
|
||||||
|
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REMOVE_BUILDING':
|
case 'REMOVE_BUILDING':
|
||||||
delete w.buildings[action.buildingId]; break
|
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
||||||
|
delete w.foresterZones[action.buildingId]
|
||||||
|
}
|
||||||
|
delete w.buildings[action.buildingId]
|
||||||
|
break
|
||||||
|
|
||||||
case 'ADD_ITEMS':
|
case 'ADD_ITEMS':
|
||||||
for (const [k, v] of Object.entries(action.items))
|
for (const [k, v] of Object.entries(action.items))
|
||||||
@@ -80,22 +258,24 @@ class StateManager {
|
|||||||
w.crops[action.crop.id] = { ...action.crop }
|
w.crops[action.crop.id] = { ...action.crop }
|
||||||
const have = w.stockpile[action.seedItem] ?? 0
|
const have = w.stockpile[action.seedItem] ?? 0
|
||||||
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
||||||
|
// Enqueue growth timers for both normal and watered paths
|
||||||
|
this.enqueueCropStage(action.crop.id, 0, action.crop.growsAt, action.crop.growsAtWatered)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'WATER_CROP': {
|
case 'WATER_CROP': {
|
||||||
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
||||||
|
// No queue change needed — the wateredPath entry was enqueued at planting time
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'HARVEST_CROP': {
|
case 'HARVEST_CROP': {
|
||||||
delete w.crops[action.cropId]
|
delete w.crops[action.cropId]
|
||||||
for (const [k, v] of Object.entries(action.rewards))
|
for (const [k, v] of Object.entries(action.rewards))
|
||||||
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
||||||
|
// Stale queue entries will be skipped automatically (crop no longer exists)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Villager actions ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
case 'SPAWN_VILLAGER':
|
case 'SPAWN_VILLAGER':
|
||||||
w.villagers[action.villager.id] = { ...action.villager }; break
|
w.villagers[action.villager.id] = { ...action.villager }; break
|
||||||
|
|
||||||
@@ -153,79 +333,40 @@ class StateManager {
|
|||||||
case 'PLANT_TREE_SEED': {
|
case 'PLANT_TREE_SEED': {
|
||||||
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||||
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
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}`]
|
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
|
||||||
|
// Enqueue growth timer
|
||||||
|
StateManager.insertSorted(this.seedlingQueue, {
|
||||||
|
id: action.seedling.id, fireAt: action.seedling.growsAt, expectedStage: 0
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REMOVE_TREE_SEEDLING':
|
case 'REMOVE_TREE_SEEDLING':
|
||||||
delete w.treeSeedlings[action.seedlingId]
|
delete w.treeSeedlings[action.seedlingId]
|
||||||
|
// Stale queue entries will be skipped automatically
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'SPAWN_RESOURCE':
|
case 'SPAWN_RESOURCE':
|
||||||
w.resources[action.resource.id] = { ...action.resource }
|
w.resources[action.resource.id] = { ...action.resource }
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'TILE_RECOVERY_START':
|
case 'TILE_RECOVERY_START': {
|
||||||
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
const fireAt = w.gameTime + TILE_RECOVERY_MS
|
||||||
|
const key = `${action.tileX},${action.tileY}`
|
||||||
|
w.tileRecovery[key] = fireAt
|
||||||
|
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FORESTER_ZONE_UPDATE': {
|
||||||
|
const zone = w.foresterZones[action.buildingId]
|
||||||
|
if (zone) zone.tiles = [...action.tiles]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tickCrops(delta: number): string[] {
|
|
||||||
const advanced: string[] = []
|
|
||||||
for (const crop of Object.values(this.state.world.crops)) {
|
|
||||||
if (crop.stage >= crop.maxStage) continue
|
|
||||||
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
|
|
||||||
if (crop.stageTimerMs <= 0) {
|
|
||||||
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
|
|
||||||
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
|
|
||||||
advanced.push(crop.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return advanced
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Persistence ───────────────────────────────────────────────────────────
|
||||||
* 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 (_) {}
|
||||||
@@ -236,15 +377,44 @@ class StateManager {
|
|||||||
const raw = localStorage.getItem(SAVE_KEY)
|
const raw = localStorage.getItem(SAVE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const p = JSON.parse(raw) as GameStateData
|
const p = JSON.parse(raw) as GameStateData
|
||||||
if (p.version !== 5) return null
|
|
||||||
|
// ── Migrate v5 → v6: countdown timers → absolute gameTime timestamps ──
|
||||||
|
if ((p.version as number) === 5) {
|
||||||
|
p.world.gameTime = 0
|
||||||
|
for (const crop of Object.values(p.world.crops)) {
|
||||||
|
const old = crop as any
|
||||||
|
const ms = old.stageTimerMs ?? CROP_CONFIGS[crop.kind]?.stageTimeMs ?? 20_000
|
||||||
|
crop.growsAt = ms
|
||||||
|
crop.growsAtWatered = ms / 2
|
||||||
|
delete old.stageTimerMs
|
||||||
|
}
|
||||||
|
for (const s of Object.values(p.world.treeSeedlings)) {
|
||||||
|
const old = s as any
|
||||||
|
s.growsAt = old.stageTimerMs ?? TREE_SEEDLING_STAGE_MS
|
||||||
|
delete old.stageTimerMs
|
||||||
|
}
|
||||||
|
// tileRecovery values were remaining-ms countdowns; with gameTime=0 they equal fireAt directly
|
||||||
|
p.version = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.version !== 6) return null
|
||||||
|
|
||||||
if (!p.world.crops) p.world.crops = {}
|
if (!p.world.crops) p.world.crops = {}
|
||||||
if (!p.world.villagers) p.world.villagers = {}
|
if (!p.world.villagers) p.world.villagers = {}
|
||||||
if (!p.world.stockpile) p.world.stockpile = {}
|
if (!p.world.stockpile) p.world.stockpile = {}
|
||||||
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
||||||
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
||||||
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
if (!p.world.foresterZones) p.world.foresterZones = {}
|
||||||
|
if (!p.world.gameTime) p.world.gameTime = 0
|
||||||
|
|
||||||
for (const v of Object.values(p.world.villagers)) {
|
for (const v of Object.values(p.world.villagers)) {
|
||||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||||
|
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
|
||||||
|
}
|
||||||
|
for (const b of Object.values(p.world.buildings)) {
|
||||||
|
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
||||||
|
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
} catch (_) { return null }
|
} catch (_) { return null }
|
||||||
@@ -253,6 +423,7 @@ class StateManager {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
localStorage.removeItem(SAVE_KEY)
|
localStorage.removeItem(SAVE_KEY)
|
||||||
this.state = makeDefaultState()
|
this.state = makeDefaultState()
|
||||||
|
this.rebuildQueues()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
|
|||||||
chest: { wood: 5, stone: 2 },
|
chest: { wood: 5, stone: 2 },
|
||||||
bed: { wood: 6 },
|
bed: { wood: 6 },
|
||||||
stockpile_zone:{ wood: 0 },
|
stockpile_zone:{ wood: 0 },
|
||||||
|
forester_hut: { wood: 50 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
|
||||||
|
export const FORESTER_ZONE_RADIUS = 5
|
||||||
|
|
||||||
export interface CropConfig {
|
export interface CropConfig {
|
||||||
stages: number
|
stages: number
|
||||||
stageTimeMs: number
|
stageTimeMs: number
|
||||||
@@ -39,6 +43,7 @@ export const VILLAGER_WORK_TIMES: Record<string, number> = {
|
|||||||
chop: 3000,
|
chop: 3000,
|
||||||
mine: 5000,
|
mine: 5000,
|
||||||
farm: 1200,
|
farm: 1200,
|
||||||
|
forester: 2000,
|
||||||
}
|
}
|
||||||
export const VILLAGER_NAMES = [
|
export const VILLAGER_NAMES = [
|
||||||
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
|
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FarmingSystem } from '../systems/FarmingSystem'
|
|||||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||||
import { DebugSystem } from '../systems/DebugSystem'
|
import { DebugSystem } from '../systems/DebugSystem'
|
||||||
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
||||||
|
import { ForesterZoneSystem } from '../systems/ForesterZoneSystem'
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private adapter!: LocalAdapter
|
private adapter!: LocalAdapter
|
||||||
@@ -23,6 +24,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
villagerSystem!: VillagerSystem
|
villagerSystem!: VillagerSystem
|
||||||
debugSystem!: DebugSystem
|
debugSystem!: DebugSystem
|
||||||
private treeSeedlingSystem!: TreeSeedlingSystem
|
private treeSeedlingSystem!: TreeSeedlingSystem
|
||||||
|
foresterZoneSystem!: ForesterZoneSystem
|
||||||
private autosaveTimer = 0
|
private autosaveTimer = 0
|
||||||
private menuOpen = false
|
private menuOpen = false
|
||||||
|
|
||||||
@@ -43,7 +45,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
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.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||||
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
|
||||||
|
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem, this.adapter)
|
||||||
|
|
||||||
this.worldSystem.create()
|
this.worldSystem.create()
|
||||||
this.renderPersistentObjects()
|
this.renderPersistentObjects()
|
||||||
@@ -68,9 +71,16 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.treeSeedlingSystem.create()
|
this.treeSeedlingSystem.create()
|
||||||
|
|
||||||
|
this.foresterZoneSystem.create()
|
||||||
|
this.foresterZoneSystem.refreshOverlay()
|
||||||
|
this.foresterZoneSystem.onEditEnded = () => this.events.emit('foresterZoneEditEnded')
|
||||||
|
this.foresterZoneSystem.onZoneChanged = (id, tiles) => this.events.emit('foresterZoneChanged', id, tiles)
|
||||||
|
|
||||||
this.villagerSystem.create()
|
this.villagerSystem.create()
|
||||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||||
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||||
|
this.villagerSystem.onPlantSeedling = (tileX, tileY, tile) =>
|
||||||
|
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||||
|
|
||||||
this.debugSystem.create()
|
this.debugSystem.create()
|
||||||
|
|
||||||
@@ -78,11 +88,30 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.adapter.onAction = (action) => {
|
this.adapter.onAction = (action) => {
|
||||||
if (action.type === 'CHANGE_TILE') {
|
if (action.type === 'CHANGE_TILE') {
|
||||||
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
||||||
|
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
|
||||||
} else if (action.type === 'SPAWN_RESOURCE') {
|
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||||
|
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||||
|
} else if (action.type === 'FORESTER_ZONE_UPDATE') {
|
||||||
|
this.foresterZoneSystem.refreshOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect left-clicks on forester huts to open the zone panel
|
||||||
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
|
if (ptr.rightButtonDown() || this.menuOpen) return
|
||||||
|
if (this.buildingSystem.isActive()) return
|
||||||
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const hut = Object.values(state.world.buildings).find(
|
||||||
|
b => b.kind === 'forester_hut' && b.tileX === tileX && b.tileY === tileY
|
||||||
|
)
|
||||||
|
if (hut) {
|
||||||
|
this.events.emit('foresterHutClicked', hut.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.scene.launch('UI')
|
this.scene.launch('UI')
|
||||||
|
|
||||||
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
||||||
@@ -91,9 +120,17 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on('uiRequestBuildMenu', () => {
|
this.events.on('uiRequestBuildMenu', () => {
|
||||||
if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu')
|
if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu')
|
||||||
})
|
})
|
||||||
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => {
|
this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number; forester: number }) => {
|
||||||
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.events.on('foresterZoneEditStart', (buildingId: string) => {
|
||||||
|
this.foresterZoneSystem.startEditMode(buildingId)
|
||||||
|
this.menuOpen = false // keep game ticking while zone editor is open
|
||||||
|
})
|
||||||
|
this.events.on('foresterZoneEditStop', () => {
|
||||||
|
this.foresterZoneSystem.exitEditMode()
|
||||||
|
})
|
||||||
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
||||||
|
|
||||||
this.autosaveTimer = AUTOSAVE_INTERVAL
|
this.autosaveTimer = AUTOSAVE_INTERVAL
|
||||||
@@ -108,6 +145,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
if (this.menuOpen) return
|
if (this.menuOpen) return
|
||||||
|
|
||||||
|
// Advance the in-game clock first so all tick methods see the updated time
|
||||||
|
stateManager.advanceTime(delta)
|
||||||
|
|
||||||
this.cameraSystem.update(delta)
|
this.cameraSystem.update(delta)
|
||||||
|
|
||||||
this.resourceSystem.update(delta)
|
this.resourceSystem.update(delta)
|
||||||
@@ -116,8 +156,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.villagerSystem.update(delta)
|
this.villagerSystem.update(delta)
|
||||||
this.debugSystem.update()
|
this.debugSystem.update()
|
||||||
|
|
||||||
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
|
// Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS
|
||||||
const recovered = stateManager.tickTileRecovery(delta)
|
const recovered = stateManager.tickTileRecovery()
|
||||||
for (const key of recovered) {
|
for (const key of recovered) {
|
||||||
const [tx, ty] = key.split(',').map(Number)
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||||
@@ -142,15 +182,27 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const name = `bobj_${building.id}`
|
const name = `bobj_${building.id}`
|
||||||
if (this.children.getByName(name)) continue
|
if (this.children.getByName(name)) continue
|
||||||
|
|
||||||
|
const worldDepth = building.tileY + 5
|
||||||
if (building.kind === 'chest') {
|
if (building.kind === 'chest') {
|
||||||
const g = this.add.graphics().setName(name).setDepth(8)
|
const g = this.add.graphics().setName(name).setDepth(worldDepth)
|
||||||
g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
|
g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
|
||||||
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
|
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
|
||||||
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
|
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
|
||||||
} else if (building.kind === 'bed') {
|
} else if (building.kind === 'bed') {
|
||||||
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8)
|
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(worldDepth)
|
||||||
} else if (building.kind === 'stockpile_zone') {
|
} else if (building.kind === 'stockpile_zone') {
|
||||||
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
|
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
|
||||||
|
} else if (building.kind === 'forester_hut') {
|
||||||
|
// Draw a simple log-cabin silhouette for the forester hut
|
||||||
|
const g = this.add.graphics().setName(name).setDepth(worldDepth)
|
||||||
|
// Body
|
||||||
|
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
|
||||||
|
// Roof
|
||||||
|
g.fillStyle(0x4a2800); g.fillTriangle(wx - 14, wy - 9, wx + 14, wy - 9, wx, wy - 22)
|
||||||
|
// Door
|
||||||
|
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
|
||||||
|
// Tree symbol on the roof
|
||||||
|
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +215,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.buildingSystem.destroy()
|
this.buildingSystem.destroy()
|
||||||
this.farmingSystem.destroy()
|
this.farmingSystem.destroy()
|
||||||
this.treeSeedlingSystem.destroy()
|
this.treeSeedlingSystem.destroy()
|
||||||
|
this.foresterZoneSystem.destroy()
|
||||||
this.villagerSystem.destroy()
|
this.villagerSystem.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private villagerPanelVisible = false
|
private villagerPanelVisible = false
|
||||||
private buildModeText!: Phaser.GameObjects.Text
|
private buildModeText!: Phaser.GameObjects.Text
|
||||||
private farmToolText!: Phaser.GameObjects.Text
|
private farmToolText!: Phaser.GameObjects.Text
|
||||||
private coordsText!: Phaser.GameObjects.Text
|
|
||||||
private controlsHintText!: Phaser.GameObjects.Text
|
|
||||||
private popText!: Phaser.GameObjects.Text
|
private popText!: Phaser.GameObjects.Text
|
||||||
private stockpileTitleText!: Phaser.GameObjects.Text
|
private stockpileTitleText!: Phaser.GameObjects.Text
|
||||||
private contextMenuGroup!: Phaser.GameObjects.Group
|
private contextMenuGroup!: Phaser.GameObjects.Group
|
||||||
@@ -52,6 +50,27 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private settingsGroup!: Phaser.GameObjects.Group
|
private settingsGroup!: Phaser.GameObjects.Group
|
||||||
private settingsVisible = false
|
private settingsVisible = false
|
||||||
|
|
||||||
|
// ── Forester Hut Panel ────────────────────────────────────────────────────
|
||||||
|
private foresterPanelGroup!: Phaser.GameObjects.Group
|
||||||
|
private foresterPanelVisible = false
|
||||||
|
private foresterPanelBuildingId: string | null = null
|
||||||
|
/** Tile-count text inside the forester panel, updated live when zone changes. */
|
||||||
|
private foresterTileCountText: Phaser.GameObjects.Text | null = null
|
||||||
|
/** True while the zone-edit tool is active (shown in ESC priority stack). */
|
||||||
|
private inForesterZoneEdit = false
|
||||||
|
|
||||||
|
// ── Action Bar ────────────────────────────────────────────────────────────
|
||||||
|
private static readonly BAR_H = 48
|
||||||
|
private static readonly TRAY_H = 68
|
||||||
|
private actionBarBg!: Phaser.GameObjects.Rectangle
|
||||||
|
private actionBuildBtn!: Phaser.GameObjects.Rectangle
|
||||||
|
private actionBuildLabel!: Phaser.GameObjects.Text
|
||||||
|
private actionNisseBtn!: Phaser.GameObjects.Rectangle
|
||||||
|
private actionNisseLabel!: Phaser.GameObjects.Text
|
||||||
|
private actionTrayGroup!: Phaser.GameObjects.Group
|
||||||
|
private actionTrayVisible = false
|
||||||
|
private activeCategory: 'build' | 'nisse' | null = null
|
||||||
|
|
||||||
constructor() { super({ key: 'UI' }) }
|
constructor() { super({ key: 'UI' }) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,15 +85,14 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.createBuildMenu()
|
this.createBuildMenu()
|
||||||
this.createBuildModeIndicator()
|
this.createBuildModeIndicator()
|
||||||
this.createFarmToolIndicator()
|
this.createFarmToolIndicator()
|
||||||
this.createCoordsDisplay()
|
|
||||||
this.createDebugPanel()
|
this.createDebugPanel()
|
||||||
|
this.createActionBar()
|
||||||
|
|
||||||
const gameScene = this.scene.get('Game')
|
const gameScene = this.scene.get('Game')
|
||||||
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
||||||
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
|
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
|
||||||
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
||||||
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
|
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
|
||||||
gameScene.events.on('cameraMoved', (pos: { tileX: number; tileY: number }) => this.onCameraMoved(pos))
|
|
||||||
|
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||||
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
||||||
@@ -93,6 +111,12 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.confirmGroup = this.add.group()
|
this.confirmGroup = this.add.group()
|
||||||
this.nisseInfoGroup = this.add.group()
|
this.nisseInfoGroup = this.add.group()
|
||||||
this.settingsGroup = this.add.group()
|
this.settingsGroup = this.add.group()
|
||||||
|
this.foresterPanelGroup = this.add.group()
|
||||||
|
this.actionTrayGroup = this.add.group()
|
||||||
|
|
||||||
|
gameScene.events.on('foresterHutClicked', (id: string) => this.openForesterPanel(id))
|
||||||
|
gameScene.events.on('foresterZoneEditEnded', () => this.onForesterEditEnded())
|
||||||
|
gameScene.events.on('foresterZoneChanged', (id: string, tiles: string[]) => this.onForesterZoneChanged(id, tiles))
|
||||||
|
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown()) {
|
if (ptr.rightButtonDown()) {
|
||||||
@@ -161,7 +185,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
/** Creates the centered hint text element near the bottom of the screen. */
|
/** Creates the centered hint text element near the bottom of the screen. */
|
||||||
private createHintText(): void {
|
private createHintText(): void {
|
||||||
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
|
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - UIScene.BAR_H - 24, '', {
|
||||||
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
|
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
|
||||||
backgroundColor: '#00000099', padding: { x: 10, y: 5 },
|
backgroundColor: '#00000099', padding: { x: 10, y: 5 },
|
||||||
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false)
|
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false)
|
||||||
@@ -204,9 +228,10 @@ export class UIScene extends Phaser.Scene {
|
|||||||
{ kind: 'chest', label: 'Chest', cost: '5 wood + 2 stone' },
|
{ kind: 'chest', label: 'Chest', cost: '5 wood + 2 stone' },
|
||||||
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
|
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
|
||||||
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
||||||
|
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
|
||||||
]
|
]
|
||||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
|
||||||
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||||
this.buildMenuGroup.add(bg)
|
this.buildMenuGroup.add(bg)
|
||||||
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
||||||
|
|
||||||
@@ -255,6 +280,10 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.villagerPanelVisible = false
|
this.villagerPanelVisible = false
|
||||||
this.villagerPanelGroup?.destroy(true)
|
this.villagerPanelGroup?.destroy(true)
|
||||||
this.scene.get('Game').events.emit('uiMenuClose')
|
this.scene.get('Game').events.emit('uiMenuClose')
|
||||||
|
if (this.activeCategory === 'nisse') {
|
||||||
|
this.activeCategory = null
|
||||||
|
this.updateCategoryHighlights()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,7 +296,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const villagers = Object.values(state.world.villagers)
|
const villagers = Object.values(state.world.villagers)
|
||||||
const panelW = 420
|
const panelW = 490
|
||||||
const rowH = 60
|
const rowH = 60
|
||||||
const panelH = Math.max(100, villagers.length * rowH + 50)
|
const panelH = Math.max(100, villagers.length * rowH + 50)
|
||||||
const px = this.scale.width / 2 - panelW / 2
|
const px = this.scale.width / 2 - panelW / 2
|
||||||
@@ -309,12 +338,12 @@ export class UIScene extends Phaser.Scene {
|
|||||||
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
|
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
|
||||||
this.villagerPanelGroup.add(eg)
|
this.villagerPanelGroup.add(eg)
|
||||||
|
|
||||||
// Job priority buttons: chop / mine / farm
|
// Job priority buttons: chop / mine / farm / forester
|
||||||
const jobs: Array<{ key: keyof JobPriorities; label: string }> = [
|
const jobs: Array<{ key: keyof JobPriorities; label: string }> = [
|
||||||
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }
|
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }, { key: 'forester', label: '🌲' }
|
||||||
]
|
]
|
||||||
jobs.forEach((job, ji) => {
|
jobs.forEach((job, ji) => {
|
||||||
const bx = px + 110 + ji * 100
|
const bx = px + 110 + ji * 76
|
||||||
const pri = v.priorities[job.key]
|
const pri = v.priorities[job.key]
|
||||||
const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}`
|
const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}`
|
||||||
const btn = this.add.text(bx, ry + 6, label, {
|
const btn = this.add.text(bx, ry + 6, label, {
|
||||||
@@ -371,24 +400,6 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Coords + controls ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
|
|
||||||
private createCoordsDisplay(): void {
|
|
||||||
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
|
||||||
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
|
|
||||||
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
|
||||||
}).setScrollFactor(0).setDepth(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the tile-coordinate display when the camera moves.
|
|
||||||
* @param pos - Tile position of the camera center
|
|
||||||
*/
|
|
||||||
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
|
|
||||||
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
|
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Creates the debug panel text object (initially hidden). */
|
/** Creates the debug panel text object (initially hidden). */
|
||||||
@@ -452,6 +463,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
'',
|
'',
|
||||||
`Paths: ${data.activePaths} (cyan lines in world)`,
|
`Paths: ${data.activePaths} (cyan lines in world)`,
|
||||||
'',
|
'',
|
||||||
|
'── Last Actions ───────────────',
|
||||||
|
...(data.actionLog.length > 0 ? data.actionLog : ['—']),
|
||||||
|
'',
|
||||||
'[F3] close',
|
'[F3] close',
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -528,8 +542,11 @@ export class UIScene extends Phaser.Scene {
|
|||||||
*/
|
*/
|
||||||
private handleEsc(): void {
|
private handleEsc(): void {
|
||||||
if (this.confirmVisible) { this.hideConfirm(); return }
|
if (this.confirmVisible) { this.hideConfirm(); return }
|
||||||
|
if (this.inForesterZoneEdit) { this.scene.get('Game').events.emit('foresterZoneEditStop'); return }
|
||||||
|
if (this.foresterPanelVisible) { this.closeForesterPanel(); return }
|
||||||
if (this.contextMenuVisible) { this.hideContextMenu(); return }
|
if (this.contextMenuVisible) { this.hideContextMenu(); return }
|
||||||
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||||
|
if (this.actionTrayVisible) { this.closeActionTray(); return }
|
||||||
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
|
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
|
||||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||||
if (this.settingsVisible) { this.closeSettings(); return }
|
if (this.settingsVisible) { this.closeSettings(); return }
|
||||||
@@ -572,8 +589,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
{ label: '⚙️ Settings', action: () => this.doSettings() },
|
{ label: '⚙️ Settings', action: () => this.doSettings() },
|
||||||
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
||||||
]
|
]
|
||||||
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
|
const keysBlock = '[WASD] Pan [Scroll] Zoom\n[F] Farm [B] Build [V] Nisse\n[F3] Debug [ESC] Menu'
|
||||||
const menuH = 32 + entries.length * (btnH + 8) + 8
|
// 32px header + entries × (btnH + 8px gap) + 8px sep + 46px keys block + 12px bottom padding
|
||||||
|
const menuH = 32 + entries.length * (btnH + 8) + 8 + 46 + 12
|
||||||
const mx = this.scale.width / 2 - menuW / 2
|
const mx = this.scale.width / 2 - menuW / 2
|
||||||
const my = this.scale.height / 2 - menuH / 2
|
const my = this.scale.height / 2 - menuH / 2
|
||||||
|
|
||||||
@@ -600,6 +618,14 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
|
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keyboard shortcuts reference at the bottom of the menu
|
||||||
|
const keysY = my + 32 + entries.length * (btnH + 8) + 8
|
||||||
|
this.escMenuGroup.add(
|
||||||
|
this.add.text(mx + menuW / 2, keysY, keysBlock, {
|
||||||
|
fontSize: '10px', color: '#555555', fontFamily: 'monospace', align: 'center',
|
||||||
|
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves the game and shows a toast confirmation. */
|
/** Saves the game and shows a toast confirmation. */
|
||||||
@@ -928,12 +954,12 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Static: priority label + buttons
|
// Static: priority label + buttons
|
||||||
const jobKeys: Array<{ key: string; icon: string }> = [
|
const jobKeys: Array<{ key: string; icon: string }> = [
|
||||||
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' },
|
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, { key: 'forester', icon: '🌲' },
|
||||||
]
|
]
|
||||||
jobKeys.forEach((j, i) => {
|
jobKeys.forEach((j, i) => {
|
||||||
const pri = v.priorities[j.key as keyof typeof v.priorities]
|
const pri = v.priorities[j.key as keyof typeof v.priorities]
|
||||||
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
|
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
|
||||||
const bx = px + 10 + i * 88
|
const bx = px + 10 + i * 66
|
||||||
const btn = this.add.text(bx, py + 78, label, {
|
const btn = this.add.text(bx, py + 78, label, {
|
||||||
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
|
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
|
||||||
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
|
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
|
||||||
@@ -1008,6 +1034,277 @@ export class UIScene extends Phaser.Scene {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Forester Hut Panel ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the forester hut info panel for the given building.
|
||||||
|
* If another forester panel is open it is replaced.
|
||||||
|
* @param buildingId - ID of the clicked forester_hut
|
||||||
|
*/
|
||||||
|
private openForesterPanel(buildingId: string): void {
|
||||||
|
this.foresterPanelBuildingId = buildingId
|
||||||
|
this.foresterPanelVisible = true
|
||||||
|
this.buildForesterPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes and destroys the forester hut panel and exits zone edit mode if active. */
|
||||||
|
private closeForesterPanel(): void {
|
||||||
|
if (!this.foresterPanelVisible) return
|
||||||
|
if (this.inForesterZoneEdit) {
|
||||||
|
this.scene.get('Game').events.emit('foresterZoneEditStop')
|
||||||
|
}
|
||||||
|
this.foresterPanelVisible = false
|
||||||
|
this.foresterPanelBuildingId = null
|
||||||
|
this.foresterTileCountText = null
|
||||||
|
this.foresterPanelGroup.destroy(true)
|
||||||
|
this.foresterPanelGroup = this.add.group()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the forester hut panel showing zone tile count and an edit-zone button.
|
||||||
|
* Positioned in the top-left corner (similar to the Nisse info panel).
|
||||||
|
*/
|
||||||
|
private buildForesterPanel(): void {
|
||||||
|
this.foresterPanelGroup.destroy(true)
|
||||||
|
this.foresterPanelGroup = this.add.group()
|
||||||
|
this.foresterTileCountText = null
|
||||||
|
|
||||||
|
const id = this.foresterPanelBuildingId
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const building = state.world.buildings[id]
|
||||||
|
if (!building) { this.closeForesterPanel(); return }
|
||||||
|
|
||||||
|
const zone = state.world.foresterZones[id]
|
||||||
|
const tileCount = zone?.tiles.length ?? 0
|
||||||
|
|
||||||
|
const panelW = 240
|
||||||
|
const panelH = 100
|
||||||
|
const px = 10, py = 10
|
||||||
|
|
||||||
|
// Background
|
||||||
|
this.foresterPanelGroup.add(
|
||||||
|
this.add.rectangle(px, py, panelW, panelH, 0x030a03, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.foresterPanelGroup.add(
|
||||||
|
this.add.text(px + 10, py + 10, '🌲 FORESTER HUT', {
|
||||||
|
fontSize: '13px', color: '#88dd88', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
|
||||||
|
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
|
||||||
|
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
|
||||||
|
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
|
||||||
|
closeBtn.on('pointerdown', () => this.closeForesterPanel())
|
||||||
|
this.foresterPanelGroup.add(closeBtn)
|
||||||
|
|
||||||
|
// Zone tile count (dynamic — updated via onForesterZoneChanged)
|
||||||
|
const countTxt = this.add.text(px + 10, py + 32, `Zone: ${tileCount} tile${tileCount === 1 ? '' : 's'} marked`, {
|
||||||
|
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||||
|
}).setScrollFactor(0).setDepth(251)
|
||||||
|
this.foresterPanelGroup.add(countTxt)
|
||||||
|
this.foresterTileCountText = countTxt
|
||||||
|
|
||||||
|
// Edit zone button
|
||||||
|
const editLabel = this.inForesterZoneEdit ? '✅ Done editing' : '✏️ Edit Zone'
|
||||||
|
const editBtn = this.add.rectangle(px + 10, py + 54, panelW - 20, 30, 0x1a3a1a, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(251).setInteractive()
|
||||||
|
editBtn.on('pointerover', () => editBtn.setFillStyle(0x2d6a4f, 0.9))
|
||||||
|
editBtn.on('pointerout', () => editBtn.setFillStyle(0x1a3a1a, 0.9))
|
||||||
|
editBtn.on('pointerdown', () => {
|
||||||
|
if (this.inForesterZoneEdit) {
|
||||||
|
this.scene.get('Game').events.emit('foresterZoneEditStop')
|
||||||
|
} else {
|
||||||
|
this.inForesterZoneEdit = true
|
||||||
|
this.scene.get('Game').events.emit('foresterZoneEditStart', id)
|
||||||
|
// Rebuild panel to show "Done editing" button
|
||||||
|
this.buildForesterPanel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.foresterPanelGroup.add(editBtn)
|
||||||
|
this.foresterPanelGroup.add(
|
||||||
|
this.add.text(px + panelW / 2, py + 69, editLabel, {
|
||||||
|
fontSize: '12px', color: '#dddddd', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(252)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the ForesterZoneSystem signals that zone editing ended
|
||||||
|
* (via right-click, ESC, or the "Done" button).
|
||||||
|
*/
|
||||||
|
private onForesterEditEnded(): void {
|
||||||
|
this.inForesterZoneEdit = false
|
||||||
|
// Rebuild panel to switch button back to "Edit Zone"
|
||||||
|
if (this.foresterPanelVisible) this.buildForesterPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the zone tiles change so we can update the tile-count text live.
|
||||||
|
* @param buildingId - Building whose zone changed
|
||||||
|
* @param tiles - Updated tile array
|
||||||
|
*/
|
||||||
|
private onForesterZoneChanged(buildingId: string, tiles: string[]): void {
|
||||||
|
if (buildingId !== this.foresterPanelBuildingId) return
|
||||||
|
if (this.foresterTileCountText) {
|
||||||
|
const n = tiles.length
|
||||||
|
this.foresterTileCountText.setText(`Zone: ${n} tile${n === 1 ? '' : 's'} marked`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Action Bar ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the persistent bottom action bar with Build and Nisse category buttons.
|
||||||
|
* The bar is always visible; individual button highlights change with the active category.
|
||||||
|
*/
|
||||||
|
private createActionBar(): void {
|
||||||
|
const { width, height } = this.scale
|
||||||
|
const barY = height - UIScene.BAR_H
|
||||||
|
|
||||||
|
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, 0.92)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||||
|
|
||||||
|
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
|
this.actionBuildBtn.on('pointerover', () => {
|
||||||
|
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, 0.9)
|
||||||
|
})
|
||||||
|
this.actionBuildBtn.on('pointerout', () => {
|
||||||
|
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, 0.9)
|
||||||
|
})
|
||||||
|
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
|
||||||
|
|
||||||
|
this.actionBuildLabel = this.add.text(52, barY + UIScene.BAR_H / 2, '🔨 Build', {
|
||||||
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
|
||||||
|
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
|
this.actionNisseBtn.on('pointerover', () => {
|
||||||
|
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, 0.9)
|
||||||
|
})
|
||||||
|
this.actionNisseBtn.on('pointerout', () => {
|
||||||
|
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, 0.9)
|
||||||
|
})
|
||||||
|
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
|
||||||
|
|
||||||
|
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
|
||||||
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the given action bar category on or off.
|
||||||
|
* Selecting the active category deselects it; selecting a new one closes the previous.
|
||||||
|
* @param cat - The category to toggle ('build' or 'nisse')
|
||||||
|
*/
|
||||||
|
private toggleCategory(cat: 'build' | 'nisse'): void {
|
||||||
|
if (this.activeCategory === cat) {
|
||||||
|
this.deactivateCategory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Close whatever was open before
|
||||||
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
|
||||||
|
this.activeCategory = cat
|
||||||
|
this.updateCategoryHighlights()
|
||||||
|
|
||||||
|
if (cat === 'build') {
|
||||||
|
this.openActionTray()
|
||||||
|
} else {
|
||||||
|
this.openVillagerPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates the currently active category, closing its associated panel or tray.
|
||||||
|
*/
|
||||||
|
private deactivateCategory(): void {
|
||||||
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
this.activeCategory = null
|
||||||
|
this.updateCategoryHighlights()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the visual highlight of the Build and Nisse buttons
|
||||||
|
* to reflect the current active category.
|
||||||
|
*/
|
||||||
|
private updateCategoryHighlights(): void {
|
||||||
|
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, 0.9)
|
||||||
|
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and shows the building tool tray above the action bar.
|
||||||
|
* Each building is shown as a clickable tile with emoji and name.
|
||||||
|
*/
|
||||||
|
private openActionTray(): void {
|
||||||
|
if (this.actionTrayVisible) return
|
||||||
|
this.actionTrayVisible = true
|
||||||
|
this.actionTrayGroup.destroy(true)
|
||||||
|
this.actionTrayGroup = this.add.group()
|
||||||
|
|
||||||
|
const { width, height } = this.scale
|
||||||
|
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H, 0x0d0d0d, 0.88)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||||
|
this.actionTrayGroup.add(bg)
|
||||||
|
|
||||||
|
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
|
||||||
|
{ kind: 'floor', emoji: '🪵', label: 'Floor' },
|
||||||
|
{ kind: 'wall', emoji: '🧱', label: 'Wall' },
|
||||||
|
{ kind: 'chest', emoji: '📦', label: 'Chest' },
|
||||||
|
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
|
||||||
|
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
|
||||||
|
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const itemW = 84
|
||||||
|
buildings.forEach((b, i) => {
|
||||||
|
const bx = 8 + i * (itemW + 4)
|
||||||
|
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, 0.9)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
|
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, 0.9))
|
||||||
|
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, 0.9))
|
||||||
|
btn.on('pointerdown', () => {
|
||||||
|
this.closeActionTray()
|
||||||
|
this.deactivateCategory()
|
||||||
|
this.scene.get('Game').events.emit('selectBuilding', b.kind)
|
||||||
|
})
|
||||||
|
this.actionTrayGroup.add(btn)
|
||||||
|
this.actionTrayGroup.add(
|
||||||
|
this.add.text(bx + itemW / 2, trayY + 18, b.emoji, { fontSize: '18px' })
|
||||||
|
.setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
)
|
||||||
|
this.actionTrayGroup.add(
|
||||||
|
this.add.text(bx + itemW / 2, trayY + 44, b.label, {
|
||||||
|
fontSize: '10px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides and destroys the building tool tray.
|
||||||
|
*/
|
||||||
|
private closeActionTray(): void {
|
||||||
|
if (!this.actionTrayVisible) return
|
||||||
|
this.actionTrayVisible = false
|
||||||
|
this.actionTrayGroup.destroy(true)
|
||||||
|
this.actionTrayGroup = this.add.group()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1028,11 +1325,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bottom elements
|
// Bottom elements
|
||||||
this.hintText.setPosition(width / 2, height - 40)
|
this.hintText.setPosition(width / 2, height - UIScene.BAR_H - 24)
|
||||||
this.toastText.setPosition(width / 2, 60)
|
this.toastText.setPosition(width / 2, 60)
|
||||||
this.coordsText.setPosition(10, height - 24)
|
|
||||||
this.controlsHintText.setPosition(10, height - 42)
|
|
||||||
|
|
||||||
|
// Action bar — reposition persistent elements
|
||||||
|
this.actionBarBg.setPosition(0, height - UIScene.BAR_H).setSize(width, UIScene.BAR_H)
|
||||||
|
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
|
||||||
|
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
|
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
|
||||||
|
this.actionNisseLabel.setPosition(144, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
|
if (this.actionTrayVisible) this.closeActionTray()
|
||||||
// Close centered panels — their position is calculated on open, so they
|
// Close centered panels — their position is calculated on open, so they
|
||||||
// would be off-center if left open during a resize
|
// would be off-center if left open during a resize
|
||||||
if (this.buildMenuVisible) this.closeBuildMenu()
|
if (this.buildMenuVisible) this.closeBuildMenu()
|
||||||
@@ -1042,5 +1344,6 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.settingsVisible) this.closeSettings()
|
if (this.settingsVisible) this.closeSettings()
|
||||||
if (this.confirmVisible) this.hideConfirm()
|
if (this.confirmVisible) this.hideConfirm()
|
||||||
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||||
|
if (this.foresterPanelVisible) this.closeForesterPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
||||||
this.ghost.setDepth(20)
|
this.ghost.setDepth(1000)
|
||||||
this.ghost.setVisible(false)
|
this.ghost.setVisible(false)
|
||||||
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export class BuildingSystem {
|
|||||||
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
||||||
})
|
})
|
||||||
this.ghostLabel.setDepth(21)
|
this.ghostLabel.setDepth(1001)
|
||||||
this.ghostLabel.setVisible(false)
|
this.ghostLabel.setVisible(false)
|
||||||
this.ghostLabel.setOrigin(0.5, 1)
|
this.ghostLabel.setOrigin(0.5, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Phaser from 'phaser'
|
|||||||
import { TILE_SIZE } from '../config'
|
import { TILE_SIZE } from '../config'
|
||||||
import { TileType } from '../types'
|
import { TileType } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
import type { VillagerSystem } from './VillagerSystem'
|
import type { VillagerSystem } from './VillagerSystem'
|
||||||
import type { WorldSystem } from './WorldSystem'
|
import type { WorldSystem } from './WorldSystem'
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export interface DebugData {
|
|||||||
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||||
jobsByType: { chop: number; mine: number; farm: number }
|
jobsByType: { chop: number; mine: number; farm: number }
|
||||||
activePaths: number
|
activePaths: number
|
||||||
|
/** Recent actions dispatched through the adapter (newest last). */
|
||||||
|
actionLog: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable names for TileType enum values. */
|
/** Human-readable names for TileType enum values. */
|
||||||
@@ -39,6 +42,7 @@ export class DebugSystem {
|
|||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private villagerSystem: VillagerSystem
|
private villagerSystem: VillagerSystem
|
||||||
private worldSystem: WorldSystem
|
private worldSystem: WorldSystem
|
||||||
|
private adapter: LocalAdapter
|
||||||
private pathGraphics!: Phaser.GameObjects.Graphics
|
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||||
private active = false
|
private active = false
|
||||||
|
|
||||||
@@ -46,11 +50,13 @@ export class DebugSystem {
|
|||||||
* @param scene - The Phaser scene this system belongs to
|
* @param scene - The Phaser scene this system belongs to
|
||||||
* @param villagerSystem - Used to read active paths for visualization
|
* @param villagerSystem - Used to read active paths for visualization
|
||||||
* @param worldSystem - Used to read tile types under the mouse
|
* @param worldSystem - Used to read tile types under the mouse
|
||||||
|
* @param adapter - Used to read the recent action log
|
||||||
*/
|
*/
|
||||||
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
|
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem, adapter: LocalAdapter) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.villagerSystem = villagerSystem
|
this.villagerSystem = villagerSystem
|
||||||
this.worldSystem = worldSystem
|
this.worldSystem = worldSystem
|
||||||
|
this.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,6 +165,7 @@ export class DebugSystem {
|
|||||||
nisseByState,
|
nisseByState,
|
||||||
jobsByType,
|
jobsByType,
|
||||||
activePaths: this.villagerSystem.getActivePaths().length,
|
activePaths: this.villagerSystem.getActivePaths().length,
|
||||||
|
actionLog: this.adapter.getActionLog(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export class FarmingSystem {
|
|||||||
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick crop growth
|
// Drain crop growth queue (no delta — gameTime is advanced by GameScene)
|
||||||
const leveled = stateManager.tickCrops(delta)
|
const leveled = stateManager.tickCrops()
|
||||||
for (const id of leveled) this.refreshCropSprite(id)
|
for (const id of leveled) this.refreshCropSprite(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +151,13 @@ export class FarmingSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = CROP_CONFIGS[kind]
|
const cfg = CROP_CONFIGS[kind]
|
||||||
|
const now = stateManager.getGameTime()
|
||||||
const crop: CropState = {
|
const crop: CropState = {
|
||||||
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
||||||
tileX, tileY, kind,
|
tileX, tileY, kind,
|
||||||
stage: 0, maxStage: cfg.stages,
|
stage: 0, maxStage: cfg.stages,
|
||||||
stageTimerMs: cfg.stageTimeMs,
|
growsAt: now + cfg.stageTimeMs,
|
||||||
|
growsAtWatered: now + cfg.stageTimeMs / 2,
|
||||||
watered: tile === TileType.WATERED_SOIL,
|
watered: tile === TileType.WATERED_SOIL,
|
||||||
}
|
}
|
||||||
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
||||||
|
|||||||
194
src/systems/ForesterZoneSystem.ts
Normal file
194
src/systems/ForesterZoneSystem.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import Phaser from 'phaser'
|
||||||
|
import { TILE_SIZE, FORESTER_ZONE_RADIUS } from '../config'
|
||||||
|
import { PLANTABLE_TILES } from '../types'
|
||||||
|
import type { TileType } from '../types'
|
||||||
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
|
|
||||||
|
/** Colors used for zone rendering. */
|
||||||
|
const COLOR_IN_RADIUS = 0x44aa44 // unselected tile within radius (edit mode only)
|
||||||
|
const COLOR_ZONE_TILE = 0x00ff44 // tile marked as part of the zone
|
||||||
|
const ALPHA_VIEW = 0.18 // always-on zone overlay
|
||||||
|
const ALPHA_RADIUS = 0.12 // in-radius tiles while editing
|
||||||
|
const ALPHA_ZONE_EDIT = 0.45 // zone tiles while editing
|
||||||
|
|
||||||
|
export class ForesterZoneSystem {
|
||||||
|
private scene: Phaser.Scene
|
||||||
|
private adapter: LocalAdapter
|
||||||
|
|
||||||
|
/** Graphics layer for the always-visible zone overlay. */
|
||||||
|
private zoneGraphics!: Phaser.GameObjects.Graphics
|
||||||
|
/** Graphics layer for the edit-mode radius/tile overlay. */
|
||||||
|
private editGraphics!: Phaser.GameObjects.Graphics
|
||||||
|
|
||||||
|
/** Building ID currently being edited, or null when not in edit mode. */
|
||||||
|
private editBuildingId: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked after a tile toggle so callers can react (e.g. refresh the panel).
|
||||||
|
* Receives the updated zone tiles array.
|
||||||
|
*/
|
||||||
|
onZoneChanged?: (buildingId: string, tiles: string[]) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked when the user exits edit mode (right-click or programmatic close).
|
||||||
|
* UIScene listens to this to close the zone edit indicator.
|
||||||
|
*/
|
||||||
|
onEditEnded?: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scene - The Phaser scene this system belongs to
|
||||||
|
* @param adapter - Network adapter for dispatching state actions
|
||||||
|
*/
|
||||||
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||||
|
this.scene = scene
|
||||||
|
this.adapter = adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the graphics layers and registers the pointer listener. */
|
||||||
|
create(): void {
|
||||||
|
this.zoneGraphics = this.scene.add.graphics().setDepth(3)
|
||||||
|
this.editGraphics = this.scene.add.graphics().setDepth(4)
|
||||||
|
|
||||||
|
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
|
if (!this.editBuildingId) return
|
||||||
|
if (ptr.rightButtonDown()) {
|
||||||
|
this.exitEditMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.handleTileClick(ptr.worldX, ptr.worldY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws all zone overlays for every forester hut in the current state.
|
||||||
|
* Should be called whenever the zone data changes.
|
||||||
|
*/
|
||||||
|
refreshOverlay(): void {
|
||||||
|
this.zoneGraphics.clear()
|
||||||
|
const state = stateManager.getState()
|
||||||
|
for (const zone of Object.values(state.world.foresterZones)) {
|
||||||
|
for (const key of zone.tiles) {
|
||||||
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
|
this.zoneGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_VIEW)
|
||||||
|
this.zoneGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates zone-editing mode for the given forester hut.
|
||||||
|
* Draws the radius indicator and zone tiles in edit colors.
|
||||||
|
* @param buildingId - ID of the forester_hut building to edit
|
||||||
|
*/
|
||||||
|
startEditMode(buildingId: string): void {
|
||||||
|
this.editBuildingId = buildingId
|
||||||
|
this.drawEditOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates zone-editing mode and clears the edit overlay.
|
||||||
|
* Triggers the onEditEnded callback.
|
||||||
|
*/
|
||||||
|
exitEditMode(): void {
|
||||||
|
if (!this.editBuildingId) return
|
||||||
|
this.editBuildingId = null
|
||||||
|
this.editGraphics.clear()
|
||||||
|
this.onEditEnded?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the zone editor is currently active. */
|
||||||
|
isEditing(): boolean {
|
||||||
|
return this.editBuildingId !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroys all graphics objects. */
|
||||||
|
destroy(): void {
|
||||||
|
this.zoneGraphics.destroy()
|
||||||
|
this.editGraphics.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a left-click during edit mode.
|
||||||
|
* Toggles the clicked tile in the zone if it is within radius and plantable.
|
||||||
|
* @param worldX - World pixel X of the pointer
|
||||||
|
* @param worldY - World pixel Y of the pointer
|
||||||
|
*/
|
||||||
|
private handleTileClick(worldX: number, worldY: number): void {
|
||||||
|
const id = this.editBuildingId
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const building = state.world.buildings[id]
|
||||||
|
if (!building) { this.exitEditMode(); return }
|
||||||
|
|
||||||
|
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
|
|
||||||
|
// Chebyshev distance — must be within radius
|
||||||
|
const dx = Math.abs(tileX - building.tileX)
|
||||||
|
const dy = Math.abs(tileY - building.tileY)
|
||||||
|
if (Math.max(dx, dy) > FORESTER_ZONE_RADIUS) return
|
||||||
|
|
||||||
|
const zone = state.world.foresterZones[id]
|
||||||
|
if (!zone) return
|
||||||
|
|
||||||
|
const key = `${tileX},${tileY}`
|
||||||
|
const idx = zone.tiles.indexOf(key)
|
||||||
|
const tiles = idx >= 0
|
||||||
|
? zone.tiles.filter(t => t !== key) // remove
|
||||||
|
: [...zone.tiles, key] // add
|
||||||
|
|
||||||
|
this.adapter.send({ type: 'FORESTER_ZONE_UPDATE', buildingId: id, tiles })
|
||||||
|
this.refreshOverlay()
|
||||||
|
this.drawEditOverlay()
|
||||||
|
this.onZoneChanged?.(id, tiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the edit-mode overlay showing the valid radius and current zone tiles.
|
||||||
|
* Only called while editBuildingId is set.
|
||||||
|
*/
|
||||||
|
private drawEditOverlay(): void {
|
||||||
|
this.editGraphics.clear()
|
||||||
|
const id = this.editBuildingId
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const building = state.world.buildings[id]
|
||||||
|
if (!building) return
|
||||||
|
|
||||||
|
const zone = state.world.foresterZones[id]
|
||||||
|
const zoneSet = new Set(zone?.tiles ?? [])
|
||||||
|
const r = FORESTER_ZONE_RADIUS
|
||||||
|
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const tx = building.tileX + dx
|
||||||
|
const ty = building.tileY + dy
|
||||||
|
const key = `${tx},${ty}`
|
||||||
|
|
||||||
|
// Only draw on plantable terrain
|
||||||
|
const tileType = state.world.tiles[ty * 512 + tx] as TileType
|
||||||
|
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||||
|
|
||||||
|
if (zoneSet.has(key)) {
|
||||||
|
this.editGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_ZONE_EDIT)
|
||||||
|
this.editGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||||
|
} else {
|
||||||
|
this.editGraphics.fillStyle(COLOR_IN_RADIUS, ALPHA_RADIUS)
|
||||||
|
}
|
||||||
|
this.editGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a subtle border around the entire radius square
|
||||||
|
const bx = (building.tileX - r) * TILE_SIZE
|
||||||
|
const by = (building.tileY - r) * TILE_SIZE
|
||||||
|
const bw = (2 * r + 1) * TILE_SIZE
|
||||||
|
this.editGraphics.lineStyle(1, COLOR_ZONE_TILE, 0.4)
|
||||||
|
this.editGraphics.strokeRect(bx, by, bw, bw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,10 +47,10 @@ export class ResourceSystem {
|
|||||||
sprite.setOrigin(0.5, 0.75)
|
sprite.setOrigin(0.5, 0.75)
|
||||||
}
|
}
|
||||||
|
|
||||||
sprite.setDepth(5)
|
sprite.setDepth(node.tileY + 5)
|
||||||
|
|
||||||
const healthBar = this.scene.add.graphics()
|
const healthBar = this.scene.add.graphics()
|
||||||
healthBar.setDepth(6)
|
healthBar.setDepth(node.tileY + 6)
|
||||||
healthBar.setVisible(false)
|
healthBar.setVisible(false)
|
||||||
|
|
||||||
this.sprites.set(node.id, { sprite, node, healthBar })
|
this.sprites.set(node.id, { sprite, node, healthBar })
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export class TreeSeedlingSystem {
|
|||||||
* @param delta - Frame delta in milliseconds
|
* @param delta - Frame delta in milliseconds
|
||||||
*/
|
*/
|
||||||
update(delta: number): void {
|
update(delta: number): void {
|
||||||
const advanced = stateManager.tickSeedlings(delta)
|
// Drain seedling growth queue (no delta — gameTime is advanced by GameScene)
|
||||||
|
const advanced = stateManager.tickSeedlings()
|
||||||
for (const id of advanced) {
|
for (const id of advanced) {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const seedling = state.world.treeSeedlings[id]
|
const seedling = state.world.treeSeedlings[id]
|
||||||
@@ -50,7 +51,6 @@ export class TreeSeedlingSystem {
|
|||||||
this.removeSprite(id)
|
this.removeSprite(id)
|
||||||
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
||||||
this.worldSystem.refreshTerrainTile(tileX, tileY, TileType.FOREST)
|
|
||||||
|
|
||||||
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
||||||
this.adapter.send({
|
this.adapter.send({
|
||||||
@@ -92,7 +92,7 @@ export class TreeSeedlingSystem {
|
|||||||
const seedling: TreeSeedlingState = {
|
const seedling: TreeSeedlingState = {
|
||||||
id, tileX, tileY,
|
id, tileX, tileY,
|
||||||
stage: 0,
|
stage: 0,
|
||||||
stageTimerMs: TREE_SEEDLING_STAGE_MS,
|
growsAt: stateManager.getGameTime() + TREE_SEEDLING_STAGE_MS,
|
||||||
underlyingTile,
|
underlyingTile,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export class TreeSeedlingSystem {
|
|||||||
const key = `seedling_${Math.min(s.stage, 2)}`
|
const key = `seedling_${Math.min(s.stage, 2)}`
|
||||||
const sprite = this.scene.add.image(x, y, key)
|
const sprite = this.scene.add.image(x, y, key)
|
||||||
.setOrigin(0.5, 0.85)
|
.setOrigin(0.5, 0.85)
|
||||||
.setDepth(5)
|
.setDepth(s.tileY + 5)
|
||||||
this.sprites.set(s.id, sprite)
|
this.sprites.set(s.id, sprite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
|
||||||
import { TileType } from '../types'
|
import { TileType, PLANTABLE_TILES } from '../types'
|
||||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import { findPath } from '../utils/pathfinding'
|
import { findPath } from '../utils/pathfinding'
|
||||||
@@ -13,6 +13,9 @@ const ARRIVAL_PX = 3
|
|||||||
|
|
||||||
const WORK_LOG_MAX = 20
|
const WORK_LOG_MAX = 20
|
||||||
|
|
||||||
|
/** Job-type → display icon mapping; defined once at module level to avoid per-frame allocation. */
|
||||||
|
const JOB_ICONS: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
|
||||||
|
|
||||||
interface VillagerRuntime {
|
interface VillagerRuntime {
|
||||||
sprite: Phaser.GameObjects.Image
|
sprite: Phaser.GameObjects.Image
|
||||||
nameLabel: Phaser.GameObjects.Text
|
nameLabel: Phaser.GameObjects.Text
|
||||||
@@ -40,6 +43,12 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
onMessage?: (msg: string) => void
|
onMessage?: (msg: string) => void
|
||||||
onNisseClick?: (villagerId: string) => void
|
onNisseClick?: (villagerId: string) => void
|
||||||
|
/**
|
||||||
|
* Called when a Nisse completes a forester planting job.
|
||||||
|
* GameScene wires this to TreeSeedlingSystem.plantSeedling so that the
|
||||||
|
* seedling sprite is spawned alongside the state action.
|
||||||
|
*/
|
||||||
|
onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param scene - The Phaser scene this system belongs to
|
* @param scene - The Phaser scene this system belongs to
|
||||||
@@ -112,15 +121,15 @@ export class VillagerSystem {
|
|||||||
case 'sleeping':this.tickSleeping(v, rt, delta); break
|
case 'sleeping':this.tickSleeping(v, rt, delta); break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync sprite to state position
|
// Nisse always render above world objects
|
||||||
rt.sprite.setPosition(v.x, v.y)
|
rt.sprite.setPosition(v.x, v.y)
|
||||||
|
|
||||||
rt.nameLabel.setPosition(v.x, v.y - 22)
|
rt.nameLabel.setPosition(v.x, v.y - 22)
|
||||||
rt.energyBar.setPosition(0, 0)
|
rt.energyBar.setPosition(0, 0)
|
||||||
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
||||||
|
|
||||||
// Job icon
|
// Job icon
|
||||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
|
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
|
||||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
|
|
||||||
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,19 +283,21 @@ 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 })
|
||||||
// Start recovery timer so DARK_GRASS reverts to GRASS after 5 minutes
|
|
||||||
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
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)')
|
// Chopping a tree yields 1–2 tree seeds in the stockpile
|
||||||
|
const seeds = Math.random() < 0.5 ? 2 : 1
|
||||||
|
this.adapter.send({ type: 'ADD_ITEMS', items: { tree_seed: seeds } })
|
||||||
|
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
||||||
}
|
}
|
||||||
} else if (job.type === 'mine') {
|
} 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)')
|
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||||
}
|
}
|
||||||
@@ -298,6 +309,20 @@ export class VillagerSystem {
|
|||||||
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}`)
|
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
|
||||||
}
|
}
|
||||||
|
} else if (job.type === 'forester') {
|
||||||
|
// Verify the tile is still empty and the stockpile still has seeds
|
||||||
|
const tileType = state.world.tiles[job.tileY * WORLD_TILES + job.tileX] as TileType
|
||||||
|
const hasSeeds = (state.world.stockpile.tree_seed ?? 0) > 0
|
||||||
|
const tileOccupied =
|
||||||
|
Object.values(state.world.resources).some(r => r.tileX === job.tileX && r.tileY === job.tileY) ||
|
||||||
|
Object.values(state.world.buildings).some(b => b.tileX === job.tileX && b.tileY === job.tileY) ||
|
||||||
|
Object.values(state.world.crops).some(c => c.tileX === job.tileX && c.tileY === job.tileY) ||
|
||||||
|
Object.values(state.world.treeSeedlings).some(s => s.tileX === job.tileX && s.tileY === job.tileY)
|
||||||
|
|
||||||
|
if (hasSeeds && PLANTABLE_TILES.has(tileType) && !tileOccupied) {
|
||||||
|
this.onPlantSeedling?.(job.tileX, job.tileY, tileType)
|
||||||
|
this.addLog(v.id, `🌱 Planted seedling at (${job.tileX}, ${job.tileY})`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the harvest produced nothing (resource already gone), clear the stale job
|
// If the harvest produced nothing (resource already gone), clear the stale job
|
||||||
@@ -338,6 +363,15 @@ export class VillagerSystem {
|
|||||||
* @param v - Villager state (used for position and priorities)
|
* @param v - Villager state (used for position and priorities)
|
||||||
* @returns The chosen job candidate, or null
|
* @returns The chosen job candidate, or null
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Selects the best available job for a Nisse based on their priority settings.
|
||||||
|
* Among jobs at the same priority level, the closest one wins.
|
||||||
|
* For chop jobs, trees within a forester zone are preferred over natural trees —
|
||||||
|
* natural trees are only offered when no forester-zone trees are available.
|
||||||
|
* Returns null if no unclaimed job is available.
|
||||||
|
* @param v - Villager state (used for position and priorities)
|
||||||
|
* @returns The chosen job candidate, or null
|
||||||
|
*/
|
||||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const p = v.priorities
|
const p = v.priorities
|
||||||
@@ -345,32 +379,84 @@ export class VillagerSystem {
|
|||||||
const vTY = Math.floor(v.y / TILE_SIZE)
|
const vTY = Math.floor(v.y / TILE_SIZE)
|
||||||
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
|
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
|
||||||
|
|
||||||
|
// Extract state collections once — avoids repeated Object.values() allocation per branch/loop.
|
||||||
|
const resources = Object.values(state.world.resources)
|
||||||
|
const buildings = Object.values(state.world.buildings)
|
||||||
|
const crops = Object.values(state.world.crops)
|
||||||
|
const seedlings = Object.values(state.world.treeSeedlings)
|
||||||
|
const zones = Object.values(state.world.foresterZones)
|
||||||
|
|
||||||
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
|
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
|
||||||
const candidates: C[] = []
|
const candidates: C[] = []
|
||||||
|
|
||||||
if (p.chop > 0) {
|
if (p.chop > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
// Build the set of all tiles belonging to forester zones for chop priority
|
||||||
|
const zoneTiles = new Set<string>()
|
||||||
|
for (const zone of zones) {
|
||||||
|
for (const key of zone.tiles) zoneTiles.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneChop: C[] = []
|
||||||
|
const naturalChop: C[] = []
|
||||||
|
|
||||||
|
for (const res of resources) {
|
||||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||||
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
// Skip trees with no reachable neighbour — A* cannot reach them.
|
||||||
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
|
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
||||||
|
if (zoneTiles.has(`${res.tileX},${res.tileY}`)) {
|
||||||
|
zoneChop.push(c)
|
||||||
|
} else {
|
||||||
|
naturalChop.push(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Prefer zone trees; fall back to natural only when no zone trees are reachable.
|
||||||
|
candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop))
|
||||||
|
}
|
||||||
|
|
||||||
if (p.mine > 0) {
|
if (p.mine > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of resources) {
|
||||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||||
|
// Same reachability guard for rock tiles.
|
||||||
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.farm > 0) {
|
if (p.farm > 0) {
|
||||||
for (const crop of Object.values(state.world.crops)) {
|
for (const crop of crops) {
|
||||||
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
|
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
|
||||||
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
|
||||||
|
// Find empty plantable zone tiles to seed
|
||||||
|
for (const zone of zones) {
|
||||||
|
for (const key of zone.tiles) {
|
||||||
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
|
const targetId = `forester_tile_${tx}_${ty}`
|
||||||
|
if (this.claimed.has(targetId)) continue
|
||||||
|
// Skip if tile is not plantable
|
||||||
|
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
||||||
|
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||||
|
// Skip if something occupies this tile — reuse already-extracted arrays
|
||||||
|
const occupied =
|
||||||
|
resources.some(r => r.tileX === tx && r.tileY === ty) ||
|
||||||
|
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
|
||||||
|
crops.some(c => c.tileX === tx && c.tileY === ty) ||
|
||||||
|
seedlings.some(s => s.tileX === tx && s.tileY === ty)
|
||||||
|
if (occupied) continue
|
||||||
|
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (candidates.length === 0) return null
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
// Lowest priority number wins; ties broken by distance
|
// Lowest priority number wins; ties broken by distance — avoid spread+map allocation
|
||||||
const bestPri = Math.min(...candidates.map(c => c.pri))
|
let bestPri = candidates[0].pri
|
||||||
|
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
|
||||||
return candidates
|
return candidates
|
||||||
.filter(c => c.pri === bestPri)
|
.filter(c => c.pri === bestPri)
|
||||||
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
||||||
@@ -397,7 +483,7 @@ export class VillagerSystem {
|
|||||||
this.claimed.delete(v.job.targetId)
|
this.claimed.delete(v.job.targetId)
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||||
}
|
}
|
||||||
rt.idleScanTimer = 1500 // longer delay after failed pathfind
|
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +520,22 @@ export class VillagerSystem {
|
|||||||
return this.nearestBuilding(v, 'bed') as any
|
return this.nearestBuilding(v, 'bed') as any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if at least one of the 8 neighbours of the given tile is passable.
|
||||||
|
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
|
||||||
|
* such as trees deep inside a dense forest cluster where A* can never reach the goal
|
||||||
|
* tile because no passable tile is adjacent to it.
|
||||||
|
* @param tileX - Target tile X
|
||||||
|
* @param tileY - Target tile Y
|
||||||
|
*/
|
||||||
|
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
|
||||||
|
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
|
||||||
|
for (const [dx, dy] of DIRS) {
|
||||||
|
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -461,7 +563,7 @@ export class VillagerSystem {
|
|||||||
y: (freeBed.tileY + 0.5) * TILE_SIZE,
|
y: (freeBed.tileY + 0.5) * TILE_SIZE,
|
||||||
bedId: freeBed.id,
|
bedId: freeBed.id,
|
||||||
job: null,
|
job: null,
|
||||||
priorities: { chop: 1, mine: 2, farm: 3 },
|
priorities: { chop: 1, mine: 2, farm: 3, forester: 4 },
|
||||||
energy: 100,
|
energy: 100,
|
||||||
aiState: 'idle',
|
aiState: 'idle',
|
||||||
}
|
}
|
||||||
@@ -478,16 +580,23 @@ export class VillagerSystem {
|
|||||||
* for a newly added Nisse.
|
* for a newly added Nisse.
|
||||||
* @param v - Villager state to create sprites for
|
* @param v - Villager state to create sprites for
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Creates and registers all runtime objects (sprite, outline, label, energy bar, icon)
|
||||||
|
* for a newly added Nisse.
|
||||||
|
* @param v - Villager state to create sprites for
|
||||||
|
*/
|
||||||
private spawnSprite(v: VillagerState): void {
|
private spawnSprite(v: VillagerState): void {
|
||||||
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
|
// Nisse always render above trees, buildings and other world objects.
|
||||||
|
const sprite = this.scene.add.image(v.x, v.y, 'villager')
|
||||||
|
.setDepth(900)
|
||||||
|
|
||||||
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
|
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
|
||||||
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
||||||
}).setOrigin(0.5, 1).setDepth(12)
|
}).setOrigin(0.5, 1).setDepth(901)
|
||||||
|
|
||||||
const energyBar = this.scene.add.graphics().setDepth(12)
|
const energyBar = this.scene.add.graphics().setDepth(901)
|
||||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902)
|
||||||
|
|
||||||
sprite.setInteractive()
|
sprite.setInteractive()
|
||||||
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
||||||
@@ -538,7 +647,10 @@ export class VillagerSystem {
|
|||||||
const v = stateManager.getState().world.villagers[villagerId]
|
const v = stateManager.getState().world.villagers[villagerId]
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
if (v.aiState === 'sleeping') return '💤 Sleeping'
|
if (v.aiState === 'sleeping') return '💤 Sleeping'
|
||||||
if (v.aiState === 'working' && v.job) return `⚒ ${v.job.type}ing`
|
if (v.aiState === 'working' && v.job) {
|
||||||
|
const label = v.job.type === 'forester' ? 'planting' : `${v.job.type}ing`
|
||||||
|
return `⚒ ${label}`
|
||||||
|
}
|
||||||
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
|
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
|
||||||
if (v.aiState === 'walking') return '🚶 Walking'
|
if (v.aiState === 'walking') return '🚶 Walking'
|
||||||
const carrying = v.job?.carrying
|
const carrying = v.job?.carrying
|
||||||
@@ -573,6 +685,14 @@ export class VillagerSystem {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
|
* Should be called when the scene shuts down.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
|
* Should be called when the scene shuts down.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Destroys all Nisse sprites and clears the runtime map.
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
* Should be called when the scene shuts down.
|
* Should be called when the scene shuts down.
|
||||||
|
|||||||
@@ -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,6 +18,12 @@ 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
|
||||||
@@ -85,6 +91,8 @@ export class WorldSystem {
|
|||||||
|
|
||||||
// Camera bounds
|
// Camera bounds
|
||||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||||
|
|
||||||
|
this.initResourceTiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||||
@@ -111,6 +119,10 @@ export class WorldSystem {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the tile at the given coordinates can be walked on.
|
* Returns whether the tile at the given coordinates can be walked on.
|
||||||
|
* Water and wall tiles are always impassable.
|
||||||
|
* Forest and rock terrain tiles are only impassable when a resource
|
||||||
|
* (tree or rock) currently occupies them — empty forest floor and bare
|
||||||
|
* rocky ground are walkable.
|
||||||
* Out-of-bounds tiles are treated as impassable.
|
* Out-of-bounds tiles are treated as impassable.
|
||||||
* @param tileX - Tile column
|
* @param tileX - Tile column
|
||||||
* @param tileY - Tile row
|
* @param tileY - Tile row
|
||||||
@@ -119,7 +131,55 @@ export class WorldSystem {
|
|||||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
||||||
return !IMPASSABLE.has(tile)
|
if (IMPASSABLE.has(tile)) return false
|
||||||
|
if (RESOURCE_TERRAIN.has(tile)) {
|
||||||
|
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the resource tile index from the current world state.
|
||||||
|
* Called once in create() so that isPassable() has an O(1) lookup.
|
||||||
|
*/
|
||||||
|
private initResourceTiles(): void {
|
||||||
|
this.resourceTiles.clear()
|
||||||
|
const state = stateManager.getState()
|
||||||
|
for (const res of Object.values(state.world.resources)) {
|
||||||
|
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a newly placed resource so isPassable() treats the tile as blocked.
|
||||||
|
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
|
||||||
|
* @param tileX - Resource tile column
|
||||||
|
* @param tileY - Resource tile row
|
||||||
|
*/
|
||||||
|
addResourceTile(tileX: number, tileY: number): void {
|
||||||
|
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a resource from the tile index so isPassable() treats the tile as free.
|
||||||
|
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
|
||||||
|
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
|
||||||
|
* but keeps the index clean for correctness.
|
||||||
|
* @param tileX - Resource tile column
|
||||||
|
* @param tileY - Resource tile row
|
||||||
|
*/
|
||||||
|
removeResourceTile(tileX: number, tileY: number): void {
|
||||||
|
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a resource (tree or rock) occupies the given tile.
|
||||||
|
* Uses the O(1) resourceTiles index.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
*/
|
||||||
|
hasResourceAt(tileX: number, tileY: number): boolean {
|
||||||
|
return this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
45
src/types.ts
45
src/types.ts
@@ -12,24 +12,31 @@ export enum TileType {
|
|||||||
WATERED_SOIL = 10,
|
WATERED_SOIL = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tiles that are always impassable regardless of what is on them. */
|
||||||
export const IMPASSABLE = new Set<number>([
|
export const IMPASSABLE = new Set<number>([
|
||||||
TileType.DEEP_WATER,
|
TileType.DEEP_WATER,
|
||||||
TileType.SHALLOW_WATER,
|
TileType.SHALLOW_WATER,
|
||||||
TileType.FOREST,
|
|
||||||
TileType.ROCK,
|
|
||||||
TileType.WALL,
|
TileType.WALL,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Tiles on which tree seedlings may be planted. */
|
||||||
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
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 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' | 'forester_hut'
|
||||||
|
|
||||||
export type CropKind = 'wheat' | 'carrot'
|
export type CropKind = 'wheat' | 'carrot'
|
||||||
|
|
||||||
export type JobType = 'chop' | 'mine' | 'farm'
|
export type JobType = 'chop' | 'mine' | 'farm' | 'forester'
|
||||||
|
|
||||||
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
|
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
|
||||||
|
|
||||||
@@ -37,6 +44,7 @@ export interface JobPriorities {
|
|||||||
chop: number // 0 = disabled, 1 = highest, 4 = lowest
|
chop: number // 0 = disabled, 1 = highest, 4 = lowest
|
||||||
mine: number
|
mine: number
|
||||||
farm: number
|
farm: number
|
||||||
|
forester: number // plant tree seedlings in forester zones
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VillagerJob {
|
export interface VillagerJob {
|
||||||
@@ -82,7 +90,11 @@ export interface CropState {
|
|||||||
kind: CropKind
|
kind: CropKind
|
||||||
stage: number
|
stage: number
|
||||||
maxStage: number
|
maxStage: number
|
||||||
stageTimerMs: number
|
/** gameTime (ms) when this stage fires at normal (unwatered) speed. */
|
||||||
|
growsAt: number
|
||||||
|
/** gameTime (ms) when this stage fires if the crop is watered (half normal time).
|
||||||
|
* Both entries are enqueued at plant/stage-advance time; the stale one is skipped. */
|
||||||
|
growsAtWatered: number
|
||||||
watered: boolean
|
watered: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +111,26 @@ export interface TreeSeedlingState {
|
|||||||
tileY: number
|
tileY: number
|
||||||
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||||
stage: number
|
stage: number
|
||||||
/** Time remaining until next stage advance, in milliseconds. */
|
/** gameTime (ms) when this seedling advances to the next stage. */
|
||||||
stageTimerMs: number
|
growsAt: number
|
||||||
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||||
underlyingTile: TileType
|
underlyingTile: TileType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of tiles assigned to one forester hut's planting zone.
|
||||||
|
* Tiles are stored as "tileX,tileY" key strings.
|
||||||
|
*/
|
||||||
|
export interface ForesterZoneState {
|
||||||
|
buildingId: string
|
||||||
|
/** Tile keys "tileX,tileY" that the player has marked for planting. */
|
||||||
|
tiles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorldState {
|
export interface WorldState {
|
||||||
seed: number
|
seed: number
|
||||||
|
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
|
||||||
|
gameTime: number
|
||||||
tiles: number[]
|
tiles: number[]
|
||||||
resources: Record<string, ResourceNodeState>
|
resources: Record<string, ResourceNodeState>
|
||||||
buildings: Record<string, BuildingState>
|
buildings: Record<string, BuildingState>
|
||||||
@@ -116,10 +140,12 @@ export interface WorldState {
|
|||||||
/** Planted tree seedlings, keyed by ID. */
|
/** Planted tree seedlings, keyed by ID. */
|
||||||
treeSeedlings: Record<string, TreeSeedlingState>
|
treeSeedlings: Record<string, TreeSeedlingState>
|
||||||
/**
|
/**
|
||||||
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
|
* Tile recovery fire-times, keyed by "tileX,tileY".
|
||||||
* Value is remaining milliseconds until the tile reverts to GRASS.
|
* Value is the gameTime (ms) at which the tile reverts to GRASS.
|
||||||
*/
|
*/
|
||||||
tileRecovery: Record<string, number>
|
tileRecovery: Record<string, number>
|
||||||
|
/** Forester zone definitions, keyed by forester_hut building ID. */
|
||||||
|
foresterZones: Record<string, ForesterZoneState>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameStateData {
|
export interface GameStateData {
|
||||||
@@ -149,3 +175,4 @@ export type GameAction =
|
|||||||
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
||||||
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
||||||
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
||||||
|
| { type: 'FORESTER_ZONE_UPDATE'; buildingId: string; tiles: string[] }
|
||||||
|
|||||||
Reference in New Issue
Block a user