diff --git a/src/__tests__/Game.test.ts b/src/__tests__/Game.test.ts new file mode 100644 index 0000000..1f42e64 --- /dev/null +++ b/src/__tests__/Game.test.ts @@ -0,0 +1,211 @@ +// Tests for src/game/Game.ts +// TDD tests for Game class orchestrator + +import { describe, it, expect, vi, beforeEach, afterEach, Mock } 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 }); + }); + }); +}); diff --git a/src/game/Game.ts b/src/game/Game.ts new file mode 100644 index 0000000..2039e17 --- /dev/null +++ b/src/game/Game.ts @@ -0,0 +1,103 @@ +// src/game/Game.ts - Main game orchestrator class +/** + * Game is the main orchestrator that coordinates all game components. + * It manages the canvas, game loop, and event system. + */ + +import { GameLoop } from './GameLoop'; +import { TypedEventEmitter } from './EventEmitter'; +import { GameEvents } from '../types'; +import { CONFIG } from '../config'; + +export class Game { + readonly canvas: HTMLCanvasElement; + readonly ctx: CanvasRenderingContext2D; + readonly loop: GameLoop; + readonly events: TypedEventEmitter; + + constructor() { + // Get canvas element + this.canvas = document.getElementById('game') as HTMLCanvasElement; + if (!this.canvas) { + throw new Error('Canvas element with id "game" not found'); + } + + // Get 2D rendering context + this.ctx = this.canvas.getContext('2d')!; + if (!this.ctx) { + throw new Error('Could not get 2D context from canvas'); + } + + // Initialize event emitter + this.events = new TypedEventEmitter(); + + // Setup canvas size and scale + this.setupCanvas(); + + // Create game loop with update callback + this.loop = new GameLoop(this.update.bind(this)); + } + + /** + * Sets up canvas dimensions and handles device pixel ratio for sharp rendering + */ + private setupCanvas(): void { + const { cols, rows } = CONFIG.grid; + const { size, gap } = CONFIG.tile; + const dpr = window.devicePixelRatio || 1; + + // Calculate logical canvas size + const width = cols * (size + gap) + gap; + const height = rows * (size + gap) + gap; + + // Set actual canvas size (accounting for device pixel ratio) + this.canvas.width = width * dpr; + this.canvas.height = height * dpr; + + // Set display size (CSS) + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + + // Scale context to account for device pixel ratio + this.ctx.scale(dpr, dpr); + } + + /** + * Starts the game + */ + start(): void { + // Emit game:start event + this.events.emit('game:start', undefined as never); + + // Start the game loop + this.loop.start(); + } + + /** + * Stops the game + */ + stop(): void { + this.loop.stop(); + } + + /** + * Update callback - called by GameLoop on each tick + * @param deltaTime - Time since last update in milliseconds + */ + private update(deltaTime: number): void { + // Emit game:tick event + this.events.emit('game:tick', { deltaTime }); + + // Render the current frame + this.render(); + } + + /** + * Renders the game state to the canvas + */ + private render(): void { + // Clear canvas with background color + this.ctx.fillStyle = CONFIG.colors.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } +}