diff --git a/app.js b/app.js index 7a381a9..5a8f4a1 100644 --- a/app.js +++ b/app.js @@ -167,13 +167,14 @@ 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.onreadyupdate = (ready, total) => { + $('btn-start-game').disabled = ready < total || total === 0; + }; mesh.createRoom(); show('host'); diff --git a/p2p.js b/p2p.js index 488ffeb..2144ab3 100644 --- a/p2p.js +++ b/p2p.js @@ -2,17 +2,25 @@ class MeshNet { constructor() { this.myPeerId = generatePeerId(); this.connections = new Map(); - this.isHost = false; + this._pendingMeshPeers = new Map(); this._pendingPeer = null; this._currentQRData = null; + this.isHost = false; + this.hostPeerId = null; + this.roster = []; + this.readyPeers = new Set(); + this._meshReadySent = false; this.onpeerconnect = null; this.onpeerdisconnect = null; this.ondata = null; this.onqrupdate = null; this.onanswerready = null; + this.onreadyupdate = null; } + /* ─── Host: room ─── */ + createRoom() { this.isHost = true; this._createPendingPeer(); @@ -25,7 +33,6 @@ class MeshNet { const peer = new SimplePeer({ initiator: true, trickle: false }); let signalSent = false; - let connected = false; peer.on('signal', signal => { if (signalSent) return; @@ -35,7 +42,6 @@ class MeshNet { }); peer.on('connect', () => { - connected = true; peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId })); }); @@ -44,19 +50,14 @@ class MeshNet { const msg = JSON.parse(data.toString()); this._handleMessage(msg, peer); } catch (e) { - console.error('p2p data parse error:', e); + console.error('p2p data error:', e); } }); - peer.on('close', () => { - this._cleanupPeer(peer); - }); + peer.on('close', () => this._cleanupPeer(peer)); + peer.on('error', err => console.error('p2p error:', err)); - peer.on('error', err => { - console.error('p2p error:', err); - }); - - this._pendingPeer = { peer, signalSent, connected, peerId: null }; + this._pendingPeer = { peer, signalSent, connected: false, peerId: null }; } feedAnswer(answerStr) { @@ -71,14 +72,16 @@ class MeshNet { } } + /* ─── Peer: join ─── */ + joinFromQR(qrContent) { const data = JSON.parse(qrContent); const remoteId = data.id; + this.hostPeerId = remoteId; 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; @@ -90,7 +93,6 @@ class MeshNet { }); 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); @@ -100,25 +102,123 @@ class MeshNet { try { const msg = JSON.parse(data.toString()); this._handleMessage(msg, peer); - } catch (e) { - console.error('p2p data parse error:', e); - } + } catch (e) { console.error('p2p data error:', e); } }); - peer.on('close', () => { - this._cleanupPeer(peer); - }); - - peer.on('error', err => { - console.error('p2p error:', err); - }); + 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 }; + this._pendingPeer = { peer, peerId: remoteId, connected: answerSent }; } + /* ─── Mesh helpers ─── */ + + _createSimplePeer(initiator, onSignal, onConnect) { + const peer = new SimplePeer({ initiator, trickle: false }); + + peer.on('signal', signal => { + if (onSignal) onSignal(signal); + }); + + peer.on('connect', () => { + if (onConnect) onConnect(); + // No identity exchange – peerId known from context + }); + + peer.on('data', data => { + try { + const msg = JSON.parse(data.toString()); + this._handleMessage(msg, peer); + } catch (e) { console.error('mesh data error:', e); } + }); + + peer.on('close', () => this._cleanupPeer(peer)); + peer.on('error', e => console.error('mesh error:', e)); + + return peer; + } + + _initiateMeshConnection(targetPeerId) { + if (this.connections.has(targetPeerId)) return; + if (this._pendingMeshPeers.has(targetPeerId)) return; + if (this.myPeerId >= targetPeerId) return; + + const peer = this._createSimplePeer(true, + signal => { + this.sendTo(this.hostPeerId, { type: 'signal_relay', to: targetPeerId, signal }); + }, + () => { + this.connections.set(targetPeerId, peer); + this._pendingMeshPeers.delete(targetPeerId); + if (this.onpeerconnect) this.onpeerconnect(targetPeerId); + this._checkMeshReady(); + } + ); + + this._pendingMeshPeers.set(targetPeerId, peer); + } + + _handleRelayedSignal(fromPeerId, signal) { + let peer = this.connections.get(fromPeerId) || this._pendingMeshPeers.get(fromPeerId); + + if (!peer) { + peer = this._createSimplePeer(false, + signal => { + this.sendTo(this.hostPeerId, { type: 'signal_relay', to: fromPeerId, signal }); + }, + () => { + this.connections.set(fromPeerId, peer); + this._pendingMeshPeers.delete(fromPeerId); + if (this.onpeerconnect) this.onpeerconnect(fromPeerId); + this._checkMeshReady(); + } + ); + this._pendingMeshPeers.set(fromPeerId, peer); + } + + peer.signal(signal); + } + + _checkMeshConnections() { + for (const pid of this.roster) { + if (pid === this.myPeerId) continue; + if (this.connections.has(pid)) continue; + if (this.myPeerId < pid) { + this._initiateMeshConnection(pid); + } + } + this._checkMeshReady(); + } + + _checkMeshReady() { + if (this._meshReadySent) return; + if (!this.hostPeerId) return; + if (!this.connections.has(this.hostPeerId)) return; + + const allConnected = this.roster.every(pid => + pid === this.myPeerId || this.connections.has(pid) + ); + + if (allConnected && this.roster.length > 0) { + this._meshReadySent = true; + this.sendTo(this.hostPeerId, { type: 'mesh_ready' }); + } + } + + _broadcastRoster() { + this.roster = this.getPeerIds(); + this.readyPeers.clear(); + this.broadcast({ type: 'roster', peers: this.roster }); + if (this.onreadyupdate) this.onreadyupdate(0, this.roster.length); + } + + /* ─── Message routing ─── */ + _handleMessage(msg, peer) { + const peerId = this._findPeerId(peer) || 'unknown'; + if (msg.type === 'identity') { const existing = this._findPeerId(peer); if (existing && existing !== msg.peerId) { @@ -132,6 +232,7 @@ class MeshNet { if (this.onpeerconnect) this.onpeerconnect(msg.peerId); if (this.isHost) { setTimeout(() => this._createPendingPeer(), 300); + this._broadcastRoster(); } } else { if (this.onpeerconnect) this.onpeerconnect(msg.peerId); @@ -140,27 +241,51 @@ class MeshNet { } if (msg.type === 'signal_relay') { - const target = this.connections.get(msg.to); + const target = this.connections.get(msg.to) || this._pendingMeshPeers.get(msg.to); if (target) { target.send(JSON.stringify({ type: 'signal_relayed', - from: msg.from, + from: peerId, signal: msg.signal })); } return; } + if (msg.type === 'signal_relayed') { + this._handleRelayedSignal(msg.from, msg.signal); + return; + } + + if (msg.type === 'roster') { + this.roster = msg.peers || []; + this._meshReadySent = false; + this._checkMeshConnections(); + return; + } + + if (msg.type === 'mesh_ready' && this.isHost) { + this.readyPeers.add(peerId); + if (this.onreadyupdate) { + this.onreadyupdate(this.readyPeers.size, this.roster.length); + } + return; + } + if (this.ondata) { - const peerId = this._findPeerId(peer) || 'unknown'; this.ondata(peerId, msg); } } + /* ─── Peer management ─── */ + _findPeerId(peer) { for (const [id, p] of this.connections) { if (p === peer) return id; } + for (const [id, p] of this._pendingMeshPeers) { + if (p === peer) return id; + } return null; } @@ -170,11 +295,21 @@ class MeshNet { this.connections.delete(peerId); if (this.onpeerdisconnect) this.onpeerdisconnect(peerId); } + + for (const [id, p] of this._pendingMeshPeers) { + if (p === peer) { + this._pendingMeshPeers.delete(id); + break; + } + } + if (this._pendingPeer && this._pendingPeer.peer === peer) { this._pendingPeer = null; } } + /* ─── Send ─── */ + broadcast(msg) { const str = JSON.stringify(msg); for (const [id, peer] of this.connections) { @@ -208,7 +343,11 @@ class MeshNet { for (const peer of this.connections.values()) { try { peer.destroy(); } catch (e) {} } + for (const peer of this._pendingMeshPeers.values()) { + try { peer.destroy(); } catch (e) {} + } this.connections.clear(); + this._pendingMeshPeers.clear(); if (this._pendingPeer) { try { this._pendingPeer.peer.destroy(); } catch (e) {} this._pendingPeer = null;