// Golf Card Game - Client Application // Debug logging - set to true to see detailed state/animation logs const DEBUG_GAME = false; function debugLog(category, message, data = null) { if (!DEBUG_GAME) return; const timestamp = new Date().toISOString().substr(11, 12); const prefix = `[${timestamp}] [${category}]`; if (data) { console.log(prefix, message, data); } else { console.log(prefix, message); } } 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; // --- Animation coordination flags --- // These flags form a system: they block renderGame() from touching the discard pile // while an animation is in flight. If any flag gets stuck true, the discard pile // freezes and the UI looks broken. Every flag MUST be cleared in every code path: // animation callbacks, error handlers, fallbacks, and the `your_turn` safety net. // If you're debugging a frozen discard pile, check these first. // Swap animation state β€” local player's swap defers state updates until animation completes this.swapAnimationInProgress = false; this.swapAnimationCardEl = null; this.swapAnimationFront = null; this.swapAnimationContentSet = false; this.pendingSwapData = 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(); // Blocks discard update: opponent swap animation in progress this.opponentSwapAnimation = null; // { playerId, position } // Blocks held card display: draw pulse animation hasn't finished yet this.drawPulseAnimation = false; // Blocks discard update: local player discarding drawn card to pile this.localDiscardAnimating = false; // Blocks discard update: opponent discarding without swap this.opponentDiscardAnimating = false; // Blocks discard update + suppresses flip prompts: deal animation in progress this.dealAnimationInProgress = false; // Track round winners for visual highlight this.roundWinnerNames = new Set(); // V3_15: Discard pile history this.discardHistory = []; this.maxDiscardHistory = 5; // Mobile detection this.isMobile = false; this.initElements(); this.initAudio(); this.initCardTooltips(); this.bindEvents(); this.initMobileDetection(); 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(); this.roomCodeInput.focus(); // Clean up URL without reloading window.history.replaceState({}, '', window.location.pathname); } } initMobileDetection() { // Set --app-height custom property to actual visible viewport height. // This works around Chrome Android's 100vh bug where vh includes the // space behind the dynamic URL bar. const setAppHeight = () => { document.documentElement.style.setProperty('--app-height', `${window.innerHeight}px`); }; window.addEventListener('resize', setAppHeight); setAppHeight(); const mql = window.matchMedia('(max-width: 500px) and (orientation: portrait)'); const update = (e) => { this.isMobile = e.matches; document.body.classList.toggle('mobile-portrait', e.matches); setAppHeight(); // Close any open drawers on layout change if (!e.matches) { this.closeDrawers(); } }; mql.addEventListener('change', update); update(mql); // Bottom bar drawer toggles const bottomBar = document.getElementById('mobile-bottom-bar'); const backdrop = document.getElementById('drawer-backdrop'); if (bottomBar) { bottomBar.addEventListener('click', (e) => { const btn = e.target.closest('.mobile-bar-btn'); if (!btn) return; const drawerId = btn.dataset.drawer; const panel = document.getElementById(drawerId); if (!panel) return; const isOpen = panel.classList.contains('drawer-open'); this.closeDrawers(); if (!isOpen) { panel.classList.add('drawer-open'); btn.classList.add('active'); if (backdrop) backdrop.classList.add('visible'); if (bottomBar) bottomBar.classList.add('hidden'); } }); } if (backdrop) { backdrop.addEventListener('click', () => this.closeDrawers()); } } closeDrawers() { document.querySelectorAll('.side-panel.drawer-open').forEach(p => p.classList.remove('drawer-open')); document.querySelectorAll('.mobile-bar-btn.active').forEach(b => b.classList.remove('active')); const backdrop = document.getElementById('drawer-backdrop'); if (backdrop) backdrop.classList.remove('visible'); const bottomBar = document.getElementById('mobile-bottom-bar'); if (bottomBar) bottomBar.classList.remove('hidden'); } 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') { // V3_16: Card place with variation + noise const pitchVar = 1 + (Math.random() - 0.5) * 0.1; oscillator.frequency.setValueAtTime(800 * pitchVar, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(400 * pitchVar, 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); this.playNoiseBurst(ctx, 0.02, 0.03); } 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') { // V3_16: Enhanced sharp snap with noise texture + pitch variation const pitchVar = 1 + (Math.random() - 0.5) * 0.15; oscillator.type = 'square'; oscillator.frequency.setValueAtTime(1800 * pitchVar, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(600 * pitchVar, 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); // Add noise burst for paper texture this.playNoiseBurst(ctx, 0.03, 0.02); } 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); } else if (type === 'alert') { // Rising triad for final turn announcement oscillator.type = 'triangle'; oscillator.frequency.setValueAtTime(523, ctx.currentTime); // C5 oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5 oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5 gainNode.gain.setValueAtTime(0.15, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.4); } else if (type === 'pair') { // Two-tone ding for column pair match const osc2 = ctx.createOscillator(); osc2.connect(gainNode); oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5 osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6 gainNode.gain.setValueAtTime(0.1, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); oscillator.start(ctx.currentTime); osc2.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.3); osc2.stop(ctx.currentTime + 0.3); } else if (type === 'draw-deck') { // Mysterious slide + rise for unknown card oscillator.type = 'triangle'; oscillator.frequency.setValueAtTime(300, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1); oscillator.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15); gainNode.gain.setValueAtTime(0.08, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.2); } else if (type === 'draw-discard') { // Quick decisive grab sound oscillator.type = 'square'; oscillator.frequency.setValueAtTime(600, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05); gainNode.gain.setValueAtTime(0.08, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.06); } else if (type === 'knock') { // Dramatic low thud for knock early oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(80, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15); gainNode.gain.setValueAtTime(0.4, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.2); // Secondary impact setTimeout(() => { const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.connect(gain2); gain2.connect(ctx.destination); osc2.type = 'sine'; osc2.frequency.setValueAtTime(60, ctx.currentTime); gain2.gain.setValueAtTime(0.2, ctx.currentTime); gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1); osc2.start(ctx.currentTime); osc2.stop(ctx.currentTime + 0.1); }, 100); } } // V3_16: Noise burst for realistic card texture playNoiseBurst(ctx, volume, duration) { try { const bufferSize = Math.floor(ctx.sampleRate * duration); const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const output = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { output[i] = Math.random() * 2 - 1; } const noise = ctx.createBufferSource(); noise.buffer = buffer; const noiseGain = ctx.createGain(); noise.connect(noiseGain); noiseGain.connect(ctx.destination); noiseGain.gain.setValueAtTime(volume, ctx.currentTime); noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); noise.start(ctx.currentTime); noise.stop(ctx.currentTime + duration); } catch (e) { // Noise burst is optional, don't break if it fails } } toggleSound() { this.soundEnabled = !this.soundEnabled; this.muteBtn.textContent = this.soundEnabled ? 'πŸ”Š' : 'πŸ”‡'; this.playSound('click'); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // --- V3_13: Card Value Tooltips --- initCardTooltips() { this.tooltip = document.createElement('div'); this.tooltip.className = 'card-value-tooltip hidden'; document.body.appendChild(this.tooltip); this.tooltipTimeout = null; } bindCardTooltipEvents(cardElement, cardData) { if (!cardData?.face_up || !cardData?.rank) return; // Desktop hover with delay cardElement.addEventListener('mouseenter', () => { this.scheduleTooltip(cardElement, cardData); }); cardElement.addEventListener('mouseleave', () => { this.hideCardTooltip(); }); // Mobile long-press let pressTimer = null; cardElement.addEventListener('touchstart', () => { pressTimer = setTimeout(() => { this.showCardTooltip(cardElement, cardData); }, 400); }, { passive: true }); cardElement.addEventListener('touchend', () => { clearTimeout(pressTimer); this.hideCardTooltip(); }); cardElement.addEventListener('touchmove', () => { clearTimeout(pressTimer); this.hideCardTooltip(); }, { passive: true }); } scheduleTooltip(cardElement, cardData) { this.hideCardTooltip(); if (!cardData?.face_up || !cardData?.rank) return; this.tooltipTimeout = setTimeout(() => { this.showCardTooltip(cardElement, cardData); }, 500); } showCardTooltip(cardElement, cardData) { if (!cardData?.face_up || !cardData?.rank) return; if (this.swapAnimationInProgress) return; // Only show tooltips on your turn if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return; const value = this.getCardPointValueForTooltip(cardData); const special = this.getCardSpecialNote(cardData); let content = `${value} pts`; if (special) { content += `${special}`; } this.tooltip.innerHTML = content; this.tooltip.classList.remove('hidden'); // Position below card const rect = cardElement.getBoundingClientRect(); let left = rect.left + rect.width / 2; let top = rect.bottom + 8; // Keep on screen if (top + 50 > window.innerHeight) { top = rect.top - 50; } left = Math.max(40, Math.min(window.innerWidth - 40, left)); this.tooltip.style.left = `${left}px`; this.tooltip.style.top = `${top}px`; } hideCardTooltip() { clearTimeout(this.tooltipTimeout); if (this.tooltip) this.tooltip.classList.add('hidden'); } getCardPointValueForTooltip(cardData) { const values = this.gameState?.card_values || this.getDefaultCardValues(); const rules = this.gameState?.scoring_rules || {}; return this.getCardPointValue(cardData, values, rules); } getCardSpecialNote(cardData) { const rank = cardData.rank; const value = this.getCardPointValueForTooltip(cardData); if (value < 0) return 'Negative - keep it!'; if (rank === 'K' && value === 0) return 'Safe card'; if (rank === 'K' && value === -2) return 'Super King!'; if (rank === '10' && value === 1) return 'Ten Penny rule'; if ((rank === 'J' || rank === 'Q') && value >= 10) return 'High - replace if possible'; return null; } initElements() { // Screens this.lobbyScreen = document.getElementById('lobby-screen'); this.matchmakingScreen = document.getElementById('matchmaking-screen'); this.waitingScreen = document.getElementById('waiting-screen'); this.gameScreen = document.getElementById('game-screen'); // Lobby elements this.roomCodeInput = document.getElementById('room-code'); this.findGameBtn = document.getElementById('find-game-btn'); this.createRoomBtn = document.getElementById('create-room-btn'); this.joinRoomBtn = document.getElementById('join-room-btn'); this.lobbyError = document.getElementById('lobby-error'); // Matchmaking elements this.matchmakingStatus = document.getElementById('matchmaking-status'); this.matchmakingTime = document.getElementById('matchmaking-time'); this.matchmakingQueueInfo = document.getElementById('matchmaking-queue-info'); this.cancelMatchmakingBtn = document.getElementById('cancel-matchmaking-btn'); this.matchmakingTimer = null; // 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.numDecksInput = document.getElementById('num-decks'); this.numDecksDisplay = document.getElementById('num-decks-display'); this.decksMinus = document.getElementById('decks-minus'); this.decksPlus = document.getElementById('decks-plus'); this.deckRecommendation = document.getElementById('deck-recommendation'); this.deckColorsGroup = document.getElementById('deck-colors-group'); this.deckColorPresetSelect = document.getElementById('deck-color-preset'); this.deckColorPreview = document.getElementById('deck-color-preview'); 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.unrankedNotice = document.getElementById('unranked-notice'); 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.findGameBtn?.addEventListener('click', () => { this.playSound('click'); this.findGame(); }); this.cancelMatchmakingBtn?.addEventListener('click', () => { this.playSound('click'); this.cancelMatchmaking(); }); 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(); }); const mobileLeaveBtn = document.getElementById('mobile-leave-btn'); if (mobileLeaveBtn) mobileLeaveBtn.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.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(); }); // Deck stepper controls if (this.decksMinus) { this.decksMinus.addEventListener('click', () => { this.playSound('click'); this.adjustDeckCount(-1); }); } if (this.decksPlus) { this.decksPlus.addEventListener('click', () => { this.playSound('click'); this.adjustDeckCount(1); }); } // Update preview when color preset changes if (this.deckColorPresetSelect) { this.deckColorPresetSelect.addEventListener('change', () => { this.updateDeckColorPreview(); }); } // 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); // Show/hide unranked notice when house rules change const houseRuleInputs = [ this.flipModeSelect, this.knockPenaltyCheckbox, this.superKingsCheckbox, this.tenPennyCheckbox, this.knockBonusCheckbox, this.underdogBonusCheckbox, this.tiedShameCheckbox, this.blackjackCheckbox, this.wolfpackCheckbox, this.flipAsActionCheckbox, this.fourOfAKindCheckbox, this.negativePairsCheckbox, this.oneEyedJacksCheckbox, this.knockEarlyCheckbox, ]; const jokerRadios = document.querySelectorAll('input[name="joker-mode"]'); const updateUnrankedNotice = () => { const hasHouseRules = ( (this.flipModeSelect?.value && this.flipModeSelect.value !== 'never') || this.knockPenaltyCheckbox?.checked || (document.querySelector('input[name="joker-mode"]:checked')?.value !== 'none') || this.superKingsCheckbox?.checked || this.tenPennyCheckbox?.checked || this.knockBonusCheckbox?.checked || this.underdogBonusCheckbox?.checked || this.tiedShameCheckbox?.checked || this.blackjackCheckbox?.checked || this.wolfpackCheckbox?.checked || this.flipAsActionCheckbox?.checked || this.fourOfAKindCheckbox?.checked || this.negativePairsCheckbox?.checked || this.oneEyedJacksCheckbox?.checked || this.knockEarlyCheckbox?.checked ); this.unrankedNotice?.classList.toggle('hidden', !hasHouseRules); }; houseRuleInputs.forEach(el => el?.addEventListener('change', updateUnrankedNotice)); jokerRadios.forEach(el => el.addEventListener('change', updateUnrankedNotice)); // 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'; let wsUrl = `${protocol}//${host}/ws`; // Attach auth token if available const token = this.authManager?.token; if (token) { wsUrl += `?token=${encodeURIComponent(token)}`; } 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'); if (!this._intentionalClose) { this.showError('Connection lost. Please refresh the page.'); } this._intentionalClose = false; }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.showError('Connection error. Please try again.'); }; } reconnect() { if (this.ws) { this.ws.onclose = null; // Prevent error message on intentional close this.ws.close(); } this.connect(); } 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.dismissScoresheetModal(); 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.drawPulseAnimation = false; // V3_15: Clear discard history for new round this.clearDiscardHistory(); // Cancel any running animations from previous round if (window.cardAnimations) { window.cardAnimations.cancelAll(); } this.showGameScreen(); // V3_02: Animate dealing instead of instant render this.runDealAnimation(); 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) { debugLog('STATE', 'Deferring state - swap animation in progress'); this.updateSwapAnimation(data.game_state.discard_top); this.pendingGameState = data.game_state; break; } const oldState = this.gameState; const newState = data.game_state; debugLog('STATE', 'Received game_state', { phase: newState.phase, currentPlayer: newState.current_player_id?.slice(-4), discardTop: newState.discard_top ? `${newState.discard_top.rank}${newState.discard_top.suit?.[0]}` : 'EMPTY', drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}${newState.drawn_card.suit?.[0]}` : null, drawnBy: newState.drawn_player_id?.slice(-4) || null, hasDrawn: newState.has_drawn_card }); // V3_03: Intercept round_over transition to defer card reveals. // The problem: the last turn's swap animation flips a card, and then // the round-end reveal animation would flip it again. We snapshot the // old state, patch it to mark the swap position as already face-up, // and use that as the "before" for the reveal animation. const roundJustEnded = oldState?.phase !== 'round_over' && newState.phase === 'round_over'; if (roundJustEnded && oldState) { // Update state first so animations can read new card data this.gameState = newState; // Fire animations for the last turn (swap/discard) before deferring try { this.triggerAnimationsForStateChange(oldState, newState); } catch (e) { console.error('Animation error on round end:', e); } // Build preRevealState from oldState, but mark swap position as // already handled so reveal animation doesn't double-flip it. // Without this patch, the card visually flips twice in a row. const preReveal = JSON.parse(JSON.stringify(oldState)); if (this.opponentSwapAnimation) { const { playerId, position } = this.opponentSwapAnimation; const player = preReveal.players.find(p => p.id === playerId); if (player?.cards[position]) { player.cards[position].face_up = true; } } this.preRevealState = preReveal; this.postRevealState = newState; break; } // 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(); // Stop all initial flip pulse animations if (window.cardAnimations) { window.cardAnimations.stopAllInitialFlipPulses(); } } // 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 console.log('[DEBUG] About to renderGame, flags:', { isDrawAnimating: this.isDrawAnimating, localDiscardAnimating: this.localDiscardAnimating, opponentDiscardAnimating: this.opponentDiscardAnimating, opponentSwapAnimation: !!this.opponentSwapAnimation, discardTop: newState.discard_top ? `${newState.discard_top.rank}-${newState.discard_top.suit}` : 'none' }); this.renderGame(); break; case 'your_turn': // Clear any stale opponent animation flags since it's now our turn this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; console.log('[DEBUG] your_turn received - clearing opponent animation flags'); // Immediately update display to show correct discard pile this.renderGame(); // Brief delay to let animations settle before showing toast 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'; if (data.source === 'deck' && window.drawAnimations) { // Deck draw: use shared animation system (flip at deck, move to hold) // Hide held card during animation - animation callback will show it // Clear any stale opponent animation flags since it's now our turn this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; this.isDrawAnimating = true; this.hideDrawnCard(); window.drawAnimations.animateDrawDeck(data.card, () => { this.isDrawAnimating = false; this.displayHeldCard(data.card, true); this.renderGame(); }); } else if (data.source === 'discard' && window.drawAnimations) { // Discard draw: use shared animation system (lift and move) this.isDrawAnimating = true; this.hideDrawnCard(); // Clear any in-progress swap animation to prevent race conditions this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; window.drawAnimations.animateDrawDiscard(data.card, () => { this.isDrawAnimating = false; this.displayHeldCard(data.card, true); this.renderGame(); }); } else { // Fallback: just show the card this.displayHeldCard(data.card, true); this.renderGame(); } this.showToast('Swap with a card or discard', 'your-turn', 3000); break; case 'can_flip': this.waitingForFlip = true; this.flipIsOptional = data.optional || false; if (this.flipIsOptional) { this.showToast('Flip a card or skip', 'your-turn', 3000); } else { this.showToast('Flip a face-down card', 'your-turn', 3000); } this.renderGame(); break; case 'round_over': // V3_03: Run dramatic reveal before showing scoreboard this.runRoundEndReveal(data.scores, 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._intentionalClose = true; if (this.ws) this.ws.close(); this.showLobby(); if (data.reason) { this.showError(data.reason); } break; case 'queue_joined': this.showScreen('matchmaking'); this.startMatchmakingTimer(); this.updateMatchmakingStatus(data); break; case 'queue_status': this.updateMatchmakingStatus(data); break; case 'queue_matched': this.stopMatchmakingTimer(); if (this.matchmakingStatus) { this.matchmakingStatus.textContent = 'Match found!'; } break; case 'queue_left': this.stopMatchmakingTimer(); this.showLobby(); break; case 'error': this.showError(data.message); break; } } // Matchmaking findGame() { this.connect(); this.ws.onopen = () => { this.send({ type: 'queue_join' }); }; } cancelMatchmaking() { this.send({ type: 'queue_leave' }); this.stopMatchmakingTimer(); this.showLobby(); } startMatchmakingTimer() { this.matchmakingStartTime = Date.now(); this.stopMatchmakingTimer(); this.matchmakingTimer = setInterval(() => { const elapsed = Math.floor((Date.now() - this.matchmakingStartTime) / 1000); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; if (this.matchmakingTime) { this.matchmakingTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; } }, 1000); } stopMatchmakingTimer() { if (this.matchmakingTimer) { clearInterval(this.matchmakingTimer); this.matchmakingTimer = null; } } updateMatchmakingStatus(data) { if (this.matchmakingQueueInfo) { const parts = []; if (data.queue_size) parts.push(`${data.queue_size} player${data.queue_size !== 1 ? 's' : ''} in queue`); if (data.position) parts.push(`Position: #${data.position}`); this.matchmakingQueueInfo.textContent = parts.join(' \u2022 '); } } // Room Actions createRoom() { const name = this.authManager?.user?.username || 'Player'; this.connect(); this.ws.onopen = () => { this.send({ type: 'create_room', player_name: name }); }; } joinRoom() { const name = this.authManager?.user?.username || '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.numDecksInput?.value || '1'); const rounds = parseInt(this.numRoundsSelect?.value || '9'); const initial_flips = parseInt(this.initialFlipsSelect?.value || '2'); // Standard options const flip_mode = this.flipModeSelect?.value || 'always'; // "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; // Deck colors const deck_colors = this.getDeckColors(decks); 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, deck_colors }); } 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; // Sound played by draw animation 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; // Sound played by draw animation 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'); // Capture the actual position of the held card before hiding it const heldRect = this.heldCardFloating.getBoundingClientRect(); // Hide the floating held card immediately (animation will create its own) this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.style.cssText = ''; // Three-part race guard. All three are needed, and they protect different things: // 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing // (it starts at opacity:0, which causes a visible flash) // 2. lastDiscardKey: prevents renderGame() from detecting a "change" to the // discard pile and re-rendering it mid-animation // 3. localDiscardAnimating: blocks renderGame() from touching the discard DOM // entirely until our animation callback fires // Remove any one of these and you get a different flavor of visual glitch. this.skipNextDiscardFlip = true; this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; this.localDiscardAnimating = true; // Animate held card to discard using anime.js if (window.cardAnimations) { window.cardAnimations.animateHeldToDiscard(discardedCard, heldRect, () => { this.updateDiscardPileDisplay(discardedCard); this.pulseDiscardLand(); this.skipNextDiscardFlip = true; this.localDiscardAnimating = false; }); } else { // Fallback: just update immediately this.updateDiscardPileDisplay(discardedCard); this.localDiscardAnimating = false; } } // Update the discard pile display with a card // Note: Don't use renderCardContent here - the card may have face_up=false // (drawn cards aren't marked face_up until server processes discard) 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'); // Render directly - discard pile cards are always visible this.discardContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`; } 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; const handRect = handCardEl.getBoundingClientRect(); const heldRect = this.heldCardFloating?.getBoundingClientRect(); // Mark animating this.swapAnimationInProgress = true; this.swapAnimationCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl; // Hide originals and UI during animation handCardEl.classList.add('swap-out'); this.discardBtn.classList.add('hidden'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = 'hidden'; } // Store drawn card data before clearing const drawnCardData = this.drawnCard; this.drawnCard = null; this.skipNextDiscardFlip = true; // Send swap to server this.send({ type: 'swap', position }); if (isAlreadyFaceUp && card) { // Face-up card - we know both cards, animate immediately this.swapAnimationContentSet = true; if (window.cardAnimations) { window.cardAnimations.animateUnifiedSwap( card, // handCardData - card going to discard drawnCardData, // heldCardData - drawn card going to hand handRect, // handRect heldRect, // heldRect { rotation: 0, wasHandFaceDown: false, onComplete: () => { handCardEl.classList.remove('swap-out'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = ''; } this.completeSwapAnimation(null); } } ); } else { setTimeout(() => { handCardEl.classList.remove('swap-out'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = ''; } this.completeSwapAnimation(null); }, 500); } } else { // Face-down card - wait for server to tell us what the card was // Store context for updateSwapAnimation to use this.swapAnimationContentSet = false; this.pendingSwapData = { handCardEl, handRect, heldRect, drawnCardData, position }; } } // Update the animated card with actual card content when server responds updateSwapAnimation(card) { // Skip if we already set the content (face-up card swap) if (this.swapAnimationContentSet) return; // Safety check if (!this.swapAnimationInProgress || !card) { return; } // Now we have the card data - run the unified animation this.swapAnimationContentSet = true; const data = this.pendingSwapData; if (!data) { console.error('Swap animation missing pending data'); this.completeSwapAnimation(null); return; } const { handCardEl, handRect, heldRect, drawnCardData } = data; if (window.cardAnimations) { window.cardAnimations.animateUnifiedSwap( card, // handCardData - now we know what it was drawnCardData, // heldCardData - drawn card going to hand handRect, // handRect heldRect, // heldRect { rotation: 0, wasHandFaceDown: true, onComplete: () => { if (handCardEl) handCardEl.classList.remove('swap-out'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = ''; } this.pendingSwapData = null; this.completeSwapAnimation(null); } } ); } else { // Fallback setTimeout(() => { if (handCardEl) handCardEl.classList.remove('swap-out'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = ''; } this.pendingSwapData = null; this.completeSwapAnimation(null); }, 500); } } completeSwapAnimation(heldCard) { // Guard against double completion if (!this.swapAnimationInProgress) return; // 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.swapAnimationContentSet = false; this.pendingSwapData = null; this.discardBtn.classList.add('hidden'); this.heldCardFloating.classList.add('hidden'); if (this.pendingGameState) { const oldState = this.gameState; const newState = this.pendingGameState; this.pendingGameState = null; // Check if the deferred state is a round_over transition const roundJustEnded = oldState?.phase !== 'round_over' && newState.phase === 'round_over'; if (roundJustEnded && oldState) { // Same intercept as the game_state handler: store pre/post // reveal states so runRoundEndReveal can animate the reveal this.gameState = newState; const preReveal = JSON.parse(JSON.stringify(oldState)); this.preRevealState = preReveal; this.postRevealState = newState; // Don't renderGame - let the reveal sequence handle it } else { this.gameState = newState; this.checkForNewPairs(oldState, newState); 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() { // V3_09: Knock early with confirmation dialog if (!this.gameState || !this.gameState.knock_early) return; const myData = this.getMyPlayerData(); if (!myData) return; const hiddenCards = myData.cards.filter(c => !c.face_up); if (hiddenCards.length === 0 || hiddenCards.length > 2) return; this.showKnockConfirmation(hiddenCards.length, () => { this.executeKnockEarly(); }); } showKnockConfirmation(hiddenCount, onConfirm) { const modal = document.createElement('div'); modal.className = 'knock-confirm-modal'; modal.innerHTML = `
⚑

