@carverjs/multiplayer 0.0.1
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/dist/NetworkManager-DrKM2tEx.d.mts +369 -0
- package/dist/NetworkManager-nvVAOr1O.d.ts +369 -0
- package/dist/chunk-3KT73N2S.mjs +655 -0
- package/dist/chunk-3KT73N2S.mjs.map +1 -0
- package/dist/chunk-EO3YNPRQ.mjs +817 -0
- package/dist/chunk-EO3YNPRQ.mjs.map +1 -0
- package/dist/chunk-UD6FDZMX.mjs +581 -0
- package/dist/chunk-UD6FDZMX.mjs.map +1 -0
- package/dist/firebase-CPu87KA0.d.ts +100 -0
- package/dist/firebase-PE6MxGdJ.d.mts +100 -0
- package/dist/index.d.mts +316 -0
- package/dist/index.d.ts +316 -0
- package/dist/index.js +3817 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1743 -0
- package/dist/index.mjs.map +1 -0
- package/dist/strategy.d.mts +7 -0
- package/dist/strategy.d.ts +7 -0
- package/dist/strategy.js +619 -0
- package/dist/strategy.js.map +1 -0
- package/dist/strategy.mjs +11 -0
- package/dist/strategy.mjs.map +1 -0
- package/dist/sync.d.mts +212 -0
- package/dist/sync.d.ts +212 -0
- package/dist/sync.js +845 -0
- package/dist/sync.js.map +1 -0
- package/dist/sync.mjs +11 -0
- package/dist/sync.mjs.map +1 -0
- package/dist/transport.d.mts +159 -0
- package/dist/transport.d.ts +159 -0
- package/dist/transport.js +1274 -0
- package/dist/transport.js.map +1 -0
- package/dist/transport.mjs +19 -0
- package/dist/transport.mjs.map +1 -0
- package/dist/types-5LHBOW08.d.mts +74 -0
- package/dist/types-5LHBOW08.d.ts +74 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/types.mjs +1 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +73 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1743 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WebRTCTransport
|
|
3
|
+
} from "./chunk-3KT73N2S.mjs";
|
|
4
|
+
import {
|
|
5
|
+
FirebaseStrategy,
|
|
6
|
+
MqttStrategy
|
|
7
|
+
} from "./chunk-UD6FDZMX.mjs";
|
|
8
|
+
import {
|
|
9
|
+
EventSync,
|
|
10
|
+
PredictionSync,
|
|
11
|
+
SnapshotSync
|
|
12
|
+
} from "./chunk-EO3YNPRQ.mjs";
|
|
13
|
+
|
|
14
|
+
// src/components/MultiplayerProvider.ts
|
|
15
|
+
import { createElement, useRef, useEffect } from "react";
|
|
16
|
+
|
|
17
|
+
// src/core/TickKeeper.ts
|
|
18
|
+
var _TickKeeper = class _TickKeeper {
|
|
19
|
+
constructor(tickRate = 60) {
|
|
20
|
+
this._accumulator = 0;
|
|
21
|
+
this._tick = 0;
|
|
22
|
+
this._serverTick = 0;
|
|
23
|
+
this._alpha = 0;
|
|
24
|
+
this._timeScale = 1;
|
|
25
|
+
this._tickRate = tickRate;
|
|
26
|
+
this._tickDelta = 1 / tickRate;
|
|
27
|
+
}
|
|
28
|
+
get tick() {
|
|
29
|
+
return this._tick;
|
|
30
|
+
}
|
|
31
|
+
get serverTick() {
|
|
32
|
+
return this._serverTick;
|
|
33
|
+
}
|
|
34
|
+
get tickDelta() {
|
|
35
|
+
return this._tickDelta;
|
|
36
|
+
}
|
|
37
|
+
get tickRate() {
|
|
38
|
+
return this._tickRate;
|
|
39
|
+
}
|
|
40
|
+
/** Interpolation alpha for rendering between ticks (0-1) */
|
|
41
|
+
get alpha() {
|
|
42
|
+
return this._alpha;
|
|
43
|
+
}
|
|
44
|
+
/** Current time scale (affected by drift correction) */
|
|
45
|
+
get timeScale() {
|
|
46
|
+
return this._timeScale;
|
|
47
|
+
}
|
|
48
|
+
/** Ticks ahead of server (positive = ahead, negative = behind) */
|
|
49
|
+
get drift() {
|
|
50
|
+
return this._tick - this._serverTick;
|
|
51
|
+
}
|
|
52
|
+
/** Update server tick from received snapshot */
|
|
53
|
+
setServerTick(serverTick) {
|
|
54
|
+
this._serverTick = serverTick;
|
|
55
|
+
this._updateDriftCorrection();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Accumulate time and return the number of fixed ticks to process.
|
|
59
|
+
* Call this once per render frame with the raw frame delta.
|
|
60
|
+
*/
|
|
61
|
+
update(rawDelta) {
|
|
62
|
+
const maxDelta = this._tickDelta * 8;
|
|
63
|
+
const delta = Math.min(rawDelta, maxDelta) * this._timeScale;
|
|
64
|
+
this._accumulator += delta;
|
|
65
|
+
let ticksThisFrame = 0;
|
|
66
|
+
while (this._accumulator >= this._tickDelta) {
|
|
67
|
+
this._accumulator -= this._tickDelta;
|
|
68
|
+
this._tick++;
|
|
69
|
+
ticksThisFrame++;
|
|
70
|
+
}
|
|
71
|
+
this._alpha = this._accumulator / this._tickDelta;
|
|
72
|
+
return ticksThisFrame;
|
|
73
|
+
}
|
|
74
|
+
/** Reset to initial state */
|
|
75
|
+
reset() {
|
|
76
|
+
this._accumulator = 0;
|
|
77
|
+
this._tick = 0;
|
|
78
|
+
this._serverTick = 0;
|
|
79
|
+
this._alpha = 0;
|
|
80
|
+
this._timeScale = 1;
|
|
81
|
+
}
|
|
82
|
+
/** Set tick rate (updates tickDelta accordingly) */
|
|
83
|
+
setTickRate(rate) {
|
|
84
|
+
this._tickRate = rate;
|
|
85
|
+
this._tickDelta = 1 / rate;
|
|
86
|
+
}
|
|
87
|
+
_updateDriftCorrection() {
|
|
88
|
+
const drift = this.drift;
|
|
89
|
+
if (drift < _TickKeeper.DRIFT_BEHIND_THRESHOLD) {
|
|
90
|
+
this._timeScale = _TickKeeper.SPEED_UP_SCALE;
|
|
91
|
+
} else if (drift > _TickKeeper.DRIFT_AHEAD_THRESHOLD) {
|
|
92
|
+
this._timeScale = _TickKeeper.SLOW_DOWN_SCALE;
|
|
93
|
+
} else {
|
|
94
|
+
this._timeScale = _TickKeeper.NORMAL_SCALE;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// Drift correction zones
|
|
99
|
+
_TickKeeper.DRIFT_BEHIND_THRESHOLD = -5;
|
|
100
|
+
_TickKeeper.DRIFT_AHEAD_THRESHOLD = 5;
|
|
101
|
+
_TickKeeper.SPEED_UP_SCALE = 1.5;
|
|
102
|
+
_TickKeeper.SLOW_DOWN_SCALE = 0.1;
|
|
103
|
+
_TickKeeper.NORMAL_SCALE = 1;
|
|
104
|
+
var TickKeeper = _TickKeeper;
|
|
105
|
+
|
|
106
|
+
// src/core/codec.ts
|
|
107
|
+
import { pack, unpack } from "msgpackr";
|
|
108
|
+
var DEFAULT_THRESHOLDS = {
|
|
109
|
+
position: 0.01,
|
|
110
|
+
rotation: 1e-3,
|
|
111
|
+
velocity: 0.05,
|
|
112
|
+
custom: "strict"
|
|
113
|
+
};
|
|
114
|
+
var SnapshotBuffer = class {
|
|
115
|
+
constructor(capacity = 120) {
|
|
116
|
+
this._capacity = capacity;
|
|
117
|
+
this._buffer = /* @__PURE__ */ new Map();
|
|
118
|
+
}
|
|
119
|
+
/** Store a snapshot at the given tick */
|
|
120
|
+
store(tick, entities) {
|
|
121
|
+
this._buffer.set(tick, entities);
|
|
122
|
+
if (this._buffer.size > this._capacity) {
|
|
123
|
+
const sortedTicks = Array.from(this._buffer.keys()).sort((a, b) => a - b);
|
|
124
|
+
const toRemove = sortedTicks.length - this._capacity;
|
|
125
|
+
for (let i = 0; i < toRemove; i++) {
|
|
126
|
+
this._buffer.delete(sortedTicks[i]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Get a snapshot at the given tick */
|
|
131
|
+
get(tick) {
|
|
132
|
+
return this._buffer.get(tick);
|
|
133
|
+
}
|
|
134
|
+
/** Clear all stored snapshots */
|
|
135
|
+
clear() {
|
|
136
|
+
this._buffer.clear();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var Codec = class {
|
|
140
|
+
constructor(options) {
|
|
141
|
+
this._thresholds = { ...DEFAULT_THRESHOLDS, ...options?.thresholds };
|
|
142
|
+
this._quantize = options?.quantize;
|
|
143
|
+
this._is2D = options?.is2D ?? false;
|
|
144
|
+
}
|
|
145
|
+
/** Serialize entity states to binary (msgpackr) */
|
|
146
|
+
serialize(entities) {
|
|
147
|
+
const quantized = this._quantize ? entities.map((e) => this._quantizeEntity(e)) : entities;
|
|
148
|
+
return pack(quantized);
|
|
149
|
+
}
|
|
150
|
+
/** Deserialize binary to entity states */
|
|
151
|
+
deserialize(data) {
|
|
152
|
+
return unpack(data);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Compute delta: only include entities that changed beyond thresholds
|
|
156
|
+
* since the baseline snapshot.
|
|
157
|
+
* Returns null if nothing changed.
|
|
158
|
+
*/
|
|
159
|
+
computeDelta(current, baseline) {
|
|
160
|
+
if (!baseline) {
|
|
161
|
+
return Array.from(current.values());
|
|
162
|
+
}
|
|
163
|
+
const changed = [];
|
|
164
|
+
for (const [id, entity] of current) {
|
|
165
|
+
const prev = baseline.get(id);
|
|
166
|
+
if (!prev || this._hasChanged(entity, prev)) {
|
|
167
|
+
changed.push(entity);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const id of baseline.keys()) {
|
|
171
|
+
if (!current.has(id)) {
|
|
172
|
+
if (this._is2D) {
|
|
173
|
+
changed.push({ id, x: 0, y: 0, a: 0, vx: 0, vy: 0, va: 0, c: { __removed: true } });
|
|
174
|
+
} else {
|
|
175
|
+
changed.push({ id, x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0, c: { __removed: true } });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return changed.length > 0 ? changed : null;
|
|
180
|
+
}
|
|
181
|
+
/** Serialize a delta snapshot packet */
|
|
182
|
+
serializeDelta(tick, baseTick, current, baseline) {
|
|
183
|
+
const delta = this.computeDelta(current, baseline);
|
|
184
|
+
if (!delta) return null;
|
|
185
|
+
const packet = {
|
|
186
|
+
t: tick,
|
|
187
|
+
b: baseline ? baseTick : -1,
|
|
188
|
+
// -1 = keyframe
|
|
189
|
+
s: this.serialize(delta)
|
|
190
|
+
};
|
|
191
|
+
return pack(packet);
|
|
192
|
+
}
|
|
193
|
+
/** Deserialize a snapshot packet */
|
|
194
|
+
deserializePacket(data) {
|
|
195
|
+
const packet = unpack(data);
|
|
196
|
+
return {
|
|
197
|
+
tick: packet.t,
|
|
198
|
+
baseTick: packet.b,
|
|
199
|
+
entities: this.deserialize(packet.s)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
_hasChanged(current, prev) {
|
|
203
|
+
const t = this._thresholds;
|
|
204
|
+
if (Math.abs(current.x - prev.x) > t.position) return true;
|
|
205
|
+
if (Math.abs(current.y - prev.y) > t.position) return true;
|
|
206
|
+
if ("z" in current && "z" in prev) {
|
|
207
|
+
const c = current;
|
|
208
|
+
const p = prev;
|
|
209
|
+
if (Math.abs(c.z - p.z) > t.position) return true;
|
|
210
|
+
if (Math.abs(c.qx - p.qx) > t.rotation) return true;
|
|
211
|
+
if (Math.abs(c.qy - p.qy) > t.rotation) return true;
|
|
212
|
+
if (Math.abs(c.qz - p.qz) > t.rotation) return true;
|
|
213
|
+
if (Math.abs(c.qw - p.qw) > t.rotation) return true;
|
|
214
|
+
if (Math.abs(c.vx - p.vx) > t.velocity) return true;
|
|
215
|
+
if (Math.abs(c.vy - p.vy) > t.velocity) return true;
|
|
216
|
+
if (Math.abs(c.vz - p.vz) > t.velocity) return true;
|
|
217
|
+
if (Math.abs(c.wx - p.wx) > t.velocity) return true;
|
|
218
|
+
if (Math.abs(c.wy - p.wy) > t.velocity) return true;
|
|
219
|
+
if (Math.abs(c.wz - p.wz) > t.velocity) return true;
|
|
220
|
+
} else {
|
|
221
|
+
const c = current;
|
|
222
|
+
const p = prev;
|
|
223
|
+
if (Math.abs(c.a - p.a) > t.rotation) return true;
|
|
224
|
+
if (Math.abs(c.vx - p.vx) > t.velocity) return true;
|
|
225
|
+
if (Math.abs(c.vy - p.vy) > t.velocity) return true;
|
|
226
|
+
if (Math.abs(c.va - p.va) > t.velocity) return true;
|
|
227
|
+
}
|
|
228
|
+
if (current.c || prev.c) {
|
|
229
|
+
const cc = current.c ?? {};
|
|
230
|
+
const pc = prev.c ?? {};
|
|
231
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(cc), ...Object.keys(pc)]);
|
|
232
|
+
for (const key of allKeys) {
|
|
233
|
+
if (t.custom === "strict") {
|
|
234
|
+
if (cc[key] !== pc[key]) return true;
|
|
235
|
+
} else {
|
|
236
|
+
const diff = typeof cc[key] === "number" && typeof pc[key] === "number" ? Math.abs(cc[key] - pc[key]) : cc[key] === pc[key] ? 0 : 1;
|
|
237
|
+
if (diff > t.custom) return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
_quantizeEntity(entity) {
|
|
244
|
+
const q = this._quantize;
|
|
245
|
+
const result = { ...entity };
|
|
246
|
+
if (q.position !== void 0) {
|
|
247
|
+
const m = Math.pow(10, q.position);
|
|
248
|
+
result.x = Math.round(result.x * m) / m;
|
|
249
|
+
result.y = Math.round(result.y * m) / m;
|
|
250
|
+
if ("z" in result) {
|
|
251
|
+
result.z = Math.round(result.z * m) / m;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (q.rotation !== void 0) {
|
|
255
|
+
const m = Math.pow(10, q.rotation);
|
|
256
|
+
if ("a" in result) {
|
|
257
|
+
result.a = Math.round(result.a * m) / m;
|
|
258
|
+
} else if ("qx" in result) {
|
|
259
|
+
const r = result;
|
|
260
|
+
r.qx = Math.round(r.qx * m) / m;
|
|
261
|
+
r.qy = Math.round(r.qy * m) / m;
|
|
262
|
+
r.qz = Math.round(r.qz * m) / m;
|
|
263
|
+
r.qw = Math.round(r.qw * m) / m;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (q.velocity !== void 0) {
|
|
267
|
+
const m = Math.pow(10, q.velocity);
|
|
268
|
+
result.vx = Math.round(result.vx * m) / m;
|
|
269
|
+
result.vy = Math.round(result.vy * m) / m;
|
|
270
|
+
if ("vz" in result) {
|
|
271
|
+
const r = result;
|
|
272
|
+
r.vz = Math.round(r.vz * m) / m;
|
|
273
|
+
r.wx = Math.round(r.wx * m) / m;
|
|
274
|
+
r.wy = Math.round(r.wy * m) / m;
|
|
275
|
+
r.wz = Math.round(r.wz * m) / m;
|
|
276
|
+
}
|
|
277
|
+
if ("va" in result) {
|
|
278
|
+
result.va = Math.round(result.va * m) / m;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// src/core/NetworkManager.ts
|
|
286
|
+
var NetworkManager = class {
|
|
287
|
+
constructor(options = {}) {
|
|
288
|
+
// Transport
|
|
289
|
+
this._transport = null;
|
|
290
|
+
this._connectionState = "disconnected";
|
|
291
|
+
// Room state
|
|
292
|
+
this._room = null;
|
|
293
|
+
this._players = /* @__PURE__ */ new Map();
|
|
294
|
+
// Sync state
|
|
295
|
+
this._syncMode = "snapshot";
|
|
296
|
+
this._networkQuality = "good";
|
|
297
|
+
// Change listeners
|
|
298
|
+
this._connectionListeners = [];
|
|
299
|
+
this._playerListeners = [];
|
|
300
|
+
this._roomListeners = [];
|
|
301
|
+
this._errorListeners = [];
|
|
302
|
+
this._options = options;
|
|
303
|
+
this._syncMode = options.mode ?? "snapshot";
|
|
304
|
+
this._tickKeeper = new TickKeeper(options.tickRate ?? 60);
|
|
305
|
+
this._codec = new Codec({
|
|
306
|
+
thresholds: options.deltaThresholds,
|
|
307
|
+
quantize: options.quantize
|
|
308
|
+
});
|
|
309
|
+
this._snapshotBuffer = new SnapshotBuffer();
|
|
310
|
+
}
|
|
311
|
+
// -- Getters --
|
|
312
|
+
get transport() {
|
|
313
|
+
return this._transport;
|
|
314
|
+
}
|
|
315
|
+
get connectionState() {
|
|
316
|
+
return this._connectionState;
|
|
317
|
+
}
|
|
318
|
+
get room() {
|
|
319
|
+
return this._room;
|
|
320
|
+
}
|
|
321
|
+
get players() {
|
|
322
|
+
return this._players;
|
|
323
|
+
}
|
|
324
|
+
get selfId() {
|
|
325
|
+
return this._transport?.peerId || null;
|
|
326
|
+
}
|
|
327
|
+
get isHost() {
|
|
328
|
+
return this._transport?.isHost ?? false;
|
|
329
|
+
}
|
|
330
|
+
get hostId() {
|
|
331
|
+
return this._transport?.hostId ?? null;
|
|
332
|
+
}
|
|
333
|
+
get syncMode() {
|
|
334
|
+
return this._syncMode;
|
|
335
|
+
}
|
|
336
|
+
get tickKeeper() {
|
|
337
|
+
return this._tickKeeper;
|
|
338
|
+
}
|
|
339
|
+
get codec() {
|
|
340
|
+
return this._codec;
|
|
341
|
+
}
|
|
342
|
+
get snapshotBuffer() {
|
|
343
|
+
return this._snapshotBuffer;
|
|
344
|
+
}
|
|
345
|
+
get networkQuality() {
|
|
346
|
+
return this._networkQuality;
|
|
347
|
+
}
|
|
348
|
+
get options() {
|
|
349
|
+
return this._options;
|
|
350
|
+
}
|
|
351
|
+
// -- Transport management --
|
|
352
|
+
setTransport(transport) {
|
|
353
|
+
this._transport = transport;
|
|
354
|
+
transport.onPeerJoin((peerId) => {
|
|
355
|
+
if (!this._players.has(peerId)) {
|
|
356
|
+
this._players.set(peerId, {
|
|
357
|
+
peerId,
|
|
358
|
+
displayName: `Player-${peerId.slice(0, 4)}`,
|
|
359
|
+
isHost: peerId === transport.hostId,
|
|
360
|
+
isSelf: false,
|
|
361
|
+
isReady: false,
|
|
362
|
+
isConnected: true,
|
|
363
|
+
metadata: {},
|
|
364
|
+
latencyMs: 0,
|
|
365
|
+
joinedAt: Date.now()
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
this._notifyPlayerListeners();
|
|
369
|
+
});
|
|
370
|
+
transport.onPeerUpdated((player) => {
|
|
371
|
+
this._players.set(player.peerId, {
|
|
372
|
+
...player,
|
|
373
|
+
isSelf: player.peerId === this.selfId
|
|
374
|
+
});
|
|
375
|
+
this._notifyPlayerListeners();
|
|
376
|
+
});
|
|
377
|
+
transport.onPeerLeave((peerId) => {
|
|
378
|
+
this._players.delete(peerId);
|
|
379
|
+
this._notifyPlayerListeners();
|
|
380
|
+
});
|
|
381
|
+
transport.onHostChanged((_newHostId) => {
|
|
382
|
+
this._notifyRoomListeners();
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
// -- Connection state --
|
|
386
|
+
setConnectionState(state) {
|
|
387
|
+
this._connectionState = state;
|
|
388
|
+
for (const listener of this._connectionListeners) listener(state);
|
|
389
|
+
}
|
|
390
|
+
onConnectionStateChange(cb) {
|
|
391
|
+
this._connectionListeners.push(cb);
|
|
392
|
+
return () => {
|
|
393
|
+
const idx = this._connectionListeners.indexOf(cb);
|
|
394
|
+
if (idx >= 0) this._connectionListeners.splice(idx, 1);
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// -- Room state --
|
|
398
|
+
setRoom(room) {
|
|
399
|
+
this._room = room;
|
|
400
|
+
this._notifyRoomListeners();
|
|
401
|
+
}
|
|
402
|
+
onRoomChange(cb) {
|
|
403
|
+
this._roomListeners.push(cb);
|
|
404
|
+
return () => {
|
|
405
|
+
const idx = this._roomListeners.indexOf(cb);
|
|
406
|
+
if (idx >= 0) this._roomListeners.splice(idx, 1);
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// -- Players --
|
|
410
|
+
setPlayers(players) {
|
|
411
|
+
this._players.clear();
|
|
412
|
+
for (const p of players) {
|
|
413
|
+
this._players.set(p.peerId, p);
|
|
414
|
+
}
|
|
415
|
+
this._notifyPlayerListeners();
|
|
416
|
+
}
|
|
417
|
+
updatePlayer(player) {
|
|
418
|
+
this._players.set(player.peerId, player);
|
|
419
|
+
this._notifyPlayerListeners();
|
|
420
|
+
}
|
|
421
|
+
removePlayer(peerId) {
|
|
422
|
+
this._players.delete(peerId);
|
|
423
|
+
this._notifyPlayerListeners();
|
|
424
|
+
}
|
|
425
|
+
onPlayersChange(cb) {
|
|
426
|
+
this._playerListeners.push(cb);
|
|
427
|
+
return () => {
|
|
428
|
+
const idx = this._playerListeners.indexOf(cb);
|
|
429
|
+
if (idx >= 0) this._playerListeners.splice(idx, 1);
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// -- Errors --
|
|
433
|
+
emitError(error) {
|
|
434
|
+
for (const listener of this._errorListeners) listener(error);
|
|
435
|
+
}
|
|
436
|
+
onError(cb) {
|
|
437
|
+
this._errorListeners.push(cb);
|
|
438
|
+
return () => {
|
|
439
|
+
const idx = this._errorListeners.indexOf(cb);
|
|
440
|
+
if (idx >= 0) this._errorListeners.splice(idx, 1);
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// -- Network quality --
|
|
444
|
+
setNetworkQuality(quality) {
|
|
445
|
+
this._networkQuality = quality;
|
|
446
|
+
}
|
|
447
|
+
// -- Sync options --
|
|
448
|
+
updateOptions(options) {
|
|
449
|
+
this._options = options;
|
|
450
|
+
if (options.mode) this._syncMode = options.mode;
|
|
451
|
+
if (options.tickRate) this._tickKeeper.setTickRate(options.tickRate);
|
|
452
|
+
if (options.deltaThresholds || options.quantize) {
|
|
453
|
+
this._codec = new Codec({
|
|
454
|
+
thresholds: options.deltaThresholds,
|
|
455
|
+
quantize: options.quantize
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// -- Cleanup --
|
|
460
|
+
destroy() {
|
|
461
|
+
this._transport?.disconnect();
|
|
462
|
+
this._transport = null;
|
|
463
|
+
this._connectionState = "disconnected";
|
|
464
|
+
this._room = null;
|
|
465
|
+
this._players.clear();
|
|
466
|
+
this._tickKeeper.reset();
|
|
467
|
+
this._snapshotBuffer.clear();
|
|
468
|
+
this._connectionListeners = [];
|
|
469
|
+
this._playerListeners = [];
|
|
470
|
+
this._roomListeners = [];
|
|
471
|
+
this._errorListeners = [];
|
|
472
|
+
}
|
|
473
|
+
// -- Private --
|
|
474
|
+
_notifyPlayerListeners() {
|
|
475
|
+
for (const listener of this._playerListeners) listener();
|
|
476
|
+
}
|
|
477
|
+
_notifyRoomListeners() {
|
|
478
|
+
for (const listener of this._roomListeners) listener();
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// src/core/MultiplayerContext.ts
|
|
483
|
+
import { createContext, useContext } from "react";
|
|
484
|
+
var MultiplayerContext = createContext(null);
|
|
485
|
+
function useMultiplayerContext() {
|
|
486
|
+
const ctx = useContext(MultiplayerContext);
|
|
487
|
+
if (!ctx) {
|
|
488
|
+
throw new Error("useMultiplayerContext must be used inside a <MultiplayerProvider>.");
|
|
489
|
+
}
|
|
490
|
+
return ctx;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/components/MultiplayerProvider.ts
|
|
494
|
+
function createStrategy(appId, config) {
|
|
495
|
+
if (!config || config.type === "mqtt") {
|
|
496
|
+
return new MqttStrategy(appId, config ?? { type: "mqtt" });
|
|
497
|
+
}
|
|
498
|
+
if (config.type === "firebase") {
|
|
499
|
+
return new FirebaseStrategy(appId, config);
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`Unknown strategy type: ${config.type}`);
|
|
502
|
+
}
|
|
503
|
+
function MultiplayerProvider({
|
|
504
|
+
appId,
|
|
505
|
+
strategy: strategyConfig,
|
|
506
|
+
iceServers,
|
|
507
|
+
children
|
|
508
|
+
}) {
|
|
509
|
+
const managerRef = useRef(null);
|
|
510
|
+
const strategyRef = useRef(null);
|
|
511
|
+
if (!managerRef.current) {
|
|
512
|
+
managerRef.current = new NetworkManager();
|
|
513
|
+
}
|
|
514
|
+
if (!strategyRef.current) {
|
|
515
|
+
strategyRef.current = createStrategy(appId, strategyConfig);
|
|
516
|
+
}
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
return () => {
|
|
519
|
+
strategyRef.current?.destroy();
|
|
520
|
+
strategyRef.current = null;
|
|
521
|
+
managerRef.current?.destroy();
|
|
522
|
+
managerRef.current = null;
|
|
523
|
+
};
|
|
524
|
+
}, []);
|
|
525
|
+
const value = {
|
|
526
|
+
appId,
|
|
527
|
+
strategy: strategyRef.current,
|
|
528
|
+
iceServers,
|
|
529
|
+
networkManager: managerRef.current
|
|
530
|
+
};
|
|
531
|
+
return createElement(MultiplayerContext.Provider, { value }, children);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/components/MultiplayerBridge.ts
|
|
535
|
+
import { createElement as createElement2, useContext as useContext2 } from "react";
|
|
536
|
+
function MultiplayerBridge({ children }) {
|
|
537
|
+
const ctx = useContext2(MultiplayerContext);
|
|
538
|
+
if (!ctx) return createElement2("group", null, children);
|
|
539
|
+
return createElement2(MultiplayerContext.Provider, { value: ctx }, children);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/hooks/useRoom.ts
|
|
543
|
+
import { useState, useEffect as useEffect2, useCallback, useRef as useRef2 } from "react";
|
|
544
|
+
function useRoom(roomId, options) {
|
|
545
|
+
const { strategy, iceServers, networkManager } = useMultiplayerContext();
|
|
546
|
+
const [connectionState, setConnectionState] = useState("disconnected");
|
|
547
|
+
const [isHost, setIsHost] = useState(false);
|
|
548
|
+
const [hostId, setHostId] = useState(null);
|
|
549
|
+
const [selfId, setSelfId] = useState(null);
|
|
550
|
+
const [currentRoomId, setCurrentRoomId] = useState(null);
|
|
551
|
+
const [error, setError] = useState(null);
|
|
552
|
+
const [transport, setTransport] = useState(null);
|
|
553
|
+
const [room, setRoom] = useState(null);
|
|
554
|
+
const reconnectAttemptsRef = useRef2(0);
|
|
555
|
+
const maxReconnectAttempts = options?.reconnectAttempts ?? 3;
|
|
556
|
+
const optionsRef = useRef2(options);
|
|
557
|
+
optionsRef.current = options;
|
|
558
|
+
const createTransport = useCallback(() => {
|
|
559
|
+
const opt = optionsRef.current;
|
|
560
|
+
if (opt?.transport && typeof opt.transport === "object" && "connect" in opt.transport) {
|
|
561
|
+
return opt.transport;
|
|
562
|
+
}
|
|
563
|
+
const servers = opt?.iceServers ?? iceServers;
|
|
564
|
+
const policy = opt?.privacy === "relay" ? "relay" : "all";
|
|
565
|
+
return new WebRTCTransport(strategy, servers, policy);
|
|
566
|
+
}, [strategy, iceServers]);
|
|
567
|
+
const transportRef = useRef2(null);
|
|
568
|
+
const doJoin = useCallback(async (targetRoomId, joinOptions) => {
|
|
569
|
+
if (transportRef.current) {
|
|
570
|
+
transportRef.current.disconnect();
|
|
571
|
+
transportRef.current = null;
|
|
572
|
+
}
|
|
573
|
+
const t = createTransport();
|
|
574
|
+
transportRef.current = t;
|
|
575
|
+
try {
|
|
576
|
+
setError(null);
|
|
577
|
+
setConnectionState("connecting");
|
|
578
|
+
networkManager.setConnectionState("connecting");
|
|
579
|
+
setTransport(t);
|
|
580
|
+
networkManager.setTransport(t);
|
|
581
|
+
t.onPeerJoin(() => {
|
|
582
|
+
});
|
|
583
|
+
t.onPeerLeave(() => {
|
|
584
|
+
});
|
|
585
|
+
t.onHostChanged((newHostId) => {
|
|
586
|
+
setHostId(newHostId);
|
|
587
|
+
setIsHost(t.peerId === newHostId);
|
|
588
|
+
optionsRef.current?.onHostMigration?.(newHostId);
|
|
589
|
+
});
|
|
590
|
+
if ("onRoomUpdated" in t && typeof t.onRoomUpdated === "function") {
|
|
591
|
+
t.onRoomUpdated((updatedRoom) => {
|
|
592
|
+
networkManager.setRoom(updatedRoom);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
await t.connect(targetRoomId, {
|
|
596
|
+
displayName: optionsRef.current?.displayName,
|
|
597
|
+
playerMetadata: optionsRef.current?.playerMetadata,
|
|
598
|
+
password: joinOptions?.password ?? optionsRef.current?.password,
|
|
599
|
+
iceServers: optionsRef.current?.iceServers,
|
|
600
|
+
iceTransportPolicy: optionsRef.current?.privacy === "relay" ? "relay" : "all"
|
|
601
|
+
});
|
|
602
|
+
if (transportRef.current !== t) return;
|
|
603
|
+
setCurrentRoomId(targetRoomId);
|
|
604
|
+
setSelfId(t.peerId);
|
|
605
|
+
setHostId(t.hostId);
|
|
606
|
+
setIsHost(t.isHost);
|
|
607
|
+
setConnectionState("connected");
|
|
608
|
+
networkManager.setConnectionState("connected");
|
|
609
|
+
if (t.room) {
|
|
610
|
+
networkManager.setRoom(t.room);
|
|
611
|
+
}
|
|
612
|
+
if (t.initialPlayers) {
|
|
613
|
+
const players = t.initialPlayers.map((p) => ({
|
|
614
|
+
...p,
|
|
615
|
+
isSelf: p.peerId === t.peerId
|
|
616
|
+
}));
|
|
617
|
+
networkManager.setPlayers(players);
|
|
618
|
+
}
|
|
619
|
+
reconnectAttemptsRef.current = 0;
|
|
620
|
+
optionsRef.current?.onConnected?.();
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (transportRef.current !== t) return;
|
|
623
|
+
const carverError = {
|
|
624
|
+
code: "CONNECTION_FAILED",
|
|
625
|
+
message: err instanceof Error ? err.message : "Connection failed",
|
|
626
|
+
recoverable: reconnectAttemptsRef.current < maxReconnectAttempts
|
|
627
|
+
};
|
|
628
|
+
setError(carverError);
|
|
629
|
+
networkManager.emitError(carverError);
|
|
630
|
+
setConnectionState("disconnected");
|
|
631
|
+
networkManager.setConnectionState("disconnected");
|
|
632
|
+
optionsRef.current?.onError?.(carverError);
|
|
633
|
+
}
|
|
634
|
+
}, [createTransport, networkManager, maxReconnectAttempts]);
|
|
635
|
+
const leave = useCallback(() => {
|
|
636
|
+
if (transportRef.current) {
|
|
637
|
+
transportRef.current.disconnect();
|
|
638
|
+
transportRef.current = null;
|
|
639
|
+
}
|
|
640
|
+
setTransport(null);
|
|
641
|
+
setConnectionState("disconnected");
|
|
642
|
+
setCurrentRoomId(null);
|
|
643
|
+
setSelfId(null);
|
|
644
|
+
setHostId(null);
|
|
645
|
+
setIsHost(false);
|
|
646
|
+
setError(null);
|
|
647
|
+
networkManager.setConnectionState("disconnected");
|
|
648
|
+
optionsRef.current?.onDisconnected?.("user_left");
|
|
649
|
+
}, [networkManager]);
|
|
650
|
+
const setReady = useCallback((ready) => {
|
|
651
|
+
transport?.setReady?.(ready);
|
|
652
|
+
const selfPlayer = networkManager.players.get(transport?.peerId ?? "");
|
|
653
|
+
if (selfPlayer) {
|
|
654
|
+
networkManager.updatePlayer({ ...selfPlayer, isReady: ready });
|
|
655
|
+
}
|
|
656
|
+
}, [transport, networkManager]);
|
|
657
|
+
const setMetadata = useCallback((meta) => {
|
|
658
|
+
transport?.setMetadata?.(meta);
|
|
659
|
+
}, [transport]);
|
|
660
|
+
const setRoomMetadata = useCallback((meta) => {
|
|
661
|
+
transport?.setRoomMetadata?.(meta);
|
|
662
|
+
}, [transport]);
|
|
663
|
+
useEffect2(() => {
|
|
664
|
+
if (roomId) {
|
|
665
|
+
doJoin(roomId);
|
|
666
|
+
}
|
|
667
|
+
return () => {
|
|
668
|
+
if (transportRef.current) {
|
|
669
|
+
transportRef.current.disconnect();
|
|
670
|
+
transportRef.current = null;
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}, [roomId, doJoin]);
|
|
674
|
+
useEffect2(() => {
|
|
675
|
+
const unsub = networkManager.onRoomChange(() => {
|
|
676
|
+
setRoom(networkManager.room);
|
|
677
|
+
});
|
|
678
|
+
setRoom(networkManager.room);
|
|
679
|
+
return unsub;
|
|
680
|
+
}, [networkManager]);
|
|
681
|
+
return {
|
|
682
|
+
roomId: currentRoomId,
|
|
683
|
+
connectionState,
|
|
684
|
+
isHost,
|
|
685
|
+
hostId,
|
|
686
|
+
selfId,
|
|
687
|
+
room,
|
|
688
|
+
error,
|
|
689
|
+
join: doJoin,
|
|
690
|
+
leave,
|
|
691
|
+
setReady,
|
|
692
|
+
setMetadata,
|
|
693
|
+
setRoomMetadata,
|
|
694
|
+
transport
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/hooks/useLobby.ts
|
|
699
|
+
import { useState as useState2, useEffect as useEffect3, useCallback as useCallback2, useRef as useRef3 } from "react";
|
|
700
|
+
function announcementToRoom(ann) {
|
|
701
|
+
return {
|
|
702
|
+
id: ann.roomId,
|
|
703
|
+
name: ann.name,
|
|
704
|
+
hostId: ann.hostId,
|
|
705
|
+
playerCount: ann.playerCount,
|
|
706
|
+
maxPlayers: ann.maxPlayers,
|
|
707
|
+
gameMode: ann.gameMode,
|
|
708
|
+
isPrivate: ann.isPrivate,
|
|
709
|
+
metadata: ann.metadata,
|
|
710
|
+
createdAt: ann.createdAt,
|
|
711
|
+
state: "lobby"
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function useLobby(options) {
|
|
715
|
+
const { strategy } = useMultiplayerContext();
|
|
716
|
+
const [rooms, setRooms] = useState2([]);
|
|
717
|
+
const [isLoading, setIsLoading] = useState2(true);
|
|
718
|
+
const [error, setError] = useState2(null);
|
|
719
|
+
const optionsRef = useRef3(options);
|
|
720
|
+
optionsRef.current = options;
|
|
721
|
+
const filterRooms = useCallback2((roomList) => {
|
|
722
|
+
const filter = optionsRef.current?.filter;
|
|
723
|
+
if (!filter) return roomList;
|
|
724
|
+
return roomList.filter((room) => {
|
|
725
|
+
if (filter.maxPlayers !== void 0 && room.maxPlayers > filter.maxPlayers) return false;
|
|
726
|
+
if (filter.gameMode !== void 0 && room.gameMode !== filter.gameMode) return false;
|
|
727
|
+
if (filter.hasPassword !== void 0 && room.isPrivate !== filter.hasPassword) return false;
|
|
728
|
+
return true;
|
|
729
|
+
});
|
|
730
|
+
}, []);
|
|
731
|
+
useEffect3(() => {
|
|
732
|
+
setIsLoading(true);
|
|
733
|
+
setError(null);
|
|
734
|
+
const unsub = strategy.subscribeToLobby((announcements) => {
|
|
735
|
+
const converted = announcements.map(announcementToRoom);
|
|
736
|
+
setRooms(filterRooms(converted));
|
|
737
|
+
setIsLoading(false);
|
|
738
|
+
});
|
|
739
|
+
const timeout = setTimeout(() => setIsLoading(false), 3e3);
|
|
740
|
+
return () => {
|
|
741
|
+
unsub();
|
|
742
|
+
clearTimeout(timeout);
|
|
743
|
+
};
|
|
744
|
+
}, [strategy, filterRooms]);
|
|
745
|
+
const refresh = useCallback2(() => {
|
|
746
|
+
setIsLoading(true);
|
|
747
|
+
setTimeout(() => setIsLoading(false), 1e3);
|
|
748
|
+
}, []);
|
|
749
|
+
const createRoom = useCallback2(async (config) => {
|
|
750
|
+
const roomId = `${config.name.toLowerCase().replace(/\s+/g, "-")}-${Date.now().toString(36)}`;
|
|
751
|
+
const announcement = {
|
|
752
|
+
roomId,
|
|
753
|
+
name: config.name,
|
|
754
|
+
hostId: strategy.selfId,
|
|
755
|
+
playerCount: 0,
|
|
756
|
+
maxPlayers: config.maxPlayers ?? 8,
|
|
757
|
+
gameMode: config.metadata?.gameMode,
|
|
758
|
+
isPrivate: config.isPrivate ?? false,
|
|
759
|
+
metadata: config.metadata ?? {},
|
|
760
|
+
createdAt: Date.now(),
|
|
761
|
+
lastSeen: Date.now()
|
|
762
|
+
};
|
|
763
|
+
strategy.announceRoom(announcement);
|
|
764
|
+
return roomId;
|
|
765
|
+
}, [strategy]);
|
|
766
|
+
return {
|
|
767
|
+
rooms,
|
|
768
|
+
isLoading,
|
|
769
|
+
error,
|
|
770
|
+
refresh,
|
|
771
|
+
createRoom
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/hooks/usePlayers.ts
|
|
776
|
+
import { useState as useState3, useEffect as useEffect4, useCallback as useCallback3 } from "react";
|
|
777
|
+
function usePlayers() {
|
|
778
|
+
const { networkManager } = useMultiplayerContext();
|
|
779
|
+
const [players, setPlayers] = useState3([]);
|
|
780
|
+
const [, setVersion] = useState3(0);
|
|
781
|
+
useEffect4(() => {
|
|
782
|
+
const unsubscribe = networkManager.onPlayersChange(() => {
|
|
783
|
+
setPlayers(Array.from(networkManager.players.values()));
|
|
784
|
+
setVersion((v) => v + 1);
|
|
785
|
+
});
|
|
786
|
+
setPlayers(Array.from(networkManager.players.values()));
|
|
787
|
+
return unsubscribe;
|
|
788
|
+
}, [networkManager]);
|
|
789
|
+
const self = players.find((p) => p.isSelf) ?? null;
|
|
790
|
+
const host = players.find((p) => p.isHost) ?? null;
|
|
791
|
+
const allReady = players.length > 0 && players.every((p) => p.isReady);
|
|
792
|
+
const getPlayer = useCallback3(
|
|
793
|
+
(peerId) => players.find((p) => p.peerId === peerId),
|
|
794
|
+
[players]
|
|
795
|
+
);
|
|
796
|
+
return {
|
|
797
|
+
players,
|
|
798
|
+
self,
|
|
799
|
+
host,
|
|
800
|
+
count: players.length,
|
|
801
|
+
allReady,
|
|
802
|
+
getPlayer
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/hooks/useHost.ts
|
|
807
|
+
import { useCallback as useCallback4 } from "react";
|
|
808
|
+
function useHost() {
|
|
809
|
+
const { networkManager } = useMultiplayerContext();
|
|
810
|
+
const getTransport = useCallback4(() => {
|
|
811
|
+
const transport = networkManager.transport;
|
|
812
|
+
if (!transport || !networkManager.isHost) return null;
|
|
813
|
+
return transport;
|
|
814
|
+
}, [networkManager]);
|
|
815
|
+
const kick = useCallback4((peerId, reason) => {
|
|
816
|
+
getTransport()?.kick?.(peerId, reason);
|
|
817
|
+
}, [getTransport]);
|
|
818
|
+
const transferHost = useCallback4((peerId) => {
|
|
819
|
+
getTransport()?.transferHost?.(peerId);
|
|
820
|
+
}, [getTransport]);
|
|
821
|
+
const setRoomState = useCallback4((state) => {
|
|
822
|
+
getTransport()?.setRoomState?.(state);
|
|
823
|
+
}, [getTransport]);
|
|
824
|
+
const setMaxPlayers = useCallback4((n) => {
|
|
825
|
+
getTransport()?.setMaxPlayers?.(n);
|
|
826
|
+
}, [getTransport]);
|
|
827
|
+
const lockRoom = useCallback4(() => {
|
|
828
|
+
getTransport()?.lockRoom?.();
|
|
829
|
+
}, [getTransport]);
|
|
830
|
+
const unlockRoom = useCallback4(() => {
|
|
831
|
+
getTransport()?.unlockRoom?.();
|
|
832
|
+
}, [getTransport]);
|
|
833
|
+
return {
|
|
834
|
+
kick,
|
|
835
|
+
transferHost,
|
|
836
|
+
setRoomState,
|
|
837
|
+
setMaxPlayers,
|
|
838
|
+
lockRoom,
|
|
839
|
+
unlockRoom
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/hooks/useMultiplayer.ts
|
|
844
|
+
import { useEffect as useEffect5, useRef as useRef4, useState as useState4, useCallback as useCallback5 } from "react";
|
|
845
|
+
import { useFrame } from "@react-three/fiber";
|
|
846
|
+
import { getActorRegistry } from "@carverjs/core/systems";
|
|
847
|
+
|
|
848
|
+
// src/core/NetworkSimulator.ts
|
|
849
|
+
var NetworkSimulator = class {
|
|
850
|
+
constructor(options) {
|
|
851
|
+
this.sentCount = 0;
|
|
852
|
+
this.droppedCount = 0;
|
|
853
|
+
this.latencySum = 0;
|
|
854
|
+
// running sum of applied latencies
|
|
855
|
+
/** Active timeout handles so we can cancel them on destroy() */
|
|
856
|
+
this.pending = /* @__PURE__ */ new Set();
|
|
857
|
+
const opts = options ?? {};
|
|
858
|
+
this.latencyMs = opts.latencyMs ?? 0;
|
|
859
|
+
this.packetLoss = opts.packetLoss ?? 0;
|
|
860
|
+
this.jitterMs = opts.jitterMs ?? 0;
|
|
861
|
+
}
|
|
862
|
+
/* ---- public API ---- */
|
|
863
|
+
/** Update simulation parameters at runtime. */
|
|
864
|
+
setOptions(options) {
|
|
865
|
+
if (options.latencyMs !== void 0) this.latencyMs = options.latencyMs;
|
|
866
|
+
if (options.packetLoss !== void 0) this.packetLoss = options.packetLoss;
|
|
867
|
+
if (options.jitterMs !== void 0) this.jitterMs = options.jitterMs;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Wrap an existing send function so that every call goes through the
|
|
871
|
+
* simulated network conditions (latency, jitter, packet loss).
|
|
872
|
+
*/
|
|
873
|
+
wrapSend(originalSend) {
|
|
874
|
+
return (data, target) => {
|
|
875
|
+
if (Math.random() < this.packetLoss) {
|
|
876
|
+
this.droppedCount++;
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
this.sentCount++;
|
|
880
|
+
const jitter = this.jitterMs > 0 ? Math.random() * 2 * this.jitterMs - this.jitterMs : 0;
|
|
881
|
+
const delay = Math.max(0, this.latencyMs + jitter);
|
|
882
|
+
this.latencySum += delay;
|
|
883
|
+
if (delay === 0) {
|
|
884
|
+
originalSend(data, target);
|
|
885
|
+
} else {
|
|
886
|
+
const handle = setTimeout(() => {
|
|
887
|
+
this.pending.delete(handle);
|
|
888
|
+
originalSend(data, target);
|
|
889
|
+
}, delay);
|
|
890
|
+
this.pending.add(handle);
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/** Current statistics snapshot. */
|
|
895
|
+
get stats() {
|
|
896
|
+
return {
|
|
897
|
+
sentCount: this.sentCount,
|
|
898
|
+
droppedCount: this.droppedCount,
|
|
899
|
+
avgLatencyMs: this.sentCount > 0 ? this.latencySum / this.sentCount : 0
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
/** Cancel all pending delayed sends and clean up. */
|
|
903
|
+
destroy() {
|
|
904
|
+
for (const handle of this.pending) {
|
|
905
|
+
clearTimeout(handle);
|
|
906
|
+
}
|
|
907
|
+
this.pending.clear();
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
// src/hooks/useMultiplayer.ts
|
|
912
|
+
var Z_THRESHOLD = 0.01;
|
|
913
|
+
function detect2D(actors) {
|
|
914
|
+
for (const [, ref] of actors) {
|
|
915
|
+
if (Math.abs(ref.object3D.position.z) > Z_THRESHOLD) return false;
|
|
916
|
+
}
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
function readEntityState2D(ref) {
|
|
920
|
+
const pos = ref.object3D.position;
|
|
921
|
+
const rot = ref.object3D.rotation;
|
|
922
|
+
const rb = ref.rigidBody;
|
|
923
|
+
let vx = 0;
|
|
924
|
+
let vy = 0;
|
|
925
|
+
let va = 0;
|
|
926
|
+
if (rb) {
|
|
927
|
+
try {
|
|
928
|
+
const lv = rb.linvel();
|
|
929
|
+
vx = lv.x;
|
|
930
|
+
vy = lv.y;
|
|
931
|
+
} catch {
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const av = rb.angvel();
|
|
935
|
+
va = typeof av === "number" ? av : av?.z ?? 0;
|
|
936
|
+
} catch {
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const nc = ref.userData.networked;
|
|
940
|
+
const custom = nc?.custom;
|
|
941
|
+
return {
|
|
942
|
+
id: ref.id,
|
|
943
|
+
x: pos.x,
|
|
944
|
+
y: pos.y,
|
|
945
|
+
a: rot.z,
|
|
946
|
+
vx,
|
|
947
|
+
vy,
|
|
948
|
+
va,
|
|
949
|
+
...custom ? { c: custom } : {}
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
function readEntityState3D(ref) {
|
|
953
|
+
const pos = ref.object3D.position;
|
|
954
|
+
const quat = ref.object3D.quaternion;
|
|
955
|
+
const rb = ref.rigidBody;
|
|
956
|
+
let vx = 0;
|
|
957
|
+
let vy = 0;
|
|
958
|
+
let vz = 0;
|
|
959
|
+
let wx = 0;
|
|
960
|
+
let wy = 0;
|
|
961
|
+
let wz = 0;
|
|
962
|
+
if (rb) {
|
|
963
|
+
try {
|
|
964
|
+
const lv = rb.linvel();
|
|
965
|
+
vx = lv.x;
|
|
966
|
+
vy = lv.y;
|
|
967
|
+
vz = lv.z ?? 0;
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
try {
|
|
971
|
+
const av = rb.angvel();
|
|
972
|
+
wx = av.x ?? 0;
|
|
973
|
+
wy = av.y ?? 0;
|
|
974
|
+
wz = av.z ?? 0;
|
|
975
|
+
} catch {
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const nc = ref.userData.networked;
|
|
979
|
+
const custom = nc?.custom;
|
|
980
|
+
return {
|
|
981
|
+
id: ref.id,
|
|
982
|
+
x: pos.x,
|
|
983
|
+
y: pos.y,
|
|
984
|
+
z: pos.z,
|
|
985
|
+
qx: quat.x,
|
|
986
|
+
qy: quat.y,
|
|
987
|
+
qz: quat.z,
|
|
988
|
+
qw: quat.w,
|
|
989
|
+
vx,
|
|
990
|
+
vy,
|
|
991
|
+
vz,
|
|
992
|
+
wx,
|
|
993
|
+
wy,
|
|
994
|
+
wz,
|
|
995
|
+
...custom ? { c: custom } : {}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
function buildEntityMap(actors, is2D) {
|
|
999
|
+
const entities = /* @__PURE__ */ new Map();
|
|
1000
|
+
for (const [id, ref] of actors) {
|
|
1001
|
+
const nc = ref.userData.networked;
|
|
1002
|
+
if (nc && nc.sync === false) continue;
|
|
1003
|
+
entities.set(id, is2D ? readEntityState2D(ref) : readEntityState3D(ref));
|
|
1004
|
+
}
|
|
1005
|
+
return entities;
|
|
1006
|
+
}
|
|
1007
|
+
function applyState2D(ref, state) {
|
|
1008
|
+
ref.object3D.position.set(state.x, state.y, 0);
|
|
1009
|
+
ref.object3D.rotation.z = state.a;
|
|
1010
|
+
if (ref.rigidBody) {
|
|
1011
|
+
try {
|
|
1012
|
+
if (typeof ref.rigidBody.setTranslation === "function") {
|
|
1013
|
+
ref.rigidBody.setTranslation({ x: state.x, y: state.y }, true);
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof ref.rigidBody.setRotation === "function") {
|
|
1016
|
+
ref.rigidBody.setRotation(state.a, true);
|
|
1017
|
+
}
|
|
1018
|
+
} catch {
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
function applyState3D(ref, state) {
|
|
1023
|
+
ref.object3D.position.set(state.x, state.y, state.z);
|
|
1024
|
+
ref.object3D.quaternion.set(state.qx, state.qy, state.qz, state.qw);
|
|
1025
|
+
if (ref.rigidBody) {
|
|
1026
|
+
try {
|
|
1027
|
+
if (typeof ref.rigidBody.setTranslation === "function") {
|
|
1028
|
+
ref.rigidBody.setTranslation({ x: state.x, y: state.y, z: state.z }, true);
|
|
1029
|
+
}
|
|
1030
|
+
if (typeof ref.rigidBody.setRotation === "function") {
|
|
1031
|
+
ref.rigidBody.setRotation(
|
|
1032
|
+
{ x: state.qx, y: state.qy, z: state.qz, w: state.qw },
|
|
1033
|
+
true
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function applyEntityState(ref, state, is2D) {
|
|
1041
|
+
if (is2D) {
|
|
1042
|
+
applyState2D(ref, state);
|
|
1043
|
+
} else {
|
|
1044
|
+
applyState3D(ref, state);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
function applyStatesToActors(states, registry, is2D) {
|
|
1048
|
+
for (const [id, state] of states) {
|
|
1049
|
+
if (state.c && state.c.__removed) continue;
|
|
1050
|
+
const ref = registry.get(id);
|
|
1051
|
+
if (!ref) continue;
|
|
1052
|
+
applyEntityState(ref, state, is2D);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function useMultiplayer(options = {}) {
|
|
1056
|
+
const { networkManager } = useMultiplayerContext();
|
|
1057
|
+
const mode = options.mode ?? networkManager.syncMode;
|
|
1058
|
+
const eventSyncRef = useRef4(null);
|
|
1059
|
+
const snapshotSyncRef = useRef4(null);
|
|
1060
|
+
const predictionSyncRef = useRef4(null);
|
|
1061
|
+
const networkSimulatorRef = useRef4(null);
|
|
1062
|
+
const is2DRef = useRef4(null);
|
|
1063
|
+
const [isActive, setIsActive] = useState4(false);
|
|
1064
|
+
const [networkQuality, setNetworkQuality] = useState4("good");
|
|
1065
|
+
const [tick, setTick] = useState4(0);
|
|
1066
|
+
const [serverTick, setServerTick] = useState4(0);
|
|
1067
|
+
const [drift, setDrift] = useState4(0);
|
|
1068
|
+
const actorRegistry = useRef4(getActorRegistry());
|
|
1069
|
+
const wasHostRef = useRef4(null);
|
|
1070
|
+
const buildSnapshotOpts = useCallback5(() => {
|
|
1071
|
+
return {
|
|
1072
|
+
broadcastRate: options.broadcastRate,
|
|
1073
|
+
keyframeInterval: options.keyframeInterval,
|
|
1074
|
+
bufferSize: options.interpolation?.bufferSize,
|
|
1075
|
+
interpolationMethod: options.interpolation?.method,
|
|
1076
|
+
extrapolateMs: options.interpolation?.extrapolateMs,
|
|
1077
|
+
is2D: is2DRef.current ?? true
|
|
1078
|
+
};
|
|
1079
|
+
}, [
|
|
1080
|
+
options.broadcastRate,
|
|
1081
|
+
options.keyframeInterval,
|
|
1082
|
+
options.interpolation?.bufferSize,
|
|
1083
|
+
options.interpolation?.method,
|
|
1084
|
+
options.interpolation?.extrapolateMs
|
|
1085
|
+
]);
|
|
1086
|
+
useEffect5(() => {
|
|
1087
|
+
const transport = networkManager.transport;
|
|
1088
|
+
if (!transport) return;
|
|
1089
|
+
const debugOpts = options.debug;
|
|
1090
|
+
let simulator = null;
|
|
1091
|
+
if (debugOpts?.simulatedLatencyMs || debugOpts?.simulatedPacketLoss) {
|
|
1092
|
+
simulator = new NetworkSimulator({
|
|
1093
|
+
latencyMs: debugOpts.simulatedLatencyMs,
|
|
1094
|
+
packetLoss: debugOpts.simulatedPacketLoss
|
|
1095
|
+
});
|
|
1096
|
+
networkSimulatorRef.current = simulator;
|
|
1097
|
+
const origCreateChannel = transport.createChannel.bind(transport);
|
|
1098
|
+
transport.createChannel = (name, channelOpts) => {
|
|
1099
|
+
const ch = origCreateChannel(name, channelOpts);
|
|
1100
|
+
const wrappedSend = simulator.wrapSend(ch.send.bind(ch));
|
|
1101
|
+
return { ...ch, send: wrappedSend };
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
eventSyncRef.current = new EventSync(transport);
|
|
1105
|
+
if (mode === "snapshot" || mode === "prediction") {
|
|
1106
|
+
snapshotSyncRef.current = new SnapshotSync(
|
|
1107
|
+
transport,
|
|
1108
|
+
networkManager.codec,
|
|
1109
|
+
networkManager.snapshotBuffer,
|
|
1110
|
+
buildSnapshotOpts()
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
if (mode === "prediction") {
|
|
1114
|
+
predictionSyncRef.current = new PredictionSync(
|
|
1115
|
+
transport,
|
|
1116
|
+
networkManager.codec,
|
|
1117
|
+
networkManager.tickKeeper,
|
|
1118
|
+
options.prediction
|
|
1119
|
+
);
|
|
1120
|
+
if (options.onPhysicsStep) {
|
|
1121
|
+
predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
wasHostRef.current = networkManager.isHost;
|
|
1125
|
+
setIsActive(true);
|
|
1126
|
+
const unsubHostChanged = (() => {
|
|
1127
|
+
const onHostChanged = (newHostId) => {
|
|
1128
|
+
const amNewHost = newHostId === transport.peerId;
|
|
1129
|
+
const wasPreviouslyHost = wasHostRef.current;
|
|
1130
|
+
wasHostRef.current = amNewHost;
|
|
1131
|
+
if (amNewHost && !wasPreviouslyHost) {
|
|
1132
|
+
snapshotSyncRef.current?.promoteToHost(buildSnapshotOpts());
|
|
1133
|
+
} else if (!amNewHost && wasPreviouslyHost) {
|
|
1134
|
+
snapshotSyncRef.current?.demoteToClient(buildSnapshotOpts());
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
transport.onHostChanged(onHostChanged);
|
|
1138
|
+
return () => {
|
|
1139
|
+
};
|
|
1140
|
+
})();
|
|
1141
|
+
return () => {
|
|
1142
|
+
unsubHostChanged();
|
|
1143
|
+
eventSyncRef.current?.destroy();
|
|
1144
|
+
snapshotSyncRef.current?.destroy();
|
|
1145
|
+
predictionSyncRef.current?.destroy();
|
|
1146
|
+
networkSimulatorRef.current?.destroy();
|
|
1147
|
+
eventSyncRef.current = null;
|
|
1148
|
+
snapshotSyncRef.current = null;
|
|
1149
|
+
predictionSyncRef.current = null;
|
|
1150
|
+
networkSimulatorRef.current = null;
|
|
1151
|
+
setIsActive(false);
|
|
1152
|
+
};
|
|
1153
|
+
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
|
|
1154
|
+
useEffect5(() => {
|
|
1155
|
+
const unsub = networkManager.onConnectionStateChange(() => {
|
|
1156
|
+
setNetworkQuality(networkManager.networkQuality);
|
|
1157
|
+
});
|
|
1158
|
+
return unsub;
|
|
1159
|
+
}, [networkManager]);
|
|
1160
|
+
useFrame((_state, delta) => {
|
|
1161
|
+
const transport = networkManager.transport;
|
|
1162
|
+
if (!transport) return;
|
|
1163
|
+
const tickKeeper = networkManager.tickKeeper;
|
|
1164
|
+
const isHost = networkManager.isHost;
|
|
1165
|
+
if (is2DRef.current === null) {
|
|
1166
|
+
const networked = actorRegistry.current.getNetworked();
|
|
1167
|
+
if (networked.size > 0) {
|
|
1168
|
+
is2DRef.current = detect2D(networked);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const is2D = is2DRef.current ?? true;
|
|
1172
|
+
if (mode === "events") return;
|
|
1173
|
+
const ticksThisFrame = tickKeeper.update(delta);
|
|
1174
|
+
for (let i = 0; i < ticksThisFrame; i++) {
|
|
1175
|
+
const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
|
|
1176
|
+
if (isHost) {
|
|
1177
|
+
const networked = actorRegistry.current.getNetworked();
|
|
1178
|
+
const entities = buildEntityMap(networked, is2D);
|
|
1179
|
+
if (mode === "snapshot" || mode === "prediction") {
|
|
1180
|
+
snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
1181
|
+
}
|
|
1182
|
+
if (mode === "prediction") {
|
|
1183
|
+
predictionSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
1184
|
+
}
|
|
1185
|
+
} else {
|
|
1186
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
1187
|
+
predictionSyncRef.current.clientTick(currentTick);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (!isHost) {
|
|
1192
|
+
if (mode === "snapshot" && snapshotSyncRef.current) {
|
|
1193
|
+
const renderTime = performance.now();
|
|
1194
|
+
const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
|
|
1195
|
+
applyStatesToActors(interpolated, actorRegistry.current, is2D);
|
|
1196
|
+
} else if (mode === "prediction" && predictionSyncRef.current) {
|
|
1197
|
+
const predicted = predictionSyncRef.current.predictedState;
|
|
1198
|
+
const smoothed = predictionSyncRef.current.applyErrorSmoothing(predicted);
|
|
1199
|
+
applyStatesToActors(smoothed, actorRegistry.current, is2D);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (ticksThisFrame > 0) {
|
|
1203
|
+
const newTick = tickKeeper.tick;
|
|
1204
|
+
const newServerTick = tickKeeper.serverTick;
|
|
1205
|
+
const newDrift = tickKeeper.drift;
|
|
1206
|
+
const newQuality = networkManager.networkQuality;
|
|
1207
|
+
setTick((prev) => prev !== newTick ? newTick : prev);
|
|
1208
|
+
setServerTick((prev) => prev !== newServerTick ? newServerTick : prev);
|
|
1209
|
+
setDrift((prev) => prev !== newDrift ? newDrift : prev);
|
|
1210
|
+
setNetworkQuality((prev) => prev !== newQuality ? newQuality : prev);
|
|
1211
|
+
}
|
|
1212
|
+
}, -55);
|
|
1213
|
+
return {
|
|
1214
|
+
isActive,
|
|
1215
|
+
networkQuality,
|
|
1216
|
+
tick,
|
|
1217
|
+
serverTick,
|
|
1218
|
+
drift,
|
|
1219
|
+
syncEngine: mode
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/hooks/useNetworkEvents.ts
|
|
1224
|
+
import { useRef as useRef5, useEffect as useEffect6, useCallback as useCallback6 } from "react";
|
|
1225
|
+
function useNetworkEvents(options) {
|
|
1226
|
+
const { networkManager } = useMultiplayerContext();
|
|
1227
|
+
const eventSyncRef = useRef5(null);
|
|
1228
|
+
const pendingRef = useRef5([]);
|
|
1229
|
+
const drainedUnsubsRef = useRef5([]);
|
|
1230
|
+
useEffect6(() => {
|
|
1231
|
+
const transport = networkManager.transport;
|
|
1232
|
+
if (!transport) return;
|
|
1233
|
+
const eventSync = new EventSync(transport, {
|
|
1234
|
+
hostValidation: options?.hostValidation
|
|
1235
|
+
});
|
|
1236
|
+
eventSyncRef.current = eventSync;
|
|
1237
|
+
const unsubs = [];
|
|
1238
|
+
for (const entry of pendingRef.current) {
|
|
1239
|
+
const unsub = eventSync.onEvent(entry.type, entry.callback);
|
|
1240
|
+
entry.unsub = unsub;
|
|
1241
|
+
unsubs.push(unsub);
|
|
1242
|
+
}
|
|
1243
|
+
pendingRef.current = [];
|
|
1244
|
+
drainedUnsubsRef.current = unsubs;
|
|
1245
|
+
return () => {
|
|
1246
|
+
for (const unsub of drainedUnsubsRef.current) {
|
|
1247
|
+
unsub();
|
|
1248
|
+
}
|
|
1249
|
+
drainedUnsubsRef.current = [];
|
|
1250
|
+
eventSync.destroy();
|
|
1251
|
+
eventSyncRef.current = null;
|
|
1252
|
+
};
|
|
1253
|
+
}, [networkManager.transport, options?.hostValidation]);
|
|
1254
|
+
const sendEvent = useCallback6((type, payload, target) => {
|
|
1255
|
+
eventSyncRef.current?.sendEvent(type, payload, target);
|
|
1256
|
+
}, []);
|
|
1257
|
+
const broadcast = useCallback6((type, payload) => {
|
|
1258
|
+
eventSyncRef.current?.broadcast(type, payload);
|
|
1259
|
+
}, []);
|
|
1260
|
+
const onEvent = useCallback6((type, callback) => {
|
|
1261
|
+
const castCallback = callback;
|
|
1262
|
+
if (eventSyncRef.current) {
|
|
1263
|
+
return eventSyncRef.current.onEvent(type, castCallback);
|
|
1264
|
+
}
|
|
1265
|
+
const entry = { type, callback: castCallback, unsub: null };
|
|
1266
|
+
pendingRef.current.push(entry);
|
|
1267
|
+
return () => {
|
|
1268
|
+
if (entry.unsub) {
|
|
1269
|
+
entry.unsub();
|
|
1270
|
+
drainedUnsubsRef.current = drainedUnsubsRef.current.filter((u) => u !== entry.unsub);
|
|
1271
|
+
} else {
|
|
1272
|
+
pendingRef.current = pendingRef.current.filter((e) => e !== entry);
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
}, []);
|
|
1276
|
+
return { sendEvent, broadcast, onEvent };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/hooks/useNetworkState.ts
|
|
1280
|
+
import { useState as useState5, useEffect as useEffect7, useCallback as useCallback7, useRef as useRef6 } from "react";
|
|
1281
|
+
var CHANNEL_NAME = "carver:network-state";
|
|
1282
|
+
function useNetworkState() {
|
|
1283
|
+
const { networkManager } = useMultiplayerContext();
|
|
1284
|
+
const [entities, setEntities] = useState5(
|
|
1285
|
+
() => /* @__PURE__ */ new Map()
|
|
1286
|
+
);
|
|
1287
|
+
const entitiesRef = useRef6(entities);
|
|
1288
|
+
entitiesRef.current = entities;
|
|
1289
|
+
const channelRef = useRef6(null);
|
|
1290
|
+
const replaceEntities = useCallback7(
|
|
1291
|
+
(updater) => {
|
|
1292
|
+
setEntities((prev) => {
|
|
1293
|
+
const next = updater(prev);
|
|
1294
|
+
entitiesRef.current = next;
|
|
1295
|
+
return next;
|
|
1296
|
+
});
|
|
1297
|
+
},
|
|
1298
|
+
[]
|
|
1299
|
+
);
|
|
1300
|
+
const applySpawn = useCallback7(
|
|
1301
|
+
(id, state) => {
|
|
1302
|
+
replaceEntities((prev) => {
|
|
1303
|
+
const next = new Map(prev);
|
|
1304
|
+
next.set(id, state);
|
|
1305
|
+
return next;
|
|
1306
|
+
});
|
|
1307
|
+
},
|
|
1308
|
+
[replaceEntities]
|
|
1309
|
+
);
|
|
1310
|
+
const applyDespawn = useCallback7(
|
|
1311
|
+
(id) => {
|
|
1312
|
+
replaceEntities((prev) => {
|
|
1313
|
+
if (!prev.has(id)) return prev;
|
|
1314
|
+
const next = new Map(prev);
|
|
1315
|
+
next.delete(id);
|
|
1316
|
+
return next;
|
|
1317
|
+
});
|
|
1318
|
+
},
|
|
1319
|
+
[replaceEntities]
|
|
1320
|
+
);
|
|
1321
|
+
useEffect7(() => {
|
|
1322
|
+
const transport = networkManager.transport;
|
|
1323
|
+
if (!transport) return;
|
|
1324
|
+
const channel = transport.createChannel(CHANNEL_NAME, {
|
|
1325
|
+
reliable: true,
|
|
1326
|
+
ordered: true
|
|
1327
|
+
});
|
|
1328
|
+
channelRef.current = channel;
|
|
1329
|
+
channel.onReceive((msg, _peerId) => {
|
|
1330
|
+
switch (msg.action) {
|
|
1331
|
+
// --- Spawn broadcast (sent by host to everyone) ---
|
|
1332
|
+
case "spawn": {
|
|
1333
|
+
if (msg.state) {
|
|
1334
|
+
applySpawn(msg.id, msg.state);
|
|
1335
|
+
}
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
// --- Despawn broadcast (sent by host to everyone) ---
|
|
1339
|
+
case "despawn": {
|
|
1340
|
+
applyDespawn(msg.id);
|
|
1341
|
+
break;
|
|
1342
|
+
}
|
|
1343
|
+
// --- Request-spawn (client -> host only) ---
|
|
1344
|
+
case "request-spawn": {
|
|
1345
|
+
if (!transport.isHost) break;
|
|
1346
|
+
if (entitiesRef.current.has(msg.id)) break;
|
|
1347
|
+
const config = msg.state ?? {};
|
|
1348
|
+
const newState = {
|
|
1349
|
+
id: msg.id,
|
|
1350
|
+
x: 0,
|
|
1351
|
+
y: 0,
|
|
1352
|
+
a: 0,
|
|
1353
|
+
vx: 0,
|
|
1354
|
+
vy: 0,
|
|
1355
|
+
va: 0,
|
|
1356
|
+
...config,
|
|
1357
|
+
// Ensure id is authoritative.
|
|
1358
|
+
...config.id !== void 0 ? {} : {}
|
|
1359
|
+
};
|
|
1360
|
+
newState.id = msg.id;
|
|
1361
|
+
applySpawn(msg.id, newState);
|
|
1362
|
+
channel.send({ action: "spawn", id: msg.id, state: newState });
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
return () => {
|
|
1368
|
+
channel.close();
|
|
1369
|
+
channelRef.current = null;
|
|
1370
|
+
};
|
|
1371
|
+
}, [networkManager.transport, applySpawn, applyDespawn]);
|
|
1372
|
+
const spawn = useCallback7(
|
|
1373
|
+
(id, initialState) => {
|
|
1374
|
+
const transport = networkManager.transport;
|
|
1375
|
+
if (!transport) {
|
|
1376
|
+
console.warn("[useNetworkState] spawn called before transport is ready.");
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (!transport.isHost) {
|
|
1380
|
+
console.warn(
|
|
1381
|
+
"[useNetworkState] spawn is host-only. Use requestSpawn() from a client."
|
|
1382
|
+
);
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (entitiesRef.current.has(id)) {
|
|
1386
|
+
console.warn(
|
|
1387
|
+
`[useNetworkState] entity "${id}" already exists. Ignoring spawn.`
|
|
1388
|
+
);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const state = { ...initialState, id };
|
|
1392
|
+
applySpawn(id, state);
|
|
1393
|
+
channelRef.current?.send({ action: "spawn", id, state });
|
|
1394
|
+
},
|
|
1395
|
+
[networkManager, applySpawn]
|
|
1396
|
+
);
|
|
1397
|
+
const despawn = useCallback7(
|
|
1398
|
+
(id) => {
|
|
1399
|
+
const transport = networkManager.transport;
|
|
1400
|
+
if (!transport) {
|
|
1401
|
+
console.warn("[useNetworkState] despawn called before transport is ready.");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (!transport.isHost) {
|
|
1405
|
+
console.warn("[useNetworkState] despawn is host-only.");
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (!entitiesRef.current.has(id)) {
|
|
1409
|
+
console.warn(
|
|
1410
|
+
`[useNetworkState] entity "${id}" does not exist. Ignoring despawn.`
|
|
1411
|
+
);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
applyDespawn(id);
|
|
1415
|
+
channelRef.current?.send({ action: "despawn", id });
|
|
1416
|
+
},
|
|
1417
|
+
[networkManager, applyDespawn]
|
|
1418
|
+
);
|
|
1419
|
+
const requestSpawn = useCallback7(
|
|
1420
|
+
(id, config) => {
|
|
1421
|
+
const transport = networkManager.transport;
|
|
1422
|
+
if (!transport) {
|
|
1423
|
+
console.warn(
|
|
1424
|
+
"[useNetworkState] requestSpawn called before transport is ready."
|
|
1425
|
+
);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (transport.isHost) {
|
|
1429
|
+
if (entitiesRef.current.has(id)) {
|
|
1430
|
+
console.warn(
|
|
1431
|
+
`[useNetworkState] entity "${id}" already exists. Ignoring requestSpawn.`
|
|
1432
|
+
);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const newState = {
|
|
1436
|
+
id,
|
|
1437
|
+
x: 0,
|
|
1438
|
+
y: 0,
|
|
1439
|
+
a: 0,
|
|
1440
|
+
vx: 0,
|
|
1441
|
+
vy: 0,
|
|
1442
|
+
va: 0,
|
|
1443
|
+
...config
|
|
1444
|
+
};
|
|
1445
|
+
newState.id = id;
|
|
1446
|
+
applySpawn(id, newState);
|
|
1447
|
+
channelRef.current?.send({ action: "spawn", id, state: newState });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
channelRef.current?.send(
|
|
1451
|
+
{ action: "request-spawn", id, state: config },
|
|
1452
|
+
transport.hostId
|
|
1453
|
+
);
|
|
1454
|
+
},
|
|
1455
|
+
[networkManager, applySpawn]
|
|
1456
|
+
);
|
|
1457
|
+
const getState = useCallback7(
|
|
1458
|
+
(id) => {
|
|
1459
|
+
return entitiesRef.current.get(id);
|
|
1460
|
+
},
|
|
1461
|
+
[]
|
|
1462
|
+
);
|
|
1463
|
+
const setState = useCallback7(
|
|
1464
|
+
(id, partialState) => {
|
|
1465
|
+
const transport = networkManager.transport;
|
|
1466
|
+
if (!transport) {
|
|
1467
|
+
console.warn("[useNetworkState] setState called before transport is ready.");
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (!transport.isHost) {
|
|
1471
|
+
console.warn("[useNetworkState] setState is host-only.");
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const existing = entitiesRef.current.get(id);
|
|
1475
|
+
if (!existing) {
|
|
1476
|
+
console.warn(
|
|
1477
|
+
`[useNetworkState] entity "${id}" does not exist. Cannot setState.`
|
|
1478
|
+
);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const updated = {
|
|
1482
|
+
...existing,
|
|
1483
|
+
...partialState,
|
|
1484
|
+
id
|
|
1485
|
+
// id is immutable
|
|
1486
|
+
};
|
|
1487
|
+
replaceEntities((prev) => {
|
|
1488
|
+
const next = new Map(prev);
|
|
1489
|
+
next.set(id, updated);
|
|
1490
|
+
return next;
|
|
1491
|
+
});
|
|
1492
|
+
channelRef.current?.send({ action: "spawn", id, state: updated });
|
|
1493
|
+
},
|
|
1494
|
+
[networkManager, replaceEntities]
|
|
1495
|
+
);
|
|
1496
|
+
return {
|
|
1497
|
+
spawn,
|
|
1498
|
+
despawn,
|
|
1499
|
+
requestSpawn,
|
|
1500
|
+
getState,
|
|
1501
|
+
setState,
|
|
1502
|
+
entities
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/core/DebugOverlay.ts
|
|
1507
|
+
var QUALITY_COLORS = {
|
|
1508
|
+
good: "#00ff00",
|
|
1509
|
+
degraded: "#ffff00",
|
|
1510
|
+
poor: "#ff4444"
|
|
1511
|
+
};
|
|
1512
|
+
var DebugOverlay = class {
|
|
1513
|
+
constructor(options) {
|
|
1514
|
+
this.visible = true;
|
|
1515
|
+
this.lastHTML = "";
|
|
1516
|
+
const opts = options ?? {};
|
|
1517
|
+
this.keyboardToggle = opts.keyboardToggle ?? "F3";
|
|
1518
|
+
this.el = document.createElement("div");
|
|
1519
|
+
const pos = opts.position ?? "top-right";
|
|
1520
|
+
const posStyles = this.positionStyles(pos);
|
|
1521
|
+
Object.assign(this.el.style, {
|
|
1522
|
+
position: "fixed",
|
|
1523
|
+
...posStyles,
|
|
1524
|
+
background: "rgba(0,0,0,0.75)",
|
|
1525
|
+
color: "#00ff00",
|
|
1526
|
+
fontFamily: "'Courier New', monospace",
|
|
1527
|
+
fontSize: "11px",
|
|
1528
|
+
padding: "8px",
|
|
1529
|
+
borderRadius: "4px",
|
|
1530
|
+
zIndex: "99999",
|
|
1531
|
+
pointerEvents: "none",
|
|
1532
|
+
whiteSpace: "pre",
|
|
1533
|
+
lineHeight: "1.4",
|
|
1534
|
+
minWidth: "200px",
|
|
1535
|
+
boxSizing: "border-box"
|
|
1536
|
+
});
|
|
1537
|
+
document.body.appendChild(this.el);
|
|
1538
|
+
this.handleKey = (e) => {
|
|
1539
|
+
if (e.key === this.keyboardToggle) {
|
|
1540
|
+
e.preventDefault();
|
|
1541
|
+
this.toggle();
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
window.addEventListener("keydown", this.handleKey);
|
|
1545
|
+
}
|
|
1546
|
+
/* ---- public API ---- */
|
|
1547
|
+
update(stats) {
|
|
1548
|
+
if (!this.visible) return;
|
|
1549
|
+
const latencyDisplay = this.formatLatency(stats.latencyMs);
|
|
1550
|
+
const qualityColor = QUALITY_COLORS[stats.networkQuality] ?? "#00ff00";
|
|
1551
|
+
const bwIn = (stats.bandwidthIn / 1024).toFixed(1);
|
|
1552
|
+
const bwOut = (stats.bandwidthOut / 1024).toFixed(1);
|
|
1553
|
+
const loss = (stats.packetLossRate * 100).toFixed(1);
|
|
1554
|
+
const html = `<b style="color:#00ccff">== NET DEBUG ==</b>
|
|
1555
|
+
tick ${stats.tick}
|
|
1556
|
+
srv tick ${stats.serverTick}
|
|
1557
|
+
drift ${stats.drift}
|
|
1558
|
+
latency ${latencyDisplay} ms
|
|
1559
|
+
loss ${loss}%
|
|
1560
|
+
bw in ${bwIn} KB/s
|
|
1561
|
+
bw out ${bwOut} KB/s
|
|
1562
|
+
quality <span style="color:${qualityColor}">${stats.networkQuality}</span>
|
|
1563
|
+
peers ${stats.peerCount}
|
|
1564
|
+
host ${stats.isHost ? "YES" : "NO"}
|
|
1565
|
+
sync ${stats.syncMode}`;
|
|
1566
|
+
if (html !== this.lastHTML) {
|
|
1567
|
+
this.el.innerHTML = html;
|
|
1568
|
+
this.lastHTML = html;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
show() {
|
|
1572
|
+
this.visible = true;
|
|
1573
|
+
this.el.style.display = "block";
|
|
1574
|
+
}
|
|
1575
|
+
hide() {
|
|
1576
|
+
this.visible = false;
|
|
1577
|
+
this.el.style.display = "none";
|
|
1578
|
+
}
|
|
1579
|
+
toggle() {
|
|
1580
|
+
if (this.visible) {
|
|
1581
|
+
this.hide();
|
|
1582
|
+
} else {
|
|
1583
|
+
this.show();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
destroy() {
|
|
1587
|
+
window.removeEventListener("keydown", this.handleKey);
|
|
1588
|
+
this.el.remove();
|
|
1589
|
+
}
|
|
1590
|
+
/* ---- helpers ---- */
|
|
1591
|
+
positionStyles(pos) {
|
|
1592
|
+
switch (pos) {
|
|
1593
|
+
case "top-left":
|
|
1594
|
+
return { top: "8px", left: "8px" };
|
|
1595
|
+
case "top-right":
|
|
1596
|
+
return { top: "8px", right: "8px" };
|
|
1597
|
+
case "bottom-left":
|
|
1598
|
+
return { bottom: "8px", left: "8px" };
|
|
1599
|
+
case "bottom-right":
|
|
1600
|
+
return { bottom: "8px", right: "8px" };
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
formatLatency(latency) {
|
|
1604
|
+
if (typeof latency === "number") {
|
|
1605
|
+
return latency.toFixed(1);
|
|
1606
|
+
}
|
|
1607
|
+
if (latency.size === 0) return "\u2014";
|
|
1608
|
+
let sum = 0;
|
|
1609
|
+
latency.forEach((v) => sum += v);
|
|
1610
|
+
return (sum / latency.size).toFixed(1);
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
// src/core/InterestManager.ts
|
|
1615
|
+
var InterestManager = class {
|
|
1616
|
+
constructor(options) {
|
|
1617
|
+
this._cellSize = options?.cellSize ?? 50;
|
|
1618
|
+
this._defaultRadius = options?.defaultRadius ?? 200;
|
|
1619
|
+
this._alwaysRelevant = new Set(options?.alwaysRelevant ?? []);
|
|
1620
|
+
this._cells = /* @__PURE__ */ new Map();
|
|
1621
|
+
this._entityPositions = /* @__PURE__ */ new Map();
|
|
1622
|
+
}
|
|
1623
|
+
// ── Public API ──
|
|
1624
|
+
/**
|
|
1625
|
+
* Rebuild the spatial hash from the authoritative entity map.
|
|
1626
|
+
* Called once per host tick before any relevance queries.
|
|
1627
|
+
*/
|
|
1628
|
+
updateEntities(entities) {
|
|
1629
|
+
this._cells.clear();
|
|
1630
|
+
this._entityPositions.clear();
|
|
1631
|
+
for (const [id, entity] of entities) {
|
|
1632
|
+
const z = "z" in entity ? entity.z : 0;
|
|
1633
|
+
const pos = { x: entity.x, y: entity.y, z };
|
|
1634
|
+
this._entityPositions.set(id, pos);
|
|
1635
|
+
const key = this._cellKey(pos.x, pos.y, pos.z);
|
|
1636
|
+
let bucket = this._cells.get(key);
|
|
1637
|
+
if (!bucket) {
|
|
1638
|
+
bucket = /* @__PURE__ */ new Set();
|
|
1639
|
+
this._cells.set(key, bucket);
|
|
1640
|
+
}
|
|
1641
|
+
bucket.add(id);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Return the set of entity ids relevant to a single client.
|
|
1646
|
+
*
|
|
1647
|
+
* Relevance is the *union* of:
|
|
1648
|
+
* 1. Entities whose cell overlaps the client's bounding sphere
|
|
1649
|
+
* 2. Entities in the `alwaysRelevant` set
|
|
1650
|
+
* 3. Entities owned by this client
|
|
1651
|
+
*/
|
|
1652
|
+
getRelevantEntities(clientPosition, clientId, owners, overrideRadius) {
|
|
1653
|
+
const radius = overrideRadius ?? this._defaultRadius;
|
|
1654
|
+
const cx = clientPosition.x;
|
|
1655
|
+
const cy = clientPosition.y;
|
|
1656
|
+
const cz = clientPosition.z ?? 0;
|
|
1657
|
+
const result = /* @__PURE__ */ new Set();
|
|
1658
|
+
const minCellX = Math.floor((cx - radius) / this._cellSize);
|
|
1659
|
+
const maxCellX = Math.floor((cx + radius) / this._cellSize);
|
|
1660
|
+
const minCellY = Math.floor((cy - radius) / this._cellSize);
|
|
1661
|
+
const maxCellY = Math.floor((cy + radius) / this._cellSize);
|
|
1662
|
+
const minCellZ = Math.floor((cz - radius) / this._cellSize);
|
|
1663
|
+
const maxCellZ = Math.floor((cz + radius) / this._cellSize);
|
|
1664
|
+
for (let ix = minCellX; ix <= maxCellX; ix++) {
|
|
1665
|
+
for (let iy = minCellY; iy <= maxCellY; iy++) {
|
|
1666
|
+
for (let iz = minCellZ; iz <= maxCellZ; iz++) {
|
|
1667
|
+
const key = `${ix},${iy},${iz}`;
|
|
1668
|
+
const bucket = this._cells.get(key);
|
|
1669
|
+
if (bucket) {
|
|
1670
|
+
for (const entityId of bucket) {
|
|
1671
|
+
result.add(entityId);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
for (const id of this._alwaysRelevant) {
|
|
1678
|
+
if (this._entityPositions.has(id)) {
|
|
1679
|
+
result.add(id);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
for (const [entityId, ownerId] of owners) {
|
|
1683
|
+
if (ownerId === clientId && this._entityPositions.has(entityId)) {
|
|
1684
|
+
result.add(entityId);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return result;
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Build a filter callback compatible with
|
|
1691
|
+
* `HostAuthority.setInterestFilter`.
|
|
1692
|
+
*
|
|
1693
|
+
* The returned function closes over a single relevance pass for every
|
|
1694
|
+
* known client so that per-entity filtering during broadcast is a cheap
|
|
1695
|
+
* `Set.has` lookup.
|
|
1696
|
+
*
|
|
1697
|
+
* @param clientPositions peerId -> position of that client's camera/player
|
|
1698
|
+
* @param owners entityId -> ownerPeerId
|
|
1699
|
+
*/
|
|
1700
|
+
createFilter(clientPositions, owners) {
|
|
1701
|
+
const relevanceSets = /* @__PURE__ */ new Map();
|
|
1702
|
+
for (const [peerId, pos] of clientPositions) {
|
|
1703
|
+
relevanceSets.set(
|
|
1704
|
+
peerId,
|
|
1705
|
+
this.getRelevantEntities(pos, peerId, owners)
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
return (entityId, peerId) => {
|
|
1709
|
+
const set = relevanceSets.get(peerId);
|
|
1710
|
+
if (!set) return true;
|
|
1711
|
+
return set.has(entityId);
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
/** Remove all data from the grid. */
|
|
1715
|
+
clear() {
|
|
1716
|
+
this._cells.clear();
|
|
1717
|
+
this._entityPositions.clear();
|
|
1718
|
+
}
|
|
1719
|
+
// ── Private helpers ──
|
|
1720
|
+
_cellKey(x, y, z) {
|
|
1721
|
+
const cx = Math.floor(x / this._cellSize);
|
|
1722
|
+
const cy = Math.floor(y / this._cellSize);
|
|
1723
|
+
const cz = Math.floor(z / this._cellSize);
|
|
1724
|
+
return `${cx},${cy},${cz}`;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
export {
|
|
1728
|
+
DebugOverlay,
|
|
1729
|
+
FirebaseStrategy,
|
|
1730
|
+
InterestManager,
|
|
1731
|
+
MqttStrategy,
|
|
1732
|
+
MultiplayerBridge,
|
|
1733
|
+
MultiplayerProvider,
|
|
1734
|
+
NetworkSimulator,
|
|
1735
|
+
useHost,
|
|
1736
|
+
useLobby,
|
|
1737
|
+
useMultiplayer,
|
|
1738
|
+
useNetworkEvents,
|
|
1739
|
+
useNetworkState,
|
|
1740
|
+
usePlayers,
|
|
1741
|
+
useRoom
|
|
1742
|
+
};
|
|
1743
|
+
//# sourceMappingURL=index.mjs.map
|