Improve some animations.
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user