From 3b021127a47c5ced4b39f5d389e03c598dd5b396 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Tue, 24 Mar 2026 08:08:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A1=20replace=20polling=20timers=20wi?= =?UTF-8?q?th=20sorted=20event=20queues=20+=20action=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/NetworkAdapter.ts | 30 ++++ src/StateManager.ts | 280 +++++++++++++++++++++++------- src/scenes/GameScene.ts | 9 +- src/scenes/UIScene.ts | 3 + src/systems/DebugSystem.ts | 9 +- src/systems/FarmingSystem.ts | 8 +- src/systems/TreeSeedlingSystem.ts | 5 +- src/types.ts | 16 +- 8 files changed, 279 insertions(+), 81 deletions(-) diff --git a/src/NetworkAdapter.ts b/src/NetworkAdapter.ts index e1f376e..5a2265a 100644 --- a/src/NetworkAdapter.ts +++ b/src/NetworkAdapter.ts @@ -8,12 +8,42 @@ export interface NetworkAdapter { onAction?: (action: GameAction) => void } +const ACTION_LOG_SIZE = 15 + /** Singleplayer: apply actions immediately and synchronously */ export class LocalAdapter implements NetworkAdapter { onAction?: (action: GameAction) => void + /** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */ + private _actionLog: string[] = [] + send(action: GameAction): void { stateManager.apply(action) + this._recordAction(action) this.onAction?.(action) } + + /** Returns a copy of the recent action log (oldest first). */ + getActionLog(): readonly string[] { return this._actionLog } + + /** + * Appends a short summary of the action to the ring-buffer. + * @param action - The dispatched game action + */ + private _recordAction(action: GameAction): void { + let entry = action.type + if ('tileX' in action && 'tileY' in action) + entry += ` (${(action as any).tileX},${(action as any).tileY})` + else if ('villagerId' in action) + entry += ` v=…${(action as any).villagerId.slice(-4)}` + else if ('resourceId' in action) + entry += ` r=…${(action as any).resourceId.slice(-4)}` + else if ('cropId' in action) + entry += ` c=…${(action as any).cropId.slice(-4)}` + else if ('seedlingId' in action) + entry += ` s=…${(action as any).seedlingId.slice(-4)}` + + if (this._actionLog.length >= ACTION_LOG_SIZE) this._actionLog.shift() + this._actionLog.push(entry) + } } diff --git a/src/StateManager.ts b/src/StateManager.ts index 263d263..3c0d571 100644 --- a/src/StateManager.ts +++ b/src/StateManager.ts @@ -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 { TileType } from './types' +// ─── Internal queue entry types ─────────────────────────────────────────────── + +/** Scheduled crop-growth entry. Two entries are created per stage (normal + watered path). */ +interface CropEntry { + id: string + fireAt: number + expectedStage: number + /** If true this entry only fires when crop.watered === true. */ + wateredPath: boolean +} + +/** Scheduled seedling-growth entry. One entry per stage. */ +interface SeedlingEntry { + id: string + fireAt: number + expectedStage: number +} + +/** Scheduled tile-recovery entry. One entry per tile. */ +interface RecoveryEntry { + key: string + fireAt: number +} + +// ─── State factories ─────────────────────────────────────────────────────────── + const DEFAULT_PLAYER: PlayerState = { id: 'player1', x: 8192, y: 8192, - inventory: {}, // empty — seeds now in stockpile + inventory: {}, } function makeEmptyWorld(seed: number): WorldState { return { seed, + gameTime: 0, tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3), resources: {}, buildings: {}, @@ -25,21 +52,164 @@ function makeEmptyWorld(seed: number): WorldState { function makeDefaultState(): GameStateData { return { - version: 5, + version: 6, world: makeEmptyWorld(Math.floor(Math.random() * 999999)), player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, } } +// ─── StateManager ───────────────────────────────────────────────────────────── + class StateManager { private state: GameStateData + // In-memory event queues (not persisted; rebuilt from state on load). + private cropQueue: CropEntry[] = [] + private seedlingQueue: SeedlingEntry[] = [] + private recoveryQueue: RecoveryEntry[] = [] + constructor() { this.state = this.load() ?? makeDefaultState() + this.rebuildQueues() } getState(): Readonly { 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(queue: T[], entry: T): void { + let lo = 0, hi = queue.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (queue[mid].fireAt <= entry.fireAt) lo = mid + 1 + else hi = mid + } + queue.splice(lo, 0, entry) + } + + /** Enqueues both growth entries (normal + watered path) for a crop's current stage. */ + private enqueueCropStage(id: string, expectedStage: number, growsAt: number, growsAtWatered: number): void { + StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAt, expectedStage, wateredPath: false }) + StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAtWatered, expectedStage, wateredPath: true }) + } + + /** + * Rebuilds all three event queues from the persisted state. + * Called once after construction or load. + */ + private rebuildQueues(): void { + this.cropQueue = [] + this.seedlingQueue = [] + this.recoveryQueue = [] + + for (const crop of Object.values(this.state.world.crops)) { + if (crop.stage >= crop.maxStage) continue + this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered) + } + + for (const s of Object.values(this.state.world.treeSeedlings)) { + if (s.stage < 2) { + StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage }) + } + } + + for (const [key, fireAt] of Object.entries(this.state.world.tileRecovery)) { + StateManager.insertSorted(this.recoveryQueue, { key, fireAt }) + } + } + + // ─── Tick methods ────────────────────────────────────────────────────────── + + /** + * Drains the crop queue up to the current gameTime. + * Returns IDs of crops that advanced a stage this frame. + */ + tickCrops(): string[] { + const now = this.state.world.gameTime + const advanced: string[] = [] + + while (this.cropQueue.length > 0 && this.cropQueue[0].fireAt <= now) { + const entry = this.cropQueue.shift()! + const crop = this.state.world.crops[entry.id] + if (!crop || crop.stage !== entry.expectedStage) continue // already removed or stale stage + if (entry.wateredPath && !crop.watered) continue // fast-path skipped: not watered + + crop.stage++ + advanced.push(crop.id) + + if (crop.stage < crop.maxStage) { + const cfg = CROP_CONFIGS[crop.kind] + crop.growsAt = now + cfg.stageTimeMs + crop.growsAtWatered = now + cfg.stageTimeMs / 2 + this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered) + } + } + return advanced + } + + /** + * Drains the seedling queue up to the current gameTime. + * Returns IDs of seedlings that advanced a stage this frame. + */ + tickSeedlings(): string[] { + const now = this.state.world.gameTime + const advanced: string[] = [] + + while (this.seedlingQueue.length > 0 && this.seedlingQueue[0].fireAt <= now) { + const entry = this.seedlingQueue.shift()! + const s = this.state.world.treeSeedlings[entry.id] + if (!s || s.stage !== entry.expectedStage) continue // removed or stale + + s.stage = Math.min(s.stage + 1, 2) + advanced.push(s.id) + + if (s.stage < 2) { + s.growsAt = now + TREE_SEEDLING_STAGE_MS + StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage }) + } + } + return advanced + } + + /** + * Drains the tile-recovery queue up to the current gameTime. + * Returns keys ("tileX,tileY") of tiles that have reverted to GRASS. + */ + tickTileRecovery(): string[] { + const now = this.state.world.gameTime + const recovered: string[] = [] + + while (this.recoveryQueue.length > 0 && this.recoveryQueue[0].fireAt <= now) { + const entry = this.recoveryQueue.shift()! + const fireAt = this.state.world.tileRecovery[entry.key] + // Skip if the entry was superseded (tile re-planted, resetting its fireAt) + if (fireAt === undefined || fireAt > now) continue + delete this.state.world.tileRecovery[entry.key] + recovered.push(entry.key) + const [tx, ty] = entry.key.split(',').map(Number) + this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS + } + return recovered + } + + // ─── State mutations ─────────────────────────────────────────────────────── + apply(action: GameAction): void { const s = this.state const w = s.world @@ -66,7 +236,6 @@ class StateManager { w.buildings[action.building.id] = action.building for (const [k, v] of Object.entries(action.costs)) w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0)) - // Automatically create an empty forester zone when a forester hut is placed if (action.building.kind === 'forester_hut') { w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] } } @@ -74,7 +243,6 @@ class StateManager { } case 'REMOVE_BUILDING': - // Remove associated forester zone when the hut is demolished if (w.buildings[action.buildingId]?.kind === 'forester_hut') { delete w.foresterZones[action.buildingId] } @@ -90,22 +258,24 @@ class StateManager { w.crops[action.crop.id] = { ...action.crop } const have = w.stockpile[action.seedItem] ?? 0 w.stockpile[action.seedItem] = Math.max(0, have - 1) + // Enqueue growth timers for both normal and watered paths + this.enqueueCropStage(action.crop.id, 0, action.crop.growsAt, action.crop.growsAtWatered) break } case 'WATER_CROP': { const c = w.crops[action.cropId]; if (c) c.watered = true; break + // No queue change needed — the wateredPath entry was enqueued at planting time } case 'HARVEST_CROP': { delete w.crops[action.cropId] for (const [k, v] of Object.entries(action.rewards)) w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) + // Stale queue entries will be skipped automatically (crop no longer exists) break } - // ── Villager actions ────────────────────────────────────────────────── - case 'SPAWN_VILLAGER': w.villagers[action.villager.id] = { ...action.villager }; break @@ -163,22 +333,30 @@ class StateManager { case 'PLANT_TREE_SEED': { w.treeSeedlings[action.seedling.id] = { ...action.seedling } w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1) - // Cancel any tile recovery on this tile delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`] + // Enqueue growth timer + StateManager.insertSorted(this.seedlingQueue, { + id: action.seedling.id, fireAt: action.seedling.growsAt, expectedStage: 0 + }) break } case 'REMOVE_TREE_SEEDLING': delete w.treeSeedlings[action.seedlingId] + // Stale queue entries will be skipped automatically break case 'SPAWN_RESOURCE': w.resources[action.resource.id] = { ...action.resource } break - case 'TILE_RECOVERY_START': - w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS + case 'TILE_RECOVERY_START': { + const fireAt = w.gameTime + TILE_RECOVERY_MS + const key = `${action.tileX},${action.tileY}` + w.tileRecovery[key] = fireAt + StateManager.insertSorted(this.recoveryQueue, { key, fireAt }) break + } case 'FORESTER_ZONE_UPDATE': { const zone = w.foresterZones[action.buildingId] @@ -188,60 +366,7 @@ class StateManager { } } - 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 - } - - /** - * 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 - } + // ─── Persistence ─────────────────────────────────────────────────────────── save(): void { try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} @@ -252,20 +377,40 @@ class StateManager { const raw = localStorage.getItem(SAVE_KEY) if (!raw) return null 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.villagers) p.world.villagers = {} if (!p.world.stockpile) p.world.stockpile = {} if (!p.world.treeSeedlings) p.world.treeSeedlings = {} if (!p.world.tileRecovery) p.world.tileRecovery = {} 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)) { 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 } - // Rebuild forester zones for huts that predate the foresterZones field 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: [] } @@ -278,6 +423,7 @@ class StateManager { reset(): void { localStorage.removeItem(SAVE_KEY) this.state = makeDefaultState() + this.rebuildQueues() } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 7af88ba..9a4bd1e 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -46,7 +46,7 @@ export class GameScene extends Phaser.Scene { this.villagerSystem.init(this.resourceSystem, this.farmingSystem) this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem) this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter) - this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) + this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem, this.adapter) this.worldSystem.create() this.renderPersistentObjects() @@ -145,6 +145,9 @@ export class GameScene extends Phaser.Scene { update(_time: number, delta: number): void { if (this.menuOpen) return + // Advance the in-game clock first so all tick methods see the updated time + stateManager.advanceTime(delta) + this.cameraSystem.update(delta) this.resourceSystem.update(delta) @@ -153,8 +156,8 @@ export class GameScene extends Phaser.Scene { this.villagerSystem.update(delta) this.debugSystem.update() - // Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS - const recovered = stateManager.tickTileRecovery(delta) + // Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS + const recovered = stateManager.tickTileRecovery() for (const key of recovered) { const [tx, ty] = key.split(',').map(Number) this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS) diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index c03db28..770bf89 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -463,6 +463,9 @@ export class UIScene extends Phaser.Scene { '', `Paths: ${data.activePaths} (cyan lines in world)`, '', + '── Last Actions ───────────────', + ...(data.actionLog.length > 0 ? data.actionLog : ['—']), + '', '[F3] close', ]) } diff --git a/src/systems/DebugSystem.ts b/src/systems/DebugSystem.ts index 9db6e7a..a2e98b6 100644 --- a/src/systems/DebugSystem.ts +++ b/src/systems/DebugSystem.ts @@ -2,6 +2,7 @@ import Phaser from 'phaser' import { TILE_SIZE } from '../config' import { TileType } from '../types' import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' import type { VillagerSystem } from './VillagerSystem' import type { WorldSystem } from './WorldSystem' @@ -18,6 +19,8 @@ export interface DebugData { nisseByState: { idle: number; walking: number; working: number; sleeping: number } jobsByType: { chop: number; mine: number; farm: number } activePaths: number + /** Recent actions dispatched through the adapter (newest last). */ + actionLog: readonly string[] } /** Human-readable names for TileType enum values. */ @@ -39,6 +42,7 @@ export class DebugSystem { private scene: Phaser.Scene private villagerSystem: VillagerSystem private worldSystem: WorldSystem + private adapter: LocalAdapter private pathGraphics!: Phaser.GameObjects.Graphics private active = false @@ -46,11 +50,13 @@ export class DebugSystem { * @param scene - The Phaser scene this system belongs to * @param villagerSystem - Used to read active paths for visualization * @param worldSystem - Used to read tile types under the mouse + * @param adapter - Used to read the recent action log */ - constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) { + constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem, adapter: LocalAdapter) { this.scene = scene this.villagerSystem = villagerSystem this.worldSystem = worldSystem + this.adapter = adapter } /** @@ -159,6 +165,7 @@ export class DebugSystem { nisseByState, jobsByType, activePaths: this.villagerSystem.getActivePaths().length, + actionLog: this.adapter.getActionLog(), } } } diff --git a/src/systems/FarmingSystem.ts b/src/systems/FarmingSystem.ts index fa29375..fe481c5 100644 --- a/src/systems/FarmingSystem.ts +++ b/src/systems/FarmingSystem.ts @@ -74,8 +74,8 @@ export class FarmingSystem { this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length]) } - // Tick crop growth - const leveled = stateManager.tickCrops(delta) + // Drain crop growth queue (no delta — gameTime is advanced by GameScene) + const leveled = stateManager.tickCrops() for (const id of leveled) this.refreshCropSprite(id) } @@ -151,11 +151,13 @@ export class FarmingSystem { } const cfg = CROP_CONFIGS[kind] + const now = stateManager.getGameTime() const crop: CropState = { id: `crop_${tileX}_${tileY}_${Date.now()}`, tileX, tileY, kind, stage: 0, maxStage: cfg.stages, - stageTimerMs: cfg.stageTimeMs, + growsAt: now + cfg.stageTimeMs, + growsAtWatered: now + cfg.stageTimeMs / 2, watered: tile === TileType.WATERED_SOIL, } this.adapter.send({ type: 'PLANT_CROP', crop, seedItem }) diff --git a/src/systems/TreeSeedlingSystem.ts b/src/systems/TreeSeedlingSystem.ts index 9c42121..f6a8ffb 100644 --- a/src/systems/TreeSeedlingSystem.ts +++ b/src/systems/TreeSeedlingSystem.ts @@ -38,7 +38,8 @@ export class TreeSeedlingSystem { * @param delta - Frame delta in milliseconds */ 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) { const state = stateManager.getState() const seedling = state.world.treeSeedlings[id] @@ -91,7 +92,7 @@ export class TreeSeedlingSystem { const seedling: TreeSeedlingState = { id, tileX, tileY, stage: 0, - stageTimerMs: TREE_SEEDLING_STAGE_MS, + growsAt: stateManager.getGameTime() + TREE_SEEDLING_STAGE_MS, underlyingTile, } diff --git a/src/types.ts b/src/types.ts index bb6cc69..fe7b134 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,7 +90,11 @@ export interface CropState { kind: CropKind stage: number maxStage: number - stageTimerMs: number + /** gameTime (ms) when this stage fires at normal (unwatered) speed. */ + growsAt: number + /** gameTime (ms) when this stage fires if the crop is watered (half normal time). + * Both entries are enqueued at plant/stage-advance time; the stale one is skipped. */ + growsAtWatered: number watered: boolean } @@ -107,8 +111,8 @@ export interface TreeSeedlingState { tileY: number /** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */ stage: number - /** Time remaining until next stage advance, in milliseconds. */ - stageTimerMs: number + /** gameTime (ms) when this seedling advances to the next stage. */ + growsAt: number /** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */ underlyingTile: TileType } @@ -125,6 +129,8 @@ export interface ForesterZoneState { export interface WorldState { seed: number + /** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */ + gameTime: number tiles: number[] resources: Record buildings: Record @@ -134,8 +140,8 @@ export interface WorldState { /** Planted tree seedlings, keyed by ID. */ treeSeedlings: Record /** - * Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY". - * Value is remaining milliseconds until the tile reverts to GRASS. + * Tile recovery fire-times, keyed by "tileX,tileY". + * Value is the gameTime (ms) at which the tile reverts to GRASS. */ tileRecovery: Record /** Forester zone definitions, keyed by forester_hut building ID. */ -- 2.49.1 From 20858a1be13e4791f8af1c9761bd10ce26a46222 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Tue, 24 Mar 2026 08:08:18 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20update=20changelog=20for=20e?= =?UTF-8?q?vent-queue=20and=20action=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091fe50..86a7b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [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 - **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 -- 2.49.1