Improved battle animations.
This commit is contained in:
@@ -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(
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user