@galfus/engine 0.0.0-stage → 0.31.1-alpha

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.
Files changed (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,588 @@
1
+ /**
2
+ * MultiverseRuntime orchestrates platform transports, frame loops, and multiverse realms.
3
+ */
4
+
5
+ import {
6
+ type AnimationTarget,
7
+ type FrameTiming,
8
+ GalfusLoopError,
9
+ type GalfusTransport,
10
+ GalfusTransportError,
11
+ type LoopOptions,
12
+ type TransportInitOptions
13
+ } from '@galfus/transport-types';
14
+ import { AnimationCoordinator } from './animation/coordinator';
15
+ import { AudioManager } from './audio/manager';
16
+ import type { SystemContext } from './ecs/system';
17
+ import { FocusManager } from './interaction/focus';
18
+ import { InteractiveSystem } from './interaction/system';
19
+ import type { InteractiveTarget } from './interaction/types';
20
+ import { MultiverseCoordinator } from './multiverse/coordinator';
21
+ import type { RealmId } from './multiverse/realm';
22
+ import { PlatformCoordinator } from './platform/index';
23
+ import { Realm2D } from './realm2d/realm';
24
+ import { Realm3D } from './realm3d/realm';
25
+ import { storeCameraLookAt } from './realm3d/resource-ids';
26
+ import { ResourceManager } from './resources/manager';
27
+ import { TextEncoding } from './text/encoding';
28
+ import { TextInput, type TextInputOptions } from './text/input';
29
+ import type { Focusable } from './interaction/focus';
30
+ import {
31
+ type FixedTickTiming,
32
+ IdAllocator,
33
+ type RuntimeEventListener,
34
+ type RuntimeEventType,
35
+ type WindowId
36
+ } from './types';
37
+
38
+ /**
39
+ * Lifecycle states of the MultiverseRuntime orchestrator.
40
+ */
41
+ export type RuntimeState =
42
+ 'ready' | 'running' | 'paused' | 'resetting' | 'disposing' | 'disposed' | 'failed';
43
+
44
+ export interface RuntimeOptions {
45
+ /** Default character encoding for explicit text/byte conversions. */
46
+ charset?: string;
47
+ /** Fixed physics and simulation timestep in seconds. Defaults to 1 / 60. */
48
+ fixedDt?: number;
49
+ /** Maximum fixed timestep sub-steps permitted in a single frame. Defaults to 8. */
50
+ maxFixedSubSteps?: number;
51
+ }
52
+
53
+ export class MultiverseRuntime {
54
+ readonly platform = new PlatformCoordinator();
55
+ readonly multiverse = new MultiverseCoordinator();
56
+ readonly resources = new ResourceManager();
57
+ readonly audio = new AudioManager();
58
+ readonly animation = new AnimationCoordinator();
59
+ readonly textEncoding: TextEncoding;
60
+ private readonly _interactive = new InteractiveSystem(this.multiverse);
61
+ private readonly _textInputs = new Map<Focusable, TextInput>();
62
+ private readonly _focus = new FocusManager(
63
+ this._interactive,
64
+ () => this.multiverse.presentationGraph.getWindowIds(),
65
+ (windowId, enabled) => this.platform.window.setTabCapture(windowId as WindowId, enabled),
66
+ (windowId) => this.platform.window.requestInputFocus(windowId as WindowId)
67
+ );
68
+ readonly svg = {
69
+ getRect: (realmId: number, svgId: number, elementId: string) => {
70
+ const realm = this.multiverse.getRealm(realmId);
71
+ return realm instanceof Realm2D ? realm.getSvgElementRect(svgId, elementId) : undefined;
72
+ },
73
+ getTextLayout: (realmId: number, svgId: number, elementId: string) => {
74
+ const realm = this.multiverse.getRealm(realmId);
75
+ return realm instanceof Realm2D ? realm.getSvgTextLayout(svgId, elementId) : undefined;
76
+ }
77
+ };
78
+ private readonly _realmIdAllocator = new IdAllocator<RealmId>(1);
79
+ private readonly _eventListeners = new Map<RuntimeEventType, Set<(event: any) => void>>();
80
+ private _animationUnsubscribe: (() => void) | null = null;
81
+ private readonly _refreshAnimatedTarget = (target: AnimationTarget): void => {
82
+ if (!('realmId' in target)) return;
83
+ const realm = this.multiverse.getRealm(target.realmId);
84
+ if (realm instanceof Realm2D && 'svgId' in target) {
85
+ realm.invalidateSvgCache(target.svgId);
86
+ } else if (
87
+ realm instanceof Realm3D &&
88
+ target.kind === 'camera3DLookAt' &&
89
+ this._interactive.usesRealm(realm.realmId)
90
+ ) {
91
+ const lookAt = realm.getCameraLookAt(target.cameraId);
92
+ if (lookAt)
93
+ storeCameraLookAt(
94
+ realm.world,
95
+ target.cameraId,
96
+ lookAt.enabled && lookAt.target ? { target: lookAt.target, up: lookAt.up } : undefined
97
+ );
98
+ }
99
+ };
100
+ private _isSubsystemsBound = false;
101
+
102
+ private _frameId = 0;
103
+ private _elapsedSeconds = 0;
104
+ private _state: RuntimeState;
105
+ readonly fixedDt: number;
106
+ readonly maxFixedSubSteps: number;
107
+ private _fixedAccumulator = 0;
108
+
109
+ readonly #transport: GalfusTransport;
110
+
111
+ constructor(transport: GalfusTransport, options: RuntimeOptions = {}) {
112
+ this.#transport = transport;
113
+ this.textEncoding = new TextEncoding(options.charset);
114
+ this.fixedDt = options.fixedDt !== undefined && options.fixedDt > 0 ? options.fixedDt : 1 / 60;
115
+ this.maxFixedSubSteps = options.maxFixedSubSteps ?? 8;
116
+ this.animation.setNativeTargetObserver(this._refreshAnimatedTarget);
117
+ this.platform.window.onLifecycle((windowId, opened) => {
118
+ if (opened) this.multiverse.windowOpened(windowId);
119
+ else {
120
+ this.multiverse.windowClosed(windowId);
121
+ this._focus.windowClosed(windowId);
122
+ this._interactive.windowClosed(windowId);
123
+ }
124
+ });
125
+ if (transport.isDisposed) {
126
+ this._state = 'disposed';
127
+ } else {
128
+ this._state = 'ready';
129
+ if (transport.isInitialized) {
130
+ this.bindSubsystems();
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Current lifecycle state of the runtime.
137
+ */
138
+ get state(): RuntimeState {
139
+ return this._state;
140
+ }
141
+
142
+ /**
143
+ * Whether the active frame loop is currently running.
144
+ */
145
+ get isRunning(): boolean {
146
+ return this._state === 'running';
147
+ }
148
+
149
+ /**
150
+ * Total elapsed runtime simulation time in seconds.
151
+ */
152
+ get elapsedSeconds(): number {
153
+ return this._elapsedSeconds;
154
+ }
155
+
156
+ /**
157
+ * Current frame counter.
158
+ */
159
+ get frameId(): number {
160
+ return this._frameId;
161
+ }
162
+
163
+ /**
164
+ * Whether the runtime has been terminated and disposed.
165
+ */
166
+ get isDisposed(): boolean {
167
+ return this._state === 'disposed';
168
+ }
169
+
170
+ /**
171
+ * Initializes the transport backend (if not ready) and binds engine to all subsystems.
172
+ * Calling init repeatedly is an idempotent no-op.
173
+ */
174
+ async init(options?: TransportInitOptions): Promise<void> {
175
+ if (this._state === 'disposed' || this._state === 'failed') {
176
+ throw new GalfusTransportError(`Cannot initialize runtime in state '${this._state}'.`);
177
+ }
178
+ if (!this.#transport.isInitialized) {
179
+ await this.#transport.init(options);
180
+ }
181
+ this.bindSubsystems();
182
+ this._state = 'ready';
183
+ }
184
+
185
+ private bindSubsystems(): void {
186
+ if (this._isSubsystemsBound) return;
187
+ const engine = this.#transport.engine;
188
+ this.platform.bindEngine(engine);
189
+ this.multiverse.bindEngine(engine);
190
+ this.resources.bindEngine(engine);
191
+ this.audio.bindEngine(engine);
192
+ this.animation.bindEngine(engine);
193
+
194
+ if (!this._animationUnsubscribe) {
195
+ this._animationUnsubscribe = this.animation.on('animation', (event) => {
196
+ const animListeners = this._eventListeners.get('animation');
197
+ if (animListeners && animListeners.size > 0) {
198
+ for (const listener of animListeners) {
199
+ listener(event);
200
+ }
201
+ }
202
+ });
203
+ }
204
+ this._isSubsystemsBound = true;
205
+ }
206
+
207
+ /**
208
+ * Creates, registers, and returns a new Realm2D in the multiverse (automatically allocating RealmId if omitted).
209
+ */
210
+ createRealm2D(realmId?: RealmId | number): Realm2D {
211
+ const id = (realmId ?? this._realmIdAllocator.allocate('RealmId')) as RealmId;
212
+ const realm = new Realm2D(id);
213
+ this.multiverse.registerRealm(realm);
214
+ return realm;
215
+ }
216
+
217
+ /**
218
+ * Creates, registers, and returns a new Realm3D in the multiverse (automatically allocating RealmId if omitted).
219
+ */
220
+ createRealm3D(realmId?: RealmId | number): Realm3D {
221
+ const id = (realmId ?? this._realmIdAllocator.allocate('RealmId')) as RealmId;
222
+ const realm = new Realm3D(id);
223
+ realm.resources = this.resources;
224
+ this.multiverse.registerRealm(realm);
225
+ return realm;
226
+ }
227
+
228
+ /** Registers a pointer-interactive target in a realm. */
229
+ interactive(realmId: number, target: InteractiveTarget) {
230
+ return this._interactive.interactive(realmId, target);
231
+ }
232
+
233
+ /** Registers a pointer target for per-window focus and keyboard navigation. */
234
+ focusable(realmId: number, target: InteractiveTarget, options: { tabIndex?: number } = {}) {
235
+ if (options.tabIndex !== undefined && !Number.isSafeInteger(options.tabIndex))
236
+ throw new RangeError('tabIndex must be a safe integer.');
237
+ return this._focus.register(this._interactive.interactive(realmId, target), options.tabIndex);
238
+ }
239
+
240
+ /** Returns the target focused in a window, if any. */
241
+ getFocused(windowId: number) {
242
+ return this._focus.getFocused(windowId);
243
+ }
244
+
245
+ /** Binds editable text to an SVG element and its interactive hit area. */
246
+ textInput(realmId: number, options: TextInputOptions): TextInput {
247
+ if (!options.elementId || options.hitAreaId === '')
248
+ throw new Error('SVG element IDs must not be empty.');
249
+ const realm = this.multiverse.getRealm(realmId);
250
+ if (!(realm instanceof Realm2D) || realm.isDisposed)
251
+ throw new Error(`Text input realm ${realmId} is not a registered Realm2D.`);
252
+ for (const input of this._textInputs.values()) {
253
+ const target = input.focusable.listener.target;
254
+ if (
255
+ input.focusable.realmId === realmId &&
256
+ input.svgId === options.svgId &&
257
+ input.elementId === options.elementId
258
+ )
259
+ throw new Error('An input is already registered for this SVG text element.');
260
+ if (
261
+ input.focusable.realmId === realmId &&
262
+ target.type === 'svg' &&
263
+ target.svgId === options.svgId &&
264
+ target.elementId === (options.hitAreaId ?? options.elementId)
265
+ )
266
+ throw new Error('An input is already registered for this SVG hit area.');
267
+ }
268
+ const focusable = this.focusable(
269
+ realmId,
270
+ {
271
+ type: 'svg',
272
+ svgId: options.svgId,
273
+ elementId: options.hitAreaId ?? options.elementId
274
+ },
275
+ { tabIndex: options.tabIndex }
276
+ );
277
+ try {
278
+ const input = new TextInput(
279
+ realm,
280
+ { ...options },
281
+ focusable,
282
+ (id, enabled) => this.platform.window.setImeAllowed(id as WindowId, enabled),
283
+ () => this._textInputs.delete(focusable)
284
+ );
285
+ this._textInputs.set(focusable, input);
286
+ return input;
287
+ } catch (error) {
288
+ focusable.dispose();
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Registers a strongly typed event listener.
295
+ *
296
+ * @param type Event category: 'tick', 'window', 'pointer', 'keyboard', 'gamepad', 'animation', 'platform'.
297
+ * @param listener Callback receiving the strongly typed event payload.
298
+ * @returns Unsubscribe function to remove the listener.
299
+ */
300
+ on<T extends RuntimeEventType>(type: T, listener: RuntimeEventListener<T>): () => void {
301
+ let set = this._eventListeners.get(type);
302
+ if (!set) {
303
+ set = new Set();
304
+ this._eventListeners.set(type, set);
305
+ }
306
+ set.add(listener as (event: any) => void);
307
+ return () => {
308
+ set?.delete(listener as (event: any) => void);
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Clears all registered event listeners, or listeners for a specific event type.
314
+ */
315
+ clearListeners(type?: RuntimeEventType): void {
316
+ if (type) {
317
+ this._eventListeners.get(type)?.clear();
318
+ } else {
319
+ for (const set of this._eventListeners.values()) {
320
+ set.clear();
321
+ }
322
+ this._eventListeners.clear();
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Starts the transport frame loop.
328
+ */
329
+ start(options?: LoopOptions): void {
330
+ if (this._state === 'disposed' || this._state === 'failed' || this._state === 'disposing') {
331
+ throw new GalfusLoopError(`Cannot start runtime in state '${this._state}'.`);
332
+ }
333
+ if (this._state === 'running') return;
334
+ if (this._state !== 'ready' && this._state !== 'paused') {
335
+ throw new GalfusLoopError(
336
+ `Cannot start runtime before ready (current state: '${this._state}').`
337
+ );
338
+ }
339
+
340
+ const previousState = this._state;
341
+ this._state = 'running';
342
+ try {
343
+ this.#transport.startLoop((timing) => {
344
+ this.tickFrame(timing);
345
+ }, options);
346
+ } catch (error) {
347
+ this._state = previousState;
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Halts the active frame loop.
354
+ */
355
+ stop(): void {
356
+ if (this._state !== 'running' && this._state !== 'paused') return;
357
+ if (this._state === 'running') this.#transport.stopLoop();
358
+ this._state = 'ready';
359
+ }
360
+
361
+ /** Pauses frame execution; call start() to resume. */
362
+ pause(): void {
363
+ if (this._state !== 'running') return;
364
+ this.#transport.stopLoop();
365
+ this._state = 'paused';
366
+ }
367
+
368
+ /**
369
+ * Resets the entire multiverse runtime, realms, invocations, windows, audio, and caches,
370
+ * keeping the transport and engine alive and ready for a fresh scene.
371
+ */
372
+ reset(): void {
373
+ if (this._state === 'disposed') {
374
+ throw new Error('Cannot reset a disposed MultiverseRuntime.');
375
+ }
376
+
377
+ try {
378
+ this.stop();
379
+ this.clearListeners();
380
+ for (const input of this._textInputs.values()) input.dispose();
381
+ this._focus.dispose();
382
+ this._interactive.dispose();
383
+ this._elapsedSeconds = 0;
384
+ this._frameId = 0;
385
+ this._fixedAccumulator = 0;
386
+
387
+ // 1. Multiverse: Invocations, Realms, and Targets
388
+ this.multiverse.reset();
389
+
390
+ // 2. Audio: Stop and unload all sounds
391
+ this.audio.dispose();
392
+ if (this.#transport.engine) {
393
+ this.audio.bindEngine(this.#transport.engine);
394
+ }
395
+
396
+ // 3. Animation: Clear transitions and paths
397
+ this.animation.dispose();
398
+ if (this.#transport.engine) {
399
+ this.animation.bindEngine(this.#transport.engine);
400
+ }
401
+
402
+ // 4. Windows: Close all open windows
403
+ this.platform.dispose();
404
+ if (this.#transport.engine) {
405
+ this.platform.bindEngine(this.#transport.engine);
406
+ }
407
+
408
+ // 5. Global Texture Resources
409
+ this.resources.dispose();
410
+ if (this.#transport.engine) {
411
+ this.resources.bindEngine(this.#transport.engine);
412
+ }
413
+
414
+ // Re-bind engine to multiverse
415
+ if (this.#transport.engine) {
416
+ this.multiverse.bindEngine(this.#transport.engine);
417
+ }
418
+
419
+ this._state = 'ready';
420
+ } catch (err) {
421
+ this._state = 'failed';
422
+ throw err;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Disposes the runtime, multiverse coordinator, all realms, and the transport.
428
+ */
429
+ dispose(): void {
430
+ if (this._state === 'disposed') return;
431
+ try {
432
+ this.stop();
433
+ this._state = 'disposing';
434
+ this.clearListeners();
435
+ for (const input of this._textInputs.values()) input.dispose();
436
+ this._focus.dispose();
437
+ this._interactive.dispose();
438
+ if (this._animationUnsubscribe) {
439
+ this._animationUnsubscribe();
440
+ this._animationUnsubscribe = null;
441
+ }
442
+ this.animation.dispose();
443
+ this.audio.dispose();
444
+ this.resources.dispose();
445
+ this.multiverse.dispose();
446
+ this.platform.dispose();
447
+ this.#transport.dispose();
448
+ this._state = 'disposed';
449
+ } catch (err) {
450
+ this._state = 'failed';
451
+ throw err;
452
+ }
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Internal Frame Tick Pipeline
457
+ // ---------------------------------------------------------------------------
458
+ private tickFrame(timing: FrameTiming): void {
459
+ // 1. Advance frameId and elapsed simulation time exactly once
460
+ this._frameId = timing.frameId;
461
+ this._elapsedSeconds += timing.dt;
462
+
463
+ // 2. Pump platform events internally
464
+ const platformEvents = this.platform.pumpEvents();
465
+ if (platformEvents.length > 0) {
466
+ const platformListeners = this._eventListeners.get('platform');
467
+ for (const event of platformEvents) {
468
+ if (event.kind === 'pointer') {
469
+ this._interactive.handle(event);
470
+ this._focus.handlePointer(event);
471
+ }
472
+ if (event.kind === 'keyboard') {
473
+ this._focus.handleKeyboard(event);
474
+ const focused = this._focus.getFocused(event.windowId);
475
+ if (focused) this._textInputs.get(focused)?.handleKeyboard(event);
476
+ }
477
+ if (event.kind === 'window' && event.type === 'focusChanged')
478
+ this._focus.windowFocusChanged(event.windowId, event.focused);
479
+ // Auto-synchronize window swapchain size in Multiverse on resize
480
+ if (event.kind === 'window' && event.type === 'resized') {
481
+ this.platform.window.updateSize(event.windowId as any, event.width, event.height);
482
+ }
483
+
484
+ // Dispatch to kind listeners ('window', 'pointer', 'keyboard', 'gamepad')
485
+ const kindListeners = this._eventListeners.get(event.kind as RuntimeEventType);
486
+ if (kindListeners && kindListeners.size > 0) {
487
+ for (const listener of kindListeners) {
488
+ listener(event);
489
+ }
490
+ }
491
+
492
+ // Forward through platform coordinator
493
+ this.platform.dispatch(event);
494
+
495
+ // Dispatch to generic 'platform' listeners
496
+ if (platformListeners && platformListeners.size > 0) {
497
+ for (const listener of platformListeners) {
498
+ listener(event);
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ // 3. Dispatch frame tick listeners ('tick')
505
+ const tickListeners = this._eventListeners.get('tick');
506
+ if (tickListeners && tickListeners.size > 0) {
507
+ for (const listener of tickListeners) {
508
+ listener(timing);
509
+ }
510
+ }
511
+
512
+ // 4. Step fixed tick listeners ('fixedTick') with deterministic accumulator
513
+ const fixedTickListeners = this._eventListeners.get('fixedTick');
514
+ this._fixedAccumulator += timing.dt;
515
+ let subStep = 0;
516
+ while (this._fixedAccumulator >= this.fixedDt && subStep < this.maxFixedSubSteps) {
517
+ if (fixedTickListeners && fixedTickListeners.size > 0) {
518
+ const payload: FixedTickTiming = {
519
+ fixedDt: this.fixedDt,
520
+ time: this._elapsedSeconds - this._fixedAccumulator + this.fixedDt,
521
+ subStep,
522
+ frameId: timing.frameId
523
+ };
524
+ for (const listener of fixedTickListeners) {
525
+ listener(payload);
526
+ }
527
+ }
528
+ this._fixedAccumulator -= this.fixedDt;
529
+ subStep++;
530
+ }
531
+ if (this._fixedAccumulator >= this.fixedDt) {
532
+ this._fixedAccumulator = this._fixedAccumulator % this.fixedDt;
533
+ }
534
+
535
+ // 5. Step animations (this also pumps animation events and triggers registered listeners)
536
+ this.animation.step(timing.dt);
537
+
538
+ // 6. Prepare ECS context
539
+ const context: SystemContext = {
540
+ time: this._elapsedSeconds,
541
+ dt: timing.dt,
542
+ frameId: timing.frameId,
543
+ engine: this.#transport.engine
544
+ };
545
+
546
+ // 7. Step pre-render stages: input -> update -> preRender (anchors, hierarchies)
547
+ this.multiverse.stepRealmsPreRender(context);
548
+
549
+ // 8. Synchronize dirty entity state to native core
550
+ this.multiverse.syncRealms(context);
551
+
552
+ // 9. Resolve multiverse schedule and dispatch to GPU
553
+ this.multiverse.dispatch(timing.frameId, timing.dt);
554
+ this._interactive.afterDispatch();
555
+ this._focus.afterDispatch();
556
+ for (const [focusable, input] of this._textInputs) if (focusable.isDisposed) input.dispose();
557
+
558
+ // 10. Step post-render stage: postRender (metrics, cleanups) AFTER dispatch
559
+ this.multiverse.stepRealmsPostRender(context);
560
+ }
561
+
562
+ /**
563
+ * Manually steps the fixed simulation timestep (useful for deterministic tests).
564
+ */
565
+ stepFixed(dt = this.fixedDt): void {
566
+ const fixedTickListeners = this._eventListeners.get('fixedTick');
567
+ if (!fixedTickListeners || fixedTickListeners.size === 0) return;
568
+ const payload: FixedTickTiming = {
569
+ fixedDt: dt,
570
+ time: this._elapsedSeconds,
571
+ subStep: 0,
572
+ frameId: this._frameId
573
+ };
574
+ for (const listener of fixedTickListeners) {
575
+ listener(payload);
576
+ }
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Creates a new MultiverseRuntime instance bound to the provided transport.
582
+ */
583
+ export function createRuntime(
584
+ transport: GalfusTransport,
585
+ options?: RuntimeOptions
586
+ ): MultiverseRuntime {
587
+ return new MultiverseRuntime(transport, options);
588
+ }