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:
@@ -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();
|
||||
|
||||
635
client/app.js
635
client/app.js
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
265
client/style.css
265
client/style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user