@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/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
- lastTime = 0;
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
- /** Registers a function that runs once per frame. Returns the unsubscribe. */
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: (dt) => this.runFrame(dt),
256
- resume: (frame) => this.resumeRuntime(frame),
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
- resumeRuntime(frame) {
310
- let previousTime = null;
311
- this.renderer.setAnimationLoop((time) => {
312
- const dt = previousTime === null ? 0 : Math.min((time - previousTime) / 1000, 0.1);
313
- previousTime = time;
314
- frame(dt);
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
- tick(time) {
318
- // Clamp dt: switching tabs or pausing doesn't fast-forward the simulation.
319
- const dt = Math.min((time - this.lastTime) / 1000, 0.1);
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.runFrame(dt);
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
- runFrame(dt) {
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
- for (const entity of [...this.entities]) {
332
- const schedule = this.componentUpdateSchedule(entity);
333
- if (!schedule)
334
- continue;
335
- for (const component of schedule)
336
- component.onUpdate?.(dt);
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
- // The UI must react to the pause itself (hide until resumed).
342
- this.ui.setActive(this.simulate);
343
- for (const fn of this.updateFns)
344
- fn(dt);
345
- this.input.endFrame();
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
- const pending = this.pendingSceneLoad;
354
- if (!pending)
355
- return;
356
- this.pendingSceneLoad = null;
357
- pending();
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
- const signature = signatureParts.sort().join('|');
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';
@@ -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
- step(dt: number): void;
66
- resume(frame: (dt: number) => void): void;
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
  }
@@ -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
- simulationTime: this.simulationTime,
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((dt) => this.advance(dt));
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
- const dt = request.dt ?? 1 / 60;
93
- const frames = request.frames ?? 1;
94
- if (!Number.isFinite(dt) || dt <= 0 || dt > 0.1) {
95
- throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dt must be finite and greater than 0 and at most 0.1.');
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(dt);
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
- advance(dt) {
126
- this.host.step(dt);
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 < 8; 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",