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:
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# V3-04: Column Pair Celebration
|
||||
|
||||
## Overview
|
||||
|
||||
Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** V3_10 (Column Pair Indicator builds on this)
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Detect when a swap creates a new column pair
|
||||
2. Play satisfying visual celebration on both cards
|
||||
3. Play a distinct "pair matched" sound
|
||||
4. Brief but noticeable - shouldn't slow gameplay
|
||||
5. Works for both local player and opponent swaps
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.
|
||||
|
||||
From the rules (RULES.md):
|
||||
```
|
||||
Column 0: positions (0, 3)
|
||||
Column 1: positions (1, 4)
|
||||
Column 2: positions (2, 5)
|
||||
```
|
||||
|
||||
A pair is formed when both cards in a column are face-up and have the same rank.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Detection
|
||||
|
||||
After any swap or flip, check if a new pair was formed:
|
||||
|
||||
```javascript
|
||||
function detectNewPair(oldCards, newCards) {
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (const [top, bottom] of columns) {
|
||||
const wasPaired = isPaired(oldCards, top, bottom);
|
||||
const nowPaired = isPaired(newCards, top, bottom);
|
||||
|
||||
if (!wasPaired && nowPaired) {
|
||||
return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPaired(cards, pos1, pos2) {
|
||||
const card1 = cards[pos1];
|
||||
const card2 = cards[pos2];
|
||||
return card1?.face_up && card2?.face_up &&
|
||||
card1?.rank && card2?.rank &&
|
||||
card1.rank === card2.rank;
|
||||
}
|
||||
```
|
||||
|
||||
### Celebration Animation
|
||||
|
||||
When a pair forms:
|
||||
|
||||
```
|
||||
1. Both cards pulse/glow simultaneously
|
||||
2. Brief sparkle effect (optional)
|
||||
3. "Pair!" sound plays
|
||||
4. Animation lasts ~400ms
|
||||
5. Cards return to normal
|
||||
```
|
||||
|
||||
### Visual Effect Options
|
||||
|
||||
**Option A: Anime.js Glow Pulse** (Recommended - matches existing animation system)
|
||||
```javascript
|
||||
// Add to CardAnimations class
|
||||
celebratePair(cardElement1, cardElement2) {
|
||||
this.playSound('pair');
|
||||
|
||||
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||
|
||||
[cardElement1, cardElement2].forEach(el => {
|
||||
anime({
|
||||
targets: el,
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||
],
|
||||
scale: [1, 1.05, 1],
|
||||
duration: duration,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Scale Bounce**
|
||||
```javascript
|
||||
anime({
|
||||
targets: [cardElement1, cardElement2],
|
||||
scale: [1, 1.1, 1],
|
||||
duration: 400,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
```
|
||||
|
||||
**Option C: Connecting Line**
|
||||
Draw a brief line connecting the paired cards (more complex).
|
||||
|
||||
**Recommendation:** Option A - anime.js glow pulse matches the existing animation system.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Timing Configuration
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
celebration: {
|
||||
pairDuration: 400, // Celebration animation length
|
||||
pairDelay: 50, // Slight delay before celebration (let swap settle)
|
||||
}
|
||||
```
|
||||
|
||||
### Sound
|
||||
|
||||
Add a new sound type for pairs:
|
||||
|
||||
```javascript
|
||||
// In playSound() method
|
||||
} else if (type === 'pair') {
|
||||
// Two-tone "ding-ding" for pair match
|
||||
const osc1 = ctx.createOscillator();
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
|
||||
|
||||
gain.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
|
||||
osc1.start(ctx.currentTime);
|
||||
osc2.start(ctx.currentTime);
|
||||
osc1.stop(ctx.currentTime + 0.3);
|
||||
osc2.stop(ctx.currentTime + 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
### Detection Integration
|
||||
|
||||
In the state differ or after swap animations:
|
||||
|
||||
```javascript
|
||||
// In triggerAnimationsForStateChange() or after swap completes
|
||||
|
||||
checkForNewPairs(oldState, newState, playerId) {
|
||||
const oldPlayer = oldState?.players?.find(p => p.id === playerId);
|
||||
const newPlayer = newState?.players?.find(p => p.id === playerId);
|
||||
|
||||
if (!oldPlayer || !newPlayer) return;
|
||||
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (const [top, bottom] of columns) {
|
||||
const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
|
||||
const nowPaired = this.isPaired(newPlayer.cards, top, bottom);
|
||||
|
||||
if (!wasPaired && nowPaired) {
|
||||
// New pair formed!
|
||||
setTimeout(() => {
|
||||
this.celebratePair(playerId, top, bottom);
|
||||
}, window.TIMING?.celebration?.pairDelay || 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPaired(cards, pos1, pos2) {
|
||||
const c1 = cards[pos1];
|
||||
const c2 = cards[pos2];
|
||||
return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
|
||||
}
|
||||
|
||||
celebratePair(playerId, pos1, pos2) {
|
||||
const cards = this.getCardElements(playerId, pos1, pos2);
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// Use CardAnimations to animate (or add method to CardAnimations)
|
||||
window.cardAnimations.celebratePair(cards[0], cards[1]);
|
||||
}
|
||||
|
||||
// Add to CardAnimations class in card-animations.js:
|
||||
celebratePair(cardElement1, cardElement2) {
|
||||
this.playSound('pair');
|
||||
|
||||
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||
|
||||
[cardElement1, cardElement2].forEach(el => {
|
||||
if (!el) return;
|
||||
|
||||
// Temporarily raise z-index so glow shows above adjacent cards
|
||||
el.style.zIndex = '10';
|
||||
|
||||
anime({
|
||||
targets: el,
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||
],
|
||||
scale: [1, 1.05, 1],
|
||||
duration: duration,
|
||||
easing: 'easeOutQuad',
|
||||
complete: () => {
|
||||
el.style.zIndex = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCardElements(playerId, ...positions) {
|
||||
const elements = [];
|
||||
|
||||
if (playerId === this.playerId) {
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
for (const pos of positions) {
|
||||
if (cards[pos]) elements.push(cards[pos]);
|
||||
}
|
||||
} else {
|
||||
const area = this.opponentsRow.querySelector(
|
||||
`.opponent-area[data-player-id="${playerId}"]`
|
||||
);
|
||||
if (area) {
|
||||
const cards = area.querySelectorAll('.card');
|
||||
for (const pos of positions) {
|
||||
if (cards[pos]) elements.push(cards[pos]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
No CSS keyframes needed - all animation is handled by anime.js in `CardAnimations.celebratePair()`.
|
||||
|
||||
The animation temporarily sets `z-index: 10` on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:
|
||||
|
||||
```javascript
|
||||
// Optional: Different color for opponent pairs
|
||||
celebratePair(cardElement1, cardElement2, isOpponent = false) {
|
||||
const color = isOpponent
|
||||
? 'rgba(100, 200, 255, 0.4)' // Blue for opponents
|
||||
: 'rgba(255, 215, 0, 0.5)'; // Gold for local player
|
||||
|
||||
// ... anime.js animation with color ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Pair Broken Then Reformed
|
||||
|
||||
If a swap breaks one pair and creates another:
|
||||
- Only celebrate the new pair
|
||||
- Don't mourn the broken pair (no negative feedback)
|
||||
|
||||
### Multiple Pairs in One Move
|
||||
|
||||
Theoretically possible (swap creates pairs in adjacent columns):
|
||||
- Celebrate all new pairs simultaneously
|
||||
- Same sound, same animation on all involved cards
|
||||
|
||||
### Pair at Round Start (Initial Flip)
|
||||
|
||||
If initial flip creates a pair:
|
||||
- Yes, celebrate it! Early luck deserves recognition
|
||||
|
||||
### Negative Card Pairs (2s, Jokers)
|
||||
|
||||
Pairing 2s or Jokers is strategically bad (wastes -2 value), but:
|
||||
- Still celebrate the pair (it's mechanically correct)
|
||||
- Player will learn the strategy over time
|
||||
- Consider: different sound/color for "bad" pairs? (Too complex for V3)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Local player creates pair** - Both cards glow, sound plays
|
||||
2. **Opponent creates pair** - Their cards glow, sound plays
|
||||
3. **Initial flip creates pair** - Celebration after flip animation
|
||||
4. **Swap breaks one pair, creates another** - Only new pair celebrates
|
||||
5. **No pair formed** - No celebration
|
||||
6. **Face-down card in column** - No false celebration
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Swap that creates a pair triggers celebration
|
||||
- [ ] Flip that creates a pair triggers celebration
|
||||
- [ ] Both paired cards animate simultaneously
|
||||
- [ ] Distinct "pair" sound plays
|
||||
- [ ] Animation is brief (~400ms)
|
||||
- [ ] Works for local player and opponents
|
||||
- [ ] No celebration when pair isn't formed
|
||||
- [ ] No celebration for already-existing pairs
|
||||
- [ ] Animation doesn't block gameplay
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `pair` sound to `playSound()` method
|
||||
2. Add celebration timing to `timing-config.js`
|
||||
3. Implement `isPaired()` helper method
|
||||
4. Implement `checkForNewPairs()` method
|
||||
5. Implement `celebratePair()` method
|
||||
6. Implement `getCardElements()` helper
|
||||
7. Add CSS animation for pair celebration
|
||||
8. Integrate into state change detection
|
||||
9. Test all pair formation scenarios
|
||||
10. Tune sound and timing for satisfaction
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Add `celebratePair()` method to the existing `CardAnimations` class
|
||||
- Use anime.js for all animation - no CSS keyframes
|
||||
- Keep the celebration brief - shouldn't slow down fast players
|
||||
- The glow color (gold) suggests "success" - matches golf scoring concept
|
||||
- Consider accessibility: animation should be visible but not overwhelming
|
||||
- The existing swap animation completes before pair check runs
|
||||
- Don't celebrate pairs that already existed before the action
|
||||
- Opponent celebration can use slightly different color (optional parameter)
|
||||
Reference in New Issue
Block a user