feat(web): add deck viewer
Add a DeckViewer that frames the active card in a deck carousel. Scene gains a fit prop so the shared bounds behavior can be disabled and refit manually.
This commit is contained in:
@@ -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<THREE.Group>(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 (
|
||||||
|
<Scene
|
||||||
|
fit={false}
|
||||||
|
autoRotate={false}
|
||||||
|
overlay={
|
||||||
|
hasCards ? (
|
||||||
|
<CarouselControls active={active} count={count} onStep={step} />
|
||||||
|
) : 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.
|
||||||
|
<CarouselCard
|
||||||
|
key={i}
|
||||||
|
card={card}
|
||||||
|
k={k}
|
||||||
|
groupRef={k === 0 ? centerRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FitActive targetRef={centerRef} didFit={didFit} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CardObjectMesh object={object} />
|
||||||
|
)}
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<THREE.Group | null>;
|
||||||
|
didFit: RefObject<boolean>;
|
||||||
|
}) {
|
||||||
|
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<THREE.Group | null>;
|
||||||
|
}) {
|
||||||
|
const localRef = useRef<THREE.Group>(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 (
|
||||||
|
<group ref={group}>
|
||||||
|
<CardObjectMesh object={card} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={prev}
|
||||||
|
aria-label="Previous card"
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="rounded-md bg-zinc-900/80 px-3 py-1 font-mono text-xs text-zinc-300 backdrop-blur">
|
||||||
|
{active + 1} / {count}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
aria-label="Next card"
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,15 +16,21 @@ import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
|||||||
*
|
*
|
||||||
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||||
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
* 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
|
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
|
||||||
* peeking under face-down cards.
|
* 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({
|
export default function Scene({
|
||||||
children,
|
children,
|
||||||
autoRotate = true,
|
autoRotate = true,
|
||||||
enablePan = false,
|
enablePan = false,
|
||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
|
fit = true,
|
||||||
overlay,
|
overlay,
|
||||||
shadowScale = 22,
|
shadowScale = 22,
|
||||||
maxPolarAngle = Math.PI,
|
maxPolarAngle = Math.PI,
|
||||||
@@ -33,6 +39,8 @@ export default function Scene({
|
|||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
enablePan?: boolean;
|
enablePan?: boolean;
|
||||||
fullscreen?: 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. */
|
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
@@ -82,7 +90,7 @@ export default function Scene({
|
|||||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Bounds fit observe clip>{children}</Bounds>
|
<Bounds fit={fit} observe={fit} clip={fit}>{children}</Bounds>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<ContactShadows
|
<ContactShadows
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { registerViewer } from '../viewers';
|
|||||||
const TileViewer = lazy(() => import('./TileViewer'));
|
const TileViewer = lazy(() => import('./TileViewer'));
|
||||||
const TokenViewer = lazy(() => import('./TokenViewer'));
|
const TokenViewer = lazy(() => import('./TokenViewer'));
|
||||||
const CardViewer = lazy(() => import('./CardViewer'));
|
const CardViewer = lazy(() => import('./CardViewer'));
|
||||||
|
const DeckViewer = lazy(() => import('./DeckViewer'));
|
||||||
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
||||||
const PdfViewer = lazy(() => import('./PdfViewer'));
|
const PdfViewer = lazy(() => import('./PdfViewer'));
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@ registerViewer('Custom_Tile', TileViewer);
|
|||||||
registerViewer('Custom_Token', TokenViewer);
|
registerViewer('Custom_Token', TokenViewer);
|
||||||
registerViewer('Card', CardViewer);
|
registerViewer('Card', CardViewer);
|
||||||
registerViewer('CardCustom', CardViewer);
|
registerViewer('CardCustom', CardViewer);
|
||||||
registerViewer('Deck', CardViewer);
|
registerViewer('Deck', DeckViewer);
|
||||||
registerViewer('DeckCustom', CardViewer);
|
registerViewer('DeckCustom', DeckViewer);
|
||||||
registerViewer('Custom_Deck', CardViewer);
|
registerViewer('Custom_Deck', DeckViewer);
|
||||||
registerViewer('Custom_Model', CustomModelViewer);
|
registerViewer('Custom_Model', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||||
|
|||||||
Reference in New Issue
Block a user