// 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.drawnFromDiscard = false; this.selectedCards = []; this.waitingForFlip = false; this.currentPlayers = []; this.allProfiles = []; this.soundEnabled = true; this.audioCtx = null; // Swap animation state this.swapAnimationInProgress = false; this.swapAnimationCardEl = null; this.swapAnimationFront = null; this.pendingGameState = null; // 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 opponent swap animation in progress (to apply swap-out class after render) this.opponentSwapAnimation = null; // { playerId, position } // Track round winners for visual highlight this.roundWinnerNames = new Set(); this.initElements(); this.initAudio(); this.bindEvents(); this.checkUrlParams(); } checkUrlParams() { // Handle ?room=XXXX share links const params = new URLSearchParams(window.location.search); const roomCode = params.get('room'); if (roomCode) { this.roomCodeInput.value = roomCode.toUpperCase(); // Focus name input so user can quickly enter name and join this.playerNameInput.focus(); // Clean up URL without reloading window.history.replaceState({}, '', window.location.pathname); } } 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 } else if (type === 'reject') { // Low buzz for rejected action oscillator.type = 'sawtooth'; oscillator.frequency.setValueAtTime(150, ctx.currentTime); oscillator.frequency.setValueAtTime(100, ctx.currentTime + 0.08); gainNode.gain.setValueAtTime(0.08, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.12); } } toggleSound() { this.soundEnabled = !this.soundEnabled; this.muteBtn.textContent = this.soundEnabled ? 'šŸ”Š' : 'šŸ”‡'; this.playSound('click'); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } 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.shareRoomLinkBtn = document.getElementById('share-room-link'); 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.flipModeSelect = document.getElementById('flip-mode'); this.knockPenaltyCheckbox = document.getElementById('knock-penalty'); // Rules screen elements this.rulesScreen = document.getElementById('rules-screen'); this.rulesBtn = document.getElementById('rules-btn'); this.rulesBackBtn = document.getElementById('rules-back-btn'); // 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'); // House Rules - New Variants this.flipAsActionCheckbox = document.getElementById('flip-as-action'); this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind'); this.negativePairsCheckbox = document.getElementById('negative-pairs-keep-value'); this.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks'); this.knockEarlyCheckbox = document.getElementById('knock-early'); this.wolfpackComboNote = document.getElementById('wolfpack-combo-note'); 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.cpuControlsSection = document.getElementById('cpu-controls-section'); this.cpuSelectModal = document.getElementById('cpu-select-modal'); this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid'); this.cancelCpuBtn = document.getElementById('cancel-cpu-btn'); this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn'); // Game elements this.currentRoundSpan = document.getElementById('current-round'); this.totalRoundsSpan = document.getElementById('total-rounds'); this.statusMessage = document.getElementById('status-message'); this.playerHeader = document.getElementById('player-header'); this.yourScore = document.getElementById('your-score'); this.muteBtn = document.getElementById('mute-btn'); this.opponentsRow = document.getElementById('opponents-row'); this.deckArea = document.querySelector('.deck-area'); this.deck = document.getElementById('deck'); this.discard = document.getElementById('discard'); this.discardContent = document.getElementById('discard-content'); this.discardBtn = document.getElementById('discard-btn'); this.skipFlipBtn = document.getElementById('skip-flip-btn'); this.knockEarlyBtn = document.getElementById('knock-early-btn'); this.playerCards = document.getElementById('player-cards'); this.playerArea = this.playerCards.closest('.player-area'); this.swapAnimation = document.getElementById('swap-animation'); this.swapCardFromHand = document.getElementById('swap-card-from-hand'); this.heldCardSlot = document.getElementById('held-card-slot'); this.heldCardDisplay = document.getElementById('held-card-display'); this.heldCardContent = document.getElementById('held-card-content'); this.heldCardFloating = document.getElementById('held-card-floating'); this.heldCardFloatingContent = document.getElementById('held-card-floating-content'); this.scoreboard = document.getElementById('scoreboard'); this.scoreTable = document.getElementById('score-table').querySelector('tbody'); this.standingsList = document.getElementById('standings-list'); 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'); this.finalTurnBadge = document.getElementById('final-turn-badge'); // In-game auth elements this.gameUsername = document.getElementById('game-username'); this.gameLogoutBtn = document.getElementById('game-logout-btn'); this.authBar = document.getElementById('auth-bar'); } bindEvents() { 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.drawFromDeck(); }); this.discard.addEventListener('click', () => { this.drawFromDiscard(); }); this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); }); this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); }); this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); }); this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); }); 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(); }); this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); }); // Copy room code to clipboard this.copyRoomCodeBtn.addEventListener('click', () => { this.playSound('click'); this.copyRoomCode(); }); // Share room link this.shareRoomLinkBtn.addEventListener('click', () => { this.playSound('click'); this.shareRoomLink(); }); // 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); }); // Show combo note when wolfpack + four-of-a-kind are both selected const updateWolfpackCombo = () => { if (this.wolfpackCheckbox.checked && this.fourOfAKindCheckbox.checked) { this.wolfpackComboNote.classList.remove('hidden'); } else { this.wolfpackComboNote.classList.add('hidden'); } }; this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo); this.fourOfAKindCheckbox.addEventListener('change', updateWolfpackCombo); // 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'); } }); } // Rules screen navigation if (this.rulesBtn) { this.rulesBtn.addEventListener('click', () => { this.playSound('click'); this.showRulesScreen(); }); } if (this.rulesBackBtn) { this.rulesBackBtn.addEventListener('click', () => { this.playSound('click'); this.showLobby(); }); } } showRulesScreen(scrollToSection = null) { this.showScreen(this.rulesScreen); if (scrollToSection) { const section = document.getElementById(scrollToSection); if (section) { section.scrollIntoView({ behavior: 'smooth' }); } } } 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)); } else { console.error('WebSocket not ready, cannot send:', message.type); this.showError('Connection lost. Please refresh.'); } } 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': // 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)); // Reset all tracking for new round this.locallyFlippedCards = new Set(); this.selectedCards = []; this.animatingPositions = new Set(); this.opponentSwapAnimation = null; this.playSound('shuffle'); this.showGameScreen(); this.renderGame(); break; case 'game_state': // 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(); break; case 'your_turn': // Brief delay to let animations settle setTimeout(() => { // Build toast based on available actions const canFlip = this.gameState && this.gameState.flip_as_action; let canKnock = false; if (this.gameState && this.gameState.knock_early) { const myData = this.gameState.players.find(p => p.id === this.playerId); const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; canKnock = faceDownCount >= 1 && faceDownCount <= 2; } if (canFlip && canKnock) { this.showToast('Your turn! Draw, flip, or knock', 'your-turn'); } else if (canFlip) { this.showToast('Your turn! Draw or flip a card', 'your-turn'); } else if (canKnock) { this.showToast('Your turn! Draw or knock', 'your-turn'); } else { this.showToast('Your turn! Draw a card', 'your-turn'); } }, 200); break; case 'card_drawn': this.drawnCard = data.card; this.drawnFromDiscard = data.source === 'discard'; this.showDrawnCard(); this.renderGame(); // Re-render to update discard pile this.showToast('Swap with a card or discard', '', 3000); break; case 'can_flip': this.waitingForFlip = true; this.flipIsOptional = data.optional || false; if (this.flipIsOptional) { this.showToast('Flip a card or skip', '', 3000); } else { 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; this.copyToClipboard(this.roomCode, this.copyRoomCodeBtn); } shareRoomLink() { if (!this.roomCode) return; // Build shareable URL with room code const url = new URL(window.location.href); url.search = ''; // Clear existing params url.hash = ''; // Clear hash url.searchParams.set('room', this.roomCode); const shareUrl = url.toString(); this.copyToClipboard(shareUrl, this.shareRoomLinkBtn); } copyToClipboard(text, feedbackBtn) { // Use execCommand which is more reliable across contexts const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); let success = false; try { success = document.execCommand('copy'); } catch (err) { console.error('Copy failed:', err); } document.body.removeChild(textarea); // Show visual feedback if (success && feedbackBtn) { const originalText = feedbackBtn.textContent; feedbackBtn.textContent = 'āœ“'; setTimeout(() => { feedbackBtn.textContent = originalText; }, 1500); } } startGame() { try { const decks = parseInt(this.numDecksSelect.value); const rounds = parseInt(this.numRoundsSelect.value); const initial_flips = parseInt(this.initialFlipsSelect.value); // Standard options const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame" const knock_penalty = this.knockPenaltyCheckbox?.checked || false; // Joker mode (radio buttons) const jokerRadio = document.querySelector('input[name="joker-mode"]:checked'); const joker_mode = jokerRadio ? jokerRadio.value : 'none'; 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 || false; const ten_penny = this.tenPennyCheckbox?.checked || false; // House Rules - Bonuses/Penalties const knock_bonus = this.knockBonusCheckbox?.checked || false; const underdog_bonus = this.underdogBonusCheckbox?.checked || false; const tied_shame = this.tiedShameCheckbox?.checked || false; const blackjack = this.blackjackCheckbox?.checked || false; const wolfpack = this.wolfpackCheckbox?.checked || false; // House Rules - New Variants const flip_as_action = this.flipAsActionCheckbox?.checked || false; const four_of_a_kind = this.fourOfAKindCheckbox?.checked || false; const negative_pairs_keep_value = this.negativePairsCheckbox?.checked || false; const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false; const knock_early = this.knockEarlyCheckbox?.checked || false; this.send({ type: 'start_game', decks, rounds, initial_flips, flip_mode, knock_penalty, use_jokers, lucky_swing, super_kings, ten_penny, knock_bonus, underdog_bonus, tied_shame, blackjack, eagle_eye, wolfpack, flip_as_action, four_of_a_kind, negative_pairs_keep_value, one_eyed_jacks, knock_early }); } catch (error) { console.error('Error starting game:', error); this.showError('Error starting game. Please refresh.'); } } 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) { if (this.gameState && !this.gameState.waiting_for_initial_flip) { this.playSound('reject'); } return; } if (this.gameState.waiting_for_initial_flip) return; this.playSound('card'); this.send({ type: 'draw', source: 'deck' }); } drawFromDiscard() { // If holding a card drawn from discard, clicking discard puts it back if (this.drawnCard && !this.gameState.can_discard) { this.playSound('click'); this.cancelDraw(); return; } if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) { if (this.gameState && !this.gameState.waiting_for_initial_flip) { this.playSound('reject'); } return; } if (this.gameState.waiting_for_initial_flip) return; if (!this.gameState.discard_top) return; this.playSound('card'); this.send({ type: 'draw', source: 'discard' }); } discardDrawn() { if (!this.drawnCard) return; const discardedCard = this.drawnCard; this.send({ type: 'discard' }); this.drawnCard = null; this.hideToast(); this.discardBtn.classList.add('hidden'); // Pre-emptively skip the flip animation - the server may broadcast the new state // before our animation completes, and we don't want renderGame() to trigger // the flip-in animation (which starts with opacity: 0, causing a flash) this.skipNextDiscardFlip = true; // Also update lastDiscardKey so renderGame() won't see a "change" this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; // Swoop animation: deck → discard (card is always held over deck) this.animateDeckToDiscardSwoop(discardedCard); } // Swoop animation for discarding a card drawn from deck animateDeckToDiscardSwoop(card) { const deckRect = this.deck.getBoundingClientRect(); const discardRect = this.discard.getBoundingClientRect(); const floater = this.heldCardFloating; // Reset any previous animation state floater.classList.remove('dropping', 'swooping', 'landed'); // Instantly position at deck (card appears to come from deck) floater.style.transition = 'none'; floater.style.left = `${deckRect.left}px`; floater.style.top = `${deckRect.top}px`; floater.style.width = `${deckRect.width}px`; floater.style.height = `${deckRect.height}px`; floater.style.transform = 'scale(1) rotate(0deg)'; // Force reflow floater.offsetHeight; // Start swoop to discard floater.style.transition = ''; floater.classList.add('swooping'); floater.style.left = `${discardRect.left}px`; floater.style.top = `${discardRect.top}px`; floater.style.width = `${discardRect.width}px`; floater.style.height = `${discardRect.height}px`; this.playSound('card'); // After swoop completes, settle and show on discard pile setTimeout(() => { floater.classList.add('landed'); setTimeout(() => { floater.classList.add('hidden'); floater.classList.remove('swooping', 'landed'); // Clear all inline styles from the animation floater.style.cssText = ''; this.updateDiscardPileDisplay(card); this.pulseDiscardLand(); this.skipNextDiscardFlip = true; }, 150); // Brief settle }, 350); // Match swoop transition duration } // Update the discard pile display with a card updateDiscardPileDisplay(card) { this.discard.classList.remove('picked-up', 'disabled'); this.discard.classList.add('has-card', 'card-front'); this.discard.classList.remove('red', 'black', 'joker'); if (card.rank === 'ā˜…') { this.discard.classList.add('joker'); const jokerIcon = card.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; this.discardContent.innerHTML = `${jokerIcon}Joker`; } else { this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black'); this.discardContent.innerHTML = this.renderCardContent(card); } this.lastDiscardKey = `${card.rank}-${card.suit}`; } cancelDraw() { if (!this.drawnCard) return; const cardToReturn = this.drawnCard; const wasFromDiscard = this.drawnFromDiscard; this.send({ type: 'cancel_draw' }); this.drawnCard = null; this.hideToast(); if (wasFromDiscard) { // Animate card from deck position back to discard pile this.animateDeckToDiscardReturn(cardToReturn); } else { this.hideDrawnCard(); } } // Animate returning a card from deck position to discard pile (for cancel draw from discard) animateDeckToDiscardReturn(card) { const discardRect = this.discard.getBoundingClientRect(); const floater = this.heldCardFloating; // Add swooping class for smooth transition floater.classList.add('swooping'); floater.style.left = `${discardRect.left}px`; floater.style.top = `${discardRect.top}px`; floater.style.width = `${discardRect.width}px`; floater.style.height = `${discardRect.height}px`; this.playSound('card'); // After swoop completes, hide floater and update discard pile setTimeout(() => { floater.classList.add('landed'); setTimeout(() => { floater.classList.add('hidden'); floater.classList.remove('swooping', 'landed'); floater.style.cssText = ''; this.updateDiscardPileDisplay(card); this.pulseDiscardLand(); }, 150); }, 350); } swapCard(position) { if (!this.drawnCard) return; this.send({ type: 'swap', position }); this.drawnCard = null; this.hideDrawnCard(); } // Animate player swapping drawn card with a card in their hand // Uses flip-in-place + teleport (no zipping movement) animateSwap(position) { const cardElements = this.playerCards.querySelectorAll('.card'); const handCardEl = cardElements[position]; if (!handCardEl) { this.swapCard(position); return; } // Check if card is already face-up const myData = this.getMyPlayerData(); const card = myData?.cards[position]; const isAlreadyFaceUp = card?.face_up; // Get positions const handRect = handCardEl.getBoundingClientRect(); // Set up the animated card at hand position const swapCard = this.swapCardFromHand; if (!swapCard) { // Animation element missing - fall back to non-animated swap console.error('Swap animation element missing, falling back to direct swap'); this.swapCard(position); return; } const swapCardFront = swapCard.querySelector('.swap-card-front'); // Position at the hand card location swapCard.style.left = handRect.left + 'px'; swapCard.style.top = handRect.top + 'px'; swapCard.style.width = handRect.width + 'px'; swapCard.style.height = handRect.height + 'px'; // Reset state - no moving class needed swapCard.classList.remove('flipping', 'moving'); swapCardFront.innerHTML = ''; swapCardFront.className = 'swap-card-front'; // Mark animating this.swapAnimationInProgress = true; this.swapAnimationCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl; if (isAlreadyFaceUp && card) { // FACE-UP CARD: Subtle pulse animation (no flip needed) this.swapAnimationContentSet = true; // Apply subtle swap pulse to both cards handCardEl.classList.add('swap-pulse'); this.heldCardFloating.classList.add('swap-pulse'); // Play a soft sound for the swap this.playSound('card'); // Send swap and let render handle the update this.send({ type: 'swap', position }); this.drawnCard = null; this.skipNextDiscardFlip = true; // Complete after pulse animation setTimeout(() => { handCardEl.classList.remove('swap-pulse'); this.heldCardFloating.classList.remove('swap-pulse'); this.completeSwapAnimation(null); }, 440); } else { // FACE-DOWN CARD: Flip in place to reveal, then teleport // Hide the actual hand card handCardEl.classList.add('swap-out'); this.swapAnimation.classList.remove('hidden'); // Store references for updateSwapAnimation this.swapAnimationFront = swapCardFront; this.swapAnimationCard = swapCard; this.swapAnimationContentSet = false; // Send swap - the flip will happen in updateSwapAnimation when server responds this.send({ type: 'swap', position }); this.drawnCard = null; this.skipNextDiscardFlip = true; } } // Update the animated card with actual card content when server responds updateSwapAnimation(card) { // Safety: if animation references are missing, complete immediately to avoid freeze if (!this.swapAnimationFront || !card) { if (this.swapAnimationInProgress && !this.swapAnimationContentSet) { console.error('Swap animation incomplete: missing front element or card data'); this.completeSwapAnimation(null); } return; } // Skip if we already set the content (face-up card swap) if (this.swapAnimationContentSet) return; // Set card color class this.swapAnimationFront.className = 'swap-card-front'; if (card.rank === 'ā˜…') { this.swapAnimationFront.classList.add('joker'); const jokerIcon = card.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; this.swapAnimationFront.innerHTML = `${jokerIcon}Joker`; } else { if (card.suit === 'hearts' || card.suit === 'diamonds') { this.swapAnimationFront.classList.add('red'); } else { this.swapAnimationFront.classList.add('black'); } this.swapAnimationFront.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; } this.swapAnimationContentSet = true; // Quick flip to reveal, then complete - server will pause before next turn if (this.swapAnimationCard) { // Step 1: Flip to reveal (quick) this.swapAnimationCard.classList.add('flipping'); this.playSound('flip'); // Step 2: Brief pulse after flip completes setTimeout(() => { this.swapAnimationCard.classList.add('swap-pulse'); this.playSound('card'); }, 350); // Step 3: Complete animation - the pause to see the result happens // on the server side before the next CPU turn starts setTimeout(() => { this.completeSwapAnimation(null); }, 550); } else { // Fallback: animation element missing, complete immediately to avoid freeze console.error('Swap animation element missing, completing immediately'); this.completeSwapAnimation(null); } } completeSwapAnimation(heldCard) { // Hide everything this.swapAnimation.classList.add('hidden'); if (this.swapAnimationCard) { this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse'); } if (heldCard) { heldCard.classList.remove('flipping', 'moving'); heldCard.classList.add('hidden'); } if (this.swapAnimationHandCardEl) { this.swapAnimationHandCardEl.classList.remove('swap-out'); } this.discard.classList.remove('swap-to-hand'); this.swapAnimationInProgress = false; this.swapAnimationFront = null; this.swapAnimationCard = null; this.swapAnimationDiscardRect = null; this.swapAnimationHandCardEl = null; this.swapAnimationHandRect = null; this.discardBtn.classList.add('hidden'); this.heldCardFloating.classList.add('hidden'); if (this.pendingGameState) { this.gameState = this.pendingGameState; this.pendingGameState = null; this.renderGame(); } } flipCard(position) { this.send({ type: 'flip_card', position }); this.waitingForFlip = false; this.flipIsOptional = false; } skipFlip() { if (!this.flipIsOptional) return; this.send({ type: 'skip_flip' }); this.waitingForFlip = false; this.flipIsOptional = false; this.hideToast(); } knockEarly() { // Flip all remaining face-down cards to go out early if (!this.gameState || !this.gameState.knock_early) return; this.send({ type: 'knock_early' }); this.hideToast(); } // Fire-and-forget animation triggers based on state changes triggerAnimationsForStateChange(oldState, newState) { if (!oldState) return; // Check for discard pile changes const newDiscard = newState.discard_top; const oldDiscard = oldState.discard_top; const discardChanged = newDiscard && (!oldDiscard || newDiscard.rank !== oldDiscard.rank || newDiscard.suit !== oldDiscard.suit); const previousPlayerId = oldState.current_player_id; const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId; // Detect which pile opponent drew from and pulse it if (wasOtherPlayer && discardChanged) { const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId); if (oldPlayer && newPlayer && oldDiscard) { // Check if any of their cards now matches the old discard top // This means they took from discard pile let tookFromDiscard = false; for (let i = 0; i < 6; i++) { const newCard = newPlayer.cards[i]; if (newCard?.face_up && newCard.rank === oldDiscard.rank && newCard.suit === oldDiscard.suit) { tookFromDiscard = true; break; } } // Pulse the appropriate pile this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck'); // Show CPU action announcement for draw if (oldPlayer.is_cpu) { this.showCpuAction(oldPlayer.name, tookFromDiscard ? 'draw-discard' : 'draw-deck', tookFromDiscard ? oldDiscard : null); } } else { // No old discard or couldn't detect - assume deck this.pulseDrawPile('deck'); // Show CPU action announcement if (oldPlayer?.is_cpu) { this.showCpuAction(oldPlayer.name, 'draw-deck'); } } } if (discardChanged) { // Check if the previous player actually SWAPPED (has a new face-up card) // vs just discarding the drawn card (no hand change) const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId); if (oldPlayer && newPlayer) { // Find the position that changed // Could be: face-down -> face-up (new reveal) // Or: different card at same position (replaced visible card) // Or: card identity became known (null -> value, indicates swap) let swappedPosition = -1; let wasFaceUp = false; // Track if old card was already face-up for (let i = 0; i < 6; i++) { const oldCard = oldPlayer.cards[i]; const newCard = newPlayer.cards[i]; const wasUp = oldCard?.face_up; const isUp = newCard?.face_up; // Case 1: face-down became face-up (needs flip) if (!wasUp && isUp) { swappedPosition = i; wasFaceUp = false; break; } // Case 2: both face-up but different card (no flip needed) if (wasUp && isUp && oldCard.rank && newCard.rank) { if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) { swappedPosition = i; wasFaceUp = true; // Face-to-face swap break; } } // Case 3: Card identity became known (opponent's hidden card was swapped) // This handles race conditions where face_up might not be updated yet if (!oldCard?.rank && newCard?.rank) { swappedPosition = i; wasFaceUp = false; break; } } // Check if opponent's cards are completely unchanged (server might send split updates) const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); if (swappedPosition >= 0 && wasOtherPlayer) { // Opponent swapped - animate from the actual position that changed this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); // Show CPU swap announcement if (oldPlayer.is_cpu) { this.showCpuAction(oldPlayer.name, 'swap'); } } else if (swappedPosition < 0 && !cardsIdentical) { // Player drew and discarded without swapping // Only fire if cards actually differ (avoid race condition with split server updates) // Show animation for other players, just pulse for local player this.fireDiscardAnimation(newDiscard, previousPlayerId); // Show CPU discard announcement if (wasOtherPlayer && oldPlayer?.is_cpu) { this.showCpuAction(oldPlayer.name, 'discard', newDiscard); } } // Skip the card-flip-in animation since we just did our own this.skipNextDiscardFlip = true; } } // Handle delayed card updates (server sends split updates: discard first, then cards) // Check if opponent cards changed even when discard didn't change if (!discardChanged && wasOtherPlayer && previousPlayerId) { const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId); if (oldPlayer && newPlayer) { // Check for card changes that indicate a swap we missed for (let i = 0; i < 6; i++) { const oldCard = oldPlayer.cards[i]; const newCard = newPlayer.cards[i]; // Card became visible (swap completed in delayed update) if (!oldCard?.face_up && newCard?.face_up) { this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false); if (oldPlayer.is_cpu) { this.showCpuAction(oldPlayer.name, 'swap'); } break; } // Card identity became known if (!oldCard?.rank && newCard?.rank) { this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false); if (oldPlayer.is_cpu) { this.showCpuAction(oldPlayer.name, 'swap'); } break; } } } } } // Flash animation on deck or discard pile to show where opponent drew from pulseDrawPile(source) { const T = window.TIMING?.feedback || {}; const pile = source === 'discard' ? this.discard : this.deck; pile.classList.remove('draw-pulse'); // Trigger reflow to restart animation void pile.offsetWidth; pile.classList.add('draw-pulse'); // Remove class after animation completes setTimeout(() => pile.classList.remove('draw-pulse'), T.drawPulse || 450); } // Pulse discard pile when a card lands on it pulseDiscardLand() { const T = window.TIMING?.feedback || {}; this.discard.classList.remove('discard-land'); void this.discard.offsetWidth; this.discard.classList.add('discard-land'); setTimeout(() => this.discard.classList.remove('discard-land'), T.discardLand || 460); } // Fire animation for discard without swap (card lands on discard pile face-up) // Shows card moving from deck to discard for other players only fireDiscardAnimation(discardCard, fromPlayerId = null) { // Only show animation for other players - local player already knows what they did const isOtherPlayer = fromPlayerId && fromPlayerId !== this.playerId; if (isOtherPlayer && discardCard) { // Show card traveling from deck to discard pile this.animateDeckToDiscard(discardCard); } // Skip animation entirely for local player } // Animate a card moving from deck to discard pile (for draw-and-discard by other players) animateDeckToDiscard(card) { const deckRect = this.deck.getBoundingClientRect(); const discardRect = this.discard.getBoundingClientRect(); // Create temporary card element const animCard = document.createElement('div'); animCard.className = 'real-card anim-card'; animCard.innerHTML = `
?
`; // Position at deck animCard.style.position = 'fixed'; animCard.style.left = `${deckRect.left}px`; animCard.style.top = `${deckRect.top}px`; animCard.style.width = `${deckRect.width}px`; animCard.style.height = `${deckRect.height}px`; animCard.style.zIndex = '1000'; animCard.style.transition = `left ${window.TIMING?.card?.move || 270}ms ease-out, top ${window.TIMING?.card?.move || 270}ms ease-out`; const inner = animCard.querySelector('.card-inner'); const front = animCard.querySelector('.card-face-front'); // Start face-down (back showing) inner.classList.add('flipped'); // Set up the front face content for the flip front.className = 'card-face card-face-front'; if (card.rank === 'ā˜…') { front.classList.add('joker'); const jokerIcon = card.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; front.innerHTML = `${jokerIcon}Joker`; } else { front.classList.add(this.isRedSuit(card.suit) ? 'red' : 'black'); front.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; } document.body.appendChild(animCard); // Small delay then start moving and flipping setTimeout(() => { this.playSound('card'); // Move to discard position animCard.style.left = `${discardRect.left}px`; animCard.style.top = `${discardRect.top}px`; // Flip to show face inner.classList.remove('flipped'); }, 50); // Clean up after animation const moveDuration = window.TIMING?.card?.move || 270; const pauseAfter = window.TIMING?.pause?.afterDiscard || 550; setTimeout(() => { animCard.remove(); this.pulseDiscardLand(); }, moveDuration + pauseAfter); } // Get rotation angle from an element's computed transform getElementRotation(element) { if (!element) return 0; const style = window.getComputedStyle(element); const transform = style.transform; if (!transform || transform === 'none') return 0; // Parse rotation from transform matrix const values = transform.split('(')[1]?.split(')')[0]?.split(','); if (values && values.length >= 2) { const a = parseFloat(values[0]); const b = parseFloat(values[1]); return Math.round(Math.atan2(b, a) * (180 / Math.PI)); } return 0; } // Fire a swap animation (non-blocking) - flip in place at opponent's position // Uses flip-in-place for face-down cards, subtle pulse for face-up cards fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) { // Track this animation so renderGame can apply swap-out class this.opponentSwapAnimation = { playerId, position }; // Find source position - the actual card that was swapped const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); let sourceRect = null; let sourceCardEl = null; let sourceRotation = 0; for (const area of opponentAreas) { const nameEl = area.querySelector('h4'); const player = this.gameState?.players.find(p => p.id === playerId); if (nameEl && player && nameEl.textContent.includes(player.name)) { const cards = area.querySelectorAll('.card'); if (cards.length > position && position >= 0) { sourceCardEl = cards[position]; sourceRect = sourceCardEl.getBoundingClientRect(); // Get rotation from the opponent area (parent has the arch rotation) sourceRotation = this.getElementRotation(area); } break; } } // Face-to-face swap: use subtle pulse on the card, no flip needed if (wasFaceUp && sourceCardEl) { sourceCardEl.classList.add('swap-pulse'); this.playSound('card'); const pulseDuration = window.TIMING?.feedback?.discardPickup || 400; setTimeout(() => { sourceCardEl.classList.remove('swap-pulse'); }, pulseDuration); return; } // Face-down to face-up: flip to reveal, pause to see it, then pulse before swap if (!sourceRect) { // Fallback: just show flip at discard position const discardRect = this.discard.getBoundingClientRect(); sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height }; } const swapCard = this.swapCardFromHand; const swapCardFront = swapCard.querySelector('.swap-card-front'); // Position at opponent's card location (flip in place there) swapCard.style.left = sourceRect.left + 'px'; swapCard.style.top = sourceRect.top + 'px'; swapCard.style.width = sourceRect.width + 'px'; swapCard.style.height = sourceRect.height + 'px'; swapCard.classList.remove('flipping', 'moving', 'swap-pulse'); // Apply source rotation to match the arch layout swapCard.style.transform = `rotate(${sourceRotation}deg)`; // Set card content (the card being discarded - what was hidden) swapCardFront.className = 'swap-card-front'; if (discardCard.rank === 'ā˜…') { swapCardFront.classList.add('joker'); const jokerIcon = discardCard.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; swapCardFront.innerHTML = `${jokerIcon}Joker`; } else { swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black'); swapCardFront.innerHTML = `${discardCard.rank}
${this.getSuitSymbol(discardCard.suit)}`; } if (sourceCardEl) sourceCardEl.classList.add('swap-out'); this.swapAnimation.classList.remove('hidden'); // Use centralized timing - no beat pauses, continuous flow const flipTime = window.TIMING?.card?.flip || 400; // Step 1: Flip to reveal the hidden card swapCard.classList.add('flipping'); this.playSound('flip'); // Step 2: After flip completes, clean up immediately setTimeout(() => { this.swapAnimation.classList.add('hidden'); swapCard.classList.remove('flipping'); swapCard.style.transform = ''; this.opponentSwapAnimation = null; this.pulseDiscardLand(); }, flipTime); } // Fire a flip animation for local player's card (non-blocking) fireLocalFlipAnimation(position, cardData) { const key = `local-${position}`; if (this.animatingPositions.has(key)) return; this.animatingPositions.add(key); const cardElements = this.playerCards.querySelectorAll('.card'); const cardEl = cardElements[position]; if (!cardEl) { this.animatingPositions.delete(key); return; } const cardRect = cardEl.getBoundingClientRect(); const swapCard = this.swapCardFromHand; const swapCardFront = swapCard.querySelector('.swap-card-front'); swapCard.style.left = cardRect.left + 'px'; swapCard.style.top = cardRect.top + 'px'; swapCard.style.width = cardRect.width + 'px'; swapCard.style.height = cardRect.height + 'px'; swapCard.classList.remove('flipping', 'moving'); // Set card content swapCardFront.className = 'swap-card-front'; if (cardData.rank === 'ā˜…') { swapCardFront.classList.add('joker'); const jokerIcon = cardData.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; swapCardFront.innerHTML = `${jokerIcon}Joker`; } else { swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); const suitSymbol = { hearts: '♄', diamonds: '♦', clubs: '♣', spades: 'ā™ ' }[cardData.suit]; swapCardFront.innerHTML = `${cardData.rank}
${suitSymbol}`; } cardEl.classList.add('swap-out'); this.swapAnimation.classList.remove('hidden'); // Use centralized timing const preFlip = window.TIMING?.pause?.beforeFlip || 50; const flipDuration = window.TIMING?.card?.flip || 540; setTimeout(() => { swapCard.classList.add('flipping'); this.playSound('flip'); }, preFlip); setTimeout(() => { this.swapAnimation.classList.add('hidden'); swapCard.classList.remove('flipping'); cardEl.classList.remove('swap-out'); this.animatingPositions.delete(key); }, preFlip + flipDuration); } // Fire a flip animation for opponent card (non-blocking) fireFlipAnimation(playerId, position, cardData) { // Skip if already animating this position const key = `${playerId}-${position}`; if (this.animatingPositions.has(key)) return; this.animatingPositions.add(key); // Find the card element and parent area (for rotation) const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); let cardEl = null; let sourceRotation = 0; for (const area of opponentAreas) { const nameEl = area.querySelector('h4'); const player = this.gameState?.players.find(p => p.id === playerId); if (nameEl && player && nameEl.textContent.includes(player.name)) { const cards = area.querySelectorAll('.card'); cardEl = cards[position]; sourceRotation = this.getElementRotation(area); break; } } if (!cardEl) { this.animatingPositions.delete(key); return; } const cardRect = cardEl.getBoundingClientRect(); const swapCard = this.swapCardFromHand; const swapCardFront = swapCard.querySelector('.swap-card-front'); swapCard.style.left = cardRect.left + 'px'; swapCard.style.top = cardRect.top + 'px'; swapCard.style.width = cardRect.width + 'px'; swapCard.style.height = cardRect.height + 'px'; swapCard.classList.remove('flipping', 'moving'); // Apply rotation to match the arch layout swapCard.style.transform = `rotate(${sourceRotation}deg)`; // Set card content swapCardFront.className = 'swap-card-front'; if (cardData.rank === 'ā˜…') { swapCardFront.classList.add('joker'); const jokerIcon = cardData.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; swapCardFront.innerHTML = `${jokerIcon}Joker`; } else { swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black'); const suitSymbol = { hearts: '♄', diamonds: '♦', clubs: '♣', spades: 'ā™ ' }[cardData.suit]; swapCardFront.innerHTML = `${cardData.rank}
${suitSymbol}`; } cardEl.classList.add('swap-out'); this.swapAnimation.classList.remove('hidden'); // Use centralized timing const preFlip = window.TIMING?.pause?.beforeFlip || 50; const flipDuration = window.TIMING?.card?.flip || 540; setTimeout(() => { swapCard.classList.add('flipping'); this.playSound('flip'); }, preFlip); setTimeout(() => { this.swapAnimation.classList.add('hidden'); swapCard.classList.remove('flipping'); swapCard.style.transform = ''; cardEl.classList.remove('swap-out'); this.animatingPositions.delete(key); }, preFlip + flipDuration); } handleCardClick(position) { const myData = this.getMyPlayerData(); if (!myData) return; const card = myData.cards[position]; // Check for flip-as-action: can flip face-down card instead of drawing const canFlipAsAction = this.gameState.flip_as_action && this.isMyTurn() && !this.drawnCard && !this.gameState.has_drawn_card && !card.face_up && !this.gameState.waiting_for_initial_flip; if (canFlipAsAction) { this.playSound('flip'); this.fireLocalFlipAnimation(position, card); this.send({ type: 'flip_as_action', position }); this.hideToast(); return; } // Check if action is allowed - if not, play reject sound const canAct = this.gameState.waiting_for_initial_flip || this.drawnCard || this.waitingForFlip; if (!canAct) { this.playSound('reject'); return; } // Initial flip phase if (this.gameState.waiting_for_initial_flip) { if (card.face_up) return; // Use Set to prevent duplicates - check both tracking mechanisms if (this.locallyFlippedCards.has(position)) return; if (this.selectedCards.includes(position)) return; const requiredFlips = this.gameState.initial_flips || 2; // Track locally and animate immediately this.locallyFlippedCards.add(position); this.selectedCards.push(position); // Fire flip animation (non-blocking) this.fireLocalFlipAnimation(position, card); // Re-render to show flipped state this.renderGame(); // Use Set to ensure unique positions when sending to server const uniquePositions = [...new Set(this.selectedCards)]; if (uniquePositions.length === requiredFlips) { this.send({ type: 'flip_initial', positions: uniquePositions }); this.selectedCards = []; // Note: locallyFlippedCards is cleared when server confirms (in game_state handler) this.hideToast(); } else { const remaining = requiredFlips - uniquePositions.length; this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); } return; } // Swap with drawn card if (this.drawnCard) { this.animateSwap(position); this.hideToast(); return; } // Flip after discarding from deck (flip_on_discard variant) if (this.waitingForFlip && !card.face_up) { // Animate immediately, then send to server this.fireLocalFlipAnimation(position, card); this.flipCard(position); this.hideToast(); return; } } nextRound() { this.clearNextHoleCountdown(); this.send({ type: 'next_round' }); this.gameButtons.classList.add('hidden'); this.nextRoundBtn.classList.remove('waiting'); } 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'); if (this.rulesScreen) { this.rulesScreen.classList.remove('active'); } screen.classList.add('active'); // Handle auth bar visibility - hide global bar during game, show in-game controls instead const isGameScreen = screen === this.gameScreen; const user = this.auth?.user; if (isGameScreen && user) { // Hide global auth bar, show in-game auth controls this.authBar?.classList.add('hidden'); this.gameUsername.textContent = user.username; this.gameUsername.classList.remove('hidden'); this.gameLogoutBtn.classList.remove('hidden'); } else { // Show global auth bar (if logged in), hide in-game auth controls if (user) { this.authBar?.classList.remove('hidden'); } this.gameUsername.classList.add('hidden'); this.gameLogoutBtn.classList.add('hidden'); } } showLobby() { this.showScreen(this.lobbyScreen); this.lobbyError.textContent = ''; this.roomCode = null; this.playerId = null; this.isHost = false; this.gameState = null; this.previousState = null; } showWaitingRoom() { this.showScreen(this.waitingScreen); this.displayRoomCode.textContent = this.roomCode; if (this.isHost) { this.hostSettings.classList.remove('hidden'); this.cpuControlsSection.classList.remove('hidden'); this.waitingMessage.classList.add('hidden'); } else { this.hostSettings.classList.add('hidden'); this.cpuControlsSection.classList.add('hidden'); this.waitingMessage.classList.remove('hidden'); } } showGameScreen() { this.showScreen(this.gameScreen); this.gameButtons.classList.add('hidden'); this.drawnCard = null; this.selectedCards = []; this.waitingForFlip = false; this.previousState = null; // 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.activeRulesBar.classList.add('hidden'); return; } const rules = this.gameState.active_rules || []; if (rules.length === 0) { // Show "Standard Rules" when no variants selected this.activeRulesList.innerHTML = 'Standard'; } else if (rules.length <= 2) { // Show all rules if 2 or fewer this.activeRulesList.innerHTML = rules .map(rule => `${rule}`) .join(''); } else { // Show first 2 rules + "+N more" with tooltip const displayed = rules.slice(0, 2); const hidden = rules.slice(2); const moreCount = hidden.length; const tooltip = hidden.join(', '); this.activeRulesList.innerHTML = displayed .map(rule => `${rule}`) .join('') + `+${moreCount} more`; } this.activeRulesBar.classList.remove('hidden'); } showError(message) { this.lobbyError.textContent = message; this.playSound('reject'); console.error('Game error:', 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'; li.innerHTML = ` ${player.name} ${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.cpuControlsSection.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); } setStatus(message, type = '') { this.statusMessage.textContent = message; this.statusMessage.className = 'status-message' + (type ? ' ' + type : ''); } // Show CPU action announcement in status bar showCpuAction(playerName, action, card = null) { const suitSymbol = card ? this.getSuitSymbol(card.suit) : ''; const messages = { 'draw-deck': `${playerName} draws from deck`, 'draw-discard': card ? `${playerName} takes ${card.rank}${suitSymbol}` : `${playerName} takes from discard`, 'swap': `${playerName} swaps a card`, 'discard': card ? `${playerName} discards ${card.rank}${suitSymbol}` : `${playerName} discards`, }; const message = messages[action]; if (message) { this.setStatus(message, 'cpu-action'); } } // Update CPU considering visual state on discard pile updateCpuConsideringState() { if (!this.gameState || !this.discard) return; const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); const isCpuTurn = currentPlayer && currentPlayer.is_cpu; const hasNotDrawn = !this.gameState.has_drawn_card; if (isCpuTurn && hasNotDrawn) { this.discard.classList.add('cpu-considering'); } else { this.discard.classList.remove('cpu-considering'); } } showToast(message, type = '', duration = 2500) { // For compatibility - just set the status message this.setStatus(message, type); } hideToast() { // Restore default status based on game state this.updateStatusFromGameState(); } updateStatusFromGameState() { if (!this.gameState) { this.setStatus(''); this.finalTurnBadge.classList.add('hidden'); return; } // Check for round/game over states if (this.gameState.phase === 'round_over') { this.setStatus('Hole Complete!', 'round-over'); this.finalTurnBadge.classList.add('hidden'); return; } if (this.gameState.phase === 'game_over') { this.setStatus('Game Over!', 'game-over'); this.finalTurnBadge.classList.add('hidden'); return; } const isFinalTurn = this.gameState.phase === 'final_turn'; const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); // Show/hide final turn badge separately if (isFinalTurn) { this.finalTurnBadge.classList.remove('hidden'); } else { this.finalTurnBadge.classList.add('hidden'); } if (currentPlayer && currentPlayer.id !== this.playerId) { this.setStatus(`${currentPlayer.name}'s turn`); } else if (this.isMyTurn()) { if (!this.drawnCard && !this.gameState.has_drawn_card) { // Build status message based on available actions let options = ['draw']; if (this.gameState.flip_as_action) options.push('flip'); // Check knock early eligibility const myData = this.gameState.players.find(p => p.id === this.playerId); const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; if (this.gameState.knock_early && faceDownCount >= 1 && faceDownCount <= 2) { options.push('knock'); } if (options.length === 1) { this.setStatus('Your turn - draw a card', 'your-turn'); } else { this.setStatus(`Your turn - ${options.join('/')}`, 'your-turn'); } } else { this.setStatus('Your turn - draw a card', 'your-turn'); } } else { this.setStatus(''); } } showDrawnCard() { // Show drawn card floating over the draw pile (deck), regardless of source const card = this.drawnCard; this.displayHeldCard(card, true); } // Display held card floating above and between deck and discard - for any player // isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow) displayHeldCard(card, isLocalPlayerHolding) { if (!card) { this.hideDrawnCard(); return; } // Set up the floating held card display this.heldCardFloating.className = 'card card-front held-card-floating'; // Clear any inline styles left over from swoop animations this.heldCardFloating.style.cssText = ''; // Position centered above and between deck and discard const deckRect = this.deck.getBoundingClientRect(); const discardRect = this.discard.getBoundingClientRect(); // Calculate center point between deck and discard const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const cardWidth = deckRect.width; const cardHeight = deckRect.height; // Position card centered, overlapping both piles (lower than before) const overlapOffset = cardHeight * 0.35; // More overlap = lower position const cardLeft = centerX - cardWidth / 2; const cardTop = deckRect.top - overlapOffset; this.heldCardFloating.style.left = `${cardLeft}px`; this.heldCardFloating.style.top = `${cardTop}px`; this.heldCardFloating.style.width = `${cardWidth}px`; this.heldCardFloating.style.height = `${cardHeight}px`; // Position discard button attached to right side of held card const scaledWidth = cardWidth * 1.15; // Account for scale transform const scaledHeight = cardHeight * 1.15; const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap) const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card this.discardBtn.style.left = `${buttonLeft}px`; this.discardBtn.style.top = `${buttonTop}px`; if (card.rank === 'ā˜…') { this.heldCardFloating.classList.add('joker'); const jokerIcon = card.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; this.heldCardFloatingContent.innerHTML = `${jokerIcon}Joker`; } else { if (this.isRedSuit(card.suit)) { this.heldCardFloating.classList.add('red'); } else { this.heldCardFloating.classList.add('black'); } this.heldCardFloatingContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; } // Show the floating card this.heldCardFloating.classList.remove('hidden'); // Add pulse glow if it's local player's turn to act on the card if (isLocalPlayerHolding) { this.heldCardFloating.classList.add('your-turn-pulse'); this.discardBtn.classList.remove('hidden'); } else { this.heldCardFloating.classList.remove('your-turn-pulse'); this.discardBtn.classList.add('hidden'); } } hideDrawnCard() { // Hide the floating held card this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.remove('your-turn-pulse'); // Clear any inline styles from animations this.heldCardFloating.style.cssText = ''; this.discardBtn.classList.add('hidden'); // Clear button positioning this.discardBtn.style.left = ''; this.discardBtn.style.top = ''; } 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 - use suit to determine icon (hearts = dragon, spades = oni) if (card.rank === 'ā˜…') { const jokerIcon = card.suit === 'hearts' ? 'šŸ‰' : 'šŸ‘¹'; return `${jokerIcon}Joker`; } return `${card.rank}
${this.getSuitSymbol(card.suit)}`; } renderGame() { if (!this.gameState) return; // Update CPU considering visual state this.updateCpuConsideringState(); // Update header this.currentRoundSpan.textContent = this.gameState.current_round; this.totalRoundsSpan.textContent = this.gameState.total_rounds; // Show/hide final turn badge const isFinalTurn = this.gameState.phase === 'final_turn'; if (isFinalTurn) { this.finalTurnBadge.classList.remove('hidden'); } else { this.finalTurnBadge.classList.add('hidden'); } // Toggle not-my-turn class to disable hover effects when it's not player's turn const isMyTurn = this.isMyTurn(); this.gameScreen.classList.toggle('not-my-turn', !isMyTurn); // Update status message (handled by specific actions, but set default here) 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`); } // Update player header (name + score like opponents) 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; // 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 ? ' āœ“' : ''; // Update player name span with crown if winner const playerNameSpan = this.playerHeader.querySelector('.player-name'); const crownHtml = isRoundWinner ? 'šŸ‘‘' : ''; playerNameSpan.innerHTML = crownHtml + displayName + checkmark; } // Update discard pile // Check if ANY player is holding a card (local or remote/CPU) const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card; if (anyPlayerHolding) { // Someone is holding a drawn card - show discard pile as greyed/disabled // If drawn from discard, show what's underneath (new discard_top or empty) // If drawn from deck, show current discard_top greyed this.discard.classList.add('picked-up'); this.discard.classList.remove('holding'); if (this.gameState.discard_top) { const discardCard = this.gameState.discard_top; this.discard.classList.add('has-card', 'card-front'); this.discard.classList.remove('card-back', 'red', 'black', 'joker'); if (discardCard.rank === 'ā˜…') { this.discard.classList.add('joker'); } else if (this.isRedSuit(discardCard.suit)) { this.discard.classList.add('red'); } else { this.discard.classList.add('black'); } this.discardContent.innerHTML = this.renderCardContent(discardCard); } else { // No card underneath - show empty this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker'); this.discardContent.innerHTML = ''; } } else { // Not holding - show normal discard pile this.discard.classList.remove('picked-up'); // Skip discard update during opponent swap animation - animation handles the visual if (this.opponentSwapAnimation) { // Don't update discard content; animation overlay shows the swap } else if (this.gameState.discard_top) { const discardCard = this.gameState.discard_top; const cardKey = `${discardCard.rank}-${discardCard.suit}`; // Only animate discard flip during active gameplay, not at round/game end const isActivePlay = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; const shouldAnimate = isActivePlay && this.lastDiscardKey && this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip; this.skipNextDiscardFlip = false; this.lastDiscardKey = cardKey; // Set card content and styling FIRST (before any animation) this.discard.classList.add('has-card', 'card-front'); this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding'); 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); // THEN animate if needed (content is already set, so no blank flash) if (shouldAnimate) { // Remove any existing animation first to allow re-trigger this.discard.classList.remove('card-flip-in'); void this.discard.offsetWidth; // Force reflow this.discard.classList.add('card-flip-in'); const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560; setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration); } } else { this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding'); this.discardContent.innerHTML = ''; this.lastDiscardKey = null; } this.discardBtn.classList.add('hidden'); } // Show held card for ANY player who has drawn (consistent visual regardless of whose turn) // Local player uses this.drawnCard, others use gameState.drawn_card if (this.drawnCard) { // Local player is holding - show with pulse and discard button this.displayHeldCard(this.drawnCard, true); } else if (this.gameState.drawn_card && this.gameState.drawn_player_id) { // Another player is holding - show without pulse/button const isLocalPlayer = this.gameState.drawn_player_id === this.playerId; this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer); } else { // No one holding a card this.hideDrawnCard(); } // 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; // Pulse the deck area when it's player's turn to draw this.deckArea.classList.toggle('your-turn-to-draw', canDraw); this.deck.classList.toggle('clickable', canDraw); // Show disabled on deck when any player has drawn (consistent dimmed look) this.deck.classList.toggle('disabled', hasDrawn); this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); // Disabled state handled by picked-up class when anyone is holding // Render opponents in a single row const opponents = this.gameState.players.filter(p => p.id !== this.playerId); this.opponentsRow.innerHTML = ''; // Don't highlight current player during round/game over const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; opponents.forEach((player) => { const div = document.createElement('div'); div.className = 'opponent-area'; if (isPlaying && player.id === this.gameState.current_player_id) { 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 crownHtml = isRoundWinner ? 'šŸ‘‘' : ''; div.innerHTML = `

