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
// Feature flag for new persistent card system
// Disabled - using improved legacy system instead
const USE_NEW_CARD_SYSTEM = false;
class GolfGame {
constructor() {
this.ws = null;
@ -19,37 +15,24 @@ class GolfGame {
this.soundEnabled = true;
this.audioCtx = null;
// Swap animation state (legacy)
// Swap animation state
this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null;
this.swapAnimationFront = 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)
this.locallyFlippedCards = new Set();
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
this.initElements();
this.initAudio();
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() {
@ -128,167 +111,10 @@ class GolfGame {
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) {
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() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
@ -345,6 +171,7 @@ class GolfGame {
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-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.scoreboard = document.getElementById('scoreboard');
@ -479,6 +306,11 @@ class GolfGame {
case 'game_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;
// Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state));
@ -487,48 +319,11 @@ class GolfGame {
this.animatingPositions = new Set();
this.playSound('shuffle');
this.showGameScreen();
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear(); // Clear any leftover cards
}
this.renderGame();
// Initialize persistent cards after DOM is ready
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
setTimeout(() => this.initializePersistentCards(), 50);
}
break;
case 'game_state':
if (USE_NEW_CARD_SYSTEM) {
// New card system: animate persistent cards directly
if (this.isAnimating) {
this.pendingGameState = data.game_state;
break;
}
const movements = this.stateDiffer.diff(this.previousState, data.game_state);
if (movements.length > 0) {
this.isAnimating = true;
this.animatePersistentCards(movements, data.game_state).then(() => {
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
// 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
@ -558,7 +353,6 @@ class GolfGame {
// Render immediately with new state
this.renderGame();
}
break;
case 'your_turn':
@ -820,16 +614,7 @@ class GolfGame {
this.hideDrawnCard();
}
// New card system swap animation
animateSwapNew(position) {
if (!this.drawnCard) return;
// Send swap immediately - animation happens via state diff
this.send({ type: 'swap', position });
this.drawnCard = null;
this.hideDrawnCard();
}
// Animate player swapping drawn card with a card in their hand
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
@ -838,7 +623,7 @@ class GolfGame {
return;
}
// Check if card is already face-up (no flip needed)
// Check if card is already face-up
const myData = this.getMyPlayerData();
const card = myData?.cards[position];
const isAlreadyFaceUp = card?.face_up;
@ -850,6 +635,7 @@ class GolfGame {
// Set up the animated card at hand position
const swapCard = this.swapCardFromHand;
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';
@ -862,8 +648,8 @@ class GolfGame {
swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front';
// If already face-up, show the card content immediately
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' ? '🐉' : '👹';
@ -873,72 +659,76 @@ class GolfGame {
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
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');
// Hide the discard (drawn card)
this.discard.classList.add('swap-to-hand');
// Show animation overlay
this.swapAnimation.classList.remove('hidden');
// Mark that we're animating - defer game state renders
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationFront = swapCardFront;
this.swapAnimationContentSet = isAlreadyFaceUp; // Skip updateSwapAnimation if we already set content
this.swapAnimationContentSet = true;
// Send swap immediately so server can respond
// Send swap
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(() => {
swapCard.classList.add('flipping');
}, 50);
}
// Step 2: Move to discard position
// Slide to discard
setTimeout(() => {
swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
}, flipDelay + 50);
}, 50);
// Step 3: Card has landed - pause to show the card
// Complete
setTimeout(() => {
swapCard.classList.remove('moving');
}, flipDelay + 400);
// Step 4: Complete animation and render final state
setTimeout(() => {
// Hide animation overlay
this.swapAnimation.classList.add('hidden');
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.hideDrawnCard();
// Render the pending game state if we have one
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}, flipDelay + 900);
}, 500);
} else {
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
// The new card will appear instantly when state updates
// 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;
this.swapAnimationContentSet = false;
// Send swap
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);
}
}
// Update the animated card with actual card content when server responds
@ -1327,11 +1117,7 @@ class GolfGame {
// Swap with drawn card
if (this.drawnCard) {
if (USE_NEW_CARD_SYSTEM) {
this.animateSwapNew(position);
} else {
this.animateSwap(position);
}
this.hideToast();
return;
}
@ -1347,8 +1133,10 @@ class GolfGame {
}
nextRound() {
this.clearNextHoleCountdown();
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
}
newGame() {
@ -1387,10 +1175,6 @@ class GolfGame {
this.isHost = false;
this.gameState = null;
this.previousState = null;
// Clear card layer
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear();
}
}
showWaitingRoom() {
@ -1417,27 +1201,23 @@ class GolfGame {
this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave';
// Update active rules bar
this.updateActiveRulesBar();
// Clear card layer for new card system
if (USE_NEW_CARD_SYSTEM && this.cardManager) {
this.cardManager.clear();
}
}
updateActiveRulesBar() {
if (!this.gameState || !this.gameState.active_rules) {
if (!this.gameState) {
this.activeRulesBar.classList.add('hidden');
return;
}
const rules = this.gameState.active_rules;
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
this.activeRulesBar.classList.add('hidden');
return;
}
// Show "Standard Rules" when no variants selected
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.activeRulesBar.classList.remove('hidden');
}
@ -1526,11 +1306,17 @@ class GolfGame {
return;
}
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
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()) {
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 {
this.setStatus('');
}
@ -1646,11 +1432,16 @@ class GolfGame {
const showingScore = this.calculateShowingScore(me.cards);
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)
const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name;
const checkmark = me.all_face_up ? ' ✓' : '';
const crownEmoji = isRoundWinner ? ' 👑' : '';
// 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)
@ -1708,25 +1499,21 @@ class GolfGame {
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 showingScore = this.calculateShowingScore(player.cards);
const crownEmoji = isRoundWinner ? ' 👑' : '';
if (USE_NEW_CARD_SYSTEM) {
// Render empty slots - cards are in card-layer
div.innerHTML = `
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
<div class="card-grid">
${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>
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}${crownEmoji}<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);
});
@ -1736,29 +1523,6 @@ class GolfGame {
if (myData) {
this.playerCards.innerHTML = '';
if (USE_NEW_CARD_SYSTEM) {
// Render empty slots - cards are in card-layer
myData.cards.forEach((card, 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');
slotEl.className = 'card-slot';
slotEl.dataset.player = this.playerId;
slotEl.dataset.position = index;
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
this.updatePersistentCards();
} else {
myData.cards.forEach((card, index) => {
// Check if this card was locally flipped (immediate feedback)
const isLocallyFlipped = this.locallyFlippedCards.has(index);
@ -1781,7 +1545,6 @@ class GolfGame {
this.playerCards.appendChild(cardEl.firstChild);
});
}
}
// Show flip prompt for initial flip
// Show flip prompt during initial flip phase
@ -1916,6 +1679,16 @@ class GolfGame {
showScoreboard(scores, isFinal, rankings) {
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));
scores.forEach(score => {
@ -1954,13 +1727,58 @@ class GolfGame {
// Show game buttons
this.gameButtons.classList.remove('hidden');
if (this.isHost) {
this.newGameBtn.classList.add('hidden');
this.nextRoundBtn.classList.remove('hidden');
this.newGameBtn.classList.add('hidden');
// Start countdown for next hole
this.startNextHoleCountdown();
}
startNextHoleCountdown() {
// 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.classList.add('hidden');
this.newGameBtn.classList.add('hidden');
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-main">
<div class="game-header">
<div class="header-col header-col-left">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div class="game-header-center">
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="header-status">
</div>
<div class="header-col header-col-center">
<div id="status-message" class="status-message"></div>
</div>
</div>
<div class="header-buttons">
<div class="header-col header-col-right">
<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>

View File

@ -285,6 +285,23 @@ input::placeholder {
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 {
background: #fff;
color: #1a472a;
@ -314,6 +331,14 @@ input::placeholder {
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 {
text-align: center;
margin: 30px 0;
@ -462,9 +487,9 @@ input::placeholder {
/* Game Screen */
.game-header {
display: flex;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
font-size: 0.9rem;
@ -473,29 +498,30 @@ input::placeholder {
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 {
font-weight: 600;
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 {
padding: 6px 12px;
font-size: 0.8rem;
@ -521,7 +547,6 @@ input::placeholder {
.active-rules-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.85rem;
}
@ -550,6 +575,11 @@ input::placeholder {
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 {
width: clamp(65px, 5.5vw, 100px);
@ -1027,15 +1057,15 @@ input::placeholder {
box-shadow: 0 0 0 2px #f4a460;
}
/* Toast Notification */
/* Header status area */
.header-status {
display: flex;
align-items: center;
justify-content: center;
min-width: 200px;
/* Round winner highlight */
.opponent-area.round-winner h4,
.player-area.round-winner h4 {
background: rgba(200, 255, 50, 0.7);
box-shadow: 0 0 15px rgba(200, 255, 50, 0.9), 0 0 30px rgba(200, 255, 50, 0.5);
color: #0a2a10;
}
/* Status message in header */
.status-message {
padding: 6px 16px;
border-radius: 4px;

Binary file not shown.