v3.0.0: V3 features, server refactoring, and documentation overhaul

- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-02-14 10:03:45 -05:00
parent 13ab5b9017
commit 9fc6b83bba
60 changed files with 11791 additions and 1639 deletions

View File

@@ -2,17 +2,26 @@
This document describes the unified animation system for the Golf card game client.
For detailed animation flow diagrams (what triggers what, in what order, with what flags), see [`docs/ANIMATION-FLOWS.md`](../docs/ANIMATION-FLOWS.md).
## Architecture
**All card animations use anime.js.** There are no CSS transitions on card elements.
**When to use anime.js vs CSS:**
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
| What | How |
|------|-----|
| Card movements | anime.js |
| Card flips | anime.js |
| Swap animations | anime.js |
| Pulse/glow effects | anime.js |
| Hover states | CSS `:hover` only |
| Pulse/glow effects on cards | anime.js |
| Button hover/active states | CSS transitions |
| Badge entrance/exit | CSS transitions |
| Status message fades | CSS transitions |
| Card hover states | anime.js `hoverIn()`/`hoverOut()` |
| Show/hide | CSS `.hidden` class only |
### Why anime.js?
@@ -130,6 +139,27 @@ cardAnimations.cleanup()
---
## Animation Coordination
### Server-Client Timing
Server CPU timing (in `server/ai.py` `CPU_TIMING`) must account for client animation durations:
- `post_draw_settle`: Must be >= draw animation duration (~1.1s for deck draw)
- `post_action_pause`: Must be >= swap/discard animation duration (~0.5s)
### Preventing Animation Overlap
Animation overlay cards are marked with `data-animating="true"` while active.
Methods like `animateUnifiedSwap` and `animateOpponentDiscard` check for active
animations and wait before starting new ones.
### Card Hover Initialization
Call `cardAnimations.initHoverListeners(container)` after dynamically creating cards.
This is done automatically in `renderGame()` for player and opponent card areas.
---
## Animation Overlay Pattern
For complex animations (flips, swaps), the system:
@@ -150,24 +180,34 @@ All timing values are in `timing-config.js` and exposed as `window.TIMING`.
### Key Durations
| Animation | Duration | Notes |
|-----------|----------|-------|
| Flip | 245ms | 3D rotateY animation |
| Deck lift | 63ms | Before moving to hold |
| Deck move | 105ms | To hold position |
| Discard lift | 25ms | Quick grab |
| Discard move | 76ms | To hold position |
| Swap pulse | 400ms | Scale + brightness |
| Turn shake | 400ms | Every 3 seconds |
All durations are configured in `timing-config.js` and read via `window.TIMING`.
| Animation | Duration | Config Key | Notes |
|-----------|----------|------------|-------|
| Flip | 320ms | `card.flip` | 3D rotateY with slight overshoot |
| Deck lift | 120ms | `draw.deckLift` | Visible lift before travel |
| Deck move | 250ms | `draw.deckMove` | Smooth travel to hold position |
| Deck flip | 320ms | `draw.deckFlip` | Reveal drawn card |
| Discard lift | 80ms | `draw.discardLift` | Quick decisive grab |
| Discard move | 200ms | `draw.discardMove` | Travel to hold position |
| Swap lift | 100ms | `swap.lift` | Pickup before arc travel |
| Swap arc | 320ms | `swap.arc` | Arc travel between positions |
| Swap settle | 100ms | `swap.settle` | Landing with gentle overshoot |
| Swap pulse | 400ms | — | Scale + brightness (face-up swap) |
| Turn shake | 400ms | — | Every 3 seconds |
### Easing Functions
Custom cubic bezier curves give cards natural weight and momentum:
```javascript
window.TIMING.anime.easing = {
flip: 'easeInOutQuad', // Smooth acceleration/deceleration
move: 'easeOutCubic', // Fast start, gentle settle
lift: 'easeOutQuad', // Quick lift
pulse: 'easeInOutSine', // Smooth oscillation
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
pulse: 'easeInOutSine', // Smooth oscillation (loops)
}
```
@@ -179,14 +219,19 @@ window.TIMING.anime.easing = {
- Static card appearance (colors, borders, sizing)
- Layout and positioning
- Hover states (`:hover` scale/shadow)
- Card hover states (`:hover` scale/shadow - no movement)
- Show/hide via `.hidden` class
- **UI chrome animations** (buttons, badges, status messages):
- Button hover/active transitions
- Badge entrance/exit animations
- Status message fade in/out
- Modal transitions
### What CSS Does NOT Do
### What CSS Does NOT Do (on card elements)
- No `transition` on any card element
- No `@keyframes` for card animations
- No `.flipped`, `.moving`, `.flipping` transition triggers
- No `transition` on any card element (`.card`, `.card-inner`, `.real-card`, `.swap-card`, `.held-card-floating`)
- No `@keyframes` for card movements or flips
- No `.flipped`, `.moving`, `.flipping` transition triggers for cards
### Important Classes
@@ -218,14 +263,15 @@ if (!this.isDrawAnimating && /* other conditions */) {
Use anime.js timelines for coordinated sequences:
```javascript
const T = window.TIMING;
const timeline = anime.timeline({
easing: 'easeOutQuad',
easing: T.anime.easing.move,
complete: () => { /* cleanup */ }
});
timeline.add({ targets: el, translateY: -15, duration: 100 });
timeline.add({ targets: el, left: x, top: y, duration: 200 });
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
timeline.add({ targets: el, translateY: -15, duration: T.card.lift, easing: T.anime.easing.lift });
timeline.add({ targets: el, left: x, top: y, duration: T.card.move });
timeline.add({ targets: inner, rotateY: 0, duration: T.card.flip, easing: T.anime.easing.flip });
```
### Fire-and-Forget Animations

View File

@@ -194,9 +194,10 @@ class CardAnimations {
this.startDrawPulse(document.getElementById('deck'));
// Delay card animation to let pulse be visible
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
}, 250);
}, pulseDelay);
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
@@ -204,6 +205,7 @@ class CardAnimations {
const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating
const inner = animCard.querySelector('.draw-anim-inner');
const D = window.TIMING?.draw || {};
if (cardData) {
this.setCardContent(animCard, cardData);
@@ -231,36 +233,36 @@ class CardAnimations {
targets: animCard,
translateY: -15,
rotate: [-2, 0],
duration: 63,
duration: D.deckLift || 120,
easing: this.getEasing('lift')
});
// Move to holding position
// Move to holding position with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
duration: 105,
duration: D.deckMove || 250,
easing: this.getEasing('move')
});
// Suspense pause
timeline.add({ duration: 200 });
// Brief pause before flip (easing handles the deceleration feel)
timeline.add({ duration: D.deckRevealPause || 80 });
// Flip to reveal
if (cardData) {
timeline.add({
targets: inner,
rotateY: 0,
duration: 245,
duration: D.deckFlip || 320,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip')
});
}
// Brief pause to see card
timeline.add({ duration: 150 });
timeline.add({ duration: D.deckViewPause || 120 });
this.activeAnimations.set('drawDeck', timeline);
} catch (e) {
@@ -286,15 +288,17 @@ class CardAnimations {
// Pulse discard pile
this.startDrawPulse(document.getElementById('discard'));
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
}, 200);
}, pulseDelay);
}
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
animCard.dataset.animating = 'true'; // Mark as actively animating
this.setCardContent(animCard, cardData);
const D = window.TIMING?.draw || {};
// Hide actual discard pile during animation to prevent visual conflict
const discardPile = document.getElementById('discard');
@@ -326,21 +330,23 @@ class CardAnimations {
targets: animCard,
translateY: -12,
scale: 1.05,
duration: 25
duration: D.discardLift || 80,
easing: this.getEasing('lift')
});
// Direct move to holding
// Direct move to holding with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
scale: 1,
duration: 76
duration: D.discardMove || 200,
easing: this.getEasing('move')
});
// Minimal pause
timeline.add({ duration: 80 });
// Brief settle
timeline.add({ duration: D.discardViewPause || 60 });
this.activeAnimations.set('drawDiscard', timeline);
} catch (e) {
@@ -365,7 +371,7 @@ class CardAnimations {
return;
}
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
try {
const anim = anime({
@@ -408,7 +414,7 @@ class CardAnimations {
cardElement.style.opacity = '0';
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
try {
// Simple smooth flip - no lift/settle
@@ -452,7 +458,7 @@ class CardAnimations {
cardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
// Helper to restore card to face-up state
const restoreCard = () => {
@@ -551,7 +557,7 @@ class CardAnimations {
handCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
const flipDuration = window.TIMING?.card?.flip || 320;
try {
const timeline = anime.timeline({
@@ -572,7 +578,7 @@ class CardAnimations {
});
// Brief pause to see the card
timeline.add({ duration: 100 });
timeline.add({ duration: 50 });
this.activeAnimations.set(`swap-${Date.now()}`, timeline);
} catch (e) {
@@ -606,7 +612,7 @@ class CardAnimations {
sourceCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
const flipDuration = window.TIMING?.card?.flip || 320;
try {
anime.timeline({
@@ -661,8 +667,8 @@ class CardAnimations {
width: discardRect.width,
height: discardRect.height,
scale: 1,
duration: 350,
easing: 'cubicBezier(0.25, 0.1, 0.25, 1)'
duration: window.TIMING?.card?.move || 300,
easing: this.getEasing('arc')
});
this.activeAnimations.set(`discard-${Date.now()}`, timeline);
@@ -686,7 +692,7 @@ class CardAnimations {
this.setCardContent(animCard, card);
const inner = animCard.querySelector('.draw-anim-inner');
const moveDuration = window.TIMING?.card?.move || 270;
const moveDuration = window.TIMING?.card?.move || 300;
try {
const timeline = anime.timeline({
@@ -943,7 +949,7 @@ class CardAnimations {
return;
}
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const handRect = handCardEl.getBoundingClientRect();
const heldRect = heldCardEl.getBoundingClientRect();
const discardRect = this.getDiscardRect();
@@ -1005,6 +1011,7 @@ class CardAnimations {
],
rotate: [0, -3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Held card arcs to hand slot (parallel)
@@ -1017,14 +1024,16 @@ class CardAnimations {
],
rotate: [0, 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('physicalSwap', timeline);
@@ -1046,7 +1055,7 @@ class CardAnimations {
// options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect();
// Safety checks
@@ -1105,10 +1114,11 @@ class CardAnimations {
// Flip to reveal, then do the swap
this.playSound('flip');
const flipDuration = window.TIMING?.card?.flip || 320;
anime({
targets: inner,
rotateY: 0,
duration: 245,
duration: flipDuration,
easing: this.getEasing('flip'),
complete: () => {
this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
@@ -1157,6 +1167,7 @@ class CardAnimations {
height: discardRect.height,
rotate: [rotation, rotation - 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Held card arcs to hand slot (apply rotation to match hand position)
@@ -1171,14 +1182,16 @@ class CardAnimations {
height: handRect.height,
rotate: [0, 3, rotation],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('unifiedSwap', timeline);
@@ -1199,7 +1212,7 @@ class CardAnimations {
return;
}
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create a traveling card showing the face at the held card's actual position
const travelingCard = this.createCardFromData(cardData, heldRect, 0);
@@ -1242,14 +1255,16 @@ class CardAnimations {
height: discardRect.height,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('heldToDiscard', timeline);
@@ -1291,7 +1306,7 @@ class CardAnimations {
}
_runOpponentDiscard(cardData, holdingRect, discardRect, onComplete) {
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create card at holding position, face-up (already revealed by draw animation)
const travelingCard = this.createAnimCard(holdingRect, false);
@@ -1336,14 +1351,16 @@ class CardAnimations {
scale: 1,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
});
// Settle
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('opponentDiscard', timeline);

View File

@@ -52,7 +52,7 @@ class CardManager {
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
<div class="card-face card-face-back"></div>
</div>
`;
@@ -64,10 +64,22 @@ class CardManager {
updateCardAppearance(cardEl, cardData) {
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
const back = cardEl.querySelector('.card-face-back');
// Reset front classes
front.className = 'card-face card-face-front';
// Apply deck color to card back
if (back) {
// Remove any existing deck color classes
back.className = back.className.replace(/\bdeck-\w+/g, '').trim();
back.className = 'card-face card-face-back';
const deckColor = this.getDeckColorClass(cardData);
if (deckColor) {
back.classList.add(deckColor);
}
}
if (!cardData || !cardData.face_up || !cardData.rank) {
// Face down or no data
inner.classList.add('flipped');
@@ -88,6 +100,17 @@ class CardManager {
}
}
// Get the deck color class for a card based on its deck_id
getDeckColorClass(cardData) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null;
}
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`;
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
@@ -104,7 +127,8 @@ class CardManager {
cardEl.style.height = `${rect.height}px`;
if (animate) {
setTimeout(() => cardEl.classList.remove('moving'), 350);
const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
}
}
@@ -130,7 +154,11 @@ class CardManager {
}
// Animate a card flip
async flipCard(playerId, position, newCardData, duration = 400) {
async flipCard(playerId, position, newCardData, duration = null) {
// Use centralized timing if not specified
if (duration === null) {
duration = window.TIMING?.cardManager?.flipDuration || 400;
}
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
@@ -158,7 +186,11 @@ class CardManager {
}
// Animate a swap: hand card goes to discard, new card comes to hand
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = null) {
// Use centralized timing if not specified
if (duration === null) {
duration = window.TIMING?.cardManager?.moveDuration || 250;
}
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
@@ -192,7 +224,8 @@ class CardManager {
}
inner.classList.remove('flipped');
await this.delay(400);
const flipDuration = window.TIMING?.cardManager?.flipDuration || 400;
await this.delay(flipDuration);
}
// Step 2: Move card to discard
@@ -202,7 +235,8 @@ class CardManager {
cardEl.classList.remove('moving');
// Pause to show the discarded card
await this.delay(250);
const pauseDuration = window.TIMING?.cardManager?.moveDuration || 250;
await this.delay(pauseDuration);
// Step 3: Update card to show new card and move back to hand
front.className = 'card-face card-face-front';

View File

@@ -282,7 +282,11 @@
</div>
<div class="header-col header-col-center">
<div id="status-message" class="status-message"></div>
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
<div id="final-turn-badge" class="final-turn-badge hidden">
<span class="final-turn-icon"></span>
<span class="final-turn-text">FINAL TURN</span>
<span class="final-turn-remaining"></span>
</div>
</div>
<div class="header-col header-col-right">
<span id="game-username" class="game-username hidden"></span>

View File

@@ -114,7 +114,8 @@ class StateDiffer {
movements.push({
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
playerId: currentPlayerId
playerId: currentPlayerId,
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
});
}

View File

@@ -2,12 +2,12 @@
// Edit these values to tune the feel of card animations and CPU gameplay
const TIMING = {
// Card animations (milliseconds) - smooth, unhurried
// Card animations (milliseconds)
card: {
flip: 400, // Card flip duration (must match CSS transition)
move: 400, // Card movement - slower = smoother
lift: 0, // No lift pause
moving: 400, // Card moving class duration
flip: 320, // Card flip duration — readable but snappy
move: 300, // General card movement
lift: 100, // Perceptible lift before travel
settle: 80, // Gentle landing cushion
},
// Pauses - minimal, let animations flow
@@ -46,10 +46,12 @@ const TIMING = {
// Anime.js animation configuration
anime: {
easing: {
flip: 'easeInOutQuad',
move: 'easeOutCubic',
lift: 'easeOutQuad',
pulse: 'easeInOutSine',
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
pulse: 'easeInOutSine', // Keep for loops
},
loop: {
turnPulse: { duration: 2000 },
@@ -60,8 +62,8 @@ const TIMING = {
// Card manager specific
cardManager: {
flipDuration: 400, // Card flip animation
moveDuration: 400, // Card move animation
flipDuration: 320, // Card flip animation
moveDuration: 300, // Card move animation
},
// V3_02: Dealing animation
@@ -108,9 +110,22 @@ const TIMING = {
// V3_11: Physical swap animation
swap: {
lift: 80, // Time to lift cards
arc: 280, // Time for arc travel
settle: 60, // Time to settle into place
lift: 100, // Time to lift cards — visible pickup
arc: 320, // Time for arc travel
settle: 100, // Time to settle into place — with overshoot easing
},
// Draw animation durations (replaces hardcoded values in card-animations.js)
draw: {
deckLift: 120, // Lift off deck before travel
deckMove: 250, // Travel to holding position
deckRevealPause: 80, // Brief pause before flip (easing does the rest)
deckFlip: 320, // Flip to reveal drawn card
deckViewPause: 120, // Time to see revealed card
discardLift: 80, // Quick grab from discard
discardMove: 200, // Travel to holding position
discardViewPause: 60, // Brief settle after arrival
pulseDelay: 200, // Delay before card appears (pulse visible first)
},
// Player swap animation steps - smooth continuous motion