@reactvision/react-viro 2.57.0 → 2.57.2
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/android/react_viro/react_viro-release.aar +0 -0
- package/android/viro_renderer/viro_renderer-release.aar +0 -0
- package/components/AR/ViroARPlane.tsx +1 -4
- package/components/AR/ViroARPlaneSelector.tsx +10 -5
- package/components/AR/ViroARScene.tsx +2 -4
- package/components/Studio/StudioARScene.tsx +243 -48
- package/components/Studio/StudioSceneNavigator.tsx +15 -0
- package/components/Studio/VRTStudioModule.ts +10 -0
- package/components/Studio/domain/StudioSounds.tsx +46 -0
- package/components/Studio/domain/apiRequestHelpers.ts +612 -0
- package/components/Studio/domain/collisionBindingsRuntime.ts +17 -2
- package/components/Studio/domain/defaultApiRequestExecutor.ts +61 -0
- package/components/Studio/domain/expressionEvaluator.ts +603 -0
- package/components/Studio/domain/sceneNavigationHandler.ts +770 -20
- package/components/Studio/domain/soundManager.ts +141 -0
- package/components/Studio/domain/utils.ts +50 -0
- package/components/Studio/domain/variableStore.ts +74 -0
- package/components/Studio/domain/viroNodeFactory.tsx +175 -44
- package/components/Studio/domain/visibilityStore.ts +66 -0
- package/components/Studio/index.ts +1 -0
- package/components/Studio/types.ts +178 -1
- package/components/Utilities/VRModuleOpenXR.ts +35 -0
- package/components/Utilities/ViroVersion.ts +1 -1
- package/components/ViroAnimatedImage.tsx +1 -0
- package/components/ViroFlexView.tsx +1 -0
- package/components/ViroImage.tsx +1 -0
- package/components/ViroObjectDetector.tsx +13 -5
- package/components/ViroParticleEmitter.tsx +2 -0
- package/components/ViroPolygon.tsx +2 -1
- package/components/ViroQuad.tsx +2 -1
- package/components/ViroSurface.tsx +1 -0
- package/components/ViroVideo.tsx +1 -0
- package/components/ViroXRSceneNavigator.tsx +8 -0
- package/dist/components/AR/ViroARPlane.d.ts +1 -1
- package/dist/components/AR/ViroARPlane.js +1 -5
- package/dist/components/AR/ViroARPlaneSelector.d.ts +1 -1
- package/dist/components/AR/ViroARPlaneSelector.js +10 -5
- package/dist/components/AR/ViroARScene.d.ts +1 -1
- package/dist/components/AR/ViroARScene.js +2 -5
- package/dist/components/Studio/StudioARScene.d.ts +3 -0
- package/dist/components/Studio/StudioARScene.js +142 -15
- package/dist/components/Studio/StudioSceneNavigator.js +14 -0
- package/dist/components/Studio/VRTStudioModule.d.ts +6 -0
- package/dist/components/Studio/VRTStudioModule.js +10 -0
- package/dist/components/Studio/domain/StudioSounds.d.ts +11 -0
- package/dist/components/Studio/domain/StudioSounds.js +69 -0
- package/dist/components/Studio/domain/apiRequestHelpers.d.ts +180 -0
- package/dist/components/Studio/domain/apiRequestHelpers.js +496 -0
- package/dist/components/Studio/domain/collisionBindingsRuntime.d.ts +3 -1
- package/dist/components/Studio/domain/collisionBindingsRuntime.js +4 -3
- package/dist/components/Studio/domain/defaultApiRequestExecutor.d.ts +14 -0
- package/dist/components/Studio/domain/defaultApiRequestExecutor.js +58 -0
- package/dist/components/Studio/domain/expressionEvaluator.d.ts +108 -0
- package/dist/components/Studio/domain/expressionEvaluator.js +520 -0
- package/dist/components/Studio/domain/sceneNavigationHandler.d.ts +35 -8
- package/dist/components/Studio/domain/sceneNavigationHandler.js +564 -22
- package/dist/components/Studio/domain/soundManager.d.ts +52 -0
- package/dist/components/Studio/domain/soundManager.js +118 -0
- package/dist/components/Studio/domain/utils.d.ts +16 -0
- package/dist/components/Studio/domain/utils.js +48 -0
- package/dist/components/Studio/domain/variableStore.d.ts +21 -0
- package/dist/components/Studio/domain/variableStore.js +65 -0
- package/dist/components/Studio/domain/viroNodeFactory.d.ts +4 -9
- package/dist/components/Studio/domain/viroNodeFactory.js +84 -25
- package/dist/components/Studio/domain/visibilityStore.d.ts +33 -0
- package/dist/components/Studio/domain/visibilityStore.js +66 -0
- package/dist/components/Studio/index.d.ts +1 -1
- package/dist/components/Studio/types.d.ts +150 -1
- package/dist/components/Utilities/VRModuleOpenXR.d.ts +19 -0
- package/dist/components/Utilities/VRModuleOpenXR.js +16 -0
- package/dist/components/Utilities/ViroVersion.d.ts +1 -1
- package/dist/components/Utilities/ViroVersion.js +1 -1
- package/dist/components/ViroAnimatedImage.js +1 -0
- package/dist/components/ViroFlexView.js +1 -0
- package/dist/components/ViroImage.js +1 -0
- package/dist/components/ViroObjectDetector.d.ts +13 -5
- package/dist/components/ViroObjectDetector.js +13 -5
- package/dist/components/ViroParticleEmitter.js +2 -0
- package/dist/components/ViroPolygon.js +1 -0
- package/dist/components/ViroQuad.js +1 -0
- package/dist/components/ViroSurface.js +1 -0
- package/dist/components/ViroVideo.js +1 -0
- package/dist/components/ViroXRSceneNavigator.d.ts +8 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -1
- package/dist/plugins/withViroAndroid.js +18 -0
- package/index.ts +4 -3
- package/ios/dist/ViroRenderer/ViroKit.framework/Info.plist +0 -0
- package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
- package/ios/dist/lib/libViroReact.a +0 -0
- package/package.json +1 -1
|
@@ -1,15 +1,155 @@
|
|
|
1
|
-
import { Alert } from "react-native";
|
|
1
|
+
import { Alert, AppState } from "react-native";
|
|
2
2
|
import { isQuest } from "../../Utilities/ViroPlatform";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
StudioAnimation,
|
|
5
|
+
StudioApiRequestExecutor,
|
|
6
|
+
StudioApiRequestOutcome,
|
|
7
|
+
StudioSceneFunction,
|
|
8
|
+
StudioSceneResponse,
|
|
9
|
+
StudioSequenceStep,
|
|
10
|
+
} from "../types";
|
|
4
11
|
import { VRTStudioModule } from "../VRTStudioModule";
|
|
12
|
+
import { applyBindings, interpolateDisplayTemplate } from "./apiRequestHelpers";
|
|
13
|
+
import {
|
|
14
|
+
evaluate,
|
|
15
|
+
evaluateBranchCondition,
|
|
16
|
+
parseExpression,
|
|
17
|
+
valueMatchesType,
|
|
18
|
+
} from "./expressionEvaluator";
|
|
19
|
+
import { StudioSoundManager } from "./soundManager";
|
|
20
|
+
import { StudioVariableStore } from "./variableStore";
|
|
21
|
+
import { StudioVisibilityStore } from "./visibilityStore";
|
|
5
22
|
|
|
6
23
|
type SceneNavigator = any; // ViroARSceneNavigator navigator object passed to AR scenes
|
|
7
24
|
|
|
8
25
|
const ANIMATION_CHAIN_MAX_DEPTH = 10;
|
|
26
|
+
// The proxy enforces the authored timeout server-side; the client backstop
|
|
27
|
+
// only covers an unreachable/unresponsive proxy.
|
|
28
|
+
const API_REQUEST_CLIENT_GRACE_MS = 5000;
|
|
9
29
|
|
|
10
30
|
/**
|
|
11
|
-
*
|
|
31
|
+
* Non-blocking, cancellable timer pool driving Sequence WAIT steps.
|
|
32
|
+
*
|
|
33
|
+
* One scheduler is owned per scene (see StudioARScene). cancelAll() must run on
|
|
34
|
+
* scene unmount and before navigating away so a pending WAIT never fires into a
|
|
35
|
+
* torn-down or replaced scene.
|
|
36
|
+
*
|
|
37
|
+
* Backgrounding is PAUSE-AND-RESUME: a WAIT freezes while the app is backgrounded
|
|
38
|
+
* and resumes its remaining time on foreground. On mobile this falls out of the
|
|
39
|
+
* suspended JS thread for free; the AppState handling below covers surfaces that
|
|
40
|
+
* keep the JS thread alive in background (e.g. headsets).
|
|
41
|
+
*/
|
|
42
|
+
type ScheduledTimer = {
|
|
43
|
+
callback: () => void;
|
|
44
|
+
remainingMs: number;
|
|
45
|
+
startedAt: number;
|
|
46
|
+
handle: ReturnType<typeof setTimeout> | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class SequenceScheduler {
|
|
50
|
+
private timers = new Set<ScheduledTimer>();
|
|
51
|
+
private appStateSub: { remove: () => void } | null = null;
|
|
52
|
+
private backgrounded = false;
|
|
53
|
+
// Sequence ids currently mid-run. A re-trigger of an in-flight sequence is
|
|
54
|
+
// ignored (no stacked/overlapping runs); single actions are unaffected.
|
|
55
|
+
private activeSequences = new Set<string>();
|
|
56
|
+
// Bumped by cancelAll(). Async work (API requests) captures the value when
|
|
57
|
+
// it starts and drops its continuation if it changed — a late response can
|
|
58
|
+
// never fire into a torn-down or replaced scene.
|
|
59
|
+
private generationCounter = 0;
|
|
60
|
+
|
|
61
|
+
get generation(): number {
|
|
62
|
+
return this.generationCounter;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
constructor() {
|
|
66
|
+
this.appStateSub = AppState.addEventListener("change", (state) => {
|
|
67
|
+
if (state === "active") this.resumeAll();
|
|
68
|
+
else this.pauseAll();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Returns false if the sequence is already running (caller should skip).
|
|
73
|
+
beginSequence(id: string): boolean {
|
|
74
|
+
if (this.activeSequences.has(id)) return false;
|
|
75
|
+
this.activeSequences.add(id);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
endSequence(id: string): void {
|
|
80
|
+
this.activeSequences.delete(id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
schedule(callback: () => void, ms: number): void {
|
|
84
|
+
const timer: ScheduledTimer = {
|
|
85
|
+
callback,
|
|
86
|
+
remainingMs: Math.max(0, ms),
|
|
87
|
+
startedAt: Date.now(),
|
|
88
|
+
handle: null,
|
|
89
|
+
};
|
|
90
|
+
this.timers.add(timer);
|
|
91
|
+
if (!this.backgrounded) this.arm(timer);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private arm(timer: ScheduledTimer): void {
|
|
95
|
+
timer.startedAt = Date.now();
|
|
96
|
+
timer.handle = setTimeout(() => {
|
|
97
|
+
this.timers.delete(timer);
|
|
98
|
+
timer.callback();
|
|
99
|
+
}, timer.remainingMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private pauseAll(): void {
|
|
103
|
+
if (this.backgrounded) return;
|
|
104
|
+
this.backgrounded = true;
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
for (const timer of this.timers) {
|
|
107
|
+
if (timer.handle === null) continue;
|
|
108
|
+
clearTimeout(timer.handle);
|
|
109
|
+
timer.handle = null;
|
|
110
|
+
timer.remainingMs = Math.max(
|
|
111
|
+
0,
|
|
112
|
+
timer.remainingMs - (now - timer.startedAt)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private resumeAll(): void {
|
|
118
|
+
if (!this.backgrounded) return;
|
|
119
|
+
this.backgrounded = false;
|
|
120
|
+
for (const timer of this.timers) this.arm(timer);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
cancelAll(): void {
|
|
124
|
+
for (const timer of this.timers) {
|
|
125
|
+
if (timer.handle !== null) clearTimeout(timer.handle);
|
|
126
|
+
}
|
|
127
|
+
this.timers.clear();
|
|
128
|
+
this.activeSequences.clear();
|
|
129
|
+
this.generationCounter++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
dispose(): void {
|
|
133
|
+
this.cancelAll();
|
|
134
|
+
this.appStateSub?.remove();
|
|
135
|
+
this.appStateSub = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Runtime context threaded through executeFunctionWithRelations: the Sequence
|
|
141
|
+
* scheduler plus the per-session variable store and API-request transport
|
|
142
|
+
* (optional so dispatch sites without them keep working).
|
|
12
143
|
*/
|
|
144
|
+
export type SequenceRuntimeContext = {
|
|
145
|
+
scheduler: SequenceScheduler;
|
|
146
|
+
variableStore?: StudioVariableStore;
|
|
147
|
+
apiRequestExecutor?: StudioApiRequestExecutor;
|
|
148
|
+
visibilityStore?: StudioVisibilityStore;
|
|
149
|
+
soundManager?: StudioSoundManager;
|
|
150
|
+
getAssetPosition?: (assetId: string) => [number, number, number] | undefined;
|
|
151
|
+
};
|
|
152
|
+
|
|
13
153
|
function resolveById(
|
|
14
154
|
id: string,
|
|
15
155
|
fns: StudioSceneFunction[]
|
|
@@ -17,6 +157,454 @@ function resolveById(
|
|
|
17
157
|
return fns.find((f) => f.id === id);
|
|
18
158
|
}
|
|
19
159
|
|
|
160
|
+
/** Everything the step walker threads through; depth is the owning list's chain depth. */
|
|
161
|
+
type StepRunnerDeps = {
|
|
162
|
+
sceneNavigator: SceneNavigator | undefined;
|
|
163
|
+
animations: StudioAnimation[];
|
|
164
|
+
onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void;
|
|
165
|
+
onSceneChange?: (sceneId: string, sceneName: string) => void;
|
|
166
|
+
runtimeCtx: SequenceRuntimeContext;
|
|
167
|
+
depth: number;
|
|
168
|
+
// Set true by an aborting sibling lane (STOP/NAV) so mid-flight pending work
|
|
169
|
+
// in OTHER lanes of the same group (WAIT timers, waitable-sound onFinish, API
|
|
170
|
+
// proceed) short-circuits instead of running into the halted sequence.
|
|
171
|
+
isCancelled?: () => boolean;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Walks an ordered step list with two continuations: onDone when the list
|
|
176
|
+
* completes, onAbort on early termination (NAVIGATION leaves the scene, STOP
|
|
177
|
+
* halts in place; the top caller releases its beginSequence guard either way,
|
|
178
|
+
* so a failed async navigation can't leave a sequence permanently blocked).
|
|
179
|
+
*/
|
|
180
|
+
function runSteps(
|
|
181
|
+
steps: StudioSequenceStep[],
|
|
182
|
+
deps: StepRunnerDeps,
|
|
183
|
+
onDone: () => void,
|
|
184
|
+
onAbort: () => void
|
|
185
|
+
): void {
|
|
186
|
+
const ordered = [...steps].sort((a, b) => a.step_order - b.step_order);
|
|
187
|
+
const runStep = (i: number): void => {
|
|
188
|
+
// A sibling lane aborting (STOP/NAV) cancels this lane too: bail before
|
|
189
|
+
// running any further step, including from a pending timer/async callback.
|
|
190
|
+
if (deps.isCancelled?.()) return;
|
|
191
|
+
if (i >= ordered.length) {
|
|
192
|
+
onDone();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const step = ordered[i];
|
|
196
|
+
if (step.step_type === "WAIT") {
|
|
197
|
+
// Non-blocking: the rest of the list continues after the timer.
|
|
198
|
+
deps.runtimeCtx.scheduler.schedule(() => {
|
|
199
|
+
if (deps.isCancelled?.()) return;
|
|
200
|
+
runStep(i + 1);
|
|
201
|
+
}, step.duration_ms ?? 0);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (step.step_type === "STOP") {
|
|
205
|
+
// Explicit terminal: halt the whole run. onAbort is threaded unchanged
|
|
206
|
+
// through every nested arm (branch/api/run-sequence/group), so it skips
|
|
207
|
+
// the outer continuations straight to the top beginSequence guard release
|
|
208
|
+
// — a STOP inside an arm ends the entire sequence, not just that arm.
|
|
209
|
+
onAbort();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ACTION: dispatch the effect, then decide advance. Wait-by-default; the
|
|
214
|
+
// per-step advance_immediately flag opts out (fire-and-forget / overlap).
|
|
215
|
+
if (step.function) {
|
|
216
|
+
const fn = step.function;
|
|
217
|
+
const immediate = step.advance_immediately === true; // false = wait
|
|
218
|
+
let advanced = false;
|
|
219
|
+
const advance = (): void => {
|
|
220
|
+
// Single-fire guard: the outer continuation runs at most once even if
|
|
221
|
+
// a detached arm and the immediate path both try to advance.
|
|
222
|
+
if (advanced) return;
|
|
223
|
+
advanced = true;
|
|
224
|
+
runStep(i + 1);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Control-flow + Group: gate on completion by default, or run detached
|
|
228
|
+
// (own no-op onDone, swallow its abort) and advance now when immediate.
|
|
229
|
+
const gateOrDetach = (
|
|
230
|
+
run: (onDone: () => void, onAbort: () => void) => void
|
|
231
|
+
): void => {
|
|
232
|
+
if (immediate) {
|
|
233
|
+
run(
|
|
234
|
+
() => {},
|
|
235
|
+
() => {}
|
|
236
|
+
);
|
|
237
|
+
advance();
|
|
238
|
+
} else {
|
|
239
|
+
// Gate: outer resumes on the arm/lane onDone; a STOP/NAV inside still
|
|
240
|
+
// bubbles to the top via onAbort.
|
|
241
|
+
run(advance, onAbort);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (fn.function_type === "BRANCH") {
|
|
246
|
+
gateOrDetach((d, a) => runBranch(fn, deps, d, a));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (fn.function_type === "API_REQUEST") {
|
|
250
|
+
gateOrDetach((d, a) => runApiRequest(fn, deps, d, a));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (fn.function_type === "SEQUENCE") {
|
|
254
|
+
gateOrDetach((d, a) => runReferencedSequence(fn, deps, d, a));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (fn.function_type === "GROUP") {
|
|
258
|
+
gateOrDetach((d, a) => runGroup(fn, deps, d, a));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// SOUND: a non-looping PLAY is waitable (the clip ends); looping PLAY and
|
|
263
|
+
// STOP are always instant. Dispatch the waitable case inline so the
|
|
264
|
+
// manager's onFinish can resume the walk; everything else goes through
|
|
265
|
+
// the single dispatcher.
|
|
266
|
+
if (fn.function_type === "SOUND") {
|
|
267
|
+
const s = fn.scene_sound;
|
|
268
|
+
const manager = deps.runtimeCtx.soundManager;
|
|
269
|
+
const waitable =
|
|
270
|
+
!!s && s.action === "PLAY" && !s.loop && !immediate && !!manager;
|
|
271
|
+
if (waitable && s && manager) {
|
|
272
|
+
if (!s.audio_url) {
|
|
273
|
+
// Missing/cross-org clip (resolve RPC nulls the url org-guarded):
|
|
274
|
+
// nothing to play or wait on; continue.
|
|
275
|
+
console.warn(
|
|
276
|
+
`[Studio] SOUND function ${fn.id}: PLAY has no audio_url (missing/cross-org clip); skipping.`
|
|
277
|
+
);
|
|
278
|
+
advance();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const position = s.target_asset_id
|
|
282
|
+
? deps.runtimeCtx.getAssetPosition?.(s.target_asset_id)
|
|
283
|
+
: undefined;
|
|
284
|
+
const scheduler = deps.runtimeCtx.scheduler;
|
|
285
|
+
const generation = scheduler.generation;
|
|
286
|
+
manager.play(
|
|
287
|
+
{
|
|
288
|
+
audioAssetId: s.audio_asset_id ?? "",
|
|
289
|
+
url: s.audio_url,
|
|
290
|
+
position,
|
|
291
|
+
volume: s.volume,
|
|
292
|
+
loop: s.loop,
|
|
293
|
+
stopOthers: s.stop_other_sounds,
|
|
294
|
+
},
|
|
295
|
+
() => {
|
|
296
|
+
// Drop a stale advance after scene-change/abort, like runApiRequest.
|
|
297
|
+
if (scheduler.generation !== generation) return;
|
|
298
|
+
// A sibling lane aborted while this clip was playing: don't advance.
|
|
299
|
+
if (deps.isCancelled?.()) return;
|
|
300
|
+
advance();
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Loop PLAY / STOP / no manager: fire-and-forget through the dispatcher.
|
|
306
|
+
executeFunctionWithRelations(
|
|
307
|
+
fn,
|
|
308
|
+
deps.sceneNavigator,
|
|
309
|
+
deps.animations,
|
|
310
|
+
deps.onAnimationTrigger,
|
|
311
|
+
deps.depth + 1,
|
|
312
|
+
deps.onSceneChange,
|
|
313
|
+
deps.runtimeCtx
|
|
314
|
+
);
|
|
315
|
+
advance();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// All remaining types dispatch synchronously through the single dispatcher.
|
|
320
|
+
executeFunctionWithRelations(
|
|
321
|
+
fn,
|
|
322
|
+
deps.sceneNavigator,
|
|
323
|
+
deps.animations,
|
|
324
|
+
deps.onAnimationTrigger,
|
|
325
|
+
deps.depth + 1,
|
|
326
|
+
deps.onSceneChange,
|
|
327
|
+
deps.runtimeCtx
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// A step list is scoped to one scene. NAVIGATION leaves it, so the walk
|
|
331
|
+
// ends here; remaining steps belong to the scene we just left.
|
|
332
|
+
// Author follow-on steps as the target scene's on_load sequence.
|
|
333
|
+
if (fn.function_type === "NAVIGATION") {
|
|
334
|
+
onAbort();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ANIMATION: hold the walk for the animation's run time so later steps
|
|
339
|
+
// begin when it finishes, not when it starts (unless opted out).
|
|
340
|
+
if (fn.function_type === "ANIMATION") {
|
|
341
|
+
if (immediate) {
|
|
342
|
+
advance();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const anim = fn.scene_animation;
|
|
346
|
+
const runMs = (anim?.delay_ms ?? 0) + (anim?.duration_ms ?? 0);
|
|
347
|
+
deps.runtimeCtx.scheduler.schedule(advance, runMs);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// SET_VARIABLE / SET_VISIBILITY / ALERT: instant.
|
|
352
|
+
advance();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
runStep(i + 1);
|
|
356
|
+
};
|
|
357
|
+
runStep(0);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Evaluates a BRANCH's conditions in eval order (first match wins) and runs the
|
|
362
|
+
* matched arm, or the no-match arm if none match, like a nested sequence.
|
|
363
|
+
* Failure policy: a condition that fails to evaluate warns and is treated as
|
|
364
|
+
* not matched (fall through to the next condition); never throws.
|
|
365
|
+
*/
|
|
366
|
+
function runBranch(
|
|
367
|
+
fn: StudioSceneFunction,
|
|
368
|
+
deps: StepRunnerDeps,
|
|
369
|
+
onDone: () => void,
|
|
370
|
+
onAbort: () => void
|
|
371
|
+
): void {
|
|
372
|
+
const branch = fn.scene_branch;
|
|
373
|
+
if (!branch) {
|
|
374
|
+
onDone();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const branchDepth = deps.depth + 1;
|
|
378
|
+
if (branchDepth > ANIMATION_CHAIN_MAX_DEPTH) {
|
|
379
|
+
console.warn(
|
|
380
|
+
`[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for branch ${branch.id}.`
|
|
381
|
+
);
|
|
382
|
+
onDone();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const store = deps.runtimeCtx.variableStore;
|
|
386
|
+
if (!store) {
|
|
387
|
+
console.warn(
|
|
388
|
+
`[Studio] BRANCH function ${fn.id} needs a runtime context (variable store); skipping.`
|
|
389
|
+
);
|
|
390
|
+
onDone();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const conditions = [...branch.conditions].sort(
|
|
394
|
+
(a, b) => a.eval_order - b.eval_order
|
|
395
|
+
);
|
|
396
|
+
for (const condition of conditions) {
|
|
397
|
+
const result = evaluateBranchCondition(
|
|
398
|
+
{
|
|
399
|
+
comparison: condition.comparison,
|
|
400
|
+
variable_name: condition.variable_name,
|
|
401
|
+
compare_literal: condition.compare_literal,
|
|
402
|
+
compare_variable_name: condition.compare_variable_name,
|
|
403
|
+
},
|
|
404
|
+
(name) => store.get(name)
|
|
405
|
+
);
|
|
406
|
+
if (!result.ok) {
|
|
407
|
+
console.warn(
|
|
408
|
+
`[Studio] BRANCH ${branch.id} condition ${condition.eval_order}: ${result.error}; treating as not matched.`
|
|
409
|
+
);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (result.value) {
|
|
413
|
+
runSteps(
|
|
414
|
+
condition.sequence.steps,
|
|
415
|
+
{ ...deps, depth: branchDepth },
|
|
416
|
+
onDone,
|
|
417
|
+
onAbort
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// No condition matched: run the no-match arm if present, else continue.
|
|
423
|
+
const arm = branch.no_match_sequence;
|
|
424
|
+
if (!arm) {
|
|
425
|
+
onDone();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
runSteps(arm.steps, { ...deps, depth: branchDepth }, onDone, onAbort);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Runs a named SEQUENCE function's steps inline as a Run Sequence step. Unlike
|
|
433
|
+
* a trigger-dispatched sequence it is NOT begin-guarded (it composes like a
|
|
434
|
+
* branch arm); the depth guard bounds reference chains the editor's cycle
|
|
435
|
+
* filter and the resolve RPC also defend against.
|
|
436
|
+
*/
|
|
437
|
+
function runReferencedSequence(
|
|
438
|
+
fn: StudioSceneFunction,
|
|
439
|
+
deps: StepRunnerDeps,
|
|
440
|
+
onDone: () => void,
|
|
441
|
+
onAbort: () => void
|
|
442
|
+
): void {
|
|
443
|
+
const seq = fn.scene_sequence;
|
|
444
|
+
if (!seq) {
|
|
445
|
+
onDone();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const seqDepth = deps.depth + 1;
|
|
449
|
+
if (seqDepth > ANIMATION_CHAIN_MAX_DEPTH) {
|
|
450
|
+
console.warn(
|
|
451
|
+
`[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for sequence ${seq.id}.`
|
|
452
|
+
);
|
|
453
|
+
onDone();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
runSteps(seq.steps, { ...deps, depth: seqDepth }, onDone, onAbort);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Runs a GROUP's lanes concurrently and completes when ALL lanes complete
|
|
461
|
+
* (barrier join). Each lane is an owned headless sequence run like a nested
|
|
462
|
+
* sequence. A NAVIGATION/STOP inside any lane ends the whole walk via onAbort;
|
|
463
|
+
* the aborted flag stops a late laneDone from also firing onDone afterwards.
|
|
464
|
+
* Fire-and-forget lanes (all-immediate steps) call laneDone synchronously, so a
|
|
465
|
+
* single-instant-step lane never deadlocks.
|
|
466
|
+
*/
|
|
467
|
+
function runGroup(
|
|
468
|
+
fn: StudioSceneFunction,
|
|
469
|
+
deps: StepRunnerDeps,
|
|
470
|
+
onDone: () => void,
|
|
471
|
+
onAbort: () => void
|
|
472
|
+
): void {
|
|
473
|
+
const group = fn.scene_group;
|
|
474
|
+
if (!group || group.lanes.length === 0) {
|
|
475
|
+
onDone();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const groupDepth = deps.depth + 1;
|
|
479
|
+
if (groupDepth > ANIMATION_CHAIN_MAX_DEPTH) {
|
|
480
|
+
console.warn(
|
|
481
|
+
`[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for group ${group.id}.`
|
|
482
|
+
);
|
|
483
|
+
onDone();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
let remaining = group.lanes.length;
|
|
487
|
+
let aborted = false;
|
|
488
|
+
// Threaded into every lane's walk so an abort in one lane also halts the
|
|
489
|
+
// pending async work (timers, sound onFinish, API proceed) of its siblings,
|
|
490
|
+
// not just the join below. Also honours an outer cancel (nested groups).
|
|
491
|
+
let cancelled = false;
|
|
492
|
+
const isCancelled = (): boolean => cancelled || !!deps.isCancelled?.();
|
|
493
|
+
const laneDone = (): void => {
|
|
494
|
+
if (aborted) return;
|
|
495
|
+
remaining -= 1;
|
|
496
|
+
if (remaining === 0) onDone();
|
|
497
|
+
};
|
|
498
|
+
const laneAbort = (): void => {
|
|
499
|
+
if (aborted) return;
|
|
500
|
+
aborted = true;
|
|
501
|
+
cancelled = true;
|
|
502
|
+
onAbort();
|
|
503
|
+
};
|
|
504
|
+
for (const lane of group.lanes) {
|
|
505
|
+
runSteps(
|
|
506
|
+
lane.sequence.steps,
|
|
507
|
+
{ ...deps, depth: groupDepth, isCancelled },
|
|
508
|
+
laneDone,
|
|
509
|
+
laneAbort
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Executes an API_REQUEST through the injected executor and runs the matching
|
|
516
|
+
* outcome arm like a nested sequence. The proxy enforces the real timeout; a
|
|
517
|
+
* scheduler backstop covers an unreachable proxy. Failure policy mirrors
|
|
518
|
+
* SET_VARIABLE/BRANCH: warn + degrade, never throw. A scheduler generation
|
|
519
|
+
* captured at start drops the continuation if the scene is torn down or
|
|
520
|
+
* replaced while the request is in flight.
|
|
521
|
+
*/
|
|
522
|
+
function runApiRequest(
|
|
523
|
+
fn: StudioSceneFunction,
|
|
524
|
+
deps: StepRunnerDeps,
|
|
525
|
+
onDone: () => void,
|
|
526
|
+
onAbort: () => void
|
|
527
|
+
): void {
|
|
528
|
+
const apiRequest = fn.scene_api_request;
|
|
529
|
+
if (!apiRequest) {
|
|
530
|
+
onDone();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const chainDepth = deps.depth + 1;
|
|
534
|
+
if (chainDepth > ANIMATION_CHAIN_MAX_DEPTH) {
|
|
535
|
+
console.warn(
|
|
536
|
+
`[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for API request ${apiRequest.id}.`
|
|
537
|
+
);
|
|
538
|
+
onDone();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const executor = deps.runtimeCtx.apiRequestExecutor;
|
|
542
|
+
if (!executor) {
|
|
543
|
+
console.warn(
|
|
544
|
+
`[Studio] API_REQUEST function ${fn.id} needs a runtime context (executor); skipping.`
|
|
545
|
+
);
|
|
546
|
+
onDone();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const scheduler = deps.runtimeCtx.scheduler;
|
|
550
|
+
const store = deps.runtimeCtx.variableStore;
|
|
551
|
+
const generation = scheduler.generation;
|
|
552
|
+
let settled = false;
|
|
553
|
+
|
|
554
|
+
const proceed = (outcome: StudioApiRequestOutcome): void => {
|
|
555
|
+
if (settled || scheduler.generation !== generation) return;
|
|
556
|
+
// A sibling lane aborted while the request was in flight: drop the arm.
|
|
557
|
+
if (deps.isCancelled?.()) return;
|
|
558
|
+
settled = true;
|
|
559
|
+
if (store) {
|
|
560
|
+
const { writes, warnings } = applyBindings(
|
|
561
|
+
apiRequest.bindings ?? [],
|
|
562
|
+
outcome
|
|
563
|
+
);
|
|
564
|
+
for (const warning of warnings) {
|
|
565
|
+
console.warn(`[Studio] API_REQUEST ${apiRequest.id}: ${warning}`);
|
|
566
|
+
}
|
|
567
|
+
for (const write of writes) {
|
|
568
|
+
store.set(write.name, write.value);
|
|
569
|
+
}
|
|
570
|
+
} else if ((apiRequest.bindings ?? []).length > 0) {
|
|
571
|
+
console.warn(
|
|
572
|
+
`[Studio] API_REQUEST ${apiRequest.id}: no variable store; bindings skipped.`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
const arm = outcome.ok
|
|
576
|
+
? apiRequest.success_sequence
|
|
577
|
+
: apiRequest.failure_sequence;
|
|
578
|
+
if (!arm) {
|
|
579
|
+
onDone();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
runSteps(arm.steps, { ...deps, depth: chainDepth }, onDone, onAbort);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
scheduler.schedule(() => {
|
|
586
|
+
proceed({
|
|
587
|
+
ok: false,
|
|
588
|
+
status: null,
|
|
589
|
+
error_code: "TIMEOUT",
|
|
590
|
+
error_message: "Request timed out",
|
|
591
|
+
});
|
|
592
|
+
}, apiRequest.timeout_ms + API_REQUEST_CLIENT_GRACE_MS);
|
|
593
|
+
|
|
594
|
+
const variables = store ? store.snapshot() : {};
|
|
595
|
+
executor(fn.id, variables)
|
|
596
|
+
.then((outcome) => proceed(outcome))
|
|
597
|
+
.catch((error: unknown) => {
|
|
598
|
+
proceed({
|
|
599
|
+
ok: false,
|
|
600
|
+
status: null,
|
|
601
|
+
error_code: "NETWORK_ERROR",
|
|
602
|
+
error_message:
|
|
603
|
+
error instanceof Error ? error.message : "Request failed",
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
20
608
|
/**
|
|
21
609
|
* Looks up target_asset_id for an ANIMATION-type scene function.
|
|
22
610
|
* The inline scene_animation only has the animation UUID — we resolve it
|
|
@@ -29,10 +617,7 @@ function resolveAnimationTargetAssetId(
|
|
|
29
617
|
return animations.find((a) => a.id === animationId)?.target_asset_id;
|
|
30
618
|
}
|
|
31
619
|
|
|
32
|
-
/**
|
|
33
|
-
* Single dispatcher for all scene function types.
|
|
34
|
-
* Used by onClick, onCollision, and on_load_function triggers.
|
|
35
|
-
*/
|
|
620
|
+
/** Used by onClick, onCollision, and on_load_function triggers. */
|
|
36
621
|
export function executeFunctionWithRelations(
|
|
37
622
|
fn: StudioSceneFunction,
|
|
38
623
|
sceneNavigator: SceneNavigator | undefined,
|
|
@@ -40,10 +625,53 @@ export function executeFunctionWithRelations(
|
|
|
40
625
|
onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void,
|
|
41
626
|
depth = 0,
|
|
42
627
|
onSceneChange?: (sceneId: string, sceneName: string) => void,
|
|
628
|
+
runtimeCtx?: SequenceRuntimeContext
|
|
43
629
|
): void {
|
|
44
630
|
if (depth > ANIMATION_CHAIN_MAX_DEPTH) {
|
|
45
631
|
console.warn(
|
|
46
|
-
`[Studio] Max
|
|
632
|
+
`[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for function ${fn.id}.`
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Shared direct-trigger dispatch for SEQUENCE/BRANCH/GROUP/API_REQUEST.
|
|
638
|
+
const runBeginGuarded = <T extends { id: string }>(
|
|
639
|
+
label: string,
|
|
640
|
+
payload: T | null | undefined,
|
|
641
|
+
run: (
|
|
642
|
+
payload: T,
|
|
643
|
+
deps: StepRunnerDeps,
|
|
644
|
+
onDone: () => void,
|
|
645
|
+
onAbort: () => void
|
|
646
|
+
) => void
|
|
647
|
+
): void => {
|
|
648
|
+
if (!payload) return;
|
|
649
|
+
if (!runtimeCtx) {
|
|
650
|
+
console.warn(
|
|
651
|
+
`[Studio] ${label} function ${fn.id} needs a runtime context (scheduler); skipping.`
|
|
652
|
+
);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (!runtimeCtx.scheduler.beginSequence(payload.id)) return;
|
|
656
|
+
const finish = () => runtimeCtx.scheduler.endSequence(payload.id);
|
|
657
|
+
run(
|
|
658
|
+
payload,
|
|
659
|
+
{
|
|
660
|
+
sceneNavigator,
|
|
661
|
+
animations,
|
|
662
|
+
onAnimationTrigger,
|
|
663
|
+
onSceneChange,
|
|
664
|
+
runtimeCtx,
|
|
665
|
+
depth,
|
|
666
|
+
},
|
|
667
|
+
finish,
|
|
668
|
+
finish
|
|
669
|
+
);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
if (fn.function_type === "SEQUENCE") {
|
|
673
|
+
runBeginGuarded("SEQUENCE", fn.scene_sequence, (seq, deps, onDone, onAbort) =>
|
|
674
|
+
runSteps(seq.steps, deps, onDone, onAbort)
|
|
47
675
|
);
|
|
48
676
|
return;
|
|
49
677
|
}
|
|
@@ -51,27 +679,41 @@ export function executeFunctionWithRelations(
|
|
|
51
679
|
if (fn.function_type === "NAVIGATION") {
|
|
52
680
|
const nav = fn.scene_navigation;
|
|
53
681
|
if (!nav?.navigate_to || !sceneNavigator) return;
|
|
54
|
-
void navigateToScene(
|
|
682
|
+
void navigateToScene(
|
|
683
|
+
sceneNavigator,
|
|
684
|
+
nav.navigate_to,
|
|
685
|
+
animations,
|
|
686
|
+
onSceneChange,
|
|
687
|
+
runtimeCtx?.variableStore
|
|
688
|
+
);
|
|
55
689
|
} else if (fn.function_type === "ALERT") {
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
690
|
+
const alert = fn.scene_alert;
|
|
691
|
+
if (!alert) return;
|
|
692
|
+
// Fail-soft {{variable}} interpolation: unresolved names stay literal so a
|
|
693
|
+
// stale reference never blanks or suppresses the alert.
|
|
694
|
+
const store = runtimeCtx?.variableStore;
|
|
695
|
+
const fill = (s: string | null): string =>
|
|
696
|
+
s ? interpolateDisplayTemplate(s, (name) => store?.get(name)) : "";
|
|
697
|
+
const title = fill(alert.alert_title);
|
|
698
|
+
const message = fill(alert.alert_message);
|
|
58
699
|
if (isQuest) {
|
|
59
700
|
// Alert.alert shows a 2D panel dialog — invisible in the VR compositor.
|
|
60
701
|
// Log it so it's not silently swallowed; in-scene VR alert UI is a TODO.
|
|
61
702
|
console.warn(
|
|
62
|
-
`[Studio] Alert (Quest — not shown in VR): "${
|
|
703
|
+
`[Studio] Alert (Quest — not shown in VR): "${title}" — ${message}`
|
|
63
704
|
);
|
|
64
705
|
return;
|
|
65
706
|
}
|
|
66
|
-
Alert.alert(
|
|
67
|
-
{ text: "OK", style: "default" },
|
|
68
|
-
]);
|
|
707
|
+
Alert.alert(title || "Alert", message, [{ text: "OK", style: "default" }]);
|
|
69
708
|
} else if (fn.function_type === "ANIMATION") {
|
|
70
709
|
const anim = fn.scene_animation;
|
|
71
710
|
if (!anim || !onAnimationTrigger) return;
|
|
72
711
|
|
|
73
712
|
const animLookupId = fn.animation ?? anim.id;
|
|
74
|
-
const targetAssetId = resolveAnimationTargetAssetId(
|
|
713
|
+
const targetAssetId = resolveAnimationTargetAssetId(
|
|
714
|
+
animLookupId,
|
|
715
|
+
animations
|
|
716
|
+
);
|
|
75
717
|
if (!targetAssetId) {
|
|
76
718
|
console.warn(
|
|
77
719
|
`[Studio] ANIMATION function ${fn.id}: could not resolve target_asset_id for animation ${anim.id}`
|
|
@@ -79,12 +721,107 @@ export function executeFunctionWithRelations(
|
|
|
79
721
|
return;
|
|
80
722
|
}
|
|
81
723
|
onAnimationTrigger(targetAssetId, anim.animation_key);
|
|
724
|
+
} else if (fn.function_type === "SET_VARIABLE") {
|
|
725
|
+
// Failure policy: warn + skip the write, never throw — the sequence continues.
|
|
726
|
+
const sv = fn.scene_set_variable;
|
|
727
|
+
const store = runtimeCtx?.variableStore;
|
|
728
|
+
if (!sv) return;
|
|
729
|
+
if (!store) {
|
|
730
|
+
console.warn(
|
|
731
|
+
`[Studio] SET_VARIABLE function ${fn.id} needs a runtime context (variable store); skipping.`
|
|
732
|
+
);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const parsed = parseExpression(sv.expression);
|
|
736
|
+
if (!parsed.ok) {
|
|
737
|
+
console.warn(
|
|
738
|
+
`[Studio] SET_VARIABLE "${sv.name}": ${parsed.error}; skipping.`
|
|
739
|
+
);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const result = evaluate(parsed.ast, (name) => store.get(name));
|
|
743
|
+
if (!result.ok) {
|
|
744
|
+
console.warn(
|
|
745
|
+
`[Studio] SET_VARIABLE "${sv.name}": ${result.error}; skipping.`
|
|
746
|
+
);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (!valueMatchesType(result.value, sv.type)) {
|
|
750
|
+
console.warn(
|
|
751
|
+
`[Studio] SET_VARIABLE "${sv.name}": result is a ${typeof result.value}, expected ${sv.type}; skipping.`
|
|
752
|
+
);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
store.set(sv.name, result.value);
|
|
756
|
+
} else if (fn.function_type === "BRANCH") {
|
|
757
|
+
runBeginGuarded("BRANCH", fn.scene_branch, (_branch, deps, onDone, onAbort) =>
|
|
758
|
+
runBranch(fn, deps, onDone, onAbort)
|
|
759
|
+
);
|
|
760
|
+
} else if (fn.function_type === "GROUP") {
|
|
761
|
+
runBeginGuarded("GROUP", fn.scene_group, (_group, deps, onDone, onAbort) =>
|
|
762
|
+
runGroup(fn, deps, onDone, onAbort)
|
|
763
|
+
);
|
|
764
|
+
} else if (fn.function_type === "API_REQUEST") {
|
|
765
|
+
runBeginGuarded(
|
|
766
|
+
"API_REQUEST",
|
|
767
|
+
fn.scene_api_request,
|
|
768
|
+
(_request, deps, onDone, onAbort) =>
|
|
769
|
+
runApiRequest(fn, deps, onDone, onAbort)
|
|
770
|
+
);
|
|
771
|
+
} else if (fn.function_type === "SET_VISIBILITY") {
|
|
772
|
+
// Instant show / hide / toggle. Fire-and-forget: as a sequence step it
|
|
773
|
+
// dispatches and the walk advances immediately (no duration to wait on).
|
|
774
|
+
// TOGGLE reads the live runtime value from the store, never the author
|
|
775
|
+
// default. Failure policy: warn + skip, never throw.
|
|
776
|
+
const sv = fn.scene_set_visibility;
|
|
777
|
+
const store = runtimeCtx?.visibilityStore;
|
|
778
|
+
if (!sv) return;
|
|
779
|
+
if (!store) {
|
|
780
|
+
console.warn(
|
|
781
|
+
`[Studio] SET_VISIBILITY function ${fn.id} needs a runtime context (visibility store); skipping.`
|
|
782
|
+
);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
store.apply(sv.target_asset_id, sv.state);
|
|
786
|
+
} else if (fn.function_type === "SOUND") {
|
|
787
|
+
// Non-blocking ACTION: PLAY adds a sound (spatial when a target asset gives
|
|
788
|
+
// a position), STOP removes by clip (null = all). Fire-and-forget like
|
|
789
|
+
// SET_VISIBILITY; the walk advances immediately. Failure policy: warn + skip.
|
|
790
|
+
const s = fn.scene_sound;
|
|
791
|
+
const manager = runtimeCtx?.soundManager;
|
|
792
|
+
if (!s) return;
|
|
793
|
+
if (!manager) {
|
|
794
|
+
console.warn(
|
|
795
|
+
`[Studio] SOUND function ${fn.id} needs a runtime context (sound manager); skipping.`
|
|
796
|
+
);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (s.action === "PLAY") {
|
|
800
|
+
if (!s.audio_url) {
|
|
801
|
+
// Missing or cross-org clip: the resolve RPC nulls the url org-guarded.
|
|
802
|
+
console.warn(
|
|
803
|
+
`[Studio] SOUND function ${fn.id}: PLAY has no audio_url (missing/cross-org clip); skipping.`
|
|
804
|
+
);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const position = s.target_asset_id
|
|
808
|
+
? runtimeCtx?.getAssetPosition?.(s.target_asset_id)
|
|
809
|
+
: undefined;
|
|
810
|
+
manager.play({
|
|
811
|
+
// PLAY always has audio_asset_id per the scene_sounds CHECK constraint.
|
|
812
|
+
audioAssetId: s.audio_asset_id ?? "",
|
|
813
|
+
url: s.audio_url,
|
|
814
|
+
position,
|
|
815
|
+
volume: s.volume,
|
|
816
|
+
loop: s.loop,
|
|
817
|
+
stopOthers: s.stop_other_sounds,
|
|
818
|
+
});
|
|
819
|
+
} else {
|
|
820
|
+
manager.stop(s.audio_asset_id ?? null); // null = all sounds
|
|
821
|
+
}
|
|
82
822
|
}
|
|
83
823
|
}
|
|
84
824
|
|
|
85
|
-
/**
|
|
86
|
-
* Executes the scene's on_load_function if set.
|
|
87
|
-
*/
|
|
88
825
|
export function executeOnLoadFunction(
|
|
89
826
|
functionId: string,
|
|
90
827
|
functions: StudioSceneFunction[],
|
|
@@ -92,13 +829,22 @@ export function executeOnLoadFunction(
|
|
|
92
829
|
animations: StudioAnimation[],
|
|
93
830
|
onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void,
|
|
94
831
|
onSceneChange?: (sceneId: string, sceneName: string) => void,
|
|
832
|
+
runtimeCtx?: SequenceRuntimeContext
|
|
95
833
|
): void {
|
|
96
834
|
const fn = resolveById(functionId, functions);
|
|
97
835
|
if (!fn) {
|
|
98
836
|
console.warn(`[Studio] on_load_function ${functionId} not found.`);
|
|
99
837
|
return;
|
|
100
838
|
}
|
|
101
|
-
executeFunctionWithRelations(
|
|
839
|
+
executeFunctionWithRelations(
|
|
840
|
+
fn,
|
|
841
|
+
sceneNavigator,
|
|
842
|
+
animations,
|
|
843
|
+
onAnimationTrigger,
|
|
844
|
+
0,
|
|
845
|
+
onSceneChange,
|
|
846
|
+
runtimeCtx
|
|
847
|
+
);
|
|
102
848
|
}
|
|
103
849
|
|
|
104
850
|
/**
|
|
@@ -113,6 +859,7 @@ async function navigateToScene(
|
|
|
113
859
|
targetSceneId: string,
|
|
114
860
|
currentAnimations: StudioAnimation[],
|
|
115
861
|
onSceneChange?: (sceneId: string, sceneName: string) => void,
|
|
862
|
+
variableStore?: StudioVariableStore
|
|
116
863
|
): Promise<void> {
|
|
117
864
|
if (!sceneNavigator) {
|
|
118
865
|
console.error("[Studio] SceneNavigator not available for navigation");
|
|
@@ -138,6 +885,9 @@ async function navigateToScene(
|
|
|
138
885
|
passProps: {
|
|
139
886
|
sceneData,
|
|
140
887
|
onSceneChange,
|
|
888
|
+
// The session store rides along on every push so values survive scene
|
|
889
|
+
// transitions for the navigator's whole lifetime.
|
|
890
|
+
variableStore,
|
|
141
891
|
},
|
|
142
892
|
});
|
|
143
893
|
|