Full mesh topology: relayed signaling via host, glare prevention (smaller peerId initiates), roster broadcast, mesh_ready gating
This commit is contained in:
parent
bdd85db8a7
commit
84a423cee5
2 changed files with 170 additions and 30 deletions
5
app.js
5
app.js
|
|
@ -167,13 +167,14 @@
|
||||||
|
|
||||||
mesh.onpeerconnect = pid => {
|
mesh.onpeerconnect = pid => {
|
||||||
renderPlayers('host-player-list');
|
renderPlayers('host-player-list');
|
||||||
$('btn-start-game').disabled = mesh.getCount() < 1;
|
|
||||||
};
|
};
|
||||||
mesh.onpeerdisconnect = () => {
|
mesh.onpeerdisconnect = () => {
|
||||||
renderPlayers('host-player-list');
|
renderPlayers('host-player-list');
|
||||||
$('btn-start-game').disabled = mesh.getCount() < 1;
|
|
||||||
};
|
};
|
||||||
mesh.onqrupdate = d => showQR('qr-host', d);
|
mesh.onqrupdate = d => showQR('qr-host', d);
|
||||||
|
mesh.onreadyupdate = (ready, total) => {
|
||||||
|
$('btn-start-game').disabled = ready < total || total === 0;
|
||||||
|
};
|
||||||
|
|
||||||
mesh.createRoom();
|
mesh.createRoom();
|
||||||
show('host');
|
show('host');
|
||||||
|
|
|
||||||
195
p2p.js
195
p2p.js
|
|
@ -2,17 +2,25 @@ class MeshNet {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.myPeerId = generatePeerId();
|
this.myPeerId = generatePeerId();
|
||||||
this.connections = new Map();
|
this.connections = new Map();
|
||||||
this.isHost = false;
|
this._pendingMeshPeers = new Map();
|
||||||
this._pendingPeer = null;
|
this._pendingPeer = null;
|
||||||
this._currentQRData = null;
|
this._currentQRData = null;
|
||||||
|
this.isHost = false;
|
||||||
|
this.hostPeerId = null;
|
||||||
|
this.roster = [];
|
||||||
|
this.readyPeers = new Set();
|
||||||
|
this._meshReadySent = false;
|
||||||
|
|
||||||
this.onpeerconnect = null;
|
this.onpeerconnect = null;
|
||||||
this.onpeerdisconnect = null;
|
this.onpeerdisconnect = null;
|
||||||
this.ondata = null;
|
this.ondata = null;
|
||||||
this.onqrupdate = null;
|
this.onqrupdate = null;
|
||||||
this.onanswerready = null;
|
this.onanswerready = null;
|
||||||
|
this.onreadyupdate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Host: room ─── */
|
||||||
|
|
||||||
createRoom() {
|
createRoom() {
|
||||||
this.isHost = true;
|
this.isHost = true;
|
||||||
this._createPendingPeer();
|
this._createPendingPeer();
|
||||||
|
|
@ -25,7 +33,6 @@ class MeshNet {
|
||||||
|
|
||||||
const peer = new SimplePeer({ initiator: true, trickle: false });
|
const peer = new SimplePeer({ initiator: true, trickle: false });
|
||||||
let signalSent = false;
|
let signalSent = false;
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
peer.on('signal', signal => {
|
peer.on('signal', signal => {
|
||||||
if (signalSent) return;
|
if (signalSent) return;
|
||||||
|
|
@ -35,7 +42,6 @@ class MeshNet {
|
||||||
});
|
});
|
||||||
|
|
||||||
peer.on('connect', () => {
|
peer.on('connect', () => {
|
||||||
connected = true;
|
|
||||||
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -44,19 +50,14 @@ class MeshNet {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
this._handleMessage(msg, peer);
|
this._handleMessage(msg, peer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('p2p data parse error:', e);
|
console.error('p2p data error:', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
peer.on('close', () => {
|
peer.on('close', () => this._cleanupPeer(peer));
|
||||||
this._cleanupPeer(peer);
|
peer.on('error', err => console.error('p2p error:', err));
|
||||||
});
|
|
||||||
|
|
||||||
peer.on('error', err => {
|
this._pendingPeer = { peer, signalSent, connected: false, peerId: null };
|
||||||
console.error('p2p error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._pendingPeer = { peer, signalSent, connected, peerId: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
feedAnswer(answerStr) {
|
feedAnswer(answerStr) {
|
||||||
|
|
@ -71,14 +72,16 @@ class MeshNet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Peer: join ─── */
|
||||||
|
|
||||||
joinFromQR(qrContent) {
|
joinFromQR(qrContent) {
|
||||||
const data = JSON.parse(qrContent);
|
const data = JSON.parse(qrContent);
|
||||||
const remoteId = data.id;
|
const remoteId = data.id;
|
||||||
|
this.hostPeerId = remoteId;
|
||||||
const offer = data.s;
|
const offer = data.s;
|
||||||
|
|
||||||
const peer = new SimplePeer({ initiator: false, trickle: false });
|
const peer = new SimplePeer({ initiator: false, trickle: false });
|
||||||
let answerSent = false;
|
let answerSent = false;
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
peer.on('signal', signal => {
|
peer.on('signal', signal => {
|
||||||
if (answerSent) return;
|
if (answerSent) return;
|
||||||
|
|
@ -90,7 +93,6 @@ class MeshNet {
|
||||||
});
|
});
|
||||||
|
|
||||||
peer.on('connect', () => {
|
peer.on('connect', () => {
|
||||||
connected = true;
|
|
||||||
this.connections.set(remoteId, peer);
|
this.connections.set(remoteId, peer);
|
||||||
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||||||
if (this.onpeerconnect) this.onpeerconnect(remoteId);
|
if (this.onpeerconnect) this.onpeerconnect(remoteId);
|
||||||
|
|
@ -100,25 +102,123 @@ class MeshNet {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
this._handleMessage(msg, peer);
|
this._handleMessage(msg, peer);
|
||||||
} catch (e) {
|
} catch (e) { console.error('p2p data error:', e); }
|
||||||
console.error('p2p data parse error:', e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
peer.on('close', () => {
|
peer.on('close', () => this._cleanupPeer(peer));
|
||||||
this._cleanupPeer(peer);
|
peer.on('error', err => console.error('p2p error:', err));
|
||||||
});
|
|
||||||
|
|
||||||
peer.on('error', err => {
|
|
||||||
console.error('p2p error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
peer.signal(offer);
|
peer.signal(offer);
|
||||||
this.connections.set(remoteId, peer);
|
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) {
|
_handleMessage(msg, peer) {
|
||||||
|
const peerId = this._findPeerId(peer) || 'unknown';
|
||||||
|
|
||||||
if (msg.type === 'identity') {
|
if (msg.type === 'identity') {
|
||||||
const existing = this._findPeerId(peer);
|
const existing = this._findPeerId(peer);
|
||||||
if (existing && existing !== msg.peerId) {
|
if (existing && existing !== msg.peerId) {
|
||||||
|
|
@ -132,6 +232,7 @@ class MeshNet {
|
||||||
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||||||
if (this.isHost) {
|
if (this.isHost) {
|
||||||
setTimeout(() => this._createPendingPeer(), 300);
|
setTimeout(() => this._createPendingPeer(), 300);
|
||||||
|
this._broadcastRoster();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||||||
|
|
@ -140,27 +241,51 @@ class MeshNet {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'signal_relay') {
|
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) {
|
if (target) {
|
||||||
target.send(JSON.stringify({
|
target.send(JSON.stringify({
|
||||||
type: 'signal_relayed',
|
type: 'signal_relayed',
|
||||||
from: msg.from,
|
from: peerId,
|
||||||
signal: msg.signal
|
signal: msg.signal
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return;
|
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) {
|
if (this.ondata) {
|
||||||
const peerId = this._findPeerId(peer) || 'unknown';
|
|
||||||
this.ondata(peerId, msg);
|
this.ondata(peerId, msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Peer management ─── */
|
||||||
|
|
||||||
_findPeerId(peer) {
|
_findPeerId(peer) {
|
||||||
for (const [id, p] of this.connections) {
|
for (const [id, p] of this.connections) {
|
||||||
if (p === peer) return id;
|
if (p === peer) return id;
|
||||||
}
|
}
|
||||||
|
for (const [id, p] of this._pendingMeshPeers) {
|
||||||
|
if (p === peer) return id;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,11 +295,21 @@ class MeshNet {
|
||||||
this.connections.delete(peerId);
|
this.connections.delete(peerId);
|
||||||
if (this.onpeerdisconnect) this.onpeerdisconnect(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) {
|
if (this._pendingPeer && this._pendingPeer.peer === peer) {
|
||||||
this._pendingPeer = null;
|
this._pendingPeer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Send ─── */
|
||||||
|
|
||||||
broadcast(msg) {
|
broadcast(msg) {
|
||||||
const str = JSON.stringify(msg);
|
const str = JSON.stringify(msg);
|
||||||
for (const [id, peer] of this.connections) {
|
for (const [id, peer] of this.connections) {
|
||||||
|
|
@ -208,7 +343,11 @@ class MeshNet {
|
||||||
for (const peer of this.connections.values()) {
|
for (const peer of this.connections.values()) {
|
||||||
try { peer.destroy(); } catch (e) {}
|
try { peer.destroy(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
for (const peer of this._pendingMeshPeers.values()) {
|
||||||
|
try { peer.destroy(); } catch (e) {}
|
||||||
|
}
|
||||||
this.connections.clear();
|
this.connections.clear();
|
||||||
|
this._pendingMeshPeers.clear();
|
||||||
if (this._pendingPeer) {
|
if (this._pendingPeer) {
|
||||||
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
||||||
this._pendingPeer = null;
|
this._pendingPeer = null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue