@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.js
ADDED
|
@@ -0,0 +1,3817 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
DebugOverlay: () => DebugOverlay,
|
|
34
|
+
FirebaseStrategy: () => FirebaseStrategy,
|
|
35
|
+
InterestManager: () => InterestManager,
|
|
36
|
+
MqttStrategy: () => MqttStrategy,
|
|
37
|
+
MultiplayerBridge: () => MultiplayerBridge,
|
|
38
|
+
MultiplayerProvider: () => MultiplayerProvider,
|
|
39
|
+
NetworkSimulator: () => NetworkSimulator,
|
|
40
|
+
useHost: () => useHost,
|
|
41
|
+
useLobby: () => useLobby,
|
|
42
|
+
useMultiplayer: () => useMultiplayer,
|
|
43
|
+
useNetworkEvents: () => useNetworkEvents,
|
|
44
|
+
useNetworkState: () => useNetworkState,
|
|
45
|
+
usePlayers: () => usePlayers,
|
|
46
|
+
useRoom: () => useRoom
|
|
47
|
+
});
|
|
48
|
+
module.exports = __toCommonJS(src_exports);
|
|
49
|
+
|
|
50
|
+
// src/components/MultiplayerProvider.ts
|
|
51
|
+
var import_react2 = require("react");
|
|
52
|
+
|
|
53
|
+
// src/transport/strategy/utils.ts
|
|
54
|
+
function generatePeerId() {
|
|
55
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
56
|
+
const bytes = new Uint8Array(20);
|
|
57
|
+
crypto.getRandomValues(bytes);
|
|
58
|
+
let id = "";
|
|
59
|
+
for (let i = 0; i < 20; i++) {
|
|
60
|
+
id += chars[bytes[i] % chars.length];
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
function mqttTopics(appId, roomId, peerId) {
|
|
65
|
+
const base = `carver/${appId}`;
|
|
66
|
+
return {
|
|
67
|
+
/** Lobby wildcard: subscribe to discover all room announcements */
|
|
68
|
+
lobbyWildcard: `${base}/lobby/+`,
|
|
69
|
+
/** Single room lobby entry */
|
|
70
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
71
|
+
/** Wildcard for all peer presence in a room */
|
|
72
|
+
roomPresenceWildcard: `${base}/room/${roomId}/presence/+`,
|
|
73
|
+
/** This peer's presence topic */
|
|
74
|
+
peerPresence: peerId ? `${base}/room/${roomId}/presence/${peerId}` : "",
|
|
75
|
+
/** This peer's signal inbox */
|
|
76
|
+
peerSignalInbox: peerId ? `${base}/room/${roomId}/signal/${peerId}` : ""
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function firebasePaths(appId, roomId, peerId) {
|
|
80
|
+
const base = `${appId}/__carver__`;
|
|
81
|
+
return {
|
|
82
|
+
lobby: `${base}/lobby`,
|
|
83
|
+
roomLobbyEntry: `${base}/lobby/${roomId}`,
|
|
84
|
+
peers: `${base}/rooms/${roomId}/peers`,
|
|
85
|
+
peerPresence: peerId ? `${base}/rooms/${roomId}/peers/${peerId}` : "",
|
|
86
|
+
peerSignalInbox: peerId ? `${base}/rooms/${roomId}/signals/${peerId}` : ""
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
var DEFAULT_MQTT_BROKERS = [
|
|
90
|
+
"wss://broker.emqx.io:8084/mqtt",
|
|
91
|
+
"wss://test.mosquitto.org:8081/mqtt"
|
|
92
|
+
];
|
|
93
|
+
var ROOM_ANNOUNCE_EXPIRY_MS = 3e4;
|
|
94
|
+
var ROOM_ANNOUNCE_INTERVAL_MS = 1e4;
|
|
95
|
+
var PRESENCE_HEARTBEAT_MS = 5e3;
|
|
96
|
+
var PEER_EXPIRY_MS = PRESENCE_HEARTBEAT_MS * 3;
|
|
97
|
+
var PRESENCE_WARMUP_DELAYS_MS = [200, 500, 1500];
|
|
98
|
+
function removeFromArray(arr, item) {
|
|
99
|
+
const idx = arr.indexOf(item);
|
|
100
|
+
if (idx >= 0) {
|
|
101
|
+
arr.splice(idx, 1);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/transport/strategy/mqtt.ts
|
|
108
|
+
var MqttStrategy = class {
|
|
109
|
+
constructor(appId, config = { type: "mqtt" }) {
|
|
110
|
+
this._client = null;
|
|
111
|
+
this._roomId = null;
|
|
112
|
+
this._peerMeta = {};
|
|
113
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
114
|
+
this._joinGeneration = 0;
|
|
115
|
+
// Lazy init
|
|
116
|
+
this._initPromise = null;
|
|
117
|
+
// Callbacks
|
|
118
|
+
this._onPeerDiscovered = [];
|
|
119
|
+
this._onPeerLeft = [];
|
|
120
|
+
this._onSignal = [];
|
|
121
|
+
this._onLobby = [];
|
|
122
|
+
// State
|
|
123
|
+
this._knownPeers = /* @__PURE__ */ new Map();
|
|
124
|
+
this._lobbyRooms = /* @__PURE__ */ new Map();
|
|
125
|
+
this._presenceTimer = null;
|
|
126
|
+
this._warmupTimers = [];
|
|
127
|
+
this._lobbyAnnounceTimer = null;
|
|
128
|
+
this._peerExpiryTimer = null;
|
|
129
|
+
this._lobbySubscribed = false;
|
|
130
|
+
this._destroyed = false;
|
|
131
|
+
this.selfId = generatePeerId();
|
|
132
|
+
this._appId = appId;
|
|
133
|
+
this._config = config;
|
|
134
|
+
}
|
|
135
|
+
// ── Public API ──
|
|
136
|
+
async init() {
|
|
137
|
+
return this._ensureInit();
|
|
138
|
+
}
|
|
139
|
+
async joinRoom(roomId, peerMeta) {
|
|
140
|
+
await this._ensureInit();
|
|
141
|
+
if (!this._client) throw new Error("MQTT client not available");
|
|
142
|
+
this._joinGeneration++;
|
|
143
|
+
this._roomId = roomId;
|
|
144
|
+
this._peerMeta = peerMeta;
|
|
145
|
+
const topics = mqttTopics(this._appId, roomId, this.selfId);
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
this._client.subscribe(
|
|
148
|
+
[topics.roomPresenceWildcard, topics.peerSignalInbox],
|
|
149
|
+
{ qos: 1 },
|
|
150
|
+
(err) => err ? reject(err) : resolve()
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
this._publishPresence();
|
|
154
|
+
for (const delay of PRESENCE_WARMUP_DELAYS_MS) {
|
|
155
|
+
this._warmupTimers.push(setTimeout(() => this._publishPresence(), delay));
|
|
156
|
+
}
|
|
157
|
+
this._presenceTimer = setInterval(() => this._publishPresence(), PRESENCE_HEARTBEAT_MS);
|
|
158
|
+
this._peerExpiryTimer = setInterval(() => this._checkPeerExpiry(), PRESENCE_HEARTBEAT_MS);
|
|
159
|
+
}
|
|
160
|
+
async leaveRoom() {
|
|
161
|
+
if (!this._client || !this._roomId) return;
|
|
162
|
+
const generation = this._joinGeneration;
|
|
163
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
164
|
+
this._clearRoomTimers();
|
|
165
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
166
|
+
this._client.unsubscribe([topics.roomPresenceWildcard, topics.peerSignalInbox]);
|
|
167
|
+
this._knownPeers.clear();
|
|
168
|
+
if (this._joinGeneration === generation) {
|
|
169
|
+
this._roomId = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
signal(targetPeerId, data) {
|
|
173
|
+
if (!this._client || !this._roomId) return;
|
|
174
|
+
const targetTopic = `carver/${this._appId}/room/${this._roomId}/signal/${targetPeerId}`;
|
|
175
|
+
this._client.publish(
|
|
176
|
+
targetTopic,
|
|
177
|
+
JSON.stringify({ from: this.selfId, data, ts: Date.now() }),
|
|
178
|
+
{ qos: 1 }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
subscribeToLobby(cb) {
|
|
182
|
+
this._onLobby.push(cb);
|
|
183
|
+
if (!this._lobbySubscribed) {
|
|
184
|
+
this._lobbySubscribed = true;
|
|
185
|
+
this._ensureInit().then(() => {
|
|
186
|
+
if (this._client && !this._destroyed) {
|
|
187
|
+
const lobbyTopic = mqttTopics(this._appId, "", "").lobbyWildcard;
|
|
188
|
+
this._client.subscribe(lobbyTopic, { qos: 0 });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return () => {
|
|
193
|
+
removeFromArray(this._onLobby, cb);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
announceRoom(announcement) {
|
|
197
|
+
if (!this._client) return;
|
|
198
|
+
const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
|
|
199
|
+
announcement.lastSeen = Date.now();
|
|
200
|
+
this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
201
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
202
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
203
|
+
announcement.lastSeen = Date.now();
|
|
204
|
+
this._client?.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
205
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
206
|
+
}
|
|
207
|
+
removeRoomAnnouncement(roomId) {
|
|
208
|
+
if (!this._client) return;
|
|
209
|
+
const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
|
|
210
|
+
this._client.publish(topic, "", { retain: true, qos: 1 });
|
|
211
|
+
if (this._lobbyAnnounceTimer) {
|
|
212
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
213
|
+
this._lobbyAnnounceTimer = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
onPeerDiscovered(cb) {
|
|
217
|
+
this._onPeerDiscovered.push(cb);
|
|
218
|
+
return () => {
|
|
219
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
onPeerLeft(cb) {
|
|
223
|
+
this._onPeerLeft.push(cb);
|
|
224
|
+
return () => {
|
|
225
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
onSignal(cb) {
|
|
229
|
+
this._onSignal.push(cb);
|
|
230
|
+
return () => {
|
|
231
|
+
removeFromArray(this._onSignal, cb);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
destroy() {
|
|
235
|
+
this._destroyed = true;
|
|
236
|
+
this._clearRoomTimers();
|
|
237
|
+
if (this._client && this._roomId) {
|
|
238
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
239
|
+
this._client.publish(topics.peerPresence, "", { retain: true, qos: 1 });
|
|
240
|
+
}
|
|
241
|
+
this._client?.end(true);
|
|
242
|
+
this._client = null;
|
|
243
|
+
this._knownPeers.clear();
|
|
244
|
+
this._lobbyRooms.clear();
|
|
245
|
+
this._onPeerDiscovered = [];
|
|
246
|
+
this._onPeerLeft = [];
|
|
247
|
+
this._onSignal = [];
|
|
248
|
+
this._onLobby = [];
|
|
249
|
+
}
|
|
250
|
+
// ── Private ──
|
|
251
|
+
_ensureInit() {
|
|
252
|
+
if (!this._initPromise) {
|
|
253
|
+
this._initPromise = this._doInit();
|
|
254
|
+
}
|
|
255
|
+
return this._initPromise;
|
|
256
|
+
}
|
|
257
|
+
async _doInit() {
|
|
258
|
+
const mqtt = await import("mqtt");
|
|
259
|
+
const brokers = this._config.brokerUrls ?? DEFAULT_MQTT_BROKERS;
|
|
260
|
+
const brokerUrl = brokers[Math.floor(Math.random() * brokers.length)];
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const connectFn = mqtt.default?.connect ?? mqtt.connect;
|
|
263
|
+
this._client = connectFn(brokerUrl, {
|
|
264
|
+
clientId: `carver_${this.selfId}`,
|
|
265
|
+
clean: true,
|
|
266
|
+
connectTimeout: 1e4,
|
|
267
|
+
keepalive: 30
|
|
268
|
+
});
|
|
269
|
+
this._client.on("connect", () => {
|
|
270
|
+
if (!this._destroyed) resolve();
|
|
271
|
+
});
|
|
272
|
+
this._client.on("error", (err) => {
|
|
273
|
+
if (!this._initPromise) return;
|
|
274
|
+
reject(err);
|
|
275
|
+
});
|
|
276
|
+
this._client.on("message", (topic, payload) => {
|
|
277
|
+
this._handleMessage(topic, payload);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
_publishPresence() {
|
|
282
|
+
if (!this._client || !this._roomId) return;
|
|
283
|
+
const topics = mqttTopics(this._appId, this._roomId, this.selfId);
|
|
284
|
+
this._client.publish(
|
|
285
|
+
topics.peerPresence,
|
|
286
|
+
JSON.stringify({ peerId: this.selfId, meta: this._peerMeta, ts: Date.now() }),
|
|
287
|
+
{ retain: true, qos: 1 }
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
_handleMessage(topic, payload) {
|
|
291
|
+
const raw = payload.toString();
|
|
292
|
+
const presenceMatch = topic.match(/\/room\/[^/]+\/presence\/([^/]+)$/);
|
|
293
|
+
if (presenceMatch) {
|
|
294
|
+
const peerId = presenceMatch[1];
|
|
295
|
+
if (peerId === this.selfId) return;
|
|
296
|
+
if (!raw) {
|
|
297
|
+
if (this._knownPeers.has(peerId)) {
|
|
298
|
+
this._knownPeers.delete(peerId);
|
|
299
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const msg = JSON.parse(raw);
|
|
305
|
+
const isNew = !this._knownPeers.has(peerId);
|
|
306
|
+
this._knownPeers.set(peerId, { meta: msg.meta ?? {}, lastSeen: msg.ts ?? Date.now() });
|
|
307
|
+
if (isNew) {
|
|
308
|
+
for (const cb of this._onPeerDiscovered) cb(peerId, msg.meta ?? {});
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const signalMatch = topic.match(/\/room\/[^/]+\/signal\/([^/]+)$/);
|
|
315
|
+
if (signalMatch) {
|
|
316
|
+
try {
|
|
317
|
+
const msg = JSON.parse(raw);
|
|
318
|
+
if (msg.from && msg.from !== this.selfId) {
|
|
319
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const lobbyMatch = topic.match(/\/lobby\/([^/]+)$/);
|
|
326
|
+
if (lobbyMatch) {
|
|
327
|
+
const roomId = lobbyMatch[1];
|
|
328
|
+
if (!raw) {
|
|
329
|
+
this._lobbyRooms.delete(roomId);
|
|
330
|
+
} else {
|
|
331
|
+
try {
|
|
332
|
+
const ann = JSON.parse(raw);
|
|
333
|
+
if (Date.now() - ann.lastSeen < ROOM_ANNOUNCE_EXPIRY_MS) {
|
|
334
|
+
this._lobbyRooms.set(roomId, ann);
|
|
335
|
+
} else {
|
|
336
|
+
this._lobbyRooms.delete(roomId);
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const rooms = Array.from(this._lobbyRooms.values());
|
|
342
|
+
for (const cb of this._onLobby) cb(rooms);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
_checkPeerExpiry() {
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
for (const [peerId, data] of this._knownPeers) {
|
|
348
|
+
if (now - data.lastSeen > PEER_EXPIRY_MS) {
|
|
349
|
+
this._knownPeers.delete(peerId);
|
|
350
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
_clearRoomTimers() {
|
|
355
|
+
if (this._presenceTimer) {
|
|
356
|
+
clearInterval(this._presenceTimer);
|
|
357
|
+
this._presenceTimer = null;
|
|
358
|
+
}
|
|
359
|
+
for (const t of this._warmupTimers) clearTimeout(t);
|
|
360
|
+
this._warmupTimers = [];
|
|
361
|
+
if (this._lobbyAnnounceTimer) {
|
|
362
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
363
|
+
this._lobbyAnnounceTimer = null;
|
|
364
|
+
}
|
|
365
|
+
if (this._peerExpiryTimer) {
|
|
366
|
+
clearInterval(this._peerExpiryTimer);
|
|
367
|
+
this._peerExpiryTimer = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// src/transport/strategy/firebase.ts
|
|
373
|
+
var FirebaseStrategy = class {
|
|
374
|
+
constructor(appId, config) {
|
|
375
|
+
this._db = null;
|
|
376
|
+
this._firebaseApp = null;
|
|
377
|
+
this._ownApp = false;
|
|
378
|
+
this._roomId = null;
|
|
379
|
+
this._peerMeta = {};
|
|
380
|
+
/** Monotonic counter to detect stale leaveRoom completions */
|
|
381
|
+
this._joinGeneration = 0;
|
|
382
|
+
// Lazy init
|
|
383
|
+
this._initPromise = null;
|
|
384
|
+
// Firebase module references (filled after dynamic import)
|
|
385
|
+
this._fb = null;
|
|
386
|
+
// Unsubscribe handles for Firebase listeners
|
|
387
|
+
this._listeners = [];
|
|
388
|
+
// Callbacks
|
|
389
|
+
this._onPeerDiscovered = [];
|
|
390
|
+
this._onPeerLeft = [];
|
|
391
|
+
this._onSignal = [];
|
|
392
|
+
this._onLobby = [];
|
|
393
|
+
// State
|
|
394
|
+
this._knownPeers = /* @__PURE__ */ new Set();
|
|
395
|
+
this._lobbyAnnounceTimer = null;
|
|
396
|
+
this._destroyed = false;
|
|
397
|
+
this.selfId = generatePeerId();
|
|
398
|
+
this._appId = appId;
|
|
399
|
+
this._config = config;
|
|
400
|
+
}
|
|
401
|
+
// ── Public API ──
|
|
402
|
+
async init() {
|
|
403
|
+
return this._ensureInit();
|
|
404
|
+
}
|
|
405
|
+
async joinRoom(roomId, peerMeta) {
|
|
406
|
+
await this._ensureInit();
|
|
407
|
+
if (!this._db || !this._fb) throw new Error("Firebase not initialized");
|
|
408
|
+
this._joinGeneration++;
|
|
409
|
+
this._roomId = roomId;
|
|
410
|
+
this._peerMeta = peerMeta;
|
|
411
|
+
const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
|
|
412
|
+
const paths = firebasePaths(this._appId, roomId, this.selfId);
|
|
413
|
+
await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
414
|
+
});
|
|
415
|
+
const presenceRef = ref(this._db, paths.peerPresence);
|
|
416
|
+
await set(presenceRef, {
|
|
417
|
+
peerId: this.selfId,
|
|
418
|
+
meta: peerMeta,
|
|
419
|
+
ts: Date.now()
|
|
420
|
+
});
|
|
421
|
+
onDisconnect(presenceRef).remove();
|
|
422
|
+
const peersRef = ref(this._db, paths.peers);
|
|
423
|
+
const addedUnsub = onChildAdded(peersRef, (snapshot) => {
|
|
424
|
+
const data = snapshot.val();
|
|
425
|
+
if (!data || data.peerId === this.selfId) return;
|
|
426
|
+
if (!this._knownPeers.has(data.peerId)) {
|
|
427
|
+
this._knownPeers.add(data.peerId);
|
|
428
|
+
for (const cb of this._onPeerDiscovered) cb(data.peerId, data.meta ?? {});
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
this._listeners.push(() => addedUnsub());
|
|
432
|
+
const removedUnsub = onChildRemoved(peersRef, (snapshot) => {
|
|
433
|
+
const data = snapshot.val();
|
|
434
|
+
const peerId = data?.peerId ?? snapshot.key;
|
|
435
|
+
if (peerId && this._knownPeers.has(peerId)) {
|
|
436
|
+
this._knownPeers.delete(peerId);
|
|
437
|
+
for (const cb of this._onPeerLeft) cb(peerId);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
this._listeners.push(() => removedUnsub());
|
|
441
|
+
const signalRef = ref(this._db, paths.peerSignalInbox);
|
|
442
|
+
const signalUnsub = onChildAdded(signalRef, (snapshot) => {
|
|
443
|
+
const msg = snapshot.val();
|
|
444
|
+
if (!msg || msg.from === this.selfId) return;
|
|
445
|
+
for (const cb of this._onSignal) cb(msg.from, msg.data);
|
|
446
|
+
remove(snapshot.ref);
|
|
447
|
+
});
|
|
448
|
+
this._listeners.push(() => signalUnsub());
|
|
449
|
+
}
|
|
450
|
+
async leaveRoom() {
|
|
451
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
452
|
+
const leavingRoomId = this._roomId;
|
|
453
|
+
const generation = this._joinGeneration;
|
|
454
|
+
const { ref, remove } = this._fb;
|
|
455
|
+
const paths = firebasePaths(this._appId, leavingRoomId, this.selfId);
|
|
456
|
+
for (const unsub of this._listeners) unsub();
|
|
457
|
+
this._listeners = [];
|
|
458
|
+
await Promise.all([
|
|
459
|
+
remove(ref(this._db, paths.peerPresence)),
|
|
460
|
+
remove(ref(this._db, paths.peerSignalInbox))
|
|
461
|
+
]).catch(() => {
|
|
462
|
+
});
|
|
463
|
+
if (this._lobbyAnnounceTimer) {
|
|
464
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
465
|
+
this._lobbyAnnounceTimer = null;
|
|
466
|
+
}
|
|
467
|
+
this._knownPeers.clear();
|
|
468
|
+
if (this._joinGeneration === generation) {
|
|
469
|
+
this._roomId = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
signal(targetPeerId, data) {
|
|
473
|
+
if (!this._db || !this._fb || !this._roomId) return;
|
|
474
|
+
const { ref, push } = this._fb;
|
|
475
|
+
const inboxPath = firebasePaths(this._appId, this._roomId, targetPeerId).peerSignalInbox;
|
|
476
|
+
push(ref(this._db, inboxPath), {
|
|
477
|
+
from: this.selfId,
|
|
478
|
+
data: sanitizeForFirebase(data),
|
|
479
|
+
ts: Date.now()
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
subscribeToLobby(cb) {
|
|
483
|
+
this._onLobby.push(cb);
|
|
484
|
+
this._ensureInit().then(() => {
|
|
485
|
+
if (!this._db || !this._fb || this._destroyed) return;
|
|
486
|
+
const { ref, onValue } = this._fb;
|
|
487
|
+
const paths = firebasePaths(this._appId, "", "");
|
|
488
|
+
const lobbyRef = ref(this._db, paths.lobby);
|
|
489
|
+
const unsub = onValue(lobbyRef, (snapshot) => {
|
|
490
|
+
const data = snapshot.val();
|
|
491
|
+
if (!data) {
|
|
492
|
+
for (const lcb of this._onLobby) lcb([]);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
const rooms = Object.values(data).filter(
|
|
497
|
+
(r) => r && now - (r.lastSeen ?? 0) < ROOM_ANNOUNCE_EXPIRY_MS
|
|
498
|
+
);
|
|
499
|
+
for (const lcb of this._onLobby) lcb(rooms);
|
|
500
|
+
});
|
|
501
|
+
this._listeners.push(() => unsub());
|
|
502
|
+
});
|
|
503
|
+
return () => {
|
|
504
|
+
removeFromArray(this._onLobby, cb);
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
announceRoom(announcement) {
|
|
508
|
+
if (!this._db || !this._fb) return;
|
|
509
|
+
const { ref, set } = this._fb;
|
|
510
|
+
const paths = firebasePaths(this._appId, announcement.roomId, "");
|
|
511
|
+
announcement.lastSeen = Date.now();
|
|
512
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
513
|
+
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
514
|
+
this._lobbyAnnounceTimer = setInterval(() => {
|
|
515
|
+
announcement.lastSeen = Date.now();
|
|
516
|
+
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
517
|
+
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
518
|
+
}
|
|
519
|
+
removeRoomAnnouncement(roomId) {
|
|
520
|
+
if (!this._db || !this._fb) return;
|
|
521
|
+
const { ref, remove } = this._fb;
|
|
522
|
+
remove(ref(this._db, firebasePaths(this._appId, roomId, "").roomLobbyEntry));
|
|
523
|
+
if (this._lobbyAnnounceTimer) {
|
|
524
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
525
|
+
this._lobbyAnnounceTimer = null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
onPeerDiscovered(cb) {
|
|
529
|
+
this._onPeerDiscovered.push(cb);
|
|
530
|
+
return () => {
|
|
531
|
+
removeFromArray(this._onPeerDiscovered, cb);
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
onPeerLeft(cb) {
|
|
535
|
+
this._onPeerLeft.push(cb);
|
|
536
|
+
return () => {
|
|
537
|
+
removeFromArray(this._onPeerLeft, cb);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
onSignal(cb) {
|
|
541
|
+
this._onSignal.push(cb);
|
|
542
|
+
return () => {
|
|
543
|
+
removeFromArray(this._onSignal, cb);
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
destroy() {
|
|
547
|
+
this._destroyed = true;
|
|
548
|
+
for (const unsub of this._listeners) unsub();
|
|
549
|
+
this._listeners = [];
|
|
550
|
+
if (this._lobbyAnnounceTimer) {
|
|
551
|
+
clearInterval(this._lobbyAnnounceTimer);
|
|
552
|
+
this._lobbyAnnounceTimer = null;
|
|
553
|
+
}
|
|
554
|
+
if (this._db && this._fb && this._roomId) {
|
|
555
|
+
const { ref, remove } = this._fb;
|
|
556
|
+
const paths = firebasePaths(this._appId, this._roomId, this.selfId);
|
|
557
|
+
remove(ref(this._db, paths.peerPresence)).catch(() => {
|
|
558
|
+
});
|
|
559
|
+
remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (this._ownApp && this._firebaseApp) {
|
|
563
|
+
import("firebase/app").then(({ deleteApp }) => {
|
|
564
|
+
deleteApp(this._firebaseApp).catch(() => {
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
this._db = null;
|
|
569
|
+
this._firebaseApp = null;
|
|
570
|
+
this._fb = null;
|
|
571
|
+
this._knownPeers.clear();
|
|
572
|
+
this._onPeerDiscovered = [];
|
|
573
|
+
this._onPeerLeft = [];
|
|
574
|
+
this._onSignal = [];
|
|
575
|
+
this._onLobby = [];
|
|
576
|
+
}
|
|
577
|
+
// ── Private ──
|
|
578
|
+
_ensureInit() {
|
|
579
|
+
if (!this._initPromise) {
|
|
580
|
+
this._initPromise = this._doInit();
|
|
581
|
+
}
|
|
582
|
+
return this._initPromise;
|
|
583
|
+
}
|
|
584
|
+
async _doInit() {
|
|
585
|
+
const { initializeApp, getApps } = await import("firebase/app");
|
|
586
|
+
const {
|
|
587
|
+
getDatabase,
|
|
588
|
+
ref,
|
|
589
|
+
set,
|
|
590
|
+
push,
|
|
591
|
+
remove,
|
|
592
|
+
onValue,
|
|
593
|
+
onChildAdded,
|
|
594
|
+
onChildRemoved,
|
|
595
|
+
onDisconnect
|
|
596
|
+
} = await import("firebase/database");
|
|
597
|
+
this._fb = { ref, set, push, remove, onValue, onChildAdded, onChildRemoved, onDisconnect };
|
|
598
|
+
if (this._config.firebaseApp) {
|
|
599
|
+
this._firebaseApp = this._config.firebaseApp;
|
|
600
|
+
this._ownApp = false;
|
|
601
|
+
} else {
|
|
602
|
+
const appName = `carver_${this._appId}`;
|
|
603
|
+
const existing = getApps().find((a) => a.name === appName);
|
|
604
|
+
if (existing) {
|
|
605
|
+
this._firebaseApp = existing;
|
|
606
|
+
this._ownApp = false;
|
|
607
|
+
} else {
|
|
608
|
+
this._firebaseApp = initializeApp({ databaseURL: this._config.databaseURL }, appName);
|
|
609
|
+
this._ownApp = true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
this._db = getDatabase(this._firebaseApp);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
function sanitizeForFirebase(obj) {
|
|
616
|
+
if (obj === null) return "__null__";
|
|
617
|
+
if (Array.isArray(obj)) return obj.map(sanitizeForFirebase);
|
|
618
|
+
if (typeof obj === "object" && obj !== null) {
|
|
619
|
+
const result = {};
|
|
620
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
621
|
+
result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
|
|
622
|
+
}
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
return obj;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/core/TickKeeper.ts
|
|
629
|
+
var _TickKeeper = class _TickKeeper {
|
|
630
|
+
constructor(tickRate = 60) {
|
|
631
|
+
this._accumulator = 0;
|
|
632
|
+
this._tick = 0;
|
|
633
|
+
this._serverTick = 0;
|
|
634
|
+
this._alpha = 0;
|
|
635
|
+
this._timeScale = 1;
|
|
636
|
+
this._tickRate = tickRate;
|
|
637
|
+
this._tickDelta = 1 / tickRate;
|
|
638
|
+
}
|
|
639
|
+
get tick() {
|
|
640
|
+
return this._tick;
|
|
641
|
+
}
|
|
642
|
+
get serverTick() {
|
|
643
|
+
return this._serverTick;
|
|
644
|
+
}
|
|
645
|
+
get tickDelta() {
|
|
646
|
+
return this._tickDelta;
|
|
647
|
+
}
|
|
648
|
+
get tickRate() {
|
|
649
|
+
return this._tickRate;
|
|
650
|
+
}
|
|
651
|
+
/** Interpolation alpha for rendering between ticks (0-1) */
|
|
652
|
+
get alpha() {
|
|
653
|
+
return this._alpha;
|
|
654
|
+
}
|
|
655
|
+
/** Current time scale (affected by drift correction) */
|
|
656
|
+
get timeScale() {
|
|
657
|
+
return this._timeScale;
|
|
658
|
+
}
|
|
659
|
+
/** Ticks ahead of server (positive = ahead, negative = behind) */
|
|
660
|
+
get drift() {
|
|
661
|
+
return this._tick - this._serverTick;
|
|
662
|
+
}
|
|
663
|
+
/** Update server tick from received snapshot */
|
|
664
|
+
setServerTick(serverTick) {
|
|
665
|
+
this._serverTick = serverTick;
|
|
666
|
+
this._updateDriftCorrection();
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Accumulate time and return the number of fixed ticks to process.
|
|
670
|
+
* Call this once per render frame with the raw frame delta.
|
|
671
|
+
*/
|
|
672
|
+
update(rawDelta) {
|
|
673
|
+
const maxDelta = this._tickDelta * 8;
|
|
674
|
+
const delta = Math.min(rawDelta, maxDelta) * this._timeScale;
|
|
675
|
+
this._accumulator += delta;
|
|
676
|
+
let ticksThisFrame = 0;
|
|
677
|
+
while (this._accumulator >= this._tickDelta) {
|
|
678
|
+
this._accumulator -= this._tickDelta;
|
|
679
|
+
this._tick++;
|
|
680
|
+
ticksThisFrame++;
|
|
681
|
+
}
|
|
682
|
+
this._alpha = this._accumulator / this._tickDelta;
|
|
683
|
+
return ticksThisFrame;
|
|
684
|
+
}
|
|
685
|
+
/** Reset to initial state */
|
|
686
|
+
reset() {
|
|
687
|
+
this._accumulator = 0;
|
|
688
|
+
this._tick = 0;
|
|
689
|
+
this._serverTick = 0;
|
|
690
|
+
this._alpha = 0;
|
|
691
|
+
this._timeScale = 1;
|
|
692
|
+
}
|
|
693
|
+
/** Set tick rate (updates tickDelta accordingly) */
|
|
694
|
+
setTickRate(rate) {
|
|
695
|
+
this._tickRate = rate;
|
|
696
|
+
this._tickDelta = 1 / rate;
|
|
697
|
+
}
|
|
698
|
+
_updateDriftCorrection() {
|
|
699
|
+
const drift = this.drift;
|
|
700
|
+
if (drift < _TickKeeper.DRIFT_BEHIND_THRESHOLD) {
|
|
701
|
+
this._timeScale = _TickKeeper.SPEED_UP_SCALE;
|
|
702
|
+
} else if (drift > _TickKeeper.DRIFT_AHEAD_THRESHOLD) {
|
|
703
|
+
this._timeScale = _TickKeeper.SLOW_DOWN_SCALE;
|
|
704
|
+
} else {
|
|
705
|
+
this._timeScale = _TickKeeper.NORMAL_SCALE;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
// Drift correction zones
|
|
710
|
+
_TickKeeper.DRIFT_BEHIND_THRESHOLD = -5;
|
|
711
|
+
_TickKeeper.DRIFT_AHEAD_THRESHOLD = 5;
|
|
712
|
+
_TickKeeper.SPEED_UP_SCALE = 1.5;
|
|
713
|
+
_TickKeeper.SLOW_DOWN_SCALE = 0.1;
|
|
714
|
+
_TickKeeper.NORMAL_SCALE = 1;
|
|
715
|
+
var TickKeeper = _TickKeeper;
|
|
716
|
+
|
|
717
|
+
// src/core/codec.ts
|
|
718
|
+
var import_msgpackr = require("msgpackr");
|
|
719
|
+
var DEFAULT_THRESHOLDS = {
|
|
720
|
+
position: 0.01,
|
|
721
|
+
rotation: 1e-3,
|
|
722
|
+
velocity: 0.05,
|
|
723
|
+
custom: "strict"
|
|
724
|
+
};
|
|
725
|
+
var SnapshotBuffer = class {
|
|
726
|
+
constructor(capacity = 120) {
|
|
727
|
+
this._capacity = capacity;
|
|
728
|
+
this._buffer = /* @__PURE__ */ new Map();
|
|
729
|
+
}
|
|
730
|
+
/** Store a snapshot at the given tick */
|
|
731
|
+
store(tick, entities) {
|
|
732
|
+
this._buffer.set(tick, entities);
|
|
733
|
+
if (this._buffer.size > this._capacity) {
|
|
734
|
+
const sortedTicks = Array.from(this._buffer.keys()).sort((a, b) => a - b);
|
|
735
|
+
const toRemove = sortedTicks.length - this._capacity;
|
|
736
|
+
for (let i = 0; i < toRemove; i++) {
|
|
737
|
+
this._buffer.delete(sortedTicks[i]);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** Get a snapshot at the given tick */
|
|
742
|
+
get(tick) {
|
|
743
|
+
return this._buffer.get(tick);
|
|
744
|
+
}
|
|
745
|
+
/** Clear all stored snapshots */
|
|
746
|
+
clear() {
|
|
747
|
+
this._buffer.clear();
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var Codec = class {
|
|
751
|
+
constructor(options) {
|
|
752
|
+
this._thresholds = { ...DEFAULT_THRESHOLDS, ...options?.thresholds };
|
|
753
|
+
this._quantize = options?.quantize;
|
|
754
|
+
this._is2D = options?.is2D ?? false;
|
|
755
|
+
}
|
|
756
|
+
/** Serialize entity states to binary (msgpackr) */
|
|
757
|
+
serialize(entities) {
|
|
758
|
+
const quantized = this._quantize ? entities.map((e) => this._quantizeEntity(e)) : entities;
|
|
759
|
+
return (0, import_msgpackr.pack)(quantized);
|
|
760
|
+
}
|
|
761
|
+
/** Deserialize binary to entity states */
|
|
762
|
+
deserialize(data) {
|
|
763
|
+
return (0, import_msgpackr.unpack)(data);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Compute delta: only include entities that changed beyond thresholds
|
|
767
|
+
* since the baseline snapshot.
|
|
768
|
+
* Returns null if nothing changed.
|
|
769
|
+
*/
|
|
770
|
+
computeDelta(current, baseline) {
|
|
771
|
+
if (!baseline) {
|
|
772
|
+
return Array.from(current.values());
|
|
773
|
+
}
|
|
774
|
+
const changed = [];
|
|
775
|
+
for (const [id, entity] of current) {
|
|
776
|
+
const prev = baseline.get(id);
|
|
777
|
+
if (!prev || this._hasChanged(entity, prev)) {
|
|
778
|
+
changed.push(entity);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
for (const id of baseline.keys()) {
|
|
782
|
+
if (!current.has(id)) {
|
|
783
|
+
if (this._is2D) {
|
|
784
|
+
changed.push({ id, x: 0, y: 0, a: 0, vx: 0, vy: 0, va: 0, c: { __removed: true } });
|
|
785
|
+
} else {
|
|
786
|
+
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 } });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return changed.length > 0 ? changed : null;
|
|
791
|
+
}
|
|
792
|
+
/** Serialize a delta snapshot packet */
|
|
793
|
+
serializeDelta(tick, baseTick, current, baseline) {
|
|
794
|
+
const delta = this.computeDelta(current, baseline);
|
|
795
|
+
if (!delta) return null;
|
|
796
|
+
const packet = {
|
|
797
|
+
t: tick,
|
|
798
|
+
b: baseline ? baseTick : -1,
|
|
799
|
+
// -1 = keyframe
|
|
800
|
+
s: this.serialize(delta)
|
|
801
|
+
};
|
|
802
|
+
return (0, import_msgpackr.pack)(packet);
|
|
803
|
+
}
|
|
804
|
+
/** Deserialize a snapshot packet */
|
|
805
|
+
deserializePacket(data) {
|
|
806
|
+
const packet = (0, import_msgpackr.unpack)(data);
|
|
807
|
+
return {
|
|
808
|
+
tick: packet.t,
|
|
809
|
+
baseTick: packet.b,
|
|
810
|
+
entities: this.deserialize(packet.s)
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
_hasChanged(current, prev) {
|
|
814
|
+
const t = this._thresholds;
|
|
815
|
+
if (Math.abs(current.x - prev.x) > t.position) return true;
|
|
816
|
+
if (Math.abs(current.y - prev.y) > t.position) return true;
|
|
817
|
+
if ("z" in current && "z" in prev) {
|
|
818
|
+
const c = current;
|
|
819
|
+
const p = prev;
|
|
820
|
+
if (Math.abs(c.z - p.z) > t.position) return true;
|
|
821
|
+
if (Math.abs(c.qx - p.qx) > t.rotation) return true;
|
|
822
|
+
if (Math.abs(c.qy - p.qy) > t.rotation) return true;
|
|
823
|
+
if (Math.abs(c.qz - p.qz) > t.rotation) return true;
|
|
824
|
+
if (Math.abs(c.qw - p.qw) > t.rotation) return true;
|
|
825
|
+
if (Math.abs(c.vx - p.vx) > t.velocity) return true;
|
|
826
|
+
if (Math.abs(c.vy - p.vy) > t.velocity) return true;
|
|
827
|
+
if (Math.abs(c.vz - p.vz) > t.velocity) return true;
|
|
828
|
+
if (Math.abs(c.wx - p.wx) > t.velocity) return true;
|
|
829
|
+
if (Math.abs(c.wy - p.wy) > t.velocity) return true;
|
|
830
|
+
if (Math.abs(c.wz - p.wz) > t.velocity) return true;
|
|
831
|
+
} else {
|
|
832
|
+
const c = current;
|
|
833
|
+
const p = prev;
|
|
834
|
+
if (Math.abs(c.a - p.a) > t.rotation) return true;
|
|
835
|
+
if (Math.abs(c.vx - p.vx) > t.velocity) return true;
|
|
836
|
+
if (Math.abs(c.vy - p.vy) > t.velocity) return true;
|
|
837
|
+
if (Math.abs(c.va - p.va) > t.velocity) return true;
|
|
838
|
+
}
|
|
839
|
+
if (current.c || prev.c) {
|
|
840
|
+
const cc = current.c ?? {};
|
|
841
|
+
const pc = prev.c ?? {};
|
|
842
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(cc), ...Object.keys(pc)]);
|
|
843
|
+
for (const key of allKeys) {
|
|
844
|
+
if (t.custom === "strict") {
|
|
845
|
+
if (cc[key] !== pc[key]) return true;
|
|
846
|
+
} else {
|
|
847
|
+
const diff = typeof cc[key] === "number" && typeof pc[key] === "number" ? Math.abs(cc[key] - pc[key]) : cc[key] === pc[key] ? 0 : 1;
|
|
848
|
+
if (diff > t.custom) return true;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
_quantizeEntity(entity) {
|
|
855
|
+
const q = this._quantize;
|
|
856
|
+
const result = { ...entity };
|
|
857
|
+
if (q.position !== void 0) {
|
|
858
|
+
const m = Math.pow(10, q.position);
|
|
859
|
+
result.x = Math.round(result.x * m) / m;
|
|
860
|
+
result.y = Math.round(result.y * m) / m;
|
|
861
|
+
if ("z" in result) {
|
|
862
|
+
result.z = Math.round(result.z * m) / m;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (q.rotation !== void 0) {
|
|
866
|
+
const m = Math.pow(10, q.rotation);
|
|
867
|
+
if ("a" in result) {
|
|
868
|
+
result.a = Math.round(result.a * m) / m;
|
|
869
|
+
} else if ("qx" in result) {
|
|
870
|
+
const r = result;
|
|
871
|
+
r.qx = Math.round(r.qx * m) / m;
|
|
872
|
+
r.qy = Math.round(r.qy * m) / m;
|
|
873
|
+
r.qz = Math.round(r.qz * m) / m;
|
|
874
|
+
r.qw = Math.round(r.qw * m) / m;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (q.velocity !== void 0) {
|
|
878
|
+
const m = Math.pow(10, q.velocity);
|
|
879
|
+
result.vx = Math.round(result.vx * m) / m;
|
|
880
|
+
result.vy = Math.round(result.vy * m) / m;
|
|
881
|
+
if ("vz" in result) {
|
|
882
|
+
const r = result;
|
|
883
|
+
r.vz = Math.round(r.vz * m) / m;
|
|
884
|
+
r.wx = Math.round(r.wx * m) / m;
|
|
885
|
+
r.wy = Math.round(r.wy * m) / m;
|
|
886
|
+
r.wz = Math.round(r.wz * m) / m;
|
|
887
|
+
}
|
|
888
|
+
if ("va" in result) {
|
|
889
|
+
result.va = Math.round(result.va * m) / m;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// src/core/NetworkManager.ts
|
|
897
|
+
var NetworkManager = class {
|
|
898
|
+
constructor(options = {}) {
|
|
899
|
+
// Transport
|
|
900
|
+
this._transport = null;
|
|
901
|
+
this._connectionState = "disconnected";
|
|
902
|
+
// Room state
|
|
903
|
+
this._room = null;
|
|
904
|
+
this._players = /* @__PURE__ */ new Map();
|
|
905
|
+
// Sync state
|
|
906
|
+
this._syncMode = "snapshot";
|
|
907
|
+
this._networkQuality = "good";
|
|
908
|
+
// Change listeners
|
|
909
|
+
this._connectionListeners = [];
|
|
910
|
+
this._playerListeners = [];
|
|
911
|
+
this._roomListeners = [];
|
|
912
|
+
this._errorListeners = [];
|
|
913
|
+
this._options = options;
|
|
914
|
+
this._syncMode = options.mode ?? "snapshot";
|
|
915
|
+
this._tickKeeper = new TickKeeper(options.tickRate ?? 60);
|
|
916
|
+
this._codec = new Codec({
|
|
917
|
+
thresholds: options.deltaThresholds,
|
|
918
|
+
quantize: options.quantize
|
|
919
|
+
});
|
|
920
|
+
this._snapshotBuffer = new SnapshotBuffer();
|
|
921
|
+
}
|
|
922
|
+
// -- Getters --
|
|
923
|
+
get transport() {
|
|
924
|
+
return this._transport;
|
|
925
|
+
}
|
|
926
|
+
get connectionState() {
|
|
927
|
+
return this._connectionState;
|
|
928
|
+
}
|
|
929
|
+
get room() {
|
|
930
|
+
return this._room;
|
|
931
|
+
}
|
|
932
|
+
get players() {
|
|
933
|
+
return this._players;
|
|
934
|
+
}
|
|
935
|
+
get selfId() {
|
|
936
|
+
return this._transport?.peerId || null;
|
|
937
|
+
}
|
|
938
|
+
get isHost() {
|
|
939
|
+
return this._transport?.isHost ?? false;
|
|
940
|
+
}
|
|
941
|
+
get hostId() {
|
|
942
|
+
return this._transport?.hostId ?? null;
|
|
943
|
+
}
|
|
944
|
+
get syncMode() {
|
|
945
|
+
return this._syncMode;
|
|
946
|
+
}
|
|
947
|
+
get tickKeeper() {
|
|
948
|
+
return this._tickKeeper;
|
|
949
|
+
}
|
|
950
|
+
get codec() {
|
|
951
|
+
return this._codec;
|
|
952
|
+
}
|
|
953
|
+
get snapshotBuffer() {
|
|
954
|
+
return this._snapshotBuffer;
|
|
955
|
+
}
|
|
956
|
+
get networkQuality() {
|
|
957
|
+
return this._networkQuality;
|
|
958
|
+
}
|
|
959
|
+
get options() {
|
|
960
|
+
return this._options;
|
|
961
|
+
}
|
|
962
|
+
// -- Transport management --
|
|
963
|
+
setTransport(transport) {
|
|
964
|
+
this._transport = transport;
|
|
965
|
+
transport.onPeerJoin((peerId) => {
|
|
966
|
+
if (!this._players.has(peerId)) {
|
|
967
|
+
this._players.set(peerId, {
|
|
968
|
+
peerId,
|
|
969
|
+
displayName: `Player-${peerId.slice(0, 4)}`,
|
|
970
|
+
isHost: peerId === transport.hostId,
|
|
971
|
+
isSelf: false,
|
|
972
|
+
isReady: false,
|
|
973
|
+
isConnected: true,
|
|
974
|
+
metadata: {},
|
|
975
|
+
latencyMs: 0,
|
|
976
|
+
joinedAt: Date.now()
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
this._notifyPlayerListeners();
|
|
980
|
+
});
|
|
981
|
+
transport.onPeerUpdated((player) => {
|
|
982
|
+
this._players.set(player.peerId, {
|
|
983
|
+
...player,
|
|
984
|
+
isSelf: player.peerId === this.selfId
|
|
985
|
+
});
|
|
986
|
+
this._notifyPlayerListeners();
|
|
987
|
+
});
|
|
988
|
+
transport.onPeerLeave((peerId) => {
|
|
989
|
+
this._players.delete(peerId);
|
|
990
|
+
this._notifyPlayerListeners();
|
|
991
|
+
});
|
|
992
|
+
transport.onHostChanged((_newHostId) => {
|
|
993
|
+
this._notifyRoomListeners();
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
// -- Connection state --
|
|
997
|
+
setConnectionState(state) {
|
|
998
|
+
this._connectionState = state;
|
|
999
|
+
for (const listener of this._connectionListeners) listener(state);
|
|
1000
|
+
}
|
|
1001
|
+
onConnectionStateChange(cb) {
|
|
1002
|
+
this._connectionListeners.push(cb);
|
|
1003
|
+
return () => {
|
|
1004
|
+
const idx = this._connectionListeners.indexOf(cb);
|
|
1005
|
+
if (idx >= 0) this._connectionListeners.splice(idx, 1);
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
// -- Room state --
|
|
1009
|
+
setRoom(room) {
|
|
1010
|
+
this._room = room;
|
|
1011
|
+
this._notifyRoomListeners();
|
|
1012
|
+
}
|
|
1013
|
+
onRoomChange(cb) {
|
|
1014
|
+
this._roomListeners.push(cb);
|
|
1015
|
+
return () => {
|
|
1016
|
+
const idx = this._roomListeners.indexOf(cb);
|
|
1017
|
+
if (idx >= 0) this._roomListeners.splice(idx, 1);
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
// -- Players --
|
|
1021
|
+
setPlayers(players) {
|
|
1022
|
+
this._players.clear();
|
|
1023
|
+
for (const p of players) {
|
|
1024
|
+
this._players.set(p.peerId, p);
|
|
1025
|
+
}
|
|
1026
|
+
this._notifyPlayerListeners();
|
|
1027
|
+
}
|
|
1028
|
+
updatePlayer(player) {
|
|
1029
|
+
this._players.set(player.peerId, player);
|
|
1030
|
+
this._notifyPlayerListeners();
|
|
1031
|
+
}
|
|
1032
|
+
removePlayer(peerId) {
|
|
1033
|
+
this._players.delete(peerId);
|
|
1034
|
+
this._notifyPlayerListeners();
|
|
1035
|
+
}
|
|
1036
|
+
onPlayersChange(cb) {
|
|
1037
|
+
this._playerListeners.push(cb);
|
|
1038
|
+
return () => {
|
|
1039
|
+
const idx = this._playerListeners.indexOf(cb);
|
|
1040
|
+
if (idx >= 0) this._playerListeners.splice(idx, 1);
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
// -- Errors --
|
|
1044
|
+
emitError(error) {
|
|
1045
|
+
for (const listener of this._errorListeners) listener(error);
|
|
1046
|
+
}
|
|
1047
|
+
onError(cb) {
|
|
1048
|
+
this._errorListeners.push(cb);
|
|
1049
|
+
return () => {
|
|
1050
|
+
const idx = this._errorListeners.indexOf(cb);
|
|
1051
|
+
if (idx >= 0) this._errorListeners.splice(idx, 1);
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
// -- Network quality --
|
|
1055
|
+
setNetworkQuality(quality) {
|
|
1056
|
+
this._networkQuality = quality;
|
|
1057
|
+
}
|
|
1058
|
+
// -- Sync options --
|
|
1059
|
+
updateOptions(options) {
|
|
1060
|
+
this._options = options;
|
|
1061
|
+
if (options.mode) this._syncMode = options.mode;
|
|
1062
|
+
if (options.tickRate) this._tickKeeper.setTickRate(options.tickRate);
|
|
1063
|
+
if (options.deltaThresholds || options.quantize) {
|
|
1064
|
+
this._codec = new Codec({
|
|
1065
|
+
thresholds: options.deltaThresholds,
|
|
1066
|
+
quantize: options.quantize
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// -- Cleanup --
|
|
1071
|
+
destroy() {
|
|
1072
|
+
this._transport?.disconnect();
|
|
1073
|
+
this._transport = null;
|
|
1074
|
+
this._connectionState = "disconnected";
|
|
1075
|
+
this._room = null;
|
|
1076
|
+
this._players.clear();
|
|
1077
|
+
this._tickKeeper.reset();
|
|
1078
|
+
this._snapshotBuffer.clear();
|
|
1079
|
+
this._connectionListeners = [];
|
|
1080
|
+
this._playerListeners = [];
|
|
1081
|
+
this._roomListeners = [];
|
|
1082
|
+
this._errorListeners = [];
|
|
1083
|
+
}
|
|
1084
|
+
// -- Private --
|
|
1085
|
+
_notifyPlayerListeners() {
|
|
1086
|
+
for (const listener of this._playerListeners) listener();
|
|
1087
|
+
}
|
|
1088
|
+
_notifyRoomListeners() {
|
|
1089
|
+
for (const listener of this._roomListeners) listener();
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// src/core/MultiplayerContext.ts
|
|
1094
|
+
var import_react = require("react");
|
|
1095
|
+
var MultiplayerContext = (0, import_react.createContext)(null);
|
|
1096
|
+
function useMultiplayerContext() {
|
|
1097
|
+
const ctx = (0, import_react.useContext)(MultiplayerContext);
|
|
1098
|
+
if (!ctx) {
|
|
1099
|
+
throw new Error("useMultiplayerContext must be used inside a <MultiplayerProvider>.");
|
|
1100
|
+
}
|
|
1101
|
+
return ctx;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/components/MultiplayerProvider.ts
|
|
1105
|
+
function createStrategy(appId, config) {
|
|
1106
|
+
if (!config || config.type === "mqtt") {
|
|
1107
|
+
return new MqttStrategy(appId, config ?? { type: "mqtt" });
|
|
1108
|
+
}
|
|
1109
|
+
if (config.type === "firebase") {
|
|
1110
|
+
return new FirebaseStrategy(appId, config);
|
|
1111
|
+
}
|
|
1112
|
+
throw new Error(`Unknown strategy type: ${config.type}`);
|
|
1113
|
+
}
|
|
1114
|
+
function MultiplayerProvider({
|
|
1115
|
+
appId,
|
|
1116
|
+
strategy: strategyConfig,
|
|
1117
|
+
iceServers,
|
|
1118
|
+
children
|
|
1119
|
+
}) {
|
|
1120
|
+
const managerRef = (0, import_react2.useRef)(null);
|
|
1121
|
+
const strategyRef = (0, import_react2.useRef)(null);
|
|
1122
|
+
if (!managerRef.current) {
|
|
1123
|
+
managerRef.current = new NetworkManager();
|
|
1124
|
+
}
|
|
1125
|
+
if (!strategyRef.current) {
|
|
1126
|
+
strategyRef.current = createStrategy(appId, strategyConfig);
|
|
1127
|
+
}
|
|
1128
|
+
(0, import_react2.useEffect)(() => {
|
|
1129
|
+
return () => {
|
|
1130
|
+
strategyRef.current?.destroy();
|
|
1131
|
+
strategyRef.current = null;
|
|
1132
|
+
managerRef.current?.destroy();
|
|
1133
|
+
managerRef.current = null;
|
|
1134
|
+
};
|
|
1135
|
+
}, []);
|
|
1136
|
+
const value = {
|
|
1137
|
+
appId,
|
|
1138
|
+
strategy: strategyRef.current,
|
|
1139
|
+
iceServers,
|
|
1140
|
+
networkManager: managerRef.current
|
|
1141
|
+
};
|
|
1142
|
+
return (0, import_react2.createElement)(MultiplayerContext.Provider, { value }, children);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/components/MultiplayerBridge.ts
|
|
1146
|
+
var import_react3 = require("react");
|
|
1147
|
+
function MultiplayerBridge({ children }) {
|
|
1148
|
+
const ctx = (0, import_react3.useContext)(MultiplayerContext);
|
|
1149
|
+
if (!ctx) return (0, import_react3.createElement)("group", null, children);
|
|
1150
|
+
return (0, import_react3.createElement)(MultiplayerContext.Provider, { value: ctx }, children);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/hooks/useRoom.ts
|
|
1154
|
+
var import_react4 = require("react");
|
|
1155
|
+
|
|
1156
|
+
// src/transport/webrtc/ice.ts
|
|
1157
|
+
var DEFAULT_STUN_SERVERS = [
|
|
1158
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
1159
|
+
{ urls: "stun:stun1.l.google.com:19302" },
|
|
1160
|
+
{ urls: "stun:stun2.l.google.com:19302" },
|
|
1161
|
+
{ urls: "stun:stun.cloudflare.com:3478" }
|
|
1162
|
+
];
|
|
1163
|
+
function buildICEConfig(options) {
|
|
1164
|
+
const servers = options?.iceServers && options.iceServers.length > 0 ? options.iceServers : DEFAULT_STUN_SERVERS;
|
|
1165
|
+
return {
|
|
1166
|
+
iceServers: servers,
|
|
1167
|
+
iceCandidatePoolSize: 10,
|
|
1168
|
+
iceTransportPolicy: options?.iceTransportPolicy ?? "all"
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/transport/webrtc/peer.ts
|
|
1173
|
+
var PeerConnection = class {
|
|
1174
|
+
constructor(peerId, config, events) {
|
|
1175
|
+
this._channels = /* @__PURE__ */ new Map();
|
|
1176
|
+
this._state = "connecting";
|
|
1177
|
+
this._remoteDescriptionSet = false;
|
|
1178
|
+
this._pendingCandidates = [];
|
|
1179
|
+
this.peerId = peerId;
|
|
1180
|
+
this._events = events;
|
|
1181
|
+
this._connection = new RTCPeerConnection(config);
|
|
1182
|
+
this._connection.onicecandidate = (e) => {
|
|
1183
|
+
if (e.candidate) {
|
|
1184
|
+
this._events.onIceCandidate(e.candidate);
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
this._connection.oniceconnectionstatechange = () => {
|
|
1188
|
+
this._updateState();
|
|
1189
|
+
};
|
|
1190
|
+
this._connection.onconnectionstatechange = () => {
|
|
1191
|
+
this._updateState();
|
|
1192
|
+
};
|
|
1193
|
+
this._connection.ondatachannel = (e) => {
|
|
1194
|
+
const channel = e.channel;
|
|
1195
|
+
this._channels.set(channel.label, channel);
|
|
1196
|
+
this._events.onDataChannel(channel);
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
get state() {
|
|
1200
|
+
return this._state;
|
|
1201
|
+
}
|
|
1202
|
+
get connection() {
|
|
1203
|
+
return this._connection;
|
|
1204
|
+
}
|
|
1205
|
+
_updateState() {
|
|
1206
|
+
const iceState = this._connection.iceConnectionState;
|
|
1207
|
+
const connState = this._connection.connectionState;
|
|
1208
|
+
let newState;
|
|
1209
|
+
if (connState === "connected" || iceState === "connected") {
|
|
1210
|
+
newState = "connected";
|
|
1211
|
+
} else if (connState === "failed" || iceState === "failed") {
|
|
1212
|
+
newState = "failed";
|
|
1213
|
+
} else if (connState === "closed" || iceState === "closed" || iceState === "disconnected") {
|
|
1214
|
+
newState = "disconnected";
|
|
1215
|
+
} else {
|
|
1216
|
+
newState = "connecting";
|
|
1217
|
+
}
|
|
1218
|
+
if (newState !== this._state) {
|
|
1219
|
+
this._state = newState;
|
|
1220
|
+
this._events.onStateChange(newState);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async createOffer() {
|
|
1224
|
+
const offer = await this._connection.createOffer();
|
|
1225
|
+
await this._connection.setLocalDescription(offer);
|
|
1226
|
+
return offer;
|
|
1227
|
+
}
|
|
1228
|
+
async handleOffer(offer) {
|
|
1229
|
+
await this._connection.setRemoteDescription(new RTCSessionDescription(offer));
|
|
1230
|
+
this._remoteDescriptionSet = true;
|
|
1231
|
+
await this._flushPendingCandidates();
|
|
1232
|
+
const answer = await this._connection.createAnswer();
|
|
1233
|
+
await this._connection.setLocalDescription(answer);
|
|
1234
|
+
return answer;
|
|
1235
|
+
}
|
|
1236
|
+
async handleAnswer(answer) {
|
|
1237
|
+
await this._connection.setRemoteDescription(new RTCSessionDescription(answer));
|
|
1238
|
+
this._remoteDescriptionSet = true;
|
|
1239
|
+
await this._flushPendingCandidates();
|
|
1240
|
+
}
|
|
1241
|
+
async addIceCandidate(candidate) {
|
|
1242
|
+
if (!this._remoteDescriptionSet) {
|
|
1243
|
+
this._pendingCandidates.push(candidate);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
try {
|
|
1247
|
+
await this._connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
1248
|
+
} catch {
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async _flushPendingCandidates() {
|
|
1252
|
+
const candidates = this._pendingCandidates;
|
|
1253
|
+
this._pendingCandidates = [];
|
|
1254
|
+
for (const c of candidates) {
|
|
1255
|
+
try {
|
|
1256
|
+
await this._connection.addIceCandidate(new RTCIceCandidate(c));
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
createDataChannel(name, options) {
|
|
1262
|
+
const existing = this._channels.get(name);
|
|
1263
|
+
if (existing && existing.readyState !== "closed") {
|
|
1264
|
+
return existing;
|
|
1265
|
+
}
|
|
1266
|
+
const dcOptions = {};
|
|
1267
|
+
if (options?.reliable === false) {
|
|
1268
|
+
dcOptions.ordered = options?.ordered ?? false;
|
|
1269
|
+
dcOptions.maxRetransmits = options?.maxRetransmits ?? 0;
|
|
1270
|
+
} else {
|
|
1271
|
+
dcOptions.ordered = options?.ordered ?? true;
|
|
1272
|
+
}
|
|
1273
|
+
const channel = this._connection.createDataChannel(name, dcOptions);
|
|
1274
|
+
this._channels.set(name, channel);
|
|
1275
|
+
return channel;
|
|
1276
|
+
}
|
|
1277
|
+
getDataChannel(name) {
|
|
1278
|
+
return this._channels.get(name);
|
|
1279
|
+
}
|
|
1280
|
+
close() {
|
|
1281
|
+
for (const channel of this._channels.values()) {
|
|
1282
|
+
try {
|
|
1283
|
+
channel.close();
|
|
1284
|
+
} catch {
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
this._channels.clear();
|
|
1288
|
+
this._pendingCandidates = [];
|
|
1289
|
+
this._remoteDescriptionSet = false;
|
|
1290
|
+
try {
|
|
1291
|
+
this._connection.close();
|
|
1292
|
+
} catch {
|
|
1293
|
+
}
|
|
1294
|
+
this._state = "disconnected";
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// src/transport/webrtc/WebRTCTransport.ts
|
|
1299
|
+
var ROOM_CONTROL_CHANNEL = "carver:room-control";
|
|
1300
|
+
function electHost(peerIds) {
|
|
1301
|
+
return [...peerIds].sort()[0];
|
|
1302
|
+
}
|
|
1303
|
+
var WebRTCTransport = class {
|
|
1304
|
+
/**
|
|
1305
|
+
* @param strategy Shared SignalingStrategy instance (managed by MultiplayerProvider)
|
|
1306
|
+
* @param iceServers Optional ICE servers (STUN + TURN). Defaults to public STUN.
|
|
1307
|
+
* @param iceTransportPolicy 'all' (default) or 'relay' (force TURN only).
|
|
1308
|
+
*/
|
|
1309
|
+
constructor(strategy, iceServers, iceTransportPolicy) {
|
|
1310
|
+
this._peers = /* @__PURE__ */ new Map();
|
|
1311
|
+
this._peerSet = /* @__PURE__ */ new Set();
|
|
1312
|
+
this._hostId = "";
|
|
1313
|
+
this._isHost = false;
|
|
1314
|
+
this._callbacks = {
|
|
1315
|
+
onPeerJoin: [],
|
|
1316
|
+
onPeerLeave: [],
|
|
1317
|
+
onPeerUpdated: [],
|
|
1318
|
+
onHostChanged: []
|
|
1319
|
+
};
|
|
1320
|
+
this._roomUpdatedCallbacks = [];
|
|
1321
|
+
this._channels = /* @__PURE__ */ new Map();
|
|
1322
|
+
this._rateLimitConfig = { maxMessagesPerSecond: 60, windowMs: 1e3 };
|
|
1323
|
+
this._rateLimitCounters = /* @__PURE__ */ new Map();
|
|
1324
|
+
this._connected = false;
|
|
1325
|
+
this._room = null;
|
|
1326
|
+
this._playerMap = /* @__PURE__ */ new Map();
|
|
1327
|
+
this._initialPeers = [];
|
|
1328
|
+
this._strategyUnsubs = [];
|
|
1329
|
+
this._strategy = strategy;
|
|
1330
|
+
this._peerId = strategy.selfId;
|
|
1331
|
+
this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
|
|
1332
|
+
}
|
|
1333
|
+
// ── CarverTransport getters ──
|
|
1334
|
+
get peerId() {
|
|
1335
|
+
return this._peerId;
|
|
1336
|
+
}
|
|
1337
|
+
get peers() {
|
|
1338
|
+
return this._peerSet;
|
|
1339
|
+
}
|
|
1340
|
+
get hostId() {
|
|
1341
|
+
return this._hostId;
|
|
1342
|
+
}
|
|
1343
|
+
get isHost() {
|
|
1344
|
+
return this._isHost;
|
|
1345
|
+
}
|
|
1346
|
+
get room() {
|
|
1347
|
+
return this._room ?? void 0;
|
|
1348
|
+
}
|
|
1349
|
+
get initialPlayers() {
|
|
1350
|
+
return this._initialPeers;
|
|
1351
|
+
}
|
|
1352
|
+
// ── Event registration ──
|
|
1353
|
+
onPeerJoin(cb) {
|
|
1354
|
+
this._callbacks.onPeerJoin.push(cb);
|
|
1355
|
+
}
|
|
1356
|
+
onPeerLeave(cb) {
|
|
1357
|
+
this._callbacks.onPeerLeave.push(cb);
|
|
1358
|
+
}
|
|
1359
|
+
onPeerUpdated(cb) {
|
|
1360
|
+
this._callbacks.onPeerUpdated.push(cb);
|
|
1361
|
+
}
|
|
1362
|
+
onRoomUpdated(cb) {
|
|
1363
|
+
this._roomUpdatedCallbacks.push(cb);
|
|
1364
|
+
}
|
|
1365
|
+
onHostChanged(cb) {
|
|
1366
|
+
this._callbacks.onHostChanged.push(cb);
|
|
1367
|
+
}
|
|
1368
|
+
// ── Channel management ──
|
|
1369
|
+
createChannel(name, options) {
|
|
1370
|
+
const existing = this._channels.get(name);
|
|
1371
|
+
if (existing) {
|
|
1372
|
+
return {
|
|
1373
|
+
send: (data, target) => this._sendOnChannel(name, data, target),
|
|
1374
|
+
onReceive: (cb) => {
|
|
1375
|
+
existing.receivers.push(cb);
|
|
1376
|
+
},
|
|
1377
|
+
close: () => {
|
|
1378
|
+
this._channels.delete(name);
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
const state = {
|
|
1383
|
+
name,
|
|
1384
|
+
options: options ?? { reliable: true, ordered: true },
|
|
1385
|
+
receivers: []
|
|
1386
|
+
};
|
|
1387
|
+
this._channels.set(name, state);
|
|
1388
|
+
if (this._connected) {
|
|
1389
|
+
for (const peer of this._peers.values()) {
|
|
1390
|
+
this._createDataChannelOnPeer(peer, name, state.options);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
send: (data, target) => this._sendOnChannel(name, data, target),
|
|
1395
|
+
onReceive: (cb) => {
|
|
1396
|
+
state.receivers.push(cb);
|
|
1397
|
+
},
|
|
1398
|
+
close: () => {
|
|
1399
|
+
this._channels.delete(name);
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
// ── Connect / Disconnect ──
|
|
1404
|
+
async connect(roomId, config) {
|
|
1405
|
+
if (config?.iceServers) {
|
|
1406
|
+
this._iceConfig = buildICEConfig({
|
|
1407
|
+
iceServers: config.iceServers,
|
|
1408
|
+
iceTransportPolicy: config.iceTransportPolicy
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
this._setupRoomControlChannel();
|
|
1412
|
+
this._preRegisterChannel("carver:events", { reliable: true, ordered: true });
|
|
1413
|
+
this._preRegisterChannel("carver:snapshots", { reliable: false, ordered: false });
|
|
1414
|
+
this._preRegisterChannel("carver:acks", { reliable: true, ordered: true });
|
|
1415
|
+
this._preRegisterChannel("carver:inputs", { reliable: true, ordered: true });
|
|
1416
|
+
this._preRegisterChannel("carver:network-state", { reliable: true, ordered: true });
|
|
1417
|
+
this._strategyUnsubs.push(
|
|
1418
|
+
this._strategy.onPeerDiscovered((peerId, meta) => {
|
|
1419
|
+
this._onStrategyPeerDiscovered(peerId, meta);
|
|
1420
|
+
})
|
|
1421
|
+
);
|
|
1422
|
+
this._strategyUnsubs.push(
|
|
1423
|
+
this._strategy.onPeerLeft((peerId) => {
|
|
1424
|
+
this._removePeer(peerId);
|
|
1425
|
+
this._playerMap.delete(peerId);
|
|
1426
|
+
this._electAndSetHost();
|
|
1427
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1428
|
+
})
|
|
1429
|
+
);
|
|
1430
|
+
this._strategyUnsubs.push(
|
|
1431
|
+
this._strategy.onSignal((fromPeerId, data) => {
|
|
1432
|
+
this._handleSignal(fromPeerId, data);
|
|
1433
|
+
})
|
|
1434
|
+
);
|
|
1435
|
+
await this._strategy.joinRoom(roomId, {
|
|
1436
|
+
displayName: config?.displayName,
|
|
1437
|
+
...config?.playerMetadata ?? {}
|
|
1438
|
+
});
|
|
1439
|
+
const selfPlayer = {
|
|
1440
|
+
peerId: this._peerId,
|
|
1441
|
+
displayName: config?.displayName ?? `Player-${this._peerId.slice(0, 4)}`,
|
|
1442
|
+
isHost: false,
|
|
1443
|
+
isSelf: true,
|
|
1444
|
+
isReady: false,
|
|
1445
|
+
isConnected: true,
|
|
1446
|
+
metadata: config?.playerMetadata ?? {},
|
|
1447
|
+
latencyMs: 0,
|
|
1448
|
+
joinedAt: Date.now()
|
|
1449
|
+
};
|
|
1450
|
+
this._playerMap.set(this._peerId, selfPlayer);
|
|
1451
|
+
this._electAndSetHost();
|
|
1452
|
+
this._room = {
|
|
1453
|
+
id: roomId,
|
|
1454
|
+
name: roomId,
|
|
1455
|
+
hostId: this._hostId,
|
|
1456
|
+
playerCount: this._playerMap.size,
|
|
1457
|
+
maxPlayers: config?.maxPlayers ?? 8,
|
|
1458
|
+
isPrivate: false,
|
|
1459
|
+
metadata: {},
|
|
1460
|
+
createdAt: Date.now(),
|
|
1461
|
+
state: "lobby"
|
|
1462
|
+
};
|
|
1463
|
+
this._initialPeers = Array.from(this._playerMap.values());
|
|
1464
|
+
this._connected = true;
|
|
1465
|
+
}
|
|
1466
|
+
disconnect() {
|
|
1467
|
+
this._connected = false;
|
|
1468
|
+
for (const unsub of this._strategyUnsubs) unsub();
|
|
1469
|
+
this._strategyUnsubs = [];
|
|
1470
|
+
for (const peer of this._peers.values()) peer.close();
|
|
1471
|
+
this._peers.clear();
|
|
1472
|
+
this._peerSet.clear();
|
|
1473
|
+
this._channels.clear();
|
|
1474
|
+
this._rateLimitCounters.clear();
|
|
1475
|
+
this._playerMap.clear();
|
|
1476
|
+
this._strategy.leaveRoom().catch(() => {
|
|
1477
|
+
});
|
|
1478
|
+
this._hostId = "";
|
|
1479
|
+
this._isHost = false;
|
|
1480
|
+
this._room = null;
|
|
1481
|
+
}
|
|
1482
|
+
/** Expose strategy for lobby hooks */
|
|
1483
|
+
get strategy() {
|
|
1484
|
+
return this._strategy;
|
|
1485
|
+
}
|
|
1486
|
+
// ── Channel pre-registration ──
|
|
1487
|
+
/**
|
|
1488
|
+
* Register a channel name and options without creating data channels yet.
|
|
1489
|
+
* When _connectToPeer runs, it iterates this._channels and creates data
|
|
1490
|
+
* channels for every registered name in the initial WebRTC offer.
|
|
1491
|
+
* Later, when EventSync/SnapshotSync call createChannel(), the idempotent
|
|
1492
|
+
* check returns the pre-registered entry and they just attach receivers.
|
|
1493
|
+
*/
|
|
1494
|
+
_preRegisterChannel(name, options) {
|
|
1495
|
+
if (this._channels.has(name)) return;
|
|
1496
|
+
this._channels.set(name, { name, options, receivers: [] });
|
|
1497
|
+
}
|
|
1498
|
+
// ── Room management (over WebRTC data channels) ──
|
|
1499
|
+
setReady(ready) {
|
|
1500
|
+
this._sendControlMessage({ type: "request-ready", ready });
|
|
1501
|
+
}
|
|
1502
|
+
setMetadata(metadata) {
|
|
1503
|
+
this._sendControlMessage({ type: "request-metadata", metadata });
|
|
1504
|
+
}
|
|
1505
|
+
setRoomMetadata(metadata) {
|
|
1506
|
+
if (!this._isHost) return;
|
|
1507
|
+
this._sendControlMessage({ type: "request-room-metadata", metadata });
|
|
1508
|
+
}
|
|
1509
|
+
kick(peerId, reason) {
|
|
1510
|
+
if (!this._isHost) return;
|
|
1511
|
+
this._broadcastControlMessage({ type: "kick", peerId, reason });
|
|
1512
|
+
}
|
|
1513
|
+
transferHost(peerId) {
|
|
1514
|
+
if (!this._isHost) return;
|
|
1515
|
+
this._sendControlMessage({ type: "request-transfer-host", peerId });
|
|
1516
|
+
}
|
|
1517
|
+
setRoomState(state) {
|
|
1518
|
+
if (!this._isHost) return;
|
|
1519
|
+
this._sendControlMessage({ type: "request-room-state", state });
|
|
1520
|
+
}
|
|
1521
|
+
setMaxPlayers(n) {
|
|
1522
|
+
if (!this._isHost) return;
|
|
1523
|
+
this._sendControlMessage({ type: "request-max-players", maxPlayers: n });
|
|
1524
|
+
}
|
|
1525
|
+
lockRoom() {
|
|
1526
|
+
if (!this._isHost) return;
|
|
1527
|
+
this._sendControlMessage({ type: "request-lock" });
|
|
1528
|
+
}
|
|
1529
|
+
unlockRoom() {
|
|
1530
|
+
if (!this._isHost) return;
|
|
1531
|
+
this._sendControlMessage({ type: "request-unlock" });
|
|
1532
|
+
}
|
|
1533
|
+
/** No-op: lobby uses strategy.subscribeToLobby() directly */
|
|
1534
|
+
requestRoomList() {
|
|
1535
|
+
}
|
|
1536
|
+
// ── Private: Strategy callbacks ──
|
|
1537
|
+
_onStrategyPeerDiscovered(peerId, meta) {
|
|
1538
|
+
this._connectToPeer(peerId);
|
|
1539
|
+
this._peerSet.add(peerId);
|
|
1540
|
+
const player = {
|
|
1541
|
+
peerId,
|
|
1542
|
+
displayName: meta.displayName ?? `Player-${peerId.slice(0, 4)}`,
|
|
1543
|
+
isHost: false,
|
|
1544
|
+
isSelf: false,
|
|
1545
|
+
isReady: false,
|
|
1546
|
+
isConnected: true,
|
|
1547
|
+
metadata: meta,
|
|
1548
|
+
latencyMs: 0,
|
|
1549
|
+
joinedAt: Date.now()
|
|
1550
|
+
};
|
|
1551
|
+
this._playerMap.set(peerId, player);
|
|
1552
|
+
this._electAndSetHost();
|
|
1553
|
+
for (const cb of this._callbacks.onPeerJoin) cb(peerId);
|
|
1554
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(player);
|
|
1555
|
+
}
|
|
1556
|
+
// ── Private: Room control channel ──
|
|
1557
|
+
_setupRoomControlChannel() {
|
|
1558
|
+
const ch = this.createChannel(ROOM_CONTROL_CHANNEL, {
|
|
1559
|
+
reliable: true,
|
|
1560
|
+
ordered: true
|
|
1561
|
+
});
|
|
1562
|
+
ch.onReceive((msg, peerId) => {
|
|
1563
|
+
this._handleControlMessage(msg, peerId);
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
_handleControlMessage(msg, fromPeerId) {
|
|
1567
|
+
switch (msg.type) {
|
|
1568
|
+
case "player-updated": {
|
|
1569
|
+
this._playerMap.set(msg.player.peerId, msg.player);
|
|
1570
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(msg.player);
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
case "room-updated": {
|
|
1574
|
+
if (this._room) {
|
|
1575
|
+
Object.assign(this._room, msg.room);
|
|
1576
|
+
for (const cb of this._roomUpdatedCallbacks) cb(this._room);
|
|
1577
|
+
}
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case "kick": {
|
|
1581
|
+
if (msg.peerId === this._peerId) {
|
|
1582
|
+
this.disconnect();
|
|
1583
|
+
}
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
case "host-changed": {
|
|
1587
|
+
this._hostId = msg.newHostId;
|
|
1588
|
+
this._isHost = msg.newHostId === this._peerId;
|
|
1589
|
+
for (const cb of this._callbacks.onHostChanged) cb(msg.newHostId);
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
case "sync-state": {
|
|
1593
|
+
this._room = msg.room;
|
|
1594
|
+
for (const p of msg.players) {
|
|
1595
|
+
this._playerMap.set(p.peerId, { ...p, isSelf: p.peerId === this._peerId });
|
|
1596
|
+
for (const cb of this._callbacks.onPeerUpdated) cb(p);
|
|
1597
|
+
}
|
|
1598
|
+
for (const cb of this._roomUpdatedCallbacks) cb(msg.room);
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
// Host processes requests from peers
|
|
1602
|
+
case "request-ready": {
|
|
1603
|
+
if (!this._isHost) break;
|
|
1604
|
+
const p = this._playerMap.get(fromPeerId);
|
|
1605
|
+
if (p) {
|
|
1606
|
+
p.isReady = msg.ready;
|
|
1607
|
+
this._broadcastControlMessage({ type: "player-updated", player: p });
|
|
1608
|
+
}
|
|
1609
|
+
break;
|
|
1610
|
+
}
|
|
1611
|
+
case "request-metadata": {
|
|
1612
|
+
if (!this._isHost) break;
|
|
1613
|
+
const pm = this._playerMap.get(fromPeerId);
|
|
1614
|
+
if (pm) {
|
|
1615
|
+
pm.metadata = { ...pm.metadata, ...msg.metadata };
|
|
1616
|
+
this._broadcastControlMessage({ type: "player-updated", player: pm });
|
|
1617
|
+
}
|
|
1618
|
+
break;
|
|
1619
|
+
}
|
|
1620
|
+
case "request-room-metadata": {
|
|
1621
|
+
if (!this._isHost || !this._room) break;
|
|
1622
|
+
this._room.metadata = { ...this._room.metadata, ...msg.metadata };
|
|
1623
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
case "request-room-state": {
|
|
1627
|
+
if (!this._isHost || !this._room) break;
|
|
1628
|
+
this._room.state = msg.state;
|
|
1629
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1630
|
+
break;
|
|
1631
|
+
}
|
|
1632
|
+
case "request-max-players": {
|
|
1633
|
+
if (!this._isHost || !this._room) break;
|
|
1634
|
+
this._room.maxPlayers = msg.maxPlayers;
|
|
1635
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1638
|
+
case "request-lock": {
|
|
1639
|
+
if (!this._isHost || !this._room) break;
|
|
1640
|
+
this._room.locked = true;
|
|
1641
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
case "request-unlock": {
|
|
1645
|
+
if (!this._isHost || !this._room) break;
|
|
1646
|
+
this._room.locked = false;
|
|
1647
|
+
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1650
|
+
case "request-transfer-host": {
|
|
1651
|
+
if (!this._isHost) break;
|
|
1652
|
+
this._hostId = msg.peerId;
|
|
1653
|
+
this._isHost = false;
|
|
1654
|
+
this._broadcastControlMessage({ type: "host-changed", newHostId: msg.peerId });
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
_sendControlMessage(msg) {
|
|
1660
|
+
if (this._isHost && msg.type.startsWith("request-")) {
|
|
1661
|
+
this._handleControlMessage(msg, this._peerId);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (this._hostId && this._hostId !== this._peerId) {
|
|
1665
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg, this._hostId);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
_broadcastControlMessage(msg) {
|
|
1669
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, msg);
|
|
1670
|
+
this._handleControlMessage(msg, this._peerId);
|
|
1671
|
+
}
|
|
1672
|
+
// ── Private: Host election ──
|
|
1673
|
+
_electAndSetHost() {
|
|
1674
|
+
const allIds = [this._peerId, ...this._peerSet];
|
|
1675
|
+
const newHostId = electHost(allIds);
|
|
1676
|
+
const changed = newHostId !== this._hostId;
|
|
1677
|
+
this._hostId = newHostId;
|
|
1678
|
+
this._isHost = newHostId === this._peerId;
|
|
1679
|
+
for (const [id, p] of this._playerMap) {
|
|
1680
|
+
p.isHost = id === newHostId;
|
|
1681
|
+
}
|
|
1682
|
+
if (this._room) {
|
|
1683
|
+
this._room.hostId = newHostId;
|
|
1684
|
+
this._room.playerCount = this._playerMap.size;
|
|
1685
|
+
}
|
|
1686
|
+
if (changed) {
|
|
1687
|
+
for (const cb of this._callbacks.onHostChanged) cb(newHostId);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
// ── Private: WebRTC peer management ──
|
|
1691
|
+
_connectToPeer(peerId) {
|
|
1692
|
+
if (this._peers.has(peerId)) return;
|
|
1693
|
+
const peer = new PeerConnection(peerId, this._iceConfig, {
|
|
1694
|
+
onStateChange: (state) => {
|
|
1695
|
+
if (state === "connected" && this._isHost && this._room) {
|
|
1696
|
+
const syncMsg = {
|
|
1697
|
+
type: "sync-state",
|
|
1698
|
+
room: this._room,
|
|
1699
|
+
players: Array.from(this._playerMap.values())
|
|
1700
|
+
};
|
|
1701
|
+
setTimeout(() => {
|
|
1702
|
+
this._sendOnChannel(ROOM_CONTROL_CHANNEL, syncMsg, peerId);
|
|
1703
|
+
}, 100);
|
|
1704
|
+
}
|
|
1705
|
+
if (state === "failed" || state === "disconnected") {
|
|
1706
|
+
this._removePeer(peerId);
|
|
1707
|
+
this._playerMap.delete(peerId);
|
|
1708
|
+
this._electAndSetHost();
|
|
1709
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1710
|
+
}
|
|
1711
|
+
},
|
|
1712
|
+
onDataChannel: (channel) => {
|
|
1713
|
+
this._setupDataChannelReceiver(channel, peerId);
|
|
1714
|
+
},
|
|
1715
|
+
onIceCandidate: (candidate) => {
|
|
1716
|
+
this._strategy.signal(peerId, { type: "ice-candidate", candidate: candidate.toJSON() });
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
this._peers.set(peerId, peer);
|
|
1720
|
+
this._peerSet.add(peerId);
|
|
1721
|
+
if (this._peerId < peerId) {
|
|
1722
|
+
for (const [name, state] of this._channels) {
|
|
1723
|
+
this._createDataChannelOnPeer(peer, name, state.options);
|
|
1724
|
+
}
|
|
1725
|
+
peer.createOffer().then((offer) => {
|
|
1726
|
+
this._strategy.signal(peerId, { type: "offer", sdp: offer });
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
async _handleSignal(peerId, data) {
|
|
1731
|
+
try {
|
|
1732
|
+
const signal = data;
|
|
1733
|
+
let peer = this._peers.get(peerId);
|
|
1734
|
+
if (signal.type === "offer") {
|
|
1735
|
+
if (!peer) {
|
|
1736
|
+
this._connectToPeer(peerId);
|
|
1737
|
+
peer = this._peers.get(peerId);
|
|
1738
|
+
}
|
|
1739
|
+
const answer = await peer.handleOffer(signal.sdp);
|
|
1740
|
+
this._strategy.signal(peerId, { type: "answer", sdp: answer });
|
|
1741
|
+
} else if (signal.type === "answer" && peer) {
|
|
1742
|
+
await peer.handleAnswer(signal.sdp);
|
|
1743
|
+
} else if (signal.type === "ice-candidate" && peer) {
|
|
1744
|
+
await peer.addIceCandidate(signal.candidate);
|
|
1745
|
+
}
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
if (typeof console !== "undefined") {
|
|
1748
|
+
console.error("[CarverJS] Signal handling failed:", err);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// ── Private: Data channel helpers ──
|
|
1753
|
+
_createDataChannelOnPeer(peer, name, options) {
|
|
1754
|
+
const channel = peer.createDataChannel(name, options);
|
|
1755
|
+
this._setupDataChannelReceiver(channel, peer.peerId);
|
|
1756
|
+
}
|
|
1757
|
+
_setupDataChannelReceiver(dataChannel, peerId) {
|
|
1758
|
+
const channelName = dataChannel.label;
|
|
1759
|
+
dataChannel.onmessage = (event) => {
|
|
1760
|
+
if (!this._checkRateLimit(peerId)) return;
|
|
1761
|
+
const channelState = this._channels.get(channelName);
|
|
1762
|
+
if (!channelState) return;
|
|
1763
|
+
try {
|
|
1764
|
+
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
|
1765
|
+
for (const receiver of channelState.receivers) receiver(data, peerId);
|
|
1766
|
+
} catch {
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
_sendOnChannel(channelName, data, target) {
|
|
1771
|
+
const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
|
|
1772
|
+
const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
|
|
1773
|
+
for (const pid of targets) {
|
|
1774
|
+
const peer = this._peers.get(pid);
|
|
1775
|
+
const ch = peer?.getDataChannel(channelName);
|
|
1776
|
+
if (ch?.readyState === "open") {
|
|
1777
|
+
try {
|
|
1778
|
+
ch.send(serialized);
|
|
1779
|
+
} catch {
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
_removePeer(peerId) {
|
|
1785
|
+
const peer = this._peers.get(peerId);
|
|
1786
|
+
if (peer) {
|
|
1787
|
+
peer.close();
|
|
1788
|
+
this._peers.delete(peerId);
|
|
1789
|
+
}
|
|
1790
|
+
this._peerSet.delete(peerId);
|
|
1791
|
+
this._rateLimitCounters.delete(peerId);
|
|
1792
|
+
}
|
|
1793
|
+
_checkRateLimit(peerId) {
|
|
1794
|
+
const now = Date.now();
|
|
1795
|
+
let c = this._rateLimitCounters.get(peerId);
|
|
1796
|
+
if (!c || now >= c.resetAt) {
|
|
1797
|
+
c = { count: 0, resetAt: now + this._rateLimitConfig.windowMs };
|
|
1798
|
+
this._rateLimitCounters.set(peerId, c);
|
|
1799
|
+
}
|
|
1800
|
+
c.count++;
|
|
1801
|
+
return c.count <= this._rateLimitConfig.maxMessagesPerSecond;
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
// src/hooks/useRoom.ts
|
|
1806
|
+
function useRoom(roomId, options) {
|
|
1807
|
+
const { strategy, iceServers, networkManager } = useMultiplayerContext();
|
|
1808
|
+
const [connectionState, setConnectionState] = (0, import_react4.useState)("disconnected");
|
|
1809
|
+
const [isHost, setIsHost] = (0, import_react4.useState)(false);
|
|
1810
|
+
const [hostId, setHostId] = (0, import_react4.useState)(null);
|
|
1811
|
+
const [selfId, setSelfId] = (0, import_react4.useState)(null);
|
|
1812
|
+
const [currentRoomId, setCurrentRoomId] = (0, import_react4.useState)(null);
|
|
1813
|
+
const [error, setError] = (0, import_react4.useState)(null);
|
|
1814
|
+
const [transport, setTransport] = (0, import_react4.useState)(null);
|
|
1815
|
+
const [room, setRoom] = (0, import_react4.useState)(null);
|
|
1816
|
+
const reconnectAttemptsRef = (0, import_react4.useRef)(0);
|
|
1817
|
+
const maxReconnectAttempts = options?.reconnectAttempts ?? 3;
|
|
1818
|
+
const optionsRef = (0, import_react4.useRef)(options);
|
|
1819
|
+
optionsRef.current = options;
|
|
1820
|
+
const createTransport = (0, import_react4.useCallback)(() => {
|
|
1821
|
+
const opt = optionsRef.current;
|
|
1822
|
+
if (opt?.transport && typeof opt.transport === "object" && "connect" in opt.transport) {
|
|
1823
|
+
return opt.transport;
|
|
1824
|
+
}
|
|
1825
|
+
const servers = opt?.iceServers ?? iceServers;
|
|
1826
|
+
const policy = opt?.privacy === "relay" ? "relay" : "all";
|
|
1827
|
+
return new WebRTCTransport(strategy, servers, policy);
|
|
1828
|
+
}, [strategy, iceServers]);
|
|
1829
|
+
const transportRef = (0, import_react4.useRef)(null);
|
|
1830
|
+
const doJoin = (0, import_react4.useCallback)(async (targetRoomId, joinOptions) => {
|
|
1831
|
+
if (transportRef.current) {
|
|
1832
|
+
transportRef.current.disconnect();
|
|
1833
|
+
transportRef.current = null;
|
|
1834
|
+
}
|
|
1835
|
+
const t = createTransport();
|
|
1836
|
+
transportRef.current = t;
|
|
1837
|
+
try {
|
|
1838
|
+
setError(null);
|
|
1839
|
+
setConnectionState("connecting");
|
|
1840
|
+
networkManager.setConnectionState("connecting");
|
|
1841
|
+
setTransport(t);
|
|
1842
|
+
networkManager.setTransport(t);
|
|
1843
|
+
t.onPeerJoin(() => {
|
|
1844
|
+
});
|
|
1845
|
+
t.onPeerLeave(() => {
|
|
1846
|
+
});
|
|
1847
|
+
t.onHostChanged((newHostId) => {
|
|
1848
|
+
setHostId(newHostId);
|
|
1849
|
+
setIsHost(t.peerId === newHostId);
|
|
1850
|
+
optionsRef.current?.onHostMigration?.(newHostId);
|
|
1851
|
+
});
|
|
1852
|
+
if ("onRoomUpdated" in t && typeof t.onRoomUpdated === "function") {
|
|
1853
|
+
t.onRoomUpdated((updatedRoom) => {
|
|
1854
|
+
networkManager.setRoom(updatedRoom);
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
await t.connect(targetRoomId, {
|
|
1858
|
+
displayName: optionsRef.current?.displayName,
|
|
1859
|
+
playerMetadata: optionsRef.current?.playerMetadata,
|
|
1860
|
+
password: joinOptions?.password ?? optionsRef.current?.password,
|
|
1861
|
+
iceServers: optionsRef.current?.iceServers,
|
|
1862
|
+
iceTransportPolicy: optionsRef.current?.privacy === "relay" ? "relay" : "all"
|
|
1863
|
+
});
|
|
1864
|
+
if (transportRef.current !== t) return;
|
|
1865
|
+
setCurrentRoomId(targetRoomId);
|
|
1866
|
+
setSelfId(t.peerId);
|
|
1867
|
+
setHostId(t.hostId);
|
|
1868
|
+
setIsHost(t.isHost);
|
|
1869
|
+
setConnectionState("connected");
|
|
1870
|
+
networkManager.setConnectionState("connected");
|
|
1871
|
+
if (t.room) {
|
|
1872
|
+
networkManager.setRoom(t.room);
|
|
1873
|
+
}
|
|
1874
|
+
if (t.initialPlayers) {
|
|
1875
|
+
const players = t.initialPlayers.map((p) => ({
|
|
1876
|
+
...p,
|
|
1877
|
+
isSelf: p.peerId === t.peerId
|
|
1878
|
+
}));
|
|
1879
|
+
networkManager.setPlayers(players);
|
|
1880
|
+
}
|
|
1881
|
+
reconnectAttemptsRef.current = 0;
|
|
1882
|
+
optionsRef.current?.onConnected?.();
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
if (transportRef.current !== t) return;
|
|
1885
|
+
const carverError = {
|
|
1886
|
+
code: "CONNECTION_FAILED",
|
|
1887
|
+
message: err instanceof Error ? err.message : "Connection failed",
|
|
1888
|
+
recoverable: reconnectAttemptsRef.current < maxReconnectAttempts
|
|
1889
|
+
};
|
|
1890
|
+
setError(carverError);
|
|
1891
|
+
networkManager.emitError(carverError);
|
|
1892
|
+
setConnectionState("disconnected");
|
|
1893
|
+
networkManager.setConnectionState("disconnected");
|
|
1894
|
+
optionsRef.current?.onError?.(carverError);
|
|
1895
|
+
}
|
|
1896
|
+
}, [createTransport, networkManager, maxReconnectAttempts]);
|
|
1897
|
+
const leave = (0, import_react4.useCallback)(() => {
|
|
1898
|
+
if (transportRef.current) {
|
|
1899
|
+
transportRef.current.disconnect();
|
|
1900
|
+
transportRef.current = null;
|
|
1901
|
+
}
|
|
1902
|
+
setTransport(null);
|
|
1903
|
+
setConnectionState("disconnected");
|
|
1904
|
+
setCurrentRoomId(null);
|
|
1905
|
+
setSelfId(null);
|
|
1906
|
+
setHostId(null);
|
|
1907
|
+
setIsHost(false);
|
|
1908
|
+
setError(null);
|
|
1909
|
+
networkManager.setConnectionState("disconnected");
|
|
1910
|
+
optionsRef.current?.onDisconnected?.("user_left");
|
|
1911
|
+
}, [networkManager]);
|
|
1912
|
+
const setReady = (0, import_react4.useCallback)((ready) => {
|
|
1913
|
+
transport?.setReady?.(ready);
|
|
1914
|
+
const selfPlayer = networkManager.players.get(transport?.peerId ?? "");
|
|
1915
|
+
if (selfPlayer) {
|
|
1916
|
+
networkManager.updatePlayer({ ...selfPlayer, isReady: ready });
|
|
1917
|
+
}
|
|
1918
|
+
}, [transport, networkManager]);
|
|
1919
|
+
const setMetadata = (0, import_react4.useCallback)((meta) => {
|
|
1920
|
+
transport?.setMetadata?.(meta);
|
|
1921
|
+
}, [transport]);
|
|
1922
|
+
const setRoomMetadata = (0, import_react4.useCallback)((meta) => {
|
|
1923
|
+
transport?.setRoomMetadata?.(meta);
|
|
1924
|
+
}, [transport]);
|
|
1925
|
+
(0, import_react4.useEffect)(() => {
|
|
1926
|
+
if (roomId) {
|
|
1927
|
+
doJoin(roomId);
|
|
1928
|
+
}
|
|
1929
|
+
return () => {
|
|
1930
|
+
if (transportRef.current) {
|
|
1931
|
+
transportRef.current.disconnect();
|
|
1932
|
+
transportRef.current = null;
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
}, [roomId, doJoin]);
|
|
1936
|
+
(0, import_react4.useEffect)(() => {
|
|
1937
|
+
const unsub = networkManager.onRoomChange(() => {
|
|
1938
|
+
setRoom(networkManager.room);
|
|
1939
|
+
});
|
|
1940
|
+
setRoom(networkManager.room);
|
|
1941
|
+
return unsub;
|
|
1942
|
+
}, [networkManager]);
|
|
1943
|
+
return {
|
|
1944
|
+
roomId: currentRoomId,
|
|
1945
|
+
connectionState,
|
|
1946
|
+
isHost,
|
|
1947
|
+
hostId,
|
|
1948
|
+
selfId,
|
|
1949
|
+
room,
|
|
1950
|
+
error,
|
|
1951
|
+
join: doJoin,
|
|
1952
|
+
leave,
|
|
1953
|
+
setReady,
|
|
1954
|
+
setMetadata,
|
|
1955
|
+
setRoomMetadata,
|
|
1956
|
+
transport
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// src/hooks/useLobby.ts
|
|
1961
|
+
var import_react5 = require("react");
|
|
1962
|
+
function announcementToRoom(ann) {
|
|
1963
|
+
return {
|
|
1964
|
+
id: ann.roomId,
|
|
1965
|
+
name: ann.name,
|
|
1966
|
+
hostId: ann.hostId,
|
|
1967
|
+
playerCount: ann.playerCount,
|
|
1968
|
+
maxPlayers: ann.maxPlayers,
|
|
1969
|
+
gameMode: ann.gameMode,
|
|
1970
|
+
isPrivate: ann.isPrivate,
|
|
1971
|
+
metadata: ann.metadata,
|
|
1972
|
+
createdAt: ann.createdAt,
|
|
1973
|
+
state: "lobby"
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
function useLobby(options) {
|
|
1977
|
+
const { strategy } = useMultiplayerContext();
|
|
1978
|
+
const [rooms, setRooms] = (0, import_react5.useState)([]);
|
|
1979
|
+
const [isLoading, setIsLoading] = (0, import_react5.useState)(true);
|
|
1980
|
+
const [error, setError] = (0, import_react5.useState)(null);
|
|
1981
|
+
const optionsRef = (0, import_react5.useRef)(options);
|
|
1982
|
+
optionsRef.current = options;
|
|
1983
|
+
const filterRooms = (0, import_react5.useCallback)((roomList) => {
|
|
1984
|
+
const filter = optionsRef.current?.filter;
|
|
1985
|
+
if (!filter) return roomList;
|
|
1986
|
+
return roomList.filter((room) => {
|
|
1987
|
+
if (filter.maxPlayers !== void 0 && room.maxPlayers > filter.maxPlayers) return false;
|
|
1988
|
+
if (filter.gameMode !== void 0 && room.gameMode !== filter.gameMode) return false;
|
|
1989
|
+
if (filter.hasPassword !== void 0 && room.isPrivate !== filter.hasPassword) return false;
|
|
1990
|
+
return true;
|
|
1991
|
+
});
|
|
1992
|
+
}, []);
|
|
1993
|
+
(0, import_react5.useEffect)(() => {
|
|
1994
|
+
setIsLoading(true);
|
|
1995
|
+
setError(null);
|
|
1996
|
+
const unsub = strategy.subscribeToLobby((announcements) => {
|
|
1997
|
+
const converted = announcements.map(announcementToRoom);
|
|
1998
|
+
setRooms(filterRooms(converted));
|
|
1999
|
+
setIsLoading(false);
|
|
2000
|
+
});
|
|
2001
|
+
const timeout = setTimeout(() => setIsLoading(false), 3e3);
|
|
2002
|
+
return () => {
|
|
2003
|
+
unsub();
|
|
2004
|
+
clearTimeout(timeout);
|
|
2005
|
+
};
|
|
2006
|
+
}, [strategy, filterRooms]);
|
|
2007
|
+
const refresh = (0, import_react5.useCallback)(() => {
|
|
2008
|
+
setIsLoading(true);
|
|
2009
|
+
setTimeout(() => setIsLoading(false), 1e3);
|
|
2010
|
+
}, []);
|
|
2011
|
+
const createRoom = (0, import_react5.useCallback)(async (config) => {
|
|
2012
|
+
const roomId = `${config.name.toLowerCase().replace(/\s+/g, "-")}-${Date.now().toString(36)}`;
|
|
2013
|
+
const announcement = {
|
|
2014
|
+
roomId,
|
|
2015
|
+
name: config.name,
|
|
2016
|
+
hostId: strategy.selfId,
|
|
2017
|
+
playerCount: 0,
|
|
2018
|
+
maxPlayers: config.maxPlayers ?? 8,
|
|
2019
|
+
gameMode: config.metadata?.gameMode,
|
|
2020
|
+
isPrivate: config.isPrivate ?? false,
|
|
2021
|
+
metadata: config.metadata ?? {},
|
|
2022
|
+
createdAt: Date.now(),
|
|
2023
|
+
lastSeen: Date.now()
|
|
2024
|
+
};
|
|
2025
|
+
strategy.announceRoom(announcement);
|
|
2026
|
+
return roomId;
|
|
2027
|
+
}, [strategy]);
|
|
2028
|
+
return {
|
|
2029
|
+
rooms,
|
|
2030
|
+
isLoading,
|
|
2031
|
+
error,
|
|
2032
|
+
refresh,
|
|
2033
|
+
createRoom
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// src/hooks/usePlayers.ts
|
|
2038
|
+
var import_react6 = require("react");
|
|
2039
|
+
function usePlayers() {
|
|
2040
|
+
const { networkManager } = useMultiplayerContext();
|
|
2041
|
+
const [players, setPlayers] = (0, import_react6.useState)([]);
|
|
2042
|
+
const [, setVersion] = (0, import_react6.useState)(0);
|
|
2043
|
+
(0, import_react6.useEffect)(() => {
|
|
2044
|
+
const unsubscribe = networkManager.onPlayersChange(() => {
|
|
2045
|
+
setPlayers(Array.from(networkManager.players.values()));
|
|
2046
|
+
setVersion((v) => v + 1);
|
|
2047
|
+
});
|
|
2048
|
+
setPlayers(Array.from(networkManager.players.values()));
|
|
2049
|
+
return unsubscribe;
|
|
2050
|
+
}, [networkManager]);
|
|
2051
|
+
const self = players.find((p) => p.isSelf) ?? null;
|
|
2052
|
+
const host = players.find((p) => p.isHost) ?? null;
|
|
2053
|
+
const allReady = players.length > 0 && players.every((p) => p.isReady);
|
|
2054
|
+
const getPlayer = (0, import_react6.useCallback)(
|
|
2055
|
+
(peerId) => players.find((p) => p.peerId === peerId),
|
|
2056
|
+
[players]
|
|
2057
|
+
);
|
|
2058
|
+
return {
|
|
2059
|
+
players,
|
|
2060
|
+
self,
|
|
2061
|
+
host,
|
|
2062
|
+
count: players.length,
|
|
2063
|
+
allReady,
|
|
2064
|
+
getPlayer
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// src/hooks/useHost.ts
|
|
2069
|
+
var import_react7 = require("react");
|
|
2070
|
+
function useHost() {
|
|
2071
|
+
const { networkManager } = useMultiplayerContext();
|
|
2072
|
+
const getTransport = (0, import_react7.useCallback)(() => {
|
|
2073
|
+
const transport = networkManager.transport;
|
|
2074
|
+
if (!transport || !networkManager.isHost) return null;
|
|
2075
|
+
return transport;
|
|
2076
|
+
}, [networkManager]);
|
|
2077
|
+
const kick = (0, import_react7.useCallback)((peerId, reason) => {
|
|
2078
|
+
getTransport()?.kick?.(peerId, reason);
|
|
2079
|
+
}, [getTransport]);
|
|
2080
|
+
const transferHost = (0, import_react7.useCallback)((peerId) => {
|
|
2081
|
+
getTransport()?.transferHost?.(peerId);
|
|
2082
|
+
}, [getTransport]);
|
|
2083
|
+
const setRoomState = (0, import_react7.useCallback)((state) => {
|
|
2084
|
+
getTransport()?.setRoomState?.(state);
|
|
2085
|
+
}, [getTransport]);
|
|
2086
|
+
const setMaxPlayers = (0, import_react7.useCallback)((n) => {
|
|
2087
|
+
getTransport()?.setMaxPlayers?.(n);
|
|
2088
|
+
}, [getTransport]);
|
|
2089
|
+
const lockRoom = (0, import_react7.useCallback)(() => {
|
|
2090
|
+
getTransport()?.lockRoom?.();
|
|
2091
|
+
}, [getTransport]);
|
|
2092
|
+
const unlockRoom = (0, import_react7.useCallback)(() => {
|
|
2093
|
+
getTransport()?.unlockRoom?.();
|
|
2094
|
+
}, [getTransport]);
|
|
2095
|
+
return {
|
|
2096
|
+
kick,
|
|
2097
|
+
transferHost,
|
|
2098
|
+
setRoomState,
|
|
2099
|
+
setMaxPlayers,
|
|
2100
|
+
lockRoom,
|
|
2101
|
+
unlockRoom
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/hooks/useMultiplayer.ts
|
|
2106
|
+
var import_react8 = require("react");
|
|
2107
|
+
var import_fiber = require("@react-three/fiber");
|
|
2108
|
+
var import_systems = require("@carverjs/core/systems");
|
|
2109
|
+
|
|
2110
|
+
// src/sync/EventSync.ts
|
|
2111
|
+
var EventSync = class {
|
|
2112
|
+
constructor(transport, options) {
|
|
2113
|
+
this._handlers = /* @__PURE__ */ new Map();
|
|
2114
|
+
this._transport = transport;
|
|
2115
|
+
this._hostValidation = options?.hostValidation ?? false;
|
|
2116
|
+
this._channel = transport.createChannel("carver:events", {
|
|
2117
|
+
reliable: true,
|
|
2118
|
+
ordered: true
|
|
2119
|
+
});
|
|
2120
|
+
this._channel.onReceive((rawData, peerId) => {
|
|
2121
|
+
try {
|
|
2122
|
+
const packet = typeof rawData === "string" ? JSON.parse(rawData) : rawData;
|
|
2123
|
+
if (this._hostValidation && this._transport.isHost && packet.sender !== this._transport.peerId) {
|
|
2124
|
+
const targets = Array.from(this._transport.peers).filter((p) => p !== peerId);
|
|
2125
|
+
if (targets.length > 0) {
|
|
2126
|
+
this._channel.send(JSON.stringify(packet), targets);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
if (this._hostValidation && !this._transport.isHost && peerId !== this._transport.hostId) {
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
const handlers = this._handlers.get(packet.type);
|
|
2133
|
+
if (handlers) {
|
|
2134
|
+
for (const handler of handlers) {
|
|
2135
|
+
handler(packet.payload, packet.sender);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
} catch {
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Send a typed event to a specific peer or all peers.
|
|
2144
|
+
*/
|
|
2145
|
+
sendEvent(type, payload, target) {
|
|
2146
|
+
const packet = {
|
|
2147
|
+
type,
|
|
2148
|
+
payload,
|
|
2149
|
+
sender: this._transport.peerId,
|
|
2150
|
+
target
|
|
2151
|
+
};
|
|
2152
|
+
const serialized = JSON.stringify(packet);
|
|
2153
|
+
if (this._hostValidation && !this._transport.isHost) {
|
|
2154
|
+
this._channel.send(serialized, this._transport.hostId);
|
|
2155
|
+
} else if (target) {
|
|
2156
|
+
this._channel.send(serialized, target);
|
|
2157
|
+
} else {
|
|
2158
|
+
this._channel.send(serialized);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Broadcast a typed event to all connected peers.
|
|
2163
|
+
*/
|
|
2164
|
+
broadcast(type, payload) {
|
|
2165
|
+
this.sendEvent(type, payload);
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Register a handler for a specific event type.
|
|
2169
|
+
* Returns an unsubscribe function.
|
|
2170
|
+
*/
|
|
2171
|
+
onEvent(type, callback) {
|
|
2172
|
+
let handlers = this._handlers.get(type);
|
|
2173
|
+
if (!handlers) {
|
|
2174
|
+
handlers = [];
|
|
2175
|
+
this._handlers.set(type, handlers);
|
|
2176
|
+
}
|
|
2177
|
+
handlers.push(callback);
|
|
2178
|
+
return () => {
|
|
2179
|
+
const arr = this._handlers.get(type);
|
|
2180
|
+
if (arr) {
|
|
2181
|
+
const idx = arr.indexOf(callback);
|
|
2182
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
2183
|
+
if (arr.length === 0) this._handlers.delete(type);
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Clean up the event channel.
|
|
2189
|
+
*/
|
|
2190
|
+
destroy() {
|
|
2191
|
+
this._channel.close();
|
|
2192
|
+
this._handlers.clear();
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
// src/core/HostAuthority.ts
|
|
2197
|
+
var HostAuthority = class {
|
|
2198
|
+
constructor(transport, codec, snapshotBuffer, options) {
|
|
2199
|
+
this._tick = 0;
|
|
2200
|
+
this._broadcastAccumulator = 0;
|
|
2201
|
+
// Per-client last ACK'd tick for delta compression
|
|
2202
|
+
this._clientBaselines = /* @__PURE__ */ new Map();
|
|
2203
|
+
// Per-client last keyframe tick for scheduling
|
|
2204
|
+
this._clientLastKeyframeTick = /* @__PURE__ */ new Map();
|
|
2205
|
+
// Interest management callback (optional)
|
|
2206
|
+
this._interestFilter = null;
|
|
2207
|
+
this._transport = transport;
|
|
2208
|
+
this._codec = codec;
|
|
2209
|
+
this._snapshotBuffer = snapshotBuffer;
|
|
2210
|
+
this._broadcastRate = options?.broadcastRate ?? 20;
|
|
2211
|
+
this._keyframeInterval = options?.keyframeInterval ?? 300;
|
|
2212
|
+
this._snapshotChannel = transport.createChannel(
|
|
2213
|
+
"carver:snapshots",
|
|
2214
|
+
{
|
|
2215
|
+
reliable: false,
|
|
2216
|
+
ordered: false,
|
|
2217
|
+
maxRetransmits: 0
|
|
2218
|
+
}
|
|
2219
|
+
);
|
|
2220
|
+
this._ackChannel = transport.createChannel("carver:acks", {
|
|
2221
|
+
reliable: true,
|
|
2222
|
+
ordered: true
|
|
2223
|
+
});
|
|
2224
|
+
this._ackChannel.onReceive((data, peerId) => {
|
|
2225
|
+
try {
|
|
2226
|
+
const ackTick = typeof data === "string" ? parseInt(data, 10) : data;
|
|
2227
|
+
if (ackTick === -1) {
|
|
2228
|
+
this._clientBaselines.delete(peerId);
|
|
2229
|
+
} else {
|
|
2230
|
+
this._clientBaselines.set(peerId, ackTick);
|
|
2231
|
+
}
|
|
2232
|
+
} catch {
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
transport.onPeerJoin((peerId) => {
|
|
2236
|
+
this._clientBaselines.delete(peerId);
|
|
2237
|
+
});
|
|
2238
|
+
transport.onPeerLeave((peerId) => {
|
|
2239
|
+
this._clientBaselines.delete(peerId);
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
/** Set optional interest management filter */
|
|
2243
|
+
setInterestFilter(filter) {
|
|
2244
|
+
this._interestFilter = filter;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Called every fixed tick by the sync engine.
|
|
2248
|
+
* Collects entity states and decides whether to broadcast.
|
|
2249
|
+
*/
|
|
2250
|
+
tick(currentTick, entities, delta) {
|
|
2251
|
+
this._tick = currentTick;
|
|
2252
|
+
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2253
|
+
this._broadcastAccumulator += delta;
|
|
2254
|
+
const broadcastInterval = 1 / this._broadcastRate;
|
|
2255
|
+
if (this._broadcastAccumulator < broadcastInterval) return;
|
|
2256
|
+
this._broadcastAccumulator -= broadcastInterval;
|
|
2257
|
+
for (const peerId of this._transport.peers) {
|
|
2258
|
+
this._broadcastToClient(peerId, currentTick, entities);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
/** Force a keyframe broadcast to all clients (e.g., after host migration) */
|
|
2262
|
+
forceKeyframe(currentTick, entities) {
|
|
2263
|
+
this._clientBaselines.clear();
|
|
2264
|
+
this._clientLastKeyframeTick.clear();
|
|
2265
|
+
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2266
|
+
for (const peerId of this._transport.peers) {
|
|
2267
|
+
this._broadcastToClient(peerId, currentTick, entities);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
destroy() {
|
|
2271
|
+
this._snapshotChannel.close();
|
|
2272
|
+
this._ackChannel.close();
|
|
2273
|
+
this._clientBaselines.clear();
|
|
2274
|
+
this._clientLastKeyframeTick.clear();
|
|
2275
|
+
}
|
|
2276
|
+
_broadcastToClient(peerId, currentTick, entities) {
|
|
2277
|
+
let clientEntities = entities;
|
|
2278
|
+
if (this._interestFilter) {
|
|
2279
|
+
clientEntities = /* @__PURE__ */ new Map();
|
|
2280
|
+
for (const [id, entity] of entities) {
|
|
2281
|
+
if (this._interestFilter(id, peerId)) {
|
|
2282
|
+
clientEntities.set(id, entity);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
const clientBaseTick = this._clientBaselines.get(peerId);
|
|
2287
|
+
const clientLastKeyframe = this._clientLastKeyframeTick.get(peerId) ?? 0;
|
|
2288
|
+
const needsKeyframe = clientBaseTick === void 0 || currentTick - clientLastKeyframe >= this._keyframeInterval;
|
|
2289
|
+
let baseline;
|
|
2290
|
+
if (!needsKeyframe && clientBaseTick !== void 0) {
|
|
2291
|
+
baseline = this._snapshotBuffer.get(clientBaseTick);
|
|
2292
|
+
}
|
|
2293
|
+
if (needsKeyframe) {
|
|
2294
|
+
this._clientLastKeyframeTick.set(peerId, currentTick);
|
|
2295
|
+
}
|
|
2296
|
+
const packet = this._codec.serializeDelta(
|
|
2297
|
+
currentTick,
|
|
2298
|
+
needsKeyframe ? -1 : clientBaseTick ?? -1,
|
|
2299
|
+
clientEntities,
|
|
2300
|
+
baseline
|
|
2301
|
+
);
|
|
2302
|
+
if (packet) {
|
|
2303
|
+
this._snapshotChannel.send(packet, peerId);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
// src/core/ClientReceiver.ts
|
|
2309
|
+
var ClientReceiver = class {
|
|
2310
|
+
constructor(transport, codec, options) {
|
|
2311
|
+
// Snapshot buffer (ring buffer of last N snapshots)
|
|
2312
|
+
this._buffer = [];
|
|
2313
|
+
// Current interpolated state
|
|
2314
|
+
this._interpolatedState = /* @__PURE__ */ new Map();
|
|
2315
|
+
// Network quality tracking
|
|
2316
|
+
this._lastSnapshotTime = 0;
|
|
2317
|
+
this._networkQuality = "good";
|
|
2318
|
+
this._packetLossCount = 0;
|
|
2319
|
+
this._packetCount = 0;
|
|
2320
|
+
// Entity state: full accumulated state from keyframes + deltas
|
|
2321
|
+
this._fullState = /* @__PURE__ */ new Map();
|
|
2322
|
+
this._transport = transport;
|
|
2323
|
+
this._codec = codec;
|
|
2324
|
+
this._bufferSize = options?.bufferSize ?? 3;
|
|
2325
|
+
this._method = options?.method ?? "hermite";
|
|
2326
|
+
this._extrapolateMs = options?.extrapolateMs ?? 250;
|
|
2327
|
+
this._is2D = options?.is2D ?? false;
|
|
2328
|
+
this._snapshotChannel = transport.createChannel(
|
|
2329
|
+
"carver:snapshots",
|
|
2330
|
+
{
|
|
2331
|
+
reliable: false,
|
|
2332
|
+
ordered: false,
|
|
2333
|
+
maxRetransmits: 0
|
|
2334
|
+
}
|
|
2335
|
+
);
|
|
2336
|
+
this._ackChannel = transport.createChannel("carver:acks", {
|
|
2337
|
+
reliable: true,
|
|
2338
|
+
ordered: true
|
|
2339
|
+
});
|
|
2340
|
+
this._snapshotChannel.onReceive((data) => {
|
|
2341
|
+
this._handleSnapshot(data);
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
/** Get the current interpolated entity states */
|
|
2345
|
+
get state() {
|
|
2346
|
+
return this._interpolatedState;
|
|
2347
|
+
}
|
|
2348
|
+
get networkQuality() {
|
|
2349
|
+
return this._networkQuality;
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Called every render frame to interpolate between buffered snapshots.
|
|
2353
|
+
* @param renderTime - current render time in ms
|
|
2354
|
+
*/
|
|
2355
|
+
interpolate(renderTime) {
|
|
2356
|
+
if (this._buffer.length < 2) {
|
|
2357
|
+
return this._buffer.length > 0 ? this._buffer[this._buffer.length - 1].entities : this._interpolatedState;
|
|
2358
|
+
}
|
|
2359
|
+
const interpDelay = (this._bufferSize - 1) * (1e3 / 20);
|
|
2360
|
+
const targetTime = renderTime - interpDelay;
|
|
2361
|
+
let from = null;
|
|
2362
|
+
let to = null;
|
|
2363
|
+
for (let i = 0; i < this._buffer.length - 1; i++) {
|
|
2364
|
+
if (this._buffer[i].receivedAt <= targetTime && this._buffer[i + 1].receivedAt > targetTime) {
|
|
2365
|
+
from = this._buffer[i];
|
|
2366
|
+
to = this._buffer[i + 1];
|
|
2367
|
+
break;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
if (!from || !to) {
|
|
2371
|
+
const latest = this._buffer[this._buffer.length - 1];
|
|
2372
|
+
const timeSinceLatest = renderTime - latest.receivedAt;
|
|
2373
|
+
if (timeSinceLatest > this._extrapolateMs) {
|
|
2374
|
+
this._updateNetworkQuality("poor");
|
|
2375
|
+
return latest.entities;
|
|
2376
|
+
}
|
|
2377
|
+
if (this._buffer.length >= 2) {
|
|
2378
|
+
from = this._buffer[this._buffer.length - 2];
|
|
2379
|
+
to = latest;
|
|
2380
|
+
this._updateNetworkQuality("degraded");
|
|
2381
|
+
} else {
|
|
2382
|
+
return latest.entities;
|
|
2383
|
+
}
|
|
2384
|
+
} else {
|
|
2385
|
+
this._updateNetworkQuality("good");
|
|
2386
|
+
}
|
|
2387
|
+
const range = to.receivedAt - from.receivedAt;
|
|
2388
|
+
const t = range > 0 ? Math.min(1, Math.max(0, (targetTime - from.receivedAt) / range)) : 1;
|
|
2389
|
+
const result = /* @__PURE__ */ new Map();
|
|
2390
|
+
const allIds = /* @__PURE__ */ new Set([...from.entities.keys(), ...to.entities.keys()]);
|
|
2391
|
+
for (const id of allIds) {
|
|
2392
|
+
const fromEntity = from.entities.get(id);
|
|
2393
|
+
const toEntity = to.entities.get(id);
|
|
2394
|
+
if (toEntity && toEntity.c?.__removed) continue;
|
|
2395
|
+
if (fromEntity && toEntity) {
|
|
2396
|
+
result.set(id, this._interpolateEntity(fromEntity, toEntity, t));
|
|
2397
|
+
} else if (toEntity) {
|
|
2398
|
+
result.set(id, toEntity);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
this._interpolatedState = result;
|
|
2402
|
+
return result;
|
|
2403
|
+
}
|
|
2404
|
+
/** Request a keyframe from the host */
|
|
2405
|
+
requestKeyframe() {
|
|
2406
|
+
this._ackChannel.send("-1");
|
|
2407
|
+
}
|
|
2408
|
+
destroy() {
|
|
2409
|
+
this._snapshotChannel.close();
|
|
2410
|
+
this._ackChannel.close();
|
|
2411
|
+
this._buffer = [];
|
|
2412
|
+
this._interpolatedState.clear();
|
|
2413
|
+
this._fullState.clear();
|
|
2414
|
+
}
|
|
2415
|
+
_handleSnapshot(data) {
|
|
2416
|
+
try {
|
|
2417
|
+
const { tick, baseTick, entities } = this._codec.deserializePacket(data);
|
|
2418
|
+
const now = performance.now();
|
|
2419
|
+
if (baseTick === -1) {
|
|
2420
|
+
this._fullState.clear();
|
|
2421
|
+
for (const entity of entities) {
|
|
2422
|
+
this._fullState.set(entity.id, entity);
|
|
2423
|
+
}
|
|
2424
|
+
} else {
|
|
2425
|
+
for (const entity of entities) {
|
|
2426
|
+
if (entity.c?.__removed) {
|
|
2427
|
+
this._fullState.delete(entity.id);
|
|
2428
|
+
} else {
|
|
2429
|
+
this._fullState.set(entity.id, entity);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
this._buffer.push({
|
|
2434
|
+
tick,
|
|
2435
|
+
entities: new Map(this._fullState),
|
|
2436
|
+
receivedAt: now
|
|
2437
|
+
});
|
|
2438
|
+
while (this._buffer.length > this._bufferSize * 2) {
|
|
2439
|
+
this._buffer.shift();
|
|
2440
|
+
}
|
|
2441
|
+
this._ackChannel.send(String(tick));
|
|
2442
|
+
this._lastSnapshotTime = now;
|
|
2443
|
+
this._packetCount++;
|
|
2444
|
+
} catch {
|
|
2445
|
+
this._packetLossCount++;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
_interpolateEntity(from, to, t) {
|
|
2449
|
+
if (this._is2D || !("z" in from)) {
|
|
2450
|
+
return this._interpolateEntity2D(
|
|
2451
|
+
from,
|
|
2452
|
+
to,
|
|
2453
|
+
t
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
return this._interpolateEntity3D(
|
|
2457
|
+
from,
|
|
2458
|
+
to,
|
|
2459
|
+
t
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
_interpolateEntity2D(from, to, t) {
|
|
2463
|
+
if (this._method === "hermite") {
|
|
2464
|
+
return {
|
|
2465
|
+
id: to.id,
|
|
2466
|
+
x: hermite(from.x, from.vx, to.x, to.vx, t),
|
|
2467
|
+
y: hermite(from.y, from.vy, to.y, to.vy, t),
|
|
2468
|
+
a: lerpAngle(from.a, to.a, t),
|
|
2469
|
+
vx: lerp(from.vx, to.vx, t),
|
|
2470
|
+
vy: lerp(from.vy, to.vy, t),
|
|
2471
|
+
va: lerp(from.va, to.va, t),
|
|
2472
|
+
c: interpolateCustom(from.c, to.c, t)
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
return {
|
|
2476
|
+
id: to.id,
|
|
2477
|
+
x: lerp(from.x, to.x, t),
|
|
2478
|
+
y: lerp(from.y, to.y, t),
|
|
2479
|
+
a: lerpAngle(from.a, to.a, t),
|
|
2480
|
+
vx: lerp(from.vx, to.vx, t),
|
|
2481
|
+
vy: lerp(from.vy, to.vy, t),
|
|
2482
|
+
va: lerp(from.va, to.va, t),
|
|
2483
|
+
c: interpolateCustom(from.c, to.c, t)
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
_interpolateEntity3D(from, to, t) {
|
|
2487
|
+
const [qx, qy, qz, qw] = slerp(
|
|
2488
|
+
from.qx,
|
|
2489
|
+
from.qy,
|
|
2490
|
+
from.qz,
|
|
2491
|
+
from.qw,
|
|
2492
|
+
to.qx,
|
|
2493
|
+
to.qy,
|
|
2494
|
+
to.qz,
|
|
2495
|
+
to.qw,
|
|
2496
|
+
t
|
|
2497
|
+
);
|
|
2498
|
+
if (this._method === "hermite") {
|
|
2499
|
+
return {
|
|
2500
|
+
id: to.id,
|
|
2501
|
+
x: hermite(from.x, from.vx, to.x, to.vx, t),
|
|
2502
|
+
y: hermite(from.y, from.vy, to.y, to.vy, t),
|
|
2503
|
+
z: hermite(from.z, from.vz, to.z, to.vz, t),
|
|
2504
|
+
qx,
|
|
2505
|
+
qy,
|
|
2506
|
+
qz,
|
|
2507
|
+
qw,
|
|
2508
|
+
vx: lerp(from.vx, to.vx, t),
|
|
2509
|
+
vy: lerp(from.vy, to.vy, t),
|
|
2510
|
+
vz: lerp(from.vz, to.vz, t),
|
|
2511
|
+
wx: lerp(from.wx, to.wx, t),
|
|
2512
|
+
wy: lerp(from.wy, to.wy, t),
|
|
2513
|
+
wz: lerp(from.wz, to.wz, t),
|
|
2514
|
+
c: interpolateCustom(from.c, to.c, t)
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
id: to.id,
|
|
2519
|
+
x: lerp(from.x, to.x, t),
|
|
2520
|
+
y: lerp(from.y, to.y, t),
|
|
2521
|
+
z: lerp(from.z, to.z, t),
|
|
2522
|
+
qx,
|
|
2523
|
+
qy,
|
|
2524
|
+
qz,
|
|
2525
|
+
qw,
|
|
2526
|
+
vx: lerp(from.vx, to.vx, t),
|
|
2527
|
+
vy: lerp(from.vy, to.vy, t),
|
|
2528
|
+
vz: lerp(from.vz, to.vz, t),
|
|
2529
|
+
wx: lerp(from.wx, to.wx, t),
|
|
2530
|
+
wy: lerp(from.wy, to.wy, t),
|
|
2531
|
+
wz: lerp(from.wz, to.wz, t),
|
|
2532
|
+
c: interpolateCustom(from.c, to.c, t)
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
_updateNetworkQuality(quality) {
|
|
2536
|
+
this._networkQuality = quality;
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
function lerp(a, b, t) {
|
|
2540
|
+
return a + (b - a) * t;
|
|
2541
|
+
}
|
|
2542
|
+
function lerpAngle(a, b, t) {
|
|
2543
|
+
let diff = b - a;
|
|
2544
|
+
while (diff > Math.PI) diff -= Math.PI * 2;
|
|
2545
|
+
while (diff < -Math.PI) diff += Math.PI * 2;
|
|
2546
|
+
return a + diff * t;
|
|
2547
|
+
}
|
|
2548
|
+
function hermite(p0, v0, p1, v1, t) {
|
|
2549
|
+
const t2 = t * t;
|
|
2550
|
+
const t3 = t2 * t;
|
|
2551
|
+
return (2 * t3 - 3 * t2 + 1) * p0 + (t3 - 2 * t2 + t) * v0 + (-2 * t3 + 3 * t2) * p1 + (t3 - t2) * v1;
|
|
2552
|
+
}
|
|
2553
|
+
function slerp(ax, ay, az, aw, bx, by, bz, bw, t) {
|
|
2554
|
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
|
2555
|
+
if (dot < 0) {
|
|
2556
|
+
bx = -bx;
|
|
2557
|
+
by = -by;
|
|
2558
|
+
bz = -bz;
|
|
2559
|
+
bw = -bw;
|
|
2560
|
+
dot = -dot;
|
|
2561
|
+
}
|
|
2562
|
+
if (dot > 0.9995) {
|
|
2563
|
+
return [lerp(ax, bx, t), lerp(ay, by, t), lerp(az, bz, t), lerp(aw, bw, t)];
|
|
2564
|
+
}
|
|
2565
|
+
const theta = Math.acos(Math.min(1, Math.max(-1, dot)));
|
|
2566
|
+
const sinTheta = Math.sin(theta);
|
|
2567
|
+
const wa = Math.sin((1 - t) * theta) / sinTheta;
|
|
2568
|
+
const wb = Math.sin(t * theta) / sinTheta;
|
|
2569
|
+
return [
|
|
2570
|
+
ax * wa + bx * wb,
|
|
2571
|
+
ay * wa + by * wb,
|
|
2572
|
+
az * wa + bz * wb,
|
|
2573
|
+
aw * wa + bw * wb
|
|
2574
|
+
];
|
|
2575
|
+
}
|
|
2576
|
+
function interpolateCustom(from, to, t) {
|
|
2577
|
+
if (!from && !to) return void 0;
|
|
2578
|
+
if (!from) return to;
|
|
2579
|
+
if (!to) return from;
|
|
2580
|
+
const result = {};
|
|
2581
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(from), ...Object.keys(to)]);
|
|
2582
|
+
for (const key of allKeys) {
|
|
2583
|
+
const fromVal = from[key];
|
|
2584
|
+
const toVal = to[key];
|
|
2585
|
+
if (typeof fromVal === "number" && typeof toVal === "number") {
|
|
2586
|
+
result[key] = lerp(fromVal, toVal, t);
|
|
2587
|
+
} else {
|
|
2588
|
+
result[key] = t >= 0.5 ? toVal : fromVal;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
return result;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// src/sync/SnapshotSync.ts
|
|
2595
|
+
var SnapshotSync = class {
|
|
2596
|
+
constructor(transport, codec, snapshotBuffer, options) {
|
|
2597
|
+
this._hostAuthority = null;
|
|
2598
|
+
this._clientReceiver = null;
|
|
2599
|
+
this._transport = transport;
|
|
2600
|
+
this._codec = codec;
|
|
2601
|
+
this._snapshotBuffer = snapshotBuffer;
|
|
2602
|
+
if (transport.isHost) {
|
|
2603
|
+
this._hostAuthority = new HostAuthority(
|
|
2604
|
+
transport,
|
|
2605
|
+
codec,
|
|
2606
|
+
snapshotBuffer,
|
|
2607
|
+
{
|
|
2608
|
+
broadcastRate: options?.broadcastRate,
|
|
2609
|
+
keyframeInterval: options?.keyframeInterval
|
|
2610
|
+
}
|
|
2611
|
+
);
|
|
2612
|
+
} else {
|
|
2613
|
+
this._clientReceiver = new ClientReceiver(transport, codec, {
|
|
2614
|
+
bufferSize: options?.bufferSize,
|
|
2615
|
+
method: options?.interpolationMethod,
|
|
2616
|
+
extrapolateMs: options?.extrapolateMs,
|
|
2617
|
+
is2D: options?.is2D
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
get isHost() {
|
|
2622
|
+
return this._hostAuthority !== null;
|
|
2623
|
+
}
|
|
2624
|
+
get hostAuthority() {
|
|
2625
|
+
return this._hostAuthority;
|
|
2626
|
+
}
|
|
2627
|
+
get clientReceiver() {
|
|
2628
|
+
return this._clientReceiver;
|
|
2629
|
+
}
|
|
2630
|
+
/** Host: called every fixed tick to potentially broadcast state */
|
|
2631
|
+
hostTick(tick, entities, delta) {
|
|
2632
|
+
this._hostAuthority?.tick(tick, entities, delta);
|
|
2633
|
+
}
|
|
2634
|
+
/** Client: called every render frame to interpolate */
|
|
2635
|
+
clientInterpolate(renderTime) {
|
|
2636
|
+
return this._clientReceiver?.interpolate(renderTime) ?? /* @__PURE__ */ new Map();
|
|
2637
|
+
}
|
|
2638
|
+
/** Set interest filter on host authority */
|
|
2639
|
+
setInterestFilter(filter) {
|
|
2640
|
+
this._hostAuthority?.setInterestFilter(filter);
|
|
2641
|
+
}
|
|
2642
|
+
/** Handle host migration: switch from client to host mode */
|
|
2643
|
+
promoteToHost(options) {
|
|
2644
|
+
this._clientReceiver?.destroy();
|
|
2645
|
+
this._clientReceiver = null;
|
|
2646
|
+
this._hostAuthority = new HostAuthority(
|
|
2647
|
+
this._transport,
|
|
2648
|
+
this._codec,
|
|
2649
|
+
this._snapshotBuffer,
|
|
2650
|
+
{
|
|
2651
|
+
broadcastRate: options?.broadcastRate,
|
|
2652
|
+
keyframeInterval: options?.keyframeInterval
|
|
2653
|
+
}
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
/** Handle host migration: switch from host to client mode */
|
|
2657
|
+
demoteToClient(options) {
|
|
2658
|
+
this._hostAuthority?.destroy();
|
|
2659
|
+
this._hostAuthority = null;
|
|
2660
|
+
this._clientReceiver = new ClientReceiver(
|
|
2661
|
+
this._transport,
|
|
2662
|
+
this._codec,
|
|
2663
|
+
{
|
|
2664
|
+
bufferSize: options?.bufferSize,
|
|
2665
|
+
method: options?.interpolationMethod,
|
|
2666
|
+
extrapolateMs: options?.extrapolateMs,
|
|
2667
|
+
is2D: options?.is2D
|
|
2668
|
+
}
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
destroy() {
|
|
2672
|
+
this._hostAuthority?.destroy();
|
|
2673
|
+
this._clientReceiver?.destroy();
|
|
2674
|
+
this._hostAuthority = null;
|
|
2675
|
+
this._clientReceiver = null;
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2678
|
+
|
|
2679
|
+
// src/sync/PredictionSync.ts
|
|
2680
|
+
var import_msgpackr2 = require("msgpackr");
|
|
2681
|
+
var DEFAULT_OPTIONS = {
|
|
2682
|
+
maxRewindTicks: 15,
|
|
2683
|
+
errorSmoothingDecay: 0.85,
|
|
2684
|
+
maxErrorPerFrame: 5,
|
|
2685
|
+
snapThreshold: 15,
|
|
2686
|
+
lagCompensation: false
|
|
2687
|
+
};
|
|
2688
|
+
var PredictionSync = class {
|
|
2689
|
+
constructor(transport, codec, tickKeeper, options) {
|
|
2690
|
+
// Input buffer: ring buffer of recent inputs keyed by tick
|
|
2691
|
+
this._inputBuffer = [];
|
|
2692
|
+
// Per-client last processed input tick (host-side tracking)
|
|
2693
|
+
this._clientLastProcessedTick = /* @__PURE__ */ new Map();
|
|
2694
|
+
// Predicted state (client-side)
|
|
2695
|
+
this._predictedState = /* @__PURE__ */ new Map();
|
|
2696
|
+
// Error correction vectors per entity
|
|
2697
|
+
this._errorCorrections = /* @__PURE__ */ new Map();
|
|
2698
|
+
// Server state (last received authoritative snapshot)
|
|
2699
|
+
this._serverState = /* @__PURE__ */ new Map();
|
|
2700
|
+
this._serverTick = 0;
|
|
2701
|
+
// Physics step callback (provided by developer)
|
|
2702
|
+
this._onPhysicsStep = null;
|
|
2703
|
+
// Own input for current tick
|
|
2704
|
+
this._currentInput = null;
|
|
2705
|
+
this._transport = transport;
|
|
2706
|
+
this._codec = codec;
|
|
2707
|
+
this._tickKeeper = tickKeeper;
|
|
2708
|
+
this._options = { ...DEFAULT_OPTIONS, ...options };
|
|
2709
|
+
this._isHost = transport.isHost;
|
|
2710
|
+
this._inputChannel = transport.createChannel("carver:inputs", {
|
|
2711
|
+
reliable: true,
|
|
2712
|
+
ordered: true
|
|
2713
|
+
});
|
|
2714
|
+
this._stateChannel = transport.createChannel("carver:pred-state", {
|
|
2715
|
+
reliable: false,
|
|
2716
|
+
ordered: false,
|
|
2717
|
+
maxRetransmits: 0
|
|
2718
|
+
});
|
|
2719
|
+
this._ackChannel = transport.createChannel("carver:pred-acks", {
|
|
2720
|
+
reliable: true,
|
|
2721
|
+
ordered: true
|
|
2722
|
+
});
|
|
2723
|
+
if (this._isHost) {
|
|
2724
|
+
this._setupHostListeners();
|
|
2725
|
+
} else {
|
|
2726
|
+
this._setupClientListeners();
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
/** Set the physics step callback (required for rollback re-simulation) */
|
|
2730
|
+
setPhysicsStep(cb) {
|
|
2731
|
+
this._onPhysicsStep = cb;
|
|
2732
|
+
}
|
|
2733
|
+
/** Set the current input for this tick (client-side) */
|
|
2734
|
+
setInput(input) {
|
|
2735
|
+
this._currentInput = input;
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Called every fixed tick on the client.
|
|
2739
|
+
* Applies input locally (prediction), buffers it, and sends to host.
|
|
2740
|
+
*/
|
|
2741
|
+
clientTick(tick) {
|
|
2742
|
+
if (this._isHost) return;
|
|
2743
|
+
if (this._currentInput !== null) {
|
|
2744
|
+
this._inputBuffer.push({ tick, input: this._currentInput });
|
|
2745
|
+
const packet = {
|
|
2746
|
+
t: tick,
|
|
2747
|
+
i: this._currentInput,
|
|
2748
|
+
p: this._transport.peerId
|
|
2749
|
+
};
|
|
2750
|
+
this._inputChannel.send(JSON.stringify(packet));
|
|
2751
|
+
if (this._onPhysicsStep) {
|
|
2752
|
+
const inputs = /* @__PURE__ */ new Map();
|
|
2753
|
+
inputs.set(this._transport.peerId, this._currentInput);
|
|
2754
|
+
this._onPhysicsStep(inputs, tick, false);
|
|
2755
|
+
}
|
|
2756
|
+
this._currentInput = null;
|
|
2757
|
+
}
|
|
2758
|
+
const minTick = tick - this._options.maxRewindTicks * 2;
|
|
2759
|
+
while (this._inputBuffer.length > 0 && this._inputBuffer[0].tick < minTick) {
|
|
2760
|
+
this._inputBuffer.shift();
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Called every fixed tick on the host.
|
|
2765
|
+
* Processes received inputs and broadcasts authoritative state.
|
|
2766
|
+
*/
|
|
2767
|
+
hostTick(tick, entities, _delta) {
|
|
2768
|
+
if (!this._isHost) return;
|
|
2769
|
+
const stateArray = Array.from(entities.values());
|
|
2770
|
+
const data = this._codec.serialize(stateArray);
|
|
2771
|
+
for (const peerId of this._transport.peers) {
|
|
2772
|
+
const lastTick = this._clientLastProcessedTick.get(peerId) ?? -1;
|
|
2773
|
+
const packet = {
|
|
2774
|
+
t: tick,
|
|
2775
|
+
s: data,
|
|
2776
|
+
li: lastTick
|
|
2777
|
+
};
|
|
2778
|
+
this._stateChannel.send((0, import_msgpackr2.pack)(packet), peerId);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Called every render frame on the client to apply visual error smoothing.
|
|
2783
|
+
* Returns the corrected entity states.
|
|
2784
|
+
*/
|
|
2785
|
+
applyErrorSmoothing(entities) {
|
|
2786
|
+
const result = /* @__PURE__ */ new Map();
|
|
2787
|
+
const decay = this._options.errorSmoothingDecay;
|
|
2788
|
+
for (const [id, entity] of entities) {
|
|
2789
|
+
const correction = this._errorCorrections.get(id);
|
|
2790
|
+
if (!correction) {
|
|
2791
|
+
result.set(id, entity);
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
const corrected = { ...entity };
|
|
2795
|
+
corrected.x += correction.x;
|
|
2796
|
+
corrected.y += correction.y;
|
|
2797
|
+
if ("z" in corrected) {
|
|
2798
|
+
corrected.z += correction.z;
|
|
2799
|
+
}
|
|
2800
|
+
correction.x *= decay;
|
|
2801
|
+
correction.y *= decay;
|
|
2802
|
+
correction.z *= decay;
|
|
2803
|
+
const mag = Math.abs(correction.x) + Math.abs(correction.y) + Math.abs(correction.z);
|
|
2804
|
+
if (mag < 1e-3) {
|
|
2805
|
+
this._errorCorrections.delete(id);
|
|
2806
|
+
}
|
|
2807
|
+
result.set(id, corrected);
|
|
2808
|
+
}
|
|
2809
|
+
return result;
|
|
2810
|
+
}
|
|
2811
|
+
get predictedState() {
|
|
2812
|
+
return this._predictedState;
|
|
2813
|
+
}
|
|
2814
|
+
get serverTick() {
|
|
2815
|
+
return this._serverTick;
|
|
2816
|
+
}
|
|
2817
|
+
destroy() {
|
|
2818
|
+
this._inputChannel.close();
|
|
2819
|
+
this._stateChannel.close();
|
|
2820
|
+
this._ackChannel.close();
|
|
2821
|
+
this._inputBuffer = [];
|
|
2822
|
+
this._predictedState.clear();
|
|
2823
|
+
this._errorCorrections.clear();
|
|
2824
|
+
this._serverState.clear();
|
|
2825
|
+
}
|
|
2826
|
+
// ── Private: Host-side ──
|
|
2827
|
+
_setupHostListeners() {
|
|
2828
|
+
this._inputChannel.onReceive((rawData, peerId) => {
|
|
2829
|
+
try {
|
|
2830
|
+
const packet = JSON.parse(rawData);
|
|
2831
|
+
if (this._onPhysicsStep) {
|
|
2832
|
+
const inputs = /* @__PURE__ */ new Map();
|
|
2833
|
+
inputs.set(peerId, packet.i);
|
|
2834
|
+
this._onPhysicsStep(inputs, packet.t, false);
|
|
2835
|
+
}
|
|
2836
|
+
const prevTick = this._clientLastProcessedTick.get(peerId) ?? -1;
|
|
2837
|
+
this._clientLastProcessedTick.set(peerId, Math.max(prevTick, packet.t));
|
|
2838
|
+
} catch (err) {
|
|
2839
|
+
if (typeof console !== "undefined") console.debug("[CarverJS] Malformed input packet:", err);
|
|
2840
|
+
}
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
// ── Private: Client-side ──
|
|
2844
|
+
_setupClientListeners() {
|
|
2845
|
+
this._stateChannel.onReceive((data) => {
|
|
2846
|
+
try {
|
|
2847
|
+
const packet = (0, import_msgpackr2.unpack)(data);
|
|
2848
|
+
const entities = this._codec.deserialize(packet.s);
|
|
2849
|
+
const serverTick = packet.t;
|
|
2850
|
+
const lastInputTick = packet.li;
|
|
2851
|
+
this._serverTick = serverTick;
|
|
2852
|
+
this._tickKeeper.setServerTick(serverTick);
|
|
2853
|
+
this._serverState.clear();
|
|
2854
|
+
for (const entity of entities) {
|
|
2855
|
+
this._serverState.set(entity.id, entity);
|
|
2856
|
+
}
|
|
2857
|
+
this._reconcile(lastInputTick);
|
|
2858
|
+
} catch (err) {
|
|
2859
|
+
if (typeof console !== "undefined") console.debug("[CarverJS] Malformed state packet:", err);
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
_reconcile(lastInputTick) {
|
|
2864
|
+
this._inputBuffer = this._inputBuffer.filter((entry) => entry.tick > lastInputTick);
|
|
2865
|
+
let needsRollback = false;
|
|
2866
|
+
let maxError = 0;
|
|
2867
|
+
for (const [id, serverEntity] of this._serverState) {
|
|
2868
|
+
const predicted = this._predictedState.get(id);
|
|
2869
|
+
if (!predicted) continue;
|
|
2870
|
+
const error = this._computeError(predicted, serverEntity);
|
|
2871
|
+
maxError = Math.max(maxError, error);
|
|
2872
|
+
if (error > this._options.maxErrorPerFrame) {
|
|
2873
|
+
needsRollback = true;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
if (maxError > this._options.snapThreshold) {
|
|
2877
|
+
this._predictedState = new Map(this._serverState);
|
|
2878
|
+
this._errorCorrections.clear();
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
if (needsRollback) {
|
|
2882
|
+
const oldPositions = /* @__PURE__ */ new Map();
|
|
2883
|
+
for (const [id, entity] of this._predictedState) {
|
|
2884
|
+
oldPositions.set(id, {
|
|
2885
|
+
x: entity.x,
|
|
2886
|
+
y: entity.y,
|
|
2887
|
+
z: "z" in entity ? entity.z : 0
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
this._predictedState = new Map(this._serverState);
|
|
2891
|
+
if (this._onPhysicsStep) {
|
|
2892
|
+
for (const entry of this._inputBuffer) {
|
|
2893
|
+
const inputs = /* @__PURE__ */ new Map();
|
|
2894
|
+
inputs.set(this._transport.peerId, entry.input);
|
|
2895
|
+
this._onPhysicsStep(inputs, entry.tick, true);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
for (const [id, newEntity] of this._predictedState) {
|
|
2899
|
+
const oldPos = oldPositions.get(id);
|
|
2900
|
+
if (oldPos) {
|
|
2901
|
+
this._errorCorrections.set(id, {
|
|
2902
|
+
x: oldPos.x - newEntity.x,
|
|
2903
|
+
y: oldPos.y - newEntity.y,
|
|
2904
|
+
z: oldPos.z - ("z" in newEntity ? newEntity.z : 0)
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
_computeError(predicted, server) {
|
|
2911
|
+
const dx = predicted.x - server.x;
|
|
2912
|
+
const dy = predicted.y - server.y;
|
|
2913
|
+
let dz = 0;
|
|
2914
|
+
if ("z" in predicted && "z" in server) {
|
|
2915
|
+
dz = predicted.z - server.z;
|
|
2916
|
+
}
|
|
2917
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
|
|
2921
|
+
// src/core/NetworkSimulator.ts
|
|
2922
|
+
var NetworkSimulator = class {
|
|
2923
|
+
constructor(options) {
|
|
2924
|
+
this.sentCount = 0;
|
|
2925
|
+
this.droppedCount = 0;
|
|
2926
|
+
this.latencySum = 0;
|
|
2927
|
+
// running sum of applied latencies
|
|
2928
|
+
/** Active timeout handles so we can cancel them on destroy() */
|
|
2929
|
+
this.pending = /* @__PURE__ */ new Set();
|
|
2930
|
+
const opts = options ?? {};
|
|
2931
|
+
this.latencyMs = opts.latencyMs ?? 0;
|
|
2932
|
+
this.packetLoss = opts.packetLoss ?? 0;
|
|
2933
|
+
this.jitterMs = opts.jitterMs ?? 0;
|
|
2934
|
+
}
|
|
2935
|
+
/* ---- public API ---- */
|
|
2936
|
+
/** Update simulation parameters at runtime. */
|
|
2937
|
+
setOptions(options) {
|
|
2938
|
+
if (options.latencyMs !== void 0) this.latencyMs = options.latencyMs;
|
|
2939
|
+
if (options.packetLoss !== void 0) this.packetLoss = options.packetLoss;
|
|
2940
|
+
if (options.jitterMs !== void 0) this.jitterMs = options.jitterMs;
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Wrap an existing send function so that every call goes through the
|
|
2944
|
+
* simulated network conditions (latency, jitter, packet loss).
|
|
2945
|
+
*/
|
|
2946
|
+
wrapSend(originalSend) {
|
|
2947
|
+
return (data, target) => {
|
|
2948
|
+
if (Math.random() < this.packetLoss) {
|
|
2949
|
+
this.droppedCount++;
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
this.sentCount++;
|
|
2953
|
+
const jitter = this.jitterMs > 0 ? Math.random() * 2 * this.jitterMs - this.jitterMs : 0;
|
|
2954
|
+
const delay = Math.max(0, this.latencyMs + jitter);
|
|
2955
|
+
this.latencySum += delay;
|
|
2956
|
+
if (delay === 0) {
|
|
2957
|
+
originalSend(data, target);
|
|
2958
|
+
} else {
|
|
2959
|
+
const handle = setTimeout(() => {
|
|
2960
|
+
this.pending.delete(handle);
|
|
2961
|
+
originalSend(data, target);
|
|
2962
|
+
}, delay);
|
|
2963
|
+
this.pending.add(handle);
|
|
2964
|
+
}
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
/** Current statistics snapshot. */
|
|
2968
|
+
get stats() {
|
|
2969
|
+
return {
|
|
2970
|
+
sentCount: this.sentCount,
|
|
2971
|
+
droppedCount: this.droppedCount,
|
|
2972
|
+
avgLatencyMs: this.sentCount > 0 ? this.latencySum / this.sentCount : 0
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2975
|
+
/** Cancel all pending delayed sends and clean up. */
|
|
2976
|
+
destroy() {
|
|
2977
|
+
for (const handle of this.pending) {
|
|
2978
|
+
clearTimeout(handle);
|
|
2979
|
+
}
|
|
2980
|
+
this.pending.clear();
|
|
2981
|
+
}
|
|
2982
|
+
};
|
|
2983
|
+
|
|
2984
|
+
// src/hooks/useMultiplayer.ts
|
|
2985
|
+
var Z_THRESHOLD = 0.01;
|
|
2986
|
+
function detect2D(actors) {
|
|
2987
|
+
for (const [, ref] of actors) {
|
|
2988
|
+
if (Math.abs(ref.object3D.position.z) > Z_THRESHOLD) return false;
|
|
2989
|
+
}
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
function readEntityState2D(ref) {
|
|
2993
|
+
const pos = ref.object3D.position;
|
|
2994
|
+
const rot = ref.object3D.rotation;
|
|
2995
|
+
const rb = ref.rigidBody;
|
|
2996
|
+
let vx = 0;
|
|
2997
|
+
let vy = 0;
|
|
2998
|
+
let va = 0;
|
|
2999
|
+
if (rb) {
|
|
3000
|
+
try {
|
|
3001
|
+
const lv = rb.linvel();
|
|
3002
|
+
vx = lv.x;
|
|
3003
|
+
vy = lv.y;
|
|
3004
|
+
} catch {
|
|
3005
|
+
}
|
|
3006
|
+
try {
|
|
3007
|
+
const av = rb.angvel();
|
|
3008
|
+
va = typeof av === "number" ? av : av?.z ?? 0;
|
|
3009
|
+
} catch {
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
const nc = ref.userData.networked;
|
|
3013
|
+
const custom = nc?.custom;
|
|
3014
|
+
return {
|
|
3015
|
+
id: ref.id,
|
|
3016
|
+
x: pos.x,
|
|
3017
|
+
y: pos.y,
|
|
3018
|
+
a: rot.z,
|
|
3019
|
+
vx,
|
|
3020
|
+
vy,
|
|
3021
|
+
va,
|
|
3022
|
+
...custom ? { c: custom } : {}
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
function readEntityState3D(ref) {
|
|
3026
|
+
const pos = ref.object3D.position;
|
|
3027
|
+
const quat = ref.object3D.quaternion;
|
|
3028
|
+
const rb = ref.rigidBody;
|
|
3029
|
+
let vx = 0;
|
|
3030
|
+
let vy = 0;
|
|
3031
|
+
let vz = 0;
|
|
3032
|
+
let wx = 0;
|
|
3033
|
+
let wy = 0;
|
|
3034
|
+
let wz = 0;
|
|
3035
|
+
if (rb) {
|
|
3036
|
+
try {
|
|
3037
|
+
const lv = rb.linvel();
|
|
3038
|
+
vx = lv.x;
|
|
3039
|
+
vy = lv.y;
|
|
3040
|
+
vz = lv.z ?? 0;
|
|
3041
|
+
} catch {
|
|
3042
|
+
}
|
|
3043
|
+
try {
|
|
3044
|
+
const av = rb.angvel();
|
|
3045
|
+
wx = av.x ?? 0;
|
|
3046
|
+
wy = av.y ?? 0;
|
|
3047
|
+
wz = av.z ?? 0;
|
|
3048
|
+
} catch {
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
const nc = ref.userData.networked;
|
|
3052
|
+
const custom = nc?.custom;
|
|
3053
|
+
return {
|
|
3054
|
+
id: ref.id,
|
|
3055
|
+
x: pos.x,
|
|
3056
|
+
y: pos.y,
|
|
3057
|
+
z: pos.z,
|
|
3058
|
+
qx: quat.x,
|
|
3059
|
+
qy: quat.y,
|
|
3060
|
+
qz: quat.z,
|
|
3061
|
+
qw: quat.w,
|
|
3062
|
+
vx,
|
|
3063
|
+
vy,
|
|
3064
|
+
vz,
|
|
3065
|
+
wx,
|
|
3066
|
+
wy,
|
|
3067
|
+
wz,
|
|
3068
|
+
...custom ? { c: custom } : {}
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
function buildEntityMap(actors, is2D) {
|
|
3072
|
+
const entities = /* @__PURE__ */ new Map();
|
|
3073
|
+
for (const [id, ref] of actors) {
|
|
3074
|
+
const nc = ref.userData.networked;
|
|
3075
|
+
if (nc && nc.sync === false) continue;
|
|
3076
|
+
entities.set(id, is2D ? readEntityState2D(ref) : readEntityState3D(ref));
|
|
3077
|
+
}
|
|
3078
|
+
return entities;
|
|
3079
|
+
}
|
|
3080
|
+
function applyState2D(ref, state) {
|
|
3081
|
+
ref.object3D.position.set(state.x, state.y, 0);
|
|
3082
|
+
ref.object3D.rotation.z = state.a;
|
|
3083
|
+
if (ref.rigidBody) {
|
|
3084
|
+
try {
|
|
3085
|
+
if (typeof ref.rigidBody.setTranslation === "function") {
|
|
3086
|
+
ref.rigidBody.setTranslation({ x: state.x, y: state.y }, true);
|
|
3087
|
+
}
|
|
3088
|
+
if (typeof ref.rigidBody.setRotation === "function") {
|
|
3089
|
+
ref.rigidBody.setRotation(state.a, true);
|
|
3090
|
+
}
|
|
3091
|
+
} catch {
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
function applyState3D(ref, state) {
|
|
3096
|
+
ref.object3D.position.set(state.x, state.y, state.z);
|
|
3097
|
+
ref.object3D.quaternion.set(state.qx, state.qy, state.qz, state.qw);
|
|
3098
|
+
if (ref.rigidBody) {
|
|
3099
|
+
try {
|
|
3100
|
+
if (typeof ref.rigidBody.setTranslation === "function") {
|
|
3101
|
+
ref.rigidBody.setTranslation({ x: state.x, y: state.y, z: state.z }, true);
|
|
3102
|
+
}
|
|
3103
|
+
if (typeof ref.rigidBody.setRotation === "function") {
|
|
3104
|
+
ref.rigidBody.setRotation(
|
|
3105
|
+
{ x: state.qx, y: state.qy, z: state.qz, w: state.qw },
|
|
3106
|
+
true
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
} catch {
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
function applyEntityState(ref, state, is2D) {
|
|
3114
|
+
if (is2D) {
|
|
3115
|
+
applyState2D(ref, state);
|
|
3116
|
+
} else {
|
|
3117
|
+
applyState3D(ref, state);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
function applyStatesToActors(states, registry, is2D) {
|
|
3121
|
+
for (const [id, state] of states) {
|
|
3122
|
+
if (state.c && state.c.__removed) continue;
|
|
3123
|
+
const ref = registry.get(id);
|
|
3124
|
+
if (!ref) continue;
|
|
3125
|
+
applyEntityState(ref, state, is2D);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
function useMultiplayer(options = {}) {
|
|
3129
|
+
const { networkManager } = useMultiplayerContext();
|
|
3130
|
+
const mode = options.mode ?? networkManager.syncMode;
|
|
3131
|
+
const eventSyncRef = (0, import_react8.useRef)(null);
|
|
3132
|
+
const snapshotSyncRef = (0, import_react8.useRef)(null);
|
|
3133
|
+
const predictionSyncRef = (0, import_react8.useRef)(null);
|
|
3134
|
+
const networkSimulatorRef = (0, import_react8.useRef)(null);
|
|
3135
|
+
const is2DRef = (0, import_react8.useRef)(null);
|
|
3136
|
+
const [isActive, setIsActive] = (0, import_react8.useState)(false);
|
|
3137
|
+
const [networkQuality, setNetworkQuality] = (0, import_react8.useState)("good");
|
|
3138
|
+
const [tick, setTick] = (0, import_react8.useState)(0);
|
|
3139
|
+
const [serverTick, setServerTick] = (0, import_react8.useState)(0);
|
|
3140
|
+
const [drift, setDrift] = (0, import_react8.useState)(0);
|
|
3141
|
+
const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
|
|
3142
|
+
const wasHostRef = (0, import_react8.useRef)(null);
|
|
3143
|
+
const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
|
|
3144
|
+
return {
|
|
3145
|
+
broadcastRate: options.broadcastRate,
|
|
3146
|
+
keyframeInterval: options.keyframeInterval,
|
|
3147
|
+
bufferSize: options.interpolation?.bufferSize,
|
|
3148
|
+
interpolationMethod: options.interpolation?.method,
|
|
3149
|
+
extrapolateMs: options.interpolation?.extrapolateMs,
|
|
3150
|
+
is2D: is2DRef.current ?? true
|
|
3151
|
+
};
|
|
3152
|
+
}, [
|
|
3153
|
+
options.broadcastRate,
|
|
3154
|
+
options.keyframeInterval,
|
|
3155
|
+
options.interpolation?.bufferSize,
|
|
3156
|
+
options.interpolation?.method,
|
|
3157
|
+
options.interpolation?.extrapolateMs
|
|
3158
|
+
]);
|
|
3159
|
+
(0, import_react8.useEffect)(() => {
|
|
3160
|
+
const transport = networkManager.transport;
|
|
3161
|
+
if (!transport) return;
|
|
3162
|
+
const debugOpts = options.debug;
|
|
3163
|
+
let simulator = null;
|
|
3164
|
+
if (debugOpts?.simulatedLatencyMs || debugOpts?.simulatedPacketLoss) {
|
|
3165
|
+
simulator = new NetworkSimulator({
|
|
3166
|
+
latencyMs: debugOpts.simulatedLatencyMs,
|
|
3167
|
+
packetLoss: debugOpts.simulatedPacketLoss
|
|
3168
|
+
});
|
|
3169
|
+
networkSimulatorRef.current = simulator;
|
|
3170
|
+
const origCreateChannel = transport.createChannel.bind(transport);
|
|
3171
|
+
transport.createChannel = (name, channelOpts) => {
|
|
3172
|
+
const ch = origCreateChannel(name, channelOpts);
|
|
3173
|
+
const wrappedSend = simulator.wrapSend(ch.send.bind(ch));
|
|
3174
|
+
return { ...ch, send: wrappedSend };
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
eventSyncRef.current = new EventSync(transport);
|
|
3178
|
+
if (mode === "snapshot" || mode === "prediction") {
|
|
3179
|
+
snapshotSyncRef.current = new SnapshotSync(
|
|
3180
|
+
transport,
|
|
3181
|
+
networkManager.codec,
|
|
3182
|
+
networkManager.snapshotBuffer,
|
|
3183
|
+
buildSnapshotOpts()
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
if (mode === "prediction") {
|
|
3187
|
+
predictionSyncRef.current = new PredictionSync(
|
|
3188
|
+
transport,
|
|
3189
|
+
networkManager.codec,
|
|
3190
|
+
networkManager.tickKeeper,
|
|
3191
|
+
options.prediction
|
|
3192
|
+
);
|
|
3193
|
+
if (options.onPhysicsStep) {
|
|
3194
|
+
predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
wasHostRef.current = networkManager.isHost;
|
|
3198
|
+
setIsActive(true);
|
|
3199
|
+
const unsubHostChanged = (() => {
|
|
3200
|
+
const onHostChanged = (newHostId) => {
|
|
3201
|
+
const amNewHost = newHostId === transport.peerId;
|
|
3202
|
+
const wasPreviouslyHost = wasHostRef.current;
|
|
3203
|
+
wasHostRef.current = amNewHost;
|
|
3204
|
+
if (amNewHost && !wasPreviouslyHost) {
|
|
3205
|
+
snapshotSyncRef.current?.promoteToHost(buildSnapshotOpts());
|
|
3206
|
+
} else if (!amNewHost && wasPreviouslyHost) {
|
|
3207
|
+
snapshotSyncRef.current?.demoteToClient(buildSnapshotOpts());
|
|
3208
|
+
}
|
|
3209
|
+
};
|
|
3210
|
+
transport.onHostChanged(onHostChanged);
|
|
3211
|
+
return () => {
|
|
3212
|
+
};
|
|
3213
|
+
})();
|
|
3214
|
+
return () => {
|
|
3215
|
+
unsubHostChanged();
|
|
3216
|
+
eventSyncRef.current?.destroy();
|
|
3217
|
+
snapshotSyncRef.current?.destroy();
|
|
3218
|
+
predictionSyncRef.current?.destroy();
|
|
3219
|
+
networkSimulatorRef.current?.destroy();
|
|
3220
|
+
eventSyncRef.current = null;
|
|
3221
|
+
snapshotSyncRef.current = null;
|
|
3222
|
+
predictionSyncRef.current = null;
|
|
3223
|
+
networkSimulatorRef.current = null;
|
|
3224
|
+
setIsActive(false);
|
|
3225
|
+
};
|
|
3226
|
+
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
|
|
3227
|
+
(0, import_react8.useEffect)(() => {
|
|
3228
|
+
const unsub = networkManager.onConnectionStateChange(() => {
|
|
3229
|
+
setNetworkQuality(networkManager.networkQuality);
|
|
3230
|
+
});
|
|
3231
|
+
return unsub;
|
|
3232
|
+
}, [networkManager]);
|
|
3233
|
+
(0, import_fiber.useFrame)((_state, delta) => {
|
|
3234
|
+
const transport = networkManager.transport;
|
|
3235
|
+
if (!transport) return;
|
|
3236
|
+
const tickKeeper = networkManager.tickKeeper;
|
|
3237
|
+
const isHost = networkManager.isHost;
|
|
3238
|
+
if (is2DRef.current === null) {
|
|
3239
|
+
const networked = actorRegistry.current.getNetworked();
|
|
3240
|
+
if (networked.size > 0) {
|
|
3241
|
+
is2DRef.current = detect2D(networked);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const is2D = is2DRef.current ?? true;
|
|
3245
|
+
if (mode === "events") return;
|
|
3246
|
+
const ticksThisFrame = tickKeeper.update(delta);
|
|
3247
|
+
for (let i = 0; i < ticksThisFrame; i++) {
|
|
3248
|
+
const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
|
|
3249
|
+
if (isHost) {
|
|
3250
|
+
const networked = actorRegistry.current.getNetworked();
|
|
3251
|
+
const entities = buildEntityMap(networked, is2D);
|
|
3252
|
+
if (mode === "snapshot" || mode === "prediction") {
|
|
3253
|
+
snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
3254
|
+
}
|
|
3255
|
+
if (mode === "prediction") {
|
|
3256
|
+
predictionSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
3257
|
+
}
|
|
3258
|
+
} else {
|
|
3259
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3260
|
+
predictionSyncRef.current.clientTick(currentTick);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
if (!isHost) {
|
|
3265
|
+
if (mode === "snapshot" && snapshotSyncRef.current) {
|
|
3266
|
+
const renderTime = performance.now();
|
|
3267
|
+
const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
|
|
3268
|
+
applyStatesToActors(interpolated, actorRegistry.current, is2D);
|
|
3269
|
+
} else if (mode === "prediction" && predictionSyncRef.current) {
|
|
3270
|
+
const predicted = predictionSyncRef.current.predictedState;
|
|
3271
|
+
const smoothed = predictionSyncRef.current.applyErrorSmoothing(predicted);
|
|
3272
|
+
applyStatesToActors(smoothed, actorRegistry.current, is2D);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
if (ticksThisFrame > 0) {
|
|
3276
|
+
const newTick = tickKeeper.tick;
|
|
3277
|
+
const newServerTick = tickKeeper.serverTick;
|
|
3278
|
+
const newDrift = tickKeeper.drift;
|
|
3279
|
+
const newQuality = networkManager.networkQuality;
|
|
3280
|
+
setTick((prev) => prev !== newTick ? newTick : prev);
|
|
3281
|
+
setServerTick((prev) => prev !== newServerTick ? newServerTick : prev);
|
|
3282
|
+
setDrift((prev) => prev !== newDrift ? newDrift : prev);
|
|
3283
|
+
setNetworkQuality((prev) => prev !== newQuality ? newQuality : prev);
|
|
3284
|
+
}
|
|
3285
|
+
}, -55);
|
|
3286
|
+
return {
|
|
3287
|
+
isActive,
|
|
3288
|
+
networkQuality,
|
|
3289
|
+
tick,
|
|
3290
|
+
serverTick,
|
|
3291
|
+
drift,
|
|
3292
|
+
syncEngine: mode
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
// src/hooks/useNetworkEvents.ts
|
|
3297
|
+
var import_react9 = require("react");
|
|
3298
|
+
function useNetworkEvents(options) {
|
|
3299
|
+
const { networkManager } = useMultiplayerContext();
|
|
3300
|
+
const eventSyncRef = (0, import_react9.useRef)(null);
|
|
3301
|
+
const pendingRef = (0, import_react9.useRef)([]);
|
|
3302
|
+
const drainedUnsubsRef = (0, import_react9.useRef)([]);
|
|
3303
|
+
(0, import_react9.useEffect)(() => {
|
|
3304
|
+
const transport = networkManager.transport;
|
|
3305
|
+
if (!transport) return;
|
|
3306
|
+
const eventSync = new EventSync(transport, {
|
|
3307
|
+
hostValidation: options?.hostValidation
|
|
3308
|
+
});
|
|
3309
|
+
eventSyncRef.current = eventSync;
|
|
3310
|
+
const unsubs = [];
|
|
3311
|
+
for (const entry of pendingRef.current) {
|
|
3312
|
+
const unsub = eventSync.onEvent(entry.type, entry.callback);
|
|
3313
|
+
entry.unsub = unsub;
|
|
3314
|
+
unsubs.push(unsub);
|
|
3315
|
+
}
|
|
3316
|
+
pendingRef.current = [];
|
|
3317
|
+
drainedUnsubsRef.current = unsubs;
|
|
3318
|
+
return () => {
|
|
3319
|
+
for (const unsub of drainedUnsubsRef.current) {
|
|
3320
|
+
unsub();
|
|
3321
|
+
}
|
|
3322
|
+
drainedUnsubsRef.current = [];
|
|
3323
|
+
eventSync.destroy();
|
|
3324
|
+
eventSyncRef.current = null;
|
|
3325
|
+
};
|
|
3326
|
+
}, [networkManager.transport, options?.hostValidation]);
|
|
3327
|
+
const sendEvent = (0, import_react9.useCallback)((type, payload, target) => {
|
|
3328
|
+
eventSyncRef.current?.sendEvent(type, payload, target);
|
|
3329
|
+
}, []);
|
|
3330
|
+
const broadcast = (0, import_react9.useCallback)((type, payload) => {
|
|
3331
|
+
eventSyncRef.current?.broadcast(type, payload);
|
|
3332
|
+
}, []);
|
|
3333
|
+
const onEvent = (0, import_react9.useCallback)((type, callback) => {
|
|
3334
|
+
const castCallback = callback;
|
|
3335
|
+
if (eventSyncRef.current) {
|
|
3336
|
+
return eventSyncRef.current.onEvent(type, castCallback);
|
|
3337
|
+
}
|
|
3338
|
+
const entry = { type, callback: castCallback, unsub: null };
|
|
3339
|
+
pendingRef.current.push(entry);
|
|
3340
|
+
return () => {
|
|
3341
|
+
if (entry.unsub) {
|
|
3342
|
+
entry.unsub();
|
|
3343
|
+
drainedUnsubsRef.current = drainedUnsubsRef.current.filter((u) => u !== entry.unsub);
|
|
3344
|
+
} else {
|
|
3345
|
+
pendingRef.current = pendingRef.current.filter((e) => e !== entry);
|
|
3346
|
+
}
|
|
3347
|
+
};
|
|
3348
|
+
}, []);
|
|
3349
|
+
return { sendEvent, broadcast, onEvent };
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
// src/hooks/useNetworkState.ts
|
|
3353
|
+
var import_react10 = require("react");
|
|
3354
|
+
var CHANNEL_NAME = "carver:network-state";
|
|
3355
|
+
function useNetworkState() {
|
|
3356
|
+
const { networkManager } = useMultiplayerContext();
|
|
3357
|
+
const [entities, setEntities] = (0, import_react10.useState)(
|
|
3358
|
+
() => /* @__PURE__ */ new Map()
|
|
3359
|
+
);
|
|
3360
|
+
const entitiesRef = (0, import_react10.useRef)(entities);
|
|
3361
|
+
entitiesRef.current = entities;
|
|
3362
|
+
const channelRef = (0, import_react10.useRef)(null);
|
|
3363
|
+
const replaceEntities = (0, import_react10.useCallback)(
|
|
3364
|
+
(updater) => {
|
|
3365
|
+
setEntities((prev) => {
|
|
3366
|
+
const next = updater(prev);
|
|
3367
|
+
entitiesRef.current = next;
|
|
3368
|
+
return next;
|
|
3369
|
+
});
|
|
3370
|
+
},
|
|
3371
|
+
[]
|
|
3372
|
+
);
|
|
3373
|
+
const applySpawn = (0, import_react10.useCallback)(
|
|
3374
|
+
(id, state) => {
|
|
3375
|
+
replaceEntities((prev) => {
|
|
3376
|
+
const next = new Map(prev);
|
|
3377
|
+
next.set(id, state);
|
|
3378
|
+
return next;
|
|
3379
|
+
});
|
|
3380
|
+
},
|
|
3381
|
+
[replaceEntities]
|
|
3382
|
+
);
|
|
3383
|
+
const applyDespawn = (0, import_react10.useCallback)(
|
|
3384
|
+
(id) => {
|
|
3385
|
+
replaceEntities((prev) => {
|
|
3386
|
+
if (!prev.has(id)) return prev;
|
|
3387
|
+
const next = new Map(prev);
|
|
3388
|
+
next.delete(id);
|
|
3389
|
+
return next;
|
|
3390
|
+
});
|
|
3391
|
+
},
|
|
3392
|
+
[replaceEntities]
|
|
3393
|
+
);
|
|
3394
|
+
(0, import_react10.useEffect)(() => {
|
|
3395
|
+
const transport = networkManager.transport;
|
|
3396
|
+
if (!transport) return;
|
|
3397
|
+
const channel = transport.createChannel(CHANNEL_NAME, {
|
|
3398
|
+
reliable: true,
|
|
3399
|
+
ordered: true
|
|
3400
|
+
});
|
|
3401
|
+
channelRef.current = channel;
|
|
3402
|
+
channel.onReceive((msg, _peerId) => {
|
|
3403
|
+
switch (msg.action) {
|
|
3404
|
+
// --- Spawn broadcast (sent by host to everyone) ---
|
|
3405
|
+
case "spawn": {
|
|
3406
|
+
if (msg.state) {
|
|
3407
|
+
applySpawn(msg.id, msg.state);
|
|
3408
|
+
}
|
|
3409
|
+
break;
|
|
3410
|
+
}
|
|
3411
|
+
// --- Despawn broadcast (sent by host to everyone) ---
|
|
3412
|
+
case "despawn": {
|
|
3413
|
+
applyDespawn(msg.id);
|
|
3414
|
+
break;
|
|
3415
|
+
}
|
|
3416
|
+
// --- Request-spawn (client -> host only) ---
|
|
3417
|
+
case "request-spawn": {
|
|
3418
|
+
if (!transport.isHost) break;
|
|
3419
|
+
if (entitiesRef.current.has(msg.id)) break;
|
|
3420
|
+
const config = msg.state ?? {};
|
|
3421
|
+
const newState = {
|
|
3422
|
+
id: msg.id,
|
|
3423
|
+
x: 0,
|
|
3424
|
+
y: 0,
|
|
3425
|
+
a: 0,
|
|
3426
|
+
vx: 0,
|
|
3427
|
+
vy: 0,
|
|
3428
|
+
va: 0,
|
|
3429
|
+
...config,
|
|
3430
|
+
// Ensure id is authoritative.
|
|
3431
|
+
...config.id !== void 0 ? {} : {}
|
|
3432
|
+
};
|
|
3433
|
+
newState.id = msg.id;
|
|
3434
|
+
applySpawn(msg.id, newState);
|
|
3435
|
+
channel.send({ action: "spawn", id: msg.id, state: newState });
|
|
3436
|
+
break;
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
return () => {
|
|
3441
|
+
channel.close();
|
|
3442
|
+
channelRef.current = null;
|
|
3443
|
+
};
|
|
3444
|
+
}, [networkManager.transport, applySpawn, applyDespawn]);
|
|
3445
|
+
const spawn = (0, import_react10.useCallback)(
|
|
3446
|
+
(id, initialState) => {
|
|
3447
|
+
const transport = networkManager.transport;
|
|
3448
|
+
if (!transport) {
|
|
3449
|
+
console.warn("[useNetworkState] spawn called before transport is ready.");
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
if (!transport.isHost) {
|
|
3453
|
+
console.warn(
|
|
3454
|
+
"[useNetworkState] spawn is host-only. Use requestSpawn() from a client."
|
|
3455
|
+
);
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
if (entitiesRef.current.has(id)) {
|
|
3459
|
+
console.warn(
|
|
3460
|
+
`[useNetworkState] entity "${id}" already exists. Ignoring spawn.`
|
|
3461
|
+
);
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
const state = { ...initialState, id };
|
|
3465
|
+
applySpawn(id, state);
|
|
3466
|
+
channelRef.current?.send({ action: "spawn", id, state });
|
|
3467
|
+
},
|
|
3468
|
+
[networkManager, applySpawn]
|
|
3469
|
+
);
|
|
3470
|
+
const despawn = (0, import_react10.useCallback)(
|
|
3471
|
+
(id) => {
|
|
3472
|
+
const transport = networkManager.transport;
|
|
3473
|
+
if (!transport) {
|
|
3474
|
+
console.warn("[useNetworkState] despawn called before transport is ready.");
|
|
3475
|
+
return;
|
|
3476
|
+
}
|
|
3477
|
+
if (!transport.isHost) {
|
|
3478
|
+
console.warn("[useNetworkState] despawn is host-only.");
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
if (!entitiesRef.current.has(id)) {
|
|
3482
|
+
console.warn(
|
|
3483
|
+
`[useNetworkState] entity "${id}" does not exist. Ignoring despawn.`
|
|
3484
|
+
);
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
applyDespawn(id);
|
|
3488
|
+
channelRef.current?.send({ action: "despawn", id });
|
|
3489
|
+
},
|
|
3490
|
+
[networkManager, applyDespawn]
|
|
3491
|
+
);
|
|
3492
|
+
const requestSpawn = (0, import_react10.useCallback)(
|
|
3493
|
+
(id, config) => {
|
|
3494
|
+
const transport = networkManager.transport;
|
|
3495
|
+
if (!transport) {
|
|
3496
|
+
console.warn(
|
|
3497
|
+
"[useNetworkState] requestSpawn called before transport is ready."
|
|
3498
|
+
);
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
if (transport.isHost) {
|
|
3502
|
+
if (entitiesRef.current.has(id)) {
|
|
3503
|
+
console.warn(
|
|
3504
|
+
`[useNetworkState] entity "${id}" already exists. Ignoring requestSpawn.`
|
|
3505
|
+
);
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
const newState = {
|
|
3509
|
+
id,
|
|
3510
|
+
x: 0,
|
|
3511
|
+
y: 0,
|
|
3512
|
+
a: 0,
|
|
3513
|
+
vx: 0,
|
|
3514
|
+
vy: 0,
|
|
3515
|
+
va: 0,
|
|
3516
|
+
...config
|
|
3517
|
+
};
|
|
3518
|
+
newState.id = id;
|
|
3519
|
+
applySpawn(id, newState);
|
|
3520
|
+
channelRef.current?.send({ action: "spawn", id, state: newState });
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
channelRef.current?.send(
|
|
3524
|
+
{ action: "request-spawn", id, state: config },
|
|
3525
|
+
transport.hostId
|
|
3526
|
+
);
|
|
3527
|
+
},
|
|
3528
|
+
[networkManager, applySpawn]
|
|
3529
|
+
);
|
|
3530
|
+
const getState = (0, import_react10.useCallback)(
|
|
3531
|
+
(id) => {
|
|
3532
|
+
return entitiesRef.current.get(id);
|
|
3533
|
+
},
|
|
3534
|
+
[]
|
|
3535
|
+
);
|
|
3536
|
+
const setState = (0, import_react10.useCallback)(
|
|
3537
|
+
(id, partialState) => {
|
|
3538
|
+
const transport = networkManager.transport;
|
|
3539
|
+
if (!transport) {
|
|
3540
|
+
console.warn("[useNetworkState] setState called before transport is ready.");
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (!transport.isHost) {
|
|
3544
|
+
console.warn("[useNetworkState] setState is host-only.");
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
const existing = entitiesRef.current.get(id);
|
|
3548
|
+
if (!existing) {
|
|
3549
|
+
console.warn(
|
|
3550
|
+
`[useNetworkState] entity "${id}" does not exist. Cannot setState.`
|
|
3551
|
+
);
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
const updated = {
|
|
3555
|
+
...existing,
|
|
3556
|
+
...partialState,
|
|
3557
|
+
id
|
|
3558
|
+
// id is immutable
|
|
3559
|
+
};
|
|
3560
|
+
replaceEntities((prev) => {
|
|
3561
|
+
const next = new Map(prev);
|
|
3562
|
+
next.set(id, updated);
|
|
3563
|
+
return next;
|
|
3564
|
+
});
|
|
3565
|
+
channelRef.current?.send({ action: "spawn", id, state: updated });
|
|
3566
|
+
},
|
|
3567
|
+
[networkManager, replaceEntities]
|
|
3568
|
+
);
|
|
3569
|
+
return {
|
|
3570
|
+
spawn,
|
|
3571
|
+
despawn,
|
|
3572
|
+
requestSpawn,
|
|
3573
|
+
getState,
|
|
3574
|
+
setState,
|
|
3575
|
+
entities
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// src/core/DebugOverlay.ts
|
|
3580
|
+
var QUALITY_COLORS = {
|
|
3581
|
+
good: "#00ff00",
|
|
3582
|
+
degraded: "#ffff00",
|
|
3583
|
+
poor: "#ff4444"
|
|
3584
|
+
};
|
|
3585
|
+
var DebugOverlay = class {
|
|
3586
|
+
constructor(options) {
|
|
3587
|
+
this.visible = true;
|
|
3588
|
+
this.lastHTML = "";
|
|
3589
|
+
const opts = options ?? {};
|
|
3590
|
+
this.keyboardToggle = opts.keyboardToggle ?? "F3";
|
|
3591
|
+
this.el = document.createElement("div");
|
|
3592
|
+
const pos = opts.position ?? "top-right";
|
|
3593
|
+
const posStyles = this.positionStyles(pos);
|
|
3594
|
+
Object.assign(this.el.style, {
|
|
3595
|
+
position: "fixed",
|
|
3596
|
+
...posStyles,
|
|
3597
|
+
background: "rgba(0,0,0,0.75)",
|
|
3598
|
+
color: "#00ff00",
|
|
3599
|
+
fontFamily: "'Courier New', monospace",
|
|
3600
|
+
fontSize: "11px",
|
|
3601
|
+
padding: "8px",
|
|
3602
|
+
borderRadius: "4px",
|
|
3603
|
+
zIndex: "99999",
|
|
3604
|
+
pointerEvents: "none",
|
|
3605
|
+
whiteSpace: "pre",
|
|
3606
|
+
lineHeight: "1.4",
|
|
3607
|
+
minWidth: "200px",
|
|
3608
|
+
boxSizing: "border-box"
|
|
3609
|
+
});
|
|
3610
|
+
document.body.appendChild(this.el);
|
|
3611
|
+
this.handleKey = (e) => {
|
|
3612
|
+
if (e.key === this.keyboardToggle) {
|
|
3613
|
+
e.preventDefault();
|
|
3614
|
+
this.toggle();
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
window.addEventListener("keydown", this.handleKey);
|
|
3618
|
+
}
|
|
3619
|
+
/* ---- public API ---- */
|
|
3620
|
+
update(stats) {
|
|
3621
|
+
if (!this.visible) return;
|
|
3622
|
+
const latencyDisplay = this.formatLatency(stats.latencyMs);
|
|
3623
|
+
const qualityColor = QUALITY_COLORS[stats.networkQuality] ?? "#00ff00";
|
|
3624
|
+
const bwIn = (stats.bandwidthIn / 1024).toFixed(1);
|
|
3625
|
+
const bwOut = (stats.bandwidthOut / 1024).toFixed(1);
|
|
3626
|
+
const loss = (stats.packetLossRate * 100).toFixed(1);
|
|
3627
|
+
const html = `<b style="color:#00ccff">== NET DEBUG ==</b>
|
|
3628
|
+
tick ${stats.tick}
|
|
3629
|
+
srv tick ${stats.serverTick}
|
|
3630
|
+
drift ${stats.drift}
|
|
3631
|
+
latency ${latencyDisplay} ms
|
|
3632
|
+
loss ${loss}%
|
|
3633
|
+
bw in ${bwIn} KB/s
|
|
3634
|
+
bw out ${bwOut} KB/s
|
|
3635
|
+
quality <span style="color:${qualityColor}">${stats.networkQuality}</span>
|
|
3636
|
+
peers ${stats.peerCount}
|
|
3637
|
+
host ${stats.isHost ? "YES" : "NO"}
|
|
3638
|
+
sync ${stats.syncMode}`;
|
|
3639
|
+
if (html !== this.lastHTML) {
|
|
3640
|
+
this.el.innerHTML = html;
|
|
3641
|
+
this.lastHTML = html;
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
show() {
|
|
3645
|
+
this.visible = true;
|
|
3646
|
+
this.el.style.display = "block";
|
|
3647
|
+
}
|
|
3648
|
+
hide() {
|
|
3649
|
+
this.visible = false;
|
|
3650
|
+
this.el.style.display = "none";
|
|
3651
|
+
}
|
|
3652
|
+
toggle() {
|
|
3653
|
+
if (this.visible) {
|
|
3654
|
+
this.hide();
|
|
3655
|
+
} else {
|
|
3656
|
+
this.show();
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
destroy() {
|
|
3660
|
+
window.removeEventListener("keydown", this.handleKey);
|
|
3661
|
+
this.el.remove();
|
|
3662
|
+
}
|
|
3663
|
+
/* ---- helpers ---- */
|
|
3664
|
+
positionStyles(pos) {
|
|
3665
|
+
switch (pos) {
|
|
3666
|
+
case "top-left":
|
|
3667
|
+
return { top: "8px", left: "8px" };
|
|
3668
|
+
case "top-right":
|
|
3669
|
+
return { top: "8px", right: "8px" };
|
|
3670
|
+
case "bottom-left":
|
|
3671
|
+
return { bottom: "8px", left: "8px" };
|
|
3672
|
+
case "bottom-right":
|
|
3673
|
+
return { bottom: "8px", right: "8px" };
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
formatLatency(latency) {
|
|
3677
|
+
if (typeof latency === "number") {
|
|
3678
|
+
return latency.toFixed(1);
|
|
3679
|
+
}
|
|
3680
|
+
if (latency.size === 0) return "\u2014";
|
|
3681
|
+
let sum = 0;
|
|
3682
|
+
latency.forEach((v) => sum += v);
|
|
3683
|
+
return (sum / latency.size).toFixed(1);
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
|
|
3687
|
+
// src/core/InterestManager.ts
|
|
3688
|
+
var InterestManager = class {
|
|
3689
|
+
constructor(options) {
|
|
3690
|
+
this._cellSize = options?.cellSize ?? 50;
|
|
3691
|
+
this._defaultRadius = options?.defaultRadius ?? 200;
|
|
3692
|
+
this._alwaysRelevant = new Set(options?.alwaysRelevant ?? []);
|
|
3693
|
+
this._cells = /* @__PURE__ */ new Map();
|
|
3694
|
+
this._entityPositions = /* @__PURE__ */ new Map();
|
|
3695
|
+
}
|
|
3696
|
+
// ── Public API ──
|
|
3697
|
+
/**
|
|
3698
|
+
* Rebuild the spatial hash from the authoritative entity map.
|
|
3699
|
+
* Called once per host tick before any relevance queries.
|
|
3700
|
+
*/
|
|
3701
|
+
updateEntities(entities) {
|
|
3702
|
+
this._cells.clear();
|
|
3703
|
+
this._entityPositions.clear();
|
|
3704
|
+
for (const [id, entity] of entities) {
|
|
3705
|
+
const z = "z" in entity ? entity.z : 0;
|
|
3706
|
+
const pos = { x: entity.x, y: entity.y, z };
|
|
3707
|
+
this._entityPositions.set(id, pos);
|
|
3708
|
+
const key = this._cellKey(pos.x, pos.y, pos.z);
|
|
3709
|
+
let bucket = this._cells.get(key);
|
|
3710
|
+
if (!bucket) {
|
|
3711
|
+
bucket = /* @__PURE__ */ new Set();
|
|
3712
|
+
this._cells.set(key, bucket);
|
|
3713
|
+
}
|
|
3714
|
+
bucket.add(id);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
/**
|
|
3718
|
+
* Return the set of entity ids relevant to a single client.
|
|
3719
|
+
*
|
|
3720
|
+
* Relevance is the *union* of:
|
|
3721
|
+
* 1. Entities whose cell overlaps the client's bounding sphere
|
|
3722
|
+
* 2. Entities in the `alwaysRelevant` set
|
|
3723
|
+
* 3. Entities owned by this client
|
|
3724
|
+
*/
|
|
3725
|
+
getRelevantEntities(clientPosition, clientId, owners, overrideRadius) {
|
|
3726
|
+
const radius = overrideRadius ?? this._defaultRadius;
|
|
3727
|
+
const cx = clientPosition.x;
|
|
3728
|
+
const cy = clientPosition.y;
|
|
3729
|
+
const cz = clientPosition.z ?? 0;
|
|
3730
|
+
const result = /* @__PURE__ */ new Set();
|
|
3731
|
+
const minCellX = Math.floor((cx - radius) / this._cellSize);
|
|
3732
|
+
const maxCellX = Math.floor((cx + radius) / this._cellSize);
|
|
3733
|
+
const minCellY = Math.floor((cy - radius) / this._cellSize);
|
|
3734
|
+
const maxCellY = Math.floor((cy + radius) / this._cellSize);
|
|
3735
|
+
const minCellZ = Math.floor((cz - radius) / this._cellSize);
|
|
3736
|
+
const maxCellZ = Math.floor((cz + radius) / this._cellSize);
|
|
3737
|
+
for (let ix = minCellX; ix <= maxCellX; ix++) {
|
|
3738
|
+
for (let iy = minCellY; iy <= maxCellY; iy++) {
|
|
3739
|
+
for (let iz = minCellZ; iz <= maxCellZ; iz++) {
|
|
3740
|
+
const key = `${ix},${iy},${iz}`;
|
|
3741
|
+
const bucket = this._cells.get(key);
|
|
3742
|
+
if (bucket) {
|
|
3743
|
+
for (const entityId of bucket) {
|
|
3744
|
+
result.add(entityId);
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
for (const id of this._alwaysRelevant) {
|
|
3751
|
+
if (this._entityPositions.has(id)) {
|
|
3752
|
+
result.add(id);
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
for (const [entityId, ownerId] of owners) {
|
|
3756
|
+
if (ownerId === clientId && this._entityPositions.has(entityId)) {
|
|
3757
|
+
result.add(entityId);
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
return result;
|
|
3761
|
+
}
|
|
3762
|
+
/**
|
|
3763
|
+
* Build a filter callback compatible with
|
|
3764
|
+
* `HostAuthority.setInterestFilter`.
|
|
3765
|
+
*
|
|
3766
|
+
* The returned function closes over a single relevance pass for every
|
|
3767
|
+
* known client so that per-entity filtering during broadcast is a cheap
|
|
3768
|
+
* `Set.has` lookup.
|
|
3769
|
+
*
|
|
3770
|
+
* @param clientPositions peerId -> position of that client's camera/player
|
|
3771
|
+
* @param owners entityId -> ownerPeerId
|
|
3772
|
+
*/
|
|
3773
|
+
createFilter(clientPositions, owners) {
|
|
3774
|
+
const relevanceSets = /* @__PURE__ */ new Map();
|
|
3775
|
+
for (const [peerId, pos] of clientPositions) {
|
|
3776
|
+
relevanceSets.set(
|
|
3777
|
+
peerId,
|
|
3778
|
+
this.getRelevantEntities(pos, peerId, owners)
|
|
3779
|
+
);
|
|
3780
|
+
}
|
|
3781
|
+
return (entityId, peerId) => {
|
|
3782
|
+
const set = relevanceSets.get(peerId);
|
|
3783
|
+
if (!set) return true;
|
|
3784
|
+
return set.has(entityId);
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
/** Remove all data from the grid. */
|
|
3788
|
+
clear() {
|
|
3789
|
+
this._cells.clear();
|
|
3790
|
+
this._entityPositions.clear();
|
|
3791
|
+
}
|
|
3792
|
+
// ── Private helpers ──
|
|
3793
|
+
_cellKey(x, y, z) {
|
|
3794
|
+
const cx = Math.floor(x / this._cellSize);
|
|
3795
|
+
const cy = Math.floor(y / this._cellSize);
|
|
3796
|
+
const cz = Math.floor(z / this._cellSize);
|
|
3797
|
+
return `${cx},${cy},${cz}`;
|
|
3798
|
+
}
|
|
3799
|
+
};
|
|
3800
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3801
|
+
0 && (module.exports = {
|
|
3802
|
+
DebugOverlay,
|
|
3803
|
+
FirebaseStrategy,
|
|
3804
|
+
InterestManager,
|
|
3805
|
+
MqttStrategy,
|
|
3806
|
+
MultiplayerBridge,
|
|
3807
|
+
MultiplayerProvider,
|
|
3808
|
+
NetworkSimulator,
|
|
3809
|
+
useHost,
|
|
3810
|
+
useLobby,
|
|
3811
|
+
useMultiplayer,
|
|
3812
|
+
useNetworkEvents,
|
|
3813
|
+
useNetworkState,
|
|
3814
|
+
usePlayers,
|
|
3815
|
+
useRoom
|
|
3816
|
+
});
|
|
3817
|
+
//# sourceMappingURL=index.js.map
|