golfgame/docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
adlee-was-taken 9fc6b83bba 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>
2026-02-14 10:03:45 -05:00

355 lines
9.7 KiB
Markdown

# 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)