'use strict'; // ─── State ──────────────────────────────────────────────────── let socket; let myName = ''; let mySeat = -1; let myToken = null; let myRoomId = null; let spectating = false; let authToken = localStorage.getItem('hearts_token') || null; let authUser = localStorage.getItem('hearts_user') || null; let lastState = null; let deferredInstallPrompt = null; // ─── Turn escalation state ──────────────────────────────────── let turnEscalationInterval = null; let turnEscalationLevel = 0; let wasMyTurn = false; // ─── AFK / AI-takeover state ────────────────────────────────── let aiControlledSeats = new Set(); let afkWarnSeat = -1; let selectedScore = 100; let selectedPublic = false; // ─── Play mode & hand display mode ─────────────────────────── function isTouchDevice() { return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; } function loadPlayMode() { const s = localStorage.getItem('hearts_play_mode'); if (s === 'tap' || s === 'drag') return s; return isTouchDevice() ? 'drag' : 'tap'; } function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); } function loadHandMode() { const s = localStorage.getItem('hearts_hand_mode'); return s === 'fan' ? 'fan' : 'scroll'; } function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); } let playMode = loadPlayMode(); let handMode = loadHandMode(); function updatePlayModeBtn() { const btn = $('btn-play-mode'); if (!btn) return; const isDrag = playMode === 'drag'; btn.textContent = isDrag ? '☝ Drag' : '👆 Tap'; btn.classList.toggle('drag-mode', isDrag); btn.title = isDrag ? 'Switch to tap mode' : 'Switch to drag mode'; } const HAND_MODES = ['scroll', 'fan', 'playables']; const HAND_MODE_LABEL = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; function updateHandModeBtn() { const btn = $('btn-hand-mode'); if (!btn) return; btn.textContent = HAND_MODE_LABEL[handMode] || '📜 Scroll'; btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables'); } function applyHandMode() { const fanLike = handMode === 'fan' || handMode === 'playables'; const handEl = $('my-hand'); handEl?.classList.toggle('fan-mode', fanLike); $('my-area')?.classList.toggle('fan-active', fanLike); // Reset inline justify so scroll mode's CSS takes over cleanly if (!fanLike && handEl) handEl.style.justifyContent = ''; } // Dynamic card spacing for fan/playables mode. // If cards overflow → minimum overlap to fit (+2px buffer). // If cards fit → spread up to 16px but never exceed available space // (prevents fan overflow on screens where cards are borderline). function updateHandSpacing() { const handEl = $('my-hand'); if (!handEl || handMode === 'scroll') return; const cards = Array.from(handEl.querySelectorAll('.card')); const n = cards.length; if (n === 0) return; if (n === 1) { cards[0].style.marginLeft = '0'; return; } const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68; const containerW = handEl.offsetWidth || (window.innerWidth - 24); const overflow = n * cardW - containerW; let ml; if (overflow > 0) { // Cards don't fit — overlap just enough, plus 2px safety buffer ml = -(Math.ceil(overflow / (n - 1)) + 2); handEl.style.justifyContent = 'flex-start'; } else { // Cards fit — spread up to 16px, capped to available space ml = Math.min(16, Math.floor(-overflow / (n - 1))); // Center the group so few cards don't hug the left edge handEl.style.justifyContent = 'center'; } cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); } // Attach touch drag-to-play handlers to a card element function addDragHandlers(cardEl, code) { const handEl = $('my-hand'); // Magnifier only helps when the hand is dense (>8 cards); sparse fans are readable const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0; const useMagnifier = isTouchDevice() && playMode === 'drag' && (handMode === 'fan' || handMode === 'playables') && cardCount > 8; let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null; cardEl.addEventListener('touchstart', e => { touchStartY = e.touches[0].clientY; touchStartX = e.touches[0].clientX; isDragging = false; if (useMagnifier) cardEl.classList.add('magnified'); }, { passive: true }); cardEl.addEventListener('touchmove', e => { const dy = touchStartY - e.touches[0].clientY; // Horizontal sweep in fan+drag mode — magnifier picks cards if (useMagnifier && !isDragging && dy < 20) { cardEl.classList.remove('magnified'); const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); const tgt = el2?.closest('.card'); if (tgt) tgt.classList.add('magnified'); return; } if (dy > 20) { e.preventDefault(); isDragging = true; handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (!ghost) { ghost = document.createElement('div'); ghost.className = 'drag-ghost'; ghost.appendChild(cardImg(code)); document.body.appendChild(ghost); $('trick-area')?.classList.add('drag-active'); } ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px'; ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px'; } }, { passive: false }); const endDrag = e => { if (ghost) { ghost.remove(); ghost = null; } $('trick-area')?.classList.remove('drag-active'); handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (isDragging) { const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY; if (endY < window.innerHeight * 0.70) playCard(code); } isDragging = false; }; cardEl.addEventListener('touchend', endDrag); cardEl.addEventListener('touchcancel', endDrag); } const SUIT_ICON = { H: '♥', D: '♦', C: '♣', S: '♠' }; const SUIT_COLOR = { H: '#e53935', D: '#e53935', C: '#111', S: '#111' }; const PASS_DIR_LABEL = { left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)' }; // Map from internal code (e.g. "S-Q") to SVG filename (e.g. "SPADE-12-QUEEN.svg") const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' }; const RANK_FILE = { 'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', '10': '10', 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING', }; // ─── Turn escalation ────────────────────────────────────────── function handleTurnReminder(isMyTurn) { clearInterval(turnEscalationInterval); turnEscalationLevel = 0; applyTurnEscalation(0); if (!isMyTurn) return; turnEscalationInterval = setInterval(() => { turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); applyTurnEscalation(turnEscalationLevel); if (turnEscalationLevel >= 3) { if (navigator.vibrate) navigator.vibrate([200, 100, 200]); try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; g.gain.setValueAtTime(0.3, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); o.start(); o.stop(ctx.currentTime + 0.4); } catch(e) {} } }, 5000); } function applyTurnEscalation(level) { const el = $('phase-msg'); if (!el) return; el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); if (level > 0) el.classList.add(`your-turn-lvl${level}`); } // ─── AFK / AI-takeover UI ───────────────────────────────────── function updateAiControlBanner() { const banner = $('ai-control-banner'); const msg = $('ai-control-msg'); if (!banner || !msg) return; if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; } if (aiControlledSeats.has(mySeat)) { msg.textContent = 'AI is playing for you — play a card to resume control'; banner.className = 'ai-control-banner ai-self'; } else { const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', '); msg.textContent = `AI is playing for ${names}`; banner.className = 'ai-control-banner ai-other'; } show('ai-control-banner'); } // ─── Helpers ────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); } function hide(...ids) { ids.forEach(id => $(id)?.classList.add('hidden')); } function showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); $(id).classList.add('active'); } function cardImg(code) { const [s, r] = code.split('-'); const img = document.createElement('img'); img.src = `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`; img.alt = code; img.draggable = false; return img; } function suitOf(code) { return code.split('-')[0]; } function rankOf(code) { return code.split('-')[1]; } function isRed(code) { const s = suitOf(code); return s === 'H' || s === 'D'; } function isHeart(code){ return suitOf(code) === 'H'; } function isQoS(code) { return code === 'S-Q'; } function cardPoints(code) { return isHeart(code) ? 1 : isQoS(code) ? 13 : 0; } // Seat mapping: my seat is bottom; left/top/right follow clockwise function relativePosition(mySeat, theirSeat) { const delta = (theirSeat - mySeat + 4) % 4; return ['bottom', 'right', 'top', 'left'][delta]; } function positionToArea(pos) { return { top: 'top', left: 'left', right: 'right', bottom: 'bottom' }[pos]; } // ─── Auth helpers ───────────────────────────────────────────── function authHeaders() { return authToken ? { 'Authorization': `Bearer ${authToken}` } : {}; } async function apiFetch(url, opts = {}) { const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(opts.headers || {}) }, body: opts.body ? JSON.stringify(opts.body) : undefined, }); return res.json(); } function setAuth(token, username) { authToken = token; authUser = username; if (token) { localStorage.setItem('hearts_token', token); localStorage.setItem('hearts_user', username); } else { localStorage.removeItem('hearts_token'); localStorage.removeItem('hearts_user'); } updateAuthBar(); } function updateAuthBar() { if (authUser) { $('auth-status').textContent = `Logged in as ${authUser}`; show('btn-show-profile', 'btn-logout'); hide('btn-show-auth'); // Pre-fill the name input so the user doesn't have to type it const nameInput = $('input-name'); if (nameInput && !nameInput.value) nameInput.value = authUser; } else { $('auth-status').textContent = 'Playing as guest'; hide('btn-show-profile', 'btn-logout'); show('btn-show-auth'); } } // ─── Session helpers ────────────────────────────────────────── function saveSession(roomId, seat, token) { localStorage.setItem('hearts_session', JSON.stringify({ roomId, seat, token, ts: Date.now() })); } function loadSession() { try { const s = JSON.parse(localStorage.getItem('hearts_session')); if (s && Date.now() - s.ts < 3 * 60 * 60 * 1000) return s; } catch { /* ignore */ } return null; } function clearSession() { localStorage.removeItem('hearts_session'); } // ─── Socket init ────────────────────────────────────────────── function initSocket() { socket = io({ auth: { token: authToken } }); socket.on('connect', () => { // Try to rejoin an active session const sess = loadSession(); if (sess && sess.roomId) { socket.emit('rejoin', { roomId: sess.roomId, seat: sess.seat, token: sess.token }); } }); socket.on('created', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); $('display-room-code').textContent = roomId; showScreen('screen-waiting'); }); socket.on('joined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); $('display-room-code').textContent = roomId; showScreen('screen-waiting'); }); socket.on('spectating', ({ roomId }) => { myRoomId = roomId; mySeat = -1; spectating = true; showScreen('screen-game'); show('spectator-banner'); }); socket.on('rejoined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); hide('overlay-pass', 'overlay-hand', 'overlay-gameover'); }); socket.on('rejoinError', (msg) => { clearSession(); $('lobby-error').textContent = msg || 'Could not rejoin'; showScreen('screen-lobby'); }); socket.on('hasActiveGame', ({ roomId, seat, token }) => { if (myRoomId) return; // already in a session myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); socket.emit('rejoin', { roomId, seat, token }); }); // Core game events — all handled by renderState socket.on('roomInfo', state => handleState(state)); socket.on('cardPlayed',state => handleState(state)); socket.on('trickWon', state => handleTrickWon(state)); socket.on('handOver', state => handleHandOver(state)); socket.on('gameOver', state => handleGameOver(state)); socket.on('error', msg => { $('lobby-error').textContent = msg; }); socket.on('joinError', msg => { $('lobby-error').textContent = msg; }); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('playError', msg => console.warn('playError:', msg)); socket.on('afkWarning', ({ seat, name }) => { afkWarnSeat = seat; const isMe = seat === mySeat; $('afk-banner-msg').textContent = isMe ? "You haven't played yet!" : `${name} hasn't played. Let AI take over?`; if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); show('afk-banner'); }); socket.on('afkResolved', () => { afkWarnSeat = -1; hide('afk-banner'); }); socket.on('aiControl', ({ seat, active, name }) => { if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); updateAiControlBanner(); }); } // ─── State rendering ────────────────────────────────────────── function syncAiState(state) { aiControlledSeats = new Set(state.aiControlledSeats || []); updateAiControlBanner(); } function handleState(state) { lastState = state; syncAiState(state); // Restore locally-selected pass cards that the server doesn't know about yet if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) { state.passSelected = passSelectedLocal; } else { passSelectedLocal = []; } // Always dismiss result overlays when fresh state arrives hide('overlay-hand', 'overlay-gameover'); if (state.state === 'WAITING') { renderWaitingRoom(state); return; } showScreen('screen-game'); renderInfoBar(state); renderTable(state); if (state.state === 'PASSING') { renderPassOverlay(state); } else { hide('overlay-pass'); } } function handleTrickWon(state) { lastState = state; syncAiState(state); renderInfoBar(state); renderTable(state); // Flash the winning slot briefly const winnerPos = spectating ? null : relativePosition(mySeat, state.lastTrickWinner); const slot = winnerPos ? $(`trick-${winnerPos}`) : null; if (slot) slot.classList.add('trick-winner-flash'); setTimeout(() => slot?.classList.remove('trick-winner-flash'), 600); } function handleHandOver(state) { lastState = state; syncAiState(state); hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass'); showHandOverlay(state); } function handleGameOver(state) { lastState = state; syncAiState(state); hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass', 'overlay-hand'); showGameOverOverlay(state); } // ── Info bar ────────────────────────────────────────────────── function renderInfoBar(state) { // Score display const scoreDiv = $('score-display'); scoreDiv.innerHTML = ''; const hpDiv = $('hand-points-display'); hpDiv.innerHTML = ''; const minScore = Math.min(...state.scores); state.names.forEach((name, i) => { const isMine = i === mySeat; const entry = document.createElement('div'); entry.className = 'score-entry' + (isMine ? ' my-score' : '') + (state.scores[i] === minScore && state.scores.filter(s => s === minScore).length < 4 ? ' leading' : ''); const nameEl = document.createElement('span'); nameEl.className = 'score-name'; nameEl.textContent = name || `P${i+1}`; const valEl = document.createElement('span'); valEl.className = 'score-val'; valEl.textContent = state.scores[i]; entry.appendChild(nameEl); entry.appendChild(valEl); scoreDiv.appendChild(entry); }); // Hand points row: only show the local player's own points const myHpVal = state.handPoints[mySeat]; if (myHpVal != null && myHpVal > 0 && mySeat >= 0) { const hp = document.createElement('span'); hp.className = 'hp-entry'; const hn = document.createElement('span'); hn.className = 'hp-name'; hn.textContent = 'You:'; const hv = document.createElement('span'); hv.className = 'hp-val has-pts'; hv.textContent = myHpVal; hp.appendChild(hn); hp.appendChild(hv); hpDiv.appendChild(hp); } // Hearts broken if (state.heartsBroken) { show('hearts-broken-display'); } else { hide('hearts-broken-display'); } // Pass direction if (state.state === 'PASSING') { $('pass-dir-display').textContent = PASS_DIR_LABEL[state.passDirection] || state.passDirection; show('pass-dir-display'); } else { hide('pass-dir-display'); } } // ── Waiting room ────────────────────────────────────────────── function renderWaitingRoom(state) { showScreen('screen-waiting'); const filled = state.names.filter((n, i) => n && (state.bots[i] || true)).length; const humanCount = state.names.filter((n, i) => n && !state.bots[i]).length; const botCount = state.bots.filter(Boolean).length; const total = humanCount + botCount; for (let i = 0; i < 4; i++) { const slot = document.querySelector(`.seat-slot[data-seat="${i}"]`); if (!slot) continue; const nameSpan = slot.querySelector('.seat-name'); if (state.names[i]) { nameSpan.textContent = state.names[i] + (state.bots[i] ? ' 🤖' : ''); slot.classList.add('filled'); } else { nameSpan.textContent = '—'; slot.classList.remove('filled'); } } const remaining = 4 - total; $('waiting-status').textContent = remaining > 0 ? `Waiting for ${remaining} more player${remaining > 1 ? 's' : ''}…` : 'All players ready!'; // Show fill-bots button if we're the first seat and there are empty seats if (mySeat === 0 && total < 4) { show('btn-fill-bots'); } else { hide('btn-fill-bots'); } const badge = $('waiting-public-badge'); if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private'; } // ── Game table ──────────────────────────────────────────────── function renderTable(state) { if (spectating) { renderSpectatorTable(state); return; } const positions = ['top', 'left', 'right']; const otherSeats = [ (mySeat + 2) % 4, // top (across) (mySeat + 3) % 4, // left (mySeat + 1) % 4, // right ]; positions.forEach((pos, idx) => { const seat = otherSeats[idx]; $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; $(`${pos}-score`).textContent = state.scores[seat]; // Turn indicator if (state.currentTurn === seat && state.state === 'PLAYING') { show(`${pos}-turn`); } else { hide(`${pos}-turn`); } // No card-back display — opponents' hand size is hidden $(`${pos}-cards`).innerHTML = ''; }); // My area $('my-name').textContent = state.names[mySeat] || 'You'; $('my-score').textContent = state.scores[mySeat]; // Only show my own in-hand points (server sends null for others) const myHp = state.handPoints[mySeat]; $('my-hand-pts').textContent = myHp > 0 ? `+${myHp}♥` : ''; if (state.currentTurn === mySeat && state.state === 'PLAYING') { show('my-turn'); } else { hide('my-turn'); } // Render my hand const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; renderMyHand(myHand, state); // Render trick renderTrick(state); // Phase message renderPhaseMsg(state); } function renderSpectatorTable(state) { // For spectators, show all hands as backs, no interaction ['top', 'left', 'right'].forEach((pos, idx) => { const seat = [2, 3, 1][idx]; $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; $(`${pos}-score`).textContent = state.scores[seat]; $(`${pos}-cards`).innerHTML = ''; // no card backs if (state.currentTurn === seat && state.state === 'PLAYING') show(`${pos}-turn`); else hide(`${pos}-turn`); }); $('my-name').textContent = state.names[0] || 'P1'; $('my-score').textContent = state.scores[0]; $('my-hand-pts').textContent = ''; $('my-hand').innerHTML = ''; renderTrick(state); renderPhaseMsg(state); } function renderOppCards(containerId, count, vertical) { const el = $(containerId); if (!el) return; el.innerHTML = ''; const max = vertical ? 8 : 13; for (let i = 0; i < Math.min(count, max); i++) { const back = document.createElement('div'); back.className = 'card-back'; el.appendChild(back); } } function renderMyHand(hand, state) { const el = $('my-hand'); el.innerHTML = ''; applyHandMode(); if (!hand.length) return; const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat; const legal = isMyTurn ? legalCards(hand, state) : []; // "Only Playables" mode: when it's my turn, render ONLY legal cards // (all cards shown when not my turn or during passing — player still needs to plan) const displayHand = (handMode === 'playables' && isMyTurn && state.state === 'PLAYING') ? legal : hand; displayHand.forEach(code => { const div = document.createElement('div'); div.className = 'card'; if (isQoS(code)) div.classList.add('card-qos'); if (isHeart(code)) div.classList.add('card-heart'); if (state.state === 'PASSING') { const selected = state.passSelected || []; if (selected.includes(code)) div.classList.add('selected'); div.addEventListener('click', () => togglePassSelect(code, state)); } else if (isMyTurn) { // In playables mode every shown card is legal — no illegal overlay needed const isLegal = legal.includes(code); if (!isLegal) { div.classList.add('illegal'); } else { if (playMode === 'tap') { div.addEventListener('click', () => playCard(code)); } addDragHandlers(div, code); } } div.appendChild(cardImg(code)); el.appendChild(div); }); // Show/hide play-mode toggle (touch devices only, during play) if (!spectating) { if (isTouchDevice() && state.state === 'PLAYING') { show('btn-play-mode'); } else { hide('btn-play-mode'); } updatePlayModeBtn(); updateHandModeBtn(); } // Fan / Playables: dynamic overlap spacing; Scroll: center visible window if (handMode === 'fan' || handMode === 'playables') { requestAnimationFrame(updateHandSpacing); } else { requestAnimationFrame(() => { const overflow = el.scrollWidth - el.clientWidth; if (overflow > 0) el.scrollLeft = overflow / 2; }); } } function renderTrick(state) { const trick = state.trick || []; const seats = spectating ? { bottom: 0, right: 1, top: 2, left: 3 } : { bottom: mySeat, right: (mySeat + 1) % 4, top: (mySeat + 2) % 4, left: (mySeat + 3) % 4, }; ['top', 'left', 'right', 'bottom'].forEach(pos => { const slot = $(`trick-${pos}`); if (!slot) return; slot.innerHTML = ''; const seat = seats[pos]; const entry = trick.find(t => t.player === seat); if (entry) { slot.appendChild(cardImg(entry.card)); } }); } function renderPhaseMsg(state) { const el = $('phase-msg'); if (!el) return; const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating; if (isMyTurn !== wasMyTurn) { wasMyTurn = isMyTurn; handleTurnReminder(isMyTurn); } if (state.state === 'WAITING') { el.textContent = 'Waiting…'; } else if (state.state === 'PASSING') { const pending = state.passReady.filter(r => !r).length; el.textContent = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} passing…` : 'Exchanging…'; } else if (state.state === 'PLAYING') { if (state.trick.length === 0) { const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; el.textContent = state.currentTurn === mySeat ? 'Your lead' : `${name} leads`; } else { const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; el.textContent = state.currentTurn === mySeat ? 'Your turn' : `${name}…`; } } else { el.textContent = ''; } } // ── Pass overlay ────────────────────────────────────────────── // Clubs → Diamonds → Spades → Hearts, one row per suit const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H']; const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' }; function renderPassOverlay(state) { if (state.passDirection === 'hold') { hide('overlay-pass'); return; } const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; const selected = state.passSelected || []; $('pass-title').textContent = 'Pass 3 cards'; $('pass-hint').textContent = PASS_DIR_LABEL[state.passDirection] || ''; // Render hand grouped by suit, one row per suit const passHandEl = $('pass-hand'); passHandEl.innerHTML = ''; PASS_SUIT_ORDER.forEach(s => { const suitCards = myHand.filter(c => suitOf(c) === s); if (suitCards.length === 0) return; const row = document.createElement('div'); row.className = 'pass-suit-row'; const lbl = document.createElement('span'); lbl.className = 'pass-suit-label suit-' + s.toLowerCase(); lbl.textContent = PASS_SUIT_LABEL[s]; row.appendChild(lbl); const cardsWrap = document.createElement('div'); cardsWrap.className = 'pass-suit-cards'; suitCards.forEach(code => { const div = document.createElement('div'); div.className = 'card' + (selected.includes(code) ? ' selected' : ''); div.appendChild(cardImg(code)); div.addEventListener('click', () => togglePassSelect(code, state)); cardsWrap.appendChild(div); }); row.appendChild(cardsWrap); passHandEl.appendChild(row); }); // Preview selected const preview = $('pass-selected-preview'); preview.innerHTML = ''; for (let i = 0; i < 3; i++) { if (selected[i]) { const div = document.createElement('div'); div.className = 'card'; div.appendChild(cardImg(selected[i])); preview.appendChild(div); } else { const ph = document.createElement('div'); ph.className = 'pass-placeholder'; preview.appendChild(ph); } } // Confirm button $('btn-confirm-pass').disabled = selected.length !== 3; // Show waiting status const readyCount = state.passReady.filter(Boolean).length; $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : ''; // If already passed, show waiting message if (state.passReady[mySeat]) { $('btn-confirm-pass').disabled = true; $('pass-waiting').textContent = 'Waiting for others…'; } show('overlay-pass'); } let passSelectedLocal = []; function togglePassSelect(code, state) { if (state.passReady[mySeat]) return; // already passed const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; const selected = [...(state.passSelected || [])]; const idx = selected.indexOf(code); if (idx !== -1) { selected.splice(idx, 1); } else if (selected.length < 3) { selected.push(code); } else { return; // already 3 selected } // Optimistic update for immediate feedback; persist so server pushes don't wipe it state.passSelected = selected; passSelectedLocal = selected; renderPassOverlay(state); // Also update my-hand in game table renderMyHand(myHand, state); } function confirmPass() { const state = lastState; if (!state) return; const selected = state.passSelected || []; if (selected.length !== 3) return; passSelectedLocal = []; socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected }); } // ── Play ────────────────────────────────────────────────────── function playCard(code) { socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); } // ── Client-side legal card computation (mirrors server) ─────── function legalCards(hand, state) { const trick = state.trick || []; // First card of first trick if (trick.length === 0 && state.tricksPlayed === 0) { return hand.includes('C-2') ? ['C-2'] : hand; } // Leading if (trick.length === 0) { const nonHearts = hand.filter(c => !isHeart(c)); if (!state.heartsBroken && nonHearts.length > 0) return nonHearts; return hand; } // Following const leadSuit = suitOf(trick[0].card); const suitCards = hand.filter(c => suitOf(c) === leadSuit); if (suitCards.length > 0) return suitCards; // Can't follow — first trick avoids points if possible if (state.tricksPlayed === 0) { const safe = hand.filter(c => cardPoints(c) === 0); if (safe.length > 0) return safe; } return hand; } // ── Hand over overlay ───────────────────────────────────────── function showHandOverlay(state) { const moon = state.moonShooter; if (moon !== -1) { $('hand-result-icon').textContent = '🌙'; $('hand-result-title').textContent = `${state.names[moon]} shot the moon!`; $('hand-result-detail').textContent = 'Everyone else gets 26 points.'; } else { $('hand-result-icon').textContent = '🃏'; $('hand-result-title').textContent = 'Hand over'; $('hand-result-detail').textContent = ''; } const scoresEl = $('hand-result-scores'); scoresEl.innerHTML = ''; const deltas = state.handDeltas || [0,0,0,0]; state.names.forEach((name, i) => { const row = document.createElement('div'); row.className = 'result-row' + (moon === i ? ' moon' : ''); const nameEl = document.createElement('span'); nameEl.textContent = name || `P${i+1}`; const deltaEl = document.createElement('span'); deltaEl.className = 'result-delta ' + (deltas[i] === 0 ? 'zero' : deltas[i] >= 13 ? 'red' : ''); deltaEl.textContent = `+${deltas[i]} → ${state.scores[i]}`; row.appendChild(nameEl); row.appendChild(deltaEl); scoresEl.appendChild(row); }); show('overlay-hand'); } // ── Game over overlay ───────────────────────────────────────── function showGameOverOverlay(state) { const winners = Array.isArray(state.gameWinner) ? state.gameWinner : [state.gameWinner]; const minScore = Math.min(...state.scores); $('gameover-title').textContent = winners.length === 1 ? `${state.names[winners[0]]} wins!` : `Tie game!`; const scoresEl = $('gameover-scores'); scoresEl.innerHTML = ''; // Sort by score ascending for display const order = state.names.map((n, i) => i).sort((a, b) => state.scores[a] - state.scores[b]); order.forEach(i => { const row = document.createElement('div'); const isWin = winners.includes(i); row.className = 'go-row' + (isWin ? ' winner' : ''); const nameEl = document.createElement('span'); nameEl.className = 'go-name'; nameEl.textContent = state.names[i] || `P${i+1}`; const scoreEl = document.createElement('span'); scoreEl.className = 'go-score'; scoreEl.textContent = state.scores[i]; if (isWin) { const badge = document.createElement('span'); badge.className = 'go-badge'; badge.textContent = '🏆'; scoreEl.appendChild(badge); } row.appendChild(nameEl); row.appendChild(scoreEl); scoresEl.appendChild(row); }); show('overlay-gameover'); } // ─── Auth flow ──────────────────────────────────────────────── async function doLogin() { const username = $('auth-login-user').value.trim(); const password = $('auth-login-pass').value; $('auth-login-error').textContent = ''; if (!username || !password) { $('auth-login-error').textContent = 'Fill in all fields'; return; } const data = await apiFetch('/api/login', { method: 'POST', body: { username, password } }); if (data.error) { $('auth-login-error').textContent = data.error; return; } setAuth(data.token, data.username); hide('overlay-auth'); // Re-init socket with new token socket.disconnect(); initSocket(); } let regEmail = ''; async function doRegister() { const username = $('auth-reg-user').value.trim(); const email = $('auth-reg-email').value.trim(); const password = $('auth-reg-pass').value; $('auth-reg-error').textContent = ''; const body = { username, email, password }; const cfWidget = window.turnstile; if (cfWidget) body.cfToken = cfWidget.getResponse?.() || ''; const data = await apiFetch('/api/register/initiate', { method: 'POST', body }); if (data.error) { $('auth-reg-error').textContent = data.error; return; } if (data.done) { setAuth(data.token, data.username); hide('overlay-auth'); socket.disconnect(); initSocket(); return; } // Email verification step regEmail = data.email; $('reg-verify-hint').textContent = `A 6-digit code was sent to ${data.email}`; hide('reg-step-form'); show('reg-step-verify'); } async function doVerify() { const code = $('auth-reg-code').value.trim(); $('auth-verify-error').textContent = ''; const data = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } }); if (data.error) { $('auth-verify-error').textContent = data.error; return; } setAuth(data.token, data.username); hide('overlay-auth'); socket.disconnect(); initSocket(); } async function loadProfile() { if (!authUser) return; const data = await apiFetch(`/api/profile/${encodeURIComponent(authUser)}`); if (data.error) return; $('profile-username').textContent = data.username; $('stat-games-played').textContent = data.games_played || 0; $('stat-games-won').textContent = data.games_won || 0; $('stat-moon-shots').textContent = data.moon_shots || 0; $('stat-total-score').textContent = data.total_score || 0; // Admin panel if (data.username === (window._adminUser || '')) { show('admin-panel'); loadAdminState(); } else { hide('admin-panel'); } } async function loadAdminState() { const data = await apiFetch('/api/config'); if (data.signupsOpen !== undefined) { $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open ✓' : 'Closed ✗'; } } async function doChangePassword() { const cur = $('change-pass-current').value; const nw = $('change-pass-new').value; $('change-pass-msg').textContent = ''; const data = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } }); if (data.error) { $('change-pass-msg').textContent = data.error; return; } $('change-pass-msg').style.color = '#69f0ae'; $('change-pass-msg').textContent = 'Password updated!'; } async function loadLeaderboard() { const data = await apiFetch('/api/leaderboard'); if (!Array.isArray(data)) return; const tbody = $('lb-body'); tbody.innerHTML = ''; data.forEach((row, idx) => { const tr = document.createElement('tr'); if (idx < 3) tr.className = 'lb-top'; const avg = row.score_per_game != null ? row.score_per_game.toFixed(1) : '—'; tr.innerHTML = ` ${['🥇','🥈','🥉'][idx] || idx+1} ${escHtml(row.username)} ${avg} ${row.games_won} ${row.games_played} ${row.moon_shots} `; tbody.appendChild(tr); }); // Attach click handlers for player details tbody.querySelectorAll('.lb-btn').forEach(btn => { btn.addEventListener('click', () => showPlayerDetails(btn.dataset.user)); }); } async function showPlayerDetails(username) { const data = await apiFetch(`/api/profile/${encodeURIComponent(username)}`); if (data.error) return; const played = data.games_played || 0; const winRate = played ? ((data.games_won / played) * 100).toFixed(1) + '%' : '—'; const scorePerGame = played ? (data.total_score / played).toFixed(1) : '—'; $('details-username').textContent = data.username; $('details-body').innerHTML = ` Games Played${played} Games Won${data.games_won} Win Rate${winRate} Score / Game${scorePerGame} Moon Shots 🌙${data.moon_shots} Total Score${data.total_score} `; show('overlay-player-details'); } function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ─── Lobby helpers ──────────────────────────────────────────── function joinGame() { const name = $('input-name').value.trim(); const roomId = $('input-code').value.trim().toUpperCase(); if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } $('lobby-error').textContent = ''; myName = name; socket.emit('join', { name, roomId }); } async function loadPublicRooms() { const listEl = $('public-rooms-list'); if (!listEl) return; listEl.innerHTML = '

