golfgame/client/card-animations.js
adlee-was-taken 3414bfad1a Sync held card position across all animation paths for mobile portrait
Update getHoldingRect() in card-animations.js and the second held card
positioning path in app.js to use the same reduced overlap offset on
mobile portrait. All three places that compute the held position now
use 0.15 on mobile-portrait vs 0.35 on desktop/landscape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:12:46 -05:00

1737 lines
58 KiB
JavaScript

// 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;
}
getDealerRect(dealerId) {
if (!dealerId) return null;
// Check if dealer is the local player
const playerArea = document.querySelector('.player-area');
if (playerArea && window.game?.playerId === dealerId) {
return playerArea.getBoundingClientRect();
}
// Check opponents
const opponentArea = document.querySelector(`.opponent-area[data-player-id="${dealerId}"]`);
if (opponentArea) {
return opponentArea.getBoundingClientRect();
}
return 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 isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.15 : 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';
}
// Font size proportional to card width — consistent across all card types.
// Mobile uses a tighter ratio since cards are smaller and closer together.
cardFontSize(width) {
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
return (width * ratio) + 'px';
}
// 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>
`;
// Set position BEFORE appending to avoid flash at 0,0
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';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
}
// 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)';
}
// Now append to body after all styles are set
document.body.appendChild(card);
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() {
// Cancel all tracked anime.js animations to prevent stale callbacks
for (const [id, anim] of this.activeAnimations) {
if (anim && typeof anim.pause === 'function') {
anim.pause();
}
}
this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating)
document.querySelectorAll('.draw-anim-card').forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') {
discardPile.style.opacity = '';
}
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
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
}, pulseDelay);
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating
const inner = animCard.querySelector('.draw-anim-inner');
const D = window.TIMING?.draw || {};
if (cardData) {
this.setCardContent(animCard, cardData);
}
this.playSound('draw-deck');
// 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: D.deckLift || 120,
easing: this.getEasing('lift')
});
// Move to holding position with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
duration: D.deckMove || 250,
easing: this.getEasing('move')
});
// Brief pause before flip (easing handles the deceleration feel)
timeline.add({ duration: D.deckRevealPause || 80 });
// Flip to reveal
if (cardData) {
timeline.add({
targets: inner,
rotateY: 0,
duration: D.deckFlip || 320,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip')
});
}
// Brief pause to see card
timeline.add({ duration: D.deckViewPause || 120 });
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'));
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
}, pulseDelay);
}
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
animCard.dataset.animating = 'true'; // Mark as actively animating
this.setCardContent(animCard, cardData);
const D = window.TIMING?.draw || {};
// Hide actual discard pile during animation to prevent visual conflict
const discardPile = document.getElementById('discard');
if (discardPile) {
discardPile.style.opacity = '0';
}
this.playSound('draw-discard');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
if (discardPile) discardPile.style.opacity = '';
this.cleanup();
if (onComplete) onComplete();
}, 600);
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
if (discardPile) discardPile.style.opacity = '';
this.cleanup();
if (onComplete) onComplete();
}
});
// Quick decisive lift
timeline.add({
targets: animCard,
translateY: -12,
scale: 1.05,
duration: D.discardLift || 80,
easing: this.getEasing('lift')
});
// Direct move to holding with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
scale: 1,
duration: D.discardMove || 200,
easing: this.getEasing('move')
});
// Brief settle
timeline.add({ duration: D.discardViewPause || 60 });
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 = window.TIMING?.card?.flip || 320;
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 = window.TIMING?.card?.flip || 320;
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 = window.TIMING?.card?.flip || 320;
// Helper to restore card to face-up state
const restoreCard = () => {
animCard.remove();
cardElement.classList.remove('swap-out');
// Restore face-up appearance
if (cardData) {
cardElement.className = 'card card-front';
if (cardData.rank === '★') {
cardElement.classList.add('joker');
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
cardElement.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
cardElement.classList.add(isRed ? 'red' : 'black');
cardElement.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
};
try {
anime({
targets: inner,
rotateY: 0,
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
complete: restoreCard
});
} catch (e) {
console.error('Opponent flip animation error:', e);
restoreCard();
}
}
// === 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 = window.TIMING?.card?.flip || 320;
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: 50 });
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 = window.TIMING?.card?.flip || 320;
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: window.TIMING?.card?.move || 300,
easing: this.getEasing('arc')
});
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 || 300;
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, -8, 8, -6, 4, 0],
duration: 400,
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 = '';
}
}
// V3_06: Opponent area thinking glow
startOpponentThinking(area) {
if (!area) return;
const playerId = area.dataset.playerId;
const id = `opponentThinking-${playerId}`;
// Don't restart if already running for this player
if (this.activeAnimations.has(id)) return;
try {
const anim = anime({
targets: area,
boxShadow: [
'0 0 0 rgba(244, 164, 96, 0)',
'0 0 15px rgba(244, 164, 96, 0.4)',
'0 0 0 rgba(244, 164, 96, 0)'
],
duration: 1500,
easing: 'easeInOutSine',
loop: true
});
this.activeAnimations.set(id, anim);
} catch (e) {
console.error('Opponent thinking animation error:', e);
}
}
stopOpponentThinking(area) {
if (!area) return;
const playerId = area.dataset.playerId;
const id = `opponentThinking-${playerId}`;
const existing = this.activeAnimations.get(id);
if (existing) {
existing.pause();
this.activeAnimations.delete(id);
}
anime.remove(area);
area.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);
}
}
// V3_11: Physical swap animation - cards visibly exchange positions
animatePhysicalSwap(handCardEl, heldCardEl, onComplete) {
if (!handCardEl || !heldCardEl) {
if (onComplete) onComplete();
return;
}
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const handRect = handCardEl.getBoundingClientRect();
const heldRect = heldCardEl.getBoundingClientRect();
const discardRect = this.getDiscardRect();
if (!discardRect) {
this.pulseSwap(handCardEl);
if (onComplete) setTimeout(onComplete, 400);
return;
}
// Create traveling clones
const travelingHand = this.createTravelingCard(handCardEl);
const travelingHeld = this.createTravelingCard(heldCardEl);
document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld);
// Position at source
this.positionAt(travelingHand, handRect);
this.positionAt(travelingHeld, heldRect);
// Hide originals
handCardEl.style.visibility = 'hidden';
heldCardEl.style.visibility = 'hidden';
this.playSound('card');
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingHand.remove();
travelingHeld.remove();
handCardEl.style.visibility = '';
heldCardEl.style.visibility = '';
this.activeAnimations.delete('physicalSwap');
if (onComplete) onComplete();
}
});
// Arc midpoints
const arcUp = Math.min(handRect.top, heldRect.top) - 30;
// Lift
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: -8,
scale: 1.02,
duration: T.lift,
easing: this.getEasing('lift')
});
// Hand card arcs to discard
timeline.add({
targets: travelingHand,
left: discardRect.left,
top: [
{ value: arcUp, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
rotate: [0, -3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Held card arcs to hand slot (parallel)
timeline.add({
targets: travelingHeld,
left: handRect.left,
top: [
{ value: arcUp + 20, duration: T.arc / 2 },
{ value: handRect.top, duration: T.arc / 2 }
],
rotate: [0, 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('physicalSwap', timeline);
} catch (e) {
console.error('Physical swap animation error:', e);
travelingHand.remove();
travelingHeld.remove();
handCardEl.style.visibility = '';
heldCardEl.style.visibility = '';
if (onComplete) onComplete();
}
}
// Unified swap animation for ALL swap scenarios
// handCardData: the card in hand being swapped out (goes to discard)
// heldCardData: the drawn/held card being swapped in (goes to hand)
// handRect: position of the hand card
// heldRect: position of the held card (or null to use default holding position)
// options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect();
// Safety checks
if (!handRect || !discardRect || !handCardData || !heldCardData) {
console.warn('animateUnifiedSwap: missing required data');
if (onComplete) onComplete();
return;
}
// Use holding position if heldRect not provided
if (!heldRect) {
heldRect = this.getHoldingRect();
}
if (!heldRect) {
if (onComplete) onComplete();
return;
}
// Wait for any in-progress draw animation to complete
// Check if there's an active draw animation by looking for overlay cards
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Now run the swap animation
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}, 100);
return;
}
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
// Create the two traveling cards
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
travelingHand.dataset.animating = 'true';
travelingHeld.dataset.animating = 'true';
document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld);
this.playSound('card');
// If hand card was face-down, flip it first
if (wasHandFaceDown) {
const inner = travelingHand.querySelector('.draw-anim-inner');
if (inner) {
// Start showing back
inner.style.transform = 'rotateY(180deg)';
// Flip to reveal, then do the swap
this.playSound('flip');
const flipDuration = window.TIMING?.card?.flip || 320;
anime({
targets: inner,
rotateY: 0,
duration: flipDuration,
easing: this.getEasing('flip'),
complete: () => {
this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
}
});
return;
}
}
// Both face-up, do the swap immediately
this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
}
_doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete) {
try {
const arcUp = Math.min(handRect.top, heldRect.top, discardRect.top) - 40;
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingHand.remove();
travelingHeld.remove();
this.activeAnimations.delete('unifiedSwap');
if (onComplete) onComplete();
}
});
// Lift both cards
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: -10,
scale: 1.03,
duration: T.lift,
easing: this.getEasing('lift')
});
// Hand card arcs to discard (apply counter-rotation to land flat)
const handFront = travelingHand.querySelector('.draw-anim-front');
const heldFront = travelingHeld.querySelector('.draw-anim-front');
timeline.add({
targets: travelingHand,
left: discardRect.left,
top: [
{ value: arcUp, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
width: discardRect.width,
height: discardRect.height,
rotate: [rotation, rotation - 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Scale hand card font to match discard size
if (handFront) {
timeline.add({
targets: handFront,
fontSize: this.cardFontSize(discardRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Held card arcs to hand slot (apply rotation to match hand position)
timeline.add({
targets: travelingHeld,
left: handRect.left,
top: [
{ value: arcUp + 25, duration: T.arc / 2 },
{ value: handRect.top, duration: T.arc / 2 }
],
width: handRect.width,
height: handRect.height,
rotate: [0, 3, rotation],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Scale held card font to match hand size
if (heldFront) {
timeline.add({
targets: heldFront,
fontSize: this.cardFontSize(handRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('unifiedSwap', timeline);
} catch (e) {
console.error('Unified swap animation error:', e);
travelingHand.remove();
travelingHeld.remove();
if (onComplete) onComplete();
}
}
// Animate held card (drawn from deck) to discard pile
animateHeldToDiscard(cardData, heldRect, onComplete) {
const discardRect = this.getDiscardRect();
if (!heldRect || !discardRect) {
if (onComplete) onComplete();
return;
}
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create a traveling card showing the face at the held card's actual position
const travelingCard = this.createCardFromData(cardData, heldRect, 0);
travelingCard.dataset.animating = 'true';
document.body.appendChild(travelingCard);
this.playSound('card');
try {
// Arc peak slightly above both positions
const arcUp = Math.min(heldRect.top, discardRect.top) - 30;
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingCard.remove();
this.activeAnimations.delete('heldToDiscard');
if (onComplete) onComplete();
}
});
// Lift
timeline.add({
targets: travelingCard,
translateY: -8,
scale: 1.02,
duration: T.lift,
easing: this.getEasing('lift')
});
// Arc to discard
timeline.add({
targets: travelingCard,
left: discardRect.left,
top: [
{ value: arcUp, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
width: discardRect.width,
height: discardRect.height,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('heldToDiscard', timeline);
} catch (e) {
console.error('Held to discard animation error:', e);
travelingCard.remove();
if (onComplete) onComplete();
}
}
// Animate opponent/CPU discarding from holding position (hold → discard)
// The draw animation already handled deck → hold, so this just completes the motion
animateOpponentDiscard(cardData, onComplete) {
const holdingRect = this.getHoldingRect();
const discardRect = this.getDiscardRect();
if (!holdingRect || !discardRect) {
if (onComplete) onComplete();
return;
}
// Wait for any in-progress draw animation to complete
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Now run the discard animation
this._runOpponentDiscard(cardData, holdingRect, discardRect, onComplete);
}, 100);
return;
}
this._runOpponentDiscard(cardData, holdingRect, discardRect, onComplete);
}
_runOpponentDiscard(cardData, holdingRect, discardRect, onComplete) {
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create card at holding position, face-up (already revealed by draw animation)
const travelingCard = this.createAnimCard(holdingRect, false);
travelingCard.dataset.animating = 'true'; // Mark as actively animating
this.setCardContent(travelingCard, cardData);
this.playSound('card');
try {
// Arc peak slightly above both positions
const arcUp = Math.min(holdingRect.top, discardRect.top) - 30;
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingCard.remove();
this.activeAnimations.delete('opponentDiscard');
if (onComplete) onComplete();
}
});
// Lift
timeline.add({
targets: travelingCard,
translateY: -8,
scale: 1.02,
duration: T.lift,
easing: this.getEasing('lift')
});
// Arc to discard
timeline.add({
targets: travelingCard,
left: discardRect.left,
top: [
{ value: arcUp, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
width: discardRect.width,
height: discardRect.height,
translateY: 0,
scale: 1,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
});
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('opponentDiscard', timeline);
} catch (e) {
console.error('Opponent discard animation error:', e);
travelingCard.remove();
if (onComplete) onComplete();
}
}
createCardFromData(cardData, rect, rotation = 0) {
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>
`;
// Apply deck color to back
const deckColor = this.getDeckColor();
if (deckColor) {
const back = card.querySelector('.draw-anim-back');
back.classList.add(`back-${deckColor}`);
}
// Set front content
this.setCardContent(card, cardData);
// Position and size
card.style.left = rect.left + 'px';
card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
if (rotation) {
card.style.transform = `rotate(${rotation}deg)`;
}
return card;
}
createTravelingCard(sourceEl) {
const clone = sourceEl.cloneNode(true);
// Preserve original classes and add traveling-card
clone.classList.add('traveling-card');
// Remove classes that interfere with animation
clone.classList.remove('hidden', 'your-turn-pulse', 'held-card-floating', 'swap-out');
clone.removeAttribute('id');
// Override positioning for animation
clone.style.position = 'fixed';
clone.style.pointerEvents = 'none';
clone.style.zIndex = '1000';
clone.style.transform = 'none';
clone.style.transformOrigin = 'center center';
clone.style.borderRadius = '6px';
clone.style.overflow = 'hidden';
return clone;
}
positionAt(element, rect) {
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
}
// Pop-in effect when card appears
popIn(element) {
if (!element) return;
try {
anime({
targets: element,
opacity: [0, 1],
duration: 200,
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);
}
// === DEALING ANIMATION ===
async animateDealing(gameState, getPlayerRect, onComplete) {
const T = window.TIMING?.dealing || {};
const shufflePause = T.shufflePause || 400;
const cardFlyTime = T.cardFlyTime || 150;
const cardStagger = T.cardStagger || 80;
const roundPause = T.roundPause || 50;
const discardFlipDelay = T.discardFlipDelay || 200;
// Get deck position as the source for dealt cards
// Cards are dealt from the deck, not from the dealer's position
const deckRect = this.getDeckRect();
if (!deckRect) {
if (onComplete) onComplete();
return;
}
// Get player order starting from dealer's left
const dealerIdx = gameState.dealer_idx || 0;
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
// Create container for animation cards
const container = document.createElement('div');
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);
// Shuffle pause
await this._delay(shufflePause);
// Deal 6 rounds of cards
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
for (const player of playerOrder) {
const targetRect = getPlayerRect(player.id, cardIdx);
if (!targetRect) continue;
// Use individual card's deck color if available
const playerCards = player.cards || [];
const cardData = playerCards[cardIdx];
const deckColors = gameState.deck_colors || window.currentDeckColors || ['red', 'blue', 'gold'];
const deckColor = cardData && cardData.deck_id !== undefined
? deckColors[cardData.deck_id] || deckColors[0]
: this.getDeckColor();
const card = this.createAnimCard(deckRect, true, deckColor);
container.appendChild(card);
// Move card from deck to target via anime.js
// Animate position and size (deck cards are larger than player cards)
try {
anime({
targets: card,
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
height: targetRect.height,
duration: cardFlyTime,
easing: this.getEasing('move'),
});
} catch (e) {
console.error('Deal animation error:', e);
}
this.playSound('card');
await this._delay(cardStagger);
}
if (cardIdx < 5) {
await this._delay(roundPause);
}
}
// Wait for last cards to land
await this._delay(cardFlyTime);
// Flip discard
if (gameState.discard_top) {
await this._delay(discardFlipDelay);
this.playSound('flip');
}
// Clean up
container.remove();
if (onComplete) onComplete();
}
getDealOrder(players, dealerIdx) {
const order = [...players];
const startIdx = (dealerIdx + 1) % order.length;
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
}
_delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// === PAIR CELEBRATION ===
celebratePair(cardElement1, cardElement2) {
this.playSound('pair');
const duration = window.TIMING?.celebration?.pairDuration || 400;
[cardElement1, cardElement2].forEach(el => {
if (!el) return;
el.style.zIndex = '10';
try {
anime({
targets: el,
boxShadow: [
'0 0 0 0 rgba(255, 215, 0, 0)',
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
'0 0 0 0 rgba(255, 215, 0, 0)'
],
scale: [1, 1.05, 1],
duration: duration,
easing: 'easeOutQuad',
complete: () => {
el.style.zIndex = '';
}
});
} catch (e) {
console.error('Pair celebration error:', e);
el.style.zIndex = '';
}
});
}
// === CARD HOVER EFFECTS ===
// Animate card hover in (called on mouseenter)
hoverIn(element, isSwappable = false) {
if (!element || element.dataset.hoverAnimating === 'true') return;
element.dataset.hoverAnimating = 'true';
try {
anime.remove(element); // Cancel any existing animation
if (isSwappable) {
// Swappable card - lift and scale
anime({
targets: element,
translateY: -5,
scale: 1.02,
duration: 150,
easing: 'easeOutQuad',
complete: () => {
element.dataset.hoverAnimating = 'false';
}
});
} else {
// Regular card - just scale
anime({
targets: element,
scale: 1.05,
duration: 150,
easing: 'easeOutQuad',
complete: () => {
element.dataset.hoverAnimating = 'false';
}
});
}
} catch (e) {
console.error('Hover in error:', e);
element.dataset.hoverAnimating = 'false';
}
}
// Animate card hover out (called on mouseleave)
hoverOut(element) {
if (!element) return;
element.dataset.hoverAnimating = 'true';
try {
anime.remove(element); // Cancel any existing animation
anime({
targets: element,
translateY: 0,
scale: 1,
duration: 150,
easing: 'easeOutQuad',
complete: () => {
element.dataset.hoverAnimating = 'false';
element.style.transform = ''; // Clean up inline styles
}
});
} catch (e) {
console.error('Hover out error:', e);
element.dataset.hoverAnimating = 'false';
element.style.transform = '';
}
}
// Initialize hover listeners on card elements
initHoverListeners(container = document) {
const cards = container.querySelectorAll('.card');
cards.forEach(card => {
// Skip if already initialized
if (card.dataset.hoverInitialized) return;
card.dataset.hoverInitialized = 'true';
card.addEventListener('mouseenter', () => {
// Check if card is in a swappable context
const isSwappable = card.closest('.player-area.can-swap') !== null;
this.hoverIn(card, isSwappable);
});
card.addEventListener('mouseleave', () => {
this.hoverOut(card);
});
});
}
// === 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;
// Initialize hover listeners when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.cardAnimations.initHoverListeners();
});
} else {
window.cardAnimations.initHoverListeners();
}