golfgame/docs/v3/V3_02_DEALING_ANIMATION.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

11 KiB
Raw Blame History

V3-02: Dealing Animation

Overview

In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.

Dependencies: V3_01 (Dealer Rotation - need to know who is dealing) Dependents: None


Goals

  1. Animate cards being dealt from a central deck position
  2. Deal one card at a time to each player in clockwise order
  3. Play shuffle sound before dealing begins
  4. Play card sound as each card lands
  5. Maintain quick perceived pace (stagger start times, not end times)
  6. Show dealing from dealer's position (or center as fallback)

Current State

From app.js, when game_started or round_started message received:

case 'game_started':
case 'round_started':
    this.gameState = data.game_state;
    this.playSound('shuffle');
    this.showGameScreen();
    this.renderGame();  // Cards appear instantly
    break;

Cards are rendered immediately via renderGame() which populates the card grids.


Design

Animation Sequence

1. Shuffle sound plays
2. Brief pause (300ms) - deck appears to shuffle
3. Deal round 1: One card to each player (clockwise from dealer's left)
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
5. Flip discard pile top card
6. Initial flip phase begins (or game starts if initial_flips=0)

Visual Flow

                    [Deck]
                      |
    ┌─────────────────┼─────────────────┐
    │                 │                 │
    ▼                 ▼                 ▼
[Opponent 1]    [Opponent 2]    [Opponent 3]
                      |
                      ▼
              [Local Player]

Cards fly from deck position to each player's card slot, face-down.

Timing

// New timing values in timing-config.js
dealing: {
    shufflePause: 400,      // Pause after shuffle sound
    cardFlyTime: 150,       // Time for card to fly to destination
    cardStagger: 80,        // Delay between cards (overlap for speed)
    roundPause: 50,         // Brief pause between deal rounds
    discardFlipDelay: 200,  // Pause before flipping discard
}

Total time for 4-player game (24 cards):

  • 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds

This feels unhurried but not slow.

Implementation Approach

Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.

Pros:

  • Clean separation from game state
  • Easy to skip/interrupt
  • No complex state management

Cons:

  • Brief flash when swapping to real cards (mitigate with timing)

Option B: Animate Real Cards

Start with cards at deck position, animate to final positions.

Pros:

  • No element swap
  • More "real"

Cons:

  • Complex coordination with renderGame()
  • State management issues

Recommendation: Option A - overlay animation


Implementation

Add to card-animations.js

Add the dealing animation as a method on the existing CardAnimations class:

// Add to CardAnimations class in card-animations.js

/**
 * Run the dealing animation using anime.js timelines
 * @param {Object} gameState - The game state with players and their cards
 * @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
 * @param {Function} onComplete - Callback when animation completes
 */
async animateDealing(gameState, getPlayerRect, onComplete) {
    const T = window.TIMING?.dealing || {
        shufflePause: 400,
        cardFlyTime: 150,
        cardStagger: 80,
        roundPause: 50,
        discardFlipDelay: 200,
    };

    const deckRect = this.getDeckRect();
    const discardRect = this.getDiscardRect();
    if (!deckRect) {
        if (onComplete) onComplete();
        return;
    }

    // Get player order starting from dealer's left
    const dealerIdx = gameState.dealer_idx || 0;
    const playerOrder = this.getDealOrder(gameState.players, dealerIdx);

    // Create container for animation cards
    const container = document.createElement('div');
    container.className = 'deal-animation-container';
    container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
    document.body.appendChild(container);

    // Shuffle sound and pause
    this.playSound('shuffle');
    await this.delay(T.shufflePause);

    // Deal 6 rounds of cards using anime.js
    const allCards = [];
    for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
        for (const player of playerOrder) {
            const targetRect = getPlayerRect(player.id, cardIdx);
            if (!targetRect) continue;

            // Create card at deck position
            const deckColor = this.getDeckColor();
            const card = this.createAnimCard(deckRect, true, deckColor);
            card.classList.add('deal-anim-card');
            container.appendChild(card);
            allCards.push({ card, targetRect });

            // Animate using anime.js
            anime({
                targets: card,
                left: targetRect.left,
                top: targetRect.top,
                width: targetRect.width,
                height: targetRect.height,
                duration: T.cardFlyTime,
                easing: this.getEasing('move'),
            });

            this.playSound('card');
            await this.delay(T.cardStagger);
        }

        // Brief pause between rounds
        if (cardIdx < 5) {
            await this.delay(T.roundPause);
        }
    }

    // Wait for last cards to land
    await this.delay(T.cardFlyTime);

    // Flip discard pile card
    if (discardRect && gameState.discard_top) {
        await this.delay(T.discardFlipDelay);
        this.playSound('flip');
    }

    // Clean up
    container.remove();
    if (onComplete) onComplete();
}

