import { useEffect, useRef, useState } from "react"; 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, type RuntimeDialogueState, type RuntimePauseState, type RuntimeSceneTransitionRequest, type RuntimeSceneLoadState } from "../runtime-three/runtime-host"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; import { areRuntimeClockStatesEqual, createRuntimeClockState, type RuntimeClockState } from "../runtime-three/runtime-project-time"; import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; const FORWARD_RUNTIME_CLOCK_INTERVAL_MS = 240; interface RunnerCanvasProps { runtimeScene: RuntimeSceneDefinition; sceneName: string; sceneLoadingScreen: SceneLoadingScreenSettings; projectAssets: Record; loadedModelAssets: Record; loadedImageAssets: Record; loadedAudioAssets: Record; navigationMode: RuntimeNavigationMode; runtimeClock?: RuntimeClockState; onRuntimeMessageChange(message: string | null): void; onRuntimeClockChange?(clock: RuntimeClockState): void; onFirstPersonTelemetryChange(telemetry: FirstPersonTelemetry | null): void; onInteractionPromptChange(prompt: RuntimeInteractionPrompt | null): void; onSceneTransitionActivated(request: RuntimeSceneTransitionRequest): void; } export function RunnerCanvas({ runtimeScene, sceneName, sceneLoadingScreen, projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets, navigationMode, runtimeClock = createRuntimeClockState(runtimeScene.time), onRuntimeMessageChange, onRuntimeClockChange = () => {}, onFirstPersonTelemetryChange, onInteractionPromptChange, onSceneTransitionActivated }: RunnerCanvasProps) { const containerRef = useRef(null); const hostRef = useRef(null); const onRuntimeClockChangeRef = useRef(onRuntimeClockChange); const lastForwardedRuntimeClockRef = useRef(runtimeClock); const lastForwardedRuntimeClockAtRef = useRef(0); const [runnerMessage, setRunnerMessage] = useState(null); const [sceneLoadState, setSceneLoadState] = useState({ status: "loading", message: null }); const [interactionPrompt, setInteractionPrompt] = useState(null); const [runtimeDialogue, setRuntimeDialogue] = useState(null); const [runtimePauseState, setRuntimePauseState] = useState({ paused: false, source: null }); const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); const overlayMessage = runnerMessage ?? sceneLoadState.message; const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status; const runnerReady = overlayStatus === "ready"; onRuntimeClockChangeRef.current = onRuntimeClockChange; useEffect(() => { const container = containerRef.current; if (container === null) { return; } try { const runtimeHost = new RuntimeHost({ enableRendering: true }); hostRef.current = runtimeHost; runtimeHost.mount(container); runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange); runtimeHost.setSceneLoadStateHandler(setSceneLoadState); runtimeHost.setRuntimeClockStateHandler?.((clock) => { const now = performance.now(); if ( areRuntimeClockStatesEqual(lastForwardedRuntimeClockRef.current, clock) ) { return; } if ( now - lastForwardedRuntimeClockAtRef.current < FORWARD_RUNTIME_CLOCK_INTERVAL_MS && clock.dayCount === lastForwardedRuntimeClockRef.current.dayCount ) { return; } lastForwardedRuntimeClockRef.current = clock; lastForwardedRuntimeClockAtRef.current = now; onRuntimeClockChangeRef.current(clock); }); runtimeHost.setFirstPersonTelemetryHandler((telemetry) => { setFirstPersonTelemetry(telemetry); onFirstPersonTelemetryChange(telemetry); }); runtimeHost.setInteractionPromptHandler((prompt) => { setInteractionPrompt(prompt); onInteractionPromptChange(prompt); }); runtimeHost.setRuntimeDialogueHandler(setRuntimeDialogue); runtimeHost.setRuntimePauseStateHandler(setRuntimePauseState); setRunnerMessage(null); return () => { onInteractionPromptChange(null); onFirstPersonTelemetryChange(null); setFirstPersonTelemetry(null); setInteractionPrompt(null); setRuntimeDialogue(null); setRuntimePauseState({ paused: false, source: null }); runtimeHost.dispose(); hostRef.current = null; }; } catch (error) { 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 ]); useEffect(() => { hostRef.current?.setSceneTransitionHandler(onSceneTransitionActivated); }, [onSceneTransitionActivated]); useEffect(() => { lastForwardedRuntimeClockRef.current = runtimeClock; }, [runtimeClock.dayCount, runtimeClock.dayLengthMinutes, runtimeClock.timeOfDayHours]); useEffect(() => { hostRef.current?.updateAssets( projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets ); }, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]); useEffect(() => { setRunnerMessage(null); setSceneLoadState({ status: "loading", message: null }); setInteractionPrompt(null); setFirstPersonTelemetry(null); onInteractionPromptChange(null); onFirstPersonTelemetryChange(null); onRuntimeMessageChange(null); hostRef.current?.setNavigationMode(navigationMode); hostRef.current?.loadScene(runtimeScene); }, [ navigationMode, onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene ]); useEffect(() => { hostRef.current?.setNavigationMode(navigationMode); }, [navigationMode]); return (
{overlayStatus === "error" ? "Load Failed" : "Loading Scene"}
{sceneName}
{sceneLoadingScreen.headline === null ? null : (
{sceneLoadingScreen.headline}
)} {sceneLoadingScreen.description === null ? null : (
{sceneLoadingScreen.description}
)} {overlayMessage === null ? null : (
{overlayMessage}
)}
{runnerReady && navigationMode === "firstPerson" && firstPersonTelemetry?.cameraSubmerged ? (