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

13 KiB

V3-07: Animated Score Tallying

Overview

In physical card games, scoring involves counting cards one by one, noting pairs, and calculating the total. Currently, scores just appear in the scoreboard. This feature adds animated score counting that highlights each card's contribution.

Dependencies: V3_03 (Round End Reveal should complete before tallying) Dependents: None


Goals

  1. Animate score counting card-by-card
  2. Highlight each card as its value is added
  3. Show column pairs canceling to zero
  4. Running total builds up visibly
  5. Special effect for negative cards and pairs
  6. Satisfying "final score" reveal

Current State

From showScoreboard() in app.js:

showScoreboard(scores, isFinal, rankings) {
    // Scores appear instantly in table
    // No animation of how score was calculated
}

The server calculates scores and sends them. The client just displays them.


Design

Tally Sequence

1. Round end reveal completes (V3_03)
2. Brief pause (300ms)
3. For each player (starting with knocker):
   a. Highlight player area
   b. Count through each column:
      - Highlight top card, show value
      - Highlight bottom card, show value
      - If pair: show "PAIR! +0" effect
      - If not pair: add values to running total
   c. Show final score with flourish
   d. Move to next player
4. Scoreboard slides in with all scores

Visual Elements

  • Card value overlay - Temporary badge showing card's point value
  • Running total - Animated counter near player area
  • Pair effect - Special animation when column pair cancels
  • Final score - Large number with celebration effect

Timing

// In timing-config.js
tally: {
    initialPause: 300,        // After reveal, before tally
    cardHighlight: 200,       // Duration to show each card value
    columnPause: 150,         // Between columns
    pairCelebration: 400,     // Pair cancel effect
    playerPause: 500,         // Between players
    finalScoreReveal: 600,    // Final score animation
}

Implementation

Card Value Overlay

// Create temporary overlay showing card value
showCardValue(cardElement, value, isNegative) {
    const overlay = document.createElement('div');
    overlay.className = 'card-value-overlay';
    if (isNegative) overlay.classList.add('negative');
    if (value === 0) overlay.classList.add('zero');

    const sign = value > 0 ? '+' : '';
    overlay.textContent = `${sign}${value}`;

    // Position over the card
    const rect = cardElement.getBoundingClientRect();
    overlay.style.left = `${rect.left + rect.width / 2}px`;
    overlay.style.top = `${rect.top + rect.height / 2}px`;

    document.body.appendChild(overlay);

    // Animate in
    overlay.classList.add('visible');

    return overlay;
}

hideCardValue(overlay) {
    overlay.classList.remove('visible');
    setTimeout(() => overlay.remove(), 200);
}

CSS for Overlays

/* Card value overlay */
.card-value-overlay {
    position: fixed;
    transform: translate(-50%, -50%) scale(0.5);
    background: rgba(30, 30, 46, 0.9);
    color: white;
    padding: 8px 14px;
    border-radius: 8px;
    font-size: 1.4em;
    font-weight: bold;
    opacity: 0;
    transition: transform 0.2s ease-out, opacity 0.2s ease-out;
    z-index: 200;
    pointer-events: none;
}

.card-value-overlay.visible {
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
}

.card-value-overlay.negative {
    background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
    color: white;
}

.card-value-overlay.zero {
    background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
}

/* Running total */
.running-total {
    position: absolute;
    bottom: -30px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(0, 0, 0, 0.8);
    color: white;
    padding: 4px 12px;
    border-radius: 15px;
    font-size: 1.2em;
    font-weight: bold;
}

.running-total.updating {
    animation: total-bounce 0.2s ease-out;
}

@keyframes total-bounce {
    0% { transform: translateX(-50%) scale(1); }
    50% { transform: translateX(-50%) scale(1.1); }
    100% { transform: translateX(-50%) scale(1); }
}

/* Pair cancel effect */
.pair-cancel-overlay {
    position: fixed;
    transform: translate(-50%, -50%);
    font-size: 1.2em;
    font-weight: bold;
    color: #f4a460;
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
    animation: pair-cancel 0.6s ease-out forwards;
    z-index: 200;
    pointer-events: none;
}

@keyframes pair-cancel {
    0% {
        transform: translate(-50%, -50%) scale(0.5);
        opacity: 0;
    }
    30% {
        transform: translate(-50%, -50%) scale(1.2);
        opacity: 1;
    }
    100% {
        transform: translate(-50%, -60%) scale(1);
        opacity: 0;
    }
}

/* Card highlight during tally */
.card.tallying {
    box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
    transform: scale(1.05);
    transition: box-shadow 0.1s, transform 0.1s;
}

/* Final score reveal */
.final-score-overlay {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) scale(0);
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
    color: white;
    padding: 20px 40px;
    border-radius: 15px;
    text-align: center;
    z-index: 250;
    animation: final-score-reveal 0.6s ease-out forwards;
}

.final-score-overlay .player-name {
    font-size: 1em;
    opacity: 0.8;
    margin-bottom: 5px;
}

.final-score-overlay .score-value {
    font-size: 3em;
    font-weight: bold;
}

.final-score-overlay .score-value.negative {
    color: #27ae60;
}

@keyframes final-score-reveal {
    0% {
        transform: translate(-50%, -50%) scale(0);
    }
    60% {
        transform: translate(-50%, -50%) scale(1.1);
    }
    100% {
        transform: translate(-50%, -50%) scale(1);
    }
}

Main Tally Logic

