(function() { 'use strict'; let mesh = null; let protocol = null; let role = null; let scanningHost = false; const $ = id => document.getElementById(id); let barcodeDetector = null; (async () => { if ('BarcodeDetector' in window) { try { const f = await BarcodeDetector.getSupportedFormats(); if (f.includes('qr_code')) barcodeDetector = new BarcodeDetector({ formats: ['qr_code'] }); } catch (_) {} } })(); if (location.search.includes('debug')) { document.body.classList.add('debug-mode'); } document.querySelectorAll('.raw-data').forEach(el => { el.style.cursor = 'pointer'; el.title = 'Klicken zum Kopieren'; el.addEventListener('click', async () => { try { await navigator.clipboard.writeText(el.textContent); const orig = el.textContent; el.textContent = 'Kopiert!'; setTimeout(() => { el.textContent = orig; }, 1200); } catch (e) { const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); document.execCommand('copy'); sel.removeAllRanges(); } }); }); /* ─── 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' : ''); const displayName = isMe ? 'Du' : mesh.getPeerName(id); li.innerHTML = `${displayName}`; list.appendChild(li); }); } /* ─── QR ─── */ let qrHost = null; function showQR(containerId, data) { const c = $(containerId); if (!c) return; c.innerHTML = ''; const size = Math.min(window.innerWidth - 64, 360); qrHost = new QRCode(c, { text: data, width: size, height: size, correctLevel: QRCode.CorrectLevel.L }); } /* ─── Camera ─── */ let camStream = null; let camRaf = null; async function camStart(videoId) { await camStop(); let stream = null; const resolutions = [ { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }, { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, { video: true } ]; for (const vc of resolutions) { try { stream = await navigator.mediaDevices.getUserMedia({ video: vc }); break; } catch (_) {} } if (!stream) { if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') { throw new Error('Kamera benötigt HTTPS – verwende localhost oder lade ein Zertifikat'); } throw new Error('Kamera nicht verfügbar oder verweigert'); } camStream = stream; const v = $(videoId); v.srcObject = stream; v.setAttribute('playsinline', ''); v.classList.add('active'); await v.play(); try { const track = stream.getVideoTracks()[0]; if (track) track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }); } catch (_) {} 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', { willReadFrequently: true }); let lastScan = 0; const detect = barcodeDetector ? async () => { const codes = await barcodeDetector.detect(v); return codes.length ? codes[0].rawValue : null; } : async () => { 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, { inversionAttempts: 'dontInvert' }); return code ? code.data : null; }; camRaf = requestAnimationFrame(async function tick(now) { if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; } if (now - lastScan < 100) { camRaf = requestAnimationFrame(tick); return; } lastScan = now; try { const raw = await detect(); if (raw) { const bytes = decode45(raw); const p = await unpack(bytes); if (p.v === 1) { callback(p, raw); return; } } } catch (_) {} camRaf = requestAnimationFrame(tick); }); } /* ─── Coin animation ─── */ function flipCoinEl(coinEl, resultBit) { coinEl.classList.remove('flipping','result-kopf','result-zahl'); void coinEl.offsetWidth; coinEl.classList.add('flipping'); setTimeout(() => { coinEl.classList.remove('flipping'); coinEl.classList.add(resultBit === 0 ? 'result-kopf' : 'result-zahl'); }, 1500); } function renderResults(results) { const container = $('results-container'); container.innerHTML = ''; container.hidden = false; const all = [mesh.myPeerId, ...mesh.getPeerIds()]; const seen = new Set(); all.forEach(id => { if (seen.has(id)) return; seen.add(id); const r = results.get(id); if (r === undefined) return; const isMe = id === mesh.myPeerId; const displayName = isMe ? 'Du' : mesh.getPeerName(id); const item = document.createElement('div'); item.className = 'result-item'; item.dataset.result = r; item.innerHTML = `
K
Z
${displayName}
`; container.appendChild(item); }); requestAnimationFrame(() => { const coins = container.querySelectorAll('.coin'); coins.forEach((coin, i) => { const r = parseInt(coin.closest('.result-item').dataset.result); setTimeout(() => flipCoinEl(coin, r), i * 400); }); }); } /* ─── 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'; $('results-container').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 = results => { $('protocol-text').textContent = 'Ergebnisse'; renderResults(results); $('result-area').hidden = false; $('result-text').textContent = 'Fertig'; $('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(); $('results-container').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'; const name = $('player-name').value.trim(); mesh = new MeshNet(); mesh.playerName = name; mesh.names.set(mesh.myPeerId, name || mesh.myPeerId.slice(0, 8)); mesh.onchat = (peerId, text) => addChatMessage(mesh.getPeerName(peerId), text); mesh.onlog = addPairingLog; mesh.onpeerconnect = pid => { renderPlayers('host-player-list'); }; mesh.onpeerdisconnect = () => { renderPlayers('host-player-list'); }; mesh.onqrupdate = d => { showQR('qr-host', d); const raw = $('host-qr-raw'); if (raw) raw.textContent = 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', async (parsed, raw) => { if (!parsed.s || !parsed.id) return; if (parsed.to && parsed.to !== mesh.myPeerId) return; camStop(); scanningHost = false; $('host-scan-status').textContent = 'Verbunden!'; await mesh.feedAnswer(raw); $('btn-scan-answer').textContent = 'Antwort-QR scannen'; }); $('btn-scan-answer').textContent = 'Scannen beenden'; } catch (e) { $('host-scan-status').textContent = e.message; } }); $('btn-host-answer-paste').addEventListener('click', async () => { const raw = $('host-answer-paste').value.trim(); if (!raw) return; if (!mesh) return; try { await unpack(decode45(raw)); } catch (e) { $('host-scan-status').textContent = 'Ungültige Daten'; return; } await mesh.feedAnswer(raw); $('host-answer-paste').value = ''; $('host-scan-status').textContent = 'Verbunden!'; }); /* ─── Join screen ─── */ $('btn-join').addEventListener('click', async () => { role = 'peer'; const name = $('player-name').value.trim(); mesh = new MeshNet(); mesh.playerName = name; mesh.names.set(mesh.myPeerId, name || mesh.myPeerId.slice(0, 8)); mesh.onchat = (peerId, text) => addChatMessage(mesh.getPeerName(peerId), text); mesh.onlog = addPairingLog; 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); const raw = $('answer-raw'); if (raw) raw.textContent = 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', async (parsed, raw) => { if (scanned) return; if (!parsed.s || !parsed.id) return; scanned = true; $('join-scan-status').textContent = 'Verbinde...'; await mesh.joinFromQR(raw); }); } catch (e) { $('join-scan-status').textContent = e.message; } }); $('btn-join-qr-paste').addEventListener('click', async () => { const raw = $('join-qr-paste').value.trim(); if (!raw) return; if (!mesh) { show('start'); return; } try { await unpack(decode45(raw)); } catch (e) { return; } await mesh.joinFromQR(raw); $('join-qr-paste').value = ''; $('join-scan-status').textContent = 'Verbinde...'; }); /* ─── 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(); $('results-container').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(); $('results-container').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'); }); /* ─── Debug pairing log ─── */ const pairingMessages = $('debug-pairing-messages'); function addPairingLog(msg) { if (!pairingMessages) return; const div = document.createElement('div'); const t = new Date().toLocaleTimeString('de-DE', { hour12: false }); div.textContent = `[${t}] ${msg}`; pairingMessages.appendChild(div); pairingMessages.scrollTop = pairingMessages.scrollHeight; } /* ─── Debug chat ─── */ const chatInput = $('debug-chat-text'); const chatMessages = $('debug-chat-messages'); function addChatMessage(from, text) { if (!chatMessages) return; const div = document.createElement('div'); div.textContent = `${from}: ${text}`; chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; } $('debug-chat-send').addEventListener('click', () => { const text = chatInput.value.trim(); if (!text || !mesh) return; mesh.broadcast({ type: 'chat', text }); addChatMessage('Du', text); chatInput.value = ''; }); chatInput.addEventListener('keydown', e => { if (e.key === 'Enter') $('debug-chat-send').click(); }); })();