361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
(function() {
|
||
'use strict';
|
||
|
||
let mesh = null;
|
||
let protocol = null;
|
||
let role = null;
|
||
let scanningHost = false;
|
||
|
||
const $ = id => document.getElementById(id);
|
||
|
||
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' : '');
|
||
li.innerHTML = `<span class="dot online"></span>${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 = '';
|
||
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();
|
||
try {
|
||
camStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }
|
||
});
|
||
} catch (_) {
|
||
camStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||
}
|
||
const v = $(videoId);
|
||
v.srcObject = camStream;
|
||
v.setAttribute('playsinline', '');
|
||
v.classList.add('active');
|
||
await v.play();
|
||
try {
|
||
const track = camStream.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;
|
||
camRaf = requestAnimationFrame(function tick(now) {
|
||
if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; }
|
||
if (now - lastScan < 100) { camRaf = requestAnimationFrame(tick); return; }
|
||
lastScan = now;
|
||
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' });
|
||
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);
|
||
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', (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';
|
||
}
|
||
});
|
||
|
||
$('btn-host-answer-paste').addEventListener('click', () => {
|
||
const raw = $('host-answer-paste').value.trim();
|
||
if (!raw) return;
|
||
if (!mesh) return;
|
||
try { JSON.parse(raw); } catch (e) {
|
||
$('host-scan-status').textContent = 'Ungültiges JSON';
|
||
return;
|
||
}
|
||
mesh.feedAnswer(raw);
|
||
$('host-answer-paste').value = '';
|
||
$('host-scan-status').textContent = 'Verbunden!';
|
||
});
|
||
|
||
/* ─── 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);
|
||
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', (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';
|
||
}
|
||
});
|
||
|
||
$('btn-join-qr-paste').addEventListener('click', () => {
|
||
const raw = $('join-qr-paste').value.trim();
|
||
if (!raw) return;
|
||
if (!mesh) { show('start'); return; }
|
||
try { JSON.parse(raw); } catch (e) { return; }
|
||
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();
|
||
$('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');
|
||
});
|
||
|
||
})();
|