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]))