Feature: public rooms, mobile UX, reconnection, and gameplay fixes

Rooms & lobby
- Rename docker-compose.yml → compose.yml
- Public/Private toggle on room creation; public rooms assign random seats
  to prevent team collusion
- GET /api/rooms API — lists open public rooms; Join tab shows live list
  with one-tap join
- Room creator: swap any two seats by tapping (select-to-swap UI); ▶ Start
  Game button force-starts with bots filling empty seats

Reconnection
- Session moved from sessionStorage → localStorage (survives browser close)
- Socket handlers split: socket.once for one-shot callbacks, persistent
  socket.on('connect') for auto-rejoin on network drops
- Server rejoin accepts userId match as fallback (cross-device rejoin for
  authenticated users); re-issues token on success
- Server emits hasActiveGame on connect so auth'd users on a new device are
  pulled back into their game automatically
- Explicit leave nulls seat/token/userIds so hasActiveGame never re-drags a
  player back in after they chose to leave

Mobile UX
- Remove all opponent/partner card backs; replace with compact card-count
  badge — frees ~120px of vertical space on small phones
- Screen height: 100dvh (dynamic viewport) instead of 100vh — fixes the
  "only top 1/5 visible" issue on phones with browser chrome
- Table grid side columns shrunk to 36px on touch devices; player names
  rotated vertically
- Bidding overlay: transparent non-blocking top panel on touch; hand stays
  visible and interactive; auto fan-mode during bidding
- touch-action: pan-x on hand scroll, none in fan/drag mode — suppresses
  Android back-gesture and Google Gemini conflicts
- user-select: none on game screen prevents long-press selection menus

Gameplay & notifications
- Center trick area now shows whose turn it is instead of trump (trump is
  already in the info bar); flashes gold when it's the player's turn
- Turn reminder after 5 s of inaction: gold glow pulse on hand area
  + Android vibration OR two-note Web Audio chime on iOS (vibrate API not
  supported by Apple)
- Fix: turn reminder was never triggered after winning a trick — justWon
  branch blocked myTurnNow from being set even when currentTurn === mySeat
- Waiting room ☰ menu: Reload and Exit accessible without entering the game
- Prevent duplicate room joins (same socket, same userId, or same name)

Service worker
- Bump to shelem-v2; pre-cache all 55 card SVGs at install time so cards
  are available instantly from the very first hand, including offline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-05-19 20:42:00 +00:00
