diff --git a/client/animation-queue.js b/client/animation-queue.js
index 8f88038..70da13c 100644
--- a/client/animation-queue.js
+++ b/client/animation-queue.js
@@ -11,16 +11,23 @@ class AnimationQueue {
this.processing = false;
this.animationInProgress = false;
- // Timing configuration (ms)
- // Rhythm: action → settle → action → breathe
+ // Timing configuration (ms) - use centralized TIMING config
+ const T = window.TIMING || {};
this.timing = {
- flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
- moveDuration: 270,
- pauseAfterFlip: 144, // Brief settle after flip before move
- pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
- pauseBeforeNewCard: 150, // Anticipation before new card moves in
- pauseAfterSwapComplete: 400, // Breathing room after swap completes
- pauseBetweenAnimations: 90
+ flipDuration: T.card?.flip || 540,
+ moveDuration: T.card?.move || 270,
+ cardLift: T.card?.lift || 100,
+ pauseAfterFlip: T.pause?.afterFlip || 144,
+ pauseAfterDiscard: T.pause?.afterDiscard || 550,
+ pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
+ pauseAfterSwapComplete: T.pause?.afterSwapComplete || 400,
+ pauseBetweenAnimations: T.pause?.betweenAnimations || 90,
+ pauseBeforeFlip: T.pause?.beforeFlip || 50,
+ // Beat timing
+ beatBase: T.beat?.base || 1000,
+ beatVariance: T.beat?.variance || 200,
+ fadeOut: T.beat?.fadeOut || 300,
+ fadeIn: T.beat?.fadeIn || 300,
};
}
@@ -124,7 +131,7 @@ class AnimationQueue {
// Animate the flip
this.playSound('flip');
- await this.delay(50); // Brief pause before flip
+ await this.delay(this.timing.pauseBeforeFlip);
// Remove flipped to trigger animation to front
inner.classList.remove('flipped');
@@ -136,11 +143,10 @@ class AnimationQueue {
animCard.remove();
}
- // Animate a card swap (hand card to discard, drawn card to hand)
+ // Animate a card swap - smooth continuous motion
async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement;
- // Get positions
const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
@@ -149,67 +155,54 @@ class AnimationQueue {
return;
}
- // Create a temporary card element for the animation
- const animCard = this.createAnimCard();
- this.cardManager.cardLayer.appendChild(animCard);
+ // Create animation cards
+ const handCard = this.createAnimCard();
+ this.cardManager.cardLayer.appendChild(handCard);
+ this.setCardPosition(handCard, slotRect);
- // Position at slot
- this.setCardPosition(animCard, slotRect);
+ const handInner = handCard.querySelector('.card-inner');
+ const handFront = handCard.querySelector('.card-face-front');
- // Start face down (showing back)
- const inner = animCard.querySelector('.card-inner');
- const front = animCard.querySelector('.card-face-front');
- inner.classList.add('flipped');
+ const heldCard = this.createAnimCard();
+ this.cardManager.cardLayer.appendChild(heldCard);
+ this.setCardPosition(heldCard, holdingRect || discardRect);
- // Step 1: If card was face down, flip to reveal it
- this.setCardFront(front, oldCard);
+ const heldInner = heldCard.querySelector('.card-inner');
+ const heldFront = heldCard.querySelector('.card-face-front');
+
+ // Set up initial state
+ this.setCardFront(handFront, oldCard);
+ if (!oldCard.face_up) {
+ handInner.classList.add('flipped');
+ }
+ this.setCardFront(heldFront, newCard);
+ heldInner.classList.remove('flipped');
+
+ // Step 1: If face-down, flip to reveal
if (!oldCard.face_up) {
this.playSound('flip');
- inner.classList.remove('flipped');
+ handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
- await this.delay(this.timing.pauseAfterFlip);
- } else {
- // Already face up, just show it immediately
- inner.classList.remove('flipped');
}
- // Step 2: Move card to discard pile
+ // Step 2: Quick crossfade swap
+ handCard.classList.add('fade-out');
+ heldCard.classList.add('fade-out');
+ await this.delay(150);
+
+ this.setCardPosition(handCard, discardRect);
+ this.setCardPosition(heldCard, slotRect);
+
this.playSound('card');
- animCard.classList.add('moving');
- this.setCardPosition(animCard, discardRect);
- await this.delay(this.timing.moveDuration);
- animCard.classList.remove('moving');
+ handCard.classList.remove('fade-out');
+ heldCard.classList.remove('fade-out');
+ handCard.classList.add('fade-in');
+ heldCard.classList.add('fade-in');
+ await this.delay(150);
- // Let discard land and pulse settle
- await this.delay(this.timing.pauseAfterDiscard);
-
- // Step 3: Create second card for the new card coming into hand
- const newAnimCard = this.createAnimCard();
- this.cardManager.cardLayer.appendChild(newAnimCard);
-
- // New card starts at holding/discard position
- this.setCardPosition(newAnimCard, holdingRect || discardRect);
- const newInner = newAnimCard.querySelector('.card-inner');
- const newFront = newAnimCard.querySelector('.card-face-front');
-
- // Show new card (it's face up from the drawn card)
- this.setCardFront(newFront, newCard);
- newInner.classList.remove('flipped');
-
- // Brief anticipation before new card moves
- await this.delay(this.timing.pauseBeforeNewCard);
-
- // Step 4: Move new card to the hand slot
- this.playSound('card');
- newAnimCard.classList.add('moving');
- this.setCardPosition(newAnimCard, slotRect);
- await this.delay(this.timing.moveDuration);
- newAnimCard.classList.remove('moving');
-
- // Breathing room after swap completes
- await this.delay(this.timing.pauseAfterSwapComplete);
- animCard.remove();
- newAnimCard.remove();
+ // Clean up
+ handCard.remove();
+ heldCard.remove();
}
// Create a temporary animation card element
@@ -337,22 +330,47 @@ class AnimationQueue {
animCard.remove();
}
- // Animate drawing from discard
+ // Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) {
- const { playerId } = movement;
-
- // Discard to holding is mostly visual feedback
- // The card "lifts" slightly
+ const { card } = movement;
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return;
- // Just play sound - visual handled by CSS :holding state
- this.playSound('card');
+ // Create animation card at discard position (face UP - visible card)
+ const animCard = this.createAnimCard();
+ this.cardManager.cardLayer.appendChild(animCard);
+ this.setCardPosition(animCard, discardRect);
+ const inner = animCard.querySelector('.card-inner');
+ const front = animCard.querySelector('.card-face-front');
+
+ // Show the card face (discard is always visible)
+ if (card) {
+ this.setCardFront(front, card);
+ }
+ inner.classList.remove('flipped'); // Face up
+
+ // Lift effect before moving - card rises slightly
+ animCard.style.transform = 'translateY(-8px) scale(1.05)';
+ animCard.style.transition = `transform ${this.timing.cardLift}ms ease-out`;
+ await this.delay(this.timing.cardLift);
+
+ // Move to holding position
+ this.playSound('card');
+ animCard.classList.add('moving');
+ animCard.style.transform = '';
+ this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
+ animCard.classList.remove('moving');
+
+ // Brief settle before state updates
+ await this.delay(this.timing.pauseBeforeNewCard);
+
+ // Clean up - renderGame will show the holding card state
+ animCard.remove();
}
// Check if animations are currently playing
diff --git a/client/app.js b/client/app.js
index cf01da2..52a8239 100644
--- a/client/app.js
+++ b/client/app.js
@@ -28,6 +28,9 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
+ // Track opponent swap animation in progress (to apply swap-out class after render)
+ this.opponentSwapAnimation = null; // { playerId, position }
+
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
@@ -417,6 +420,7 @@ class GolfGame {
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
+ this.opponentSwapAnimation = null;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame();
@@ -790,7 +794,6 @@ class GolfGame {
discardDrawn() {
if (!this.drawnCard) return;
const discardedCard = this.drawnCard;
- const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' });
this.drawnCard = null;
this.hideToast();
@@ -803,21 +806,8 @@ class GolfGame {
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
- if (wasFromDeck) {
- // Swoop animation: deck → discard (makes it clear the card is being tossed)
- this.animateDeckToDiscardSwoop(discardedCard);
- } else {
- // Simple drop (drawn from discard, putting it back - though this requires swap usually)
- this.heldCardFloating.classList.add('dropping');
- this.playSound('card');
- setTimeout(() => {
- this.heldCardFloating.classList.add('hidden');
- this.heldCardFloating.classList.remove('dropping');
- this.updateDiscardPileDisplay(discardedCard);
- this.pulseDiscardLand();
- this.skipNextDiscardFlip = true;
- }, 250);
- }
+ // Swoop animation: deck → discard (card is always held over deck)
+ this.animateDeckToDiscardSwoop(discardedCard);
}
// Swoop animation for discarding a card drawn from deck
@@ -885,10 +875,46 @@ class GolfGame {
cancelDraw() {
if (!this.drawnCard) return;
+ const cardToReturn = this.drawnCard;
+ const wasFromDiscard = this.drawnFromDiscard;
this.send({ type: 'cancel_draw' });
this.drawnCard = null;
- this.hideDrawnCard();
this.hideToast();
+
+ if (wasFromDiscard) {
+ // Animate card from deck position back to discard pile
+ this.animateDeckToDiscardReturn(cardToReturn);
+ } else {
+ this.hideDrawnCard();
+ }
+ }
+
+ // Animate returning a card from deck position to discard pile (for cancel draw from discard)
+ animateDeckToDiscardReturn(card) {
+ const discardRect = this.discard.getBoundingClientRect();
+ const floater = this.heldCardFloating;
+
+ // Add swooping class for smooth 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, hide floater and update discard pile
+ setTimeout(() => {
+ floater.classList.add('landed');
+
+ setTimeout(() => {
+ floater.classList.add('hidden');
+ floater.classList.remove('swooping', 'landed');
+ floater.style.cssText = '';
+ this.updateDiscardPileDisplay(card);
+ this.pulseDiscardLand();
+ }, 150);
+ }, 350);
}
swapCard(position) {
@@ -1124,9 +1150,19 @@ class GolfGame {
// Pulse the appropriate pile
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
+
+ // Show CPU action announcement for draw
+ if (oldPlayer.is_cpu) {
+ this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null);
+ }
} else {
// No old discard or couldn't detect - assume deck
this.pulseDrawPile('deck');
+
+ // Show CPU action announcement
+ if (oldPlayer?.is_cpu) {
+ this.showCpuAction(oldPlayer.name, 'draw-deck');
+ }
}
}
@@ -1140,8 +1176,10 @@ class GolfGame {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
+ // Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
+
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
@@ -1162,47 +1200,167 @@ class GolfGame {
break;
}
}
+ // Case 3: Card identity became known (opponent's hidden card was swapped)
+ // This handles race conditions where face_up might not be updated yet
+ if (!oldCard?.rank && newCard?.rank) {
+ swappedPosition = i;
+ wasFaceUp = false;
+ break;
+ }
}
+ // Check if opponent's cards are completely unchanged (server might send split updates)
+ const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
+
if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
- } else if (swappedPosition < 0) {
- // Player drew and discarded without swapping - pulse for everyone
- this.fireDiscardAnimation(newDiscard);
+ // Show CPU swap announcement
+ 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
+ this.fireDiscardAnimation(newDiscard, previousPlayerId);
+ // Show CPU discard announcement
+ if (wasOtherPlayer && oldPlayer?.is_cpu) {
+ this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
+ }
}
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
}
}
- // Note: We don't separately animate card flips for swaps anymore
- // The swap animation handles showing the card at the correct position
+ // 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) {
+ const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
+ const newPlayer = newState.players.find(p => p.id === previousPlayerId);
+
+ if (oldPlayer && newPlayer) {
+ // Check for card changes that indicate a swap we missed
+ for (let i = 0; i < 6; i++) {
+ const oldCard = oldPlayer.cards[i];
+ const newCard = newPlayer.cards[i];
+
+ // Card became visible (swap completed in delayed update)
+ if (!oldCard?.face_up && newCard?.face_up) {
+ this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
+ if (oldPlayer.is_cpu) {
+ this.showCpuAction(oldPlayer.name, 'swap');
+ }
+ break;
+ }
+ // Card identity became known
+ if (!oldCard?.rank && newCard?.rank) {
+ this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
+ if (oldPlayer.is_cpu) {
+ this.showCpuAction(oldPlayer.name, 'swap');
+ }
+ break;
+ }
+ }
+ }
+ }
}
// Flash animation on deck or discard pile to show where opponent drew from
pulseDrawPile(source) {
+ const T = window.TIMING?.feedback || {};
const pile = source === 'discard' ? this.discard : this.deck;
pile.classList.remove('draw-pulse');
// Trigger reflow to restart animation
void pile.offsetWidth;
pile.classList.add('draw-pulse');
// Remove class after animation completes
- setTimeout(() => pile.classList.remove('draw-pulse'), 450);
+ setTimeout(() => pile.classList.remove('draw-pulse'), T.drawPulse || 450);
}
// Pulse discard pile when a card lands on it
pulseDiscardLand() {
+ const T = window.TIMING?.feedback || {};
this.discard.classList.remove('discard-land');
void this.discard.offsetWidth;
this.discard.classList.add('discard-land');
- setTimeout(() => this.discard.classList.remove('discard-land'), 460);
+ setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460);
}
// Fire animation for discard without swap (card lands on discard pile face-up)
- fireDiscardAnimation(discardCard) {
- // Card is already known - just pulse to show it landed (no flip needed)
- this.pulseDiscardLand();
+ // Shows card moving from deck to discard for other players only
+ fireDiscardAnimation(discardCard, fromPlayerId = null) {
+ // 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);
+ }
+ // 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
@@ -1225,6 +1383,8 @@ class GolfGame {
// 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
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');
@@ -1251,9 +1411,10 @@ class GolfGame {
if (wasFaceUp && sourceCardEl) {
sourceCardEl.classList.add('swap-pulse');
this.playSound('card');
+ const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
setTimeout(() => {
sourceCardEl.classList.remove('swap-pulse');
- }, 400);
+ }, pulseDuration);
return;
}
@@ -1291,26 +1452,21 @@ class GolfGame {
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
+ // Use centralized timing - no beat pauses, continuous flow
+ const flipTime = window.TIMING?.card?.flip || 400;
+
// Step 1: Flip to reveal the hidden card
- setTimeout(() => {
- swapCard.classList.add('flipping');
- this.playSound('flip');
- }, 50);
+ swapCard.classList.add('flipping');
+ this.playSound('flip');
- // Step 2: After flip, pause to see the card then pulse before being replaced
- setTimeout(() => {
- swapCard.classList.add('swap-pulse');
- this.playSound('card');
- }, 850);
-
- // Step 3: Strategic pause to show discarded card, then complete
+ // Step 2: After flip completes, clean up immediately
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
- swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
+ swapCard.classList.remove('flipping');
swapCard.style.transform = '';
- if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
+ this.opponentSwapAnimation = null;
this.pulseDiscardLand();
- }, 1400);
+ }, flipTime);
}
// Fire a flip animation for local player's card (non-blocking)
@@ -1351,17 +1507,21 @@ class GolfGame {
cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
+ // Use centralized timing
+ const preFlip = window.TIMING?.pause?.beforeFlip || 50;
+ const flipDuration = window.TIMING?.card?.flip || 540;
+
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
- }, 50);
+ }, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
- }, 550);
+ }, preFlip + flipDuration);
}
// Fire a flip animation for opponent card (non-blocking)
@@ -1420,10 +1580,14 @@ class GolfGame {
cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
+ // Use centralized timing
+ const preFlip = window.TIMING?.pause?.beforeFlip || 50;
+ const flipDuration = window.TIMING?.card?.flip || 540;
+
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
- }, 60);
+ }, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
@@ -1431,7 +1595,7 @@ class GolfGame {
swapCard.style.transform = '';
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
- }, 560);
+ }, preFlip + flipDuration);
}
handleCardClick(position) {
@@ -1706,6 +1870,36 @@ class GolfGame {
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
}
+ // Show CPU action announcement in status bar
+ showCpuAction(playerName, action, card = null) {
+ const suitSymbol = card ? this.getSuitSymbol(card.suit) : '';
+ const messages = {
+ 'draw-deck': `${playerName} draws from deck`,
+ 'draw-discard': card ? `${playerName} takes ${card.rank}${suitSymbol}` : `${playerName} takes from discard`,
+ 'swap': `${playerName} swaps a card`,
+ 'discard': card ? `${playerName} discards ${card.rank}${suitSymbol}` : `${playerName} discards`,
+ };
+ const message = messages[action];
+ if (message) {
+ this.setStatus(message, 'cpu-action');
+ }
+ }
+
+ // Update CPU considering visual state on discard pile
+ 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;
+
+ if (isCpuTurn && hasNotDrawn) {
+ this.discard.classList.add('cpu-considering');
+ } else {
+ this.discard.classList.remove('cpu-considering');
+ }
+ }
+
showToast(message, type = '', duration = 2500) {
// For compatibility - just set the status message
this.setStatus(message, type);
@@ -1772,13 +1966,50 @@ class GolfGame {
}
showDrawnCard() {
- // Show drawn card floating over the discard pile (larger, closer to viewer)
+ // Show drawn card floating over the draw pile (deck), regardless of source
const card = this.drawnCard;
+ this.displayHeldCard(card, true);
+ }
+
+ // Display held card floating above and between deck and discard - for any player
+ // isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow)
+ displayHeldCard(card, isLocalPlayerHolding) {
+ if (!card) {
+ this.hideDrawnCard();
+ return;
+ }
// Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
+
+ // Position centered above and between deck and discard
+ const deckRect = this.deck.getBoundingClientRect();
+ const discardRect = this.discard.getBoundingClientRect();
+
+ // Calculate center point between deck and discard
+ const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
+ const cardWidth = deckRect.width;
+ const cardHeight = deckRect.height;
+
+ // Position card centered, overlapping both piles (lower than before)
+ const overlapOffset = cardHeight * 0.35; // More overlap = lower position
+ const cardLeft = centerX - cardWidth / 2;
+ const cardTop = deckRect.top - overlapOffset;
+ this.heldCardFloating.style.left = `${cardLeft}px`;
+ this.heldCardFloating.style.top = `${cardTop}px`;
+ this.heldCardFloating.style.width = `${cardWidth}px`;
+ this.heldCardFloating.style.height = `${cardHeight}px`;
+
+ // Position discard button attached to right side of held card
+ const scaledWidth = cardWidth * 1.15; // Account for scale transform
+ const scaledHeight = cardHeight * 1.15;
+ const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap)
+ const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card
+ this.discardBtn.style.left = `${buttonLeft}px`;
+ this.discardBtn.style.top = `${buttonTop}px`;
+
if (card.rank === '★') {
this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
@@ -1792,17 +2023,29 @@ class GolfGame {
this.heldCardFloatingContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
}
- // Show the floating card and discard button
+ // Show the floating card
this.heldCardFloating.classList.remove('hidden');
- this.discardBtn.classList.remove('hidden');
+
+ // Add pulse glow if it's local player's turn to act on the card
+ if (isLocalPlayerHolding) {
+ this.heldCardFloating.classList.add('your-turn-pulse');
+ this.discardBtn.classList.remove('hidden');
+ } else {
+ this.heldCardFloating.classList.remove('your-turn-pulse');
+ this.discardBtn.classList.add('hidden');
+ }
}
hideDrawnCard() {
// Hide the floating held card
this.heldCardFloating.classList.add('hidden');
+ this.heldCardFloating.classList.remove('your-turn-pulse');
// Clear any inline styles from animations
this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden');
+ // Clear button positioning
+ this.discardBtn.style.left = '';
+ this.discardBtn.style.top = '';
}
isRedSuit(suit) {
@@ -1869,6 +2112,9 @@ class GolfGame {
renderGame() {
if (!this.gameState) return;
+ // Update CPU considering visual state
+ this.updateCpuConsideringState();
+
// Update header
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
@@ -1912,8 +2158,10 @@ class GolfGame {
}
// Update discard pile
- if (this.drawnCard) {
- // Holding a drawn card - show discard pile as greyed/disabled
+ // Check if ANY player is holding a card (local or remote/CPU)
+ const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
+ if (anyPlayerHolding) {
+ // Someone is holding a drawn card - show discard pile as greyed/disabled
// If drawn from discard, show what's underneath (new discard_top or empty)
// If drawn from deck, show current discard_top greyed
this.discard.classList.add('picked-up');
@@ -1941,7 +2189,10 @@ class GolfGame {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
- if (this.gameState.discard_top) {
+ // Skip discard update during opponent swap animation - animation handles the visual
+ if (this.opponentSwapAnimation) {
+ // Don't update discard content; animation overlay shows the swap
+ } else if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
@@ -1973,7 +2224,8 @@ class GolfGame {
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
- setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
+ const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560;
+ setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration);
}
} else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
@@ -1983,6 +2235,20 @@ class GolfGame {
this.discardBtn.classList.add('hidden');
}
+ // Show held card for ANY player who has drawn (consistent visual regardless of whose turn)
+ // Local player uses this.drawnCard, others use gameState.drawn_card
+ if (this.drawnCard) {
+ // Local player is holding - show with pulse and discard button
+ this.displayHeldCard(this.drawnCard, true);
+ } else if (this.gameState.drawn_card && this.gameState.drawn_player_id) {
+ // Another player is holding - show without pulse/button
+ const isLocalPlayer = this.gameState.drawn_player_id === this.playerId;
+ this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
+ } else {
+ // No one holding a card
+ this.hideDrawnCard();
+ }
+
// Update deck/discard clickability and visual state
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
@@ -1991,13 +2257,11 @@ class GolfGame {
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
this.deck.classList.toggle('clickable', canDraw);
- // Only show disabled on deck when it's my turn and I've drawn
- this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
+ // Show disabled on deck when any player has drawn (consistent dimmed look)
+ this.deck.classList.toggle('disabled', hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
- // Only show disabled state when it's my turn and I've drawn (not holding visible card)
- // Don't grey out when opponents are playing
- this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
+ // Disabled state handled by picked-up class when anyone is holding
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
@@ -2047,8 +2311,11 @@ class GolfGame {
? { ...card, face_up: true }
: card;
+ // Check if clickable during initial flip
+ const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
+
const isClickable = (
- (this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
+ isInitialFlipClickable ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
@@ -2056,6 +2323,12 @@ class GolfGame {
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
+
+ // Add pulse animation during initial flip phase
+ if (isInitialFlipClickable) {
+ cardEl.firstChild.classList.add('initial-flip-pulse');
+ }
+
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(cardEl.firstChild);
});
@@ -2496,7 +2769,8 @@ class GolfGame {
navigator.clipboard.writeText(shareText).then(() => {
const btn = document.getElementById('share-results-btn');
btn.textContent = '✓ Copied!';
- setTimeout(() => btn.textContent = '📋 Copy Results', 2000);
+ const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000;
+ setTimeout(() => btn.textContent = '📋 Copy Results', copyDelay);
});
});
diff --git a/client/style.css b/client/style.css
index c54bf48..8ad5cd3 100644
--- a/client/style.css
+++ b/client/style.css
@@ -1011,13 +1011,14 @@ input::placeholder {
display: none !important;
}
-/* Held card floating over discard pile (larger, closer to viewer) */
+/* Held card floating above and between deck and discard (larger, closer to viewer) */
.held-card-floating {
- position: absolute;
+ position: fixed;
top: 0;
left: 0;
z-index: 100;
- transform: scale(1.2) translateY(-12px);
+ transform: scale(1.15);
+ transform-origin: center bottom;
border: 3px solid #f4a460 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none;
@@ -1026,33 +1027,26 @@ input::placeholder {
.held-card-floating.hidden {
opacity: 0;
- transform: scale(1) translateY(0);
+ transform: scale(0.9);
pointer-events: none;
}
/* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping {
- transform: scale(1) translateY(0);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
- transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
+ transition: border-color 0.3s ease-out, box-shadow 0.3s ease-out;
}
/* Swoop animation for deck → immediate discard */
.held-card-floating.swooping {
- transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
- top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
- transform 0.35s ease-out,
- border-color 0.35s ease-out,
- box-shadow 0.35s ease-out;
- transform: scale(1.15) rotate(-8deg);
- border-color: rgba(244, 164, 96, 0.8) !important;
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
+ transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
+ top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
+ width 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
+ height 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.held-card-floating.swooping.landed {
- transform: scale(1) rotate(0deg);
- border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
@@ -1080,11 +1074,10 @@ input::placeholder {
transform: scale(1.05);
}
-/* Picked-up state - showing card underneath after drawing from discard */
+/* Picked-up state - dimmed when someone is holding a card */
#discard.picked-up {
opacity: 0.5;
filter: grayscale(40%);
- transform: scale(0.95);
}
.discard-stack {
@@ -1092,13 +1085,40 @@ input::placeholder {
flex-direction: column;
align-items: center;
gap: 8px;
- position: relative;
}
.discard-stack .btn {
white-space: nowrap;
}
+/* Discard button as a tab attached to right side of held card */
+#discard-btn {
+ position: fixed;
+ z-index: 101;
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ padding: 16px 8px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ border-radius: 0 8px 8px 0;
+ background: linear-gradient(90deg, #e8914d 0%, #f4a460 100%);
+ color: #1a472a;
+ box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3);
+ border: none;
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s, opacity 0.15s;
+}
+
+#discard-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 3px 3px 12px rgba(0, 0, 0, 0.4);
+}
+
+#discard-btn:active {
+ transform: scale(0.98);
+}
+
#deck.disabled,
#discard.disabled {
opacity: 0.5;
@@ -1141,53 +1161,65 @@ input::placeholder {
}
}
-/* Card flip animation for discard pile */
+/* Card appearing on discard pile */
.card-flip-in {
- animation: cardFlipIn 0.56s ease-out;
+ animation: cardFlipIn 0.25s ease-out;
}
@keyframes cardFlipIn {
- 0% {
- transform: scale(1.4) translateY(-20px);
- opacity: 0;
- box-shadow: 0 0 40px rgba(244, 164, 96, 1);
- }
- 30% {
- transform: scale(1.25) translateY(-10px);
- opacity: 1;
- box-shadow: 0 0 35px rgba(244, 164, 96, 0.9);
- }
- 70% {
- transform: scale(1.1) translateY(0);
- box-shadow: 0 0 20px rgba(244, 164, 96, 0.5);
- }
- 100% {
- transform: scale(1) translateY(0);
- opacity: 1;
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
- }
+ from { opacity: 0.5; }
+ to { opacity: 1; }
}
-/* Discard pile pulse when card lands */
+/* Discard pile pulse when card lands - simple glow */
#discard.discard-land {
- animation: discardLand 0.46s ease-out;
+ animation: discardLand 0.3s ease-out;
}
@keyframes discardLand {
0% {
- transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
- 40% {
- transform: scale(1.18);
- box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
+ 50% {
+ box-shadow: 0 0 20px rgba(244, 164, 96, 0.8);
}
100% {
- transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
+/* CPU considering discard pile - subtle blue glow pulse */
+#discard.cpu-considering {
+ animation: cpuConsider 1.5s ease-in-out infinite;
+}
+
+@keyframes cpuConsider {
+ 0%, 100% {
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ }
+ 50% {
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3),
+ 0 0 18px rgba(59, 130, 246, 0.5);
+ }
+}
+
+/* Discard pickup animation - simple dim */
+#discard.discard-pickup {
+ animation: discardPickup 0.25s ease-out;
+}
+
+@keyframes discardPickup {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
/* Swap animation overlay */
.swap-animation {
position: fixed;
@@ -1197,7 +1229,6 @@ input::placeholder {
height: 100%;
pointer-events: none;
z-index: 1000;
- perspective: 1000px;
}
.swap-animation.hidden {
@@ -1208,7 +1239,7 @@ input::placeholder {
position: absolute;
width: 70px;
height: 98px;
- perspective: 1000px;
+ perspective: 800px;
}
.swap-card.hidden {
@@ -1219,8 +1250,9 @@ input::placeholder {
position: relative;
width: 100%;
height: 100%;
+ border-radius: 8px;
transform-style: preserve-3d;
- transition: transform 0.54s ease-in-out;
+ transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.swap-card.flipping .swap-card-inner {
@@ -1232,13 +1264,13 @@ input::placeholder {
position: absolute;
width: 100%;
height: 100%;
- backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
+ backface-visibility: hidden;
}
.swap-card-back {
@@ -1248,17 +1280,18 @@ input::placeholder {
}
.swap-card-front {
- background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
- transform: rotateY(180deg);
- font-size: 2rem;
+ background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
+ border: 2px solid #ddd;
+ font-size: clamp(1.8rem, 2.2vw, 2.8rem);
flex-direction: column;
- color: #2c3e50;
+ color: #333;
line-height: 1.1;
font-weight: bold;
+ transform: rotateY(180deg);
}
.swap-card-front.red {
- color: #e74c3c;
+ color: #c0392b;
}
.swap-card-front.black {
@@ -1270,25 +1303,20 @@ input::placeholder {
}
.swap-card-front .joker-icon {
- font-size: 1.6em;
+ font-size: 1.5em;
line-height: 1;
}
.swap-card-front .joker-label {
- font-size: 0.45em;
+ font-size: 0.4em;
font-weight: 700;
text-transform: uppercase;
-}
-
-/* Movement animation */
-.swap-card.flipping {
- filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
+ letter-spacing: 0.05em;
}
.swap-card.moving {
- transition: top 0.45s cubic-bezier(0.4, 0, 0.2, 1), left 0.45s cubic-bezier(0.4, 0, 0.2, 1), transform 0.45s ease-out;
- transform: scale(1.1) rotate(-5deg);
- filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
+ transition: top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
+ left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
/* Card in hand fading during swap */
@@ -1328,6 +1356,51 @@ input::placeholder {
}
}
+/* Fade transitions for swap animation */
+.card.fade-out,
+.held-card-floating.fade-out,
+.anim-card.fade-out {
+ opacity: 0;
+ transition: opacity 0.3s ease-out;
+}
+
+.card.fade-in,
+.held-card-floating.fade-in,
+.anim-card.fade-in {
+ opacity: 1;
+ transition: opacity 0.3s ease-in;
+}
+
+/* Pulse animation for clickable cards during initial flip phase */
+.card.clickable.initial-flip-pulse {
+ animation: initialFlipPulse 1.5s ease-in-out infinite;
+}
+
+@keyframes initialFlipPulse {
+ 0%, 100% {
+ box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
+ }
+ 50% {
+ box-shadow: 0 0 0 4px rgba(244, 164, 96, 0.8),
+ 0 0 15px rgba(244, 164, 96, 0.4);
+ }
+}
+
+/* Held card pulse glow for local player's turn */
+.held-card-floating.your-turn-pulse {
+ animation: heldCardPulse 1.5s ease-in-out infinite;
+}
+
+@keyframes heldCardPulse {
+ 0%, 100% {
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7);
+ }
+ 50% {
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
+ 0 0 50px rgba(244, 164, 96, 0.5);
+ }
+}
+
/* Player Area */
.player-section {
text-align: center;
@@ -1435,6 +1508,12 @@ input::placeholder {
color: #2d3436;
}
+/* 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 {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
@@ -1631,7 +1710,9 @@ input::placeholder {
}
.game-buttons {
- margin-bottom: 8px;
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
gap: 5px;
@@ -1938,14 +2019,14 @@ input::placeholder {
display: none;
}
-/* Real Card - persistent card element with 3D structure */
+/* Real Card - persistent card element with 3D flip */
.real-card {
position: fixed;
border-radius: 6px;
- perspective: 1000px;
z-index: 501;
cursor: pointer;
- transition: box-shadow 0.2s, opacity 0.2s;
+ transition: box-shadow 0.3s ease-out, opacity 0.3s ease-out;
+ perspective: 800px;
}
.real-card:hover {
@@ -1956,8 +2037,9 @@ input::placeholder {
position: relative;
width: 100%;
height: 100%;
+ border-radius: 6px;
transform-style: preserve-3d;
- transition: transform 0.54s ease-in-out;
+ transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.real-card .card-inner.flipped {
@@ -1968,7 +2050,6 @@ input::placeholder {
position: absolute;
width: 100%;
height: 100%;
- backface-visibility: hidden;
border-radius: 6px;
display: flex;
flex-direction: column;
@@ -1976,6 +2057,7 @@ input::placeholder {
justify-content: center;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ backface-visibility: hidden;
}
/* Card Front */
@@ -1984,7 +2066,7 @@ input::placeholder {
border: 2px solid #ddd;
color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
- line-height: 1.1;
+ line-height: 0.95;
}
.real-card .card-face-front.red {
@@ -2031,11 +2113,8 @@ input::placeholder {
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
- transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
- top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
- transform 0.27s ease-out;
- filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
- transform: scale(1.08) rotate(-3deg);
+ transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
+ top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
/* Animation card - temporary cards used for animations */
@@ -2045,14 +2124,13 @@ input::placeholder {
}
.real-card.anim-card .card-inner {
- transition: transform 0.54s ease-in-out;
+ transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.real-card.holding {
z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4);
- transform: scale(1.08);
}
.real-card.clickable {
@@ -2062,7 +2140,6 @@ input::placeholder {
.real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3);
- transform: scale(1.02);
}
/* Disable hover effects when not player's turn */
@@ -2077,7 +2154,6 @@ input::placeholder {
.real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
- transform: scale(1.06);
z-index: 520;
}
diff --git a/client/timing-config.js b/client/timing-config.js
new file mode 100644
index 0000000..1638bd7
--- /dev/null
+++ b/client/timing-config.js
@@ -0,0 +1,76 @@
+// Centralized timing configuration for all animations and pauses
+// Edit these values to tune the feel of card animations and CPU gameplay
+
+const TIMING = {
+ // Card animations (milliseconds) - smooth, unhurried
+ card: {
+ flip: 400, // Card flip duration (must match CSS transition)
+ move: 400, // Card movement - slower = smoother
+ lift: 0, // No lift pause
+ moving: 400, // Card moving class duration
+ },
+
+ // Pauses - minimal, let animations flow
+ pause: {
+ afterFlip: 0, // No pause - flow into next action
+ afterDiscard: 100, // Brief settle
+ beforeNewCard: 0, // No pause
+ afterSwapComplete: 100, // Brief settle
+ betweenAnimations: 0, // No gaps - continuous flow
+ beforeFlip: 0, // No pause
+ },
+
+ // Beat timing for animation phases (~1.2 sec with variance)
+ beat: {
+ base: 1200, // Base beat duration (longer to see results)
+ variance: 200, // +/- variance for natural feel
+ fadeOut: 300, // Fade out duration
+ fadeIn: 300, // Fade in duration
+ },
+
+ // UI feedback durations (milliseconds)
+ feedback: {
+ drawPulse: 300, // Draw pile highlight duration
+ discardLand: 300, // Discard land effect duration
+ cardFlipIn: 300, // Card flip-in effect duration
+ statusMessage: 2000, // Toast/status message duration
+ copyConfirm: 2000, // Copy button confirmation duration
+ discardPickup: 250, // Discard pickup animation duration
+ },
+
+ // CSS animation timing (for reference - actual values in style.css)
+ css: {
+ cpuConsidering: 1500, // CPU considering pulse cycle
+ },
+
+ // Card manager specific
+ cardManager: {
+ flipDuration: 400, // Card flip animation
+ moveDuration: 400, // Card move animation
+ },
+
+ // Player swap animation steps - smooth continuous motion
+ playerSwap: {
+ flipToReveal: 400, // Initial flip to show card
+ pauseAfterReveal: 50, // Tiny beat to register the card
+ moveToDiscard: 400, // Move old card to discard
+ pulseBeforeSwap: 0, // No pulse - just flow
+ completePause: 50, // Tiny settle
+ },
+};
+
+// Helper to get beat duration with variance
+function getBeatDuration() {
+ const base = TIMING.beat.base;
+ const variance = TIMING.beat.variance;
+ return base + (Math.random() * variance * 2 - variance);
+}
+
+// Export for module systems, also attach to window for direct use
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = TIMING;
+}
+if (typeof window !== 'undefined') {
+ window.TIMING = TIMING;
+ window.getBeatDuration = getBeatDuration;
+}
diff --git a/server/ai.py b/server/ai.py
index 980485d..73fbcc7 100644
--- a/server/ai.py
+++ b/server/ai.py
@@ -33,6 +33,40 @@ def ai_log(message: str):
ai_logger.debug(message)
+# =============================================================================
+# CPU Turn Timing Configuration (seconds)
+# =============================================================================
+# Centralized timing constants for all CPU turn delays.
+# Adjust these values to tune the "feel" of CPU gameplay.
+
+CPU_TIMING = {
+ # Delay before CPU "looks at" the discard pile
+ "initial_look": (0.5, 0.7),
+ # Brief pause after draw broadcast
+ "post_draw_settle": 0.05,
+ # Consideration time after drawing (before swap/discard decision)
+ "post_draw_consider": (0.2, 0.4),
+ # Variance multiplier range for chaotic personality players
+ "thinking_multiplier_chaotic": (0.6, 1.4),
+ # Pause after swap/discard to let animation complete and show result
+ "post_action_pause": (0.5, 0.7),
+}
+
+# Thinking time ranges by card difficulty (seconds)
+THINKING_TIME = {
+ # Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
+ "easy_good": (0.2, 0.4),
+ # Obviously bad cards (10s, Jacks, Queens) - easy pass
+ "easy_bad": (0.2, 0.4),
+ # Medium difficulty (3, 4, 8, 9)
+ "medium": (0.2, 0.4),
+ # Hardest decisions (5, 6, 7 - middle of range)
+ "hard": (0.2, 0.4),
+ # No discard available - quick decision
+ "no_card": (0.2, 0.4),
+}
+
+
# Alias for backwards compatibility - use the centralized function from game.py
def get_ai_card_value(card: Card, options: GameOptions) -> int:
"""Get card value with house rules applied for AI decisions.
@@ -50,32 +84,37 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
- Easy decisions (obviously good or bad cards) = quick (400-600ms)
- Hard decisions (medium value cards) = slower (900-1100ms)
+ Easy decisions (obviously good or bad cards) = quick
+ Hard decisions (medium value cards) = slower
- Returns time in seconds.
+ Returns time in seconds. Uses THINKING_TIME constants.
"""
if not card:
# No discard available - quick decision to draw from deck
- return random.uniform(0.4, 0.5)
+ t = THINKING_TIME["no_card"]
+ return random.uniform(t[0], t[1])
value = get_card_value(card, options)
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
if value <= 1:
- return random.uniform(0.4, 0.6)
+ t = THINKING_TIME["easy_good"]
+ return random.uniform(t[0], t[1])
# Obviously bad cards (easy pass): 10, J, Q (value 10)
if value >= 10:
- return random.uniform(0.4, 0.6)
+ t = THINKING_TIME["easy_bad"]
+ return random.uniform(t[0], t[1])
# Medium cards require more thought: 3-9
# 5, 6, 7 are the hardest decisions (middle of the range)
if value in (5, 6, 7):
- return random.uniform(0.9, 1.1)
+ t = THINKING_TIME["hard"]
+ return random.uniform(t[0], t[1])
# 3, 4, 8, 9 - moderate difficulty
- return random.uniform(0.6, 0.85)
+ t = THINKING_TIME["medium"]
+ return random.uniform(t[0], t[1])
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
@@ -1316,7 +1355,8 @@ async def process_cpu_turn(
logger = get_logger() if game_id else None
# Brief initial delay before CPU "looks at" the discard pile
- await asyncio.sleep(random.uniform(0.08, 0.15))
+ initial_look = CPU_TIMING["initial_look"]
+ await asyncio.sleep(random.uniform(initial_look[0], initial_look[1]))
# "Thinking" delay based on how obvious the discard decision is
# Easy decisions (good/bad cards) are quick, medium cards take longer
@@ -1325,7 +1365,8 @@ async def process_cpu_turn(
# Adjust for personality - chaotic players have more variance
if profile.unpredictability > 0.2:
- thinking_time *= random.uniform(0.6, 1.4)
+ chaos_mult = CPU_TIMING["thinking_multiplier_chaotic"]
+ thinking_time *= random.uniform(chaos_mult[0], chaos_mult[1])
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
@@ -1397,8 +1438,10 @@ async def process_cpu_turn(
await broadcast_callback()
# Brief pause after draw to let the flash animation register visually
- await asyncio.sleep(0.08)
- await asyncio.sleep(0.35 + random.uniform(0, 0.35))
+ await asyncio.sleep(CPU_TIMING["post_draw_settle"])
+ # Consideration time before swap/discard decision
+ consider = CPU_TIMING["post_draw_consider"]
+ await asyncio.sleep(consider[0] + random.uniform(0, consider[1] - consider[0]))
# Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
@@ -1535,3 +1578,7 @@ async def process_cpu_turn(
)
await broadcast_callback()
+
+ # Pause to let client animation complete and show result before next turn
+ post_action = CPU_TIMING["post_action_pause"]
+ await asyncio.sleep(random.uniform(post_action[0], post_action[1]))