@usions/sdk 2.2.0 → 2.11.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 +3 -2
- package/src/browser.js +2554 -122
- package/src/modules/backend-channel.js +76 -0
- package/src/modules/cloud.js +67 -0
- package/src/modules/core.js +6 -0
- package/src/modules/game-core.js +4 -0
- package/src/modules/game-direct.js +45 -0
- package/src/modules/game-netcode.js +346 -0
- package/src/modules/game-socket.js +4 -0
- package/src/modules/index.js +15 -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/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/types/index.d.ts +449 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK — unified backend channel.
|
|
3
|
+
*
|
|
4
|
+
* One switchboard for backend request/response + server push that works in
|
|
5
|
+
* every mode, so features (lobby, matchmaking, presence, …) never reach for a
|
|
6
|
+
* specific socket:
|
|
7
|
+
*
|
|
8
|
+
* - standalone / platform : uses the SDK's own Socket.IO socket (game.socket)
|
|
9
|
+
* - embedded (iframe/WebView): relays through the parent app via postMessage
|
|
10
|
+
* (BACKEND_EMIT request → host emits on its authenticated socket; the host
|
|
11
|
+
* forwards allow-listed server pushes back as BACKEND_EVENT)
|
|
12
|
+
*
|
|
13
|
+
* Security: in embedded mode the host MUST restrict which events it relays to a
|
|
14
|
+
* safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
|
|
15
|
+
* the user's authenticated connection. The backend re-validates every call.
|
|
16
|
+
*/
|
|
17
|
+
export function applyBackendChannel(Usion) {
|
|
18
|
+
Usion._backendHandlers = {};
|
|
19
|
+
Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bind a Socket.IO socket so any allow-listed server event is routed to the
|
|
23
|
+
* registered _backendOn handlers (via onAny — robust to registration order).
|
|
24
|
+
*/
|
|
25
|
+
Usion._bindBackendSocket = function (socket) {
|
|
26
|
+
if (!socket || typeof socket.onAny !== 'function') return;
|
|
27
|
+
if (this._boundSockets) {
|
|
28
|
+
if (this._boundSockets.has(socket)) return;
|
|
29
|
+
this._boundSockets.add(socket);
|
|
30
|
+
}
|
|
31
|
+
const self = this;
|
|
32
|
+
socket.onAny(function (event, payload) {
|
|
33
|
+
const h = self._backendHandlers[event];
|
|
34
|
+
if (h) h(payload);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Subscribe to a backend server-push event (works in all modes). */
|
|
39
|
+
Usion._backendOn = function (event, handler) {
|
|
40
|
+
this._backendHandlers[event] = handler;
|
|
41
|
+
const s = this.game && this.game.socket;
|
|
42
|
+
if (s) {
|
|
43
|
+
if (typeof s.onAny === 'function') this._bindBackendSocket(s);
|
|
44
|
+
else if (typeof s.on === 'function') s.on(event, handler); // fallback (tests / minimal sockets)
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Emit a backend request and await its ack. Routes to the SDK socket when
|
|
50
|
+
* standalone, or through the parent host when embedded.
|
|
51
|
+
* @returns {Promise<any>}
|
|
52
|
+
*/
|
|
53
|
+
Usion._backendEmit = function (event, data, timeout) {
|
|
54
|
+
const self = this;
|
|
55
|
+
timeout = timeout || 8000;
|
|
56
|
+
const s = self.game && self.game.socket;
|
|
57
|
+
if (s && s.connected) {
|
|
58
|
+
return new Promise(function (resolve, reject) {
|
|
59
|
+
let done = false;
|
|
60
|
+
const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
|
|
61
|
+
try {
|
|
62
|
+
s.emit(event, data || {}, function (resp) {
|
|
63
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
64
|
+
if (resp && resp.error) reject(new Error(resp.message || resp.error));
|
|
65
|
+
else resolve(resp);
|
|
66
|
+
});
|
|
67
|
+
} catch (e) { clearTimeout(timer); reject(e); }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (self._isEmbedded) {
|
|
71
|
+
// Host relays this onto its authenticated socket and replies with the ack.
|
|
72
|
+
return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
|
|
73
|
+
}
|
|
74
|
+
return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Cloud — server-persisted KV storage for mini-apps.
|
|
3
|
+
*
|
|
4
|
+
* Unlike `Usion.storage` (device-local), cloud values live on the backend and
|
|
5
|
+
* survive reinstall / device switch. Enabled for every service, bounded by
|
|
6
|
+
* quotas (64 KB per value, 200 keys / 1 MB per bucket, 60 ops/min).
|
|
7
|
+
* Rides the unified backend channel, so it works standalone AND embedded.
|
|
8
|
+
*
|
|
9
|
+
* Per-user scope (each user sees only their own data):
|
|
10
|
+
* await Usion.cloud.set('save', { level: 3 });
|
|
11
|
+
* const save = await Usion.cloud.get('save'); // null if absent
|
|
12
|
+
* await Usion.cloud.remove('save');
|
|
13
|
+
* const keys = await Usion.cloud.keys();
|
|
14
|
+
*
|
|
15
|
+
* Shared scope (one bucket per app — ALL users read and write):
|
|
16
|
+
* await Usion.cloud.shared.set('motd', 'hello');
|
|
17
|
+
* const plays = await Usion.cloud.shared.incr('plays'); // atomic counter
|
|
18
|
+
*
|
|
19
|
+
* Keys: 1-128 chars of A-Za-z0-9_.-:/
|
|
20
|
+
*/
|
|
21
|
+
export function createCloudModule(Usion) {
|
|
22
|
+
function serviceId(opts) {
|
|
23
|
+
return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emit(event, scope, extra, opts) {
|
|
27
|
+
var payload = Object.assign({ service_id: serviceId(opts), scope: scope }, extra);
|
|
28
|
+
return Usion._backendEmit(event, payload);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scopeApi(scope) {
|
|
32
|
+
return {
|
|
33
|
+
/** Get a value; resolves to null when the key doesn't exist. */
|
|
34
|
+
get: function (key, opts) {
|
|
35
|
+
return emit('kv:get', scope, { key: key }, opts)
|
|
36
|
+
.then(function (r) { return r && r.exists ? r.value : null; });
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** Set a JSON-serializable value (max 64 KB). */
|
|
40
|
+
set: function (key, value, opts) {
|
|
41
|
+
return emit('kv:set', scope, { key: key, value: value }, opts);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** Remove a key. Resolves to { success, removed }. */
|
|
45
|
+
remove: function (key, opts) {
|
|
46
|
+
return emit('kv:remove', scope, { key: key }, opts);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** List keys in this scope's bucket. */
|
|
50
|
+
keys: function (opts) {
|
|
51
|
+
return emit('kv:keys', scope, {}, opts)
|
|
52
|
+
.then(function (r) { return (r && r.keys) || []; });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var cloud = scopeApi('user');
|
|
58
|
+
cloud.shared = scopeApi('shared');
|
|
59
|
+
|
|
60
|
+
/** Atomically increment a shared numeric value (default delta 1). Resolves to the new value. */
|
|
61
|
+
cloud.shared.incr = function (key, delta, opts) {
|
|
62
|
+
return emit('kv:incr', 'shared', { key: key, delta: delta == null ? 1 : delta }, opts)
|
|
63
|
+
.then(function (r) { return r && r.value; });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return cloud;
|
|
67
|
+
}
|
package/src/modules/core.js
CHANGED
|
@@ -181,6 +181,12 @@ export const core = {
|
|
|
181
181
|
if (data.type === 'BOT_MESSAGE' && self.bot && self.bot._messageHandler) {
|
|
182
182
|
self.bot._messageHandler(data.message);
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
// Handle backend server-push events relayed by the host (embedded mode).
|
|
186
|
+
if (data.type === 'BACKEND_EVENT' && data.event && self._backendHandlers) {
|
|
187
|
+
const h = self._backendHandlers[data.event];
|
|
188
|
+
if (h) h(data.data);
|
|
189
|
+
}
|
|
184
190
|
});
|
|
185
191
|
|
|
186
192
|
// Signal ready to parent
|
package/src/modules/game-core.js
CHANGED
|
@@ -6,6 +6,7 @@ import { applyGameDirect } from './game-direct.js';
|
|
|
6
6
|
import { applyGameSocket } from './game-socket.js';
|
|
7
7
|
import { applyGameProxy } from './game-proxy.js';
|
|
8
8
|
import { applyGameMethods } from './game-methods.js';
|
|
9
|
+
import { applyGameNetcode } from './game-netcode.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Create the game module with all sub-modules applied
|
|
@@ -30,6 +31,8 @@ export function createGameModule(Usion) {
|
|
|
30
31
|
_useProxy: false,
|
|
31
32
|
_proxyListenerSetup: false,
|
|
32
33
|
_heartbeatInterval: null,
|
|
34
|
+
_pingMeter: null,
|
|
35
|
+
_pongWaiters: [],
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Connect to the game socket server
|
|
@@ -145,6 +148,7 @@ export function createGameModule(Usion) {
|
|
|
145
148
|
applyGameSocket(game, Usion);
|
|
146
149
|
applyGameProxy(game, Usion);
|
|
147
150
|
applyGameMethods(game, Usion);
|
|
151
|
+
applyGameNetcode(game, Usion);
|
|
148
152
|
|
|
149
153
|
return game;
|
|
150
154
|
}
|
|
@@ -26,6 +26,9 @@ export function applyGameDirect(game, Usion) {
|
|
|
26
26
|
|
|
27
27
|
self._connecting = true;
|
|
28
28
|
self.directMode = true;
|
|
29
|
+
self._autoReconnect = config.autoReconnect !== undefined
|
|
30
|
+
? config.autoReconnect
|
|
31
|
+
: !(Usion.config && Usion.config.autoReconnect === false);
|
|
29
32
|
self._connectPromise = self._fetchDirectAccess(config)
|
|
30
33
|
.then(function(access) {
|
|
31
34
|
self.directConfig = access;
|
|
@@ -154,6 +157,10 @@ export function applyGameDirect(game, Usion) {
|
|
|
154
157
|
if (self._eventHandlers.disconnect) {
|
|
155
158
|
self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
|
|
156
159
|
}
|
|
160
|
+
// Seamless resume: if the drop wasn't an intentional disconnect()
|
|
161
|
+
// (which clears directMode), transparently re-establish + re-join +
|
|
162
|
+
// resync from the last sequence.
|
|
163
|
+
if (self.directMode && self._autoReconnect !== false) self._scheduleDirectReconnect();
|
|
157
164
|
};
|
|
158
165
|
|
|
159
166
|
ws.onmessage = function(evt) {
|
|
@@ -162,6 +169,39 @@ export function applyGameDirect(game, Usion) {
|
|
|
162
169
|
});
|
|
163
170
|
};
|
|
164
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Reconnect a dropped direct-mode socket with capped exponential backoff,
|
|
174
|
+
* then re-join the room and request a resync (Dota-style "fetch latest
|
|
175
|
+
* snapshot = instant rejoin"). Keeps retrying while still in directMode.
|
|
176
|
+
* @private
|
|
177
|
+
*/
|
|
178
|
+
game._scheduleDirectReconnect = function() {
|
|
179
|
+
var self = this;
|
|
180
|
+
if (self._reconnecting) return;
|
|
181
|
+
self._reconnecting = true;
|
|
182
|
+
var attempt = self._reconnectAttempt || 0;
|
|
183
|
+
var go = function() {
|
|
184
|
+
if (!self.directMode) { self._reconnecting = false; return; } // disconnected meanwhile
|
|
185
|
+
attempt += 1;
|
|
186
|
+
self._reconnectAttempt = attempt;
|
|
187
|
+
self._fetchDirectAccess({})
|
|
188
|
+
.then(function(access) { self.directConfig = access; return self._initDirectSocket(access); })
|
|
189
|
+
.then(function() {
|
|
190
|
+
self.connected = true;
|
|
191
|
+
self._reconnecting = false;
|
|
192
|
+
self._reconnectAttempt = 0;
|
|
193
|
+
if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
|
|
194
|
+
if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
|
|
195
|
+
})
|
|
196
|
+
.catch(function() {
|
|
197
|
+
if (!self.directMode) { self._reconnecting = false; return; }
|
|
198
|
+
var delay = Math.min(1000 * Math.pow(2, attempt - 1), 15000);
|
|
199
|
+
setTimeout(go, delay);
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
setTimeout(go, 500);
|
|
203
|
+
};
|
|
204
|
+
|
|
165
205
|
game._sendDirect = function(type, payload) {
|
|
166
206
|
if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
|
|
167
207
|
this._directSeq = this._directSeq + 1;
|
|
@@ -205,6 +245,11 @@ export function applyGameDirect(game, Usion) {
|
|
|
205
245
|
return;
|
|
206
246
|
}
|
|
207
247
|
if (data.type === 'pong') {
|
|
248
|
+
// Resolve a pending game.ping() RTT probe, if any.
|
|
249
|
+
if (this._pongWaiters && this._pongWaiters.length) {
|
|
250
|
+
const waiter = this._pongWaiters.shift();
|
|
251
|
+
if (waiter) waiter();
|
|
252
|
+
}
|
|
208
253
|
if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
|
|
209
254
|
return;
|
|
210
255
|
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Game Netcode — convenience wiring of the netcode toolkit onto the
|
|
3
|
+
* game module. These helpers ride on the existing realtime/action channels, so
|
|
4
|
+
* they work in every connection mode with no host/backend change (the one
|
|
5
|
+
* exception is RTT measurement — see game.ping below).
|
|
6
|
+
*/
|
|
7
|
+
import { SnapshotInterpolation } from './netcode/interpolation.js';
|
|
8
|
+
import { Predictor } from './netcode/prediction.js';
|
|
9
|
+
import { Coalescer } from './netcode/sender.js';
|
|
10
|
+
import { PingMeter } from './netcode/ping.js';
|
|
11
|
+
import { MeshConnection } from './netcode/mesh.js';
|
|
12
|
+
import { MeshNetwork } from './netcode/mesh-network.js';
|
|
13
|
+
import { WebTransportConnection } from './netcode/webtransport.js';
|
|
14
|
+
import { NetworkSim } from './netcode/netsim.js';
|
|
15
|
+
import { Lockstep } from './netcode/lockstep.js';
|
|
16
|
+
import { LagCompensator } from './netcode/lagcomp.js';
|
|
17
|
+
import { diff, patch, quantize } from './netcode/delta.js';
|
|
18
|
+
import { encode, decode } from './netcode/binary.js';
|
|
19
|
+
|
|
20
|
+
export function applyGameNetcode(game, Usion) {
|
|
21
|
+
game.diff = diff;
|
|
22
|
+
game.patch = patch;
|
|
23
|
+
game.quantize = quantize;
|
|
24
|
+
game.encode = encode;
|
|
25
|
+
game.decode = decode;
|
|
26
|
+
|
|
27
|
+
game.createInterpolation = function (opts) { return new SnapshotInterpolation(opts || {}); };
|
|
28
|
+
game.createPredictor = function (opts) { return new Predictor(opts || {}); };
|
|
29
|
+
game.createLagCompensator = function (opts) { return new LagCompensator(opts || {}); };
|
|
30
|
+
game.createLockstep = function (opts) { return new Lockstep(opts || {}); };
|
|
31
|
+
|
|
32
|
+
// ── Realtime channel router ──────────────────────────────────────────────
|
|
33
|
+
// Multiplexes realtime by action_type so replicate / mesh / mesh-network and
|
|
34
|
+
// the game's own onRealtime can coexist. Also the inject point for the
|
|
35
|
+
// network simulator's inbound side.
|
|
36
|
+
game._channels = game._channels || {};
|
|
37
|
+
game._userRealtime = game._userRealtime || null;
|
|
38
|
+
game._inboundDispatch = null;
|
|
39
|
+
function dispatchRealtime(data) {
|
|
40
|
+
const at = data && data.action_type;
|
|
41
|
+
if (at && game._channels[at]) { game._channels[at](data && (data.action_data !== undefined ? data.action_data : data), data); return; }
|
|
42
|
+
if (game._userRealtime) game._userRealtime(data);
|
|
43
|
+
}
|
|
44
|
+
game._dispatchRealtime = dispatchRealtime;
|
|
45
|
+
game._installRealtimeRouter = function () {
|
|
46
|
+
if (game._routerInstalled) return;
|
|
47
|
+
if (game._eventHandlers.realtime && game._eventHandlers.realtime !== game._router) game._userRealtime = game._eventHandlers.realtime;
|
|
48
|
+
game._router = function (data) { (game._inboundDispatch || game._dispatchRealtime)(data); };
|
|
49
|
+
game._eventHandlers.realtime = game._router;
|
|
50
|
+
game._routerInstalled = true;
|
|
51
|
+
};
|
|
52
|
+
/** Register a handler for one realtime action_type. Returns an unsubscribe fn. */
|
|
53
|
+
game._onChannel = function (type, handler) { game._channels[type] = handler; game._installRealtimeRouter(); return function () { delete game._channels[type]; }; };
|
|
54
|
+
// Route onRealtime through the router so channels keep working.
|
|
55
|
+
game.onRealtime = function (cb) { game._userRealtime = cb; game._installRealtimeRouter(); };
|
|
56
|
+
|
|
57
|
+
/** Fixed-rate outbound sender (defaults to game.realtime). */
|
|
58
|
+
game.createSender = function (opts) {
|
|
59
|
+
opts = opts || {};
|
|
60
|
+
const self = this;
|
|
61
|
+
const send = opts.send || function (type, data) { self.realtime(type, data); };
|
|
62
|
+
return new Coalescer({
|
|
63
|
+
hz: opts.hz || 20,
|
|
64
|
+
autoStart: opts.autoStart !== false,
|
|
65
|
+
onFlush: function (entries) { for (let i = 0; i < entries.length; i++) send(entries[i].type, entries[i].data); },
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Coalesced, sequence-guarded, delta-compressed snapshot sender.
|
|
71
|
+
*
|
|
72
|
+
* Deltas are computed against the **last keyframe** (not the previous frame),
|
|
73
|
+
* so a single lost delta on an unreliable channel never desyncs — the next
|
|
74
|
+
* delta still applies to the keyframe the receiver holds. Each message
|
|
75
|
+
* carries a monotonic seq (`s`) and, for deltas, the keyframe seq it's based
|
|
76
|
+
* on (`b`); the receiver uses these to drop stale/out-of-order frames and to
|
|
77
|
+
* hold until it has the right keyframe. Optional float `precision`
|
|
78
|
+
* (quantization) and binary `encode` shrink the wire further.
|
|
79
|
+
*
|
|
80
|
+
* Pass `source` (a getter) to auto-read the state each tick — the basis for
|
|
81
|
+
* game.replicate(). Pair with game.createSnapshotReceiver().
|
|
82
|
+
*/
|
|
83
|
+
game.createSnapshotSender = function (opts) {
|
|
84
|
+
opts = opts || {};
|
|
85
|
+
const self = this;
|
|
86
|
+
const channel = opts.channel || 'state';
|
|
87
|
+
const useDelta = opts.delta !== false;
|
|
88
|
+
const keyframeEvery = opts.keyframeEvery != null ? opts.keyframeEvery : 30;
|
|
89
|
+
const precision = opts.precision;
|
|
90
|
+
const enc = opts.encode === true ? encode : (typeof opts.encode === 'function' ? opts.encode : null);
|
|
91
|
+
const send = opts.send || function (type, data) { self.realtime(type, data); };
|
|
92
|
+
const source = typeof opts.source === 'function' ? opts.source : null;
|
|
93
|
+
let keyframeState = null; // an immutable clone (deltas diff against this)
|
|
94
|
+
let keyframeSeq = 0;
|
|
95
|
+
let seq = 0;
|
|
96
|
+
let sinceKey = 0;
|
|
97
|
+
|
|
98
|
+
function snapshotClone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); }
|
|
99
|
+
|
|
100
|
+
function emit(raw) {
|
|
101
|
+
if (raw === undefined || raw === null) return;
|
|
102
|
+
const state = precision != null ? quantize(raw, precision) : raw;
|
|
103
|
+
let payload;
|
|
104
|
+
const wantKey = !useDelta || keyframeState === null || sinceKey >= keyframeEvery;
|
|
105
|
+
if (wantKey) {
|
|
106
|
+
seq += 1; keyframeState = snapshotClone(state); keyframeSeq = seq; sinceKey = 0;
|
|
107
|
+
payload = { s: seq, f: state };
|
|
108
|
+
} else {
|
|
109
|
+
const d = diff(keyframeState, state);
|
|
110
|
+
if (d === undefined) return; // nothing changed — don't burn a seq
|
|
111
|
+
seq += 1; sinceKey += 1;
|
|
112
|
+
payload = { s: seq, b: keyframeSeq, d: d };
|
|
113
|
+
}
|
|
114
|
+
send(channel, enc ? enc(payload) : payload);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Two drive modes: a `source` getter (read every tick) or queued send().
|
|
118
|
+
const hz = opts.hz || 20;
|
|
119
|
+
let queued;
|
|
120
|
+
const co = source ? null : new Coalescer({
|
|
121
|
+
hz: hz, autoStart: opts.autoStart !== false,
|
|
122
|
+
onFlush: function (entries) { for (let i = 0; i < entries.length; i++) if (entries[i].type === '__snap') queued = entries[i].data; emit(queued); },
|
|
123
|
+
});
|
|
124
|
+
let timer = null;
|
|
125
|
+
function start() {
|
|
126
|
+
if (co) { co.start(); return; }
|
|
127
|
+
if (timer || typeof setInterval === 'undefined') return;
|
|
128
|
+
timer = setInterval(function () { emit(source()); }, Math.max(1, Math.round(1000 / hz)));
|
|
129
|
+
}
|
|
130
|
+
function stop() { if (co) co.stop(); if (timer) { clearInterval(timer); timer = null; } }
|
|
131
|
+
if (source && opts.autoStart !== false) start();
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
send: function (state) { if (co) co.queue('__snap', state); else emit(state); },
|
|
135
|
+
flush: function () { if (co) co.flush(); else if (source) emit(source()); },
|
|
136
|
+
start: start,
|
|
137
|
+
stop: stop,
|
|
138
|
+
reset: function () { keyframeState = null; keyframeSeq = 0; seq = 0; sinceKey = 0; },
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Receiver for createSnapshotSender. Holds the keyframe baseline + last
|
|
144
|
+
* applied seq, so it safely drops stale/out-of-order frames and ignores
|
|
145
|
+
* deltas whose keyframe it missed (until the next keyframe arrives).
|
|
146
|
+
* @returns {{ receive:(msg:any)=>any, state:any, stats:object, reset:()=>void }}
|
|
147
|
+
*/
|
|
148
|
+
game.createSnapshotReceiver = function (opts) {
|
|
149
|
+
opts = opts || {};
|
|
150
|
+
const dec = opts.decode === true ? decode : (typeof opts.decode === 'function' ? opts.decode : null);
|
|
151
|
+
let base = null, baseSeq = -1, current = null, appliedSeq = -1, dropped = 0;
|
|
152
|
+
return {
|
|
153
|
+
receive: function (msg) {
|
|
154
|
+
if (dec && (msg instanceof ArrayBuffer || ArrayBuffer.isView(msg))) msg = dec(msg);
|
|
155
|
+
if (!msg) return current;
|
|
156
|
+
if (msg.f !== undefined) {
|
|
157
|
+
if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; }
|
|
158
|
+
base = msg.f; baseSeq = msg.s !== undefined ? msg.s : 0; current = msg.f; appliedSeq = baseSeq;
|
|
159
|
+
return current;
|
|
160
|
+
}
|
|
161
|
+
if (msg.d !== undefined) {
|
|
162
|
+
if (msg.b !== undefined && msg.b !== baseSeq) { dropped += 1; return current; } // missed keyframe
|
|
163
|
+
if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; } // stale/dup
|
|
164
|
+
current = patch(base, msg.d);
|
|
165
|
+
if (msg.s !== undefined) appliedSeq = msg.s;
|
|
166
|
+
return current;
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
},
|
|
170
|
+
get state() { return current; },
|
|
171
|
+
get stats() { return { appliedSeq: appliedSeq, baseSeq: baseSeq, dropped: dropped }; },
|
|
172
|
+
reset: function () { base = null; baseSeq = -1; current = null; appliedSeq = -1; dropped = 0; },
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Declarative state replication (host side). Mutate the plain object you pass
|
|
178
|
+
* in; the SDK auto-diffs and broadcasts it every tick (sequence-guarded,
|
|
179
|
+
* delta-compressed, optional quantize/binary). Clients read it with
|
|
180
|
+
* game.replica(). This is the ~40-lines-into-2 ergonomic.
|
|
181
|
+
* @returns {{ state:any, flush:()=>void, start:()=>void, stop:()=>void }}
|
|
182
|
+
*/
|
|
183
|
+
game.replicate = function (obj, opts) {
|
|
184
|
+
opts = opts || {};
|
|
185
|
+
let state = obj != null ? obj : {};
|
|
186
|
+
const sender = this.createSnapshotSender(Object.assign({}, opts, { source: function () { return state; } }));
|
|
187
|
+
return {
|
|
188
|
+
get state() { return state; },
|
|
189
|
+
set state(v) { state = v; },
|
|
190
|
+
flush: function () { sender.flush(); },
|
|
191
|
+
start: function () { sender.start(); },
|
|
192
|
+
stop: function () { sender.stop(); },
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Declarative state replication (client side). Receives what a host
|
|
198
|
+
* replicate()s on the same channel, drops stale/out-of-order frames, and
|
|
199
|
+
* (optionally) interpolates. Read `.state` for the latest authoritative state,
|
|
200
|
+
* or `.view()` for smoothed entities when `interpolate` keys are given.
|
|
201
|
+
* @returns {{ state:any, view:()=>any, onChange:(cb)=>void, stop:()=>void }}
|
|
202
|
+
*/
|
|
203
|
+
game.replica = function (opts) {
|
|
204
|
+
opts = opts || {};
|
|
205
|
+
const channel = opts.channel || 'state';
|
|
206
|
+
const rx = this.createSnapshotReceiver({ decode: opts.decode });
|
|
207
|
+
const interp = opts.interpolate ? this.createInterpolation(typeof opts.interpolate === 'object' ? opts.interpolate : {}) : null;
|
|
208
|
+
const keys = typeof opts.interpolate === 'string' ? opts.interpolate : (opts.keys || (opts.interpolate && opts.interpolate.keys));
|
|
209
|
+
const group = opts.group || (opts.interpolate && opts.interpolate.group);
|
|
210
|
+
let onChange = null;
|
|
211
|
+
const off = this._onChannel(channel, function (data) {
|
|
212
|
+
const next = rx.receive(data);
|
|
213
|
+
if (interp && next != null) interp.add(next);
|
|
214
|
+
if (onChange) onChange(next);
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
get state() { return rx.state; },
|
|
218
|
+
view: function () { return interp ? interp.calc(keys || 'x y', group) : rx.state; },
|
|
219
|
+
onChange: function (cb) { onChange = cb; },
|
|
220
|
+
stop: function () { off(); },
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One-line WebRTC peer-to-peer setup (2 peers). Signaling rides the realtime
|
|
226
|
+
* channel via the router; gameplay then flows directly peer-to-peer over UDP.
|
|
227
|
+
*/
|
|
228
|
+
game.createMesh = function (opts) {
|
|
229
|
+
opts = opts || {};
|
|
230
|
+
const self = this;
|
|
231
|
+
const channel = opts.signalChannel || 'signal';
|
|
232
|
+
const mesh = new MeshConnection(Object.assign({}, opts, {
|
|
233
|
+
sendSignal: function (payload) { self.realtime(channel, payload); },
|
|
234
|
+
}));
|
|
235
|
+
this._onChannel(channel, function (payload) { if (payload && payload.type) mesh.handleSignal(payload); });
|
|
236
|
+
return mesh;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* N-peer full mesh. Signaling routed per-peer over the realtime channel (each
|
|
241
|
+
* message carries {to, from}); messages addressed to us are dispatched to the
|
|
242
|
+
* right peer connection. selfId defaults to the current user id.
|
|
243
|
+
* @returns {MeshNetwork}
|
|
244
|
+
*/
|
|
245
|
+
game.createMeshNetwork = function (opts) {
|
|
246
|
+
opts = opts || {};
|
|
247
|
+
const self = this;
|
|
248
|
+
const channel = opts.signalChannel || 'mesh';
|
|
249
|
+
const selfId = opts.selfId || (Usion.user && Usion.user.getId && Usion.user.getId());
|
|
250
|
+
const net = new MeshNetwork(Object.assign({}, opts, {
|
|
251
|
+
selfId: selfId,
|
|
252
|
+
sendSignal: function (toPeerId, payload) { self.realtime(channel, { to: toPeerId, from: selfId, payload: payload }); },
|
|
253
|
+
}));
|
|
254
|
+
this._onChannel(channel, function (ad) { if (ad && ad.to === selfId && ad.from) net.handleSignal(ad.from, ad.payload); });
|
|
255
|
+
return net;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Inject artificial latency / jitter / packet loss locally to test how the
|
|
260
|
+
* game feels on a bad network (every studio has this). Affects both outbound
|
|
261
|
+
* realtime sends and inbound realtime dispatch. Call with null/false to turn
|
|
262
|
+
* it off. No-op safety: realtime must exist on the game module.
|
|
263
|
+
* @param {{latencyMs?:number,jitterMs?:number,lossPct?:number,dupPct?:number}|null} opts
|
|
264
|
+
*/
|
|
265
|
+
game.simulateNetwork = function (opts) {
|
|
266
|
+
const self = this;
|
|
267
|
+
self._installRealtimeRouter();
|
|
268
|
+
if (!self._realtimeRaw && typeof self.realtime === 'function') self._realtimeRaw = self.realtime.bind(self);
|
|
269
|
+
if (!opts) {
|
|
270
|
+
if (self._realtimeRaw) self.realtime = self._realtimeRaw;
|
|
271
|
+
self._inboundDispatch = null;
|
|
272
|
+
if (self._sim) self._sim.flush();
|
|
273
|
+
self._sim = null;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!self._sim) self._sim = new NetworkSim(opts);
|
|
277
|
+
else self._sim.set(opts);
|
|
278
|
+
if (self._realtimeRaw) self.realtime = self._sim.wrap(self._realtimeRaw);
|
|
279
|
+
self._inboundDispatch = self._sim.wrap(self._dispatchRealtime);
|
|
280
|
+
return self._sim;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Create a WebTransport (HTTP/3) connection — the lowest-latency client-server
|
|
285
|
+
* path (UDP-like datagrams, no TCP head-of-line blocking). `url` defaults to
|
|
286
|
+
* Usion.config.webTransportUrl. Call `connect()`, then `send()` (datagram) /
|
|
287
|
+
* `sendReliable()`; feed `onMessage` into a snapshot receiver. Direct/standalone
|
|
288
|
+
* games only (the iframe/WebView proxy relay can't carry datagrams).
|
|
289
|
+
* @returns {WebTransportConnection}
|
|
290
|
+
*/
|
|
291
|
+
game.createWebTransport = function (opts) {
|
|
292
|
+
opts = opts || {};
|
|
293
|
+
const url = opts.url || (Usion.config && (Usion.config.webTransportUrl || Usion.config.wtUrl));
|
|
294
|
+
return new WebTransportConnection(Object.assign({}, opts, { url: url }));
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Measure round-trip time once (single outstanding probe), updating the
|
|
299
|
+
* rolling estimate (game.getRtt). Direct mode uses the protocol ping/pong;
|
|
300
|
+
* platform mode uses a lightweight server ack. Resolves to ms, or null if
|
|
301
|
+
* unavailable (proxy mode, or not connected).
|
|
302
|
+
* @returns {Promise<number|null>}
|
|
303
|
+
*/
|
|
304
|
+
game.ping = function () {
|
|
305
|
+
const self = this;
|
|
306
|
+
if (!self._pingMeter) self._pingMeter = new PingMeter();
|
|
307
|
+
if (self._pingInFlight) return self._pingInFlight; // single outstanding probe
|
|
308
|
+
|
|
309
|
+
let promise;
|
|
310
|
+
if (self.directMode) {
|
|
311
|
+
if (!self.directSocket || self.directSocket.readyState !== 1) return Promise.resolve(null);
|
|
312
|
+
promise = new Promise(function (resolve) {
|
|
313
|
+
const id = self._pingMeter.begin();
|
|
314
|
+
let done = false;
|
|
315
|
+
const finish = function () { if (done) return; done = true; resolve(self._pingMeter.end(id)); };
|
|
316
|
+
self._pongWaiters.push(finish);
|
|
317
|
+
self._sendDirect('ping', { t: Date.now() });
|
|
318
|
+
setTimeout(function () {
|
|
319
|
+
if (done) return; done = true;
|
|
320
|
+
const idx = self._pongWaiters.indexOf(finish);
|
|
321
|
+
if (idx >= 0) self._pongWaiters.splice(idx, 1);
|
|
322
|
+
delete self._pingMeter._outstanding[id];
|
|
323
|
+
resolve(null);
|
|
324
|
+
}, 3000);
|
|
325
|
+
});
|
|
326
|
+
} else if (self.socket && self.connected && !self._useProxy) {
|
|
327
|
+
promise = new Promise(function (resolve) {
|
|
328
|
+
const start = Date.now();
|
|
329
|
+
let done = false;
|
|
330
|
+
try {
|
|
331
|
+
self.socket.emit('game:ping', { t: start }, function () { if (done) return; done = true; resolve(self._pingMeter.sample(Date.now() - start)); });
|
|
332
|
+
} catch (e) { resolve(null); return; }
|
|
333
|
+
setTimeout(function () { if (done) return; done = true; resolve(null); }, 3000);
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
return Promise.resolve(null); // proxy mode has no point-to-point ack
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
self._pingInFlight = promise;
|
|
340
|
+
promise.then(function () { self._pingInFlight = null; }, function () { self._pingInFlight = null; });
|
|
341
|
+
return promise;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Latest smoothed round-trip time in ms (null until first ping). */
|
|
345
|
+
game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
|
|
346
|
+
}
|
|
@@ -40,6 +40,10 @@ export function applyGameSocket(game, Usion) {
|
|
|
40
40
|
reconnectionDelayMax: 10000
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
// Route allow-listed backend server events (lobby, etc.) to the unified
|
|
44
|
+
// backend channel — robust to listener registration order.
|
|
45
|
+
if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
|
|
46
|
+
|
|
43
47
|
self.socket.on('connect', function() {
|
|
44
48
|
self.connected = true;
|
|
45
49
|
self._connecting = false;
|
package/src/modules/index.js
CHANGED
|
@@ -25,6 +25,12 @@ import { uiMethods } from './ui.js';
|
|
|
25
25
|
import { miscMethods } from './misc.js';
|
|
26
26
|
import { createFileStorageModule } from './file-storage.js';
|
|
27
27
|
import { createGameModule } from './game-core.js';
|
|
28
|
+
import { createLobbyModule } from './lobby.js';
|
|
29
|
+
import { createLeaderboardModule } from './leaderboard.js';
|
|
30
|
+
import { createCloudModule } from './cloud.js';
|
|
31
|
+
import { createMatchmakingModule } from './matchmaking.js';
|
|
32
|
+
import { applyBackendChannel } from './backend-channel.js';
|
|
33
|
+
import { netcode } from './netcode/index.js';
|
|
28
34
|
|
|
29
35
|
// Build the Usion object from core
|
|
30
36
|
const Usion = Object.assign({}, core);
|
|
@@ -38,6 +44,15 @@ Usion.chat = createChatModule(Usion);
|
|
|
38
44
|
Usion.bot = createBotModule(Usion);
|
|
39
45
|
Usion.fileStorage = createFileStorageModule(Usion);
|
|
40
46
|
Usion.game = createGameModule(Usion);
|
|
47
|
+
// Unified backend channel (used by lobby etc.; works standalone + embedded).
|
|
48
|
+
applyBackendChannel(Usion);
|
|
49
|
+
Usion.lobby = createLobbyModule(Usion);
|
|
50
|
+
Usion.leaderboard = createLeaderboardModule(Usion);
|
|
51
|
+
Usion.cloud = createCloudModule(Usion);
|
|
52
|
+
Usion.matchmaking = createMatchmakingModule(Usion);
|
|
53
|
+
|
|
54
|
+
// Netcode toolkit (transport-agnostic, zero-dependency).
|
|
55
|
+
Usion.netcode = netcode;
|
|
41
56
|
|
|
42
57
|
// Attach results methods directly on Usion
|
|
43
58
|
Object.assign(Usion, createResultsMethods(Usion));
|