Improved battle animations.

This commit is contained in:
Greyson Parrelli
2026-07-23 21:39:01 -04:00
parent ce70b1d89f
commit 9d9acc4577
3 changed files with 234 additions and 24 deletions
+21 -3
View File
@@ -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) => (
<div
key={c.id}
className={`setaside-card ${s.leaving.includes(c.id) ? 'is-leaving' : ''}`}
className={`setaside-card ${s.leaving.includes(c.id) ? 'is-leaving' : ''} ${
c.id === newSetAsideId ? 'setaside-in' : ''
}`}
>
<CardView card={c} size="sm" />
</div>
@@ -361,7 +377,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{foods.map((f, i) => (
<div
key={f.id}
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''}`}
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''} ${
f.id === newFoodId ? `food-in food-in-${dir}` : ''
}`}
style={{ zIndex: i + 1 }}
>
<CardView card={f} size="sm" />
@@ -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(' ')}
+102 -16
View File
@@ -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<CardFlyer | null>(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 (
<div className="shop">
{/* 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}
/>
</div>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
{shopAnim.ghosts.map((g) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} size="lg" />
</div>
))}
{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) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} size="lg" />
</div>
))}
</div>
{myTurn && (
<div className="hint">
@@ -179,15 +232,25 @@ export function ShopPhase({ view, you, send }: Props) {
<div className="muted deck-empty">No cards yet buy something!</div>
) : (
<div className="deck-row" ref={deckAnim.containerRef}>
{deck.map((c) => (
<div key={c.id} className="card-cell" data-flip-key={c.id} data-enter="pop">
<CardView
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
</div>
))}
{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 (
<div
key={c.id}
className={`card-cell ${flying ? 'is-arriving' : ''}`}
data-flip-key={c.id}
data-enter={flying ? 'none' : 'pop'}
>
<CardView
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
</div>
)
})}
{deckAnim.ghosts.map((g) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} />
@@ -287,6 +350,29 @@ export function ShopPhase({ view, you, send }: Props) {
</div>
)}
{cardFlyer &&
createPortal(
<div
key={cardFlyer.id}
className="buy-flyer"
style={
{
left: cardFlyer.dest.left,
top: cardFlyer.dest.top,
width: cardFlyer.dest.width,
height: cardFlyer.dest.height,
'--dx': `${cardFlyer.from.left - cardFlyer.dest.left}px`,
'--dy': `${cardFlyer.from.top - cardFlyer.dest.top}px`,
'--s': `${cardFlyer.from.width / cardFlyer.dest.width}`,
} as CSSProperties
}
onAnimationEnd={() => setCardFlyer(null)}
>
<CardView card={cardFlyer.card} />
</div>,
document.body,
)}
{flyers.length > 0 &&
createPortal(
<>
+111 -5
View File
@@ -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 --- */