// Golf Card Game - Client Application
class GolfGame {
constructor() {
this.ws = null;
this.playerId = null;
this.roomCode = null;
this.isHost = false;
this.gameState = null;
this.drawnCard = null;
this.selectedCards = [];
this.waitingForFlip = false;
this.currentPlayers = [];
this.allProfiles = [];
this.soundEnabled = true;
this.audioCtx = null;
this.initElements();
this.initAudio();
this.bindEvents();
}
initAudio() {
// Initialize audio context on first user interaction
const initCtx = () => {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
document.removeEventListener('click', initCtx);
};
document.addEventListener('click', initCtx);
}
playSound(type = 'click') {
if (!this.soundEnabled || !this.audioCtx) return;
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'click') {
oscillator.frequency.setValueAtTime(600, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05);
} else if (type === 'card') {
oscillator.frequency.setValueAtTime(800, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.08);
} else if (type === 'success') {
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} else if (type === 'shuffle') {
// Multiple quick sounds to simulate shuffling
for (let i = 0; i < 8; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
const time = ctx.currentTime + i * 0.06;
osc.frequency.setValueAtTime(200 + Math.random() * 400, time);
gain.gain.setValueAtTime(0.03, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
osc.start(time);
osc.stop(time + 0.05);
}
return; // Early return since we don't use the main oscillator
}
}
toggleSound() {
this.soundEnabled = !this.soundEnabled;
this.muteBtn.textContent = this.soundEnabled ? '🔊' : '🔇';
this.playSound('click');
}
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
this.playerNameInput = document.getElementById('player-name');
this.roomCodeInput = document.getElementById('room-code');
this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn');
this.lobbyError = document.getElementById('lobby-error');
// Waiting room elements
this.displayRoomCode = document.getElementById('display-room-code');
this.playersList = document.getElementById('players-list');
this.hostSettings = document.getElementById('host-settings');
this.waitingMessage = document.getElementById('waiting-message');
this.numDecksSelect = document.getElementById('num-decks');
this.deckRecommendation = document.getElementById('deck-recommendation');
this.numRoundsSelect = document.getElementById('num-rounds');
this.initialFlipsSelect = document.getElementById('initial-flips');
this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard');
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
this.jokerModeSelect = document.getElementById('joker-mode');
// House Rules - Point Modifiers
this.superKingsCheckbox = document.getElementById('super-kings');
this.luckySevensCheckbox = document.getElementById('lucky-sevens');
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');
// House Rules - Gameplay Twists
this.queensWildCheckbox = document.getElementById('queens-wild');
this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind');
this.eagleEyeCheckbox = document.getElementById('eagle-eye');
this.eagleEyeLabel = document.getElementById('eagle-eye-label');
this.startGameBtn = document.getElementById('start-game-btn');
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn');
// Game elements
this.currentRoundSpan = document.getElementById('current-round');
this.totalRoundsSpan = document.getElementById('total-rounds');
this.deckCountSpan = document.getElementById('deck-count');
this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row');
this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.drawnCardArea = document.getElementById('drawn-card-area');
this.drawnCardEl = document.getElementById('drawn-card');
this.discardBtn = document.getElementById('discard-btn');
this.playerCards = document.getElementById('player-cards');
this.flipPrompt = document.getElementById('flip-prompt');
this.toast = document.getElementById('toast');
this.scoreboard = document.getElementById('scoreboard');
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.gameButtons = document.getElementById('game-buttons');
this.nextRoundBtn = document.getElementById('next-round-btn');
this.newGameBtn = document.getElementById('new-game-btn');
}
bindEvents() {
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
this.leaveRoomBtn.addEventListener('click', () => { this.playSound('click'); this.leaveRoom(); });
this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); });
this.removeCpuBtn.addEventListener('click', () => { this.playSound('click'); this.removeCpu(); });
this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
this.muteBtn.addEventListener('click', () => this.toggleSound());
// 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();
});
// Eagle Eye only works with Standard Jokers (need 2 to pair them)
const updateEagleEyeVisibility = () => {
const isStandardJokers = this.jokerModeSelect.value === 'standard';
if (isStandardJokers) {
this.eagleEyeLabel.classList.remove('hidden');
} else {
this.eagleEyeLabel.classList.add('hidden');
this.eagleEyeCheckbox.checked = false;
}
};
this.jokerModeSelect.addEventListener('change', updateEagleEyeVisibility);
// Check initial state
updateEagleEyeVisibility();
// Update deck recommendation when deck selection changes
this.numDecksSelect.addEventListener('change', () => {
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
this.updateDeckRecommendation(playerCount);
});
// Toggle scoreboard collapse on mobile
const scoreboardTitle = this.scoreboard.querySelector('h4');
if (scoreboardTitle) {
scoreboardTitle.addEventListener('click', () => {
if (window.innerWidth <= 700) {
this.scoreboard.classList.toggle('collapsed');
}
});
}
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8000';
const wsUrl = `${protocol}//${host}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Connected to server');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
console.log('Disconnected from server');
this.showError('Connection lost. Please refresh the page.');
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.showError('Connection error. Please try again.');
};
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
handleMessage(data) {
console.log('Received:', data);
switch (data.type) {
case 'room_created':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = true;
this.showWaitingRoom();
break;
case 'room_joined':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = false;
this.showWaitingRoom();
break;
case 'player_joined':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'cpu_profiles':
this.allProfiles = data.profiles;
this.renderCpuSelect();
break;
case 'player_left':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'game_started':
case 'round_started':
this.gameState = data.game_state;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame();
break;
case 'game_state':
this.gameState = data.game_state;
this.renderGame();
break;
case 'your_turn':
this.showToast('Your turn! Draw a card', 'your-turn');
break;
case 'card_drawn':
this.drawnCard = data.card;
this.showDrawnCard();
this.showToast('Swap with a card or discard', '', 3000);
break;
case 'can_flip':
this.waitingForFlip = true;
this.showToast('Flip a face-down card', '', 3000);
this.renderGame();
break;
case 'round_over':
this.showScoreboard(data.scores, false, data.rankings);
break;
case 'game_over':
this.showScoreboard(data.final_scores, true, data.rankings);
break;
case '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();
}
startGame() {
const decks = parseInt(this.numDecksSelect.value);
const rounds = parseInt(this.numRoundsSelect.value);
const initial_flips = parseInt(this.initialFlipsSelect.value);
// Standard options
const flip_on_discard = this.flipOnDiscardCheckbox.checked;
const knock_penalty = this.knockPenaltyCheckbox.checked;
// Joker mode
const joker_mode = this.jokerModeSelect.value;
const use_jokers = joker_mode !== 'none';
const lucky_swing = joker_mode === 'lucky-swing';
// House Rules - Point Modifiers
const super_kings = this.superKingsCheckbox.checked;
const lucky_sevens = this.luckySevensCheckbox.checked;
const ten_penny = this.tenPennyCheckbox.checked;
// House Rules - Bonuses/Penalties
const knock_bonus = this.knockBonusCheckbox.checked;
const underdog_bonus = this.underdogBonusCheckbox.checked;
const tied_shame = this.tiedShameCheckbox.checked;
const blackjack = this.blackjackCheckbox.checked;
// House Rules - Gameplay Twists
const queens_wild = this.queensWildCheckbox.checked;
const four_of_a_kind = this.fourOfAKindCheckbox.checked;
const eagle_eye = this.eagleEyeCheckbox.checked;
this.send({
type: 'start_game',
decks,
rounds,
initial_flips,
flip_on_discard,
knock_penalty,
use_jokers,
lucky_swing,
super_kings,
lucky_sevens,
ten_penny,
knock_bonus,
underdog_bonus,
tied_shame,
blackjack,
queens_wild,
four_of_a_kind,
eagle_eye
});
}
showCpuSelect() {
// Request available profiles from server
this.selectedCpus = new Set();
this.send({ type: 'get_cpu_profiles' });
this.cpuSelectModal.classList.remove('hidden');
}
hideCpuSelect() {
this.cpuSelectModal.classList.add('hidden');
this.selectedCpus = new Set();
}
renderCpuSelect() {
if (!this.allProfiles) return;
// Get names of CPUs already in the game
const usedNames = new Set(
(this.currentPlayers || [])
.filter(p => p.is_cpu)
.map(p => p.name)
);
this.cpuProfilesGrid.innerHTML = '';
this.allProfiles.forEach(profile => {
const div = document.createElement('div');
const isUsed = usedNames.has(profile.name);
const isSelected = this.selectedCpus && this.selectedCpus.has(profile.name);
div.className = 'profile-card' + (isUsed ? ' unavailable' : '') + (isSelected ? ' selected' : '');
const avatar = this.getCpuAvatar(profile.name);
const checkbox = isUsed ? '' : `
${isSelected ? '✓' : ''}
`;
div.innerHTML = `
${checkbox}
${avatar}
${profile.name}
${profile.style}
${isUsed ? 'In Game
' : ''}
`;
if (!isUsed) {
div.addEventListener('click', () => this.toggleCpuSelection(profile.name));
}
this.cpuProfilesGrid.appendChild(div);
});
this.updateAddCpuButton();
}
getCpuAvatar(name) {
const avatars = {
'Sofia': ` `,
'Maya': ` `,
'Priya': ` `,
'Marcus': ` `,
'Kenji': ` `,
'Diego': ` `,
'River': ` `,
'Sage': ` `
};
return avatars[name] || ` `;
}
toggleCpuSelection(profileName) {
if (!this.selectedCpus) this.selectedCpus = new Set();
if (this.selectedCpus.has(profileName)) {
this.selectedCpus.delete(profileName);
} else {
this.selectedCpus.add(profileName);
}
this.renderCpuSelect();
}
updateAddCpuButton() {
const count = this.selectedCpus ? this.selectedCpus.size : 0;
this.addSelectedCpusBtn.textContent = count > 0 ? `Add ${count} CPU${count > 1 ? 's' : ''}` : 'Add';
this.addSelectedCpusBtn.disabled = count === 0;
}
addSelectedCpus() {
if (!this.selectedCpus || this.selectedCpus.size === 0) return;
this.selectedCpus.forEach(profileName => {
this.send({ type: 'add_cpu', profile_name: profileName });
});
this.hideCpuSelect();
}
removeCpu() {
this.send({ type: 'remove_cpu' });
}
// Game Actions
drawFromDeck() {
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return;
if (this.gameState.waiting_for_initial_flip) return;
this.send({ type: 'draw', source: 'deck' });
}
drawFromDiscard() {
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return;
if (this.gameState.waiting_for_initial_flip) return;
if (!this.gameState.discard_top) return;
this.send({ type: 'draw', source: 'discard' });
}
discardDrawn() {
if (!this.drawnCard) return;
this.send({ type: 'discard' });
this.drawnCard = null;
this.hideDrawnCard();
this.hideToast();
}
swapCard(position) {
if (!this.drawnCard) return;
this.send({ type: 'swap', position });
this.drawnCard = null;
this.hideDrawnCard();
}
flipCard(position) {
this.send({ type: 'flip_card', position });
this.waitingForFlip = false;
}
handleCardClick(position) {
const myData = this.getMyPlayerData();
if (!myData) return;
const card = myData.cards[position];
// Initial flip phase
if (this.gameState.waiting_for_initial_flip) {
if (card.face_up) return;
this.playSound('card');
const requiredFlips = this.gameState.initial_flips || 2;
if (this.selectedCards.includes(position)) {
this.selectedCards = this.selectedCards.filter(p => p !== position);
} else {
this.selectedCards.push(position);
}
if (this.selectedCards.length === requiredFlips) {
this.send({ type: 'flip_initial', positions: this.selectedCards });
this.selectedCards = [];
this.hideToast();
} else {
const remaining = requiredFlips - this.selectedCards.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
}
this.renderGame();
return;
}
// Swap with drawn card
if (this.drawnCard) {
this.swapCard(position);
this.hideToast();
return;
}
// Flip after discarding from deck
if (this.waitingForFlip && !card.face_up) {
this.flipCard(position);
this.hideToast();
return;
}
}
nextRound() {
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
}
newGame() {
this.leaveRoom();
}
// UI Helpers
showScreen(screen) {
this.lobbyScreen.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
screen.classList.add('active');
}
showLobby() {
this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = '';
this.roomCode = null;
this.playerId = null;
this.isHost = false;
this.gameState = null;
}
showWaitingRoom() {
this.showScreen(this.waitingScreen);
this.displayRoomCode.textContent = this.roomCode;
if (this.isHost) {
this.hostSettings.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
} else {
this.hostSettings.classList.add('hidden');
this.waitingMessage.classList.remove('hidden');
}
}
showGameScreen() {
this.showScreen(this.gameScreen);
this.gameButtons.classList.add('hidden');
this.drawnCard = null;
this.selectedCards = [];
this.waitingForFlip = false;
}
showError(message) {
this.lobbyError.textContent = message;
}
updatePlayersList(players) {
this.playersList.innerHTML = '';
players.forEach(player => {
const li = document.createElement('li');
let badges = '';
if (player.is_host) badges += 'HOST ';
if (player.is_cpu) badges += 'CPU ';
let nameDisplay = player.name;
if (player.style) {
nameDisplay += ` (${player.style}) `;
}
li.innerHTML = `
${nameDisplay}
${badges}
`;
if (player.id === this.playerId) {
li.style.background = 'rgba(244, 164, 96, 0.3)';
}
this.playersList.appendChild(li);
if (player.id === this.playerId && player.is_host) {
this.isHost = true;
this.hostSettings.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
}
});
// Auto-select 2 decks when reaching 4+ players (host only)
const prevCount = this.currentPlayers ? this.currentPlayers.length : 0;
if (this.isHost && prevCount < 4 && players.length >= 4) {
this.numDecksSelect.value = '2';
}
// Update deck recommendation visibility
this.updateDeckRecommendation(players.length);
}
updateDeckRecommendation(playerCount) {
if (!this.isHost || !this.deckRecommendation) return;
const decks = parseInt(this.numDecksSelect.value);
// Show recommendation if 4+ players and only 1 deck selected
if (playerCount >= 4 && decks < 2) {
this.deckRecommendation.classList.remove('hidden');
} else {
this.deckRecommendation.classList.add('hidden');
}
}
isMyTurn() {
return this.gameState && this.gameState.current_player_id === this.playerId;
}
getMyPlayerData() {
if (!this.gameState) return null;
return this.gameState.players.find(p => p.id === this.playerId);
}
showToast(message, type = '', duration = 2500) {
this.toast.textContent = message;
this.toast.className = 'toast' + (type ? ' ' + type : '');
clearTimeout(this.toastTimeout);
this.toastTimeout = setTimeout(() => {
this.toast.classList.add('hidden');
}, duration);
}
hideToast() {
this.toast.classList.add('hidden');
clearTimeout(this.toastTimeout);
}
showDrawnCard() {
this.drawnCardArea.classList.remove('hidden');
// Drawn card is always revealed to the player, so render directly
const card = this.drawnCard;
this.drawnCardEl.className = 'card card-front';
// Handle jokers specially
if (card.rank === '★') {
this.drawnCardEl.innerHTML = '★ JOKER';
this.drawnCardEl.classList.add('joker');
} else {
this.drawnCardEl.innerHTML = `${card.rank} ${this.getSuitSymbol(card.suit)}`;
if (this.isRedSuit(card.suit)) {
this.drawnCardEl.classList.add('red');
} else {
this.drawnCardEl.classList.add('black');
}
}
}
hideDrawnCard() {
this.drawnCardArea.classList.add('hidden');
}
isRedSuit(suit) {
return suit === 'hearts' || suit === 'diamonds';
}
getSuitSymbol(suit) {
const symbols = {
hearts: '♥',
diamonds: '♦',
clubs: '♣',
spades: 'â™ '
};
return symbols[suit] || '';
}
renderCardContent(card) {
if (!card || !card.face_up) return '';
// Jokers show star symbol without suit
if (card.rank === '★') {
return '★ JOKER';
}
return `${card.rank} ${this.getSuitSymbol(card.suit)}`;
}
renderGame() {
if (!this.gameState) return;
// Update header
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
this.deckCountSpan.textContent = this.gameState.deck_remaining;
// Update discard pile
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 {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
this.discardContent.innerHTML = '';
}
// Update deck/discard clickability and visual state
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
this.deck.classList.toggle('clickable', canDraw);
this.deck.classList.toggle('disabled', hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
this.discard.classList.toggle('disabled', hasDrawn);
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
this.opponentsRow.innerHTML = '';
opponents.forEach((player) => {
const div = document.createElement('div');
div.className = 'opponent-area';
if (player.id === this.gameState.current_player_id) {
div.classList.add('current-turn');
}
const displayName = player.name.length > 8 ? player.name.substring(0, 7) + '…' : player.name;
div.innerHTML = `
${displayName}${player.all_face_up ? ' ✓' : ''}
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
`;
this.opponentsRow.appendChild(div);
});
// Render player's cards
const myData = this.getMyPlayerData();
if (myData) {
this.playerCards.innerHTML = '';
myData.cards.forEach((card, index) => {
const isClickable = (
(this.gameState.waiting_for_initial_flip && !card.face_up) ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(index);
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(card, isClickable, isSelected);
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
this.playerCards.appendChild(cardEl.firstChild);
});
}
// Show flip prompt for initial flip
if (this.gameState.waiting_for_initial_flip) {
const requiredFlips = this.gameState.initial_flips || 2;
const remaining = requiredFlips - this.selectedCards.length;
if (remaining > 0) {
this.flipPrompt.textContent = `Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`;
this.flipPrompt.classList.remove('hidden');
} else {
this.flipPrompt.classList.add('hidden');
}
} else {
this.flipPrompt.classList.add('hidden');
}
// Disable discard button if can't discard (must_swap_discard rule)
if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled');
} else {
this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled');
}
// Update scoreboard panel
this.updateScorePanel();
}
updateScorePanel() {
if (!this.gameState) return;
this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => {
const tr = document.createElement('tr');
// Highlight current player
if (player.id === this.gameState.current_player_id) {
tr.classList.add('current-player');
}
// Truncate long names
const displayName = player.name.length > 10
? player.name.substring(0, 9) + '…'
: 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);
});
}
renderCard(card, clickable, selected) {
let classes = 'card';
let content = '';
if (card.face_up) {
classes += ' card-front';
if (card.rank === '★') {
classes += ' joker';
} else if (this.isRedSuit(card.suit)) {
classes += ' red';
} else {
classes += ' black';
}
content = this.renderCardContent(card);
} else {
classes += ' card-back';
}
if (clickable) classes += ' clickable';
if (selected) classes += ' selected';
return `${content}
`;
}
showScoreboard(scores, isFinal, rankings) {
this.scoreTable.innerHTML = '';
const minScore = Math.min(...scores.map(s => s.total || s.score || 0));
scores.forEach(score => {
const tr = document.createElement('tr');
const total = score.total !== undefined ? score.total : score.score;
const roundScore = score.score !== undefined ? score.score : '-';
const roundsWon = score.rounds_won || 0;
// Truncate long names
const displayName = score.name.length > 10
? score.name.substring(0, 9) + '…'
: score.name;
if (total === minScore) {
tr.classList.add('winner');
}
tr.innerHTML = `
${displayName}
${roundScore}
${total}
${roundsWon}
`;
this.scoreTable.appendChild(tr);
});
// Show rankings announcement
this.showRankingsAnnouncement(rankings, isFinal);
// Show game buttons
this.gameButtons.classList.remove('hidden');
if (isFinal) {
this.nextRoundBtn.classList.add('hidden');
this.newGameBtn.classList.remove('hidden');
} else if (this.isHost) {
this.nextRoundBtn.classList.remove('hidden');
this.newGameBtn.classList.add('hidden');
} else {
this.nextRoundBtn.classList.add('hidden');
this.newGameBtn.classList.add('hidden');
}
}
showRankingsAnnouncement(rankings, isFinal) {
// Remove existing announcement if any
const existing = document.getElementById('rankings-announcement');
if (existing) existing.remove();
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 > 8 ? p.name.substring(0, 7) + '…' : p.name;
return `${medal} ${name} ${p.total}pt
`;
}).join('');
// Build holes won ranking (most wins) with tie handling
let holesRank = 0;
let prevHoles = null;
const holesHtml = rankings.by_holes_won.map((p, i) => {
if (p.rounds_won !== prevHoles) {
holesRank = i;
prevHoles = p.rounds_won;
}
// No medal for 0 wins
const medal = p.rounds_won === 0 ? '-' :
holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`;
const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name;
return `${medal} ${name} ${p.rounds_won}W
`;
}).join('');
const doubleVictoryHtml = isDoubleVictory
? `DOUBLE VICTORY! ${pointsLeader.name}
`
: '';
announcement.innerHTML = `
${title}
${doubleVictoryHtml}
Points (Low Wins)
${pointsHtml}
Holes Won
${holesHtml}
`;
// Insert before the scoreboard
this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild);
}
}
// Initialize game when page loads
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
});