Additional animation work and AI strategy enhancements and logging for performance analytics.

This commit is contained in:
Aaron D. Lee 2026-01-25 18:49:18 -05:00
parent f80bab3b4b
commit 0f44464c4f
4 changed files with 281 additions and 433 deletions

View File

@ -1,9 +1,5 @@
// Golf Card Game - Client Application // Golf Card Game - Client Application
// Feature flag for new persistent card system
// Disabled - using improved legacy system instead
const USE_NEW_CARD_SYSTEM = false;
class GolfGame { class GolfGame {
constructor() { constructor() {
this.ws = null; this.ws = null;
@ -19,37 +15,24 @@ class GolfGame {
this.soundEnabled = true; this.soundEnabled = true;
this.audioCtx = null; this.audioCtx = null;
// Swap animation state (legacy) // Swap animation state
this.swapAnimationInProgress = false; this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null; this.swapAnimationCardEl = null;
this.swapAnimationFront = null; this.swapAnimationFront = null;
this.pendingGameState = null; this.pendingGameState = null;
// New card system state
this.previousState = null;
this.isAnimating = false;
// Track cards we've locally flipped (for immediate feedback during selection) // Track cards we've locally flipped (for immediate feedback during selection)
this.locallyFlippedCards = new Set(); this.locallyFlippedCards = new Set();
// Animation lock - prevent overlapping animations on same elements // Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set(); this.animatingPositions = new Set();
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
this.initElements(); this.initElements();
this.initAudio(); this.initAudio();
this.bindEvents(); this.bindEvents();
// Initialize new card system
if (USE_NEW_CARD_SYSTEM) {
this.initNewCardSystem();
// Update card positions on resize
window.addEventListener('resize', () => {
if (this.cardManager && this.gameState) {
this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos));
}
});
}
} }
initAudio() { initAudio() {
@ -128,167 +111,10 @@ class GolfGame {
this.playSound('click'); this.playSound('click');
} }
initNewCardSystem() {
const cardLayer = document.getElementById('card-layer');
this.cardManager = new CardManager(cardLayer);
this.stateDiffer = new StateDiffer();
this.animationQueue = new AnimationQueue(
this.cardManager,
(playerId, position) => this.getSlotRect(playerId, position),
(location) => this.getLocationRectNew(location),
(type) => this.playSound(type)
);
}
// Get the bounding rect of a card slot
getSlotRect(playerId, position) {
// Try to find by data attribute first (new system)
const slotByData = document.querySelector(`.card-slot[data-player="${playerId}"][data-position="${position}"]`);
if (slotByData) {
const rect = slotByData.getBoundingClientRect();
if (rect.width > 0) return rect;
}
// Fallback: Check if it's the local player
if (playerId === this.playerId) {
const slots = this.playerCards.querySelectorAll('.card, .card-slot');
if (slots[position]) {
return slots[position].getBoundingClientRect();
}
}
return null;
}
// Get rect for deck/discard/holding locations
getLocationRectNew(location) {
switch (location) {
case 'deck':
return this.deck.getBoundingClientRect();
case 'discard':
return this.discard.getBoundingClientRect();
case 'holding': {
const rect = this.discard.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
};
}
default:
return null;
}
}
// Initialize persistent cards for a new game/round
initializePersistentCards() {
if (!this.cardManager || !this.gameState) return;
this.cardManager.initializeCards(
this.gameState,
this.playerId,
(pid, pos) => this.getSlotRect(pid, pos),
() => this.deck.getBoundingClientRect(),
() => this.discard.getBoundingClientRect()
);
// Retry positioning a few times to handle layout delays
let retries = 0;
const tryPosition = () => {
const positioned = this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos));
retries++;
if (retries < 5) {
requestAnimationFrame(tryPosition);
}
};
requestAnimationFrame(tryPosition);
}
// Animate persistent cards based on detected movements
async animatePersistentCards(movements, newState) {
if (!this.cardManager) return;
for (const movement of movements) {
switch (movement.type) {
case 'flip':
this.playSound('flip');
await this.cardManager.flipCard(
movement.playerId,
movement.position,
movement.card
);
break;
case 'swap':
this.playSound('flip');
await this.cardManager.animateSwap(
movement.playerId,
movement.position,
movement.oldCard,
movement.newCard,
(pid, pos) => this.getSlotRect(pid, pos),
() => this.discard.getBoundingClientRect()
);
this.playSound('card');
break;
case 'draw-deck':
case 'draw-discard':
this.playSound('card');
await this.delay(200);
break;
}
// Small pause between animations
await this.delay(100);
}
}
delay(ms) { delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
// Update persistent card positions and visual states
updatePersistentCards() {
if (!this.cardManager || !this.gameState) return;
// If cards haven't been created yet, initialize them
if (this.cardManager.handCards.size === 0) {
// Need to wait for DOM to have slots - already handled in game_started
return;
}
// Update positions (in case window resized)
this.cardManager.updateAllPositions((pid, pos) => this.getSlotRect(pid, pos));
// Update card visual states (clickable, selected)
const myData = this.getMyPlayerData();
if (myData) {
for (let i = 0; i < 6; i++) {
const cardInfo = this.cardManager.getHandCard(this.playerId, i);
if (cardInfo) {
const card = myData.cards[i];
const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up) ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(i);
cardInfo.element.classList.toggle('clickable', isClickable);
cardInfo.element.classList.toggle('selected', isSelected);
// Make card clickable
if (!cardInfo.element._hasClickHandler) {
const pos = i;
cardInfo.element.addEventListener('click', () => this.handleCardClick(pos));
cardInfo.element._hasClickHandler = true;
}
}
}
}
}
initElements() { initElements() {
// Screens // Screens
this.lobbyScreen = document.getElementById('lobby-screen'); this.lobbyScreen = document.getElementById('lobby-screen');
@ -345,6 +171,7 @@ class GolfGame {
this.discardContent = document.getElementById('discard-content'); this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn'); this.discardBtn = document.getElementById('discard-btn');
this.playerCards = document.getElementById('player-cards'); this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation'); this.swapAnimation = document.getElementById('swap-animation');
this.swapCardFromHand = document.getElementById('swap-card-from-hand'); this.swapCardFromHand = document.getElementById('swap-card-from-hand');
this.scoreboard = document.getElementById('scoreboard'); this.scoreboard = document.getElementById('scoreboard');
@ -479,6 +306,11 @@ class GolfGame {
case 'game_started': case 'game_started':
case 'round_started': case 'round_started':
// Clear any countdown from previous hole
this.clearNextHoleCountdown();
this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights
this.roundWinnerNames = new Set();
this.gameState = data.game_state; this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues // Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state)); this.previousState = JSON.parse(JSON.stringify(data.game_state));
@ -487,78 +319,40 @@ class GolfGame {
this.animatingPositions = new Set(); this.animatingPositions = new Set();
this.playSound('shuffle'); this.playSound('shuffle');
this.showGameScreen(); this.showGameScreen();
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear(); // Clear any leftover cards
}
this.renderGame(); this.renderGame();
// Initialize persistent cards after DOM is ready
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
setTimeout(() => this.initializePersistentCards(), 50);
}
break; break;
case 'game_state': case 'game_state':
if (USE_NEW_CARD_SYSTEM) { // State updates are instant, animations are fire-and-forget
// New card system: animate persistent cards directly // Exception: Local player's swap animation defers state until complete
if (this.isAnimating) {
this.pendingGameState = data.game_state;
break;
}
const movements = this.stateDiffer.diff(this.previousState, data.game_state); // If local swap animation is running, defer this state update
if (this.swapAnimationInProgress) {
if (movements.length > 0) { this.updateSwapAnimation(data.game_state.discard_top);
this.isAnimating = true; this.pendingGameState = data.game_state;
this.animatePersistentCards(movements, data.game_state).then(() => { break;
this.isAnimating = false;
this.gameState = data.game_state;
this.previousState = JSON.parse(JSON.stringify(data.game_state));
this.renderGame();
if (this.pendingGameState) {
const pending = this.pendingGameState;
this.pendingGameState = null;
this.handleMessage({ type: 'game_state', game_state: pending });
}
});
} else {
this.gameState = data.game_state;
this.previousState = JSON.parse(JSON.stringify(data.game_state));
this.renderGame();
}
} else {
// Legacy animation system - simplified
// Principle: State updates are instant, animations are fire-and-forget
// Exception: Local player's swap animation defers state until complete
// If local swap animation is running, defer this state update
if (this.swapAnimationInProgress) {
this.updateSwapAnimation(data.game_state.discard_top);
this.pendingGameState = data.game_state;
break;
}
const oldState = this.gameState;
const newState = data.game_state;
// Update state FIRST (always)
this.gameState = newState;
// Clear local flip tracking if server confirmed our flips
if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) {
this.locallyFlippedCards = new Set();
}
// Detect and fire animations (non-blocking, errors shouldn't break game)
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error:', e);
}
// Render immediately with new state
this.renderGame();
} }
const oldState = this.gameState;
const newState = data.game_state;
// Update state FIRST (always)
this.gameState = newState;
// Clear local flip tracking if server confirmed our flips
if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) {
this.locallyFlippedCards = new Set();
}
// Detect and fire animations (non-blocking, errors shouldn't break game)
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error:', e);
}
// Render immediately with new state
this.renderGame();
break; break;
case 'your_turn': case 'your_turn':
@ -820,16 +614,7 @@ class GolfGame {
this.hideDrawnCard(); this.hideDrawnCard();
} }
// New card system swap animation // Animate player swapping drawn card with a card in their hand
animateSwapNew(position) {
if (!this.drawnCard) return;
// Send swap immediately - animation happens via state diff
this.send({ type: 'swap', position });
this.drawnCard = null;
this.hideDrawnCard();
}
animateSwap(position) { animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card'); const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position]; const handCardEl = cardElements[position];
@ -838,7 +623,7 @@ class GolfGame {
return; return;
} }
// Check if card is already face-up (no flip needed) // Check if card is already face-up
const myData = this.getMyPlayerData(); const myData = this.getMyPlayerData();
const card = myData?.cards[position]; const card = myData?.cards[position];
const isAlreadyFaceUp = card?.face_up; const isAlreadyFaceUp = card?.face_up;
@ -850,6 +635,7 @@ class GolfGame {
// Set up the animated card at hand position // Set up the animated card at hand position
const swapCard = this.swapCardFromHand; const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front'); const swapCardFront = swapCard.querySelector('.swap-card-front');
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at the hand card location // Position at the hand card location
swapCard.style.left = handRect.left + 'px'; swapCard.style.left = handRect.left + 'px';
@ -862,8 +648,8 @@ class GolfGame {
swapCardFront.innerHTML = ''; swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front'; swapCardFront.className = 'swap-card-front';
// If already face-up, show the card content immediately
if (isAlreadyFaceUp && card) { if (isAlreadyFaceUp && card) {
// FACE-UP CARD: Show card content immediately, then slide to discard
if (card.rank === '★') { if (card.rank === '★') {
swapCardFront.classList.add('joker'); swapCardFront.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹'; const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
@ -873,72 +659,76 @@ class GolfGame {
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit]; const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`; swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`;
} }
swapCard.classList.add('flipping'); // Start showing front immediately swapCard.classList.add('flipping'); // Show front immediately
}
// Hide the actual hand card // Hide the actual hand card and discard
handCardEl.classList.add('swap-out'); handCardEl.classList.add('swap-out');
this.discard.classList.add('swap-to-hand');
this.swapAnimation.classList.remove('hidden');
// Hide the discard (drawn card) // Mark animating
this.discard.classList.add('swap-to-hand'); this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationContentSet = true;
// Show animation overlay // Send swap
this.swapAnimation.classList.remove('hidden'); this.send({ type: 'swap', position });
this.drawnCard = null;
// Mark that we're animating - defer game state renders // Slide to discard
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationFront = swapCardFront;
this.swapAnimationContentSet = isAlreadyFaceUp; // Skip updateSwapAnimation if we already set content
// Send swap immediately so server can respond
this.send({ type: 'swap', position });
this.drawnCard = null;
// Timing depends on whether we need to flip first
const flipDelay = isAlreadyFaceUp ? 0 : 450;
// Step 1: Flip the card over (only if face-down)
if (!isAlreadyFaceUp) {
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('flipping'); swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
}, 50); }, 50);
}
// Step 2: Move to discard position // Complete
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('moving'); this.swapAnimation.classList.add('hidden');
swapCard.style.left = discardRect.left + 'px'; swapCard.classList.remove('flipping', 'moving');
swapCard.style.top = discardRect.top + 'px'; handCardEl.classList.remove('swap-out');
}, flipDelay + 50); this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.hideDrawnCard();
// Step 3: Card has landed - pause to show the card if (this.pendingGameState) {
setTimeout(() => { this.gameState = this.pendingGameState;
swapCard.classList.remove('moving'); this.pendingGameState = null;
}, flipDelay + 400); this.renderGame();
}
}, 500);
} else {
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
// The new card will appear instantly when state updates
// Step 4: Complete animation and render final state // Don't use overlay for face-down - just send swap and let state handle it
setTimeout(() => { // This avoids the clunky "flip to empty front" issue
// Hide animation overlay this.swapAnimationInProgress = true;
this.swapAnimation.classList.add('hidden'); this.swapAnimationCardEl = handCardEl;
swapCard.classList.remove('flipping', 'moving');
// Reset card states
handCardEl.classList.remove('swap-out');
this.discard.classList.remove('swap-to-hand');
// Now allow renders and show the final state
this.swapAnimationInProgress = false;
this.swapAnimationContentSet = false; this.swapAnimationContentSet = false;
this.hideDrawnCard();
// Render the pending game state if we have one // Send swap
if (this.pendingGameState) { this.send({ type: 'swap', position });
this.gameState = this.pendingGameState; this.drawnCard = null;
this.pendingGameState = null;
this.renderGame(); // Brief visual feedback - hide drawn card area
} this.discard.classList.add('swap-to-hand');
}, flipDelay + 900); 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);
}
} }
// Update the animated card with actual card content when server responds // Update the animated card with actual card content when server responds
@ -1327,11 +1117,7 @@ class GolfGame {
// Swap with drawn card // Swap with drawn card
if (this.drawnCard) { if (this.drawnCard) {
if (USE_NEW_CARD_SYSTEM) { this.animateSwap(position);
this.animateSwapNew(position);
} else {
this.animateSwap(position);
}
this.hideToast(); this.hideToast();
return; return;
} }
@ -1347,8 +1133,10 @@ class GolfGame {
} }
nextRound() { nextRound() {
this.clearNextHoleCountdown();
this.send({ type: 'next_round' }); this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden'); this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
} }
newGame() { newGame() {
@ -1387,10 +1175,6 @@ class GolfGame {
this.isHost = false; this.isHost = false;
this.gameState = null; this.gameState = null;
this.previousState = null; this.previousState = null;
// Clear card layer
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear();
}
} }
showWaitingRoom() { showWaitingRoom() {
@ -1417,27 +1201,23 @@ class GolfGame {
this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave'; this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave';
// Update active rules bar // Update active rules bar
this.updateActiveRulesBar(); this.updateActiveRulesBar();
// Clear card layer for new card system
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear();
}
} }
updateActiveRulesBar() { updateActiveRulesBar() {
if (!this.gameState || !this.gameState.active_rules) { if (!this.gameState) {
this.activeRulesBar.classList.add('hidden'); this.activeRulesBar.classList.add('hidden');
return; return;
} }
const rules = this.gameState.active_rules; const rules = this.gameState.active_rules || [];
if (rules.length === 0) { if (rules.length === 0) {
this.activeRulesBar.classList.add('hidden'); // Show "Standard Rules" when no variants selected
return; this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
} else {
this.activeRulesList.innerHTML = rules
.map(rule => `<span class="rule-tag">${rule}</span>`)
.join('');
} }
this.activeRulesList.innerHTML = rules
.map(rule => `<span class="rule-tag">${rule}</span>`)
.join('');
this.activeRulesBar.classList.remove('hidden'); this.activeRulesBar.classList.remove('hidden');
} }
@ -1526,11 +1306,17 @@ class GolfGame {
return; return;
} }
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
if (currentPlayer && currentPlayer.id !== this.playerId) { if (currentPlayer && currentPlayer.id !== this.playerId) {
this.setStatus(`${currentPlayer.name}'s turn`); const prefix = isFinalTurn ? '⚡ Final turn: ' : '';
this.setStatus(`${prefix}${currentPlayer.name}'s turn`);
} else if (this.isMyTurn()) { } else if (this.isMyTurn()) {
this.setStatus('Your turn - draw a card', 'your-turn'); const message = isFinalTurn
? '⚡ Final turn! Draw a card'
: 'Your turn - draw a card';
this.setStatus(message, 'your-turn');
} else { } else {
this.setStatus(''); this.setStatus('');
} }
@ -1646,11 +1432,16 @@ class GolfGame {
const showingScore = this.calculateShowingScore(me.cards); const showingScore = this.calculateShowingScore(me.cards);
this.yourScore.textContent = showingScore; this.yourScore.textContent = showingScore;
// Check if player won the round
const isRoundWinner = this.roundWinnerNames.has(me.name);
this.playerArea.classList.toggle('round-winner', isRoundWinner);
// Update player name in header (truncate if needed) // Update player name in header (truncate if needed)
const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name; const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name;
const checkmark = me.all_face_up ? ' ✓' : ''; const checkmark = me.all_face_up ? ' ✓' : '';
const crownEmoji = isRoundWinner ? ' 👑' : '';
// Set text content before the score span // Set text content before the score span
this.playerHeader.childNodes[0].textContent = displayName + checkmark; this.playerHeader.childNodes[0].textContent = displayName + checkmark + crownEmoji;
} }
// Update discard pile (skip if holding a drawn card) // Update discard pile (skip if holding a drawn card)
@ -1708,25 +1499,21 @@ class GolfGame {
div.classList.add('current-turn'); div.classList.add('current-turn');
} }
const isRoundWinner = this.roundWinnerNames.has(player.name);
if (isRoundWinner) {
div.classList.add('round-winner');
}
const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name;
const showingScore = this.calculateShowingScore(player.cards); const showingScore = this.calculateShowingScore(player.cards);
const crownEmoji = isRoundWinner ? ' 👑' : '';
if (USE_NEW_CARD_SYSTEM) { div.innerHTML = `
// Render empty slots - cards are in card-layer <h4>${displayName}${player.all_face_up ? ' ✓' : ''}${crownEmoji}<span class="opponent-showing">${showingScore}</span></h4>
div.innerHTML = ` <div class="card-grid">
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4> ${player.cards.map(card => this.renderCard(card, false, false)).join('')}
<div class="card-grid"> </div>
${player.cards.map((_, i) => `<div class="card-slot" data-player="${player.id}" data-position="${i}"></div>`).join('')} `;
</div>
`;
} else {
div.innerHTML = `
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
<div class="card-grid">
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
</div>
`;
}
this.opponentsRow.appendChild(div); this.opponentsRow.appendChild(div);
}); });
@ -1736,51 +1523,27 @@ class GolfGame {
if (myData) { if (myData) {
this.playerCards.innerHTML = ''; this.playerCards.innerHTML = '';
if (USE_NEW_CARD_SYSTEM) { myData.cards.forEach((card, index) => {
// Render empty slots - cards are in card-layer // Check if this card was locally flipped (immediate feedback)
myData.cards.forEach((card, index) => { const isLocallyFlipped = this.locallyFlippedCards.has(index);
const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up) ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(index);
const slotEl = document.createElement('div'); // Create a display card that shows face-up if locally flipped
slotEl.className = 'card-slot'; const displayCard = isLocallyFlipped
slotEl.dataset.player = this.playerId; ? { ...card, face_up: true }
slotEl.dataset.position = index; : card;
if (isClickable) slotEl.classList.add('clickable');
if (isSelected) slotEl.classList.add('selected');
slotEl.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(slotEl);
});
// Update persistent card positions and states const isClickable = (
this.updatePersistentCards(); (this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
} else { (this.drawnCard) ||
myData.cards.forEach((card, index) => { (this.waitingForFlip && !card.face_up)
// Check if this card was locally flipped (immediate feedback) );
const isLocallyFlipped = this.locallyFlippedCards.has(index); const isSelected = this.selectedCards.includes(index);
// Create a display card that shows face-up if locally flipped const cardEl = document.createElement('div');
const displayCard = isLocallyFlipped cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
? { ...card, face_up: true } cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
: card; this.playerCards.appendChild(cardEl.firstChild);
});
const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(index);
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(cardEl.firstChild);
});
}
} }
// Show flip prompt for initial flip // Show flip prompt for initial flip
@ -1916,6 +1679,16 @@ class GolfGame {
showScoreboard(scores, isFinal, rankings) { showScoreboard(scores, isFinal, rankings) {
this.scoreTable.innerHTML = ''; this.scoreTable.innerHTML = '';
// Find round winner(s) - lowest round score (not total)
const roundScores = scores.map(s => s.score);
const minRoundScore = Math.min(...roundScores);
this.roundWinnerNames = new Set(
scores.filter(s => s.score === minRoundScore).map(s => s.name)
);
// Re-render to show winner highlights
this.renderGame();
const minScore = Math.min(...scores.map(s => s.total || s.score || 0)); const minScore = Math.min(...scores.map(s => s.total || s.score || 0));
scores.forEach(score => { scores.forEach(score => {
@ -1954,13 +1727,58 @@ class GolfGame {
// Show game buttons // Show game buttons
this.gameButtons.classList.remove('hidden'); this.gameButtons.classList.remove('hidden');
this.newGameBtn.classList.add('hidden');
this.nextRoundBtn.classList.remove('hidden');
if (this.isHost) { // Start countdown for next hole
this.nextRoundBtn.classList.remove('hidden'); this.startNextHoleCountdown();
this.newGameBtn.classList.add('hidden'); }
} else {
this.nextRoundBtn.classList.add('hidden'); startNextHoleCountdown() {
this.newGameBtn.classList.add('hidden'); // Clear any existing countdown
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
}
const COUNTDOWN_SECONDS = 15;
let remaining = COUNTDOWN_SECONDS;
const updateButton = () => {
if (this.isHost) {
this.nextRoundBtn.textContent = `Next Hole (${remaining}s)`;
this.nextRoundBtn.disabled = false;
} else {
this.nextRoundBtn.textContent = `Next hole in ${remaining}s...`;
this.nextRoundBtn.disabled = true;
this.nextRoundBtn.classList.add('waiting');
}
};
updateButton();
this.nextHoleCountdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
// Auto-advance if host
if (this.isHost) {
this.nextRound();
} else {
this.nextRoundBtn.textContent = 'Waiting for host...';
}
} else {
updateButton();
}
}, 1000);
}
clearNextHoleCountdown() {
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
} }
} }

View File

@ -205,17 +205,17 @@
<div class="game-layout"> <div class="game-layout">
<div class="game-main"> <div class="game-main">
<div class="game-header"> <div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div> <div class="header-col header-col-left">
<div class="game-header-center"> <div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div id="active-rules-bar" class="active-rules-bar hidden"> <div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span> <span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span> <span id="active-rules-list" class="rules-list"></span>
</div> </div>
<div class="header-status">
<div id="status-message" class="status-message"></div>
</div>
</div> </div>
<div class="header-buttons"> <div class="header-col header-col-center">
<div id="status-message" class="status-message"></div>
</div>
<div class="header-col header-col-right">
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</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> <button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div> </div>

View File

@ -285,6 +285,23 @@ input::placeholder {
color: #1a472a; color: #1a472a;
} }
/* Pulsing glow for Next Hole button */
#next-round-btn:not(.hidden) {
animation: glow-pulse 1.5s ease-in-out infinite;
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 5px rgba(244, 164, 96, 0.4),
0 0 10px rgba(244, 164, 96, 0.2);
}
50% {
box-shadow: 0 0 15px rgba(244, 164, 96, 0.8),
0 0 30px rgba(244, 164, 96, 0.4),
0 0 45px rgba(244, 164, 96, 0.2);
}
}
.btn-secondary { .btn-secondary {
background: #fff; background: #fff;
color: #1a472a; color: #1a472a;
@ -314,6 +331,14 @@ input::placeholder {
box-shadow: none; box-shadow: none;
} }
/* Waiting state for non-host next hole button */
#next-round-btn.waiting {
animation: none;
background: rgba(244, 164, 96, 0.4);
color: rgba(255, 255, 255, 0.8);
box-shadow: none;
}
.divider { .divider {
text-align: center; text-align: center;
margin: 30px 0; margin: 30px 0;
@ -462,9 +487,9 @@ input::placeholder {
/* Game Screen */ /* Game Screen */
.game-header { .game-header {
display: flex; display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center; align-items: center;
justify-content: space-between;
padding: 10px 20px; padding: 10px 20px;
background: rgba(0,0,0,0.35); background: rgba(0,0,0,0.35);
font-size: 0.9rem; font-size: 0.9rem;
@ -473,29 +498,30 @@ input::placeholder {
box-sizing: border-box; box-sizing: border-box;
} }
.header-col {
display: flex;
align-items: center;
}
.header-col-left {
justify-content: flex-start;
gap: 12px;
}
.header-col-center {
justify-content: center;
}
.header-col-right {
justify-content: flex-end;
gap: 8px;
}
.game-header .round-info { .game-header .round-info {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.game-header-center {
display: flex;
align-items: center;
gap: 40px;
}
.game-header .turn-info {
font-weight: 600;
color: #f4a460;
white-space: nowrap;
}
.game-header .header-buttons {
display: flex;
align-items: center;
gap: 8px;
}
#leave-game-btn { #leave-game-btn {
padding: 6px 12px; padding: 6px 12px;
font-size: 0.8rem; font-size: 0.8rem;
@ -521,7 +547,6 @@ input::placeholder {
.active-rules-bar { .active-rules-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 6px; gap: 6px;
font-size: 0.85rem; font-size: 0.85rem;
} }
@ -550,6 +575,11 @@ input::placeholder {
font-weight: 600; font-weight: 600;
} }
.active-rules-bar .rule-tag.standard {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
/* Card Styles */ /* Card Styles */
.card { .card {
width: clamp(65px, 5.5vw, 100px); width: clamp(65px, 5.5vw, 100px);
@ -1027,15 +1057,15 @@ input::placeholder {
box-shadow: 0 0 0 2px #f4a460; box-shadow: 0 0 0 2px #f4a460;
} }
/* Toast Notification */ /* Round winner highlight */
/* Header status area */ .opponent-area.round-winner h4,
.header-status { .player-area.round-winner h4 {
display: flex; background: rgba(200, 255, 50, 0.7);
align-items: center; box-shadow: 0 0 15px rgba(200, 255, 50, 0.9), 0 0 30px rgba(200, 255, 50, 0.5);
justify-content: center; color: #0a2a10;
min-width: 200px;
} }
/* Status message in header */
.status-message { .status-message {
padding: 6px 16px; padding: 6px 16px;
border-radius: 4px; border-radius: 4px;

Binary file not shown.