2026-03-20 08:11:31 +00:00
import Phaser from 'phaser'
import type { BuildingType , JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem'
import { stateManager } from '../StateManager'
const ITEM_ICONS : Record < string , string > = {
wood : '🪵' , stone : '🪨' , wheat_seed : '🌱' , carrot_seed : '🥕' ,
wheat : '🌾' , carrot : '🧡' ,
}
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 popText ! : Phaser . GameObjects . Text
2026-03-20 11:57:09 +00:00
private contextMenuGroup ! : Phaser . GameObjects . Group
private contextMenuVisible = false
private inBuildMode = false
private inFarmMode = false
2026-03-20 08:11:31 +00:00
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 ( ) )
2026-03-20 11:57:09 +00:00
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 ( ) )
2026-03-20 08:11:31 +00:00
}
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 . 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 {
2026-03-20 11:57:09 +00:00
this . inBuildMode = active
2026-03-20 08:11:31 +00:00
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 {
2026-03-20 11:57:09 +00:00
this . inFarmMode = tool !== 'none'
2026-03-20 08:11:31 +00:00
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 . 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 } ` )
}
2026-03-20 11:57:09 +00:00
// ─── 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' )
}
2026-03-20 08:11:31 +00:00
// ─── Resize ───────────────────────────────────────────────────────────────
private repositionUI ( ) : void {
const { width , height } = this . scale
this . hintText . setPosition ( width / 2 , height - 40 )
this . toastText . setPosition ( width / 2 , 60 )
this . coordsText . setPosition ( 10 , height - 24 )
}
}