golfgame/docs/v3/V3_15_DISCARD_PILE_HISTORY.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.9 KiB
Raw Permalink Blame History

V3-15: Discard Pile History

Overview

In physical card games, you can see the top few cards of the discard pile fanned out slightly. This provides memory aid and context for recent play. Currently our discard pile shows only the top card.

Dependencies: None Dependents: None


Goals

  1. Show 2-3 recent discards visually fanned
  2. Help players track what's been discarded recently
  3. Subtle visual depth without cluttering
  4. Optional: expandable full discard view
  5. Authentic card game feel

Current State

From app.js and CSS:

// Only shows the top card
updateDiscard(cardData) {
    this.discard.innerHTML = this.createCardHTML(cardData);
}

The discard pile is a single card element with no history visualization.


Design

Visual Treatment

Current:           With history:
┌─────┐            ┌─────┐
│  7  │            │  7  │  ← Top card (clickable)
│  ♥  │           ╱└─────┘
└─────┘            └─────┘  ← Previous (faded, offset)
                    └─────┘  ← Older (more faded)

Fan Layout

  • Top card: Full visibility, normal position
  • Previous card: Offset 3-4px left and up, 50% opacity
  • Older card: Offset 6-8px left and up, 25% opacity
  • Maximum 3 visible cards (performance + clarity)

Implementation

Track Discard History

// In app.js constructor
this.discardHistory = [];
this.maxVisibleHistory = 3;

// Update when discard changes
updateDiscardHistory(newCard) {
    if (!newCard) {
        this.discardHistory = [];
        return;
    }

    // Add new card to front
    this.discardHistory.unshift(newCard);

    // Keep only recent cards
    if (this.discardHistory.length > this.maxVisibleHistory) {
        this.discardHistory = this.discardHistory.slice(0, this.maxVisibleHistory);
    }
}

// Called from state differ or handleMessage
onDiscardChange(newCard, oldCard) {
    // Only add if it's a new card (not initial state)
    if (oldCard && newCard && oldCard.rank !== newCard.rank) {
        this.updateDiscardHistory(newCard);
    } else if (newCard && !oldCard) {
        this.updateDiscardHistory(newCard);
    }

    this.renderDiscardPile();
}

Render Fanned Pile

renderDiscardPile() {
    const container = this.discard;
    container.innerHTML = '';

    if (this.discardHistory.length === 0) {
        container.innerHTML = '<div class="card empty">Empty</div>';
        return;
    }

    // Render from oldest to newest (back to front)
    const cards = [...this.discardHistory].reverse();

    cards.forEach((cardData, index) => {
        const reverseIndex = cards.length - 1 - index;
        const card = this.createDiscardCard(cardData, reverseIndex);
        container.appendChild(card);
    });
}

createDiscardCard(cardData, depthIndex) {
    const card = document.createElement('div');
    card.className = 'card discard-card';
    card.dataset.depth = depthIndex;

    // Only top card is interactive
    if (depthIndex === 0) {
        card.classList.add('top-card');
        card.addEventListener('click', () => this.handleDiscardClick());
    }

    // Set card content
    card.innerHTML = this.createCardContentHTML(cardData);

    // Apply offset based on depth
    const offset = depthIndex * 4;
    card.style.setProperty('--depth-offset', `${offset}px`);

    return card;
}

CSS Styling

/* Discard pile container */
#discard {
    position: relative;
    width: var(--card-width);
    height: var(--card-height);
}

/* Stacked discard cards */
.discard-card {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    transition: transform 0.2s, opacity 0.2s;
}

/* Depth-based styling */
.discard-card[data-depth="0"] {
    z-index: 3;
    opacity: 1;
    transform: translate(0, 0);
}

.discard-card[data-depth="1"] {
    z-index: 2;
    opacity: 0.5;
    transform: translate(-4px, -4px);
    pointer-events: none;
}

.discard-card[data-depth="2"] {
    z-index: 1;
    opacity: 0.25;
    transform: translate(-8px, -8px);
    pointer-events: none;
}

/* Using CSS variable for dynamic offset */
.discard-card:not(.top-card) {
    transform: translate(
        calc(var(--depth-offset, 0px) * -1),
        calc(var(--depth-offset, 0px) * -1)
    );
}

/* Hover to expand history slightly */
#discard:hover .discard-card[data-depth="1"] {
    opacity: 0.7;
    transform: translate(-8px, -8px);
}

