randomp2p/app.js
Ole becd1593b6
All checks were successful
Pin to IPFS / pin (push) Successful in 11s
Pairing-Logs in ?debug: _log() in p2p.js, Panel links unten
2026-06-14 18:33:36 +02:00

478 lines
15 KiB
JavaScript
Raw 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;
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;
$('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();
});
})();