# 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: ```javascript // 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 ```javascript // 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 ```javascript renderDiscardPile() { const container = this.discard; container.innerHTML = ''; if (this.discardHistory.length === 0) { container.innerHTML = '
Empty
'; 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 ```css /* 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 ```javascript // 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 ```javascript // 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: ```javascript // 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 => `
${this.createCardContentHTML(card)}
` ).join(''); } ``` ```css #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: ```css @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