getDealOrder(players, dealerIdx) {
    // Rotate so dealing starts to dealer's left
    const order = [...players];
    const startIdx = (dealerIdx + 1) % order.length;
    return [...order.slice(startIdx), ...order.slice(0, startIdx)];
}

delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

CSS for Deal Animation

/* In style.css - minimal, anime.js handles all animation */

/* Deal animation container */
.deal-animation-container {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    pointer-events: none;
    z-index: 1000;
}

/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
.deal-anim-card {
    /* Uses same structure as createAnimCard() */
}

Integration in app.js

// In handleMessage, game_started/round_started case:

case 'game_started':
case 'round_started':
    this.clearNextHoleCountdown();
    this.nextRoundBtn.classList.remove('waiting');
    this.roundWinnerNames = new Set();
    this.gameState = data.game_state;
    this.previousState = JSON.parse(JSON.stringify(data.game_state));
    this.locallyFlippedCards = new Set();
    this.selectedCards = [];
    this.animatingPositions = new Set();
    this.opponentSwapAnimation = null;

    this.showGameScreen();

    // NEW: Run deal animation using CardAnimations
    this.runDealAnimation(() => {
        this.renderGame();
    });
    break;

// New method using CardAnimations
runDealAnimation(onComplete) {
    // Hide cards initially
    this.playerCards.style.visibility = 'hidden';
    this.opponentsRow.style.visibility = 'hidden';

    // Use the global cardAnimations instance
    window.cardAnimations.animateDealing(
        this.gameState,
        (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
        () => {
            // Show real cards
            this.playerCards.style.visibility = 'visible';
            this.opponentsRow.style.visibility = 'visible';
            onComplete();
        }
    );
}

// Helper to get card slot position
getCardSlotRect(playerId, cardIdx) {
    if (playerId === this.playerId) {
        // Local player
        const cards = this.playerCards.querySelectorAll('.card');
        return cards[cardIdx]?.getBoundingClientRect();
    } else {
        // Opponent
        const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
        for (const area of opponentAreas) {
            if (area.dataset.playerId === playerId) {
                const cards = area.querySelectorAll('.card');
                return cards[cardIdx]?.getBoundingClientRect();
            }
        }
    }
    return null;
}

Timing Tuning

Perceived Speed Tricks

  1. Overlap card flights - Start next card before previous lands
  2. Ease-out timing - Cards decelerate into position (feels snappier)
  3. Batch by round - 6 deal rounds feels rhythmic
  4. Quick stagger - 80ms between cards feels like rapid dealing

Accessibility

// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    // Skip animation, just show cards
    this.renderGame();
    return;
}

Edge Cases

Animation Interrupted

If player disconnects or game state changes during dealing:

  • Cancel animation
  • Show cards immediately
  • Continue with normal game flow

Varying Player Counts

2-6 players supported:

  • Fewer players = faster deal (fewer cards per round)
  • 2 players: 12 cards total, ~1.5 seconds
  • 6 players: 36 cards total, ~3.5 seconds

Opponent Areas Not Ready

If opponent areas haven't rendered yet:

  • Fall back to animating to center positions
  • Or skip animation for that player

Test Scenarios

  1. 2-player game - Dealing alternates correctly
  2. 6-player game - All players receive cards in order
  3. Quick tap through - Animation can be interrupted
  4. Round 2+ - Dealing starts from correct dealer position
  5. Mobile - Animation runs smoothly at 60fps
  6. Reduced motion - Animation skipped appropriately

Acceptance Criteria

  • Cards animate from deck to player positions
  • Deal order follows clockwise from dealer's left
  • Shuffle sound plays before dealing
  • Card sound plays as each card lands
  • Animation completes in < 4 seconds for 6 players
  • Real cards appear after animation (no flash)
  • Reduced motion preference respected
  • Works on mobile (60fps)
  • Can be interrupted without breaking game

Implementation Order

  1. Add timing values to timing-config.js
  2. Create deal-animation.js with DealAnimation class
  3. Add CSS for deal animation cards
  4. Add data-player-id to opponent areas for targeting
  5. Add getCardSlotRect() helper method
  6. Integrate animation in game_started/round_started handler
  7. Test with various player counts
  8. Add reduced motion support
  9. Tune timing for best feel

Notes for Agent

  • Add animateDealing() as a method on the existing CardAnimations class
  • Use createAnimCard() to create deal cards (already exists, handles 3D structure)
  • Use anime.js for all card movements, not CSS transitions
  • The existing CardManager handles persistent cards - don't modify it
  • Timing values should all be in timing-config.js under dealing key
  • Consider: Show dealer's hands actually dealing? (complex, skip for V3)
  • The shuffle sound already exists - reuse it via playSound('shuffle')
  • Cards should deal face-down (use createAnimCard(rect, true, deckColor))