feat(mesh): render cards, tiles, and tokens with separated front/back/walls

Split extrudeShapeParts into front, back, and walls so each face can carry
its own material. Flip back textures left/right on the material so they are
not mirrored, and slice card faces from the deck sprite sheet via CardID.
Resolve deck config from the parent deck, which is authoritative over a
card's own CustomDeck. Add unit tests for card resolution, sprite UVs, and
the flip helper.
This commit is contained in:
2026-08-08 17:41:59 +08:00
parent f7139495fe
commit 835250abdd
12 changed files with 633 additions and 80 deletions
+153 -18
View File
@@ -1,37 +1,172 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
/** Corner radius as a fraction of the shorter card edge. */
const CORNER_RADIUS = 0.05;
/** Thickness of the card. */
const CARD_THICKNESS = 0.06;
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
// Without it, the face/back hooks would be called conditionally, which breaks
// React's rules of hooks when switching between objects with different URL
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
const FALLBACK_URL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/**
* A playing card: a thin box with the face texture on the front and the back
* texture on the rear. Reads `CustomDeck` face/back URLs, falling back to a
* neutral color when absent.
* A playing card: a thin rounded rect with the face texture on the front and
* the back texture on the rear. Covers `Card`/`Deck`/`DeckCustom`/`Custom_Deck`
* (via `CustomDeck` face/back URLs) and `CardCustom` (via `CustomImage`).
*
* A deck image is a sheet divided into a `NumWidth` x `NumHeight` grid of
* sprites. The card footprint is sized to a single sprite's aspect ratio, and
* the face material uses UV offset/scaling to show the sprite selected by
* `CardID`. The corners stay circular because the rounded rect is built from
* the final width/height rather than scaling a square.
*
* `CardID` encodes the deck index in the hundreds place and the 1-based card
* number in the last two digits (e.g. 354 -> deck 3, card 54). The deck config
* (grid, face/back URLs) is resolved from the containing deck object's
* `CustomDeck[deckIndex]`, since a card's own `CustomDeck` may be keyed
* differently or absent.
*
* The back is treated like a tile (a single full image) unless the deck has
* `UniqueBack`, in which case it is a sheet too and gets the same sprite cell.
* It is flipped left/right so it isn't mirrored when viewed from the back of
* the card.
*/
export default function CardViewer({ object }: { object: TTSObject }) {
const deck = object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined;
const faceUrl = deck?.FaceURL;
const backUrl = deck?.BackURL;
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
resolveCardConfig(object);
return (
<Scene>
<CardMesh faceUrl={faceUrl} backUrl={backUrl} />
<CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
/>
</Scene>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
function CardMesh({ faceUrl, backUrl }: { faceUrl?: string; backUrl?: string }) {
const face = faceUrl ? useTexture(assetUrl(faceUrl)) : null;
const back = backUrl ? useTexture(assetUrl(backUrl)) : null;
function CardMesh({
faceUrl,
backUrl,
numWidth,
numHeight,
uniqueBack,
cardId,
}: {
faceUrl?: string;
backUrl?: string;
numWidth?: number;
numHeight?: number;
uniqueBack: boolean;
cardId?: number;
}) {
// Always call both hooks so the hook count is stable across renders. The
// placeholder is used only when a URL is absent; presence is checked via the
// URL strings below, not the texture objects.
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
// Front texture: the sprite cell from the sheet (or the full image when there
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
// that share the same sheet URL (drei caches textures globally by URL).
const faceMap = useMemo(() => {
if (!faceUrl) return null;
const tex = face.clone();
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return tex;
}, [faceUrl, face, cardId, numWidth, numHeight]);
// Back texture: a single full image (tile) unless the deck has unique backs,
// in which case it's a sheet too. Flipped left/right so it reads correctly
// instead of being mirrored on the back face.
const backMap = useMemo(() => {
if (!backUrl) return null;
const tex = back.clone();
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
? spriteUv(cardId, numWidth, numHeight)
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return flipTexture(tex);
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid
// white, matching TTS card tinting.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement
| undefined;
const aspect = cardAspect(img, numWidth, numHeight);
const width = CARD_LENGTH * aspect;
const height = CARD_LENGTH;
// Radius scales with the shorter edge so corners look proportional and
// stay circular (no scaling distortion).
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, {
height: CARD_THICKNESS,
});
return {
frontGeo: toGeometry(frontGeo),
backGeo: toGeometry(backGeo),
wallsGeo: toGeometry(wallsGeo),
};
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
return (
<mesh>
<boxGeometry args={[1.4, 2, 0.06]} />
<meshStandardMaterial
color={face || back ? '#ffffff' : '#52525b'}
map={face ?? back ?? undefined}
roughness={0.6}
/>
</mesh>
<group>
<mesh geometry={frontGeo}>
<meshStandardMaterial
color={faceMap ? '#ffffff' : '#52525b'}
map={faceMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial
color={backMap ? '#ffffff' : '#52525b'}
map={backMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color="#ffffff" roughness={0.6} />
</mesh>
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}