@vgai/engine 0.2.0 → 0.4.0-canary.20260715.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 (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Character primitive — spring-bone chains (hair, ponytails, cloth strips)
3
+ * on top of `@pixiv/three-vrm-springbone`'s raw API.
4
+ *
5
+ * This is a THIN COMPOSITION HELPER, not a wrapper (CLAUDE.md "use libraries
6
+ * directly"): every export here is a small function that assembles the
7
+ * library's own objects (`THREE.Bone`, `VRMSpringBoneJoint`,
8
+ * `VRMSpringBoneCollider`, `VRMSpringBoneManager`) — nothing from the
9
+ * library is re-exported, and callers still import `@pixiv/three-vrm-
10
+ * springbone` types directly for anything beyond what's here. Kept
11
+ * deliberately bare: composing a specific character (a caped viking, a
12
+ * ponytailed ranger) is ordinary calling code on top of these pieces, not a
13
+ * costume/preset system in the engine.
14
+ *
15
+ * Sharp edge this module exists partly to contain (see the mission brief /
16
+ * `cloth-hair-bench/spring-rig.ts`'s header for the full discovery):
17
+ * `VRMSpringBoneCollider` overrides `updateWorldMatrix` (the ON-DEMAND,
18
+ * manually-invoked method), NOT `updateMatrixWorld` (the one the renderer
19
+ * calls automatically) — `SpringColliderRig.reposition()` below calls
20
+ * `collider.updateWorldMatrix(true, false)` itself every tick so callers
21
+ * can't forget it.
22
+ */
23
+
24
+ import {
25
+ VRMSpringBoneCollider,
26
+ type VRMSpringBoneColliderGroup,
27
+ VRMSpringBoneColliderShapeCapsule,
28
+ VRMSpringBoneColliderShapeSphere,
29
+ VRMSpringBoneJoint,
30
+ VRMSpringBoneManager,
31
+ } from '@pixiv/three-vrm-springbone';
32
+ import * as THREE from 'three';
33
+
34
+ const DEFAULT_GRAVITY_DIR = new THREE.Vector3(0, -1, 0);
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Bone chains
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface BoneChain {
41
+ /** Swinging bones, root (nearest the anchor) first. */
42
+ bones: THREE.Bone[];
43
+ /** A non-simulated leaf `Object3D` past the last bone — the spring joint
44
+ * "tail" target for the last bone in the chain. */
45
+ tip: THREE.Object3D;
46
+ }
47
+
48
+ /** World-space offset from `parent`'s CURRENT world position -> the local
49
+ * position a new direct child of `parent` must have to sit there. Requires
50
+ * `parent.matrixWorld` to be current. Sidesteps ever reasoning about a
51
+ * bone's local rest-axis convention (arbitrary per-bone on an imported
52
+ * rig) — the caller only ever thinks in world-space directions. */
53
+ export function worldOffsetToLocalPos(
54
+ parent: THREE.Object3D,
55
+ worldDir: THREE.Vector3,
56
+ length: number,
57
+ ): THREE.Vector3 {
58
+ const parentWorld = new THREE.Vector3();
59
+ parent.getWorldPosition(parentWorld);
60
+ const targetWorld = parentWorld.clone().addScaledVector(worldDir, length);
61
+ return parent.worldToLocal(targetWorld.clone());
62
+ }
63
+
64
+ /** Build a straight chain of `count` new `THREE.Bone`s under `parent`, each
65
+ * offset `segmentLength` further along the WORLD-space unit vector
66
+ * `worldDir` — the natural rest shape for hair/cloth hanging under gravity.
67
+ * Ends with a `tip` `Object3D` (one more segment past the last bone) so
68
+ * every bone has an explicit spring-joint child target. Can be anchored to
69
+ * either an existing rig bone (hair off a head) or any plain `Object3D`. */
70
+ export function buildBoneChain(
71
+ parent: THREE.Object3D,
72
+ namePrefix: string,
73
+ count: number,
74
+ segmentLength: number,
75
+ worldDir: THREE.Vector3,
76
+ ): BoneChain {
77
+ const bones: THREE.Bone[] = [];
78
+ let cur: THREE.Object3D = parent;
79
+ cur.updateWorldMatrix(true, false);
80
+ for (let i = 0; i < count; i++) {
81
+ const localPos = worldOffsetToLocalPos(cur, worldDir, segmentLength);
82
+ const bone = new THREE.Bone();
83
+ bone.name = `${namePrefix}${i}`;
84
+ bone.position.copy(localPos);
85
+ cur.add(bone);
86
+ bone.updateWorldMatrix(true, false);
87
+ bones.push(bone);
88
+ cur = bone;
89
+ }
90
+ const tip = new THREE.Object3D();
91
+ tip.name = `${namePrefix}Tip`;
92
+ tip.position.copy(worldOffsetToLocalPos(cur, worldDir, segmentLength));
93
+ cur.add(tip);
94
+ return { bones, tip };
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Spring joints
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export interface ChainJointSettings {
102
+ stiffness?: number;
103
+ gravityPower?: number;
104
+ gravityDir?: THREE.Vector3;
105
+ dragForce?: number;
106
+ hitRadius?: number;
107
+ }
108
+
109
+ export interface ChainJointHandle {
110
+ joints: VRMSpringBoneJoint[];
111
+ /** Deregisters every joint this chain added from `manager`. Does NOT
112
+ * detach the bones from the scene graph (the caller owns that). */
113
+ dispose(): void;
114
+ }
115
+
116
+ /** Register one `VRMSpringBoneJoint` per bone in `chain` on `manager` (each
117
+ * targeting the next bone, the last targeting `chain.tip`). Does not call
118
+ * `manager.setInitState()` — batch that once after all chains for a
119
+ * manager are registered and the scene's world matrices are current (sharp
120
+ * edge: `VRMSpringBoneJoint` sets `bone.matrixAutoUpdate = false`, so
121
+ * `setInitState()` must run AFTER `scene.updateMatrixWorld(true)`). */
122
+ export function registerChainJoints(
123
+ manager: VRMSpringBoneManager,
124
+ chain: BoneChain,
125
+ colliderGroups: VRMSpringBoneColliderGroup[],
126
+ settings: ChainJointSettings = {},
127
+ ): ChainJointHandle {
128
+ const joints: VRMSpringBoneJoint[] = [];
129
+ for (let i = 0; i < chain.bones.length; i++) {
130
+ const bone = chain.bones[i]!;
131
+ const child = chain.bones[i + 1] ?? chain.tip;
132
+ const joint = new VRMSpringBoneJoint(
133
+ bone,
134
+ child,
135
+ {
136
+ stiffness: settings.stiffness ?? 1.2,
137
+ gravityPower: settings.gravityPower ?? 0.9,
138
+ gravityDir: settings.gravityDir ?? DEFAULT_GRAVITY_DIR,
139
+ dragForce: settings.dragForce ?? 0.35,
140
+ hitRadius: settings.hitRadius ?? 0.02,
141
+ },
142
+ colliderGroups,
143
+ );
144
+ manager.addJoint(joint);
145
+ joints.push(joint);
146
+ }
147
+ return {
148
+ joints,
149
+ dispose(): void {
150
+ for (const joint of joints) manager.deleteJoint(joint);
151
+ },
152
+ };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Colliders
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface SphereColliderDef {
160
+ shape: 'sphere';
161
+ name: string;
162
+ /** The collider tracks this object's world position every `reposition()`. */
163
+ followBone: THREE.Object3D;
164
+ radius: number;
165
+ /** Extra WORLD-space offset added after sampling `followBone`'s position
166
+ * (e.g. nudging a head collider up from the neck joint). */
167
+ worldOffset?: THREE.Vector3;
168
+ }
169
+
170
+ export interface CapsuleColliderDef {
171
+ shape: 'capsule';
172
+ name: string;
173
+ followBone: THREE.Object3D;
174
+ radius: number;
175
+ /** Capsule tail, in the collider's own local space (world-axis-aligned —
176
+ * colliders are parented directly to the scene, see `buildSpringColliderRig`). */
177
+ tail: THREE.Vector3;
178
+ worldOffset?: THREE.Vector3;
179
+ }
180
+
181
+ export type ColliderDef = SphereColliderDef | CapsuleColliderDef;
182
+
183
+ export interface SpringColliderRig {
184
+ group: VRMSpringBoneColliderGroup;
185
+ colliders: Map<string, VRMSpringBoneCollider>;
186
+ /** Reposition every collider from its `followBone`'s CURRENT world
187
+ * transform and refresh its `colliderMatrix` (see module header). Call
188
+ * once per tick, BEFORE `manager.update(dt)`. */
189
+ reposition(): void;
190
+ dispose(): void;
191
+ }
192
+
193
+ /** Build sphere/capsule colliders parented directly to `scene` (world
194
+ * space) — sidesteps reasoning about any bone's local rest-axis convention
195
+ * for the collider shape's offset/tail vectors. */
196
+ export function buildSpringColliderRig(scene: THREE.Scene, defs: ColliderDef[]): SpringColliderRig {
197
+ const colliders = new Map<string, VRMSpringBoneCollider>();
198
+ const followers: Array<{ def: ColliderDef; collider: VRMSpringBoneCollider }> = [];
199
+
200
+ for (const def of defs) {
201
+ const collider =
202
+ def.shape === 'sphere'
203
+ ? new VRMSpringBoneCollider(new VRMSpringBoneColliderShapeSphere({ radius: def.radius }))
204
+ : new VRMSpringBoneCollider(
205
+ new VRMSpringBoneColliderShapeCapsule({
206
+ radius: def.radius,
207
+ offset: new THREE.Vector3(0, 0, 0),
208
+ tail: def.tail,
209
+ }),
210
+ );
211
+ scene.add(collider);
212
+ colliders.set(def.name, collider);
213
+ followers.push({ def, collider });
214
+ }
215
+
216
+ const group: VRMSpringBoneColliderGroup = {
217
+ name: 'body',
218
+ colliders: [...colliders.values()],
219
+ };
220
+
221
+ const worldTmp = new THREE.Vector3();
222
+ return {
223
+ group,
224
+ colliders,
225
+ reposition(): void {
226
+ for (const { def, collider } of followers) {
227
+ def.followBone.getWorldPosition(worldTmp);
228
+ collider.position.copy(worldTmp);
229
+ if (def.worldOffset) collider.position.add(def.worldOffset);
230
+ collider.updateWorldMatrix(true, false);
231
+ }
232
+ },
233
+ dispose(): void {
234
+ for (const collider of colliders.values()) scene.remove(collider);
235
+ colliders.clear();
236
+ },
237
+ };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Composed chain rig (the common case: one chain, its own manager)
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export interface SpringChainRigOptions {
245
+ parent: THREE.Object3D;
246
+ namePrefix: string;
247
+ count: number;
248
+ segmentLength: number;
249
+ worldDir: THREE.Vector3;
250
+ /** Colliders this chain collides against. Omit for a chain with no
251
+ * collision (e.g. a side strand tucked away from the body). */
252
+ colliderRig?: SpringColliderRig;
253
+ settings?: ChainJointSettings;
254
+ }
255
+
256
+ export interface SpringChainRig {
257
+ bones: THREE.Bone[];
258
+ tip: THREE.Object3D;
259
+ /** The manager owning this chain's joints — a "manager-compatible handle"
260
+ * a caller can also register additional joints on directly. */
261
+ manager: VRMSpringBoneManager;
262
+ /** Repositions `colliderRig` (if given) and steps the spring simulation.
263
+ * Handles the collider `updateWorldMatrix` footgun internally — callers
264
+ * never need to call `collider.updateWorldMatrix` themselves. */
265
+ tick(dt: number): void;
266
+ dispose(): void;
267
+ }
268
+
269
+ /** Compose `buildBoneChain` + `registerChainJoints` behind one call for the
270
+ * common single-chain case: builds the chain, gives it its OWN
271
+ * `VRMSpringBoneManager` (managers are cheap — one per independent chain
272
+ * keeps `dispose()`/`tick()` fully self-contained), registers its joints,
273
+ * and calls `setInitState()` once construction is complete.
274
+ *
275
+ * For MULTIPLE chains sharing one collider rig (e.g. a ponytail + two side
276
+ * strands all colliding against the same head), build each chain with its
277
+ * own `buildSpringChainRig` call (same `colliderRig` passed to each) and
278
+ * tick all of them — `colliderRig.reposition()` is idempotent per frame, so
279
+ * calling it once per chain tick is correct, just slightly redundant work
280
+ * (cheap: it only touches a handful of colliders). */
281
+ export function buildSpringChainRig(options: SpringChainRigOptions): SpringChainRig {
282
+ const chain = buildBoneChain(
283
+ options.parent,
284
+ options.namePrefix,
285
+ options.count,
286
+ options.segmentLength,
287
+ options.worldDir,
288
+ );
289
+ const manager = new VRMSpringBoneManager();
290
+ const colliderGroups = options.colliderRig ? [options.colliderRig.group] : [];
291
+ const jointHandle = registerChainJoints(manager, chain, colliderGroups, options.settings);
292
+ manager.setInitState();
293
+
294
+ return {
295
+ bones: chain.bones,
296
+ tip: chain.tip,
297
+ manager,
298
+ tick(dt: number): void {
299
+ options.colliderRig?.reposition();
300
+ manager.update(dt);
301
+ },
302
+ dispose(): void {
303
+ jointHandle.dispose();
304
+ chain.bones[0]?.removeFromParent();
305
+ },
306
+ };
307
+ }
@@ -1,4 +1,4 @@
1
- import type { GameLoopConfig } from './types';
1
+ import type { GameLoopConfig, GameLoopLiveness } from './types';
2
2
 
3
3
  /**
4
4
  * Fixed-timestep game loop with accumulator pattern.
@@ -18,6 +18,13 @@ export function createGameLoop(config: GameLoopConfig) {
18
18
  const maxSubSteps = config.maxSubSteps ?? 8;
19
19
  // Also the accumulator's hard ceiling (see the spiral-of-death guard below).
20
20
  const maxAccumulator = fixedDt * maxSubSteps;
21
+ // I2 (render-control runtime mode, see `GameLoopConfig.externalDrive`'s
22
+ // doc comment): in this mode `start()` below deliberately skips both the
23
+ // `requestAnimationFrame` arm AND the `visibilitychange` listener install —
24
+ // no wall-clock timing source ever exists for this loop instance, so
25
+ // `config.update` can only ever be invoked by an external driver calling
26
+ // into the render-control seam, never by this file's own `frame()`.
27
+ const externalDrive = config.externalDrive ?? false;
21
28
 
22
29
  let accumulator = 0;
23
30
  let lastTime = 0;
@@ -87,12 +94,32 @@ export function createGameLoop(config: GameLoopConfig) {
87
94
  hiddenPaused = false;
88
95
  lastTime = performance.now();
89
96
  accumulator = 0;
90
- rafId = requestAnimationFrame(frame);
91
97
 
98
+ // External-drive mode (I2): never arm rAF and never install the
99
+ // visibilitychange auto-stop handler. `running` still flips `true`
100
+ // above (so `isRunning`/`stop()` behave normally for a caller that
101
+ // treats this loop as "started"), but nothing will ever call
102
+ // `frame()` — a headless/backgrounded capture page cannot have this
103
+ // loop silently killed by `document.hidden`, because there is no
104
+ // listener to fire in the first place.
105
+ if (externalDrive) return;
106
+
107
+ // A play session can be started while the editor tab is ALREADY
108
+ // hidden. In that case no future visibilitychange-to-hidden event will
109
+ // arrive, and arming rAF would leave `running=true` forever while the
110
+ // browser executes zero callbacks. Classify and park the loop before
111
+ // arming it so status is truthful and visibility restore can resume it.
92
112
  if (!visibilityListenerAttached && typeof document !== 'undefined') {
93
113
  document.addEventListener('visibilitychange', handleVisibilityChange);
94
114
  visibilityListenerAttached = true;
95
115
  }
116
+ if (typeof document !== 'undefined' && document.hidden) {
117
+ running = false;
118
+ hiddenPaused = true;
119
+ return;
120
+ }
121
+
122
+ rafId = requestAnimationFrame(frame);
96
123
  },
97
124
 
98
125
  stop() {
@@ -110,6 +137,22 @@ export function createGameLoop(config: GameLoopConfig) {
110
137
  return running;
111
138
  },
112
139
 
140
+ /**
141
+ * Truthful liveness (issue #175 — see `GameLoopLiveness`'s doc comment
142
+ * in `core/types.ts` for the full contract). Unlike `isRunning` above,
143
+ * this DISTINGUISHES a hidden-tab pause from an actually-stopped loop:
144
+ * `hiddenPaused` is only ever set by `handleVisibilityChange` (T2.1's
145
+ * idle throttle) and cleared by `start()`/`stop()`/visibility-restore,
146
+ * so it can never be true at the same time `running` is true. An
147
+ * `externalDrive` loop never installs the visibility listener at all
148
+ * (see `start()` above), so `hiddenPaused` stays permanently `false` for
149
+ * it — it only ever reports `'running'` or `'stopped'`.
150
+ */
151
+ get liveness(): GameLoopLiveness {
152
+ if (hiddenPaused) return 'hidden-paused';
153
+ return running ? 'running' : 'stopped';
154
+ },
155
+
113
156
  set timeScale(value: number) {
114
157
  const clamped = Math.min(8, Math.max(0, value));
115
158
  if (clamped !== value) {
@@ -123,5 +166,17 @@ export function createGameLoop(config: GameLoopConfig) {
123
166
  get timeScale() {
124
167
  return timeScale;
125
168
  },
169
+
170
+ /**
171
+ * The fixed substep timestep (seconds) this loop's accumulator consumes
172
+ * per `config.update()` call (`config.fixedTimestep ?? 1/60`) — read by
173
+ * `Game.runTicks` (D15, `runtime/game.ts`) so a synchronous fast-forward
174
+ * burst drives `game.runFrame` with the SAME `fixedDt` this loop's own
175
+ * rAF-driven accumulator would have used, without exposing (or
176
+ * `runTicks` needing) any other internal loop state.
177
+ */
178
+ get fixedDt() {
179
+ return fixedDt;
180
+ },
126
181
  };
127
182
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * D15 — the seeded-random core (`docs/D15-DETERMINISM-DESIGN.md` §2.a,
3
+ * T-D15.1). `ctx.random` is a game-scoped PRNG with NAMED STREAMS: calling
4
+ * the object itself (`ctx.random()`) draws from the `'gameplay'` stream;
5
+ * `ctx.random.stream('vfx')` (or any other name) derives an INDEPENDENT
6
+ * generator, so a cosmetic/VFX draw can never perturb the gameplay draw
7
+ * order — the classic replay-drift trap option B ("one game-scoped PRNG
8
+ * only") would fall into (see the design doc's §2.a option table).
9
+ *
10
+ * Each stream is seeded from `fnv1a(name) ^ rootSeed` — deterministic given
11
+ * the root seed, independent of draw order across streams (drawing from
12
+ * `'vfx'` never advances `'gameplay'`'s generator, since they are two
13
+ * separate mulberry32 instances). `reseed(seed)` re-derives every stream
14
+ * that has EVER been asked for via `.stream(name)` (including the implicit
15
+ * `'gameplay'` stream `ctx.random()` itself draws from) — future draws only:
16
+ * numbers already returned before a `reseed()` call are not (and cannot be)
17
+ * un-returned; this matches `play.seed.set`'s documented semantics (T-D15.6).
18
+ *
19
+ * Deliberately reuses {@link createMulberry32} FROM `runtime/render-seed.ts`
20
+ * (the render/capture door's own generator) rather than duplicating the
21
+ * algorithm — `render-seed.ts` stays a zero-import module itself (this file
22
+ * imports FROM it, never the reverse), so this is a dependency-clean
23
+ * direction: the render/capture door and the gameplay determinism door share
24
+ * one PRNG implementation without either depending on the other's door
25
+ * logic (§2.a: "Kept as-is for the render/capture door only" — the two doors
26
+ * stay orthogonal; only the generator function itself is shared).
27
+ */
28
+
29
+ import { createMulberry32 } from '../runtime/render-seed';
30
+
31
+ /** The name `ctx.random()` (called with no `.stream(...)`) draws from. */
32
+ export const GAMEPLAY_STREAM = 'gameplay';
33
+
34
+ /** The fixed fallback seed a `SeededRandom` boots with when no explicit seed
35
+ * is supplied (mirrors `render-seed.ts`'s own `DEFAULT_RENDER_SEED` in
36
+ * spirit — an arbitrary but fixed golden-ratio-derived constant, never
37
+ * wall-clock/`Math.random`-derived, so an UNDECLARED project's `ctx.random`
38
+ * is still perfectly reproducible on its own terms even though nothing in
39
+ * the manifest asked for that — it just isn't a documented CONTRACT until
40
+ * `determinism.seededRandom` is declared, per T4.1's "no dead field" rule). */
41
+ export const DEFAULT_SEEDED_RANDOM_SEED = 0x9e3779b9;
42
+
43
+ /**
44
+ * The `ctx.random` surface (`GameContext.random`, `runtime/types.ts`).
45
+ * Callable (draws from the `'gameplay'` stream), plus:
46
+ * - `stream(name)` — an independent named generator, `[0, 1)` floats, same
47
+ * call signature as `Math.random`/the bare `SeededRandom` call itself.
48
+ * - `reseed(seed)` — re-derive every stream ever requested so far (future
49
+ * draws only) from a NEW root seed.
50
+ * - `seed` — the CURRENT root seed (reflects the last `reseed()` call, if
51
+ * any) — a live getter, not a value snapshotted at construction.
52
+ */
53
+ export interface SeededRandom {
54
+ (): number;
55
+ stream(name: string): () => number;
56
+ reseed(seed: number): void;
57
+ readonly seed: number;
58
+ }
59
+
60
+ /** fnv1a-32, the standard non-cryptographic string hash — used only to mix a
61
+ * stream NAME into the root seed, never as a determinism primitive on its
62
+ * own (mulberry32 is still what actually produces the `[0,1)` sequence). */
63
+ function fnv1a32(str: string): number {
64
+ let hash = 0x811c9dc5;
65
+ for (let i = 0; i < str.length; i++) {
66
+ hash ^= str.charCodeAt(i);
67
+ hash = Math.imul(hash, 0x01000193);
68
+ }
69
+ return hash >>> 0;
70
+ }
71
+
72
+ interface StreamBox {
73
+ /** Reassigned wholesale on `reseed()` — the wrapper function `.stream(name)`
74
+ * returns to callers stays the SAME identity forever; only what it reads
75
+ * from changes, which is what makes reseed observable through a handle a
76
+ * caller obtained long before the reseed call. */
77
+ gen: () => number;
78
+ }
79
+
80
+ /**
81
+ * Construct a fresh, game-scoped seeded-random surface. `initialSeed` is
82
+ * coerced to an unsigned 32-bit integer the same way `render-seed.ts`'s
83
+ * `installDeterministicRandom` does (`>>> 0`), so any finite JS number is
84
+ * accepted.
85
+ */
86
+ export function createSeededRandom(initialSeed: number): SeededRandom {
87
+ let rootSeed = initialSeed >>> 0;
88
+ const streams = new Map<string, StreamBox>();
89
+
90
+ function deriveStreamSeed(name: string): number {
91
+ return (fnv1a32(name) ^ rootSeed) >>> 0;
92
+ }
93
+
94
+ function getBox(name: string): StreamBox {
95
+ let box = streams.get(name);
96
+ if (!box) {
97
+ box = { gen: createMulberry32(deriveStreamSeed(name)) };
98
+ streams.set(name, box);
99
+ }
100
+ return box;
101
+ }
102
+
103
+ function streamFn(name: string): () => number {
104
+ const box = getBox(name);
105
+ return () => box.gen();
106
+ }
107
+
108
+ const random = (() => getBox(GAMEPLAY_STREAM).gen()) as SeededRandom;
109
+
110
+ Object.defineProperties(random, {
111
+ stream: { value: streamFn, enumerable: true },
112
+ reseed: {
113
+ value: (seed: number) => {
114
+ rootSeed = seed >>> 0;
115
+ // Re-derive every stream ANYONE has ever asked for (including the
116
+ // implicit 'gameplay' stream, once `random()` or
117
+ // `.stream('gameplay')` has been called at least once) — a handle a
118
+ // caller stashed before this call keeps its identity but now reads
119
+ // from the freshly-seeded generator on its NEXT call (future draws
120
+ // only, per the design doc's `play.seed.set` semantics).
121
+ for (const [name, box] of streams) {
122
+ box.gen = createMulberry32(deriveStreamSeed(name));
123
+ }
124
+ },
125
+ enumerable: true,
126
+ },
127
+ seed: {
128
+ get: () => rootSeed,
129
+ enumerable: true,
130
+ },
131
+ });
132
+
133
+ return random;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Game-scoped registry — mirrors `runtime/debug-registry.ts`'s
138
+ // `registerDebugRegistry`/`getDebugRegistry` WeakMap pattern exactly, but
139
+ // keyed on a bare `object` (not `Game`) so this module never needs to import
140
+ // `runtime/game.ts` even as a type — `core/` stays independent of `runtime/`
141
+ // except for the one explicit, documented `render-seed.ts` reuse above.
142
+ // `createGame` (`runtime/game.ts`) is the one real registrant, passing itself
143
+ // (the `GameInternal` shell) as the key, exactly like it does for
144
+ // `registerDebugRegistry(gameInternal, debugRegistry)`.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const registryByOwner = new WeakMap<object, SeededRandom>();
148
+
149
+ /** Called once by `createGame`, right after both the seeded-random surface
150
+ * and the Game shell object exist — mirrors `registerDebugRegistry`. */
151
+ export function registerSeededRandom(owner: object, random: SeededRandom): void {
152
+ registryByOwner.set(owner, random);
153
+ }
154
+
155
+ /** The game-scoped `SeededRandom` backing every world's `ctx.random` — `null`
156
+ * for an owner built without one (there is always one for every real
157
+ * `createGame` call; `null` only for a hand-built `Game`-shaped stand-in a
158
+ * test constructs without going through `createGame`). */
159
+ export function getSeededRandom(owner: object): SeededRandom | null {
160
+ return registryByOwner.get(owner) ?? null;
161
+ }
@@ -1,4 +1,11 @@
1
- import { PHASE_ORDER, type SystemDef, type SystemFn, type SystemPhaseName } from './types';
1
+ import {
2
+ PHASE_ORDER,
3
+ type SystemDef,
4
+ type SystemFn,
5
+ type SystemOptions,
6
+ type SystemPhaseName,
7
+ type SystemRunObserver,
8
+ } from './types';
2
9
 
3
10
  /**
4
11
  * Ordered system execution by named phase.
@@ -25,10 +32,12 @@ import { PHASE_ORDER, type SystemDef, type SystemFn, type SystemPhaseName } from
25
32
  *
26
33
  * Within each bucket, systems run in registration order.
27
34
  */
28
- export function createSystemRunner() {
35
+ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world') {
29
36
  const systems = new Map<SystemPhaseName, SystemFn[]>();
30
37
  const registered: SystemDef[] = [];
31
38
  const componentTicks = new Map<SystemPhaseName, SystemFn | null>();
39
+ const labels = new Map<SystemFn, string>();
40
+ let anonymousId = 0;
32
41
 
33
42
  // Initialize all phases with empty arrays / no component tick.
34
43
  for (const phase of PHASE_ORDER) {
@@ -56,11 +65,16 @@ export function createSystemRunner() {
56
65
 
57
66
  /** Run one system function, isolating a throw so it never wedges the frame. */
58
67
  function runOne(fn: SystemFn, phase: SystemPhaseName, dt: number): void {
68
+ const label = labels.get(fn) ?? fn.name ?? `anonymous-${++anonymousId}`;
69
+ labels.set(fn, label);
70
+ observer?.beginSystem(scope, phase, label);
59
71
  try {
60
72
  fn(dt);
61
73
  } catch (err) {
62
74
  const label = fn.name ? `"${fn.name}"` : '(anonymous)';
63
75
  console.error(`[system-runner] system ${label} in phase "${phase}" threw:`, err);
76
+ } finally {
77
+ observer?.endSystem(scope, phase, label);
64
78
  }
65
79
  }
66
80
 
@@ -69,12 +83,13 @@ export function createSystemRunner() {
69
83
  * Register a bare system function in a specific phase.
70
84
  * Systems within the same phase run in the order they were added.
71
85
  */
72
- add(phase: SystemPhaseName, fn: SystemFn) {
86
+ add(phase: SystemPhaseName, fn: SystemFn, options?: SystemOptions) {
73
87
  const list = systems.get(phase);
74
88
  if (!list) {
75
89
  throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
76
90
  }
77
91
  list.push(fn);
92
+ labels.set(fn, options?.name ?? fn.name ?? `anonymous-${++anonymousId}`);
78
93
  },
79
94
 
80
95
  /**
@@ -102,6 +117,7 @@ export function createSystemRunner() {
102
117
  throw new Error(`Unknown phase: ${system.phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
103
118
  }
104
119
  list.push(system.update);
120
+ labels.set(system.update, system.name ?? system.update.name ?? `anonymous-${++anonymousId}`);
105
121
  registered.push(system);
106
122
  },
107
123
 
@@ -141,6 +157,7 @@ export function createSystemRunner() {
141
157
  );
142
158
  }
143
159
  componentTicks.set(phase, fn);
160
+ labels.set(fn, `components.${phase}`);
144
161
  },
145
162
 
146
163
  /**