Show overlay upon player death
This commit is contained in:
@@ -134,5 +134,10 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
|
|||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player);
|
const action = decideEnemyAction(w, actor, player);
|
||||||
events.push(...applyAction(w, actor.id, action));
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
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)
|
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
|
||||||
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
||||||
if (!this.awaitingPlayer) return;
|
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
|
// Level transition
|
||||||
if (isPlayerOnExit(this.world, this.playerId)) {
|
if (isPlayerOnExit(this.world, this.playerId)) {
|
||||||
this.syncRunStateFromPlayer();
|
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() {
|
private centerCameraOnPlayer() {
|
||||||
const player = this.world.actors.get(this.playerId)!;
|
const player = this.world.actors.get(this.playerId)!;
|
||||||
this.cameras.main.centerOn(
|
this.cameras.main.centerOn(
|
||||||
|
|||||||
177
src/scenes/__tests__/GameScene.test.ts
Normal file
177
src/scenes/__tests__/GameScene.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,11 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
private menuButton!: Phaser.GameObjects.Container;
|
private menuButton!: Phaser.GameObjects.Container;
|
||||||
private mapButton!: 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() {
|
constructor() {
|
||||||
super({ key: "GameUI" });
|
super({ key: "GameUI" });
|
||||||
}
|
}
|
||||||
@@ -22,6 +27,7 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
create() {
|
create() {
|
||||||
this.createHud();
|
this.createHud();
|
||||||
this.createMenu();
|
this.createMenu();
|
||||||
|
this.createDeathScreen();
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
const gameScene = this.scene.get("GameScene");
|
const gameScene = this.scene.get("GameScene");
|
||||||
@@ -108,6 +114,78 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
this.setMenuOpen(false);
|
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() {
|
private toggleMenu() {
|
||||||
this.setMenuOpen(!this.menuOpen);
|
this.setMenuOpen(!this.menuOpen);
|
||||||
// Request UI update when menu is opened to populate the text
|
// Request UI update when menu is opened to populate the text
|
||||||
|
|||||||
Reference in New Issue
Block a user