diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index d050670..98e933a 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -78,8 +78,10 @@ export class GameScene extends Phaser.Scene { this.adapter.onAction = (action) => { if (action.type === 'CHANGE_TILE') { this.worldSystem.setTile(action.tileX, action.tileY, action.tile) + this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile) } else if (action.type === 'SPAWN_RESOURCE') { this.resourceSystem.spawnResourcePublic(action.resource) + this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY) } } diff --git a/src/systems/TreeSeedlingSystem.ts b/src/systems/TreeSeedlingSystem.ts index 6d91144..5167293 100644 --- a/src/systems/TreeSeedlingSystem.ts +++ b/src/systems/TreeSeedlingSystem.ts @@ -50,7 +50,6 @@ export class TreeSeedlingSystem { 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({ diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index e1776be..906b14b 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -274,10 +274,9 @@ export class VillagerSystem { const res = state.world.resources[job.targetId] if (res) { 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.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Chopped tree (+2 wood)') } @@ -285,8 +284,8 @@ export class VillagerSystem { const res = state.world.resources[job.targetId] if (res) { this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) - // Clear the ROCK tile so the area becomes passable for future pathfinding this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS }) + this.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Mined rock (+2 stone)') } @@ -351,12 +350,17 @@ export class VillagerSystem { if (p.chop > 0) { for (const res of Object.values(state.world.resources)) { if (res.kind !== 'tree' || this.claimed.has(res.id)) continue + // Skip trees with no reachable neighbour — A* cannot enter an impassable goal + // tile unless at least one passable neighbour exists to jump from. + if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }) } } if (p.mine > 0) { for (const res of Object.values(state.world.resources)) { if (res.kind !== 'rock' || this.claimed.has(res.id)) continue + // Same reachability guard for rock tiles. + if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) } } @@ -397,7 +401,7 @@ export class VillagerSystem { this.claimed.delete(v.job.targetId) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) } - rt.idleScanTimer = 1500 // longer delay after failed pathfind + rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops return } @@ -434,6 +438,22 @@ export class VillagerSystem { return this.nearestBuilding(v, 'bed') as any } + /** + * Returns true if at least one of the 8 neighbours of the given tile is passable. + * Used to pre-filter job targets that are fully enclosed by impassable terrain — + * such as trees deep inside a dense forest cluster where A* can never reach the goal + * tile because no passable tile is adjacent to it. + * @param tileX - Target tile X + * @param tileY - Target tile Y + */ + private hasAdjacentPassable(tileX: number, tileY: number): boolean { + const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const + for (const [dx, dy] of DIRS) { + if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true + } + return false + } + // ─── Spawning ───────────────────────────────────────────────────────────── /** diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts index 76608e5..6c33d1b 100644 --- a/src/systems/WorldSystem.ts +++ b/src/systems/WorldSystem.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser' import { TILE_SIZE, WORLD_TILES } from '../config' -import { TileType, IMPASSABLE } from '../types' +import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types' import { stateManager } from '../StateManager' const BIOME_COLORS: Record = { @@ -18,6 +18,12 @@ const BIOME_COLORS: Record = { export class WorldSystem { private scene: Phaser.Scene private map!: Phaser.Tilemaps.Tilemap + /** + * Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile + * that is currently occupied by a tree or rock resource. + * Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked. + */ + private resourceTiles = new Set() private tileset!: Phaser.Tilemaps.Tileset private bgImage!: Phaser.GameObjects.Image private builtLayer!: Phaser.Tilemaps.TilemapLayer @@ -85,6 +91,8 @@ export class WorldSystem { // Camera bounds this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) + + this.initResourceTiles() } /** Returns the built-tile tilemap layer (floor, wall, soil). */ @@ -111,6 +119,10 @@ export class WorldSystem { /** * Returns whether the tile at the given coordinates can be walked on. + * Water and wall tiles are always impassable. + * Forest and rock terrain tiles are only impassable when a resource + * (tree or rock) currently occupies them — empty forest floor and bare + * rocky ground are walkable. * Out-of-bounds tiles are treated as impassable. * @param tileX - Tile column * @param tileY - Tile row @@ -119,7 +131,45 @@ export class WorldSystem { if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false const state = stateManager.getState() const tile = state.world.tiles[tileY * WORLD_TILES + tileX] - return !IMPASSABLE.has(tile) + if (IMPASSABLE.has(tile)) return false + if (RESOURCE_TERRAIN.has(tile)) { + return !this.resourceTiles.has(tileY * WORLD_TILES + tileX) + } + return true + } + + /** + * Builds the resource tile index from the current world state. + * Called once in create() so that isPassable() has an O(1) lookup. + */ + private initResourceTiles(): void { + this.resourceTiles.clear() + const state = stateManager.getState() + for (const res of Object.values(state.world.resources)) { + this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX) + } + } + + /** + * Registers a newly placed resource so isPassable() treats the tile as blocked. + * Call this whenever a resource is added at runtime (e.g. a seedling matures). + * @param tileX - Resource tile column + * @param tileY - Resource tile row + */ + addResourceTile(tileX: number, tileY: number): void { + this.resourceTiles.add(tileY * WORLD_TILES + tileX) + } + + /** + * Removes a resource from the tile index so isPassable() treats the tile as free. + * Call this when a resource is removed at runtime (e.g. after chopping/mining). + * Not strictly required when the tile type also changes (FOREST → DARK_GRASS), + * but keeps the index clean for correctness. + * @param tileX - Resource tile column + * @param tileY - Resource tile row + */ + removeResourceTile(tileX: number, tileY: number): void { + this.resourceTiles.delete(tileY * WORLD_TILES + tileX) } /** diff --git a/src/types.ts b/src/types.ts index 83cf68c..7e37beb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,14 +12,21 @@ export enum TileType { WATERED_SOIL = 10, } +/** Tiles that are always impassable regardless of what is on them. */ export const IMPASSABLE = new Set([ TileType.DEEP_WATER, TileType.SHALLOW_WATER, - TileType.FOREST, - TileType.ROCK, TileType.WALL, ]) +/** + * Terrain tiles whose passability depends on whether a resource + * (tree or rock) is currently placed on them. + * An empty FOREST tile is walkable forest floor; a ROCK tile without a + * rock resource is just rocky ground. + */ +export const RESOURCE_TERRAIN = new Set([TileType.FOREST, TileType.ROCK]) + /** Tiles on which tree seedlings may be planted. */ export const PLANTABLE_TILES = new Set([TileType.GRASS, TileType.DARK_GRASS])