- 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>
9.7 KiB
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
- Detect when a swap creates a new column pair
- Play satisfying visual celebration on both cards
- Play a distinct "pair matched" sound
- Brief but noticeable - shouldn't slow gameplay
- 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:
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)
// 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
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
// 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:
// 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:
// 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:
// 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
- Local player creates pair - Both cards glow, sound plays
- Opponent creates pair - Their cards glow, sound plays
- Initial flip creates pair - Celebration after flip animation
- Swap breaks one pair, creates another - Only new pair celebrates
- No pair formed - No celebration
- 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
- Add
pairsound toplaySound()method - Add celebration timing to
timing-config.js - Implement
isPaired()helper method - Implement
checkForNewPairs()method - Implement
celebratePair()method - Implement
getCardElements()helper - Add CSS animation for pair celebration
- Integrate into state change detection
- Test all pair formation scenarios
- Tune sound and timing for satisfaction
Notes for Agent
- Add
celebratePair()method to the existingCardAnimationsclass - 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)