async runScoreTally(players, onComplete) {
    const T = window.TIMING?.tally || {};

    // Initial pause after reveal
    await this.delay(T.initialPause || 300);

    // Get card values from game state
    const cardValues = this.gameState?.card_values || this.getDefaultCardValues();

    // Tally each player
    for (const player of players) {
        const area = this.getPlayerArea(player.id);
        if (!area) continue;

        // Highlight player area
        area.classList.add('tallying-player');

        // Create running total display
        const runningTotal = document.createElement('div');
        runningTotal.className = 'running-total';
        runningTotal.textContent = '0';
        area.appendChild(runningTotal);

        let total = 0;
        const cards = area.querySelectorAll('.card');

        // Process each column
        const columns = [[0, 3], [1, 4], [2, 5]];

        for (const [topIdx, bottomIdx] of columns) {
            const topCard = cards[topIdx];
            const bottomCard = cards[bottomIdx];
            const topData = player.cards[topIdx];
            const bottomData = player.cards[bottomIdx];

            // Highlight top card
            topCard.classList.add('tallying');
            const topValue = cardValues[topData.rank] ?? 0;
            const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
            await this.delay(T.cardHighlight || 200);

            // Highlight bottom card
            bottomCard.classList.add('tallying');
            const bottomValue = cardValues[bottomData.rank] ?? 0;
            const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
            await this.delay(T.cardHighlight || 200);

            // Check for pair
            if (topData.rank === bottomData.rank) {
                // Pair! Show cancel effect
                this.hideCardValue(topOverlay);
                this.hideCardValue(bottomOverlay);
                this.showPairCancel(topCard, bottomCard);
                await this.delay(T.pairCelebration || 400);
            } else {
                // Add values to total
                total += topValue + bottomValue;
                this.updateRunningTotal(runningTotal, total);
                this.hideCardValue(topOverlay);
                this.hideCardValue(bottomOverlay);
            }

            // Clear card highlights
            topCard.classList.remove('tallying');
            bottomCard.classList.remove('tallying');

            await this.delay(T.columnPause || 150);
        }

        // Show final score for this player
        await this.showFinalScore(player.name, total);
        await this.delay(T.finalScoreReveal || 600);

        // Clean up
        runningTotal.remove();
        area.classList.remove('tallying-player');

        await this.delay(T.playerPause || 500);
    }

    onComplete();
}

showPairCancel(card1, card2) {
    // Position between the two cards
    const rect1 = card1.getBoundingClientRect();
    const rect2 = card2.getBoundingClientRect();
    const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
    const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;

    const overlay = document.createElement('div');
    overlay.className = 'pair-cancel-overlay';
    overlay.textContent = 'PAIR! +0';
    overlay.style.left = `${centerX}px`;
    overlay.style.top = `${centerY}px`;

    document.body.appendChild(overlay);

    // Pulse both cards
    card1.classList.add('pair-matched');
    card2.classList.add('pair-matched');

    setTimeout(() => {
        overlay.remove();
        card1.classList.remove('pair-matched');
        card2.classList.remove('pair-matched');
    }, 600);

    this.playSound('pair');
}

updateRunningTotal(element, value) {
    element.textContent = value >= 0 ? value : value;
    element.classList.add('updating');
    setTimeout(() => element.classList.remove('updating'), 200);
}

async showFinalScore(playerName, score) {
    const overlay = document.createElement('div');
    overlay.className = 'final-score-overlay';
    overlay.innerHTML = `
        <div class="player-name">${playerName}</div>
        <div class="score-value ${score < 0 ? 'negative' : ''}">${score}</div>
    `;

    document.body.appendChild(overlay);

    this.playSound(score < 0 ? 'success' : 'card');

    await this.delay(800);

    overlay.style.opacity = '0';
    overlay.style.transition = 'opacity 0.3s';
    await this.delay(300);
    overlay.remove();
}

getDefaultCardValues() {
    return {
        'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
        '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
    };
}

Integration with Round End

// In runRoundEndReveal completion callback

async runRoundEndReveal(oldState, newState, onComplete) {
    // ... existing reveal logic ...

    // After all reveals complete
    await this.runScoreTally(newState.players, () => {
        // Now show the scoreboard
        onComplete();
    });
}

Simplified Mode

For faster games, offer a simplified tally that just shows final scores:

if (this.settings.quickTally) {
    // Just flash the final scores, skip card-by-card
    for (const player of players) {
        const score = this.calculateScore(player.cards);
        await this.showFinalScore(player.name, score);
        await this.delay(400);
    }
    onComplete();
    return;
}

Test Scenarios

  1. Normal hand - Values add up correctly
  2. Paired column - Shows "PAIR! +0" effect
  3. All pairs - Total is 0, multiple pair celebrations
  4. Negative cards - Green highlight, reduces total
  5. Multiple players - Tallies sequentially
  6. Various scores - Positive, negative, zero

Acceptance Criteria

  • Cards highlight as they're counted
  • Point values show as temporary overlays
  • Running total updates with each card
  • Paired columns show cancel effect
  • Final score has celebration animation
  • Tally order: knocker first, then clockwise
  • Sound effects enhance the experience
  • Total time under 10 seconds for 4 players
  • Scoreboard appears after tally completes

Implementation Order

  1. Add tally timing to timing-config.js
  2. Create CSS for all overlays and animations
  3. Implement showCardValue() and hideCardValue()
  4. Implement showPairCancel()
  5. Implement updateRunningTotal()
  6. Implement showFinalScore()
  7. Implement main runScoreTally() method
  8. Integrate with round end reveal
  9. Test various scoring scenarios
  10. Add quick tally option

Notes for Agent

  • CSS vs anime.js: Use CSS for UI overlays (value badges, running total). Use anime.js for card highlight effects.
  • Card highlighting can use window.cardAnimations methods or simple anime.js calls
  • The tally should feel satisfying, not tedious
  • Keep individual card highlight times short
  • Pair cancellation is a highlight moment - give it emphasis
  • Consider accessibility: values should be readable
  • The running total helps players follow the math
  • Don't forget to handle house rules affecting card values (use gameState.card_values)