'use strict'; // ─── State ──────────────────────────────────────────────────── let socket; let myName = ''; let mySeat = -1; let myToken = null; let myRoomId = null; let spectating = false; let authToken = localStorage.getItem('shelem_token') || null; let authUser = localStorage.getItem('shelem_user') || null; let lastState = null; // Lobby selections let selectedJoker = true; let selectedScore = 505; let selectedPublic = false; let swapSelectedSeat = -1; // seat highlighted for swap in waiting room // Widow discard state let widowSelected = []; // Bid UI state let currentBidAmount = 85; let lastBidHandNumber = -1; // Turn reminder state let turnReminderTimer = null; let turnReminderActive = false; // Unlock Web Audio on first user gesture (required by iOS) let _audioCtx = null; function getAudioCtx() { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_audioCtx.state === 'suspended') _audioCtx.resume(); return _audioCtx; } document.addEventListener('pointerdown', () => getAudioCtx(), { once: true }); function playTurnChime() { try { const ctx = getAudioCtx(); const now = ctx.currentTime; // Two-note chime: G5 then B5 [[784, 0], [988, 0.18]].forEach(([freq, delay]) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, now + delay); gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45); osc.start(now + delay); osc.stop(now + delay + 0.5); }); } catch (e) { /* audio not available */ } } function handleTurnReminder(isMyTurn) { if (isMyTurn && !turnReminderActive) { turnReminderActive = true; $('my-area')?.classList.remove('turn-urgent'); turnReminderTimer = setTimeout(() => { $('my-area')?.classList.add('turn-urgent'); // Vibrate on Android; chime on iOS (vibrate not supported there) if (navigator.vibrate) { navigator.vibrate([300, 100, 300]); } else { playTurnChime(); } }, 5000); } else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) { clearTimeout(turnReminderTimer); turnReminderTimer = null; turnReminderActive = false; $('my-area')?.classList.remove('turn-urgent'); } } // ─── Play / hand-display mode (mirror of Hearts) ────────────── function isTouchDevice() { return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; } function loadPlayMode() { const s = localStorage.getItem('shelem_play_mode'); if (s === 'tap' || s === 'drag') return s; return isTouchDevice() ? 'drag' : 'tap'; } function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); } function loadHandMode() { const s = localStorage.getItem('shelem_hand_mode'); return ['scroll','fan','playables'].includes(s) ? s : 'scroll'; } function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); } function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; } function saveBarBottom(v) { localStorage.setItem('shelem_bar_bottom', v ? '1' : '0'); } let playMode = loadPlayMode(); let handMode = loadHandMode(); let barAtBottom = loadBarBottom(); function applyBarBottom() { document.getElementById('screen-game').classList.toggle('bar-bottom', barAtBottom); const btn = $('btn-toggle-bar'); if (btn) btn.textContent = barAtBottom ? '⬆ Bar to top' : '⬇ Bar to bottom'; } const HAND_MODES = ['scroll', 'fan', 'playables']; const HAND_LABELS = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; function updatePlayModeBtn() { const btn = $('btn-play-mode'); if (!btn) return; const drag = playMode === 'drag'; btn.textContent = drag ? '☝ Drag' : '👆 Tap'; btn.classList.toggle('drag-mode', drag); btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode'; } function updateHandModeBtn() { const btn = $('btn-hand-mode'); if (!btn) return; btn.textContent = HAND_LABELS[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); if (!fanLike && handEl) handEl.style.justifyContent = ''; } 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) { ml = -(Math.ceil(overflow / (n - 1)) + 2); handEl.style.justifyContent = 'flex-start'; } else { ml = Math.min(16, Math.floor(-overflow / (n - 1))); handEl.style.justifyContent = 'center'; } cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); } // Drag-to-play (touch devices) function addDragHandlers(cardEl, code, onPlay) { 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; 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; $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (!ghost) { ghost = document.createElement('div'); ghost.className = 'drag-ghost'; const gi = document.createElement('img'); gi.src = cardSvg(code); gi.alt = code; gi.draggable = false; ghost.appendChild(gi); 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'); $('my-hand')?.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) onPlay(code); } isDragging = false; }; cardEl.addEventListener('touchend', endDrag); cardEl.addEventListener('touchcancel', endDrag); } // ─── Helpers ────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(id) { const el = $(id); if (el) el.classList.remove('hidden'); } function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); } function cardSvg(code) { if (code === 'JOKER-COLOR') return '/cards/JOKER-1.svg'; if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg'; const [suit, rank] = code.split('-'); const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' }; const name = suitMap[suit]; if (rank === 'A') return `/cards/${name}-1.svg`; if (rank === 'J') return `/cards/${name}-11-JACK.svg`; if (rank === 'Q') return `/cards/${name}-12-QUEEN.svg`; if (rank === 'K') return `/cards/${name}-13-KING.svg`; return `/cards/${name}-${rank}.svg`; } function suitSymbol(suit) { return { C: '♣', D: '♦', H: '♥', S: '♠' }[suit] || suit; } function suitName(suit) { return { C: 'Clubs', D: 'Diamonds', H: 'Hearts', S: 'Spades' }[suit] || suit; } function teamOf(seat) { return seat % 2; } // Map logical seats to visual positions relative to mySeat // Returns the seat number for a visual slot function visualSeat(slot) { // slot: 'bottom'=me, 'top'=partner, 'left', 'right' if (mySeat < 0) return -1; const offsets = { bottom: 0, left: 1, top: 2, right: 3 }; return (mySeat + offsets[slot]) % 4; } // ─── Screens ────────────────────────────────────────────────── function showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); const el = $(id); if (el) el.classList.add('active'); } // ─── Card elements ───────────────────────────────────────────── // Card element for trick-area slots (includes joker badge) function makeTrickImg(code) { if (code === 'JOKER-COLOR' || code === 'JOKER-BLACK') { const wrap = document.createElement('div'); wrap.className = 'trick-card-wrap'; const img = document.createElement('img'); img.src = cardSvg(code); img.alt = code; img.draggable = false; wrap.appendChild(img); const badge = document.createElement('div'); badge.className = 'joker-badge ' + (code === 'JOKER-COLOR' ? 'joker-color-badge' : 'joker-black-badge'); badge.textContent = code === 'JOKER-COLOR' ? '🌈 Color · 20' : '⚫ Black · 15'; wrap.appendChild(badge); return wrap; } const img = document.createElement('img'); img.src = cardSvg(code); img.alt = code; img.draggable = false; return img; } function makeCardEl(code, opts = {}) { const el = document.createElement('div'); el.className = 'card'; el.dataset.card = code; const img = document.createElement('img'); img.src = cardSvg(code); img.alt = code; el.appendChild(img); // Overlay badge so players can instantly tell the two jokers apart if (code === 'JOKER-COLOR') { const badge = document.createElement('div'); badge.className = 'joker-badge joker-color-badge'; badge.textContent = '🌈 Color · 20'; el.appendChild(badge); } else if (code === 'JOKER-BLACK') { const badge = document.createElement('div'); badge.className = 'joker-badge joker-black-badge'; badge.textContent = '⚫ Black · 15'; el.appendChild(badge); } if (opts.onClick) el.addEventListener('click', () => opts.onClick(code, el)); if (opts.illegal) el.classList.add('illegal'); return el; } function makeCardBack() { const el = document.createElement('div'); el.className = 'card-back'; return el; } // ─── Lobby init ─────────────────────────────────────────────── function initLobby() { updateAuthBar(); // 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(); }); }); // Mode buttons document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedJoker = btn.dataset.joker === 'true'; }); }); // Score 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 = +btn.dataset.score; }); }); // Visibility toggle 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'; }); }); $('btn-create').addEventListener('click', createGame); $('btn-join').addEventListener('click', joinGame); $('btn-spectate').addEventListener('click', spectateGame); $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); $('btn-show-auth').addEventListener('click', () => { show('overlay-auth'); $('auth-login-user').focus(); }); $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); $('btn-show-leaderboard').addEventListener('click', showLeaderboard); $('btn-show-profile').addEventListener('click', () => showProfile(authUser)); $('btn-logout').addEventListener('click', doLogout); // Auth tabs 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); $('auth-reg-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); }); } function createGame() { const name = $('input-name').value.trim(); if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } $('lobby-error').textContent = ''; connectSocket(() => { socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore, isPublic: selectedPublic }); }); } async function loadPublicRooms() { const listEl = $('public-rooms-list'); listEl.innerHTML = '

