8e8478e45b
Full-stack multiplayer Shelem (Iranian trick-taking card game) with Socket.IO, JWT auth, bot players, joker mode, and mobile-friendly UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1255 lines
45 KiB
JavaScript
1255 lines
45 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('shelem_token') || null;
|
|
let authUser = localStorage.getItem('shelem_user') || null;
|
|
let lastState = null;
|
|
|
|
// Lobby selections
|
|
let selectedJoker = true;
|
|
let selectedScore = 505;
|
|
|
|
// Widow discard state
|
|
let widowSelected = [];
|
|
|
|
// Bid UI state
|
|
let currentBidAmount = 85;
|
|
let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount
|
|
|
|
// ─── Play / hand-display mode (mirror of Hearts) ──────────────
|
|
function isTouchDevice() {
|
|
return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
|
|
}
|
|
function loadPlayMode() {
|
|
const s = localStorage.getItem('shelem_play_mode');
|
|
if (s === 'tap' || s === 'drag') return s;
|
|
return isTouchDevice() ? 'drag' : 'tap';
|
|
}
|
|
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
|
|
function loadHandMode() {
|
|
const s = localStorage.getItem('shelem_hand_mode');
|
|
return ['scroll','fan','playables'].includes(s) ? s : 'scroll';
|
|
}
|
|
function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); }
|
|
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
|
|
function saveBarBottom(v) { localStorage.setItem('shelem_bar_bottom', v ? '1' : '0'); }
|
|
|
|
let playMode = loadPlayMode();
|
|
let handMode = loadHandMode();
|
|
let barAtBottom = loadBarBottom();
|
|
|
|
function applyBarBottom() {
|
|
document.getElementById('screen-game').classList.toggle('bar-bottom', barAtBottom);
|
|
const btn = $('btn-toggle-bar');
|
|
if (btn) btn.textContent = barAtBottom ? '⬆ Bar to top' : '⬇ Bar to bottom';
|
|
}
|
|
|
|
const HAND_MODES = ['scroll', 'fan', 'playables'];
|
|
const HAND_LABELS = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' };
|
|
|
|
function updatePlayModeBtn() {
|
|
const btn = $('btn-play-mode');
|
|
if (!btn) return;
|
|
const drag = playMode === 'drag';
|
|
btn.textContent = drag ? '☝ Drag' : '👆 Tap';
|
|
btn.classList.toggle('drag-mode', drag);
|
|
btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode';
|
|
}
|
|
function updateHandModeBtn() {
|
|
const btn = $('btn-hand-mode');
|
|
if (!btn) return;
|
|
btn.textContent = HAND_LABELS[handMode] || '📜 Scroll';
|
|
btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables');
|
|
}
|
|
function applyHandMode() {
|
|
const fanLike = handMode === 'fan' || handMode === 'playables';
|
|
const handEl = $('my-hand');
|
|
handEl?.classList.toggle('fan-mode', fanLike);
|
|
$('my-area')?.classList.toggle('fan-active', fanLike);
|
|
if (!fanLike && handEl) handEl.style.justifyContent = '';
|
|
}
|
|
function updateHandSpacing() {
|
|
const handEl = $('my-hand');
|
|
if (!handEl || handMode === 'scroll') return;
|
|
const cards = Array.from(handEl.querySelectorAll('.card'));
|
|
const n = cards.length;
|
|
if (n === 0) return;
|
|
if (n === 1) { cards[0].style.marginLeft = '0'; return; }
|
|
const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68;
|
|
const containerW = handEl.offsetWidth || (window.innerWidth - 24);
|
|
const overflow = n * cardW - containerW;
|
|
let ml;
|
|
if (overflow > 0) {
|
|
ml = -(Math.ceil(overflow / (n - 1)) + 2);
|
|
handEl.style.justifyContent = 'flex-start';
|
|
} else {
|
|
ml = Math.min(16, Math.floor(-overflow / (n - 1)));
|
|
handEl.style.justifyContent = 'center';
|
|
}
|
|
cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; });
|
|
}
|
|
|
|
// Drag-to-play (touch devices)
|
|
function addDragHandlers(cardEl, code, onPlay) {
|
|
const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0;
|
|
const useMagnifier = isTouchDevice() && playMode === 'drag'
|
|
&& (handMode === 'fan' || handMode === 'playables')
|
|
&& cardCount > 8;
|
|
let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null;
|
|
|
|
cardEl.addEventListener('touchstart', e => {
|
|
touchStartY = e.touches[0].clientY;
|
|
touchStartX = e.touches[0].clientX;
|
|
isDragging = false;
|
|
if (useMagnifier) cardEl.classList.add('magnified');
|
|
}, { passive: true });
|
|
|
|
cardEl.addEventListener('touchmove', e => {
|
|
const dy = touchStartY - e.touches[0].clientY;
|
|
if (useMagnifier && !isDragging && dy < 20) {
|
|
cardEl.classList.remove('magnified');
|
|
const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
|
|
const tgt = el2?.closest('.card');
|
|
if (tgt) tgt.classList.add('magnified');
|
|
return;
|
|
}
|
|
if (dy > 20) {
|
|
e.preventDefault();
|
|
isDragging = true;
|
|
$('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
|
|
if (!ghost) {
|
|
ghost = document.createElement('div');
|
|
ghost.className = 'drag-ghost';
|
|
const gi = document.createElement('img');
|
|
gi.src = cardSvg(code); gi.alt = code; gi.draggable = false;
|
|
ghost.appendChild(gi);
|
|
document.body.appendChild(ghost);
|
|
$('trick-area')?.classList.add('drag-active');
|
|
}
|
|
ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px';
|
|
ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px';
|
|
}
|
|
}, { passive: false });
|
|
|
|
const endDrag = e => {
|
|
if (ghost) { ghost.remove(); ghost = null; }
|
|
$('trick-area')?.classList.remove('drag-active');
|
|
$('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
|
|
if (isDragging) {
|
|
const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY;
|
|
if (endY < window.innerHeight * 0.70) onPlay(code);
|
|
}
|
|
isDragging = false;
|
|
};
|
|
cardEl.addEventListener('touchend', endDrag);
|
|
cardEl.addEventListener('touchcancel', endDrag);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────
|
|
function $(id) { return document.getElementById(id); }
|
|
function show(id) { const el = $(id); if (el) el.classList.remove('hidden'); }
|
|
function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); }
|
|
|
|
function cardSvg(code) {
|
|
if (code === 'JOKER-COLOR') return '/cards/JOKER-1.svg';
|
|
if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg';
|
|
const [suit, rank] = code.split('-');
|
|
const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' };
|
|
const name = suitMap[suit];
|
|
if (rank === 'A') return `/cards/${name}-1.svg`;
|
|
if (rank === 'J') return `/cards/${name}-11-JACK.svg`;
|
|
if (rank === 'Q') return `/cards/${name}-12-QUEEN.svg`;
|
|
if (rank === 'K') return `/cards/${name}-13-KING.svg`;
|
|
return `/cards/${name}-${rank}.svg`;
|
|
}
|
|
|
|
function suitSymbol(suit) {
|
|
return { C: '♣', D: '♦', H: '♥', S: '♠' }[suit] || suit;
|
|
}
|
|
|
|
function suitName(suit) {
|
|
return { C: 'Clubs', D: 'Diamonds', H: 'Hearts', S: 'Spades' }[suit] || suit;
|
|
}
|
|
|
|
function teamOf(seat) { return seat % 2; }
|
|
|
|
// Map logical seats to visual positions relative to mySeat
|
|
// Returns the seat number for a visual slot
|
|
function visualSeat(slot) {
|
|
// slot: 'bottom'=me, 'top'=partner, 'left', 'right'
|
|
if (mySeat < 0) return -1;
|
|
const offsets = { bottom: 0, left: 1, top: 2, right: 3 };
|
|
return (mySeat + offsets[slot]) % 4;
|
|
}
|
|
|
|
// ─── Screens ──────────────────────────────────────────────────
|
|
function showScreen(id) {
|
|
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
|
const el = $(id);
|
|
if (el) el.classList.add('active');
|
|
}
|
|
|
|
// ─── Card elements ─────────────────────────────────────────────
|
|
// Card element for trick-area slots (includes joker badge)
|
|
function makeTrickImg(code) {
|
|
if (code === 'JOKER-COLOR' || code === 'JOKER-BLACK') {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'trick-card-wrap';
|
|
const img = document.createElement('img');
|
|
img.src = cardSvg(code);
|
|
img.alt = code;
|
|
img.draggable = false;
|
|
wrap.appendChild(img);
|
|
const badge = document.createElement('div');
|
|
badge.className = 'joker-badge ' + (code === 'JOKER-COLOR' ? 'joker-color-badge' : 'joker-black-badge');
|
|
badge.textContent = code === 'JOKER-COLOR' ? '🌈 Color · 20' : '⚫ Black · 15';
|
|
wrap.appendChild(badge);
|
|
return wrap;
|
|
}
|
|
const img = document.createElement('img');
|
|
img.src = cardSvg(code);
|
|
img.alt = code;
|
|
img.draggable = false;
|
|
return img;
|
|
}
|
|
|
|
function makeCardEl(code, opts = {}) {
|
|
const el = document.createElement('div');
|
|
el.className = 'card';
|
|
el.dataset.card = code;
|
|
const img = document.createElement('img');
|
|
img.src = cardSvg(code);
|
|
img.alt = code;
|
|
el.appendChild(img);
|
|
// Overlay badge so players can instantly tell the two jokers apart
|
|
if (code === 'JOKER-COLOR') {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'joker-badge joker-color-badge';
|
|
badge.textContent = '🌈 Color · 20';
|
|
el.appendChild(badge);
|
|
} else if (code === 'JOKER-BLACK') {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'joker-badge joker-black-badge';
|
|
badge.textContent = '⚫ Black · 15';
|
|
el.appendChild(badge);
|
|
}
|
|
if (opts.onClick) el.addEventListener('click', () => opts.onClick(code, el));
|
|
if (opts.illegal) el.classList.add('illegal');
|
|
return el;
|
|
}
|
|
|
|
function makeCardBack() {
|
|
const el = document.createElement('div');
|
|
el.className = 'card-back';
|
|
return el;
|
|
}
|
|
|
|
// ─── Lobby init ───────────────────────────────────────────────
|
|
function initLobby() {
|
|
updateAuthBar();
|
|
|
|
// Tabs
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
$('tab-' + btn.dataset.tab)?.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Mode buttons
|
|
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
selectedJoker = btn.dataset.joker === 'true';
|
|
});
|
|
});
|
|
|
|
// Score buttons
|
|
document.querySelectorAll('.score-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
selectedScore = +btn.dataset.score;
|
|
});
|
|
});
|
|
|
|
$('btn-create').addEventListener('click', createGame);
|
|
$('btn-join').addEventListener('click', joinGame);
|
|
$('btn-spectate').addEventListener('click', spectateGame);
|
|
|
|
$('btn-show-auth').addEventListener('click', () => {
|
|
show('overlay-auth');
|
|
$('auth-login-user').focus();
|
|
});
|
|
$('btn-auth-close').addEventListener('click', () => hide('overlay-auth'));
|
|
$('btn-show-leaderboard').addEventListener('click', showLeaderboard);
|
|
$('btn-show-profile').addEventListener('click', () => showProfile(authUser));
|
|
$('btn-logout').addEventListener('click', doLogout);
|
|
|
|
// Auth tabs
|
|
document.querySelectorAll('.auth-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
$('auth-panel-' + tab.dataset.authTab)?.classList.add('active');
|
|
});
|
|
});
|
|
|
|
$('btn-do-login').addEventListener('click', doLogin);
|
|
$('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
|
$('btn-do-register').addEventListener('click', doRegister);
|
|
$('auth-reg-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); });
|
|
}
|
|
|
|
function createGame() {
|
|
const name = $('input-name').value.trim();
|
|
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
|
|
$('lobby-error').textContent = '';
|
|
connectSocket(() => {
|
|
socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore });
|
|
});
|
|
}
|
|
|
|
function joinGame() {
|
|
const name = $('input-name').value.trim();
|
|
const code = $('input-code').value.trim().toUpperCase();
|
|
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
|
|
if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; }
|
|
$('lobby-error').textContent = '';
|
|
connectSocket(() => {
|
|
socket.emit('join', { name, roomId: code });
|
|
});
|
|
}
|
|
|
|
function spectateGame() {
|
|
const code = $('input-spectate-code').value.trim().toUpperCase();
|
|
if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; }
|
|
$('lobby-error').textContent = '';
|
|
spectating = true;
|
|
connectSocket(() => {
|
|
socket.emit('spectate', { roomId: code });
|
|
});
|
|
}
|
|
|
|
// ─── Socket ───────────────────────────────────────────────────
|
|
function connectSocket(onReady) {
|
|
if (socket?.connected) { onReady(); return; }
|
|
socket = io({ auth: { token: authToken } });
|
|
|
|
socket.on('connect', onReady);
|
|
|
|
socket.on('created', ({ roomId, seat, token }) => {
|
|
myRoomId = roomId; mySeat = seat; myToken = token;
|
|
saveSession();
|
|
showWaitingRoom();
|
|
});
|
|
socket.on('joined', ({ roomId, seat, token }) => {
|
|
myRoomId = roomId; mySeat = seat; myToken = token;
|
|
saveSession();
|
|
showWaitingRoom();
|
|
});
|
|
socket.on('spectating', ({ roomId }) => {
|
|
myRoomId = roomId; mySeat = -1;
|
|
showScreen('screen-game');
|
|
});
|
|
socket.on('rejoined', ({ roomId, seat, token }) => {
|
|
myRoomId = roomId; mySeat = seat; myToken = token;
|
|
});
|
|
|
|
socket.on('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; });
|
|
socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; });
|
|
socket.on('rejoinError', () => { clearSession(); });
|
|
socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; });
|
|
socket.on('playError', (msg) => { console.warn('playError:', msg); });
|
|
|
|
socket.on('roomInfo', render);
|
|
socket.on('cardPlayed',render);
|
|
socket.on('trickWon', render);
|
|
socket.on('handOver', onHandOver);
|
|
socket.on('gameOver', onGameOver);
|
|
|
|
socket.on('disconnect', () => {});
|
|
}
|
|
|
|
// ─── Session persistence ──────────────────────────────────────
|
|
function saveSession() {
|
|
sessionStorage.setItem('shelem_room', myRoomId);
|
|
sessionStorage.setItem('shelem_seat', mySeat);
|
|
sessionStorage.setItem('shelem_token', myToken);
|
|
}
|
|
function clearSession() {
|
|
sessionStorage.removeItem('shelem_room');
|
|
sessionStorage.removeItem('shelem_seat');
|
|
sessionStorage.removeItem('shelem_token');
|
|
}
|
|
function tryRejoin() {
|
|
const room = sessionStorage.getItem('shelem_room');
|
|
const seat = sessionStorage.getItem('shelem_seat');
|
|
const token = sessionStorage.getItem('shelem_token');
|
|
if (!room || seat === null || !token) return;
|
|
myRoomId = room; mySeat = +seat; myToken = token;
|
|
connectSocket(() => {
|
|
socket.emit('rejoin', { roomId: room, seat: +seat, token });
|
|
});
|
|
}
|
|
|
|
// ─── Waiting Room ──────────────────────────────────────────────
|
|
function showWaitingRoom() {
|
|
showScreen('screen-waiting');
|
|
}
|
|
|
|
function renderWaitingRoom(st) {
|
|
$('display-room-code').textContent = st.id;
|
|
show('waiting-options');
|
|
$('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers';
|
|
$('waiting-score-label').textContent = `Win: ${st.winScore}`;
|
|
|
|
const slots = document.querySelectorAll('.seat-slot');
|
|
slots.forEach(slot => {
|
|
const s = +slot.dataset.seat;
|
|
const name = slot.querySelector('.seat-name');
|
|
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—');
|
|
});
|
|
|
|
const humanCount = st.seats ? st.seats.filter(Boolean).length : 0;
|
|
const botCount = st.bots.filter(Boolean).length;
|
|
const filled = humanCount + botCount;
|
|
$('waiting-status').textContent = filled < 4
|
|
? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…`
|
|
: 'Starting game…';
|
|
|
|
if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots');
|
|
}
|
|
|
|
// ─── Main render ──────────────────────────────────────────────
|
|
function render(st) {
|
|
if (!st) return;
|
|
lastState = st;
|
|
|
|
if (st.state === 'WAITING') {
|
|
if (document.getElementById('screen-waiting')?.classList.contains('active')) {
|
|
renderWaitingRoom(st);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Transition to game screen if needed
|
|
if (!document.getElementById('screen-game')?.classList.contains('active')) {
|
|
showScreen('screen-game');
|
|
}
|
|
|
|
renderInfoBar(st);
|
|
renderTable(st);
|
|
renderOverlays(st);
|
|
}
|
|
|
|
// ─── Info bar ──────────────────────────────────────────────────
|
|
function renderInfoBar(st) {
|
|
// Team scores
|
|
const t0names = [st.names[0], st.names[2]].filter(Boolean).join(' & ');
|
|
const t1names = [st.names[1], st.names[3]].filter(Boolean).join(' & ');
|
|
const ts0 = $('team-score-0');
|
|
const ts1 = $('team-score-1');
|
|
ts0.className = 'team-score team-a';
|
|
ts1.className = 'team-score team-b';
|
|
ts0.textContent = `${t0names || 'Team A'}: ${st.scores[0]}`;
|
|
ts1.textContent = `${t1names || 'Team B'}: ${st.scores[1]}`;
|
|
|
|
// Trump + bid
|
|
const td = $('trump-display');
|
|
const bd = $('bid-display');
|
|
if (st.trump) {
|
|
td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`;
|
|
show('trump-display');
|
|
} else {
|
|
hide('trump-display');
|
|
}
|
|
if (st.highBid > 0 && st.state !== 'HAND_OVER' && st.state !== 'GAME_OVER') {
|
|
bd.textContent = `Bid: ${st.highBid} (${st.names[st.highBidder] || '?'})`;
|
|
show('bid-display');
|
|
} else {
|
|
hide('bid-display');
|
|
}
|
|
}
|
|
|
|
// ─── Table ────────────────────────────────────────────────────
|
|
function renderTable(st) {
|
|
const slots = { bottom: mySeat < 0 ? 0 : mySeat,
|
|
top: mySeat < 0 ? 2 : (mySeat + 2) % 4,
|
|
left: mySeat < 0 ? 1 : (mySeat + 1) % 4,
|
|
right: mySeat < 0 ? 3 : (mySeat + 3) % 4 };
|
|
|
|
// Player labels & card backs for opponents
|
|
renderOpponent('top', slots.top, st);
|
|
renderOpponent('left', slots.left, st);
|
|
renderOpponent('right', slots.right, st);
|
|
|
|
// Trick area
|
|
renderTrick(st, slots);
|
|
|
|
// My hand
|
|
renderMyHand(st);
|
|
|
|
// My info
|
|
if (mySeat >= 0) {
|
|
$('my-name').textContent = st.names[mySeat] || 'You';
|
|
const myTeam = teamOf(mySeat);
|
|
const partnerSeat = (mySeat + 2) % 4;
|
|
$('my-score').textContent = st.scores[myTeam];
|
|
|
|
// Partner label color
|
|
const pm = $('my-partner-label');
|
|
pm.textContent = `⇔ ${st.names[partnerSeat] || ''}`;
|
|
pm.classList.remove('hidden');
|
|
|
|
// Turn dot
|
|
st.currentTurn === mySeat && st.state === 'PLAYING'
|
|
? show('my-turn') : hide('my-turn');
|
|
|
|
// Tricks badge
|
|
const tricks = st.trickWins[mySeat];
|
|
if (tricks > 0) {
|
|
$('my-tricks').textContent = `${tricks} trick${tricks > 1 ? 's' : ''}`;
|
|
show('my-tricks');
|
|
} else {
|
|
hide('my-tricks');
|
|
}
|
|
}
|
|
|
|
// Phase message
|
|
let msg = '';
|
|
if (st.state === 'BIDDING') msg = 'Bidding…';
|
|
else if (st.state === 'WIDOW') msg = 'Picking widow…';
|
|
else if (st.state === 'PLAYING' && !st.trump) msg = 'First card sets trump…';
|
|
else if (st.state === 'PLAYING' && st.trump) msg = `Trump: ${suitSymbol(st.trump)}`;
|
|
$('phase-msg').textContent = msg;
|
|
|
|
// Spectator banner
|
|
spectating ? show('spectator-banner') : hide('spectator-banner');
|
|
}
|
|
|
|
function renderOpponent(slot, seat, st) {
|
|
const name = $(`${slot}-name`);
|
|
const turn = $(`${slot}-turn`);
|
|
const score = $(`${slot}-score`);
|
|
const cards = $(`${slot}-cards`);
|
|
const partnerEl = $(`${slot}-partner`);
|
|
|
|
name.textContent = st.names[seat] || '—';
|
|
score.textContent = st.scores[teamOf(seat)];
|
|
|
|
if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`);
|
|
else hide(`${slot}-turn`);
|
|
|
|
// Partner badge for top player
|
|
if (slot === 'top' && mySeat >= 0 && partnerEl) {
|
|
const isPartner = teamOf(seat) === teamOf(mySeat);
|
|
isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`);
|
|
}
|
|
|
|
// Show card backs
|
|
cards.innerHTML = '';
|
|
const count = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length;
|
|
const vertical = slot === 'left' || slot === 'right';
|
|
for (let i = 0; i < count; i++) {
|
|
const cb = makeCardBack();
|
|
if (vertical) cb.style.width = '14px';
|
|
cards.appendChild(cb);
|
|
}
|
|
}
|
|
|
|
function renderTrick(st, slots) {
|
|
const trickMap = {};
|
|
for (const t of st.trick) trickMap[t.player] = t.card;
|
|
|
|
const positions = [
|
|
{ id: 'trick-bottom', seat: slots.bottom },
|
|
{ id: 'trick-top', seat: slots.top },
|
|
{ id: 'trick-left', seat: slots.left },
|
|
{ id: 'trick-right', seat: slots.right },
|
|
];
|
|
for (const { id, seat } of positions) {
|
|
const el = $(id);
|
|
el.innerHTML = '';
|
|
if (trickMap[seat]) {
|
|
el.appendChild(makeTrickImg(trickMap[seat]));
|
|
}
|
|
}
|
|
|
|
if (st.lastTrickWinner >= 0 && st.trick.length === 0) {
|
|
$('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`;
|
|
}
|
|
}
|
|
|
|
function renderMyHand(st) {
|
|
const handEl = $('my-hand');
|
|
handEl.innerHTML = '';
|
|
if (mySeat < 0) return;
|
|
|
|
const myHand = st.hands[mySeat];
|
|
if (!Array.isArray(myHand) || myHand.length === 0) return;
|
|
|
|
const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat;
|
|
const legal = isMyTurn ? computeLegalCards(st, mySeat) : [];
|
|
|
|
// In "playables" mode only show the legal cards when it's my turn
|
|
const display = (handMode === 'playables' && isMyTurn) ? legal : myHand;
|
|
|
|
function doPlay(code) {
|
|
if (!isMyTurn) return;
|
|
if (!computeLegalCards(st, mySeat).includes(code)) return;
|
|
socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code });
|
|
}
|
|
|
|
for (const code of display) {
|
|
const isLegal = legal.length === 0 || legal.includes(code);
|
|
const cardEl = makeCardEl(code, {
|
|
illegal: isMyTurn && !isLegal,
|
|
});
|
|
|
|
if (isMyTurn && isLegal) {
|
|
if (playMode === 'tap') {
|
|
cardEl.addEventListener('click', () => doPlay(code));
|
|
}
|
|
addDragHandlers(cardEl, code, doPlay);
|
|
}
|
|
|
|
handEl.appendChild(cardEl);
|
|
}
|
|
|
|
// Show/hide play-mode toggle (touch-only, during play)
|
|
if (!spectating) {
|
|
if (isTouchDevice() && st.state === 'PLAYING') show('btn-play-mode');
|
|
else hide('btn-play-mode');
|
|
updatePlayModeBtn();
|
|
updateHandModeBtn();
|
|
}
|
|
|
|
applyHandMode();
|
|
if (handMode === 'fan' || handMode === 'playables') {
|
|
requestAnimationFrame(updateHandSpacing);
|
|
} else {
|
|
requestAnimationFrame(() => {
|
|
const overflow = handEl.scrollWidth - handEl.clientWidth;
|
|
if (overflow > 0) handEl.scrollLeft = overflow / 2;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Legal cards (client-side mirror of server logic) ─────────
|
|
function computeLegalCards(st, seat) {
|
|
const hand = st.hands[seat];
|
|
const trick = st.trick;
|
|
const trump = st.trump;
|
|
if (!Array.isArray(hand)) return [];
|
|
|
|
function isTrump(c) {
|
|
if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return true;
|
|
return c.split('-')[0] === trump;
|
|
}
|
|
function suitOf(c) {
|
|
if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return 'JOKER';
|
|
return c.split('-')[0];
|
|
}
|
|
|
|
if (trick.length === 0) {
|
|
// First lead of the hand sets trump — jokers not allowed
|
|
if (!trump) return hand.filter(c => c !== 'JOKER-COLOR' && c !== 'JOKER-BLACK');
|
|
return hand;
|
|
}
|
|
|
|
const ledCard = trick[0].card;
|
|
const ledTrump = isTrump(ledCard);
|
|
|
|
if (ledTrump) {
|
|
const tc = hand.filter(c => isTrump(c));
|
|
return tc.length > 0 ? tc : hand;
|
|
}
|
|
const ls = suitOf(ledCard);
|
|
const sc = hand.filter(c => suitOf(c) === ls);
|
|
return sc.length > 0 ? sc : hand;
|
|
}
|
|
|
|
// ─── Overlays ─────────────────────────────────────────────────
|
|
function renderOverlays(st) {
|
|
hideAllOverlays();
|
|
|
|
if (st.state === 'BIDDING') {
|
|
renderBiddingOverlay(st);
|
|
} else if (st.state === 'WIDOW' && mySeat === st.declarer) {
|
|
renderWidowOverlay(st);
|
|
} else if (st.state === 'WIDOW' && mySeat !== st.declarer) {
|
|
$('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`;
|
|
}
|
|
}
|
|
|
|
function hideAllOverlays() {
|
|
hide('overlay-bid');
|
|
hide('overlay-widow');
|
|
hide('overlay-hand');
|
|
// Restore actual hand mode if we temporarily forced fan mode during bidding
|
|
if (isTouchDevice()) applyHandMode();
|
|
}
|
|
|
|
// ─── Bidding overlay ───────────────────────────────────────────
|
|
function renderBiddingOverlay(st) {
|
|
show('overlay-bid');
|
|
// On touch devices show hand in fan mode so players can see their cards while bidding
|
|
if (isTouchDevice()) {
|
|
$('my-hand')?.classList.add('fan-mode');
|
|
$('my-area')?.classList.add('fan-active');
|
|
requestAnimationFrame(updateHandSpacing);
|
|
}
|
|
const history = $('bid-history');
|
|
history.innerHTML = '';
|
|
|
|
for (let s = 0; s < 4; s++) {
|
|
const row = document.createElement('div');
|
|
row.className = 'bid-row' + (st.currentBidder === s ? ' active-bidder' : '');
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'bid-name';
|
|
nameSpan.textContent = (st.names[s] || `Seat ${s+1}`) + (s === mySeat ? ' (You)' : '');
|
|
|
|
const valSpan = document.createElement('span');
|
|
valSpan.className = 'bid-val';
|
|
if (st.bids[s] === null) {
|
|
valSpan.className += ' waiting';
|
|
valSpan.textContent = st.currentBidder === s ? '⟵ bidding' : '—';
|
|
} else if (st.bids[s] === 'pass') {
|
|
valSpan.className += ' passed';
|
|
valSpan.textContent = 'Pass';
|
|
} else {
|
|
valSpan.textContent = st.bids[s];
|
|
}
|
|
row.appendChild(nameSpan);
|
|
row.appendChild(valSpan);
|
|
history.appendChild(row);
|
|
}
|
|
|
|
const myBid = st.bids[mySeat];
|
|
// myBid can be a previous bid (number) — player may rebid higher; only 'pass' locks them out
|
|
const isMyTurn = st.currentBidder === mySeat && myBid !== 'pass' && !spectating;
|
|
|
|
if (isMyTurn) {
|
|
show('bid-controls');
|
|
hide('bid-waiting-msg');
|
|
const floorBid = st.jokerMode ? 105 : 85;
|
|
const minBid = st.highBid > 0 ? st.highBid + 5 : floorBid;
|
|
// Reset to minimum at the start of every new hand
|
|
if (st.handNumber !== lastBidHandNumber) {
|
|
currentBidAmount = minBid;
|
|
lastBidHandNumber = st.handNumber;
|
|
}
|
|
if (currentBidAmount < minBid) currentBidAmount = minBid;
|
|
$('bid-amount-display').textContent = currentBidAmount;
|
|
} else {
|
|
hide('bid-controls');
|
|
if (myBid === 'pass') {
|
|
$('bid-waiting-msg').textContent = 'You passed — waiting for bidding to finish…';
|
|
} else {
|
|
const bidder = st.names[st.currentBidder] || 'Another player';
|
|
$('bid-waiting-msg').textContent = `${bidder} is bidding…`;
|
|
}
|
|
show('bid-waiting-msg');
|
|
}
|
|
}
|
|
|
|
// ─── Widow overlay ────────────────────────────────────────────
|
|
function renderWidowOverlay(st) {
|
|
widowSelected = [];
|
|
show('overlay-widow');
|
|
|
|
const needed = st.widowSize;
|
|
$('widow-title').textContent = `Pick up widow — discard ${needed} cards`;
|
|
$('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`;
|
|
|
|
renderWidowHand(st);
|
|
updateWidowPreview(st);
|
|
}
|
|
|
|
const WIDOW_SUIT_ORDER = ['C', 'D', 'H', 'S'];
|
|
const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' };
|
|
const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' };
|
|
|
|
function renderWidowHand(st) {
|
|
const container = $('widow-hand');
|
|
container.innerHTML = '';
|
|
const hand = st.hands[mySeat];
|
|
if (!Array.isArray(hand)) return;
|
|
|
|
function onCardClick(c, el) {
|
|
const idx = widowSelected.indexOf(c);
|
|
if (idx >= 0) {
|
|
widowSelected.splice(idx, 1);
|
|
el.classList.remove('selected');
|
|
} else {
|
|
const needed = lastState?.widowSize || 4;
|
|
if (widowSelected.length < needed) {
|
|
widowSelected.push(c);
|
|
el.classList.add('selected');
|
|
}
|
|
}
|
|
updateWidowPreview(lastState);
|
|
}
|
|
|
|
// Render cards grouped by suit (like Hearts pass overlay)
|
|
WIDOW_SUIT_ORDER.forEach(s => {
|
|
const suitCards = hand.filter(c => c.startsWith(s + '-'));
|
|
if (suitCards.length === 0) return;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'pass-suit-row';
|
|
|
|
const lbl = document.createElement('span');
|
|
lbl.className = 'pass-suit-label suit-' + s.toLowerCase();
|
|
lbl.textContent = WIDOW_SUIT_LABEL[s];
|
|
row.appendChild(lbl);
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'pass-suit-cards';
|
|
suitCards.forEach(code => {
|
|
const card = makeCardEl(code, { onClick: onCardClick });
|
|
if (widowSelected.includes(code)) card.classList.add('selected');
|
|
wrap.appendChild(card);
|
|
});
|
|
row.appendChild(wrap);
|
|
container.appendChild(row);
|
|
});
|
|
|
|
// Jokers as their own row at the end
|
|
const jokers = hand.filter(c => c.startsWith('JOKER-'));
|
|
if (jokers.length > 0) {
|
|
const row = document.createElement('div');
|
|
row.className = 'pass-suit-row';
|
|
const lbl = document.createElement('span');
|
|
lbl.className = 'pass-suit-label';
|
|
lbl.textContent = '🃏';
|
|
row.appendChild(lbl);
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'pass-suit-cards';
|
|
jokers.forEach(code => {
|
|
const card = makeCardEl(code, { onClick: onCardClick });
|
|
if (widowSelected.includes(code)) card.classList.add('selected');
|
|
wrap.appendChild(card);
|
|
});
|
|
row.appendChild(wrap);
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function updateWidowPreview(st) {
|
|
const needed = st?.widowSize || 4;
|
|
const preview = $('widow-selected-preview');
|
|
preview.innerHTML = '';
|
|
for (let i = 0; i < needed; i++) {
|
|
if (widowSelected[i]) {
|
|
preview.appendChild(makeCardEl(widowSelected[i]));
|
|
} else {
|
|
const ph = document.createElement('div');
|
|
ph.className = 'pass-placeholder';
|
|
preview.appendChild(ph);
|
|
}
|
|
}
|
|
$('btn-confirm-discard').disabled = widowSelected.length !== needed;
|
|
}
|
|
|
|
// ─── Trump overlay ────────────────────────────────────────────
|
|
function renderTrumpOverlay(st) {
|
|
show('overlay-trump');
|
|
if (mySeat === st.declarer && !spectating) {
|
|
show('trump-declare-inner');
|
|
hide('trump-waiting-inner');
|
|
} else {
|
|
hide('trump-declare-inner');
|
|
show('trump-waiting-inner');
|
|
$('trump-waiting-msg').textContent =
|
|
`${st.names[st.declarer] || 'Declarer'} is choosing the trump suit…`;
|
|
}
|
|
}
|
|
|
|
// ─── Hand Over ────────────────────────────────────────────────
|
|
function onHandOver(st) {
|
|
lastState = st;
|
|
hideAllOverlays();
|
|
renderInfoBar(st);
|
|
|
|
const dTeam = st.declarer >= 0 ? st.declarer % 2 : 0;
|
|
const oTeam = 1 - dTeam;
|
|
const dNames = [st.names[dTeam === 0 ? 0 : 1], st.names[dTeam === 0 ? 2 : 3]].join(' & ');
|
|
const oNames = [st.names[oTeam === 0 ? 0 : 1], st.names[oTeam === 0 ? 2 : 3]].join(' & ');
|
|
const dDelta = st.handDeltas?.[dTeam] ?? 0;
|
|
const oDelta = st.handDeltas?.[oTeam] ?? 0;
|
|
|
|
let icon = '🎴', title = 'Hand Over';
|
|
if (st.isShelemHand) {
|
|
icon = '🎯';
|
|
title = 'SHELEM! All tricks won!';
|
|
} else if (dDelta < 0) {
|
|
icon = '😬';
|
|
title = `${dNames} failed the bid`;
|
|
} else {
|
|
icon = '✅';
|
|
title = `${dNames} made the bid`;
|
|
}
|
|
|
|
$('hand-result-icon').textContent = icon;
|
|
$('hand-result-title').textContent = title;
|
|
$('hand-result-detail').textContent =
|
|
`Bid: ${st.highBid} | Declarer earned: ${st.teamCardPoints?.[dTeam] ?? 0} pts`;
|
|
|
|
const scoresEl = $('hand-result-scores');
|
|
scoresEl.innerHTML = '';
|
|
|
|
for (let team = 0; team < 2; team++) {
|
|
const delta = st.handDeltas?.[team] ?? 0;
|
|
const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]]
|
|
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
|
|
const row = document.createElement('div');
|
|
row.className = 'result-score-row';
|
|
const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0;
|
|
if (isWinner) row.classList.add('winner');
|
|
row.innerHTML = `
|
|
<span class="team-label">${names}</span>
|
|
<span class="delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${delta}</span>
|
|
<span style="color:rgba(255,255,255,.6);font-size:.8rem">→ ${st.scores[team]}</span>
|
|
`;
|
|
scoresEl.appendChild(row);
|
|
}
|
|
|
|
show('overlay-hand');
|
|
// overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state)
|
|
}
|
|
|
|
// ─── Game Over ────────────────────────────────────────────────
|
|
function onGameOver(st) {
|
|
lastState = st;
|
|
hideAllOverlays();
|
|
hide('overlay-hand');
|
|
|
|
const winTeams = new Set(st.gameWinner || []);
|
|
const myTeam = mySeat >= 0 ? teamOf(mySeat) : -1;
|
|
const iWon = winTeams.has(myTeam);
|
|
$('gameover-title').textContent = iWon ? '🏆 Your team wins!' : '🎴 Game Over';
|
|
|
|
const scoresEl = $('gameover-scores');
|
|
scoresEl.innerHTML = '';
|
|
for (let team = 0; team < 2; team++) {
|
|
const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]]
|
|
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
|
|
const row = document.createElement('div');
|
|
row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : '');
|
|
row.innerHTML = `<span>${names}</span><span style="font-weight:700;color:${team===0?'var(--team-a)':'var(--team-b)'}">${st.scores[team]}</span>`;
|
|
scoresEl.appendChild(row);
|
|
}
|
|
|
|
show('overlay-gameover');
|
|
}
|
|
|
|
// ─── Auth ─────────────────────────────────────────────────────
|
|
function updateAuthBar() {
|
|
if (authUser) {
|
|
$('auth-status').textContent = `Logged in as ${authUser}`;
|
|
show('btn-show-profile'); show('btn-logout'); hide('btn-show-auth');
|
|
} else {
|
|
$('auth-status').textContent = 'Playing as guest';
|
|
hide('btn-show-profile'); hide('btn-logout'); show('btn-show-auth');
|
|
}
|
|
}
|
|
|
|
async function doLogin() {
|
|
const username = $('auth-login-user').value.trim();
|
|
const password = $('auth-login-pass').value;
|
|
$('auth-login-error').textContent = '';
|
|
try {
|
|
const r = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) { $('auth-login-error').textContent = d.error || 'Login failed'; return; }
|
|
authToken = d.token; authUser = d.username;
|
|
localStorage.setItem('shelem_token', authToken);
|
|
localStorage.setItem('shelem_user', authUser);
|
|
hide('overlay-auth');
|
|
updateAuthBar();
|
|
if (socket) socket.auth = { token: authToken };
|
|
} catch { $('auth-login-error').textContent = 'Network error'; }
|
|
}
|
|
|
|
async function doRegister() {
|
|
const username = $('auth-reg-user').value.trim();
|
|
const password = $('auth-reg-pass').value;
|
|
$('auth-reg-error').textContent = '';
|
|
if (!username || !password) { $('auth-reg-error').textContent = 'All fields required'; return; }
|
|
try {
|
|
const r = await fetch('/api/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) { $('auth-reg-error').textContent = d.error || 'Registration failed'; return; }
|
|
authToken = d.token; authUser = d.username;
|
|
localStorage.setItem('shelem_token', authToken);
|
|
localStorage.setItem('shelem_user', authUser);
|
|
hide('overlay-auth');
|
|
updateAuthBar();
|
|
} catch { $('auth-reg-error').textContent = 'Network error'; }
|
|
}
|
|
|
|
function doLogout() {
|
|
authToken = null; authUser = null;
|
|
localStorage.removeItem('shelem_token');
|
|
localStorage.removeItem('shelem_user');
|
|
updateAuthBar();
|
|
}
|
|
|
|
// ─── Profile ──────────────────────────────────────────────────
|
|
async function showProfile(username) {
|
|
if (!username) return;
|
|
showScreen('screen-profile');
|
|
$('profile-username').textContent = username;
|
|
try {
|
|
const r = await fetch(`/api/profile/${encodeURIComponent(username)}`);
|
|
const d = await r.json();
|
|
if (!r.ok) return;
|
|
$('stat-games-played').textContent = d.games_played;
|
|
$('stat-games-won').textContent = d.games_won;
|
|
$('stat-shelem').textContent = d.shelemCount || 0;
|
|
$('stat-total-score').textContent = d.total_score;
|
|
|
|
// Change password button (only own profile)
|
|
if (username === authUser) show('btn-show-change-pass');
|
|
else hide('btn-show-change-pass');
|
|
|
|
// Admin panel
|
|
if (d.isAdmin) {
|
|
show('admin-panel');
|
|
const toggleBtn = $('btn-toggle-signups');
|
|
toggleBtn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
|
|
toggleBtn.classList.toggle('signup-open', d.signupsOpen);
|
|
} else {
|
|
hide('admin-panel');
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function showLeaderboard() {
|
|
showScreen('screen-leaderboard');
|
|
try {
|
|
const r = await fetch('/api/leaderboard');
|
|
const rows = await r.json();
|
|
const tbody = $('lb-body');
|
|
tbody.innerHTML = '';
|
|
rows.forEach((row, i) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td>${i + 1}</td>
|
|
<td>${row.username}</td>
|
|
<td>${row.score_per_game ?? '—'}</td>
|
|
<td>${row.games_won}</td>
|
|
<td>${row.games_played}</td>
|
|
<td>${row.shelemCount || 0}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// ─── Event wiring ──────────────────────────────────────────────
|
|
function wireGameEvents() {
|
|
// Waiting room
|
|
$('btn-leave-waiting').addEventListener('click', () => {
|
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
|
clearSession();
|
|
showScreen('screen-lobby');
|
|
});
|
|
$('btn-copy').addEventListener('click', async () => {
|
|
const code = $('display-room-code').textContent.trim();
|
|
const btn = $('btn-copy');
|
|
try {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(code);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = code;
|
|
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
|
|
document.body.appendChild(ta);
|
|
ta.focus(); ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
btn.textContent = '✓';
|
|
btn.style.color = '#69f0ae';
|
|
} catch(e) {
|
|
btn.textContent = '✗';
|
|
}
|
|
setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500);
|
|
});
|
|
$('btn-fill-bots').addEventListener('click', () => {
|
|
socket?.emit('fillBots', { roomId: myRoomId });
|
|
});
|
|
|
|
// Game menu
|
|
$('btn-game-menu').addEventListener('click', () => {
|
|
$('game-menu-dropdown').classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', e => {
|
|
if (!e.target.closest('.game-menu-wrap')) hide('game-menu-dropdown');
|
|
});
|
|
$('btn-refresh-game').addEventListener('click', () => {
|
|
if (myRoomId && mySeat >= 0 && myToken) {
|
|
socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
|
|
}
|
|
hide('game-menu-dropdown');
|
|
});
|
|
$('btn-toggle-bar').addEventListener('click', () => {
|
|
barAtBottom = !barAtBottom;
|
|
saveBarBottom(barAtBottom);
|
|
applyBarBottom();
|
|
hide('game-menu-dropdown');
|
|
});
|
|
$('btn-exit-game').addEventListener('click', () => {
|
|
hide('game-menu-dropdown');
|
|
show('overlay-exit-confirm');
|
|
});
|
|
$('btn-leave-game').addEventListener('click', () => show('overlay-exit-confirm'));
|
|
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
|
clearSession();
|
|
hide('overlay-exit-confirm');
|
|
hide('overlay-hand');
|
|
hide('overlay-gameover');
|
|
mySeat = -1; myRoomId = null; myToken = null; spectating = false;
|
|
showScreen('screen-lobby');
|
|
});
|
|
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
|
|
|
// Bid controls
|
|
$('btn-bid-plus').addEventListener('click', () => {
|
|
currentBidAmount += 5;
|
|
$('bid-amount-display').textContent = currentBidAmount;
|
|
});
|
|
$('btn-bid-minus').addEventListener('click', () => {
|
|
const floorBid = lastState?.jokerMode ? 105 : 85;
|
|
const minBid = Math.max(floorBid, (lastState?.highBid || 0) + 5);
|
|
if (currentBidAmount - 5 >= minBid) currentBidAmount -= 5;
|
|
$('bid-amount-display').textContent = currentBidAmount;
|
|
});
|
|
$('btn-do-bid').addEventListener('click', () => {
|
|
if (!lastState) return;
|
|
const minBid = Math.max(85, (lastState.highBid || 80) + 5);
|
|
if (currentBidAmount < minBid) { currentBidAmount = minBid; }
|
|
socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: currentBidAmount });
|
|
});
|
|
$('btn-do-pass').addEventListener('click', () => {
|
|
socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: 'pass' });
|
|
});
|
|
|
|
// Discard confirm
|
|
$('btn-confirm-discard').addEventListener('click', () => {
|
|
if (widowSelected.length !== (lastState?.widowSize || 4)) return;
|
|
socket?.emit('discard', { roomId: myRoomId, seat: mySeat, token: myToken, cards: widowSelected });
|
|
});
|
|
|
|
// Trump declaration
|
|
document.querySelectorAll('.trump-suit-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
socket?.emit('declareTrump', { roomId: myRoomId, seat: mySeat, token: myToken, trump: btn.dataset.suit });
|
|
});
|
|
});
|
|
|
|
// Play mode toggle (tap ↔ drag)
|
|
$('btn-play-mode')?.addEventListener('click', () => {
|
|
playMode = playMode === 'tap' ? 'drag' : 'tap';
|
|
savePlayMode(playMode);
|
|
updatePlayModeBtn();
|
|
if (lastState) renderMyHand(lastState);
|
|
});
|
|
|
|
// Hand display mode cycle: scroll → fan → playables → scroll
|
|
$('btn-hand-mode')?.addEventListener('click', () => {
|
|
const idx = HAND_MODES.indexOf(handMode);
|
|
handMode = HAND_MODES[(idx + 1) % HAND_MODES.length];
|
|
saveHandMode(handMode);
|
|
updateHandModeBtn();
|
|
if (lastState) renderMyHand(lastState);
|
|
});
|
|
|
|
// New game
|
|
$('btn-new-game').addEventListener('click', () => {
|
|
hide('overlay-gameover');
|
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
|
clearSession();
|
|
mySeat = -1; myRoomId = null; myToken = null;
|
|
showScreen('screen-lobby');
|
|
});
|
|
|
|
// Profile / leaderboard back buttons
|
|
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
|
|
$('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby'));
|
|
|
|
// Change password
|
|
$('btn-show-change-pass').addEventListener('click', () => {
|
|
$('change-pass-form').classList.toggle('hidden');
|
|
});
|
|
$('btn-do-change-pass').addEventListener('click', async () => {
|
|
const cur = $('change-pass-current').value;
|
|
const nw = $('change-pass-new').value;
|
|
$('change-pass-msg').textContent = '';
|
|
try {
|
|
const r = await fetch('/api/change-password', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify({ currentPassword: cur, newPassword: nw }),
|
|
});
|
|
const d = await r.json();
|
|
$('change-pass-msg').textContent = r.ok ? '✓ Password updated' : (d.error || 'Failed');
|
|
$('change-pass-msg').style.color = r.ok ? '#69f0ae' : '#ff6b6b';
|
|
} catch { $('change-pass-msg').textContent = 'Network error'; }
|
|
});
|
|
|
|
$('btn-toggle-signups').addEventListener('click', async () => {
|
|
try {
|
|
const r = await fetch('/api/admin/toggle-signups', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${authToken}` },
|
|
});
|
|
const d = await r.json();
|
|
if (r.ok) {
|
|
const btn = $('btn-toggle-signups');
|
|
btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
|
|
btn.classList.toggle('signup-open', d.signupsOpen);
|
|
}
|
|
} catch { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
// ─── Boot ─────────────────────────────────────────────────────
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initLobby();
|
|
wireGameEvents();
|
|
applyBarBottom();
|
|
|
|
// Pre-fill name from auth
|
|
if (authUser) $('input-name').value = authUser;
|
|
|
|
// Try to rejoin previous session
|
|
tryRejoin();
|
|
});
|