@vgai/engine 0.5.2 → 0.5.4

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