feat(web): add tabletop scene controls

Disable auto-rotate and enable panning on the setup view, add a
fullscreen toggle, and convert the controls to iconify icon buttons.
This commit is contained in:
2026-08-09 23:04:47 +08:00
parent d9d38c2bee
commit abf901418b
2 changed files with 74 additions and 9 deletions
@@ -1,4 +1,5 @@
import { Suspense, useMemo } from 'react'; import { Suspense, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import type { SerializedPackage, Setup } from '@tts/bgm'; import type { SerializedPackage, Setup } from '@tts/bgm';
import { import {
SetupLoader, SetupLoader,
@@ -27,6 +28,7 @@ export default function TabletopScene({
}) { }) {
const packageData = useMemo(() => serializedToPackage(pkg), [pkg]); const packageData = useMemo(() => serializedToPackage(pkg), [pkg]);
const surfaces = useTabletopStore((s) => s.surfaces); const surfaces = useTabletopStore((s) => s.surfaces);
const [showSurface, setShowSurface] = useState(false);
const tree = useMemo( const tree = useMemo(
() => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))), () => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))),
@@ -34,14 +36,31 @@ export default function TabletopScene({
); );
return ( return (
<Scene> <Scene
autoRotate={false}
enablePan
fullscreen
overlay={
<button
onClick={() => setShowSurface((v) => !v)}
title={showSurface ? 'Hide surfaces' : 'Show surfaces'}
aria-label={showSurface ? 'Hide surfaces' : 'Show surfaces'}
className="absolute right-2 top-10 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
<Icon
icon={showSurface ? 'mdi:view-grid' : 'mdi:view-grid-outline'}
className="h-5 w-5"
/>
</button>
}
>
<SetupLoader pkg={packageData} setup={setup} /> <SetupLoader pkg={packageData} setup={setup} />
<Suspense fallback={null}> <Suspense fallback={null}>
{tree.world.map((node) => ( {tree.world.map((node) => (
<WorldSurfaceView key={node.id} pkg={packageData} node={node} /> <WorldSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
))} ))}
{tree.hud.map((node) => ( {tree.hud.map((node) => (
<HudSurfaceView key={node.id} pkg={packageData} node={node} /> <HudSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
))} ))}
</Suspense> </Suspense>
</Scene> </Scene>
+51 -5
View File
@@ -1,4 +1,5 @@
import { Suspense, type ReactNode } from 'react'; import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { Icon } from '@iconify/react';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei'; import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
@@ -12,11 +13,56 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
* The camera is fitted to the bounds of the content on mount. `Bounds` sits * The camera is fitted to the bounds of the content on mount. `Bounds` sits
* inside the Suspense boundary, so it only mounts once the (suspending) content * inside the Suspense boundary, so it only mounts once the (suspending) content
* has loaded and its geometry is present. * has loaded and its geometry is present.
*
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
* example, pans instead of rotating). `fullscreen` adds a toggle button that
* expands the scene to the full screen.
*/ */
export default function Scene({ children }: { children: ReactNode }) { export default function Scene({
children,
autoRotate = true,
enablePan = false,
fullscreen = false,
overlay,
}: {
children: ReactNode;
autoRotate?: boolean;
enablePan?: boolean;
fullscreen?: boolean;
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
overlay?: ReactNode;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void containerRef.current?.requestFullscreen();
};
return ( return (
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950"> <div
ref={containerRef}
className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950"
>
<LoadingOverlay /> <LoadingOverlay />
{fullscreen && (
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
className="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} className="h-5 w-5" />
</button>
)}
{overlay}
<Canvas <Canvas
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }} camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
dpr={[1, 2]} dpr={[1, 2]}
@@ -40,10 +86,10 @@ export default function Scene({ children }: { children: ReactNode }) {
resolution={256} resolution={256}
/> />
<OrbitControls <OrbitControls
enablePan={false} enablePan={enablePan}
minDistance={0.01} minDistance={0.01}
maxDistance={8} maxDistance={8}
autoRotate autoRotate={autoRotate}
makeDefault makeDefault
/> />