Timing and animation changes for a more natural feeling game with CPU opps.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6950769bc3
commit
7b64b8c17c
@ -11,16 +11,23 @@ class AnimationQueue {
|
|||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.animationInProgress = false;
|
this.animationInProgress = false;
|
||||||
|
|
||||||
// Timing configuration (ms)
|
// Timing configuration (ms) - use centralized TIMING config
|
||||||
// Rhythm: action → settle → action → breathe
|
const T = window.TIMING || {};
|
||||||
this.timing = {
|
this.timing = {
|
||||||
flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
|
flipDuration: T.card?.flip || 540,
|
||||||
moveDuration: 270,
|
moveDuration: T.card?.move || 270,
|
||||||
pauseAfterFlip: 144, // Brief settle after flip before move
|
cardLift: T.card?.lift || 100,
|
||||||
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
|
pauseAfterFlip: T.pause?.afterFlip || 144,
|
||||||
pauseBeforeNewCard: 150, // Anticipation before new card moves in
|
pauseAfterDiscard: T.pause?.afterDiscard || 550,
|
||||||
pauseAfterSwapComplete: 400, // Breathing room after swap completes
|
pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
|
||||||
pauseBetweenAnimations: 90
|
pauseAfterSwapComplete: T.pause?.afterSwapComplete || 400,
|
||||||
|
pauseBetweenAnimations: T.pause?.betweenAnimations || 90,
|
||||||
|
pauseBeforeFlip: T.pause?.beforeFlip || 50,
|
||||||
|
// Beat timing
|
||||||
|
beatBase: T.beat?.base || 1000,
|
||||||
|
beatVariance: T.beat?.variance || 200,
|
||||||
|
fadeOut: T.beat?.fadeOut || 300,
|
||||||
|
fadeIn: T.beat?.fadeIn || 300,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +131,7 @@ class AnimationQueue {
|
|||||||
|
|
||||||
// Animate the flip
|
// Animate the flip
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
await this.delay(50); // Brief pause before flip
|
await this.delay(this.timing.pauseBeforeFlip);
|
||||||
|
|
||||||
// Remove flipped to trigger animation to front
|
// Remove flipped to trigger animation to front
|
||||||
inner.classList.remove('flipped');
|
inner.classList.remove('flipped');
|
||||||
@ -136,11 +143,10 @@ class AnimationQueue {
|
|||||||
animCard.remove();
|
animCard.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate a card swap (hand card to discard, drawn card to hand)
|
// Animate a card swap - smooth continuous motion
|
||||||
async animateSwap(movement) {
|
async animateSwap(movement) {
|
||||||
const { playerId, position, oldCard, newCard } = movement;
|
const { playerId, position, oldCard, newCard } = movement;
|
||||||
|
|
||||||
// Get positions
|
|
||||||
const slotRect = this.getSlotRect(playerId, position);
|
const slotRect = this.getSlotRect(playerId, position);
|
||||||
const discardRect = this.getLocationRect('discard');
|
const discardRect = this.getLocationRect('discard');
|
||||||
const holdingRect = this.getLocationRect('holding');
|
const holdingRect = this.getLocationRect('holding');
|
||||||
@ -149,67 +155,54 @@ class AnimationQueue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary card element for the animation
|
// Create animation cards
|
||||||
const animCard = this.createAnimCard();
|
const handCard = this.createAnimCard();
|
||||||
this.cardManager.cardLayer.appendChild(animCard);
|
this.cardManager.cardLayer.appendChild(handCard);
|
||||||
|
this.setCardPosition(handCard, slotRect);
|
||||||
|
|
||||||
// Position at slot
|
const handInner = handCard.querySelector('.card-inner');
|
||||||
this.setCardPosition(animCard, slotRect);
|
const handFront = handCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
// Start face down (showing back)
|
const heldCard = this.createAnimCard();
|
||||||
const inner = animCard.querySelector('.card-inner');
|
this.cardManager.cardLayer.appendChild(heldCard);
|
||||||
const front = animCard.querySelector('.card-face-front');
|
this.setCardPosition(heldCard, holdingRect || discardRect);
|
||||||
inner.classList.add('flipped');
|
|
||||||
|
|
||||||
// Step 1: If card was face down, flip to reveal it
|
const heldInner = heldCard.querySelector('.card-inner');
|
||||||
this.setCardFront(front, oldCard);
|
const heldFront = heldCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Set up initial state
|
||||||
|
this.setCardFront(handFront, oldCard);
|
||||||
|
if (!oldCard.face_up) {
|
||||||
|
handInner.classList.add('flipped');
|
||||||
|
}
|
||||||
|
this.setCardFront(heldFront, newCard);
|
||||||
|
heldInner.classList.remove('flipped');
|
||||||
|
|
||||||
|
// Step 1: If face-down, flip to reveal
|
||||||
if (!oldCard.face_up) {
|
if (!oldCard.face_up) {
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
inner.classList.remove('flipped');
|
handInner.classList.remove('flipped');
|
||||||
await this.delay(this.timing.flipDuration);
|
await this.delay(this.timing.flipDuration);
|
||||||
await this.delay(this.timing.pauseAfterFlip);
|
|
||||||
} else {
|
|
||||||
// Already face up, just show it immediately
|
|
||||||
inner.classList.remove('flipped');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Move card to discard pile
|
// Step 2: Quick crossfade swap
|
||||||
|
handCard.classList.add('fade-out');
|
||||||
|
heldCard.classList.add('fade-out');
|
||||||
|
await this.delay(150);
|
||||||
|
|
||||||
|
this.setCardPosition(handCard, discardRect);
|
||||||
|
this.setCardPosition(heldCard, slotRect);
|
||||||
|
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
animCard.classList.add('moving');
|
handCard.classList.remove('fade-out');
|
||||||
this.setCardPosition(animCard, discardRect);
|
heldCard.classList.remove('fade-out');
|
||||||
await this.delay(this.timing.moveDuration);
|
handCard.classList.add('fade-in');
|
||||||
animCard.classList.remove('moving');
|
heldCard.classList.add('fade-in');
|
||||||
|
await this.delay(150);
|
||||||
|
|
||||||
// Let discard land and pulse settle
|
// Clean up
|
||||||
await this.delay(this.timing.pauseAfterDiscard);
|
handCard.remove();
|
||||||
|
heldCard.remove();
|
||||||
// Step 3: Create second card for the new card coming into hand
|
|
||||||
const newAnimCard = this.createAnimCard();
|
|
||||||
this.cardManager.cardLayer.appendChild(newAnimCard);
|
|
||||||
|
|
||||||
// New card starts at holding/discard position
|
|
||||||
this.setCardPosition(newAnimCard, holdingRect || discardRect);
|
|
||||||
const newInner = newAnimCard.querySelector('.card-inner');
|
|
||||||
const newFront = newAnimCard.querySelector('.card-face-front');
|
|
||||||
|
|
||||||
// Show new card (it's face up from the drawn card)
|
|
||||||
this.setCardFront(newFront, newCard);
|
|
||||||
newInner.classList.remove('flipped');
|
|
||||||
|
|
||||||
// Brief anticipation before new card moves
|
|
||||||
await this.delay(this.timing.pauseBeforeNewCard);
|
|
||||||
|
|
||||||
// Step 4: Move new card to the hand slot
|
|
||||||
this.playSound('card');
|
|
||||||
newAnimCard.classList.add('moving');
|
|
||||||
this.setCardPosition(newAnimCard, slotRect);
|
|
||||||
await this.delay(this.timing.moveDuration);
|
|
||||||
newAnimCard.classList.remove('moving');
|
|
||||||
|
|
||||||
// Breathing room after swap completes
|
|
||||||
await this.delay(this.timing.pauseAfterSwapComplete);
|
|
||||||
animCard.remove();
|
|
||||||
newAnimCard.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary animation card element
|
// Create a temporary animation card element
|
||||||
@ -337,22 +330,47 @@ class AnimationQueue {
|
|||||||
animCard.remove();
|
animCard.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate drawing from discard
|
// Animate drawing from discard - show card lifting and moving to holding position
|
||||||
async animateDrawDiscard(movement) {
|
async animateDrawDiscard(movement) {
|
||||||
const { playerId } = movement;
|
const { card } = movement;
|
||||||
|
|
||||||
// Discard to holding is mostly visual feedback
|
|
||||||
// The card "lifts" slightly
|
|
||||||
|
|
||||||
const discardRect = this.getLocationRect('discard');
|
const discardRect = this.getLocationRect('discard');
|
||||||
const holdingRect = this.getLocationRect('holding');
|
const holdingRect = this.getLocationRect('holding');
|
||||||
|
|
||||||
if (!discardRect || !holdingRect) return;
|
if (!discardRect || !holdingRect) return;
|
||||||
|
|
||||||
// Just play sound - visual handled by CSS :holding state
|
// Create animation card at discard position (face UP - visible card)
|
||||||
this.playSound('card');
|
const animCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(animCard);
|
||||||
|
this.setCardPosition(animCard, discardRect);
|
||||||
|
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
const front = animCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Show the card face (discard is always visible)
|
||||||
|
if (card) {
|
||||||
|
this.setCardFront(front, card);
|
||||||
|
}
|
||||||
|
inner.classList.remove('flipped'); // Face up
|
||||||
|
|
||||||
|
// Lift effect before moving - card rises slightly
|
||||||
|
animCard.style.transform = 'translateY(-8px) scale(1.05)';
|
||||||
|
animCard.style.transition = `transform ${this.timing.cardLift}ms ease-out`;
|
||||||
|
await this.delay(this.timing.cardLift);
|
||||||
|
|
||||||
|
// Move to holding position
|
||||||
|
this.playSound('card');
|
||||||
|
animCard.classList.add('moving');
|
||||||
|
animCard.style.transform = '';
|
||||||
|
this.setCardPosition(animCard, holdingRect);
|
||||||
await this.delay(this.timing.moveDuration);
|
await this.delay(this.timing.moveDuration);
|
||||||
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
|
// Brief settle before state updates
|
||||||
|
await this.delay(this.timing.pauseBeforeNewCard);
|
||||||
|
|
||||||
|
// Clean up - renderGame will show the holding card state
|
||||||
|
animCard.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if animations are currently playing
|
// Check if animations are currently playing
|
||||||
|
|||||||
394
client/app.js
394
client/app.js
@ -28,6 +28,9 @@ class GolfGame {
|
|||||||
// Animation lock - prevent overlapping animations on same elements
|
// Animation lock - prevent overlapping animations on same elements
|
||||||
this.animatingPositions = new Set();
|
this.animatingPositions = new Set();
|
||||||
|
|
||||||
|
// Track opponent swap animation in progress (to apply swap-out class after render)
|
||||||
|
this.opponentSwapAnimation = null; // { playerId, position }
|
||||||
|
|
||||||
// Track round winners for visual highlight
|
// Track round winners for visual highlight
|
||||||
this.roundWinnerNames = new Set();
|
this.roundWinnerNames = new Set();
|
||||||
|
|
||||||
@ -417,6 +420,7 @@ class GolfGame {
|
|||||||
this.locallyFlippedCards = new Set();
|
this.locallyFlippedCards = new Set();
|
||||||
this.selectedCards = [];
|
this.selectedCards = [];
|
||||||
this.animatingPositions = new Set();
|
this.animatingPositions = new Set();
|
||||||
|
this.opponentSwapAnimation = null;
|
||||||
this.playSound('shuffle');
|
this.playSound('shuffle');
|
||||||
this.showGameScreen();
|
this.showGameScreen();
|
||||||
this.renderGame();
|
this.renderGame();
|
||||||
@ -790,7 +794,6 @@ class GolfGame {
|
|||||||
discardDrawn() {
|
discardDrawn() {
|
||||||
if (!this.drawnCard) return;
|
if (!this.drawnCard) return;
|
||||||
const discardedCard = this.drawnCard;
|
const discardedCard = this.drawnCard;
|
||||||
const wasFromDeck = !this.drawnFromDiscard;
|
|
||||||
this.send({ type: 'discard' });
|
this.send({ type: 'discard' });
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
@ -803,21 +806,8 @@ class GolfGame {
|
|||||||
// Also update lastDiscardKey so renderGame() won't see a "change"
|
// Also update lastDiscardKey so renderGame() won't see a "change"
|
||||||
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
|
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
|
||||||
|
|
||||||
if (wasFromDeck) {
|
// Swoop animation: deck → discard (card is always held over deck)
|
||||||
// Swoop animation: deck → discard (makes it clear the card is being tossed)
|
this.animateDeckToDiscardSwoop(discardedCard);
|
||||||
this.animateDeckToDiscardSwoop(discardedCard);
|
|
||||||
} else {
|
|
||||||
// Simple drop (drawn from discard, putting it back - though this requires swap usually)
|
|
||||||
this.heldCardFloating.classList.add('dropping');
|
|
||||||
this.playSound('card');
|
|
||||||
setTimeout(() => {
|
|
||||||
this.heldCardFloating.classList.add('hidden');
|
|
||||||
this.heldCardFloating.classList.remove('dropping');
|
|
||||||
this.updateDiscardPileDisplay(discardedCard);
|
|
||||||
this.pulseDiscardLand();
|
|
||||||
this.skipNextDiscardFlip = true;
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swoop animation for discarding a card drawn from deck
|
// Swoop animation for discarding a card drawn from deck
|
||||||
@ -885,10 +875,46 @@ class GolfGame {
|
|||||||
|
|
||||||
cancelDraw() {
|
cancelDraw() {
|
||||||
if (!this.drawnCard) return;
|
if (!this.drawnCard) return;
|
||||||
|
const cardToReturn = this.drawnCard;
|
||||||
|
const wasFromDiscard = this.drawnFromDiscard;
|
||||||
this.send({ type: 'cancel_draw' });
|
this.send({ type: 'cancel_draw' });
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
this.hideDrawnCard();
|
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
|
|
||||||
|
if (wasFromDiscard) {
|
||||||
|
// Animate card from deck position back to discard pile
|
||||||
|
this.animateDeckToDiscardReturn(cardToReturn);
|
||||||
|
} else {
|
||||||
|
this.hideDrawnCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate returning a card from deck position to discard pile (for cancel draw from discard)
|
||||||
|
animateDeckToDiscardReturn(card) {
|
||||||
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
const floater = this.heldCardFloating;
|
||||||
|
|
||||||
|
// Add swooping class for smooth transition
|
||||||
|
floater.classList.add('swooping');
|
||||||
|
floater.style.left = `${discardRect.left}px`;
|
||||||
|
floater.style.top = `${discardRect.top}px`;
|
||||||
|
floater.style.width = `${discardRect.width}px`;
|
||||||
|
floater.style.height = `${discardRect.height}px`;
|
||||||
|
|
||||||
|
this.playSound('card');
|
||||||
|
|
||||||
|
// After swoop completes, hide floater and update discard pile
|
||||||
|
setTimeout(() => {
|
||||||
|
floater.classList.add('landed');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
floater.classList.add('hidden');
|
||||||
|
floater.classList.remove('swooping', 'landed');
|
||||||
|
floater.style.cssText = '';
|
||||||
|
this.updateDiscardPileDisplay(card);
|
||||||
|
this.pulseDiscardLand();
|
||||||
|
}, 150);
|
||||||
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
swapCard(position) {
|
swapCard(position) {
|
||||||
@ -1124,9 +1150,19 @@ class GolfGame {
|
|||||||
|
|
||||||
// Pulse the appropriate pile
|
// Pulse the appropriate pile
|
||||||
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
|
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
|
||||||
|
|
||||||
|
// Show CPU action announcement for draw
|
||||||
|
if (oldPlayer.is_cpu) {
|
||||||
|
this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No old discard or couldn't detect - assume deck
|
// No old discard or couldn't detect - assume deck
|
||||||
this.pulseDrawPile('deck');
|
this.pulseDrawPile('deck');
|
||||||
|
|
||||||
|
// Show CPU action announcement
|
||||||
|
if (oldPlayer?.is_cpu) {
|
||||||
|
this.showCpuAction(oldPlayer.name, 'draw-deck');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1140,8 +1176,10 @@ class GolfGame {
|
|||||||
// Find the position that changed
|
// Find the position that changed
|
||||||
// Could be: face-down -> face-up (new reveal)
|
// Could be: face-down -> face-up (new reveal)
|
||||||
// Or: different card at same position (replaced visible card)
|
// Or: different card at same position (replaced visible card)
|
||||||
|
// Or: card identity became known (null -> value, indicates swap)
|
||||||
let swappedPosition = -1;
|
let swappedPosition = -1;
|
||||||
let wasFaceUp = false; // Track if old card was already face-up
|
let wasFaceUp = false; // Track if old card was already face-up
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const oldCard = oldPlayer.cards[i];
|
const oldCard = oldPlayer.cards[i];
|
||||||
const newCard = newPlayer.cards[i];
|
const newCard = newPlayer.cards[i];
|
||||||
@ -1162,47 +1200,167 @@ class GolfGame {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Case 3: Card identity became known (opponent's hidden card was swapped)
|
||||||
|
// This handles race conditions where face_up might not be updated yet
|
||||||
|
if (!oldCard?.rank && newCard?.rank) {
|
||||||
|
swappedPosition = i;
|
||||||
|
wasFaceUp = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if opponent's cards are completely unchanged (server might send split updates)
|
||||||
|
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
|
||||||
|
|
||||||
if (swappedPosition >= 0 && wasOtherPlayer) {
|
if (swappedPosition >= 0 && wasOtherPlayer) {
|
||||||
// Opponent swapped - animate from the actual position that changed
|
// Opponent swapped - animate from the actual position that changed
|
||||||
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
||||||
} else if (swappedPosition < 0) {
|
// Show CPU swap announcement
|
||||||
// Player drew and discarded without swapping - pulse for everyone
|
if (oldPlayer.is_cpu) {
|
||||||
this.fireDiscardAnimation(newDiscard);
|
this.showCpuAction(oldPlayer.name, 'swap');
|
||||||
|
}
|
||||||
|
} else if (swappedPosition < 0 && !cardsIdentical) {
|
||||||
|
// Player drew and discarded without swapping
|
||||||
|
// Only fire if cards actually differ (avoid race condition with split server updates)
|
||||||
|
// Show animation for other players, just pulse for local player
|
||||||
|
this.fireDiscardAnimation(newDiscard, previousPlayerId);
|
||||||
|
// Show CPU discard announcement
|
||||||
|
if (wasOtherPlayer && oldPlayer?.is_cpu) {
|
||||||
|
this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Skip the card-flip-in animation since we just did our own
|
// Skip the card-flip-in animation since we just did our own
|
||||||
this.skipNextDiscardFlip = true;
|
this.skipNextDiscardFlip = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We don't separately animate card flips for swaps anymore
|
// Handle delayed card updates (server sends split updates: discard first, then cards)
|
||||||
// The swap animation handles showing the card at the correct position
|
// Check if opponent cards changed even when discard didn't change
|
||||||
|
if (!discardChanged && wasOtherPlayer && previousPlayerId) {
|
||||||
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
||||||
|
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
||||||
|
|
||||||
|
if (oldPlayer && newPlayer) {
|
||||||
|
// Check for card changes that indicate a swap we missed
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const oldCard = oldPlayer.cards[i];
|
||||||
|
const newCard = newPlayer.cards[i];
|
||||||
|
|
||||||
|
// Card became visible (swap completed in delayed update)
|
||||||
|
if (!oldCard?.face_up && newCard?.face_up) {
|
||||||
|
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
|
||||||
|
if (oldPlayer.is_cpu) {
|
||||||
|
this.showCpuAction(oldPlayer.name, 'swap');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Card identity became known
|
||||||
|
if (!oldCard?.rank && newCard?.rank) {
|
||||||
|
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
|
||||||
|
if (oldPlayer.is_cpu) {
|
||||||
|
this.showCpuAction(oldPlayer.name, 'swap');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash animation on deck or discard pile to show where opponent drew from
|
// Flash animation on deck or discard pile to show where opponent drew from
|
||||||
pulseDrawPile(source) {
|
pulseDrawPile(source) {
|
||||||
|
const T = window.TIMING?.feedback || {};
|
||||||
const pile = source === 'discard' ? this.discard : this.deck;
|
const pile = source === 'discard' ? this.discard : this.deck;
|
||||||
pile.classList.remove('draw-pulse');
|
pile.classList.remove('draw-pulse');
|
||||||
// Trigger reflow to restart animation
|
// Trigger reflow to restart animation
|
||||||
void pile.offsetWidth;
|
void pile.offsetWidth;
|
||||||
pile.classList.add('draw-pulse');
|
pile.classList.add('draw-pulse');
|
||||||
// Remove class after animation completes
|
// Remove class after animation completes
|
||||||
setTimeout(() => pile.classList.remove('draw-pulse'), 450);
|
setTimeout(() => pile.classList.remove('draw-pulse'), T.drawPulse || 450);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pulse discard pile when a card lands on it
|
// Pulse discard pile when a card lands on it
|
||||||
pulseDiscardLand() {
|
pulseDiscardLand() {
|
||||||
|
const T = window.TIMING?.feedback || {};
|
||||||
this.discard.classList.remove('discard-land');
|
this.discard.classList.remove('discard-land');
|
||||||
void this.discard.offsetWidth;
|
void this.discard.offsetWidth;
|
||||||
this.discard.classList.add('discard-land');
|
this.discard.classList.add('discard-land');
|
||||||
setTimeout(() => this.discard.classList.remove('discard-land'), 460);
|
setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire animation for discard without swap (card lands on discard pile face-up)
|
// Fire animation for discard without swap (card lands on discard pile face-up)
|
||||||
fireDiscardAnimation(discardCard) {
|
// Shows card moving from deck to discard for other players only
|
||||||
// Card is already known - just pulse to show it landed (no flip needed)
|
fireDiscardAnimation(discardCard, fromPlayerId = null) {
|
||||||
this.pulseDiscardLand();
|
// Only show animation for other players - local player already knows what they did
|
||||||
|
const isOtherPlayer = fromPlayerId && fromPlayerId !== this.playerId;
|
||||||
|
|
||||||
|
if (isOtherPlayer && discardCard) {
|
||||||
|
// Show card traveling from deck to discard pile
|
||||||
|
this.animateDeckToDiscard(discardCard);
|
||||||
|
}
|
||||||
|
// Skip animation entirely for local player
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate a card moving from deck to discard pile (for draw-and-discard by other players)
|
||||||
|
animateDeckToDiscard(card) {
|
||||||
|
const deckRect = this.deck.getBoundingClientRect();
|
||||||
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Create temporary card element
|
||||||
|
const animCard = document.createElement('div');
|
||||||
|
animCard.className = 'real-card anim-card';
|
||||||
|
animCard.innerHTML = `
|
||||||
|
<div class="card-inner">
|
||||||
|
<div class="card-face card-face-front"></div>
|
||||||
|
<div class="card-face card-face-back"><span>?</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position at deck
|
||||||
|
animCard.style.position = 'fixed';
|
||||||
|
animCard.style.left = `${deckRect.left}px`;
|
||||||
|
animCard.style.top = `${deckRect.top}px`;
|
||||||
|
animCard.style.width = `${deckRect.width}px`;
|
||||||
|
animCard.style.height = `${deckRect.height}px`;
|
||||||
|
animCard.style.zIndex = '1000';
|
||||||
|
animCard.style.transition = `left ${window.TIMING?.card?.move || 270}ms ease-out, top ${window.TIMING?.card?.move || 270}ms ease-out`;
|
||||||
|
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
const front = animCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Start face-down (back showing)
|
||||||
|
inner.classList.add('flipped');
|
||||||
|
|
||||||
|
// Set up the front face content for the flip
|
||||||
|
front.className = 'card-face card-face-front';
|
||||||
|
if (card.rank === '★') {
|
||||||
|
front.classList.add('joker');
|
||||||
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
front.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
front.classList.add(this.isRedSuit(card.suit) ? 'red' : 'black');
|
||||||
|
front.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(animCard);
|
||||||
|
|
||||||
|
// Small delay then start moving and flipping
|
||||||
|
setTimeout(() => {
|
||||||
|
this.playSound('card');
|
||||||
|
// Move to discard position
|
||||||
|
animCard.style.left = `${discardRect.left}px`;
|
||||||
|
animCard.style.top = `${discardRect.top}px`;
|
||||||
|
// Flip to show face
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// Clean up after animation
|
||||||
|
const moveDuration = window.TIMING?.card?.move || 270;
|
||||||
|
const pauseAfter = window.TIMING?.pause?.afterDiscard || 550;
|
||||||
|
setTimeout(() => {
|
||||||
|
animCard.remove();
|
||||||
|
this.pulseDiscardLand();
|
||||||
|
}, moveDuration + pauseAfter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get rotation angle from an element's computed transform
|
// Get rotation angle from an element's computed transform
|
||||||
@ -1225,6 +1383,8 @@ class GolfGame {
|
|||||||
// Fire a swap animation (non-blocking) - flip in place at opponent's position
|
// Fire a swap animation (non-blocking) - flip in place at opponent's position
|
||||||
// Uses flip-in-place for face-down cards, subtle pulse for face-up cards
|
// Uses flip-in-place for face-down cards, subtle pulse for face-up cards
|
||||||
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
|
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
|
||||||
|
// Track this animation so renderGame can apply swap-out class
|
||||||
|
this.opponentSwapAnimation = { playerId, position };
|
||||||
|
|
||||||
// Find source position - the actual card that was swapped
|
// Find source position - the actual card that was swapped
|
||||||
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
||||||
@ -1251,9 +1411,10 @@ class GolfGame {
|
|||||||
if (wasFaceUp && sourceCardEl) {
|
if (wasFaceUp && sourceCardEl) {
|
||||||
sourceCardEl.classList.add('swap-pulse');
|
sourceCardEl.classList.add('swap-pulse');
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
|
const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sourceCardEl.classList.remove('swap-pulse');
|
sourceCardEl.classList.remove('swap-pulse');
|
||||||
}, 400);
|
}, pulseDuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1291,26 +1452,21 @@ class GolfGame {
|
|||||||
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
|
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
|
||||||
this.swapAnimation.classList.remove('hidden');
|
this.swapAnimation.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Use centralized timing - no beat pauses, continuous flow
|
||||||
|
const flipTime = window.TIMING?.card?.flip || 400;
|
||||||
|
|
||||||
// Step 1: Flip to reveal the hidden card
|
// Step 1: Flip to reveal the hidden card
|
||||||
setTimeout(() => {
|
swapCard.classList.add('flipping');
|
||||||
swapCard.classList.add('flipping');
|
this.playSound('flip');
|
||||||
this.playSound('flip');
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Step 2: After flip, pause to see the card then pulse before being replaced
|
// Step 2: After flip completes, clean up immediately
|
||||||
setTimeout(() => {
|
|
||||||
swapCard.classList.add('swap-pulse');
|
|
||||||
this.playSound('card');
|
|
||||||
}, 850);
|
|
||||||
|
|
||||||
// Step 3: Strategic pause to show discarded card, then complete
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
|
swapCard.classList.remove('flipping');
|
||||||
swapCard.style.transform = '';
|
swapCard.style.transform = '';
|
||||||
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
this.opponentSwapAnimation = null;
|
||||||
this.pulseDiscardLand();
|
this.pulseDiscardLand();
|
||||||
}, 1400);
|
}, flipTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire a flip animation for local player's card (non-blocking)
|
// Fire a flip animation for local player's card (non-blocking)
|
||||||
@ -1351,17 +1507,21 @@ class GolfGame {
|
|||||||
cardEl.classList.add('swap-out');
|
cardEl.classList.add('swap-out');
|
||||||
this.swapAnimation.classList.remove('hidden');
|
this.swapAnimation.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Use centralized timing
|
||||||
|
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
|
||||||
|
const flipDuration = window.TIMING?.card?.flip || 540;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
swapCard.classList.add('flipping');
|
swapCard.classList.add('flipping');
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
}, 50);
|
}, preFlip);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
swapCard.classList.remove('flipping');
|
swapCard.classList.remove('flipping');
|
||||||
cardEl.classList.remove('swap-out');
|
cardEl.classList.remove('swap-out');
|
||||||
this.animatingPositions.delete(key);
|
this.animatingPositions.delete(key);
|
||||||
}, 550);
|
}, preFlip + flipDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire a flip animation for opponent card (non-blocking)
|
// Fire a flip animation for opponent card (non-blocking)
|
||||||
@ -1420,10 +1580,14 @@ class GolfGame {
|
|||||||
cardEl.classList.add('swap-out');
|
cardEl.classList.add('swap-out');
|
||||||
this.swapAnimation.classList.remove('hidden');
|
this.swapAnimation.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Use centralized timing
|
||||||
|
const preFlip = window.TIMING?.pause?.beforeFlip || 50;
|
||||||
|
const flipDuration = window.TIMING?.card?.flip || 540;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
swapCard.classList.add('flipping');
|
swapCard.classList.add('flipping');
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
}, 60);
|
}, preFlip);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
@ -1431,7 +1595,7 @@ class GolfGame {
|
|||||||
swapCard.style.transform = '';
|
swapCard.style.transform = '';
|
||||||
cardEl.classList.remove('swap-out');
|
cardEl.classList.remove('swap-out');
|
||||||
this.animatingPositions.delete(key);
|
this.animatingPositions.delete(key);
|
||||||
}, 560);
|
}, preFlip + flipDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCardClick(position) {
|
handleCardClick(position) {
|
||||||
@ -1706,6 +1870,36 @@ class GolfGame {
|
|||||||
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
|
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show CPU action announcement in status bar
|
||||||
|
showCpuAction(playerName, action, card = null) {
|
||||||
|
const suitSymbol = card ? this.getSuitSymbol(card.suit) : '';
|
||||||
|
const messages = {
|
||||||
|
'draw-deck': `${playerName} draws from deck`,
|
||||||
|
'draw-discard': card ? `${playerName} takes ${card.rank}${suitSymbol}` : `${playerName} takes from discard`,
|
||||||
|
'swap': `${playerName} swaps a card`,
|
||||||
|
'discard': card ? `${playerName} discards ${card.rank}${suitSymbol}` : `${playerName} discards`,
|
||||||
|
};
|
||||||
|
const message = messages[action];
|
||||||
|
if (message) {
|
||||||
|
this.setStatus(message, 'cpu-action');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update CPU considering visual state on discard pile
|
||||||
|
updateCpuConsideringState() {
|
||||||
|
if (!this.gameState || !this.discard) return;
|
||||||
|
|
||||||
|
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
|
||||||
|
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
|
||||||
|
const hasNotDrawn = !this.gameState.has_drawn_card;
|
||||||
|
|
||||||
|
if (isCpuTurn && hasNotDrawn) {
|
||||||
|
this.discard.classList.add('cpu-considering');
|
||||||
|
} else {
|
||||||
|
this.discard.classList.remove('cpu-considering');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showToast(message, type = '', duration = 2500) {
|
showToast(message, type = '', duration = 2500) {
|
||||||
// For compatibility - just set the status message
|
// For compatibility - just set the status message
|
||||||
this.setStatus(message, type);
|
this.setStatus(message, type);
|
||||||
@ -1772,13 +1966,50 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showDrawnCard() {
|
showDrawnCard() {
|
||||||
// Show drawn card floating over the discard pile (larger, closer to viewer)
|
// Show drawn card floating over the draw pile (deck), regardless of source
|
||||||
const card = this.drawnCard;
|
const card = this.drawnCard;
|
||||||
|
this.displayHeldCard(card, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display held card floating above and between deck and discard - for any player
|
||||||
|
// isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow)
|
||||||
|
displayHeldCard(card, isLocalPlayerHolding) {
|
||||||
|
if (!card) {
|
||||||
|
this.hideDrawnCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Set up the floating held card display
|
// Set up the floating held card display
|
||||||
this.heldCardFloating.className = 'card card-front held-card-floating';
|
this.heldCardFloating.className = 'card card-front held-card-floating';
|
||||||
// Clear any inline styles left over from swoop animations
|
// Clear any inline styles left over from swoop animations
|
||||||
this.heldCardFloating.style.cssText = '';
|
this.heldCardFloating.style.cssText = '';
|
||||||
|
|
||||||
|
// Position centered above and between deck and discard
|
||||||
|
const deckRect = this.deck.getBoundingClientRect();
|
||||||
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate center point between deck and discard
|
||||||
|
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||||
|
const cardWidth = deckRect.width;
|
||||||
|
const cardHeight = deckRect.height;
|
||||||
|
|
||||||
|
// Position card centered, overlapping both piles (lower than before)
|
||||||
|
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
|
||||||
|
const cardLeft = centerX - cardWidth / 2;
|
||||||
|
const cardTop = deckRect.top - overlapOffset;
|
||||||
|
this.heldCardFloating.style.left = `${cardLeft}px`;
|
||||||
|
this.heldCardFloating.style.top = `${cardTop}px`;
|
||||||
|
this.heldCardFloating.style.width = `${cardWidth}px`;
|
||||||
|
this.heldCardFloating.style.height = `${cardHeight}px`;
|
||||||
|
|
||||||
|
// Position discard button attached to right side of held card
|
||||||
|
const scaledWidth = cardWidth * 1.15; // Account for scale transform
|
||||||
|
const scaledHeight = cardHeight * 1.15;
|
||||||
|
const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap)
|
||||||
|
const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card
|
||||||
|
this.discardBtn.style.left = `${buttonLeft}px`;
|
||||||
|
this.discardBtn.style.top = `${buttonTop}px`;
|
||||||
|
|
||||||
if (card.rank === '★') {
|
if (card.rank === '★') {
|
||||||
this.heldCardFloating.classList.add('joker');
|
this.heldCardFloating.classList.add('joker');
|
||||||
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
||||||
@ -1792,17 +2023,29 @@ class GolfGame {
|
|||||||
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the floating card and discard button
|
// Show the floating card
|
||||||
this.heldCardFloating.classList.remove('hidden');
|
this.heldCardFloating.classList.remove('hidden');
|
||||||
this.discardBtn.classList.remove('hidden');
|
|
||||||
|
// Add pulse glow if it's local player's turn to act on the card
|
||||||
|
if (isLocalPlayerHolding) {
|
||||||
|
this.heldCardFloating.classList.add('your-turn-pulse');
|
||||||
|
this.discardBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
this.heldCardFloating.classList.remove('your-turn-pulse');
|
||||||
|
this.discardBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hideDrawnCard() {
|
hideDrawnCard() {
|
||||||
// Hide the floating held card
|
// Hide the floating held card
|
||||||
this.heldCardFloating.classList.add('hidden');
|
this.heldCardFloating.classList.add('hidden');
|
||||||
|
this.heldCardFloating.classList.remove('your-turn-pulse');
|
||||||
// Clear any inline styles from animations
|
// Clear any inline styles from animations
|
||||||
this.heldCardFloating.style.cssText = '';
|
this.heldCardFloating.style.cssText = '';
|
||||||
this.discardBtn.classList.add('hidden');
|
this.discardBtn.classList.add('hidden');
|
||||||
|
// Clear button positioning
|
||||||
|
this.discardBtn.style.left = '';
|
||||||
|
this.discardBtn.style.top = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
isRedSuit(suit) {
|
isRedSuit(suit) {
|
||||||
@ -1869,6 +2112,9 @@ class GolfGame {
|
|||||||
renderGame() {
|
renderGame() {
|
||||||
if (!this.gameState) return;
|
if (!this.gameState) return;
|
||||||
|
|
||||||
|
// Update CPU considering visual state
|
||||||
|
this.updateCpuConsideringState();
|
||||||
|
|
||||||
// Update header
|
// Update header
|
||||||
this.currentRoundSpan.textContent = this.gameState.current_round;
|
this.currentRoundSpan.textContent = this.gameState.current_round;
|
||||||
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
|
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
|
||||||
@ -1912,8 +2158,10 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update discard pile
|
// Update discard pile
|
||||||
if (this.drawnCard) {
|
// Check if ANY player is holding a card (local or remote/CPU)
|
||||||
// Holding a drawn card - show discard pile as greyed/disabled
|
const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
|
||||||
|
if (anyPlayerHolding) {
|
||||||
|
// Someone is holding a drawn card - show discard pile as greyed/disabled
|
||||||
// If drawn from discard, show what's underneath (new discard_top or empty)
|
// If drawn from discard, show what's underneath (new discard_top or empty)
|
||||||
// If drawn from deck, show current discard_top greyed
|
// If drawn from deck, show current discard_top greyed
|
||||||
this.discard.classList.add('picked-up');
|
this.discard.classList.add('picked-up');
|
||||||
@ -1941,7 +2189,10 @@ class GolfGame {
|
|||||||
// Not holding - show normal discard pile
|
// Not holding - show normal discard pile
|
||||||
this.discard.classList.remove('picked-up');
|
this.discard.classList.remove('picked-up');
|
||||||
|
|
||||||
if (this.gameState.discard_top) {
|
// Skip discard update during opponent swap animation - animation handles the visual
|
||||||
|
if (this.opponentSwapAnimation) {
|
||||||
|
// Don't update discard content; animation overlay shows the swap
|
||||||
|
} else if (this.gameState.discard_top) {
|
||||||
const discardCard = this.gameState.discard_top;
|
const discardCard = this.gameState.discard_top;
|
||||||
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
||||||
|
|
||||||
@ -1973,7 +2224,8 @@ class GolfGame {
|
|||||||
this.discard.classList.remove('card-flip-in');
|
this.discard.classList.remove('card-flip-in');
|
||||||
void this.discard.offsetWidth; // Force reflow
|
void this.discard.offsetWidth; // Force reflow
|
||||||
this.discard.classList.add('card-flip-in');
|
this.discard.classList.add('card-flip-in');
|
||||||
setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
|
const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560;
|
||||||
|
setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
|
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
|
||||||
@ -1983,6 +2235,20 @@ class GolfGame {
|
|||||||
this.discardBtn.classList.add('hidden');
|
this.discardBtn.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show held card for ANY player who has drawn (consistent visual regardless of whose turn)
|
||||||
|
// Local player uses this.drawnCard, others use gameState.drawn_card
|
||||||
|
if (this.drawnCard) {
|
||||||
|
// Local player is holding - show with pulse and discard button
|
||||||
|
this.displayHeldCard(this.drawnCard, true);
|
||||||
|
} else if (this.gameState.drawn_card && this.gameState.drawn_player_id) {
|
||||||
|
// Another player is holding - show without pulse/button
|
||||||
|
const isLocalPlayer = this.gameState.drawn_player_id === this.playerId;
|
||||||
|
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
|
||||||
|
} else {
|
||||||
|
// No one holding a card
|
||||||
|
this.hideDrawnCard();
|
||||||
|
}
|
||||||
|
|
||||||
// Update deck/discard clickability and visual state
|
// Update deck/discard clickability and visual state
|
||||||
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
|
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
|
||||||
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
|
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
|
||||||
@ -1991,13 +2257,11 @@ class GolfGame {
|
|||||||
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
|
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
|
||||||
|
|
||||||
this.deck.classList.toggle('clickable', canDraw);
|
this.deck.classList.toggle('clickable', canDraw);
|
||||||
// Only show disabled on deck when it's my turn and I've drawn
|
// Show disabled on deck when any player has drawn (consistent dimmed look)
|
||||||
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
|
this.deck.classList.toggle('disabled', hasDrawn);
|
||||||
|
|
||||||
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
|
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
|
||||||
// Only show disabled state when it's my turn and I've drawn (not holding visible card)
|
// Disabled state handled by picked-up class when anyone is holding
|
||||||
// Don't grey out when opponents are playing
|
|
||||||
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
|
|
||||||
|
|
||||||
// Render opponents in a single row
|
// Render opponents in a single row
|
||||||
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
|
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
|
||||||
@ -2047,8 +2311,11 @@ class GolfGame {
|
|||||||
? { ...card, face_up: true }
|
? { ...card, face_up: true }
|
||||||
: card;
|
: card;
|
||||||
|
|
||||||
|
// Check if clickable during initial flip
|
||||||
|
const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
|
||||||
|
|
||||||
const isClickable = (
|
const isClickable = (
|
||||||
(this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
|
isInitialFlipClickable ||
|
||||||
(this.drawnCard) ||
|
(this.drawnCard) ||
|
||||||
(this.waitingForFlip && !card.face_up)
|
(this.waitingForFlip && !card.face_up)
|
||||||
);
|
);
|
||||||
@ -2056,6 +2323,12 @@ class GolfGame {
|
|||||||
|
|
||||||
const cardEl = document.createElement('div');
|
const cardEl = document.createElement('div');
|
||||||
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
|
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
|
||||||
|
|
||||||
|
// Add pulse animation during initial flip phase
|
||||||
|
if (isInitialFlipClickable) {
|
||||||
|
cardEl.firstChild.classList.add('initial-flip-pulse');
|
||||||
|
}
|
||||||
|
|
||||||
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
|
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
|
||||||
this.playerCards.appendChild(cardEl.firstChild);
|
this.playerCards.appendChild(cardEl.firstChild);
|
||||||
});
|
});
|
||||||
@ -2496,7 +2769,8 @@ class GolfGame {
|
|||||||
navigator.clipboard.writeText(shareText).then(() => {
|
navigator.clipboard.writeText(shareText).then(() => {
|
||||||
const btn = document.getElementById('share-results-btn');
|
const btn = document.getElementById('share-results-btn');
|
||||||
btn.textContent = '✓ Copied!';
|
btn.textContent = '✓ Copied!';
|
||||||
setTimeout(() => btn.textContent = '📋 Copy Results', 2000);
|
const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000;
|
||||||
|
setTimeout(() => btn.textContent = '📋 Copy Results', copyDelay);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
240
client/style.css
240
client/style.css
@ -1011,13 +1011,14 @@ input::placeholder {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Held card floating over discard pile (larger, closer to viewer) */
|
/* Held card floating above and between deck and discard (larger, closer to viewer) */
|
||||||
.held-card-floating {
|
.held-card-floating {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: scale(1.2) translateY(-12px);
|
transform: scale(1.15);
|
||||||
|
transform-origin: center bottom;
|
||||||
border: 3px solid #f4a460 !important;
|
border: 3px solid #f4a460 !important;
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -1026,33 +1027,26 @@ input::placeholder {
|
|||||||
|
|
||||||
.held-card-floating.hidden {
|
.held-card-floating.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(0.9);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animate floating card dropping to discard pile (when drawn from discard) */
|
/* Animate floating card dropping to discard pile (when drawn from discard) */
|
||||||
.held-card-floating.dropping {
|
.held-card-floating.dropping {
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
|
transition: border-color 0.3s ease-out, box-shadow 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Swoop animation for deck → immediate discard */
|
/* Swoop animation for deck → immediate discard */
|
||||||
.held-card-floating.swooping {
|
.held-card-floating.swooping {
|
||||||
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
transform 0.35s ease-out,
|
width 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
border-color 0.35s ease-out,
|
height 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
box-shadow 0.35s ease-out;
|
|
||||||
transform: scale(1.15) rotate(-8deg);
|
|
||||||
border-color: rgba(244, 164, 96, 0.8) !important;
|
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.held-card-floating.swooping.landed {
|
.held-card-floating.swooping.landed {
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
border-color: transparent !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1080,11 +1074,10 @@ input::placeholder {
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Picked-up state - showing card underneath after drawing from discard */
|
/* Picked-up state - dimmed when someone is holding a card */
|
||||||
#discard.picked-up {
|
#discard.picked-up {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
filter: grayscale(40%);
|
filter: grayscale(40%);
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.discard-stack {
|
.discard-stack {
|
||||||
@ -1092,13 +1085,40 @@ input::placeholder {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.discard-stack .btn {
|
.discard-stack .btn {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Discard button as a tab attached to right side of held card */
|
||||||
|
#discard-btn {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 101;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
padding: 16px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
background: linear-gradient(90deg, #e8914d 0%, #f4a460 100%);
|
||||||
|
color: #1a472a;
|
||||||
|
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#discard-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 3px 3px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#discard-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
#deck.disabled,
|
#deck.disabled,
|
||||||
#discard.disabled {
|
#discard.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@ -1141,53 +1161,65 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card flip animation for discard pile */
|
/* Card appearing on discard pile */
|
||||||
.card-flip-in {
|
.card-flip-in {
|
||||||
animation: cardFlipIn 0.56s ease-out;
|
animation: cardFlipIn 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cardFlipIn {
|
@keyframes cardFlipIn {
|
||||||
0% {
|
from { opacity: 0.5; }
|
||||||
transform: scale(1.4) translateY(-20px);
|
to { opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
box-shadow: 0 0 40px rgba(244, 164, 96, 1);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: scale(1.25) translateY(-10px);
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 35px rgba(244, 164, 96, 0.9);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(1.1) translateY(0);
|
|
||||||
box-shadow: 0 0 20px rgba(244, 164, 96, 0.5);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Discard pile pulse when card lands */
|
/* Discard pile pulse when card lands - simple glow */
|
||||||
#discard.discard-land {
|
#discard.discard-land {
|
||||||
animation: discardLand 0.46s ease-out;
|
animation: discardLand 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes discardLand {
|
@keyframes discardLand {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
40% {
|
50% {
|
||||||
transform: scale(1.18);
|
box-shadow: 0 0 20px rgba(244, 164, 96, 0.8);
|
||||||
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
|
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CPU considering discard pile - subtle blue glow pulse */
|
||||||
|
#discard.cpu-considering {
|
||||||
|
animation: cpuConsider 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cpuConsider {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3),
|
||||||
|
0 0 18px rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discard pickup animation - simple dim */
|
||||||
|
#discard.discard-pickup {
|
||||||
|
animation: discardPickup 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes discardPickup {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Swap animation overlay */
|
/* Swap animation overlay */
|
||||||
.swap-animation {
|
.swap-animation {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -1197,7 +1229,6 @@ input::placeholder {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
perspective: 1000px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-animation.hidden {
|
.swap-animation.hidden {
|
||||||
@ -1208,7 +1239,7 @@ input::placeholder {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 98px;
|
height: 98px;
|
||||||
perspective: 1000px;
|
perspective: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card.hidden {
|
.swap-card.hidden {
|
||||||
@ -1219,8 +1250,9 @@ input::placeholder {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: transform 0.54s ease-in-out;
|
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card.flipping .swap-card-inner {
|
.swap-card.flipping .swap-card-inner {
|
||||||
@ -1232,13 +1264,13 @@ input::placeholder {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
backface-visibility: hidden;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-back {
|
.swap-card-back {
|
||||||
@ -1248,17 +1280,18 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-front {
|
.swap-card-front {
|
||||||
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
|
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
|
||||||
transform: rotateY(180deg);
|
border: 2px solid #ddd;
|
||||||
font-size: 2rem;
|
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #2c3e50;
|
color: #333;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
transform: rotateY(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-front.red {
|
.swap-card-front.red {
|
||||||
color: #e74c3c;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-front.black {
|
.swap-card-front.black {
|
||||||
@ -1270,25 +1303,20 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-front .joker-icon {
|
.swap-card-front .joker-icon {
|
||||||
font-size: 1.6em;
|
font-size: 1.5em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card-front .joker-label {
|
.swap-card-front .joker-label {
|
||||||
font-size: 0.45em;
|
font-size: 0.4em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
/* Movement animation */
|
|
||||||
.swap-card.flipping {
|
|
||||||
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card.moving {
|
.swap-card.moving {
|
||||||
transition: top 0.45s cubic-bezier(0.4, 0, 0.2, 1), left 0.45s cubic-bezier(0.4, 0, 0.2, 1), transform 0.45s ease-out;
|
transition: top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
transform: scale(1.1) rotate(-5deg);
|
left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card in hand fading during swap */
|
/* Card in hand fading during swap */
|
||||||
@ -1328,6 +1356,51 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade transitions for swap animation */
|
||||||
|
.card.fade-out,
|
||||||
|
.held-card-floating.fade-out,
|
||||||
|
.anim-card.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.fade-in,
|
||||||
|
.held-card-floating.fade-in,
|
||||||
|
.anim-card.fade-in {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for clickable cards during initial flip phase */
|
||||||
|
.card.clickable.initial-flip-pulse {
|
||||||
|
animation: initialFlipPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes initialFlipPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(244, 164, 96, 0.8),
|
||||||
|
0 0 15px rgba(244, 164, 96, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Held card pulse glow for local player's turn */
|
||||||
|
.held-card-floating.your-turn-pulse {
|
||||||
|
animation: heldCardPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heldCardPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
|
||||||
|
0 0 50px rgba(244, 164, 96, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Player Area */
|
/* Player Area */
|
||||||
.player-section {
|
.player-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -1435,6 +1508,12 @@ input::placeholder {
|
|||||||
color: #2d3436;
|
color: #2d3436;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CPU action status - subtle blue to indicate CPU is doing something */
|
||||||
|
.status-message.cpu-action {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Final turn badge - separate indicator */
|
/* Final turn badge - separate indicator */
|
||||||
.final-turn-badge {
|
.final-turn-badge {
|
||||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||||
@ -1631,7 +1710,9 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons {
|
.game-buttons {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@ -1938,14 +2019,14 @@ input::placeholder {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Real Card - persistent card element with 3D structure */
|
/* Real Card - persistent card element with 3D flip */
|
||||||
.real-card {
|
.real-card {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
perspective: 1000px;
|
|
||||||
z-index: 501;
|
z-index: 501;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, opacity 0.2s;
|
transition: box-shadow 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
|
perspective: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card:hover {
|
.real-card:hover {
|
||||||
@ -1956,8 +2037,9 @@ input::placeholder {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: transform 0.54s ease-in-out;
|
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card .card-inner.flipped {
|
.real-card .card-inner.flipped {
|
||||||
@ -1968,7 +2050,6 @@ input::placeholder {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
backface-visibility: hidden;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1976,6 +2057,7 @@ input::placeholder {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card Front */
|
/* Card Front */
|
||||||
@ -1984,7 +2066,7 @@ input::placeholder {
|
|||||||
border: 2px solid #ddd;
|
border: 2px solid #ddd;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
|
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
|
||||||
line-height: 1.1;
|
line-height: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card .card-face-front.red {
|
.real-card .card-face-front.red {
|
||||||
@ -2031,11 +2113,8 @@ input::placeholder {
|
|||||||
.real-card.moving,
|
.real-card.moving,
|
||||||
.real-card.anim-card.moving {
|
.real-card.anim-card.moving {
|
||||||
z-index: 600;
|
z-index: 600;
|
||||||
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
|
transition: left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
|
top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
transform 0.27s ease-out;
|
|
||||||
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
|
||||||
transform: scale(1.08) rotate(-3deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation card - temporary cards used for animations */
|
/* Animation card - temporary cards used for animations */
|
||||||
@ -2045,14 +2124,13 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.real-card.anim-card .card-inner {
|
.real-card.anim-card .card-inner {
|
||||||
transition: transform 0.54s ease-in-out;
|
transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card.holding {
|
.real-card.holding {
|
||||||
z-index: 550;
|
z-index: 550;
|
||||||
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
|
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
|
||||||
0 4px 15px rgba(0, 0, 0, 0.4);
|
0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
transform: scale(1.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card.clickable {
|
.real-card.clickable {
|
||||||
@ -2062,7 +2140,6 @@ input::placeholder {
|
|||||||
.real-card.clickable:hover {
|
.real-card.clickable:hover {
|
||||||
box-shadow: 0 0 0 3px #f4a460,
|
box-shadow: 0 0 0 3px #f4a460,
|
||||||
0 4px 12px rgba(0, 0, 0, 0.3);
|
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable hover effects when not player's turn */
|
/* Disable hover effects when not player's turn */
|
||||||
@ -2077,7 +2154,6 @@ input::placeholder {
|
|||||||
|
|
||||||
.real-card.selected {
|
.real-card.selected {
|
||||||
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
|
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
|
||||||
transform: scale(1.06);
|
|
||||||
z-index: 520;
|
z-index: 520;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
client/timing-config.js
Normal file
76
client/timing-config.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// Centralized timing configuration for all animations and pauses
|
||||||
|
// Edit these values to tune the feel of card animations and CPU gameplay
|
||||||
|
|
||||||
|
const TIMING = {
|
||||||
|
// Card animations (milliseconds) - smooth, unhurried
|
||||||
|
card: {
|
||||||
|
flip: 400, // Card flip duration (must match CSS transition)
|
||||||
|
move: 400, // Card movement - slower = smoother
|
||||||
|
lift: 0, // No lift pause
|
||||||
|
moving: 400, // Card moving class duration
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pauses - minimal, let animations flow
|
||||||
|
pause: {
|
||||||
|
afterFlip: 0, // No pause - flow into next action
|
||||||
|
afterDiscard: 100, // Brief settle
|
||||||
|
beforeNewCard: 0, // No pause
|
||||||
|
afterSwapComplete: 100, // Brief settle
|
||||||
|
betweenAnimations: 0, // No gaps - continuous flow
|
||||||
|
beforeFlip: 0, // No pause
|
||||||
|
},
|
||||||
|
|
||||||
|
// Beat timing for animation phases (~1.2 sec with variance)
|
||||||
|
beat: {
|
||||||
|
base: 1200, // Base beat duration (longer to see results)
|
||||||
|
variance: 200, // +/- variance for natural feel
|
||||||
|
fadeOut: 300, // Fade out duration
|
||||||
|
fadeIn: 300, // Fade in duration
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI feedback durations (milliseconds)
|
||||||
|
feedback: {
|
||||||
|
drawPulse: 300, // Draw pile highlight duration
|
||||||
|
discardLand: 300, // Discard land effect duration
|
||||||
|
cardFlipIn: 300, // Card flip-in effect duration
|
||||||
|
statusMessage: 2000, // Toast/status message duration
|
||||||
|
copyConfirm: 2000, // Copy button confirmation duration
|
||||||
|
discardPickup: 250, // Discard pickup animation duration
|
||||||
|
},
|
||||||
|
|
||||||
|
// CSS animation timing (for reference - actual values in style.css)
|
||||||
|
css: {
|
||||||
|
cpuConsidering: 1500, // CPU considering pulse cycle
|
||||||
|
},
|
||||||
|
|
||||||
|
// Card manager specific
|
||||||
|
cardManager: {
|
||||||
|
flipDuration: 400, // Card flip animation
|
||||||
|
moveDuration: 400, // Card move animation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Player swap animation steps - smooth continuous motion
|
||||||
|
playerSwap: {
|
||||||
|
flipToReveal: 400, // Initial flip to show card
|
||||||
|
pauseAfterReveal: 50, // Tiny beat to register the card
|
||||||
|
moveToDiscard: 400, // Move old card to discard
|
||||||
|
pulseBeforeSwap: 0, // No pulse - just flow
|
||||||
|
completePause: 50, // Tiny settle
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get beat duration with variance
|
||||||
|
function getBeatDuration() {
|
||||||
|
const base = TIMING.beat.base;
|
||||||
|
const variance = TIMING.beat.variance;
|
||||||
|
return base + (Math.random() * variance * 2 - variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module systems, also attach to window for direct use
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = TIMING;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.TIMING = TIMING;
|
||||||
|
window.getBeatDuration = getBeatDuration;
|
||||||
|
}
|
||||||
71
server/ai.py
71
server/ai.py
@ -33,6 +33,40 @@ def ai_log(message: str):
|
|||||||
ai_logger.debug(message)
|
ai_logger.debug(message)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CPU Turn Timing Configuration (seconds)
|
||||||
|
# =============================================================================
|
||||||
|
# Centralized timing constants for all CPU turn delays.
|
||||||
|
# Adjust these values to tune the "feel" of CPU gameplay.
|
||||||
|
|
||||||
|
CPU_TIMING = {
|
||||||
|
# Delay before CPU "looks at" the discard pile
|
||||||
|
"initial_look": (0.5, 0.7),
|
||||||
|
# Brief pause after draw broadcast
|
||||||
|
"post_draw_settle": 0.05,
|
||||||
|
# Consideration time after drawing (before swap/discard decision)
|
||||||
|
"post_draw_consider": (0.2, 0.4),
|
||||||
|
# Variance multiplier range for chaotic personality players
|
||||||
|
"thinking_multiplier_chaotic": (0.6, 1.4),
|
||||||
|
# Pause after swap/discard to let animation complete and show result
|
||||||
|
"post_action_pause": (0.5, 0.7),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Thinking time ranges by card difficulty (seconds)
|
||||||
|
THINKING_TIME = {
|
||||||
|
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
|
||||||
|
"easy_good": (0.2, 0.4),
|
||||||
|
# Obviously bad cards (10s, Jacks, Queens) - easy pass
|
||||||
|
"easy_bad": (0.2, 0.4),
|
||||||
|
# Medium difficulty (3, 4, 8, 9)
|
||||||
|
"medium": (0.2, 0.4),
|
||||||
|
# Hardest decisions (5, 6, 7 - middle of range)
|
||||||
|
"hard": (0.2, 0.4),
|
||||||
|
# No discard available - quick decision
|
||||||
|
"no_card": (0.2, 0.4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility - use the centralized function from game.py
|
# Alias for backwards compatibility - use the centralized function from game.py
|
||||||
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
||||||
"""Get card value with house rules applied for AI decisions.
|
"""Get card value with house rules applied for AI decisions.
|
||||||
@ -50,32 +84,37 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
|
|||||||
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
|
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
|
||||||
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
|
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
|
||||||
|
|
||||||
Easy decisions (obviously good or bad cards) = quick (400-600ms)
|
Easy decisions (obviously good or bad cards) = quick
|
||||||
Hard decisions (medium value cards) = slower (900-1100ms)
|
Hard decisions (medium value cards) = slower
|
||||||
|
|
||||||
Returns time in seconds.
|
Returns time in seconds. Uses THINKING_TIME constants.
|
||||||
"""
|
"""
|
||||||
if not card:
|
if not card:
|
||||||
# No discard available - quick decision to draw from deck
|
# No discard available - quick decision to draw from deck
|
||||||
return random.uniform(0.4, 0.5)
|
t = THINKING_TIME["no_card"]
|
||||||
|
return random.uniform(t[0], t[1])
|
||||||
|
|
||||||
value = get_card_value(card, options)
|
value = get_card_value(card, options)
|
||||||
|
|
||||||
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
|
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
|
||||||
if value <= 1:
|
if value <= 1:
|
||||||
return random.uniform(0.4, 0.6)
|
t = THINKING_TIME["easy_good"]
|
||||||
|
return random.uniform(t[0], t[1])
|
||||||
|
|
||||||
# Obviously bad cards (easy pass): 10, J, Q (value 10)
|
# Obviously bad cards (easy pass): 10, J, Q (value 10)
|
||||||
if value >= 10:
|
if value >= 10:
|
||||||
return random.uniform(0.4, 0.6)
|
t = THINKING_TIME["easy_bad"]
|
||||||
|
return random.uniform(t[0], t[1])
|
||||||
|
|
||||||
# Medium cards require more thought: 3-9
|
# Medium cards require more thought: 3-9
|
||||||
# 5, 6, 7 are the hardest decisions (middle of the range)
|
# 5, 6, 7 are the hardest decisions (middle of the range)
|
||||||
if value in (5, 6, 7):
|
if value in (5, 6, 7):
|
||||||
return random.uniform(0.9, 1.1)
|
t = THINKING_TIME["hard"]
|
||||||
|
return random.uniform(t[0], t[1])
|
||||||
|
|
||||||
# 3, 4, 8, 9 - moderate difficulty
|
# 3, 4, 8, 9 - moderate difficulty
|
||||||
return random.uniform(0.6, 0.85)
|
t = THINKING_TIME["medium"]
|
||||||
|
return random.uniform(t[0], t[1])
|
||||||
|
|
||||||
|
|
||||||
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
|
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
|
||||||
@ -1316,7 +1355,8 @@ async def process_cpu_turn(
|
|||||||
logger = get_logger() if game_id else None
|
logger = get_logger() if game_id else None
|
||||||
|
|
||||||
# Brief initial delay before CPU "looks at" the discard pile
|
# Brief initial delay before CPU "looks at" the discard pile
|
||||||
await asyncio.sleep(random.uniform(0.08, 0.15))
|
initial_look = CPU_TIMING["initial_look"]
|
||||||
|
await asyncio.sleep(random.uniform(initial_look[0], initial_look[1]))
|
||||||
|
|
||||||
# "Thinking" delay based on how obvious the discard decision is
|
# "Thinking" delay based on how obvious the discard decision is
|
||||||
# Easy decisions (good/bad cards) are quick, medium cards take longer
|
# Easy decisions (good/bad cards) are quick, medium cards take longer
|
||||||
@ -1325,7 +1365,8 @@ async def process_cpu_turn(
|
|||||||
|
|
||||||
# Adjust for personality - chaotic players have more variance
|
# Adjust for personality - chaotic players have more variance
|
||||||
if profile.unpredictability > 0.2:
|
if profile.unpredictability > 0.2:
|
||||||
thinking_time *= random.uniform(0.6, 1.4)
|
chaos_mult = CPU_TIMING["thinking_multiplier_chaotic"]
|
||||||
|
thinking_time *= random.uniform(chaos_mult[0], chaos_mult[1])
|
||||||
|
|
||||||
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
|
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
|
||||||
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
|
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
|
||||||
@ -1397,8 +1438,10 @@ async def process_cpu_turn(
|
|||||||
|
|
||||||
await broadcast_callback()
|
await broadcast_callback()
|
||||||
# Brief pause after draw to let the flash animation register visually
|
# Brief pause after draw to let the flash animation register visually
|
||||||
await asyncio.sleep(0.08)
|
await asyncio.sleep(CPU_TIMING["post_draw_settle"])
|
||||||
await asyncio.sleep(0.35 + random.uniform(0, 0.35))
|
# Consideration time before swap/discard decision
|
||||||
|
consider = CPU_TIMING["post_draw_consider"]
|
||||||
|
await asyncio.sleep(consider[0] + random.uniform(0, consider[1] - consider[0]))
|
||||||
|
|
||||||
# Decide whether to swap or discard
|
# Decide whether to swap or discard
|
||||||
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
|
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
|
||||||
@ -1535,3 +1578,7 @@ async def process_cpu_turn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_callback()
|
await broadcast_callback()
|
||||||
|
|
||||||
|
# Pause to let client animation complete and show result before next turn
|
||||||
|
post_action = CPU_TIMING["post_action_pause"]
|
||||||
|
await asyncio.sleep(random.uniform(post_action[0], post_action[1]))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user