Migrate animation system to unified anime.js framework

- Replace CSS transitions with anime.js for all card animations
- Create card-animations.js as single source for all animation logic
- Remove draw-animations.js (merged into card-animations.js)
- Strip CSS transitions from card elements to prevent conflicts
- Fix held card appearing before draw animation completes
- Make opponent/CPU animations match local player behavior
- Add subtle shake effect for turn indicator (replaces brightness pulse)
- Speed up flip animations by 30% for snappier feel
- Remove unnecessary pulse effects after draws/swaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-01 22:57:53 -05:00
parent 7b64b8c17c
commit bc1b1b7725
7 changed files with 1654 additions and 326 deletions

8
client/anime.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,19 @@
// Golf Card Game - Client Application // Golf Card Game - Client Application
// Debug logging - set to true to see detailed state/animation logs
const DEBUG_GAME = false;
function debugLog(category, message, data = null) {
if (!DEBUG_GAME) return;
const timestamp = new Date().toISOString().substr(11, 12);
const prefix = `[${timestamp}] [${category}]`;
if (data) {
console.log(prefix, message, data);
} else {
console.log(prefix, message);
}
}
class GolfGame { class GolfGame {
constructor() { constructor() {
this.ws = null; this.ws = null;
@ -31,6 +45,12 @@ class GolfGame {
// Track opponent swap animation in progress (to apply swap-out class after render) // Track opponent swap animation in progress (to apply swap-out class after render)
this.opponentSwapAnimation = null; // { playerId, position } this.opponentSwapAnimation = null; // { playerId, position }
// Track draw pulse animation in progress (defer held card display until pulse completes)
this.drawPulseAnimation = false;
// Track local discard animation in progress (prevent renderGame from updating discard)
this.localDiscardAnimating = false;
// Track round winners for visual highlight // Track round winners for visual highlight
this.roundWinnerNames = new Set(); this.roundWinnerNames = new Set();
@ -162,8 +182,14 @@ class GolfGame {
this.playersList = document.getElementById('players-list'); this.playersList = document.getElementById('players-list');
this.hostSettings = document.getElementById('host-settings'); this.hostSettings = document.getElementById('host-settings');
this.waitingMessage = document.getElementById('waiting-message'); this.waitingMessage = document.getElementById('waiting-message');
this.numDecksSelect = document.getElementById('num-decks'); this.numDecksInput = document.getElementById('num-decks');
this.numDecksDisplay = document.getElementById('num-decks-display');
this.decksMinus = document.getElementById('decks-minus');
this.decksPlus = document.getElementById('decks-plus');
this.deckRecommendation = document.getElementById('deck-recommendation'); this.deckRecommendation = document.getElementById('deck-recommendation');
this.deckColorsGroup = document.getElementById('deck-colors-group');
this.deckColorPresetSelect = document.getElementById('deck-color-preset');
this.deckColorPreview = document.getElementById('deck-color-preview');
this.numRoundsSelect = document.getElementById('num-rounds'); this.numRoundsSelect = document.getElementById('num-rounds');
this.initialFlipsSelect = document.getElementById('initial-flips'); this.initialFlipsSelect = document.getElementById('initial-flips');
this.flipModeSelect = document.getElementById('flip-mode'); this.flipModeSelect = document.getElementById('flip-mode');
@ -285,11 +311,26 @@ class GolfGame {
e.target.value = e.target.value.toUpperCase(); e.target.value = e.target.value.toUpperCase();
}); });
// Update deck recommendation when deck selection changes // Deck stepper controls
this.numDecksSelect.addEventListener('change', () => { if (this.decksMinus) {
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0; this.decksMinus.addEventListener('click', () => {
this.updateDeckRecommendation(playerCount); this.playSound('click');
}); this.adjustDeckCount(-1);
});
}
if (this.decksPlus) {
this.decksPlus.addEventListener('click', () => {
this.playSound('click');
this.adjustDeckCount(1);
});
}
// Update preview when color preset changes
if (this.deckColorPresetSelect) {
this.deckColorPresetSelect.addEventListener('change', () => {
this.updateDeckColorPreview();
});
}
// Show combo note when wolfpack + four-of-a-kind are both selected // Show combo note when wolfpack + four-of-a-kind are both selected
const updateWolfpackCombo = () => { const updateWolfpackCombo = () => {
@ -421,6 +462,11 @@ class GolfGame {
this.selectedCards = []; this.selectedCards = [];
this.animatingPositions = new Set(); this.animatingPositions = new Set();
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.drawPulseAnimation = false;
// Cancel any running animations from previous round
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.playSound('shuffle'); this.playSound('shuffle');
this.showGameScreen(); this.showGameScreen();
this.renderGame(); this.renderGame();
@ -432,6 +478,7 @@ class GolfGame {
// If local swap animation is running, defer this state update // If local swap animation is running, defer this state update
if (this.swapAnimationInProgress) { if (this.swapAnimationInProgress) {
debugLog('STATE', 'Deferring state - swap animation in progress');
this.updateSwapAnimation(data.game_state.discard_top); this.updateSwapAnimation(data.game_state.discard_top);
this.pendingGameState = data.game_state; this.pendingGameState = data.game_state;
break; break;
@ -440,12 +487,25 @@ class GolfGame {
const oldState = this.gameState; const oldState = this.gameState;
const newState = data.game_state; const newState = data.game_state;
debugLog('STATE', 'Received game_state', {
phase: newState.phase,
currentPlayer: newState.current_player_id?.slice(-4),
discardTop: newState.discard_top ? `${newState.discard_top.rank}${newState.discard_top.suit?.[0]}` : 'EMPTY',
drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}${newState.drawn_card.suit?.[0]}` : null,
drawnBy: newState.drawn_player_id?.slice(-4) || null,
hasDrawn: newState.has_drawn_card
});
// Update state FIRST (always) // Update state FIRST (always)
this.gameState = newState; this.gameState = newState;
// Clear local flip tracking if server confirmed our flips // Clear local flip tracking if server confirmed our flips
if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) { if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) {
this.locallyFlippedCards = new Set(); this.locallyFlippedCards = new Set();
// Stop all initial flip pulse animations
if (window.cardAnimations) {
window.cardAnimations.stopAllInitialFlipPulses();
}
} }
// Detect and fire animations (non-blocking, errors shouldn't break game) // Detect and fire animations (non-blocking, errors shouldn't break game)
@ -485,8 +545,31 @@ class GolfGame {
case 'card_drawn': case 'card_drawn':
this.drawnCard = data.card; this.drawnCard = data.card;
this.drawnFromDiscard = data.source === 'discard'; this.drawnFromDiscard = data.source === 'discard';
this.showDrawnCard();
this.renderGame(); // Re-render to update discard pile if (data.source === 'deck' && window.drawAnimations) {
// Deck draw: use shared animation system (flip at deck, move to hold)
// Hide held card during animation - animation callback will show it
this.isDrawAnimating = true;
this.hideDrawnCard();
window.drawAnimations.animateDrawDeck(data.card, () => {
this.isDrawAnimating = false;
this.displayHeldCard(data.card, true);
this.renderGame();
});
} else if (data.source === 'discard' && window.drawAnimations) {
// Discard draw: use shared animation system (lift and move)
this.isDrawAnimating = true;
this.hideDrawnCard();
window.drawAnimations.animateDrawDiscard(data.card, () => {
this.isDrawAnimating = false;
this.displayHeldCard(data.card, true);
this.renderGame();
});
} else {
// Fallback: just show the card
this.displayHeldCard(data.card, true);
this.renderGame();
}
this.showToast('Swap with a card or discard', '', 3000); this.showToast('Swap with a card or discard', '', 3000);
break; break;
@ -601,12 +684,12 @@ class GolfGame {
startGame() { startGame() {
try { try {
const decks = parseInt(this.numDecksSelect.value); const decks = parseInt(this.numDecksInput?.value || '1');
const rounds = parseInt(this.numRoundsSelect.value); const rounds = parseInt(this.numRoundsSelect?.value || '9');
const initial_flips = parseInt(this.initialFlipsSelect.value); const initial_flips = parseInt(this.initialFlipsSelect?.value || '2');
// Standard options // Standard options
const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame" const flip_mode = this.flipModeSelect?.value || 'always'; // "never", "always", or "endgame"
const knock_penalty = this.knockPenaltyCheckbox?.checked || false; const knock_penalty = this.knockPenaltyCheckbox?.checked || false;
// Joker mode (radio buttons) // Joker mode (radio buttons)
@ -634,6 +717,9 @@ class GolfGame {
const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false; const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false;
const knock_early = this.knockEarlyCheckbox?.checked || false; const knock_early = this.knockEarlyCheckbox?.checked || false;
// Deck colors
const deck_colors = this.getDeckColors(decks);
this.send({ this.send({
type: 'start_game', type: 'start_game',
decks, decks,
@ -655,7 +741,8 @@ class GolfGame {
four_of_a_kind, four_of_a_kind,
negative_pairs_keep_value, negative_pairs_keep_value,
one_eyed_jacks, one_eyed_jacks,
knock_early knock_early,
deck_colors
}); });
} catch (error) { } catch (error) {
console.error('Error starting game:', error); console.error('Error starting game:', error);
@ -767,7 +854,7 @@ class GolfGame {
return; return;
} }
if (this.gameState.waiting_for_initial_flip) return; if (this.gameState.waiting_for_initial_flip) return;
this.playSound('card'); // Sound played by draw animation
this.send({ type: 'draw', source: 'deck' }); this.send({ type: 'draw', source: 'deck' });
} }
@ -787,7 +874,7 @@ class GolfGame {
} }
if (this.gameState.waiting_for_initial_flip) return; if (this.gameState.waiting_for_initial_flip) return;
if (!this.gameState.discard_top) return; if (!this.gameState.discard_top) return;
this.playSound('card'); // Sound played by draw animation
this.send({ type: 'draw', source: 'discard' }); this.send({ type: 'draw', source: 'discard' });
} }
@ -806,6 +893,9 @@ 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}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true;
// Swoop animation: deck → discard (card is always held over deck) // Swoop animation: deck → discard (card is always held over deck)
this.animateDeckToDiscardSwoop(discardedCard); this.animateDeckToDiscardSwoop(discardedCard);
} }
@ -852,11 +942,15 @@ class GolfGame {
this.updateDiscardPileDisplay(card); this.updateDiscardPileDisplay(card);
this.pulseDiscardLand(); this.pulseDiscardLand();
this.skipNextDiscardFlip = true; this.skipNextDiscardFlip = true;
// Allow renderGame to update discard again
this.localDiscardAnimating = false;
}, 150); // Brief settle }, 150); // Brief settle
}, 350); // Match swoop transition duration }, 350); // Match swoop transition duration
} }
// Update the discard pile display with a card // Update the discard pile display with a card
// Note: Don't use renderCardContent here - the card may have face_up=false
// (drawn cards aren't marked face_up until server processes discard)
updateDiscardPileDisplay(card) { updateDiscardPileDisplay(card) {
this.discard.classList.remove('picked-up', 'disabled'); this.discard.classList.remove('picked-up', 'disabled');
this.discard.classList.add('has-card', 'card-front'); this.discard.classList.add('has-card', 'card-front');
@ -868,7 +962,8 @@ class GolfGame {
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`; this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else { } else {
this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black'); this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
this.discardContent.innerHTML = this.renderCardContent(card); // Render directly - discard pile cards are always visible
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
} }
this.lastDiscardKey = `${card.rank}-${card.suit}`; this.lastDiscardKey = `${card.rank}-${card.suit}`;
} }
@ -972,12 +1067,11 @@ class GolfGame {
// FACE-UP CARD: Subtle pulse animation (no flip needed) // FACE-UP CARD: Subtle pulse animation (no flip needed)
this.swapAnimationContentSet = true; this.swapAnimationContentSet = true;
// Apply subtle swap pulse to both cards // Apply subtle swap pulse using anime.js
handCardEl.classList.add('swap-pulse'); if (window.cardAnimations) {
this.heldCardFloating.classList.add('swap-pulse'); window.cardAnimations.pulseSwap(handCardEl);
window.cardAnimations.pulseSwap(this.heldCardFloating);
// Play a soft sound for the swap }
this.playSound('card');
// Send swap and let render handle the update // Send swap and let render handle the update
this.send({ type: 'swap', position }); this.send({ type: 'swap', position });
@ -986,8 +1080,6 @@ class GolfGame {
// Complete after pulse animation // Complete after pulse animation
setTimeout(() => { setTimeout(() => {
handCardEl.classList.remove('swap-pulse');
this.heldCardFloating.classList.remove('swap-pulse');
this.completeSwapAnimation(null); this.completeSwapAnimation(null);
}, 440); }, 440);
} else { } else {
@ -1042,21 +1134,25 @@ class GolfGame {
// Quick flip to reveal, then complete - server will pause before next turn // Quick flip to reveal, then complete - server will pause before next turn
if (this.swapAnimationCard) { if (this.swapAnimationCard) {
// Step 1: Flip to reveal (quick) const swapCardInner = this.swapAnimationCard.querySelector('.swap-card-inner');
this.swapAnimationCard.classList.add('flipping'); const flipDuration = 245; // Match other flip durations
this.playSound('flip'); this.playSound('flip');
// Step 2: Brief pulse after flip completes // Use anime.js for the flip animation
setTimeout(() => { anime({
this.swapAnimationCard.classList.add('swap-pulse'); targets: swapCardInner,
this.playSound('card'); rotateY: [0, 180],
}, 350); duration: flipDuration,
easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
// Step 3: Complete animation - the pause to see the result happens complete: () => {
// on the server side before the next CPU turn starts swapCardInner.style.transform = '';
setTimeout(() => { // Brief pause to see the card, then complete
this.completeSwapAnimation(null); setTimeout(() => {
}, 550); this.completeSwapAnimation(null);
}, 100);
}
});
} else { } else {
// Fallback: animation element missing, complete immediately to avoid freeze // Fallback: animation element missing, complete immediately to avoid freeze
console.error('Swap animation element missing, completing immediately'); console.error('Swap animation element missing, completing immediately');
@ -1119,6 +1215,10 @@ class GolfGame {
triggerAnimationsForStateChange(oldState, newState) { triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return; if (!oldState) return;
const currentPlayerId = newState.current_player_id;
const previousPlayerId = oldState.current_player_id;
const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId;
// Check for discard pile changes // Check for discard pile changes
const newDiscard = newState.discard_top; const newDiscard = newState.discard_top;
const oldDiscard = oldState.discard_top; const oldDiscard = oldState.discard_top;
@ -1126,47 +1226,64 @@ class GolfGame {
newDiscard.rank !== oldDiscard.rank || newDiscard.rank !== oldDiscard.rank ||
newDiscard.suit !== oldDiscard.suit); newDiscard.suit !== oldDiscard.suit);
const previousPlayerId = oldState.current_player_id; debugLog('DIFFER', 'State diff', {
const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId; discardChanged,
oldDiscard: oldDiscard ? `${oldDiscard.rank}${oldDiscard.suit?.[0]}` : 'EMPTY',
newDiscard: newDiscard ? `${newDiscard.rank}${newDiscard.suit?.[0]}` : 'EMPTY',
turnChanged: previousPlayerId !== currentPlayerId,
wasOtherPlayer
});
// Detect which pile opponent drew from and pulse it // STEP 1: Detect when someone DRAWS (drawn_card goes from null to something)
if (wasOtherPlayer && discardChanged) { const justDrew = !oldState.drawn_card && newState.drawn_card;
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const drawingPlayerId = newState.drawn_player_id;
const newPlayer = newState.players.find(p => p.id === previousPlayerId); const isOtherPlayerDrawing = drawingPlayerId && drawingPlayerId !== this.playerId;
if (oldPlayer && newPlayer && oldDiscard) { if (justDrew && isOtherPlayerDrawing) {
// Check if any of their cards now matches the old discard top // Detect source: if old discard is gone, they took from discard
// This means they took from discard pile const discardWasTaken = oldDiscard && (!newDiscard ||
let tookFromDiscard = false; newDiscard.rank !== oldDiscard.rank ||
for (let i = 0; i < 6; i++) { newDiscard.suit !== oldDiscard.suit);
const newCard = newPlayer.cards[i];
if (newCard?.face_up && debugLog('DIFFER', 'Other player drew', {
newCard.rank === oldDiscard.rank && source: discardWasTaken ? 'discard' : 'deck',
newCard.suit === oldDiscard.suit) { drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}` : '?'
tookFromDiscard = true; });
break;
// Use shared draw animation system for consistent look
if (window.drawAnimations) {
// Set flag to defer held card display until animation completes
this.drawPulseAnimation = true;
const drawnCard = newState.drawn_card;
const onAnimComplete = () => {
this.drawPulseAnimation = false;
// Show the held card after animation (no popIn - match local player)
if (this.gameState?.drawn_card) {
this.displayHeldCard(this.gameState.drawn_card, false);
} }
};
if (discardWasTaken) {
window.drawAnimations.animateDrawDiscard(drawnCard, onAnimComplete);
} else {
window.drawAnimations.animateDrawDeck(drawnCard, onAnimComplete);
} }
}
// Pulse the appropriate pile // Show CPU action announcement
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck'); const drawingPlayer = newState.players.find(p => p.id === drawingPlayerId);
if (drawingPlayer?.is_cpu) {
// Show CPU action announcement for draw if (discardWasTaken && oldDiscard) {
if (oldPlayer.is_cpu) { this.showCpuAction(drawingPlayer.name, 'draw-discard', oldDiscard);
this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null); } else {
} this.showCpuAction(drawingPlayer.name, 'draw-deck');
} 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');
} }
} }
} }
if (discardChanged) { // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
if (discardChanged && wasOtherPlayer) {
// Check if the previous player actually SWAPPED (has a new face-up card) // Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change) // vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
@ -1268,24 +1385,44 @@ class GolfGame {
} }
// 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
// Defers held card display until pulse completes for clean sequencing
pulseDrawPile(source) { pulseDrawPile(source) {
const T = window.TIMING?.feedback || {}; const T = window.TIMING?.feedback || {};
const pulseDuration = T.drawPulse || 450;
const pile = source === 'discard' ? this.discard : this.deck; const pile = source === 'discard' ? this.discard : this.deck;
// Set flag to defer held card display
this.drawPulseAnimation = true;
pile.classList.remove('draw-pulse'); pile.classList.remove('draw-pulse');
// 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
setTimeout(() => pile.classList.remove('draw-pulse'), T.drawPulse || 450); // After pulse completes, show the held card
setTimeout(() => {
pile.classList.remove('draw-pulse');
this.drawPulseAnimation = false;
// Show the held card (no pop-in - match local player behavior)
if (this.gameState?.drawn_card && this.gameState?.drawn_player_id !== this.playerId) {
this.displayHeldCard(this.gameState.drawn_card, false);
}
}, pulseDuration);
} }
// Pulse discard pile when a card lands on it // Pulse discard pile when a card lands on it
pulseDiscardLand() { // Optional callback fires after pulse completes (for sequencing turn indicator update)
pulseDiscardLand(onComplete = null) {
// Use anime.js for discard pulse
if (window.cardAnimations) {
window.cardAnimations.pulseDiscard();
}
// Execute callback after animation
const T = window.TIMING?.feedback || {}; const T = window.TIMING?.feedback || {};
this.discard.classList.remove('discard-land'); const duration = T.discardLand || 375;
void this.discard.offsetWidth; setTimeout(() => {
this.discard.classList.add('discard-land'); if (onComplete) onComplete();
setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460); }, duration);
} }
// 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)
@ -1312,7 +1449,7 @@ class GolfGame {
animCard.innerHTML = ` animCard.innerHTML = `
<div class="card-inner"> <div class="card-inner">
<div class="card-face card-face-front"></div> <div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div> <div class="card-face card-face-back"></div>
</div> </div>
`; `;
@ -1409,11 +1546,17 @@ class GolfGame {
// Face-to-face swap: use subtle pulse on the card, no flip needed // Face-to-face swap: use subtle pulse on the card, no flip needed
if (wasFaceUp && sourceCardEl) { if (wasFaceUp && sourceCardEl) {
sourceCardEl.classList.add('swap-pulse'); if (window.cardAnimations) {
this.playSound('card'); window.cardAnimations.pulseSwap(sourceCardEl);
}
const pulseDuration = window.TIMING?.feedback?.discardPickup || 400; const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
setTimeout(() => { setTimeout(() => {
sourceCardEl.classList.remove('swap-pulse'); // Pulse discard, then update turn indicator after pulse completes
// Keep opponentSwapAnimation set during pulse so isVisuallyMyTurn stays correct
this.pulseDiscardLand(() => {
this.opponentSwapAnimation = null;
this.renderGame();
});
}, pulseDuration); }, pulseDuration);
return; return;
} }
@ -1452,21 +1595,26 @@ 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 // Use anime.js for the flip animation (CSS transitions removed)
const flipTime = window.TIMING?.card?.flip || 400; const flipDuration = 245; // Match other flip durations
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Step 1: Flip to reveal the hidden card
swapCard.classList.add('flipping');
this.playSound('flip'); this.playSound('flip');
anime({
// Step 2: After flip completes, clean up immediately targets: swapCardInner,
setTimeout(() => { rotateY: [0, 180],
this.swapAnimation.classList.add('hidden'); duration: flipDuration,
swapCard.classList.remove('flipping'); easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
swapCard.style.transform = ''; complete: () => {
this.opponentSwapAnimation = null; this.swapAnimation.classList.add('hidden');
this.pulseDiscardLand(); swapCardInner.style.transform = '';
}, flipTime); swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
// Update turn indicator after flip completes
this.opponentSwapAnimation = null;
this.renderGame();
}
});
} }
// Fire a flip animation for local player's card (non-blocking) // Fire a flip animation for local player's card (non-blocking)
@ -1482,46 +1630,15 @@ class GolfGame {
return; return;
} }
const cardRect = cardEl.getBoundingClientRect(); // Use the unified card animation system for consistent flip animation
const swapCard = this.swapCardFromHand; if (window.cardAnimations) {
const swapCardFront = swapCard.querySelector('.swap-card-front'); window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key);
swapCard.style.left = cardRect.left + 'px'; });
swapCard.style.top = cardRect.top + 'px';
swapCard.style.width = cardRect.width + 'px';
swapCard.style.height = cardRect.height + 'px';
swapCard.classList.remove('flipping', 'moving');
// Set card content
swapCardFront.className = 'swap-card-front';
if (cardData.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else { } else {
swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); // Fallback if card animations not available
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit];
swapCardFront.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
}
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');
}, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, preFlip + flipDuration); }
} }
// Fire a flip animation for opponent card (non-blocking) // Fire a flip animation for opponent card (non-blocking)
@ -1552,50 +1669,15 @@ class GolfGame {
return; return;
} }
const cardRect = cardEl.getBoundingClientRect(); // Use the unified card animation system for consistent flip animation
const swapCard = this.swapCardFromHand; if (window.cardAnimations) {
const swapCardFront = swapCard.querySelector('.swap-card-front'); window.cardAnimations.animateOpponentFlip(cardEl, cardData, sourceRotation);
swapCard.style.left = cardRect.left + 'px';
swapCard.style.top = cardRect.top + 'px';
swapCard.style.width = cardRect.width + 'px';
swapCard.style.height = cardRect.height + 'px';
swapCard.classList.remove('flipping', 'moving');
// Apply rotation to match the arch layout
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
// Set card content
swapCardFront.className = 'swap-card-front';
if (cardData.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black');
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit];
swapCardFront.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
} }
cardEl.classList.add('swap-out'); // Clear tracking after animation duration
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');
this.playSound('flip');
}, preFlip);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping');
swapCard.style.transform = '';
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, preFlip + flipDuration); }, (window.TIMING?.card?.flip || 400) + 100);
} }
handleCardClick(position) { handleCardClick(position) {
@ -1753,6 +1835,8 @@ class GolfGame {
this.hostSettings.classList.remove('hidden'); this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden'); this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden'); this.waitingMessage.classList.add('hidden');
// Initialize deck color preview
this.updateDeckColorPreview();
} else { } else {
this.hostSettings.classList.add('hidden'); this.hostSettings.classList.add('hidden');
this.cpuControlsSection.classList.add('hidden'); this.cpuControlsSection.classList.add('hidden');
@ -1837,7 +1921,9 @@ class GolfGame {
// Auto-select 2 decks when reaching 4+ players (host only) // Auto-select 2 decks when reaching 4+ players (host only)
const prevCount = this.currentPlayers ? this.currentPlayers.length : 0; const prevCount = this.currentPlayers ? this.currentPlayers.length : 0;
if (this.isHost && prevCount < 4 && players.length >= 4) { if (this.isHost && prevCount < 4 && players.length >= 4) {
this.numDecksSelect.value = '2'; if (this.numDecksInput) this.numDecksInput.value = '2';
if (this.numDecksDisplay) this.numDecksDisplay.textContent = '2';
this.updateDeckColorPreview();
} }
// Update deck recommendation visibility // Update deck recommendation visibility
@ -1847,7 +1933,7 @@ class GolfGame {
updateDeckRecommendation(playerCount) { updateDeckRecommendation(playerCount) {
if (!this.isHost || !this.deckRecommendation) return; if (!this.isHost || !this.deckRecommendation) return;
const decks = parseInt(this.numDecksSelect.value); const decks = parseInt(this.numDecksInput?.value || '1');
// Show recommendation if 4+ players and only 1 deck selected // Show recommendation if 4+ players and only 1 deck selected
if (playerCount >= 4 && decks < 2) { if (playerCount >= 4 && decks < 2) {
this.deckRecommendation.classList.remove('hidden'); this.deckRecommendation.classList.remove('hidden');
@ -1856,10 +1942,83 @@ class GolfGame {
} }
} }
adjustDeckCount(delta) {
if (!this.numDecksInput) return;
let current = parseInt(this.numDecksInput.value) || 1;
let newValue = Math.max(1, Math.min(3, current + delta));
this.numDecksInput.value = newValue;
if (this.numDecksDisplay) {
this.numDecksDisplay.textContent = newValue;
}
// Update related UI
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
this.updateDeckRecommendation(playerCount);
this.updateDeckColorPreview();
}
getDeckColors(numDecks) {
const multiColorPresets = {
classic: ['red', 'blue', 'gold'],
ninja: ['green', 'purple', 'orange'],
ocean: ['blue', 'teal', 'cyan'],
forest: ['green', 'gold', 'brown'],
sunset: ['orange', 'red', 'purple'],
berry: ['purple', 'pink', 'red'],
neon: ['pink', 'cyan', 'green'],
royal: ['purple', 'gold', 'red'],
earth: ['brown', 'green', 'gold']
};
const singleColorPresets = {
'all-red': 'red',
'all-blue': 'blue',
'all-green': 'green',
'all-gold': 'gold',
'all-purple': 'purple',
'all-teal': 'teal',
'all-pink': 'pink',
'all-slate': 'slate'
};
const preset = this.deckColorPresetSelect?.value || 'classic';
if (singleColorPresets[preset]) {
const color = singleColorPresets[preset];
return Array(numDecks).fill(color);
}
const colors = multiColorPresets[preset] || multiColorPresets.classic;
return colors.slice(0, numDecks);
}
updateDeckColorPreview() {
if (!this.deckColorPreview) return;
const numDecks = parseInt(this.numDecksInput?.value || '1');
const colors = this.getDeckColors(numDecks);
this.deckColorPreview.innerHTML = '';
colors.forEach(color => {
const card = document.createElement('div');
card.className = `preview-card deck-${color}`;
this.deckColorPreview.appendChild(card);
});
}
isMyTurn() { isMyTurn() {
return this.gameState && this.gameState.current_player_id === this.playerId; return this.gameState && this.gameState.current_player_id === this.playerId;
} }
// Visual check: don't show "my turn" indicators until opponent swap animation completes
isVisuallyMyTurn() {
if (this.opponentSwapAnimation) return false;
return this.isMyTurn();
}
getMyPlayerData() { getMyPlayerData() {
if (!this.gameState) return null; if (!this.gameState) return null;
return this.gameState.players.find(p => p.id === this.playerId); return this.gameState.players.find(p => p.id === this.playerId);
@ -1895,8 +2054,15 @@ class GolfGame {
if (isCpuTurn && hasNotDrawn) { if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering'); this.discard.classList.add('cpu-considering');
// Use anime.js for CPU thinking animation
if (window.cardAnimations) {
window.cardAnimations.startCpuThinking(this.discard);
}
} else { } else {
this.discard.classList.remove('cpu-considering'); this.discard.classList.remove('cpu-considering');
if (window.cardAnimations) {
window.cardAnimations.stopCpuThinking(this.discard);
}
} }
} }
@ -2036,6 +2202,40 @@ class GolfGame {
} }
} }
// Display a face-down held card (for when opponent draws from deck)
displayHeldCardFaceDown() {
// Set up as face-down card with deck color (use deck_top_deck_id for the color)
let className = 'card card-back held-card-floating';
if (this.gameState?.deck_colors) {
const deckId = this.gameState.deck_top_deck_id || 0;
const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
if (color) className += ` back-${color}`;
}
this.heldCardFloating.className = className;
this.heldCardFloating.style.cssText = '';
// Position centered above and between deck and discard
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
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`;
this.heldCardFloatingContent.innerHTML = '';
this.heldCardFloating.classList.remove('hidden');
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');
@ -2101,6 +2301,10 @@ class GolfGame {
renderCardContent(card) { renderCardContent(card) {
if (!card || !card.face_up) return ''; if (!card || !card.face_up) return '';
// Handle locally-flipped cards where rank/suit aren't known yet
if (!card.rank || !card.suit) {
return '';
}
// Jokers - use suit to determine icon (hearts = dragon, spades = oni) // Jokers - use suit to determine icon (hearts = dragon, spades = oni)
if (card.rank === '★') { if (card.rank === '★') {
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
@ -2128,13 +2332,18 @@ class GolfGame {
} }
// Toggle not-my-turn class to disable hover effects when it's not player's turn // Toggle not-my-turn class to disable hover effects when it's not player's turn
const isMyTurn = this.isMyTurn(); // Use visual check so turn indicators sync with discard land animation
this.gameScreen.classList.toggle('not-my-turn', !isMyTurn); const isVisuallyMyTurn = this.isVisuallyMyTurn();
this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn);
// Update status message (handled by specific actions, but set default here) // Update status message (handled by specific actions, but set default here)
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); // During opponent swap animation, show the animating player (not the new current player)
if (currentPlayer && currentPlayer.id !== this.playerId) { const displayedPlayerId = this.opponentSwapAnimation
this.setStatus(`${currentPlayer.name}'s turn`); ? this.opponentSwapAnimation.playerId
: this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`);
} }
// Update player header (name + score like opponents) // Update player header (name + score like opponents)
@ -2160,6 +2369,14 @@ class GolfGame {
// Update discard pile // Update discard pile
// Check if ANY player is holding a card (local or remote/CPU) // Check if ANY player is holding a card (local or remote/CPU)
const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card; const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
debugLog('RENDER', 'Discard pile', {
anyPlayerHolding: !!anyPlayerHolding,
localDrawn: this.drawnCard ? `${this.drawnCard.rank}` : null,
serverDrawn: this.gameState.drawn_card ? `${this.gameState.drawn_card.rank}` : null,
discardTop: this.gameState.discard_top ? `${this.gameState.discard_top.rank}${this.gameState.discard_top.suit?.[0]}` : 'EMPTY'
});
if (anyPlayerHolding) { if (anyPlayerHolding) {
// Someone is holding a drawn card - show discard pile as greyed/disabled // 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)
@ -2189,8 +2406,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');
// Skip discard update during opponent swap animation - animation handles the visual // Skip discard update during local discard animation - animation handles the visual
if (this.opponentSwapAnimation) { if (this.localDiscardAnimating) {
// Don't update discard content; animation will call updateDiscardPileDisplay
} else if (this.opponentSwapAnimation) {
// Don't update discard content; animation overlay shows the swap // Don't update discard content; animation overlay shows the swap
} else if (this.gameState.discard_top) { } else if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top; const discardCard = this.gameState.discard_top;
@ -2237,29 +2456,54 @@ class GolfGame {
// Show held card for ANY player who has drawn (consistent visual regardless of whose turn) // 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 // Local player uses this.drawnCard, others use gameState.drawn_card
if (this.drawnCard) { // Skip for opponents during draw pulse animation (pulse callback will show it)
// Skip for local player during draw animation (animation callback will show it)
if (this.drawnCard && !this.isDrawAnimating) {
// Local player is holding - show with pulse and discard button // Local player is holding - show with pulse and discard button
this.displayHeldCard(this.drawnCard, true); this.displayHeldCard(this.drawnCard, true);
} else if (this.gameState.drawn_card && this.gameState.drawn_player_id) { } else if (this.gameState.drawn_card && this.gameState.drawn_player_id) {
// Another player is holding - show without pulse/button // Another player is holding - show without pulse/button
// But defer display during draw pulse animation for clean sequencing
// Also skip for local player during their draw animation
const isLocalPlayer = this.gameState.drawn_player_id === this.playerId; const isLocalPlayer = this.gameState.drawn_player_id === this.playerId;
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer); const skipForLocalAnim = isLocalPlayer && this.isDrawAnimating;
if (!this.drawPulseAnimation && !skipForLocalAnim) {
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
}
} else { } else {
// No one holding a card // No one holding a card
this.hideDrawnCard(); this.hideDrawnCard();
} }
// Update deck/discard clickability and visual state // Update deck/discard clickability and visual state
// Use visual check so indicators sync with opponent swap animation
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.isVisuallyMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
// Pulse the deck area when it's player's turn to draw // Pulse the deck area when it's player's turn to draw
const wasTurnToDraw = this.deckArea.classList.contains('your-turn-to-draw');
this.deckArea.classList.toggle('your-turn-to-draw', canDraw); this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
// Use anime.js for turn pulse animation
if (canDraw && !wasTurnToDraw && window.cardAnimations) {
window.cardAnimations.startTurnPulse(this.deckArea);
} else if (!canDraw && wasTurnToDraw && window.cardAnimations) {
window.cardAnimations.stopTurnPulse(this.deckArea);
}
this.deck.classList.toggle('clickable', canDraw); this.deck.classList.toggle('clickable', canDraw);
// Show disabled on deck when any player has drawn (consistent dimmed look) // Show disabled on deck when any player has drawn (consistent dimmed look)
this.deck.classList.toggle('disabled', hasDrawn); this.deck.classList.toggle('disabled', hasDrawn);
// Apply deck color based on top card's deck_id
if (this.gameState.deck_colors && this.gameState.deck_colors.length > 0) {
const deckId = this.gameState.deck_top_deck_id || 0;
const deckColor = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
// Remove any existing back-* classes
this.deck.className = this.deck.className.replace(/\bback-\w+\b/g, '').trim();
this.deck.classList.add(`back-${deckColor}`);
}
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
// Disabled state handled by picked-up class when anyone is holding // Disabled state handled by picked-up class when anyone is holding
@ -2271,10 +2515,16 @@ class GolfGame {
// Don't highlight current player during round/game over // Don't highlight current player during round/game over
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
// During opponent swap animation, keep highlighting the player who just acted
// (turn indicator changes after the discard lands, not before)
const displayedCurrentPlayer = this.opponentSwapAnimation
? this.opponentSwapAnimation.playerId
: this.gameState.current_player_id;
opponents.forEach((player) => { opponents.forEach((player) => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'opponent-area'; div.className = 'opponent-area';
if (isPlaying && player.id === this.gameState.current_player_id) { if (isPlaying && player.id === displayedCurrentPlayer) {
div.classList.add('current-turn'); div.classList.add('current-turn');
} }
@ -2327,6 +2577,11 @@ class GolfGame {
// Add pulse animation during initial flip phase // Add pulse animation during initial flip phase
if (isInitialFlipClickable) { if (isInitialFlipClickable) {
cardEl.firstChild.classList.add('initial-flip-pulse'); cardEl.firstChild.classList.add('initial-flip-pulse');
cardEl.firstChild.dataset.position = index;
// Use anime.js for initial flip pulse
if (window.cardAnimations) {
window.cardAnimations.startInitialFlipPulse(cardEl.firstChild);
}
} }
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
@ -2489,6 +2744,12 @@ class GolfGame {
content = this.renderCardContent(card); content = this.renderCardContent(card);
} else { } else {
classes += ' card-back'; classes += ' card-back';
// Apply deck color based on card's deck_id
if (this.gameState?.deck_colors) {
const deckId = card.deck_id || 0;
const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
if (color) classes += ` back-${color}`;
}
} }
if (clickable) classes += ' clickable'; if (clickable) classes += ' clickable';

876
client/card-animations.js Normal file
View File

@ -0,0 +1,876 @@
// CardAnimations - Unified anime.js-based animation system
// Replaces draw-animations.js and handles ALL card animations
class CardAnimations {
constructor() {
this.activeAnimations = new Map();
this.isAnimating = false;
this.cleanupTimeout = null;
}
// === UTILITY METHODS ===
getDeckRect() {
const deck = document.getElementById('deck');
return deck ? deck.getBoundingClientRect() : null;
}
getDiscardRect() {
const discard = document.getElementById('discard');
return discard ? discard.getBoundingClientRect() : null;
}
getHoldingRect() {
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) return null;
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
return {
left: centerX - cardWidth / 2,
top: deckRect.top - overlapOffset,
width: cardWidth,
height: cardHeight
};
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
isRedSuit(suit) {
return suit === 'hearts' || suit === 'diamonds';
}
playSound(type) {
if (window.game && typeof window.game.playSound === 'function') {
window.game.playSound(type);
}
}
getEasing(type) {
const easings = window.TIMING?.anime?.easing || {};
return easings[type] || 'easeOutQuad';
}
// Create animated card element with 3D flip structure
createAnimCard(rect, showBack = false, deckColor = null) {
const card = document.createElement('div');
card.className = 'draw-anim-card';
card.innerHTML = `
<div class="draw-anim-inner">
<div class="draw-anim-front card card-front"></div>
<div class="draw-anim-back card card-back"></div>
</div>
`;
document.body.appendChild(card);
// Apply deck color to back
if (deckColor) {
const back = card.querySelector('.draw-anim-back');
back.classList.add(`back-${deckColor}`);
}
if (showBack) {
card.querySelector('.draw-anim-inner').style.transform = 'rotateY(180deg)';
}
if (rect) {
card.style.left = rect.left + 'px';
card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px';
}
return card;
}
setCardContent(card, cardData) {
const front = card.querySelector('.draw-anim-front');
if (!front) return;
front.className = 'draw-anim-front card card-front';
if (!cardData) return;
if (cardData.rank === '★') {
front.classList.add('joker');
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = this.isRedSuit(cardData.suit);
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
getDeckColor() {
if (window.game?.gameState?.deck_colors) {
const deckId = window.game.gameState.deck_top_deck_id || 0;
return window.game.gameState.deck_colors[deckId] || window.game.gameState.deck_colors[0];
}
return null;
}
cleanup() {
document.querySelectorAll('.draw-anim-card').forEach(el => el.remove());
this.isAnimating = false;
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}
}
cancelAll() {
// Cancel all tracked anime.js animations
for (const [id, anim] of this.activeAnimations) {
if (anim && typeof anim.pause === 'function') {
anim.pause();
}
}
this.activeAnimations.clear();
this.cleanup();
}
// === DRAW ANIMATIONS ===
// Draw from deck with suspenseful reveal
animateDrawDeck(cardData, onComplete) {
this.cleanup();
const deckRect = this.getDeckRect();
const holdingRect = this.getHoldingRect();
if (!deckRect || !holdingRect) {
if (onComplete) onComplete();
return;
}
this.isAnimating = true;
// Pulse the deck before drawing
this.startDrawPulse(document.getElementById('deck'));
// Delay card animation to let pulse be visible
setTimeout(() => {
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
}, 250);
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
const inner = animCard.querySelector('.draw-anim-inner');
if (cardData) {
this.setCardContent(animCard, cardData);
}
this.playSound('card');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
this.cleanup();
if (onComplete) onComplete();
}, 1500);
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.cleanup();
if (onComplete) onComplete();
}
});
// Lift off deck with slight wobble
timeline.add({
targets: animCard,
translateY: -15,
rotate: [-2, 0],
duration: 105,
easing: this.getEasing('lift')
});
// Move to holding position
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
duration: 175,
easing: this.getEasing('move')
});
// Suspense pause
timeline.add({ duration: 200 });
// Flip to reveal
if (cardData) {
timeline.add({
targets: inner,
rotateY: 0,
duration: 245,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip')
});
}
// Brief pause to see card
timeline.add({ duration: 150 });
this.activeAnimations.set('drawDeck', timeline);
} catch (e) {
console.error('Draw animation error:', e);
this.cleanup();
if (onComplete) onComplete();
}
}
// Draw from discard (quick decisive grab, no flip)
animateDrawDiscard(cardData, onComplete) {
this.cleanup();
const discardRect = this.getDiscardRect();
const holdingRect = this.getHoldingRect();
if (!discardRect || !holdingRect) {
if (onComplete) onComplete();
return;
}
this.isAnimating = true;
// Pulse discard pile
this.startDrawPulse(document.getElementById('discard'));
setTimeout(() => {
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
}, 200);
}
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
this.setCardContent(animCard, cardData);
this.playSound('card');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
this.cleanup();
if (onComplete) onComplete();
}, 600);
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.cleanup();
if (onComplete) onComplete();
}
});
// Quick decisive lift
timeline.add({
targets: animCard,
translateY: -12,
scale: 1.05,
duration: 42
});
// Direct move to holding
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
scale: 1,
duration: 126
});
// Minimal pause
timeline.add({ duration: 80 });
this.activeAnimations.set('drawDiscard', timeline);
} catch (e) {
console.error('Draw animation error:', e);
this.cleanup();
if (onComplete) onComplete();
}
}
// === FLIP ANIMATIONS ===
// Animate flipping a card element
animateFlip(element, cardData, onComplete) {
if (!element) {
if (onComplete) onComplete();
return;
}
const inner = element.querySelector('.card-inner');
if (!inner) {
if (onComplete) onComplete();
return;
}
const duration = 245; // 30% faster flip
try {
const anim = anime({
targets: inner,
rotateY: [180, 0],
duration: duration,
easing: this.getEasing('flip'),
begin: () => {
this.playSound('flip');
inner.classList.remove('flipped');
},
complete: () => {
if (onComplete) onComplete();
}
});
this.activeAnimations.set(`flip-${Date.now()}`, anim);
} catch (e) {
console.error('Flip animation error:', e);
inner.classList.remove('flipped');
if (onComplete) onComplete();
}
}
// Animate initial flip at game start - smooth flip only, no lift
animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) {
if (onComplete) onComplete();
return;
}
const rect = cardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
// Create overlay card for flip animation
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData);
// Hide original card during animation
cardElement.style.opacity = '0';
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
try {
// Simple smooth flip - no lift/settle
anime({
targets: inner,
rotateY: 0,
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
complete: () => {
animCard.remove();
cardElement.style.opacity = '1';
if (onComplete) onComplete();
}
});
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) {
console.error('Initial flip animation error:', e);
animCard.remove();
cardElement.style.opacity = '1';
if (onComplete) onComplete();
}
}
// Fire-and-forget flip for opponent cards
animateOpponentFlip(cardElement, cardData, rotation = 0) {
if (!cardElement) return;
const rect = cardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout
if (rotation) {
animCard.style.transform = `rotate(${rotation}deg)`;
}
cardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
try {
anime({
targets: inner,
rotateY: 0,
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
complete: () => {
animCard.remove();
cardElement.classList.remove('swap-out');
}
});
} catch (e) {
console.error('Opponent flip animation error:', e);
animCard.remove();
cardElement.classList.remove('swap-out');
}
}
// === SWAP ANIMATIONS ===
// Animate player swapping drawn card with hand card
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
if (!handCardElement) {
if (onComplete) onComplete();
return;
}
const isAlreadyFaceUp = oldCard?.face_up;
if (isAlreadyFaceUp) {
// Face-up swap: subtle pulse, no flip needed
this._animateFaceUpSwap(handCardElement, onComplete);
} else {
// Face-down swap: flip reveal then swap
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
}
}
_animateFaceUpSwap(handCardElement, onComplete) {
this.playSound('card');
// Apply swap pulse via anime.js
try {
const timeline = anime.timeline({
easing: 'easeOutQuad',
complete: () => {
if (onComplete) onComplete();
}
});
timeline.add({
targets: handCardElement,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
this.activeAnimations.set(`swapPulse-${Date.now()}`, timeline);
} catch (e) {
console.error('Face-up swap animation error:', e);
if (onComplete) onComplete();
}
}
_animateFaceDownSwap(position, oldCard, handCardElement, onComplete) {
const rect = handCardElement.getBoundingClientRect();
const discardRect = this.getDiscardRect();
const deckColor = this.getDeckColor();
// Create animated card at hand position
const animCard = this.createAnimCard(rect, true, deckColor);
// Set content to show what's being revealed (the OLD card going to discard)
if (oldCard) {
this.setCardContent(animCard, oldCard);
}
handCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
try {
const timeline = anime.timeline({
easing: this.getEasing('flip'),
complete: () => {
animCard.remove();
handCardElement.classList.remove('swap-out');
if (onComplete) onComplete();
}
});
// Flip to reveal old card
timeline.add({
targets: inner,
rotateY: 0,
duration: flipDuration,
begin: () => this.playSound('flip')
});
// Brief pause to see the card
timeline.add({ duration: 100 });
this.activeAnimations.set(`swap-${Date.now()}`, timeline);
} catch (e) {
console.error('Face-down swap animation error:', e);
animCard.remove();
handCardElement.classList.remove('swap-out');
if (onComplete) onComplete();
}
}
// Fire-and-forget opponent swap animation
animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation = 0, wasFaceUp = false) {
if (wasFaceUp && sourceCardElement) {
// Face-to-face swap: just pulse
this.pulseSwap(sourceCardElement);
return;
}
if (!sourceCardElement) return;
const rect = sourceCardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, discardCard);
if (rotation) {
animCard.style.transform = `rotate(${rotation}deg)`;
}
sourceCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
try {
anime.timeline({
easing: this.getEasing('flip'),
complete: () => {
animCard.remove();
this.pulseDiscard();
}
})
.add({
targets: inner,
rotateY: 0,
duration: flipDuration,
begin: () => this.playSound('flip')
});
} catch (e) {
console.error('Opponent swap animation error:', e);
animCard.remove();
}
}
// === DISCARD ANIMATIONS ===
// Animate held card swooping to discard pile
animateDiscard(heldCardElement, targetCard, onComplete) {
if (!heldCardElement) {
if (onComplete) onComplete();
return;
}
const discardRect = this.getDiscardRect();
if (!discardRect) {
if (onComplete) onComplete();
return;
}
this.playSound('card');
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.pulseDiscard();
if (onComplete) onComplete();
}
});
timeline.add({
targets: heldCardElement,
left: discardRect.left,
top: discardRect.top,
width: discardRect.width,
height: discardRect.height,
scale: 1,
duration: 350,
easing: 'cubicBezier(0.25, 0.1, 0.25, 1)'
});
this.activeAnimations.set(`discard-${Date.now()}`, timeline);
} catch (e) {
console.error('Discard animation error:', e);
if (onComplete) onComplete();
}
}
// Animate deck draw then immediate discard (for draw-discard by other players)
animateDeckToDiscard(card, onComplete) {
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) {
if (onComplete) onComplete();
return;
}
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
this.setCardContent(animCard, card);
const inner = animCard.querySelector('.draw-anim-inner');
const moveDuration = window.TIMING?.card?.move || 270;
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
animCard.remove();
this.pulseDiscard();
if (onComplete) onComplete();
}
});
// Small delay
timeline.add({ duration: 50 });
// Move to discard while flipping
timeline.add({
targets: animCard,
left: discardRect.left,
top: discardRect.top,
duration: moveDuration,
begin: () => this.playSound('card')
});
timeline.add({
targets: inner,
rotateY: 0,
duration: moveDuration * 0.8,
easing: this.getEasing('flip')
}, `-=${moveDuration * 0.6}`);
this.activeAnimations.set(`deckToDiscard-${Date.now()}`, timeline);
} catch (e) {
console.error('Deck to discard animation error:', e);
animCard.remove();
if (onComplete) onComplete();
}
}
// === AMBIENT EFFECTS (looping) ===
// Your turn to draw - quick rattlesnake shake every few seconds
startTurnPulse(element) {
if (!element) return;
const id = 'turnPulse';
this.stopTurnPulse(element);
// Quick shake animation
const doShake = () => {
if (!this.activeAnimations.has(id)) return;
anime({
targets: element,
translateX: [0, -4, 4, -3, 2, 0],
duration: 200,
easing: 'easeInOutQuad'
});
};
// Do initial shake, then repeat every 3 seconds
doShake();
const interval = setInterval(doShake, 3000);
this.activeAnimations.set(id, { interval });
}
stopTurnPulse(element) {
const id = 'turnPulse';
const existing = this.activeAnimations.get(id);
if (existing) {
if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause();
this.activeAnimations.delete(id);
}
if (element) {
anime.remove(element);
element.style.transform = '';
}
}
// CPU thinking - glow on discard pile
startCpuThinking(element) {
if (!element) return;
const id = 'cpuThinking';
this.stopCpuThinking(element);
const config = window.TIMING?.anime?.loop?.cpuThinking || { duration: 1500 };
try {
const anim = anime({
targets: element,
boxShadow: [
'0 4px 12px rgba(0,0,0,0.3)',
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
'0 4px 12px rgba(0,0,0,0.3)'
],
duration: config.duration,
easing: 'easeInOutSine',
loop: true
});
this.activeAnimations.set(id, anim);
} catch (e) {
console.error('CPU thinking animation error:', e);
}
}
stopCpuThinking(element) {
const id = 'cpuThinking';
const existing = this.activeAnimations.get(id);
if (existing) {
existing.pause();
this.activeAnimations.delete(id);
}
if (element) {
anime.remove(element);
element.style.boxShadow = '';
}
}
// Initial flip phase - clickable cards glow
startInitialFlipPulse(element) {
if (!element) return;
const id = `initialFlipPulse-${element.dataset.position || Date.now()}`;
const config = window.TIMING?.anime?.loop?.initialFlipGlow || { duration: 1500 };
try {
const anim = anime({
targets: element,
boxShadow: [
'0 0 0 2px rgba(244, 164, 96, 0.5)',
'0 0 0 4px rgba(244, 164, 96, 0.8), 0 0 15px rgba(244, 164, 96, 0.4)',
'0 0 0 2px rgba(244, 164, 96, 0.5)'
],
duration: config.duration,
easing: 'easeInOutSine',
loop: true
});
this.activeAnimations.set(id, anim);
} catch (e) {
console.error('Initial flip pulse animation error:', e);
}
}
stopInitialFlipPulse(element) {
if (!element) return;
const id = `initialFlipPulse-${element.dataset.position || ''}`;
// Try to find and stop any matching animation
for (const [key, anim] of this.activeAnimations) {
if (key.startsWith('initialFlipPulse')) {
anim.pause();
this.activeAnimations.delete(key);
}
}
anime.remove(element);
element.style.boxShadow = '';
}
stopAllInitialFlipPulses() {
for (const [key, anim] of this.activeAnimations) {
if (key.startsWith('initialFlipPulse')) {
anim.pause();
this.activeAnimations.delete(key);
}
}
}
// === ONE-SHOT EFFECTS ===
// Pulse when card lands on discard
pulseDiscard() {
const discard = document.getElementById('discard');
if (!discard) return;
const duration = window.TIMING?.feedback?.discardLand || 375;
try {
anime({
targets: discard,
scale: [1, 1.08, 1],
duration: duration,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Discard pulse error:', e);
}
}
// Pulse effect on swap
pulseSwap(element) {
if (!element) return;
this.playSound('card');
try {
anime({
targets: element,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Swap pulse error:', e);
}
}
// Pop-in effect when card appears
popIn(element) {
if (!element) return;
try {
anime({
targets: element,
scale: [0.5, 1.25, 1.15],
opacity: [0, 1, 1],
duration: 300,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Pop-in error:', e);
}
}
// Draw pulse effect (gold ring expanding)
startDrawPulse(element) {
if (!element) return;
element.classList.add('draw-pulse');
setTimeout(() => {
element.classList.remove('draw-pulse');
}, 450);
}
// === HELPER METHODS ===
isBusy() {
return this.isAnimating;
}
cancel() {
this.cancelAll();
}
}
// Create global instance
window.cardAnimations = new CardAnimations();
// Backwards compatibility - point drawAnimations to the new system
window.drawAnimations = window.cardAnimations;

View File

@ -78,12 +78,13 @@
<h3>Game Settings</h3> <h3>Game Settings</h3>
<div class="basic-settings-row"> <div class="basic-settings-row">
<div class="form-group"> <div class="form-group">
<label for="num-decks">Decks</label> <label>Decks</label>
<select id="num-decks"> <div class="stepper-control">
<option value="1">1</option> <button type="button" id="decks-minus" class="stepper-btn"></button>
<option value="2">2</option> <span id="num-decks-display" class="stepper-value">1</span>
<option value="3">3</option> <input type="hidden" id="num-decks" value="1">
</select> <button type="button" id="decks-plus" class="stepper-btn">+</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="num-rounds">Holes</label> <label for="num-rounds">Holes</label>
@ -94,13 +95,36 @@
<option value="1">1</option> <option value="1">1</option>
</select> </select>
</div> </div>
<div class="form-group"> <div id="deck-colors-group" class="form-group">
<label for="initial-flips">Cards Revealed</label> <label for="deck-color-preset">Card Backs</label>
<select id="initial-flips"> <div class="deck-color-selector">
<option value="2" selected>2 cards</option> <select id="deck-color-preset">
<option value="1">1 card</option> <optgroup label="Themes">
<option value="0">None</option> <option value="classic" selected>Classic</option>
</select> <option value="ninja">Ninja Turtles</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="berry">Berry</option>
<option value="neon">Neon</option>
<option value="royal">Royal</option>
<option value="earth">Earth</option>
</optgroup>
<optgroup label="Single Color">
<option value="all-red">All Red</option>
<option value="all-blue">All Blue</option>
<option value="all-green">All Green</option>
<option value="all-gold">All Gold</option>
<option value="all-purple">All Purple</option>
<option value="all-teal">All Teal</option>
<option value="all-pink">All Pink</option>
<option value="all-slate">All Slate</option>
</optgroup>
</select>
<div id="deck-color-preview" class="deck-color-preview">
<div class="preview-card deck-red"></div>
</div>
</div>
</div> </div>
</div> </div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p> <p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
@ -281,9 +305,7 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div id="deck" class="card card-back"> <div id="deck" class="card card-back"></div>
<span>?</span>
</div>
<div class="discard-stack"> <div class="discard-stack">
<div id="discard" class="card"> <div id="discard" class="card">
<span id="discard-content"></span> <span id="discard-content"></span>
@ -312,14 +334,14 @@
<div id="swap-card-from-hand" class="swap-card"> <div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
<!-- Drawn card being held (animates to hand) --> <!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden"> <div id="held-card" class="swap-card hidden">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
</div> </div>
@ -335,11 +357,12 @@
<!-- Right panel: Scores --> <!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel"> <div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden"> <div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button> <button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button> <button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
<hr class="scores-divider">
</div> </div>
<h4>Scores</h4>
<table id="score-table"> <table id="score-table">
<thead> <thead>
<tr> <tr>
@ -805,6 +828,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div> </div>
</div> </div>
<script src="anime.min.js"></script>
<script src="timing-config.js"></script>
<script src="card-animations.js"></script>
<script src="card-manager.js"></script> <script src="card-manager.js"></script>
<script src="state-differ.js"></script> <script src="state-differ.js"></script>
<script src="animation-queue.js"></script> <script src="animation-queue.js"></script>

View File

@ -249,6 +249,87 @@ body {
padding: 8px 4px; padding: 8px 4px;
} }
/* Stepper Control */
.stepper-control {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
padding: 4px 8px;
}
.stepper-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: #4a5568;
color: white;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.stepper-btn:hover {
background: #5a6578;
}
.stepper-btn:active {
background: #3a4558;
}
.stepper-value {
min-width: 24px;
text-align: center;
font-weight: bold;
font-size: 1.1rem;
}
/* Deck Color Selector */
.deck-color-selector {
display: flex;
align-items: center;
gap: 10px;
}
.deck-color-selector select {
flex: 1;
}
.deck-color-preview {
display: flex;
gap: 3px;
padding: 4px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.preview-card {
width: 16px;
height: 22px;
border-radius: 2px;
border: 1px solid rgba(255,255,255,0.2);
}
/* Deck color classes for preview cards */
.deck-red { background: linear-gradient(135deg, #c41e3a 0%, #922b21 100%); }
.deck-blue { background: linear-gradient(135deg, #2e5cb8 0%, #1a3a7a 100%); }
.deck-green { background: linear-gradient(135deg, #228b22 0%, #145214 100%); }
.deck-gold { background: linear-gradient(135deg, #daa520 0%, #b8860b 100%); }
.deck-purple { background: linear-gradient(135deg, #6a0dad 0%, #4b0082 100%); }
.deck-teal { background: linear-gradient(135deg, #008b8b 0%, #005f5f 100%); }
.deck-pink { background: linear-gradient(135deg, #db7093 0%, #c04f77 100%); }
.deck-slate { background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%); }
.deck-orange { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); }
.deck-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.deck-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
/* CPU Controls Section - below players list */ /* CPU Controls Section - below players list */
.cpu-controls-section { .cpu-controls-section {
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
@ -778,7 +859,7 @@ input::placeholder {
} }
.card-back { .card-back {
/* Bee-style diamond grid pattern - red with white crosshatch */ /* Bee-style diamond grid pattern - default red with white crosshatch */
background-color: #c41e3a; background-color: #c41e3a;
background-image: background-image:
linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%), linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
@ -793,6 +874,19 @@ input::placeholder {
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
/* Card back color variants */
.card-back.back-red { background-color: #c41e3a; border-color: #8b1528; }
.card-back.back-blue { background-color: #2e5cb8; border-color: #1a3a7a; }
.card-back.back-green { background-color: #228b22; border-color: #145214; }
.card-back.back-gold { background-color: #daa520; border-color: #b8860b; }
.card-back.back-purple { background-color: #6a0dad; border-color: #4b0082; }
.card-back.back-teal { background-color: #008b8b; border-color: #005f5f; }
.card-back.back-pink { background-color: #db7093; border-color: #c04f77; }
.card-back.back-slate { background-color: #4a5568; border-color: #2d3748; }
.card-back.back-orange { background-color: #e67e22; border-color: #d35400; }
.card-back.back-cyan { background-color: #00bcd4; border-color: #0097a7; }
.card-back.back-brown { background-color: #8b4513; border-color: #5d2f0d; }
.card-front { .card-front {
background: #fff; background: #fff;
border: 2px solid #ddd; border: 2px solid #ddd;
@ -829,6 +923,13 @@ input::placeholder {
color: #9b59b6; color: #9b59b6;
} }
/* Unknown card placeholder (locally flipped, server hasn't confirmed yet) */
.card-front .unknown-card {
font-size: 1.8em;
color: #7f8c8d;
opacity: 0.6;
}
.card.clickable { .card.clickable {
cursor: pointer; cursor: pointer;
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5); box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
@ -990,23 +1091,42 @@ input::placeholder {
align-items: flex-start; align-items: flex-start;
} }
/* Gentle pulse when it's your turn to draw */ /* Gentle pulse when it's your turn to draw - handled by anime.js */
.deck-area.your-turn-to-draw { /* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
animation: deckAreaPulse 2s ease-in-out infinite;
}
@keyframes deckAreaPulse {
0%, 100% {
filter: brightness(1);
transform: scale(1);
}
50% {
filter: brightness(1.08);
transform: scale(1.02);
}
}
/* Held card slot - hidden, using floating card over discard instead */ /* Held card slot - hidden, using floating card over discard instead */
/* Draw animation card (Anime.js powered) */
.draw-anim-card {
position: fixed;
z-index: 200;
perspective: 800px;
pointer-events: none;
}
.draw-anim-inner {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
}
.draw-anim-front,
.draw-anim-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
}
.draw-anim-front {
transform: rotateY(0deg);
}
.draw-anim-back {
transform: rotateY(180deg);
}
.held-card-slot { .held-card-slot {
display: none !important; display: none !important;
} }
@ -1022,28 +1142,30 @@ input::placeholder {
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;
transition: opacity 0.15s ease-out, transform 0.15s ease-out; /* No transition - anime.js handles animations */
} }
.held-card-floating.hidden { .held-card-floating.hidden {
opacity: 0; opacity: 0;
transform: scale(0.9);
pointer-events: none; pointer-events: none;
} }
/* Pop-in animation - now handled by anime.js popIn() */
/* Keeping class for backwards compatibility */
.held-card-floating.pop-in {
/* Animation handled by JS */
}
/* 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 {
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: border-color 0.3s ease-out, box-shadow 0.3s ease-out; /* transition removed - anime.js handles animations */
} }
/* Swoop animation for deck → immediate discard */ /* Swoop animation for deck → immediate discard */
.held-card-floating.swooping { .held-card-floating.swooping {
transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), /* transition removed - anime.js handles animations */
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 { .held-card-floating.swooping.landed {
@ -1132,35 +1254,57 @@ input::placeholder {
box-shadow: none; box-shadow: none;
} }
/* Highlight flash when opponent draws from a pile */ /* Highlight flash when drawing from a pile - uses ::after for guaranteed visibility */
#deck.draw-pulse, #deck.draw-pulse,
#discard.draw-pulse { #discard.draw-pulse {
animation: draw-highlight 0.45s ease-out; position: relative;
z-index: 100; z-index: 250;
} }
@keyframes draw-highlight { #deck.draw-pulse::after,
#discard.draw-pulse::after {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
border: 4px solid gold;
border-radius: 10px;
animation: draw-highlight-ring 0.4s ease-out forwards;
pointer-events: none;
z-index: 999;
}
@keyframes draw-highlight-ring {
0% { 0% {
transform: scale(1); opacity: 1;
outline: 0px solid rgba(255, 220, 100, 0); transform: scale(0.9);
border-width: 4px;
} }
15% { 30% {
transform: scale(1.08); opacity: 1;
outline: 3px solid rgba(255, 220, 100, 1); transform: scale(1.1);
outline-offset: 2px; border-width: 6px;
}
40% {
transform: scale(1.04);
outline: 3px solid rgba(255, 200, 80, 0.7);
outline-offset: 4px;
} }
100% { 100% {
transform: scale(1); opacity: 0;
outline: 3px solid rgba(255, 200, 80, 0); transform: scale(1.2);
outline-offset: 8px; border-width: 2px;
} }
} }
/* Deck "dealing" effect when drawing from deck */
#deck.dealing {
animation: deck-deal 0.15s ease-out;
}
@keyframes deck-deal {
0% { transform: scale(1); }
30% { transform: scale(0.97) translateY(2px); }
100% { transform: scale(1); }
}
/* Card appearing on discard pile */ /* Card appearing on discard pile */
.card-flip-in { .card-flip-in {
animation: cardFlipIn 0.25s ease-out; animation: cardFlipIn 0.25s ease-out;
@ -1171,37 +1315,11 @@ input::placeholder {
to { opacity: 1; } to { opacity: 1; }
} }
/* Discard pile pulse when card lands - simple glow */ /* Discard pile pulse when card lands - handled by anime.js pulseDiscard() */
#discard.discard-land { /* The .discard-land class is kept for backwards compatibility */
animation: discardLand 0.3s ease-out;
}
@keyframes discardLand { /* CPU considering discard pile - handled by anime.js startCpuThinking() */
0% { /* The .cpu-considering class is still used as a flag, but animation is via JS */
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
50% {
box-shadow: 0 0 20px rgba(244, 164, 96, 0.8);
}
100% {
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 pickup animation - simple dim */
#discard.discard-pickup { #discard.discard-pickup {
@ -1252,7 +1370,7 @@ input::placeholder {
height: 100%; height: 100%;
border-radius: 8px; border-radius: 8px;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); /* transition removed - anime.js handles all flip animations */
} }
.swap-card.flipping .swap-card-inner { .swap-card.flipping .swap-card-inner {
@ -1279,6 +1397,19 @@ input::placeholder {
font-size: 2rem; font-size: 2rem;
} }
/* Swap card back color variants */
.swap-card-back.back-red { background: linear-gradient(135deg, #c41e3a 0%, #922b21 100%); }
.swap-card-back.back-blue { background: linear-gradient(135deg, #2e5cb8 0%, #1a3a7a 100%); }
.swap-card-back.back-green { background: linear-gradient(135deg, #228b22 0%, #145214 100%); }
.swap-card-back.back-gold { background: linear-gradient(135deg, #daa520 0%, #b8860b 100%); }
.swap-card-back.back-purple { background: linear-gradient(135deg, #6a0dad 0%, #4b0082 100%); }
.swap-card-back.back-teal { background: linear-gradient(135deg, #008b8b 0%, #005f5f 100%); }
.swap-card-back.back-pink { background: linear-gradient(135deg, #db7093 0%, #c04f77 100%); }
.swap-card-back.back-slate { background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%); }
.swap-card-back.back-orange { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); }
.swap-card-back.back-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.swap-card-back.back-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
.swap-card-front { .swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%); background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 2px solid #ddd; border: 2px solid #ddd;
@ -1314,79 +1445,59 @@ input::placeholder {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.swap-card-front.unknown {
color: #7f8c8d;
}
.swap-card-front .unknown-icon {
font-size: 2em;
opacity: 0.6;
}
.swap-card.moving { .swap-card.moving {
transition: top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), /* transition removed - anime.js handles animations */
left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
} }
/* Card in hand fading during swap */ /* Card in hand fading during swap */
.card.swap-out { .card.swap-out {
opacity: 0; opacity: 0;
transition: opacity 0.1s; /* transition removed - anime.js handles animations */
} }
/* Discard fading during swap */ /* Discard fading during swap */
#discard.swap-to-hand { #discard.swap-to-hand {
opacity: 0; opacity: 0;
transition: opacity 0.2s; /* transition removed - anime.js handles animations */
} }
/* Subtle swap pulse for face-to-face swaps (no flip needed) */ /* Subtle swap pulse for face-to-face swaps - handled by anime.js pulseSwap() */
.card.swap-pulse { /* Keeping the class for backwards compatibility */
animation: swapPulse 0.4s ease-out;
}
@keyframes swapPulse {
0% {
transform: scale(1);
filter: brightness(1);
}
20% {
transform: scale(0.92);
filter: brightness(0.85);
}
50% {
transform: scale(1.08);
filter: brightness(1.15);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
100% {
transform: scale(1);
filter: brightness(1);
}
}
/* Fade transitions for swap animation */ /* Fade transitions for swap animation */
.card.fade-out, .card.fade-out,
.held-card-floating.fade-out, .held-card-floating.fade-out,
.anim-card.fade-out { .anim-card.fade-out {
opacity: 0; opacity: 0;
transition: opacity 0.3s ease-out; /* transition removed - anime.js handles animations */
} }
.card.fade-in, .card.fade-in,
.held-card-floating.fade-in, .held-card-floating.fade-in,
.anim-card.fade-in { .anim-card.fade-in {
opacity: 1; opacity: 1;
transition: opacity 0.3s ease-in; /* transition removed - anime.js handles animations */
} }
/* Pulse animation for clickable cards during initial flip phase */ /* Pulse animation for clickable cards during initial flip phase */
/* Now handled by anime.js startInitialFlipPulse() for consistency */
/* Keeping the class as a hook but animation is via JS */
.card.clickable.initial-flip-pulse { .card.clickable.initial-flip-pulse {
animation: initialFlipPulse 1.5s ease-in-out infinite; /* Fallback static glow if JS doesn't start animation */
} box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
@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 pulse glow for local player's turn */
/* Keeping CSS animation for this as it's a simple looping effect */
.held-card-floating.your-turn-pulse { .held-card-floating.your-turn-pulse {
animation: heldCardPulse 1.5s ease-in-out infinite; animation: heldCardPulse 1.5s ease-in-out infinite;
} }
@ -1711,11 +1822,16 @@ input::placeholder {
.game-buttons { .game-buttons {
margin-bottom: 10px; 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: 8px;
}
.game-buttons .scores-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.2);
margin: 4px 0 0 0;
width: 100%;
} }
.game-buttons .btn { .game-buttons .btn {
@ -2039,7 +2155,7 @@ input::placeholder {
height: 100%; height: 100%;
border-radius: 6px; border-radius: 6px;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); /* transition removed - anime.js handles all flip animations */
} }
.real-card .card-inner.flipped { .real-card .card-inner.flipped {
@ -2113,8 +2229,7 @@ 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.4s cubic-bezier(0.25, 0.1, 0.25, 1), /* transition removed - anime.js handles animations */
top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
} }
/* Animation card - temporary cards used for animations */ /* Animation card - temporary cards used for animations */
@ -2124,7 +2239,7 @@ input::placeholder {
} }
.real-card.anim-card .card-inner { .real-card.anim-card .card-inner {
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); /* transition removed - anime.js handles all flip animations */
} }
.real-card.holding { .real-card.holding {

View File

@ -30,8 +30,8 @@ const TIMING = {
// UI feedback durations (milliseconds) // UI feedback durations (milliseconds)
feedback: { feedback: {
drawPulse: 300, // Draw pile highlight duration drawPulse: 375, // Draw pile highlight duration (25% slower for clear sequencing)
discardLand: 300, // Discard land effect duration discardLand: 375, // Discard land effect duration (25% slower)
cardFlipIn: 300, // Card flip-in effect duration cardFlipIn: 300, // Card flip-in effect duration
statusMessage: 2000, // Toast/status message duration statusMessage: 2000, // Toast/status message duration
copyConfirm: 2000, // Copy button confirmation duration copyConfirm: 2000, // Copy button confirmation duration
@ -43,6 +43,21 @@ const TIMING = {
cpuConsidering: 1500, // CPU considering pulse cycle cpuConsidering: 1500, // CPU considering pulse cycle
}, },
// Anime.js animation configuration
anime: {
easing: {
flip: 'easeInOutQuad',
move: 'easeOutCubic',
lift: 'easeOutQuad',
pulse: 'easeInOutSine',
},
loop: {
turnPulse: { duration: 2000 },
cpuThinking: { duration: 1500 },
initialFlipGlow: { duration: 1500 },
}
},
// Card manager specific // Card manager specific
cardManager: { cardManager: {
flipDuration: 400, // Card flip animation flipDuration: 400, // Card flip animation

View File

@ -645,6 +645,16 @@ async def websocket_endpoint(websocket: WebSocket):
num_decks = data.get("decks", 1) num_decks = data.get("decks", 1)
num_rounds = data.get("rounds", 1) num_rounds = data.get("rounds", 1)
# Parse deck colors (validate against allowed colors)
allowed_colors = {
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
"green", "pink", "cyan", "brown", "slate"
}
raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"])
deck_colors = [c for c in raw_deck_colors if c in allowed_colors]
if not deck_colors:
deck_colors = ["red", "blue", "gold"]
# Build game options # Build game options
options = GameOptions( options = GameOptions(
# Standard options # Standard options
@ -669,6 +679,8 @@ async def websocket_endpoint(websocket: WebSocket):
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False), negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False), one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False), knock_early=data.get("knock_early", False),
# Multi-deck card back colors
deck_colors=deck_colors,
) )
# Validate settings # Validate settings
@ -1132,6 +1144,9 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Pause before CPU starts - let client animations settle and show current state
await asyncio.sleep(0.6)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) await broadcast_game_state(room)
@ -1191,6 +1206,10 @@ if os.path.exists(client_path):
async def serve_animation_queue(): async def serve_animation_queue():
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript") return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
@app.get("/timing-config.js")
async def serve_timing_config():
return FileResponse(os.path.join(client_path, "timing-config.js"), media_type="application/javascript")
@app.get("/leaderboard.js") @app.get("/leaderboard.js")
async def serve_leaderboard_js(): async def serve_leaderboard_js():
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript") return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript")
@ -1216,6 +1235,14 @@ if os.path.exists(client_path):
async def serve_replay_js(): async def serve_replay_js():
return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript") return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript")
@app.get("/card-animations.js")
async def serve_card_animations_js():
return FileResponse(os.path.join(client_path, "card-animations.js"), media_type="application/javascript")
@app.get("/anime.min.js")
async def serve_anime_js():
return FileResponse(os.path.join(client_path, "anime.min.js"), media_type="application/javascript")
# Serve replay page for share links # Serve replay page for share links
@app.get("/replay/{share_code}") @app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str): async def serve_replay_page(share_code: str):