@adatechnology/game-runtime 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/index.d.ts +183 -0
- package/dist/index.global.js +573 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +503 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anderson Fernandes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @adatechnology/game-runtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The owner of the frame. A phased scheduler with a fixed simulation step, and one time budget shared by every deferred queue of the kit. Layer L1: no three.js, no DOM unless `startLoop` runs on a page.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createScheduler, startLoop } from '@adatechnology/game-runtime'
|
|
7
|
+
|
|
8
|
+
const scheduler = createScheduler({ fixedDt: 1 / 60, maxFrameSeconds: 0.05 })
|
|
9
|
+
scheduler.add((ctx) => stepWorld(ctx.dt), { phase: 'fixedSimulate' })
|
|
10
|
+
scheduler.add((ctx) => drawWorld(ctx.alpha), { phase: 'present' })
|
|
11
|
+
scheduler.add(() => renderer.render(scene, camera), { phase: 'render' })
|
|
12
|
+
const stop = startLoop(scheduler)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Phases
|
|
16
|
+
|
|
17
|
+
`input` (real time) → `fixedSimulate` (0 to `maxSubsteps` steps of `fixedDt`, seeded work) → `lateSimulate` (scaled time, what `ModuleHost.onFrame` has always been) → `present` (state to the scene, queues of `present`) → `render` → `idle` (queues of `idle`).
|
|
18
|
+
|
|
19
|
+
- Pause stops `fixedSimulate` and `lateSimulate`; `timeScale` scales the accumulator and the dt of `lateSimulate` and `present`.
|
|
20
|
+
- The accumulator counts whole microseconds, so 60 frames of 1/60 give exactly 60 steps. A frame within `snapMs` (0.25) of a whole number of steps is snapped to it.
|
|
21
|
+
- `frame(seconds, { exact: true })` uses the time as given (no clamp, no snap, no cap): for tests and hosts without a loop.
|
|
22
|
+
- `advance(seconds, { dt })` steps the world synchronously with the budget unbounded and `ctx.scripted = true`: scenarios and tests.
|
|
23
|
+
- What input does to the simulation goes through `scheduler.commands` and is consumed at the next fixed tick.
|
|
24
|
+
|
|
25
|
+
## Budget
|
|
26
|
+
|
|
27
|
+
`scheduler.budget.register(queue, { priority, phase, reserveMs, minEveryFrames })` takes a `DeferredQueue` (a contract of `@adatechnology/game-contracts`) and calls its `attach()`: from then on the owner stops draining on its own. Queues of one phase run by priority inside a shared time budget (4 ms by default); a unit that goes over becomes debt for the next frame; a queue with work never waits more than `minEveryFrames`; `reserveMs` keeps time for a queue the others must not starve. A queue that was never registered keeps its own draining.
|
|
28
|
+
|
|
29
|
+
## Errors, bad times and teardown
|
|
30
|
+
|
|
31
|
+
- A system or a queue that throws is reported (`onError`, default: logged once per id; a queue's id is `queue:<id>`) and the frame goes on; a queue that threw counts 0 units. If a frame fails outside any system the scheduler is left usable: the next frame runs.
|
|
32
|
+
- A frame time that is NaN, infinite or negative counts as no time; a negative `timeScale` is 0 and a NaN one is 1; `advance` with a bad `dt` uses `fixedDt`.
|
|
33
|
+
- `budget.register` throws for a queue that is already registered. After `scheduler.dispose()`, `frame` and `advance` throw, `add` does nothing and the systems that were waiting to join are dropped.
|
|
34
|
+
- A host that runs the scheduler itself must call `frame` every frame: queues registered with the budget stop draining on their own.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { DeferredQueue, DeferredQueueOptions, FramePhase } from '@adatechnology/game-contracts';
|
|
2
|
+
export { DEFERRED_PRIORITY, DeferredQueue, DeferredQueueOptions, FRAME_PHASES, FramePhase } from '@adatechnology/game-contracts';
|
|
3
|
+
|
|
4
|
+
interface FrameContext {
|
|
5
|
+
/** Seconds this phase advances (see the phase table of the design). */
|
|
6
|
+
readonly dt: number;
|
|
7
|
+
/** Real seconds of this frame after the clamp and the snap, never scaled, not 0 when paused. */
|
|
8
|
+
readonly realDt: number;
|
|
9
|
+
/** The fixed step, seconds (1/60 by default). */
|
|
10
|
+
readonly fixedDt: number;
|
|
11
|
+
/** Accumulator / fixedDt after the fixed phase, in [0, 1). 0 inside and right after `advance`. */
|
|
12
|
+
readonly alpha: number;
|
|
13
|
+
/** Frames since start. */
|
|
14
|
+
readonly frame: number;
|
|
15
|
+
/** Fixed steps run since start: the clock of everything seeded. Inside `fixedSimulate`, the index of the step being run. */
|
|
16
|
+
readonly tick: number;
|
|
17
|
+
/** Index of the substep inside this frame (fixedSimulate only), else 0. */
|
|
18
|
+
readonly substep: number;
|
|
19
|
+
/** Simulated seconds since start (sum of the dt given to lateSimulate). */
|
|
20
|
+
readonly simTime: number;
|
|
21
|
+
readonly paused: boolean;
|
|
22
|
+
/** true inside `advance` (a scenario steps the world): systems that do not run in a scenario step read it and skip. */
|
|
23
|
+
readonly scripted: boolean;
|
|
24
|
+
/** false in a frame run with `simulate: false` (the display frame while a scenario steps the world), true otherwise. */
|
|
25
|
+
readonly simulated: boolean;
|
|
26
|
+
}
|
|
27
|
+
type SystemFn = (ctx: FrameContext) => void;
|
|
28
|
+
interface SystemOptions {
|
|
29
|
+
readonly phase: FramePhase;
|
|
30
|
+
/** Lower first; ties keep the order of registration. Default 0. */
|
|
31
|
+
readonly order?: number;
|
|
32
|
+
/** For stats and errors. */
|
|
33
|
+
readonly id?: string;
|
|
34
|
+
}
|
|
35
|
+
interface SchedulerOptions {
|
|
36
|
+
/** Default 1/60. */
|
|
37
|
+
readonly fixedDt?: number;
|
|
38
|
+
/** Default 4. What is left after the last substep is dropped (`stats().droppedSeconds`). */
|
|
39
|
+
readonly maxSubsteps?: number;
|
|
40
|
+
/** Real seconds of one frame are clamped to this. Default 0.25. */
|
|
41
|
+
readonly maxFrameSeconds?: number;
|
|
42
|
+
/** A frame time within this many ms of a whole number of fixed steps is snapped to it. Default 0.25. 0 turns it off. */
|
|
43
|
+
readonly snapMs?: number;
|
|
44
|
+
/** Frame budget for the deferred queues, ms. Default 4. */
|
|
45
|
+
readonly budgetMs?: number;
|
|
46
|
+
/** Milliseconds clock; default `performance.now` (or `Date.now`). Tests inject a fake. */
|
|
47
|
+
readonly clock?: () => number;
|
|
48
|
+
/** A system that throws: default logs once per system id and keeps the frame going. */
|
|
49
|
+
readonly onError?: (error: unknown, system: {
|
|
50
|
+
id: string;
|
|
51
|
+
phase: FramePhase;
|
|
52
|
+
}) => void;
|
|
53
|
+
}
|
|
54
|
+
interface FrameRunOptions {
|
|
55
|
+
/** false skips the render phase (headless tests, `room.update`). Default true. */
|
|
56
|
+
readonly render?: boolean;
|
|
57
|
+
/** false skips input, fixedSimulate and lateSimulate (a scenario is stepping the world synchronously). Default true. */
|
|
58
|
+
readonly simulate?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* true: `realSeconds` is used as given. No clamp, no snap, no `maxSubsteps` cap (every whole fixed step runs, the remainder stays in the
|
|
61
|
+
* accumulator, nothing goes to `droppedSeconds`), one `input` with `dt = realSeconds` and one `lateSimulate` with `dt = realSeconds * scale`.
|
|
62
|
+
*/
|
|
63
|
+
readonly exact?: boolean;
|
|
64
|
+
}
|
|
65
|
+
interface Command {
|
|
66
|
+
readonly kind: string;
|
|
67
|
+
readonly tick: number;
|
|
68
|
+
readonly payload?: unknown;
|
|
69
|
+
}
|
|
70
|
+
interface CommandBuffer {
|
|
71
|
+
/** From `input`: valid from the next fixed tick (or from `tick`, for a replay). */
|
|
72
|
+
push(kind: string, payload?: unknown, tick?: number): void;
|
|
73
|
+
/** From the first fixedSimulate system: the commands due at this tick or before, in push order. They leave the buffer. */
|
|
74
|
+
take(tick: number): readonly Command[];
|
|
75
|
+
/** Drops every pending command. */
|
|
76
|
+
clear(): void;
|
|
77
|
+
}
|
|
78
|
+
interface QueueStats {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
readonly pending: number;
|
|
81
|
+
readonly ranUnits: number;
|
|
82
|
+
readonly spentMs: number;
|
|
83
|
+
readonly framesWaiting: number;
|
|
84
|
+
readonly maxFramesWaiting: number;
|
|
85
|
+
readonly forced: number;
|
|
86
|
+
}
|
|
87
|
+
interface BudgetStats {
|
|
88
|
+
readonly ms: number;
|
|
89
|
+
/** Milliseconds spent by the queues in the last frame. */
|
|
90
|
+
readonly spentMs: number;
|
|
91
|
+
/** Milliseconds the last frame went over; repaid by the next one (capped at one budget). */
|
|
92
|
+
readonly debtMs: number;
|
|
93
|
+
readonly queues: readonly QueueStats[];
|
|
94
|
+
}
|
|
95
|
+
interface FrameBudget {
|
|
96
|
+
/** Milliseconds per frame shared by every queue. Default 4. */
|
|
97
|
+
ms: number;
|
|
98
|
+
/** Calls `queue.attach?.()`; the returned function calls `queue.detach?.()` and takes it out. */
|
|
99
|
+
register(queue: DeferredQueue, options: DeferredQueueOptions): () => void;
|
|
100
|
+
/** Starts a frame: counter, reserves and debt. The scheduler calls it; `drain('present')` starts one by itself when none is open. */
|
|
101
|
+
beginFrame(): void;
|
|
102
|
+
/** Drains the queues of one phase; the scheduler calls it, a host without the scheduler may too. */
|
|
103
|
+
drain(phase: 'present' | 'idle'): void;
|
|
104
|
+
/** Every queue to empty, now (synchronous step, clear). */
|
|
105
|
+
flushAll(): void;
|
|
106
|
+
/** Unbounded: every drain runs every queue to empty (scenarios, goldens). */
|
|
107
|
+
unbounded: boolean;
|
|
108
|
+
stats(): BudgetStats;
|
|
109
|
+
/** Detaches every queue (each owner goes back to its internal draining). */
|
|
110
|
+
dispose(): void;
|
|
111
|
+
}
|
|
112
|
+
interface SchedulerStats {
|
|
113
|
+
readonly frame: number;
|
|
114
|
+
readonly tick: number;
|
|
115
|
+
readonly substepsLastFrame: number;
|
|
116
|
+
readonly droppedSeconds: number;
|
|
117
|
+
/** Milliseconds per phase in the last frame. */
|
|
118
|
+
readonly phaseMs: Readonly<Record<FramePhase, number>>;
|
|
119
|
+
readonly budget: BudgetStats;
|
|
120
|
+
}
|
|
121
|
+
interface Scheduler {
|
|
122
|
+
add(system: SystemFn, options: SystemOptions): () => void;
|
|
123
|
+
/** Runs one frame with `realSeconds` of wall time. */
|
|
124
|
+
frame(realSeconds: number, options?: FrameRunOptions): void;
|
|
125
|
+
/**
|
|
126
|
+
* Advances `seconds` of simulation synchronously, in steps of `dt` (default fixedDt), with no render, `ctx.scripted = true` and the budget
|
|
127
|
+
* unbounded (every queue drained each step). Resets the accumulator before and after (`alpha = 0`). For scenarios and tests.
|
|
128
|
+
*/
|
|
129
|
+
advance(seconds: number, options?: {
|
|
130
|
+
readonly dt?: number;
|
|
131
|
+
}): void;
|
|
132
|
+
pause(): void;
|
|
133
|
+
resume(): void;
|
|
134
|
+
readonly paused: boolean;
|
|
135
|
+
/** 1 = real time, 0.25 = slow motion. Scales fixedSimulate's accumulator and lateSimulate/present dt. */
|
|
136
|
+
timeScale: number;
|
|
137
|
+
/** Forgets the accumulator and the snap residue (after the tab comes back, after a reset). */
|
|
138
|
+
resetClock(): void;
|
|
139
|
+
readonly budget: FrameBudget;
|
|
140
|
+
/** Commands the input phase leaves for the next fixed tick. */
|
|
141
|
+
readonly commands: CommandBuffer;
|
|
142
|
+
stats(): SchedulerStats;
|
|
143
|
+
dispose(): void;
|
|
144
|
+
}
|
|
145
|
+
interface LoopOptions {
|
|
146
|
+
/** Default `globalThis.requestAnimationFrame` / `cancelAnimationFrame`; a host without them passes its own (tests, workers). */
|
|
147
|
+
readonly requestFrame?: (callback: (nowMs: number) => void) => number;
|
|
148
|
+
readonly cancelFrame?: (handle: number) => void;
|
|
149
|
+
/** Timestamp the first frame is measured from; default `performance.now()`. */
|
|
150
|
+
readonly startMs?: number;
|
|
151
|
+
/** Called first in every frame with the unclamped real seconds (the room's metrics). */
|
|
152
|
+
readonly onFrame?: (realSeconds: number) => void;
|
|
153
|
+
/** true: this frame only presents and renders (a scenario is stepping the world). */
|
|
154
|
+
readonly skipSimulation?: () => boolean;
|
|
155
|
+
/**
|
|
156
|
+
* The least milliseconds between two frames that run (a frame cap): a display frame that comes sooner is skipped and its time goes to the next one. Asked on
|
|
157
|
+
* every display frame, so the cap may change while the loop runs; 0 or absent runs every display frame.
|
|
158
|
+
*/
|
|
159
|
+
readonly minFrameMs?: () => number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface FrameBudgetOptions {
|
|
163
|
+
readonly ms?: number;
|
|
164
|
+
readonly clock?: () => number;
|
|
165
|
+
/** A queue that throws in `run`: default logs once per queue id. The frame goes on and the queue counts 0 units. */
|
|
166
|
+
readonly onError?: (error: unknown, queue: {
|
|
167
|
+
readonly id: string;
|
|
168
|
+
readonly phase: 'present' | 'idle';
|
|
169
|
+
}) => void;
|
|
170
|
+
}
|
|
171
|
+
declare function createFrameBudget(options?: FrameBudgetOptions): FrameBudget;
|
|
172
|
+
|
|
173
|
+
/** `order` of the fixedSimulate system that takes the tick's commands: it runs before every other fixed system. */
|
|
174
|
+
declare const COMMAND_SYSTEM_ORDER = -10000;
|
|
175
|
+
/** `nextTick` answers which fixed tick runs next: that is the tick a command pushed from `input` becomes valid at. */
|
|
176
|
+
declare function createCommandBuffer(nextTick: () => number): CommandBuffer;
|
|
177
|
+
|
|
178
|
+
/** Drives `scheduler.frame` from the display's frames; listens to `visibilitychange` when there is a document. Returns the stop function. */
|
|
179
|
+
declare function startLoop(scheduler: Scheduler, options?: LoopOptions): () => void;
|
|
180
|
+
|
|
181
|
+
declare function createScheduler(options?: SchedulerOptions): Scheduler;
|
|
182
|
+
|
|
183
|
+
export { type BudgetStats, COMMAND_SYSTEM_ORDER, type Command, type CommandBuffer, type FrameBudget, type FrameBudgetOptions, type FrameContext, type FrameRunOptions, type LoopOptions, type QueueStats, type Scheduler, type SchedulerOptions, type SchedulerStats, type SystemFn, type SystemOptions, createCommandBuffer, createFrameBudget, createScheduler, startLoop };
|