golfgame/docs/v3/V3_10_COLUMN_PAIR_INDICATOR.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.5 KiB

V3-10: Column Pair Indicator

Overview

When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.

Dependencies: V3_04 (Column Pair Celebration - this builds on that) Dependents: None


Goals

  1. Show which columns are currently paired
  2. Visual connector between paired cards
  3. Score indicator showing "+0" or "locked"
  4. Don't clutter the interface
  5. Help new players understand pairing

Current State

After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.


Design

Visual Options

Option A: Connecting Line Draw a subtle line or bracket connecting paired cards.

Option B: Shared Glow Both cards have a subtle shared glow color.

Option C: Zero Badge Small "0" badge on the column.

Option D: Lock Icon Small lock icon indicating "locked in" pair.

Recommendation: Option A (line) + Option C (badge) - clear and informative.

Visual Treatment

Normal columns:        Paired column:
┌───┐  ┌───┐          ┌───┐ ─┐
│ K │  │ 7 │          │ 5 │  │ [0]
└───┘  └───┘          └───┘  │
                             │
┌───┐  ┌───┐          ┌───┐ ─┘
│ Q │  │ 3 │          │ 5 │
└───┘  └───┘          └───┘

Implementation

Detecting Pairs

getColumnPairs(cards) {
    const pairs = [];
    const columns = [[0, 3], [1, 4], [2, 5]];

    for (let i = 0; i < columns.length; i++) {
        const [top, bottom] = columns[i];
        const topCard = cards[top];
        const bottomCard = cards[bottom];

        if (topCard?.face_up && bottomCard?.face_up &&
            topCard?.rank && topCard.rank === bottomCard?.rank) {
            pairs.push({
                column: i,
                topPosition: top,
                bottomPosition: bottom,
                rank: topCard.rank
            });
        }
    }

    return pairs;
}

Rendering Pair Indicators

renderPairIndicators(playerId, cards) {
    const pairs = this.getColumnPairs(cards);
    const container = this.getPairIndicatorContainer(playerId);

    // Clear existing indicators
    container.innerHTML = '';

    if (pairs.length === 0) return;

    const cardElements = this.getCardElements(playerId);

    for (const pair of pairs) {
        const topCard = cardElements[pair.topPosition];
        const bottomCard = cardElements[pair.bottomPosition];

        if (!topCard || !bottomCard) continue;

        // Create connector line
        const connector = this.createPairConnector(topCard, bottomCard, pair.column);
        container.appendChild(connector);

        // Add paired class to cards
        topCard.classList.add('paired');
        bottomCard.classList.add('paired');
    }
}

createPairConnector(topCard, bottomCard, columnIndex) {
    const connector = document.createElement('div');
    connector.className = 'pair-connector';
    connector.dataset.column = columnIndex;

    // Calculate position
    const topRect = topCard.getBoundingClientRect();
    const bottomRect = bottomCard.getBoundingClientRect();
    const containerRect = topCard.closest('.card-grid').getBoundingClientRect();

    // Position connector to the right of the column
    const x = topRect.right - containerRect.left + 5;
    const y = topRect.top - containerRect.top;
    const height = bottomRect.bottom - topRect.top;

    connector.style.cssText = `
        left: ${x}px;
        top: ${y}px;
        height: ${height}px;
    `;

    // Add zero badge
    const badge = document.createElement('div');
    badge.className = 'pair-badge';
    badge.textContent = '0';
    connector.appendChild(badge);

    return connector;
}

getPairIndicatorContainer(playerId) {
    // Get or create indicator container
    const area = playerId === this.playerId
        ? this.playerCards
        : this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);

    if (!area) return document.createElement('div'); // Fallback

    let container = area.querySelector('.pair-indicators');
    if (!container) {
        container = document.createElement('div');
        container.className = 'pair-indicators';
        area.style.position = 'relative';
        area.appendChild(container);
    }

    return container;
}

CSS

/* Pair indicators container */
.pair-indicators {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    pointer-events: none;
    z-index: 5;
}

/* Connector line */
.pair-connector {
    position: absolute;
    width: 3px;
    background: linear-gradient(180deg,
        rgba(244, 164, 96, 0.6) 0%,
        rgba(244, 164, 96, 0.8) 50%,
        rgba(244, 164, 96, 0.6) 100%
    );
    border-radius: 2px;
}

