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>
This commit is contained in:
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# 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)`)
|
||||
Reference in New Issue
Block a user