From e46bc1db7a12ec1d30fec517f9f77906b5c2e3dd Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 10 Mar 2026 23:46:34 +0700 Subject: [PATCH] test(01-02): add failing tests for GameLoop class - Tests for start(), stop(), isRunning(), getRafId() - Tests for update callback with deltaTime - Tests for delta time accumulation - Tests for loop lifecycle (start/stop multiple times) Co-Authored-By: Claude Opus 4.6 --- src/__tests__/GameLoop.test.ts | 188 +++++++++++++++++++++++++++++++++ src/game/GameLoop.ts | 92 ++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/__tests__/GameLoop.test.ts create mode 100644 src/game/GameLoop.ts diff --git a/src/__tests__/GameLoop.test.ts b/src/__tests__/GameLoop.test.ts new file mode 100644 index 0000000..c7548b7 --- /dev/null +++ b/src/__tests__/GameLoop.test.ts @@ -0,0 +1,188 @@ +// src/__tests__/GameLoop.test.ts - Unit tests for GameLoop class +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GameLoop } from '../game/GameLoop'; + +describe('GameLoop', () => { + let gameLoop: GameLoop; + let updateCallback: ReturnType; + let originalRaf: typeof globalThis.requestAnimationFrame; + let originalCancelRaf: typeof globalThis.cancelAnimationFrame; + + beforeEach(() => { + updateCallback = vi.fn(); + originalRaf = globalThis.requestAnimationFrame; + originalCancelRaf = globalThis.cancelAnimationFrame; + + // Mock requestAnimationFrame and cancelAnimationFrame on global + let rafId = 0; + const rafCallbacks: Map = new Map(); + + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => { + rafId++; + rafCallbacks.set(rafId, cb); + return rafId; + }); + + globalThis.cancelAnimationFrame = vi.fn((id: number) => { + rafCallbacks.delete(id); + }); + }); + + afterEach(() => { + gameLoop.stop(); + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCancelRaf; + vi.restoreAllMocks(); + }); + + describe('start()', () => { + it('should set running to true', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + expect(gameLoop.isRunning()).toBe(true); + }); + + it('should call requestAnimationFrame', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + expect(globalThis.requestAnimationFrame).toHaveBeenCalled(); + }); + + it('should not start again if already running', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + const callCount = (globalThis.requestAnimationFrame as ReturnType).mock.calls.length; + gameLoop.start(); // Should be ignored + expect((globalThis.requestAnimationFrame as ReturnType).mock.calls.length).toBe(callCount); + }); + }); + + describe('stop()', () => { + it('should set running to false', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + gameLoop.stop(); + expect(gameLoop.isRunning()).toBe(false); + }); + + it('should cancel the animation frame', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + const rafId = gameLoop.getRafId(); + gameLoop.stop(); + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(rafId); + }); + + it('should be safe to call stop without start', () => { + gameLoop = new GameLoop(updateCallback); + expect(() => gameLoop.stop()).not.toThrow(); + expect(gameLoop.isRunning()).toBe(false); + }); + + it('should be safe to call stop multiple times', () => { + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + gameLoop.stop(); + gameLoop.stop(); + expect(gameLoop.isRunning()).toBe(false); + }); + }); + + describe('update callback', () => { + it('should call update with deltaTime when time has passed', () => { + vi.useFakeTimers(); + + let capturedCallback: FrameRequestCallback | null = null; + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => { + capturedCallback = cb; + return 1; + }); + + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + + // Simulate a frame with enough time for one tick (~16.67ms) + if (capturedCallback) { + capturedCallback(20); // 20ms passed + } + + // One tick should have been called + expect(updateCallback).toHaveBeenCalledWith(1000 / 60); + + vi.useRealTimers(); + }); + + it('should not call update when no time has passed', () => { + vi.useFakeTimers(); + + let capturedCallback: FrameRequestCallback | null = null; + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => { + capturedCallback = cb; + return 1; + }); + + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + + // Simulate a frame with no time passed + if (capturedCallback) { + capturedCallback(0); // No time passed + } + + expect(updateCallback).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); + + describe('loop lifecycle', () => { + it('should be able to start and stop multiple times', () => { + gameLoop = new GameLoop(updateCallback); + + gameLoop.start(); + expect(gameLoop.isRunning()).toBe(true); + + gameLoop.stop(); + expect(gameLoop.isRunning()).toBe(false); + + gameLoop.start(); + expect(gameLoop.isRunning()).toBe(true); + + gameLoop.stop(); + expect(gameLoop.isRunning()).toBe(false); + }); + }); + + describe('delta time accumulation', () => { + it('should accumulate multiple ticks correctly', () => { + vi.useFakeTimers(); + + let capturedCallback: FrameRequestCallback | null = null; + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => { + capturedCallback = cb; + return 1; + }); + + gameLoop = new GameLoop(updateCallback); + gameLoop.start(); + + // Simulate a frame with enough time for 3 ticks (~50ms) + if (capturedCallback) { + capturedCallback(50); + } + + // Three ticks should have been called + expect(updateCallback).toHaveBeenCalledTimes(3); + expect(updateCallback).toHaveBeenCalledWith(1000 / 60); + + vi.useRealTimers(); + }); + }); + + describe('tickLength', () => { + it('should target 60fps (16.67ms per tick)', () => { + gameLoop = new GameLoop(updateCallback); + expect(gameLoop.getTickLength()).toBeCloseTo(1000 / 60, 2); + }); + }); +}); diff --git a/src/game/GameLoop.ts b/src/game/GameLoop.ts new file mode 100644 index 0000000..35eed9e --- /dev/null +++ b/src/game/GameLoop.ts @@ -0,0 +1,92 @@ +// src/game/GameLoop.ts - requestAnimationFrame-based game loop +/** + * GameLoop provides a fixed-timestep game loop using requestAnimationFrame. + * It accumulates delta time and calls the update callback at 60fps. + */ + +export class GameLoop { + private readonly tickLength: number = 1000 / 60; // 60 FPS target + private lastTick: number = 0; + private rafId: number = 0; + private running: boolean = false; + + /** + * Creates a new GameLoop + * @param update - Callback function called with delta time in milliseconds + */ + constructor(private update: (deltaTime: number) => void) {} + + /** + * Starts the game loop + */ + start(): void { + if (this.running) return; + + this.running = true; + this.lastTick = performance.now(); + this.rafId = requestAnimationFrame(this.main); + } + + /** + * Stops the game loop + */ + stop(): void { + if (!this.running) return; + + this.running = false; + cancelAnimationFrame(this.rafId); + this.rafId = 0; + } + + /** + * Checks if the loop is currently running + */ + isRunning(): boolean { + return this.running; + } + + /** + * Gets the current requestAnimationFrame ID + */ + getRafId(): number { + return this.rafId; + } + + /** + * Gets the tick length in milliseconds + */ + getTickLength(): number { + return this.tickLength; + } + + /** + * Main loop callback - called by requestAnimationFrame + */ + private main = (timestamp: number): void => { + if (!this.running) return; + + this.rafId = requestAnimationFrame(this.main); + + // Calculate how many ticks have passed + const nextTick = this.lastTick + this.tickLength; + let numTicks = 0; + + if (timestamp > nextTick) { + const timeSinceTick = timestamp - this.lastTick; + numTicks = Math.floor(timeSinceTick / this.tickLength); + } + + // Process accumulated ticks + this.queueUpdates(numTicks); + }; + + /** + * Calls update for each accumulated tick + */ + private queueUpdates(numTicks: number): void { + for (let i = 0; i < numTicks; i++) { + this.lastTick += this.tickLength; + this.update(this.tickLength); + } + } +}