diff --git a/client/anime.min.js b/client/anime.min.js new file mode 100644 index 0000000..5f19b3d --- /dev/null +++ b/client/anime.min.js @@ -0,0 +1,8 @@ +/* + * anime.js v3.2.2 + * (c) 2023 Julian Garnier + * Released under the MIT license + * animejs.com + */ + +!function(n,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):n.anime=e()}(this,function(){"use strict";var i={update:null,begin:null,loopBegin:null,changeBegin:null,change:null,changeComplete:null,loopComplete:null,complete:null,loop:1,direction:"normal",autoplay:!0,timelineOffset:0},M={duration:1e3,delay:0,endDelay:0,easing:"easeOutElastic(1, .5)",round:0},j=["translateX","translateY","translateZ","rotate","rotateX","rotateY","rotateZ","scale","scaleX","scaleY","scaleZ","skew","skewX","skewY","perspective","matrix","matrix3d"],l={CSS:{},springs:{}};function C(n,e,t){return Math.min(Math.max(n,e),t)}function u(n,e){return-1 { - const playerCount = this.currentPlayers ? this.currentPlayers.length : 0; - this.updateDeckRecommendation(playerCount); - }); + // Deck stepper controls + if (this.decksMinus) { + this.decksMinus.addEventListener('click', () => { + 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 const updateWolfpackCombo = () => { @@ -421,6 +462,11 @@ class GolfGame { this.selectedCards = []; this.animatingPositions = new Set(); this.opponentSwapAnimation = null; + this.drawPulseAnimation = false; + // Cancel any running animations from previous round + if (window.cardAnimations) { + window.cardAnimations.cancelAll(); + } this.playSound('shuffle'); this.showGameScreen(); this.renderGame(); @@ -432,6 +478,7 @@ class GolfGame { // If local swap animation is running, defer this state update if (this.swapAnimationInProgress) { + debugLog('STATE', 'Deferring state - swap animation in progress'); this.updateSwapAnimation(data.game_state.discard_top); this.pendingGameState = data.game_state; break; @@ -440,12 +487,25 @@ class GolfGame { const oldState = this.gameState; 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) this.gameState = newState; // Clear local flip tracking if server confirmed our flips if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) { 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) @@ -485,8 +545,31 @@ class GolfGame { case 'card_drawn': this.drawnCard = data.card; 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); break; @@ -601,12 +684,12 @@ class GolfGame { startGame() { try { - const decks = parseInt(this.numDecksSelect.value); - const rounds = parseInt(this.numRoundsSelect.value); - const initial_flips = parseInt(this.initialFlipsSelect.value); + const decks = parseInt(this.numDecksInput?.value || '1'); + const rounds = parseInt(this.numRoundsSelect?.value || '9'); + const initial_flips = parseInt(this.initialFlipsSelect?.value || '2'); // 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; // Joker mode (radio buttons) @@ -634,6 +717,9 @@ class GolfGame { const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false; const knock_early = this.knockEarlyCheckbox?.checked || false; + // Deck colors + const deck_colors = this.getDeckColors(decks); + this.send({ type: 'start_game', decks, @@ -655,7 +741,8 @@ class GolfGame { four_of_a_kind, negative_pairs_keep_value, one_eyed_jacks, - knock_early + knock_early, + deck_colors }); } catch (error) { console.error('Error starting game:', error); @@ -767,7 +854,7 @@ class GolfGame { return; } if (this.gameState.waiting_for_initial_flip) return; - this.playSound('card'); + // Sound played by draw animation this.send({ type: 'draw', source: 'deck' }); } @@ -787,7 +874,7 @@ class GolfGame { } if (this.gameState.waiting_for_initial_flip) return; if (!this.gameState.discard_top) return; - this.playSound('card'); + // Sound played by draw animation this.send({ type: 'draw', source: 'discard' }); } @@ -806,6 +893,9 @@ class GolfGame { // Also update lastDiscardKey so renderGame() won't see a "change" 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) this.animateDeckToDiscardSwoop(discardedCard); } @@ -852,11 +942,15 @@ class GolfGame { this.updateDiscardPileDisplay(card); this.pulseDiscardLand(); this.skipNextDiscardFlip = true; + // Allow renderGame to update discard again + this.localDiscardAnimating = false; }, 150); // Brief settle }, 350); // Match swoop transition duration } // 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) { this.discard.classList.remove('picked-up', 'disabled'); this.discard.classList.add('has-card', 'card-front'); @@ -868,7 +962,8 @@ class GolfGame { this.discardContent.innerHTML = `${jokerIcon}Joker`; } else { 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}
${this.getSuitSymbol(card.suit)}`; } this.lastDiscardKey = `${card.rank}-${card.suit}`; } @@ -972,12 +1067,11 @@ class GolfGame { // FACE-UP CARD: Subtle pulse animation (no flip needed) this.swapAnimationContentSet = true; - // Apply subtle swap pulse to both cards - handCardEl.classList.add('swap-pulse'); - this.heldCardFloating.classList.add('swap-pulse'); - - // Play a soft sound for the swap - this.playSound('card'); + // Apply subtle swap pulse using anime.js + if (window.cardAnimations) { + window.cardAnimations.pulseSwap(handCardEl); + window.cardAnimations.pulseSwap(this.heldCardFloating); + } // Send swap and let render handle the update this.send({ type: 'swap', position }); @@ -986,8 +1080,6 @@ class GolfGame { // Complete after pulse animation setTimeout(() => { - handCardEl.classList.remove('swap-pulse'); - this.heldCardFloating.classList.remove('swap-pulse'); this.completeSwapAnimation(null); }, 440); } else { @@ -1042,21 +1134,25 @@ class GolfGame { // Quick flip to reveal, then complete - server will pause before next turn if (this.swapAnimationCard) { - // Step 1: Flip to reveal (quick) - this.swapAnimationCard.classList.add('flipping'); + const swapCardInner = this.swapAnimationCard.querySelector('.swap-card-inner'); + const flipDuration = 245; // Match other flip durations + this.playSound('flip'); - // Step 2: Brief pulse after flip completes - setTimeout(() => { - this.swapAnimationCard.classList.add('swap-pulse'); - this.playSound('card'); - }, 350); - - // Step 3: Complete animation - the pause to see the result happens - // on the server side before the next CPU turn starts - setTimeout(() => { - this.completeSwapAnimation(null); - }, 550); + // Use anime.js for the flip animation + anime({ + targets: swapCardInner, + rotateY: [0, 180], + duration: flipDuration, + easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad', + complete: () => { + swapCardInner.style.transform = ''; + // Brief pause to see the card, then complete + setTimeout(() => { + this.completeSwapAnimation(null); + }, 100); + } + }); } else { // Fallback: animation element missing, complete immediately to avoid freeze console.error('Swap animation element missing, completing immediately'); @@ -1119,6 +1215,10 @@ class GolfGame { triggerAnimationsForStateChange(oldState, newState) { 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 const newDiscard = newState.discard_top; const oldDiscard = oldState.discard_top; @@ -1126,47 +1226,64 @@ class GolfGame { newDiscard.rank !== oldDiscard.rank || newDiscard.suit !== oldDiscard.suit); - const previousPlayerId = oldState.current_player_id; - const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId; + debugLog('DIFFER', 'State diff', { + 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 - if (wasOtherPlayer && discardChanged) { - const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); - const newPlayer = newState.players.find(p => p.id === previousPlayerId); + // STEP 1: Detect when someone DRAWS (drawn_card goes from null to something) + const justDrew = !oldState.drawn_card && newState.drawn_card; + const drawingPlayerId = newState.drawn_player_id; + const isOtherPlayerDrawing = drawingPlayerId && drawingPlayerId !== this.playerId; - if (oldPlayer && newPlayer && oldDiscard) { - // Check if any of their cards now matches the old discard top - // This means they took from discard pile - let tookFromDiscard = false; - for (let i = 0; i < 6; i++) { - const newCard = newPlayer.cards[i]; - if (newCard?.face_up && - newCard.rank === oldDiscard.rank && - newCard.suit === oldDiscard.suit) { - tookFromDiscard = true; - break; + if (justDrew && isOtherPlayerDrawing) { + // Detect source: if old discard is gone, they took from discard + const discardWasTaken = oldDiscard && (!newDiscard || + newDiscard.rank !== oldDiscard.rank || + newDiscard.suit !== oldDiscard.suit); + + debugLog('DIFFER', 'Other player drew', { + source: discardWasTaken ? 'discard' : 'deck', + drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}` : '?' + }); + + // 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 - 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'); + // Show CPU action announcement + const drawingPlayer = newState.players.find(p => p.id === drawingPlayerId); + if (drawingPlayer?.is_cpu) { + if (discardWasTaken && oldDiscard) { + this.showCpuAction(drawingPlayer.name, 'draw-discard', oldDiscard); + } else { + this.showCpuAction(drawingPlayer.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) // vs just discarding the drawn card (no hand change) 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 + // Defers held card display until pulse completes for clean sequencing pulseDrawPile(source) { const T = window.TIMING?.feedback || {}; + const pulseDuration = T.drawPulse || 450; const pile = source === 'discard' ? this.discard : this.deck; + + // Set flag to defer held card display + this.drawPulseAnimation = true; + 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'), 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 - 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 || {}; - this.discard.classList.remove('discard-land'); - void this.discard.offsetWidth; - this.discard.classList.add('discard-land'); - setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460); + const duration = T.discardLand || 375; + setTimeout(() => { + if (onComplete) onComplete(); + }, duration); } // Fire animation for discard without swap (card lands on discard pile face-up) @@ -1312,7 +1449,7 @@ class GolfGame { animCard.innerHTML = `
-
?
+
`; @@ -1409,11 +1546,17 @@ class GolfGame { // Face-to-face swap: use subtle pulse on the card, no flip needed if (wasFaceUp && sourceCardEl) { - sourceCardEl.classList.add('swap-pulse'); - this.playSound('card'); + if (window.cardAnimations) { + window.cardAnimations.pulseSwap(sourceCardEl); + } const pulseDuration = window.TIMING?.feedback?.discardPickup || 400; 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); return; } @@ -1452,21 +1595,26 @@ 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; + // Use anime.js for the flip animation (CSS transitions removed) + 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'); - - // Step 2: After flip completes, clean up immediately - setTimeout(() => { - this.swapAnimation.classList.add('hidden'); - swapCard.classList.remove('flipping'); - swapCard.style.transform = ''; - this.opponentSwapAnimation = null; - this.pulseDiscardLand(); - }, flipTime); + anime({ + targets: swapCardInner, + rotateY: [0, 180], + duration: flipDuration, + easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad', + complete: () => { + this.swapAnimation.classList.add('hidden'); + swapCardInner.style.transform = ''; + 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) @@ -1482,46 +1630,15 @@ class GolfGame { return; } - const cardRect = cardEl.getBoundingClientRect(); - const swapCard = this.swapCardFromHand; - const swapCardFront = swapCard.querySelector('.swap-card-front'); - - 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 = `${jokerIcon}Joker`; + // Use the unified card animation system for consistent flip animation + if (window.cardAnimations) { + window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { + this.animatingPositions.delete(key); + }); } else { - swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); - const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit]; - swapCardFront.innerHTML = `${cardData.rank}
${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'); + // Fallback if card animations not available this.animatingPositions.delete(key); - }, preFlip + flipDuration); + } } // Fire a flip animation for opponent card (non-blocking) @@ -1552,50 +1669,15 @@ class GolfGame { return; } - const cardRect = cardEl.getBoundingClientRect(); - const swapCard = this.swapCardFromHand; - const swapCardFront = swapCard.querySelector('.swap-card-front'); - - 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 = `${jokerIcon}Joker`; - } else { - swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); - const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit]; - swapCardFront.innerHTML = `${cardData.rank}
${suitSymbol}`; + // Use the unified card animation system for consistent flip animation + if (window.cardAnimations) { + window.cardAnimations.animateOpponentFlip(cardEl, cardData, sourceRotation); } - 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; - + // Clear tracking after animation duration 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); - }, preFlip + flipDuration); + }, (window.TIMING?.card?.flip || 400) + 100); } handleCardClick(position) { @@ -1753,6 +1835,8 @@ class GolfGame { this.hostSettings.classList.remove('hidden'); this.cpuControlsSection.classList.remove('hidden'); this.waitingMessage.classList.add('hidden'); + // Initialize deck color preview + this.updateDeckColorPreview(); } else { this.hostSettings.classList.add('hidden'); this.cpuControlsSection.classList.add('hidden'); @@ -1837,7 +1921,9 @@ class GolfGame { // Auto-select 2 decks when reaching 4+ players (host only) const prevCount = this.currentPlayers ? this.currentPlayers.length : 0; 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 @@ -1847,7 +1933,7 @@ class GolfGame { updateDeckRecommendation(playerCount) { 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 if (playerCount >= 4 && decks < 2) { 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() { 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() { if (!this.gameState) return null; return this.gameState.players.find(p => p.id === this.playerId); @@ -1895,8 +2054,15 @@ class GolfGame { if (isCpuTurn && hasNotDrawn) { this.discard.classList.add('cpu-considering'); + // Use anime.js for CPU thinking animation + if (window.cardAnimations) { + window.cardAnimations.startCpuThinking(this.discard); + } } else { 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() { // Hide the floating held card this.heldCardFloating.classList.add('hidden'); @@ -2101,6 +2301,10 @@ class GolfGame { renderCardContent(card) { 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) if (card.rank === '★') { 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 - const isMyTurn = this.isMyTurn(); - this.gameScreen.classList.toggle('not-my-turn', !isMyTurn); + // Use visual check so turn indicators sync with discard land animation + const isVisuallyMyTurn = this.isVisuallyMyTurn(); + this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn); // 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); - if (currentPlayer && currentPlayer.id !== this.playerId) { - this.setStatus(`${currentPlayer.name}'s turn`); + // During opponent swap animation, show the animating player (not the new current player) + const displayedPlayerId = this.opponentSwapAnimation + ? 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) @@ -2160,6 +2369,14 @@ class GolfGame { // Update discard pile // Check if ANY player is holding a card (local or remote/CPU) 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) { // 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) @@ -2189,8 +2406,10 @@ class GolfGame { // Not holding - show normal discard pile this.discard.classList.remove('picked-up'); - // Skip discard update during opponent swap animation - animation handles the visual - if (this.opponentSwapAnimation) { + // Skip discard update during local discard animation - animation handles the visual + 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 } else if (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) // 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 this.displayHeldCard(this.drawnCard, true); } else if (this.gameState.drawn_card && this.gameState.drawn_player_id) { // 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; - this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer); + const skipForLocalAnim = isLocalPlayer && this.isDrawAnimating; + if (!this.drawPulseAnimation && !skipForLocalAnim) { + this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer); + } } else { // No one holding a card this.hideDrawnCard(); } // 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 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 + const wasTurnToDraw = this.deckArea.classList.contains('your-turn-to-draw'); 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); // Show disabled on deck when any player has drawn (consistent dimmed look) 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); // 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 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) => { const div = document.createElement('div'); div.className = 'opponent-area'; - if (isPlaying && player.id === this.gameState.current_player_id) { + if (isPlaying && player.id === displayedCurrentPlayer) { div.classList.add('current-turn'); } @@ -2327,6 +2577,11 @@ class GolfGame { // Add pulse animation during initial flip phase if (isInitialFlipClickable) { 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)); @@ -2489,6 +2744,12 @@ class GolfGame { content = this.renderCardContent(card); } else { 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'; diff --git a/client/card-animations.js b/client/card-animations.js new file mode 100644 index 0000000..e4a2075 --- /dev/null +++ b/client/card-animations.js @@ -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 = ` +
+
+
+
+ `; + 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 = `${icon}Joker`; + } else { + const isRed = this.isRedSuit(cardData.suit); + front.classList.add(isRed ? 'red' : 'black'); + front.innerHTML = `${cardData.rank}
${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; diff --git a/client/index.html b/client/index.html index 730b880..c482f52 100644 --- a/client/index.html +++ b/client/index.html @@ -78,12 +78,13 @@

Game Settings

- - + +
+ + 1 + + +
@@ -94,13 +95,36 @@
-
- - +
+ +
+ +
+
+
+
@@ -281,9 +305,7 @@
Holding -
- ? -
+
@@ -312,14 +334,14 @@
-
?
+
@@ -335,11 +357,12 @@
-

Scores

+

Scores

@@ -805,6 +828,9 @@ TOTAL: 0 + 8 + 16 = 24 points + + + diff --git a/client/style.css b/client/style.css index 8ad5cd3..dc9fabe 100644 --- a/client/style.css +++ b/client/style.css @@ -249,6 +249,87 @@ body { 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 { background: rgba(0,0,0,0.2); @@ -778,7 +859,7 @@ input::placeholder { } .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-image: 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); } +/* 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 { background: #fff; border: 2px solid #ddd; @@ -829,6 +923,13 @@ input::placeholder { 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 { cursor: pointer; box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5); @@ -990,23 +1091,42 @@ input::placeholder { align-items: flex-start; } -/* Gentle pulse when it's your turn to draw */ -.deck-area.your-turn-to-draw { - 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); - } -} +/* Gentle pulse when it's your turn to draw - handled by anime.js */ +/* The .your-turn-to-draw class triggers anime.js startTurnPulse() */ /* 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 { display: none !important; } @@ -1022,28 +1142,30 @@ input::placeholder { border: 3px solid #f4a460 !important; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important; pointer-events: none; - transition: opacity 0.15s ease-out, transform 0.15s ease-out; + /* No transition - anime.js handles animations */ } .held-card-floating.hidden { opacity: 0; - transform: scale(0.9); 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) */ .held-card-floating.dropping { border-color: transparent !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 */ .held-card-floating.swooping { - transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), - top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), - width 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), - height 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); + /* transition removed - anime.js handles animations */ } .held-card-floating.swooping.landed { @@ -1132,35 +1254,57 @@ input::placeholder { 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, #discard.draw-pulse { - animation: draw-highlight 0.45s ease-out; - z-index: 100; + position: relative; + 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% { - transform: scale(1); - outline: 0px solid rgba(255, 220, 100, 0); + opacity: 1; + transform: scale(0.9); + border-width: 4px; } - 15% { - transform: scale(1.08); - outline: 3px solid rgba(255, 220, 100, 1); - outline-offset: 2px; - } - 40% { - transform: scale(1.04); - outline: 3px solid rgba(255, 200, 80, 0.7); - outline-offset: 4px; + 30% { + opacity: 1; + transform: scale(1.1); + border-width: 6px; } 100% { - transform: scale(1); - outline: 3px solid rgba(255, 200, 80, 0); - outline-offset: 8px; + opacity: 0; + transform: scale(1.2); + 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-flip-in { animation: cardFlipIn 0.25s ease-out; @@ -1171,37 +1315,11 @@ input::placeholder { to { opacity: 1; } } -/* Discard pile pulse when card lands - simple glow */ -#discard.discard-land { - animation: discardLand 0.3s ease-out; -} +/* Discard pile pulse when card lands - handled by anime.js pulseDiscard() */ +/* The .discard-land class is kept for backwards compatibility */ -@keyframes discardLand { - 0% { - 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); - } -} +/* CPU considering discard pile - handled by anime.js startCpuThinking() */ +/* The .cpu-considering class is still used as a flag, but animation is via JS */ /* Discard pickup animation - simple dim */ #discard.discard-pickup { @@ -1252,7 +1370,7 @@ input::placeholder { height: 100%; border-radius: 8px; 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 { @@ -1279,6 +1397,19 @@ input::placeholder { 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 { background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%); border: 2px solid #ddd; @@ -1314,79 +1445,59 @@ input::placeholder { letter-spacing: 0.05em; } +.swap-card-front.unknown { + color: #7f8c8d; +} + +.swap-card-front .unknown-icon { + font-size: 2em; + opacity: 0.6; +} + .swap-card.moving { - transition: top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), - left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); + /* transition removed - anime.js handles animations */ } /* Card in hand fading during swap */ .card.swap-out { opacity: 0; - transition: opacity 0.1s; + /* transition removed - anime.js handles animations */ } /* Discard fading during swap */ #discard.swap-to-hand { opacity: 0; - transition: opacity 0.2s; + /* transition removed - anime.js handles animations */ } -/* Subtle swap pulse for face-to-face swaps (no flip needed) */ -.card.swap-pulse { - 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); - } -} +/* Subtle swap pulse for face-to-face swaps - handled by anime.js pulseSwap() */ +/* Keeping the class for backwards compatibility */ /* Fade transitions for swap animation */ .card.fade-out, .held-card-floating.fade-out, .anim-card.fade-out { opacity: 0; - transition: opacity 0.3s ease-out; + /* transition removed - anime.js handles animations */ } .card.fade-in, .held-card-floating.fade-in, .anim-card.fade-in { opacity: 1; - transition: opacity 0.3s ease-in; + /* transition removed - anime.js handles animations */ } /* 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 { - animation: initialFlipPulse 1.5s ease-in-out infinite; -} - -@keyframes initialFlipPulse { - 0%, 100% { - box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5); - } - 50% { - box-shadow: 0 0 0 4px rgba(244, 164, 96, 0.8), - 0 0 15px rgba(244, 164, 96, 0.4); - } + /* Fallback static glow if JS doesn't start animation */ + box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5); } /* 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 { animation: heldCardPulse 1.5s ease-in-out infinite; } @@ -1711,11 +1822,16 @@ input::placeholder { .game-buttons { margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.2); display: flex; 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 { @@ -2039,7 +2155,7 @@ input::placeholder { height: 100%; border-radius: 6px; 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 { @@ -2113,8 +2229,7 @@ input::placeholder { .real-card.moving, .real-card.anim-card.moving { z-index: 600; - transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), - top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); + /* transition removed - anime.js handles animations */ } /* Animation card - temporary cards used for animations */ @@ -2124,7 +2239,7 @@ input::placeholder { } .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 { diff --git a/client/timing-config.js b/client/timing-config.js index 1638bd7..4e57096 100644 --- a/client/timing-config.js +++ b/client/timing-config.js @@ -30,8 +30,8 @@ const TIMING = { // UI feedback durations (milliseconds) feedback: { - drawPulse: 300, // Draw pile highlight duration - discardLand: 300, // Discard land effect duration + drawPulse: 375, // Draw pile highlight duration (25% slower for clear sequencing) + discardLand: 375, // Discard land effect duration (25% slower) cardFlipIn: 300, // Card flip-in effect duration statusMessage: 2000, // Toast/status message duration copyConfirm: 2000, // Copy button confirmation duration @@ -43,6 +43,21 @@ const TIMING = { 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 cardManager: { flipDuration: 400, // Card flip animation diff --git a/server/main.py b/server/main.py index 7b638e9..0379df5 100644 --- a/server/main.py +++ b/server/main.py @@ -645,6 +645,16 @@ async def websocket_endpoint(websocket: WebSocket): num_decks = data.get("decks", 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 options = GameOptions( # Standard options @@ -669,6 +679,8 @@ async def websocket_endpoint(websocket: WebSocket): negative_pairs_keep_value=data.get("negative_pairs_keep_value", False), one_eyed_jacks=data.get("one_eyed_jacks", False), knock_early=data.get("knock_early", False), + # Multi-deck card back colors + deck_colors=deck_colors, ) # 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: return + # Pause before CPU starts - let client animations settle and show current state + await asyncio.sleep(0.6) + # Run CPU turn async def broadcast_cb(): await broadcast_game_state(room) @@ -1191,6 +1206,10 @@ if os.path.exists(client_path): async def serve_animation_queue(): 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") async def serve_leaderboard_js(): 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(): 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 @app.get("/replay/{share_code}") async def serve_replay_page(share_code: str):