Files
tts-workshop/apps/web/src/components/viewers/cardResolution.ts
T
hypercross 835250abdd 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.
2026-08-08 17:41:59 +08:00

91 lines
3.4 KiB
TypeScript

import type { TTSObject } from '@tts/shared';
/**
* Everything needed to render a card, derived from the object and its
* containing deck. Kept free of react-three so it can be unit-tested in a
* plain node environment.
*/
export interface CardRenderConfig {
/** The card's `CardID`: deck index in the hundreds place, 0-based card
* number in the last two digits (e.g. 354 -> deck 3, card 54). */
cardId?: number;
faceUrl?: string;
backUrl?: string;
/** Grid columns of the face sheet. */
numWidth?: number;
/** Grid rows of the face sheet. */
numHeight?: number;
/** Whether each card has its own back sprite (a sheet) vs. a shared tile. */
uniqueBack: boolean;
}
/**
* Resolve a card's render config.
*
* The deck config (grid, face/back URLs) lives on the containing deck object,
* keyed by the hundreds digit of the card's `CardID`. A card's own
* `CustomDeck` may be keyed differently or absent, so the parent deck is the
* authoritative source. Falls back to the object's own `CustomDeck` (or
* `CustomImage` for `CardCustom`) when there's no parent deck.
*/
export function resolveCardConfig(object: TTSObject): CardRenderConfig {
const cardId = object.CardID;
const deckIndex = cardId != null ? Math.floor(cardId / 100) : undefined;
const deck =
object.Parent?.CustomDeck?.[deckIndex!] ??
(object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined);
return {
cardId,
faceUrl: deck?.FaceURL ?? object.CustomImage?.ImageURL,
backUrl: deck?.BackURL ?? object.CustomImage?.ImageSecondaryURL,
numWidth: deck?.NumWidth,
numHeight: deck?.NumHeight,
uniqueBack: deck?.UniqueBack ?? false,
};
}
/**
* UV repeat/offset that selects a single sprite from a `NumWidth` x `NumHeight`
* sheet. `CardID` encodes the deck index in the hundreds place and the 0-based
* card number in the last two digits (e.g. 354 -> deck 3, card 54). Without a
* grid, the whole image is shown (repeat 1, offset 0).
*/
export function spriteUv(
cardId: number | undefined,
numWidth: number | undefined,
numHeight: number | undefined,
): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } {
if (!numWidth || !numHeight) {
return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
}
// The card number is 0-based (e.g. CardID 806 -> card 6), so clamp to
// [0, numWidth*numHeight - 1] and index directly.
const cardNumber = cardId != null ? cardId % 100 : 0;
const n = Math.min(Math.max(cardNumber, 0), numWidth * numHeight - 1);
const col = n % numWidth;
const row = Math.floor(n / numWidth);
return {
repeatX: 1 / numWidth,
repeatY: 1 / numHeight,
offsetX: col / numWidth,
// Row 0 is the top of the image (v=1), so the offset counts down from 1.
offsetY: 1 - (row + 1) / numHeight,
};
}
/**
* The aspect ratio (width / height) of a single card sprite. For a card sheet,
* the sheet dimensions are divided by the `NumWidth`/`NumHeight` grid so the
* result reflects one card rather than the whole sheet. Falls back to 1 (a
* square) while the image is loading or when there's no image.
*/
export function cardAspect(
img: HTMLImageElement | undefined,
numWidth: number | undefined,
numHeight: number | undefined,
): number {
if (!img || !img.width || !img.height) return 1;
const w = numWidth && numWidth > 0 ? img.width / numWidth : img.width;
const h = numHeight && numHeight > 0 ? img.height / numHeight : img.height;
return w / h;
}