/* Bracket style alternative */
.pair-connector::before,
.pair-connector::after {
    content: '';
    position: absolute;
    left: 0;
    width: 8px;
    height: 3px;
    background: rgba(244, 164, 96, 0.6);
}

.pair-connector::before {
    top: 0;
    border-radius: 2px 0 0 0;
}

.pair-connector::after {
    bottom: 0;
    border-radius: 0 0 0 2px;
}

/* Zero badge */
.pair-badge {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: #f4a460;
    color: #1a1a2e;
    font-size: 0.7em;
    font-weight: bold;
    padding: 2px 6px;
    border-radius: 10px;
    white-space: nowrap;
}

/* Paired card subtle highlight */
.card.paired {
    box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
}

/* Opponent paired cards - smaller/subtler */
.opponent-area .pair-connector {
    width: 2px;
}

.opponent-area .pair-badge {
    font-size: 0.6em;
    padding: 1px 4px;
}

.opponent-area .card.paired {
    box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
}

Integration with renderGame

// In renderGame(), after rendering cards
renderGame() {
    // ... existing rendering ...

    // Update pair indicators for all players
    for (const player of this.gameState.players) {
        this.renderPairIndicators(player.id, player.cards);
    }
}

Handling Window Resize

Pair connectors are positioned absolutely, so they need updating on resize:

constructor() {
    // ... existing constructor ...

    // Debounced resize handler for pair indicators
    window.addEventListener('resize', this.debounce(() => {
        if (this.gameState) {
            for (const player of this.gameState.players) {
                this.renderPairIndicators(player.id, player.cards);
            }
        }
    }, 100));
}

debounce(fn, delay) {
    let timeout;
    return (...args) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn.apply(this, args), delay);
    };
}

Alternative: CSS-Only Approach

Simpler approach using only CSS classes:

// In renderGame, just add classes
for (const player of this.gameState.players) {
    const pairs = this.getColumnPairs(player.cards);
    const cards = this.getCardElements(player.id);

    // Clear previous
    cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));

    for (const pair of pairs) {
        cards[pair.topPosition]?.classList.add('paired', 'pair-top');
        cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
    }
}
/* CSS-only pair indication */
.card.pair-top {
    border-bottom: 3px solid #f4a460;
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
}

.card.pair-bottom {
    border-top: 3px solid #f4a460;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

.card.paired::after {
    content: '';
    position: absolute;
    right: -10px;
    top: 0;
    bottom: 0;
    width: 3px;
    background: rgba(244, 164, 96, 0.5);
}

.card.pair-bottom::after {
    top: -100%; /* Extend up to connect */
}

Recommendation: Start with CSS-only approach. Add connector elements if more visual clarity needed.


Test Scenarios

  1. Single pair - One column shows indicator
  2. Multiple pairs - Multiple indicators (rare but possible)
  3. No pairs - No indicators
  4. Pair broken - Indicator disappears
  5. Pair formed - Indicator appears (after celebration)
  6. Face-down card in column - No indicator
  7. Opponent pairs - Smaller indicators visible

Acceptance Criteria

  • Paired columns show visual connector
  • "0" badge indicates the score contribution
  • Indicators update when cards change
  • Works for local player and opponents
  • Smaller/subtler for opponents
  • Handles window resize
  • Doesn't clutter interface
  • Helps new players understand pairing

Implementation Order

  1. Implement getColumnPairs() method
  2. Choose approach: CSS-only or connector elements
  3. If connector: implement createPairConnector()
  4. Add CSS for indicators
  5. Integrate into renderGame()
  6. Add resize handling
  7. Test various pair scenarios
  8. Adjust styling for opponents

Notes for Agent

  • CSS vs anime.js: CSS is appropriate for static indicators (not animated elements)
  • Keep indicators subtle - informative not distracting
  • Opponent indicators should be smaller/lighter
  • CSS-only approach is simpler to maintain
  • The badge helps players learning the scoring system
  • Consider: toggle option to hide indicators? (For experienced players)
  • Make sure indicators don't overlap cards on mobile