mirror of
https://github.com/tiennm99/gsd-framework.git
synced 2026-05-31 08:13:21 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
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<number, FrameRequestCallback> = 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<typeof vi.fn>).mock.calls.length;
|
||||
gameLoop.start(); // Should be ignored
|
||||
expect((globalThis.requestAnimationFrame as ReturnType<typeof vi.fn>).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user