parent 8e8478e45b
commit a4fefd92f1
7 changed files with 636 additions and 78 deletions
+260 -49
View File
@@ -12,15 +12,71 @@ let authUser = localStorage.getItem('shelem_user') || null;
let lastState = null;
// Lobby selections
let selectedJoker = true;
let selectedScore = 505;
let selectedJoker = true;
let selectedScore = 505;
let selectedPublic = false;
let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
// Widow discard state
let widowSelected = [];
// Bid UI state
let currentBidAmount = 85;
let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount
let lastBidHandNumber = -1;
// Turn reminder state
let turnReminderTimer = null;
let turnReminderActive = false;
// Unlock Web Audio on first user gesture (required by iOS)
let _audioCtx = null;
function getAudioCtx() {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
return _audioCtx;
}
document.addEventListener('pointerdown', () => getAudioCtx(), { once: true });
function playTurnChime() {
try {
const ctx = getAudioCtx();
const now = ctx.currentTime;
// Two-note chime: G5 then B5
[[784, 0], [988, 0.18]].forEach(([freq, delay]) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, now + delay);
gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
osc.start(now + delay);
osc.stop(now + delay + 0.5);
});
} catch (e) { /* audio not available */ }
}
function handleTurnReminder(isMyTurn) {
if (isMyTurn && !turnReminderActive) {
turnReminderActive = true;
$('my-area')?.classList.remove('turn-urgent');
turnReminderTimer = setTimeout(() => {
$('my-area')?.classList.add('turn-urgent');
// Vibrate on Android; chime on iOS (vibrate not supported there)
if (navigator.vibrate) {
navigator.vibrate([300, 100, 300]);
} else {
playTurnChime();
}
}, 5000);
} else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) {
clearTimeout(turnReminderTimer);
turnReminderTimer = null;
turnReminderActive = false;
$('my-area')?.classList.remove('turn-urgent');
}
}
// ─── Play / hand-display mode (mirror of Hearts) ──────────────
function isTouchDevice() {
@@ -261,6 +317,7 @@ function initLobby() {
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();
});
});
@@ -282,9 +339,19 @@ function initLobby() {
});
});
// Visibility toggle
document.querySelectorAll('.visibility-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedPublic = btn.dataset.public === 'true';
});
});
$('btn-create').addEventListener('click', createGame);
$('btn-join').addEventListener('click', joinGame);
$('btn-spectate').addEventListener('click', spectateGame);
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
$('btn-show-auth').addEventListener('click', () => {
show('overlay-auth');
@@ -316,10 +383,45 @@ function createGame() {
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
$('lobby-error').textContent = '';
connectSocket(() => {
socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore });
socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore, isPublic: selectedPublic });
});
}
async function loadPublicRooms() {
const listEl = $('public-rooms-list');
listEl.innerHTML = '<p class="hint">Loading…</p>';
try {
const r = await fetch('/api/rooms');
const list = await r.json();
if (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 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore}</span>
</div>
<button class="btn-join-pub btn-primary" data-room="${escHtml(room.id)}">Join</button>`;
row.querySelector('.btn-join-pub').addEventListener('click', () => {
$('input-code').value = room.id;
joinGame();
});
listEl.appendChild(row);
});
} catch {
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
}
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function joinGame() {
const name = $('input-name').value.trim();
const code = $('input-code').value.trim().toUpperCase();
@@ -344,9 +446,20 @@ function spectateGame() {
// ─── Socket ───────────────────────────────────────────────────
function connectSocket(onReady) {
if (socket?.connected) { onReady(); return; }
socket = io({ auth: { token: authToken } });
if (!socket) {
socket = io({ auth: { token: authToken } });
initSocketHandlers();
}
socket.once('connect', onReady);
}
socket.on('connect', onReady);
function initSocketHandlers() {
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
socket.on('connect', () => {
if (myRoomId && mySeat >= 0 && myToken) {
socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
}
});
socket.on('created', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = token;
@@ -364,13 +477,34 @@ function connectSocket(onReady) {
});
socket.on('rejoined', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = token;
saveSession();
});
socket.on('seatChanged', ({ seat, token }) => {
mySeat = seat; myToken = token;
saveSession();
});
// Server pushes this when an authenticated user connects with an in-progress game
// on a different device (token on that device is unknown / stale)
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
if (myRoomId) return; // already have a session, ignore
myRoomId = roomId; mySeat = seat; myToken = token;
saveSession();
socket.emit('rejoin', { roomId, seat, token });
});
socket.on('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; });
socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; });
socket.on('rejoinError', () => { clearSession(); });
socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; });
socket.on('playError', (msg) => { console.warn('playError:', msg); });
socket.on('rejoinError', () => {
// Always clear stale session and return to lobby — hasActiveGame will
// re-establish it for authenticated users who have a game on another device
clearSession();
myRoomId = null; mySeat = -1; myToken = null;
showScreen('screen-lobby');
});
socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; });
socket.on('playError', (msg) => { console.warn('playError:', msg); });
socket.on('roomInfo', render);
socket.on('cardPlayed',render);
@@ -381,24 +515,26 @@ function connectSocket(onReady) {
socket.on('disconnect', () => {});
}
// ─── Session persistence ──────────────────────────────────────
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
function saveSession() {
sessionStorage.setItem('shelem_room', myRoomId);
sessionStorage.setItem('shelem_seat', mySeat);
sessionStorage.setItem('shelem_token', myToken);
localStorage.setItem('shelem_room', myRoomId);
localStorage.setItem('shelem_seat', mySeat);
localStorage.setItem('shelem_token', myToken);
}
function clearSession() {
sessionStorage.removeItem('shelem_room');
sessionStorage.removeItem('shelem_seat');
sessionStorage.removeItem('shelem_token');
localStorage.removeItem('shelem_room');
localStorage.removeItem('shelem_seat');
localStorage.removeItem('shelem_token');
}
function tryRejoin() {
const room = sessionStorage.getItem('shelem_room');
const seat = sessionStorage.getItem('shelem_seat');
const token = sessionStorage.getItem('shelem_token');
const room = localStorage.getItem('shelem_room');
const seat = localStorage.getItem('shelem_seat');
const token = localStorage.getItem('shelem_token');
if (!room || seat === null || !token) return;
myRoomId = room; mySeat = +seat; myToken = token;
// Set inside the callback so the persistent 'connect' handler doesn't
// also emit rejoin on the very first connection (would be a duplicate)
connectSocket(() => {
myRoomId = room; mySeat = +seat; myToken = token;
socket.emit('rejoin', { roomId: room, seat: +seat, token });
});
}
@@ -414,21 +550,57 @@ function renderWaitingRoom(st) {
$('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers';
$('waiting-score-label').textContent = `Win: ${st.winScore}`;
// Public badge
let pubBadge = $('waiting-public-badge');
if (!pubBadge) {
pubBadge = document.createElement('span');
pubBadge.id = 'waiting-public-badge';
pubBadge.className = 'waiting-opt-label';
$('waiting-options').appendChild(pubBadge);
}
pubBadge.textContent = st.isPublic ? '🌐 Public' : '🔒 Private';
const isCreator = mySeat === 0;
// Seat slots — with swap UI for creator
const slots = document.querySelectorAll('.seat-slot');
slots.forEach(slot => {
const s = +slot.dataset.seat;
const name = slot.querySelector('.seat-name');
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—');
if (isCreator) {
slot.classList.toggle('seat-swappable', true);
slot.classList.toggle('seat-swap-selected', swapSelectedSeat === s);
slot.onclick = () => {
if (swapSelectedSeat === -1) {
swapSelectedSeat = s;
} else if (swapSelectedSeat === s) {
swapSelectedSeat = -1;
} else {
socket?.emit('swapSeats', { roomId: myRoomId, seatA: swapSelectedSeat, seatB: s });
swapSelectedSeat = -1;
}
renderWaitingRoom(lastState);
};
} else {
slot.classList.remove('seat-swappable', 'seat-swap-selected');
slot.onclick = null;
}
});
// Status text
const humanCount = st.seats ? st.seats.filter(Boolean).length : 0;
const botCount = st.bots.filter(Boolean).length;
const filled = humanCount + botCount;
$('waiting-status').textContent = filled < 4
let statusText = filled < 4
? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}`
: 'Starting game…';
: 'Ready to start!';
if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕';
$('waiting-status').textContent = statusText;
if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots');
// Creator controls
if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls');
}
// ─── Main render ──────────────────────────────────────────────
@@ -526,46 +698,63 @@ function renderTable(st) {
}
}
// Phase message
// Phase message — whose turn it is
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;
let myTurnNow = false;
if (st.state === 'BIDDING') {
const bidder = st.names[st.currentBidder] || '?';
msg = st.currentBidder === mySeat ? 'Your bid' : `${bidder} bidding`;
} else if (st.state === 'WIDOW') {
const dec = st.names[st.declarer] || '?';
msg = st.declarer === mySeat ? 'Pick widow' : `${dec} picking widow`;
} else if (st.state === 'PLAYING') {
const justWon = st.trick.length === 0 && st.lastTrickWinner >= 0 && st.tricksPlayed > 0;
if (justWon) {
const w = st.names[st.lastTrickWinner] || '?';
msg = st.lastTrickWinner === mySeat ? 'You won!' : `${w.slice(0,9)} won`;
// Still need to lead — turn reminder must fire even during the "won" flash
if (st.currentTurn === mySeat) myTurnNow = true;
} else if (st.currentTurn === mySeat) {
msg = 'Your turn!';
myTurnNow = true;
} else if (st.currentTurn >= 0) {
msg = `${(st.names[st.currentTurn] || '?').slice(0,9)}'s turn`;
}
}
const pm = $('phase-msg');
pm.textContent = msg;
pm.classList.toggle('your-turn', myTurnNow);
handleTurnReminder(myTurnNow && !spectating);
// Spectator banner
spectating ? show('spectator-banner') : hide('spectator-banner');
}
function renderOpponent(slot, seat, st) {
const name = $(`${slot}-name`);
const turn = $(`${slot}-turn`);
const score = $(`${slot}-score`);
const cards = $(`${slot}-cards`);
const nameEl = $(`${slot}-name`);
const turnEl = $(`${slot}-turn`);
const scoreEl = $(`${slot}-score`);
const countEl = $(`${slot}-count`);
const partnerEl = $(`${slot}-partner`);
name.textContent = st.names[seat] || '—';
score.textContent = st.scores[teamOf(seat)];
nameEl.textContent = st.names[seat] || '—';
scoreEl.textContent = st.scores[teamOf(seat)];
if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`);
else hide(`${slot}-turn`);
// Card count badge
if (countEl) {
const n = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length;
countEl.textContent = n > 0 ? `${n}` : '';
}
// Partner badge for top player
if (slot === 'top' && mySeat >= 0 && partnerEl) {
const isPartner = teamOf(seat) === teamOf(mySeat);
isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`);
}
// 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) {
@@ -586,9 +775,7 @@ function renderTrick(st, slots) {
}
}
if (st.lastTrickWinner >= 0 && st.trick.length === 0) {
$('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`;
}
// phase-msg for trick winner is handled in renderTable
}
function renderMyHand(st) {
@@ -1076,6 +1263,21 @@ function wireGameEvents() {
clearSession();
showScreen('screen-lobby');
});
$('btn-waiting-menu').addEventListener('click', () => {
$('waiting-menu-dropdown').classList.toggle('hidden');
});
$('btn-waiting-reload').addEventListener('click', () => {
if (myRoomId && mySeat >= 0 && myToken) {
socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
}
hide('waiting-menu-dropdown');
});
$('btn-waiting-exit').addEventListener('click', () => {
hide('waiting-menu-dropdown');
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
showScreen('screen-lobby');
});
$('btn-copy').addEventListener('click', async () => {
const code = $('display-room-code').textContent.trim();
const btn = $('btn-copy');
@@ -1101,13 +1303,19 @@ function wireGameEvents() {
$('btn-fill-bots').addEventListener('click', () => {
socket?.emit('fillBots', { roomId: myRoomId });
});
$('btn-start-game').addEventListener('click', () => {
socket?.emit('forceStart', { roomId: myRoomId });
});
// Game menu
$('btn-game-menu').addEventListener('click', () => {
$('game-menu-dropdown').classList.toggle('hidden');
});
document.addEventListener('click', e => {
if (!e.target.closest('.game-menu-wrap')) hide('game-menu-dropdown');
if (!e.target.closest('.game-menu-wrap')) {
hide('game-menu-dropdown');
hide('waiting-menu-dropdown');
}
});
$('btn-refresh-game').addEventListener('click', () => {
if (myRoomId && mySeat >= 0 && myToken) {
@@ -1251,4 +1459,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Try to rejoin previous session
tryRejoin();
// For authenticated users with no local session, connect so the server
// can push hasActiveGame (enables cross-device rejoin)
if (authToken && !myRoomId) connectSocket(() => {});
});