// 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;
// Swap animation state
this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null;
this.swapAnimationFront = null;
this.pendingGameState = null;
// Track cards we've locally flipped (for immediate feedback during selection)
this.locallyFlippedCards = new Set();
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
// Track opponent swap animation in progress (to apply swap-out class after render)
this.opponentSwapAnimation = null; // { playerId, position }
// Track draw pulse animation in progress (defer held card display until pulse completes)
this.drawPulseAnimation = false;
// Track local discard animation in progress (prevent renderGame from updating discard)
this.localDiscardAnimating = false;
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
this.initElements();
this.initAudio();
this.bindEvents();
this.checkUrlParams();
}
checkUrlParams() {
// Handle ?room=XXXX share links
const params = new URLSearchParams(window.location.search);
const roomCode = params.get('room');
if (roomCode) {
this.roomCodeInput.value = roomCode.toUpperCase();
// Focus name input so user can quickly enter name and join
this.playerNameInput.focus();
// Clean up URL without reloading
window.history.replaceState({}, '', window.location.pathname);
}
}
initAudio() {
// Initialize audio context on first user interaction
const initCtx = () => {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
document.removeEventListener('click', initCtx);
};
document.addEventListener('click', initCtx);
}
playSound(type = 'click') {
if (!this.soundEnabled || !this.audioCtx) return;
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'click') {
oscillator.frequency.setValueAtTime(600, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05);
} else if (type === 'card') {
oscillator.frequency.setValueAtTime(800, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.08);
} else if (type === 'success') {
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} else if (type === 'flip') {
// Sharp quick click for card flips
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(1800, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02);
gainNode.gain.setValueAtTime(0.12, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.025);
} else if (type === 'shuffle') {
// Multiple quick sounds to simulate shuffling
for (let i = 0; i < 8; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
const time = ctx.currentTime + i * 0.06;
osc.frequency.setValueAtTime(200 + Math.random() * 400, time);
gain.gain.setValueAtTime(0.03, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
osc.start(time);
osc.stop(time + 0.05);
}
return; // Early return since we don't use the main oscillator
} else if (type === 'reject') {
// Low buzz for rejected action
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(150, ctx.currentTime);
oscillator.frequency.setValueAtTime(100, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.12);
}
}
toggleSound() {
this.soundEnabled = !this.soundEnabled;
this.muteBtn.textContent = this.soundEnabled ? 'š' : 'š';
this.playSound('click');
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
this.playerNameInput = document.getElementById('player-name');
this.roomCodeInput = document.getElementById('room-code');
this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn');
this.lobbyError = document.getElementById('lobby-error');
// Waiting room elements
this.displayRoomCode = document.getElementById('display-room-code');
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
this.shareRoomLinkBtn = document.getElementById('share-room-link');
this.playersList = document.getElementById('players-list');
this.hostSettings = document.getElementById('host-settings');
this.waitingMessage = document.getElementById('waiting-message');
this.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.startGameBtn = document.getElementById('start-game-btn');
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
this.cpuControlsSection = document.getElementById('cpu-controls-section');
this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn');
// Game elements
this.currentRoundSpan = document.getElementById('current-round');
this.totalRoundsSpan = document.getElementById('total-rounds');
this.statusMessage = document.getElementById('status-message');
this.playerHeader = document.getElementById('player-header');
this.yourScore = document.getElementById('your-score');
this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row');
this.deckArea = document.querySelector('.deck-area');
this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.knockEarlyBtn = document.getElementById('knock-early-btn');
this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation');
this.swapCardFromHand = document.getElementById('swap-card-from-hand');
this.heldCardSlot = document.getElementById('held-card-slot');
this.heldCardDisplay = document.getElementById('held-card-display');
this.heldCardContent = document.getElementById('held-card-content');
this.heldCardFloating = document.getElementById('held-card-floating');
this.heldCardFloatingContent = document.getElementById('held-card-floating-content');
this.scoreboard = document.getElementById('scoreboard');
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.standingsList = document.getElementById('standings-list');
this.gameButtons = document.getElementById('game-buttons');
this.nextRoundBtn = document.getElementById('next-round-btn');
this.newGameBtn = document.getElementById('new-game-btn');
this.leaveGameBtn = document.getElementById('leave-game-btn');
this.activeRulesBar = document.getElementById('active-rules-bar');
this.activeRulesList = document.getElementById('active-rules-list');
this.finalTurnBadge = document.getElementById('final-turn-badge');
// In-game auth elements
this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar');
}
bindEvents() {
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
this.leaveRoomBtn.addEventListener('click', () => { this.playSound('click'); this.leaveRoom(); });
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); });
this.removeCpuBtn.addEventListener('click', () => { this.playSound('click'); this.removeCpu(); });
this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
this.muteBtn.addEventListener('click', () => this.toggleSound());
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
// Copy room code to clipboard
this.copyRoomCodeBtn.addEventListener('click', () => {
this.playSound('click');
this.copyRoomCode();
});
// Share room link
this.shareRoomLinkBtn.addEventListener('click', () => {
this.playSound('click');
this.shareRoomLink();
});
// Enter key handlers
this.playerNameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.createRoomBtn.click();
});
this.roomCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.joinRoomBtn.click();
});
// Auto-uppercase room code
this.roomCodeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase();
});
// 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);
// Toggle scoreboard collapse on mobile
const scoreboardTitle = this.scoreboard.querySelector('h4');
if (scoreboardTitle) {
scoreboardTitle.addEventListener('click', () => {
if (window.innerWidth <= 700) {
this.scoreboard.classList.toggle('collapsed');
}
});
}
// Rules screen navigation
if (this.rulesBtn) {
this.rulesBtn.addEventListener('click', () => {
this.playSound('click');
this.showRulesScreen();
});
}
if (this.rulesBackBtn) {
this.rulesBackBtn.addEventListener('click', () => {
this.playSound('click');
this.showLobby();
});
}
}
showRulesScreen(scrollToSection = null) {
this.showScreen(this.rulesScreen);
if (scrollToSection) {
const section = document.getElementById(scrollToSection);
if (section) {
section.scrollIntoView({ behavior: 'smooth' });
}
}
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8000';
const wsUrl = `${protocol}//${host}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Connected to server');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
console.log('Disconnected from server');
this.showError('Connection lost. Please refresh the page.');
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.showError('Connection error. Please try again.');
};
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket not ready, cannot send:', message.type);
this.showError('Connection lost. Please refresh.');
}
}
handleMessage(data) {
console.log('Received:', data);
switch (data.type) {
case 'room_created':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = true;
this.showWaitingRoom();
break;
case 'room_joined':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = false;
this.showWaitingRoom();
break;
case 'player_joined':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'cpu_profiles':
this.allProfiles = data.profiles;
this.renderCpuSelect();
break;
case 'player_left':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'game_started':
case 'round_started':
// Clear any countdown from previous hole
this.clearNextHoleCountdown();
this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights
this.roundWinnerNames = new Set();
this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state));
// Reset all tracking for new round
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.drawPulseAnimation = false;
// Cancel any running animations from previous round
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.playSound('shuffle');
this.showGameScreen();
this.renderGame();
break;
case 'game_state':
// State updates are instant, animations are fire-and-forget
// Exception: Local player's swap animation defers state until complete
// If local swap animation is running, defer this state update
if (this.swapAnimationInProgress) {
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
});
// 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
this.renderGame();
break;
case 'your_turn':
// Brief delay to let animations settle
setTimeout(() => {
// Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action;
let canKnock = false;
if (this.gameState && this.gameState.knock_early) {
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
canKnock = faceDownCount >= 1 && faceDownCount <= 2;
}
if (canFlip && canKnock) {
this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
} else if (canFlip) {
this.showToast('Your turn! Draw or flip a card', 'your-turn');
} else if (canKnock) {
this.showToast('Your turn! Draw or knock', 'your-turn');
} else {
this.showToast('Your turn! Draw a card', 'your-turn');
}
}, 200);
break;
case 'card_drawn':
this.drawnCard = data.card;
this.drawnFromDiscard = data.source === 'discard';
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
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();
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', '', 3000);
break;
case 'can_flip':
this.waitingForFlip = true;
this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) {
this.showToast('Flip a card or skip', '', 3000);
} else {
this.showToast('Flip a face-down card', '', 3000);
}
this.renderGame();
break;
case 'round_over':
this.showScoreboard(data.scores, false, data.rankings);
break;
case 'game_over':
this.showScoreboard(data.final_scores, true, data.rankings);
break;
case 'game_ended':
// Host ended the game or player was kicked
this.ws.close();
this.showLobby();
if (data.reason) {
this.showError(data.reason);
}
break;
case 'error':
this.showError(data.message);
break;
}
}
// Room Actions
createRoom() {
const name = this.playerNameInput.value.trim() || 'Player';
this.connect();
this.ws.onopen = () => {
this.send({ type: 'create_room', player_name: name });
};
}
joinRoom() {
const name = this.playerNameInput.value.trim() || 'Player';
const code = this.roomCodeInput.value.trim().toUpperCase();
if (code.length !== 4) {
this.showError('Please enter a 4-letter room code');
return;
}
this.connect();
this.ws.onopen = () => {
this.send({ type: 'join_room', room_code: code, player_name: name });
};
}
leaveRoom() {
this.send({ type: 'leave_room' });
this.ws.close();
this.showLobby();
}
copyRoomCode() {
if (!this.roomCode) return;
this.copyToClipboard(this.roomCode, this.copyRoomCodeBtn);
}
shareRoomLink() {
if (!this.roomCode) return;
// Build shareable URL with room code
const url = new URL(window.location.href);
url.search = ''; // Clear existing params
url.hash = ''; // Clear hash
url.searchParams.set('room', this.roomCode);
const shareUrl = url.toString();
this.copyToClipboard(shareUrl, this.shareRoomLinkBtn);
}
copyToClipboard(text, feedbackBtn) {
// Use execCommand which is more reliable across contexts
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
console.error('Copy failed:', err);
}
document.body.removeChild(textarea);
// Show visual feedback
if (success && feedbackBtn) {
const originalText = feedbackBtn.textContent;
feedbackBtn.textContent = 'ā';
setTimeout(() => {
feedbackBtn.textContent = originalText;
}, 1500);
}
}
startGame() {
try {
const decks = parseInt(this.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');
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true;
// Swoop animation: deck ā discard (card is always held over deck)
this.animateDeckToDiscardSwoop(discardedCard);
}
// Swoop animation for discarding a card drawn from deck
animateDeckToDiscardSwoop(card) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const floater = this.heldCardFloating;
// Reset any previous animation state
floater.classList.remove('dropping', 'swooping', 'landed');
// Instantly position at deck (card appears to come from deck)
floater.style.transition = 'none';
floater.style.left = `${deckRect.left}px`;
floater.style.top = `${deckRect.top}px`;
floater.style.width = `${deckRect.width}px`;
floater.style.height = `${deckRect.height}px`;
floater.style.transform = 'scale(1) rotate(0deg)';
// Force reflow
floater.offsetHeight;
// Start swoop to discard
floater.style.transition = '';
floater.classList.add('swooping');
floater.style.left = `${discardRect.left}px`;
floater.style.top = `${discardRect.top}px`;
floater.style.width = `${discardRect.width}px`;
floater.style.height = `${discardRect.height}px`;
this.playSound('card');
// After swoop completes, settle and show on discard pile
setTimeout(() => {
floater.classList.add('landed');
setTimeout(() => {
floater.classList.add('hidden');
floater.classList.remove('swooping', 'landed');
// Clear all inline styles from the animation
floater.style.cssText = '';
this.updateDiscardPileDisplay(card);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
// Allow renderGame to update discard again
this.localDiscardAnimating = false;
}, 150); // Brief settle
}, 350); // Match swoop transition duration
}
// 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;
// Get positions
const handRect = handCardEl.getBoundingClientRect();
// Set up the animated card at hand position
const swapCard = this.swapCardFromHand;
if (!swapCard) {
// Animation element missing - fall back to non-animated swap
console.error('Swap animation element missing, falling back to direct swap');
this.swapCard(position);
return;
}
const swapCardFront = swapCard.querySelector('.swap-card-front');
// Position at the hand card location
swapCard.style.left = handRect.left + 'px';
swapCard.style.top = handRect.top + 'px';
swapCard.style.width = handRect.width + 'px';
swapCard.style.height = handRect.height + 'px';
// Reset state - no moving class needed
swapCard.classList.remove('flipping', 'moving');
swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front';
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
if (isAlreadyFaceUp && card) {
// FACE-UP CARD: Subtle pulse animation (no flip needed)
this.swapAnimationContentSet = true;
// Apply subtle swap pulse using anime.js
if (window.cardAnimations) {
window.cardAnimations.pulseSwap(handCardEl);
window.cardAnimations.pulseSwap(this.heldCardFloating);
}
// Send swap and let render handle the update
this.send({ type: 'swap', position });
this.drawnCard = null;
this.skipNextDiscardFlip = true;
// Complete after pulse animation
setTimeout(() => {
this.completeSwapAnimation(null);
}, 440);
} else {
// FACE-DOWN CARD: Flip in place to reveal, then teleport
// Hide the actual hand card
handCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Store references for updateSwapAnimation
this.swapAnimationFront = swapCardFront;
this.swapAnimationCard = swapCard;
this.swapAnimationContentSet = false;
// Send swap - the flip will happen in updateSwapAnimation when server responds
this.send({ type: 'swap', position });
this.drawnCard = null;
this.skipNextDiscardFlip = true;
}
}
// Update the animated card with actual card content when server responds
updateSwapAnimation(card) {
// Safety: if animation references are missing, complete immediately to avoid freeze
if (!this.swapAnimationFront || !card) {
if (this.swapAnimationInProgress && !this.swapAnimationContentSet) {
console.error('Swap animation incomplete: missing front element or card data');
this.completeSwapAnimation(null);
}
return;
}
// Skip if we already set the content (face-up card swap)
if (this.swapAnimationContentSet) return;
// Set card color class
this.swapAnimationFront.className = 'swap-card-front';
if (card.rank === 'ā
') {
this.swapAnimationFront.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? 'š' : 'š¹';
this.swapAnimationFront.innerHTML = `${jokerIcon}Joker`;
} else {
if (card.suit === 'hearts' || card.suit === 'diamonds') {
this.swapAnimationFront.classList.add('red');
} else {
this.swapAnimationFront.classList.add('black');
}
this.swapAnimationFront.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
}
this.swapAnimationContentSet = true;
// Quick flip to reveal, then complete - server will pause before next turn
if (this.swapAnimationCard) {
const swapCardInner = this.swapAnimationCard.querySelector('.swap-card-inner');
const flipDuration = 245; // Match other flip durations
this.playSound('flip');
// Use anime.js for the flip animation
anime({
targets: swapCardInner,
rotateY: [0, 180],
duration: flipDuration,
easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
complete: () => {
swapCardInner.style.transform = '';
// Brief pause to see the card, then complete
setTimeout(() => {
this.completeSwapAnimation(null);
}, 100);
}
});
} else {
// Fallback: animation element missing, complete immediately to avoid freeze
console.error('Swap animation element missing, completing immediately');
this.completeSwapAnimation(null);
}
}
completeSwapAnimation(heldCard) {
// Hide everything
this.swapAnimation.classList.add('hidden');
if (this.swapAnimationCard) {
this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse');
}
if (heldCard) {
heldCard.classList.remove('flipping', 'moving');
heldCard.classList.add('hidden');
}
if (this.swapAnimationHandCardEl) {
this.swapAnimationHandCardEl.classList.remove('swap-out');
}
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.swapAnimationFront = null;
this.swapAnimationCard = null;
this.swapAnimationDiscardRect = null;
this.swapAnimationHandCardEl = null;
this.swapAnimationHandRect = null;
this.discardBtn.classList.add('hidden');
this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}
flipCard(position) {
this.send({ type: 'flip_card', position });
this.waitingForFlip = false;
this.flipIsOptional = false;
}
skipFlip() {
if (!this.flipIsOptional) return;
this.send({ type: 'skip_flip' });
this.waitingForFlip = false;
this.flipIsOptional = false;
this.hideToast();
}
knockEarly() {
// Flip all remaining face-down cards to go out early
if (!this.gameState || !this.gameState.knock_early) return;
this.send({ type: 'knock_early' });
this.hideToast();
}
// Fire-and-forget animation triggers based on state changes
triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return;
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) {
window.drawAnimations.animateDrawDiscard(drawnCard, onAnimComplete);
} else {
window.drawAnimations.animateDrawDeck(drawnCard, 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');
}
}
}
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
if (discardChanged && wasOtherPlayer) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
const wasUp = oldCard?.face_up;
const isUp = newCard?.face_up;
// Case 1: face-down became face-up (needs flip)
if (!wasUp && isUp) {
swappedPosition = i;
wasFaceUp = false;
break;
}
// Case 2: both face-up but different card (no flip needed)
if (wasUp && isUp && oldCard.rank && newCard.rank) {
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
swappedPosition = i;
wasFaceUp = true; // Face-to-face swap
break;
}
}
// Case 3: Card identity became known (opponent's hidden card was swapped)
// This handles race conditions where face_up might not be updated yet
if (!oldCard?.rank && newCard?.rank) {
swappedPosition = i;
wasFaceUp = false;
break;
}
}
// Check if opponent's cards are completely unchanged (server might send split updates)
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
} else if (swappedPosition < 0 && !cardsIdentical) {
// Player drew and discarded without swapping
// Only fire if cards actually differ (avoid race condition with split server updates)
// Show animation for other players, just pulse for local player
this.fireDiscardAnimation(newDiscard, previousPlayerId);
// Show CPU discard announcement
if (wasOtherPlayer && oldPlayer?.is_cpu) {
this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
}
}
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
}
}
// Handle delayed card updates (server sends split updates: discard first, then cards)
// Check if opponent cards changed even when discard didn't change
if (!discardChanged && wasOtherPlayer && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// Check for card changes that indicate a swap we missed
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card became visible (swap completed in delayed update)
if (!oldCard?.face_up && newCard?.face_up) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
// Card identity became known
if (!oldCard?.rank && newCard?.rank) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
}
}
}
}
// Flash animation on deck or discard pile to show where opponent drew from
// 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) {
// Show card traveling from deck to discard pile
this.animateDeckToDiscard(discardCard);
}
// Skip animation entirely for local player
}
// Animate a card moving from deck to discard pile (for draw-and-discard by other players)
animateDeckToDiscard(card) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Create temporary card element
const animCard = document.createElement('div');
animCard.className = 'real-card anim-card';
animCard.innerHTML = `
`;
// Position at deck
animCard.style.position = 'fixed';
animCard.style.left = `${deckRect.left}px`;
animCard.style.top = `${deckRect.top}px`;
animCard.style.width = `${deckRect.width}px`;
animCard.style.height = `${deckRect.height}px`;
animCard.style.zIndex = '1000';
animCard.style.transition = `left ${window.TIMING?.card?.move || 270}ms ease-out, top ${window.TIMING?.card?.move || 270}ms ease-out`;
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Start face-down (back showing)
inner.classList.add('flipped');
// Set up the front face content for the flip
front.className = 'card-face card-face-front';
if (card.rank === 'ā
') {
front.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? 'š' : 'š¹';
front.innerHTML = `${jokerIcon}Joker`;
} else {
front.classList.add(this.isRedSuit(card.suit) ? 'red' : 'black');
front.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
}
document.body.appendChild(animCard);
// Small delay then start moving and flipping
setTimeout(() => {
this.playSound('card');
// Move to discard position
animCard.style.left = `${discardRect.left}px`;
animCard.style.top = `${discardRect.top}px`;
// Flip to show face
inner.classList.remove('flipped');
}, 50);
// Clean up after animation
const moveDuration = window.TIMING?.card?.move || 270;
const pauseAfter = window.TIMING?.pause?.afterDiscard || 550;
setTimeout(() => {
animCard.remove();
this.pulseDiscardLand();
}, moveDuration + pauseAfter);
}
// Get rotation angle from an element's computed transform
getElementRotation(element) {
if (!element) return 0;
const style = window.getComputedStyle(element);
const transform = style.transform;
if (!transform || transform === 'none') return 0;
// Parse rotation from transform matrix
const values = transform.split('(')[1]?.split(')')[0]?.split(',');
if (values && values.length >= 2) {
const a = parseFloat(values[0]);
const b = parseFloat(values[1]);
return Math.round(Math.atan2(b, a) * (180 / Math.PI));
}
return 0;
}
// Fire a swap animation (non-blocking) - flip in place at opponent's position
// Uses flip-in-place for face-down cards, subtle pulse for face-up cards
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
// Track this animation so renderGame can apply swap-out class
this.opponentSwapAnimation = { playerId, position };
// Find source position - the actual card that was swapped
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
let sourceRect = null;
let sourceCardEl = null;
let sourceRotation = 0;
for (const area of opponentAreas) {
const nameEl = area.querySelector('h4');
const player = this.gameState?.players.find(p => p.id === playerId);
if (nameEl && player && nameEl.textContent.includes(player.name)) {
const cards = area.querySelectorAll('.card');
if (cards.length > position && position >= 0) {
sourceCardEl = cards[position];
sourceRect = sourceCardEl.getBoundingClientRect();
// Get rotation from the opponent area (parent has the arch rotation)
sourceRotation = this.getElementRotation(area);
}
break;
}
}
// Face-to-face swap: use subtle pulse on the card, no flip needed
if (wasFaceUp && sourceCardEl) {
if (window.cardAnimations) {
window.cardAnimations.pulseSwap(sourceCardEl);
}
const pulseDuration = window.TIMING?.feedback?.discardPickup || 400;
setTimeout(() => {
// Pulse discard, then update turn indicator after pulse completes
// Keep opponentSwapAnimation set during pulse so isVisuallyMyTurn stays correct
this.pulseDiscardLand(() => {
this.opponentSwapAnimation = null;
this.renderGame();
});
}, pulseDuration);
return;
}
// Face-down to face-up: flip to reveal, pause to see it, then pulse before swap
if (!sourceRect) {
// Fallback: just show flip at discard position
const discardRect = this.discard.getBoundingClientRect();
sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
}
const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front');
// Position at opponent's card location (flip in place there)
swapCard.style.left = sourceRect.left + 'px';
swapCard.style.top = sourceRect.top + 'px';
swapCard.style.width = sourceRect.width + 'px';
swapCard.style.height = sourceRect.height + 'px';
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
// Apply source rotation to match the arch layout
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
// Set card content (the card being discarded - what was hidden)
swapCardFront.className = 'swap-card-front';
if (discardCard.rank === 'ā
') {
swapCardFront.classList.add('joker');
const jokerIcon = discardCard.suit === 'hearts' ? 'š' : 'š¹';
swapCardFront.innerHTML = `${jokerIcon}Joker`;
} else {
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
swapCardFront.innerHTML = `${discardCard.rank}
${this.getSuitSymbol(discardCard.suit)}`;
}
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
// Use anime.js for the flip animation (CSS transitions removed)
const flipDuration = 245; // Match other flip durations
const swapCardInner = swapCard.querySelector('.swap-card-inner');
this.playSound('flip');
anime({
targets: swapCardInner,
rotateY: [0, 180],
duration: flipDuration,
easing: window.TIMING?.anime?.easing?.flip || 'easeInOutQuad',
complete: () => {
this.swapAnimation.classList.add('hidden');
swapCardInner.style.transform = '';
swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
// Update turn indicator after flip completes
this.opponentSwapAnimation = null;
this.renderGame();
}
});
}
// 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);
});
} 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`, '', 5000);
}
return;
}
// Swap with drawn card
if (this.drawnCard) {
this.animateSwap(position);
this.hideToast();
return;
}
// Flip after discarding from deck (flip_on_discard variant)
if (this.waitingForFlip && !card.face_up) {
// Animate immediately, then send to server
this.fireLocalFlipAnimation(position, card);
this.flipCard(position);
this.hideToast();
return;
}
}
nextRound() {
this.clearNextHoleCountdown();
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
}
newGame() {
this.leaveRoom();
}
leaveGame() {
if (this.isHost) {
// Host ending game affects everyone
if (confirm('End game for all players?')) {
this.send({ type: 'end_game' });
}
} else {
// Regular player just leaves
if (confirm('Leave this game?')) {
this.send({ type: 'leave_game' });
this.ws.close();
this.showLobby();
}
}
}
// UI Helpers
showScreen(screen) {
this.lobbyScreen.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
if (this.rulesScreen) {
this.rulesScreen.classList.remove('active');
}
screen.classList.add('active');
// Handle auth bar visibility - hide global bar during game, show in-game controls instead
const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user;
if (isGameScreen && user) {
// Hide global auth bar, show in-game auth controls
this.authBar?.classList.add('hidden');
this.gameUsername.textContent = user.username;
this.gameUsername.classList.remove('hidden');
this.gameLogoutBtn.classList.remove('hidden');
} else {
// Show global auth bar (if logged in), hide in-game auth controls
if (user) {
this.authBar?.classList.remove('hidden');
}
this.gameUsername.classList.add('hidden');
this.gameLogoutBtn.classList.add('hidden');
}
}
showLobby() {
this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = '';
this.roomCode = null;
this.playerId = null;
this.isHost = false;
this.gameState = null;
this.previousState = null;
}
showWaitingRoom() {
this.showScreen(this.waitingScreen);
this.displayRoomCode.textContent = this.roomCode;
if (this.isHost) {
this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
// 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
this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave';
// Update active rules bar
this.updateActiveRulesBar();
}
updateActiveRulesBar() {
if (!this.gameState) {
this.activeRulesBar.classList.add('hidden');
return;
}
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
// Show "Standard Rules" when no variants selected
this.activeRulesList.innerHTML = 'Standard';
} else if (rules.length <= 2) {
// Show all rules if 2 or fewer
this.activeRulesList.innerHTML = rules
.map(rule => `${rule}`)
.join('');
} else {
// Show first 2 rules + "+N more" with tooltip
const displayed = rules.slice(0, 2);
const hidden = rules.slice(2);
const moreCount = hidden.length;
const tooltip = hidden.join(', ');
this.activeRulesList.innerHTML = displayed
.map(rule => `${rule}`)
.join('') +
`+${moreCount} more`;
}
this.activeRulesBar.classList.remove('hidden');
}
showError(message) {
this.lobbyError.textContent = message;
this.playSound('reject');
console.error('Game error:', message);
}
updatePlayersList(players) {
this.playersList.innerHTML = '';
players.forEach(player => {
const li = document.createElement('li');
let badges = '';
if (player.is_host) badges += 'HOST';
if (player.is_cpu) badges += 'CPU';
li.innerHTML = `
${player.name}
${badges}
`;
if (player.id === this.playerId) {
li.style.background = 'rgba(244, 164, 96, 0.3)';
}
this.playersList.appendChild(li);
if (player.id === this.playerId && player.is_host) {
this.isHost = true;
this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
}
});
// Auto-select 2 decks when reaching 4+ players (host only)
const prevCount = this.currentPlayers ? this.currentPlayers.length : 0;
if (this.isHost && prevCount < 4 && players.length >= 4) {
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
updateCpuConsideringState() {
if (!this.gameState || !this.discard) return;
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
// Use anime.js for CPU thinking animation
if (window.cardAnimations) {
window.cardAnimations.startCpuThinking(this.discard);
}
} else {
this.discard.classList.remove('cpu-considering');
if (window.cardAnimations) {
window.cardAnimations.stopCpuThinking(this.discard);
}
}
}
showToast(message, type = '', duration = 2500) {
// For compatibility - just set the status message
this.setStatus(message, type);
}
hideToast() {
// Restore default status based on game state
this.updateStatusFromGameState();
}
updateStatusFromGameState() {
if (!this.gameState) {
this.setStatus('');
this.finalTurnBadge.classList.add('hidden');
return;
}
// Check for round/game over states
if (this.gameState.phase === 'round_over') {
this.setStatus('Hole Complete!', 'round-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
if (this.gameState.phase === 'game_over') {
this.setStatus('Game Over!', 'game-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
// Show/hide final turn badge separately
if (isFinalTurn) {
this.finalTurnBadge.classList.remove('hidden');
} else {
this.finalTurnBadge.classList.add('hidden');
}
if (currentPlayer && currentPlayer.id !== this.playerId) {
this.setStatus(`${currentPlayer.name}'s turn`);
} else if (this.isMyTurn()) {
if (!this.drawnCard && !this.gameState.has_drawn_card) {
// Build status message based on available actions
let options = ['draw'];
if (this.gameState.flip_as_action) options.push('flip');
// Check knock early eligibility
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
if (this.gameState.knock_early && faceDownCount >= 1 && faceDownCount <= 2) {
options.push('knock');
}
if (options.length === 1) {
this.setStatus('Your turn - draw a card', 'your-turn');
} else {
this.setStatus(`Your turn - ${options.join('/')}`, 'your-turn');
}
} else {
this.setStatus('Your turn - draw a card', 'your-turn');
}
} else {
this.setStatus('');
}
}
showDrawnCard() {
// Show drawn card floating over the draw pile (deck), regardless of source
const card = this.drawnCard;
this.displayHeldCard(card, true);
}
// Display held card floating above and between deck and discard - for any player
// isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow)
displayHeldCard(card, isLocalPlayerHolding) {
if (!card) {
this.hideDrawnCard();
return;
}
// Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
// Position centered above and between deck and discard
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Calculate center point between deck and discard
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`;
// Position discard button attached to right side of held card
const scaledWidth = cardWidth * 1.15; // Account for scale transform
const scaledHeight = cardHeight * 1.15;
const buttonLeft = cardLeft + scaledWidth / 2 + cardWidth / 2; // Right edge of scaled card (no gap)
const buttonTop = cardTop + (scaledHeight - cardHeight) / 2 + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
if (card.rank === 'ā
') {
this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? 'š' : 'š¹';
this.heldCardFloatingContent.innerHTML = `${jokerIcon}Joker`;
} else {
if (this.isRedSuit(card.suit)) {
this.heldCardFloating.classList.add('red');
} else {
this.heldCardFloating.classList.add('black');
}
this.heldCardFloatingContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
}
// Show the floating card
this.heldCardFloating.classList.remove('hidden');
// Add pulse glow if it's local player's turn to act on the card
if (isLocalPlayerHolding) {
this.heldCardFloating.classList.add('your-turn-pulse');
this.discardBtn.classList.remove('hidden');
} else {
this.heldCardFloating.classList.remove('your-turn-pulse');
this.discardBtn.classList.add('hidden');
}
}
// 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 overlapOffset = cardHeight * 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`;
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';
}
calculateShowingScore(cards) {
if (!cards || cards.length !== 6) return 0;
// Use card values from server (includes house rules) or defaults
const cardValues = this.gameState?.card_values || {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, 'ā
': -2
};
const getCardValue = (card) => {
if (!card.face_up) return 0;
return cardValues[card.rank] ?? 0;
};
// Check for column pairs (cards in same column cancel out if matching)
let total = 0;
for (let col = 0; col < 3; col++) {
const topCard = cards[col];
const bottomCard = cards[col + 3];
const topUp = topCard.face_up;
const bottomUp = bottomCard.face_up;
// If both face up and matching rank, they cancel (score 0)
if (topUp && bottomUp && topCard.rank === bottomCard.rank) {
// Matching pair = 0 points for both
continue;
}
// Otherwise add individual values
total += getCardValue(topCard);
total += getCardValue(bottomCard);
}
return total;
}
getSuitSymbol(suit) {
const symbols = {
hearts: 'ā„',
diamonds: 'ā¦',
clubs: 'ā£',
spades: 'ā '
};
return symbols[suit] || '';
}
renderCardContent(card) {
if (!card || !card.face_up) return '';
// 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;
// Update CPU considering visual state
this.updateCpuConsideringState();
// Update header
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
// Show/hide final turn badge
const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) {
this.finalTurnBadge.classList.remove('hidden');
} else {
this.finalTurnBadge.classList.add('hidden');
}
// Toggle not-my-turn class to disable hover effects when it's not player's turn
// Use visual check so turn indicators sync with discard land animation
const isVisuallyMyTurn = this.isVisuallyMyTurn();
this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn);
// 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`);
}
// Update player header (name + score like opponents)
const me = this.gameState.players.find(p => p.id === this.playerId);
if (me) {
// Calculate visible score from face-up cards
const showingScore = this.calculateShowingScore(me.cards);
this.yourScore.textContent = showingScore;
// Check if player won the round
const isRoundWinner = this.roundWinnerNames.has(me.name);
this.playerArea.classList.toggle('round-winner', isRoundWinner);
// Update player name in header (truncate if needed)
const displayName = me.name.length > 12 ? me.name.substring(0, 11) + 'ā¦' : me.name;
const checkmark = me.all_face_up ? ' ā' : '';
// Update player name span with crown if winner
const playerNameSpan = this.playerHeader.querySelector('.player-name');
const crownHtml = isRoundWinner ? 'š' : '';
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
}
// Update discard pile
// Check if ANY player is holding a card (local or remote/CPU)
const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
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');
// Skip discard update during local discard animation - animation handles the visual
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.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Only animate discard flip during active gameplay, not at round/game end
const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey;
// Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
if (discardCard.rank === 'ā
') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(discardCard.suit)) {
this.discard.classList.add('red');
} else {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
// THEN animate if needed (content is already set, so no blank flash)
if (shouldAnimate) {
// Remove any existing animation first to allow re-trigger
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560;
setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration);
}
} else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
this.discardContent.innerHTML = '';
this.lastDiscardKey = null;
}
this.discardBtn.classList.add('hidden');
}
// Show held card for ANY player who has drawn (consistent visual regardless of whose turn)
// Local player uses this.drawnCard, others use gameState.drawn_card
// 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 canDraw = 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';
if (isPlaying && player.id === displayedCurrentPlayer) {
div.classList.add('current-turn');
}
const isRoundWinner = this.roundWinnerNames.has(player.name);
if (isRoundWinner) {
div.classList.add('round-winner');
}
const displayName = player.name.length > 12 ? player.name.substring(0, 11) + 'ā¦' : player.name;
const showingScore = this.calculateShowingScore(player.cards);
const crownHtml = isRoundWinner ? 'š' : '';
div.innerHTML = `
${crownHtml}${displayName}${player.all_face_up ? ' ā' : ''}${showingScore}
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
`;
this.opponentsRow.appendChild(div);
});
// Render player's cards
const myData = this.getMyPlayerData();
if (myData) {
this.playerCards.innerHTML = '';
myData.cards.forEach((card, index) => {
// Check if this card was locally flipped (immediate feedback)
const isLocallyFlipped = this.locallyFlippedCards.has(index);
// Create a display card that shows face-up if locally flipped
const displayCard = isLocallyFlipped
? { ...card, face_up: true }
: card;
// Check if clickable during initial flip
const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
const isClickable = (
isInitialFlipClickable ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(index);
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
// Add pulse animation during initial flip phase
if (isInitialFlipClickable) {
cardEl.firstChild.classList.add('initial-flip-pulse');
cardEl.firstChild.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));
this.playerCards.appendChild(cardEl.firstChild);
});
}
// Show flip prompt for initial flip
// Show flip prompt during initial flip phase
if (this.gameState.waiting_for_initial_flip) {
const requiredFlips = this.gameState.initial_flips || 2;
const flippedCount = this.locallyFlippedCards.size;
const remaining = requiredFlips - flippedCount;
if (remaining > 0) {
this.setStatus(`Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`, 'your-turn');
}
}
// Disable discard button if can't discard (must_swap_discard rule)
if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled');
} else {
this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled');
}
// Show/hide skip flip button (only when flip is optional in endgame mode)
if (this.waitingForFlip && this.flipIsOptional) {
this.skipFlipBtn.classList.remove('hidden');
} else {
this.skipFlipBtn.classList.add('hidden');
}
// Show/hide knock early button (when knock_early rule is enabled)
// Conditions: rule enabled, my turn, no drawn card, have 1-2 face-down cards
const canKnockEarly = this.gameState.knock_early &&
this.isMyTurn() &&
!this.drawnCard &&
!this.gameState.has_drawn_card &&
!this.gameState.waiting_for_initial_flip;
if (canKnockEarly) {
// Count face-down cards for current player
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
if (faceDownCount >= 1 && faceDownCount <= 2) {
this.knockEarlyBtn.classList.remove('hidden');
} else {
this.knockEarlyBtn.classList.add('hidden');
}
} else {
this.knockEarlyBtn.classList.add('hidden');
}
// Update scoreboard panel
this.updateScorePanel();
}
updateScorePanel() {
if (!this.gameState) return;
// Update standings (left panel)
this.updateStandings();
// Skip score table update during round_over/game_over - showScoreboard handles these
if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') {
return;
}
// Update score table (right panel)
this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => {
const tr = document.createElement('tr');
// Highlight current player (but not during round/game over)
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
if (isPlaying && player.id === this.gameState.current_player_id) {
tr.classList.add('current-player');
}
// Truncate long names
const displayName = player.name.length > 12
? player.name.substring(0, 11) + 'ā¦'
: player.name;
const roundScore = player.score !== null ? player.score : '-';
const roundsWon = player.rounds_won || 0;
tr.innerHTML = `
${displayName} |
${roundScore} |
${player.total_score} |
${roundsWon} |
`;
this.scoreTable.appendChild(tr);
});
}
updateStandings() {
if (!this.gameState || !this.standingsList) return;
// Sort by total points (lowest wins) - top 4
const byPoints = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score).slice(0, 4);
// Sort by holes won (most wins) - top 4
const byHoles = [...this.gameState.players].sort((a, b) => b.rounds_won - a.rounds_won).slice(0, 4);
// Build points ranking
let pointsRank = 0;
let prevPoints = null;
const pointsHtml = byPoints.map((p, i) => {
if (p.total_score !== prevPoints) {
pointsRank = i;
prevPoints = p.total_score;
}
const medal = pointsRank === 0 ? 'š„' : pointsRank === 1 ? 'š„' : pointsRank === 2 ? 'š„' : '4.';
const name = p.name.length > 8 ? p.name.substring(0, 7) + 'ā¦' : p.name;
return `${medal}${name}${p.total_score} pts
`;
}).join('');
// Build holes won ranking
let holesRank = 0;
let prevHoles = null;
const holesHtml = byHoles.map((p, i) => {
if (p.rounds_won !== prevHoles) {
holesRank = i;
prevHoles = p.rounds_won;
}
const medal = p.rounds_won === 0 ? '-' :
holesRank === 0 ? 'š„' : holesRank === 1 ? 'š„' : holesRank === 2 ? 'š„' : '4.';
const name = p.name.length > 8 ? p.name.substring(0, 7) + 'ā¦' : p.name;
return `${medal}${name}${p.rounds_won} wins
`;
}).join('');
this.standingsList.innerHTML = `
`;
}
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;
}
// Show game buttons
this.gameButtons.classList.remove('hidden');
this.newGameBtn.classList.add('hidden');
this.nextRoundBtn.classList.remove('hidden');
// Start countdown for next hole
this.startNextHoleCountdown();
}
startNextHoleCountdown() {
// Clear any existing countdown
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
}
const COUNTDOWN_SECONDS = 15;
let remaining = COUNTDOWN_SECONDS;
const updateButton = () => {
if (this.isHost) {
this.nextRoundBtn.textContent = `Next Hole (${remaining}s)`;
this.nextRoundBtn.disabled = false;
} else {
this.nextRoundBtn.textContent = `Next hole in ${remaining}s...`;
this.nextRoundBtn.disabled = true;
this.nextRoundBtn.classList.add('waiting');
}
};
updateButton();
this.nextHoleCountdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
// Auto-advance if host
if (this.isHost) {
this.nextRound();
} else {
this.nextRoundBtn.textContent = 'Waiting for host...';
}
} else {
updateButton();
}
}, 1000);
}
clearNextHoleCountdown() {
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
}
}
showRankingsAnnouncement(rankings, isFinal) {
// Remove existing announcement if any
const existing = document.getElementById('rankings-announcement');
if (existing) existing.remove();
const existingVictory = document.getElementById('double-victory-banner');
if (existingVictory) existingVictory.remove();
if (!rankings) return;
const announcement = document.createElement('div');
announcement.id = 'rankings-announcement';
announcement.className = 'rankings-announcement';
const title = isFinal ? 'Final Results' : 'Current Standings';
// Check for double victory (same player leads both categories) - only at game end
const pointsLeader = rankings.by_points[0];
const holesLeader = rankings.by_holes_won[0];
const isDoubleVictory = isFinal && pointsLeader && holesLeader &&
pointsLeader.name === holesLeader.name &&
holesLeader.rounds_won > 0;
// Build points ranking (lowest wins) with tie handling
let pointsRank = 0;
let prevPoints = null;
const pointsHtml = rankings.by_points.map((p, i) => {
if (p.total !== prevPoints) {
pointsRank = i;
prevPoints = p.total;
}
const medal = pointsRank === 0 ? 'š„' : pointsRank === 1 ? 'š„' : pointsRank === 2 ? 'š„' : `${pointsRank + 1}.`;
const name = p.name.length > 12 ? p.name.substring(0, 11) + 'ā¦' : p.name;
return `${medal}${name}${p.total} pts
`;
}).join('');
// Build holes won ranking (most wins) with tie handling
let holesRank = 0;
let prevHoles = null;
const holesHtml = rankings.by_holes_won.map((p, i) => {
if (p.rounds_won !== prevHoles) {
holesRank = i;
prevHoles = p.rounds_won;
}
// No medal for 0 wins
const medal = p.rounds_won === 0 ? '-' :
holesRank === 0 ? 'š„' : holesRank === 1 ? 'š„' : holesRank === 2 ? 'š„' : `${holesRank + 1}.`;
const name = p.name.length > 12 ? p.name.substring(0, 11) + 'ā¦' : p.name;
return `${medal}${name}${p.rounds_won} wins
`;
}).join('');
// If double victory, show banner above the left panel (standings)
if (isDoubleVictory) {
const victoryBanner = document.createElement('div');
victoryBanner.id = 'double-victory-banner';
victoryBanner.className = 'double-victory';
victoryBanner.textContent = `DOUBLE VICTORY! ${pointsLeader.name}`;
const standingsPanel = document.getElementById('standings-panel');
if (standingsPanel) {
standingsPanel.insertBefore(victoryBanner, standingsPanel.firstChild);
}
}
announcement.innerHTML = `
${title}
Points (Low Wins)
${pointsHtml}
Holes Won
${holesHtml}
`;
// Insert before the scoreboard
this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild);
}
showFinalResultsModal(rankings, scores) {
// Hide side panels
const standingsPanel = document.getElementById('standings-panel');
const scoreboard = document.getElementById('scoreboard');
if (standingsPanel) standingsPanel.classList.add('hidden');
if (scoreboard) scoreboard.classList.add('hidden');
// Remove existing modal if any
const existing = document.getElementById('final-results-modal');
if (existing) existing.remove();
// Determine winners
const pointsLeader = rankings.by_points[0];
const holesLeader = rankings.by_holes_won[0];
const isDoubleVictory = pointsLeader && holesLeader &&
pointsLeader.name === holesLeader.name &&
holesLeader.rounds_won > 0;
// Build points ranking
let pointsRank = 0;
let prevPoints = null;
const pointsHtml = rankings.by_points.map((p, i) => {
if (p.total !== prevPoints) {
pointsRank = i;
prevPoints = p.total;
}
const medal = pointsRank === 0 ? 'š„' : pointsRank === 1 ? 'š„' : pointsRank === 2 ? 'š„' : `${pointsRank + 1}.`;
return `${medal}${p.name}${p.total} pts
`;
}).join('');
// Build holes ranking
let holesRank = 0;
let prevHoles = null;
const holesHtml = rankings.by_holes_won.map((p, i) => {
if (p.rounds_won !== prevHoles) {
holesRank = i;
prevHoles = p.rounds_won;
}
const medal = p.rounds_won === 0 ? '-' :
holesRank === 0 ? 'š„' : holesRank === 1 ? 'š„' : holesRank === 2 ? 'š„' : `${holesRank + 1}.`;
return `${medal}${p.name}${p.rounds_won} wins
`;
}).join('');
// Build share text
const shareText = this.buildShareText(rankings, isDoubleVictory);
// Create modal
const modal = document.createElement('div');
modal.id = 'final-results-modal';
modal.className = 'final-results-modal';
modal.innerHTML = `
šļø Final Results
${isDoubleVictory ? `
š DOUBLE VICTORY: ${pointsLeader.name} š
` : ''}
By Points (Low Wins)
${pointsHtml}
By Holes Won
${holesHtml}
`;
document.body.appendChild(modal);
// Bind button events
document.getElementById('share-results-btn').addEventListener('click', () => {
navigator.clipboard.writeText(shareText).then(() => {
const btn = document.getElementById('share-results-btn');
btn.textContent = 'ā Copied!';
const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000;
setTimeout(() => btn.textContent = 'š Copy Results', copyDelay);
});
});
document.getElementById('close-results-btn').addEventListener('click', () => {
modal.remove();
this.leaveRoom();
});
}
buildShareText(rankings, isDoubleVictory) {
let text = 'šļø Golf Card Game Results\n';
text += 'āāāāāāāāāāāāāāāāāāāāāāā\n\n';
if (isDoubleVictory) {
text += `š DOUBLE VICTORY: ${rankings.by_points[0].name}!\n\n`;
}
text += 'š By Points (Low Wins):\n';
rankings.by_points.forEach((p, i) => {
const medal = i === 0 ? 'š„' : i === 1 ? 'š„' : i === 2 ? 'š„' : `${i + 1}.`;
text += `${medal} ${p.name}: ${p.total} pts\n`;
});
text += '\nā³ By Holes Won:\n';
rankings.by_holes_won.forEach((p, i) => {
const medal = p.rounds_won === 0 ? '-' : i === 0 ? 'š„' : i === 1 ? 'š„' : i === 2 ? 'š„' : `${i + 1}.`;
text += `${medal} ${p.name}: ${p.rounds_won} wins\n`;
});
text += '\nPlayed at golf.game';
return text;
}
}
// Initialize game when page loads
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
window.auth = new AuthManager(window.game);
});
// ===========================================
// AUTH MANAGER
// ===========================================
class AuthManager {
constructor(game) {
this.game = game;
this.token = localStorage.getItem('authToken');
this.user = JSON.parse(localStorage.getItem('authUser') || 'null');
this.initElements();
this.bindEvents();
this.updateUI();
}
initElements() {
this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username');
this.logoutBtn = document.getElementById('auth-logout-btn');
this.authButtons = document.getElementById('auth-buttons');
this.loginBtn = document.getElementById('login-btn');
this.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal');
this.modalClose = document.getElementById('auth-modal-close');
this.loginFormContainer = document.getElementById('login-form-container');
this.loginForm = document.getElementById('login-form');
this.loginUsername = document.getElementById('login-username');
this.loginPassword = document.getElementById('login-password');
this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup');
this.showLoginLink = document.getElementById('show-login');
}
bindEvents() {
this.loginBtn?.addEventListener('click', () => this.showModal('login'));
this.signupBtn?.addEventListener('click', () => this.showModal('signup'));
this.modalClose?.addEventListener('click', () => this.hideModal());
this.modal?.addEventListener('click', (e) => {
if (e.target === this.modal) this.hideModal();
});
this.showSignupLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('signup');
});
this.showLoginLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e));
this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e));
this.logoutBtn?.addEventListener('click', () => this.logout());
}
showModal(form = 'login') {
this.modal.classList.remove('hidden');
this.showForm(form);
this.clearErrors();
}
hideModal() {
this.modal.classList.add('hidden');
this.clearForms();
}
showForm(form) {
if (form === 'login') {
this.loginFormContainer.classList.remove('hidden');
this.signupFormContainer.classList.add('hidden');
this.loginUsername.focus();
} else {
this.loginFormContainer.classList.add('hidden');
this.signupFormContainer.classList.remove('hidden');
this.signupUsername.focus();
}
}
clearForms() {
this.loginForm.reset();
this.signupForm.reset();
this.clearErrors();
}
clearErrors() {
this.loginError.textContent = '';
this.signupError.textContent = '';
}
async handleLogin(e) {
e.preventDefault();
this.clearErrors();
const username = this.loginUsername.value.trim();
const password = this.loginPassword.value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
this.loginError.textContent = data.detail || 'Login failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.loginError.textContent = 'Connection error';
}
}
async handleSignup(e) {
e.preventDefault();
this.clearErrors();
const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value;
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
});
const data = await response.json();
if (!response.ok) {
this.signupError.textContent = data.detail || 'Signup failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.signupError.textContent = 'Connection error';
}
}
setAuth(token, user) {
this.token = token;
this.user = user;
localStorage.setItem('authToken', token);
localStorage.setItem('authUser', JSON.stringify(user));
this.updateUI();
}
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('authToken');
localStorage.removeItem('authUser');
this.updateUI();
}
updateUI() {
if (this.user) {
this.authBar?.classList.remove('hidden');
this.authButtons?.classList.add('hidden');
if (this.authUsername) {
this.authUsername.textContent = this.user.username;
}
if (this.game.playerNameInput && !this.game.playerNameInput.value) {
this.game.playerNameInput.value = this.user.username;
}
} else {
this.authBar?.classList.add('hidden');
this.authButtons?.classList.remove('hidden');
}
}
}