19 Commits

Author SHA1 Message Date
ae6c14d9a1 🐛 reposition debug panel when Nisse info panel is open (#41)
Debug panel shifts below the Nisse info panel to avoid overlap.
repositionDebugPanel() is called on toggle, open, and close.

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

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

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

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

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

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

Closes #36
Closes #37
2026-03-24 08:08:05 +00:00
26c3807481 Merge pull request 'Fix GC-Ruckler in pickJob und tickVillager' (#35) from fix/gc-alloc-picjob into master
Reviewed-on: #35
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 20:28:45 +00:00
d02ed33435 fix GC ruckler — Object.values() einmal pro pickJob-Aufruf
Fixes #34. Alle Object.values()-Aufrufe werden einmal am Anfang von
pickJob() extrahiert und in allen Branches wiederverwendet. Der
Forester-Loop rief zuvor fuer jedes Zone-Tile 4x Object.values() auf.
JOB_ICONS als Modul-Konstante, Math.min-spread durch Schleife ersetzt.
2026-03-23 20:18:00 +00:00
c7cf971e54 📝 update CHANGELOG for depth sorting (PR #32) 2026-03-23 20:08:12 +00:00
08dffa135f Merge pull request 'Fix Y-based depth sorting for world objects' (#32) from feature/depth-sorting into master
Reviewed-on: #32
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 20:07:28 +00:00
4f2e9f73b6 ♻️ remove silhouette — Nisse always render above world objects
Depth fixed at 900; isOccluded() and outlineSprite removed.
WorldSystem.hasResourceAt() stays as a useful utility.
2026-03-23 20:05:53 +00:00
84b6e51746 🐛 improve occlusion detection with wider tile check
Expands isOccluded() from same-column-only to a 3x4 tile window
(tileX+-1, tileY+1..4) to catch trees whose canopy extends sideways
and well above the trunk tile. Outline scale bumped to 1.15.
2026-03-23 20:02:40 +00:00
5f646d54ca 🐛 fix Nisse outline only shown when actually occluded
Silhouette now hidden by default and toggled on per frame only when
isOccluded() detects a tree, rock or building 1–3 tiles below the Nisse.
Adds WorldSystem.hasResourceAt() for O(1) tile lookup. Outline colour
changed to light blue (0xaaddff) at scale 1.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:52:37 +00:00
94b2f7f457 add Nisse silhouette outline for occlusion visibility
Fixes #33. Each Nisse now has a white filled outline sprite at depth 900
that is always visible above trees and buildings. The main sprite uses
Y-based depth (floor(y/TILE_SIZE)+5) so Nisse sort correctly with world
objects. Name label, energy bar and job icon moved to depth 901/902 so
they remain readable regardless of occlusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:47:51 +00:00
cd171c859c fix depth sorting for world objects by tileY
Fixes #31. All trees, rocks, seedlings and buildings now use
tileY+5 as depth instead of a fixed value, so objects further
down the screen always render in front of objects above them
regardless of spawn order. Build ghost moved to depth 1000/1001.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:40:27 +00:00
d9ef57c6b0 Merge pull request 'Add bottom action bar with Build and Nisse buttons' (#30) from feature/action-bar into master
Reviewed-on: #30
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 19:24:46 +00:00
13 changed files with 392 additions and 125 deletions

View File

@@ -7,6 +7,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **Debug-Panel überlagert Nisse-Info-Panel** (Issue #41): F3-Debug-Panel weicht dynamisch aus — wenn das Nisse-Info-Panel offen ist, erscheint das Debug-Panel unterhalb davon statt darüber
- **Stockpile-Overlay Transparenz** (Issue #39): `updateStaticPanelOpacity()` verwendete `setAlpha()` statt `setFillStyle()` — dadurch wurde die Opacity quadratisch statt linear angewendet; bei 100 % blieb das Panel sichtbar transparent
- **Action Bar Transparenz** (Issue #40): Action Bar ignorierte `uiOpacity` komplett — Hintergrund war hardcoded auf 0.92; wird jetzt korrekt mit `uiOpacity` erstellt und per `updateStaticPanelOpacity()` live aktualisiert
### Performance
- **Event-queue timers** (Issue #36): crops, tree seedlings, and tile-recovery events now use a sorted priority queue with absolute `gameTime` timestamps instead of per-frame countdown iteration — O(due items) per tick instead of O(total items); `WorldState.gameTime` tracks the in-game clock; save migrated from v5 to v6
### Added
- **Action log in F3 debug panel** (Issue #37): last 15 actions dispatched through the adapter are shown in the F3 overlay under "Last Actions"; ring buffer maintained in `LocalAdapter`
### Fixed
- **Y-based depth sorting** (Issue #31): trees, rocks, seedlings and buildings now use `tileY + 5` as depth instead of fixed values — objects lower on screen always render in front of objects above them, regardless of spawn order; build ghost moved to depth 1000
- **Nisse always visible** (Issue #33): Nisse sprites fixed at depth 900, always rendered above world objects
### Added ### Added
- **Försterkreislauf** (Issue #25): - **Försterkreislauf** (Issue #25):
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 12 `tree_seed` in den Stockpile - **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 12 `tree_seed` in den Stockpile

View File

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

View File

@@ -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: {},
@@ -25,21 +52,164 @@ function makeEmptyWorld(seed: number): WorldState {
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
@@ -66,7 +236,6 @@ 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))
// Automatically create an empty forester zone when a forester hut is placed
if (action.building.kind === 'forester_hut') { if (action.building.kind === 'forester_hut') {
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] } w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
} }
@@ -74,7 +243,6 @@ class StateManager {
} }
case 'REMOVE_BUILDING': case 'REMOVE_BUILDING':
// Remove associated forester zone when the hut is demolished
if (w.buildings[action.buildingId]?.kind === 'forester_hut') { if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
delete w.foresterZones[action.buildingId] delete w.foresterZones[action.buildingId]
} }
@@ -90,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
@@ -163,22 +333,30 @@ 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 break
}
case 'FORESTER_ZONE_UPDATE': { case 'FORESTER_ZONE_UPDATE': {
const zone = w.foresterZones[action.buildingId] const zone = w.foresterZones[action.buildingId]
@@ -188,60 +366,7 @@ class StateManager {
} }
} }
tickCrops(delta: number): string[] { // ─── Persistence ───────────────────────────────────────────────────────────
const advanced: string[] = []
for (const crop of Object.values(this.state.world.crops)) {
if (crop.stage >= crop.maxStage) continue
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
if (crop.stageTimerMs <= 0) {
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
advanced.push(crop.id)
}
}
return advanced
}
/**
* 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 (_) {}
@@ -252,20 +377,40 @@ 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 = {}
if (!p.world.foresterZones) p.world.foresterZones = {} if (!p.world.foresterZones) p.world.foresterZones = {}
// Reset in-flight AI states to idle on load so runtime timers start fresh 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'
// Migrate older saves that don't have the forester priority
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4 if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
} }
// Rebuild forester zones for huts that predate the foresterZones field
for (const b of Object.values(p.world.buildings)) { for (const b of Object.values(p.world.buildings)) {
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) { if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] } p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
@@ -278,6 +423,7 @@ class StateManager {
reset(): void { reset(): void {
localStorage.removeItem(SAVE_KEY) localStorage.removeItem(SAVE_KEY)
this.state = makeDefaultState() this.state = makeDefaultState()
this.rebuildQueues()
} }
} }

View File

@@ -46,7 +46,7 @@ export class GameScene extends Phaser.Scene {
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.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter) this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem, this.adapter)
this.worldSystem.create() this.worldSystem.create()
this.renderPersistentObjects() this.renderPersistentObjects()
@@ -145,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)
@@ -153,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)
@@ -179,18 +182,19 @@ 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') { } else if (building.kind === 'forester_hut') {
// Draw a simple log-cabin silhouette for the forester hut // Draw a simple log-cabin silhouette for the forester hut
const g = this.add.graphics().setName(name).setDepth(8) const g = this.add.graphics().setName(name).setDepth(worldDepth)
// Body // Body
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18) g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
// Roof // Roof

View File

@@ -405,7 +405,7 @@ export class UIScene extends Phaser.Scene {
/** Creates the debug panel text object (initially hidden). */ /** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void { private createDebugPanel(): void {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0') const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 80, '', { this.debugPanelText = this.add.text(10, 10, '', {
fontSize: '12px', fontSize: '12px',
color: '#cccccc', color: '#cccccc',
backgroundColor: `#000000${hexAlpha}`, backgroundColor: `#000000${hexAlpha}`,
@@ -419,9 +419,20 @@ export class UIScene extends Phaser.Scene {
private toggleDebugPanel(): void { private toggleDebugPanel(): void {
this.debugActive = !this.debugActive this.debugActive = !this.debugActive
this.debugPanelText.setVisible(this.debugActive) this.debugPanelText.setVisible(this.debugActive)
this.repositionDebugPanel()
this.scene.get('Game').events.emit('debugToggle') this.scene.get('Game').events.emit('debugToggle')
} }
/**
* Repositions the debug panel to avoid overlapping the Nisse info panel.
* When the Nisse info panel is open, the debug panel shifts below it.
*/
private repositionDebugPanel(): void {
const NISSE_PANEL_H = 120 + 10 * 14 + 16 // matches buildNisseInfoPanel: 276px
const debugY = this.nisseInfoVisible ? 10 + NISSE_PANEL_H + 10 : 10
this.debugPanelText.setY(debugY)
}
/** /**
* Reads current debug data from DebugSystem and updates the panel text. * Reads current debug data from DebugSystem and updates the panel text.
* Called every frame while debug mode is active. * Called every frame while debug mode is active.
@@ -463,6 +474,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',
]) ])
} }
@@ -780,11 +794,13 @@ export class UIScene extends Phaser.Scene {
/** /**
* Applies the current uiOpacity to all static UI elements that are not * Applies the current uiOpacity to all static UI elements that are not
* rebuilt on open (stockpile panel, debug panel background). * rebuilt on open (stockpile panel, action bar, debug panel background).
* Called whenever uiOpacity changes. * Called whenever uiOpacity changes.
*/ */
private updateStaticPanelOpacity(): void { private updateStaticPanelOpacity(): void {
this.stockpilePanel.setAlpha(this.uiOpacity) this.stockpilePanel.setFillStyle(0x000000, this.uiOpacity)
this.actionBarBg.setFillStyle(0x080808, this.uiOpacity)
this.updateCategoryHighlights()
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0') const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` }) this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
} }
@@ -874,6 +890,7 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoId = villagerId this.nisseInfoId = villagerId
this.nisseInfoVisible = true this.nisseInfoVisible = true
this.buildNisseInfoPanel() this.buildNisseInfoPanel()
this.repositionDebugPanel()
} }
/** Closes and destroys the Nisse info panel. */ /** Closes and destroys the Nisse info panel. */
@@ -883,6 +900,7 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoId = null this.nisseInfoId = null
this.nisseInfoGroup.destroy(true) this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group() this.nisseInfoGroup = this.add.group()
this.repositionDebugPanel()
} }
/** /**
@@ -1166,16 +1184,16 @@ export class UIScene extends Phaser.Scene {
const { width, height } = this.scale const { width, height } = this.scale
const barY = height - UIScene.BAR_H const barY = height - UIScene.BAR_H
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, 0.92) this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300) .setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, 0.9) this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive() .setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionBuildBtn.on('pointerover', () => { this.actionBuildBtn.on('pointerover', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, 0.9) if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, this.uiOpacity)
}) })
this.actionBuildBtn.on('pointerout', () => { this.actionBuildBtn.on('pointerout', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, 0.9) if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, this.uiOpacity)
}) })
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build')) this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
@@ -1183,13 +1201,13 @@ export class UIScene extends Phaser.Scene {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace', fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302) }).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, 0.9) this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive() .setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionNisseBtn.on('pointerover', () => { this.actionNisseBtn.on('pointerover', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, 0.9) if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, this.uiOpacity)
}) })
this.actionNisseBtn.on('pointerout', () => { this.actionNisseBtn.on('pointerout', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, 0.9) if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, this.uiOpacity)
}) })
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse')) this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
@@ -1237,8 +1255,8 @@ export class UIScene extends Phaser.Scene {
* to reflect the current active category. * to reflect the current active category.
*/ */
private updateCategoryHighlights(): void { private updateCategoryHighlights(): void {
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, 0.9) this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, 0.9) this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, this.uiOpacity)
} }
/** /**
@@ -1250,12 +1268,13 @@ export class UIScene extends Phaser.Scene {
this.actionTrayVisible = true this.actionTrayVisible = true
this.actionTrayGroup.destroy(true) this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group() this.actionTrayGroup = this.add.group()
this.actionBarBg.setAlpha(0)
const { width, height } = this.scale const { width, height } = this.scale
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H, 0x0d0d0d, 0.88) const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H + UIScene.BAR_H, 0x080808, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300) .setOrigin(0, 0).setScrollFactor(0).setDepth(299)
this.actionTrayGroup.add(bg) this.actionTrayGroup.add(bg)
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [ const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
@@ -1270,10 +1289,10 @@ export class UIScene extends Phaser.Scene {
const itemW = 84 const itemW = 84
buildings.forEach((b, i) => { buildings.forEach((b, i) => {
const bx = 8 + i * (itemW + 4) const bx = 8 + i * (itemW + 4)
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, 0.9) const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive() .setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, 0.9)) btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, this.uiOpacity))
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, 0.9)) btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, this.uiOpacity))
btn.on('pointerdown', () => { btn.on('pointerdown', () => {
this.closeActionTray() this.closeActionTray()
this.deactivateCategory() this.deactivateCategory()
@@ -1300,6 +1319,7 @@ export class UIScene extends Phaser.Scene {
this.actionTrayVisible = false this.actionTrayVisible = false
this.actionTrayGroup.destroy(true) this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group() this.actionTrayGroup = this.add.group()
this.actionBarBg.setAlpha(1)
} }
// ─── Resize ─────────────────────────────────────────────────────────────── // ─── Resize ───────────────────────────────────────────────────────────────

View File

@@ -32,7 +32,7 @@ export class BuildingSystem {
create(): void { create(): void {
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35) this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
this.ghost.setDepth(20) this.ghost.setDepth(1000)
this.ghost.setVisible(false) this.ghost.setVisible(false)
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8) this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
@@ -40,7 +40,7 @@ export class BuildingSystem {
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace', fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#000000aa', padding: { x: 3, y: 2 } backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
}) })
this.ghostLabel.setDepth(21) this.ghostLabel.setDepth(1001)
this.ghostLabel.setVisible(false) this.ghostLabel.setVisible(false)
this.ghostLabel.setOrigin(0.5, 1) this.ghostLabel.setOrigin(0.5, 1)

View File

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

View File

@@ -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 })

View File

@@ -47,10 +47,10 @@ export class ResourceSystem {
sprite.setOrigin(0.5, 0.75) sprite.setOrigin(0.5, 0.75)
} }
sprite.setDepth(5) sprite.setDepth(node.tileY + 5)
const healthBar = this.scene.add.graphics() const healthBar = this.scene.add.graphics()
healthBar.setDepth(6) healthBar.setDepth(node.tileY + 6)
healthBar.setVisible(false) healthBar.setVisible(false)
this.sprites.set(node.id, { sprite, node, healthBar }) this.sprites.set(node.id, { sprite, node, healthBar })

View File

@@ -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]
@@ -91,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,
} }
@@ -110,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)
} }

View File

@@ -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
@@ -118,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: '🌾', forester: '🌲', '': '' } 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)
} }
@@ -376,20 +379,27 @@ 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) {
// Build the set of all tiles belonging to forester zones for chop priority // Build the set of all tiles belonging to forester zones for chop priority
const zoneTiles = new Set<string>() const zoneTiles = new Set<string>()
for (const zone of Object.values(state.world.foresterZones)) { for (const zone of zones) {
for (const key of zone.tiles) zoneTiles.add(key) for (const key of zone.tiles) zoneTiles.add(key)
} }
const zoneChop: C[] = [] const zoneChop: C[] = []
const naturalChop: C[] = [] const naturalChop: C[] = []
for (const res of Object.values(state.world.resources)) { 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
// Skip trees with no reachable neighbour — A* cannot reach them. // Skip trees with no reachable neighbour — A* cannot reach them.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
@@ -405,7 +415,7 @@ export class VillagerSystem {
} }
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. // Same reachability guard for rock tiles.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
@@ -414,7 +424,7 @@ export class VillagerSystem {
} }
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 })
} }
@@ -422,7 +432,7 @@ export class VillagerSystem {
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) { if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
// Find empty plantable zone tiles to seed // Find empty plantable zone tiles to seed
for (const zone of Object.values(state.world.foresterZones)) { for (const zone of zones) {
for (const key of zone.tiles) { for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number) const [tx, ty] = key.split(',').map(Number)
const targetId = `forester_tile_${tx}_${ty}` const targetId = `forester_tile_${tx}_${ty}`
@@ -430,12 +440,12 @@ export class VillagerSystem {
// Skip if tile is not plantable // Skip if tile is not plantable
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
if (!PLANTABLE_TILES.has(tileType)) continue if (!PLANTABLE_TILES.has(tileType)) continue
// Skip if something occupies this tile // Skip if something occupies this tile — reuse already-extracted arrays
const occupied = const occupied =
Object.values(state.world.resources).some(r => r.tileX === tx && r.tileY === ty) || resources.some(r => r.tileX === tx && r.tileY === ty) ||
Object.values(state.world.buildings).some(b => b.tileX === tx && b.tileY === ty) || buildings.some(b => b.tileX === tx && b.tileY === ty) ||
Object.values(state.world.crops).some(c => c.tileX === tx && c.tileY === ty) || crops.some(c => c.tileX === tx && c.tileY === ty) ||
Object.values(state.world.treeSeedlings).some(s => s.tileX === tx && s.tileY === ty) seedlings.some(s => s.tileX === tx && s.tileY === ty)
if (occupied) continue if (occupied) continue
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester }) candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
} }
@@ -444,8 +454,9 @@ export class VillagerSystem {
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
@@ -569,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))
@@ -667,6 +685,14 @@ export class VillagerSystem {
return result return result
} }
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/** /**
* Destroys all Nisse sprites and clears the runtime map. * Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down. * Should be called when the scene shuts down.

View File

@@ -172,6 +172,16 @@ export class WorldSystem {
this.resourceTiles.delete(tileY * WORLD_TILES + tileX) this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
} }
/**
* Returns true if a resource (tree or rock) occupies the given tile.
* Uses the O(1) resourceTiles index.
* @param tileX - Tile column
* @param tileY - Tile row
*/
hasResourceAt(tileX: number, tileY: number): boolean {
return this.resourceTiles.has(tileY * WORLD_TILES + tileX)
}
/** /**
* Converts world pixel coordinates to tile coordinates. * Converts world pixel coordinates to tile coordinates.
* @param worldX - World X in pixels * @param worldX - World X in pixels

View File

@@ -90,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
} }
@@ -107,8 +111,8 @@ 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
} }
@@ -125,6 +129,8 @@ export interface ForesterZoneState {
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>
@@ -134,8 +140,8 @@ 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. */ /** Forester zone definitions, keyed by forester_hut building ID. */