Improved battle animations.
This commit is contained in:
@@ -293,6 +293,20 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
||||||
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
|
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
|
||||||
const revealing = !done && lastEvent?.type === 'reveal' && 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.
|
// Hold the −N / miss! pop until the dice settle.
|
||||||
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step)
|
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) => (
|
{s.setAside.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
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" />
|
<CardView card={c} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
@@ -361,7 +377,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
{foods.map((f, i) => (
|
{foods.map((f, i) => (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
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 }}
|
style={{ zIndex: i + 1 }}
|
||||||
>
|
>
|
||||||
<CardView card={f} size="sm" />
|
<CardView card={f} size="sm" />
|
||||||
@@ -381,7 +399,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
'battle-unit',
|
'battle-unit',
|
||||||
clashing || clashDying ? `clash-${dir}` : '',
|
clashing || clashDying ? `clash-${dir}` : '',
|
||||||
s.unit.dying ? 'unit-dying' : '',
|
s.unit.dying ? 'unit-dying' : '',
|
||||||
revealing ? 'unit-reveal' : '',
|
revealing ? `unit-reveal unit-reveal-${dir}` : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom'
|
||||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
@@ -11,6 +12,24 @@ interface Flyer {
|
|||||||
y: number
|
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 {
|
interface Props {
|
||||||
view: GameView
|
view: GameView
|
||||||
you: PlayerView
|
you: PlayerView
|
||||||
@@ -34,6 +53,23 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
const shopAnim = useCardAnimations(view.shopRow, (c) => c.id || 'empty')
|
const shopAnim = useCardAnimations(view.shopRow, (c) => c.id || 'empty')
|
||||||
const deckAnim = useCardAnimations(deck, (c) => c.id)
|
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).
|
// Drop selections that no longer exist (bought/traded/discarded cards).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id)))
|
setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id)))
|
||||||
@@ -104,6 +140,20 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
setSelected([])
|
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 (
|
return (
|
||||||
<div className="shop">
|
<div className="shop">
|
||||||
{/* Status line */}
|
{/* Status line */}
|
||||||
@@ -144,14 +194,17 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
card={c}
|
card={c}
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!canBuy}
|
disabled={!canBuy}
|
||||||
onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined}
|
onClick={canBuy ? () => buy(i) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div key={`empty-${i}`} className="card-slot-empty" />
|
<div key={`empty-${i}`} className="card-slot-empty" />
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{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) => (
|
||||||
<div key={g.key} className="card-cell card-ghost" style={g.style}>
|
<div key={g.key} className="card-cell card-ghost" style={g.style}>
|
||||||
<CardView card={g.item} size="lg" />
|
<CardView card={g.item} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
@@ -179,15 +232,25 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
<div className="muted deck-empty">No cards yet — buy something!</div>
|
<div className="muted deck-empty">No cards yet — buy something!</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="deck-row" ref={deckAnim.containerRef}>
|
<div className="deck-row" ref={deckAnim.containerRef}>
|
||||||
{deck.map((c) => (
|
{deck.map((c) => {
|
||||||
<div key={c.id} className="card-cell" data-flip-key={c.id} data-enter="pop">
|
// 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
|
<CardView
|
||||||
card={c}
|
card={c}
|
||||||
selected={selected.includes(c.id)}
|
selected={selected.includes(c.id)}
|
||||||
onClick={() => toggle(c.id)}
|
onClick={() => toggle(c.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{deckAnim.ghosts.map((g) => (
|
{deckAnim.ghosts.map((g) => (
|
||||||
<div key={g.key} className="card-cell card-ghost" style={g.style}>
|
<div key={g.key} className="card-cell card-ghost" style={g.style}>
|
||||||
<CardView card={g.item} />
|
<CardView card={g.item} />
|
||||||
@@ -287,6 +350,29 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
</div>
|
</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 &&
|
{flyers.length > 0 &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<>
|
<>
|
||||||
|
|||||||
+111
-5
@@ -1481,6 +1481,32 @@ h3 {
|
|||||||
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
|
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 --- */
|
/* --- the pet in play --- */
|
||||||
|
|
||||||
.battle-unit-zone {
|
.battle-unit-zone {
|
||||||
@@ -1540,6 +1566,59 @@ h3 {
|
|||||||
pointer-events: none;
|
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 {
|
.battle-unit {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1557,19 +1636,46 @@ h3 {
|
|||||||
font-size: 1.5rem;
|
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 {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotateY(90deg) scale(0.8);
|
transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotateY(0) scale(1);
|
transform: translateX(0) perspective(760px) rotateY(0deg) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit.unit-reveal {
|
@keyframes unit-reveal-right {
|
||||||
animation: unit-reveal 500ms ease;
|
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 --- */
|
/* --- rock dice --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user