feat(04-04): implement restart() method with score preservation

- Added previousScore property to Game class
- Implemented restart() method that stores previousScore, resets grid/score/state/UI
- Added updatePreviousScoreDisplay() helper method
- Added game:restart event type to GameEvents interface
- Restart resets grid via initializeGrid(), resets score to 0, transitions state to IDLE
- Previous score preserved and displayed, hidden if 0
- All tests passing for restart functionality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
GSD Executor
2026-03-11 08:28:44 +00:00
parent 1b7324141e
commit c9d0fdbc63
3 changed files with 193 additions and 0 deletions
+110
View File
@@ -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');
});
});
});
+82
View File
@@ -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);
}
}
+1
View File
@@ -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 };