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