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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user