diff --git a/app.js b/app.js index c457b5b..43fc7e4 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,10 @@ const $ = id => document.getElementById(id); + if (location.search.includes('debug')) { + document.body.classList.add('debug-mode'); + } + /* ─── Screen routing ─── */ function show(name) { ['start','host','join','game','result'].forEach(s => @@ -179,7 +183,15 @@ mesh.onpeerdisconnect = () => { renderPlayers('host-player-list'); }; - mesh.onqrupdate = d => showQR('qr-host', d); + mesh.onqrupdate = d => { + showQR('qr-host', d); + const raw = $('host-qr-raw'); + if (raw) raw.textContent = d; + const codeEl = $('host-room-code'); + if (codeEl && mesh.roomCode) codeEl.textContent = mesh.roomCode; + const fullSDP = mesh.getFullSDPData ? mesh.getFullSDPData() : null; + if (fullSDP) showQR('qr-host-full', fullSDP); + }; mesh.onreadyupdate = (ready, total) => { $('btn-start-game').disabled = ready < total || total === 0; }; @@ -212,6 +224,19 @@ } }); + $('btn-host-answer-paste').addEventListener('click', () => { + const raw = $('host-answer-paste').value.trim(); + if (!raw) return; + if (!mesh) return; + try { JSON.parse(raw); } catch (e) { + $('host-scan-status').textContent = 'Ungültiges JSON'; + return; + } + mesh.feedAnswer(raw); + $('host-answer-paste').value = ''; + $('host-scan-status').textContent = 'Verbunden!'; + }); + /* ─── Join screen ─── */ $('btn-join').addEventListener('click', async () => { role = 'peer'; @@ -243,16 +268,41 @@ let scanned = false; camScan('join-camera', 'join-camera-canvas', (parsed, raw) => { if (scanned) return; - if (!parsed.s || !parsed.id) return; + if (!parsed.id) return; scanned = true; $('join-scan-status').textContent = 'Verbinde...'; - mesh.joinFromQR(raw); + if (parsed.room) { + $('join-room-input').value = parsed.room; + mesh.joinFromRoomCode(parsed.room); + } else if (parsed.s) { + mesh.joinFromQR(raw); + } }); } catch (e) { $('join-scan-status').textContent = 'Kamera nicht verfügbar'; } }); + $('btn-join-qr-paste').addEventListener('click', () => { + const raw = $('join-qr-paste').value.trim(); + if (!raw) return; + if (!mesh) { show('start'); return; } + try { JSON.parse(raw); } catch (e) { return; } + mesh.joinFromQR(raw); + $('join-qr-paste').value = ''; + $('join-scan-status').textContent = 'Verbinde...'; + }); + + $('btn-join-room').addEventListener('click', () => { + const code = $('join-room-input').value.trim().toUpperCase(); + if (!code) return; + if (!mesh) { show('start'); return; } + camStop(); + $('join-scan-status').textContent = 'Verbinde...'; + $('join-camera').classList.remove('active'); + mesh.joinFromRoomCode(code); + }); + /* ─── Host: start game button (in host screen) ─── */ $('btn-start-game').addEventListener('click', () => { setupGame(false); diff --git a/index.html b/index.html index fd74221..f851d63 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,11 @@
Scannen lassen, um beizutreten
+Auf gleichem Rechner: Code in anderem Tab eingeben
+QR-Code scannen...
+Antwort-QR für den Host:
diff --git a/p2p.js b/p2p.js index 2144ab3..d36a51d 100644 --- a/p2p.js +++ b/p2p.js @@ -5,11 +5,15 @@ class MeshNet { this._pendingMeshPeers = new Map(); this._pendingPeer = null; this._currentQRData = null; + this._fullSDPData = null; this.isHost = false; this.hostPeerId = null; this.roster = []; this.readyPeers = new Set(); this._meshReadySent = false; + this.roomCode = null; + this._processedAnswers = new Set(); + this._answerPollInterval = null; this.onpeerconnect = null; this.onpeerdisconnect = null; @@ -19,11 +23,59 @@ class MeshNet { this.onreadyupdate = null; } + _generateRoomCode() { + return Math.random().toString(36).substring(2, 6).toUpperCase(); + } + + _startAnswerPolling() { + this._stopAnswerPolling(); + this._answerPollInterval = setInterval(() => { + if (!this.isHost || !this.roomCode || !this._pendingPeer || this._pendingPeer.signalReceived) return; + const prefix = `rp2p_answer_${this.roomCode}_`; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix) && !this._processedAnswers.has(key)) { + this._processedAnswers.add(key); + try { + const raw = localStorage.getItem(key); + if (!raw) continue; + const data = JSON.parse(raw); + this.feedAnswer(JSON.stringify({ v: 1, id: data.id, s: data.s })); + } catch (err) { + console.error('poll answer error:', err); + } + } + } + }, 400); + } + + _stopAnswerPolling() { + if (this._answerPollInterval) { + clearInterval(this._answerPollInterval); + this._answerPollInterval = null; + } + } + /* ─── Host: room ─── */ createRoom() { this.isHost = true; + this.roomCode = this._generateRoomCode(); + this._processedAnswers = new Set(); + this._cleanupStorage(); this._createPendingPeer(); + this._startAnswerPolling(); + } + + _cleanupStorage() { + const toRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && (key.startsWith('rp2p_offer_') || key.startsWith('rp2p_answer_'))) { + toRemove.push(key); + } + } + toRemove.forEach(k => localStorage.removeItem(k)); } _createPendingPeer() { @@ -31,13 +83,23 @@ class MeshNet { try { this._pendingPeer.peer.destroy(); } catch (e) {} } - const peer = new SimplePeer({ initiator: true, trickle: false }); + const peer = new SimplePeer({ initiator: true, trickle: false, config: { iceServers: [] } }); let signalSent = false; peer.on('signal', signal => { if (signalSent) return; signalSent = true; - this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, s: signal }); + + const sdpJson = JSON.stringify({ v: 1, id: this.myPeerId, s: signal }); + this._fullSDPData = sdpJson; + + if (this.roomCode) { + localStorage.setItem(`rp2p_offer_${this.roomCode}`, sdpJson); + this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, room: this.roomCode }); + } else { + this._currentQRData = sdpJson; + } + if (this.onqrupdate) this.onqrupdate(this._currentQRData); }); @@ -80,7 +142,7 @@ class MeshNet { this.hostPeerId = remoteId; const offer = data.s; - const peer = new SimplePeer({ initiator: false, trickle: false }); + const peer = new SimplePeer({ initiator: false, trickle: false, config: { iceServers: [] } }); let answerSent = false; peer.on('signal', signal => { @@ -113,10 +175,53 @@ class MeshNet { this._pendingPeer = { peer, peerId: remoteId, connected: answerSent }; } + joinFromRoomCode(code) { + const key = `rp2p_offer_${code.toUpperCase()}`; + const offerStr = localStorage.getItem(key); + if (!offerStr) { + console.error('Room not found:', code); + return; + } + const offerData = JSON.parse(offerStr); + const remoteId = offerData.id; + this.hostPeerId = remoteId; + const offer = offerData.s; + + const peer = new SimplePeer({ initiator: false, trickle: false, config: { iceServers: [] } }); + let answerSent = false; + + peer.on('signal', signal => { + if (answerSent) return; + answerSent = true; + const answerKey = `rp2p_answer_${code.toUpperCase()}_${this.myPeerId}`; + localStorage.setItem(answerKey, JSON.stringify({ id: this.myPeerId, s: signal })); + }); + + peer.on('connect', () => { + 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 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: answerSent }; + } + /* ─── Mesh helpers ─── */ _createSimplePeer(initiator, onSignal, onConnect) { - const peer = new SimplePeer({ initiator, trickle: false }); + const peer = new SimplePeer({ initiator, trickle: false, config: { iceServers: [] } }); peer.on('signal', signal => { if (onSignal) onSignal(signal); @@ -339,7 +444,12 @@ class MeshNet { return this.connections.size; } + getFullSDPData() { + return this._fullSDPData; + } + destroy() { + this._stopAnswerPolling(); for (const peer of this.connections.values()) { try { peer.destroy(); } catch (e) {} } diff --git a/style.css b/style.css index 1804cd9..1bcfcd7 100644 --- a/style.css +++ b/style.css @@ -326,5 +326,86 @@ h3 { .coin.large .coin-face { font-size: 3rem; } } +/* Debug helpers (visible only in ?debug mode) */ +.debug-only { display: none; } +.debug-mode .debug-only { display: revert; } + +.debug-helper { + margin-top: 12px; + text-align: left; + font-size: 0.8rem; +} +.debug-helper summary { + cursor: pointer; + color: var(--text-dim); +} +.debug-helper summary:hover { + color: var(--text); +} +.raw-data { + margin-top: 6px; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-family: monospace; + font-size: 0.7rem; + word-break: break-all; + white-space: pre-wrap; + color: var(--text-dim); + max-height: 100px; + overflow: auto; +} +.debug-helper textarea { + width: 100%; + margin-top: 6px; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: monospace; + font-size: 0.75rem; + resize: vertical; +} +.btn-small { + margin-top: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + cursor: pointer; + font-size: 0.75rem; +} +.btn-small:hover { + background: var(--bg-hover); +} + +/* Room code (debug mode) */ +.room-code { + margin: 16px 0; + text-align: center; +} +.room-code-label { + display: block; + font-size: 0.8rem; + color: var(--text-dim); + margin-bottom: 4px; +} +.room-code-value { + display: inline-block; + font-family: 'Courier New', monospace; + font-size: 2.5rem; + font-weight: 800; + letter-spacing: 6px; + color: var(--gold); + background: var(--bg-card); + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 8px 24px; + user-select: all; +} + /* Utility */ .hidden { display: none !important; }