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; }