Add scene loading screen and error handling to RunnerCanvas
This commit is contained in:
@@ -4,14 +4,17 @@ import type { LoadedAudioAsset } from "../assets/audio-assets";
|
||||
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
||||
import type { LoadedImageAsset } from "../assets/image-assets";
|
||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||
import type { SceneLoadingScreenSettings } from "../document/scene-document";
|
||||
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
|
||||
import { RuntimeHost } from "../runtime-three/runtime-host";
|
||||
import { RuntimeHost, type RuntimeSceneLoadState } from "../runtime-three/runtime-host";
|
||||
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
|
||||
import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
|
||||
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
||||
|
||||
interface RunnerCanvasProps {
|
||||
runtimeScene: RuntimeSceneDefinition;
|
||||
sceneName: string;
|
||||
sceneLoadingScreen: SceneLoadingScreenSettings;
|
||||
projectAssets: Record<string, ProjectAssetRecord>;
|
||||
loadedModelAssets: Record<string, LoadedModelAsset>;
|
||||
loadedImageAssets: Record<string, LoadedImageAsset>;
|
||||
@@ -24,6 +27,8 @@ interface RunnerCanvasProps {
|
||||
|
||||
export function RunnerCanvas({
|
||||
runtimeScene,
|
||||
sceneName,
|
||||
sceneLoadingScreen,
|
||||
projectAssets,
|
||||
loadedModelAssets,
|
||||
loadedImageAssets,
|
||||
@@ -36,8 +41,15 @@ export function RunnerCanvas({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hostRef = useRef<RuntimeHost | null>(null);
|
||||
const [runnerMessage, setRunnerMessage] = useState<string | null>(null);
|
||||
const [sceneLoadState, setSceneLoadState] = useState<RuntimeSceneLoadState>({
|
||||
status: "loading",
|
||||
message: null
|
||||
});
|
||||
const [interactionPrompt, setInteractionPrompt] = useState<RuntimeInteractionPrompt | null>(null);
|
||||
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null);
|
||||
const overlayMessage = runnerMessage ?? sceneLoadState.message;
|
||||
const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status;
|
||||
const runnerReady = overlayStatus === "ready";
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -53,6 +65,7 @@ export function RunnerCanvas({
|
||||
hostRef.current = runtimeHost;
|
||||
runtimeHost.mount(container);
|
||||
runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange);
|
||||
runtimeHost.setSceneLoadStateHandler(setSceneLoadState);
|
||||
runtimeHost.setFirstPersonTelemetryHandler((telemetry) => {
|
||||
setFirstPersonTelemetry(telemetry);
|
||||
onFirstPersonTelemetryChange(telemetry);
|
||||
@@ -65,14 +78,24 @@ export function RunnerCanvas({
|
||||
|
||||
return () => {
|
||||
onInteractionPromptChange(null);
|
||||
onFirstPersonTelemetryChange(null);
|
||||
setFirstPersonTelemetry(null);
|
||||
setInteractionPrompt(null);
|
||||
runtimeHost.dispose();
|
||||
hostRef.current = null;
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Runner initialization failed.";
|
||||
setRunnerMessage(`Runner initialization failed: ${message}`);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Runner initialization failed.";
|
||||
const failureMessage = `Runner initialization failed: ${message}`;
|
||||
setRunnerMessage(failureMessage);
|
||||
setSceneLoadState({
|
||||
status: "error",
|
||||
message: failureMessage
|
||||
});
|
||||
onRuntimeMessageChange(failureMessage);
|
||||
onInteractionPromptChange(null);
|
||||
onFirstPersonTelemetryChange(null);
|
||||
return;
|
||||
}
|
||||
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]);
|
||||
@@ -82,8 +105,18 @@ export function RunnerCanvas({
|
||||
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunnerMessage(null);
|
||||
setSceneLoadState({
|
||||
status: "loading",
|
||||
message: null
|
||||
});
|
||||
setInteractionPrompt(null);
|
||||
setFirstPersonTelemetry(null);
|
||||
onInteractionPromptChange(null);
|
||||
onFirstPersonTelemetryChange(null);
|
||||
onRuntimeMessageChange(null);
|
||||
hostRef.current?.loadScene(runtimeScene);
|
||||
}, [runtimeScene]);
|
||||
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene]);
|
||||
|
||||
useEffect(() => {
|
||||
hostRef.current?.setNavigationMode(navigationMode);
|
||||
@@ -95,14 +128,69 @@ export function RunnerCanvas({
|
||||
className={`runner-canvas ${navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? "runner-canvas--underwater" : ""}`}
|
||||
data-testid="runner-shell"
|
||||
aria-label="Built-in scene runner"
|
||||
aria-busy={!runnerReady}
|
||||
style={createWorldBackgroundStyle(
|
||||
runtimeScene.world.background,
|
||||
runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null
|
||||
)}
|
||||
>
|
||||
{navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? <div className="runner-canvas__underwater" aria-hidden="true" /> : null}
|
||||
{navigationMode === "firstPerson" ? <div className="runner-canvas__crosshair" aria-hidden="true" /> : null}
|
||||
{navigationMode === "firstPerson" && interactionPrompt !== null ? (
|
||||
<div
|
||||
className={`runner-canvas__loading-overlay ${runnerReady ? "runner-canvas__loading-overlay--hidden" : ""}`}
|
||||
data-testid="runner-loading-overlay"
|
||||
aria-hidden={runnerReady}
|
||||
role={overlayStatus === "error" ? "alert" : "status"}
|
||||
aria-live="polite"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.3) 100%), ${sceneLoadingScreen.colorHex}`
|
||||
}}
|
||||
>
|
||||
<div className="runner-canvas__loading-card">
|
||||
<div className="runner-canvas__loading-badge">
|
||||
{overlayStatus === "error" ? "Load Failed" : "Loading Scene"}
|
||||
</div>
|
||||
<div
|
||||
className="runner-canvas__loading-scene-name"
|
||||
data-testid="runner-loading-scene-name"
|
||||
>
|
||||
{sceneName}
|
||||
</div>
|
||||
{sceneLoadingScreen.headline === null ? null : (
|
||||
<div
|
||||
className="runner-canvas__loading-headline"
|
||||
data-testid="runner-loading-headline"
|
||||
>
|
||||
{sceneLoadingScreen.headline}
|
||||
</div>
|
||||
)}
|
||||
{sceneLoadingScreen.description === null ? null : (
|
||||
<div
|
||||
className="runner-canvas__loading-description"
|
||||
data-testid="runner-loading-description"
|
||||
>
|
||||
{sceneLoadingScreen.description}
|
||||
</div>
|
||||
)}
|
||||
{overlayMessage === null ? null : (
|
||||
<div
|
||||
className="runner-canvas__loading-error"
|
||||
data-testid="runner-loading-error"
|
||||
>
|
||||
{overlayMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{runnerReady &&
|
||||
navigationMode === "firstPerson" &&
|
||||
firstPersonTelemetry?.cameraSubmerged ? (
|
||||
<div className="runner-canvas__underwater" aria-hidden="true" />
|
||||
) : null}
|
||||
{runnerReady && navigationMode === "firstPerson" ? (
|
||||
<div className="runner-canvas__crosshair" aria-hidden="true" />
|
||||
) : null}
|
||||
{runnerReady &&
|
||||
navigationMode === "firstPerson" &&
|
||||
interactionPrompt !== null ? (
|
||||
<div className="runner-canvas__prompt" data-testid="runner-interaction-prompt" role="status" aria-live="polite">
|
||||
<div className="runner-canvas__prompt-badge">Click</div>
|
||||
<div className="runner-canvas__prompt-text" data-testid="runner-interaction-prompt-text">
|
||||
|
||||
Reference in New Issue
Block a user