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(
<>