(function() { 'use strict'; let mesh = null; let protocol = null; let role = null; let scanningHost = false; const $ = id => document.getElementById(id); /* ─── Screen routing ─── */ function show(name) { ['start','host','join','game','result'].forEach(s => $(`screen-${s}`).classList.toggle('active', s === name) ); } /* ─── Player list ─── */ function renderPlayers(containerId) { const list = $(containerId); if (!list) return; const all = [mesh.myPeerId, ...mesh.getPeerIds()]; const seen = new Set(); list.innerHTML = ''; all.forEach(id => { if (seen.has(id)) return; seen.add(id); const isMe = id === mesh.myPeerId; const li = document.createElement('li'); li.className = 'player-chip' + (isMe ? ' self' : ''); li.innerHTML = `${isMe ? 'Du' : id.slice(0, 8)}`; list.appendChild(li); }); } /* ─── QR ─── */ let qrHost = null; function showQR(containerId, data) { const c = $(containerId); if (!c) return; c.innerHTML = ''; qrHost = new QRCode(c, { text: data, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.L }); } /* ─── Camera ─── */ let camStream = null; let camRaf = null; async function camStart(videoId) { await camStop(); camStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 320 }, height: { ideal: 240 } } }); const v = $(videoId); v.srcObject = camStream; v.setAttribute('playsinline', ''); v.classList.add('active'); await v.play(); return v; } function camStop() { if (camRaf) { cancelAnimationFrame(camRaf); camRaf = null; } if (camStream) { camStream.getTracks().forEach(t => t.stop()); camStream = null; } } function camScan(videoId, canvasId, callback) { const v = $(videoId); const c = $(canvasId); const ctx = c.getContext('2d'); camRaf = requestAnimationFrame(function tick() { if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; } c.width = v.videoWidth; c.height = v.videoHeight; ctx.drawImage(v, 0, 0); const img = ctx.getImageData(0, 0, c.width, c.height); const code = jsQR(img.data, img.width, img.height); if (code && code.data) { try { const p = JSON.parse(code.data); if (p.v === 1) { callback(p, code.data); return; } } catch(e) {} } camRaf = requestAnimationFrame(tick); }); } /* ─── Coin animation ─── */ function flipCoin(resultBit, areaId) { const area = $(areaId); if (!area) return; area.hidden = false; const coin = area.querySelector('.coin'); coin.classList.remove('flipping','result-kopf','result-zahl'); void coin.offsetWidth; coin.classList.add('flipping'); setTimeout(() => { coin.classList.remove('flipping'); coin.classList.add(resultBit === 0 ? 'result-kopf' : 'result-zahl'); }, 1500); } /* ─── Protocol phases UI ─── */ function updatePhases(state) { const map = { IDLE:'ready', STARTING:'ready', COMMITTING:'commit', WAITING_FOR_COMMITS:'commit', REVEALING:'reveal', WAITING_FOR_REVEALS:'reveal', VERIFYING:'verify', COMPLETE:'done', ABORTED:'done' }; const active = map[state] || 'ready'; let doneSeen = false; ['ready','commit','reveal','verify','done'].forEach(p => { const el = $('phase-'+p); if (!el) return; el.classList.toggle('active', p === active && !doneSeen); el.classList.toggle('done', doneSeen); if (p === active) doneSeen = true; }); } /* ─── Game setup ─── */ function setupGame(autostart) { show('game'); renderPlayers('game-player-list'); $('btn-start-protocol').hidden = autostart || role !== 'host'; $('coin-area').hidden = true; $('result-area').hidden = true; $('protocol-text').textContent = autostart ? 'Starte Protokoll...' : 'Bereit zum Münzwurf'; updatePhases('IDLE'); protocol = new CoinFlipProtocol(mesh); protocol.onstatechange = updatePhases; protocol.onprogress = (phase, rcvd, total) => { $('protocol-text').textContent = phase === 'commit' ? `Commits: ${rcvd}/${total}` : `Reveals: ${rcvd}/${total}`; }; protocol.oncomplete = result => { const text = result === 0 ? 'KOPF' : 'ZAHL'; $('protocol-text').textContent = `Ergebnis: ${text}!`; flipCoin(result, 'coin-area'); $('result-area').hidden = false; $('result-text').textContent = text; $('btn-start-protocol').hidden = true; }; protocol.onerror = err => { $('protocol-text').textContent = err; $('result-area').hidden = false; $('result-text').textContent = 'Abgebrochen'; $('btn-start-protocol').hidden = true; }; mesh.ondata = (peerId, msg) => { if (msg.type === 'protocol_start') { if (protocol) protocol.reset(); $('coin-area').hidden = true; $('result-area').hidden = true; $('btn-start-protocol').hidden = true; updatePhases('IDLE'); protocol.start(); return; } if (msg.type === 'protocol' && protocol) { protocol.handleMessage(peerId, msg); } }; if (autostart) { protocol.start(); } } /* ─── Start screen ─── */ $('btn-create').addEventListener('click', () => { role = 'host'; mesh = new MeshNet(); mesh.onpeerconnect = pid => { renderPlayers('host-player-list'); }; mesh.onpeerdisconnect = () => { renderPlayers('host-player-list'); }; mesh.onqrupdate = d => showQR('qr-host', d); mesh.onreadyupdate = (ready, total) => { $('btn-start-game').disabled = ready < total || total === 0; }; mesh.createRoom(); show('host'); renderPlayers('host-player-list'); $('btn-start-game').disabled = true; }); /* ─── Host: scan answer QR ─── */ $('btn-scan-answer').addEventListener('click', async () => { if (camStream) { camStop(); return; } try { await camStart('host-camera'); $('host-scan-status').textContent = 'Warte auf Antwort-QR...'; scanningHost = true; camScan('host-camera', 'host-camera-canvas', (parsed, raw) => { if (!parsed.s || !parsed.id) return; if (parsed.to && parsed.to !== mesh.myPeerId) return; camStop(); scanningHost = false; $('host-scan-status').textContent = 'Verbunden!'; mesh.feedAnswer(raw); $('btn-scan-answer').textContent = 'Antwort-QR scannen'; }); $('btn-scan-answer').textContent = 'Scannen beenden'; } catch (e) { $('host-scan-status').textContent = 'Kamera-Fehler'; } }); /* ─── Join screen ─── */ $('btn-join').addEventListener('click', async () => { role = 'peer'; mesh = new MeshNet(); show('join'); $('join-answer-section').hidden = true; $('join-scan-status').textContent = 'QR-Code scannen...'; mesh.onanswerready = answerData => { $('join-answer-section').hidden = false; showQR('qr-answer', answerData); $('join-scan-status').textContent = 'Antwort-QR dem Host zeigen'; camStop(); }; mesh.onpeerconnect = () => { renderPlayers('join-player-list'); $('join-scan-status').textContent = 'Verbunden – warte auf Start...'; }; mesh.ondata = (peerId, msg) => { if (msg.type === 'protocol_start') { setupGame(true); } }; try { await camStart('join-camera'); let scanned = false; camScan('join-camera', 'join-camera-canvas', (parsed, raw) => { if (scanned) return; if (!parsed.s || !parsed.id) return; scanned = true; $('join-scan-status').textContent = 'Verbinde...'; mesh.joinFromQR(raw); }); } catch (e) { $('join-scan-status').textContent = 'Kamera nicht verfügbar'; } }); /* ─── Host: start game button (in host screen) ─── */ $('btn-start-game').addEventListener('click', () => { setupGame(false); }); /* ─── Host: start protocol button (in game screen) ─── */ $('btn-start-protocol').addEventListener('click', async () => { if (!mesh || !protocol) return; mesh.broadcast({ type: 'protocol_start' }); $('btn-start-protocol').hidden = true; await protocol.start(); }); /* ─── Retry / again ─── */ $('btn-retry').addEventListener('click', () => { if (protocol) protocol.reset(); $('coin-area').hidden = true; $('result-area').hidden = true; $('btn-start-protocol').hidden = role !== 'host'; $('protocol-text').textContent = 'Bereit zum Münzwurf'; updatePhases('IDLE'); }); /* ─── Result screen → play again ─── */ $('btn-play-again').addEventListener('click', () => { if (protocol) protocol.reset(); $('coin-area').hidden = true; $('result-area').hidden = true; setupGame(false); }); /* ─── Back to start ─── */ $('btn-back-start').addEventListener('click', () => { if (mesh) { mesh.destroy(); mesh = null; } protocol = null; camStop(); show('start'); }); })();