@waica/engine 0.13.0 → 0.14.1

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,11 +1,13 @@
1
1
  import * as THREE from 'three';
2
2
  import { AudioSubsystem } from './audio/audio-subsystem.js';
3
+ import { collisionBody } from './collision-body.js';
3
4
  import { collisionOverlap } from './collision-shape.js';
4
5
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
5
6
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
6
7
  import { Hitbox } from './components/hitbox.js';
7
8
  import { Entity } from './entity.js';
8
9
  import { Emitter } from './events.js';
10
+ import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
9
11
  import { Input } from './input.js';
10
12
  import { Pointer } from './pointer.js';
11
13
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
@@ -13,6 +15,7 @@ import { RuntimeInspector } from './runtime-inspection.js';
13
15
  import { projectIsometric, unprojectIsometric } from './projection.js';
14
16
  import { isYSortParticipant, ySortZ } from './render-sort.js';
15
17
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
18
+ import { createSpatialQuery } from './spatial-query.js';
16
19
  import { Stats } from './stats.js';
17
20
  import { GameUi } from './ui.js';
18
21
  /**
@@ -24,6 +27,7 @@ export class Game {
24
27
  camera;
25
28
  input;
26
29
  pointer;
30
+ query;
27
31
  entities = [];
28
32
  events = new Emitter();
29
33
  stats;
@@ -44,6 +48,14 @@ export class Game {
44
48
  resizeObserver;
45
49
  updateFns = new Set();
46
50
  invalidUpdateCompositions = new WeakMap();
51
+ /**
52
+ * componentUpdateSchedule's result for the last composition signature seen
53
+ * per entity: the resolve (Tarjan SCC + Kahn sort) it's built from is pure
54
+ * for a fixed composition, so a signature match skips it entirely. Only
55
+ * ever holds a successful resolution — an invalid composition is never
56
+ * cached, since invalidUpdateCompositions already dedupes its console.error.
57
+ */
58
+ updateScheduleCache = new WeakMap();
47
59
  resolution;
48
60
  /** The constructor's viewHeight — unloadScene() restores it. */
49
61
  baseViewHeight;
