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