golfgame/docs/ANIMATION-FLOWS.md
adlee-was-taken 9fc6b83bba 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>
2026-02-14 10:03:45 -05:00

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.