Timing and animation changes for a more natural feeling game with CPU opps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-01-31 12:22:54 -05:00
parent 6950769bc3
commit 7b64b8c17c
5 changed files with 715 additions and 224 deletions

View File

@ -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

View File

@ -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)
// Swoop animation: deck → discard (card is always held over deck)
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 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)
// 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 = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
// 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 = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
front.classList.add(this.isRedSuit(card.suit) ? 'red' : 'black');
front.innerHTML = `${card.rank}<br>${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);
// 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}<br>${this.getSuitSymbol(card.suit)}`;
}
// Show the floating card and discard button
// Show the floating card
this.heldCardFloating.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);
});
});

View File

@ -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;
}

76
client/timing-config.js Normal file
View File

@ -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;
}

View File

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