- 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>
11 KiB
V3-02: Dealing Animation
Overview
In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.
Dependencies: V3_01 (Dealer Rotation - need to know who is dealing) Dependents: None
Goals
- Animate cards being dealt from a central deck position
- Deal one card at a time to each player in clockwise order
- Play shuffle sound before dealing begins
- Play card sound as each card lands
- Maintain quick perceived pace (stagger start times, not end times)
- Show dealing from dealer's position (or center as fallback)
Current State
From app.js, when game_started or round_started message received:
case 'game_started':
case 'round_started':
this.gameState = data.game_state;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame(); // Cards appear instantly
break;
Cards are rendered immediately via renderGame() which populates the card grids.
Design
Animation Sequence
1. Shuffle sound plays
2. Brief pause (300ms) - deck appears to shuffle
3. Deal round 1: One card to each player (clockwise from dealer's left)
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
5. Flip discard pile top card
6. Initial flip phase begins (or game starts if initial_flips=0)
Visual Flow
[Deck]
|
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
[Opponent 1] [Opponent 2] [Opponent 3]
|
▼
[Local Player]
Cards fly from deck position to each player's card slot, face-down.
Timing
// New timing values in timing-config.js
dealing: {
shufflePause: 400, // Pause after shuffle sound
cardFlyTime: 150, // Time for card to fly to destination
cardStagger: 80, // Delay between cards (overlap for speed)
roundPause: 50, // Brief pause between deal rounds
discardFlipDelay: 200, // Pause before flipping discard
}
Total time for 4-player game (24 cards):
- 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds
This feels unhurried but not slow.
Implementation Approach
Option A: Overlay Animation (Recommended)
Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.
Pros:
- Clean separation from game state
- Easy to skip/interrupt
- No complex state management
Cons:
- Brief flash when swapping to real cards (mitigate with timing)
Option B: Animate Real Cards
Start with cards at deck position, animate to final positions.
Pros:
- No element swap
- More "real"
Cons:
- Complex coordination with renderGame()
- State management issues
Recommendation: Option A - overlay animation
Implementation
Add to card-animations.js
Add the dealing animation as a method on the existing CardAnimations class:
// Add to CardAnimations class in card-animations.js
/**
* Run the dealing animation using anime.js timelines
* @param {Object} gameState - The game state with players and their cards
* @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
* @param {Function} onComplete - Callback when animation completes
*/
async animateDealing(gameState, getPlayerRect, onComplete) {
const T = window.TIMING?.dealing || {
shufflePause: 400,
cardFlyTime: 150,
cardStagger: 80,
roundPause: 50,
discardFlipDelay: 200,
};
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect) {
if (onComplete) onComplete();
return;
}
// Get player order starting from dealer's left
const dealerIdx = gameState.dealer_idx || 0;
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
// Create container for animation cards
const container = document.createElement('div');
container.className = 'deal-animation-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);
// Shuffle sound and pause
this.playSound('shuffle');
await this.delay(T.shufflePause);
// Deal 6 rounds of cards using anime.js
const allCards = [];
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
for (const player of playerOrder) {
const targetRect = getPlayerRect(player.id, cardIdx);
if (!targetRect) continue;
// Create card at deck position
const deckColor = this.getDeckColor();
const card = this.createAnimCard(deckRect, true, deckColor);
card.classList.add('deal-anim-card');
container.appendChild(card);
allCards.push({ card, targetRect });
// Animate using anime.js
anime({
targets: card,
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
height: targetRect.height,
duration: T.cardFlyTime,
easing: this.getEasing('move'),
});
this.playSound('card');
await this.delay(T.cardStagger);
}
// Brief pause between rounds
if (cardIdx < 5) {
await this.delay(T.roundPause);
}
}
// Wait for last cards to land
await this.delay(T.cardFlyTime);
// Flip discard pile card
if (discardRect && gameState.discard_top) {
await this.delay(T.discardFlipDelay);
this.playSound('flip');
}
// Clean up
container.remove();
if (onComplete) onComplete();
}
getDealOrder(players, dealerIdx) {
// Rotate so dealing starts to dealer's left
const order = [...players];
const startIdx = (dealerIdx + 1) % order.length;
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
CSS for Deal Animation
/* In style.css - minimal, anime.js handles all animation */
/* Deal animation container */
.deal-animation-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
.deal-anim-card {
/* Uses same structure as createAnimCard() */
}
Integration in app.js
// In handleMessage, game_started/round_started case:
case 'game_started':
case 'round_started':
this.clearNextHoleCountdown();
this.nextRoundBtn.classList.remove('waiting');
this.roundWinnerNames = new Set();
this.gameState = data.game_state;
this.previousState = JSON.parse(JSON.stringify(data.game_state));
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.showGameScreen();
// NEW: Run deal animation using CardAnimations
this.runDealAnimation(() => {
this.renderGame();
});
break;
// New method using CardAnimations
runDealAnimation(onComplete) {
// Hide cards initially
this.playerCards.style.visibility = 'hidden';
this.opponentsRow.style.visibility = 'hidden';
// Use the global cardAnimations instance
window.cardAnimations.animateDealing(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
onComplete();
}
);
}
// Helper to get card slot position
getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) {
// Local player
const cards = this.playerCards.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
} else {
// Opponent
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
for (const area of opponentAreas) {
if (area.dataset.playerId === playerId) {
const cards = area.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
}
}
}
return null;
}
Timing Tuning
Perceived Speed Tricks
- Overlap card flights - Start next card before previous lands
- Ease-out timing - Cards decelerate into position (feels snappier)
- Batch by round - 6 deal rounds feels rhythmic
- Quick stagger - 80ms between cards feels like rapid dealing
Accessibility
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Skip animation, just show cards
this.renderGame();
return;
}
Edge Cases
Animation Interrupted
If player disconnects or game state changes during dealing:
- Cancel animation
- Show cards immediately
- Continue with normal game flow
Varying Player Counts
2-6 players supported:
- Fewer players = faster deal (fewer cards per round)
- 2 players: 12 cards total, ~1.5 seconds
- 6 players: 36 cards total, ~3.5 seconds
Opponent Areas Not Ready
If opponent areas haven't rendered yet:
- Fall back to animating to center positions
- Or skip animation for that player
Test Scenarios
- 2-player game - Dealing alternates correctly
- 6-player game - All players receive cards in order
- Quick tap through - Animation can be interrupted
- Round 2+ - Dealing starts from correct dealer position
- Mobile - Animation runs smoothly at 60fps
- Reduced motion - Animation skipped appropriately
Acceptance Criteria
- Cards animate from deck to player positions
- Deal order follows clockwise from dealer's left
- Shuffle sound plays before dealing
- Card sound plays as each card lands
- Animation completes in < 4 seconds for 6 players
- Real cards appear after animation (no flash)
- Reduced motion preference respected
- Works on mobile (60fps)
- Can be interrupted without breaking game
Implementation Order
- Add timing values to
timing-config.js - Create
deal-animation.jswith DealAnimation class - Add CSS for deal animation cards
- Add
data-player-idto opponent areas for targeting - Add
getCardSlotRect()helper method - Integrate animation in game_started/round_started handler
- Test with various player counts
- Add reduced motion support
- Tune timing for best feel
Notes for Agent
- Add
animateDealing()as a method on the existingCardAnimationsclass - Use
createAnimCard()to create deal cards (already exists, handles 3D structure) - Use anime.js for all card movements, not CSS transitions
- The existing
CardManagerhandles persistent cards - don't modify it - Timing values should all be in
timing-config.jsunderdealingkey - Consider: Show dealer's hands actually dealing? (complex, skip for V3)
- The shuffle sound already exists - reuse it via
playSound('shuffle') - Cards should deal face-down (use
createAnimCard(rect, true, deckColor))