diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bbe3c..1be15c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- **Unified Tile System** (Issue #14): + - Tree seedlings: player plants `tree_seed` on grass/dark-grass via the F-key farming tool; seedling grows through two stages (sprout → sapling → young tree, ~1 min each); on maturity it becomes a FOREST tile with a harvestable tree resource + - Tile recovery: when a Nisse chops a tree, the resulting DARK_GRASS tile starts a 5-minute recovery timer and reverts to GRASS automatically, with the terrain canvas updated in real time + - Three new procedural seedling textures (`seedling_0/1/2`) generated in BootScene + - `tree_seed` added to stockpile display (default 5 at game start) and to the farming tool cycle + - `WorldSystem.refreshTerrainTile()` updates the terrain canvas for a single tile without regenerating the full background + - New `TreeSeedlingSystem` manages seedling sprites, growth ticking, and maturation + ### Added - **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse - Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile diff --git a/src/StateManager.ts b/src/StateManager.ts index f1fc7e6..3c854d6 100644 --- a/src/StateManager.ts +++ b/src/StateManager.ts @@ -1,5 +1,6 @@ -import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config' +import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config' import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types' +import { TileType } from './types' const DEFAULT_PLAYER: PlayerState = { id: 'player1', @@ -15,13 +16,15 @@ function makeEmptyWorld(seed: number): WorldState { buildings: {}, crops: {}, villagers: {}, - stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 }, + stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 }, + treeSeedlings: {}, + tileRecovery: {}, } } function makeDefaultState(): GameStateData { return { - version: 4, + version: 5, world: makeEmptyWorld(Math.floor(Math.random() * 999999)), player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, } @@ -146,6 +149,26 @@ class StateManager { if (v) v.priorities = { ...action.priorities } break } + + case 'PLANT_TREE_SEED': { + w.treeSeedlings[action.seedling.id] = { ...action.seedling } + w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1) + // Cancel any tile recovery on this tile + delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`] + break + } + + case 'REMOVE_TREE_SEEDLING': + delete w.treeSeedlings[action.seedlingId] + break + + case 'SPAWN_RESOURCE': + w.resources[action.resource.id] = { ...action.resource } + break + + case 'TILE_RECOVERY_START': + w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS + break } } @@ -163,6 +186,47 @@ class StateManager { return advanced } + /** + * 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 { try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} } @@ -172,10 +236,12 @@ class StateManager { const raw = localStorage.getItem(SAVE_KEY) if (!raw) return null const p = JSON.parse(raw) as GameStateData - if (p.version !== 4) return null - if (!p.world.crops) p.world.crops = {} - if (!p.world.villagers) p.world.villagers = {} - if (!p.world.stockpile) p.world.stockpile = {} + if (p.version !== 5) 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 = {} // Reset in-flight AI states to idle on load so runtime timers start fresh for (const v of Object.values(p.world.villagers)) { if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle' diff --git a/src/config.ts b/src/config.ts index d37c30d..0cb0ee0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,5 +46,11 @@ export const VILLAGER_NAMES = [ 'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex', ] -export const SAVE_KEY = 'tg_save_v4' +export const SAVE_KEY = 'tg_save_v5' export const AUTOSAVE_INTERVAL = 30_000 + +/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */ +export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total + +/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */ +export const TILE_RECOVERY_MS = 300_000 // 5 minutes diff --git a/src/scenes/BootScene.ts b/src/scenes/BootScene.ts index f3496a6..8f85436 100644 --- a/src/scenes/BootScene.ts +++ b/src/scenes/BootScene.ts @@ -20,6 +20,7 @@ export class BootScene extends Phaser.Scene { this.buildResourceTextures() this.buildPlayerTexture() this.buildCropTextures() + this.buildSeedlingTextures() this.buildUITextures() this.buildVillagerAndBuildingTextures() this.generateWorldIfNeeded() @@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene { g3.generateTexture('crop_carrot_3', W, H); g3.destroy() } + // ─── Tree seedling textures (3 growth stages) ──────────────────────────── + + /** + * Generates textures for the three tree-seedling growth stages: + * seedling_0 – small sprout + * seedling_1 – sapling with leaves + * seedling_2 – young tree (about to mature into a FOREST tile) + */ + private buildSeedlingTextures(): void { + // Stage 0: tiny sprout + const g0 = this.add.graphics() + g0.fillStyle(0x6D4C41); g0.fillRect(10, 20, 4, 10) + g0.fillStyle(0x66BB6A); g0.fillEllipse(12, 16, 12, 8) + g0.fillStyle(0x4CAF50); g0.fillEllipse(12, 13, 8, 6) + g0.generateTexture('seedling_0', 24, 32); g0.destroy() + + // Stage 1: sapling + const g1 = this.add.graphics() + g1.fillStyle(0x6D4C41); g1.fillRect(9, 15, 5, 16) + g1.fillStyle(0x4CAF50); g1.fillCircle(12, 12, 8) + g1.fillStyle(0x66BB6A, 0.7); g1.fillCircle(7, 16, 5); g1.fillCircle(17, 16, 5) + g1.fillStyle(0x81C784); g1.fillCircle(12, 8, 5) + g1.generateTexture('seedling_1', 24, 32); g1.destroy() + + // Stage 2: young tree (mature, ready to become a resource) + const g2 = this.add.graphics() + g2.fillStyle(0x000000, 0.15); g2.fillEllipse(12, 28, 16, 6) + g2.fillStyle(0x6D4C41); g2.fillRect(9, 14, 6, 14) + g2.fillStyle(0x2E7D32); g2.fillCircle(12, 9, 10) + g2.fillStyle(0x388E3C); g2.fillCircle(7, 13, 7); g2.fillCircle(17, 13, 7) + g2.fillStyle(0x43A047); g2.fillCircle(12, 6, 7) + g2.generateTexture('seedling_2', 24, 32); g2.destroy() + } + // ─── UI panel texture ───────────────────────────────────────────────────── private buildUITextures(): void { diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index d0c2852..d050670 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser' import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config' +import { TileType } from '../types' import type { BuildingType } from '../types' import { stateManager } from '../StateManager' import { LocalAdapter } from '../NetworkAdapter' @@ -10,6 +11,7 @@ import { BuildingSystem } from '../systems/BuildingSystem' import { FarmingSystem } from '../systems/FarmingSystem' import { VillagerSystem } from '../systems/VillagerSystem' import { DebugSystem } from '../systems/DebugSystem' +import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem' export class GameScene extends Phaser.Scene { private adapter!: LocalAdapter @@ -20,6 +22,7 @@ export class GameScene extends Phaser.Scene { private farmingSystem!: FarmingSystem villagerSystem!: VillagerSystem debugSystem!: DebugSystem + private treeSeedlingSystem!: TreeSeedlingSystem private autosaveTimer = 0 private menuOpen = false @@ -37,9 +40,10 @@ export class GameScene extends Phaser.Scene { this.resourceSystem = new ResourceSystem(this, this.adapter) this.buildingSystem = new BuildingSystem(this, this.adapter) this.farmingSystem = new FarmingSystem(this, this.adapter) - this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) + this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) this.villagerSystem.init(this.resourceSystem, this.farmingSystem) - this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) + this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem) + this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem) this.worldSystem.create() this.renderPersistentObjects() @@ -57,8 +61,12 @@ export class GameScene extends Phaser.Scene { } this.farmingSystem.create() - this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg) - this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) + this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg) + this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) + this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) => + this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile) + + this.treeSeedlingSystem.create() this.villagerSystem.create() this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) @@ -70,6 +78,8 @@ export class GameScene extends Phaser.Scene { this.adapter.onAction = (action) => { if (action.type === 'CHANGE_TILE') { this.worldSystem.setTile(action.tileX, action.tileY, action.tile) + } else if (action.type === 'SPAWN_RESOURCE') { + this.resourceSystem.spawnResourcePublic(action.resource) } } @@ -102,9 +112,17 @@ export class GameScene extends Phaser.Scene { this.resourceSystem.update(delta) this.farmingSystem.update(delta) + this.treeSeedlingSystem.update(delta) this.villagerSystem.update(delta) this.debugSystem.update() + // Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS + const recovered = stateManager.tickTileRecovery(delta) + for (const key of recovered) { + const [tx, ty] = key.split(',').map(Number) + this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS) + } + this.events.emit('cameraMoved', this.cameraSystem.getCenterTile()) this.buildingSystem.update() @@ -144,6 +162,7 @@ export class GameScene extends Phaser.Scene { this.resourceSystem.destroy() this.buildingSystem.destroy() this.farmingSystem.destroy() + this.treeSeedlingSystem.destroy() this.villagerSystem.destroy() } } diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index 0974ab1..cabc6f3 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -6,7 +6,7 @@ import { stateManager } from '../StateManager' const ITEM_ICONS: Record = { wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕', - wheat: '🌾', carrot: '🧡', + wheat: '🌾', carrot: '🧡', tree_seed: '🌲', } export class UIScene extends Phaser.Scene { @@ -119,14 +119,14 @@ export class UIScene extends Phaser.Scene { /** Creates the stockpile panel in the top-right corner with item rows and population count. */ private createStockpilePanel(): void { const x = this.scale.width - 178, y = 10 - this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) + this.stockpilePanel = this.add.rectangle(x, y, 168, 187, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) - const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const + const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const items.forEach((item, i) => { const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) this.stockpileTexts.set(item, t) }) - this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) + this.popText = this.add.text(x + 10, y + 167, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) } /** Refreshes all item quantities and colors in the stockpile panel. */ diff --git a/src/systems/FarmingSystem.ts b/src/systems/FarmingSystem.ts index ea30a1a..fa29375 100644 --- a/src/systems/FarmingSystem.ts +++ b/src/systems/FarmingSystem.ts @@ -5,15 +5,16 @@ import type { CropKind, CropState, ItemId } from '../types' import { stateManager } from '../StateManager' import type { LocalAdapter } from '../NetworkAdapter' -export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water' +export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water' -const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water'] +const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water'] const TOOL_LABELS: Record = { none: '— None', hoe: '⛏ Hoe (till grass)', wheat_seed: '🌾 Wheat Seeds', carrot_seed: '🥕 Carrot Seeds', + tree_seed: '🌲 Tree Seeds (plant on grass)', water: '💧 Watering Can', } @@ -30,6 +31,14 @@ export class FarmingSystem { onToolChange?: (tool: FarmingTool, label: string) => void /** Emitted for toast notifications */ onMessage?: (msg: string) => void + /** + * Called when the player uses the tree_seed tool on a tile. + * @param tileX - Target tile column + * @param tileY - Target tile row + * @param underlyingTile - The tile type at that position + * @returns true if planting succeeded, false if validation failed + */ + onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean constructor(scene: Phaser.Scene, adapter: LocalAdapter) { this.scene = scene @@ -87,9 +96,27 @@ export class FarmingSystem { const state = stateManager.getState() const tile = state.world.tiles[tileY * 512 + tileX] as TileType - if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile) - else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile) - else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind) + if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile) + else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile) + else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile) + else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind) + } + + /** + * Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem). + * Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure. + * @param tileX - Target tile column + * @param tileY - Target tile row + * @param tile - Current tile type at that position + */ + private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void { + if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) { + this.onMessage?.('Plant tree seeds on grass!') + return + } + const ok = this.onPlantTreeSeed?.(tileX, tileY, tile) + if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!') + else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)') } private tillSoil(tileX: number, tileY: number, tile: TileType): void { diff --git a/src/systems/ResourceSystem.ts b/src/systems/ResourceSystem.ts index 067c9d6..d490726 100644 --- a/src/systems/ResourceSystem.ts +++ b/src/systems/ResourceSystem.ts @@ -76,6 +76,16 @@ export class ResourceSystem { this.removeSprite(id) } + /** + * Spawns a sprite for a resource that was created at runtime + * (e.g. a tree grown from a seedling). The resource must already be + * present in the game state when this is called. + * @param node - The resource node to render + */ + public spawnResourcePublic(node: ResourceNodeState): void { + this.spawnSprite(node) + } + /** Called when WorldSystem changes a tile (e.g. after tree removed) */ syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void { const state = stateManager.getState() diff --git a/src/systems/TreeSeedlingSystem.ts b/src/systems/TreeSeedlingSystem.ts new file mode 100644 index 0000000..6d91144 --- /dev/null +++ b/src/systems/TreeSeedlingSystem.ts @@ -0,0 +1,131 @@ +import Phaser from 'phaser' +import { TILE_SIZE, TREE_SEEDLING_STAGE_MS } from '../config' +import { TileType, PLANTABLE_TILES } from '../types' +import type { TreeSeedlingState } from '../types' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' +import type { WorldSystem } from './WorldSystem' + +export class TreeSeedlingSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private worldSystem: WorldSystem + private sprites = new Map() + + /** + * @param scene - The Phaser scene this system belongs to + * @param adapter - Network adapter for dispatching state actions + * @param worldSystem - Used to refresh the terrain canvas when a seedling matures + */ + constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) { + this.scene = scene + this.adapter = adapter + this.worldSystem = worldSystem + } + + /** Spawns sprites for all seedlings that exist in the saved state. */ + create(): void { + const state = stateManager.getState() + for (const s of Object.values(state.world.treeSeedlings)) { + this.spawnSprite(s) + } + } + + /** + * Ticks all seedling growth timers and handles stage changes. + * Stage 0→1: updates the sprite to the sapling texture. + * Stage 1→2: removes the seedling, spawns a tree resource, and updates the terrain canvas. + * @param delta - Frame delta in milliseconds + */ + update(delta: number): void { + const advanced = stateManager.tickSeedlings(delta) + for (const id of advanced) { + const state = stateManager.getState() + const seedling = state.world.treeSeedlings[id] + if (!seedling) continue + + if (seedling.stage === 2) { + // Fully mature: become a FOREST tile and a real tree resource + const { tileX, tileY } = seedling + this.removeSprite(id) + this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id }) + this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST }) + this.worldSystem.refreshTerrainTile(tileX, tileY, TileType.FOREST) + + const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}` + this.adapter.send({ + type: 'SPAWN_RESOURCE', + resource: { id: resourceId, tileX, tileY, kind: 'tree', hp: 3 }, + }) + } else { + // Stage 0→1: update sprite to sapling + const sprite = this.sprites.get(id) + if (sprite) sprite.setTexture(`seedling_${seedling.stage}`) + } + } + } + + /** + * Attempts to plant a tree seedling on a grass tile. + * Validates that the stockpile has at least one tree_seed, the tile type is + * plantable (GRASS or DARK_GRASS), and no other object occupies the tile. + * @param tileX - Target tile column + * @param tileY - Target tile row + * @param underlyingTile - The current tile type (stored on the seedling for later restoration) + * @returns true if the seedling was planted, false if validation failed + */ + plantSeedling(tileX: number, tileY: number, underlyingTile: TileType): boolean { + const state = stateManager.getState() + + if ((state.world.stockpile.tree_seed ?? 0) <= 0) return false + if (!PLANTABLE_TILES.has(underlyingTile)) return false + + const occupied = + Object.values(state.world.resources).some(r => r.tileX === tileX && r.tileY === tileY) || + Object.values(state.world.buildings).some(b => b.tileX === tileX && b.tileY === tileY) || + Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY) || + Object.values(state.world.treeSeedlings).some(s => s.tileX === tileX && s.tileY === tileY) + + if (occupied) return false + + const id = `seedling_${tileX}_${tileY}_${Date.now()}` + const seedling: TreeSeedlingState = { + id, tileX, tileY, + stage: 0, + stageTimerMs: TREE_SEEDLING_STAGE_MS, + underlyingTile, + } + + this.adapter.send({ type: 'PLANT_TREE_SEED', seedling }) + this.spawnSprite(seedling) + return true + } + + /** + * Creates and registers the sprite for a seedling. + * @param s - Seedling state to render + */ + private spawnSprite(s: TreeSeedlingState): void { + const x = (s.tileX + 0.5) * TILE_SIZE + const y = (s.tileY + 0.5) * TILE_SIZE + const key = `seedling_${Math.min(s.stage, 2)}` + const sprite = this.scene.add.image(x, y, key) + .setOrigin(0.5, 0.85) + .setDepth(5) + this.sprites.set(s.id, sprite) + } + + /** + * Destroys the sprite for a seedling and removes it from the registry. + * @param id - Seedling ID + */ + private removeSprite(id: string): void { + const s = this.sprites.get(id) + if (s) { s.destroy(); this.sprites.delete(id) } + } + + /** Destroys all seedling sprites and clears the registry. */ + destroy(): void { + for (const id of [...this.sprites.keys()]) this.removeSprite(id) + } +} diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 7b3f172..e1776be 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -276,6 +276,8 @@ export class VillagerSystem { 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 }) + // 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.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Chopped tree (+2 wood)') } diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts index faff74f..76608e5 100644 --- a/src/systems/WorldSystem.ts +++ b/src/systems/WorldSystem.ts @@ -21,6 +21,7 @@ export class WorldSystem { private tileset!: Phaser.Tilemaps.Tileset private bgImage!: Phaser.GameObjects.Image private builtLayer!: Phaser.Tilemaps.TilemapLayer + private bgCanvasTexture!: Phaser.Textures.CanvasTexture /** @param scene - The Phaser scene this system belongs to */ constructor(scene: Phaser.Scene) { @@ -35,10 +36,8 @@ export class WorldSystem { const state = stateManager.getState() // --- Canvas background (1px per tile, scaled up, LINEAR filtered) --- - const canvas = document.createElement('canvas') - canvas.width = WORLD_TILES - canvas.height = WORLD_TILES - const ctx = canvas.getContext('2d')! + const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture + const ctx = canvasTexture.context for (let y = 0; y < WORLD_TILES; y++) { for (let x = 0; x < WORLD_TILES; x++) { @@ -48,12 +47,14 @@ export class WorldSystem { } } - this.scene.textures.addCanvas('terrain_bg', canvas) + canvasTexture.refresh() + this.bgCanvasTexture = canvasTexture + this.bgImage = this.scene.add.image(0, 0, 'terrain_bg') .setOrigin(0, 0) .setScale(TILE_SIZE) .setDepth(0) - this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR) + canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR) // --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) --- this.map = this.scene.make.tilemap({ @@ -157,6 +158,21 @@ export class WorldSystem { return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType } + /** + * Updates a single tile's pixel on the background canvas and refreshes the GPU texture. + * Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery, + * or GRASS → FOREST when a seedling matures). + * @param tileX - Tile column + * @param tileY - Tile row + * @param type - New tile type to reflect visually + */ + refreshTerrainTile(tileX: number, tileY: number, type: TileType): void { + const color = BIOME_COLORS[type] ?? '#0a2210' + this.bgCanvasTexture.context.fillStyle = color + this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1) + this.bgCanvasTexture.refresh() + } + /** Destroys the tilemap and background image. */ destroy(): void { this.map.destroy() diff --git a/src/types.ts b/src/types.ts index b80f6bf..83cf68c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,10 @@ export const IMPASSABLE = new Set([ TileType.WALL, ]) -export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' +/** Tiles on which tree seedlings may be planted. */ +export const PLANTABLE_TILES = new Set([TileType.GRASS, TileType.DARK_GRASS]) + +export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed' export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' @@ -90,6 +93,18 @@ export interface PlayerState { inventory: Partial> } +export interface TreeSeedlingState { + id: string + tileX: number + tileY: number + /** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */ + stage: number + /** Time remaining until next stage advance, in milliseconds. */ + stageTimerMs: number + /** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */ + underlyingTile: TileType +} + export interface WorldState { seed: number tiles: number[] @@ -98,6 +113,13 @@ export interface WorldState { crops: Record villagers: Record stockpile: Partial> + /** 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. + */ + tileRecovery: Record } export interface GameStateData { @@ -123,3 +145,7 @@ export type GameAction = | { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string } | { type: 'VILLAGER_DEPOSIT'; villagerId: string } | { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities } + | { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState } + | { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string } + | { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState } + | { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }