308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
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 (1–6 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>
|
||
)
|
||
}
|