@volter/editor-game 0.5.66 → 0.5.68

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 (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,80 @@
1
+ import * as THREE from 'three';
2
+
3
+ type UpdateMatrixWorld = (force?: boolean) => void;
4
+ type PoseReporter = (type: string) => void;
5
+ interface AudioNode3D {
6
+ matrixWorld: THREE.Matrix4;
7
+ type: string;
8
+ context: { readonly state: AudioContextState };
9
+ // Three's AudioListener owns this private clock; PositionalAudio reads its
10
+ // listener's public timeDelta. Only the verified getDelta method is needed.
11
+ _clock?: Pick<THREE.Clock, 'getDelta'>;
12
+ timeDelta?: number;
13
+ }
14
+
15
+ interface GuardState {
16
+ dropped: number;
17
+ report?: PoseReporter;
18
+ }
19
+
20
+ // The editor bundle and source-served runtime can load this helper separately
21
+ // while sharing Three. Installation belongs to the actual prototype identity.
22
+ const GUARD_STATE = Symbol.for('vgai.audio-pose-guard');
23
+ type AudioPrototype = {
24
+ updateMatrixWorld: UpdateMatrixWorld;
25
+ [GUARD_STATE]?: GuardState;
26
+ };
27
+
28
+ function poseIsFinite(matrixWorld: THREE.Matrix4): boolean {
29
+ return matrixWorld.elements.every(Number.isFinite);
30
+ }
31
+
32
+ function guard(prototype: AudioPrototype, report?: PoseReporter): void {
33
+ const existing = prototype[GUARD_STATE];
34
+ if (existing) {
35
+ if (report) existing.report = report;
36
+ return;
37
+ }
38
+ const state: GuardState = { dropped: 0, ...(report ? { report } : {}) };
39
+ Object.defineProperty(prototype, GUARD_STATE, { value: state });
40
+ const suspendedListeners = new WeakSet<AudioNode3D>();
41
+ const original = prototype.updateMatrixWorld;
42
+ prototype.updateMatrixWorld = function guardedUpdateMatrixWorld(
43
+ this: AudioNode3D,
44
+ force?: boolean,
45
+ ): void {
46
+ // Keep the scene graph current even when Web Audio cannot consume a pose.
47
+ THREE.Object3D.prototype.updateMatrixWorld.call(this as unknown as THREE.Object3D, force);
48
+ if (this.context.state !== 'running') {
49
+ if (this._clock) {
50
+ this.timeDelta = 0;
51
+ suspendedListeners.add(this);
52
+ }
53
+ return;
54
+ }
55
+ if (poseIsFinite(this.matrixWorld)) {
56
+ // A hidden tab may have rendered no frames while suspended. Discard
57
+ // that wall-time gap before Three computes its next short pose ramp.
58
+ if (suspendedListeners.delete(this)) this._clock?.getDelta();
59
+ original.call(this, force);
60
+ return;
61
+ }
62
+ state.dropped++;
63
+ state.report?.(this.type);
64
+ };
65
+ }
66
+
67
+ /** Install once per Three prototype. Later editor installation can add its
68
+ * diagnostic without replacing the runtime wrapper or losing its count. */
69
+ export function installAudioPoseGuard(report?: PoseReporter): void {
70
+ guard(THREE.AudioListener.prototype as unknown as AudioPrototype, report);
71
+ guard(THREE.PositionalAudio.prototype as unknown as AudioPrototype, report);
72
+ }
73
+
74
+ /** Non-finite pose updates dropped by this Three instance's guard. */
75
+ export function audioPoseUpdatesDropped(): number {
76
+ return (
77
+ ((THREE.AudioListener.prototype as unknown as AudioPrototype)[GUARD_STATE]?.dropped ?? 0) +
78
+ ((THREE.PositionalAudio.prototype as unknown as AudioPrototype)[GUARD_STATE]?.dropped ?? 0)
79
+ );
80
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Frame pacing — the accumulator/alpha arithmetic of the fixed-timestep loop,
3
+ * extracted PURE.
4
+ *
5
+ * `editor-game/src/runtime/core/game-loop.ts` owns the browser side (rAF, `visibilitychange`,
6
+ * `performance.now`); this module owns the arithmetic, so the interesting
7
+ * cases — a display frame that consumes zero substeps, a frame that hits the
8
+ * substep ceiling, the spiral-of-death clamp, alpha's monotonic march across a
9
+ * substep gap — are testable without stubbing a single global.
10
+ *
11
+ * The substep half is byte-for-byte the pacing the loop has always used
12
+ * (clamp the raw frame gap, scale by `timeScale`, clamp the accumulator, then
13
+ * consume up to `maxSubSteps` whole steps). The addition is `alpha`: the
14
+ * fraction of a fixed step the accumulator is holding AFTER those substeps
15
+ * were consumed, which is what lets a renderer present between two fixed
16
+ * states instead of only at them.
17
+ */
18
+
19
+ /** The two constants that define a loop's fixed cadence. */
20
+ export interface FramePacingLimits {
21
+ /** Seconds per fixed substep (the loop's `fixedTimestep`). */
22
+ readonly fixedDt: number;
23
+ /** Substep ceiling per display frame — also the accumulator's hard ceiling. */
24
+ readonly maxSubSteps: number;
25
+ }
26
+
27
+ /** What one display frame's worth of wall time resolves to. */
28
+ export interface PacedFrame {
29
+ /** Whole fixed substeps this display frame should consume (0..maxSubSteps). */
30
+ readonly steps: number;
31
+ /** The accumulator AFTER those substeps were consumed. */
32
+ readonly accumulator: number;
33
+ /**
34
+ * Interpolation alpha in `[0, 1]` — `accumulator / fixedDt`, i.e. how far
35
+ * the presentation clock sits past the last completed fixed state. `0` means
36
+ * "exactly on the last fixed state"; `0.5` means "halfway to the next one".
37
+ */
38
+ readonly alpha: number;
39
+ /**
40
+ * The display frame's own delta in seconds, clamped and `timeScale`d exactly
41
+ * like the time fed to the accumulator. This is the delta a per-display-frame
42
+ * consumer (post-processing, particles, a `RenderStepped`-shaped callback)
43
+ * should integrate against — NOT `fixedDt`, which is a sim quantity.
44
+ */
45
+ readonly displayDt: number;
46
+ }
47
+
48
+ /**
49
+ * `accumulator / fixedDt`, clamped to `[0, 1]`.
50
+ *
51
+ * Clamped rather than asserted because the accumulator can legitimately still
52
+ * hold a whole step when a frame hits the `maxSubSteps` ceiling; presenting
53
+ * "all the way at the next fixed state" is the only honest answer there, and a
54
+ * NaN/negative/zero-`fixedDt` degenerate resolves to `0` (present the last
55
+ * fixed state) rather than poisoning every transform downstream.
56
+ */
57
+ export function interpolationAlpha(accumulator: number, fixedDt: number): number {
58
+ if (!Number.isFinite(accumulator) || !Number.isFinite(fixedDt) || fixedDt <= 0) return 0;
59
+ if (accumulator <= 0) return 0;
60
+ const alpha = accumulator / fixedDt;
61
+ return alpha >= 1 ? 1 : alpha;
62
+ }
63
+
64
+ /**
65
+ * The one legal range for `GameLoop.timeScale`, and therefore the range any
66
+ * instrument that DRIVES it must offer (`editor-game/src/runtime/dev/instruments.ts`'s Time-scale
67
+ * slider reads exactly this — a second copy of `[0, 8]` in a control's bounds
68
+ * is how a slider ends up able to request a value the loop then silently
69
+ * refuses).
70
+ */
71
+ export const TIME_SCALE_RANGE = { min: 0, max: 8 } as const;
72
+
73
+ /**
74
+ * Clamp a requested time scale into {@link TIME_SCALE_RANGE}. Extracted from
75
+ * `editor-game/src/runtime/core/game-loop.ts`'s `set timeScale` (which still owns the out-of-range
76
+ * console warning — this function only computes) so the loop and every reader
77
+ * of the range agree by construction.
78
+ *
79
+ * A NON-FINITE request resolves to `min`, not to itself: `Math.min(8,
80
+ * Math.max(0, NaN))` is `NaN`, and a `NaN` time scale poisons the accumulator
81
+ * permanently — every frame afterwards consumes zero substeps and no warning
82
+ * ever repeats. Freezing (loudly, via the setter's warning) is recoverable;
83
+ * a silently `NaN`-ed loop is not.
84
+ */
85
+ export function clampTimeScale(value: number): number {
86
+ if (!Number.isFinite(value)) return TIME_SCALE_RANGE.min;
87
+ return Math.min(TIME_SCALE_RANGE.max, Math.max(TIME_SCALE_RANGE.min, value));
88
+ }
89
+
90
+ /**
91
+ * Resolve one display frame: how many fixed substeps it consumes, what the
92
+ * accumulator holds afterwards, and the alpha/displayDt the render pass should
93
+ * present with.
94
+ *
95
+ * @param accumulator unconsumed sim time carried in from the previous frame
96
+ * @param rawDt wall-clock seconds since the previous frame
97
+ * @param timeScale the loop's live time scale (already clamped to [0, 8] by
98
+ * its setter — this function does not re-clamp it)
99
+ */
100
+ export function paceFrame(
101
+ accumulator: number,
102
+ rawDt: number,
103
+ timeScale: number,
104
+ limits: FramePacingLimits,
105
+ ): PacedFrame {
106
+ const { fixedDt, maxSubSteps } = limits;
107
+ // Also the accumulator's hard ceiling (see the spiral-of-death clamp below).
108
+ const maxAccumulator = fixedDt * maxSubSteps;
109
+
110
+ // Clamp large frame gaps (a slow frame, a debugger pause) BEFORE scaling by
111
+ // timeScale.
112
+ const displayDt = Math.min(rawDt, maxAccumulator) * timeScale;
113
+ let next = accumulator + displayDt;
114
+ // Spiral-of-death guard: bound the accumulator regardless of timeScale or
115
+ // frame-gap size. Time that can't possibly be caught up on is dropped, never
116
+ // carried forward.
117
+ if (next > maxAccumulator) next = maxAccumulator;
118
+
119
+ let steps = 0;
120
+ while (next >= fixedDt && steps < maxSubSteps) {
121
+ next -= fixedDt;
122
+ steps++;
123
+ }
124
+
125
+ return { steps, accumulator: next, alpha: interpolationAlpha(next, fixedDt), displayDt };
126
+ }
@@ -0,0 +1,225 @@
1
+ import { clampTimeScale, paceFrame, TIME_SCALE_RANGE } from './frame-pacing';
2
+ import type { GameLoopConfig, GameLoopLiveness } from './types';
3
+
4
+ /** A visible rAF chain that has produced no callback for this long is not
5
+ * honestly "running" merely because its callback remains armed. One second
6
+ * is far beyond an ordinary dropped frame and short enough for status to
7
+ * report browser starvation while it is still actionable. */
8
+ const LOOP_STARVATION_MS = 1_000;
9
+
10
+ /**
11
+ * Fixed-timestep game loop with accumulator pattern — SIM at a fixed rate,
12
+ * PRESENTATION at the display's rate.
13
+ *
14
+ * Per real (rAF) frame:
15
+ *
16
+ * 1. the accumulator absorbs the frame gap (clamped, `timeScale`d) and
17
+ * `config.update(fixedDt)` runs once per WHOLE fixed substep it can
18
+ * consume, up to `maxSubSteps` — unchanged, and still the only thing that
19
+ * advances gameplay, physics, `tick`/`simT` and the sim clock;
20
+ * 2. `config.render(alpha, displayDt)` runs EXACTLY ONCE, whatever step 1
21
+ * did — including zero times. `alpha` is `accumulator / fixedDt` after
22
+ * step 1 (see `core/frame-pacing.ts`): how far presentation sits past the
23
+ * last completed fixed state, in `[0, 1]`.
24
+ *
25
+ * So a 120 Hz display draws 120 frames a second off a 60 Hz simulation, and a
26
+ * frame that consumes zero substeps still presents — which is the whole point:
27
+ * before this, motion presented at SIM rate, so half of every 120 Hz display's
28
+ * frames were duplicates and there was nowhere at all to host
29
+ * `RenderStepped`-shaped per-display-frame code (camera polish, procedural
30
+ * sway). `Game.onRenderStep` (`runtime/game.ts`) is that host, driven from
31
+ * this callback.
32
+ *
33
+ * `config.render` is OPTIONAL, and the hosts that don't pass it keep the old
34
+ * shape exactly: the `render` phase then still runs inside `config.update` as
35
+ * the last phase of `PHASE_ORDER` (`runtime/game.ts` only skips the render
36
+ * phases when its caller asks it to). That is deliberate, because two paths
37
+ * must stay frame-exact and neither goes through the rAF arm at all:
38
+ * `externalDrive` capture/offline export (see `GameLoopConfig.externalDrive`)
39
+ * and `Game.runTicks`, which drives `runFrame` directly.
40
+ */
41
+ export function createGameLoop(config: GameLoopConfig) {
42
+ const fixedDt = config.fixedTimestep ?? 1 / 60;
43
+ // Also the accumulator's hard ceiling — `paceFrame` derives
44
+ // `fixedDt * maxSubSteps` from these two and clamps against it.
45
+ const maxSubSteps = config.maxSubSteps ?? 8;
46
+ // I2 (render-control runtime mode, see `GameLoopConfig.externalDrive`'s
47
+ // doc comment): in this mode `start()` below deliberately skips both the
48
+ // `requestAnimationFrame` arm AND the `visibilitychange` listener install —
49
+ // no wall-clock timing source ever exists for this loop instance, so
50
+ // `config.update` can only ever be invoked by an external driver calling
51
+ // into the render-control seam, never by this file's own `frame()`.
52
+ const externalDrive = config.externalDrive ?? false;
53
+
54
+ let accumulator = 0;
55
+ let lastTime = 0;
56
+ let running = false;
57
+ let rafId = 0;
58
+ let timeScale = 1.0;
59
+
60
+ // Idle throttle (T2.1): a hidden tab stops the loop outright; becoming
61
+ // visible again restarts it with the accumulator clock resynced to "now" —
62
+ // deliberately no catch-up burst for the wall-clock time spent hidden.
63
+ let visibilityPaused = false;
64
+ let visibilityListenerAttached = false;
65
+
66
+ /** Reconcile the gate against the CURRENT document bit, not merely the
67
+ * last event. Browsers can miss/coalesce visibilitychange around window
68
+ * occlusion and reloads; reading liveness is itself a recovery opportunity,
69
+ * so a stale event can never leave this flag permanently wedged. */
70
+ function reconcileVisibilityGate() {
71
+ if (externalDrive || !visibilityListenerAttached) return;
72
+ if (typeof document === 'undefined') return;
73
+ if (document.hidden) {
74
+ if (running) {
75
+ running = false;
76
+ cancelAnimationFrame(rafId);
77
+ visibilityPaused = true;
78
+ }
79
+ } else if (visibilityPaused) {
80
+ visibilityPaused = false;
81
+ running = true;
82
+ lastTime = performance.now();
83
+ accumulator = 0;
84
+ rafId = requestAnimationFrame(frame);
85
+ }
86
+ }
87
+
88
+ function handleVisibilityChange() {
89
+ reconcileVisibilityGate();
90
+ }
91
+
92
+ function frame(currentTime: number) {
93
+ if (!running) return;
94
+ rafId = requestAnimationFrame(frame);
95
+
96
+ // Convert to seconds
97
+ const rawDt = (currentTime - lastTime) / 1000;
98
+ lastTime = currentTime;
99
+
100
+ // All of the pacing arithmetic — frame-gap clamp, timeScale, the
101
+ // spiral-of-death accumulator clamp, the substep count and the
102
+ // interpolation alpha — lives in `core/frame-pacing.ts`, pure and unit
103
+ // tested. The accumulator is written back BEFORE the substeps run so a
104
+ // throwing `update` cannot make the loop re-consume time it already
105
+ // charged for.
106
+ const paced = paceFrame(accumulator, rawDt, timeScale, { fixedDt, maxSubSteps });
107
+ accumulator = paced.accumulator;
108
+
109
+ // Fixed-rate: gameplay/physics, once per consumed substep. Zero times on
110
+ // a frame that could not fill one.
111
+ for (let i = 0; i < paced.steps; i++) config.update(fixedDt);
112
+
113
+ // Display-rate: exactly once per real frame, whatever the substep count
114
+ // was — see the module doc comment. Optional; a host that never passes
115
+ // `render` keeps rendering inside `update`'s phase list.
116
+ config.render?.(paced.alpha, paced.displayDt);
117
+ }
118
+
119
+ return {
120
+ start() {
121
+ if (running) return;
122
+ running = true;
123
+ // A direct start() call can land while the loop is auto-loop-starved
124
+ // (`visibilityPaused` true, `running` false, no rAF pending). Without this
125
+ // reset, a later visibilitychange-to-visible would see stale
126
+ // `visibilityPaused === true` and spawn a SECOND rAF chain on top of the
127
+ // one this call is about to start (doubled update()/render() overhead
128
+ // until the next stop() — no sim-speed effect, but wasted work).
129
+ visibilityPaused = false;
130
+ lastTime = performance.now();
131
+ accumulator = 0;
132
+
133
+ // External-drive mode (I2): never arm rAF and never install the
134
+ // visibilitychange auto-stop handler. `running` still flips `true`
135
+ // above (so `isRunning`/`stop()` behave normally for a caller that
136
+ // treats this loop as "started"), but nothing will ever call
137
+ // `frame()` — a headless/backgrounded capture page cannot have this
138
+ // loop silently killed by `document.hidden`, because there is no
139
+ // listener to fire in the first place.
140
+ if (externalDrive) return;
141
+
142
+ // A play session can be started while the editor tab is ALREADY
143
+ // hidden. In that case no future visibilitychange-to-hidden event will
144
+ // arrive, and arming rAF would leave `running=true` forever while the
145
+ // browser executes zero callbacks. Classify and park the loop before
146
+ // arming it so status is truthful and visibility restore can resume it.
147
+ if (!visibilityListenerAttached && typeof document !== 'undefined') {
148
+ document.addEventListener('visibilitychange', handleVisibilityChange);
149
+ visibilityListenerAttached = true;
150
+ }
151
+ if (typeof document !== 'undefined' && document.hidden) {
152
+ running = false;
153
+ visibilityPaused = true;
154
+ return;
155
+ }
156
+
157
+ rafId = requestAnimationFrame(frame);
158
+ },
159
+
160
+ stop() {
161
+ running = false;
162
+ visibilityPaused = false;
163
+ cancelAnimationFrame(rafId);
164
+
165
+ if (visibilityListenerAttached && typeof document !== 'undefined') {
166
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
167
+ visibilityListenerAttached = false;
168
+ }
169
+ },
170
+
171
+ get isRunning() {
172
+ reconcileVisibilityGate();
173
+ return running;
174
+ },
175
+
176
+ /**
177
+ * Truthful liveness (issue #175 — see `GameLoopLiveness`'s doc comment
178
+ * in `core/types.ts` for the full contract). Unlike `isRunning` above,
179
+ * this distinguishes a starved rAF loop from an actually-stopped loop.
180
+ * `visibilityPaused` records the deliberate `document.hidden` gate, and
181
+ * every read first reconciles that flag against the CURRENT document bit
182
+ * so a missed visibilitychange cannot wedge it. A visible, armed chain
183
+ * also reports starvation when no real frame callback has arrived within
184
+ * {@link LOOP_STARVATION_MS}; "armed" alone is not evidence of progress. An
185
+ * `externalDrive` loop never installs the visibility listener at all
186
+ * (see `start()` above), so `visibilityPaused` stays permanently `false` for
187
+ * it — it only ever reports `'running'` or `'stopped'`.
188
+ */
189
+ get liveness(): GameLoopLiveness {
190
+ reconcileVisibilityGate();
191
+ if (visibilityPaused) return 'loop-starved';
192
+ if (running && !externalDrive && performance.now() - lastTime > LOOP_STARVATION_MS) {
193
+ return 'loop-starved';
194
+ }
195
+ return running ? 'running' : 'stopped';
196
+ },
197
+
198
+ set timeScale(value: number) {
199
+ const clamped = clampTimeScale(value);
200
+ if (clamped !== value) {
201
+ console.warn(
202
+ `[game-loop] timeScale ${value} is out of range ` +
203
+ `[${TIME_SCALE_RANGE.min}, ${TIME_SCALE_RANGE.max}]; clamped to ${clamped}.`,
204
+ );
205
+ }
206
+ timeScale = clamped;
207
+ },
208
+
209
+ get timeScale() {
210
+ return timeScale;
211
+ },
212
+
213
+ /**
214
+ * The fixed substep timestep (seconds) this loop's accumulator consumes
215
+ * per `config.update()` call (`config.fixedTimestep ?? 1/60`) — read by
216
+ * `Game.runTicks` (D15, `runtime/game.ts`) so a synchronous fast-forward
217
+ * burst drives `game.runFrame` with the SAME `fixedDt` this loop's own
218
+ * rAF-driven accumulator would have used, without exposing (or
219
+ * `runTicks` needing) any other internal loop state.
220
+ */
221
+ get fixedDt() {
222
+ return fixedDt;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A non-enumerable slot carried by the game object itself.
3
+ *
4
+ * Packaged editor play can legitimately involve two copies of `@volter/game-runtime`:
5
+ * the editor bundle creates the Game, while the project's Vite graph creates
6
+ * its root adapter. Module-local WeakMaps cannot cross that boundary. A
7
+ * registry-backed symbol can, while keeping the internal value off the public
8
+ * string-keyed Game surface.
9
+ */
10
+ export function createGameScopedSlot<T>(name: string): {
11
+ set(owner: object, value: T): void;
12
+ get(owner: object): T | null;
13
+ } {
14
+ const key = Symbol.for(`@vgai/game-runtime/game-scoped/${name}`);
15
+
16
+ return {
17
+ set(owner, value) {
18
+ Object.defineProperty(owner, key, {
19
+ configurable: true,
20
+ enumerable: false,
21
+ value,
22
+ });
23
+ },
24
+ get(owner) {
25
+ return (owner as { [key: symbol]: T | undefined })[key] ?? null;
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The seeded-random core (T-D15.1). `ctx.random` is a game-scoped PRNG with
3
+ * NAMED STREAMS: calling the object itself (`ctx.random()`) draws from the
4
+ * `'gameplay'` stream; `ctx.random.stream('vfx')` (or any other name) derives
5
+ * an INDEPENDENT generator, so a cosmetic/VFX draw can never perturb the
6
+ * gameplay draw order — the classic replay-drift trap option B ("one
7
+ * game-scoped PRNG only") would fall into (see the design doc's §2.a option
8
+ * 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 '../render-seed';
30
+ import { createGameScopedSlot } from './game-scoped-slot';
31
+
32
+ /** The name `ctx.random()` (called with no `.stream(...)`) draws from. */
33
+ export const GAMEPLAY_STREAM = 'gameplay';
34
+
35
+ /** The fixed fallback seed a `SeededRandom` boots with when no explicit seed
36
+ * is supplied (mirrors `render-seed.ts`'s own `DEFAULT_RENDER_SEED` in
37
+ * spirit — an arbitrary but fixed golden-ratio-derived constant, never
38
+ * wall-clock/`Math.random`-derived, so an UNDECLARED project's `ctx.random`
39
+ * is still perfectly reproducible on its own terms even though nothing in
40
+ * the manifest asked for that — it just isn't a documented CONTRACT until
41
+ * `determinism.seededRandom` is declared, per T4.1's "no dead field" rule). */
42
+ export const DEFAULT_SEEDED_RANDOM_SEED = 0x9e3779b9;
43
+
44
+ /**
45
+ * A game-scoped random surface.
46
+ * Callable (draws from the `'gameplay'` stream), plus:
47
+ * - `stream(name)` — an independent named generator, `[0, 1)` floats, same
48
+ * call signature as `Math.random`/the bare `SeededRandom` call itself.
49
+ * - `reseed(seed)` — re-derive every stream ever requested so far (future
50
+ * draws only) from a NEW root seed.
51
+ * - `seed` — the CURRENT root seed (reflects the last `reseed()` call, if
52
+ * any) — a live getter, not a value snapshotted at construction.
53
+ */
54
+ export interface SeededRandom {
55
+ (): number;
56
+ stream(name: string): () => number;
57
+ reseed(seed: number): void;
58
+ readonly seed: number;
59
+ }
60
+
61
+ /** fnv1a-32, the standard non-cryptographic string hash — used only to mix a
62
+ * stream NAME into the root seed, never as a determinism primitive on its
63
+ * own (mulberry32 is still what actually produces the `[0,1)` sequence). */
64
+ function fnv1a32(str: string): number {
65
+ let hash = 0x811c9dc5;
66
+ for (let i = 0; i < str.length; i++) {
67
+ hash ^= str.charCodeAt(i);
68
+ hash = Math.imul(hash, 0x01000193);
69
+ }
70
+ return hash >>> 0;
71
+ }
72
+
73
+ interface StreamBox {
74
+ /** Reassigned wholesale on `reseed()` — the wrapper function `.stream(name)`
75
+ * returns to callers stays the SAME identity forever; only what it reads
76
+ * from changes, which is what makes reseed observable through a handle a
77
+ * caller obtained long before the reseed call. */
78
+ gen: () => number;
79
+ }
80
+
81
+ /**
82
+ * Construct a fresh, game-scoped seeded-random surface. `initialSeed` is
83
+ * coerced to an unsigned 32-bit integer the same way `render-seed.ts`'s
84
+ * `installDeterministicRandom` does (`>>> 0`), so any finite JS number is
85
+ * accepted.
86
+ */
87
+ export function createSeededRandom(initialSeed: number): SeededRandom {
88
+ let rootSeed = initialSeed >>> 0;
89
+ const streams = new Map<string, StreamBox>();
90
+
91
+ function deriveStreamSeed(name: string): number {
92
+ return (fnv1a32(name) ^ rootSeed) >>> 0;
93
+ }
94
+
95
+ function getBox(name: string): StreamBox {
96
+ let box = streams.get(name);
97
+ if (!box) {
98
+ box = { gen: createMulberry32(deriveStreamSeed(name)) };
99
+ streams.set(name, box);
100
+ }
101
+ return box;
102
+ }
103
+
104
+ function streamFn(name: string): () => number {
105
+ const box = getBox(name);
106
+ return () => box.gen();
107
+ }
108
+
109
+ const random = (() => getBox(GAMEPLAY_STREAM).gen()) as SeededRandom;
110
+
111
+ Object.defineProperties(random, {
112
+ stream: { value: streamFn, enumerable: true },
113
+ reseed: {
114
+ value: (seed: number) => {
115
+ rootSeed = seed >>> 0;
116
+ // Re-derive every stream ANYONE has ever asked for (including the
117
+ // implicit 'gameplay' stream, once `random()` or
118
+ // `.stream('gameplay')` has been called at least once) — a handle a
119
+ // caller stashed before this call keeps its identity but now reads
120
+ // from the freshly-seeded generator on its NEXT call (future draws
121
+ // only, per the design doc's `play.seed.set` semantics).
122
+ for (const [name, box] of streams) {
123
+ box.gen = createMulberry32(deriveStreamSeed(name));
124
+ }
125
+ },
126
+ enumerable: true,
127
+ },
128
+ seed: {
129
+ get: () => rootSeed,
130
+ enumerable: true,
131
+ },
132
+ });
133
+
134
+ return random;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Game-scoped registry — mirrors `editor-game/src/runtime/debug-registry.ts`'s
139
+ // `registerDebugRegistry`/`getDebugRegistry` game-slot pattern exactly, but
140
+ // keyed on a bare `object` (not `Game`) so this module never needs to import
141
+ // `editor-game/src/runtime/game.ts` even as a type — `core/` stays independent of `runtime/`
142
+ // except for the one explicit, documented `render-seed.ts` reuse above.
143
+ // `createGame` (`editor-game/src/runtime/game.ts`) is the one real registrant, passing itself
144
+ // (the `GameInternal` shell) as the key, exactly like it does for
145
+ // `registerDebugRegistry(gameInternal, debugRegistry)`.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ const registryByOwner = createGameScopedSlot<SeededRandom>('seeded-random');
149
+
150
+ /** Called once by `createGame`, right after both the seeded-random surface
151
+ * and the Game shell object exist — mirrors `registerDebugRegistry`. */
152
+ export function registerSeededRandom(owner: object, random: SeededRandom): void {
153
+ registryByOwner.set(owner, random);
154
+ }
155
+
156
+ /** The game-scoped `SeededRandom` backing every world's `ctx.random` — `null`
157
+ * for an owner built without one (there is always one for every real
158
+ * `createGame` call; `null` only for a hand-built `Game`-shaped stand-in a
159
+ * test constructs without going through `createGame`). */
160
+ export function getSeededRandom(owner: object): SeededRandom | null {
161
+ return registryByOwner.get(owner) ?? null;
162
+ }