@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
package/dist/game.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
|
+
import { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
2
3
|
import { collisionOverlap } from './collision-shape.js';
|
|
3
4
|
import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
|
|
4
5
|
import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
5
6
|
import { Hitbox } from './components/hitbox.js';
|
|
6
7
|
import { Entity } from './entity.js';
|
|
7
8
|
import { Emitter } from './events.js';
|
|
9
|
+
import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
|
|
8
10
|
import { Input } from './input.js';
|
|
9
11
|
import { Pointer } from './pointer.js';
|
|
10
12
|
import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
|
|
11
13
|
import { RuntimeInspector } from './runtime-inspection.js';
|
|
12
|
-
import { projectIsometric } from './projection.js';
|
|
14
|
+
import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
13
15
|
import { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
14
16
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
15
17
|
import { Stats } from './stats.js';
|
|
@@ -28,6 +30,8 @@ export class Game {
|
|
|
28
30
|
stats;
|
|
29
31
|
/** The HTML UI layer: presentation-only pieces toggled from code. */
|
|
30
32
|
ui;
|
|
33
|
+
/** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
|
|
34
|
+
audio;
|
|
31
35
|
/** Registry retained by loadScene for runtime prefab spawning. */
|
|
32
36
|
registry = null;
|
|
33
37
|
paramOverrides = {};
|
|
@@ -41,6 +45,14 @@ export class Game {
|
|
|
41
45
|
resizeObserver;
|
|
42
46
|
updateFns = new Set();
|
|
43
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();
|
|
44
56
|
resolution;
|
|
45
57
|
/** The constructor's viewHeight — unloadScene() restores it. */
|
|
46
58
|
baseViewHeight;
|
|
@@ -48,7 +60,12 @@ export class Game {
|
|
|
48
60
|
sceneCamera = null;
|
|
49
61
|
renderSort = null;
|
|
50
62
|
sceneProjection = null;
|
|
51
|
-
|
|
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;
|
|
52
69
|
runtimeBridge = null;
|
|
53
70
|
/** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
|
|
54
71
|
sceneCatalog = null;
|
|
@@ -66,6 +83,15 @@ export class Game {
|
|
|
66
83
|
this.input = new Input(options.bindings);
|
|
67
84
|
this.stats = new Stats(options.stats);
|
|
68
85
|
this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
|
|
86
|
+
this.audio = new AudioSubsystem({
|
|
87
|
+
canvas,
|
|
88
|
+
backend: options.audio,
|
|
89
|
+
// The catalog registered via registerSceneCatalog, never game.registry:
|
|
90
|
+
// unloadScene() nulls the latter but leaves the catalog (and its
|
|
91
|
+
// resolver) untouched, which is exactly what a { scope: 'session' }
|
|
92
|
+
// music bed needs across a scene swap.
|
|
93
|
+
resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
|
|
94
|
+
});
|
|
69
95
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
70
96
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
71
97
|
this.scene.background = new THREE.Color(background);
|
|
@@ -119,6 +145,7 @@ export class Game {
|
|
|
119
145
|
*/
|
|
120
146
|
unloadScene() {
|
|
121
147
|
this.ui.unloadScene();
|
|
148
|
+
this.audio.unloadScene();
|
|
122
149
|
// An explicit unload means "no scene": a swap queued earlier this frame
|
|
123
150
|
// would otherwise flush next frame and resurrect one.
|
|
124
151
|
this.pendingSceneLoad = null;
|
|
@@ -204,7 +231,12 @@ export class Game {
|
|
|
204
231
|
if (override)
|
|
205
232
|
Object.assign(component, override);
|
|
206
233
|
}
|
|
207
|
-
/**
|
|
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
|
+
*/
|
|
208
240
|
onUpdate(fn) {
|
|
209
241
|
this.updateFns.add(fn);
|
|
210
242
|
return () => this.updateFns.delete(fn);
|
|
@@ -252,8 +284,8 @@ export class Game {
|
|
|
252
284
|
if (!this.runtimeBridge) {
|
|
253
285
|
const inspector = new RuntimeInspector(this);
|
|
254
286
|
this.runtimeBridge = new EngineRuntimeBridge(this.renderer.domElement, activation, {
|
|
255
|
-
step: (
|
|
256
|
-
resume: (
|
|
287
|
+
step: (onStep) => this.runFrame(1, onStep),
|
|
288
|
+
resume: (onStep) => this.resumeRuntime(onStep),
|
|
257
289
|
pause: () => this.stop(),
|
|
258
290
|
injectAction: (action, operation) => this.input.injectAction(action, operation),
|
|
259
291
|
availableActions: () => this.input.availableActions(),
|
|
@@ -266,11 +298,13 @@ export class Game {
|
|
|
266
298
|
availableScenes: () => this.availableScenes,
|
|
267
299
|
});
|
|
268
300
|
activation.register(this.runtimeBridge);
|
|
301
|
+
this.audio.setSilenced(true);
|
|
269
302
|
window.addEventListener('pagehide', this.unregisterRuntimeBridge);
|
|
270
303
|
}
|
|
271
304
|
this.renderSurface();
|
|
272
305
|
return;
|
|
273
306
|
}
|
|
307
|
+
this.resetClock();
|
|
274
308
|
this.renderer.setAnimationLoop((time) => this.tick(time));
|
|
275
309
|
}
|
|
276
310
|
stop() {
|
|
@@ -302,64 +336,140 @@ export class Game {
|
|
|
302
336
|
this.pointer.dispose();
|
|
303
337
|
this.resizeObserver.disconnect();
|
|
304
338
|
this.ui.dispose();
|
|
339
|
+
this.audio.dispose();
|
|
305
340
|
for (const entity of [...this.entities])
|
|
306
341
|
entity.destroy();
|
|
307
342
|
this.renderer.dispose();
|
|
308
343
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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));
|
|
316
354
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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;
|
|
320
372
|
this.lastTime = time;
|
|
321
|
-
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);
|
|
322
384
|
}
|
|
323
|
-
|
|
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) {
|
|
324
396
|
this.insideFrame = true;
|
|
325
397
|
try {
|
|
326
|
-
// Flushes a scene swap enqueued mid-frame last time (CA-7): applied
|
|
327
|
-
// before this frame's own simulation, so the incoming scene's
|
|
328
|
-
// entities are present only from this next frame onward.
|
|
329
398
|
this.flushPendingSceneLoad();
|
|
330
399
|
if (this.simulate) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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?.();
|
|
337
409
|
}
|
|
338
|
-
this.dispatchCollisions();
|
|
339
|
-
this.updateSceneCamera(dt);
|
|
340
410
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
}
|
|
418
|
+
this.audio.setActive(this.simulate);
|
|
419
|
+
// Positional audio (CA-8): recomputed every frame, on this same pass —
|
|
420
|
+
// never a second walk of `this.entities`, since `this.audio` already
|
|
421
|
+
// holds direct references to whichever entities are tracked.
|
|
422
|
+
this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
|
|
346
423
|
this.renderSurface();
|
|
347
424
|
}
|
|
348
425
|
finally {
|
|
349
426
|
this.insideFrame = false;
|
|
350
427
|
}
|
|
351
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
|
+
}
|
|
352
455
|
flushPendingSceneLoad() {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
}
|
|
358
467
|
}
|
|
359
468
|
unregisterRuntimeBridge = () => {
|
|
360
469
|
window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
|
|
361
470
|
this.runtimeBridge?.unregister();
|
|
362
471
|
this.runtimeBridge = null;
|
|
472
|
+
this.audio.setSilenced(false);
|
|
363
473
|
};
|
|
364
474
|
/** Under y-sort, re-derives every participant's z from layer band + entity Y. */
|
|
365
475
|
applyYSort() {
|
|
@@ -399,24 +509,36 @@ export class Game {
|
|
|
399
509
|
}
|
|
400
510
|
componentUpdateSchedule(entity) {
|
|
401
511
|
const components = [...entity.components];
|
|
402
|
-
const registry = {
|
|
403
|
-
...(this.registry?.components ?? {}),
|
|
404
|
-
};
|
|
405
512
|
const byName = new Map();
|
|
406
513
|
const names = [];
|
|
407
514
|
const signatureParts = [];
|
|
408
515
|
for (const component of components) {
|
|
409
516
|
const Class = component.constructor;
|
|
410
517
|
const name = Class.componentName;
|
|
411
|
-
registry[name] = Class;
|
|
412
518
|
names.push(name);
|
|
413
519
|
byName.set(name, component);
|
|
414
520
|
signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
|
|
415
521
|
[...new Set(Class.updateAfter ?? [])].sort().join(','));
|
|
416
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
|
+
}
|
|
417
539
|
const result = resolveComponentUpdateSchedule(names, registry);
|
|
418
540
|
if (!result.ok) {
|
|
419
|
-
|
|
541
|
+
this.updateScheduleCache.delete(entity);
|
|
420
542
|
if (this.invalidUpdateCompositions.get(entity) !== signature) {
|
|
421
543
|
this.invalidUpdateCompositions.set(entity, signature);
|
|
422
544
|
console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
|
|
@@ -425,6 +547,7 @@ export class Game {
|
|
|
425
547
|
return null;
|
|
426
548
|
}
|
|
427
549
|
this.invalidUpdateCompositions.delete(entity);
|
|
550
|
+
this.updateScheduleCache.set(entity, { signature, order: result.order });
|
|
428
551
|
return result.order.map((name) => byName.get(name));
|
|
429
552
|
}
|
|
430
553
|
updateSceneCamera(dt) {
|
|
@@ -456,6 +579,17 @@ export class Game {
|
|
|
456
579
|
renderPoint(x, y) {
|
|
457
580
|
return this.sceneProjection === 'isometric' ? projectIsometric(x, y) : { x, y };
|
|
458
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* The audio listener's position (CA-8) in logical coordinates. The camera
|
|
584
|
+
* itself only ever holds render-space coordinates (see `updateSceneCamera`,
|
|
585
|
+
* `setSceneCamera`), so under `projection: 'isometric'` this is the exact
|
|
586
|
+
* inverse of `renderPoint` — without it, distance-based attenuation would
|
|
587
|
+
* measure render-space distance instead of real game distance.
|
|
588
|
+
*/
|
|
589
|
+
audioListenerPosition() {
|
|
590
|
+
const { x, y } = this.camera.position;
|
|
591
|
+
return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
|
|
592
|
+
}
|
|
459
593
|
dispatchCollisions() {
|
|
460
594
|
const boxed = this.entities.filter((e) => e.has(Hitbox));
|
|
461
595
|
for (let i = 0; i < boxed.length; i++) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { Game } from './game.js';
|
|
2
2
|
export type { GameOptions, GameResolution, SceneCatalog, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
|
|
3
|
+
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
4
|
+
export type { AudioSubsystemOptions } from './audio/audio-subsystem.js';
|
|
5
|
+
export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './audio/backend.js';
|
|
6
|
+
export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
|
|
3
7
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
4
8
|
export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
|
|
5
9
|
export { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
@@ -9,6 +13,7 @@ export type { ProjectedPoint } from './projection.js';
|
|
|
9
13
|
export { spritePlacement } from './sprite-placement.js';
|
|
10
14
|
export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
|
|
11
15
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
16
|
+
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
12
17
|
export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
|
|
13
18
|
export { Entity } from './entity.js';
|
|
14
19
|
export { Component } from './component.js';
|
|
@@ -25,7 +30,7 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
|
|
|
25
30
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|
|
26
31
|
export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
|
|
27
32
|
export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
|
|
28
|
-
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
33
|
+
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
29
34
|
export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
|
|
30
35
|
export { Stats } from './stats.js';
|
|
31
36
|
export type { StatValue } from './stats.js';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { Game } from './game.js';
|
|
2
|
+
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
2
3
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
3
4
|
export { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
4
5
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
5
6
|
export { spritePlacement } from './sprite-placement.js';
|
|
6
7
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
8
|
+
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
7
9
|
export { Entity } from './entity.js';
|
|
8
10
|
export { Component } from './component.js';
|
|
9
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,3 +1,4 @@
|
|
|
1
|
+
import type { AudioChannelState, LiveSoundInfo } from './audio/types.js';
|
|
1
2
|
import type { Game } from './game.js';
|
|
2
3
|
import type { RuntimeMetadata } from './runtime-bridge.js';
|
|
3
4
|
import type { StatValue } from './stats.js';
|
|
@@ -48,12 +49,29 @@ export interface RuntimeEntitySnapshot {
|
|
|
48
49
|
transform: RuntimeTransformSnapshot;
|
|
49
50
|
components: RuntimeComponentSnapshot[];
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The mixer's state (CA-15): `master` and every channel's volume/mute,
|
|
54
|
+
* sorted by name, plus `playing` — `game.audio.liveSounds()` verbatim,
|
|
55
|
+
* already sorted (by uri then channel). Despite the name, `playing` is not
|
|
56
|
+
* "every currently-audible sound": per `liveSounds()`'s own docstring it
|
|
57
|
+
* also carries a loop retained before the autoplay unlock, a sound still
|
|
58
|
+
* loading, and even one about to fail to load (gone a tick later). Emitted
|
|
59
|
+
* unconditionally, like every other snapshot section —
|
|
60
|
+
* `[DEVIATION 2026-09-08]` in the spec: no section of RuntimeSnapshot is
|
|
61
|
+
* filterable today, so audio does not invent the first one.
|
|
62
|
+
*/
|
|
63
|
+
export interface RuntimeSnapshotAudio {
|
|
64
|
+
master: number;
|
|
65
|
+
channels: Record<string, AudioChannelState>;
|
|
66
|
+
playing: LiveSoundInfo[];
|
|
67
|
+
}
|
|
51
68
|
export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
52
69
|
stats: Record<string, StatValue>;
|
|
53
70
|
/** The live scene's name (its catalog key), or null with no scene loaded. */
|
|
54
71
|
scene: string | null;
|
|
55
72
|
entities: RuntimeEntitySnapshot[];
|
|
56
73
|
projectionIssues: ProjectionIssue[];
|
|
74
|
+
audio: RuntimeSnapshotAudio;
|
|
57
75
|
}
|
|
58
76
|
export declare const RUNTIME_PROJECTION_LIMITS: {
|
|
59
77
|
readonly depth: 5;
|
|
@@ -68,6 +86,7 @@ export declare class RuntimeInspector {
|
|
|
68
86
|
private nextId;
|
|
69
87
|
constructor(game: Game);
|
|
70
88
|
snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
|
|
89
|
+
private audioSnapshot;
|
|
71
90
|
private capSnapshot;
|
|
72
91
|
private idFor;
|
|
73
92
|
}
|
|
@@ -237,8 +237,20 @@ export class RuntimeInspector {
|
|
|
237
237
|
scene: this.game.sceneName,
|
|
238
238
|
entities,
|
|
239
239
|
projectionIssues,
|
|
240
|
+
audio: this.audioSnapshot(),
|
|
240
241
|
});
|
|
241
242
|
}
|
|
243
|
+
audioSnapshot() {
|
|
244
|
+
const channels = {};
|
|
245
|
+
for (const name of this.game.audio.channels().sort()) {
|
|
246
|
+
channels[name] = this.game.audio.channelState(name);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
master: this.game.audio.master,
|
|
250
|
+
channels,
|
|
251
|
+
playing: this.game.audio.liveSounds(),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
242
254
|
capSnapshot(snapshot) {
|
|
243
255
|
if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
|
|
244
256
|
return snapshot;
|
|
@@ -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
|