When you accidentally click the discard pile, you can now put the card back instead of being forced to swap. The "Put Back" button appears only when you've drawn from the discard pile. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2514 lines
102 KiB
JavaScript
2514 lines
102 KiB
JavaScript
// 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;
|
|
|
|
// 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 round winners for visual highlight
|
|
this.roundWinnerNames = new Set();
|
|
|
|
this.initElements();
|
|
this.initAudio();
|
|
this.bindEvents();
|
|
this.checkUrlParams();
|
|
}
|
|
|
|
checkUrlParams() {
|
|
// Handle ?room=XXXX share links
|
|
const params = new URLSearchParams(window.location.search);
|
|
const roomCode = params.get('room');
|
|
if (roomCode) {
|
|
this.roomCodeInput.value = roomCode.toUpperCase();
|
|
// Focus name input so user can quickly enter name and join
|
|
this.playerNameInput.focus();
|
|
// Clean up URL without reloading
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
}
|
|
|
|
initAudio() {
|
|
// Initialize audio context on first user interaction
|
|
const initCtx = () => {
|
|
if (!this.audioCtx) {
|
|
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
document.removeEventListener('click', initCtx);
|
|
};
|
|
document.addEventListener('click', initCtx);
|
|
}
|
|
|
|
playSound(type = 'click') {
|
|
if (!this.soundEnabled || !this.audioCtx) return;
|
|
|
|
const ctx = this.audioCtx;
|
|
const oscillator = ctx.createOscillator();
|
|
const gainNode = ctx.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(ctx.destination);
|
|
|
|
if (type === 'click') {
|
|
oscillator.frequency.setValueAtTime(600, ctx.currentTime);
|
|
oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
|
|
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.05);
|
|
} else if (type === 'card') {
|
|
oscillator.frequency.setValueAtTime(800, ctx.currentTime);
|
|
oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08);
|
|
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.08);
|
|
} else if (type === 'success') {
|
|
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
|
|
oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
|
|
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.2);
|
|
} else if (type === 'flip') {
|
|
// Sharp quick click for card flips
|
|
oscillator.type = 'square';
|
|
oscillator.frequency.setValueAtTime(1800, ctx.currentTime);
|
|
oscillator.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02);
|
|
gainNode.gain.setValueAtTime(0.12, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.025);
|
|
} else if (type === 'shuffle') {
|
|
// Multiple quick sounds to simulate shuffling
|
|
for (let i = 0; i < 8; i++) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.type = 'square';
|
|
const time = ctx.currentTime + i * 0.06;
|
|
osc.frequency.setValueAtTime(200 + Math.random() * 400, time);
|
|
gain.gain.setValueAtTime(0.03, time);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
|
|
osc.start(time);
|
|
osc.stop(time + 0.05);
|
|
}
|
|
return; // Early return since we don't use the main oscillator
|
|
} else if (type === 'reject') {
|
|
// Low buzz for rejected action
|
|
oscillator.type = 'sawtooth';
|
|
oscillator.frequency.setValueAtTime(150, ctx.currentTime);
|
|
oscillator.frequency.setValueAtTime(100, ctx.currentTime + 0.08);
|
|
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.12);
|
|
}
|
|
}
|
|
|
|
toggleSound() {
|
|
this.soundEnabled = !this.soundEnabled;
|
|
this.muteBtn.textContent = this.soundEnabled ? '🔊' : '🔇';
|
|
this.playSound('click');
|
|
}
|
|
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
initElements() {
|
|
// Screens
|
|
this.lobbyScreen = document.getElementById('lobby-screen');
|
|
this.waitingScreen = document.getElementById('waiting-screen');
|
|
this.gameScreen = document.getElementById('game-screen');
|
|
|
|
// Lobby elements
|
|
this.playerNameInput = document.getElementById('player-name');
|
|
this.roomCodeInput = document.getElementById('room-code');
|
|
this.createRoomBtn = document.getElementById('create-room-btn');
|
|
this.joinRoomBtn = document.getElementById('join-room-btn');
|
|
this.lobbyError = document.getElementById('lobby-error');
|
|
|
|
// Waiting room elements
|
|
this.displayRoomCode = document.getElementById('display-room-code');
|
|
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
|
|
this.shareRoomLinkBtn = document.getElementById('share-room-link');
|
|
this.playersList = document.getElementById('players-list');
|
|
this.hostSettings = document.getElementById('host-settings');
|
|
this.waitingMessage = document.getElementById('waiting-message');
|
|
this.numDecksSelect = document.getElementById('num-decks');
|
|
this.deckRecommendation = document.getElementById('deck-recommendation');
|
|
this.numRoundsSelect = document.getElementById('num-rounds');
|
|
this.initialFlipsSelect = document.getElementById('initial-flips');
|
|
this.flipModeSelect = document.getElementById('flip-mode');
|
|
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
|
|
|
|
// Rules screen elements
|
|
this.rulesScreen = document.getElementById('rules-screen');
|
|
this.rulesBtn = document.getElementById('rules-btn');
|
|
this.rulesBackBtn = document.getElementById('rules-back-btn');
|
|
// House Rules - Point Modifiers
|
|
this.superKingsCheckbox = document.getElementById('super-kings');
|
|
this.tenPennyCheckbox = document.getElementById('ten-penny');
|
|
// House Rules - Bonuses/Penalties
|
|
this.knockBonusCheckbox = document.getElementById('knock-bonus');
|
|
this.underdogBonusCheckbox = document.getElementById('underdog-bonus');
|
|
this.tiedShameCheckbox = document.getElementById('tied-shame');
|
|
this.blackjackCheckbox = document.getElementById('blackjack');
|
|
this.wolfpackCheckbox = document.getElementById('wolfpack');
|
|
// House Rules - New Variants
|
|
this.flipAsActionCheckbox = document.getElementById('flip-as-action');
|
|
this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind');
|
|
this.negativePairsCheckbox = document.getElementById('negative-pairs-keep-value');
|
|
this.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks');
|
|
this.knockEarlyCheckbox = document.getElementById('knock-early');
|
|
this.wolfpackComboNote = document.getElementById('wolfpack-combo-note');
|
|
this.startGameBtn = document.getElementById('start-game-btn');
|
|
this.leaveRoomBtn = document.getElementById('leave-room-btn');
|
|
this.addCpuBtn = document.getElementById('add-cpu-btn');
|
|
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
|
|
this.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.deck = document.getElementById('deck');
|
|
this.discard = document.getElementById('discard');
|
|
this.discardContent = document.getElementById('discard-content');
|
|
this.discardBtn = document.getElementById('discard-btn');
|
|
this.cancelDrawBtn = document.getElementById('cancel-draw-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.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');
|
|
}
|
|
|
|
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.cancelDrawBtn.addEventListener('click', () => { this.playSound('click'); this.cancelDraw(); });
|
|
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(); });
|
|
|
|
// Copy room code to clipboard
|
|
this.copyRoomCodeBtn.addEventListener('click', () => {
|
|
this.playSound('click');
|
|
this.copyRoomCode();
|
|
});
|
|
|
|
// Share room link
|
|
this.shareRoomLinkBtn.addEventListener('click', () => {
|
|
this.playSound('click');
|
|
this.shareRoomLink();
|
|
});
|
|
|
|
// Enter key handlers
|
|
this.playerNameInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') this.createRoomBtn.click();
|
|
});
|
|
this.roomCodeInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') this.joinRoomBtn.click();
|
|
});
|
|
|
|
// Auto-uppercase room code
|
|
this.roomCodeInput.addEventListener('input', (e) => {
|
|
e.target.value = e.target.value.toUpperCase();
|
|
});
|
|
|
|
// Update deck recommendation when deck selection changes
|
|
this.numDecksSelect.addEventListener('change', () => {
|
|
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
|
|
this.updateDeckRecommendation(playerCount);
|
|
});
|
|
|
|
// Show combo note when wolfpack + four-of-a-kind are both selected
|
|
const updateWolfpackCombo = () => {
|
|
if (this.wolfpackCheckbox.checked && this.fourOfAKindCheckbox.checked) {
|
|
this.wolfpackComboNote.classList.remove('hidden');
|
|
} else {
|
|
this.wolfpackComboNote.classList.add('hidden');
|
|
}
|
|
};
|
|
this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo);
|
|
this.fourOfAKindCheckbox.addEventListener('change', updateWolfpackCombo);
|
|
|
|
// Toggle scoreboard collapse on mobile
|
|
const scoreboardTitle = this.scoreboard.querySelector('h4');
|
|
if (scoreboardTitle) {
|
|
scoreboardTitle.addEventListener('click', () => {
|
|
if (window.innerWidth <= 700) {
|
|
this.scoreboard.classList.toggle('collapsed');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Rules screen navigation
|
|
if (this.rulesBtn) {
|
|
this.rulesBtn.addEventListener('click', () => {
|
|
this.playSound('click');
|
|
this.showRulesScreen();
|
|
});
|
|
}
|
|
if (this.rulesBackBtn) {
|
|
this.rulesBackBtn.addEventListener('click', () => {
|
|
this.playSound('click');
|
|
this.showLobby();
|
|
});
|
|
}
|
|
}
|
|
|
|
showRulesScreen(scrollToSection = null) {
|
|
this.showScreen(this.rulesScreen);
|
|
if (scrollToSection) {
|
|
const section = document.getElementById(scrollToSection);
|
|
if (section) {
|
|
section.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}
|
|
}
|
|
|
|
connect() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const host = window.location.host || 'localhost:8000';
|
|
const wsUrl = `${protocol}//${host}/ws`;
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('Connected to server');
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
this.handleMessage(data);
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log('Disconnected from server');
|
|
this.showError('Connection lost. Please refresh the page.');
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
this.showError('Connection error. Please try again.');
|
|
};
|
|
}
|
|
|
|
send(message) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(message));
|
|
} else {
|
|
console.error('WebSocket not ready, cannot send:', message.type);
|
|
this.showError('Connection lost. Please refresh.');
|
|
}
|
|
}
|
|
|
|
handleMessage(data) {
|
|
console.log('Received:', data);
|
|
|
|
switch (data.type) {
|
|
case 'room_created':
|
|
this.playerId = data.player_id;
|
|
this.roomCode = data.room_code;
|
|
this.isHost = true;
|
|
this.showWaitingRoom();
|
|
break;
|
|
|
|
case 'room_joined':
|
|
this.playerId = data.player_id;
|
|
this.roomCode = data.room_code;
|
|
this.isHost = false;
|
|
this.showWaitingRoom();
|
|
break;
|
|
|
|
case 'player_joined':
|
|
this.updatePlayersList(data.players);
|
|
this.currentPlayers = data.players;
|
|
break;
|
|
|
|
case 'cpu_profiles':
|
|
this.allProfiles = data.profiles;
|
|
this.renderCpuSelect();
|
|
break;
|
|
|
|
case 'player_left':
|
|
this.updatePlayersList(data.players);
|
|
this.currentPlayers = data.players;
|
|
break;
|
|
|
|
case 'game_started':
|
|
case 'round_started':
|
|
// Clear any countdown from previous hole
|
|
this.clearNextHoleCountdown();
|
|
this.nextRoundBtn.classList.remove('waiting');
|
|
// Clear round winner highlights
|
|
this.roundWinnerNames = new Set();
|
|
this.gameState = data.game_state;
|
|
// Deep copy for previousState to avoid reference issues
|
|
this.previousState = JSON.parse(JSON.stringify(data.game_state));
|
|
// Reset tracking for new round
|
|
this.locallyFlippedCards = new Set();
|
|
this.animatingPositions = new Set();
|
|
this.playSound('shuffle');
|
|
this.showGameScreen();
|
|
this.renderGame();
|
|
break;
|
|
|
|
case 'game_state':
|
|
// State updates are instant, animations are fire-and-forget
|
|
// Exception: Local player's swap animation defers state until complete
|
|
|
|
// If local swap animation is running, defer this state update
|
|
if (this.swapAnimationInProgress) {
|
|
this.updateSwapAnimation(data.game_state.discard_top);
|
|
this.pendingGameState = data.game_state;
|
|
break;
|
|
}
|
|
|
|
const oldState = this.gameState;
|
|
const newState = data.game_state;
|
|
|
|
// Update state FIRST (always)
|
|
this.gameState = newState;
|
|
|
|
// Clear local flip tracking if server confirmed our flips
|
|
if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) {
|
|
this.locallyFlippedCards = new Set();
|
|
}
|
|
|
|
// Detect and fire animations (non-blocking, errors shouldn't break game)
|
|
try {
|
|
this.triggerAnimationsForStateChange(oldState, newState);
|
|
} catch (e) {
|
|
console.error('Animation error:', e);
|
|
}
|
|
|
|
// Render immediately with new state
|
|
this.renderGame();
|
|
break;
|
|
|
|
case 'your_turn':
|
|
// 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');
|
|
}
|
|
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.flipIsOptional = data.optional || false;
|
|
if (this.flipIsOptional) {
|
|
this.showToast('Flip a card or skip', '', 3000);
|
|
} else {
|
|
this.showToast('Flip a face-down card', '', 3000);
|
|
}
|
|
this.renderGame();
|
|
break;
|
|
|
|
case 'round_over':
|
|
this.showScoreboard(data.scores, false, data.rankings);
|
|
break;
|
|
|
|
case 'game_over':
|
|
this.showScoreboard(data.final_scores, true, data.rankings);
|
|
break;
|
|
|
|
case 'game_ended':
|
|
// Host ended the game or player was kicked
|
|
this.ws.close();
|
|
this.showLobby();
|
|
if (data.reason) {
|
|
this.showError(data.reason);
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
this.showError(data.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Room Actions
|
|
createRoom() {
|
|
const name = this.playerNameInput.value.trim() || 'Player';
|
|
this.connect();
|
|
this.ws.onopen = () => {
|
|
this.send({ type: 'create_room', player_name: name });
|
|
};
|
|
}
|
|
|
|
joinRoom() {
|
|
const name = this.playerNameInput.value.trim() || 'Player';
|
|
const code = this.roomCodeInput.value.trim().toUpperCase();
|
|
|
|
if (code.length !== 4) {
|
|
this.showError('Please enter a 4-letter room code');
|
|
return;
|
|
}
|
|
|
|
this.connect();
|
|
this.ws.onopen = () => {
|
|
this.send({ type: 'join_room', room_code: code, player_name: name });
|
|
};
|
|
}
|
|
|
|
leaveRoom() {
|
|
this.send({ type: 'leave_room' });
|
|
this.ws.close();
|
|
this.showLobby();
|
|
}
|
|
|
|
copyRoomCode() {
|
|
if (!this.roomCode) return;
|
|
this.copyToClipboard(this.roomCode, this.copyRoomCodeBtn);
|
|
}
|
|
|
|
shareRoomLink() {
|
|
if (!this.roomCode) return;
|
|
|
|
// Build shareable URL with room code
|
|
const url = new URL(window.location.href);
|
|
url.search = ''; // Clear existing params
|
|
url.hash = ''; // Clear hash
|
|
url.searchParams.set('room', this.roomCode);
|
|
const shareUrl = url.toString();
|
|
|
|
this.copyToClipboard(shareUrl, this.shareRoomLinkBtn);
|
|
}
|
|
|
|
copyToClipboard(text, feedbackBtn) {
|
|
// Use execCommand which is more reliable across contexts
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.left = '-9999px';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
let success = false;
|
|
try {
|
|
success = document.execCommand('copy');
|
|
} catch (err) {
|
|
console.error('Copy failed:', err);
|
|
}
|
|
document.body.removeChild(textarea);
|
|
|
|
// Show visual feedback
|
|
if (success && feedbackBtn) {
|
|
const originalText = feedbackBtn.textContent;
|
|
feedbackBtn.textContent = '✓';
|
|
setTimeout(() => {
|
|
feedbackBtn.textContent = originalText;
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
startGame() {
|
|
try {
|
|
const decks = parseInt(this.numDecksSelect.value);
|
|
const rounds = parseInt(this.numRoundsSelect.value);
|
|
const initial_flips = parseInt(this.initialFlipsSelect.value);
|
|
|
|
// Standard options
|
|
const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame"
|
|
const knock_penalty = this.knockPenaltyCheckbox?.checked || false;
|
|
|
|
// Joker mode (radio buttons)
|
|
const jokerRadio = document.querySelector('input[name="joker-mode"]:checked');
|
|
const joker_mode = jokerRadio ? jokerRadio.value : 'none';
|
|
const use_jokers = joker_mode !== 'none';
|
|
const lucky_swing = joker_mode === 'lucky-swing';
|
|
const eagle_eye = joker_mode === 'eagle-eye';
|
|
|
|
// House Rules - Point Modifiers
|
|
const super_kings = this.superKingsCheckbox?.checked || false;
|
|
const ten_penny = this.tenPennyCheckbox?.checked || false;
|
|
|
|
// House Rules - Bonuses/Penalties
|
|
const knock_bonus = this.knockBonusCheckbox?.checked || false;
|
|
const underdog_bonus = this.underdogBonusCheckbox?.checked || false;
|
|
const tied_shame = this.tiedShameCheckbox?.checked || false;
|
|
const blackjack = this.blackjackCheckbox?.checked || false;
|
|
const wolfpack = this.wolfpackCheckbox?.checked || false;
|
|
|
|
// House Rules - New Variants
|
|
const flip_as_action = this.flipAsActionCheckbox?.checked || false;
|
|
const four_of_a_kind = this.fourOfAKindCheckbox?.checked || false;
|
|
const negative_pairs_keep_value = this.negativePairsCheckbox?.checked || false;
|
|
const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false;
|
|
const knock_early = this.knockEarlyCheckbox?.checked || false;
|
|
|
|
this.send({
|
|
type: 'start_game',
|
|
decks,
|
|
rounds,
|
|
initial_flips,
|
|
flip_mode,
|
|
knock_penalty,
|
|
use_jokers,
|
|
lucky_swing,
|
|
super_kings,
|
|
ten_penny,
|
|
knock_bonus,
|
|
underdog_bonus,
|
|
tied_shame,
|
|
blackjack,
|
|
eagle_eye,
|
|
wolfpack,
|
|
flip_as_action,
|
|
four_of_a_kind,
|
|
negative_pairs_keep_value,
|
|
one_eyed_jacks,
|
|
knock_early
|
|
});
|
|
} catch (error) {
|
|
console.error('Error starting game:', error);
|
|
this.showError('Error starting game. Please refresh.');
|
|
}
|
|
}
|
|
|
|
showCpuSelect() {
|
|
// Request available profiles from server
|
|
this.selectedCpus = new Set();
|
|
this.send({ type: 'get_cpu_profiles' });
|
|
this.cpuSelectModal.classList.remove('hidden');
|
|
}
|
|
|
|
hideCpuSelect() {
|
|
this.cpuSelectModal.classList.add('hidden');
|
|
this.selectedCpus = new Set();
|
|
}
|
|
|
|
renderCpuSelect() {
|
|
if (!this.allProfiles) return;
|
|
|
|
// Get names of CPUs already in the game
|
|
const usedNames = new Set(
|
|
(this.currentPlayers || [])
|
|
.filter(p => p.is_cpu)
|
|
.map(p => p.name)
|
|
);
|
|
|
|
this.cpuProfilesGrid.innerHTML = '';
|
|
|
|
this.allProfiles.forEach(profile => {
|
|
const div = document.createElement('div');
|
|
const isUsed = usedNames.has(profile.name);
|
|
const isSelected = this.selectedCpus && this.selectedCpus.has(profile.name);
|
|
div.className = 'profile-card' + (isUsed ? ' unavailable' : '') + (isSelected ? ' selected' : '');
|
|
|
|
const avatar = this.getCpuAvatar(profile.name);
|
|
const checkbox = isUsed ? '' : `<div class="profile-checkbox">${isSelected ? '✓' : ''}</div>`;
|
|
|
|
div.innerHTML = `
|
|
${checkbox}
|
|
<div class="profile-avatar">${avatar}</div>
|
|
<div class="profile-name">${profile.name}</div>
|
|
<div class="profile-style">${profile.style}</div>
|
|
${isUsed ? '<div class="profile-in-game">In Game</div>' : ''}
|
|
`;
|
|
|
|
if (!isUsed) {
|
|
div.addEventListener('click', () => this.toggleCpuSelection(profile.name));
|
|
}
|
|
|
|
this.cpuProfilesGrid.appendChild(div);
|
|
});
|
|
|
|
this.updateAddCpuButton();
|
|
}
|
|
|
|
getCpuAvatar(name) {
|
|
const avatars = {
|
|
'Sofia': `<svg viewBox="0 0 40 40"><path d="M8 19 Q8 5 20 5 Q32 5 32 19 L32 29 Q28 31 26 27 L26 19 M8 19 L8 29 Q12 31 14 27 L14 19" fill="#c44d00"/><circle cx="20" cy="19" r="9" fill="#e8b4b8"/><circle cx="17" cy="18" r="1.5" fill="#333"/><circle cx="23" cy="18" r="1.5" fill="#333"/><path d="M17 22 Q20 24 23 22" stroke="#333" fill="none" stroke-width="1.2"/><path d="M11 10 Q15 7 20 9 Q25 7 29 10 Q25 13 20 12 Q15 13 11 10" fill="#c44d00"/></svg>`,
|
|
'Maya': `<svg viewBox="0 0 40 40"><circle cx="20" cy="19" r="9" fill="#d4a574"/><circle cx="17" cy="17" r="1.5" fill="#333"/><circle cx="23" cy="17" r="1.5" fill="#333"/><path d="M16 22 L24 22" stroke="#333" stroke-width="1.5"/><path d="M11 15 Q11 8 20 8 Q29 8 29 15" fill="#5c4033"/><ellipse cx="32" cy="17" rx="4" ry="6" fill="#5c4033"/><circle cx="30" cy="15" r="2" fill="#e91e63"/></svg>`,
|
|
'Priya': `<svg viewBox="0 0 40 40"><circle cx="20" cy="21" r="9" fill="#c4956a"/><path d="M12 15 Q12 8 20 8 Q28 8 28 15" fill="#111"/><path d="M12 14 Q10 16 11 20" stroke="#111" stroke-width="2" fill="none"/><path d="M28 14 Q30 16 29 20" stroke="#111" stroke-width="2" fill="none"/><circle cx="17" cy="20" r="1.5" fill="#333"/><circle cx="23" cy="20" r="1.5" fill="#333"/><path d="M17 25 Q20 27 23 25" stroke="#333" fill="none" stroke-width="1.2"/><circle cx="20" cy="17" r="1" fill="#e74c3c"/></svg>`,
|
|
'Marcus': `<svg viewBox="0 0 40 40"><circle cx="20" cy="21" r="10" fill="#a67c52"/><circle cx="17" cy="19" r="2" fill="#333"/><circle cx="23" cy="19" r="2" fill="#333"/><path d="M16 25 Q20 27 24 25" stroke="#333" fill="none" stroke-width="1.5"/><rect x="10" y="8" width="20" height="6" rx="2" fill="#333"/></svg>`,
|
|
'Kenji': `<svg viewBox="0 0 40 40"><circle cx="20" cy="20" r="10" fill="#f0d5a8"/><circle cx="17" cy="18" r="2" fill="#333"/><circle cx="23" cy="18" r="2" fill="#333"/><path d="M17 23 L23 23" stroke="#333" stroke-width="1.5"/><path d="M10 16 Q10 8 20 8 Q30 8 30 16 L28 14 L26 16 L24 13 L22 16 L20 12 L18 16 L16 13 L14 16 L12 14 Z" fill="#1a1a1a"/></svg>`,
|
|
'Diego': `<svg viewBox="0 0 40 40"><circle cx="20" cy="20" r="10" fill="#c9a86c"/><circle cx="17" cy="18" r="2" fill="#333"/><circle cx="23" cy="18" r="2" fill="#333"/><path d="M15 23 Q20 28 25 23" stroke="#333" fill="none" stroke-width="1.5"/><path d="M10 14 Q15 9 20 12 Q25 9 30 14" stroke="#2c1810" fill="none" stroke-width="3"/><rect x="17" y="26" width="6" height="4" rx="1" fill="#4a3728"/></svg>`,
|
|
'River': `<svg viewBox="0 0 40 40"><circle cx="20" cy="21" r="9" fill="#e0c8a8"/><path d="M10 19 Q10 11 20 11 Q30 11 30 19" fill="#7c5e3c"/><circle cx="17" cy="20" r="1.5" fill="#333"/><circle cx="23" cy="20" r="1.5" fill="#333"/><path d="M17 24 Q20 26 23 24" stroke="#333" fill="none" stroke-width="1.2"/><path d="M6 17 Q6 9 20 9 Q34 9 34 17" stroke="#333" stroke-width="2" fill="none"/><ellipse cx="6" cy="21" rx="4" ry="5" fill="#222"/><ellipse cx="34" cy="21" rx="4" ry="5" fill="#222"/><ellipse cx="6" cy="21" rx="2.5" ry="3.5" fill="#444"/><ellipse cx="34" cy="21" rx="2.5" ry="3.5" fill="#444"/></svg>`,
|
|
'Sage': `<svg viewBox="0 0 40 40"><circle cx="20" cy="26" r="10" fill="#d4b896"/><circle cx="17" cy="24" r="2" fill="#333"/><circle cx="23" cy="24" r="2" fill="#333"/><path d="M17 30 L23 28" stroke="#333" stroke-width="1.5"/><path d="M8 18 L20 1 L32 18 Z" fill="#3a3a80"/><ellipse cx="20" cy="18" rx="14" ry="4" fill="#3a3a80"/><circle cx="16" cy="12" r="1" fill="#ffd700"/><circle cx="24" cy="8" r="1.2" fill="#ffd700"/></svg>`
|
|
};
|
|
return avatars[name] || `<svg viewBox="0 0 40 40"><circle cx="20" cy="16" r="10" fill="#ccc"/><circle cx="17" cy="14" r="2" fill="#333"/><circle cx="23" cy="14" r="2" fill="#333"/></svg>`;
|
|
}
|
|
|
|
toggleCpuSelection(profileName) {
|
|
if (!this.selectedCpus) this.selectedCpus = new Set();
|
|
|
|
if (this.selectedCpus.has(profileName)) {
|
|
this.selectedCpus.delete(profileName);
|
|
} else {
|
|
this.selectedCpus.add(profileName);
|
|
}
|
|
this.renderCpuSelect();
|
|
}
|
|
|
|
updateAddCpuButton() {
|
|
const count = this.selectedCpus ? this.selectedCpus.size : 0;
|
|
this.addSelectedCpusBtn.textContent = count > 0 ? `Add ${count} CPU${count > 1 ? 's' : ''}` : 'Add';
|
|
this.addSelectedCpusBtn.disabled = count === 0;
|
|
}
|
|
|
|
addSelectedCpus() {
|
|
if (!this.selectedCpus || this.selectedCpus.size === 0) return;
|
|
|
|
this.selectedCpus.forEach(profileName => {
|
|
this.send({ type: 'add_cpu', profile_name: profileName });
|
|
});
|
|
this.hideCpuSelect();
|
|
}
|
|
|
|
removeCpu() {
|
|
this.send({ type: 'remove_cpu' });
|
|
}
|
|
|
|
// Game Actions
|
|
drawFromDeck() {
|
|
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
|
|
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
|
|
this.playSound('reject');
|
|
}
|
|
return;
|
|
}
|
|
if (this.gameState.waiting_for_initial_flip) return;
|
|
this.playSound('card');
|
|
this.send({ type: 'draw', source: 'deck' });
|
|
}
|
|
|
|
drawFromDiscard() {
|
|
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
|
|
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
|
|
this.playSound('reject');
|
|
}
|
|
return;
|
|
}
|
|
if (this.gameState.waiting_for_initial_flip) return;
|
|
if (!this.gameState.discard_top) return;
|
|
this.playSound('card');
|
|
this.send({ type: 'draw', source: 'discard' });
|
|
}
|
|
|
|
discardDrawn() {
|
|
if (!this.drawnCard) return;
|
|
this.send({ type: 'discard' });
|
|
this.drawnCard = null;
|
|
this.hideDrawnCard();
|
|
this.hideToast();
|
|
}
|
|
|
|
cancelDraw() {
|
|
if (!this.drawnCard) return;
|
|
this.send({ type: 'cancel_draw' });
|
|
this.drawnCard = null;
|
|
this.hideDrawnCard();
|
|
this.hideToast();
|
|
}
|
|
|
|
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
|
|
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();
|
|
const discardRect = this.discard.getBoundingClientRect();
|
|
|
|
// Set up the animated card at hand position
|
|
const swapCard = this.swapCardFromHand;
|
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
const swapCardInner = swapCard.querySelector('.swap-card-inner');
|
|
|
|
// 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
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
swapCardFront.innerHTML = '';
|
|
swapCardFront.className = 'swap-card-front';
|
|
|
|
if (isAlreadyFaceUp && card) {
|
|
// FACE-UP CARD: Show card content immediately, then slide to discard
|
|
if (card.rank === '★') {
|
|
swapCardFront.classList.add('joker');
|
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
|
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
|
|
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
|
|
swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`;
|
|
}
|
|
swapCard.classList.add('flipping'); // Show front immediately
|
|
|
|
// Hide the actual hand card and discard
|
|
handCardEl.classList.add('swap-out');
|
|
this.discard.classList.add('swap-to-hand');
|
|
this.swapAnimation.classList.remove('hidden');
|
|
|
|
// Mark animating
|
|
this.swapAnimationInProgress = true;
|
|
this.swapAnimationCardEl = handCardEl;
|
|
this.swapAnimationContentSet = true;
|
|
|
|
// Send swap
|
|
this.send({ type: 'swap', position });
|
|
this.drawnCard = null;
|
|
|
|
// Slide to discard
|
|
setTimeout(() => {
|
|
swapCard.classList.add('moving');
|
|
swapCard.style.left = discardRect.left + 'px';
|
|
swapCard.style.top = discardRect.top + 'px';
|
|
}, 50);
|
|
|
|
// Complete
|
|
setTimeout(() => {
|
|
this.swapAnimation.classList.add('hidden');
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
handCardEl.classList.remove('swap-out');
|
|
this.discard.classList.remove('swap-to-hand');
|
|
this.swapAnimationInProgress = false;
|
|
this.hideDrawnCard();
|
|
|
|
if (this.pendingGameState) {
|
|
this.gameState = this.pendingGameState;
|
|
this.pendingGameState = null;
|
|
this.renderGame();
|
|
}
|
|
}, 500);
|
|
} else {
|
|
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
|
|
// The new card will appear instantly when state updates
|
|
|
|
// Don't use overlay for face-down - just send swap and let state handle it
|
|
// This avoids the clunky "flip to empty front" issue
|
|
this.swapAnimationInProgress = true;
|
|
this.swapAnimationCardEl = handCardEl;
|
|
this.swapAnimationContentSet = false;
|
|
|
|
// Send swap
|
|
this.send({ type: 'swap', position });
|
|
this.drawnCard = null;
|
|
|
|
// Brief visual feedback - hide drawn card area
|
|
this.discard.classList.add('swap-to-hand');
|
|
handCardEl.classList.add('swap-out');
|
|
|
|
// Short timeout then let state update handle it
|
|
setTimeout(() => {
|
|
this.discard.classList.remove('swap-to-hand');
|
|
handCardEl.classList.remove('swap-out');
|
|
this.swapAnimationInProgress = false;
|
|
this.hideDrawnCard();
|
|
|
|
if (this.pendingGameState) {
|
|
this.gameState = this.pendingGameState;
|
|
this.pendingGameState = null;
|
|
this.renderGame();
|
|
}
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
// Update the animated card with actual card content when server responds
|
|
updateSwapAnimation(card) {
|
|
if (!this.swapAnimationFront || !card) 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 = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
if (card.suit === 'hearts' || card.suit === 'diamonds') {
|
|
this.swapAnimationFront.classList.add('red');
|
|
} else {
|
|
this.swapAnimationFront.classList.add('black');
|
|
}
|
|
this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
|
}
|
|
}
|
|
|
|
flipCard(position) {
|
|
this.send({ type: 'flip_card', position });
|
|
this.waitingForFlip = false;
|
|
this.flipIsOptional = false;
|
|
}
|
|
|
|
skipFlip() {
|
|
if (!this.flipIsOptional) return;
|
|
this.send({ type: 'skip_flip' });
|
|
this.waitingForFlip = false;
|
|
this.flipIsOptional = false;
|
|
this.hideToast();
|
|
}
|
|
|
|
knockEarly() {
|
|
// Flip all remaining face-down cards to go out early
|
|
if (!this.gameState || !this.gameState.knock_early) return;
|
|
this.send({ type: 'knock_early' });
|
|
this.hideToast();
|
|
}
|
|
|
|
// Fire-and-forget animation triggers based on state changes
|
|
triggerAnimationsForStateChange(oldState, newState) {
|
|
if (!oldState) return;
|
|
|
|
// Check for discard pile changes
|
|
const newDiscard = newState.discard_top;
|
|
const oldDiscard = oldState.discard_top;
|
|
const discardChanged = newDiscard && (!oldDiscard ||
|
|
newDiscard.rank !== oldDiscard.rank ||
|
|
newDiscard.suit !== oldDiscard.suit);
|
|
|
|
const previousPlayerId = oldState.current_player_id;
|
|
const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId;
|
|
|
|
// Detect which pile opponent drew from and pulse it
|
|
if (wasOtherPlayer && discardChanged) {
|
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
|
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
|
|
|
if (oldPlayer && newPlayer && oldDiscard) {
|
|
// Check if any of their cards now matches the old discard top
|
|
// This means they took from discard pile
|
|
let tookFromDiscard = false;
|
|
for (let i = 0; i < 6; i++) {
|
|
const newCard = newPlayer.cards[i];
|
|
if (newCard?.face_up &&
|
|
newCard.rank === oldDiscard.rank &&
|
|
newCard.suit === oldDiscard.suit) {
|
|
tookFromDiscard = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Pulse the appropriate pile
|
|
this.pulseDrawPile(tookFromDiscard ? 'discard' : 'deck');
|
|
} else {
|
|
// No old discard or couldn't detect - assume deck
|
|
this.pulseDrawPile('deck');
|
|
}
|
|
}
|
|
|
|
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)
|
|
let swappedPosition = -1;
|
|
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
|
|
if (!wasUp && isUp) {
|
|
swappedPosition = i;
|
|
break;
|
|
}
|
|
// Case 2: both face-up but different card (rank or suit changed)
|
|
if (wasUp && isUp && oldCard.rank && newCard.rank) {
|
|
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
|
|
swappedPosition = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (swappedPosition >= 0) {
|
|
// Player swapped - animate from the actual position that changed
|
|
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition);
|
|
} else {
|
|
// Player drew and discarded without swapping
|
|
// Animate card going from deck area to discard
|
|
this.fireDiscardAnimation(newDiscard);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: We don't separately animate card flips for swaps anymore
|
|
// The swap animation handles showing the card at the correct position
|
|
}
|
|
|
|
// Flash animation on deck or discard pile to show where opponent drew from
|
|
pulseDrawPile(source) {
|
|
const pile = source === 'discard' ? this.discard : this.deck;
|
|
pile.classList.remove('draw-pulse');
|
|
// Trigger reflow to restart animation
|
|
void pile.offsetWidth;
|
|
pile.classList.add('draw-pulse');
|
|
// Remove class after animation completes
|
|
setTimeout(() => pile.classList.remove('draw-pulse'), 400);
|
|
}
|
|
|
|
// Fire animation for discard without swap (card goes deck -> discard)
|
|
fireDiscardAnimation(discardCard) {
|
|
const deckRect = this.deck.getBoundingClientRect();
|
|
const discardRect = this.discard.getBoundingClientRect();
|
|
const swapCard = this.swapCardFromHand;
|
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
|
|
// Start at deck position
|
|
swapCard.style.left = deckRect.left + 'px';
|
|
swapCard.style.top = deckRect.top + 'px';
|
|
swapCard.style.width = deckRect.width + 'px';
|
|
swapCard.style.height = deckRect.height + 'px';
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
|
|
// Set card content
|
|
swapCardFront.className = 'swap-card-front';
|
|
if (discardCard.rank === '★') {
|
|
swapCardFront.classList.add('joker');
|
|
const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
|
|
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
|
|
swapCardFront.innerHTML = `${discardCard.rank}<br>${this.getSuitSymbol(discardCard.suit)}`;
|
|
}
|
|
|
|
this.swapAnimation.classList.remove('hidden');
|
|
|
|
// Flip to reveal card
|
|
setTimeout(() => {
|
|
swapCard.classList.add('flipping');
|
|
this.playSound('flip');
|
|
}, 50);
|
|
|
|
// Move to discard
|
|
setTimeout(() => {
|
|
swapCard.classList.add('moving');
|
|
swapCard.style.left = discardRect.left + 'px';
|
|
swapCard.style.top = discardRect.top + 'px';
|
|
}, 400);
|
|
|
|
// Complete
|
|
setTimeout(() => {
|
|
this.swapAnimation.classList.add('hidden');
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
}, 800);
|
|
}
|
|
|
|
// 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)
|
|
fireSwapAnimation(playerId, discardCard, 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;
|
|
}
|
|
}
|
|
|
|
if (!sourceRect) {
|
|
const discardRect = this.discard.getBoundingClientRect();
|
|
sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height };
|
|
}
|
|
|
|
const discardRect = this.discard.getBoundingClientRect();
|
|
const swapCard = this.swapCardFromHand;
|
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
const swapCardInner = swapCard.querySelector('.swap-card-inner');
|
|
|
|
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');
|
|
|
|
// Apply source rotation to match the arch layout
|
|
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
|
|
|
|
// Set card content
|
|
swapCardFront.className = 'swap-card-front';
|
|
if (discardCard.rank === '★') {
|
|
swapCardFront.classList.add('joker');
|
|
const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
|
|
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
|
|
swapCardFront.innerHTML = `${discardCard.rank}<br>${this.getSuitSymbol(discardCard.suit)}`;
|
|
}
|
|
|
|
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
|
|
this.swapAnimation.classList.remove('hidden');
|
|
|
|
// Timing: flip takes ~400ms, then move takes ~400ms
|
|
setTimeout(() => {
|
|
swapCard.classList.add('flipping');
|
|
this.playSound('flip');
|
|
}, 50);
|
|
setTimeout(() => {
|
|
// Start move AFTER flip completes - also animate rotation back to 0
|
|
swapCard.classList.add('moving');
|
|
swapCard.style.left = discardRect.left + 'px';
|
|
swapCard.style.top = discardRect.top + 'px';
|
|
swapCard.style.transform = 'rotate(0deg)';
|
|
}, 500);
|
|
setTimeout(() => {
|
|
this.swapAnimation.classList.add('hidden');
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
swapCard.style.transform = '';
|
|
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
|
}, 1000);
|
|
}
|
|
|
|
// Fire a flip animation for local player's card (non-blocking)
|
|
fireLocalFlipAnimation(position, cardData) {
|
|
const key = `local-${position}`;
|
|
if (this.animatingPositions.has(key)) return;
|
|
this.animatingPositions.add(key);
|
|
|
|
const cardElements = this.playerCards.querySelectorAll('.card');
|
|
const cardEl = cardElements[position];
|
|
if (!cardEl) {
|
|
this.animatingPositions.delete(key);
|
|
return;
|
|
}
|
|
|
|
const cardRect = cardEl.getBoundingClientRect();
|
|
const swapCard = this.swapCardFromHand;
|
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
|
|
swapCard.style.left = cardRect.left + 'px';
|
|
swapCard.style.top = cardRect.top + 'px';
|
|
swapCard.style.width = cardRect.width + 'px';
|
|
swapCard.style.height = cardRect.height + 'px';
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
|
|
// Set card content
|
|
swapCardFront.className = 'swap-card-front';
|
|
if (cardData.rank === '★') {
|
|
swapCardFront.classList.add('joker');
|
|
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
|
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black');
|
|
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit];
|
|
swapCardFront.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
|
|
}
|
|
|
|
cardEl.classList.add('swap-out');
|
|
this.swapAnimation.classList.remove('hidden');
|
|
|
|
setTimeout(() => {
|
|
swapCard.classList.add('flipping');
|
|
this.playSound('flip');
|
|
}, 50);
|
|
|
|
setTimeout(() => {
|
|
this.swapAnimation.classList.add('hidden');
|
|
swapCard.classList.remove('flipping');
|
|
cardEl.classList.remove('swap-out');
|
|
this.animatingPositions.delete(key);
|
|
}, 450);
|
|
}
|
|
|
|
// Fire a flip animation for opponent card (non-blocking)
|
|
fireFlipAnimation(playerId, position, cardData) {
|
|
// Skip if already animating this position
|
|
const key = `${playerId}-${position}`;
|
|
if (this.animatingPositions.has(key)) return;
|
|
this.animatingPositions.add(key);
|
|
|
|
// Find the card element and parent area (for rotation)
|
|
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
|
let cardEl = null;
|
|
let sourceRotation = 0;
|
|
|
|
for (const area of opponentAreas) {
|
|
const nameEl = area.querySelector('h4');
|
|
const player = this.gameState?.players.find(p => p.id === playerId);
|
|
if (nameEl && player && nameEl.textContent.includes(player.name)) {
|
|
const cards = area.querySelectorAll('.card');
|
|
cardEl = cards[position];
|
|
sourceRotation = this.getElementRotation(area);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!cardEl) {
|
|
this.animatingPositions.delete(key);
|
|
return;
|
|
}
|
|
|
|
const cardRect = cardEl.getBoundingClientRect();
|
|
const swapCard = this.swapCardFromHand;
|
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
|
|
swapCard.style.left = cardRect.left + 'px';
|
|
swapCard.style.top = cardRect.top + 'px';
|
|
swapCard.style.width = cardRect.width + 'px';
|
|
swapCard.style.height = cardRect.height + 'px';
|
|
swapCard.classList.remove('flipping', 'moving');
|
|
|
|
// Apply rotation to match the arch layout
|
|
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
|
|
|
|
// Set card content
|
|
swapCardFront.className = 'swap-card-front';
|
|
if (cardData.rank === '★') {
|
|
swapCardFront.classList.add('joker');
|
|
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
|
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
swapCardFront.classList.add(cardData.suit === 'hearts' || cardData.suit === 'diamonds' ? 'red' : 'black');
|
|
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[cardData.suit];
|
|
swapCardFront.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
|
|
}
|
|
|
|
cardEl.classList.add('swap-out');
|
|
this.swapAnimation.classList.remove('hidden');
|
|
|
|
setTimeout(() => {
|
|
swapCard.classList.add('flipping');
|
|
this.playSound('flip');
|
|
}, 50);
|
|
|
|
setTimeout(() => {
|
|
this.swapAnimation.classList.add('hidden');
|
|
swapCard.classList.remove('flipping');
|
|
swapCard.style.transform = '';
|
|
cardEl.classList.remove('swap-out');
|
|
this.animatingPositions.delete(key);
|
|
}, 450);
|
|
}
|
|
|
|
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;
|
|
if (this.locallyFlippedCards.has(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();
|
|
|
|
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);
|
|
}
|
|
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');
|
|
}
|
|
|
|
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.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;
|
|
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 = '<span class="rule-tag standard">Standard</span>';
|
|
} else if (rules.length <= 2) {
|
|
// Show all rules if 2 or fewer
|
|
this.activeRulesList.innerHTML = rules
|
|
.map(rule => `<span class="rule-tag">${rule}</span>`)
|
|
.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 => `<span class="rule-tag">${rule}</span>`)
|
|
.join('') +
|
|
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
|
|
}
|
|
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 += '<span class="host-badge">HOST</span>';
|
|
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
|
|
|
|
let nameDisplay = player.name;
|
|
if (player.style) {
|
|
nameDisplay += ` <span class="cpu-style">(${player.style})</span>`;
|
|
}
|
|
|
|
li.innerHTML = `
|
|
<span>${nameDisplay}</span>
|
|
<span>${badges}</span>
|
|
`;
|
|
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);
|
|
}
|
|
|
|
setStatus(message, type = '') {
|
|
this.statusMessage.textContent = message;
|
|
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 in the discard pile position, highlighted
|
|
const card = this.drawnCard;
|
|
|
|
this.discard.className = 'card card-front holding';
|
|
if (card.rank === '★') {
|
|
this.discard.classList.add('joker');
|
|
} else if (this.isRedSuit(card.suit)) {
|
|
this.discard.classList.add('red');
|
|
} else {
|
|
this.discard.classList.add('black');
|
|
}
|
|
|
|
// Render card directly without checking face_up (drawn card is always visible to drawer)
|
|
if (card.rank === '★') {
|
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
|
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
} else {
|
|
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
|
}
|
|
this.discardBtn.classList.remove('hidden');
|
|
}
|
|
|
|
hideDrawnCard() {
|
|
// Restore discard pile to show actual top card (handled by renderGame)
|
|
this.discard.classList.remove('holding');
|
|
this.discardBtn.classList.add('hidden');
|
|
this.cancelDrawBtn.classList.add('hidden');
|
|
}
|
|
|
|
isRedSuit(suit) {
|
|
return suit === 'hearts' || suit === 'diamonds';
|
|
}
|
|
|
|
calculateShowingScore(cards) {
|
|
if (!cards || cards.length !== 6) return 0;
|
|
|
|
// Use card values from server (includes house rules) or defaults
|
|
const cardValues = this.gameState?.card_values || {
|
|
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
|
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
|
};
|
|
|
|
const getCardValue = (card) => {
|
|
if (!card.face_up) return 0;
|
|
return cardValues[card.rank] ?? 0;
|
|
};
|
|
|
|
// Check for column pairs (cards in same column cancel out if matching)
|
|
let total = 0;
|
|
for (let col = 0; col < 3; col++) {
|
|
const topCard = cards[col];
|
|
const bottomCard = cards[col + 3];
|
|
|
|
const topUp = topCard.face_up;
|
|
const bottomUp = bottomCard.face_up;
|
|
|
|
// If both face up and matching rank, they cancel (score 0)
|
|
if (topUp && bottomUp && topCard.rank === bottomCard.rank) {
|
|
// Matching pair = 0 points for both
|
|
continue;
|
|
}
|
|
|
|
// Otherwise add individual values
|
|
total += getCardValue(topCard);
|
|
total += getCardValue(bottomCard);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
getSuitSymbol(suit) {
|
|
const symbols = {
|
|
hearts: '♥',
|
|
diamonds: '♦',
|
|
clubs: '♣',
|
|
spades: '♠'
|
|
};
|
|
return symbols[suit] || '';
|
|
}
|
|
|
|
renderCardContent(card) {
|
|
if (!card || !card.face_up) return '';
|
|
// Jokers - use suit to determine icon (hearts = dragon, spades = oni)
|
|
if (card.rank === '★') {
|
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
|
return `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
}
|
|
return `${card.rank}<br>${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;
|
|
|
|
// Show/hide final turn badge
|
|
const isFinalTurn = this.gameState.phase === 'final_turn';
|
|
if (isFinalTurn) {
|
|
this.finalTurnBadge.classList.remove('hidden');
|
|
} else {
|
|
this.finalTurnBadge.classList.add('hidden');
|
|
}
|
|
|
|
// Toggle not-my-turn class to disable hover effects when it's not player's turn
|
|
const isMyTurn = this.isMyTurn();
|
|
this.gameScreen.classList.toggle('not-my-turn', !isMyTurn);
|
|
|
|
// Update status message (handled by specific actions, but set default here)
|
|
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
|
|
if (currentPlayer && currentPlayer.id !== this.playerId) {
|
|
this.setStatus(`${currentPlayer.name}'s turn`);
|
|
}
|
|
|
|
// Update player header (name + score like opponents)
|
|
const me = this.gameState.players.find(p => p.id === this.playerId);
|
|
if (me) {
|
|
// Calculate visible score from face-up cards
|
|
const showingScore = this.calculateShowingScore(me.cards);
|
|
this.yourScore.textContent = showingScore;
|
|
|
|
// Check if player won the round
|
|
const isRoundWinner = this.roundWinnerNames.has(me.name);
|
|
this.playerArea.classList.toggle('round-winner', isRoundWinner);
|
|
|
|
// Update player name in header (truncate if needed)
|
|
const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name;
|
|
const checkmark = me.all_face_up ? ' ✓' : '';
|
|
// Update player name span with crown if winner
|
|
const playerNameSpan = this.playerHeader.querySelector('.player-name');
|
|
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
|
|
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
|
|
}
|
|
|
|
// Update discard pile (skip if holding a drawn card)
|
|
if (!this.drawnCard) {
|
|
if (this.gameState.discard_top) {
|
|
const discardCard = this.gameState.discard_top;
|
|
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
|
|
|
// Animate if discard changed
|
|
if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) {
|
|
this.discard.classList.add('card-flip-in');
|
|
setTimeout(() => this.discard.classList.remove('card-flip-in'), 400);
|
|
}
|
|
this.lastDiscardKey = cardKey;
|
|
|
|
this.discard.classList.add('has-card', 'card-front');
|
|
this.discard.classList.remove('card-back', 'red', 'black', 'joker', '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);
|
|
} else {
|
|
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
|
|
this.discardContent.innerHTML = '';
|
|
this.lastDiscardKey = null;
|
|
}
|
|
this.discardBtn.classList.add('hidden');
|
|
}
|
|
|
|
// 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);
|
|
// Don't show disabled state when we're holding a drawn card (it's displayed in discard position)
|
|
this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard);
|
|
|
|
// 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 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 ? '<span class="winner-crown">👑</span>' : '';
|
|
|
|
div.innerHTML = `
|
|
<h4>${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
|
|
<div class="card-grid">
|
|
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
|
|
const isClickable = (
|
|
(this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped) ||
|
|
(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);
|
|
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');
|
|
// Show cancel button when drawn from discard (can put it back)
|
|
this.cancelDrawBtn.classList.remove('hidden');
|
|
} else {
|
|
this.discardBtn.disabled = false;
|
|
this.discardBtn.classList.remove('disabled');
|
|
this.cancelDrawBtn.classList.add('hidden');
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Update score table (right panel)
|
|
this.scoreTable.innerHTML = '';
|
|
|
|
this.gameState.players.forEach(player => {
|
|
const tr = document.createElement('tr');
|
|
|
|
// Highlight current player
|
|
if (player.id === this.gameState.current_player_id) {
|
|
tr.classList.add('current-player');
|
|
}
|
|
|
|
// Truncate long names
|
|
const displayName = player.name.length > 12
|
|
? player.name.substring(0, 11) + '…'
|
|
: player.name;
|
|
|
|
const roundScore = player.score !== null ? player.score : '-';
|
|
const roundsWon = player.rounds_won || 0;
|
|
|
|
tr.innerHTML = `
|
|
<td>${displayName}</td>
|
|
<td>${roundScore}</td>
|
|
<td>${player.total_score}</td>
|
|
<td>${roundsWon}</td>
|
|
`;
|
|
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 `<div class="rank-row ${pointsRank === 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.total_score} pts</span></div>`;
|
|
}).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 `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
|
|
}).join('');
|
|
|
|
this.standingsList.innerHTML = `
|
|
<div class="standings-section">
|
|
<div class="standings-title">By Score</div>
|
|
${pointsHtml}
|
|
</div>
|
|
<div class="standings-section">
|
|
<div class="standings-title">By Holes</div>
|
|
${holesHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `<div class="${classes}">${content}</div>`;
|
|
}
|
|
|
|
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 = `
|
|
<td>${displayName}</td>
|
|
<td>${roundScore}</td>
|
|
<td>${total}</td>
|
|
<td>${roundsWon}</td>
|
|
`;
|
|
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 `<div class="rank-row ${pointsRank === 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.total} pts</span></div>`;
|
|
}).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 `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
|
|
}).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 = `
|
|
<h3>${title}</h3>
|
|
<div class="rankings-columns">
|
|
<div class="ranking-section">
|
|
<h4>Points (Low Wins)</h4>
|
|
${pointsHtml}
|
|
</div>
|
|
<div class="ranking-section">
|
|
<h4>Holes Won</h4>
|
|
${holesHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 `<div class="final-rank-row ${pointsRank === 0 ? 'winner' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${p.name}</span><span class="rank-val">${p.total} pts</span></div>`;
|
|
}).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 `<div class="final-rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'winner' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${p.name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
|
|
}).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 = `
|
|
<div class="final-results-content">
|
|
<h2>🏌️ Final Results</h2>
|
|
${isDoubleVictory ? `<div class="double-victory-banner">🏆 DOUBLE VICTORY: ${pointsLeader.name} 🏆</div>` : ''}
|
|
<div class="final-rankings">
|
|
<div class="final-ranking-section">
|
|
<h3>By Points (Low Wins)</h3>
|
|
${pointsHtml}
|
|
</div>
|
|
<div class="final-ranking-section">
|
|
<h3>By Holes Won</h3>
|
|
${holesHtml}
|
|
</div>
|
|
</div>
|
|
<div class="final-actions">
|
|
<button class="btn btn-primary" id="share-results-btn">📋 Copy Results</button>
|
|
<button class="btn btn-secondary" id="close-results-btn">New Game</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Bind button events
|
|
document.getElementById('share-results-btn').addEventListener('click', () => {
|
|
navigator.clipboard.writeText(shareText).then(() => {
|
|
const btn = document.getElementById('share-results-btn');
|
|
btn.textContent = '✓ Copied!';
|
|
setTimeout(() => btn.textContent = '📋 Copy Results', 2000);
|
|
});
|
|
});
|
|
|
|
document.getElementById('close-results-btn').addEventListener('click', () => {
|
|
modal.remove();
|
|
this.leaveRoom();
|
|
});
|
|
}
|
|
|
|
buildShareText(rankings, isDoubleVictory) {
|
|
let text = '🏌️ Golf Card Game Results\n';
|
|
text += '═══════════════════════\n\n';
|
|
|
|
if (isDoubleVictory) {
|
|
text += `🏆 DOUBLE VICTORY: ${rankings.by_points[0].name}!\n\n`;
|
|
}
|
|
|
|
text += '📊 By Points (Low Wins):\n';
|
|
rankings.by_points.forEach((p, i) => {
|
|
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
text += `${medal} ${p.name}: ${p.total} pts\n`;
|
|
});
|
|
|
|
text += '\n⛳ By Holes Won:\n';
|
|
rankings.by_holes_won.forEach((p, i) => {
|
|
const medal = p.rounds_won === 0 ? '-' : i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
text += `${medal} ${p.name}: ${p.rounds_won} wins\n`;
|
|
});
|
|
|
|
text += '\nPlayed at golf.game';
|
|
return text;
|
|
}
|
|
}
|
|
|
|
// Initialize game when page loads
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.game = new GolfGame();
|
|
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');
|
|
}
|
|
}
|
|
}
|