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

@@ -28,6 +28,9 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
// Track opponent swap animation in progress (to apply swap-out class after render)
this.opponentSwapAnimation = null; // { playerId, position }
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
@@ -417,6 +420,7 @@ class GolfGame {
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame();
@@ -790,7 +794,6 @@ class GolfGame {
discardDrawn() {
if (!this.drawnCard) return;
const discardedCard = this.drawnCard;
const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' });
this.drawnCard = null;
this.hideToast();
@@ -803,21 +806,8 @@ class GolfGame {
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
if (wasFromDeck) {
// Swoop animation: deck → discard (makes it clear the card is being tossed)
this.animateDeckToDiscardSwoop(discardedCard);
} else {
// Simple drop (drawn from discard, putting it back - though this requires swap usually)
this.heldCardFloating.classList.add('dropping');
this.playSound('card');
setTimeout(() => {
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.classList.remove('dropping');
this.updateDiscardPileDisplay(discardedCard);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
}, 250);
}
// Swoop animation: deck → discard (card is always held over deck)
this.animateDeckToDiscardSwoop(discardedCard);
}
// Swoop animation for discarding a card drawn from deck
@@ -885,10 +875,46 @@ class GolfGame {
cancelDraw() {
if (!this.drawnCard) return;
const cardToReturn = this.drawnCard;
const wasFromDiscard = this.drawnFromDiscard;
this.send({ type: 'cancel_draw' });
this.drawnCard = null;
this.hideDrawnCard();
this.hideToast();
if (wasFromDiscard) {
// Animate card from deck position back to discard pile
this.animateDeckToDiscardReturn(cardToReturn);
} else {
this.hideDrawnCard();
}
}
// Animate returning a card from deck position to discard pile (for cancel draw from discard)
animateDeckToDiscardReturn(card) {
const discardRect = this.discard.getBoundingClientRect();
const floater = this.heldCardFloating;
// Add swooping class for smooth transition
floater.classList.add('swooping');
floater.style.left = `${discardRect.left}px`;
floater.style.top = `${discardRect.top}px`;
floater.style.width = `${discardRect.width}px`;
floater.style.height = `${discardRect.height}px`;
this.playSound('card');
// After swoop completes, hide floater and update discard pile
setTimeout(() => {
floater.classList.add('landed');
setTimeout(() => {
floater.classList.add('hidden');
floater.classList.remove('swooping', 'landed');
floater.style.cssText = '';
this.updateDiscardPileDisplay(card);
this.pulseDiscardLand();
}, 150);
}, 350);
}
swapCard(position) {
@@ -1124,9 +1150,19 @@ class GolfGame {
// Pulse the appropriate pile
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
// Show CPU action announcement for draw
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null);
}
} else {
// No old discard or couldn't detect - assume deck
this.pulseDrawPile('deck');
// Show CPU action announcement
if (oldPlayer?.is_cpu) {
this.showCpuAction(oldPlayer.name, 'draw-deck');
}
}
}
@@ -1140,8 +1176,10 @@ class GolfGame {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
@@ -1162,47 +1200,167 @@ class GolfGame {
break;
}
}
// Case 3: Card identity became known (opponent's hidden card was swapped)
// This handles race conditions where face_up might not be updated yet
if (!oldCard?.rank && newCard?.rank) {
swappedPosition = i;
wasFaceUp = false;
break;
}
}
// Check if opponent's cards are completely unchanged (server might send split updates)
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
} else if (swappedPosition < 0) {
// Player drew and discarded without swapping - pulse for everyone
this.fireDiscardAnimation(newDiscard);
// Show CPU swap announcement
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
} else if (swappedPosition < 0 && !cardsIdentical) {
// Player drew and discarded without swapping
// Only fire if cards actually differ (avoid race condition with split server updates)
// Show animation for other players, just pulse for local player
this.fireDiscardAnimation(newDiscard, previousPlayerId);
// Show CPU discard announcement
if (wasOtherPlayer && oldPlayer?.is_cpu) {
this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
}
}
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
}
}
// Note: We don't separately animate card flips for swaps anymore
// The swap animation handles showing the card at the correct position
// Handle delayed card updates (server sends split updates: discard first, then cards)
// Check if opponent cards changed even when discard didn't change
if (!discardChanged && wasOtherPlayer && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// Check for card changes that indicate a swap we missed
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card became visible (swap completed in delayed update)
if (!oldCard?.face_up && newCard?.face_up) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
// Card identity became known
if (!oldCard?.rank && newCard?.rank) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
}
}
}
}
// Flash animation on deck or discard pile to show where opponent drew from
pulseDrawPile(source) {
const T = window.TIMING?.feedback || {};
const pile = source === 'discard' ? this.discard : this.deck;
pile.classList.remove('draw-pulse');
// Trigger reflow to restart animation
void pile.offsetWidth;
pile.classList.add('draw-pulse');
// Remove class after animation completes
setTimeout(() => pile.classList.remove('draw-pulse'), 450);
setTimeout(() => pile.classList.remove('draw-pulse'), T.drawPulse || 450);
}
// Pulse discard pile when a card lands on it
pulseDiscardLand() {
const T = window.TIMING?.feedback || {};
this.discard.classList.remove('discard-land');
void this.discard.offsetWidth;
this.discard.classList.add('discard-land');
setTimeout(() => this.discard.classList.remove('discard-land'), 460);
setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460);
}
// Fire animation for discard without swap (card lands on discard pile face-up)
fireDiscardAnimation(discardCard) {
// Card is already known - just pulse to show it landed (no flip needed)
this.pulseDiscardLand();
// Shows card moving from deck to discard for other players only
fireDiscardAnimation(discardCard, fromPlayerId = null) {
// Only show animation for other players - local player already knows what they did
const isOtherPlayer = fromPlayerId && fromPlayerId !== this.playerId;
if (isOtherPlayer && discardCard) {
// Show card traveling from deck to discard pile
this.animateDeckToDiscard(discardCard);
}
// Skip animation entirely for local player
}
// Animate a card moving from deck to discard pile (for draw-and-discard by other players)
animateDeckToDiscard(card) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Create temporary card element
const animCard = document.createElement('div');
animCard.className = 'real-card anim-card';
animCard.innerHTML = `
<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);
swapCard.classList.add('flipping');
this.playSound('flip');
// Step 2: After flip, pause to see the card then pulse before being replaced
setTimeout(() => {
swapCard.classList.add('swap-pulse');
this.playSound('card');
}, 850);
// Step 3: Strategic pause to show discarded card, then complete
// Step 2: After flip completes, clean up immediately
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
swapCard.classList.remove('flipping');
swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
this.pulseDiscardLand();
}, 1400);
}, flipTime);
}
// Fire a flip animation for local player's card (non-blocking)
@@ -1351,17 +1507,21 @@ class GolfGame {
cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Use centralized timing
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
const flipDuration = window.TIMING?.card?.flip || 540;
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
}, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
}, 550);
}, preFlip + flipDuration);
}
// Fire a flip animation for opponent card (non-blocking)
@@ -1420,10 +1580,14 @@ class GolfGame {
cardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Use centralized timing
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
const flipDuration = window.TIMING?.card?.flip || 540;
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 60);
}, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
@@ -1431,7 +1595,7 @@ class GolfGame {
swapCard.style.transform = '';
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
}, 560);
}, preFlip + flipDuration);
}
handleCardClick(position) {
@@ -1706,6 +1870,36 @@ class GolfGame {
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
}
// Show CPU action announcement in status bar
showCpuAction(playerName, action, card = null) {
const suitSymbol = card ? this.getSuitSymbol(card.suit) : '';
const messages = {
'draw-deck': `${playerName} draws from deck`,
'draw-discard': card ? `${playerName} takes ${card.rank}${suitSymbol}` : `${playerName} takes from discard`,
'swap': `${playerName} swaps a card`,
'discard': card ? `${playerName} discards ${card.rank}${suitSymbol}` : `${playerName} discards`,
};
const message = messages[action];
if (message) {
this.setStatus(message, 'cpu-action');
}
}
// Update CPU considering visual state on discard pile
updateCpuConsideringState() {
if (!this.gameState || !this.discard) return;
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
} else {
this.discard.classList.remove('cpu-considering');
}
}
showToast(message, type = '', duration = 2500) {
// For compatibility - just set the status message
this.setStatus(message, type);
@@ -1772,13 +1966,50 @@ class GolfGame {
}
showDrawnCard() {
// Show drawn card floating over the discard pile (larger, closer to viewer)
// Show drawn card floating over the draw pile (deck), regardless of source
const card = this.drawnCard;
this.displayHeldCard(card, true);
}
// Display held card floating above and between deck and discard - for any player
// isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow)
displayHeldCard(card, isLocalPlayerHolding) {
if (!card) {
this.hideDrawnCard();
return;
}
// Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
// Position centered above and between deck and discard
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Calculate center point between deck and discard
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`;
// Position discard button attached to right side of held card
const scaledWidth = cardWidth * 1.15; // Account for scale transform
const scaledHeight = cardHeight * 1.15;
const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap)
const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
if (card.rank === '★') {
this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
@@ -1792,17 +2023,29 @@ class GolfGame {
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
// Show the floating card and discard button
// Show the floating card
this.heldCardFloating.classList.remove('hidden');
this.discardBtn.classList.remove('hidden');
// Add pulse glow if it's local player's turn to act on the card
if (isLocalPlayerHolding) {
this.heldCardFloating.classList.add('your-turn-pulse');
this.discardBtn.classList.remove('hidden');
} else {
this.heldCardFloating.classList.remove('your-turn-pulse');
this.discardBtn.classList.add('hidden');
}
}
hideDrawnCard() {
// Hide the floating held card
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.classList.remove('your-turn-pulse');
// Clear any inline styles from animations
this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden');
// Clear button positioning
this.discardBtn.style.left = '';
this.discardBtn.style.top = '';
}
isRedSuit(suit) {
@@ -1869,6 +2112,9 @@ class GolfGame {
renderGame() {
if (!this.gameState) return;
// Update CPU considering visual state
this.updateCpuConsideringState();
// Update header
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
@@ -1912,8 +2158,10 @@ class GolfGame {
}
// Update discard pile
if (this.drawnCard) {
// Holding a drawn card - show discard pile as greyed/disabled
// Check if ANY player is holding a card (local or remote/CPU)
const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
if (anyPlayerHolding) {
// Someone is holding a drawn card - show discard pile as greyed/disabled
// If drawn from discard, show what's underneath (new discard_top or empty)
// If drawn from deck, show current discard_top greyed
this.discard.classList.add('picked-up');
@@ -1941,7 +2189,10 @@ class GolfGame {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
if (this.gameState.discard_top) {
// Skip discard update during opponent swap animation - animation handles the visual
if (this.opponentSwapAnimation) {
// Don't update discard content; animation overlay shows the swap
} else if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
@@ -1973,7 +2224,8 @@ class GolfGame {
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560;
setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration);
}
} else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
@@ -1983,6 +2235,20 @@ class GolfGame {
this.discardBtn.classList.add('hidden');
}
// Show held card for ANY player who has drawn (consistent visual regardless of whose turn)
// Local player uses this.drawnCard, others use gameState.drawn_card
if (this.drawnCard) {
// Local player is holding - show with pulse and discard button
this.displayHeldCard(this.drawnCard, true);
} else if (this.gameState.drawn_card && this.gameState.drawn_player_id) {
// Another player is holding - show without pulse/button
const isLocalPlayer = this.gameState.drawn_player_id === this.playerId;
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
} else {
// No one holding a card
this.hideDrawnCard();
}
// Update deck/discard clickability and visual state
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
@@ -1991,13 +2257,11 @@ class GolfGame {
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
this.deck.classList.toggle('clickable', canDraw);
// Only show disabled on deck when it's my turn and I've drawn
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
// Show disabled on deck when any player has drawn (consistent dimmed look)
this.deck.classList.toggle('disabled', hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
// Only show disabled state when it's my turn and I've drawn (not holding visible card)
// Don't grey out when opponents are playing
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
// Disabled state handled by picked-up class when anyone is holding
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
@@ -2047,8 +2311,11 @@ class GolfGame {
? { ...card, face_up: true }
: card;
// Check if clickable during initial flip
const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
isInitialFlipClickable ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
@@ -2056,6 +2323,12 @@ class GolfGame {
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
// Add pulse animation during initial flip phase
if (isInitialFlipClickable) {
cardEl.firstChild.classList.add('initial-flip-pulse');
}
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(cardEl.firstChild);
});
@@ -2496,7 +2769,8 @@ class GolfGame {
navigator.clipboard.writeText(shareText).then(() => {
const btn = document.getElementById('share-results-btn');
btn.textContent = '✓ Copied!';
setTimeout(() => btn.textContent = '📋 Copy Results', 2000);
const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000;
setTimeout(() => btn.textContent = '📋 Copy Results', copyDelay);
});
});