#discard:hover .discard-card[data-depth="2"] {
    opacity: 0.4;
    transform: translate(-16px, -16px);
}

/* Animation when new card is discarded */
@keyframes discard-land {
    0% {
        transform: translate(0, -20px) scale(1.05);
        opacity: 0;
    }
    100% {
        transform: translate(0, 0) scale(1);
        opacity: 1;
    }
}

.discard-card.top-card.just-landed {
    animation: discard-land 0.2s ease-out;
}

/* Shift animation for cards moving back */
@keyframes shift-back {
    0% { transform: translate(0, 0); }
    100% { transform: translate(var(--depth-offset) * -1, var(--depth-offset) * -1); }
}

Integration with State Changes

// In state-differ.js or wherever discard changes are detected
detectDiscardChange(oldState, newState) {
    const oldDiscard = oldState?.discard_pile?.[oldState.discard_pile.length - 1];
    const newDiscard = newState?.discard_pile?.[newState.discard_pile.length - 1];

    if (this.cardsDifferent(oldDiscard, newDiscard)) {
        return {
            type: 'discard_change',
            oldCard: oldDiscard,
            newCard: newDiscard
        };
    }
    return null;
}

// Handle the change
handleDiscardChange(change) {
    this.onDiscardChange(change.newCard, change.oldCard);
}

Round/Game Reset

// Clear history at start of new round
onNewRound() {
    this.discardHistory = [];
    this.renderDiscardPile();
}

// Or when deck is reshuffled (if that's a game mechanic)
onDeckReshuffle() {
    this.discardHistory = [];
}

Optional: Expandable Full History

For players who want to see all discards:

// Toggle full discard view
showDiscardHistory() {
    const modal = document.getElementById('discard-history-modal');
    modal.innerHTML = this.buildFullDiscardView();
    modal.classList.add('visible');
}

buildFullDiscardView() {
    // Show all cards in discard pile from game state
    const discards = this.gameState.discard_pile || [];
    return discards.map(card =>
        `<div class="card mini">${this.createCardContentHTML(card)}</div>`
    ).join('');
}
#discard-history-modal {
    position: fixed;
    bottom: 80px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(26, 26, 46, 0.95);
    padding: 12px;
    border-radius: 12px;
    display: none;
    max-width: 90vw;
    overflow-x: auto;
}

#discard-history-modal.visible {
    display: flex;
    gap: 8px;
}

#discard-history-modal .card.mini {
    width: 40px;
    height: 56px;
    font-size: 0.7em;
}

Mobile Considerations

On smaller screens, reduce the fan offset:

@media (max-width: 600px) {
    .discard-card[data-depth="1"] {
        transform: translate(-2px, -2px);
    }
    .discard-card[data-depth="2"] {
        transform: translate(-4px, -4px);
    }

    /* Skip hover expansion on touch */
    #discard:hover .discard-card {
        transform: translate(
            calc(var(--depth-offset, 0px) * -0.5),
            calc(var(--depth-offset, 0px) * -0.5)
        );
    }
}

Test Scenarios

  1. First discard - Single card shows
  2. Second discard - Two cards fanned
  3. Third+ discards - Three cards max, oldest drops off
  4. New round - History clears
  5. Draw from discard - Top card removed, others shift forward
  6. Hover interaction - Cards fan out slightly more
  7. Mobile view - Smaller offset, still visible

Acceptance Criteria

  • Recent 2-3 discards visible in fanned pile
  • Older cards progressively more faded
  • Only top card is interactive
  • History updates smoothly when cards change
  • History clears on new round
  • Hover expands fan slightly (desktop)
  • Works on mobile with smaller offsets
  • Optional: expandable full history view

Implementation Order

  1. Add discardHistory array tracking
  2. Implement renderDiscardPile() method
  3. Add CSS for fanned stack
  4. Integrate with state change detection
  5. Add round reset handling
  6. Add hover expansion effect
  7. Test on various screen sizes
  8. Optional: Add full history modal

Notes for Agent

  • CSS vs anime.js: CSS is appropriate for static fan layout. If adding "landing" animation for new discards, use anime.js.
  • Keep visible history small (3 cards max) for clarity
  • The fan offset should be subtle, not dramatic
  • History helps players remember what was recently played
  • Consider: Should drawing from discard affect history display?
  • Mobile: smaller offset but still visible
  • Don't overcomplicate - this is a nice-to-have feature