Improved card design.

This commit is contained in:
Greyson Parrelli
2026-07-25 14:08:59 -04:00
parent 3a7f92b3cb
commit 3598180ff1
2 changed files with 310 additions and 156 deletions
+91 -24
View File
@@ -3,6 +3,60 @@ 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
@@ -177,6 +231,9 @@ export function CardView({
// 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}`,
@@ -206,33 +263,43 @@ export function CardView({
onMouseLeave={preview ? undefined : () => setHover(null)}
>
{previewEl}
<div className="card-top">
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
</div>
{/* Power sits in a spiky burst badge on its own centered row (pets only),
with any battle damage marker beside it, so it never overlaps the art,
name, or ability text. */}
{card.kind === 'pet' && (
<div className="card-combat">
{/* 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>}
)}
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div>
)}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div>
<div className="card-name">{card.name}</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'
: ''}
{/* 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>