import Phaser from 'phaser' import type { BuildingType, JobPriorities } from '../types' import type { FarmingTool } from '../systems/FarmingSystem' import { stateManager } from '../StateManager' const ITEM_ICONS: Record = { wood: '๐Ÿชต', stone: '๐Ÿชจ', wheat_seed: '๐ŸŒฑ', carrot_seed: '๐Ÿฅ•', wheat: '๐ŸŒพ', carrot: '๐Ÿงก', } 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 constructor() { super({ key: 'UI' }) } create(): void { this.createStockpilePanel() this.createHintText() this.createToast() this.createBuildMenu() this.createBuildModeIndicator() this.createFarmToolIndicator() this.createCoordsDisplay() 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.scale.on('resize', () => this.repositionUI()) this.input.mouse!.disableContextMenu() this.contextMenuGroup = this.add.group() 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.hideContextMenu()) } update(_t: number, delta: number): void { this.updateStockpile() this.updateToast(delta) this.updatePopText() } // โ”€โ”€โ”€ Stockpile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private createStockpilePanel(): void { const x = this.scale.width - 178, y = 10 this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).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','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) }) this.popText = this.add.text(x + 10, y + 145, '๐Ÿ‘ฅ Pop: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) } 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}`) } } 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(`๐Ÿ‘ฅ Pop: ${current} / ${beds} [V] manage`) } // โ”€โ”€โ”€ Hint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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) } showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 } 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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)' }, ] const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140 const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).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) } private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() } private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') } private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') } // โ”€โ”€โ”€ Villager Panel (V key) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private toggleVillagerPanel(): void { if (this.villagerPanelVisible) { this.closeVillagerPanel() } else { this.openVillagerPanel() } } private openVillagerPanel(): void { this.villagerPanelVisible = true this.buildVillagerPanel() this.scene.get('Game').events.emit('uiMenuOpen') } private closeVillagerPanel(): void { this.villagerPanelVisible = false this.villagerPanelGroup?.destroy(true) this.scene.get('Game').events.emit('uiMenuClose') } 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 = 420 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, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210) this.villagerPanelGroup.add(bg) this.villagerPanelGroup.add( this.add.text(px + panelW/2, py + 12, '๐Ÿ‘ฅ VILLAGERS [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 villagers 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 const jobs: Array<{ key: keyof JobPriorities; label: string }> = [ { key: 'chop', label: '๐Ÿช“' }, { key: 'mine', label: 'โ›' }, { key: 'farm', label: '๐ŸŒพ' } ] jobs.forEach((job, ji) => { const bx = px + 110 + ji * 100 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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) } 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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) } 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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] Villagers', { fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 } }).setScrollFactor(0).setDepth(100) } private onCameraMoved(pos: { tileX: number; tileY: number }): void { this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`) } // โ”€โ”€โ”€ 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, 0.88) .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: '๐Ÿ‘ฅ Folks', 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') } // โ”€โ”€โ”€ 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() } }