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:
2026-08-14 10:29:04 +08:00
parent 2403abc07b
commit c665212209
3 changed files with 215 additions and 5 deletions
@@ -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>
);
}
+10 -2
View File
@@ -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
+4 -3
View File
@@ -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);