import { lazy, Suspense, useEffect, useState, type CSSProperties } from 'react'; import type { CompiledPage } from '../../src/contracts/page'; import type { SceneRecord } from '../../src/contracts/scene'; import { sceneAspect } from './scene-coordinate-map'; import { HotspotOverlay } from './HotspotOverlay'; import { MotionPreferenceControl, useMotionPreference } from './MotionPreferenceControl'; import { probeRenderer } from './renderer-capability'; import { useSceneNavigation } from './useSceneNavigation'; const LobbyRiveCanvas = lazy(() => import('./LobbyRiveCanvas.client')); const DOOR_OPEN_MS = 760; function LobbyCopyOverlay() { return ( ); } /** Poster first (prerendered), native layer only in the browser and only when a verified .riv exists; real links on top. */ export function SceneShell({ scene, page }: { scene: SceneRecord; page: CompiledPage }) { const [mounted, setMounted] = useState(false); const [renderer, setRenderer] = useState('pending'); const [nativeReady, setNativeReady] = useState(false); const [nativeFailed, setNativeFailed] = useState(false); const [focus, setFocus] = useState(null); const [motion, setMotion] = useMotionPreference(); const [pointer, setPointer] = useState({ x: 0.5, y: 0.5, active: false }); useEffect(() => { setMounted(true); const verdict = probeRenderer(); setRenderer(verdict.ok ? verdict.renderer : verdict.reason); }, []); useEffect(() => { if (!motion) setNativeReady(false); }, [motion]); const { hotspots, activate, leaving, leavingTarget } = useSceneNavigation({ hotspots: scene.hotspots, pageId: page.id, motion: motion && nativeReady, transitionMs: hotspot => scene.id === 'lobby' && hotspot.id.startsWith('door-') ? DOOR_OPEN_MS : 0, onRouteStart: hotspot => setFocus(hotspot.id), }); const hardware = mounted && !/^no |^software/i.test(renderer) && renderer !== 'pending'; const showLobbyNative = Boolean(hardware && motion && scene.id === 'lobby' && scene.rive && !nativeFailed); const caption = focus ? scene.captions[focus] ?? hotspots.find(h => h.id === focus)?.label ?? '' : scene.captionRest; const planeStyle = { aspectRatio: sceneAspect(scene.canvas), '--scene-w': scene.canvas.width, '--scene-h': scene.canvas.height } as CSSProperties; const sceneStyle = { ...planeStyle, '--pointer-x': pointer.x, '--pointer-y': pointer.y } as CSSProperties; return (
{ const rect = event.currentTarget.getBoundingClientRect(); setPointer({ x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)), active: true }); }} onPointerLeave={() => setPointer(pointer => ({ ...pointer, active: false }))} > {/* One plane in room coordinates, sized to cover the viewport: poster, native layer and hit shapes map the same way. */}
{scene.posterAlt})} /> {showLobbyNative && ( setNativeReady(true)} onError={() => { setNativeFailed(true); setNativeReady(false); }} /> )} {scene.id === 'lobby' && nativeReady && } { if (!leaving) setFocus(id); }} onActivate={activate} />

{caption}

{mounted && scene.id === 'lobby' && }
); }