Loading…

'; try { const r = await fetch('/api/rooms'); const list = await r.json(); if (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 · ${room.jokerMode ? '🃏' : '♠'} ${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.

'; } } function escHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function joinGame() { const name = $('input-name').value.trim(); const code = $('input-code').value.trim().toUpperCase(); if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } $('lobby-error').textContent = ''; connectSocket(() => { socket.emit('join', { name, roomId: code }); }); } function spectateGame() { const code = $('input-spectate-code').value.trim().toUpperCase(); if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } $('lobby-error').textContent = ''; spectating = true; connectSocket(() => { socket.emit('spectate', { roomId: code }); }); } // ─── Socket ─────────────────────────────────────────────────── function connectSocket(onReady) { if (socket?.connected) { onReady(); return; } if (!socket) { socket = io({ auth: { token: authToken } }); initSocketHandlers(); } socket.once('connect', onReady); } function initSocketHandlers() { // Persistent reconnect handler — auto-rejoin after any network drop mid-game socket.on('connect', () => { if (myRoomId && mySeat >= 0 && myToken) { socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } }); socket.on('created', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showWaitingRoom(); }); socket.on('joined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showWaitingRoom(); }); socket.on('spectating', ({ roomId }) => { myRoomId = roomId; mySeat = -1; showScreen('screen-game'); }); socket.on('rejoined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); }); socket.on('seatChanged', ({ seat, token }) => { mySeat = seat; myToken = token; saveSession(); }); // Server pushes this when an authenticated user connects with an in-progress game // on a different device (token on that device is unknown / stale) socket.on('hasActiveGame', ({ roomId, seat, token }) => { if (myRoomId) return; // already have a session, ignore myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); socket.emit('rejoin', { roomId, seat, token }); }); socket.on('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; }); socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; }); socket.on('rejoinError', () => { // Always clear stale session and return to lobby — hasActiveGame will // re-establish it for authenticated users who have a game on another device clearSession(); myRoomId = null; mySeat = -1; myToken = null; showScreen('screen-lobby'); }); socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; }); socket.on('playError', (msg) => { console.warn('playError:', msg); }); socket.on('roomInfo', render); socket.on('cardPlayed',render); socket.on('trickWon', render); socket.on('handOver', onHandOver); socket.on('gameOver', onGameOver); socket.on('disconnect', () => {}); } // ─── Session persistence (localStorage so it survives tab/app close) ────────── function saveSession() { localStorage.setItem('shelem_room', myRoomId); localStorage.setItem('shelem_seat', mySeat); localStorage.setItem('shelem_token', myToken); } function clearSession() { localStorage.removeItem('shelem_room'); localStorage.removeItem('shelem_seat'); localStorage.removeItem('shelem_token'); } function tryRejoin() { const room = localStorage.getItem('shelem_room'); const seat = localStorage.getItem('shelem_seat'); const token = localStorage.getItem('shelem_token'); if (!room || seat === null || !token) return; // Set inside the callback so the persistent 'connect' handler doesn't // also emit rejoin on the very first connection (would be a duplicate) connectSocket(() => { myRoomId = room; mySeat = +seat; myToken = token; socket.emit('rejoin', { roomId: room, seat: +seat, token }); }); } // ─── Waiting Room ────────────────────────────────────────────── function showWaitingRoom() { showScreen('screen-waiting'); } function renderWaitingRoom(st) { $('display-room-code').textContent = st.id; show('waiting-options'); $('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers'; $('waiting-score-label').textContent = `Win: ${st.winScore}`; // Public badge let pubBadge = $('waiting-public-badge'); if (!pubBadge) { pubBadge = document.createElement('span'); pubBadge.id = 'waiting-public-badge'; pubBadge.className = 'waiting-opt-label'; $('waiting-options').appendChild(pubBadge); } pubBadge.textContent = st.isPublic ? '🌐 Public' : '🔒 Private'; const isCreator = mySeat === 0; // Seat slots — with swap UI for creator const slots = document.querySelectorAll('.seat-slot'); slots.forEach(slot => { const s = +slot.dataset.seat; const name = slot.querySelector('.seat-name'); name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—'); if (isCreator) { slot.classList.toggle('seat-swappable', true); slot.classList.toggle('seat-swap-selected', swapSelectedSeat === s); slot.onclick = () => { if (swapSelectedSeat === -1) { swapSelectedSeat = s; } else if (swapSelectedSeat === s) { swapSelectedSeat = -1; } else { socket?.emit('swapSeats', { roomId: myRoomId, seatA: swapSelectedSeat, seatB: s }); swapSelectedSeat = -1; } renderWaitingRoom(lastState); }; } else { slot.classList.remove('seat-swappable', 'seat-swap-selected'); slot.onclick = null; } }); // Status text const humanCount = st.seats ? st.seats.filter(Boolean).length : 0; const botCount = st.bots.filter(Boolean).length; const filled = humanCount + botCount; let statusText = filled < 4 ? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…` : 'Ready to start!'; if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕'; $('waiting-status').textContent = statusText; // Creator controls if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls'); } // ─── Main render ────────────────────────────────────────────── function render(st) { if (!st) return; lastState = st; if (st.state === 'WAITING') { if (document.getElementById('screen-waiting')?.classList.contains('active')) { renderWaitingRoom(st); } return; } // Transition to game screen if needed if (!document.getElementById('screen-game')?.classList.contains('active')) { showScreen('screen-game'); } renderInfoBar(st); renderTable(st); renderOverlays(st); } // ─── Info bar ────────────────────────────────────────────────── function renderInfoBar(st) { // Team scores const t0names = [st.names[0], st.names[2]].filter(Boolean).join(' & '); const t1names = [st.names[1], st.names[3]].filter(Boolean).join(' & '); const ts0 = $('team-score-0'); const ts1 = $('team-score-1'); ts0.className = 'team-score team-a'; ts1.className = 'team-score team-b'; ts0.textContent = `${t0names || 'Team A'}: ${st.scores[0]}`; ts1.textContent = `${t1names || 'Team B'}: ${st.scores[1]}`; // Trump + bid const td = $('trump-display'); const bd = $('bid-display'); if (st.trump) { td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`; show('trump-display'); } else { hide('trump-display'); } if (st.highBid > 0 && st.state !== 'HAND_OVER' && st.state !== 'GAME_OVER') { bd.textContent = `Bid: ${st.highBid} (${st.names[st.highBidder] || '?'})`; show('bid-display'); } else { hide('bid-display'); } } // ─── Table ──────────────────────────────────────────────────── function renderTable(st) { const slots = { bottom: mySeat < 0 ? 0 : mySeat, top: mySeat < 0 ? 2 : (mySeat + 2) % 4, left: mySeat < 0 ? 1 : (mySeat + 1) % 4, right: mySeat < 0 ? 3 : (mySeat + 3) % 4 }; // Player labels & card backs for opponents renderOpponent('top', slots.top, st); renderOpponent('left', slots.left, st); renderOpponent('right', slots.right, st); // Trick area renderTrick(st, slots); // My hand renderMyHand(st); // My info if (mySeat >= 0) { $('my-name').textContent = st.names[mySeat] || 'You'; const myTeam = teamOf(mySeat); const partnerSeat = (mySeat + 2) % 4; $('my-score').textContent = st.scores[myTeam]; // Partner label color const pm = $('my-partner-label'); pm.textContent = `⇔ ${st.names[partnerSeat] || ''}`; pm.classList.remove('hidden'); // Turn dot st.currentTurn === mySeat && st.state === 'PLAYING' ? show('my-turn') : hide('my-turn'); // Tricks badge const tricks = st.trickWins[mySeat]; if (tricks > 0) { $('my-tricks').textContent = `${tricks} trick${tricks > 1 ? 's' : ''}`; show('my-tricks'); } else { hide('my-tricks'); } } // Phase message — whose turn it is let msg = ''; let myTurnNow = false; if (st.state === 'BIDDING') { const bidder = st.names[st.currentBidder] || '?'; msg = st.currentBidder === mySeat ? 'Your bid' : `${bidder} bidding`; } else if (st.state === 'WIDOW') { const dec = st.names[st.declarer] || '?'; msg = st.declarer === mySeat ? 'Pick widow' : `${dec} picking widow`; } else if (st.state === 'PLAYING') { const justWon = st.trick.length === 0 && st.lastTrickWinner >= 0 && st.tricksPlayed > 0; if (justWon) { const w = st.names[st.lastTrickWinner] || '?'; msg = st.lastTrickWinner === mySeat ? 'You won!' : `${w.slice(0,9)} won`; // Still need to lead — turn reminder must fire even during the "won" flash if (st.currentTurn === mySeat) myTurnNow = true; } else if (st.currentTurn === mySeat) { msg = 'Your turn!'; myTurnNow = true; } else if (st.currentTurn >= 0) { msg = `${(st.names[st.currentTurn] || '?').slice(0,9)}'s turn`; } } const pm = $('phase-msg'); pm.textContent = msg; pm.classList.toggle('your-turn', myTurnNow); handleTurnReminder(myTurnNow && !spectating); // Spectator banner spectating ? show('spectator-banner') : hide('spectator-banner'); } function renderOpponent(slot, seat, st) { const nameEl = $(`${slot}-name`); const turnEl = $(`${slot}-turn`); const scoreEl = $(`${slot}-score`); const countEl = $(`${slot}-count`); const partnerEl = $(`${slot}-partner`); nameEl.textContent = st.names[seat] || '—'; scoreEl.textContent = st.scores[teamOf(seat)]; if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`); else hide(`${slot}-turn`); // Card count badge if (countEl) { const n = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length; countEl.textContent = n > 0 ? `${n}` : ''; } // Partner badge for top player if (slot === 'top' && mySeat >= 0 && partnerEl) { const isPartner = teamOf(seat) === teamOf(mySeat); isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`); } } function renderTrick(st, slots) { const trickMap = {}; for (const t of st.trick) trickMap[t.player] = t.card; const positions = [ { id: 'trick-bottom', seat: slots.bottom }, { id: 'trick-top', seat: slots.top }, { id: 'trick-left', seat: slots.left }, { id: 'trick-right', seat: slots.right }, ]; for (const { id, seat } of positions) { const el = $(id); el.innerHTML = ''; if (trickMap[seat]) { el.appendChild(makeTrickImg(trickMap[seat])); } } // phase-msg for trick winner is handled in renderTable } function renderMyHand(st) { const handEl = $('my-hand'); handEl.innerHTML = ''; if (mySeat < 0) return; const myHand = st.hands[mySeat]; if (!Array.isArray(myHand) || myHand.length === 0) return; const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat; const legal = isMyTurn ? computeLegalCards(st, mySeat) : []; // In "playables" mode only show the legal cards when it's my turn const display = (handMode === 'playables' && isMyTurn) ? legal : myHand; function doPlay(code) { if (!isMyTurn) return; if (!computeLegalCards(st, mySeat).includes(code)) return; socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); } for (const code of display) { const isLegal = legal.length === 0 || legal.includes(code); const cardEl = makeCardEl(code, { illegal: isMyTurn && !isLegal, }); if (isMyTurn && isLegal) { if (playMode === 'tap') { cardEl.addEventListener('click', () => doPlay(code)); } addDragHandlers(cardEl, code, doPlay); } handEl.appendChild(cardEl); } // Show/hide play-mode toggle (touch-only, during play) if (!spectating) { if (isTouchDevice() && st.state === 'PLAYING') show('btn-play-mode'); else hide('btn-play-mode'); updatePlayModeBtn(); updateHandModeBtn(); } applyHandMode(); if (handMode === 'fan' || handMode === 'playables') { requestAnimationFrame(updateHandSpacing); } else { requestAnimationFrame(() => { const overflow = handEl.scrollWidth - handEl.clientWidth; if (overflow > 0) handEl.scrollLeft = overflow / 2; }); } } // ─── Legal cards (client-side mirror of server logic) ───────── function computeLegalCards(st, seat) { const hand = st.hands[seat]; const trick = st.trick; const trump = st.trump; if (!Array.isArray(hand)) return []; function isTrump(c) { if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return true; return c.split('-')[0] === trump; } function suitOf(c) { if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return 'JOKER'; return c.split('-')[0]; } if (trick.length === 0) { // First lead of the hand sets trump — jokers not allowed if (!trump) return hand.filter(c => c !== 'JOKER-COLOR' && c !== 'JOKER-BLACK'); return hand; } const ledCard = trick[0].card; const ledTrump = isTrump(ledCard); if (ledTrump) { const tc = hand.filter(c => isTrump(c)); return tc.length > 0 ? tc : hand; } const ls = suitOf(ledCard); const sc = hand.filter(c => suitOf(c) === ls); return sc.length > 0 ? sc : hand; } // ─── Overlays ───────────────────────────────────────────────── function renderOverlays(st) { hideAllOverlays(); if (st.state === 'BIDDING') { renderBiddingOverlay(st); } else if (st.state === 'WIDOW' && mySeat === st.declarer) { renderWidowOverlay(st); } else if (st.state === 'WIDOW' && mySeat !== st.declarer) { $('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`; } } function hideAllOverlays() { hide('overlay-bid'); hide('overlay-widow'); hide('overlay-hand'); // Restore actual hand mode if we temporarily forced fan mode during bidding if (isTouchDevice()) applyHandMode(); } // ─── Bidding overlay ─────────────────────────────────────────── function renderBiddingOverlay(st) { show('overlay-bid'); // On touch devices show hand in fan mode so players can see their cards while bidding if (isTouchDevice()) { $('my-hand')?.classList.add('fan-mode'); $('my-area')?.classList.add('fan-active'); requestAnimationFrame(updateHandSpacing); } const history = $('bid-history'); history.innerHTML = ''; for (let s = 0; s < 4; s++) { const row = document.createElement('div'); row.className = 'bid-row' + (st.currentBidder === s ? ' active-bidder' : ''); const nameSpan = document.createElement('span'); nameSpan.className = 'bid-name'; nameSpan.textContent = (st.names[s] || `Seat ${s+1}`) + (s === mySeat ? ' (You)' : ''); const valSpan = document.createElement('span'); valSpan.className = 'bid-val'; if (st.bids[s] === null) { valSpan.className += ' waiting'; valSpan.textContent = st.currentBidder === s ? '⟵ bidding' : '—'; } else if (st.bids[s] === 'pass') { valSpan.className += ' passed'; valSpan.textContent = 'Pass'; } else { valSpan.textContent = st.bids[s]; } row.appendChild(nameSpan); row.appendChild(valSpan); history.appendChild(row); } const myBid = st.bids[mySeat]; // myBid can be a previous bid (number) — player may rebid higher; only 'pass' locks them out const isMyTurn = st.currentBidder === mySeat && myBid !== 'pass' && !spectating; if (isMyTurn) { show('bid-controls'); hide('bid-waiting-msg'); const floorBid = st.jokerMode ? 105 : 85; const minBid = st.highBid > 0 ? st.highBid + 5 : floorBid; // Reset to minimum at the start of every new hand if (st.handNumber !== lastBidHandNumber) { currentBidAmount = minBid; lastBidHandNumber = st.handNumber; } if (currentBidAmount < minBid) currentBidAmount = minBid; $('bid-amount-display').textContent = currentBidAmount; } else { hide('bid-controls'); if (myBid === 'pass') { $('bid-waiting-msg').textContent = 'You passed — waiting for bidding to finish…'; } else { const bidder = st.names[st.currentBidder] || 'Another player'; $('bid-waiting-msg').textContent = `${bidder} is bidding…`; } show('bid-waiting-msg'); } } // ─── Widow overlay ──────────────────────────────────────────── function renderWidowOverlay(st) { widowSelected = []; show('overlay-widow'); const needed = st.widowSize; $('widow-title').textContent = `Pick up widow — discard ${needed} cards`; $('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`; renderWidowHand(st); updateWidowPreview(st); } const WIDOW_SUIT_ORDER = ['C', 'D', 'H', 'S']; const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' }; const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' }; function renderWidowHand(st) { const container = $('widow-hand'); container.innerHTML = ''; const hand = st.hands[mySeat]; if (!Array.isArray(hand)) return; function onCardClick(c, el) { const idx = widowSelected.indexOf(c); if (idx >= 0) { widowSelected.splice(idx, 1); el.classList.remove('selected'); } else { const needed = lastState?.widowSize || 4; if (widowSelected.length < needed) { widowSelected.push(c); el.classList.add('selected'); } } updateWidowPreview(lastState); } // Render cards grouped by suit (like Hearts pass overlay) WIDOW_SUIT_ORDER.forEach(s => { const suitCards = hand.filter(c => c.startsWith(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 = WIDOW_SUIT_LABEL[s]; row.appendChild(lbl); const wrap = document.createElement('div'); wrap.className = 'pass-suit-cards'; suitCards.forEach(code => { const card = makeCardEl(code, { onClick: onCardClick }); if (widowSelected.includes(code)) card.classList.add('selected'); wrap.appendChild(card); }); row.appendChild(wrap); container.appendChild(row); }); // Jokers as their own row at the end const jokers = hand.filter(c => c.startsWith('JOKER-')); if (jokers.length > 0) { const row = document.createElement('div'); row.className = 'pass-suit-row'; const lbl = document.createElement('span'); lbl.className = 'pass-suit-label'; lbl.textContent = '🃏'; row.appendChild(lbl); const wrap = document.createElement('div'); wrap.className = 'pass-suit-cards'; jokers.forEach(code => { const card = makeCardEl(code, { onClick: onCardClick }); if (widowSelected.includes(code)) card.classList.add('selected'); wrap.appendChild(card); }); row.appendChild(wrap); container.appendChild(row); } } function updateWidowPreview(st) { const needed = st?.widowSize || 4; const preview = $('widow-selected-preview'); preview.innerHTML = ''; for (let i = 0; i < needed; i++) { if (widowSelected[i]) { preview.appendChild(makeCardEl(widowSelected[i])); } else { const ph = document.createElement('div'); ph.className = 'pass-placeholder'; preview.appendChild(ph); } } $('btn-confirm-discard').disabled = widowSelected.length !== needed; } // ─── Trump overlay ──────────────────────────────────────────── function renderTrumpOverlay(st) { show('overlay-trump'); if (mySeat === st.declarer && !spectating) { show('trump-declare-inner'); hide('trump-waiting-inner'); } else { hide('trump-declare-inner'); show('trump-waiting-inner'); $('trump-waiting-msg').textContent = `${st.names[st.declarer] || 'Declarer'} is choosing the trump suit…`; } } // ─── Hand Over ──────────────────────────────────────────────── function onHandOver(st) { lastState = st; hideAllOverlays(); renderInfoBar(st); const dTeam = st.declarer >= 0 ? st.declarer % 2 : 0; const oTeam = 1 - dTeam; const dNames = [st.names[dTeam === 0 ? 0 : 1], st.names[dTeam === 0 ? 2 : 3]].join(' & '); const oNames = [st.names[oTeam === 0 ? 0 : 1], st.names[oTeam === 0 ? 2 : 3]].join(' & '); const dDelta = st.handDeltas?.[dTeam] ?? 0; const oDelta = st.handDeltas?.[oTeam] ?? 0; let icon = '🎴', title = 'Hand Over'; if (st.isShelemHand) { icon = '🎯'; title = 'SHELEM! All tricks won!'; } else if (dDelta < 0) { icon = '😬'; title = `${dNames} failed the bid`; } else { icon = '✅'; title = `${dNames} made the bid`; } $('hand-result-icon').textContent = icon; $('hand-result-title').textContent = title; $('hand-result-detail').textContent = `Bid: ${st.highBid} | Declarer earned: ${st.teamCardPoints?.[dTeam] ?? 0} pts`; const scoresEl = $('hand-result-scores'); scoresEl.innerHTML = ''; for (let team = 0; team < 2; team++) { const delta = st.handDeltas?.[team] ?? 0; const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]] .filter(Boolean).join(' & ') || `Team ${team + 1}`; const row = document.createElement('div'); row.className = 'result-score-row'; const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0; if (isWinner) row.classList.add('winner'); row.innerHTML = ` ${names} ${delta >= 0 ? '+' : ''}${delta} → ${st.scores[team]} `; scoresEl.appendChild(row); } show('overlay-hand'); // overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state) } // ─── Game Over ──────────────────────────────────────────────── function onGameOver(st) { lastState = st; hideAllOverlays(); hide('overlay-hand'); const winTeams = new Set(st.gameWinner || []); const myTeam = mySeat >= 0 ? teamOf(mySeat) : -1; const iWon = winTeams.has(myTeam); $('gameover-title').textContent = iWon ? '🏆 Your team wins!' : '🎴 Game Over'; const scoresEl = $('gameover-scores'); scoresEl.innerHTML = ''; for (let team = 0; team < 2; team++) { const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]] .filter(Boolean).join(' & ') || `Team ${team + 1}`; const row = document.createElement('div'); row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : ''); row.innerHTML = `${names}${st.scores[team]}`; scoresEl.appendChild(row); } show('overlay-gameover'); } // ─── Auth ───────────────────────────────────────────────────── function updateAuthBar() { if (authUser) { $('auth-status').textContent = `Logged in as ${authUser}`; show('btn-show-profile'); show('btn-logout'); hide('btn-show-auth'); } else { $('auth-status').textContent = 'Playing as guest'; hide('btn-show-profile'); hide('btn-logout'); show('btn-show-auth'); } } async function doLogin() { const username = $('auth-login-user').value.trim(); const password = $('auth-login-pass').value; $('auth-login-error').textContent = ''; try { const r = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const d = await r.json(); if (!r.ok) { $('auth-login-error').textContent = d.error || 'Login failed'; return; } authToken = d.token; authUser = d.username; localStorage.setItem('shelem_token', authToken); localStorage.setItem('shelem_user', authUser); hide('overlay-auth'); updateAuthBar(); if (socket) socket.auth = { token: authToken }; } catch { $('auth-login-error').textContent = 'Network error'; } } async function doRegister() { const username = $('auth-reg-user').value.trim(); const password = $('auth-reg-pass').value; $('auth-reg-error').textContent = ''; if (!username || !password) { $('auth-reg-error').textContent = 'All fields required'; return; } try { const r = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const d = await r.json(); if (!r.ok) { $('auth-reg-error').textContent = d.error || 'Registration failed'; return; } authToken = d.token; authUser = d.username; localStorage.setItem('shelem_token', authToken); localStorage.setItem('shelem_user', authUser); hide('overlay-auth'); updateAuthBar(); } catch { $('auth-reg-error').textContent = 'Network error'; } } function doLogout() { authToken = null; authUser = null; localStorage.removeItem('shelem_token'); localStorage.removeItem('shelem_user'); updateAuthBar(); } // ─── Profile ────────────────────────────────────────────────── async function showProfile(username) { if (!username) return; showScreen('screen-profile'); $('profile-username').textContent = username; try { const r = await fetch(`/api/profile/${encodeURIComponent(username)}`); const d = await r.json(); if (!r.ok) return; $('stat-games-played').textContent = d.games_played; $('stat-games-won').textContent = d.games_won; $('stat-shelem').textContent = d.shelemCount || 0; $('stat-total-score').textContent = d.total_score; // Change password button (only own profile) if (username === authUser) show('btn-show-change-pass'); else hide('btn-show-change-pass'); // Admin panel if (d.isAdmin) { show('admin-panel'); const toggleBtn = $('btn-toggle-signups'); toggleBtn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; toggleBtn.classList.toggle('signup-open', d.signupsOpen); } else { hide('admin-panel'); } } catch { /* ignore */ } } async function showLeaderboard() { showScreen('screen-leaderboard'); try { const r = await fetch('/api/leaderboard'); const rows = await r.json(); const tbody = $('lb-body'); tbody.innerHTML = ''; rows.forEach((row, i) => { const tr = document.createElement('tr'); tr.innerHTML = ` ${i + 1} ${row.username} ${row.score_per_game ?? '—'} ${row.games_won} ${row.games_played} ${row.shelemCount || 0} `; tbody.appendChild(tr); }); } catch { /* ignore */ } } // ─── Event wiring ────────────────────────────────────────────── function wireGameEvents() { // Waiting room $('btn-leave-waiting').addEventListener('click', () => { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); showScreen('screen-lobby'); }); $('btn-waiting-menu').addEventListener('click', () => { $('waiting-menu-dropdown').classList.toggle('hidden'); }); $('btn-waiting-reload').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('waiting-menu-dropdown'); }); $('btn-waiting-exit').addEventListener('click', () => { hide('waiting-menu-dropdown'); socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); showScreen('screen-lobby'); }); $('btn-copy').addEventListener('click', async () => { const code = $('display-room-code').textContent.trim(); const btn = $('btn-copy'); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(code); } else { const ta = document.createElement('textarea'); ta.value = code; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } btn.textContent = '✓'; btn.style.color = '#69f0ae'; } catch(e) { btn.textContent = '✗'; } setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500); }); $('btn-fill-bots').addEventListener('click', () => { socket?.emit('fillBots', { roomId: myRoomId }); }); $('btn-start-game').addEventListener('click', () => { socket?.emit('forceStart', { roomId: myRoomId }); }); // Game menu $('btn-game-menu').addEventListener('click', () => { $('game-menu-dropdown').classList.toggle('hidden'); }); document.addEventListener('click', e => { if (!e.target.closest('.game-menu-wrap')) { hide('game-menu-dropdown'); hide('waiting-menu-dropdown'); } }); $('btn-refresh-game').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('game-menu-dropdown'); }); $('btn-toggle-bar').addEventListener('click', () => { barAtBottom = !barAtBottom; saveBarBottom(barAtBottom); applyBarBottom(); hide('game-menu-dropdown'); }); $('btn-exit-game').addEventListener('click', () => { hide('game-menu-dropdown'); show('overlay-exit-confirm'); }); $('btn-leave-game').addEventListener('click', () => show('overlay-exit-confirm')); $('btn-exit-confirm-yes').addEventListener('click', () => { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); hide('overlay-exit-confirm'); hide('overlay-hand'); hide('overlay-gameover'); mySeat = -1; myRoomId = null; myToken = null; spectating = false; showScreen('screen-lobby'); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); // Bid controls $('btn-bid-plus').addEventListener('click', () => { currentBidAmount += 5; $('bid-amount-display').textContent = currentBidAmount; }); $('btn-bid-minus').addEventListener('click', () => { const floorBid = lastState?.jokerMode ? 105 : 85; const minBid = Math.max(floorBid, (lastState?.highBid || 0) + 5); if (currentBidAmount - 5 >= minBid) currentBidAmount -= 5; $('bid-amount-display').textContent = currentBidAmount; }); $('btn-do-bid').addEventListener('click', () => { if (!lastState) return; const minBid = Math.max(85, (lastState.highBid || 80) + 5); if (currentBidAmount < minBid) { currentBidAmount = minBid; } socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: currentBidAmount }); }); $('btn-do-pass').addEventListener('click', () => { socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: 'pass' }); }); // Discard confirm $('btn-confirm-discard').addEventListener('click', () => { if (widowSelected.length !== (lastState?.widowSize || 4)) return; socket?.emit('discard', { roomId: myRoomId, seat: mySeat, token: myToken, cards: widowSelected }); }); // Trump declaration document.querySelectorAll('.trump-suit-btn').forEach(btn => { btn.addEventListener('click', () => { socket?.emit('declareTrump', { roomId: myRoomId, seat: mySeat, token: myToken, trump: btn.dataset.suit }); }); }); // Play mode toggle (tap ↔ drag) $('btn-play-mode')?.addEventListener('click', () => { playMode = playMode === 'tap' ? 'drag' : 'tap'; savePlayMode(playMode); updatePlayModeBtn(); if (lastState) renderMyHand(lastState); }); // Hand display mode cycle: 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); updateHandModeBtn(); if (lastState) renderMyHand(lastState); }); // New game $('btn-new-game').addEventListener('click', () => { hide('overlay-gameover'); socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); mySeat = -1; myRoomId = null; myToken = null; showScreen('screen-lobby'); }); // Profile / leaderboard back buttons $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); // Change password $('btn-show-change-pass').addEventListener('click', () => { $('change-pass-form').classList.toggle('hidden'); }); $('btn-do-change-pass').addEventListener('click', async () => { const cur = $('change-pass-current').value; const nw = $('change-pass-new').value; $('change-pass-msg').textContent = ''; try { const r = await fetch('/api/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ currentPassword: cur, newPassword: nw }), }); const d = await r.json(); $('change-pass-msg').textContent = r.ok ? '✓ Password updated' : (d.error || 'Failed'); $('change-pass-msg').style.color = r.ok ? '#69f0ae' : '#ff6b6b'; } catch { $('change-pass-msg').textContent = 'Network error'; } }); $('btn-toggle-signups').addEventListener('click', async () => { try { const r = await fetch('/api/admin/toggle-signups', { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}` }, }); const d = await r.json(); if (r.ok) { const btn = $('btn-toggle-signups'); btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; btn.classList.toggle('signup-open', d.signupsOpen); } } catch { /* ignore */ } }); } // ─── Boot ───────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initLobby(); wireGameEvents(); applyBarBottom(); // Pre-fill name from auth if (authUser) $('input-name').value = authUser; // Try to rejoin previous session tryRejoin(); // For authenticated users with no local session, connect so the server // can push hasActiveGame (enables cross-device rejoin) if (authToken && !myRoomId) connectSocket(() => {}); });