From 9d9acc45778c9ff4563bf813f20f1802e397f6b8 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 21:39:01 -0400 Subject: [PATCH] Improved battle animations. --- web/src/components/BattlePhase.tsx | 24 +++++- web/src/components/ShopPhase.tsx | 118 +++++++++++++++++++++++++---- web/src/styles.css | 116 ++++++++++++++++++++++++++-- 3 files changed, 234 insertions(+), 24 deletions(-) diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 8b0d5e7..406af92 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -293,6 +293,20 @@ export function BattlePhase({ view, send, step, setStep }: Props) { const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat + // A food (apple) that just landed on this side's fan — reveal off the deck or + // a Battle Prep hand-out — animates in from the deck rather than popping. + const newFoodId = + !done && + lastEvent?.seat === seat && + (lastEvent.type === 'prep' || + (lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food')) + ? lastEvent.card?.id + : null + // A pet just set aside rises up from the arena into the set-aside row. + const newSetAsideId = + !done && lastEvent?.type === 'setaside' && lastEvent.seat === seat + ? lastEvent.card?.id + : null // Hold the −N / miss! pop until the dice settle. const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step) @@ -344,7 +358,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) { {s.setAside.map((c) => (
@@ -361,7 +377,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) { {foods.map((f, i) => (
@@ -381,7 +399,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) { 'battle-unit', clashing || clashDying ? `clash-${dir}` : '', s.unit.dying ? 'unit-dying' : '', - revealing ? 'unit-reveal' : '', + revealing ? `unit-reveal unit-reveal-${dir}` : '', ] .filter(Boolean) .join(' ')} diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 5e2e357..6161714 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' import { createPortal } from 'react-dom' import type { Card, ClientMessage, GameView, PlayerView } from '../types' import { CardView } from './CardView' @@ -11,6 +12,24 @@ interface Flyer { y: number } +// A bought card in flight from the shop slot it was clicked to the spot it lands +// in your deck. `from` is where the click happened; `dest` is measured once the +// card shows up in the deck. +interface CardFlyer { + id: string + card: Card + from: DOMRect + dest: DOMRect +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + !!window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + interface Props { view: GameView you: PlayerView @@ -34,6 +53,23 @@ export function ShopPhase({ view, you, send }: Props) { const shopAnim = useCardAnimations(view.shopRow, (c) => c.id || 'empty') const deckAnim = useCardAnimations(deck, (c) => c.id) + // --- buy animation: a bought card flies from the shop slot down into the + // hand. `buyFly` is the pending intent (set on click, before the server round + // trip); once the card lands in the deck we measure its slot and hand off to + // `cardFlyer`, which renders the traveling copy. While either references a + // card, that deck slot is held empty (hidden) so the card doesn't also pop in. + const [buyFly, setBuyFly] = useState<{ id: string; from: DOMRect; card: Card } | null>(null) + const [cardFlyer, setCardFlyer] = useState(null) + + useLayoutEffect(() => { + if (!buyFly) return + if (!deck.some((c) => c.id === buyFly.id)) return // hasn't landed in the deck yet + const el = document.querySelector(`.deck-row [data-card-id="${buyFly.id}"]`) + if (!el) return + setCardFlyer({ id: buyFly.id, card: buyFly.card, from: buyFly.from, dest: el.getBoundingClientRect() }) + setBuyFly(null) + }, [deck, buyFly]) + // Drop selections that no longer exist (bought/traded/discarded cards). useEffect(() => { setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id))) @@ -104,6 +140,20 @@ export function ShopPhase({ view, you, send }: Props) { setSelected([]) } + // Buy a card, flying it from the slot you clicked down into your hand. Capture + // the clicked card's rect now (the slot refills with a new card right away); + // the launch effect measures where it lands and animates the trip. + function buy(row: number) { + const card = view.shopRow[row] + const el = card.id ? document.querySelector(`.shop-row [data-card-id="${card.id}"]`) : null + if (el && !prefersReducedMotion()) { + setBuyFly({ id: card.id, from: el.getBoundingClientRect(), card }) + // Safety net: if the card never shows up in the deck, stop hiding its slot. + window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000) + } + act({ type: 'buy', row }) + } + return (
{/* Status line */} @@ -144,18 +194,21 @@ export function ShopPhase({ view, you, send }: Props) { card={c} size="lg" disabled={!canBuy} - onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined} + onClick={canBuy ? () => buy(i) : undefined} />
) : (
), )} - {shopAnim.ghosts.map((g) => ( -
- -
- ))} + {shopAnim.ghosts + // The bought card flies out as its own copy, so skip its shrink ghost. + .filter((g) => g.key !== buyFly?.id && g.key !== cardFlyer?.id) + .map((g) => ( +
+ +
+ ))}
{myTurn && (
@@ -179,15 +232,25 @@ export function ShopPhase({ view, you, send }: Props) {
No cards yet — buy something!
) : (
- {deck.map((c) => ( -
- toggle(c.id)} - /> -
- ))} + {deck.map((c) => { + // Hold the slot empty while the bought card is flying into it, and + // skip the pop enter — the flight is its entrance. + const flying = c.id === buyFly?.id || c.id === cardFlyer?.id + return ( +
+ toggle(c.id)} + /> +
+ ) + })} {deckAnim.ghosts.map((g) => (
@@ -287,6 +350,29 @@ export function ShopPhase({ view, you, send }: Props) {
)} + {cardFlyer && + createPortal( +
setCardFlyer(null)} + > + +
, + document.body, + )} + {flyers.length > 0 && createPortal( <> diff --git a/web/src/styles.css b/web/src/styles.css index 60d7c6b..3513250 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1481,6 +1481,32 @@ h3 { filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)); } +/* A bought card in flight from the shop slot to its landing spot in your hand. + The element is positioned over its destination slot (fixed) and inverted back + to where it was clicked (--dx/--dy/--s), then plays that offset out — a FLIP + from shop to hand. Its deck slot stays empty (.is-arriving) until it lands. */ +@keyframes buy-fly { + from { + transform: translate(var(--dx), var(--dy)) scale(var(--s)); + } + to { + transform: translate(0, 0) scale(1); + } +} + +.buy-flyer { + position: fixed; + z-index: 60; + transform-origin: top left; + animation: buy-fly 480ms cubic-bezier(0.5, 0.02, 0.3, 1) forwards; + pointer-events: none; + filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5)); +} + +.card-cell.is-arriving { + opacity: 0; +} + /* --- the pet in play --- */ .battle-unit-zone { @@ -1540,6 +1566,59 @@ h3 { pointer-events: none; } +/* A freshly dealt food (apple) sails in from the deck — up and toward the outer + edge — and settles onto the fan, rather than blinking into place. Direction + follows the side the deck sits on (see food-in-left / food-in-right). */ +@keyframes food-in-left { + from { + opacity: 0; + transform: translate(-64px, -52px) rotate(-14deg) scale(0.6); + } + to { + opacity: 1; + transform: translate(0, 0) rotate(0) scale(1); + } +} + +@keyframes food-in-right { + from { + opacity: 0; + transform: translate(64px, -52px) rotate(14deg) scale(0.6); + } + to { + opacity: 1; + transform: translate(0, 0) rotate(0) scale(1); + } +} + +.food-fan-card.food-in-left { + animation: food-in-left 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; +} + +.food-fan-card.food-in-right { + animation: food-in-right 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; +} + +/* A pet that faints with a pending effect rises up out of the arena into the + set-aside row, shrinking from its arena size to the smaller set-aside size. */ +@keyframes setaside-in { + from { + opacity: 0; + transform: translateY(150px) scale(1.2); + } + 55% { + opacity: 1; + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.setaside-card.setaside-in { + animation: setaside-in 600ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; +} + .battle-unit { position: relative; flex-shrink: 0; @@ -1557,19 +1636,46 @@ h3 { font-size: 1.5rem; } -@keyframes unit-reveal { +/* A pet flips face-up off its deck: it starts over the stack (outer edge) still + turned edge-on, then slides in toward its arena slot while turning to face us. + The deck sits to the outer side of each unit, so each side pulls the reveal in + from its own edge. */ +@keyframes unit-reveal-left { from { opacity: 0; - transform: rotateY(90deg) scale(0.8); + transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92); + } + 45% { + opacity: 1; } to { opacity: 1; - transform: rotateY(0) scale(1); + transform: translateX(0) perspective(760px) rotateY(0deg) scale(1); } } -.battle-unit.unit-reveal { - animation: unit-reveal 500ms ease; +@keyframes unit-reveal-right { + from { + opacity: 0; + transform: translateX(108px) perspective(760px) rotateY(92deg) scale(0.92); + } + 45% { + opacity: 1; + } + to { + opacity: 1; + transform: translateX(0) perspective(760px) rotateY(0deg) scale(1); + } +} + +.battle-unit.unit-reveal-left { + transform-origin: left center; + animation: unit-reveal-left 520ms cubic-bezier(0.2, 0.8, 0.3, 1); +} + +.battle-unit.unit-reveal-right { + transform-origin: right center; + animation: unit-reveal-right 520ms cubic-bezier(0.2, 0.8, 0.3, 1); } /* --- rock dice --- */