@waica/engine 0.13.0 → 0.14.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/dist/animation/clip-player.js +6 -1
- package/dist/fixed-step-test-support.d.ts +9 -0
- package/dist/fixed-step-test-support.js +9 -0
- package/dist/fixed-step.d.ts +93 -0
- package/dist/fixed-step.js +109 -0
- package/dist/game.d.ts +56 -1
- package/dist/game.js +144 -42
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime-bridge.d.ts +16 -7
- package/dist/runtime-bridge.js +18 -14
- package/dist/state/state-machine.js +10 -2
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
1
2
|
/**
|
|
2
3
|
* Advances a clip through time. Pure logic (no three, no DOM) so it can
|
|
3
4
|
* be tested deterministically.
|
|
@@ -16,7 +17,11 @@ export class ClipPlayer {
|
|
|
16
17
|
/** Advances the clock and returns the sheet frame to show. */
|
|
17
18
|
advance(dt) {
|
|
18
19
|
this.t += dt;
|
|
19
|
-
|
|
20
|
+
// this.t is a sum of SIMULATION_STEP-sized dts, which float error can
|
|
21
|
+
// leave a hair under an exact multiple of a frame's duration; without
|
|
22
|
+
// the epsilon, floor(t * fps) holds some frames one step short and
|
|
23
|
+
// others one step long instead of a flat, even count per frame.
|
|
24
|
+
const idx = Math.floor((this.t + SIMULATION_TIME_EPSILON) * this.fps);
|
|
20
25
|
const n = this.frames.length;
|
|
21
26
|
const clamped = this.loop ? idx % n : Math.min(idx, n - 1);
|
|
22
27
|
return this.frames[clamped] ?? 0;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milliseconds per display frame at `hz`, for driving `Game`'s real
|
|
3
|
+
* animation-loop callback in a test. No epsilon pad: frame-rate snapping
|
|
4
|
+
* (`snapElapsedToStep`) is what makes a measured duration this close to a
|
|
5
|
+
* whole number of Simulation Steps count as exactly that many, not a float
|
|
6
|
+
* nudge chosen to dodge the exact boundary — `1000 / 60 / 1000 === 1 / 60`
|
|
7
|
+
* bit-for-bit already, before snapping even applies.
|
|
8
|
+
*/
|
|
9
|
+
export declare const frameMs: (hz: number) => number;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milliseconds per display frame at `hz`, for driving `Game`'s real
|
|
3
|
+
* animation-loop callback in a test. No epsilon pad: frame-rate snapping
|
|
4
|
+
* (`snapElapsedToStep`) is what makes a measured duration this close to a
|
|
5
|
+
* whole number of Simulation Steps count as exactly that many, not a float
|
|
6
|
+
* nudge chosen to dodge the exact boundary — `1000 / 60 / 1000 === 1 / 60`
|
|
7
|
+
* bit-for-bit already, before snapping even applies.
|
|
8
|
+
*/
|
|
9
|
+
export const frameMs = (hz) => 1000 / hz;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Simulation Step (ADR 0014): the fixed slice of game time by which the
|
|
3
|
+
* engine advances every component update, however often the display
|
|
4
|
+
* refreshes. An engine constant, not a project setting — the archetypes'
|
|
5
|
+
* feel is tuned against it, and the Runtime Bridge's `step` means exactly
|
|
6
|
+
* one of these.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SIMULATION_STEP: number;
|
|
9
|
+
/**
|
|
10
|
+
* The most steps one render frame may run. Beyond it the elapsed time is
|
|
11
|
+
* dropped, never repaid: a machine that cannot keep up falls behind the
|
|
12
|
+
* wall clock instead of spiralling into ever-longer frames. Six steps is
|
|
13
|
+
* 100 ms, the same ceiling the old per-frame clamp imposed.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAX_STEPS_PER_FRAME = 6;
|
|
16
|
+
/**
|
|
17
|
+
* Floating-point slack for comparing a value built by summing many
|
|
18
|
+
* SIMULATION_STEP-sized deltas (a state's `elapsed`, a countdown, a clip's
|
|
19
|
+
* playback clock) against a target duration. Each addition can leave the
|
|
20
|
+
* sum a hair under the mathematically exact value — 15 additions of 1/60
|
|
21
|
+
* give 0.24999999999999997, not 0.25 — by an error on the order of 1e-15,
|
|
22
|
+
* many orders of magnitude below this. Large enough to call a step-multiple
|
|
23
|
+
* duration exact, far too small to ever mistake a genuinely later step for
|
|
24
|
+
* an earlier one.
|
|
25
|
+
*/
|
|
26
|
+
export declare const SIMULATION_TIME_EPSILON = 1e-9;
|
|
27
|
+
/**
|
|
28
|
+
* Cap on same-tick, chained processing that could otherwise spin forever
|
|
29
|
+
* on a degenerate cycle: StateMachine settling a chain of transitions
|
|
30
|
+
* within one `onUpdate` (land → idle → run…) and Game draining a chain of
|
|
31
|
+
* `loadSceneByName` calls queued from a scene's own `onReady` within one
|
|
32
|
+
* frame. Shared so the two call sites' caps stay in lockstep instead of
|
|
33
|
+
* matching only by coincidence; eight is comfortably more hops than any
|
|
34
|
+
* real content chains in a single update.
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_CHAINED_HOPS = 8;
|
|
37
|
+
export interface SimulationSteps {
|
|
38
|
+
/** Whole steps this frame runs, 0 through MAX_STEPS_PER_FRAME. */
|
|
39
|
+
steps: number;
|
|
40
|
+
/** Seconds carried into the next frame; 0 whenever the cap was hit. */
|
|
41
|
+
remainder: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pure accumulator: from the time retained after the last frame and the
|
|
45
|
+
* seconds elapsed since it, how many whole steps to run now and what to
|
|
46
|
+
* keep. Hitting the cap discards the whole remainder (CA-2). A non-finite
|
|
47
|
+
* or negative `elapsed` (a NaN timestamp delta, a backwards clock) is
|
|
48
|
+
* treated as zero rather than poisoning the remainder or yielding negative
|
|
49
|
+
* steps — hardening against inputs a real rAF timestamp never produces.
|
|
50
|
+
*/
|
|
51
|
+
export declare function consumeSimulationSteps(remainder: number, elapsed: number): SimulationSteps;
|
|
52
|
+
/**
|
|
53
|
+
* How far a measured frame duration may sit from a whole number of
|
|
54
|
+
* Simulation Steps and still count as exactly that many (round 2
|
|
55
|
+
* correctness fix). Sized to absorb not just the sub-millisecond jitter a
|
|
56
|
+
* real 60 Hz `requestAnimationFrame` shows on Chromium — timestamp
|
|
57
|
+
* coarsening and float noise there put it at roughly 0.1-0.3 ms — but also
|
|
58
|
+
* the coarser 1-2 ms `requestAnimationFrame` timestamp rounding Firefox
|
|
59
|
+
* and Safari apply (their privacy/fingerprinting mitigation), without ever
|
|
60
|
+
* mistaking a genuine partial step for one of these snaps.
|
|
61
|
+
*/
|
|
62
|
+
export declare const STEP_SNAP_TOLERANCE = 0.0025;
|
|
63
|
+
export interface SnapResult {
|
|
64
|
+
/** The elapsed time to feed the accumulator: snapped, and possibly resynced. */
|
|
65
|
+
elapsed: number;
|
|
66
|
+
/** Time discarded by snapping so far, still owed to (or by) the wall clock. */
|
|
67
|
+
residual: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Frame-rate snapping (ADR 0014, round 2 correctness): a measured frame
|
|
71
|
+
* duration that lands within STEP_SNAP_TOLERANCE of an exact multiple of
|
|
72
|
+
* SIMULATION_STEP is treated as exactly that multiple. Without this, a
|
|
73
|
+
* display refreshing at exactly 60.00 Hz can measure e.g. 16.6666 ms
|
|
74
|
+
* instead of the true 16.6667 ms — `consumeSimulationSteps` then floors the
|
|
75
|
+
* whole-steps count to 0 that frame and 2 the next, a routine 0/2-step
|
|
76
|
+
* judder at the one refresh rate ADR 0014 calls exact; on Firefox and
|
|
77
|
+
* Safari the same judder shows up permanently, every frame, because their
|
|
78
|
+
* coarser timestamp rounding never lands as close to the exact multiple as
|
|
79
|
+
* a 0.25 ms tolerance required. Applied to the raw per-frame measurement
|
|
80
|
+
* before it ever reaches the accumulator, so `consumeSimulationSteps`
|
|
81
|
+
* itself — and CA-2's 0.034 s / 0.0999 s / 0.005 s examples, each well
|
|
82
|
+
* outside the tolerance — are untouched.
|
|
83
|
+
*
|
|
84
|
+
* Every snap discards `elapsed - target`, which is carried forward in
|
|
85
|
+
* `residual` (round 3 correctness) instead of vanishing: a display a hair
|
|
86
|
+
* off 60.00 Hz — 59.94 Hz, the common NTSC-derived panel rate, discards
|
|
87
|
+
* ~0.017 ms every frame — would otherwise drift from the wall clock
|
|
88
|
+
* without bound (measured: -3.6 s/h at 59.94 Hz). Once the accumulated
|
|
89
|
+
* residual reaches a whole Simulation Step, one step is repaid into
|
|
90
|
+
* `elapsed` right away and subtracted back out of the residual, so the
|
|
91
|
+
* simulation is never more than about one step away from the wall clock.
|
|
92
|
+
*/
|
|
93
|
+
export declare function snapElapsedToStep(elapsed: number, residual: number): SnapResult;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Simulation Step (ADR 0014): the fixed slice of game time by which the
|
|
3
|
+
* engine advances every component update, however often the display
|
|
4
|
+
* refreshes. An engine constant, not a project setting — the archetypes'
|
|
5
|
+
* feel is tuned against it, and the Runtime Bridge's `step` means exactly
|
|
6
|
+
* one of these.
|
|
7
|
+
*/
|
|
8
|
+
export const SIMULATION_STEP = 1 / 60;
|
|
9
|
+
/**
|
|
10
|
+
* The most steps one render frame may run. Beyond it the elapsed time is
|
|
11
|
+
* dropped, never repaid: a machine that cannot keep up falls behind the
|
|
12
|
+
* wall clock instead of spiralling into ever-longer frames. Six steps is
|
|
13
|
+
* 100 ms, the same ceiling the old per-frame clamp imposed.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_STEPS_PER_FRAME = 6;
|
|
16
|
+
/**
|
|
17
|
+
* Floating-point slack for comparing a value built by summing many
|
|
18
|
+
* SIMULATION_STEP-sized deltas (a state's `elapsed`, a countdown, a clip's
|
|
19
|
+
* playback clock) against a target duration. Each addition can leave the
|
|
20
|
+
* sum a hair under the mathematically exact value — 15 additions of 1/60
|
|
21
|
+
* give 0.24999999999999997, not 0.25 — by an error on the order of 1e-15,
|
|
22
|
+
* many orders of magnitude below this. Large enough to call a step-multiple
|
|
23
|
+
* duration exact, far too small to ever mistake a genuinely later step for
|
|
24
|
+
* an earlier one.
|
|
25
|
+
*/
|
|
26
|
+
export const SIMULATION_TIME_EPSILON = 1e-9;
|
|
27
|
+
/**
|
|
28
|
+
* Cap on same-tick, chained processing that could otherwise spin forever
|
|
29
|
+
* on a degenerate cycle: StateMachine settling a chain of transitions
|
|
30
|
+
* within one `onUpdate` (land → idle → run…) and Game draining a chain of
|
|
31
|
+
* `loadSceneByName` calls queued from a scene's own `onReady` within one
|
|
32
|
+
* frame. Shared so the two call sites' caps stay in lockstep instead of
|
|
33
|
+
* matching only by coincidence; eight is comfortably more hops than any
|
|
34
|
+
* real content chains in a single update.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_CHAINED_HOPS = 8;
|
|
37
|
+
/**
|
|
38
|
+
* Pure accumulator: from the time retained after the last frame and the
|
|
39
|
+
* seconds elapsed since it, how many whole steps to run now and what to
|
|
40
|
+
* keep. Hitting the cap discards the whole remainder (CA-2). A non-finite
|
|
41
|
+
* or negative `elapsed` (a NaN timestamp delta, a backwards clock) is
|
|
42
|
+
* treated as zero rather than poisoning the remainder or yielding negative
|
|
43
|
+
* steps — hardening against inputs a real rAF timestamp never produces.
|
|
44
|
+
*/
|
|
45
|
+
export function consumeSimulationSteps(remainder, elapsed) {
|
|
46
|
+
const safeElapsed = Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
|
|
47
|
+
const available = remainder + safeElapsed;
|
|
48
|
+
const whole = Math.floor(available / SIMULATION_STEP);
|
|
49
|
+
if (whole >= MAX_STEPS_PER_FRAME)
|
|
50
|
+
return { steps: MAX_STEPS_PER_FRAME, remainder: 0 };
|
|
51
|
+
return { steps: whole, remainder: available - whole * SIMULATION_STEP };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* How far a measured frame duration may sit from a whole number of
|
|
55
|
+
* Simulation Steps and still count as exactly that many (round 2
|
|
56
|
+
* correctness fix). Sized to absorb not just the sub-millisecond jitter a
|
|
57
|
+
* real 60 Hz `requestAnimationFrame` shows on Chromium — timestamp
|
|
58
|
+
* coarsening and float noise there put it at roughly 0.1-0.3 ms — but also
|
|
59
|
+
* the coarser 1-2 ms `requestAnimationFrame` timestamp rounding Firefox
|
|
60
|
+
* and Safari apply (their privacy/fingerprinting mitigation), without ever
|
|
61
|
+
* mistaking a genuine partial step for one of these snaps.
|
|
62
|
+
*/
|
|
63
|
+
export const STEP_SNAP_TOLERANCE = 0.0025; // seconds (2.5 ms)
|
|
64
|
+
/**
|
|
65
|
+
* Snapping considers only these small step counts: a display sitting
|
|
66
|
+
* exactly on one of the first few Simulation Step boundaries (60 Hz → 1,
|
|
67
|
+
* 30 Hz → 2, and so on) is the case timestamp jitter can push a hair off:
|
|
68
|
+
* higher counts already mean a hitch, where a fraction of a millisecond
|
|
69
|
+
* doesn't matter and forcing a snap would be presumptuous.
|
|
70
|
+
*/
|
|
71
|
+
const SNAPPABLE_STEPS = 4;
|
|
72
|
+
/**
|
|
73
|
+
* Frame-rate snapping (ADR 0014, round 2 correctness): a measured frame
|
|
74
|
+
* duration that lands within STEP_SNAP_TOLERANCE of an exact multiple of
|
|
75
|
+
* SIMULATION_STEP is treated as exactly that multiple. Without this, a
|
|
76
|
+
* display refreshing at exactly 60.00 Hz can measure e.g. 16.6666 ms
|
|
77
|
+
* instead of the true 16.6667 ms — `consumeSimulationSteps` then floors the
|
|
78
|
+
* whole-steps count to 0 that frame and 2 the next, a routine 0/2-step
|
|
79
|
+
* judder at the one refresh rate ADR 0014 calls exact; on Firefox and
|
|
80
|
+
* Safari the same judder shows up permanently, every frame, because their
|
|
81
|
+
* coarser timestamp rounding never lands as close to the exact multiple as
|
|
82
|
+
* a 0.25 ms tolerance required. Applied to the raw per-frame measurement
|
|
83
|
+
* before it ever reaches the accumulator, so `consumeSimulationSteps`
|
|
84
|
+
* itself — and CA-2's 0.034 s / 0.0999 s / 0.005 s examples, each well
|
|
85
|
+
* outside the tolerance — are untouched.
|
|
86
|
+
*
|
|
87
|
+
* Every snap discards `elapsed - target`, which is carried forward in
|
|
88
|
+
* `residual` (round 3 correctness) instead of vanishing: a display a hair
|
|
89
|
+
* off 60.00 Hz — 59.94 Hz, the common NTSC-derived panel rate, discards
|
|
90
|
+
* ~0.017 ms every frame — would otherwise drift from the wall clock
|
|
91
|
+
* without bound (measured: -3.6 s/h at 59.94 Hz). Once the accumulated
|
|
92
|
+
* residual reaches a whole Simulation Step, one step is repaid into
|
|
93
|
+
* `elapsed` right away and subtracted back out of the residual, so the
|
|
94
|
+
* simulation is never more than about one step away from the wall clock.
|
|
95
|
+
*/
|
|
96
|
+
export function snapElapsedToStep(elapsed, residual) {
|
|
97
|
+
for (let steps = 1; steps <= SNAPPABLE_STEPS; steps += 1) {
|
|
98
|
+
const target = steps * SIMULATION_STEP;
|
|
99
|
+
if (Math.abs(elapsed - target) < STEP_SNAP_TOLERANCE) {
|
|
100
|
+
const pending = residual + (elapsed - target);
|
|
101
|
+
if (Math.abs(pending) >= SIMULATION_STEP) {
|
|
102
|
+
const repaid = Math.sign(pending) * SIMULATION_STEP;
|
|
103
|
+
return { elapsed: target + repaid, residual: pending - repaid };
|
|
104
|
+
}
|
|
105
|
+
return { elapsed: target, residual: pending };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { elapsed, residual };
|
|
109
|
+
}
|
package/dist/game.d.ts
CHANGED
|
@@ -77,6 +77,14 @@ export declare class Game {
|
|
|
77
77
|
private readonly resizeObserver;
|
|
78
78
|
private readonly updateFns;
|
|
79
79
|
private readonly invalidUpdateCompositions;
|
|
80
|
+
/**
|
|
81
|
+
* componentUpdateSchedule's result for the last composition signature seen
|
|
82
|
+
* per entity: the resolve (Tarjan SCC + Kahn sort) it's built from is pure
|
|
83
|
+
* for a fixed composition, so a signature match skips it entirely. Only
|
|
84
|
+
* ever holds a successful resolution — an invalid composition is never
|
|
85
|
+
* cached, since invalidUpdateCompositions already dedupes its console.error.
|
|
86
|
+
*/
|
|
87
|
+
private readonly updateScheduleCache;
|
|
80
88
|
private readonly resolution;
|
|
81
89
|
/** The constructor's viewHeight — unloadScene() restores it. */
|
|
82
90
|
private readonly baseViewHeight;
|
|
@@ -84,7 +92,12 @@ export declare class Game {
|
|
|
84
92
|
private sceneCamera;
|
|
85
93
|
private renderSort;
|
|
86
94
|
private sceneProjection;
|
|
95
|
+
/** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
|
|
87
96
|
private lastTime;
|
|
97
|
+
/** Seconds of elapsed time not yet worth a whole Simulation Step (ADR 0014). */
|
|
98
|
+
private stepRemainder;
|
|
99
|
+
/** Seconds discarded by frame-rate snapping, not yet repaid (round 3 correctness). */
|
|
100
|
+
private snapResidual;
|
|
88
101
|
private runtimeBridge;
|
|
89
102
|
/** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
|
|
90
103
|
private sceneCatalog;
|
|
@@ -134,7 +147,12 @@ export declare class Game {
|
|
|
134
147
|
loadParams(url: string): Promise<void>;
|
|
135
148
|
/** Applies persisted overrides to a freshly added component. */
|
|
136
149
|
applyParamOverrides(entity: Entity, component: Component): void;
|
|
137
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Registers a function that runs once per Simulation Step, with the step
|
|
152
|
+
* as its dt (ADR 0014). While the Game is not simulating (the editor's edit
|
|
153
|
+
* mode) it runs once per render frame instead, so a host can keep drawing
|
|
154
|
+
* its overlays. Returns the unsubscribe.
|
|
155
|
+
*/
|
|
138
156
|
onUpdate(fn: UpdateFn): () => void;
|
|
139
157
|
/**
|
|
140
158
|
* Adopts a scene's render block. Called by loadScene; without a block the
|
|
@@ -157,9 +175,46 @@ export declare class Game {
|
|
|
157
175
|
setViewHeight(value: number): void;
|
|
158
176
|
/** Shuts the game down completely (loop, input, GPU). */
|
|
159
177
|
dispose(): void;
|
|
178
|
+
/**
|
|
179
|
+
* Real-time playback for the Runtime Bridge: the same clock-driven loop
|
|
180
|
+
* as start(), sharing the accumulator, with `onStep` told after every
|
|
181
|
+
* Simulation Step so the bridge counts frames exactly as paused stepping
|
|
182
|
+
* does (CA-7). No wall-clock catch-up: the first frame only seeds the
|
|
183
|
+
* clock (CA-3).
|
|
184
|
+
*/
|
|
160
185
|
private resumeRuntime;
|
|
186
|
+
/** Forgets the clock and any partial step, so the next frame runs no burst. */
|
|
187
|
+
private resetClock;
|
|
188
|
+
/**
|
|
189
|
+
* One animation frame (ADR 0014): the elapsed wall-clock time joins the
|
|
190
|
+
* retained remainder, and as many whole Simulation Steps as it holds run
|
|
191
|
+
* — capped, with the excess dropped, so a hitch can neither spiral nor
|
|
192
|
+
* play in slow motion. Not simulating: no time accrues at all. The
|
|
193
|
+
* measured duration is frame-rate-snapped first (round 2 correctness) so
|
|
194
|
+
* sub-millisecond timestamp jitter at an exact cadence like 60 Hz can't
|
|
195
|
+
* flip the whole-steps floor and judder 0/2/0/2.
|
|
196
|
+
*/
|
|
161
197
|
private tick;
|
|
198
|
+
/**
|
|
199
|
+
* Runs `steps` Simulation Steps back to back, then the once-per-frame
|
|
200
|
+
* tail: audio activity and placements, the UI overlay and the render
|
|
201
|
+
* (CA-5). A queued scene swap flushes at the very start of the frame —
|
|
202
|
+
* loadSceneByName's contract — and again before every step after the
|
|
203
|
+
* first (CA-4), so two steps in one frame never see the same press
|
|
204
|
+
* twice or the outgoing scene once too often; a frame that runs zero
|
|
205
|
+
* steps (round 2 correctness) still flushes, so it never renders/
|
|
206
|
+
* audio-places the outgoing scene one frame longer than it should.
|
|
207
|
+
*/
|
|
162
208
|
private runFrame;
|
|
209
|
+
/**
|
|
210
|
+
* One Simulation Step: the Component Update Schedule (ADR 0004) in full,
|
|
211
|
+
* collisions, the scene camera and the host's callbacks, every one of
|
|
212
|
+
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
213
|
+
*/
|
|
214
|
+
private simulateStep;
|
|
215
|
+
/** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
|
|
216
|
+
private finishStep;
|
|
217
|
+
private runHostUpdates;
|
|
163
218
|
private flushPendingSceneLoad;
|
|
164
219
|
private unregisterRuntimeBridge;
|
|
165
220
|
/** Under y-sort, re-derives every participant's z from layer band + entity Y. */
|
package/dist/game.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
|
6
6
|
import { Hitbox } from './components/hitbox.js';
|
|
7
7
|
import { Entity } from './entity.js';
|
|
8
8
|
import { Emitter } from './events.js';
|
|
9
|
+
import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
|
|
9
10
|
import { Input } from './input.js';
|
|
10
11
|
import { Pointer } from './pointer.js';
|
|
11
12
|
import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
|
|
@@ -44,6 +45,14 @@ export class Game {
|
|
|
44
45
|
resizeObserver;
|
|
45
46
|
updateFns = new Set();
|
|
46
47
|
invalidUpdateCompositions = new WeakMap();
|
|
48
|
+
/**
|
|
49
|
+
* componentUpdateSchedule's result for the last composition signature seen
|
|
50
|
+
* per entity: the resolve (Tarjan SCC + Kahn sort) it's built from is pure
|
|
51
|
+
* for a fixed composition, so a signature match skips it entirely. Only
|
|
52
|
+
* ever holds a successful resolution — an invalid composition is never
|
|
53
|
+
* cached, since invalidUpdateCompositions already dedupes its console.error.
|
|
54
|
+
*/
|
|
55
|
+
updateScheduleCache = new WeakMap();
|
|
47
56
|
resolution;
|
|
48
57
|
/** The constructor's viewHeight — unloadScene() restores it. */
|
|
49
58
|
baseViewHeight;
|
|
@@ -51,7 +60,12 @@ export class Game {
|
|
|
51
60
|
sceneCamera = null;
|
|
52
61
|
renderSort = null;
|
|
53
62
|
sceneProjection = null;
|
|
54
|
-
|
|
63
|
+
/** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
|
|
64
|
+
lastTime = null;
|
|
65
|
+
/** Seconds of elapsed time not yet worth a whole Simulation Step (ADR 0014). */
|
|
66
|
+
stepRemainder = 0;
|
|
67
|
+
/** Seconds discarded by frame-rate snapping, not yet repaid (round 3 correctness). */
|
|
68
|
+
snapResidual = 0;
|
|
55
69
|
runtimeBridge = null;
|
|
56
70
|
/** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
|
|
57
71
|
sceneCatalog = null;
|
|
@@ -217,7 +231,12 @@ export class Game {
|
|
|
217
231
|
if (override)
|
|
218
232
|
Object.assign(component, override);
|
|
219
233
|
}
|
|
220
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Registers a function that runs once per Simulation Step, with the step
|
|
236
|
+
* as its dt (ADR 0014). While the Game is not simulating (the editor's edit
|
|
237
|
+
* mode) it runs once per render frame instead, so a host can keep drawing
|
|
238
|
+
* its overlays. Returns the unsubscribe.
|
|
239
|
+
*/
|
|
221
240
|
onUpdate(fn) {
|
|
222
241
|
this.updateFns.add(fn);
|
|
223
242
|
return () => this.updateFns.delete(fn);
|
|
@@ -265,8 +284,8 @@ export class Game {
|
|
|
265
284
|
if (!this.runtimeBridge) {
|
|
266
285
|
const inspector = new RuntimeInspector(this);
|
|
267
286
|
this.runtimeBridge = new EngineRuntimeBridge(this.renderer.domElement, activation, {
|
|
268
|
-
step: (
|
|
269
|
-
resume: (
|
|
287
|
+
step: (onStep) => this.runFrame(1, onStep),
|
|
288
|
+
resume: (onStep) => this.resumeRuntime(onStep),
|
|
270
289
|
pause: () => this.stop(),
|
|
271
290
|
injectAction: (action, operation) => this.input.injectAction(action, operation),
|
|
272
291
|
availableActions: () => this.input.availableActions(),
|
|
@@ -285,6 +304,7 @@ export class Game {
|
|
|
285
304
|
this.renderSurface();
|
|
286
305
|
return;
|
|
287
306
|
}
|
|
307
|
+
this.resetClock();
|
|
288
308
|
this.renderer.setAnimationLoop((time) => this.tick(time));
|
|
289
309
|
}
|
|
290
310
|
stop() {
|
|
@@ -321,60 +341,129 @@ export class Game {
|
|
|
321
341
|
entity.destroy();
|
|
322
342
|
this.renderer.dispose();
|
|
323
343
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Real-time playback for the Runtime Bridge: the same clock-driven loop
|
|
346
|
+
* as start(), sharing the accumulator, with `onStep` told after every
|
|
347
|
+
* Simulation Step so the bridge counts frames exactly as paused stepping
|
|
348
|
+
* does (CA-7). No wall-clock catch-up: the first frame only seeds the
|
|
349
|
+
* clock (CA-3).
|
|
350
|
+
*/
|
|
351
|
+
resumeRuntime(onStep) {
|
|
352
|
+
this.resetClock();
|
|
353
|
+
this.renderer.setAnimationLoop((time) => this.tick(time, onStep));
|
|
331
354
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
355
|
+
/** Forgets the clock and any partial step, so the next frame runs no burst. */
|
|
356
|
+
resetClock() {
|
|
357
|
+
this.lastTime = null;
|
|
358
|
+
this.stepRemainder = 0;
|
|
359
|
+
this.snapResidual = 0;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* One animation frame (ADR 0014): the elapsed wall-clock time joins the
|
|
363
|
+
* retained remainder, and as many whole Simulation Steps as it holds run
|
|
364
|
+
* — capped, with the excess dropped, so a hitch can neither spiral nor
|
|
365
|
+
* play in slow motion. Not simulating: no time accrues at all. The
|
|
366
|
+
* measured duration is frame-rate-snapped first (round 2 correctness) so
|
|
367
|
+
* sub-millisecond timestamp jitter at an exact cadence like 60 Hz can't
|
|
368
|
+
* flip the whole-steps floor and judder 0/2/0/2.
|
|
369
|
+
*/
|
|
370
|
+
tick(time, onStep) {
|
|
371
|
+
const measured = this.lastTime === null ? 0 : (time - this.lastTime) / 1000;
|
|
335
372
|
this.lastTime = time;
|
|
336
|
-
this.
|
|
373
|
+
if (!this.simulate) {
|
|
374
|
+
this.stepRemainder = 0;
|
|
375
|
+
this.snapResidual = 0;
|
|
376
|
+
this.runFrame(0);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const { elapsed, residual } = snapElapsedToStep(measured, this.snapResidual);
|
|
380
|
+
this.snapResidual = residual;
|
|
381
|
+
const { steps, remainder } = consumeSimulationSteps(this.stepRemainder, elapsed);
|
|
382
|
+
this.stepRemainder = remainder;
|
|
383
|
+
this.runFrame(steps, onStep);
|
|
337
384
|
}
|
|
338
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Runs `steps` Simulation Steps back to back, then the once-per-frame
|
|
387
|
+
* tail: audio activity and placements, the UI overlay and the render
|
|
388
|
+
* (CA-5). A queued scene swap flushes at the very start of the frame —
|
|
389
|
+
* loadSceneByName's contract — and again before every step after the
|
|
390
|
+
* first (CA-4), so two steps in one frame never see the same press
|
|
391
|
+
* twice or the outgoing scene once too often; a frame that runs zero
|
|
392
|
+
* steps (round 2 correctness) still flushes, so it never renders/
|
|
393
|
+
* audio-places the outgoing scene one frame longer than it should.
|
|
394
|
+
*/
|
|
395
|
+
runFrame(steps, onStep) {
|
|
339
396
|
this.insideFrame = true;
|
|
340
397
|
try {
|
|
341
|
-
// Flushes a scene swap enqueued mid-frame last time (CA-7): applied
|
|
342
|
-
// before this frame's own simulation, so the incoming scene's
|
|
343
|
-
// entities are present only from this next frame onward.
|
|
344
398
|
this.flushPendingSceneLoad();
|
|
345
399
|
if (this.simulate) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
400
|
+
// Re-read every iteration, not just once before the loop: a
|
|
401
|
+
// component or host callback can set `simulate = false` mid-step,
|
|
402
|
+
// and the remaining steps of this catch-up frame must not run.
|
|
403
|
+
for (let index = 0; index < steps && this.simulate; index += 1) {
|
|
404
|
+
// Step 0 was just flushed above; only later steps need it again.
|
|
405
|
+
if (index > 0)
|
|
406
|
+
this.flushPendingSceneLoad();
|
|
407
|
+
this.simulateStep();
|
|
408
|
+
onStep?.();
|
|
352
409
|
}
|
|
353
|
-
this.dispatchCollisions();
|
|
354
|
-
this.updateSceneCamera(dt);
|
|
355
410
|
}
|
|
356
|
-
|
|
357
|
-
|
|
411
|
+
else {
|
|
412
|
+
// [DEVIATION 2026-09-12] The editor draws its edit-mode overlays from
|
|
413
|
+
// game.onUpdate with simulate = false (Viewport.tsx), exactly as it
|
|
414
|
+
// did before the fixed step: a non-simulating frame runs no step but
|
|
415
|
+
// still hands the host one callback and closes the input frame.
|
|
416
|
+
this.finishStep();
|
|
417
|
+
}
|
|
358
418
|
this.audio.setActive(this.simulate);
|
|
359
419
|
// Positional audio (CA-8): recomputed every frame, on this same pass —
|
|
360
420
|
// never a second walk of `this.entities`, since `this.audio` already
|
|
361
421
|
// holds direct references to whichever entities are tracked.
|
|
362
422
|
this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
|
|
363
|
-
for (const fn of this.updateFns)
|
|
364
|
-
fn(dt);
|
|
365
|
-
this.input.endFrame();
|
|
366
423
|
this.renderSurface();
|
|
367
424
|
}
|
|
368
425
|
finally {
|
|
369
426
|
this.insideFrame = false;
|
|
370
427
|
}
|
|
371
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* One Simulation Step: the Component Update Schedule (ADR 0004) in full,
|
|
431
|
+
* collisions, the scene camera and the host's callbacks, every one of
|
|
432
|
+
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
433
|
+
*/
|
|
434
|
+
simulateStep() {
|
|
435
|
+
for (const entity of [...this.entities]) {
|
|
436
|
+
const schedule = this.componentUpdateSchedule(entity);
|
|
437
|
+
if (!schedule)
|
|
438
|
+
continue;
|
|
439
|
+
for (const component of schedule)
|
|
440
|
+
component.onUpdate?.(SIMULATION_STEP);
|
|
441
|
+
}
|
|
442
|
+
this.dispatchCollisions();
|
|
443
|
+
this.updateSceneCamera(SIMULATION_STEP);
|
|
444
|
+
this.finishStep();
|
|
445
|
+
}
|
|
446
|
+
/** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
|
|
447
|
+
finishStep() {
|
|
448
|
+
this.runHostUpdates();
|
|
449
|
+
this.input.endFrame();
|
|
450
|
+
}
|
|
451
|
+
runHostUpdates() {
|
|
452
|
+
for (const fn of this.updateFns)
|
|
453
|
+
fn(SIMULATION_STEP);
|
|
454
|
+
}
|
|
372
455
|
flushPendingSceneLoad() {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
456
|
+
// Drains the whole chain, not just one level: a loadSceneByName called
|
|
457
|
+
// from the incoming scene's onReady (still insideFrame) re-queues
|
|
458
|
+
// pendingSceneLoad, and a frame that runs zero steps never reaches the
|
|
459
|
+
// per-step flush that would otherwise pick it up next. Capped like the
|
|
460
|
+
// state machine's chained-transition loop (MAX_CHAINED_HOPS), so a
|
|
461
|
+
// degenerate scene cycle can't hang here either.
|
|
462
|
+
for (let hops = 0; hops < MAX_CHAINED_HOPS && this.pendingSceneLoad; hops += 1) {
|
|
463
|
+
const pending = this.pendingSceneLoad;
|
|
464
|
+
this.pendingSceneLoad = null;
|
|
465
|
+
pending();
|
|
466
|
+
}
|
|
378
467
|
}
|
|
379
468
|
unregisterRuntimeBridge = () => {
|
|
380
469
|
window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
|
|
@@ -420,24 +509,36 @@ export class Game {
|
|
|
420
509
|
}
|
|
421
510
|
componentUpdateSchedule(entity) {
|
|
422
511
|
const components = [...entity.components];
|
|
423
|
-
const registry = {
|
|
424
|
-
...(this.registry?.components ?? {}),
|
|
425
|
-
};
|
|
426
512
|
const byName = new Map();
|
|
427
513
|
const names = [];
|
|
428
514
|
const signatureParts = [];
|
|
429
515
|
for (const component of components) {
|
|
430
516
|
const Class = component.constructor;
|
|
431
517
|
const name = Class.componentName;
|
|
432
|
-
registry[name] = Class;
|
|
433
518
|
names.push(name);
|
|
434
519
|
byName.set(name, component);
|
|
435
520
|
signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
|
|
436
521
|
[...new Set(Class.updateAfter ?? [])].sort().join(','));
|
|
437
522
|
}
|
|
523
|
+
const signature = signatureParts.sort().join('|');
|
|
524
|
+
// The resolve below (duplicate check + Tarjan SCC + Kahn sort) is pure
|
|
525
|
+
// for a fixed composition, and this method now runs once per entity per
|
|
526
|
+
// Simulation Step rather than once per rendered frame: skip it entirely
|
|
527
|
+
// when nothing about this entity's components changed since last time.
|
|
528
|
+
const cached = this.updateScheduleCache.get(entity);
|
|
529
|
+
if (cached && cached.signature === signature) {
|
|
530
|
+
return cached.order.map((name) => byName.get(name));
|
|
531
|
+
}
|
|
532
|
+
const registry = {
|
|
533
|
+
...(this.registry?.components ?? {}),
|
|
534
|
+
};
|
|
535
|
+
for (const component of components) {
|
|
536
|
+
const Class = component.constructor;
|
|
537
|
+
registry[Class.componentName] = Class;
|
|
538
|
+
}
|
|
438
539
|
const result = resolveComponentUpdateSchedule(names, registry);
|
|
439
540
|
if (!result.ok) {
|
|
440
|
-
|
|
541
|
+
this.updateScheduleCache.delete(entity);
|
|
441
542
|
if (this.invalidUpdateCompositions.get(entity) !== signature) {
|
|
442
543
|
this.invalidUpdateCompositions.set(entity, signature);
|
|
443
544
|
console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
|
|
@@ -446,6 +547,7 @@ export class Game {
|
|
|
446
547
|
return null;
|
|
447
548
|
}
|
|
448
549
|
this.invalidUpdateCompositions.delete(entity);
|
|
550
|
+
this.updateScheduleCache.set(entity, { signature, order: result.order });
|
|
449
551
|
return result.order.map((name) => byName.get(name));
|
|
450
552
|
}
|
|
451
553
|
updateSceneCamera(dt) {
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type { ProjectedPoint } from './projection.js';
|
|
|
13
13
|
export { spritePlacement } from './sprite-placement.js';
|
|
14
14
|
export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
|
|
15
15
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
16
|
+
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
16
17
|
export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
|
|
17
18
|
export { Entity } from './entity.js';
|
|
18
19
|
export { Component } from './component.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
|
5
5
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
6
6
|
export { spritePlacement } from './sprite-placement.js';
|
|
7
7
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
8
|
+
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
8
9
|
export { Entity } from './entity.js';
|
|
9
10
|
export { Component } from './component.js';
|
|
10
11
|
export { authoringDefaults } from './authoring-defaults.js';
|
package/dist/runtime-bridge.d.ts
CHANGED
|
@@ -9,9 +9,10 @@ export type RuntimeMode = 'paused' | 'real-time';
|
|
|
9
9
|
* pre-CA-10 engine simply lacks this field, which is exactly what callers
|
|
10
10
|
* gating on capabilities check for (review finding #4) — protocol 1 alone
|
|
11
11
|
* doesn't distinguish an engine that silently no-ops an unknown operation
|
|
12
|
-
* from one that runs it.
|
|
12
|
+
* from one that runs it. `fixed-step` (ADR 0014) announces that `step`
|
|
13
|
+
* advances whole 1/60 s Simulation Steps and takes no `dt`.
|
|
13
14
|
*/
|
|
14
|
-
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene'];
|
|
15
|
+
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step'];
|
|
15
16
|
export interface RuntimeMetadata {
|
|
16
17
|
bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
|
|
17
18
|
engineVersion: string;
|
|
@@ -25,9 +26,10 @@ export type RuntimeControlRequest = {
|
|
|
25
26
|
action: string;
|
|
26
27
|
} | {
|
|
27
28
|
operation: 'pause' | 'resume';
|
|
28
|
-
}
|
|
29
|
+
}
|
|
30
|
+
/** Advances `frames` whole Simulation Steps (1/60 s each, ADR 0014); default 1. */
|
|
31
|
+
| {
|
|
29
32
|
operation: 'step';
|
|
30
|
-
dt?: number;
|
|
31
33
|
frames?: number;
|
|
32
34
|
} | {
|
|
33
35
|
operation: 'click';
|
|
@@ -62,8 +64,14 @@ export interface RuntimeBridgeActivation {
|
|
|
62
64
|
}
|
|
63
65
|
export declare function activeRuntimeBridgeHook(): RuntimeBridgeActivation | null;
|
|
64
66
|
export interface RuntimeBridgeHost {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Runs exactly one Simulation Step and renders; `onStep` is told only if a
|
|
69
|
+
* step actually ran (the Game may be non-simulating, in which case this
|
|
70
|
+
* renders a frame but advances nothing).
|
|
71
|
+
*/
|
|
72
|
+
step(onStep: () => void): void;
|
|
73
|
+
/** Starts clock-driven playback; `onStep` is told after every Simulation Step. */
|
|
74
|
+
resume(onStep: () => void): void;
|
|
67
75
|
pause(): void;
|
|
68
76
|
injectAction(action: string, operation: 'press' | 'hold' | 'release'): boolean;
|
|
69
77
|
availableActions(): string[];
|
|
@@ -81,12 +89,13 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
|
|
|
81
89
|
readonly engineVersion: string;
|
|
82
90
|
private registered;
|
|
83
91
|
private mode;
|
|
92
|
+
/** Simulation Steps advanced since registration, paused or real-time alike. */
|
|
84
93
|
private frame;
|
|
85
|
-
private simulationTime;
|
|
86
94
|
constructor(surface: HTMLCanvasElement, activation: RuntimeBridgeActivation, host: RuntimeBridgeHost);
|
|
87
95
|
metadata(): RuntimeMetadata;
|
|
88
96
|
inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
|
|
89
97
|
control(request: RuntimeControlRequest): RuntimeControlResult;
|
|
98
|
+
/** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
|
|
90
99
|
private advance;
|
|
91
100
|
unregister(): void;
|
|
92
101
|
}
|
package/dist/runtime-bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import enginePackage from '../package.json' with { type: 'json' };
|
|
2
|
+
import { SIMULATION_STEP } from './fixed-step.js';
|
|
2
3
|
export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
|
|
3
4
|
export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
|
|
4
5
|
/**
|
|
@@ -8,9 +9,10 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
|
|
|
8
9
|
* pre-CA-10 engine simply lacks this field, which is exactly what callers
|
|
9
10
|
* gating on capabilities check for (review finding #4) — protocol 1 alone
|
|
10
11
|
* doesn't distinguish an engine that silently no-ops an unknown operation
|
|
11
|
-
* from one that runs it.
|
|
12
|
+
* from one that runs it. `fixed-step` (ADR 0014) announces that `step`
|
|
13
|
+
* advances whole 1/60 s Simulation Steps and takes no `dt`.
|
|
12
14
|
*/
|
|
13
|
-
export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene'];
|
|
15
|
+
export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene', 'fixed-step'];
|
|
14
16
|
export class RuntimeBridgeOperationError extends Error {
|
|
15
17
|
code;
|
|
16
18
|
availableActions;
|
|
@@ -43,8 +45,8 @@ export class EngineRuntimeBridge {
|
|
|
43
45
|
engineVersion = enginePackage.version;
|
|
44
46
|
registered = true;
|
|
45
47
|
mode = 'paused';
|
|
48
|
+
/** Simulation Steps advanced since registration, paused or real-time alike. */
|
|
46
49
|
frame = 0;
|
|
47
|
-
simulationTime = 0;
|
|
48
50
|
constructor(surface, activation, host) {
|
|
49
51
|
this.surface = surface;
|
|
50
52
|
this.activation = activation;
|
|
@@ -56,7 +58,8 @@ export class EngineRuntimeBridge {
|
|
|
56
58
|
engineVersion: this.engineVersion,
|
|
57
59
|
mode: this.mode,
|
|
58
60
|
frame: this.frame,
|
|
59
|
-
|
|
61
|
+
// Derived, never summed: 60 steps are exactly 1 s, with no float drift.
|
|
62
|
+
simulationTime: this.frame * SIMULATION_STEP,
|
|
60
63
|
capabilities: RUNTIME_BRIDGE_CAPABILITIES,
|
|
61
64
|
};
|
|
62
65
|
}
|
|
@@ -74,7 +77,7 @@ export class EngineRuntimeBridge {
|
|
|
74
77
|
case 'resume':
|
|
75
78
|
if (this.mode === 'paused') {
|
|
76
79
|
this.mode = 'real-time';
|
|
77
|
-
this.host.resume((
|
|
80
|
+
this.host.resume(() => this.advance());
|
|
78
81
|
}
|
|
79
82
|
break;
|
|
80
83
|
case 'press':
|
|
@@ -89,16 +92,18 @@ export class EngineRuntimeBridge {
|
|
|
89
92
|
if (this.mode !== 'paused') {
|
|
90
93
|
throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
|
|
91
94
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dt
|
|
95
|
+
// A caller-chosen dt is rejected outright, never ignored: a pre-ADR-0014
|
|
96
|
+
// client that still sends one would otherwise believe it stepped by it.
|
|
97
|
+
if ('dt' in request) {
|
|
98
|
+
throw new RuntimeBridgeOperationError('runtime-operation-failed', 'step takes no dt: it advances whole Simulation Steps of 1/60 s each; pass frames (1 through 600) instead.');
|
|
96
99
|
}
|
|
100
|
+
const frames = request.frames ?? 1;
|
|
97
101
|
if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
|
|
98
102
|
throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
|
|
99
103
|
}
|
|
100
|
-
for (let index = 0; index < frames; index += 1)
|
|
101
|
-
this.advance(
|
|
104
|
+
for (let index = 0; index < frames; index += 1) {
|
|
105
|
+
this.host.step(() => this.advance());
|
|
106
|
+
}
|
|
102
107
|
break;
|
|
103
108
|
}
|
|
104
109
|
case 'click': {
|
|
@@ -122,10 +127,9 @@ export class EngineRuntimeBridge {
|
|
|
122
127
|
}
|
|
123
128
|
return { ...this.metadata(), heldActions: this.host.heldActions() };
|
|
124
129
|
}
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
/** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
|
|
131
|
+
advance() {
|
|
127
132
|
this.frame += 1;
|
|
128
|
-
this.simulationTime += dt;
|
|
129
133
|
}
|
|
130
134
|
unregister() {
|
|
131
135
|
if (!this.registered)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from '../animation/directional.js';
|
|
2
2
|
import { Component } from '../component.js';
|
|
3
3
|
import { AnimatedSprite } from '../components/animated-sprite.js';
|
|
4
|
+
import { MAX_CHAINED_HOPS, SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
4
5
|
import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
|
|
5
6
|
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
6
7
|
export function evaluateTrigger(on, env) {
|
|
@@ -11,8 +12,15 @@ export function evaluateTrigger(on, env) {
|
|
|
11
12
|
const arg = on.slice(sep + 1);
|
|
12
13
|
if (kind === 'input')
|
|
13
14
|
return env.justPressed(arg);
|
|
15
|
+
// A float epsilon of tolerance, not half a Simulation Step: `elapsed` is
|
|
16
|
+
// a sum of many SIMULATION_STEP-sized dts, and float error can leave it a
|
|
17
|
+
// hair under an exact multiple (15 additions of 1/60 give
|
|
18
|
+
// 0.24999999999999997, not 0.25), which a bare `>=` would fire one whole
|
|
19
|
+
// step late. A tolerance as wide as half a step instead fired non-multiple
|
|
20
|
+
// durations one whole step early, since a target can sit closer to the
|
|
21
|
+
// step below than to the one it truly belongs to.
|
|
14
22
|
if (kind === 'timer')
|
|
15
|
-
return env.elapsed >= Number(arg);
|
|
23
|
+
return env.elapsed + SIMULATION_TIME_EPSILON >= Number(arg);
|
|
16
24
|
if (kind === 'signal')
|
|
17
25
|
return env.signals.has(arg);
|
|
18
26
|
return false;
|
|
@@ -105,7 +113,7 @@ export class StateMachine extends Component {
|
|
|
105
113
|
this.elapsed += dt;
|
|
106
114
|
// Chained transitions settle within the frame (e.g. land → idle → run),
|
|
107
115
|
// capped so a degenerate cyclic graph can't hang the loop.
|
|
108
|
-
for (let hops = 0; hops <
|
|
116
|
+
for (let hops = 0; hops < MAX_CHAINED_HOPS; hops++) {
|
|
109
117
|
const edge = nextTransition(this.states, this.current, this.env());
|
|
110
118
|
// A '*' edge is re-merged against whatever state the loop just
|
|
111
119
|
// entered, so a still-queued signal (signals.clear() only runs after
|