${crownHtml}${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) => { // Check if this card was locally flipped (immediate feedback) const isLocallyFlipped = this.locallyFlippedCards.has(index); // Create a display card that shows face-up if locally flipped const displayCard = isLocallyFlipped ? { ...card, face_up: true } : card; // Check if clickable during initial flip const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped; const isClickable = ( isInitialFlipClickable || (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); // Add pulse animation during initial flip phase if (isInitialFlipClickable) { cardEl.firstChild.classList.add('initial-flip-pulse'); } cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); this.playerCards.appendChild(cardEl.firstChild); }); } // Show flip prompt for initial flip // Show flip prompt during initial flip phase if (this.gameState.waiting_for_initial_flip) { const requiredFlips = this.gameState.initial_flips || 2; const flippedCount = this.locallyFlippedCards.size; const remaining = requiredFlips - flippedCount; if (remaining > 0) { this.setStatus(`Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`, 'your-turn'); } } // 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'); } // Show/hide skip flip button (only when flip is optional in endgame mode) if (this.waitingForFlip && this.flipIsOptional) { this.skipFlipBtn.classList.remove('hidden'); } else { this.skipFlipBtn.classList.add('hidden'); } // Show/hide knock early button (when knock_early rule is enabled) // Conditions: rule enabled, my turn, no drawn card, have 1-2 face-down cards const canKnockEarly = this.gameState.knock_early && this.isMyTurn() && !this.drawnCard && !this.gameState.has_drawn_card && !this.gameState.waiting_for_initial_flip; if (canKnockEarly) { // Count face-down cards for current player const myData = this.gameState.players.find(p => p.id === this.playerId); const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; if (faceDownCount >= 1 && faceDownCount <= 2) { this.knockEarlyBtn.classList.remove('hidden'); } else { this.knockEarlyBtn.classList.add('hidden'); } } else { this.knockEarlyBtn.classList.add('hidden'); } // Update scoreboard panel this.updateScorePanel(); } updateScorePanel() { if (!this.gameState) return; // Update standings (left panel) this.updateStandings(); // Skip score table update during round_over/game_over - showScoreboard handles these if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') { return; } // Update score table (right panel) this.scoreTable.innerHTML = ''; this.gameState.players.forEach(player => { const tr = document.createElement('tr'); // Highlight current player (but not during round/game over) const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; if (isPlaying && player.id === this.gameState.current_player_id) { tr.classList.add('current-player'); } // 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} pts
`; }).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} wins
`; }).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 = ''; // Clear the final turn badge and status message this.finalTurnBadge.classList.add('hidden'); if (isFinal) { this.setStatus('Game Over!'); } else { this.setStatus('Hole complete'); } // 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 => { 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'); this.newGameBtn.classList.add('hidden'); this.nextRoundBtn.classList.remove('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.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; } } 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} pts
`; }).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} wins
`; }).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!'; const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000; setTimeout(() => btn.textContent = 'šŸ“‹ Copy Results', copyDelay); }); }); 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(); window.auth = new AuthManager(window.game); }); // =========================================== // AUTH MANAGER // =========================================== class AuthManager { constructor(game) { this.game = game; this.token = localStorage.getItem('authToken'); this.user = JSON.parse(localStorage.getItem('authUser') || 'null'); this.initElements(); this.bindEvents(); this.updateUI(); } initElements() { this.authBar = document.getElementById('auth-bar'); this.authUsername = document.getElementById('auth-username'); this.logoutBtn = document.getElementById('auth-logout-btn'); this.authButtons = document.getElementById('auth-buttons'); this.loginBtn = document.getElementById('login-btn'); this.signupBtn = document.getElementById('signup-btn'); this.modal = document.getElementById('auth-modal'); this.modalClose = document.getElementById('auth-modal-close'); this.loginFormContainer = document.getElementById('login-form-container'); this.loginForm = document.getElementById('login-form'); this.loginUsername = document.getElementById('login-username'); this.loginPassword = document.getElementById('login-password'); this.loginError = document.getElementById('login-error'); this.signupFormContainer = document.getElementById('signup-form-container'); this.signupForm = document.getElementById('signup-form'); this.signupUsername = document.getElementById('signup-username'); this.signupEmail = document.getElementById('signup-email'); this.signupPassword = document.getElementById('signup-password'); this.signupError = document.getElementById('signup-error'); this.showSignupLink = document.getElementById('show-signup'); this.showLoginLink = document.getElementById('show-login'); } bindEvents() { this.loginBtn?.addEventListener('click', () => this.showModal('login')); this.signupBtn?.addEventListener('click', () => this.showModal('signup')); this.modalClose?.addEventListener('click', () => this.hideModal()); this.modal?.addEventListener('click', (e) => { if (e.target === this.modal) this.hideModal(); }); this.showSignupLink?.addEventListener('click', (e) => { e.preventDefault(); this.showForm('signup'); }); this.showLoginLink?.addEventListener('click', (e) => { e.preventDefault(); this.showForm('login'); }); this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e)); this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e)); this.logoutBtn?.addEventListener('click', () => this.logout()); } showModal(form = 'login') { this.modal.classList.remove('hidden'); this.showForm(form); this.clearErrors(); } hideModal() { this.modal.classList.add('hidden'); this.clearForms(); } showForm(form) { if (form === 'login') { this.loginFormContainer.classList.remove('hidden'); this.signupFormContainer.classList.add('hidden'); this.loginUsername.focus(); } else { this.loginFormContainer.classList.add('hidden'); this.signupFormContainer.classList.remove('hidden'); this.signupUsername.focus(); } } clearForms() { this.loginForm.reset(); this.signupForm.reset(); this.clearErrors(); } clearErrors() { this.loginError.textContent = ''; this.signupError.textContent = ''; } async handleLogin(e) { e.preventDefault(); this.clearErrors(); const username = this.loginUsername.value.trim(); const password = this.loginPassword.value; try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await response.json(); if (!response.ok) { this.loginError.textContent = data.detail || 'Login failed'; return; } this.setAuth(data.token, data.user); this.hideModal(); if (data.user.username && this.game.playerNameInput) { this.game.playerNameInput.value = data.user.username; } } catch (err) { this.loginError.textContent = 'Connection error'; } } async handleSignup(e) { e.preventDefault(); this.clearErrors(); const username = this.signupUsername.value.trim(); const email = this.signupEmail.value.trim() || null; const password = this.signupPassword.value; try { const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, email, password }), }); const data = await response.json(); if (!response.ok) { this.signupError.textContent = data.detail || 'Signup failed'; return; } this.setAuth(data.token, data.user); this.hideModal(); if (data.user.username && this.game.playerNameInput) { this.game.playerNameInput.value = data.user.username; } } catch (err) { this.signupError.textContent = 'Connection error'; } } setAuth(token, user) { this.token = token; this.user = user; localStorage.setItem('authToken', token); localStorage.setItem('authUser', JSON.stringify(user)); this.updateUI(); } logout() { this.token = null; this.user = null; localStorage.removeItem('authToken'); localStorage.removeItem('authUser'); this.updateUI(); } updateUI() { if (this.user) { this.authBar?.classList.remove('hidden'); this.authButtons?.classList.add('hidden'); if (this.authUsername) { this.authUsername.textContent = this.user.username; } if (this.game.playerNameInput && !this.game.playerNameInput.value) { this.game.playerNameInput.value = this.user.username; } } else { this.authBar?.classList.add('hidden'); this.authButtons?.classList.remove('hidden'); } } }