From 6a050ac7a991745eea7ed69de5871441ed9a83de Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 4 Jan 2026 16:06:08 +1100 Subject: [PATCH] Show overlay upon player death --- src/engine/simulation/simulation.ts | 5 + src/scenes/GameScene.ts | 27 ++++ src/scenes/__tests__/GameScene.test.ts | 177 +++++++++++++++++++++++++ src/ui/GameUI.ts | 78 +++++++++++ 4 files changed, 287 insertions(+) create mode 100644 src/scenes/__tests__/GameScene.test.ts diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 9fb8355..4db0d01 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -134,5 +134,10 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla const action = decideEnemyAction(w, actor, player); events.push(...applyAction(w, actor.id, action)); + + // Check if player was killed by this action + if (!w.actors.has(playerId)) { + return { awaitingPlayerId: null as any, events }; + } } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 03635ff..b2dd863 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -103,6 +103,11 @@ export class GameScene extends Phaser.Scene { this.emitUIUpdate(); }); + // Listen for game restart + this.events.on("restart-game", () => { + this.restartGame(); + }); + // Mouse click -> compute path (only during player turn, and not while menu/minimap is open) this.input.on("pointerdown", (p: Phaser.Input.Pointer) => { if (!this.awaitingPlayer) return; @@ -234,6 +239,20 @@ export class GameScene extends Phaser.Scene { } } + // Check if player died + if (!this.world.actors.has(this.playerId)) { + this.syncRunStateFromPlayer(); // Save final stats for death screen + const uiScene = this.scene.get("GameUI") as any; + if (uiScene) { + uiScene.showDeathScreen({ + level: this.levelIndex, + gold: this.runState.inventory.gold, + stats: this.runState.stats + }); + } + return; + } + // Level transition if (isPlayerOnExit(this.world, this.playerId)) { this.syncRunStateFromPlayer(); @@ -285,6 +304,14 @@ export class GameScene extends Phaser.Scene { }; } + private restartGame() { + this.runState = { + stats: { ...GAME_CONFIG.player.initialStats }, + inventory: { gold: 0, items: [] } + }; + this.loadLevel(1); + } + private centerCameraOnPlayer() { const player = this.world.actors.get(this.playerId)!; this.cameras.main.centerOn( diff --git a/src/scenes/__tests__/GameScene.test.ts b/src/scenes/__tests__/GameScene.test.ts new file mode 100644 index 0000000..8862a09 --- /dev/null +++ b/src/scenes/__tests__/GameScene.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GameScene } from '../GameScene'; +import * as simulation from '../../engine/simulation/simulation'; +import * as generator from '../../engine/world/generator'; + +// Mock Phaser +vi.mock('phaser', () => { + const mockEventEmitter = { + on: vi.fn(), + emit: vi.fn(), + off: vi.fn(), + }; + + return { + default: { + Scene: class { + events = mockEventEmitter; + input = { + keyboard: { + createCursorKeys: vi.fn(() => ({})), + on: vi.fn(), + }, + on: vi.fn(), + }; + cameras = { + main: { + setZoom: vi.fn(), + setBounds: vi.fn(), + centerOn: vi.fn(), + }, + }; + scene = { + launch: vi.fn(), + get: vi.fn(), + }; + add = { + graphics: vi.fn(() => ({})), + text: vi.fn(() => ({})), + rectangle: vi.fn(() => ({})), + container: vi.fn(() => ({})), + }; + load = { + spritesheet: vi.fn(), + }; + anims = { + create: vi.fn(), + exists: vi.fn(() => true), + generateFrameNumbers: vi.fn(), + }; + }, + Input: { + Keyboard: { + JustDown: vi.fn(), + }, + }, + }, + }; +}); + +// Mock other modules +vi.mock('../../rendering/DungeonRenderer', () => ({ + DungeonRenderer: vi.fn().mockImplementation(function() { + return { + initializeLevel: vi.fn(), + computeFov: vi.fn(), + render: vi.fn(), + showDamage: vi.fn(), + spawnCorpse: vi.fn(), + showWait: vi.fn(), + isMinimapVisible: vi.fn(() => false), + }; + }), +})); + +vi.mock('../../engine/simulation/simulation', () => ({ + applyAction: vi.fn(), + stepUntilPlayerTurn: vi.fn(), +})); + +vi.mock('../../engine/world/generator', () => ({ + makeTestWorld: vi.fn(), +})); + +vi.mock('../../engine/world/world-logic', () => ({ + inBounds: vi.fn(() => true), + isBlocked: vi.fn(() => false), + isPlayerOnExit: vi.fn(() => false), + idx: vi.fn((w, x, y) => y * w.width + x), +})); + +describe('GameScene', () => { + let scene: GameScene; + let mockWorld: any; + let mockUI: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mock UI + mockUI = { + showDeathScreen: vi.fn(), + }; + + // Initialize Scene + scene = new GameScene(); + + // Mock the Phaser scene system to return our mock UI + (scene as any).scene = { + launch: vi.fn(), + get: vi.fn((key) => { + if (key === 'GameUI') return mockUI; + return null; + }), + }; + + // Mock initial world state + mockWorld = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 }, + }; + + const mockPlayer = { + id: 1, + isPlayer: true, + pos: { x: 1, y: 1 }, + speed: 100, + energy: 0, + stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 }, + inventory: { gold: 0, items: [] }, + }; + mockWorld.actors.set(1, mockPlayer); + + (generator.makeTestWorld as any).mockReturnValue({ + world: mockWorld, + playerId: 1, + }); + + (simulation.stepUntilPlayerTurn as any).mockReturnValue({ + awaitingPlayerId: 1, + events: [], + }); + + // Run create to initialize some things + scene.create(); + }); + + it('should trigger death screen when player is killed', () => { + // 1. Mock simulation so that after action, player is gone from world + (simulation.applyAction as any).mockImplementation((world: any) => { + // simulate player being killed + world.actors.delete(1); + return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }]; + }); + + (simulation.stepUntilPlayerTurn as any).mockReturnValue({ + awaitingPlayerId: null, + events: [], + }); + + // 2. Commit an action + // We need to access private method or trigger it via public interface + // commitPlayerAction is private, let's cast to any to call it + (scene as any).commitPlayerAction({ type: 'wait' }); + + // 3. Verify showDeathScreen was called on the mock UI + expect(mockUI.showDeathScreen).toHaveBeenCalled(); + + // Verify it was called with some stats + const callArgs = mockUI.showDeathScreen.mock.calls[0][0]; + expect(callArgs).toHaveProperty('level'); + expect(callArgs).toHaveProperty('gold'); + expect(callArgs).toHaveProperty('stats'); + }); +}); diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index 70e29e6..717fbe7 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -15,6 +15,11 @@ export default class GameUI extends Phaser.Scene { private menuButton!: Phaser.GameObjects.Container; private mapButton!: Phaser.GameObjects.Container; + // Death Screen + private deathContainer!: Phaser.GameObjects.Container; + private deathText!: Phaser.GameObjects.Text; + private restartButton!: Phaser.GameObjects.Container; + constructor() { super({ key: "GameUI" }); } @@ -22,6 +27,7 @@ export default class GameUI extends Phaser.Scene { create() { this.createHud(); this.createMenu(); + this.createDeathScreen(); // Listen for updates from GameScene const gameScene = this.scene.get("GameScene"); @@ -108,6 +114,78 @@ export default class GameUI extends Phaser.Scene { this.setMenuOpen(false); } + private createDeathScreen() { + const cam = this.cameras.main; + const panelW = GAME_CONFIG.ui.menuPanelWidth + 40; + const panelH = GAME_CONFIG.ui.menuPanelHeight + 60; + + const bg = this.add + .rectangle(0, 0, cam.width, cam.height, 0x000000, 0.85) + .setOrigin(0) + .setInteractive(); + + const panel = this.add + .rectangle(cam.width / 2, cam.height / 2, panelW, panelH, 0x000000, 0.9) + .setStrokeStyle(2, 0xff3333, 1); + + const title = this.add + .text(cam.width / 2, cam.height / 2 - panelH / 2 + 30, "YOU HAVE PERISHED", { + fontSize: "28px", + color: "#ff3333", + fontStyle: "bold" + }) + .setOrigin(0.5); + + this.deathText = this.add + .text(cam.width / 2, cam.height / 2 - 20, "", { + fontSize: "16px", + color: "#ffffff", + align: "center", + lineSpacing: 10 + }) + .setOrigin(0.5); + + // Restart Button + const btnW = 160; + const btnH = 40; + const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x440000, 1).setStrokeStyle(2, 0xff3333, 1); + const btnLabel = this.add.text(0, 0, "NEW GAME", { fontSize: "18px", color: "#ffffff", fontStyle: "bold" }).setOrigin(0.5); + + this.restartButton = this.add.container(cam.width / 2, cam.height / 2 + panelH / 2 - 50, [btnBg, btnLabel]); + btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => { + const gameScene = this.scene.get("GameScene"); + gameScene.events.emit("restart-game"); + this.hideDeathScreen(); + }); + + this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]); + this.deathContainer.setDepth(2000); + this.deathContainer.setVisible(false); + } + + showDeathScreen(data: { level: number; gold: number; stats: any }) { + const lines = [ + `Dungeon Level: ${data.level}`, + `Gold Collected: ${data.gold}`, + "", + `Final HP: 0 / ${data.stats.maxHp}`, + `Attack: ${data.stats.attack}`, + `Defense: ${data.stats.defense}` + ]; + this.deathText.setText(lines.join("\n")); + this.deathContainer.setVisible(true); + + // Disable other UI interactions + this.menuButton.setVisible(false); + this.mapButton.setVisible(false); + } + + hideDeathScreen() { + this.deathContainer.setVisible(false); + this.menuButton.setVisible(true); + this.mapButton.setVisible(true); + } + private toggleMenu() { this.setMenuOpen(!this.menuOpen); // Request UI update when menu is opened to populate the text