♻️ resource-based passability: FOREST/ROCK walkable without a resource (Issue #22)

Previously FOREST and ROCK tile types were always impassable, making 30 % of
forest floor and 50 % of rocky terrain permanently blocked even with no object
on them.

- Remove FOREST + ROCK from IMPASSABLE in types.ts
- Add RESOURCE_TERRAIN set (FOREST, ROCK) for tiles that need resource check
- WorldSystem: add resourceTiles Set<number> as O(1) spatial index
  - initResourceTiles() builds index from state on create()
  - addResourceTile() / removeResourceTile() keep it in sync at runtime
- isPassable() now: impassable tiles → false | RESOURCE_TERRAIN → check index | else → true
- GameScene: call addResourceTile() when SPAWN_RESOURCE fires (seedling matures)
- VillagerSystem: call removeResourceTile() after chop / mine completes

Side effect: trees fully enclosed by other trees are now reachable once an
adjacent tree is cleared; the hasAdjacentPassable() guard in pickJob still
correctly skips resources with zero passable neighbours.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 11:55:24 +00:00
parent 774054db56
commit f2a1811a36
4 changed files with 64 additions and 7 deletions

View File

@@ -80,6 +80,7 @@ export class GameScene extends Phaser.Scene {
this.worldSystem.setTile(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)
}
}

View File

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

View File

@@ -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<number, string> = {
@@ -18,6 +18,12 @@ const BIOME_COLORS: Record<number, string> = {
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<number>()
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)
}
/**

View File

@@ -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<number>([
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<number>([TileType.FOREST, TileType.ROCK])
/** Tiles on which tree seedlings may be planted. */
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])