Fix animation race conditions and improve UI feedback
- Fix discard pile "do-si-do" race condition when CPU draws from discard - Add isDrawAnimating flag for opponent draw animations - Skip STEP 2 (discard detection) when draw from discard detected - Fix deal animation using wrong rect (was using whole player area) - Add player area highlight when it's their turn (green glow) - Clear opponent animation flags when your_turn message received - Hide discard pile during draw-from-discard animation - Add comprehensive debug logging for animation flags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4664aae8aa
commit
c615c8b433
1580
client/app.js
1580
client/app.js
File diff suppressed because it is too large
Load Diff
@ -20,6 +20,24 @@ class CardAnimations {
|
|||||||
return discard ? discard.getBoundingClientRect() : null;
|
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() {
|
getHoldingRect() {
|
||||||
const deckRect = this.getDeckRect();
|
const deckRect = this.getDeckRect();
|
||||||
const discardRect = this.getDiscardRect();
|
const discardRect = this.getDiscardRect();
|
||||||
@ -67,7 +85,14 @@ class CardAnimations {
|
|||||||
<div class="draw-anim-back card card-back"></div>
|
<div class="draw-anim-back card card-back"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(card);
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
|
||||||
// Apply deck color to back
|
// Apply deck color to back
|
||||||
if (deckColor) {
|
if (deckColor) {
|
||||||
@ -79,12 +104,8 @@ class CardAnimations {
|
|||||||
card.querySelector('.draw-anim-inner').style.transform = 'rotateY(180deg)';
|
card.querySelector('.draw-anim-inner').style.transform = 'rotateY(180deg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rect) {
|
// Now append to body after all styles are set
|
||||||
card.style.left = rect.left + 'px';
|
document.body.appendChild(card);
|
||||||
card.style.top = rect.top + 'px';
|
|
||||||
card.style.width = rect.width + 'px';
|
|
||||||
card.style.height = rect.height + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@ -116,7 +137,26 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
document.querySelectorAll('.draw-anim-card').forEach(el => el.remove());
|
// 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;
|
this.isAnimating = false;
|
||||||
if (this.cleanupTimeout) {
|
if (this.cleanupTimeout) {
|
||||||
clearTimeout(this.cleanupTimeout);
|
clearTimeout(this.cleanupTimeout);
|
||||||
@ -162,13 +202,14 @@ class CardAnimations {
|
|||||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||||
|
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||||
const inner = animCard.querySelector('.draw-anim-inner');
|
const inner = animCard.querySelector('.draw-anim-inner');
|
||||||
|
|
||||||
if (cardData) {
|
if (cardData) {
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playSound('card');
|
this.playSound('draw-deck');
|
||||||
|
|
||||||
// Failsafe cleanup
|
// Failsafe cleanup
|
||||||
this.cleanupTimeout = setTimeout(() => {
|
this.cleanupTimeout = setTimeout(() => {
|
||||||
@ -252,12 +293,20 @@ class CardAnimations {
|
|||||||
|
|
||||||
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
|
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
|
||||||
const animCard = this.createAnimCard(discardRect, false);
|
const animCard = this.createAnimCard(discardRect, false);
|
||||||
|
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
|
|
||||||
this.playSound('card');
|
// 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
|
// Failsafe cleanup
|
||||||
this.cleanupTimeout = setTimeout(() => {
|
this.cleanupTimeout = setTimeout(() => {
|
||||||
|
if (discardPile) discardPile.style.opacity = '';
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}, 600);
|
}, 600);
|
||||||
@ -266,6 +315,7 @@ class CardAnimations {
|
|||||||
const timeline = anime.timeline({
|
const timeline = anime.timeline({
|
||||||
easing: this.getEasing('move'),
|
easing: this.getEasing('move'),
|
||||||
complete: () => {
|
complete: () => {
|
||||||
|
if (discardPile) discardPile.style.opacity = '';
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}
|
}
|
||||||
@ -404,6 +454,25 @@ class CardAnimations {
|
|||||||
const inner = animCard.querySelector('.draw-anim-inner');
|
const inner = animCard.querySelector('.draw-anim-inner');
|
||||||
const duration = 245; // 30% faster flip
|
const duration = 245; // 30% faster flip
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
anime({
|
anime({
|
||||||
targets: inner,
|
targets: inner,
|
||||||
@ -411,15 +480,11 @@ class CardAnimations {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
easing: this.getEasing('flip'),
|
easing: this.getEasing('flip'),
|
||||||
begin: () => this.playSound('flip'),
|
begin: () => this.playSound('flip'),
|
||||||
complete: () => {
|
complete: restoreCard
|
||||||
animCard.remove();
|
|
||||||
cardElement.classList.remove('swap-out');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Opponent flip animation error:', e);
|
console.error('Opponent flip animation error:', e);
|
||||||
animCard.remove();
|
restoreCard();
|
||||||
cardElement.classList.remove('swap-out');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -741,6 +806,46 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Initial flip phase - clickable cards glow
|
||||||
startInitialFlipPulse(element) {
|
startInitialFlipPulse(element) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@ -831,6 +936,482 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: 80, arc: 280, settle: 60 };
|
||||||
|
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,
|
||||||
|
}, `-=${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,
|
||||||
|
}, `-=${T.arc + T.lift / 2}`);
|
||||||
|
|
||||||
|
// Settle
|
||||||
|
timeline.add({
|
||||||
|
targets: [travelingHand, travelingHeld],
|
||||||
|
translateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: T.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: 80, arc: 280, settle: 60 };
|
||||||
|
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');
|
||||||
|
anime({
|
||||||
|
targets: inner,
|
||||||
|
rotateY: 0,
|
||||||
|
duration: 245,
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
}, `-=${T.lift / 2}`);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}, `-=${T.arc + T.lift / 2}`);
|
||||||
|
|
||||||
|
// Settle
|
||||||
|
timeline.add({
|
||||||
|
targets: [travelingHand, travelingHeld],
|
||||||
|
translateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: T.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: 80, arc: 280, settle: 60 };
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}, `-=${T.lift / 2}`);
|
||||||
|
|
||||||
|
// Settle
|
||||||
|
timeline.add({
|
||||||
|
targets: travelingCard,
|
||||||
|
translateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: T.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: 80, arc: 280, settle: 60 };
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settle
|
||||||
|
timeline.add({
|
||||||
|
targets: travelingCard,
|
||||||
|
translateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: T.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';
|
||||||
|
|
||||||
|
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
|
// Pop-in effect when card appears
|
||||||
popIn(element) {
|
popIn(element) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@ -858,6 +1439,223 @@ class CardAnimations {
|
|||||||
}, 450);
|
}, 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 ===
|
// === HELPER METHODS ===
|
||||||
|
|
||||||
isBusy() {
|
isBusy() {
|
||||||
@ -874,3 +1672,12 @@ window.cardAnimations = new CardAnimations();
|
|||||||
|
|
||||||
// Backwards compatibility - point drawAnimations to the new system
|
// Backwards compatibility - point drawAnimations to the new system
|
||||||
window.drawAnimations = window.cardAnimations;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
517
client/style.css
517
client/style.css
@ -848,15 +848,12 @@ input::placeholder {
|
|||||||
font-size: clamp(2rem, 2.5vw, 3.2rem);
|
font-size: clamp(2rem, 2.5vw, 3.2rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
/* No CSS transition - hover effects handled by anime.js */
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
/* Hover state set by anime.js - do not add CSS hover transform here */
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-back {
|
.card-back {
|
||||||
/* Bee-style diamond grid pattern - default red with white crosshatch */
|
/* Bee-style diamond grid pattern - default red with white crosshatch */
|
||||||
@ -1000,7 +997,7 @@ input::placeholder {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: clamp(12px, 1.8vw, 35px);
|
gap: clamp(12px, 1.8vw, 35px);
|
||||||
min-height: clamp(120px, 14vw, 200px);
|
min-height: clamp(120px, 14vw, 200px);
|
||||||
padding: 0 20px;
|
padding: 8px 20px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1522,6 +1519,7 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-area {
|
.player-area {
|
||||||
|
position: relative;
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
@ -1530,6 +1528,7 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Opponent Areas */
|
/* Opponent Areas */
|
||||||
.opponent-area {
|
.opponent-area {
|
||||||
|
position: relative;
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: clamp(4px, 0.5vw, 10px) clamp(6px, 0.7vw, 14px) clamp(6px, 0.7vw, 14px);
|
padding: clamp(4px, 0.5vw, 10px) clamp(6px, 0.7vw, 14px) clamp(6px, 0.7vw, 14px);
|
||||||
@ -1591,6 +1590,12 @@ input::placeholder {
|
|||||||
box-shadow: 0 0 0 2px #f4a460;
|
box-shadow: 0 0 0 2px #f4a460;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Local player turn highlight - green tint to match "your turn" status */
|
||||||
|
.player-area.current-turn {
|
||||||
|
background: rgba(181, 212, 132, 0.25);
|
||||||
|
box-shadow: 0 0 0 2px #9ab973;
|
||||||
|
}
|
||||||
|
|
||||||
/* Round winner highlight */
|
/* Round winner highlight */
|
||||||
.opponent-area.round-winner h4,
|
.opponent-area.round-winner h4,
|
||||||
.player-area.round-winner h4 {
|
.player-area.round-winner h4 {
|
||||||
@ -1619,34 +1624,48 @@ input::placeholder {
|
|||||||
color: #2d3436;
|
color: #2d3436;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opponent turn status - subtle purple/slate for other player's turn */
|
||||||
|
.status-message.opponent-turn {
|
||||||
|
background: linear-gradient(135deg, #8b7eb8 0%, #6b5b95 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* CPU action status - subtle blue to indicate CPU is doing something */
|
/* CPU action status - subtle blue to indicate CPU is doing something */
|
||||||
.status-message.cpu-action {
|
.status-message.cpu-action {
|
||||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Final turn badge - separate indicator */
|
/* Final turn badge - enhanced V3 with countdown */
|
||||||
.final-turn-badge {
|
.final-turn-badge {
|
||||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
animation: pulse-subtle 2s ease-in-out infinite;
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
|
||||||
|
animation: final-turn-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-turn-badge .final-turn-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.final-turn-badge.hidden {
|
.final-turn-badge.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-subtle {
|
@keyframes final-turn-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
|
box-shadow: 0 2px 12px rgba(214, 48, 49, 0.4);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0);
|
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4153,3 +4172,473 @@ input::placeholder {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
V3 FEATURES
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- V3_01: Dealer Indicator --- */
|
||||||
|
.dealer-chip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
left: -10px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: linear-gradient(145deg, #ffc078 0%, #f4a460 40%, #d4884a 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 2px 4px rgba(255, 255, 255, 0.4),
|
||||||
|
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_03: Round End Reveal --- */
|
||||||
|
.reveal-prompt {
|
||||||
|
position: fixed;
|
||||||
|
top: 20%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: prompt-entrance 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt.fading {
|
||||||
|
animation: prompt-fade 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prompt-entrance {
|
||||||
|
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||||||
|
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prompt-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt-text {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt-countdown {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards clickable during voluntary reveal */
|
||||||
|
.player-area.voluntary-flip .card.can-flip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player area highlight during reveal */
|
||||||
|
.player-area.revealing,
|
||||||
|
.opponent-area.revealing {
|
||||||
|
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.3);
|
||||||
|
transition: box-shadow 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_05: Final Turn Urgency --- */
|
||||||
|
.final-turn-icon {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-turn-remaining {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game area border pulse during final turn */
|
||||||
|
#game-screen.final-turn-active {
|
||||||
|
animation: game-area-urgency 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes game-area-urgency {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knocker highlight */
|
||||||
|
.player-area.is-knocker,
|
||||||
|
.opponent-area.is-knocker {
|
||||||
|
border: 2px solid #ff6b35;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knocker-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
background: #ff6b35;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_08: Card Hover Selection Preview --- */
|
||||||
|
.player-area.can-swap .card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swap card hover state - anime.js handles transform, CSS handles box-shadow only */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.player-area.can-swap .card:hover {
|
||||||
|
/* Transform handled by anime.js cardHover methods */
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-area.can-swap .card.card-back:hover {
|
||||||
|
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_09: Knock Early Drama --- */
|
||||||
|
.knock-confirm-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
animation: modal-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-fade-in {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 320px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: modal-scale-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-scale-in {
|
||||||
|
0% { transform: scale(0.9); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-icon {
|
||||||
|
font-size: 3em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content h3 {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-warning {
|
||||||
|
color: #e74c3c !important;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-buttons .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 400;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: knock-banner-in 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner span {
|
||||||
|
display: block;
|
||||||
|
font-size: 4em;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ffe082;
|
||||||
|
background: rgba(20, 20, 36, 0.95);
|
||||||
|
padding: 20px 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 3px solid rgba(255, 215, 0, 0.5);
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-banner-in {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner span {
|
||||||
|
animation: knock-text-in 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-text-in {
|
||||||
|
0% { transform: scale(0); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner.fading {
|
||||||
|
animation: knock-banner-out 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-banner-out {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes screen-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-3px); }
|
||||||
|
40% { transform: translateX(3px); }
|
||||||
|
60% { transform: translateX(-2px); }
|
||||||
|
80% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
body.screen-shake {
|
||||||
|
animation: screen-shake 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-knock-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 30%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: prompt-entrance 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-knock-banner.fading {
|
||||||
|
animation: prompt-fade 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_07: Score Tallying Animation --- */
|
||||||
|
.card-value-overlay {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -50%) scale(0.5);
|
||||||
|
background: rgba(20, 20, 36, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.15s ease-out, opacity 0.12s ease-out;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.card-value-overlay.visible {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.card-value-overlay.negative {
|
||||||
|
background: linear-gradient(135deg, #1b944f 0%, #166b3a 100%);
|
||||||
|
border-color: rgba(39, 174, 96, 0.5);
|
||||||
|
}
|
||||||
|
.card-value-overlay.zero {
|
||||||
|
background: linear-gradient(135deg, #8b6914 0%, #6b5010 100%);
|
||||||
|
border-color: rgba(180, 140, 40, 0.5);
|
||||||
|
color: #f5e6b8;
|
||||||
|
}
|
||||||
|
.card.tallying {
|
||||||
|
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6) !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
/* No CSS transition - tallying effect handled by JS */
|
||||||
|
}
|
||||||
|
.pair-cancel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffe082;
|
||||||
|
background: rgba(20, 20, 36, 0.92);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid rgba(255, 215, 0, 0.4);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: pair-cancel 0.5s ease-out forwards;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes pair-cancel {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0; }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_10: Column Pair Indicator --- */
|
||||||
|
.card.paired {
|
||||||
|
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
|
||||||
|
}
|
||||||
|
.card.pair-top {
|
||||||
|
border-bottom: 2px solid rgba(244, 164, 96, 0.5);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
.card.pair-bottom {
|
||||||
|
border-top: 2px solid rgba(244, 164, 96, 0.5);
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
.opponent-area .card.paired {
|
||||||
|
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
|
||||||
|
}
|
||||||
|
.opponent-area .card.pair-top {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
.opponent-area .card.pair-bottom {
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_06: Opponent Thinking Indicator --- */
|
||||||
|
.thinking-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.thinking-indicator.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_15: Discard Pile History Depth --- */
|
||||||
|
#discard[data-depth="2"] {
|
||||||
|
box-shadow:
|
||||||
|
2px 2px 0 0 rgba(255, 255, 255, 0.08),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
#discard[data-depth="3"] {
|
||||||
|
box-shadow:
|
||||||
|
2px 2px 0 0 rgba(255, 255, 255, 0.08),
|
||||||
|
4px 4px 0 0 rgba(255, 255, 255, 0.04),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_14: Active Rules Context --- */
|
||||||
|
.rule-tag.rule-highlighted {
|
||||||
|
background: rgba(244, 164, 96, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
|
||||||
|
animation: rule-pulse 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes rule-pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
.rule-message {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
font-weight: bold;
|
||||||
|
color: #f4a460;
|
||||||
|
animation: rule-message-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes rule-message-in {
|
||||||
|
0% { opacity: 0; transform: translateX(-5px); }
|
||||||
|
100% { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_13: Card Value Tooltips --- */
|
||||||
|
.card-value-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.card-value-tooltip.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.card-value-tooltip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-bottom-color: rgba(26, 26, 46, 0.95);
|
||||||
|
}
|
||||||
|
.tooltip-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.tooltip-value.negative {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
.tooltip-note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- V3_11: Swap Animation --- */
|
||||||
|
.traveling-card {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|||||||
@ -64,6 +64,55 @@ const TIMING = {
|
|||||||
moveDuration: 400, // Card move animation
|
moveDuration: 400, // Card move animation
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// V3_02: Dealing animation
|
||||||
|
dealing: {
|
||||||
|
shufflePause: 400, // Pause after shuffle sound
|
||||||
|
cardFlyTime: 150, // Time for card to fly to destination
|
||||||
|
cardStagger: 80, // Delay between cards
|
||||||
|
roundPause: 50, // Pause between deal rounds
|
||||||
|
discardFlipDelay: 200, // Pause before flipping discard
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_03: Round end reveal timing
|
||||||
|
reveal: {
|
||||||
|
voluntaryWindow: 4000, // Time for players to flip their own cards
|
||||||
|
initialPause: 500, // Pause before auto-reveals start
|
||||||
|
cardStagger: 100, // Between cards in same hand
|
||||||
|
playerPause: 400, // Pause after each player's reveal
|
||||||
|
highlightDuration: 200, // Player area highlight fade-in
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_04: Pair celebration
|
||||||
|
celebration: {
|
||||||
|
pairDuration: 400, // Celebration animation length
|
||||||
|
pairDelay: 50, // Slight delay before celebration
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_07: Score tallying animation
|
||||||
|
tally: {
|
||||||
|
initialPause: 200, // After reveal, before tally
|
||||||
|
cardHighlight: 140, // Duration to show each card value
|
||||||
|
columnPause: 100, // Between columns
|
||||||
|
pairCelebration: 300, // Pair cancel effect
|
||||||
|
playerPause: 350, // Between players
|
||||||
|
finalScoreReveal: 400, // Final score animation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Opponent initial flip stagger (after dealing)
|
||||||
|
// All players flip concurrently within this window (not taking turns)
|
||||||
|
initialFlips: {
|
||||||
|
windowStart: 500, // Minimum delay before any opponent starts flipping
|
||||||
|
windowEnd: 2500, // Maximum delay before opponent starts (random in range)
|
||||||
|
cardStagger: 400, // Delay between an opponent's two card flips
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_11: Physical swap animation
|
||||||
|
swap: {
|
||||||
|
lift: 80, // Time to lift cards
|
||||||
|
arc: 280, // Time for arc travel
|
||||||
|
settle: 60, // Time to settle into place
|
||||||
|
},
|
||||||
|
|
||||||
// Player swap animation steps - smooth continuous motion
|
// Player swap animation steps - smooth continuous motion
|
||||||
playerSwap: {
|
playerSwap: {
|
||||||
flipToReveal: 400, // Initial flip to show card
|
flipToReveal: 400, // Initial flip to show card
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user