From a4fefd92f14f77a62e7efe79c6dedc9190cdb06f Mon Sep 17 00:00:00 2001 From: goyban Date: Tue, 19 May 2026 20:42:00 +0000 Subject: [PATCH] Feature: public rooms, mobile UX, reconnection, and gameplay fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + docker-compose.yml => compose.yml | 0 public/app.js | 309 +++++++++++++++++++++++++----- public/index.html | 37 +++- public/style.css | 228 ++++++++++++++++++++-- public/sw.js | 28 ++- server.js | 111 +++++++++-- 7 files changed, 636 insertions(+), 78 deletions(-) rename docker-compose.yml => compose.yml (100%) diff --git a/.gitignore b/.gitignore index 517063a..8b13a87 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data/ # Node node_modules/ npm-debug.log* + diff --git a/docker-compose.yml b/compose.yml similarity index 100% rename from docker-compose.yml rename to compose.yml diff --git a/public/app.js b/public/app.js index 7297426..64b18a5 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = '

Loading…

'; + try { + const r = await fetch('/api/rooms'); + const list = await r.json(); + if (list.length === 0) { + listEl.innerHTML = '

No public rooms right now.

'; + return; + } + listEl.innerHTML = ''; + list.forEach(room => { + const row = document.createElement('div'); + row.className = 'public-room-item'; + row.innerHTML = ` +
+ ${escHtml(room.hostName)}'s room + ${room.playerCount}/4 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore} +
+ `; + row.querySelector('.btn-join-pub').addEventListener('click', () => { + $('input-code').value = room.id; + joinGame(); + }); + listEl.appendChild(row); + }); + } catch { + listEl.innerHTML = '

Failed to load rooms.

'; + } +} + +function escHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(() => {}); }); diff --git a/public/index.html b/public/index.html index 4365af7..8ce33d5 100644 --- a/public/index.html +++ b/public/index.html @@ -57,6 +57,13 @@ +
+ +
+ + +
+
@@ -67,6 +74,14 @@
+
+
+ Public Rooms + +
+

Loading…

+
+
@@ -81,7 +96,16 @@
- +
+ +
+ + +
+

Waiting for Players

Room Code @@ -103,7 +127,10 @@
- +
@@ -149,8 +176,8 @@ partner +
-
@@ -159,8 +186,8 @@ + -
@@ -182,8 +209,8 @@ + -
diff --git a/public/style.css b/public/style.css index 2323262..5cb0f0b 100644 --- a/public/style.css +++ b/public/style.css @@ -28,7 +28,11 @@ html, body { /* ── Screens ──────────────────────────────────── */ .screen { display: none; } -.screen.active { display: flex; flex-direction: column; height: 100vh; } +.screen.active { + display: flex; flex-direction: column; + height: 100vh; /* fallback */ + height: 100dvh; /* modern: excludes browser chrome / address bar */ +} /* ── Lobby ────────────────────────────────────── */ #screen-lobby { @@ -119,6 +123,26 @@ input[type=text], input[type=password], input[type=email] { } input:focus { border-color: var(--gold); } +/* ── Visibility toggle (Private / Public) ─────── */ +.visibility-row { display: flex; gap: 6px; } +.visibility-btn { + flex: 1; + padding: 8px 6px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + font-size: .82rem; + text-align: center; + transition: background .15s; +} +.visibility-btn.active, .visibility-btn:hover { + background: rgba(245,197,24,.2); + border-color: var(--gold); + color: var(--gold); +} + /* ── Mode / score buttons ─────────────────────── */ .mode-row, .score-row { display: flex; gap: 6px; } .mode-btn, .score-btn { @@ -239,17 +263,60 @@ input:focus { border-color: var(--gold); } border: 1px solid rgba(245,197,24,.25); border-radius: 12px; } +/* ── Public rooms list ────────────────────────── */ +.public-rooms-section { display: flex; flex-direction: column; gap: 6px; width: 100%; } +.rooms-header { + display: flex; align-items: center; justify-content: space-between; + font-size: .78rem; color: var(--muted); +} +.rooms-label { font-weight: 600; letter-spacing: .5px; } +.public-room-item { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 10px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; +} +.public-room-info { display: flex; flex-direction: column; gap: 2px; text-align: left; } +.public-room-host { font-size: .85rem; font-weight: 600; color: #fff; } +.public-room-meta { font-size: .72rem; color: var(--muted); } +.btn-join-pub { padding: 5px 14px; font-size: .82rem; flex-shrink: 0; } + +/* ── Creator controls in waiting room ────────── */ +.waiting-creator-controls { + display: flex; gap: 8px; width: 100%; +} .btn-fill-bots { - padding: 8px 16px; + flex: 1; + padding: 8px 10px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.16); border-radius: 10px; color: var(--muted); cursor: pointer; - font-size: .85rem; - width: 100%; + font-size: .82rem; } .btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; } +.btn-start-game { + flex: 1; + padding: 8px 10px; + background: rgba(100,200,100,.15); + border: 1px solid rgba(100,200,100,.35); + border-radius: 10px; + color: #a5d6a7; + cursor: pointer; + font-size: .82rem; + font-weight: 600; +} +.btn-start-game:hover { background: rgba(100,200,100,.28); color: #c8e6c9; } + +/* ── Seat swap (creator taps to reorder) ─────── */ +.seat-slot.seat-swappable { cursor: pointer; transition: background .12s, border-color .12s; } +.seat-slot.seat-swappable:hover { background: rgba(255,255,255,.1); } +.seat-slot.seat-swap-selected { + border-color: var(--gold); + background: rgba(245,197,24,.12); +} /* ── Game screen ──────────────────────────────── */ #screen-game { background: var(--felt); overflow: hidden; position: relative; } @@ -523,11 +590,25 @@ input:focus { border-color: var(--gold); } display: flex; align-items: center; justify-content: center; gap: 4px; } .trick-center-info { - width: 60px; text-align: center; flex-shrink: 0; + flex: 0 0 80px; + text-align: center; } .phase-msg { font-size: .72rem; color: rgba(255,255,255,.6); line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.phase-msg.your-turn { + color: var(--gold); + font-weight: 700; + font-size: .78rem; + animation: your-turn-flash .8s ease-in-out infinite alternate; +} +@keyframes your-turn-flash { + from { opacity: .7; } + to { opacity: 1; } } /* Drop-zone pulse when dragging */ @@ -914,6 +995,37 @@ input:focus { border-color: var(--gold); } .lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); } .lb-table tr:hover td { background: rgba(255,255,255,.04); } +/* ── Waiting room top row (Leave + ☰ menu) ───── */ +.waiting-top-row { + display: flex; + width: 100%; + align-items: center; +} + +/* ── Turn urgent reminder ──────────────────────── */ +@keyframes turn-urgent-glow { + 0%, 100% { border-top-color: rgba(0,0,0,.3); box-shadow: none; } + 50% { + border-top-color: rgba(245,197,24,.7); + box-shadow: 0 -2px 16px rgba(245,197,24,.3); + } +} +#my-area.turn-urgent { + animation: turn-urgent-glow .65s ease-in-out 6; +} + +/* ── Card count badge (replaces card backs) ──── */ +.card-count-badge { + font-size: .65rem; + color: rgba(255,255,255,.55); + padding: 1px 5px; + background: rgba(255,255,255,.08); + border-radius: 8px; + min-width: 20px; + text-align: center; + flex-shrink: 0; +} + /* ── Util ─────────────────────────────────────── */ .hidden { display: none !important; } @@ -928,8 +1040,89 @@ input:focus { border-color: var(--gold); } bottom: calc(100% + 6px); } -/* ── Mobile bidding: transparent panel at top so hand stays visible ── */ +/* ═══════════════════════════════════════════════ + MOBILE OVERHAUL (touch devices / small screens) + ═══════════════════════════════════════════════ */ @media (pointer: coarse) { + + /* Prevent text selection and system long-press menus on game elements */ + #screen-game { + user-select: none; + -webkit-user-select: none; + } + + /* Allow horizontal scroll in scroll mode, block vertical pull-to-refresh */ + #my-hand { + touch-action: pan-x; + overscroll-behavior-x: contain; + } + + /* In fan mode cards need full touch control for drag */ + #my-hand.fan-mode { + touch-action: none; + } + #my-hand.fan-mode .card { + touch-action: none; + } + + /* ── Info bar: compact ─────────────────────── */ + #info-bar { padding: 3px 8px; gap: 5px; } + .team-score { font-size: .7rem; padding: 2px 5px; } + .trump-display, .bid-display { font-size: .68rem; padding: 2px 6px; } + .btn-leave-screen { font-size: .75rem; padding: 3px 7px; } + + /* ── Table grid: narrow side columns ──────── */ + #table-grid { + grid-template-columns: 36px 1fr 36px; + gap: 2px; + padding: 3px; + } + + /* ── Top player label: minimal height ──────── */ + .area-top .player-label { + font-size: .7rem; + padding: 2px 6px; + gap: 4px; + flex-wrap: wrap; + justify-content: center; + } + .area-top .player-score-badge { display: none; } + + /* ── Side player labels: vertical, very narrow */ + .player-label.vertical { + font-size: .6rem; + padding: 3px 2px; + gap: 3px; + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + max-height: 140px; + text-align: center; + } + /* Rotate back the children that shouldn't be rotated */ + .player-label.vertical .turn-dot { + writing-mode: horizontal-tb; + transform: none; + font-size: .55rem; + } + .player-label.vertical .player-score-badge { display: none; } + .player-label.vertical .card-count-badge { + writing-mode: horizontal-tb; + transform: none; + font-size: .55rem; + min-width: 16px; + } + + /* ── My area: ensure it's always fully visible */ + #my-area { + padding: 3px 6px calc(6px + env(safe-area-inset-bottom)); + gap: 3px; + flex-shrink: 0; + } + #my-label { font-size: .72rem; gap: 4px; } + .btn-hand-mode, .btn-play-mode { font-size: .65rem; padding: 2px 6px; } + + /* ── Bidding overlay: compact panel at top so hand stays visible ── */ #overlay-bid { align-items: flex-start; background: transparent; @@ -942,17 +1135,28 @@ input:focus { border-color: var(--gold); } max-width: 100%; border-radius: 0 0 20px 20px; border-top: none; - background: rgba(8, 22, 8, 0.97); - box-shadow: 0 6px 28px rgba(0,0,0,.75); - max-height: 62vh; + background: rgba(6, 20, 6, 0.97); + box-shadow: 0 6px 28px rgba(0,0,0,.8); + max-height: 55vh; } + /* Shrink bid history on mobile so it doesn't swallow the screen */ + .bid-history { max-height: 110px; } + .bid-box h3 { font-size: .95rem; margin-bottom: 0; } } -/* ── Mobile tweaks ────────────────────────────── */ -@media (max-width: 480px) { +/* ── Small phones (≤ 390px wide) ─────────────── */ +@media (pointer: coarse) and (max-width: 390px) { + :root { --card-w: 48px; --card-h: 70px; } + #table-grid { grid-template-columns: 32px 1fr 32px; } + .overlay-box { padding: 14px 12px; } + .bid-amount-display { font-size: 1.3rem; } + .trump-suit-btn { font-size: .82rem; padding: 10px 4px; } +} + +/* ── Desktop / mouse-pointer fallback tweaks ─── */ +@media (max-width: 480px) and (pointer: fine) { :root { --card-w: 54px; --card-h: 78px; } #table-grid { grid-template-columns: 70px 1fr 70px; } .overlay-box { padding: 18px 16px; } .bid-amount-display { font-size: 1.4rem; } - .trump-suit-btn { font-size: .88rem; padding: 11px 6px; } } diff --git a/public/sw.js b/public/sw.js index 4eb65f6..3d6ca4d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,28 @@ 'use strict'; -const CACHE_NAME = 'shelem-v1'; +const CACHE_NAME = 'shelem-v2'; + +// ── Generate all 55 card paths ────────────────────────────────── +const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE']; +const CARD_PATHS = [ + '/cards/JOKER-1.svg', + '/cards/JOKER-2.svg', + '/cards/JOKER-3.svg', + ...SUITS.flatMap(s => [ + `/cards/${s}-1.svg`, + `/cards/${s}-2.svg`, + `/cards/${s}-3.svg`, + `/cards/${s}-4.svg`, + `/cards/${s}-5.svg`, + `/cards/${s}-6.svg`, + `/cards/${s}-7.svg`, + `/cards/${s}-8.svg`, + `/cards/${s}-9.svg`, + `/cards/${s}-10.svg`, + `/cards/${s}-11-JACK.svg`, + `/cards/${s}-12-QUEEN.svg`, + `/cards/${s}-13-KING.svg`, + ]), +]; const PRECACHE = [ '/', @@ -10,6 +33,7 @@ const PRECACHE = [ '/icons/icon-192.png', '/icons/icon-512.png', '/icons/icon.svg', + ...CARD_PATHS, ]; self.addEventListener('install', event => { @@ -33,6 +57,7 @@ self.addEventListener('fetch', event => { if (event.request.method !== 'GET') return; if (url.includes('/socket.io/')) return; + // Cards: always serve from cache (never change) if (url.includes('/cards/')) { event.respondWith( caches.match(event.request).then(cached => { @@ -46,6 +71,7 @@ self.addEventListener('fetch', event => { return; } + // Everything else: network-first, cache as offline fallback event.respondWith( fetch(event.request) .then(res => { diff --git a/server.js b/server.js index 1367ea3..74e0d6e 100644 --- a/server.js +++ b/server.js @@ -216,6 +216,22 @@ app.get('/api/leaderboard', (_req, res) => { res.json(rows); }); +app.get('/api/rooms', (_req, res) => { + const list = []; + for (const [, room] of rooms) { + if (!room.isPublic || room.state !== 'WAITING') continue; + const playerCount = room.seats.filter(Boolean).length + room.bots.filter(Boolean).length; + list.push({ + id: room.id, + playerCount, + jokerMode: room.jokerMode, + winScore: room.winScore, + hostName: room.names[0] || '?', + }); + } + res.json(list); +}); + app.post('/api/admin/toggle-signups', requireAuth, (req, res) => { if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' }); config.signupsOpen = !config.signupsOpen; @@ -337,6 +353,7 @@ function makeToken() { function newRoom(id) { return { id, + isPublic: false, state: 'WAITING', names: ['', '', '', ''], seats: [null, null, null, null], @@ -414,6 +431,7 @@ function publicInfo(room, seat) { gameWinner: room.gameWinner, jokerMode: room.jokerMode, winScore: room.winScore, + isPublic: room.isPublic, spectatorCount: room.spectators.size, }; } @@ -844,14 +862,25 @@ io.use((socket, next) => { io.on('connection', (socket) => { const user = socket.data.user; - if (user) userSockets.set(user.id, socket.id); + if (user) { + userSockets.set(user.id, socket.id); + // Notify authenticated user if they have an in-progress game (enables cross-device rejoin) + for (const [, room] of rooms) { + const seat = room.userIds.indexOf(user.id); + if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') { + socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] }); + break; + } + } + } // ── Create room ──────────────────────────────────────────── - socket.on('create', ({ name, jokerMode, winScore } = {}) => { + socket.on('create', ({ name, jokerMode, winScore, isPublic } = {}) => { if (!name?.trim()) return socket.emit('error', 'Name is required'); const id = Math.random().toString(36).slice(2, 8).toUpperCase(); const room = newRoom(id); - room.jokerMode = jokerMode !== false; // default true + room.jokerMode = jokerMode !== false; + room.isPublic = isPublic === true; const ws = [205, 505, 1005]; room.winScore = ws.includes(+winScore) ? +winScore : 505; room.names[0] = name.trim().slice(0, 16); @@ -871,11 +900,24 @@ io.on('connection', (socket) => { if (!room) return socket.emit('joinError', 'Room not found'); if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress'); - let openSeat = -1; + // Prevent the same socket, user, or name from occupying more than one seat + const trimmedName = name.trim().toLowerCase(); for (let i = 0; i < 4; i++) { - if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; } + if (room.seats[i] === socket.id) return socket.emit('joinError', 'You are already in this room'); + if (user?.id && room.userIds[i] === user.id) return socket.emit('joinError', 'You are already in this room'); + if (room.seats[i] && room.names[i].toLowerCase() === trimmedName) + return socket.emit('joinError', 'That name is already taken in this room'); } - if (openSeat === -1) return socket.emit('joinError', 'Room is full'); + + // For public rooms assign a random open seat; private rooms take first open seat + const openSeats = []; + for (let i = 0; i < 4; i++) { + if (!room.seats[i] && !room.bots[i]) openSeats.push(i); + } + if (openSeats.length === 0) return socket.emit('joinError', 'Room is full'); + const openSeat = room.isPublic + ? openSeats[Math.floor(Math.random() * openSeats.length)] + : openSeats[0]; room.names[openSeat] = name.trim().slice(0, 16); room.userIds[openSeat] = user?.id || null; @@ -901,11 +943,21 @@ io.on('connection', (socket) => { // ── Rejoin ───────────────────────────────────────────────── socket.on('rejoin', ({ roomId, seat, token } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); - if (!room) return socket.emit('rejoinError', 'Room no longer exists'); - if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token'); + if (!room) return socket.emit('rejoinError', 'not_found'); + + const tokenOk = room.tokens[seat] === token; + const userOk = user && room.userIds[seat] != null + && room.userIds[seat] === user.id + && room.tokens[seat] !== null; // null means player explicitly left + + if (!tokenOk && !userOk) return socket.emit('rejoinError', 'bad_token'); + + // Re-issue token when only userId matched (e.g. different device) + if (!tokenOk) room.tokens[seat] = makeToken(); + room.seats[seat] = socket.id; socket.join(room.id); - socket.emit('rejoined', { roomId: room.id, seat, token }); + socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] }); socket.emit('roomInfo', publicInfo(room, seat)); broadcastState(room, 'roomInfo'); }); @@ -926,6 +978,42 @@ io.on('connection', (socket) => { tryStartGame(room); }); + // ── Swap seats (creator only) ────────────────────────────── + socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + const isCreator = room.seats[0] === socket.id || + (user?.id && room.userIds[0] === user.id); + if (!isCreator) return; + if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return; + + for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) { + [room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]]; + } + // Tell each affected human player their new seat and token + if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] }); + if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] }); + broadcastState(room, 'roomInfo'); + }); + + // ── Force start (creator only, fills remaining seats with bots) ── + socket.on('forceStart', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + const isCreator = room.seats[0] === socket.id || + (user?.id && room.userIds[0] === user.id); + if (!isCreator) return; + const botNames = ['Ali', 'Mina', 'Reza', 'Sara']; + for (let i = 0; i < 4; i++) { + if (!room.seats[i] && !room.bots[i]) { + room.bots[i] = true; + room.names[i] = botNames[i]; + } + } + dealHand(room); + startBidding(room); + }); + // ── Bid ──────────────────────────────────────────────────── socket.on('bid', ({ roomId, seat, token, amount } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); @@ -958,8 +1046,9 @@ io.on('connection', (socket) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; if (room.tokens[seat] === token) { - room.seats[seat] = null; - room.tokens[seat] = null; + room.seats[seat] = null; + room.tokens[seat] = null; + room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in } socket.leave(room.id); });