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.processing = false;
this.animationInProgress = false; this.animationInProgress = false;
// Timing configuration (ms) // Timing configuration (ms) - use centralized TIMING config
// Rhythm: action → settle → action → breathe const T = window.TIMING || {};
this.timing = { this.timing = {
flipDuration: 540, // Must match CSS .card-inner transition (0.54s) flipDuration: T.card?.flip || 540,
moveDuration: 270, moveDuration: T.card?.move || 270,
pauseAfterFlip: 144, // Brief settle after flip before move cardLift: T.card?.lift || 100,
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle pauseAfterFlip: T.pause?.afterFlip || 144,
pauseBeforeNewCard: 150, // Anticipation before new card moves in pauseAfterDiscard: T.pause?.afterDiscard || 550,
pauseAfterSwapComplete: 400, // Breathing room after swap completes pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
pauseBetweenAnimations: 90 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 // Animate the flip
this.playSound('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 // Remove flipped to trigger animation to front
inner.classList.remove('flipped'); inner.classList.remove('flipped');
@ -136,11 +143,10 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate a card swap (hand card to discard, drawn card to hand) // Animate a card swap - smooth continuous motion
async animateSwap(movement) { async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement; const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position); const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
@ -149,67 +155,54 @@ class AnimationQueue {
return; return;
} }
// Create a temporary card element for the animation // Create animation cards
const animCard = this.createAnimCard(); const handCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard); this.cardManager.cardLayer.appendChild(handCard);
this.setCardPosition(handCard, slotRect);
// Position at slot const handInner = handCard.querySelector('.card-inner');
this.setCardPosition(animCard, slotRect); const handFront = handCard.querySelector('.card-face-front');
// Start face down (showing back) const heldCard = this.createAnimCard();
const inner = animCard.querySelector('.card-inner'); this.cardManager.cardLayer.appendChild(heldCard);
const front = animCard.querySelector('.card-face-front'); this.setCardPosition(heldCard, holdingRect || discardRect);
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it const heldInner = heldCard.querySelector('.card-inner');
this.setCardFront(front, oldCard); 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) { if (!oldCard.face_up) {
this.playSound('flip'); this.playSound('flip');
inner.classList.remove('flipped'); handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration); 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'); this.playSound('card');
animCard.classList.add('moving'); handCard.classList.remove('fade-out');
this.setCardPosition(animCard, discardRect); heldCard.classList.remove('fade-out');
await this.delay(this.timing.moveDuration); handCard.classList.add('fade-in');
animCard.classList.remove('moving'); heldCard.classList.add('fade-in');
await this.delay(150);
// Let discard land and pulse settle // Clean up
await this.delay(this.timing.pauseAfterDiscard); handCard.remove();
heldCard.remove();
// 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();
} }
// Create a temporary animation card element // Create a temporary animation card element
@ -337,22 +330,47 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate drawing from discard // Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) { async animateDrawDiscard(movement) {
const { playerId } = movement; const { card } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return; if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state // Create animation card at discard position (face UP - visible card)
this.playSound('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); 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 // Check if animations are currently playing

View File

@ -28,6 +28,9 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements // Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set(); 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 // Track round winners for visual highlight
this.roundWinnerNames = new Set(); this.roundWinnerNames = new Set();
@ -417,6 +420,7 @@ class GolfGame {
this.locallyFlippedCards = new Set(); this.locallyFlippedCards = new Set();
this.selectedCards = []; this.selectedCards = [];
this.animatingPositions = new Set(); this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.playSound('shuffle'); this.playSound('shuffle');
this.showGameScreen(); this.showGameScreen();
this.renderGame(); this.renderGame();
@ -790,7 +794,6 @@ class GolfGame {
discardDrawn() { discardDrawn() {
if (!this.drawnCard) return; if (!this.drawnCard) return;
const discardedCard = this.drawnCard; const discardedCard = this.drawnCard;
const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' }); this.send({ type: 'discard' });
this.drawnCard = null; this.drawnCard = null;
this.hideToast(); this.hideToast();
@ -803,21 +806,8 @@ class GolfGame {
// Also update lastDiscardKey so renderGame() won't see a "change" // Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
if (wasFromDeck) { // Swoop animation: deck → discard (card is always held over deck)
// Swoop animation: deck → discard (makes it clear the card is being tossed) this.animateDeckToDiscardSwoop(discardedCard);
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 // Swoop animation for discarding a card drawn from deck
@ -885,10 +875,46 @@ class GolfGame {
cancelDraw() { cancelDraw() {
if (!this.drawnCard) return; if (!this.drawnCard) return;
const cardToReturn = this.drawnCard;
const wasFromDiscard = this.drawnFromDiscard;
this.send({ type: 'cancel_draw' }); this.send({ type: 'cancel_draw' });
this.drawnCard = null; this.drawnCard = null;
this.hideDrawnCard();
this.hideToast(); 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) { swapCard(position) {
@ -1124,9 +1150,19 @@ class GolfGame {
// Pulse the appropriate pile // Pulse the appropriate pile
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck'); 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 { } else {
// No old discard or couldn't detect - assume deck // No old discard or couldn't detect - assume deck
this.pulseDrawPile('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 // Find the position that changed
// Could be: face-down -> face-up (new reveal) // Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card) // Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1; let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i]; const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i]; const newCard = newPlayer.cards[i];
@ -1162,47 +1200,167 @@ class GolfGame {
break; 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) { if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
} else if (swappedPosition < 0) { // Show CPU swap announcement
// Player drew and discarded without swapping - pulse for everyone if (oldPlayer.is_cpu) {
this.fireDiscardAnimation(newDiscard); 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 // Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true; this.skipNextDiscardFlip = true;
} }
} }
// Note: We don't separately animate card flips for swaps anymore // Handle delayed card updates (server sends split updates: discard first, then cards)
// The swap animation handles showing the card at the correct position // 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 // Flash animation on deck or discard pile to show where opponent drew from
pulseDrawPile(source) { pulseDrawPile(source) {
const T = window.TIMING?.feedback || {};
const pile = source === 'discard' ? this.discard : this.deck; const pile = source === 'discard' ? this.discard : this.deck;
pile.classList.remove('draw-pulse'); pile.classList.remove('draw-pulse');
// Trigger reflow to restart animation // Trigger reflow to restart animation
void pile.offsetWidth; void pile.offsetWidth;
pile.classList.add('draw-pulse'); pile.classList.add('draw-pulse');
// Remove class after animation completes // 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 // Pulse discard pile when a card lands on it
pulseDiscardLand() { pulseDiscardLand() {
const T = window.TIMING?.feedback || {};
this.discard.classList.remove('discard-land'); this.discard.classList.remove('discard-land');
void this.discard.offsetWidth; void this.discard.offsetWidth;
this.discard.classList.add('discard-land'); 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) // Fire animation for discard without swap (card lands on discard pile face-up)
fireDiscardAnimation(discardCard) { // Shows card moving from deck to discard for other players only
// Card is already known - just pulse to show it landed (no flip needed) fireDiscardAnimation(discardCard, fromPlayerId = null) {
this.pulseDiscardLand(); // 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 // 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 // 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 // Uses flip-in-place for face-down cards, subtle pulse for face-up cards
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) { 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 // Find source position - the actual card that was swapped
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
@ -1251,9 +1411,10 @@ class GolfGame {
if (wasFaceUp && sourceCardEl) { if (wasFaceUp && sourceCardEl) {
sourceCardEl.classList.add('swap-pulse'); sourceCardEl.classList.add('swap-pulse');
this.playSound('card'); this.playSound('card');
const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
setTimeout(() => { setTimeout(() => {
sourceCardEl.classList.remove('swap-pulse'); sourceCardEl.classList.remove('swap-pulse');
}, 400); }, pulseDuration);
return; return;
} }
@ -1291,26 +1452,21 @@ class GolfGame {
if (sourceCardEl) sourceCardEl.classList.add('swap-out'); if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden'); 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 // Step 1: Flip to reveal the hidden card
setTimeout(() => { swapCard.classList.add('flipping');
swapCard.classList.add('flipping'); this.playSound('flip');
this.playSound('flip');
}, 50);
// Step 2: After flip, pause to see the card then pulse before being replaced // Step 2: After flip completes, clean up immediately
setTimeout(() => {
swapCard.classList.add('swap-pulse');
this.playSound('card');
}, 850);
// Step 3: Strategic pause to show discarded card, then complete
setTimeout(() => { setTimeout(() => {
this.swapAnimation.classList.add('hidden'); this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving', 'swap-pulse'); swapCard.classList.remove('flipping');
swapCard.style.transform = ''; swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); this.opponentSwapAnimation = null;
this.pulseDiscardLand(); this.pulseDiscardLand();
}, 1400); }, flipTime);
} }
// Fire a flip animation for local player's card (non-blocking) // Fire a flip animation for local player's card (non-blocking)
@ -1351,17 +1507,21 @@ class GolfGame {
cardEl.classList.add('swap-out'); cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden'); this.swapAnimation.classList.remove('hidden');
// Use centralized timing
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
const flipDuration = window.TIMING?.card?.flip || 540;
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('flipping'); swapCard.classList.add('flipping');
this.playSound('flip'); this.playSound('flip');
}, 50); }, preFlip);
setTimeout(() => { setTimeout(() => {
this.swapAnimation.classList.add('hidden'); this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping'); swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out'); cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, 550); }, preFlip + flipDuration);
} }
// Fire a flip animation for opponent card (non-blocking) // Fire a flip animation for opponent card (non-blocking)
@ -1420,10 +1580,14 @@ class GolfGame {
cardEl.classList.add('swap-out'); cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden'); this.swapAnimation.classList.remove('hidden');
// Use centralized timing
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
const flipDuration = window.TIMING?.card?.flip || 540;
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('flipping'); swapCard.classList.add('flipping');
this.playSound('flip'); this.playSound('flip');
}, 60); }, preFlip);
setTimeout(() => { setTimeout(() => {
this.swapAnimation.classList.add('hidden'); this.swapAnimation.classList.add('hidden');
@ -1431,7 +1595,7 @@ class GolfGame {
swapCard.style.transform = ''; swapCard.style.transform = '';
cardEl.classList.remove('swap-out'); cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, 560); }, preFlip + flipDuration);
} }
handleCardClick(position) { handleCardClick(position) {
@ -1706,6 +1870,36 @@ class GolfGame {
this.statusMessage.className = 'status-message' + (type ? ' ' + type : ''); 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) { showToast(message, type = '', duration = 2500) {
// For compatibility - just set the status message // For compatibility - just set the status message
this.setStatus(message, type); this.setStatus(message, type);
@ -1772,13 +1966,50 @@ class GolfGame {
} }
showDrawnCard() { 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; 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 // Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating'; this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations // Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = ''; 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 === '★') { if (card.rank === '★') {
this.heldCardFloating.classList.add('joker'); this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
@ -1792,17 +2023,29 @@ class GolfGame {
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`; 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'); 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() { hideDrawnCard() {
// Hide the floating held card // Hide the floating held card
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.classList.remove('your-turn-pulse');
// Clear any inline styles from animations // Clear any inline styles from animations
this.heldCardFloating.style.cssText = ''; this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden'); this.discardBtn.classList.add('hidden');
// Clear button positioning
this.discardBtn.style.left = '';
this.discardBtn.style.top = '';
} }
isRedSuit(suit) { isRedSuit(suit) {
@ -1869,6 +2112,9 @@ class GolfGame {
renderGame() { renderGame() {
if (!this.gameState) return; if (!this.gameState) return;
// Update CPU considering visual state
this.updateCpuConsideringState();
// Update header // Update header
this.currentRoundSpan.textContent = this.gameState.current_round; this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds; this.totalRoundsSpan.textContent = this.gameState.total_rounds;
@ -1912,8 +2158,10 @@ class GolfGame {
} }
// Update discard pile // Update discard pile
if (this.drawnCard) { // Check if ANY player is holding a card (local or remote/CPU)
// Holding a drawn card - show discard pile as greyed/disabled 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 discard, show what's underneath (new discard_top or empty)
// If drawn from deck, show current discard_top greyed // If drawn from deck, show current discard_top greyed
this.discard.classList.add('picked-up'); this.discard.classList.add('picked-up');
@ -1941,7 +2189,10 @@ class GolfGame {
// Not holding - show normal discard pile // Not holding - show normal discard pile
this.discard.classList.remove('picked-up'); 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 discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`; const cardKey = `${discardCard.rank}-${discardCard.suit}`;
@ -1973,7 +2224,8 @@ class GolfGame {
this.discard.classList.remove('card-flip-in'); this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in'); 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 { } else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding'); this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
@ -1983,6 +2235,20 @@ class GolfGame {
this.discardBtn.classList.add('hidden'); 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 // Update deck/discard clickability and visual state
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card; const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip; 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.deckArea.classList.toggle('your-turn-to-draw', canDraw);
this.deck.classList.toggle('clickable', canDraw); this.deck.classList.toggle('clickable', canDraw);
// Only show disabled on deck when it's my turn and I've drawn // Show disabled on deck when any player has drawn (consistent dimmed look)
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn); this.deck.classList.toggle('disabled', hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); 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) // Disabled state handled by picked-up class when anyone is holding
// Don't grey out when opponents are playing
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
// Render opponents in a single row // Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId); const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
@ -2047,8 +2311,11 @@ class GolfGame {
? { ...card, face_up: true } ? { ...card, face_up: true }
: card; : card;
// Check if clickable during initial flip
const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
const isClickable = ( const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) || isInitialFlipClickable ||
(this.drawnCard) || (this.drawnCard) ||
(this.waitingForFlip && !card.face_up) (this.waitingForFlip && !card.face_up)
); );
@ -2056,6 +2323,12 @@ class GolfGame {
const cardEl = document.createElement('div'); const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected); 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)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(cardEl.firstChild); this.playerCards.appendChild(cardEl.firstChild);
}); });
@ -2496,7 +2769,8 @@ class GolfGame {
navigator.clipboard.writeText(shareText).then(() => { navigator.clipboard.writeText(shareText).then(() => {
const btn = document.getElementById('share-results-btn'); const btn = document.getElementById('share-results-btn');
btn.textContent = '✓ Copied!'; 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; 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 { .held-card-floating {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 100; z-index: 100;
transform: scale(1.2) translateY(-12px); transform: scale(1.15);
transform-origin: center bottom;
border: 3px solid #f4a460 !important; 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; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none; pointer-events: none;
@ -1026,33 +1027,26 @@ input::placeholder {
.held-card-floating.hidden { .held-card-floating.hidden {
opacity: 0; opacity: 0;
transform: scale(1) translateY(0); transform: scale(0.9);
pointer-events: none; pointer-events: none;
} }
/* Animate floating card dropping to discard pile (when drawn from discard) */ /* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping { .held-card-floating.dropping {
transform: scale(1) translateY(0);
border-color: transparent !important; border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !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 */ /* Swoop animation for deck → immediate discard */
.held-card-floating.swooping { .held-card-floating.swooping {
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1), transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
top 0.35s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
transform 0.35s ease-out, width 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
border-color 0.35s ease-out, height 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
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;
} }
.held-card-floating.swooping.landed { .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; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
} }
@ -1080,11 +1074,10 @@ input::placeholder {
transform: scale(1.05); 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 { #discard.picked-up {
opacity: 0.5; opacity: 0.5;
filter: grayscale(40%); filter: grayscale(40%);
transform: scale(0.95);
} }
.discard-stack { .discard-stack {
@ -1092,13 +1085,40 @@ input::placeholder {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
position: relative;
} }
.discard-stack .btn { .discard-stack .btn {
white-space: nowrap; 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, #deck.disabled,
#discard.disabled { #discard.disabled {
opacity: 0.5; opacity: 0.5;
@ -1141,53 +1161,65 @@ input::placeholder {
} }
} }
/* Card flip animation for discard pile */ /* Card appearing on discard pile */
.card-flip-in { .card-flip-in {
animation: cardFlipIn 0.56s ease-out; animation: cardFlipIn 0.25s ease-out;
} }
@keyframes cardFlipIn { @keyframes cardFlipIn {
0% { from { opacity: 0.5; }
transform: scale(1.4) translateY(-20px); to { opacity: 1; }
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);
}
} }
/* Discard pile pulse when card lands */ /* Discard pile pulse when card lands - simple glow */
#discard.discard-land { #discard.discard-land {
animation: discardLand 0.46s ease-out; animation: discardLand 0.3s ease-out;
} }
@keyframes discardLand { @keyframes discardLand {
0% { 0% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
40% { 50% {
transform: scale(1.18); box-shadow: 0 0 20px rgba(244, 164, 96, 0.8);
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
} }
100% { 100% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3); 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 overlay */
.swap-animation { .swap-animation {
position: fixed; position: fixed;
@ -1197,7 +1229,6 @@ input::placeholder {
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
perspective: 1000px;
} }
.swap-animation.hidden { .swap-animation.hidden {
@ -1208,7 +1239,7 @@ input::placeholder {
position: absolute; position: absolute;
width: 70px; width: 70px;
height: 98px; height: 98px;
perspective: 1000px; perspective: 800px;
} }
.swap-card.hidden { .swap-card.hidden {
@ -1219,8 +1250,9 @@ input::placeholder {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 8px;
transform-style: preserve-3d; 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 { .swap-card.flipping .swap-card-inner {
@ -1232,13 +1264,13 @@ input::placeholder {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
backface-visibility: hidden;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4); box-shadow: 0 4px 15px rgba(0,0,0,0.4);
backface-visibility: hidden;
} }
.swap-card-back { .swap-card-back {
@ -1248,17 +1280,18 @@ input::placeholder {
} }
.swap-card-front { .swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%); background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
transform: rotateY(180deg); border: 2px solid #ddd;
font-size: 2rem; font-size: clamp(1.8rem, 2.2vw, 2.8rem);
flex-direction: column; flex-direction: column;
color: #2c3e50; color: #333;
line-height: 1.1; line-height: 1.1;
font-weight: bold; font-weight: bold;
transform: rotateY(180deg);
} }
.swap-card-front.red { .swap-card-front.red {
color: #e74c3c; color: #c0392b;
} }
.swap-card-front.black { .swap-card-front.black {
@ -1270,25 +1303,20 @@ input::placeholder {
} }
.swap-card-front .joker-icon { .swap-card-front .joker-icon {
font-size: 1.6em; font-size: 1.5em;
line-height: 1; line-height: 1;
} }
.swap-card-front .joker-label { .swap-card-front .joker-label {
font-size: 0.45em; font-size: 0.4em;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
} letter-spacing: 0.05em;
/* Movement animation */
.swap-card.flipping {
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
} }
.swap-card.moving { .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; transition: top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
transform: scale(1.1) rotate(-5deg); left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
} }
/* Card in hand fading during swap */ /* 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 Area */
.player-section { .player-section {
text-align: center; text-align: center;
@ -1435,6 +1508,12 @@ input::placeholder {
color: #2d3436; 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 - separate indicator */
.final-turn-badge { .final-turn-badge {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
@ -1631,7 +1710,9 @@ input::placeholder {
} }
.game-buttons { .game-buttons {
margin-bottom: 8px; margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
@ -1938,14 +2019,14 @@ input::placeholder {
display: none; display: none;
} }
/* Real Card - persistent card element with 3D structure */ /* Real Card - persistent card element with 3D flip */
.real-card { .real-card {
position: fixed; position: fixed;
border-radius: 6px; border-radius: 6px;
perspective: 1000px;
z-index: 501; z-index: 501;
cursor: pointer; 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 { .real-card:hover {
@ -1956,8 +2037,9 @@ input::placeholder {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 6px;
transform-style: preserve-3d; 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 { .real-card .card-inner.flipped {
@ -1968,7 +2050,6 @@ input::placeholder {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
backface-visibility: hidden;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1976,6 +2057,7 @@ input::placeholder {
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backface-visibility: hidden;
} }
/* Card Front */ /* Card Front */
@ -1984,7 +2066,7 @@ input::placeholder {
border: 2px solid #ddd; border: 2px solid #ddd;
color: #333; color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem); font-size: clamp(1.8rem, 2.2vw, 2.8rem);
line-height: 1.1; line-height: 0.95;
} }
.real-card .card-face-front.red { .real-card .card-face-front.red {
@ -2031,11 +2113,8 @@ input::placeholder {
.real-card.moving, .real-card.moving,
.real-card.anim-card.moving { .real-card.anim-card.moving {
z-index: 600; z-index: 600;
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1), transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
top 0.27s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
transform 0.27s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg);
} }
/* Animation card - temporary cards used for animations */ /* Animation card - temporary cards used for animations */
@ -2045,14 +2124,13 @@ input::placeholder {
} }
.real-card.anim-card .card-inner { .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 { .real-card.holding {
z-index: 550; z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6), box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4); 0 4px 15px rgba(0, 0, 0, 0.4);
transform: scale(1.08);
} }
.real-card.clickable { .real-card.clickable {
@ -2062,7 +2140,6 @@ input::placeholder {
.real-card.clickable:hover { .real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460, box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3); 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
} }
/* Disable hover effects when not player's turn */ /* Disable hover effects when not player's turn */
@ -2077,7 +2154,6 @@ input::placeholder {
.real-card.selected { .real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460; box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
transform: scale(1.06);
z-index: 520; 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) 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 # Alias for backwards compatibility - use the centralized function from game.py
def get_ai_card_value(card: Card, options: GameOptions) -> int: def get_ai_card_value(card: Card, options: GameOptions) -> int:
"""Get card value with house rules applied for AI decisions. """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: def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
"""Calculate CPU 'thinking time' based on how obvious the discard decision is. """Calculate CPU 'thinking time' based on how obvious the discard decision is.
Easy decisions (obviously good or bad cards) = quick (400-600ms) Easy decisions (obviously good or bad cards) = quick
Hard decisions (medium value cards) = slower (900-1100ms) Hard decisions (medium value cards) = slower
Returns time in seconds. Returns time in seconds. Uses THINKING_TIME constants.
""" """
if not card: if not card:
# No discard available - quick decision to draw from deck # 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) value = get_card_value(card, options)
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1) # Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
if value <= 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) # Obviously bad cards (easy pass): 10, J, Q (value 10)
if 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 # Medium cards require more thought: 3-9
# 5, 6, 7 are the hardest decisions (middle of the range) # 5, 6, 7 are the hardest decisions (middle of the range)
if value in (5, 6, 7): 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 # 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: 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 logger = get_logger() if game_id else None
# Brief initial delay before CPU "looks at" the discard pile # 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 # "Thinking" delay based on how obvious the discard decision is
# Easy decisions (good/bad cards) are quick, medium cards take longer # 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 # Adjust for personality - chaotic players have more variance
if profile.unpredictability > 0.2: 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" 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})") 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() await broadcast_callback()
# Brief pause after draw to let the flash animation register visually # Brief pause after draw to let the flash animation register visually
await asyncio.sleep(0.08) await asyncio.sleep(CPU_TIMING["post_draw_settle"])
await asyncio.sleep(0.35 + random.uniform(0, 0.35)) # 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 # Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game) swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
@ -1535,3 +1578,7 @@ async def process_cpu_turn(
) )
await broadcast_callback() 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]))