@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/README.md +41 -0
- package/dist/animation/clip-player.js +6 -1
- package/dist/collision-body.d.ts +5 -0
- package/dist/collision-body.js +11 -0
- 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 +58 -1
- package/dist/game.js +149 -57
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime-bridge.d.ts +16 -7
- package/dist/runtime-bridge.js +18 -14
- package/dist/spatial-query-geometry.d.ts +20 -0
- package/dist/spatial-query-geometry.js +291 -0
- package/dist/spatial-query.d.ts +92 -0
- package/dist/spatial-query.js +162 -0
- package/dist/state/state-machine.js +10 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
/**
|
|
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: (
|
|
269
|
-
resume: (
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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';
|
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)
|
|
@@ -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;
|