@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
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Usion SDK Game Proxy — postMessage relay through parent app
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { isTrustedMessageSource } from './core.js';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Add proxy connection methods to game module
|
|
7
9
|
* @param {object} game - The game module object
|
|
@@ -58,6 +60,8 @@ export function applyGameProxy(game, Usion) {
|
|
|
58
60
|
self._proxyListenerSetup = true;
|
|
59
61
|
|
|
60
62
|
window.addEventListener('message', function(event) {
|
|
63
|
+
if (!isTrustedMessageSource(event)) return;
|
|
64
|
+
|
|
61
65
|
var data;
|
|
62
66
|
try {
|
|
63
67
|
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
@@ -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,11 @@ 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 { createMatchmakingModule } from './matchmaking.js';
|
|
31
|
+
import { applyBackendChannel } from './backend-channel.js';
|
|
32
|
+
import { netcode } from './netcode/index.js';
|
|
28
33
|
|
|
29
34
|
// Build the Usion object from core
|
|
30
35
|
const Usion = Object.assign({}, core);
|
|
@@ -38,6 +43,14 @@ Usion.chat = createChatModule(Usion);
|
|
|
38
43
|
Usion.bot = createBotModule(Usion);
|
|
39
44
|
Usion.fileStorage = createFileStorageModule(Usion);
|
|
40
45
|
Usion.game = createGameModule(Usion);
|
|
46
|
+
// Unified backend channel (used by lobby etc.; works standalone + embedded).
|
|
47
|
+
applyBackendChannel(Usion);
|
|
48
|
+
Usion.lobby = createLobbyModule(Usion);
|
|
49
|
+
Usion.leaderboard = createLeaderboardModule(Usion);
|
|
50
|
+
Usion.matchmaking = createMatchmakingModule(Usion);
|
|
51
|
+
|
|
52
|
+
// Netcode toolkit (transport-agnostic, zero-dependency).
|
|
53
|
+
Usion.netcode = netcode;
|
|
41
54
|
|
|
42
55
|
// Attach results methods directly on Usion
|
|
43
56
|
Object.assign(Usion, createResultsMethods(Usion));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Leaderboard — scores for mini-games.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in per game (the service must have `leaderboard.enabled`). Rides the
|
|
5
|
+
* unified backend channel, so it works in standalone AND embedded games.
|
|
6
|
+
*
|
|
7
|
+
* await Usion.leaderboard.submit(1500); // your score
|
|
8
|
+
* const friends = await Usion.leaderboard.friends(); // people you've messaged + you
|
|
9
|
+
* const top = await Usion.leaderboard.top(); // global top N
|
|
10
|
+
* const me = await Usion.leaderboard.me(); // { score, rank, total }
|
|
11
|
+
*
|
|
12
|
+
* The **friends** board is scoped to users you've messaged (your conversations)
|
|
13
|
+
* plus yourself — that's the "see the scores of people I've chatted with" view.
|
|
14
|
+
* Entries: { user_id, name, avatar, score, rank, is_me, metadata }.
|
|
15
|
+
*/
|
|
16
|
+
export function createLeaderboardModule(Usion) {
|
|
17
|
+
function serviceId(opts) {
|
|
18
|
+
return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
/** Submit a score (best score is kept, per the service's order config). */
|
|
23
|
+
submit: function (score, metadata, opts) {
|
|
24
|
+
return Usion._backendEmit('lb:submit', { service_id: serviceId(opts), score: score, metadata: metadata != null ? metadata : null });
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/** Leaderboard of people you've messaged (plus you). Returns entries[]. */
|
|
28
|
+
friends: function (opts) {
|
|
29
|
+
opts = opts || {};
|
|
30
|
+
return Usion._backendEmit('lb:friends', { service_id: serviceId(opts), limit: opts.limit || 50 })
|
|
31
|
+
.then(function (r) { return (r && r.entries) || []; });
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Global top N. Returns entries[]. */
|
|
35
|
+
top: function (opts) {
|
|
36
|
+
opts = opts || {};
|
|
37
|
+
return Usion._backendEmit('lb:top', { service_id: serviceId(opts), limit: opts.limit || 20 })
|
|
38
|
+
.then(function (r) { return (r && r.entries) || []; });
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** Your own score + global rank: { score, rank, total }. */
|
|
42
|
+
me: function (opts) {
|
|
43
|
+
return Usion._backendEmit('lb:me', { service_id: serviceId(opts) });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Lobby — parties, ready-up, and matchmaking.
|
|
3
|
+
*
|
|
4
|
+
* The social rendezvous layer that game rooms can't provide: form a party by
|
|
5
|
+
* code, ready up together, and start as a group — plus a thin wrapper over the
|
|
6
|
+
* platform's stranger matchmaking. Rides the platform Socket.IO connection
|
|
7
|
+
* (Usion.game.socket), so connect first with Usion.game.connect().
|
|
8
|
+
*
|
|
9
|
+
* const { code } = await Usion.lobby.create({ maxPlayers: 4 });
|
|
10
|
+
* Usion.lobby.onUpdate(({ members }) => renderLobby(members));
|
|
11
|
+
* await Usion.lobby.setReady(true);
|
|
12
|
+
* // host, once everyone's ready: create a room then start the party in it
|
|
13
|
+
* const room = await Usion.lobby.queue(serviceId); // or your own room API
|
|
14
|
+
* await Usion.lobby.start(room.id);
|
|
15
|
+
* Usion.lobby.onStarted(({ room_id }) => Usion.game.join(room_id));
|
|
16
|
+
*
|
|
17
|
+
* Works in every mode: it rides the unified backend channel (Usion._backendEmit
|
|
18
|
+
* / _backendOn), which uses the SDK's own socket when standalone and relays
|
|
19
|
+
* through the parent app when embedded (iframe/WebView).
|
|
20
|
+
*/
|
|
21
|
+
export function createLobbyModule(Usion) {
|
|
22
|
+
const state = { code: null, host: null, status: null, members: [] };
|
|
23
|
+
const handlers = {};
|
|
24
|
+
let bound = false;
|
|
25
|
+
|
|
26
|
+
function bind() {
|
|
27
|
+
if (bound) return;
|
|
28
|
+
bound = true;
|
|
29
|
+
Usion._backendOn('lobby:update', function (d) {
|
|
30
|
+
state.code = d.code; state.host = d.host; state.status = d.status; state.members = d.members || [];
|
|
31
|
+
if (handlers.update) handlers.update(d);
|
|
32
|
+
});
|
|
33
|
+
Usion._backendOn('lobby:started', function (d) {
|
|
34
|
+
state.status = 'started';
|
|
35
|
+
if (handlers.started) handlers.started(d);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ack(event, data) {
|
|
40
|
+
bind();
|
|
41
|
+
return Usion._backendEmit(event, data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
get state() { return state; },
|
|
46
|
+
|
|
47
|
+
/** Register a handler for lobby membership/ready changes. */
|
|
48
|
+
onUpdate: function (cb) { handlers.update = cb; bind(); },
|
|
49
|
+
/** Register a handler for when the host starts the party. */
|
|
50
|
+
onStarted: function (cb) { handlers.started = cb; bind(); },
|
|
51
|
+
|
|
52
|
+
/** Create a party. Resolves with { code }. You become the host. */
|
|
53
|
+
create: async function (opts) {
|
|
54
|
+
opts = opts || {};
|
|
55
|
+
const r = await ack('lobby:create', { max_players: opts.maxPlayers || 8, public: !!opts.public });
|
|
56
|
+
if (r && r.code) state.code = r.code;
|
|
57
|
+
return r;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/** Join a party by code. */
|
|
61
|
+
join: async function (code) {
|
|
62
|
+
const r = await ack('lobby:join', { code: String(code || '').toUpperCase() });
|
|
63
|
+
if (r && r.code) state.code = r.code;
|
|
64
|
+
return r;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** Leave the current party. */
|
|
68
|
+
leave: function () { state.code = null; return ack('lobby:leave', {}).catch(function () {}); },
|
|
69
|
+
|
|
70
|
+
/** Set your ready state. */
|
|
71
|
+
setReady: function (ready) { return ack('lobby:ready', { ready: ready !== false }); },
|
|
72
|
+
|
|
73
|
+
/** True when every member is ready. */
|
|
74
|
+
allReady: function () { return state.members.length > 0 && state.members.every(function (m) { return m.ready; }); },
|
|
75
|
+
|
|
76
|
+
/** Whether the current user is the party host. */
|
|
77
|
+
isHost: function () {
|
|
78
|
+
const id = Usion.user && Usion.user.getId && Usion.user.getId();
|
|
79
|
+
return !!(state.host && id && state.host === id);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** Host: start the party in an already-created game room. */
|
|
83
|
+
start: function (roomId) { return ack('lobby:start', { room_id: roomId }); },
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stranger matchmaking — find or create a game room for a service via the
|
|
87
|
+
* platform's REST matchmaker. Resolves with the room.
|
|
88
|
+
*/
|
|
89
|
+
queue: async function (serviceId, opts) {
|
|
90
|
+
opts = opts || {};
|
|
91
|
+
const apiUrl = (Usion.config && Usion.config.apiUrl) || '';
|
|
92
|
+
const token = Usion.user && Usion.user.getToken && Usion.user.getToken();
|
|
93
|
+
if (!apiUrl) throw new Error('No apiUrl configured');
|
|
94
|
+
const res = await fetch(apiUrl.replace(/\/$/, '') + '/games/matchmake', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
97
|
+
body: JSON.stringify({ service_id: serviceId, conversation_id: opts.conversationId || ('standalone_' + serviceId) }),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) throw new Error('Matchmake failed: HTTP ' + res.status);
|
|
100
|
+
return res.json();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Matchmaking — pair up with online strangers ("quick match").
|
|
3
|
+
*
|
|
4
|
+
* Lets a mini-app connect players who don't know each other: join a queue, and
|
|
5
|
+
* when enough players are waiting the platform creates a room and matches you.
|
|
6
|
+
* Rides the unified backend channel, so it works standalone AND embedded.
|
|
7
|
+
*
|
|
8
|
+
* const m = await Usion.matchmaking.find(); // resolves when matched
|
|
9
|
+
* await Usion.game.connect(); await Usion.game.join(m.roomId);
|
|
10
|
+
* // ...or cancel while waiting:
|
|
11
|
+
* Usion.matchmaking.cancel();
|
|
12
|
+
* Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
|
|
13
|
+
*/
|
|
14
|
+
export function createMatchmakingModule(Usion) {
|
|
15
|
+
let pending = null; // { resolve, reject } for an in-flight find()
|
|
16
|
+
let onMatchCb = null;
|
|
17
|
+
let bound = false;
|
|
18
|
+
|
|
19
|
+
function normalize(d) {
|
|
20
|
+
return { roomId: d && d.room_id, players: (d && d.player_ids) || [], serviceId: d && d.service_id };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bind() {
|
|
24
|
+
if (bound) return;
|
|
25
|
+
bound = true;
|
|
26
|
+
Usion._backendOn('mm:matched', function (d) {
|
|
27
|
+
const r = normalize(d);
|
|
28
|
+
if (onMatchCb) onMatchCb(r);
|
|
29
|
+
if (pending) { const p = pending; pending = null; p.resolve(r); }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
/** Register a handler called whenever a match is found. */
|
|
35
|
+
onMatch: function (cb) { onMatchCb = cb; bind(); },
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Join the queue for `serviceId` (defaults to the current game) and resolve
|
|
39
|
+
* when matched with { roomId, players, serviceId }. Stays pending until a
|
|
40
|
+
* match (use cancel() to stop waiting).
|
|
41
|
+
*/
|
|
42
|
+
find: function (serviceId, opts) {
|
|
43
|
+
opts = opts || {};
|
|
44
|
+
const sid = serviceId || (Usion.config && Usion.config.serviceId);
|
|
45
|
+
bind();
|
|
46
|
+
const self = this;
|
|
47
|
+
return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
|
|
48
|
+
return new Promise(function (resolve, reject) {
|
|
49
|
+
if (pending) pending.reject(new Error('superseded'));
|
|
50
|
+
pending = { resolve: resolve, reject: reject };
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Leave the queue / stop waiting. */
|
|
56
|
+
cancel: function () {
|
|
57
|
+
if (pending) { pending.reject(new Error('cancelled')); pending = null; }
|
|
58
|
+
return Usion._backendEmit('mm:cancel', {});
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/modules/misc.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Usion SDK Misc — submit, error, exit, share, log, on, requestPayment (legacy)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { isTrustedMessageSource } from './core.js';
|
|
6
|
+
|
|
5
7
|
export const miscMethods = {
|
|
6
8
|
/**
|
|
7
9
|
* Request payment from user (legacy method)
|
|
@@ -108,6 +110,8 @@ export const miscMethods = {
|
|
|
108
110
|
*/
|
|
109
111
|
on: function(type, callback) {
|
|
110
112
|
window.addEventListener('message', function(event) {
|
|
113
|
+
if (!isTrustedMessageSource(event)) return;
|
|
114
|
+
|
|
111
115
|
let data;
|
|
112
116
|
try {
|
|
113
117
|
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|