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:
@@ -2,17 +2,26 @@
|
||||
|
||||
This document describes the unified animation system for the Golf card game client.
|
||||
|
||||
For detailed animation flow diagrams (what triggers what, in what order, with what flags), see [`docs/ANIMATION-FLOWS.md`](../docs/ANIMATION-FLOWS.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
**All card animations use anime.js.** There are no CSS transitions on card elements.
|
||||
**When to use anime.js vs CSS:**
|
||||
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||
|
||||
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||
|
||||
| What | How |
|
||||
|------|-----|
|
||||
| Card movements | anime.js |
|
||||
| Card flips | anime.js |
|
||||
| Swap animations | anime.js |
|
||||
| Pulse/glow effects | anime.js |
|
||||
| Hover states | CSS `:hover` only |
|
||||
| Pulse/glow effects on cards | anime.js |
|
||||
| Button hover/active states | CSS transitions |
|
||||
| Badge entrance/exit | CSS transitions |
|
||||
| Status message fades | CSS transitions |
|
||||
| Card hover states | anime.js `hoverIn()`/`hoverOut()` |
|
||||
| Show/hide | CSS `.hidden` class only |
|
||||
|
||||
### Why anime.js?
|
||||
@@ -130,6 +139,27 @@ cardAnimations.cleanup()
|
||||
|
||||
---
|
||||
|
||||
## Animation Coordination
|
||||
|
||||
### Server-Client Timing
|
||||
|
||||
Server CPU timing (in `server/ai.py` `CPU_TIMING`) must account for client animation durations:
|
||||
- `post_draw_settle`: Must be >= draw animation duration (~1.1s for deck draw)
|
||||
- `post_action_pause`: Must be >= swap/discard animation duration (~0.5s)
|
||||
|
||||
### Preventing Animation Overlap
|
||||
|
||||
Animation overlay cards are marked with `data-animating="true"` while active.
|
||||
Methods like `animateUnifiedSwap` and `animateOpponentDiscard` check for active
|
||||
animations and wait before starting new ones.
|
||||
|
||||
### Card Hover Initialization
|
||||
|
||||
Call `cardAnimations.initHoverListeners(container)` after dynamically creating cards.
|
||||
This is done automatically in `renderGame()` for player and opponent card areas.
|
||||
|
||||
---
|
||||
|
||||
## Animation Overlay Pattern
|
||||
|
||||
For complex animations (flips, swaps), the system:
|
||||
@@ -150,24 +180,34 @@ All timing values are in `timing-config.js` and exposed as `window.TIMING`.
|
||||
|
||||
### Key Durations
|
||||
|
||||
| Animation | Duration | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Flip | 245ms | 3D rotateY animation |
|
||||
| Deck lift | 63ms | Before moving to hold |
|
||||
| Deck move | 105ms | To hold position |
|
||||
| Discard lift | 25ms | Quick grab |
|
||||
| Discard move | 76ms | To hold position |
|
||||
| Swap pulse | 400ms | Scale + brightness |
|
||||
| Turn shake | 400ms | Every 3 seconds |
|
||||
All durations are configured in `timing-config.js` and read via `window.TIMING`.
|
||||
|
||||
| Animation | Duration | Config Key | Notes |
|
||||
|-----------|----------|------------|-------|
|
||||
| Flip | 320ms | `card.flip` | 3D rotateY with slight overshoot |
|
||||
| Deck lift | 120ms | `draw.deckLift` | Visible lift before travel |
|
||||
| Deck move | 250ms | `draw.deckMove` | Smooth travel to hold position |
|
||||
| Deck flip | 320ms | `draw.deckFlip` | Reveal drawn card |
|
||||
| Discard lift | 80ms | `draw.discardLift` | Quick decisive grab |
|
||||
| Discard move | 200ms | `draw.discardMove` | Travel to hold position |
|
||||
| Swap lift | 100ms | `swap.lift` | Pickup before arc travel |
|
||||
| Swap arc | 320ms | `swap.arc` | Arc travel between positions |
|
||||
| Swap settle | 100ms | `swap.settle` | Landing with gentle overshoot |
|
||||
| Swap pulse | 400ms | — | Scale + brightness (face-up swap) |
|
||||
| Turn shake | 400ms | — | Every 3 seconds |
|
||||
|
||||
### Easing Functions
|
||||
|
||||
Custom cubic bezier curves give cards natural weight and momentum:
|
||||
|
||||
```javascript
|
||||
window.TIMING.anime.easing = {
|
||||
flip: 'easeInOutQuad', // Smooth acceleration/deceleration
|
||||
move: 'easeOutCubic', // Fast start, gentle settle
|
||||
lift: 'easeOutQuad', // Quick lift
|
||||
pulse: 'easeInOutSine', // Smooth oscillation
|
||||
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
|
||||
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
|
||||
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
|
||||
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
|
||||
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
|
||||
pulse: 'easeInOutSine', // Smooth oscillation (loops)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -179,14 +219,19 @@ window.TIMING.anime.easing = {
|
||||
|
||||
- Static card appearance (colors, borders, sizing)
|
||||
- Layout and positioning
|
||||
- Hover states (`:hover` scale/shadow)
|
||||
- Card hover states (`:hover` scale/shadow - no movement)
|
||||
- Show/hide via `.hidden` class
|
||||
- **UI chrome animations** (buttons, badges, status messages):
|
||||
- Button hover/active transitions
|
||||
- Badge entrance/exit animations
|
||||
- Status message fade in/out
|
||||
- Modal transitions
|
||||
|
||||
### What CSS Does NOT Do
|
||||
### What CSS Does NOT Do (on card elements)
|
||||
|
||||
- No `transition` on any card element
|
||||
- No `@keyframes` for card animations
|
||||
- No `.flipped`, `.moving`, `.flipping` transition triggers
|
||||
- No `transition` on any card element (`.card`, `.card-inner`, `.real-card`, `.swap-card`, `.held-card-floating`)
|
||||
- No `@keyframes` for card movements or flips
|
||||
- No `.flipped`, `.moving`, `.flipping` transition triggers for cards
|
||||
|
||||
### Important Classes
|
||||
|
||||
@@ -218,14 +263,15 @@ if (!this.isDrawAnimating && /* other conditions */) {
|
||||
Use anime.js timelines for coordinated sequences:
|
||||
|
||||
```javascript
|
||||
const T = window.TIMING;
|
||||
const timeline = anime.timeline({
|
||||
easing: 'easeOutQuad',
|
||||
easing: T.anime.easing.move,
|
||||
complete: () => { /* cleanup */ }
|
||||
});
|
||||
|
||||
timeline.add({ targets: el, translateY: -15, duration: 100 });
|
||||
timeline.add({ targets: el, left: x, top: y, duration: 200 });
|
||||
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
|
||||
timeline.add({ targets: el, translateY: -15, duration: T.card.lift, easing: T.anime.easing.lift });
|
||||
timeline.add({ targets: el, left: x, top: y, duration: T.card.move });
|
||||
timeline.add({ targets: inner, rotateY: 0, duration: T.card.flip, easing: T.anime.easing.flip });
|
||||
```
|
||||
|
||||
### Fire-and-Forget Animations
|
||||
|
||||
@@ -194,9 +194,10 @@ class CardAnimations {
|
||||
this.startDrawPulse(document.getElementById('deck'));
|
||||
|
||||
// Delay card animation to let pulse be visible
|
||||
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
|
||||
setTimeout(() => {
|
||||
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
|
||||
}, 250);
|
||||
}, pulseDelay);
|
||||
}
|
||||
|
||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||
@@ -204,6 +205,7 @@ class CardAnimations {
|
||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const D = window.TIMING?.draw || {};
|
||||
|
||||
if (cardData) {
|
||||
this.setCardContent(animCard, cardData);
|
||||
@@ -231,36 +233,36 @@ class CardAnimations {
|
||||
targets: animCard,
|
||||
translateY: -15,
|
||||
rotate: [-2, 0],
|
||||
duration: 63,
|
||||
duration: D.deckLift || 120,
|
||||
easing: this.getEasing('lift')
|
||||
});
|
||||
|
||||
// Move to holding position
|
||||
// Move to holding position with smooth deceleration
|
||||
timeline.add({
|
||||
targets: animCard,
|
||||
left: holdingRect.left,
|
||||
top: holdingRect.top,
|
||||
translateY: 0,
|
||||
duration: 105,
|
||||
duration: D.deckMove || 250,
|
||||
easing: this.getEasing('move')
|
||||
});
|
||||
|
||||
// Suspense pause
|
||||
timeline.add({ duration: 200 });
|
||||
// Brief pause before flip (easing handles the deceleration feel)
|
||||
timeline.add({ duration: D.deckRevealPause || 80 });
|
||||
|
||||
// Flip to reveal
|
||||
if (cardData) {
|
||||
timeline.add({
|
||||
targets: inner,
|
||||
rotateY: 0,
|
||||
duration: 245,
|
||||
duration: D.deckFlip || 320,
|
||||
easing: this.getEasing('flip'),
|
||||
begin: () => this.playSound('flip')
|
||||
});
|
||||
}
|
||||
|
||||
// Brief pause to see card
|
||||
timeline.add({ duration: 150 });
|
||||
timeline.add({ duration: D.deckViewPause || 120 });
|
||||
|
||||
this.activeAnimations.set('drawDeck', timeline);
|
||||
} catch (e) {
|
||||
@@ -286,15 +288,17 @@ class CardAnimations {
|
||||
// Pulse discard pile
|
||||
this.startDrawPulse(document.getElementById('discard'));
|
||||
|
||||
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
|
||||
setTimeout(() => {
|
||||
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
|
||||
}, 200);
|
||||
}, pulseDelay);
|
||||
}
|
||||
|
||||
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
|
||||
const animCard = this.createAnimCard(discardRect, false);
|
||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||
this.setCardContent(animCard, cardData);
|
||||
const D = window.TIMING?.draw || {};
|
||||
|
||||
// Hide actual discard pile during animation to prevent visual conflict
|
||||
const discardPile = document.getElementById('discard');
|
||||
@@ -326,21 +330,23 @@ class CardAnimations {
|
||||
targets: animCard,
|
||||
translateY: -12,
|
||||
scale: 1.05,
|
||||
duration: 25
|
||||
duration: D.discardLift || 80,
|
||||
easing: this.getEasing('lift')
|
||||
});
|
||||
|
||||
// Direct move to holding
|
||||
// Direct move to holding with smooth deceleration
|
||||
timeline.add({
|
||||
targets: animCard,
|
||||
left: holdingRect.left,
|
||||
top: holdingRect.top,
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
duration: 76
|
||||
duration: D.discardMove || 200,
|
||||
easing: this.getEasing('move')
|
||||
});
|
||||
|
||||
// Minimal pause
|
||||
timeline.add({ duration: 80 });
|
||||
// Brief settle
|
||||
timeline.add({ duration: D.discardViewPause || 60 });
|
||||
|
||||
this.activeAnimations.set('drawDiscard', timeline);
|
||||
} catch (e) {
|
||||
@@ -365,7 +371,7 @@ class CardAnimations {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 245; // 30% faster flip
|
||||
const duration = window.TIMING?.card?.flip || 320;
|
||||
|
||||
try {
|
||||
const anim = anime({
|
||||
@@ -408,7 +414,7 @@ class CardAnimations {
|
||||
cardElement.style.opacity = '0';
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const duration = 245; // 30% faster flip
|
||||
const duration = window.TIMING?.card?.flip || 320;
|
||||
|
||||
try {
|
||||
// Simple smooth flip - no lift/settle
|
||||
@@ -452,7 +458,7 @@ class CardAnimations {
|
||||
cardElement.classList.add('swap-out');
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const duration = 245; // 30% faster flip
|
||||
const duration = window.TIMING?.card?.flip || 320;
|
||||
|
||||
// Helper to restore card to face-up state
|
||||
const restoreCard = () => {
|
||||
@@ -551,7 +557,7 @@ class CardAnimations {
|
||||
handCardElement.classList.add('swap-out');
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const flipDuration = 245; // 30% faster flip
|
||||
const flipDuration = window.TIMING?.card?.flip || 320;
|
||||
|
||||
try {
|
||||
const timeline = anime.timeline({
|
||||
@@ -572,7 +578,7 @@ class CardAnimations {
|
||||
});
|
||||
|
||||
// Brief pause to see the card
|
||||
timeline.add({ duration: 100 });
|
||||
timeline.add({ duration: 50 });
|
||||
|
||||
this.activeAnimations.set(`swap-${Date.now()}`, timeline);
|
||||
} catch (e) {
|
||||
@@ -606,7 +612,7 @@ class CardAnimations {
|
||||
sourceCardElement.classList.add('swap-out');
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const flipDuration = 245; // 30% faster flip
|
||||
const flipDuration = window.TIMING?.card?.flip || 320;
|
||||
|
||||
try {
|
||||
anime.timeline({
|
||||
@@ -661,8 +667,8 @@ class CardAnimations {
|
||||
width: discardRect.width,
|
||||
height: discardRect.height,
|
||||
scale: 1,
|
||||
duration: 350,
|
||||
easing: 'cubicBezier(0.25, 0.1, 0.25, 1)'
|
||||
duration: window.TIMING?.card?.move || 300,
|
||||
easing: this.getEasing('arc')
|
||||
});
|
||||
|
||||
this.activeAnimations.set(`discard-${Date.now()}`, timeline);
|
||||
@@ -686,7 +692,7 @@ class CardAnimations {
|
||||
this.setCardContent(animCard, card);
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const moveDuration = window.TIMING?.card?.move || 270;
|
||||
const moveDuration = window.TIMING?.card?.move || 300;
|
||||
|
||||
try {
|
||||
const timeline = anime.timeline({
|
||||
@@ -943,7 +949,7 @@ class CardAnimations {
|
||||
return;
|
||||
}
|
||||
|
||||
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
|
||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||
const handRect = handCardEl.getBoundingClientRect();
|
||||
const heldRect = heldCardEl.getBoundingClientRect();
|
||||
const discardRect = this.getDiscardRect();
|
||||
@@ -1005,6 +1011,7 @@ class CardAnimations {
|
||||
],
|
||||
rotate: [0, -3, 0],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.lift / 2}`);
|
||||
|
||||
// Held card arcs to hand slot (parallel)
|
||||
@@ -1017,14 +1024,16 @@ class CardAnimations {
|
||||
],
|
||||
rotate: [0, 3, 0],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.arc + T.lift / 2}`);
|
||||
|
||||
// Settle
|
||||
// Settle with gentle overshoot
|
||||
timeline.add({
|
||||
targets: [travelingHand, travelingHeld],
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
scale: [1.02, 1],
|
||||
duration: T.settle,
|
||||
easing: this.getEasing('settle'),
|
||||
});
|
||||
|
||||
this.activeAnimations.set('physicalSwap', timeline);
|
||||
@@ -1046,7 +1055,7 @@ class CardAnimations {
|
||||
// options: { rotation, wasHandFaceDown, onComplete }
|
||||
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
|
||||
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
|
||||
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
|
||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||
const discardRect = this.getDiscardRect();
|
||||
|
||||
// Safety checks
|
||||
@@ -1105,10 +1114,11 @@ class CardAnimations {
|
||||
|
||||
// Flip to reveal, then do the swap
|
||||
this.playSound('flip');
|
||||
const flipDuration = window.TIMING?.card?.flip || 320;
|
||||
anime({
|
||||
targets: inner,
|
||||
rotateY: 0,
|
||||
duration: 245,
|
||||
duration: flipDuration,
|
||||
easing: this.getEasing('flip'),
|
||||
complete: () => {
|
||||
this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
|
||||
@@ -1157,6 +1167,7 @@ class CardAnimations {
|
||||
height: discardRect.height,
|
||||
rotate: [rotation, rotation - 3, 0],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.lift / 2}`);
|
||||
|
||||
// Held card arcs to hand slot (apply rotation to match hand position)
|
||||
@@ -1171,14 +1182,16 @@ class CardAnimations {
|
||||
height: handRect.height,
|
||||
rotate: [0, 3, rotation],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.arc + T.lift / 2}`);
|
||||
|
||||
// Settle
|
||||
// Settle with gentle overshoot
|
||||
timeline.add({
|
||||
targets: [travelingHand, travelingHeld],
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
scale: [1.02, 1],
|
||||
duration: T.settle,
|
||||
easing: this.getEasing('settle'),
|
||||
});
|
||||
|
||||
this.activeAnimations.set('unifiedSwap', timeline);
|
||||
@@ -1199,7 +1212,7 @@ class CardAnimations {
|
||||
return;
|
||||
}
|
||||
|
||||
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
|
||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||
|
||||
// Create a traveling card showing the face at the held card's actual position
|
||||
const travelingCard = this.createCardFromData(cardData, heldRect, 0);
|
||||
@@ -1242,14 +1255,16 @@ class CardAnimations {
|
||||
height: discardRect.height,
|
||||
rotate: [0, -2, 0],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${T.lift / 2}`);
|
||||
|
||||
// Settle
|
||||
// Settle with gentle overshoot
|
||||
timeline.add({
|
||||
targets: travelingCard,
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
scale: [1.02, 1],
|
||||
duration: T.settle,
|
||||
easing: this.getEasing('settle'),
|
||||
});
|
||||
|
||||
this.activeAnimations.set('heldToDiscard', timeline);
|
||||
@@ -1291,7 +1306,7 @@ class CardAnimations {
|
||||
}
|
||||
|
||||
_runOpponentDiscard(cardData, holdingRect, discardRect, onComplete) {
|
||||
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
|
||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||
|
||||
// Create card at holding position, face-up (already revealed by draw animation)
|
||||
const travelingCard = this.createAnimCard(holdingRect, false);
|
||||
@@ -1336,14 +1351,16 @@ class CardAnimations {
|
||||
scale: 1,
|
||||
rotate: [0, -2, 0],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
});
|
||||
|
||||
// Settle
|
||||
// Settle with gentle overshoot
|
||||
timeline.add({
|
||||
targets: travelingCard,
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
scale: [1.02, 1],
|
||||
duration: T.settle,
|
||||
easing: this.getEasing('settle'),
|
||||
});
|
||||
|
||||
this.activeAnimations.set('opponentDiscard', timeline);
|
||||
|
||||
@@ -52,7 +52,7 @@ class CardManager {
|
||||
card.innerHTML = `
|
||||
<div class="card-inner">
|
||||
<div class="card-face card-face-front"></div>
|
||||
<div class="card-face card-face-back"><span>?</span></div>
|
||||
<div class="card-face card-face-back"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -64,10 +64,22 @@ class CardManager {
|
||||
updateCardAppearance(cardEl, cardData) {
|
||||
const inner = cardEl.querySelector('.card-inner');
|
||||
const front = cardEl.querySelector('.card-face-front');
|
||||
const back = cardEl.querySelector('.card-face-back');
|
||||
|
||||
// Reset front classes
|
||||
front.className = 'card-face card-face-front';
|
||||
|
||||
// Apply deck color to card back
|
||||
if (back) {
|
||||
// Remove any existing deck color classes
|
||||
back.className = back.className.replace(/\bdeck-\w+/g, '').trim();
|
||||
back.className = 'card-face card-face-back';
|
||||
const deckColor = this.getDeckColorClass(cardData);
|
||||
if (deckColor) {
|
||||
back.classList.add(deckColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardData || !cardData.face_up || !cardData.rank) {
|
||||
// Face down or no data
|
||||
inner.classList.add('flipped');
|
||||
@@ -88,6 +100,17 @@ class CardManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the deck color class for a card based on its deck_id
|
||||
getDeckColorClass(cardData) {
|
||||
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||
return null;
|
||||
}
|
||||
// Get deck colors from game state (set by app.js)
|
||||
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
||||
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
||||
return `deck-${colorName}`;
|
||||
}
|
||||
|
||||
getSuitSymbol(suit) {
|
||||
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
|
||||
}
|
||||
@@ -104,7 +127,8 @@ class CardManager {
|
||||
cardEl.style.height = `${rect.height}px`;
|
||||
|
||||
if (animate) {
|
||||
setTimeout(() => cardEl.classList.remove('moving'), 350);
|
||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +154,11 @@ class CardManager {
|
||||
}
|
||||
|
||||
// Animate a card flip
|
||||
async flipCard(playerId, position, newCardData, duration = 400) {
|
||||
async flipCard(playerId, position, newCardData, duration = null) {
|
||||
// Use centralized timing if not specified
|
||||
if (duration === null) {
|
||||
duration = window.TIMING?.cardManager?.flipDuration || 400;
|
||||
}
|
||||
const cardInfo = this.getHandCard(playerId, position);
|
||||
if (!cardInfo) return;
|
||||
|
||||
@@ -158,7 +186,11 @@ class CardManager {
|
||||
}
|
||||
|
||||
// Animate a swap: hand card goes to discard, new card comes to hand
|
||||
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
|
||||
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = null) {
|
||||
// Use centralized timing if not specified
|
||||
if (duration === null) {
|
||||
duration = window.TIMING?.cardManager?.moveDuration || 250;
|
||||
}
|
||||
const cardInfo = this.getHandCard(playerId, position);
|
||||
if (!cardInfo) return;
|
||||
|
||||
@@ -192,7 +224,8 @@ class CardManager {
|
||||
}
|
||||
|
||||
inner.classList.remove('flipped');
|
||||
await this.delay(400);
|
||||
const flipDuration = window.TIMING?.cardManager?.flipDuration || 400;
|
||||
await this.delay(flipDuration);
|
||||
}
|
||||
|
||||
// Step 2: Move card to discard
|
||||
@@ -202,7 +235,8 @@ class CardManager {
|
||||
cardEl.classList.remove('moving');
|
||||
|
||||
// Pause to show the discarded card
|
||||
await this.delay(250);
|
||||
const pauseDuration = window.TIMING?.cardManager?.moveDuration || 250;
|
||||
await this.delay(pauseDuration);
|
||||
|
||||
// Step 3: Update card to show new card and move back to hand
|
||||
front.className = 'card-face card-face-front';
|
||||
|
||||
@@ -282,7 +282,11 @@
|
||||
</div>
|
||||
<div class="header-col header-col-center">
|
||||
<div id="status-message" class="status-message"></div>
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||
<span class="final-turn-icon">⚡</span>
|
||||
<span class="final-turn-text">FINAL TURN</span>
|
||||
<span class="final-turn-remaining"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-col header-col-right">
|
||||
<span id="game-username" class="game-username hidden"></span>
|
||||
|
||||
@@ -114,7 +114,8 @@ class StateDiffer {
|
||||
|
||||
movements.push({
|
||||
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
|
||||
playerId: currentPlayerId
|
||||
playerId: currentPlayerId,
|
||||
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// Edit these values to tune the feel of card animations and CPU gameplay
|
||||
|
||||
const TIMING = {
|
||||
// Card animations (milliseconds) - smooth, unhurried
|
||||
// Card animations (milliseconds)
|
||||
card: {
|
||||
flip: 400, // Card flip duration (must match CSS transition)
|
||||
move: 400, // Card movement - slower = smoother
|
||||
lift: 0, // No lift pause
|
||||
moving: 400, // Card moving class duration
|
||||
flip: 320, // Card flip duration — readable but snappy
|
||||
move: 300, // General card movement
|
||||
lift: 100, // Perceptible lift before travel
|
||||
settle: 80, // Gentle landing cushion
|
||||
},
|
||||
|
||||
// Pauses - minimal, let animations flow
|
||||
@@ -46,10 +46,12 @@ const TIMING = {
|
||||
// Anime.js animation configuration
|
||||
anime: {
|
||||
easing: {
|
||||
flip: 'easeInOutQuad',
|
||||
move: 'easeOutCubic',
|
||||
lift: 'easeOutQuad',
|
||||
pulse: 'easeInOutSine',
|
||||
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
|
||||
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
|
||||
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
|
||||
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
|
||||
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
|
||||
pulse: 'easeInOutSine', // Keep for loops
|
||||
},
|
||||
loop: {
|
||||
turnPulse: { duration: 2000 },
|
||||
@@ -60,8 +62,8 @@ const TIMING = {
|
||||
|
||||
// Card manager specific
|
||||
cardManager: {
|
||||
flipDuration: 400, // Card flip animation
|
||||
moveDuration: 400, // Card move animation
|
||||
flipDuration: 320, // Card flip animation
|
||||
moveDuration: 300, // Card move animation
|
||||
},
|
||||
|
||||
// V3_02: Dealing animation
|
||||
@@ -108,9 +110,22 @@ const TIMING = {
|
||||
|
||||
// V3_11: Physical swap animation
|
||||
swap: {
|
||||
lift: 80, // Time to lift cards
|
||||
arc: 280, // Time for arc travel
|
||||
settle: 60, // Time to settle into place
|
||||
lift: 100, // Time to lift cards — visible pickup
|
||||
arc: 320, // Time for arc travel
|
||||
settle: 100, // Time to settle into place — with overshoot easing
|
||||
},
|
||||
|
||||
// Draw animation durations (replaces hardcoded values in card-animations.js)
|
||||
draw: {
|
||||
deckLift: 120, // Lift off deck before travel
|
||||
deckMove: 250, // Travel to holding position
|
||||
deckRevealPause: 80, // Brief pause before flip (easing does the rest)
|
||||
deckFlip: 320, // Flip to reveal drawn card
|
||||
deckViewPause: 120, // Time to see revealed card
|
||||
discardLift: 80, // Quick grab from discard
|
||||
discardMove: 200, // Travel to holding position
|
||||
discardViewPause: 60, // Brief settle after arrival
|
||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||
},
|
||||
|
||||
// Player swap animation steps - smooth continuous motion
|
||||
|
||||
Reference in New Issue
Block a user