diff --git a/apps/web/src/components/viewers/DeckViewer.tsx b/apps/web/src/components/viewers/DeckViewer.tsx new file mode 100644 index 0000000..8d10ff6 --- /dev/null +++ b/apps/web/src/components/viewers/DeckViewer.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import type { TTSObject } from '@tts/shared'; +import { useBounds } from '@react-three/drei'; +import Scene from './Scene'; +import { CardObjectMesh } from './CardMesh'; + +/** How many cards to show to each side of the active card. */ +const HALF_WINDOW = 3; +/** Angular spacing between adjacent cards in the arc, in radians. */ +const ARC_STEP = 0.32; +/** Radius of the arc, in world units (large enough for card widths). */ +const ARC_RADIUS = 3.2; +/** + * A deck carousel: the deck's contained cards are fanned in a 3D arc with the + * active card front and center. Prev/next controls step through the deck, each + * card animating to its new slot. Side cards are turned 90° in y (album flow) + * so only the active card's face is framed; all neighbors edge-on around it. + * + * The camera is fitted to just the active card (not the whole carousel): the + * shared scene's auto-fit is disabled and `useBounds` refits whenever the + * selection changes. + * + * Only a window of cards around the active one is rendered (the rest stay + * hidden), so large decks stay lean. Falls back to a single card (the deck + * object itself) when there are no contained cards. + */ +export default function DeckViewer({ object }: { object: TTSObject }) { + const cards = (object.ContainedObjects ?? []).filter( + (o) => o.CardID != null || o.CustomImage != null, + ); + const count = cards.length; + const [active, setActive] = useState(0); + const hasCards = count > 0; + const centerRef = useRef(null); + + const step = (dir: number) => setActive((a) => (a + dir + count) % count); + + const visible = useMemo( + () => + cards + .map((card, i) => ({ card, i, k: i - active })) + .filter((v) => Math.abs(v.k) <= HALF_WINDOW), + [cards, active], + ); + + // The active card always settles to the arc center, so the camera only needs + // to frame it once on mount. + const didFit = useRef(false); + + return ( + + ) : undefined + } + > + {hasCards ? ( + <> + {visible.map(({ card, i, k }) => ( + // Key by the card's index (stable across renders) so the element + // persists and tweens as its slot changes; the index-path key is + // unique even though cards in a deck share the same GUID. + + ))} + + + ) : ( + + )} + + ); +} + +/** + * Fits the camera once, on mount, to frame the active card. The center card + * always settles in the same slot, so no refit is needed while navigating — + * that would restart the camera tween on every step and feel laggy. + */ +function FitActive({ + targetRef, + didFit, +}: { + targetRef: RefObject; + didFit: RefObject; +}) { + const bounds = useBounds(); + + useEffect(() => { + if (didFit.current) return; + didFit.current = true; + const node = targetRef.current; + if (node) bounds.refresh(node).fit(); + }, [bounds, targetRef, didFit]); + + return null; +} + +/** A single card that tweens into its arc slot each frame. */ +function CarouselCard({ + card, + k, + groupRef, +}: { + card: TTSObject; + k: number; + groupRef?: RefObject; +}) { + const localRef = useRef(null); + const group = groupRef ?? localRef; + // Start at the target so the first render doesn't tween into place. + const state = useRef(slotTransform(k)); + const target = useMemo(() => slotTransform(k), [k]); + + useFrame((_, dt) => { + const g = group.current; + if (!g) return; + // Smooth per-frame damping independent of frame rate. + const f = 1 - Math.pow(0.0001, dt); + const t = target; + const s = state.current; + s.x += (t.x - s.x) * f; + s.z += (t.z - s.z) * f; + s.rot += (t.rot - s.rot) * f; + s.scale += (t.scale - s.scale) * f; + g.position.set(s.x, 0, s.z); + g.rotation.y = s.rot; + g.scale.setScalar(s.scale); + }); + + return ( + + + + ); +} + +interface Slots { + x: number; + z: number; + rot: number; + scale: number; +} + +/** World transform for a card at arc offset `k` (0 = front and center). */ +function slotTransform(k: number): Slots { + const ang = k * ARC_STEP; + // Side cards turn edge-on (album flow); the active card stays forward. + const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2); + return { + x: Math.sin(ang) * ARC_RADIUS, + z: Math.cos(ang) * ARC_RADIUS, + rot: turn, + scale: 1.15 - 0.15 * Math.abs(k), + }; +} + +/** Prev/next controls and a counter, rendered as the scene overlay. */ +function CarouselControls({ + active, + count, + onStep, +}: { + active: number; + count: number; + onStep: (dir: number) => void; +}) { + const prev = () => onStep(-1); + const next = () => onStep(1); + return ( +
+ + + {active + 1} / {count} + + +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/viewers/Scene.tsx b/apps/web/src/components/viewers/Scene.tsx index 0ea3d14..4e9b62e 100644 --- a/apps/web/src/components/viewers/Scene.tsx +++ b/apps/web/src/components/viewers/Scene.tsx @@ -16,15 +16,21 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing'; * * `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. `maxPolarAngle` (radians) clamps how + * `maxPolarAngle` (radians) clamps how * far the camera can tilt below the horizon, e.g. to stop a tabletop view from * peeking under face-down cards. + * + * `fit` (default true) bounds, fits, and clips the camera to the scene's + * content on mount and resize. A viewer that needs to frame a specific part of + * its content (e.g. the active card in a deck carousel) can set it to false and + * call `useBounds()` itself to refit. */ export default function Scene({ children, autoRotate = true, enablePan = false, fullscreen = false, + fit = true, overlay, shadowScale = 22, maxPolarAngle = Math.PI, @@ -33,6 +39,8 @@ export default function Scene({ autoRotate?: boolean; enablePan?: boolean; fullscreen?: boolean; + /** Whether the shared scene fits + clips its children with `Bounds` (default true). */ + fit?: boolean; /** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */ overlay?: ReactNode; /** Contact shadow plane size in world units; defaults to a generous 22. */ @@ -82,7 +90,7 @@ export default function Scene({ - {children} + {children} import('./TileViewer')); const TokenViewer = lazy(() => import('./TokenViewer')); const CardViewer = lazy(() => import('./CardViewer')); +const DeckViewer = lazy(() => import('./DeckViewer')); const CustomModelViewer = lazy(() => import('./CustomModelViewer')); const PdfViewer = lazy(() => import('./PdfViewer')); @@ -16,9 +17,9 @@ registerViewer('Custom_Tile', TileViewer); registerViewer('Custom_Token', TokenViewer); registerViewer('Card', CardViewer); registerViewer('CardCustom', CardViewer); -registerViewer('Deck', CardViewer); -registerViewer('DeckCustom', CardViewer); -registerViewer('Custom_Deck', CardViewer); +registerViewer('Deck', DeckViewer); +registerViewer('DeckCustom', DeckViewer); +registerViewer('Custom_Deck', DeckViewer); registerViewer('Custom_Model', CustomModelViewer); registerViewer('Custom_Model_Bag', CustomModelViewer); registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);