- 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>
617 lines
26 KiB
Markdown
617 lines
26 KiB
Markdown
# Animation Flow Reference
|
|
|
|
Complete reference for how card animations are triggered, sequenced, and cleaned up.
|
|
All animations use anime.js via the `CardAnimations` class (`client/card-animations.js`).
|
|
Timing is configured in `client/timing-config.js`.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Architecture Overview](#architecture-overview)
|
|
2. [Animation Flags](#animation-flags)
|
|
3. [Flow 1: Local Player Draws from Deck](#flow-1-local-player-draws-from-deck)
|
|
4. [Flow 2: Local Player Draws from Discard](#flow-2-local-player-draws-from-discard)
|
|
5. [Flow 3: Local Player Swaps](#flow-3-local-player-swaps)
|
|
6. [Flow 4: Local Player Discards](#flow-4-local-player-discards)
|
|
7. [Flow 5: Opponent Draws from Deck then Swaps](#flow-5-opponent-draws-from-deck-then-swaps)
|
|
8. [Flow 6: Opponent Draws from Deck then Discards](#flow-6-opponent-draws-from-deck-then-discards)
|
|
9. [Flow 7: Opponent Draws from Discard then Swaps](#flow-7-opponent-draws-from-discard-then-swaps)
|
|
10. [Flow 8: Initial Card Flip](#flow-8-initial-card-flip)
|
|
11. [Flow 9: Deal Animation](#flow-9-deal-animation)
|
|
12. [Flow 10: Round End Reveal](#flow-10-round-end-reveal)
|
|
13. [Flag Lifecycle Summary](#flag-lifecycle-summary)
|
|
14. [Safety Clears](#safety-clears)
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ app.js │
|
|
│ │
|
|
│ User Click / WebSocket ──► triggerAnimationsForStateChange │
|
|
│ │ │ │
|
|
│ ▼ ▼ │
|
|
│ Set flags ──────────────► CardAnimations method │
|
|
│ │ │ │
|
|
│ ▼ ▼ │
|
|
│ renderGame() skips anime.js timeline runs │
|
|
│ flagged elements │ │
|
|
│ │ ▼ │
|
|
│ │ Callback fires │
|
|
│ │ │ │
|
|
│ ▼ ▼ │
|
|
│ Flags cleared ◄──────── renderGame() called │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ Normal rendering resumes │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Key principle:** Flags block `renderGame()` from updating the DOM while animations are in flight. The animation callback clears flags and triggers a fresh render.
|
|
|
|
---
|
|
|
|
## Animation Flags
|
|
|
|
Flags in `app.js` that prevent `renderGame()` from updating the discard pile or held card during animations:
|
|
|
|
| Flag | Type | Blocks | Purpose |
|
|
|------|------|--------|---------|
|
|
| `isDrawAnimating` | bool | Discard pile, held card | Draw animation in progress |
|
|
| `localDiscardAnimating` | bool | Discard pile | Local player discarding drawn card |
|
|
| `opponentDiscardAnimating` | bool | Discard pile | Opponent discarding without swap |
|
|
| `opponentSwapAnimation` | object/null | Discard pile, turn indicator | Opponent swap `{ playerId, position }` |
|
|
| `dealAnimationInProgress` | bool | Flip prompts | Deal animation running |
|
|
| `swapAnimationInProgress` | bool | Game state application | Local swap — defers incoming state |
|
|
|
|
**renderGame() skip logic:**
|
|
```
|
|
if (localDiscardAnimating OR opponentSwapAnimation OR
|
|
opponentDiscardAnimating OR isDrawAnimating):
|
|
→ skip discard pile update
|
|
```
|
|
|
|
---
|
|
|
|
## Flow 1: Local Player Draws from Deck
|
|
|
|
**Trigger:** User clicks deck
|
|
|
|
```
|
|
User clicks deck
|
|
│
|
|
▼
|
|
drawFromDeck()
|
|
├─ Validate: isMyTurn(), no drawnCard
|
|
└─ Send: { type: 'draw', source: 'deck' }
|
|
│
|
|
▼
|
|
Server responds: 'card_drawn'
|
|
├─ Store drawnCard, drawnFromDiscard=false
|
|
├─ Clear stale flags (opponentSwap, opponentDiscard)
|
|
├─ SET isDrawAnimating = true
|
|
└─ hideDrawnCard()
|
|
│
|
|
▼
|
|
cardAnimations.animateDrawDeck(card, callback)
|
|
│
|
|
├─ Pulse deck (gold ring)
|
|
├─ Wait pulseDelay (200ms)
|
|
│
|
|
▼
|
|
_animateDrawDeckCard() timeline:
|
|
┌─────────────────────────────────────────┐
|
|
│ 1. Lift off deck (120ms, lift ease) │
|
|
│ translateY: -15, rotate wobble │
|
|
│ │
|
|
│ 2. Move to hold pos (250ms, move ease) │
|
|
│ left/top to holdingRect │
|
|
│ │
|
|
│ 3. Brief pause (80ms) │
|
|
│ │
|
|
│ 4. Flip to reveal (320ms, flip ease) │
|
|
│ rotateY: 180→0, play flip sound │
|
|
│ │
|
|
│ 5. View pause (120ms) │
|
|
└─────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback:
|
|
├─ CLEAR isDrawAnimating = false
|
|
├─ displayHeldCard(card) with popIn
|
|
├─ renderGame()
|
|
└─ Show toast: "Swap with a card or discard"
|
|
```
|
|
|
|
**Total animation time:** ~200 + 120 + 250 + 80 + 320 + 120 = ~1090ms
|
|
|
|
---
|
|
|
|
## Flow 2: Local Player Draws from Discard
|
|
|
|
**Trigger:** User clicks discard pile
|
|
|
|
```
|
|
User clicks discard
|
|
│
|
|
▼
|
|
drawFromDiscard()
|
|
├─ Validate: isMyTurn(), no drawnCard, discard_top exists
|
|
└─ Send: { type: 'draw', source: 'discard' }
|
|
│
|
|
▼
|
|
Server responds: 'card_drawn'
|
|
├─ Store drawnCard, drawnFromDiscard=true
|
|
├─ Clear stale flags
|
|
├─ SET isDrawAnimating = true
|
|
└─ hideDrawnCard()
|
|
│
|
|
▼
|
|
cardAnimations.animateDrawDiscard(card, callback)
|
|
│
|
|
├─ Pulse discard (gold ring)
|
|
├─ Wait pulseDelay (200ms)
|
|
│
|
|
▼
|
|
_animateDrawDiscardCard() timeline:
|
|
┌─────────────────────────────────────────┐
|
|
│ Hide actual discard pile (opacity: 0) │
|
|
│ │
|
|
│ 1. Quick lift (80ms, lift ease) │
|
|
│ translateY: -12, scale: 1.05 │
|
|
│ │
|
|
│ 2. Move to hold pos (200ms, move ease) │
|
|
│ left/top to holdingRect │
|
|
│ │
|
|
│ 3. Brief settle (60ms) │
|
|
└─────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback:
|
|
├─ Restore discard pile opacity
|
|
├─ CLEAR isDrawAnimating = false
|
|
├─ displayHeldCard(card) with popIn
|
|
├─ renderGame()
|
|
└─ Show toast: "Swap with a card or discard"
|
|
```
|
|
|
|
**Total animation time:** ~200 + 80 + 200 + 60 = ~540ms
|
|
|
|
---
|
|
|
|
## Flow 3: Local Player Swaps
|
|
|
|
**Trigger:** User clicks hand card while holding a drawn card
|
|
|
|
```
|
|
User clicks hand card (position N)
|
|
│
|
|
▼
|
|
handleCardClick(position)
|
|
└─ drawnCard exists → animateSwap(position)
|
|
│
|
|
▼
|
|
animateSwap(position)
|
|
├─ SET swapAnimationInProgress = true
|
|
├─ Hide originals (swap-out class, visibility:hidden)
|
|
├─ Store drawnCard, clear this.drawnCard
|
|
├─ SET skipNextDiscardFlip = true
|
|
└─ Send: { type: 'swap', position }
|
|
│
|
|
├──────────────────────────────────┐
|
|
│ Face-up card? │ Face-down card?
|
|
▼ ▼
|
|
Card data known Store pendingSwapData
|
|
immediately Wait for server response
|
|
│ │
|
|
│ ▼
|
|
│ Server: 'game_state'
|
|
│ ├─ Detect swapAnimationInProgress
|
|
│ ├─ Store pendingGameState
|
|
│ └─ updateSwapAnimation(discard_top)
|
|
│ │
|
|
▼──────────────────────────────────▼
|
|
cardAnimations.animateUnifiedSwap()
|
|
│
|
|
▼
|
|
_doArcSwap() timeline:
|
|
┌───────────────────────────────────────────┐
|
|
│ (If face-down: flip first, 320ms) │
|
|
│ │
|
|
│ 1. Lift both cards (100ms, lift ease) │
|
|
│ translateY: -10, scale: 1.03 │
|
|
│ │
|
|
│ 2a. Hand card arcs (320ms, arc ease) │
|
|
│ → discard pile │
|
|
│ │
|
|
│ 2b. Held card arcs (320ms, arc ease) │ ← parallel
|
|
│ → hand slot │ with 2a
|
|
│ │
|
|
│ 3. Settle (100ms, settle ease)│
|
|
│ scale: 1.02→1 (gentle overshoot) │
|
|
└───────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback → completeSwapAnimation()
|
|
├─ Clean up animation state, remove classes
|
|
├─ CLEAR swapAnimationInProgress = false
|
|
├─ Apply pendingGameState if exists
|
|
└─ renderGame()
|
|
```
|
|
|
|
**Total animation time:** ~100 + 320 + 100 = ~520ms (face-up), ~840ms (face-down)
|
|
|
|
---
|
|
|
|
## Flow 4: Local Player Discards
|
|
|
|
**Trigger:** User clicks discard button while holding a drawn card
|
|
|
|
```
|
|
User clicks discard button
|
|
│
|
|
▼
|
|
discardDrawn()
|
|
├─ Store discardedCard
|
|
├─ Send: { type: 'discard' }
|
|
├─ Clear drawnCard, hide toast/button
|
|
├─ Get heldRect (position of floating card)
|
|
├─ Hide floating held card
|
|
├─ SET skipNextDiscardFlip = true
|
|
└─ SET localDiscardAnimating = true
|
|
│
|
|
▼
|
|
cardAnimations.animateHeldToDiscard(card, heldRect, callback)
|
|
│
|
|
▼
|
|
Timeline:
|
|
┌───────────────────────────────────────────┐
|
|
│ 1. Lift (100ms, lift ease) │
|
|
│ translateY: -8, scale: 1.02 │
|
|
│ │
|
|
│ 2. Arc to discard (320ms, arc ease) │
|
|
│ left/top with arc peak above │
|
|
│ │
|
|
│ 3. Settle (100ms, settle ease)│
|
|
│ scale: 1.02→1 │
|
|
└───────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback:
|
|
├─ updateDiscardPileDisplay(card)
|
|
├─ pulseDiscardLand()
|
|
├─ SET skipNextDiscardFlip = true
|
|
└─ CLEAR localDiscardAnimating = false
|
|
```
|
|
|
|
**Total animation time:** ~100 + 320 + 100 = ~520ms
|
|
|
|
---
|
|
|
|
## Flow 5: Opponent Draws from Deck then Swaps
|
|
|
|
**Trigger:** State change detected via WebSocket `game_state` update
|
|
|
|
```
|
|
Server sends game_state (opponent drew + swapped)
|
|
│
|
|
▼
|
|
triggerAnimationsForStateChange(old, new)
|
|
│
|
|
├─── STEP 1: Draw Detection ───────────────────────┐
|
|
│ drawn_card: null → something │
|
|
│ drawn_player_id != local player │
|
|
│ Discard unchanged → drew from DECK │
|
|
│ │
|
|
│ ├─ Clear stale opponent flags │
|
|
│ ├─ SET isDrawAnimating = true │
|
|
│ └─ animateDrawDeck(null, callback) │
|
|
│ │ │
|
|
│ └─ Callback: CLEAR isDrawAnimating │
|
|
│ │
|
|
├─── STEP 2: Swap Detection ───────────────────────┐
|
|
│ discard_top changed │
|
|
│ Previous player's hand has different card │
|
|
│ NOT justDetectedDraw (skip guard) │
|
|
│ │
|
|
│ └─ fireSwapAnimation(playerId, card, pos) │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ SET opponentSwapAnimation = { playerId, pos } │
|
|
│ Hide source card (swap-out) │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ animateUnifiedSwap() → _doArcSwap() │
|
|
│ (same timeline as Flow 3) │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ Callback: │
|
|
│ ├─ Restore source card │
|
|
│ ├─ CLEAR opponentSwapAnimation = null │
|
|
│ └─ renderGame() │
|
|
└───────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Note:** STEP 1 and STEP 2 are detected in the same `triggerAnimationsForStateChange` call. The draw animation fires first; the swap animation fires after (may overlap slightly depending on timing).
|
|
|
|
---
|
|
|
|
## Flow 6: Opponent Draws from Deck then Discards
|
|
|
|
**Trigger:** State change — opponent drew from deck but didn't swap (discarded drawn card)
|
|
|
|
```
|
|
Server sends game_state (opponent drew + discarded)
|
|
│
|
|
▼
|
|
triggerAnimationsForStateChange(old, new)
|
|
│
|
|
├─── STEP 1: Draw Detection ──────────────────┐
|
|
│ (Same as Flow 5 — draw from deck) │
|
|
│ SET isDrawAnimating = true │
|
|
│ animateDrawDeck(null, callback) │
|
|
│ │
|
|
├─── STEP 2: Discard Detection ────────────────┐
|
|
│ discard_top changed │
|
|
│ No hand position changed (no swap) │
|
|
│ │
|
|
│ └─ fireDiscardAnimation(card, playerId) │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ SET opponentDiscardAnimating = true │
|
|
│ SET skipNextDiscardFlip = true │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ animateOpponentDiscard(card, callback) │
|
|
│ │
|
|
│ Timeline: │
|
|
│ ┌────────────────────────────────────────┐ │
|
|
│ │ (Wait for draw overlay to clear) │ │
|
|
│ │ │ │
|
|
│ │ 1. Lift (100ms, lift ease) │ │
|
|
│ │ 2. Arc→discard (320ms, arc ease) │ │
|
|
│ │ 3. Settle (100ms, settle ease) │ │
|
|
│ └────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ Callback: │
|
|
│ ├─ CLEAR opponentDiscardAnimating = false │
|
|
│ ├─ updateDiscardPileDisplay(card) │
|
|
│ └─ pulseDiscardLand() │
|
|
└───────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Flow 7: Opponent Draws from Discard then Swaps
|
|
|
|
**Trigger:** State change — opponent took from discard pile and swapped
|
|
|
|
```
|
|
Server sends game_state (opponent drew from discard + swapped)
|
|
│
|
|
▼
|
|
triggerAnimationsForStateChange(old, new)
|
|
│
|
|
├─── STEP 1: Draw Detection ──────────────────┐
|
|
│ drawn_card: null → something │
|
|
│ Discard top CHANGED → drew from DISCARD │
|
|
│ │
|
|
│ ├─ Clear stale opponent flags │
|
|
│ ├─ SET isDrawAnimating = true │
|
|
│ └─ animateDrawDiscard(card, callback) │
|
|
│ │
|
|
├─── STEP 2: Skip Guard ───────────────────────┐
|
|
│ justDetectedDraw AND discard changed? │
|
|
│ YES → SKIP STEP 2 │
|
|
│ │
|
|
│ The discard change was from REMOVING a │
|
|
│ card (draw), not ADDING one (discard). │
|
|
│ The swap detection comes from a LATER │
|
|
│ state update when the turn completes. │
|
|
└───────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
(Next state update detects the swap via STEP 2)
|
|
└─ fireSwapAnimation() — same as Flow 5
|
|
```
|
|
|
|
**Critical:** The skip guard (`!justDetectedDraw`) prevents double-animating when an opponent draws from the discard pile. Without it, the discard change would trigger both a draw animation AND a discard animation.
|
|
|
|
---
|
|
|
|
## Flow 8: Initial Card Flip
|
|
|
|
**Trigger:** User clicks face-down card during the initial flip phase (start of round)
|
|
|
|
```
|
|
User clicks face-down card (position N)
|
|
│
|
|
▼
|
|
handleCardClick(position)
|
|
├─ Check: waiting_for_initial_flip
|
|
├─ Validate: card is face-down, not already tracked
|
|
├─ Add to locallyFlippedCards set
|
|
├─ Add to selectedCards array
|
|
└─ fireLocalFlipAnimation(position, card)
|
|
│
|
|
▼
|
|
fireLocalFlipAnimation()
|
|
├─ Add to animatingPositions set (prevent overlap)
|
|
└─ cardAnimations.animateInitialFlip(cardEl, card, callback)
|
|
│
|
|
▼
|
|
Timeline:
|
|
┌──────────────────────────────────┐
|
|
│ Create overlay at card position │
|
|
│ Hide original (opacity: 0) │
|
|
│ │
|
|
│ 1. Flip (320ms, flip) │
|
|
│ rotateY: 180→0 │
|
|
│ Play flip sound │
|
|
└──────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback:
|
|
├─ Remove overlay, restore original
|
|
└─ Remove from animatingPositions
|
|
│
|
|
▼
|
|
renderGame() (called after click)
|
|
└─ Shows flipped state immediately (optimistic)
|
|
│
|
|
▼
|
|
(If all required flips selected)
|
|
└─ Send: { type: 'flip_cards', positions: [...] }
|
|
│
|
|
▼
|
|
Server confirms → clear locallyFlippedCards
|
|
```
|
|
|
|
---
|
|
|
|
## Flow 9: Deal Animation
|
|
|
|
**Trigger:** `game_started` or `round_started` WebSocket message
|
|
|
|
```
|
|
Server: 'game_started' / 'round_started'
|
|
│
|
|
▼
|
|
Reset all state, cancel animations
|
|
SET dealAnimationInProgress = true
|
|
renderGame() — layout card slots
|
|
Hide player/opponent cards (visibility: hidden)
|
|
│
|
|
▼
|
|
cardAnimations.animateDealing(gameState, getPlayerRect, callback)
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ Shuffle pause (400ms) │
|
|
│ │
|
|
│ For each deal round (6 total): │
|
|
│ For each player (dealer's left first): │
|
|
│ ┌─────────────────────────────────────┐ │
|
|
│ │ Create overlay at deck position │ │
|
|
│ │ Fly to player card slot (150ms) │ │
|
|
│ │ Play card sound │ │
|
|
│ │ Stagger delay (80ms) │ │
|
|
│ └─────────────────────────────────────┘ │
|
|
│ Round pause (50ms) │
|
|
│ │
|
|
│ Wait for last cards to land │
|
|
│ Flip discard card (200ms delay + flip sound) │
|
|
│ Clean up all overlays │
|
|
└─────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Callback:
|
|
├─ CLEAR dealAnimationInProgress = false
|
|
├─ Show real cards (visibility: visible)
|
|
├─ renderGame()
|
|
└─ animateOpponentInitialFlips()
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ For each opponent: │
|
|
│ Random delay (500-2500ms window) │
|
|
│ For each face-up card: │
|
|
│ Temporarily show as face-down │
|
|
│ animateOpponentFlip() (320ms) │
|
|
│ Stagger (400ms between cards) │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Total deal time:** ~400 + (6 rounds x players x 230ms) + 350ms flip
|
|
|
|
---
|
|
|
|
## Flow 10: Round End Reveal
|
|
|
|
**Trigger:** `round_over` WebSocket message after round ends
|
|
|
|
```
|
|
Server: 'game_state' (phase → 'round_over')
|
|
├─ Detect roundJustEnded
|
|
├─ Save pre/post reveal states
|
|
└─ Update gameState but DON'T render
|
|
│
|
|
▼
|
|
Server: 'round_over' (scores, rankings)
|
|
│
|
|
▼
|
|
runRoundEndReveal(scores, rankings)
|
|
├─ SET revealAnimationInProgress = true
|
|
├─ renderGame() — show current layout
|
|
├─ Compute cardsToReveal (face-down → face-up)
|
|
└─ Get reveal order (knocker first, then clockwise)
|
|
│
|
|
▼
|
|
┌──────────────────────────────────────────┐
|
|
│ For each player (in reveal order): │
|
|
│ Highlight player area │
|
|
│ Pause (200ms) │
|
|
│ │
|
|
│ For each face-down card: │
|
|
│ animateRevealFlip(id, pos, card) │
|
|
│ ├─ Local: animateInitialFlip (320ms) │
|
|
│ └─ Opponent: animateOpponentFlip │
|
|
│ Stagger (100ms) │
|
|
│ │
|
|
│ Wait for last flip + pause │
|
|
│ Remove highlight │
|
|
└──────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
CLEAR revealAnimationInProgress = false
|
|
renderGame()
|
|
│
|
|
▼
|
|
Run score tally animation
|
|
Show scoreboard
|
|
```
|
|
|
|
---
|
|
|
|
## Flag Lifecycle Summary
|
|
|
|
Every flag follows the same pattern: **SET before animation, CLEAR in callback**.
|
|
|
|
```
|
|
SET flag ──► Animation runs ──► Callback fires ──► CLEAR flag
|
|
│
|
|
▼
|
|
renderGame()
|
|
```
|
|
|
|
### Where each flag is cleared
|
|
|
|
| Flag | Normal Clear | Safety Clears |
|
|
|------|-------------|---------------|
|
|
| `isDrawAnimating` | Draw animation callback | — |
|
|
| `localDiscardAnimating` | Discard animation callback | Fallback path |
|
|
| `opponentDiscardAnimating` | Opponent discard callback | `your_turn`, `card_drawn`, before opponent draw |
|
|
| `opponentSwapAnimation` | Swap animation callback | `your_turn`, `card_drawn`, before opponent draw, new round |
|
|
| `dealAnimationInProgress` | Deal complete callback | — |
|
|
| `swapAnimationInProgress` | `completeSwapAnimation()` | — |
|
|
|
|
---
|
|
|
|
## Safety Clears
|
|
|
|
Stale flags can freeze the UI. Multiple locations clear opponent flags as a safety net:
|
|
|
|
| Location | Clears | When |
|
|
|----------|--------|------|
|
|
| `your_turn` message handler | `opponentSwapAnimation`, `opponentDiscardAnimating` | Player's turn starts |
|
|
| `card_drawn` handler (deck) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
|
| `card_drawn` handler (discard) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
|
| Before opponent draw animation | `opponentSwapAnimation`, `opponentDiscardAnimating` | New opponent animation starts |
|
|
| `game_started`/`round_started` | All flags | New round resets everything |
|
|
|
|
**Rule:** If you add a new animation flag, add safety clears in the `your_turn` handler and at round start.
|