@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Netcode — Snapshot interpolation.
|
|
3
|
+
*
|
|
4
|
+
* Render every entity slightly in the past and interpolate between the two
|
|
5
|
+
* snapshots straddling "renderTime", so motion stays smooth and late /
|
|
6
|
+
* duplicate / out-of-order / dropped packets are absorbed for free. This is
|
|
7
|
+
* the single biggest perceived-lag fix for real-time games.
|
|
8
|
+
*
|
|
9
|
+
* Practices adopted (Valve Source "Interpolation" + geckos.io):
|
|
10
|
+
* - Interpolation delay (buffer) sized in server-frames, with an **adaptive**
|
|
11
|
+
* mode that grows the buffer with measured network jitter (cl_interp_ratio).
|
|
12
|
+
* - **Capped extrapolation** when the buffer underruns (packet loss): project
|
|
13
|
+
* forward from the last known velocity, bounded (Source caps at 0.25s) so
|
|
14
|
+
* prediction error stays small.
|
|
15
|
+
* - Optional **server-time domain**: if snapshots carry a server `time`, render
|
|
16
|
+
* against an estimated server clock (robust to bursty arrival). Default stays
|
|
17
|
+
* the client arrival-time domain — zero clock sync required.
|
|
18
|
+
*
|
|
19
|
+
* Key-spec syntax: 'x y angle(deg) r(rad)'.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function shortestAngle(a, b, twoPi) {
|
|
23
|
+
let d = (b - a) % twoPi;
|
|
24
|
+
const half = twoPi / 2;
|
|
25
|
+
if (d > half) d -= twoPi;
|
|
26
|
+
if (d < -half) d += twoPi;
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseKeys(spec) {
|
|
31
|
+
return String(spec || '')
|
|
32
|
+
.trim()
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((tok) => {
|
|
36
|
+
const m = tok.match(/^(.+?)\((deg|rad)\)$/);
|
|
37
|
+
return m ? { key: m[1], type: m[2] } : { key: tok, type: 'linear' };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lerpField(a, b, t, type) {
|
|
42
|
+
if (type === 'deg') return a + shortestAngle(a, b, 360) * t;
|
|
43
|
+
if (type === 'rad') return a + shortestAngle(a, b, Math.PI * 2) * t;
|
|
44
|
+
return a + (b - a) * t;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Time-ordered ring buffer of snapshots. Newest last. */
|
|
48
|
+
export class Vault {
|
|
49
|
+
constructor(maxSize = 120) {
|
|
50
|
+
this._items = [];
|
|
51
|
+
this._maxSize = maxSize;
|
|
52
|
+
}
|
|
53
|
+
add(snapshot) {
|
|
54
|
+
this._items.push(snapshot);
|
|
55
|
+
if (this._items.length > this._maxSize) this._items.shift();
|
|
56
|
+
}
|
|
57
|
+
get size() { return this._items.length; }
|
|
58
|
+
setMaxSize(n) { this._maxSize = Math.max(2, n | 0); }
|
|
59
|
+
latest() { return this._items.length ? this._items[this._items.length - 1] : null; }
|
|
60
|
+
prevLatest() { return this._items.length >= 2 ? this._items[this._items.length - 2] : null; }
|
|
61
|
+
clear() { this._items = []; }
|
|
62
|
+
|
|
63
|
+
/** [older, newer] snapshots straddling `time`, clamping at the ends. */
|
|
64
|
+
straddle(time) {
|
|
65
|
+
const items = this._items;
|
|
66
|
+
if (items.length === 0) return [null, null];
|
|
67
|
+
if (items.length === 1) return [items[0], items[0]];
|
|
68
|
+
if (time <= items[0].time) return [items[0], items[0]];
|
|
69
|
+
const last = items[items.length - 1];
|
|
70
|
+
if (time >= last.time) return [last, last];
|
|
71
|
+
for (let i = items.length - 1; i > 0; i--) {
|
|
72
|
+
if (items[i - 1].time <= time && time <= items[i].time) return [items[i - 1], items[i]];
|
|
73
|
+
}
|
|
74
|
+
return [last, last];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function asEntityArray(state, group) {
|
|
79
|
+
if (group) return Array.isArray(state[group]) ? state[group] : [];
|
|
80
|
+
return Array.isArray(state) ? state : [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class SnapshotInterpolation {
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} [opts]
|
|
86
|
+
* @param {number} [opts.serverFps=20] Expected snapshot rate (sets default buffer).
|
|
87
|
+
* @param {number} [opts.bufferMs] Fixed interpolation delay (default ≈ 3 frames).
|
|
88
|
+
* @param {boolean} [opts.adaptive=false] Grow the buffer with measured jitter.
|
|
89
|
+
* @param {number} [opts.minBufferMs] Lower clamp for adaptive buffer.
|
|
90
|
+
* @param {number} [opts.maxBufferMs] Upper clamp for adaptive buffer.
|
|
91
|
+
* @param {number} [opts.extrapolationMs=0] Max forward extrapolation on underrun (0 = off).
|
|
92
|
+
* @param {boolean} [opts.serverTime=false] Use snapshot.time (server clock) domain.
|
|
93
|
+
* @param {number} [opts.maxSize=120]
|
|
94
|
+
* @param {function} [opts.now]
|
|
95
|
+
*/
|
|
96
|
+
constructor(opts = {}) {
|
|
97
|
+
const serverFps = opts.serverFps || 20;
|
|
98
|
+
this._frameMs = 1000 / serverFps;
|
|
99
|
+
this.vault = new Vault(opts.maxSize || 120);
|
|
100
|
+
this._fixedBuffer = opts.bufferMs != null ? opts.bufferMs : Math.ceil(this._frameMs * 3);
|
|
101
|
+
this._bufferMs = this._fixedBuffer;
|
|
102
|
+
this._adaptive = !!opts.adaptive;
|
|
103
|
+
this._minBuffer = opts.minBufferMs != null ? opts.minBufferMs : Math.ceil(this._frameMs * 2);
|
|
104
|
+
this._maxBuffer = opts.maxBufferMs != null ? opts.maxBufferMs : Math.ceil(this._frameMs * 8);
|
|
105
|
+
this._extrapolationMs = opts.extrapolationMs || 0;
|
|
106
|
+
this._useServerTime = !!opts.serverTime;
|
|
107
|
+
this._now = opts.now || (() => Date.now());
|
|
108
|
+
|
|
109
|
+
// adaptive/jitter + server-clock estimation state
|
|
110
|
+
this._lastArrival = null;
|
|
111
|
+
this._avgInterval = this._frameMs;
|
|
112
|
+
this._jitter = 0;
|
|
113
|
+
this._offset = null; // EWMA(arrival - serverTime)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getBufferMs() { return this._bufferMs; }
|
|
117
|
+
setBufferMs(ms) { this._fixedBuffer = Math.max(0, ms | 0); this._bufferMs = this._fixedBuffer; }
|
|
118
|
+
getJitter() { return this._jitter; }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Add a received snapshot. `state` is an entity array (each needs a stable
|
|
122
|
+
* `id`) or a map of named groups → entity arrays. Pass `{ state, time }` to
|
|
123
|
+
* supply a server timestamp for the server-time domain.
|
|
124
|
+
*/
|
|
125
|
+
add(snapshot) {
|
|
126
|
+
if (!snapshot) return;
|
|
127
|
+
const arrival = this._now();
|
|
128
|
+
const state = snapshot.state !== undefined ? snapshot.state : snapshot;
|
|
129
|
+
const serverTime = snapshot.time;
|
|
130
|
+
|
|
131
|
+
// Jitter / interval estimate (EWMA), used by the adaptive buffer.
|
|
132
|
+
if (this._lastArrival != null) {
|
|
133
|
+
const interval = arrival - this._lastArrival;
|
|
134
|
+
this._avgInterval += 0.1 * (interval - this._avgInterval);
|
|
135
|
+
this._jitter += 0.1 * (Math.abs(interval - this._avgInterval) - this._jitter);
|
|
136
|
+
if (this._adaptive) {
|
|
137
|
+
const target = this._avgInterval + 2 * this._jitter;
|
|
138
|
+
this._bufferMs = Math.max(this._minBuffer, Math.min(this._maxBuffer, target));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this._lastArrival = arrival;
|
|
142
|
+
|
|
143
|
+
// Server-clock offset estimate.
|
|
144
|
+
if (this._useServerTime && typeof serverTime === 'number') {
|
|
145
|
+
const o = arrival - serverTime;
|
|
146
|
+
this._offset = this._offset == null ? o : this._offset + 0.05 * (o - this._offset);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Stamp the time used for the interpolation domain.
|
|
150
|
+
const time = (this._useServerTime && typeof serverTime === 'number') ? serverTime : arrival;
|
|
151
|
+
this.vault.add({ time, state });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_renderTime() {
|
|
155
|
+
if (this._useServerTime && this._offset != null) return (this._now() - this._offset) - this._bufferMs;
|
|
156
|
+
return this._now() - this._bufferMs;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Interpolated entities for the current render instant.
|
|
161
|
+
* @param {string} keys e.g. 'x y' or 'x y angle(deg)'
|
|
162
|
+
* @param {string} [group]
|
|
163
|
+
* @returns {Array|null}
|
|
164
|
+
*/
|
|
165
|
+
calc(keys, group) {
|
|
166
|
+
const renderTime = this._renderTime();
|
|
167
|
+
const latest = this.vault.latest();
|
|
168
|
+
if (!latest) return null;
|
|
169
|
+
const specs = parseKeys(keys);
|
|
170
|
+
|
|
171
|
+
// Buffer underrun: render time is ahead of the newest snapshot.
|
|
172
|
+
if (renderTime > latest.time) {
|
|
173
|
+
if (this._extrapolationMs > 0) return this._extrapolate(renderTime, specs, group);
|
|
174
|
+
return asEntityArray(latest.state, group).map((e) => Object.assign({}, e));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [older, newer] = this.vault.straddle(renderTime);
|
|
178
|
+
if (!older || !newer) return null;
|
|
179
|
+
|
|
180
|
+
let t = 0;
|
|
181
|
+
if (newer.time !== older.time) {
|
|
182
|
+
t = (renderTime - older.time) / (newer.time - older.time);
|
|
183
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
184
|
+
}
|
|
185
|
+
return this._blend(asEntityArray(older.state, group), asEntityArray(newer.state, group), t, specs);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_blend(oldEntities, newEntities, t, specs) {
|
|
189
|
+
const oldById = {};
|
|
190
|
+
for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
|
|
191
|
+
return newEntities.map((nentity) => {
|
|
192
|
+
const oentity = oldById[nentity.id];
|
|
193
|
+
const out = Object.assign({}, nentity);
|
|
194
|
+
if (oentity) {
|
|
195
|
+
for (let i = 0; i < specs.length; i++) {
|
|
196
|
+
const { key, type } = specs[i];
|
|
197
|
+
const a = oentity[key];
|
|
198
|
+
const b = nentity[key];
|
|
199
|
+
if (typeof a === 'number' && typeof b === 'number') out[key] = lerpField(a, b, t, type);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_extrapolate(renderTime, specs, group) {
|
|
207
|
+
const newer = this.vault.latest();
|
|
208
|
+
const older = this.vault.prevLatest();
|
|
209
|
+
const newEntities = asEntityArray(newer.state, group);
|
|
210
|
+
if (!older || newer.time <= older.time) return newEntities.map((e) => Object.assign({}, e));
|
|
211
|
+
|
|
212
|
+
const dt = Math.min(renderTime - newer.time, this._extrapolationMs); // cap
|
|
213
|
+
const span = newer.time - older.time;
|
|
214
|
+
const oldById = {};
|
|
215
|
+
const oldEntities = asEntityArray(older.state, group);
|
|
216
|
+
for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
|
|
217
|
+
|
|
218
|
+
return newEntities.map((nentity) => {
|
|
219
|
+
const oentity = oldById[nentity.id];
|
|
220
|
+
const out = Object.assign({}, nentity);
|
|
221
|
+
if (oentity) {
|
|
222
|
+
for (let i = 0; i < specs.length; i++) {
|
|
223
|
+
const { key } = specs[i]; // linear extrapolation (angles handled as linear here)
|
|
224
|
+
const a = oentity[key];
|
|
225
|
+
const b = nentity[key];
|
|
226
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
227
|
+
const vel = (b - a) / span;
|
|
228
|
+
out[key] = b + vel * dt;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Netcode — server-side lag compensation ("server rewind").
|
|
3
|
+
*
|
|
4
|
+
* The fairness technique behind CS:GO/CS2 and Valorant. The authoritative
|
|
5
|
+
* server records a short history of world snapshots; when a client claims an
|
|
6
|
+
* action (e.g. a shot) the server **rewinds** the world to what that client
|
|
7
|
+
* actually saw — accounting for their latency and interpolation delay — and
|
|
8
|
+
* resolves the hit against that past state. Without this, high-ping players
|
|
9
|
+
* must "lead" their targets and hit registration feels broken.
|
|
10
|
+
*
|
|
11
|
+
* Entities are matched by `id` and interpolated between the two recorded
|
|
12
|
+
* snapshots straddling the rewind time (same math as client interpolation).
|
|
13
|
+
* Pure and testable; use it inside your game server (direct mode).
|
|
14
|
+
*/
|
|
15
|
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
16
|
+
|
|
17
|
+
export class LagCompensator {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {number} [opts.historyMs=1000] How far back to retain snapshots.
|
|
21
|
+
* @param {number} [opts.maxSize=256] Hard cap on retained snapshots.
|
|
22
|
+
* @param {function} [opts.now] Clock source (default Date.now).
|
|
23
|
+
*/
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
this._historyMs = opts.historyMs || 1000;
|
|
26
|
+
this._maxSize = opts.maxSize || 256;
|
|
27
|
+
this._now = opts.now || (() => Date.now());
|
|
28
|
+
this._history = []; // [{ time, entities:[{id,...}] }] oldest→newest
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record the authoritative world state for this tick.
|
|
33
|
+
* @param {Array} entities Array of entities (each with a stable `id`).
|
|
34
|
+
* @param {number} [time] Server time (default now()).
|
|
35
|
+
*/
|
|
36
|
+
record(entities, time) {
|
|
37
|
+
const t = time != null ? time : this._now();
|
|
38
|
+
this._history.push({ time: t, entities: entities.map((e) => Object.assign({}, e)) });
|
|
39
|
+
const cutoff = t - this._historyMs;
|
|
40
|
+
while (this._history.length > this._maxSize || (this._history.length > 2 && this._history[0].time < cutoff)) {
|
|
41
|
+
this._history.shift();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get size() { return this._history.length; }
|
|
46
|
+
clear() { this._history = []; }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reconstruct the world as it was at `time` (interpolated), keyed by id.
|
|
50
|
+
* @param {number} time Absolute server time to rewind to.
|
|
51
|
+
* @param {string[]} [keys] Numeric fields to interpolate (default: all numbers).
|
|
52
|
+
* @returns {Object<string, object>} entities by id, or {} if no history.
|
|
53
|
+
*/
|
|
54
|
+
rewind(time, keys) {
|
|
55
|
+
const h = this._history;
|
|
56
|
+
if (h.length === 0) return {};
|
|
57
|
+
if (time <= h[0].time) return byId(h[0].entities);
|
|
58
|
+
if (time >= h[h.length - 1].time) return byId(h[h.length - 1].entities);
|
|
59
|
+
|
|
60
|
+
let older = h[0], newer = h[h.length - 1];
|
|
61
|
+
for (let i = h.length - 1; i > 0; i--) {
|
|
62
|
+
if (h[i - 1].time <= time && time <= h[i].time) { older = h[i - 1]; newer = h[i]; break; }
|
|
63
|
+
}
|
|
64
|
+
const span = newer.time - older.time;
|
|
65
|
+
const t = span > 0 ? (time - older.time) / span : 0;
|
|
66
|
+
const oldById = byId(older.entities);
|
|
67
|
+
const out = {};
|
|
68
|
+
const newById = byId(newer.entities);
|
|
69
|
+
for (const id in newById) {
|
|
70
|
+
const b = newById[id];
|
|
71
|
+
const a = oldById[id];
|
|
72
|
+
const e = Object.assign({}, b);
|
|
73
|
+
if (a) {
|
|
74
|
+
const fields = keys || Object.keys(b);
|
|
75
|
+
for (let k = 0; k < fields.length; k++) {
|
|
76
|
+
const key = fields[k];
|
|
77
|
+
if (typeof a[key] === 'number' && typeof b[key] === 'number') e[key] = lerp(a[key], b[key], t);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
out[id] = e;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Convenience: rewind to what a client saw, given their measured round-trip
|
|
87
|
+
* time and interpolation buffer. rewindMs is clamped to `maxRewindMs` (cap
|
|
88
|
+
* it — Valorant caps ~35ms; CS allows more).
|
|
89
|
+
* @returns {Object<string, object>} entities by id
|
|
90
|
+
*/
|
|
91
|
+
rewindForClient(rttMs, interpBufferMs, opts) {
|
|
92
|
+
opts = opts || {};
|
|
93
|
+
const maxRewind = opts.maxRewindMs != null ? opts.maxRewindMs : 250;
|
|
94
|
+
let rewindMs = (rttMs || 0) / 2 + (interpBufferMs || 0);
|
|
95
|
+
if (rewindMs > maxRewind) rewindMs = maxRewind;
|
|
96
|
+
return this.rewind(this._now() - rewindMs, opts.keys);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function byId(entities) {
|
|
101
|
+
const m = {};
|
|
102
|
+
for (let i = 0; i < entities.length; i++) m[entities[i].id] = entities[i];
|
|
103
|
+
return m;
|
|
104
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Netcode — deterministic lockstep (the League-of-Legends / RTS model).
|
|
3
|
+
*
|
|
4
|
+
* Instead of streaming world state, every client runs the **same deterministic
|
|
5
|
+
* simulation** and exchanges only **inputs**. Bandwidth is tiny even with
|
|
6
|
+
* hundreds of units, and you get **replays for free** (re-run the input log).
|
|
7
|
+
*
|
|
8
|
+
* A frame advances only once every player's input for that frame is known.
|
|
9
|
+
* Local inputs are scheduled `inputDelay` frames ahead so they have time to
|
|
10
|
+
* reach peers before that frame is simulated (this hides latency). The game
|
|
11
|
+
* owns its simulation; this class only orders inputs and tells you when to step.
|
|
12
|
+
*
|
|
13
|
+
* Determinism is the contract: your `step(frame, inputsByPlayer)` must be
|
|
14
|
+
* fully deterministic (no Date.now/Math.random without a seeded source, no
|
|
15
|
+
* floating-point divergence) or clients will desync.
|
|
16
|
+
*/
|
|
17
|
+
export class Lockstep {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} opts.playerId
|
|
21
|
+
* @param {string[]} opts.players All player ids in the match.
|
|
22
|
+
* @param {(frame:number, inputs:Object)=>void} opts.step Deterministic sim step.
|
|
23
|
+
* @param {(msg:{frame:number,playerId:string,input:any})=>void} [opts.send]
|
|
24
|
+
* @param {number} [opts.inputDelay=2] Frames of input delay (latency hiding).
|
|
25
|
+
* @param {any} [opts.idleInput=null] Input used for the seeded startup frames.
|
|
26
|
+
*/
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
if (!opts.playerId) throw new Error('Lockstep requires playerId');
|
|
29
|
+
if (!Array.isArray(opts.players) || opts.players.length === 0) throw new Error('Lockstep requires players[]');
|
|
30
|
+
if (typeof opts.step !== 'function') throw new Error('Lockstep requires a step(frame, inputs) function');
|
|
31
|
+
this.playerId = opts.playerId;
|
|
32
|
+
this._players = opts.players.slice();
|
|
33
|
+
this._step = opts.step;
|
|
34
|
+
this._send = opts.send || function () {};
|
|
35
|
+
this._inputDelay = opts.inputDelay != null ? opts.inputDelay : 2;
|
|
36
|
+
this._idle = opts.idleInput != null ? opts.idleInput : null;
|
|
37
|
+
|
|
38
|
+
this._inputs = {}; // frame -> { playerId: input }
|
|
39
|
+
this._frame = 0; // next frame to simulate
|
|
40
|
+
this._submitFrame = this._inputDelay; // next frame the local player will fill
|
|
41
|
+
this._replay = []; // [{ frame, inputs }]
|
|
42
|
+
|
|
43
|
+
// Seed the first `inputDelay` frames as idle so the sim can start.
|
|
44
|
+
for (let f = 0; f < this._inputDelay; f++) {
|
|
45
|
+
this._inputs[f] = {};
|
|
46
|
+
for (let i = 0; i < this._players.length; i++) this._inputs[f][this._players[i]] = this._idle;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get frame() { return this._frame; }
|
|
51
|
+
get players() { return this._players.slice(); }
|
|
52
|
+
|
|
53
|
+
/** Submit the local player's input for the next schedulable frame. */
|
|
54
|
+
submit(input) {
|
|
55
|
+
const frame = this._submitFrame++;
|
|
56
|
+
if (!this._inputs[frame]) this._inputs[frame] = {};
|
|
57
|
+
this._inputs[frame][this.playerId] = input;
|
|
58
|
+
this._send({ frame: frame, playerId: this.playerId, input: input });
|
|
59
|
+
return frame;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Record a remote player's input. */
|
|
63
|
+
receive(msg) {
|
|
64
|
+
if (!msg || msg.frame == null || !msg.playerId) return;
|
|
65
|
+
if (!this._inputs[msg.frame]) this._inputs[msg.frame] = {};
|
|
66
|
+
this._inputs[msg.frame][msg.playerId] = msg.input;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_ready(frame) {
|
|
70
|
+
const fr = this._inputs[frame];
|
|
71
|
+
if (!fr) return false;
|
|
72
|
+
for (let i = 0; i < this._players.length; i++) if (!(this._players[i] in fr)) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Advance the simulation by every frame whose inputs are fully known.
|
|
78
|
+
* @returns {number} frames advanced this call.
|
|
79
|
+
*/
|
|
80
|
+
tick() {
|
|
81
|
+
let advanced = 0;
|
|
82
|
+
while (this._ready(this._frame)) {
|
|
83
|
+
const inputs = this._inputs[this._frame];
|
|
84
|
+
this._step(this._frame, inputs);
|
|
85
|
+
this._replay.push({ frame: this._frame, inputs: inputs });
|
|
86
|
+
delete this._inputs[this._frame];
|
|
87
|
+
this._frame += 1;
|
|
88
|
+
advanced += 1;
|
|
89
|
+
}
|
|
90
|
+
return advanced;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True if the sim is blocked waiting on a missing input (stall detection). */
|
|
94
|
+
isStalled() { return !this._ready(this._frame); }
|
|
95
|
+
|
|
96
|
+
/** The ordered input log — persist it to enable replays. */
|
|
97
|
+
getReplay() { return this._replay.slice(); }
|
|
98
|
+
|
|
99
|
+
/** Re-simulate a recorded match by replaying its input log. */
|
|
100
|
+
static replay(log, step) {
|
|
101
|
+
for (let i = 0; i < log.length; i++) step(log[i].frame, log[i].inputs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Netcode — N-peer full mesh.
|
|
3
|
+
*
|
|
4
|
+
* Manages one MeshConnection per remote peer so >2 players can talk directly
|
|
5
|
+
* peer-to-peer. For each pair, the peer with the lexicographically smaller id
|
|
6
|
+
* is the host (deterministic role assignment avoids offer/answer "glare").
|
|
7
|
+
* Signaling is routed per-peer (`sendSignal(toPeerId, payload)`); feed inbound
|
|
8
|
+
* signaling with `handleSignal(fromPeerId, payload)`.
|
|
9
|
+
*/
|
|
10
|
+
import { MeshConnection } from './mesh.js';
|
|
11
|
+
|
|
12
|
+
export class MeshNetwork {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {string} opts.selfId
|
|
16
|
+
* @param {(toPeerId:string, payload:object)=>void} opts.sendSignal
|
|
17
|
+
* @param {Array} [opts.iceServers]
|
|
18
|
+
* @param {Function} [opts.RTCPeerConnection]
|
|
19
|
+
* @param {Function} [opts.setTimeout]
|
|
20
|
+
* @param {boolean} [opts.sequenced]
|
|
21
|
+
* @param {boolean} [opts.autoReconnect]
|
|
22
|
+
*/
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
if (!opts.selfId) throw new Error('MeshNetwork requires a selfId');
|
|
25
|
+
if (typeof opts.sendSignal !== 'function') throw new Error('MeshNetwork requires sendSignal(toPeerId, payload)');
|
|
26
|
+
this.selfId = opts.selfId;
|
|
27
|
+
this._sendSignal = opts.sendSignal;
|
|
28
|
+
this._opts = opts;
|
|
29
|
+
this._peers = {}; // peerId -> MeshConnection
|
|
30
|
+
|
|
31
|
+
this.onPeerOpen = null; // (peerId) => void
|
|
32
|
+
this.onPeerClose = null; // (peerId) => void
|
|
33
|
+
this.onMessage = null; // (peerId, data, channel) => void
|
|
34
|
+
this.onError = null; // (peerId, err) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get peerIds() { return Object.keys(this._peers); }
|
|
38
|
+
get connectedCount() { let n = 0; for (const id in this._peers) if (this._peers[id].connected) n += 1; return n; }
|
|
39
|
+
peer(peerId) { return this._peers[peerId] || null; }
|
|
40
|
+
|
|
41
|
+
_roleFor(peerId) { return this.selfId < peerId ? 'host' : 'guest'; }
|
|
42
|
+
|
|
43
|
+
_ensurePeer(peerId) {
|
|
44
|
+
if (this._peers[peerId]) return this._peers[peerId];
|
|
45
|
+
const self = this;
|
|
46
|
+
const conn = new MeshConnection({
|
|
47
|
+
role: this._roleFor(peerId),
|
|
48
|
+
iceServers: this._opts.iceServers,
|
|
49
|
+
RTCPeerConnection: this._opts.RTCPeerConnection,
|
|
50
|
+
setTimeout: this._opts.setTimeout,
|
|
51
|
+
sequenced: this._opts.sequenced,
|
|
52
|
+
autoReconnect: this._opts.autoReconnect,
|
|
53
|
+
sendSignal: (payload) => self._sendSignal(peerId, payload),
|
|
54
|
+
});
|
|
55
|
+
conn.onOpen = () => { if (self.onPeerOpen) self.onPeerOpen(peerId); };
|
|
56
|
+
conn.onClose = () => { if (self.onPeerClose) self.onPeerClose(peerId); };
|
|
57
|
+
conn.onMessage = (data, channel) => { if (self.onMessage) self.onMessage(peerId, data, channel); };
|
|
58
|
+
conn.onError = (err) => { if (self.onError) self.onError(peerId, err); };
|
|
59
|
+
this._peers[peerId] = conn;
|
|
60
|
+
return conn;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Connect to a peer (creates the connection and starts negotiation). */
|
|
64
|
+
async addPeer(peerId) {
|
|
65
|
+
if (peerId === this.selfId) return null;
|
|
66
|
+
const conn = this._ensurePeer(peerId);
|
|
67
|
+
await conn.start();
|
|
68
|
+
return conn;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sync the mesh to a roster of peer ids: connect to new ones, drop missing. */
|
|
72
|
+
async setRoster(peerIds) {
|
|
73
|
+
const want = {};
|
|
74
|
+
for (let i = 0; i < peerIds.length; i++) if (peerIds[i] !== this.selfId) want[peerIds[i]] = true;
|
|
75
|
+
for (const id in want) if (!this._peers[id]) await this.addPeer(id);
|
|
76
|
+
for (const id in this._peers) if (!want[id]) this.removePeer(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
removePeer(peerId) {
|
|
80
|
+
const conn = this._peers[peerId];
|
|
81
|
+
if (!conn) return;
|
|
82
|
+
try { conn.close(); } catch (_) {}
|
|
83
|
+
delete this._peers[peerId];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Route an inbound signaling message from a peer. */
|
|
87
|
+
async handleSignal(fromPeerId, payload) {
|
|
88
|
+
if (!fromPeerId || fromPeerId === this.selfId) return;
|
|
89
|
+
const conn = this._ensurePeer(fromPeerId);
|
|
90
|
+
if (!conn._pc) await conn.start(); // lazily start guest side on first offer
|
|
91
|
+
await conn.handleSignal(payload);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Send to one peer over the unreliable channel. */
|
|
95
|
+
send(peerId, data) { const c = this._peers[peerId]; return c ? c.send(data) : false; }
|
|
96
|
+
sendReliable(peerId, data) { const c = this._peers[peerId]; return c ? c.sendReliable(data) : false; }
|
|
97
|
+
|
|
98
|
+
/** Broadcast to all connected peers (unreliable). */
|
|
99
|
+
broadcast(data) { for (const id in this._peers) this._peers[id].send(data); }
|
|
100
|
+
broadcastReliable(data) { for (const id in this._peers) this._peers[id].sendReliable(data); }
|
|
101
|
+
|
|
102
|
+
close() { for (const id in this._peers) { try { this._peers[id].close(); } catch (_) {} } this._peers = {}; }
|
|
103
|
+
}
|