From 3e34e9b293bee4e11077ac97371db6c538e525a2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 21:24:30 -0400 Subject: [PATCH] Improve some animations. --- web/src/anim.ts | Bin 0 -> 6180 bytes web/src/components/ArrangePhase.tsx | 25 +++++++++++--- web/src/components/ShopPhase.tsx | 49 +++++++++++++++++++--------- web/src/styles.css | 27 +++++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 5 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 web/src/anim.ts diff --git a/web/src/anim.ts b/web/src/anim.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb362f039e3bea4122c8b77bbd361357af234f73 GIT binary patch literal 6180 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 b3n22crTK6GUWH4A-P7qPoIQao_k;fe(MFY< literal 0 HcmV?d00001 diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 4cf1992..4bb8166 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -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(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([]) 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) { )}

-
+
{ + trayRef.current = el + arrangeAnim.containerRef.current = el + }} + >
⚔️ first
{order.map((c, i) => (
{ cardRefs.current[i] = el }} diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 140bdca..5e2e357 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -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} · {view.deckCounts[view.round - 1]} left in deck
-
+
{view.shopRow.map((c, i) => c.id ? ( - act({ type: 'buy', row: i }) : undefined} - /> +
+ act({ type: 'buy', row: i }) : undefined} + /> +
) : (
), )} + {shopAnim.ghosts.map((g) => ( +
+ +
+ ))}
{myTurn && (
@@ -165,14 +178,20 @@ export function ShopPhase({ view, you, send }: Props) { {deck.length === 0 ? (
No cards yet — buy something!
) : ( -
+
{deck.map((c) => ( - toggle(c.id)} - /> +
+ toggle(c.id)} + /> +
+ ))} + {deckAnim.ghosts.map((g) => ( +
+ +
))}
)} diff --git a/web/src/styles.css b/web/src/styles.css index 751958a..60d7c6b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1051,6 +1051,33 @@ h3 { gap: 16px; flex-wrap: wrap; justify-content: center; + /* Positioning context for the exit "ghost" cards, which are pinned to where + the departing card sat while they shrink away (see .card-ghost). */ + position: relative; +} + +/* A thin wrapper around each animated card. The FLIP animator moves and flips + this element (not the .card inside), so the card's own hover/selected lift + transforms stay independent. backface-visibility hides the card mid-flip so + a dealt-in card reads as turning face-up rather than showing mirrored text. */ +.card-cell { + display: flex; + flex: 0 0 auto; + backface-visibility: hidden; +} + +/* An exiting card: pinned (inline styles) where it last sat, shrinking away as + its former neighbors glide into the gap. */ +.card-ghost { + z-index: 3; + animation: card-ghost-out 280ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards; +} + +@keyframes card-ghost-out { + to { + opacity: 0; + transform: scale(0.62) translateY(-6px); + } } .deck-empty { diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 59ee396..ca90d72 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file