From 547cd81a9938a82048b5f616edb1dc04bda3d911 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 11:55:12 -0400 Subject: [PATCH] Various UX improvements. --- web/src/components/BattlePhase.tsx | 40 ++++++++++++++++++++++++------ web/src/components/CardView.tsx | 40 +++++++++++++++++++++++++++--- web/src/styles.css | 38 +++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 15 deletions(-) diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 0182865..8b0d5e7 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -30,6 +30,9 @@ interface SideVis { pending: Card[] // foods revealed (or prepped) waiting for a pet unit: UnitVis | null setAside: Card[] // fainted pets kept beside the arena with a pending effect + // Ids of cards (set-aside pets or spent food perks) released this step: kept + // in their arrays for one beat so they can animate out, cleared next event. + leaving: string[] } // Milliseconds each event type stays on screen during playback. @@ -57,10 +60,24 @@ const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').le // seat's visual state. Units that died in the last applied event are still // present with dying=true so they can animate out. function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] { - const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null, setAside: [] })) + const sides: SideVis[] = stackSizes.map((n) => ({ + stack: n, + pending: [], + unit: null, + setAside: [], + leaving: [], + })) for (let k = 0; k < upto && k < events.length; k++) { for (const s of sides) { if (s.unit?.dying) s.unit = null // clear last step's casualties + if (s.leaving.length) { + // Drop cards that finished animating out last step. + const gone = new Set(s.leaving) + s.setAside = s.setAside.filter((c) => !gone.has(c.id)) + if (s.unit) s.unit.foods = s.unit.foods.filter((c) => !gone.has(c.id)) + s.pending = s.pending.filter((c) => !gone.has(c.id)) + s.leaving = [] + } } const ev = events[k] switch (ev.type) { @@ -152,12 +169,10 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side break case 'release': { // A spent card leaves the play area: a set-aside pet (Turtle) or a - // used-up food perk (Melon), which lives in the pet's food fan. - const s = sides[ev.seat!] + // used-up food perk (Melon), which lives in the pet's food fan. Flag it + // rather than removing it, so it animates out before unmounting. const id = ev.card?.id - s.setAside = s.setAside.filter((c) => c.id !== id) - if (s.unit) s.unit.foods = s.unit.foods.filter((c) => c.id !== id) - s.pending = s.pending.filter((c) => c.id !== id) + if (id) sides[ev.seat!].leaving.push(id) break } } @@ -327,7 +342,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) { const setAsideEl = s.setAside.length > 0 && (
{s.setAside.map((c) => ( - +
+ +
))}
) @@ -339,7 +359,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) { const foodFanEl = foods.length > 0 && (
{foods.map((f, i) => ( -
+
))} diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index cec8e7c..1bd9c89 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -1,8 +1,35 @@ -import { useState } from 'react' +import { useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { Card } from '../types' import { artFor } from '../petArt' +// 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(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 +} + interface Props { card: Card size?: 'sm' | 'md' | 'lg' @@ -39,6 +66,9 @@ export function CardView({ // 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 ?? ''}`) const classes = [ 'card', `card-${size}`, @@ -89,14 +119,18 @@ export function CardView({
{card.name}
{card.kind === 'pet' ? ( <> - {card.effectText &&
{card.effectText}
} + {card.effectText && ( +
+ {card.effectText} +
+ )}
0 ? 'is-buffed' : ''}`}>{power} {damage > 0 && !dead && −{damage}}
) : ( -
+
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
)} diff --git a/web/src/styles.css b/web/src/styles.css index 1aee67e..842bee9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -854,9 +854,10 @@ h3 { width: 14px; height: 14px; border-radius: 50%; - border: 1.5px solid rgba(0, 0, 0, 0.3); vertical-align: middle; + /* Inset ring instead of a real border — see .card-power. */ box-shadow: + inset 0 0 0 1.5px rgba(0, 0, 0, 0.3), inset 0 1.5px 1px rgba(255, 255, 255, 0.6), inset 0 -2px 2px rgba(0, 0, 0, 0.25); } @@ -879,14 +880,17 @@ h3 { background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark)); color: #fff; text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55); - border: 2px solid rgba(120, 45, 5, 0.35); border-radius: 50%; width: 31px; height: 31px; display: grid; place-items: center; font-size: 0.98rem; + /* The rim is drawn with an inset ring rather than a real border: a + border-radius:50% element with a semi-transparent border renders a + visible seam at its 6 o'clock point in Chromium (scale-invariant). */ box-shadow: + inset 0 0 0 2px rgba(120, 45, 5, 0.35), inset 0 1.5px 1px rgba(255, 255, 255, 0.55), inset 0 -2px 3px rgba(120, 45, 5, 0.4), 0 2px 3px rgba(0, 0, 0, 0.3); @@ -894,9 +898,9 @@ h3 { .card-power.is-buffed { background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55); - border-color: rgba(15, 80, 40, 0.4); text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55); box-shadow: + inset 0 0 0 2px rgba(15, 80, 40, 0.4), inset 0 1.5px 1px rgba(255, 255, 255, 0.55), inset 0 -2px 3px rgba(15, 80, 40, 0.4), 0 0 8px rgba(80, 220, 120, 0.5); @@ -1434,6 +1438,22 @@ h3 { margin-top: -88px; } +/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and + fade out over their release step rather than vanishing instantly. The step + lasts 500ms (EVENT_MS.release), so the 400ms exit finishes before unmount. */ +@keyframes card-leave { + to { + opacity: 0; + transform: scale(0.55) translateY(-8px); + } +} + +.setaside-card.is-leaving, +.food-fan-card.is-leaving { + animation: card-leave 400ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards; + pointer-events: none; +} + .battle-unit { position: relative; flex-shrink: 0; @@ -1444,6 +1464,13 @@ h3 { height: 132px; } +/* Battle cards are short, so give the ability text more of the card by + shrinking the decorative art; useFitText then keeps the text at a larger + size before it has to shrink to fit. */ +.battle-unit .card-art { + font-size: 1.5rem; +} + @keyframes unit-reveal { from { opacity: 0; @@ -1835,7 +1862,10 @@ h3 { color: var(--cream); border: 2px solid var(--cocoa); border-right: none; - border-radius: 8px 0 0 8px; + /* The button is flipped 180° (below) to read text upward, which also flips + these corners — so round the right pair here to land rounded corners on + the inner (screen-facing) edge after the rotation. */ + border-radius: 0 8px 8px 0; font-weight: 800; font-size: 0.8rem; padding: 8px 10px;