diff --git a/src/__tests__/Game.test.ts b/src/__tests__/Game.test.ts index d94ad1d..20170aa 100644 --- a/src/__tests__/Game.test.ts +++ b/src/__tests__/Game.test.ts @@ -412,4 +412,114 @@ describe('Game', () => { expect(document.getElementById('previous-score-display')).toBeDefined(); }); }); + + describe('restart() method', () => { + let mockOverlay: any; + let mockScoreDisplay: any; + let mockPreviousScoreDisplay: any; + + beforeEach(() => { + mockOverlay = { style: { display: '' }, textContent: '' }; + mockScoreDisplay = { style: {}, textContent: '' }; + mockPreviousScoreDisplay = { style: { display: 'none' }, textContent: '' }; + + vi.stubGlobal('document', { + getElementById: vi.fn((id: string) => { + if (id === 'game') return mockCanvas; + if (id === 'score-display') return mockScoreDisplay; + if (id === 'previous-score-display') return mockPreviousScoreDisplay; + if (id === 'game-over-overlay') return mockOverlay; + return null; + }), + }); + + game = new Game(); + }); + + it('should store current score as previousScore before reset', () => { + // Set a score + game['score'] = 500; + + // Call restart + game.restart(); + + // Verify previousScore was stored + expect(game['previousScore']).toBe(500); + }); + + it('should call gridManager.initializeGrid() to regenerate tiles', () => { + const initializeGridSpy = vi.spyOn(game.gridManager, 'initializeGrid'); + + game.restart(); + + expect(initializeGridSpy).toHaveBeenCalledTimes(1); + }); + + it('should reset score to 0 for new game', () => { + // Set a score + game['score'] = 500; + + game.restart(); + + // Verify score is reset + expect(game['score']).toBe(0); + }); + + it('should call gameStateManager.reset() to transition to IDLE', () => { + const resetSpy = vi.spyOn(game.gameStateManager, 'reset'); + + game.restart(); + + expect(resetSpy).toHaveBeenCalledTimes(1); + }); + + it('should hide game over overlay', () => { + // Show overlay first + mockOverlay.style.display = 'flex'; + + game.restart(); + + // Verify overlay is hidden + expect(mockOverlay.style.display).toBe('none'); + }); + + it('should update previous score display with preserved score', () => { + // Set a score + game['score'] = 500; + + game.restart(); + + // Verify previous score display is updated + expect(mockPreviousScoreDisplay.textContent).toBe('Previous: 500'); + expect(mockPreviousScoreDisplay.style.display).toBe('block'); + }); + + it('should hide previous score display if previousScore is 0', () => { + // Score is already 0 + game['score'] = 0; + + game.restart(); + + // Verify previous score display is hidden + expect(mockPreviousScoreDisplay.style.display).toBe('none'); + }); + + it('should emit game:restart event', () => { + const emitSpy = vi.spyOn(game.events, 'emit'); + + game.restart(); + + expect(emitSpy).toHaveBeenCalledWith('game:restart', undefined as never); + }); + + it('should update current score display to 0', () => { + // Set a score + game['score'] = 500; + + game.restart(); + + // Verify current score display shows 0 + expect(mockScoreDisplay.textContent).toBe('Score: 0'); + }); + }); }); diff --git a/src/game/Game.ts b/src/game/Game.ts index cae7f49..65fc43d 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -12,6 +12,7 @@ import { GridManager } from '../managers/GridManager'; import { Renderer } from '../rendering/Renderer'; import { MatchEngine } from '../matching/MatchEngine'; import { GameStateManager, GameState } from '../state/GameStateManager'; +import { NoMovesDetector } from '../detection/NoMovesDetector'; export class Game { readonly canvas: HTMLCanvasElement; @@ -24,6 +25,7 @@ export class Game { readonly gameStateManager: GameStateManager; private resizeTimeout: number | undefined; private score = 0; + private previousScore = 0; constructor() { // Get canvas element @@ -81,6 +83,15 @@ export class Game { // Clear tiles from board after path animation completes setTimeout(() => { this.gridManager.clearTiles([tile1, tile2]); + + // Check for no-moves condition after tiles cleared + const grid = this.gridManager.getAllTiles(); + const hasValidMoves = NoMovesDetector.hasValidMoves(grid); + + if (!hasValidMoves && this.gameStateManager.getState() !== GameState.GAME_OVER) { + // No moves left - game over + this.handleGameOver(false); + } }, 300); // Wait for path animation (300ms) // Update score immediately @@ -216,6 +227,11 @@ export class Game { * @param event - MouseEvent or TouchEvent */ private handleInput(event: MouseEvent | TouchEvent): void { + // Block input if game is in GAME_OVER state + if (!this.gameStateManager.canSelectTile()) { + return; + } + const rect = this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; @@ -283,5 +299,71 @@ export class Game { // Emit game:over event this.events.emit('game:over', { won }); + + // Show game over overlay + this.showGameOverOverlay(won); + } + + /** + * Show game over overlay with win/lose message + * @param won - Whether the player won (true) or lost (false) + */ + private showGameOverOverlay(won: boolean): void { + const overlay = document.getElementById('game-over-overlay'); + const message = document.getElementById('game-over-message'); + + if (overlay && message) { + message.textContent = won ? 'You Win!' : 'No moves left!'; + overlay.style.display = 'flex'; + } + } + + /** + * Hide game over overlay + */ + private hideGameOverOverlay(): void { + const overlay = document.getElementById('game-over-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + } + + /** + * Update previous score display in HTML overlay + */ + private updatePreviousScoreDisplay(): void { + const previousScoreDisplay = document.getElementById('previous-score-display'); + if (previousScoreDisplay) { + previousScoreDisplay.textContent = `Previous: ${this.previousScore}`; + // Show the element if there's a previous score, hide if 0 + previousScoreDisplay.style.display = this.previousScore > 0 ? 'block' : 'none'; + } + } + + /** + * Restart the game - reset grid, score, state, and UI + */ + restart(): void { + // Store current score as previous score BEFORE reset + this.previousScore = this.score; + + // Reset grid to initial state + this.gridManager.initializeGrid(); + + // Reset score to 0 for new game + this.score = 0; + + // Reset state machine to IDLE + this.gameStateManager.reset(); + + // Hide game over overlay + this.hideGameOverOverlay(); + + // Update score displays (current = 0, previous = preserved) + this.updateScoreDisplay(); + this.updatePreviousScoreDisplay(); + + // Emit restart event for extensibility (future listeners can subscribe) + this.events.emit('game:restart', undefined as never); } } diff --git a/src/types/index.ts b/src/types/index.ts index 2906acd..555b3f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -83,6 +83,7 @@ export interface StateChangeEvent { */ export interface GameEvents { 'game:start': void; + 'game:restart': void; 'game:tick': { deltaTime: number }; 'game:stateChange': StateChangeEvent; 'tilesSelected': { tile1: Tile; tile2: Tile };