@vgai/engine 0.2.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.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,748 @@
1
+ import type * as PIXI from 'pixi.js';
2
+ import type * as THREE from 'three';
3
+ import { PHASE_ORDER, type SystemPhaseName } from '../core/types';
4
+ import type { PhysicsRefs, PhysicsRegistry } from '../physics/physics-registry';
5
+ import { isFirstPartyMounted, type WorldInstance, type WorldKind } from '../runtime/game';
6
+ import type { GameContext } from '../runtime/types';
7
+ import type { Physics2DRefs, Physics2DRegistry } from '../world2d/physics2d-registry';
8
+ import { GameComponent, type GameComponentClass, type NodeOf } from './game-component';
9
+ import { logHmrSwapMiss } from './hmr-swap-report';
10
+
11
+ /**
12
+ * The manager's node/instance types, widened over every `WorldKind` (T7.2
13
+ * slice 2, D8 §3). `NodeOf<WorldKind>` distributes to `THREE.Object3D |
14
+ * PIXI.Container` (react contributes `never`, which drops out of the
15
+ * union) — this is what lets a single manager instance be constructed for
16
+ * ANY one kind (threejs, pixijs, or react) while still type-checking
17
+ * `attach()` calls made with a default-kind (`GameComponent`, K =
18
+ * 'threejs') instance, unmodified, from every existing call site.
19
+ */
20
+ type AnyNode = NodeOf<WorldKind>;
21
+ type AnyComponent = GameComponent<WorldKind>;
22
+
23
+ /**
24
+ * Optional identity a caller of `attach()` can supply for a component instance
25
+ * it looked up in a `ComponentRegistry`/`Component2DRegistry` (T5.4) — `key` is
26
+ * the name the class was registered under (the swap key `hotSwap` matches on),
27
+ * `props` is the raw, pre-`schema.parse` authored data for that instance (kept
28
+ * so a later `hotSwap` can re-validate it against a NEW class version's
29
+ * `static schema`). Deliberately NOT imported from `scene/component-registry.ts`
30
+ * to avoid a cycle (that module already imports `ComponentManager` from here) —
31
+ * this is a structural duck-type of the same shape.
32
+ */
33
+ export interface RegistryAttachInfo {
34
+ key: string;
35
+ props: Record<string, unknown>;
36
+ }
37
+
38
+ /**
39
+ * Find the `WorldInstance` (T7.1, `docs/GAME-ROOT-DESIGN.md`) that owns this
40
+ * manager's `ctx`, for wiring `GameComponent.world` at attach (T7.2, D8 —
41
+ * `docs/GAME-COMPONENT-GENERALIZATION.md` §2). Matches by `mounted.ctx ===
42
+ * ctx` identity (the same probe `isFirstPartyMounted` narrows for) rather
43
+ * than by id, so it works unmodified whether this manager belongs to the
44
+ * default world or a second/third registered world (`test/
45
+ * game-two-worlds.test.ts`).
46
+ *
47
+ * WORLD-WIRING STATE (T7.2 slice 2 — was a KNOWN GAP in slice 1, now closed
48
+ * two ways): `ctx.game`/`ctx.worlds` exist from the start of `mount()`, but
49
+ * the `WorldInstance` itself is constructed by `registerThreeWorld` in
50
+ * `create-runtime.ts` only AFTER `mount()` returns — i.e. AFTER
51
+ * scene-authored components have already been attached by the scene loader
52
+ * during that same `mount()` call. For those attaches, THIS function still
53
+ * resolves to `undefined` (the world genuinely doesn't exist yet — there is
54
+ * no way around that ordering; the `WorldInstance` wraps `mounted`, which
55
+ * only exists once `mount()` returns). What closes the gap is
56
+ * `adoptWorld()` below: `registerThreeWorld` calls it immediately after
57
+ * building the `WorldInstance`, and it backfills `instance.world` for every
58
+ * already-attached instance still missing one — a one-time wiring
59
+ * completion at registration, not a lazy getter and not a per-frame sync.
60
+ * Only a component attached AFTER that backfill and before the NEXT world's
61
+ * registration (there is no such window in the single-manager-per-world
62
+ * architecture) would still see a stale `undefined`; in practice every
63
+ * instance ends up with a defined, correct `world` by the time
64
+ * `createGameRuntime`/`registerThreeWorld` returns.
65
+ */
66
+ function resolveWorldInstance(ctx: GameContext): WorldInstance | undefined {
67
+ if (!ctx.game) return undefined;
68
+ for (const w of ctx.game.worlds) {
69
+ if (isFirstPartyMounted(w.mounted) && w.mounted.ctx === ctx) return w;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /** Best-effort human label for an error message — a node's `label` (PIXI.Container,
75
+ * v8) or `name` (THREE.Object3D, and PIXI's deprecated alias), falling back to a
76
+ * generic placeholder when neither is set. */
77
+ function describeNode(node: AnyNode): string {
78
+ const n = node as { label?: unknown; name?: unknown };
79
+ const label =
80
+ typeof n.label === 'string' && n.label
81
+ ? n.label
82
+ : typeof n.name === 'string' && n.name
83
+ ? n.name
84
+ : undefined;
85
+ return label ? `"${label}"` : '(unnamed entity)';
86
+ }
87
+
88
+ /**
89
+ * Manages GameComponent instances attached to entities (Object3Ds).
90
+ *
91
+ * Tracks instances per Object3D and per phase. Registers one tick function per
92
+ * phase via `ctx.systems.setComponentTick(phase, ...)` (T7.1 slice 2) — a
93
+ * dedicated slot, not a bare `add()`ed system. `SystemRunner.runPhase` always
94
+ * runs the componentTick slot after every engine-bucket system and before
95
+ * every game-bucket system in that phase, so component updates interleave
96
+ * correctly with engine systems BY STRUCTURE: an engine system `add()`ed
97
+ * after this manager is constructed still runs before its tick (see
98
+ * `test/system-runs-before-component-tick.test.ts`).
99
+ *
100
+ * Kind (T7.2 slice 2, D8 §3): a manager is constructed for exactly ONE
101
+ * `WorldKind` (`opts.kind`, default `'threejs'` — every existing call site
102
+ * omits it and gets the same manager slice 1 built). `attach()` enforces two
103
+ * rules against that kind, loudly:
104
+ * - a `'react'`-kind manager throws on EVERY attach (react entities host no
105
+ * GameComponents — render from game state via the T7.4 state bridge);
106
+ * - any other kind throws when the component's `static declaredKind` is set
107
+ * and doesn't match this manager's kind (`'any'` is exempt — a
108
+ * kind-agnostic component may attach wherever components are legal).
109
+ * A `'pixijs'`-kind manager wires `rigidBody`/`collider` from `opts.physics2d`
110
+ * (a `Physics2DRegistry`) instead of the 3D `physics: PhysicsRegistry`
111
+ * parameter — the field names match by design (`world2d/types.ts`), so this
112
+ * is a retype of the wiring, not a new shape. `physics` is optional (T7.3
113
+ * slice 2) — a `'pixijs'`-kind manager never reads it (`resolvePhysicsRefs`
114
+ * below only reaches the 3D branch for a non-pixijs manager), so a caller
115
+ * building one no longer has to construct a throwaway 3D `PhysicsRegistry`
116
+ * just to satisfy this signature.
117
+ */
118
+ export function createComponentManager(
119
+ ctx: GameContext,
120
+ physics?: PhysicsRegistry,
121
+ opts?: { kind?: WorldKind; physics2d?: Physics2DRegistry },
122
+ ) {
123
+ const kind: WorldKind = opts?.kind ?? 'threejs';
124
+ const physics2d = opts?.physics2d;
125
+
126
+ const byPhase = new Map<SystemPhaseName, AnyComponent[]>();
127
+ const byEntity = new Map<AnyNode, AnyComponent[]>();
128
+
129
+ // --- HMR registry-keyed identity (T5.4) ----------------------------------
130
+ // `hotSwap` used to match live instances by `inst.constructor.name` — fragile
131
+ // under minification, a dev renaming a class, or two distinct classes that
132
+ // happen to share a runtime name. Both trees walked by `applyComponents`
133
+ // (`scene/component-registry.ts`) and `applyComponents2D`
134
+ // (`world2d/scene2d-loader.ts`) now pass the `componentRegistry` name the
135
+ // class was looked up under — plus the raw pre-`schema.parse` authored data —
136
+ // into `attach()`'s optional third argument, recorded here keyed by instance
137
+ // identity (a WeakMap: no explicit cleanup needed, entries die with the
138
+ // instance). An instance attached WITHOUT this info (an ad-hoc, code-attached
139
+ // component — `playground/main.ts`'s `ctx.components.attach(cube, new Cls())`,
140
+ // and this file's own `component-lifecycle.test.ts`) has no registry key on
141
+ // record; `hotSwap` falls back to the legacy `constructor.name` compare for
142
+ // those ONLY, preserving pre-T5.4 behavior for the ad-hoc path exactly (see
143
+ // `hotSwap` below).
144
+ const registryKeyOf = new WeakMap<AnyComponent, string>();
145
+ const authoredPropsOf = new WeakMap<AnyComponent, Record<string, unknown>>();
146
+
147
+ // --- Frame-boundary init flush (T1.11) -----------------------------------
148
+ // `pendingInit` holds every attached-but-not-yet-initialized instance. An
149
+ // instance is pushed into its phase's `byPhase` list immediately on attach
150
+ // (so getComponents()/hotSwap() see it right away), but the per-phase tick
151
+ // below FILTERS OUT anything still in `pendingInit` — so an instance can
152
+ // never receive update() before its init() has resolved, no matter how many
153
+ // frames init() takes (a real async load included).
154
+ //
155
+ // `initPromises` tracks in-flight init() calls (keyed by instance) so:
156
+ // (a) init() is called exactly once per instance, whether it was started
157
+ // by the automatic per-frame flush below or by an explicit
158
+ // `initAll()` call (they share the same `startInit` helper), and
159
+ // (b) `initAll()` can `await` an init() the automatic flush already
160
+ // kicked off, instead of double-invoking it.
161
+ //
162
+ // Semantics: init() is awaited (via a chained promise) before the instance
163
+ // is removed from `pendingInit`; only then is it eligible for update(). A
164
+ // throwing/rejecting init() is isolated the same way update()/dispose() are
165
+ // (T1.8) — logged loudly, never left to crash the frame loop or wedge the
166
+ // instance out of ticking forever (it's still removed from `pendingInit`
167
+ // after the error, matching "degrade loudly, don't hang").
168
+ const pendingInit = new Set<AnyComponent>();
169
+ const initPromises = new Map<AnyComponent, Promise<void>>();
170
+
171
+ // --- Pending-init re-arm on hotSwap (§7.1-6, probe8) ---------------------
172
+ // `swapOneIfMatched` may land while an instance's init() is still in flight
173
+ // (`initPromises.has(inst)` — the OLD class's init hasn't settled yet). The
174
+ // live instance's prototype is swapped to the NEW class SYNCHRONOUSLY at
175
+ // swap time, but the in-flight promise chain still belongs to the OLD
176
+ // class's init call — its trailing `.then()` below is what clears
177
+ // `pendingInit`/`initPromises` once it settles, and nothing else re-triggers
178
+ // init() for the new class. `pendingReinit` records "a swap landed while
179
+ // this instance's init was in flight" so that SAME trailing `.then()` can,
180
+ // instead of clearing pendingInit, re-kick `startInit` for the (by-then
181
+ // already-swapped) instance — whose `.init` now resolves to the NEW class's
182
+ // method. `pendingInit` membership is deliberately left untouched across the
183
+ // whole handoff: the instance must stay ineligible for update() from the
184
+ // moment the OLD init started until the NEW init resolves, exactly as it
185
+ // was mid-old-init (T1.11's "never before init() resolves" invariant, now
186
+ // spanning a swap instead of being violated by one). A Set, not a Map to a
187
+ // specific class: by the time this fires, the CURRENT prototype (whichever
188
+ // class most recently swapped in) is what `inst.init` resolves to, so
189
+ // multiple swaps mid-flight collapse to exactly one re-kicked init for the
190
+ // LAST class — never a double-invoke, never a lost update.
191
+ const pendingReinit = new Set<AnyComponent>();
192
+
193
+ function startInit(inst: AnyComponent): Promise<void> {
194
+ const existing = initPromises.get(inst);
195
+ if (existing) return existing;
196
+ const p = Promise.resolve()
197
+ .then(() => inst.init?.(ctx))
198
+ .catch((err) => {
199
+ console.error(`[component-manager] ${inst.constructor.name}.init() threw:`, err);
200
+ })
201
+ .then(() => {
202
+ if (pendingReinit.has(inst)) {
203
+ // A hotSwap landed mid-init (probe8): don't clear pendingInit — the
204
+ // instance must stay un-ticked until the NEW class's init (kicked
205
+ // off below) resolves. Clear initPromises first so the re-kicked
206
+ // startInit doesn't see itself as "already in flight" and return
207
+ // this (already-settling) promise back out.
208
+ pendingReinit.delete(inst);
209
+ initPromises.delete(inst);
210
+ startInit(inst);
211
+ return;
212
+ }
213
+ pendingInit.delete(inst);
214
+ initPromises.delete(inst);
215
+ });
216
+ initPromises.set(inst, p);
217
+ return p;
218
+ }
219
+
220
+ /**
221
+ * Kick off init() for anything attached since the last flush that hasn't
222
+ * been started yet. Fire-and-forget (not awaited) — called at the start of
223
+ * every phase tick below, so a component attached mid-frame (e.g. from a
224
+ * trigger callback) gets its init() started as soon as possible without
225
+ * game code ever calling `initAll()` manually. Idempotent / cheap no-op
226
+ * when `pendingInit` is empty or everything in it is already in flight.
227
+ */
228
+ function flushPendingInitAuto() {
229
+ for (const inst of pendingInit) startInit(inst);
230
+ }
231
+
232
+ // --- In-tick structural-change deferral state ---------------------------
233
+ // A component's update() may call attach()/detach() (a Unity-MonoBehaviour
234
+ // style spawn/despawn-from-update pattern). Mutating `byPhase` mid-iteration
235
+ // would (a) skip a sibling when a splice shifts indices, and (b) tick a fresh
236
+ // instance before its init() ran. So while a phase loop is running we DEFER
237
+ // structural changes and apply them only after the loop completes.
238
+ let isTicking = false;
239
+ // Entities whose detach was requested during the current tick — applied
240
+ // (dispose + removal) after the phase loop completes.
241
+ const pendingDetach: AnyNode[] = [];
242
+ // Components whose attach was requested during the current tick — applied
243
+ // after the phase loop completes, so they start ticking next frame (never
244
+ // before their init()).
245
+ const pendingAttach: Array<{
246
+ node: AnyNode;
247
+ instance: AnyComponent;
248
+ registry?: RegistryAttachInfo;
249
+ }> = [];
250
+ // Live set of instances detached this tick. The running phase loop iterates a
251
+ // snapshot, so this guard ensures an instance detached earlier in the frame is
252
+ // not update()'d again (use-after-dispose protection).
253
+ const detachedThisTick = new Set<AnyComponent>();
254
+
255
+ for (const phase of PHASE_ORDER) {
256
+ byPhase.set(phase, []);
257
+ }
258
+
259
+ function flushPending() {
260
+ // Detaches win within a flush: an attach and a detach of the same entity
261
+ // issued in one tick resolves to detached, regardless of issue order
262
+ // (NOT "resolves in the order the caller issued the two kinds of op" —
263
+ // that was never actually true; see probe7 "attach-detach" finding).
264
+ // Detaches are applied first (as before), and every entity they touch is
265
+ // collected into `detachedEntities` so a same-tick pending attach whose
266
+ // target is in that set can be skipped below rather than landing a
267
+ // component on an entity the caller already despawned this tick.
268
+ let detachedEntities: Set<AnyNode> | null = null;
269
+ if (pendingDetach.length > 0) {
270
+ const batch = pendingDetach.splice(0);
271
+ detachedEntities = new Set(batch);
272
+ for (const node of batch) performDetach(node);
273
+ }
274
+ if (pendingAttach.length > 0) {
275
+ const batch = pendingAttach.splice(0);
276
+ for (const { node, instance, registry } of batch) {
277
+ if (detachedEntities?.has(node)) {
278
+ // The target entity was detached in this same flush: the attach
279
+ // never actually happened (it was never pushed into byPhase/
280
+ // byEntity), so there is nothing to detach/dispose here — just
281
+ // purge it from init tracking so it never gets init()'d.
282
+ pendingInit.delete(instance);
283
+ initPromises.delete(instance);
284
+ pendingReinit.delete(instance);
285
+ continue;
286
+ }
287
+ performAttach(node, instance, registry);
288
+ }
289
+ }
290
+ detachedThisTick.clear();
291
+ }
292
+
293
+ // Register the ONE component-tick function for each phase, via the
294
+ // dedicated slot (T7.1 slice 2) — NOT ctx.systems.add(). This is what
295
+ // makes "engine systems before component ticks" hold by structure: the
296
+ // slot always runs after the phase's entire engine bucket, regardless of
297
+ // when this manager was constructed relative to other systems.add() calls.
298
+ for (const phase of PHASE_ORDER) {
299
+ ctx.systems.setComponentTick(phase, (dt: number) => {
300
+ // Frame-boundary init flush (T1.11): every phase tick starts by kicking
301
+ // off init() for anything pending — no manual `initAll()` call required
302
+ // from game code. See `flushPendingInitAuto` above.
303
+ flushPendingInitAuto();
304
+
305
+ isTicking = true;
306
+ try {
307
+ // Snapshot the phase list: an in-loop attach must not be ticked this frame,
308
+ // and an in-loop detach (which only marks `detachedThisTick`) must not shift
309
+ // the indices we are iterating and skip a sibling.
310
+ const list = byPhase.get(phase)!.slice();
311
+ for (let i = 0; i < list.length; i++) {
312
+ const inst = list[i]!;
313
+ // A component detached earlier this frame must NOT be update()'d again.
314
+ if (detachedThisTick.has(inst)) continue;
315
+ // A component whose init() hasn't resolved yet must NOT be
316
+ // update()'d — this is the actual guarantee behind T1.11: never
317
+ // before init() resolves, regardless of how it got attached.
318
+ if (pendingInit.has(inst)) continue;
319
+ // Isolate per component: a throwing update() must not stop siblings
320
+ // in the same phase from ticking, and must not leave the manager
321
+ // wedged (isTicking stuck true, pending attach/detach never
322
+ // flushed). Failures are loud — logged with the component name —
323
+ // never swallowed silently.
324
+ try {
325
+ inst.update(dt, ctx);
326
+ } catch (err) {
327
+ console.error(`[component-manager] ${inst.constructor.name}.update() threw:`, err);
328
+ }
329
+ }
330
+ } finally {
331
+ // Guaranteed even if something above throws unexpectedly (e.g. a
332
+ // future edit removes the per-component try/catch): isTicking must
333
+ // never get stuck true, and structural changes queued during this
334
+ // tick must still flush before the next tick runs.
335
+ isTicking = false;
336
+ flushPending();
337
+ }
338
+ });
339
+ }
340
+
341
+ function migratePhase(inst: AnyComponent, from: SystemPhaseName, to: SystemPhaseName) {
342
+ if (from === to) return;
343
+ const oldList = byPhase.get(from)!;
344
+ const idx = oldList.indexOf(inst);
345
+ if (idx !== -1) oldList.splice(idx, 1);
346
+ byPhase.get(to)!.push(inst);
347
+ }
348
+
349
+ /**
350
+ * `hotSwap`'s per-instance identity check + swap (T5.4) — split out of
351
+ * `hotSwap` itself purely to keep that function's cognitive complexity
352
+ * down; matching semantics are documented on `hotSwap` above. Returns
353
+ * whether `inst` matched `name` (and was therefore swapped).
354
+ */
355
+ function swapOneIfMatched(
356
+ inst: AnyComponent,
357
+ name: string,
358
+ NewClass: GameComponentClass,
359
+ ): boolean {
360
+ const trackedKey = registryKeyOf.get(inst);
361
+ const isMatch = trackedKey !== undefined ? trackedKey === name : inst.constructor.name === name;
362
+ if (!isMatch) return false;
363
+ const oldPhase = (inst.constructor as typeof GameComponent).phase ?? 'gameLogic';
364
+ const newPhase = (NewClass as unknown as typeof GameComponent).phase ?? 'gameLogic';
365
+ Object.setPrototypeOf(inst, NewClass.prototype);
366
+ migratePhase(inst, oldPhase, newPhase);
367
+ reparseSchemaOnSwap(inst, NewClass, name);
368
+ // §7.1-6 / probe8: if this instance's init() is still in flight (attached,
369
+ // startInit already called, promise not yet settled), record that a swap
370
+ // landed mid-init so `startInit`'s trailing `.then()` re-kicks a fresh
371
+ // init() for the NEW class once the OLD one settles, instead of silently
372
+ // clearing `pendingInit` and leaving the new init never invoked. An
373
+ // instance that is merely QUEUED (attached, `startInit` not yet called —
374
+ // `pendingInit.has(inst)` but no `initPromises` entry) needs no help here:
375
+ // its eventual first `startInit` call naturally reads the (already
376
+ // swapped) NEW prototype's `init`. An instance whose init already fully
377
+ // resolved (no `initPromises` entry, `pendingInit` doesn't have it either)
378
+ // is intentionally left alone — a swap is a live-patch, not a remount.
379
+ if (initPromises.has(inst)) {
380
+ pendingReinit.add(inst);
381
+ }
382
+ return true;
383
+ }
384
+
385
+ /**
386
+ * Schema re-parse on swap (T5.4, docs/BACKBONE-TASKS.md's T5.4 detail note).
387
+ * `inst` just had its prototype swapped to `NewClass`; if `NewClass` declares
388
+ * a `static schema` AND `inst` was attached with recorded authored props
389
+ * (registry-keyed instances only — ad-hoc instances have none and are
390
+ * skipped, nothing to validate), re-validate those props against the NEW
391
+ * schema:
392
+ * - success: `next = schema.parse(authoredData)`. Merge ONLY fields
393
+ * `next` has that `inst` does NOT already own — i.e. fields the NEW
394
+ * schema added (which get their schema default/authored value) — the
395
+ * merge never touches a field already present on `inst`, so mutated
396
+ * runtime/gameplay state always wins over a re-derived authored value.
397
+ * Authored-VALUE edits (the user changing a field in the inspector)
398
+ * flow through the normal scene-edit path, never through this merge.
399
+ * - failure (old authored data no longer valid against the new schema,
400
+ * e.g. a field's type changed or a new field has no default): loud
401
+ * swap-miss warning, `inst`'s fields are left exactly as they were
402
+ * (last-good props) — never partially or invalidly overwritten.
403
+ */
404
+ function reparseSchemaOnSwap(
405
+ inst: AnyComponent,
406
+ NewClass: GameComponentClass,
407
+ key: string,
408
+ ): void {
409
+ const schema = NewClass.schema;
410
+ if (!schema) return;
411
+ const authored = authoredPropsOf.get(inst);
412
+ if (authored === undefined) return;
413
+ const result = schema.safeParse(authored);
414
+ if (!result.success) {
415
+ logHmrSwapMiss({ key, reason: 'schema-reparse-failed', error: result.error.message });
416
+ return;
417
+ }
418
+ const next = result.data as Record<string, unknown>;
419
+ const target = inst as unknown as Record<string, unknown>;
420
+ for (const [field, value] of Object.entries(next)) {
421
+ if (!Object.hasOwn(target, field)) {
422
+ target[field] = value;
423
+ }
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Enforce the attach rules (T7.2 slice 2, D8 §3) BEFORE any structural
429
+ * change happens — called synchronously from the public `attach()`, ahead
430
+ * of the isTicking defer, so the throw always surfaces to the caller of
431
+ * `attach()` and never fires later out of a deferred `flushPending()` (mid
432
+ * -tick attaches are validated up front, at the moment they're requested).
433
+ */
434
+ function validateAttach(node: AnyNode, instance: AnyComponent): void {
435
+ const ComponentClass = instance.constructor as typeof GameComponent;
436
+ const declaredKind = ComponentClass.declaredKind ?? 'threejs';
437
+ const label = describeNode(node);
438
+
439
+ if (kind === 'react') {
440
+ throw new Error(
441
+ `${ComponentClass.name}: cannot attach to entity ${label} — this manager's world is a ` +
442
+ "'react' world, and react entities host no GameComponents; render from game state " +
443
+ 'via the T7.4 state bridge instead.',
444
+ );
445
+ }
446
+
447
+ if (declaredKind !== 'any' && declaredKind !== kind) {
448
+ throw new Error(
449
+ `${ComponentClass.name}: declared kind '${declaredKind}' does not match entity ` +
450
+ `${label}'s world kind '${kind}' — set \`static declaredKind\` on ${ComponentClass.name} ` +
451
+ "if it is meant to attach in a non-default-kind world (or declare 'any' for a " +
452
+ 'kind-agnostic component).',
453
+ );
454
+ }
455
+ }
456
+
457
+ /** Resolve rigidBody/collider refs for `node` from this manager's kind-appropriate
458
+ * registry: `Physics2DRegistry` for a 'pixijs' manager, `PhysicsRegistry` otherwise
459
+ * (`physics` is only optional for a 'pixijs' manager, which never reaches this
460
+ * branch — every real threejs call site still passes it). */
461
+ function resolvePhysicsRefs(node: AnyNode): PhysicsRefs | Physics2DRefs | undefined {
462
+ if (kind === 'pixijs') {
463
+ return physics2d?.get(node as PIXI.Container);
464
+ }
465
+ return physics?.get(node as THREE.Object3D);
466
+ }
467
+
468
+ function performAttach(node: AnyNode, instance: AnyComponent, registry?: RegistryAttachInfo) {
469
+ instance.node = node;
470
+ instance.world = resolveWorldInstance(ctx) as WorldInstance;
471
+ const refs = resolvePhysicsRefs(node);
472
+ instance.rigidBody = refs?.body ?? null;
473
+ instance.collider = refs?.collider ?? null;
474
+
475
+ if (registry) {
476
+ registryKeyOf.set(instance, registry.key);
477
+ authoredPropsOf.set(instance, registry.props);
478
+ }
479
+
480
+ const phase = (instance.constructor as typeof GameComponent).phase ?? 'gameLogic';
481
+ byPhase.get(phase)!.push(instance);
482
+
483
+ let entityList = byEntity.get(node);
484
+ if (!entityList) {
485
+ entityList = [];
486
+ byEntity.set(node, entityList);
487
+ }
488
+ entityList.push(instance);
489
+
490
+ pendingInit.add(instance);
491
+ }
492
+
493
+ function performDetach(node: AnyNode) {
494
+ const instances = byEntity.get(node);
495
+ if (!instances) return;
496
+
497
+ for (const inst of instances) {
498
+ // Isolate dispose() the same way as update(): one component's dispose
499
+ // throwing must not stop the rest of this entity's components (or the
500
+ // rest of a pending-detach batch) from being cleaned up and removed.
501
+ try {
502
+ inst.dispose?.(ctx);
503
+ } catch (err) {
504
+ console.error(`[component-manager] ${inst.constructor.name}.dispose() threw:`, err);
505
+ }
506
+
507
+ const phase = (inst.constructor as typeof GameComponent).phase ?? 'gameLogic';
508
+ const phaseList = byPhase.get(phase)!;
509
+ const idx = phaseList.indexOf(inst);
510
+ if (idx !== -1) phaseList.splice(idx, 1);
511
+
512
+ // Also drop it from the pending-init tracking so a not-yet-flushed
513
+ // init() never (re)starts on an already-disposed component (keeps
514
+ // detach symmetric with clear()). The in-flight promise, if any, is
515
+ // simply abandoned — its `.then()` continuation is now a no-op since
516
+ // both maps no longer reference this instance. `pendingReinit` too —
517
+ // a swap recorded mid-init on an instance now being detached must not
518
+ // re-kick an init() for a component that no longer exists.
519
+ pendingInit.delete(inst);
520
+ initPromises.delete(inst);
521
+ pendingReinit.delete(inst);
522
+ }
523
+
524
+ byEntity.delete(node);
525
+ }
526
+
527
+ return {
528
+ /** Attach a component instance to an entity node (Object3D in a threejs
529
+ * world, PIXI.Container in a pixijs world). Validates the attach rules
530
+ * (§3: react-world throw, kind-mismatch throw) synchronously before any
531
+ * structural change, whether or not a phase tick is currently running.
532
+ * `registry` (T5.4, optional) records the `componentRegistry` name +
533
+ * pre-`schema.parse` authored data a scene-authored attach looked the
534
+ * class up under — `hotSwap` keys on it instead of the live
535
+ * `constructor.name`. Omit it for an ad-hoc, code-attached instance (no
536
+ * registry entry) — `hotSwap` falls back to matching those by class name,
537
+ * unchanged from pre-T5.4 behavior. */
538
+ attach(node: AnyNode, instance: AnyComponent, registry?: RegistryAttachInfo) {
539
+ validateAttach(node, instance);
540
+ // Called from inside a phase loop (e.g. a spawn-from-update): defer so the
541
+ // new instance starts ticking next frame, after a subsequent initAll().
542
+ if (isTicking) {
543
+ pendingAttach.push({ node, instance, ...(registry ? { registry } : {}) });
544
+ return;
545
+ }
546
+ performAttach(node, instance, registry);
547
+ },
548
+
549
+ /**
550
+ * Call init() on all components that haven't been initialized yet, and
551
+ * wait for them to resolve. Components attached since the last call are
552
+ * covered. Safe to call even when the automatic per-frame flush has
553
+ * already started some of them — `startInit` is idempotent per instance,
554
+ * so this awaits the same in-flight promise rather than double-invoking
555
+ * init(). Manual calls remain useful when subsequent code depends on an
556
+ * init()'s side effects having completed synchronously (the automatic
557
+ * flush only guarantees "ticking starts once init resolves", not
558
+ * "resolved by the time this function returns").
559
+ */
560
+ async initAll() {
561
+ const batch = [...pendingInit];
562
+ await Promise.all(batch.map(startInit));
563
+ },
564
+
565
+ /** Remove all components from an entity node, calling dispose. */
566
+ detach(node: AnyNode) {
567
+ // Called from inside a phase loop (e.g. a despawn-from-update): mark the
568
+ // entity's instances so the running loop skips them, and defer the actual
569
+ // dispose + removal until the loop completes.
570
+ if (isTicking) {
571
+ const instances = byEntity.get(node);
572
+ if (!instances) return;
573
+ for (const inst of instances) detachedThisTick.add(inst);
574
+ pendingDetach.push(node);
575
+ return;
576
+ }
577
+ performDetach(node);
578
+ },
579
+
580
+ /**
581
+ * Backfill `instance.world` for every already-attached instance whose
582
+ * world is still undefined (T7.2 slice 2 — closes the KNOWN GAP
583
+ * documented on `resolveWorldInstance` above). A ONE-TIME wiring
584
+ * completion, called exactly once by `registerThreeWorld`
585
+ * (`create-runtime.ts`) right after this manager's `WorldInstance` is
586
+ * constructed — NOT a lazy getter, NOT a per-frame sync: a plain
587
+ * backfill pass over the existing `byEntity` bookkeeping, with no
588
+ * ongoing cost once it returns. Idempotent and safe to call more than
589
+ * once (only ever touches instances still missing a world).
590
+ */
591
+ adoptWorld(world: WorldInstance): void {
592
+ for (const [, instances] of byEntity) {
593
+ for (const inst of instances) {
594
+ if (inst.world === undefined) inst.world = world;
595
+ }
596
+ }
597
+ },
598
+
599
+ /** Get all component instances for an entity node. */
600
+ getComponents(node: AnyNode): AnyComponent[] {
601
+ return byEntity.get(node) ?? [];
602
+ },
603
+
604
+ /**
605
+ * Query all live attached instances of a given component class within
606
+ * THIS manager — the per-world half of the §5 cross-world query
607
+ * (`docs/GAME-ROOT-DESIGN.md` §5; `Game.queryByComponent` in
608
+ * `runtime/game.ts` is the game-level aggregation over every world's
609
+ * manager).
610
+ *
611
+ * Returns instances (not nodes), `instanceof cls`, in stable attach
612
+ * order — walked from the existing `byEntity` bookkeeping (the same
613
+ * structure `hotSwap`/`clear` iterate), not a new index, so correctness
614
+ * tracks the manager's real attach/detach state rather than a
615
+ * maintained-in-parallel cache. Order note: this is attach order
616
+ * PER ENTITY (the order components were pushed for that Object3D),
617
+ * concatenated in `byEntity`'s insertion order (first-attach order of
618
+ * the entities themselves) — for the common case of each entity
619
+ * attached once this coincides with global chronological attach order;
620
+ * it would not for an entity re-attached to after other entities were
621
+ * attached in between, which this manager does not otherwise track a
622
+ * global order for.
623
+ *
624
+ * Excludes instances pending-detach this tick (`detachedThisTick`) —
625
+ * mirrors the same use-after-dispose guard the phase tick applies, so a
626
+ * query run mid-tick (e.g. from inside a component's `update`) never
627
+ * returns something already torn down this frame.
628
+ */
629
+ queryByComponent<T extends AnyComponent>(cls: new () => T): T[] {
630
+ const result: T[] = [];
631
+ for (const [, instances] of byEntity) {
632
+ for (const inst of instances) {
633
+ if (detachedThisTick.has(inst)) continue;
634
+ if (inst instanceof cls) result.push(inst);
635
+ }
636
+ }
637
+ return result;
638
+ },
639
+
640
+ /**
641
+ * Hot-swap prototypes for every live instance registered under `name`
642
+ * (T5.4). State on `this` survives; method bodies update to the new
643
+ * class. `name` is matched against each instance's RECORDED registry key
644
+ * (`registryKeyOf`, set by `attach()`'s optional `registry` argument) —
645
+ * not the live `constructor.name`, which is fragile under minification, a
646
+ * class rename, or two distinct classes sharing one runtime name (a
647
+ * registry key is stable identity chosen at scene-author time,
648
+ * independent of what the class calls itself). An instance attached
649
+ * WITHOUT a registry key (ad-hoc/code-attached — no `ComponentRegistry`
650
+ * entry) has no such recorded identity, so it falls back to the legacy
651
+ * `constructor.name` compare — this is the ONLY place that fallback
652
+ * applies; a registry-keyed instance never matches by name, so two
653
+ * registry-keyed classes that happen to share a `constructor.name` (e.g.
654
+ * both minified to the same short name) swap independently.
655
+ *
656
+ * Zero matches is a loud swap-miss (`hmr-swap-report.ts`), never a silent
657
+ * no-op — this is "renamed class → loud miss" (docs/BACKBONE-TASKS.md's
658
+ * T5.4 detail note): if a dev renames a component's export, the hot
659
+ * update arrives keyed under the NEW name, no live instance is recorded
660
+ * under it yet (the registry/scene JSON hasn't been re-authored), so
661
+ * nothing matches and the warning fires — the OLD class stays in effect
662
+ * rather than the rename silently doing nothing.
663
+ *
664
+ * GameComponent-subclass guard (§7.1-5, probe7): every editor HMR call
665
+ * site passes any `typeof value === 'function'` module export — that used
666
+ * to include a plain helper function sharing a live component's registry
667
+ * key/name, which this method would happily `Object.setPrototypeOf` onto
668
+ * a live instance, leaving it with no `update()` (a caught-and-logged
669
+ * `TypeError` every frame, forever). `NewClass` is now required to have
670
+ * `GameComponent` somewhere in its prototype chain — anything else is
671
+ * refused OUTRIGHT, loudly, with NO instance touched. This is a second,
672
+ * independent line of defense behind the call-site filter
673
+ * (`play-mode.ts`'s HMR handlers now filter to `value.prototype
674
+ * instanceof GameComponent` before ever calling this) — belt AND
675
+ * suspenders, so a future caller that forgets to filter still can't
676
+ * corrupt a live instance.
677
+ *
678
+ * `opts.warnOnMiss` (default `true`, §7.1-4/probe2b): the multi-world HMR
679
+ * fan-out (`play-mode.ts`'s `hotSwapAcrossWorlds`) calls this once per
680
+ * first-party world and must not let a per-world zero-match here look
681
+ * like "renamed class" when the name simply lives in a DIFFERENT world's
682
+ * manager — the fan-out helper suppresses this method's own warning
683
+ * (`{ warnOnMiss: false }`) and emits the ONE real warning itself only
684
+ * when the total across every world is zero. Every other existing caller
685
+ * (`applyBrowserComponentEdit`, the dev HMR demos, every hotSwap unit
686
+ * test) omits `opts` and keeps today's per-call warn-on-miss behavior
687
+ * unchanged. Returns the number of live instances swapped, so a caller
688
+ * that needs to aggregate across several managers (the fan-out helper)
689
+ * can sum it without re-deriving it from a side-channel.
690
+ */
691
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the GameComponent-subclass guard (§7.1-5) and the warnOnMiss option are both load-bearing early-exit branches ahead of the existing match loop — splitting them into separate helpers would obscure that hotSwap has exactly one guarded entry point
692
+ hotSwap(name: string, NewClass: GameComponentClass, opts?: { warnOnMiss?: boolean }): number {
693
+ if (typeof NewClass !== 'function' || !(NewClass.prototype instanceof GameComponent)) {
694
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors logHmrSwapMiss's own deliberate direct console.warn
695
+ console.warn(
696
+ `[component-manager] hotSwap("${name}"): refused — ` +
697
+ `"${(NewClass as { name?: string } | undefined)?.name ?? String(NewClass)}" does not ` +
698
+ 'extend GameComponent (no GameComponent in its prototype chain), so no live instance ' +
699
+ 'was touched. This guards against a plain function/class export sharing a registry ' +
700
+ 'key/name with a real component (§7.1-5, T5.4).',
701
+ );
702
+ return 0;
703
+ }
704
+ let matched = 0;
705
+ for (const [, instances] of byEntity) {
706
+ for (const inst of instances) {
707
+ if (swapOneIfMatched(inst, name, NewClass)) matched++;
708
+ }
709
+ }
710
+ const warnOnMiss = opts?.warnOnMiss ?? true;
711
+ if (matched === 0 && warnOnMiss) {
712
+ logHmrSwapMiss({ key: name, reason: 'no-live-instance' });
713
+ }
714
+ return matched;
715
+ },
716
+
717
+ /** Dispose all components and clear all maps. */
718
+ clear() {
719
+ // Isolate dispose() exactly like performDetach: one throwing dispose
720
+ // must not abort the rest of teardown (the remaining instances still
721
+ // get dispose()'d, and the maps/state below still get reset). A
722
+ // teardown path that aborts partway through is unrecoverable (a retry
723
+ // is a no-op since byEntity etc. are never cleared) — see the probe5
724
+ // "dispose-abort" finding this closes.
725
+ for (const [, instances] of byEntity) {
726
+ for (const inst of instances) {
727
+ try {
728
+ inst.dispose?.(ctx);
729
+ } catch (err) {
730
+ console.error(`[component-manager] ${inst.constructor.name}.dispose() threw:`, err);
731
+ }
732
+ }
733
+ }
734
+ byEntity.clear();
735
+ for (const phase of PHASE_ORDER) {
736
+ byPhase.set(phase, []);
737
+ }
738
+ pendingInit.clear();
739
+ initPromises.clear();
740
+ pendingReinit.clear();
741
+ pendingAttach.length = 0;
742
+ pendingDetach.length = 0;
743
+ detachedThisTick.clear();
744
+ },
745
+ };
746
+ }
747
+
748
+ export type ComponentManager = ReturnType<typeof createComponentManager>;