2026-03-20 08:11:31 +00:00
|
|
|
import Phaser from 'phaser'
|
|
|
|
|
import { TILE_SIZE, BUILDING_COSTS } from '../config'
|
|
|
|
|
import { TileType, IMPASSABLE } from '../types'
|
|
|
|
|
import type { BuildingType } from '../types'
|
|
|
|
|
import { stateManager } from '../StateManager'
|
|
|
|
|
import type { LocalAdapter } from '../NetworkAdapter'
|
|
|
|
|
|
|
|
|
|
const BUILDING_TILE: Partial<Record<BuildingType, TileType>> = {
|
|
|
|
|
floor: TileType.FLOOR,
|
|
|
|
|
wall: TileType.WALL,
|
|
|
|
|
chest: TileType.FLOOR, // chest placed on floor tile
|
|
|
|
|
// bed and stockpile_zone do NOT change the underlying tile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class BuildingSystem {
|
|
|
|
|
private scene: Phaser.Scene
|
|
|
|
|
private adapter: LocalAdapter
|
|
|
|
|
private active = false
|
|
|
|
|
private selectedBuilding: BuildingType = 'floor'
|
|
|
|
|
private ghost!: Phaser.GameObjects.Rectangle
|
|
|
|
|
private ghostLabel!: Phaser.GameObjects.Text
|
|
|
|
|
private buildKey!: Phaser.Input.Keyboard.Key
|
|
|
|
|
private cancelKey!: Phaser.Input.Keyboard.Key
|
|
|
|
|
|
|
|
|
|
onModeChange?: (active: boolean, building: BuildingType) => void
|
|
|
|
|
onPlaced?: (msg: string) => void
|
|
|
|
|
|
|
|
|
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
|
|
|
|
this.scene = scene
|
|
|
|
|
this.adapter = adapter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
create(): void {
|
|
|
|
|
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
2026-03-23 19:40:27 +00:00
|
|
|
this.ghost.setDepth(1000)
|
2026-03-20 08:11:31 +00:00
|
|
|
this.ghost.setVisible(false)
|
|
|
|
|
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
|
|
|
|
|
|
|
|
|
this.ghostLabel = this.scene.add.text(0, 0, '', {
|
|
|
|
|
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
|
|
|
|
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
|
|
|
|
})
|
2026-03-23 19:40:27 +00:00
|
|
|
this.ghostLabel.setDepth(1001)
|
2026-03-20 08:11:31 +00:00
|
|
|
this.ghostLabel.setVisible(false)
|
|
|
|
|
this.ghostLabel.setOrigin(0.5, 1)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
|
|
|
|
if (!this.active) return
|
|
|
|
|
if (ptr.rightButtonDown()) {
|
|
|
|
|
this.deactivate()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
this.tryPlace(ptr)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Select a building type and activate build mode */
|
|
|
|
|
selectBuilding(kind: BuildingType): void {
|
|
|
|
|
this.selectedBuilding = kind
|
|
|
|
|
this.activate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private activate(): void {
|
|
|
|
|
this.active = true
|
|
|
|
|
this.ghost.setVisible(true)
|
|
|
|
|
this.ghostLabel.setVisible(true)
|
|
|
|
|
this.onModeChange?.(true, this.selectedBuilding)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deactivate(): void {
|
|
|
|
|
this.active = false
|
|
|
|
|
this.ghost.setVisible(false)
|
|
|
|
|
this.ghostLabel.setVisible(false)
|
|
|
|
|
this.onModeChange?.(false, this.selectedBuilding)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isActive(): boolean { return this.active }
|
|
|
|
|
|
|
|
|
|
update(): void {
|
|
|
|
|
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
|
|
|
|
if (this.active) this.deactivate()
|
|
|
|
|
// If not active, UIScene opens build menu
|
|
|
|
|
}
|
|
|
|
|
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
|
|
|
|
this.deactivate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.active) return
|
|
|
|
|
|
|
|
|
|
// Update ghost to follow mouse (snapped to tile grid)
|
2026-03-20 12:19:57 +00:00
|
|
|
const ptr = this.scene.input.activePointer
|
|
|
|
|
const worldX = ptr.worldX
|
|
|
|
|
const worldY = ptr.worldY
|
2026-03-20 08:11:31 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
this.ghost.setPosition(snapX, snapY)
|
|
|
|
|
this.ghostLabel.setPosition(snapX, snapY - 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)
|
|
|
|
|
this.ghost.setStrokeStyle(2, color, 0.9)
|
|
|
|
|
|
|
|
|
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
|
|
|
|
const costStr = Object.entries(costs).map(([k, v]) => `${v}${k[0].toUpperCase()}`).join(' ')
|
|
|
|
|
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private canBuildAt(tileX: number, tileY: number): boolean {
|
|
|
|
|
const state = stateManager.getState()
|
|
|
|
|
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)
|
|
|
|
|
if (IMPASSABLE.has(tile)) return false
|
|
|
|
|
|
|
|
|
|
// Check no resource node on this tile
|
|
|
|
|
for (const res of Object.values(state.world.resources)) {
|
|
|
|
|
if (res.tileX === tileX && res.tileY === tileY) return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check no existing building of any kind on this tile
|
|
|
|
|
for (const b of Object.values(state.world.buildings)) {
|
|
|
|
|
if (b.tileX === tileX && b.tileY === tileY) return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check have enough resources
|
|
|
|
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
2026-03-20 12:19:57 +00:00
|
|
|
const worldX = ptr.worldX
|
|
|
|
|
const worldY = ptr.worldY
|
2026-03-20 08:11:31 +00:00
|
|
|
const tileX = Math.floor(worldX / TILE_SIZE)
|
|
|
|
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
|
|
|
|
|
|
|
|
|
if (!this.canBuildAt(tileX, tileY)) {
|
|
|
|
|
this.onPlaced?.('Cannot build here!')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
|
|
|
|
const building = {
|
|
|
|
|
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
|
|
|
|
tileX,
|
|
|
|
|
tileY,
|
|
|
|
|
kind: this.selectedBuilding,
|
|
|
|
|
ownerId: stateManager.getState().player.id,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
|
|
|
|
this.ghost.destroy()
|
|
|
|
|
this.ghostLabel.destroy()
|
|
|
|
|
}
|
|
|
|
|
}
|