golfgame/client/app.js
adlee-was-taken ecad259db2 Lower held card position and add opponent row padding on mobile
Reduce held card overlap offset from 0.35 to 0.15 on mobile portrait
so it doesn't cover the second row of opponents. Increase bottom
padding on opponents row from 6px to 12px for more breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:10:11 -05:00

4920 lines
202 KiB
JavaScript

// Golf Card Game - Client Application
// Debug logging - set to true to see detailed state/animation logs
const DEBUG_GAME = false;
function debugLog(category, message, data = null) {
if (!DEBUG_GAME) return;
const timestamp = new Date().toISOString().substr(11, 12);
const prefix = `[${timestamp}] [${category}]`;
if (data) {
console.log(prefix, message, data);
} else {
console.log(prefix, message);
}
}
class GolfGame {
constructor() {
this.ws = null;
this.playerId = null;
this.roomCode = null;
this.isHost = false;
this.gameState = null;
this.drawnCard = null;
this.drawnFromDiscard = false;
this.selectedCards = [];
this.waitingForFlip = false;
this.currentPlayers = [];
this.allProfiles = [];
this.soundEnabled = true;
this.audioCtx = null;
// Swap animation state
this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null;
this.swapAnimationFront = null;
this.swapAnimationContentSet = false;
this.pendingSwapData = null;
this.pendingGameState = null;
// Track cards we've locally flipped (for immediate feedback during selection)
this.locallyFlippedCards = new Set();
// Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set();
// Track opponent swap animation in progress (to apply swap-out class after render)
this.opponentSwapAnimation = null; // { playerId, position }
// Track draw pulse animation in progress (defer held card display until pulse completes)
this.drawPulseAnimation = false;
// Track local discard animation in progress (prevent renderGame from updating discard)
this.localDiscardAnimating = false;
// Track opponent discard animation in progress (prevent renderGame from updating discard)
this.opponentDiscardAnimating = false;
// Track deal animation in progress (suppress flip prompts until dealing complete)
this.dealAnimationInProgress = false;
// Track round winners for visual highlight
this.roundWinnerNames = new Set();
// V3_15: Discard pile history
this.discardHistory = [];
this.maxDiscardHistory = 5;
// Mobile detection
this.isMobile = false;
this.initElements();
this.initAudio();
this.initCardTooltips();
this.bindEvents();
this.initMobileDetection();
this.checkUrlParams();
}
checkUrlParams() {
// Handle ?room=XXXX share links
const params = new URLSearchParams(window.location.search);
const roomCode = params.get('room');
if (roomCode) {
this.roomCodeInput.value = roomCode.toUpperCase();
this.roomCodeInput.focus();
// Clean up URL without reloading
window.history.replaceState({}, '', window.location.pathname);
}
}
initMobileDetection() {
// Set --app-height custom property to actual visible viewport height.
// This works around Chrome Android's 100vh bug where vh includes the
// space behind the dynamic URL bar.
const setAppHeight = () => {
document.documentElement.style.setProperty('--app-height', `${window.innerHeight}px`);
};
window.addEventListener('resize', setAppHeight);
setAppHeight();
const mql = window.matchMedia('(max-width: 500px) and (orientation: portrait)');
const update = (e) => {
this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches);
setAppHeight();
// Close any open drawers on layout change
if (!e.matches) {
this.closeDrawers();
}
};
mql.addEventListener('change', update);
update(mql);
// Bottom bar drawer toggles
const bottomBar = document.getElementById('mobile-bottom-bar');
const backdrop = document.getElementById('drawer-backdrop');
if (bottomBar) {
bottomBar.addEventListener('click', (e) => {
const btn = e.target.closest('.mobile-bar-btn');
if (!btn) return;
const drawerId = btn.dataset.drawer;
const panel = document.getElementById(drawerId);
if (!panel) return;
const isOpen = panel.classList.contains('drawer-open');
this.closeDrawers();
if (!isOpen) {
panel.classList.add('drawer-open');
btn.classList.add('active');
if (backdrop) backdrop.classList.add('visible');
if (bottomBar) bottomBar.classList.add('hidden');
}
});
}
if (backdrop) {
backdrop.addEventListener('click', () => this.closeDrawers());
}
}
closeDrawers() {
document.querySelectorAll('.side-panel.drawer-open').forEach(p => p.classList.remove('drawer-open'));
document.querySelectorAll('.mobile-bar-btn.active').forEach(b => b.classList.remove('active'));
const backdrop = document.getElementById('drawer-backdrop');
if (backdrop) backdrop.classList.remove('visible');
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.remove('hidden');
}
initAudio() {
// Initialize audio context on first user interaction
const initCtx = () => {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
document.removeEventListener('click', initCtx);
};
document.addEventListener('click', initCtx);
}
playSound(type = 'click') {
if (!this.soundEnabled || !this.audioCtx) return;
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'click') {
oscillator.frequency.setValueAtTime(600, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05);
} else if (type === 'card') {
// V3_16: Card place with variation + noise
const pitchVar = 1 + (Math.random() - 0.5) * 0.1;
oscillator.frequency.setValueAtTime(800 * pitchVar, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(400 * pitchVar, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.08);
this.playNoiseBurst(ctx, 0.02, 0.03);
} else if (type === 'success') {
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} else if (type === 'flip') {
// V3_16: Enhanced sharp snap with noise texture + pitch variation
const pitchVar = 1 + (Math.random() - 0.5) * 0.15;
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(1800 * pitchVar, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(600 * pitchVar, ctx.currentTime + 0.02);
gainNode.gain.setValueAtTime(0.12, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.025);
// Add noise burst for paper texture
this.playNoiseBurst(ctx, 0.03, 0.02);
} else if (type === 'shuffle') {
// Multiple quick sounds to simulate shuffling
for (let i = 0; i < 8; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
const time = ctx.currentTime + i * 0.06;
osc.frequency.setValueAtTime(200 + Math.random() * 400, time);
gain.gain.setValueAtTime(0.03, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
osc.start(time);
osc.stop(time + 0.05);
}
return; // Early return since we don't use the main oscillator
} else if (type === 'reject') {
// Low buzz for rejected action
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(150, ctx.currentTime);
oscillator.frequency.setValueAtTime(100, ctx.currentTime + 0.08);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.12);
} else if (type === 'alert') {
// Rising triad for final turn announcement
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(523, ctx.currentTime); // C5
oscillator.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
oscillator.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
} else if (type === 'pair') {
// Two-tone ding for column pair match
const osc2 = ctx.createOscillator();
osc2.connect(gainNode);
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
osc2.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
osc2.stop(ctx.currentTime + 0.3);
} else if (type === 'draw-deck') {
// Mysterious slide + rise for unknown card
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(300, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
oscillator.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} else if (type === 'draw-discard') {
// Quick decisive grab sound
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(600, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.06);
} else if (type === 'knock') {
// Dramatic low thud for knock early
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(80, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
gainNode.gain.setValueAtTime(0.4, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
// Secondary impact
setTimeout(() => {
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(60, ctx.currentTime);
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc2.start(ctx.currentTime);
osc2.stop(ctx.currentTime + 0.1);
}, 100);
}
}
// V3_16: Noise burst for realistic card texture
playNoiseBurst(ctx, volume, duration) {
try {
const bufferSize = Math.floor(ctx.sampleRate * duration);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const noiseGain = ctx.createGain();
noise.connect(noiseGain);
noiseGain.connect(ctx.destination);
noiseGain.gain.setValueAtTime(volume, ctx.currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
noise.start(ctx.currentTime);
noise.stop(ctx.currentTime + duration);
} catch (e) {
// Noise burst is optional, don't break if it fails
}
}
toggleSound() {
this.soundEnabled = !this.soundEnabled;
this.muteBtn.textContent = this.soundEnabled ? '🔊' : '🔇';
this.playSound('click');
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// --- V3_13: Card Value Tooltips ---
initCardTooltips() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'card-value-tooltip hidden';
document.body.appendChild(this.tooltip);
this.tooltipTimeout = null;
}
bindCardTooltipEvents(cardElement, cardData) {
if (!cardData?.face_up || !cardData?.rank) return;
// Desktop hover with delay
cardElement.addEventListener('mouseenter', () => {
this.scheduleTooltip(cardElement, cardData);
});
cardElement.addEventListener('mouseleave', () => {
this.hideCardTooltip();
});
// Mobile long-press
let pressTimer = null;
cardElement.addEventListener('touchstart', () => {
pressTimer = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
}, 400);
}, { passive: true });
cardElement.addEventListener('touchend', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
});
cardElement.addEventListener('touchmove', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
}, { passive: true });
}
scheduleTooltip(cardElement, cardData) {
this.hideCardTooltip();
if (!cardData?.face_up || !cardData?.rank) return;
this.tooltipTimeout = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
}, 500);
}
showCardTooltip(cardElement, cardData) {
if (!cardData?.face_up || !cardData?.rank) return;
if (this.swapAnimationInProgress) return;
// Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValue(cardData);
const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
if (special) {
content += `<span class="tooltip-note">${special}</span>`;
}
this.tooltip.innerHTML = content;
this.tooltip.classList.remove('hidden');
// Position below card
const rect = cardElement.getBoundingClientRect();
let left = rect.left + rect.width / 2;
let top = rect.bottom + 8;
// Keep on screen
if (top + 50 > window.innerHeight) {
top = rect.top - 50;
}
left = Math.max(40, Math.min(window.innerWidth - 40, left));
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
}
hideCardTooltip() {
clearTimeout(this.tooltipTimeout);
if (this.tooltip) this.tooltip.classList.add('hidden');
}
getCardPointValue(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues();
return values[cardData.rank] ?? 0;
}
getCardSpecialNote(cardData) {
const rank = cardData.rank;
const value = this.getCardPointValue(cardData);
if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!';
if (rank === '10' && value === 1) return 'Ten Penny rule';
if ((rank === 'J' || rank === 'Q') && value >= 10) return 'High - replace if possible';
return null;
}
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
this.matchmakingScreen = document.getElementById('matchmaking-screen');
this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
this.roomCodeInput = document.getElementById('room-code');
this.findGameBtn = document.getElementById('find-game-btn');
this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn');
this.lobbyError = document.getElementById('lobby-error');
// Matchmaking elements
this.matchmakingStatus = document.getElementById('matchmaking-status');
this.matchmakingTime = document.getElementById('matchmaking-time');
this.matchmakingQueueInfo = document.getElementById('matchmaking-queue-info');
this.cancelMatchmakingBtn = document.getElementById('cancel-matchmaking-btn');
this.matchmakingTimer = null;
// Waiting room elements
this.displayRoomCode = document.getElementById('display-room-code');
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
this.shareRoomLinkBtn = document.getElementById('share-room-link');
this.playersList = document.getElementById('players-list');
this.hostSettings = document.getElementById('host-settings');
this.waitingMessage = document.getElementById('waiting-message');
this.numDecksInput = document.getElementById('num-decks');
this.numDecksDisplay = document.getElementById('num-decks-display');
this.decksMinus = document.getElementById('decks-minus');
this.decksPlus = document.getElementById('decks-plus');
this.deckRecommendation = document.getElementById('deck-recommendation');
this.deckColorsGroup = document.getElementById('deck-colors-group');
this.deckColorPresetSelect = document.getElementById('deck-color-preset');
this.deckColorPreview = document.getElementById('deck-color-preview');
this.numRoundsSelect = document.getElementById('num-rounds');
this.initialFlipsSelect = document.getElementById('initial-flips');
this.flipModeSelect = document.getElementById('flip-mode');
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
// Rules screen elements
this.rulesScreen = document.getElementById('rules-screen');
this.rulesBtn = document.getElementById('rules-btn');
this.rulesBackBtn = document.getElementById('rules-back-btn');
// House Rules - Point Modifiers
this.superKingsCheckbox = document.getElementById('super-kings');
this.tenPennyCheckbox = document.getElementById('ten-penny');
// House Rules - Bonuses/Penalties
this.knockBonusCheckbox = document.getElementById('knock-bonus');
this.underdogBonusCheckbox = document.getElementById('underdog-bonus');
this.tiedShameCheckbox = document.getElementById('tied-shame');
this.blackjackCheckbox = document.getElementById('blackjack');
this.wolfpackCheckbox = document.getElementById('wolfpack');
// House Rules - New Variants
this.flipAsActionCheckbox = document.getElementById('flip-as-action');
this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind');
this.negativePairsCheckbox = document.getElementById('negative-pairs-keep-value');
this.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks');
this.knockEarlyCheckbox = document.getElementById('knock-early');
this.wolfpackComboNote = document.getElementById('wolfpack-combo-note');
this.unrankedNotice = document.getElementById('unranked-notice');
this.startGameBtn = document.getElementById('start-game-btn');
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
this.cpuControlsSection = document.getElementById('cpu-controls-section');
this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn');
// Game elements
this.currentRoundSpan = document.getElementById('current-round');
this.totalRoundsSpan = document.getElementById('total-rounds');
this.statusMessage = document.getElementById('status-message');
this.playerHeader = document.getElementById('player-header');
this.yourScore = document.getElementById('your-score');
this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row');
this.deckArea = document.querySelector('.deck-area');
this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.knockEarlyBtn = document.getElementById('knock-early-btn');
this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation');
this.swapCardFromHand = document.getElementById('swap-card-from-hand');
this.heldCardSlot = document.getElementById('held-card-slot');
this.heldCardDisplay = document.getElementById('held-card-display');
this.heldCardContent = document.getElementById('held-card-content');
this.heldCardFloating = document.getElementById('held-card-floating');
this.heldCardFloatingContent = document.getElementById('held-card-floating-content');
this.scoreboard = document.getElementById('scoreboard');
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.standingsList = document.getElementById('standings-list');
this.gameButtons = document.getElementById('game-buttons');
this.nextRoundBtn = document.getElementById('next-round-btn');
this.newGameBtn = document.getElementById('new-game-btn');
this.leaveGameBtn = document.getElementById('leave-game-btn');
this.activeRulesBar = document.getElementById('active-rules-bar');
this.activeRulesList = document.getElementById('active-rules-list');
this.finalTurnBadge = document.getElementById('final-turn-badge');
// In-game auth elements
this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar');
}
bindEvents() {
this.findGameBtn?.addEventListener('click', () => { this.playSound('click'); this.findGame(); });
this.cancelMatchmakingBtn?.addEventListener('click', () => { this.playSound('click'); this.cancelMatchmaking(); });
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
this.leaveRoomBtn.addEventListener('click', () => { this.playSound('click'); this.leaveRoom(); });
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); });
this.removeCpuBtn.addEventListener('click', () => { this.playSound('click'); this.removeCpu(); });
this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
this.muteBtn.addEventListener('click', () => this.toggleSound());
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
const mobileLeaveBtn = document.getElementById('mobile-leave-btn');
if (mobileLeaveBtn) mobileLeaveBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
// Copy room code to clipboard
this.copyRoomCodeBtn.addEventListener('click', () => {
this.playSound('click');
this.copyRoomCode();
});
// Share room link
this.shareRoomLinkBtn.addEventListener('click', () => {
this.playSound('click');
this.shareRoomLink();
});
// Enter key handlers
this.roomCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.joinRoomBtn.click();
});
// Auto-uppercase room code
this.roomCodeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase();
});
// Deck stepper controls
if (this.decksMinus) {
this.decksMinus.addEventListener('click', () => {
this.playSound('click');
this.adjustDeckCount(-1);
});
}
if (this.decksPlus) {
this.decksPlus.addEventListener('click', () => {
this.playSound('click');
this.adjustDeckCount(1);
});
}
// Update preview when color preset changes
if (this.deckColorPresetSelect) {
this.deckColorPresetSelect.addEventListener('change', () => {
this.updateDeckColorPreview();
});
}
// Show combo note when wolfpack + four-of-a-kind are both selected
const updateWolfpackCombo = () => {
if (this.wolfpackCheckbox.checked && this.fourOfAKindCheckbox.checked) {
this.wolfpackComboNote.classList.remove('hidden');
} else {
this.wolfpackComboNote.classList.add('hidden');
}
};
this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo);
this.fourOfAKindCheckbox.addEventListener('change', updateWolfpackCombo);
// Show/hide unranked notice when house rules change
const houseRuleInputs = [
this.flipModeSelect, this.knockPenaltyCheckbox,
this.superKingsCheckbox, this.tenPennyCheckbox,
this.knockBonusCheckbox, this.underdogBonusCheckbox,
this.tiedShameCheckbox, this.blackjackCheckbox,
this.wolfpackCheckbox, this.flipAsActionCheckbox,
this.fourOfAKindCheckbox, this.negativePairsCheckbox,
this.oneEyedJacksCheckbox, this.knockEarlyCheckbox,
];
const jokerRadios = document.querySelectorAll('input[name="joker-mode"]');
const updateUnrankedNotice = () => {
const hasHouseRules = (
(this.flipModeSelect?.value && this.flipModeSelect.value !== 'never') ||
this.knockPenaltyCheckbox?.checked ||
(document.querySelector('input[name="joker-mode"]:checked')?.value !== 'none') ||
this.superKingsCheckbox?.checked || this.tenPennyCheckbox?.checked ||
this.knockBonusCheckbox?.checked || this.underdogBonusCheckbox?.checked ||
this.tiedShameCheckbox?.checked || this.blackjackCheckbox?.checked ||
this.wolfpackCheckbox?.checked || this.flipAsActionCheckbox?.checked ||
this.fourOfAKindCheckbox?.checked || this.negativePairsCheckbox?.checked ||
this.oneEyedJacksCheckbox?.checked || this.knockEarlyCheckbox?.checked
);
this.unrankedNotice?.classList.toggle('hidden', !hasHouseRules);
};
houseRuleInputs.forEach(el => el?.addEventListener('change', updateUnrankedNotice));
jokerRadios.forEach(el => el.addEventListener('change', updateUnrankedNotice));
// Toggle scoreboard collapse on mobile
const scoreboardTitle = this.scoreboard.querySelector('h4');
if (scoreboardTitle) {
scoreboardTitle.addEventListener('click', () => {
if (window.innerWidth <= 700) {
this.scoreboard.classList.toggle('collapsed');
}
});
}
// Rules screen navigation
if (this.rulesBtn) {
this.rulesBtn.addEventListener('click', () => {
this.playSound('click');
this.showRulesScreen();
});
}
if (this.rulesBackBtn) {
this.rulesBackBtn.addEventListener('click', () => {
this.playSound('click');
this.showLobby();
});
}
}
showRulesScreen(scrollToSection = null) {
this.showScreen(this.rulesScreen);
if (scrollToSection) {
const section = document.getElementById(scrollToSection);
if (section) {
section.scrollIntoView({ behavior: 'smooth' });
}
}
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8000';
let wsUrl = `${protocol}//${host}/ws`;
// Attach auth token if available
const token = this.authManager?.token;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Connected to server');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
console.log('Disconnected from server');
if (!this._intentionalClose) {
this.showError('Connection lost. Please refresh the page.');
}
this._intentionalClose = false;
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.showError('Connection error. Please try again.');
};
}
reconnect() {
if (this.ws) {
this.ws.onclose = null; // Prevent error message on intentional close
this.ws.close();
}
this.connect();
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket not ready, cannot send:', message.type);
this.showError('Connection lost. Please refresh.');
}
}
handleMessage(data) {
console.log('Received:', data);
switch (data.type) {
case 'room_created':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = true;
this.showWaitingRoom();
break;
case 'room_joined':
this.playerId = data.player_id;
this.roomCode = data.room_code;
this.isHost = false;
this.showWaitingRoom();
break;
case 'player_joined':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'cpu_profiles':
this.allProfiles = data.profiles;
this.renderCpuSelect();
break;
case 'player_left':
this.updatePlayersList(data.players);
this.currentPlayers = data.players;
break;
case 'game_started':
case 'round_started':
// Clear any countdown from previous hole
this.clearNextHoleCountdown();
this.dismissScoresheetModal();
this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights
this.roundWinnerNames = new Set();
this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state));
// Reset all tracking for new round
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.drawPulseAnimation = false;
// V3_15: Clear discard history for new round
this.clearDiscardHistory();
// Cancel any running animations from previous round
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.showGameScreen();
// V3_02: Animate dealing instead of instant render
this.runDealAnimation();
break;
case 'game_state':
// State updates are instant, animations are fire-and-forget
// Exception: Local player's swap animation defers state until complete
// If local swap animation is running, defer this state update
if (this.swapAnimationInProgress) {
debugLog('STATE', 'Deferring state - swap animation in progress');
this.updateSwapAnimation(data.game_state.discard_top);
this.pendingGameState = data.game_state;
break;
}
const oldState = this.gameState;
const newState = data.game_state;
debugLog('STATE', 'Received game_state', {
phase: newState.phase,
currentPlayer: newState.current_player_id?.slice(-4),
discardTop: newState.discard_top ? `${newState.discard_top.rank}${newState.discard_top.suit?.[0]}` : 'EMPTY',
drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}${newState.drawn_card.suit?.[0]}` : null,
drawnBy: newState.drawn_player_id?.slice(-4) || null,
hasDrawn: newState.has_drawn_card
});
// V3_03: Intercept round_over transition to defer card reveals
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded && oldState) {
// Save pre-reveal state for the reveal animation
this.preRevealState = JSON.parse(JSON.stringify(oldState));
this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
this.gameState = newState;
break;
}
// Update state FIRST (always)
this.gameState = newState;
// Clear local flip tracking if server confirmed our flips
if (!newState.waiting_for_initial_flip && oldState?.waiting_for_initial_flip) {
this.locallyFlippedCards = new Set();
// Stop all initial flip pulse animations
if (window.cardAnimations) {
window.cardAnimations.stopAllInitialFlipPulses();
}
}
// Detect and fire animations (non-blocking, errors shouldn't break game)
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error:', e);
}
// Render immediately with new state
console.log('[DEBUG] About to renderGame, flags:', {
isDrawAnimating: this.isDrawAnimating,
localDiscardAnimating: this.localDiscardAnimating,
opponentDiscardAnimating: this.opponentDiscardAnimating,
opponentSwapAnimation: !!this.opponentSwapAnimation,
discardTop: newState.discard_top ? `${newState.discard_top.rank}-${newState.discard_top.suit}` : 'none'
});
this.renderGame();
break;
case 'your_turn':
// Clear any stale opponent animation flags since it's now our turn
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
console.log('[DEBUG] your_turn received - clearing opponent animation flags');
// Immediately update display to show correct discard pile
this.renderGame();
// Brief delay to let animations settle before showing toast
setTimeout(() => {
// Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action;
let canKnock = false;
if (this.gameState && this.gameState.knock_early) {
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
canKnock = faceDownCount >= 1 && faceDownCount <= 2;
}
if (canFlip && canKnock) {
this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
} else if (canFlip) {
this.showToast('Your turn! Draw or flip a card', 'your-turn');
} else if (canKnock) {
this.showToast('Your turn! Draw or knock', 'your-turn');
} else {
this.showToast('Your turn! Draw a card', 'your-turn');
}
}, 200);
break;
case 'card_drawn':
this.drawnCard = data.card;
this.drawnFromDiscard = data.source === 'discard';
if (data.source === 'deck' && window.drawAnimations) {
// Deck draw: use shared animation system (flip at deck, move to hold)
// Hide held card during animation - animation callback will show it
// Clear any stale opponent animation flags since it's now our turn
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
this.isDrawAnimating = true;
this.hideDrawnCard();
window.drawAnimations.animateDrawDeck(data.card, () => {
this.isDrawAnimating = false;
this.displayHeldCard(data.card, true);
this.renderGame();
});
} else if (data.source === 'discard' && window.drawAnimations) {
// Discard draw: use shared animation system (lift and move)
this.isDrawAnimating = true;
this.hideDrawnCard();
// Clear any in-progress swap animation to prevent race conditions
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
window.drawAnimations.animateDrawDiscard(data.card, () => {
this.isDrawAnimating = false;
this.displayHeldCard(data.card, true);
this.renderGame();
});
} else {
// Fallback: just show the card
this.displayHeldCard(data.card, true);
this.renderGame();
}
this.showToast('Swap with a card or discard', '', 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':
// V3_03: Run dramatic reveal before showing scoreboard
this.runRoundEndReveal(data.scores, data.rankings);
break;
case 'game_over':
this.showScoreboard(data.final_scores, true, data.rankings);
break;
case 'game_ended':
// Host ended the game or player was kicked
this._intentionalClose = true;
if (this.ws) this.ws.close();
this.showScreen('lobby');
if (data.reason) {
this.showError(data.reason);
}
break;
case 'queue_joined':
this.showScreen('matchmaking');
this.startMatchmakingTimer();
this.updateMatchmakingStatus(data);
break;
case 'queue_status':
this.updateMatchmakingStatus(data);
break;
case 'queue_matched':
this.stopMatchmakingTimer();
if (this.matchmakingStatus) {
this.matchmakingStatus.textContent = 'Match found!';
}
break;
case 'queue_left':
this.stopMatchmakingTimer();
this.showScreen('lobby');
break;
case 'error':
this.showError(data.message);
break;
}
}
// Matchmaking
findGame() {
this.connect();
this.ws.onopen = () => {
this.send({ type: 'queue_join' });
};
}
cancelMatchmaking() {
this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer();
this.showScreen('lobby');
}
startMatchmakingTimer() {
this.matchmakingStartTime = Date.now();
this.stopMatchmakingTimer();
this.matchmakingTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.matchmakingStartTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
if (this.matchmakingTime) {
this.matchmakingTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
}
}, 1000);
}
stopMatchmakingTimer() {
if (this.matchmakingTimer) {
clearInterval(this.matchmakingTimer);
this.matchmakingTimer = null;
}
}
updateMatchmakingStatus(data) {
if (this.matchmakingQueueInfo) {
const parts = [];
if (data.queue_size) parts.push(`${data.queue_size} player${data.queue_size !== 1 ? 's' : ''} in queue`);
if (data.position) parts.push(`Position: #${data.position}`);
this.matchmakingQueueInfo.textContent = parts.join(' \u2022 ');
}
}
// Room Actions
createRoom() {
const name = this.authManager?.user?.username || 'Player';
this.connect();
this.ws.onopen = () => {
this.send({ type: 'create_room', player_name: name });
};
}
joinRoom() {
const name = this.authManager?.user?.username || 'Player';
const code = this.roomCodeInput.value.trim().toUpperCase();
if (code.length !== 4) {
this.showError('Please enter a 4-letter room code');
return;
}
this.connect();
this.ws.onopen = () => {
this.send({ type: 'join_room', room_code: code, player_name: name });
};
}
leaveRoom() {
this.send({ type: 'leave_room' });
this.ws.close();
this.showLobby();
}
copyRoomCode() {
if (!this.roomCode) return;
this.copyToClipboard(this.roomCode, this.copyRoomCodeBtn);
}
shareRoomLink() {
if (!this.roomCode) return;
// Build shareable URL with room code
const url = new URL(window.location.href);
url.search = ''; // Clear existing params
url.hash = ''; // Clear hash
url.searchParams.set('room', this.roomCode);
const shareUrl = url.toString();
this.copyToClipboard(shareUrl, this.shareRoomLinkBtn);
}
copyToClipboard(text, feedbackBtn) {
// Use execCommand which is more reliable across contexts
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
console.error('Copy failed:', err);
}
document.body.removeChild(textarea);
// Show visual feedback
if (success && feedbackBtn) {
const originalText = feedbackBtn.textContent;
feedbackBtn.textContent = '✓';
setTimeout(() => {
feedbackBtn.textContent = originalText;
}, 1500);
}
}
startGame() {
try {
const decks = parseInt(this.numDecksInput?.value || '1');
const rounds = parseInt(this.numRoundsSelect?.value || '9');
const initial_flips = parseInt(this.initialFlipsSelect?.value || '2');
// Standard options
const flip_mode = this.flipModeSelect?.value || 'always'; // "never", "always", or "endgame"
const knock_penalty = this.knockPenaltyCheckbox?.checked || false;
// Joker mode (radio buttons)
const jokerRadio = document.querySelector('input[name="joker-mode"]:checked');
const joker_mode = jokerRadio ? jokerRadio.value : 'none';
const use_jokers = joker_mode !== 'none';
const lucky_swing = joker_mode === 'lucky-swing';
const eagle_eye = joker_mode === 'eagle-eye';
// House Rules - Point Modifiers
const super_kings = this.superKingsCheckbox?.checked || false;
const ten_penny = this.tenPennyCheckbox?.checked || false;
// House Rules - Bonuses/Penalties
const knock_bonus = this.knockBonusCheckbox?.checked || false;
const underdog_bonus = this.underdogBonusCheckbox?.checked || false;
const tied_shame = this.tiedShameCheckbox?.checked || false;
const blackjack = this.blackjackCheckbox?.checked || false;
const wolfpack = this.wolfpackCheckbox?.checked || false;
// House Rules - New Variants
const flip_as_action = this.flipAsActionCheckbox?.checked || false;
const four_of_a_kind = this.fourOfAKindCheckbox?.checked || false;
const negative_pairs_keep_value = this.negativePairsCheckbox?.checked || false;
const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false;
const knock_early = this.knockEarlyCheckbox?.checked || false;
// Deck colors
const deck_colors = this.getDeckColors(decks);
this.send({
type: 'start_game',
decks,
rounds,
initial_flips,
flip_mode,
knock_penalty,
use_jokers,
lucky_swing,
super_kings,
ten_penny,
knock_bonus,
underdog_bonus,
tied_shame,
blackjack,
eagle_eye,
wolfpack,
flip_as_action,
four_of_a_kind,
negative_pairs_keep_value,
one_eyed_jacks,
knock_early,
deck_colors
});
} catch (error) {
console.error('Error starting game:', error);
this.showError('Error starting game. Please refresh.');
}
}
showCpuSelect() {
// Request available profiles from server
this.selectedCpus = new Set();
this.send({ type: 'get_cpu_profiles' });
this.cpuSelectModal.classList.remove('hidden');
}
hideCpuSelect() {
this.cpuSelectModal.classList.add('hidden');
this.selectedCpus = new Set();
}
renderCpuSelect() {
if (!this.allProfiles) return;
// Get names of CPUs already in the game
const usedNames = new Set(
(this.currentPlayers || [])
.filter(p => p.is_cpu)
.map(p => p.name)
);
this.cpuProfilesGrid.innerHTML = '';
this.allProfiles.forEach(profile => {
const div = document.createElement('div');
const isUsed = usedNames.has(profile.name);
const isSelected = this.selectedCpus && this.selectedCpus.has(profile.name);
div.className = 'profile-card' + (isUsed ? ' unavailable' : '') + (isSelected ? ' selected' : '');
const avatar = this.getCpuAvatar(profile.name);
const checkbox = isUsed ? '' : `<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;
// Sound played by draw animation
this.send({ type: 'draw', source: 'deck' });
}
drawFromDiscard() {
// If holding a card drawn from discard, clicking discard puts it back
if (this.drawnCard && !this.gameState.can_discard) {
this.playSound('click');
this.cancelDraw();
return;
}
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
this.playSound('reject');
}
return;
}
if (this.gameState.waiting_for_initial_flip) return;
if (!this.gameState.discard_top) return;
// Sound played by draw animation
this.send({ type: 'draw', source: 'discard' });
}
discardDrawn() {
if (!this.drawnCard) return;
const discardedCard = this.drawnCard;
this.send({ type: 'discard' });
this.drawnCard = null;
this.hideToast();
this.discardBtn.classList.add('hidden');
// Capture the actual position of the held card before hiding it
const heldRect = this.heldCardFloating.getBoundingClientRect();
// Hide the floating held card immediately (animation will create its own)
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.style.cssText = '';
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true;
// Animate held card to discard using anime.js
if (window.cardAnimations) {
window.cardAnimations.animateHeldToDiscard(discardedCard, heldRect, () => {
this.updateDiscardPileDisplay(discardedCard);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
this.localDiscardAnimating = false;
});
} else {
// Fallback: just update immediately
this.updateDiscardPileDisplay(discardedCard);
this.localDiscardAnimating = false;
}
}
// Update the discard pile display with a card
// Note: Don't use renderCardContent here - the card may have face_up=false
// (drawn cards aren't marked face_up until server processes discard)
updateDiscardPileDisplay(card) {
this.discard.classList.remove('picked-up', 'disabled');
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('red', 'black', 'joker');
if (card.rank === '★') {
this.discard.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
// Render directly - discard pile cards are always visible
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
this.lastDiscardKey = `${card.rank}-${card.suit}`;
}
cancelDraw() {
if (!this.drawnCard) return;
const cardToReturn = this.drawnCard;
const wasFromDiscard = this.drawnFromDiscard;
this.send({ type: 'cancel_draw' });
this.drawnCard = null;
this.hideToast();
if (wasFromDiscard) {
// Animate card from deck position back to discard pile
this.animateDeckToDiscardReturn(cardToReturn);
} else {
this.hideDrawnCard();
}
}
// Animate returning a card from deck position to discard pile (for cancel draw from discard)
animateDeckToDiscardReturn(card) {
const discardRect = this.discard.getBoundingClientRect();
const floater = this.heldCardFloating;
// Add swooping class for smooth transition
floater.classList.add('swooping');
floater.style.left = `${discardRect.left}px`;
floater.style.top = `${discardRect.top}px`;
floater.style.width = `${discardRect.width}px`;
floater.style.height = `${discardRect.height}px`;
this.playSound('card');
// After swoop completes, hide floater and update discard pile
setTimeout(() => {
floater.classList.add('landed');
setTimeout(() => {
floater.classList.add('hidden');
floater.classList.remove('swooping', 'landed');
floater.style.cssText = '';
this.updateDiscardPileDisplay(card);
this.pulseDiscardLand();
}, 150);
}, 350);
}
swapCard(position) {
if (!this.drawnCard) return;
this.send({ type: 'swap', position });
this.drawnCard = null;
this.hideDrawnCard();
}
// Animate player swapping drawn card with a card in their hand
// Uses flip-in-place + teleport (no zipping movement)
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
if (!handCardEl) {
this.swapCard(position);
return;
}
// Check if card is already face-up
const myData = this.getMyPlayerData();
const card = myData?.cards[position];
const isAlreadyFaceUp = card?.face_up;
const handRect = handCardEl.getBoundingClientRect();
const heldRect = this.heldCardFloating?.getBoundingClientRect();
// Mark animating
this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
// Hide originals during animation
handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
// Store drawn card data before clearing
const drawnCardData = this.drawnCard;
this.drawnCard = null;
this.skipNextDiscardFlip = true;
// Send swap to server
this.send({ type: 'swap', position });
if (isAlreadyFaceUp && card) {
// Face-up card - we know both cards, animate immediately
this.swapAnimationContentSet = true;
if (window.cardAnimations) {
window.cardAnimations.animateUnifiedSwap(
card, // handCardData - card going to discard
drawnCardData, // heldCardData - drawn card going to hand
handRect, // handRect
heldRect, // heldRect
{
rotation: 0,
wasHandFaceDown: false,
onComplete: () => {
handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = '';
}
this.completeSwapAnimation(null);
}
}
);
} else {
setTimeout(() => {
handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = '';
}
this.completeSwapAnimation(null);
}, 500);
}
} else {
// Face-down card - wait for server to tell us what the card was
// Store context for updateSwapAnimation to use
this.swapAnimationContentSet = false;
this.pendingSwapData = {
handCardEl,
handRect,
heldRect,
drawnCardData,
position
};
}
}
// Update the animated card with actual card content when server responds
updateSwapAnimation(card) {
// Skip if we already set the content (face-up card swap)
if (this.swapAnimationContentSet) return;
// Safety check
if (!this.swapAnimationInProgress || !card) {
return;
}
// Now we have the card data - run the unified animation
this.swapAnimationContentSet = true;
const data = this.pendingSwapData;
if (!data) {
console.error('Swap animation missing pending data');
this.completeSwapAnimation(null);
return;
}
const { handCardEl, handRect, heldRect, drawnCardData } = data;
if (window.cardAnimations) {
window.cardAnimations.animateUnifiedSwap(
card, // handCardData - now we know what it was
drawnCardData, // heldCardData - drawn card going to hand
handRect, // handRect
heldRect, // heldRect
{
rotation: 0,
wasHandFaceDown: true,
onComplete: () => {
if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = '';
}
this.pendingSwapData = null;
this.completeSwapAnimation(null);
}
}
);
} else {
// Fallback
setTimeout(() => {
if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = '';
}
this.pendingSwapData = null;
this.completeSwapAnimation(null);
}, 500);
}
}
completeSwapAnimation(heldCard) {
// Guard against double completion
if (!this.swapAnimationInProgress) return;
// Hide everything
this.swapAnimation.classList.add('hidden');
if (this.swapAnimationCard) {
this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse');
}
if (heldCard) {
heldCard.classList.remove('flipping', 'moving');
heldCard.classList.add('hidden');
}
if (this.swapAnimationHandCardEl) {
this.swapAnimationHandCardEl.classList.remove('swap-out');
}
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.swapAnimationFront = null;
this.swapAnimationCard = null;
this.swapAnimationDiscardRect = null;
this.swapAnimationHandCardEl = null;
this.swapAnimationHandRect = null;
this.swapAnimationContentSet = false;
this.pendingSwapData = null;
this.discardBtn.classList.add('hidden');
this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) {
const oldState = this.gameState;
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.checkForNewPairs(oldState, this.gameState);
this.renderGame();
}
}
flipCard(position) {
this.send({ type: 'flip_card', position });
this.waitingForFlip = false;
this.flipIsOptional = false;
}
skipFlip() {
if (!this.flipIsOptional) return;
this.send({ type: 'skip_flip' });
this.waitingForFlip = false;
this.flipIsOptional = false;
this.hideToast();
}
knockEarly() {
// V3_09: Knock early with confirmation dialog
if (!this.gameState || !this.gameState.knock_early) return;
const myData = this.getMyPlayerData();
if (!myData) return;
const hiddenCards = myData.cards.filter(c => !c.face_up);
if (hiddenCards.length === 0 || hiddenCards.length > 2) return;
this.showKnockConfirmation(hiddenCards.length, () => {
this.executeKnockEarly();
});
}
showKnockConfirmation(hiddenCount, onConfirm) {
const modal = document.createElement('div');
modal.className = 'knock-confirm-modal';
modal.innerHTML = `
<div class="knock-confirm-content">
<div class="knock-confirm-icon">⚡</div>
<h3>Knock Early?</h3>
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
<p class="knock-warning">This cannot be undone!</p>
<div class="knock-confirm-buttons">
<button class="btn btn-secondary knock-cancel">Cancel</button>
<button class="btn btn-primary knock-confirm">Knock!</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('.knock-cancel').addEventListener('click', () => {
this.playSound('click');
modal.remove();
});
modal.querySelector('.knock-confirm').addEventListener('click', () => {
this.playSound('click');
modal.remove();
onConfirm();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
}
async executeKnockEarly() {
this.playSound('knock');
const myData = this.getMyPlayerData();
if (!myData) return;
const hiddenPositions = myData.cards
.map((card, i) => ({ card, position: i }))
.filter(({ card }) => !card.face_up)
.map(({ position }) => position);
// Rapid sequential flips
for (const position of hiddenPositions) {
this.fireLocalFlipAnimation(position, myData.cards[position]);
this.playSound('flip');
await this.delay(150);
}
await this.delay(300);
this.showKnockBanner();
this.send({ type: 'knock_early' });
this.hideToast();
}
showKnockBanner(playerName) {
const duration = window.TIMING?.knock?.statusDuration || 2500;
const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!';
this.setStatus(message, 'knock');
document.body.classList.add('screen-shake');
setTimeout(() => {
document.body.classList.remove('screen-shake');
}, 300);
// Restore normal status after duration
this._knockStatusTimeout = setTimeout(() => {
// Only clear if still showing knock status
if (this.statusMessage.classList.contains('knock')) {
this.setStatus('Final turn!', 'opponent-turn');
}
}, duration);
}
// --- V3_17: Scoresheet Modal ---
showScoresheetModal(scores, gameState, rankings) {
// Remove existing modal if any
const existing = document.getElementById('scoresheet-modal');
if (existing) existing.remove();
// Also update side panel data (silently)
this.showScoreboard(scores, false, rankings);
const cardValues = gameState?.card_values || this.getDefaultCardValues();
const scoringRules = gameState?.scoring_rules || {};
const knockerId = gameState?.finisher_id;
const currentRound = gameState?.current_round || '?';
const totalRounds = gameState?.total_rounds || '?';
// Find round winner(s)
const roundScores = scores.map(s => s.score);
const minRoundScore = Math.min(...roundScores);
// Build player rows - knocker first, then others
const ordered = [...gameState.players].sort((a, b) => {
if (a.id === knockerId) return -1;
if (b.id === knockerId) return 1;
return 0;
});
const playerRowsHtml = ordered.map(player => {
const scoreData = scores.find(s => s.name === player.name) || {};
const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
const isKnocker = player.id === knockerId;
const isLowScore = scoreData.score === minRoundScore;
const colIndices = [[0, 3], [1, 4], [2, 5]];
// Badge
let badge = '';
if (isKnocker && isLowScore) badge = '<span class="ss-badge ss-badge-knock">KNOCKED</span><span class="ss-badge ss-badge-low">LOW SCORE</span>';
else if (isKnocker) badge = '<span class="ss-badge ss-badge-knock">KNOCKED</span>';
else if (isLowScore) badge = '<span class="ss-badge ss-badge-low">LOW SCORE</span>';
// Build columns
const columnsHtml = colIndices.map((indices, c) => {
const col = result.columns[c];
const topCard = player.cards[indices[0]];
const bottomCard = player.cards[indices[1]];
const isPair = col.isPair;
const topMini = this.renderMiniCard(topCard, isPair);
const bottomMini = this.renderMiniCard(bottomCard, isPair);
let colScore;
if (isPair) {
const pairLabel = col.pairValue !== 0 ? `PAIR ${col.pairValue}` : 'PAIR 0';
colScore = `<div class="ss-col-score ss-pair">${pairLabel}</div>`;
} else {
const val = col.topValue + col.bottomValue;
const cls = val < 0 ? 'ss-negative' : '';
colScore = `<div class="ss-col-score ${cls}">${val >= 0 ? '+' + val : val}</div>`;
}
return `<div class="ss-column${isPair ? ' ss-column-paired' : ''}">
${topMini}${bottomMini}
${colScore}
</div>`;
}).join('');
// Bonuses
let bonusHtml = '';
if (result.bonuses.length > 0) {
bonusHtml = result.bonuses.map(b => {
const label = b.type === 'wolfpack' ? 'WOLFPACK' : 'FOUR OF A KIND';
return `<span class="ss-bonus">${label} ${b.value}</span>`;
}).join(' ');
}
const roundScore = scoreData.score !== undefined ? scoreData.score : '-';
const totalScore = scoreData.total !== undefined ? scoreData.total : '-';
return `<div class="ss-player-row">
<div class="ss-player-header">
<span class="ss-player-name">${player.name}</span>
${badge}
</div>
<div class="ss-columns">${columnsHtml}</div>
${bonusHtml ? `<div class="ss-bonuses">${bonusHtml}</div>` : ''}
<div class="ss-scores">
<span>Hole: <strong>${roundScore}</strong></span>
<span>Total: <strong>${totalScore}</strong></span>
</div>
</div>`;
}).join('');
// Create modal
const modal = document.createElement('div');
modal.id = 'scoresheet-modal';
modal.className = 'scoresheet-modal';
modal.innerHTML = `
<div class="scoresheet-content">
<div class="ss-header">Hole ${currentRound} of ${totalRounds}</div>
<div class="ss-players">${playerRowsHtml}</div>
<button class="btn btn-primary ss-next-btn" id="ss-next-btn">Next Hole</button>
</div>
`;
document.body.appendChild(modal);
this.setStatus('Hole complete');
// Hide bottom bar so it doesn't overlay the modal
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.add('hidden');
// Bind next button
const nextBtn = document.getElementById('ss-next-btn');
nextBtn.addEventListener('click', () => {
this.playSound('click');
this.dismissScoresheetModal();
this.nextRound();
});
// Start countdown
this.startScoresheetCountdown(nextBtn);
// Animate entrance
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.animateScoresheetEntrance(modal);
}
}
renderMiniCard(card, isPaired) {
if (!card || !card.rank) return '<div class="ss-mini-card ss-mini-back"></div>';
const suit = card.suit;
const isRed = suit === 'hearts' || suit === 'diamonds';
const symbol = this.getSuitSymbol(suit);
const rank = card.rank === '★' ? '★' : card.rank;
const classes = [
'ss-mini-card',
isRed ? 'ss-red' : 'ss-black',
isPaired ? 'ss-mini-paired' : ''
].filter(Boolean).join(' ');
return `<div class="${classes}">${rank}${symbol}</div>`;
}
animateScoresheetEntrance(modal) {
const T = window.TIMING?.scoresheet || {};
const playerRows = modal.querySelectorAll('.ss-player-row');
const nextBtn = modal.querySelector('.ss-next-btn');
// Start everything hidden
playerRows.forEach(row => {
row.style.opacity = '0';
row.style.transform = 'translateY(10px)';
});
if (nextBtn) {
nextBtn.style.opacity = '0';
}
// Stagger player rows in
if (window.anime) {
anime({
targets: Array.from(playerRows),
opacity: [0, 1],
translateY: [10, 0],
delay: anime.stagger(T.playerStagger || 150),
duration: 300,
easing: 'easeOutCubic',
complete: () => {
// Animate paired columns glow
setTimeout(() => {
modal.querySelectorAll('.ss-column-paired').forEach(col => {
col.classList.add('ss-pair-glow');
});
}, T.pairGlowDelay || 200);
}
});
// Fade in button after rows
const totalRowDelay = (playerRows.length - 1) * (T.playerStagger || 150) + 300;
anime({
targets: nextBtn,
opacity: [0, 1],
delay: totalRowDelay,
duration: 200,
easing: 'easeOutCubic',
});
} else {
// No anime.js - show immediately
playerRows.forEach(row => {
row.style.opacity = '1';
row.style.transform = '';
});
if (nextBtn) nextBtn.style.opacity = '1';
}
}
startScoresheetCountdown(btn) {
this.clearScoresheetCountdown();
const COUNTDOWN_SECONDS = 15;
let remaining = COUNTDOWN_SECONDS;
const update = () => {
if (this.isHost) {
btn.textContent = `Next Hole (${remaining}s)`;
btn.disabled = false;
} else {
btn.textContent = `Next hole in ${remaining}s...`;
btn.disabled = true;
}
};
update();
this.scoresheetCountdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
this.clearScoresheetCountdown();
if (this.isHost) {
this.dismissScoresheetModal();
this.nextRound();
} else {
btn.textContent = 'Waiting for host...';
}
} else {
update();
}
}, 1000);
}
clearScoresheetCountdown() {
if (this.scoresheetCountdownInterval) {
clearInterval(this.scoresheetCountdownInterval);
this.scoresheetCountdownInterval = null;
}
}
dismissScoresheetModal() {
this.clearScoresheetCountdown();
const modal = document.getElementById('scoresheet-modal');
if (modal) modal.remove();
// Restore bottom bar
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.remove('hidden');
}
// --- V3_02: Dealing Animation ---
runDealAnimation() {
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.playSound('shuffle');
this.renderGame();
return;
}
// Render first so card slot positions exist
this.renderGame();
// Hide cards during animation
this.playerCards.style.visibility = 'hidden';
this.opponentsRow.style.visibility = 'hidden';
// Suppress flip prompts until dealing complete
this.dealAnimationInProgress = true;
if (window.cardAnimations) {
// Use double-rAF to ensure layout is fully computed after renderGame().
// First rAF: browser computes styles. Second rAF: layout is painted.
// This is critical on mobile where CSS !important rules need to apply
// before getBoundingClientRect() returns correct card slot positions.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Verify rects are valid before starting animation
const testRect = this.getCardSlotRect(this.playerId, 0);
if (this.isMobile) {
console.log('[DEAL] Starting deal animation, test rect:', testRect);
}
window.cardAnimations.animateDealing(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Deal complete - allow flip prompts
this.dealAnimationInProgress = false;
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
this.renderGame();
// Stagger opponent initial flips right after dealing
this.animateOpponentInitialFlips();
}
);
});
});
} else {
// Fallback
this.dealAnimationInProgress = false;
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
this.playSound('shuffle');
}
}
animateOpponentInitialFlips() {
const T = window.TIMING?.initialFlips || {};
const windowStart = T.windowStart || 500;
const windowEnd = T.windowEnd || 2500;
const cardStagger = T.cardStagger || 400;
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
// Collect face-up cards per opponent and convert them to show backs
for (const player of opponents) {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${player.id}"]`
);
if (!area) continue;
const cardEls = area.querySelectorAll('.card-grid .card');
const faceUpCards = [];
player.cards.forEach((card, idx) => {
if (card.face_up && cardEls[idx]) {
const el = cardEls[idx];
faceUpCards.push({ el, card, idx });
// Convert to card-back appearance while waiting
el.className = 'card card-back';
if (this.gameState?.deck_colors) {
const deckId = card.deck_id || 0;
const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
if (color) el.classList.add(`back-${color}`);
}
el.innerHTML = '';
}
});
if (faceUpCards.length > 0) {
const rotation = this.getElementRotation(area);
// Each opponent starts at a random time within the window (concurrent, not sequential)
const startDelay = windowStart + Math.random() * (windowEnd - windowStart);
setTimeout(() => {
faceUpCards.forEach(({ el, card }, i) => {
setTimeout(() => {
window.cardAnimations.animateOpponentFlip(el, card, rotation);
}, i * cardStagger);
});
}, startDelay);
}
}
}
getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) {
const cards = this.playerCards.querySelectorAll('.card');
const rect = cards[cardIdx]?.getBoundingClientRect() || null;
if (this.isMobile && rect) {
console.log(`[DEAL-DEBUG] Player card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height});
}
return rect;
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
const cards = area.querySelectorAll('.card');
const rect = cards[cardIdx]?.getBoundingClientRect() || null;
if (this.isMobile && rect) {
console.log(`[DEAL-DEBUG] Opponent ${playerId} card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height});
}
return rect;
}
}
return null;
}
// --- V3_03: Round End Dramatic Reveal ---
async runRoundEndReveal(scores, rankings) {
const T = window.TIMING?.reveal || {};
const oldState = this.preRevealState;
const newState = this.postRevealState || this.gameState;
if (!oldState || !newState) {
// Fallback: show scoresheet immediately
this.showScoresheetModal(scores, this.gameState, rankings);
return;
}
// First, render the game with the OLD state (pre-reveal) so cards show face-down
this.gameState = newState;
// But render with pre-reveal card visuals
this.revealAnimationInProgress = true;
// Render game to show current layout (opponents, etc)
this.renderGame();
// Compute what needs revealing
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial pause
this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500);
// Reveal each player's cards
for (const player of revealOrder) {
const cardsToFlip = revealsByPlayer.get(player.id) || [];
if (cardsToFlip.length === 0) continue;
// Highlight player area
this.highlightPlayerArea(player.id, true);
await this.delay(T.highlightDuration || 200);
// Flip each card with stagger
for (const { position, card } of cardsToFlip) {
this.animateRevealFlip(player.id, position, card);
await this.delay(T.cardStagger || 100);
}
// Wait for last flip to complete + pause
await this.delay(300 + (T.playerPause || 400));
// Remove highlight
this.highlightPlayerArea(player.id, false);
}
// All revealed - show scoresheet modal
this.revealAnimationInProgress = false;
this.preRevealState = null;
this.postRevealState = null;
this.renderGame();
// V3_17: Scoresheet modal replaces tally + side panel scoreboard
this.showScoresheetModal(scores, newState, rankings);
}
getCardsToReveal(oldState, newState) {
const reveals = new Map();
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (!oldPlayer) continue;
const cardsToFlip = [];
for (let i = 0; i < 6; i++) {
const wasHidden = !oldPlayer.cards[i]?.face_up;
const nowVisible = newPlayer.cards[i]?.face_up;
if (wasHidden && nowVisible) {
cardsToFlip.push({
position: i,
card: newPlayer.cards[i]
});
}
}
if (cardsToFlip.length > 0) {
reveals.set(newPlayer.id, cardsToFlip);
}
}
return reveals;
}
getRevealOrder(players, knockerId) {
const knocker = players.find(p => p.id === knockerId);
const others = players.filter(p => p.id !== knockerId);
if (knocker) {
return [knocker, ...others];
}
return others;
}
highlightPlayerArea(playerId, highlight) {
if (playerId === this.playerId) {
this.playerArea.classList.toggle('revealing', highlight);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
area.classList.toggle('revealing', highlight);
}
}
}
animateRevealFlip(playerId, position, cardData) {
if (playerId === this.playerId) {
// Local player card
const cards = this.playerCards.querySelectorAll('.card');
const cardEl = cards[position];
if (cardEl && window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
// Re-render this card to show revealed state
this.renderGame();
});
}
} else {
// Opponent card
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
const cards = area.querySelectorAll('.card');
const cardEl = cards[position];
if (cardEl && window.cardAnimations) {
const rotation = this.getElementRotation(area);
window.cardAnimations.animateOpponentFlip(cardEl, cardData, rotation);
}
}
}
this.playSound('flip');
}
// --- V3_07: Animated Score Tallying ---
async runScoreTally(players, knockerId) {
const T = window.TIMING?.tally || {};
await this.delay(T.initialPause || 300);
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
const scoringRules = this.gameState?.scoring_rules || {};
// Order: knocker first, then others
const ordered = [...players].sort((a, b) => {
if (a.id === knockerId) return -1;
if (b.id === knockerId) return 1;
return 0;
});
for (const player of ordered) {
const cardEls = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
if (cardEls.length < 6) continue;
// Use shared scoring logic (all cards revealed at round end)
const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
// Highlight player area
this.highlightPlayerArea(player.id, true);
const colIndices = [[0, 3], [1, 4], [2, 5]];
for (let c = 0; c < 3; c++) {
const [topIdx, bottomIdx] = colIndices[c];
const col = result.columns[c];
const topCard = cardEls[topIdx];
const bottomCard = cardEls[bottomIdx];
if (col.isPair) {
topCard?.classList.add('tallying');
bottomCard?.classList.add('tallying');
this.showPairCancel(topCard, bottomCard, col.pairValue);
await this.delay(T.pairCelebration || 400);
} else {
// Show individual card values
topCard?.classList.add('tallying');
const topOverlay = this.showCardValue(topCard, col.topValue, col.topValue < 0);
await this.delay(T.cardHighlight || 200);
bottomCard?.classList.add('tallying');
const bottomOverlay = this.showCardValue(bottomCard, col.bottomValue, col.bottomValue < 0);
await this.delay(T.cardHighlight || 200);
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
}
topCard?.classList.remove('tallying');
bottomCard?.classList.remove('tallying');
await this.delay(T.columnPause || 150);
}
// Show bonuses (wolfpack, four-of-a-kind)
if (result.bonuses.length > 0) {
for (const bonus of result.bonuses) {
const label = bonus.type === 'wolfpack' ? 'WOLFPACK!' : 'FOUR OF A KIND!';
this.showBonusOverlay(player.id, label, bonus.value);
await this.delay(T.pairCelebration || 400);
}
}
this.highlightPlayerArea(player.id, false);
await this.delay(T.playerPause || 500);
}
}
showCardValue(cardElement, value, isNegative) {
if (!cardElement) return null;
const overlay = document.createElement('div');
overlay.className = 'card-value-overlay';
if (isNegative) overlay.classList.add('negative');
if (value === 0) overlay.classList.add('zero');
const sign = value > 0 ? '+' : '';
overlay.textContent = `${sign}${value}`;
const rect = cardElement.getBoundingClientRect();
overlay.style.left = `${rect.left + rect.width / 2}px`;
overlay.style.top = `${rect.top + rect.height / 2}px`;
document.body.appendChild(overlay);
// Trigger reflow then animate in
void overlay.offsetWidth;
overlay.classList.add('visible');
return overlay;
}
hideCardValue(overlay) {
if (!overlay) return;
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 200);
}
showPairCancel(card1, card2, pairValue = 0) {
if (!card1 || !card2) return;
const rect1 = card1.getBoundingClientRect();
const rect2 = card2.getBoundingClientRect();
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
const sign = pairValue > 0 ? '+' : '';
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay';
if (pairValue < 0) overlay.classList.add('negative');
overlay.textContent = `PAIR! ${sign}${pairValue}`;
overlay.style.left = `${centerX}px`;
overlay.style.top = `${centerY}px`;
document.body.appendChild(overlay);
card1.classList.add('pair-matched');
card2.classList.add('pair-matched');
this.playSound('pair');
setTimeout(() => {
overlay.remove();
card1.classList.remove('pair-matched');
card2.classList.remove('pair-matched');
}, 600);
}
showBonusOverlay(playerId, label, value) {
const area = playerId === this.playerId
? this.playerArea
: this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`);
if (!area) return;
const rect = area.getBoundingClientRect();
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay negative';
overlay.textContent = `${label} ${value}`;
overlay.style.left = `${rect.left + rect.width / 2}px`;
overlay.style.top = `${rect.top + rect.height / 2}px`;
document.body.appendChild(overlay);
this.playSound('pair');
setTimeout(() => overlay.remove(), 600);
}
getDefaultCardValues() {
return {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
};
}
// Fire-and-forget animation triggers based on state changes
triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return;
const currentPlayerId = newState.current_player_id;
const previousPlayerId = oldState.current_player_id;
const wasOtherPlayer = previousPlayerId && previousPlayerId !== this.playerId;
// Check for discard pile changes
const newDiscard = newState.discard_top;
const oldDiscard = oldState.discard_top;
const discardChanged = newDiscard && (!oldDiscard ||
newDiscard.rank !== oldDiscard.rank ||
newDiscard.suit !== oldDiscard.suit);
debugLog('DIFFER', 'State diff', {
discardChanged,
oldDiscard: oldDiscard ? `${oldDiscard.rank}${oldDiscard.suit?.[0]}` : 'EMPTY',
newDiscard: newDiscard ? `${newDiscard.rank}${newDiscard.suit?.[0]}` : 'EMPTY',
turnChanged: previousPlayerId !== currentPlayerId,
wasOtherPlayer
});
// STEP 1: Detect when someone DRAWS (drawn_card goes from null to something)
const justDrew = !oldState.drawn_card && newState.drawn_card;
const drawingPlayerId = newState.drawn_player_id;
const isOtherPlayerDrawing = drawingPlayerId && drawingPlayerId !== this.playerId;
if (justDrew && isOtherPlayerDrawing) {
// Detect source: if old discard is gone, they took from discard
const discardWasTaken = oldDiscard && (!newDiscard ||
newDiscard.rank !== oldDiscard.rank ||
newDiscard.suit !== oldDiscard.suit);
debugLog('DIFFER', 'Other player drew', {
source: discardWasTaken ? 'discard' : 'deck',
drawnCard: newState.drawn_card ? `${newState.drawn_card.rank}` : '?'
});
// Use shared draw animation system for consistent look
if (window.drawAnimations) {
// Set flag to defer held card display until animation completes
this.drawPulseAnimation = true;
const drawnCard = newState.drawn_card;
const onAnimComplete = () => {
this.drawPulseAnimation = false;
// Show the held card after animation (no popIn - match local player)
if (this.gameState?.drawn_card) {
this.displayHeldCard(this.gameState.drawn_card, false);
}
};
if (discardWasTaken) {
// Clear any in-progress animations to prevent race conditions
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile
this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDiscard(drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false;
onAnimComplete();
});
} else {
// Clear any in-progress animations to prevent race conditions
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false;
onAnimComplete();
});
}
}
// Show CPU action announcement
const drawingPlayer = newState.players.find(p => p.id === drawingPlayerId);
if (drawingPlayer?.is_cpu) {
if (discardWasTaken && oldDiscard) {
this.showCpuAction(drawingPlayer.name, 'draw-discard', oldDiscard);
} else {
this.showCpuAction(drawingPlayer.name, 'draw-deck');
}
}
}
// V3_15: Track discard history
if (discardChanged && newDiscard) {
this.trackDiscardHistory(newDiscard);
}
// Track if we detected a draw this update - if so, skip STEP 2
// Drawing from discard changes the discard pile but isn't a "discard" action
const justDetectedDraw = justDrew && isOtherPlayerDrawing;
if (justDetectedDraw && discardChanged) {
console.log('[DEBUG] Skipping STEP 2 - discard change was from draw, not discard action');
}
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
// Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one
if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
const wasUp = oldCard?.face_up;
const isUp = newCard?.face_up;
// Case 1: face-down became face-up (needs flip)
if (!wasUp && isUp) {
swappedPosition = i;
wasFaceUp = false;
break;
}
// Case 2: both face-up but different card (no flip needed)
if (wasUp && isUp && oldCard.rank && newCard.rank) {
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
swappedPosition = i;
wasFaceUp = true; // Face-to-face swap
break;
}
}
// Case 3: Card identity became known (opponent's hidden card was swapped)
// This handles race conditions where face_up might not be updated yet
if (!oldCard?.rank && newCard?.rank) {
swappedPosition = i;
wasFaceUp = false;
break;
}
}
// Check if opponent's cards are completely unchanged (server might send split updates)
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) {
// Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
} else if (swappedPosition < 0 && wasOtherPlayer) {
// Opponent drew and discarded without swapping (cards unchanged)
this.fireDiscardAnimation(newDiscard, previousPlayerId);
// Show CPU discard announcement
if (oldPlayer?.is_cpu) {
this.showCpuAction(oldPlayer.name, 'discard', newDiscard);
}
}
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
}
}
// V3_04: Check for new column pairs after any state change
this.checkForNewPairs(oldState, newState);
// V3_09: Detect opponent knock (phase transition to final_turn)
if (oldState.phase !== 'final_turn' && newState.phase === 'final_turn') {
const knocker = newState.players.find(p => p.id === newState.finisher_id);
if (knocker && knocker.id !== this.playerId) {
this.playSound('alert');
this.showKnockBanner(knocker.name);
}
// V3_14: Highlight relevant knock rules
if (this.gameState?.knock_penalty) {
this.highlightRule('knock_penalty', '+10 if beaten!');
}
if (this.gameState?.knock_bonus) {
this.highlightRule('knock_bonus', '-5 for going out!');
}
}
// Handle delayed card updates (server sends split updates: discard first, then cards)
// Check if opponent cards changed even when discard didn't change
if (!discardChanged && wasOtherPlayer && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// Check for card changes that indicate a swap we missed
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card became visible (swap completed in delayed update)
if (!oldCard?.face_up && newCard?.face_up) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
// Card identity became known
if (!oldCard?.rank && newCard?.rank) {
this.fireSwapAnimation(previousPlayerId, newState.discard_top, i, false);
if (oldPlayer.is_cpu) {
this.showCpuAction(oldPlayer.name, 'swap');
}
break;
}
}
}
}
}
// --- V3_04: Column Pair Detection ---
checkForNewPairs(oldState, newState) {
if (!oldState || !newState) return;
const columns = [[0, 3], [1, 4], [2, 5]];
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (!oldPlayer) continue;
for (const [top, bottom] of columns) {
const wasPaired = this.isColumnPaired(oldPlayer.cards, top, bottom);
const nowPaired = this.isColumnPaired(newPlayer.cards, top, bottom);
if (!wasPaired && nowPaired) {
// New pair formed!
setTimeout(() => {
this.firePairCelebration(newPlayer.id, top, bottom);
}, window.TIMING?.celebration?.pairDelay || 50);
}
}
}
}
isColumnPaired(cards, pos1, pos2) {
const c1 = cards[pos1];
const c2 = cards[pos2];
return c1?.face_up && c2?.face_up && c1?.rank && c2?.rank && c1.rank === c2.rank;
}
firePairCelebration(playerId, pos1, pos2) {
this.playSound('pair');
const elements = this.getCardElements(playerId, pos1, pos2);
if (elements.length < 2) return;
if (window.cardAnimations) {
window.cardAnimations.celebratePair(elements[0], elements[1]);
}
}
getCardElements(playerId, ...positions) {
const elements = [];
if (playerId === this.playerId) {
const cards = this.playerCards.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
const cards = area.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
}
}
return elements;
}
// V3_15: Track discard pile history
trackDiscardHistory(card) {
if (!card) return;
// Avoid duplicates at front
if (this.discardHistory.length > 0 &&
this.discardHistory[0].rank === card.rank &&
this.discardHistory[0].suit === card.suit) return;
this.discardHistory.unshift({ rank: card.rank, suit: card.suit });
if (this.discardHistory.length > this.maxDiscardHistory) {
this.discardHistory = this.discardHistory.slice(0, this.maxDiscardHistory);
}
this.updateDiscardDepth();
}
updateDiscardDepth() {
if (!this.discard) return;
const depth = Math.min(this.discardHistory.length, 3);
this.discard.dataset.depth = depth;
}
clearDiscardHistory() {
this.discardHistory = [];
if (this.discard) this.discard.dataset.depth = '0';
}
// V3_10: Render persistent pair indicators on all players' cards
renderPairIndicators() {
if (!this.gameState) return;
const columns = [[0, 3], [1, 4], [2, 5]];
for (const player of this.gameState.players) {
const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
if (cards.length < 6) continue;
// Clear previous pair classes
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
for (const [top, bottom] of columns) {
if (this.isColumnPaired(player.cards, top, bottom)) {
cards[top]?.classList.add('paired', 'pair-top');
cards[bottom]?.classList.add('paired', 'pair-bottom');
}
}
}
}
// Flash animation on deck or discard pile to show where opponent drew from
// Defers held card display until pulse completes for clean sequencing
pulseDrawPile(source) {
const T = window.TIMING?.feedback || {};
const pulseDuration = T.drawPulse || 450;
const pile = source === 'discard' ? this.discard : this.deck;
// Set flag to defer held card display
this.drawPulseAnimation = true;
pile.classList.remove('draw-pulse');
void pile.offsetWidth;
pile.classList.add('draw-pulse');
// After pulse completes, show the held card
setTimeout(() => {
pile.classList.remove('draw-pulse');
this.drawPulseAnimation = false;
// Show the held card (no pop-in - match local player behavior)
if (this.gameState?.drawn_card && this.gameState?.drawn_player_id !== this.playerId) {
this.displayHeldCard(this.gameState.drawn_card, false);
}
}, pulseDuration);
}
// Pulse discard pile when a card lands on it
// Optional callback fires after pulse completes (for sequencing turn indicator update)
pulseDiscardLand(onComplete = null) {
// Use anime.js for discard pulse
if (window.cardAnimations) {
window.cardAnimations.pulseDiscard();
}
// Execute callback after animation
const T = window.TIMING?.feedback || {};
const duration = T.discardLand || 375;
setTimeout(() => {
if (onComplete) onComplete();
}, duration);
}
// Fire animation for discard without swap (card lands on discard pile face-up)
// Shows card moving from deck to discard for other players only
fireDiscardAnimation(discardCard, fromPlayerId = null) {
// Only show animation for other players - local player already knows what they did
const isOtherPlayer = fromPlayerId && fromPlayerId !== this.playerId;
if (isOtherPlayer && discardCard && window.cardAnimations) {
// Block renderGame from updating discard during animation
this.opponentDiscardAnimating = true;
this.skipNextDiscardFlip = true;
// Update lastDiscardKey so renderGame won't see a "change" and trigger flip animation
this.lastDiscardKey = `${discardCard.rank}-${discardCard.suit}`;
// Animate card from hold → discard using anime.js
window.cardAnimations.animateOpponentDiscard(discardCard, () => {
this.opponentDiscardAnimating = false;
this.updateDiscardPileDisplay(discardCard);
this.pulseDiscardLand();
});
}
// Skip animation entirely for local player
}
// Get rotation angle from an element's computed transform
getElementRotation(element) {
if (!element) return 0;
const style = window.getComputedStyle(element);
const transform = style.transform;
if (!transform || transform === 'none') return 0;
// Parse rotation from transform matrix
const values = transform.split('(')[1]?.split(')')[0]?.split(',');
if (values && values.length >= 2) {
const a = parseFloat(values[0]);
const b = parseFloat(values[1]);
return Math.round(Math.atan2(b, a) * (180 / Math.PI));
}
return 0;
}
// Fire a swap animation (non-blocking) - unified arc swap for all players
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
// Track this animation so renderGame can apply swap-out class
this.opponentSwapAnimation = { playerId, position };
// Find source position - the actual card that was swapped
const area = this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`);
let sourceRect = null;
let sourceCardEl = null;
let sourceRotation = 0;
if (area) {
const cards = area.querySelectorAll('.card-grid .card');
if (cards.length > position && position >= 0) {
sourceCardEl = cards[position];
sourceRect = sourceCardEl.getBoundingClientRect();
sourceRotation = this.getElementRotation(area);
}
}
// Get the held card data (what's being swapped IN to the hand)
const player = this.gameState?.players.find(p => p.id === playerId);
const newCardInHand = player?.cards[position];
// Safety check - need valid data for animation
if (!sourceRect || !discardCard || !newCardInHand) {
console.warn('fireSwapAnimation: missing data', { sourceRect: !!sourceRect, discardCard: !!discardCard, newCardInHand: !!newCardInHand });
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
this.renderGame();
return;
}
// Hide the source card during animation
sourceCardEl.classList.add('swap-out');
// Use unified swap animation
if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard
newCardInHand, // heldCardData - card going to hand
sourceRect, // handRect - where the hand card is
heldRect, // heldRect - holding position, opponent card size
{
rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp,
onComplete: () => {
sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
this.renderGame();
}
}
);
} else {
// Fallback
setTimeout(() => {
sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation fallback complete - clearing flags');
this.renderGame();
}, 500);
}
}
// Fire a flip animation for local player's card (non-blocking)
fireLocalFlipAnimation(position, cardData) {
const key = `local-${position}`;
if (this.animatingPositions.has(key)) return;
this.animatingPositions.add(key);
const cardElements = this.playerCards.querySelectorAll('.card');
const cardEl = cardElements[position];
if (!cardEl) {
this.animatingPositions.delete(key);
return;
}
// Use the unified card animation system for consistent flip animation
if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key);
});
} else {
// Fallback if card animations not available
this.animatingPositions.delete(key);
}
}
// Fire a flip animation for opponent card (non-blocking)
fireFlipAnimation(playerId, position, cardData) {
// Skip if already animating this position
const key = `${playerId}-${position}`;
if (this.animatingPositions.has(key)) return;
this.animatingPositions.add(key);
// Find the card element and parent area (for rotation)
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
let cardEl = null;
let sourceRotation = 0;
for (const area of opponentAreas) {
const nameEl = area.querySelector('h4');
const player = this.gameState?.players.find(p => p.id === playerId);
if (nameEl && player && nameEl.textContent.includes(player.name)) {
const cards = area.querySelectorAll('.card');
cardEl = cards[position];
sourceRotation = this.getElementRotation(area);
break;
}
}
if (!cardEl) {
this.animatingPositions.delete(key);
return;
}
// Use the unified card animation system for consistent flip animation
if (window.cardAnimations) {
window.cardAnimations.animateOpponentFlip(cardEl, cardData, sourceRotation);
}
// Clear tracking after animation duration
setTimeout(() => {
this.animatingPositions.delete(key);
}, (window.TIMING?.card?.flip || 400) + 100);
}
handleCardClick(position) {
const myData = this.getMyPlayerData();
if (!myData) return;
const card = myData.cards[position];
// Check for flip-as-action: can flip face-down card instead of drawing
const canFlipAsAction = this.gameState.flip_as_action &&
this.isMyTurn() &&
!this.drawnCard &&
!this.gameState.has_drawn_card &&
!card.face_up &&
!this.gameState.waiting_for_initial_flip;
if (canFlipAsAction) {
this.playSound('flip');
this.fireLocalFlipAnimation(position, card);
this.send({ type: 'flip_as_action', position });
this.hideToast();
return;
}
// Check if action is allowed - if not, play reject sound
const canAct = this.gameState.waiting_for_initial_flip ||
this.drawnCard ||
this.waitingForFlip;
if (!canAct) {
this.playSound('reject');
return;
}
// Initial flip phase
if (this.gameState.waiting_for_initial_flip) {
if (card.face_up) return;
// Use Set to prevent duplicates - check both tracking mechanisms
if (this.locallyFlippedCards.has(position)) return;
if (this.selectedCards.includes(position)) return;
const requiredFlips = this.gameState.initial_flips || 2;
// Track locally and animate immediately
this.locallyFlippedCards.add(position);
this.selectedCards.push(position);
// Fire flip animation (non-blocking)
this.fireLocalFlipAnimation(position, card);
// Re-render to show flipped state
this.renderGame();
// Use Set to ensure unique positions when sending to server
const uniquePositions = [...new Set(this.selectedCards)];
if (uniquePositions.length === requiredFlips) {
this.send({ type: 'flip_initial', positions: uniquePositions });
this.selectedCards = [];
// Note: locallyFlippedCards is cleared when server confirms (in game_state handler)
this.hideToast();
} else {
const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
}
return;
}
// Swap with drawn card
if (this.drawnCard) {
this.animateSwap(position);
this.hideToast();
return;
}
// Flip after discarding from deck (flip_on_discard variant)
if (this.waitingForFlip && !card.face_up) {
// Animate immediately, then send to server
this.fireLocalFlipAnimation(position, card);
this.flipCard(position);
this.hideToast();
return;
}
}
nextRound() {
this.clearNextHoleCountdown();
this.clearScoresheetCountdown();
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
}
newGame() {
this.leaveRoom();
}
leaveGame() {
if (this.isHost) {
// Host ending game affects everyone
if (confirm('End game for all players?')) {
this.send({ type: 'end_game' });
}
} else {
// Regular player just leaves
if (confirm('Leave this game?')) {
this.send({ type: 'leave_game' });
this.ws.close();
this.showLobby();
}
}
}
// UI Helpers
showScreen(screen) {
// Accept string names or DOM elements
if (typeof screen === 'string') {
const screenMap = {
'lobby': this.lobbyScreen,
'matchmaking': this.matchmakingScreen,
'waiting': this.waitingScreen,
'game': this.gameScreen,
};
screen = screenMap[screen] || screen;
}
this.lobbyScreen.classList.remove('active');
this.matchmakingScreen?.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
if (this.rulesScreen) {
this.rulesScreen.classList.remove('active');
}
screen.classList.add('active');
// Close mobile drawers on screen change
if (this.isMobile) {
this.closeDrawers();
}
// Handle auth bar visibility - hide global bar during game, show in-game controls instead
const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user;
if (isGameScreen && user) {
// Hide global auth bar, show in-game auth controls
this.authBar?.classList.add('hidden');
this.gameUsername.textContent = user.username;
this.gameUsername.classList.remove('hidden');
this.gameLogoutBtn.classList.remove('hidden');
} else {
// Show global auth bar (if logged in), hide in-game auth controls
if (user) {
this.authBar?.classList.remove('hidden');
}
this.gameUsername.classList.add('hidden');
this.gameLogoutBtn.classList.add('hidden');
}
}
showLobby() {
this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = '';
this.roomCode = null;
this.playerId = null;
this.isHost = false;
this.gameState = null;
this.previousState = null;
}
showWaitingRoom() {
this.showScreen(this.waitingScreen);
this.displayRoomCode.textContent = this.roomCode;
if (this.isHost) {
this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
// Initialize deck color preview
this.updateDeckColorPreview();
} else {
this.hostSettings.classList.add('hidden');
this.cpuControlsSection.classList.add('hidden');
this.waitingMessage.classList.remove('hidden');
}
}
showGameScreen() {
this.showScreen(this.gameScreen);
this.gameButtons.classList.add('hidden');
this.drawnCard = null;
this.selectedCards = [];
this.waitingForFlip = false;
this.previousState = null;
// Update leave button text based on role
const leaveText = this.isHost ? 'End Game' : 'Leave';
this.leaveGameBtn.textContent = leaveText;
const mobileLeave = document.getElementById('mobile-leave-btn');
if (mobileLeave) mobileLeave.textContent = leaveText;
// Update active rules bar
this.updateActiveRulesBar();
}
updateActiveRulesBar() {
if (!this.gameState) {
this.activeRulesBar.classList.add('hidden');
return;
}
const rules = this.gameState.active_rules || [];
// V3_14: Add data-rule attributes for contextual highlighting
const renderTag = (rule) => {
const key = this.getRuleKey(rule);
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
};
const unrankedTag = this.gameState.is_standard_rules === false
? '<span class="rule-tag unranked">Unranked</span>' : '';
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
} else if (rules.length <= 2) {
this.activeRulesList.innerHTML = unrankedTag + rules.map(renderTag).join('');
} else {
const displayed = rules.slice(0, 2);
const hidden = rules.slice(2);
const moreCount = hidden.length;
const tooltip = hidden.join(', ');
this.activeRulesList.innerHTML = unrankedTag + displayed.map(renderTag).join('') +
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
}
this.activeRulesBar.classList.remove('hidden');
}
// V3_14: Map display names to rule keys
getRuleKey(ruleName) {
const mapping = {
'Speed Golf': 'flip_mode', 'Endgame Flip': 'flip_mode',
'Knock Penalty': 'knock_penalty', 'Knock Bonus': 'knock_bonus',
'Super Kings': 'super_kings', 'Ten Penny': 'ten_penny',
'Lucky Swing': 'lucky_swing', 'Eagle Eye': 'eagle_eye',
'Underdog': 'underdog_bonus', 'Tied Shame': 'tied_shame',
'Blackjack': 'blackjack', 'Wolfpack': 'wolfpack',
'Flip Action': 'flip_as_action', '4 of a Kind': 'four_of_a_kind',
'Negative Pairs': 'negative_pairs_keep_value',
'One-Eyed Jacks': 'one_eyed_jacks', 'Knock Early': 'knock_early',
};
return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
}
// V3_14: Contextual rule highlighting
highlightRule(ruleKey, message, duration = 3000) {
const ruleTag = this.activeRulesList?.querySelector(`[data-rule="${ruleKey}"]`);
if (!ruleTag) return;
ruleTag.classList.add('rule-highlighted');
const messageEl = document.createElement('span');
messageEl.className = 'rule-message';
messageEl.textContent = message;
ruleTag.appendChild(messageEl);
setTimeout(() => {
ruleTag.classList.remove('rule-highlighted');
messageEl.remove();
}, duration);
}
showError(message) {
this.lobbyError.textContent = message;
this.playSound('reject');
console.error('Game error:', message);
}
updatePlayersList(players) {
this.playersList.innerHTML = '';
players.forEach(player => {
const li = document.createElement('li');
let badges = '';
if (player.is_host) badges += '<span class="host-badge">HOST</span>';
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
li.innerHTML = `
<span>${player.name}</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.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
}
});
// Auto-select 2 decks when reaching 4+ players (host only)
const prevCount = this.currentPlayers ? this.currentPlayers.length : 0;
if (this.isHost && prevCount < 4 && players.length >= 4) {
if (this.numDecksInput) this.numDecksInput.value = '2';
if (this.numDecksDisplay) this.numDecksDisplay.textContent = '2';
this.updateDeckColorPreview();
}
// Update deck recommendation visibility
this.updateDeckRecommendation(players.length);
}
updateDeckRecommendation(playerCount) {
if (!this.isHost || !this.deckRecommendation) return;
const decks = parseInt(this.numDecksInput?.value || '1');
// Show recommendation if 4+ players and only 1 deck selected
if (playerCount >= 4 && decks < 2) {
this.deckRecommendation.classList.remove('hidden');
} else {
this.deckRecommendation.classList.add('hidden');
}
}
adjustDeckCount(delta) {
if (!this.numDecksInput) return;
let current = parseInt(this.numDecksInput.value) || 1;
let newValue = Math.max(1, Math.min(3, current + delta));
this.numDecksInput.value = newValue;
if (this.numDecksDisplay) {
this.numDecksDisplay.textContent = newValue;
}
// Update related UI
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
this.updateDeckRecommendation(playerCount);
this.updateDeckColorPreview();
}
getDeckColors(numDecks) {
const multiColorPresets = {
classic: ['red', 'blue', 'gold'],
ninja: ['green', 'purple', 'orange'],
ocean: ['blue', 'teal', 'cyan'],
forest: ['green', 'gold', 'brown'],
sunset: ['orange', 'red', 'purple'],
berry: ['purple', 'pink', 'red'],
neon: ['pink', 'cyan', 'green'],
royal: ['purple', 'gold', 'red'],
earth: ['brown', 'green', 'gold']
};
const singleColorPresets = {
'all-red': 'red',
'all-blue': 'blue',
'all-green': 'green',
'all-gold': 'gold',
'all-purple': 'purple',
'all-teal': 'teal',
'all-pink': 'pink',
'all-slate': 'slate'
};
const preset = this.deckColorPresetSelect?.value || 'classic';
if (singleColorPresets[preset]) {
const color = singleColorPresets[preset];
return Array(numDecks).fill(color);
}
const colors = multiColorPresets[preset] || multiColorPresets.classic;
return colors.slice(0, numDecks);
}
updateDeckColorPreview() {
if (!this.deckColorPreview) return;
const numDecks = parseInt(this.numDecksInput?.value || '1');
const colors = this.getDeckColors(numDecks);
this.deckColorPreview.innerHTML = '';
colors.forEach(color => {
const card = document.createElement('div');
card.className = `preview-card deck-${color}`;
this.deckColorPreview.appendChild(card);
});
}
isMyTurn() {
return this.gameState && this.gameState.current_player_id === this.playerId;
}
// Visual check: don't show "my turn" indicators until opponent swap animation completes
isVisuallyMyTurn() {
if (this.opponentSwapAnimation) return false;
return this.isMyTurn();
}
getMyPlayerData() {
if (!this.gameState) return null;
return this.gameState.players.find(p => p.id === this.playerId);
}
setStatus(message, type = '') {
this.statusMessage.textContent = message;
this.statusMessage.className = 'status-message' + (type ? ' ' + type : '');
}
// Show CPU action announcement in status bar
showCpuAction(playerName, action, card = null) {
const suitSymbol = card ? this.getSuitSymbol(card.suit) : '';
const messages = {
'draw-deck': `${playerName} draws from deck`,
'draw-discard': card ? `${playerName} takes ${card.rank}${suitSymbol}` : `${playerName} takes from discard`,
'swap': `${playerName} swaps a card`,
'discard': card ? `${playerName} discards ${card.rank}${suitSymbol}` : `${playerName} discards`,
};
const message = messages[action];
if (message) {
this.setStatus(message, 'cpu-action');
}
}
// Update CPU considering visual state on discard pile and opponent area
updateCpuConsideringState() {
if (!this.gameState || !this.discard) return;
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
const isOtherTurn = currentPlayer && currentPlayer.id !== this.playerId;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
if (window.cardAnimations) {
window.cardAnimations.startCpuThinking(this.discard);
}
} else {
this.discard.classList.remove('cpu-considering');
if (window.cardAnimations) {
window.cardAnimations.stopCpuThinking(this.discard);
}
}
// V3_06: Update thinking indicator and opponent area glow
this.opponentsRow.querySelectorAll('.opponent-area').forEach(area => {
const playerId = area.dataset.playerId;
const isThisPlayer = playerId === this.gameState.current_player_id;
const player = this.gameState.players.find(p => p.id === playerId);
const isCpu = player?.is_cpu;
// Thinking indicator visibility
const indicator = area.querySelector('.thinking-indicator');
if (indicator) {
indicator.classList.toggle('hidden', !(isCpu && isThisPlayer && hasNotDrawn));
}
// Opponent area thinking glow (anime.js)
if (isOtherTurn && isThisPlayer && hasNotDrawn && window.cardAnimations) {
window.cardAnimations.startOpponentThinking(area);
} else if (window.cardAnimations) {
window.cardAnimations.stopOpponentThinking(area);
}
});
}
showToast(message, type = '', duration = 2500) {
// For compatibility - just set the status message
this.setStatus(message, type);
}
hideToast() {
// Restore default status based on game state
this.updateStatusFromGameState();
}
updateStatusFromGameState() {
if (!this.gameState) {
this.setStatus('');
this.finalTurnBadge.classList.add('hidden');
return;
}
// Check for round/game over states
if (this.gameState.phase === 'round_over') {
this.setStatus('Hole Complete!', 'round-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
if (this.gameState.phase === 'game_over') {
this.setStatus('Game Over!', 'game-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
// Show/hide final turn badge
if (isFinalTurn) {
this.updateFinalTurnDisplay();
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
if (currentPlayer && currentPlayer.id !== this.playerId) {
this.setStatus(`${currentPlayer.name}'s turn`, 'opponent-turn');
} else if (this.isMyTurn()) {
if (!this.drawnCard && !this.gameState.has_drawn_card) {
// Build status message based on available actions
let options = ['draw'];
if (this.gameState.flip_as_action) options.push('flip');
// Check knock early eligibility
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
if (this.gameState.knock_early && faceDownCount >= 1 && faceDownCount <= 2) {
options.push('knock');
}
if (options.length === 1) {
this.setStatus('Your turn - draw a card', 'your-turn');
} else {
this.setStatus(`Your turn - ${options.join('/')}`, 'your-turn');
}
} else {
this.setStatus('Your turn - draw a card', 'your-turn');
}
} else {
this.setStatus('');
}
}
// --- V3_05: Final Turn Urgency ---
updateFinalTurnDisplay() {
const finisherId = this.gameState?.finisher_id;
// Toggle game area class for border pulse
this.gameScreen.classList.add('final-turn-active');
// Show badge
this.finalTurnBadge.classList.remove('hidden');
// Mark knocker
this.markKnocker(finisherId);
// Play alert sound on first appearance
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
}
countRemainingTurns() {
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
const finisherId = this.gameState.finisher_id;
const players = this.gameState.players;
const currentIdx = players.findIndex(p => p.id === this.gameState.current_player_id);
const finisherIdx = players.findIndex(p => p.id === finisherId);
if (currentIdx === -1 || finisherIdx === -1) return 0;
let count = 0;
let idx = currentIdx;
while (idx !== finisherIdx) {
count++;
idx = (idx + 1) % players.length;
}
return count;
}
markKnocker(knockerId) {
this.clearKnockerMark();
if (!knockerId) return;
if (knockerId === this.playerId) {
this.playerArea.classList.add('is-knocker');
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
this.playerArea.appendChild(badge);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${knockerId}"]`
);
if (area) {
area.classList.add('is-knocker');
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
area.appendChild(badge);
}
}
}
clearKnockerMark() {
document.querySelectorAll('.is-knocker').forEach(el => {
el.classList.remove('is-knocker');
});
document.querySelectorAll('.knocker-badge').forEach(el => {
el.remove();
});
}
showDrawnCard() {
// Show drawn card floating over the draw pile (deck), regardless of source
const card = this.drawnCard;
this.displayHeldCard(card, true);
}
// Display held card floating above and between deck and discard - for any player
// isLocalPlayerHolding: true if this is the local player's card (shows discard button, pulse glow)
displayHeldCard(card, isLocalPlayerHolding) {
if (!card) {
this.hideDrawnCard();
return;
}
// Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
// Position centered above and between deck and discard
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Calculate center point between deck and discard
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before)
// On mobile portrait, reduce overlap so held card doesn't cover 2-row opponents
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.15 : 0.35);
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`;
// Scale font to card width (matches cardAnimations.cardFontSize ratio)
if (this.isMobile) {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
// Position discard button attached to right side of held card
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
if (card.rank === '★') {
this.heldCardFloating.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.heldCardFloatingContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
if (this.isRedSuit(card.suit)) {
this.heldCardFloating.classList.add('red');
} else {
this.heldCardFloating.classList.add('black');
}
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
// Show the floating card
this.heldCardFloating.classList.remove('hidden');
// Add pulse glow if it's local player's turn to act on the card
if (isLocalPlayerHolding) {
this.heldCardFloating.classList.add('your-turn-pulse');
this.discardBtn.classList.remove('hidden');
} else {
this.heldCardFloating.classList.remove('your-turn-pulse');
this.discardBtn.classList.add('hidden');
}
}
// Display a face-down held card (for when opponent draws from deck)
displayHeldCardFaceDown() {
// Set up as face-down card with deck color (use deck_top_deck_id for the color)
let className = 'card card-back held-card-floating';
if (this.gameState?.deck_colors) {
const deckId = this.gameState.deck_top_deck_id || 0;
const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
if (color) className += ` back-${color}`;
}
this.heldCardFloating.className = className;
this.heldCardFloating.style.cssText = '';
// Position centered above and between deck and discard
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
this.heldCardFloating.style.top = `${cardTop}px`;
this.heldCardFloating.style.width = `${cardWidth}px`;
this.heldCardFloating.style.height = `${cardHeight}px`;
if (this.isMobile) {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
this.heldCardFloatingContent.innerHTML = '';
this.heldCardFloating.classList.remove('hidden');
this.heldCardFloating.classList.remove('your-turn-pulse');
this.discardBtn.classList.add('hidden');
}
hideDrawnCard() {
// Hide the floating held card
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.classList.remove('your-turn-pulse');
// Clear any inline styles from animations
this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden');
// Clear button positioning
this.discardBtn.style.left = '';
this.discardBtn.style.top = '';
}
isRedSuit(suit) {
return suit === 'hearts' || suit === 'diamonds';
}
/**
* Get the point value for a single card, respecting house rules.
* Handles one_eyed_jacks (J♥/J♠ = 0) which can't be in the card_values map.
*/
getCardPointValue(card, cardValues, scoringRules) {
if (!card.rank) return 0;
if (scoringRules?.one_eyed_jacks && card.rank === 'J' &&
(card.suit === 'hearts' || card.suit === 'spades')) {
return 0;
}
return cardValues[card.rank] ?? 0;
}
/**
* Calculate structured scoring results for a 6-card hand.
* Single source of truth for client-side scoring logic.
*
* @param {Array} cards - 6-element array of card data objects ({rank, suit, face_up})
* @param {Object} cardValues - rank→point map from server (includes lucky_swing, super_kings, etc.)
* @param {Object} scoringRules - house rule flags from server (eagle_eye, negative_pairs_keep_value, etc.)
* @param {boolean} onlyFaceUp - if true, only count face-up cards (for live score badge)
* @returns {{ columns: Array<{isPair, pairValue, topValue, bottomValue}>, bonuses: Array<{type, value}>, total: number }}
*/
calculateColumnScores(cards, cardValues, scoringRules, onlyFaceUp = false) {
const rules = scoringRules || {};
const columns = [];
let total = 0;
let jackPairs = 0;
const pairedRanks = [];
for (let col = 0; col < 3; col++) {
const topCard = cards[col];
const bottomCard = cards[col + 3];
const topUp = topCard.face_up;
const bottomUp = bottomCard.face_up;
const topValue = (topUp || !onlyFaceUp) ? this.getCardPointValue(topCard, cardValues, rules) : 0;
const bottomValue = (bottomUp || !onlyFaceUp) ? this.getCardPointValue(bottomCard, cardValues, rules) : 0;
const bothVisible = onlyFaceUp ? (topUp && bottomUp) : true;
const isPair = bothVisible && topCard.rank && bottomCard.rank && topCard.rank === bottomCard.rank;
if (isPair) {
pairedRanks.push(topCard.rank);
if (topCard.rank === 'J') jackPairs++;
let pairValue = 0;
// Eagle Eye: paired jokers score -4
if (rules.eagle_eye && topCard.rank === '★') {
pairValue = -4;
}
// Negative Pairs Keep Value: negative-value pairs keep their score
else if (rules.negative_pairs_keep_value && (topValue < 0 || bottomValue < 0)) {
pairValue = topValue + bottomValue;
}
// Normal pair: 0
total += pairValue;
columns.push({ isPair: true, pairValue, topValue, bottomValue });
} else {
total += topValue + bottomValue;
columns.push({ isPair: false, pairValue: 0, topValue, bottomValue });
}
}
// Bonuses
const bonuses = [];
if (rules.wolfpack && jackPairs >= 2) {
bonuses.push({ type: 'wolfpack', value: -20 });
total += -20;
}
if (rules.four_of_a_kind) {
const rankCounts = {};
for (const r of pairedRanks) {
rankCounts[r] = (rankCounts[r] || 0) + 1;
}
for (const [rank, count] of Object.entries(rankCounts)) {
if (count >= 2) {
bonuses.push({ type: 'four_of_a_kind', value: -20, rank });
total += -20;
}
}
}
return { columns, bonuses, total };
}
calculateShowingScore(cards) {
if (!cards || cards.length !== 6) return 0;
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
const scoringRules = this.gameState?.scoring_rules || {};
return this.calculateColumnScores(cards, cardValues, scoringRules, true).total;
}
getSuitSymbol(suit) {
const symbols = {
hearts: '♥',
diamonds: '♦',
clubs: '♣',
spades: '♠'
};
return symbols[suit] || '';
}
renderCardContent(card) {
if (!card || !card.face_up) return '';
// Handle locally-flipped cards where rank/suit aren't known yet
if (!card.rank || !card.suit) {
return '';
}
// Jokers - use suit to determine icon (hearts = dragon, spades = oni)
if (card.rank === '★') {
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
return `<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;
if (this.dealAnimationInProgress) return;
// Update CPU considering visual state
this.updateCpuConsideringState();
// Update header
this.currentRoundSpan.textContent = this.gameState.current_round;
this.totalRoundsSpan.textContent = this.gameState.total_rounds;
// Sync mobile bottom bar round info
const mobileRound = document.getElementById('mobile-current-round');
const mobileTotal = document.getElementById('mobile-total-rounds');
if (mobileRound) mobileRound.textContent = this.gameState.current_round;
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency
const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) {
this.updateFinalTurnDisplay();
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
// Toggle not-my-turn class to disable hover effects when it's not player's turn
// Use visual check so turn indicators sync with discard land animation
const isVisuallyMyTurn = this.isVisuallyMyTurn();
this.gameScreen.classList.toggle('not-my-turn', !isVisuallyMyTurn);
// V3_08: Toggle can-swap class for card hover preview when holding a drawn card
this.playerArea.classList.toggle('can-swap', !!this.drawnCard && this.isMyTurn());
// Highlight player area when it's their turn (matching opponent-area.current-turn)
const isActivePlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
this.playerArea.classList.toggle('current-turn', isVisuallyMyTurn && isActivePlaying);
// Update status message (handled by specific actions, but set default here)
// During opponent swap animation, show the animating player (not the new current player)
const displayedPlayerId = this.opponentSwapAnimation
? this.opponentSwapAnimation.playerId
: this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
}
// Update player header (name + score like opponents)
const me = this.gameState.players.find(p => p.id === this.playerId);
if (me) {
// Calculate visible score from face-up cards
const showingScore = this.calculateShowingScore(me.cards);
this.yourScore.textContent = showingScore;
// Check if player won the round
const isRoundWinner = this.roundWinnerNames.has(me.name);
this.playerArea.classList.toggle('round-winner', isRoundWinner);
// Update player name in header (truncate if needed)
const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name;
const checkmark = me.all_face_up ? ' ✓' : '';
// Update player name span with crown if winner
const playerNameSpan = this.playerHeader.querySelector('.player-name');
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
// Dealer chip on player area
const isDealer = this.playerId === this.gameState.dealer_id;
let dealerChip = this.playerArea.querySelector('.dealer-chip');
if (isDealer && !dealerChip) {
dealerChip = document.createElement('div');
dealerChip.className = 'dealer-chip';
dealerChip.textContent = 'D';
this.playerArea.appendChild(dealerChip);
} else if (!isDealer && dealerChip) {
dealerChip.remove();
}
}
// Update discard pile
// Check if ANY player is holding a card (local or remote/CPU)
const anyPlayerHolding = this.drawnCard || this.gameState.drawn_card;
debugLog('RENDER', 'Discard pile', {
anyPlayerHolding: !!anyPlayerHolding,
localDrawn: this.drawnCard ? `${this.drawnCard.rank}` : null,
serverDrawn: this.gameState.drawn_card ? `${this.gameState.drawn_card.rank}` : null,
discardTop: this.gameState.discard_top ? `${this.gameState.discard_top.rank}${this.gameState.discard_top.suit?.[0]}` : 'EMPTY'
});
if (anyPlayerHolding) {
// Someone is holding a drawn card - show discard pile as greyed/disabled
// If drawn from discard, show what's underneath (new discard_top or empty)
// If drawn from deck, show current discard_top greyed
this.discard.classList.add('picked-up');
this.discard.classList.remove('holding');
if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
if (discardCard.rank === '★') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(discardCard.suit)) {
this.discard.classList.add('red');
} else {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
} else {
// No card underneath - show empty
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
this.discardContent.innerHTML = '';
}
} else {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
// Skip discard update during any discard-related animation - animation handles the visual
const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
this.opponentSwapAnimation ? 'opponentSwapAnimation' :
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
this.isDrawAnimating ? 'isDrawAnimating' : null;
if (skipReason) {
console.log('[DEBUG] Skipping discard update, reason:', skipReason,
'discard_top:', this.gameState.discard_top ?
`${this.gameState.discard_top.rank}-${this.gameState.discard_top.suit}` : 'none');
}
if (this.localDiscardAnimating) {
// Don't update discard content; animation will call updateDiscardPileDisplay
} else if (this.opponentSwapAnimation) {
// Don't update discard content; animation overlay shows the swap
} else if (this.opponentDiscardAnimating) {
// Don't update discard content; opponent discard animation in progress
} else if (this.isDrawAnimating) {
// Don't update discard content; draw animation in progress
} else if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Only animate discard flip during active gameplay, not at round/game end
const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey;
console.log('[DEBUG] Actually updating discard pile content to:', cardKey);
// Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
if (discardCard.rank === '★') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(discardCard.suit)) {
this.discard.classList.add('red');
} else {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
// THEN animate if needed (content is already set, so no blank flash)
if (shouldAnimate) {
// Remove any existing animation first to allow re-trigger
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
const flipInDuration = window.TIMING?.feedback?.cardFlipIn || 560;
setTimeout(() => this.discard.classList.remove('card-flip-in'), flipInDuration);
}
} else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
this.discardContent.innerHTML = '';
this.lastDiscardKey = null;
}
this.discardBtn.classList.add('hidden');
}
// Show held card for ANY player who has drawn (consistent visual regardless of whose turn)
// Local player uses this.drawnCard, others use gameState.drawn_card
// Skip for opponents during draw pulse animation (pulse callback will show it)
// Skip for local player during draw animation (animation callback will show it)
if (this.drawnCard && !this.isDrawAnimating) {
// Local player is holding - show with pulse and discard button
this.displayHeldCard(this.drawnCard, true);
} else if (this.gameState.drawn_card && this.gameState.drawn_player_id) {
// Another player is holding - show without pulse/button
// But defer display during draw pulse animation for clean sequencing
// Also skip for local player during their draw animation
const isLocalPlayer = this.gameState.drawn_player_id === this.playerId;
const skipForLocalAnim = isLocalPlayer && this.isDrawAnimating;
if (!this.drawPulseAnimation && !skipForLocalAnim) {
this.displayHeldCard(this.gameState.drawn_card, isLocalPlayer);
}
} else {
// No one holding a card
this.hideDrawnCard();
}
// Update deck/discard clickability and visual state
// Use visual check so indicators sync with opponent swap animation
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const isRoundActive = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
const canDraw = isRoundActive && this.isVisuallyMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
// Pulse the deck area when it's player's turn to draw
const wasTurnToDraw = this.deckArea.classList.contains('your-turn-to-draw');
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
// Use anime.js for turn pulse animation
if (canDraw && !wasTurnToDraw && window.cardAnimations) {
window.cardAnimations.startTurnPulse(this.deckArea);
} else if (!canDraw && wasTurnToDraw && window.cardAnimations) {
window.cardAnimations.stopTurnPulse(this.deckArea);
}
this.deck.classList.toggle('clickable', canDraw);
// Show disabled on deck when any player has drawn (consistent dimmed look)
this.deck.classList.toggle('disabled', hasDrawn);
// Apply deck color based on top card's deck_id
if (this.gameState.deck_colors && this.gameState.deck_colors.length > 0) {
const deckId = this.gameState.deck_top_deck_id || 0;
const deckColor = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
// Remove any existing back-* classes
this.deck.className = this.deck.className.replace(/\bback-\w+\b/g, '').trim();
this.deck.classList.add(`back-${deckColor}`);
}
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
// Disabled state handled by picked-up class when anyone is holding
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
this.opponentsRow.innerHTML = '';
// Don't highlight current player during round/game over
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
// During opponent swap animation, keep highlighting the player who just acted
// (turn indicator changes after the discard lands, not before)
const displayedCurrentPlayer = this.opponentSwapAnimation
? this.opponentSwapAnimation.playerId
: this.gameState.current_player_id;
opponents.forEach((player) => {
const div = document.createElement('div');
div.className = 'opponent-area';
div.dataset.playerId = player.id;
if (isPlaying && player.id === displayedCurrentPlayer) {
div.classList.add('current-turn');
}
const isRoundWinner = this.roundWinnerNames.has(player.name);
if (isRoundWinner) {
div.classList.add('round-winner');
}
// Dealer chip
const isDealer = player.id === this.gameState.dealer_id;
const dealerChipHtml = isDealer ? '<div class="dealer-chip">D</div>' : '';
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>' : '';
// V3_06: Add thinking indicator for CPU opponents
const isCpuThinking = player.is_cpu && isPlaying &&
player.id === displayedCurrentPlayer && !newState?.has_drawn_card;
const thinkingHtml = player.is_cpu
? `<span class="thinking-indicator${isCpuThinking ? '' : ' hidden'}">🤔</span>`
: '';
div.innerHTML = `
${dealerChipHtml}
<h4>${thinkingHtml}<span class="opponent-name">${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}</span><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;
// Check if clickable during initial flip
const isInitialFlipClickable = this.gameState.waiting_for_initial_flip && !card.face_up && !isLocallyFlipped;
const isClickable = (
isInitialFlipClickable ||
(this.drawnCard) ||
(this.waitingForFlip && !card.face_up)
);
const isSelected = this.selectedCards.includes(index);
const cardEl = document.createElement('div');
cardEl.innerHTML = this.renderCard(displayCard, isClickable, isSelected);
// Add pulse animation during initial flip phase
if (isInitialFlipClickable) {
cardEl.firstChild.classList.add('initial-flip-pulse');
cardEl.firstChild.dataset.position = index;
// Use anime.js for initial flip pulse
if (window.cardAnimations) {
window.cardAnimations.startInitialFlipPulse(cardEl.firstChild);
}
}
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
this.playerCards.appendChild(cardEl.firstChild);
});
}
// V3_10: Update persistent pair indicators
this.renderPairIndicators();
// Show flip prompt for initial flip
// Show flip prompt during initial flip phase (but not during deal animation)
if (this.gameState.waiting_for_initial_flip && !this.dealAnimationInProgress) {
const requiredFlips = this.gameState.initial_flips || 2;
const flippedCount = this.locallyFlippedCards.size;
const remaining = requiredFlips - flippedCount;
if (remaining > 0) {
this.setStatus(`Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`, 'your-turn');
}
}
// Disable discard button if can't discard (must_swap_discard rule)
if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled');
} else {
this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled');
}
// Show/hide skip flip button (only when flip is optional in endgame mode)
if (this.waitingForFlip && this.flipIsOptional) {
this.skipFlipBtn.classList.remove('hidden');
} else {
this.skipFlipBtn.classList.add('hidden');
}
// Show/hide knock early button (when knock_early rule is enabled)
// Conditions: rule enabled, my turn, no drawn card, have 1-2 face-down cards
const canKnockEarly = this.gameState.knock_early &&
this.isMyTurn() &&
!this.drawnCard &&
!this.gameState.has_drawn_card &&
!this.gameState.waiting_for_initial_flip;
if (canKnockEarly) {
// Count face-down cards for current player
const myData = this.gameState.players.find(p => p.id === this.playerId);
const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
if (faceDownCount >= 1 && faceDownCount <= 2) {
this.knockEarlyBtn.classList.remove('hidden');
} else {
this.knockEarlyBtn.classList.add('hidden');
}
} else {
this.knockEarlyBtn.classList.add('hidden');
}
// Update scoreboard panel
this.updateScorePanel();
// Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards);
window.cardAnimations.initHoverListeners(this.opponentsRow);
}
}
updateScorePanel() {
if (!this.gameState) return;
// Update standings (left panel)
this.updateStandings();
// Skip score table update during round_over/game_over - showScoreboard handles these
if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') {
return;
}
// Update score table (right panel)
this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => {
const tr = document.createElement('tr');
// Highlight current player (but not during round/game over)
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
if (isPlaying && player.id === this.gameState.current_player_id) {
tr.classList.add('current-player');
}
// Truncate long names
const displayName = player.name.length > 12
? player.name.substring(0, 11) + '…'
: player.name;
const roundScore = player.score !== null ? player.score : '-';
const roundsWon = player.rounds_won || 0;
tr.innerHTML = `
<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';
// Apply deck color based on card's deck_id
if (this.gameState?.deck_colors) {
const deckId = card.deck_id || 0;
const color = this.gameState.deck_colors[deckId] || this.gameState.deck_colors[0];
if (color) classes += ` back-${color}`;
}
}
if (clickable) classes += ' clickable';
if (selected) classes += ' selected';
return `<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;
}
// V3_17: Scoresheet modal handles Next Hole button/countdown now
// Side panel just updates data silently
}
startNextHoleCountdown() {
// Clear any existing countdown
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
}
const COUNTDOWN_SECONDS = 15;
let remaining = COUNTDOWN_SECONDS;
const updateButton = () => {
if (this.isHost) {
this.nextRoundBtn.textContent = `Next Hole (${remaining}s)`;
this.nextRoundBtn.disabled = false;
} else {
this.nextRoundBtn.textContent = `Next hole in ${remaining}s...`;
this.nextRoundBtn.disabled = true;
this.nextRoundBtn.classList.add('waiting');
}
};
updateButton();
this.nextHoleCountdownInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
// Auto-advance if host
if (this.isHost) {
this.nextRound();
} else {
this.nextRoundBtn.textContent = 'Waiting for host...';
}
} else {
updateButton();
}
}, 1000);
}
clearNextHoleCountdown() {
if (this.nextHoleCountdownInterval) {
clearInterval(this.nextHoleCountdownInterval);
this.nextHoleCountdownInterval = null;
}
}
showRankingsAnnouncement(rankings, isFinal) {
// Remove existing announcement if any
const existing = document.getElementById('rankings-announcement');
if (existing) existing.remove();
const existingVictory = document.getElementById('double-victory-banner');
if (existingVictory) existingVictory.remove();
if (!rankings) return;
const announcement = document.createElement('div');
announcement.id = 'rankings-announcement';
announcement.className = 'rankings-announcement';
const title = isFinal ? 'Final Results' : 'Current Standings';
// Check for double victory (same player leads both categories) - only at game end
const pointsLeader = rankings.by_points[0];
const holesLeader = rankings.by_holes_won[0];
const isDoubleVictory = isFinal && pointsLeader && holesLeader &&
pointsLeader.name === holesLeader.name &&
holesLeader.rounds_won > 0;
// Build points ranking (lowest wins) with tie handling
let pointsRank = 0;
let prevPoints = null;
const pointsHtml = rankings.by_points.map((p, i) => {
if (p.total !== prevPoints) {
pointsRank = i;
prevPoints = p.total;
}
const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`;
const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name;
return `<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!';
const copyDelay = window.TIMING?.feedback?.copyConfirm || 2000;
setTimeout(() => btn.textContent = '📋 Copy Results', copyDelay);
});
});
document.getElementById('close-results-btn').addEventListener('click', () => {
modal.remove();
this.leaveRoom();
});
}
buildShareText(rankings, isDoubleVictory) {
let text = '🏌️ Golf Card Game Results\n';
text += '═══════════════════════\n\n';
if (isDoubleVictory) {
text += `🏆 DOUBLE VICTORY: ${rankings.by_points[0].name}!\n\n`;
}
text += '📊 By Points (Low Wins):\n';
rankings.by_points.forEach((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
text += `${medal} ${p.name}: ${p.total} pts\n`;
});
text += '\n⛳ By Holes Won:\n';
rankings.by_holes_won.forEach((p, i) => {
const medal = p.rounds_won === 0 ? '-' : i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
text += `${medal} ${p.name}: ${p.rounds_won} wins\n`;
});
text += '\nPlayed at golf.game';
return text;
}
}
// Initialize game when page loads
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
window.auth = new AuthManager(window.game);
window.game.authManager = window.auth;
});
// ===========================================
// AUTH MANAGER
// ===========================================
class AuthManager {
constructor(game) {
this.game = game;
this.token = localStorage.getItem('authToken');
this.user = JSON.parse(localStorage.getItem('authUser') || 'null');
this.initElements();
this.bindEvents();
this.updateUI();
// Validate stored token on load
if (this.token) {
this.validateToken();
}
}
async validateToken() {
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${this.token}` },
});
if (!response.ok) {
this.logout();
}
} catch {
// Network error - keep token, will fail on next action
}
}
initElements() {
this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username');
this.logoutBtn = document.getElementById('auth-logout-btn');
this.authPrompt = document.getElementById('auth-prompt');
this.lobbyGameControls = document.getElementById('lobby-game-controls');
this.loginBtn = document.getElementById('login-btn');
this.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal');
this.modalClose = document.getElementById('auth-modal-close');
this.loginFormContainer = document.getElementById('login-form-container');
this.loginForm = document.getElementById('login-form');
this.loginUsername = document.getElementById('login-username');
this.loginPassword = document.getElementById('login-password');
this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code');
this.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');
this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container');
this.forgotForm = document.getElementById('forgot-form');
this.forgotEmail = document.getElementById('forgot-email');
this.forgotError = document.getElementById('forgot-error');
this.forgotSuccess = document.getElementById('forgot-success');
this.forgotBackLogin = document.getElementById('forgot-back-login');
this.resetFormContainer = document.getElementById('reset-form-container');
this.resetForm = document.getElementById('reset-form');
this.resetPassword = document.getElementById('reset-password');
this.resetPasswordConfirm = document.getElementById('reset-password-confirm');
this.resetError = document.getElementById('reset-error');
this.resetSuccess = document.getElementById('reset-success');
}
bindEvents() {
this.loginBtn?.addEventListener('click', () => this.showModal('login'));
this.signupBtn?.addEventListener('click', () => this.showModal('signup'));
this.modalClose?.addEventListener('click', () => this.hideModal());
this.modal?.addEventListener('click', (e) => {
if (e.target === this.modal) this.hideModal();
});
this.showSignupLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('signup');
});
this.showLoginLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e));
this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e));
this.logoutBtn?.addEventListener('click', () => this.logout());
this.showForgotLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('forgot');
});
this.forgotBackLogin?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e));
this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e));
// Check URL for reset token or invite code on page load
this.checkResetToken();
this.checkInviteCode();
}
showModal(form = 'login') {
this.modal.classList.remove('hidden');
this.showForm(form);
this.clearErrors();
}
hideModal() {
this.modal.classList.add('hidden');
this.clearForms();
}
showForm(form) {
this.loginFormContainer.classList.add('hidden');
this.signupFormContainer.classList.add('hidden');
this.forgotFormContainer?.classList.add('hidden');
this.resetFormContainer?.classList.add('hidden');
this.clearErrors();
if (form === 'login') {
this.loginFormContainer.classList.remove('hidden');
this.loginUsername.focus();
} else if (form === 'signup') {
this.signupFormContainer.classList.remove('hidden');
this.signupUsername.focus();
} else if (form === 'forgot') {
this.forgotFormContainer?.classList.remove('hidden');
this.forgotEmail?.focus();
} else if (form === 'reset') {
this.resetFormContainer?.classList.remove('hidden');
this.resetPassword?.focus();
}
}
clearForms() {
this.loginForm.reset();
this.signupForm.reset();
this.clearErrors();
}
clearErrors() {
this.loginError.textContent = '';
this.signupError.textContent = '';
if (this.forgotError) this.forgotError.textContent = '';
if (this.forgotSuccess) this.forgotSuccess.textContent = '';
if (this.resetError) this.resetError.textContent = '';
if (this.resetSuccess) this.resetSuccess.textContent = '';
}
async handleLogin(e) {
e.preventDefault();
this.clearErrors();
const username = this.loginUsername.value.trim();
const password = this.loginPassword.value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
this.loginError.textContent = data.detail || 'Login failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
} catch (err) {
this.loginError.textContent = 'Connection error';
}
}
async handleSignup(e) {
e.preventDefault();
this.clearErrors();
const invite_code = this.signupInviteCode?.value.trim() || null;
const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value;
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_code, username, email, password }),
});
const data = await response.json();
if (!response.ok) {
this.signupError.textContent = data.detail || 'Signup failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
} catch (err) {
this.signupError.textContent = 'Connection error';
}
}
setAuth(token, user) {
this.token = token;
this.user = user;
localStorage.setItem('authToken', token);
localStorage.setItem('authUser', JSON.stringify(user));
this.updateUI();
}
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('authToken');
localStorage.removeItem('authUser');
this.updateUI();
}
updateUI() {
if (this.user) {
this.authBar?.classList.remove('hidden');
this.authPrompt?.classList.add('hidden');
this.lobbyGameControls?.classList.remove('hidden');
if (this.authUsername) {
this.authUsername.textContent = this.user.username;
}
} else {
this.authBar?.classList.add('hidden');
this.authPrompt?.classList.remove('hidden');
this.lobbyGameControls?.classList.add('hidden');
}
}
checkResetToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const path = window.location.pathname;
if (token && path.includes('reset-password')) {
this._resetToken = token;
this.showModal('reset');
// Clean URL
window.history.replaceState({}, '', '/');
}
}
checkInviteCode() {
const params = new URLSearchParams(window.location.search);
const invite = params.get('invite');
if (invite) {
this.signupInviteCode.value = invite;
this.showModal('signup');
// Clean URL
window.history.replaceState({}, '', '/');
}
}
async handleForgotPassword(e) {
e.preventDefault();
this.clearErrors();
const email = this.forgotEmail.value.trim();
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json();
this.forgotError.textContent = data.detail || 'Request failed';
return;
}
this.forgotSuccess.textContent = 'If an account exists with that email, a reset link has been sent.';
this.forgotForm.reset();
} catch (err) {
this.forgotError.textContent = 'Connection error';
}
}
async handleResetPassword(e) {
e.preventDefault();
this.clearErrors();
const password = this.resetPassword.value;
const confirm = this.resetPasswordConfirm.value;
if (password !== confirm) {
this.resetError.textContent = 'Passwords do not match';
return;
}
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this._resetToken, new_password: password }),
});
const data = await response.json();
if (!response.ok) {
this.resetError.textContent = data.detail || 'Reset failed';
return;
}
this.resetSuccess.textContent = 'Password reset! You can now log in.';
this.resetForm.reset();
setTimeout(() => this.showForm('login'), 2000);
} catch (err) {
this.resetError.textContent = 'Connection error';
}
}
}