11 Commits

Author SHA1 Message Date
42c076b568 Merge pull request ' Demolish Mode — Gebäude abreißen (Issue #50)' (#55) from feature/demolish-buildings into master 2026-03-24 20:24:59 +00:00
fc10201469 add demolish mode for buildings (Issue #50)
- New 💥 Demolish button in the action bar
- Red ghost highlights building footprint on hover
- Refund: 100% within 3 min, decays linearly to 0%
- Mine teardown unblocks passability tiles and removes status label
- Nisse inside demolished mine are rescued and reset to idle
- Floor/wall/chest tiles restored to GRASS on demolish
- Build error now shows missing resources instead of generic message
- BuildingState gains builtAt field; old saves default to 0 (no refund)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:18:49 +00:00
6eeb47c720 Merge pull request ' Mine Building — automated stone production (Issue #42)' (#45) from feature/mine-building into master 2026-03-24 19:59:01 +00:00
fcf805d4d2 🐛 add mine to action bar build tray
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:58:50 +00:00
ccc224e2b9 📝 update changelog for mine building (Issue #42)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:58:50 +00:00
202ff435f7 add mine building for automated stone production
- New 3×2 building placeable only on ROCK tiles without resources (50 stone + 200 wood)
- Mine entrance at bottom-centre tile (tileX+1, tileY+1) — only passable tile
- Other 5 footprint tiles blocked via resourceTiles index (impassable ROCK)
- Nisse with mine priority > 0 walk to entrance, hide inside for MINE_WORK_MS (15 s),
  then reappear carrying MINE_STONE_YIELD (2) stone and haul to stockpile
- Up to MINE_CAPACITY (3) Nisse work simultaneously; overflow Nisse wait for a slot
- ⛏ X/3 world-space status label above building updated each frame
- Surface rock harvesting unchanged; mine buildings take precedence in pickJob
- Ghost resizes to 3×2 when mine is selected; placement validated across full footprint
- Mine added to build menu with cost and placement hint

Closes #42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:58:50 +00:00
a6c2aa5309 Merge pull request 'fix: Keep ROCK tile after surface rock mining (Issue #48)' (#49) from fix/rock-tile-preserved into master 2026-03-24 19:56:13 +00:00
3bf143993e 🐛 keep ROCK tile type after surface rock mining
CHANGE_TILE ROCK→GRASS removed from the surface-rock harvest branch.
Empty ROCK tiles are now passable (no resource on them) but remain ROCK,
so the mine building can still be placed on harvested rock ground.

Fixes #48

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:28:57 +00:00
0a706b8def Merge pull request 'Fix: Debug-Panel (F3) weicht Nisse-Info-Panel aus (#41)' (#44) from fix/debug-panel-overlap-41 into master 2026-03-24 18:42:55 +00:00
ae6c14d9a1 🐛 reposition debug panel when Nisse info panel is open (#41)
Debug panel shifts below the Nisse info panel to avoid overlap.
repositionDebugPanel() is called on toggle, open, and close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:16:17 +00:00
3e099d92e2 Merge pull request 'Fix: uiOpacity auf Stockpile und Action Bar vereinheitlicht (#39, #40)' (#43) from fix/ui-opacity-panels into master 2026-03-24 17:14:50 +00:00
8 changed files with 606 additions and 66 deletions

View File

@@ -7,7 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- **Demolish Mode** (Issue #50): New 💥 Demolish button in the action bar; hover shows a red ghost over any building with a refund percentage; buildings demolished within 3 minutes return 100% of costs (linear decay to 0%); mine footprint tiles are unblocked on teardown; Nisse working inside a demolished building are rescued and resume idle; tile types are restored where applicable (floor/wall/chest → grass)
- **Mine Building** (Issue #42): 3×2 building placeable only on resource-free ROCK tiles (costs: 200 wood + 50 stone); Nisse with mine priority walk to the entrance, disappear inside for 15 s, then reappear carrying 2 stone; up to 3 Nisse work simultaneously; ⛏ X/3 status label shown directly on the building in world space; surface rock harvesting remains functional alongside the building
### Fixed
- **Debug-Panel überlagert Nisse-Info-Panel** (Issue #41): F3-Debug-Panel weicht dynamisch aus — wenn das Nisse-Info-Panel offen ist, erscheint das Debug-Panel unterhalb davon statt darüber
- **Stockpile-Overlay Transparenz** (Issue #39): `updateStaticPanelOpacity()` verwendete `setAlpha()` statt `setFillStyle()` — dadurch wurde die Opacity quadratisch statt linear angewendet; bei 100 % blieb das Panel sichtbar transparent
- **Action Bar Transparenz** (Issue #40): Action Bar ignorierte `uiOpacity` komplett — Hintergrund war hardcoded auf 0.92; wird jetzt korrekt mit `uiOpacity` erstellt und per `updateStaticPanelOpacity()` live aktualisiert

View File

@@ -233,7 +233,7 @@ class StateManager {
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
case 'PLACE_BUILDING': {
w.buildings[action.building.id] = action.building
w.buildings[action.building.id] = { ...action.building, builtAt: w.gameTime }
for (const [k, v] of Object.entries(action.costs))
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
if (action.building.kind === 'forester_hut') {
@@ -415,6 +415,8 @@ class StateManager {
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
}
// Migrate buildings without builtAt (pre-demolish saves): set to 0 = no refund
if (typeof (b as any).builtAt === 'undefined') (b as any).builtAt = 0
}
return p
} catch (_) { return null }

View File

@@ -20,8 +20,18 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
bed: { wood: 6 },
stockpile_zone:{ wood: 0 },
forester_hut: { wood: 50 },
mine: { stone: 50, wood: 200 },
}
/** Maximum number of Nisse that can work inside a mine simultaneously. */
export const MINE_CAPACITY = 3
/** Milliseconds a Nisse spends inside a mine per visit. */
export const MINE_WORK_MS = 15_000
/** Stone yielded per mine visit. */
export const MINE_STONE_YIELD = 2
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
export const FORESTER_ZONE_RADIUS = 5
@@ -51,6 +61,9 @@ export const VILLAGER_NAMES = [
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
]
/** Milliseconds after placement during which demolishing gives a full refund (linearly decays to 0%). */
export const DEMOLISH_REFUND_MS = 180_000 // 3 minutes
export const SAVE_KEY = 'tg_save_v5'
export const AUTOSAVE_INTERVAL = 30_000

View File

@@ -1,5 +1,5 @@
import Phaser from 'phaser'
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
import { AUTOSAVE_INTERVAL, TILE_SIZE, MINE_CAPACITY } from '../config'
import { TileType } from '../types'
import type { BuildingType } from '../types'
import { stateManager } from '../StateManager'
@@ -27,6 +27,8 @@ export class GameScene extends Phaser.Scene {
foresterZoneSystem!: ForesterZoneSystem
private autosaveTimer = 0
private menuOpen = false
/** World-space status labels for mine buildings, keyed by building ID. */
private mineStatusTexts = new Map<string, Phaser.GameObjects.Text>()
constructor() { super({ key: 'Game' }) }
@@ -62,6 +64,31 @@ export class GameScene extends Phaser.Scene {
this.events.emit('toast', msg)
this.renderPersistentObjects()
}
this.buildingSystem.onDemolishModeChange = (active) => this.events.emit('demolishModeChanged', active)
this.buildingSystem.onDemolished = (building, refund) => {
// Remove the building sprite
this.children.getByName(`bobj_${building.id}`)?.destroy()
// Mine-specific cleanup: unblock the 5 passability tiles and remove status label
if (building.kind === 'mine') {
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 3; dx++) {
if (dx === 1 && dy === 1) continue // entrance tile was never blocked
this.worldSystem.removeResourceTile(building.tileX + dx, building.tileY + dy)
}
}
this.mineStatusTexts.get(building.id)?.destroy()
this.mineStatusTexts.delete(building.id)
}
// Rescue any Nisse working in or walking to this building
this.villagerSystem.rescueNisseFromBuilding(building.id)
const refundMsg = Object.keys(refund).length
? ` (+${Object.entries(refund).map(([k, v]) => `${v} ${k}`).join(', ')})`
: ' (no refund)'
this.events.emit('toast', `Demolished ${building.kind}${refundMsg}`)
}
this.farmingSystem.create()
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
@@ -100,7 +127,7 @@ export class GameScene extends Phaser.Scene {
// Detect left-clicks on forester huts to open the zone panel
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown() || this.menuOpen) return
if (this.buildingSystem.isActive()) return
if (this.buildingSystem.isActive() || this.buildingSystem.isDemolishActive()) return
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const state = stateManager.getState()
@@ -114,7 +141,9 @@ export class GameScene extends Phaser.Scene {
this.scene.launch('UI')
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
this.events.on('activateDemolish', () => this.buildingSystem.activateDemolish())
this.events.on('deactivateDemolish', () => this.buildingSystem.deactivateDemolish())
this.events.on('uiMenuOpen', () => { this.menuOpen = true })
this.events.on('uiMenuClose', () => { this.menuOpen = false })
this.events.on('uiRequestBuildMenu', () => {
@@ -165,6 +194,7 @@ export class GameScene extends Phaser.Scene {
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
this.buildingSystem.update()
this.updateMineStatusLabels()
this.autosaveTimer -= delta
if (this.autosaveTimer <= 0) {
@@ -203,10 +233,105 @@ export class GameScene extends Phaser.Scene {
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
// Tree symbol on the roof
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
} else if (building.kind === 'mine') {
this.renderMineBuilding(building)
}
}
}
/**
* Draws a mine building (3×2 tiles) at the given building position.
* Blocks the 5 non-entrance tiles in the WorldSystem passability index and
* creates a world-space status label showing current / max workers.
* @param building - The mine building state to render
*/
private renderMineBuilding(building: ReturnType<typeof stateManager.getState>['world']['buildings'][string]): void {
const name = `bobj_${building.id}`
const left = building.tileX * TILE_SIZE
const top = building.tileY * TILE_SIZE
const W = TILE_SIZE * 3 // 96 px
const H = TILE_SIZE * 2 // 64 px
const g = this.add.graphics().setName(name).setDepth(building.tileY + 6)
// Rocky stone face
g.fillStyle(0x424242); g.fillRect(left, top, W, H)
// Stone texture highlights — top row
g.fillStyle(0x5a5a5a, 0.7)
g.fillRect(left + 4, top + 3, 20, 10)
g.fillRect(left + 28, top + 5, 18, 9)
g.fillRect(left + 52, top + 3, 22, 11)
g.fillRect(left + 76, top + 5, 14, 10)
// Stone highlights — bottom row sides (left of entrance, right of entrance)
g.fillRect(left + 4, top + 36, 16, 10)
g.fillRect(left + 70, top + 37, 18, 10)
g.fillStyle(0x3a3a3a, 0.5)
g.fillRect(left + 6, top + 50, 18, 8)
g.fillRect(left + 68, top + 51, 16, 8)
// Wooden support posts flanking entrance
g.fillStyle(0x8B4513)
g.fillRect(left + 30, top + 22, 8, H - 22) // left post
g.fillRect(left + 58, top + 22, 8, H - 22) // right post
// Lintel beam across top of entrance
g.fillStyle(0x6B3311)
g.fillRect(left + 28, top + 20, 40, 10)
// Horizontal wood grain lines on posts
g.lineStyle(1, 0x5C2A00, 0.4)
for (let yy = top + 28; yy < top + H; yy += 7) {
g.lineBetween(left + 30, yy, left + 38, yy)
g.lineBetween(left + 58, yy, left + 66, yy)
}
// Mine shaft (dark entrance opening)
g.fillStyle(0x0d0d0d); g.fillRect(left + 38, top + 30, 20, H - 30)
g.fillStyle(0x000000, 0.5); g.fillRect(left + 38, top + 30, 4, H - 30) // left shadow
// Rail track at entrance floor
g.fillStyle(0x888888); g.fillRect(left + 42, top + H - 5, 12, 2)
g.fillStyle(0x777777)
g.fillRect(left + 44, top + H - 8, 2, 6)
g.fillRect(left + 50, top + H - 8, 2, 6)
// Block the 5 non-entrance tiles in the passability index.
// Entrance = (tileX+1, tileY+1) — stays passable.
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 3; dx++) {
if (dx === 1 && dy === 1) continue // entrance tile: skip
this.worldSystem.addResourceTile(building.tileX + dx, building.tileY + dy)
}
}
// Create the ⛏ X/3 status label above the building (only once)
if (!this.mineStatusTexts.has(building.id)) {
const cx = left + W / 2
const st = this.add.text(cx, top - 4, `⛏ 0/${MINE_CAPACITY}`, {
fontSize: '10px', color: '#ffdd88', fontFamily: 'monospace',
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
}).setOrigin(0.5, 1).setDepth(building.tileY + 7)
this.mineStatusTexts.set(building.id, st)
}
}
/**
* Updates the ⛏ X/3 status label for every mine building each frame.
* Counts Nisse currently working (inside) the mine from the game state.
*/
private updateMineStatusLabels(): void {
const state = stateManager.getState()
const villagers = Object.values(state.world.villagers)
for (const [buildingId, text] of this.mineStatusTexts) {
const count = villagers.filter(
v => v.job?.targetId === buildingId && v.aiState === 'working'
).length
text.setText(`${count}/${MINE_CAPACITY}`)
}
}
/** Saves game state and destroys all systems cleanly on scene shutdown. */
shutdown(): void {
stateManager.save()

View File

@@ -67,9 +67,11 @@ export class UIScene extends Phaser.Scene {
private actionBuildLabel!: Phaser.GameObjects.Text
private actionNisseBtn!: Phaser.GameObjects.Rectangle
private actionNisseLabel!: Phaser.GameObjects.Text
private actionDemolishBtn!: Phaser.GameObjects.Rectangle
private actionDemolishLabel!: Phaser.GameObjects.Text
private actionTrayGroup!: Phaser.GameObjects.Group
private actionTrayVisible = false
private activeCategory: 'build' | 'nisse' | null = null
private activeCategory: 'build' | 'nisse' | 'demolish' | null = null
constructor() { super({ key: 'UI' }) }
@@ -89,10 +91,16 @@ export class UIScene extends Phaser.Scene {
this.createActionBar()
const gameScene = this.scene.get('Game')
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
gameScene.events.on('toast', (m: string) => this.showToast(m))
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
gameScene.events.on('toast', (m: string) => this.showToast(m))
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
gameScene.events.on('demolishModeChanged', (active: boolean) => {
if (!active && this.activeCategory === 'demolish') {
this.activeCategory = null
this.updateCategoryHighlights()
}
})
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
@@ -229,9 +237,10 @@ export class UIScene extends Phaser.Scene {
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
{ kind: 'mine', label: '⛏ Mine', cost: '200 wood + 50 stone (place on ROCK)' },
]
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 186
const bg = this.add.rectangle(menuX, menuY, 300, 372, 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))
@@ -405,7 +414,7 @@ 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, '', {
this.debugPanelText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: `#000000${hexAlpha}`,
@@ -419,9 +428,20 @@ export class UIScene extends Phaser.Scene {
private toggleDebugPanel(): void {
this.debugActive = !this.debugActive
this.debugPanelText.setVisible(this.debugActive)
this.repositionDebugPanel()
this.scene.get('Game').events.emit('debugToggle')
}
/**
* Repositions the debug panel to avoid overlapping the Nisse info panel.
* When the Nisse info panel is open, the debug panel shifts below it.
*/
private repositionDebugPanel(): void {
const NISSE_PANEL_H = 120 + 10 * 14 + 16 // matches buildNisseInfoPanel: 276px
const debugY = this.nisseInfoVisible ? 10 + NISSE_PANEL_H + 10 : 10
this.debugPanelText.setY(debugY)
}
/**
* Reads current debug data from DebugSystem and updates the panel text.
* Called every frame while debug mode is active.
@@ -551,9 +571,9 @@ export class UIScene extends Phaser.Scene {
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.
// Build/farm/demolish mode: let their systems handle ESC. Skip opening the ESC menu.
if (this.inBuildMode || this.inFarmMode) return
if (this.activeCategory === 'demolish') { this.deactivateCategory(); return }
this.openEscMenu()
}
@@ -879,6 +899,7 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.buildNisseInfoPanel()
this.repositionDebugPanel()
}
/** Closes and destroys the Nisse info panel. */
@@ -888,6 +909,7 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.repositionDebugPanel()
}
/**
@@ -1201,14 +1223,28 @@ export class UIScene extends Phaser.Scene {
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
this.actionDemolishBtn = this.add.rectangle(200, barY + 8, 88, 32, 0x3a1a1a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionDemolishBtn.on('pointerover', () => {
if (this.activeCategory !== 'demolish') this.actionDemolishBtn.setFillStyle(0x5a2a2a, this.uiOpacity)
})
this.actionDemolishBtn.on('pointerout', () => {
if (this.activeCategory !== 'demolish') this.actionDemolishBtn.setFillStyle(0x3a1a1a, this.uiOpacity)
})
this.actionDemolishBtn.on('pointerdown', () => this.toggleCategory('demolish'))
this.actionDemolishLabel = this.add.text(244, barY + UIScene.BAR_H / 2, '💥 Demolish', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
}
/**
* Toggles the given action bar category on or off.
* Selecting the active category deselects it; selecting a new one closes the previous.
* @param cat - The category to toggle ('build' or 'nisse')
* @param cat - The category to toggle
*/
private toggleCategory(cat: 'build' | 'nisse'): void {
private toggleCategory(cat: 'build' | 'nisse' | 'demolish'): void {
if (this.activeCategory === cat) {
this.deactivateCategory()
return
@@ -1216,14 +1252,17 @@ export class UIScene extends Phaser.Scene {
// Close whatever was open before
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
this.activeCategory = cat
this.updateCategoryHighlights()
if (cat === 'build') {
this.openActionTray()
} else {
} else if (cat === 'nisse') {
this.openVillagerPanel()
} else {
this.scene.get('Game').events.emit('activateDemolish')
}
}
@@ -1233,17 +1272,19 @@ export class UIScene extends Phaser.Scene {
private deactivateCategory(): void {
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
this.activeCategory = null
this.updateCategoryHighlights()
}
/**
* Updates the visual highlight of the Build and Nisse buttons
* Updates the visual highlight of the Build, Nisse, and Demolish buttons
* to reflect the current active category.
*/
private updateCategoryHighlights(): void {
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, this.uiOpacity)
this.actionDemolishBtn.setFillStyle(this.activeCategory === 'demolish' ? 0x7a3d3d : 0x3a1a1a, this.uiOpacity)
}
/**
@@ -1271,6 +1312,7 @@ export class UIScene extends Phaser.Scene {
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
{ kind: 'mine', emoji: '⛏', label: 'Mine' },
]
const itemW = 84
@@ -1337,7 +1379,9 @@ export class UIScene extends Phaser.Scene {
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
this.actionNisseLabel.setPosition(144, height - UIScene.BAR_H + UIScene.BAR_H / 2)
this.actionNisseLabel.setPosition(148, height - UIScene.BAR_H + UIScene.BAR_H / 2)
this.actionDemolishBtn.setPosition(200, height - UIScene.BAR_H + 8)
this.actionDemolishLabel.setPosition(244, height - UIScene.BAR_H + UIScene.BAR_H / 2)
if (this.actionTrayVisible) this.closeActionTray()
// Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize

View File

@@ -1,7 +1,7 @@
import Phaser from 'phaser'
import { TILE_SIZE, BUILDING_COSTS } from '../config'
import { TILE_SIZE, BUILDING_COSTS, DEMOLISH_REFUND_MS } from '../config'
import { TileType, IMPASSABLE } from '../types'
import type { BuildingType } from '../types'
import type { BuildingType, BuildingState, ItemId } from '../types'
import { stateManager } from '../StateManager'
import type { LocalAdapter } from '../NetworkAdapter'
@@ -12,10 +12,18 @@ const BUILDING_TILE: Partial<Record<BuildingType, TileType>> = {
// bed and stockpile_zone do NOT change the underlying tile
}
/** Tile type to restore when a building that changed its tile is demolished. */
const DEMOLISH_RESTORE_TILE: Partial<Record<BuildingType, TileType>> = {
floor: TileType.GRASS,
wall: TileType.GRASS,
chest: TileType.GRASS,
}
export class BuildingSystem {
private scene: Phaser.Scene
private adapter: LocalAdapter
private active = false
private demolishActive = false
private selectedBuilding: BuildingType = 'floor'
private ghost!: Phaser.GameObjects.Rectangle
private ghostLabel!: Phaser.GameObjects.Text
@@ -24,12 +32,23 @@ export class BuildingSystem {
onModeChange?: (active: boolean, building: BuildingType) => void
onPlaced?: (msg: string) => void
onDemolishModeChange?: (active: boolean) => void
/**
* Called after a building is demolished with the removed building data and the refund items.
* @param building - The BuildingState that was removed
* @param refund - Items returned to stockpile
*/
onDemolished?: (building: BuildingState, refund: Partial<Record<ItemId, number>>) => void
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
this.scene = scene
this.adapter = adapter
}
/**
* Initialises ghost sprite, label, and keyboard/pointer handlers for
* both build mode and demolish mode.
*/
create(): void {
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
this.ghost.setDepth(1000)
@@ -47,20 +66,64 @@ export class BuildingSystem {
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
// Click to place
// Click to place (build mode) or demolish (demolish mode)
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (!this.active) return
if (ptr.rightButtonDown()) {
this.deactivate()
if (this.active) this.deactivate()
if (this.demolishActive) this.deactivateDemolish()
return
}
this.tryPlace(ptr)
if (this.active) this.tryPlace(ptr)
else if (this.demolishActive) this.tryDemolish(ptr)
})
}
/**
* Returns the tile footprint dimensions for the given building type.
* @param kind - The building type to query
* @returns Width and height in tiles
*/
private getFootprint(kind: BuildingType): { w: number; h: number } {
if (kind === 'mine') return { w: 3, h: 2 }
return { w: 1, h: 1 }
}
/**
* Returns all tile positions occupied by the given building,
* expanding multi-tile buildings (e.g. mine: 3×2) to their full footprint.
* @param b - The building state to expand
* @returns Array of tile positions in the building's footprint
*/
private getBuildingFootprintTiles(b: BuildingState): Array<{ tileX: number; tileY: number }> {
if (b.kind === 'mine') {
const result: Array<{ tileX: number; tileY: number }> = []
for (let dy = 0; dy < 2; dy++)
for (let dx = 0; dx < 3; dx++)
result.push({ tileX: b.tileX + dx, tileY: b.tileY + dy })
return result
}
return [{ tileX: b.tileX, tileY: b.tileY }]
}
/**
* Finds the building whose footprint contains the given tile, if any.
* @param tileX - Tile column to check
* @param tileY - Tile row to check
* @returns The matching BuildingState, or undefined
*/
private findBuildingAtTile(tileX: number, tileY: number): BuildingState | undefined {
const buildings = Object.values(stateManager.getState().world.buildings)
return buildings.find(b =>
this.getBuildingFootprintTiles(b).some(t => t.tileX === tileX && t.tileY === tileY)
)
}
/** Select a building type and activate build mode */
selectBuilding(kind: BuildingType): void {
if (this.demolishActive) this.deactivateDemolish()
this.selectedBuilding = kind
const { w, h } = this.getFootprint(kind)
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
this.activate()
}
@@ -80,6 +143,37 @@ export class BuildingSystem {
isActive(): boolean { return this.active }
/**
* Activates demolish mode. Deactivates build mode if currently active.
* In demolish mode the ghost turns red and clicking a building removes it.
*/
activateDemolish(): void {
if (this.active) this.deactivate()
this.demolishActive = true
this.ghost.setSize(TILE_SIZE, TILE_SIZE)
this.ghost.setFillStyle(0xFF2222, 0.35)
this.ghost.setStrokeStyle(2, 0xFF2222, 0.9)
this.ghost.setVisible(true)
this.ghostLabel.setVisible(true)
this.onDemolishModeChange?.(true)
}
/**
* Deactivates demolish mode and hides the ghost.
*/
deactivateDemolish(): void {
this.demolishActive = false
this.ghost.setVisible(false)
this.ghostLabel.setVisible(false)
this.onDemolishModeChange?.(false)
}
/** Returns true if demolish mode is currently active. */
isDemolishActive(): boolean { return this.demolishActive }
/**
* Updates ghost position and label each frame for both build and demolish modes.
*/
update(): void {
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
if (this.active) this.deactivate()
@@ -87,23 +181,30 @@ export class BuildingSystem {
}
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
this.deactivate()
this.deactivateDemolish()
}
if (!this.active) return
if (this.active) {
this.updateBuildGhost()
} else if (this.demolishActive) {
this.updateDemolishGhost()
}
}
// Update ghost to follow mouse (snapped to tile grid)
/**
* Updates the green/red build-mode ghost to follow the mouse, snapped to the tile grid.
*/
private updateBuildGhost(): void {
const ptr = this.scene.input.activePointer
const worldX = ptr.worldX
const worldY = ptr.worldY
const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE)
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
const snapY = tileY * TILE_SIZE + TILE_SIZE / 2
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const { w, h } = this.getFootprint(this.selectedBuilding)
const snapX = tileX * TILE_SIZE + (w * TILE_SIZE) / 2
const snapY = tileY * TILE_SIZE + (h * TILE_SIZE) / 2
this.ghost.setPosition(snapX, snapY)
this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2)
this.ghostLabel.setPosition(snapX, snapY - (h * TILE_SIZE) / 2 - 2)
// Color ghost based on can-build
const canBuild = this.canBuildAt(tileX, tileY)
const color = canBuild ? 0x00FF00 : 0xFF4444
this.ghost.setFillStyle(color, 0.35)
@@ -114,8 +215,62 @@ export class BuildingSystem {
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
}
/**
* Updates the red demolish ghost to follow the mouse. Highlights the hovered building's
* footprint and shows the refund percentage in the label.
*/
private updateDemolishGhost(): void {
const ptr = this.scene.input.activePointer
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const building = this.findBuildingAtTile(tileX, tileY)
if (building) {
const { w, h } = this.getFootprint(building.kind)
const snapX = building.tileX * TILE_SIZE + (w * TILE_SIZE) / 2
const snapY = building.tileY * TILE_SIZE + (h * TILE_SIZE) / 2
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
this.ghost.setPosition(snapX, snapY)
this.ghostLabel.setPosition(snapX, snapY - (h * TILE_SIZE) / 2 - 2)
this.ghost.setFillStyle(0xFF2222, 0.45)
this.ghost.setStrokeStyle(2, 0xFF2222, 1)
const refundPct = this.calcRefundPct(building)
const label = refundPct > 0
? `${building.kind} [refund ${Math.round(refundPct * 100)}%]`
: `${building.kind} [no refund]`
this.ghostLabel.setText(label)
} else {
// No building under cursor — small neutral ghost
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
const snapY = tileY * TILE_SIZE + TILE_SIZE / 2
this.ghost.setSize(TILE_SIZE, TILE_SIZE)
this.ghost.setPosition(snapX, snapY)
this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2)
this.ghost.setFillStyle(0x444444, 0.2)
this.ghost.setStrokeStyle(1, 0x666666, 0.5)
this.ghostLabel.setText('')
}
}
/**
* Calculates the refund fraction (01) for a building based on how long ago it was built.
* Returns 1.0 within the first 3 minutes, decaying linearly to 0.
* @param building - The building to evaluate
* @returns Refund fraction between 0 and 1
*/
private calcRefundPct(building: BuildingState): number {
const elapsed = stateManager.getGameTime() - (building.builtAt ?? 0)
return Math.max(0, 1 - elapsed / DEMOLISH_REFUND_MS)
}
private canBuildAt(tileX: number, tileY: number): boolean {
const state = stateManager.getState()
if (this.selectedBuilding === 'mine') {
return this.canPlaceMineAt(tileX, tileY)
}
const tile = state.world.tiles[tileY * 512 + tileX] as TileType // 512 = WORLD_TILES
// Can only build on passable ground tiles (not water, not existing buildings)
@@ -141,6 +296,40 @@ export class BuildingSystem {
return true
}
/**
* Checks whether a 3×2 mine can be placed with top-left at (tileX, tileY).
* All 6 tiles must be ROCK with no resources and no overlapping buildings.
* @param tileX - Top-left tile column
* @param tileY - Top-left tile row
*/
private canPlaceMineAt(tileX: number, tileY: number): boolean {
const state = stateManager.getState()
// Check costs
const costs = BUILDING_COSTS.mine
for (const [item, qty] of Object.entries(costs)) {
const have = state.world.stockpile[item as keyof typeof state.world.stockpile] ?? 0
if (have < qty) return false
}
const resources = Object.values(state.world.resources)
const buildings = Object.values(state.world.buildings)
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 3; dx++) {
const tx = tileX + dx, ty = tileY + dy
// Must be ROCK
if ((state.world.tiles[ty * 512 + tx] as TileType) !== TileType.ROCK) return false
// No resource on this tile
if (resources.some(r => r.tileX === tx && r.tileY === ty)) return false
// No existing building footprint overlapping this tile
if (buildings.some(b => this.getBuildingFootprintTiles(b).some(t => t.tileX === tx && t.tileY === ty))) return false
}
}
return true
}
private tryPlace(ptr: Phaser.Input.Pointer): void {
const worldX = ptr.worldX
const worldY = ptr.worldY
@@ -148,33 +337,92 @@ export class BuildingSystem {
const tileY = Math.floor(worldY / TILE_SIZE)
if (!this.canBuildAt(tileX, tileY)) {
this.onPlaced?.('Cannot build here!')
const missing = this.getMissingResources(tileX, tileY)
this.onPlaced?.(missing.length ? `Need: ${missing}` : 'Cannot build here!')
return
}
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
const building = {
const building: BuildingState = {
id: `building_${tileX}_${tileY}_${Date.now()}`,
tileX,
tileY,
kind: this.selectedBuilding,
ownerId: stateManager.getState().player.id,
builtAt: stateManager.getGameTime(),
}
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
// Only change the tile type for buildings that have a floor/wall tile mapping
const tileMapped = BUILDING_TILE[this.selectedBuilding]
if (tileMapped !== undefined) {
this.adapter.send({
type: 'CHANGE_TILE',
tileX,
tileY,
tile: tileMapped,
})
// Mine keeps its ROCK tile type; footprint blocking is handled in renderPersistentObjects.
// Other buildings change tile type where applicable.
if (this.selectedBuilding !== 'mine') {
const tileMapped = BUILDING_TILE[this.selectedBuilding]
if (tileMapped !== undefined) {
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: tileMapped })
}
}
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
}
/**
* Returns a human-readable string describing which resources are missing
* to build the currently selected building at the given tile.
* @param tileX - Tile column
* @param tileY - Tile row
* @returns Comma-separated missing resource string, or empty string if nothing is missing
*/
private getMissingResources(tileX: number, tileY: number): string {
const state = stateManager.getState()
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
const parts: string[] = []
for (const [item, qty] of Object.entries(costs)) {
const have = state.world.stockpile[item as keyof typeof state.world.stockpile] ?? 0
if (have < qty) parts.push(`${qty - have} ${item}`)
}
return parts.join(', ')
}
/**
* Attempts to demolish the building at the clicked tile.
* Calculates the time-based refund, removes the building from state,
* restores the tile type if applicable, and fires onDemolished.
* @param ptr - The pointer that was clicked
*/
private tryDemolish(ptr: Phaser.Input.Pointer): void {
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
const building = this.findBuildingAtTile(tileX, tileY)
if (!building) return
// Calculate refund
const costs = BUILDING_COSTS[building.kind] ?? {}
const refundPct = this.calcRefundPct(building)
const refund: Partial<Record<ItemId, number>> = {}
for (const [item, qty] of Object.entries(costs)) {
const amount = Math.floor((qty ?? 0) * refundPct)
if (amount > 0) refund[item as ItemId] = amount
}
this.adapter.send({ type: 'REMOVE_BUILDING', buildingId: building.id })
// Restore tile type for buildings that changed it on placement
const restoreTile = DEMOLISH_RESTORE_TILE[building.kind]
if (restoreTile !== undefined) {
this.adapter.send({ type: 'CHANGE_TILE', tileX: building.tileX, tileY: building.tileY, tile: restoreTile })
}
// Return resources to stockpile
if (Object.keys(refund).length > 0) {
this.adapter.send({ type: 'ADD_ITEMS', items: refund })
}
this.onDemolished?.(building, refund)
}
/**
* Cleans up ghost sprites on scene shutdown.
*/
destroy(): void {
this.ghost.destroy()
this.ghostLabel.destroy()

View File

@@ -1,5 +1,5 @@
import Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES, MINE_CAPACITY, MINE_WORK_MS, MINE_STONE_YIELD } from '../config'
import { TileType, PLANTABLE_TILES } from '../types'
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
import { stateManager } from '../StateManager'
@@ -37,7 +37,8 @@ export class VillagerSystem {
private farmingSystem!: FarmingSystem
private runtime = new Map<string, VillagerRuntime>()
private claimed = new Set<string>() // target IDs currently claimed by a villager
private claimed = new Set<string>() // target IDs currently claimed (resources, crops, etc.)
private mineClaimsMap = new Map<string, number>() // mine building ID → number of claimed slots
private spawnTimer = 0
private nameIndex = 0
@@ -72,6 +73,48 @@ export class VillagerSystem {
this.farmingSystem = farmingSystem
}
// ─── Claim helpers ────────────────────────────────────────────────────────
/**
* Claims a job target. Mine buildings use a capacity counter (up to MINE_CAPACITY);
* all other targets use the exclusive claimed Set.
* @param targetId - The resource, crop, or building ID being claimed
*/
private claimTarget(targetId: string): void {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
this.mineClaimsMap.set(targetId, (this.mineClaimsMap.get(targetId) ?? 0) + 1)
} else {
this.claimed.add(targetId)
}
}
/**
* Releases a job target claim.
* @param targetId - The previously claimed target ID
*/
private releaseTarget(targetId: string): void {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
const n = this.mineClaimsMap.get(targetId) ?? 0
if (n <= 1) this.mineClaimsMap.delete(targetId)
else this.mineClaimsMap.set(targetId, n - 1)
} else {
this.claimed.delete(targetId)
}
}
/**
* Returns true if the given target is fully claimed.
* Mine buildings are claimed when their worker count reaches MINE_CAPACITY.
* All other targets are claimed exclusively.
* @param targetId - The target ID to check
*/
private isTargetClaimed(targetId: string): boolean {
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
return (this.mineClaimsMap.get(targetId) ?? 0) >= MINE_CAPACITY
}
return this.claimed.has(targetId)
}
/**
* Spawns sprites for all Nisse that exist in the saved state
* and re-claims any active job targets.
@@ -81,7 +124,7 @@ export class VillagerSystem {
for (const v of Object.values(state.world.villagers)) {
this.spawnSprite(v)
// Re-claim any active job targets
if (v.job) this.claimed.add(v.job.targetId)
if (v.job) this.claimTarget(v.job.targetId)
}
}
@@ -173,7 +216,7 @@ export class VillagerSystem {
// Find a job
const job = this.pickJob(v)
if (job) {
this.claimed.add(job.targetId)
this.claimTarget(job.targetId)
this.adapter.send({
type: 'VILLAGER_SET_JOB', villagerId: v.id,
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
@@ -232,10 +275,20 @@ export class VillagerSystem {
*/
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
switch (rt.destination) {
case 'job':
rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000
case 'job': {
// Mine buildings take longer than surface-rock mining and hide the Nisse sprite.
const isMineBuilding = v.job?.type === 'mine' &&
stateManager.getState().world.buildings[v.job.targetId]?.kind === 'mine'
rt.workTimer = isMineBuilding ? MINE_WORK_MS : (VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000)
if (isMineBuilding) {
rt.sprite.setVisible(false)
rt.nameLabel.setVisible(false)
rt.energyBar.setVisible(false)
rt.jobIcon.setVisible(false)
}
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' })
break
}
case 'stockpile':
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
@@ -276,7 +329,7 @@ export class VillagerSystem {
const job = v.job
if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return }
this.claimed.delete(job.targetId)
this.releaseTarget(job.targetId)
const state = stateManager.getState()
if (job.type === 'chop') {
@@ -293,13 +346,27 @@ export class VillagerSystem {
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
}
} else if (job.type === 'mine') {
const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
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)')
const building = state.world.buildings[job.targetId]
if (building?.kind === 'mine') {
// Mine building: yield stone directly into carrying, then show the Nisse again
const mutableJob = v.job as { carrying: Partial<Record<'stone', number>> }
mutableJob.carrying.stone = (mutableJob.carrying.stone ?? 0) + MINE_STONE_YIELD
rt.sprite.setVisible(true)
rt.nameLabel.setVisible(true)
rt.energyBar.setVisible(true)
rt.jobIcon.setVisible(true)
this.addLog(v.id, `✓ Mined (+${MINE_STONE_YIELD} stone) at mine`)
} else {
// Surface rock: ROCK tile stays ROCK after mining
const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
// and valid for mine building placement.
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
this.resourceSystem.removeResource(job.targetId)
this.addLog(v.id, '✓ Mined rock (+2 stone)')
}
}
} else if (job.type === 'farm') {
const crop = state.world.crops[job.targetId]
@@ -400,7 +467,7 @@ export class VillagerSystem {
const naturalChop: C[] = []
for (const res of resources) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
if (res.kind !== 'tree' || this.isTargetClaimed(res.id)) continue
// Skip trees with no reachable neighbour — A* cannot reach them.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
@@ -415,8 +482,15 @@ export class VillagerSystem {
}
if (p.mine > 0) {
// Mine buildings: walk to entrance tile (tileX+1, tileY+1) and work inside
for (const b of buildings) {
if (b.kind !== 'mine' || this.isTargetClaimed(b.id)) continue
const eTileX = b.tileX + 1, eTileY = b.tileY + 1
candidates.push({ type: 'mine', targetId: b.id, tileX: eTileX, tileY: eTileY, dist: dist(eTileX, eTileY), pri: p.mine })
}
// Surface rocks (still valid without a mine building)
for (const res of resources) {
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
if (res.kind !== 'rock' || this.isTargetClaimed(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 })
@@ -425,7 +499,7 @@ export class VillagerSystem {
if (p.farm > 0) {
for (const crop of crops) {
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
if (crop.stage < crop.maxStage || this.isTargetClaimed(crop.id)) continue
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
}
}
@@ -436,7 +510,7 @@ export class VillagerSystem {
for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number)
const targetId = `forester_tile_${tx}_${ty}`
if (this.claimed.has(targetId)) continue
if (this.isTargetClaimed(targetId)) continue
// Skip if tile is not plantable
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
if (!PLANTABLE_TILES.has(tileType)) continue
@@ -480,7 +554,7 @@ export class VillagerSystem {
if (!path) {
if (v.job) {
this.claimed.delete(v.job.targetId)
this.releaseTarget(v.job.targetId)
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
}
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
@@ -693,6 +767,33 @@ export class VillagerSystem {
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/**
* Rescues all Nisse that were working inside a demolished building.
* Makes hidden sprites visible again, clears their jobs, and resets AI to idle.
* Also releases any mine-capacity claims for that building.
* @param buildingId - ID of the building that was demolished
*/
rescueNisseFromBuilding(buildingId: string): void {
this.mineClaimsMap.delete(buildingId)
const state = stateManager.getState()
for (const v of Object.values(state.world.villagers)) {
if (v.job?.targetId !== buildingId) continue
const rt = this.runtime.get(v.id)
if (!rt) continue
// Make sprite visible in case the Nisse was hidden inside the mine
rt.sprite.setVisible(true)
rt.nameLabel.setVisible(true)
rt.energyBar.setVisible(true)
rt.jobIcon.setVisible(true)
rt.workTimer = 0
rt.idleScanTimer = 0
this.claimed.delete(buildingId)
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
this.addLog(v.id, '! Building demolished — resuming')
}
}
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.

View File

@@ -32,7 +32,7 @@ export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut'
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut' | 'mine'
export type CropKind = 'wheat' | 'carrot'
@@ -81,6 +81,8 @@ export interface BuildingState {
tileY: number
kind: BuildingType
ownerId: string
/** In-game time (ms) when the building was placed. Used for demolish refund calculation. */
builtAt: number
}
export interface CropState {