randomp2p/app.js
Niels Göttsch 1dbeb16e86
All checks were successful
Pin to IPFS / pin (push) Successful in 10s
Scan: throttled Erfolgs-Log + sichtbare Decode-Fehler statt stillem catch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:26:34 +02:00

529 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function() {
'use strict';
let mesh = null;
let protocol = null;
let role = null;
let scanningHost = false;
let _nextReady = new Set();
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 = '';
c.dataset.qrdata = data;
c.style.cursor = 'pointer';
c.title = 'QR kopieren';
c.onclick = async () => {
try {
await navigator.clipboard.writeText(data);
const orig = c.innerHTML;
c.textContent = 'Kopiert!';
setTimeout(() => { c.innerHTML = orig; }, 1200);
} catch (e) {}
};
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;
let lastLog = 0;
const logThrottled = (now, msg) => {
if (now - lastLog < 1000) return;
lastLog = now;
addPairingLog(msg);
};
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;
let raw = null;
try {
raw = await detect();
if (raw) {
const p = await unpack(raw);
if (p.v === 1) {
logThrottled(now, 'QR gescannt ✓');
callback(p, raw);
return;
}
}
} catch (e) {
if (raw) logThrottled(now, `QR gescannt, aber ungültige Daten: ${e.message}`);
}
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;
});
}
/* ─── Show start-protocol button after all ready ─── */
function _showStartButton() {
$('btn-start-protocol').hidden = false;
$('result-area').hidden = true;
$('results-container').hidden = true;
$('protocol-text').textContent = 'Bereit zum Münzwurf';
$('btn-retry').disabled = false;
$('btn-retry').textContent = 'Nochmal';
updatePhases('IDLE');
}
/* ─── Game setup ─── */
function setupGame() {
_nextReady = new Set();
show('game');
renderPlayers('game-player-list');
$('btn-start-protocol').hidden = false;
$('results-container').hidden = true;
$('result-area').hidden = true;
$('protocol-text').textContent = '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 === 'ready_next') {
_nextReady.add(peerId);
const all = [mesh.myPeerId, ...mesh.getPeerIds()];
if (_nextReady.size >= all.length) {
mesh.broadcast({ type: 'ready_next_all' });
_showStartButton();
}
return;
}
if (msg.type === 'ready_next_all') {
_showStartButton();
return;
}
if (msg.type === 'protocol' && protocol) {
protocol.handleMessage(peerId, msg);
}
};
}
/* ─── 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);
if (typeof d !== 'string') {
const raw = $('host-qr-raw');
if (raw) raw.textContent = 'Kein String: ' + typeof d;
return;
}
unpack(d).then(obj => {
const raw = $('host-qr-raw');
if (raw) raw.textContent = JSON.stringify(obj, null, 2);
}).catch(err => {
const raw = $('host-qr-raw');
if (raw) raw.textContent = 'Fehler: ' + (err && err.stack ? err.stack : String(err));
console.error('unpack error:', err);
});
};
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(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);
unpack(answerData).then(obj => {
const raw = $('answer-raw');
if (raw) raw.textContent = JSON.stringify(obj, null, 2);
}).catch(() => {});
$('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 === 'game_start') {
setupGame();
}
};
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(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', () => {
if (!mesh) return;
mesh.broadcast({ type: 'game_start' });
setupGame();
});
/* ─── Host: start protocol button (in game screen) ─── */
$('btn-start-protocol').addEventListener('click', async () => {
if (!mesh || !protocol) return;
_nextReady = new Set();
$('btn-start-protocol').hidden = true;
await protocol.start();
});
/* ─── Retry / again ─── */
$('btn-retry').addEventListener('click', () => {
if (protocol) protocol.reset();
_nextReady.add(mesh.myPeerId);
mesh.broadcast({ type: 'ready_next' });
$('btn-retry').disabled = true;
$('btn-retry').textContent = 'Warte...';
});
/* ─── Result screen → play again ─── */
$('btn-play-again').addEventListener('click', () => {
if (protocol) protocol.reset();
$('results-container').hidden = true;
$('result-area').hidden = true;
setupGame();
});
/* ─── 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();
});
})();