randomp2p/app.js
Ole 14aa83ff6e
All checks were successful
Pin to IPFS / pin (push) Successful in 11s
Debug-Chat per Data-Channel (nur ?debug)
2026-06-14 17:11:17 +02:00

435 lines
14 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);
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();
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(async 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 bytes = decode45(code.data);
const p = await unpack(bytes);
if (p.v === 1) { callback(p, code.data); return; }
} catch(e) {}
}
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.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 = 'Kamera-Fehler';
}
});
$('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);
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 = 'Kamera nicht verfügbar';
}
});
$('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 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();
});
})();