@genex-ai/multiplayer 0.2.2 → 0.2.6
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 +6 -4
- package/dist/index.js +122 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,11 +87,13 @@ room.leave();
|
|
|
87
87
|
|
|
88
88
|
## Smoothing remote players
|
|
89
89
|
|
|
90
|
-
Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and
|
|
90
|
+
Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the **sender's clock** (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay **self-tunes to the live snapshot rate**, so it works whether you send at 5Hz or 30Hz.
|
|
91
91
|
|
|
92
92
|
- `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
|
|
93
93
|
- **Your own player is never delayed** — you always render yourself live.
|
|
94
|
-
-
|
|
94
|
+
- **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
|
|
95
|
+
- **Send rotation as a quaternion:** `state.q = mesh.quaternion.toArray()`, then on the remote `mesh.quaternion.fromArray(p.state.q)`. The package slerps it and **output-smooths** it so it glides through turns.
|
|
96
|
+
- **Late/dropped packets keep moving:** **position** dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; **rotation** holds its last orientation rather than extrapolating (extrapolating a turn overshoots and snaps — Mirror/Unity interpolate rotation only). Both are output-smoothed, so the catch-up glides. This is what holds up on high-latency links.
|
|
95
97
|
|
|
96
98
|
> No client-side prediction/reconciliation: in this relay you're authoritative over **yourself** (you render instantly, the server never corrects you), so there's nothing to predict — smoothing *remote* players is the piece that matters.
|
|
97
99
|
|
|
@@ -114,7 +116,7 @@ setInterval(() => room.me.set(me), 66);
|
|
|
114
116
|
function frame() {
|
|
115
117
|
for (const [id, p] of room.players) {
|
|
116
118
|
const cube = cubeFor(id); // create-or-reuse a mesh per id
|
|
117
|
-
cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // smoothed
|
|
119
|
+
cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // auto-smoothed
|
|
118
120
|
}
|
|
119
121
|
requestAnimationFrame(frame);
|
|
120
122
|
}
|
|
@@ -134,7 +136,7 @@ onKey('right', () => { me.x += 1; });
|
|
|
134
136
|
- **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
|
|
135
137
|
- **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
|
|
136
138
|
- **`room` (game id) must be stable per game** — use the project slug. Same id = same room; different id = isolated.
|
|
137
|
-
- **
|
|
139
|
+
- **Remote players are smoothed automatically** — read `stateRaw` when you need the raw latest (hit-tests). No server-side prediction — you render yourself live.
|
|
138
140
|
- `me.set` replaces your whole state object — send the full object each time, not a partial.
|
|
139
141
|
|
|
140
142
|
---
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Client, getStateCallbacks } from "colyseus.js";
|
|
3
3
|
var RELAY_ROOM_TYPE = "play";
|
|
4
|
-
var INTERP_RATIO =
|
|
5
|
-
var INTERP_MIN_MS =
|
|
6
|
-
var INTERP_MAX_MS =
|
|
4
|
+
var INTERP_RATIO = 1.5;
|
|
5
|
+
var INTERP_MIN_MS = 50;
|
|
6
|
+
var INTERP_MAX_MS = 300;
|
|
7
7
|
var INTERP_EASE = 0.1;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
var MAX_EXTRAP_MS = 250;
|
|
9
|
+
var CLOCK_WIN = 80;
|
|
10
|
+
var SMOOTH_TAU_POS = 30;
|
|
11
|
+
var SMOOTH_TAU_ROT = 70;
|
|
12
|
+
var SEND_TIME_KEY = "__t";
|
|
13
|
+
function parseData(s) {
|
|
14
|
+
if (!s) return { state: {} };
|
|
10
15
|
try {
|
|
11
16
|
const v = JSON.parse(s);
|
|
12
|
-
|
|
17
|
+
if (!v || typeof v !== "object") return { state: {} };
|
|
18
|
+
const o = v;
|
|
19
|
+
const t = o[SEND_TIME_KEY];
|
|
20
|
+
if (typeof t === "number" && Number.isFinite(t)) {
|
|
21
|
+
const state = { ...o };
|
|
22
|
+
delete state[SEND_TIME_KEY];
|
|
23
|
+
return { state, sendTime: t };
|
|
24
|
+
}
|
|
25
|
+
return { state: o };
|
|
13
26
|
} catch {
|
|
14
|
-
return {};
|
|
27
|
+
return { state: {} };
|
|
15
28
|
}
|
|
16
29
|
}
|
|
17
30
|
function parseValue(s) {
|
|
@@ -23,26 +36,82 @@ function parseValue(s) {
|
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
38
|
var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
26
|
-
function
|
|
39
|
+
function slerp(a, b, t) {
|
|
40
|
+
let [bx, by, bz, bw] = b;
|
|
41
|
+
const [ax, ay, az, aw] = a;
|
|
42
|
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
|
43
|
+
if (dot < 0) {
|
|
44
|
+
bx = -bx;
|
|
45
|
+
by = -by;
|
|
46
|
+
bz = -bz;
|
|
47
|
+
bw = -bw;
|
|
48
|
+
dot = -dot;
|
|
49
|
+
}
|
|
50
|
+
if (dot > 0.9995) {
|
|
51
|
+
const x = ax + (bx - ax) * t, y = ay + (by - ay) * t, z = az + (bz - az) * t, w = aw + (bw - aw) * t;
|
|
52
|
+
const l = Math.hypot(x, y, z, w) || 1;
|
|
53
|
+
return [x / l, y / l, z / l, w / l];
|
|
54
|
+
}
|
|
55
|
+
const theta0 = Math.acos(dot);
|
|
56
|
+
const sin0 = Math.sin(theta0);
|
|
57
|
+
const s0 = Math.sin(theta0 - theta0 * t) / sin0;
|
|
58
|
+
const s1 = Math.sin(theta0 * t) / sin0;
|
|
59
|
+
return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
|
|
60
|
+
}
|
|
61
|
+
function isQuat(v) {
|
|
62
|
+
return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
63
|
+
}
|
|
64
|
+
function blend(a, b, tNum, tQuat) {
|
|
27
65
|
const out = {};
|
|
28
66
|
for (const k in b) {
|
|
29
67
|
const av = a[k];
|
|
30
68
|
const bv = b[k];
|
|
31
|
-
|
|
69
|
+
if (typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv)) {
|
|
70
|
+
out[k] = av + (bv - av) * tNum;
|
|
71
|
+
} else if (isQuat(av) && isQuat(bv)) {
|
|
72
|
+
out[k] = slerp(av, bv, tQuat);
|
|
73
|
+
} else {
|
|
74
|
+
out[k] = bv;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function easeState(prev, target, aNum, aQuat) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const k in target) {
|
|
82
|
+
const tv = target[k];
|
|
83
|
+
const pv = prev[k];
|
|
84
|
+
if (typeof pv === "number" && typeof tv === "number" && Number.isFinite(pv) && Number.isFinite(tv)) {
|
|
85
|
+
out[k] = pv + (tv - pv) * aNum;
|
|
86
|
+
} else if (isQuat(pv) && isQuat(tv)) {
|
|
87
|
+
out[k] = slerp(pv, tv, aQuat);
|
|
88
|
+
} else {
|
|
89
|
+
out[k] = tv;
|
|
90
|
+
}
|
|
32
91
|
}
|
|
33
92
|
return out;
|
|
34
93
|
}
|
|
35
94
|
function sampleBuffer(buf, renderTime) {
|
|
36
95
|
const n = buf.length;
|
|
37
96
|
if (n === 0) return void 0;
|
|
97
|
+
if (n === 1) return buf[0].state;
|
|
38
98
|
let j = 0;
|
|
39
99
|
while (j < n && buf[j].t <= renderTime) j++;
|
|
40
100
|
if (j === 0) return buf[0].state;
|
|
41
|
-
if (j >= n)
|
|
101
|
+
if (j >= n) {
|
|
102
|
+
const a2 = buf[n - 2];
|
|
103
|
+
const b2 = buf[n - 1];
|
|
104
|
+
const span2 = b2.t - a2.t;
|
|
105
|
+
if (span2 <= 0) return b2.state;
|
|
106
|
+
const capped = Math.min(renderTime, b2.t + MAX_EXTRAP_MS);
|
|
107
|
+
const t2 = (capped - a2.t) / span2;
|
|
108
|
+
return blend(a2.state, b2.state, t2, 1);
|
|
109
|
+
}
|
|
42
110
|
const a = buf[j - 1];
|
|
43
111
|
const b = buf[j];
|
|
44
112
|
const span = b.t - a.t;
|
|
45
|
-
|
|
113
|
+
const t = span > 0 ? (renderTime - a.t) / span : 1;
|
|
114
|
+
return blend(a.state, b.state, t, t);
|
|
46
115
|
}
|
|
47
116
|
function median(xs) {
|
|
48
117
|
if (xs.length === 0) return 66;
|
|
@@ -57,13 +126,25 @@ async function connect(opts) {
|
|
|
57
126
|
...opts.options
|
|
58
127
|
});
|
|
59
128
|
const buffers = /* @__PURE__ */ new Map();
|
|
60
|
-
const
|
|
129
|
+
const lastStamp = /* @__PURE__ */ new Map();
|
|
130
|
+
const clockOffsets = /* @__PURE__ */ new Map();
|
|
131
|
+
const offsetWin = /* @__PURE__ */ new Map();
|
|
132
|
+
const smoothed = /* @__PURE__ */ new Map();
|
|
133
|
+
const smoothedAt = /* @__PURE__ */ new Map();
|
|
61
134
|
const intervals = [];
|
|
62
135
|
let autoDelay = 100;
|
|
63
|
-
const pushSnap = (id, state) => {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
136
|
+
const pushSnap = (id, state, sendTime) => {
|
|
137
|
+
const arrival = nowMs();
|
|
138
|
+
const t = sendTime ?? arrival;
|
|
139
|
+
if (sendTime != null) {
|
|
140
|
+
let win = offsetWin.get(id);
|
|
141
|
+
if (!win) offsetWin.set(id, win = []);
|
|
142
|
+
win.push(arrival - sendTime);
|
|
143
|
+
if (win.length > CLOCK_WIN) win.shift();
|
|
144
|
+
clockOffsets.set(id, Math.min(...win));
|
|
145
|
+
}
|
|
146
|
+
const prev = lastStamp.get(id);
|
|
147
|
+
lastStamp.set(id, t);
|
|
67
148
|
if (prev != null) {
|
|
68
149
|
const gap = t - prev;
|
|
69
150
|
if (gap > 5 && gap < 1e3) {
|
|
@@ -85,19 +166,23 @@ async function connect(opts) {
|
|
|
85
166
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
86
167
|
const $ = getStateCallbacks(room);
|
|
87
168
|
$(room.state).players.onAdd((player, id) => {
|
|
88
|
-
const
|
|
89
|
-
emit("join", id,
|
|
90
|
-
if (id !== room.sessionId) pushSnap(id,
|
|
169
|
+
const { state, sendTime } = parseData(player.data);
|
|
170
|
+
emit("join", id, state);
|
|
171
|
+
if (id !== room.sessionId) pushSnap(id, state, sendTime);
|
|
91
172
|
$(player).onChange(() => {
|
|
92
|
-
const st =
|
|
173
|
+
const { state: st, sendTime: ts } = parseData(player.data);
|
|
93
174
|
emit("change", id, st);
|
|
94
|
-
if (id !== room.sessionId) pushSnap(id, st);
|
|
175
|
+
if (id !== room.sessionId) pushSnap(id, st, ts);
|
|
95
176
|
});
|
|
96
177
|
});
|
|
97
178
|
$(room.state).players.onRemove((_player, id) => {
|
|
98
179
|
emit("leave", id);
|
|
99
180
|
buffers.delete(id);
|
|
100
|
-
|
|
181
|
+
lastStamp.delete(id);
|
|
182
|
+
clockOffsets.delete(id);
|
|
183
|
+
offsetWin.delete(id);
|
|
184
|
+
smoothed.delete(id);
|
|
185
|
+
smoothedAt.delete(id);
|
|
101
186
|
});
|
|
102
187
|
$(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
|
|
103
188
|
const on = ((event, cb) => {
|
|
@@ -116,14 +201,25 @@ async function connect(opts) {
|
|
|
116
201
|
id: room.sessionId,
|
|
117
202
|
get players() {
|
|
118
203
|
const out = /* @__PURE__ */ new Map();
|
|
119
|
-
const
|
|
204
|
+
const localNow = nowMs();
|
|
120
205
|
room.state?.players?.forEach((p, id) => {
|
|
121
|
-
const latest =
|
|
206
|
+
const { state: latest } = parseData(p.data);
|
|
122
207
|
let state = latest;
|
|
123
208
|
if (id !== room.sessionId) {
|
|
124
209
|
const buf = buffers.get(id);
|
|
210
|
+
const renderTime = localNow - (clockOffsets.get(id) ?? 0) - autoDelay;
|
|
125
211
|
const sampled = buf && sampleBuffer(buf, renderTime);
|
|
126
|
-
if (sampled)
|
|
212
|
+
if (sampled) {
|
|
213
|
+
const prev = smoothed.get(id);
|
|
214
|
+
if (prev) {
|
|
215
|
+
const dt = Math.min(250, Math.max(0, localNow - (smoothedAt.get(id) ?? localNow)));
|
|
216
|
+
state = easeState(prev, sampled, 1 - Math.exp(-dt / SMOOTH_TAU_POS), 1 - Math.exp(-dt / SMOOTH_TAU_ROT));
|
|
217
|
+
} else {
|
|
218
|
+
state = sampled;
|
|
219
|
+
}
|
|
220
|
+
smoothed.set(id, state);
|
|
221
|
+
smoothedAt.set(id, localNow);
|
|
222
|
+
}
|
|
127
223
|
}
|
|
128
224
|
out.set(id, { id, name: p.name, state, stateRaw: latest });
|
|
129
225
|
});
|
|
@@ -131,7 +227,7 @@ async function connect(opts) {
|
|
|
131
227
|
},
|
|
132
228
|
me: {
|
|
133
229
|
set(state) {
|
|
134
|
-
room.send("state", JSON.stringify(state));
|
|
230
|
+
room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
|
|
135
231
|
}
|
|
136
232
|
},
|
|
137
233
|
shared: {
|