From 90e11198d0143ab15d049f9eb8c92a8c791810cb Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 7 Oct 2025 21:58:48 +0900 Subject: [PATCH] feat: add fixed timestep loop utility --- PROJECT_DESCRIPTION.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- docs/index.d.ts | 31 ++++++++++++ examples/fixedTimestep.ts | 15 ++++++ src/index.ts | 8 +++ src/util/fixedTimestep.ts | 99 +++++++++++++++++++++++++++++++++++++ tests/fixedTimestep.test.ts | 62 +++++++++++++++++++++++ tests/index.test.ts | 2 + 9 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 examples/fixedTimestep.ts create mode 100644 src/util/fixedTimestep.ts create mode 100644 tests/fixedTimestep.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 7e4da59..7d879e8 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -38,7 +38,7 @@ npm run build | Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | -| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | +| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle`, `createFixedTimestepLoop` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts`, `examples/fixedTimestep.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | diff --git a/README.md b/README.md index bbd7043..e2510ac 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ CDN usage: | Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | -| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | +| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle`, `createFixedTimestepLoop` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts`, `examples/fixedTimestep.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | diff --git a/ROADMAP.md b/ROADMAP.md index 23cae63..06202cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ - [x] Dungeon generation suite (BSP subdivision, rooms & corridors variants) - [ ] Maze algorithms pack (Recursive backtracking ✅, Prim's ✅, Kruskal's ✅, Wilson's ✅, Aldous–Broder ✅, Recursive Division ✅) - Gameplay systems & utilities: - - [ ] Fixed-timestep game loop utility with interpolation helpers + - [x] Fixed-timestep game loop utility with interpolation helpers - [ ] Delta-time manager for frame-independent timing - [x] Object pool helper for reusable entities - [x] Weighted random selector (alias method) diff --git a/docs/index.d.ts b/docs/index.d.ts index 64b10bd..41cc4cd 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -92,6 +92,7 @@ export const examples: { readonly createWeightedAliasSampler: 'examples/weightedAlias.ts'; readonly createObjectPool: 'examples/objectPool.ts'; readonly fisherYatesShuffle: 'examples/fisherYates.ts'; + readonly createFixedTimestepLoop: 'examples/fixedTimestep.ts'; }; readonly ai: { readonly seek: 'examples/steering.ts'; @@ -859,6 +860,36 @@ export function createObjectPool(options: ObjectPoolOptions): ObjectPool(items: T[], options?: { random?: () => number }): T[]; +/** + * Fixed timestep options for deterministic update loops. + * Use for: game loops, physics updates, consistent simulations. + * Import: util/fixedTimestep.ts + */ +export interface FixedTimestepOptions { + step: number; + maxDelta?: number; + update: (context: { alpha: number; accumulator: number; elapsed: number }) => void; + render?: (context: { alpha: number; accumulator: number; elapsed: number }) => void; +} + +/** + * Fixed timestep loop interface. + * Import: util/fixedTimestep.ts + */ +export interface FixedTimestepLoop { + start(): void; + stop(): void; + isRunning(): boolean; +} + +/** + * Creates a fixed timestep loop for deterministic updates. + * Use for: gameplay loops, physics, consistent tick simulation. + * Performance: O(n) updates per frame capped by maxDelta + * Import: util/fixedTimestep.ts + */ +export function createFixedTimestepLoop(options: FixedTimestepOptions): FixedTimestepLoop; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/fixedTimestep.ts b/examples/fixedTimestep.ts new file mode 100644 index 0000000..971976d --- /dev/null +++ b/examples/fixedTimestep.ts @@ -0,0 +1,15 @@ +import { createFixedTimestepLoop } from '../src/index.js'; + +let ticks = 0; +const loop = createFixedTimestepLoop({ + step: 1 / 60, + update: () => { + ticks += 1; + if (ticks >= 3) { + loop.stop(); + console.log('Stopped after 3 ticks'); + } + }, +}); + +loop.start(); diff --git a/src/index.ts b/src/index.ts index 2a87cea..9cfe608 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,7 @@ export const examples = { createWeightedAliasSampler: 'examples/weightedAlias.ts', createObjectPool: 'examples/objectPool.ts', fisherYatesShuffle: 'examples/fisherYates.ts', + createFixedTimestepLoop: 'examples/fixedTimestep.ts', }, ai: { seek: 'examples/steering.ts', @@ -431,6 +432,13 @@ export { createObjectPool } from './util/objectPool.js'; */ export { fisherYatesShuffle } from './util/fisherYates.js'; +/** + * Fixed timestep loop for deterministic gameplay updates. + * + * Example file: examples/fixedTimestep.ts + */ +export { createFixedTimestepLoop } from './util/fixedTimestep.js'; + /** * Virtual scroll type exports to help define rendering contracts. */ diff --git a/src/util/fixedTimestep.ts b/src/util/fixedTimestep.ts new file mode 100644 index 0000000..e22a9af --- /dev/null +++ b/src/util/fixedTimestep.ts @@ -0,0 +1,99 @@ +export interface FixedTimestepOptions { + /** Target fixed update step in seconds. */ + step: number; + /** Maximum delta to avoid spiral of death (seconds). */ + maxDelta?: number; + /** Callback invoked for each fixed update tick. */ + update: (context: { alpha: number; accumulator: number; elapsed: number }) => void; + /** Callback invoked for rendering/interpolation with interpolation alpha. */ + render?: (context: { alpha: number; accumulator: number; elapsed: number }) => void; +} + +export interface FixedTimestepLoop { + start(): void; + stop(): void; + isRunning(): boolean; +} + +/** + * Creates a fixed timestep loop ideal for gameplay update ticks. + * Useful for: deterministic game logic and interpolation between frames. + */ +export function createFixedTimestepLoop({ + step, + maxDelta = step * 5, + update, + render, +}: FixedTimestepOptions): FixedTimestepLoop { + if (step <= 0) { + throw new Error('step must be greater than zero.'); + } + if (typeof update !== 'function') { + throw new Error('update callback is required.'); + } + + let running = false; + let lastTime: number | undefined; + let accumulator = 0; + let frameId: number | undefined; + + const epsilon = step * 0.0001; + + function loop(timestamp: number) { + if (!running) { + return; + } + if (lastTime === undefined) { + lastTime = timestamp; + } + + let delta = (timestamp - lastTime) / 1000; + lastTime = timestamp; + if (delta > maxDelta) { + delta = maxDelta; + } + + accumulator += delta; + + while (accumulator + epsilon >= step) { + accumulator -= step; + update({ alpha: accumulator / step, accumulator, elapsed: step }); + } + if (accumulator < 0) { + accumulator = 0; + } + + if (render) { + render({ alpha: accumulator / step, accumulator, elapsed: delta }); + } + + frameId = requestAnimationFrame(loop); + } + + function start() { + if (running) { + return; + } + running = true; + lastTime = undefined; + accumulator = 0; + frameId = requestAnimationFrame(loop); + } + + function stop() { + if (!running) { + return; + } + running = false; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + } + + function isRunning(): boolean { + return running; + } + + return { start, stop, isRunning }; +} diff --git a/tests/fixedTimestep.test.ts b/tests/fixedTimestep.test.ts new file mode 100644 index 0000000..4373ffa --- /dev/null +++ b/tests/fixedTimestep.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createFixedTimestepLoop } from '../src/index.js'; + +const originalRAF = globalThis.requestAnimationFrame; +const originalCancel = globalThis.cancelAnimationFrame; + +afterEach(() => { + globalThis.requestAnimationFrame = originalRAF; + globalThis.cancelAnimationFrame = originalCancel; +}); + +describe('createFixedTimestepLoop', () => { + it('invokes update multiple times based on accumulated delta', () => { + let storedCallback: FrameRequestCallback | undefined; + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + storedCallback = cb; + return 1; + }); + globalThis.cancelAnimationFrame = vi.fn(); + + let updates = 0; + const loop = createFixedTimestepLoop({ + step: 0.1, + update: () => { + updates += 1; + }, + }); + + loop.start(); + expect(storedCallback).toBeDefined(); + storedCallback?.(0); + storedCallback?.(300); // 0.3 seconds -> 3 updates + expect(updates).toBe(3); + loop.stop(); + }); + + it('caps delta by maxDelta to avoid spiral', () => { + const callbacks: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + callbacks.push(cb); + return callbacks.length; + }); + globalThis.cancelAnimationFrame = vi.fn(); + + let totalElapsed = 0; + const loop = createFixedTimestepLoop({ + step: 0.1, + maxDelta: 0.3, + update: ({ elapsed }) => { + totalElapsed += elapsed; + }, + }); + + loop.start(); + expect(callbacks.length).toBeGreaterThan(0); + callbacks[0]?.(0); + callbacks[1]?.(1000); + expect(totalElapsed).toBeCloseTo(0.3, 5); + loop.stop(); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 92880f5..66a1903 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -23,6 +23,7 @@ describe('package entry point', () => { expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); expect(examples.performance.createObjectPool).toBe('examples/objectPool.ts'); expect(examples.performance.fisherYatesShuffle).toBe('examples/fisherYates.ts'); + expect(examples.performance.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); @@ -87,6 +88,7 @@ describe('package entry point', () => { | 'createWeightedAliasSampler' | 'createObjectPool' | 'fisherYatesShuffle' + | 'createFixedTimestepLoop' >(); }); });