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

9.7 KiB

V3-04: Column Pair Celebration

Overview

Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.

Dependencies: None Dependents: V3_10 (Column Pair Indicator builds on this)


Goals

  1. Detect when a swap creates a new column pair
  2. Play satisfying visual celebration on both cards
  3. Play a distinct "pair matched" sound
  4. Brief but noticeable - shouldn't slow gameplay
  5. Works for both local player and opponent swaps

Current State

Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.

From the rules (RULES.md):

Column 0: positions (0, 3)
Column 1: positions (1, 4)
Column 2: positions (2, 5)

A pair is formed when both cards in a column are face-up and have the same rank.


Design

Detection

After any swap or flip, check if a new pair was formed:

function detectNewPair(oldCards, newCards) {
    const columns = [[0, 3], [1, 4], [2, 5]];

    for (const [top, bottom] of columns) {
        const wasPaired = isPaired(oldCards, top, bottom);
        const nowPaired = isPaired(newCards, top, bottom);

        if (!wasPaired && nowPaired) {
            return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
        }
    }
    return null;
}

function isPaired(cards, pos1, pos2) {
    const card1 = cards[pos1];
    const card2 = cards[pos2];
    return card1?.face_up && card2?.face_up &&
           card1?.rank && card2?.rank &&
           card1.rank === card2.rank;
}

Celebration Animation

When a pair forms:

1. Both cards pulse/glow simultaneously
2. Brief sparkle effect (optional)
3. "Pair!" sound plays
4. Animation lasts ~400ms
5. Cards return to normal

Visual Effect Options

Option A: Anime.js Glow Pulse (Recommended - matches existing animation system)

// Add to CardAnimations class
celebratePair(cardElement1, cardElement2) {
    this.playSound('pair');

    const duration = window.TIMING?.celebration?.pairDuration || 400;

    [cardElement1, cardElement2].forEach(el => {
        anime({
            targets: el,
            boxShadow: [
                '0 0 0 0 rgba(255, 215, 0, 0)',
                '0 0 15px 8px rgba(255, 215, 0, 0.5)',
                '0 0 0 0 rgba(255, 215, 0, 0)'
            ],
            scale: [1, 1.05, 1],
            duration: duration,
            easing: 'easeOutQuad'
        });
    });
}

Option B: Scale Bounce

anime({
    targets: [cardElement1, cardElement2],
    scale: [1, 1.1, 1],
    duration: 400,
    easing: 'easeOutQuad'
});

Option C: Connecting Line Draw a brief line connecting the paired cards (more complex).

Recommendation: Option A - anime.js glow pulse matches the existing animation system.


Implementation

Timing Configuration

// In timing-config.js
celebration: {
    pairDuration: 400,    // Celebration animation length
    pairDelay: 50,        // Slight delay before celebration (let swap settle)
}

Sound

Add a new sound type for pairs:

// In playSound() method
} else if (type === 'pair') {
    // Two-tone "ding-ding" for pair match
    const osc1 = ctx.createOscillator();
    const osc2 = ctx.createOscillator();
    const gain = ctx.createGain();

    osc1.connect(gain);
    osc2.connect(gain);
    gain.connect(ctx.destination);

    osc1.frequency.setValueAtTime(880, ctx.currentTime);  // A5
    osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6

    gain.gain.setValueAtTime(0.1, ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);

    osc1.start(ctx.currentTime);
    osc2.start(ctx.currentTime);
    osc1.stop(ctx.currentTime + 0.3);
    osc2.stop(ctx.currentTime + 0.3);
}

Detection Integration

In the state differ or after swap animations:

// In triggerAnimationsForStateChange() or after swap completes

checkForNewPairs(oldState, newState, playerId) {
    const oldPlayer = oldState?.players?.find(p => p.id === playerId);
    const newPlayer = newState?.players?.find(p => p.id === playerId);

    if (!oldPlayer || !newPlayer) return;

    const columns = [[0, 3], [1, 4], [2, 5]];

    for (const [top, bottom] of columns) {
        const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
        const nowPaired = this.isPaired(newPlayer.cards, top, bottom);

        if (!wasPaired && nowPaired) {
            // New pair formed!
            setTimeout(() => {
                this.celebratePair(playerId, top, bottom);
            }, window.TIMING?.celebration?.pairDelay || 50);
        }
    }
}

isPaired(cards, pos1, pos2) {
    const c1 = cards[pos1];
    const c2 = cards[pos2];
    return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
}

