(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 = `
${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;
$('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();
});
})();