Files

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