Merge pull request '✨ Demolish Mode — Gebäude abreißen (Issue #50)' (#55) from feature/demolish-buildings into master
This commit is contained in:
@@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- **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
|
### Fixed
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ class StateManager {
|
|||||||
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
|
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
|
||||||
|
|
||||||
case 'PLACE_BUILDING': {
|
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))
|
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))
|
w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0))
|
||||||
if (action.building.kind === 'forester_hut') {
|
if (action.building.kind === 'forester_hut') {
|
||||||
@@ -415,6 +415,8 @@ class StateManager {
|
|||||||
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
||||||
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
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
|
return p
|
||||||
} catch (_) { return null }
|
} catch (_) { return null }
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const VILLAGER_NAMES = [
|
|||||||
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
'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 SAVE_KEY = 'tg_save_v5'
|
||||||
export const AUTOSAVE_INTERVAL = 30_000
|
export const AUTOSAVE_INTERVAL = 30_000
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,31 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit('toast', msg)
|
this.events.emit('toast', msg)
|
||||||
this.renderPersistentObjects()
|
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.create()
|
||||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
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
|
// Detect left-clicks on forester huts to open the zone panel
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown() || this.menuOpen) return
|
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 tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
@@ -116,7 +141,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.scene.launch('UI')
|
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('uiMenuOpen', () => { this.menuOpen = true })
|
||||||
this.events.on('uiMenuClose', () => { this.menuOpen = false })
|
this.events.on('uiMenuClose', () => { this.menuOpen = false })
|
||||||
this.events.on('uiRequestBuildMenu', () => {
|
this.events.on('uiRequestBuildMenu', () => {
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private actionBuildLabel!: Phaser.GameObjects.Text
|
private actionBuildLabel!: Phaser.GameObjects.Text
|
||||||
private actionNisseBtn!: Phaser.GameObjects.Rectangle
|
private actionNisseBtn!: Phaser.GameObjects.Rectangle
|
||||||
private actionNisseLabel!: Phaser.GameObjects.Text
|
private actionNisseLabel!: Phaser.GameObjects.Text
|
||||||
|
private actionDemolishBtn!: Phaser.GameObjects.Rectangle
|
||||||
|
private actionDemolishLabel!: Phaser.GameObjects.Text
|
||||||
private actionTrayGroup!: Phaser.GameObjects.Group
|
private actionTrayGroup!: Phaser.GameObjects.Group
|
||||||
private actionTrayVisible = false
|
private actionTrayVisible = false
|
||||||
private activeCategory: 'build' | 'nisse' | null = null
|
private activeCategory: 'build' | 'nisse' | 'demolish' | null = null
|
||||||
|
|
||||||
constructor() { super({ key: 'UI' }) }
|
constructor() { super({ key: 'UI' }) }
|
||||||
|
|
||||||
@@ -89,10 +91,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.createActionBar()
|
this.createActionBar()
|
||||||
|
|
||||||
const gameScene = this.scene.get('Game')
|
const gameScene = this.scene.get('Game')
|
||||||
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
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('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
|
||||||
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
||||||
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
|
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)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||||
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
||||||
@@ -563,9 +571,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||||
if (this.settingsVisible) { this.closeSettings(); return }
|
if (this.settingsVisible) { this.closeSettings(); return }
|
||||||
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||||
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
// Build/farm/demolish mode: let their systems handle ESC. Skip opening the ESC menu.
|
||||||
// We only skip opening the ESC menu while those modes are active.
|
|
||||||
if (this.inBuildMode || this.inFarmMode) return
|
if (this.inBuildMode || this.inFarmMode) return
|
||||||
|
if (this.activeCategory === 'demolish') { this.deactivateCategory(); return }
|
||||||
this.openEscMenu()
|
this.openEscMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,14 +1223,28 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
|
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
|
||||||
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
}).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.
|
* Toggles the given action bar category on or off.
|
||||||
* Selecting the active category deselects it; selecting a new one closes the previous.
|
* 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) {
|
if (this.activeCategory === cat) {
|
||||||
this.deactivateCategory()
|
this.deactivateCategory()
|
||||||
return
|
return
|
||||||
@@ -1230,14 +1252,17 @@ export class UIScene extends Phaser.Scene {
|
|||||||
// Close whatever was open before
|
// Close whatever was open before
|
||||||
if (this.activeCategory === 'build') this.closeActionTray()
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
|
||||||
|
|
||||||
this.activeCategory = cat
|
this.activeCategory = cat
|
||||||
this.updateCategoryHighlights()
|
this.updateCategoryHighlights()
|
||||||
|
|
||||||
if (cat === 'build') {
|
if (cat === 'build') {
|
||||||
this.openActionTray()
|
this.openActionTray()
|
||||||
} else {
|
} else if (cat === 'nisse') {
|
||||||
this.openVillagerPanel()
|
this.openVillagerPanel()
|
||||||
|
} else {
|
||||||
|
this.scene.get('Game').events.emit('activateDemolish')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,17 +1272,19 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private deactivateCategory(): void {
|
private deactivateCategory(): void {
|
||||||
if (this.activeCategory === 'build') this.closeActionTray()
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
|
||||||
this.activeCategory = null
|
this.activeCategory = null
|
||||||
this.updateCategoryHighlights()
|
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.
|
* to reflect the current active category.
|
||||||
*/
|
*/
|
||||||
private updateCategoryHighlights(): void {
|
private updateCategoryHighlights(): void {
|
||||||
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
|
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
|
||||||
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, 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.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
|
||||||
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
|
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()
|
if (this.actionTrayVisible) this.closeActionTray()
|
||||||
// Close centered panels — their position is calculated on open, so they
|
// Close centered panels — their position is calculated on open, so they
|
||||||
// would be off-center if left open during a resize
|
// would be off-center if left open during a resize
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Phaser from 'phaser'
|
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 { TileType, IMPASSABLE } from '../types'
|
||||||
import type { BuildingType, BuildingState } from '../types'
|
import type { BuildingType, BuildingState, ItemId } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import type { LocalAdapter } from '../NetworkAdapter'
|
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
|
// 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 {
|
export class BuildingSystem {
|
||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private adapter: LocalAdapter
|
private adapter: LocalAdapter
|
||||||
private active = false
|
private active = false
|
||||||
|
private demolishActive = false
|
||||||
private selectedBuilding: BuildingType = 'floor'
|
private selectedBuilding: BuildingType = 'floor'
|
||||||
private ghost!: Phaser.GameObjects.Rectangle
|
private ghost!: Phaser.GameObjects.Rectangle
|
||||||
private ghostLabel!: Phaser.GameObjects.Text
|
private ghostLabel!: Phaser.GameObjects.Text
|
||||||
@@ -24,12 +32,23 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
onModeChange?: (active: boolean, building: BuildingType) => void
|
onModeChange?: (active: boolean, building: BuildingType) => void
|
||||||
onPlaced?: (msg: string) => 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) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises ghost sprite, label, and keyboard/pointer handlers for
|
||||||
|
* both build mode and demolish mode.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
||||||
this.ghost.setDepth(1000)
|
this.ghost.setDepth(1000)
|
||||||
@@ -47,14 +66,15 @@ export class BuildingSystem {
|
|||||||
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||||
this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
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) => {
|
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (!this.active) return
|
|
||||||
if (ptr.rightButtonDown()) {
|
if (ptr.rightButtonDown()) {
|
||||||
this.deactivate()
|
if (this.active) this.deactivate()
|
||||||
|
if (this.demolishActive) this.deactivateDemolish()
|
||||||
return
|
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 }]
|
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 */
|
/** Select a building type and activate build mode */
|
||||||
selectBuilding(kind: BuildingType): void {
|
selectBuilding(kind: BuildingType): void {
|
||||||
|
if (this.demolishActive) this.deactivateDemolish()
|
||||||
this.selectedBuilding = kind
|
this.selectedBuilding = kind
|
||||||
const { w, h } = this.getFootprint(kind)
|
const { w, h } = this.getFootprint(kind)
|
||||||
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
|
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
|
||||||
@@ -109,6 +143,37 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
isActive(): boolean { return this.active }
|
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 {
|
update(): void {
|
||||||
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
||||||
if (this.active) this.deactivate()
|
if (this.active) this.deactivate()
|
||||||
@@ -116,16 +181,23 @@ export class BuildingSystem {
|
|||||||
}
|
}
|
||||||
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
||||||
this.deactivate()
|
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 ptr = this.scene.input.activePointer
|
||||||
const worldX = ptr.worldX
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
const worldY = ptr.worldY
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
|
||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
|
||||||
const { w, h } = this.getFootprint(this.selectedBuilding)
|
const { w, h } = this.getFootprint(this.selectedBuilding)
|
||||||
const snapX = tileX * TILE_SIZE + (w * TILE_SIZE) / 2
|
const snapX = tileX * TILE_SIZE + (w * TILE_SIZE) / 2
|
||||||
const snapY = tileY * TILE_SIZE + (h * 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.ghost.setPosition(snapX, snapY)
|
||||||
this.ghostLabel.setPosition(snapX, snapY - (h * 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 canBuild = this.canBuildAt(tileX, tileY)
|
||||||
const color = canBuild ? 0x00FF00 : 0xFF4444
|
const color = canBuild ? 0x00FF00 : 0xFF4444
|
||||||
this.ghost.setFillStyle(color, 0.35)
|
this.ghost.setFillStyle(color, 0.35)
|
||||||
@@ -144,6 +215,55 @@ export class BuildingSystem {
|
|||||||
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
|
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 {
|
private canBuildAt(tileX: number, tileY: number): boolean {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
@@ -217,17 +337,19 @@ export class BuildingSystem {
|
|||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
|
|
||||||
if (!this.canBuildAt(tileX, tileY)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||||
const building = {
|
const building: BuildingState = {
|
||||||
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
||||||
tileX,
|
tileX,
|
||||||
tileY,
|
tileY,
|
||||||
kind: this.selectedBuilding,
|
kind: this.selectedBuilding,
|
||||||
ownerId: stateManager.getState().player.id,
|
ownerId: stateManager.getState().player.id,
|
||||||
|
builtAt: stateManager.getGameTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
|
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
|
||||||
@@ -242,6 +364,65 @@ export class BuildingSystem {
|
|||||||
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
|
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 {
|
destroy(): void {
|
||||||
this.ghost.destroy()
|
this.ghost.destroy()
|
||||||
this.ghostLabel.destroy()
|
this.ghostLabel.destroy()
|
||||||
|
|||||||
@@ -767,6 +767,33 @@ export class VillagerSystem {
|
|||||||
* Destroys all Nisse sprites and clears the runtime map.
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
* Should be called when the scene shuts down.
|
* 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.
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
* Should be called when the scene shuts down.
|
* Should be called when the scene shuts down.
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export interface BuildingState {
|
|||||||
tileY: number
|
tileY: number
|
||||||
kind: BuildingType
|
kind: BuildingType
|
||||||
ownerId: string
|
ownerId: string
|
||||||
|
/** In-game time (ms) when the building was placed. Used for demolish refund calculation. */
|
||||||
|
builtAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropState {
|
export interface CropState {
|
||||||
|
|||||||
Reference in New Issue
Block a user