479 lines
15 KiB
JavaScript
479 lines
15 KiB
JavaScript
(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 = `<span class="dot online"></span>${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 = `
|
||
<div class="coin mini">
|
||
<div class="coin-inner">
|
||
<div class="coin-face front">K</div>
|
||
<div class="coin-face back">Z</div>
|
||
</div>
|
||
</div>
|
||
<div class="result-label">${displayName}</div>
|
||
`;
|
||
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();
|
||
});
|
||
|
||
})();
|