// Golf Card Game - Client Application class GolfGame { constructor() { this.ws = null; this.playerId = null; this.roomCode = null; this.isHost = false; this.gameState = null; this.drawnCard = null; this.selectedCards = []; this.waitingForFlip = false; this.currentPlayers = []; this.allProfiles = []; this.soundEnabled = true; this.audioCtx = null; this.initElements(); this.initAudio(); this.bindEvents(); } initAudio() { // Initialize audio context on first user interaction const initCtx = () => { if (!this.audioCtx) { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } document.removeEventListener('click', initCtx); }; document.addEventListener('click', initCtx); } playSound(type = 'click') { if (!this.soundEnabled || !this.audioCtx) return; const ctx = this.audioCtx; const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); if (type === 'click') { oscillator.frequency.setValueAtTime(600, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05); gainNode.gain.setValueAtTime(0.1, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.05); } else if (type === 'card') { oscillator.frequency.setValueAtTime(800, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08); gainNode.gain.setValueAtTime(0.08, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.08); } else if (type === 'success') { oscillator.frequency.setValueAtTime(400, ctx.currentTime); oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1); gainNode.gain.setValueAtTime(0.1, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.2); } else if (type === 'flip') { // Sharp quick click for card flips oscillator.type = 'square'; oscillator.frequency.setValueAtTime(1800, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02); gainNode.gain.setValueAtTime(0.12, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.025); } else if (type === 'shuffle') { // Multiple quick sounds to simulate shuffling for (let i = 0; i < 8; i++) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'square'; const time = ctx.currentTime + i * 0.06; osc.frequency.setValueAtTime(200 + Math.random() * 400, time); gain.gain.setValueAtTime(0.03, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); osc.start(time); osc.stop(time + 0.05); } return; // Early return since we don't use the main oscillator } } toggleSound() { this.soundEnabled = !this.soundEnabled; this.muteBtn.textContent = this.soundEnabled ? 'šŸ”Š' : 'šŸ”‡'; this.playSound('click'); } initElements() { // Screens this.lobbyScreen = document.getElementById('lobby-screen'); this.waitingScreen = document.getElementById('waiting-screen'); this.gameScreen = document.getElementById('game-screen'); // Lobby elements this.playerNameInput = document.getElementById('player-name'); this.roomCodeInput = document.getElementById('room-code'); this.createRoomBtn = document.getElementById('create-room-btn'); this.joinRoomBtn = document.getElementById('join-room-btn'); this.lobbyError = document.getElementById('lobby-error'); // Waiting room elements this.displayRoomCode = document.getElementById('display-room-code'); this.copyRoomCodeBtn = document.getElementById('copy-room-code'); this.playersList = document.getElementById('players-list'); this.hostSettings = document.getElementById('host-settings'); this.waitingMessage = document.getElementById('waiting-message'); this.numDecksSelect = document.getElementById('num-decks'); this.deckRecommendation = document.getElementById('deck-recommendation'); this.numRoundsSelect = document.getElementById('num-rounds'); this.initialFlipsSelect = document.getElementById('initial-flips'); this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard'); this.knockPenaltyCheckbox = document.getElementById('knock-penalty'); // House Rules - Point Modifiers this.superKingsCheckbox = document.getElementById('super-kings'); this.tenPennyCheckbox = document.getElementById('ten-penny'); // House Rules - Bonuses/Penalties this.knockBonusCheckbox = document.getElementById('knock-bonus'); this.underdogBonusCheckbox = document.getElementById('underdog-bonus'); this.tiedShameCheckbox = document.getElementById('tied-shame'); this.blackjackCheckbox = document.getElementById('blackjack'); this.wolfpackCheckbox = document.getElementById('wolfpack'); this.startGameBtn = document.getElementById('start-game-btn'); this.leaveRoomBtn = document.getElementById('leave-room-btn'); this.addCpuBtn = document.getElementById('add-cpu-btn'); this.removeCpuBtn = document.getElementById('remove-cpu-btn'); this.cpuSelectModal = document.getElementById('cpu-select-modal'); this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid'); this.cancelCpuBtn = document.getElementById('cancel-cpu-btn'); this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn'); // Game elements this.currentRoundSpan = document.getElementById('current-round'); this.totalRoundsSpan = document.getElementById('total-rounds'); this.turnInfo = document.getElementById('turn-info'); this.yourScore = document.getElementById('your-score'); this.muteBtn = document.getElementById('mute-btn'); this.opponentsRow = document.getElementById('opponents-row'); this.deck = document.getElementById('deck'); this.discard = document.getElementById('discard'); this.discardContent = document.getElementById('discard-content'); this.drawnCardArea = document.getElementById('drawn-card-area'); this.drawnCardEl = document.getElementById('drawn-card'); this.discardBtn = document.getElementById('discard-btn'); this.playerCards = document.getElementById('player-cards'); this.flipPrompt = document.getElementById('flip-prompt'); this.toast = document.getElementById('toast'); this.scoreboard = document.getElementById('scoreboard'); this.scoreTable = document.getElementById('score-table').querySelector('tbody'); this.standingsList = document.getElementById('standings-list'); this.gameButtons = document.getElementById('game-buttons'); this.nextRoundBtn = document.getElementById('next-round-btn'); this.newGameBtn = document.getElementById('new-game-btn'); this.leaveGameBtn = document.getElementById('leave-game-btn'); this.activeRulesBar = document.getElementById('active-rules-bar'); this.activeRulesList = document.getElementById('active-rules-list'); } bindEvents() { this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); }); this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); }); this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); }); this.leaveRoomBtn.addEventListener('click', () => { this.playSound('click'); this.leaveRoom(); }); this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); }); this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); }); this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); }); this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); }); this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); }); this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); }); this.removeCpuBtn.addEventListener('click', () => { this.playSound('click'); this.removeCpu(); }); this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); }); this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); }); this.muteBtn.addEventListener('click', () => this.toggleSound()); this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); }); // Copy room code to clipboard this.copyRoomCodeBtn.addEventListener('click', () => { this.playSound('click'); this.copyRoomCode(); }); // Enter key handlers this.playerNameInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.createRoomBtn.click(); }); this.roomCodeInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.joinRoomBtn.click(); }); // Auto-uppercase room code this.roomCodeInput.addEventListener('input', (e) => { e.target.value = e.target.value.toUpperCase(); }); // Update deck recommendation when deck selection changes this.numDecksSelect.addEventListener('change', () => { const playerCount = this.currentPlayers ? this.currentPlayers.length : 0; this.updateDeckRecommendation(playerCount); }); // Toggle scoreboard collapse on mobile const scoreboardTitle = this.scoreboard.querySelector('h4'); if (scoreboardTitle) { scoreboardTitle.addEventListener('click', () => { if (window.innerWidth <= 700) { this.scoreboard.classList.toggle('collapsed'); } }); } } connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host || 'localhost:8000'; const wsUrl = `${protocol}//${host}/ws`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('Connected to server'); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleMessage(data); }; this.ws.onclose = () => { console.log('Disconnected from server'); this.showError('Connection lost. Please refresh the page.'); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.showError('Connection error. Please try again.'); }; } send(message) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } handleMessage(data) { console.log('Received:', data); switch (data.type) { case 'room_created': this.playerId = data.player_id; this.roomCode = data.room_code; this.isHost = true; this.showWaitingRoom(); break; case 'room_joined': this.playerId = data.player_id; this.roomCode = data.room_code; this.isHost = false; this.showWaitingRoom(); break; case 'player_joined': this.updatePlayersList(data.players); this.currentPlayers = data.players; break; case 'cpu_profiles': this.allProfiles = data.profiles; this.renderCpuSelect(); break; case 'player_left': this.updatePlayersList(data.players); this.currentPlayers = data.players; break; case 'game_started': case 'round_started': this.gameState = data.game_state; this.playSound('shuffle'); this.showGameScreen(); this.renderGame(); break; case 'game_state': this.gameState = data.game_state; this.renderGame(); break; case 'your_turn': this.showToast('Your turn! Draw a card', 'your-turn'); break; case 'card_drawn': this.drawnCard = data.card; this.showDrawnCard(); this.showToast('Swap with a card or discard', '', 3000); break; case 'can_flip': this.waitingForFlip = true; this.showToast('Flip a face-down card', '', 3000); this.renderGame(); break; case 'round_over': this.showScoreboard(data.scores, false, data.rankings); break; case 'game_over': this.showScoreboard(data.final_scores, true, data.rankings); break; case 'game_ended': // Host ended the game or player was kicked this.ws.close(); this.showLobby(); if (data.reason) { this.showError(data.reason); } break; case 'error': this.showError(data.message); break; } } // Room Actions createRoom() { const name = this.playerNameInput.value.trim() || 'Player'; this.connect(); this.ws.onopen = () => { this.send({ type: 'create_room', player_name: name }); }; } joinRoom() { const name = this.playerNameInput.value.trim() || 'Player'; const code = this.roomCodeInput.value.trim().toUpperCase(); if (code.length !== 4) { this.showError('Please enter a 4-letter room code'); return; } this.connect(); this.ws.onopen = () => { this.send({ type: 'join_room', room_code: code, player_name: name }); }; } leaveRoom() { this.send({ type: 'leave_room' }); this.ws.close(); this.showLobby(); } copyRoomCode() { if (!this.roomCode) return; navigator.clipboard.writeText(this.roomCode).then(() => { // Show brief visual feedback const originalText = this.copyRoomCodeBtn.textContent; this.copyRoomCodeBtn.textContent = 'āœ“'; setTimeout(() => { this.copyRoomCodeBtn.textContent = originalText; }, 1500); }).catch(err => { console.error('Failed to copy room code:', err); // Fallback: select the text for manual copy const range = document.createRange(); range.selectNode(this.displayRoomCode); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); }); } startGame() { const decks = parseInt(this.numDecksSelect.value); const rounds = parseInt(this.numRoundsSelect.value); const initial_flips = parseInt(this.initialFlipsSelect.value); // Standard options const flip_on_discard = this.flipOnDiscardCheckbox.checked; const knock_penalty = this.knockPenaltyCheckbox.checked; // Joker mode (radio buttons) const joker_mode = document.querySelector('input[name="joker-mode"]:checked').value; const use_jokers = joker_mode !== 'none'; const lucky_swing = joker_mode === 'lucky-swing'; const eagle_eye = joker_mode === 'eagle-eye'; // House Rules - Point Modifiers const super_kings = this.superKingsCheckbox.checked; const ten_penny = this.tenPennyCheckbox.checked; // House Rules - Bonuses/Penalties const knock_bonus = this.knockBonusCheckbox.checked; const underdog_bonus = this.underdogBonusCheckbox.checked; const tied_shame = this.tiedShameCheckbox.checked; const blackjack = this.blackjackCheckbox.checked; const wolfpack = this.wolfpackCheckbox.checked; this.send({ type: 'start_game', decks, rounds, initial_flips, flip_on_discard, knock_penalty, use_jokers, lucky_swing, super_kings, ten_penny, knock_bonus, underdog_bonus, tied_shame, blackjack, eagle_eye, wolfpack }); } showCpuSelect() { // Request available profiles from server this.selectedCpus = new Set(); this.send({ type: 'get_cpu_profiles' }); this.cpuSelectModal.classList.remove('hidden'); } hideCpuSelect() { this.cpuSelectModal.classList.add('hidden'); this.selectedCpus = new Set(); } renderCpuSelect() { if (!this.allProfiles) return; // Get names of CPUs already in the game const usedNames = new Set( (this.currentPlayers || []) .filter(p => p.is_cpu) .map(p => p.name) ); this.cpuProfilesGrid.innerHTML = ''; this.allProfiles.forEach(profile => { const div = document.createElement('div'); const isUsed = usedNames.has(profile.name); const isSelected = this.selectedCpus && this.selectedCpus.has(profile.name); div.className = 'profile-card' + (isUsed ? ' unavailable' : '') + (isSelected ? ' selected' : ''); const avatar = this.getCpuAvatar(profile.name); const checkbox = isUsed ? '' : `
${isSelected ? 'āœ“' : ''}
`; div.innerHTML = ` ${checkbox}
${avatar}
${profile.name}
${profile.style}
${isUsed ? '
In Game
' : ''} `; if (!isUsed) { div.addEventListener('click', () => this.toggleCpuSelection(profile.name)); } this.cpuProfilesGrid.appendChild(div); }); this.updateAddCpuButton(); } getCpuAvatar(name) { const avatars = { 'Sofia': ``, 'Maya': ``, 'Priya': ``, 'Marcus': ``, 'Kenji': ``, 'Diego': ``, 'River': ``, 'Sage': `` }; return avatars[name] || ``; } toggleCpuSelection(profileName) { if (!this.selectedCpus) this.selectedCpus = new Set(); if (this.selectedCpus.has(profileName)) { this.selectedCpus.delete(profileName); } else { this.selectedCpus.add(profileName); } this.renderCpuSelect(); } updateAddCpuButton() { const count = this.selectedCpus ? this.selectedCpus.size : 0; this.addSelectedCpusBtn.textContent = count > 0 ? `Add ${count} CPU${count > 1 ? 's' : ''}` : 'Add'; this.addSelectedCpusBtn.disabled = count === 0; } addSelectedCpus() { if (!this.selectedCpus || this.selectedCpus.size === 0) return; this.selectedCpus.forEach(profileName => { this.send({ type: 'add_cpu', profile_name: profileName }); }); this.hideCpuSelect(); } removeCpu() { this.send({ type: 'remove_cpu' }); } // Game Actions drawFromDeck() { if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return; if (this.gameState.waiting_for_initial_flip) return; this.send({ type: 'draw', source: 'deck' }); } drawFromDiscard() { if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return; if (this.gameState.waiting_for_initial_flip) return; if (!this.gameState.discard_top) return; this.send({ type: 'draw', source: 'discard' }); } discardDrawn() { if (!this.drawnCard) return; this.send({ type: 'discard' }); this.drawnCard = null; this.hideDrawnCard(); this.hideToast(); } swapCard(position) { if (!this.drawnCard) return; this.send({ type: 'swap', position }); this.drawnCard = null; this.hideDrawnCard(); } flipCard(position) { this.send({ type: 'flip_card', position }); this.waitingForFlip = false; } handleCardClick(position) { const myData = this.getMyPlayerData(); if (!myData) return; const card = myData.cards[position]; // Initial flip phase if (this.gameState.waiting_for_initial_flip) { if (card.face_up) return; this.playSound('flip'); const requiredFlips = this.gameState.initial_flips || 2; if (this.selectedCards.includes(position)) { this.selectedCards = this.selectedCards.filter(p => p !== position); } else { this.selectedCards.push(position); } if (this.selectedCards.length === requiredFlips) { this.send({ type: 'flip_initial', positions: this.selectedCards }); this.selectedCards = []; this.hideToast(); } else { const remaining = requiredFlips - this.selectedCards.length; this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); } this.renderGame(); return; } // Swap with drawn card if (this.drawnCard) { this.swapCard(position); this.hideToast(); return; } // Flip after discarding from deck if (this.waitingForFlip && !card.face_up) { this.flipCard(position); this.hideToast(); return; } } nextRound() { this.send({ type: 'next_round' }); this.gameButtons.classList.add('hidden'); } newGame() { this.leaveRoom(); } leaveGame() { if (this.isHost) { // Host ending game affects everyone if (confirm('End game for all players?')) { this.send({ type: 'end_game' }); } } else { // Regular player just leaves if (confirm('Leave this game?')) { this.send({ type: 'leave_game' }); this.ws.close(); this.showLobby(); } } } // UI Helpers showScreen(screen) { this.lobbyScreen.classList.remove('active'); this.waitingScreen.classList.remove('active'); this.gameScreen.classList.remove('active'); screen.classList.add('active'); } showLobby() { this.showScreen(this.lobbyScreen); this.lobbyError.textContent = ''; this.roomCode = null; this.playerId = null; this.isHost = false; this.gameState = null; } showWaitingRoom() { this.showScreen(this.waitingScreen); this.displayRoomCode.textContent = this.roomCode; if (this.isHost) { this.hostSettings.classList.remove('hidden'); this.waitingMessage.classList.add('hidden'); } else { this.hostSettings.classList.add('hidden'); this.waitingMessage.classList.remove('hidden'); } } showGameScreen() { this.showScreen(this.gameScreen); this.gameButtons.classList.add('hidden'); this.drawnCard = null; this.selectedCards = []; this.waitingForFlip = false; // Update leave button text based on role this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave'; // Update active rules bar this.updateActiveRulesBar(); } updateActiveRulesBar() { if (!this.gameState || !this.gameState.active_rules) { this.activeRulesBar.classList.add('hidden'); return; } const rules = this.gameState.active_rules; if (rules.length === 0) { this.activeRulesBar.classList.add('hidden'); return; } this.activeRulesList.innerHTML = rules .map(rule => `${rule}`) .join(''); this.activeRulesBar.classList.remove('hidden'); } showError(message) { this.lobbyError.textContent = message; } updatePlayersList(players) { this.playersList.innerHTML = ''; players.forEach(player => { const li = document.createElement('li'); let badges = ''; if (player.is_host) badges += 'HOST'; if (player.is_cpu) badges += 'CPU'; let nameDisplay = player.name; if (player.style) { nameDisplay += ` (${player.style})`; } li.innerHTML = ` ${nameDisplay} ${badges} `; if (player.id === this.playerId) { li.style.background = 'rgba(244, 164, 96, 0.3)'; } this.playersList.appendChild(li); if (player.id === this.playerId && player.is_host) { this.isHost = true; this.hostSettings.classList.remove('hidden'); this.waitingMessage.classList.add('hidden'); } }); // Auto-select 2 decks when reaching 4+ players (host only) const prevCount = this.currentPlayers ? this.currentPlayers.length : 0; if (this.isHost && prevCount < 4 && players.length >= 4) { this.numDecksSelect.value = '2'; } // Update deck recommendation visibility this.updateDeckRecommendation(players.length); } updateDeckRecommendation(playerCount) { if (!this.isHost || !this.deckRecommendation) return; const decks = parseInt(this.numDecksSelect.value); // Show recommendation if 4+ players and only 1 deck selected if (playerCount >= 4 && decks < 2) { this.deckRecommendation.classList.remove('hidden'); } else { this.deckRecommendation.classList.add('hidden'); } } isMyTurn() { return this.gameState && this.gameState.current_player_id === this.playerId; } getMyPlayerData() { if (!this.gameState) return null; return this.gameState.players.find(p => p.id === this.playerId); } showToast(message, type = '', duration = 2500) { this.toast.textContent = message; this.toast.className = 'toast' + (type ? ' ' + type : ''); clearTimeout(this.toastTimeout); this.toastTimeout = setTimeout(() => { this.toast.classList.add('hidden'); }, duration); } hideToast() { this.toast.classList.add('hidden'); clearTimeout(this.toastTimeout); } showDrawnCard() { this.drawnCardArea.classList.remove('hidden'); // Drawn card is always revealed to the player, so render directly const card = this.drawnCard; this.drawnCardEl.className = 'card card-front'; // Handle jokers specially if (card.rank === 'ā˜…') { this.drawnCardEl.innerHTML = 'ā˜…
JOKER'; this.drawnCardEl.classList.add('joker'); } else { this.drawnCardEl.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; if (this.isRedSuit(card.suit)) { this.drawnCardEl.classList.add('red'); } else { this.drawnCardEl.classList.add('black'); } } } hideDrawnCard() { this.drawnCardArea.classList.add('hidden'); } isRedSuit(suit) { return suit === 'hearts' || suit === 'diamonds'; } calculateShowingScore(cards) { if (!cards || cards.length !== 6) return 0; // Use card values from server (includes house rules) or defaults const cardValues = this.gameState?.card_values || { 'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, 'ā˜…': -2 }; const getCardValue = (card) => { if (!card.face_up) return 0; return cardValues[card.rank] ?? 0; }; // Check for column pairs (cards in same column cancel out if matching) let total = 0; for (let col = 0; col < 3; col++) { const topCard = cards[col]; const bottomCard = cards[col + 3]; const topUp = topCard.face_up; const bottomUp = bottomCard.face_up; // If both face up and matching rank, they cancel (score 0) if (topUp && bottomUp && topCard.rank === bottomCard.rank) { // Matching pair = 0 points for both continue; } // Otherwise add individual values total += getCardValue(topCard); total += getCardValue(bottomCard); } return total; } getSuitSymbol(suit) { const symbols = { hearts: '♄', diamonds: '♦', clubs: '♣', spades: 'ā™ ' }; return symbols[suit] || ''; } renderCardContent(card) { if (!card || !card.face_up) return ''; // Jokers show star symbol without suit if (card.rank === 'ā˜…') { return 'ā˜…
JOKER'; } return `${card.rank}
${this.getSuitSymbol(card.suit)}`; } renderGame() { if (!this.gameState) return; // Update header this.currentRoundSpan.textContent = this.gameState.current_round; this.totalRoundsSpan.textContent = this.gameState.total_rounds; // Update turn info const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); if (currentPlayer) { if (currentPlayer.id === this.playerId) { this.turnInfo.textContent = "Your turn!"; this.turnInfo.style.color = "#f4a460"; } else { this.turnInfo.textContent = `${currentPlayer.name}'s turn`; this.turnInfo.style.color = "#fff"; } } // Update your score (points currently showing on your cards) const me = this.gameState.players.find(p => p.id === this.playerId); if (me) { // Calculate visible score from face-up cards const showingScore = this.calculateShowingScore(me.cards); this.yourScore.textContent = showingScore; } // Update discard pile 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); } this.lastDiscardKey = cardKey; 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 { this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker'); this.discardContent.innerHTML = ''; this.lastDiscardKey = null; } // Update deck/discard clickability and visual state const hasDrawn = this.drawnCard || this.gameState.has_drawn_card; const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip; this.deck.classList.toggle('clickable', canDraw); this.deck.classList.toggle('disabled', hasDrawn); this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); this.discard.classList.toggle('disabled', hasDrawn); // Render opponents in a single row const opponents = this.gameState.players.filter(p => p.id !== this.playerId); this.opponentsRow.innerHTML = ''; opponents.forEach((player) => { const div = document.createElement('div'); div.className = 'opponent-area'; if (player.id === this.gameState.current_player_id) { div.classList.add('current-turn'); } const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const showingScore = this.calculateShowingScore(player.cards); div.innerHTML = `

