diff --git a/README.md b/README.md index dcd48c9..1a13c09 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ # 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 '@curiouslearning/core'; + +draw(deltaTime: number) { + if (!this.isPaused) { + schedulerService.update(deltaTime); + } +} +``` + +> **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 + +Call `createRegistry()` to get a `TimeoutRegistry` scoped to your component. Schedule timers through the registry and call `cancelAll()` on teardown. + +```typescript +import schedulerService from '@curiouslearning/core'; +import type { TimeoutRegistry, TimerId } from '@curiouslearning/core'; + +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. | 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 new file mode 100644 index 0000000..febae45 --- /dev/null +++ b/src/scheduler/index.ts @@ -0,0 +1,41 @@ +import Scheduler from './scheduler'; +import { TimeoutRegistry } from './timeout-registry'; +import type { TimerId } from './scheduler'; + +class SchedulerService { + // Making 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 type { TimerId }; +export type { TimeoutRegistry }; + +// Making scheduler service a singleton. +const schedulerService = new SchedulerService(); +export default schedulerService; 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 new file mode 100644 index 0000000..84d2476 --- /dev/null +++ b/src/scheduler/scheduler.ts @@ -0,0 +1,103 @@ +import { Opaque } from 'type-fest'; + +type TimerId = Opaque; +type Timer = { + id: TimerId; + callback: () => void; + delay: number; + remaining: number; + loop: boolean; +}; + +/** + * 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(); + + /** + * 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 = this.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. + */ + cancel(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 = this.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.cancel(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.spec.ts b/src/scheduler/timeout-registry.spec.ts new file mode 100644 index 0000000..5ba6ba1 --- /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 not throw when cancelling an already-fired timer', () => { + 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 new file mode 100644 index 0000000..d74c2ec --- /dev/null +++ b/src/scheduler/timeout-registry.ts @@ -0,0 +1,37 @@ +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.cancel(timerId); + this.timeouts.delete(timerId); + } + + cancelAll(): void { + try { + for (const timerId of this.timeouts) { + this.scheduler.cancel(timerId); + } + } finally { + this.timeouts.clear(); + } + } +}