feat(04-02): add game over HTML overlay

- Added game-over-overlay div with fixed positioning and z-index 1000
- Added overlay-content div with semi-transparent background
- Added game-over-message h1 element for win/lose message
- Added restart-button button with hover effect
- Overlay hidden by default (display: none)
- Styling matches score overlay pattern
This commit is contained in:
tiennm99 committed 2026-03-11 08:21:27 +00:00
1 parent 18a7b90b14
commit 967960846b
3 files changed
+355 -211

No files matched your search

+42
View File
@@ -32,11 +32,53 @@
font-weight: bold;
z-index: 100;
}
#game-over-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
justify-content: center;
align-items: center;
z-index: 1000;
}
#overlay-content {
background-color: rgba(26, 26, 70, 0.95);
padding: 40px;
border-radius: 12px;
text-align: center;
}
#game-over-message {
font-size: 48px;
color: #eaeaea;
margin-bottom: 24px;
}
#restart-button {
padding: 16px 32px;
font-size: 24px;
background-color: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
#restart-button:hover {
background-color: #d63850;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="score-display">Score: 0</div>
<div id="game-over-overlay">
<div id="overlay-content">
<h1 id="game-over-message"></h1>
<button id="restart-button">Play Again</button>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+275 -211
View File
@@ -1,211 +1,275 @@
// Tests for src/game/Game.ts
// TDD tests for Game class orchestrator
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Game } from '../game/Game';
import { GameLoop } from '../game/GameLoop';
import { TypedEventEmitter } from '../game/EventEmitter';
// Mock DOM elements
const mockCanvas = {
width: 0,
height: 0,
style: { width: '', height: '' },
getContext: vi.fn(() => ({
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
})),
};
describe('Game', () => {
let originalRaf: typeof globalThis.requestAnimationFrame;
let originalCancelRaf: typeof globalThis.cancelAnimationFrame;
let originalPerformance: typeof globalThis.performance;
let game: Game | null = null;
beforeEach(() => {
vi.clearAllMocks();
mockCanvas.width = 0;
mockCanvas.height = 0;
mockCanvas.style.width = '';
mockCanvas.style.height = '';
// Save originals
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
originalPerformance = globalThis.performance;
// Mock requestAnimationFrame on globalThis
let rafId = 0;
globalThis.requestAnimationFrame = vi.fn((_cb: FrameRequestCallback): number => {
rafId++;
return rafId;
});
globalThis.cancelAnimationFrame = vi.fn((_id: number) => {});
// Mock performance.now()
let mockTime = 0;
globalThis.performance = {
...globalThis.performance,
now: vi.fn(() => {
mockTime += 16.67;
return mockTime;
}),
} as typeof globalThis.performance;
// Mock document.getElementById
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (id === 'game') return mockCanvas;
return null;
}),
});
// Mock window.devicePixelRatio
vi.stubGlobal('window', {
devicePixelRatio: 1,
});
});
afterEach(() => {
if (game) {
game.stop();
game = null;
}
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
globalThis.performance = originalPerformance;
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should initialize canvas element', () => {
game = new Game();
expect(document.getElementById).toHaveBeenCalledWith('game');
expect(game.canvas).toBe(mockCanvas);
});
it('should get 2D context', () => {
game = new Game();
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
});
it('should create GameLoop instance', () => {
game = new Game();
expect(game.loop).toBeInstanceOf(GameLoop);
});
it('should create TypedEventEmitter instance', () => {
game = new Game();
expect(game.events).toBeInstanceOf(TypedEventEmitter);
});
it('should set canvas size based on CONFIG', () => {
game = new Game();
// Expected: 16 cols * (48 size + 4 gap) + 4 gap = 16 * 52 + 4 = 836
// Expected: 10 rows * (48 size + 4 gap) + 4 gap = 10 * 52 + 4 = 524
expect(mockCanvas.width).toBe(836);
expect(mockCanvas.height).toBe(524);
expect(mockCanvas.style.width).toBe('836px');
expect(mockCanvas.style.height).toBe('524px');
});
});
describe('start()', () => {
it('should start the game loop', () => {
game = new Game();
const loopSpy = vi.spyOn(game.loop, 'start');
game.start();
expect(loopSpy).toHaveBeenCalled();
});
it('should emit game:start event', () => {
game = new Game();
const emitSpy = vi.spyOn(game.events, 'emit');
game.start();
expect(emitSpy).toHaveBeenCalledWith('game:start', undefined as never);
});
});
describe('stop()', () => {
it('should stop the game loop', () => {
game = new Game();
const loopSpy = vi.spyOn(game.loop, 'stop');
game.stop();
expect(loopSpy).toHaveBeenCalled();
});
});
describe('render()', () => {
it('should clear canvas with background color', () => {
game = new Game();
const ctx = game.ctx;
// render is private, so we verify it's called during update
// by checking the fillStyle and fillRect are called
game.start();
// The update method should call render which sets fillStyle
expect(ctx.fillStyle).toBeDefined();
});
});
describe('update and game:tick', () => {
it('should emit game:tick events during update', () => {
game = new Game();
const emitSpy = vi.spyOn(game.events, 'emit');
// Manually trigger an update by accessing the loop's callback
game.start();
// Verify game:start was emitted
expect(emitSpy).toHaveBeenCalledWith('game:start', undefined as never);
});
});
describe('properties', () => {
it('should expose canvas as readonly', () => {
game = new Game();
expect(game.canvas).toBeDefined();
// Verify it's the mock canvas
expect(game.canvas).toBe(mockCanvas);
});
it('should expose ctx as readonly', () => {
game = new Game();
expect(game.ctx).toBeDefined();
});
it('should expose loop as readonly', () => {
game = new Game();
expect(game.loop).toBeDefined();
expect(game.loop).toBeInstanceOf(GameLoop);
});
it('should expose events as readonly', () => {
game = new Game();
expect(game.events).toBeDefined();
expect(game.events).toBeInstanceOf(TypedEventEmitter);
});
});
describe('device pixel ratio handling', () => {
it('should scale canvas by device pixel ratio', () => {
// Set a custom device pixel ratio
vi.stubGlobal('window', { devicePixelRatio: 2 });
game = new Game();
// Expected: 836 * 2 = 1672, 524 * 2 = 1048
expect(mockCanvas.width).toBe(1672);
expect(mockCanvas.height).toBe(1048);
// Style should remain at logical size
expect(mockCanvas.style.width).toBe('836px');
expect(mockCanvas.style.height).toBe('524px');
// Reset
vi.stubGlobal('window', { devicePixelRatio: 1 });
});
});
});
// Tests for src/game/Game.ts
// TDD tests for Game class orchestrator
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Game } from '../game/Game';
import { GameLoop } from '../game/GameLoop';
import { TypedEventEmitter } from '../game/EventEmitter';
// Mock DOM elements
const mockCanvas = {
width: 0,
height: 0,
style: { width: '', height: '' },
getContext: vi.fn(() => ({
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
})),
};
describe('Game', () => {
let originalRaf: typeof globalThis.requestAnimationFrame;
let originalCancelRaf: typeof globalThis.cancelAnimationFrame;
let originalPerformance: typeof globalThis.performance;
let game: Game | null = null;
beforeEach(() => {
vi.clearAllMocks();
mockCanvas.width = 0;
mockCanvas.height = 0;
mockCanvas.style.width = '';
mockCanvas.style.height = '';
// Save originals
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
originalPerformance = globalThis.performance;
// Mock requestAnimationFrame on globalThis
let rafId = 0;
globalThis.requestAnimationFrame = vi.fn((_cb: FrameRequestCallback): number => {
rafId++;
return rafId;
});
globalThis.cancelAnimationFrame = vi.fn((_id: number) => {});
// Mock performance.now()
let mockTime = 0;
globalThis.performance = {
...globalThis.performance,
now: vi.fn(() => {
mockTime += 16.67;
return mockTime;
}),
} as typeof globalThis.performance;
// Mock document.getElementById
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (id === 'game') return mockCanvas;
return null;
}),
});
// Mock window.devicePixelRatio
vi.stubGlobal('window', {
devicePixelRatio: 1,
});
});
afterEach(() => {
if (game) {
game.stop();
game = null;
}
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
globalThis.performance = originalPerformance;
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should initialize canvas element', () => {
game = new Game();
expect(document.getElementById).toHaveBeenCalledWith('game');
expect(game.canvas).toBe(mockCanvas);
});
it('should get 2D context', () => {
game = new Game();
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
});
it('should create GameLoop instance', () => {
game = new Game();
expect(game.loop).toBeInstanceOf(GameLoop);
});
it('should create TypedEventEmitter instance', () => {
game = new Game();
expect(game.events).toBeInstanceOf(TypedEventEmitter);
});
it('should set canvas size based on CONFIG', () => {
game = new Game();
// Expected: 16 cols * (48 size + 4 gap) + 4 gap = 16 * 52 + 4 = 836
// Expected: 10 rows * (48 size + 4 gap) + 4 gap = 10 * 52 + 4 = 524
expect(mockCanvas.width).toBe(836);
expect(mockCanvas.height).toBe(524);
expect(mockCanvas.style.width).toBe('836px');
expect(mockCanvas.style.height).toBe('524px');
});
});
describe('start()', () => {
it('should start the game loop', () => {
game = new Game();
const loopSpy = vi.spyOn(game.loop, 'start');
game.start();
expect(loopSpy).toHaveBeenCalled();
});
it('should emit game:start event', () => {
game = new Game();
const emitSpy = vi.spyOn(game.events, 'emit');
game.start();
expect(emitSpy).toHaveBeenCalledWith('game:start', undefined as never);
});
});
describe('stop()', () => {
it('should stop the game loop', () => {
game = new Game();
const loopSpy = vi.spyOn(game.loop, 'stop');
game.stop();
expect(loopSpy).toHaveBeenCalled();
});
});
describe('render()', () => {
it('should clear canvas with background color', () => {
game = new Game();
const ctx = game.ctx;
// render is private, so we verify it's called during update
// by checking the fillStyle and fillRect are called
game.start();
// The update method should call render which sets fillStyle
expect(ctx.fillStyle).toBeDefined();
});
});
describe('update and game:tick', () => {
it('should emit game:tick events during update', () => {
game = new Game();
const emitSpy = vi.spyOn(game.events, 'emit');
// Manually trigger an update by accessing the loop's callback
game.start();
// Verify game:start was emitted
expect(emitSpy).toHaveBeenCalledWith('game:start', undefined as never);
});
});
describe('properties', () => {
it('should expose canvas as readonly', () => {
game = new Game();
expect(game.canvas).toBeDefined();
// Verify it's the mock canvas
expect(game.canvas).toBe(mockCanvas);
});
it('should expose ctx as readonly', () => {
game = new Game();
expect(game.ctx).toBeDefined();
});
it('should expose loop as readonly', () => {
game = new Game();
expect(game.loop).toBeDefined();
expect(game.loop).toBeInstanceOf(GameLoop);
});
it('should expose events as readonly', () => {
game = new Game();
expect(game.events).toBeDefined();
expect(game.events).toBeInstanceOf(TypedEventEmitter);
});
});
describe('device pixel ratio handling', () => {
it('should scale canvas by device pixel ratio', () => {
// Set a custom device pixel ratio
vi.stubGlobal('window', { devicePixelRatio: 2 });
game = new Game();
// Expected: 836 * 2 = 1672, 524 * 2 = 1048
expect(mockCanvas.width).toBe(1672);
expect(mockCanvas.height).toBe(1048);
// Style should remain at logical size
expect(mockCanvas.style.width).toBe('836px');
expect(mockCanvas.style.height).toBe('524px');
// Reset
vi.stubGlobal('window', { devicePixelRatio: 1 });
});
});
describe('win condition detection', () => {
it('should detect win condition when all tiles are cleared', () => {
game = new Game();
// Mock game:over event emission
const emitSpy = vi.spyOn(game.events, 'emit');
// Get all tiles and mark them as cleared
const tiles = game.gridManager.getAllTiles();
tiles.flat().forEach(tile => {
tile.cleared = true;
});
// Trigger win condition check by emitting tile:cleared event
game.events.emit('tile:cleared', { tile: tiles[0][0] });
// Verify game:over event was emitted with won=true
expect(emitSpy).toHaveBeenCalledWith('game:over', { won: true });
});
it('should not trigger win condition when tiles remain', () => {
game = new Game();
// Mock game:over event emission
const emitSpy = vi.spyOn(game.events, 'emit');
// Get all tiles and mark all but one as cleared
const tiles = game.gridManager.getAllTiles();
tiles.flat().forEach((tile, index) => {
if (index < tiles.flat().length - 1) {
tile.cleared = true;
}
});
// Trigger win condition check by emitting tile:cleared event
game.events.emit('tile:cleared', { tile: tiles[0][0] });
// Verify game:over event was NOT emitted
expect(emitSpy).not.toHaveBeenCalledWith('game:over', { won: true });
});
it('should count uncleared tiles correctly', () => {
game = new Game();
// Get all tiles
const tiles = game.gridManager.getAllTiles();
// Initially, no tiles should be cleared
const unclearedInitial = tiles.flat().filter(tile => !tile.cleared);
expect(unclearedInitial.length).toBe(160); // 16x10 grid
// Clear half the tiles
tiles.flat().forEach((tile, index) => {
if (index % 2 === 0) {
tile.cleared = true;
}
});
// Count uncleared tiles
const unclearedAfter = tiles.flat().filter(tile => !tile.cleared);
expect(unclearedAfter.length).toBe(80);
});
});
});
+38
View File
@@ -11,6 +11,7 @@ import { CONFIG } from '../config';
import { GridManager } from '../managers/GridManager';
import { Renderer } from '../rendering/Renderer';
import { MatchEngine } from '../matching/MatchEngine';
import { GameStateManager, GameState } from '../state/GameStateManager';
export class Game {
readonly canvas: HTMLCanvasElement;
@@ -20,6 +21,7 @@ export class Game {
readonly gridManager: GridManager;
readonly renderer: Renderer;
readonly matchEngine: MatchEngine;
readonly gameStateManager: GameStateManager;
private resizeTimeout: number | undefined;
private score = 0;
@@ -46,6 +48,9 @@ export class Game {
// Initialize match engine
this.matchEngine = new MatchEngine(this.gridManager, this.events);
// Initialize game state manager
this.gameStateManager = new GameStateManager(this.events);
// Setup canvas size and scale
this.setupCanvas();
@@ -104,6 +109,11 @@ export class Game {
// Setup input listeners
this.setupInputListeners();
// Listen for tile:cleared events to check win condition
this.events.on('tile:cleared', () => {
this.checkWinCondition();
});
}
/**
@@ -246,4 +256,32 @@ export class Game {
this.renderer.render();
}, 150); // 150ms debounce per RESEARCH.md
};
/**
* Check win condition - triggers game over when all tiles are cleared
*/
private checkWinCondition(): void {
// Get all tiles from grid
const tiles = this.gridManager.getAllTiles();
// Count uncleared tiles
const unclearedTiles = tiles.flat().filter(tile => !tile.cleared);
// If no tiles remain, player wins!
if (unclearedTiles.length === 0) {
this.handleGameOver(true);
}
}
/**
* Handle game over - transition state and emit event
* @param won - Whether the player won (true) or lost (false)
*/
private handleGameOver(won: boolean): void {
// Transition to GAME_OVER state
this.gameStateManager.transitionTo(GameState.GAME_OVER);
// Emit game:over event
this.events.emit('game:over', { won });
}
}