@usions/sdk 2.1.5 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/package.json +6 -3
- package/src/browser.js +2658 -124
- package/src/modules/backend-channel.js +76 -0
- package/src/modules/core.js +61 -0
- package/src/modules/game-core.js +4 -0
- package/src/modules/game-direct.js +45 -0
- package/src/modules/game-methods.js +107 -1
- package/src/modules/game-netcode.js +346 -0
- package/src/modules/game-proxy.js +4 -0
- package/src/modules/game-socket.js +4 -0
- package/src/modules/index.js +13 -0
- package/src/modules/leaderboard.js +46 -0
- package/src/modules/lobby.js +103 -0
- package/src/modules/matchmaking.js +61 -0
- package/src/modules/misc.js +4 -0
- package/src/modules/netcode/binary.js +113 -0
- package/src/modules/netcode/delta.js +236 -0
- package/src/modules/netcode/index.js +41 -0
- package/src/modules/netcode/interpolation.js +235 -0
- package/src/modules/netcode/lagcomp.js +104 -0
- package/src/modules/netcode/lockstep.js +103 -0
- package/src/modules/netcode/mesh-network.js +103 -0
- package/src/modules/netcode/mesh.js +188 -0
- package/src/modules/netcode/netsim.js +77 -0
- package/src/modules/netcode/ping.js +69 -0
- package/src/modules/netcode/prediction.js +113 -0
- package/src/modules/netcode/sender.js +102 -0
- package/src/modules/netcode/webtransport.js +195 -0
- package/src/modules/wallet.js +5 -1
- package/types/index.d.ts +463 -0
package/src/browser.js
CHANGED
|
@@ -13,6 +13,58 @@ var Usion = (function () {
|
|
|
13
13
|
return ++_requestId;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Trusted origin of the host shell that embedded us (web iframe only).
|
|
17
|
+
// Resolved lazily from the real embedder, never from message contents.
|
|
18
|
+
let _parentOrigin = null;
|
|
19
|
+
|
|
20
|
+
function _resolveParentOrigin() {
|
|
21
|
+
try {
|
|
22
|
+
if (window.location.ancestorOrigins && window.location.ancestorOrigins.length) {
|
|
23
|
+
return window.location.ancestorOrigins[0];
|
|
24
|
+
}
|
|
25
|
+
} catch (e) { /* not supported */ }
|
|
26
|
+
try {
|
|
27
|
+
if (typeof document !== 'undefined' && document.referrer) {
|
|
28
|
+
return new URL(document.referrer).origin;
|
|
29
|
+
}
|
|
30
|
+
} catch (e) { /* malformed referrer */ }
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether an incoming postMessage may be trusted.
|
|
36
|
+
*
|
|
37
|
+
* The host shell is the only legitimate sender. A sibling iframe or any other
|
|
38
|
+
* script on the page must NOT be able to forge messages (e.g. a fake
|
|
39
|
+
* PAYMENT_SUCCESS that unlocks paid value for free).
|
|
40
|
+
*
|
|
41
|
+
* - React Native WebView: messages are delivered in-process and carry no
|
|
42
|
+
* usable origin/source, so they are trusted.
|
|
43
|
+
* - Web iframe: the only window that equals `window.parent` is the real
|
|
44
|
+
* embedder. `event.source` is set by the browser and cannot be spoofed, so
|
|
45
|
+
* `event.source === window.parent` rejects siblings and self-posts. We then
|
|
46
|
+
* cross-check `event.origin` against the embedder's origin as defense-in-depth.
|
|
47
|
+
* - Not embedded (standalone / tests): nothing to protect against; allowed.
|
|
48
|
+
*
|
|
49
|
+
* @param {MessageEvent} event
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function isTrustedMessageSource(event) {
|
|
53
|
+
if (typeof window === 'undefined') return true;
|
|
54
|
+
if (window.ReactNativeWebView) return true;
|
|
55
|
+
if (window.parent === window) return true;
|
|
56
|
+
|
|
57
|
+
if (event && event.source && event.source !== window.parent) return false;
|
|
58
|
+
|
|
59
|
+
if (_parentOrigin === null) {
|
|
60
|
+
_parentOrigin = _resolveParentOrigin();
|
|
61
|
+
}
|
|
62
|
+
if (_parentOrigin && event && event.origin && event.origin !== _parentOrigin) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
16
68
|
/**
|
|
17
69
|
* Core Usion object with init, _post, _request
|
|
18
70
|
*/
|
|
@@ -49,6 +101,9 @@ var Usion = (function () {
|
|
|
49
101
|
|
|
50
102
|
// Setup global message handler
|
|
51
103
|
window.addEventListener('message', function(event) {
|
|
104
|
+
// Reject messages from anything other than the host shell.
|
|
105
|
+
if (!isTrustedMessageSource(event)) return;
|
|
106
|
+
|
|
52
107
|
let data;
|
|
53
108
|
try {
|
|
54
109
|
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
@@ -129,6 +184,12 @@ var Usion = (function () {
|
|
|
129
184
|
if (data.type === 'BOT_MESSAGE' && self.bot && self.bot._messageHandler) {
|
|
130
185
|
self.bot._messageHandler(data.message);
|
|
131
186
|
}
|
|
187
|
+
|
|
188
|
+
// Handle backend server-push events relayed by the host (embedded mode).
|
|
189
|
+
if (data.type === 'BACKEND_EVENT' && data.event && self._backendHandlers) {
|
|
190
|
+
const h = self._backendHandlers[data.event];
|
|
191
|
+
if (h) h(data.data);
|
|
192
|
+
}
|
|
132
193
|
});
|
|
133
194
|
|
|
134
195
|
// Signal ready to parent
|
|
@@ -394,6 +455,10 @@ var Usion = (function () {
|
|
|
394
455
|
|
|
395
456
|
// Listen for response
|
|
396
457
|
function handler(event) {
|
|
458
|
+
// Only honor payment results from the trusted host shell — a forged
|
|
459
|
+
// PAYMENT_SUCCESS must never resolve this promise.
|
|
460
|
+
if (!isTrustedMessageSource(event)) return;
|
|
461
|
+
|
|
397
462
|
let response;
|
|
398
463
|
try {
|
|
399
464
|
response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
@@ -780,6 +845,7 @@ var Usion = (function () {
|
|
|
780
845
|
* Usion SDK Misc — submit, error, exit, share, log, on, requestPayment (legacy)
|
|
781
846
|
*/
|
|
782
847
|
|
|
848
|
+
|
|
783
849
|
const miscMethods = {
|
|
784
850
|
/**
|
|
785
851
|
* Request payment from user (legacy method)
|
|
@@ -886,6 +952,8 @@ var Usion = (function () {
|
|
|
886
952
|
*/
|
|
887
953
|
on: function(type, callback) {
|
|
888
954
|
window.addEventListener('message', function(event) {
|
|
955
|
+
if (!isTrustedMessageSource(event)) return;
|
|
956
|
+
|
|
889
957
|
let data;
|
|
890
958
|
try {
|
|
891
959
|
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
@@ -975,6 +1043,9 @@ var Usion = (function () {
|
|
|
975
1043
|
|
|
976
1044
|
self._connecting = true;
|
|
977
1045
|
self.directMode = true;
|
|
1046
|
+
self._autoReconnect = config.autoReconnect !== undefined
|
|
1047
|
+
? config.autoReconnect
|
|
1048
|
+
: !(Usion.config && Usion.config.autoReconnect === false);
|
|
978
1049
|
self._connectPromise = self._fetchDirectAccess(config)
|
|
979
1050
|
.then(function(access) {
|
|
980
1051
|
self.directConfig = access;
|
|
@@ -1103,6 +1174,10 @@ var Usion = (function () {
|
|
|
1103
1174
|
if (self._eventHandlers.disconnect) {
|
|
1104
1175
|
self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
|
|
1105
1176
|
}
|
|
1177
|
+
// Seamless resume: if the drop wasn't an intentional disconnect()
|
|
1178
|
+
// (which clears directMode), transparently re-establish + re-join +
|
|
1179
|
+
// resync from the last sequence.
|
|
1180
|
+
if (self.directMode && self._autoReconnect !== false) self._scheduleDirectReconnect();
|
|
1106
1181
|
};
|
|
1107
1182
|
|
|
1108
1183
|
ws.onmessage = function(evt) {
|
|
@@ -1111,6 +1186,39 @@ var Usion = (function () {
|
|
|
1111
1186
|
});
|
|
1112
1187
|
};
|
|
1113
1188
|
|
|
1189
|
+
/**
|
|
1190
|
+
* Reconnect a dropped direct-mode socket with capped exponential backoff,
|
|
1191
|
+
* then re-join the room and request a resync (Dota-style "fetch latest
|
|
1192
|
+
* snapshot = instant rejoin"). Keeps retrying while still in directMode.
|
|
1193
|
+
* @private
|
|
1194
|
+
*/
|
|
1195
|
+
game._scheduleDirectReconnect = function() {
|
|
1196
|
+
var self = this;
|
|
1197
|
+
if (self._reconnecting) return;
|
|
1198
|
+
self._reconnecting = true;
|
|
1199
|
+
var attempt = self._reconnectAttempt || 0;
|
|
1200
|
+
var go = function() {
|
|
1201
|
+
if (!self.directMode) { self._reconnecting = false; return; } // disconnected meanwhile
|
|
1202
|
+
attempt += 1;
|
|
1203
|
+
self._reconnectAttempt = attempt;
|
|
1204
|
+
self._fetchDirectAccess({})
|
|
1205
|
+
.then(function(access) { self.directConfig = access; return self._initDirectSocket(access); })
|
|
1206
|
+
.then(function() {
|
|
1207
|
+
self.connected = true;
|
|
1208
|
+
self._reconnecting = false;
|
|
1209
|
+
self._reconnectAttempt = 0;
|
|
1210
|
+
if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
|
|
1211
|
+
if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
|
|
1212
|
+
})
|
|
1213
|
+
.catch(function() {
|
|
1214
|
+
if (!self.directMode) { self._reconnecting = false; return; }
|
|
1215
|
+
var delay = Math.min(1000 * Math.pow(2, attempt - 1), 15000);
|
|
1216
|
+
setTimeout(go, delay);
|
|
1217
|
+
});
|
|
1218
|
+
};
|
|
1219
|
+
setTimeout(go, 500);
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1114
1222
|
game._sendDirect = function(type, payload) {
|
|
1115
1223
|
if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
|
|
1116
1224
|
this._directSeq = this._directSeq + 1;
|
|
@@ -1154,6 +1262,11 @@ var Usion = (function () {
|
|
|
1154
1262
|
return;
|
|
1155
1263
|
}
|
|
1156
1264
|
if (data.type === 'pong') {
|
|
1265
|
+
// Resolve a pending game.ping() RTT probe, if any.
|
|
1266
|
+
if (this._pongWaiters && this._pongWaiters.length) {
|
|
1267
|
+
const waiter = this._pongWaiters.shift();
|
|
1268
|
+
if (waiter) waiter();
|
|
1269
|
+
}
|
|
1157
1270
|
if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
|
|
1158
1271
|
return;
|
|
1159
1272
|
}
|
|
@@ -1209,6 +1322,10 @@ var Usion = (function () {
|
|
|
1209
1322
|
reconnectionDelayMax: 10000
|
|
1210
1323
|
});
|
|
1211
1324
|
|
|
1325
|
+
// Route allow-listed backend server events (lobby, etc.) to the unified
|
|
1326
|
+
// backend channel — robust to listener registration order.
|
|
1327
|
+
if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
|
|
1328
|
+
|
|
1212
1329
|
self.socket.on('connect', function() {
|
|
1213
1330
|
self.connected = true;
|
|
1214
1331
|
self._connecting = false;
|
|
@@ -1367,6 +1484,7 @@ var Usion = (function () {
|
|
|
1367
1484
|
* Usion SDK Game Proxy — postMessage relay through parent app
|
|
1368
1485
|
*/
|
|
1369
1486
|
|
|
1487
|
+
|
|
1370
1488
|
/**
|
|
1371
1489
|
* Add proxy connection methods to game module
|
|
1372
1490
|
* @param {object} game - The game module object
|
|
@@ -1423,6 +1541,8 @@ var Usion = (function () {
|
|
|
1423
1541
|
self._proxyListenerSetup = true;
|
|
1424
1542
|
|
|
1425
1543
|
window.addEventListener('message', function(event) {
|
|
1544
|
+
if (!isTrustedMessageSource(event)) return;
|
|
1545
|
+
|
|
1426
1546
|
var data;
|
|
1427
1547
|
try {
|
|
1428
1548
|
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
@@ -1636,7 +1756,10 @@ var Usion = (function () {
|
|
|
1636
1756
|
const self = this;
|
|
1637
1757
|
|
|
1638
1758
|
if (self.directMode) {
|
|
1639
|
-
self._sendDirect(
|
|
1759
|
+
self._sendDirect('action', {
|
|
1760
|
+
action_type: actionType || 'default',
|
|
1761
|
+
action_data: actionData || {}
|
|
1762
|
+
});
|
|
1640
1763
|
return Promise.resolve({ success: true });
|
|
1641
1764
|
}
|
|
1642
1765
|
|
|
@@ -1836,6 +1959,109 @@ var Usion = (function () {
|
|
|
1836
1959
|
}
|
|
1837
1960
|
};
|
|
1838
1961
|
|
|
1962
|
+
// ───────────────────────────────────────────────────────────
|
|
1963
|
+
// Cross-reload state persistence
|
|
1964
|
+
// ───────────────────────────────────────────────────────────
|
|
1965
|
+
// When a mini-app's iframe is unmounted and later re-mounted (e.g. the
|
|
1966
|
+
// user navigates back to the chat and re-opens the game from the same
|
|
1967
|
+
// room), the entire JS context is destroyed. Server-side room state is
|
|
1968
|
+
// preserved, but anything the game holds in memory — board state, phase,
|
|
1969
|
+
// whose turn it is, placement choices — is lost. The platform sync
|
|
1970
|
+
// mechanism only replays raw actions; reconstructing client-visible
|
|
1971
|
+
// state from a zero baseline is fragile or impossible.
|
|
1972
|
+
//
|
|
1973
|
+
// These helpers give every game a uniform way to snapshot whatever it
|
|
1974
|
+
// needs to localStorage. Keys are scoped to (player_id, room_id) so a
|
|
1975
|
+
// single browser can hold independent state for different rooms or
|
|
1976
|
+
// accounts without collision, and "play a different match in the same
|
|
1977
|
+
// room" naturally collides — which is the correct outcome.
|
|
1978
|
+
|
|
1979
|
+
const STATE_KEY_PREFIX = '_usion_game_state:';
|
|
1980
|
+
|
|
1981
|
+
function _stateKey(self) {
|
|
1982
|
+
const rid = self.roomId || (Usion.config && Usion.config.roomId);
|
|
1983
|
+
const pid = self.playerId
|
|
1984
|
+
|| (Usion.user && typeof Usion.user.getId === 'function' && Usion.user.getId())
|
|
1985
|
+
|| (Usion.config && Usion.config.userId);
|
|
1986
|
+
if (!rid || !pid) return null;
|
|
1987
|
+
return STATE_KEY_PREFIX + pid + ':' + rid;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Persist arbitrary JSON-serializable game state across iframe reloads.
|
|
1992
|
+
* The mini-app decides the schema; the SDK only stores/retrieves.
|
|
1993
|
+
* @param {*} state - Any JSON-serializable value
|
|
1994
|
+
* @returns {boolean} true if saved, false if not (no room/player yet, or storage error)
|
|
1995
|
+
*/
|
|
1996
|
+
game.saveState = function(state) {
|
|
1997
|
+
const self = this;
|
|
1998
|
+
const key = _stateKey(self);
|
|
1999
|
+
if (!key) return false;
|
|
2000
|
+
try {
|
|
2001
|
+
localStorage.setItem(key, JSON.stringify({ state: state, savedAt: Date.now() }));
|
|
2002
|
+
return true;
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
// Quota exceeded, private-mode rejection, etc. — non-fatal.
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Retrieve previously-saved state for the current (player, room).
|
|
2011
|
+
* @returns {*} The saved state value, or null if none / unreadable.
|
|
2012
|
+
*/
|
|
2013
|
+
game.loadState = function() {
|
|
2014
|
+
const self = this;
|
|
2015
|
+
const key = _stateKey(self);
|
|
2016
|
+
if (!key) return null;
|
|
2017
|
+
try {
|
|
2018
|
+
const raw = localStorage.getItem(key);
|
|
2019
|
+
if (!raw) return null;
|
|
2020
|
+
const parsed = JSON.parse(raw);
|
|
2021
|
+
return parsed && parsed.state !== undefined ? parsed.state : null;
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Drop any persisted state for the current (player, room).
|
|
2029
|
+
* Call this when the game ends or starts fresh, so the next iframe
|
|
2030
|
+
* mount in the same room doesn't pick up stale data.
|
|
2031
|
+
*/
|
|
2032
|
+
game.clearState = function() {
|
|
2033
|
+
const self = this;
|
|
2034
|
+
const key = _stateKey(self);
|
|
2035
|
+
if (!key) return;
|
|
2036
|
+
try { localStorage.removeItem(key); } catch (e) { /* non-fatal */ }
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Forward a debug snapshot to the parent platform. The platform renders
|
|
2041
|
+
* it in a top-right overlay when the iframe host is opened with
|
|
2042
|
+
* `?debug=1`. The payload schema is up to the game — anything JSON-
|
|
2043
|
+
* serializable. No-op when not running inside an iframe.
|
|
2044
|
+
*
|
|
2045
|
+
* Games should call this at every meaningful state transition (turn
|
|
2046
|
+
* change, action sent, action received, sync, phase change, etc.) so
|
|
2047
|
+
* the overlay reflects live state.
|
|
2048
|
+
*
|
|
2049
|
+
* @param {object} payload - Arbitrary JSON-serializable debug data
|
|
2050
|
+
*/
|
|
2051
|
+
game.debug = function(payload) {
|
|
2052
|
+
try {
|
|
2053
|
+
// Must work in both web iframes (window.parent !== window) and
|
|
2054
|
+
// React Native WebView (window.parent === window, but a host bridge
|
|
2055
|
+
// exists at window.ReactNativeWebView). Usion._post handles routing
|
|
2056
|
+
// for both; this guard just avoids no-op work in standalone pages.
|
|
2057
|
+
var inFrame = window.parent && window.parent !== window;
|
|
2058
|
+
var inRNWebView = !!window.ReactNativeWebView;
|
|
2059
|
+
if (inFrame || inRNWebView) {
|
|
2060
|
+
Usion._post({ type: 'GAME_DEBUG', payload: payload || {} });
|
|
2061
|
+
}
|
|
2062
|
+
} catch (e) { /* non-fatal */ }
|
|
2063
|
+
};
|
|
2064
|
+
|
|
1839
2065
|
/**
|
|
1840
2066
|
* Get connection status
|
|
1841
2067
|
* @returns {boolean}
|
|
@@ -1849,152 +2075,2452 @@ var Usion = (function () {
|
|
|
1849
2075
|
}
|
|
1850
2076
|
|
|
1851
2077
|
/**
|
|
1852
|
-
* Usion SDK
|
|
2078
|
+
* Usion SDK Netcode — Snapshot interpolation.
|
|
2079
|
+
*
|
|
2080
|
+
* Render every entity slightly in the past and interpolate between the two
|
|
2081
|
+
* snapshots straddling "renderTime", so motion stays smooth and late /
|
|
2082
|
+
* duplicate / out-of-order / dropped packets are absorbed for free. This is
|
|
2083
|
+
* the single biggest perceived-lag fix for real-time games.
|
|
2084
|
+
*
|
|
2085
|
+
* Practices adopted (Valve Source "Interpolation" + geckos.io):
|
|
2086
|
+
* - Interpolation delay (buffer) sized in server-frames, with an **adaptive**
|
|
2087
|
+
* mode that grows the buffer with measured network jitter (cl_interp_ratio).
|
|
2088
|
+
* - **Capped extrapolation** when the buffer underruns (packet loss): project
|
|
2089
|
+
* forward from the last known velocity, bounded (Source caps at 0.25s) so
|
|
2090
|
+
* prediction error stays small.
|
|
2091
|
+
* - Optional **server-time domain**: if snapshots carry a server `time`, render
|
|
2092
|
+
* against an estimated server clock (robust to bursty arrival). Default stays
|
|
2093
|
+
* the client arrival-time domain — zero clock sync required.
|
|
2094
|
+
*
|
|
2095
|
+
* Key-spec syntax: 'x y angle(deg) r(rad)'.
|
|
1853
2096
|
*/
|
|
1854
2097
|
|
|
2098
|
+
function shortestAngle(a, b, twoPi) {
|
|
2099
|
+
let d = (b - a) % twoPi;
|
|
2100
|
+
const half = twoPi / 2;
|
|
2101
|
+
if (d > half) d -= twoPi;
|
|
2102
|
+
if (d < -half) d += twoPi;
|
|
2103
|
+
return d;
|
|
2104
|
+
}
|
|
1855
2105
|
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
connected: false,
|
|
1867
|
-
directMode: false,
|
|
1868
|
-
directConfig: null,
|
|
1869
|
-
_directSeq: 0,
|
|
1870
|
-
_eventHandlers: {},
|
|
1871
|
-
_lastSequence: 0,
|
|
1872
|
-
_connecting: false,
|
|
1873
|
-
_connectPromise: null,
|
|
1874
|
-
_joined: false,
|
|
1875
|
-
_joinPromise: null,
|
|
1876
|
-
_useProxy: false,
|
|
1877
|
-
_proxyListenerSetup: false,
|
|
1878
|
-
_heartbeatInterval: null,
|
|
2106
|
+
function parseKeys(spec) {
|
|
2107
|
+
return String(spec || '')
|
|
2108
|
+
.trim()
|
|
2109
|
+
.split(/\s+/)
|
|
2110
|
+
.filter(Boolean)
|
|
2111
|
+
.map((tok) => {
|
|
2112
|
+
const m = tok.match(/^(.+?)\((deg|rad)\)$/);
|
|
2113
|
+
return m ? { key: m[1], type: m[2] } : { key: tok, type: 'linear' };
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
1879
2116
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
*/
|
|
1886
|
-
connect: function(socketUrl, token) {
|
|
1887
|
-
const self = this;
|
|
1888
|
-
var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
|
|
1889
|
-
if (connectionMode === 'direct') {
|
|
1890
|
-
return self.connectDirect();
|
|
1891
|
-
}
|
|
2117
|
+
function lerpField(a, b, t, type) {
|
|
2118
|
+
if (type === 'deg') return a + shortestAngle(a, b, 360) * t;
|
|
2119
|
+
if (type === 'rad') return a + shortestAngle(a, b, Math.PI * 2) * t;
|
|
2120
|
+
return a + (b - a) * t;
|
|
2121
|
+
}
|
|
1892
2122
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2123
|
+
/** Time-ordered ring buffer of snapshots. Newest last. */
|
|
2124
|
+
class Vault {
|
|
2125
|
+
constructor(maxSize = 120) {
|
|
2126
|
+
this._items = [];
|
|
2127
|
+
this._maxSize = maxSize;
|
|
2128
|
+
}
|
|
2129
|
+
add(snapshot) {
|
|
2130
|
+
this._items.push(snapshot);
|
|
2131
|
+
if (this._items.length > this._maxSize) this._items.shift();
|
|
2132
|
+
}
|
|
2133
|
+
get size() { return this._items.length; }
|
|
2134
|
+
setMaxSize(n) { this._maxSize = Math.max(2, n | 0); }
|
|
2135
|
+
latest() { return this._items.length ? this._items[this._items.length - 1] : null; }
|
|
2136
|
+
prevLatest() { return this._items.length >= 2 ? this._items[this._items.length - 2] : null; }
|
|
2137
|
+
clear() { this._items = []; }
|
|
2138
|
+
|
|
2139
|
+
/** [older, newer] snapshots straddling `time`, clamping at the ends. */
|
|
2140
|
+
straddle(time) {
|
|
2141
|
+
const items = this._items;
|
|
2142
|
+
if (items.length === 0) return [null, null];
|
|
2143
|
+
if (items.length === 1) return [items[0], items[0]];
|
|
2144
|
+
if (time <= items[0].time) return [items[0], items[0]];
|
|
2145
|
+
const last = items[items.length - 1];
|
|
2146
|
+
if (time >= last.time) return [last, last];
|
|
2147
|
+
for (let i = items.length - 1; i > 0; i--) {
|
|
2148
|
+
if (items[i - 1].time <= time && time <= items[i].time) return [items[i - 1], items[i]];
|
|
2149
|
+
}
|
|
2150
|
+
return [last, last];
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
1900
2153
|
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2154
|
+
function asEntityArray(state, group) {
|
|
2155
|
+
if (group) return Array.isArray(state[group]) ? state[group] : [];
|
|
2156
|
+
return Array.isArray(state) ? state : [];
|
|
2157
|
+
}
|
|
1905
2158
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
2159
|
+
class SnapshotInterpolation {
|
|
2160
|
+
/**
|
|
2161
|
+
* @param {object} [opts]
|
|
2162
|
+
* @param {number} [opts.serverFps=20] Expected snapshot rate (sets default buffer).
|
|
2163
|
+
* @param {number} [opts.bufferMs] Fixed interpolation delay (default ≈ 3 frames).
|
|
2164
|
+
* @param {boolean} [opts.adaptive=false] Grow the buffer with measured jitter.
|
|
2165
|
+
* @param {number} [opts.minBufferMs] Lower clamp for adaptive buffer.
|
|
2166
|
+
* @param {number} [opts.maxBufferMs] Upper clamp for adaptive buffer.
|
|
2167
|
+
* @param {number} [opts.extrapolationMs=0] Max forward extrapolation on underrun (0 = off).
|
|
2168
|
+
* @param {boolean} [opts.serverTime=false] Use snapshot.time (server clock) domain.
|
|
2169
|
+
* @param {number} [opts.maxSize=120]
|
|
2170
|
+
* @param {function} [opts.now]
|
|
2171
|
+
*/
|
|
2172
|
+
constructor(opts = {}) {
|
|
2173
|
+
const serverFps = opts.serverFps || 20;
|
|
2174
|
+
this._frameMs = 1000 / serverFps;
|
|
2175
|
+
this.vault = new Vault(opts.maxSize || 120);
|
|
2176
|
+
this._fixedBuffer = opts.bufferMs != null ? opts.bufferMs : Math.ceil(this._frameMs * 3);
|
|
2177
|
+
this._bufferMs = this._fixedBuffer;
|
|
2178
|
+
this._adaptive = !!opts.adaptive;
|
|
2179
|
+
this._minBuffer = opts.minBufferMs != null ? opts.minBufferMs : Math.ceil(this._frameMs * 2);
|
|
2180
|
+
this._maxBuffer = opts.maxBufferMs != null ? opts.maxBufferMs : Math.ceil(this._frameMs * 8);
|
|
2181
|
+
this._extrapolationMs = opts.extrapolationMs || 0;
|
|
2182
|
+
this._useServerTime = !!opts.serverTime;
|
|
2183
|
+
this._now = opts.now || (() => Date.now());
|
|
2184
|
+
|
|
2185
|
+
// adaptive/jitter + server-clock estimation state
|
|
2186
|
+
this._lastArrival = null;
|
|
2187
|
+
this._avgInterval = this._frameMs;
|
|
2188
|
+
this._jitter = 0;
|
|
2189
|
+
this._offset = null; // EWMA(arrival - serverTime)
|
|
2190
|
+
}
|
|
1912
2191
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2192
|
+
getBufferMs() { return this._bufferMs; }
|
|
2193
|
+
setBufferMs(ms) { this._fixedBuffer = Math.max(0, ms | 0); this._bufferMs = this._fixedBuffer; }
|
|
2194
|
+
getJitter() { return this._jitter; }
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Add a received snapshot. `state` is an entity array (each needs a stable
|
|
2198
|
+
* `id`) or a map of named groups → entity arrays. Pass `{ state, time }` to
|
|
2199
|
+
* supply a server timestamp for the server-time domain.
|
|
2200
|
+
*/
|
|
2201
|
+
add(snapshot) {
|
|
2202
|
+
if (!snapshot) return;
|
|
2203
|
+
const arrival = this._now();
|
|
2204
|
+
const state = snapshot.state !== undefined ? snapshot.state : snapshot;
|
|
2205
|
+
const serverTime = snapshot.time;
|
|
2206
|
+
|
|
2207
|
+
// Jitter / interval estimate (EWMA), used by the adaptive buffer.
|
|
2208
|
+
if (this._lastArrival != null) {
|
|
2209
|
+
const interval = arrival - this._lastArrival;
|
|
2210
|
+
this._avgInterval += 0.1 * (interval - this._avgInterval);
|
|
2211
|
+
this._jitter += 0.1 * (Math.abs(interval - this._avgInterval) - this._jitter);
|
|
2212
|
+
if (this._adaptive) {
|
|
2213
|
+
const target = this._avgInterval + 2 * this._jitter;
|
|
2214
|
+
this._bufferMs = Math.max(this._minBuffer, Math.min(this._maxBuffer, target));
|
|
1916
2215
|
}
|
|
2216
|
+
}
|
|
2217
|
+
this._lastArrival = arrival;
|
|
1917
2218
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2219
|
+
// Server-clock offset estimate.
|
|
2220
|
+
if (this._useServerTime && typeof serverTime === 'number') {
|
|
2221
|
+
const o = arrival - serverTime;
|
|
2222
|
+
this._offset = this._offset == null ? o : this._offset + 0.05 * (o - this._offset);
|
|
2223
|
+
}
|
|
1921
2224
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2225
|
+
// Stamp the time used for the interpolation domain.
|
|
2226
|
+
const time = (this._useServerTime && typeof serverTime === 'number') ? serverTime : arrival;
|
|
2227
|
+
this.vault.add({ time, state });
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
_renderTime() {
|
|
2231
|
+
if (this._useServerTime && this._offset != null) return (this._now() - this._offset) - this._bufferMs;
|
|
2232
|
+
return this._now() - this._bufferMs;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* Interpolated entities for the current render instant.
|
|
2237
|
+
* @param {string} keys e.g. 'x y' or 'x y angle(deg)'
|
|
2238
|
+
* @param {string} [group]
|
|
2239
|
+
* @returns {Array|null}
|
|
2240
|
+
*/
|
|
2241
|
+
calc(keys, group) {
|
|
2242
|
+
const renderTime = this._renderTime();
|
|
2243
|
+
const latest = this.vault.latest();
|
|
2244
|
+
if (!latest) return null;
|
|
2245
|
+
const specs = parseKeys(keys);
|
|
2246
|
+
|
|
2247
|
+
// Buffer underrun: render time is ahead of the newest snapshot.
|
|
2248
|
+
if (renderTime > latest.time) {
|
|
2249
|
+
if (this._extrapolationMs > 0) return this._extrapolate(renderTime, specs, group);
|
|
2250
|
+
return asEntityArray(latest.state, group).map((e) => Object.assign({}, e));
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const [older, newer] = this.vault.straddle(renderTime);
|
|
2254
|
+
if (!older || !newer) return null;
|
|
2255
|
+
|
|
2256
|
+
let t = 0;
|
|
2257
|
+
if (newer.time !== older.time) {
|
|
2258
|
+
t = (renderTime - older.time) / (newer.time - older.time);
|
|
2259
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
2260
|
+
}
|
|
2261
|
+
return this._blend(asEntityArray(older.state, group), asEntityArray(newer.state, group), t, specs);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
_blend(oldEntities, newEntities, t, specs) {
|
|
2265
|
+
const oldById = {};
|
|
2266
|
+
for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
|
|
2267
|
+
return newEntities.map((nentity) => {
|
|
2268
|
+
const oentity = oldById[nentity.id];
|
|
2269
|
+
const out = Object.assign({}, nentity);
|
|
2270
|
+
if (oentity) {
|
|
2271
|
+
for (let i = 0; i < specs.length; i++) {
|
|
2272
|
+
const { key, type } = specs[i];
|
|
2273
|
+
const a = oentity[key];
|
|
2274
|
+
const b = nentity[key];
|
|
2275
|
+
if (typeof a === 'number' && typeof b === 'number') out[key] = lerpField(a, b, t, type);
|
|
2276
|
+
}
|
|
1927
2277
|
}
|
|
2278
|
+
return out;
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
1928
2281
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
} else {
|
|
1954
|
-
self._initSocket(socketUrl, token, resolve, reject);
|
|
2282
|
+
_extrapolate(renderTime, specs, group) {
|
|
2283
|
+
const newer = this.vault.latest();
|
|
2284
|
+
const older = this.vault.prevLatest();
|
|
2285
|
+
const newEntities = asEntityArray(newer.state, group);
|
|
2286
|
+
if (!older || newer.time <= older.time) return newEntities.map((e) => Object.assign({}, e));
|
|
2287
|
+
|
|
2288
|
+
const dt = Math.min(renderTime - newer.time, this._extrapolationMs); // cap
|
|
2289
|
+
const span = newer.time - older.time;
|
|
2290
|
+
const oldById = {};
|
|
2291
|
+
const oldEntities = asEntityArray(older.state, group);
|
|
2292
|
+
for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
|
|
2293
|
+
|
|
2294
|
+
return newEntities.map((nentity) => {
|
|
2295
|
+
const oentity = oldById[nentity.id];
|
|
2296
|
+
const out = Object.assign({}, nentity);
|
|
2297
|
+
if (oentity) {
|
|
2298
|
+
for (let i = 0; i < specs.length; i++) {
|
|
2299
|
+
const { key } = specs[i]; // linear extrapolation (angles handled as linear here)
|
|
2300
|
+
const a = oentity[key];
|
|
2301
|
+
const b = nentity[key];
|
|
2302
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
2303
|
+
const vel = (b - a) / span;
|
|
2304
|
+
out[key] = b + vel * dt;
|
|
2305
|
+
}
|
|
1955
2306
|
}
|
|
1956
|
-
}
|
|
2307
|
+
}
|
|
2308
|
+
return out;
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
1957
2312
|
|
|
1958
|
-
|
|
1959
|
-
|
|
2313
|
+
/**
|
|
2314
|
+
* Usion SDK Netcode — Client-side prediction + server reconciliation.
|
|
2315
|
+
*
|
|
2316
|
+
* Apply local input instantly (tagged with a sequence number); when the
|
|
2317
|
+
* authoritative state arrives carrying the last input the server processed,
|
|
2318
|
+
* snap to it and replay every still-unacknowledged input on top. The local
|
|
2319
|
+
* player sees zero input delay while staying consistent with the server.
|
|
2320
|
+
*
|
|
2321
|
+
* Error smoothing (Overwatch / Gabriel Gambetta): hard-snapping the render
|
|
2322
|
+
* position on every correction looks jittery. Instead we keep the visible
|
|
2323
|
+
* position as `corrected + error`, where `error` is the gap a correction
|
|
2324
|
+
* introduced, and decay that error to zero over a few frames — so corrections
|
|
2325
|
+
* are smooth and, in the common case, invisible. Enable via `opts.smooth`.
|
|
2326
|
+
*
|
|
2327
|
+
* The game supplies a pure `apply(state, input) -> newState` (must not mutate).
|
|
2328
|
+
*/
|
|
2329
|
+
function parseSmoothKeys(smooth) {
|
|
2330
|
+
if (!smooth) return null;
|
|
2331
|
+
let keys = smooth.keys != null ? smooth.keys : smooth;
|
|
2332
|
+
if (typeof keys === 'string') keys = keys.trim().split(/\s+/).filter(Boolean);
|
|
2333
|
+
return Array.isArray(keys) && keys.length ? keys : null;
|
|
2334
|
+
}
|
|
1960
2335
|
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2336
|
+
class Predictor {
|
|
2337
|
+
/**
|
|
2338
|
+
* @param {object} opts
|
|
2339
|
+
* @param {(state:any, input:any)=>any} opts.apply Pure state transition.
|
|
2340
|
+
* @param {any} [opts.initialState]
|
|
2341
|
+
* @param {{keys?:string|string[], rate?:number, snapTo?:number}|string} [opts.smooth]
|
|
2342
|
+
* Error-smoothing config. `keys` are numeric fields to blend; `rate` is the
|
|
2343
|
+
* per-frame decay (0..1, default 0.2); `snapTo` is the residual below which
|
|
2344
|
+
* the error is zeroed (default 0.001).
|
|
2345
|
+
*/
|
|
2346
|
+
constructor(opts = {}) {
|
|
2347
|
+
if (typeof opts.apply !== 'function') throw new Error('Predictor requires an apply(state, input) function');
|
|
2348
|
+
this._apply = opts.apply;
|
|
2349
|
+
this._state = opts.initialState !== undefined ? opts.initialState : null;
|
|
2350
|
+
this._seq = 0;
|
|
2351
|
+
this._pending = [];
|
|
2352
|
+
this._smoothKeys = parseSmoothKeys(opts.smooth);
|
|
2353
|
+
this._smoothRate = (opts.smooth && opts.smooth.rate != null) ? opts.smooth.rate : 0.2;
|
|
2354
|
+
this._snapTo = (opts.smooth && opts.smooth.snapTo != null) ? opts.smooth.snapTo : 0.001;
|
|
2355
|
+
this._error = {};
|
|
2356
|
+
}
|
|
1976
2357
|
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2358
|
+
get state() { return this._state; }
|
|
2359
|
+
get pending() { return this._pending.slice(); }
|
|
2360
|
+
get lastSeq() { return this._seq; }
|
|
2361
|
+
|
|
2362
|
+
/** Apply an input locally and record it. Attach the returned `seq` when sending. */
|
|
2363
|
+
predict(input) {
|
|
2364
|
+
this._seq += 1;
|
|
2365
|
+
const seq = this._seq;
|
|
2366
|
+
this._pending.push({ seq, input });
|
|
2367
|
+
this._state = this._apply(this._state, input);
|
|
2368
|
+
return { seq, state: this._state };
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Reconcile against authoritative server state.
|
|
2373
|
+
* @param {any} serverState Authoritative state.
|
|
2374
|
+
* @param {number} ackedSeq Highest input sequence the server has applied.
|
|
2375
|
+
* @returns {any} the corrected predicted state (authoritative + replayed inputs)
|
|
2376
|
+
*/
|
|
2377
|
+
reconcile(serverState, ackedSeq) {
|
|
2378
|
+
const before = this._state;
|
|
2379
|
+
const ack = ackedSeq == null ? -1 : ackedSeq;
|
|
2380
|
+
this._pending = this._pending.filter((p) => p.seq > ack);
|
|
2381
|
+
let s = serverState;
|
|
2382
|
+
for (let i = 0; i < this._pending.length; i++) s = this._apply(s, this._pending[i].input);
|
|
2383
|
+
|
|
2384
|
+
// Accumulate the correction gap into the error offset for smoothing.
|
|
2385
|
+
if (this._smoothKeys && before && s) {
|
|
2386
|
+
for (let i = 0; i < this._smoothKeys.length; i++) {
|
|
2387
|
+
const k = this._smoothKeys[i];
|
|
2388
|
+
if (typeof before[k] === 'number' && typeof s[k] === 'number') {
|
|
2389
|
+
this._error[k] = (this._error[k] || 0) + (before[k] - s[k]);
|
|
2390
|
+
}
|
|
1985
2391
|
}
|
|
1986
2392
|
}
|
|
1987
|
-
|
|
2393
|
+
this._state = s;
|
|
2394
|
+
return this._state;
|
|
2395
|
+
}
|
|
1988
2396
|
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2397
|
+
/**
|
|
2398
|
+
* The state to render: corrected state plus the decaying error offset.
|
|
2399
|
+
* Call once per rendered frame; it decays the error toward zero.
|
|
2400
|
+
* @param {number} [rate] Override the per-frame decay factor.
|
|
2401
|
+
* @returns {any}
|
|
2402
|
+
*/
|
|
2403
|
+
view(rate) {
|
|
2404
|
+
if (!this._smoothKeys || !this._state || typeof this._state !== 'object') return this._state;
|
|
2405
|
+
const r = rate != null ? rate : this._smoothRate;
|
|
2406
|
+
const out = Array.isArray(this._state) ? this._state.slice() : Object.assign({}, this._state);
|
|
2407
|
+
for (let i = 0; i < this._smoothKeys.length; i++) {
|
|
2408
|
+
const k = this._smoothKeys[i];
|
|
2409
|
+
let e = this._error[k] || 0;
|
|
2410
|
+
if (typeof out[k] === 'number' && e !== 0) out[k] = out[k] + e;
|
|
2411
|
+
e *= (1 - r);
|
|
2412
|
+
if (Math.abs(e) < this._snapTo) e = 0;
|
|
2413
|
+
this._error[k] = e;
|
|
2414
|
+
}
|
|
2415
|
+
return out;
|
|
2416
|
+
}
|
|
1994
2417
|
|
|
1995
|
-
|
|
2418
|
+
/** Hard reset (e.g. on rematch). */
|
|
2419
|
+
reset(state) {
|
|
2420
|
+
this._state = state !== undefined ? state : null;
|
|
2421
|
+
this._seq = 0;
|
|
2422
|
+
this._pending = [];
|
|
2423
|
+
this._error = {};
|
|
2424
|
+
}
|
|
1996
2425
|
}
|
|
1997
2426
|
|
|
2427
|
+
/**
|
|
2428
|
+
* Usion SDK Netcode — Outbound send coalescing at a fixed tick rate.
|
|
2429
|
+
*
|
|
2430
|
+
* Games tend to emit on every input event or every animation frame (often
|
|
2431
|
+
* 60+/s). Each emit is a separate Socket.IO message and, in platform mode,
|
|
2432
|
+
* a separate server-side broadcast (and historically a Redis write). That
|
|
2433
|
+
* floods the wire and the server far beyond what gameplay needs.
|
|
2434
|
+
*
|
|
2435
|
+
* The Coalescer buffers outbound messages and flushes them on a fixed tick
|
|
2436
|
+
* (e.g. 20 Hz). Two modes per channel:
|
|
2437
|
+
* - queue(type, data): latest-wins — only the newest value per type is sent
|
|
2438
|
+
* each tick. Ideal for state snapshots, cursor/position updates.
|
|
2439
|
+
* - append(type, data): buffered — every value is kept and sent as a batch.
|
|
2440
|
+
* Use for discrete inputs you can't afford to drop.
|
|
2441
|
+
*
|
|
2442
|
+
* `onFlush(entries)` receives `[{ type, data }]` in insertion order. The
|
|
2443
|
+
* Coalescer is transport-agnostic; the caller decides how to send each entry
|
|
2444
|
+
* (game.realtime, game.action, a WebRTC datachannel, etc.).
|
|
2445
|
+
*/
|
|
2446
|
+
class Coalescer {
|
|
2447
|
+
/**
|
|
2448
|
+
* @param {object} opts
|
|
2449
|
+
* @param {number} [opts.hz=20] Flush frequency.
|
|
2450
|
+
* @param {(entries:Array<{type:string,data:any}>)=>void} opts.onFlush
|
|
2451
|
+
* @param {boolean} [opts.autoStart=true]
|
|
2452
|
+
* @param {Function} [opts.setInterval] Injectable for tests.
|
|
2453
|
+
* @param {Function} [opts.clearInterval]
|
|
2454
|
+
*/
|
|
2455
|
+
constructor(opts = {}) {
|
|
2456
|
+
if (typeof opts.onFlush !== 'function') {
|
|
2457
|
+
throw new Error('Coalescer requires an onFlush(entries) callback');
|
|
2458
|
+
}
|
|
2459
|
+
this._onFlush = opts.onFlush;
|
|
2460
|
+
this._hz = opts.hz || 20;
|
|
2461
|
+
this._setInterval = opts.setInterval || (typeof setInterval !== 'undefined' ? setInterval : null);
|
|
2462
|
+
this._clearInterval = opts.clearInterval || (typeof clearInterval !== 'undefined' ? clearInterval : null);
|
|
2463
|
+
this._order = []; // slot keys in first-seen order
|
|
2464
|
+
this._slots = {}; // key -> { mode:'latest'|'list', value, list }
|
|
2465
|
+
this._timer = null;
|
|
2466
|
+
if (opts.autoStart !== false) this.start();
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
get running() { return this._timer !== null; }
|
|
2470
|
+
|
|
2471
|
+
_ensure(type, mode) {
|
|
2472
|
+
let slot = this._slots[type];
|
|
2473
|
+
if (!slot) {
|
|
2474
|
+
slot = { mode, value: undefined, list: [] };
|
|
2475
|
+
this._slots[type] = slot;
|
|
2476
|
+
this._order.push(type);
|
|
2477
|
+
}
|
|
2478
|
+
return slot;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/** Latest-wins: only the newest data for `type` is sent on the next flush. */
|
|
2482
|
+
queue(type, data) {
|
|
2483
|
+
const slot = this._ensure(type, 'latest');
|
|
2484
|
+
slot.mode = 'latest';
|
|
2485
|
+
slot.value = data;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
/** Buffered: every value for `type` is kept and sent on the next flush. */
|
|
2489
|
+
append(type, data) {
|
|
2490
|
+
const slot = this._ensure(type, 'list');
|
|
2491
|
+
slot.mode = 'list';
|
|
2492
|
+
slot.list.push(data);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/** Build pending entries (insertion order) and clear the buffer. */
|
|
2496
|
+
drain() {
|
|
2497
|
+
const entries = [];
|
|
2498
|
+
for (let i = 0; i < this._order.length; i++) {
|
|
2499
|
+
const type = this._order[i];
|
|
2500
|
+
const slot = this._slots[type];
|
|
2501
|
+
if (slot.mode === 'latest') {
|
|
2502
|
+
if (slot.value !== undefined) entries.push({ type, data: slot.value });
|
|
2503
|
+
} else {
|
|
2504
|
+
for (let j = 0; j < slot.list.length; j++) entries.push({ type, data: slot.list[j] });
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
this._order = [];
|
|
2508
|
+
this._slots = {};
|
|
2509
|
+
return entries;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/** Flush now (also called automatically on each tick). */
|
|
2513
|
+
flush() {
|
|
2514
|
+
if (this._order.length === 0) return;
|
|
2515
|
+
const entries = this.drain();
|
|
2516
|
+
if (entries.length) this._onFlush(entries);
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
start() {
|
|
2520
|
+
if (this._timer || !this._setInterval) return;
|
|
2521
|
+
this._timer = this._setInterval(() => this.flush(), Math.max(1, Math.round(1000 / this._hz)));
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
stop() {
|
|
2525
|
+
if (this._timer && this._clearInterval) this._clearInterval(this._timer);
|
|
2526
|
+
this._timer = null;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Usion SDK Netcode — Round-trip-time meter.
|
|
2532
|
+
*
|
|
2533
|
+
* Tracks RTT with an exponentially-weighted moving average plus a jitter
|
|
2534
|
+
* estimate, so games (and the debug overlay) can surface real latency and so
|
|
2535
|
+
* the interpolation buffer can be tuned to network conditions. Transport
|
|
2536
|
+
* agnostic: feed it raw samples, or use begin()/end() to time individual
|
|
2537
|
+
* probes.
|
|
2538
|
+
*/
|
|
2539
|
+
class PingMeter {
|
|
2540
|
+
/**
|
|
2541
|
+
* @param {object} [opts]
|
|
2542
|
+
* @param {number} [opts.alpha=0.2] EWMA smoothing factor (0..1).
|
|
2543
|
+
* @param {Function} [opts.now] Clock source (default Date.now).
|
|
2544
|
+
*/
|
|
2545
|
+
constructor(opts = {}) {
|
|
2546
|
+
this._alpha = opts.alpha != null ? opts.alpha : 0.2;
|
|
2547
|
+
this._now = opts.now || (() => Date.now());
|
|
2548
|
+
this._rtt = null;
|
|
2549
|
+
this._jitter = 0;
|
|
2550
|
+
this._last = null;
|
|
2551
|
+
this._id = 0;
|
|
2552
|
+
this._outstanding = {};
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
/** Smoothed round-trip time in ms (null until the first sample). */
|
|
2556
|
+
get rtt() { return this._rtt; }
|
|
2557
|
+
/** One-way latency estimate (≈ rtt / 2). */
|
|
2558
|
+
get latency() { return this._rtt == null ? null : this._rtt / 2; }
|
|
2559
|
+
/** Smoothed absolute variation between samples (ms). */
|
|
2560
|
+
get jitter() { return this._jitter; }
|
|
2561
|
+
/** Most recent raw RTT sample (ms). */
|
|
2562
|
+
get last() { return this._last; }
|
|
2563
|
+
|
|
2564
|
+
/** Start timing a probe; pass the returned id to end(). */
|
|
2565
|
+
begin() {
|
|
2566
|
+
const id = ++this._id;
|
|
2567
|
+
this._outstanding[id] = this._now();
|
|
2568
|
+
return id;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/** Complete a probe started with begin(); returns the RTT sample or null. */
|
|
2572
|
+
end(id) {
|
|
2573
|
+
const sent = this._outstanding[id];
|
|
2574
|
+
if (sent == null) return null;
|
|
2575
|
+
delete this._outstanding[id];
|
|
2576
|
+
return this.sample(this._now() - sent);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Feed a raw RTT sample (ms). Returns the same value. */
|
|
2580
|
+
sample(rttMs) {
|
|
2581
|
+
if (!(rttMs >= 0)) return null;
|
|
2582
|
+
if (this._rtt == null) {
|
|
2583
|
+
this._rtt = rttMs;
|
|
2584
|
+
} else {
|
|
2585
|
+
this._jitter += this._alpha * (Math.abs(rttMs - this._rtt) - this._jitter);
|
|
2586
|
+
this._rtt += this._alpha * (rttMs - this._rtt);
|
|
2587
|
+
}
|
|
2588
|
+
this._last = rttMs;
|
|
2589
|
+
return rttMs;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
reset() {
|
|
2593
|
+
this._rtt = null;
|
|
2594
|
+
this._jitter = 0;
|
|
2595
|
+
this._last = null;
|
|
2596
|
+
this._outstanding = {};
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/**
|
|
2601
|
+
* Usion SDK Netcode — WebRTC peer-to-peer data channels.
|
|
2602
|
+
*
|
|
2603
|
+
* WebSocket runs over TCP (ordered+reliable) → one lost packet stalls
|
|
2604
|
+
* everything behind it (head-of-line blocking). WebRTC data channels run over
|
|
2605
|
+
* UDP/SCTP and can be unreliable+unordered, so the newest state always gets
|
|
2606
|
+
* through. Gameplay flows peer-to-peer (no backend hop, no HOL blocking).
|
|
2607
|
+
*
|
|
2608
|
+
* Practices adopted (WebRTC game-networking guidance):
|
|
2609
|
+
* - **Two channels**: 'unreliable' (ordered:false, maxRetransmits:0) for
|
|
2610
|
+
* gameplay, 'reliable' (ordered:true) for must-arrive events.
|
|
2611
|
+
* - **Sequence numbers** on the unreliable channel → drop stale/out-of-order
|
|
2612
|
+
* frames (the receiver only ever advances).
|
|
2613
|
+
* - **TURN-ready**: ~15–20% of sessions need a relay; pass iceServers (use the
|
|
2614
|
+
* `MeshConnection.iceServers()` helper). Trickle ICE is used by default.
|
|
2615
|
+
* - **Reconnect**: monitor (ice)connectionState and recover via ICE restart.
|
|
2616
|
+
*
|
|
2617
|
+
* Signaling I/O is injected, so the same class works in every connection mode
|
|
2618
|
+
* and is testable with a fake RTCPeerConnection.
|
|
2619
|
+
*/
|
|
2620
|
+
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
|
|
2621
|
+
|
|
2622
|
+
function isBinary$1(d) {
|
|
2623
|
+
return d instanceof ArrayBuffer || ArrayBuffer.isView(d);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
class MeshConnection {
|
|
2627
|
+
/**
|
|
2628
|
+
* @param {object} opts
|
|
2629
|
+
* @param {'host'|'guest'} opts.role
|
|
2630
|
+
* @param {(payload:object)=>void} opts.sendSignal
|
|
2631
|
+
* @param {Array} [opts.iceServers]
|
|
2632
|
+
* @param {boolean} [opts.sequenced=true] Drop stale frames on the unreliable channel.
|
|
2633
|
+
* @param {boolean} [opts.autoReconnect=true] Recover via ICE restart (host).
|
|
2634
|
+
* @param {number} [opts.maxRestarts=5]
|
|
2635
|
+
* @param {Function} [opts.RTCPeerConnection] Injectable for tests.
|
|
2636
|
+
* @param {Function} [opts.setTimeout] Injectable for tests.
|
|
2637
|
+
*/
|
|
2638
|
+
constructor(opts = {}) {
|
|
2639
|
+
if (opts.role !== 'host' && opts.role !== 'guest') throw new Error("MeshConnection requires role 'host' or 'guest'");
|
|
2640
|
+
if (typeof opts.sendSignal !== 'function') throw new Error('MeshConnection requires a sendSignal(payload) function');
|
|
2641
|
+
this.role = opts.role;
|
|
2642
|
+
this._sendSignal = opts.sendSignal;
|
|
2643
|
+
this._iceServers = opts.iceServers || DEFAULT_ICE;
|
|
2644
|
+
this._sequenced = opts.sequenced !== false;
|
|
2645
|
+
this._autoReconnect = opts.autoReconnect !== false;
|
|
2646
|
+
this._maxRestarts = opts.maxRestarts != null ? opts.maxRestarts : 5;
|
|
2647
|
+
this._RTCPeerConnection = opts.RTCPeerConnection || (typeof RTCPeerConnection !== 'undefined' ? RTCPeerConnection : null);
|
|
2648
|
+
this._setTimeout = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
2649
|
+
|
|
2650
|
+
this.connected = false;
|
|
2651
|
+
this._pc = null;
|
|
2652
|
+
this._unreliable = null;
|
|
2653
|
+
this._reliable = null;
|
|
2654
|
+
this._sendSeq = 0;
|
|
2655
|
+
this._recvSeq = 0;
|
|
2656
|
+
this._restarts = 0;
|
|
2657
|
+
|
|
2658
|
+
this.onOpen = null; // () => void
|
|
2659
|
+
this.onMessage = null; // (data, channel) => void
|
|
2660
|
+
this.onClose = null; // () => void
|
|
2661
|
+
this.onError = null; // (err) => void
|
|
2662
|
+
this.onStateChange = null; // (state:string) => void
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/** Build an iceServers array with optional TURN relay. */
|
|
2666
|
+
static iceServers(cfg = {}) {
|
|
2667
|
+
const list = [{ urls: cfg.stun || 'stun:stun.l.google.com:19302' }];
|
|
2668
|
+
if (cfg.turn) {
|
|
2669
|
+
const entry = { urls: cfg.turn };
|
|
2670
|
+
if (cfg.turnUsername) entry.username = cfg.turnUsername;
|
|
2671
|
+
if (cfg.turnCredential) entry.credential = cfg.turnCredential;
|
|
2672
|
+
list.push(entry);
|
|
2673
|
+
}
|
|
2674
|
+
return list;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
async start() {
|
|
2678
|
+
if (!this._RTCPeerConnection) throw new Error('RTCPeerConnection unavailable');
|
|
2679
|
+
const pc = new this._RTCPeerConnection({ iceServers: this._iceServers });
|
|
2680
|
+
this._pc = pc;
|
|
2681
|
+
|
|
2682
|
+
pc.onicecandidate = (e) => { if (e && e.candidate) this._sendSignal({ type: 'ice', candidate: e.candidate }); };
|
|
2683
|
+
const onState = () => this._handleStateChange(pc.connectionState || pc.iceConnectionState);
|
|
2684
|
+
pc.onconnectionstatechange = onState;
|
|
2685
|
+
pc.oniceconnectionstatechange = onState;
|
|
2686
|
+
|
|
2687
|
+
if (this.role === 'host') {
|
|
2688
|
+
this._bindChannel((this._unreliable = pc.createDataChannel('unreliable', { ordered: false, maxRetransmits: 0 })), 'unreliable');
|
|
2689
|
+
this._bindChannel((this._reliable = pc.createDataChannel('reliable', { ordered: true })), 'reliable');
|
|
2690
|
+
await this._makeOffer(false);
|
|
2691
|
+
} else {
|
|
2692
|
+
pc.ondatachannel = (e) => {
|
|
2693
|
+
const ch = e.channel;
|
|
2694
|
+
if (ch.label === 'unreliable') this._bindChannel((this._unreliable = ch), 'unreliable');
|
|
2695
|
+
else if (ch.label === 'reliable') this._bindChannel((this._reliable = ch), 'reliable');
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
async _makeOffer(iceRestart) {
|
|
2701
|
+
const pc = this._pc;
|
|
2702
|
+
const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
|
|
2703
|
+
await pc.setLocalDescription(offer);
|
|
2704
|
+
this._sendSignal({ type: 'offer', sdp: pc.localDescription, restart: !!iceRestart });
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
_handleStateChange(state) {
|
|
2708
|
+
if (this.onStateChange) this.onStateChange(state);
|
|
2709
|
+
if (state === 'connected') { this._restarts = 0; return; }
|
|
2710
|
+
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
|
2711
|
+
if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
|
|
2712
|
+
if (this._autoReconnect && this.role === 'host' && state !== 'closed' && this._restarts < this._maxRestarts) {
|
|
2713
|
+
this._restarts += 1;
|
|
2714
|
+
const delay = Math.min(1000 * this._restarts, 5000);
|
|
2715
|
+
if (this._setTimeout) this._setTimeout(() => { this._makeOffer(true).catch((err) => { if (this.onError) this.onError(err); }); }, delay);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
async handleSignal(payload) {
|
|
2721
|
+
const pc = this._pc;
|
|
2722
|
+
if (!pc || !payload || !payload.type) return;
|
|
2723
|
+
try {
|
|
2724
|
+
if (payload.type === 'offer') {
|
|
2725
|
+
await pc.setRemoteDescription(payload.sdp);
|
|
2726
|
+
const answer = await pc.createAnswer();
|
|
2727
|
+
await pc.setLocalDescription(answer);
|
|
2728
|
+
this._sendSignal({ type: 'answer', sdp: pc.localDescription });
|
|
2729
|
+
} else if (payload.type === 'answer') {
|
|
2730
|
+
await pc.setRemoteDescription(payload.sdp);
|
|
2731
|
+
} else if (payload.type === 'ice' && payload.candidate) {
|
|
2732
|
+
await pc.addIceCandidate(payload.candidate);
|
|
2733
|
+
}
|
|
2734
|
+
} catch (err) {
|
|
2735
|
+
if (this.onError) this.onError(err);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
_bindChannel(ch, label) {
|
|
2740
|
+
ch.binaryType = 'arraybuffer';
|
|
2741
|
+
ch.onopen = () => {
|
|
2742
|
+
if (label === 'unreliable' && !this.connected) { this.connected = true; if (this.onOpen) this.onOpen(); }
|
|
2743
|
+
};
|
|
2744
|
+
ch.onmessage = (e) => {
|
|
2745
|
+
if (!this.onMessage) return;
|
|
2746
|
+
let data = e.data;
|
|
2747
|
+
if (isBinary$1(data)) { this.onMessage(data, label); return; }
|
|
2748
|
+
try { data = typeof data === 'string' ? JSON.parse(data) : data; } catch (_) { this.onMessage(e.data, label); return; }
|
|
2749
|
+
if (label === 'unreliable' && this._sequenced && data && typeof data.__s === 'number') {
|
|
2750
|
+
if (data.__s <= this._recvSeq) return; // stale / out-of-order — drop
|
|
2751
|
+
this._recvSeq = data.__s;
|
|
2752
|
+
this.onMessage(data.m, label);
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
this.onMessage(data, label);
|
|
2756
|
+
};
|
|
2757
|
+
ch.onclose = () => { if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); } };
|
|
2758
|
+
ch.onerror = (e) => { if (this.onError) this.onError(e); };
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
/** Send over the unreliable/unordered channel (sequenced unless binary). */
|
|
2762
|
+
send(data) {
|
|
2763
|
+
const ch = this._unreliable;
|
|
2764
|
+
if (!ch || ch.readyState !== 'open') return false;
|
|
2765
|
+
if (isBinary$1(data)) { ch.send(data); return true; }
|
|
2766
|
+
if (this._sequenced) { this._sendSeq += 1; ch.send(JSON.stringify({ __s: this._sendSeq, m: data })); }
|
|
2767
|
+
else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/** Send over the reliable/ordered channel. */
|
|
2772
|
+
sendReliable(data) {
|
|
2773
|
+
const ch = this._reliable;
|
|
2774
|
+
if (!ch || ch.readyState !== 'open') return false;
|
|
2775
|
+
if (isBinary$1(data)) ch.send(data);
|
|
2776
|
+
else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
2777
|
+
return true;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
close() {
|
|
2781
|
+
try { if (this._unreliable) this._unreliable.close(); } catch (_) {}
|
|
2782
|
+
try { if (this._reliable) this._reliable.close(); } catch (_) {}
|
|
2783
|
+
try { if (this._pc) this._pc.close(); } catch (_) {}
|
|
2784
|
+
this._unreliable = this._reliable = this._pc = null;
|
|
2785
|
+
this.connected = false;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
/**
|
|
2790
|
+
* Usion SDK Netcode — N-peer full mesh.
|
|
2791
|
+
*
|
|
2792
|
+
* Manages one MeshConnection per remote peer so >2 players can talk directly
|
|
2793
|
+
* peer-to-peer. For each pair, the peer with the lexicographically smaller id
|
|
2794
|
+
* is the host (deterministic role assignment avoids offer/answer "glare").
|
|
2795
|
+
* Signaling is routed per-peer (`sendSignal(toPeerId, payload)`); feed inbound
|
|
2796
|
+
* signaling with `handleSignal(fromPeerId, payload)`.
|
|
2797
|
+
*/
|
|
2798
|
+
|
|
2799
|
+
class MeshNetwork {
|
|
2800
|
+
/**
|
|
2801
|
+
* @param {object} opts
|
|
2802
|
+
* @param {string} opts.selfId
|
|
2803
|
+
* @param {(toPeerId:string, payload:object)=>void} opts.sendSignal
|
|
2804
|
+
* @param {Array} [opts.iceServers]
|
|
2805
|
+
* @param {Function} [opts.RTCPeerConnection]
|
|
2806
|
+
* @param {Function} [opts.setTimeout]
|
|
2807
|
+
* @param {boolean} [opts.sequenced]
|
|
2808
|
+
* @param {boolean} [opts.autoReconnect]
|
|
2809
|
+
*/
|
|
2810
|
+
constructor(opts = {}) {
|
|
2811
|
+
if (!opts.selfId) throw new Error('MeshNetwork requires a selfId');
|
|
2812
|
+
if (typeof opts.sendSignal !== 'function') throw new Error('MeshNetwork requires sendSignal(toPeerId, payload)');
|
|
2813
|
+
this.selfId = opts.selfId;
|
|
2814
|
+
this._sendSignal = opts.sendSignal;
|
|
2815
|
+
this._opts = opts;
|
|
2816
|
+
this._peers = {}; // peerId -> MeshConnection
|
|
2817
|
+
|
|
2818
|
+
this.onPeerOpen = null; // (peerId) => void
|
|
2819
|
+
this.onPeerClose = null; // (peerId) => void
|
|
2820
|
+
this.onMessage = null; // (peerId, data, channel) => void
|
|
2821
|
+
this.onError = null; // (peerId, err) => void
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
get peerIds() { return Object.keys(this._peers); }
|
|
2825
|
+
get connectedCount() { let n = 0; for (const id in this._peers) if (this._peers[id].connected) n += 1; return n; }
|
|
2826
|
+
peer(peerId) { return this._peers[peerId] || null; }
|
|
2827
|
+
|
|
2828
|
+
_roleFor(peerId) { return this.selfId < peerId ? 'host' : 'guest'; }
|
|
2829
|
+
|
|
2830
|
+
_ensurePeer(peerId) {
|
|
2831
|
+
if (this._peers[peerId]) return this._peers[peerId];
|
|
2832
|
+
const self = this;
|
|
2833
|
+
const conn = new MeshConnection({
|
|
2834
|
+
role: this._roleFor(peerId),
|
|
2835
|
+
iceServers: this._opts.iceServers,
|
|
2836
|
+
RTCPeerConnection: this._opts.RTCPeerConnection,
|
|
2837
|
+
setTimeout: this._opts.setTimeout,
|
|
2838
|
+
sequenced: this._opts.sequenced,
|
|
2839
|
+
autoReconnect: this._opts.autoReconnect,
|
|
2840
|
+
sendSignal: (payload) => self._sendSignal(peerId, payload),
|
|
2841
|
+
});
|
|
2842
|
+
conn.onOpen = () => { if (self.onPeerOpen) self.onPeerOpen(peerId); };
|
|
2843
|
+
conn.onClose = () => { if (self.onPeerClose) self.onPeerClose(peerId); };
|
|
2844
|
+
conn.onMessage = (data, channel) => { if (self.onMessage) self.onMessage(peerId, data, channel); };
|
|
2845
|
+
conn.onError = (err) => { if (self.onError) self.onError(peerId, err); };
|
|
2846
|
+
this._peers[peerId] = conn;
|
|
2847
|
+
return conn;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
/** Connect to a peer (creates the connection and starts negotiation). */
|
|
2851
|
+
async addPeer(peerId) {
|
|
2852
|
+
if (peerId === this.selfId) return null;
|
|
2853
|
+
const conn = this._ensurePeer(peerId);
|
|
2854
|
+
await conn.start();
|
|
2855
|
+
return conn;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/** Sync the mesh to a roster of peer ids: connect to new ones, drop missing. */
|
|
2859
|
+
async setRoster(peerIds) {
|
|
2860
|
+
const want = {};
|
|
2861
|
+
for (let i = 0; i < peerIds.length; i++) if (peerIds[i] !== this.selfId) want[peerIds[i]] = true;
|
|
2862
|
+
for (const id in want) if (!this._peers[id]) await this.addPeer(id);
|
|
2863
|
+
for (const id in this._peers) if (!want[id]) this.removePeer(id);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
removePeer(peerId) {
|
|
2867
|
+
const conn = this._peers[peerId];
|
|
2868
|
+
if (!conn) return;
|
|
2869
|
+
try { conn.close(); } catch (_) {}
|
|
2870
|
+
delete this._peers[peerId];
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/** Route an inbound signaling message from a peer. */
|
|
2874
|
+
async handleSignal(fromPeerId, payload) {
|
|
2875
|
+
if (!fromPeerId || fromPeerId === this.selfId) return;
|
|
2876
|
+
const conn = this._ensurePeer(fromPeerId);
|
|
2877
|
+
if (!conn._pc) await conn.start(); // lazily start guest side on first offer
|
|
2878
|
+
await conn.handleSignal(payload);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/** Send to one peer over the unreliable channel. */
|
|
2882
|
+
send(peerId, data) { const c = this._peers[peerId]; return c ? c.send(data) : false; }
|
|
2883
|
+
sendReliable(peerId, data) { const c = this._peers[peerId]; return c ? c.sendReliable(data) : false; }
|
|
2884
|
+
|
|
2885
|
+
/** Broadcast to all connected peers (unreliable). */
|
|
2886
|
+
broadcast(data) { for (const id in this._peers) this._peers[id].send(data); }
|
|
2887
|
+
broadcastReliable(data) { for (const id in this._peers) this._peers[id].sendReliable(data); }
|
|
2888
|
+
|
|
2889
|
+
close() { for (const id in this._peers) { try { this._peers[id].close(); } catch (_) {} } this._peers = {}; }
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Usion SDK Netcode — WebTransport (HTTP/3 / QUIC) client transport.
|
|
2894
|
+
*
|
|
2895
|
+
* The lowest-latency *client-server* path for browser games (Baseline across
|
|
2896
|
+
* major browsers since 2026). Unlike WebSocket (TCP → head-of-line blocking)
|
|
2897
|
+
* it offers UDP-like unreliable **datagrams** — the newest snapshot always
|
|
2898
|
+
* gets through — plus reliable **streams** for must-arrive events, over a
|
|
2899
|
+
* single QUIC connection, without WebRTC's ICE/STUN/TURN/SDP complexity.
|
|
2900
|
+
*
|
|
2901
|
+
* Uses the native `WebTransport` API (zero dependency). On the server, pair
|
|
2902
|
+
* with an HTTP/3 server such as the open-source `@fails-components/webtransport`
|
|
2903
|
+
* (Node). Same interface shape as MeshConnection so it drops into the same
|
|
2904
|
+
* snapshot sender/receiver + interpolation pipeline.
|
|
2905
|
+
*
|
|
2906
|
+
* Framing:
|
|
2907
|
+
* - datagram = [seq:uint32 BE][type:uint8][payload] (one datagram = one msg)
|
|
2908
|
+
* - stream = [len:uint32 BE][type:uint8][payload] (len = 1 + payload bytes)
|
|
2909
|
+
* - type 0 = binary (Uint8Array), 1 = JSON (utf8). Sequenced datagrams drop
|
|
2910
|
+
* stale/out-of-order frames (receiver only advances).
|
|
2911
|
+
*/
|
|
2912
|
+
const _enc$1 = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
2913
|
+
const _dec$1 = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
|
2914
|
+
|
|
2915
|
+
function toBytes(s) { return _enc$1 ? _enc$1.encode(s) : Uint8Array.from(Buffer.from(s, 'utf8')); }
|
|
2916
|
+
function fromBytes(b) { return _dec$1 ? _dec$1.decode(b) : Buffer.from(b).toString('utf8'); }
|
|
2917
|
+
function isBinary(d) { return d instanceof ArrayBuffer || ArrayBuffer.isView(d); }
|
|
2918
|
+
|
|
2919
|
+
/** Encode a value to { type, bytes }: binary passthrough or JSON utf8. */
|
|
2920
|
+
function encodePayload(data) {
|
|
2921
|
+
if (isBinary(data)) return { type: 0, bytes: data instanceof Uint8Array ? data : new Uint8Array(data.buffer || data) };
|
|
2922
|
+
return { type: 1, bytes: toBytes(JSON.stringify(data)) };
|
|
2923
|
+
}
|
|
2924
|
+
function decodePayload(type, bytes) {
|
|
2925
|
+
if (type === 1) { try { return JSON.parse(fromBytes(bytes)); } catch (_) { return null; } }
|
|
2926
|
+
return bytes;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/** datagram frame: [seq:4][type:1][payload] */
|
|
2930
|
+
function encodeDatagram(seq, data) {
|
|
2931
|
+
const { type, bytes } = encodePayload(data);
|
|
2932
|
+
const out = new Uint8Array(5 + bytes.length);
|
|
2933
|
+
new DataView(out.buffer).setUint32(0, seq >>> 0, false);
|
|
2934
|
+
out[4] = type;
|
|
2935
|
+
out.set(bytes, 5);
|
|
2936
|
+
return out;
|
|
2937
|
+
}
|
|
2938
|
+
function decodeDatagram(frame) {
|
|
2939
|
+
const b = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
|
|
2940
|
+
if (b.length < 5) return null;
|
|
2941
|
+
const seq = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(0, false);
|
|
2942
|
+
return { seq: seq, value: decodePayload(b[4], b.subarray(5)) };
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/** stream frame: [len:4][type:1][payload], len = 1 + payload length */
|
|
2946
|
+
function encodeStreamFrame(data) {
|
|
2947
|
+
const { type, bytes } = encodePayload(data);
|
|
2948
|
+
const out = new Uint8Array(4 + 1 + bytes.length);
|
|
2949
|
+
new DataView(out.buffer).setUint32(0, 1 + bytes.length, false);
|
|
2950
|
+
out[4] = type;
|
|
2951
|
+
out.set(bytes, 5);
|
|
2952
|
+
return out;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/** Stateful deframer for the reliable byte stream. push(bytes) → [values]. */
|
|
2956
|
+
class StreamDeframer {
|
|
2957
|
+
constructor() { this._buf = new Uint8Array(0); }
|
|
2958
|
+
push(chunk) {
|
|
2959
|
+
const c = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
2960
|
+
const merged = new Uint8Array(this._buf.length + c.length);
|
|
2961
|
+
merged.set(this._buf, 0); merged.set(c, this._buf.length);
|
|
2962
|
+
this._buf = merged;
|
|
2963
|
+
const out = [];
|
|
2964
|
+
while (this._buf.length >= 4) {
|
|
2965
|
+
const len = new DataView(this._buf.buffer, this._buf.byteOffset, 4).getUint32(0, false);
|
|
2966
|
+
if (this._buf.length < 4 + len) break;
|
|
2967
|
+
const type = this._buf[4];
|
|
2968
|
+
const payload = this._buf.subarray(5, 4 + len);
|
|
2969
|
+
out.push(decodePayload(type, payload.slice()));
|
|
2970
|
+
this._buf = this._buf.subarray(4 + len);
|
|
2971
|
+
}
|
|
2972
|
+
return out;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
class WebTransportConnection {
|
|
2977
|
+
/**
|
|
2978
|
+
* @param {object} opts
|
|
2979
|
+
* @param {string} opts.url https:// HTTP/3 endpoint
|
|
2980
|
+
* @param {Array} [opts.serverCertificateHashes] for self-signed dev certs
|
|
2981
|
+
* @param {boolean}[opts.sequenced=true] drop stale datagrams
|
|
2982
|
+
* @param {Function}[opts.WebTransport] injectable for tests
|
|
2983
|
+
*/
|
|
2984
|
+
constructor(opts = {}) {
|
|
2985
|
+
if (!opts.url) throw new Error('WebTransportConnection requires a url');
|
|
2986
|
+
this._url = opts.url;
|
|
2987
|
+
this._opts = opts;
|
|
2988
|
+
this._sequenced = opts.sequenced !== false;
|
|
2989
|
+
this._WT = opts.WebTransport || (typeof WebTransport !== 'undefined' ? WebTransport : null);
|
|
2990
|
+
|
|
2991
|
+
this.connected = false;
|
|
2992
|
+
this._t = null;
|
|
2993
|
+
this._dgWriter = null;
|
|
2994
|
+
this._streamWriter = null;
|
|
2995
|
+
this._deframer = new StreamDeframer();
|
|
2996
|
+
this._sendSeq = 0;
|
|
2997
|
+
this._recvSeq = 0;
|
|
2998
|
+
|
|
2999
|
+
this.onOpen = null; // () => void
|
|
3000
|
+
this.onMessage = null; // (data, channel:'datagram'|'reliable') => void
|
|
3001
|
+
this.onClose = null; // () => void
|
|
3002
|
+
this.onError = null; // (err) => void
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
async connect() {
|
|
3006
|
+
if (!this._WT) throw new Error('WebTransport unavailable in this environment');
|
|
3007
|
+
const init = {};
|
|
3008
|
+
if (this._opts.serverCertificateHashes) init.serverCertificateHashes = this._opts.serverCertificateHashes;
|
|
3009
|
+
const t = new this._WT(this._url, init);
|
|
3010
|
+
this._t = t;
|
|
3011
|
+
if (t.ready && typeof t.ready.then === 'function') await t.ready;
|
|
3012
|
+
|
|
3013
|
+
if (t.datagrams && t.datagrams.writable) this._dgWriter = t.datagrams.writable.getWriter();
|
|
3014
|
+
if (typeof t.createBidirectionalStream === 'function') {
|
|
3015
|
+
try {
|
|
3016
|
+
const s = await t.createBidirectionalStream();
|
|
3017
|
+
this._streamWriter = s.writable.getWriter();
|
|
3018
|
+
this._pumpReadable(s.readable, 'reliable');
|
|
3019
|
+
} catch (e) { /* reliable stream optional */ }
|
|
3020
|
+
}
|
|
3021
|
+
if (t.datagrams && t.datagrams.readable) this._pumpDatagrams(t.datagrams.readable);
|
|
3022
|
+
if (t.closed && typeof t.closed.then === 'function') t.closed.then(() => this._onClosed(), () => this._onClosed());
|
|
3023
|
+
|
|
3024
|
+
this.connected = true;
|
|
3025
|
+
if (this.onOpen) this.onOpen();
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
async _pumpDatagrams(readable) {
|
|
3029
|
+
try {
|
|
3030
|
+
const reader = readable.getReader();
|
|
3031
|
+
for (;;) {
|
|
3032
|
+
const { value, done } = await reader.read();
|
|
3033
|
+
if (done) break;
|
|
3034
|
+
if (value) this._onDatagramBytes(value);
|
|
3035
|
+
}
|
|
3036
|
+
} catch (e) { if (this.onError) this.onError(e); }
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
async _pumpReadable(readable, channel) {
|
|
3040
|
+
try {
|
|
3041
|
+
const reader = readable.getReader();
|
|
3042
|
+
for (;;) {
|
|
3043
|
+
const { value, done } = await reader.read();
|
|
3044
|
+
if (done) break;
|
|
3045
|
+
if (value) { const msgs = this._deframer.push(value); for (let i = 0; i < msgs.length; i++) if (this.onMessage) this.onMessage(msgs[i], channel); }
|
|
3046
|
+
}
|
|
3047
|
+
} catch (e) { if (this.onError) this.onError(e); }
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** Handle one inbound datagram frame (also the test entry point). */
|
|
3051
|
+
_onDatagramBytes(frame) {
|
|
3052
|
+
const parsed = decodeDatagram(frame);
|
|
3053
|
+
if (!parsed) return;
|
|
3054
|
+
if (this._sequenced) {
|
|
3055
|
+
if (parsed.seq <= this._recvSeq) return; // stale / out-of-order
|
|
3056
|
+
this._recvSeq = parsed.seq;
|
|
3057
|
+
}
|
|
3058
|
+
if (this.onMessage) this.onMessage(parsed.value, 'datagram');
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
/** Send over the unreliable datagram channel (sequenced). */
|
|
3062
|
+
send(data) {
|
|
3063
|
+
if (!this._dgWriter) return false;
|
|
3064
|
+
this._sendSeq += 1;
|
|
3065
|
+
try { this._dgWriter.write(encodeDatagram(this._sendSeq, data)); return true; }
|
|
3066
|
+
catch (e) { return false; }
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
/** Send over the reliable ordered stream. */
|
|
3070
|
+
sendReliable(data) {
|
|
3071
|
+
if (!this._streamWriter) return false;
|
|
3072
|
+
try { this._streamWriter.write(encodeStreamFrame(data)); return true; }
|
|
3073
|
+
catch (e) { return false; }
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
_onClosed() {
|
|
3077
|
+
if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
close() {
|
|
3081
|
+
try { if (this._dgWriter) this._dgWriter.releaseLock && this._dgWriter.releaseLock(); } catch (_) {}
|
|
3082
|
+
try { if (this._t) this._t.close(); } catch (_) {}
|
|
3083
|
+
this._t = this._dgWriter = this._streamWriter = null;
|
|
3084
|
+
this.connected = false;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
/**
|
|
3089
|
+
* Usion SDK Netcode — network condition simulator.
|
|
3090
|
+
*
|
|
3091
|
+
* Every serious game studio tests under degraded networks. NetworkSim wraps a
|
|
3092
|
+
* send (or receive) function and injects artificial **latency**, **jitter**,
|
|
3093
|
+
* **packet loss**, and **duplication** so creators can feel and tune their game
|
|
3094
|
+
* on a bad connection — locally, before shipping. Pure and deterministic when
|
|
3095
|
+
* you inject `random` / `setTimeout` (used in tests).
|
|
3096
|
+
*/
|
|
3097
|
+
class NetworkSim {
|
|
3098
|
+
/**
|
|
3099
|
+
* @param {object} [opts]
|
|
3100
|
+
* @param {number} [opts.latencyMs=0] Base one-way delay added to each message.
|
|
3101
|
+
* @param {number} [opts.jitterMs=0] Random +/- variation around the latency.
|
|
3102
|
+
* @param {number} [opts.lossPct=0] Probability (0–100) a message is dropped.
|
|
3103
|
+
* @param {number} [opts.dupPct=0] Probability (0–100) a message is duplicated.
|
|
3104
|
+
* @param {Function} [opts.setTimeout] Injectable scheduler (tests).
|
|
3105
|
+
* @param {Function} [opts.clearTimeout]
|
|
3106
|
+
* @param {Function} [opts.random] Injectable RNG (tests).
|
|
3107
|
+
*/
|
|
3108
|
+
constructor(opts = {}) {
|
|
3109
|
+
this._set = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
3110
|
+
this._clear = opts.clearTimeout || (typeof clearTimeout !== 'undefined' ? clearTimeout : null);
|
|
3111
|
+
this._rand = opts.random || Math.random;
|
|
3112
|
+
this._timers = new Set();
|
|
3113
|
+
this.set(opts);
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
/** Update conditions live. */
|
|
3117
|
+
set(opts = {}) {
|
|
3118
|
+
if (opts.latencyMs != null) this.latencyMs = Math.max(0, opts.latencyMs);
|
|
3119
|
+
else if (this.latencyMs == null) this.latencyMs = 0;
|
|
3120
|
+
if (opts.jitterMs != null) this.jitterMs = Math.max(0, opts.jitterMs);
|
|
3121
|
+
else if (this.jitterMs == null) this.jitterMs = 0;
|
|
3122
|
+
if (opts.lossPct != null) this.lossPct = Math.max(0, Math.min(100, opts.lossPct));
|
|
3123
|
+
else if (this.lossPct == null) this.lossPct = 0;
|
|
3124
|
+
if (opts.dupPct != null) this.dupPct = Math.max(0, Math.min(100, opts.dupPct));
|
|
3125
|
+
else if (this.dupPct == null) this.dupPct = 0;
|
|
3126
|
+
return this;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/** Delay for one message: latency ± jitter (never negative). */
|
|
3130
|
+
_delay() {
|
|
3131
|
+
const j = this.jitterMs ? (this._rand() * 2 - 1) * this.jitterMs : 0;
|
|
3132
|
+
const d = this.latencyMs + j;
|
|
3133
|
+
return d > 0 ? d : 0;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
_schedule(fn, delay) {
|
|
3137
|
+
if (!this._set) { fn(); return; }
|
|
3138
|
+
const id = this._set(() => { this._timers.delete(id); fn(); }, delay);
|
|
3139
|
+
this._timers.add(id);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* Wrap a delivery function so calls are delayed/dropped/duplicated per the
|
|
3144
|
+
* current conditions. The returned function has the same signature.
|
|
3145
|
+
*/
|
|
3146
|
+
wrap(fn) {
|
|
3147
|
+
const self = this;
|
|
3148
|
+
return function (...args) {
|
|
3149
|
+
// Fast path: no degradation configured.
|
|
3150
|
+
if (!self.latencyMs && !self.jitterMs && !self.lossPct && !self.dupPct) return fn.apply(this, args);
|
|
3151
|
+
if (self.lossPct && self._rand() * 100 < self.lossPct) return undefined; // dropped
|
|
3152
|
+
const ctx = this;
|
|
3153
|
+
self._schedule(() => fn.apply(ctx, args), self._delay());
|
|
3154
|
+
if (self.dupPct && self._rand() * 100 < self.dupPct) self._schedule(() => fn.apply(ctx, args), self._delay());
|
|
3155
|
+
return undefined;
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
/** Cancel all in-flight scheduled deliveries. */
|
|
3160
|
+
flush() {
|
|
3161
|
+
if (this._clear) for (const id of this._timers) this._clear(id);
|
|
3162
|
+
this._timers.clear();
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/**
|
|
3167
|
+
* Usion SDK Netcode — deterministic lockstep (the League-of-Legends / RTS model).
|
|
3168
|
+
*
|
|
3169
|
+
* Instead of streaming world state, every client runs the **same deterministic
|
|
3170
|
+
* simulation** and exchanges only **inputs**. Bandwidth is tiny even with
|
|
3171
|
+
* hundreds of units, and you get **replays for free** (re-run the input log).
|
|
3172
|
+
*
|
|
3173
|
+
* A frame advances only once every player's input for that frame is known.
|
|
3174
|
+
* Local inputs are scheduled `inputDelay` frames ahead so they have time to
|
|
3175
|
+
* reach peers before that frame is simulated (this hides latency). The game
|
|
3176
|
+
* owns its simulation; this class only orders inputs and tells you when to step.
|
|
3177
|
+
*
|
|
3178
|
+
* Determinism is the contract: your `step(frame, inputsByPlayer)` must be
|
|
3179
|
+
* fully deterministic (no Date.now/Math.random without a seeded source, no
|
|
3180
|
+
* floating-point divergence) or clients will desync.
|
|
3181
|
+
*/
|
|
3182
|
+
class Lockstep {
|
|
3183
|
+
/**
|
|
3184
|
+
* @param {object} opts
|
|
3185
|
+
* @param {string} opts.playerId
|
|
3186
|
+
* @param {string[]} opts.players All player ids in the match.
|
|
3187
|
+
* @param {(frame:number, inputs:Object)=>void} opts.step Deterministic sim step.
|
|
3188
|
+
* @param {(msg:{frame:number,playerId:string,input:any})=>void} [opts.send]
|
|
3189
|
+
* @param {number} [opts.inputDelay=2] Frames of input delay (latency hiding).
|
|
3190
|
+
* @param {any} [opts.idleInput=null] Input used for the seeded startup frames.
|
|
3191
|
+
*/
|
|
3192
|
+
constructor(opts = {}) {
|
|
3193
|
+
if (!opts.playerId) throw new Error('Lockstep requires playerId');
|
|
3194
|
+
if (!Array.isArray(opts.players) || opts.players.length === 0) throw new Error('Lockstep requires players[]');
|
|
3195
|
+
if (typeof opts.step !== 'function') throw new Error('Lockstep requires a step(frame, inputs) function');
|
|
3196
|
+
this.playerId = opts.playerId;
|
|
3197
|
+
this._players = opts.players.slice();
|
|
3198
|
+
this._step = opts.step;
|
|
3199
|
+
this._send = opts.send || function () {};
|
|
3200
|
+
this._inputDelay = opts.inputDelay != null ? opts.inputDelay : 2;
|
|
3201
|
+
this._idle = opts.idleInput != null ? opts.idleInput : null;
|
|
3202
|
+
|
|
3203
|
+
this._inputs = {}; // frame -> { playerId: input }
|
|
3204
|
+
this._frame = 0; // next frame to simulate
|
|
3205
|
+
this._submitFrame = this._inputDelay; // next frame the local player will fill
|
|
3206
|
+
this._replay = []; // [{ frame, inputs }]
|
|
3207
|
+
|
|
3208
|
+
// Seed the first `inputDelay` frames as idle so the sim can start.
|
|
3209
|
+
for (let f = 0; f < this._inputDelay; f++) {
|
|
3210
|
+
this._inputs[f] = {};
|
|
3211
|
+
for (let i = 0; i < this._players.length; i++) this._inputs[f][this._players[i]] = this._idle;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
get frame() { return this._frame; }
|
|
3216
|
+
get players() { return this._players.slice(); }
|
|
3217
|
+
|
|
3218
|
+
/** Submit the local player's input for the next schedulable frame. */
|
|
3219
|
+
submit(input) {
|
|
3220
|
+
const frame = this._submitFrame++;
|
|
3221
|
+
if (!this._inputs[frame]) this._inputs[frame] = {};
|
|
3222
|
+
this._inputs[frame][this.playerId] = input;
|
|
3223
|
+
this._send({ frame: frame, playerId: this.playerId, input: input });
|
|
3224
|
+
return frame;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Record a remote player's input. */
|
|
3228
|
+
receive(msg) {
|
|
3229
|
+
if (!msg || msg.frame == null || !msg.playerId) return;
|
|
3230
|
+
if (!this._inputs[msg.frame]) this._inputs[msg.frame] = {};
|
|
3231
|
+
this._inputs[msg.frame][msg.playerId] = msg.input;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
_ready(frame) {
|
|
3235
|
+
const fr = this._inputs[frame];
|
|
3236
|
+
if (!fr) return false;
|
|
3237
|
+
for (let i = 0; i < this._players.length; i++) if (!(this._players[i] in fr)) return false;
|
|
3238
|
+
return true;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
/**
|
|
3242
|
+
* Advance the simulation by every frame whose inputs are fully known.
|
|
3243
|
+
* @returns {number} frames advanced this call.
|
|
3244
|
+
*/
|
|
3245
|
+
tick() {
|
|
3246
|
+
let advanced = 0;
|
|
3247
|
+
while (this._ready(this._frame)) {
|
|
3248
|
+
const inputs = this._inputs[this._frame];
|
|
3249
|
+
this._step(this._frame, inputs);
|
|
3250
|
+
this._replay.push({ frame: this._frame, inputs: inputs });
|
|
3251
|
+
delete this._inputs[this._frame];
|
|
3252
|
+
this._frame += 1;
|
|
3253
|
+
advanced += 1;
|
|
3254
|
+
}
|
|
3255
|
+
return advanced;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/** True if the sim is blocked waiting on a missing input (stall detection). */
|
|
3259
|
+
isStalled() { return !this._ready(this._frame); }
|
|
3260
|
+
|
|
3261
|
+
/** The ordered input log — persist it to enable replays. */
|
|
3262
|
+
getReplay() { return this._replay.slice(); }
|
|
3263
|
+
|
|
3264
|
+
/** Re-simulate a recorded match by replaying its input log. */
|
|
3265
|
+
static replay(log, step) {
|
|
3266
|
+
for (let i = 0; i < log.length; i++) step(log[i].frame, log[i].inputs);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
/**
|
|
3271
|
+
* Usion SDK Netcode — server-side lag compensation ("server rewind").
|
|
3272
|
+
*
|
|
3273
|
+
* The fairness technique behind CS:GO/CS2 and Valorant. The authoritative
|
|
3274
|
+
* server records a short history of world snapshots; when a client claims an
|
|
3275
|
+
* action (e.g. a shot) the server **rewinds** the world to what that client
|
|
3276
|
+
* actually saw — accounting for their latency and interpolation delay — and
|
|
3277
|
+
* resolves the hit against that past state. Without this, high-ping players
|
|
3278
|
+
* must "lead" their targets and hit registration feels broken.
|
|
3279
|
+
*
|
|
3280
|
+
* Entities are matched by `id` and interpolated between the two recorded
|
|
3281
|
+
* snapshots straddling the rewind time (same math as client interpolation).
|
|
3282
|
+
* Pure and testable; use it inside your game server (direct mode).
|
|
3283
|
+
*/
|
|
3284
|
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
3285
|
+
|
|
3286
|
+
class LagCompensator {
|
|
3287
|
+
/**
|
|
3288
|
+
* @param {object} [opts]
|
|
3289
|
+
* @param {number} [opts.historyMs=1000] How far back to retain snapshots.
|
|
3290
|
+
* @param {number} [opts.maxSize=256] Hard cap on retained snapshots.
|
|
3291
|
+
* @param {function} [opts.now] Clock source (default Date.now).
|
|
3292
|
+
*/
|
|
3293
|
+
constructor(opts = {}) {
|
|
3294
|
+
this._historyMs = opts.historyMs || 1000;
|
|
3295
|
+
this._maxSize = opts.maxSize || 256;
|
|
3296
|
+
this._now = opts.now || (() => Date.now());
|
|
3297
|
+
this._history = []; // [{ time, entities:[{id,...}] }] oldest→newest
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/**
|
|
3301
|
+
* Record the authoritative world state for this tick.
|
|
3302
|
+
* @param {Array} entities Array of entities (each with a stable `id`).
|
|
3303
|
+
* @param {number} [time] Server time (default now()).
|
|
3304
|
+
*/
|
|
3305
|
+
record(entities, time) {
|
|
3306
|
+
const t = time != null ? time : this._now();
|
|
3307
|
+
this._history.push({ time: t, entities: entities.map((e) => Object.assign({}, e)) });
|
|
3308
|
+
const cutoff = t - this._historyMs;
|
|
3309
|
+
while (this._history.length > this._maxSize || (this._history.length > 2 && this._history[0].time < cutoff)) {
|
|
3310
|
+
this._history.shift();
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
get size() { return this._history.length; }
|
|
3315
|
+
clear() { this._history = []; }
|
|
3316
|
+
|
|
3317
|
+
/**
|
|
3318
|
+
* Reconstruct the world as it was at `time` (interpolated), keyed by id.
|
|
3319
|
+
* @param {number} time Absolute server time to rewind to.
|
|
3320
|
+
* @param {string[]} [keys] Numeric fields to interpolate (default: all numbers).
|
|
3321
|
+
* @returns {Object<string, object>} entities by id, or {} if no history.
|
|
3322
|
+
*/
|
|
3323
|
+
rewind(time, keys) {
|
|
3324
|
+
const h = this._history;
|
|
3325
|
+
if (h.length === 0) return {};
|
|
3326
|
+
if (time <= h[0].time) return byId(h[0].entities);
|
|
3327
|
+
if (time >= h[h.length - 1].time) return byId(h[h.length - 1].entities);
|
|
3328
|
+
|
|
3329
|
+
let older = h[0], newer = h[h.length - 1];
|
|
3330
|
+
for (let i = h.length - 1; i > 0; i--) {
|
|
3331
|
+
if (h[i - 1].time <= time && time <= h[i].time) { older = h[i - 1]; newer = h[i]; break; }
|
|
3332
|
+
}
|
|
3333
|
+
const span = newer.time - older.time;
|
|
3334
|
+
const t = span > 0 ? (time - older.time) / span : 0;
|
|
3335
|
+
const oldById = byId(older.entities);
|
|
3336
|
+
const out = {};
|
|
3337
|
+
const newById = byId(newer.entities);
|
|
3338
|
+
for (const id in newById) {
|
|
3339
|
+
const b = newById[id];
|
|
3340
|
+
const a = oldById[id];
|
|
3341
|
+
const e = Object.assign({}, b);
|
|
3342
|
+
if (a) {
|
|
3343
|
+
const fields = keys || Object.keys(b);
|
|
3344
|
+
for (let k = 0; k < fields.length; k++) {
|
|
3345
|
+
const key = fields[k];
|
|
3346
|
+
if (typeof a[key] === 'number' && typeof b[key] === 'number') e[key] = lerp(a[key], b[key], t);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
out[id] = e;
|
|
3350
|
+
}
|
|
3351
|
+
return out;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
/**
|
|
3355
|
+
* Convenience: rewind to what a client saw, given their measured round-trip
|
|
3356
|
+
* time and interpolation buffer. rewindMs is clamped to `maxRewindMs` (cap
|
|
3357
|
+
* it — Valorant caps ~35ms; CS allows more).
|
|
3358
|
+
* @returns {Object<string, object>} entities by id
|
|
3359
|
+
*/
|
|
3360
|
+
rewindForClient(rttMs, interpBufferMs, opts) {
|
|
3361
|
+
opts = opts || {};
|
|
3362
|
+
const maxRewind = opts.maxRewindMs != null ? opts.maxRewindMs : 250;
|
|
3363
|
+
let rewindMs = (rttMs || 0) / 2 + (interpBufferMs || 0);
|
|
3364
|
+
if (rewindMs > maxRewind) rewindMs = maxRewind;
|
|
3365
|
+
return this.rewind(this._now() - rewindMs, opts.keys);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function byId(entities) {
|
|
3370
|
+
const m = {};
|
|
3371
|
+
for (let i = 0; i < entities.length; i++) m[entities[i].id] = entities[i];
|
|
3372
|
+
return m;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
/**
|
|
3376
|
+
* Usion SDK Netcode — Delta compression for JSON game state.
|
|
3377
|
+
*
|
|
3378
|
+
* Real-time games typically re-send their entire world every tick. Most of
|
|
3379
|
+
* that payload is unchanged. `diff` produces a minimal patch describing only
|
|
3380
|
+
* what changed since the previous state; `patch` reconstructs the new state
|
|
3381
|
+
* from a base + patch. Both are plain JSON, so the patch rides on the existing
|
|
3382
|
+
* realtime/action channels unchanged.
|
|
3383
|
+
*
|
|
3384
|
+
* Wire format:
|
|
3385
|
+
* - Objects → `{ k: <patch> }` for changed keys, `_d: [keys]` for deletions.
|
|
3386
|
+
* - **Entity arrays** (every element a plain object with a stable `id`) →
|
|
3387
|
+
* keyed diff `{ _ka:{id:patch}, _add:[entities], _del:[ids], _ord:[ids] }`.
|
|
3388
|
+
* This is O(changes), not O(index shift): inserting/removing an element no
|
|
3389
|
+
* longer rewrites every following index. (Gaffer-on-Games "state sync".)
|
|
3390
|
+
* - Other arrays → index diff `{ _a:{i:patch}, _n:length }`.
|
|
3391
|
+
* - Primitives / type changes → `{ _s: value }`.
|
|
3392
|
+
* - No change → `undefined`.
|
|
3393
|
+
*
|
|
3394
|
+
* `quantize` rounds numeric fields to a fixed resolution before diffing, so
|
|
3395
|
+
* sub-resolution jitter doesn't produce spurious deltas and payloads shrink
|
|
3396
|
+
* (the single biggest, simplest snapshot-compression win — Gaffer on Games).
|
|
3397
|
+
*/
|
|
3398
|
+
|
|
3399
|
+
const SET = '_s'; // wholesale set
|
|
3400
|
+
const DEL = '_d'; // deleted object keys
|
|
3401
|
+
const ARR$1 = '_a'; // index array patches
|
|
3402
|
+
const LEN = '_n'; // index array length
|
|
3403
|
+
const KA = '_ka'; // keyed-array: per-id patches
|
|
3404
|
+
const ADD = '_add'; // keyed-array: added entities (full)
|
|
3405
|
+
const KDEL = '_del';// keyed-array: removed ids
|
|
3406
|
+
const ORD = '_ord'; // keyed-array: explicit id order (only when it changes)
|
|
3407
|
+
|
|
3408
|
+
function isPlainObject(v) {
|
|
3409
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function isEntityArray(v) {
|
|
3413
|
+
if (!Array.isArray(v) || v.length === 0) return false;
|
|
3414
|
+
for (let i = 0; i < v.length; i++) {
|
|
3415
|
+
const e = v[i];
|
|
3416
|
+
if (!isPlainObject(e) || (typeof e.id !== 'string' && typeof e.id !== 'number')) return false;
|
|
3417
|
+
}
|
|
3418
|
+
return true;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function sameOrder(a, b) {
|
|
3422
|
+
if (a.length !== b.length) return false;
|
|
3423
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
3424
|
+
return true;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
/**
|
|
3428
|
+
* Compute a minimal patch turning `prev` into `next`.
|
|
3429
|
+
* @returns the patch, or `undefined` when nothing changed.
|
|
3430
|
+
*/
|
|
3431
|
+
function diff(prev, next) {
|
|
3432
|
+
if (prev === next) return undefined;
|
|
3433
|
+
|
|
3434
|
+
const prevArr = Array.isArray(prev);
|
|
3435
|
+
const nextArr = Array.isArray(next);
|
|
3436
|
+
|
|
3437
|
+
// Type changed (or one side primitive) — send the whole value.
|
|
3438
|
+
if (prevArr !== nextArr || isPlainObject(prev) !== isPlainObject(next)) {
|
|
3439
|
+
return { [SET]: next };
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
if (nextArr) {
|
|
3443
|
+
// Keyed (entity) arrays: diff by id so index shifts are free.
|
|
3444
|
+
if (isEntityArray(prev) && isEntityArray(next)) return diffKeyedArray(prev, next);
|
|
3445
|
+
return diffIndexArray(prev, next);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
if (isPlainObject(next)) return diffObject(prev, next);
|
|
3449
|
+
|
|
3450
|
+
// Two differing primitives.
|
|
3451
|
+
return { [SET]: next };
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
function diffObject(prev, next) {
|
|
3455
|
+
const out = {};
|
|
3456
|
+
let changed = false;
|
|
3457
|
+
for (const k in next) {
|
|
3458
|
+
if (!Object.prototype.hasOwnProperty.call(next, k)) continue;
|
|
3459
|
+
const d = diff(prev[k], next[k]);
|
|
3460
|
+
if (d !== undefined) { out[k] = d; changed = true; }
|
|
3461
|
+
}
|
|
3462
|
+
const deleted = [];
|
|
3463
|
+
for (const k in prev) {
|
|
3464
|
+
if (!Object.prototype.hasOwnProperty.call(prev, k)) continue;
|
|
3465
|
+
if (!Object.prototype.hasOwnProperty.call(next, k)) deleted.push(k);
|
|
3466
|
+
}
|
|
3467
|
+
if (deleted.length) { out[DEL] = deleted; changed = true; }
|
|
3468
|
+
return changed ? out : undefined;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
function diffIndexArray(prev, next) {
|
|
3472
|
+
const out = { [ARR$1]: {} };
|
|
3473
|
+
let changed = false;
|
|
3474
|
+
for (let i = 0; i < next.length; i++) {
|
|
3475
|
+
const d = i < prev.length ? diff(prev[i], next[i]) : { [SET]: next[i] };
|
|
3476
|
+
if (d !== undefined) { out[ARR$1][i] = d; changed = true; }
|
|
3477
|
+
}
|
|
3478
|
+
if (next.length !== prev.length) { out[LEN] = next.length; changed = true; }
|
|
3479
|
+
return changed ? out : undefined;
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
function diffKeyedArray(prev, next) {
|
|
3483
|
+
const prevById = {};
|
|
3484
|
+
const prevIds = [];
|
|
3485
|
+
for (let i = 0; i < prev.length; i++) { prevById[prev[i].id] = prev[i]; prevIds.push(prev[i].id); }
|
|
3486
|
+
const nextIds = [];
|
|
3487
|
+
const out = {};
|
|
3488
|
+
let changed = false;
|
|
3489
|
+
|
|
3490
|
+
for (let i = 0; i < next.length; i++) {
|
|
3491
|
+
const e = next[i];
|
|
3492
|
+
nextIds.push(e.id);
|
|
3493
|
+
if (Object.prototype.hasOwnProperty.call(prevById, e.id)) {
|
|
3494
|
+
const d = diff(prevById[e.id], e);
|
|
3495
|
+
if (d !== undefined) {
|
|
3496
|
+
if (!out[KA]) out[KA] = {};
|
|
3497
|
+
out[KA][e.id] = d;
|
|
3498
|
+
changed = true;
|
|
3499
|
+
}
|
|
3500
|
+
} else {
|
|
3501
|
+
if (!out[ADD]) out[ADD] = [];
|
|
3502
|
+
out[ADD].push(e);
|
|
3503
|
+
changed = true;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
const nextIdSet = {};
|
|
3508
|
+
for (let i = 0; i < nextIds.length; i++) nextIdSet[nextIds[i]] = true;
|
|
3509
|
+
for (let i = 0; i < prevIds.length; i++) {
|
|
3510
|
+
if (!nextIdSet[prevIds[i]]) {
|
|
3511
|
+
if (!out[KDEL]) out[KDEL] = [];
|
|
3512
|
+
out[KDEL].push(prevIds[i]);
|
|
3513
|
+
changed = true;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
if (!sameOrder(prevIds, nextIds)) { out[ORD] = nextIds; changed = true; }
|
|
3518
|
+
return changed ? out : undefined;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/**
|
|
3522
|
+
* Apply a patch produced by `diff` to `base`, returning the new value.
|
|
3523
|
+
* Does not mutate `base`. A nullish patch returns `base` unchanged.
|
|
3524
|
+
*/
|
|
3525
|
+
function patch(base, p) {
|
|
3526
|
+
if (p === undefined || p === null) return base;
|
|
3527
|
+
if (Object.prototype.hasOwnProperty.call(p, SET)) return p[SET];
|
|
3528
|
+
|
|
3529
|
+
// Keyed (entity) array.
|
|
3530
|
+
if (p[KA] || p[ADD] || p[KDEL] || p[ORD]) return patchKeyedArray(base, p);
|
|
3531
|
+
|
|
3532
|
+
// Index array.
|
|
3533
|
+
if (Object.prototype.hasOwnProperty.call(p, ARR$1) || Object.prototype.hasOwnProperty.call(p, LEN)) {
|
|
3534
|
+
const src = Array.isArray(base) ? base : [];
|
|
3535
|
+
const len = Object.prototype.hasOwnProperty.call(p, LEN) ? p[LEN] : src.length;
|
|
3536
|
+
const out = src.slice(0, len);
|
|
3537
|
+
const patches = p[ARR$1] || {};
|
|
3538
|
+
for (const i in patches) {
|
|
3539
|
+
if (!Object.prototype.hasOwnProperty.call(patches, i)) continue;
|
|
3540
|
+
out[i] = patch(out[i], patches[i]);
|
|
3541
|
+
}
|
|
3542
|
+
return out;
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
// Object patch.
|
|
3546
|
+
const out = isPlainObject(base) ? Object.assign({}, base) : {};
|
|
3547
|
+
const deleted = p[DEL];
|
|
3548
|
+
for (const k in p) {
|
|
3549
|
+
if (k === DEL) continue;
|
|
3550
|
+
if (!Object.prototype.hasOwnProperty.call(p, k)) continue;
|
|
3551
|
+
out[k] = patch(out[k], p[k]);
|
|
3552
|
+
}
|
|
3553
|
+
if (Array.isArray(deleted)) for (let i = 0; i < deleted.length; i++) delete out[deleted[i]];
|
|
3554
|
+
return out;
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
function patchKeyedArray(base, p) {
|
|
3558
|
+
const baseArr = Array.isArray(base) ? base : [];
|
|
3559
|
+
const byId = {};
|
|
3560
|
+
const baseOrder = [];
|
|
3561
|
+
for (let i = 0; i < baseArr.length; i++) {
|
|
3562
|
+
const e = baseArr[i];
|
|
3563
|
+
if (e && (typeof e.id === 'string' || typeof e.id === 'number')) { byId[e.id] = e; baseOrder.push(e.id); }
|
|
3564
|
+
}
|
|
3565
|
+
// Apply per-id field patches.
|
|
3566
|
+
if (p[KA]) for (const id in p[KA]) {
|
|
3567
|
+
if (Object.prototype.hasOwnProperty.call(p[KA], id) && byId[id] !== undefined) byId[id] = patch(byId[id], p[KA][id]);
|
|
3568
|
+
}
|
|
3569
|
+
// Additions.
|
|
3570
|
+
if (p[ADD]) for (let i = 0; i < p[ADD].length; i++) { const e = p[ADD][i]; byId[e.id] = e; }
|
|
3571
|
+
// Removals.
|
|
3572
|
+
const removed = {};
|
|
3573
|
+
if (p[KDEL]) for (let i = 0; i < p[KDEL].length; i++) removed[p[KDEL][i]] = true;
|
|
3574
|
+
|
|
3575
|
+
if (p[ORD]) {
|
|
3576
|
+
const out = [];
|
|
3577
|
+
for (let i = 0; i < p[ORD].length; i++) {
|
|
3578
|
+
const id = p[ORD][i];
|
|
3579
|
+
if (byId[id] !== undefined) out.push(byId[id]);
|
|
3580
|
+
}
|
|
3581
|
+
return out;
|
|
3582
|
+
}
|
|
3583
|
+
// No order change: keep base order minus removals, patched in place.
|
|
3584
|
+
const out = [];
|
|
3585
|
+
for (let i = 0; i < baseOrder.length; i++) {
|
|
3586
|
+
const id = baseOrder[i];
|
|
3587
|
+
if (!removed[id]) out.push(byId[id]);
|
|
3588
|
+
}
|
|
3589
|
+
return out;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/**
|
|
3593
|
+
* Round every numeric field to `precision` decimal places (default 2),
|
|
3594
|
+
* returning a new value. Apply before diffing so sub-resolution jitter and
|
|
3595
|
+
* float noise don't generate spurious deltas, and payloads stay small.
|
|
3596
|
+
*/
|
|
3597
|
+
function quantize(value, precision = 2) {
|
|
3598
|
+
const f = Math.pow(10, precision);
|
|
3599
|
+
function q(v) {
|
|
3600
|
+
if (typeof v === 'number') return Number.isFinite(v) ? Math.round(v * f) / f : v;
|
|
3601
|
+
if (Array.isArray(v)) return v.map(q);
|
|
3602
|
+
if (isPlainObject(v)) {
|
|
3603
|
+
const o = {};
|
|
3604
|
+
for (const k in v) if (Object.prototype.hasOwnProperty.call(v, k)) o[k] = q(v[k]);
|
|
3605
|
+
return o;
|
|
3606
|
+
}
|
|
3607
|
+
return v;
|
|
3608
|
+
}
|
|
3609
|
+
return q(value);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
/**
|
|
3613
|
+
* Usion SDK Netcode — compact binary codec (zero-dependency).
|
|
3614
|
+
*
|
|
3615
|
+
* A tiny MessagePack-style serializer for JSON-like snapshot payloads. Pair it
|
|
3616
|
+
* with the snapshot sender/receiver (`encode`/`decode` options) to send game
|
|
3617
|
+
* state as binary over the WebRTC data channel or a binary-capable WebSocket —
|
|
3618
|
+
* meaningfully smaller than JSON, especially after quantization (small ints
|
|
3619
|
+
* pack into 1–2 bytes). WebRTC/Socket.IO transmit ArrayBuffers natively.
|
|
3620
|
+
*
|
|
3621
|
+
* NOT for the iframe/WebView proxy path (that bridge relays JSON) — use binary
|
|
3622
|
+
* on direct/mesh transports.
|
|
3623
|
+
*
|
|
3624
|
+
* Wire tags: 0xc0 null · 0xc2 false · 0xc3 true · 0xc4 int(zigzag varint) ·
|
|
3625
|
+
* 0xc5 float64 · 0xc6 string · 0xc7 array · 0xc8 object
|
|
3626
|
+
*/
|
|
3627
|
+
const NULL = 0xc0, FALSE = 0xc2, TRUE = 0xc3, INT = 0xc4, F64 = 0xc5, STR = 0xc6, ARR = 0xc7, OBJ = 0xc8;
|
|
3628
|
+
|
|
3629
|
+
const _enc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
3630
|
+
const _dec = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
|
3631
|
+
|
|
3632
|
+
function pushVarint(out, n) {
|
|
3633
|
+
// unsigned LEB128
|
|
3634
|
+
n = n >>> 0;
|
|
3635
|
+
while (n > 0x7f) { out.push((n & 0x7f) | 0x80); n >>>= 7; }
|
|
3636
|
+
out.push(n);
|
|
3637
|
+
}
|
|
3638
|
+
function zigzag(n) { return ((n << 1) ^ (n >> 31)) >>> 0; }
|
|
3639
|
+
function unzigzag(u) { return (u >>> 1) ^ -(u & 1); }
|
|
3640
|
+
|
|
3641
|
+
function encodeValue(out, v) {
|
|
3642
|
+
if (v === null || v === undefined) { out.push(NULL); return; }
|
|
3643
|
+
const t = typeof v;
|
|
3644
|
+
if (t === 'boolean') { out.push(v ? TRUE : FALSE); return; }
|
|
3645
|
+
if (t === 'number') {
|
|
3646
|
+
if (Number.isInteger(v) && v >= -2147483648 && v <= 2147483647) {
|
|
3647
|
+
out.push(INT); pushVarint(out, zigzag(v));
|
|
3648
|
+
} else {
|
|
3649
|
+
out.push(F64);
|
|
3650
|
+
const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v, true);
|
|
3651
|
+
for (let i = 0; i < 8; i++) out.push(b[i]);
|
|
3652
|
+
}
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
if (t === 'string') {
|
|
3656
|
+
out.push(STR);
|
|
3657
|
+
const bytes = _enc ? _enc.encode(v) : Buffer.from(v, 'utf8');
|
|
3658
|
+
pushVarint(out, bytes.length);
|
|
3659
|
+
for (let i = 0; i < bytes.length; i++) out.push(bytes[i]);
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
if (Array.isArray(v)) {
|
|
3663
|
+
out.push(ARR); pushVarint(out, v.length);
|
|
3664
|
+
for (let i = 0; i < v.length; i++) encodeValue(out, v[i]);
|
|
3665
|
+
return;
|
|
3666
|
+
}
|
|
3667
|
+
if (t === 'object') {
|
|
3668
|
+
const keys = Object.keys(v);
|
|
3669
|
+
out.push(OBJ); pushVarint(out, keys.length);
|
|
3670
|
+
for (let i = 0; i < keys.length; i++) {
|
|
3671
|
+
const k = keys[i];
|
|
3672
|
+
const kb = _enc ? _enc.encode(k) : Buffer.from(k, 'utf8');
|
|
3673
|
+
pushVarint(out, kb.length);
|
|
3674
|
+
for (let j = 0; j < kb.length; j++) out.push(kb[j]);
|
|
3675
|
+
encodeValue(out, v[k]);
|
|
3676
|
+
}
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3679
|
+
out.push(NULL);
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
/** Encode a JSON-like value to a Uint8Array. */
|
|
3683
|
+
function encode(value) {
|
|
3684
|
+
const out = [];
|
|
3685
|
+
encodeValue(out, value);
|
|
3686
|
+
return Uint8Array.from(out);
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
function reader(bytes) {
|
|
3690
|
+
let off = 0;
|
|
3691
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3692
|
+
function varint() {
|
|
3693
|
+
let shift = 0, result = 0, b;
|
|
3694
|
+
do { b = bytes[off++]; result |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80);
|
|
3695
|
+
return result >>> 0;
|
|
3696
|
+
}
|
|
3697
|
+
function str() {
|
|
3698
|
+
const len = varint();
|
|
3699
|
+
const slice = bytes.subarray(off, off + len);
|
|
3700
|
+
off += len;
|
|
3701
|
+
return _dec ? _dec.decode(slice) : Buffer.from(slice).toString('utf8');
|
|
3702
|
+
}
|
|
3703
|
+
function value() {
|
|
3704
|
+
const tag = bytes[off++];
|
|
3705
|
+
switch (tag) {
|
|
3706
|
+
case NULL: return null;
|
|
3707
|
+
case TRUE: return true;
|
|
3708
|
+
case FALSE: return false;
|
|
3709
|
+
case INT: return unzigzag(varint());
|
|
3710
|
+
case F64: { const v = dv.getFloat64(off, true); off += 8; return v; }
|
|
3711
|
+
case STR: return str();
|
|
3712
|
+
case ARR: { const n = varint(); const a = new Array(n); for (let i = 0; i < n; i++) a[i] = value(); return a; }
|
|
3713
|
+
case OBJ: { const n = varint(); const o = {}; for (let i = 0; i < n; i++) { const k = str(); o[k] = value(); } return o; }
|
|
3714
|
+
default: return null;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
return value;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
/** Decode a Uint8Array / ArrayBuffer produced by `encode`. */
|
|
3721
|
+
function decode(buf) {
|
|
3722
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
3723
|
+
return reader(bytes)();
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
/**
|
|
3727
|
+
* Usion SDK Game Netcode — convenience wiring of the netcode toolkit onto the
|
|
3728
|
+
* game module. These helpers ride on the existing realtime/action channels, so
|
|
3729
|
+
* they work in every connection mode with no host/backend change (the one
|
|
3730
|
+
* exception is RTT measurement — see game.ping below).
|
|
3731
|
+
*/
|
|
3732
|
+
|
|
3733
|
+
function applyGameNetcode(game, Usion) {
|
|
3734
|
+
game.diff = diff;
|
|
3735
|
+
game.patch = patch;
|
|
3736
|
+
game.quantize = quantize;
|
|
3737
|
+
game.encode = encode;
|
|
3738
|
+
game.decode = decode;
|
|
3739
|
+
|
|
3740
|
+
game.createInterpolation = function (opts) { return new SnapshotInterpolation(opts || {}); };
|
|
3741
|
+
game.createPredictor = function (opts) { return new Predictor(opts || {}); };
|
|
3742
|
+
game.createLagCompensator = function (opts) { return new LagCompensator(opts || {}); };
|
|
3743
|
+
game.createLockstep = function (opts) { return new Lockstep(opts || {}); };
|
|
3744
|
+
|
|
3745
|
+
// ── Realtime channel router ──────────────────────────────────────────────
|
|
3746
|
+
// Multiplexes realtime by action_type so replicate / mesh / mesh-network and
|
|
3747
|
+
// the game's own onRealtime can coexist. Also the inject point for the
|
|
3748
|
+
// network simulator's inbound side.
|
|
3749
|
+
game._channels = game._channels || {};
|
|
3750
|
+
game._userRealtime = game._userRealtime || null;
|
|
3751
|
+
game._inboundDispatch = null;
|
|
3752
|
+
function dispatchRealtime(data) {
|
|
3753
|
+
const at = data && data.action_type;
|
|
3754
|
+
if (at && game._channels[at]) { game._channels[at](data && (data.action_data !== undefined ? data.action_data : data), data); return; }
|
|
3755
|
+
if (game._userRealtime) game._userRealtime(data);
|
|
3756
|
+
}
|
|
3757
|
+
game._dispatchRealtime = dispatchRealtime;
|
|
3758
|
+
game._installRealtimeRouter = function () {
|
|
3759
|
+
if (game._routerInstalled) return;
|
|
3760
|
+
if (game._eventHandlers.realtime && game._eventHandlers.realtime !== game._router) game._userRealtime = game._eventHandlers.realtime;
|
|
3761
|
+
game._router = function (data) { (game._inboundDispatch || game._dispatchRealtime)(data); };
|
|
3762
|
+
game._eventHandlers.realtime = game._router;
|
|
3763
|
+
game._routerInstalled = true;
|
|
3764
|
+
};
|
|
3765
|
+
/** Register a handler for one realtime action_type. Returns an unsubscribe fn. */
|
|
3766
|
+
game._onChannel = function (type, handler) { game._channels[type] = handler; game._installRealtimeRouter(); return function () { delete game._channels[type]; }; };
|
|
3767
|
+
// Route onRealtime through the router so channels keep working.
|
|
3768
|
+
game.onRealtime = function (cb) { game._userRealtime = cb; game._installRealtimeRouter(); };
|
|
3769
|
+
|
|
3770
|
+
/** Fixed-rate outbound sender (defaults to game.realtime). */
|
|
3771
|
+
game.createSender = function (opts) {
|
|
3772
|
+
opts = opts || {};
|
|
3773
|
+
const self = this;
|
|
3774
|
+
const send = opts.send || function (type, data) { self.realtime(type, data); };
|
|
3775
|
+
return new Coalescer({
|
|
3776
|
+
hz: opts.hz || 20,
|
|
3777
|
+
autoStart: opts.autoStart !== false,
|
|
3778
|
+
onFlush: function (entries) { for (let i = 0; i < entries.length; i++) send(entries[i].type, entries[i].data); },
|
|
3779
|
+
});
|
|
3780
|
+
};
|
|
3781
|
+
|
|
3782
|
+
/**
|
|
3783
|
+
* Coalesced, sequence-guarded, delta-compressed snapshot sender.
|
|
3784
|
+
*
|
|
3785
|
+
* Deltas are computed against the **last keyframe** (not the previous frame),
|
|
3786
|
+
* so a single lost delta on an unreliable channel never desyncs — the next
|
|
3787
|
+
* delta still applies to the keyframe the receiver holds. Each message
|
|
3788
|
+
* carries a monotonic seq (`s`) and, for deltas, the keyframe seq it's based
|
|
3789
|
+
* on (`b`); the receiver uses these to drop stale/out-of-order frames and to
|
|
3790
|
+
* hold until it has the right keyframe. Optional float `precision`
|
|
3791
|
+
* (quantization) and binary `encode` shrink the wire further.
|
|
3792
|
+
*
|
|
3793
|
+
* Pass `source` (a getter) to auto-read the state each tick — the basis for
|
|
3794
|
+
* game.replicate(). Pair with game.createSnapshotReceiver().
|
|
3795
|
+
*/
|
|
3796
|
+
game.createSnapshotSender = function (opts) {
|
|
3797
|
+
opts = opts || {};
|
|
3798
|
+
const self = this;
|
|
3799
|
+
const channel = opts.channel || 'state';
|
|
3800
|
+
const useDelta = opts.delta !== false;
|
|
3801
|
+
const keyframeEvery = opts.keyframeEvery != null ? opts.keyframeEvery : 30;
|
|
3802
|
+
const precision = opts.precision;
|
|
3803
|
+
const enc = opts.encode === true ? encode : (typeof opts.encode === 'function' ? opts.encode : null);
|
|
3804
|
+
const send = opts.send || function (type, data) { self.realtime(type, data); };
|
|
3805
|
+
const source = typeof opts.source === 'function' ? opts.source : null;
|
|
3806
|
+
let keyframeState = null; // an immutable clone (deltas diff against this)
|
|
3807
|
+
let keyframeSeq = 0;
|
|
3808
|
+
let seq = 0;
|
|
3809
|
+
let sinceKey = 0;
|
|
3810
|
+
|
|
3811
|
+
function snapshotClone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); }
|
|
3812
|
+
|
|
3813
|
+
function emit(raw) {
|
|
3814
|
+
if (raw === undefined || raw === null) return;
|
|
3815
|
+
const state = precision != null ? quantize(raw, precision) : raw;
|
|
3816
|
+
let payload;
|
|
3817
|
+
const wantKey = !useDelta || keyframeState === null || sinceKey >= keyframeEvery;
|
|
3818
|
+
if (wantKey) {
|
|
3819
|
+
seq += 1; keyframeState = snapshotClone(state); keyframeSeq = seq; sinceKey = 0;
|
|
3820
|
+
payload = { s: seq, f: state };
|
|
3821
|
+
} else {
|
|
3822
|
+
const d = diff(keyframeState, state);
|
|
3823
|
+
if (d === undefined) return; // nothing changed — don't burn a seq
|
|
3824
|
+
seq += 1; sinceKey += 1;
|
|
3825
|
+
payload = { s: seq, b: keyframeSeq, d: d };
|
|
3826
|
+
}
|
|
3827
|
+
send(channel, enc ? enc(payload) : payload);
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
// Two drive modes: a `source` getter (read every tick) or queued send().
|
|
3831
|
+
const hz = opts.hz || 20;
|
|
3832
|
+
let queued;
|
|
3833
|
+
const co = source ? null : new Coalescer({
|
|
3834
|
+
hz: hz, autoStart: opts.autoStart !== false,
|
|
3835
|
+
onFlush: function (entries) { for (let i = 0; i < entries.length; i++) if (entries[i].type === '__snap') queued = entries[i].data; emit(queued); },
|
|
3836
|
+
});
|
|
3837
|
+
let timer = null;
|
|
3838
|
+
function start() {
|
|
3839
|
+
if (co) { co.start(); return; }
|
|
3840
|
+
if (timer || typeof setInterval === 'undefined') return;
|
|
3841
|
+
timer = setInterval(function () { emit(source()); }, Math.max(1, Math.round(1000 / hz)));
|
|
3842
|
+
}
|
|
3843
|
+
function stop() { if (co) co.stop(); if (timer) { clearInterval(timer); timer = null; } }
|
|
3844
|
+
if (source && opts.autoStart !== false) start();
|
|
3845
|
+
|
|
3846
|
+
return {
|
|
3847
|
+
send: function (state) { if (co) co.queue('__snap', state); else emit(state); },
|
|
3848
|
+
flush: function () { if (co) co.flush(); else if (source) emit(source()); },
|
|
3849
|
+
start: start,
|
|
3850
|
+
stop: stop,
|
|
3851
|
+
reset: function () { keyframeState = null; keyframeSeq = 0; seq = 0; sinceKey = 0; },
|
|
3852
|
+
};
|
|
3853
|
+
};
|
|
3854
|
+
|
|
3855
|
+
/**
|
|
3856
|
+
* Receiver for createSnapshotSender. Holds the keyframe baseline + last
|
|
3857
|
+
* applied seq, so it safely drops stale/out-of-order frames and ignores
|
|
3858
|
+
* deltas whose keyframe it missed (until the next keyframe arrives).
|
|
3859
|
+
* @returns {{ receive:(msg:any)=>any, state:any, stats:object, reset:()=>void }}
|
|
3860
|
+
*/
|
|
3861
|
+
game.createSnapshotReceiver = function (opts) {
|
|
3862
|
+
opts = opts || {};
|
|
3863
|
+
const dec = opts.decode === true ? decode : (typeof opts.decode === 'function' ? opts.decode : null);
|
|
3864
|
+
let base = null, baseSeq = -1, current = null, appliedSeq = -1, dropped = 0;
|
|
3865
|
+
return {
|
|
3866
|
+
receive: function (msg) {
|
|
3867
|
+
if (dec && (msg instanceof ArrayBuffer || ArrayBuffer.isView(msg))) msg = dec(msg);
|
|
3868
|
+
if (!msg) return current;
|
|
3869
|
+
if (msg.f !== undefined) {
|
|
3870
|
+
if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; }
|
|
3871
|
+
base = msg.f; baseSeq = msg.s !== undefined ? msg.s : 0; current = msg.f; appliedSeq = baseSeq;
|
|
3872
|
+
return current;
|
|
3873
|
+
}
|
|
3874
|
+
if (msg.d !== undefined) {
|
|
3875
|
+
if (msg.b !== undefined && msg.b !== baseSeq) { dropped += 1; return current; } // missed keyframe
|
|
3876
|
+
if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; } // stale/dup
|
|
3877
|
+
current = patch(base, msg.d);
|
|
3878
|
+
if (msg.s !== undefined) appliedSeq = msg.s;
|
|
3879
|
+
return current;
|
|
3880
|
+
}
|
|
3881
|
+
return current;
|
|
3882
|
+
},
|
|
3883
|
+
get state() { return current; },
|
|
3884
|
+
get stats() { return { appliedSeq: appliedSeq, baseSeq: baseSeq, dropped: dropped }; },
|
|
3885
|
+
reset: function () { base = null; baseSeq = -1; current = null; appliedSeq = -1; dropped = 0; },
|
|
3886
|
+
};
|
|
3887
|
+
};
|
|
3888
|
+
|
|
3889
|
+
/**
|
|
3890
|
+
* Declarative state replication (host side). Mutate the plain object you pass
|
|
3891
|
+
* in; the SDK auto-diffs and broadcasts it every tick (sequence-guarded,
|
|
3892
|
+
* delta-compressed, optional quantize/binary). Clients read it with
|
|
3893
|
+
* game.replica(). This is the ~40-lines-into-2 ergonomic.
|
|
3894
|
+
* @returns {{ state:any, flush:()=>void, start:()=>void, stop:()=>void }}
|
|
3895
|
+
*/
|
|
3896
|
+
game.replicate = function (obj, opts) {
|
|
3897
|
+
opts = opts || {};
|
|
3898
|
+
let state = obj != null ? obj : {};
|
|
3899
|
+
const sender = this.createSnapshotSender(Object.assign({}, opts, { source: function () { return state; } }));
|
|
3900
|
+
return {
|
|
3901
|
+
get state() { return state; },
|
|
3902
|
+
set state(v) { state = v; },
|
|
3903
|
+
flush: function () { sender.flush(); },
|
|
3904
|
+
start: function () { sender.start(); },
|
|
3905
|
+
stop: function () { sender.stop(); },
|
|
3906
|
+
};
|
|
3907
|
+
};
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* Declarative state replication (client side). Receives what a host
|
|
3911
|
+
* replicate()s on the same channel, drops stale/out-of-order frames, and
|
|
3912
|
+
* (optionally) interpolates. Read `.state` for the latest authoritative state,
|
|
3913
|
+
* or `.view()` for smoothed entities when `interpolate` keys are given.
|
|
3914
|
+
* @returns {{ state:any, view:()=>any, onChange:(cb)=>void, stop:()=>void }}
|
|
3915
|
+
*/
|
|
3916
|
+
game.replica = function (opts) {
|
|
3917
|
+
opts = opts || {};
|
|
3918
|
+
const channel = opts.channel || 'state';
|
|
3919
|
+
const rx = this.createSnapshotReceiver({ decode: opts.decode });
|
|
3920
|
+
const interp = opts.interpolate ? this.createInterpolation(typeof opts.interpolate === 'object' ? opts.interpolate : {}) : null;
|
|
3921
|
+
const keys = typeof opts.interpolate === 'string' ? opts.interpolate : (opts.keys || (opts.interpolate && opts.interpolate.keys));
|
|
3922
|
+
const group = opts.group || (opts.interpolate && opts.interpolate.group);
|
|
3923
|
+
let onChange = null;
|
|
3924
|
+
const off = this._onChannel(channel, function (data) {
|
|
3925
|
+
const next = rx.receive(data);
|
|
3926
|
+
if (interp && next != null) interp.add(next);
|
|
3927
|
+
if (onChange) onChange(next);
|
|
3928
|
+
});
|
|
3929
|
+
return {
|
|
3930
|
+
get state() { return rx.state; },
|
|
3931
|
+
view: function () { return interp ? interp.calc(keys || 'x y', group) : rx.state; },
|
|
3932
|
+
onChange: function (cb) { onChange = cb; },
|
|
3933
|
+
stop: function () { off(); },
|
|
3934
|
+
};
|
|
3935
|
+
};
|
|
3936
|
+
|
|
3937
|
+
/**
|
|
3938
|
+
* One-line WebRTC peer-to-peer setup (2 peers). Signaling rides the realtime
|
|
3939
|
+
* channel via the router; gameplay then flows directly peer-to-peer over UDP.
|
|
3940
|
+
*/
|
|
3941
|
+
game.createMesh = function (opts) {
|
|
3942
|
+
opts = opts || {};
|
|
3943
|
+
const self = this;
|
|
3944
|
+
const channel = opts.signalChannel || 'signal';
|
|
3945
|
+
const mesh = new MeshConnection(Object.assign({}, opts, {
|
|
3946
|
+
sendSignal: function (payload) { self.realtime(channel, payload); },
|
|
3947
|
+
}));
|
|
3948
|
+
this._onChannel(channel, function (payload) { if (payload && payload.type) mesh.handleSignal(payload); });
|
|
3949
|
+
return mesh;
|
|
3950
|
+
};
|
|
3951
|
+
|
|
3952
|
+
/**
|
|
3953
|
+
* N-peer full mesh. Signaling routed per-peer over the realtime channel (each
|
|
3954
|
+
* message carries {to, from}); messages addressed to us are dispatched to the
|
|
3955
|
+
* right peer connection. selfId defaults to the current user id.
|
|
3956
|
+
* @returns {MeshNetwork}
|
|
3957
|
+
*/
|
|
3958
|
+
game.createMeshNetwork = function (opts) {
|
|
3959
|
+
opts = opts || {};
|
|
3960
|
+
const self = this;
|
|
3961
|
+
const channel = opts.signalChannel || 'mesh';
|
|
3962
|
+
const selfId = opts.selfId || (Usion.user && Usion.user.getId && Usion.user.getId());
|
|
3963
|
+
const net = new MeshNetwork(Object.assign({}, opts, {
|
|
3964
|
+
selfId: selfId,
|
|
3965
|
+
sendSignal: function (toPeerId, payload) { self.realtime(channel, { to: toPeerId, from: selfId, payload: payload }); },
|
|
3966
|
+
}));
|
|
3967
|
+
this._onChannel(channel, function (ad) { if (ad && ad.to === selfId && ad.from) net.handleSignal(ad.from, ad.payload); });
|
|
3968
|
+
return net;
|
|
3969
|
+
};
|
|
3970
|
+
|
|
3971
|
+
/**
|
|
3972
|
+
* Inject artificial latency / jitter / packet loss locally to test how the
|
|
3973
|
+
* game feels on a bad network (every studio has this). Affects both outbound
|
|
3974
|
+
* realtime sends and inbound realtime dispatch. Call with null/false to turn
|
|
3975
|
+
* it off. No-op safety: realtime must exist on the game module.
|
|
3976
|
+
* @param {{latencyMs?:number,jitterMs?:number,lossPct?:number,dupPct?:number}|null} opts
|
|
3977
|
+
*/
|
|
3978
|
+
game.simulateNetwork = function (opts) {
|
|
3979
|
+
const self = this;
|
|
3980
|
+
self._installRealtimeRouter();
|
|
3981
|
+
if (!self._realtimeRaw && typeof self.realtime === 'function') self._realtimeRaw = self.realtime.bind(self);
|
|
3982
|
+
if (!opts) {
|
|
3983
|
+
if (self._realtimeRaw) self.realtime = self._realtimeRaw;
|
|
3984
|
+
self._inboundDispatch = null;
|
|
3985
|
+
if (self._sim) self._sim.flush();
|
|
3986
|
+
self._sim = null;
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3989
|
+
if (!self._sim) self._sim = new NetworkSim(opts);
|
|
3990
|
+
else self._sim.set(opts);
|
|
3991
|
+
if (self._realtimeRaw) self.realtime = self._sim.wrap(self._realtimeRaw);
|
|
3992
|
+
self._inboundDispatch = self._sim.wrap(self._dispatchRealtime);
|
|
3993
|
+
return self._sim;
|
|
3994
|
+
};
|
|
3995
|
+
|
|
3996
|
+
/**
|
|
3997
|
+
* Create a WebTransport (HTTP/3) connection — the lowest-latency client-server
|
|
3998
|
+
* path (UDP-like datagrams, no TCP head-of-line blocking). `url` defaults to
|
|
3999
|
+
* Usion.config.webTransportUrl. Call `connect()`, then `send()` (datagram) /
|
|
4000
|
+
* `sendReliable()`; feed `onMessage` into a snapshot receiver. Direct/standalone
|
|
4001
|
+
* games only (the iframe/WebView proxy relay can't carry datagrams).
|
|
4002
|
+
* @returns {WebTransportConnection}
|
|
4003
|
+
*/
|
|
4004
|
+
game.createWebTransport = function (opts) {
|
|
4005
|
+
opts = opts || {};
|
|
4006
|
+
const url = opts.url || (Usion.config && (Usion.config.webTransportUrl || Usion.config.wtUrl));
|
|
4007
|
+
return new WebTransportConnection(Object.assign({}, opts, { url: url }));
|
|
4008
|
+
};
|
|
4009
|
+
|
|
4010
|
+
/**
|
|
4011
|
+
* Measure round-trip time once (single outstanding probe), updating the
|
|
4012
|
+
* rolling estimate (game.getRtt). Direct mode uses the protocol ping/pong;
|
|
4013
|
+
* platform mode uses a lightweight server ack. Resolves to ms, or null if
|
|
4014
|
+
* unavailable (proxy mode, or not connected).
|
|
4015
|
+
* @returns {Promise<number|null>}
|
|
4016
|
+
*/
|
|
4017
|
+
game.ping = function () {
|
|
4018
|
+
const self = this;
|
|
4019
|
+
if (!self._pingMeter) self._pingMeter = new PingMeter();
|
|
4020
|
+
if (self._pingInFlight) return self._pingInFlight; // single outstanding probe
|
|
4021
|
+
|
|
4022
|
+
let promise;
|
|
4023
|
+
if (self.directMode) {
|
|
4024
|
+
if (!self.directSocket || self.directSocket.readyState !== 1) return Promise.resolve(null);
|
|
4025
|
+
promise = new Promise(function (resolve) {
|
|
4026
|
+
const id = self._pingMeter.begin();
|
|
4027
|
+
let done = false;
|
|
4028
|
+
const finish = function () { if (done) return; done = true; resolve(self._pingMeter.end(id)); };
|
|
4029
|
+
self._pongWaiters.push(finish);
|
|
4030
|
+
self._sendDirect('ping', { t: Date.now() });
|
|
4031
|
+
setTimeout(function () {
|
|
4032
|
+
if (done) return; done = true;
|
|
4033
|
+
const idx = self._pongWaiters.indexOf(finish);
|
|
4034
|
+
if (idx >= 0) self._pongWaiters.splice(idx, 1);
|
|
4035
|
+
delete self._pingMeter._outstanding[id];
|
|
4036
|
+
resolve(null);
|
|
4037
|
+
}, 3000);
|
|
4038
|
+
});
|
|
4039
|
+
} else if (self.socket && self.connected && !self._useProxy) {
|
|
4040
|
+
promise = new Promise(function (resolve) {
|
|
4041
|
+
const start = Date.now();
|
|
4042
|
+
let done = false;
|
|
4043
|
+
try {
|
|
4044
|
+
self.socket.emit('game:ping', { t: start }, function () { if (done) return; done = true; resolve(self._pingMeter.sample(Date.now() - start)); });
|
|
4045
|
+
} catch (e) { resolve(null); return; }
|
|
4046
|
+
setTimeout(function () { if (done) return; done = true; resolve(null); }, 3000);
|
|
4047
|
+
});
|
|
4048
|
+
} else {
|
|
4049
|
+
return Promise.resolve(null); // proxy mode has no point-to-point ack
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
self._pingInFlight = promise;
|
|
4053
|
+
promise.then(function () { self._pingInFlight = null; }, function () { self._pingInFlight = null; });
|
|
4054
|
+
return promise;
|
|
4055
|
+
};
|
|
4056
|
+
|
|
4057
|
+
/** Latest smoothed round-trip time in ms (null until first ping). */
|
|
4058
|
+
game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
/**
|
|
4062
|
+
* Usion SDK Game Core — game module base, connect routing, event registrations
|
|
4063
|
+
*/
|
|
4064
|
+
|
|
4065
|
+
|
|
4066
|
+
/**
|
|
4067
|
+
* Create the game module with all sub-modules applied
|
|
4068
|
+
* @param {object} Usion - Reference to the main Usion object
|
|
4069
|
+
*/
|
|
4070
|
+
function createGameModule(Usion) {
|
|
4071
|
+
const game = {
|
|
4072
|
+
socket: null,
|
|
4073
|
+
directSocket: null,
|
|
4074
|
+
roomId: null,
|
|
4075
|
+
playerId: null,
|
|
4076
|
+
connected: false,
|
|
4077
|
+
directMode: false,
|
|
4078
|
+
directConfig: null,
|
|
4079
|
+
_directSeq: 0,
|
|
4080
|
+
_eventHandlers: {},
|
|
4081
|
+
_lastSequence: 0,
|
|
4082
|
+
_connecting: false,
|
|
4083
|
+
_connectPromise: null,
|
|
4084
|
+
_joined: false,
|
|
4085
|
+
_joinPromise: null,
|
|
4086
|
+
_useProxy: false,
|
|
4087
|
+
_proxyListenerSetup: false,
|
|
4088
|
+
_heartbeatInterval: null,
|
|
4089
|
+
_pingMeter: null,
|
|
4090
|
+
_pongWaiters: [],
|
|
4091
|
+
|
|
4092
|
+
/**
|
|
4093
|
+
* Connect to the game socket server
|
|
4094
|
+
* @param {string} socketUrl - Socket.IO server URL (optional, uses config)
|
|
4095
|
+
* @param {string} token - JWT auth token (optional, uses user.getToken())
|
|
4096
|
+
* @returns {Promise} Resolves when connected
|
|
4097
|
+
*/
|
|
4098
|
+
connect: function(socketUrl, token) {
|
|
4099
|
+
const self = this;
|
|
4100
|
+
var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
|
|
4101
|
+
if (connectionMode === 'direct') {
|
|
4102
|
+
return self.connectDirect();
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
// If already connected (direct or proxy), return immediately
|
|
4106
|
+
if (self._useProxy && self.connected) {
|
|
4107
|
+
return Promise.resolve();
|
|
4108
|
+
}
|
|
4109
|
+
if (self.socket && self.connected) {
|
|
4110
|
+
return Promise.resolve();
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
// If currently connecting, return the existing promise
|
|
4114
|
+
if (self._connecting && self._connectPromise) {
|
|
4115
|
+
return self._connectPromise;
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
// When running inside an iframe or WebView, use parent as socket proxy
|
|
4119
|
+
// (checked BEFORE token validation — iframe games don't need a token)
|
|
4120
|
+
var isInFrame = !!window.__USION_PROXY__
|
|
4121
|
+
|| window.parent !== window
|
|
4122
|
+
|| !!window.ReactNativeWebView
|
|
4123
|
+
|| !!Usion._isEmbedded;
|
|
4124
|
+
|
|
4125
|
+
if (isInFrame) {
|
|
4126
|
+
Usion.log('Running in iframe \u2013 using parent app as socket proxy');
|
|
4127
|
+
return self._connectViaProxy();
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// Use config values as defaults (only for direct socket connections)
|
|
4131
|
+
socketUrl = socketUrl || Usion.config.socketUrl;
|
|
4132
|
+
token = token || Usion.user.getToken();
|
|
4133
|
+
|
|
4134
|
+
if (!socketUrl) {
|
|
4135
|
+
return Promise.reject(new Error('No socket URL provided'));
|
|
4136
|
+
}
|
|
4137
|
+
if (!token) {
|
|
4138
|
+
return Promise.reject(new Error('No auth token available'));
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
self._connecting = true;
|
|
4142
|
+
self._connectPromise = new Promise(function(resolve, reject) {
|
|
4143
|
+
// Check if socket.io-client is available
|
|
4144
|
+
if (typeof io === 'undefined') {
|
|
4145
|
+
// Load socket.io client
|
|
4146
|
+
var script = document.createElement('script');
|
|
4147
|
+
script.src = '/socket.io.min.js';
|
|
4148
|
+
script.onload = function() {
|
|
4149
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
4150
|
+
};
|
|
4151
|
+
script.onerror = function() {
|
|
4152
|
+
// Local file not available, try CDN as fallback
|
|
4153
|
+
var cdnScript = document.createElement('script');
|
|
4154
|
+
cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
|
|
4155
|
+
cdnScript.onload = function() {
|
|
4156
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
4157
|
+
};
|
|
4158
|
+
cdnScript.onerror = function() {
|
|
4159
|
+
self._connecting = false;
|
|
4160
|
+
reject(new Error('Failed to load Socket.IO client'));
|
|
4161
|
+
};
|
|
4162
|
+
document.head.appendChild(cdnScript);
|
|
4163
|
+
};
|
|
4164
|
+
document.head.appendChild(script);
|
|
4165
|
+
} else {
|
|
4166
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
4167
|
+
}
|
|
4168
|
+
});
|
|
4169
|
+
|
|
4170
|
+
return self._connectPromise;
|
|
4171
|
+
},
|
|
4172
|
+
|
|
4173
|
+
// Event handler registrations
|
|
4174
|
+
onJoined: function(callback) { this._eventHandlers.joined = callback; },
|
|
4175
|
+
onPlayerJoined: function(callback) { this._eventHandlers.playerJoined = callback; },
|
|
4176
|
+
onPlayerLeft: function(callback) { this._eventHandlers.playerLeft = callback; },
|
|
4177
|
+
onStateUpdate: function(callback) { this._eventHandlers.stateUpdate = callback; },
|
|
4178
|
+
onSync: function(callback) { this._eventHandlers.sync = callback; },
|
|
4179
|
+
onAction: function(callback) { this._eventHandlers.action = callback; },
|
|
4180
|
+
onRealtime: function(callback) { this._eventHandlers.realtime = callback; },
|
|
4181
|
+
onGameFinished: function(callback) { this._eventHandlers.finished = callback; },
|
|
4182
|
+
onGameRestarted: function(callback) { this._eventHandlers.restarted = callback; },
|
|
4183
|
+
onError: function(callback) { this._eventHandlers.error = callback; },
|
|
4184
|
+
onRematchRequest: function(callback) { this._eventHandlers.rematchRequest = callback; },
|
|
4185
|
+
onDisconnect: function(callback) { this._eventHandlers.disconnect = callback; },
|
|
4186
|
+
onReconnect: function(callback) { this._eventHandlers.reconnect = callback; },
|
|
4187
|
+
onConnectionError: function(callback) { this._eventHandlers.connectionError = callback; },
|
|
4188
|
+
|
|
4189
|
+
/**
|
|
4190
|
+
* Register a generic event handler
|
|
4191
|
+
* @param {string} event - Event name
|
|
4192
|
+
* @param {function} callback - Handler function
|
|
4193
|
+
*/
|
|
4194
|
+
on: function(event, callback) {
|
|
4195
|
+
if (this.socket) {
|
|
4196
|
+
this.socket.on(event, callback);
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
};
|
|
4200
|
+
|
|
4201
|
+
// Apply sub-modules
|
|
4202
|
+
applyGameDirect(game, Usion);
|
|
4203
|
+
applyGameSocket(game, Usion);
|
|
4204
|
+
applyGameProxy(game, Usion);
|
|
4205
|
+
applyGameMethods(game, Usion);
|
|
4206
|
+
applyGameNetcode(game, Usion);
|
|
4207
|
+
|
|
4208
|
+
return game;
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
/**
|
|
4212
|
+
* Usion SDK Lobby — parties, ready-up, and matchmaking.
|
|
4213
|
+
*
|
|
4214
|
+
* The social rendezvous layer that game rooms can't provide: form a party by
|
|
4215
|
+
* code, ready up together, and start as a group — plus a thin wrapper over the
|
|
4216
|
+
* platform's stranger matchmaking. Rides the platform Socket.IO connection
|
|
4217
|
+
* (Usion.game.socket), so connect first with Usion.game.connect().
|
|
4218
|
+
*
|
|
4219
|
+
* const { code } = await Usion.lobby.create({ maxPlayers: 4 });
|
|
4220
|
+
* Usion.lobby.onUpdate(({ members }) => renderLobby(members));
|
|
4221
|
+
* await Usion.lobby.setReady(true);
|
|
4222
|
+
* // host, once everyone's ready: create a room then start the party in it
|
|
4223
|
+
* const room = await Usion.lobby.queue(serviceId); // or your own room API
|
|
4224
|
+
* await Usion.lobby.start(room.id);
|
|
4225
|
+
* Usion.lobby.onStarted(({ room_id }) => Usion.game.join(room_id));
|
|
4226
|
+
*
|
|
4227
|
+
* Works in every mode: it rides the unified backend channel (Usion._backendEmit
|
|
4228
|
+
* / _backendOn), which uses the SDK's own socket when standalone and relays
|
|
4229
|
+
* through the parent app when embedded (iframe/WebView).
|
|
4230
|
+
*/
|
|
4231
|
+
function createLobbyModule(Usion) {
|
|
4232
|
+
const state = { code: null, host: null, status: null, members: [] };
|
|
4233
|
+
const handlers = {};
|
|
4234
|
+
let bound = false;
|
|
4235
|
+
|
|
4236
|
+
function bind() {
|
|
4237
|
+
if (bound) return;
|
|
4238
|
+
bound = true;
|
|
4239
|
+
Usion._backendOn('lobby:update', function (d) {
|
|
4240
|
+
state.code = d.code; state.host = d.host; state.status = d.status; state.members = d.members || [];
|
|
4241
|
+
if (handlers.update) handlers.update(d);
|
|
4242
|
+
});
|
|
4243
|
+
Usion._backendOn('lobby:started', function (d) {
|
|
4244
|
+
state.status = 'started';
|
|
4245
|
+
if (handlers.started) handlers.started(d);
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
function ack(event, data) {
|
|
4250
|
+
bind();
|
|
4251
|
+
return Usion._backendEmit(event, data);
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
return {
|
|
4255
|
+
get state() { return state; },
|
|
4256
|
+
|
|
4257
|
+
/** Register a handler for lobby membership/ready changes. */
|
|
4258
|
+
onUpdate: function (cb) { handlers.update = cb; bind(); },
|
|
4259
|
+
/** Register a handler for when the host starts the party. */
|
|
4260
|
+
onStarted: function (cb) { handlers.started = cb; bind(); },
|
|
4261
|
+
|
|
4262
|
+
/** Create a party. Resolves with { code }. You become the host. */
|
|
4263
|
+
create: async function (opts) {
|
|
4264
|
+
opts = opts || {};
|
|
4265
|
+
const r = await ack('lobby:create', { max_players: opts.maxPlayers || 8, public: !!opts.public });
|
|
4266
|
+
if (r && r.code) state.code = r.code;
|
|
4267
|
+
return r;
|
|
4268
|
+
},
|
|
4269
|
+
|
|
4270
|
+
/** Join a party by code. */
|
|
4271
|
+
join: async function (code) {
|
|
4272
|
+
const r = await ack('lobby:join', { code: String(code || '').toUpperCase() });
|
|
4273
|
+
if (r && r.code) state.code = r.code;
|
|
4274
|
+
return r;
|
|
4275
|
+
},
|
|
4276
|
+
|
|
4277
|
+
/** Leave the current party. */
|
|
4278
|
+
leave: function () { state.code = null; return ack('lobby:leave', {}).catch(function () {}); },
|
|
4279
|
+
|
|
4280
|
+
/** Set your ready state. */
|
|
4281
|
+
setReady: function (ready) { return ack('lobby:ready', { ready: ready !== false }); },
|
|
4282
|
+
|
|
4283
|
+
/** True when every member is ready. */
|
|
4284
|
+
allReady: function () { return state.members.length > 0 && state.members.every(function (m) { return m.ready; }); },
|
|
4285
|
+
|
|
4286
|
+
/** Whether the current user is the party host. */
|
|
4287
|
+
isHost: function () {
|
|
4288
|
+
const id = Usion.user && Usion.user.getId && Usion.user.getId();
|
|
4289
|
+
return !!(state.host && id && state.host === id);
|
|
4290
|
+
},
|
|
4291
|
+
|
|
4292
|
+
/** Host: start the party in an already-created game room. */
|
|
4293
|
+
start: function (roomId) { return ack('lobby:start', { room_id: roomId }); },
|
|
4294
|
+
|
|
4295
|
+
/**
|
|
4296
|
+
* Stranger matchmaking — find or create a game room for a service via the
|
|
4297
|
+
* platform's REST matchmaker. Resolves with the room.
|
|
4298
|
+
*/
|
|
4299
|
+
queue: async function (serviceId, opts) {
|
|
4300
|
+
opts = opts || {};
|
|
4301
|
+
const apiUrl = (Usion.config && Usion.config.apiUrl) || '';
|
|
4302
|
+
const token = Usion.user && Usion.user.getToken && Usion.user.getToken();
|
|
4303
|
+
if (!apiUrl) throw new Error('No apiUrl configured');
|
|
4304
|
+
const res = await fetch(apiUrl.replace(/\/$/, '') + '/games/matchmake', {
|
|
4305
|
+
method: 'POST',
|
|
4306
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
4307
|
+
body: JSON.stringify({ service_id: serviceId, conversation_id: opts.conversationId || ('standalone_' + serviceId) }),
|
|
4308
|
+
});
|
|
4309
|
+
if (!res.ok) throw new Error('Matchmake failed: HTTP ' + res.status);
|
|
4310
|
+
return res.json();
|
|
4311
|
+
},
|
|
4312
|
+
};
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
/**
|
|
4316
|
+
* Usion SDK Leaderboard — scores for mini-games.
|
|
4317
|
+
*
|
|
4318
|
+
* Opt-in per game (the service must have `leaderboard.enabled`). Rides the
|
|
4319
|
+
* unified backend channel, so it works in standalone AND embedded games.
|
|
4320
|
+
*
|
|
4321
|
+
* await Usion.leaderboard.submit(1500); // your score
|
|
4322
|
+
* const friends = await Usion.leaderboard.friends(); // people you've messaged + you
|
|
4323
|
+
* const top = await Usion.leaderboard.top(); // global top N
|
|
4324
|
+
* const me = await Usion.leaderboard.me(); // { score, rank, total }
|
|
4325
|
+
*
|
|
4326
|
+
* The **friends** board is scoped to users you've messaged (your conversations)
|
|
4327
|
+
* plus yourself — that's the "see the scores of people I've chatted with" view.
|
|
4328
|
+
* Entries: { user_id, name, avatar, score, rank, is_me, metadata }.
|
|
4329
|
+
*/
|
|
4330
|
+
function createLeaderboardModule(Usion) {
|
|
4331
|
+
function serviceId(opts) {
|
|
4332
|
+
return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
return {
|
|
4336
|
+
/** Submit a score (best score is kept, per the service's order config). */
|
|
4337
|
+
submit: function (score, metadata, opts) {
|
|
4338
|
+
return Usion._backendEmit('lb:submit', { service_id: serviceId(opts), score: score, metadata: metadata != null ? metadata : null });
|
|
4339
|
+
},
|
|
4340
|
+
|
|
4341
|
+
/** Leaderboard of people you've messaged (plus you). Returns entries[]. */
|
|
4342
|
+
friends: function (opts) {
|
|
4343
|
+
opts = opts || {};
|
|
4344
|
+
return Usion._backendEmit('lb:friends', { service_id: serviceId(opts), limit: opts.limit || 50 })
|
|
4345
|
+
.then(function (r) { return (r && r.entries) || []; });
|
|
4346
|
+
},
|
|
4347
|
+
|
|
4348
|
+
/** Global top N. Returns entries[]. */
|
|
4349
|
+
top: function (opts) {
|
|
4350
|
+
opts = opts || {};
|
|
4351
|
+
return Usion._backendEmit('lb:top', { service_id: serviceId(opts), limit: opts.limit || 20 })
|
|
4352
|
+
.then(function (r) { return (r && r.entries) || []; });
|
|
4353
|
+
},
|
|
4354
|
+
|
|
4355
|
+
/** Your own score + global rank: { score, rank, total }. */
|
|
4356
|
+
me: function (opts) {
|
|
4357
|
+
return Usion._backendEmit('lb:me', { service_id: serviceId(opts) });
|
|
4358
|
+
},
|
|
4359
|
+
};
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
/**
|
|
4363
|
+
* Usion SDK Matchmaking — pair up with online strangers ("quick match").
|
|
4364
|
+
*
|
|
4365
|
+
* Lets a mini-app connect players who don't know each other: join a queue, and
|
|
4366
|
+
* when enough players are waiting the platform creates a room and matches you.
|
|
4367
|
+
* Rides the unified backend channel, so it works standalone AND embedded.
|
|
4368
|
+
*
|
|
4369
|
+
* const m = await Usion.matchmaking.find(); // resolves when matched
|
|
4370
|
+
* await Usion.game.connect(); await Usion.game.join(m.roomId);
|
|
4371
|
+
* // ...or cancel while waiting:
|
|
4372
|
+
* Usion.matchmaking.cancel();
|
|
4373
|
+
* Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
|
|
4374
|
+
*/
|
|
4375
|
+
function createMatchmakingModule(Usion) {
|
|
4376
|
+
let pending = null; // { resolve, reject } for an in-flight find()
|
|
4377
|
+
let onMatchCb = null;
|
|
4378
|
+
let bound = false;
|
|
4379
|
+
|
|
4380
|
+
function normalize(d) {
|
|
4381
|
+
return { roomId: d && d.room_id, players: (d && d.player_ids) || [], serviceId: d && d.service_id };
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
function bind() {
|
|
4385
|
+
if (bound) return;
|
|
4386
|
+
bound = true;
|
|
4387
|
+
Usion._backendOn('mm:matched', function (d) {
|
|
4388
|
+
const r = normalize(d);
|
|
4389
|
+
if (onMatchCb) onMatchCb(r);
|
|
4390
|
+
if (pending) { const p = pending; pending = null; p.resolve(r); }
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
return {
|
|
4395
|
+
/** Register a handler called whenever a match is found. */
|
|
4396
|
+
onMatch: function (cb) { onMatchCb = cb; bind(); },
|
|
4397
|
+
|
|
4398
|
+
/**
|
|
4399
|
+
* Join the queue for `serviceId` (defaults to the current game) and resolve
|
|
4400
|
+
* when matched with { roomId, players, serviceId }. Stays pending until a
|
|
4401
|
+
* match (use cancel() to stop waiting).
|
|
4402
|
+
*/
|
|
4403
|
+
find: function (serviceId, opts) {
|
|
4404
|
+
opts = opts || {};
|
|
4405
|
+
const sid = serviceId || (Usion.config && Usion.config.serviceId);
|
|
4406
|
+
bind();
|
|
4407
|
+
return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
|
|
4408
|
+
return new Promise(function (resolve, reject) {
|
|
4409
|
+
if (pending) pending.reject(new Error('superseded'));
|
|
4410
|
+
pending = { resolve: resolve, reject: reject };
|
|
4411
|
+
});
|
|
4412
|
+
});
|
|
4413
|
+
},
|
|
4414
|
+
|
|
4415
|
+
/** Leave the queue / stop waiting. */
|
|
4416
|
+
cancel: function () {
|
|
4417
|
+
if (pending) { pending.reject(new Error('cancelled')); pending = null; }
|
|
4418
|
+
return Usion._backendEmit('mm:cancel', {});
|
|
4419
|
+
},
|
|
4420
|
+
};
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
/**
|
|
4424
|
+
* Usion SDK — unified backend channel.
|
|
4425
|
+
*
|
|
4426
|
+
* One switchboard for backend request/response + server push that works in
|
|
4427
|
+
* every mode, so features (lobby, matchmaking, presence, …) never reach for a
|
|
4428
|
+
* specific socket:
|
|
4429
|
+
*
|
|
4430
|
+
* - standalone / platform : uses the SDK's own Socket.IO socket (game.socket)
|
|
4431
|
+
* - embedded (iframe/WebView): relays through the parent app via postMessage
|
|
4432
|
+
* (BACKEND_EMIT request → host emits on its authenticated socket; the host
|
|
4433
|
+
* forwards allow-listed server pushes back as BACKEND_EVENT)
|
|
4434
|
+
*
|
|
4435
|
+
* Security: in embedded mode the host MUST restrict which events it relays to a
|
|
4436
|
+
* safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
|
|
4437
|
+
* the user's authenticated connection. The backend re-validates every call.
|
|
4438
|
+
*/
|
|
4439
|
+
function applyBackendChannel(Usion) {
|
|
4440
|
+
Usion._backendHandlers = {};
|
|
4441
|
+
Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
4442
|
+
|
|
4443
|
+
/**
|
|
4444
|
+
* Bind a Socket.IO socket so any allow-listed server event is routed to the
|
|
4445
|
+
* registered _backendOn handlers (via onAny — robust to registration order).
|
|
4446
|
+
*/
|
|
4447
|
+
Usion._bindBackendSocket = function (socket) {
|
|
4448
|
+
if (!socket || typeof socket.onAny !== 'function') return;
|
|
4449
|
+
if (this._boundSockets) {
|
|
4450
|
+
if (this._boundSockets.has(socket)) return;
|
|
4451
|
+
this._boundSockets.add(socket);
|
|
4452
|
+
}
|
|
4453
|
+
const self = this;
|
|
4454
|
+
socket.onAny(function (event, payload) {
|
|
4455
|
+
const h = self._backendHandlers[event];
|
|
4456
|
+
if (h) h(payload);
|
|
4457
|
+
});
|
|
4458
|
+
};
|
|
4459
|
+
|
|
4460
|
+
/** Subscribe to a backend server-push event (works in all modes). */
|
|
4461
|
+
Usion._backendOn = function (event, handler) {
|
|
4462
|
+
this._backendHandlers[event] = handler;
|
|
4463
|
+
const s = this.game && this.game.socket;
|
|
4464
|
+
if (s) {
|
|
4465
|
+
if (typeof s.onAny === 'function') this._bindBackendSocket(s);
|
|
4466
|
+
else if (typeof s.on === 'function') s.on(event, handler); // fallback (tests / minimal sockets)
|
|
4467
|
+
}
|
|
4468
|
+
};
|
|
4469
|
+
|
|
4470
|
+
/**
|
|
4471
|
+
* Emit a backend request and await its ack. Routes to the SDK socket when
|
|
4472
|
+
* standalone, or through the parent host when embedded.
|
|
4473
|
+
* @returns {Promise<any>}
|
|
4474
|
+
*/
|
|
4475
|
+
Usion._backendEmit = function (event, data, timeout) {
|
|
4476
|
+
const self = this;
|
|
4477
|
+
timeout = timeout || 8000;
|
|
4478
|
+
const s = self.game && self.game.socket;
|
|
4479
|
+
if (s && s.connected) {
|
|
4480
|
+
return new Promise(function (resolve, reject) {
|
|
4481
|
+
let done = false;
|
|
4482
|
+
const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
|
|
4483
|
+
try {
|
|
4484
|
+
s.emit(event, data || {}, function (resp) {
|
|
4485
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
4486
|
+
if (resp && resp.error) reject(new Error(resp.message || resp.error));
|
|
4487
|
+
else resolve(resp);
|
|
4488
|
+
});
|
|
4489
|
+
} catch (e) { clearTimeout(timer); reject(e); }
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
if (self._isEmbedded) {
|
|
4493
|
+
// Host relays this onto its authenticated socket and replies with the ack.
|
|
4494
|
+
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
|
|
4495
|
+
}
|
|
4496
|
+
return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
/**
|
|
4501
|
+
* Usion SDK Netcode — public namespace.
|
|
4502
|
+
*
|
|
4503
|
+
* A small, zero-dependency, transport-agnostic toolkit for smooth low-latency
|
|
4504
|
+
* multiplayer. Works across all Usion connection modes (platform / direct /
|
|
4505
|
+
* proxy) and both platforms, computing on top of the existing realtime/action
|
|
4506
|
+
* plumbing — no host or backend changes required to use it.
|
|
4507
|
+
*
|
|
4508
|
+
* SnapshotInterpolation — smooth rendering; adaptive buffer + capped extrapolation
|
|
4509
|
+
* Predictor — client prediction + reconciliation + error smoothing
|
|
4510
|
+
* Coalescer — fixed-Hz outbound send batching
|
|
4511
|
+
* PingMeter — RTT / jitter telemetry
|
|
4512
|
+
* MeshConnection — WebRTC P2P (sequenced, TURN-ready, auto-reconnect)
|
|
4513
|
+
* MeshNetwork — N-peer full mesh
|
|
4514
|
+
* diff / patch / quantize — JSON delta compression (id-keyed arrays)
|
|
4515
|
+
* encode / decode — compact binary codec
|
|
4516
|
+
*/
|
|
4517
|
+
|
|
4518
|
+
const netcode = {
|
|
4519
|
+
SnapshotInterpolation, Vault, Predictor, Coalescer, PingMeter,
|
|
4520
|
+
MeshConnection, MeshNetwork, WebTransportConnection, NetworkSim, Lockstep, LagCompensator,
|
|
4521
|
+
diff, patch, quantize, encode, decode,
|
|
4522
|
+
};
|
|
4523
|
+
|
|
1998
4524
|
/**
|
|
1999
4525
|
* Usion Mini App SDK v2.1
|
|
2000
4526
|
*
|
|
@@ -2022,6 +4548,14 @@ var Usion = (function () {
|
|
|
2022
4548
|
Usion.bot = createBotModule(Usion);
|
|
2023
4549
|
Usion.fileStorage = createFileStorageModule(Usion);
|
|
2024
4550
|
Usion.game = createGameModule(Usion);
|
|
4551
|
+
// Unified backend channel (used by lobby etc.; works standalone + embedded).
|
|
4552
|
+
applyBackendChannel(Usion);
|
|
4553
|
+
Usion.lobby = createLobbyModule(Usion);
|
|
4554
|
+
Usion.leaderboard = createLeaderboardModule(Usion);
|
|
4555
|
+
Usion.matchmaking = createMatchmakingModule(Usion);
|
|
4556
|
+
|
|
4557
|
+
// Netcode toolkit (transport-agnostic, zero-dependency).
|
|
4558
|
+
Usion.netcode = netcode;
|
|
2025
4559
|
|
|
2026
4560
|
// Attach results methods directly on Usion
|
|
2027
4561
|
Object.assign(Usion, createResultsMethods(Usion));
|