Files
nissefolk/src/scenes/UIScene.ts
tekki mariani 969a82949e Försterkreislauf: Setzlinge beim Fällen, Försterhaus, Förster-Job
- Gefällter Baum → 1–2 tree_seed im Stockpile (zufällig)
- Neues Gebäude forester_hut (50 wood): Log-Hütten-Grafik, Klick öffnet Info-Panel
- Zonenmarkierung: Edit-Zone-Tool, Radius 5 Tiles, halbtransparente Overlay-Anzeige
- Neuer JobType 'forester': Nisse pflanzen Setzlinge auf markierten Zonen-Tiles
- Chop-Priorisierung: Zonen-Bäume werden vor natürlichen Bäumen gefällt
- Nisse-Panel & Info-Panel zeigen forester-Priorität-Button

Closes #25

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:07:36 +00:00

1190 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from 'phaser'
import type { BuildingType, JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem'
import type { DebugData } from '../systems/DebugSystem'
import { stateManager } from '../StateManager'
import { UI_SETTINGS_KEY } from '../config'
const ITEM_ICONS: Record<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
}
export class UIScene extends Phaser.Scene {
private stockpileTexts: Map<string, Phaser.GameObjects.Text> = new Map()
private stockpilePanel!: Phaser.GameObjects.Rectangle
private hintText!: Phaser.GameObjects.Text
private toastText!: Phaser.GameObjects.Text
private toastTimer = 0
private buildMenuGroup!: Phaser.GameObjects.Group
private buildMenuVisible = false
private villagerPanelGroup!: Phaser.GameObjects.Group
private villagerPanelVisible = false
private buildModeText!: Phaser.GameObjects.Text
private farmToolText!: Phaser.GameObjects.Text
private coordsText!: Phaser.GameObjects.Text
private controlsHintText!: Phaser.GameObjects.Text
private popText!: Phaser.GameObjects.Text
private stockpileTitleText!: Phaser.GameObjects.Text
private contextMenuGroup!: Phaser.GameObjects.Group
private contextMenuVisible = false
private inBuildMode = false
private inFarmMode = false
private debugPanelText!: Phaser.GameObjects.Text
private debugActive = false
private escMenuGroup!: Phaser.GameObjects.Group
private escMenuVisible = false
private confirmGroup!: Phaser.GameObjects.Group
private confirmVisible = false
private nisseInfoGroup!: Phaser.GameObjects.Group
private nisseInfoVisible = false
private nisseInfoId: string | null = null
private nisseInfoDynamic: {
statusText: Phaser.GameObjects.Text
energyBar: Phaser.GameObjects.Graphics
energyPct: Phaser.GameObjects.Text
jobText: Phaser.GameObjects.Text
logTexts: Phaser.GameObjects.Text[]
} | null = null
/** Current overlay background opacity (0.41.0, default 0.8). Persisted in localStorage. */
private uiOpacity = 0.8
private settingsGroup!: Phaser.GameObjects.Group
private settingsVisible = false
// ── Forester Hut Panel ────────────────────────────────────────────────────
private foresterPanelGroup!: Phaser.GameObjects.Group
private foresterPanelVisible = false
private foresterPanelBuildingId: string | null = null
/** Tile-count text inside the forester panel, updated live when zone changes. */
private foresterTileCountText: Phaser.GameObjects.Text | null = null
/** True while the zone-edit tool is active (shown in ESC priority stack). */
private inForesterZoneEdit = false
constructor() { super({ key: 'UI' }) }
/**
* Creates all HUD elements, wires up game scene events, and registers
* keyboard shortcuts (B, V, F3, ESC).
*/
create(): void {
this.loadUISettings()
this.createStockpilePanel()
this.createHintText()
this.createToast()
this.createBuildMenu()
this.createBuildModeIndicator()
this.createFarmToolIndicator()
this.createCoordsDisplay()
this.createDebugPanel()
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('cameraMoved', (pos: { tileX: number; tileY: number }) => this.onCameraMoved(pos))
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V)
.on('down', () => this.toggleVillagerPanel())
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F3)
.on('down', () => this.toggleDebugPanel())
this.scale.on('resize', () => this.repositionUI())
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.nisseInfoGroup = this.add.group()
this.settingsGroup = this.add.group()
this.foresterPanelGroup = this.add.group()
gameScene.events.on('foresterHutClicked', (id: string) => this.openForesterPanel(id))
gameScene.events.on('foresterZoneEditEnded', () => this.onForesterEditEnded())
gameScene.events.on('foresterZoneChanged', (id: string, tiles: string[]) => this.onForesterZoneChanged(id, tiles))
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) {
if (!this.inBuildMode && !this.inFarmMode && !this.buildMenuVisible && !this.villagerPanelVisible) {
this.showContextMenu(ptr.x, ptr.y)
}
} else if (this.contextMenuVisible) {
this.hideContextMenu()
}
})
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.handleEsc())
}
/**
* Updates the stockpile display, toast fade timer, population count,
* and the debug panel each frame.
* @param _t - Total elapsed time (unused)
* @param delta - Frame delta in milliseconds
*/
update(_t: number, delta: number): void {
this.updateStockpile()
this.updateToast(delta)
this.updatePopText()
if (this.debugActive) this.updateDebugPanel()
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
}
// ─── Stockpile ────────────────────────────────────────────────────────────
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
private createStockpilePanel(): void {
const x = this.scale.width - 178, y = 10
// 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const
items.forEach((item, i) => {
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
this.stockpileTexts.set(item, t)
})
// last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
this.popText = this.add.text(x + 10, y + 192, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
}
/** Refreshes all item quantities and colors in the stockpile panel. */
private updateStockpile(): void {
const sp = stateManager.getState().world.stockpile
for (const [item, t] of this.stockpileTexts) {
const qty = sp[item as keyof typeof sp] ?? 0
t.setStyle({ color: qty > 0 ? '#88dd88' : '#444444' })
t.setText(`${ITEM_ICONS[item]} ${item}: ${qty}`)
}
}
/** Updates the Nisse population / bed capacity counter. */
private updatePopText(): void {
const state = stateManager.getState()
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
const current = Object.keys(state.world.villagers).length
this.popText?.setText(`👥 Nisse: ${current} / ${beds} [V]`)
}
// ─── Hint ─────────────────────────────────────────────────────────────────
/** Creates the centered hint text element near the bottom of the screen. */
private createHintText(): void {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
backgroundColor: '#00000099', padding: { x: 10, y: 5 },
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false)
}
// ─── Toast ────────────────────────────────────────────────────────────────
/** Creates the toast notification text element (top center, initially hidden). */
private createToast(): void {
this.toastText = this.add.text(this.scale.width / 2, 60, '', {
fontSize: '15px', color: '#88ff88', fontFamily: 'monospace',
backgroundColor: '#00000099', padding: { x: 12, y: 6 },
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0)
}
/**
* Displays a toast message for 2.2 seconds then fades it out.
* @param msg - Message to display
*/
showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 }
/**
* Counts down the toast timer and triggers the fade-out tween when it expires.
* @param delta - Frame delta in milliseconds
*/
private updateToast(delta: number): void {
if (this.toastTimer <= 0) return
this.toastTimer -= delta
if (this.toastTimer <= 0) this.tweens.add({ targets: this.toastText, alpha: 0, duration: 400 })
}
// ─── Build Menu ───────────────────────────────────────────────────────────
/** Creates and hides the build menu with buttons for each available building type. */
private createBuildMenu(): void {
this.buildMenuGroup = this.add.group()
const buildings: { kind: BuildingType; label: string; cost: string }[] = [
{ kind: 'floor', label: 'Floor', cost: '2 wood' },
{ kind: 'wall', label: 'Wall', cost: '3 wood + 1 stone' },
{ kind: 'chest', label: 'Chest', cost: '5 wood + 2 stone' },
{ 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' },
]
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)
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))
buildings.forEach((b, i) => {
const btnY = menuY + 38 + i * 46
const btn = this.add.rectangle(menuX + 14, btnY, 272, 38, 0x1a3a1a, 0.9).setOrigin(0,0).setScrollFactor(0).setDepth(201).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d6a4f, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a3a1a, 0.9))
btn.on('pointerdown', () => { this.closeBuildMenu(); this.scene.get('Game').events.emit('selectBuilding', b.kind) })
this.buildMenuGroup.add(btn)
this.buildMenuGroup.add(this.add.text(menuX + 24, btnY + 5, b.label, { fontSize: '13px', color: '#ffffff', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(202))
this.buildMenuGroup.add(this.add.text(menuX + 24, btnY + 22, `Cost: ${b.cost}`, { fontSize: '10px', color: '#888888', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(202))
})
this.buildMenuGroup.setVisible(false)
}
/** Toggles the build menu open or closed. */
private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() }
/** Opens the build menu and notifies GameScene that a menu is active. */
private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') }
/** Closes the build menu and notifies GameScene that no menu is active. */
private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') }
// ─── Villager Panel (V key) ───────────────────────────────────────────────
/** Toggles the Nisse management panel open or closed. */
private toggleVillagerPanel(): void {
if (this.villagerPanelVisible) {
this.closeVillagerPanel()
} else {
this.openVillagerPanel()
}
}
/** Opens the Nisse panel, builds its contents, and notifies GameScene. */
private openVillagerPanel(): void {
this.villagerPanelVisible = true
this.buildVillagerPanel()
this.scene.get('Game').events.emit('uiMenuOpen')
}
/** Closes and destroys the Nisse panel and notifies GameScene. */
private closeVillagerPanel(): void {
this.villagerPanelVisible = false
this.villagerPanelGroup?.destroy(true)
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
* Destroys and rebuilds the Nisse panel from current state.
* Shows name, status, energy bar, and job priority buttons per Nisse.
*/
private buildVillagerPanel(): void {
if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true)
this.villagerPanelGroup = this.add.group()
const state = stateManager.getState()
const villagers = Object.values(state.world.villagers)
const panelW = 490
const rowH = 60
const panelH = Math.max(100, villagers.length * rowH + 50)
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
this.villagerPanelGroup.add(bg)
this.villagerPanelGroup.add(
this.add.text(px + panelW/2, py + 12, '👥 NISSE [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
.setOrigin(0.5, 0).setScrollFactor(0).setDepth(211)
)
if (villagers.length === 0) {
this.villagerPanelGroup.add(
this.add.text(px + panelW/2, py + panelH/2, 'No Nisse yet.\nBuild a 🛏 Bed first!', {
fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center'
}).setOrigin(0.5).setScrollFactor(0).setDepth(211)
)
}
villagers.forEach((v, i) => {
const ry = py + 38 + i * rowH
const gameScene = this.scene.get('Game') as any
// Name + status
const statusText = gameScene.villagerSystem?.getStatusText(v.id) ?? '—'
this.villagerPanelGroup.add(
this.add.text(px + 12, ry, `${v.name}`, { fontSize: '13px', color: '#ffffff', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(211)
)
this.villagerPanelGroup.add(
this.add.text(px + 12, ry + 16, statusText, { fontSize: '10px', color: '#888888', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(211)
)
// Energy bar
const eg = this.add.graphics().setScrollFactor(0).setDepth(211)
eg.fillStyle(0x333333); eg.fillRect(px + 12, ry + 30, 80, 6)
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
this.villagerPanelGroup.add(eg)
// Job priority buttons: chop / mine / farm / forester
const jobs: Array<{ key: keyof JobPriorities; label: string }> = [
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }, { key: 'forester', label: '🌲' }
]
jobs.forEach((job, ji) => {
const bx = px + 110 + ji * 76
const pri = v.priorities[job.key]
const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}`
const btn = this.add.text(bx, ry + 6, label, {
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a4a1a',
padding: { x: 6, y: 4 }
}).setScrollFactor(0).setDepth(212).setInteractive()
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a4a1a' }))
btn.on('pointerdown', () => {
const newPri = ((v.priorities[job.key] + 1) % 5) // 0→1→2→3→4→0
const newPriorities: JobPriorities = { ...v.priorities, [job.key]: newPri }
this.scene.get('Game').events.emit('updatePriorities', v.id, newPriorities)
this.closeVillagerPanel()
this.openVillagerPanel() // Rebuild to reflect change
})
this.villagerPanelGroup.add(btn)
})
})
}
// ─── Build mode indicator ─────────────────────────────────────────────────
/** Creates the build-mode indicator text in the top-left corner (initially hidden). */
private createBuildModeIndicator(): void {
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
}
/**
* Shows or hides the build-mode indicator based on whether build mode is active.
* @param active - Whether build mode is currently active
* @param building - The selected building type
*/
private onBuildModeChanged(active: boolean, building: BuildingType): void {
this.inBuildMode = active
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
}
// ─── Farm tool indicator ──────────────────────────────────────────────────
/** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */
private createFarmToolIndicator(): void {
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
}
/**
* Shows or hides the farm-tool indicator and updates the active tool label.
* @param tool - Currently selected farm tool
* @param label - Human-readable label for the tool
*/
private onFarmToolChanged(tool: FarmingTool, label: string): void {
this.inFarmMode = tool !== 'none'
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
}
// ─── Coords + controls ────────────────────────────────────────────────────
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
private createCoordsDisplay(): void {
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
}).setScrollFactor(0).setDepth(100)
}
/**
* Updates the tile-coordinate display when the camera moves.
* @param pos - Tile position of the camera center
*/
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
}
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */
private createDebugPanel(): void {
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText = this.add.text(10, 80, '', {
fontSize: '12px',
color: '#cccccc',
backgroundColor: `#000000${hexAlpha}`,
padding: { x: 8, y: 6 },
lineSpacing: 2,
fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(150).setVisible(false)
}
/** Toggles the debug panel and notifies GameScene to toggle the pathfinding overlay. */
private toggleDebugPanel(): void {
this.debugActive = !this.debugActive
this.debugPanelText.setVisible(this.debugActive)
this.scene.get('Game').events.emit('debugToggle')
}
/**
* Reads current debug data from DebugSystem and updates the panel text.
* Called every frame while debug mode is active.
*/
private updateDebugPanel(): void {
const gameScene = this.scene.get('Game') as any
const debugSystem = gameScene.debugSystem
if (!debugSystem?.isActive()) return
const ptr = this.input.activePointer
const data = debugSystem.getDebugData(ptr) as DebugData
const resLine = data.resourcesOnTile.length > 0
? data.resourcesOnTile.map(r => `${r.kind} (hp:${r.hp})`).join(', ')
: '—'
const bldLine = data.buildingsOnTile.length > 0 ? data.buildingsOnTile.join(', ') : '—'
const cropLine = data.cropsOnTile.length > 0
? data.cropsOnTile.map(c => `${c.kind} (${c.stage}/${c.maxStage})`).join(', ')
: '—'
const { idle, walking, working, sleeping } = data.nisseByState
const { chop, mine, farm } = data.jobsByType
this.debugPanelText.setText([
'── F3 DEBUG ──────────────────',
`FPS: ${data.fps}`,
'',
`Mouse world: ${data.mouseWorld.x.toFixed(1)}, ${data.mouseWorld.y.toFixed(1)}`,
`Mouse tile: ${data.mouseTile.tileX}, ${data.mouseTile.tileY}`,
`Tile type: ${data.tileType}`,
`Resources: ${resLine}`,
`Buildings: ${bldLine}`,
`Crops: ${cropLine}`,
'',
`Nisse: ${data.nisseTotal} total`,
` idle: ${idle} walking: ${walking} working: ${working} sleeping: ${sleeping}`,
'',
`Jobs active:`,
` chop: ${chop} mine: ${mine} farm: ${farm}`,
'',
`Paths: ${data.activePaths} (cyan lines in world)`,
'',
'[F3] close',
])
}
// ─── Context Menu ─────────────────────────────────────────────────────────
/**
* Shows the right-click context menu at the given screen coordinates.
* Any previously open context menu is closed first.
* @param x - Screen x position of the pointer
* @param y - Screen y position of the pointer
*/
private showContextMenu(x: number, y: number): void {
this.hideContextMenu()
const menuW = 150
const btnH = 32
const menuH = 8 + 2 * (btnH + 6) - 6 + 8
const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg)
const entries: { label: string; action: () => void }[] = [
{
label: '🏗 Build',
action: () => { this.hideContextMenu(); this.scene.get('Game').events.emit('uiRequestBuildMenu') },
},
{
label: '👥 Nisse',
action: () => { this.hideContextMenu(); this.toggleVillagerPanel() },
},
]
entries.forEach((entry, i) => {
const by = my + 8 + i * (btnH + 6)
const btn = this.add.rectangle(mx + 8, by, menuW - 16, btnH, 0x1a3a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d6a4f, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a3a1a, 0.9))
btn.on('pointerdown', entry.action)
this.contextMenuGroup.add(btn)
this.contextMenuGroup.add(
this.add.text(mx + 16, by + btnH / 2, entry.label, {
fontSize: '13px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(302)
)
})
this.contextMenuVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
}
/**
* Closes and destroys the context menu if it is currently visible.
*/
private hideContextMenu(): void {
if (!this.contextMenuVisible) return
this.contextMenuGroup.destroy(true)
this.contextMenuGroup = this.add.group()
this.contextMenuVisible = false
this.scene.get('Game').events.emit('uiMenuClose')
}
// ─── ESC key handler ──────────────────────────────────────────────────────
/**
* Handles ESC key presses with a priority stack:
* confirm dialog → context menu → build menu → villager panel →
* esc menu → build/farm mode (handled by their own systems) → open ESC menu.
*/
private handleEsc(): void {
if (this.confirmVisible) { this.hideConfirm(); return }
if (this.inForesterZoneEdit) { this.scene.get('Game').events.emit('foresterZoneEditStop'); return }
if (this.foresterPanelVisible) { this.closeForesterPanel(); return }
if (this.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
if (this.settingsVisible) { this.closeSettings(); return }
if (this.escMenuVisible) { this.closeEscMenu(); return }
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
// We only skip opening the ESC menu while those modes are active.
if (this.inBuildMode || this.inFarmMode) return
this.openEscMenu()
}
// ─── ESC Menu ─────────────────────────────────────────────────────────────
/** Opens the ESC pause menu (New Game / Save / Load / Settings). */
private openEscMenu(): void {
if (this.escMenuVisible) return
this.escMenuVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildEscMenu()
}
/** Closes and destroys the ESC menu. */
private closeEscMenu(): void {
if (!this.escMenuVisible) return
this.escMenuVisible = false
this.escMenuGroup.destroy(true)
this.escMenuGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/** Builds the ESC menu UI elements. */
private buildEscMenu(): void {
if (this.escMenuGroup) this.escMenuGroup.destroy(true)
this.escMenuGroup = this.add.group()
const menuW = 240
const btnH = 40
const entries: { label: string; action: () => void }[] = [
{ label: '💾 Save Game', action: () => this.doSaveGame() },
{ label: '📂 Load Game', action: () => this.doLoadGame() },
{ label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() },
]
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
const menuH = 32 + entries.length * (btnH + 8) + 8
const mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 2
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
this.escMenuGroup.add(bg)
this.escMenuGroup.add(
this.add.text(mx + menuW / 2, my + 12, 'MENU [ESC] close', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
)
entries.forEach((entry, i) => {
const by = my + 32 + i * (btnH + 8)
const btn = this.add.rectangle(mx + 12, by, menuW - 24, btnH, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(401).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2a2a4e, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a1a2e, 0.9))
btn.on('pointerdown', entry.action)
this.escMenuGroup.add(btn)
this.escMenuGroup.add(
this.add.text(mx + 24, by + btnH / 2, entry.label, {
fontSize: '14px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
)
})
}
/** Saves the game and shows a toast confirmation. */
private doSaveGame(): void {
stateManager.save()
this.closeEscMenu()
this.showToast('Game saved!')
}
/** Reloads the page to load the last save from localStorage. */
private doLoadGame(): void {
this.closeEscMenu()
window.location.reload()
}
/** Opens the Settings overlay. */
private doSettings(): void {
this.closeEscMenu()
this.openSettings()
}
// ─── Settings overlay ─────────────────────────────────────────────────────
/** Opens the settings overlay if it is not already open. */
private openSettings(): void {
if (this.settingsVisible) return
this.settingsVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
this.buildSettings()
}
/** Closes and destroys the settings overlay. */
private closeSettings(): void {
if (!this.settingsVisible) return
this.settingsVisible = false
this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
/**
* Builds the settings overlay with an overlay-opacity row (step buttons).
* Destroying and recreating this method is used to refresh the displayed value.
*/
private buildSettings(): void {
if (this.settingsGroup) this.settingsGroup.destroy(true)
this.settingsGroup = this.add.group()
const panelW = 280
const panelH = 130
const px = this.scale.width / 2 - panelW / 2
const py = this.scale.height / 2 - panelH / 2
// Background
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
this.settingsGroup.add(bg)
// Title
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
)
// Opacity label
this.settingsGroup.add(
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
)
// Minus button
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
minusBtn.on('pointerdown', () => {
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(minusBtn)
this.settingsGroup.add(
this.add.text(px + 183, py + 58, '', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Value display
this.settingsGroup.add(
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
)
// Plus button
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
plusBtn.on('pointerdown', () => {
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
this.saveUISettings()
this.updateStaticPanelOpacity()
this.buildSettings()
})
this.settingsGroup.add(plusBtn)
this.settingsGroup.add(
this.add.text(px + 255, py + 58, '+', {
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
// Close button
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
closeBtnRect.on('pointerdown', () => this.closeSettings())
this.settingsGroup.add(closeBtnRect)
this.settingsGroup.add(
this.add.text(px + panelW / 2, py + 106, 'Close', {
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
)
}
/**
* Loads UI settings from localStorage and applies the stored opacity value.
* Falls back to the default (0.8) if no setting is found.
*/
private loadUISettings(): void {
try {
const raw = localStorage.getItem(UI_SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { opacity?: number }
if (typeof parsed.opacity === 'number') {
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
}
}
} catch (_) {}
}
/**
* Persists the current UI settings (opacity) to localStorage.
* Stored separately from the game save so New Game does not wipe it.
*/
private saveUISettings(): void {
try {
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
} catch (_) {}
}
/**
* Applies the current uiOpacity to all static UI elements that are not
* rebuilt on open (stockpile panel, debug panel background).
* Called whenever uiOpacity changes.
*/
private updateStaticPanelOpacity(): void {
this.stockpilePanel.setAlpha(this.uiOpacity)
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
}
/** Shows a confirmation dialog before starting a new game. */
private doNewGame(): void {
this.closeEscMenu()
this.showConfirm(
'Start a new game?\nAll progress will be lost.',
() => { stateManager.reset(); window.location.reload() },
)
}
// ─── Confirm dialog ───────────────────────────────────────────────────────
/**
* Shows a modal confirmation dialog with OK and Cancel buttons.
* @param message - Message to display (newlines supported)
* @param onConfirm - Callback invoked when the user confirms
*/
private showConfirm(message: string, onConfirm: () => void): void {
this.hideConfirm()
this.confirmVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
const dialogW = 280
const dialogH = 130
const dx = this.scale.width / 2 - dialogW / 2
const dy = this.scale.height / 2 - dialogH / 2
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
this.confirmGroup.add(bg)
this.confirmGroup.add(
this.add.text(dx + dialogW / 2, dy + 20, message, {
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
align: 'center', wordWrap: { width: dialogW - 32 },
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(501)
)
const btnY = dy + dialogH - 44
// Cancel button
const cancelBtn = this.add.rectangle(dx + 16, btnY, 110, 30, 0x333333, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
cancelBtn.on('pointerover', () => cancelBtn.setFillStyle(0x555555, 0.9))
cancelBtn.on('pointerout', () => cancelBtn.setFillStyle(0x333333, 0.9))
cancelBtn.on('pointerdown', () => this.hideConfirm())
this.confirmGroup.add(cancelBtn)
this.confirmGroup.add(
this.add.text(dx + 71, btnY + 15, 'Cancel', {
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
)
// OK button
const okBtn = this.add.rectangle(dx + dialogW - 126, btnY, 110, 30, 0x4a1a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
okBtn.on('pointerover', () => okBtn.setFillStyle(0x8a2a2a, 0.9))
okBtn.on('pointerout', () => okBtn.setFillStyle(0x4a1a1a, 0.9))
okBtn.on('pointerdown', () => { this.hideConfirm(); onConfirm() })
this.confirmGroup.add(okBtn)
this.confirmGroup.add(
this.add.text(dx + dialogW - 71, btnY + 15, 'OK', {
fontSize: '13px', color: '#ff8888', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
)
}
/** Closes and destroys the confirmation dialog. */
private hideConfirm(): void {
if (!this.confirmVisible) return
this.confirmVisible = false
this.confirmGroup.destroy(true)
this.confirmGroup = this.add.group()
this.scene.get('Game').events.emit('uiMenuClose')
}
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
/**
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
* If another Nisse's panel is already open, it is replaced.
* @param villagerId - ID of the Nisse to display
*/
private openNisseInfoPanel(villagerId: string): void {
this.nisseInfoId = villagerId
this.nisseInfoVisible = true
this.buildNisseInfoPanel()
}
/** Closes and destroys the Nisse info panel. */
private closeNisseInfoPanel(): void {
if (!this.nisseInfoVisible) return
this.nisseInfoVisible = false
this.nisseInfoId = null
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
}
/**
* Builds the static skeleton of the Nisse info panel (background, name, close
* button, labels, priority buttons) and stores references to the dynamic parts
* (status text, energy bar, job text, work log texts).
*/
private buildNisseInfoPanel(): void {
this.nisseInfoGroup.destroy(true)
this.nisseInfoGroup = this.add.group()
this.nisseInfoDynamic = null
const id = this.nisseInfoId
if (!id) return
const state = stateManager.getState()
const v = state.world.villagers[id]
if (!v) { this.closeNisseInfoPanel(); return }
const LOG_ROWS = 10
const panelW = 280
const panelH = 120 + LOG_ROWS * 14 + 16
const px = 10, py = 10
// Background
this.nisseInfoGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Name
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 10, v.name, {
fontSize: '14px', color: '#ffffff', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeNisseInfoPanel())
this.nisseInfoGroup.add(closeBtn)
// Dynamic: status text
const statusTxt = this.add.text(px + 10, py + 28, '', {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(statusTxt)
// Dynamic: energy bar + pct
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyBar)
const energyPct = this.add.text(px + 136, py + 46, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(energyPct)
// Dynamic: job text
const jobTxt = this.add.text(px + 10, py + 60, '', {
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(jobTxt)
// Static: priority label + buttons
const jobKeys: Array<{ key: string; icon: string }> = [
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, { key: 'forester', icon: '🌲' },
]
jobKeys.forEach((j, i) => {
const pri = v.priorities[j.key as keyof typeof v.priorities]
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
const bx = px + 10 + i * 66
const btn = this.add.text(bx, py + 78, label, {
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
padding: { x: 5, y: 3 },
}).setScrollFactor(0).setDepth(252).setInteractive()
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' }))
btn.on('pointerdown', () => {
const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5
const newPriorities = { ...v.priorities, [j.key]: newPri }
this.scene.get('Game').events.emit('updatePriorities', id, newPriorities)
// Rebuild panel so priority buttons reflect the new values immediately
this.buildNisseInfoPanel()
})
this.nisseInfoGroup.add(btn)
})
// Static: work log header
this.nisseInfoGroup.add(
this.add.text(px + 10, py + 98, '── Work Log ──────────────────', {
fontSize: '10px', color: '#555555', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Dynamic: log text rows (pre-allocated)
const logTexts: Phaser.GameObjects.Text[] = []
for (let i = 0; i < LOG_ROWS; i++) {
const t = this.add.text(px + 10, py + 112 + i * 14, '', {
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.nisseInfoGroup.add(t)
logTexts.push(t)
}
this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts }
this.refreshNisseInfoPanel()
}
/**
* Updates only the dynamic parts of the Nisse info panel (status, energy,
* job, work log) without destroying and recreating the full group.
* Called every frame while the panel is visible.
*/
private refreshNisseInfoPanel(): void {
const dyn = this.nisseInfoDynamic
if (!dyn || !this.nisseInfoId) return
const state = stateManager.getState()
const v = state.world.villagers[this.nisseInfoId]
if (!v) { this.closeNisseInfoPanel(); return }
const gameScene = this.scene.get('Game') as any
const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[]
const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string
dyn.statusText.setText(statusStr)
// Energy bar
const px = 10, py = 10
dyn.energyBar.clear()
dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7)
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7)
dyn.energyPct.setText(`${Math.round(v.energy)}%`)
// Job
dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`)
// Work log rows
dyn.logTexts.forEach((t, i) => {
t.setText(workLog[i] ?? '')
})
}
// ─── Forester Hut Panel ───────────────────────────────────────────────────
/**
* Opens the forester hut info panel for the given building.
* If another forester panel is open it is replaced.
* @param buildingId - ID of the clicked forester_hut
*/
private openForesterPanel(buildingId: string): void {
this.foresterPanelBuildingId = buildingId
this.foresterPanelVisible = true
this.buildForesterPanel()
}
/** Closes and destroys the forester hut panel and exits zone edit mode if active. */
private closeForesterPanel(): void {
if (!this.foresterPanelVisible) return
if (this.inForesterZoneEdit) {
this.scene.get('Game').events.emit('foresterZoneEditStop')
}
this.foresterPanelVisible = false
this.foresterPanelBuildingId = null
this.foresterTileCountText = null
this.foresterPanelGroup.destroy(true)
this.foresterPanelGroup = this.add.group()
}
/**
* Builds the forester hut panel showing zone tile count and an edit-zone button.
* Positioned in the top-left corner (similar to the Nisse info panel).
*/
private buildForesterPanel(): void {
this.foresterPanelGroup.destroy(true)
this.foresterPanelGroup = this.add.group()
this.foresterTileCountText = null
const id = this.foresterPanelBuildingId
if (!id) return
const state = stateManager.getState()
const building = state.world.buildings[id]
if (!building) { this.closeForesterPanel(); return }
const zone = state.world.foresterZones[id]
const tileCount = zone?.tiles.length ?? 0
const panelW = 240
const panelH = 100
const px = 10, py = 10
// Background
this.foresterPanelGroup.add(
this.add.rectangle(px, py, panelW, panelH, 0x030a03, this.uiOpacity)
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
)
// Title
this.foresterPanelGroup.add(
this.add.text(px + 10, py + 10, '🌲 FORESTER HUT', {
fontSize: '13px', color: '#88dd88', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
)
// Close button
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
closeBtn.on('pointerdown', () => this.closeForesterPanel())
this.foresterPanelGroup.add(closeBtn)
// Zone tile count (dynamic — updated via onForesterZoneChanged)
const countTxt = this.add.text(px + 10, py + 32, `Zone: ${tileCount} tile${tileCount === 1 ? '' : 's'} marked`, {
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
}).setScrollFactor(0).setDepth(251)
this.foresterPanelGroup.add(countTxt)
this.foresterTileCountText = countTxt
// Edit zone button
const editLabel = this.inForesterZoneEdit ? '✅ Done editing' : '✏️ Edit Zone'
const editBtn = this.add.rectangle(px + 10, py + 54, panelW - 20, 30, 0x1a3a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(251).setInteractive()
editBtn.on('pointerover', () => editBtn.setFillStyle(0x2d6a4f, 0.9))
editBtn.on('pointerout', () => editBtn.setFillStyle(0x1a3a1a, 0.9))
editBtn.on('pointerdown', () => {
if (this.inForesterZoneEdit) {
this.scene.get('Game').events.emit('foresterZoneEditStop')
} else {
this.inForesterZoneEdit = true
this.scene.get('Game').events.emit('foresterZoneEditStart', id)
// Rebuild panel to show "Done editing" button
this.buildForesterPanel()
}
})
this.foresterPanelGroup.add(editBtn)
this.foresterPanelGroup.add(
this.add.text(px + panelW / 2, py + 69, editLabel, {
fontSize: '12px', color: '#dddddd', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(252)
)
}
/**
* Called when the ForesterZoneSystem signals that zone editing ended
* (via right-click, ESC, or the "Done" button).
*/
private onForesterEditEnded(): void {
this.inForesterZoneEdit = false
// Rebuild panel to switch button back to "Edit Zone"
if (this.foresterPanelVisible) this.buildForesterPanel()
}
/**
* Called when the zone tiles change so we can update the tile-count text live.
* @param buildingId - Building whose zone changed
* @param tiles - Updated tile array
*/
private onForesterZoneChanged(buildingId: string, tiles: string[]): void {
if (buildingId !== this.foresterPanelBuildingId) return
if (this.foresterTileCountText) {
const n = tiles.length
this.foresterTileCountText.setText(`Zone: ${n} tile${n === 1 ? '' : 's'} marked`)
}
}
// ─── Resize ───────────────────────────────────────────────────────────────
/**
* Repositions all fixed UI elements after a canvas resize.
* Open overlay panels are closed so they reopen correctly centered.
*/
private repositionUI(): void {
const { width, height } = this.scale
// Stockpile panel — anchored to top-right; move all elements by the delta
const newPanelX = width - 178
const deltaX = newPanelX - this.stockpilePanel.x
if (deltaX !== 0) {
this.stockpilePanel.setX(newPanelX)
this.stockpileTitleText.setX(this.stockpileTitleText.x + deltaX)
this.stockpileTexts.forEach(t => t.setX(t.x + deltaX))
this.popText.setX(this.popText.x + deltaX)
}
// Bottom elements
this.hintText.setPosition(width / 2, height - 40)
this.toastText.setPosition(width / 2, 60)
this.coordsText.setPosition(10, height - 24)
this.controlsHintText.setPosition(10, height - 42)
// Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize
if (this.buildMenuVisible) this.closeBuildMenu()
if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu()
if (this.settingsVisible) this.closeSettings()
if (this.confirmVisible) this.hideConfirm()
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
if (this.foresterPanelVisible) this.closeForesterPanel()
}
}