@volter/editor-game 0.5.66 → 0.5.67
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.
- package/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +20 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +3 -3
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1614 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +361 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +1 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +449 -14
- package/src/network/network-inspector-model.ts +14 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +169 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +657 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- 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
|
+
}
|