From df8b9a3e00d9a796d4e333b40a29c17c63a597b2 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 19:18:19 +0200 Subject: [PATCH] refactor: improve game start logic to ensure all active players are ready before starting --- public/client.js | 480 -------------------------------------------- public/js/render.js | 6 +- src/server/game.js | 9 +- 3 files changed, 10 insertions(+), 485 deletions(-) delete mode 100644 public/client.js diff --git a/public/client.js b/public/client.js deleted file mode 100644 index 8cd9688..0000000 --- a/public/client.js +++ /dev/null @@ -1,480 +0,0 @@ -import { badgeColorForYear } from './utils/colors.js'; - -let ws; -let reconnectAttempts = 0; -let reconnectTimer = null; -const outbox = []; -let sessionId = localStorage.getItem('sessionId') || null; -let lastRoomId = localStorage.getItem('lastRoomId') || null; -function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; } -function sendMsg(obj) { - if (wsIsOpen()) ws.send(JSON.stringify(obj)); - else outbox.push(obj); -} -function scheduleReconnect() { - if (reconnectTimer) return; - const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts)); - reconnectAttempts++; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connectWS(); - }, delay); -} -function connectWS() { - const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host; - ws = new WebSocket(url); - ws.addEventListener('open', () => { - reconnectAttempts = 0; - // Try to resume session immediately - if (sessionId) { - try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {} - } - // Flush queued messages - setTimeout(() => { - while (outbox.length && wsIsOpen()) { - try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } - } - }, 100); - }); - ws.addEventListener('message', (ev) => handleMessage(ev)); - ws.addEventListener('close', () => { scheduleReconnect(); }); - ws.addEventListener('error', () => { try { ws.close(); } catch {} }); -} - -let state = { - playerId: null, - room: null, - lastTrack: null, - revealed: false, - pendingReady: null, - isBuffering: false, -}; - -// Elements -const el = (id) => document.getElementById(id); -const $lobby = el('lobby'); -const $room = el('room'); -// Removed old players chip list; using dashboard only -const $dashboardList = el('dashboardList'); -const $roomId = el('roomId'); -const $status = el('status'); -const $guesser = el('guesser'); -const $timeline = el('timeline'); -const $tokens = el('tokens'); -const $audio = el('audio'); -if ($audio) { try { $audio.preload = 'none'; } catch {} } -const $np = document.getElementById('nowPlaying'); -const $npTitle = el('npTitle'); -const $npArtist = el('npArtist'); -const $npYear = el('npYear'); -const $readyChk = document.getElementById('readyChk'); -const $startGame = document.getElementById('startGame'); -const $revealBanner = document.getElementById('revealBanner'); -const $placeArea = document.getElementById('placeArea'); -const $slotSelect = document.getElementById('slotSelect'); -const $placeBtn = document.getElementById('placeBtn'); -const $mediaControls = document.getElementById('mediaControls'); -const $playBtn = document.getElementById('playBtn'); -const $pauseBtn = document.getElementById('pauseBtn'); -const $nextArea = document.getElementById('nextArea'); -const $nextBtn = document.getElementById('nextBtn'); -// Custom player UI -const $recordDisc = document.getElementById('recordDisc'); -const $progressFill = document.getElementById('progressFill'); -const $volumeSlider = document.getElementById('volumeSlider'); -const $bufferBadge = document.getElementById('bufferBadge'); -// Copy Room Code button -const $copyRoomCode = document.getElementById('copyRoomCode'); -// Name (lobby input + room display) -const $nameLobby = document.getElementById('name'); -const $setNameLobby = document.getElementById('setName'); -const $nameDisplay = document.getElementById('nameDisplay'); - -function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); } -function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); } - -function renderRoom(room) { - state.room = room; - try { if (room?.id) { lastRoomId = room.id; localStorage.setItem('lastRoomId', room.id); } } catch {} - if (!room) { showLobby(); return; } - showRoom(); - $roomId.textContent = room.id; - // Ensure copy button is visible and set up - if ($copyRoomCode) { - $copyRoomCode.style.display = 'inline-block'; - $copyRoomCode.onclick = function() { - if (room.id) { - navigator.clipboard.writeText(room.id).then(() => { - $copyRoomCode.textContent = '✔️'; - showToast('Code kopiert!'); - setTimeout(()=>{$copyRoomCode.textContent = '📋';}, 1200); - }); - } - }; - } - // Also allow clicking the room code itself to copy - if ($roomId) { - $roomId.onclick = function() { - if (room.id) { - navigator.clipboard.writeText(room.id).then(() => { - $roomId.title = 'Kopiert!'; - showToast('Code kopiert!'); - setTimeout(()=>{$roomId.title = 'Klicken zum Kopieren';}, 1200); - }); - } - }; - $roomId.style.cursor = 'pointer'; - } -const $toast = document.getElementById('toast'); -function showToast(msg) { - if (!$toast) return; - $toast.textContent = msg; - $toast.style.opacity = '1'; - setTimeout(() => { - $toast.style.opacity = '0'; - }, 1200); -} - $status.textContent = room.state.status; - $guesser.textContent = shortName(room.state.currentGuesser); - // Show my current name (from server if available) or fallback to stored value - const me = room.players.find(p=>p.id===state.playerId); - if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-'); - // Old players chip list removed - // Dashboard rows - if ($dashboardList) { - $dashboardList.innerHTML = room.players.map(p => { - const connected = p.connected ? 'online' : 'offline'; - const ready = p.ready ? 'bereit' : '-'; - const score = (room.state.timeline?.[p.id]?.length) ?? 0; - const isMe = p.id === state.playerId; - return ` - - -
- ${escapeHtml(p.name)}${p.spectator ? ' 👻' : ''} - ${p.id===room.hostId ? '\u2B50' : ''} - ${isMe ? '(du)' : ''} -
- - ${connected} - ${ready} - ${score} - `; - }).join(''); - } - const myTl = room.state.timeline?.[state.playerId] || []; - $timeline.innerHTML = myTl.map(t => { - const title = escapeHtml(t.title || t.trackId || 'Unbekannt'); - const artist = t.artist ? escapeHtml(t.artist) : ''; - const year = (t.year ?? '?'); - const badgeStyle = badgeColorForYear(year); - return ` -
-
${year}
-
-
${title}
-
${artist}
-
-
- `; - }).join(''); - $tokens.textContent = room.state.tokens?.[state.playerId] ?? 0; - // Ready control visibility - if ($readyChk) { - const serverReady = !!me?.ready; - // If user recently toggled, keep local visual state until server matches - if (state.pendingReady === null || state.pendingReady === undefined) { - $readyChk.checked = serverReady; - } else { - $readyChk.checked = !!state.pendingReady; - // Clear pending once it matches server - if (serverReady === state.pendingReady) state.pendingReady = null; - } - $readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby'); - } - // Host start button when all ready - const isHost = state.playerId === room.hostId; - const allReady = room.players.length>0 && room.players.every(p=>p.ready); - if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady)); - // Show guess buttons only when it's my turn and a track is active - const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack; - const canGuess = isMyTurn; - // Build slot options: 0..n - if ($placeArea && $slotSelect) { - if (canGuess) { - const tl = room.state.timeline?.[state.playerId] || []; - $slotSelect.innerHTML = ''; - for (let i = 0; i <= tl.length; i++) { - const left = i>0 ? (tl[i-1]?.year ?? '?') : null; - const right = ix.id===id); - return p ? p.name : id.slice(0,4); -} - -function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } - -// color helper imported from utils/colors.js - -function handleMessage(ev) { - const msg = JSON.parse(ev.data); - if (msg.type === 'connected') { - state.playerId = msg.playerId; - if (msg.sessionId && !sessionId) { - sessionId = msg.sessionId; - try { localStorage.setItem('sessionId', sessionId); } catch {} - } - // Try to auto-apply stored name - const stored = localStorage.getItem('playerName'); - if (stored) { - if ($nameLobby && $nameLobby.value !== stored) { - $nameLobby.value = stored; - } - if ($nameDisplay) { - $nameDisplay.textContent = stored; - } - sendMsg({ type: 'set_name', name: stored }); - } - // If we already have a sessionId, we'll resume instead of auto-joining here - if (!sessionId) { - const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); - if (code) sendMsg({ type: 'join_room', code }); - } - } - if (msg.type === 'resume_result') { - if (msg.ok) { - if (msg.playerId) state.playerId = msg.playerId; - // If we have a known room, ensure we rejoin it - if (msg.roomId) { - // Update local roomId (we will receive a room_update shortly) - sendMsg({ type: 'join_room', code: msg.roomId }); - } else { - const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); - if (code) sendMsg({ type: 'join_room', code }); - } - } else { - // If resume failed, try to rejoin by last room code - const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); - if (code) sendMsg({ type: 'join_room', code }); - } - } - if (msg.type === 'room_update') { - renderRoom(msg.room); - } - if (msg.type === 'play_track') { - const t = msg.track; - state.lastTrack = t; - state.revealed = false; - // Hide metadata until a guess is placed - $npTitle.textContent = '???'; - $npArtist.textContent = ''; - $npYear.textContent = ''; - try { $audio.preload = 'auto'; } catch {} - $audio.src = t.url; - // Reset custom UI - if ($progressFill) $progressFill.style.width = '0%'; - if ($recordDisc) $recordDisc.classList.remove('spin-record'); - // Sync start using server-provided timestamp - const { startAt, serverNow } = msg; - if (startAt && serverNow) { - const now = Date.now(); - const offsetMs = startAt - serverNow; // server delay until start - const localStart = now + offsetMs; - const delay = Math.max(0, localStart - now); - setTimeout(() => { - $audio.currentTime = 0; - $audio.play().catch(()=>{}); - if ($recordDisc) $recordDisc.classList.add('spin-record'); - }, delay); - } else { - $audio.play().catch(()=>{}); - if ($recordDisc) $recordDisc.classList.add('spin-record'); - } - if (state.room) renderRoom(state.room); - } - if (msg.type === 'sync') { - const { startAt, serverNow } = msg; - if (!state.room?.state?.currentTrack || !startAt || !serverNow) return; - if (state.room?.state?.paused) return; // don't auto-resume while paused - if (state.isBuffering) return; // avoid corrections while buffering - const now = Date.now(); - const elapsed = (now - startAt) / 1000; // seconds - const drift = ($audio.currentTime || 0) - elapsed; - // Soft sync via playbackRate adjustments; hard seek if way off - const abs = Math.abs(drift); - if (abs > 1.0) { - // Hard correct when over 1s - $audio.currentTime = Math.max(0, elapsed); - if ($audio.paused) $audio.play().catch(()=>{}); - $audio.playbackRate = 1.0; - } else if (abs > 0.12) { - // Gently nudge speed up to +/-3% - const maxNudge = 0.03; - const sign = drift > 0 ? -1 : 1; // if ahead (positive drift), slow down - const rate = 1 + sign * Math.min(maxNudge, abs * 0.5); - $audio.playbackRate = Math.max(0.8, Math.min(1.2, rate)); - } else { - // Close enough - if (Math.abs($audio.playbackRate - 1) > 0.001) { - $audio.playbackRate = 1.0; - } - } - } - if (msg.type === 'control') { - const { action, startAt, serverNow } = msg; - if (action === 'pause') { - $audio.pause(); - if ($recordDisc) $recordDisc.classList.remove('spin-record'); - $audio.playbackRate = 1.0; - } else if (action === 'play') { - if (startAt && serverNow) { - const now = Date.now(); - const elapsed = (now - startAt) / 1000; - $audio.currentTime = Math.max(0, elapsed); - } - $audio.play().catch(()=>{}); - if ($recordDisc) $recordDisc.classList.add('spin-record'); - } - } - if (msg.type === 'reveal') { - const { result, track } = msg; - // Reveal metadata - $npTitle.textContent = track.title || track.id || 'Track'; - $npArtist.textContent = track.artist ? ` – ${track.artist}` : ''; - $npYear.textContent = track.year ? ` (${track.year})` : ''; - state.revealed = true; - // Show banner - if ($revealBanner) { - if (result.correct) { - $revealBanner.textContent = 'Richtig!'; - $revealBanner.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium'; - } else { - $revealBanner.textContent = 'Falsch!'; - $revealBanner.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium'; - } - } - // Hide placement during reveal - if ($placeArea) $placeArea.classList.add('hidden'); - } - if (msg.type === 'game_ended') { - alert(`Gewinner: ${shortName(msg.winner)}`); - } -} - -// Start connection -connectWS(); -window.addEventListener('online', () => { - if (!wsIsOpen()) { - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - connectWS(); - } -}); - -// UI events -el('setName').onclick = () => { - const name = $nameLobby.value.trim(); - if (!name) return; - localStorage.setItem('playerName', name); - if ($nameDisplay) $nameDisplay.textContent = name; - sendMsg({ type: 'set_name', name }); -}; -el('createRoom').onclick = () => { sendMsg({ type: 'create_room' }); }; -el('joinRoom').onclick = () => { - const code = el('roomCode').value.trim(); - if (code) sendMsg({ type: 'join_room', code }); -}; -el('leaveRoom').onclick = () => { - sendMsg({ type: 'leave_room' }); - state.room = null; showLobby(); -}; -el('startGame').onclick = () => sendMsg({ type: 'start_game' }); -document.getElementById('readyChk').onchange = (e) => { - const val = !!e.target.checked; - state.pendingReady = val; - sendMsg({ type: 'set_ready', ready: val }); -}; -el('earnToken').onclick = () => sendMsg({ type: 'earn_token' }); - -if ($placeBtn) { - $placeBtn.onclick = () => { - const slot = parseInt($slotSelect.value, 10); - sendMsg({ type: 'place_guess', slot }); - }; -} - -if ($playBtn) $playBtn.onclick = () => sendMsg({ type: 'player_control', action: 'play' }); -if ($pauseBtn) $pauseBtn.onclick = () => sendMsg({ type: 'player_control', action: 'pause' }); -if ($nextBtn) $nextBtn.onclick = () => sendMsg({ type: 'next_track' }); - -// Progress + volume updates -if ($audio) { - // Try to preserve pitch during slight playbackRate changes if supported - try { - if ('preservesPitch' in $audio) $audio.preservesPitch = true; - if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true; - if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true; - } catch {} - $audio.addEventListener('timeupdate', () => { - const dur = $audio.duration || 0; - if (!dur || !$progressFill) return; - const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100)); - $progressFill.style.width = pct + '%'; - }); - const showBuffer = (v) => { - state.isBuffering = v; - if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v); - if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused); - }; - $audio.addEventListener('waiting', () => showBuffer(true)); - $audio.addEventListener('stalled', () => showBuffer(true)); - $audio.addEventListener('canplay', () => showBuffer(false)); - $audio.addEventListener('playing', () => showBuffer(false)); - $audio.addEventListener('ended', () => { - if ($recordDisc) $recordDisc.classList.remove('spin-record'); - $audio.playbackRate = 1.0; - }); -} -if ($volumeSlider && $audio) { - // Initialize from current volume - try { $volumeSlider.value = String($audio.volume ?? 1); } catch {} - $volumeSlider.addEventListener('input', () => { - $audio.volume = parseFloat($volumeSlider.value); - }); -} - -// Try to restore room view if server restarts won't preserve sessions; basic behavior only -(() => { - const saved = localStorage.getItem('playerName'); - if (saved) { - if ($nameLobby && $nameLobby.value !== saved) $nameLobby.value = saved; - if ($nameDisplay) $nameDisplay.textContent = saved; - } -})(); diff --git a/public/js/render.js b/public/js/render.js index b0a95ab..062b207 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -56,8 +56,10 @@ export function renderRoom(room) { $readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby'); } const isHost = state.playerId === room.hostId; - const allReady = room.players.length>0 && room.players.every(p=>p.ready); - if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady)); + const activePlayers = room.players.filter(p => !p.spectator && p.connected); + const allReady = activePlayers.length>0 && activePlayers.every(p=>p.ready); + const canStart = room.state.status==='lobby' && isHost && allReady; + if ($startGame) $startGame.classList.toggle('hidden', !canStart); const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack; const canGuess = isMyTurn; // Media controls (play/pause) only for current guesser while guessing and a track is active diff --git a/src/server/game.js b/src/server/game.js index 09c38d7..4d9dd7e 100644 --- a/src/server/game.js +++ b/src/server/game.js @@ -145,9 +145,12 @@ export function setupWebSocket(server) { if (!player.roomId) return; const room = rooms.get(player.roomId); if (!room) return; room.players.delete(player.id); player.roomId = null; if (room.state.ready) delete room.state.ready[player.id]; if (room.state.spectators) delete room.state.spectators[player.id]; if (room.players.size === 0) rooms.delete(room.id); else broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'set_ready') { const room = rooms.get(player.roomId); if (!room) return; const value = !!msg.ready; room.state.ready[player.id] = value; broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'start_game') { - const room = rooms.get(player.roomId); if (!room) return; if (room.hostId !== player.id) return send('error', { message: 'Only host can start' }); - const pids = [...room.players.values()].filter(p => !room.state.spectators?.[p.id]).map(p => p.id); - const allReady = pids.every((pid) => !!room.state.ready?.[pid]); if (!allReady) return send('error', { message: 'All players must be ready' }); + const room = rooms.get(player.roomId); if (!room) return; + if (room.hostId !== player.id) return send('error', { message: 'Only host can start' }); + const active = [...room.players.values()].filter(p => !room.state.spectators?.[p.id] && p.connected); + const allReady = active.length>0 && active.every(p => !!room.state.ready?.[p.id]); + if (!allReady) return send('error', { message: 'All active players must be ready' }); + const pids = active.map(p => p.id); room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0]; room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []])); room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));