${displayName}${player.all_face_up ? ' āœ“' : ''}${showingScore}

${player.cards.map(card => this.renderCard(card, false, false)).join('')}
`; this.opponentsRow.appendChild(div); }); // Render player's cards const myData = this.getMyPlayerData(); if (myData) { this.playerCards.innerHTML = ''; 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 cardEl = document.createElement('div'); cardEl.innerHTML = this.renderCard(card, isClickable, isSelected); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); this.playerCards.appendChild(cardEl.firstChild); }); } // Show flip prompt for initial flip if (this.gameState.waiting_for_initial_flip) { const requiredFlips = this.gameState.initial_flips || 2; const remaining = requiredFlips - this.selectedCards.length; if (remaining > 0) { this.flipPrompt.textContent = `Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`; this.flipPrompt.classList.remove('hidden'); } else { this.flipPrompt.classList.add('hidden'); } } else { this.flipPrompt.classList.add('hidden'); } // Disable discard button if can't discard (must_swap_discard rule) if (this.drawnCard && !this.gameState.can_discard) { this.discardBtn.disabled = true; this.discardBtn.classList.add('disabled'); } else { this.discardBtn.disabled = false; this.discardBtn.classList.remove('disabled'); } // Update scoreboard panel this.updateScorePanel(); } updateScorePanel() { if (!this.gameState) return; // Update standings (left panel) this.updateStandings(); // 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) { tr.classList.add('current-player'); } // Truncate long names const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const roundScore = player.score !== null ? player.score : '-'; const roundsWon = player.rounds_won || 0; tr.innerHTML = ` ${displayName} ${roundScore} ${player.total_score} ${roundsWon} `; this.scoreTable.appendChild(tr); }); } updateStandings() { if (!this.gameState || !this.standingsList) return; // Sort by total points (lowest wins) - top 4 const byPoints = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score).slice(0, 4); // Sort by holes won (most wins) - top 4 const byHoles = [...this.gameState.players].sort((a, b) => b.rounds_won - a.rounds_won).slice(0, 4); // Build points ranking let pointsRank = 0; let prevPoints = null; const pointsHtml = byPoints.map((p, i) => { if (p.total_score !== prevPoints) { pointsRank = i; prevPoints = p.total_score; } const medal = pointsRank === 0 ? 'šŸ„‡' : pointsRank === 1 ? '🄈' : pointsRank === 2 ? 'šŸ„‰' : '4.'; const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; return `
${medal}${name}${p.total_score}pt
`; }).join(''); // Build holes won ranking let holesRank = 0; let prevHoles = null; const holesHtml = byHoles.map((p, i) => { if (p.rounds_won !== prevHoles) { holesRank = i; prevHoles = p.rounds_won; } const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? 'šŸ„‡' : holesRank === 1 ? '🄈' : holesRank === 2 ? 'šŸ„‰' : '4.'; const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; return `
${medal}${name}${p.rounds_won}W
`; }).join(''); this.standingsList.innerHTML = `
By Score
${pointsHtml}
By Holes
${holesHtml}
`; } renderCard(card, clickable, selected) { let classes = 'card'; let content = ''; if (card.face_up) { classes += ' card-front'; if (card.rank === 'ā˜…') { classes += ' joker'; } else if (this.isRedSuit(card.suit)) { classes += ' red'; } else { classes += ' black'; } content = this.renderCardContent(card); } else { classes += ' card-back'; } if (clickable) classes += ' clickable'; if (selected) classes += ' selected'; return `
${content}
`; } showScoreboard(scores, isFinal, rankings) { this.scoreTable.innerHTML = ''; const minScore = Math.min(...scores.map(s => s.total || s.score || 0)); scores.forEach(score => { const tr = document.createElement('tr'); const total = score.total !== undefined ? score.total : score.score; const roundScore = score.score !== undefined ? score.score : '-'; const roundsWon = score.rounds_won || 0; // Truncate long names const displayName = score.name.length > 12 ? score.name.substring(0, 11) + '…' : score.name; if (total === minScore) { tr.classList.add('winner'); } tr.innerHTML = ` ${displayName} ${roundScore} ${total} ${roundsWon} `; this.scoreTable.appendChild(tr); }); // Show rankings announcement only for final results const existingAnnouncement = document.getElementById('rankings-announcement'); if (existingAnnouncement) existingAnnouncement.remove(); if (isFinal) { // Show big final results modal instead of side panel stuff this.showFinalResultsModal(rankings, scores); return; } // Show game buttons this.gameButtons.classList.remove('hidden'); if (this.isHost) { this.nextRoundBtn.classList.remove('hidden'); this.newGameBtn.classList.add('hidden'); } else { this.nextRoundBtn.classList.add('hidden'); this.newGameBtn.classList.add('hidden'); } } showRankingsAnnouncement(rankings, isFinal) { // Remove existing announcement if any const existing = document.getElementById('rankings-announcement'); if (existing) existing.remove(); const existingVictory = document.getElementById('double-victory-banner'); if (existingVictory) existingVictory.remove(); if (!rankings) return; const announcement = document.createElement('div'); announcement.id = 'rankings-announcement'; announcement.className = 'rankings-announcement'; const title = isFinal ? 'Final Results' : 'Current Standings'; // Check for double victory (same player leads both categories) - only at game end const pointsLeader = rankings.by_points[0]; const holesLeader = rankings.by_holes_won[0]; const isDoubleVictory = isFinal && pointsLeader && holesLeader && pointsLeader.name === holesLeader.name && holesLeader.rounds_won > 0; // Build points ranking (lowest wins) with tie handling let pointsRank = 0; let prevPoints = null; const pointsHtml = rankings.by_points.map((p, i) => { if (p.total !== prevPoints) { pointsRank = i; prevPoints = p.total; } const medal = pointsRank === 0 ? 'šŸ„‡' : pointsRank === 1 ? '🄈' : pointsRank === 2 ? 'šŸ„‰' : `${pointsRank + 1}.`; const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; return `
${medal}${name}${p.total}pt
`; }).join(''); // Build holes won ranking (most wins) with tie handling let holesRank = 0; let prevHoles = null; const holesHtml = rankings.by_holes_won.map((p, i) => { if (p.rounds_won !== prevHoles) { holesRank = i; prevHoles = p.rounds_won; } // No medal for 0 wins const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? 'šŸ„‡' : holesRank === 1 ? '🄈' : holesRank === 2 ? 'šŸ„‰' : `${holesRank + 1}.`; const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; return `
${medal}${name}${p.rounds_won}W
`; }).join(''); // If double victory, show banner above the left panel (standings) if (isDoubleVictory) { const victoryBanner = document.createElement('div'); victoryBanner.id = 'double-victory-banner'; victoryBanner.className = 'double-victory'; victoryBanner.textContent = `DOUBLE VICTORY! ${pointsLeader.name}`; const standingsPanel = document.getElementById('standings-panel'); if (standingsPanel) { standingsPanel.insertBefore(victoryBanner, standingsPanel.firstChild); } } announcement.innerHTML = `