Knock Early?

You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.

This cannot be undone!

`; document.body.appendChild(modal); modal.querySelector('.knock-cancel').addEventListener('click', () => { this.playSound('click'); modal.remove(); }); modal.querySelector('.knock-confirm').addEventListener('click', () => { this.playSound('click'); modal.remove(); onConfirm(); }); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } async executeKnockEarly() { this.playSound('knock'); const myData = this.getMyPlayerData(); if (!myData) return; const hiddenPositions = myData.cards .map((card, i) => ({ card, position: i })) .filter(({ card }) => !card.face_up) .map(({ position }) => position); // Rapid sequential flips for (const position of hiddenPositions) { this.fireLocalFlipAnimation(position, myData.cards[position]); this.playSound('flip'); await this.delay(150); } await this.delay(300); this.showKnockBanner(); this.send({ type: 'knock_early' }); this.hideToast(); } showKnockBanner(playerName) { const duration = window.TIMING?.knock?.statusDuration || 2500; const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!'; this.setStatus(message, 'knock'); document.body.classList.add('screen-shake'); setTimeout(() => { document.body.classList.remove('screen-shake'); }, 300); // Restore normal status after duration this._knockStatusTimeout = setTimeout(() => { // Only clear if still showing knock status if (this.statusMessage.classList.contains('knock')) { this.setStatus('Final turn!', 'opponent-turn'); } }, duration); } // --- V3_17: Scoresheet Modal --- showScoresheetModal(scores, gameState, rankings) { // Remove existing modal if any const existing = document.getElementById('scoresheet-modal'); if (existing) existing.remove(); // Also update side panel data (silently) this.showScoreboard(scores, false, rankings); const cardValues = gameState?.card_values || this.getDefaultCardValues(); const scoringRules = gameState?.scoring_rules || {}; const knockerId = gameState?.finisher_id; const currentRound = gameState?.current_round || '?'; const totalRounds = gameState?.total_rounds || '?'; // Find round winner(s) const roundScores = scores.map(s => s.score); const minRoundScore = Math.min(...roundScores); // Build player rows - knocker first, then others const ordered = [...gameState.players].sort((a, b) => { if (a.id === knockerId) return -1; if (b.id === knockerId) return 1; return 0; }); const playerRowsHtml = ordered.map(player => { const scoreData = scores.find(s => s.name === player.name) || {}; const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false); const isKnocker = player.id === knockerId; const isLowScore = scoreData.score === minRoundScore; const colIndices = [[0, 3], [1, 4], [2, 5]]; // Badge let badge = ''; if (isKnocker && isLowScore) badge = 'KNOCKEDLOW SCORE'; else if (isKnocker) badge = 'KNOCKED'; else if (isLowScore) badge = 'LOW SCORE'; // Build columns const columnsHtml = colIndices.map((indices, c) => { const col = result.columns[c]; const topCard = player.cards[indices[0]]; const bottomCard = player.cards[indices[1]]; const isPair = col.isPair; const topMini = this.renderMiniCard(topCard, isPair); const bottomMini = this.renderMiniCard(bottomCard, isPair); let colScore; if (isPair) { const pairLabel = col.pairValue !== 0 ? `PAIR ${col.pairValue}` : 'PAIR 0'; colScore = `
${pairLabel}
`; } else { const val = col.topValue + col.bottomValue; const cls = val < 0 ? 'ss-negative' : ''; colScore = `
${val >= 0 ? '+' + val : val}
`; } return `
${topMini}${bottomMini} ${colScore}
`; }).join(''); // Bonuses let bonusHtml = ''; if (result.bonuses.length > 0) { bonusHtml = result.bonuses.map(b => { const label = b.type === 'wolfpack' ? 'WOLFPACK' : 'FOUR OF A KIND'; return `${label} ${b.value}`; }).join(' '); } const roundScore = scoreData.score !== undefined ? scoreData.score : '-'; const totalScore = scoreData.total !== undefined ? scoreData.total : '-'; return `
${player.name} ${badge}
${columnsHtml}
${bonusHtml ? `
${bonusHtml}
` : ''}
Hole: ${roundScore} Total: ${totalScore}
`; }).join(''); // Create modal const modal = document.createElement('div'); modal.id = 'scoresheet-modal'; modal.className = 'scoresheet-modal'; modal.innerHTML = `
Hole ${currentRound} of ${totalRounds}
${playerRowsHtml}
`; document.body.appendChild(modal); this.setStatus('Hole complete'); // Hide bottom bar so it doesn't overlay the modal const bottomBar = document.getElementById('mobile-bottom-bar'); if (bottomBar) bottomBar.classList.add('hidden'); // Bind next button const nextBtn = document.getElementById('ss-next-btn'); nextBtn.addEventListener('click', () => { this.playSound('click'); this.dismissScoresheetModal(); this.nextRound(); }); // Start countdown this.startScoresheetCountdown(nextBtn); // Animate entrance if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { this.animateScoresheetEntrance(modal); } } renderMiniCard(card, isPaired) { if (!card || !card.rank) return '
'; const suit = card.suit; const isRed = suit === 'hearts' || suit === 'diamonds'; const symbol = this.getSuitSymbol(suit); const rank = card.rank === 'β˜…' ? 'β˜…' : card.rank; const classes = [ 'ss-mini-card', isRed ? 'ss-red' : 'ss-black', isPaired ? 'ss-mini-paired' : '' ].filter(Boolean).join(' '); return `
${rank}${symbol}
`; } animateScoresheetEntrance(modal) { const T = window.TIMING?.scoresheet || {}; const playerRows = modal.querySelectorAll('.ss-player-row'); const nextBtn = modal.querySelector('.ss-next-btn'); // Start everything hidden playerRows.forEach(row => { row.style.opacity = '0'; row.style.transform = 'translateY(10px)'; }); if (nextBtn) { nextBtn.style.opacity = '0'; } // Stagger player rows in if (window.anime) { anime({ targets: Array.from(playerRows), opacity: [0, 1], translateY: [10, 0], delay: anime.stagger(T.playerStagger || 150), duration: 300, easing: 'easeOutCubic', complete: () => { // Animate paired columns glow setTimeout(() => { modal.querySelectorAll('.ss-column-paired').forEach(col => { col.classList.add('ss-pair-glow'); }); }, T.pairGlowDelay || 200); } }); // Fade in button after rows const totalRowDelay = (playerRows.length - 1) * (T.playerStagger || 150) + 300; anime({ targets: nextBtn, opacity: [0, 1], delay: totalRowDelay, duration: 200, easing: 'easeOutCubic', }); } else { // No anime.js - show immediately playerRows.forEach(row => { row.style.opacity = '1'; row.style.transform = ''; }); if (nextBtn) nextBtn.style.opacity = '1'; } } startScoresheetCountdown(btn) { this.clearScoresheetCountdown(); const COUNTDOWN_SECONDS = 15; let remaining = COUNTDOWN_SECONDS; const update = () => { if (this.isHost) { btn.textContent = `Next Hole (${remaining}s)`; btn.disabled = false; } else { btn.textContent = `Next hole in ${remaining}s...`; btn.disabled = true; } }; update(); this.scoresheetCountdownInterval = setInterval(() => { remaining--; if (remaining <= 0) { this.clearScoresheetCountdown(); if (this.isHost) { this.dismissScoresheetModal(); this.nextRound(); } else { btn.textContent = 'Waiting for host...'; } } else { update(); } }, 1000); } clearScoresheetCountdown() { if (this.scoresheetCountdownInterval) { clearInterval(this.scoresheetCountdownInterval); this.scoresheetCountdownInterval = null; } } dismissScoresheetModal() { this.clearScoresheetCountdown(); const modal = document.getElementById('scoresheet-modal'); if (modal) modal.remove(); // Restore bottom bar const bottomBar = document.getElementById('mobile-bottom-bar'); if (bottomBar) bottomBar.classList.remove('hidden'); } // --- V3_02: Dealing Animation --- runDealAnimation() { // Respect reduced motion preference if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { this.playSound('shuffle'); this.renderGame(); return; } // Render first so card slot positions exist this.renderGame(); // Hide cards during animation this.playerCards.style.visibility = 'hidden'; this.opponentsRow.style.visibility = 'hidden'; // Suppress flip prompts until dealing complete this.dealAnimationInProgress = true; if (window.cardAnimations) { // Use double-rAF to ensure layout is fully computed after renderGame(). // First rAF: browser computes styles. Second rAF: layout is painted. // This is critical on mobile where CSS !important rules need to apply // before getBoundingClientRect() returns correct card slot positions. requestAnimationFrame(() => { requestAnimationFrame(() => { // Verify rects are valid before starting animation const testRect = this.getCardSlotRect(this.playerId, 0); if (this.isMobile) { console.log('[DEAL] Starting deal animation, test rect:', testRect); } window.cardAnimations.animateDealing( this.gameState, (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx), () => { // Deal complete - allow flip prompts this.dealAnimationInProgress = false; // Show real cards this.playerCards.style.visibility = 'visible'; this.opponentsRow.style.visibility = 'visible'; this.renderGame(); // Stagger opponent initial flips right after dealing this.animateOpponentInitialFlips(); } ); }); }); } else { // Fallback this.dealAnimationInProgress = false; this.playerCards.style.visibility = 'visible'; this.opponentsRow.style.visibility = 'visible'; this.playSound('shuffle'); } } animateOpponentInitialFlips() { const T = window.TIMING?.initialFlips || {}; const windowStart = T.windowStart || 500; const windowEnd = T.windowEnd || 2500; const cardStagger = T.cardStagger || 400; const opponents = this.gameState.players.filter(p => p.id !== this.playerId); // Collect face-up cards per opponent and convert them to show backs for (const player of opponents) { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${player.id}"]` ); if (!area) continue; const cardEls = area.querySelectorAll('.card-grid .card'); const faceUpCards = []; player.cards.forEach((card, idx) => { if (card.face_up && cardEls[idx]) { const el = cardEls[idx]; faceUpCards.push({ el, card, idx }); // Convert to card-back appearance while waiting el.className = 'card card-back'; if (this.gameState?.deck_colors) { const deckId = card.deck_id || 0; const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0]; if (color) el.classList.add(`back-${color}`); } el.innerHTML = ''; } }); if (faceUpCards.length > 0) { const rotation = this.getElementRotation(area); // Each opponent starts at a random time within the window (concurrent, not sequential) const startDelay = windowStart + Math.random() * (windowEnd - windowStart); setTimeout(() => { faceUpCards.forEach(({ el, card }, i) => { setTimeout(() => { window.cardAnimations.animateOpponentFlip(el, card, rotation); }, i * cardStagger); }); }, startDelay); } } } getCardSlotRect(playerId, cardIdx) { if (playerId === this.playerId) { const cards = this.playerCards.querySelectorAll('.card'); const rect = cards[cardIdx]?.getBoundingClientRect() || null; if (this.isMobile && rect) { console.log(`[DEAL-DEBUG] Player card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height}); } return rect; } else { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${playerId}"]` ); if (area) { const cards = area.querySelectorAll('.card'); const rect = cards[cardIdx]?.getBoundingClientRect() || null; if (this.isMobile && rect) { console.log(`[DEAL-DEBUG] Opponent ${playerId} card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height}); } return rect; } } return null; } // --- V3_03: Round End Dramatic Reveal --- async runRoundEndReveal(scores, rankings) { const T = window.TIMING?.reveal || {}; // preRevealState may not be set yet if the game_state was deferred // (e.g., local swap animation was in progress). Wait briefly for it. if (!this.preRevealState) { const waitStart = Date.now(); while (!this.preRevealState && Date.now() - waitStart < 3000) { await this.delay(100); } } const oldState = this.preRevealState; const newState = this.postRevealState || this.gameState; if (!oldState || !newState) { // Fallback: show scoresheet immediately this.showScoresheetModal(scores, this.gameState, rankings); return; } // Compute what needs revealing (before renderGame changes the DOM) const revealsByPlayer = this.getCardsToReveal(oldState, newState); // Get reveal order: knocker first, then clockwise const knockerId = newState.finisher_id; const revealOrder = this.getRevealOrder(newState.players, knockerId); // Wait for the last player's animation (swap/discard/draw) to finish // so the final play is visible before the reveal sequence starts const maxWait = 3000; const start = Date.now(); while (Date.now() - start < maxWait) { if (!this.isDrawAnimating && !this.opponentSwapAnimation && !this.opponentDiscardAnimating && !this.localDiscardAnimating && !this.swapAnimationInProgress) { break; } await this.delay(100); } // Extra pause so the final play registers visually before we // re-render the board (renderGame below resets card positions) await this.delay(T.lastPlayPause || 2500); // Now render with pre-reveal state (face-down cards) for the reveal sequence this.gameState = newState; this.revealAnimationInProgress = true; this.renderGame(); this.setStatus('Revealing cards...', 'reveal'); await this.delay(T.initialPause || 500); // Reveal each player's cards for (const player of revealOrder) { const cardsToFlip = revealsByPlayer.get(player.id) || []; if (cardsToFlip.length === 0) continue; // Highlight player area this.highlightPlayerArea(player.id, true); await this.delay(T.highlightDuration || 200); // Flip each card with stagger for (const { position, card } of cardsToFlip) { this.animateRevealFlip(player.id, position, card); await this.delay(T.cardStagger || 100); } // Wait for last flip to complete + pause await this.delay(300 + (T.playerPause || 400)); // Remove highlight this.highlightPlayerArea(player.id, false); } // All revealed - show scoresheet modal this.revealAnimationInProgress = false; this.preRevealState = null; this.postRevealState = null; this.renderGame(); // V3_17: Scoresheet modal replaces tally + side panel scoreboard this.showScoresheetModal(scores, newState, rankings); } getCardsToReveal(oldState, newState) { const reveals = new Map(); for (const newPlayer of newState.players) { const oldPlayer = oldState.players.find(p => p.id === newPlayer.id); if (!oldPlayer) continue; const cardsToFlip = []; for (let i = 0; i < 6; i++) { const wasHidden = !oldPlayer.cards[i]?.face_up; const nowVisible = newPlayer.cards[i]?.face_up; if (wasHidden && nowVisible) { cardsToFlip.push({ position: i, card: newPlayer.cards[i] }); } } if (cardsToFlip.length > 0) { reveals.set(newPlayer.id, cardsToFlip); } } return reveals; } getRevealOrder(players, knockerId) { const knocker = players.find(p => p.id === knockerId); const others = players.filter(p => p.id !== knockerId); if (knocker) { return [knocker, ...others]; } return others; } highlightPlayerArea(playerId, highlight) { if (playerId === this.playerId) { this.playerArea.classList.toggle('revealing', highlight); } else { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${playerId}"]` ); if (area) { area.classList.toggle('revealing', highlight); } } } animateRevealFlip(playerId, position, cardData) { if (playerId === this.playerId) { // Local player card const cards = this.playerCards.querySelectorAll('.card'); const cardEl = cards[position]; if (cardEl && window.cardAnimations) { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { // Re-render this card to show revealed state this.renderGame(); }); } } else { // Opponent card const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${playerId}"]` ); if (area) { const cards = area.querySelectorAll('.card'); const cardEl = cards[position]; if (cardEl && window.cardAnimations) { const rotation = this.getElementRotation(area); window.cardAnimations.animateOpponentFlip(cardEl, cardData, rotation); } } } this.playSound('flip'); } // --- V3_07: Animated Score Tallying --- async runScoreTally(players, knockerId) { const T = window.TIMING?.tally || {}; await this.delay(T.initialPause || 300); const cardValues = this.gameState?.card_values || this.getDefaultCardValues(); const scoringRules = this.gameState?.scoring_rules || {}; // Order: knocker first, then others const ordered = [...players].sort((a, b) => { if (a.id === knockerId) return -1; if (b.id === knockerId) return 1; return 0; }); for (const player of ordered) { const cardEls = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5); if (cardEls.length < 6) continue; // Use shared scoring logic (all cards revealed at round end) const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false); // Highlight player area this.highlightPlayerArea(player.id, true); const colIndices = [[0, 3], [1, 4], [2, 5]]; for (let c = 0; c < 3; c++) { const [topIdx, bottomIdx] = colIndices[c]; const col = result.columns[c]; const topCard = cardEls[topIdx]; const bottomCard = cardEls[bottomIdx]; if (col.isPair) { topCard?.classList.add('tallying'); bottomCard?.classList.add('tallying'); this.showPairCancel(topCard, bottomCard, col.pairValue); await this.delay(T.pairCelebration || 400); } else { // Show individual card values topCard?.classList.add('tallying'); const topOverlay = this.showCardValue(topCard, col.topValue, col.topValue < 0); await this.delay(T.cardHighlight || 200); bottomCard?.classList.add('tallying'); const bottomOverlay = this.showCardValue(bottomCard, col.bottomValue, col.bottomValue < 0); await this.delay(T.cardHighlight || 200); this.hideCardValue(topOverlay); this.hideCardValue(bottomOverlay); } topCard?.classList.remove('tallying'); bottomCard?.classList.remove('tallying'); await this.delay(T.columnPause || 150); } // Show bonuses (wolfpack, four-of-a-kind) if (result.bonuses.length > 0) { for (const bonus of result.bonuses) { const label = bonus.type === 'wolfpack' ? 'WOLFPACK!' : 'FOUR OF A KIND!'; this.showBonusOverlay(player.id, label, bonus.value); await this.delay(T.pairCelebration || 400); } } this.highlightPlayerArea(player.id, false); await this.delay(T.playerPause || 500); } } showCardValue(cardElement, value, isNegative) { if (!cardElement) return null; const overlay = document.createElement('div'); overlay.className = 'card-value-overlay'; if (isNegative) overlay.classList.add('negative'); if (value === 0) overlay.classList.add('zero'); const sign = value > 0 ? '+' : ''; overlay.textContent = `${sign}${value}`; const rect = cardElement.getBoundingClientRect(); overlay.style.left = `${rect.left + rect.width / 2}px`; overlay.style.top = `${rect.top + rect.height / 2}px`; document.body.appendChild(overlay); // Trigger reflow then animate in void overlay.offsetWidth; overlay.classList.add('visible'); return overlay; } hideCardValue(overlay) { if (!overlay) return; overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 200); } showPairCancel(card1, card2, pairValue = 0) { if (!card1 || !card2) return; const rect1 = card1.getBoundingClientRect(); const rect2 = card2.getBoundingClientRect(); const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4; const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4; const sign = pairValue > 0 ? '+' : ''; const overlay = document.createElement('div'); overlay.className = 'pair-cancel-overlay'; if (pairValue < 0) overlay.classList.add('negative'); overlay.textContent = `PAIR! ${sign}${pairValue}`; overlay.style.left = `${centerX}px`; overlay.style.top = `${centerY}px`; document.body.appendChild(overlay); card1.classList.add('pair-matched'); card2.classList.add('pair-matched'); this.playSound('pair'); setTimeout(() => { overlay.remove(); card1.classList.remove('pair-matched'); card2.classList.remove('pair-matched'); }, 600); } showBonusOverlay(playerId, label, value) { const area = playerId === this.playerId ? this.playerArea : this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`); if (!area) return; const rect = area.getBoundingClientRect(); const overlay = document.createElement('div'); overlay.className = 'pair-cancel-overlay negative'; overlay.textContent = `${label} ${value}`; overlay.style.left = `${rect.left + rect.width / 2}px`; overlay.style.top = `${rect.top + rect.height / 2}px`; document.body.appendChild(overlay); this.playSound('pair'); setTimeout(() => overlay.remove(), 600); } getDefaultCardValues() { return { '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 }; } // Fire-and-forget animation triggers based on state diffs. // Two-step detection: // STEP 1: Did someone draw? (drawn_card goes null -> something) // STEP 2: Did someone finish their turn? (discard pile changed + turn advanced) // Critical: if STEP 1 detects a draw-from-discard, STEP 2 must be skipped. // The discard pile changed because a card was REMOVED, not ADDED. Without this // suppression, we'd fire a phantom discard animation for a card nobody discarded. triggerAnimationsForStateChange(oldState, newState) { if (!oldState) return; const currentPlayerId = newState.current_player_id; const previousPlayerId = oldState.current_player_id; const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId; // 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); debugLog('DIFFER', 'State diff', { discardChanged, oldDiscard: oldDiscard ? `${oldDiscard.rank}${oldDiscard.suit?.[0]}` : 'EMPTY', newDiscard: newDiscard ? `${newDiscard.rank}${newDiscard.suit?.[0]}` : 'EMPTY', turnChanged: previousPlayerId !== currentPlayerId, wasOtherPlayer }); // STEP 1: Detect when someone DRAWS (drawn_card goes from null to something) const justDrew = !oldState.drawn_card && newState.drawn_card; const drawingPlayerId = newState.drawn_player_id; const isOtherPlayerDrawing = drawingPlayerId && drawingPlayerId !== this.playerId; if (justDrew && isOtherPlayerDrawing) { // Detect source: if old discard is gone, they took from discard const discardWasTaken = oldDiscard && (!newDiscard || newDiscard.rank !== oldDiscard.rank || newDiscard.suit !== oldDiscard.suit); debugLog('DIFFER', 'Other player drew', { source: discardWasTaken ? 'discard' : 'deck', drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}` : '?' }); // Use shared draw animation system for consistent look if (window.drawAnimations) { // Set flag to defer held card display until animation completes this.drawPulseAnimation = true; const drawnCard = newState.drawn_card; const onAnimComplete = () => { this.drawPulseAnimation = false; // Show the held card after animation (no popIn - match local player) if (this.gameState?.drawn_card) { this.displayHeldCard(this.gameState.drawn_card, false); } }; if (discardWasTaken) { // Clear any in-progress animations to prevent race conditions this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; // Set isDrawAnimating to block renderGame from updating discard pile this.isDrawAnimating = true; // Force discard DOM to show the card being drawn before animation starts // (previous animation may have blocked renderGame from updating it) if (oldDiscard) { this.updateDiscardPileDisplay(oldDiscard); } console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true'); window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => { console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating'); this.isDrawAnimating = false; onAnimComplete(); }); } else { // Clear any in-progress animations to prevent race conditions this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; this.isDrawAnimating = true; console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY'); window.drawAnimations.animateDrawDeck(drawnCard, () => { console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); this.isDrawAnimating = false; onAnimComplete(); }); } } // Show CPU action announcement const drawingPlayer = newState.players.find(p => p.id === drawingPlayerId); if (drawingPlayer?.is_cpu) { if (discardWasTaken && oldDiscard) { this.showCpuAction(drawingPlayer.name, 'draw-discard', oldDiscard); } else { this.showCpuAction(drawingPlayer.name, 'draw-deck'); } } } // V3_15: Track discard history if (discardChanged && newDiscard) { this.trackDiscardHistory(newDiscard); } // Track if we detected a draw this update - if so, skip STEP 2 // Drawing from discard changes the discard pile but isn't a "discard" action const justDetectedDraw = justDrew && isOtherPlayerDrawing; if (justDetectedDraw && discardChanged) { console.log('[DEBUG] Skipping STEP 2 - discard change was from draw, not discard action'); } // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances) // Skip if we just detected a draw β€” see comment at top of function. if (discardChanged && wasOtherPlayer && !justDetectedDraw) { // Figure out if the previous player SWAPPED (a card in their hand changed) // or just discarded their drawn card (hand is identical). // Three cases to detect a swap: // Case 1: face-down -> face-up (normal swap into hidden position) // Case 2: both face-up but different card (swap into already-revealed position) // Case 3: card identity null -> known (race condition: face_up flag lagging behind) const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId); if (oldPlayer && newPlayer) { 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) { console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank }); // 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 && wasOtherPlayer) { // Opponent drew and discarded without swapping (cards unchanged) this.fireDiscardAnimation(newDiscard, previousPlayerId); // Show CPU discard announcement if (oldPlayer?.is_cpu) { this.showCpuAction(oldPlayer.name, 'discard', newDiscard); } } // Skip the card-flip-in animation since we just did our own this.skipNextDiscardFlip = true; } } // V3_04: Check for new column pairs after any state change this.checkForNewPairs(oldState, newState); // V3_09: Detect opponent knock (phase transition to final_turn) if (oldState.phase !== 'final_turn' && newState.phase === 'final_turn') { const knocker = newState.players.find(p => p.id === newState.finisher_id); if (knocker && knocker.id !== this.playerId) { this.playSound('alert'); this.showKnockBanner(knocker.name); } // V3_14: Highlight relevant knock rules if (this.gameState?.knock_penalty) { this.highlightRule('knock_penalty', '+10 if beaten!'); } if (this.gameState?.knock_bonus) { this.highlightRule('knock_bonus', '-5 for going out!'); } } // 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; } } } } } // --- V3_04: Column Pair Detection --- checkForNewPairs(oldState, newState) { if (!oldState || !newState) return; const columns = [[0, 3], [1, 4], [2, 5]]; for (const newPlayer of newState.players) { const oldPlayer = oldState.players.find(p => p.id === newPlayer.id); if (!oldPlayer) continue; for (const [top, bottom] of columns) { const wasPaired = this.isColumnPaired(oldPlayer.cards, top, bottom); const nowPaired = this.isColumnPaired(newPlayer.cards, top, bottom); if (!wasPaired && nowPaired) { // New pair formed! setTimeout(() => { this.firePairCelebration(newPlayer.id, top, bottom); }, window.TIMING?.celebration?.pairDelay || 50); } } } } isColumnPaired(cards, pos1, pos2) { const c1 = cards[pos1]; const c2 = cards[pos2]; return c1?.face_up && c2?.face_up && c1?.rank && c2?.rank && c1.rank === c2.rank; } firePairCelebration(playerId, pos1, pos2) { this.playSound('pair'); const elements = this.getCardElements(playerId, pos1, pos2); if (elements.length < 2) return; if (window.cardAnimations) { window.cardAnimations.celebratePair(elements[0], elements[1]); } } getCardElements(playerId, ...positions) { const elements = []; if (playerId === this.playerId) { const cards = this.playerCards.querySelectorAll('.card'); for (const pos of positions) { if (cards[pos]) elements.push(cards[pos]); } } else { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${playerId}"]` ); if (area) { const cards = area.querySelectorAll('.card'); for (const pos of positions) { if (cards[pos]) elements.push(cards[pos]); } } } return elements; } // V3_15: Track discard pile history trackDiscardHistory(card) { if (!card) return; // Avoid duplicates at front if (this.discardHistory.length > 0 && this.discardHistory[0].rank === card.rank && this.discardHistory[0].suit === card.suit) return; this.discardHistory.unshift({ rank: card.rank, suit: card.suit }); if (this.discardHistory.length > this.maxDiscardHistory) { this.discardHistory = this.discardHistory.slice(0, this.maxDiscardHistory); } this.updateDiscardDepth(); } updateDiscardDepth() { if (!this.discard) return; const depth = Math.min(this.discardHistory.length, 3); this.discard.dataset.depth = depth; } clearDiscardHistory() { this.discardHistory = []; if (this.discard) this.discard.dataset.depth = '0'; } // V3_10: Render persistent pair indicators on all players' cards renderPairIndicators() { if (!this.gameState) return; const columns = [[0, 3], [1, 4], [2, 5]]; for (const player of this.gameState.players) { const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5); if (cards.length < 6) continue; // Clear previous pair classes cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom')); for (const [top, bottom] of columns) { if (this.isColumnPaired(player.cards, top, bottom)) { cards[top]?.classList.add('paired', 'pair-top'); cards[bottom]?.classList.add('paired', 'pair-bottom'); } } } } // Flash animation on deck or discard pile to show where opponent drew from // Defers held card display until pulse completes for clean sequencing pulseDrawPile(source) { const T = window.TIMING?.feedback || {}; const pulseDuration = T.drawPulse || 450; const pile = source === 'discard' ? this.discard : this.deck; // Set flag to defer held card display this.drawPulseAnimation = true; pile.classList.remove('draw-pulse'); void pile.offsetWidth; pile.classList.add('draw-pulse'); // After pulse completes, show the held card setTimeout(() => { pile.classList.remove('draw-pulse'); this.drawPulseAnimation = false; // Show the held card (no pop-in - match local player behavior) if (this.gameState?.drawn_card && this.gameState?.drawn_player_id !== this.playerId) { this.displayHeldCard(this.gameState.drawn_card, false); } }, pulseDuration); } // Pulse discard pile when a card lands on it // Optional callback fires after pulse completes (for sequencing turn indicator update) pulseDiscardLand(onComplete = null) { // Use anime.js for discard pulse if (window.cardAnimations) { window.cardAnimations.pulseDiscard(); } // Execute callback after animation const T = window.TIMING?.feedback || {}; const duration = T.discardLand || 375; setTimeout(() => { if (onComplete) onComplete(); }, duration); } // 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 && window.cardAnimations) { // Block renderGame from updating discard during animation this.opponentDiscardAnimating = true; this.skipNextDiscardFlip = true; // Update lastDiscardKey so renderGame won't see a "change" and trigger flip animation this.lastDiscardKey = `${discardCard.rank}-${discardCard.suit}`; // Animate card from hold β†’ discard using anime.js window.cardAnimations.animateOpponentDiscard(discardCard, () => { this.opponentDiscardAnimating = false; this.updateDiscardPileDisplay(discardCard); this.pulseDiscardLand(); }); } // Skip animation entirely for local player } // 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) - unified arc swap for all players 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 area = this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`); let sourceRect = null; let sourceCardEl = null; let sourceRotation = 0; if (area) { const cards = area.querySelectorAll('.card-grid .card'); if (cards.length > position && position >= 0) { sourceCardEl = cards[position]; sourceRect = sourceCardEl.getBoundingClientRect(); sourceRotation = this.getElementRotation(area); } } // Get the held card data (what's being swapped IN to the hand) const player = this.gameState?.players.find(p => p.id === playerId); const newCardInHand = player?.cards[position]; // Safety check - need valid data for animation if (!sourceRect || !discardCard || !newCardInHand) { console.warn('fireSwapAnimation: missing data', { sourceRect: !!sourceRect, discardCard: !!discardCard, newCardInHand: !!newCardInHand }); this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; this.renderGame(); return; } // Hide the source card during animation sourceCardEl.classList.add('swap-out'); // Use unified swap animation if (window.cardAnimations) { const heldRect = window.cardAnimations.getHoldingRect(); window.cardAnimations.animateUnifiedSwap( discardCard, // handCardData - card going to discard newCardInHand, // heldCardData - card going to hand sourceRect, // handRect - where the hand card is heldRect, // heldRect - holding position, opponent card size { rotation: sourceRotation, wasHandFaceDown: !wasFaceUp, onComplete: () => { if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); // Don't re-render during reveal animation - it handles its own rendering if (!this.revealAnimationInProgress) { this.renderGame(); } } } ); } else { // Fallback setTimeout(() => { if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; console.log('[DEBUG] Swap animation fallback complete - clearing flags'); // Don't re-render during reveal animation - it handles its own rendering if (!this.revealAnimationInProgress) { this.renderGame(); } }, 500); } } // 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; } // Use the unified card animation system for consistent flip animation if (window.cardAnimations) { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { this.animatingPositions.delete(key); // Unhide the current card element (may have been rebuilt by renderGame) const currentCards = this.playerCards.querySelectorAll('.card'); if (currentCards[position]) { currentCards[position].style.visibility = ''; } }); } else { // Fallback if card animations not available this.animatingPositions.delete(key); } } // 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; } // Use the unified card animation system for consistent flip animation if (window.cardAnimations) { window.cardAnimations.animateOpponentFlip(cardEl, cardData, sourceRotation); } // Clear tracking after animation duration setTimeout(() => { this.animatingPositions.delete(key); }, (window.TIMING?.card?.flip || 400) + 100); } 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`, 'your-turn', 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.clearScoresheetCountdown(); 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) { // Accept string names or DOM elements if (typeof screen === 'string') { const screenMap = { 'lobby': this.lobbyScreen, 'matchmaking': this.matchmakingScreen, 'waiting': this.waitingScreen, 'game': this.gameScreen, }; screen = screenMap[screen] || screen; } this.lobbyScreen.classList.remove('active'); this.matchmakingScreen?.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'); // Close mobile drawers on screen change if (this.isMobile) { this.closeDrawers(); } // 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() { if (window.cardAnimations) { window.cardAnimations.cancelAll(); } this.dealAnimationInProgress = false; this.isDrawAnimating = false; this.localDiscardAnimating = false; this.opponentDiscardAnimating = false; this.opponentSwapAnimation = false; 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'); // Initialize deck color preview this.updateDeckColorPreview(); } 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 const leaveText = this.isHost ? 'End Game' : 'Leave'; this.leaveGameBtn.textContent = leaveText; const mobileLeave = document.getElementById('mobile-leave-btn'); if (mobileLeave) mobileLeave.textContent = leaveText; // Update active rules bar this.updateActiveRulesBar(); } updateActiveRulesBar() { if (!this.gameState) { this.activeRulesBar.classList.add('hidden'); return; } const rules = this.gameState.active_rules || []; // V3_14: Add data-rule attributes for contextual highlighting const renderTag = (rule) => { const key = this.getRuleKey(rule); return `${rule}`; }; const unrankedTag = this.gameState.is_standard_rules === false ? 'Unranked' : ''; if (rules.length === 0) { this.activeRulesList.innerHTML = 'Standard'; } else if (rules.length <= 2) { this.activeRulesList.innerHTML = unrankedTag + rules.map(renderTag).join(''); } else { const displayed = rules.slice(0, 2); const hidden = rules.slice(2); const moreCount = hidden.length; const tooltip = hidden.join(', '); this.activeRulesList.innerHTML = unrankedTag + displayed.map(renderTag).join('') + `+${moreCount} more`; } this.activeRulesBar.classList.remove('hidden'); // Update mobile rules indicator const mobileRulesBtn = document.getElementById('mobile-rules-btn'); const mobileRulesIcon = document.getElementById('mobile-rules-icon'); const mobileRulesContent = document.getElementById('mobile-rules-content'); if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) { const isHouseRules = rules.length > 0; mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES'; mobileRulesBtn.classList.toggle('house-rules', isHouseRules); if (!isHouseRules) { mobileRulesContent.innerHTML = '
Standard Rules
'; } else { const tagHtml = (unrankedTag ? 'Unranked' : '') + rules.map(renderTag).join(''); mobileRulesContent.innerHTML = `
${tagHtml}
`; } } } // V3_14: Map display names to rule keys getRuleKey(ruleName) { const mapping = { 'Speed Golf': 'flip_mode', 'Endgame Flip': 'flip_mode', 'Knock Penalty': 'knock_penalty', 'Knock Bonus': 'knock_bonus', 'Super Kings': 'super_kings', 'Ten Penny': 'ten_penny', 'Lucky Swing': 'lucky_swing', 'Eagle Eye': 'eagle_eye', 'Underdog': 'underdog_bonus', 'Tied Shame': 'tied_shame', 'Blackjack': 'blackjack', 'Wolfpack': 'wolfpack', 'Flip Action': 'flip_as_action', '4 of a Kind': 'four_of_a_kind', 'Negative Pairs': 'negative_pairs_keep_value', 'One-Eyed Jacks': 'one_eyed_jacks', 'Knock Early': 'knock_early', }; return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_'); } // V3_14: Contextual rule highlighting highlightRule(ruleKey, message, duration = 3000) { const ruleTag = this.activeRulesList?.querySelector(`[data-rule="${ruleKey}"]`); if (!ruleTag) return; ruleTag.classList.add('rule-highlighted'); const messageEl = document.createElement('span'); messageEl.className = 'rule-message'; messageEl.textContent = message; ruleTag.appendChild(messageEl); setTimeout(() => { ruleTag.classList.remove('rule-highlighted'); messageEl.remove(); }, duration); } 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) { if (this.numDecksInput) this.numDecksInput.value = '2'; if (this.numDecksDisplay) this.numDecksDisplay.textContent = '2'; this.updateDeckColorPreview(); } // Update deck recommendation visibility this.updateDeckRecommendation(players.length); } updateDeckRecommendation(playerCount) { if (!this.isHost || !this.deckRecommendation) return; const decks = parseInt(this.numDecksInput?.value || '1'); // 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'); } } adjustDeckCount(delta) { if (!this.numDecksInput) return; let current = parseInt(this.numDecksInput.value) || 1; let newValue = Math.max(1, Math.min(3, current + delta)); this.numDecksInput.value = newValue; if (this.numDecksDisplay) { this.numDecksDisplay.textContent = newValue; } // Update related UI const playerCount = this.currentPlayers ? this.currentPlayers.length : 0; this.updateDeckRecommendation(playerCount); this.updateDeckColorPreview(); } getDeckColors(numDecks) { const multiColorPresets = { classic: ['red', 'blue', 'gold'], ninja: ['green', 'purple', 'orange'], ocean: ['blue', 'teal', 'cyan'], forest: ['green', 'gold', 'brown'], sunset: ['orange', 'red', 'purple'], berry: ['purple', 'pink', 'red'], neon: ['pink', 'cyan', 'green'], royal: ['purple', 'gold', 'red'], earth: ['brown', 'green', 'gold'] }; const singleColorPresets = { 'all-red': 'red', 'all-blue': 'blue', 'all-green': 'green', 'all-gold': 'gold', 'all-purple': 'purple', 'all-teal': 'teal', 'all-pink': 'pink', 'all-slate': 'slate' }; const preset = this.deckColorPresetSelect?.value || 'classic'; if (singleColorPresets[preset]) { const color = singleColorPresets[preset]; return Array(numDecks).fill(color); } const colors = multiColorPresets[preset] || multiColorPresets.classic; return colors.slice(0, numDecks); } updateDeckColorPreview() { if (!this.deckColorPreview) return; const numDecks = parseInt(this.numDecksInput?.value || '1'); const colors = this.getDeckColors(numDecks); this.deckColorPreview.innerHTML = ''; colors.forEach(color => { const card = document.createElement('div'); card.className = `preview-card deck-${color}`; this.deckColorPreview.appendChild(card); }); } isMyTurn() { return this.gameState && this.gameState.current_player_id === this.playerId; } // Visual check: don't show "my turn" indicators until opponent swap animation completes isVisuallyMyTurn() { if (this.opponentSwapAnimation) return false; return this.isMyTurn(); } 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 and opponent area 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; const isOtherTurn = currentPlayer && currentPlayer.id !== this.playerId; if (isCpuTurn && hasNotDrawn) { this.discard.classList.add('cpu-considering'); if (window.cardAnimations) { window.cardAnimations.startCpuThinking(this.discard); } } else { this.discard.classList.remove('cpu-considering'); if (window.cardAnimations) { window.cardAnimations.stopCpuThinking(this.discard); } } // V3_06: Update thinking indicator and opponent area glow this.opponentsRow.querySelectorAll('.opponent-area').forEach(area => { const playerId = area.dataset.playerId; const isThisPlayer = playerId === this.gameState.current_player_id; const player = this.gameState.players.find(p => p.id === playerId); const isCpu = player?.is_cpu; // Thinking indicator visibility const indicator = area.querySelector('.thinking-indicator'); if (indicator) { indicator.classList.toggle('hidden', !(isCpu && isThisPlayer && hasNotDrawn)); } // Opponent area thinking glow (anime.js) if (isOtherTurn && isThisPlayer && hasNotDrawn && window.cardAnimations) { window.cardAnimations.startOpponentThinking(area); } else if (window.cardAnimations) { window.cardAnimations.stopOpponentThinking(area); } }); } 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 if (isFinalTurn) { this.updateFinalTurnDisplay(); } else { this.finalTurnBadge.classList.add('hidden'); this.gameScreen.classList.remove('final-turn-active'); this.finalTurnAnnounced = false; this.clearKnockerMark(); } if (currentPlayer && currentPlayer.id !== this.playerId) { this.setStatus(`${currentPlayer.name}'s turn`, 'opponent-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(''); } } // --- V3_05: Final Turn Urgency --- updateFinalTurnDisplay() { const finisherId = this.gameState?.finisher_id; // Toggle game area class for border pulse this.gameScreen.classList.add('final-turn-active'); // Show badge this.finalTurnBadge.classList.remove('hidden'); // Mark knocker this.markKnocker(finisherId); // Play alert sound on first appearance if (!this.finalTurnAnnounced) { this.playSound('alert'); this.finalTurnAnnounced = true; } } countRemainingTurns() { if (!this.gameState || this.gameState.phase !== 'final_turn') return 0; const finisherId = this.gameState.finisher_id; const players = this.gameState.players; const currentIdx = players.findIndex(p => p.id === this.gameState.current_player_id); const finisherIdx = players.findIndex(p => p.id === finisherId); if (currentIdx === -1 || finisherIdx === -1) return 0; let count = 0; let idx = currentIdx; while (idx !== finisherIdx) { count++; idx = (idx + 1) % players.length; } return count; } markKnocker(knockerId) { this.clearKnockerMark(); if (!knockerId) return; if (knockerId === this.playerId) { this.playerArea.classList.add('is-knocker'); const badge = document.createElement('div'); badge.className = 'knocker-badge'; badge.textContent = 'OUT'; this.playerArea.appendChild(badge); } else { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${knockerId}"]` ); if (area) { area.classList.add('is-knocker'); const badge = document.createElement('div'); badge.className = 'knocker-badge'; badge.textContent = 'OUT'; area.appendChild(badge); } } } clearKnockerMark() { document.querySelectorAll('.is-knocker').forEach(el => { el.classList.remove('is-knocker'); }); document.querySelectorAll('.knocker-badge').forEach(el => { el.remove(); }); } 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) // On mobile portrait, place held card fully above the deck/discard area const isMobilePortrait = document.body.classList.contains('mobile-portrait'); const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35); 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`; // Scale font to card width (matches cardAnimations.cardFontSize ratio) if (this.isMobile) { this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; } // Position discard button if (isMobilePortrait) { // Below the held card, centered const btnRect = this.discardBtn.getBoundingClientRect(); const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2; const buttonTop = cardTop + cardHeight + 4; this.discardBtn.style.left = `${buttonLeft}px`; this.discardBtn.style.top = `${buttonTop}px`; } else { // Right side of held card (desktop) const buttonLeft = cardLeft + cardWidth; const buttonTop = cardTop + cardHeight * 0.3; 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'); } } // Display a face-down held card (for when opponent draws from deck) displayHeldCardFaceDown() { // Set up as face-down card with deck color (use deck_top_deck_id for the color) let className = 'card card-back held-card-floating'; if (this.gameState?.deck_colors) { const deckId = this.gameState.deck_top_deck_id || 0; const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0]; if (color) className += ` back-${color}`; } this.heldCardFloating.className = className; this.heldCardFloating.style.cssText = ''; // Position centered above and between deck and discard const deckRect = this.deck.getBoundingClientRect(); const discardRect = this.discard.getBoundingClientRect(); const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const cardWidth = deckRect.width; const cardHeight = deckRect.height; const isMobilePortrait = document.body.classList.contains('mobile-portrait'); const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35); 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`; if (this.isMobile) { this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; } this.heldCardFloatingContent.innerHTML = ''; this.heldCardFloating.classList.remove('hidden'); 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'; } /** * Get the point value for a single card, respecting house rules. * Handles one_eyed_jacks (Jβ™₯/Jβ™  = 0) which can't be in the card_values map. */ getCardPointValue(card, cardValues, scoringRules) { if (!card.rank) return 0; if (scoringRules?.one_eyed_jacks && card.rank === 'J' && (card.suit === 'hearts' || card.suit === 'spades')) { return 0; } return cardValues[card.rank] ?? 0; } /** * Calculate structured scoring results for a 6-card hand. * Single source of truth for client-side scoring logic. * * @param {Array} cards - 6-element array of card data objects ({rank, suit, face_up}) * @param {Object} cardValues - rankβ†’point map from server (includes lucky_swing, super_kings, etc.) * @param {Object} scoringRules - house rule flags from server (eagle_eye, negative_pairs_keep_value, etc.) * @param {boolean} onlyFaceUp - if true, only count face-up cards (for live score badge) * @returns {{ columns: Array<{isPair, pairValue, topValue, bottomValue}>, bonuses: Array<{type, value}>, total: number }} */ calculateColumnScores(cards, cardValues, scoringRules, onlyFaceUp = false) { const rules = scoringRules || {}; const columns = []; let total = 0; let jackPairs = 0; const pairedRanks = []; 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; const topValue = (topUp || !onlyFaceUp) ? this.getCardPointValue(topCard, cardValues, rules) : 0; const bottomValue = (bottomUp || !onlyFaceUp) ? this.getCardPointValue(bottomCard, cardValues, rules) : 0; const bothVisible = onlyFaceUp ? (topUp && bottomUp) : true; const isPair = bothVisible && topCard.rank && bottomCard.rank && topCard.rank === bottomCard.rank; if (isPair) { pairedRanks.push(topCard.rank); if (topCard.rank === 'J') jackPairs++; let pairValue = 0; // Eagle Eye: paired jokers score -4 if (rules.eagle_eye && topCard.rank === 'β˜…') { pairValue = -4; } // Negative Pairs Keep Value: negative-value pairs keep their score else if (rules.negative_pairs_keep_value && (topValue < 0 || bottomValue < 0)) { pairValue = topValue + bottomValue; } // Normal pair: 0 total += pairValue; columns.push({ isPair: true, pairValue, topValue, bottomValue }); } else { total += topValue + bottomValue; columns.push({ isPair: false, pairValue: 0, topValue, bottomValue }); } } // Bonuses const bonuses = []; if (rules.wolfpack && jackPairs >= 2) { bonuses.push({ type: 'wolfpack', value: -20 }); total += -20; } if (rules.four_of_a_kind) { const rankCounts = {}; for (const r of pairedRanks) { rankCounts[r] = (rankCounts[r] || 0) + 1; } for (const [rank, count] of Object.entries(rankCounts)) { if (count >= 2) { bonuses.push({ type: 'four_of_a_kind', value: -20, rank }); total += -20; } } } return { columns, bonuses, total }; } calculateShowingScore(cards) { if (!cards || cards.length !== 6) return 0; const cardValues = this.gameState?.card_values || this.getDefaultCardValues(); const scoringRules = this.gameState?.scoring_rules || {}; return this.calculateColumnScores(cards, cardValues, scoringRules, true).total; } getSuitSymbol(suit) { const symbols = { hearts: 'β™₯', diamonds: '♦', clubs: '♣', spades: 'β™ ' }; return symbols[suit] || ''; } renderCardContent(card) { if (!card || !card.face_up) return ''; // Handle locally-flipped cards where rank/suit aren't known yet if (!card.rank || !card.suit) { 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; if (this.dealAnimationInProgress) return; // Update CPU considering visual state this.updateCpuConsideringState(); // Update header this.currentRoundSpan.textContent = this.gameState.current_round; this.totalRoundsSpan.textContent = this.gameState.total_rounds; // Sync mobile bottom bar round info const mobileRound = document.getElementById('mobile-current-round'); const mobileTotal = document.getElementById('mobile-total-rounds'); if (mobileRound) mobileRound.textContent = this.gameState.current_round; if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds; // Show/hide final turn badge with enhanced urgency // Note: markKnocker() is deferred until after opponent areas are rebuilt below const isFinalTurn = this.gameState.phase === 'final_turn'; if (isFinalTurn) { this.gameScreen.classList.add('final-turn-active'); this.finalTurnBadge.classList.remove('hidden'); if (!this.finalTurnAnnounced) { this.playSound('alert'); this.finalTurnAnnounced = true; } } else { this.finalTurnBadge.classList.add('hidden'); this.gameScreen.classList.remove('final-turn-active'); this.finalTurnAnnounced = false; } // Toggle not-my-turn class to disable hover effects when it's not player's turn // Use visual check so turn indicators sync with discard land animation const isVisuallyMyTurn = this.isVisuallyMyTurn(); this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn); // V3_08: Toggle can-swap class for card hover preview when holding a drawn card this.playerArea.classList.toggle('can-swap', !!this.drawnCard && this.isMyTurn()); // Highlight player area when it's their turn (matching opponent-area.current-turn) const isActivePlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; this.playerArea.classList.toggle('current-turn', isVisuallyMyTurn && isActivePlaying); // Update status message (handled by specific actions, but set default here) // During opponent swap animation, show the animating player (not the new current player) const displayedPlayerId = this.opponentSwapAnimation ? this.opponentSwapAnimation.playerId : this.gameState.current_player_id; const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId); if (displayedPlayer && displayedPlayerId !== this.playerId) { this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-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; // Dealer chip on player area const isDealer = this.playerId === this.gameState.dealer_id; let dealerChip = this.playerArea.querySelector('.dealer-chip'); if (isDealer && !dealerChip) { dealerChip = document.createElement('div'); dealerChip.className = 'dealer-chip'; dealerChip.textContent = 'D'; this.playerArea.appendChild(dealerChip); } else if (!isDealer && dealerChip) { dealerChip.remove(); } } // Update discard pile // Check if ANY player is holding a card (local or remote/CPU) const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card; debugLog('RENDER', 'Discard pile', { anyPlayerHolding: !!anyPlayerHolding, localDrawn: this.drawnCard ? `${this.drawnCard.rank}` : null, serverDrawn: this.gameState.drawn_card ? `${this.gameState.drawn_card.rank}` : null, discardTop: this.gameState.discard_top ? `${this.gameState.discard_top.rank}${this.gameState.discard_top.suit?.[0]}` : 'EMPTY' }); 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'); // The discard pile is touched by four different animation paths. // Each flag represents a different in-flight animation that "owns" the discard DOM. // renderGame() must not update the discard while any of these are active, or you'll // see the card content flash/change underneath the animation overlay. // Priority order doesn't matter β€” any one of them is reason enough to skip. const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' : this.opponentSwapAnimation ? 'opponentSwapAnimation' : this.opponentDiscardAnimating ? 'opponentDiscardAnimating' : this.isDrawAnimating ? 'isDrawAnimating' : null; if (skipReason) { console.log('[DEBUG] Skipping discard update, reason:', skipReason, 'discard_top:', this.gameState.discard_top ? `${this.gameState.discard_top.rank}-${this.gameState.discard_top.suit}` : 'none'); } if (this.localDiscardAnimating) { // Don't update discard content; animation will call updateDiscardPileDisplay } else if (this.opponentSwapAnimation) { // Don't update discard content; animation overlay shows the swap } else if (this.opponentDiscardAnimating) { // Don't update discard content; opponent discard animation in progress } else if (this.isDrawAnimating) { // Don't update discard content; draw animation in progress } 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. // lastDiscardKey is pre-set by discardDrawn() to prevent a false "change" // detection when the server confirms what we already animated locally. 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; console.log('[DEBUG] Actually updating discard pile content to:', 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 // Skip for opponents during draw pulse animation (pulse callback will show it) // Skip for local player during draw animation (animation callback will show it) if (this.drawnCard && !this.isDrawAnimating) { // 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 // But defer display during draw pulse animation for clean sequencing // Also skip for local player during their draw animation const isLocalPlayer = this.gameState.drawn_player_id === this.playerId; const skipForLocalAnim = isLocalPlayer && this.isDrawAnimating; if (!this.drawPulseAnimation && !skipForLocalAnim) { this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer); } } else { // No one holding a card this.hideDrawnCard(); } // Update deck/discard clickability and visual state // Use visual check so indicators sync with opponent swap animation const hasDrawn = this.drawnCard || this.gameState.has_drawn_card; const isRoundActive = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; const canDraw = isRoundActive && this.isVisuallyMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip; // Pulse the deck area when it's player's turn to draw const wasTurnToDraw = this.deckArea.classList.contains('your-turn-to-draw'); this.deckArea.classList.toggle('your-turn-to-draw', canDraw); // Use anime.js for turn pulse animation if (canDraw && !wasTurnToDraw && window.cardAnimations) { window.cardAnimations.startTurnPulse(this.deckArea); } else if (!canDraw && wasTurnToDraw && window.cardAnimations) { window.cardAnimations.stopTurnPulse(this.deckArea); } this.deck.classList.toggle('clickable', canDraw); // Show disabled on deck when any player has drawn (consistent dimmed look) this.deck.classList.toggle('disabled', hasDrawn); // Apply deck color based on top card's deck_id if (this.gameState.deck_colors && this.gameState.deck_colors.length > 0) { const deckId = this.gameState.deck_top_deck_id || 0; const deckColor = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0]; // Remove any existing back-* classes this.deck.className = this.deck.className.replace(/\bback-\w+\b/g, '').trim(); this.deck.classList.add(`back-${deckColor}`); } 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'; // During opponent swap animation, keep highlighting the player who just acted // (turn indicator changes after the discard lands, not before) const displayedCurrentPlayer = this.opponentSwapAnimation ? this.opponentSwapAnimation.playerId : this.gameState.current_player_id; opponents.forEach((player) => { const div = document.createElement('div'); div.className = 'opponent-area'; div.dataset.playerId = player.id; if (isPlaying && player.id === displayedCurrentPlayer) { div.classList.add('current-turn'); } const isRoundWinner = this.roundWinnerNames.has(player.name); if (isRoundWinner) { div.classList.add('round-winner'); } // Dealer chip const isDealer = player.id === this.gameState.dealer_id; const dealerChipHtml = isDealer ? '
D
' : ''; const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const showingScore = this.calculateShowingScore(player.cards); const crownHtml = isRoundWinner ? 'πŸ‘‘' : ''; // V3_06: Add thinking indicator for CPU opponents const isCpuThinking = player.is_cpu && isPlaying && player.id === displayedCurrentPlayer && !newState?.has_drawn_card; const thinkingHtml = player.is_cpu ? `πŸ€”` : ''; div.innerHTML = ` ${dealerChipHtml}

${thinkingHtml}${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.dataset.position = index; // Use anime.js for initial flip pulse if (window.cardAnimations) { window.cardAnimations.startInitialFlipPulse(cardEl.firstChild); } } cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); // V3_13: Bind tooltip events for face-up cards this.bindCardTooltipEvents(cardEl.firstChild, displayCard); const appendedCard = cardEl.firstChild; this.playerCards.appendChild(appendedCard); // Hide card if flip animation overlay is active on this position if (this.animatingPositions.has(`local-${index}`)) { appendedCard.style.visibility = 'hidden'; } }); } // V3_10: Update persistent pair indicators this.renderPairIndicators(); // Show flip prompt for initial flip // Show flip prompt during initial flip phase (but not during deal animation) if (this.gameState.waiting_for_initial_flip && !this.dealAnimationInProgress) { 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(); // Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it) if (this.gameState.phase === 'final_turn') { this.markKnocker(this.gameState.finisher_id); } else { this.clearKnockerMark(); } // Initialize anime.js hover listeners on newly created cards if (window.cardAnimations) { window.cardAnimations.initHoverListeners(this.playerCards); window.cardAnimations.initHoverListeners(this.opponentsRow); } } 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'; // Apply deck color based on card's deck_id if (this.gameState?.deck_colors) { const deckId = card.deck_id || 0; const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0]; if (color) classes += ` back-${color}`; } } 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; } // V3_17: Scoresheet modal handles Next Hole button/countdown now // Side panel just updates data silently } 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); window.game.authManager = window.auth; }); // =========================================== // 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(); // Validate stored token on load if (this.token) { this.validateToken(); } } async validateToken() { try { const response = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${this.token}` }, }); if (!response.ok) { this.logout(); } } catch { // Network error - keep token, will fail on next action } } initElements() { this.authBar = document.getElementById('auth-bar'); this.authUsername = document.getElementById('auth-username'); this.logoutBtn = document.getElementById('auth-logout-btn'); this.authPrompt = document.getElementById('auth-prompt'); this.lobbyGameControls = document.getElementById('lobby-game-controls'); 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.signupInviteCode = document.getElementById('signup-invite-code'); this.inviteCodeGroup = document.getElementById('invite-code-group'); this.inviteCodeHint = document.getElementById('invite-code-hint'); 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.signupInfo = null; // populated by fetchSignupInfo() this.showLoginLink = document.getElementById('show-login'); this.showForgotLink = document.getElementById('show-forgot'); this.forgotFormContainer = document.getElementById('forgot-form-container'); this.forgotForm = document.getElementById('forgot-form'); this.forgotEmail = document.getElementById('forgot-email'); this.forgotError = document.getElementById('forgot-error'); this.forgotSuccess = document.getElementById('forgot-success'); this.forgotBackLogin = document.getElementById('forgot-back-login'); this.resetFormContainer = document.getElementById('reset-form-container'); this.resetForm = document.getElementById('reset-form'); this.resetPassword = document.getElementById('reset-password'); this.resetPasswordConfirm = document.getElementById('reset-password-confirm'); this.resetError = document.getElementById('reset-error'); this.resetSuccess = document.getElementById('reset-success'); } 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()); this.showForgotLink?.addEventListener('click', (e) => { e.preventDefault(); this.showForm('forgot'); }); this.forgotBackLogin?.addEventListener('click', (e) => { e.preventDefault(); this.showForm('login'); }); this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e)); this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e)); // Check URL for reset token or invite code on page load this.checkResetToken(); this.checkInviteCode(); // Fetch signup availability info (metered open signups) this.fetchSignupInfo(); } showModal(form = 'login') { this.modal.classList.remove('hidden'); this.showForm(form); this.clearErrors(); } hideModal() { this.modal.classList.add('hidden'); this.clearForms(); } showForm(form) { this.loginFormContainer.classList.add('hidden'); this.signupFormContainer.classList.add('hidden'); this.forgotFormContainer?.classList.add('hidden'); this.resetFormContainer?.classList.add('hidden'); this.clearErrors(); if (form === 'login') { this.loginFormContainer.classList.remove('hidden'); this.loginUsername.focus(); } else if (form === 'signup') { this.signupFormContainer.classList.remove('hidden'); this.signupUsername.focus(); } else if (form === 'forgot') { this.forgotFormContainer?.classList.remove('hidden'); this.forgotEmail?.focus(); } else if (form === 'reset') { this.resetFormContainer?.classList.remove('hidden'); this.resetPassword?.focus(); } } clearForms() { this.loginForm.reset(); this.signupForm.reset(); this.clearErrors(); } clearErrors() { this.loginError.textContent = ''; this.signupError.textContent = ''; if (this.forgotError) this.forgotError.textContent = ''; if (this.forgotSuccess) this.forgotSuccess.textContent = ''; if (this.resetError) this.resetError.textContent = ''; if (this.resetSuccess) this.resetSuccess.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(); } catch (err) { this.loginError.textContent = 'Connection error'; } } async handleSignup(e) { e.preventDefault(); this.clearErrors(); const invite_code = this.signupInviteCode?.value.trim() || null; 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({ invite_code, 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(); } 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.authPrompt?.classList.add('hidden'); this.lobbyGameControls?.classList.remove('hidden'); if (this.authUsername) { this.authUsername.textContent = this.user.username; } } else { this.authBar?.classList.add('hidden'); this.authPrompt?.classList.remove('hidden'); this.lobbyGameControls?.classList.add('hidden'); } } checkResetToken() { const params = new URLSearchParams(window.location.search); const token = params.get('token'); const path = window.location.pathname; if (token && path.includes('reset-password')) { this._resetToken = token; this.showModal('reset'); // Clean URL window.history.replaceState({}, '', '/'); } } checkInviteCode() { const params = new URLSearchParams(window.location.search); const invite = params.get('invite'); if (invite) { this.signupInviteCode.value = invite; this.showModal('signup'); // Clean URL window.history.replaceState({}, '', '/'); } } async fetchSignupInfo() { try { const resp = await fetch('/api/auth/signup-info'); if (resp.ok) { this.signupInfo = await resp.json(); this.updateInviteCodeField(); } } catch (err) { // Fail silently β€” invite field stays required by default } } updateInviteCodeField() { if (!this.signupInfo || !this.signupInviteCode) return; const { invite_required, open_signups_enabled, remaining_today, unlimited } = this.signupInfo; if (invite_required) { this.signupInviteCode.required = true; this.signupInviteCode.placeholder = 'Invite Code (required)'; if (this.inviteCodeHint) this.inviteCodeHint.textContent = ''; } else if (open_signups_enabled) { this.signupInviteCode.required = false; this.signupInviteCode.placeholder = 'Invite Code (optional)'; if (this.inviteCodeHint) { if (unlimited) { this.inviteCodeHint.textContent = 'Open registration β€” no invite needed'; } else if (remaining_today !== null && remaining_today > 0) { this.inviteCodeHint.textContent = `${remaining_today} open signup${remaining_today !== 1 ? 's' : ''} left today`; } else if (remaining_today === 0) { this.signupInviteCode.required = true; this.signupInviteCode.placeholder = 'Invite Code (required)'; this.inviteCodeHint.textContent = 'Daily signups full β€” invite code required'; } } } } async handleForgotPassword(e) { e.preventDefault(); this.clearErrors(); const email = this.forgotEmail.value.trim(); try { const response = await fetch('/api/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!response.ok) { const data = await response.json(); this.forgotError.textContent = data.detail || 'Request failed'; return; } this.forgotSuccess.textContent = 'If an account exists with that email, a reset link has been sent.'; this.forgotForm.reset(); } catch (err) { this.forgotError.textContent = 'Connection error'; } } async handleResetPassword(e) { e.preventDefault(); this.clearErrors(); const password = this.resetPassword.value; const confirm = this.resetPasswordConfirm.value; if (password !== confirm) { this.resetError.textContent = 'Passwords do not match'; return; } try { const response = await fetch('/api/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this._resetToken, new_password: password }), }); const data = await response.json(); if (!response.ok) { this.resetError.textContent = data.detail || 'Reset failed'; return; } this.resetSuccess.textContent = 'Password reset! You can now log in.'; this.resetForm.reset(); setTimeout(() => this.showForm('login'), 2000); } catch (err) { this.resetError.textContent = 'Connection error'; } } }