celebratePair(playerId, pos1, pos2) {
    const cards = this.getCardElements(playerId, pos1, pos2);
    if (cards.length === 0) return;

    // Use CardAnimations to animate (or add method to CardAnimations)
    window.cardAnimations.celebratePair(cards[0], cards[1]);
}

// Add to CardAnimations class in card-animations.js:
celebratePair(cardElement1, cardElement2) {
    this.playSound('pair');

    const duration = window.TIMING?.celebration?.pairDuration || 400;

    [cardElement1, cardElement2].forEach(el => {
        if (!el) return;

        // Temporarily raise z-index so glow shows above adjacent cards
        el.style.zIndex = '10';

        anime({
            targets: el,
            boxShadow: [
                '0 0 0 0 rgba(255, 215, 0, 0)',
                '0 0 15px 8px rgba(255, 215, 0, 0.5)',
                '0 0 0 0 rgba(255, 215, 0, 0)'
            ],
            scale: [1, 1.05, 1],
            duration: duration,
            easing: 'easeOutQuad',
            complete: () => {
                el.style.zIndex = '';
            }
        });
    });
}

getCardElements(playerId, ...positions) {
    const elements = [];

    if (playerId === this.playerId) {
        const cards = this.playerCards.querySelectorAll('.card');
        for (const pos of positions) {
            if (cards[pos]) elements.push(cards[pos]);
        }
    } else {
        const area = this.opponentsRow.querySelector(
            `.opponent-area[data-player-id="${playerId}"]`
        );
        if (area) {
            const cards = area.querySelectorAll('.card');
            for (const pos of positions) {
                if (cards[pos]) elements.push(cards[pos]);
            }
        }
    }

    return elements;
}

CSS

No CSS keyframes needed - all animation is handled by anime.js in CardAnimations.celebratePair().

The animation temporarily sets z-index: 10 on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:

// Optional: Different color for opponent pairs
celebratePair(cardElement1, cardElement2, isOpponent = false) {
    const color = isOpponent
        ? 'rgba(100, 200, 255, 0.4)'  // Blue for opponents
        : 'rgba(255, 215, 0, 0.5)';    // Gold for local player

    // ... anime.js animation with color ...
}

Edge Cases

Pair Broken Then Reformed

If a swap breaks one pair and creates another:

  • Only celebrate the new pair
  • Don't mourn the broken pair (no negative feedback)

Multiple Pairs in One Move

Theoretically possible (swap creates pairs in adjacent columns):

  • Celebrate all new pairs simultaneously
  • Same sound, same animation on all involved cards

Pair at Round Start (Initial Flip)

If initial flip creates a pair:

  • Yes, celebrate it! Early luck deserves recognition

Negative Card Pairs (2s, Jokers)

Pairing 2s or Jokers is strategically bad (wastes -2 value), but:

  • Still celebrate the pair (it's mechanically correct)
  • Player will learn the strategy over time
  • Consider: different sound/color for "bad" pairs? (Too complex for V3)

Test Scenarios

  1. Local player creates pair - Both cards glow, sound plays
  2. Opponent creates pair - Their cards glow, sound plays
  3. Initial flip creates pair - Celebration after flip animation
  4. Swap breaks one pair, creates another - Only new pair celebrates
  5. No pair formed - No celebration
  6. Face-down card in column - No false celebration

Acceptance Criteria

  • Swap that creates a pair triggers celebration
  • Flip that creates a pair triggers celebration
  • Both paired cards animate simultaneously
  • Distinct "pair" sound plays
  • Animation is brief (~400ms)
  • Works for local player and opponents
  • No celebration when pair isn't formed
  • No celebration for already-existing pairs
  • Animation doesn't block gameplay

Implementation Order

  1. Add pair sound to playSound() method
  2. Add celebration timing to timing-config.js
  3. Implement isPaired() helper method
  4. Implement checkForNewPairs() method
  5. Implement celebratePair() method
  6. Implement getCardElements() helper
  7. Add CSS animation for pair celebration
  8. Integrate into state change detection
  9. Test all pair formation scenarios
  10. Tune sound and timing for satisfaction

Notes for Agent

  • Add celebratePair() method to the existing CardAnimations class
  • Use anime.js for all animation - no CSS keyframes
  • Keep the celebration brief - shouldn't slow down fast players
  • The glow color (gold) suggests "success" - matches golf scoring concept
  • Consider accessibility: animation should be visible but not overwhelming
  • The existing swap animation completes before pair check runs
  • Don't celebrate pairs that already existed before the action
  • Opponent celebration can use slightly different color (optional parameter)