Loading…

'; try { const list = await apiFetch('/api/rooms'); if (!Array.isArray(list) || list.length === 0) { listEl.innerHTML = '

No public rooms right now.

'; return; } listEl.innerHTML = ''; list.forEach(room => { const row = document.createElement('div'); row.className = 'public-room-item'; row.innerHTML = `
${escHtml(room.hostName)}'s room ${room.playerCount}/4 · score limit ${room.winScore}
`; row.querySelector('.btn-join-pub').addEventListener('click', () => { $('input-code').value = room.id; joinGame(); }); listEl.appendChild(row); }); } catch { listEl.innerHTML = '

Failed to load rooms.

'; } } // ─── PWA install ────────────────────────────────────────────── window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; show('btn-install'); // Show banner after 3s setTimeout(() => { const banner = $('pwa-banner'); banner.classList.remove('hidden'); banner.classList.add('show'); }, 3000); }); // ─── Event listeners ────────────────────────────────────────── window.addEventListener('DOMContentLoaded', () => { // Load config (turnstile, signups) apiFetch('/api/config').then(cfg => { window._turnstileSiteKey = cfg.turnstileSiteKey; if (cfg.turnstileSiteKey) { // Load Turnstile script const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; document.head.appendChild(s); s.onload = () => { if (window.turnstile) { window.turnstile.render('#auth-captcha-wrap', { sitekey: cfg.turnstileSiteKey }); show('auth-captcha-wrap'); } }; } }); updateAuthBar(); initSocket(); // Score limit buttons document.querySelectorAll('.score-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedScore = parseInt(btn.dataset.score); }); }); // Lobby tabs document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $(`tab-${btn.dataset.tab}`)?.classList.add('active'); if (btn.dataset.tab === 'join') loadPublicRooms(); }); }); // Visibility toggle (public/private) document.querySelectorAll('.visibility-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedPublic = btn.dataset.public === 'true'; }); }); // Public rooms refresh $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); // Create game $('btn-create').addEventListener('click', () => { const name = $('input-name').value.trim(); if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } $('lobby-error').textContent = ''; myName = name; socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); }); // Join game $('btn-join').addEventListener('click', joinGame); // Spectate $('btn-spectate').addEventListener('click', () => { const roomId = $('input-spectate-code').value.trim().toUpperCase(); if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } spectating = true; socket.emit('spectate', { roomId }); }); // Leave waiting room $('btn-leave-waiting').addEventListener('click', () => { clearSession(); socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); myRoomId = null; mySeat = -1; myToken = null; showScreen('screen-lobby'); }); // Fill bots $('btn-fill-bots').addEventListener('click', () => { socket.emit('fillBots', { roomId: myRoomId }); }); // Copy room code $('btn-copy').addEventListener('click', () => { navigator.clipboard?.writeText($('display-room-code').textContent); }); // Pass overlay confirm $('btn-confirm-pass').addEventListener('click', confirmPass); // AFK banner $('afk-vote-btn').addEventListener('click', () => { socket.emit('voteAITakeover', { roomId: myRoomId }); hide('afk-banner'); }); $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); // Leave game $('btn-leave-game').addEventListener('click', () => { if (spectating) { spectating = false; clearSession(); showScreen('screen-lobby'); return; } show('overlay-exit-confirm'); hide('game-menu-dropdown'); }); $('btn-exit-confirm-yes').addEventListener('click', () => { hide('overlay-exit-confirm'); clearSession(); socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); myRoomId = null; mySeat = -1; myToken = null; spectating = false; wasMyTurn = false; handleTurnReminder(false); aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); // New game (after game over) $('btn-new-game').addEventListener('click', () => { hide('overlay-gameover'); clearSession(); myRoomId = null; mySeat = -1; myToken = null; spectating = false; wasMyTurn = false; handleTurnReminder(false); aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); // Game menu // Play mode toggle (tap ↔ drag) — touch only $('btn-play-mode').addEventListener('click', () => { playMode = playMode === 'drag' ? 'tap' : 'drag'; savePlayMode(playMode); updatePlayModeBtn(); if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); }); // Hand display mode — cycles: scroll → fan → playables → scroll $('btn-hand-mode').addEventListener('click', () => { const idx = HAND_MODES.indexOf(handMode); handMode = HAND_MODES[(idx + 1) % HAND_MODES.length]; saveHandMode(handMode); applyHandMode(); updateHandModeBtn(); if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); }); // Initialize mode buttons with saved preferences updatePlayModeBtn(); updateHandModeBtn(); applyHandMode(); if (!isTouchDevice()) hide('btn-play-mode'); $('btn-game-menu').addEventListener('click', (e) => { e.stopPropagation(); $('game-menu-dropdown').classList.toggle('hidden'); }); document.addEventListener('click', () => { hide('game-menu-dropdown'); }); $('btn-info-pos').addEventListener('click', () => { $('screen-game').classList.toggle('info-bottom'); hide('game-menu-dropdown'); }); $('btn-refresh-game').addEventListener('click', () => location.reload()); $('btn-exit-game').addEventListener('click', () => { show('overlay-exit-confirm'); hide('game-menu-dropdown'); }); // Auth modal $('btn-show-auth').addEventListener('click', () => show('overlay-auth')); $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); document.querySelectorAll('.auth-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); $(`auth-panel-${tab.dataset.authTab}`)?.classList.add('active'); }); }); $('btn-do-login').addEventListener('click', doLogin); $('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); $('btn-do-register').addEventListener('click', doRegister); $('btn-do-verify').addEventListener('click', doVerify); $('btn-reg-back').addEventListener('click', () => { hide('reg-step-verify'); show('reg-step-form'); $('auth-verify-error').textContent = ''; }); // Profile $('btn-show-profile').addEventListener('click', () => { loadProfile(); showScreen('screen-profile'); }); $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-logout').addEventListener('click', () => { setAuth(null, null); socket.disconnect(); initSocket(); }); $('btn-show-change-pass').addEventListener('click', () => { $('change-pass-form').classList.toggle('hidden'); }); $('btn-do-change-pass').addEventListener('click', doChangePassword); $('btn-toggle-signups').addEventListener('click', async () => { const data = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); if (data.signupsOpen !== undefined) { $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open ✓' : 'Closed ✗'; } }); // Leaderboard $('btn-show-leaderboard').addEventListener('click', () => { loadLeaderboard(); showScreen('screen-leaderboard'); }); $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); // PWA $('btn-install').addEventListener('click', () => { deferredInstallPrompt?.prompt(); }); $('pwa-banner-install').addEventListener('click', () => { deferredInstallPrompt?.prompt(); }); $('pwa-banner-dismiss').addEventListener('click', () => { $('pwa-banner').classList.remove('show'); }); // Recalculate fan spacing on resize / orientation change window.addEventListener('resize', () => { if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); }); });