From fc10201469b6e0bdc3ceba343f06d8ee2055ffef Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Tue, 24 Mar 2026 20:18:49 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20demolish=20mode=20for=20build?= =?UTF-8?q?ings=20(Issue=20#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 1 + src/StateManager.ts | 4 +- src/config.ts | 3 + src/scenes/GameScene.ts | 31 ++++- src/scenes/UIScene.ts | 53 +++++++-- src/systems/BuildingSystem.ts | 211 +++++++++++++++++++++++++++++++--- src/systems/VillagerSystem.ts | 27 +++++ src/types.ts | 2 + 8 files changed, 302 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f8f53..927fcf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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 diff --git a/src/StateManager.ts b/src/StateManager.ts index 3c0d571..7a87619 100644 --- a/src/StateManager.ts +++ b/src/StateManager.ts @@ -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 } diff --git a/src/config.ts b/src/config.ts index 7b23952..5a1862f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,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 diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 210086e..94ccff1 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -64,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) @@ -102,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() @@ -116,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', () => { diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index bf87497..c756c76 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -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')) @@ -563,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() } @@ -1215,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 @@ -1230,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') } } @@ -1247,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) } /** @@ -1352,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 diff --git a/src/systems/BuildingSystem.ts b/src/systems/BuildingSystem.ts index 0065dc5..908ed5f 100644 --- a/src/systems/BuildingSystem.ts +++ b/src/systems/BuildingSystem.ts @@ -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, BuildingState } 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> = { // 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> = { + 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>) => 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,14 +66,15 @@ 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) }) } @@ -85,8 +105,22 @@ export class BuildingSystem { 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) @@ -109,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() @@ -116,16 +181,23 @@ 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 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 @@ -133,7 +205,6 @@ export class BuildingSystem { this.ghost.setPosition(snapX, snapY) 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) @@ -144,6 +215,55 @@ 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 (0–1) 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() @@ -217,17 +337,19 @@ 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 }) @@ -242,6 +364,65 @@ export class BuildingSystem { 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> = {} + 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() diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 50ffdd2..287b2d7 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -767,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. diff --git a/src/types.ts b/src/types.ts index d90bc17..691f4bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {