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:
parent
7b64b8c17c
commit
bc1b1b7725
8
client/anime.min.js
vendored
Normal file
8
client/anime.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
615
client/app.js
615
client/app.js
@ -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 = '';
|
||||||
|
// Brief pause to see the card, then complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.completeSwapAnimation(null);
|
this.completeSwapAnimation(null);
|
||||||
}, 550);
|
}, 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);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Pulse the appropriate pile
|
if (discardWasTaken) {
|
||||||
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
|
window.drawAnimations.animateDrawDiscard(drawnCard, onAnimComplete);
|
||||||
|
|
||||||
// Show CPU action announcement for draw
|
|
||||||
if (oldPlayer.is_cpu) {
|
|
||||||
this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No old discard or couldn't detect - assume deck
|
window.drawAnimations.animateDrawDeck(drawnCard, onAnimComplete);
|
||||||
this.pulseDrawPile('deck');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show CPU action announcement
|
// Show CPU action announcement
|
||||||
if (oldPlayer?.is_cpu) {
|
const drawingPlayer = newState.players.find(p => p.id === drawingPlayerId);
|
||||||
this.showCpuAction(oldPlayer.name, 'draw-deck');
|
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)
|
// 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],
|
||||||
|
duration: flipDuration,
|
||||||
|
easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
|
||||||
|
complete: () => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
swapCard.classList.remove('flipping');
|
swapCardInner.style.transform = '';
|
||||||
swapCard.style.transform = '';
|
swapCard.style.transform = '';
|
||||||
|
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
||||||
|
// Update turn indicator after flip completes
|
||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.pulseDiscardLand();
|
this.renderGame();
|
||||||
}, flipTime);
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, () => {
|
||||||
|
|
||||||
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 {
|
|
||||||
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');
|
|
||||||
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);
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback if card animations not available
|
||||||
|
this.animatingPositions.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
const skipForLocalAnim = isLocalPlayer && this.isDrawAnimating;
|
||||||
|
if (!this.drawPulseAnimation && !skipForLocalAnim) {
|
||||||
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
|
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
876
client/card-animations.js
Normal 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;
|
||||||
@ -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>
|
||||||
|
<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>
|
</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>
|
||||||
|
|||||||
353
client/style.css
353
client/style.css
@ -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 */
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes initialFlipPulse {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
|
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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user