- 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>
1190 lines
51 KiB
TypeScript
1190 lines
51 KiB
TypeScript
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.4–1.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()
|
||
}
|
||
}
|