@genex-ai/multiplayer 0.2.2 → 0.4.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 +44 -4
- package/dist/index.d.ts +57 -3
- package/dist/index.js +276 -41
- package/package.json +8 -9
package/README.md
CHANGED
|
@@ -57,6 +57,42 @@ room.shared.get('round'); // 3
|
|
|
57
57
|
room.shared.keys(); // ['round', ...]
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
## `objects` — shared objects nobody owns until claimed
|
|
61
|
+
|
|
62
|
+
For a **ball, a puck, an NPC** — a moving thing that isn't any one player. Exactly one client
|
|
63
|
+
**owns** an object at a time (the server enforces it); the owner simulates it and `set`s it, and
|
|
64
|
+
everyone else reads it **auto-smoothed**, on the same interpolation as remote players. Ownership
|
|
65
|
+
survives the owner leaving — the object is reassigned to the room host.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
if (iKicked) room.objects.claim('ball'); // take ownership (last claim wins)
|
|
69
|
+
|
|
70
|
+
if (room.objects.get('ball')?.isMine) {
|
|
71
|
+
room.objects.set('ball', simulateBall()); // only the owner's writes land
|
|
72
|
+
}
|
|
73
|
+
const ball = room.objects.get('ball'); // { owner, isMine, state, stateRaw }
|
|
74
|
+
drawBall(ball.state); // smoothed for everyone (live for the owner)
|
|
75
|
+
|
|
76
|
+
room.objects.release('ball'); // give it up (stays until reclaimed)
|
|
77
|
+
room.objects.remove('bullet'); // destroy transient objects when done
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
|
|
81
|
+
player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.ids()` lists them.
|
|
82
|
+
|
|
83
|
+
## `isHost` / `host` — the elected room authority
|
|
84
|
+
|
|
85
|
+
One client is the **host** (the first joiner still present, re-elected automatically when they
|
|
86
|
+
leave). Use it to pick a single writer for scores/rounds or a single simulator for NPCs.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
if (room.isHost) room.shared.set('round', nextRound); // only the host advances the round
|
|
90
|
+
room.on('host', (id) => {}); // host changed (migration)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`isHost`/`host` settle within the first patch after `connect()` — read them in your loop (or react
|
|
94
|
+
to `on('host')`), not once at startup.
|
|
95
|
+
|
|
60
96
|
## `on` — events
|
|
61
97
|
|
|
62
98
|
```ts
|
|
@@ -64,6 +100,8 @@ room.on('join', (id, state) => {}); // a player appeared (also fires for YOU
|
|
|
64
100
|
room.on('change', (id, state) => {}); // a player updated their me-state
|
|
65
101
|
room.on('leave', (id) => {}); // a player left
|
|
66
102
|
room.on('shared', (key, value) => {}); // a shared key changed
|
|
103
|
+
room.on('object', (id) => {}); // an object's owner changed (handoff)
|
|
104
|
+
room.on('host', (id) => {}); // the room host changed
|
|
67
105
|
room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
|
|
68
106
|
```
|
|
69
107
|
|
|
@@ -87,11 +125,13 @@ room.leave();
|
|
|
87
125
|
|
|
88
126
|
## Smoothing remote players
|
|
89
127
|
|
|
90
|
-
Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and
|
|
128
|
+
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
129
|
|
|
92
130
|
- `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
|
|
93
131
|
- **Your own player is never delayed** — you always render yourself live.
|
|
94
|
-
-
|
|
132
|
+
- **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
|
|
133
|
+
- **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.
|
|
134
|
+
- **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
135
|
|
|
96
136
|
> 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
137
|
|
|
@@ -114,7 +154,7 @@ setInterval(() => room.me.set(me), 66);
|
|
|
114
154
|
function frame() {
|
|
115
155
|
for (const [id, p] of room.players) {
|
|
116
156
|
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
|
|
157
|
+
cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // auto-smoothed
|
|
118
158
|
}
|
|
119
159
|
requestAnimationFrame(frame);
|
|
120
160
|
}
|
|
@@ -134,7 +174,7 @@ onKey('right', () => { me.x += 1; });
|
|
|
134
174
|
- **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
|
|
135
175
|
- **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
|
|
136
176
|
- **`room` (game id) must be stable per game** — use the project slug. Same id = same room; different id = isolated.
|
|
137
|
-
- **
|
|
177
|
+
- **Remote players are smoothed automatically** — read `stateRaw` when you need the raw latest (hit-tests). No server-side prediction — you render yourself live.
|
|
138
178
|
- `me.set` replaces your whole state object — send the full object each time, not a partial.
|
|
139
179
|
|
|
140
180
|
---
|
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,20 @@ type ConnectOptions = {
|
|
|
7
7
|
/** Game id — everyone who passes the same id shares a room; different ids are
|
|
8
8
|
* fully isolated. Use your project slug (or any stable per-game string). */
|
|
9
9
|
room: string;
|
|
10
|
-
/** Display name for this player
|
|
10
|
+
/** Display name for this player. The server prefers the verified identity's
|
|
11
|
+
* name (from `auth`) when present; this is the display fallback. */
|
|
11
12
|
name?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Verified-identity credential — REQUIRED to actually join: the relay
|
|
15
|
+
* rejects tokenless joins (there is no anonymous play). Pass
|
|
16
|
+
* `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForAuth()`
|
|
17
|
+
* (from `@genex-ai/embed-sdk`), and read it fresh at every connect() call —
|
|
18
|
+
* never cache the object across reconnects (tokens rotate every ~10 min).
|
|
19
|
+
* NEVER log this value.
|
|
20
|
+
*/
|
|
21
|
+
auth?: {
|
|
22
|
+
embedToken: string;
|
|
23
|
+
};
|
|
12
24
|
/** Extra join options passed to the room. */
|
|
13
25
|
options?: Record<string, unknown>;
|
|
14
26
|
};
|
|
@@ -21,6 +33,19 @@ type PlayerView<TState> = {
|
|
|
21
33
|
/** The latest raw snapshot (never smoothed/delayed) — use for hit-tests / logic. */
|
|
22
34
|
stateRaw: TState;
|
|
23
35
|
};
|
|
36
|
+
/** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
|
|
37
|
+
type ObjectView<TState> = {
|
|
38
|
+
id: string;
|
|
39
|
+
/** Session id of the current owner, or `undefined` if unowned. */
|
|
40
|
+
owner: string | undefined;
|
|
41
|
+
/** True if you own it (so you should simulate + `objects.set` it). */
|
|
42
|
+
isMine: boolean;
|
|
43
|
+
/** State to render — **auto-smoothed** like a remote player when someone else owns it;
|
|
44
|
+
* live (your last `set`) when it's yours. */
|
|
45
|
+
state: TState;
|
|
46
|
+
/** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
|
|
47
|
+
stateRaw: TState;
|
|
48
|
+
};
|
|
24
49
|
type Session<TState extends Record<string, unknown>> = {
|
|
25
50
|
/** Your own session id. */
|
|
26
51
|
readonly id: string;
|
|
@@ -45,12 +70,41 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
45
70
|
/** All shared keys currently set. */
|
|
46
71
|
keys(): string[];
|
|
47
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Shared objects nobody owns until claimed — a ball, a puck, an NPC. Exactly one client
|
|
75
|
+
* owns an object at a time (server-enforced); the owner simulates it and `set`s it, and
|
|
76
|
+
* everyone else reads it **auto-smoothed**, on the same interpolation as remote players.
|
|
77
|
+
* Ownership survives an owner leaving: the object is reassigned to the room host.
|
|
78
|
+
*/
|
|
79
|
+
readonly objects: {
|
|
80
|
+
/** Take ownership (last claim wins — call it when you kick/touch the object). */
|
|
81
|
+
claim(id: string): void;
|
|
82
|
+
/** Give up ownership (leaves the object unowned until someone claims it). */
|
|
83
|
+
release(id: string): void;
|
|
84
|
+
/** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
|
|
85
|
+
remove(id: string): void;
|
|
86
|
+
/** Publish the object's state — only takes effect while you own it. Full object each call. */
|
|
87
|
+
set(id: string, state: Record<string, unknown>): void;
|
|
88
|
+
/** Read an object, or `undefined` if it doesn't exist yet. */
|
|
89
|
+
get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
|
|
90
|
+
/** Ids of every object seen so far. */
|
|
91
|
+
ids(): string[];
|
|
92
|
+
};
|
|
93
|
+
/** True if you are the elected room host (first joiner still present). Settles within the
|
|
94
|
+
* first state patch after connect — read it in your loop or react to `on('host')`, not once. */
|
|
95
|
+
readonly isHost: boolean;
|
|
96
|
+
/** Session id of the current room host, or `undefined` until the first host patch lands. */
|
|
97
|
+
readonly host: string | undefined;
|
|
48
98
|
/** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
|
|
49
99
|
on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
|
|
50
100
|
/** A player left. */
|
|
51
101
|
on(event: 'leave', cb: (id: string) => void): () => void;
|
|
52
|
-
/** A shared key changed (
|
|
102
|
+
/** A shared key changed (reserved `__*` keys are hidden). For live values just read `shared.get`. */
|
|
53
103
|
on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
|
|
104
|
+
/** An object's owner or state changed (fires with its id). */
|
|
105
|
+
on(event: 'object', cb: (id: string) => void): () => void;
|
|
106
|
+
/** The room host changed (fires with the new host's id, or `undefined`). */
|
|
107
|
+
on(event: 'host', cb: (host: string | undefined) => void): () => void;
|
|
54
108
|
/** A custom event another client sent via `send(type, ...)`. */
|
|
55
109
|
on(event: string & {}, cb: (payload: unknown) => void): () => void;
|
|
56
110
|
/** Fire-and-forget event to all OTHER clients (not stored in state). */
|
|
@@ -75,4 +129,4 @@ type Session<TState extends Record<string, unknown>> = {
|
|
|
75
129
|
*/
|
|
76
130
|
declare function connect<TState extends Record<string, unknown> = never>(opts: ConnectOptions): Promise<Session<TState>>;
|
|
77
131
|
|
|
78
|
-
export { type ConnectOptions, type PlayerView, type Session, connect };
|
|
132
|
+
export { type ConnectOptions, type ObjectView, type PlayerView, type Session, connect };
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
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
|
+
var RESERVED_PREFIX = "__";
|
|
14
|
+
var OBJ_PREFIX = "__obj:";
|
|
15
|
+
var HOST_KEY = "__host";
|
|
16
|
+
var MP_PROTOCOL = 1;
|
|
17
|
+
function parseData(s) {
|
|
18
|
+
if (!s) return { state: {} };
|
|
10
19
|
try {
|
|
11
20
|
const v = JSON.parse(s);
|
|
12
|
-
|
|
21
|
+
if (!v || typeof v !== "object") return { state: {} };
|
|
22
|
+
const o = v;
|
|
23
|
+
const t = o[SEND_TIME_KEY];
|
|
24
|
+
if (typeof t === "number" && Number.isFinite(t)) {
|
|
25
|
+
const state = { ...o };
|
|
26
|
+
delete state[SEND_TIME_KEY];
|
|
27
|
+
return { state, sendTime: t };
|
|
28
|
+
}
|
|
29
|
+
return { state: o };
|
|
13
30
|
} catch {
|
|
14
|
-
return {};
|
|
31
|
+
return { state: {} };
|
|
15
32
|
}
|
|
16
33
|
}
|
|
17
34
|
function parseValue(s) {
|
|
@@ -23,26 +40,82 @@ function parseValue(s) {
|
|
|
23
40
|
}
|
|
24
41
|
}
|
|
25
42
|
var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
26
|
-
function
|
|
43
|
+
function slerp(a, b, t) {
|
|
44
|
+
let [bx, by, bz, bw] = b;
|
|
45
|
+
const [ax, ay, az, aw] = a;
|
|
46
|
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
|
47
|
+
if (dot < 0) {
|
|
48
|
+
bx = -bx;
|
|
49
|
+
by = -by;
|
|
50
|
+
bz = -bz;
|
|
51
|
+
bw = -bw;
|
|
52
|
+
dot = -dot;
|
|
53
|
+
}
|
|
54
|
+
if (dot > 0.9995) {
|
|
55
|
+
const x = ax + (bx - ax) * t, y = ay + (by - ay) * t, z = az + (bz - az) * t, w = aw + (bw - aw) * t;
|
|
56
|
+
const l = Math.hypot(x, y, z, w) || 1;
|
|
57
|
+
return [x / l, y / l, z / l, w / l];
|
|
58
|
+
}
|
|
59
|
+
const theta0 = Math.acos(dot);
|
|
60
|
+
const sin0 = Math.sin(theta0);
|
|
61
|
+
const s0 = Math.sin(theta0 - theta0 * t) / sin0;
|
|
62
|
+
const s1 = Math.sin(theta0 * t) / sin0;
|
|
63
|
+
return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
|
|
64
|
+
}
|
|
65
|
+
function isQuat(v) {
|
|
66
|
+
return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
67
|
+
}
|
|
68
|
+
function blend(a, b, tNum, tQuat) {
|
|
27
69
|
const out = {};
|
|
28
70
|
for (const k in b) {
|
|
29
71
|
const av = a[k];
|
|
30
72
|
const bv = b[k];
|
|
31
|
-
|
|
73
|
+
if (typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv)) {
|
|
74
|
+
out[k] = av + (bv - av) * tNum;
|
|
75
|
+
} else if (isQuat(av) && isQuat(bv)) {
|
|
76
|
+
out[k] = slerp(av, bv, tQuat);
|
|
77
|
+
} else {
|
|
78
|
+
out[k] = bv;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function easeState(prev, target, aNum, aQuat) {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const k in target) {
|
|
86
|
+
const tv = target[k];
|
|
87
|
+
const pv = prev[k];
|
|
88
|
+
if (typeof pv === "number" && typeof tv === "number" && Number.isFinite(pv) && Number.isFinite(tv)) {
|
|
89
|
+
out[k] = pv + (tv - pv) * aNum;
|
|
90
|
+
} else if (isQuat(pv) && isQuat(tv)) {
|
|
91
|
+
out[k] = slerp(pv, tv, aQuat);
|
|
92
|
+
} else {
|
|
93
|
+
out[k] = tv;
|
|
94
|
+
}
|
|
32
95
|
}
|
|
33
96
|
return out;
|
|
34
97
|
}
|
|
35
98
|
function sampleBuffer(buf, renderTime) {
|
|
36
99
|
const n = buf.length;
|
|
37
100
|
if (n === 0) return void 0;
|
|
101
|
+
if (n === 1) return buf[0].state;
|
|
38
102
|
let j = 0;
|
|
39
103
|
while (j < n && buf[j].t <= renderTime) j++;
|
|
40
104
|
if (j === 0) return buf[0].state;
|
|
41
|
-
if (j >= n)
|
|
105
|
+
if (j >= n) {
|
|
106
|
+
const a2 = buf[n - 2];
|
|
107
|
+
const b2 = buf[n - 1];
|
|
108
|
+
const span2 = b2.t - a2.t;
|
|
109
|
+
if (span2 <= 0) return b2.state;
|
|
110
|
+
const capped = Math.min(renderTime, b2.t + MAX_EXTRAP_MS);
|
|
111
|
+
const t2 = (capped - a2.t) / span2;
|
|
112
|
+
return blend(a2.state, b2.state, t2, 1);
|
|
113
|
+
}
|
|
42
114
|
const a = buf[j - 1];
|
|
43
115
|
const b = buf[j];
|
|
44
116
|
const span = b.t - a.t;
|
|
45
|
-
|
|
117
|
+
const t = span > 0 ? (renderTime - a.t) / span : 1;
|
|
118
|
+
return blend(a.state, b.state, t, t);
|
|
46
119
|
}
|
|
47
120
|
function median(xs) {
|
|
48
121
|
if (xs.length === 0) return 66;
|
|
@@ -54,23 +127,46 @@ async function connect(opts) {
|
|
|
54
127
|
const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
|
|
55
128
|
gameId: opts.room,
|
|
56
129
|
name: opts.name,
|
|
57
|
-
|
|
130
|
+
embedToken: opts.auth?.embedToken,
|
|
131
|
+
...opts.options,
|
|
132
|
+
__v: MP_PROTOCOL
|
|
133
|
+
// tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
|
|
58
134
|
});
|
|
59
135
|
const buffers = /* @__PURE__ */ new Map();
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
136
|
+
const lastStamp = /* @__PURE__ */ new Map();
|
|
137
|
+
const clockOffsets = /* @__PURE__ */ new Map();
|
|
138
|
+
const offsetWin = /* @__PURE__ */ new Map();
|
|
139
|
+
const smoothed = /* @__PURE__ */ new Map();
|
|
140
|
+
const smoothedAt = /* @__PURE__ */ new Map();
|
|
141
|
+
const intervalsById = /* @__PURE__ */ new Map();
|
|
142
|
+
const autoDelayById = /* @__PURE__ */ new Map();
|
|
143
|
+
let localState;
|
|
144
|
+
const objOwners = /* @__PURE__ */ new Map();
|
|
145
|
+
const objRaw = /* @__PURE__ */ new Map();
|
|
146
|
+
const localObjState = /* @__PURE__ */ new Map();
|
|
147
|
+
let hostId;
|
|
148
|
+
const pushSnap = (id, state, sendTime) => {
|
|
149
|
+
const arrival = nowMs();
|
|
150
|
+
const t = sendTime ?? arrival;
|
|
151
|
+
if (sendTime != null) {
|
|
152
|
+
let win = offsetWin.get(id);
|
|
153
|
+
if (!win) offsetWin.set(id, win = []);
|
|
154
|
+
win.push(arrival - sendTime);
|
|
155
|
+
if (win.length > CLOCK_WIN) win.shift();
|
|
156
|
+
clockOffsets.set(id, Math.min(...win));
|
|
157
|
+
}
|
|
158
|
+
const prev = lastStamp.get(id);
|
|
159
|
+
lastStamp.set(id, t);
|
|
67
160
|
if (prev != null) {
|
|
68
161
|
const gap = t - prev;
|
|
69
162
|
if (gap > 5 && gap < 1e3) {
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
163
|
+
let iv = intervalsById.get(id);
|
|
164
|
+
if (!iv) intervalsById.set(id, iv = []);
|
|
165
|
+
iv.push(gap);
|
|
166
|
+
if (iv.length > 30) iv.shift();
|
|
167
|
+
const target = Math.min(INTERP_MAX_MS, Math.max(INTERP_MIN_MS, INTERP_RATIO * median(iv)));
|
|
168
|
+
const cur = autoDelayById.get(id) ?? 100;
|
|
169
|
+
autoDelayById.set(id, cur + (target - cur) * INTERP_EASE);
|
|
74
170
|
}
|
|
75
171
|
}
|
|
76
172
|
let buf = buffers.get(id);
|
|
@@ -79,27 +175,105 @@ async function connect(opts) {
|
|
|
79
175
|
const cutoff = t - 1500;
|
|
80
176
|
while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
|
|
81
177
|
};
|
|
178
|
+
const sampleSmoothed = (streamKey, localNow) => {
|
|
179
|
+
const buf = buffers.get(streamKey);
|
|
180
|
+
const autoDelay = autoDelayById.get(streamKey) ?? 100;
|
|
181
|
+
const renderTime = localNow - (clockOffsets.get(streamKey) ?? 0) - autoDelay;
|
|
182
|
+
const sampled = buf && sampleBuffer(buf, renderTime);
|
|
183
|
+
if (!sampled) return void 0;
|
|
184
|
+
const prev = smoothed.get(streamKey);
|
|
185
|
+
let out;
|
|
186
|
+
if (prev) {
|
|
187
|
+
const dt = Math.min(250, Math.max(0, localNow - (smoothedAt.get(streamKey) ?? localNow)));
|
|
188
|
+
out = easeState(prev, sampled, 1 - Math.exp(-dt / SMOOTH_TAU_POS), 1 - Math.exp(-dt / SMOOTH_TAU_ROT));
|
|
189
|
+
} else {
|
|
190
|
+
out = sampled;
|
|
191
|
+
}
|
|
192
|
+
smoothed.set(streamKey, out);
|
|
193
|
+
smoothedAt.set(streamKey, localNow);
|
|
194
|
+
return out;
|
|
195
|
+
};
|
|
196
|
+
const clearStream = (streamKey) => {
|
|
197
|
+
buffers.delete(streamKey);
|
|
198
|
+
lastStamp.delete(streamKey);
|
|
199
|
+
clockOffsets.delete(streamKey);
|
|
200
|
+
offsetWin.delete(streamKey);
|
|
201
|
+
smoothed.delete(streamKey);
|
|
202
|
+
smoothedAt.delete(streamKey);
|
|
203
|
+
intervalsById.delete(streamKey);
|
|
204
|
+
autoDelayById.delete(streamKey);
|
|
205
|
+
};
|
|
82
206
|
const emitters = /* @__PURE__ */ new Map();
|
|
83
207
|
const bridged = /* @__PURE__ */ new Set();
|
|
84
|
-
const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared"]);
|
|
208
|
+
const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared", "object", "host"]);
|
|
85
209
|
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
86
210
|
const $ = getStateCallbacks(room);
|
|
87
211
|
$(room.state).players.onAdd((player, id) => {
|
|
88
|
-
const
|
|
89
|
-
emit("join", id,
|
|
90
|
-
if (id !== room.sessionId) pushSnap(id,
|
|
212
|
+
const { state, sendTime } = parseData(player.data);
|
|
213
|
+
emit("join", id, state);
|
|
214
|
+
if (id !== room.sessionId) pushSnap(id, state, sendTime);
|
|
91
215
|
$(player).onChange(() => {
|
|
92
|
-
const st =
|
|
216
|
+
const { state: st, sendTime: ts } = parseData(player.data);
|
|
93
217
|
emit("change", id, st);
|
|
94
|
-
if (id !== room.sessionId) pushSnap(id, st);
|
|
218
|
+
if (id !== room.sessionId) pushSnap(id, st, ts);
|
|
95
219
|
});
|
|
96
220
|
});
|
|
97
221
|
$(room.state).players.onRemove((_player, id) => {
|
|
98
222
|
emit("leave", id);
|
|
99
|
-
|
|
100
|
-
|
|
223
|
+
clearStream(id);
|
|
224
|
+
});
|
|
225
|
+
const lastShared = /* @__PURE__ */ new Map();
|
|
226
|
+
const handleShared = (value, key) => {
|
|
227
|
+
if (lastShared.get(key) === value) return;
|
|
228
|
+
lastShared.set(key, value);
|
|
229
|
+
if (key === HOST_KEY) {
|
|
230
|
+
const next = parseValue(value) || void 0;
|
|
231
|
+
if (next !== hostId) {
|
|
232
|
+
hostId = next;
|
|
233
|
+
emit("host", hostId);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (key.startsWith(OBJ_PREFIX)) {
|
|
238
|
+
const id = key.slice(OBJ_PREFIX.length);
|
|
239
|
+
const blob = parseValue(value);
|
|
240
|
+
if (!blob || typeof blob !== "object") return;
|
|
241
|
+
const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
|
|
242
|
+
const ownerChanged = owner !== objOwners.get(id);
|
|
243
|
+
if (ownerChanged) {
|
|
244
|
+
if (owner) objOwners.set(id, owner);
|
|
245
|
+
else objOwners.delete(id);
|
|
246
|
+
if (owner !== room.sessionId) localObjState.delete(id);
|
|
247
|
+
}
|
|
248
|
+
if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
|
|
249
|
+
objRaw.set(id, blob.d);
|
|
250
|
+
if (owner !== room.sessionId) pushSnap(key, blob.d, blob.t);
|
|
251
|
+
}
|
|
252
|
+
if (ownerChanged) emit("object", id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
emit("shared", key, parseValue(value));
|
|
256
|
+
};
|
|
257
|
+
$(room.state).shared.onAdd(handleShared);
|
|
258
|
+
$(room.state).shared.onChange(handleShared);
|
|
259
|
+
$(room.state).shared.onRemove((_value, key) => {
|
|
260
|
+
lastShared.delete(key);
|
|
261
|
+
if (key === HOST_KEY) {
|
|
262
|
+
if (hostId !== void 0) {
|
|
263
|
+
hostId = void 0;
|
|
264
|
+
emit("host", void 0);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (key.startsWith(OBJ_PREFIX)) {
|
|
269
|
+
const id = key.slice(OBJ_PREFIX.length);
|
|
270
|
+
clearStream(key);
|
|
271
|
+
objRaw.delete(id);
|
|
272
|
+
objOwners.delete(id);
|
|
273
|
+
localObjState.delete(id);
|
|
274
|
+
emit("object", id);
|
|
275
|
+
}
|
|
101
276
|
});
|
|
102
|
-
$(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
|
|
103
277
|
const on = ((event, cb) => {
|
|
104
278
|
let set = emitters.get(event);
|
|
105
279
|
if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
|
|
@@ -116,35 +290,96 @@ async function connect(opts) {
|
|
|
116
290
|
id: room.sessionId,
|
|
117
291
|
get players() {
|
|
118
292
|
const out = /* @__PURE__ */ new Map();
|
|
119
|
-
const
|
|
293
|
+
const localNow = nowMs();
|
|
120
294
|
room.state?.players?.forEach((p, id) => {
|
|
121
|
-
const latest =
|
|
295
|
+
const { state: latest } = parseData(p.data);
|
|
122
296
|
let state = latest;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
297
|
+
let raw = latest;
|
|
298
|
+
if (id === room.sessionId) {
|
|
299
|
+
if (localState) {
|
|
300
|
+
state = localState;
|
|
301
|
+
raw = localState;
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
state = sampleSmoothed(id, localNow) ?? latest;
|
|
127
305
|
}
|
|
128
|
-
out.set(id, { id, name: p.name, state, stateRaw:
|
|
306
|
+
out.set(id, { id, name: p.name, state, stateRaw: raw });
|
|
129
307
|
});
|
|
130
308
|
return out;
|
|
131
309
|
},
|
|
132
310
|
me: {
|
|
133
311
|
set(state) {
|
|
134
|
-
|
|
312
|
+
localState = { ...state };
|
|
313
|
+
room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
|
|
135
314
|
}
|
|
136
315
|
},
|
|
137
316
|
shared: {
|
|
138
317
|
get(key) {
|
|
318
|
+
if (key.startsWith(RESERVED_PREFIX)) return void 0;
|
|
139
319
|
return parseValue(room.state?.shared?.get(key));
|
|
140
320
|
},
|
|
141
321
|
set(key, value) {
|
|
322
|
+
if (key.startsWith(RESERVED_PREFIX)) return;
|
|
142
323
|
room.send("shared", { key, value: JSON.stringify(value) });
|
|
143
324
|
},
|
|
144
325
|
keys() {
|
|
145
|
-
return room.state?.shared ? Array.from(room.state.shared.keys()) : [];
|
|
326
|
+
return room.state?.shared ? Array.from(room.state.shared.keys()).filter((k) => !k.startsWith(RESERVED_PREFIX)) : [];
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
objects: {
|
|
330
|
+
claim(id) {
|
|
331
|
+
const key = String(id);
|
|
332
|
+
objOwners.set(key, room.sessionId);
|
|
333
|
+
room.send("claim", { id: key });
|
|
334
|
+
},
|
|
335
|
+
release(id) {
|
|
336
|
+
const key = String(id);
|
|
337
|
+
localObjState.delete(key);
|
|
338
|
+
if (objOwners.get(key) === room.sessionId) objOwners.delete(key);
|
|
339
|
+
room.send("release", { id: key });
|
|
340
|
+
},
|
|
341
|
+
remove(id) {
|
|
342
|
+
const key = String(id);
|
|
343
|
+
if (objOwners.get(key) === room.sessionId) {
|
|
344
|
+
localObjState.delete(key);
|
|
345
|
+
objRaw.delete(key);
|
|
346
|
+
objOwners.delete(key);
|
|
347
|
+
clearStream(OBJ_PREFIX + key);
|
|
348
|
+
}
|
|
349
|
+
room.send("destroy", { id: key });
|
|
350
|
+
},
|
|
351
|
+
set(id, state) {
|
|
352
|
+
const key = String(id);
|
|
353
|
+
if (objOwners.get(key) === room.sessionId) localObjState.set(key, { ...state });
|
|
354
|
+
room.send("obj", { id: key, d: { ...state } });
|
|
355
|
+
},
|
|
356
|
+
get(id) {
|
|
357
|
+
const key = String(id);
|
|
358
|
+
const owner = objOwners.get(key);
|
|
359
|
+
const mine = localObjState.get(key);
|
|
360
|
+
if (owner === void 0 && !objRaw.has(key) && !mine) return void 0;
|
|
361
|
+
const isMine = owner !== void 0 && owner === room.sessionId;
|
|
362
|
+
let state;
|
|
363
|
+
let raw;
|
|
364
|
+
if (isMine && mine) {
|
|
365
|
+
state = mine;
|
|
366
|
+
raw = mine;
|
|
367
|
+
} else {
|
|
368
|
+
raw = objRaw.get(key) ?? {};
|
|
369
|
+
state = sampleSmoothed(OBJ_PREFIX + key, nowMs()) ?? raw;
|
|
370
|
+
}
|
|
371
|
+
return { id: key, owner, isMine, state, stateRaw: raw };
|
|
372
|
+
},
|
|
373
|
+
ids() {
|
|
374
|
+
return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
|
|
146
375
|
}
|
|
147
376
|
},
|
|
377
|
+
get isHost() {
|
|
378
|
+
return hostId !== void 0 && hostId === room.sessionId;
|
|
379
|
+
},
|
|
380
|
+
get host() {
|
|
381
|
+
return hostId;
|
|
382
|
+
},
|
|
148
383
|
on,
|
|
149
384
|
send(type, payload) {
|
|
150
385
|
room.send(type, payload);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/multiplayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,12 +20,6 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"dist"
|
|
22
22
|
],
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "tsup",
|
|
25
|
-
"typecheck": "tsc --noEmit",
|
|
26
|
-
"check": "pnpm build && publint && attw --pack . --profile esm-only",
|
|
27
|
-
"prepack": "pnpm build && publint"
|
|
28
|
-
},
|
|
29
23
|
"publishConfig": {
|
|
30
24
|
"access": "public"
|
|
31
25
|
},
|
|
@@ -51,5 +45,10 @@
|
|
|
51
45
|
"url": "git+https://github.com/me-ai-org/genex-demo.git",
|
|
52
46
|
"directory": "packages/multiplayer"
|
|
53
47
|
},
|
|
54
|
-
"license": "MIT"
|
|
55
|
-
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"check": "pnpm build && publint && attw --pack . --profile esm-only"
|
|
53
|
+
}
|
|
54
|
+
}
|