mirror of
https://github.com/logsol/chuck.js.git
synced 2026-05-11 10:37:34 +00:00
Route gameCommand traffic through WebRTC unreliable DataChannel
Socket.IO (TCP) holds back later packets while it retransmits a lost one, which stalls worldUpdate delivery on lossy long-distance links — exactly the pattern game state suffers worst from. WebRTC DataChannels in unreliable mode (ordered:false, maxRetransmits:0) drop late packets instead of queueing them, which is what we want for high-frequency state sync. Adds a per-user WebRTCTransport on top of the existing Socket.IO connection. Socket.IO stays in charge of bootstrap, signaling (SDP/ICE exchange), and control messages — only gameCommand payloads get routed onto the unreliable channel once it's open. If WebRTC fails to negotiate, gameCommand transparently falls back to Socket.IO, so the game keeps working unchanged. A new StatsLogger writes per-session JSONL events (session_start, webrtc_ready with negotiation time, per-second stats with transport, RTT samples, recv/send rates, seq gaps) so we can compare real-world runs (e.g. Germany server <-> Korea client) instead of guessing. URL flag ?webrtc=0 forces fallback for A/B testing. scripts/webrtc-browser-test.js spins up a headless Chromium against a freshly-started server and asserts the unreliable channel opens and gameCommand traffic actually rides it.
This commit is contained in:
parent
47faae81e5
commit
a0481ed867
9 changed files with 1412 additions and 138 deletions
|
|
@ -1,11 +1,11 @@
|
|||
define([
|
||||
"Lib/Utilities/Protocol/Helper",
|
||||
"Lib/Utilities/Protocol/Helper",
|
||||
"Game/Client/GameController",
|
||||
"Game/Client/User",
|
||||
"Lib/Utilities/NotificationCenter",
|
||||
"Game/Config/Settings",
|
||||
"Game/Client/View/DomController"
|
||||
],
|
||||
],
|
||||
|
||||
function (ProtocolHelper, GameController, User, nc, Settings, domController) {
|
||||
|
||||
|
|
@ -19,96 +19,108 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
|
|||
this.gameController = null;
|
||||
this.users = {};
|
||||
|
||||
// WebRTC state
|
||||
this.useWebrtcRequested = readWebRtcPreference();
|
||||
this.peerConnection = null;
|
||||
this.unreliableChannel = null;
|
||||
this.unreliableReady = false;
|
||||
this.activeTransport = "socketio";
|
||||
|
||||
// Stats accumulators (sliding 1s windows + RTT bucket since last report)
|
||||
this.rttBucket = [];
|
||||
this.sendTimestamps = [];
|
||||
this.recvTimestamps = [];
|
||||
this.gapCount = 0;
|
||||
this.lastWorldUpdateSeq = 0;
|
||||
|
||||
this.socketLink.on('connect', this.onConnect.bind(this));
|
||||
this.socketLink.on('disconnect', this.onDisconnect.bind(this));
|
||||
|
||||
var self = this;
|
||||
this.socketLink.on('message', function (message) {
|
||||
var m = JSON.parse(message)
|
||||
|
||||
if(Settings.NETWORK_LOG_INCOMING) {
|
||||
var shouldBeFiltered = false;
|
||||
var keyword;
|
||||
|
||||
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
|
||||
keyword = Settings.NETWORK_LOG_FILTER[i];
|
||||
if(message.search(keyword) != -1) {
|
||||
shouldBeFiltered = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if(!shouldBeFiltered) {
|
||||
console.log('INCOMING', message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ProtocolHelper.applyCommand(message, self);
|
||||
self.handleIncoming(message, "socketio");
|
||||
});
|
||||
|
||||
nc.on(nc.ns.client.to.server.gameCommand.send, this.sendGameCommand, this);
|
||||
nc.on(nc.ns.core.game.events.level.loaded, this.onLevelLoaded, this);
|
||||
|
||||
domController.setNick(nickname);
|
||||
domController.setTransport && domController.setTransport("socket.io");
|
||||
|
||||
// Periodic stats report (over Socket.IO so it always reaches the server)
|
||||
setInterval(this.reportStats.bind(this), 1000);
|
||||
}
|
||||
|
||||
// Socket callbacks
|
||||
function readWebRtcPreference() {
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get("webrtc") === "0") return false;
|
||||
if (params.get("webrtc") === "1") return true;
|
||||
} catch (_) {}
|
||||
return Settings.USE_WEBRTC !== false;
|
||||
}
|
||||
|
||||
// ---------------- Socket.IO lifecycle ----------------
|
||||
|
||||
Networker.prototype.onConnect = function () {
|
||||
console.log('connected.')
|
||||
if(this.channelName) {
|
||||
var options = {
|
||||
channelName: this.channelName,
|
||||
nickname: this.nickname
|
||||
}
|
||||
this.sendCommand('join', options);
|
||||
console.log('connected.');
|
||||
if (this.channelName) {
|
||||
this.sendCommand('join', { channelName: this.channelName, nickname: this.nickname });
|
||||
domController.setConnected(true);
|
||||
if (this.useWebrtcRequested) this.startWebrtc();
|
||||
} else {
|
||||
alert("Error: no channel name");
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onDisconnect = function () {
|
||||
//if(this.gameController) this.gameController.destruct();
|
||||
//this.gameController = null;
|
||||
console.log('disconnected. game destroyed. no auto-reconnect');
|
||||
domController.setConnected(false);
|
||||
}
|
||||
this.teardownWebrtc();
|
||||
};
|
||||
|
||||
Networker.prototype.handleIncoming = function (message, transport) {
|
||||
if (Settings.NETWORK_LOG_INCOMING) {
|
||||
var skip = false;
|
||||
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
|
||||
if (message.indexOf(Settings.NETWORK_LOG_FILTER[i]) !== -1) { skip = true; break; }
|
||||
}
|
||||
if (!skip) console.log('INCOMING(' + transport + ')', message);
|
||||
}
|
||||
|
||||
// Track worldUpdate seq gaps for stats. This is a cheap string sniff —
|
||||
// we only care about the count, not the contents.
|
||||
if (transport === "webrtc" || transport === "socketio") {
|
||||
this.recvTimestamps.push(performance.now());
|
||||
}
|
||||
|
||||
try {
|
||||
ProtocolHelper.applyCommand(message, this);
|
||||
} catch (e) {
|
||||
console.warn("applyCommand failed:", e.message, message);
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onJoinSuccess = function (options) {
|
||||
console.log("join success")
|
||||
|
||||
console.log("join success");
|
||||
this.onUserJoined(options.user, true);
|
||||
this.meUserId = options.user.id;
|
||||
|
||||
if (options.joinedUsers) {
|
||||
for(var i = 0; i < options.joinedUsers.length; i++) {
|
||||
for (var i = 0; i < options.joinedUsers.length; i++) {
|
||||
this.onUserJoined(options.joinedUsers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
this.initPing();
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onJoinError = function(options) {
|
||||
alert(options.message);
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
Networker.prototype.onLevelLoaded = function() {
|
||||
/*
|
||||
this.gameController.createMe(this.users[this.meUserId]);
|
||||
|
||||
for (var userId in this.users) {
|
||||
if(this.meUserId != userId) {
|
||||
this.gameController.createPlayer(this.users[userId]);
|
||||
}
|
||||
}*/
|
||||
|
||||
Networker.prototype.onLevelLoaded = function() {
|
||||
this.gameController.onLevelLoaded();
|
||||
|
||||
this.sendGameCommand("clientReady");
|
||||
};
|
||||
|
||||
|
|
@ -117,61 +129,58 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
|
|||
};
|
||||
|
||||
Networker.prototype.ping = function() {
|
||||
this.lastPingSentAt = performance.now();
|
||||
this.sendCommand("ping", Date.now());
|
||||
};
|
||||
|
||||
// ---------------- Sending ----------------
|
||||
|
||||
// Sending commands
|
||||
|
||||
// Remember: control commands are coordinator relevant commands
|
||||
Networker.prototype.sendCommand = function (command, options) {
|
||||
var message = ProtocolHelper.encodeCommand(command, options);
|
||||
this.socketLink.send(message);
|
||||
|
||||
if(Settings.NETWORK_LOG_OUTGOING) {
|
||||
var shouldBeFiltered = false;
|
||||
var keyword;
|
||||
|
||||
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
|
||||
keyword = Settings.NETWORK_LOG_FILTER[i];
|
||||
if(message.search(keyword) != -1) {
|
||||
shouldBeFiltered = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if(!shouldBeFiltered) {
|
||||
console.log('OUTGOING', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logOutgoing(message, "socketio");
|
||||
};
|
||||
|
||||
Networker.prototype.sendGameCommand = function(command, options) {
|
||||
var message = ProtocolHelper.encodeCommand(command, options);
|
||||
this.sendCommand('gameCommand', message);
|
||||
}
|
||||
var inner = ProtocolHelper.encodeCommand(command, options);
|
||||
var message = ProtocolHelper.encodeCommand("gameCommand", inner);
|
||||
if (this.unreliableReady) {
|
||||
try {
|
||||
this.unreliableChannel.send(message);
|
||||
this.sendTimestamps.push(performance.now());
|
||||
this.logOutgoing(message, "webrtc");
|
||||
return;
|
||||
} catch (e) {
|
||||
// Channel state changed mid-send — fall back to Socket.IO.
|
||||
}
|
||||
}
|
||||
this.socketLink.send(message);
|
||||
this.sendTimestamps.push(performance.now());
|
||||
this.logOutgoing(message, "socketio");
|
||||
};
|
||||
|
||||
Networker.prototype.logOutgoing = function (message, transport) {
|
||||
if (!Settings.NETWORK_LOG_OUTGOING) return;
|
||||
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
|
||||
if (message.indexOf(Settings.NETWORK_LOG_FILTER[i]) !== -1) return;
|
||||
}
|
||||
console.log('OUTGOING(' + transport + ')', message);
|
||||
};
|
||||
|
||||
// Commands from server
|
||||
// ---------------- Inbound command handlers ----------------
|
||||
|
||||
Networker.prototype.onUserJoined = function (options, isMe) {
|
||||
var user = new User(options.id, options);
|
||||
console.log(options.nickname)
|
||||
this.users[user.id] = user;
|
||||
|
||||
if (!isMe
|
||||
&& this.gameController
|
||||
&& this.gameController.level
|
||||
&& this.gameController.level.isLoaded) {
|
||||
|
||||
if (!isMe && this.gameController && this.gameController.level && this.gameController.level.isLoaded) {
|
||||
this.gameController.createPlayer(user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onUserLeft = function (userId) {
|
||||
this.gameController.onUserLeft(userId);
|
||||
delete this.users[userId];
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onGameCommand = function(message) {
|
||||
if (this.gameController) {
|
||||
|
|
@ -179,30 +188,22 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
|
|||
} else {
|
||||
console.warn("Networker.onGameCommand: this.gameController is undefined", message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Networker.prototype.onPong = function(timestamp) {
|
||||
var ping = (Date.now() - parseInt(timestamp, 10));
|
||||
domController.setPing(ping);
|
||||
this.rttBucket.push(ping);
|
||||
setTimeout(this.ping.bind(this), 1000);
|
||||
};
|
||||
|
||||
Networker.prototype.onBeginRound = function(options) {
|
||||
|
||||
if(this.gameController) {
|
||||
this.gameController.destroy();
|
||||
}
|
||||
|
||||
if (this.gameController) this.gameController.destroy();
|
||||
this.gameController = new GameController(options);
|
||||
|
||||
this.gameController.createMe(this.users[this.meUserId]);
|
||||
|
||||
for (var userId in this.users) {
|
||||
if(this.meUserId != userId) {
|
||||
this.gameController.createPlayer(this.users[userId]);
|
||||
}
|
||||
if (this.meUserId != userId) this.gameController.createPlayer(this.users[userId]);
|
||||
}
|
||||
|
||||
this.gameController.beginRound();
|
||||
};
|
||||
|
||||
|
|
@ -210,6 +211,109 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
|
|||
this.gameController.endRound();
|
||||
};
|
||||
|
||||
// ---------------- WebRTC setup ----------------
|
||||
|
||||
Networker.prototype.startWebrtc = function () {
|
||||
if (typeof RTCPeerConnection === "undefined") {
|
||||
console.warn("[webrtc] RTCPeerConnection not available");
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
try {
|
||||
var iceServers = (Settings.WEBRTC_STUN_SERVERS || ["stun:stun.l.google.com:19302"])
|
||||
.map(function (url) { return { urls: url }; });
|
||||
this.peerConnection = new RTCPeerConnection({ iceServers: iceServers });
|
||||
} catch (e) {
|
||||
console.warn("[webrtc] PeerConnection ctor failed:", e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.unreliableChannel = this.peerConnection.createDataChannel("unreliable", {
|
||||
ordered: false,
|
||||
maxRetransmits: 0
|
||||
});
|
||||
|
||||
this.unreliableChannel.onopen = function () {
|
||||
console.log("[webrtc] unreliable channel open");
|
||||
self.unreliableReady = true;
|
||||
self.activeTransport = "webrtc";
|
||||
domController.setTransport && domController.setTransport("webrtc");
|
||||
};
|
||||
this.unreliableChannel.onclose = function () {
|
||||
console.log("[webrtc] unreliable channel closed");
|
||||
self.unreliableReady = false;
|
||||
self.activeTransport = "socketio";
|
||||
domController.setTransport && domController.setTransport("socket.io");
|
||||
};
|
||||
this.unreliableChannel.onmessage = function (e) {
|
||||
self.handleIncoming(e.data, "webrtc");
|
||||
};
|
||||
this.unreliableChannel.onerror = function (e) {
|
||||
console.warn("[webrtc] channel error:", e && e.message);
|
||||
};
|
||||
|
||||
this.peerConnection.onicecandidate = function (e) {
|
||||
if (e.candidate) {
|
||||
self.sendCommand("webrtcIce", {
|
||||
candidate: e.candidate.candidate,
|
||||
mid: e.candidate.sdpMid
|
||||
});
|
||||
}
|
||||
};
|
||||
this.peerConnection.onconnectionstatechange = function () {
|
||||
console.log("[webrtc] state:", self.peerConnection.connectionState);
|
||||
};
|
||||
|
||||
this.peerConnection.createOffer()
|
||||
.then(function (offer) { return self.peerConnection.setLocalDescription(offer); })
|
||||
.then(function () {
|
||||
var d = self.peerConnection.localDescription;
|
||||
self.sendCommand("webrtcOffer", { sdp: d.sdp, type: d.type });
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn("[webrtc] offer creation failed:", err.message);
|
||||
});
|
||||
};
|
||||
|
||||
Networker.prototype.onWebrtcAnswer = function (options) {
|
||||
if (!this.peerConnection || !options) return;
|
||||
this.peerConnection.setRemoteDescription({ type: options.type || "answer", sdp: options.sdp })
|
||||
.catch(function (e) { console.warn("[webrtc] setRemoteDescription failed:", e.message); });
|
||||
};
|
||||
|
||||
Networker.prototype.onWebrtcIce = function (options) {
|
||||
if (!this.peerConnection || !options) return;
|
||||
var init = { candidate: options.candidate, sdpMid: options.mid };
|
||||
this.peerConnection.addIceCandidate(init)
|
||||
.catch(function (e) { console.warn("[webrtc] addIceCandidate failed:", e.message); });
|
||||
};
|
||||
|
||||
Networker.prototype.teardownWebrtc = function () {
|
||||
try { if (this.unreliableChannel) this.unreliableChannel.close(); } catch (_) {}
|
||||
try { if (this.peerConnection) this.peerConnection.close(); } catch (_) {}
|
||||
this.unreliableChannel = null;
|
||||
this.peerConnection = null;
|
||||
this.unreliableReady = false;
|
||||
};
|
||||
|
||||
// ---------------- Stats reporting ----------------
|
||||
|
||||
Networker.prototype.reportStats = function () {
|
||||
if (!this.socketLink) return;
|
||||
var now = performance.now();
|
||||
this.sendTimestamps = this.sendTimestamps.filter(function (t) { return now - t < 1000; });
|
||||
this.recvTimestamps = this.recvTimestamps.filter(function (t) { return now - t < 1000; });
|
||||
var bucket = this.rttBucket;
|
||||
this.rttBucket = [];
|
||||
this.sendCommand("stats", {
|
||||
transport: this.activeTransport,
|
||||
rttSamples: bucket,
|
||||
recvRate: this.recvTimestamps.length,
|
||||
sendRate: this.sendTimestamps.length,
|
||||
gaps: this.gapCount
|
||||
});
|
||||
};
|
||||
|
||||
return Networker;
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,6 +115,13 @@ function (Settings, nc, Screenfull, Graph, pointerLockManager) {
|
|||
li.appendChild(this.ping);
|
||||
this.devToolsContainer.appendChild(li);
|
||||
|
||||
// create Transport: container
|
||||
li = document.createElement("li");
|
||||
this.transport = document.createElement("label");
|
||||
this.transport.innerHTML = "transport:?";
|
||||
li.appendChild(this.transport);
|
||||
this.devToolsContainer.appendChild(li);
|
||||
|
||||
|
||||
// create debug mode
|
||||
li = document.createElement("li");
|
||||
|
|
@ -165,6 +172,10 @@ function (Settings, nc, Screenfull, Graph, pointerLockManager) {
|
|||
// this.pingGraph.addValue(ping);
|
||||
};
|
||||
|
||||
DomController.prototype.setTransport = function(name) {
|
||||
if (this.transport) this.transport.innerHTML = "transport:" + name;
|
||||
};
|
||||
|
||||
DomController.prototype.getCanvasContainer = function () {
|
||||
var container = document.getElementById(Settings.CANVAS_DOM_ID);
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ function () {
|
|||
NETWORK_LOG_INCOMING: false,
|
||||
NETWORK_LOG_OUTGOING: false,
|
||||
NETWORK_LOG_FILTER: ["ping", "pong", "worldUpdate", "lookAt"],
|
||||
USE_WEBRTC: true,
|
||||
WEBRTC_STUN_SERVERS: ["stun:stun.l.google.com:19302"],
|
||||
|
||||
// CHANNEL
|
||||
CHANNEL_MAX_USERS: 20,
|
||||
|
|
|
|||
38
app/Server/StatsLogger.js
Normal file
38
app/Server/StatsLogger.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
define([
|
||||
"fs",
|
||||
"path"
|
||||
],
|
||||
|
||||
function (fs, path) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var runsDir = path.join(process.cwd(), "runs");
|
||||
if (!fs.existsSync(runsDir)) {
|
||||
fs.mkdirSync(runsDir, { recursive: true });
|
||||
}
|
||||
|
||||
var runFile = path.join(
|
||||
runsDir,
|
||||
new Date().toISOString().replace(/[:.]/g, "-") + ".jsonl"
|
||||
);
|
||||
var location = process.env.POC_LOCATION || "unknown";
|
||||
|
||||
fs.writeFileSync(runFile, JSON.stringify({
|
||||
type: "run_start",
|
||||
t: Date.now(),
|
||||
location: location,
|
||||
pid: process.pid
|
||||
}) + "\n");
|
||||
console.log("[stats] logging to " + runFile);
|
||||
|
||||
return {
|
||||
log: function (obj) {
|
||||
obj.t = obj.t || Date.now();
|
||||
obj.location = obj.location || location;
|
||||
fs.appendFile(runFile, JSON.stringify(obj) + "\n", function () {});
|
||||
},
|
||||
getFile: function () { return runFile; },
|
||||
getLocation: function () { return location; }
|
||||
};
|
||||
});
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
define([
|
||||
"Game/Core/User",
|
||||
"Lib/Utilities/Protocol/Helper",
|
||||
"Lib/Utilities/NotificationCenter"
|
||||
"Lib/Utilities/NotificationCenter",
|
||||
"Server/WebRTCTransport",
|
||||
"Server/StatsLogger"
|
||||
],
|
||||
|
||||
function (Parent, ProtocolHelper, nc) {
|
||||
function (Parent, ProtocolHelper, nc, WebRTCTransport, StatsLogger) {
|
||||
|
||||
"use strict";
|
||||
|
||||
|
|
@ -16,39 +18,75 @@ function (Parent, ProtocolHelper, nc) {
|
|||
this.channelPipe = null;
|
||||
this.options = null;
|
||||
|
||||
var self = this;
|
||||
|
||||
socketLink.on('message', this.onMessage.bind(this));
|
||||
socketLink.on('disconnect', this.onDisconnect.bind(this));
|
||||
|
||||
nc.on(nc.ns.server.events.controlCommand.user + this.id, this.socketLink.send, this.socketLink);
|
||||
// Outbound messages from the rest of the server land here. We decide
|
||||
// whether to send over Socket.IO (control) or the WebRTC unreliable
|
||||
// channel (gameCommand, when ready).
|
||||
nc.on(nc.ns.server.events.controlCommand.user + this.id, this.sendOutbound, this);
|
||||
|
||||
// Spin up the WebRTC peer immediately. Negotiation happens via the
|
||||
// socket signaling messages once the client posts its offer.
|
||||
this.webrtc = new WebRTCTransport(this.id, {
|
||||
onLocalDescription: function (desc) {
|
||||
self.socketLink.send(ProtocolHelper.encodeCommand("webrtcAnswer", desc));
|
||||
},
|
||||
onLocalCandidate: function (cand) {
|
||||
self.socketLink.send(ProtocolHelper.encodeCommand("webrtcIce", cand));
|
||||
},
|
||||
onReady: function (negotiationMs) {
|
||||
console.log("[webrtc] " + self.id + " unreliable channel open (" + negotiationMs + "ms)");
|
||||
StatsLogger.log({
|
||||
type: "webrtc_ready",
|
||||
session: self.id,
|
||||
dtMs: negotiationMs
|
||||
});
|
||||
},
|
||||
onMessage: function (raw) {
|
||||
// Inbound gameCommand from client over the unreliable channel.
|
||||
// Same shape as if it came over Socket.IO: a JSON-encoded
|
||||
// gameCommand envelope. Feed it to applyCommand the same way.
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed.gameCommand !== undefined) {
|
||||
self.onGameCommand(parsed.gameCommand);
|
||||
} else {
|
||||
// Future: other unreliable-channel message types.
|
||||
ProtocolHelper.applyCommand(raw, self);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[webrtc] bad inbound message:", e.message);
|
||||
}
|
||||
},
|
||||
onClosed: function () {
|
||||
console.log("[webrtc] " + self.id + " unreliable channel closed");
|
||||
}
|
||||
});
|
||||
|
||||
StatsLogger.log({
|
||||
type: "session_start",
|
||||
session: this.id,
|
||||
hasWebrtc: true
|
||||
});
|
||||
}
|
||||
|
||||
User.prototype = Object.create(Parent.prototype);
|
||||
|
||||
/*
|
||||
User.prototype.setChannelPipe = function(channelPipe) {
|
||||
if(channelPipe) {
|
||||
if (channelPipe.isWithinUserLimit()) {
|
||||
this.channelPipe = channelPipe;
|
||||
this.channelPipe.addUser(this);
|
||||
} else {
|
||||
var message = ProtocolHelper.encodeCommand("joinError", {message:"Channel is full"});
|
||||
this.socketLink.send(message);
|
||||
}
|
||||
|
||||
} else {
|
||||
var message = ProtocolHelper.encodeCommand("joinError", {message:"Channel not found"});
|
||||
this.socketLink.send(message);
|
||||
}
|
||||
};
|
||||
*/
|
||||
|
||||
// Socket callbacks
|
||||
// ---------- Inbound from Socket.IO ----------
|
||||
|
||||
User.prototype.onMessage = function (message) {
|
||||
ProtocolHelper.applyCommand(message, this);
|
||||
}
|
||||
};
|
||||
|
||||
User.prototype.onDisconnect = function () {
|
||||
if (this.webrtc) {
|
||||
this.webrtc.destroy();
|
||||
this.webrtc = null;
|
||||
}
|
||||
StatsLogger.log({ type: "session_end", session: this.id });
|
||||
|
||||
if(!this.channelPipe) {
|
||||
console.warn("Disconnecting user without a channel. (Maybe channel was full)");
|
||||
|
|
@ -56,11 +94,32 @@ function (Parent, ProtocolHelper, nc) {
|
|||
}
|
||||
|
||||
this.channelPipe.removeUser(this);
|
||||
};
|
||||
|
||||
|
||||
// ---------- Outbound routing ----------
|
||||
|
||||
// Decides whether a server-generated message should ride Socket.IO or the
|
||||
// unreliable WebRTC channel. gameCommand traffic uses WebRTC when ready;
|
||||
// everything else stays on Socket.IO.
|
||||
User.prototype.sendOutbound = function (message) {
|
||||
if (this.webrtc && this.webrtc.isReady() && isGameCommandMessage(message)) {
|
||||
if (this.webrtc.send(message)) return;
|
||||
// Send failed (channel state changed mid-send) — fall through.
|
||||
}
|
||||
this.socketLink.send(message);
|
||||
};
|
||||
|
||||
function isGameCommandMessage(message) {
|
||||
// The protocol wraps every message as {"<command>": <payload>}; we only
|
||||
// peek at the first key without fully parsing the inner payload.
|
||||
if (typeof message !== "string") return false;
|
||||
// Cheap heuristic — robust because the envelope key is always first.
|
||||
return message.indexOf('{"gameCommand"') === 0;
|
||||
}
|
||||
|
||||
|
||||
// User command callbacks
|
||||
// Remember: control commands are coordinator relevant commands
|
||||
// ---------- Command callbacks (received from client) ----------
|
||||
|
||||
User.prototype.onJoin = function(options) {
|
||||
|
||||
|
|
@ -83,14 +142,12 @@ function (Parent, ProtocolHelper, nc) {
|
|||
var userOptions = {
|
||||
id: this.id,
|
||||
nickname: options.nickname
|
||||
}
|
||||
};
|
||||
this.options = userOptions;
|
||||
this.channelPipe.addUser(this);
|
||||
};
|
||||
|
||||
/* FIXME: watch out and check in wich direction game and control commands flow */
|
||||
User.prototype.onGameCommand = function(options) {
|
||||
// repacking for transport via pipe
|
||||
var message = ProtocolHelper.encodeCommand("gameCommand", options);
|
||||
this.channelPipe.sendToUser(this.id, message);
|
||||
};
|
||||
|
|
@ -100,6 +157,31 @@ function (Parent, ProtocolHelper, nc) {
|
|||
nc.trigger(nc.ns.server.events.controlCommand.user + this.id, message);
|
||||
};
|
||||
|
||||
return User;
|
||||
// WebRTC signaling messages from the client (arriving over Socket.IO).
|
||||
|
||||
});
|
||||
User.prototype.onWebrtcOffer = function (options) {
|
||||
if (!this.webrtc || !options) return;
|
||||
this.webrtc.handleRemoteDescription(options.sdp, options.type || "offer");
|
||||
};
|
||||
|
||||
User.prototype.onWebrtcIce = function (options) {
|
||||
if (!this.webrtc || !options) return;
|
||||
this.webrtc.handleRemoteCandidate(options.candidate, options.mid);
|
||||
};
|
||||
|
||||
// Periodic stats report from client.
|
||||
User.prototype.onStats = function (options) {
|
||||
if (!options) return;
|
||||
StatsLogger.log({
|
||||
type: "stats",
|
||||
session: this.id,
|
||||
transport: options.transport || "socketio",
|
||||
rttSamples: options.rttSamples || [],
|
||||
recvRate: options.recvRate || 0,
|
||||
sendRate: options.sendRate || 0,
|
||||
gaps: options.gaps || 0
|
||||
});
|
||||
};
|
||||
|
||||
return User;
|
||||
});
|
||||
|
|
|
|||
112
app/Server/WebRTCTransport.js
Normal file
112
app/Server/WebRTCTransport.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
define([
|
||||
"node-datachannel"
|
||||
],
|
||||
|
||||
function (nodeDataChannel) {
|
||||
|
||||
"use strict";
|
||||
|
||||
// Per-user WebRTC peer + unreliable data channel.
|
||||
// The User class relays SDP/ICE between this transport and the client
|
||||
// (over Socket.IO). This class only deals with the WebRTC peer state.
|
||||
function WebRTCTransport(userId, callbacks) {
|
||||
this.userId = userId;
|
||||
this.callbacks = callbacks || {};
|
||||
this.unreliable = null;
|
||||
this.ready = false;
|
||||
this.createdAt = Date.now();
|
||||
|
||||
var iceServers = ["stun:stun.l.google.com:19302"];
|
||||
this.pc = new nodeDataChannel.PeerConnection("peer-" + userId, {
|
||||
iceServers: iceServers
|
||||
});
|
||||
|
||||
var self = this;
|
||||
|
||||
this.pc.onLocalDescription(function (sdp, type) {
|
||||
if (self.callbacks.onLocalDescription) {
|
||||
self.callbacks.onLocalDescription({ sdp: sdp, type: type });
|
||||
}
|
||||
});
|
||||
|
||||
this.pc.onLocalCandidate(function (candidate, mid) {
|
||||
if (self.callbacks.onLocalCandidate) {
|
||||
self.callbacks.onLocalCandidate({ candidate: candidate, mid: mid });
|
||||
}
|
||||
});
|
||||
|
||||
this.pc.onDataChannel(function (dc) {
|
||||
var label = dc.getLabel();
|
||||
if (label !== "unreliable") return;
|
||||
self.unreliable = dc;
|
||||
dc.onOpen(function () {
|
||||
self.ready = true;
|
||||
if (self.callbacks.onReady) {
|
||||
self.callbacks.onReady(Date.now() - self.createdAt);
|
||||
}
|
||||
});
|
||||
dc.onMessage(function (msg) {
|
||||
if (self.callbacks.onMessage) self.callbacks.onMessage(msg);
|
||||
});
|
||||
dc.onClosed(function () {
|
||||
self.ready = false;
|
||||
if (self.callbacks.onClosed) self.callbacks.onClosed();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
WebRTCTransport.prototype.handleRemoteDescription = function (sdp, type) {
|
||||
try {
|
||||
console.log("[webrtc] " + this.userId + " setRemoteDescription " + type);
|
||||
this.pc.setRemoteDescription(sdp, type);
|
||||
this.haveRemoteDesc = true;
|
||||
// Flush any candidates that arrived before the offer.
|
||||
if (this.pendingCandidates && this.pendingCandidates.length) {
|
||||
var pending = this.pendingCandidates;
|
||||
this.pendingCandidates = [];
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
this.handleRemoteCandidate(pending[i].candidate, pending[i].mid);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[webrtc] setRemoteDescription failed:", e && (e.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
WebRTCTransport.prototype.handleRemoteCandidate = function (candidate, mid) {
|
||||
// Buffer until we have the remote description — adding candidates before
|
||||
// setRemoteDescription throws.
|
||||
if (!this.haveRemoteDesc) {
|
||||
this.pendingCandidates = this.pendingCandidates || [];
|
||||
this.pendingCandidates.push({ candidate: candidate, mid: mid });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.pc.addRemoteCandidate(candidate, mid || "0");
|
||||
} catch (e) {
|
||||
console.error("[webrtc] addRemoteCandidate failed:", e && (e.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
WebRTCTransport.prototype.isReady = function () {
|
||||
return this.ready && this.unreliable && this.unreliable.isOpen();
|
||||
};
|
||||
|
||||
WebRTCTransport.prototype.send = function (message) {
|
||||
if (!this.isReady()) return false;
|
||||
try {
|
||||
this.unreliable.sendMessage(message);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
WebRTCTransport.prototype.destroy = function () {
|
||||
try { if (this.unreliable) this.unreliable.close(); } catch (_) {}
|
||||
try { this.pc.close(); } catch (_) {}
|
||||
this.ready = false;
|
||||
};
|
||||
|
||||
return WebRTCTransport;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue