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

8.8 KiB

V3-11: Swap Animation Improvements

Overview

When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.

Dependencies: None Dependents: None


Goals

  1. Cards visibly exchange positions (not teleport)
  2. Old card slides toward discard
  3. New card slides into hand slot
  4. Brief "crossing" moment visible
  5. Smooth, performant animation
  6. Works for both face-up and face-down swaps

Current State

From card-animations.js (CardAnimations class):

// Current swap uses anime.js with pulse effect for face-up swaps
// and flip animation for face-down swaps

animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
    if (isAlreadyFaceUp) {
        // Face-up swap: subtle pulse, no flip needed
        this._animateFaceUpSwap(handCardElement, onComplete);
    } else {
        // Face-down swap: flip reveal then swap
        this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
    }
}

_animateFaceUpSwap(handCardElement, onComplete) {
    anime({
        targets: handCardElement,
        scale: [1, 0.92, 1.08, 1],
        filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
        duration: 400,
        easing: 'easeOutQuad'
    });
}

The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.


Design

Animation Sequence

1. If face-down: Flip hand card to reveal (existing)
2. Lift both cards slightly (z-index, shadow)
3. Hand card arcs toward discard pile
4. Held card arcs toward hand slot
5. Cards cross paths visually (middle of arc)
6. Cards land at destinations
7. Landing pulse effect

Arc Paths

Instead of straight lines, cards follow curved paths:

     Hand card path
    ╭─────────────────╮
    │                 │
  [Hand]          [Discard]
    │                 │
    ╰─────────────────╯
     Held card path

The curves create a visual "exchange" moment.


Implementation

Enhanced Swap Animation (Add to CardAnimations class)

// In card-animations.js - enhance the existing animateSwap method

async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
    const T = window.TIMING?.swap || {
        lift: 80,
        arc: 280,
        settle: 60,
    };

    // Create animation elements that will travel
    const travelingHandCard = this.createTravelingCard(handCardEl);
    const travelingHeldCard = this.createTravelingCard(heldCardEl);

    document.body.appendChild(travelingHandCard);
    document.body.appendChild(travelingHeldCard);

    // Position at start
    this.positionAt(travelingHandCard, handRect);
    this.positionAt(travelingHeldCard, holdingRect || discardRect);

    // Hide originals
    handCardEl.style.visibility = 'hidden';
    heldCardEl.style.visibility = 'hidden';

    this.playSound('card');

    // Use anime.js timeline for coordinated arc movement
    const timeline = anime.timeline({
        easing: this.getEasing('move'),
        complete: () => {
            travelingHandCard.remove();
            travelingHeldCard.remove();
            handCardEl.style.visibility = 'visible';
            heldCardEl.style.visibility = 'visible';
            this.pulseDiscard();
            if (onComplete) onComplete();
        }
    });

    // Calculate arc midpoints
    const midY1 = (handRect.top + discardRect.top) / 2 - 40;  // Arc up
    const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40;  // Arc down

    // Step 1: Lift both cards with shadow increase
    timeline.add({
        targets: [travelingHandCard, travelingHeldCard],
        translateY: -10,
        boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
        scale: 1.02,
        duration: T.lift,
        easing: this.getEasing('lift')
    });

    // Step 2: Hand card arcs to discard
    timeline.add({
        targets: travelingHandCard,
        left: discardRect.left,
        top: [
            { value: midY1, duration: T.arc / 2 },
            { value: discardRect.top, duration: T.arc / 2 }
        ],
        rotate: [0, -5, 0],
        duration: T.arc,
    }, `-=${T.lift / 2}`);

    // Held card arcs to hand (in parallel)
    timeline.add({
        targets: travelingHeldCard,
        left: handRect.left,
        top: [
            { value: midY2, duration: T.arc / 2 },
            { value: handRect.top, duration: T.arc / 2 }
        ],
        rotate: [0, 5, 0],
        duration: T.arc,
    }, `-=${T.arc + T.lift / 2}`);

    // Step 3: Settle
    timeline.add({
        targets: [travelingHandCard, travelingHeldCard],
        translateY: 0,
        boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
        scale: 1,
        duration: T.settle,
    });

    this.activeAnimations.set('physicalSwap', timeline);
}

createTravelingCard(sourceCard) {
    const clone = sourceCard.cloneNode(true);
    clone.className = 'traveling-card';
    clone.style.position = 'fixed';
    clone.style.pointerEvents = 'none';
    clone.style.zIndex = '1000';
    clone.style.borderRadius = '6px';
    return clone;
}

positionAt(element, rect) {
    element.style.left = `${rect.left}px`;
    element.style.top = `${rect.top}px`;
    element.style.width = `${rect.width}px`;
    element.style.height = `${rect.height}px`;
}

CSS for Traveling Cards

Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:

/* Traveling card during swap - base styles only */
.traveling-card {
    position: fixed;
    border-radius: 6px;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
    /* All animation handled by anime.js */
}

Timing Configuration

// In timing-config.js
swap: {
    lift: 80,         // Time to lift cards
    arc: 280,         // Time for arc travel
    settle: 60,       // Time to settle into place
    // Total: ~420ms (similar to current)
}

Note on Animation Approach

All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:

  • Better coordination between multiple elements
  • Consistent with rest of animation system
  • Easier timing control via window.TIMING
  • Proper animation cancellation via activeAnimations tracking

Integration Points

For Local Player Swap

// In animateSwap() method
animateSwap(position) {
    const cardElements = this.playerCards.querySelectorAll('.card');
    const handCardEl = cardElements[position];

    // Get positions
    const handRect = handCardEl.getBoundingClientRect();
    const discardRect = this.discard.getBoundingClientRect();
    const holdingRect = this.getHoldingRect();

    // If face-down, flip first (existing logic)
    // ...

    // Then do physical swap
    this.animatePhysicalSwap(
        handCardEl,
        this.heldCardFloating,
        handRect,
        discardRect,
        holdingRect
    );
}

For Opponent Swap

The opponent swap animation in fireSwapAnimation() can use similar arc logic for the visible card traveling to discard.


Test Scenarios

  1. Swap face-up card - Direct arc exchange
  2. Swap face-down card - Flip first, then arc
  3. Fast repeated swaps - No animation overlap
  4. Mobile - Animation performs at 60fps
  5. Different screen sizes - Arcs scale appropriately

Acceptance Criteria

  • Cards visibly travel to new positions (not teleport)
  • Arc paths create "crossing" visual
  • Lift and settle effects enhance physicality
  • Animation total time ~400ms (not slower than current)
  • Works for face-up and face-down cards
  • Performant on mobile (60fps)
  • Landing effect on discard pile
  • Opponent swaps also improved

Implementation Order

  1. Add swap timing to timing-config.js
  2. Implement createTravelingCard() helper
  3. Implement animateArc() with Web Animations API
  4. Implement animatePhysicalSwap() method
  5. Add CSS for traveling cards
  6. Integrate with local player swap
  7. Integrate with opponent swap animation
  8. Test on various devices
  9. Tune arc height and timing

Notes for Agent

  • Add animatePhysicalSwap() to the existing CardAnimations class
  • Use anime.js timelines for coordinated multi-element animation
  • Arc height should scale with card distance
  • The "crossing" moment is the key visual improvement
  • Keep total animation time similar to current (~400ms)
  • Track animation in activeAnimations for proper cancellation
  • Consider: option for "fast mode" with simpler animations?
  • Make sure sound timing aligns with visual (card leaving hand)
  • Existing animateSwap() can call this new method internally