@waica/engine 0.12.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/archetype.d.ts +11 -0
- package/dist/audio/audio-subsystem.d.ts +141 -0
- package/dist/audio/audio-subsystem.js +398 -0
- package/dist/audio/backend.d.ts +69 -0
- package/dist/audio/backend.js +1 -0
- package/dist/audio/spatial.d.ts +22 -0
- package/dist/audio/spatial.js +32 -0
- package/dist/audio/types.d.ts +42 -0
- package/dist/audio/types.js +1 -0
- package/dist/audio/web-audio-backend.d.ts +39 -0
- package/dist/audio/web-audio-backend.js +149 -0
- package/dist/component.d.ts +1 -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 +76 -1
- package/dist/game.js +177 -43
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -0
- package/dist/runtime-bridge.d.ts +16 -7
- package/dist/runtime-bridge.js +18 -14
- package/dist/runtime-inspection.d.ts +19 -0
- package/dist/runtime-inspection.js +12 -0
- package/dist/state/state-machine.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine constants for CA-8 positional audio. There are no public tuning
|
|
3
|
+
* knobs (spec decision 20): the curve is picked once, in the spirit of
|
|
4
|
+
* `CAMERA_DEFAULTS` (`camera.ts`), and frozen — CA-21 is the human-only
|
|
5
|
+
* game-feel pass that may revisit these numbers by ear, never a per-call or
|
|
6
|
+
* per-channel option.
|
|
7
|
+
*
|
|
8
|
+
* The attenuation curve mirrors WebAudio's own `PannerNode` "linear"
|
|
9
|
+
* distance model: full volume at or inside `referenceDistance`, a straight
|
|
10
|
+
* ramp down to silence at `maxDistance`, silent beyond it. `panDistance` is
|
|
11
|
+
* the render-space horizontal offset (world units) that reaches full
|
|
12
|
+
* left/right pan.
|
|
13
|
+
*/
|
|
14
|
+
export const AUDIO_SPATIAL_DEFAULTS = {
|
|
15
|
+
referenceDistance: 3,
|
|
16
|
+
maxDistance: 16,
|
|
17
|
+
panDistance: 6,
|
|
18
|
+
};
|
|
19
|
+
/** 1 at/inside referenceDistance, 0 at/beyond maxDistance, linear in between. */
|
|
20
|
+
export function attenuationForDistance(distance) {
|
|
21
|
+
const { referenceDistance, maxDistance } = AUDIO_SPATIAL_DEFAULTS;
|
|
22
|
+
if (distance <= referenceDistance)
|
|
23
|
+
return 1;
|
|
24
|
+
if (distance >= maxDistance)
|
|
25
|
+
return 0;
|
|
26
|
+
return 1 - (distance - referenceDistance) / (maxDistance - referenceDistance);
|
|
27
|
+
}
|
|
28
|
+
/** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
|
|
29
|
+
export function panForOffset(dx) {
|
|
30
|
+
const { panDistance } = AUDIO_SPATIAL_DEFAULTS;
|
|
31
|
+
return Math.max(-1, Math.min(1, dx / panDistance));
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Entity } from '../entity.js';
|
|
2
|
+
export interface AudioPlayOptions {
|
|
3
|
+
/** Mixer channel; defaults to 'sfx'. Naming any other channel creates it at volume 1. */
|
|
4
|
+
channel?: string;
|
|
5
|
+
/** The sound's own gain, independent of the channel/master mix. Defaults to 1. */
|
|
6
|
+
volume?: number;
|
|
7
|
+
loop?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 'session' survives Game.unloadScene() (CA-7); omitted means scene-scoped
|
|
10
|
+
* — the default, and the common case (ADR 0012).
|
|
11
|
+
*/
|
|
12
|
+
scope?: 'session';
|
|
13
|
+
/**
|
|
14
|
+
* Positional playback (CA-8). An `Entity` tracks its current position
|
|
15
|
+
* every frame; a plain `{ x, y }` fixes the placement. Omitted, the sound
|
|
16
|
+
* is flat: no panning, no distance attenuation. Attenuation is computed
|
|
17
|
+
* from the distance to the listener (the camera) in logical coordinates;
|
|
18
|
+
* panning is computed from both positions after `game.renderPoint()`, so
|
|
19
|
+
* an isometric source that reads to the right on screen pans right even
|
|
20
|
+
* though its volume reflects real (logical) game distance.
|
|
21
|
+
*/
|
|
22
|
+
at?: Entity | {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface SoundHandle {
|
|
28
|
+
stop(opts?: {
|
|
29
|
+
fadeMs?: number;
|
|
30
|
+
}): void;
|
|
31
|
+
readonly playing: boolean;
|
|
32
|
+
volume: number;
|
|
33
|
+
}
|
|
34
|
+
export interface AudioChannelState {
|
|
35
|
+
volume: number;
|
|
36
|
+
muted: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface LiveSoundInfo {
|
|
39
|
+
uri: string;
|
|
40
|
+
channel: string;
|
|
41
|
+
scope: 'scene' | 'session';
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './backend.js';
|
|
2
|
+
/**
|
|
3
|
+
* The real WebAudio implementation (ADR 0013's default). Builds a small
|
|
4
|
+
* mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
|
|
5
|
+
* a per-channel GainNode, feeding a single master GainNode — so
|
|
6
|
+
* muting/volume/master changes are plain WebAudio gain assignments, and
|
|
7
|
+
* positional panning (CA-8) is a plain WebAudio pan assignment, neither
|
|
8
|
+
* something this class recomputes by hand for every live sound.
|
|
9
|
+
*
|
|
10
|
+
* The AudioContext itself is never created eagerly: `happy-dom` (this
|
|
11
|
+
* repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
|
|
12
|
+
* and real browsers block unsolicited audio output before a user gesture.
|
|
13
|
+
* `ensureContext()` is the one lazy constructor, reached only from
|
|
14
|
+
* `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
|
|
15
|
+
* ahead of an unlock). If AudioContext isn't available in the current
|
|
16
|
+
* environment, every method degrades to a safe no-op / rejected load
|
|
17
|
+
* instead of throwing — the audio subsystem's own load-failure handling
|
|
18
|
+
* (CA-9) turns that rejection into a single warning, never a crash.
|
|
19
|
+
*/
|
|
20
|
+
export declare class WebAudioBackend implements AudioBackend {
|
|
21
|
+
private context;
|
|
22
|
+
private masterGain;
|
|
23
|
+
private masterVolume;
|
|
24
|
+
private readonly channelGains;
|
|
25
|
+
private readonly channelVolumes;
|
|
26
|
+
private readonly channelMuted;
|
|
27
|
+
load(uri: string): Promise<AudioResource>;
|
|
28
|
+
play(resource: AudioResource, options: BackendPlayOptions): BackendPlayHandle;
|
|
29
|
+
setChannelVolume(channel: string, volume: number): void;
|
|
30
|
+
setChannelMuted(channel: string, muted: boolean): void;
|
|
31
|
+
setMasterVolume(volume: number): void;
|
|
32
|
+
suspend(): void;
|
|
33
|
+
resume(): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
private ensureContext;
|
|
36
|
+
private ensureChannelGain;
|
|
37
|
+
private applyChannelGain;
|
|
38
|
+
private effectiveChannelGain;
|
|
39
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real WebAudio implementation (ADR 0013's default). Builds a small
|
|
3
|
+
* mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
|
|
4
|
+
* a per-channel GainNode, feeding a single master GainNode — so
|
|
5
|
+
* muting/volume/master changes are plain WebAudio gain assignments, and
|
|
6
|
+
* positional panning (CA-8) is a plain WebAudio pan assignment, neither
|
|
7
|
+
* something this class recomputes by hand for every live sound.
|
|
8
|
+
*
|
|
9
|
+
* The AudioContext itself is never created eagerly: `happy-dom` (this
|
|
10
|
+
* repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
|
|
11
|
+
* and real browsers block unsolicited audio output before a user gesture.
|
|
12
|
+
* `ensureContext()` is the one lazy constructor, reached only from
|
|
13
|
+
* `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
|
|
14
|
+
* ahead of an unlock). If AudioContext isn't available in the current
|
|
15
|
+
* environment, every method degrades to a safe no-op / rejected load
|
|
16
|
+
* instead of throwing — the audio subsystem's own load-failure handling
|
|
17
|
+
* (CA-9) turns that rejection into a single warning, never a crash.
|
|
18
|
+
*/
|
|
19
|
+
export class WebAudioBackend {
|
|
20
|
+
context = null;
|
|
21
|
+
masterGain = null;
|
|
22
|
+
masterVolume = 1;
|
|
23
|
+
channelGains = new Map();
|
|
24
|
+
channelVolumes = new Map();
|
|
25
|
+
channelMuted = new Map();
|
|
26
|
+
async load(uri) {
|
|
27
|
+
const context = this.ensureContext();
|
|
28
|
+
if (!context)
|
|
29
|
+
throw new Error('AudioContext is not available in this environment');
|
|
30
|
+
const response = await fetch(uri);
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new Error(`HTTP ${response.status} fetching "${uri}"`);
|
|
33
|
+
const data = await response.arrayBuffer();
|
|
34
|
+
return context.decodeAudioData(data);
|
|
35
|
+
}
|
|
36
|
+
play(resource, options) {
|
|
37
|
+
const context = this.ensureContext();
|
|
38
|
+
if (!context)
|
|
39
|
+
return noopHandle();
|
|
40
|
+
const source = context.createBufferSource();
|
|
41
|
+
source.buffer = resource;
|
|
42
|
+
source.loop = options.loop;
|
|
43
|
+
const soundGain = context.createGain();
|
|
44
|
+
soundGain.gain.value = options.volume;
|
|
45
|
+
const panner = context.createStereoPanner();
|
|
46
|
+
source.connect(soundGain);
|
|
47
|
+
soundGain.connect(panner);
|
|
48
|
+
panner.connect(this.ensureChannelGain(options.channel));
|
|
49
|
+
let ended = false;
|
|
50
|
+
const finish = () => {
|
|
51
|
+
if (ended)
|
|
52
|
+
return;
|
|
53
|
+
ended = true;
|
|
54
|
+
options.onEnded();
|
|
55
|
+
};
|
|
56
|
+
source.onended = finish;
|
|
57
|
+
source.start();
|
|
58
|
+
return {
|
|
59
|
+
setVolume: (volume) => {
|
|
60
|
+
soundGain.gain.value = volume;
|
|
61
|
+
},
|
|
62
|
+
setPan: (pan) => {
|
|
63
|
+
panner.pan.value = pan;
|
|
64
|
+
},
|
|
65
|
+
stop: (fadeMs) => {
|
|
66
|
+
if (ended)
|
|
67
|
+
return;
|
|
68
|
+
const now = context.currentTime;
|
|
69
|
+
if (fadeMs && fadeMs > 0) {
|
|
70
|
+
soundGain.gain.linearRampToValueAtTime(0, now + fadeMs / 1000);
|
|
71
|
+
source.stop(now + fadeMs / 1000);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
source.stop();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
setChannelVolume(channel, volume) {
|
|
80
|
+
this.channelVolumes.set(channel, volume);
|
|
81
|
+
this.applyChannelGain(channel);
|
|
82
|
+
}
|
|
83
|
+
setChannelMuted(channel, muted) {
|
|
84
|
+
this.channelMuted.set(channel, muted);
|
|
85
|
+
this.applyChannelGain(channel);
|
|
86
|
+
}
|
|
87
|
+
setMasterVolume(volume) {
|
|
88
|
+
this.masterVolume = volume;
|
|
89
|
+
if (this.masterGain)
|
|
90
|
+
this.masterGain.gain.value = volume;
|
|
91
|
+
}
|
|
92
|
+
suspend() {
|
|
93
|
+
void this.context?.suspend();
|
|
94
|
+
}
|
|
95
|
+
resume() {
|
|
96
|
+
void this.ensureContext()?.resume();
|
|
97
|
+
}
|
|
98
|
+
close() {
|
|
99
|
+
void this.context?.close();
|
|
100
|
+
this.context = null;
|
|
101
|
+
this.masterGain = null;
|
|
102
|
+
this.channelGains.clear();
|
|
103
|
+
}
|
|
104
|
+
ensureContext() {
|
|
105
|
+
if (this.context)
|
|
106
|
+
return this.context;
|
|
107
|
+
if (typeof AudioContext === 'undefined')
|
|
108
|
+
return null;
|
|
109
|
+
const context = new AudioContext();
|
|
110
|
+
this.context = context;
|
|
111
|
+
const masterGain = context.createGain();
|
|
112
|
+
masterGain.gain.value = this.masterVolume;
|
|
113
|
+
masterGain.connect(context.destination);
|
|
114
|
+
this.masterGain = masterGain;
|
|
115
|
+
for (const channel of this.channelVolumes.keys())
|
|
116
|
+
this.ensureChannelGain(channel);
|
|
117
|
+
return context;
|
|
118
|
+
}
|
|
119
|
+
ensureChannelGain(channel) {
|
|
120
|
+
const existing = this.channelGains.get(channel);
|
|
121
|
+
if (existing)
|
|
122
|
+
return existing;
|
|
123
|
+
// Safe: only called once ensureContext() has already produced a context.
|
|
124
|
+
const context = this.context;
|
|
125
|
+
const gain = context.createGain();
|
|
126
|
+
gain.gain.value = this.effectiveChannelGain(channel);
|
|
127
|
+
gain.connect(this.masterGain);
|
|
128
|
+
this.channelGains.set(channel, gain);
|
|
129
|
+
return gain;
|
|
130
|
+
}
|
|
131
|
+
applyChannelGain(channel) {
|
|
132
|
+
const gain = this.channelGains.get(channel);
|
|
133
|
+
if (gain)
|
|
134
|
+
gain.gain.value = this.effectiveChannelGain(channel);
|
|
135
|
+
}
|
|
136
|
+
effectiveChannelGain(channel) {
|
|
137
|
+
if (this.channelMuted.get(channel))
|
|
138
|
+
return 0;
|
|
139
|
+
return this.channelVolumes.get(channel) ?? 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Returned when the environment has no AudioContext at all: inert, never throws. */
|
|
143
|
+
function noopHandle() {
|
|
144
|
+
return {
|
|
145
|
+
setVolume: () => { },
|
|
146
|
+
setPan: () => { },
|
|
147
|
+
stop: () => { },
|
|
148
|
+
};
|
|
149
|
+
}
|
package/dist/component.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface ParamSpec {
|
|
|
10
10
|
/** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
|
|
11
11
|
options?: string[];
|
|
12
12
|
/** Project value this string param names; rendered and validated as a typed reference. */
|
|
13
|
-
ref?: 'prefab' | 'stat' | 'action' | 'clip';
|
|
13
|
+
ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound';
|
|
14
14
|
}
|
|
15
15
|
export interface ComponentClass<T extends Component = Component> {
|
|
16
16
|
new (): T;
|
|
@@ -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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
|
+
import type { AudioBackend } from './audio/backend.js';
|
|
3
|
+
import { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
2
4
|
import { type SceneCameraJson } from './camera.js';
|
|
3
5
|
import type { Component } from './component.js';
|
|
4
6
|
import { Entity } from './entity.js';
|
|
@@ -26,6 +28,14 @@ export interface GameOptions {
|
|
|
26
28
|
bindings?: InputBindings;
|
|
27
29
|
/** Initial stat values (points, lives…) from the project's stats.json. */
|
|
28
30
|
stats?: Record<string, StatValue>;
|
|
31
|
+
/**
|
|
32
|
+
* Replaces the real WebAudio implementation (ADR 0013) — mainly for a
|
|
33
|
+
* project's own tests, since `happy-dom` has no AudioContext, AudioBuffer
|
|
34
|
+
* or GainNode at all. Defaults to the real backend either way; `game.audio`
|
|
35
|
+
* always exists, and the real AudioContext is constructed lazily, at the
|
|
36
|
+
* first unlock (CA-6), never eagerly here.
|
|
37
|
+
*/
|
|
38
|
+
audio?: AudioBackend;
|
|
29
39
|
}
|
|
30
40
|
export type UpdateFn = (dt: number) => void;
|
|
31
41
|
export interface SpawnPrefabOptions {
|
|
@@ -53,6 +63,8 @@ export declare class Game {
|
|
|
53
63
|
readonly stats: Stats;
|
|
54
64
|
/** The HTML UI layer: presentation-only pieces toggled from code. */
|
|
55
65
|
readonly ui: GameUi;
|
|
66
|
+
/** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
|
|
67
|
+
readonly audio: AudioSubsystem;
|
|
56
68
|
/** Registry retained by loadScene for runtime prefab spawning. */
|
|
57
69
|
registry: SceneRegistry | null;
|
|
58
70
|
paramOverrides: ParamOverrides;
|
|
@@ -65,6 +77,14 @@ export declare class Game {
|
|
|
65
77
|
private readonly resizeObserver;
|
|
66
78
|
private readonly updateFns;
|
|
67
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;
|
|
68
88
|
private readonly resolution;
|
|
69
89
|
/** The constructor's viewHeight — unloadScene() restores it. */
|
|
70
90
|
private readonly baseViewHeight;
|
|
@@ -72,7 +92,12 @@ export declare class Game {
|
|
|
72
92
|
private sceneCamera;
|
|
73
93
|
private renderSort;
|
|
74
94
|
private sceneProjection;
|
|
95
|
+
/** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
|
|
75
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;
|
|
76
101
|
private runtimeBridge;
|
|
77
102
|
/** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
|
|
78
103
|
private sceneCatalog;
|
|
@@ -122,7 +147,12 @@ export declare class Game {
|
|
|
122
147
|
loadParams(url: string): Promise<void>;
|
|
123
148
|
/** Applies persisted overrides to a freshly added component. */
|
|
124
149
|
applyParamOverrides(entity: Entity, component: Component): void;
|
|
125
|
-
/**
|
|
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
|
+
*/
|
|
126
156
|
onUpdate(fn: UpdateFn): () => void;
|
|
127
157
|
/**
|
|
128
158
|
* Adopts a scene's render block. Called by loadScene; without a block the
|
|
@@ -145,9 +175,46 @@ export declare class Game {
|
|
|
145
175
|
setViewHeight(value: number): void;
|
|
146
176
|
/** Shuts the game down completely (loop, input, GPU). */
|
|
147
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
|
+
*/
|
|
148
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
|
+
*/
|
|
149
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
|
+
*/
|
|
150
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;
|
|
151
218
|
private flushPendingSceneLoad;
|
|
152
219
|
private unregisterRuntimeBridge;
|
|
153
220
|
/** Under y-sort, re-derives every participant's z from layer band + entity Y. */
|
|
@@ -156,6 +223,14 @@ export declare class Game {
|
|
|
156
223
|
private componentUpdateSchedule;
|
|
157
224
|
private updateSceneCamera;
|
|
158
225
|
private renderPoint;
|
|
226
|
+
/**
|
|
227
|
+
* The audio listener's position (CA-8) in logical coordinates. The camera
|
|
228
|
+
* itself only ever holds render-space coordinates (see `updateSceneCamera`,
|
|
229
|
+
* `setSceneCamera`), so under `projection: 'isometric'` this is the exact
|
|
230
|
+
* inverse of `renderPoint` — without it, distance-based attenuation would
|
|
231
|
+
* measure render-space distance instead of real game distance.
|
|
232
|
+
*/
|
|
233
|
+
private audioListenerPosition;
|
|
159
234
|
private dispatchCollisions;
|
|
160
235
|
private resize;
|
|
161
236
|
}
|