Merge pull request '🐛 Skip unreachable job targets in pickJob' (#23) from fix/unreachable-job-skip into master
This commit is contained in:
@@ -78,8 +78,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.adapter.onAction = (action) => {
|
this.adapter.onAction = (action) => {
|
||||||
if (action.type === 'CHANGE_TILE') {
|
if (action.type === 'CHANGE_TILE') {
|
||||||
this.worldSystem.setTile(action.tileX, action.tileY, action.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') {
|
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||||
|
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export class TreeSeedlingSystem {
|
|||||||
this.removeSprite(id)
|
this.removeSprite(id)
|
||||||
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
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()}`
|
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
||||||
this.adapter.send({
|
this.adapter.send({
|
||||||
|
|||||||
@@ -274,10 +274,9 @@ export class VillagerSystem {
|
|||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
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 })
|
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.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.resourceSystem.removeResource(job.targetId)
|
||||||
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||||
}
|
}
|
||||||
@@ -285,8 +284,8 @@ export class VillagerSystem {
|
|||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
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.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.resourceSystem.removeResource(job.targetId)
|
||||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||||
}
|
}
|
||||||
@@ -351,12 +350,17 @@ export class VillagerSystem {
|
|||||||
if (p.chop > 0) {
|
if (p.chop > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.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 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 })
|
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) {
|
if (p.mine > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.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.
|
||||||
|
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 })
|
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.claimed.delete(v.job.targetId)
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +438,22 @@ export class VillagerSystem {
|
|||||||
return this.nearestBuilding(v, 'bed') as any
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||||
import { TileType, IMPASSABLE } from '../types'
|
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
|
||||||
const BIOME_COLORS: Record<number, string> = {
|
const BIOME_COLORS: Record<number, string> = {
|
||||||
@@ -18,6 +18,12 @@ const BIOME_COLORS: Record<number, string> = {
|
|||||||
export class WorldSystem {
|
export class WorldSystem {
|
||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private map!: Phaser.Tilemaps.Tilemap
|
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 tileset!: Phaser.Tilemaps.Tileset
|
||||||
private bgImage!: Phaser.GameObjects.Image
|
private bgImage!: Phaser.GameObjects.Image
|
||||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||||
@@ -85,6 +91,8 @@ export class WorldSystem {
|
|||||||
|
|
||||||
// Camera bounds
|
// Camera bounds
|
||||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
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). */
|
/** 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.
|
* 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.
|
* Out-of-bounds tiles are treated as impassable.
|
||||||
* @param tileX - Tile column
|
* @param tileX - Tile column
|
||||||
* @param tileY - Tile row
|
* @param tileY - Tile row
|
||||||
@@ -119,7 +131,45 @@ export class WorldSystem {
|
|||||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -12,14 +12,21 @@ export enum TileType {
|
|||||||
WATERED_SOIL = 10,
|
WATERED_SOIL = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tiles that are always impassable regardless of what is on them. */
|
||||||
export const IMPASSABLE = new Set<number>([
|
export const IMPASSABLE = new Set<number>([
|
||||||
TileType.DEEP_WATER,
|
TileType.DEEP_WATER,
|
||||||
TileType.SHALLOW_WATER,
|
TileType.SHALLOW_WATER,
|
||||||
TileType.FOREST,
|
|
||||||
TileType.ROCK,
|
|
||||||
TileType.WALL,
|
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. */
|
/** Tiles on which tree seedlings may be planted. */
|
||||||
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user