dc051cbe45
Layout & engine borrowed from Shelem: waiting-room menu with seat swap and force-start, game menu with bar-to-bottom, escalating turn reminder, three hand modes defaulting to fan on touch, card-count badges instead of card backs, leave buttons inside phase overlays, version footer with tap-to-update. Seats now run clockwise from the viewer (left = +1, across = +2, right = +3) so play flows anti-clockwise on screen as the server advances it; the pass exchange was flipped to match, otherwise "pass left" delivered to the right. Game history: finished games are saved with a per-hand breakdown, readable from the profile and shown on the end-of-hand and end-of-game screens. Card flashing: the service worker precaches all 52 faces, each face is decoded once into a template image, and the renderers reconcile against existing DOM instead of rebuilding it, so card nodes and their images survive a re-render. Card-count integrity: a seat can no longer put two cards into one trick, and the server snapshots each trick start and rewinds to the last sound state if the four hands ever drift apart. Leaderboard: ranked by win rate with a 5-game minimum; below-threshold players are listed unranked. Moon shots are now recorded instead of always zero, and stat reads no longer create and persist a row per user on every request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1597 lines
61 KiB
JavaScript
1597 lines
61 KiB
JavaScript
'use strict';
|
|
|
|
// ─── State ────────────────────────────────────────────────────
|
|
let socket;
|
|
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;
|
|
|
|
// Lobby selections
|
|
let selectedScore = 100;
|
|
let selectedPublic = false;
|
|
let swapSelectedSeat = -1; // seat highlighted for swap in the waiting room
|
|
|
|
// Pass (3-card exchange) state
|
|
let passSelected = [];
|
|
let passActive = false; // true while the pass overlay owns a live selection
|
|
|
|
// Turn reminder / escalation state
|
|
let turnReminderTimer = null;
|
|
let turnReminderActive = false;
|
|
let turnEscalationLevel = 0;
|
|
|
|
// AFK / AI control state
|
|
let afkBannerVoted = false;
|
|
let aiControlledSeats = new Set();
|
|
|
|
// PWA
|
|
let deferredInstallPrompt = null;
|
|
|
|
// ─── Audio (unlocked on first 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;
|
|
[[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 { /* audio not available */ }
|
|
}
|
|
|
|
function applyTurnEscalation(level) {
|
|
const pm = $('phase-msg');
|
|
if (!pm) return;
|
|
pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
|
|
if (level > 0) pm.classList.add(`your-turn-lvl${level}`);
|
|
}
|
|
|
|
function handleTurnReminder(isMyTurn) {
|
|
if (isMyTurn && !turnReminderActive) {
|
|
turnReminderActive = true;
|
|
turnEscalationLevel = 0;
|
|
$('my-area')?.classList.remove('turn-urgent');
|
|
applyTurnEscalation(0);
|
|
// Escalate every 5 seconds: level 1 → 2 → 3 (vibrate/chime at level 3)
|
|
turnReminderTimer = setInterval(() => {
|
|
turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
|
|
applyTurnEscalation(turnEscalationLevel);
|
|
if (turnEscalationLevel >= 3) {
|
|
$('my-area')?.classList.add('turn-urgent');
|
|
if (navigator.vibrate) navigator.vibrate([300, 100, 300]);
|
|
else playTurnChime();
|
|
clearInterval(turnReminderTimer);
|
|
turnReminderTimer = null;
|
|
}
|
|
}, 5000);
|
|
} else if (!isMyTurn && turnReminderActive) {
|
|
if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
|
|
turnReminderActive = false;
|
|
turnEscalationLevel = 0;
|
|
applyTurnEscalation(0);
|
|
$('my-area')?.classList.remove('turn-urgent');
|
|
}
|
|
}
|
|
|
|
// ─── Play / 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');
|
|
if (['scroll', 'fan', 'playables'].includes(s)) return s;
|
|
return isTouchDevice() ? 'fan' : 'scroll';
|
|
}
|
|
function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); }
|
|
function loadBarBottom() { return localStorage.getItem('hearts_bar_bottom') === '1'; }
|
|
function saveBarBottom(v) { localStorage.setItem('hearts_bar_bottom', v ? '1' : '0'); }
|
|
|
|
let playMode = loadPlayMode();
|
|
let handMode = loadHandMode();
|
|
let barAtBottom = loadBarBottom();
|
|
|
|
function applyBarBottom() {
|
|
$('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 = '';
|
|
}
|
|
|
|
// Dynamic overlap for fan/playables mode: overlap just enough when the cards
|
|
// don't fit, spread up to 16px when they do.
|
|
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'; });
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// CARD ASSETS — loaded once, never refetched
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Cards used to visibly flash at the start of every trick because each render
|
|
// built brand-new <img> elements. Now every face is decoded once into a
|
|
// template image; everything on screen is a clone of that template, and the
|
|
// renderers below reuse existing DOM nodes instead of rebuilding them.
|
|
|
|
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',
|
|
};
|
|
const SUITS = ['C', 'D', 'H', 'S'];
|
|
const RANKS = ['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
|
|
const ALL_CARDS = SUITS.flatMap(s => RANKS.map(r => `${s}-${r}`));
|
|
|
|
function cardSvg(code) {
|
|
const [s, r] = code.split('-');
|
|
return `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`;
|
|
}
|
|
|
|
const cardTemplates = new Map();
|
|
function cardTemplate(code) {
|
|
let t = cardTemplates.get(code);
|
|
if (!t) {
|
|
t = new Image();
|
|
t.src = cardSvg(code);
|
|
t.alt = code;
|
|
t.draggable = false;
|
|
t.dataset.card = code;
|
|
cardTemplates.set(code, t);
|
|
}
|
|
return t;
|
|
}
|
|
// Cloning a loaded image reuses the decoded bitmap — no request, no flash.
|
|
function cardImg(code) { return cardTemplate(code).cloneNode(true); }
|
|
function preloadCards() { ALL_CARDS.forEach(cardTemplate); }
|
|
|
|
function makeCardEl(code) {
|
|
const el = document.createElement('div');
|
|
el.className = 'card';
|
|
el.dataset.card = code;
|
|
el.appendChild(cardImg(code));
|
|
wireCardDrag(el);
|
|
return el;
|
|
}
|
|
|
|
// Reconcile a row of cards against `codes`, keeping the DOM nodes that are
|
|
// already there. Returns the elements in display order.
|
|
function syncCards(container, codes) {
|
|
const existing = new Map();
|
|
for (const el of Array.from(container.children)) {
|
|
if (el.dataset?.card) existing.set(el.dataset.card, el);
|
|
}
|
|
const wanted = codes.map(code => existing.get(code) || makeCardEl(code));
|
|
const keep = new Set(wanted);
|
|
for (const el of existing.values()) if (!keep.has(el)) el.remove();
|
|
// appendChild/insertBefore on an existing node moves it; the image is untouched
|
|
wanted.forEach((el, i) => {
|
|
if (container.children[i] !== el) container.insertBefore(el, container.children[i] || null);
|
|
});
|
|
return wanted;
|
|
}
|
|
|
|
// A trick slot holds at most one card. Leave it alone if it already shows it.
|
|
function setSlotCard(slotId, code) {
|
|
const el = $(slotId);
|
|
if (!el) return;
|
|
const cur = el.firstElementChild;
|
|
if (!code) { if (cur) el.replaceChildren(); return; }
|
|
if (cur && cur.dataset.card === code) return;
|
|
el.replaceChildren(cardImg(code));
|
|
}
|
|
|
|
// ─── Drag-to-play (touch devices) ─────────────────────────────
|
|
// Wired once per card element; the mode checks happen at touch time so a mode
|
|
// switch doesn't require rebuilding the hand.
|
|
function wireCardDrag(cardEl) {
|
|
let touchStartY = 0, isDragging = false, ghost = null, magnifying = false;
|
|
|
|
cardEl.addEventListener('touchstart', e => {
|
|
if (!cardEl.classList.contains('playable')) return;
|
|
touchStartY = e.touches[0].clientY;
|
|
isDragging = false;
|
|
const count = $('my-hand')?.querySelectorAll('.card').length ?? 0;
|
|
magnifying = isTouchDevice() && playMode === 'drag'
|
|
&& (handMode === 'fan' || handMode === 'playables') && count > 8;
|
|
if (magnifying) cardEl.classList.add('magnified');
|
|
}, { passive: true });
|
|
|
|
cardEl.addEventListener('touchmove', e => {
|
|
if (!cardEl.classList.contains('playable')) return;
|
|
const dy = touchStartY - e.touches[0].clientY;
|
|
// Horizontal sweep with the magnifier picks a card without playing it
|
|
if (magnifying && !isDragging && dy < 20) {
|
|
cardEl.classList.remove('magnified');
|
|
const under = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
|
|
under?.closest('.card')?.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';
|
|
ghost.appendChild(cardImg(cardEl.dataset.card));
|
|
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 && cardEl.classList.contains('playable')) {
|
|
const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY;
|
|
if (endY < window.innerHeight * 0.70) playCard(cardEl.dataset.card);
|
|
}
|
|
isDragging = false;
|
|
};
|
|
cardEl.addEventListener('touchend', endDrag);
|
|
cardEl.addEventListener('touchcancel', endDrag);
|
|
}
|
|
|
|
// ─── 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 escHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
function suitOf(code) { return code.split('-')[0]; }
|
|
function isHeart(code) { return suitOf(code) === 'H'; }
|
|
function cardPoints(code) { return isHeart(code) ? 1 : code === 'S-Q' ? 13 : 0; }
|
|
|
|
const PASS_DIR_LABEL = {
|
|
left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)',
|
|
};
|
|
|
|
// Seats run clockwise from the viewer: left = +1, across = +2, right = +3.
|
|
// Play runs the other way (seat → seat+3), i.e. bottom → right → top → left.
|
|
function seatSlots() {
|
|
const base = mySeat < 0 ? 0 : mySeat;
|
|
return { bottom: base, left: (base + 1) % 4, top: (base + 2) % 4, right: (base + 3) % 4 };
|
|
}
|
|
|
|
// ─── Auth fetch 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();
|
|
}
|
|
|
|
// ─── Lobby init ───────────────────────────────────────────────
|
|
function initLobby() {
|
|
updateAuthBar();
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
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 = '';
|
|
});
|
|
}
|
|
|
|
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, winScore: selectedScore, isPublic: selectedPublic });
|
|
});
|
|
}
|
|
|
|
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 }));
|
|
}
|
|
|
|
async function loadPublicRooms() {
|
|
const listEl = $('public-rooms-list');
|
|
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
|
try {
|
|
const list = await fetch('/api/rooms').then(r => r.json());
|
|
if (!Array.isArray(list) || list.length === 0) {
|
|
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
|
|
return;
|
|
}
|
|
listEl.innerHTML = '';
|
|
list.forEach(room => {
|
|
const row = document.createElement('div');
|
|
row.className = 'public-room-item';
|
|
row.innerHTML = `
|
|
<div class="public-room-info">
|
|
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
|
|
<span class="public-room-meta">${room.playerCount}/4 · to ${room.winScore}</span>
|
|
</div>
|
|
<button class="btn-join-pub btn-primary" data-room="${escHtml(room.id)}">Join</button>`;
|
|
row.querySelector('.btn-join-pub').addEventListener('click', () => {
|
|
$('input-code').value = room.id;
|
|
joinGame();
|
|
});
|
|
listEl.appendChild(row);
|
|
});
|
|
} catch {
|
|
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
|
|
}
|
|
}
|
|
|
|
// ─── Socket ───────────────────────────────────────────────────
|
|
function connectSocket(onReady) {
|
|
if (socket?.connected) { onReady(); return; }
|
|
if (!socket) {
|
|
socket = io({ auth: { token: authToken } });
|
|
initSocketHandlers();
|
|
} else {
|
|
socket.auth = { token: authToken };
|
|
}
|
|
socket.once('connect', onReady);
|
|
}
|
|
|
|
// Auth is only read at (re)connect time, so bounce the socket after login.
|
|
function reauthSocket() {
|
|
if (!socket) return;
|
|
socket.auth = { token: authToken };
|
|
if (socket.connected) socket.disconnect().connect();
|
|
}
|
|
|
|
function initSocketHandlers() {
|
|
// 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();
|
|
showScreen('screen-waiting');
|
|
});
|
|
socket.on('joined', ({ roomId, seat, token }) => {
|
|
myRoomId = roomId; mySeat = seat; myToken = token;
|
|
saveSession();
|
|
showScreen('screen-waiting');
|
|
});
|
|
socket.on('spectating', ({ roomId }) => {
|
|
myRoomId = roomId; mySeat = -1; spectating = true;
|
|
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();
|
|
});
|
|
|
|
// Pushed when an authenticated user connects with a game running elsewhere
|
|
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
|
|
if (myRoomId) return;
|
|
myRoomId = roomId; mySeat = seat; myToken = token;
|
|
saveSession();
|
|
socket.emit('rejoin', { roomId, seat, token });
|
|
});
|
|
|
|
socket.on('joinError', msg => { $('lobby-error').textContent = msg || 'Could not join room.'; });
|
|
socket.on('spectateError', msg => { $('lobby-error').textContent = msg || 'Could not spectate.'; });
|
|
socket.on('rejoinError', () => {
|
|
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('passError', msg => console.warn('passError:', msg));
|
|
|
|
socket.on('roomInfo', render);
|
|
socket.on('cardPlayed', render);
|
|
socket.on('trickWon', onTrickWon);
|
|
socket.on('handOver', onHandOver);
|
|
socket.on('gameOver', onGameOver);
|
|
|
|
// The server rewound the hand because the card counts had drifted apart
|
|
socket.on('stateReverted', ({ trick } = {}) => {
|
|
$('revert-banner-msg').textContent =
|
|
`⚠ Card counts didn't match — the hand was rewound to the start of trick ${trick}.`;
|
|
show('revert-banner');
|
|
setTimeout(() => hide('revert-banner'), 6000);
|
|
});
|
|
|
|
socket.on('afkWarning', ({ seat, name } = {}) => {
|
|
afkBannerVoted = false;
|
|
const isMe = seat === mySeat;
|
|
$('afk-banner-msg').textContent = isMe
|
|
? "You haven't played for a minute!"
|
|
: `${name} hasn't played for 1 minute.`;
|
|
const btn = $('afk-vote-btn');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; }
|
|
if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn');
|
|
show('afk-banner');
|
|
});
|
|
socket.on('afkResolved', () => { afkBannerVoted = false; hide('afk-banner'); });
|
|
socket.on('aiControl', ({ seat, active } = {}) => {
|
|
if (active) aiControlledSeats.add(seat);
|
|
else aiControlledSeats.delete(seat);
|
|
updateAiControlBanner();
|
|
});
|
|
}
|
|
|
|
// ─── Session persistence ──────────────────────────────────────
|
|
// The seat token uses its own key — sharing 'hearts_token' with the auth JWT
|
|
// would overwrite the login on every join.
|
|
function saveSession() {
|
|
localStorage.setItem('hearts_room', myRoomId);
|
|
localStorage.setItem('hearts_seat', mySeat);
|
|
localStorage.setItem('hearts_seat_token', myToken);
|
|
}
|
|
function clearSession() {
|
|
localStorage.removeItem('hearts_room');
|
|
localStorage.removeItem('hearts_seat');
|
|
localStorage.removeItem('hearts_seat_token');
|
|
}
|
|
function tryRejoin() {
|
|
const room = localStorage.getItem('hearts_room');
|
|
const seat = localStorage.getItem('hearts_seat');
|
|
const token = localStorage.getItem('hearts_seat_token');
|
|
if (!room || seat === null || !token) return;
|
|
connectSocket(() => {
|
|
myRoomId = room; mySeat = +seat; myToken = token;
|
|
socket.emit('rejoin', { roomId: room, seat: +seat, token });
|
|
});
|
|
}
|
|
|
|
// ─── Waiting room ─────────────────────────────────────────────
|
|
function renderWaitingRoom(st) {
|
|
$('display-room-code').textContent = st.id;
|
|
show('waiting-options');
|
|
$('waiting-score-label').textContent = `To ${st.winScore} pts`;
|
|
$('waiting-public-badge').textContent = st.isPublic ? '🌐 Public' : '🔒 Private';
|
|
|
|
const isCreator = mySeat === 0;
|
|
|
|
document.querySelectorAll('.seat-slot').forEach(slot => {
|
|
const s = +slot.dataset.seat;
|
|
const name = slot.querySelector('.seat-name');
|
|
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—');
|
|
slot.classList.toggle('filled', !!st.names[s]);
|
|
|
|
if (isCreator) {
|
|
slot.classList.add('seat-swappable');
|
|
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;
|
|
}
|
|
});
|
|
|
|
const filled = (st.seats ? st.seats.filter(Boolean).length : 0) + st.bots.filter(Boolean).length;
|
|
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;
|
|
|
|
if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls');
|
|
}
|
|
|
|
// ─── Main render ──────────────────────────────────────────────
|
|
function render(st) {
|
|
if (!st) return;
|
|
lastState = st;
|
|
aiControlledSeats = new Set(st.aiControlledSeats || []);
|
|
updateAiControlBanner();
|
|
|
|
if (st.state === 'WAITING') {
|
|
if ($('screen-waiting')?.classList.contains('active')) renderWaitingRoom(st);
|
|
return;
|
|
}
|
|
|
|
// Only switch screens when we actually need to — re-running showScreen on
|
|
// every state push made the whole table blink.
|
|
if (!$('screen-game')?.classList.contains('active')) showScreen('screen-game');
|
|
|
|
// Reloading or rejoining during a result screen must bring the result back,
|
|
// not drop the player onto an empty table.
|
|
if (st.state === 'GAME_OVER') { onGameOver(st); return; }
|
|
if (st.state === 'HAND_OVER') { onHandOver(st); return; }
|
|
|
|
hide('overlay-hand', 'overlay-gameover');
|
|
|
|
renderInfoBar(st);
|
|
renderTable(st);
|
|
renderOverlays(st);
|
|
}
|
|
|
|
// ─── Info bar ─────────────────────────────────────────────────
|
|
function renderInfoBar(st) {
|
|
const scoreDiv = $('score-display');
|
|
const minScore = Math.min(...st.scores);
|
|
const soleLeader = st.scores.filter(s => s === minScore).length < 4;
|
|
|
|
// Reuse the four entry nodes rather than rebuilding them each tick
|
|
for (let i = 0; i < 4; i++) {
|
|
let entry = scoreDiv.children[i];
|
|
if (!entry) {
|
|
entry = document.createElement('div');
|
|
entry.innerHTML = '<span class="score-name"></span><span class="score-val"></span>';
|
|
scoreDiv.appendChild(entry);
|
|
}
|
|
entry.className = 'score-entry'
|
|
+ (i === mySeat ? ' my-score' : '')
|
|
+ (soleLeader && st.scores[i] === minScore ? ' leading' : '');
|
|
entry.firstChild.textContent = st.names[i] || `P${i + 1}`;
|
|
entry.lastChild.textContent = st.scores[i];
|
|
}
|
|
|
|
if (st.heartsBroken && st.state === 'PLAYING') show('hearts-broken-display');
|
|
else hide('hearts-broken-display');
|
|
|
|
if (st.state === 'PASSING') {
|
|
$('pass-dir-display').textContent = PASS_DIR_LABEL[st.passDirection] || st.passDirection;
|
|
show('pass-dir-display');
|
|
} else {
|
|
hide('pass-dir-display');
|
|
}
|
|
}
|
|
|
|
// ─── Table ────────────────────────────────────────────────────
|
|
function renderTable(st) {
|
|
const slots = seatSlots();
|
|
|
|
renderOpponent('top', slots.top, st);
|
|
renderOpponent('left', slots.left, st);
|
|
renderOpponent('right', slots.right, st);
|
|
|
|
renderTrick(st, slots);
|
|
renderMyHand(st);
|
|
|
|
if (mySeat < 0) {
|
|
// Spectator: the bottom of the table is seat 0's place
|
|
$('my-name').textContent = st.names[0] || 'P1';
|
|
$('my-score').textContent = st.scores[0];
|
|
hide('my-turn', 'my-hand-pts', 'btn-play-mode');
|
|
} else {
|
|
$('my-name').textContent = st.names[mySeat] || 'You';
|
|
$('my-score').textContent = st.scores[mySeat];
|
|
|
|
(st.currentTurn === mySeat && st.state === 'PLAYING') ? show('my-turn') : hide('my-turn');
|
|
|
|
// Points taken so far this hand (only ever my own — the server hides the rest)
|
|
const hp = st.handPoints[mySeat];
|
|
if (hp > 0) {
|
|
$('my-hand-pts').textContent = `+${hp} this hand`;
|
|
show('my-hand-pts');
|
|
} else {
|
|
hide('my-hand-pts');
|
|
}
|
|
}
|
|
|
|
// Phase message
|
|
let msg = '', myTurnNow = false;
|
|
if (st.state === 'PASSING') {
|
|
const pending = st.passReady.filter(r => !r).length;
|
|
msg = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} still passing…` : 'Exchanging…';
|
|
} 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 took it' : `${w.slice(0, 9)} took it`;
|
|
if (st.currentTurn === mySeat) myTurnNow = true;
|
|
} else if (st.currentTurn === mySeat) {
|
|
msg = st.trick.length === 0 ? 'Your lead!' : 'Your turn!';
|
|
myTurnNow = true;
|
|
} else if (st.currentTurn >= 0) {
|
|
const n = (st.names[st.currentTurn] || '?').slice(0, 9);
|
|
msg = st.trick.length === 0 ? `${n} leads` : `${n}'s turn`;
|
|
}
|
|
}
|
|
const pm = $('phase-msg');
|
|
pm.textContent = msg;
|
|
pm.classList.toggle('your-turn', myTurnNow);
|
|
|
|
handleTurnReminder(myTurnNow && !spectating);
|
|
spectating ? show('spectator-banner') : hide('spectator-banner');
|
|
}
|
|
|
|
function renderOpponent(slot, seat, st) {
|
|
$(`${slot}-name`).textContent = st.names[seat] || '—';
|
|
$(`${slot}-score`).textContent = st.scores[seat];
|
|
|
|
(st.currentTurn === seat && st.state === 'PLAYING') ? show(`${slot}-turn`) : hide(`${slot}-turn`);
|
|
|
|
// Card-count badge instead of a fan of card backs — cheaper and clearer,
|
|
// and it makes a count mismatch visible to the players immediately.
|
|
const countEl = $(`${slot}-count`);
|
|
if (countEl) {
|
|
const h = st.hands[seat];
|
|
const n = typeof h === 'number' ? h : (Array.isArray(h) ? h.length : 0);
|
|
countEl.textContent = n > 0 ? `${n}` : '';
|
|
}
|
|
}
|
|
|
|
function renderTrick(st, slots) {
|
|
const played = {};
|
|
for (const t of st.trick) played[t.player] = t.card;
|
|
setSlotCard('trick-bottom', played[slots.bottom]);
|
|
setSlotCard('trick-top', played[slots.top]);
|
|
setSlotCard('trick-left', played[slots.left]);
|
|
setSlotCard('trick-right', played[slots.right]);
|
|
}
|
|
|
|
function renderMyHand(st) {
|
|
const handEl = $('my-hand');
|
|
if (mySeat < 0) { handEl.replaceChildren(); return; }
|
|
|
|
const myHand = st.hands[mySeat];
|
|
if (!Array.isArray(myHand)) { handEl.replaceChildren(); return; }
|
|
|
|
const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat && !spectating;
|
|
const legal = isMyTurn ? computeLegalCards(st, mySeat) : [];
|
|
const legalSet = new Set(legal);
|
|
const passing = st.state === 'PASSING' && !spectating;
|
|
|
|
// "Playables" mode shows only the legal cards while it's my turn
|
|
const display = (handMode === 'playables' && isMyTurn) ? legal : myHand;
|
|
|
|
const els = syncCards(handEl, display);
|
|
els.forEach((el, i) => {
|
|
const code = display[i];
|
|
el.classList.toggle('illegal', isMyTurn && !legalSet.has(code));
|
|
el.classList.toggle('playable', isMyTurn && legalSet.has(code));
|
|
el.classList.toggle('selected', passing && passSelected.includes(code));
|
|
});
|
|
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
function onTrickWon(st) {
|
|
render(st);
|
|
// Flash the slot that took the trick
|
|
const slots = seatSlots();
|
|
const pos = Object.keys(slots).find(k => slots[k] === st.lastTrickWinner);
|
|
const slot = pos ? $(`trick-${pos}`) : null;
|
|
if (slot) {
|
|
slot.classList.add('trick-winner-flash');
|
|
setTimeout(() => slot.classList.remove('trick-winner-flash'), 600);
|
|
}
|
|
}
|
|
|
|
// ─── Legal cards (client-side mirror of the server rules) ─────
|
|
function computeLegalCards(st, seat) {
|
|
const hand = st.hands[seat];
|
|
if (!Array.isArray(hand)) return [];
|
|
const trick = st.trick || [];
|
|
|
|
// Opening lead of the hand must be 2♣
|
|
if (trick.length === 0 && st.tricksPlayed === 0) {
|
|
return hand.includes('C-2') ? ['C-2'] : hand;
|
|
}
|
|
// Leading: no hearts until they're broken
|
|
if (trick.length === 0) {
|
|
const nonHearts = hand.filter(c => !isHeart(c));
|
|
if (!st.heartsBroken && nonHearts.length > 0) return nonHearts;
|
|
return hand;
|
|
}
|
|
// Following: must follow suit if able
|
|
const leadSuit = suitOf(trick[0].card);
|
|
const suitCards = hand.filter(c => suitOf(c) === leadSuit);
|
|
if (suitCards.length > 0) return suitCards;
|
|
// Void on the first trick: no point cards if avoidable
|
|
if (st.tricksPlayed === 0) {
|
|
const safe = hand.filter(c => cardPoints(c) === 0);
|
|
if (safe.length > 0) return safe;
|
|
}
|
|
return hand;
|
|
}
|
|
|
|
function playCard(code) {
|
|
if (!socket || mySeat < 0) return;
|
|
socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code });
|
|
}
|
|
|
|
// ─── Overlays ─────────────────────────────────────────────────
|
|
function renderOverlays(st) {
|
|
const passingNow = st.state === 'PASSING' && mySeat >= 0 && !spectating
|
|
&& st.passDirection !== 'hold';
|
|
if (!passingNow) {
|
|
if (passActive) { passActive = false; passSelected = []; }
|
|
hide('overlay-pass');
|
|
return;
|
|
}
|
|
renderPassOverlay(st);
|
|
}
|
|
|
|
function hideAllOverlays() {
|
|
hide('overlay-pass', 'overlay-hand');
|
|
passActive = false;
|
|
}
|
|
|
|
// ─── Pass overlay ─────────────────────────────────────────────
|
|
const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H'];
|
|
const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' };
|
|
|
|
function renderPassOverlay(st) {
|
|
const alreadyPassed = st.passReady[mySeat];
|
|
|
|
if (!passActive) {
|
|
// First render of this PASSING phase — start from a clean selection
|
|
passActive = true;
|
|
passSelected = [];
|
|
show('overlay-pass');
|
|
} else {
|
|
// Re-render while open (a spectator joined, someone else passed, …).
|
|
// Keep the selection; only drop cards that are no longer in hand.
|
|
const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : [];
|
|
passSelected = passSelected.filter(c => hand.includes(c));
|
|
}
|
|
|
|
$('pass-title').textContent = 'Pass 3 cards';
|
|
$('pass-hint').textContent = PASS_DIR_LABEL[st.passDirection] || '';
|
|
|
|
renderPassHand(st, alreadyPassed);
|
|
updatePassPreview();
|
|
|
|
const readyCount = st.passReady.filter(Boolean).length;
|
|
if (alreadyPassed) {
|
|
$('btn-confirm-pass').disabled = true;
|
|
$('pass-waiting').textContent = `Waiting for others… (${readyCount}/4 ready)`;
|
|
} else {
|
|
$('btn-confirm-pass').disabled = passSelected.length !== 3;
|
|
$('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : '';
|
|
}
|
|
}
|
|
|
|
function renderPassHand(st, locked) {
|
|
const container = $('pass-hand');
|
|
const hand = st.hands[mySeat];
|
|
if (!Array.isArray(hand)) return;
|
|
|
|
// One row per suit, reusing the card nodes across renders
|
|
PASS_SUIT_ORDER.forEach(s => {
|
|
const suitCards = hand.filter(c => suitOf(c) === s);
|
|
let row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`);
|
|
if (suitCards.length === 0) { row?.remove(); return; }
|
|
if (!row) {
|
|
row = document.createElement('div');
|
|
row.className = 'pass-suit-row';
|
|
row.dataset.suit = s;
|
|
row.innerHTML = `<span class="pass-suit-label suit-${s.toLowerCase()}">${PASS_SUIT_LABEL[s]}</span>`
|
|
+ '<div class="pass-suit-cards"></div>';
|
|
container.appendChild(row);
|
|
}
|
|
const els = syncCards(row.querySelector('.pass-suit-cards'), suitCards);
|
|
els.forEach((el, i) => {
|
|
el.classList.toggle('selected', passSelected.includes(suitCards[i]));
|
|
el.classList.toggle('illegal', locked);
|
|
});
|
|
});
|
|
|
|
// Keep suit rows in a stable order
|
|
PASS_SUIT_ORDER.forEach(s => {
|
|
const row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`);
|
|
if (row) container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function togglePassSelect(code) {
|
|
if (!lastState || lastState.passReady[mySeat]) return;
|
|
const i = passSelected.indexOf(code);
|
|
if (i >= 0) passSelected.splice(i, 1);
|
|
else if (passSelected.length < 3) passSelected.push(code);
|
|
else return;
|
|
renderPassOverlay(lastState);
|
|
renderMyHand(lastState);
|
|
}
|
|
|
|
function updatePassPreview() {
|
|
const preview = $('pass-selected-preview');
|
|
preview.replaceChildren();
|
|
for (let i = 0; i < 3; i++) {
|
|
if (passSelected[i]) preview.appendChild(makeCardEl(passSelected[i]));
|
|
else {
|
|
const ph = document.createElement('div');
|
|
ph.className = 'pass-placeholder';
|
|
preview.appendChild(ph);
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmPass() {
|
|
if (passSelected.length !== 3) return;
|
|
socket?.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: passSelected });
|
|
}
|
|
|
|
// ─── Hand over ────────────────────────────────────────────────
|
|
function onHandOver(st) {
|
|
lastState = st;
|
|
hideAllOverlays();
|
|
renderInfoBar(st);
|
|
renderTable(st);
|
|
|
|
const moon = st.moonShooter;
|
|
if (moon !== -1) {
|
|
$('hand-result-icon').textContent = '🌙';
|
|
$('hand-result-title').textContent = `${st.names[moon] || 'Someone'} shot the moon!`;
|
|
$('hand-result-detail').textContent = moon === mySeat
|
|
? 'You score 0 — everyone else takes 26.'
|
|
: 'Everyone else takes 26 points.';
|
|
} else {
|
|
$('hand-result-icon').textContent = '🃏';
|
|
$('hand-result-title').textContent = 'Hand over';
|
|
const deltas = st.handDeltas || [0, 0, 0, 0];
|
|
const mine = mySeat >= 0 ? deltas[mySeat] : null;
|
|
$('hand-result-detail').textContent =
|
|
mine === 0 ? 'You took nothing this hand.'
|
|
: mine != null ? `You took ${mine} point${mine === 1 ? '' : 's'}.`
|
|
: '';
|
|
}
|
|
|
|
const scoresEl = $('hand-result-scores');
|
|
scoresEl.replaceChildren();
|
|
const deltas = st.handDeltas || [0, 0, 0, 0];
|
|
const minDelta = Math.min(...deltas);
|
|
st.names.forEach((name, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'result-score-row'
|
|
+ (moon === i ? ' winner' : '')
|
|
+ (i === mySeat ? ' mine' : '');
|
|
row.innerHTML = `
|
|
<span class="team-label">${escHtml(name || `P${i + 1}`)}</span>
|
|
<span class="delta ${deltas[i] === minDelta ? 'pos' : 'neg'}">+${deltas[i]}</span>
|
|
<span style="color:rgba(255,255,255,.6);font-size:.8rem">→ ${st.scores[i]}</span>`;
|
|
scoresEl.appendChild(row);
|
|
});
|
|
|
|
renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.names, st.scores);
|
|
show('overlay-hand');
|
|
}
|
|
|
|
// ─── Game over ────────────────────────────────────────────────
|
|
function onGameOver(st) {
|
|
lastState = st;
|
|
hideAllOverlays();
|
|
hide('afk-banner');
|
|
renderInfoBar(st);
|
|
renderTable(st);
|
|
|
|
const winners = new Set(Array.isArray(st.gameWinner) ? st.gameWinner : [st.gameWinner]);
|
|
const iWon = mySeat >= 0 && winners.has(mySeat);
|
|
$('gameover-title').textContent = iWon
|
|
? '🏆 You win!'
|
|
: winners.size > 1 ? 'Tie game!' : `${st.names[[...winners][0]] || 'Someone'} wins!`;
|
|
|
|
const scoresEl = $('gameover-scores');
|
|
scoresEl.replaceChildren();
|
|
// Lowest score first — that's the winner in Hearts
|
|
st.names
|
|
.map((n, i) => i)
|
|
.sort((a, b) => st.scores[a] - st.scores[b])
|
|
.forEach(i => {
|
|
const row = document.createElement('div');
|
|
row.className = 'gameover-row' + (winners.has(i) ? ' winner' : '');
|
|
row.innerHTML = `<span>${escHtml(st.names[i] || `P${i + 1}`)}</span>`
|
|
+ `<span style="font-weight:700">${st.scores[i]}${winners.has(i) ? ' 🏆' : ''}</span>`;
|
|
scoresEl.appendChild(row);
|
|
});
|
|
|
|
renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.names, st.scores);
|
|
show('overlay-gameover');
|
|
}
|
|
|
|
// ─── Round-by-round breakdown (shared) ────────────────────────
|
|
// One row per hand showing what each player picked up, then a ruled-off totals
|
|
// row with the running score. Used at hand-over, game-over and in history.
|
|
function renderRoundBreakdown(container, rounds, names, finalScores) {
|
|
if (!container) return;
|
|
if (!Array.isArray(rounds) || rounds.length === 0) {
|
|
container.innerHTML = '<p class="hint">No hands played yet.</p>';
|
|
return;
|
|
}
|
|
const totals = finalScores || rounds[rounds.length - 1].scores;
|
|
|
|
let html = '<div class="rb-scroll"><table class="round-breakdown"><thead><tr><th class="rb-round"></th>';
|
|
for (let i = 0; i < 4; i++) html += `<th>${escHtml(names?.[i] || `P${i + 1}`)}</th>`;
|
|
html += '</tr></thead><tbody>';
|
|
|
|
rounds.forEach(r => {
|
|
const deltas = r.deltas || [0, 0, 0, 0];
|
|
const best = Math.min(...deltas);
|
|
html += `<tr><td class="rb-round">H${r.hand}</td>`;
|
|
for (let i = 0; i < 4; i++) {
|
|
const cls = r.moonShooter === i ? 'rb-moon' : deltas[i] === best ? 'rb-best' : '';
|
|
html += `<td class="${cls}">${r.moonShooter === i ? '🌙 ' : ''}+${deltas[i]}</td>`;
|
|
}
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody><tfoot><tr class="rb-final"><td class="rb-round">Total</td>';
|
|
for (let i = 0; i < 4; i++) html += `<td>${totals[i]}</td>`;
|
|
html += '</tr></tfoot></table></div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ─── AI control banner ────────────────────────────────────────
|
|
function updateAiControlBanner() {
|
|
if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; }
|
|
const isMe = aiControlledSeats.has(mySeat);
|
|
const msg = isMe
|
|
? '🤖 AI is playing for you! Play a card to take back control.'
|
|
: `🤖 AI is playing for: ${[...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`).join(', ')}`;
|
|
$('ai-control-msg').textContent = msg;
|
|
$('ai-control-banner').classList.toggle('is-me', isMe);
|
|
show('ai-control-banner');
|
|
}
|
|
|
|
function clearGameState() {
|
|
aiControlledSeats.clear();
|
|
passSelected = [];
|
|
passActive = false;
|
|
handleTurnReminder(false);
|
|
hide('ai-control-banner', 'afk-banner', 'revert-banner',
|
|
'overlay-pass', 'overlay-hand', 'overlay-gameover');
|
|
}
|
|
|
|
// ─── Auth ─────────────────────────────────────────────────────
|
|
function updateAuthBar() {
|
|
if (authUser) {
|
|
$('auth-status').textContent = `Logged in as ${authUser}`;
|
|
show('btn-show-profile', 'btn-logout'); hide('btn-show-auth');
|
|
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');
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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; }
|
|
try {
|
|
const d = await apiFetch('/api/login', { method: 'POST', body: { username, password } });
|
|
if (d.error) { $('auth-login-error').textContent = d.error; return; }
|
|
setAuth(d.token, d.username);
|
|
hide('overlay-auth');
|
|
reauthSocket();
|
|
} catch { $('auth-login-error').textContent = 'Network error'; }
|
|
}
|
|
|
|
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 };
|
|
if (window.turnstile) body.cfToken = window.turnstile.getResponse?.() || '';
|
|
|
|
const d = await apiFetch('/api/register/initiate', { method: 'POST', body });
|
|
if (d.error) { $('auth-reg-error').textContent = d.error; return; }
|
|
if (d.done) {
|
|
setAuth(d.token, d.username);
|
|
hide('overlay-auth');
|
|
reauthSocket();
|
|
return;
|
|
}
|
|
regEmail = d.email;
|
|
$('reg-verify-hint').textContent = `A 6-digit code was sent to ${d.email}`;
|
|
hide('reg-step-form'); show('reg-step-verify');
|
|
}
|
|
|
|
async function doVerify() {
|
|
const code = $('auth-reg-code').value.trim();
|
|
$('auth-verify-error').textContent = '';
|
|
const d = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } });
|
|
if (d.error) { $('auth-verify-error').textContent = d.error; return; }
|
|
setAuth(d.token, d.username);
|
|
hide('overlay-auth');
|
|
reauthSocket();
|
|
}
|
|
|
|
function doLogout() {
|
|
setAuth(null, null);
|
|
reauthSocket();
|
|
}
|
|
|
|
// Drop a stale or corrupted token before we act on it, so we don't run around
|
|
// as a "logged in" ghost whose socket is actually an unauthenticated guest.
|
|
async function validateAuth() {
|
|
if (!authToken) return;
|
|
try {
|
|
const r = await fetch('/api/me', { headers: authHeaders() });
|
|
if (!r.ok) throw new Error('invalid');
|
|
const d = await r.json();
|
|
authUser = d.username;
|
|
localStorage.setItem('hearts_user', authUser);
|
|
} catch {
|
|
authToken = null; authUser = null;
|
|
localStorage.removeItem('hearts_token');
|
|
localStorage.removeItem('hearts_user');
|
|
}
|
|
updateAuthBar();
|
|
}
|
|
|
|
// ─── Profile / leaderboard ────────────────────────────────────
|
|
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-win-rate').textContent = (d.win_rate ?? 0) + '%';
|
|
$('stat-games-won').textContent = d.games_won || 0;
|
|
$('stat-games-played').textContent = d.games_played || 0;
|
|
$('stat-moon-shots').textContent = d.moon_shots || 0;
|
|
$('stat-avg-score').textContent = d.avg_score ?? 0;
|
|
$('stat-total-score').textContent = d.total_score || 0;
|
|
|
|
$('btn-show-history').dataset.username = username;
|
|
if (username === authUser) show('btn-show-change-pass');
|
|
else hide('btn-show-change-pass');
|
|
|
|
if (d.isAdmin) {
|
|
show('admin-panel');
|
|
const btn = $('btn-toggle-signups');
|
|
btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
|
|
btn.classList.toggle('signup-open', d.signupsOpen);
|
|
} else {
|
|
hide('admin-panel');
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function showLeaderboard() {
|
|
showScreen('screen-leaderboard');
|
|
try {
|
|
const rows = await fetch('/api/leaderboard').then(r => r.json());
|
|
const tbody = $('lb-body');
|
|
tbody.innerHTML = '';
|
|
let rank = 0, dividerDone = false;
|
|
|
|
rows.forEach(row => {
|
|
// Players below the minimum game count are listed after the ranking,
|
|
// under a divider, so a newcomer can see how close they are.
|
|
if (!row.ranked && !dividerDone) {
|
|
dividerDone = true;
|
|
const sep = document.createElement('tr');
|
|
sep.className = 'lb-divider';
|
|
sep.innerHTML = `<td colspan="8">Not yet ranked — needs ${
|
|
row.gamesToRank + row.games_played} games</td>`;
|
|
tbody.appendChild(sep);
|
|
}
|
|
|
|
const tr = document.createElement('tr');
|
|
let rankCell;
|
|
if (row.ranked) {
|
|
rank++;
|
|
tr.className = rank <= 3 ? 'lb-top' : '';
|
|
rankCell = ['🥇','🥈','🥉'][rank - 1] || rank;
|
|
} else {
|
|
tr.className = 'lb-provisional';
|
|
rankCell = '—';
|
|
}
|
|
tr.innerHTML = `
|
|
<td class="lb-rank">${rankCell}</td>
|
|
<td class="lb-name">${escHtml(row.username)}</td>
|
|
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</td>
|
|
<td>${row.games_won}</td>
|
|
<td>${row.games_played}${row.ranked ? '' : ` <span class="lb-togo">+${row.gamesToRank}</span>`}</td>
|
|
<td>${row.score_per_game ?? '—'}</td>
|
|
<td>${row.moon_shots || 0}</td>
|
|
<td><button class="lb-btn" data-user="${escHtml(row.username)}">▸</button></td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
tbody.querySelectorAll('.lb-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => showPlayerDetails(btn.dataset.user));
|
|
});
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function showPlayerDetails(username) {
|
|
const d = await apiFetch(`/api/profile/${encodeURIComponent(username)}`);
|
|
if (d.error) return;
|
|
$('details-username').textContent = d.username;
|
|
$('details-body').innerHTML = `
|
|
<tr><td>Games Played</td><td>${d.games_played}</td></tr>
|
|
<tr><td>Games Won</td><td>${d.games_won}</td></tr>
|
|
<tr><td>Win Rate</td><td>${d.win_rate}%</td></tr>
|
|
<tr><td>Avg Score / Game</td><td>${d.avg_score}</td></tr>
|
|
<tr><td>Moon Shots 🌙</td><td>${d.moon_shots}</td></tr>
|
|
<tr><td>Total Score</td><td>${d.total_score}</td></tr>`;
|
|
show('overlay-player-details');
|
|
}
|
|
|
|
// ─── Game history ─────────────────────────────────────────────
|
|
async function showHistory(username) {
|
|
if (!username) return;
|
|
showScreen('screen-history');
|
|
$('history-title').textContent = username === authUser ? 'Your Game History' : `${username}'s History`;
|
|
hide('history-detail');
|
|
show('history-list');
|
|
const listEl = $('history-list');
|
|
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
|
try {
|
|
const games = await fetch(`/api/history/${encodeURIComponent(username)}`).then(r => r.json());
|
|
if (!Array.isArray(games) || games.length === 0) {
|
|
listEl.innerHTML = '<p class="hint">No games played yet.</p>';
|
|
return;
|
|
}
|
|
listEl.innerHTML = '';
|
|
games.forEach(g => {
|
|
const row = document.createElement('div');
|
|
row.className = 'history-item' + (g.won ? ' won' : ' lost');
|
|
const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
const others = g.playerNames.filter((_, i) => i !== g.mySeat).join(', ');
|
|
row.innerHTML = `
|
|
<div class="history-item-main">
|
|
<span class="history-item-result">${g.won ? '🏆 Won' : '✖ Lost'}</span>
|
|
<span class="history-item-teams">vs ${escHtml(others)}</span>
|
|
</div>
|
|
<div class="history-item-meta">
|
|
<span>${g.myScore} pts · ${g.rounds.length} hand${g.rounds.length === 1 ? '' : 's'}</span>
|
|
<span class="history-item-date">${date}</span>
|
|
</div>`;
|
|
row.addEventListener('click', () => showHistoryDetail(g));
|
|
listEl.appendChild(row);
|
|
});
|
|
} catch {
|
|
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load history.</p>';
|
|
}
|
|
}
|
|
|
|
function showHistoryDetail(g) {
|
|
hide('history-list');
|
|
show('history-detail');
|
|
$('history-detail-title').textContent =
|
|
`${g.won ? '🏆 ' : ''}Game to ${g.winScore} — ${new Date(g.endedAt).toLocaleString()}`;
|
|
|
|
const scoresEl = $('history-detail-scores');
|
|
scoresEl.replaceChildren();
|
|
g.playerNames
|
|
.map((n, i) => i)
|
|
.sort((a, b) => g.finalScores[a] - g.finalScores[b])
|
|
.forEach(i => {
|
|
const row = document.createElement('div');
|
|
row.className = 'gameover-row' + (g.winners.includes(i) ? ' winner' : '');
|
|
row.innerHTML = `<span>${escHtml(g.playerNames[i] || `P${i + 1}`)}${i === g.mySeat ? ' (you)' : ''}</span>`
|
|
+ `<span style="font-weight:700">${g.finalScores[i]}</span>`;
|
|
scoresEl.appendChild(row);
|
|
});
|
|
|
|
renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.playerNames, g.finalScores);
|
|
}
|
|
|
|
// ─── Event wiring ─────────────────────────────────────────────
|
|
function leaveAndReturnToLobby() {
|
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
|
clearSession();
|
|
clearGameState();
|
|
mySeat = -1; myRoomId = null; myToken = null; spectating = false;
|
|
swapSelectedSeat = -1;
|
|
showScreen('screen-lobby');
|
|
}
|
|
|
|
function wireGameEvents() {
|
|
// ── Waiting room ──
|
|
$('btn-leave-waiting').addEventListener('click', leaveAndReturnToLobby);
|
|
$('btn-waiting-menu').addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
$('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');
|
|
leaveAndReturnToLobby();
|
|
});
|
|
$('btn-copy').addEventListener('click', async () => {
|
|
const code = $('display-room-code').textContent.trim();
|
|
const btn = $('btn-copy');
|
|
try {
|
|
if (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 { 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', e => {
|
|
e.stopPropagation();
|
|
$('game-menu-dropdown').classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', e => {
|
|
if (!e.target.closest('.game-menu-wrap')) {
|
|
hide('game-menu-dropdown', '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', () => {
|
|
if (spectating) { spectating = false; clearGameState(); showScreen('screen-lobby'); return; }
|
|
show('overlay-exit-confirm');
|
|
});
|
|
// Leave buttons inside phase overlays — those cover the info bar, so players
|
|
// need their own way out while passing.
|
|
document.querySelectorAll('[data-overlay-leave]').forEach(btn => {
|
|
btn.addEventListener('click', () => show('overlay-exit-confirm'));
|
|
});
|
|
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
|
hide('overlay-exit-confirm');
|
|
leaveAndReturnToLobby();
|
|
});
|
|
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
|
|
|
// ── Cards: one delegated tap handler each, so reused nodes never stack listeners ──
|
|
$('my-hand').addEventListener('click', e => {
|
|
const card = e.target.closest('.card');
|
|
if (!card || !card.classList.contains('playable')) return;
|
|
if (playMode !== 'tap') return;
|
|
playCard(card.dataset.card);
|
|
});
|
|
$('pass-hand').addEventListener('click', e => {
|
|
const card = e.target.closest('.card');
|
|
if (!card || card.classList.contains('illegal')) return;
|
|
togglePassSelect(card.dataset.card);
|
|
});
|
|
$('btn-confirm-pass').addEventListener('click', confirmPass);
|
|
|
|
// ── Display modes ──
|
|
$('btn-play-mode').addEventListener('click', () => {
|
|
playMode = playMode === 'tap' ? 'drag' : 'tap';
|
|
savePlayMode(playMode);
|
|
updatePlayModeBtn();
|
|
});
|
|
$('btn-hand-mode').addEventListener('click', () => {
|
|
handMode = HAND_MODES[(HAND_MODES.indexOf(handMode) + 1) % HAND_MODES.length];
|
|
saveHandMode(handMode);
|
|
updateHandModeBtn();
|
|
if (lastState) renderMyHand(lastState);
|
|
});
|
|
|
|
// ── Post-game ──
|
|
$('btn-new-game').addEventListener('click', () => {
|
|
hide('overlay-gameover');
|
|
leaveAndReturnToLobby();
|
|
});
|
|
|
|
// ── Screens ──
|
|
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
|
|
$('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby'));
|
|
$('btn-details-close').addEventListener('click', () => hide('overlay-player-details'));
|
|
$('btn-show-history').addEventListener('click', e =>
|
|
showHistory(e.currentTarget.dataset.username || authUser));
|
|
$('btn-history-back').addEventListener('click', () => showScreen('screen-profile'));
|
|
$('btn-history-detail-back').addEventListener('click', () => {
|
|
hide('history-detail'); show('history-list');
|
|
});
|
|
|
|
// ── 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 d = await apiFetch('/api/change-password', {
|
|
method: 'POST', body: { currentPassword: cur, newPassword: nw },
|
|
});
|
|
$('change-pass-msg').textContent = d.error ? d.error : '✓ Password updated';
|
|
$('change-pass-msg').style.color = d.error ? '#ff6b6b' : '#69f0ae';
|
|
} catch { $('change-pass-msg').textContent = 'Network error'; }
|
|
});
|
|
|
|
// ── AFK ──
|
|
$('afk-vote-btn').addEventListener('click', () => {
|
|
if (afkBannerVoted) return;
|
|
afkBannerVoted = true;
|
|
socket?.emit('voteAITakeover', { roomId: myRoomId });
|
|
const btn = $('afk-vote-btn');
|
|
btn.disabled = true; btn.textContent = 'Voted ✓';
|
|
});
|
|
$('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner'));
|
|
|
|
// ── Admin ──
|
|
$('btn-toggle-signups').addEventListener('click', async () => {
|
|
const d = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} });
|
|
if (d.signupsOpen !== undefined) {
|
|
const btn = $('btn-toggle-signups');
|
|
btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
|
|
btn.classList.toggle('signup-open', d.signupsOpen);
|
|
}
|
|
});
|
|
|
|
// ── PWA ──
|
|
$('btn-install').addEventListener('click', () => deferredInstallPrompt?.prompt());
|
|
$('pwa-banner-install').addEventListener('click', () => deferredInstallPrompt?.prompt());
|
|
$('pwa-banner-dismiss').addEventListener('click', () => $('pwa-banner').classList.remove('show'));
|
|
|
|
window.addEventListener('resize', () => {
|
|
if (handMode === 'fan' || handMode === 'playables') updateHandSpacing();
|
|
});
|
|
}
|
|
|
|
window.addEventListener('beforeinstallprompt', e => {
|
|
e.preventDefault();
|
|
deferredInstallPrompt = e;
|
|
show('btn-install');
|
|
setTimeout(() => {
|
|
const banner = $('pwa-banner');
|
|
banner.classList.remove('hidden');
|
|
banner.classList.add('show');
|
|
}, 3000);
|
|
});
|
|
|
|
// ─── Version footer ───────────────────────────────────────────
|
|
async function loadConfig() {
|
|
try {
|
|
const d = await fetch('/api/config').then(r => r.json());
|
|
if (d.version) $('app-version').textContent = `v${d.version} · tap to update`;
|
|
if (d.turnstileSiteKey) {
|
|
const s = document.createElement('script');
|
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
|
s.onload = () => {
|
|
if (window.turnstile) {
|
|
window.turnstile.render('#auth-captcha-wrap', { sitekey: d.turnstileSiteKey });
|
|
show('auth-captcha-wrap');
|
|
}
|
|
};
|
|
document.head.appendChild(s);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Tapping the version drops the service worker and every cache, so a phone
|
|
// stuck on stale assets can pull the latest build.
|
|
async function forceUpdate() {
|
|
const el = $('app-version');
|
|
if (el) el.textContent = 'Updating…';
|
|
try {
|
|
if ('serviceWorker' in navigator) {
|
|
const regs = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(regs.map(r => r.unregister()));
|
|
}
|
|
if (window.caches) {
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.map(k => caches.delete(k)));
|
|
}
|
|
} catch { /* ignore */ }
|
|
location.reload();
|
|
}
|
|
|
|
// ─── Boot ─────────────────────────────────────────────────────
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
preloadCards(); // decode every face once, up front
|
|
initLobby();
|
|
wireGameEvents();
|
|
applyBarBottom();
|
|
updatePlayModeBtn();
|
|
updateHandModeBtn();
|
|
applyHandMode();
|
|
if (!isTouchDevice()) hide('btn-play-mode');
|
|
loadConfig();
|
|
$('app-version').addEventListener('click', forceUpdate);
|
|
|
|
await validateAuth();
|
|
if (authUser) $('input-name').value = authUser;
|
|
|
|
tryRejoin();
|
|
// Authenticated with no local session: connect anyway so the server can push
|
|
// hasActiveGame for a cross-device rejoin
|
|
if (authToken && !myRoomId) connectSocket(() => {});
|
|
});
|