13 Commits

Author SHA1 Message Date
b024cf36fb 📝 update CHANGELOG for Issue #22 2026-03-23 12:30:09 +00:00
8197348cfc Merge pull request '🐛 Skip unreachable job targets in pickJob' (#23) from fix/unreachable-job-skip into master 2026-03-23 12:29:17 +00:00
732d9100ab 🐛 fix terrain canvas not updating after tile changes (Issue #22)
CHANGE_TILE only called worldSystem.setTile() (built-tile layer only),
never refreshTerrainTile() — so chopped trees stayed visually dark-green
(FOREST color) even though the tile type was already DARK_GRASS.

- adapter.onAction for CHANGE_TILE now also calls refreshTerrainTile()
  → all tile transitions (chop, mine, seedling maturation) update the
    canvas pixel immediately and consistently in one place
- Remove now-redundant explicit refreshTerrainTile() call in
  TreeSeedlingSystem (the adapter handler covers it)
- Tile-recovery path in GameScene (stateManager.tickTileRecovery) is
  NOT routed through the adapter, so its manual refreshTerrainTile()
  call is kept as-is

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:21:23 +00:00
f2a1811a36 ♻️ 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>
2026-03-23 11:55:24 +00:00
774054db56 🐛 skip unreachable job targets in pickJob (Issue #22)
Trees/rocks fully enclosed by impassable tiles have no passable neighbour
for A* to jump from — pathfinding always returns null, causing a tight
1.5 s retry loop that fills the work log with identical entries.

- Add hasAdjacentPassable() helper: checks all 8 neighbours of a tile
- pickJob now skips chop/mine candidates with no passable neighbour
- idleScanTimer on pathfind failure raised 1500 → 4000 ms as safety net

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:43:00 +00:00
0ed3bfaea6 Merge pull request '🐛 Stockpile opacity + layout overlap + ESC menu padding' (#21) from fix/stockpile-layout-esc-margin into master 2026-03-23 11:28:28 +00:00
1d46446012 📝 update CHANGELOG for Issue #20 2026-03-23 10:48:52 +00:00
a5c37f20f6 🐛 fix stockpile opacity, popText overlap, ESC menu padding (Issue #20)
- Stockpile panel: use uiOpacity instead of hardcoded 0.72
- updateStaticPanelOpacity() replaces updateDebugPanelBackground() and also
  updates stockpilePanel.setAlpha() when opacity changes in Settings
- Stockpile panel height 187→210; popText y 167→192 (8px gap after carrot row)
- ESC menu menuH formula: 16+…+8 → 32+…+8 so last button has 16px bottom
  padding instead of 0px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 10:48:04 +00:00
174db14c7a 📝 update CHANGELOG for Issue #16 overlay opacity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:38:09 +00:00
c7ebf49bf2 overlay opacity: global setting + settings screen (Issue #16)
- Add UI_SETTINGS_KEY to config.ts for separate localStorage entry
- Add uiOpacity field (default 0.8, range 0.4–1.0, 10 % steps) to UIScene
- loadUISettings / saveUISettings persist opacity independently of game save
- Replace all hardcoded panel BG alphas with this.uiOpacity:
  build menu, villager panel, context menu, ESC menu, confirm dialog,
  nisse info panel
- Debug panel (F3) background synced via updateDebugPanelBackground()
- Replace Settings toast with real Settings overlay:
  title, opacity − / value / + buttons, Close button
- ESC key priority stack now includes settingsVisible
- repositionUI closes settings panel on window resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:36:42 +00:00
b259d966ee Merge pull request '🐛 Nisse info panel no longer pauses the game' (#18) from fix/nisse-info-panel-no-pause into master
Reviewed-on: #18
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-22 09:21:37 +00:00
9b22f708a5 🐛 fix nisse info panel no longer pauses the game
Removes uiMenuOpen/uiMenuClose calls from openNisseInfoPanel() and
closeNisseInfoPanel() — the info panel is an observational overlay and
should not interrupt the game loop. Closes #15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 06:55:11 +00:00
a0e813e86b Merge pull request 'Unified tile system (Issue #14)' (#17) from feature/tile-system into master 2026-03-21 16:27:11 +00:00
8 changed files with 272 additions and 23 deletions

View File

@@ -7,6 +7,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
### Added
- **Overlay Opacity Setting** (Issue #16): all UI overlay backgrounds (build menu, villager panel, context menu, ESC menu, confirm dialog, Nisse info panel, debug panel) now use a central `uiOpacity` value instead of hardcoded alphas
- **Settings Screen**: ESC menu → Settings now opens a real overlay with an overlay-opacity row ( / value% / + step buttons, range 40 %100 %, default 80 %); setting persisted in `localStorage` under `tg_ui_settings`, separate from game save so New Game does not wipe it
### 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

View File

@@ -49,6 +49,9 @@ export const VILLAGER_NAMES = [
export const SAVE_KEY = 'tg_save_v5'
export const AUTOSAVE_INTERVAL = 30_000
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
export const UI_SETTINGS_KEY = 'tg_ui_settings'
/** 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

View File

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

View File

@@ -3,6 +3,7 @@ import type { BuildingType, JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem'
import type { DebugData } from '../systems/DebugSystem'
import { stateManager } from '../StateManager'
import { UI_SETTINGS_KEY } from '../config'
const ITEM_ICONS: Record<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
@@ -46,6 +47,11 @@ export class UIScene extends Phaser.Scene {
logTexts: Phaser.GameObjects.Text[]
} | null = null
/** Current overlay background opacity (0.41.0, default 0.8). Persisted in localStorage. */
private uiOpacity = 0.8
private settingsGroup!: Phaser.GameObjects.Group
private settingsVisible = false
constructor() { super({ key: 'UI' }) }
/**
@@ -53,6 +59,7 @@ export class UIScene extends Phaser.Scene {
* keyboard shortcuts (B, V, F3, ESC).
*/
create(): void {
this.loadUISettings()
this.createStockpilePanel()
this.createHintText()
this.createToast()
@@ -85,6 +92,7 @@ export class UIScene extends Phaser.Scene {
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.nisseInfoGroup = this.add.group()
this.settingsGroup = this.add.group()
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) {
@@ -119,14 +127,16 @@ 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, 187, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
// 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).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','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 + 167, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
// last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
this.popText = this.add.text(x + 10, y + 192, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
}
/** Refreshes all item quantities and colors in the stockpile panel. */
@@ -196,7 +206,7 @@ export class UIScene extends Phaser.Scene {
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
]
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200)
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
this.buildMenuGroup.add(bg)
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
@@ -263,7 +273,7 @@ export class UIScene extends Phaser.Scene {
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210)
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
this.villagerPanelGroup.add(bg)
this.villagerPanelGroup.add(
@@ -383,10 +393,11 @@ export class UIScene extends Phaser.Scene {
/** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 80, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: '#000000cc',
backgroundColor: `#000000${hexAlpha}`,
padding: { x: 8, y: 6 },
lineSpacing: 2,
fontFamily: 'monospace',
@@ -463,7 +474,7 @@ export class UIScene extends Phaser.Scene {
const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg)
@@ -521,6 +532,7 @@ export class UIScene extends Phaser.Scene {
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
if (this.settingsVisible) { this.closeSettings(); return }
if (this.escMenuVisible) { this.closeEscMenu(); return }
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
// We only skip opening the ESC menu while those modes are active.
@@ -560,11 +572,12 @@ export class UIScene extends Phaser.Scene {
{ label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() },
]
const menuH = 16 + entries.length * (btnH + 8) + 8
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
const menuH = 32 + entries.length * (btnH + 8) + 8
const mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 2
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, 0.95)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
this.escMenuGroup.add(bg)
this.escMenuGroup.add(
@@ -602,10 +615,155 @@ export class UIScene extends Phaser.Scene {
window.location.reload()
}
/** Opens an empty Settings panel (placeholder). */
/** Opens the Settings overlay. */
private doSettings(): void {
this.closeEscMenu()
this.showToast('Settings — coming soon')
this.openSettings()
}
// ─── Settings overlay ─────────────────────────────────────────────────────
/** Opens the settings overlay if it is not already open. */
private openSettings(): void {
if (this.settingsVisible) return
this.settingsVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildSettings()
}
/** Closes and destroys the settings overlay. */
private closeSettings(): void {
if (!this.settingsVisible) return
this.settingsVisible = false
this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
* Builds the settings overlay with an overlay-opacity row (step buttons).
* Destroying and recreating this method is used to refresh the displayed value.
*/
private buildSettings(): void {
if (this.settingsGroup) this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
const panelW = 280
const panelH = 130
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
// Background
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
this.settingsGroup.add(bg)
// Title
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
)
// Opacity label
this.settingsGroup.add(
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
)
// Minus button
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
minusBtn.on('pointerdown', () => {
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(minusBtn)
this.settingsGroup.add(
this.add.text(px + 183, py + 58, '', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Value display
this.settingsGroup.add(
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
)
// Plus button
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
plusBtn.on('pointerdown', () => {
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(plusBtn)
this.settingsGroup.add(
this.add.text(px + 255, py + 58, '+', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Close button
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
closeBtnRect.on('pointerdown', () => this.closeSettings())
this.settingsGroup.add(closeBtnRect)
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 106, 'Close', {
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
}
/**
* Loads UI settings from localStorage and applies the stored opacity value.
* Falls back to the default (0.8) if no setting is found.
*/
private loadUISettings(): void {
try {
const raw = localStorage.getItem(UI_SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { opacity?: number }
if (typeof parsed.opacity === 'number') {
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
}
}
} catch (_) {}
}
/**
* Persists the current UI settings (opacity) to localStorage.
* Stored separately from the game save so New Game does not wipe it.
*/
private saveUISettings(): void {
try {
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
} catch (_) {}
}
/**
* Applies the current uiOpacity to all static UI elements that are not
* rebuilt on open (stockpile panel, debug panel background).
* Called whenever uiOpacity changes.
*/
private updateStaticPanelOpacity(): void {
this.stockpilePanel.setAlpha(this.uiOpacity)
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
}
/** Shows a confirmation dialog before starting a new game. */
@@ -634,7 +792,7 @@ export class UIScene extends Phaser.Scene {
const dx = this.scale.width / 2 - dialogW / 2
const dy = this.scale.height / 2 - dialogH / 2
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, 0.97)
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
this.confirmGroup.add(bg)
@@ -692,7 +850,6 @@ export class UIScene extends Phaser.Scene {
private openNisseInfoPanel(villagerId: string): void {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildNisseInfoPanel()
}
@@ -703,7 +860,6 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
@@ -730,7 +886,7 @@ export class UIScene extends Phaser.Scene {
// Background
this.nisseInfoGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x050510, 0.93)
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
@@ -883,6 +1039,7 @@ export class UIScene extends Phaser.Scene {
if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu()
if (this.settingsVisible) this.closeSettings()
if (this.confirmVisible) this.hideConfirm()
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
}

View File

@@ -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({

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

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])