Improve some animations.

This commit is contained in:
Greyson Parrelli
2026-07-23 21:24:30 -04:00
parent 72e198a750
commit 3e34e9b293
5 changed files with 83 additions and 20 deletions
+21 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { useCardAnimations } from '../anim'
interface Props {
view: GameView
@@ -26,6 +27,11 @@ export function ArrangePhase({ view, you, send }: Props) {
// play order across the wrap, arrowhead landing on the next pet to fight.
const trayRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
// The arrow buttons reorder `order` and the cards glide to their new slot.
// Drag-and-drop reorders instantly instead: a drag fires continuously, so
// animating every crossing looks frantic — we suppress it while dragging.
const arrangeAnim = useCardAnimations(order, (c) => c.id, !dragging)
const [connectors, setConnectors] = useState<string[]>([])
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 })
@@ -44,8 +50,11 @@ export function ArrangePhase({ view, you, send }: Props) {
}, [you.deck])
// Measure the laid-out cards and recompute the inter-row connector paths.
// Runs whenever the order changes and whenever the tray resizes (which is
// what actually moves the wrap points), so the arrows track the real layout.
// The arrows connect fixed slot positions, not specific cards: reordering
// swaps which card sits in a slot but never moves the slots, so we only
// remeasure when the card count changes or the tray resizes (which is what
// actually moves the wrap points). Recomputing on every reorder would also
// read cards mid-glide and make the arrows jump around.
useLayoutEffect(() => {
const tray = trayRef.current
if (!tray) return
@@ -131,7 +140,8 @@ export function ArrangePhase({ view, you, send }: Props) {
const ro = new ResizeObserver(measure)
ro.observe(tray)
return () => ro.disconnect()
}, [order])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [order.length])
function move(from: number, to: number) {
if (to < 0 || to >= order.length) return
@@ -192,12 +202,19 @@ export function ArrangePhase({ view, you, send }: Props) {
)}
</p>
<div className="arrange-row" ref={trayRef}>
<div
className="arrange-row"
ref={(el) => {
trayRef.current = el
arrangeAnim.containerRef.current = el
}}
>
<div className="arrange-marker"> first</div>
{order.map((c, i) => (
<div
key={c.id}
className="arrange-card"
data-flip-key={c.id}
ref={(el) => {
cardRefs.current[i] = el
}}
+34 -15
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { useCardAnimations } from '../anim'
interface Flyer {
key: string
@@ -27,6 +28,12 @@ export function ShopPhase({ view, you, send }: Props) {
const pending = view.pending
const myPending = pending?.playerId === you.id
// Cards flip in off the deck, spring in when bought, and shrink away when
// sold/bought; surviving cards glide to close the gap. Empty shop slots
// (id '') carry no flip key, so they sit still.
const shopAnim = useCardAnimations(view.shopRow, (c) => c.id || 'empty')
const deckAnim = useCardAnimations(deck, (c) => c.id)
// Drop selections that no longer exist (bought/traded/discarded cards).
useEffect(() => {
setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id)))
@@ -129,20 +136,26 @@ export function ShopPhase({ view, you, send }: Props) {
Shop · Tier {view.round}
<span className="muted"> · {view.deckCounts[view.round - 1]} left in deck</span>
</div>
<div className="shop-row">
<div className="shop-row" ref={shopAnim.containerRef}>
{view.shopRow.map((c, i) =>
c.id ? (
<CardView
key={c.id}
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined}
/>
<div key={c.id} className="card-cell" data-flip-key={c.id} data-enter="flip">
<CardView
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => act({ type: 'buy', row: 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>
))}
</div>
{myTurn && (
<div className="hint">
@@ -165,14 +178,20 @@ export function ShopPhase({ view, you, send }: Props) {
{deck.length === 0 ? (
<div className="muted deck-empty">No cards yet buy something!</div>
) : (
<div className="deck-row">
<div className="deck-row" ref={deckAnim.containerRef}>
{deck.map((c) => (
<CardView
key={c.id}
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
<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>
))}
{deckAnim.ghosts.map((g) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} />
</div>
))}
</div>
)}