Show overlay upon player death

This commit is contained in:
Peter Stockings
2026-01-04 16:06:08 +11:00
parent bfe5ebae8c
commit 6a050ac7a9
4 changed files with 287 additions and 0 deletions

View File

@@ -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 };
}
}
}

View File

@@ -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(

View File

@@ -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');
});
});

View File

@@ -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