diff --git a/web/src/anim.ts b/web/src/anim.ts new file mode 100644 index 0000000..cb362f0 Binary files /dev/null and b/web/src/anim.ts differ 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