${title}

Points (Low Wins)

${pointsHtml}

Holes Won

${holesHtml}
`; // Insert before the scoreboard this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild); } showFinalResultsModal(rankings, scores) { // Hide side panels const standingsPanel = document.getElementById('standings-panel'); const scoreboard = document.getElementById('scoreboard'); if (standingsPanel) standingsPanel.classList.add('hidden'); if (scoreboard) scoreboard.classList.add('hidden'); // Remove existing modal if any const existing = document.getElementById('final-results-modal'); if (existing) existing.remove(); // Determine winners const pointsLeader = rankings.by_points[0]; const holesLeader = rankings.by_holes_won[0]; const isDoubleVictory = pointsLeader && holesLeader && pointsLeader.name === holesLeader.name && holesLeader.rounds_won > 0; // Build points ranking let pointsRank = 0; let prevPoints = null; const pointsHtml = rankings.by_points.map((p, i) => { if (p.total !== prevPoints) { pointsRank = i; prevPoints = p.total; } const medal = pointsRank === 0 ? 'šŸ„‡' : pointsRank === 1 ? '🄈' : pointsRank === 2 ? 'šŸ„‰' : `${pointsRank + 1}.`; return `
${medal}${p.name}${p.total} pts
`; }).join(''); // Build holes ranking let holesRank = 0; let prevHoles = null; const holesHtml = rankings.by_holes_won.map((p, i) => { if (p.rounds_won !== prevHoles) { holesRank = i; prevHoles = p.rounds_won; } const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? 'šŸ„‡' : holesRank === 1 ? '🄈' : holesRank === 2 ? 'šŸ„‰' : `${holesRank + 1}.`; return `
${medal}${p.name}${p.rounds_won} wins
`; }).join(''); // Build share text const shareText = this.buildShareText(rankings, isDoubleVictory); // Create modal const modal = document.createElement('div'); modal.id = 'final-results-modal'; modal.className = 'final-results-modal'; modal.innerHTML = `

