mirror of
https://github.com/logsol/chuck.js.git
synced 2026-05-11 10:37:34 +00:00
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.
319 lines
12 KiB
JavaScript
Executable file
319 lines
12 KiB
JavaScript
Executable file
define([
|
|
"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) {
|
|
|
|
"use strict";
|
|
|
|
function Networker (socketLink, channelName, nickname) {
|
|
|
|
this.channelName = channelName;
|
|
this.nickname = nickname;
|
|
this.socketLink = socketLink;
|
|
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) {
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
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 () {
|
|
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");
|
|
this.onUserJoined(options.user, true);
|
|
this.meUserId = options.user.id;
|
|
if (options.joinedUsers) {
|
|
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.onLevelLoaded();
|
|
this.sendGameCommand("clientReady");
|
|
};
|
|
|
|
Networker.prototype.initPing = function() {
|
|
this.ping();
|
|
};
|
|
|
|
Networker.prototype.ping = function() {
|
|
this.lastPingSentAt = performance.now();
|
|
this.sendCommand("ping", Date.now());
|
|
};
|
|
|
|
// ---------------- Sending ----------------
|
|
|
|
Networker.prototype.sendCommand = function (command, options) {
|
|
var message = ProtocolHelper.encodeCommand(command, options);
|
|
this.socketLink.send(message);
|
|
this.logOutgoing(message, "socketio");
|
|
};
|
|
|
|
Networker.prototype.sendGameCommand = function(command, options) {
|
|
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);
|
|
};
|
|
|
|
// ---------------- Inbound command handlers ----------------
|
|
|
|
Networker.prototype.onUserJoined = function (options, isMe) {
|
|
var user = new User(options.id, options);
|
|
this.users[user.id] = user;
|
|
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) {
|
|
this.gameController.onGameCommand(message);
|
|
} 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();
|
|
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]);
|
|
}
|
|
this.gameController.beginRound();
|
|
};
|
|
|
|
Networker.prototype.onEndRound = function() {
|
|
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;
|
|
|
|
});
|