2026-03-20 08:11:31 +00:00
import Phaser from 'phaser'
import type { BuildingType , JobPriorities } from '../types'
import type { FarmingTool } from '../systems/FarmingSystem'
2026-03-21 12:11:54 +00:00
import type { DebugData } from '../systems/DebugSystem'
2026-03-20 08:11:31 +00:00
import { stateManager } from '../StateManager'
2026-03-23 09:36:42 +00:00
import { UI_SETTINGS_KEY } from '../config'
2026-03-20 08:11:31 +00:00
const ITEM_ICONS : Record < string , string > = {
wood : '🪵' , stone : '🪨' , wheat_seed : '🌱' , carrot_seed : '🥕' ,
2026-03-21 16:15:21 +00:00
wheat : '🌾' , carrot : '🧡' , tree_seed : '🌲' ,
2026-03-20 08:11:31 +00:00
}
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
2026-03-20 12:19:57 +00:00
private controlsHintText ! : Phaser . GameObjects . Text
2026-03-20 08:11:31 +00:00
private popText ! : Phaser . GameObjects . Text
2026-03-20 12:19:57 +00:00
private stockpileTitleText ! : 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-21 12:11:54 +00:00
private debugPanelText ! : Phaser . GameObjects . Text
private debugActive = false
2026-03-21 14:13:53 +00:00
private escMenuGroup ! : Phaser . GameObjects . Group
private escMenuVisible = false
private confirmGroup ! : Phaser . GameObjects . Group
private confirmVisible = false
2026-03-21 14:21:12 +00:00
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
2026-03-20 08:11:31 +00:00
2026-03-23 09:36:42 +00:00
/** 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
2026-03-20 08:11:31 +00:00
constructor ( ) { super ( { key : 'UI' } ) }
2026-03-21 12:11:54 +00:00
/ * *
* Creates all HUD elements , wires up game scene events , and registers
* keyboard shortcuts ( B , V , F3 , ESC ) .
* /
2026-03-20 08:11:31 +00:00
create ( ) : void {
2026-03-23 09:36:42 +00:00
this . loadUISettings ( )
2026-03-20 08:11:31 +00:00
this . createStockpilePanel ( )
this . createHintText ( )
this . createToast ( )
this . createBuildMenu ( )
this . createBuildModeIndicator ( )
this . createFarmToolIndicator ( )
this . createCoordsDisplay ( )
2026-03-21 12:11:54 +00:00
this . createDebugPanel ( )
2026-03-20 08:11:31 +00:00
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 ( ) )
2026-03-21 12:11:54 +00:00
this . input . keyboard ! . addKey ( Phaser . Input . Keyboard . KeyCodes . F3 )
. on ( 'down' , ( ) = > this . toggleDebugPanel ( ) )
2026-03-20 08:11:31 +00:00
this . scale . on ( 'resize' , ( ) = > this . repositionUI ( ) )
2026-03-20 11:57:09 +00:00
2026-03-21 14:21:12 +00:00
gameScene . events . on ( 'nisseClicked' , ( id : string ) = > this . openNisseInfoPanel ( id ) )
2026-03-20 11:57:09 +00:00
this . input . mouse ! . disableContextMenu ( )
this . contextMenuGroup = this . add . group ( )
2026-03-21 14:13:53 +00:00
this . escMenuGroup = this . add . group ( )
this . confirmGroup = this . add . group ( )
2026-03-21 14:21:12 +00:00
this . nisseInfoGroup = this . add . group ( )
2026-03-23 09:36:42 +00:00
this . settingsGroup = this . add . group ( )
2026-03-20 11:57:09 +00:00
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 )
2026-03-21 14:13:53 +00:00
. on ( 'down' , ( ) = > this . handleEsc ( ) )
2026-03-20 08:11:31 +00:00
}
2026-03-21 12:11:54 +00:00
/ * *
* 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
* /
2026-03-20 08:11:31 +00:00
update ( _t : number , delta : number ) : void {
this . updateStockpile ( )
this . updateToast ( delta )
this . updatePopText ( )
2026-03-21 12:11:54 +00:00
if ( this . debugActive ) this . updateDebugPanel ( )
2026-03-21 14:21:12 +00:00
if ( this . nisseInfoVisible ) this . refreshNisseInfoPanel ( )
2026-03-20 08:11:31 +00:00
}
// ─── Stockpile ────────────────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
2026-03-20 08:11:31 +00:00
private createStockpilePanel ( ) : void {
const x = this . scale . width - 178 , y = 10
2026-03-21 16:15:21 +00:00
this . stockpilePanel = this . add . rectangle ( x , y , 168 , 187 , 0x000000 , 0.72 ) . setOrigin ( 0 , 0 ) . setScrollFactor ( 0 ) . setDepth ( 100 )
2026-03-20 12:19:57 +00:00
this . stockpileTitleText = this . add . text ( x + 10 , y + 7 , '⚡ STOCKPILE' , { fontSize : '11px' , color : '#aaaaaa' , fontFamily : 'monospace' } ) . setScrollFactor ( 0 ) . setDepth ( 101 )
2026-03-21 16:15:21 +00:00
const items = [ 'wood' , 'stone' , 'wheat_seed' , 'carrot_seed' , 'tree_seed' , 'wheat' , 'carrot' ] as const
2026-03-20 08:11:31 +00:00
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 )
} )
2026-03-21 16:15:21 +00:00
this . popText = this . add . text ( x + 10 , y + 167 , '👥 Nisse: 0 / 0' , { fontSize : '11px' , color : '#aaaaaa' , fontFamily : 'monospace' } ) . setScrollFactor ( 0 ) . setDepth ( 101 )
2026-03-20 08:11:31 +00:00
}
2026-03-21 12:11:54 +00:00
/** Refreshes all item quantities and colors in the stockpile panel. */
2026-03-20 08:11:31 +00:00
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 } ` )
}
}
2026-03-21 12:11:54 +00:00
/** Updates the Nisse population / bed capacity counter. */
2026-03-20 08:11:31 +00:00
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
2026-03-20 17:07:34 +00:00
this . popText ? . setText ( ` 👥 Nisse: ${ current } / ${ beds } [V] ` )
2026-03-20 08:11:31 +00:00
}
// ─── Hint ─────────────────────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the centered hint text element near the bottom of the screen. */
2026-03-20 08:11:31 +00:00
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 ────────────────────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the toast notification text element (top center, initially hidden). */
2026-03-20 08:11:31 +00:00
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 )
}
2026-03-21 12:11:54 +00:00
/ * *
* Displays a toast message for 2.2 seconds then fades it out .
* @param msg - Message to display
* /
2026-03-20 08:11:31 +00:00
showToast ( msg : string ) : void { this . toastText . setText ( msg ) . setAlpha ( 1 ) ; this . toastTimer = 2200 }
2026-03-21 12:11:54 +00:00
/ * *
* Counts down the toast timer and triggers the fade - out tween when it expires .
* @param delta - Frame delta in milliseconds
* /
2026-03-20 08:11:31 +00:00
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 ───────────────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates and hides the build menu with buttons for each available building type. */
2026-03-20 08:11:31 +00:00
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
2026-03-23 09:36:42 +00:00
const bg = this . add . rectangle ( menuX , menuY , 300 , 280 , 0x000000 , this . uiOpacity ) . setOrigin ( 0 , 0 ) . setScrollFactor ( 0 ) . setDepth ( 200 )
2026-03-20 08:11:31 +00:00
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 )
}
2026-03-21 12:11:54 +00:00
/** Toggles the build menu open or closed. */
2026-03-20 08:11:31 +00:00
private toggleBuildMenu ( ) : void { this . buildMenuVisible ? this . closeBuildMenu ( ) : this . openBuildMenu ( ) }
2026-03-21 12:11:54 +00:00
/** Opens the build menu and notifies GameScene that a menu is active. */
2026-03-20 08:11:31 +00:00
private openBuildMenu ( ) : void { this . buildMenuVisible = true ; this . buildMenuGroup . setVisible ( true ) ; this . scene . get ( 'Game' ) . events . emit ( 'uiMenuOpen' ) }
2026-03-21 12:11:54 +00:00
/** Closes the build menu and notifies GameScene that no menu is active. */
2026-03-20 08:11:31 +00:00
private closeBuildMenu ( ) : void { this . buildMenuVisible = false ; this . buildMenuGroup . setVisible ( false ) ; this . scene . get ( 'Game' ) . events . emit ( 'uiMenuClose' ) }
// ─── Villager Panel (V key) ───────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Toggles the Nisse management panel open or closed. */
2026-03-20 08:11:31 +00:00
private toggleVillagerPanel ( ) : void {
if ( this . villagerPanelVisible ) {
this . closeVillagerPanel ( )
} else {
this . openVillagerPanel ( )
}
}
2026-03-21 12:11:54 +00:00
/** Opens the Nisse panel, builds its contents, and notifies GameScene. */
2026-03-20 08:11:31 +00:00
private openVillagerPanel ( ) : void {
this . villagerPanelVisible = true
this . buildVillagerPanel ( )
this . scene . get ( 'Game' ) . events . emit ( 'uiMenuOpen' )
}
2026-03-21 12:11:54 +00:00
/** Closes and destroys the Nisse panel and notifies GameScene. */
2026-03-20 08:11:31 +00:00
private closeVillagerPanel ( ) : void {
this . villagerPanelVisible = false
this . villagerPanelGroup ? . destroy ( true )
this . scene . get ( 'Game' ) . events . emit ( 'uiMenuClose' )
}
2026-03-21 12:11:54 +00:00
/ * *
* Destroys and rebuilds the Nisse panel from current state .
* Shows name , status , energy bar , and job priority buttons per Nisse .
* /
2026-03-20 08:11:31 +00:00
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
2026-03-23 09:36:42 +00:00
const bg = this . add . rectangle ( px , py , panelW , panelH , 0x0a0a0a , this . uiOpacity ) . setOrigin ( 0 , 0 ) . setScrollFactor ( 0 ) . setDepth ( 210 )
2026-03-20 08:11:31 +00:00
this . villagerPanelGroup . add ( bg )
this . villagerPanelGroup . add (
2026-03-20 17:07:34 +00:00
this . add . text ( px + panelW / 2 , py + 12 , '👥 NISSE [V] close' , { fontSize : '12px' , color : '#aaaaaa' , fontFamily : 'monospace' } )
2026-03-20 08:11:31 +00:00
. setOrigin ( 0.5 , 0 ) . setScrollFactor ( 0 ) . setDepth ( 211 )
)
if ( villagers . length === 0 ) {
this . villagerPanelGroup . add (
2026-03-20 17:07:34 +00:00
this . add . text ( px + panelW / 2 , py + panelH / 2 , 'No Nisse yet.\nBuild a 🛏 Bed first!' , {
2026-03-20 08:11:31 +00:00
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 ─────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the build-mode indicator text in the top-left corner (initially hidden). */
2026-03-20 08:11:31 +00:00
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 )
}
2026-03-21 12:11:54 +00:00
/ * *
* 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
* /
2026-03-20 08:11:31 +00:00
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 ──────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the farm-tool indicator text below the build-mode indicator (initially hidden). */
2026-03-20 08:11:31 +00:00
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 )
}
2026-03-21 12:11:54 +00:00
/ * *
* 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
* /
2026-03-20 08:11:31 +00:00
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 ────────────────────────────────────────────────────
2026-03-21 12:11:54 +00:00
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
2026-03-20 08:11:31 +00:00
private createCoordsDisplay ( ) : void {
this . coordsText = this . add . text ( 10 , this . scale . height - 24 , '' , { fontSize : '11px' , color : '#666666' , fontFamily : 'monospace' } ) . setScrollFactor ( 0 ) . setDepth ( 100 )
2026-03-21 12:11:54 +00:00
this . controlsHintText = this . add . text ( 10 , this . scale . height - 42 , '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug' , {
2026-03-20 08:11:31 +00:00
fontSize : '10px' , color : '#444444' , fontFamily : 'monospace' , backgroundColor : '#00000066' , padding : { x : 4 , y : 2 }
} ) . setScrollFactor ( 0 ) . setDepth ( 100 )
}
2026-03-21 12:11:54 +00:00
/ * *
* Updates the tile - coordinate display when the camera moves .
* @param pos - Tile position of the camera center
* /
2026-03-20 08:11:31 +00:00
private onCameraMoved ( pos : { tileX : number ; tileY : number } ) : void {
this . coordsText . setText ( ` Tile: ${ pos . tileX } , ${ pos . tileY } ` )
}
2026-03-21 12:11:54 +00:00
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */
private createDebugPanel ( ) : void {
2026-03-23 09:36:42 +00:00
const hexAlpha = Math . round ( this . uiOpacity * 255 ) . toString ( 16 ) . padStart ( 2 , '0' )
2026-03-21 12:11:54 +00:00
this . debugPanelText = this . add . text ( 10 , 80 , '' , {
fontSize : '12px' ,
color : '#cccccc' ,
2026-03-23 09:36:42 +00:00
backgroundColor : ` #000000 ${ hexAlpha } ` ,
2026-03-21 12:11:54 +00:00
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' ,
] )
}
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 )
2026-03-23 09:36:42 +00:00
const bg = this . add . rectangle ( mx , my , menuW , menuH , 0x000000 , this . uiOpacity )
2026-03-20 11:57:09 +00:00
. 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' ) } ,
} ,
{
2026-03-20 17:07:34 +00:00
label : '👥 Nisse' ,
2026-03-20 11:57:09 +00:00
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-21 14:13:53 +00:00
// ─── 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 {
2026-03-21 14:21:12 +00:00
if ( this . confirmVisible ) { this . hideConfirm ( ) ; 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 }
2026-03-23 09:36:42 +00:00
if ( this . settingsVisible ) { this . closeSettings ( ) ; return }
2026-03-21 14:21:12 +00:00
if ( this . escMenuVisible ) { this . closeEscMenu ( ) ; return }
2026-03-21 14:13:53 +00:00
// 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 ( ) } ,
]
const menuH = 16 + entries . length * ( btnH + 8 ) + 8
const mx = this . scale . width / 2 - menuW / 2
const my = this . scale . height / 2 - menuH / 2
2026-03-23 09:36:42 +00:00
const bg = this . add . rectangle ( mx , my , menuW , menuH , 0x0a0a0a , this . uiOpacity )
2026-03-21 14:13:53 +00:00
. 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 ( )
}
2026-03-23 09:36:42 +00:00
/** Opens the Settings overlay. */
2026-03-21 14:13:53 +00:00
private doSettings ( ) : void {
this . closeEscMenu ( )
2026-03-23 09:36:42 +00:00
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 . updateDebugPanelBackground ( )
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 . updateDebugPanelBackground ( )
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 the debug panel text background .
* Called whenever uiOpacity changes so the debug panel stays in sync .
* /
private updateDebugPanelBackground ( ) : void {
const hexAlpha = Math . round ( this . uiOpacity * 255 ) . toString ( 16 ) . padStart ( 2 , '0' )
this . debugPanelText . setStyle ( { backgroundColor : ` #000000 ${ hexAlpha } ` } )
2026-03-21 14:13:53 +00:00
}
/** 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
2026-03-23 09:36:42 +00:00
const bg = this . add . rectangle ( dx , dy , dialogW , dialogH , 0x0a0a0a , this . uiOpacity )
2026-03-21 14:13:53 +00:00
. 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' )
}
2026-03-21 14:21:12 +00:00
// ─── 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 (
2026-03-23 09:36:42 +00:00
this . add . rectangle ( px , py , panelW , panelH , 0x050510 , this . uiOpacity )
2026-03-21 14:21:12 +00:00
. 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 : '🌾' } ,
]
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 * 88
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 ] ? ? '' )
} )
}
2026-03-20 08:11:31 +00:00
// ─── Resize ───────────────────────────────────────────────────────────────
2026-03-20 12:19:57 +00:00
/ * *
* Repositions all fixed UI elements after a canvas resize .
* Open overlay panels are closed so they reopen correctly centered .
* /
2026-03-20 08:11:31 +00:00
private repositionUI ( ) : void {
const { width , height } = this . scale
2026-03-20 12:19:57 +00:00
// 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 )
2026-03-20 08:11:31 +00:00
this . coordsText . setPosition ( 10 , height - 24 )
2026-03-20 12:19:57 +00:00
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
2026-03-21 14:13:53 +00:00
if ( this . buildMenuVisible ) this . closeBuildMenu ( )
if ( this . villagerPanelVisible ) this . closeVillagerPanel ( )
if ( this . contextMenuVisible ) this . hideContextMenu ( )
if ( this . escMenuVisible ) this . closeEscMenu ( )
2026-03-23 09:36:42 +00:00
if ( this . settingsVisible ) this . closeSettings ( )
2026-03-21 14:13:53 +00:00
if ( this . confirmVisible ) this . hideConfirm ( )
2026-03-21 14:21:12 +00:00
if ( this . nisseInfoVisible ) this . closeNisseInfoPanel ( )
2026-03-20 08:11:31 +00:00
}
}