@@ -51,7 +63,12 @@ export class Game {
51
63
  sceneCamera = null;
52
64
  renderSort = null;
53
65
  sceneProjection = null;
54
- lastTime = 0;
66
+ /** Timestamp of the last animation frame; null until the loop's first frame seeds it. */
67
+ lastTime = null;
68
+ /** Seconds of elapsed time not yet worth a whole Simulation Step (ADR 0014). */
69
+ stepRemainder = 0;
70
+ /** Seconds discarded by frame-rate snapping, not yet repaid (round 3 correctness). */
71
+ snapResidual = 0;
55
72
  runtimeBridge = null;
56
73
  /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
57
74
  sceneCatalog = null;
@@ -67,6 +84,7 @@ export class Game {
67
84
  this.viewHeight = viewHeight;
68
85
  this.resolution = options.resolution ?? null;
69
86
  this.input = new Input(options.bindings);
87
+ this.query = createSpatialQuery(this);
70
88
  this.stats = new Stats(options.stats);
71
89
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
72
90
  this.audio = new AudioSubsystem({
@@ -217,7 +235,12 @@ export class Game {
217
235
  if (override)
218
236
  Object.assign(component, override);
219
237
  }
220
- /** Registers a function that runs once per frame. Returns the unsubscribe. */
238
+ /**
239
+ * Registers a function that runs once per Simulation Step, with the step
240
+ * as its dt (ADR 0014). While the Game is not simulating (the editor's edit
241
+ * mode) it runs once per render frame instead, so a host can keep drawing
242
+ * its overlays. Returns the unsubscribe.
243
+ */
221
244
  onUpdate(fn) {
222
245
  this.updateFns.add(fn);
223
246
  return () => this.updateFns.delete(fn);
@@ -265,8 +288,8 @@ export class Game {
265
288
  if (!this.runtimeBridge) {
266
289
  const inspector = new RuntimeInspector(this);
267
290
  this.runtimeBridge = new EngineRuntimeBridge(this.renderer.domElement, activation, {
268
- step: (dt) => this.runFrame(dt),
269
- resume: (frame) => this.resumeRuntime(frame),
291
+ step: (onStep) => this.runFrame(1, onStep),
292
+ resume: (onStep) => this.resumeRuntime(onStep),
270
293
  pause: () => this.stop(),
271
294
  injectAction: (action, operation) => this.input.injectAction(action, operation),
272
295
  availableActions: () => this.input.availableActions(),
@@ -285,6 +308,7 @@ export class Game {
285
308
  this.renderSurface();
286
309
  return;
287
310
  }
311
+ this.resetClock();
288
312
  this.renderer.setAnimationLoop((time) => this.tick(time));
289
313
  }
290
314
  stop() {
@@ -321,60 +345,129 @@ export class Game {
321
345
  entity.destroy();
322
346
  this.renderer.dispose();
323
347
  }
324
- resumeRuntime(frame) {
325
- let previousTime = null;
326
- this.renderer.setAnimationLoop((time) => {
327
- const dt = previousTime === null ? 0 : Math.min((time - previousTime) / 1000, 0.1);
328
- previousTime = time;
329
- frame(dt);
330
- });
348
+ /**
349
+ * Real-time playback for the Runtime Bridge: the same clock-driven loop
350
+ * as start(), sharing the accumulator, with `onStep` told after every
351
+ * Simulation Step so the bridge counts frames exactly as paused stepping
352
+ * does (CA-7). No wall-clock catch-up: the first frame only seeds the
353
+ * clock (CA-3).
354
+ */
355
+ resumeRuntime(onStep) {
356
+ this.resetClock();
357
+ this.renderer.setAnimationLoop((time) => this.tick(time, onStep));
331
358
  }
332
- tick(time) {
333
- // Clamp dt: switching tabs or pausing doesn't fast-forward the simulation.
334
- const dt = Math.min((time - this.lastTime) / 1000, 0.1);
359
+ /** Forgets the clock and any partial step, so the next frame runs no burst. */
360
+ resetClock() {
361
+ this.lastTime = null;
362
+ this.stepRemainder = 0;
363
+ this.snapResidual = 0;
364
+ }
365
+ /**
366
+ * One animation frame (ADR 0014): the elapsed wall-clock time joins the
367
+ * retained remainder, and as many whole Simulation Steps as it holds run
368
+ * — capped, with the excess dropped, so a hitch can neither spiral nor
369
+ * play in slow motion. Not simulating: no time accrues at all. The
370
+ * measured duration is frame-rate-snapped first (round 2 correctness) so
371
+ * sub-millisecond timestamp jitter at an exact cadence like 60 Hz can't
372
+ * flip the whole-steps floor and judder 0/2/0/2.
373
+ */
374
+ tick(time, onStep) {
375
+ const measured = this.lastTime === null ? 0 : (time - this.lastTime) / 1000;
335
376
  this.lastTime = time;
336
- this.runFrame(dt);
377
+ if (!this.simulate) {
378
+ this.stepRemainder = 0;
379
+ this.snapResidual = 0;
380
+ this.runFrame(0);
381
+ return;
382
+ }
383
+ const { elapsed, residual } = snapElapsedToStep(measured, this.snapResidual);
384
+ this.snapResidual = residual;
385
+ const { steps, remainder } = consumeSimulationSteps(this.stepRemainder, elapsed);
386
+ this.stepRemainder = remainder;
387
+ this.runFrame(steps, onStep);
337
388
  }
338
- runFrame(dt) {
389
+ /**
390
+ * Runs `steps` Simulation Steps back to back, then the once-per-frame
391
+ * tail: audio activity and placements, the UI overlay and the render
392
+ * (CA-5). A queued scene swap flushes at the very start of the frame —
393
+ * loadSceneByName's contract — and again before every step after the
394
+ * first (CA-4), so two steps in one frame never see the same press
395
+ * twice or the outgoing scene once too often; a frame that runs zero
396
+ * steps (round 2 correctness) still flushes, so it never renders/
397
+ * audio-places the outgoing scene one frame longer than it should.
398
+ */
399
+ runFrame(steps, onStep) {
339
400
  this.insideFrame = true;
340
401
  try {
341
- // Flushes a scene swap enqueued mid-frame last time (CA-7): applied
342
- // before this frame's own simulation, so the incoming scene's
343
- // entities are present only from this next frame onward.
344
402
  this.flushPendingSceneLoad();
345
403
  if (this.simulate) {
346
- for (const entity of [...this.entities]) {
347
- const schedule = this.componentUpdateSchedule(entity);
348
- if (!schedule)
349
- continue;
350
- for (const component of schedule)
351
- component.onUpdate?.(dt);
404
+ // Re-read every iteration, not just once before the loop: a
405
+ // component or host callback can set `simulate = false` mid-step,
406
+ // and the remaining steps of this catch-up frame must not run.
407
+ for (let index = 0; index < steps && this.simulate; index += 1) {
408
+ // Step 0 was just flushed above; only later steps need it again.
409
+ if (index > 0)
410
+ this.flushPendingSceneLoad();
411
+ this.simulateStep();
412
+ onStep?.();
352
413
  }
353
- this.dispatchCollisions();
354
- this.updateSceneCamera(dt);
355
414
  }
356
- // The UI must react to the pause itself (hide until resumed).
357
- this.ui.setActive(this.simulate);
415
+ else {
416
+ // [DEVIATION 2026-09-12] The editor draws its edit-mode overlays from
417
+ // game.onUpdate with simulate = false (Viewport.tsx), exactly as it
418
+ // did before the fixed step: a non-simulating frame runs no step but
419
+ // still hands the host one callback and closes the input frame.
420
+ this.finishStep();
421
+ }
358
422
  this.audio.setActive(this.simulate);
359
423
  // Positional audio (CA-8): recomputed every frame, on this same pass —
360
424
  // never a second walk of `this.entities`, since `this.audio` already
361
425
  // holds direct references to whichever entities are tracked.
362
426
  this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
363
- for (const fn of this.updateFns)
364
- fn(dt);
365
- this.input.endFrame();
366
427
  this.renderSurface();
367
428
  }
368
429
  finally {
369
430
  this.insideFrame = false;
370
431
  }
371
432
  }
433
+ /**
434
+ * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
435
+ * collisions, the scene camera and the host's callbacks, every one of
436
+ * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
437
+ */
438
+ simulateStep() {
439
+ for (const entity of [...this.entities]) {
440
+ const schedule = this.componentUpdateSchedule(entity);
441
+ if (!schedule)
442
+ continue;
443
+ for (const component of schedule)
444
+ component.onUpdate?.(SIMULATION_STEP);
445
+ }
446
+ this.dispatchCollisions();
447
+ this.updateSceneCamera(SIMULATION_STEP);
448
+ this.finishStep();
449
+ }
450
+ /** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
451
+ finishStep() {
452
+ this.runHostUpdates();
453
+ this.input.endFrame();
454
+ }
455
+ runHostUpdates() {
456
+ for (const fn of this.updateFns)
457
+ fn(SIMULATION_STEP);
458
+ }
372
459
  flushPendingSceneLoad() {
373
- const pending = this.pendingSceneLoad;
374
- if (!pending)
375
- return;
376
- this.pendingSceneLoad = null;
377
- pending();
460
+ // Drains the whole chain, not just one level: a loadSceneByName called
461
+ // from the incoming scene's onReady (still insideFrame) re-queues
462
+ // pendingSceneLoad, and a frame that runs zero steps never reaches the
463
+ // per-step flush that would otherwise pick it up next. Capped like the
464
+ // state machine's chained-transition loop (MAX_CHAINED_HOPS), so a
465
+ // degenerate scene cycle can't hang here either.
466
+ for (let hops = 0; hops < MAX_CHAINED_HOPS && this.pendingSceneLoad; hops += 1) {
467
+ const pending = this.pendingSceneLoad;
468
+ this.pendingSceneLoad = null;
469
+ pending();
470
+ }
378
471
  }
379
472
  unregisterRuntimeBridge = () => {
380
473
  window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
@@ -420,24 +513,36 @@ export class Game {
420
513
  }
421
514
  componentUpdateSchedule(entity) {
422
515
  const components = [...entity.components];
423
- const registry = {
424
- ...(this.registry?.components ?? {}),
425
- };
426
516
  const byName = new Map();
427
517
  const names = [];
428
518
  const signatureParts = [];
429
519
  for (const component of components) {
430
520
  const Class = component.constructor;
431
521
  const name = Class.componentName;
432
- registry[name] = Class;
433
522
  names.push(name);
434
523
  byName.set(name, component);
435
524
  signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
436
525
  [...new Set(Class.updateAfter ?? [])].sort().join(','));
437
526
  }
527
+ const signature = signatureParts.sort().join('|');
528
+ // The resolve below (duplicate check + Tarjan SCC + Kahn sort) is pure
529
+ // for a fixed composition, and this method now runs once per entity per
530
+ // Simulation Step rather than once per rendered frame: skip it entirely
531
+ // when nothing about this entity's components changed since last time.
532
+ const cached = this.updateScheduleCache.get(entity);
533
+ if (cached && cached.signature === signature) {
534
+ return cached.order.map((name) => byName.get(name));
535
+ }
536
+ const registry = {
537
+ ...(this.registry?.components ?? {}),
538
+ };
539
+ for (const component of components) {
540
+ const Class = component.constructor;
541
+ registry[Class.componentName] = Class;
542
+ }
438
543
  const result = resolveComponentUpdateSchedule(names, registry);
439
544
  if (!result.ok) {
440
- const signature = signatureParts.sort().join('|');
545
+ this.updateScheduleCache.delete(entity);
441
546
  if (this.invalidUpdateCompositions.get(entity) !== signature) {
442
547
  this.invalidUpdateCompositions.set(entity, signature);
443
548
  console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
@@ -446,6 +551,7 @@ export class Game {
446
551
  return null;
447
552
  }
448
553
  this.invalidUpdateCompositions.delete(entity);
554
+ this.updateScheduleCache.set(entity, { signature, order: result.order });
449
555
  return result.order.map((name) => byName.get(name));
450
556
  }
451
557
  updateSceneCamera(dt) {
@@ -500,21 +606,7 @@ export class Game {
500
606
  const hb = b.get(Hitbox);
501
607
  if (!ha || !hb)
502
608
  continue;
503
- const hit = collisionOverlap({
504
- x: a.position.x + ha.offsetX,
505
- y: a.position.y + ha.offsetY,
506
- width: ha.width,
507
- height: ha.height,
508
- shape: ha.shape,
509
- points: ha.points,
510
- }, {
511
- x: b.position.x + hb.offsetX,
512
- y: b.position.y + hb.offsetY,
513
- width: hb.width,
514
- height: hb.height,
515
- shape: hb.shape,
516
- points: hb.points,
517
- });
609
+ const hit = collisionOverlap(collisionBody(ha), collisionBody(hb));
518
610
  if (!hit)
519
611
  continue;
520
612
  for (const c of [...a.components])
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export type { ProjectedPoint } from './projection.js';
13
13
  export { spritePlacement } from './sprite-placement.js';
14
14
  export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
15
15
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
16
+ export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
16
17
  export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
17
18
  export { Entity } from './entity.js';
18
19
  export { Component } from './component.js';
@@ -49,6 +50,7 @@ export { cellAt, cellBounds, cellIndex } from './tilemap-grid.js';
49
50
  export type { TilemapCell, TilemapCellBounds, TilemapGridSpec, } from './tilemap-grid.js';
50
51
  export { COLLISION_SHAPES, DEFAULT_COLLISION_POLYGON, collisionBounds, collisionOverlap, collisionVertices, resolveCollisionPoints, } from './collision-shape.js';
51
52
  export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, } from './collision-shape.js';
53
+ export type { EntityWith, NearestQueryContext, NearestSpatialQueryFilter, RayHit, SpatialQuery, SpatialQueryFilter, } from './spatial-query.js';
52
54
  export { Emitter } from './events.js';
53
55
  export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
54
56
  export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, PrefabJson, } from './scene.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { isYSortParticipant, ySortZ } from './render-sort.js';
5
5
  export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
6
6
  export { spritePlacement } from './sprite-placement.js';
7
7
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
8
+ export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
8
9
  export { Entity } from './entity.js';
9
10
  export { Component } from './component.js';
10
11
  export { authoringDefaults } from './authoring-defaults.js';
@@ -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)
@@ -0,0 +1,20 @@
1
+ import { type CollisionBody } from './collision-shape.js';
2
+ /** Internal absolute tolerance shared by point and ray boundary rules. */
3
+ export declare const SPATIAL_QUERY_EPSILON = 1e-9;
4
+ export interface SpatialGeometryRayHit {
5
+ readonly distance: number;
6
+ readonly point: Readonly<{
7
+ x: number;
8
+ y: number;
9
+ }>;
10
+ readonly normal: Readonly<{
11
+ x: number;
12
+ y: number;
13
+ }>;
14
+ }
15
+ /** Valid finite geometry with a positive two-dimensional area. */
16
+ export declare function usableCollisionBody(value: unknown): value is CollisionBody;
17
+ /** Strict containment in the existing collision outline. */
18
+ export declare function collisionBodyContainsPoint(body: unknown, x: number, y: number): boolean;
19
+ /** First strict crossing against one resolved body. Direction must be normalized. */
20
+ export declare function collisionBodyRay(body: unknown, x: number, y: number, dx: number, dy: number, maxDistance: number): SpatialGeometryRayHit | null;