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 = { wood: '๐Ÿชต', stone: '๐Ÿชจ', wheat_seed: '๐ŸŒฑ', carrot_seed: '๐Ÿฅ•', wheat: '๐ŸŒพ', carrot: '๐Ÿงก', tree_seed: '๐ŸŒฒ', } export class UIScene extends Phaser.Scene { private stockpileTexts: Map = 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() } }