Initial commit: P2P coin flip with QR signaling and commit-reveal protocol
This commit is contained in:
commit
bdd85db8a7
7 changed files with 1234 additions and 0 deletions
113
README.md
Normal file
113
README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# randomp2p
|
||||||
|
|
||||||
|
**Vertrauensloser Multiplayer-Münzwurf – P2P im Browser, keine Server, kein Trust.**
|
||||||
|
|
||||||
|
Zwei bis N Spieler verbinden sich via WebRTC (simple-peer) und führen ein kryptografisches Commit-Reveal-Protokoll aus, um einen garantiert fairen Münzwurf zu erhalten. Solange **ein** Teilnehmer ehrlich ist, ist das Ergebnis zufällig und manipulationssicher.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **P2P** – Kein Server, kein Account, keine zentrale Instanz
|
||||||
|
- **Trustless** – Commit-Reveal-Protokoll mit SHA-256-Bindung, jeder verifiziert
|
||||||
|
- **QR-Bootstrapping** – SDP-Offers/Answers werden als QR-Codes ausgetauscht
|
||||||
|
- **Multiplayer (2–n)** – Beliebig viele Teilnehmer in einer Runde
|
||||||
|
- **Coin-Animation** – 3D-Münzwurf via CSS
|
||||||
|
- **100% Vanilla JS** – Keine Build-Tools, kein npm, keine Abhängigkeiten außer CDN-Libs
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd randomp2p
|
||||||
|
python3 -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann auf zwei (oder mehr) Geräten `http://localhost:8080` öffnen.
|
||||||
|
|
||||||
|
> WebRTC + Kamera (`getUserMedia`) + Web Crypto API brauchen einen **sicheren Kontext**. Lokal via localhost funktioniert das. Für andere Geräte im selben Netzwerk die lokale IP verwenden.
|
||||||
|
|
||||||
|
## Spielanleitung
|
||||||
|
|
||||||
|
### Host
|
||||||
|
|
||||||
|
1. **"Raum erstellen"** – QR-Code mit SDP Offer wird angezeigt
|
||||||
|
2. **"Antwort-QR scannen"** – Kamera startet, scannt die Antwort-QRs der Beitreter
|
||||||
|
3. Jeder neue Spieler erscheint in der Liste
|
||||||
|
4. **"Münzwurf starten"** – Übergang in den Spiel-Screen
|
||||||
|
5. **"Münzwurf starten"** – Protokoll beginnt (Commit → Reveal → Ergebnis)
|
||||||
|
|
||||||
|
### Beitreter
|
||||||
|
|
||||||
|
1. **"Raum beitreten"** – Kamera startet
|
||||||
|
2. QR des Hosts scannen
|
||||||
|
3. **Antwort-QR zeigen** – Host scannt diesen QR
|
||||||
|
4. Verbindung steht, warten auf Start
|
||||||
|
5. Protokoll läuft automatisch durch – Ergebnis erscheint
|
||||||
|
|
||||||
|
### Protokoll-Phasen
|
||||||
|
|
||||||
|
| Phase | Beschreibung |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Commit** | Jeder Spieler generiert 32 Zufallsbytes und sendet den SHA-256-Hash an alle |
|
||||||
|
| **Warten** | Sammle Commits aller Teilnehmer |
|
||||||
|
| **Reveal** | Jeder sendet die rohen Zufallsbytes an alle |
|
||||||
|
| **Verify** | Jeder prüft: SHA-256(received) == stored_commit |
|
||||||
|
| **Result** | XOR aller Secrets → Parity (0=Kopf, 1=Zahl) |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
randomp2p/
|
||||||
|
├── index.html – 5 Screens + CDN-Libs
|
||||||
|
├── style.css – Dark-Theme, Coin-Animation
|
||||||
|
├── crypto.js – SHA-256, Zufallsbytes, XOR-Combine
|
||||||
|
├── p2p.js – MeshNet: simple-peer, QR-Signaling, Datenkanal
|
||||||
|
├── protocol.js – Commit-Reveal State Machine
|
||||||
|
└── app.js – UI-Routing, Kamera/QR-Scan, Verkabelung
|
||||||
|
```
|
||||||
|
|
||||||
|
### Abhängigkeiten (CDN)
|
||||||
|
|
||||||
|
| Library | Zweck |
|
||||||
|
|---------|-------|
|
||||||
|
| [simple-peer](https://github.com/feross/simple-peer) | WebRTC-Datenkanäle |
|
||||||
|
| [qrcodejs](https://github.com/davidshimjs/qrcodejs) | QR-Code-Generierung |
|
||||||
|
| [jsQR](https://github.com/cozmo/jsQR) | QR-Code-Scanning per Kamera |
|
||||||
|
|
||||||
|
## Netzwerk-Topologie
|
||||||
|
|
||||||
|
**Best Case (gleiches WiFi):**
|
||||||
|
QR-SDP-Austausch zwischen Host und jedem Peer. Host teilt IP-Liste → potentiell volles Mesh über `signal_relay`.
|
||||||
|
|
||||||
|
**Worst Case (NAT/Internet):**
|
||||||
|
Stern-Topologie über den Host. Protokoll funktioniert trotzdem – die Fairness ist nicht von der Topologie abhängig.
|
||||||
|
|
||||||
|
## Warum ist das fair?
|
||||||
|
|
||||||
|
Das Commit-Reveal-Protokoll garantiert:
|
||||||
|
|
||||||
|
1. **Keine späte Manipulation:** Der SHA-256-Commit bindet jeden Spieler vor dem Reveal an sein Secret (Preimage-Resistenz)
|
||||||
|
2. **Keine Absprache nötig:** Solange **ein** Teilnehmer ehrlich Zufallsbytes beisteuert, ist das XOR-Ergebnis zufällig
|
||||||
|
3. **Volle Transparenz:** Jeder Teilnehmer rechnet lokal alle Prüfungen und das Endergebnis
|
||||||
|
|
||||||
|
Mathematisch: `result = S_1 ⊕ S_2 ⊕ ... ⊕ S_n`. Wenn `S_k` echt zufällig und vor `S_1..S_{k-1}, S_{k+1}..S_n` festgelegt wurde, ist `result` zufällig – unabhängig von allen anderen Secrets.
|
||||||
|
|
||||||
|
## IPFS-Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ipfs add -r .
|
||||||
|
# → CID notieren, über IPFS-Gateway aufrufbar
|
||||||
|
# → Gateway-URL als QR-Code in die App einbauen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ausblick / TODOs
|
||||||
|
|
||||||
|
- [ ] Volles Mesh: Peers verbinden sich direkt via relayed signaling
|
||||||
|
- [ ] Timeout + Reconnect bei Verbindungsabbruch
|
||||||
|
- [ ] Raum-Code als Alternative zum QR-Scan
|
||||||
|
- [ ] TURN-Server-Konfiguration für NAT-Traversal
|
||||||
|
- [ ] Mehrere Runden mit Historie
|
||||||
|
- [ ] PWA-Manifest + ServiceWorker für IPFS-Distribution
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
286
app.js
Normal file
286
app.js
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let mesh = null;
|
||||||
|
let protocol = null;
|
||||||
|
let role = null;
|
||||||
|
let scanningHost = false;
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
/* ─── 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 = '';
|
||||||
|
qrHost = new QRCode(c, { text: data, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.L });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Camera ─── */
|
||||||
|
let camStream = null;
|
||||||
|
let camRaf = null;
|
||||||
|
|
||||||
|
async function camStart(videoId) {
|
||||||
|
await camStop();
|
||||||
|
camStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment', width: { ideal: 320 }, height: { ideal: 240 } }
|
||||||
|
});
|
||||||
|
const v = $(videoId);
|
||||||
|
v.srcObject = camStream;
|
||||||
|
v.setAttribute('playsinline', '');
|
||||||
|
v.classList.add('active');
|
||||||
|
await v.play();
|
||||||
|
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');
|
||||||
|
camRaf = requestAnimationFrame(function tick() {
|
||||||
|
if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; }
|
||||||
|
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);
|
||||||
|
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' };
|
||||||
|
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 = 'Fehler: ' + err;
|
||||||
|
};
|
||||||
|
|
||||||
|
mesh.ondata = (peerId, msg) => {
|
||||||
|
if (msg.type === 'protocol_start' && protocol && protocol.state === 'IDLE') {
|
||||||
|
$('btn-start-protocol').hidden = true;
|
||||||
|
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');
|
||||||
|
$('btn-start-game').disabled = mesh.getCount() < 1;
|
||||||
|
};
|
||||||
|
mesh.onpeerdisconnect = () => {
|
||||||
|
renderPlayers('host-player-list');
|
||||||
|
$('btn-start-game').disabled = mesh.getCount() < 1;
|
||||||
|
};
|
||||||
|
mesh.onqrupdate = d => showQR('qr-host', d);
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── 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);
|
||||||
|
$('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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
61
crypto.js
Normal file
61
crypto.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
function generatePeerId() {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSecret() {
|
||||||
|
const secret = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(secret);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(secret) {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', secret);
|
||||||
|
return new Uint8Array(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a, b) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combine(secrets) {
|
||||||
|
const result = new Uint8Array(32);
|
||||||
|
for (const secret of secrets) {
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
result[i] ^= secret[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result[0] & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufToBase64(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBuf(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBuf(hex) {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
118
index.html
Normal file
118
index.html
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>randomp2p – Münzwurf</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- SCREEN: START -->
|
||||||
|
<section id="screen-start" class="screen active">
|
||||||
|
<div class="screen-inner">
|
||||||
|
<h1>randomp2p</h1>
|
||||||
|
<p class="subtitle">Vertrauensloser Münzwurf mit Freunden</p>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button id="btn-create" class="btn-primary">Raum erstellen</button>
|
||||||
|
<button id="btn-join" class="btn-secondary">Raum beitreten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN: HOST LOBBY -->
|
||||||
|
<section id="screen-host" class="screen">
|
||||||
|
<div class="screen-inner">
|
||||||
|
<h2>Dein Raum</h2>
|
||||||
|
<div class="qr-section">
|
||||||
|
<div id="qr-host"></div>
|
||||||
|
<p class="hint">Scannen lassen, um beizutreten</p>
|
||||||
|
</div>
|
||||||
|
<div id="host-players" class="player-section">
|
||||||
|
<h3>Spieler</h3>
|
||||||
|
<ul id="host-player-list" class="player-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div id="host-scan-answer">
|
||||||
|
<button id="btn-scan-answer" class="btn-secondary">Antwort-QR scannen</button>
|
||||||
|
<video id="host-camera" autoplay playsinline muted></video>
|
||||||
|
<canvas id="host-camera-canvas" hidden></canvas>
|
||||||
|
<p id="host-scan-status"></p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-start-game" class="btn-primary" disabled>Münzwurf starten</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN: JOIN LOBBY -->
|
||||||
|
<section id="screen-join" class="screen">
|
||||||
|
<div class="screen-inner">
|
||||||
|
<h2>Raum beitreten</h2>
|
||||||
|
<div class="camera-section">
|
||||||
|
<video id="join-camera" autoplay playsinline muted></video>
|
||||||
|
<canvas id="join-camera-canvas" hidden></canvas>
|
||||||
|
<p id="join-scan-status">QR-Code scannen...</p>
|
||||||
|
</div>
|
||||||
|
<div id="join-answer-section" hidden>
|
||||||
|
<p>Antwort-QR für den Host:</p>
|
||||||
|
<div id="qr-answer"></div>
|
||||||
|
<p class="hint">Zeige diesen QR-Code dem Host</p>
|
||||||
|
</div>
|
||||||
|
<ul id="join-player-list" class="player-list"></ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN: GAME -->
|
||||||
|
<section id="screen-game" class="screen">
|
||||||
|
<div class="screen-inner">
|
||||||
|
<h2>Münzwurf</h2>
|
||||||
|
<div id="game-players" class="player-section">
|
||||||
|
<ul id="game-player-list" class="player-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div id="protocol-status" class="protocol-status">
|
||||||
|
<div class="phase" id="phase-ready">Bereit</div>
|
||||||
|
<div class="phase" id="phase-commit">Commit</div>
|
||||||
|
<div class="phase" id="phase-reveal">Reveal</div>
|
||||||
|
<div class="phase" id="phase-verify">Verifikation</div>
|
||||||
|
<div class="phase" id="phase-done">Ergebnis</div>
|
||||||
|
</div>
|
||||||
|
<div id="protocol-text" class="protocol-text">Warte auf Start...</div>
|
||||||
|
<div id="coin-area" hidden>
|
||||||
|
<div id="coin" class="coin">
|
||||||
|
<div class="coin-inner">
|
||||||
|
<div class="coin-face front">K</div>
|
||||||
|
<div class="coin-face back">Z</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="result-area" hidden>
|
||||||
|
<h3 id="result-text"></h3>
|
||||||
|
<button id="btn-retry" class="btn-primary">Nochmal</button>
|
||||||
|
</div>
|
||||||
|
<button id="btn-start-protocol" class="btn-primary">Münzwurf starten</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN: RESULT OVERLAY -->
|
||||||
|
<section id="screen-result" class="screen">
|
||||||
|
<div class="screen-inner">
|
||||||
|
<div id="result-coin" class="coin large">
|
||||||
|
<div class="coin-inner">
|
||||||
|
<div class="coin-face front">K</div>
|
||||||
|
<div class="coin-face back">Z</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="result-title"></h2>
|
||||||
|
<p id="result-detail"></p>
|
||||||
|
<button id="btn-play-again" class="btn-primary">Nochmal spielen</button>
|
||||||
|
<button id="btn-back-start" class="btn-secondary">Zurück zum Start</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/simple-peer@9.11.1/simplepeer.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||||
|
<script src="crypto.js"></script>
|
||||||
|
<script src="p2p.js"></script>
|
||||||
|
<script src="protocol.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
217
p2p.js
Normal file
217
p2p.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
class MeshNet {
|
||||||
|
constructor() {
|
||||||
|
this.myPeerId = generatePeerId();
|
||||||
|
this.connections = new Map();
|
||||||
|
this.isHost = false;
|
||||||
|
this._pendingPeer = null;
|
||||||
|
this._currentQRData = null;
|
||||||
|
|
||||||
|
this.onpeerconnect = null;
|
||||||
|
this.onpeerdisconnect = null;
|
||||||
|
this.ondata = null;
|
||||||
|
this.onqrupdate = null;
|
||||||
|
this.onanswerready = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom() {
|
||||||
|
this.isHost = true;
|
||||||
|
this._createPendingPeer();
|
||||||
|
}
|
||||||
|
|
||||||
|
_createPendingPeer() {
|
||||||
|
if (this._pendingPeer) {
|
||||||
|
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = new SimplePeer({ initiator: true, trickle: false });
|
||||||
|
let signalSent = false;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
peer.on('signal', signal => {
|
||||||
|
if (signalSent) return;
|
||||||
|
signalSent = true;
|
||||||
|
this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, s: signal });
|
||||||
|
if (this.onqrupdate) this.onqrupdate(this._currentQRData);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('connect', () => {
|
||||||
|
connected = true;
|
||||||
|
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('data', data => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
this._handleMessage(msg, peer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('p2p data parse error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('close', () => {
|
||||||
|
this._cleanupPeer(peer);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('error', err => {
|
||||||
|
console.error('p2p error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._pendingPeer = { peer, signalSent, connected, peerId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
feedAnswer(answerStr) {
|
||||||
|
if (!this._pendingPeer || this._pendingPeer.signalReceived) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(answerStr);
|
||||||
|
this._pendingPeer.peerId = data.id;
|
||||||
|
this._pendingPeer.peer.signal(data.s);
|
||||||
|
this._pendingPeer.signalReceived = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('feedAnswer error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joinFromQR(qrContent) {
|
||||||
|
const data = JSON.parse(qrContent);
|
||||||
|
const remoteId = data.id;
|
||||||
|
const offer = data.s;
|
||||||
|
|
||||||
|
const peer = new SimplePeer({ initiator: false, trickle: false });
|
||||||
|
let answerSent = false;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
peer.on('signal', signal => {
|
||||||
|
if (answerSent) return;
|
||||||
|
answerSent = true;
|
||||||
|
const answerData = JSON.stringify({
|
||||||
|
v: 1, id: this.myPeerId, s: signal, to: remoteId
|
||||||
|
});
|
||||||
|
if (this.onanswerready) this.onanswerready(answerData);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('connect', () => {
|
||||||
|
connected = true;
|
||||||
|
this.connections.set(remoteId, peer);
|
||||||
|
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||||||
|
if (this.onpeerconnect) this.onpeerconnect(remoteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('data', data => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
this._handleMessage(msg, peer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('p2p data parse error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('close', () => {
|
||||||
|
this._cleanupPeer(peer);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('error', err => {
|
||||||
|
console.error('p2p error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.signal(offer);
|
||||||
|
this.connections.set(remoteId, peer);
|
||||||
|
this._pendingPeer = { peer, peerId: remoteId, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMessage(msg, peer) {
|
||||||
|
if (msg.type === 'identity') {
|
||||||
|
const existing = this._findPeerId(peer);
|
||||||
|
if (existing && existing !== msg.peerId) {
|
||||||
|
this.connections.delete(existing);
|
||||||
|
}
|
||||||
|
this.connections.set(msg.peerId, peer);
|
||||||
|
|
||||||
|
if (this._pendingPeer && this._pendingPeer.peer === peer) {
|
||||||
|
this._pendingPeer.peerId = msg.peerId;
|
||||||
|
this._pendingPeer.connected = true;
|
||||||
|
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||||||
|
if (this.isHost) {
|
||||||
|
setTimeout(() => this._createPendingPeer(), 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'signal_relay') {
|
||||||
|
const target = this.connections.get(msg.to);
|
||||||
|
if (target) {
|
||||||
|
target.send(JSON.stringify({
|
||||||
|
type: 'signal_relayed',
|
||||||
|
from: msg.from,
|
||||||
|
signal: msg.signal
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ondata) {
|
||||||
|
const peerId = this._findPeerId(peer) || 'unknown';
|
||||||
|
this.ondata(peerId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_findPeerId(peer) {
|
||||||
|
for (const [id, p] of this.connections) {
|
||||||
|
if (p === peer) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupPeer(peer) {
|
||||||
|
const peerId = this._findPeerId(peer);
|
||||||
|
if (peerId) {
|
||||||
|
this.connections.delete(peerId);
|
||||||
|
if (this.onpeerdisconnect) this.onpeerdisconnect(peerId);
|
||||||
|
}
|
||||||
|
if (this._pendingPeer && this._pendingPeer.peer === peer) {
|
||||||
|
this._pendingPeer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(msg) {
|
||||||
|
const str = JSON.stringify(msg);
|
||||||
|
for (const [id, peer] of this.connections) {
|
||||||
|
try { peer.send(str); } catch (e) { console.error('broadcast to', id, 'failed:', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTo(peerId, msg) {
|
||||||
|
const peer = this.connections.get(peerId);
|
||||||
|
if (peer) {
|
||||||
|
try { peer.send(JSON.stringify(msg)); } catch (e) { console.error('sendTo', peerId, 'failed:', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relaySignal(from, to, signal) {
|
||||||
|
const target = this.connections.get(to);
|
||||||
|
if (target) {
|
||||||
|
target.send(JSON.stringify({ type: 'signal_relay', from, to, signal }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeerIds() {
|
||||||
|
return Array.from(this.connections.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
getCount() {
|
||||||
|
return this.connections.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
for (const peer of this.connections.values()) {
|
||||||
|
try { peer.destroy(); } catch (e) {}
|
||||||
|
}
|
||||||
|
this.connections.clear();
|
||||||
|
if (this._pendingPeer) {
|
||||||
|
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
||||||
|
this._pendingPeer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
protocol.js
Normal file
109
protocol.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
class CoinFlipProtocol {
|
||||||
|
constructor(mesh) {
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.state = 'IDLE';
|
||||||
|
this.secret = null;
|
||||||
|
this.commits = new Map();
|
||||||
|
this.reveals = new Map();
|
||||||
|
this.result = null;
|
||||||
|
this.totalPeers = 0;
|
||||||
|
|
||||||
|
this.onstatechange = null;
|
||||||
|
this.onprogress = null;
|
||||||
|
this.oncomplete = null;
|
||||||
|
this.onerror = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.state !== 'IDLE') return;
|
||||||
|
this.state = 'STARTING';
|
||||||
|
this.totalPeers = this.mesh.getCount() + 1;
|
||||||
|
this.secret = await generateSecret();
|
||||||
|
const myCommit = await commit(this.secret);
|
||||||
|
this.commits.set(this.mesh.myPeerId, myCommit);
|
||||||
|
if (this.onprogress) this.onprogress('commit', 1, this.totalPeers);
|
||||||
|
if (this.onstatechange) this.onstatechange('COMMITTING');
|
||||||
|
this.mesh.broadcast({ type: 'protocol', phase: 'commit', data: bufToBase64(myCommit) });
|
||||||
|
this.state = 'WAITING_FOR_COMMITS';
|
||||||
|
if (this.onstatechange) this.onstatechange('WAITING_FOR_COMMITS');
|
||||||
|
this._checkCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(peerId, msg) {
|
||||||
|
if (msg.type !== 'protocol') return;
|
||||||
|
|
||||||
|
if (msg.phase === 'commit' && (this.state === 'WAITING_FOR_COMMITS' || this.state === 'COMMITTING' || this.state === 'STARTING')) {
|
||||||
|
if (!this.commits.has(peerId)) {
|
||||||
|
this.commits.set(peerId, base64ToBuf(msg.data));
|
||||||
|
if (this.onprogress) this.onprogress('commit', this.commits.size, this.totalPeers);
|
||||||
|
this._checkCommits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.phase === 'reveal' && (this.state === 'WAITING_FOR_REVEALS' || this.state === 'REVEALING')) {
|
||||||
|
if (!this.reveals.has(peerId)) {
|
||||||
|
this.reveals.set(peerId, base64ToBuf(msg.data));
|
||||||
|
if (this.onprogress) this.onprogress('reveal', this.reveals.size, this.totalPeers);
|
||||||
|
this._checkReveals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkCommits() {
|
||||||
|
if (this.state !== 'WAITING_FOR_COMMITS') return;
|
||||||
|
if (this.commits.size >= this.totalPeers) {
|
||||||
|
this._allCommitsReceived();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _allCommitsReceived() {
|
||||||
|
this.state = 'REVEALING';
|
||||||
|
if (this.onstatechange) this.onstatechange('REVEALING');
|
||||||
|
this.mesh.broadcast({ type: 'protocol', phase: 'reveal', data: bufToBase64(this.secret) });
|
||||||
|
this.reveals.set(this.mesh.myPeerId, this.secret);
|
||||||
|
if (this.onprogress) this.onprogress('reveal', 1, this.totalPeers);
|
||||||
|
this.state = 'WAITING_FOR_REVEALS';
|
||||||
|
if (this.onstatechange) this.onstatechange('WAITING_FOR_REVEALS');
|
||||||
|
this._checkReveals();
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkReveals() {
|
||||||
|
if (this.state !== 'WAITING_FOR_REVEALS') return;
|
||||||
|
if (this.reveals.size >= this.totalPeers) {
|
||||||
|
this._allRevealsReceived();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _allRevealsReceived() {
|
||||||
|
this.state = 'VERIFYING';
|
||||||
|
if (this.onstatechange) this.onstatechange('VERIFYING');
|
||||||
|
|
||||||
|
for (const [peerId, secret] of this.reveals) {
|
||||||
|
if (peerId === this.mesh.myPeerId) continue;
|
||||||
|
const commitment = this.commits.get(peerId);
|
||||||
|
if (!commitment) {
|
||||||
|
if (this.onerror) this.onerror(`Fehler: Kein Commit von ${peerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expected = await commit(secret);
|
||||||
|
if (!arraysEqual(expected, commitment)) {
|
||||||
|
if (this.onerror) this.onerror(`Betrug erkannt! ${peerId} hat gefälscht.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'COMPLETE';
|
||||||
|
this.result = combine(Array.from(this.reveals.values()));
|
||||||
|
if (this.onstatechange) this.onstatechange('COMPLETE');
|
||||||
|
if (this.oncomplete) this.oncomplete(this.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.state = 'IDLE';
|
||||||
|
this.secret = null;
|
||||||
|
this.commits.clear();
|
||||||
|
this.reveals.clear();
|
||||||
|
this.result = null;
|
||||||
|
this.totalPeers = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
330
style.css
Normal file
330
style.css
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-card: #161b22;
|
||||||
|
--bg-hover: #1c2333;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #238636;
|
||||||
|
--accent-hover: #2ea043;
|
||||||
|
--border: #30363d;
|
||||||
|
--gold: #f0c040;
|
||||||
|
--silver: #c0c0c0;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screens */
|
||||||
|
.screen {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.screen.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.screen-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--gold), #e0a030);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.btn-primary, .btn-secondary {
|
||||||
|
padding: 14px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR section */
|
||||||
|
.qr-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.qr-section canvas, .qr-section img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player list */
|
||||||
|
.player-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.player-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.player-chip {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.player-chip .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.player-chip .dot.online {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.player-chip .dot.waiting {
|
||||||
|
background: #f0c040;
|
||||||
|
}
|
||||||
|
.player-chip.self {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Camera section */
|
||||||
|
.camera-section {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.camera-section video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Protocol status */
|
||||||
|
.protocol-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.phase {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.phase.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.phase.done {
|
||||||
|
background: #1a4a1a;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-text {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coin flip animation */
|
||||||
|
.coin {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 24px auto;
|
||||||
|
perspective: 600px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.coin.large {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
.coin-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 1.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.coin-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.coin.large .coin-face {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
.coin-face.front {
|
||||||
|
background: radial-gradient(circle at 40% 35%, #ffe066, #f0c040 50%, #c89020);
|
||||||
|
border: 4px solid #b8860b;
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
.coin-face.back {
|
||||||
|
background: radial-gradient(circle at 40% 35%, #e8e8e8, #c0c0c0 50%, #909090);
|
||||||
|
border: 4px solid #707070;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
.coin.flipping .coin-inner {
|
||||||
|
animation: coinFlip 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes coinFlip {
|
||||||
|
0% { transform: rotateY(0deg); }
|
||||||
|
60% { transform: rotateY(720deg); }
|
||||||
|
80% { transform: rotateY(800deg); }
|
||||||
|
100% { transform: rotateY(720deg); }
|
||||||
|
}
|
||||||
|
.coin.result-kopf .coin-inner {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
.coin.result-zahl .coin-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result area */
|
||||||
|
#result-area {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
#result-text {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
#result-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
}
|
||||||
|
#result-detail {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Host camera (scan answer) */
|
||||||
|
#host-scan-answer {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
#host-camera {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: none;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
#host-camera.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#host-scan-status {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Join answer QR */
|
||||||
|
#join-answer-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
#join-answer-section .qr-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.screen {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6rem; }
|
||||||
|
.phase {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.coin { width: 100px; height: 100px; }
|
||||||
|
.coin.large { width: 140px; height: 140px; }
|
||||||
|
.coin-face { font-size: 2rem; }
|
||||||
|
.coin.large .coin-face { font-size: 3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.hidden { display: none !important; }
|
||||||
Loading…
Reference in a new issue