@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,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordinary module surface a native game may expose to its own debugger.
|
|
3
|
+
*
|
|
4
|
+
* A root entry re-exports one `debug` object from the project's command module:
|
|
5
|
+
*
|
|
6
|
+
* export { debug } from './commands';
|
|
7
|
+
*
|
|
8
|
+
* The command module imports no vgai API. Its functions are the application's
|
|
9
|
+
* own functions over its own stores; the native adapter merely projects that
|
|
10
|
+
* existing registry onto the session debug/input doors after the root mounts.
|
|
11
|
+
* Loading through the root entry is load-bearing: the functions close over the
|
|
12
|
+
* exact module graph the mounted application uses, never a separately evaluated
|
|
13
|
+
* copy of its stores.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { VgaiGameSystemAdapters } from '@volter/editor-project/adapter/ingest/game-contract';
|
|
17
|
+
import type {
|
|
18
|
+
NativeCommandEntry,
|
|
19
|
+
NativeDebugBinding,
|
|
20
|
+
NativeDebugModule,
|
|
21
|
+
NativeInputValue,
|
|
22
|
+
NativeInputValueType,
|
|
23
|
+
NativeSystemsBinding,
|
|
24
|
+
NativeTable,
|
|
25
|
+
} from '@volter/editor-project/adapter/native-entry-surface';
|
|
26
|
+
import { NATIVE_INPUT_VALUE_TYPES } from '@volter/editor-project/adapter/native-entry-surface';
|
|
27
|
+
import {
|
|
28
|
+
DebugError,
|
|
29
|
+
type DebugRegistry,
|
|
30
|
+
type DebugVirtualInputTarget,
|
|
31
|
+
getDebugRegistry,
|
|
32
|
+
} from '../debug-registry';
|
|
33
|
+
import type { Game } from '../game';
|
|
34
|
+
import {
|
|
35
|
+
CONTRACT_SYSTEM_SLOTS,
|
|
36
|
+
type ContractSurface,
|
|
37
|
+
projectContractSystemAdapters,
|
|
38
|
+
} from './ingest/contract-system-adapters';
|
|
39
|
+
|
|
40
|
+
export type {
|
|
41
|
+
NativeCommandEntry,
|
|
42
|
+
NativeDebugBinding,
|
|
43
|
+
NativeDebugModule,
|
|
44
|
+
NativeInputValue,
|
|
45
|
+
NativeInputValueType,
|
|
46
|
+
NativeSystemsBinding,
|
|
47
|
+
NativeTable,
|
|
48
|
+
} from '@volter/editor-project/adapter/native-entry-surface';
|
|
49
|
+
export { NATIVE_INPUT_VALUE_TYPES } from '@volter/editor-project/adapter/native-entry-surface';
|
|
50
|
+
|
|
51
|
+
type NativeInputBinding = NonNullable<NativeDebugModule['input']>;
|
|
52
|
+
|
|
53
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rejectUnknownKeys(value: Record<string, unknown>, allowed: readonly string[], at: string) {
|
|
58
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
59
|
+
if (unknown.length > 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${at} has unknown ${unknown.length === 1 ? 'key' : 'keys'}: ${unknown.join(', ')}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function functionRecord(
|
|
67
|
+
value: unknown,
|
|
68
|
+
at: string,
|
|
69
|
+
): Record<string, (...args: unknown[]) => unknown> {
|
|
70
|
+
if (!isRecord(value)) throw new Error(`${at} must be an object of named functions.`);
|
|
71
|
+
const out: Record<string, (...args: unknown[]) => unknown> = {};
|
|
72
|
+
for (const [name, candidate] of Object.entries(value)) {
|
|
73
|
+
if (!name) throw new Error(`${at} contains an empty name.`);
|
|
74
|
+
if (typeof candidate !== 'function') throw new Error(`${at}.${name} must be a function.`);
|
|
75
|
+
out[name] = candidate as (...args: unknown[]) => unknown;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseActions(value: unknown): Record<string, NativeInputValueType> {
|
|
81
|
+
if (!isRecord(value)) {
|
|
82
|
+
throw new Error('debug.input.actions must be an object mapping action names to value types.');
|
|
83
|
+
}
|
|
84
|
+
const actions: Record<string, NativeInputValueType> = {};
|
|
85
|
+
for (const [name, valueType] of Object.entries(value)) {
|
|
86
|
+
if (!name) throw new Error('debug.input.actions contains an empty action name.');
|
|
87
|
+
if (!NATIVE_INPUT_VALUE_TYPES.includes(valueType as NativeInputValueType)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`debug.input.actions.${name} must be one of ${NATIVE_INPUT_VALUE_TYPES.join(', ')}.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
actions[name] = valueType as NativeInputValueType;
|
|
93
|
+
}
|
|
94
|
+
return actions;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseInput(value: unknown): NativeInputBinding {
|
|
98
|
+
if (!isRecord(value)) throw new Error('debug.input must be an object.');
|
|
99
|
+
rejectUnknownKeys(value, ['actions', 'set', 'clear', 'tap', 'scheduleAtTick'], 'debug.input');
|
|
100
|
+
if (typeof value['actions'] === 'function') {
|
|
101
|
+
// A live table: validated per read (requireAction/the actions source), not
|
|
102
|
+
// here — the whole point is that its content changes after install.
|
|
103
|
+
const set = value['set'];
|
|
104
|
+
const clear = value['clear'];
|
|
105
|
+
if (typeof set !== 'function') throw new Error('debug.input.set must be a function.');
|
|
106
|
+
if (typeof clear !== 'function') throw new Error('debug.input.clear must be a function.');
|
|
107
|
+
return value as unknown as NativeInputBinding;
|
|
108
|
+
}
|
|
109
|
+
const set = value['set'];
|
|
110
|
+
const clear = value['clear'];
|
|
111
|
+
const tap = value['tap'];
|
|
112
|
+
const scheduleAtTick = value['scheduleAtTick'];
|
|
113
|
+
if (typeof set !== 'function') throw new Error('debug.input.set must be a function.');
|
|
114
|
+
if (typeof clear !== 'function') throw new Error('debug.input.clear must be a function.');
|
|
115
|
+
if (tap !== undefined && typeof tap !== 'function') {
|
|
116
|
+
throw new Error('debug.input.tap must be a function when provided.');
|
|
117
|
+
}
|
|
118
|
+
if (scheduleAtTick !== undefined && typeof scheduleAtTick !== 'function') {
|
|
119
|
+
throw new Error('debug.input.scheduleAtTick must be a function when provided.');
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
actions: parseActions(value['actions']),
|
|
123
|
+
set: set as NativeInputBinding['set'],
|
|
124
|
+
clear: clear as NativeInputBinding['clear'],
|
|
125
|
+
...(typeof tap === 'function' ? { tap: tap as NonNullable<NativeInputBinding['tap']> } : {}),
|
|
126
|
+
...(typeof scheduleAtTick === 'function'
|
|
127
|
+
? {
|
|
128
|
+
scheduleAtTick: scheduleAtTick as NonNullable<NativeInputBinding['scheduleAtTick']>,
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read the single native `debug` export from an already-loaded root module. */
|
|
135
|
+
export function nativeDebugBindingFromEntryModule(
|
|
136
|
+
rootId: string,
|
|
137
|
+
entryModule: unknown,
|
|
138
|
+
): NativeDebugBinding | null {
|
|
139
|
+
if (!isRecord(entryModule) || entryModule['debug'] === undefined) return null;
|
|
140
|
+
if (!isRecord(entryModule['debug'])) {
|
|
141
|
+
throw new Error(`Root "${rootId}" exports \`debug\`, but it is not an object.`);
|
|
142
|
+
}
|
|
143
|
+
const raw = entryModule['debug'];
|
|
144
|
+
rejectUnknownKeys(
|
|
145
|
+
raw,
|
|
146
|
+
['commands', 'state', 'input', 'events', 'settled'],
|
|
147
|
+
`Root "${rootId}" debug export`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// A thunk table defers to install time (see {@link NativeTable}); a plain
|
|
151
|
+
// record validates here, where the error can still name the export.
|
|
152
|
+
const parseTable = (value: unknown, at: string) => {
|
|
153
|
+
if (value === undefined) return undefined;
|
|
154
|
+
if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
|
|
155
|
+
return functionRecord(value, at);
|
|
156
|
+
};
|
|
157
|
+
// Commands allow the `{ description?, run }` entry shape; full validation
|
|
158
|
+
// happens at install (`resolveCommandTable`), where thunks resolve too.
|
|
159
|
+
const parseCommands = (value: unknown, at: string) => {
|
|
160
|
+
if (value === undefined) return undefined;
|
|
161
|
+
if (typeof value === 'function') return value as () => Readonly<Record<string, never>>;
|
|
162
|
+
if (!isRecord(value)) throw new Error(`${at} must be an object of named commands.`);
|
|
163
|
+
return value as Readonly<Record<string, NativeCommandEntry>>;
|
|
164
|
+
};
|
|
165
|
+
const commands = parseCommands(raw['commands'], 'debug.commands');
|
|
166
|
+
const state = parseTable(raw['state'], 'debug.state');
|
|
167
|
+
const input = raw['input'] === undefined ? undefined : parseInput(raw['input']);
|
|
168
|
+
const events = raw['events'] === undefined ? undefined : parseEvents(raw['events']);
|
|
169
|
+
const settled = raw['settled'];
|
|
170
|
+
if (settled !== undefined && typeof settled !== 'function') {
|
|
171
|
+
throw new Error('debug.settled must be a function returning a boolean.');
|
|
172
|
+
}
|
|
173
|
+
if (!commands && !state && !input && !events && !settled) {
|
|
174
|
+
throw new Error(`Root "${rootId}" exports an empty \`debug\` object.`);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
rootId,
|
|
178
|
+
debug: {
|
|
179
|
+
...(commands ? { commands } : {}),
|
|
180
|
+
...(state ? { state } : {}),
|
|
181
|
+
...(input ? { input } : {}),
|
|
182
|
+
...(events ? { events } : {}),
|
|
183
|
+
...(settled ? { settled: settled as () => boolean } : {}),
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseEvents(value: unknown): NonNullable<NativeDebugModule['events']> {
|
|
189
|
+
if (!isRecord(value) || typeof value['subscribe'] !== 'function') {
|
|
190
|
+
throw new Error('debug.events must be an object with a subscribe(listener) function.');
|
|
191
|
+
}
|
|
192
|
+
return value as unknown as NonNullable<NativeDebugModule['events']>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolve a possibly-thunk COMMAND table, validating each entry's shape. */
|
|
196
|
+
function resolveCommandTable(
|
|
197
|
+
table: NativeTable<NativeCommandEntry> | undefined,
|
|
198
|
+
at: string,
|
|
199
|
+
): Readonly<Record<string, NativeCommandEntry>> {
|
|
200
|
+
if (table === undefined) return {};
|
|
201
|
+
const raw = typeof table === 'function' ? table() : table;
|
|
202
|
+
if (!isRecord(raw)) throw new Error(`${at} must be an object of named commands.`);
|
|
203
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
204
|
+
if (!name) throw new Error(`${at} contains an empty name.`);
|
|
205
|
+
const ok =
|
|
206
|
+
typeof entry === 'function' ||
|
|
207
|
+
(isRecord(entry) && typeof (entry as { run?: unknown }).run === 'function');
|
|
208
|
+
if (!ok) throw new Error(`${at}.${name} must be a function or { description?, run }.`);
|
|
209
|
+
}
|
|
210
|
+
return raw as Readonly<Record<string, NativeCommandEntry>>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Resolve a possibly-thunk table at install time, validating the result. */
|
|
214
|
+
function resolveTable<T>(
|
|
215
|
+
table: NativeTable<T> | undefined,
|
|
216
|
+
at: string,
|
|
217
|
+
): Readonly<Record<string, T>> {
|
|
218
|
+
if (table === undefined) return {};
|
|
219
|
+
if (typeof table === 'function') {
|
|
220
|
+
return functionRecord(table(), at) as Readonly<Record<string, T>>;
|
|
221
|
+
}
|
|
222
|
+
return table;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function valueMatches(type: NativeInputValueType, value: NativeInputValue): boolean {
|
|
226
|
+
if (type === 'digital') return typeof value === 'boolean';
|
|
227
|
+
if (type === 'scalar') return typeof value === 'number' && Number.isFinite(value);
|
|
228
|
+
return (
|
|
229
|
+
typeof value === 'object' &&
|
|
230
|
+
value !== null &&
|
|
231
|
+
typeof value.x === 'number' &&
|
|
232
|
+
Number.isFinite(value.x) &&
|
|
233
|
+
typeof value.y === 'number' &&
|
|
234
|
+
Number.isFinite(value.y)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function installInput(
|
|
239
|
+
game: Game,
|
|
240
|
+
registry: DebugRegistry,
|
|
241
|
+
binding: NativeDebugBinding,
|
|
242
|
+
input: NativeInputBinding,
|
|
243
|
+
): void {
|
|
244
|
+
const trace: Array<{ tick: number; action: string; value: NativeInputValue }> = [];
|
|
245
|
+
let recording = false;
|
|
246
|
+
|
|
247
|
+
// LIVE read on every use — see the `actions` field's own comment.
|
|
248
|
+
const liveActions = (): Readonly<Record<string, NativeInputValueType>> =>
|
|
249
|
+
typeof input.actions === 'function' ? input.actions() : input.actions;
|
|
250
|
+
|
|
251
|
+
const requireAction = (action: string, value: NativeInputValue): NativeInputValueType => {
|
|
252
|
+
const actions = liveActions();
|
|
253
|
+
const type = actions[action];
|
|
254
|
+
if (!type) {
|
|
255
|
+
throw new DebugError(
|
|
256
|
+
'INPUT_ACTION_NOT_FOUND',
|
|
257
|
+
`Native input has no action named "${action}".`,
|
|
258
|
+
{
|
|
259
|
+
registered: Object.keys(actions),
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (!valueMatches(type, value)) {
|
|
264
|
+
throw new DebugError(
|
|
265
|
+
'INPUT_ACTION_VALUE_TYPE',
|
|
266
|
+
`Native input action "${action}" expects ${type}, not ${typeof value}.`,
|
|
267
|
+
{ action, expected: type },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return type;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const apply = (action: string, value: NativeInputValue, tick: number): void => {
|
|
274
|
+
requireAction(action, value);
|
|
275
|
+
input.set(action, value);
|
|
276
|
+
if (recording) trace.push({ tick, action, value });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const unsupportedSource = (method: string): never => {
|
|
280
|
+
throw new DebugError(
|
|
281
|
+
'NATIVE_INPUT_SOURCE_UNSUPPORTED',
|
|
282
|
+
`${method} addresses an engine test source, but this game exposes an app-owned action store. Drive it by action name instead.`,
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
const target: DebugVirtualInputTarget = {
|
|
286
|
+
setVirtualAction(action, value) {
|
|
287
|
+
apply(action, value, registry.getGameTick());
|
|
288
|
+
return { delivered: true };
|
|
289
|
+
},
|
|
290
|
+
tapVirtualAction(action) {
|
|
291
|
+
if (!input.tap) {
|
|
292
|
+
throw new DebugError(
|
|
293
|
+
'NATIVE_INPUT_TAP_UNSUPPORTED',
|
|
294
|
+
`Native input for root "${binding.rootId}" does not export tap(); use setVirtualAction through the app-owned store.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
requireAction(action, true);
|
|
298
|
+
input.tap(action);
|
|
299
|
+
return { delivered: true };
|
|
300
|
+
},
|
|
301
|
+
clearVirtualActions() {
|
|
302
|
+
input.clear();
|
|
303
|
+
},
|
|
304
|
+
scheduleActionAtTick(tick, action, value) {
|
|
305
|
+
const currentTick = registry.getGameTick();
|
|
306
|
+
if (!Number.isInteger(tick) || tick < currentTick) {
|
|
307
|
+
throw new DebugError(
|
|
308
|
+
'TICK_ALREADY_PASSED',
|
|
309
|
+
`Cannot schedule native input for tick ${tick}; current tick is ${currentTick}.`,
|
|
310
|
+
{ tick, currentTick },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
requireAction(action, value);
|
|
314
|
+
if (!input.scheduleAtTick) {
|
|
315
|
+
throw new DebugError(
|
|
316
|
+
'NATIVE_INPUT_SCHEDULE_UNSUPPORTED',
|
|
317
|
+
`Native input for root "${binding.rootId}" does not export scheduleAtTick(); the adapter will not create a host scheduler for it.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
input.scheduleAtTick(tick, action, value);
|
|
321
|
+
},
|
|
322
|
+
startInputRecording() {
|
|
323
|
+
trace.length = 0;
|
|
324
|
+
recording = true;
|
|
325
|
+
},
|
|
326
|
+
stopInputRecording() {
|
|
327
|
+
recording = false;
|
|
328
|
+
},
|
|
329
|
+
isInputRecording() {
|
|
330
|
+
return recording;
|
|
331
|
+
},
|
|
332
|
+
injectAxis() {
|
|
333
|
+
unsupportedSource('injectAxis');
|
|
334
|
+
},
|
|
335
|
+
injectVector2() {
|
|
336
|
+
unsupportedSource('injectVector2');
|
|
337
|
+
},
|
|
338
|
+
injectPointerDelta() {
|
|
339
|
+
unsupportedSource('injectPointerDelta');
|
|
340
|
+
},
|
|
341
|
+
injectPointerPosition() {
|
|
342
|
+
unsupportedSource('injectPointerPosition');
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
registry.setInputActionsSource(binding.rootId, () =>
|
|
347
|
+
Object.entries(liveActions()).map(([name, valueType]) => ({ name, valueType })),
|
|
348
|
+
);
|
|
349
|
+
registry.setInputTraceSource(binding.rootId, () => ({
|
|
350
|
+
version: 1,
|
|
351
|
+
seed: null,
|
|
352
|
+
fixedDt: game.loop.fixedDt,
|
|
353
|
+
ticks: [...trace],
|
|
354
|
+
}));
|
|
355
|
+
registry.setVirtualInputTarget(binding.rootId, target);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Project native registries onto the existing session debugger after mount. */
|
|
359
|
+
export function installNativeDebugBindings(
|
|
360
|
+
game: Game,
|
|
361
|
+
bindings: readonly NativeDebugBinding[],
|
|
362
|
+
): void {
|
|
363
|
+
if (bindings.length === 0) return;
|
|
364
|
+
const registry = getDebugRegistry(game);
|
|
365
|
+
if (!registry)
|
|
366
|
+
throw new Error('Cannot install native debug bindings: mounted Game has no debug registry.');
|
|
367
|
+
for (const binding of bindings) {
|
|
368
|
+
if (!game.world(binding.rootId)) {
|
|
369
|
+
throw new Error(`Native debug binding names unmounted root "${binding.rootId}".`);
|
|
370
|
+
}
|
|
371
|
+
const root = registry.forRoot(binding.rootId);
|
|
372
|
+
const state = resolveTable(binding.debug.state, `Root "${binding.rootId}" debug.state`);
|
|
373
|
+
for (const [name, read] of Object.entries(state)) {
|
|
374
|
+
root.registerStateProvider(name, read, { tier: 'assisted' });
|
|
375
|
+
}
|
|
376
|
+
const commands = resolveCommandTable(
|
|
377
|
+
binding.debug.commands,
|
|
378
|
+
`Root "${binding.rootId}" debug.commands`,
|
|
379
|
+
);
|
|
380
|
+
for (const [name, entry] of Object.entries(commands)) {
|
|
381
|
+
const run = typeof entry === 'function' ? entry : entry.run;
|
|
382
|
+
const description = typeof entry === 'function' ? undefined : entry.description;
|
|
383
|
+
root.registerCommand(
|
|
384
|
+
name,
|
|
385
|
+
{ locus: 'client', ...(description === undefined ? {} : { description }) },
|
|
386
|
+
run,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (binding.debug.input) installInput(game, registry, binding, binding.debug.input);
|
|
390
|
+
// The app's emitter → the session's tick-stamped event log. The
|
|
391
|
+
// subscription's disposer is deliberately dropped: it lives exactly as
|
|
392
|
+
// long as this Game's registry does.
|
|
393
|
+
binding.debug.events?.subscribe((event, detail) => root.emit(event, detail));
|
|
394
|
+
// The app's own between-worlds truth → the session's tick gate
|
|
395
|
+
// (`runTicksWhenSettled`). Same lifetime rule as the event subscription.
|
|
396
|
+
if (binding.debug.settled) registry.registerWorldSettledProbe(binding.debug.settled);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// The native `systems` export — the first-party door onto `SystemAdapters`
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
//
|
|
404
|
+
// The sibling of `debug` on the SAME module surface: a root entry re-exports
|
|
405
|
+
// one `systems` object beside it —
|
|
406
|
+
//
|
|
407
|
+
// export { debug, systems } from './commands';
|
|
408
|
+
//
|
|
409
|
+
// — whose slots are the ONE declarable carrier both realms share,
|
|
410
|
+
// `VgaiGameSystemAdapters` (`ingest/game-contract.ts`; the native engine is
|
|
411
|
+
// the premade 100% implementation of that contract). Validation is the SAME
|
|
412
|
+
// projection the ingest realm uses (`ingest/contract-system-adapters.ts`), so
|
|
413
|
+
// there is one shape law, not two. This door is what retires
|
|
414
|
+
// `ctx.registerSystemAdapter` from component code (ARCHITECTURE-CORE §System
|
|
415
|
+
// adapters: "a project's `vgai.adapter.ts` binds app-owned systems through
|
|
416
|
+
// declared native exports … Components never call `registerSystemAdapter`").
|
|
417
|
+
//
|
|
418
|
+
// Native-realm difference from ingest: a malformed slot THROWS (this is our
|
|
419
|
+
// own code failing its own contract — fail fast), where the ingest projection
|
|
420
|
+
// files a verdict for the coverage report. A `{ present: false, evidence }`
|
|
421
|
+
// slot is accepted as the positive absence it is and binds nothing.
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Read the single native `systems` export from an already-loaded root module.
|
|
425
|
+
* `surface` is the root's own mount surface when the caller knows it — it
|
|
426
|
+
* feeds the physics keying check (`PHYSICS_KEYING_BY_SURFACE`).
|
|
427
|
+
*/
|
|
428
|
+
export function nativeSystemsBindingFromEntryModule(
|
|
429
|
+
rootId: string,
|
|
430
|
+
entryModule: unknown,
|
|
431
|
+
surface?: ContractSurface | undefined,
|
|
432
|
+
): NativeSystemsBinding | null {
|
|
433
|
+
if (!isRecord(entryModule) || entryModule['systems'] === undefined) return null;
|
|
434
|
+
const raw = entryModule['systems'];
|
|
435
|
+
if (!isRecord(raw)) {
|
|
436
|
+
throw new Error(`Root "${rootId}" exports \`systems\`, but it is not an object.`);
|
|
437
|
+
}
|
|
438
|
+
rejectUnknownKeys(raw, CONTRACT_SYSTEM_SLOTS, `Root "${rootId}" systems export`);
|
|
439
|
+
const projection = projectContractSystemAdapters(
|
|
440
|
+
{ systemAdapters: raw as VgaiGameSystemAdapters },
|
|
441
|
+
surface,
|
|
442
|
+
);
|
|
443
|
+
if (projection.malformed.length > 0) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`Root "${rootId}" systems export is malformed: ` +
|
|
446
|
+
projection.malformed.map((slot) => `${slot.slot} — ${slot.reason}`).join('; '),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (Object.keys(projection.bound).length === 0 && projection.empty.length === 0) {
|
|
450
|
+
throw new Error(`Root "${rootId}" exports an empty \`systems\` object.`);
|
|
451
|
+
}
|
|
452
|
+
return { rootId, slots: projection.bound, absent: projection.empty };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Install every declared binding onto the mounted Game's game-scoped slot
|
|
457
|
+
* table (`Game.installDeclaredSystemAdapters`), where the ordinary
|
|
458
|
+
* `game.systemAdapters` merge picks them up ahead of any lingering component
|
|
459
|
+
* registration for the same root.
|
|
460
|
+
*
|
|
461
|
+
* A root's ABSENCES travel the same call and install NOTHING — they are
|
|
462
|
+
* recorded, never bound, because the whole point of `absent()` is that no
|
|
463
|
+
* adapter exists to bind (see its comment: a marker, never a stub). They are
|
|
464
|
+
* recorded HERE rather than left on the binding alone so the answer is
|
|
465
|
+
* game-scoped, matching the registry every editor panel already reads: a
|
|
466
|
+
* `SystemAdapters` slot is filled by whichever root builds it, so "does this
|
|
467
|
+
* GAME have physics" cannot be answered one root at a time.
|
|
468
|
+
*
|
|
469
|
+
* A root that declares only absences therefore still calls through — skipping
|
|
470
|
+
* it on an empty `slots` map is what would drop exactly the games whose whole
|
|
471
|
+
* declaration is "I have none of these".
|
|
472
|
+
*/
|
|
473
|
+
export function installNativeSystemsBindings(
|
|
474
|
+
game: Game,
|
|
475
|
+
bindings: readonly NativeSystemsBinding[],
|
|
476
|
+
): void {
|
|
477
|
+
if (bindings.length === 0) return;
|
|
478
|
+
const install = game.installDeclaredSystemAdapters;
|
|
479
|
+
if (!install) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
'Cannot install native systems bindings: this Game does not implement ' +
|
|
482
|
+
'installDeclaredSystemAdapters.',
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
for (const binding of bindings) {
|
|
486
|
+
if (Object.keys(binding.slots).length === 0 && binding.absent.length === 0) continue;
|
|
487
|
+
install.call(
|
|
488
|
+
game,
|
|
489
|
+
binding.rootId,
|
|
490
|
+
binding.slots,
|
|
491
|
+
binding.absent.map((slot) => ({
|
|
492
|
+
rootId: binding.rootId,
|
|
493
|
+
slot: slot.slot,
|
|
494
|
+
reason: slot.evidence,
|
|
495
|
+
})),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named-bus mixer over Web Audio: buses created on demand, routed into each
|
|
3
|
+
* other by name, and set in DECIBELS.
|
|
4
|
+
*
|
|
5
|
+
* Every mixer a game reaches for is this shape — Unity's `AudioMixerGroup`,
|
|
6
|
+
* Godot's bus layout, FMOD's VCAs, and the `music`/`sfx`/`voice` trio
|
|
7
|
+
* `setup/setup-audio.ts` hands a first-party three world. What they share is
|
|
8
|
+
* not the protocol (that part is genuinely per-ecosystem and stays with the
|
|
9
|
+
* lane that speaks it) but the machinery underneath: a `GainNode` per bus, a
|
|
10
|
+
* name → node map, a parent to route into, and the one conversion below. This
|
|
11
|
+
* file owns exactly that, and nothing that names an ecosystem.
|
|
12
|
+
*
|
|
13
|
+
* ## dB is not a gain, and the mistake is silent
|
|
14
|
+
*
|
|
15
|
+
* `GainNode.gain` is a LINEAR amplitude multiplier; every mixer's authored
|
|
16
|
+
* volume is logarithmic dB, where 0 dB is unity and −80 dB is silence. Writing
|
|
17
|
+
* the dB number straight onto `gain.value` gives you a gain of −80 (a phase
|
|
18
|
+
* inversion at 80x) where the author asked for silence, and it does not throw,
|
|
19
|
+
* warn, or look wrong in a debugger — it is just loud. {@link dbToLinear} is
|
|
20
|
+
* the one conversion, `10^(dB/20)`, which is both Godot's `db_to_linear`
|
|
21
|
+
* (`core/math/math_funcs.h`: `exp(db * 0.11512925464970228…)`, the same
|
|
22
|
+
* function written in base e) and Unity's mixer volume curve.
|
|
23
|
+
*
|
|
24
|
+
* It deliberately does NOT clamp. A mixer's RANGE is protocol — Unity's
|
|
25
|
+
* exposed parameters saturate at its slider's ends, Godot's `volume_db` does
|
|
26
|
+
* not — so the lane that knows the range applies it before calling here, and
|
|
27
|
+
* this file cannot silently mute or silently boost anyone's authored value.
|
|
28
|
+
*
|
|
29
|
+
* ## Create-on-demand, and why it is the contract rather than a convenience
|
|
30
|
+
*
|
|
31
|
+
* {@link AudioBusMixer.bus} MINTS a bus the first time it is named and returns
|
|
32
|
+
* the SAME node forever after. The identity is the contract, not the minting:
|
|
33
|
+
* a source connects to a bus long before anything sets that bus's volume, and
|
|
34
|
+
* a parent is usually named by its child before anyone asks for it directly
|
|
35
|
+
* (`bus('music', 'master')` builds `master` too, and the later `bus('master')`
|
|
36
|
+
* must be that same node). A `bus()` that minted per call would hand the
|
|
37
|
+
* volume control a SECOND node — the sources would keep feeding the first one,
|
|
38
|
+
* every later write would land on a node nothing plays through, and nothing
|
|
39
|
+
* would throw or look wrong. So the map is authoritative, and the only way a
|
|
40
|
+
* name stops resolving to its node is {@link AudioBusMixer.remove} or
|
|
41
|
+
* {@link AudioBusMixer.disconnect}.
|
|
42
|
+
*
|
|
43
|
+
* A re-ask that names a DIFFERENT parent throws by name. Routing is decided at
|
|
44
|
+
* creation (the node is already connected by then), so honouring the second
|
|
45
|
+
* parent would mean silently re-routing live audio and ignoring it would mean
|
|
46
|
+
* silently keeping the first — a loud error is the only reading that is not a
|
|
47
|
+
* lie.
|
|
48
|
+
*
|
|
49
|
+
* ## RESOURCE OWNERSHIP
|
|
50
|
+
*
|
|
51
|
+
* No module-scoped state: {@link createAudioBusMixer} is a factory and the
|
|
52
|
+
* OWNER of an instance is whoever called it (a `setupAudio` graph, one
|
|
53
|
+
* translated port's mixer store). What an instance ALLOCATES is one `GainNode`
|
|
54
|
+
* per named bus, and disconnecting those is the whole of its teardown
|
|
55
|
+
* obligation — {@link AudioBusMixer.disconnect}, called once by that same
|
|
56
|
+
* owner. The `context` and the `destination` node are NOT its own: they are
|
|
57
|
+
* passed in, shared with everything else hanging off the same graph, and this
|
|
58
|
+
* file never creates, closes, suspends or disconnects either one.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The graph an {@link AudioBusMixer} hangs off, as the two members it touches.
|
|
63
|
+
*
|
|
64
|
+
* Narrow on purpose: `createGain` is all it needs from the context, so a real
|
|
65
|
+
* `AudioContext`, an `OfflineAudioContext` and a headless stand-in are all
|
|
66
|
+
* acceptable without a cast.
|
|
67
|
+
*/
|
|
68
|
+
export interface AudioBusMixerOptions {
|
|
69
|
+
readonly context: { createGain(): GainNode };
|
|
70
|
+
/** The node every parentless bus feeds. Usually a master gain or the context's destination. */
|
|
71
|
+
readonly destination: AudioNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AudioBusMixer {
|
|
75
|
+
/**
|
|
76
|
+
* The bus named `name`, created on the first ask and connected to `parent`
|
|
77
|
+
* (itself created on demand) or to the destination.
|
|
78
|
+
*
|
|
79
|
+
* The returned node is the real `GainNode` — set `gain.value`, schedule
|
|
80
|
+
* automation on `gain`, connect sources to it. Nothing here stands between a
|
|
81
|
+
* caller and Web Audio's own API.
|
|
82
|
+
*/
|
|
83
|
+
bus(name: string, parent?: string): GainNode;
|
|
84
|
+
/** The bus named `name` if it EXISTS, else null. Never mints one. */
|
|
85
|
+
find(name: string): GainNode | null;
|
|
86
|
+
/** Set this bus's gain from decibels, creating it if absent. Clamp before calling — see the header. */
|
|
87
|
+
setDb(name: string, db: number, parent?: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Disconnect one bus and forget its name, so a later {@link bus} mints a
|
|
90
|
+
* fresh node. Buses that named it as their parent are NOT re-routed — they
|
|
91
|
+
* keep feeding the removed node, which now reaches nothing.
|
|
92
|
+
*/
|
|
93
|
+
remove(name: string): void;
|
|
94
|
+
/** Disconnect every bus and forget them all. The owner's ONE teardown. */
|
|
95
|
+
disconnect(): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** dB → linear amplitude: `10^(dB/20)`. 0 dB is 1, −6 dB is ~0.5, −80 dB is ~0.0001. Never clamps. */
|
|
99
|
+
export function dbToLinear(db: number): number {
|
|
100
|
+
return 10 ** (db / 20);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface BusRecord {
|
|
104
|
+
readonly node: GainNode;
|
|
105
|
+
readonly parent: string | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeDisconnect(node: GainNode): void {
|
|
109
|
+
try {
|
|
110
|
+
node.disconnect();
|
|
111
|
+
} catch {
|
|
112
|
+
// already disconnected
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Create one named-bus mixer. See the header for what owns the instance and what it allocates. */
|
|
117
|
+
export function createAudioBusMixer(options: AudioBusMixerOptions): AudioBusMixer {
|
|
118
|
+
const { context, destination } = options;
|
|
119
|
+
const buses = new Map<string, BusRecord>();
|
|
120
|
+
|
|
121
|
+
function bus(name: string, parent?: string): GainNode {
|
|
122
|
+
const existing = buses.get(name);
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
if (parent !== undefined && parent !== existing.parent) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`audio bus ${JSON.stringify(name)} already routes into ` +
|
|
127
|
+
`${existing.parent === undefined ? 'the destination' : JSON.stringify(existing.parent)}` +
|
|
128
|
+
`; it cannot also route into ${JSON.stringify(parent)}. Routing is decided when a bus ` +
|
|
129
|
+
'is created (see packages/editor-game/src/runtime/audio/bus-mixer.ts).',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return existing.node;
|
|
133
|
+
}
|
|
134
|
+
// The parent is resolved (and minted, recursively) before this bus is
|
|
135
|
+
// seated, so a conflicting-parent throw upstream leaves nothing half-built.
|
|
136
|
+
const into = parent === undefined ? destination : bus(parent);
|
|
137
|
+
const node = context.createGain();
|
|
138
|
+
node.gain.value = 1;
|
|
139
|
+
node.connect(into);
|
|
140
|
+
buses.set(name, { node, parent });
|
|
141
|
+
return node;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
bus,
|
|
146
|
+
find: (name) => buses.get(name)?.node ?? null,
|
|
147
|
+
setDb: (name, db, parent) => {
|
|
148
|
+
bus(name, parent).gain.value = dbToLinear(db);
|
|
149
|
+
},
|
|
150
|
+
remove: (name) => {
|
|
151
|
+
const record = buses.get(name);
|
|
152
|
+
if (record === undefined) return;
|
|
153
|
+
buses.delete(name);
|
|
154
|
+
safeDisconnect(record.node);
|
|
155
|
+
},
|
|
156
|
+
disconnect: () => {
|
|
157
|
+
for (const record of buses.values()) safeDisconnect(record.node);
|
|
158
|
+
buses.clear();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|