@aura3d/core 1.0.6
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/README.md +51 -0
- package/dist/Diagnostics.d.ts +25 -0
- package/dist/Diagnostics.d.ts.map +1 -0
- package/dist/Diagnostics.js +60 -0
- package/dist/Diagnostics.js.map +1 -0
- package/dist/Disposable.d.ts +12 -0
- package/dist/Disposable.d.ts.map +1 -0
- package/dist/Disposable.js +37 -0
- package/dist/Disposable.js.map +1 -0
- package/dist/Engine.d.ts +46 -0
- package/dist/Engine.d.ts.map +1 -0
- package/dist/Engine.js +130 -0
- package/dist/Engine.js.map +1 -0
- package/dist/EngineConfig.d.ts +18 -0
- package/dist/EngineConfig.d.ts.map +1 -0
- package/dist/EngineConfig.js +23 -0
- package/dist/EngineConfig.js.map +1 -0
- package/dist/EngineLoop.d.ts +33 -0
- package/dist/EngineLoop.d.ts.map +1 -0
- package/dist/EngineLoop.js +63 -0
- package/dist/EngineLoop.js.map +1 -0
- package/dist/Errors.d.ts +14 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +24 -0
- package/dist/Errors.js.map +1 -0
- package/dist/EventBus.d.ts +18 -0
- package/dist/EventBus.d.ts.map +1 -0
- package/dist/EventBus.js +45 -0
- package/dist/EventBus.js.map +1 -0
- package/dist/FixedStepAccumulator.d.ts +15 -0
- package/dist/FixedStepAccumulator.d.ts.map +1 -0
- package/dist/FixedStepAccumulator.js +34 -0
- package/dist/FixedStepAccumulator.js.map +1 -0
- package/dist/Logger.d.ts +23 -0
- package/dist/Logger.d.ts.map +1 -0
- package/dist/Logger.js +53 -0
- package/dist/Logger.js.map +1 -0
- package/dist/ResourceScope.d.ts +18 -0
- package/dist/ResourceScope.d.ts.map +1 -0
- package/dist/ResourceScope.js +53 -0
- package/dist/ResourceScope.js.map +1 -0
- package/dist/Scheduler.d.ts +31 -0
- package/dist/Scheduler.d.ts.map +1 -0
- package/dist/Scheduler.js +77 -0
- package/dist/Scheduler.js.map +1 -0
- package/dist/TaskQueue.d.ts +13 -0
- package/dist/TaskQueue.d.ts.map +1 -0
- package/dist/TaskQueue.js +42 -0
- package/dist/TaskQueue.js.map +1 -0
- package/dist/Time.d.ts +24 -0
- package/dist/Time.d.ts.map +1 -0
- package/dist/Time.js +45 -0
- package/dist/Time.js.map +1 -0
- package/dist/VersionedSerialization.d.ts +96 -0
- package/dist/VersionedSerialization.d.ts.map +1 -0
- package/dist/VersionedSerialization.js +278 -0
- package/dist/VersionedSerialization.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/package.json +16 -0
- package/src/Diagnostics.ts +80 -0
- package/src/Disposable.ts +41 -0
- package/src/Engine.ts +140 -0
- package/src/EngineConfig.ts +38 -0
- package/src/EngineLoop.ts +83 -0
- package/src/Errors.ts +28 -0
- package/src/EventBus.ts +52 -0
- package/src/FixedStepAccumulator.ts +39 -0
- package/src/Logger.ts +67 -0
- package/src/ResourceScope.ts +55 -0
- package/src/Scheduler.ts +94 -0
- package/src/TaskQueue.ts +50 -0
- package/src/Time.ts +54 -0
- package/src/VersionedSerialization.ts +351 -0
- package/src/index.ts +14 -0
|
@@ -0,0 +1,83 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|