@aura3d/core 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aura3d/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,6 +11,38 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@aura3d/math": "1.4.0"
15
- }
14
+ "@aura3d/math": "1.4.1"
15
+ },
16
+ "description": "Engine lifecycle, scheduling, deterministic time stepping, diagnostics, events, logging, task queues, and disposable resource scopes for Aura3D.",
17
+ "keywords": [
18
+ "aura3d",
19
+ "3d",
20
+ "browser-3d",
21
+ "web-3d",
22
+ "typescript",
23
+ "engine-core",
24
+ "scheduler",
25
+ "game-loop",
26
+ "diagnostics",
27
+ "events",
28
+ "lifecycle",
29
+ "deterministic"
30
+ ],
31
+ "homepage": "https://aura3d.auraone.ai",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/auraoneai/aura3d.git",
35
+ "directory": "packages/core"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/auraoneai/aura3d/issues"
39
+ },
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "files": [
45
+ "README.md",
46
+ "dist"
47
+ ]
16
48
  }
@@ -1,80 +0,0 @@
1
- export type MetricKind = "counter" | "gauge" | "timing";
2
-
3
- export interface MetricSnapshot {
4
- readonly kind: MetricKind;
5
- readonly name: string;
6
- readonly value: number;
7
- readonly samples: readonly number[];
8
- }
9
-
10
- export interface DiagnosticsSnapshot {
11
- readonly metrics: readonly MetricSnapshot[];
12
- readonly timestamp: number;
13
- }
14
-
15
- interface MutableMetric {
16
- kind: MetricKind;
17
- value: number;
18
- samples: number[];
19
- }
20
-
21
- export class Diagnostics {
22
- private readonly metrics = new Map<string, MutableMetric>();
23
- private readonly activeTimers = new Map<string, number>();
24
-
25
- constructor(private readonly maxHistory = 120) {}
26
-
27
- increment(name: string, amount = 1): void {
28
- const metric = this.ensure(name, "counter");
29
- metric.value += amount;
30
- this.recordSample(metric, metric.value);
31
- }
32
-
33
- gauge(name: string, value: number): void {
34
- const metric = this.ensure(name, "gauge");
35
- metric.value = value;
36
- this.recordSample(metric, value);
37
- }
38
-
39
- begin(name: string): void {
40
- if (this.activeTimers.has(name)) throw new Error(`Timer ${name} already started.`);
41
- this.activeTimers.set(name, performance.now());
42
- }
43
-
44
- end(name: string): number {
45
- const start = this.activeTimers.get(name);
46
- if (start === undefined) throw new Error(`Timer ${name} was not started.`);
47
- this.activeTimers.delete(name);
48
- const duration = performance.now() - start;
49
- const metric = this.ensure(name, "timing");
50
- metric.value = duration;
51
- this.recordSample(metric, duration);
52
- return duration;
53
- }
54
-
55
- snapshot(): DiagnosticsSnapshot {
56
- const metrics = [...this.metrics.entries()].map(([name, metric]) => Object.freeze({
57
- kind: metric.kind,
58
- name,
59
- value: metric.value,
60
- samples: Object.freeze([...metric.samples])
61
- }));
62
- return Object.freeze({ metrics: Object.freeze(metrics), timestamp: Date.now() });
63
- }
64
-
65
- private ensure(name: string, kind: MetricKind): MutableMetric {
66
- const existing = this.metrics.get(name);
67
- if (existing) {
68
- if (existing.kind !== kind) throw new Error(`Metric ${name} already exists as ${existing.kind}.`);
69
- return existing;
70
- }
71
- const metric: MutableMetric = { kind, value: 0, samples: [] };
72
- this.metrics.set(name, metric);
73
- return metric;
74
- }
75
-
76
- private recordSample(metric: MutableMetric, value: number): void {
77
- metric.samples.push(value);
78
- if (metric.samples.length > this.maxHistory) metric.samples.shift();
79
- }
80
- }
package/src/Disposable.ts DELETED
@@ -1,41 +0,0 @@
1
- export interface Disposable {
2
- dispose(): void | Promise<void>;
3
- }
4
-
5
- export function isDisposable(value: unknown): value is Disposable {
6
- return typeof value === "object" && value !== null && "dispose" in value && typeof (value as Disposable).dispose === "function";
7
- }
8
-
9
- export class DisposableStack implements Disposable {
10
- private readonly entries: Disposable[] = [];
11
- private disposed = false;
12
-
13
- use<T extends Disposable>(resource: T): T {
14
- if (this.disposed) {
15
- throw new Error("Cannot add a disposable to an already disposed stack.");
16
- }
17
- this.entries.push(resource);
18
- return resource;
19
- }
20
-
21
- get size(): number {
22
- return this.entries.length;
23
- }
24
-
25
- async dispose(): Promise<void> {
26
- if (this.disposed) return;
27
- this.disposed = true;
28
-
29
- const errors: unknown[] = [];
30
- while (this.entries.length > 0) {
31
- const entry = this.entries.pop();
32
- if (!entry) continue;
33
- try {
34
- await entry.dispose();
35
- } catch (error) {
36
- errors.push(error);
37
- }
38
- }
39
- if (errors.length > 0) throw new AggregateError(errors, "One or more disposables failed.");
40
- }
41
- }
package/src/Engine.ts DELETED
@@ -1,140 +0,0 @@
1
- import { DisposableStack } from "./Disposable.js";
2
- import { EngineLoop, type FrameContext } from "./EngineLoop.js";
3
- import { resolveEngineConfig, type EngineConfig, type ResolvedEngineConfig } from "./EngineConfig.js";
4
- import { LifecycleError } from "./Errors.js";
5
- import { EventBus } from "./EventBus.js";
6
- import { Diagnostics } from "./Diagnostics.js";
7
- import { Logger } from "./Logger.js";
8
- import { ResourceScope } from "./ResourceScope.js";
9
- import { Scheduler, SystemPhase } from "./Scheduler.js";
10
- import { TaskQueue } from "./TaskQueue.js";
11
-
12
- export type EngineState = "created" | "initializing" | "initialized" | "running" | "stopped" | "disposed" | "failed";
13
-
14
- export interface EngineEvents {
15
- stateChanged: { readonly previous: EngineState; readonly current: EngineState };
16
- frame: FrameContext;
17
- error: unknown;
18
- }
19
-
20
- export interface EnginePlugin {
21
- readonly id: string;
22
- init?(engine: Engine): void | Promise<void>;
23
- dispose?(): void | Promise<void>;
24
- }
25
-
26
- export class Engine {
27
- readonly config: ResolvedEngineConfig;
28
- readonly events = new EventBus<EngineEvents>((error) => this.events.emit("error", error));
29
- readonly diagnostics = new Diagnostics();
30
- readonly logger = new Logger();
31
- readonly scheduler = new Scheduler();
32
- readonly tasks = new TaskQueue();
33
- readonly resources = new ResourceScope("engine");
34
-
35
- private readonly plugins: EnginePlugin[] = [];
36
- private readonly pluginDisposables = new DisposableStack();
37
- private readonly loop: EngineLoop;
38
- private stateValue: EngineState = "created";
39
-
40
- constructor(config: EngineConfig = {}) {
41
- this.config = resolveEngineConfig(config);
42
- this.loop = new EngineLoop({
43
- mode: "manual",
44
- fixedDelta: this.config.fixedDelta,
45
- maxDelta: this.config.maxDelta,
46
- maxFixedSteps: this.config.maxFixedSteps,
47
- timeScale: this.config.timeScale
48
- }, (context) => this.runFrame(context));
49
- }
50
-
51
- get state(): EngineState {
52
- return this.stateValue;
53
- }
54
-
55
- use(plugin: EnginePlugin): this {
56
- if (this.stateValue !== "created") throw new LifecycleError("PLUGIN_AFTER_INIT", "Plugins must be registered before init.");
57
- if (this.plugins.some((existing) => existing.id === plugin.id)) throw new LifecycleError("DUPLICATE_PLUGIN", `Plugin ${plugin.id} already registered.`);
58
- this.plugins.push(plugin);
59
- return this;
60
- }
61
-
62
- async init(): Promise<void> {
63
- if (this.stateValue === "initialized" || this.stateValue === "running" || this.stateValue === "stopped") return;
64
- if (this.stateValue !== "created") throw new LifecycleError("INVALID_INIT", `Cannot init engine from ${this.stateValue}.`);
65
- this.setState("initializing");
66
- try {
67
- for (const plugin of this.plugins) {
68
- await plugin.init?.(this);
69
- if (plugin.dispose) this.pluginDisposables.use({ dispose: () => plugin.dispose?.() });
70
- }
71
- this.setState("initialized");
72
- if (this.config.autoStart) this.start();
73
- } catch (error) {
74
- this.setState("failed");
75
- await this.pluginDisposables.dispose();
76
- throw error;
77
- }
78
- }
79
-
80
- start(): void {
81
- if (this.stateValue !== "initialized" && this.stateValue !== "stopped") throw new LifecycleError("START_BEFORE_INIT", "Engine must be initialized before start.");
82
- this.loop.start();
83
- this.setState("running");
84
- }
85
-
86
- stop(): void {
87
- if (this.stateValue !== "running") return;
88
- this.loop.stop();
89
- this.setState("stopped");
90
- }
91
-
92
- async step(delta: number): Promise<FrameContext> {
93
- if (this.stateValue === "created") await this.init();
94
- if (this.stateValue !== "initialized" && this.stateValue !== "running" && this.stateValue !== "stopped") {
95
- throw new LifecycleError("STEP_INVALID_STATE", `Cannot step engine from ${this.stateValue}.`);
96
- }
97
- return this.loop.step(delta);
98
- }
99
-
100
- async dispose(): Promise<void> {
101
- if (this.stateValue === "disposed") return;
102
- if (this.stateValue === "running") this.stop();
103
- const errors: unknown[] = [];
104
- try {
105
- await this.pluginDisposables.dispose();
106
- } catch (error) {
107
- errors.push(error);
108
- }
109
- try {
110
- await this.resources.dispose();
111
- } catch (error) {
112
- errors.push(error);
113
- }
114
- this.events.clear();
115
- this.setState("disposed");
116
- if (errors.length > 0) throw new AggregateError(errors, "Engine disposal failed.");
117
- }
118
-
119
- private async runFrame(context: FrameContext): Promise<void> {
120
- this.diagnostics.increment("frames");
121
- await this.scheduler.runPhase(SystemPhase.Platform);
122
- await this.scheduler.runPhase(SystemPhase.Tasks);
123
- await this.tasks.flush();
124
- for (let step = 0; step < context.fixedSteps; step++) {
125
- await this.scheduler.runPhase(SystemPhase.Fixed, step);
126
- }
127
- await this.scheduler.runPhase(SystemPhase.Update);
128
- await this.scheduler.runPhase(SystemPhase.Scene);
129
- await this.scheduler.runPhase(SystemPhase.Render);
130
- await this.scheduler.runPhase(SystemPhase.Present);
131
- await this.scheduler.runPhase(SystemPhase.Cleanup);
132
- this.events.emit("frame", context);
133
- }
134
-
135
- private setState(next: EngineState): void {
136
- const previous = this.stateValue;
137
- this.stateValue = next;
138
- this.events.emit("stateChanged", Object.freeze({ previous, current: next }));
139
- }
140
- }
@@ -1,38 +0,0 @@
1
- import { ValidationError } from "./Errors.js";
2
-
3
- export interface EngineConfig {
4
- targetFPS?: number;
5
- fixedDelta?: number;
6
- maxDelta?: number;
7
- maxFixedSteps?: number;
8
- timeScale?: number;
9
- autoStart?: boolean;
10
- }
11
-
12
- export interface ResolvedEngineConfig {
13
- readonly targetFPS: number;
14
- readonly fixedDelta: number;
15
- readonly maxDelta: number;
16
- readonly maxFixedSteps: number;
17
- readonly timeScale: number;
18
- readonly autoStart: boolean;
19
- }
20
-
21
- export function resolveEngineConfig(config: EngineConfig = {}): ResolvedEngineConfig {
22
- const resolved = {
23
- targetFPS: config.targetFPS ?? 60,
24
- fixedDelta: config.fixedDelta ?? 1 / 60,
25
- maxDelta: config.maxDelta ?? 0.25,
26
- maxFixedSteps: config.maxFixedSteps ?? 5,
27
- timeScale: config.timeScale ?? 1,
28
- autoStart: config.autoStart ?? false
29
- };
30
-
31
- if (!Number.isFinite(resolved.targetFPS) || resolved.targetFPS <= 0) throw new ValidationError("INVALID_TARGET_FPS", "targetFPS must be positive.");
32
- if (!Number.isFinite(resolved.fixedDelta) || resolved.fixedDelta <= 0) throw new ValidationError("INVALID_FIXED_DELTA", "fixedDelta must be positive.");
33
- if (!Number.isFinite(resolved.maxDelta) || resolved.maxDelta <= 0) throw new ValidationError("INVALID_MAX_DELTA", "maxDelta must be positive.");
34
- if (!Number.isInteger(resolved.maxFixedSteps) || resolved.maxFixedSteps <= 0) throw new ValidationError("INVALID_MAX_FIXED_STEPS", "maxFixedSteps must be a positive integer.");
35
- if (!Number.isFinite(resolved.timeScale) || resolved.timeScale < 0) throw new ValidationError("INVALID_TIME_SCALE", "timeScale must be finite and non-negative.");
36
-
37
- return Object.freeze(resolved);
38
- }
package/src/EngineLoop.ts DELETED
@@ -1,83 +0,0 @@
1
- import { FixedStepAccumulator } from "./FixedStepAccumulator.js";
2
- import { Time, type TimeSnapshot } from "./Time.js";
3
-
4
- export type LoopMode = "manual" | "raf";
5
-
6
- export interface FrameContext {
7
- readonly time: TimeSnapshot;
8
- readonly fixedSteps: number;
9
- readonly interpolationAlpha: number;
10
- }
11
-
12
- export type FrameCallback = (context: FrameContext) => void | Promise<void>;
13
-
14
- export interface EngineLoopOptions {
15
- readonly mode: LoopMode;
16
- readonly fixedDelta: number;
17
- readonly maxDelta: number;
18
- readonly maxFixedSteps: number;
19
- readonly timeScale?: number;
20
- }
21
-
22
- export class EngineLoop {
23
- private readonly time: Time;
24
- private readonly accumulator: FixedStepAccumulator;
25
- private running = false;
26
- private rafHandle: number | undefined;
27
- private lastTimestamp: number | undefined;
28
-
29
- constructor(
30
- private readonly options: EngineLoopOptions,
31
- private readonly onFrame: FrameCallback
32
- ) {
33
- this.time = new Time(options.maxDelta, options.timeScale ?? 1);
34
- this.accumulator = new FixedStepAccumulator(options.fixedDelta, options.maxFixedSteps);
35
- }
36
-
37
- start(): void {
38
- if (this.running) return;
39
- this.running = true;
40
- if (this.options.mode === "raf") {
41
- if (typeof requestAnimationFrame !== "function") throw new Error("requestAnimationFrame is unavailable.");
42
- this.lastTimestamp = undefined;
43
- this.rafHandle = requestAnimationFrame((timestamp) => void this.tickRaf(timestamp));
44
- }
45
- }
46
-
47
- stop(): void {
48
- if (!this.running) return;
49
- this.running = false;
50
- if (this.rafHandle !== undefined && typeof cancelAnimationFrame === "function") {
51
- cancelAnimationFrame(this.rafHandle);
52
- }
53
- this.rafHandle = undefined;
54
- }
55
-
56
- pause(): void {
57
- this.stop();
58
- }
59
-
60
- resume(): void {
61
- this.start();
62
- }
63
-
64
- async step(delta: number): Promise<FrameContext> {
65
- const time = this.time.update(delta);
66
- const fixed = this.accumulator.add(time.delta);
67
- const context = Object.freeze({ time, fixedSteps: fixed.steps, interpolationAlpha: fixed.alpha });
68
- await this.onFrame(context);
69
- return context;
70
- }
71
-
72
- snapshot(): TimeSnapshot {
73
- return this.time.snapshot();
74
- }
75
-
76
- private async tickRaf(timestamp: number): Promise<void> {
77
- if (!this.running) return;
78
- const delta = this.lastTimestamp === undefined ? 0 : (timestamp - this.lastTimestamp) / 1000;
79
- this.lastTimestamp = timestamp;
80
- await this.step(delta);
81
- if (this.running) this.rafHandle = requestAnimationFrame((nextTimestamp) => void this.tickRaf(nextTimestamp));
82
- }
83
- }
package/src/Errors.ts DELETED
@@ -1,28 +0,0 @@
1
- export class EngineError extends Error {
2
- constructor(
3
- readonly code: string,
4
- message: string,
5
- options?: ErrorOptions
6
- ) {
7
- super(message, options);
8
- this.name = new.target.name;
9
- }
10
- }
11
-
12
- export class LifecycleError extends EngineError {
13
- constructor(code: string, message: string, options?: ErrorOptions) {
14
- super(`LIFECYCLE_${code}`, message, options);
15
- }
16
- }
17
-
18
- export class ValidationError extends EngineError {
19
- constructor(code: string, message: string, options?: ErrorOptions) {
20
- super(`VALIDATION_${code}`, message, options);
21
- }
22
- }
23
-
24
- export class ResourceError extends EngineError {
25
- constructor(code: string, message: string, options?: ErrorOptions) {
26
- super(`RESOURCE_${code}`, message, options);
27
- }
28
- }
package/src/EventBus.ts DELETED
@@ -1,52 +0,0 @@
1
- export type EventMapBase = object;
2
- export type EventListener<T> = (event: T) => void;
3
- export type EventErrorHandler = (error: unknown, eventName: string) => void;
4
-
5
- export interface Subscription {
6
- unsubscribe(): void;
7
- }
8
-
9
- export class EventBus<TEvents extends EventMapBase> {
10
- private readonly listeners = new Map<keyof TEvents, Set<EventListener<TEvents[keyof TEvents]>>>();
11
- private readonly onceListeners = new WeakSet<EventListener<TEvents[keyof TEvents]>>();
12
-
13
- constructor(private readonly onListenerError?: EventErrorHandler) {}
14
-
15
- on<K extends keyof TEvents>(eventName: K, listener: EventListener<TEvents[K]>): Subscription {
16
- const set = this.listeners.get(eventName) ?? new Set<EventListener<TEvents[keyof TEvents]>>();
17
- set.add(listener as EventListener<TEvents[keyof TEvents]>);
18
- this.listeners.set(eventName, set);
19
- return { unsubscribe: () => this.off(eventName, listener) };
20
- }
21
-
22
- once<K extends keyof TEvents>(eventName: K, listener: EventListener<TEvents[K]>): Subscription {
23
- const subscription = this.on(eventName, listener);
24
- this.onceListeners.add(listener as EventListener<TEvents[keyof TEvents]>);
25
- return subscription;
26
- }
27
-
28
- off<K extends keyof TEvents>(eventName: K, listener: EventListener<TEvents[K]>): void {
29
- const set = this.listeners.get(eventName);
30
- set?.delete(listener as EventListener<TEvents[keyof TEvents]>);
31
- if (set?.size === 0) this.listeners.delete(eventName);
32
- }
33
-
34
- emit<K extends keyof TEvents>(eventName: K, event: TEvents[K]): void {
35
- const snapshot = [...(this.listeners.get(eventName) ?? [])];
36
- for (const listener of snapshot) {
37
- if (!this.listeners.get(eventName)?.has(listener)) continue;
38
- try {
39
- (listener as EventListener<TEvents[K]>)(event);
40
- } catch (error) {
41
- this.onListenerError?.(error, String(eventName));
42
- }
43
- if (this.onceListeners.has(listener)) {
44
- this.listeners.get(eventName)?.delete(listener);
45
- }
46
- }
47
- }
48
-
49
- clear(): void {
50
- this.listeners.clear();
51
- }
52
- }
@@ -1,39 +0,0 @@
1
- export interface FixedStepResult {
2
- readonly steps: number;
3
- readonly alpha: number;
4
- readonly droppedTime: number;
5
- }
6
-
7
- export class FixedStepAccumulator {
8
- private accumulator = 0;
9
-
10
- constructor(
11
- readonly fixedDelta: number,
12
- readonly maxSteps: number
13
- ) {
14
- if (!Number.isFinite(fixedDelta) || fixedDelta <= 0) throw new RangeError("fixedDelta must be finite and positive.");
15
- if (!Number.isInteger(maxSteps) || maxSteps <= 0) throw new RangeError("maxSteps must be a positive integer.");
16
- }
17
-
18
- add(delta: number): FixedStepResult {
19
- if (!Number.isFinite(delta) || delta < 0) throw new RangeError("delta must be finite and non-negative.");
20
- this.accumulator += delta;
21
- const possibleSteps = Math.floor(this.accumulator / this.fixedDelta);
22
- const steps = Math.min(possibleSteps, this.maxSteps);
23
- this.accumulator -= steps * this.fixedDelta;
24
- let droppedTime = 0;
25
- if (possibleSteps > this.maxSteps) {
26
- droppedTime = this.accumulator;
27
- this.accumulator = 0;
28
- }
29
- return Object.freeze({ steps, alpha: this.alpha, droppedTime });
30
- }
31
-
32
- get alpha(): number {
33
- return this.accumulator / this.fixedDelta;
34
- }
35
-
36
- reset(): void {
37
- this.accumulator = 0;
38
- }
39
- }
package/src/Logger.ts DELETED
@@ -1,67 +0,0 @@
1
- export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
2
-
3
- export interface LogEntry {
4
- readonly level: Exclude<LogLevel, "silent">;
5
- readonly category: string;
6
- readonly message: string;
7
- readonly timestamp: number;
8
- readonly data?: unknown;
9
- }
10
-
11
- export type LogSink = (entry: LogEntry) => void;
12
-
13
- const levelRank: Record<LogLevel, number> = {
14
- debug: 10,
15
- info: 20,
16
- warn: 30,
17
- error: 40,
18
- silent: 50
19
- };
20
-
21
- export class Logger {
22
- private readonly sinks = new Set<LogSink>();
23
- private readonly lastEmitted = new Map<string, number>();
24
-
25
- constructor(private level: LogLevel = "info") {}
26
-
27
- setLevel(level: LogLevel): void {
28
- this.level = level;
29
- }
30
-
31
- addSink(sink: LogSink): () => void {
32
- this.sinks.add(sink);
33
- return () => this.sinks.delete(sink);
34
- }
35
-
36
- debug(category: string, message: string, data?: unknown): void {
37
- this.log("debug", category, message, data);
38
- }
39
-
40
- info(category: string, message: string, data?: unknown): void {
41
- this.log("info", category, message, data);
42
- }
43
-
44
- warn(category: string, message: string, data?: unknown): void {
45
- this.log("warn", category, message, data);
46
- }
47
-
48
- error(category: string, message: string, data?: unknown): void {
49
- this.log("error", category, message, data);
50
- }
51
-
52
- log(level: Exclude<LogLevel, "silent">, category: string, message: string, data?: unknown, rateLimitMs = 0): void {
53
- if (levelRank[level] < levelRank[this.level]) return;
54
- const key = `${level}:${category}:${message}`;
55
- const now = Date.now();
56
- if (rateLimitMs > 0 && now - (this.lastEmitted.get(key) ?? -Infinity) < rateLimitMs) return;
57
- this.lastEmitted.set(key, now);
58
- const entry: LogEntry = Object.freeze({ level, category, message, timestamp: now, data });
59
- for (const sink of [...this.sinks]) {
60
- try {
61
- sink(entry);
62
- } catch {
63
- this.sinks.delete(sink);
64
- }
65
- }
66
- }
67
- }
@@ -1,55 +0,0 @@
1
- import { DisposableStack, type Disposable } from "./Disposable.js";
2
-
3
- export interface ResourceLeak {
4
- name: string;
5
- resourceCount: number;
6
- childScopes: ResourceLeak[];
7
- }
8
-
9
- export class ResourceScope implements Disposable {
10
- private readonly stack = new DisposableStack();
11
- private readonly children = new Set<ResourceScope>();
12
- private disposed = false;
13
-
14
- constructor(readonly name: string) {}
15
-
16
- use<T extends Disposable>(resource: T): T {
17
- if (this.disposed) throw new Error(`Resource scope ${this.name} is disposed.`);
18
- return this.stack.use(resource);
19
- }
20
-
21
- createChild(name: string): ResourceScope {
22
- if (this.disposed) throw new Error(`Resource scope ${this.name} is disposed.`);
23
- const child = new ResourceScope(name);
24
- this.children.add(child);
25
- return child;
26
- }
27
-
28
- leakSnapshot(): ResourceLeak {
29
- return {
30
- name: this.name,
31
- resourceCount: this.stack.size,
32
- childScopes: [...this.children].map((child) => child.leakSnapshot())
33
- };
34
- }
35
-
36
- async dispose(): Promise<void> {
37
- if (this.disposed) return;
38
- this.disposed = true;
39
- const errors: unknown[] = [];
40
- for (const child of [...this.children].reverse()) {
41
- try {
42
- await child.dispose();
43
- } catch (error) {
44
- errors.push(error);
45
- }
46
- }
47
- this.children.clear();
48
- try {
49
- await this.stack.dispose();
50
- } catch (error) {
51
- errors.push(error);
52
- }
53
- if (errors.length > 0) throw new AggregateError(errors, `Resource scope ${this.name} failed to dispose.`);
54
- }
55
- }
package/src/Scheduler.ts DELETED
@@ -1,94 +0,0 @@
1
- import { ValidationError } from "./Errors.js";
2
-
3
- export const SystemPhase = Object.freeze({
4
- Platform: "platform",
5
- Tasks: "tasks",
6
- Fixed: "fixed",
7
- Update: "update",
8
- Scene: "scene",
9
- Render: "render",
10
- Present: "present",
11
- Cleanup: "cleanup"
12
- });
13
-
14
- export type SystemPhase = (typeof SystemPhase)[keyof typeof SystemPhase];
15
-
16
- export interface SchedulerContext {
17
- readonly phase: SystemPhase;
18
- readonly fixedStepIndex?: number;
19
- }
20
-
21
- export interface ScheduledTask {
22
- readonly id: string;
23
- readonly phase: SystemPhase;
24
- readonly dependsOn?: readonly string[];
25
- readonly enabled?: boolean;
26
- run(context: SchedulerContext): void | Promise<void>;
27
- }
28
-
29
- const phaseOrder = Object.values(SystemPhase);
30
-
31
- export class Scheduler {
32
- private readonly tasks = new Map<string, ScheduledTask>();
33
-
34
- add(task: ScheduledTask): void {
35
- if (this.tasks.has(task.id)) throw new ValidationError("DUPLICATE_TASK", `Task ${task.id} already exists.`);
36
- this.tasks.set(task.id, task);
37
- }
38
-
39
- remove(id: string): void {
40
- this.tasks.delete(id);
41
- }
42
-
43
- getExecutionPlan(): readonly ScheduledTask[] {
44
- const ordered: ScheduledTask[] = [];
45
- const byPhase = new Map<SystemPhase, ScheduledTask[]>();
46
- for (const task of this.tasks.values()) {
47
- if (task.enabled === false) continue;
48
- const list = byPhase.get(task.phase) ?? [];
49
- list.push(task);
50
- byPhase.set(task.phase, list);
51
- }
52
-
53
- for (const phase of phaseOrder) {
54
- ordered.push(...this.sortPhase(byPhase.get(phase) ?? []));
55
- }
56
- return Object.freeze(ordered);
57
- }
58
-
59
- async runPhase(phase: SystemPhase, fixedStepIndex?: number): Promise<void> {
60
- for (const task of this.getExecutionPlan()) {
61
- if (task.phase === phase) {
62
- const context: SchedulerContext = fixedStepIndex === undefined ? { phase } : { phase, fixedStepIndex };
63
- await task.run(context);
64
- }
65
- }
66
- }
67
-
68
- private sortPhase(tasks: readonly ScheduledTask[]): ScheduledTask[] {
69
- const taskIds = new Set(tasks.map((task) => task.id));
70
- const visiting = new Set<string>();
71
- const visited = new Set<string>();
72
- const output: ScheduledTask[] = [];
73
- const taskById = new Map(tasks.map((task) => [task.id, task]));
74
-
75
- const visit = (task: ScheduledTask): void => {
76
- if (visited.has(task.id)) return;
77
- if (visiting.has(task.id)) throw new ValidationError("TASK_CYCLE", `Scheduler cycle includes ${task.id}.`);
78
- visiting.add(task.id);
79
- for (const dependency of task.dependsOn ?? []) {
80
- const dep = this.tasks.get(dependency);
81
- if (!dep || dep.enabled === false) throw new ValidationError("MISSING_DEPENDENCY", `Task ${task.id} depends on missing task ${dependency}.`);
82
- if (dep.phase !== task.phase) throw new ValidationError("CROSS_PHASE_DEPENDENCY", `Task ${task.id} depends on ${dependency} in a different phase.`);
83
- if (!taskIds.has(dependency)) throw new ValidationError("MISSING_DEPENDENCY", `Task ${task.id} depends on missing phase peer ${dependency}.`);
84
- visit(taskById.get(dependency)!);
85
- }
86
- visiting.delete(task.id);
87
- visited.add(task.id);
88
- output.push(task);
89
- };
90
-
91
- for (const task of tasks) visit(task);
92
- return output;
93
- }
94
- }
package/src/TaskQueue.ts DELETED
@@ -1,50 +0,0 @@
1
- export interface TaskHandle {
2
- readonly id: number;
3
- cancel(): void;
4
- readonly cancelled: boolean;
5
- }
6
-
7
- interface QueuedTask {
8
- handle: MutableTaskHandle;
9
- task: () => void | Promise<void>;
10
- }
11
-
12
- class MutableTaskHandle implements TaskHandle {
13
- cancelled = false;
14
- constructor(readonly id: number) {}
15
- cancel(): void {
16
- this.cancelled = true;
17
- }
18
- }
19
-
20
- export class TaskQueue {
21
- private queue: QueuedTask[] = [];
22
- private nextId = 1;
23
-
24
- enqueue(task: () => void | Promise<void>): TaskHandle {
25
- const handle = new MutableTaskHandle(this.nextId++);
26
- this.queue.push({ handle, task });
27
- return handle;
28
- }
29
-
30
- get size(): number {
31
- return this.queue.length;
32
- }
33
-
34
- async flush(): Promise<void> {
35
- const errors: unknown[] = [];
36
- while (this.queue.length > 0) {
37
- const batch = this.queue;
38
- this.queue = [];
39
- for (const item of batch) {
40
- if (item.handle.cancelled) continue;
41
- try {
42
- await item.task();
43
- } catch (error) {
44
- errors.push(error);
45
- }
46
- }
47
- }
48
- if (errors.length > 0) throw new AggregateError(errors, "One or more queued tasks failed.");
49
- }
50
- }
package/src/Time.ts DELETED
@@ -1,54 +0,0 @@
1
- export interface TimeSnapshot {
2
- readonly frame: number;
3
- readonly elapsed: number;
4
- readonly unscaledElapsed: number;
5
- readonly delta: number;
6
- readonly unscaledDelta: number;
7
- readonly smoothedDelta: number;
8
- readonly timeScale: number;
9
- }
10
-
11
- export class Time {
12
- private frameValue = 0;
13
- private elapsedValue = 0;
14
- private unscaledElapsedValue = 0;
15
- private deltaValue = 0;
16
- private unscaledDeltaValue = 0;
17
- private smoothedDeltaValue = 0;
18
-
19
- constructor(
20
- private readonly maxDelta: number,
21
- private timeScaleValue = 1
22
- ) {
23
- if (!Number.isFinite(maxDelta) || maxDelta <= 0) throw new RangeError("maxDelta must be finite and positive.");
24
- this.setTimeScale(timeScaleValue);
25
- }
26
-
27
- setTimeScale(timeScale: number): void {
28
- if (!Number.isFinite(timeScale) || timeScale < 0) throw new RangeError("timeScale must be finite and non-negative.");
29
- this.timeScaleValue = timeScale;
30
- }
31
-
32
- update(unscaledDelta: number): TimeSnapshot {
33
- if (!Number.isFinite(unscaledDelta) || unscaledDelta < 0) throw new RangeError("delta must be finite and non-negative.");
34
- this.unscaledDeltaValue = Math.min(unscaledDelta, this.maxDelta);
35
- this.deltaValue = this.unscaledDeltaValue * this.timeScaleValue;
36
- this.unscaledElapsedValue += this.unscaledDeltaValue;
37
- this.elapsedValue += this.deltaValue;
38
- this.frameValue++;
39
- this.smoothedDeltaValue = this.frameValue === 1 ? this.deltaValue : this.smoothedDeltaValue * 0.9 + this.deltaValue * 0.1;
40
- return this.snapshot();
41
- }
42
-
43
- snapshot(): TimeSnapshot {
44
- return Object.freeze({
45
- frame: this.frameValue,
46
- elapsed: this.elapsedValue,
47
- unscaledElapsed: this.unscaledElapsedValue,
48
- delta: this.deltaValue,
49
- unscaledDelta: this.unscaledDeltaValue,
50
- smoothedDelta: this.smoothedDeltaValue,
51
- timeScale: this.timeScaleValue
52
- });
53
- }
54
- }
@@ -1,351 +0,0 @@
1
- import { ValidationError } from "./Errors.js";
2
-
3
- export type VersionedFieldKind =
4
- | "boolean"
5
- | "int32"
6
- | "uint32"
7
- | "float32"
8
- | "float64"
9
- | "string"
10
- | "array"
11
- | "object";
12
-
13
- export interface VersionedSchemaField {
14
- readonly name: string;
15
- readonly kind: VersionedFieldKind;
16
- readonly required?: boolean;
17
- readonly defaultValue?: unknown;
18
- readonly elementKind?: VersionedFieldKind;
19
- readonly schema?: VersionedSchema;
20
- }
21
-
22
- export interface VersionedSchemaValidation {
23
- readonly ok: boolean;
24
- readonly issues: readonly string[];
25
- }
26
-
27
- export class VersionedSchema {
28
- private readonly fields = new Map<string, VersionedSchemaField>();
29
-
30
- constructor(readonly name: string, readonly version: number) {
31
- if (!name.trim()) throw new ValidationError("SCHEMA_NAME", "VersionedSchema requires a non-empty name.");
32
- if (!Number.isSafeInteger(version) || version < 0) throw new ValidationError("SCHEMA_VERSION", "VersionedSchema version must be a non-negative integer.");
33
- }
34
-
35
- addField(field: VersionedSchemaField): this {
36
- if (!field.name.trim()) throw new ValidationError("SCHEMA_FIELD", "Schema field requires a non-empty name.");
37
- if (this.fields.has(field.name)) throw new ValidationError("SCHEMA_FIELD_DUPLICATE", `Duplicate schema field: ${field.name}`);
38
- if (field.kind === "array" && !field.elementKind) throw new ValidationError("SCHEMA_ARRAY_ELEMENT", `Array field ${field.name} requires elementKind.`);
39
- this.fields.set(field.name, field);
40
- return this;
41
- }
42
-
43
- getField(name: string): VersionedSchemaField | undefined {
44
- return this.fields.get(name);
45
- }
46
-
47
- getFields(): readonly VersionedSchemaField[] {
48
- return [...this.fields.values()];
49
- }
50
-
51
- validate(data: unknown): VersionedSchemaValidation {
52
- const issues: string[] = [];
53
- if (!isRecord(data)) {
54
- return { ok: false, issues: [`${this.name} must be an object.`] };
55
- }
56
- for (const field of this.fields.values()) {
57
- const value = data[field.name];
58
- if (value === undefined) {
59
- if (field.required && field.defaultValue === undefined) issues.push(`${field.name} is required.`);
60
- continue;
61
- }
62
- collectTypeIssues(value, field, field.name, issues);
63
- }
64
- return { ok: issues.length === 0, issues };
65
- }
66
-
67
- serialize(data: unknown): Record<string, unknown> {
68
- const validation = this.validate(data);
69
- if (!validation.ok) throw new ValidationError("SCHEMA_VALIDATE", validation.issues.join(" "));
70
- const record = data as Record<string, unknown>;
71
- const result: Record<string, unknown> = {};
72
- for (const field of this.fields.values()) {
73
- const value = record[field.name];
74
- if (value !== undefined) {
75
- result[field.name] = value;
76
- } else if (field.defaultValue !== undefined) {
77
- result[field.name] = cloneValue(field.defaultValue);
78
- }
79
- }
80
- return result;
81
- }
82
-
83
- deserialize(data: unknown): Record<string, unknown> {
84
- return this.serialize(data);
85
- }
86
-
87
- static builder(name: string, version: number): VersionedSchemaBuilder {
88
- return new VersionedSchemaBuilder(name, version);
89
- }
90
- }
91
-
92
- export class VersionedSchemaBuilder {
93
- private readonly schema: VersionedSchema;
94
-
95
- constructor(name: string, version: number) {
96
- this.schema = new VersionedSchema(name, version);
97
- }
98
-
99
- boolean(name: string, options: FieldOptions<boolean> = {}): this {
100
- return this.field({ name, kind: "boolean", ...options });
101
- }
102
-
103
- int32(name: string, options: FieldOptions<number> = {}): this {
104
- return this.field({ name, kind: "int32", ...options });
105
- }
106
-
107
- uint32(name: string, options: FieldOptions<number> = {}): this {
108
- return this.field({ name, kind: "uint32", ...options });
109
- }
110
-
111
- float32(name: string, options: FieldOptions<number> = {}): this {
112
- return this.field({ name, kind: "float32", ...options });
113
- }
114
-
115
- string(name: string, options: FieldOptions<string> = {}): this {
116
- return this.field({ name, kind: "string", ...options });
117
- }
118
-
119
- array(name: string, elementKind: VersionedFieldKind, options: FieldOptions<readonly unknown[]> = {}): this {
120
- return this.field({ name, kind: "array", elementKind, ...options });
121
- }
122
-
123
- object(name: string, schema?: VersionedSchema, options: FieldOptions<Record<string, unknown>> = {}): this {
124
- return this.field({ name, kind: "object", schema, ...options });
125
- }
126
-
127
- field(field: VersionedSchemaField): this {
128
- this.schema.addField(field);
129
- return this;
130
- }
131
-
132
- build(): VersionedSchema {
133
- return this.schema;
134
- }
135
- }
136
-
137
- export interface FieldOptions<T> {
138
- readonly required?: boolean;
139
- readonly defaultValue?: T;
140
- }
141
-
142
- export interface MigrationDefinition<T = unknown> {
143
- readonly from: number;
144
- readonly to: number;
145
- readonly description?: string;
146
- migrate(data: T): T;
147
- }
148
-
149
- export class MigrationRegistry<T = unknown> {
150
- private readonly migrations = new Map<number, MigrationDefinition<T>[]>();
151
-
152
- register(migration: MigrationDefinition<T>): this {
153
- if (!Number.isSafeInteger(migration.from) || !Number.isSafeInteger(migration.to) || migration.to <= migration.from) {
154
- throw new ValidationError("MIGRATION_VERSION", "Migration must move from a lower integer version to a higher integer version.");
155
- }
156
- const list = this.migrations.get(migration.from) ?? [];
157
- if (list.some((entry) => entry.to === migration.to)) {
158
- throw new ValidationError("MIGRATION_DUPLICATE", `Duplicate migration path: ${migration.from} -> ${migration.to}`);
159
- }
160
- list.push(migration);
161
- list.sort((left, right) => left.to - right.to);
162
- this.migrations.set(migration.from, list);
163
- return this;
164
- }
165
-
166
- canMigrate(from: number, to: number): boolean {
167
- try {
168
- this.plan(from, to);
169
- return true;
170
- } catch {
171
- return false;
172
- }
173
- }
174
-
175
- migrate(data: T, from: number, to: number): T {
176
- let current = data;
177
- for (const migration of this.plan(from, to)) {
178
- current = migration.migrate(current);
179
- }
180
- return current;
181
- }
182
-
183
- list(): readonly MigrationDefinition<T>[] {
184
- return [...this.migrations.values()].flat().sort((left, right) => left.from - right.from || left.to - right.to);
185
- }
186
-
187
- private plan(from: number, to: number): readonly MigrationDefinition<T>[] {
188
- if (from === to) return [];
189
- if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) {
190
- throw new ValidationError("MIGRATION_TARGET", "Migration target must be an integer version greater than or equal to the source.");
191
- }
192
- const plan: MigrationDefinition<T>[] = [];
193
- let current = from;
194
- while (current < to) {
195
- const next = (this.migrations.get(current) ?? []).find((migration) => migration.to <= to);
196
- if (!next) throw new ValidationError("MIGRATION_PATH", `No migration path from version ${current} to ${to}.`);
197
- plan.push(next);
198
- current = next.to;
199
- }
200
- return plan;
201
- }
202
- }
203
-
204
- export type SaveSlotFormat = "json" | "binary";
205
-
206
- export interface SaveSlotMetadata {
207
- readonly name?: string;
208
- readonly description?: string;
209
- readonly playTime?: number;
210
- readonly version?: string;
211
- readonly autoSave?: boolean;
212
- readonly [key: string]: unknown;
213
- }
214
-
215
- export interface SerializedSaveSlot {
216
- readonly id: string;
217
- readonly format: SaveSlotFormat;
218
- readonly timestamp: number;
219
- readonly metadata: SaveSlotMetadata;
220
- readonly compressed: boolean;
221
- readonly data: string;
222
- }
223
-
224
- export class SaveSlot {
225
- private data: string | Uint8Array | null = null;
226
- private format: SaveSlotFormat = "json";
227
- private timestamp: number;
228
- private metadata: SaveSlotMetadata = {};
229
- private compressed = false;
230
-
231
- constructor(readonly id: string, timestamp = Date.now()) {
232
- if (!id.trim()) throw new ValidationError("SAVE_SLOT_ID", "SaveSlot requires a non-empty id.");
233
- this.timestamp = timestamp;
234
- }
235
-
236
- setData(data: string | Uint8Array, format: SaveSlotFormat, options: { readonly compressed?: boolean } = {}): void {
237
- this.data = typeof data === "string" ? data : new Uint8Array(data);
238
- this.format = format;
239
- this.compressed = options.compressed ?? false;
240
- this.timestamp = Date.now();
241
- }
242
-
243
- getData(): string | Uint8Array | null {
244
- return typeof this.data === "string" || this.data === null ? this.data : new Uint8Array(this.data);
245
- }
246
-
247
- getFormat(): SaveSlotFormat {
248
- return this.format;
249
- }
250
-
251
- getTimestamp(): number {
252
- return this.timestamp;
253
- }
254
-
255
- getMetadata(): SaveSlotMetadata {
256
- return { ...this.metadata };
257
- }
258
-
259
- setMetadata(metadata: SaveSlotMetadata): void {
260
- this.metadata = { ...this.metadata, ...metadata };
261
- }
262
-
263
- isCompressed(): boolean {
264
- return this.compressed;
265
- }
266
-
267
- getSize(): number {
268
- if (this.data === null) return 0;
269
- return typeof this.data === "string" ? new TextEncoder().encode(this.data).byteLength : this.data.byteLength;
270
- }
271
-
272
- toJSON(): SerializedSaveSlot {
273
- return {
274
- id: this.id,
275
- format: this.format,
276
- timestamp: this.timestamp,
277
- metadata: { ...this.metadata },
278
- compressed: this.compressed,
279
- data: this.data instanceof Uint8Array ? bytesToBase64(this.data) : this.data ?? ""
280
- };
281
- }
282
-
283
- static fromJSON(serialized: SerializedSaveSlot): SaveSlot {
284
- const slot = new SaveSlot(serialized.id, serialized.timestamp);
285
- slot.format = serialized.format;
286
- slot.metadata = { ...serialized.metadata };
287
- slot.compressed = serialized.compressed;
288
- slot.data = serialized.format === "binary" ? base64ToBytes(serialized.data) : serialized.data;
289
- return slot;
290
- }
291
- }
292
-
293
- function collectTypeIssues(value: unknown, field: VersionedSchemaField, path: string, issues: string[]): void {
294
- if (!matchesKind(value, field.kind)) {
295
- issues.push(`${path} expected ${field.kind}.`);
296
- return;
297
- }
298
- if (field.kind === "array" && field.elementKind && Array.isArray(value)) {
299
- value.forEach((entry, index) => {
300
- if (!matchesKind(entry, field.elementKind as VersionedFieldKind)) issues.push(`${path}[${index}] expected ${field.elementKind}.`);
301
- });
302
- }
303
- if (field.kind === "object" && field.schema) {
304
- const nested = field.schema.validate(value);
305
- for (const issue of nested.issues) issues.push(`${path}.${issue}`);
306
- }
307
- }
308
-
309
- function matchesKind(value: unknown, kind: VersionedFieldKind): boolean {
310
- switch (kind) {
311
- case "boolean":
312
- return typeof value === "boolean";
313
- case "int32":
314
- return Number.isInteger(value) && Number(value) >= -2147483648 && Number(value) <= 2147483647;
315
- case "uint32":
316
- return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 4294967295;
317
- case "float32":
318
- case "float64":
319
- return typeof value === "number" && Number.isFinite(value);
320
- case "string":
321
- return typeof value === "string";
322
- case "array":
323
- return Array.isArray(value);
324
- case "object":
325
- return isRecord(value);
326
- }
327
- }
328
-
329
- function cloneValue(value: unknown): unknown {
330
- if (typeof structuredClone === "function") return structuredClone(value);
331
- return JSON.parse(JSON.stringify(value)) as unknown;
332
- }
333
-
334
- function isRecord(value: unknown): value is Record<string, unknown> {
335
- return typeof value === "object" && value !== null && !Array.isArray(value);
336
- }
337
-
338
- function bytesToBase64(bytes: Uint8Array): string {
339
- if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
340
- let binary = "";
341
- for (const byte of bytes) binary += String.fromCharCode(byte);
342
- return btoa(binary);
343
- }
344
-
345
- function base64ToBytes(base64: string): Uint8Array {
346
- if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(base64, "base64"));
347
- const binary = atob(base64);
348
- const bytes = new Uint8Array(binary.length);
349
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
350
- return bytes;
351
- }
package/src/index.ts DELETED
@@ -1,14 +0,0 @@
1
- export * from "./Engine.js";
2
- export * from "./EngineConfig.js";
3
- export * from "./EngineLoop.js";
4
- export * from "./Time.js";
5
- export * from "./FixedStepAccumulator.js";
6
- export * from "./EventBus.js";
7
- export * from "./Scheduler.js";
8
- export * from "./TaskQueue.js";
9
- export * from "./Logger.js";
10
- export * from "./Diagnostics.js";
11
- export * from "./Errors.js";
12
- export * from "./Disposable.js";
13
- export * from "./ResourceScope.js";
14
- export * from "./VersionedSerialization.js";