From f15ec45f242a7c34afab3d24663a26d0e08c0200 Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Mon, 9 Mar 2026 11:49:44 +0800 Subject: [PATCH 1/8] feature: Added Scheduler to core features (FM-814). --- src/scheduler/index.ts | 38 +++++++++++ src/scheduler/scheduler.ts | 105 ++++++++++++++++++++++++++++++ src/scheduler/timeout-registry.ts | 34 ++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/scheduler/index.ts create mode 100644 src/scheduler/scheduler.ts create mode 100644 src/scheduler/timeout-registry.ts diff --git a/src/scheduler/index.ts b/src/scheduler/index.ts new file mode 100644 index 0000000..4fbd2c5 --- /dev/null +++ b/src/scheduler/index.ts @@ -0,0 +1,38 @@ +import Scheduler from './scheduler'; +import { TimeoutRegistry } from './timeout-registry'; +import type { TimerId } from './scheduler'; + +class SchedulerService { + // Make scheduler a singleton. + private scheduler = new Scheduler(); + + /** + * Drives all active timers. Must be called every frame from the game loop. + * @param delta Time elapsed since last frame in milliseconds. + */ + update(delta: number): void { + this.scheduler.update(delta); + } + + /** + * Creates a new TimeoutRegistry instance scoped to a component or entity. + * Use the returned registry to schedule and manage timers tied to that scope. + * Call registry.cancelAll() on component teardown. + * @returns A new TimeoutRegistry instance. + */ + createRegistry(): TimeoutRegistry { + return new TimeoutRegistry(this.scheduler); + } + + /** + * Clears all active timers across all registries. + * Call on full game teardown. + */ + destroy(): void { + this.scheduler.destroy(); + } +} + +export const schedulerService = new SchedulerService(); +export type { TimerId }; +export type { TimeoutRegistry }; \ No newline at end of file diff --git a/src/scheduler/scheduler.ts b/src/scheduler/scheduler.ts new file mode 100644 index 0000000..dc8b9a4 --- /dev/null +++ b/src/scheduler/scheduler.ts @@ -0,0 +1,105 @@ +import { Opaque } from 'type-fest'; + +type TimerId = Opaque; + +type Timer = { + id: TimerId; + callback: () => void; + delay: number; + remaining: number; + loop: boolean; +}; + +let nextTimerId = 0; + +/** + * Custom scheduler for managing time-based events within the game loop. + * Unlike standard window.setTimeout/setInterval, this respects the game's + * pause state and is driven by deltaTime from the main update loop. + */ +export default class Scheduler { + private timers: Map = new Map(); + + /** + * Schedules a one-time callback after a specified delay. + * @param callback The function to execute. + * @param delay Delay in milliseconds. + * @returns A unique TimerId for clearing the timeout. + */ + setTimeout(callback: () => void, delay: number): TimerId { + const id = nextTimerId++ as TimerId; + this.timers.set(id, { + id, + callback, + delay, + remaining: delay, + loop: false, + }); + return id; + } + + /** + * Cancels a previously scheduled timeout or interval. + * @param id The ID of the timer to clear. + */ + cancelTimeout(id: TimerId): void { + if (id !== undefined && id !== null) { + this.timers.delete(id); + } + } + + /** + * Schedules a repeating callback at a specified interval. + * @param callback The function to execute. + * @param delay Interval in milliseconds. + * @returns A unique TimerId for clearing the interval. + */ + setInterval(callback: () => void, delay: number): TimerId { + const id = nextTimerId++ as TimerId; + const safeDelay = Math.max(1, delay); // Minimum 1ms to prevent runaway intervals + this.timers.set(id, { + id, + callback, + delay: safeDelay, + remaining: safeDelay, + loop: true, + }); + return id; + } + + /** + * Updates all active timers by subtracting the provided delta time. + * Executed by the main game loop. + * @param delta The time elapsed since the last update in milliseconds. + */ + update(delta: number): void { + const snapshot = [...this.timers.values()]; + for (const timer of snapshot) { + if (!this.timers.has(timer.id)) continue; // already cancelled + timer.remaining -= delta; + if (timer.remaining <= 0) { + try { + timer.callback(); + } catch (e) { + console.error("Error in scheduled callback:", e); + } + + if (timer.loop) { + timer.remaining += timer.delay; + } else { + this.cancelTimeout(timer.id); + } + } + } + } + + /** + * Clears all active timers and resets the scheduler state. + */ + destroy(): void { + this.timers.clear(); + } +} + + +export { TimerId }; \ No newline at end of file diff --git a/src/scheduler/timeout-registry.ts b/src/scheduler/timeout-registry.ts new file mode 100644 index 0000000..c3431ce --- /dev/null +++ b/src/scheduler/timeout-registry.ts @@ -0,0 +1,34 @@ +import Scheduler, { TimerId } from './scheduler'; + +export class TimeoutRegistry { + private timeouts: Set = new Set(); + private scheduler: Scheduler; + + constructor(scheduler: Scheduler) { + this.scheduler = scheduler; + } + + setTimeout(callback: () => void, delay: number): TimerId { + const timerId = this.scheduler.setTimeout(() => { + this.timeouts.delete(timerId); + callback(); + }, delay); + this.timeouts.add(timerId); + return timerId; + } + + cancel(timerId: TimerId | null | undefined): void { + if (timerId === null || timerId === undefined) { + return; + } + this.scheduler.cancelTimeout(timerId); + this.timeouts.delete(timerId); + } + + cancelAll(): void { + for (const timerId of this.timeouts) { + this.scheduler.cancelTimeout(timerId); + } + this.timeouts.clear(); + } +} From 2c044c5e86ba00a91feee0abf39169433d8e9ddd Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Mon, 9 Mar 2026 12:47:28 +0800 Subject: [PATCH 2/8] feature: Code clean up and added test spec for scheduler and timeout (FM-814). --- src/scheduler/scheduler.spec.ts | 55 +++++++++++++++++++++++++ src/scheduler/scheduler.ts | 12 +++--- src/scheduler/timeout-registry.spec.ts | 57 ++++++++++++++++++++++++++ src/scheduler/timeout-registry.ts | 11 +++-- 4 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 src/scheduler/scheduler.spec.ts create mode 100644 src/scheduler/timeout-registry.spec.ts diff --git a/src/scheduler/scheduler.spec.ts b/src/scheduler/scheduler.spec.ts new file mode 100644 index 0000000..fabd4b0 --- /dev/null +++ b/src/scheduler/scheduler.spec.ts @@ -0,0 +1,55 @@ +import Scheduler from './scheduler'; + +describe('Scheduler', () => { + let scheduler: Scheduler; + + beforeEach(() => { + scheduler = new Scheduler(); + }); + + it('should fire a setTimeout callback after the specified delay', () => { + const callback = jest.fn(); + scheduler.setTimeout(callback, 1000); + + scheduler.update(999); + expect(callback).not.toHaveBeenCalled(); + + scheduler.update(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not fire a setTimeout callback after it has been cancelled', () => { + const callback = jest.fn(); + const id = scheduler.setTimeout(callback, 1000); + + scheduler.cancel(id); + scheduler.update(1000); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should fire a setInterval callback repeatedly', () => { + const callback = jest.fn(); + scheduler.setInterval(callback, 500); + + scheduler.update(500); + expect(callback).toHaveBeenCalledTimes(1); + + scheduler.update(500); + expect(callback).toHaveBeenCalledTimes(2); + + scheduler.update(500); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('should clear all timers on destroy', () => { + const callback = jest.fn(); + scheduler.setTimeout(callback, 500); + scheduler.setInterval(callback, 200); + + scheduler.destroy(); + scheduler.update(1000); + + expect(callback).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/scheduler/scheduler.ts b/src/scheduler/scheduler.ts index dc8b9a4..84d2476 100644 --- a/src/scheduler/scheduler.ts +++ b/src/scheduler/scheduler.ts @@ -1,7 +1,6 @@ import { Opaque } from 'type-fest'; type TimerId = Opaque; - type Timer = { id: TimerId; callback: () => void; @@ -10,14 +9,13 @@ type Timer = { loop: boolean; }; -let nextTimerId = 0; - /** * Custom scheduler for managing time-based events within the game loop. * Unlike standard window.setTimeout/setInterval, this respects the game's * pause state and is driven by deltaTime from the main update loop. */ export default class Scheduler { + private nextTimerId = 0; private timers: Map = new Map(); /** @@ -27,7 +25,7 @@ export default class Scheduler { * @returns A unique TimerId for clearing the timeout. */ setTimeout(callback: () => void, delay: number): TimerId { - const id = nextTimerId++ as TimerId; + const id = this.nextTimerId++ as TimerId; this.timers.set(id, { id, callback, @@ -42,7 +40,7 @@ export default class Scheduler { * Cancels a previously scheduled timeout or interval. * @param id The ID of the timer to clear. */ - cancelTimeout(id: TimerId): void { + cancel(id: TimerId): void { if (id !== undefined && id !== null) { this.timers.delete(id); } @@ -55,7 +53,7 @@ export default class Scheduler { * @returns A unique TimerId for clearing the interval. */ setInterval(callback: () => void, delay: number): TimerId { - const id = nextTimerId++ as TimerId; + const id = this.nextTimerId++ as TimerId; const safeDelay = Math.max(1, delay); // Minimum 1ms to prevent runaway intervals this.timers.set(id, { id, @@ -87,7 +85,7 @@ export default class Scheduler { if (timer.loop) { timer.remaining += timer.delay; } else { - this.cancelTimeout(timer.id); + this.cancel(timer.id); } } } diff --git a/src/scheduler/timeout-registry.spec.ts b/src/scheduler/timeout-registry.spec.ts new file mode 100644 index 0000000..652c448 --- /dev/null +++ b/src/scheduler/timeout-registry.spec.ts @@ -0,0 +1,57 @@ +import Scheduler from './scheduler'; +import { TimeoutRegistry } from './timeout-registry'; + +describe('TimeoutRegistry', () => { + let scheduler: Scheduler; + let registry: TimeoutRegistry; + + beforeEach(() => { + scheduler = new Scheduler(); + registry = new TimeoutRegistry(scheduler); + }); + + it('should fire a setTimeout callback after the specified delay', () => { + const callback = jest.fn(); + registry.setTimeout(callback, 1000); + + scheduler.update(999); + expect(callback).not.toHaveBeenCalled(); + + scheduler.update(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not fire a callback after it has been cancelled', () => { + const callback = jest.fn(); + const id = registry.setTimeout(callback, 1000); + + registry.cancel(id); + scheduler.update(1000); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should cancel all timers on cancelAll', () => { + const callbackA = jest.fn(); + const callbackB = jest.fn(); + registry.setTimeout(callbackA, 500); + registry.setTimeout(callbackB, 800); + + registry.cancelAll(); + scheduler.update(1000); + + expect(callbackA).not.toHaveBeenCalled(); + expect(callbackB).not.toHaveBeenCalled(); + }); + + it('should auto-remove a timer from the registry after it fires', () => { + const callback = jest.fn(); + const id = registry.setTimeout(callback, 500); + + scheduler.update(500); + expect(callback).toHaveBeenCalledTimes(1); + + // cancel after fire should not throw + expect(() => registry.cancel(id)).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/scheduler/timeout-registry.ts b/src/scheduler/timeout-registry.ts index c3431ce..d74c2ec 100644 --- a/src/scheduler/timeout-registry.ts +++ b/src/scheduler/timeout-registry.ts @@ -21,14 +21,17 @@ export class TimeoutRegistry { if (timerId === null || timerId === undefined) { return; } - this.scheduler.cancelTimeout(timerId); + this.scheduler.cancel(timerId); this.timeouts.delete(timerId); } cancelAll(): void { - for (const timerId of this.timeouts) { - this.scheduler.cancelTimeout(timerId); + try { + for (const timerId of this.timeouts) { + this.scheduler.cancel(timerId); + } + } finally { + this.timeouts.clear(); } - this.timeouts.clear(); } } From c703823a60825f5d130366df238e15b1526e22e0 Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Mon, 9 Mar 2026 12:54:28 +0800 Subject: [PATCH 3/8] feature: Added scheduler service to main index for properly usage and made scheduler service a singleton (FM-814). --- src/index.ts | 3 ++- src/scheduler/index.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 528e585..9f79925 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export * from './canvas-renderer/canvas-renderer'; export * from './delta-time/delta-time'; export * from './scene-manager/scene-manager'; export * from './android-interface/android-interface'; -export * from './pub-sub/pub-sub'; \ No newline at end of file +export * from './pub-sub/pub-sub'; +export { default as schedulerService } from './scheduler' \ No newline at end of file diff --git a/src/scheduler/index.ts b/src/scheduler/index.ts index 4fbd2c5..febae45 100644 --- a/src/scheduler/index.ts +++ b/src/scheduler/index.ts @@ -3,7 +3,7 @@ import { TimeoutRegistry } from './timeout-registry'; import type { TimerId } from './scheduler'; class SchedulerService { - // Make scheduler a singleton. + // Making scheduler a singleton. private scheduler = new Scheduler(); /** @@ -33,6 +33,9 @@ class SchedulerService { } } -export const schedulerService = new SchedulerService(); export type { TimerId }; -export type { TimeoutRegistry }; \ No newline at end of file +export type { TimeoutRegistry }; + +// Making scheduler service a singleton. +const schedulerService = new SchedulerService(); +export default schedulerService; From a7ad8dc762929fd4476bd64c3fbe5a87bce8fae7 Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Mon, 9 Mar 2026 14:55:33 +0800 Subject: [PATCH 4/8] feature: Added documentation on README.md for scheduler (FM-814) --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/README.md b/README.md index dcd48c9..4939834 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ # core A collection of reusable core features, classes and functionalities used to create web-based apps and games. + +# SchedulerService + +A game-loop-driven timer scheduler that respects pause state. Provides `setTimeout` equivalents driven by `deltaTime` instead of the browser clock — so timers pause when your game pauses. + +--- + +## Usage + +### 1. Wire the game loop + +Call `update()` every frame. Skip it when paused — timers will naturally pause too. + +```typescript +import schedulerService from 'your-package'; + +draw(deltaTime: number) { + if (!this.isPaused) { + schedulerService.update(deltaTime); + } +} +``` + +--- + +### 2. Create a registry per component + +Call `createRegistry()` to get a `TimeoutRegistry` scoped to your component. Schedule timers through the registry and call `cancelAll()` on teardown. + +```typescript +import schedulerService from 'your-package'; +import type { TimeoutRegistry, TimerId } from '@curiouslearning/schedulerService'; + +class TutorialHandler { + private registry: TimeoutRegistry = schedulerService.createRegistry(); + private timerId: TimerId | null = null; + + startDelay() { + this.timerId = this.registry.setTimeout(() => { + this.onDelayComplete(); + }, 6000); + } + + reset() { + this.registry.cancel(this.timerId); + this.timerId = null; + } + + destroy() { + this.registry.cancelAll(); + } +} +``` + +--- + +## API + +### `schedulerService` + +The singleton instance. Import and use directly. + +| Method | Description | +|---|---| +| `update(delta: number)` | Ticks all active timers. Must be called every frame from the game loop. | +| `createRegistry()` | Returns a new `TimeoutRegistry` instance scoped to a component or entity. | +| `destroy()` | Clears all active timers. Call on full game teardown. | + +--- + +### `TimeoutRegistry` + +Returned by `schedulerService.createRegistry()`. Manages timers scoped to a single component or entity. + +| Method | Description | +|---|---| +| `setTimeout(callback, delay)` | Schedules a one-time callback after `delay` ms. Returns a `TimerId`. Auto-removes itself from the registry after firing. | +| `cancel(id)` | Cancels a specific timer by ID. Safely accepts `null` or `undefined`. | +| `cancelAll()` | Cancels all timers owned by this registry. Call on component teardown. | From 2e831b8ee5a7d5a2341b5244039acaef811facba Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Tue, 10 Mar 2026 16:50:23 +0800 Subject: [PATCH 5/8] feature: Code clean up based on coderabbit feedback (FM-814). --- README.md | 7 +++++++ src/scheduler/timeout-registry.spec.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4939834..d753617 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ draw(deltaTime: number) { } ``` +> **Note:** If used outside of `requestAnimationFrame` — for example in a custom loop that may produce high or unpredictable delta values — it is recommended to cap the delta time to prevent timers from misfiring: +> ```typescript +> const MAX_DELTA_MS = 100; +> schedulerService.update(Math.min(deltaTime, MAX_DELTA_MS)); +> ``` + + --- ### 2. Create a registry per component diff --git a/src/scheduler/timeout-registry.spec.ts b/src/scheduler/timeout-registry.spec.ts index 652c448..5ba6ba1 100644 --- a/src/scheduler/timeout-registry.spec.ts +++ b/src/scheduler/timeout-registry.spec.ts @@ -44,7 +44,7 @@ describe('TimeoutRegistry', () => { expect(callbackB).not.toHaveBeenCalled(); }); - it('should auto-remove a timer from the registry after it fires', () => { + it('should not throw when cancelling an already-fired timer', () => { const callback = jest.fn(); const id = registry.setTimeout(callback, 500); From 74d6d77557546707e523362a7403e68568ae98cb Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Tue, 10 Mar 2026 16:55:34 +0800 Subject: [PATCH 6/8] feature: Code clean up based on coderabbit feedback (FM-814). --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d753617..f16de5a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A game-loop-driven timer scheduler that respects pause state. Provides `setTimeo Call `update()` every frame. Skip it when paused — timers will naturally pause too. ```typescript -import schedulerService from 'your-package'; +import schedulerService from '@curiouslearning/schedulerService'; draw(deltaTime: number) { if (!this.isPaused) { From 321bd384e6dfe3d36d7d573bdc097e17e6fd508c Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Tue, 10 Mar 2026 16:56:53 +0800 Subject: [PATCH 7/8] feature: Code clean up based on coderabbit feedback (FM-814). --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f16de5a..da4ecac 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A game-loop-driven timer scheduler that respects pause state. Provides `setTimeo Call `update()` every frame. Skip it when paused — timers will naturally pause too. ```typescript -import schedulerService from '@curiouslearning/schedulerService'; +import schedulerService from '@curiouslearning/core'; draw(deltaTime: number) { if (!this.isPaused) { @@ -37,7 +37,7 @@ draw(deltaTime: number) { Call `createRegistry()` to get a `TimeoutRegistry` scoped to your component. Schedule timers through the registry and call `cancelAll()` on teardown. ```typescript -import schedulerService from 'your-package'; +import schedulerService from '@curiouslearning/core'; import type { TimeoutRegistry, TimerId } from '@curiouslearning/schedulerService'; class TutorialHandler { From 7081b354471ff818d7c4293d860c84f0c4e66adf Mon Sep 17 00:00:00 2001 From: Bernhard Cena Date: Tue, 10 Mar 2026 16:58:30 +0800 Subject: [PATCH 8/8] feature: Added documentation on README.md for scheduler (FM-814) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da4ecac..1a13c09 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Call `createRegistry()` to get a `TimeoutRegistry` scoped to your component. Sch ```typescript import schedulerService from '@curiouslearning/core'; -import type { TimeoutRegistry, TimerId } from '@curiouslearning/schedulerService'; +import type { TimeoutRegistry, TimerId } from '@curiouslearning/core'; class TutorialHandler { private registry: TimeoutRegistry = schedulerService.createRegistry();