- 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>
407 lines
11 KiB
Markdown
407 lines
11 KiB
Markdown
# 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
|
||
|
||
1. Animate cards being dealt from a central deck position
|
||
2. Deal one card at a time to each player in clockwise order
|
||
3. Play shuffle sound before dealing begins
|
||
4. Play card sound as each card lands
|
||
5. Maintain quick perceived pace (stagger start times, not end times)
|
||
6. Show dealing from dealer's position (or center as fallback)
|
||
|
||
---
|
||
|
||
## Current State
|
||
|
||
From `app.js`, when `game_started` or `round_started` message received:
|
||
|
||
```javascript
|
||
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
|
||
|
||
```javascript
|
||
// 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:
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
```css
|
||
/* 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
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
1. **Overlap card flights** - Start next card before previous lands
|
||
2. **Ease-out timing** - Cards decelerate into position (feels snappier)
|
||
3. **Batch by round** - 6 deal rounds feels rhythmic
|
||
4. **Quick stagger** - 80ms between cards feels like rapid dealing
|
||
|
||
### Accessibility
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
1. **2-player game** - Dealing alternates correctly
|
||
2. **6-player game** - All players receive cards in order
|
||
3. **Quick tap through** - Animation can be interrupted
|
||
4. **Round 2+** - Dealing starts from correct dealer position
|
||
5. **Mobile** - Animation runs smoothly at 60fps
|
||
6. **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
|
||
|
||
1. Add timing values to `timing-config.js`
|
||
2. Create `deal-animation.js` with DealAnimation class
|
||
3. Add CSS for deal animation cards
|
||
4. Add `data-player-id` to opponent areas for targeting
|
||
5. Add `getCardSlotRect()` helper method
|
||
6. Integrate animation in game_started/round_started handler
|
||
7. Test with various player counts
|
||
8. Add reduced motion support
|
||
9. Tune timing for best feel
|
||
|
||
---
|
||
|
||
## Notes for Agent
|
||
|
||
- Add `animateDealing()` as a method on the existing `CardAnimations` class
|
||
- Use `createAnimCard()` to create deal cards (already exists, handles 3D structure)
|
||
- Use anime.js for all card movements, not CSS transitions
|
||
- The existing `CardManager` handles persistent cards - don't modify it
|
||
- Timing values should all be in `timing-config.js` under `dealing` key
|
||
- 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)`)
|