šŸŒļø Final Results

${isDoubleVictory ? `
šŸ† DOUBLE VICTORY: ${pointsLeader.name} šŸ†
` : ''}

By Points (Low Wins)

${pointsHtml}

By Holes Won

${holesHtml}
`; document.body.appendChild(modal); // Bind button events document.getElementById('share-results-btn').addEventListener('click', () => { navigator.clipboard.writeText(shareText).then(() => { const btn = document.getElementById('share-results-btn'); btn.textContent = 'āœ“ Copied!'; setTimeout(() => btn.textContent = 'šŸ“‹ Copy Results', 2000); }); }); document.getElementById('close-results-btn').addEventListener('click', () => { modal.remove(); this.leaveRoom(); }); } buildShareText(rankings, isDoubleVictory) { let text = 'šŸŒļø Golf Card Game Results\n'; text += '═══════════════════════\n\n'; if (isDoubleVictory) { text += `šŸ† DOUBLE VICTORY: ${rankings.by_points[0].name}!\n\n`; } text += 'šŸ“Š By Points (Low Wins):\n'; rankings.by_points.forEach((p, i) => { const medal = i === 0 ? 'šŸ„‡' : i === 1 ? '🄈' : i === 2 ? 'šŸ„‰' : `${i + 1}.`; text += `${medal} ${p.name}: ${p.total} pts\n`; }); text += '\n⛳ By Holes Won:\n'; rankings.by_holes_won.forEach((p, i) => { const medal = p.rounds_won === 0 ? '-' : i === 0 ? 'šŸ„‡' : i === 1 ? '🄈' : i === 2 ? 'šŸ„‰' : `${i + 1}.`; text += `${medal} ${p.name}: ${p.rounds_won} wins\n`; }); text += '\nPlayed at golf.game'; return text; } } // Initialize game when page loads document.addEventListener('DOMContentLoaded', () => { window.game = new GolfGame(); });