diff --git a/client/app.js b/client/app.js
index ae2713b..086e1aa 100644
--- a/client/app.js
+++ b/client/app.js
@@ -34,6 +34,8 @@ class GolfGame {
this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null;
this.swapAnimationFront = null;
+ this.swapAnimationContentSet = false;
+ this.pendingSwapData = null;
this.pendingGameState = null;
// Track cards we've locally flipped (for immediate feedback during selection)
@@ -51,11 +53,19 @@ class GolfGame {
// Track local discard animation in progress (prevent renderGame from updating discard)
this.localDiscardAnimating = false;
+ // Track opponent discard animation in progress (prevent renderGame from updating discard)
+ this.opponentDiscardAnimating = false;
+
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
+ // V3_15: Discard pile history
+ this.discardHistory = [];
+ this.maxDiscardHistory = 5;
+
this.initElements();
this.initAudio();
+ this.initCardTooltips();
this.bindEvents();
this.checkUrlParams();
}
@@ -102,12 +112,15 @@ class GolfGame {
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05);
} else if (type === 'card') {
- oscillator.frequency.setValueAtTime(800, ctx.currentTime);
- oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08);
+ // V3_16: Card place with variation + noise
+ const pitchVar = 1 + (Math.random() - 0.5) * 0.1;
+ oscillator.frequency.setValueAtTime(800 * pitchVar, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(400 * pitchVar, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.08);
+ this.playNoiseBurst(ctx, 0.02, 0.03);
} else if (type === 'success') {
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
@@ -116,14 +129,17 @@ class GolfGame {
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} else if (type === 'flip') {
- // Sharp quick click for card flips
+ // V3_16: Enhanced sharp snap with noise texture + pitch variation
+ const pitchVar = 1 + (Math.random() - 0.5) * 0.15;
oscillator.type = 'square';
- oscillator.frequency.setValueAtTime(1800, ctx.currentTime);
- oscillator.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02);
+ oscillator.frequency.setValueAtTime(1800 * pitchVar, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(600 * pitchVar, ctx.currentTime + 0.02);
gainNode.gain.setValueAtTime(0.12, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.025);
+ // Add noise burst for paper texture
+ this.playNoiseBurst(ctx, 0.03, 0.02);
} else if (type === 'shuffle') {
// Multiple quick sounds to simulate shuffling
for (let i = 0; i < 8; i++) {
@@ -149,6 +165,92 @@ class GolfGame {
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.12);
+ } else if (type === 'alert') {
+ // Rising triad for final turn announcement
+ oscillator.type = 'triangle';
+ oscillator.frequency.setValueAtTime(523, ctx.currentTime); // C5
+ oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
+ oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
+ gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.4);
+ } else if (type === 'pair') {
+ // Two-tone ding for column pair match
+ const osc2 = ctx.createOscillator();
+ osc2.connect(gainNode);
+ oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
+ osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
+ gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
+ oscillator.start(ctx.currentTime);
+ osc2.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.3);
+ osc2.stop(ctx.currentTime + 0.3);
+ } else if (type === 'draw-deck') {
+ // Mysterious slide + rise for unknown card
+ oscillator.type = 'triangle';
+ oscillator.frequency.setValueAtTime(300, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
+ oscillator.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
+ gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.2);
+ } else if (type === 'draw-discard') {
+ // Quick decisive grab sound
+ oscillator.type = 'square';
+ oscillator.frequency.setValueAtTime(600, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
+ gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.06);
+ } else if (type === 'knock') {
+ // Dramatic low thud for knock early
+ oscillator.type = 'sine';
+ oscillator.frequency.setValueAtTime(80, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
+ gainNode.gain.setValueAtTime(0.4, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.2);
+ // Secondary impact
+ setTimeout(() => {
+ const osc2 = ctx.createOscillator();
+ const gain2 = ctx.createGain();
+ osc2.connect(gain2);
+ gain2.connect(ctx.destination);
+ osc2.type = 'sine';
+ osc2.frequency.setValueAtTime(60, ctx.currentTime);
+ gain2.gain.setValueAtTime(0.2, ctx.currentTime);
+ gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
+ osc2.start(ctx.currentTime);
+ osc2.stop(ctx.currentTime + 0.1);
+ }, 100);
+ }
+ }
+
+ // V3_16: Noise burst for realistic card texture
+ playNoiseBurst(ctx, volume, duration) {
+ try {
+ const bufferSize = Math.floor(ctx.sampleRate * duration);
+ const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
+ const output = buffer.getChannelData(0);
+ for (let i = 0; i < bufferSize; i++) {
+ output[i] = Math.random() * 2 - 1;
+ }
+ const noise = ctx.createBufferSource();
+ noise.buffer = buffer;
+ const noiseGain = ctx.createGain();
+ noise.connect(noiseGain);
+ noiseGain.connect(ctx.destination);
+ noiseGain.gain.setValueAtTime(volume, ctx.currentTime);
+ noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
+ noise.start(ctx.currentTime);
+ noise.stop(ctx.currentTime + duration);
+ } catch (e) {
+ // Noise burst is optional, don't break if it fails
}
}
@@ -162,6 +264,103 @@ class GolfGame {
return new Promise(resolve => setTimeout(resolve, ms));
}
+ // --- V3_13: Card Value Tooltips ---
+
+ initCardTooltips() {
+ this.tooltip = document.createElement('div');
+ this.tooltip.className = 'card-value-tooltip hidden';
+ document.body.appendChild(this.tooltip);
+ this.tooltipTimeout = null;
+ }
+
+ bindCardTooltipEvents(cardElement, cardData) {
+ if (!cardData?.face_up || !cardData?.rank) return;
+
+ // Desktop hover with delay
+ cardElement.addEventListener('mouseenter', () => {
+ this.scheduleTooltip(cardElement, cardData);
+ });
+ cardElement.addEventListener('mouseleave', () => {
+ this.hideCardTooltip();
+ });
+
+ // Mobile long-press
+ let pressTimer = null;
+ cardElement.addEventListener('touchstart', () => {
+ pressTimer = setTimeout(() => {
+ this.showCardTooltip(cardElement, cardData);
+ }, 400);
+ }, { passive: true });
+ cardElement.addEventListener('touchend', () => {
+ clearTimeout(pressTimer);
+ this.hideCardTooltip();
+ });
+ cardElement.addEventListener('touchmove', () => {
+ clearTimeout(pressTimer);
+ this.hideCardTooltip();
+ }, { passive: true });
+ }
+
+ scheduleTooltip(cardElement, cardData) {
+ this.hideCardTooltip();
+ if (!cardData?.face_up || !cardData?.rank) return;
+ this.tooltipTimeout = setTimeout(() => {
+ this.showCardTooltip(cardElement, cardData);
+ }, 500);
+ }
+
+ showCardTooltip(cardElement, cardData) {
+ if (!cardData?.face_up || !cardData?.rank) return;
+ if (this.swapAnimationInProgress) return;
+ // Only show tooltips on your turn
+ if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
+
+ const value = this.getCardPointValue(cardData);
+ const special = this.getCardSpecialNote(cardData);
+
+ let content = `${value} pts`;
+ if (special) {
+ content += `${special}`;
+ }
+ this.tooltip.innerHTML = content;
+ this.tooltip.classList.remove('hidden');
+
+ // Position below card
+ const rect = cardElement.getBoundingClientRect();
+ let left = rect.left + rect.width / 2;
+ let top = rect.bottom + 8;
+
+ // Keep on screen
+ if (top + 50 > window.innerHeight) {
+ top = rect.top - 50;
+ }
+ left = Math.max(40, Math.min(window.innerWidth - 40, left));
+
+ this.tooltip.style.left = `${left}px`;
+ this.tooltip.style.top = `${top}px`;
+ }
+
+ hideCardTooltip() {
+ clearTimeout(this.tooltipTimeout);
+ if (this.tooltip) this.tooltip.classList.add('hidden');
+ }
+
+ getCardPointValue(cardData) {
+ const values = this.gameState?.card_values || this.getDefaultCardValues();
+ return values[cardData.rank] ?? 0;
+ }
+
+ getCardSpecialNote(cardData) {
+ const rank = cardData.rank;
+ const value = this.getCardPointValue(cardData);
+ if (value < 0) return 'Negative - keep it!';
+ if (rank === 'K' && value === 0) return 'Safe card';
+ if (rank === 'K' && value === -2) return 'Super King!';
+ if (rank === '10' && value === 1) return 'Ten Penny rule';
+ if ((rank === 'J' || rank === 'Q') && value >= 10) return 'High - replace if possible';
+ return null;
+ }
+
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
@@ -463,13 +662,15 @@ class GolfGame {
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.drawPulseAnimation = false;
+ // V3_15: Clear discard history for new round
+ this.clearDiscardHistory();
// Cancel any running animations from previous round
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
- this.playSound('shuffle');
this.showGameScreen();
- this.renderGame();
+ // V3_02: Animate dealing instead of instant render
+ this.runDealAnimation();
break;
case 'game_state':
@@ -496,6 +697,19 @@ class GolfGame {
hasDrawn: newState.has_drawn_card
});
+ // V3_03: Intercept round_over transition to defer card reveals
+ const roundJustEnded = oldState?.phase !== 'round_over' &&
+ newState.phase === 'round_over';
+
+ if (roundJustEnded && oldState) {
+ // Save pre-reveal state for the reveal animation
+ this.preRevealState = JSON.parse(JSON.stringify(oldState));
+ this.postRevealState = newState;
+ // Update state but DON'T render yet - reveal animation will handle it
+ this.gameState = newState;
+ break;
+ }
+
// Update state FIRST (always)
this.gameState = newState;
@@ -516,11 +730,24 @@ class GolfGame {
}
// Render immediately with new state
+ console.log('[DEBUG] About to renderGame, flags:', {
+ isDrawAnimating: this.isDrawAnimating,
+ localDiscardAnimating: this.localDiscardAnimating,
+ opponentDiscardAnimating: this.opponentDiscardAnimating,
+ opponentSwapAnimation: !!this.opponentSwapAnimation,
+ discardTop: newState.discard_top ? `${newState.discard_top.rank}-${newState.discard_top.suit}` : 'none'
+ });
this.renderGame();
break;
case 'your_turn':
- // Brief delay to let animations settle
+ // Clear any stale opponent animation flags since it's now our turn
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ console.log('[DEBUG] your_turn received - clearing opponent animation flags');
+ // Immediately update display to show correct discard pile
+ this.renderGame();
+ // Brief delay to let animations settle before showing toast
setTimeout(() => {
// Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action;
@@ -549,6 +776,9 @@ class GolfGame {
if (data.source === 'deck' && window.drawAnimations) {
// Deck draw: use shared animation system (flip at deck, move to hold)
// Hide held card during animation - animation callback will show it
+ // Clear any stale opponent animation flags since it's now our turn
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
this.isDrawAnimating = true;
this.hideDrawnCard();
window.drawAnimations.animateDrawDeck(data.card, () => {
@@ -560,6 +790,9 @@ class GolfGame {
// Discard draw: use shared animation system (lift and move)
this.isDrawAnimating = true;
this.hideDrawnCard();
+ // Clear any in-progress swap animation to prevent race conditions
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
window.drawAnimations.animateDrawDiscard(data.card, () => {
this.isDrawAnimating = false;
this.displayHeldCard(data.card, true);
@@ -585,7 +818,8 @@ class GolfGame {
break;
case 'round_over':
- this.showScoreboard(data.scores, false, data.rankings);
+ // V3_03: Run dramatic reveal before showing scoreboard
+ this.runRoundEndReveal(data.scores, data.rankings);
break;
case 'game_over':
@@ -886,6 +1120,13 @@ class GolfGame {
this.hideToast();
this.discardBtn.classList.add('hidden');
+ // Capture the actual position of the held card before hiding it
+ const heldRect = this.heldCardFloating.getBoundingClientRect();
+
+ // Hide the floating held card immediately (animation will create its own)
+ this.heldCardFloating.classList.add('hidden');
+ this.heldCardFloating.style.cssText = '';
+
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
@@ -896,56 +1137,19 @@ class GolfGame {
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true;
- // Swoop animation: deck → discard (card is always held over deck)
- this.animateDeckToDiscardSwoop(discardedCard);
- }
-
- // Swoop animation for discarding a card drawn from deck
- animateDeckToDiscardSwoop(card) {
- const deckRect = this.deck.getBoundingClientRect();
- const discardRect = this.discard.getBoundingClientRect();
- const floater = this.heldCardFloating;
-
- // Reset any previous animation state
- floater.classList.remove('dropping', 'swooping', 'landed');
-
- // Instantly position at deck (card appears to come from deck)
- floater.style.transition = 'none';
- floater.style.left = `${deckRect.left}px`;
- floater.style.top = `${deckRect.top}px`;
- floater.style.width = `${deckRect.width}px`;
- floater.style.height = `${deckRect.height}px`;
- floater.style.transform = 'scale(1) rotate(0deg)';
-
- // Force reflow
- floater.offsetHeight;
-
- // Start swoop to discard
- floater.style.transition = '';
- floater.classList.add('swooping');
- floater.style.left = `${discardRect.left}px`;
- floater.style.top = `${discardRect.top}px`;
- floater.style.width = `${discardRect.width}px`;
- floater.style.height = `${discardRect.height}px`;
-
- this.playSound('card');
-
- // After swoop completes, settle and show on discard pile
- setTimeout(() => {
- floater.classList.add('landed');
-
- setTimeout(() => {
- floater.classList.add('hidden');
- floater.classList.remove('swooping', 'landed');
- // Clear all inline styles from the animation
- floater.style.cssText = '';
- this.updateDiscardPileDisplay(card);
+ // Animate held card to discard using anime.js
+ if (window.cardAnimations) {
+ window.cardAnimations.animateHeldToDiscard(discardedCard, heldRect, () => {
+ this.updateDiscardPileDisplay(discardedCard);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
- // Allow renderGame to update discard again
this.localDiscardAnimating = false;
- }, 150); // Brief settle
- }, 350); // Match swoop transition duration
+ });
+ } else {
+ // Fallback: just update immediately
+ this.updateDiscardPileDisplay(discardedCard);
+ this.localDiscardAnimating = false;
+ }
}
// Update the discard pile display with a card
@@ -1033,134 +1237,131 @@ class GolfGame {
const myData = this.getMyPlayerData();
const card = myData?.cards[position];
const isAlreadyFaceUp = card?.face_up;
-
- // Get positions
const handRect = handCardEl.getBoundingClientRect();
-
- // Set up the animated card at hand position
- const swapCard = this.swapCardFromHand;
- if (!swapCard) {
- // Animation element missing - fall back to non-animated swap
- console.error('Swap animation element missing, falling back to direct swap');
- this.swapCard(position);
- return;
- }
- const swapCardFront = swapCard.querySelector('.swap-card-front');
-
- // Position at the hand card location
- swapCard.style.left = handRect.left + 'px';
- swapCard.style.top = handRect.top + 'px';
- swapCard.style.width = handRect.width + 'px';
- swapCard.style.height = handRect.height + 'px';
-
- // Reset state - no moving class needed
- swapCard.classList.remove('flipping', 'moving');
- swapCardFront.innerHTML = '';
- swapCardFront.className = 'swap-card-front';
+ const heldRect = this.heldCardFloating?.getBoundingClientRect();
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
+ // Hide originals during animation
+ handCardEl.classList.add('swap-out');
+ if (this.heldCardFloating) {
+ this.heldCardFloating.style.visibility = 'hidden';
+ }
+
+ // Store drawn card data before clearing
+ const drawnCardData = this.drawnCard;
+ this.drawnCard = null;
+ this.skipNextDiscardFlip = true;
+
+ // Send swap to server
+ this.send({ type: 'swap', position });
+
if (isAlreadyFaceUp && card) {
- // FACE-UP CARD: Subtle pulse animation (no flip needed)
+ // Face-up card - we know both cards, animate immediately
this.swapAnimationContentSet = true;
- // Apply subtle swap pulse using anime.js
if (window.cardAnimations) {
- window.cardAnimations.pulseSwap(handCardEl);
- window.cardAnimations.pulseSwap(this.heldCardFloating);
+ window.cardAnimations.animateUnifiedSwap(
+ card, // handCardData - card going to discard
+ drawnCardData, // heldCardData - drawn card going to hand
+ handRect, // handRect
+ heldRect, // heldRect
+ {
+ rotation: 0,
+ wasHandFaceDown: false,
+ onComplete: () => {
+ handCardEl.classList.remove('swap-out');
+ if (this.heldCardFloating) {
+ this.heldCardFloating.style.visibility = '';
+ }
+ this.completeSwapAnimation(null);
+ }
+ }
+ );
+ } else {
+ setTimeout(() => {
+ handCardEl.classList.remove('swap-out');
+ if (this.heldCardFloating) {
+ this.heldCardFloating.style.visibility = '';
+ }
+ this.completeSwapAnimation(null);
+ }, 500);
}
-
- // Send swap and let render handle the update
- this.send({ type: 'swap', position });
- this.drawnCard = null;
- this.skipNextDiscardFlip = true;
-
- // Complete after pulse animation
- setTimeout(() => {
- this.completeSwapAnimation(null);
- }, 440);
} else {
- // FACE-DOWN CARD: Flip in place to reveal, then teleport
-
- // Hide the actual hand card
- handCardEl.classList.add('swap-out');
- this.swapAnimation.classList.remove('hidden');
-
- // Store references for updateSwapAnimation
- this.swapAnimationFront = swapCardFront;
- this.swapAnimationCard = swapCard;
+ // Face-down card - wait for server to tell us what the card was
+ // Store context for updateSwapAnimation to use
this.swapAnimationContentSet = false;
-
- // Send swap - the flip will happen in updateSwapAnimation when server responds
- this.send({ type: 'swap', position });
- this.drawnCard = null;
- this.skipNextDiscardFlip = true;
+ this.pendingSwapData = {
+ handCardEl,
+ handRect,
+ heldRect,
+ drawnCardData,
+ position
+ };
}
}
// Update the animated card with actual card content when server responds
updateSwapAnimation(card) {
- // Safety: if animation references are missing, complete immediately to avoid freeze
- if (!this.swapAnimationFront || !card) {
- if (this.swapAnimationInProgress && !this.swapAnimationContentSet) {
- console.error('Swap animation incomplete: missing front element or card data');
- this.completeSwapAnimation(null);
- }
- return;
- }
-
// Skip if we already set the content (face-up card swap)
if (this.swapAnimationContentSet) return;
- // Set card color class
- this.swapAnimationFront.className = 'swap-card-front';
- if (card.rank === '★') {
- this.swapAnimationFront.classList.add('joker');
- const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
- this.swapAnimationFront.innerHTML = `${jokerIcon}Joker`;
- } else {
- if (card.suit === 'hearts' || card.suit === 'diamonds') {
- this.swapAnimationFront.classList.add('red');
- } else {
- this.swapAnimationFront.classList.add('black');
- }
- this.swapAnimationFront.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
+ // Safety check
+ if (!this.swapAnimationInProgress || !card) {
+ return;
}
+ // Now we have the card data - run the unified animation
this.swapAnimationContentSet = true;
- // Quick flip to reveal, then complete - server will pause before next turn
- if (this.swapAnimationCard) {
- const swapCardInner = this.swapAnimationCard.querySelector('.swap-card-inner');
- const flipDuration = 245; // Match other flip durations
-
- this.playSound('flip');
-
- // Use anime.js for the flip animation
- anime({
- targets: swapCardInner,
- rotateY: [0, 180],
- duration: flipDuration,
- easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
- complete: () => {
- swapCardInner.style.transform = '';
- // Brief pause to see the card, then complete
- setTimeout(() => {
- this.completeSwapAnimation(null);
- }, 100);
- }
- });
- } else {
- // Fallback: animation element missing, complete immediately to avoid freeze
- console.error('Swap animation element missing, completing immediately');
+ const data = this.pendingSwapData;
+ if (!data) {
+ console.error('Swap animation missing pending data');
this.completeSwapAnimation(null);
+ return;
+ }
+
+ const { handCardEl, handRect, heldRect, drawnCardData } = data;
+
+ if (window.cardAnimations) {
+ window.cardAnimations.animateUnifiedSwap(
+ card, // handCardData - now we know what it was
+ drawnCardData, // heldCardData - drawn card going to hand
+ handRect, // handRect
+ heldRect, // heldRect
+ {
+ rotation: 0,
+ wasHandFaceDown: true,
+ onComplete: () => {
+ if (handCardEl) handCardEl.classList.remove('swap-out');
+ if (this.heldCardFloating) {
+ this.heldCardFloating.style.visibility = '';
+ }
+ this.pendingSwapData = null;
+ this.completeSwapAnimation(null);
+ }
+ }
+ );
+ } else {
+ // Fallback
+ setTimeout(() => {
+ if (handCardEl) handCardEl.classList.remove('swap-out');
+ if (this.heldCardFloating) {
+ this.heldCardFloating.style.visibility = '';
+ }
+ this.pendingSwapData = null;
+ this.completeSwapAnimation(null);
+ }, 500);
}
}
completeSwapAnimation(heldCard) {
+ // Guard against double completion
+ if (!this.swapAnimationInProgress) return;
+
// Hide everything
this.swapAnimation.classList.add('hidden');
if (this.swapAnimationCard) {
@@ -1180,6 +1381,8 @@ class GolfGame {
this.swapAnimationDiscardRect = null;
this.swapAnimationHandCardEl = null;
this.swapAnimationHandRect = null;
+ this.swapAnimationContentSet = false;
+ this.pendingSwapData = null;
this.discardBtn.classList.add('hidden');
this.heldCardFloating.classList.add('hidden');
@@ -1205,12 +1408,465 @@ class GolfGame {
}
knockEarly() {
- // Flip all remaining face-down cards to go out early
+ // V3_09: Knock early with confirmation dialog
if (!this.gameState || !this.gameState.knock_early) return;
+
+ const myData = this.getMyPlayerData();
+ if (!myData) return;
+ const hiddenCards = myData.cards.filter(c => !c.face_up);
+ if (hiddenCards.length === 0 || hiddenCards.length > 2) return;
+
+ this.showKnockConfirmation(hiddenCards.length, () => {
+ this.executeKnockEarly();
+ });
+ }
+
+ showKnockConfirmation(hiddenCount, onConfirm) {
+ const modal = document.createElement('div');
+ modal.className = 'knock-confirm-modal';
+ modal.innerHTML = `
+
+
⚡
+
Knock Early?
+
You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.
+
This cannot be undone!
+
+
+
+
+
+ `;
+ document.body.appendChild(modal);
+
+ modal.querySelector('.knock-cancel').addEventListener('click', () => {
+ this.playSound('click');
+ modal.remove();
+ });
+ modal.querySelector('.knock-confirm').addEventListener('click', () => {
+ this.playSound('click');
+ modal.remove();
+ onConfirm();
+ });
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) modal.remove();
+ });
+ }
+
+ async executeKnockEarly() {
+ this.playSound('knock');
+
+ const myData = this.getMyPlayerData();
+ if (!myData) return;
+ const hiddenPositions = myData.cards
+ .map((card, i) => ({ card, position: i }))
+ .filter(({ card }) => !card.face_up)
+ .map(({ position }) => position);
+
+ // Rapid sequential flips
+ for (const position of hiddenPositions) {
+ this.fireLocalFlipAnimation(position, myData.cards[position]);
+ this.playSound('flip');
+ await this.delay(150);
+ }
+ await this.delay(300);
+
+ this.showKnockBanner();
+
this.send({ type: 'knock_early' });
this.hideToast();
}
+ showKnockBanner(playerName) {
+ const banner = document.createElement('div');
+ banner.className = 'knock-banner';
+ banner.innerHTML = `${playerName ? playerName + ' knocked!' : 'KNOCK!'}`;
+ document.body.appendChild(banner);
+
+ document.body.classList.add('screen-shake');
+
+ setTimeout(() => {
+ banner.classList.add('fading');
+ document.body.classList.remove('screen-shake');
+ }, 800);
+ setTimeout(() => {
+ banner.remove();
+ }, 1100);
+ }
+
+ // --- V3_02: Dealing Animation ---
+
+ runDealAnimation() {
+ // Respect reduced motion preference
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ this.playSound('shuffle');
+ this.renderGame();
+ return;
+ }
+
+ // Render first so card slot positions exist
+ this.renderGame();
+
+ // Hide cards during animation
+ this.playerCards.style.visibility = 'hidden';
+ this.opponentsRow.style.visibility = 'hidden';
+
+ if (window.cardAnimations) {
+ window.cardAnimations.animateDealing(
+ this.gameState,
+ (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
+ () => {
+ // Show real cards
+ this.playerCards.style.visibility = 'visible';
+ this.opponentsRow.style.visibility = 'visible';
+ this.renderGame();
+ // Stagger opponent initial flips right after dealing
+ this.animateOpponentInitialFlips();
+ }
+ );
+ } else {
+ // Fallback
+ this.playerCards.style.visibility = 'visible';
+ this.opponentsRow.style.visibility = 'visible';
+ this.playSound('shuffle');
+ }
+ }
+
+ animateOpponentInitialFlips() {
+ const T = window.TIMING?.initialFlips || {};
+ const windowStart = T.windowStart || 500;
+ const windowEnd = T.windowEnd || 2500;
+ const cardStagger = T.cardStagger || 400;
+
+ const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
+
+ // Collect face-up cards per opponent and convert them to show backs
+ for (const player of opponents) {
+ const area = this.opponentsRow.querySelector(
+ `.opponent-area[data-player-id="${player.id}"]`
+ );
+ if (!area) continue;
+
+ const cardEls = area.querySelectorAll('.card-grid .card');
+ const faceUpCards = [];
+ player.cards.forEach((card, idx) => {
+ if (card.face_up && cardEls[idx]) {
+ const el = cardEls[idx];
+ faceUpCards.push({ el, card, idx });
+ // Convert to card-back appearance while waiting
+ el.className = 'card card-back';
+ if (this.gameState?.deck_colors) {
+ const deckId = card.deck_id || 0;
+ const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
+ if (color) el.classList.add(`back-${color}`);
+ }
+ el.innerHTML = '';
+ }
+ });
+
+ if (faceUpCards.length > 0) {
+ const rotation = this.getElementRotation(area);
+ // Each opponent starts at a random time within the window (concurrent, not sequential)
+ const startDelay = windowStart + Math.random() * (windowEnd - windowStart);
+
+ setTimeout(() => {
+ faceUpCards.forEach(({ el, card }, i) => {
+ setTimeout(() => {
+ window.cardAnimations.animateOpponentFlip(el, card, rotation);
+ }, i * cardStagger);
+ });
+ }, startDelay);
+ }
+ }
+ }
+
+ getCardSlotRect(playerId, cardIdx) {
+ if (playerId === this.playerId) {
+ const cards = this.playerCards.querySelectorAll('.card');
+ return cards[cardIdx]?.getBoundingClientRect() || null;
+ } else {
+ const area = this.opponentsRow.querySelector(
+ `.opponent-area[data-player-id="${playerId}"]`
+ );
+ if (area) {
+ const cards = area.querySelectorAll('.card');
+ return cards[cardIdx]?.getBoundingClientRect() || null;
+ }
+ }
+ return null;
+ }
+
+ // --- V3_03: Round End Dramatic Reveal ---
+
+ async runRoundEndReveal(scores, rankings) {
+ const T = window.TIMING?.reveal || {};
+ const oldState = this.preRevealState;
+ const newState = this.postRevealState || this.gameState;
+
+ if (!oldState || !newState) {
+ // Fallback: show scoreboard immediately
+ this.showScoreboard(scores, false, rankings);
+ return;
+ }
+
+ // First, render the game with the OLD state (pre-reveal) so cards show face-down
+ this.gameState = newState;
+ // But render with pre-reveal card visuals
+ this.revealAnimationInProgress = true;
+
+ // Render game to show current layout (opponents, etc)
+ this.renderGame();
+
+ // Compute what needs revealing
+ const revealsByPlayer = this.getCardsToReveal(oldState, newState);
+
+ // Get reveal order: knocker first, then clockwise
+ const knockerId = newState.finisher_id;
+ const revealOrder = this.getRevealOrder(newState.players, knockerId);
+
+ // Initial pause
+ this.setStatus('Revealing cards...', 'reveal');
+ await this.delay(T.initialPause || 500);
+
+ // Reveal each player's cards
+ for (const player of revealOrder) {
+ const cardsToFlip = revealsByPlayer.get(player.id) || [];
+ if (cardsToFlip.length === 0) continue;
+
+ // Highlight player area
+ this.highlightPlayerArea(player.id, true);
+ await this.delay(T.highlightDuration || 200);
+
+ // Flip each card with stagger
+ for (const { position, card } of cardsToFlip) {
+ this.animateRevealFlip(player.id, position, card);
+ await this.delay(T.cardStagger || 100);
+ }
+
+ // Wait for last flip to complete + pause
+ await this.delay(300 + (T.playerPause || 400));
+
+ // Remove highlight
+ this.highlightPlayerArea(player.id, false);
+ }
+
+ // All revealed - run score tally before showing scoreboard
+ this.revealAnimationInProgress = false;
+ this.preRevealState = null;
+ this.postRevealState = null;
+ this.renderGame();
+
+ // V3_07: Animated score tallying
+ if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ await this.runScoreTally(newState.players, knockerId);
+ }
+
+ this.showScoreboard(scores, false, rankings);
+ }
+
+ getCardsToReveal(oldState, newState) {
+ const reveals = new Map();
+
+ for (const newPlayer of newState.players) {
+ const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
+ if (!oldPlayer) continue;
+
+ const cardsToFlip = [];
+ for (let i = 0; i < 6; i++) {
+ const wasHidden = !oldPlayer.cards[i]?.face_up;
+ const nowVisible = newPlayer.cards[i]?.face_up;
+
+ if (wasHidden && nowVisible) {
+ cardsToFlip.push({
+ position: i,
+ card: newPlayer.cards[i]
+ });
+ }
+ }
+
+ if (cardsToFlip.length > 0) {
+ reveals.set(newPlayer.id, cardsToFlip);
+ }
+ }
+
+ return reveals;
+ }
+
+ getRevealOrder(players, knockerId) {
+ const knocker = players.find(p => p.id === knockerId);
+ const others = players.filter(p => p.id !== knockerId);
+
+ if (knocker) {
+ return [knocker, ...others];
+ }
+ return others;
+ }
+
+ highlightPlayerArea(playerId, highlight) {
+ if (playerId === this.playerId) {
+ this.playerArea.classList.toggle('revealing', highlight);
+ } else {
+ const area = this.opponentsRow.querySelector(
+ `.opponent-area[data-player-id="${playerId}"]`
+ );
+ if (area) {
+ area.classList.toggle('revealing', highlight);
+ }
+ }
+ }
+
+ animateRevealFlip(playerId, position, cardData) {
+ if (playerId === this.playerId) {
+ // Local player card
+ const cards = this.playerCards.querySelectorAll('.card');
+ const cardEl = cards[position];
+ if (cardEl && window.cardAnimations) {
+ window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
+ // Re-render this card to show revealed state
+ this.renderGame();
+ });
+ }
+ } else {
+ // Opponent card
+ const area = this.opponentsRow.querySelector(
+ `.opponent-area[data-player-id="${playerId}"]`
+ );
+ if (area) {
+ const cards = area.querySelectorAll('.card');
+ const cardEl = cards[position];
+ if (cardEl && window.cardAnimations) {
+ const rotation = this.getElementRotation(area);
+ window.cardAnimations.animateOpponentFlip(cardEl, cardData, rotation);
+ }
+ }
+ }
+ this.playSound('flip');
+ }
+
+ // --- V3_07: Animated Score Tallying ---
+
+ async runScoreTally(players, knockerId) {
+ const T = window.TIMING?.tally || {};
+ await this.delay(T.initialPause || 300);
+
+ const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
+
+ // Order: knocker first, then others
+ const ordered = [...players].sort((a, b) => {
+ if (a.id === knockerId) return -1;
+ if (b.id === knockerId) return 1;
+ return 0;
+ });
+
+ for (const player of ordered) {
+ const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
+ if (cards.length < 6) continue;
+
+ // Highlight player area
+ this.highlightPlayerArea(player.id, true);
+
+ let total = 0;
+ const columns = [[0, 3], [1, 4], [2, 5]];
+
+ for (const [topIdx, bottomIdx] of columns) {
+ const topData = player.cards[topIdx];
+ const bottomData = player.cards[bottomIdx];
+ const topCard = cards[topIdx];
+ const bottomCard = cards[bottomIdx];
+ const isPair = topData?.rank && bottomData?.rank && topData.rank === bottomData.rank;
+
+ if (isPair) {
+ // Just show pair cancel — no individual card values
+ topCard?.classList.add('tallying');
+ bottomCard?.classList.add('tallying');
+ this.showPairCancel(topCard, bottomCard);
+ await this.delay(T.pairCelebration || 400);
+ } else {
+ // Show individual card values
+ topCard?.classList.add('tallying');
+ const topValue = cardValues[topData?.rank] ?? 0;
+ const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
+ await this.delay(T.cardHighlight || 200);
+
+ bottomCard?.classList.add('tallying');
+ const bottomValue = cardValues[bottomData?.rank] ?? 0;
+ const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
+ await this.delay(T.cardHighlight || 200);
+
+ total += topValue + bottomValue;
+ this.hideCardValue(topOverlay);
+ this.hideCardValue(bottomOverlay);
+ }
+
+ topCard?.classList.remove('tallying');
+ bottomCard?.classList.remove('tallying');
+ await this.delay(T.columnPause || 150);
+ }
+
+ this.highlightPlayerArea(player.id, false);
+ await this.delay(T.playerPause || 500);
+ }
+ }
+
+ showCardValue(cardElement, value, isNegative) {
+ if (!cardElement) return null;
+ const overlay = document.createElement('div');
+ overlay.className = 'card-value-overlay';
+ if (isNegative) overlay.classList.add('negative');
+ if (value === 0) overlay.classList.add('zero');
+
+ const sign = value > 0 ? '+' : '';
+ overlay.textContent = `${sign}${value}`;
+
+ const rect = cardElement.getBoundingClientRect();
+ overlay.style.left = `${rect.left + rect.width / 2}px`;
+ overlay.style.top = `${rect.top + rect.height / 2}px`;
+
+ document.body.appendChild(overlay);
+ // Trigger reflow then animate in
+ void overlay.offsetWidth;
+ overlay.classList.add('visible');
+ return overlay;
+ }
+
+ hideCardValue(overlay) {
+ if (!overlay) return;
+ overlay.classList.remove('visible');
+ setTimeout(() => overlay.remove(), 200);
+ }
+
+ showPairCancel(card1, card2) {
+ if (!card1 || !card2) return;
+ const rect1 = card1.getBoundingClientRect();
+ const rect2 = card2.getBoundingClientRect();
+ const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
+ const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
+
+ const overlay = document.createElement('div');
+ overlay.className = 'pair-cancel-overlay';
+ overlay.textContent = 'PAIR! +0';
+ overlay.style.left = `${centerX}px`;
+ overlay.style.top = `${centerY}px`;
+ document.body.appendChild(overlay);
+
+ card1.classList.add('pair-matched');
+ card2.classList.add('pair-matched');
+
+ this.playSound('pair');
+
+ setTimeout(() => {
+ overlay.remove();
+ card1.classList.remove('pair-matched');
+ card2.classList.remove('pair-matched');
+ }, 600);
+ }
+
+ getDefaultCardValues() {
+ return {
+ 'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
+ '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
+ };
+ }
+
// Fire-and-forget animation triggers based on state changes
triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return;
@@ -1265,9 +1921,28 @@ class GolfGame {
};
if (discardWasTaken) {
- window.drawAnimations.animateDrawDiscard(drawnCard, onAnimComplete);
+ // Clear any in-progress animations to prevent race conditions
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ // Set isDrawAnimating to block renderGame from updating discard pile
+ this.isDrawAnimating = true;
+ console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
+ window.drawAnimations.animateDrawDiscard(drawnCard, () => {
+ console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
+ this.isDrawAnimating = false;
+ onAnimComplete();
+ });
} else {
- window.drawAnimations.animateDrawDeck(drawnCard, onAnimComplete);
+ // Clear any in-progress animations to prevent race conditions
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ this.isDrawAnimating = true;
+ console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true');
+ window.drawAnimations.animateDrawDeck(drawnCard, () => {
+ console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
+ this.isDrawAnimating = false;
+ onAnimComplete();
+ });
}
}
@@ -1282,8 +1957,21 @@ class GolfGame {
}
}
+ // V3_15: Track discard history
+ if (discardChanged && newDiscard) {
+ this.trackDiscardHistory(newDiscard);
+ }
+
+ // Track if we detected a draw this update - if so, skip STEP 2
+ // Drawing from discard changes the discard pile but isn't a "discard" action
+ const justDetectedDraw = justDrew && isOtherPlayerDrawing;
+ if (justDetectedDraw && discardChanged) {
+ console.log('[DEBUG] Skipping STEP 2 - discard change was from draw, not discard action');
+ }
+
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
- if (discardChanged && wasOtherPlayer) {
+ // Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one
+ if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
@@ -1336,13 +2024,11 @@ class GolfGame {
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
- } else if (swappedPosition < 0 && !cardsIdentical) {
- // Player drew and discarded without swapping
- // Only fire if cards actually differ (avoid race condition with split server updates)
- // Show animation for other players, just pulse for local player
+ } else if (swappedPosition < 0 && wasOtherPlayer) {
+ // Opponent drew and discarded without swapping (cards unchanged)
this.fireDiscardAnimation(newDiscard, previousPlayerId);
// Show CPU discard announcement
- if (wasOtherPlayer && oldPlayer?.is_cpu) {
+ if (oldPlayer?.is_cpu) {
this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
}
}
@@ -1351,6 +2037,25 @@ class GolfGame {
}
}
+ // V3_04: Check for new column pairs after any state change
+ this.checkForNewPairs(oldState, newState);
+
+ // V3_09: Detect opponent knock (phase transition to final_turn)
+ if (oldState.phase !== 'final_turn' && newState.phase === 'final_turn') {
+ const knocker = newState.players.find(p => p.id === newState.finisher_id);
+ if (knocker && knocker.id !== this.playerId) {
+ this.playSound('alert');
+ this.showKnockBanner(knocker.name);
+ }
+ // V3_14: Highlight relevant knock rules
+ if (this.gameState?.knock_penalty) {
+ this.highlightRule('knock_penalty', '+10 if beaten!');
+ }
+ if (this.gameState?.knock_bonus) {
+ this.highlightRule('knock_bonus', '-5 for going out!');
+ }
+ }
+
// Handle delayed card updates (server sends split updates: discard first, then cards)
// Check if opponent cards changed even when discard didn't change
if (!discardChanged && wasOtherPlayer && previousPlayerId) {
@@ -1384,6 +2089,115 @@ class GolfGame {
}
}
+ // --- V3_04: Column Pair Detection ---
+
+ checkForNewPairs(oldState, newState) {
+ if (!oldState || !newState) return;
+
+ const columns = [[0, 3], [1, 4], [2, 5]];
+
+ for (const newPlayer of newState.players) {
+ const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
+ if (!oldPlayer) continue;
+
+ for (const [top, bottom] of columns) {
+ const wasPaired = this.isColumnPaired(oldPlayer.cards, top, bottom);
+ const nowPaired = this.isColumnPaired(newPlayer.cards, top, bottom);
+
+ if (!wasPaired && nowPaired) {
+ // New pair formed!
+ setTimeout(() => {
+ this.firePairCelebration(newPlayer.id, top, bottom);
+ }, window.TIMING?.celebration?.pairDelay || 50);
+ }
+ }
+ }
+ }
+
+ isColumnPaired(cards, pos1, pos2) {
+ const c1 = cards[pos1];
+ const c2 = cards[pos2];
+ return c1?.face_up && c2?.face_up && c1?.rank && c2?.rank && c1.rank === c2.rank;
+ }
+
+ firePairCelebration(playerId, pos1, pos2) {
+ const elements = this.getCardElements(playerId, pos1, pos2);
+ if (elements.length < 2) return;
+
+ if (window.cardAnimations) {
+ window.cardAnimations.celebratePair(elements[0], elements[1]);
+ }
+ }
+
+ 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;
+ }
+
+ // V3_15: Track discard pile history
+ trackDiscardHistory(card) {
+ if (!card) return;
+ // Avoid duplicates at front
+ if (this.discardHistory.length > 0 &&
+ this.discardHistory[0].rank === card.rank &&
+ this.discardHistory[0].suit === card.suit) return;
+ this.discardHistory.unshift({ rank: card.rank, suit: card.suit });
+ if (this.discardHistory.length > this.maxDiscardHistory) {
+ this.discardHistory = this.discardHistory.slice(0, this.maxDiscardHistory);
+ }
+ this.updateDiscardDepth();
+ }
+
+ updateDiscardDepth() {
+ if (!this.discard) return;
+ const depth = Math.min(this.discardHistory.length, 3);
+ this.discard.dataset.depth = depth;
+ }
+
+ clearDiscardHistory() {
+ this.discardHistory = [];
+ if (this.discard) this.discard.dataset.depth = '0';
+ }
+
+ // V3_10: Render persistent pair indicators on all players' cards
+ renderPairIndicators() {
+ if (!this.gameState) return;
+ const columns = [[0, 3], [1, 4], [2, 5]];
+
+ for (const player of this.gameState.players) {
+ const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
+ if (cards.length < 6) continue;
+
+ // Clear previous pair classes
+ cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
+
+ for (const [top, bottom] of columns) {
+ if (this.isColumnPaired(player.cards, top, bottom)) {
+ cards[top]?.classList.add('paired', 'pair-top');
+ cards[bottom]?.classList.add('paired', 'pair-bottom');
+ }
+ }
+ }
+ }
+
// Flash animation on deck or discard pile to show where opponent drew from
// Defers held card display until pulse completes for clean sequencing
pulseDrawPile(source) {
@@ -1431,75 +2245,24 @@ class GolfGame {
// Only show animation for other players - local player already knows what they did
const isOtherPlayer = fromPlayerId && fromPlayerId !== this.playerId;
- if (isOtherPlayer && discardCard) {
- // Show card traveling from deck to discard pile
- this.animateDeckToDiscard(discardCard);
+ if (isOtherPlayer && discardCard && window.cardAnimations) {
+ // Block renderGame from updating discard during animation
+ this.opponentDiscardAnimating = true;
+ this.skipNextDiscardFlip = true;
+
+ // Update lastDiscardKey so renderGame won't see a "change" and trigger flip animation
+ this.lastDiscardKey = `${discardCard.rank}-${discardCard.suit}`;
+
+ // Animate card from hold → discard using anime.js
+ window.cardAnimations.animateOpponentDiscard(discardCard, () => {
+ this.opponentDiscardAnimating = false;
+ this.updateDiscardPileDisplay(discardCard);
+ this.pulseDiscardLand();
+ });
}
// Skip animation entirely for local player
}
- // Animate a card moving from deck to discard pile (for draw-and-discard by other players)
- animateDeckToDiscard(card) {
- const deckRect = this.deck.getBoundingClientRect();
- const discardRect = this.discard.getBoundingClientRect();
-
- // Create temporary card element
- const animCard = document.createElement('div');
- animCard.className = 'real-card anim-card';
- animCard.innerHTML = `
-
- `;
-
- // Position at deck
- animCard.style.position = 'fixed';
- animCard.style.left = `${deckRect.left}px`;
- animCard.style.top = `${deckRect.top}px`;
- animCard.style.width = `${deckRect.width}px`;
- animCard.style.height = `${deckRect.height}px`;
- animCard.style.zIndex = '1000';
- animCard.style.transition = `left ${window.TIMING?.card?.move || 270}ms ease-out, top ${window.TIMING?.card?.move || 270}ms ease-out`;
-
- const inner = animCard.querySelector('.card-inner');
- const front = animCard.querySelector('.card-face-front');
-
- // Start face-down (back showing)
- inner.classList.add('flipped');
-
- // Set up the front face content for the flip
- front.className = 'card-face card-face-front';
- if (card.rank === '★') {
- front.classList.add('joker');
- const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
- front.innerHTML = `${jokerIcon}Joker`;
- } else {
- front.classList.add(this.isRedSuit(card.suit) ? 'red' : 'black');
- front.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
- }
-
- document.body.appendChild(animCard);
-
- // Small delay then start moving and flipping
- setTimeout(() => {
- this.playSound('card');
- // Move to discard position
- animCard.style.left = `${discardRect.left}px`;
- animCard.style.top = `${discardRect.top}px`;
- // Flip to show face
- inner.classList.remove('flipped');
- }, 50);
-
- // Clean up after animation
- const moveDuration = window.TIMING?.card?.move || 270;
- const pauseAfter = window.TIMING?.pause?.afterDiscard || 550;
- setTimeout(() => {
- animCard.remove();
- this.pulseDiscardLand();
- }, moveDuration + pauseAfter);
- }
-
// Get rotation angle from an element's computed transform
getElementRotation(element) {
if (!element) return 0;
@@ -1517,104 +2280,71 @@ class GolfGame {
return 0;
}
- // Fire a swap animation (non-blocking) - flip in place at opponent's position
- // Uses flip-in-place for face-down cards, subtle pulse for face-up cards
+ // Fire a swap animation (non-blocking) - unified arc swap for all players
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
// Track this animation so renderGame can apply swap-out class
this.opponentSwapAnimation = { playerId, position };
// Find source position - the actual card that was swapped
- const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
+ const area = this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`);
let sourceRect = null;
let sourceCardEl = null;
let sourceRotation = 0;
- for (const area of opponentAreas) {
- const nameEl = area.querySelector('h4');
- const player = this.gameState?.players.find(p => p.id === playerId);
- if (nameEl && player && nameEl.textContent.includes(player.name)) {
- const cards = area.querySelectorAll('.card');
- if (cards.length > position && position >= 0) {
- sourceCardEl = cards[position];
- sourceRect = sourceCardEl.getBoundingClientRect();
- // Get rotation from the opponent area (parent has the arch rotation)
- sourceRotation = this.getElementRotation(area);
- }
- break;
+ if (area) {
+ const cards = area.querySelectorAll('.card-grid .card');
+ if (cards.length > position && position >= 0) {
+ sourceCardEl = cards[position];
+ sourceRect = sourceCardEl.getBoundingClientRect();
+ sourceRotation = this.getElementRotation(area);
}
}
- // Face-to-face swap: use subtle pulse on the card, no flip needed
- if (wasFaceUp && sourceCardEl) {
- if (window.cardAnimations) {
- window.cardAnimations.pulseSwap(sourceCardEl);
- }
- const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
- setTimeout(() => {
- // Pulse discard, then update turn indicator after pulse completes
- // Keep opponentSwapAnimation set during pulse so isVisuallyMyTurn stays correct
- this.pulseDiscardLand(() => {
- this.opponentSwapAnimation = null;
- this.renderGame();
- });
- }, pulseDuration);
+ // Get the held card data (what's being swapped IN to the hand)
+ const player = this.gameState?.players.find(p => p.id === playerId);
+ const newCardInHand = player?.cards[position];
+
+ // Safety check - need valid data for animation
+ if (!sourceRect || !discardCard || !newCardInHand) {
+ console.warn('fireSwapAnimation: missing data', { sourceRect: !!sourceRect, discardCard: !!discardCard, newCardInHand: !!newCardInHand });
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ this.renderGame();
return;
}
- // Face-down to face-up: flip to reveal, pause to see it, then pulse before swap
- if (!sourceRect) {
- // Fallback: just show flip at discard position
- const discardRect = this.discard.getBoundingClientRect();
- sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
- }
+ // Hide the source card during animation
+ sourceCardEl.classList.add('swap-out');
- const swapCard = this.swapCardFromHand;
- const swapCardFront = swapCard.querySelector('.swap-card-front');
-
- // Position at opponent's card location (flip in place there)
- swapCard.style.left = sourceRect.left + 'px';
- swapCard.style.top = sourceRect.top + 'px';
- swapCard.style.width = sourceRect.width + 'px';
- swapCard.style.height = sourceRect.height + 'px';
- swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
-
- // Apply source rotation to match the arch layout
- swapCard.style.transform = `rotate(${sourceRotation}deg)`;
-
- // Set card content (the card being discarded - what was hidden)
- swapCardFront.className = 'swap-card-front';
- if (discardCard.rank === '★') {
- swapCardFront.classList.add('joker');
- const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
- swapCardFront.innerHTML = `${jokerIcon}Joker`;
+ // Use unified swap animation
+ if (window.cardAnimations) {
+ window.cardAnimations.animateUnifiedSwap(
+ discardCard, // handCardData - card going to discard
+ newCardInHand, // heldCardData - card going to hand
+ sourceRect, // handRect - where the hand card is
+ null, // heldRect - use default holding position
+ {
+ rotation: sourceRotation,
+ wasHandFaceDown: !wasFaceUp,
+ onComplete: () => {
+ sourceCardEl.classList.remove('swap-out');
+ this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
+ this.renderGame();
+ }
+ }
+ );
} else {
- swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
- swapCardFront.innerHTML = `${discardCard.rank}
${this.getSuitSymbol(discardCard.suit)}`;
- }
-
- if (sourceCardEl) sourceCardEl.classList.add('swap-out');
- this.swapAnimation.classList.remove('hidden');
-
- // Use anime.js for the flip animation (CSS transitions removed)
- const flipDuration = 245; // Match other flip durations
- const swapCardInner = swapCard.querySelector('.swap-card-inner');
-
- this.playSound('flip');
- anime({
- targets: swapCardInner,
- rotateY: [0, 180],
- duration: flipDuration,
- easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
- complete: () => {
- this.swapAnimation.classList.add('hidden');
- swapCardInner.style.transform = '';
- swapCard.style.transform = '';
- if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
- // Update turn indicator after flip completes
+ // Fallback
+ setTimeout(() => {
+ sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
+ this.opponentDiscardAnimating = false;
+ console.log('[DEBUG] Swap animation fallback complete - clearing flags');
this.renderGame();
- }
- });
+ }, 500);
+ }
}
// Fire a flip animation for local player's card (non-blocking)
@@ -1864,29 +2594,61 @@ class GolfGame {
}
const rules = this.gameState.active_rules || [];
+ // V3_14: Add data-rule attributes for contextual highlighting
+ const renderTag = (rule) => {
+ const key = this.getRuleKey(rule);
+ return `${rule}`;
+ };
+
if (rules.length === 0) {
- // Show "Standard Rules" when no variants selected
this.activeRulesList.innerHTML = 'Standard';
} else if (rules.length <= 2) {
- // Show all rules if 2 or fewer
- this.activeRulesList.innerHTML = rules
- .map(rule => `${rule}`)
- .join('');
+ this.activeRulesList.innerHTML = rules.map(renderTag).join('');
} else {
- // Show first 2 rules + "+N more" with tooltip
const displayed = rules.slice(0, 2);
const hidden = rules.slice(2);
const moreCount = hidden.length;
const tooltip = hidden.join(', ');
- this.activeRulesList.innerHTML = displayed
- .map(rule => `${rule}`)
- .join('') +
+ this.activeRulesList.innerHTML = displayed.map(renderTag).join('') +
`+${moreCount} more`;
}
this.activeRulesBar.classList.remove('hidden');
}
+ // V3_14: Map display names to rule keys
+ getRuleKey(ruleName) {
+ const mapping = {
+ 'Speed Golf': 'flip_mode', 'Endgame Flip': 'flip_mode',
+ 'Knock Penalty': 'knock_penalty', 'Knock Bonus': 'knock_bonus',
+ 'Super Kings': 'super_kings', 'Ten Penny': 'ten_penny',
+ 'Lucky Swing': 'lucky_swing', 'Eagle Eye': 'eagle_eye',
+ 'Underdog': 'underdog_bonus', 'Tied Shame': 'tied_shame',
+ 'Blackjack': 'blackjack', 'Wolfpack': 'wolfpack',
+ 'Flip Action': 'flip_as_action', '4 of a Kind': 'four_of_a_kind',
+ 'Negative Pairs': 'negative_pairs_keep_value',
+ 'One-Eyed Jacks': 'one_eyed_jacks', 'Knock Early': 'knock_early',
+ };
+ return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
+ }
+
+ // V3_14: Contextual rule highlighting
+ highlightRule(ruleKey, message, duration = 3000) {
+ const ruleTag = this.activeRulesList?.querySelector(`[data-rule="${ruleKey}"]`);
+ if (!ruleTag) return;
+
+ ruleTag.classList.add('rule-highlighted');
+ const messageEl = document.createElement('span');
+ messageEl.className = 'rule-message';
+ messageEl.textContent = message;
+ ruleTag.appendChild(messageEl);
+
+ setTimeout(() => {
+ ruleTag.classList.remove('rule-highlighted');
+ messageEl.remove();
+ }, duration);
+ }
+
showError(message) {
this.lobbyError.textContent = message;
this.playSound('reject');
@@ -2044,17 +2806,17 @@ class GolfGame {
}
}
- // Update CPU considering visual state on discard pile
+ // Update CPU considering visual state on discard pile and opponent area
updateCpuConsideringState() {
if (!this.gameState || !this.discard) return;
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
+ const isOtherTurn = currentPlayer && currentPlayer.id !== this.playerId;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
- // Use anime.js for CPU thinking animation
if (window.cardAnimations) {
window.cardAnimations.startCpuThinking(this.discard);
}
@@ -2064,6 +2826,27 @@ class GolfGame {
window.cardAnimations.stopCpuThinking(this.discard);
}
}
+
+ // V3_06: Update thinking indicator and opponent area glow
+ this.opponentsRow.querySelectorAll('.opponent-area').forEach(area => {
+ const playerId = area.dataset.playerId;
+ const isThisPlayer = playerId === this.gameState.current_player_id;
+ const player = this.gameState.players.find(p => p.id === playerId);
+ const isCpu = player?.is_cpu;
+
+ // Thinking indicator visibility
+ const indicator = area.querySelector('.thinking-indicator');
+ if (indicator) {
+ indicator.classList.toggle('hidden', !(isCpu && isThisPlayer && hasNotDrawn));
+ }
+
+ // Opponent area thinking glow (anime.js)
+ if (isOtherTurn && isThisPlayer && hasNotDrawn && window.cardAnimations) {
+ window.cardAnimations.startOpponentThinking(area);
+ } else if (window.cardAnimations) {
+ window.cardAnimations.stopOpponentThinking(area);
+ }
+ });
}
showToast(message, type = '', duration = 2500) {
@@ -2098,15 +2881,18 @@ class GolfGame {
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
- // Show/hide final turn badge separately
+ // Show/hide final turn badge
if (isFinalTurn) {
- this.finalTurnBadge.classList.remove('hidden');
+ this.updateFinalTurnDisplay();
} else {
this.finalTurnBadge.classList.add('hidden');
+ this.gameScreen.classList.remove('final-turn-active');
+ this.finalTurnAnnounced = false;
+ this.clearKnockerMark();
}
if (currentPlayer && currentPlayer.id !== this.playerId) {
- this.setStatus(`${currentPlayer.name}'s turn`);
+ this.setStatus(`${currentPlayer.name}'s turn`, 'opponent-turn');
} else if (this.isMyTurn()) {
if (!this.drawnCard && !this.gameState.has_drawn_card) {
// Build status message based on available actions
@@ -2131,6 +2917,89 @@ class GolfGame {
}
}
+ // --- V3_05: Final Turn Urgency ---
+
+ updateFinalTurnDisplay() {
+ const finisherId = this.gameState?.finisher_id;
+
+ // Toggle game area class for border pulse
+ this.gameScreen.classList.add('final-turn-active');
+
+ // Calculate remaining turns
+ const remaining = this.countRemainingTurns();
+
+ // Update badge content
+ const remainingEl = this.finalTurnBadge.querySelector('.final-turn-remaining');
+ if (remainingEl) {
+ remainingEl.textContent = remaining === 1 ? '1 turn left' : `${remaining} turns left`;
+ }
+
+ // Show badge
+ this.finalTurnBadge.classList.remove('hidden');
+
+ // Mark knocker
+ this.markKnocker(finisherId);
+
+ // Play alert sound on first appearance
+ if (!this.finalTurnAnnounced) {
+ this.playSound('alert');
+ this.finalTurnAnnounced = true;
+ }
+ }
+
+ countRemainingTurns() {
+ if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
+
+ const finisherId = this.gameState.finisher_id;
+ const players = this.gameState.players;
+ const currentIdx = players.findIndex(p => p.id === this.gameState.current_player_id);
+ const finisherIdx = players.findIndex(p => p.id === finisherId);
+
+ if (currentIdx === -1 || finisherIdx === -1) return 0;
+
+ let count = 0;
+ let idx = currentIdx;
+ while (idx !== finisherIdx) {
+ count++;
+ idx = (idx + 1) % players.length;
+ }
+
+ return count;
+ }
+
+ markKnocker(knockerId) {
+ this.clearKnockerMark();
+ if (!knockerId) return;
+
+ if (knockerId === this.playerId) {
+ this.playerArea.classList.add('is-knocker');
+ const badge = document.createElement('div');
+ badge.className = 'knocker-badge';
+ badge.textContent = 'OUT';
+ this.playerArea.appendChild(badge);
+ } else {
+ const area = this.opponentsRow.querySelector(
+ `.opponent-area[data-player-id="${knockerId}"]`
+ );
+ if (area) {
+ area.classList.add('is-knocker');
+ const badge = document.createElement('div');
+ badge.className = 'knocker-badge';
+ badge.textContent = 'OUT';
+ area.appendChild(badge);
+ }
+ }
+ }
+
+ clearKnockerMark() {
+ document.querySelectorAll('.is-knocker').forEach(el => {
+ el.classList.remove('is-knocker');
+ });
+ document.querySelectorAll('.knocker-badge').forEach(el => {
+ el.remove();
+ });
+ }
+
showDrawnCard() {
// Show drawn card floating over the draw pile (deck), regardless of source
const card = this.drawnCard;
@@ -2323,12 +3192,15 @@ class GolfGame {
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
- // Show/hide final turn badge
+ // Show/hide final turn badge with enhanced urgency
const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) {
- this.finalTurnBadge.classList.remove('hidden');
+ this.updateFinalTurnDisplay();
} else {
this.finalTurnBadge.classList.add('hidden');
+ this.gameScreen.classList.remove('final-turn-active');
+ this.finalTurnAnnounced = false;
+ this.clearKnockerMark();
}
// Toggle not-my-turn class to disable hover effects when it's not player's turn
@@ -2336,6 +3208,13 @@ class GolfGame {
const isVisuallyMyTurn = this.isVisuallyMyTurn();
this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn);
+ // V3_08: Toggle can-swap class for card hover preview when holding a drawn card
+ this.playerArea.classList.toggle('can-swap', !!this.drawnCard && this.isMyTurn());
+
+ // Highlight player area when it's their turn (matching opponent-area.current-turn)
+ const isActivePlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
+ this.playerArea.classList.toggle('current-turn', isVisuallyMyTurn && isActivePlaying);
+
// Update status message (handled by specific actions, but set default here)
// During opponent swap animation, show the animating player (not the new current player)
const displayedPlayerId = this.opponentSwapAnimation
@@ -2364,6 +3243,18 @@ class GolfGame {
const playerNameSpan = this.playerHeader.querySelector('.player-name');
const crownHtml = isRoundWinner ? '👑' : '';
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
+
+ // Dealer chip on player area
+ const isDealer = this.playerId === this.gameState.dealer_id;
+ let dealerChip = this.playerArea.querySelector('.dealer-chip');
+ if (isDealer && !dealerChip) {
+ dealerChip = document.createElement('div');
+ dealerChip.className = 'dealer-chip';
+ dealerChip.textContent = 'D';
+ this.playerArea.appendChild(dealerChip);
+ } else if (!isDealer && dealerChip) {
+ dealerChip.remove();
+ }
}
// Update discard pile
@@ -2406,11 +3297,26 @@ class GolfGame {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
- // Skip discard update during local discard animation - animation handles the visual
+ // Skip discard update during any discard-related animation - animation handles the visual
+ const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
+ this.opponentSwapAnimation ? 'opponentSwapAnimation' :
+ this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
+ this.isDrawAnimating ? 'isDrawAnimating' : null;
+
+ if (skipReason) {
+ console.log('[DEBUG] Skipping discard update, reason:', skipReason,
+ 'discard_top:', this.gameState.discard_top ?
+ `${this.gameState.discard_top.rank}-${this.gameState.discard_top.suit}` : 'none');
+ }
+
if (this.localDiscardAnimating) {
// Don't update discard content; animation will call updateDiscardPileDisplay
} else if (this.opponentSwapAnimation) {
// Don't update discard content; animation overlay shows the swap
+ } else if (this.opponentDiscardAnimating) {
+ // Don't update discard content; opponent discard animation in progress
+ } else if (this.isDrawAnimating) {
+ // Don't update discard content; draw animation in progress
} else if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
@@ -2424,6 +3330,8 @@ class GolfGame {
this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey;
+ console.log('[DEBUG] Actually updating discard pile content to:', cardKey);
+
// Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
@@ -2524,6 +3432,7 @@ class GolfGame {
opponents.forEach((player) => {
const div = document.createElement('div');
div.className = 'opponent-area';
+ div.dataset.playerId = player.id;
if (isPlaying && player.id === displayedCurrentPlayer) {
div.classList.add('current-turn');
}
@@ -2533,12 +3442,24 @@ class GolfGame {
div.classList.add('round-winner');
}
+ // Dealer chip
+ const isDealer = player.id === this.gameState.dealer_id;
+ const dealerChipHtml = isDealer ? 'D
' : '';
+
const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name;
const showingScore = this.calculateShowingScore(player.cards);
const crownHtml = isRoundWinner ? '👑' : '';
+ // V3_06: Add thinking indicator for CPU opponents
+ const isCpuThinking = player.is_cpu && isPlaying &&
+ player.id === displayedCurrentPlayer && !newState?.has_drawn_card;
+ const thinkingHtml = player.is_cpu
+ ? `🤔`
+ : '';
+
div.innerHTML = `
- ${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}
+ ${dealerChipHtml}
+ ${thinkingHtml}${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
@@ -2585,10 +3506,15 @@ class GolfGame {
}
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
+ // V3_13: Bind tooltip events for face-up cards
+ this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
this.playerCards.appendChild(cardEl.firstChild);
});
}
+ // V3_10: Update persistent pair indicators
+ this.renderPairIndicators();
+
// Show flip prompt for initial flip
// Show flip prompt during initial flip phase
if (this.gameState.waiting_for_initial_flip) {
@@ -2638,6 +3564,12 @@ class GolfGame {
// Update scoreboard panel
this.updateScorePanel();
+
+ // Initialize anime.js hover listeners on newly created cards
+ if (window.cardAnimations) {
+ window.cardAnimations.initHoverListeners(this.playerCards);
+ window.cardAnimations.initHoverListeners(this.opponentsRow);
+ }
}
updateScorePanel() {
diff --git a/client/card-animations.js b/client/card-animations.js
index 0cb2cae..6df75f1 100644
--- a/client/card-animations.js
+++ b/client/card-animations.js
@@ -20,6 +20,24 @@ class CardAnimations {
return discard ? discard.getBoundingClientRect() : null;
}
+ getDealerRect(dealerId) {
+ if (!dealerId) return null;
+
+ // Check if dealer is the local player
+ const playerArea = document.querySelector('.player-area');
+ if (playerArea && window.game?.playerId === dealerId) {
+ return playerArea.getBoundingClientRect();
+ }
+
+ // Check opponents
+ const opponentArea = document.querySelector(`.opponent-area[data-player-id="${dealerId}"]`);
+ if (opponentArea) {
+ return opponentArea.getBoundingClientRect();
+ }
+
+ return null;
+ }
+
getHoldingRect() {
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
@@ -67,7 +85,14 @@ class CardAnimations {
`;
- document.body.appendChild(card);
+
+ // Set position BEFORE appending to avoid flash at 0,0
+ if (rect) {
+ card.style.left = rect.left + 'px';
+ card.style.top = rect.top + 'px';
+ card.style.width = rect.width + 'px';
+ card.style.height = rect.height + 'px';
+ }
// Apply deck color to back
if (deckColor) {
@@ -79,12 +104,8 @@ class CardAnimations {
card.querySelector('.draw-anim-inner').style.transform = 'rotateY(180deg)';
}
- if (rect) {
- card.style.left = rect.left + 'px';
- card.style.top = rect.top + 'px';
- card.style.width = rect.width + 'px';
- card.style.height = rect.height + 'px';
- }
+ // Now append to body after all styles are set
+ document.body.appendChild(card);
return card;
}
@@ -116,7 +137,26 @@ class CardAnimations {
}
cleanup() {
- document.querySelectorAll('.draw-anim-card').forEach(el => el.remove());
+ // Cancel all tracked anime.js animations to prevent stale callbacks
+ for (const [id, anim] of this.activeAnimations) {
+ if (anim && typeof anim.pause === 'function') {
+ anim.pause();
+ }
+ }
+ this.activeAnimations.clear();
+
+ // Remove all animation card elements (including those marked as animating)
+ document.querySelectorAll('.draw-anim-card').forEach(el => {
+ delete el.dataset.animating;
+ el.remove();
+ });
+
+ // Restore discard pile visibility if it was hidden during animation
+ const discardPile = document.getElementById('discard');
+ if (discardPile && discardPile.style.opacity === '0') {
+ discardPile.style.opacity = '';
+ }
+
this.isAnimating = false;
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
@@ -162,13 +202,14 @@ class CardAnimations {
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
+ animCard.dataset.animating = 'true'; // Mark as actively animating
const inner = animCard.querySelector('.draw-anim-inner');
if (cardData) {
this.setCardContent(animCard, cardData);
}
- this.playSound('card');
+ this.playSound('draw-deck');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
@@ -252,12 +293,20 @@ class CardAnimations {
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
+ animCard.dataset.animating = 'true'; // Mark as actively animating
this.setCardContent(animCard, cardData);
- this.playSound('card');
+ // Hide actual discard pile during animation to prevent visual conflict
+ const discardPile = document.getElementById('discard');
+ if (discardPile) {
+ discardPile.style.opacity = '0';
+ }
+
+ this.playSound('draw-discard');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
+ if (discardPile) discardPile.style.opacity = '';
this.cleanup();
if (onComplete) onComplete();
}, 600);
@@ -266,6 +315,7 @@ class CardAnimations {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
+ if (discardPile) discardPile.style.opacity = '';
this.cleanup();
if (onComplete) onComplete();
}
@@ -404,6 +454,25 @@ class CardAnimations {
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
+ // Helper to restore card to face-up state
+ const restoreCard = () => {
+ animCard.remove();
+ cardElement.classList.remove('swap-out');
+ // Restore face-up appearance
+ if (cardData) {
+ cardElement.className = 'card card-front';
+ if (cardData.rank === '★') {
+ cardElement.classList.add('joker');
+ const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
+ cardElement.innerHTML = `${icon}Joker`;
+ } else {
+ const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
+ cardElement.classList.add(isRed ? 'red' : 'black');
+ cardElement.innerHTML = `${cardData.rank}
${this.getSuitSymbol(cardData.suit)}`;
+ }
+ }
+ };
+
try {
anime({
targets: inner,
@@ -411,15 +480,11 @@ class CardAnimations {
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
- complete: () => {
- animCard.remove();
- cardElement.classList.remove('swap-out');
- }
+ complete: restoreCard
});
} catch (e) {
console.error('Opponent flip animation error:', e);
- animCard.remove();
- cardElement.classList.remove('swap-out');
+ restoreCard();
}
}
@@ -741,6 +806,46 @@ class CardAnimations {
}
}
+ // V3_06: Opponent area thinking glow
+ startOpponentThinking(area) {
+ if (!area) return;
+ const playerId = area.dataset.playerId;
+ const id = `opponentThinking-${playerId}`;
+
+ // Don't restart if already running for this player
+ if (this.activeAnimations.has(id)) return;
+
+ try {
+ const anim = anime({
+ targets: area,
+ boxShadow: [
+ '0 0 0 rgba(244, 164, 96, 0)',
+ '0 0 15px rgba(244, 164, 96, 0.4)',
+ '0 0 0 rgba(244, 164, 96, 0)'
+ ],
+ duration: 1500,
+ easing: 'easeInOutSine',
+ loop: true
+ });
+ this.activeAnimations.set(id, anim);
+ } catch (e) {
+ console.error('Opponent thinking animation error:', e);
+ }
+ }
+
+ stopOpponentThinking(area) {
+ if (!area) return;
+ const playerId = area.dataset.playerId;
+ const id = `opponentThinking-${playerId}`;
+ const existing = this.activeAnimations.get(id);
+ if (existing) {
+ existing.pause();
+ this.activeAnimations.delete(id);
+ }
+ anime.remove(area);
+ area.style.boxShadow = '';
+ }
+
// Initial flip phase - clickable cards glow
startInitialFlipPulse(element) {
if (!element) return;
@@ -831,6 +936,482 @@ class CardAnimations {
}
}
+ // V3_11: Physical swap animation - cards visibly exchange positions
+ animatePhysicalSwap(handCardEl, heldCardEl, onComplete) {
+ if (!handCardEl || !heldCardEl) {
+ if (onComplete) onComplete();
+ return;
+ }
+
+ const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
+ const handRect = handCardEl.getBoundingClientRect();
+ const heldRect = heldCardEl.getBoundingClientRect();
+ const discardRect = this.getDiscardRect();
+
+ if (!discardRect) {
+ this.pulseSwap(handCardEl);
+ if (onComplete) setTimeout(onComplete, 400);
+ return;
+ }
+
+ // Create traveling clones
+ const travelingHand = this.createTravelingCard(handCardEl);
+ const travelingHeld = this.createTravelingCard(heldCardEl);
+ document.body.appendChild(travelingHand);
+ document.body.appendChild(travelingHeld);
+
+ // Position at source
+ this.positionAt(travelingHand, handRect);
+ this.positionAt(travelingHeld, heldRect);
+
+ // Hide originals
+ handCardEl.style.visibility = 'hidden';
+ heldCardEl.style.visibility = 'hidden';
+
+ this.playSound('card');
+
+ try {
+ const timeline = anime.timeline({
+ easing: this.getEasing('move'),
+ complete: () => {
+ travelingHand.remove();
+ travelingHeld.remove();
+ handCardEl.style.visibility = '';
+ heldCardEl.style.visibility = '';
+ this.activeAnimations.delete('physicalSwap');
+ if (onComplete) onComplete();
+ }
+ });
+
+ // Arc midpoints
+ const arcUp = Math.min(handRect.top, heldRect.top) - 30;
+
+ // Lift
+ timeline.add({
+ targets: [travelingHand, travelingHeld],
+ translateY: -8,
+ scale: 1.02,
+ duration: T.lift,
+ easing: this.getEasing('lift')
+ });
+
+ // Hand card arcs to discard
+ timeline.add({
+ targets: travelingHand,
+ left: discardRect.left,
+ top: [
+ { value: arcUp, duration: T.arc / 2 },
+ { value: discardRect.top, duration: T.arc / 2 }
+ ],
+ rotate: [0, -3, 0],
+ duration: T.arc,
+ }, `-=${T.lift / 2}`);
+
+ // Held card arcs to hand slot (parallel)
+ timeline.add({
+ targets: travelingHeld,
+ left: handRect.left,
+ top: [
+ { value: arcUp + 20, duration: T.arc / 2 },
+ { value: handRect.top, duration: T.arc / 2 }
+ ],
+ rotate: [0, 3, 0],
+ duration: T.arc,
+ }, `-=${T.arc + T.lift / 2}`);
+
+ // Settle
+ timeline.add({
+ targets: [travelingHand, travelingHeld],
+ translateY: 0,
+ scale: 1,
+ duration: T.settle,
+ });
+
+ this.activeAnimations.set('physicalSwap', timeline);
+ } catch (e) {
+ console.error('Physical swap animation error:', e);
+ travelingHand.remove();
+ travelingHeld.remove();
+ handCardEl.style.visibility = '';
+ heldCardEl.style.visibility = '';
+ if (onComplete) onComplete();
+ }
+ }
+
+ // Unified swap animation for ALL swap scenarios
+ // handCardData: the card in hand being swapped out (goes to discard)
+ // heldCardData: the drawn/held card being swapped in (goes to hand)
+ // handRect: position of the hand card
+ // heldRect: position of the held card (or null to use default holding position)
+ // 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 discardRect = this.getDiscardRect();
+
+ // Safety checks
+ if (!handRect || !discardRect || !handCardData || !heldCardData) {
+ console.warn('animateUnifiedSwap: missing required data');
+ if (onComplete) onComplete();
+ return;
+ }
+
+ // Use holding position if heldRect not provided
+ if (!heldRect) {
+ heldRect = this.getHoldingRect();
+ }
+ if (!heldRect) {
+ if (onComplete) onComplete();
+ return;
+ }
+
+ // Wait for any in-progress draw animation to complete
+ // Check if there's an active draw animation by looking for overlay cards
+ const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
+ if (existingDrawCards.length > 0) {
+ // Draw animation still in progress - wait a bit and retry
+ setTimeout(() => {
+ // Clean up the draw animation overlay
+ existingDrawCards.forEach(el => {
+ delete el.dataset.animating;
+ el.remove();
+ });
+ // Now run the swap animation
+ this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
+ }, 100);
+ return;
+ }
+
+ this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
+ }
+
+ _runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
+ // Create the two traveling cards
+ const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
+ const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
+ travelingHand.dataset.animating = 'true';
+ travelingHeld.dataset.animating = 'true';
+ document.body.appendChild(travelingHand);
+ document.body.appendChild(travelingHeld);
+
+ this.playSound('card');
+
+ // If hand card was face-down, flip it first
+ if (wasHandFaceDown) {
+ const inner = travelingHand.querySelector('.draw-anim-inner');
+ if (inner) {
+ // Start showing back
+ inner.style.transform = 'rotateY(180deg)';
+
+ // Flip to reveal, then do the swap
+ this.playSound('flip');
+ anime({
+ targets: inner,
+ rotateY: 0,
+ duration: 245,
+ easing: this.getEasing('flip'),
+ complete: () => {
+ this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
+ }
+ });
+ return;
+ }
+ }
+
+ // Both face-up, do the swap immediately
+ this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
+ }
+
+ _doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete) {
+ try {
+ const arcUp = Math.min(handRect.top, heldRect.top, discardRect.top) - 40;
+
+ const timeline = anime.timeline({
+ easing: this.getEasing('move'),
+ complete: () => {
+ travelingHand.remove();
+ travelingHeld.remove();
+ this.activeAnimations.delete('unifiedSwap');
+ if (onComplete) onComplete();
+ }
+ });
+
+ // Lift both cards
+ timeline.add({
+ targets: [travelingHand, travelingHeld],
+ translateY: -10,
+ scale: 1.03,
+ duration: T.lift,
+ easing: this.getEasing('lift')
+ });
+
+ // Hand card arcs to discard (apply counter-rotation to land flat)
+ timeline.add({
+ targets: travelingHand,
+ left: discardRect.left,
+ top: [
+ { value: arcUp, duration: T.arc / 2 },
+ { value: discardRect.top, duration: T.arc / 2 }
+ ],
+ width: discardRect.width,
+ height: discardRect.height,
+ rotate: [rotation, rotation - 3, 0],
+ duration: T.arc,
+ }, `-=${T.lift / 2}`);
+
+ // Held card arcs to hand slot (apply rotation to match hand position)
+ timeline.add({
+ targets: travelingHeld,
+ left: handRect.left,
+ top: [
+ { value: arcUp + 25, duration: T.arc / 2 },
+ { value: handRect.top, duration: T.arc / 2 }
+ ],
+ width: handRect.width,
+ height: handRect.height,
+ rotate: [0, 3, rotation],
+ duration: T.arc,
+ }, `-=${T.arc + T.lift / 2}`);
+
+ // Settle
+ timeline.add({
+ targets: [travelingHand, travelingHeld],
+ translateY: 0,
+ scale: 1,
+ duration: T.settle,
+ });
+
+ this.activeAnimations.set('unifiedSwap', timeline);
+ } catch (e) {
+ console.error('Unified swap animation error:', e);
+ travelingHand.remove();
+ travelingHeld.remove();
+ if (onComplete) onComplete();
+ }
+ }
+
+ // Animate held card (drawn from deck) to discard pile
+ animateHeldToDiscard(cardData, heldRect, onComplete) {
+ const discardRect = this.getDiscardRect();
+
+ if (!heldRect || !discardRect) {
+ if (onComplete) onComplete();
+ return;
+ }
+
+ const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
+
+ // Create a traveling card showing the face at the held card's actual position
+ const travelingCard = this.createCardFromData(cardData, heldRect, 0);
+ travelingCard.dataset.animating = 'true';
+ document.body.appendChild(travelingCard);
+
+ this.playSound('card');
+
+ try {
+ // Arc peak slightly above both positions
+ const arcUp = Math.min(heldRect.top, discardRect.top) - 30;
+
+ const timeline = anime.timeline({
+ easing: this.getEasing('move'),
+ complete: () => {
+ travelingCard.remove();
+ this.activeAnimations.delete('heldToDiscard');
+ if (onComplete) onComplete();
+ }
+ });
+
+ // Lift
+ timeline.add({
+ targets: travelingCard,
+ translateY: -8,
+ scale: 1.02,
+ duration: T.lift,
+ easing: this.getEasing('lift')
+ });
+
+ // Arc to discard
+ timeline.add({
+ targets: travelingCard,
+ left: discardRect.left,
+ top: [
+ { value: arcUp, duration: T.arc / 2 },
+ { value: discardRect.top, duration: T.arc / 2 }
+ ],
+ width: discardRect.width,
+ height: discardRect.height,
+ rotate: [0, -2, 0],
+ duration: T.arc,
+ }, `-=${T.lift / 2}`);
+
+ // Settle
+ timeline.add({
+ targets: travelingCard,
+ translateY: 0,
+ scale: 1,
+ duration: T.settle,
+ });
+
+ this.activeAnimations.set('heldToDiscard', timeline);
+ } catch (e) {
+ console.error('Held to discard animation error:', e);
+ travelingCard.remove();
+ if (onComplete) onComplete();
+ }
+ }
+
+ // Animate opponent/CPU discarding from holding position (hold → discard)
+ // The draw animation already handled deck → hold, so this just completes the motion
+ animateOpponentDiscard(cardData, onComplete) {
+ const holdingRect = this.getHoldingRect();
+ const discardRect = this.getDiscardRect();
+
+ if (!holdingRect || !discardRect) {
+ if (onComplete) onComplete();
+ return;
+ }
+
+ // Wait for any in-progress draw animation to complete
+ const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
+ if (existingDrawCards.length > 0) {
+ // Draw animation still in progress - wait a bit and retry
+ setTimeout(() => {
+ // Clean up the draw animation overlay
+ existingDrawCards.forEach(el => {
+ delete el.dataset.animating;
+ el.remove();
+ });
+ // Now run the discard animation
+ this._runOpponentDiscard(cardData, holdingRect, discardRect, onComplete);
+ }, 100);
+ return;
+ }
+
+ this._runOpponentDiscard(cardData, holdingRect, discardRect, onComplete);
+ }
+
+ _runOpponentDiscard(cardData, holdingRect, discardRect, onComplete) {
+ const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
+
+ // Create card at holding position, face-up (already revealed by draw animation)
+ const travelingCard = this.createAnimCard(holdingRect, false);
+ travelingCard.dataset.animating = 'true'; // Mark as actively animating
+ this.setCardContent(travelingCard, cardData);
+
+ this.playSound('card');
+
+ try {
+ // Arc peak slightly above both positions
+ const arcUp = Math.min(holdingRect.top, discardRect.top) - 30;
+
+ const timeline = anime.timeline({
+ easing: this.getEasing('move'),
+ complete: () => {
+ travelingCard.remove();
+ this.activeAnimations.delete('opponentDiscard');
+ if (onComplete) onComplete();
+ }
+ });
+
+ // Lift
+ timeline.add({
+ targets: travelingCard,
+ translateY: -8,
+ scale: 1.02,
+ duration: T.lift,
+ easing: this.getEasing('lift')
+ });
+
+ // Arc to discard
+ timeline.add({
+ targets: travelingCard,
+ left: discardRect.left,
+ top: [
+ { value: arcUp, duration: T.arc / 2 },
+ { value: discardRect.top, duration: T.arc / 2 }
+ ],
+ width: discardRect.width,
+ height: discardRect.height,
+ translateY: 0,
+ scale: 1,
+ rotate: [0, -2, 0],
+ duration: T.arc,
+ });
+
+ // Settle
+ timeline.add({
+ targets: travelingCard,
+ translateY: 0,
+ scale: 1,
+ duration: T.settle,
+ });
+
+ this.activeAnimations.set('opponentDiscard', timeline);
+ } catch (e) {
+ console.error('Opponent discard animation error:', e);
+ travelingCard.remove();
+ if (onComplete) onComplete();
+ }
+ }
+
+ createCardFromData(cardData, rect, rotation = 0) {
+ const card = document.createElement('div');
+ card.className = 'draw-anim-card';
+ card.innerHTML = `
+
+ `;
+
+ // Apply deck color to back
+ const deckColor = this.getDeckColor();
+ if (deckColor) {
+ const back = card.querySelector('.draw-anim-back');
+ back.classList.add(`back-${deckColor}`);
+ }
+
+ // Set front content
+ this.setCardContent(card, cardData);
+
+ // Position and size
+ card.style.left = rect.left + 'px';
+ card.style.top = rect.top + 'px';
+ card.style.width = rect.width + 'px';
+ card.style.height = rect.height + 'px';
+
+ if (rotation) {
+ card.style.transform = `rotate(${rotation}deg)`;
+ }
+
+ return card;
+ }
+
+ createTravelingCard(sourceEl) {
+ const clone = sourceEl.cloneNode(true);
+ // Preserve original classes and add traveling-card
+ clone.classList.add('traveling-card');
+ // Remove classes that interfere with animation
+ clone.classList.remove('hidden', 'your-turn-pulse', 'held-card-floating', 'swap-out');
+ clone.removeAttribute('id');
+ // Override positioning for animation
+ clone.style.position = 'fixed';
+ clone.style.pointerEvents = 'none';
+ clone.style.zIndex = '1000';
+ clone.style.transform = 'none';
+ clone.style.transformOrigin = 'center center';
+ clone.style.borderRadius = '6px';
+ clone.style.overflow = 'hidden';
+ 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`;
+ }
+
// Pop-in effect when card appears
popIn(element) {
if (!element) return;
@@ -858,6 +1439,223 @@ class CardAnimations {
}, 450);
}
+ // === DEALING ANIMATION ===
+
+ async animateDealing(gameState, getPlayerRect, onComplete) {
+ const T = window.TIMING?.dealing || {};
+ const shufflePause = T.shufflePause || 400;
+ const cardFlyTime = T.cardFlyTime || 150;
+ const cardStagger = T.cardStagger || 80;
+ const roundPause = T.roundPause || 50;
+ const discardFlipDelay = T.discardFlipDelay || 200;
+
+ // Get deck position as the source for dealt cards
+ // Cards are dealt from the deck, not from the dealer's position
+ const deckRect = this.getDeckRect();
+ if (!deckRect) {
+ if (onComplete) onComplete();
+ return;
+ }
+
+ // Get player order starting from dealer's left
+ const dealerIdx = gameState.dealer_idx || 0;
+ const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
+
+ // Create container for animation cards
+ const container = document.createElement('div');
+ container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
+ document.body.appendChild(container);
+
+ // Shuffle pause
+ await this._delay(shufflePause);
+
+ // Deal 6 rounds of cards
+ for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
+ for (const player of playerOrder) {
+ const targetRect = getPlayerRect(player.id, cardIdx);
+ if (!targetRect) continue;
+
+ // Use individual card's deck color if available
+ const playerCards = player.cards || [];
+ const cardData = playerCards[cardIdx];
+ const deckColors = gameState.deck_colors || window.currentDeckColors || ['red', 'blue', 'gold'];
+ const deckColor = cardData && cardData.deck_id !== undefined
+ ? deckColors[cardData.deck_id] || deckColors[0]
+ : this.getDeckColor();
+ const card = this.createAnimCard(deckRect, true, deckColor);
+ container.appendChild(card);
+
+ // Move card from deck to target via anime.js
+ // Animate position and size (deck cards are larger than player cards)
+ try {
+ anime({
+ targets: card,
+ left: targetRect.left,
+ top: targetRect.top,
+ width: targetRect.width,
+ height: targetRect.height,
+ duration: cardFlyTime,
+ easing: this.getEasing('move'),
+ });
+ } catch (e) {
+ console.error('Deal animation error:', e);
+ }
+
+ this.playSound('card');
+ await this._delay(cardStagger);
+ }
+
+ if (cardIdx < 5) {
+ await this._delay(roundPause);
+ }
+ }
+
+ // Wait for last cards to land
+ await this._delay(cardFlyTime);
+
+ // Flip discard
+ if (gameState.discard_top) {
+ await this._delay(discardFlipDelay);
+ this.playSound('flip');
+ }
+
+ // Clean up
+ container.remove();
+ if (onComplete) onComplete();
+ }
+
+ getDealOrder(players, dealerIdx) {
+ const order = [...players];
+ const startIdx = (dealerIdx + 1) % order.length;
+ return [...order.slice(startIdx), ...order.slice(0, startIdx)];
+ }
+
+ _delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ // === PAIR CELEBRATION ===
+
+ celebratePair(cardElement1, cardElement2) {
+ this.playSound('pair');
+
+ const duration = window.TIMING?.celebration?.pairDuration || 400;
+
+ [cardElement1, cardElement2].forEach(el => {
+ if (!el) return;
+
+ el.style.zIndex = '10';
+
+ try {
+ 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 = '';
+ }
+ });
+ } catch (e) {
+ console.error('Pair celebration error:', e);
+ el.style.zIndex = '';
+ }
+ });
+ }
+
+ // === CARD HOVER EFFECTS ===
+
+ // Animate card hover in (called on mouseenter)
+ hoverIn(element, isSwappable = false) {
+ if (!element || element.dataset.hoverAnimating === 'true') return;
+
+ element.dataset.hoverAnimating = 'true';
+
+ try {
+ anime.remove(element); // Cancel any existing animation
+
+ if (isSwappable) {
+ // Swappable card - lift and scale
+ anime({
+ targets: element,
+ translateY: -5,
+ scale: 1.02,
+ duration: 150,
+ easing: 'easeOutQuad',
+ complete: () => {
+ element.dataset.hoverAnimating = 'false';
+ }
+ });
+ } else {
+ // Regular card - just scale
+ anime({
+ targets: element,
+ scale: 1.05,
+ duration: 150,
+ easing: 'easeOutQuad',
+ complete: () => {
+ element.dataset.hoverAnimating = 'false';
+ }
+ });
+ }
+ } catch (e) {
+ console.error('Hover in error:', e);
+ element.dataset.hoverAnimating = 'false';
+ }
+ }
+
+ // Animate card hover out (called on mouseleave)
+ hoverOut(element) {
+ if (!element) return;
+
+ element.dataset.hoverAnimating = 'true';
+
+ try {
+ anime.remove(element); // Cancel any existing animation
+
+ anime({
+ targets: element,
+ translateY: 0,
+ scale: 1,
+ duration: 150,
+ easing: 'easeOutQuad',
+ complete: () => {
+ element.dataset.hoverAnimating = 'false';
+ element.style.transform = ''; // Clean up inline styles
+ }
+ });
+ } catch (e) {
+ console.error('Hover out error:', e);
+ element.dataset.hoverAnimating = 'false';
+ element.style.transform = '';
+ }
+ }
+
+ // Initialize hover listeners on card elements
+ initHoverListeners(container = document) {
+ const cards = container.querySelectorAll('.card');
+ cards.forEach(card => {
+ // Skip if already initialized
+ if (card.dataset.hoverInitialized) return;
+ card.dataset.hoverInitialized = 'true';
+
+ card.addEventListener('mouseenter', () => {
+ // Check if card is in a swappable context
+ const isSwappable = card.closest('.player-area.can-swap') !== null;
+ this.hoverIn(card, isSwappable);
+ });
+
+ card.addEventListener('mouseleave', () => {
+ this.hoverOut(card);
+ });
+ });
+ }
+
// === HELPER METHODS ===
isBusy() {
@@ -874,3 +1672,12 @@ window.cardAnimations = new CardAnimations();
// Backwards compatibility - point drawAnimations to the new system
window.drawAnimations = window.cardAnimations;
+
+// Initialize hover listeners when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ window.cardAnimations.initHoverListeners();
+ });
+} else {
+ window.cardAnimations.initHoverListeners();
+}
diff --git a/client/style.css b/client/style.css
index dc9fabe..dd774be 100644
--- a/client/style.css
+++ b/client/style.css
@@ -848,15 +848,12 @@ input::placeholder {
font-size: clamp(2rem, 2.5vw, 3.2rem);
font-weight: bold;
cursor: pointer;
- transition: transform 0.2s, box-shadow 0.2s;
+ /* No CSS transition - hover effects handled by anime.js */
position: relative;
user-select: none;
}
-.card:hover {
- transform: scale(1.05);
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
-}
+/* Hover state set by anime.js - do not add CSS hover transform here */
.card-back {
/* Bee-style diamond grid pattern - default red with white crosshatch */
@@ -1000,7 +997,7 @@ input::placeholder {
align-items: flex-end;
gap: clamp(12px, 1.8vw, 35px);
min-height: clamp(120px, 14vw, 200px);
- padding: 0 20px;
+ padding: 8px 20px 0;
width: 100%;
}
@@ -1522,6 +1519,7 @@ input::placeholder {
}
.player-area {
+ position: relative;
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px 15px;
@@ -1530,6 +1528,7 @@ input::placeholder {
/* Opponent Areas */
.opponent-area {
+ position: relative;
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: clamp(4px, 0.5vw, 10px) clamp(6px, 0.7vw, 14px) clamp(6px, 0.7vw, 14px);
@@ -1591,6 +1590,12 @@ input::placeholder {
box-shadow: 0 0 0 2px #f4a460;
}
+/* Local player turn highlight - green tint to match "your turn" status */
+.player-area.current-turn {
+ background: rgba(181, 212, 132, 0.25);
+ box-shadow: 0 0 0 2px #9ab973;
+}
+
/* Round winner highlight */
.opponent-area.round-winner h4,
.player-area.round-winner h4 {
@@ -1619,34 +1624,48 @@ input::placeholder {
color: #2d3436;
}
+/* Opponent turn status - subtle purple/slate for other player's turn */
+.status-message.opponent-turn {
+ background: linear-gradient(135deg, #8b7eb8 0%, #6b5b95 100%);
+ color: #fff;
+}
+
/* CPU action status - subtle blue to indicate CPU is doing something */
.status-message.cpu-action {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
color: #fff;
}
-/* Final turn badge - separate indicator */
+/* Final turn badge - enhanced V3 with countdown */
.final-turn-badge {
- background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: #fff;
padding: 6px 14px;
- border-radius: 4px;
- font-size: 0.85rem;
+ border-radius: 8px;
font-weight: 700;
letter-spacing: 0.05em;
- animation: pulse-subtle 2s ease-in-out infinite;
+ white-space: nowrap;
+ box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
+ animation: final-turn-pulse 1.5s ease-in-out infinite;
+}
+
+.final-turn-badge .final-turn-text {
+ font-size: 0.85rem;
}
.final-turn-badge.hidden {
display: none;
}
-@keyframes pulse-subtle {
+@keyframes final-turn-pulse {
0%, 100% {
- box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
+ box-shadow: 0 2px 12px rgba(214, 48, 49, 0.4);
}
50% {
- box-shadow: 0 0 0 4px rgba(220, 38, 38, 0);
+ box-shadow: 0 2px 20px rgba(214, 48, 49, 0.6);
}
}
@@ -4153,3 +4172,473 @@ input::placeholder {
width: 100%;
}
}
+
+/* ============================================================
+ V3 FEATURES
+ ============================================================ */
+
+/* --- V3_01: Dealer Indicator --- */
+.dealer-chip {
+ position: absolute;
+ bottom: -10px;
+ left: -10px;
+ width: 38px;
+ height: 38px;
+ background: linear-gradient(145deg, #ffc078 0%, #f4a460 40%, #d4884a 100%);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: bold;
+ color: #1a1a2e;
+ border: 3px solid rgba(255, 255, 255, 0.9);
+ box-shadow:
+ 0 4px 8px rgba(0, 0, 0, 0.4),
+ 0 2px 4px rgba(0, 0, 0, 0.3),
+ inset 0 2px 4px rgba(255, 255, 255, 0.4),
+ inset 0 -2px 4px rgba(0, 0, 0, 0.2);
+ z-index: 10;
+ pointer-events: none;
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.3);
+}
+
+/* --- V3_03: Round End Reveal --- */
+.reveal-prompt {
+ position: fixed;
+ top: 20%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
+ color: white;
+ padding: 15px 30px;
+ border-radius: 12px;
+ text-align: center;
+ z-index: 200;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+ animation: prompt-entrance 0.3s ease-out;
+}
+
+.reveal-prompt.fading {
+ animation: prompt-fade 0.3s ease-out forwards;
+}
+
+@keyframes prompt-entrance {
+ 0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
+ 100% { transform: translateX(-50%) translateY(0); opacity: 1; }
+}
+
+@keyframes prompt-fade {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+.reveal-prompt-text {
+ font-size: 1.1em;
+ margin-bottom: 8px;
+}
+
+.reveal-prompt-countdown {
+ font-size: 2em;
+ font-weight: bold;
+}
+
+/* Cards clickable during voluntary reveal */
+.player-area.voluntary-flip .card.can-flip {
+ cursor: pointer;
+}
+
+/* Player area highlight during reveal */
+.player-area.revealing,
+.opponent-area.revealing {
+ box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.3);
+ transition: box-shadow 0.2s ease-out;
+}
+
+/* --- V3_05: Final Turn Urgency --- */
+.final-turn-icon {
+ font-size: 1.1em;
+}
+
+.final-turn-remaining {
+ font-size: 0.85em;
+ opacity: 0.9;
+}
+
+/* Game area border pulse during final turn */
+#game-screen.final-turn-active {
+ animation: game-area-urgency 2s ease-in-out infinite;
+}
+
+@keyframes game-area-urgency {
+ 0%, 100% {
+ box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
+ }
+ 50% {
+ box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.12);
+ }
+}
+
+/* Knocker highlight */
+.player-area.is-knocker,
+.opponent-area.is-knocker {
+ border: 2px solid #ff6b35;
+ border-radius: 8px;
+}
+
+.knocker-badge {
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ background: #ff6b35;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 0.7em;
+ font-weight: bold;
+ z-index: 10;
+ pointer-events: none;
+}
+
+/* --- V3_08: Card Hover Selection Preview --- */
+.player-area.can-swap .card {
+ cursor: pointer;
+}
+
+/* Swap card hover state - anime.js handles transform, CSS handles box-shadow only */
+@media (hover: hover) {
+ .player-area.can-swap .card:hover {
+ /* Transform handled by anime.js cardHover methods */
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
+ }
+
+ .player-area.can-swap .card.card-back:hover {
+ box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
+ }
+}
+
+/* --- V3_09: Knock Early Drama --- */
+.knock-confirm-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 300;
+ animation: modal-fade-in 0.2s ease-out;
+}
+
+@keyframes modal-fade-in {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
+.knock-confirm-content {
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+ padding: 30px;
+ border-radius: 15px;
+ text-align: center;
+ max-width: 320px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+ animation: modal-scale-in 0.2s ease-out;
+}
+
+@keyframes modal-scale-in {
+ 0% { transform: scale(0.9); }
+ 100% { transform: scale(1); }
+}
+
+.knock-confirm-icon {
+ font-size: 3em;
+ margin-bottom: 10px;
+}
+
+.knock-confirm-content h3 {
+ margin: 0 0 15px;
+ color: #f4a460;
+}
+
+.knock-confirm-content p {
+ margin: 0 0 10px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.knock-warning {
+ color: #e74c3c !important;
+ font-size: 0.9em;
+}
+
+.knock-confirm-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.knock-confirm-buttons .btn {
+ flex: 1;
+}
+
+.knock-banner {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 400;
+ pointer-events: none;
+ animation: knock-banner-in 0.3s ease-out forwards;
+}
+
+.knock-banner span {
+ display: block;
+ font-size: 4em;
+ font-weight: 900;
+ color: #ffe082;
+ background: rgba(20, 20, 36, 0.95);
+ padding: 20px 50px;
+ border-radius: 12px;
+ border: 3px solid rgba(255, 215, 0, 0.5);
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
+ letter-spacing: 0.15em;
+}
+
+@keyframes knock-banner-in {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
+.knock-banner span {
+ animation: knock-text-in 0.3s ease-out forwards;
+}
+
+@keyframes knock-text-in {
+ 0% { transform: scale(0); }
+ 50% { transform: scale(1.1); }
+ 100% { transform: scale(1); }
+}
+
+.knock-banner.fading {
+ animation: knock-banner-out 0.3s ease-out forwards;
+}
+
+@keyframes knock-banner-out {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@keyframes screen-shake {
+ 0%, 100% { transform: translateX(0); }
+ 20% { transform: translateX(-3px); }
+ 40% { transform: translateX(3px); }
+ 60% { transform: translateX(-2px); }
+ 80% { transform: translateX(2px); }
+}
+
+body.screen-shake {
+ animation: screen-shake 0.3s ease-out;
+}
+
+.opponent-knock-banner {
+ position: fixed;
+ top: 30%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
+ color: white;
+ padding: 15px 30px;
+ border-radius: 12px;
+ font-size: 1.2em;
+ font-weight: bold;
+ z-index: 200;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+ animation: prompt-entrance 0.3s ease-out;
+}
+
+.opponent-knock-banner.fading {
+ animation: prompt-fade 0.3s ease-out forwards;
+}
+
+/* --- V3_07: Score Tallying Animation --- */
+.card-value-overlay {
+ position: fixed;
+ transform: translate(-50%, -50%) scale(0.5);
+ background: rgba(20, 20, 36, 0.95);
+ color: #fff;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 1.3em;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ opacity: 0;
+ transition: transform 0.15s ease-out, opacity 0.12s ease-out;
+ z-index: 200;
+ pointer-events: none;
+ border: 2px solid rgba(255, 255, 255, 0.25);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
+}
+.card-value-overlay.visible {
+ transform: translate(-50%, -50%) scale(1);
+ opacity: 1;
+}
+.card-value-overlay.negative {
+ background: linear-gradient(135deg, #1b944f 0%, #166b3a 100%);
+ border-color: rgba(39, 174, 96, 0.5);
+}
+.card-value-overlay.zero {
+ background: linear-gradient(135deg, #8b6914 0%, #6b5010 100%);
+ border-color: rgba(180, 140, 40, 0.5);
+ color: #f5e6b8;
+}
+.card.tallying {
+ box-shadow: 0 0 15px rgba(244, 164, 96, 0.6) !important;
+ transform: scale(1.05);
+ /* No CSS transition - tallying effect handled by JS */
+}
+.pair-cancel-overlay {
+ position: fixed;
+ transform: translate(-50%, -50%);
+ font-size: 1.1em;
+ font-weight: 800;
+ color: #ffe082;
+ background: rgba(20, 20, 36, 0.92);
+ padding: 5px 12px;
+ border-radius: 6px;
+ border: 2px solid rgba(255, 215, 0, 0.4);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+ animation: pair-cancel 0.5s ease-out forwards;
+ z-index: 200;
+ pointer-events: none;
+}
+@keyframes pair-cancel {
+ 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0; }
+ 25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
+ 100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
+}
+
+/* --- V3_10: Column Pair Indicator --- */
+.card.paired {
+ box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
+}
+.card.pair-top {
+ border-bottom: 2px solid rgba(244, 164, 96, 0.5);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+.card.pair-bottom {
+ border-top: 2px solid rgba(244, 164, 96, 0.5);
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+.opponent-area .card.paired {
+ box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
+}
+.opponent-area .card.pair-top {
+ border-bottom-width: 1px;
+}
+.opponent-area .card.pair-bottom {
+ border-top-width: 1px;
+}
+
+/* --- V3_06: Opponent Thinking Indicator --- */
+.thinking-indicator {
+ display: inline-block;
+ margin-right: 4px;
+ font-size: 0.9em;
+}
+.thinking-indicator.hidden {
+ display: none;
+}
+
+/* --- V3_15: Discard Pile History Depth --- */
+#discard[data-depth="2"] {
+ box-shadow:
+ 2px 2px 0 0 rgba(255, 255, 255, 0.08),
+ 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+#discard[data-depth="3"] {
+ box-shadow:
+ 2px 2px 0 0 rgba(255, 255, 255, 0.08),
+ 4px 4px 0 0 rgba(255, 255, 255, 0.04),
+ 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+/* --- V3_14: Active Rules Context --- */
+.rule-tag.rule-highlighted {
+ background: rgba(244, 164, 96, 0.3);
+ box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
+ animation: rule-pulse 0.5s ease-out;
+}
+@keyframes rule-pulse {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+ 100% { transform: scale(1); }
+}
+.rule-message {
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid rgba(255, 255, 255, 0.3);
+ font-weight: bold;
+ color: #f4a460;
+ animation: rule-message-in 0.3s ease-out;
+}
+@keyframes rule-message-in {
+ 0% { opacity: 0; transform: translateX(-5px); }
+ 100% { opacity: 1; transform: translateX(0); }
+}
+
+/* --- V3_13: Card Value Tooltips --- */
+.card-value-tooltip {
+ position: fixed;
+ transform: translateX(-50%);
+ background: rgba(26, 26, 46, 0.95);
+ color: white;
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-size: 0.85em;
+ text-align: center;
+ z-index: 500;
+ pointer-events: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ transition: opacity 0.15s;
+}
+.card-value-tooltip.hidden {
+ opacity: 0;
+}
+.card-value-tooltip::before {
+ content: '';
+ position: absolute;
+ top: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 6px solid transparent;
+ border-bottom-color: rgba(26, 26, 46, 0.95);
+}
+.tooltip-value {
+ display: block;
+ font-size: 1.2em;
+ font-weight: bold;
+}
+.tooltip-value.negative {
+ color: #27ae60;
+}
+.tooltip-note {
+ display: block;
+ font-size: 0.85em;
+ color: rgba(255, 255, 255, 0.7);
+ margin-top: 2px;
+}
+
+/* --- V3_11: Swap Animation --- */
+.traveling-card {
+ position: fixed;
+ border-radius: 6px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+}
diff --git a/client/timing-config.js b/client/timing-config.js
index 4e57096..fee2202 100644
--- a/client/timing-config.js
+++ b/client/timing-config.js
@@ -64,6 +64,55 @@ const TIMING = {
moveDuration: 400, // Card move animation
},
+ // V3_02: Dealing animation
+ dealing: {
+ shufflePause: 400, // Pause after shuffle sound
+ cardFlyTime: 150, // Time for card to fly to destination
+ cardStagger: 80, // Delay between cards
+ roundPause: 50, // Pause between deal rounds
+ discardFlipDelay: 200, // Pause before flipping discard
+ },
+
+ // V3_03: Round end reveal timing
+ reveal: {
+ voluntaryWindow: 4000, // Time for players to flip their own cards
+ initialPause: 500, // Pause before auto-reveals start
+ cardStagger: 100, // Between cards in same hand
+ playerPause: 400, // Pause after each player's reveal
+ highlightDuration: 200, // Player area highlight fade-in
+ },
+
+ // V3_04: Pair celebration
+ celebration: {
+ pairDuration: 400, // Celebration animation length
+ pairDelay: 50, // Slight delay before celebration
+ },
+
+ // V3_07: Score tallying animation
+ tally: {
+ initialPause: 200, // After reveal, before tally
+ cardHighlight: 140, // Duration to show each card value
+ columnPause: 100, // Between columns
+ pairCelebration: 300, // Pair cancel effect
+ playerPause: 350, // Between players
+ finalScoreReveal: 400, // Final score animation
+ },
+
+ // Opponent initial flip stagger (after dealing)
+ // All players flip concurrently within this window (not taking turns)
+ initialFlips: {
+ windowStart: 500, // Minimum delay before any opponent starts flipping
+ windowEnd: 2500, // Maximum delay before opponent starts (random in range)
+ cardStagger: 400, // Delay between an opponent's two card flips
+ },
+
+ // 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
+ },
+
// Player swap animation steps - smooth continuous motion
playerSwap: {
flipToReveal: 400, // Initial flip to show card