Issue #7: ESC Menu #12

Merged
claude merged 1 commits from feature/esc-menu into master 2026-03-21 14:22:19 +00:00
2 changed files with 189 additions and 4 deletions

View File

@@ -7,6 +7,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- **ESC Menu**: pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save
- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu
### Added
- **F3 Debug View**: toggleable overlay showing FPS, tile type and contents under the cursor, Nisse count by AI state, active jobs by type, and pathfinding visualization (cyan lines in world space)

View File

@@ -31,6 +31,10 @@ export class UIScene extends Phaser.Scene {
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
constructor() { super({ key: 'UI' }) }
@@ -66,6 +70,8 @@ export class UIScene extends Phaser.Scene {
this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group()
this.escMenuGroup = this.add.group()
this.confirmGroup = this.add.group()
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) {
@@ -78,7 +84,7 @@ export class UIScene extends Phaser.Scene {
})
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.hideContextMenu())
.on('down', () => this.handleEsc())
}
/**
@@ -488,6 +494,179 @@ export class UIScene extends Phaser.Scene {
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.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.villagerPanelVisible){ this.closeVillagerPanel(); 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() },
]
const menuH = 16 + 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, 0.95)
.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 an empty Settings panel (placeholder). */
private doSettings(): void {
this.closeEscMenu()
this.showToast('Settings — coming soon')
}
/** 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, 0.97)
.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')
}
// ─── Resize ───────────────────────────────────────────────────────────────
/**
@@ -515,8 +694,10 @@ export class UIScene extends Phaser.Scene {
// 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.buildMenuVisible) this.closeBuildMenu()
if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu()
if (this.escMenuVisible) this.closeEscMenu()
if (this.confirmVisible) this.hideConfirm()
}
}