- 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>
318 lines
8.8 KiB
Markdown
318 lines
8.8 KiB
Markdown
# V3-11: Swap Animation Improvements
|
|
|
|
## Overview
|
|
|
|
When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.
|
|
|
|
**Dependencies:** None
|
|
**Dependents:** None
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
1. Cards visibly exchange positions (not teleport)
|
|
2. Old card slides toward discard
|
|
3. New card slides into hand slot
|
|
4. Brief "crossing" moment visible
|
|
5. Smooth, performant animation
|
|
6. Works for both face-up and face-down swaps
|
|
|
|
---
|
|
|
|
## Current State
|
|
|
|
From `card-animations.js` (CardAnimations class):
|
|
```javascript
|
|
// Current swap uses anime.js with pulse effect for face-up swaps
|
|
// and flip animation for face-down swaps
|
|
|
|
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
|
|
if (isAlreadyFaceUp) {
|
|
// Face-up swap: subtle pulse, no flip needed
|
|
this._animateFaceUpSwap(handCardElement, onComplete);
|
|
} else {
|
|
// Face-down swap: flip reveal then swap
|
|
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
|
|
}
|
|
}
|
|
|
|
_animateFaceUpSwap(handCardElement, onComplete) {
|
|
anime({
|
|
targets: handCardElement,
|
|
scale: [1, 0.92, 1.08, 1],
|
|
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
|
|
duration: 400,
|
|
easing: 'easeOutQuad'
|
|
});
|
|
}
|
|
```
|
|
|
|
The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.
|
|
|
|
---
|
|
|
|
## Design
|
|
|
|
### Animation Sequence
|
|
|
|
```
|
|
1. If face-down: Flip hand card to reveal (existing)
|
|
2. Lift both cards slightly (z-index, shadow)
|
|
3. Hand card arcs toward discard pile
|
|
4. Held card arcs toward hand slot
|
|
5. Cards cross paths visually (middle of arc)
|
|
6. Cards land at destinations
|
|
7. Landing pulse effect
|
|
```
|
|
|
|
### Arc Paths
|
|
|
|
Instead of straight lines, cards follow curved paths:
|
|
|
|
```
|
|
Hand card path
|
|
╭─────────────────╮
|
|
│ │
|
|
[Hand] [Discard]
|
|
│ │
|
|
╰─────────────────╯
|
|
Held card path
|
|
```
|
|
|
|
The curves create a visual "exchange" moment.
|
|
|
|
---
|
|
|
|
## Implementation
|
|
|
|
### Enhanced Swap Animation (Add to CardAnimations class)
|
|
|
|
```javascript
|
|
// In card-animations.js - enhance the existing animateSwap method
|
|
|
|
async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
|
|
const T = window.TIMING?.swap || {
|
|
lift: 80,
|
|
arc: 280,
|
|
settle: 60,
|
|
};
|
|
|
|
// Create animation elements that will travel
|
|
const travelingHandCard = this.createTravelingCard(handCardEl);
|
|
const travelingHeldCard = this.createTravelingCard(heldCardEl);
|
|
|
|
document.body.appendChild(travelingHandCard);
|
|
document.body.appendChild(travelingHeldCard);
|
|
|
|
// Position at start
|
|
this.positionAt(travelingHandCard, handRect);
|
|
this.positionAt(travelingHeldCard, holdingRect || discardRect);
|
|
|
|
// Hide originals
|
|
handCardEl.style.visibility = 'hidden';
|
|
heldCardEl.style.visibility = 'hidden';
|
|
|
|
this.playSound('card');
|
|
|
|
// Use anime.js timeline for coordinated arc movement
|
|
const timeline = anime.timeline({
|
|
easing: this.getEasing('move'),
|
|
complete: () => {
|
|
travelingHandCard.remove();
|
|
travelingHeldCard.remove();
|
|
handCardEl.style.visibility = 'visible';
|
|
heldCardEl.style.visibility = 'visible';
|
|
this.pulseDiscard();
|
|
if (onComplete) onComplete();
|
|
}
|
|
});
|
|
|
|
// Calculate arc midpoints
|
|
const midY1 = (handRect.top + discardRect.top) / 2 - 40; // Arc up
|
|
const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40; // Arc down
|
|
|
|
// Step 1: Lift both cards with shadow increase
|
|
timeline.add({
|
|
targets: [travelingHandCard, travelingHeldCard],
|
|
translateY: -10,
|
|
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
|
|
scale: 1.02,
|
|
duration: T.lift,
|
|
easing: this.getEasing('lift')
|
|
});
|
|
|
|
// Step 2: Hand card arcs to discard
|
|
timeline.add({
|
|
targets: travelingHandCard,
|
|
left: discardRect.left,
|
|
top: [
|
|
{ value: midY1, duration: T.arc / 2 },
|
|
{ value: discardRect.top, duration: T.arc / 2 }
|
|
],
|
|
rotate: [0, -5, 0],
|
|
duration: T.arc,
|
|
}, `-=${T.lift / 2}`);
|
|
|
|
// Held card arcs to hand (in parallel)
|
|
timeline.add({
|
|
targets: travelingHeldCard,
|
|
left: handRect.left,
|
|
top: [
|
|
{ value: midY2, duration: T.arc / 2 },
|
|
{ value: handRect.top, duration: T.arc / 2 }
|
|
],
|
|
rotate: [0, 5, 0],
|
|
duration: T.arc,
|
|
}, `-=${T.arc + T.lift / 2}`);
|
|
|
|
// Step 3: Settle
|
|
timeline.add({
|
|
targets: [travelingHandCard, travelingHeldCard],
|
|
translateY: 0,
|
|
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
|
|
scale: 1,
|
|
duration: T.settle,
|
|
});
|
|
|
|
this.activeAnimations.set('physicalSwap', timeline);
|
|
}
|
|
|
|
createTravelingCard(sourceCard) {
|
|
const clone = sourceCard.cloneNode(true);
|
|
clone.className = 'traveling-card';
|
|
clone.style.position = 'fixed';
|
|
clone.style.pointerEvents = 'none';
|
|
clone.style.zIndex = '1000';
|
|
clone.style.borderRadius = '6px';
|
|
return clone;
|
|
}
|
|
|
|
positionAt(element, rect) {
|
|
element.style.left = `${rect.left}px`;
|
|
element.style.top = `${rect.top}px`;
|
|
element.style.width = `${rect.width}px`;
|
|
element.style.height = `${rect.height}px`;
|
|
}
|
|
```
|
|
|
|
### CSS for Traveling Cards
|
|
|
|
Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:
|
|
|
|
```css
|
|
/* Traveling card during swap - base styles only */
|
|
.traveling-card {
|
|
position: fixed;
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
/* All animation handled by anime.js */
|
|
}
|
|
```
|
|
|
|
### Timing Configuration
|
|
|
|
```javascript
|
|
// In timing-config.js
|
|
swap: {
|
|
lift: 80, // Time to lift cards
|
|
arc: 280, // Time for arc travel
|
|
settle: 60, // Time to settle into place
|
|
// Total: ~420ms (similar to current)
|
|
}
|
|
```
|
|
|
|
### Note on Animation Approach
|
|
|
|
All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:
|
|
- Better coordination between multiple elements
|
|
- Consistent with rest of animation system
|
|
- Easier timing control via `window.TIMING`
|
|
- Proper animation cancellation via `activeAnimations` tracking
|
|
|
|
---
|
|
|
|
## Integration Points
|
|
|
|
### For Local Player Swap
|
|
|
|
```javascript
|
|
// In animateSwap() method
|
|
animateSwap(position) {
|
|
const cardElements = this.playerCards.querySelectorAll('.card');
|
|
const handCardEl = cardElements[position];
|
|
|
|
// Get positions
|
|
const handRect = handCardEl.getBoundingClientRect();
|
|
const discardRect = this.discard.getBoundingClientRect();
|
|
const holdingRect = this.getHoldingRect();
|
|
|
|
// If face-down, flip first (existing logic)
|
|
// ...
|
|
|
|
// Then do physical swap
|
|
this.animatePhysicalSwap(
|
|
handCardEl,
|
|
this.heldCardFloating,
|
|
handRect,
|
|
discardRect,
|
|
holdingRect
|
|
);
|
|
}
|
|
```
|
|
|
|
### For Opponent Swap
|
|
|
|
The opponent swap animation in `fireSwapAnimation()` can use similar arc logic for the visible card traveling to discard.
|
|
|
|
---
|
|
|
|
## Test Scenarios
|
|
|
|
1. **Swap face-up card** - Direct arc exchange
|
|
2. **Swap face-down card** - Flip first, then arc
|
|
3. **Fast repeated swaps** - No animation overlap
|
|
4. **Mobile** - Animation performs at 60fps
|
|
5. **Different screen sizes** - Arcs scale appropriately
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] Cards visibly travel to new positions (not teleport)
|
|
- [ ] Arc paths create "crossing" visual
|
|
- [ ] Lift and settle effects enhance physicality
|
|
- [ ] Animation total time ~400ms (not slower than current)
|
|
- [ ] Works for face-up and face-down cards
|
|
- [ ] Performant on mobile (60fps)
|
|
- [ ] Landing effect on discard pile
|
|
- [ ] Opponent swaps also improved
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
1. Add swap timing to `timing-config.js`
|
|
2. Implement `createTravelingCard()` helper
|
|
3. Implement `animateArc()` with Web Animations API
|
|
4. Implement `animatePhysicalSwap()` method
|
|
5. Add CSS for traveling cards
|
|
6. Integrate with local player swap
|
|
7. Integrate with opponent swap animation
|
|
8. Test on various devices
|
|
9. Tune arc height and timing
|
|
|
|
---
|
|
|
|
## Notes for Agent
|
|
|
|
- Add `animatePhysicalSwap()` to the existing CardAnimations class
|
|
- Use anime.js timelines for coordinated multi-element animation
|
|
- Arc height should scale with card distance
|
|
- The "crossing" moment is the key visual improvement
|
|
- Keep total animation time similar to current (~400ms)
|
|
- Track animation in `activeAnimations` for proper cancellation
|
|
- Consider: option for "fast mode" with simpler animations?
|
|
- Make sure sound timing aligns with visual (card leaving hand)
|
|
- Existing `animateSwap()` can call this new method internally
|