Version 2.0.0: Animation fixes, timing improvements, and E2E test suite

Animation fixes:
- Fix held card positioning bug (was appearing at bottom of page)
- Fix discard pile blank/white flash on turn transitions
- Fix blank card at round end by skipping animations during round_over/game_over
- Set card content before triggering flip animation to prevent flash
- Center suit symbol on 10 cards

Timing improvements:
- Reduce post-discard delay from 700ms to 500ms
- Reduce post-swap delay from 1800ms to 1000ms
- Speed up swap flip animation from 1150ms to 550ms
- Reduce CPU initial thinking delay from 150-250ms to 80-150ms
- Pause now happens after swap completes (showing result) instead of before

E2E test suite:
- Add Playwright-based test bot that plays full games
- State parser extracts game state from DOM for validation
- AI brain ports decision logic for automated play
- Freeze detector monitors for UI hangs
- Visual validator checks CSS states
- Full game, stress, and visual test specs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-29 18:33:28 -05:00
parent 724bf87c43
commit 6950769bc3
29 changed files with 5153 additions and 348 deletions

View File

@@ -12,12 +12,15 @@ class AnimationQueue {
this.animationInProgress = false;
// Timing configuration (ms)
// Rhythm: action → settle → action → breathe
this.timing = {
flipDuration: 400,
moveDuration: 300,
pauseAfterMove: 200,
pauseAfterFlip: 100,
pauseBetweenAnimations: 100
flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
moveDuration: 270,
pauseAfterFlip: 144, // Brief settle after flip before move
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
pauseBeforeNewCard: 150, // Anticipation before new card moves in
pauseAfterSwapComplete: 400, // Breathing room after swap completes
pauseBetweenAnimations: 90
};
}
@@ -159,21 +162,17 @@ class AnimationQueue {
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it
this.setCardFront(front, oldCard);
if (!oldCard.face_up) {
// Set up the front with the old card content (what we're discarding)
this.setCardFront(front, oldCard);
this.playSound('flip');
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
} else {
// Already face up, just show it
this.setCardFront(front, oldCard);
// Already face up, just show it immediately
inner.classList.remove('flipped');
}
await this.delay(100);
// Step 2: Move card to discard pile
this.playSound('card');
animCard.classList.add('moving');
@@ -181,8 +180,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Pause to show the card landing on discard
await this.delay(this.timing.pauseAfterMove + 200);
// Let discard land and pulse settle
await this.delay(this.timing.pauseAfterDiscard);
// Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard();
@@ -197,6 +196,9 @@ class AnimationQueue {
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');
@@ -204,8 +206,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving');
// Clean up animation cards
await this.delay(this.timing.pauseAfterMove);
// Breathing room after swap completes
await this.delay(this.timing.pauseAfterSwapComplete);
animCard.remove();
newAnimCard.remove();
}
@@ -297,7 +299,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
await this.delay(this.timing.pauseAfterMove);
// Same timing as player swap - let discard land and pulse settle
await this.delay(this.timing.pauseAfterDiscard);
// Clean up
animCard.remove();
@@ -322,17 +325,13 @@ class AnimationQueue {
// Move to holding position
this.playSound('card');
await this.delay(50);
animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// The card stays face down until the player decides what to do
// (the actual card reveal happens when server sends card_drawn)
await this.delay(this.timing.pauseAfterMove);
// Brief settle before state updates
await this.delay(this.timing.pauseBeforeNewCard);
// Clean up - renderGame will show the holding card state
animCard.remove();

View File

@@ -8,6 +8,7 @@ class GolfGame {
this.isHost = false;
this.gameState = null;
this.drawnCard = null;
this.drawnFromDiscard = false;
this.selectedCards = [];
this.waitingForFlip = false;
this.currentPlayers = [];
@@ -189,6 +190,7 @@ class GolfGame {
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
this.cpuControlsSection = document.getElementById('cpu-controls-section');
this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
@@ -202,17 +204,22 @@ class GolfGame {
this.yourScore = document.getElementById('your-score');
this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row');
this.deckArea = document.querySelector('.deck-area');
this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn');
this.cancelDrawBtn = document.getElementById('cancel-draw-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.knockEarlyBtn = document.getElementById('knock-early-btn');
this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation');
this.swapCardFromHand = document.getElementById('swap-card-from-hand');
this.heldCardSlot = document.getElementById('held-card-slot');
this.heldCardDisplay = document.getElementById('held-card-display');
this.heldCardContent = document.getElementById('held-card-content');
this.heldCardFloating = document.getElementById('held-card-floating');
this.heldCardFloatingContent = document.getElementById('held-card-floating-content');
this.scoreboard = document.getElementById('scoreboard');
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.standingsList = document.getElementById('standings-list');
@@ -223,6 +230,11 @@ class GolfGame {
this.activeRulesBar = document.getElementById('active-rules-bar');
this.activeRulesList = document.getElementById('active-rules-list');
this.finalTurnBadge = document.getElementById('final-turn-badge');
// In-game auth elements
this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar');
}
bindEvents() {
@@ -233,7 +245,6 @@ class GolfGame {
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
this.cancelDrawBtn.addEventListener('click', () => { this.playSound('click'); this.cancelDraw(); });
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
@@ -244,6 +255,7 @@ class GolfGame {
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
this.muteBtn.addEventListener('click', () => this.toggleSound());
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
// Copy room code to clipboard
this.copyRoomCodeBtn.addEventListener('click', () => {
@@ -401,8 +413,9 @@ class GolfGame {
this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state));
// Reset tracking for new round
// Reset all tracking for new round
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.playSound('shuffle');
this.showGameScreen();
@@ -443,28 +456,33 @@ class GolfGame {
break;
case 'your_turn':
// Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action;
let canKnock = false;
if (this.gameState && this.gameState.knock_early) {
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
canKnock = faceDownCount >= 1 && faceDownCount <= 2;
}
if (canFlip && canKnock) {
this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
} else if (canFlip) {
this.showToast('Your turn! Draw or flip a card', 'your-turn');
} else if (canKnock) {
this.showToast('Your turn! Draw or knock', 'your-turn');
} else {
this.showToast('Your turn! Draw a card', 'your-turn');
}
// Brief delay to let animations settle
setTimeout(() => {
// Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action;
let canKnock = false;
if (this.gameState && this.gameState.knock_early) {
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
canKnock = faceDownCount >= 1 && faceDownCount <= 2;
}
if (canFlip && canKnock) {
this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
} else if (canFlip) {
this.showToast('Your turn! Draw or flip a card', 'your-turn');
} else if (canKnock) {
this.showToast('Your turn! Draw or knock', 'your-turn');
} else {
this.showToast('Your turn! Draw a card', 'your-turn');
}
}, 200);
break;
case 'card_drawn':
this.drawnCard = data.card;
this.drawnFromDiscard = data.source === 'discard';
this.showDrawnCard();
this.renderGame(); // Re-render to update discard pile
this.showToast('Swap with a card or discard', '', 3000);
break;
@@ -750,6 +768,13 @@ class GolfGame {
}
drawFromDiscard() {
// If holding a card drawn from discard, clicking discard puts it back
if (this.drawnCard && !this.gameState.can_discard) {
this.playSound('click');
this.cancelDraw();
return;
}
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
this.playSound('reject');
@@ -764,10 +789,98 @@ class GolfGame {
discardDrawn() {
if (!this.drawnCard) return;
const discardedCard = this.drawnCard;
const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' });
this.drawnCard = null;
this.hideDrawnCard();
this.hideToast();
this.discardBtn.classList.add('hidden');
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
if (wasFromDeck) {
// Swoop animation: deck → discard (makes it clear the card is being tossed)
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
animateDeckToDiscardSwoop(card) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const floater = this.heldCardFloating;
// Reset any previous animation state
floater.classList.remove('dropping', 'swooping', 'landed');
// Instantly position at deck (card appears to come from deck)
floater.style.transition = 'none';
floater.style.left = `${deckRect.left}px`;
floater.style.top = `${deckRect.top}px`;
floater.style.width = `${deckRect.width}px`;
floater.style.height = `${deckRect.height}px`;
floater.style.transform = 'scale(1) rotate(0deg)';
// Force reflow
floater.offsetHeight;
// Start swoop to discard
floater.style.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, settle and show on discard pile
setTimeout(() => {
floater.classList.add('landed');
setTimeout(() => {
floater.classList.add('hidden');
floater.classList.remove('swooping', 'landed');
// Clear all inline styles from the animation
floater.style.cssText = '';
this.updateDiscardPileDisplay(card);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
}, 150); // Brief settle
}, 350); // Match swoop transition duration
}
// Update the discard pile display with a card
updateDiscardPileDisplay(card) {
this.discard.classList.remove('picked-up', 'disabled');
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('red', 'black', 'joker');
if (card.rank === '★') {
this.discard.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
this.discardContent.innerHTML = this.renderCardContent(card);
}
this.lastDiscardKey = `${card.rank}-${card.suit}`;
}
cancelDraw() {
@@ -786,6 +899,7 @@ class GolfGame {
}
// Animate player swapping drawn card with a card in their hand
// Uses flip-in-place + teleport (no zipping movement)
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
@@ -801,12 +915,16 @@ class GolfGame {
// Get positions
const handRect = handCardEl.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Set up the animated card at hand position
const swapCard = this.swapCardFromHand;
if (!swapCard) {
// Animation element missing - fall back to non-animated swap
console.error('Swap animation element missing, falling back to direct swap');
this.swapCard(position);
return;
}
const swapCardFront = swapCard.querySelector('.swap-card-front');
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at the hand card location
swapCard.style.left = handRect.left + 'px';
@@ -814,97 +932,67 @@ class GolfGame {
swapCard.style.width = handRect.width + 'px';
swapCard.style.height = handRect.height + 'px';
// Reset state
// Reset state - no moving class needed
swapCard.classList.remove('flipping', 'moving');
swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front';
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
if (isAlreadyFaceUp && card) {
// FACE-UP CARD: Show card content immediately, then slide to discard
if (card.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`;
}
swapCard.classList.add('flipping'); // Show front immediately
// Hide the actual hand card and discard
handCardEl.classList.add('swap-out');
this.discard.classList.add('swap-to-hand');
this.swapAnimation.classList.remove('hidden');
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
// FACE-UP CARD: Subtle pulse animation (no flip needed)
this.swapAnimationContentSet = true;
// Send swap
// Apply subtle swap pulse to both cards
handCardEl.classList.add('swap-pulse');
this.heldCardFloating.classList.add('swap-pulse');
// Play a soft sound for the swap
this.playSound('card');
// Send swap and let render handle the update
this.send({ type: 'swap', position });
this.drawnCard = null;
this.skipNextDiscardFlip = true;
// Slide to discard
// Complete after pulse animation
setTimeout(() => {
swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
}, 50);
// Complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving');
handCardEl.classList.remove('swap-out');
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.hideDrawnCard();
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}, 500);
handCardEl.classList.remove('swap-pulse');
this.heldCardFloating.classList.remove('swap-pulse');
this.completeSwapAnimation(null);
}, 440);
} else {
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
// The new card will appear instantly when state updates
// FACE-DOWN CARD: Flip in place to reveal, then teleport
// Don't use overlay for face-down - just send swap and let state handle it
// This avoids the clunky "flip to empty front" issue
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
// Hide the actual hand card
handCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Store references for updateSwapAnimation
this.swapAnimationFront = swapCardFront;
this.swapAnimationCard = swapCard;
this.swapAnimationContentSet = false;
// Send swap
// Send swap - the flip will happen in updateSwapAnimation when server responds
this.send({ type: 'swap', position });
this.drawnCard = null;
// Brief visual feedback - hide drawn card area
this.discard.classList.add('swap-to-hand');
handCardEl.classList.add('swap-out');
// Short timeout then let state update handle it
setTimeout(() => {
this.discard.classList.remove('swap-to-hand');
handCardEl.classList.remove('swap-out');
this.swapAnimationInProgress = false;
this.hideDrawnCard();
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}, 300);
this.skipNextDiscardFlip = true;
}
}
// Update the animated card with actual card content when server responds
updateSwapAnimation(card) {
if (!this.swapAnimationFront || !card) return;
// Safety: if animation references are missing, complete immediately to avoid freeze
if (!this.swapAnimationFront || !card) {
if (this.swapAnimationInProgress && !this.swapAnimationContentSet) {
console.error('Swap animation incomplete: missing front element or card data');
this.completeSwapAnimation(null);
}
return;
}
// Skip if we already set the content (face-up card swap)
if (this.swapAnimationContentSet) return;
@@ -923,6 +1011,61 @@ class GolfGame {
}
this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
this.swapAnimationContentSet = true;
// Quick flip to reveal, then complete - server will pause before next turn
if (this.swapAnimationCard) {
// Step 1: Flip to reveal (quick)
this.swapAnimationCard.classList.add('flipping');
this.playSound('flip');
// Step 2: Brief pulse after flip completes
setTimeout(() => {
this.swapAnimationCard.classList.add('swap-pulse');
this.playSound('card');
}, 350);
// Step 3: Complete animation - the pause to see the result happens
// on the server side before the next CPU turn starts
setTimeout(() => {
this.completeSwapAnimation(null);
}, 550);
} else {
// Fallback: animation element missing, complete immediately to avoid freeze
console.error('Swap animation element missing, completing immediately');
this.completeSwapAnimation(null);
}
}
completeSwapAnimation(heldCard) {
// Hide everything
this.swapAnimation.classList.add('hidden');
if (this.swapAnimationCard) {
this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse');
}
if (heldCard) {
heldCard.classList.remove('flipping', 'moving');
heldCard.classList.add('hidden');
}
if (this.swapAnimationHandCardEl) {
this.swapAnimationHandCardEl.classList.remove('swap-out');
}
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.swapAnimationFront = null;
this.swapAnimationCard = null;
this.swapAnimationDiscardRect = null;
this.swapAnimationHandCardEl = null;
this.swapAnimationHandRect = null;
this.discardBtn.classList.add('hidden');
this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}
flipCard(position) {
@@ -987,7 +1130,7 @@ class GolfGame {
}
}
if (discardChanged && wasOtherPlayer) {
if (discardChanged) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
@@ -998,34 +1141,38 @@ class GolfGame {
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
const wasUp = oldCard?.face_up;
const isUp = newCard?.face_up;
// Case 1: face-down became face-up
// Case 1: face-down became face-up (needs flip)
if (!wasUp && isUp) {
swappedPosition = i;
wasFaceUp = false;
break;
}
// Case 2: both face-up but different card (rank or suit changed)
// Case 2: both face-up but different card (no flip needed)
if (wasUp && isUp && oldCard.rank && newCard.rank) {
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
swappedPosition = i;
wasFaceUp = true; // Face-to-face swap
break;
}
}
}
if (swappedPosition >= 0) {
// Player swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition);
} else {
// Player drew and discarded without swapping
// Animate card going from deck area to discard
if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
} else if (swappedPosition < 0) {
// Player drew and discarded without swapping - pulse for everyone
this.fireDiscardAnimation(newDiscard);
}
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
}
}
@@ -1041,54 +1188,21 @@ class GolfGame {
void pile.offsetWidth;
pile.classList.add('draw-pulse');
// Remove class after animation completes
setTimeout(() => pile.classList.remove('draw-pulse'), 400);
setTimeout(() => pile.classList.remove('draw-pulse'), 450);
}
// Fire animation for discard without swap (card goes deck -> discard)
// Pulse discard pile when a card lands on it
pulseDiscardLand() {
this.discard.classList.remove('discard-land');
void this.discard.offsetWidth;
this.discard.classList.add('discard-land');
setTimeout(() => this.discard.classList.remove('discard-land'), 460);
}
// Fire animation for discard without swap (card lands on discard pile face-up)
fireDiscardAnimation(discardCard) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front');
// Start at deck position
swapCard.style.left = deckRect.left + 'px';
swapCard.style.top = deckRect.top + 'px';
swapCard.style.width = deckRect.width + 'px';
swapCard.style.height = deckRect.height + 'px';
swapCard.classList.remove('flipping', 'moving');
// Set card content
swapCardFront.className = 'swap-card-front';
if (discardCard.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
swapCardFront.innerHTML = `${discardCard.rank}<br>${this.getSuitSymbol(discardCard.suit)}`;
}
this.swapAnimation.classList.remove('hidden');
// Flip to reveal card
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
// Move to discard
setTimeout(() => {
swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
}, 400);
// Complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving');
}, 800);
// Card is already known - just pulse to show it landed (no flip needed)
this.pulseDiscardLand();
}
// Get rotation angle from an element's computed transform
@@ -1108,8 +1222,9 @@ class GolfGame {
return 0;
}
// Fire a swap animation (non-blocking)
fireSwapAnimation(playerId, discardCard, 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
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
// Find source position - the actual card that was swapped
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
@@ -1132,26 +1247,37 @@ class GolfGame {
}
}
if (!sourceRect) {
const discardRect = this.discard.getBoundingClientRect();
sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height };
// Face-to-face swap: use subtle pulse on the card, no flip needed
if (wasFaceUp && sourceCardEl) {
sourceCardEl.classList.add('swap-pulse');
this.playSound('card');
setTimeout(() => {
sourceCardEl.classList.remove('swap-pulse');
}, 400);
return;
}
// Face-down to face-up: flip to reveal, pause to see it, then pulse before swap
if (!sourceRect) {
// Fallback: just show flip at discard position
const discardRect = this.discard.getBoundingClientRect();
sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
}
const discardRect = this.discard.getBoundingClientRect();
const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front');
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at opponent's card location (flip in place there)
swapCard.style.left = sourceRect.left + 'px';
swapCard.style.top = sourceRect.top + 'px';
swapCard.style.width = sourceRect.width + 'px';
swapCard.style.height = sourceRect.height + 'px';
swapCard.classList.remove('flipping', 'moving');
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
// Apply source rotation to match the arch layout
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
// Set card content
// Set card content (the card being discarded - what was hidden)
swapCardFront.className = 'swap-card-front';
if (discardCard.rank === '★') {
swapCardFront.classList.add('joker');
@@ -1165,24 +1291,26 @@ class GolfGame {
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Timing: flip takes ~400ms, then move takes ~400ms
// Step 1: Flip to reveal the hidden card
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
// Step 2: After flip, pause to see the card then pulse before being replaced
setTimeout(() => {
// Start move AFTER flip completes - also animate rotation back to 0
swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
swapCard.style.transform = 'rotate(0deg)';
}, 500);
swapCard.classList.add('swap-pulse');
this.playSound('card');
}, 850);
// Step 3: Strategic pause to show discarded card, then complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving');
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
}, 1000);
this.pulseDiscardLand();
}, 1400);
}
// Fire a flip animation for local player's card (non-blocking)
@@ -1233,7 +1361,7 @@ class GolfGame {
swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
}, 450);
}, 550);
}
// Fire a flip animation for opponent card (non-blocking)
@@ -1295,7 +1423,7 @@ class GolfGame {
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
}, 60);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
@@ -1303,7 +1431,7 @@ class GolfGame {
swapCard.style.transform = '';
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
}, 450);
}, 560);
}
handleCardClick(position) {
@@ -1339,7 +1467,9 @@ class GolfGame {
// Initial flip phase
if (this.gameState.waiting_for_initial_flip) {
if (card.face_up) return;
// Use Set to prevent duplicates - check both tracking mechanisms
if (this.locallyFlippedCards.has(position)) return;
if (this.selectedCards.includes(position)) return;
const requiredFlips = this.gameState.initial_flips || 2;
@@ -1353,12 +1483,15 @@ class GolfGame {
// Re-render to show flipped state
this.renderGame();
if (this.selectedCards.length === requiredFlips) {
this.send({ type: 'flip_initial', positions: this.selectedCards });
// Use Set to ensure unique positions when sending to server
const uniquePositions = [...new Set(this.selectedCards)];
if (uniquePositions.length === requiredFlips) {
this.send({ type: 'flip_initial', positions: uniquePositions });
this.selectedCards = [];
// Note: locallyFlippedCards is cleared when server confirms (in game_state handler)
this.hideToast();
} else {
const remaining = requiredFlips - this.selectedCards.length;
const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
}
return;
@@ -1417,6 +1550,25 @@ class GolfGame {
this.rulesScreen.classList.remove('active');
}
screen.classList.add('active');
// Handle auth bar visibility - hide global bar during game, show in-game controls instead
const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user;
if (isGameScreen && user) {
// Hide global auth bar, show in-game auth controls
this.authBar?.classList.add('hidden');
this.gameUsername.textContent = user.username;
this.gameUsername.classList.remove('hidden');
this.gameLogoutBtn.classList.remove('hidden');
} else {
// Show global auth bar (if logged in), hide in-game auth controls
if (user) {
this.authBar?.classList.remove('hidden');
}
this.gameUsername.classList.add('hidden');
this.gameLogoutBtn.classList.add('hidden');
}
}
showLobby() {
@@ -1435,9 +1587,11 @@ class GolfGame {
if (this.isHost) {
this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
} else {
this.hostSettings.classList.add('hidden');
this.cpuControlsSection.classList.add('hidden');
this.waitingMessage.classList.remove('hidden');
}
}
@@ -1499,13 +1653,8 @@ class GolfGame {
if (player.is_host) badges += '<span class="host-badge">HOST</span>';
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
let nameDisplay = player.name;
if (player.style) {
nameDisplay += ` <span class="cpu-style">(${player.style})</span>`;
}
li.innerHTML = `
<span>${nameDisplay}</span>
<span>${player.name}</span>
<span>${badges}</span>
`;
if (player.id === this.playerId) {
@@ -1516,6 +1665,7 @@ class GolfGame {
if (player.id === this.playerId && player.is_host) {
this.isHost = true;
this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
}
});
@@ -1573,6 +1723,18 @@ class GolfGame {
return;
}
// Check for round/game over states
if (this.gameState.phase === 'round_over') {
this.setStatus('Hole Complete!', 'round-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
if (this.gameState.phase === 'game_over') {
this.setStatus('Game Over!', 'game-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
@@ -1610,33 +1772,37 @@ class GolfGame {
}
showDrawnCard() {
// Show drawn card in the discard pile position, highlighted
// Show drawn card floating over the discard pile (larger, closer to viewer)
const card = this.drawnCard;
this.discard.className = 'card card-front holding';
// Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
if (card.rank === '★') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(card.suit)) {
this.discard.classList.add('red');
this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.heldCardFloatingContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discard.classList.add('black');
if (this.isRedSuit(card.suit)) {
this.heldCardFloating.classList.add('red');
} else {
this.heldCardFloating.classList.add('black');
}
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
// Render card directly without checking face_up (drawn card is always visible to drawer)
if (card.rank === '★') {
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
// Show the floating card and discard button
this.heldCardFloating.classList.remove('hidden');
this.discardBtn.classList.remove('hidden');
}
hideDrawnCard() {
// Restore discard pile to show actual top card (handled by renderGame)
this.discard.classList.remove('holding');
// Hide the floating held card
this.heldCardFloating.classList.add('hidden');
// Clear any inline styles from animations
this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden');
this.cancelDrawBtn.classList.add('hidden');
}
isRedSuit(suit) {
@@ -1745,19 +1911,50 @@ class GolfGame {
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
}
// Update discard pile (skip if holding a drawn card)
if (!this.drawnCard) {
// Update discard pile
if (this.drawnCard) {
// 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 deck, show current discard_top greyed
this.discard.classList.add('picked-up');
this.discard.classList.remove('holding');
if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
if (discardCard.rank === '★') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(discardCard.suit)) {
this.discard.classList.add('red');
} else {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
} else {
// No card underneath - show empty
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
this.discardContent.innerHTML = '';
}
} else {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Animate if discard changed
if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) {
this.discard.classList.add('card-flip-in');
setTimeout(() => this.discard.classList.remove('card-flip-in'), 400);
}
// Only animate discard flip during active gameplay, not at round/game end
const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey;
// Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
@@ -1769,6 +1966,15 @@ class GolfGame {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
// THEN animate if needed (content is already set, so no blank flash)
if (shouldAnimate) {
// Remove any existing animation first to allow re-trigger
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
}
} else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
this.discardContent.innerHTML = '';
@@ -1781,22 +1987,30 @@ class GolfGame {
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
// Pulse the deck area when it's player's turn to draw
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
this.deck.classList.toggle('clickable', canDraw);
this.deck.classList.toggle('disabled', hasDrawn);
// Only show disabled on deck when it's my turn and I've drawn
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
// Don't show disabled state when we're holding a drawn card (it's displayed in discard position)
this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard);
// Only show disabled state when it's my turn and I've drawn (not holding visible card)
// Don't grey out when opponents are playing
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
this.opponentsRow.innerHTML = '';
// Don't highlight current player during round/game over
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
opponents.forEach((player) => {
const div = document.createElement('div');
div.className = 'opponent-area';
if (player.id === this.gameState.current_player_id) {
if (isPlaying && player.id === this.gameState.current_player_id) {
div.classList.add('current-turn');
}
@@ -1810,7 +2024,7 @@ class GolfGame {
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
div.innerHTML = `
<h4>${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
<h4><span class="opponent-name">${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}</span><span class="opponent-showing">${showingScore}</span></h4>
<div class="card-grid">
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
</div>
@@ -1862,12 +2076,9 @@ class GolfGame {
if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled');
// Show cancel button when drawn from discard (can put it back)
this.cancelDrawBtn.classList.remove('hidden');
} else {
this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled');
this.cancelDrawBtn.classList.add('hidden');
}
// Show/hide skip flip button (only when flip is optional in endgame mode)
@@ -1907,14 +2118,20 @@ class GolfGame {
// Update standings (left panel)
this.updateStandings();
// Skip score table update during round_over/game_over - showScoreboard handles these
if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') {
return;
}
// Update score table (right panel)
this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => {
const tr = document.createElement('tr');
// Highlight current player
if (player.id === this.gameState.current_player_id) {
// Highlight current player (but not during round/game over)
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
if (isPlaying && player.id === this.gameState.current_player_id) {
tr.classList.add('current-player');
}

View File

@@ -51,11 +51,10 @@
<!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen">
<div class="room-code-banner">
<span class="room-code-label">ROOM CODE</span>
<span class="room-code-value" id="display-room-code"></span>
<div class="room-code-buttons">
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
<button class="room-code-copy" id="share-room-link" title="Copy link">🌐</button>
<button class="room-code-copy" id="share-room-link" title="Copy invite link">🌐</button>
</div>
</div>
@@ -65,19 +64,19 @@
<h3>Players</h3>
<ul id="players-list"></ul>
</div>
<div id="cpu-controls-section" class="cpu-controls-section hidden">
<h4>Add CPU Opponents</h4>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div>
<div id="host-settings" class="settings hidden">
<h3>Game Settings</h3>
<div class="basic-settings-row">
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
</div>
</div>
<div class="form-group">
<label for="num-decks">Decks</label>
<select id="num-decks">
@@ -262,6 +261,8 @@
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
</div>
<div class="header-col header-col-right">
<span id="game-username" class="game-username hidden"></span>
<button id="game-logout-btn" class="btn btn-small hidden">Logout</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div>
@@ -273,6 +274,13 @@
<div class="player-row">
<div class="table-center">
<div class="deck-area">
<!-- Held card slot (left of deck) -->
<div id="held-card-slot" class="held-card-slot hidden">
<div id="held-card-display" class="card card-front">
<span id="held-card-content"></span>
</div>
<span class="held-label">Holding</span>
</div>
<div id="deck" class="card card-back">
<span>?</span>
</div>
@@ -280,8 +288,11 @@
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="cancel-draw-btn" class="btn btn-small btn-secondary hidden">Put Back</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div>
@@ -295,14 +306,22 @@
</div>
</div>
<!-- Legacy swap animation overlay (kept for rollback) -->
<!-- Animation overlay for card movements -->
<div id="swap-animation" class="swap-animation hidden">
<!-- Card being discarded from hand -->
<div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
</div>
</div>
<!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
</div>
</div>
</div>
</div>
</div>
@@ -317,6 +336,10 @@
<!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
<table id="score-table">
<thead>
<tr>
@@ -328,10 +351,6 @@
</thead>
<tbody></tbody>
</table>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
</div>
</div>
</div>

View File

@@ -195,12 +195,13 @@ body {
grid-template-columns: 220px 1fr;
gap: 25px;
align-items: start;
padding-top: 70px;
}
.waiting-left-col {
display: flex;
flex-direction: column;
gap: 15px;
gap: 10px;
}
.waiting-left-col .players-list {
@@ -227,7 +228,7 @@ body {
/* Basic settings in a row */
.basic-settings-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 15px;
align-items: end;
@@ -248,14 +249,29 @@ body {
padding: 8px 4px;
}
.basic-settings-row .cpu-controls {
display: flex;
gap: 5px;
/* CPU Controls Section - below players list */
.cpu-controls-section {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px 12px;
}
.basic-settings-row .cpu-controls .btn {
.cpu-controls-section h4 {
margin: 0 0 6px 0;
font-size: 0.8rem;
color: #f4a460;
}
.cpu-controls-section .cpu-controls {
display: flex;
gap: 6px;
}
.cpu-controls-section .cpu-controls .btn {
flex: 1;
padding: 8px 0;
padding: 6px 0;
font-size: 1rem;
font-weight: bold;
}
#waiting-message {
@@ -282,14 +298,14 @@ body {
left: 20px;
z-index: 100;
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
padding: 12px 16px 20px;
padding: 10px 14px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 6px;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
/* Ribbon forked end (snake tongue style) */
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 12px), 0 100%);
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 10px), 0 100%);
}
.room-code-banner::before {
@@ -302,39 +318,27 @@ body {
background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%);
}
.room-code-label {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.25em;
color: rgba(255, 255, 255, 0.85);
text-align: center;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.room-code-value {
font-size: 1.6rem;
font-size: 1.5rem;
font-weight: 800;
font-family: 'Courier New', monospace;
letter-spacing: 0.2em;
letter-spacing: 0.15em;
color: #fff;
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3);
padding: 2px 0;
}
.room-code-buttons {
display: flex;
gap: 6px;
margin-top: 2px;
gap: 5px;
}
.room-code-copy {
background: rgba(255, 255, 255, 0.85);
border: none;
border-radius: 4px;
padding: 4px 8px;
padding: 4px 6px;
cursor: pointer;
font-size: 0.9rem;
font-size: 0.85rem;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
@@ -349,6 +353,7 @@ body {
}
h1 {
font-size: 3rem;
text-align: center;
@@ -463,6 +468,15 @@ input::placeholder {
width: auto;
}
.game-buttons .btn-next-round {
padding: 10px 20px;
font-size: 1rem;
font-weight: 600;
width: 100%;
background: #f4a460;
color: #1a472a;
}
.btn.disabled,
.btn:disabled {
opacity: 0.4;
@@ -625,7 +639,7 @@ input::placeholder {
/* Game Screen */
.game-header {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
@@ -653,8 +667,18 @@ input::placeholder {
}
.header-col-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 8px;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
min-width: max-content;
}
#game-logout-btn {
padding: 4px 8px;
font-size: 0.75rem;
}
.game-header .round-info {
@@ -773,6 +797,7 @@ input::placeholder {
background: #fff;
border: 2px solid #ddd;
color: #333;
text-align: center;
}
.card-front.red {
@@ -962,7 +987,73 @@ input::placeholder {
.deck-area {
display: flex;
gap: 15px;
align-items: center;
align-items: flex-start;
}
/* Gentle pulse when it's your turn to draw */
.deck-area.your-turn-to-draw {
animation: deckAreaPulse 2s ease-in-out infinite;
}
@keyframes deckAreaPulse {
0%, 100% {
filter: brightness(1);
transform: scale(1);
}
50% {
filter: brightness(1.08);
transform: scale(1.02);
}
}
/* Held card slot - hidden, using floating card over discard instead */
.held-card-slot {
display: none !important;
}
/* Held card floating over discard pile (larger, closer to viewer) */
.held-card-floating {
position: absolute;
top: 0;
left: 0;
z-index: 100;
transform: scale(1.2) translateY(-12px);
border: 3px solid #f4a460 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none;
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.held-card-floating.hidden {
opacity: 0;
transform: scale(1) translateY(0);
pointer-events: none;
}
/* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping {
transform: scale(1) translateY(0);
border-color: transparent !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;
}
/* Swoop animation for deck → immediate discard */
.held-card-floating.swooping {
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s ease-out,
border-color 0.35s ease-out,
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 {
transform: scale(1) rotate(0deg);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
.deck-area .card {
@@ -989,11 +1080,19 @@ input::placeholder {
transform: scale(1.05);
}
/* Picked-up state - showing card underneath after drawing from discard */
#discard.picked-up {
opacity: 0.5;
filter: grayscale(40%);
transform: scale(0.95);
}
.discard-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
}
.discard-stack .btn {
@@ -1016,7 +1115,7 @@ input::placeholder {
/* Highlight flash when opponent draws from a pile */
#deck.draw-pulse,
#discard.draw-pulse {
animation: draw-highlight 0.4s ease-out;
animation: draw-highlight 0.45s ease-out;
z-index: 100;
}
@@ -1044,7 +1143,7 @@ input::placeholder {
/* Card flip animation for discard pile */
.card-flip-in {
animation: cardFlipIn 0.5s ease-out;
animation: cardFlipIn 0.56s ease-out;
}
@keyframes cardFlipIn {
@@ -1069,6 +1168,26 @@ input::placeholder {
}
}
/* Discard pile pulse when card lands */
#discard.discard-land {
animation: discardLand 0.46s ease-out;
}
@keyframes discardLand {
0% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
40% {
transform: scale(1.18);
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
}
100% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
/* Swap animation overlay */
.swap-animation {
position: fixed;
@@ -1092,12 +1211,16 @@ input::placeholder {
perspective: 1000px;
}
.swap-card.hidden {
display: none;
}
.swap-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
transition: transform 0.54s ease-in-out;
}
.swap-card.flipping .swap-card-inner {
@@ -1163,7 +1286,7 @@ input::placeholder {
}
.swap-card.moving {
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out;
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;
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
}
@@ -1180,6 +1303,31 @@ input::placeholder {
transition: opacity 0.2s;
}
/* Subtle swap pulse for face-to-face swaps (no flip needed) */
.card.swap-pulse {
animation: swapPulse 0.4s ease-out;
}
@keyframes swapPulse {
0% {
transform: scale(1);
filter: brightness(1);
}
20% {
transform: scale(0.92);
filter: brightness(0.85);
}
50% {
transform: scale(1.08);
filter: brightness(1.15);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
100% {
transform: scale(1);
filter: brightness(1);
}
}
/* Player Area */
.player-section {
text-align: center;
@@ -1483,14 +1631,14 @@ input::placeholder {
}
.game-buttons {
margin-top: 8px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.game-buttons .btn {
font-size: 0.7rem;
font-size: 0.8rem;
padding: 6px 8px;
width: 100%;
}
@@ -1728,11 +1876,27 @@ input::placeholder {
}
.game-header {
display: flex;
flex-direction: column;
text-align: center;
gap: 3px;
}
.header-col-right {
justify-content: center;
}
#game-logout-btn,
#leave-game-btn {
padding: 3px 6px;
font-size: 0.7rem;
}
.game-username {
font-size: 0.7rem;
max-width: 60px;
}
.table-center {
padding: 10px 15px;
}
@@ -1793,7 +1957,7 @@ input::placeholder {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
transition: transform 0.54s ease-in-out;
}
.real-card .card-inner.flipped {
@@ -1867,9 +2031,9 @@ input::placeholder {
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s ease-out;
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.27s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg);
}
@@ -1881,7 +2045,7 @@ input::placeholder {
}
.real-card.anim-card .card-inner {
transition: transform 0.4s ease-in-out;
transition: transform 0.54s ease-in-out;
}
.real-card.holding {
@@ -2881,11 +3045,30 @@ input::placeholder {
display: none;
}
/* Hide global auth-bar when game screen is active */
#app:has(#game-screen.active) > .auth-bar {
display: none !important;
}
#auth-username {
color: #f4a460;
font-weight: 500;
}
/* Username in game header */
.game-username {
color: #f4a460;
font-weight: 500;
font-size: 0.75rem;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.game-username.hidden {
display: none;
}
/* Auth buttons in lobby */
.auth-buttons {
display: flex;