Files
super-auto-pets-board-game/web/src/components/CardView.tsx
T

308 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Card } from '../types'
import { artFor } from '../petArt'
// The suit's "hat" — the trade-in symbol printed on the real cards, tinted to
// the suit's colour. Drawn as a tiny sun-hat SVG so it scales crisply.
const SUIT_HAT: Record<string, { fill: string; band: string }> = {
red: { fill: '#e5563f', band: '#b62c1f' },
blue: { fill: '#4d8ce3', band: '#2b5fb0' },
yellow: { fill: '#f2c24d', band: '#d9992f' },
}
function SuitHat({ suit }: { suit: string }) {
const c = SUIT_HAT[suit] ?? SUIT_HAT.yellow
return (
<svg className="card-hat" viewBox="0 0 28 18" aria-hidden>
<path
d="M7 12.5 Q7 3.5 14 3.5 Q21 3.5 21 12.5 Z"
fill={c.fill}
stroke="rgba(50,25,8,.45)"
strokeWidth="1"
/>
<path d="M7.4 11 Q14 13 20.6 11 L20.6 12.6 Q14 14.6 7.4 12.6 Z" fill={c.band} />
<ellipse
cx="14"
cy="13.4"
rx="12.6"
ry="3.7"
fill={c.fill}
stroke="rgba(50,25,8,.45)"
strokeWidth="1"
/>
</svg>
)
}
// The tier shown as a die face (16 pips), like the real cards' corner marker.
const DIE_PIPS: Record<number, [number, number][]> = {
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 (
<svg className="card-die" viewBox="0 0 30 30" aria-hidden>
<rect x="1.5" y="1.5" width="27" height="27" rx="6.5" fill="#fff" stroke="#2c1c0e" strokeWidth="2" />
{pips.map(([cx, cy], i) => (
<circle key={i} cx={cx} cy={cy} r="2.9" fill="#2c1c0e" />
))}
</svg>
)
}
// useFitText shrinks the effect text until it fits its band, so long abilities
// (and the smaller battle cards) render in full instead of being clipped by
// the fixed card height. It never grows past the CSS size, only shrinks toward
// a small floor, and re-fits when the band resizes (breakpoints, battle mode).
function useFitText(dep: unknown) {
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
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 band.
for (let i = 0; i < 40 && el.scrollHeight > el.clientHeight && 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
) : (
<>
<strong>{segment.slice(0, colon)} </strong>
{segment.slice(colon + 1)}
</>
)
return (
<span className="card-effect-line" key={i}>
{node}
</span>
)
})
}
// TRIGGER_GLOSS explains when each ability fires, shown under the hover
// magnifier so the trigger words aren't jargon.
const TRIGGER_GLOSS: Record<string, string> = {
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 (
<div className="trigger-gloss">
{trigs.map((t) => (
<div className="trigger-gloss-line" key={t}>
<strong>{t}</strong> {TRIGGER_GLOSS[t]}
</div>
))}
</div>
)
}
// 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(
<div
className={`card-magnify ${flip ? 'card-magnify-flip' : ''}`}
style={
flip
? { right: Math.max(8, window.innerWidth - x + 24), top }
: { left: Math.min(x + 24, window.innerWidth - 200), top }
}
>
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
<TriggerGloss card={card} />
</div>,
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 ? (
<CardMagnify card={card} x={hover.x} y={hover.y} bonus={bonus} damage={damage} dead={dead} />
) : null
return (
<div
className={classes}
data-card-id={card.id || undefined}
onClick={disabled ? undefined : onClick}
role={onClick ? 'button' : undefined}
onMouseEnter={preview ? undefined : (e) => 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. */}
<div className="card-scene">
{card.kind === 'pet' && (
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
)}
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div>
</div>
{/* Lower panel: the suit hat, name, and tier die on one row, the ability
beneath — a white card in a rounded green frame. */}
<div className="card-panel">
<div className="card-panel-head">
{card.suit ? (
<SuitHat suit={card.suit} />
) : (
<span className="card-panel-slot" aria-hidden />
)}
<span className="card-name" ref={nameRef}>
{card.name}
</span>
{card.tier ? <TierDie tier={card.tier} /> : <span className="card-panel-slot" aria-hidden />}
</div>
<div className="card-effect" ref={effectRef}>
{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'
: ''}
</div>
</div>
{selected && <div className="card-check"></div>}
</div>
)
}