@waica/engine 0.14.1 → 0.16.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.
@@ -0,0 +1,401 @@
1
+ import { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
2
+ const INACTIVE_HANDLE = Object.freeze({
3
+ cancel() { },
4
+ active: false,
5
+ elapsed: 0,
6
+ remaining: 0,
7
+ });
8
+ const EASINGS = {
9
+ linear: (t) => t,
10
+ quadIn: (t) => t * t,
11
+ quadOut: (t) => 1 - (1 - t) * (1 - t),
12
+ quadInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
13
+ cubicIn: (t) => t * t * t,
14
+ cubicOut: (t) => 1 - (1 - t) ** 3,
15
+ cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2),
16
+ sineInOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
17
+ };
18
+ function resolveEasing(easing) {
19
+ if (easing === undefined)
20
+ return EASINGS.linear;
21
+ if (typeof easing === 'function')
22
+ return easing;
23
+ return Object.prototype.hasOwnProperty.call(EASINGS, easing) ? EASINGS[easing] : null;
24
+ }
25
+ function isValidOwner(owner) {
26
+ return (typeof owner === 'object' &&
27
+ owner !== null &&
28
+ typeof owner['alive'] === 'boolean');
29
+ }
30
+ /** `'session'` survives a scene change; any other value (including absent) means scene. */
31
+ function normalizeScope(scope) {
32
+ return scope === 'session' ? 'session' : 'scene';
33
+ }
34
+ /**
35
+ * Module-private key for the per-step advance pass. Not exported, so
36
+ * `time[ADVANCE]()` cannot be spelled outside this file — `advanceGameTime`
37
+ * (below, exported) is the only reachable entry point from project code.
38
+ */
39
+ const ADVANCE = Symbol('waica.gameTime.advance');
40
+ /**
41
+ * The `game.time` service (CA-1..CA-9, CA-14): `after`, `every` and `tween`
42
+ * scheduled against Game Time (`now`), advanced only by Simulation Steps —
43
+ * never the wall clock, never synchronously, never while paused or not
44
+ * simulating. Constructible standalone, like `Stats`: a `Game` owns one via
45
+ * `game.time`, and a behaviors-test stub game can carry its own, advanced by
46
+ * the same engine-internal hook `Game` uses (`advanceGameTime`, exported
47
+ * below — not part of this class's documented API).
48
+ */
49
+ export class GameTime {
50
+ stepCount = 0;
51
+ nextId = 0;
52
+ timers = [];
53
+ tweens = [];
54
+ /**
55
+ * Set whenever a timer/tween goes inactive since the matching array was
56
+ * last pruned, whether by firing/completing the per-step advance pass or
57
+ * by a plain `handle.cancel()` from outside it; consumed (and cleared) by
58
+ * the next `reclaim()` that actually reassigns that array (CA-3 perf: no
59
+ * reassignment when nothing died). Invariant: a flag is false only when
60
+ * its array holds no inactive entries — every path that clears a flag
61
+ * prunes that array first, and every path that deactivates an entry sets
62
+ * the matching flag. `reclaim()` relies on this to skip a prune safely.
63
+ */
64
+ timersDirty = false;
65
+ tweensDirty = false;
66
+ /** 0 on a new GameTime; `N * SIMULATION_STEP` exactly after N steps — computed, never summed (CA-9). */
67
+ get now() {
68
+ return this.stepCount * SIMULATION_STEP;
69
+ }
70
+ /** Active timers plus active tweens across both scopes (CA-10). */
71
+ get pending() {
72
+ return this.countActive(this.timers) + this.countActive(this.tweens);
73
+ }
74
+ /** Smallest positive integer n s.t. n more steps fires/completes something; null when nothing is pending (CA-10). */
75
+ get nextInSteps() {
76
+ return this.minStepsUntilDue(this.tweens, this.minStepsUntilDue(this.timers, null));
77
+ }
78
+ /** Shared traversal for `pending`: how many entries in `entries` are active. */
79
+ countActive(entries) {
80
+ let count = 0;
81
+ for (const entry of entries)
82
+ if (entry.active)
83
+ count += 1;
84
+ return count;
85
+ }
86
+ /** Shared traversal for `nextInSteps`: folds `stepsUntilDue` over every active entry, starting from `min`. */
87
+ minStepsUntilDue(entries, min) {
88
+ for (const entry of entries) {
89
+ if (!entry.active)
90
+ continue;
91
+ const steps = this.stepsUntilDue(entry.dueTime);
92
+ min = min === null ? steps : Math.min(min, steps);
93
+ }
94
+ return min;
95
+ }
96
+ /**
97
+ * Runs `callback` once, `seconds` of Game Time from now (0 or a negative
98
+ * duration: the start of the next step). Never synchronous (CA-1).
99
+ */
100
+ after(seconds, callback, options = {}) {
101
+ if (!Number.isFinite(seconds))
102
+ return this.invalid('after(): seconds must be finite');
103
+ if (typeof callback !== 'function')
104
+ return this.invalid('after(): callback must be a function');
105
+ const owner = options.owner;
106
+ if (owner !== undefined && !isValidOwner(owner)) {
107
+ return this.invalid('after(): owner must be an object with a boolean "alive" property');
108
+ }
109
+ if (owner !== undefined && !owner.alive)
110
+ return INACTIVE_HANDLE;
111
+ const effective = Math.max(0, seconds);
112
+ const startTime = this.now;
113
+ const entry = {
114
+ id: this.nextId++,
115
+ kind: 'after',
116
+ scope: normalizeScope(options.scope),
117
+ owner,
118
+ active: true,
119
+ referenceStart: startTime,
120
+ dueTime: startTime + effective,
121
+ deactivatedAt: null,
122
+ callback,
123
+ };
124
+ this.timers.push(entry);
125
+ return this.handleFor(entry);
126
+ }
127
+ /**
128
+ * Runs `callback` every `max(seconds, SIMULATION_STEP)` of Game Time,
129
+ * first one interval after creation, with no drift: each due time is
130
+ * `start + n * interval`, computed directly rather than by repeated
131
+ * addition (CA-2).
132
+ */
133
+ every(seconds, callback, options = {}) {
134
+ if (!Number.isFinite(seconds))
135
+ return this.invalid('every(): seconds must be finite');
136
+ if (typeof callback !== 'function')
137
+ return this.invalid('every(): callback must be a function');
138
+ const owner = options.owner;
139
+ if (owner !== undefined && !isValidOwner(owner)) {
140
+ return this.invalid('every(): owner must be an object with a boolean "alive" property');
141
+ }
142
+ if (owner !== undefined && !owner.alive)
143
+ return INACTIVE_HANDLE;
144
+ const interval = Math.max(seconds, SIMULATION_STEP);
145
+ const startTime = this.now;
146
+ const entry = {
147
+ id: this.nextId++,
148
+ kind: 'every',
149
+ scope: normalizeScope(options.scope),
150
+ owner,
151
+ active: true,
152
+ referenceStart: startTime,
153
+ dueTime: startTime + interval,
154
+ deactivatedAt: null,
155
+ callback,
156
+ startTime,
157
+ intervalSeconds: interval,
158
+ occurrence: 1,
159
+ };
160
+ this.timers.push(entry);
161
+ return this.handleFor(entry);
162
+ }
163
+ /**
164
+ * Carries a single number from `from` to `to` over `seconds` of Game
165
+ * Time, reporting it through `onUpdate` on every step including creation
166
+ * (synchronous, before this returns — CA-7). Owner already dead, or any
167
+ * invalid input, skips even that first call.
168
+ */
169
+ tween(options) {
170
+ const { from, to, seconds, onUpdate, onComplete } = options;
171
+ if (!Number.isFinite(seconds))
172
+ return this.invalid('tween(): seconds must be finite');
173
+ if (!Number.isFinite(from))
174
+ return this.invalid('tween(): from must be finite');
175
+ if (!Number.isFinite(to))
176
+ return this.invalid('tween(): to must be finite');
177
+ if (typeof onUpdate !== 'function')
178
+ return this.invalid('tween(): onUpdate must be a function');
179
+ if (onComplete !== undefined && typeof onComplete !== 'function') {
180
+ return this.invalid('tween(): onComplete must be a function when present');
181
+ }
182
+ const ease = resolveEasing(options.easing);
183
+ if (!ease)
184
+ return this.invalid('tween(): easing must be a known name or a function');
185
+ const owner = options.owner;
186
+ if (owner !== undefined && !isValidOwner(owner)) {
187
+ return this.invalid('tween(): owner must be an object with a boolean "alive" property');
188
+ }
189
+ if (owner !== undefined && !owner.alive)
190
+ return INACTIVE_HANDLE;
191
+ const effective = Math.max(0, seconds);
192
+ const createdAt = this.now;
193
+ const entry = {
194
+ id: this.nextId++,
195
+ kind: 'tween',
196
+ scope: normalizeScope(options.scope),
197
+ owner,
198
+ active: true,
199
+ referenceStart: createdAt,
200
+ dueTime: createdAt + effective,
201
+ deactivatedAt: null,
202
+ from,
203
+ to,
204
+ seconds: effective,
205
+ ease,
206
+ onUpdate,
207
+ onComplete,
208
+ };
209
+ // Registers nothing if this throws (CA-7): the entry is only pushed after.
210
+ onUpdate(from);
211
+ this.tweens.push(entry);
212
+ return this.handleFor(entry);
213
+ }
214
+ /**
215
+ * Internal: the start-of-step pass (CA-3) — advances `now`, then runs
216
+ * every due timer (due time, then creation order), then advances every
217
+ * tween that existed before this pass (creation order). Reachable only as
218
+ * `time[ADVANCE]()`, and `ADVANCE` is not exported, so `advanceGameTime()`
219
+ * (below, exported) is genuinely the only way in from project code. With
220
+ * nothing scheduled (the common case once a scene settles) this does no
221
+ * allocation beyond the step count itself; with timers but no tweens, the
222
+ * tween snapshot and `advanceTweens()` are skipped too.
223
+ */
224
+ [ADVANCE]() {
225
+ this.stepCount += 1;
226
+ if (this.timers.length === 0 && this.tweens.length === 0)
227
+ return;
228
+ if (this.tweens.length === 0) {
229
+ this.runDueTimers();
230
+ }
231
+ else {
232
+ // Snapshot before any callback runs: a tween a due timer creates
233
+ // during this pass must not be advanced (or completed) until the
234
+ // next step. Skipped entirely above when there is nothing to snapshot.
235
+ const tweens = [...this.tweens];
236
+ this.runDueTimers();
237
+ this.advanceTweens(tweens);
238
+ }
239
+ this.reclaim();
240
+ }
241
+ /**
242
+ * Engine-internal by convention, like `Game.removeEntity`: a public
243
+ * method, not access-controlled, that `Game.unloadScene()` and every
244
+ * scene load are expected to call, including the first (CA-4).
245
+ */
246
+ cancelSceneScoped() {
247
+ for (const timer of this.timers)
248
+ if (timer.scope !== 'session')
249
+ this.deactivate(timer);
250
+ for (const tween of this.tweens)
251
+ if (tween.scope !== 'session')
252
+ this.deactivate(tween);
253
+ this.reclaim();
254
+ }
255
+ /**
256
+ * Engine-internal by convention, like `Game.removeEntity`: a public
257
+ * method, not access-controlled, that `Entity.destroy()` (CA-5) is
258
+ * expected to be the only caller of. Nothing re-reads `owner.alive` on
259
+ * later steps; a non-`Entity` owner whose `alive` flips without ever
260
+ * going through a real `destroy()` call never reaches here, so its
261
+ * timers/tweens keep running (see `TimerOptions.owner`).
262
+ */
263
+ cancelOwnedBy(owner) {
264
+ for (const timer of this.timers)
265
+ if (timer.owner === owner)
266
+ this.deactivate(timer);
267
+ for (const tween of this.tweens)
268
+ if (tween.owner === owner)
269
+ this.deactivate(tween);
270
+ this.reclaim();
271
+ }
272
+ /**
273
+ * Engine-internal by convention, like `Game.removeEntity`: a public
274
+ * method, not access-controlled, that `Game.dispose()` is expected to
275
+ * call (CA-4).
276
+ */
277
+ cancelAll() {
278
+ for (const timer of this.timers)
279
+ this.deactivate(timer);
280
+ for (const tween of this.tweens)
281
+ this.deactivate(tween);
282
+ this.reclaim();
283
+ }
284
+ /**
285
+ * Prunes each array only when its dirty flag is set, then clears that
286
+ * flag — safe because of the invariant documented on `timersDirty`
287
+ * /`tweensDirty`: a false flag means the array already holds no inactive
288
+ * entries, so skipping the filter changes no observable behavior. Called
289
+ * both at the tail of the per-step advance pass and by
290
+ * `cancelSceneScoped`/`cancelOwnedBy`/`cancelAll`, which are also
291
+ * reachable with `simulate === false` (e.g. the editor's edit mode
292
+ * calling `loadScene` on every edit), when no step ever runs to reclaim
293
+ * otherwise.
294
+ */
295
+ reclaim() {
296
+ if (this.timersDirty) {
297
+ this.timers = this.timers.filter((timer) => timer.active);
298
+ this.timersDirty = false;
299
+ }
300
+ if (this.tweensDirty) {
301
+ this.tweens = this.tweens.filter((tween) => tween.active);
302
+ this.tweensDirty = false;
303
+ }
304
+ }
305
+ runDueTimers() {
306
+ const due = this.timers.filter((timer) => timer.active && timer.dueTime <= this.now + SIMULATION_TIME_EPSILON);
307
+ if (due.length === 0)
308
+ return;
309
+ if (due.length > 1)
310
+ due.sort((a, b) => a.dueTime - b.dueTime || a.id - b.id);
311
+ for (const timer of due) {
312
+ if (!timer.active)
313
+ continue; // an earlier callback this same pass may have cancelled it
314
+ if (timer.kind === 'every') {
315
+ timer.referenceStart = timer.dueTime;
316
+ timer.occurrence += 1;
317
+ timer.dueTime = timer.startTime + timer.occurrence * timer.intervalSeconds;
318
+ }
319
+ else {
320
+ timer.active = false;
321
+ timer.deactivatedAt = timer.dueTime;
322
+ this.timersDirty = true;
323
+ }
324
+ timer.callback();
325
+ }
326
+ }
327
+ advanceTweens(tweens) {
328
+ for (const tween of tweens) {
329
+ if (!tween.active)
330
+ continue;
331
+ if (tween.dueTime <= this.now + SIMULATION_TIME_EPSILON) {
332
+ tween.active = false;
333
+ tween.deactivatedAt = tween.dueTime;
334
+ this.tweensDirty = true;
335
+ tween.onUpdate(tween.to);
336
+ tween.onComplete?.();
337
+ continue;
338
+ }
339
+ const elapsed = this.now - tween.referenceStart;
340
+ // Reaching 1 would mean elapsed >= seconds, i.e. now >= dueTime — the
341
+ // branch above always exits first in that case, so this is never
342
+ // clamped in practice.
343
+ const t = elapsed / tween.seconds;
344
+ tween.onUpdate(tween.from + (tween.to - tween.from) * tween.ease(t));
345
+ }
346
+ }
347
+ deactivate(entry) {
348
+ if (!entry.active)
349
+ return;
350
+ entry.active = false;
351
+ entry.deactivatedAt = this.now;
352
+ if (entry.kind === 'tween')
353
+ this.tweensDirty = true;
354
+ else
355
+ this.timersDirty = true;
356
+ }
357
+ /** Smallest n >= 1 such that `n` more steps reaches `dueTime`, guarding against float noise near a step boundary. */
358
+ stepsUntilDue(dueTime) {
359
+ const raw = (dueTime - this.now) / SIMULATION_STEP;
360
+ const n = Math.ceil(raw - SIMULATION_TIME_EPSILON / SIMULATION_STEP);
361
+ return Math.max(1, n);
362
+ }
363
+ invalid(message) {
364
+ console.warn(`[waica] ${message}`);
365
+ return INACTIVE_HANDLE;
366
+ }
367
+ handleFor(entry) {
368
+ const time = this;
369
+ return {
370
+ cancel() {
371
+ time.deactivate(entry);
372
+ },
373
+ get active() {
374
+ return entry.active;
375
+ },
376
+ get elapsed() {
377
+ const end = entry.deactivatedAt ?? time.now;
378
+ return Math.max(0, end - entry.referenceStart);
379
+ },
380
+ get remaining() {
381
+ if (!entry.active)
382
+ return 0;
383
+ return Math.max(0, entry.dueTime - time.now);
384
+ },
385
+ };
386
+ }
387
+ }
388
+ /**
389
+ * Engine-internal: advances `time` by exactly one Simulation Step (CA-3).
390
+ * `Game.simulateStep()` calls this as its very first statement, so nothing
391
+ * advances with `simulate === false`, on a zero-step frame, or while the
392
+ * Runtime Bridge is paused and not stepping. A standalone `GameTime` — a
393
+ * behaviors-test stub game, or this package's own game-time.test.ts (CA-14)
394
+ * — must call this the same way. This is the only way in: the pass itself
395
+ * lives behind the module-private `ADVANCE` symbol key, unreachable from
396
+ * outside this file, so project code has no `advanceStep()`-shaped method
397
+ * to call directly and desynchronize Game Time with.
398
+ */
399
+ export function advanceGameTime(time) {
400
+ time[ADVANCE]();
401
+ }
package/dist/game.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type SceneCameraJson } from './camera.js';
5
5
  import type { Component } from './component.js';
