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:
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# V3-10: Column Pair Indicator
|
||||
|
||||
## Overview
|
||||
|
||||
When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.
|
||||
|
||||
**Dependencies:** V3_04 (Column Pair Celebration - this builds on that)
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Show which columns are currently paired
|
||||
2. Visual connector between paired cards
|
||||
3. Score indicator showing "+0" or "locked"
|
||||
4. Don't clutter the interface
|
||||
5. Help new players understand pairing
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Visual Options
|
||||
|
||||
**Option A: Connecting Line**
|
||||
Draw a subtle line or bracket connecting paired cards.
|
||||
|
||||
**Option B: Shared Glow**
|
||||
Both cards have a subtle shared glow color.
|
||||
|
||||
**Option C: Zero Badge**
|
||||
Small "0" badge on the column.
|
||||
|
||||
**Option D: Lock Icon**
|
||||
Small lock icon indicating "locked in" pair.
|
||||
|
||||
**Recommendation:** Option A (line) + Option C (badge) - clear and informative.
|
||||
|
||||
### Visual Treatment
|
||||
|
||||
```
|
||||
Normal columns: Paired column:
|
||||
┌───┐ ┌───┐ ┌───┐ ─┐
|
||||
│ K │ │ 7 │ │ 5 │ │ [0]
|
||||
└───┘ └───┘ └───┘ │
|
||||
│
|
||||
┌───┐ ┌───┐ ┌───┐ ─┘
|
||||
│ Q │ │ 3 │ │ 5 │
|
||||
└───┘ └───┘ └───┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Detecting Pairs
|
||||
|
||||
```javascript
|
||||
getColumnPairs(cards) {
|
||||
const pairs = [];
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const [top, bottom] = columns[i];
|
||||
const topCard = cards[top];
|
||||
const bottomCard = cards[bottom];
|
||||
|
||||
if (topCard?.face_up && bottomCard?.face_up &&
|
||||
topCard?.rank && topCard.rank === bottomCard?.rank) {
|
||||
pairs.push({
|
||||
column: i,
|
||||
topPosition: top,
|
||||
bottomPosition: bottom,
|
||||
rank: topCard.rank
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Pair Indicators
|
||||
|
||||
```javascript
|
||||
renderPairIndicators(playerId, cards) {
|
||||
const pairs = this.getColumnPairs(cards);
|
||||
const container = this.getPairIndicatorContainer(playerId);
|
||||
|
||||
// Clear existing indicators
|
||||
container.innerHTML = '';
|
||||
|
||||
if (pairs.length === 0) return;
|
||||
|
||||
const cardElements = this.getCardElements(playerId);
|
||||
|
||||
for (const pair of pairs) {
|
||||
const topCard = cardElements[pair.topPosition];
|
||||
const bottomCard = cardElements[pair.bottomPosition];
|
||||
|
||||
if (!topCard || !bottomCard) continue;
|
||||
|
||||
// Create connector line
|
||||
const connector = this.createPairConnector(topCard, bottomCard, pair.column);
|
||||
container.appendChild(connector);
|
||||
|
||||
// Add paired class to cards
|
||||
topCard.classList.add('paired');
|
||||
bottomCard.classList.add('paired');
|
||||
}
|
||||
}
|
||||
|
||||
createPairConnector(topCard, bottomCard, columnIndex) {
|
||||
const connector = document.createElement('div');
|
||||
connector.className = 'pair-connector';
|
||||
connector.dataset.column = columnIndex;
|
||||
|
||||
// Calculate position
|
||||
const topRect = topCard.getBoundingClientRect();
|
||||
const bottomRect = bottomCard.getBoundingClientRect();
|
||||
const containerRect = topCard.closest('.card-grid').getBoundingClientRect();
|
||||
|
||||
// Position connector to the right of the column
|
||||
const x = topRect.right - containerRect.left + 5;
|
||||
const y = topRect.top - containerRect.top;
|
||||
const height = bottomRect.bottom - topRect.top;
|
||||
|
||||
connector.style.cssText = `
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
height: ${height}px;
|
||||
`;
|
||||
|
||||
// Add zero badge
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'pair-badge';
|
||||
badge.textContent = '0';
|
||||
connector.appendChild(badge);
|
||||
|
||||
return connector;
|
||||
}
|
||||
|
||||
getPairIndicatorContainer(playerId) {
|
||||
// Get or create indicator container
|
||||
const area = playerId === this.playerId
|
||||
? this.playerCards
|
||||
: this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);
|
||||
|
||||
if (!area) return document.createElement('div'); // Fallback
|
||||
|
||||
let container = area.querySelector('.pair-indicators');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'pair-indicators';
|
||||
area.style.position = 'relative';
|
||||
area.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
```css
|
||||
/* Pair indicators container */
|
||||
.pair-indicators {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Connector line */
|
||||
.pair-connector {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(244, 164, 96, 0.6) 0%,
|
||||
rgba(244, 164, 96, 0.8) 50%,
|
||||
rgba(244, 164, 96, 0.6) 100%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Bracket style alternative */
|
||||
.pair-connector::before,
|
||||
.pair-connector::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
background: rgba(244, 164, 96, 0.6);
|
||||
}
|
||||
|
||||
.pair-connector::before {
|
||||
top: 0;
|
||||
border-radius: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.pair-connector::after {
|
||||
bottom: 0;
|
||||
border-radius: 0 0 0 2px;
|
||||
}
|
||||
|
||||
/* Zero badge */
|
||||
.pair-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #f4a460;
|
||||
color: #1a1a2e;
|
||||
font-size: 0.7em;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Paired card subtle highlight */
|
||||
.card.paired {
|
||||
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
|
||||
}
|
||||
|
||||
/* Opponent paired cards - smaller/subtler */
|
||||
.opponent-area .pair-connector {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.opponent-area .pair-badge {
|
||||
font-size: 0.6em;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.opponent-area .card.paired {
|
||||
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with renderGame
|
||||
|
||||
```javascript
|
||||
// In renderGame(), after rendering cards
|
||||
renderGame() {
|
||||
// ... existing rendering ...
|
||||
|
||||
// Update pair indicators for all players
|
||||
for (const player of this.gameState.players) {
|
||||
this.renderPairIndicators(player.id, player.cards);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Window Resize
|
||||
|
||||
Pair connectors are positioned absolutely, so they need updating on resize:
|
||||
|
||||
```javascript
|
||||
constructor() {
|
||||
// ... existing constructor ...
|
||||
|
||||
// Debounced resize handler for pair indicators
|
||||
window.addEventListener('resize', this.debounce(() => {
|
||||
if (this.gameState) {
|
||||
for (const player of this.gameState.players) {
|
||||
this.renderPairIndicators(player.id, player.cards);
|
||||
}
|
||||
}
|
||||
}, 100));
|
||||
}
|
||||
|
||||
debounce(fn, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative: CSS-Only Approach
|
||||
|
||||
Simpler approach using only CSS classes:
|
||||
|
||||
```javascript
|
||||
// In renderGame, just add classes
|
||||
for (const player of this.gameState.players) {
|
||||
const pairs = this.getColumnPairs(player.cards);
|
||||
const cards = this.getCardElements(player.id);
|
||||
|
||||
// Clear previous
|
||||
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
|
||||
|
||||
for (const pair of pairs) {
|
||||
cards[pair.topPosition]?.classList.add('paired', 'pair-top');
|
||||
cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* CSS-only pair indication */
|
||||
.card.pair-top {
|
||||
border-bottom: 3px solid #f4a460;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.card.pair-bottom {
|
||||
border-top: 3px solid #f4a460;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.card.paired::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgba(244, 164, 96, 0.5);
|
||||
}
|
||||
|
||||
.card.pair-bottom::after {
|
||||
top: -100%; /* Extend up to connect */
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Start with CSS-only approach. Add connector elements if more visual clarity needed.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Single pair** - One column shows indicator
|
||||
2. **Multiple pairs** - Multiple indicators (rare but possible)
|
||||
3. **No pairs** - No indicators
|
||||
4. **Pair broken** - Indicator disappears
|
||||
5. **Pair formed** - Indicator appears (after celebration)
|
||||
6. **Face-down card in column** - No indicator
|
||||
7. **Opponent pairs** - Smaller indicators visible
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Paired columns show visual connector
|
||||
- [ ] "0" badge indicates the score contribution
|
||||
- [ ] Indicators update when cards change
|
||||
- [ ] Works for local player and opponents
|
||||
- [ ] Smaller/subtler for opponents
|
||||
- [ ] Handles window resize
|
||||
- [ ] Doesn't clutter interface
|
||||
- [ ] Helps new players understand pairing
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Implement `getColumnPairs()` method
|
||||
2. Choose approach: CSS-only or connector elements
|
||||
3. If connector: implement `createPairConnector()`
|
||||
4. Add CSS for indicators
|
||||
5. Integrate into `renderGame()`
|
||||
6. Add resize handling
|
||||
7. Test various pair scenarios
|
||||
8. Adjust styling for opponents
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for static indicators (not animated elements)
|
||||
- Keep indicators subtle - informative not distracting
|
||||
- Opponent indicators should be smaller/lighter
|
||||
- CSS-only approach is simpler to maintain
|
||||
- The badge helps players learning the scoring system
|
||||
- Consider: toggle option to hide indicators? (For experienced players)
|
||||
- Make sure indicators don't overlap cards on mobile
|
||||
Reference in New Issue
Block a user