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
|
||||
* 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({
|
||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit observe clip>{children}</Bounds>
|
||||
<Bounds fit={fit} observe={fit} clip={fit}>{children}</Bounds>
|
||||
</Suspense>
|
||||
|
||||
<ContactShadows
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerViewer } from '../viewers';
|
||||
const TileViewer = lazy(() => 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);
|
||||
|
||||
Reference in New Issue
Block a user