6
6
  import { Entity } from './entity.js';
7
7
  import { Emitter } from './events.js';
8
+ import { GameTime } from './game-time.js';
8
9
  import { Input, type InputBindings } from './input.js';
9
10
  import { Pointer } from './pointer.js';
10
11
  import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
@@ -49,7 +50,7 @@ export interface SceneCatalog {
49
50
  registry: SceneRegistry;
50
51
  }
51
52
  /** Persisted overrides: entity → componentName → prop → value. */
52
- export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string>>>;
53
+ export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string | string[]>>>;
53
54
  /**
54
55
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
55
56
  * entities with components, and input. See DESIGN.md.
@@ -67,6 +68,8 @@ export declare class Game {
67
68
  readonly ui: GameUi;
68
69
  /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
69
70
  readonly audio: AudioSubsystem;
71
+ /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
72
+ readonly time: GameTime;
70
73
  /** Registry retained by loadScene for runtime prefab spawning. */
71
74
  registry: SceneRegistry | null;
72
75
  paramOverrides: ParamOverrides;
@@ -137,8 +140,8 @@ export declare class Game {
137
140
  * the live scene. An unknown name warns and leaves the live scene
138
141
  * untouched. Triggered mid-frame (e.g. from a SceneTransition's
139
142
  * onCollide/onInteract) the swap is deferred to the very start of the
140
- * next runFrame — dispatchCollisions finishes its double loop over the
141
- * outgoing scene, and the incoming scene's entities are present only
143
+ * next runFrame — dispatchCollisions finishes its frozen pair snapshot for
144
+ * the outgoing scene, and the incoming scene's entities are present only
142
145
  * from the next frame. Called from outside a frame (boot, or the Runtime
143
146
  * Bridge's `scene` control operation) it applies synchronously and wins
144
147
  * over anything queued earlier this frame. A second mid-frame request
@@ -209,7 +212,8 @@ export declare class Game {
209
212
  */
210
213
  private runFrame;
211
214
  /**
212
- * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
215
+ * One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
216
+ * first, then the Component Update Schedule (ADR 0004) in full,
213
217
  * collisions, the scene camera and the host's callbacks, every one of
214
218
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
215
219
  */
package/dist/game.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import * as THREE from 'three';
2
2
  import { AudioSubsystem } from './audio/audio-subsystem.js';
3
- import { collisionBody } from './collision-body.js';
4
- import { collisionOverlap } from './collision-shape.js';
3
+ import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
5
4
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
6
5
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
7
- import { Hitbox } from './components/hitbox.js';
8
6
  import { Entity } from './entity.js';
9
7
  import { Emitter } from './events.js';
10
8
  import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
9
+ import { advanceGameTime, GameTime } from './game-time.js';
11
10
  import { Input } from './input.js';
12
11
  import { Pointer } from './pointer.js';
13
12
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
@@ -35,6 +34,8 @@ export class Game {
35
34
  ui;
36
35
  /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
37
36
  audio;
37
+ /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
38
+ time = new GameTime();
38
39
  /** Registry retained by loadScene for runtime prefab spawning. */
39
40
  registry = null;
40
41
  paramOverrides = {};
@@ -150,6 +151,9 @@ export class Game {
150
151
  unloadScene() {
151
152
  this.ui.unloadScene();
152
153
  this.audio.unloadScene();
154
+ // Scene-scoped timers/tweens die here too (ADR 0017): before entities are
155
+ // destroyed below, so an owner's own destroy() cancellation is a no-op.
156
+ this.time.cancelSceneScoped();
153
157
  // An explicit unload means "no scene": a swap queued earlier this frame
154
158
  // would otherwise flush next frame and resurrect one.
155
159
  this.pendingSceneLoad = null;
@@ -181,8 +185,8 @@ export class Game {
181
185
  * the live scene. An unknown name warns and leaves the live scene
182
186
  * untouched. Triggered mid-frame (e.g. from a SceneTransition's
183
187
  * onCollide/onInteract) the swap is deferred to the very start of the
184
- * next runFrame — dispatchCollisions finishes its double loop over the
185
- * outgoing scene, and the incoming scene's entities are present only
188
+ * next runFrame — dispatchCollisions finishes its frozen pair snapshot for
189
+ * the outgoing scene, and the incoming scene's entities are present only
186
190
  * from the next frame. Called from outside a frame (boot, or the Runtime
187
191
  * Bridge's `scene` control operation) it applies synchronously and wins
188
192
  * over anything queued earlier this frame. A second mid-frame request
@@ -341,6 +345,9 @@ export class Game {
341
345
  this.resizeObserver.disconnect();
342
346
  this.ui.dispose();
343
347
  this.audio.dispose();
348
+ // Cancels both scopes, including session-scoped work no entity owns
349
+ // (entity.destroy() below only ever reaches owned work) — ADR 0017.
350
+ this.time.cancelAll();
344
351
  for (const entity of [...this.entities])
345
352
  entity.destroy();
346
353
  this.renderer.dispose();
@@ -431,11 +438,13 @@ export class Game {
431
438
  }
432
439
  }
433
440
  /**
434
- * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
441
+ * One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
442
+ * first, then the Component Update Schedule (ADR 0004) in full,
435
443
  * collisions, the scene camera and the host's callbacks, every one of
436
444
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
437
445
  */
438
446
  simulateStep() {
447
+ advanceGameTime(this.time);
439
448
  for (const entity of [...this.entities]) {
440
449
  const schedule = this.componentUpdateSchedule(entity);
441
450
  if (!schedule)
@@ -595,28 +604,7 @@ export class Game {
595
604
  return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
596
605
  }
597
606
  dispatchCollisions() {
598
- const boxed = this.entities.filter((e) => e.has(Hitbox));
599
- for (let i = 0; i < boxed.length; i++) {
600
- for (let j = i + 1; j < boxed.length; j++) {
601
- const a = boxed[i];
602
- const b = boxed[j];
603
- if (!a?.alive || !b?.alive)
604
- continue;
605
- const ha = a.get(Hitbox);
606
- const hb = b.get(Hitbox);
607
- if (!ha || !hb)
608
- continue;
609
- const hit = collisionOverlap(collisionBody(ha), collisionBody(hb));
610
- if (!hit)
611
- continue;
612
- for (const c of [...a.components])
613
- c.onCollide?.(b);
614
- if (!a.alive || !b.alive)
615
- continue;
616
- for (const c of [...b.components])
617
- c.onCollide?.(a);
618
- }
619
- }
607
+ dispatchHitboxCollisions(this);
620
608
  }
621
609
  resize() {
622
610
  const canvas = this.renderer.domElement;
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ 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
16
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
17
+ export { GameTime, advanceGameTime } from './game-time.js';
18
+ export type { EasingName, TimerHandle, TimerOptions, TweenOptions } from './game-time.js';
17
19
  export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
18
20
  export { Entity } from './entity.js';
19
21
  export { Component } from './component.js';
@@ -30,7 +32,7 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
30
32
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
31
33
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
32
34
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
33
- export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
35
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeTransformSnapshot, } from './runtime-inspection.js';
34
36
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
35
37
  export { Stats } from './stats.js';
36
38
  export type { StatValue } from './stats.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { projectIsometric, screenInputToLogical, unprojectIsometric } from './pr
6
6
  export { spritePlacement } from './sprite-placement.js';
7
7
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
8
8
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
9
+ export { GameTime, advanceGameTime } from './game-time.js';
9
10
  export { Entity } from './entity.js';
10
11
  export { Component } from './component.js';
11
12
  export { authoringDefaults } from './authoring-defaults.js';
@@ -65,6 +65,23 @@ export interface RuntimeSnapshotAudio {
65
65
  channels: Record<string, AudioChannelState>;
66
66
  playing: LiveSoundInfo[];
67
67
  }
68
+ /**
69
+ * `game.time`'s pending work (CA-10), beside `audio`: `pending` counts
70
+ * active timers plus active tweens across both scopes; `nextInSteps` is the
71
+ * smallest positive integer n such that `step { frames: n }` makes an
72
+ * active timer run or an active tween complete, or null when `pending` is
73
+ * 0. Emitted unconditionally, like `audio` — never filtered, and never
74
+ * repeats `now` (metadata already carries `simulationTime`). That promise
75
+ * holds only while the Game is simulating: `runFrame` runs zero steps with
76
+ * `simulate === false` (`game.ts`), so on a Game that is paused AND not
77
+ * simulating, `nextInSteps` is still reported but stepping never consumes
78
+ * it — a Run Session starts with `simulate === true`, so this only matters
79
+ * if project code flips it.
80
+ */
81
+ export interface RuntimeSnapshotTime {
82
+ pending: number;
83
+ nextInSteps: number | null;
84
+ }
68
85
  export interface RuntimeSnapshot extends RuntimeMetadata {
69
86
  stats: Record<string, StatValue>;
70
87
  /** The live scene's name (its catalog key), or null with no scene loaded. */
@@ -72,6 +89,7 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
72
89
  entities: RuntimeEntitySnapshot[];
73
90
  projectionIssues: ProjectionIssue[];
74
91
  audio: RuntimeSnapshotAudio;
92
+ time: RuntimeSnapshotTime;
75
93
  }
76
94
  export declare const RUNTIME_PROJECTION_LIMITS: {
77
95
  readonly depth: 5;
@@ -87,6 +105,7 @@ export declare class RuntimeInspector {
87
105
  constructor(game: Game);
88
106
  snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
89
107
  private audioSnapshot;
108
+ private timeSnapshot;
90
109
  private capSnapshot;
91
110
  private idFor;
92
111
  }
@@ -238,6 +238,7 @@ export class RuntimeInspector {
238
238
  entities,
239
239
  projectionIssues,
240
240
  audio: this.audioSnapshot(),
241
+ time: this.timeSnapshot(),
241
242
  });
242
243
  }
243
244
  audioSnapshot() {
@@ -251,6 +252,12 @@ export class RuntimeInspector {
251
252
  playing: this.game.audio.liveSounds(),
252
253
  };
253
254
  }
255
+ timeSnapshot() {
256
+ return {
257
+ pending: this.game.time.pending,
258
+ nextInSteps: this.game.time.nextInSteps,
259
+ };
260
+ }
254
261
  capSnapshot(snapshot) {
255
262
  if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
256
263
  return snapshot;
package/dist/scene.js CHANGED
@@ -85,8 +85,17 @@ export function spawnFromJson(game, json, registry) {
85
85
  * at a time.
86
86
  */
87
87
  export function loadScene(game, scene, registry) {
88
- if (game.registry)
88
+ if (game.registry) {
89
89
  game.unloadScene();
90
+ }
91
+ else {
92
+ // unloadScene() would otherwise be skipped on a Game's first load
93
+ // (F11), leaving a scene-scoped timer/tween created before it — a boot
94
+ // clock, say — to survive this load and die only at the next one
95
+ // instead. Cancel that slice of unloadScene()'s work directly; there is
96
+ // no scene, UI or audio yet for the rest of it to touch (ADR 0017, CA-4).
97
+ game.time.cancelSceneScoped();
98
+ }
90
99
  game.registry = registry;
91
100
  game.setSceneRender(scene.render);
92
101
  for (const entityJson of scene.entities)