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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user