- 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>
344 lines
8.4 KiB
Markdown
344 lines
8.4 KiB
Markdown
# V3-08: Card Hover/Selection Enhancement
|
|
|
|
## Overview
|
|
|
|
When holding a drawn card, players must choose which card to swap with. Currently, clicking a card immediately swaps. This feature adds better hover feedback showing the potential swap before committing.
|
|
|
|
**Dependencies:** None
|
|
**Dependents:** None
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
1. Clear visual preview of the swap before clicking
|
|
2. Show where the held card will go
|
|
3. Show where the hand card will go (discard)
|
|
4. Distinct hover states for face-up vs face-down cards
|
|
5. Mobile-friendly (no hover, but clear tap targets)
|
|
|
|
---
|
|
|
|
## Current State
|
|
|
|
From `app.js`:
|
|
```javascript
|
|
handleCardClick(position) {
|
|
// ... if holding drawn card ...
|
|
if (this.drawnCard) {
|
|
this.animateSwap(position); // Immediately swaps
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
Cards have basic hover effects in CSS but no swap preview.
|
|
|
|
---
|
|
|
|
## Design
|
|
|
|
### Desktop Hover Preview
|
|
|
|
When hovering over a hand card while holding a drawn card:
|
|
|
|
```
|
|
1. Hovered card lifts slightly and dims
|
|
2. Ghost of held card appears in that slot (semi-transparent)
|
|
3. Arrow or line hints at the swap direction
|
|
4. "Click to swap" tooltip (optional)
|
|
```
|
|
|
|
### Mobile Tap Preview
|
|
|
|
Since mobile has no hover:
|
|
- First tap = select/highlight the card
|
|
- Second tap = confirm swap
|
|
- Or: long-press shows preview, release to swap
|
|
|
|
**Recommendation:** Immediate swap on tap (current behavior) is fine for mobile. Focus on desktop hover preview.
|
|
|
|
---
|
|
|
|
## Implementation
|
|
|
|
### CSS Hover Enhancements
|
|
|
|
```css
|
|
/* Card hover when holding drawn card */
|
|
.player-area.can-swap .card {
|
|
cursor: pointer;
|
|
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
|
|
}
|
|
|
|
.player-area.can-swap .card:hover {
|
|
transform: translateY(-5px) scale(1.02);
|
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
/* Dimmed state showing "this will be replaced" */
|
|
.player-area.can-swap .card:hover::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Ghost preview of incoming card */
|
|
.card-ghost-preview {
|
|
position: absolute;
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
transform: scale(0.95);
|
|
z-index: 5;
|
|
border: 2px dashed rgba(244, 164, 96, 0.8);
|
|
}
|
|
|
|
/* Swap indicator arrow */
|
|
.swap-indicator {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.player-area.can-swap .card:hover ~ .swap-indicator {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Different highlight for face-down cards */
|
|
.player-area.can-swap .card.card-back:hover {
|
|
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
|
|
}
|
|
|
|
/* "Unknown" indicator for face-down hover */
|
|
.card.card-back:hover::before {
|
|
content: '?';
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 2em;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
```
|
|
|
|
### JavaScript Implementation
|
|
|
|
```javascript
|
|
// Add swap preview functionality
|
|
setupSwapPreview() {
|
|
this.ghostPreview = document.createElement('div');
|
|
this.ghostPreview.className = 'card-ghost-preview hidden';
|
|
this.playerCards.appendChild(this.ghostPreview);
|
|
}
|
|
|
|
// Call during render when player is holding a card
|
|
updateSwapPreviewState() {
|
|
const canSwap = this.drawnCard && this.isMyTurn();
|
|
|
|
this.playerArea.classList.toggle('can-swap', canSwap);
|
|
|
|
if (!canSwap) {
|
|
this.ghostPreview?.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
// Set up ghost preview content
|
|
if (this.drawnCard && this.ghostPreview) {
|
|
this.ghostPreview.className = 'card-ghost-preview card card-front hidden';
|
|
|
|
if (this.drawnCard.rank === '★') {
|
|
this.ghostPreview.classList.add('joker');
|
|
} else if (this.isRedSuit(this.drawnCard.suit)) {
|
|
this.ghostPreview.classList.add('red');
|
|
} else {
|
|
this.ghostPreview.classList.add('black');
|
|
}
|
|
|
|
this.ghostPreview.innerHTML = this.renderCardContent(this.drawnCard);
|
|
}
|
|
}
|
|
|
|
// Bind hover events to cards
|
|
bindCardHoverEvents() {
|
|
const cards = this.playerCards.querySelectorAll('.card');
|
|
|
|
cards.forEach((card, index) => {
|
|
card.addEventListener('mouseenter', () => {
|
|
if (!this.drawnCard || !this.isMyTurn()) return;
|
|
this.showSwapPreview(card, index);
|
|
});
|
|
|
|
card.addEventListener('mouseleave', () => {
|
|
this.hideSwapPreview();
|
|
});
|
|
});
|
|
}
|
|
|
|
showSwapPreview(targetCard, position) {
|
|
if (!this.ghostPreview) return;
|
|
|
|
// Position ghost at target card location
|
|
const rect = targetCard.getBoundingClientRect();
|
|
const containerRect = this.playerCards.getBoundingClientRect();
|
|
|
|
this.ghostPreview.style.left = `${rect.left - containerRect.left}px`;
|
|
this.ghostPreview.style.top = `${rect.top - containerRect.top}px`;
|
|
this.ghostPreview.style.width = `${rect.width}px`;
|
|
this.ghostPreview.style.height = `${rect.height}px`;
|
|
|
|
this.ghostPreview.classList.remove('hidden');
|
|
|
|
// Highlight target card
|
|
targetCard.classList.add('swap-target');
|
|
|
|
// Show what will happen
|
|
this.setStatus(`Swap with position ${position + 1}`, 'swap-preview');
|
|
}
|
|
|
|
hideSwapPreview() {
|
|
this.ghostPreview?.classList.add('hidden');
|
|
|
|
// Remove target highlight
|
|
this.playerCards.querySelectorAll('.card').forEach(card => {
|
|
card.classList.remove('swap-target');
|
|
});
|
|
|
|
// Restore normal status
|
|
this.updateStatusFromGameState();
|
|
}
|
|
```
|
|
|
|
### Card Position Labels (Optional Enhancement)
|
|
|
|
Show position numbers on cards during swap selection:
|
|
|
|
```css
|
|
.player-area.can-swap .card::before {
|
|
content: attr(data-position);
|
|
position: absolute;
|
|
top: -8px;
|
|
left: -8px;
|
|
width: 18px;
|
|
height: 18px;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
color: white;
|
|
border-radius: 50%;
|
|
font-size: 11px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
// In renderGame, add data-position to cards
|
|
const cards = this.playerCards.querySelectorAll('.card');
|
|
cards.forEach((card, i) => {
|
|
card.dataset.position = i + 1;
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Visual Preview Options
|
|
|
|
### Option A: Ghost Card (Recommended)
|
|
|
|
Semi-transparent copy of the held card appears over the target slot.
|
|
|
|
### Option B: Arrow Indicator
|
|
|
|
Arrow from held card to target slot, and from target to discard.
|
|
|
|
### Option C: Split Preview
|
|
|
|
Show both cards side-by-side with swap arrows.
|
|
|
|
**Recommendation:** Option A is simplest and most intuitive.
|
|
|
|
---
|
|
|
|
## Face-Down Card Interaction
|
|
|
|
When swapping with a face-down card, player is taking a risk:
|
|
|
|
- Show "?" indicator to emphasize unknown
|
|
- Maybe show estimated value range? (Too complex for V3)
|
|
- Different hover color (orange = warning)
|
|
|
|
```css
|
|
.player-area.can-swap .card.card-back:hover {
|
|
border: 2px solid #f4a460;
|
|
}
|
|
|
|
.player-area.can-swap .card.card-back:hover::after {
|
|
content: 'Unknown';
|
|
position: absolute;
|
|
bottom: -20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 0.7em;
|
|
color: #f4a460;
|
|
white-space: nowrap;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Test Scenarios
|
|
|
|
1. **Hover over face-up card** - Shows preview, card lifts
|
|
2. **Hover over face-down card** - Shows warning styling
|
|
3. **Move between cards** - Preview updates smoothly
|
|
4. **Mouse leaves card area** - Preview disappears
|
|
5. **Not holding card** - No special hover effects
|
|
6. **Not my turn** - No hover effects
|
|
7. **Mobile tap** - Works without preview (existing behavior)
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] Cards lift on hover when holding drawn card
|
|
- [ ] Ghost preview shows incoming card
|
|
- [ ] Face-down cards have distinct hover (unknown warning)
|
|
- [ ] Preview disappears on mouse leave
|
|
- [ ] No effects when not holding card
|
|
- [ ] No effects when not your turn
|
|
- [ ] Mobile tap still works normally
|
|
- [ ] Smooth transitions, no jank
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
1. Add `can-swap` class toggle to player area
|
|
2. Add CSS for hover lift effect
|
|
3. Create ghost preview element
|
|
4. Implement `showSwapPreview()` method
|
|
5. Implement `hideSwapPreview()` method
|
|
6. Bind mouseenter/mouseleave events
|
|
7. Add face-down card distinct styling
|
|
8. Test on desktop and mobile
|
|
9. Optional: Add position labels
|
|
|
|
---
|
|
|
|
## Notes for Agent
|
|
|
|
- **CSS vs anime.js**: CSS is appropriate for simple hover effects (performant, no JS overhead)
|
|
- Keep hover effects performant (CSS transforms preferred)
|
|
- Don't break existing click-to-swap behavior
|
|
- Mobile should work exactly as before (immediate swap)
|
|
- Consider reduced motion preferences
|
|
- The ghost preview should match the actual card appearance
|
|
- Position labels help new players understand the grid
|