import { useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { Card } from '../types' import { artFor, artUrlFor } from '../petArt' // The tier shown as a die face (1–6 pips), like the real cards' corner marker. const DIE_PIPS: Record = { 1: [[15, 15]], 2: [[9, 9], [21, 21]], 3: [[9, 9], [15, 15], [21, 21]], 4: [[9, 9], [21, 9], [9, 21], [21, 21]], 5: [[9, 9], [21, 9], [15, 15], [9, 21], [21, 21]], 6: [[9, 9], [21, 9], [9, 15], [21, 15], [9, 21], [21, 21]], } function TierDie({ tier }: { tier: number }) { const pips = DIE_PIPS[tier] ?? DIE_PIPS[1] return ( {pips.map(([cx, cy], i) => ( ))} ) } // useFitText shrinks text until it fits its box, so long abilities (and the // smaller battle cards) render in full instead of being clipped by the fixed // card height, and long single-line names shrink instead of wrapping. It // checks both axes — the effect band wraps and overflows vertically, the // (nowrap) name row overflows horizontally. It never grows past the CSS size, // only shrinks toward a small floor, and re-fits when the box resizes // (breakpoints, battle mode). function useFitText(dep: unknown) { const ref = useRef(null) useLayoutEffect(() => { const el = ref.current if (!el) return const overflows = () => el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth const fit = () => { el.style.fontSize = '' let size = parseFloat(getComputedStyle(el).fontSize) const min = 6 // Guard the loop; each step is 0.5px so ~40 covers any realistic box. for (let i = 0; i < 40 && overflows() && size > min; i++) { size = Math.max(min, size - 0.5) el.style.fontSize = `${size}px` } } fit() const ro = new ResizeObserver(fit) ro.observe(el) return () => ro.disconnect() }, [dep]) return ref } // renderEffect bolds each effect's trigger and renders the colon separating it // from the action as an arrow, so "Sell: add 1 extra Apple" shows "Sell →" in // bold. Cards can list several effects joined by " · "; each is stacked on its // own line rather than run together. function renderEffect(text: string) { return text.split(' · ').map((segment, i) => { const colon = segment.indexOf(':') const node = colon === -1 ? ( segment ) : ( <> {segment.slice(0, colon)} → {segment.slice(colon + 1)} ) return ( {node} ) }) } // TRIGGER_GLOSS explains when each ability fires, shown under the hover // magnifier so the trigger words aren't jargon. const TRIGGER_GLOSS: Record = { Faint: 'when this pet is knocked out', Play: 'when it enters the battle', Hurt: 'when it survives taking damage', Sell: 'when you sell it in the shop', Buy: 'when you buy it', Triple: 'when tripled as one of three same-suit pets', 'After Attacking': 'right after it attacks in a clash', 'Battle Prep': 'as the battle is arranged', 'Shop start': 'when the shop opens each round', 'Enemy Faints': 'when an enemy pet is knocked out', 'Enemy Played': 'when the enemy plays a pet', } // triggersOf extracts the trigger words (the part before each ":") from an // effect string. function triggersOf(text?: string): string[] { if (!text) return [] const out: string[] = [] for (const seg of text.split(' · ')) { const colon = seg.indexOf(':') if (colon > 0) out.push(seg.slice(0, colon).trim()) } return out } // TriggerGloss renders the plain-English explanation of a card's trigger words, // shown beneath the hover magnifier (and nothing at all if none apply). function TriggerGloss({ card }: { card: Card }) { const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t]) if (trigs.length === 0) return null return (
{trigs.map((t) => (
{t} — {TRIGGER_GLOSS[t]}
))}
) } // CardMagnify portals a large floating copy of a card near the cursor, clamped // into the viewport. It's the shared hover-preview used both by CardView's own // magnifier and by card references in the event log. export function CardMagnify({ card, x, y, bonus = 0, damage = 0, dead, flip, }: { card: Card x: number y: number bonus?: number damage?: number dead?: boolean // flip anchors the preview to the right of the cursor and grows it leftward, // for hovers near the right edge (e.g. the event log) where the default // rightward growth would run off-screen. flip?: boolean }) { const top = Math.min(Math.max(y - 120, 8), window.innerHeight - 320) return createPortal(
, document.body, ) } interface Props { card: Card size?: 'sm' | 'md' | 'lg' selected?: boolean disabled?: boolean onClick?: () => void // Battle decorations bonus?: number damage?: number dead?: boolean // preview marks the floating magnified copy so it doesn't magnify itself. preview?: boolean // noMagnify suppresses the hover magnifier (e.g. while this card is being // dragged, so the frozen preview copy doesn't linger over the drag ghost). noMagnify?: boolean } // CardView renders one physical card: pets get a power badge and a colored // suit dot, foods a description line. Battle mode layers on buffs and damage // markers. export function CardView({ card, size = 'md', selected, disabled, onClick, bonus = 0, damage = 0, dead, preview, noMagnify, }: Props) { // Hover magnifier: show a large floating copy beside the cursor. The // preview copy itself opts out so it can't recurse. const [hover, setHover] = useState<{ x: number; y: number } | null>(null) const power = (card.power ?? 0) + bonus // Shrink long ability text to fit the (fixed-height) card, re-fitting when // the text or card size changes. Only the branch that renders attaches it. const effectRef = useFitText(`${size}|${card.effectText ?? ''}|${card.food ?? ''}`) // Long single-word names (e.g. Calygreyhound) shrink to fit the name row // instead of wrapping into a ragged stack. const nameRef = useFitText(`${size}|${card.name}`) const classes = [ 'card', `card-${size}`, card.kind === 'ailment' ? 'card-ailment' : card.kind === 'food' ? 'card-food' : 'card-pet', selected ? 'is-selected' : '', disabled ? 'is-disabled' : '', dead ? 'is-dead' : '', onClick && !disabled ? 'is-clickable' : '', ] .filter(Boolean) .join(' ') // Place the magnified preview near the cursor, clamped into the viewport. const previewEl = hover && !preview && !noMagnify ? ( ) : null return (
setHover({ x: e.clientX, y: e.clientY })} onMouseMove={preview ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} onMouseLeave={preview ? undefined : () => setHover(null)} > {previewEl} {/* Upper illustration: a stylised landscape with the pet as a sticker and the red starburst attack badge floating at the top, like the real cards. */}
{card.kind === 'pet' && ( 0 ? 'is-buffed' : ''}`}>{power} )} {damage > 0 && !dead && −{damage}}
{artUrlFor(card.name) ? ( ) : ( artFor(card.name) )}
{/* Lower panel: the suit hat, name, and tier die on one row, the ability beneath — a white card in a rounded green frame. */}
{card.suit ? ( ) : ( )} {card.name} {card.tier ? : }
{card.effectText ? renderEffect(card.effectText) : card.kind === 'food' && card.food === 'apple' ? '+1 power (this battle)' : card.ailment === 'spooked' ? 'Deals 1 less damage' : card.ailment === 'exposed' ? 'Takes 1 extra damage' : ''}
{selected &&
}
) }