Skip to content

Commit e880374

Browse files
committed
Merge feature/CG-0MM00LGEU0IM99ZC-overlay-browser-test: Add browser tests for game-over overlay buttons
2 parents 6c318de + 0d829cd commit e880374

File tree

2 files changed

+354
-0
lines changed

2 files changed

+354
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Factory function to create a Phaser game instance for The Mind.
3+
* Used by both main.ts and browser tests.
4+
*/
5+
import Phaser from 'phaser';
6+
import '../../src/ui/hiDpiText'; // side-effect: crisp text on HiDPI displays
7+
import { TheMindScene } from './scenes/TheMindScene';
8+
9+
export interface TheMindGameOptions {
10+
/** DOM element ID to parent the game canvas to. Default: 'game-container' */
11+
parent?: string;
12+
/** Game width in pixels. Default: 1280 */
13+
width?: number;
14+
/** Game height in pixels. Default: 720 */
15+
height?: number;
16+
}
17+
18+
export function createTheMindGame(
19+
options: TheMindGameOptions = {},
20+
): Phaser.Game {
21+
const {
22+
parent = 'game-container',
23+
width = 1280,
24+
height = 720,
25+
} = options;
26+
27+
const config: Phaser.Types.Core.GameConfig = {
28+
type: Phaser.AUTO,
29+
parent,
30+
width,
31+
height,
32+
backgroundColor: '#1a1a2e',
33+
scene: [TheMindScene],
34+
scale: {
35+
mode: Phaser.Scale.FIT,
36+
autoCenter: Phaser.Scale.CENTER_BOTH,
37+
},
38+
render: {
39+
roundPixels: true,
40+
},
41+
audio: {
42+
disableWebAudio: false,
43+
},
44+
};
45+
46+
return new Phaser.Game(config);
47+
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/**
2+
* TheMindScene overlay button browser tests -- verify that game-over overlay
3+
* buttons respond to real pointer events routed through Phaser's input
4+
* pipeline, and that scene.restart() works correctly after clicking
5+
* "Try Again".
6+
*
7+
* These tests run inside a real Chromium browser via Vitest browser mode
8+
* and Playwright. They dispatch actual DOM MouseEvents on the canvas
9+
* element so the full Phaser input system (hit-testing, depth sorting,
10+
* topOnly filtering) is exercised.
11+
*
12+
* NOTE: Each test boots a fresh Phaser game which creates a WebGL context.
13+
* Browsers limit concurrent WebGL contexts (~8-16). We keep total boots
14+
* per file <= 4 to stay well within that budget.
15+
*/
16+
17+
import { describe, it, expect, afterEach } from 'vitest';
18+
import Phaser from 'phaser';
19+
20+
// ── Helpers ─────────────────────────────────────────────────
21+
22+
async function bootGame(): Promise<Phaser.Game> {
23+
let container = document.getElementById('game-container');
24+
if (container) container.remove();
25+
container = document.createElement('div');
26+
container.id = 'game-container';
27+
document.body.appendChild(container);
28+
29+
const { createTheMindGame } = await import(
30+
'../../example-games/the-mind/createTheMindGame'
31+
);
32+
const game = createTheMindGame();
33+
await waitForScene(game, 'TheMindScene', 10_000);
34+
return game;
35+
}
36+
37+
function waitForScene(
38+
game: Phaser.Game,
39+
sceneKey: string,
40+
timeoutMs: number,
41+
): Promise<void> {
42+
return new Promise((resolve, reject) => {
43+
const start = Date.now();
44+
const check = () => {
45+
const scene = game.scene.getScene(sceneKey);
46+
if (
47+
scene &&
48+
(scene as Phaser.Scene & { sys: Phaser.Scenes.Systems }).sys.isActive()
49+
) {
50+
resolve();
51+
return;
52+
}
53+
if (Date.now() - start > timeoutMs) {
54+
reject(
55+
new Error(
56+
`Scene "${sceneKey}" did not become active within ${timeoutMs}ms`,
57+
),
58+
);
59+
return;
60+
}
61+
requestAnimationFrame(check);
62+
};
63+
check();
64+
});
65+
}
66+
67+
function destroyGame(game: Phaser.Game | null): void {
68+
if (game) game.destroy(true, false);
69+
const container = document.getElementById('game-container');
70+
if (container) container.remove();
71+
}
72+
73+
function waitFrames(n: number): Promise<void> {
74+
return new Promise((resolve) => {
75+
let count = 0;
76+
const step = () => {
77+
count++;
78+
if (count >= n) {
79+
resolve();
80+
} else {
81+
requestAnimationFrame(step);
82+
}
83+
};
84+
requestAnimationFrame(step);
85+
});
86+
}
87+
88+
/**
89+
* Get scene private properties via type-safe cast.
90+
*/
91+
function getSceneInternals(scene: Phaser.Scene) {
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
return scene as any;
94+
}
95+
96+
/**
97+
* Dispatch a real DOM MouseEvent on the game canvas at the given
98+
* game-world coordinates. This routes through Phaser's full input
99+
* pipeline: InputManager -> InputPlugin -> hit-test -> sortGameObjects.
100+
*
101+
* IMPORTANT: Phaser 3.x listens for 'mousedown'/'mouseup' (NOT
102+
* 'pointerdown'/'pointerup'). Synthetic `dispatchEvent(new PointerEvent(...))`
103+
* does NOT trigger the browser's automatic mousedown compatibility event,
104+
* so we must dispatch MouseEvent directly.
105+
*/
106+
function clickAtGameCoords(
107+
game: Phaser.Game,
108+
gameX: number,
109+
gameY: number,
110+
): void {
111+
const canvas = game.canvas;
112+
const scale = game.scale;
113+
114+
// Ensure ScaleManager bounds are up to date before computing coords
115+
scale.refresh();
116+
117+
const pageX =
118+
gameX / scale.displayScale.x + scale.canvasBounds.left;
119+
const pageY =
120+
gameY / scale.displayScale.y + scale.canvasBounds.top;
121+
122+
const eventInit: MouseEventInit = {
123+
clientX: pageX,
124+
clientY: pageY,
125+
screenX: pageX,
126+
screenY: pageY,
127+
bubbles: true,
128+
cancelable: true,
129+
button: 0,
130+
buttons: 1,
131+
};
132+
133+
const down = new MouseEvent('mousedown', eventInit);
134+
Object.defineProperty(down, 'pageX', { value: pageX });
135+
Object.defineProperty(down, 'pageY', { value: pageY });
136+
canvas.dispatchEvent(down);
137+
138+
const up = new MouseEvent('mouseup', { ...eventInit, buttons: 0 });
139+
Object.defineProperty(up, 'pageX', { value: pageX });
140+
Object.defineProperty(up, 'pageY', { value: pageY });
141+
canvas.dispatchEvent(up);
142+
}
143+
144+
/**
145+
* Force the TheMindScene into game-over (loss) state and show the
146+
* loss overlay. Manipulates session state directly so handleGameOver()
147+
* sees outcome='loss'.
148+
*/
149+
function forceLossOverlay(scene: Phaser.Scene): void {
150+
const internals = getSceneInternals(scene);
151+
// Set session to loss state
152+
internals.session.lives = 0;
153+
internals.session.outcome = 'loss';
154+
// Call handleGameOver which shows the loss overlay
155+
internals.handleGameOver();
156+
}
157+
158+
/**
159+
* Force the TheMindScene into game-over (win) state and show the
160+
* win overlay. Manipulates session state directly so handleGameOver()
161+
* sees outcome='win'.
162+
*/
163+
function forceWinOverlay(scene: Phaser.Scene): void {
164+
const internals = getSceneInternals(scene);
165+
// Set session to win state
166+
internals.session.outcome = 'win';
167+
// Call handleGameOver which shows the win overlay
168+
internals.handleGameOver();
169+
}
170+
171+
// ── Tests ───────────────────────────────────────────────────
172+
173+
describe('The Mind overlay button tests', () => {
174+
let game: Phaser.Game | null = null;
175+
176+
afterEach(() => {
177+
destroyGame(game);
178+
game = null;
179+
});
180+
181+
it('should show loss overlay buttons that are interactive', async () => {
182+
game = await bootGame();
183+
const scene = game.scene.getScene('TheMindScene')!;
184+
185+
forceLossOverlay(scene);
186+
await waitFrames(3);
187+
188+
// Find text objects with overlay button labels
189+
const texts = scene.children.list.filter(
190+
(child: Phaser.GameObjects.GameObject) =>
191+
child instanceof Phaser.GameObjects.Text,
192+
) as Phaser.GameObjects.Text[];
193+
194+
const tryAgainBtn = texts.find((t) => t.text === '[ Try Again ]');
195+
const menuBtn = texts.find((t) => t.text === '[ Menu ]');
196+
197+
expect(tryAgainBtn).toBeDefined();
198+
expect(menuBtn).toBeDefined();
199+
expect(tryAgainBtn!.input?.enabled).toBe(true);
200+
expect(menuBtn!.input?.enabled).toBe(true);
201+
});
202+
203+
it('should restart the scene when "Try Again" is clicked via DOM pointer event', async () => {
204+
game = await bootGame();
205+
const scene = game.scene.getScene('TheMindScene')!;
206+
207+
// Record original session to verify it changes after restart
208+
const originalSession = getSceneInternals(scene).session;
209+
210+
forceLossOverlay(scene);
211+
// Wait for the overlay to render and Phaser to process the frame
212+
await waitFrames(5);
213+
214+
// Find the "Try Again" button to get its coordinates
215+
const texts = scene.children.list.filter(
216+
(child: Phaser.GameObjects.GameObject) =>
217+
child instanceof Phaser.GameObjects.Text,
218+
) as Phaser.GameObjects.Text[];
219+
const tryAgainBtn = texts.find((t) => t.text === '[ Try Again ]');
220+
expect(tryAgainBtn).toBeDefined();
221+
222+
// Click at the button's game-world position through the DOM
223+
clickAtGameCoords(game, tryAgainBtn!.x, tryAgainBtn!.y);
224+
225+
// Wait for restart: Phaser queues scene restart for next frame
226+
await waitFrames(3);
227+
// scene.restart() destroys and recreates; wait for re-activation
228+
await waitForScene(game, 'TheMindScene', 10_000);
229+
await waitFrames(3);
230+
231+
// Verify: new session was created (different object reference)
232+
const newScene = game.scene.getScene('TheMindScene')!;
233+
const newSession = getSceneInternals(newScene).session;
234+
expect(newSession).not.toBe(originalSession);
235+
236+
// Verify: the scene is in 'playing' or 'dealing' phase (not game-lost)
237+
const newPhase = getSceneInternals(newScene).phase;
238+
expect(newPhase).not.toBe('game-lost');
239+
expect(newPhase).not.toBe('game-won');
240+
241+
// Verify: overlay buttons no longer exist
242+
const newTexts = newScene.children.list.filter(
243+
(child: Phaser.GameObjects.GameObject) =>
244+
child instanceof Phaser.GameObjects.Text,
245+
) as Phaser.GameObjects.Text[];
246+
const tryAgainAfterRestart = newTexts.find(
247+
(t) => t.text === '[ Try Again ]',
248+
);
249+
expect(tryAgainAfterRestart).toBeUndefined();
250+
});
251+
252+
it('should show win overlay buttons that respond to DOM clicks', async () => {
253+
game = await bootGame();
254+
const scene = game.scene.getScene('TheMindScene')!;
255+
256+
const originalSession = getSceneInternals(scene).session;
257+
258+
forceWinOverlay(scene);
259+
await waitFrames(5);
260+
261+
// Find the "Play Again" button
262+
const texts = scene.children.list.filter(
263+
(child: Phaser.GameObjects.GameObject) =>
264+
child instanceof Phaser.GameObjects.Text,
265+
) as Phaser.GameObjects.Text[];
266+
const playAgainBtn = texts.find((t) => t.text === '[ Play Again ]');
267+
expect(playAgainBtn).toBeDefined();
268+
expect(playAgainBtn!.input?.enabled).toBe(true);
269+
270+
// Click at the button's game-world position through the DOM
271+
clickAtGameCoords(game, playAgainBtn!.x, playAgainBtn!.y);
272+
273+
// Wait for restart
274+
await waitFrames(3);
275+
await waitForScene(game, 'TheMindScene', 10_000);
276+
await waitFrames(3);
277+
278+
// Verify: new session was created
279+
const newScene = game.scene.getScene('TheMindScene')!;
280+
const newSession = getSceneInternals(newScene).session;
281+
expect(newSession).not.toBe(originalSession);
282+
});
283+
284+
it('should have an interactive input blocker at overlay depth', async () => {
285+
game = await bootGame();
286+
const scene = game.scene.getScene('TheMindScene')!;
287+
288+
forceLossOverlay(scene);
289+
await waitFrames(3);
290+
291+
// Find interactive rectangles at depth 2000 (the overlay background)
292+
const rects = scene.children.list.filter(
293+
(child: Phaser.GameObjects.GameObject) =>
294+
child instanceof Phaser.GameObjects.Rectangle &&
295+
(child as Phaser.GameObjects.Rectangle).depth === 2000,
296+
) as Phaser.GameObjects.Rectangle[];
297+
298+
// Should have at least 2 rectangles: the full-screen blocker and the visible overlay box
299+
expect(rects.length).toBeGreaterThanOrEqual(2);
300+
301+
// The full-screen blocker should be interactive (1280x720 viewport)
302+
const fullScreenBlocker = rects.find(
303+
(r) => r.width === 1280 && r.height === 720 && r.input?.enabled,
304+
);
305+
expect(fullScreenBlocker).toBeDefined();
306+
});
307+
});

0 commit comments

Comments
 (0)