@blcklab/anyo-player 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/createAnyoPlayer.d.ts +3 -0
- package/dist/createAnyoPlayer.d.ts.map +1 -0
- package/dist/createAnyoPlayer.js +5 -0
- package/dist/createAnyoPlayer.js.map +1 -0
- package/dist/element-define.d.ts +2 -0
- package/dist/element-define.d.ts.map +1 -0
- package/dist/element-define.js +4 -0
- package/dist/element-define.js.map +1 -0
- package/dist/element.d.ts +66 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +363 -0
- package/dist/element.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/inputBindings.d.ts +3 -0
- package/dist/inputBindings.d.ts.map +1 -0
- package/dist/inputBindings.js +3 -0
- package/dist/inputBindings.js.map +1 -0
- package/dist/internal/AccessibilityController.d.ts +50 -0
- package/dist/internal/AccessibilityController.d.ts.map +1 -0
- package/dist/internal/AccessibilityController.js +212 -0
- package/dist/internal/AccessibilityController.js.map +1 -0
- package/dist/internal/AnyoPlayerCore.d.ts +357 -0
- package/dist/internal/AnyoPlayerCore.d.ts.map +1 -0
- package/dist/internal/AnyoPlayerCore.js +3880 -0
- package/dist/internal/AnyoPlayerCore.js.map +1 -0
- package/dist/internal/AudioController.d.ts +37 -0
- package/dist/internal/AudioController.d.ts.map +1 -0
- package/dist/internal/AudioController.js +167 -0
- package/dist/internal/AudioController.js.map +1 -0
- package/dist/internal/CaptionController.d.ts +16 -0
- package/dist/internal/CaptionController.d.ts.map +1 -0
- package/dist/internal/CaptionController.js +83 -0
- package/dist/internal/CaptionController.js.map +1 -0
- package/dist/internal/ContainerHost.d.ts +17 -0
- package/dist/internal/ContainerHost.d.ts.map +1 -0
- package/dist/internal/ContainerHost.js +110 -0
- package/dist/internal/ContainerHost.js.map +1 -0
- package/dist/internal/DesktopInputController.d.ts +80 -0
- package/dist/internal/DesktopInputController.d.ts.map +1 -0
- package/dist/internal/DesktopInputController.js +367 -0
- package/dist/internal/DesktopInputController.js.map +1 -0
- package/dist/internal/EmbeddingController.d.ts +33 -0
- package/dist/internal/EmbeddingController.d.ts.map +1 -0
- package/dist/internal/EmbeddingController.js +161 -0
- package/dist/internal/EmbeddingController.js.map +1 -0
- package/dist/internal/EventHub.d.ts +7 -0
- package/dist/internal/EventHub.d.ts.map +1 -0
- package/dist/internal/EventHub.js +27 -0
- package/dist/internal/EventHub.js.map +1 -0
- package/dist/internal/FrameworkAdapterController.d.ts +46 -0
- package/dist/internal/FrameworkAdapterController.d.ts.map +1 -0
- package/dist/internal/FrameworkAdapterController.js +165 -0
- package/dist/internal/FrameworkAdapterController.js.map +1 -0
- package/dist/internal/FullscreenController.d.ts +29 -0
- package/dist/internal/FullscreenController.d.ts.map +1 -0
- package/dist/internal/FullscreenController.js +122 -0
- package/dist/internal/FullscreenController.js.map +1 -0
- package/dist/internal/GamepadController.d.ts +54 -0
- package/dist/internal/GamepadController.d.ts.map +1 -0
- package/dist/internal/GamepadController.js +276 -0
- package/dist/internal/GamepadController.js.map +1 -0
- package/dist/internal/InputBindingsController.d.ts +34 -0
- package/dist/internal/InputBindingsController.d.ts.map +1 -0
- package/dist/internal/InputBindingsController.js +223 -0
- package/dist/internal/InputBindingsController.js.map +1 -0
- package/dist/internal/InteractionPresentationController.d.ts +38 -0
- package/dist/internal/InteractionPresentationController.d.ts.map +1 -0
- package/dist/internal/InteractionPresentationController.js +208 -0
- package/dist/internal/InteractionPresentationController.js.map +1 -0
- package/dist/internal/MapController.d.ts +16 -0
- package/dist/internal/MapController.d.ts.map +1 -0
- package/dist/internal/MapController.js +68 -0
- package/dist/internal/MapController.js.map +1 -0
- package/dist/internal/PerformanceController.d.ts +94 -0
- package/dist/internal/PerformanceController.d.ts.map +1 -0
- package/dist/internal/PerformanceController.js +455 -0
- package/dist/internal/PerformanceController.js.map +1 -0
- package/dist/internal/PlayerCameraController.d.ts +79 -0
- package/dist/internal/PlayerCameraController.d.ts.map +1 -0
- package/dist/internal/PlayerCameraController.js +551 -0
- package/dist/internal/PlayerCameraController.js.map +1 -0
- package/dist/internal/ResponsiveController.d.ts +37 -0
- package/dist/internal/ResponsiveController.d.ts.map +1 -0
- package/dist/internal/ResponsiveController.js +143 -0
- package/dist/internal/ResponsiveController.js.map +1 -0
- package/dist/internal/RuntimeFactory.d.ts +42 -0
- package/dist/internal/RuntimeFactory.d.ts.map +1 -0
- package/dist/internal/RuntimeFactory.js +93 -0
- package/dist/internal/RuntimeFactory.js.map +1 -0
- package/dist/internal/ScreenshotController.d.ts +8 -0
- package/dist/internal/ScreenshotController.d.ts.map +1 -0
- package/dist/internal/ScreenshotController.js +47 -0
- package/dist/internal/ScreenshotController.js.map +1 -0
- package/dist/internal/SessionController.d.ts +44 -0
- package/dist/internal/SessionController.d.ts.map +1 -0
- package/dist/internal/SessionController.js +411 -0
- package/dist/internal/SessionController.js.map +1 -0
- package/dist/internal/SourceResolver.d.ts +31 -0
- package/dist/internal/SourceResolver.d.ts.map +1 -0
- package/dist/internal/SourceResolver.js +362 -0
- package/dist/internal/SourceResolver.js.map +1 -0
- package/dist/internal/StateMachine.d.ts +10 -0
- package/dist/internal/StateMachine.d.ts.map +1 -0
- package/dist/internal/StateMachine.js +35 -0
- package/dist/internal/StateMachine.js.map +1 -0
- package/dist/internal/StyleController.d.ts +14 -0
- package/dist/internal/StyleController.d.ts.map +1 -0
- package/dist/internal/StyleController.js +116 -0
- package/dist/internal/StyleController.js.map +1 -0
- package/dist/internal/TelemetryController.d.ts +28 -0
- package/dist/internal/TelemetryController.d.ts.map +1 -0
- package/dist/internal/TelemetryController.js +113 -0
- package/dist/internal/TelemetryController.js.map +1 -0
- package/dist/internal/TouchInputController.d.ts +73 -0
- package/dist/internal/TouchInputController.d.ts.map +1 -0
- package/dist/internal/TouchInputController.js +379 -0
- package/dist/internal/TouchInputController.js.map +1 -0
- package/dist/internal/VfxController.d.ts +11 -0
- package/dist/internal/VfxController.d.ts.map +1 -0
- package/dist/internal/VfxController.js +23 -0
- package/dist/internal/VfxController.js.map +1 -0
- package/dist/internal/ViewPreferenceController.d.ts +40 -0
- package/dist/internal/ViewPreferenceController.d.ts.map +1 -0
- package/dist/internal/ViewPreferenceController.js +132 -0
- package/dist/internal/ViewPreferenceController.js.map +1 -0
- package/dist/internal/VisibilityController.d.ts +26 -0
- package/dist/internal/VisibilityController.d.ts.map +1 -0
- package/dist/internal/VisibilityController.js +62 -0
- package/dist/internal/VisibilityController.js.map +1 -0
- package/dist/internal/XRController.d.ts +42 -0
- package/dist/internal/XRController.d.ts.map +1 -0
- package/dist/internal/XRController.js +169 -0
- package/dist/internal/XRController.js.map +1 -0
- package/dist/internal/sourceUrls.d.ts +6 -0
- package/dist/internal/sourceUrls.d.ts.map +1 -0
- package/dist/internal/sourceUrls.js +79 -0
- package/dist/internal/sourceUrls.js.map +1 -0
- package/dist/react.d.ts +44 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +115 -0
- package/dist/react.js.map +1 -0
- package/dist/session.d.ts +3 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +3 -0
- package/dist/session.js.map +1 -0
- package/dist/styleText.d.ts +2 -0
- package/dist/styleText.d.ts.map +1 -0
- package/dist/styleText.js +2 -0
- package/dist/styleText.js.map +1 -0
- package/dist/styles.css +573 -0
- package/dist/types.d.ts +1476 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/PlayerUI.d.ts +193 -0
- package/dist/ui/PlayerUI.d.ts.map +1 -0
- package/dist/ui/PlayerUI.js +1201 -0
- package/dist/ui/PlayerUI.js.map +1 -0
- package/dist/vue.d.ts +97 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +126 -0
- package/dist/vue.js.map +1 -0
- package/dist/worldCache.d.ts +16 -0
- package/dist/worldCache.d.ts.map +1 -0
- package/dist/worldCache.js +46 -0
- package/dist/worldCache.js.map +1 -0
- package/dist/worldSources.d.ts +19 -0
- package/dist/worldSources.d.ts.map +1 -0
- package/dist/worldSources.js +51 -0
- package/dist/worldSources.js.map +1 -0
- package/package.json +114 -0
|
@@ -0,0 +1,3880 @@
|
|
|
1
|
+
import { hashWorldDocument } from '@blcklab/anyo';
|
|
2
|
+
import { createWebSurfaceAppRegistry, } from '@blcklab/anyo/web-surface';
|
|
3
|
+
import { AnyoPlayerError, toPlayerError } from '../errors.js';
|
|
4
|
+
import { ContainerHost } from './ContainerHost.js';
|
|
5
|
+
import { VfxController } from './VfxController.js';
|
|
6
|
+
import { MapController } from './MapController.js';
|
|
7
|
+
import { DEFAULT_DESKTOP_KEYS, DesktopInputController, } from './DesktopInputController.js';
|
|
8
|
+
import { EventHub } from './EventHub.js';
|
|
9
|
+
import { DefaultRuntimeFactory, } from './RuntimeFactory.js';
|
|
10
|
+
import { SourceResolver } from './SourceResolver.js';
|
|
11
|
+
import { StateMachine } from './StateMachine.js';
|
|
12
|
+
import { StyleController } from './StyleController.js';
|
|
13
|
+
import { ResponsiveController } from './ResponsiveController.js';
|
|
14
|
+
import { FullscreenController } from './FullscreenController.js';
|
|
15
|
+
import { VisibilityController } from './VisibilityController.js';
|
|
16
|
+
import { DEFAULT_UI_LABELS, createPlayerUI } from '../ui/PlayerUI.js';
|
|
17
|
+
import { TouchInputController, detectTouchCapability, } from './TouchInputController.js';
|
|
18
|
+
import { XRController } from './XRController.js';
|
|
19
|
+
import { SessionController } from './SessionController.js';
|
|
20
|
+
import { InteractionPresentationController } from './InteractionPresentationController.js';
|
|
21
|
+
import { InputBindingsController, cloneInputBindingMap } from './InputBindingsController.js';
|
|
22
|
+
import { GamepadController } from './GamepadController.js';
|
|
23
|
+
import { AccessibilityController } from './AccessibilityController.js';
|
|
24
|
+
import { AudioController } from './AudioController.js';
|
|
25
|
+
import { ScreenshotController } from './ScreenshotController.js';
|
|
26
|
+
import { TelemetryController } from './TelemetryController.js';
|
|
27
|
+
import { EmbeddingController } from './EmbeddingController.js';
|
|
28
|
+
import { PerformanceController } from './PerformanceController.js';
|
|
29
|
+
import { ViewPreferenceController } from './ViewPreferenceController.js';
|
|
30
|
+
import { CaptionController } from './CaptionController.js';
|
|
31
|
+
const TERMINAL_PROGRESS = {
|
|
32
|
+
queued: 0,
|
|
33
|
+
loading: 0,
|
|
34
|
+
loaded: 0,
|
|
35
|
+
failed: 0,
|
|
36
|
+
total: 0,
|
|
37
|
+
ratio: 1,
|
|
38
|
+
};
|
|
39
|
+
function samePlayerSource(left, right) {
|
|
40
|
+
if (left === right)
|
|
41
|
+
return true;
|
|
42
|
+
if (typeof left === 'string' && typeof right === 'string')
|
|
43
|
+
return left === right;
|
|
44
|
+
if (left instanceof URL && right instanceof URL)
|
|
45
|
+
return left.href === right.href;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function cloneResolvedSource(source) {
|
|
49
|
+
const info = source.info ?? {
|
|
50
|
+
kind: 'document',
|
|
51
|
+
url: source.documentUrl ?? null,
|
|
52
|
+
bytes: null,
|
|
53
|
+
cache: 'none',
|
|
54
|
+
integrity: 'not-requested',
|
|
55
|
+
migratedFrom: null,
|
|
56
|
+
documentVersion: source.document.version,
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
document: structuredClone(source.document),
|
|
60
|
+
...(source.documentUrl === undefined ? {} : { documentUrl: source.documentUrl }),
|
|
61
|
+
info: structuredClone(info),
|
|
62
|
+
...(source.cleanup ? { cleanup: source.cleanup } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isWorldDefinition(value) {
|
|
66
|
+
return Boolean(value && typeof value === 'object' && 'source' in value && !('version' in value));
|
|
67
|
+
}
|
|
68
|
+
function normalizeTransitionOptions(value, fallback) {
|
|
69
|
+
if (value === false)
|
|
70
|
+
return { presentation: 'none', minimumDuration: 0 };
|
|
71
|
+
const minimumDuration = value?.minimumDuration ?? fallback?.minimumDuration ?? 160;
|
|
72
|
+
if (!Number.isFinite(minimumDuration) || minimumDuration < 0 || minimumDuration > 60_000) {
|
|
73
|
+
throw new TypeError('navigation.transition.minimumDuration must be between 0 and 60000 milliseconds.');
|
|
74
|
+
}
|
|
75
|
+
const presentation = value?.presentation ?? fallback?.presentation ?? 'fade';
|
|
76
|
+
if (!['none', 'fade'].includes(presentation)) {
|
|
77
|
+
throw new TypeError('navigation.transition.presentation must be "none" or "fade".');
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
presentation,
|
|
81
|
+
minimumDuration,
|
|
82
|
+
...(value?.beforeSwitch ?? fallback?.beforeSwitch
|
|
83
|
+
? { beforeSwitch: value?.beforeSwitch ?? fallback?.beforeSwitch }
|
|
84
|
+
: {}),
|
|
85
|
+
...(value?.afterSwitch ?? fallback?.afterSwitch
|
|
86
|
+
? { afterSwitch: value?.afterSwitch ?? fallback?.afterSwitch }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function normalizeWorldDefinition(rawId, rawDefinition) {
|
|
91
|
+
const id = rawId.trim();
|
|
92
|
+
if (!id)
|
|
93
|
+
throw new TypeError('navigation world identifiers must not be empty.');
|
|
94
|
+
const definition = isWorldDefinition(rawDefinition) ? rawDefinition : { source: rawDefinition };
|
|
95
|
+
if (definition.source === undefined || definition.source === null)
|
|
96
|
+
throw new TypeError(`navigation world "${id}" source is required.`);
|
|
97
|
+
return { id, source: definition.source, label: definition.label?.trim() || null, metadata: definition.metadata ? structuredClone(definition.metadata) : null };
|
|
98
|
+
}
|
|
99
|
+
function normalizeNavigationOptions(value) {
|
|
100
|
+
if (value === false || value === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
if (!value.worlds || typeof value.worlds !== 'object' || Array.isArray(value.worlds)) {
|
|
103
|
+
throw new TypeError('navigation.worlds must be an object map of world identifiers to sources.');
|
|
104
|
+
}
|
|
105
|
+
const worlds = new Map();
|
|
106
|
+
for (const [rawId, rawDefinition] of Object.entries(value.worlds)) {
|
|
107
|
+
const definition = normalizeWorldDefinition(rawId, rawDefinition);
|
|
108
|
+
worlds.set(definition.id, definition);
|
|
109
|
+
}
|
|
110
|
+
if (worlds.size === 0)
|
|
111
|
+
throw new TypeError('navigation.worlds must define at least one world.');
|
|
112
|
+
const initialWorld = value.initialWorld?.trim() || null;
|
|
113
|
+
if (initialWorld && !worlds.has(initialWorld)) {
|
|
114
|
+
throw new TypeError(`navigation.initialWorld references unknown world "${initialWorld}".`);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
worlds,
|
|
118
|
+
initialWorld,
|
|
119
|
+
transition: normalizeTransitionOptions(value.transition),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function cloneNavigationStatus(status) {
|
|
123
|
+
return { ...status };
|
|
124
|
+
}
|
|
125
|
+
function waitForMinimumDuration(startedAt, minimumDuration) {
|
|
126
|
+
const remaining = minimumDuration - (Date.now() - startedAt);
|
|
127
|
+
return remaining > 0 ? new Promise(resolve => setTimeout(resolve, remaining)) : Promise.resolve();
|
|
128
|
+
}
|
|
129
|
+
const HIDDEN_INTERACTION_PROMPT = {
|
|
130
|
+
visible: false,
|
|
131
|
+
text: '',
|
|
132
|
+
title: null,
|
|
133
|
+
description: null,
|
|
134
|
+
actionLabel: null,
|
|
135
|
+
inputHint: null,
|
|
136
|
+
ariaLabel: null,
|
|
137
|
+
actionAvailable: false,
|
|
138
|
+
trigger: null,
|
|
139
|
+
entityId: null,
|
|
140
|
+
primitiveId: null,
|
|
141
|
+
};
|
|
142
|
+
const EMPTY_INTERACTION_TARGET = {
|
|
143
|
+
available: false,
|
|
144
|
+
entityId: null,
|
|
145
|
+
primitiveId: null,
|
|
146
|
+
instanceId: null,
|
|
147
|
+
distance: null,
|
|
148
|
+
source: null,
|
|
149
|
+
};
|
|
150
|
+
const HIDDEN_RETICLE = {
|
|
151
|
+
visible: false,
|
|
152
|
+
active: false,
|
|
153
|
+
};
|
|
154
|
+
function asVROptions(value) {
|
|
155
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
156
|
+
}
|
|
157
|
+
function asReticleOptions(value) {
|
|
158
|
+
if (value === false)
|
|
159
|
+
return { enabled: false };
|
|
160
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
161
|
+
}
|
|
162
|
+
function asTouchOptions(value) {
|
|
163
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
164
|
+
}
|
|
165
|
+
function isRecord(value) {
|
|
166
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
167
|
+
}
|
|
168
|
+
function boundedNumber(value, fallback, minimum, maximum, name) {
|
|
169
|
+
if (value === undefined)
|
|
170
|
+
return fallback;
|
|
171
|
+
if (!Number.isFinite(value))
|
|
172
|
+
throw new TypeError(`${name} must be a finite number.`);
|
|
173
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
174
|
+
}
|
|
175
|
+
function normalizeRendererRecoveryOptions(value) {
|
|
176
|
+
if (value === false) {
|
|
177
|
+
return { enabled: false, automatic: false, maxAttempts: 0, delayMs: 0, backoff: 1, restoreSession: false, fallbackBackend: false, fallbackAfterAttempt: 1 };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
enabled: value?.enabled !== false,
|
|
181
|
+
automatic: value?.automatic === true,
|
|
182
|
+
maxAttempts: Math.trunc(boundedNumber(value?.maxAttempts, 2, 1, 10, 'rendererRecovery.maxAttempts')),
|
|
183
|
+
delayMs: Math.trunc(boundedNumber(value?.delayMs, 250, 0, 60_000, 'rendererRecovery.delayMs')),
|
|
184
|
+
backoff: boundedNumber(value?.backoff, 2, 1, 10, 'rendererRecovery.backoff'),
|
|
185
|
+
restoreSession: value?.restoreSession !== false,
|
|
186
|
+
fallbackBackend: value?.fallbackBackend ?? false,
|
|
187
|
+
fallbackAfterAttempt: Math.trunc(boundedNumber(value?.fallbackAfterAttempt, 1, 1, 10, 'rendererRecovery.fallbackAfterAttempt')),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function cloneRecoveryStatus(status) {
|
|
191
|
+
return { ...status };
|
|
192
|
+
}
|
|
193
|
+
function mergeKeyBindings(overrides) {
|
|
194
|
+
return {
|
|
195
|
+
forward: overrides?.forward ?? DEFAULT_DESKTOP_KEYS.forward,
|
|
196
|
+
backward: overrides?.backward ?? DEFAULT_DESKTOP_KEYS.backward,
|
|
197
|
+
left: overrides?.left ?? DEFAULT_DESKTOP_KEYS.left,
|
|
198
|
+
right: overrides?.right ?? DEFAULT_DESKTOP_KEYS.right,
|
|
199
|
+
run: overrides?.run ?? DEFAULT_DESKTOP_KEYS.run,
|
|
200
|
+
jump: overrides?.jump ?? DEFAULT_DESKTOP_KEYS.jump,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function keyboardBindings(codes) {
|
|
204
|
+
return codes.map(code => ({ device: 'keyboard', code }));
|
|
205
|
+
}
|
|
206
|
+
function createDefaultInputBindings(options) {
|
|
207
|
+
const keys = mergeKeyBindings(options.exploration?.keys);
|
|
208
|
+
const interactKeys = options.interaction === false
|
|
209
|
+
? ['KeyE']
|
|
210
|
+
: options.interaction?.activateKeys ?? ['KeyE'];
|
|
211
|
+
return {
|
|
212
|
+
'move-forward': [...keyboardBindings(keys.forward), { device: 'gamepad-axis', axis: 1, direction: -1 }],
|
|
213
|
+
'move-backward': [...keyboardBindings(keys.backward), { device: 'gamepad-axis', axis: 1, direction: 1 }],
|
|
214
|
+
'move-left': [...keyboardBindings(keys.left), { device: 'gamepad-axis', axis: 0, direction: -1 }],
|
|
215
|
+
'move-right': [...keyboardBindings(keys.right), { device: 'gamepad-axis', axis: 0, direction: 1 }],
|
|
216
|
+
'look-up': [{ device: 'gamepad-axis', axis: 3, direction: -1 }],
|
|
217
|
+
'look-down': [{ device: 'gamepad-axis', axis: 3, direction: 1 }],
|
|
218
|
+
'look-left': [{ device: 'gamepad-axis', axis: 2, direction: -1 }],
|
|
219
|
+
'look-right': [{ device: 'gamepad-axis', axis: 2, direction: 1 }],
|
|
220
|
+
run: [...keyboardBindings(keys.run), { device: 'gamepad-button', button: 10 }],
|
|
221
|
+
jump: [...keyboardBindings(keys.jump), { device: 'gamepad-button', button: 0 }],
|
|
222
|
+
interact: [...keyboardBindings(interactKeys), { device: 'gamepad-button', button: 2 }],
|
|
223
|
+
pause: [{ device: 'keyboard', code: 'KeyP' }, { device: 'gamepad-button', button: 9 }],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function keyboardCodes(bindings, action) {
|
|
227
|
+
return bindings[action]
|
|
228
|
+
.filter((binding) => binding.device === 'keyboard')
|
|
229
|
+
.map(binding => binding.code);
|
|
230
|
+
}
|
|
231
|
+
function describeInputBinding(binding) {
|
|
232
|
+
if (!binding)
|
|
233
|
+
return null;
|
|
234
|
+
if (binding.device === 'keyboard') {
|
|
235
|
+
if (binding.code.startsWith('Key'))
|
|
236
|
+
return binding.code.slice(3);
|
|
237
|
+
if (binding.code.startsWith('Digit'))
|
|
238
|
+
return binding.code.slice(5);
|
|
239
|
+
return binding.code;
|
|
240
|
+
}
|
|
241
|
+
if (binding.device === 'gamepad-button') {
|
|
242
|
+
const labels = {
|
|
243
|
+
0: 'A / Cross', 1: 'B / Circle', 2: 'X / Square', 3: 'Y / Triangle',
|
|
244
|
+
8: 'View', 9: 'Menu', 10: 'Left stick', 11: 'Right stick',
|
|
245
|
+
};
|
|
246
|
+
return labels[binding.button] ?? `Button ${binding.button}`;
|
|
247
|
+
}
|
|
248
|
+
return `Axis ${binding.axis} ${binding.direction > 0 ? '+' : '-'}`;
|
|
249
|
+
}
|
|
250
|
+
async function waitForPendingPreload(preload, signal) {
|
|
251
|
+
if (signal.aborted)
|
|
252
|
+
throw signal.reason;
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
const handleAbort = () => reject(signal.reason);
|
|
255
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
256
|
+
preload.then(value => {
|
|
257
|
+
signal.removeEventListener('abort', handleAbort);
|
|
258
|
+
resolve(value);
|
|
259
|
+
}, error => {
|
|
260
|
+
signal.removeEventListener('abort', handleAbort);
|
|
261
|
+
reject(error);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
export class AnyoPlayerCore {
|
|
266
|
+
container;
|
|
267
|
+
canvas;
|
|
268
|
+
options;
|
|
269
|
+
host;
|
|
270
|
+
runtimeFactory;
|
|
271
|
+
sourceResolver;
|
|
272
|
+
webSurfaceRegistryValue;
|
|
273
|
+
webSurfaceRuntimeOptions;
|
|
274
|
+
webSurfaceRegistrationCleanups;
|
|
275
|
+
stateMachine = new StateMachine();
|
|
276
|
+
events = new EventHub();
|
|
277
|
+
actions = new Map();
|
|
278
|
+
ui;
|
|
279
|
+
styleController;
|
|
280
|
+
desktopInput;
|
|
281
|
+
touchInput;
|
|
282
|
+
inputBindingsController;
|
|
283
|
+
gamepadInput;
|
|
284
|
+
desktopEnabled;
|
|
285
|
+
touchEnabledValue;
|
|
286
|
+
responsive;
|
|
287
|
+
fullscreenController;
|
|
288
|
+
visibilityController;
|
|
289
|
+
embeddingController;
|
|
290
|
+
xrController;
|
|
291
|
+
vrEnabledValue;
|
|
292
|
+
sessionController;
|
|
293
|
+
interactionPresentation;
|
|
294
|
+
accessibilityController;
|
|
295
|
+
audioController;
|
|
296
|
+
captionController;
|
|
297
|
+
vfxController = new VfxController();
|
|
298
|
+
mapController;
|
|
299
|
+
screenshotController;
|
|
300
|
+
telemetryController;
|
|
301
|
+
performanceController;
|
|
302
|
+
viewPreferenceController;
|
|
303
|
+
rendererRecoveryOptions;
|
|
304
|
+
diagnosticHistoryLimit;
|
|
305
|
+
now;
|
|
306
|
+
reticleMode;
|
|
307
|
+
navigationOptions;
|
|
308
|
+
worldPreloadCache = new Map();
|
|
309
|
+
worldPreloadTasks = new Map();
|
|
310
|
+
activeRuntime = null;
|
|
311
|
+
pendingRuntime = null;
|
|
312
|
+
activeDocument = null;
|
|
313
|
+
progressValue = { ...TERMINAL_PROGRESS };
|
|
314
|
+
errorValue = null;
|
|
315
|
+
phaseValue = 'idle';
|
|
316
|
+
readyStatusValue = null;
|
|
317
|
+
lastSource;
|
|
318
|
+
currentAbort = null;
|
|
319
|
+
operationId = 0;
|
|
320
|
+
operationTail = Promise.resolve();
|
|
321
|
+
disposalPromise = null;
|
|
322
|
+
pauseReasonValue = null;
|
|
323
|
+
resumeInputModeAfterPause = null;
|
|
324
|
+
inputModeValue = null;
|
|
325
|
+
interactionPromptValue = { ...HIDDEN_INTERACTION_PROMPT };
|
|
326
|
+
interactionTargetValue = { ...EMPTY_INTERACTION_TARGET };
|
|
327
|
+
reticleValue = { ...HIDDEN_RETICLE };
|
|
328
|
+
reticleInteractionContext = null;
|
|
329
|
+
interactionPromptTimer = null;
|
|
330
|
+
hoveredInteractionEntity = null;
|
|
331
|
+
hoveredInteractionPayload = null;
|
|
332
|
+
suppressInputExit = false;
|
|
333
|
+
xrExitRequested = false;
|
|
334
|
+
replacementContext = null;
|
|
335
|
+
replacementStage = 'idle';
|
|
336
|
+
sessionRestoreActive = false;
|
|
337
|
+
inputBindingsRestoreAttempted = false;
|
|
338
|
+
diagnosticsValue = [];
|
|
339
|
+
diagnosticSequence = 0;
|
|
340
|
+
rendererRecoveryValue;
|
|
341
|
+
fallRecoveryValue = { state: 'idle', attempt: 0, maxAttempts: 3, position: null, message: null };
|
|
342
|
+
rendererRecoveryTimer = null;
|
|
343
|
+
rendererRecoveryPromise = null;
|
|
344
|
+
rendererRecoverySnapshot = null;
|
|
345
|
+
activatedValue = false;
|
|
346
|
+
preloadedSource = null;
|
|
347
|
+
pendingPreload = null;
|
|
348
|
+
currentWorldIdValue = null;
|
|
349
|
+
sourceInfoValue = null;
|
|
350
|
+
activeSourceCleanup = null;
|
|
351
|
+
performancePreferenceRestoreAttempted = false;
|
|
352
|
+
viewPreferenceRestoreAttempted = false;
|
|
353
|
+
recoveryRendererBackendOverride = null;
|
|
354
|
+
navigationValue = {
|
|
355
|
+
state: 'idle',
|
|
356
|
+
fromWorldId: null,
|
|
357
|
+
toWorldId: null,
|
|
358
|
+
error: null,
|
|
359
|
+
};
|
|
360
|
+
navigationAbort = null;
|
|
361
|
+
navigationCommitted = false;
|
|
362
|
+
navigationSequence = 0;
|
|
363
|
+
constructor(options, dependencies = {}) {
|
|
364
|
+
this.options = options;
|
|
365
|
+
this.now = dependencies.now ?? (() => new Date());
|
|
366
|
+
this.navigationOptions = normalizeNavigationOptions(options.navigation);
|
|
367
|
+
this.rendererRecoveryOptions = normalizeRendererRecoveryOptions(options.rendererRecovery);
|
|
368
|
+
const diagnosticLimit = options.diagnostics === false ? 0 : options.diagnostics?.historyLimit ?? 50;
|
|
369
|
+
if (!Number.isFinite(diagnosticLimit))
|
|
370
|
+
throw new TypeError('diagnostics.historyLimit must be a finite number.');
|
|
371
|
+
this.diagnosticHistoryLimit = Math.min(1000, Math.max(0, Math.trunc(diagnosticLimit)));
|
|
372
|
+
const configuredWebSurface = options.webSurface === false ? null : (options.webSurface ?? {});
|
|
373
|
+
if (!configuredWebSurface) {
|
|
374
|
+
this.webSurfaceRegistryValue = null;
|
|
375
|
+
this.webSurfaceRuntimeOptions = false;
|
|
376
|
+
this.webSurfaceRegistrationCleanups = [];
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const registry = configuredWebSurface.registry ?? createWebSurfaceAppRegistry();
|
|
380
|
+
const cleanups = [];
|
|
381
|
+
try {
|
|
382
|
+
for (const [id, app] of Object.entries(configuredWebSurface.apps ?? {})) {
|
|
383
|
+
cleanups.push(registry.register(id, app));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
for (const cleanup of cleanups.reverse())
|
|
388
|
+
cleanup();
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
this.webSurfaceRegistryValue = registry;
|
|
392
|
+
this.webSurfaceRegistrationCleanups = cleanups;
|
|
393
|
+
this.webSurfaceRuntimeOptions = {
|
|
394
|
+
registry,
|
|
395
|
+
...(configuredWebSurface.root === undefined ? {} : { root: configuredWebSurface.root }),
|
|
396
|
+
...(configuredWebSurface.zIndex === undefined ? {} : { zIndex: configuredWebSurface.zIndex }),
|
|
397
|
+
...(configuredWebSurface.externalUrls === undefined ? {} : { externalUrls: configuredWebSurface.externalUrls }),
|
|
398
|
+
onDiagnostic: diagnostic => this.handleWebSurfaceDiagnostic(diagnostic, configuredWebSurface.onDiagnostic),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
this.rendererRecoveryValue = {
|
|
402
|
+
state: 'idle',
|
|
403
|
+
attempt: 0,
|
|
404
|
+
maxAttempts: this.rendererRecoveryOptions.maxAttempts,
|
|
405
|
+
automatic: false,
|
|
406
|
+
diagnosticCode: null,
|
|
407
|
+
error: null,
|
|
408
|
+
};
|
|
409
|
+
this.desktopEnabled = options.exploration?.desktop !== false;
|
|
410
|
+
this.vrEnabledValue = options.exploration?.vr === true || typeof options.exploration?.vr === 'object';
|
|
411
|
+
this.host = new ContainerHost(options.container, options.canvas, options.ariaLabel ?? 'Interactive Anyo world');
|
|
412
|
+
this.container = this.host.container;
|
|
413
|
+
this.canvas = this.host.canvas;
|
|
414
|
+
const resolvedUiOptions = options.ui === false ? undefined : options.ui;
|
|
415
|
+
this.styleController = new StyleController(this.container, resolvedUiOptions?.styles, resolvedUiOptions?.theme);
|
|
416
|
+
this.sessionController = new SessionController(this.container, options.session, {
|
|
417
|
+
...(dependencies.sessionStorage ? { storage: dependencies.sessionStorage } : {}),
|
|
418
|
+
now: this.now,
|
|
419
|
+
});
|
|
420
|
+
this.inputBindingsController = new InputBindingsController(this.container, createDefaultInputBindings(options), options.input, dependencies.inputStorage ? { storage: dependencies.inputStorage } : {});
|
|
421
|
+
const viewOptions = options.view === false
|
|
422
|
+
? false
|
|
423
|
+
: {
|
|
424
|
+
...(options.view ?? {}),
|
|
425
|
+
...(options.view?.fieldOfView === undefined && options.renderer?.fieldOfView !== undefined
|
|
426
|
+
? { fieldOfView: options.renderer.fieldOfView }
|
|
427
|
+
: {}),
|
|
428
|
+
};
|
|
429
|
+
this.viewPreferenceController = new ViewPreferenceController(this.container, viewOptions, dependencies.viewStorage ? { storage: dependencies.viewStorage } : {});
|
|
430
|
+
this.accessibilityController = new AccessibilityController(this.container, this.canvas, options.accessibility, {
|
|
431
|
+
onReducedMotionChange: (previous, reducedMotion, preference) => {
|
|
432
|
+
this.events.emit('reducedmotionchange', { previous, reducedMotion, preference });
|
|
433
|
+
},
|
|
434
|
+
onInputModalityChange: (previous, modality) => {
|
|
435
|
+
this.events.emit('inputmodalitychange', { previous, modality });
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
this.captionController = new CaptionController(this.now, (previous, caption) => {
|
|
439
|
+
this.ui?.setCaption?.(caption);
|
|
440
|
+
this.events.emit('captionchange', { previous, caption });
|
|
441
|
+
});
|
|
442
|
+
this.audioController = new AudioController(options.audio, {
|
|
443
|
+
onChange: (previousMuted, state, source) => {
|
|
444
|
+
this.ui?.setAudioState?.(state);
|
|
445
|
+
this.events.emit('audiochange', { ...state, previousMuted, source });
|
|
446
|
+
},
|
|
447
|
+
onUnlocked: state => {
|
|
448
|
+
this.ui?.setAudioState?.(state);
|
|
449
|
+
this.events.emit('audiounlocked', state);
|
|
450
|
+
},
|
|
451
|
+
onError: error => {
|
|
452
|
+
this.ui?.setControlError?.(error);
|
|
453
|
+
this.events.emit('audioerror', error);
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
this.mapController = new MapController(text => this.ui.setMapAttribution(text));
|
|
457
|
+
this.screenshotController = new ScreenshotController(this.now);
|
|
458
|
+
const touchOption = options.exploration?.touch;
|
|
459
|
+
this.touchEnabledValue = touchOption === false
|
|
460
|
+
? false
|
|
461
|
+
: touchOption === true || typeof touchOption === 'object' || detectTouchCapability(this.container);
|
|
462
|
+
this.runtimeFactory = dependencies.runtimeFactory ?? new DefaultRuntimeFactory();
|
|
463
|
+
this.sourceResolver = dependencies.sourceResolver ?? new SourceResolver({
|
|
464
|
+
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
|
|
465
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
466
|
+
...(options.loading === undefined ? {} : { loading: options.loading }),
|
|
467
|
+
});
|
|
468
|
+
const labels = {
|
|
469
|
+
...DEFAULT_UI_LABELS,
|
|
470
|
+
...(options.ui === false ? {} : options.ui?.labels),
|
|
471
|
+
};
|
|
472
|
+
this.touchInput = new TouchInputController(this.container, this.canvas, {
|
|
473
|
+
enabled: this.touchEnabledValue,
|
|
474
|
+
showControls: options.ui !== false && options.ui?.showTouchControls !== false,
|
|
475
|
+
moveLabel: labels.touchMove,
|
|
476
|
+
runLabel: labels.touchRun,
|
|
477
|
+
jumpLabel: labels.touchJump,
|
|
478
|
+
options: asTouchOptions(touchOption),
|
|
479
|
+
}, {
|
|
480
|
+
onEnterRequest: () => {
|
|
481
|
+
this.accessibilityController.setInputModality('touch');
|
|
482
|
+
return this.enterTouchFromGesture();
|
|
483
|
+
},
|
|
484
|
+
onTap: (clientX, clientY) => this.handleTouchTap(clientX, clientY),
|
|
485
|
+
});
|
|
486
|
+
this.desktopInput = new DesktopInputController(this.canvas, {
|
|
487
|
+
onEnterRequest: source => {
|
|
488
|
+
this.accessibilityController.setInputModality(source);
|
|
489
|
+
this.enterFromGesture();
|
|
490
|
+
},
|
|
491
|
+
onExitRequest: reason => this.handleInputExit(reason),
|
|
492
|
+
onPointerLockChange: locked => {
|
|
493
|
+
this.events.emit('pointerlockchange', { locked });
|
|
494
|
+
this.syncInteractionPresentation();
|
|
495
|
+
},
|
|
496
|
+
onPointerLockError: error => this.handlePointerLockError(error),
|
|
497
|
+
onAction: action => this.handleBoundAction(action, 'keyboard'),
|
|
498
|
+
onActionValue: (action, value, pressed) => {
|
|
499
|
+
if (pressed || Math.abs(value) > 0.01)
|
|
500
|
+
this.accessibilityController.setInputModality('keyboard');
|
|
501
|
+
this.events.emit('inputaction', { action, value, pressed, device: 'keyboard' });
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
const inputOptions = options.input === false ? undefined : options.input;
|
|
505
|
+
this.gamepadInput = new GamepadController(this.canvas, inputOptions?.gamepad, this.inputBindingsController.bindings, {
|
|
506
|
+
onEnterRequest: index => this.enterGamepadFromActivity(index),
|
|
507
|
+
onExitRequest: () => this.handleGamepadExit(),
|
|
508
|
+
onAction: action => this.handleBoundAction(action, 'gamepad'),
|
|
509
|
+
onActionValue: (action, value, pressed) => {
|
|
510
|
+
if (pressed || Math.abs(value) > 0.01)
|
|
511
|
+
this.accessibilityController.setInputModality('gamepad');
|
|
512
|
+
this.events.emit('inputaction', { action, value, pressed, device: 'gamepad' });
|
|
513
|
+
},
|
|
514
|
+
onGamepadsChange: (gamepads, activeGamepad) => {
|
|
515
|
+
this.events.emit('gamepadchange', { gamepads, activeGamepad });
|
|
516
|
+
},
|
|
517
|
+
onWarning: message => this.emitWarning(message),
|
|
518
|
+
});
|
|
519
|
+
const uiOptions = options.ui === false
|
|
520
|
+
? false
|
|
521
|
+
: {
|
|
522
|
+
...(options.ui ?? {}),
|
|
523
|
+
...(options.fullscreen === false || options.fullscreen?.enabled === false
|
|
524
|
+
? { showFullscreenControl: false }
|
|
525
|
+
: {}),
|
|
526
|
+
...(options.audio === false ? { showAudioControl: false } : {}),
|
|
527
|
+
};
|
|
528
|
+
this.ui = dependencies.ui ?? createPlayerUI(this.container, uiOptions, () => this.retry(), () => this.enterFromGesture(), () => this.togglePauseFromGesture(), () => this.toggleFullscreen(), () => this.toggleVRFromGesture(), () => this.interact(), () => this.toggleAudioFromGesture(), () => this.captureScreenshotFromMenu(), options.pauseMenu, this.audioController.state, {
|
|
529
|
+
options: options.accessibility,
|
|
530
|
+
isKeyboardModality: () => this.accessibilityController.keyboardActive,
|
|
531
|
+
}, error => options.onWarning?.(`Anyo Player UI adapter failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
532
|
+
this.ui.setAudioState(this.audioController.state);
|
|
533
|
+
this.ui.setCaption?.(this.captionController.state);
|
|
534
|
+
const interactionOptions = options.interaction === false ? undefined : options.interaction;
|
|
535
|
+
const reticleOptions = asReticleOptions(interactionOptions?.reticle);
|
|
536
|
+
this.reticleMode = reticleOptions.mode ?? 'pointer-lock';
|
|
537
|
+
this.interactionPresentation = new InteractionPresentationController(this.canvas, reticleOptions, {
|
|
538
|
+
onTargetChange: (target, context) => this.handleInteractionTargetChange(target, context),
|
|
539
|
+
onActivateStart: target => this.handleInteractionActivateStart(target),
|
|
540
|
+
onActivateComplete: (target, selected) => this.handleInteractionActivateComplete(target, selected),
|
|
541
|
+
onError: error => this.handleInteractionError(error),
|
|
542
|
+
});
|
|
543
|
+
this.xrController = new XRController(this.vrEnabledValue, asVROptions(options.exploration?.vr), {
|
|
544
|
+
onSupportChange: (previous, state) => this.handleVRSupportChange(previous, state),
|
|
545
|
+
onStateChange: (previous, state) => this.handleXRStateChange(previous, state),
|
|
546
|
+
onSessionStart: () => this.handleXRSessionStart(),
|
|
547
|
+
onSessionEnd: () => this.handleXRSessionEnd(),
|
|
548
|
+
onInputsChange: inputs => this.handleXRInputsChange(inputs),
|
|
549
|
+
onTrackingChange: (previous, state) => this.handleXRTrackingChange(previous, state),
|
|
550
|
+
onReferenceSpaceReset: () => this.emitWarning('The XR reference space was reset by the browser.'),
|
|
551
|
+
onError: error => this.handleXRError(error),
|
|
552
|
+
});
|
|
553
|
+
this.ui.setVRSupport(this.xrController.supportState);
|
|
554
|
+
this.responsive = new ResponsiveController(this.container, options.resize, options.renderer?.pixelRatio, options.renderer?.maxPixelRatio, {
|
|
555
|
+
onResize: event => this.events.emit('resize', event),
|
|
556
|
+
onWarning: message => this.emitWarning(message),
|
|
557
|
+
});
|
|
558
|
+
this.fullscreenController = new FullscreenController(this.container, this.canvas, options.fullscreen, {
|
|
559
|
+
onChange: fullscreen => {
|
|
560
|
+
this.ui.setFullscreen(fullscreen);
|
|
561
|
+
this.events.emit('fullscreenchange', { fullscreen });
|
|
562
|
+
this.responsive.resizeNow(true);
|
|
563
|
+
},
|
|
564
|
+
onError: error => this.handleFullscreenError(error),
|
|
565
|
+
});
|
|
566
|
+
this.visibilityController = new VisibilityController(this.container.ownerDocument, options.visibility, {
|
|
567
|
+
onHidden: () => {
|
|
568
|
+
this.performanceController?.setActive(false);
|
|
569
|
+
this.handleVisibilityHidden();
|
|
570
|
+
},
|
|
571
|
+
onVisible: () => {
|
|
572
|
+
this.performanceController?.setActive(this.container.ownerDocument.hasFocus?.() !== false);
|
|
573
|
+
this.handleVisibilityVisible();
|
|
574
|
+
},
|
|
575
|
+
onUnfocused: () => {
|
|
576
|
+
if (this.visibilityController?.reduceWhenUnfocused)
|
|
577
|
+
this.performanceController?.setActive(false);
|
|
578
|
+
},
|
|
579
|
+
onFocused: () => {
|
|
580
|
+
if (this.visibilityController?.reduceWhenUnfocused)
|
|
581
|
+
this.performanceController?.setActive(!this.container.ownerDocument.hidden);
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
this.embeddingController = new EmbeddingController(this.container, options.embedding, {
|
|
585
|
+
onIntersectionChange: (previous, state) => {
|
|
586
|
+
this.events.emit('intersectionchange', { previous, state });
|
|
587
|
+
this.emitTelemetry('embedding.intersection-change', { previous, state }, 'lifecycle');
|
|
588
|
+
},
|
|
589
|
+
onVisible: () => this.handleIntersectionVisible(),
|
|
590
|
+
onHidden: () => this.handleIntersectionHidden(),
|
|
591
|
+
onPosterChange: visible => this.events.emit('posterchange', { visible }),
|
|
592
|
+
});
|
|
593
|
+
this.telemetryController = new TelemetryController(options.analytics, {
|
|
594
|
+
onEvent: event => this.events.emit('telemetry', event),
|
|
595
|
+
onError: error => {
|
|
596
|
+
this.events.emit('analyticserror', error);
|
|
597
|
+
this.options.onWarning?.(error.message);
|
|
598
|
+
this.ui.handleWarning(error.message);
|
|
599
|
+
this.events.emit('warning', { message: error.message });
|
|
600
|
+
},
|
|
601
|
+
}, this.now);
|
|
602
|
+
const performanceOptions = options.performance === false
|
|
603
|
+
? false
|
|
604
|
+
: {
|
|
605
|
+
...(options.performance ?? {}),
|
|
606
|
+
...(options.performance?.inactiveRenderScale === undefined && options.visibility !== false && options.visibility?.unfocusedRenderScale !== undefined
|
|
607
|
+
? { inactiveRenderScale: options.visibility.unfocusedRenderScale }
|
|
608
|
+
: {}),
|
|
609
|
+
};
|
|
610
|
+
this.performanceController = new PerformanceController(performanceOptions, {
|
|
611
|
+
onChange: event => this.events.emit('performancechange', event),
|
|
612
|
+
onQualityApplied: (quality, renderScale) => this.emitTelemetry('renderer.quality-applied', { quality, renderScale }, 'renderer'),
|
|
613
|
+
}, {
|
|
614
|
+
...(dependencies.performanceStorage ? { storage: dependencies.performanceStorage } : {}),
|
|
615
|
+
...(options.renderer?.optimization ? { rendererOptimization: options.renderer.optimization } : {}),
|
|
616
|
+
});
|
|
617
|
+
this.responsive.start();
|
|
618
|
+
this.syncAccessibilityBindings(this.inputBindingsController.bindings);
|
|
619
|
+
const initialNavigationWorld = this.navigationOptions?.initialWorld
|
|
620
|
+
? this.navigationOptions.worlds.get(this.navigationOptions.initialWorld) ?? null
|
|
621
|
+
: null;
|
|
622
|
+
this.lastSource = options.source ?? initialNavigationWorld?.source;
|
|
623
|
+
this.currentWorldIdValue = this.lastSource === undefined ? null : this.findNavigationWorldId(this.lastSource);
|
|
624
|
+
queueMicrotask(() => this.initializeEmbeddingLifecycle());
|
|
625
|
+
}
|
|
626
|
+
get state() {
|
|
627
|
+
return this.stateMachine.state;
|
|
628
|
+
}
|
|
629
|
+
get phase() {
|
|
630
|
+
return this.phaseValue;
|
|
631
|
+
}
|
|
632
|
+
get readyStatus() {
|
|
633
|
+
return this.readyStatusValue;
|
|
634
|
+
}
|
|
635
|
+
get world() {
|
|
636
|
+
return this.activeRuntime?.world ?? null;
|
|
637
|
+
}
|
|
638
|
+
get webSurfaceRegistry() {
|
|
639
|
+
return this.webSurfaceRegistryValue;
|
|
640
|
+
}
|
|
641
|
+
get progress() {
|
|
642
|
+
return { ...this.progressValue };
|
|
643
|
+
}
|
|
644
|
+
get error() {
|
|
645
|
+
return this.errorValue;
|
|
646
|
+
}
|
|
647
|
+
get entered() {
|
|
648
|
+
return this.state === 'running' && this.inputModeValue !== null;
|
|
649
|
+
}
|
|
650
|
+
get inputMode() {
|
|
651
|
+
return this.inputModeValue;
|
|
652
|
+
}
|
|
653
|
+
get touchEnabled() {
|
|
654
|
+
return this.touchEnabledValue;
|
|
655
|
+
}
|
|
656
|
+
get pointerLocked() {
|
|
657
|
+
return this.desktopInput.pointerLocked;
|
|
658
|
+
}
|
|
659
|
+
get paused() {
|
|
660
|
+
return this.state === 'paused';
|
|
661
|
+
}
|
|
662
|
+
get pauseReason() {
|
|
663
|
+
return this.pauseReasonValue;
|
|
664
|
+
}
|
|
665
|
+
get fullscreen() {
|
|
666
|
+
return this.fullscreenController.fullscreen;
|
|
667
|
+
}
|
|
668
|
+
get viewport() {
|
|
669
|
+
return this.responsive.viewport;
|
|
670
|
+
}
|
|
671
|
+
get interactionPrompt() {
|
|
672
|
+
return { ...this.interactionPromptValue };
|
|
673
|
+
}
|
|
674
|
+
get interactionTarget() {
|
|
675
|
+
return { ...this.interactionTargetValue };
|
|
676
|
+
}
|
|
677
|
+
get reticle() {
|
|
678
|
+
return { ...this.reticleValue };
|
|
679
|
+
}
|
|
680
|
+
get vrEnabled() {
|
|
681
|
+
return this.vrEnabledValue;
|
|
682
|
+
}
|
|
683
|
+
get vrSupportState() {
|
|
684
|
+
return this.xrController.supportState;
|
|
685
|
+
}
|
|
686
|
+
get vrSupported() {
|
|
687
|
+
return this.xrController.supported;
|
|
688
|
+
}
|
|
689
|
+
get xrState() {
|
|
690
|
+
return this.xrController.state;
|
|
691
|
+
}
|
|
692
|
+
get xrTracking() {
|
|
693
|
+
return this.xrController.tracking;
|
|
694
|
+
}
|
|
695
|
+
get xrInputs() {
|
|
696
|
+
return this.xrController.inputs;
|
|
697
|
+
}
|
|
698
|
+
get sessionEnabled() {
|
|
699
|
+
return this.sessionController.enabled;
|
|
700
|
+
}
|
|
701
|
+
get sessionStorageAvailable() {
|
|
702
|
+
return this.sessionController.storageAvailable;
|
|
703
|
+
}
|
|
704
|
+
get sessionKey() {
|
|
705
|
+
return this.sessionController.storageKey;
|
|
706
|
+
}
|
|
707
|
+
get inputBindings() {
|
|
708
|
+
return this.inputBindingsController.bindings;
|
|
709
|
+
}
|
|
710
|
+
get inputBindingsStorageAvailable() {
|
|
711
|
+
return this.inputBindingsController.storageAvailable;
|
|
712
|
+
}
|
|
713
|
+
get inputBindingsKey() {
|
|
714
|
+
return this.inputBindingsController.storageKey;
|
|
715
|
+
}
|
|
716
|
+
get gamepadEnabled() {
|
|
717
|
+
return this.gamepadInput.enabled;
|
|
718
|
+
}
|
|
719
|
+
get gamepads() {
|
|
720
|
+
return this.gamepadInput.gamepads;
|
|
721
|
+
}
|
|
722
|
+
get activeGamepad() {
|
|
723
|
+
return this.gamepadInput.activeGamepad;
|
|
724
|
+
}
|
|
725
|
+
get reducedMotion() {
|
|
726
|
+
return this.accessibilityController.reducedMotion;
|
|
727
|
+
}
|
|
728
|
+
get reducedMotionPreference() {
|
|
729
|
+
return this.accessibilityController.preference;
|
|
730
|
+
}
|
|
731
|
+
get inputModality() {
|
|
732
|
+
return this.accessibilityController.inputModality;
|
|
733
|
+
}
|
|
734
|
+
get audio() {
|
|
735
|
+
return this.audioController.state;
|
|
736
|
+
}
|
|
737
|
+
get rendererRecovery() {
|
|
738
|
+
return cloneRecoveryStatus(this.rendererRecoveryValue);
|
|
739
|
+
}
|
|
740
|
+
get performance() {
|
|
741
|
+
return this.performanceController.snapshot;
|
|
742
|
+
}
|
|
743
|
+
get qualityPreference() {
|
|
744
|
+
return this.performanceController.preference;
|
|
745
|
+
}
|
|
746
|
+
get deviceProfile() {
|
|
747
|
+
return this.performanceController.deviceProfile;
|
|
748
|
+
}
|
|
749
|
+
get viewPreference() {
|
|
750
|
+
return this.viewPreferenceController.preference;
|
|
751
|
+
}
|
|
752
|
+
get sourceInfo() {
|
|
753
|
+
return this.sourceInfoValue ? structuredClone(this.sourceInfoValue) : null;
|
|
754
|
+
}
|
|
755
|
+
get health() {
|
|
756
|
+
return this.createRuntimeHealth();
|
|
757
|
+
}
|
|
758
|
+
get caption() {
|
|
759
|
+
return this.captionController.state;
|
|
760
|
+
}
|
|
761
|
+
get cameraMode() {
|
|
762
|
+
return this.activeRuntime?.camera?.mode ?? 'explore';
|
|
763
|
+
}
|
|
764
|
+
get fallRecovery() {
|
|
765
|
+
return structuredClone(this.fallRecoveryValue);
|
|
766
|
+
}
|
|
767
|
+
get diagnostics() {
|
|
768
|
+
return this.diagnosticsValue.map(record => structuredClone(record));
|
|
769
|
+
}
|
|
770
|
+
get telemetry() {
|
|
771
|
+
return this.telemetryController.history;
|
|
772
|
+
}
|
|
773
|
+
get activated() {
|
|
774
|
+
return this.activatedValue;
|
|
775
|
+
}
|
|
776
|
+
get preloaded() {
|
|
777
|
+
return this.preloadedSource !== null;
|
|
778
|
+
}
|
|
779
|
+
get intersection() {
|
|
780
|
+
return this.embeddingController.intersection;
|
|
781
|
+
}
|
|
782
|
+
get posterVisible() {
|
|
783
|
+
return this.embeddingController.posterVisible;
|
|
784
|
+
}
|
|
785
|
+
get worldIds() {
|
|
786
|
+
return this.navigationOptions ? [...this.navigationOptions.worlds.keys()] : [];
|
|
787
|
+
}
|
|
788
|
+
get currentWorldId() {
|
|
789
|
+
return this.currentWorldIdValue;
|
|
790
|
+
}
|
|
791
|
+
get navigation() {
|
|
792
|
+
return cloneNavigationStatus(this.navigationValue);
|
|
793
|
+
}
|
|
794
|
+
get theme() {
|
|
795
|
+
return this.styleController.theme;
|
|
796
|
+
}
|
|
797
|
+
registerWorld(worldId, source, options = {}) {
|
|
798
|
+
this.assertNotDisposed();
|
|
799
|
+
const definition = normalizeWorldDefinition(worldId, source);
|
|
800
|
+
if (!this.navigationOptions)
|
|
801
|
+
this.navigationOptions = { worlds: new Map(), initialWorld: null, transition: normalizeTransitionOptions(undefined) };
|
|
802
|
+
const previous = this.navigationOptions.worlds.get(definition.id);
|
|
803
|
+
if (previous && options.replace !== true)
|
|
804
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_WORLD_EXISTS', `World "${definition.id}" is already registered.`);
|
|
805
|
+
this.cancelWorldPreload(definition.id);
|
|
806
|
+
this.releaseResolvedSource(this.worldPreloadCache.get(definition.id));
|
|
807
|
+
this.worldPreloadCache.delete(definition.id);
|
|
808
|
+
this.navigationOptions.worlds.set(definition.id, definition);
|
|
809
|
+
let active = true;
|
|
810
|
+
return () => {
|
|
811
|
+
if (!active)
|
|
812
|
+
return;
|
|
813
|
+
active = false;
|
|
814
|
+
const current = this.navigationOptions?.worlds.get(definition.id);
|
|
815
|
+
if (current !== definition || this.currentWorldIdValue === definition.id)
|
|
816
|
+
return;
|
|
817
|
+
this.cancelWorldPreload(definition.id);
|
|
818
|
+
this.releaseResolvedSource(this.worldPreloadCache.get(definition.id));
|
|
819
|
+
this.worldPreloadCache.delete(definition.id);
|
|
820
|
+
if (previous)
|
|
821
|
+
this.navigationOptions?.worlds.set(definition.id, previous);
|
|
822
|
+
else
|
|
823
|
+
this.navigationOptions?.worlds.delete(definition.id);
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
unregisterWorld(worldId) {
|
|
827
|
+
this.assertNotDisposed();
|
|
828
|
+
const id = worldId.trim();
|
|
829
|
+
if (!id || !this.navigationOptions?.worlds.has(id) || id === this.currentWorldIdValue)
|
|
830
|
+
return false;
|
|
831
|
+
this.cancelWorldPreload(id);
|
|
832
|
+
this.releaseResolvedSource(this.worldPreloadCache.get(id));
|
|
833
|
+
this.worldPreloadCache.delete(id);
|
|
834
|
+
return this.navigationOptions.worlds.delete(id);
|
|
835
|
+
}
|
|
836
|
+
async preloadWorld(worldId) {
|
|
837
|
+
this.assertNotDisposed();
|
|
838
|
+
const entry = this.getNavigationWorld(worldId);
|
|
839
|
+
const cached = this.worldPreloadCache.get(entry.id);
|
|
840
|
+
if (cached)
|
|
841
|
+
return structuredClone(cached.document);
|
|
842
|
+
const existing = this.worldPreloadTasks.get(entry.id);
|
|
843
|
+
if (existing)
|
|
844
|
+
return structuredClone((await existing.promise).document);
|
|
845
|
+
const controller = new AbortController();
|
|
846
|
+
const promise = this.sourceResolver.resolve(entry.source, controller.signal);
|
|
847
|
+
this.worldPreloadTasks.set(entry.id, { controller, promise });
|
|
848
|
+
let resolved = null;
|
|
849
|
+
let stored = false;
|
|
850
|
+
try {
|
|
851
|
+
resolved = await promise;
|
|
852
|
+
this.assertNotDisposed();
|
|
853
|
+
this.worldPreloadCache.set(entry.id, cloneResolvedSource(resolved));
|
|
854
|
+
stored = true;
|
|
855
|
+
const document = structuredClone(resolved.document);
|
|
856
|
+
this.events.emit('worldpreloaded', { worldId: entry.id, source: entry.source, document });
|
|
857
|
+
this.emitTelemetry('navigation.world-preloaded', { worldId: entry.id }, 'lifecycle');
|
|
858
|
+
return document;
|
|
859
|
+
}
|
|
860
|
+
catch (cause) {
|
|
861
|
+
throw toPlayerError(cause, controller.signal.aborted ? 'PLAYER_NAVIGATION_CANCELED' : 'PLAYER_NAVIGATION_FAILED', `Could not preload world "${entry.id}".`);
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
if (!stored)
|
|
865
|
+
this.releaseResolvedSource(resolved);
|
|
866
|
+
const current = this.worldPreloadTasks.get(entry.id);
|
|
867
|
+
if (current?.controller === controller)
|
|
868
|
+
this.worldPreloadTasks.delete(entry.id);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
clearWorldPreload(worldId) {
|
|
872
|
+
this.assertNotDisposed();
|
|
873
|
+
if (worldId === undefined) {
|
|
874
|
+
for (const resolved of this.worldPreloadCache.values())
|
|
875
|
+
this.releaseResolvedSource(resolved);
|
|
876
|
+
this.worldPreloadCache.clear();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const id = this.getNavigationWorld(worldId).id;
|
|
880
|
+
this.releaseResolvedSource(this.worldPreloadCache.get(id));
|
|
881
|
+
this.worldPreloadCache.delete(id);
|
|
882
|
+
}
|
|
883
|
+
cancelWorldPreload(worldId) {
|
|
884
|
+
this.assertNotDisposed();
|
|
885
|
+
return this.cancelWorldPreloadTasks(worldId);
|
|
886
|
+
}
|
|
887
|
+
cancelWorldPreloadTasks(worldId) {
|
|
888
|
+
let canceled = false;
|
|
889
|
+
const ids = worldId === undefined ? [...this.worldPreloadTasks.keys()] : [worldId.trim()];
|
|
890
|
+
for (const id of ids) {
|
|
891
|
+
const task = this.worldPreloadTasks.get(id);
|
|
892
|
+
if (!task || task.controller.signal.aborted)
|
|
893
|
+
continue;
|
|
894
|
+
task.controller.abort(new AnyoPlayerError('PLAYER_NAVIGATION_CANCELED', `World preload "${id}" was canceled.`));
|
|
895
|
+
this.worldPreloadTasks.delete(id);
|
|
896
|
+
canceled = true;
|
|
897
|
+
}
|
|
898
|
+
return canceled;
|
|
899
|
+
}
|
|
900
|
+
async navigateTo(worldId, options = {}) {
|
|
901
|
+
this.assertNotDisposed();
|
|
902
|
+
const entry = this.getNavigationWorld(worldId);
|
|
903
|
+
if (entry.id === this.currentWorldIdValue && this.activeRuntime && options.force !== true)
|
|
904
|
+
return;
|
|
905
|
+
if (this.navigationCommitted) {
|
|
906
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_BUSY', 'The current world navigation has already started committing to the Anyo runtime.');
|
|
907
|
+
}
|
|
908
|
+
this.cancelNavigation();
|
|
909
|
+
const sequence = ++this.navigationSequence;
|
|
910
|
+
const controller = new AbortController();
|
|
911
|
+
this.navigationAbort = controller;
|
|
912
|
+
const fromWorldId = this.currentWorldIdValue;
|
|
913
|
+
const transition = normalizeTransitionOptions(options.transition, this.navigationOptions?.transition);
|
|
914
|
+
const startedAt = Date.now();
|
|
915
|
+
const context = {
|
|
916
|
+
fromWorldId,
|
|
917
|
+
toWorldId: entry.id,
|
|
918
|
+
source: entry.source,
|
|
919
|
+
signal: controller.signal,
|
|
920
|
+
};
|
|
921
|
+
this.setNavigationPresentation(transition.presentation, 'preparing');
|
|
922
|
+
this.setNavigation({ state: 'preparing', fromWorldId, toWorldId: entry.id, error: null });
|
|
923
|
+
this.events.emit('worldnavigationstart', { fromWorldId, toWorldId: entry.id, source: entry.source });
|
|
924
|
+
this.emitTelemetry('navigation.started', { fromWorldId, toWorldId: entry.id }, 'lifecycle');
|
|
925
|
+
try {
|
|
926
|
+
await transition.beforeSwitch?.(context);
|
|
927
|
+
this.assertNavigationCurrent(sequence, controller);
|
|
928
|
+
let resolved = this.worldPreloadCache.get(entry.id);
|
|
929
|
+
if (!resolved) {
|
|
930
|
+
resolved = await this.sourceResolver.resolve(entry.source, controller.signal);
|
|
931
|
+
this.assertNavigationCurrent(sequence, controller);
|
|
932
|
+
this.worldPreloadCache.set(entry.id, cloneResolvedSource(resolved));
|
|
933
|
+
const document = structuredClone(resolved.document);
|
|
934
|
+
this.events.emit('worldpreloaded', { worldId: entry.id, source: entry.source, document });
|
|
935
|
+
}
|
|
936
|
+
this.navigationCommitted = true;
|
|
937
|
+
this.setNavigationPresentation(transition.presentation, 'switching');
|
|
938
|
+
this.setNavigation({ state: 'switching', fromWorldId, toWorldId: entry.id, error: null });
|
|
939
|
+
this.releaseResolvedSource(this.preloadedSource?.resolved);
|
|
940
|
+
this.preloadedSource = { source: entry.source, resolved: cloneResolvedSource(resolved) };
|
|
941
|
+
if (resolved.cleanup)
|
|
942
|
+
this.worldPreloadCache.delete(entry.id);
|
|
943
|
+
if (this.activeRuntime && this.activeDocument)
|
|
944
|
+
await this.replaceWorld(entry.source);
|
|
945
|
+
else
|
|
946
|
+
await this.load(entry.source);
|
|
947
|
+
this.lastSource = entry.source;
|
|
948
|
+
await waitForMinimumDuration(startedAt, transition.minimumDuration);
|
|
949
|
+
this.assertNavigationCurrent(sequence, controller, true);
|
|
950
|
+
this.currentWorldIdValue = entry.id;
|
|
951
|
+
const world = this.activeRuntime?.world;
|
|
952
|
+
if (!world)
|
|
953
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_FAILED', 'The destination world did not become active.');
|
|
954
|
+
if (transition.afterSwitch) {
|
|
955
|
+
try {
|
|
956
|
+
await transition.afterSwitch({ ...context, world });
|
|
957
|
+
}
|
|
958
|
+
catch (hookError) {
|
|
959
|
+
this.emitWarning(`Anyo Player afterSwitch hook failed after navigating to "${entry.id}": ${String(hookError)}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
this.setNavigation({ state: 'completed', fromWorldId, toWorldId: entry.id, error: null });
|
|
963
|
+
this.events.emit('worldnavigationcomplete', { fromWorldId, toWorldId: entry.id, source: entry.source, world });
|
|
964
|
+
this.emitTelemetry('navigation.completed', { fromWorldId, toWorldId: entry.id }, 'lifecycle');
|
|
965
|
+
}
|
|
966
|
+
catch (cause) {
|
|
967
|
+
const canceled = controller.signal.aborted && !this.navigationCommitted;
|
|
968
|
+
if (canceled) {
|
|
969
|
+
const error = new AnyoPlayerError('PLAYER_NAVIGATION_CANCELED', 'World navigation was canceled.', { cause });
|
|
970
|
+
this.setNavigation({ state: 'canceled', fromWorldId, toWorldId: entry.id, error });
|
|
971
|
+
this.events.emit('worldnavigationcanceled', { fromWorldId, toWorldId: entry.id, source: entry.source });
|
|
972
|
+
this.emitTelemetry('navigation.canceled', { fromWorldId, toWorldId: entry.id }, 'lifecycle');
|
|
973
|
+
throw error;
|
|
974
|
+
}
|
|
975
|
+
const error = toPlayerError(cause, 'PLAYER_NAVIGATION_FAILED', `Could not navigate to world "${entry.id}".`);
|
|
976
|
+
this.setNavigation({ state: 'failed', fromWorldId, toWorldId: entry.id, error });
|
|
977
|
+
this.events.emit('worldnavigationerror', { fromWorldId, toWorldId: entry.id, source: entry.source, error });
|
|
978
|
+
this.emitTelemetry('navigation.failed', { fromWorldId, toWorldId: entry.id, code: error.code }, 'lifecycle');
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
finally {
|
|
982
|
+
if (this.navigationSequence === sequence) {
|
|
983
|
+
this.navigationAbort = null;
|
|
984
|
+
this.navigationCommitted = false;
|
|
985
|
+
this.setNavigationPresentation('none', null);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
cancelNavigation() {
|
|
990
|
+
this.assertNotDisposed();
|
|
991
|
+
if (!this.navigationAbort || this.navigationCommitted || this.navigationAbort.signal.aborted)
|
|
992
|
+
return false;
|
|
993
|
+
this.navigationAbort.abort(new AnyoPlayerError('PLAYER_NAVIGATION_CANCELED', 'World navigation was canceled by the host.'));
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
async preload(source = this.lastSource) {
|
|
997
|
+
this.assertNotDisposed();
|
|
998
|
+
if (source === undefined) {
|
|
999
|
+
throw new AnyoPlayerError('PLAYER_INVALID_SOURCE', 'No Anyo Player source was provided for preloading.');
|
|
1000
|
+
}
|
|
1001
|
+
this.lastSource = source;
|
|
1002
|
+
if (this.preloadedSource && samePlayerSource(this.preloadedSource.source, source)) {
|
|
1003
|
+
return structuredClone(this.preloadedSource.resolved.document);
|
|
1004
|
+
}
|
|
1005
|
+
if (this.pendingPreload && samePlayerSource(this.pendingPreload.source, source)) {
|
|
1006
|
+
const resolved = await this.pendingPreload.promise;
|
|
1007
|
+
return structuredClone(resolved.document);
|
|
1008
|
+
}
|
|
1009
|
+
this.clearPreload();
|
|
1010
|
+
const controller = new AbortController();
|
|
1011
|
+
const promise = this.sourceResolver.resolve(source, controller.signal);
|
|
1012
|
+
const pending = { source, controller, promise };
|
|
1013
|
+
this.pendingPreload = pending;
|
|
1014
|
+
let resolved = null;
|
|
1015
|
+
let stored = false;
|
|
1016
|
+
try {
|
|
1017
|
+
resolved = await promise;
|
|
1018
|
+
this.assertNotDisposed();
|
|
1019
|
+
if (this.pendingPreload !== pending) {
|
|
1020
|
+
throw new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player preloading was superseded by a newer source.');
|
|
1021
|
+
}
|
|
1022
|
+
this.preloadedSource = { source, resolved: cloneResolvedSource(resolved) };
|
|
1023
|
+
stored = true;
|
|
1024
|
+
const document = structuredClone(resolved.document);
|
|
1025
|
+
this.events.emit('preloaded', { source, document });
|
|
1026
|
+
this.emitTelemetry('embedding.source-preloaded', {
|
|
1027
|
+
documentVersion: document.version,
|
|
1028
|
+
documentUrl: resolved.documentUrl ?? null,
|
|
1029
|
+
}, 'lifecycle');
|
|
1030
|
+
return document;
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
if (this.state === 'disposing' || this.state === 'disposed')
|
|
1034
|
+
throw error;
|
|
1035
|
+
const playerError = toPlayerError(error, 'PLAYER_PRELOAD_FAILED', 'Anyo Player could not preload the world source.');
|
|
1036
|
+
this.emitWarning(playerError.message);
|
|
1037
|
+
throw playerError;
|
|
1038
|
+
}
|
|
1039
|
+
finally {
|
|
1040
|
+
if (!stored)
|
|
1041
|
+
this.releaseResolvedSource(resolved);
|
|
1042
|
+
if (this.pendingPreload === pending)
|
|
1043
|
+
this.pendingPreload = null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
clearPreload() {
|
|
1047
|
+
this.pendingPreload?.controller.abort(new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player preloading was cleared.'));
|
|
1048
|
+
this.pendingPreload = null;
|
|
1049
|
+
this.releaseResolvedSource(this.preloadedSource?.resolved);
|
|
1050
|
+
this.preloadedSource = null;
|
|
1051
|
+
}
|
|
1052
|
+
activate() {
|
|
1053
|
+
this.assertNotDisposed();
|
|
1054
|
+
this.setActivated(true);
|
|
1055
|
+
if (this.activeRuntime && ['ready', 'entering', 'running', 'paused', 'vr-entering', 'vr-active', 'vr-exiting'].includes(this.state)) {
|
|
1056
|
+
return Promise.resolve();
|
|
1057
|
+
}
|
|
1058
|
+
if (this.state === 'loading' || this.state === 'replacing')
|
|
1059
|
+
return this.operationTail;
|
|
1060
|
+
return this.load(this.lastSource);
|
|
1061
|
+
}
|
|
1062
|
+
load(source = this.lastSource) {
|
|
1063
|
+
this.assertCanLoad();
|
|
1064
|
+
this.setActivated(true);
|
|
1065
|
+
this.embeddingController.handleLoadStart();
|
|
1066
|
+
if (source === undefined) {
|
|
1067
|
+
const error = new AnyoPlayerError('PLAYER_INVALID_SOURCE', 'No Anyo Player source was provided.');
|
|
1068
|
+
this.errorValue = error;
|
|
1069
|
+
this.readyStatusValue = null;
|
|
1070
|
+
this.transition('error');
|
|
1071
|
+
this.setPhase('error');
|
|
1072
|
+
this.ui.setError(error);
|
|
1073
|
+
this.events.emit('error', error);
|
|
1074
|
+
return Promise.reject(error);
|
|
1075
|
+
}
|
|
1076
|
+
this.lastSource = source;
|
|
1077
|
+
this.errorValue = null;
|
|
1078
|
+
this.inputModeValue = null;
|
|
1079
|
+
this.touchInput.setVisible(false);
|
|
1080
|
+
this.clearInteractionPrompt();
|
|
1081
|
+
this.stopInteractionPresentation();
|
|
1082
|
+
this.readyStatusValue = null;
|
|
1083
|
+
this.progressValue = { ...TERMINAL_PROGRESS, ratio: 0 };
|
|
1084
|
+
this.emitProgress();
|
|
1085
|
+
if (this.state === 'idle' || this.state === 'error')
|
|
1086
|
+
this.transition('loading');
|
|
1087
|
+
this.setPhase('resolving-source');
|
|
1088
|
+
const operationId = ++this.operationId;
|
|
1089
|
+
this.currentAbort?.abort(new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player loading was superseded by a newer operation.'));
|
|
1090
|
+
const abortController = new AbortController();
|
|
1091
|
+
this.currentAbort = abortController;
|
|
1092
|
+
const run = this.operationTail.then(() => this.performLoad(operationId, source, abortController));
|
|
1093
|
+
this.operationTail = run.then(() => undefined, () => undefined);
|
|
1094
|
+
return run;
|
|
1095
|
+
}
|
|
1096
|
+
async replaceWorld(source) {
|
|
1097
|
+
this.assertNotDisposed();
|
|
1098
|
+
if (!this.activeRuntime || !this.activeDocument)
|
|
1099
|
+
return this.load(source);
|
|
1100
|
+
if (this.state === 'vr-entering' || this.state === 'vr-active') {
|
|
1101
|
+
await this.exitVR();
|
|
1102
|
+
}
|
|
1103
|
+
else if (this.state === 'vr-exiting') {
|
|
1104
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Wait for the active VR session to finish exiting before replacing the world.');
|
|
1105
|
+
}
|
|
1106
|
+
if (this.state === 'replacing' && this.replacementStage !== 'resolving') {
|
|
1107
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'The current world replacement has already started mutating the Anyo runtime.');
|
|
1108
|
+
}
|
|
1109
|
+
if (!['ready', 'entering', 'running', 'paused', 'replacing'].includes(this.state)) {
|
|
1110
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `replaceWorld() requires a loaded world, not state "${this.state}".`);
|
|
1111
|
+
}
|
|
1112
|
+
if (!this.replacementContext) {
|
|
1113
|
+
const wasPaused = this.state === 'paused';
|
|
1114
|
+
this.replacementContext = {
|
|
1115
|
+
returnState: wasPaused ? 'paused' : 'ready',
|
|
1116
|
+
pauseReason: wasPaused ? this.pauseReasonValue : null,
|
|
1117
|
+
previousSource: this.lastSource,
|
|
1118
|
+
previousDocument: structuredClone(this.activeDocument),
|
|
1119
|
+
previousProgress: { ...this.progressValue },
|
|
1120
|
+
previousReadyStatus: this.readyStatusValue ?? 'ready',
|
|
1121
|
+
};
|
|
1122
|
+
if (this.state === 'running' || this.state === 'entering') {
|
|
1123
|
+
this.deactivateActiveInput(true);
|
|
1124
|
+
this.events.emit('exited', { reason: 'replace-world' });
|
|
1125
|
+
}
|
|
1126
|
+
this.inputModeValue = null;
|
|
1127
|
+
this.touchInput.setVisible(false);
|
|
1128
|
+
this.clearInteractionPrompt();
|
|
1129
|
+
this.transition('replacing');
|
|
1130
|
+
}
|
|
1131
|
+
this.errorValue = null;
|
|
1132
|
+
this.readyStatusValue = null;
|
|
1133
|
+
this.progressValue = { ...TERMINAL_PROGRESS, ratio: 0 };
|
|
1134
|
+
this.emitProgress();
|
|
1135
|
+
this.setPhase('resolving-source');
|
|
1136
|
+
this.replacementStage = 'resolving';
|
|
1137
|
+
this.events.emit('worldreplacing', { world: this.activeRuntime.world, source });
|
|
1138
|
+
const operationId = ++this.operationId;
|
|
1139
|
+
this.activeRuntime.operationId = operationId;
|
|
1140
|
+
this.currentAbort?.abort(new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player world replacement was superseded by a newer source.'));
|
|
1141
|
+
const abortController = new AbortController();
|
|
1142
|
+
this.currentAbort = abortController;
|
|
1143
|
+
const run = this.operationTail.then(() => this.performReplacement(operationId, source, abortController));
|
|
1144
|
+
this.operationTail = run.then(() => undefined, () => undefined);
|
|
1145
|
+
return run;
|
|
1146
|
+
}
|
|
1147
|
+
retry() {
|
|
1148
|
+
if (this.state !== 'error') {
|
|
1149
|
+
return Promise.reject(new AnyoPlayerError('PLAYER_INVALID_STATE', `retry() requires the player to be in the "error" state, not "${this.state}".`));
|
|
1150
|
+
}
|
|
1151
|
+
if (this.errorValue?.code === 'PLAYER_RENDERER_LOST' && this.rendererRecoveryOptions.enabled) {
|
|
1152
|
+
return this.recoverRenderer();
|
|
1153
|
+
}
|
|
1154
|
+
return this.load(this.lastSource);
|
|
1155
|
+
}
|
|
1156
|
+
enter() {
|
|
1157
|
+
this.assertNotDisposed();
|
|
1158
|
+
if (!this.desktopEnabled) {
|
|
1159
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Desktop exploration is disabled for this Anyo Player.');
|
|
1160
|
+
}
|
|
1161
|
+
if (this.state !== 'ready' || !this.activeRuntime) {
|
|
1162
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `enter() requires the player to be in the "ready" state, not "${this.state}".`);
|
|
1163
|
+
}
|
|
1164
|
+
if (this.audioController.unlockOnEnter && !this.audioController.state.unlocked) {
|
|
1165
|
+
void this.unlockAudio().catch(() => undefined);
|
|
1166
|
+
}
|
|
1167
|
+
this.transition('entering');
|
|
1168
|
+
this.ui.setEntering();
|
|
1169
|
+
this.touchInput.deactivate();
|
|
1170
|
+
this.touchInput.setVisible(false);
|
|
1171
|
+
this.inputModeValue = 'desktop';
|
|
1172
|
+
this.clearInteractionPrompt();
|
|
1173
|
+
const pointerLockRequested = this.desktopInput.activate();
|
|
1174
|
+
if (this.state !== 'entering')
|
|
1175
|
+
return;
|
|
1176
|
+
this.transition('running');
|
|
1177
|
+
this.ui.setRunning();
|
|
1178
|
+
this.embeddingController.handleEntered();
|
|
1179
|
+
this.syncInteractionPresentation();
|
|
1180
|
+
this.events.emit('entered', {
|
|
1181
|
+
world: this.activeRuntime.world,
|
|
1182
|
+
mode: 'desktop',
|
|
1183
|
+
pointerLockRequested,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
setCameraMode(mode, options = {}) {
|
|
1187
|
+
this.assertNotDisposed();
|
|
1188
|
+
const runtime = this.activeRuntime;
|
|
1189
|
+
if (!runtime || !['ready', 'entering', 'running', 'paused'].includes(this.state)) {
|
|
1190
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `Camera mode requires a loaded world, not state "${this.state}".`);
|
|
1191
|
+
}
|
|
1192
|
+
if (mode !== 'explore' && (this.state === 'running' || this.state === 'entering')) {
|
|
1193
|
+
this.deactivateActiveInput(true);
|
|
1194
|
+
this.inputModeValue = null;
|
|
1195
|
+
this.transition('ready');
|
|
1196
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, false);
|
|
1197
|
+
this.touchInput.setVisible(false);
|
|
1198
|
+
this.clearInteractionPrompt();
|
|
1199
|
+
this.events.emit('exited', { reason: 'camera-mode' });
|
|
1200
|
+
}
|
|
1201
|
+
if (!runtime.camera) {
|
|
1202
|
+
if (mode !== 'explore')
|
|
1203
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'This custom Player runtime does not provide inspection camera controls.');
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
runtime.camera.setMode(mode, options);
|
|
1207
|
+
}
|
|
1208
|
+
frameCamera(options = {}) {
|
|
1209
|
+
this.assertNotDisposed();
|
|
1210
|
+
if (!this.activeRuntime)
|
|
1211
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Camera framing requires a loaded world.');
|
|
1212
|
+
if (!this.activeRuntime.camera)
|
|
1213
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'This custom Player runtime does not provide camera framing.');
|
|
1214
|
+
this.activeRuntime.camera.frame(options);
|
|
1215
|
+
}
|
|
1216
|
+
teleport(options) {
|
|
1217
|
+
this.assertNotDisposed();
|
|
1218
|
+
const runtime = this.activeRuntime;
|
|
1219
|
+
if (!runtime)
|
|
1220
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Teleport requires a loaded world.');
|
|
1221
|
+
if (runtime.camera)
|
|
1222
|
+
runtime.camera.teleport(options);
|
|
1223
|
+
else {
|
|
1224
|
+
runtime.renderer.camera.setPosition([...options.position]);
|
|
1225
|
+
if (options.rotation)
|
|
1226
|
+
runtime.renderer.camera.setRotation(options.rotation[0], options.rotation[1]);
|
|
1227
|
+
runtime.world.exploration.clearInput();
|
|
1228
|
+
}
|
|
1229
|
+
const rotation = runtime.renderer.camera.getRotation();
|
|
1230
|
+
this.events.emit('teleported', {
|
|
1231
|
+
position: [...runtime.renderer.camera.getPosition()],
|
|
1232
|
+
rotation: [rotation[0], rotation[1]],
|
|
1233
|
+
resetMotion: options.resetMotion !== false,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
resetToSpawn() {
|
|
1237
|
+
this.assertNotDisposed();
|
|
1238
|
+
const runtime = this.activeRuntime;
|
|
1239
|
+
const document = this.activeDocument;
|
|
1240
|
+
if (!runtime || !document)
|
|
1241
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Reset to spawn requires a loaded world.');
|
|
1242
|
+
const exploration = document.exploration;
|
|
1243
|
+
const position = exploration?.spawn?.position
|
|
1244
|
+
?? [0, exploration?.eyeHeight ?? exploration?.height ?? 1.7, 0];
|
|
1245
|
+
if (exploration?.spawn?.room)
|
|
1246
|
+
runtime.world.setCurrentRoom(exploration.spawn.room);
|
|
1247
|
+
this.teleport({ position: [...position], rotation: [0, 0], resetMotion: true, clearInput: true });
|
|
1248
|
+
}
|
|
1249
|
+
setQualityPreset(preset) {
|
|
1250
|
+
this.assertNotDisposed();
|
|
1251
|
+
const previous = this.performanceController.preference;
|
|
1252
|
+
const performance = this.performanceController.setQuality(preset);
|
|
1253
|
+
this.emitQualityPreferenceChange(previous, 'runtime');
|
|
1254
|
+
this.persistQualityPreferenceIfConfigured();
|
|
1255
|
+
return performance;
|
|
1256
|
+
}
|
|
1257
|
+
setTargetFps(targetFps) {
|
|
1258
|
+
this.assertNotDisposed();
|
|
1259
|
+
const previous = this.performanceController.preference;
|
|
1260
|
+
const performance = this.performanceController.setTargetFps(targetFps);
|
|
1261
|
+
this.emitQualityPreferenceChange(previous, 'runtime');
|
|
1262
|
+
this.persistQualityPreferenceIfConfigured();
|
|
1263
|
+
return performance;
|
|
1264
|
+
}
|
|
1265
|
+
setDynamicResolution(enabled) {
|
|
1266
|
+
this.assertNotDisposed();
|
|
1267
|
+
const previous = this.performanceController.preference;
|
|
1268
|
+
const performance = this.performanceController.setDynamicResolution(enabled);
|
|
1269
|
+
this.emitQualityPreferenceChange(previous, 'runtime');
|
|
1270
|
+
this.persistQualityPreferenceIfConfigured();
|
|
1271
|
+
return performance;
|
|
1272
|
+
}
|
|
1273
|
+
getPerformanceSnapshot() {
|
|
1274
|
+
this.assertNotDisposed();
|
|
1275
|
+
return this.performanceController.sample();
|
|
1276
|
+
}
|
|
1277
|
+
async saveQualityPreference(key) {
|
|
1278
|
+
this.assertNotDisposed();
|
|
1279
|
+
try {
|
|
1280
|
+
const saved = await this.performanceController.save(key);
|
|
1281
|
+
this.events.emit('qualitypreferencesaved', saved);
|
|
1282
|
+
this.emitTelemetry('quality.preference-saved', { key: saved.key }, 'host');
|
|
1283
|
+
return saved.snapshot;
|
|
1284
|
+
}
|
|
1285
|
+
catch (cause) {
|
|
1286
|
+
throw this.preferenceError(cause, 'PLAYER_QUALITY_STORAGE_UNAVAILABLE', 'Could not save the Anyo Player quality preference.');
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
async loadQualityPreference(key) {
|
|
1290
|
+
this.assertNotDisposed();
|
|
1291
|
+
const previous = this.performanceController.preference;
|
|
1292
|
+
try {
|
|
1293
|
+
const loaded = await this.performanceController.load(key);
|
|
1294
|
+
const resolvedKey = loaded?.key ?? key?.trim() ?? this.performanceController.storageKey ?? '';
|
|
1295
|
+
this.events.emit('qualitypreferenceloaded', { key: resolvedKey, snapshot: loaded?.snapshot ?? null });
|
|
1296
|
+
if (loaded)
|
|
1297
|
+
this.emitQualityPreferenceChange(previous, 'storage');
|
|
1298
|
+
return loaded?.snapshot ?? null;
|
|
1299
|
+
}
|
|
1300
|
+
catch (cause) {
|
|
1301
|
+
throw this.preferenceError(cause, 'PLAYER_QUALITY_PREFERENCE_INVALID', 'Could not load the Anyo Player quality preference.');
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
async clearQualityPreference(key) {
|
|
1305
|
+
this.assertNotDisposed();
|
|
1306
|
+
try {
|
|
1307
|
+
const resolvedKey = await this.performanceController.clear(key);
|
|
1308
|
+
this.events.emit('qualitypreferencecleared', { key: resolvedKey });
|
|
1309
|
+
}
|
|
1310
|
+
catch (cause) {
|
|
1311
|
+
throw this.preferenceError(cause, 'PLAYER_QUALITY_STORAGE_UNAVAILABLE', 'Could not clear the Anyo Player quality preference.');
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
setViewPreference(preference) {
|
|
1315
|
+
this.assertNotDisposed();
|
|
1316
|
+
const previous = this.viewPreferenceController.preference;
|
|
1317
|
+
const next = this.viewPreferenceController.set(preference);
|
|
1318
|
+
this.applyViewPreference();
|
|
1319
|
+
this.events.emit('viewpreferencechange', { previous, preference: next, source: 'runtime' });
|
|
1320
|
+
this.persistViewPreferenceIfConfigured();
|
|
1321
|
+
return next;
|
|
1322
|
+
}
|
|
1323
|
+
resetViewPreference() {
|
|
1324
|
+
this.assertNotDisposed();
|
|
1325
|
+
const previous = this.viewPreferenceController.preference;
|
|
1326
|
+
const next = this.viewPreferenceController.reset();
|
|
1327
|
+
this.applyViewPreference();
|
|
1328
|
+
this.events.emit('viewpreferencechange', { previous, preference: next, source: 'reset' });
|
|
1329
|
+
this.persistViewPreferenceIfConfigured();
|
|
1330
|
+
return next;
|
|
1331
|
+
}
|
|
1332
|
+
async saveViewPreference(key) {
|
|
1333
|
+
this.assertNotDisposed();
|
|
1334
|
+
try {
|
|
1335
|
+
const saved = await this.viewPreferenceController.save(key);
|
|
1336
|
+
this.events.emit('viewpreferencesaved', saved);
|
|
1337
|
+
return saved.snapshot;
|
|
1338
|
+
}
|
|
1339
|
+
catch (cause) {
|
|
1340
|
+
throw this.preferenceError(cause, 'PLAYER_VIEW_STORAGE_UNAVAILABLE', 'Could not save the Anyo Player view preference.');
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
async loadViewPreference(key) {
|
|
1344
|
+
this.assertNotDisposed();
|
|
1345
|
+
const previous = this.viewPreferenceController.preference;
|
|
1346
|
+
try {
|
|
1347
|
+
const loaded = await this.viewPreferenceController.load(key);
|
|
1348
|
+
const resolvedKey = loaded?.key ?? key?.trim() ?? this.viewPreferenceController.storageKey ?? '';
|
|
1349
|
+
this.events.emit('viewpreferenceloaded', { key: resolvedKey, snapshot: loaded?.snapshot ?? null });
|
|
1350
|
+
if (loaded) {
|
|
1351
|
+
this.applyViewPreference();
|
|
1352
|
+
this.events.emit('viewpreferencechange', { previous, preference: loaded.snapshot, source: 'storage' });
|
|
1353
|
+
}
|
|
1354
|
+
return loaded?.snapshot ?? null;
|
|
1355
|
+
}
|
|
1356
|
+
catch (cause) {
|
|
1357
|
+
throw this.preferenceError(cause, 'PLAYER_VIEW_PREFERENCE_INVALID', 'Could not load the Anyo Player view preference.');
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
async clearViewPreference(key) {
|
|
1361
|
+
this.assertNotDisposed();
|
|
1362
|
+
try {
|
|
1363
|
+
const resolvedKey = await this.viewPreferenceController.clear(key);
|
|
1364
|
+
this.events.emit('viewpreferencecleared', { key: resolvedKey });
|
|
1365
|
+
}
|
|
1366
|
+
catch (cause) {
|
|
1367
|
+
throw this.preferenceError(cause, 'PLAYER_VIEW_STORAGE_UNAVAILABLE', 'Could not clear the Anyo Player view preference.');
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
createRuntimeHealth() {
|
|
1371
|
+
const reasons = [];
|
|
1372
|
+
const rendererAvailable = this.activeRuntime !== null;
|
|
1373
|
+
const worldAvailable = this.world !== null;
|
|
1374
|
+
const assetsReady = this.progress.failed === 0 && this.progress.loading === 0 && this.progress.queued === 0;
|
|
1375
|
+
const performance = this.performanceController.snapshot;
|
|
1376
|
+
const performanceWithinTarget = performance.fps <= 0 || performance.inactive || performance.fps >= Math.max(15, performance.targetFps * 0.75);
|
|
1377
|
+
const recoveryActive = ['scheduled', 'recovering'].includes(this.rendererRecoveryValue.state);
|
|
1378
|
+
if (!rendererAvailable)
|
|
1379
|
+
reasons.push('renderer-unavailable');
|
|
1380
|
+
if (!worldAvailable)
|
|
1381
|
+
reasons.push('world-unavailable');
|
|
1382
|
+
if (!assetsReady)
|
|
1383
|
+
reasons.push(this.progress.failed > 0 ? 'asset-failures' : 'assets-loading');
|
|
1384
|
+
if (!performanceWithinTarget)
|
|
1385
|
+
reasons.push('performance-below-target');
|
|
1386
|
+
if (recoveryActive)
|
|
1387
|
+
reasons.push('renderer-recovery-active');
|
|
1388
|
+
if (this.state === 'error' || this.errorValue)
|
|
1389
|
+
reasons.push(this.errorValue?.code ?? 'player-error');
|
|
1390
|
+
const state = this.state === 'error'
|
|
1391
|
+
? 'failed'
|
|
1392
|
+
: recoveryActive
|
|
1393
|
+
? 'recovering'
|
|
1394
|
+
: reasons.length > 0
|
|
1395
|
+
? 'degraded'
|
|
1396
|
+
: 'healthy';
|
|
1397
|
+
return { state, reasons, rendererAvailable, worldAvailable, assetsReady, performanceWithinTarget, recoveryActive };
|
|
1398
|
+
}
|
|
1399
|
+
createRuntimeReport() {
|
|
1400
|
+
this.assertNotDisposed();
|
|
1401
|
+
const world = this.world;
|
|
1402
|
+
const compiled = world?.compiled ?? null;
|
|
1403
|
+
const rendererInfo = world?.renderer.info;
|
|
1404
|
+
const nativeRenderer = this.activeRuntime?.renderer
|
|
1405
|
+
?.getNativeAccess?.()?.engine?.renderer;
|
|
1406
|
+
const rendererFeatures = nativeRenderer?.capabilities?.features;
|
|
1407
|
+
const diagnostics = this.diagnosticsValue;
|
|
1408
|
+
const count = (value) => Array.isArray(value) ? value.length : 0;
|
|
1409
|
+
return {
|
|
1410
|
+
format: '@blcklab/anyo-player/runtime-report',
|
|
1411
|
+
version: 1,
|
|
1412
|
+
createdAt: this.now().toISOString(),
|
|
1413
|
+
lifecycle: {
|
|
1414
|
+
state: this.state,
|
|
1415
|
+
phase: this.phase,
|
|
1416
|
+
readyStatus: this.readyStatus,
|
|
1417
|
+
entered: this.entered,
|
|
1418
|
+
paused: this.paused,
|
|
1419
|
+
pauseReason: this.pauseReason,
|
|
1420
|
+
activated: this.activated,
|
|
1421
|
+
},
|
|
1422
|
+
world: {
|
|
1423
|
+
loaded: world !== null,
|
|
1424
|
+
documentVersion: world?.document?.version ?? null,
|
|
1425
|
+
currentRoom: world?.getCurrentRoom() ?? null,
|
|
1426
|
+
rooms: count(compiled?.rooms),
|
|
1427
|
+
entities: count(compiled?.entities),
|
|
1428
|
+
primitives: count(compiled?.primitives),
|
|
1429
|
+
materials: count(compiled?.materials),
|
|
1430
|
+
colliders: count(compiled?.colliders),
|
|
1431
|
+
portals: count(compiled?.portals),
|
|
1432
|
+
triggers: count(compiled?.triggers),
|
|
1433
|
+
},
|
|
1434
|
+
renderer: {
|
|
1435
|
+
name: rendererInfo?.name ?? null,
|
|
1436
|
+
version: rendererInfo?.version ?? null,
|
|
1437
|
+
backend: rendererInfo?.capabilities.backend ?? null,
|
|
1438
|
+
shadows: rendererInfo ? rendererInfo.capabilities.shadows : null,
|
|
1439
|
+
atmosphere: rendererInfo?.capabilities.atmosphere ?? null,
|
|
1440
|
+
colorGrading: rendererInfo?.capabilities.colorGrading ?? null,
|
|
1441
|
+
gpuPostProcessing: rendererFeatures?.gpuPostProcessing ?? null,
|
|
1442
|
+
ssao: rendererFeatures?.ssao ?? null,
|
|
1443
|
+
bloom: rendererFeatures?.bloom ?? null,
|
|
1444
|
+
outlines: rendererFeatures?.outlines ?? null,
|
|
1445
|
+
cascadedShadows: rendererFeatures?.cascadedShadows ?? null,
|
|
1446
|
+
mipmapGeneration: rendererFeatures?.mipmapGeneration ?? null,
|
|
1447
|
+
},
|
|
1448
|
+
performance: this.performanceController.sample(),
|
|
1449
|
+
viewport: this.viewport,
|
|
1450
|
+
input: {
|
|
1451
|
+
mode: this.inputMode,
|
|
1452
|
+
modality: this.inputModality,
|
|
1453
|
+
pointerLocked: this.pointerLocked,
|
|
1454
|
+
touchEnabled: this.touchEnabled,
|
|
1455
|
+
activeGamepad: this.activeGamepad,
|
|
1456
|
+
reducedMotion: this.reducedMotion,
|
|
1457
|
+
},
|
|
1458
|
+
assets: this.progress,
|
|
1459
|
+
recovery: this.rendererRecovery,
|
|
1460
|
+
diagnostics: {
|
|
1461
|
+
total: diagnostics.length,
|
|
1462
|
+
errors: diagnostics.filter(record => record.severity === 'error').length,
|
|
1463
|
+
warnings: diagnostics.filter(record => record.severity === 'warning').length,
|
|
1464
|
+
},
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
downloadRuntimeReport(filename = 'anyo-player-runtime-report.json') {
|
|
1468
|
+
const report = this.createRuntimeReport();
|
|
1469
|
+
if (typeof document === 'undefined' || typeof URL === 'undefined')
|
|
1470
|
+
return report;
|
|
1471
|
+
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
|
1472
|
+
const url = URL.createObjectURL(blob);
|
|
1473
|
+
const anchor = document.createElement('a');
|
|
1474
|
+
anchor.href = url;
|
|
1475
|
+
anchor.download = filename;
|
|
1476
|
+
anchor.style.display = 'none';
|
|
1477
|
+
document.body.append(anchor);
|
|
1478
|
+
anchor.click();
|
|
1479
|
+
anchor.remove();
|
|
1480
|
+
URL.revokeObjectURL(url);
|
|
1481
|
+
return report;
|
|
1482
|
+
}
|
|
1483
|
+
createDiagnosticBundle() {
|
|
1484
|
+
this.assertNotDisposed();
|
|
1485
|
+
const inputBindings = this.inputBindingsController.bindings;
|
|
1486
|
+
return {
|
|
1487
|
+
format: '@blcklab/anyo-player/diagnostic-bundle',
|
|
1488
|
+
version: 1,
|
|
1489
|
+
createdAt: this.now().toISOString(),
|
|
1490
|
+
health: this.createRuntimeHealth(),
|
|
1491
|
+
runtime: this.createRuntimeReport(),
|
|
1492
|
+
source: this.sourceInfo,
|
|
1493
|
+
documentHash: this.activeDocument ? hashWorldDocument(this.activeDocument) : null,
|
|
1494
|
+
diagnostics: this.diagnostics,
|
|
1495
|
+
telemetry: this.telemetry,
|
|
1496
|
+
qualityPreference: this.qualityPreference,
|
|
1497
|
+
viewPreference: this.viewPreference,
|
|
1498
|
+
inputBindings: {
|
|
1499
|
+
format: '@blcklab/anyo-player/input-bindings',
|
|
1500
|
+
version: 1,
|
|
1501
|
+
bindings: cloneInputBindingMap(inputBindings),
|
|
1502
|
+
},
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
downloadDiagnosticBundle(filename = 'anyo-player-diagnostic-bundle.json') {
|
|
1506
|
+
const bundle = this.createDiagnosticBundle();
|
|
1507
|
+
this.downloadJson(bundle, filename);
|
|
1508
|
+
return bundle;
|
|
1509
|
+
}
|
|
1510
|
+
pause() {
|
|
1511
|
+
this.pauseInternal('user');
|
|
1512
|
+
}
|
|
1513
|
+
resume() {
|
|
1514
|
+
this.resumeInternal(true);
|
|
1515
|
+
}
|
|
1516
|
+
async enterFullscreen() {
|
|
1517
|
+
this.assertFullscreenState();
|
|
1518
|
+
try {
|
|
1519
|
+
await this.fullscreenController.enter();
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
const playerError = toPlayerError(error, 'PLAYER_FULLSCREEN_FAILED', 'Anyo Player could not enter fullscreen.');
|
|
1523
|
+
this.handleFullscreenError(playerError);
|
|
1524
|
+
throw playerError;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
async exitFullscreen() {
|
|
1528
|
+
this.assertNotDisposed();
|
|
1529
|
+
try {
|
|
1530
|
+
await this.fullscreenController.exit();
|
|
1531
|
+
}
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
const playerError = toPlayerError(error, 'PLAYER_FULLSCREEN_FAILED', 'Anyo Player could not exit fullscreen.');
|
|
1534
|
+
this.handleFullscreenError(playerError);
|
|
1535
|
+
throw playerError;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
async toggleFullscreen() {
|
|
1539
|
+
if (this.fullscreen)
|
|
1540
|
+
await this.exitFullscreen();
|
|
1541
|
+
else
|
|
1542
|
+
await this.enterFullscreen();
|
|
1543
|
+
}
|
|
1544
|
+
async checkVRSupport() {
|
|
1545
|
+
this.assertNotDisposed();
|
|
1546
|
+
if (!this.vrEnabledValue)
|
|
1547
|
+
return false;
|
|
1548
|
+
if (!this.activeRuntime) {
|
|
1549
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Load an Anyo world before checking VR support.');
|
|
1550
|
+
}
|
|
1551
|
+
try {
|
|
1552
|
+
return await this.xrController.checkSupport();
|
|
1553
|
+
}
|
|
1554
|
+
catch (cause) {
|
|
1555
|
+
const error = new AnyoPlayerError('PLAYER_VR_SUPPORT_FAILED', 'Anyo Player could not determine whether immersive VR is supported.', { cause });
|
|
1556
|
+
this.ui.setControlError(error);
|
|
1557
|
+
this.events.emit('vrerror', error);
|
|
1558
|
+
throw error;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async enterVR() {
|
|
1562
|
+
this.assertNotDisposed();
|
|
1563
|
+
if (!this.vrEnabledValue) {
|
|
1564
|
+
throw new AnyoPlayerError('PLAYER_VR_DISABLED', 'VR is disabled for this Anyo Player.');
|
|
1565
|
+
}
|
|
1566
|
+
if (!this.activeRuntime || !['ready', 'entering', 'running'].includes(this.state)) {
|
|
1567
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `enterVR() requires a ready or running world, not state "${this.state}".`);
|
|
1568
|
+
}
|
|
1569
|
+
const supported = this.vrSupported ?? await this.checkVRSupport();
|
|
1570
|
+
if (!supported) {
|
|
1571
|
+
const error = new AnyoPlayerError('PLAYER_VR_UNSUPPORTED', 'Immersive VR is not supported by the current browser, device, or renderer.');
|
|
1572
|
+
this.ui.setControlError(error);
|
|
1573
|
+
this.events.emit('vrerror', error);
|
|
1574
|
+
throw error;
|
|
1575
|
+
}
|
|
1576
|
+
const controlsWereActive = this.state === 'running' || this.state === 'entering';
|
|
1577
|
+
if (controlsWereActive) {
|
|
1578
|
+
this.deactivateActiveInput(true);
|
|
1579
|
+
this.events.emit('exited', { reason: 'vr-enter' });
|
|
1580
|
+
}
|
|
1581
|
+
this.touchInput.setVisible(false);
|
|
1582
|
+
this.clearInteractionPrompt();
|
|
1583
|
+
this.xrExitRequested = false;
|
|
1584
|
+
this.transition('vr-entering');
|
|
1585
|
+
this.ui.setVRSession('entering', this.xrInputs.length);
|
|
1586
|
+
try {
|
|
1587
|
+
await this.xrController.enter();
|
|
1588
|
+
if (this.state === 'vr-entering')
|
|
1589
|
+
this.activateXRSession();
|
|
1590
|
+
}
|
|
1591
|
+
catch (cause) {
|
|
1592
|
+
if (this.state === 'vr-entering')
|
|
1593
|
+
this.transition('ready');
|
|
1594
|
+
this.ui.setVRSession('idle', 0);
|
|
1595
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
1596
|
+
this.ui.setVRSupport(this.xrController.supportState);
|
|
1597
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
1598
|
+
const error = new AnyoPlayerError('PLAYER_VR_ENTER_FAILED', 'Anyo Player could not enter immersive VR.', { cause });
|
|
1599
|
+
this.ui.setControlError(error);
|
|
1600
|
+
this.events.emit('vrerror', error);
|
|
1601
|
+
throw error;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
async exitVR() {
|
|
1605
|
+
this.assertNotDisposed();
|
|
1606
|
+
if (!this.activeRuntime || !['vr-entering', 'vr-active'].includes(this.state)) {
|
|
1607
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `exitVR() requires an active or entering VR session, not state "${this.state}".`);
|
|
1608
|
+
}
|
|
1609
|
+
this.xrExitRequested = true;
|
|
1610
|
+
if (this.state === 'vr-active')
|
|
1611
|
+
this.transition('vr-exiting');
|
|
1612
|
+
this.ui.setVRSession('exiting', this.xrInputs.length);
|
|
1613
|
+
try {
|
|
1614
|
+
await this.xrController.exit();
|
|
1615
|
+
if (this.state === 'vr-exiting' || this.state === 'vr-entering')
|
|
1616
|
+
this.finishXRSession(false);
|
|
1617
|
+
}
|
|
1618
|
+
catch (cause) {
|
|
1619
|
+
this.xrExitRequested = false;
|
|
1620
|
+
if (this.state === 'vr-exiting')
|
|
1621
|
+
this.transition('vr-active');
|
|
1622
|
+
this.ui.setVRSession('active', this.xrInputs.length);
|
|
1623
|
+
const error = new AnyoPlayerError('PLAYER_VR_EXIT_FAILED', 'Anyo Player could not exit immersive VR cleanly.', { cause });
|
|
1624
|
+
this.ui.setControlError(error);
|
|
1625
|
+
this.events.emit('vrerror', error);
|
|
1626
|
+
throw error;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
resizeNow() {
|
|
1630
|
+
this.assertNotDisposed();
|
|
1631
|
+
return this.responsive.resizeNow(true);
|
|
1632
|
+
}
|
|
1633
|
+
captureSession(options = {}) {
|
|
1634
|
+
this.assertSessionWorldAvailable('captureSession');
|
|
1635
|
+
const snapshot = this.sessionController.capture({
|
|
1636
|
+
world: this.activeRuntime.world,
|
|
1637
|
+
document: this.activeDocument,
|
|
1638
|
+
paused: this.state === 'paused',
|
|
1639
|
+
pauseReason: this.pauseReasonValue,
|
|
1640
|
+
inputMode: this.inputModeValue ?? this.resumeInputModeAfterPause,
|
|
1641
|
+
}, options);
|
|
1642
|
+
this.events.emit('sessioncaptured', { snapshot: structuredClone(snapshot) });
|
|
1643
|
+
return structuredClone(snapshot);
|
|
1644
|
+
}
|
|
1645
|
+
async restoreSession(snapshot, options = {}) {
|
|
1646
|
+
this.assertSessionWorldAvailable('restoreSession');
|
|
1647
|
+
if (this.state === 'vr-entering' || this.state === 'vr-active')
|
|
1648
|
+
await this.exitVR();
|
|
1649
|
+
else if (this.state === 'vr-exiting') {
|
|
1650
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Wait for the active VR session to finish exiting before restoring a session.');
|
|
1651
|
+
}
|
|
1652
|
+
const run = this.operationTail.then(() => this.performSessionRestore(snapshot, options));
|
|
1653
|
+
this.operationTail = run.then(() => undefined, () => undefined);
|
|
1654
|
+
return run;
|
|
1655
|
+
}
|
|
1656
|
+
async saveSession(key, options = {}) {
|
|
1657
|
+
const snapshot = this.captureSession(options);
|
|
1658
|
+
try {
|
|
1659
|
+
const resolvedKey = await this.sessionController.save(key, snapshot);
|
|
1660
|
+
this.events.emit('sessionsaved', { key: resolvedKey, snapshot: structuredClone(snapshot) });
|
|
1661
|
+
return snapshot;
|
|
1662
|
+
}
|
|
1663
|
+
catch (error) {
|
|
1664
|
+
const playerError = this.sessionError(error, 'PLAYER_SESSION_SAVE_FAILED', 'Anyo Player could not save the current session.');
|
|
1665
|
+
this.events.emit('sessionerror', playerError);
|
|
1666
|
+
throw playerError;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async loadSession(key, options = {}) {
|
|
1670
|
+
this.assertSessionWorldAvailable('loadSession');
|
|
1671
|
+
try {
|
|
1672
|
+
const loaded = await this.sessionController.load(key);
|
|
1673
|
+
this.events.emit('sessionloaded', {
|
|
1674
|
+
key: loaded?.key ?? key?.trim() ?? this.sessionController.storageKey ?? '',
|
|
1675
|
+
snapshot: loaded ? structuredClone(loaded.snapshot) : null,
|
|
1676
|
+
});
|
|
1677
|
+
if (!loaded)
|
|
1678
|
+
return null;
|
|
1679
|
+
await this.restoreSession(loaded.snapshot, options);
|
|
1680
|
+
return structuredClone(loaded.snapshot);
|
|
1681
|
+
}
|
|
1682
|
+
catch (error) {
|
|
1683
|
+
const playerError = this.sessionError(error, 'PLAYER_SESSION_LOAD_FAILED', 'Anyo Player could not load the saved session.');
|
|
1684
|
+
this.events.emit('sessionerror', playerError);
|
|
1685
|
+
throw playerError;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
async clearSession(key) {
|
|
1689
|
+
this.assertNotDisposed();
|
|
1690
|
+
try {
|
|
1691
|
+
const resolvedKey = await this.sessionController.clear(key);
|
|
1692
|
+
this.events.emit('sessioncleared', { key: resolvedKey });
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
const playerError = this.sessionError(error, 'PLAYER_SESSION_SAVE_FAILED', 'Anyo Player could not clear the saved session.');
|
|
1696
|
+
this.events.emit('sessionerror', playerError);
|
|
1697
|
+
throw playerError;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
setInputBindings(bindings, options = {}) {
|
|
1701
|
+
this.assertNotDisposed();
|
|
1702
|
+
try {
|
|
1703
|
+
const value = this.inputBindingsController.setBindings(bindings, options.merge !== false);
|
|
1704
|
+
this.applyInputBindings(value);
|
|
1705
|
+
this.events.emit('inputbindingschange', { bindings: cloneInputBindingMap(value), source: 'runtime' });
|
|
1706
|
+
this.persistInputBindingsOnChange();
|
|
1707
|
+
return cloneInputBindingMap(value);
|
|
1708
|
+
}
|
|
1709
|
+
catch (error) {
|
|
1710
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_INVALID', 'Anyo Player could not apply the input bindings.');
|
|
1711
|
+
this.events.emit('inputbindingserror', playerError);
|
|
1712
|
+
throw playerError;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
resetInputBindings() {
|
|
1716
|
+
this.assertNotDisposed();
|
|
1717
|
+
const value = this.inputBindingsController.reset();
|
|
1718
|
+
this.applyInputBindings(value);
|
|
1719
|
+
this.events.emit('inputbindingschange', { bindings: cloneInputBindingMap(value), source: 'reset' });
|
|
1720
|
+
this.persistInputBindingsOnChange();
|
|
1721
|
+
return cloneInputBindingMap(value);
|
|
1722
|
+
}
|
|
1723
|
+
async saveInputBindings(key) {
|
|
1724
|
+
this.assertNotDisposed();
|
|
1725
|
+
try {
|
|
1726
|
+
const saved = await this.inputBindingsController.save(key);
|
|
1727
|
+
this.events.emit('inputbindingssaved', {
|
|
1728
|
+
key: saved.key,
|
|
1729
|
+
snapshot: structuredClone(saved.snapshot),
|
|
1730
|
+
});
|
|
1731
|
+
return structuredClone(saved.snapshot);
|
|
1732
|
+
}
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_SAVE_FAILED', 'Anyo Player could not save the current input bindings.');
|
|
1735
|
+
this.events.emit('inputbindingserror', playerError);
|
|
1736
|
+
throw playerError;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
async loadInputBindings(key) {
|
|
1740
|
+
this.assertNotDisposed();
|
|
1741
|
+
try {
|
|
1742
|
+
const loaded = await this.inputBindingsController.load(key);
|
|
1743
|
+
this.applyInputBindings(this.inputBindingsController.bindings);
|
|
1744
|
+
this.events.emit('inputbindingsloaded', {
|
|
1745
|
+
key: loaded?.key ?? key?.trim() ?? this.inputBindingsController.storageKey ?? '',
|
|
1746
|
+
snapshot: loaded ? structuredClone(loaded.snapshot) : null,
|
|
1747
|
+
});
|
|
1748
|
+
if (!loaded)
|
|
1749
|
+
return null;
|
|
1750
|
+
this.events.emit('inputbindingschange', {
|
|
1751
|
+
bindings: cloneInputBindingMap(loaded.snapshot.bindings),
|
|
1752
|
+
source: 'storage',
|
|
1753
|
+
});
|
|
1754
|
+
return structuredClone(loaded.snapshot);
|
|
1755
|
+
}
|
|
1756
|
+
catch (error) {
|
|
1757
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_LOAD_FAILED', 'Anyo Player could not load the saved input bindings.');
|
|
1758
|
+
this.events.emit('inputbindingserror', playerError);
|
|
1759
|
+
throw playerError;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
async clearInputBindings(key) {
|
|
1763
|
+
this.assertNotDisposed();
|
|
1764
|
+
try {
|
|
1765
|
+
const resolvedKey = await this.inputBindingsController.clear(key);
|
|
1766
|
+
this.events.emit('inputbindingscleared', { key: resolvedKey });
|
|
1767
|
+
}
|
|
1768
|
+
catch (error) {
|
|
1769
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_SAVE_FAILED', 'Anyo Player could not clear the saved input bindings.');
|
|
1770
|
+
this.events.emit('inputbindingserror', playerError);
|
|
1771
|
+
throw playerError;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
setReducedMotion(preference) {
|
|
1775
|
+
this.assertNotDisposed();
|
|
1776
|
+
return this.accessibilityController.setReducedMotion(preference);
|
|
1777
|
+
}
|
|
1778
|
+
announce(message, options = {}) {
|
|
1779
|
+
this.assertNotDisposed();
|
|
1780
|
+
this.accessibilityController.announce(message, options.priority ?? 'polite');
|
|
1781
|
+
}
|
|
1782
|
+
showCaption(text, options = {}) {
|
|
1783
|
+
this.assertNotDisposed();
|
|
1784
|
+
const caption = this.captionController.show(text, options);
|
|
1785
|
+
if (options.announce === true) {
|
|
1786
|
+
const message = caption.speaker ? `${caption.speaker}: ${caption.text}` : caption.text;
|
|
1787
|
+
this.accessibilityController.announce(message, 'polite');
|
|
1788
|
+
}
|
|
1789
|
+
return caption;
|
|
1790
|
+
}
|
|
1791
|
+
clearCaption() {
|
|
1792
|
+
this.assertNotDisposed();
|
|
1793
|
+
this.captionController.clear();
|
|
1794
|
+
}
|
|
1795
|
+
registerCaptionTarget(target) {
|
|
1796
|
+
this.assertNotDisposed();
|
|
1797
|
+
return this.captionController.register(target);
|
|
1798
|
+
}
|
|
1799
|
+
registerVfxTarget(target) {
|
|
1800
|
+
this.assertNotDisposed();
|
|
1801
|
+
return this.vfxController.register(target);
|
|
1802
|
+
}
|
|
1803
|
+
registerMapTarget(target) {
|
|
1804
|
+
this.assertNotDisposed();
|
|
1805
|
+
return this.mapController.register(target);
|
|
1806
|
+
}
|
|
1807
|
+
setMapAttribution(text) {
|
|
1808
|
+
this.assertNotDisposed();
|
|
1809
|
+
this.mapController.setAttribution(text);
|
|
1810
|
+
}
|
|
1811
|
+
registerAudioTarget(target) {
|
|
1812
|
+
this.assertNotDisposed();
|
|
1813
|
+
return this.audioController.register(target);
|
|
1814
|
+
}
|
|
1815
|
+
unlockAudio() {
|
|
1816
|
+
this.assertNotDisposed();
|
|
1817
|
+
return this.audioController.unlock();
|
|
1818
|
+
}
|
|
1819
|
+
setAudioMuted(muted) {
|
|
1820
|
+
this.assertNotDisposed();
|
|
1821
|
+
return this.audioController.setMuted(muted);
|
|
1822
|
+
}
|
|
1823
|
+
toggleAudioMuted() {
|
|
1824
|
+
this.assertNotDisposed();
|
|
1825
|
+
return this.audioController.setMuted(!this.audioController.state.muted);
|
|
1826
|
+
}
|
|
1827
|
+
async captureScreenshot(options = {}) {
|
|
1828
|
+
this.assertNotDisposed();
|
|
1829
|
+
if (!this.activeRuntime || !['ready', 'entering', 'running', 'paused'].includes(this.state)) {
|
|
1830
|
+
const error = new AnyoPlayerError('PLAYER_INVALID_STATE', `captureScreenshot() requires a loaded non-XR world, not state "${this.state}".`);
|
|
1831
|
+
this.events.emit('screenshoterror', error);
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const screenshot = await this.screenshotController.capture(this.canvas, () => this.activeRuntime?.renderer.render(0), options);
|
|
1836
|
+
this.events.emit('screenshotcaptured', { screenshot });
|
|
1837
|
+
return screenshot;
|
|
1838
|
+
}
|
|
1839
|
+
catch (cause) {
|
|
1840
|
+
const error = cause instanceof AnyoPlayerError
|
|
1841
|
+
? cause
|
|
1842
|
+
: new AnyoPlayerError('PLAYER_SCREENSHOT_FAILED', 'Anyo Player could not capture a screenshot.', { cause });
|
|
1843
|
+
this.ui.setControlError(error);
|
|
1844
|
+
this.events.emit('screenshoterror', error);
|
|
1845
|
+
throw error;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
recoverRenderer() {
|
|
1849
|
+
this.assertNotDisposed();
|
|
1850
|
+
if (!this.rendererRecoveryOptions.enabled) {
|
|
1851
|
+
return Promise.reject(new AnyoPlayerError('PLAYER_RENDERER_RECOVERY_DISABLED', 'Renderer recovery is disabled for this Anyo Player.'));
|
|
1852
|
+
}
|
|
1853
|
+
if (this.rendererRecoveryPromise)
|
|
1854
|
+
return this.rendererRecoveryPromise;
|
|
1855
|
+
if (this.state !== 'error' || this.errorValue?.code !== 'PLAYER_RENDERER_LOST' || this.lastSource === undefined) {
|
|
1856
|
+
return Promise.reject(new AnyoPlayerError('PLAYER_INVALID_STATE', `recoverRenderer() requires a renderer-loss error state, not "${this.state}".`));
|
|
1857
|
+
}
|
|
1858
|
+
this.clearRendererRecoveryTimer();
|
|
1859
|
+
const run = this.performRendererRecovery(false);
|
|
1860
|
+
const tracked = run.finally(() => {
|
|
1861
|
+
if (this.rendererRecoveryPromise === tracked)
|
|
1862
|
+
this.rendererRecoveryPromise = null;
|
|
1863
|
+
});
|
|
1864
|
+
this.rendererRecoveryPromise = tracked;
|
|
1865
|
+
return tracked;
|
|
1866
|
+
}
|
|
1867
|
+
cancelRendererRecovery() {
|
|
1868
|
+
this.assertNotDisposed();
|
|
1869
|
+
this.clearRendererRecoveryTimer();
|
|
1870
|
+
if (this.rendererRecoveryValue.state === 'scheduled') {
|
|
1871
|
+
this.setRendererRecovery({
|
|
1872
|
+
...this.rendererRecoveryValue,
|
|
1873
|
+
state: 'canceled',
|
|
1874
|
+
automatic: true,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
clearDiagnostics() {
|
|
1879
|
+
this.assertNotDisposed();
|
|
1880
|
+
this.diagnosticsValue = [];
|
|
1881
|
+
}
|
|
1882
|
+
registerAnalyticsSink(sink) {
|
|
1883
|
+
this.assertNotDisposed();
|
|
1884
|
+
return this.telemetryController.register(sink);
|
|
1885
|
+
}
|
|
1886
|
+
trackTelemetry(name, details, category = 'host') {
|
|
1887
|
+
this.assertNotDisposed();
|
|
1888
|
+
return this.telemetryController.track(name, category, { state: this.state, phase: this.phase }, details);
|
|
1889
|
+
}
|
|
1890
|
+
clearTelemetry() {
|
|
1891
|
+
this.assertNotDisposed();
|
|
1892
|
+
this.telemetryController.clear();
|
|
1893
|
+
}
|
|
1894
|
+
setTheme(theme) {
|
|
1895
|
+
this.assertNotDisposed();
|
|
1896
|
+
const value = this.styleController.setTheme(theme);
|
|
1897
|
+
this.events.emit('themechange', { theme: value });
|
|
1898
|
+
return value;
|
|
1899
|
+
}
|
|
1900
|
+
async interact() {
|
|
1901
|
+
this.assertNotDisposed();
|
|
1902
|
+
if (!this.activeRuntime || this.state !== 'running' || !['desktop', 'gamepad'].includes(this.inputModeValue ?? ''))
|
|
1903
|
+
return false;
|
|
1904
|
+
try {
|
|
1905
|
+
return await this.interactionPresentation.activate();
|
|
1906
|
+
}
|
|
1907
|
+
catch (cause) {
|
|
1908
|
+
const error = new AnyoPlayerError('PLAYER_INTERACTION_FAILED', 'Anyo Player could not activate the focused interaction.', { cause });
|
|
1909
|
+
this.ui.setControlError(error);
|
|
1910
|
+
this.events.emit('interactionerror', error);
|
|
1911
|
+
throw error;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
registerAction(name, handler) {
|
|
1915
|
+
this.assertNotDisposed();
|
|
1916
|
+
const normalizedName = name.trim();
|
|
1917
|
+
if (!normalizedName)
|
|
1918
|
+
throw new TypeError('Anyo Player action name must not be empty.');
|
|
1919
|
+
if (typeof handler !== 'function')
|
|
1920
|
+
throw new TypeError('Anyo Player action handler must be a function.');
|
|
1921
|
+
const previous = this.actions.get(normalizedName);
|
|
1922
|
+
if (previous)
|
|
1923
|
+
this.removeRuntimeAction(normalizedName, previous.token);
|
|
1924
|
+
const registration = { token: Symbol(normalizedName), handler };
|
|
1925
|
+
this.actions.set(normalizedName, registration);
|
|
1926
|
+
this.installAction(this.pendingRuntime, normalizedName, registration);
|
|
1927
|
+
this.installAction(this.activeRuntime, normalizedName, registration);
|
|
1928
|
+
return () => {
|
|
1929
|
+
const current = this.actions.get(normalizedName);
|
|
1930
|
+
if (!current || current.token !== registration.token)
|
|
1931
|
+
return;
|
|
1932
|
+
this.actions.delete(normalizedName);
|
|
1933
|
+
this.removeRuntimeAction(normalizedName, registration.token);
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
on(event, listener) {
|
|
1937
|
+
this.assertNotDisposed();
|
|
1938
|
+
return this.events.on(event, listener);
|
|
1939
|
+
}
|
|
1940
|
+
disposeAsync() {
|
|
1941
|
+
if (this.disposalPromise)
|
|
1942
|
+
return this.disposalPromise;
|
|
1943
|
+
if (this.state === 'disposed')
|
|
1944
|
+
return Promise.resolve();
|
|
1945
|
+
if (this.state === 'running' || this.state === 'entering') {
|
|
1946
|
+
this.deactivateActiveInput(true);
|
|
1947
|
+
this.events.emit('exited', { reason: 'dispose' });
|
|
1948
|
+
}
|
|
1949
|
+
this.clearInteractionPrompt();
|
|
1950
|
+
this.clearRendererRecoveryTimer();
|
|
1951
|
+
this.transition('disposing');
|
|
1952
|
+
this.setPhase('disposing');
|
|
1953
|
+
++this.operationId;
|
|
1954
|
+
this.currentAbort?.abort(new AnyoPlayerError('PLAYER_DISPOSED', 'Anyo Player is being disposed.'));
|
|
1955
|
+
this.currentAbort = null;
|
|
1956
|
+
this.disposalPromise = (async () => {
|
|
1957
|
+
await this.operationTail;
|
|
1958
|
+
if (this.fullscreen) {
|
|
1959
|
+
try {
|
|
1960
|
+
await this.fullscreenController.exit();
|
|
1961
|
+
}
|
|
1962
|
+
catch (error) {
|
|
1963
|
+
this.emitWarning(`Anyo Player could not exit fullscreen during disposal: ${String(error)}`);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
this.visibilityController.dispose();
|
|
1967
|
+
this.navigationAbort?.abort(new AnyoPlayerError('PLAYER_DISPOSED', 'Anyo Player was disposed during navigation.'));
|
|
1968
|
+
this.navigationAbort = null;
|
|
1969
|
+
this.cancelWorldPreloadTasks();
|
|
1970
|
+
for (const resolved of this.worldPreloadCache.values())
|
|
1971
|
+
this.releaseResolvedSource(resolved);
|
|
1972
|
+
this.worldPreloadCache.clear();
|
|
1973
|
+
this.setNavigationPresentation('none', null);
|
|
1974
|
+
this.embeddingController.dispose();
|
|
1975
|
+
this.clearPreload();
|
|
1976
|
+
this.fullscreenController.dispose();
|
|
1977
|
+
this.responsive.dispose();
|
|
1978
|
+
this.desktopInput.dispose();
|
|
1979
|
+
this.touchInput.dispose();
|
|
1980
|
+
this.gamepadInput.dispose();
|
|
1981
|
+
this.interactionPresentation.dispose();
|
|
1982
|
+
this.xrController.dispose();
|
|
1983
|
+
this.accessibilityController.dispose();
|
|
1984
|
+
this.captionController.dispose();
|
|
1985
|
+
this.audioController.dispose();
|
|
1986
|
+
this.performanceController.dispose();
|
|
1987
|
+
this.vfxController.dispose();
|
|
1988
|
+
this.mapController.dispose();
|
|
1989
|
+
this.clearInteractionPromptTimer();
|
|
1990
|
+
const runtimes = new Set();
|
|
1991
|
+
if (this.pendingRuntime)
|
|
1992
|
+
runtimes.add(this.pendingRuntime);
|
|
1993
|
+
if (this.activeRuntime)
|
|
1994
|
+
runtimes.add(this.activeRuntime);
|
|
1995
|
+
this.pendingRuntime = null;
|
|
1996
|
+
this.activeRuntime = null;
|
|
1997
|
+
this.activeDocument = null;
|
|
1998
|
+
this.replacementContext = null;
|
|
1999
|
+
this.replacementStage = 'idle';
|
|
2000
|
+
for (const runtime of runtimes)
|
|
2001
|
+
await this.disposeManagedRuntime(runtime);
|
|
2002
|
+
this.releaseActiveSource();
|
|
2003
|
+
for (const cleanup of this.webSurfaceRegistrationCleanups.splice(0))
|
|
2004
|
+
cleanup();
|
|
2005
|
+
this.actions.clear();
|
|
2006
|
+
this.ui.dispose();
|
|
2007
|
+
this.styleController.dispose();
|
|
2008
|
+
this.host.dispose();
|
|
2009
|
+
this.transition('disposed');
|
|
2010
|
+
this.setPhase('disposed');
|
|
2011
|
+
this.events.emit('disposed', undefined);
|
|
2012
|
+
this.events.clear();
|
|
2013
|
+
})();
|
|
2014
|
+
return this.disposalPromise;
|
|
2015
|
+
}
|
|
2016
|
+
async performLoad(operationId, source, abortController) {
|
|
2017
|
+
let runtime = null;
|
|
2018
|
+
let resolved = null;
|
|
2019
|
+
let sourcePromoted = false;
|
|
2020
|
+
try {
|
|
2021
|
+
resolved = await this.resolveRuntimeSource(source, abortController.signal);
|
|
2022
|
+
this.assertCurrentOperation(operationId);
|
|
2023
|
+
await this.restoreConfiguredPreferences();
|
|
2024
|
+
this.assertCurrentOperation(operationId);
|
|
2025
|
+
await this.restoreConfiguredInputBindings();
|
|
2026
|
+
this.assertCurrentOperation(operationId);
|
|
2027
|
+
this.setPhase('creating-runtime');
|
|
2028
|
+
const created = this.runtimeFactory.create({
|
|
2029
|
+
canvas: this.canvas,
|
|
2030
|
+
renderer: {
|
|
2031
|
+
...(this.options.renderer ?? {}),
|
|
2032
|
+
...(this.recoveryRendererBackendOverride ? { backend: this.recoveryRendererBackendOverride } : {}),
|
|
2033
|
+
},
|
|
2034
|
+
performance: this.performanceController.runtimeOptions,
|
|
2035
|
+
exploration: this.options.exploration ?? {},
|
|
2036
|
+
webSurface: this.webSurfaceRuntimeOptions,
|
|
2037
|
+
...(this.options.components === undefined ? {} : { components: this.options.components }),
|
|
2038
|
+
plugins: [...(this.options.plugins ?? [])],
|
|
2039
|
+
systems: [...(this.options.systems ?? [])],
|
|
2040
|
+
...(this.options.systemOptions === undefined ? {} : { systemOptions: this.options.systemOptions }),
|
|
2041
|
+
...(this.options.validation === undefined ? {} : { validation: this.options.validation }),
|
|
2042
|
+
onWarning: message => this.emitWarning(message),
|
|
2043
|
+
onCameraModeChange: (previous, mode) => {
|
|
2044
|
+
if (!this.isCurrentOperation(operationId))
|
|
2045
|
+
return;
|
|
2046
|
+
this.events.emit('cameramodechange', { previous, mode });
|
|
2047
|
+
this.emitTelemetry('camera.mode', { previous, mode });
|
|
2048
|
+
},
|
|
2049
|
+
onFallRecoveryChange: status => {
|
|
2050
|
+
if (!this.isCurrentOperation(operationId))
|
|
2051
|
+
return;
|
|
2052
|
+
const previous = structuredClone(this.fallRecoveryValue);
|
|
2053
|
+
this.fallRecoveryValue = structuredClone(status);
|
|
2054
|
+
this.events.emit('fallrecoverychange', { previous, recovery: structuredClone(status) });
|
|
2055
|
+
this.emitTelemetry('exploration.fall-recovery', { state: status.state, attempt: status.attempt, maxAttempts: status.maxAttempts });
|
|
2056
|
+
if (status.state === 'failed' && status.message)
|
|
2057
|
+
this.emitWarning(status.message);
|
|
2058
|
+
},
|
|
2059
|
+
});
|
|
2060
|
+
runtime = this.manageRuntime(created, operationId);
|
|
2061
|
+
this.pendingRuntime = runtime;
|
|
2062
|
+
this.responsive.attach(runtime.renderer);
|
|
2063
|
+
this.installStagedActions(runtime);
|
|
2064
|
+
runtime.world.exploration.setInputEnabled(false);
|
|
2065
|
+
this.setPhase('loading-world');
|
|
2066
|
+
await this.awaitRuntimeStep(runtime, runtime.world.load(resolved.document));
|
|
2067
|
+
this.assertCurrentOperation(operationId);
|
|
2068
|
+
this.responsive.resizeNow(true);
|
|
2069
|
+
this.setPhase('waiting-ready');
|
|
2070
|
+
await this.awaitRuntimeStep(runtime, runtime.world.whenReady());
|
|
2071
|
+
this.assertCurrentOperation(operationId);
|
|
2072
|
+
runtime.world.start();
|
|
2073
|
+
this.setPhase('loading-assets');
|
|
2074
|
+
await this.awaitRuntimeStep(runtime, runtime.world.whenIdle());
|
|
2075
|
+
this.assertCurrentOperation(operationId);
|
|
2076
|
+
this.progressValue = { ...runtime.world.getAssetProgress() };
|
|
2077
|
+
this.emitProgress();
|
|
2078
|
+
this.pendingRuntime = null;
|
|
2079
|
+
this.activeRuntime = runtime;
|
|
2080
|
+
this.performanceController.attach(this.activeRuntime.renderer);
|
|
2081
|
+
this.interactionPresentation.bind(runtime.world, runtime.renderer);
|
|
2082
|
+
runtime = null;
|
|
2083
|
+
this.activeDocument = structuredClone(resolved.document);
|
|
2084
|
+
if (!this.navigationCommitted)
|
|
2085
|
+
this.currentWorldIdValue = this.findNavigationWorldId(source);
|
|
2086
|
+
this.configureInput(resolved.document, this.activeRuntime.world);
|
|
2087
|
+
this.promoteResolvedSource(resolved);
|
|
2088
|
+
sourcePromoted = true;
|
|
2089
|
+
this.xrController.bind(this.activeRuntime.world);
|
|
2090
|
+
this.ui.setVRSupport(this.xrController.supportState);
|
|
2091
|
+
const readyStatus = this.progressValue.failed > 0
|
|
2092
|
+
? 'ready-with-warnings'
|
|
2093
|
+
: 'ready';
|
|
2094
|
+
this.readyStatusValue = readyStatus;
|
|
2095
|
+
this.transition('ready');
|
|
2096
|
+
this.setPhase(readyStatus);
|
|
2097
|
+
this.ui.setReady(readyStatus, this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2098
|
+
this.embeddingController.handleReady();
|
|
2099
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2100
|
+
this.events.emit('ready', {
|
|
2101
|
+
world: this.activeRuntime.world,
|
|
2102
|
+
progress: this.progress,
|
|
2103
|
+
status: readyStatus,
|
|
2104
|
+
});
|
|
2105
|
+
await this.restoreConfiguredSessionAfterLoad();
|
|
2106
|
+
if (this.xrController.checkSupportOnLoad && this.vrEnabledValue) {
|
|
2107
|
+
void this.checkVRSupport().catch(() => undefined);
|
|
2108
|
+
}
|
|
2109
|
+
if (this.visibilityController.pauseWhenHidden && this.visibilityController.hidden) {
|
|
2110
|
+
this.pauseInternal('visibility', false);
|
|
2111
|
+
}
|
|
2112
|
+
else if (this.embeddingController.pauseWhenOffscreen && this.embeddingController.intersection === 'hidden') {
|
|
2113
|
+
this.pauseInternal('offscreen', false);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
catch (error) {
|
|
2117
|
+
if (runtime) {
|
|
2118
|
+
if (this.pendingRuntime === runtime)
|
|
2119
|
+
this.pendingRuntime = null;
|
|
2120
|
+
await this.disposeManagedRuntime(runtime);
|
|
2121
|
+
}
|
|
2122
|
+
if (!this.isCurrentOperation(operationId)) {
|
|
2123
|
+
if (this.state === 'disposing' || this.state === 'disposed') {
|
|
2124
|
+
throw new AnyoPlayerError('PLAYER_DISPOSED', 'Anyo Player was disposed during loading.', { cause: error });
|
|
2125
|
+
}
|
|
2126
|
+
throw new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player loading was superseded by a newer operation.', { cause: error });
|
|
2127
|
+
}
|
|
2128
|
+
const playerError = toPlayerError(error, 'PLAYER_WORLD_LOAD_FAILED', 'Anyo Player failed to create or load the world.');
|
|
2129
|
+
this.errorValue = playerError;
|
|
2130
|
+
this.readyStatusValue = null;
|
|
2131
|
+
this.transition('error');
|
|
2132
|
+
this.setPhase('error');
|
|
2133
|
+
this.ui.setError(playerError);
|
|
2134
|
+
this.embeddingController.handleError();
|
|
2135
|
+
this.events.emit('error', playerError);
|
|
2136
|
+
throw playerError;
|
|
2137
|
+
}
|
|
2138
|
+
finally {
|
|
2139
|
+
if (!sourcePromoted)
|
|
2140
|
+
this.releaseResolvedSource(resolved);
|
|
2141
|
+
if (this.currentAbort === abortController)
|
|
2142
|
+
this.currentAbort = null;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
async performReplacement(operationId, source, abortController) {
|
|
2146
|
+
const runtime = this.activeRuntime;
|
|
2147
|
+
const context = this.replacementContext;
|
|
2148
|
+
if (!runtime || !context) {
|
|
2149
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'Anyo Player lost its active runtime before world replacement could begin.');
|
|
2150
|
+
}
|
|
2151
|
+
let resolved = null;
|
|
2152
|
+
let sourcePromoted = false;
|
|
2153
|
+
try {
|
|
2154
|
+
resolved = await this.resolveRuntimeSource(source, abortController.signal);
|
|
2155
|
+
this.assertCurrentOperation(operationId);
|
|
2156
|
+
await this.restoreConfiguredPreferences();
|
|
2157
|
+
this.assertCurrentOperation(operationId);
|
|
2158
|
+
this.replacementStage = 'mutating';
|
|
2159
|
+
runtime.operationId = operationId;
|
|
2160
|
+
this.setPhase('replacing-world');
|
|
2161
|
+
await this.awaitRuntimeStep(runtime, runtime.world.load(resolved.document));
|
|
2162
|
+
this.assertCurrentOperation(operationId);
|
|
2163
|
+
this.responsive.resizeNow(true);
|
|
2164
|
+
this.setPhase('waiting-ready');
|
|
2165
|
+
await this.awaitRuntimeStep(runtime, runtime.world.whenReady());
|
|
2166
|
+
this.assertCurrentOperation(operationId);
|
|
2167
|
+
if (context.returnState === 'ready' && !runtime.world.isRunning)
|
|
2168
|
+
runtime.world.start();
|
|
2169
|
+
if (context.returnState === 'paused' && runtime.world.isRunning)
|
|
2170
|
+
runtime.world.pause();
|
|
2171
|
+
this.setPhase('loading-assets');
|
|
2172
|
+
await this.awaitRuntimeStep(runtime, runtime.world.whenIdle());
|
|
2173
|
+
this.assertCurrentOperation(operationId);
|
|
2174
|
+
this.activeDocument = structuredClone(resolved.document);
|
|
2175
|
+
this.lastSource = source;
|
|
2176
|
+
if (!this.navigationCommitted)
|
|
2177
|
+
this.currentWorldIdValue = this.findNavigationWorldId(source);
|
|
2178
|
+
this.progressValue = { ...runtime.world.getAssetProgress() };
|
|
2179
|
+
this.emitProgress();
|
|
2180
|
+
this.configureInput(resolved.document, runtime.world);
|
|
2181
|
+
this.promoteResolvedSource(resolved);
|
|
2182
|
+
sourcePromoted = true;
|
|
2183
|
+
this.interactionPresentation.bind(runtime.world, runtime.renderer);
|
|
2184
|
+
this.interactionPresentation.refreshNow();
|
|
2185
|
+
this.xrController.bind(runtime.world);
|
|
2186
|
+
this.ui.setVRSupport(this.xrController.supportState);
|
|
2187
|
+
const readyStatus = this.progressValue.failed > 0
|
|
2188
|
+
? 'ready-with-warnings'
|
|
2189
|
+
: 'ready';
|
|
2190
|
+
this.readyStatusValue = readyStatus;
|
|
2191
|
+
this.errorValue = null;
|
|
2192
|
+
this.replacementContext = null;
|
|
2193
|
+
this.replacementStage = 'idle';
|
|
2194
|
+
this.setPhase(readyStatus);
|
|
2195
|
+
this.embeddingController.handleReady();
|
|
2196
|
+
if (context.returnState === 'paused') {
|
|
2197
|
+
this.pauseReasonValue = context.pauseReason ?? 'user';
|
|
2198
|
+
this.transition('paused');
|
|
2199
|
+
this.ui.setPaused(this.pauseReasonValue);
|
|
2200
|
+
this.touchInput.setVisible(false);
|
|
2201
|
+
}
|
|
2202
|
+
else {
|
|
2203
|
+
this.pauseReasonValue = null;
|
|
2204
|
+
this.transition('ready');
|
|
2205
|
+
this.ui.setReady(readyStatus, this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2206
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2207
|
+
}
|
|
2208
|
+
const readyEvent = {
|
|
2209
|
+
world: runtime.world,
|
|
2210
|
+
progress: this.progress,
|
|
2211
|
+
status: readyStatus,
|
|
2212
|
+
};
|
|
2213
|
+
this.events.emit('ready', readyEvent);
|
|
2214
|
+
this.performanceController.attach(runtime.renderer);
|
|
2215
|
+
this.events.emit('worldreplaced', readyEvent);
|
|
2216
|
+
await this.restoreConfiguredSessionAfterLoad();
|
|
2217
|
+
if (this.xrController.checkSupportOnLoad && this.vrEnabledValue) {
|
|
2218
|
+
void this.checkVRSupport().catch(() => undefined);
|
|
2219
|
+
}
|
|
2220
|
+
if (context.returnState === 'ready'
|
|
2221
|
+
&& this.visibilityController.pauseWhenHidden
|
|
2222
|
+
&& this.visibilityController.hidden) {
|
|
2223
|
+
this.pauseInternal('visibility', false);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
catch (cause) {
|
|
2227
|
+
if (!this.isCurrentOperation(operationId)) {
|
|
2228
|
+
if (this.state === 'disposing' || this.state === 'disposed') {
|
|
2229
|
+
throw new AnyoPlayerError('PLAYER_DISPOSED', 'Anyo Player was disposed during world replacement.', { cause });
|
|
2230
|
+
}
|
|
2231
|
+
throw new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player world replacement was superseded by a newer source.', { cause });
|
|
2232
|
+
}
|
|
2233
|
+
if (runtime.fatalDiagnostic || this.state === 'error') {
|
|
2234
|
+
this.replacementContext = null;
|
|
2235
|
+
this.replacementStage = 'idle';
|
|
2236
|
+
throw this.errorValue ?? this.rendererLossError(runtime.fatalDiagnostic);
|
|
2237
|
+
}
|
|
2238
|
+
const replacementError = toPlayerError(cause, 'PLAYER_WORLD_REPLACE_FAILED', 'The new world could not be loaded. The previous world is still available.');
|
|
2239
|
+
let rollbackHealthy = false;
|
|
2240
|
+
try {
|
|
2241
|
+
await runtime.world.whenReady();
|
|
2242
|
+
if (context.returnState === 'ready' && !runtime.world.isRunning)
|
|
2243
|
+
runtime.world.start();
|
|
2244
|
+
if (context.returnState === 'paused' && runtime.world.isRunning)
|
|
2245
|
+
runtime.world.pause();
|
|
2246
|
+
await runtime.world.whenIdle();
|
|
2247
|
+
rollbackHealthy = true;
|
|
2248
|
+
}
|
|
2249
|
+
catch (rollbackError) {
|
|
2250
|
+
this.emitWarning(`Anyo Player could not verify the previous world after replacement failure: ${String(rollbackError)}`);
|
|
2251
|
+
}
|
|
2252
|
+
if (!rollbackHealthy) {
|
|
2253
|
+
const fatalError = new AnyoPlayerError('PLAYER_WORLD_REPLACE_FAILED', 'World replacement failed and the previous world could not be restored safely.', { cause });
|
|
2254
|
+
this.replacementContext = null;
|
|
2255
|
+
this.replacementStage = 'idle';
|
|
2256
|
+
this.errorValue = fatalError;
|
|
2257
|
+
this.readyStatusValue = null;
|
|
2258
|
+
this.activeDocument = null;
|
|
2259
|
+
this.transition('error');
|
|
2260
|
+
this.setPhase('error');
|
|
2261
|
+
this.ui.setError(fatalError);
|
|
2262
|
+
this.events.emit('error', fatalError);
|
|
2263
|
+
if (this.activeRuntime === runtime) {
|
|
2264
|
+
this.activeRuntime = null;
|
|
2265
|
+
this.performanceController.detach();
|
|
2266
|
+
}
|
|
2267
|
+
await this.disposeManagedRuntime(runtime);
|
|
2268
|
+
this.releaseActiveSource();
|
|
2269
|
+
throw fatalError;
|
|
2270
|
+
}
|
|
2271
|
+
this.lastSource = context.previousSource;
|
|
2272
|
+
this.activeDocument = structuredClone(context.previousDocument);
|
|
2273
|
+
this.progressValue = { ...runtime.world.getAssetProgress() };
|
|
2274
|
+
if (this.progressValue.total === 0 && context.previousProgress.total > 0) {
|
|
2275
|
+
this.progressValue = { ...context.previousProgress };
|
|
2276
|
+
}
|
|
2277
|
+
this.emitProgress();
|
|
2278
|
+
this.readyStatusValue = this.progressValue.failed > 0
|
|
2279
|
+
? 'ready-with-warnings'
|
|
2280
|
+
: context.previousReadyStatus;
|
|
2281
|
+
this.errorValue = null;
|
|
2282
|
+
this.configureInput(context.previousDocument, runtime.world);
|
|
2283
|
+
this.interactionPresentation.bind(runtime.world, runtime.renderer);
|
|
2284
|
+
this.interactionPresentation.refreshNow();
|
|
2285
|
+
this.replacementContext = null;
|
|
2286
|
+
this.replacementStage = 'idle';
|
|
2287
|
+
this.setPhase(this.readyStatusValue);
|
|
2288
|
+
if (context.returnState === 'paused') {
|
|
2289
|
+
this.pauseReasonValue = context.pauseReason ?? 'user';
|
|
2290
|
+
this.transition('paused');
|
|
2291
|
+
this.ui.setPaused(this.pauseReasonValue);
|
|
2292
|
+
this.touchInput.setVisible(false);
|
|
2293
|
+
}
|
|
2294
|
+
else {
|
|
2295
|
+
this.pauseReasonValue = null;
|
|
2296
|
+
this.transition('ready');
|
|
2297
|
+
this.ui.setReady(this.readyStatusValue, this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2298
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2299
|
+
}
|
|
2300
|
+
this.ui.setControlError(replacementError);
|
|
2301
|
+
this.events.emit('worldreplaceerror', replacementError);
|
|
2302
|
+
throw replacementError;
|
|
2303
|
+
}
|
|
2304
|
+
finally {
|
|
2305
|
+
if (!sourcePromoted)
|
|
2306
|
+
this.releaseResolvedSource(resolved);
|
|
2307
|
+
if (this.currentAbort === abortController)
|
|
2308
|
+
this.currentAbort = null;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
async restoreConfiguredPreferences() {
|
|
2312
|
+
if (!this.performancePreferenceRestoreAttempted) {
|
|
2313
|
+
this.performancePreferenceRestoreAttempted = true;
|
|
2314
|
+
if (this.performanceController.restoreOnLoad) {
|
|
2315
|
+
const previous = this.performanceController.preference;
|
|
2316
|
+
try {
|
|
2317
|
+
const loaded = await this.performanceController.restoreConfigured();
|
|
2318
|
+
const key = loaded?.key ?? this.performanceController.storageKey ?? '';
|
|
2319
|
+
this.events.emit('qualitypreferenceloaded', { key, snapshot: loaded?.snapshot ?? null });
|
|
2320
|
+
if (loaded)
|
|
2321
|
+
this.emitQualityPreferenceChange(previous, 'storage');
|
|
2322
|
+
}
|
|
2323
|
+
catch (cause) {
|
|
2324
|
+
this.emitWarning(this.preferenceError(cause, 'PLAYER_QUALITY_PREFERENCE_INVALID', 'Stored quality preference could not be restored.').message);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (!this.viewPreferenceRestoreAttempted) {
|
|
2329
|
+
this.viewPreferenceRestoreAttempted = true;
|
|
2330
|
+
if (this.viewPreferenceController.restoreOnLoad) {
|
|
2331
|
+
const previous = this.viewPreferenceController.preference;
|
|
2332
|
+
try {
|
|
2333
|
+
const loaded = await this.viewPreferenceController.restoreConfigured();
|
|
2334
|
+
const key = loaded?.key ?? this.viewPreferenceController.storageKey ?? '';
|
|
2335
|
+
this.events.emit('viewpreferenceloaded', { key, snapshot: loaded?.snapshot ?? null });
|
|
2336
|
+
if (loaded) {
|
|
2337
|
+
this.applyViewPreference();
|
|
2338
|
+
this.events.emit('viewpreferencechange', { previous, preference: loaded.snapshot, source: 'storage' });
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
catch (cause) {
|
|
2342
|
+
this.emitWarning(this.preferenceError(cause, 'PLAYER_VIEW_PREFERENCE_INVALID', 'Stored view preference could not be restored.').message);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
emitQualityPreferenceChange(previous, source) {
|
|
2348
|
+
const preference = this.performanceController.preference;
|
|
2349
|
+
this.events.emit('qualitypreferencechange', { previous, preference, source });
|
|
2350
|
+
this.emitTelemetry('quality.preference-changed', {
|
|
2351
|
+
source,
|
|
2352
|
+
quality: preference.quality,
|
|
2353
|
+
targetFps: preference.targetFps,
|
|
2354
|
+
dynamicResolution: preference.dynamicResolution,
|
|
2355
|
+
}, 'renderer');
|
|
2356
|
+
}
|
|
2357
|
+
persistQualityPreferenceIfConfigured() {
|
|
2358
|
+
if (!this.performanceController.saveOnChange)
|
|
2359
|
+
return;
|
|
2360
|
+
void this.saveQualityPreference().catch(error => this.emitWarning(error instanceof Error ? error.message : String(error)));
|
|
2361
|
+
}
|
|
2362
|
+
persistViewPreferenceIfConfigured() {
|
|
2363
|
+
if (!this.viewPreferenceController.saveOnChange)
|
|
2364
|
+
return;
|
|
2365
|
+
void this.saveViewPreference().catch(error => this.emitWarning(error instanceof Error ? error.message : String(error)));
|
|
2366
|
+
}
|
|
2367
|
+
preferenceError(cause, code, message) {
|
|
2368
|
+
if (cause instanceof AnyoPlayerError)
|
|
2369
|
+
return cause;
|
|
2370
|
+
return new AnyoPlayerError(code, message, { cause });
|
|
2371
|
+
}
|
|
2372
|
+
applyViewPreference() {
|
|
2373
|
+
if (!this.viewPreferenceController.enabled)
|
|
2374
|
+
return;
|
|
2375
|
+
const preference = this.viewPreferenceController.preference;
|
|
2376
|
+
this.activeRuntime?.camera?.setFieldOfView(preference.fieldOfView);
|
|
2377
|
+
this.touchInput.setLookSettings(preference.touchLookScale, preference.invertY);
|
|
2378
|
+
this.gamepadInput.setLookSettings(preference.gamepadLookScale, preference.invertY);
|
|
2379
|
+
}
|
|
2380
|
+
promoteResolvedSource(resolved) {
|
|
2381
|
+
const normalized = cloneResolvedSource(resolved);
|
|
2382
|
+
this.activeSourceCleanup?.();
|
|
2383
|
+
this.activeSourceCleanup = normalized.cleanup ?? null;
|
|
2384
|
+
this.sourceInfoValue = structuredClone(normalized.info);
|
|
2385
|
+
}
|
|
2386
|
+
releaseResolvedSource(resolved) {
|
|
2387
|
+
try {
|
|
2388
|
+
resolved?.cleanup?.();
|
|
2389
|
+
}
|
|
2390
|
+
catch (error) {
|
|
2391
|
+
this.emitWarning(`Anyo Player source cleanup failed: ${String(error)}`);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
releaseActiveSource() {
|
|
2395
|
+
try {
|
|
2396
|
+
this.activeSourceCleanup?.();
|
|
2397
|
+
}
|
|
2398
|
+
catch (error) {
|
|
2399
|
+
this.emitWarning(`Anyo Player source cleanup failed: ${String(error)}`);
|
|
2400
|
+
}
|
|
2401
|
+
this.activeSourceCleanup = null;
|
|
2402
|
+
this.sourceInfoValue = null;
|
|
2403
|
+
}
|
|
2404
|
+
downloadJson(value, filename) {
|
|
2405
|
+
if (typeof document === 'undefined' || typeof URL === 'undefined')
|
|
2406
|
+
return;
|
|
2407
|
+
const blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' });
|
|
2408
|
+
const url = URL.createObjectURL(blob);
|
|
2409
|
+
const anchor = document.createElement('a');
|
|
2410
|
+
anchor.href = url;
|
|
2411
|
+
anchor.download = filename;
|
|
2412
|
+
anchor.style.display = 'none';
|
|
2413
|
+
document.body.append(anchor);
|
|
2414
|
+
anchor.click();
|
|
2415
|
+
anchor.remove();
|
|
2416
|
+
URL.revokeObjectURL(url);
|
|
2417
|
+
}
|
|
2418
|
+
findNavigationWorldId(source) {
|
|
2419
|
+
if (!this.navigationOptions)
|
|
2420
|
+
return null;
|
|
2421
|
+
for (const entry of this.navigationOptions.worlds.values()) {
|
|
2422
|
+
if (samePlayerSource(entry.source, source))
|
|
2423
|
+
return entry.id;
|
|
2424
|
+
}
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
getNavigationWorld(worldId) {
|
|
2428
|
+
if (!this.navigationOptions) {
|
|
2429
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_DISABLED', 'World navigation is not configured for this Anyo Player.');
|
|
2430
|
+
}
|
|
2431
|
+
const id = worldId.trim();
|
|
2432
|
+
const entry = this.navigationOptions.worlds.get(id);
|
|
2433
|
+
if (!entry) {
|
|
2434
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_UNKNOWN_WORLD', `Unknown Anyo Player world "${id || worldId}".`);
|
|
2435
|
+
}
|
|
2436
|
+
return entry;
|
|
2437
|
+
}
|
|
2438
|
+
assertNavigationCurrent(sequence, controller, allowCommitted = false) {
|
|
2439
|
+
if (controller.signal.aborted || sequence !== this.navigationSequence) {
|
|
2440
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_CANCELED', 'World navigation was superseded or canceled.', {
|
|
2441
|
+
cause: controller.signal.reason,
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
if (!allowCommitted && this.navigationCommitted) {
|
|
2445
|
+
throw new AnyoPlayerError('PLAYER_NAVIGATION_BUSY', 'World navigation has already started committing.');
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
setNavigation(navigation) {
|
|
2449
|
+
const previous = cloneNavigationStatus(this.navigationValue);
|
|
2450
|
+
this.navigationValue = cloneNavigationStatus(navigation);
|
|
2451
|
+
this.events.emit('worldnavigationchange', { previous, navigation: cloneNavigationStatus(navigation) });
|
|
2452
|
+
}
|
|
2453
|
+
setNavigationPresentation(presentation, phase) {
|
|
2454
|
+
const active = presentation !== 'none' && phase !== null;
|
|
2455
|
+
if (active) {
|
|
2456
|
+
this.container.classList.add('anyo-player--world-transition');
|
|
2457
|
+
this.container.setAttribute('data-anyo-player-world-transition', phase);
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
this.container.classList.remove('anyo-player--world-transition');
|
|
2461
|
+
this.container.removeAttribute('data-anyo-player-world-transition');
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
configureInput(document, world) {
|
|
2465
|
+
const exploration = this.options.exploration ?? {};
|
|
2466
|
+
const bindings = this.inputBindingsController.bindings;
|
|
2467
|
+
if (this.desktopEnabled) {
|
|
2468
|
+
this.desktopInput.configure({
|
|
2469
|
+
pointerLock: exploration.pointerLock ?? document.exploration?.pointerLock ?? true,
|
|
2470
|
+
keys: {
|
|
2471
|
+
forward: keyboardCodes(bindings, 'move-forward'),
|
|
2472
|
+
backward: keyboardCodes(bindings, 'move-backward'),
|
|
2473
|
+
left: keyboardCodes(bindings, 'move-left'),
|
|
2474
|
+
right: keyboardCodes(bindings, 'move-right'),
|
|
2475
|
+
run: keyboardCodes(bindings, 'run'),
|
|
2476
|
+
jump: keyboardCodes(bindings, 'jump'),
|
|
2477
|
+
},
|
|
2478
|
+
interact: keyboardCodes(bindings, 'interact'),
|
|
2479
|
+
pause: keyboardCodes(bindings, 'pause'),
|
|
2480
|
+
enter: this.accessibilityController.keyboardNavigation ? ['Enter', 'Space'] : [],
|
|
2481
|
+
look: {
|
|
2482
|
+
up: keyboardCodes(bindings, 'look-up'),
|
|
2483
|
+
down: keyboardCodes(bindings, 'look-down'),
|
|
2484
|
+
left: keyboardCodes(bindings, 'look-left'),
|
|
2485
|
+
right: keyboardCodes(bindings, 'look-right'),
|
|
2486
|
+
},
|
|
2487
|
+
lookStep: this.options.input === false ? 8 : this.options.input?.keyboardLookSensitivity ?? 8,
|
|
2488
|
+
lookScale: this.viewPreference.pointerLookScale,
|
|
2489
|
+
invertY: this.viewPreference.invertY,
|
|
2490
|
+
preventDefaultKeys: exploration.preventDefaultKeys ?? true,
|
|
2491
|
+
});
|
|
2492
|
+
this.desktopInput.bind(world.exploration);
|
|
2493
|
+
}
|
|
2494
|
+
else {
|
|
2495
|
+
world.exploration.setInputEnabled(false);
|
|
2496
|
+
}
|
|
2497
|
+
this.syncAccessibilityBindings(bindings);
|
|
2498
|
+
if (this.touchEnabledValue) {
|
|
2499
|
+
this.touchInput.setLookSettings(this.viewPreference.touchLookScale, this.viewPreference.invertY);
|
|
2500
|
+
this.touchInput.bind(world.exploration);
|
|
2501
|
+
}
|
|
2502
|
+
if (this.gamepadInput.enabled) {
|
|
2503
|
+
this.gamepadInput.setLookSettings(this.viewPreference.gamepadLookScale, this.viewPreference.invertY);
|
|
2504
|
+
this.gamepadInput.configure(bindings);
|
|
2505
|
+
this.gamepadInput.bind(world.exploration);
|
|
2506
|
+
}
|
|
2507
|
+
this.applyViewPreference();
|
|
2508
|
+
}
|
|
2509
|
+
enterFromGesture() {
|
|
2510
|
+
if (this.state !== 'ready')
|
|
2511
|
+
return;
|
|
2512
|
+
try {
|
|
2513
|
+
this.enter();
|
|
2514
|
+
}
|
|
2515
|
+
catch (error) {
|
|
2516
|
+
const playerError = toPlayerError(error, 'PLAYER_INVALID_STATE', 'Anyo Player could not enter desktop controls.');
|
|
2517
|
+
this.ui.setInputError(playerError);
|
|
2518
|
+
this.events.emit('inputerror', playerError);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
enterTouchFromGesture() {
|
|
2522
|
+
if (!this.touchEnabledValue || !this.activeRuntime)
|
|
2523
|
+
return false;
|
|
2524
|
+
if (this.state === 'running')
|
|
2525
|
+
return this.inputModeValue === 'touch';
|
|
2526
|
+
if (this.state !== 'ready')
|
|
2527
|
+
return false;
|
|
2528
|
+
this.transition('entering');
|
|
2529
|
+
this.ui.setEntering();
|
|
2530
|
+
this.deactivateDesktop(true);
|
|
2531
|
+
this.inputModeValue = 'touch';
|
|
2532
|
+
this.clearInteractionPrompt();
|
|
2533
|
+
if (!this.touchInput.activate()) {
|
|
2534
|
+
this.inputModeValue = null;
|
|
2535
|
+
this.transition('ready');
|
|
2536
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2537
|
+
return false;
|
|
2538
|
+
}
|
|
2539
|
+
this.transition('running');
|
|
2540
|
+
this.ui.setRunning();
|
|
2541
|
+
this.syncInteractionPresentation();
|
|
2542
|
+
this.touchInput.setVisible(true);
|
|
2543
|
+
this.events.emit('entered', {
|
|
2544
|
+
world: this.activeRuntime.world,
|
|
2545
|
+
mode: 'touch',
|
|
2546
|
+
pointerLockRequested: false,
|
|
2547
|
+
});
|
|
2548
|
+
return true;
|
|
2549
|
+
}
|
|
2550
|
+
enterGamepadFromActivity(index) {
|
|
2551
|
+
if (!this.gamepadInput.enabled || !this.activeRuntime)
|
|
2552
|
+
return false;
|
|
2553
|
+
if (this.state === 'running')
|
|
2554
|
+
return this.inputModeValue === 'gamepad';
|
|
2555
|
+
if (this.state === 'paused') {
|
|
2556
|
+
try {
|
|
2557
|
+
this.resumeInternal(false);
|
|
2558
|
+
}
|
|
2559
|
+
catch {
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
if (this.state !== 'ready')
|
|
2564
|
+
return false;
|
|
2565
|
+
this.transition('entering');
|
|
2566
|
+
this.ui.setEntering();
|
|
2567
|
+
this.deactivateDesktop(true);
|
|
2568
|
+
this.touchInput.deactivate();
|
|
2569
|
+
this.touchInput.setVisible(false);
|
|
2570
|
+
this.inputModeValue = 'gamepad';
|
|
2571
|
+
this.clearInteractionPrompt();
|
|
2572
|
+
if (!this.gamepadInput.activate(index)) {
|
|
2573
|
+
this.inputModeValue = null;
|
|
2574
|
+
this.transition('ready');
|
|
2575
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2576
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2577
|
+
return false;
|
|
2578
|
+
}
|
|
2579
|
+
this.transition('running');
|
|
2580
|
+
this.ui.setRunning();
|
|
2581
|
+
this.syncInteractionPresentation();
|
|
2582
|
+
this.events.emit('entered', {
|
|
2583
|
+
world: this.activeRuntime.world,
|
|
2584
|
+
mode: 'gamepad',
|
|
2585
|
+
pointerLockRequested: false,
|
|
2586
|
+
});
|
|
2587
|
+
return true;
|
|
2588
|
+
}
|
|
2589
|
+
handleGamepadExit() {
|
|
2590
|
+
if (this.inputModeValue !== 'gamepad')
|
|
2591
|
+
return;
|
|
2592
|
+
if (this.state !== 'running' && this.state !== 'entering')
|
|
2593
|
+
return;
|
|
2594
|
+
this.gamepadInput.deactivate();
|
|
2595
|
+
this.inputModeValue = null;
|
|
2596
|
+
this.transition('ready');
|
|
2597
|
+
this.ui.setEnterReady('gamepad-disconnected');
|
|
2598
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2599
|
+
this.clearInteractionPrompt();
|
|
2600
|
+
this.events.emit('exited', { reason: 'gamepad-disconnected' });
|
|
2601
|
+
}
|
|
2602
|
+
handleBoundAction(action, device) {
|
|
2603
|
+
if (action === 'interact') {
|
|
2604
|
+
if (this.state !== 'running')
|
|
2605
|
+
return;
|
|
2606
|
+
if (device === 'keyboard' && this.inputModeValue !== 'desktop')
|
|
2607
|
+
return;
|
|
2608
|
+
if (device === 'gamepad' && this.inputModeValue !== 'gamepad')
|
|
2609
|
+
return;
|
|
2610
|
+
void this.interact().catch(() => undefined);
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
try {
|
|
2614
|
+
if (this.state === 'paused')
|
|
2615
|
+
this.resumeInternal(device !== 'gamepad');
|
|
2616
|
+
else if (['ready', 'entering', 'running'].includes(this.state))
|
|
2617
|
+
this.pauseInternal('user');
|
|
2618
|
+
}
|
|
2619
|
+
catch (error) {
|
|
2620
|
+
const playerError = toPlayerError(error, 'PLAYER_RUNTIME_CONTROL_FAILED', 'Anyo Player could not change pause state from the input action map.');
|
|
2621
|
+
this.ui.setControlError(playerError);
|
|
2622
|
+
this.events.emit('inputerror', playerError);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
handleTouchTap(clientX, clientY) {
|
|
2626
|
+
const runtime = this.activeRuntime;
|
|
2627
|
+
if (!runtime || this.state !== 'running' || this.inputModeValue !== 'touch')
|
|
2628
|
+
return;
|
|
2629
|
+
let result;
|
|
2630
|
+
try {
|
|
2631
|
+
result = runtime.renderer.pick(clientX, clientY);
|
|
2632
|
+
}
|
|
2633
|
+
catch (error) {
|
|
2634
|
+
this.emitWarning(`Anyo Player touch picking failed: ${String(error)}`);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (!result)
|
|
2638
|
+
return;
|
|
2639
|
+
void runtime.world.selectPrimitive({ ...result, source: 'touch' }).catch(error => {
|
|
2640
|
+
this.emitWarning(`Anyo Player touch interaction failed: ${String(error)}`);
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
handleInteraction(trigger, payload) {
|
|
2644
|
+
const primitive = payload.primitiveId
|
|
2645
|
+
? this.activeRuntime?.world.compiled?.primitiveById?.get(payload.primitiveId)
|
|
2646
|
+
: undefined;
|
|
2647
|
+
const context = {
|
|
2648
|
+
trigger,
|
|
2649
|
+
...(payload.entityId === undefined ? {} : { entityId: payload.entityId }),
|
|
2650
|
+
...(payload.primitiveId === undefined ? {} : { primitiveId: payload.primitiveId }),
|
|
2651
|
+
...(payload.instanceId === undefined ? {} : { instanceId: payload.instanceId }),
|
|
2652
|
+
...(payload.source === undefined ? {} : { source: payload.source }),
|
|
2653
|
+
...(payload.data === undefined ? {} : { data: payload.data }),
|
|
2654
|
+
...(primitive?.interaction?.action === undefined ? {} : { action: primitive.interaction.action }),
|
|
2655
|
+
...(primitive?.interaction?.event === undefined ? {} : { event: primitive.interaction.event }),
|
|
2656
|
+
...(primitive?.interaction?.params === undefined ? {} : { params: primitive.interaction.params }),
|
|
2657
|
+
};
|
|
2658
|
+
if (trigger === 'select')
|
|
2659
|
+
this.events.emit('interaction', context);
|
|
2660
|
+
if (!this.interactionEnabled())
|
|
2661
|
+
return;
|
|
2662
|
+
if (trigger === 'hover' && this.interactionPresentation.running)
|
|
2663
|
+
return;
|
|
2664
|
+
const content = this.resolveInteractionPrompt(context);
|
|
2665
|
+
if (!content) {
|
|
2666
|
+
if (trigger === 'hover')
|
|
2667
|
+
this.restoreInteractionPrompt();
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
this.showInteractionPrompt(content, context, false);
|
|
2671
|
+
if (trigger === 'select') {
|
|
2672
|
+
const duration = this.options.interaction === false
|
|
2673
|
+
? 0
|
|
2674
|
+
: Math.max(0, this.options.interaction?.selectionDuration ?? 1200);
|
|
2675
|
+
this.clearInteractionPromptTimer();
|
|
2676
|
+
if (duration === 0) {
|
|
2677
|
+
this.restoreInteractionPrompt();
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
this.interactionPromptTimer = setTimeout(() => {
|
|
2681
|
+
this.interactionPromptTimer = null;
|
|
2682
|
+
this.restoreInteractionPrompt();
|
|
2683
|
+
}, duration);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
restoreInteractionPrompt() {
|
|
2688
|
+
if (this.interactionPresentation.running && this.reticleInteractionContext) {
|
|
2689
|
+
const content = this.resolveInteractionPrompt(this.reticleInteractionContext);
|
|
2690
|
+
if (content) {
|
|
2691
|
+
this.showInteractionPrompt(content, this.reticleInteractionContext, true);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
const payload = this.hoveredInteractionPayload;
|
|
2696
|
+
if (payload)
|
|
2697
|
+
this.handleInteraction('hover', payload);
|
|
2698
|
+
else
|
|
2699
|
+
this.clearInteractionPrompt();
|
|
2700
|
+
}
|
|
2701
|
+
interactionEnabled() {
|
|
2702
|
+
return this.options.interaction !== false && this.options.interaction?.enabled !== false;
|
|
2703
|
+
}
|
|
2704
|
+
resolveInteractionPrompt(context) {
|
|
2705
|
+
const options = this.options.interaction === false ? undefined : this.options.interaction;
|
|
2706
|
+
try {
|
|
2707
|
+
const resolved = options?.resolvePrompt?.(context);
|
|
2708
|
+
const content = this.normalizeInteractionPromptContent(resolved);
|
|
2709
|
+
if (content)
|
|
2710
|
+
return content;
|
|
2711
|
+
if (resolved === null)
|
|
2712
|
+
return null;
|
|
2713
|
+
}
|
|
2714
|
+
catch (error) {
|
|
2715
|
+
this.emitWarning(`Anyo Player interaction prompt resolver failed: ${String(error)}`);
|
|
2716
|
+
}
|
|
2717
|
+
const dataKey = options?.dataKey ?? 'interactionPrompt';
|
|
2718
|
+
if (isRecord(context.data)) {
|
|
2719
|
+
const dataContent = this.normalizeInteractionPromptContent(context.data[dataKey]);
|
|
2720
|
+
if (dataContent)
|
|
2721
|
+
return dataContent;
|
|
2722
|
+
}
|
|
2723
|
+
const fallback = options?.defaultText
|
|
2724
|
+
?? (this.options.ui === false ? DEFAULT_UI_LABELS.interactionDefault : this.options.ui?.labels?.interactionDefault)
|
|
2725
|
+
?? DEFAULT_UI_LABELS.interactionDefault;
|
|
2726
|
+
return fallback.trim() ? { text: fallback.trim() } : null;
|
|
2727
|
+
}
|
|
2728
|
+
normalizeInteractionPromptContent(value) {
|
|
2729
|
+
if (typeof value === 'string') {
|
|
2730
|
+
const text = value.trim();
|
|
2731
|
+
return text ? { text } : null;
|
|
2732
|
+
}
|
|
2733
|
+
if (!isRecord(value))
|
|
2734
|
+
return null;
|
|
2735
|
+
const textValue = typeof value.text === 'string' ? value.text.trim() : '';
|
|
2736
|
+
const titleValue = typeof value.title === 'string' ? value.title.trim() : '';
|
|
2737
|
+
const text = textValue || titleValue;
|
|
2738
|
+
if (!text)
|
|
2739
|
+
return null;
|
|
2740
|
+
return {
|
|
2741
|
+
text,
|
|
2742
|
+
...(titleValue && titleValue !== text ? { title: titleValue } : {}),
|
|
2743
|
+
...(typeof value.description === 'string' && value.description.trim()
|
|
2744
|
+
? { description: value.description.trim() }
|
|
2745
|
+
: {}),
|
|
2746
|
+
...(typeof value.actionLabel === 'string' && value.actionLabel.trim()
|
|
2747
|
+
? { actionLabel: value.actionLabel.trim() }
|
|
2748
|
+
: {}),
|
|
2749
|
+
...(typeof value.inputHint === 'string' && value.inputHint.trim()
|
|
2750
|
+
? { inputHint: value.inputHint.trim() }
|
|
2751
|
+
: {}),
|
|
2752
|
+
...(typeof value.ariaLabel === 'string' && value.ariaLabel.trim()
|
|
2753
|
+
? { ariaLabel: value.ariaLabel.trim() }
|
|
2754
|
+
: {}),
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
showInteractionPrompt(content, context, actionAvailable) {
|
|
2758
|
+
this.clearInteractionPromptTimer();
|
|
2759
|
+
const labels = this.options.ui === false ? undefined : this.options.ui?.labels;
|
|
2760
|
+
const interactBindings = this.inputBindingsController.bindings.interact;
|
|
2761
|
+
const preferredDevice = this.inputModeValue === 'gamepad' ? 'gamepad' : 'keyboard';
|
|
2762
|
+
const preferredBinding = interactBindings.find(binding => preferredDevice === 'keyboard'
|
|
2763
|
+
? binding.device === 'keyboard'
|
|
2764
|
+
: binding.device !== 'keyboard')
|
|
2765
|
+
?? interactBindings[0];
|
|
2766
|
+
const keyLabel = describeInputBinding(preferredBinding) ?? 'E';
|
|
2767
|
+
const actionLabel = content.actionLabel
|
|
2768
|
+
?? labels?.interactionAction
|
|
2769
|
+
?? DEFAULT_UI_LABELS.interactionAction;
|
|
2770
|
+
const inputHint = actionAvailable
|
|
2771
|
+
? content.inputHint
|
|
2772
|
+
?? (labels?.interactionHint ?? DEFAULT_UI_LABELS.interactionHint).replaceAll('{key}', keyLabel)
|
|
2773
|
+
: content.inputHint ?? null;
|
|
2774
|
+
this.interactionPromptValue = {
|
|
2775
|
+
visible: true,
|
|
2776
|
+
text: content.text,
|
|
2777
|
+
title: content.title ?? null,
|
|
2778
|
+
description: content.description ?? null,
|
|
2779
|
+
actionLabel: actionAvailable ? actionLabel : content.actionLabel ?? null,
|
|
2780
|
+
inputHint,
|
|
2781
|
+
ariaLabel: content.ariaLabel ?? null,
|
|
2782
|
+
actionAvailable,
|
|
2783
|
+
trigger: context.trigger,
|
|
2784
|
+
entityId: context.entityId ?? null,
|
|
2785
|
+
primitiveId: context.primitiveId ?? null,
|
|
2786
|
+
};
|
|
2787
|
+
this.ui.setInteractionPrompt(this.interactionPromptValue);
|
|
2788
|
+
this.events.emit('interactionpromptchange', this.interactionPrompt);
|
|
2789
|
+
}
|
|
2790
|
+
clearInteractionPrompt() {
|
|
2791
|
+
this.clearInteractionPromptTimer();
|
|
2792
|
+
if (!this.interactionPromptValue.visible && this.interactionPromptValue.text === '')
|
|
2793
|
+
return;
|
|
2794
|
+
this.interactionPromptValue = { ...HIDDEN_INTERACTION_PROMPT };
|
|
2795
|
+
this.ui.setInteractionPrompt(this.interactionPromptValue);
|
|
2796
|
+
this.events.emit('interactionpromptchange', this.interactionPrompt);
|
|
2797
|
+
}
|
|
2798
|
+
handleInteractionTargetChange(target, context) {
|
|
2799
|
+
this.interactionTargetValue = { ...target };
|
|
2800
|
+
this.reticleInteractionContext = context;
|
|
2801
|
+
this.reticleValue = {
|
|
2802
|
+
visible: this.interactionPresentation.running,
|
|
2803
|
+
active: target.available,
|
|
2804
|
+
};
|
|
2805
|
+
this.ui.setReticle(this.reticleValue);
|
|
2806
|
+
this.events.emit('interactiontargetchange', { target: this.interactionTarget });
|
|
2807
|
+
if (context && this.interactionEnabled()) {
|
|
2808
|
+
const content = this.resolveInteractionPrompt(context);
|
|
2809
|
+
if (content)
|
|
2810
|
+
this.showInteractionPrompt(content, context, true);
|
|
2811
|
+
else
|
|
2812
|
+
this.clearInteractionPrompt();
|
|
2813
|
+
}
|
|
2814
|
+
else if (this.interactionPresentation.running) {
|
|
2815
|
+
this.clearInteractionPrompt();
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
handleInteractionActivateStart(target) {
|
|
2819
|
+
if (!this.interactionPromptValue.visible || this.interactionPromptValue.primitiveId !== target.primitiveId)
|
|
2820
|
+
return;
|
|
2821
|
+
this.interactionPromptValue = { ...this.interactionPromptValue, actionAvailable: false };
|
|
2822
|
+
this.ui.setInteractionPrompt(this.interactionPromptValue);
|
|
2823
|
+
}
|
|
2824
|
+
handleInteractionActivateComplete(target, selected) {
|
|
2825
|
+
this.events.emit('interactionactivated', { target, selected });
|
|
2826
|
+
if (!selected)
|
|
2827
|
+
this.restoreInteractionPrompt();
|
|
2828
|
+
}
|
|
2829
|
+
handleInteractionError(cause) {
|
|
2830
|
+
this.restoreInteractionPrompt();
|
|
2831
|
+
this.emitWarning(`Anyo Player interaction presentation reported an error: ${String(cause)}`);
|
|
2832
|
+
}
|
|
2833
|
+
syncInteractionPresentation() {
|
|
2834
|
+
const shouldRun = this.interactionEnabled()
|
|
2835
|
+
&& this.state === 'running'
|
|
2836
|
+
&& (this.inputModeValue === 'desktop' || this.inputModeValue === 'gamepad')
|
|
2837
|
+
&& (this.inputModeValue === 'gamepad'
|
|
2838
|
+
|| this.reticleMode === 'running'
|
|
2839
|
+
|| this.desktopInput.pointerLocked);
|
|
2840
|
+
if (shouldRun && this.activeRuntime) {
|
|
2841
|
+
this.interactionPresentation.bind(this.activeRuntime.world, this.activeRuntime.renderer);
|
|
2842
|
+
this.interactionPresentation.start();
|
|
2843
|
+
this.reticleValue = {
|
|
2844
|
+
visible: this.interactionPresentation.running,
|
|
2845
|
+
active: this.interactionPresentation.running && this.interactionTargetValue.available,
|
|
2846
|
+
};
|
|
2847
|
+
this.ui.setReticle(this.reticleValue);
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
this.stopInteractionPresentation();
|
|
2851
|
+
}
|
|
2852
|
+
stopInteractionPresentation() {
|
|
2853
|
+
this.interactionPresentation.stop();
|
|
2854
|
+
this.reticleInteractionContext = null;
|
|
2855
|
+
this.interactionTargetValue = { ...EMPTY_INTERACTION_TARGET };
|
|
2856
|
+
this.reticleValue = { ...HIDDEN_RETICLE };
|
|
2857
|
+
this.ui.setReticle(this.reticleValue);
|
|
2858
|
+
}
|
|
2859
|
+
clearInteractionPromptTimer() {
|
|
2860
|
+
if (this.interactionPromptTimer === null)
|
|
2861
|
+
return;
|
|
2862
|
+
clearTimeout(this.interactionPromptTimer);
|
|
2863
|
+
this.interactionPromptTimer = null;
|
|
2864
|
+
}
|
|
2865
|
+
handleInputExit(reason) {
|
|
2866
|
+
if (this.suppressInputExit || this.inputModeValue !== 'desktop')
|
|
2867
|
+
return;
|
|
2868
|
+
if (this.state !== 'running' && this.state !== 'entering')
|
|
2869
|
+
return;
|
|
2870
|
+
this.desktopInput.deactivate(reason !== 'pointer-lock-exit');
|
|
2871
|
+
this.inputModeValue = null;
|
|
2872
|
+
this.transition('ready');
|
|
2873
|
+
this.ui.setEnterReady(reason);
|
|
2874
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2875
|
+
this.clearInteractionPrompt();
|
|
2876
|
+
this.events.emit('exited', { reason });
|
|
2877
|
+
}
|
|
2878
|
+
handlePointerLockError(error) {
|
|
2879
|
+
const playerError = new AnyoPlayerError('PLAYER_POINTER_LOCK_FAILED', 'Anyo Player could not capture the mouse pointer. Click the player to try again.', { cause: error });
|
|
2880
|
+
this.ui.setInputError(playerError);
|
|
2881
|
+
this.events.emit('inputerror', playerError);
|
|
2882
|
+
}
|
|
2883
|
+
pauseInternal(reason, strict = true) {
|
|
2884
|
+
if (this.state === 'disposing' || this.state === 'disposed') {
|
|
2885
|
+
if (!strict)
|
|
2886
|
+
return;
|
|
2887
|
+
this.assertNotDisposed();
|
|
2888
|
+
}
|
|
2889
|
+
if (!this.activeRuntime || !['ready', 'entering', 'running'].includes(this.state)) {
|
|
2890
|
+
if (!strict)
|
|
2891
|
+
return;
|
|
2892
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `pause() requires a ready or running world, not state "${this.state}".`);
|
|
2893
|
+
}
|
|
2894
|
+
const controlsWereActive = this.state === 'running' || this.state === 'entering';
|
|
2895
|
+
const previousInputMode = controlsWereActive ? this.inputModeValue : null;
|
|
2896
|
+
this.suppressInputExit = true;
|
|
2897
|
+
try {
|
|
2898
|
+
this.activeRuntime.world.pause();
|
|
2899
|
+
if (controlsWereActive)
|
|
2900
|
+
this.deactivateActiveInput(false);
|
|
2901
|
+
}
|
|
2902
|
+
catch (cause) {
|
|
2903
|
+
const error = new AnyoPlayerError('PLAYER_RUNTIME_CONTROL_FAILED', 'Anyo Player could not pause the world runtime.', { cause });
|
|
2904
|
+
if (!strict) {
|
|
2905
|
+
this.emitWarning(error.message);
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
throw error;
|
|
2909
|
+
}
|
|
2910
|
+
finally {
|
|
2911
|
+
this.suppressInputExit = false;
|
|
2912
|
+
}
|
|
2913
|
+
this.resumeInputModeAfterPause = previousInputMode;
|
|
2914
|
+
this.touchInput.setVisible(false);
|
|
2915
|
+
this.clearInteractionPrompt();
|
|
2916
|
+
this.pauseReasonValue = reason;
|
|
2917
|
+
this.transition('paused');
|
|
2918
|
+
this.ui.setPaused(reason);
|
|
2919
|
+
if (controlsWereActive) {
|
|
2920
|
+
this.events.emit('exited', { reason: reason === 'visibility' ? 'visibility' : reason === 'offscreen' ? 'offscreen' : 'pause' });
|
|
2921
|
+
}
|
|
2922
|
+
this.events.emit('paused', { reason });
|
|
2923
|
+
this.vfxController.pause();
|
|
2924
|
+
this.mapController.pause();
|
|
2925
|
+
void this.audioController.pause();
|
|
2926
|
+
if (!this.sessionRestoreActive
|
|
2927
|
+
&& ((reason === 'user' && this.sessionController.saveOnPause)
|
|
2928
|
+
|| (reason === 'visibility' && this.sessionController.saveOnVisibilityHidden))) {
|
|
2929
|
+
void this.saveConfiguredSession().catch(() => undefined);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
resumeInternal(requestControls, strict = true) {
|
|
2933
|
+
if (this.state === 'disposing' || this.state === 'disposed') {
|
|
2934
|
+
if (!strict)
|
|
2935
|
+
return;
|
|
2936
|
+
this.assertNotDisposed();
|
|
2937
|
+
}
|
|
2938
|
+
if (!this.activeRuntime || this.state !== 'paused' || !this.pauseReasonValue) {
|
|
2939
|
+
if (!strict)
|
|
2940
|
+
return;
|
|
2941
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `resume() requires the player to be paused, not state "${this.state}".`);
|
|
2942
|
+
}
|
|
2943
|
+
const reason = this.pauseReasonValue;
|
|
2944
|
+
const resumeMode = requestControls ? this.resumeInputModeAfterPause : null;
|
|
2945
|
+
const controlsRequested = resumeMode !== null;
|
|
2946
|
+
try {
|
|
2947
|
+
this.activeRuntime.world.resume();
|
|
2948
|
+
}
|
|
2949
|
+
catch (cause) {
|
|
2950
|
+
const error = new AnyoPlayerError('PLAYER_RUNTIME_CONTROL_FAILED', 'Anyo Player could not resume the world runtime.', { cause });
|
|
2951
|
+
if (!strict) {
|
|
2952
|
+
this.emitWarning(error.message);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
throw error;
|
|
2956
|
+
}
|
|
2957
|
+
this.pauseReasonValue = null;
|
|
2958
|
+
this.resumeInputModeAfterPause = null;
|
|
2959
|
+
this.transition('ready');
|
|
2960
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
2961
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
2962
|
+
this.events.emit('resumed', { reason, controlsRequested });
|
|
2963
|
+
this.vfxController.resume();
|
|
2964
|
+
this.mapController.resume();
|
|
2965
|
+
void this.audioController.resume();
|
|
2966
|
+
if (resumeMode === 'desktop')
|
|
2967
|
+
this.enterFromGesture();
|
|
2968
|
+
else if (resumeMode === 'touch')
|
|
2969
|
+
this.enterTouchFromGesture();
|
|
2970
|
+
else if (resumeMode === 'gamepad') {
|
|
2971
|
+
const gamepadIndex = this.gamepadInput.gamepads[0]?.index;
|
|
2972
|
+
if (gamepadIndex !== undefined)
|
|
2973
|
+
this.enterGamepadFromActivity(gamepadIndex);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
togglePauseFromGesture() {
|
|
2977
|
+
try {
|
|
2978
|
+
if (this.state === 'paused')
|
|
2979
|
+
this.resumeInternal(true);
|
|
2980
|
+
else
|
|
2981
|
+
this.pauseInternal('user');
|
|
2982
|
+
}
|
|
2983
|
+
catch (error) {
|
|
2984
|
+
const playerError = toPlayerError(error, 'PLAYER_RUNTIME_CONTROL_FAILED', 'Anyo Player could not change pause state.');
|
|
2985
|
+
this.ui.setControlError(playerError);
|
|
2986
|
+
this.emitWarning(playerError.message);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
async toggleAudioFromGesture() {
|
|
2990
|
+
try {
|
|
2991
|
+
if (!this.audioController.state.unlocked || this.audioController.state.blocked) {
|
|
2992
|
+
await this.unlockAudio();
|
|
2993
|
+
}
|
|
2994
|
+
else {
|
|
2995
|
+
await this.toggleAudioMuted();
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
catch {
|
|
2999
|
+
// AudioController already emitted the typed error and updated UI diagnostics.
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
async captureScreenshotFromMenu() {
|
|
3003
|
+
try {
|
|
3004
|
+
await this.captureScreenshot();
|
|
3005
|
+
this.accessibilityController.announce('Screenshot captured.', 'polite');
|
|
3006
|
+
}
|
|
3007
|
+
catch {
|
|
3008
|
+
// captureScreenshot already emitted the typed error and updated UI diagnostics.
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
async toggleVRFromGesture() {
|
|
3012
|
+
if (this.state === 'vr-active' || this.state === 'vr-entering') {
|
|
3013
|
+
await this.exitVR();
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
await this.enterVR();
|
|
3017
|
+
}
|
|
3018
|
+
handleVRSupportChange(previous, state) {
|
|
3019
|
+
this.ui.setVRSupport(state);
|
|
3020
|
+
this.events.emit('vrsupportchange', {
|
|
3021
|
+
previous,
|
|
3022
|
+
state,
|
|
3023
|
+
supported: state === 'supported' ? true : state === 'unsupported' ? false : null,
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
handleXRStateChange(previous, state) {
|
|
3027
|
+
this.events.emit('xrstatechange', { previous, state });
|
|
3028
|
+
if (state === 'entering')
|
|
3029
|
+
this.ui.setVRSession('entering', this.xrInputs.length);
|
|
3030
|
+
else if (state === 'active')
|
|
3031
|
+
this.activateXRSession();
|
|
3032
|
+
else if (state === 'exiting')
|
|
3033
|
+
this.ui.setVRSession('exiting', this.xrInputs.length);
|
|
3034
|
+
else if (state === 'failed' && ['vr-entering', 'vr-active', 'vr-exiting'].includes(this.state)) {
|
|
3035
|
+
this.finishXRSession(!this.xrExitRequested);
|
|
3036
|
+
}
|
|
3037
|
+
else if (state === 'idle' && ['vr-entering', 'vr-active', 'vr-exiting'].includes(this.state)) {
|
|
3038
|
+
this.finishXRSession(!this.xrExitRequested);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
handleXRSessionStart() {
|
|
3042
|
+
this.activateXRSession();
|
|
3043
|
+
}
|
|
3044
|
+
handleXRSessionEnd() {
|
|
3045
|
+
this.finishXRSession(!this.xrExitRequested);
|
|
3046
|
+
}
|
|
3047
|
+
activateXRSession() {
|
|
3048
|
+
if (!this.activeRuntime || this.state !== 'vr-entering')
|
|
3049
|
+
return;
|
|
3050
|
+
this.transition('vr-active');
|
|
3051
|
+
this.ui.setVRSession('active', this.xrInputs.length);
|
|
3052
|
+
this.ui.setXRTracking(this.xrTracking);
|
|
3053
|
+
this.events.emit('vrentered', {
|
|
3054
|
+
world: this.activeRuntime.world,
|
|
3055
|
+
inputs: this.xrInputs,
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
finishXRSession(browserEnded) {
|
|
3059
|
+
if (!['vr-entering', 'vr-active', 'vr-exiting'].includes(this.state))
|
|
3060
|
+
return;
|
|
3061
|
+
this.xrExitRequested = false;
|
|
3062
|
+
this.transition('ready');
|
|
3063
|
+
this.ui.setVRSession('idle', 0);
|
|
3064
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
3065
|
+
this.ui.setVRSupport(this.xrController.supportState);
|
|
3066
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
3067
|
+
this.events.emit('vrexited', { browserEnded });
|
|
3068
|
+
}
|
|
3069
|
+
handleXRInputsChange(inputs) {
|
|
3070
|
+
this.ui.setVRSession(this.state === 'vr-active' ? 'active' : this.state === 'vr-entering' ? 'entering' : this.state === 'vr-exiting' ? 'exiting' : 'idle', inputs.length);
|
|
3071
|
+
this.events.emit('xrinputchange', { inputs });
|
|
3072
|
+
}
|
|
3073
|
+
handleXRTrackingChange(previous, state) {
|
|
3074
|
+
this.ui.setXRTracking(state);
|
|
3075
|
+
this.events.emit('xrtrackingchange', { previous, state });
|
|
3076
|
+
}
|
|
3077
|
+
handleXRError(cause) {
|
|
3078
|
+
const entering = this.state === 'vr-entering';
|
|
3079
|
+
const exiting = this.state === 'vr-exiting';
|
|
3080
|
+
const error = new AnyoPlayerError(exiting ? 'PLAYER_VR_EXIT_FAILED' : entering ? 'PLAYER_VR_ENTER_FAILED' : 'PLAYER_VR_SUPPORT_FAILED', exiting
|
|
3081
|
+
? 'Anyo Player encountered an error while exiting immersive VR.'
|
|
3082
|
+
: entering
|
|
3083
|
+
? 'Anyo Player encountered an error while entering immersive VR.'
|
|
3084
|
+
: 'Anyo Player received an XR runtime error.', { cause });
|
|
3085
|
+
this.ui.setControlError(error);
|
|
3086
|
+
if (!entering && !exiting)
|
|
3087
|
+
this.events.emit('vrerror', error);
|
|
3088
|
+
}
|
|
3089
|
+
initializeEmbeddingLifecycle() {
|
|
3090
|
+
if (this.state === 'disposing' || this.state === 'disposed' || this.lastSource === undefined)
|
|
3091
|
+
return;
|
|
3092
|
+
const preload = this.embeddingController.preload;
|
|
3093
|
+
const activation = this.embeddingController.activation;
|
|
3094
|
+
if (preload === 'source')
|
|
3095
|
+
void this.preload().catch(() => undefined);
|
|
3096
|
+
if (preload === 'runtime' || activation === 'immediate') {
|
|
3097
|
+
void this.activate().catch(() => undefined);
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
if (activation === 'visible' && this.embeddingController.intersection === 'unsupported') {
|
|
3101
|
+
void this.activate().catch(() => undefined);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
handleIntersectionHidden() {
|
|
3105
|
+
if (!this.embeddingController.pauseWhenOffscreen)
|
|
3106
|
+
return;
|
|
3107
|
+
if (this.state === 'vr-entering' || this.state === 'vr-active' || this.state === 'vr-exiting')
|
|
3108
|
+
return;
|
|
3109
|
+
this.pauseInternal('offscreen', false);
|
|
3110
|
+
}
|
|
3111
|
+
handleIntersectionVisible() {
|
|
3112
|
+
if (this.embeddingController.activation === 'visible' && !this.activatedValue && this.lastSource !== undefined) {
|
|
3113
|
+
void this.activate().catch(() => undefined);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
if (!this.embeddingController.resumeWhenVisible)
|
|
3117
|
+
return;
|
|
3118
|
+
if (this.state === 'paused' && this.pauseReasonValue === 'offscreen') {
|
|
3119
|
+
this.resumeInternal(false, false);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
setActivated(activated) {
|
|
3123
|
+
if (this.activatedValue === activated)
|
|
3124
|
+
return;
|
|
3125
|
+
this.activatedValue = activated;
|
|
3126
|
+
this.events.emit('activationchange', { activated });
|
|
3127
|
+
this.emitTelemetry(activated ? 'embedding.activated' : 'embedding.deactivated', undefined, 'lifecycle');
|
|
3128
|
+
}
|
|
3129
|
+
async resolveRuntimeSource(source, signal) {
|
|
3130
|
+
if (this.preloadedSource && samePlayerSource(this.preloadedSource.source, source)) {
|
|
3131
|
+
const resolved = cloneResolvedSource(this.preloadedSource.resolved);
|
|
3132
|
+
this.preloadedSource = null;
|
|
3133
|
+
return resolved;
|
|
3134
|
+
}
|
|
3135
|
+
const pending = this.pendingPreload;
|
|
3136
|
+
if (pending && samePlayerSource(pending.source, source)) {
|
|
3137
|
+
const resolved = await waitForPendingPreload(pending.promise, signal);
|
|
3138
|
+
if (this.pendingPreload === pending)
|
|
3139
|
+
this.pendingPreload = null;
|
|
3140
|
+
this.preloadedSource = null;
|
|
3141
|
+
return cloneResolvedSource(resolved);
|
|
3142
|
+
}
|
|
3143
|
+
return this.sourceResolver.resolve(source, signal);
|
|
3144
|
+
}
|
|
3145
|
+
handleVisibilityHidden() {
|
|
3146
|
+
if (this.state === 'vr-entering' || this.state === 'vr-active' || this.state === 'vr-exiting')
|
|
3147
|
+
return;
|
|
3148
|
+
if (!this.visibilityController.pauseWhenHidden)
|
|
3149
|
+
return;
|
|
3150
|
+
this.pauseInternal('visibility', false);
|
|
3151
|
+
}
|
|
3152
|
+
handleVisibilityVisible() {
|
|
3153
|
+
if (!this.visibilityController.resumeWhenVisible)
|
|
3154
|
+
return;
|
|
3155
|
+
if (this.state === 'paused' && this.pauseReasonValue === 'visibility') {
|
|
3156
|
+
this.resumeInternal(false, false);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
async performSessionRestore(snapshotInput, options) {
|
|
3160
|
+
this.assertSessionWorldAvailable('restoreSession');
|
|
3161
|
+
const runtime = this.activeRuntime;
|
|
3162
|
+
const document = this.activeDocument;
|
|
3163
|
+
if (this.state === 'running' || this.state === 'entering') {
|
|
3164
|
+
this.deactivateActiveInput(true);
|
|
3165
|
+
this.inputModeValue = null;
|
|
3166
|
+
this.transition('ready');
|
|
3167
|
+
this.ui.setReady(this.readyStatusValue ?? 'ready', this.progressValue, this.desktopEnabled && !this.touchEnabledValue);
|
|
3168
|
+
this.touchInput.setVisible(this.touchEnabledValue);
|
|
3169
|
+
this.events.emit('exited', { reason: 'session-restore' });
|
|
3170
|
+
}
|
|
3171
|
+
this.clearInteractionPrompt();
|
|
3172
|
+
this.sessionRestoreActive = true;
|
|
3173
|
+
try {
|
|
3174
|
+
let snapshot;
|
|
3175
|
+
try {
|
|
3176
|
+
snapshot = await this.sessionController.restoreWorld({
|
|
3177
|
+
world: runtime.world,
|
|
3178
|
+
document,
|
|
3179
|
+
onWarning: message => this.emitWarning(message),
|
|
3180
|
+
}, snapshotInput, options);
|
|
3181
|
+
}
|
|
3182
|
+
catch (error) {
|
|
3183
|
+
const playerError = this.sessionError(error, 'PLAYER_SESSION_RESTORE_FAILED', 'Anyo Player could not restore the session.');
|
|
3184
|
+
this.events.emit('sessionerror', playerError);
|
|
3185
|
+
throw playerError;
|
|
3186
|
+
}
|
|
3187
|
+
const restorePauseState = options.restorePauseState ?? true;
|
|
3188
|
+
if (restorePauseState) {
|
|
3189
|
+
if (snapshot.player.paused) {
|
|
3190
|
+
if (this.state !== 'paused')
|
|
3191
|
+
this.pauseInternal(snapshot.player.pauseReason ?? 'user');
|
|
3192
|
+
else {
|
|
3193
|
+
this.pauseReasonValue = snapshot.player.pauseReason ?? 'user';
|
|
3194
|
+
this.ui.setPaused(this.pauseReasonValue);
|
|
3195
|
+
}
|
|
3196
|
+
this.resumeInputModeAfterPause = this.supportedSessionInputMode(snapshot.player.inputMode);
|
|
3197
|
+
}
|
|
3198
|
+
else if (this.state === 'paused') {
|
|
3199
|
+
this.resumeInternal(false);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
this.events.emit('sessionrestored', { snapshot: structuredClone(snapshot) });
|
|
3203
|
+
return structuredClone(snapshot);
|
|
3204
|
+
}
|
|
3205
|
+
finally {
|
|
3206
|
+
this.sessionRestoreActive = false;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
supportedSessionInputMode(mode) {
|
|
3210
|
+
if (mode === 'desktop' && this.desktopEnabled)
|
|
3211
|
+
return mode;
|
|
3212
|
+
if (mode === 'touch' && this.touchEnabledValue)
|
|
3213
|
+
return mode;
|
|
3214
|
+
if (mode === 'gamepad' && this.gamepadInput.enabled)
|
|
3215
|
+
return mode;
|
|
3216
|
+
return null;
|
|
3217
|
+
}
|
|
3218
|
+
applyInputBindings(bindings) {
|
|
3219
|
+
const exploration = this.options.exploration ?? {};
|
|
3220
|
+
this.desktopInput.configure({
|
|
3221
|
+
pointerLock: exploration.pointerLock ?? this.activeDocument?.exploration?.pointerLock ?? true,
|
|
3222
|
+
keys: {
|
|
3223
|
+
forward: keyboardCodes(bindings, 'move-forward'),
|
|
3224
|
+
backward: keyboardCodes(bindings, 'move-backward'),
|
|
3225
|
+
left: keyboardCodes(bindings, 'move-left'),
|
|
3226
|
+
right: keyboardCodes(bindings, 'move-right'),
|
|
3227
|
+
run: keyboardCodes(bindings, 'run'),
|
|
3228
|
+
jump: keyboardCodes(bindings, 'jump'),
|
|
3229
|
+
},
|
|
3230
|
+
interact: keyboardCodes(bindings, 'interact'),
|
|
3231
|
+
pause: keyboardCodes(bindings, 'pause'),
|
|
3232
|
+
enter: this.accessibilityController.keyboardNavigation ? ['Enter', 'Space'] : [],
|
|
3233
|
+
look: {
|
|
3234
|
+
up: keyboardCodes(bindings, 'look-up'),
|
|
3235
|
+
down: keyboardCodes(bindings, 'look-down'),
|
|
3236
|
+
left: keyboardCodes(bindings, 'look-left'),
|
|
3237
|
+
right: keyboardCodes(bindings, 'look-right'),
|
|
3238
|
+
},
|
|
3239
|
+
lookStep: this.options.input === false ? 8 : this.options.input?.keyboardLookSensitivity ?? 8,
|
|
3240
|
+
lookScale: this.viewPreference.pointerLookScale,
|
|
3241
|
+
invertY: this.viewPreference.invertY,
|
|
3242
|
+
preventDefaultKeys: exploration.preventDefaultKeys ?? true,
|
|
3243
|
+
});
|
|
3244
|
+
this.gamepadInput.configure(bindings);
|
|
3245
|
+
this.syncAccessibilityBindings(bindings);
|
|
3246
|
+
if (this.interactionPromptValue.visible)
|
|
3247
|
+
this.restoreInteractionPrompt();
|
|
3248
|
+
}
|
|
3249
|
+
syncAccessibilityBindings(bindings) {
|
|
3250
|
+
this.ui.setKeyboardShortcuts(bindings);
|
|
3251
|
+
const configured = this.options.accessibility === false ? undefined : this.options.accessibility?.instructions;
|
|
3252
|
+
if (configured !== undefined) {
|
|
3253
|
+
this.accessibilityController.setInstructions(configured);
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
const movement = [
|
|
3257
|
+
...keyboardCodes(bindings, 'move-forward'),
|
|
3258
|
+
...keyboardCodes(bindings, 'move-backward'),
|
|
3259
|
+
...keyboardCodes(bindings, 'move-left'),
|
|
3260
|
+
...keyboardCodes(bindings, 'move-right'),
|
|
3261
|
+
].map(code => describeInputBinding({ device: 'keyboard', code })).filter(Boolean);
|
|
3262
|
+
const movementKeys = [...new Set(movement)].join(', ');
|
|
3263
|
+
const interact = describeInputBinding(bindings.interact[0]);
|
|
3264
|
+
const pause = describeInputBinding(bindings.pause[0]);
|
|
3265
|
+
const instructions = [
|
|
3266
|
+
'Interactive 3D world.',
|
|
3267
|
+
this.accessibilityController.keyboardNavigation ? 'Press Enter or Space to enable world controls.' : '',
|
|
3268
|
+
movementKeys ? `Move with ${movementKeys}.` : '',
|
|
3269
|
+
interact ? `Interact with ${interact}.` : '',
|
|
3270
|
+
pause ? `Pause or resume with ${pause}.` : '',
|
|
3271
|
+
'Press Escape to release world controls. Use Tab to reach player buttons.',
|
|
3272
|
+
].filter(Boolean).join(' ');
|
|
3273
|
+
this.accessibilityController.setInstructions(instructions);
|
|
3274
|
+
}
|
|
3275
|
+
persistInputBindingsOnChange() {
|
|
3276
|
+
if (!this.inputBindingsController.saveOnChange || !this.inputBindingsController.storageAvailable)
|
|
3277
|
+
return;
|
|
3278
|
+
void this.inputBindingsController.save().then(saved => {
|
|
3279
|
+
if (this.state === 'disposed' || this.state === 'disposing')
|
|
3280
|
+
return;
|
|
3281
|
+
this.events.emit('inputbindingssaved', {
|
|
3282
|
+
key: saved.key,
|
|
3283
|
+
snapshot: structuredClone(saved.snapshot),
|
|
3284
|
+
});
|
|
3285
|
+
}).catch(error => {
|
|
3286
|
+
if (this.state === 'disposed' || this.state === 'disposing')
|
|
3287
|
+
return;
|
|
3288
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_SAVE_FAILED', 'Anyo Player could not persist the changed input bindings.');
|
|
3289
|
+
this.events.emit('inputbindingserror', playerError);
|
|
3290
|
+
this.emitWarning(playerError.message);
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
async restoreConfiguredInputBindings() {
|
|
3294
|
+
if (this.inputBindingsRestoreAttempted)
|
|
3295
|
+
return;
|
|
3296
|
+
this.inputBindingsRestoreAttempted = true;
|
|
3297
|
+
if (!this.inputBindingsController.restoreOnLoad
|
|
3298
|
+
|| !this.inputBindingsController.storageAvailable
|
|
3299
|
+
|| !this.inputBindingsController.storageKey)
|
|
3300
|
+
return;
|
|
3301
|
+
try {
|
|
3302
|
+
const loaded = await this.inputBindingsController.load();
|
|
3303
|
+
this.applyInputBindings(this.inputBindingsController.bindings);
|
|
3304
|
+
this.events.emit('inputbindingsloaded', {
|
|
3305
|
+
key: loaded?.key ?? this.inputBindingsController.storageKey,
|
|
3306
|
+
snapshot: loaded ? structuredClone(loaded.snapshot) : null,
|
|
3307
|
+
});
|
|
3308
|
+
if (loaded) {
|
|
3309
|
+
this.events.emit('inputbindingschange', {
|
|
3310
|
+
bindings: cloneInputBindingMap(loaded.snapshot.bindings),
|
|
3311
|
+
source: 'storage',
|
|
3312
|
+
});
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
catch (error) {
|
|
3316
|
+
const playerError = this.inputBindingsError(error, 'PLAYER_INPUT_BINDINGS_LOAD_FAILED', 'Anyo Player could not restore the configured input bindings.');
|
|
3317
|
+
this.events.emit('inputbindingserror', playerError);
|
|
3318
|
+
this.emitWarning(playerError.message);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
inputBindingsError(error, fallbackCode, fallbackMessage) {
|
|
3322
|
+
if (error instanceof AnyoPlayerError)
|
|
3323
|
+
return error;
|
|
3324
|
+
if (error instanceof TypeError || error instanceof SyntaxError) {
|
|
3325
|
+
return new AnyoPlayerError('PLAYER_INPUT_BINDINGS_INVALID', error.message, { cause: error });
|
|
3326
|
+
}
|
|
3327
|
+
return new AnyoPlayerError(fallbackCode, fallbackMessage, { cause: error });
|
|
3328
|
+
}
|
|
3329
|
+
async restoreConfiguredSessionAfterLoad() {
|
|
3330
|
+
if (!this.sessionController.restoreOnLoad
|
|
3331
|
+
|| !this.sessionController.storageAvailable
|
|
3332
|
+
|| !this.sessionController.storageKey
|
|
3333
|
+
|| !this.activeRuntime
|
|
3334
|
+
|| !this.activeDocument)
|
|
3335
|
+
return;
|
|
3336
|
+
try {
|
|
3337
|
+
const loaded = await this.sessionController.load();
|
|
3338
|
+
this.events.emit('sessionloaded', {
|
|
3339
|
+
key: loaded?.key ?? this.sessionController.storageKey,
|
|
3340
|
+
snapshot: loaded ? structuredClone(loaded.snapshot) : null,
|
|
3341
|
+
});
|
|
3342
|
+
if (loaded)
|
|
3343
|
+
await this.performSessionRestore(loaded.snapshot, {});
|
|
3344
|
+
}
|
|
3345
|
+
catch (error) {
|
|
3346
|
+
const playerError = this.sessionError(error, 'PLAYER_SESSION_LOAD_FAILED', 'Anyo Player could not restore the configured saved session.');
|
|
3347
|
+
this.events.emit('sessionerror', playerError);
|
|
3348
|
+
this.emitWarning(playerError.message);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
async saveConfiguredSession() {
|
|
3352
|
+
if (!this.sessionController.storageAvailable || !this.sessionController.storageKey)
|
|
3353
|
+
return;
|
|
3354
|
+
try {
|
|
3355
|
+
await this.saveSession();
|
|
3356
|
+
}
|
|
3357
|
+
catch {
|
|
3358
|
+
// saveSession emits the typed sessionerror event. Automatic persistence is non-fatal.
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
assertSessionWorldAvailable(operation) {
|
|
3362
|
+
this.assertNotDisposed();
|
|
3363
|
+
if (!this.sessionController.enabled) {
|
|
3364
|
+
throw new AnyoPlayerError('PLAYER_SESSION_DISABLED', 'Session support is disabled for this Anyo Player.');
|
|
3365
|
+
}
|
|
3366
|
+
if (!this.activeRuntime
|
|
3367
|
+
|| !this.activeDocument
|
|
3368
|
+
|| !['ready', 'entering', 'running', 'paused', 'vr-entering', 'vr-active'].includes(this.state)) {
|
|
3369
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `${operation}() requires a loaded world, not state "${this.state}".`);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
sessionError(error, fallbackCode, fallbackMessage) {
|
|
3373
|
+
if (error instanceof AnyoPlayerError)
|
|
3374
|
+
return error;
|
|
3375
|
+
if (error instanceof TypeError || error instanceof SyntaxError) {
|
|
3376
|
+
return new AnyoPlayerError('PLAYER_SESSION_INVALID', error.message, { cause: error });
|
|
3377
|
+
}
|
|
3378
|
+
return new AnyoPlayerError(fallbackCode, fallbackMessage, { cause: error });
|
|
3379
|
+
}
|
|
3380
|
+
handleFullscreenError(error) {
|
|
3381
|
+
this.ui.setControlError(error);
|
|
3382
|
+
this.events.emit('fullscreenerror', error);
|
|
3383
|
+
}
|
|
3384
|
+
assertFullscreenState() {
|
|
3385
|
+
this.assertNotDisposed();
|
|
3386
|
+
if (!this.activeRuntime || !['ready', 'entering', 'running', 'paused'].includes(this.state)) {
|
|
3387
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', `Fullscreen requires a loaded world, not state "${this.state}".`);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
deactivateDesktop(releasePointerLock) {
|
|
3391
|
+
this.suppressInputExit = true;
|
|
3392
|
+
try {
|
|
3393
|
+
this.desktopInput.deactivate(releasePointerLock);
|
|
3394
|
+
}
|
|
3395
|
+
finally {
|
|
3396
|
+
this.suppressInputExit = false;
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
deactivateActiveInput(releasePointerLock) {
|
|
3400
|
+
this.stopInteractionPresentation();
|
|
3401
|
+
if (this.inputModeValue === 'desktop')
|
|
3402
|
+
this.deactivateDesktop(releasePointerLock);
|
|
3403
|
+
else if (this.inputModeValue === 'touch')
|
|
3404
|
+
this.touchInput.deactivate();
|
|
3405
|
+
else if (this.inputModeValue === 'gamepad')
|
|
3406
|
+
this.gamepadInput.deactivate();
|
|
3407
|
+
this.inputModeValue = null;
|
|
3408
|
+
}
|
|
3409
|
+
manageRuntime(runtime, operationId) {
|
|
3410
|
+
let resolveFatal;
|
|
3411
|
+
const fatalSignal = new Promise(resolve => {
|
|
3412
|
+
resolveFatal = resolve;
|
|
3413
|
+
});
|
|
3414
|
+
const managed = {
|
|
3415
|
+
...runtime,
|
|
3416
|
+
operationId,
|
|
3417
|
+
eventCleanups: [],
|
|
3418
|
+
actionCleanups: new Map(),
|
|
3419
|
+
fatalDiagnostic: null,
|
|
3420
|
+
fatalSignal,
|
|
3421
|
+
resolveFatal,
|
|
3422
|
+
fatalHandled: false,
|
|
3423
|
+
disposed: false,
|
|
3424
|
+
};
|
|
3425
|
+
managed.eventCleanups.push(managed.world.on('assets:progress', progress => {
|
|
3426
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3427
|
+
return;
|
|
3428
|
+
this.progressValue = { ...progress };
|
|
3429
|
+
this.emitProgress();
|
|
3430
|
+
}), managed.world.on('renderer:diagnostic', diagnosticValue => {
|
|
3431
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3432
|
+
return;
|
|
3433
|
+
const diagnostic = diagnosticValue;
|
|
3434
|
+
this.events.emit('diagnostic', diagnostic);
|
|
3435
|
+
this.recordRendererDiagnostic(diagnostic);
|
|
3436
|
+
this.emitTelemetry('renderer.diagnostic', {
|
|
3437
|
+
code: diagnostic.code,
|
|
3438
|
+
severity: diagnostic.severity,
|
|
3439
|
+
message: diagnostic.message,
|
|
3440
|
+
details: diagnostic.details,
|
|
3441
|
+
}, 'renderer');
|
|
3442
|
+
this.ui.handleDiagnostic(diagnostic);
|
|
3443
|
+
if (diagnostic.code === 'SEKAI64_WEBGPU_DEVICE_LOST') {
|
|
3444
|
+
this.markRuntimeFatal(managed, diagnostic);
|
|
3445
|
+
if (this.activeRuntime === managed
|
|
3446
|
+
&& (this.state === 'ready' || this.state === 'entering' || this.state === 'running' || this.state === 'paused' || this.state === 'replacing' || this.state === 'vr-entering' || this.state === 'vr-active' || this.state === 'vr-exiting')) {
|
|
3447
|
+
this.handleActiveRuntimeLoss(managed, diagnostic);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
}), managed.world.on('entity:hover', payload => {
|
|
3451
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3452
|
+
return;
|
|
3453
|
+
this.hoveredInteractionEntity = payload.entityId ?? null;
|
|
3454
|
+
this.hoveredInteractionPayload = payload;
|
|
3455
|
+
this.handleInteraction('hover', payload);
|
|
3456
|
+
}), managed.world.on('entity:hover-leave', payload => {
|
|
3457
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3458
|
+
return;
|
|
3459
|
+
if (payload.entityId && this.hoveredInteractionEntity !== payload.entityId)
|
|
3460
|
+
return;
|
|
3461
|
+
this.hoveredInteractionEntity = null;
|
|
3462
|
+
this.hoveredInteractionPayload = null;
|
|
3463
|
+
this.clearInteractionPrompt();
|
|
3464
|
+
}), managed.world.on('entity:select', payload => {
|
|
3465
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3466
|
+
return;
|
|
3467
|
+
this.handleInteraction('select', payload);
|
|
3468
|
+
}), managed.world.on('audio:blocked', () => {
|
|
3469
|
+
if (!this.isRuntimeRelevant(managed))
|
|
3470
|
+
return;
|
|
3471
|
+
this.audioController.markBlocked();
|
|
3472
|
+
this.ui.setAudioState(this.audioController.state);
|
|
3473
|
+
}));
|
|
3474
|
+
return managed;
|
|
3475
|
+
}
|
|
3476
|
+
async awaitRuntimeStep(runtime, step) {
|
|
3477
|
+
const result = await Promise.race([
|
|
3478
|
+
step.then(value => ({ kind: 'value', value })),
|
|
3479
|
+
runtime.fatalSignal.then(diagnostic => ({ kind: 'fatal', diagnostic })),
|
|
3480
|
+
]);
|
|
3481
|
+
if (result.kind === 'fatal')
|
|
3482
|
+
throw this.rendererLossError(result.diagnostic);
|
|
3483
|
+
return result.value;
|
|
3484
|
+
}
|
|
3485
|
+
markRuntimeFatal(runtime, diagnostic) {
|
|
3486
|
+
if (runtime.fatalDiagnostic)
|
|
3487
|
+
return;
|
|
3488
|
+
runtime.fatalDiagnostic = diagnostic;
|
|
3489
|
+
runtime.resolveFatal(diagnostic);
|
|
3490
|
+
}
|
|
3491
|
+
handleActiveRuntimeLoss(runtime, diagnostic) {
|
|
3492
|
+
if (runtime.fatalHandled || !this.isRuntimeRelevant(runtime))
|
|
3493
|
+
return;
|
|
3494
|
+
runtime.fatalHandled = true;
|
|
3495
|
+
this.rendererRecoverySnapshot = this.captureRendererRecoverySession();
|
|
3496
|
+
if (this.state === 'running' || this.state === 'entering') {
|
|
3497
|
+
this.deactivateActiveInput(true);
|
|
3498
|
+
this.events.emit('exited', { reason: 'runtime-error' });
|
|
3499
|
+
}
|
|
3500
|
+
if (this.state === 'vr-entering' || this.state === 'vr-active' || this.state === 'vr-exiting') {
|
|
3501
|
+
this.xrController.unbind();
|
|
3502
|
+
}
|
|
3503
|
+
this.pauseReasonValue = null;
|
|
3504
|
+
this.resumeInputModeAfterPause = null;
|
|
3505
|
+
this.inputModeValue = null;
|
|
3506
|
+
this.desktopInput.unbind();
|
|
3507
|
+
this.touchInput.unbind();
|
|
3508
|
+
this.gamepadInput.unbind();
|
|
3509
|
+
this.touchInput.setVisible(false);
|
|
3510
|
+
this.clearInteractionPrompt();
|
|
3511
|
+
this.activeDocument = null;
|
|
3512
|
+
this.replacementContext = null;
|
|
3513
|
+
this.replacementStage = 'idle';
|
|
3514
|
+
const error = this.rendererLossError(diagnostic);
|
|
3515
|
+
this.errorValue = error;
|
|
3516
|
+
this.readyStatusValue = null;
|
|
3517
|
+
this.transition('error');
|
|
3518
|
+
this.setPhase('error');
|
|
3519
|
+
this.ui.setError(error);
|
|
3520
|
+
this.events.emit('error', error);
|
|
3521
|
+
try {
|
|
3522
|
+
runtime.world.stop();
|
|
3523
|
+
}
|
|
3524
|
+
catch (stopError) {
|
|
3525
|
+
this.emitWarning(`Anyo Player could not stop a lost renderer cleanly: ${String(stopError)}`);
|
|
3526
|
+
}
|
|
3527
|
+
const cleanup = this.operationTail.then(async () => {
|
|
3528
|
+
if (this.activeRuntime === runtime)
|
|
3529
|
+
this.activeRuntime = null;
|
|
3530
|
+
await this.disposeManagedRuntime(runtime);
|
|
3531
|
+
});
|
|
3532
|
+
this.operationTail = cleanup.then(() => undefined, () => undefined);
|
|
3533
|
+
if (this.rendererRecoveryOptions.enabled && this.rendererRecoveryOptions.automatic) {
|
|
3534
|
+
this.scheduleRendererRecovery(diagnostic);
|
|
3535
|
+
}
|
|
3536
|
+
else {
|
|
3537
|
+
this.setRendererRecovery({
|
|
3538
|
+
state: 'idle',
|
|
3539
|
+
attempt: 0,
|
|
3540
|
+
maxAttempts: this.rendererRecoveryOptions.maxAttempts,
|
|
3541
|
+
automatic: false,
|
|
3542
|
+
diagnosticCode: diagnostic.code,
|
|
3543
|
+
error: null,
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
rendererLossError(diagnostic) {
|
|
3548
|
+
return new AnyoPlayerError('PLAYER_RENDERER_LOST', diagnostic.code === 'SEKAI64_WEBGPU_DEVICE_LOST'
|
|
3549
|
+
? 'The WebGPU device was lost. Retry to create a clean renderer and reload the world.'
|
|
3550
|
+
: diagnostic.message, { cause: diagnostic });
|
|
3551
|
+
}
|
|
3552
|
+
emitTelemetry(name, details, category = 'host') {
|
|
3553
|
+
return this.telemetryController.track(name, category, { state: this.state, phase: this.phase }, details);
|
|
3554
|
+
}
|
|
3555
|
+
handleWebSurfaceDiagnostic(diagnostic, hostCallback) {
|
|
3556
|
+
try {
|
|
3557
|
+
this.recordWebSurfaceDiagnostic(diagnostic);
|
|
3558
|
+
this.emitTelemetry('web-surface.diagnostic', {
|
|
3559
|
+
code: diagnostic.code,
|
|
3560
|
+
severity: diagnostic.severity,
|
|
3561
|
+
message: diagnostic.message,
|
|
3562
|
+
entityId: diagnostic.entityId,
|
|
3563
|
+
primitiveId: diagnostic.primitiveId,
|
|
3564
|
+
details: diagnostic.details,
|
|
3565
|
+
}, 'renderer');
|
|
3566
|
+
if (diagnostic.severity !== 'info') {
|
|
3567
|
+
this.emitWarning(`[${diagnostic.code}] ${diagnostic.message}`);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
catch {
|
|
3571
|
+
// Diagnostics must never break the active world frame.
|
|
3572
|
+
}
|
|
3573
|
+
if (!hostCallback)
|
|
3574
|
+
return;
|
|
3575
|
+
try {
|
|
3576
|
+
hostCallback(diagnostic);
|
|
3577
|
+
}
|
|
3578
|
+
catch (error) {
|
|
3579
|
+
try {
|
|
3580
|
+
this.emitWarning(`Anyo Player Web Surface diagnostic callback failed: ${String(error)}`);
|
|
3581
|
+
}
|
|
3582
|
+
catch {
|
|
3583
|
+
// Host warning callbacks are isolated from the Web Surface runtime.
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
recordWebSurfaceDiagnostic(diagnostic) {
|
|
3588
|
+
if (this.diagnosticHistoryLimit <= 0)
|
|
3589
|
+
return;
|
|
3590
|
+
const record = {
|
|
3591
|
+
sequence: ++this.diagnosticSequence,
|
|
3592
|
+
timestamp: this.now().toISOString(),
|
|
3593
|
+
source: 'player',
|
|
3594
|
+
code: diagnostic.code,
|
|
3595
|
+
message: diagnostic.message,
|
|
3596
|
+
severity: diagnostic.severity,
|
|
3597
|
+
recoverable: true,
|
|
3598
|
+
};
|
|
3599
|
+
this.diagnosticsValue.push(record);
|
|
3600
|
+
if (this.diagnosticsValue.length > this.diagnosticHistoryLimit) {
|
|
3601
|
+
this.diagnosticsValue.splice(0, this.diagnosticsValue.length - this.diagnosticHistoryLimit);
|
|
3602
|
+
}
|
|
3603
|
+
this.events.emit('diagnosticrecorded', { record: structuredClone(record) });
|
|
3604
|
+
}
|
|
3605
|
+
recordPlayerDiagnostic(error, recoverable) {
|
|
3606
|
+
if (this.diagnosticHistoryLimit <= 0)
|
|
3607
|
+
return;
|
|
3608
|
+
const record = {
|
|
3609
|
+
sequence: ++this.diagnosticSequence,
|
|
3610
|
+
timestamp: this.now().toISOString(),
|
|
3611
|
+
source: 'player',
|
|
3612
|
+
code: error.code,
|
|
3613
|
+
message: error.message,
|
|
3614
|
+
severity: 'error',
|
|
3615
|
+
recoverable,
|
|
3616
|
+
};
|
|
3617
|
+
this.diagnosticsValue.push(record);
|
|
3618
|
+
if (this.diagnosticsValue.length > this.diagnosticHistoryLimit) {
|
|
3619
|
+
this.diagnosticsValue.splice(0, this.diagnosticsValue.length - this.diagnosticHistoryLimit);
|
|
3620
|
+
}
|
|
3621
|
+
this.events.emit('diagnosticrecorded', { record: structuredClone(record) });
|
|
3622
|
+
}
|
|
3623
|
+
recordRendererDiagnostic(diagnostic) {
|
|
3624
|
+
if (this.diagnosticHistoryLimit <= 0)
|
|
3625
|
+
return;
|
|
3626
|
+
const fatal = diagnostic.code === 'SEKAI64_WEBGPU_DEVICE_LOST';
|
|
3627
|
+
const recoverable = fatal
|
|
3628
|
+
? this.rendererRecoveryOptions.enabled
|
|
3629
|
+
: diagnostic.code === 'SEKAI64_WEBGL_CONTEXT_LOST' || diagnostic.code === 'SEKAI64_WEBGL_CONTEXT_RESTORED';
|
|
3630
|
+
const record = {
|
|
3631
|
+
sequence: ++this.diagnosticSequence,
|
|
3632
|
+
timestamp: this.now().toISOString(),
|
|
3633
|
+
source: 'renderer',
|
|
3634
|
+
code: diagnostic.code,
|
|
3635
|
+
message: diagnostic.message,
|
|
3636
|
+
severity: fatal ? 'fatal' : diagnostic.severity,
|
|
3637
|
+
recoverable,
|
|
3638
|
+
diagnostic: structuredClone(diagnostic),
|
|
3639
|
+
};
|
|
3640
|
+
this.diagnosticsValue.push(record);
|
|
3641
|
+
if (this.diagnosticsValue.length > this.diagnosticHistoryLimit) {
|
|
3642
|
+
this.diagnosticsValue.splice(0, this.diagnosticsValue.length - this.diagnosticHistoryLimit);
|
|
3643
|
+
}
|
|
3644
|
+
this.events.emit('diagnosticrecorded', { record: structuredClone(record) });
|
|
3645
|
+
}
|
|
3646
|
+
captureRendererRecoverySession() {
|
|
3647
|
+
if (!this.rendererRecoveryOptions.restoreSession || !this.sessionController.enabled || !this.activeRuntime || !this.activeDocument)
|
|
3648
|
+
return null;
|
|
3649
|
+
try {
|
|
3650
|
+
return this.sessionController.capture({
|
|
3651
|
+
world: this.activeRuntime.world,
|
|
3652
|
+
document: this.activeDocument,
|
|
3653
|
+
paused: this.state === 'paused',
|
|
3654
|
+
pauseReason: this.pauseReasonValue,
|
|
3655
|
+
inputMode: this.inputModeValue ?? this.resumeInputModeAfterPause,
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
catch (error) {
|
|
3659
|
+
this.emitWarning(`Anyo Player could not capture a renderer-recovery session: ${String(error)}`);
|
|
3660
|
+
return null;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
scheduleRendererRecovery(diagnostic) {
|
|
3664
|
+
this.clearRendererRecoveryTimer();
|
|
3665
|
+
this.setRendererRecovery({
|
|
3666
|
+
state: 'scheduled',
|
|
3667
|
+
attempt: 0,
|
|
3668
|
+
maxAttempts: this.rendererRecoveryOptions.maxAttempts,
|
|
3669
|
+
automatic: true,
|
|
3670
|
+
diagnosticCode: diagnostic.code,
|
|
3671
|
+
error: null,
|
|
3672
|
+
});
|
|
3673
|
+
this.scheduleNextRendererRecoveryAttempt();
|
|
3674
|
+
}
|
|
3675
|
+
scheduleNextRendererRecoveryAttempt() {
|
|
3676
|
+
const attempt = this.rendererRecoveryValue.attempt + 1;
|
|
3677
|
+
const delay = this.rendererRecoveryOptions.delayMs
|
|
3678
|
+
* Math.pow(this.rendererRecoveryOptions.backoff, Math.max(0, attempt - 1));
|
|
3679
|
+
this.rendererRecoveryTimer = setTimeout(() => {
|
|
3680
|
+
this.rendererRecoveryTimer = null;
|
|
3681
|
+
const run = this.performRendererRecovery(true);
|
|
3682
|
+
const tracked = run.finally(() => {
|
|
3683
|
+
if (this.rendererRecoveryPromise === tracked)
|
|
3684
|
+
this.rendererRecoveryPromise = null;
|
|
3685
|
+
});
|
|
3686
|
+
this.rendererRecoveryPromise = tracked;
|
|
3687
|
+
void tracked.catch(() => undefined);
|
|
3688
|
+
}, delay);
|
|
3689
|
+
}
|
|
3690
|
+
async performRendererRecovery(automatic) {
|
|
3691
|
+
const attempt = automatic ? this.rendererRecoveryValue.attempt + 1 : Math.max(1, this.rendererRecoveryValue.attempt + 1);
|
|
3692
|
+
this.setRendererRecovery({
|
|
3693
|
+
state: 'recovering',
|
|
3694
|
+
attempt,
|
|
3695
|
+
maxAttempts: this.rendererRecoveryOptions.maxAttempts,
|
|
3696
|
+
automatic,
|
|
3697
|
+
diagnosticCode: this.rendererRecoveryValue.diagnosticCode,
|
|
3698
|
+
error: null,
|
|
3699
|
+
});
|
|
3700
|
+
const fallback = this.rendererRecoveryOptions.fallbackBackend;
|
|
3701
|
+
if (fallback
|
|
3702
|
+
&& attempt >= this.rendererRecoveryOptions.fallbackAfterAttempt
|
|
3703
|
+
&& this.rendererRecoveryValue.diagnosticCode === 'SEKAI64_WEBGPU_DEVICE_LOST') {
|
|
3704
|
+
this.recoveryRendererBackendOverride = fallback;
|
|
3705
|
+
this.emitTelemetry('renderer.recovery-backend-fallback', { backend: fallback, attempt }, 'recovery');
|
|
3706
|
+
}
|
|
3707
|
+
try {
|
|
3708
|
+
await this.load(this.lastSource);
|
|
3709
|
+
const snapshot = this.rendererRecoverySnapshot;
|
|
3710
|
+
if (snapshot && this.rendererRecoveryOptions.restoreSession) {
|
|
3711
|
+
await this.restoreSession(snapshot, { restorePauseState: true, restoreRuntimeData: true });
|
|
3712
|
+
}
|
|
3713
|
+
this.rendererRecoverySnapshot = null;
|
|
3714
|
+
this.setRendererRecovery({
|
|
3715
|
+
...this.rendererRecoveryValue,
|
|
3716
|
+
state: 'recovered',
|
|
3717
|
+
error: null,
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
catch (cause) {
|
|
3721
|
+
const error = cause instanceof AnyoPlayerError
|
|
3722
|
+
? cause
|
|
3723
|
+
: new AnyoPlayerError('PLAYER_RENDERER_RECOVERY_FAILED', 'Anyo Player could not rebuild the lost renderer.', { cause });
|
|
3724
|
+
if (automatic && attempt < this.rendererRecoveryOptions.maxAttempts && this.state === 'error') {
|
|
3725
|
+
this.setRendererRecovery({
|
|
3726
|
+
...this.rendererRecoveryValue,
|
|
3727
|
+
state: 'scheduled',
|
|
3728
|
+
error,
|
|
3729
|
+
});
|
|
3730
|
+
this.scheduleNextRendererRecoveryAttempt();
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
const recoveryError = error.code === 'PLAYER_RENDERER_RECOVERY_FAILED'
|
|
3734
|
+
? error
|
|
3735
|
+
: new AnyoPlayerError('PLAYER_RENDERER_RECOVERY_FAILED', 'Anyo Player exhausted renderer recovery attempts.', { cause: error });
|
|
3736
|
+
this.errorValue = recoveryError;
|
|
3737
|
+
this.ui.setError(recoveryError);
|
|
3738
|
+
this.recordPlayerDiagnostic(recoveryError, false);
|
|
3739
|
+
this.events.emit('error', recoveryError);
|
|
3740
|
+
this.setRendererRecovery({
|
|
3741
|
+
...this.rendererRecoveryValue,
|
|
3742
|
+
state: 'failed',
|
|
3743
|
+
error: recoveryError,
|
|
3744
|
+
});
|
|
3745
|
+
throw recoveryError;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
setRendererRecovery(next) {
|
|
3749
|
+
const previous = cloneRecoveryStatus(this.rendererRecoveryValue);
|
|
3750
|
+
this.rendererRecoveryValue = cloneRecoveryStatus(next);
|
|
3751
|
+
this.events.emit('rendererrecoverychange', {
|
|
3752
|
+
previous,
|
|
3753
|
+
recovery: cloneRecoveryStatus(next),
|
|
3754
|
+
});
|
|
3755
|
+
this.emitTelemetry(`renderer.recovery-${next.state}`, {
|
|
3756
|
+
attempt: next.attempt,
|
|
3757
|
+
maxAttempts: next.maxAttempts,
|
|
3758
|
+
automatic: next.automatic,
|
|
3759
|
+
diagnosticCode: next.diagnosticCode,
|
|
3760
|
+
errorCode: next.error?.code ?? null,
|
|
3761
|
+
}, 'recovery');
|
|
3762
|
+
}
|
|
3763
|
+
clearRendererRecoveryTimer() {
|
|
3764
|
+
if (this.rendererRecoveryTimer === null)
|
|
3765
|
+
return;
|
|
3766
|
+
clearTimeout(this.rendererRecoveryTimer);
|
|
3767
|
+
this.rendererRecoveryTimer = null;
|
|
3768
|
+
}
|
|
3769
|
+
installStagedActions(runtime) {
|
|
3770
|
+
for (const [name, registration] of this.actions)
|
|
3771
|
+
this.installAction(runtime, name, registration);
|
|
3772
|
+
}
|
|
3773
|
+
installAction(runtime, name, registration) {
|
|
3774
|
+
if (!runtime || runtime.disposed)
|
|
3775
|
+
return;
|
|
3776
|
+
runtime.actionCleanups.get(name)?.cleanup();
|
|
3777
|
+
runtime.actionCleanups.set(name, {
|
|
3778
|
+
token: registration.token,
|
|
3779
|
+
cleanup: runtime.world.registerAction(name, registration.handler),
|
|
3780
|
+
});
|
|
3781
|
+
}
|
|
3782
|
+
removeRuntimeAction(name, token) {
|
|
3783
|
+
for (const runtime of [this.pendingRuntime, this.activeRuntime]) {
|
|
3784
|
+
const installed = runtime?.actionCleanups.get(name);
|
|
3785
|
+
if (!runtime || !installed || installed.token !== token)
|
|
3786
|
+
continue;
|
|
3787
|
+
installed.cleanup();
|
|
3788
|
+
runtime.actionCleanups.delete(name);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
async disposeManagedRuntime(runtime) {
|
|
3792
|
+
if (runtime.disposed)
|
|
3793
|
+
return;
|
|
3794
|
+
runtime.disposed = true;
|
|
3795
|
+
this.responsive.detach(runtime.renderer);
|
|
3796
|
+
if (this.activeRuntime === runtime || this.pendingRuntime === runtime)
|
|
3797
|
+
this.interactionPresentation.unbind();
|
|
3798
|
+
for (const cleanup of runtime.eventCleanups.splice(0))
|
|
3799
|
+
cleanup();
|
|
3800
|
+
for (const installed of runtime.actionCleanups.values())
|
|
3801
|
+
installed.cleanup();
|
|
3802
|
+
runtime.actionCleanups.clear();
|
|
3803
|
+
try {
|
|
3804
|
+
await runtime.world.disposeAsync();
|
|
3805
|
+
}
|
|
3806
|
+
catch (error) {
|
|
3807
|
+
this.emitWarning(`Anyo Player runtime disposal reported an error: ${String(error)}`);
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
isRuntimeRelevant(runtime) {
|
|
3811
|
+
if (runtime.disposed)
|
|
3812
|
+
return false;
|
|
3813
|
+
if (this.activeRuntime === runtime)
|
|
3814
|
+
return true;
|
|
3815
|
+
return this.pendingRuntime === runtime && runtime.operationId === this.operationId;
|
|
3816
|
+
}
|
|
3817
|
+
assertCanLoad() {
|
|
3818
|
+
this.assertNotDisposed();
|
|
3819
|
+
if (this.state === 'ready' || this.state === 'entering' || this.state === 'running' || this.state === 'paused' || this.state === 'replacing' || this.state === 'vr-entering' || this.state === 'vr-active' || this.state === 'vr-exiting') {
|
|
3820
|
+
throw new AnyoPlayerError('PLAYER_INVALID_STATE', 'This Anyo Player has already loaded a world. Use replaceWorld(source) for live replacement.');
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
assertNotDisposed() {
|
|
3824
|
+
if (this.state === 'disposing' || this.state === 'disposed') {
|
|
3825
|
+
throw new AnyoPlayerError('PLAYER_DISPOSED', 'Anyo Player is disposing or has already been disposed.');
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
assertCurrentOperation(operationId) {
|
|
3829
|
+
this.assertNotDisposed();
|
|
3830
|
+
if (!this.isCurrentOperation(operationId)) {
|
|
3831
|
+
throw new AnyoPlayerError('PLAYER_OPERATION_SUPERSEDED', 'Anyo Player loading was superseded by a newer operation.');
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
isCurrentOperation(operationId) {
|
|
3835
|
+
return operationId === this.operationId;
|
|
3836
|
+
}
|
|
3837
|
+
transition(next) {
|
|
3838
|
+
const change = this.stateMachine.transition(next);
|
|
3839
|
+
if (!change)
|
|
3840
|
+
return;
|
|
3841
|
+
this.events.emit('statechange', change);
|
|
3842
|
+
this.emitTelemetry('player.state-change', change, 'lifecycle');
|
|
3843
|
+
}
|
|
3844
|
+
setPhase(phase) {
|
|
3845
|
+
if (this.phaseValue === phase)
|
|
3846
|
+
return;
|
|
3847
|
+
const previous = this.phaseValue;
|
|
3848
|
+
this.phaseValue = phase;
|
|
3849
|
+
this.ui.setPhase(phase);
|
|
3850
|
+
const change = { previous, phase };
|
|
3851
|
+
this.events.emit('phasechange', change);
|
|
3852
|
+
this.emitTelemetry('player.phase-change', change, 'lifecycle');
|
|
3853
|
+
if (this.options.accessibility !== false && this.options.accessibility?.announceStatusChanges === true) {
|
|
3854
|
+
const announcements = {
|
|
3855
|
+
'resolving-source': 'Loading world source.',
|
|
3856
|
+
'creating-runtime': 'Creating renderer.',
|
|
3857
|
+
'loading-world': 'Loading world.',
|
|
3858
|
+
'replacing-world': 'Replacing world.',
|
|
3859
|
+
'ready': 'World ready.',
|
|
3860
|
+
'ready-with-warnings': 'World ready with warnings.',
|
|
3861
|
+
'error': 'World loading failed.',
|
|
3862
|
+
};
|
|
3863
|
+
const message = announcements[phase];
|
|
3864
|
+
if (message)
|
|
3865
|
+
this.accessibilityController.announce(message, phase === 'error' ? 'assertive' : 'polite');
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
emitProgress() {
|
|
3869
|
+
const progress = this.progress;
|
|
3870
|
+
this.ui.setProgress(progress);
|
|
3871
|
+
this.events.emit('progress', progress);
|
|
3872
|
+
}
|
|
3873
|
+
emitWarning(message) {
|
|
3874
|
+
this.emitTelemetry('player.warning', { message }, 'lifecycle');
|
|
3875
|
+
this.options.onWarning?.(message);
|
|
3876
|
+
this.ui.handleWarning(message);
|
|
3877
|
+
this.events.emit('warning', { message });
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
//# sourceMappingURL=AnyoPlayerCore.js.map
|