From 967960846bc512b2357b1ee39935c38ebe5689b0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 11 Mar 2026 08:21:27 +0000 Subject: [PATCH] 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 --- gsd-framework/index.html | 42 ++ gsd-framework/src/__tests__/Game.test.ts | 486 +++++++++++++---------- gsd-framework/src/game/Game.ts | 38 ++ 3 files changed, 355 insertions(+), 211 deletions(-) diff --git a/gsd-framework/index.html b/gsd-framework/index.html index eb8abad..88e1d90 100644 --- a/gsd-framework/index.html +++ b/gsd-framework/index.html @@ -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; + }
Score: 0
+
+
+

+ +
+
diff --git a/gsd-framework/src/__tests__/Game.test.ts b/gsd-framework/src/__tests__/Game.test.ts index 0c1cd74..134a89e 100644 --- a/gsd-framework/src/__tests__/Game.test.ts +++ b/gsd-framework/src/__tests__/Game.test.ts @@ -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); + }); + }); +}); diff --git a/gsd-framework/src/game/Game.ts b/gsd-framework/src/game/Game.ts index 1a505b0..cae7f49 100644 --- a/gsd-framework/src/game/Game.ts +++ b/gsd-framework/src/game/Game.ts @@ -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 }); + } }