@carverjs/multiplayer 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/dist/InputBuffer-J6XT_Tt0.d.mts +61 -0
- package/dist/InputBuffer-V7XfHbc6.d.ts +61 -0
- package/dist/{NetworkManager-nvVAOr1O.d.ts → NetworkManager-D-DxFgdM.d.mts} +66 -14
- package/dist/{NetworkManager-DrKM2tEx.d.mts → NetworkManager-DH9uGVMg.d.ts} +66 -14
- package/dist/{chunk-UD6FDZMX.mjs → chunk-GOTAQDBJ.mjs} +47 -4
- package/dist/chunk-GOTAQDBJ.mjs.map +1 -0
- package/dist/{chunk-3KT73N2S.mjs → chunk-LPNEP2VH.mjs} +0 -0
- package/dist/chunk-LPNEP2VH.mjs.map +1 -0
- package/dist/{chunk-EO3YNPRQ.mjs → chunk-Q25TJEY4.mjs} +494 -204
- package/dist/chunk-Q25TJEY4.mjs.map +1 -0
- package/dist/{firebase-CPu87KA0.d.ts → firebase-B5MgLlHk.d.ts} +6 -1
- package/dist/{firebase-PE6MxGdJ.d.mts → firebase-GrbVrNgs.d.mts} +6 -1
- package/dist/index.d.mts +27 -6
- package/dist/index.d.ts +27 -6
- package/dist/index.js +821 -258
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -37
- package/dist/index.mjs.map +1 -1
- package/dist/strategy.d.mts +2 -2
- package/dist/strategy.d.ts +2 -2
- package/dist/strategy.js +46 -3
- package/dist/strategy.js.map +1 -1
- package/dist/strategy.mjs +1 -1
- package/dist/sync.d.mts +134 -50
- package/dist/sync.d.ts +134 -50
- package/dist/sync.js +499 -205
- package/dist/sync.js.map +1 -1
- package/dist/sync.mjs +15 -3
- package/dist/transport.d.mts +0 -0
- package/dist/transport.d.ts +0 -0
- package/dist/transport.js +0 -0
- package/dist/transport.js.map +1 -1
- package/dist/transport.mjs +2 -2
- package/dist/{types-5LHBOW08.d.mts → types-hNfCIBzj.d.mts} +7 -0
- package/dist/{types-5LHBOW08.d.ts → types-hNfCIBzj.d.ts} +7 -0
- package/dist/types.d.mts +2 -2
- package/dist/types.d.ts +2 -2
- package/dist/types.js.map +1 -1
- package/package.json +26 -5
- package/dist/chunk-3KT73N2S.mjs.map +0 -1
- package/dist/chunk-EO3YNPRQ.mjs.map +0 -1
- package/dist/chunk-UD6FDZMX.mjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -32,11 +32,13 @@ var src_exports = {};
|
|
|
32
32
|
__export(src_exports, {
|
|
33
33
|
DebugOverlay: () => DebugOverlay,
|
|
34
34
|
FirebaseStrategy: () => FirebaseStrategy,
|
|
35
|
+
InputBuffer: () => InputBuffer,
|
|
35
36
|
InterestManager: () => InterestManager,
|
|
36
37
|
MqttStrategy: () => MqttStrategy,
|
|
37
38
|
MultiplayerBridge: () => MultiplayerBridge,
|
|
38
39
|
MultiplayerProvider: () => MultiplayerProvider,
|
|
39
40
|
NetworkSimulator: () => NetworkSimulator,
|
|
41
|
+
computeJustPressed: () => computeJustPressed,
|
|
40
42
|
useHost: () => useHost,
|
|
41
43
|
useLobby: () => useLobby,
|
|
42
44
|
useMultiplayer: () => useMultiplayer,
|
|
@@ -128,6 +130,7 @@ var MqttStrategy = class {
|
|
|
128
130
|
this._peerExpiryTimer = null;
|
|
129
131
|
this._lobbySubscribed = false;
|
|
130
132
|
this._destroyed = false;
|
|
133
|
+
this._lastAnnouncement = null;
|
|
131
134
|
this.selfId = generatePeerId();
|
|
132
135
|
this._appId = appId;
|
|
133
136
|
this._config = config;
|
|
@@ -193,9 +196,19 @@ var MqttStrategy = class {
|
|
|
193
196
|
removeFromArray(this._onLobby, cb);
|
|
194
197
|
};
|
|
195
198
|
}
|
|
199
|
+
updateRoomOccupancy(roomId, playerCount, state) {
|
|
200
|
+
const ann = this._lastAnnouncement;
|
|
201
|
+
if (!ann || ann.roomId !== roomId || !this._client) return;
|
|
202
|
+
ann.playerCount = playerCount;
|
|
203
|
+
if (state) ann.state = state;
|
|
204
|
+
ann.lastSeen = Date.now();
|
|
205
|
+
const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
|
|
206
|
+
this._client.publish(topic, JSON.stringify(ann), { retain: true, qos: 1 });
|
|
207
|
+
}
|
|
196
208
|
announceRoom(announcement) {
|
|
197
209
|
if (!this._client) return;
|
|
198
210
|
const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
|
|
211
|
+
this._lastAnnouncement = announcement;
|
|
199
212
|
announcement.lastSeen = Date.now();
|
|
200
213
|
this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
|
|
201
214
|
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
@@ -393,6 +406,8 @@ var FirebaseStrategy = class {
|
|
|
393
406
|
// State
|
|
394
407
|
this._knownPeers = /* @__PURE__ */ new Set();
|
|
395
408
|
this._lobbyAnnounceTimer = null;
|
|
409
|
+
this._lastAnnouncement = null;
|
|
410
|
+
this._lobbyWired = false;
|
|
396
411
|
this._destroyed = false;
|
|
397
412
|
this.selfId = generatePeerId();
|
|
398
413
|
this._appId = appId;
|
|
@@ -408,7 +423,7 @@ var FirebaseStrategy = class {
|
|
|
408
423
|
this._joinGeneration++;
|
|
409
424
|
this._roomId = roomId;
|
|
410
425
|
this._peerMeta = peerMeta;
|
|
411
|
-
const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
|
|
426
|
+
const { ref, set, onChildAdded, onChildRemoved, onDisconnect, onValue, remove } = this._fb;
|
|
412
427
|
const paths = firebasePaths(this._appId, roomId, this.selfId);
|
|
413
428
|
await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
|
|
414
429
|
});
|
|
@@ -419,6 +434,18 @@ var FirebaseStrategy = class {
|
|
|
419
434
|
ts: Date.now()
|
|
420
435
|
});
|
|
421
436
|
onDisconnect(presenceRef).remove();
|
|
437
|
+
const generation = this._joinGeneration;
|
|
438
|
+
const connectedRef = ref(this._db, ".info/connected");
|
|
439
|
+
const connectedUnsub = onValue(connectedRef, (snap) => {
|
|
440
|
+
if (snap.val() !== true) return;
|
|
441
|
+
if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
|
|
442
|
+
onDisconnect(presenceRef).remove().then(() => {
|
|
443
|
+
if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
|
|
444
|
+
return set(presenceRef, { peerId: this.selfId, meta: peerMeta, ts: Date.now() });
|
|
445
|
+
}).catch(() => {
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
this._listeners.push(() => connectedUnsub());
|
|
422
449
|
const peersRef = ref(this._db, paths.peers);
|
|
423
450
|
const addedUnsub = onChildAdded(peersRef, (snapshot) => {
|
|
424
451
|
const data = snapshot.val();
|
|
@@ -481,6 +508,12 @@ var FirebaseStrategy = class {
|
|
|
481
508
|
}
|
|
482
509
|
subscribeToLobby(cb) {
|
|
483
510
|
this._onLobby.push(cb);
|
|
511
|
+
if (this._lobbyWired) {
|
|
512
|
+
return () => {
|
|
513
|
+
removeFromArray(this._onLobby, cb);
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
this._lobbyWired = true;
|
|
484
517
|
this._ensureInit().then(() => {
|
|
485
518
|
if (!this._db || !this._fb || this._destroyed) return;
|
|
486
519
|
const { ref, onValue } = this._fb;
|
|
@@ -508,14 +541,25 @@ var FirebaseStrategy = class {
|
|
|
508
541
|
if (!this._db || !this._fb) return;
|
|
509
542
|
const { ref, set } = this._fb;
|
|
510
543
|
const paths = firebasePaths(this._appId, announcement.roomId, "");
|
|
544
|
+
this._lastAnnouncement = announcement;
|
|
511
545
|
announcement.lastSeen = Date.now();
|
|
512
|
-
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
546
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
|
|
513
547
|
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
514
548
|
this._lobbyAnnounceTimer = setInterval(() => {
|
|
515
549
|
announcement.lastSeen = Date.now();
|
|
516
|
-
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
550
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
|
|
517
551
|
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
518
552
|
}
|
|
553
|
+
updateRoomOccupancy(roomId, playerCount, state) {
|
|
554
|
+
const ann = this._lastAnnouncement;
|
|
555
|
+
if (!ann || ann.roomId !== roomId || !this._db || !this._fb) return;
|
|
556
|
+
ann.playerCount = playerCount;
|
|
557
|
+
if (state) ann.state = state;
|
|
558
|
+
ann.lastSeen = Date.now();
|
|
559
|
+
const { ref, set } = this._fb;
|
|
560
|
+
const paths = firebasePaths(this._appId, roomId, "");
|
|
561
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(ann));
|
|
562
|
+
}
|
|
519
563
|
removeRoomAnnouncement(roomId) {
|
|
520
564
|
if (!this._db || !this._fb) return;
|
|
521
565
|
const { ref, remove } = this._fb;
|
|
@@ -618,6 +662,7 @@ function sanitizeForFirebase(obj) {
|
|
|
618
662
|
if (typeof obj === "object" && obj !== null) {
|
|
619
663
|
const result = {};
|
|
620
664
|
for (const [key, value] of Object.entries(obj)) {
|
|
665
|
+
if (value === void 0) continue;
|
|
621
666
|
result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
|
|
622
667
|
}
|
|
623
668
|
return result;
|
|
@@ -665,6 +710,11 @@ var _TickKeeper = class _TickKeeper {
|
|
|
665
710
|
this._serverTick = serverTick;
|
|
666
711
|
this._updateDriftCorrection();
|
|
667
712
|
}
|
|
713
|
+
/** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
|
|
714
|
+
snapTick(tick) {
|
|
715
|
+
this._tick = tick;
|
|
716
|
+
this._updateDriftCorrection();
|
|
717
|
+
}
|
|
668
718
|
/**
|
|
669
719
|
* Accumulate time and return the number of fixed ticks to process.
|
|
670
720
|
* Call this once per render frame with the raw frame delta.
|
|
@@ -789,15 +839,16 @@ var Codec = class {
|
|
|
789
839
|
}
|
|
790
840
|
return changed.length > 0 ? changed : null;
|
|
791
841
|
}
|
|
792
|
-
/** Serialize a delta snapshot packet */
|
|
793
|
-
serializeDelta(tick, baseTick, current, baseline) {
|
|
842
|
+
/** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
|
|
843
|
+
serializeDelta(tick, baseTick, current, baseline, hostInput) {
|
|
794
844
|
const delta = this.computeDelta(current, baseline);
|
|
795
845
|
if (!delta) return null;
|
|
796
846
|
const packet = {
|
|
797
847
|
t: tick,
|
|
798
848
|
b: baseline ? baseTick : -1,
|
|
799
849
|
// -1 = keyframe
|
|
800
|
-
s: this.serialize(delta)
|
|
850
|
+
s: this.serialize(delta),
|
|
851
|
+
...hostInput !== void 0 ? { hi: hostInput } : {}
|
|
801
852
|
};
|
|
802
853
|
return (0, import_msgpackr.pack)(packet);
|
|
803
854
|
}
|
|
@@ -807,7 +858,8 @@ var Codec = class {
|
|
|
807
858
|
return {
|
|
808
859
|
tick: packet.t,
|
|
809
860
|
baseTick: packet.b,
|
|
810
|
-
entities: this.deserialize(packet.s)
|
|
861
|
+
entities: this.deserialize(packet.s),
|
|
862
|
+
hostInput: packet.hi
|
|
811
863
|
};
|
|
812
864
|
}
|
|
813
865
|
_hasChanged(current, prev) {
|
|
@@ -1119,6 +1171,7 @@ function MultiplayerProvider({
|
|
|
1119
1171
|
}) {
|
|
1120
1172
|
const managerRef = (0, import_react2.useRef)(null);
|
|
1121
1173
|
const strategyRef = (0, import_react2.useRef)(null);
|
|
1174
|
+
const destroyTimerRef = (0, import_react2.useRef)(null);
|
|
1122
1175
|
if (!managerRef.current) {
|
|
1123
1176
|
managerRef.current = new NetworkManager();
|
|
1124
1177
|
}
|
|
@@ -1126,11 +1179,18 @@ function MultiplayerProvider({
|
|
|
1126
1179
|
strategyRef.current = createStrategy(appId, strategyConfig);
|
|
1127
1180
|
}
|
|
1128
1181
|
(0, import_react2.useEffect)(() => {
|
|
1182
|
+
if (destroyTimerRef.current) {
|
|
1183
|
+
clearTimeout(destroyTimerRef.current);
|
|
1184
|
+
destroyTimerRef.current = null;
|
|
1185
|
+
}
|
|
1129
1186
|
return () => {
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1187
|
+
destroyTimerRef.current = setTimeout(() => {
|
|
1188
|
+
destroyTimerRef.current = null;
|
|
1189
|
+
strategyRef.current?.destroy();
|
|
1190
|
+
strategyRef.current = null;
|
|
1191
|
+
managerRef.current?.destroy();
|
|
1192
|
+
managerRef.current = null;
|
|
1193
|
+
}, 0);
|
|
1134
1194
|
};
|
|
1135
1195
|
}, []);
|
|
1136
1196
|
const value = {
|
|
@@ -1220,8 +1280,8 @@ var PeerConnection = class {
|
|
|
1220
1280
|
this._events.onStateChange(newState);
|
|
1221
1281
|
}
|
|
1222
1282
|
}
|
|
1223
|
-
async createOffer() {
|
|
1224
|
-
const offer = await this._connection.createOffer();
|
|
1283
|
+
async createOffer(options) {
|
|
1284
|
+
const offer = await this._connection.createOffer(options);
|
|
1225
1285
|
await this._connection.setLocalDescription(offer);
|
|
1226
1286
|
return offer;
|
|
1227
1287
|
}
|
|
@@ -1297,8 +1357,17 @@ var PeerConnection = class {
|
|
|
1297
1357
|
|
|
1298
1358
|
// src/transport/webrtc/WebRTCTransport.ts
|
|
1299
1359
|
var ROOM_CONTROL_CHANNEL = "carver:room-control";
|
|
1300
|
-
function electHost(peerIds) {
|
|
1301
|
-
|
|
1360
|
+
function electHost(peerIds, rankOf) {
|
|
1361
|
+
let best = peerIds[0];
|
|
1362
|
+
let bestRank = rankOf(best);
|
|
1363
|
+
for (const id of peerIds) {
|
|
1364
|
+
const rank = rankOf(id);
|
|
1365
|
+
if (rank < bestRank || rank === bestRank && id < best) {
|
|
1366
|
+
best = id;
|
|
1367
|
+
bestRank = rank;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return best;
|
|
1302
1371
|
}
|
|
1303
1372
|
var WebRTCTransport = class {
|
|
1304
1373
|
/**
|
|
@@ -1326,6 +1395,11 @@ var WebRTCTransport = class {
|
|
|
1326
1395
|
this._playerMap = /* @__PURE__ */ new Map();
|
|
1327
1396
|
this._initialPeers = [];
|
|
1328
1397
|
this._strategyUnsubs = [];
|
|
1398
|
+
// ── Private: WebRTC peer management ──
|
|
1399
|
+
/** Grace timers for transient ICE 'disconnected' states */
|
|
1400
|
+
this._disconnectTimers = /* @__PURE__ */ new Map();
|
|
1401
|
+
/** Sends queued while a data channel is not yet open: key = peerIdchannel */
|
|
1402
|
+
this._pendingSends = /* @__PURE__ */ new Map();
|
|
1329
1403
|
this._strategy = strategy;
|
|
1330
1404
|
this._peerId = strategy.selfId;
|
|
1331
1405
|
this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
|
|
@@ -1421,10 +1495,7 @@ var WebRTCTransport = class {
|
|
|
1421
1495
|
);
|
|
1422
1496
|
this._strategyUnsubs.push(
|
|
1423
1497
|
this._strategy.onPeerLeft((peerId) => {
|
|
1424
|
-
this.
|
|
1425
|
-
this._playerMap.delete(peerId);
|
|
1426
|
-
this._electAndSetHost();
|
|
1427
|
-
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1498
|
+
this._onStrategyPeerLeft(peerId);
|
|
1428
1499
|
})
|
|
1429
1500
|
);
|
|
1430
1501
|
this._strategyUnsubs.push(
|
|
@@ -1537,16 +1608,17 @@ var WebRTCTransport = class {
|
|
|
1537
1608
|
_onStrategyPeerDiscovered(peerId, meta) {
|
|
1538
1609
|
this._connectToPeer(peerId);
|
|
1539
1610
|
this._peerSet.add(peerId);
|
|
1611
|
+
const existing = this._playerMap.get(peerId);
|
|
1540
1612
|
const player = {
|
|
1541
1613
|
peerId,
|
|
1542
|
-
displayName: meta.displayName ?? `Player-${peerId.slice(0, 4)}`,
|
|
1614
|
+
displayName: meta.displayName ?? existing?.displayName ?? `Player-${peerId.slice(0, 4)}`,
|
|
1543
1615
|
isHost: false,
|
|
1544
1616
|
isSelf: false,
|
|
1545
|
-
isReady: false,
|
|
1617
|
+
isReady: existing?.isReady ?? false,
|
|
1546
1618
|
isConnected: true,
|
|
1547
1619
|
metadata: meta,
|
|
1548
|
-
latencyMs: 0,
|
|
1549
|
-
joinedAt: Date.now()
|
|
1620
|
+
latencyMs: existing?.latencyMs ?? 0,
|
|
1621
|
+
joinedAt: existing?.joinedAt ?? Date.now()
|
|
1550
1622
|
};
|
|
1551
1623
|
this._playerMap.set(peerId, player);
|
|
1552
1624
|
this._electAndSetHost();
|
|
@@ -1626,6 +1698,7 @@ var WebRTCTransport = class {
|
|
|
1626
1698
|
case "request-room-state": {
|
|
1627
1699
|
if (!this._isHost || !this._room) break;
|
|
1628
1700
|
this._room.state = msg.state;
|
|
1701
|
+
this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
|
|
1629
1702
|
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1630
1703
|
break;
|
|
1631
1704
|
}
|
|
@@ -1670,9 +1743,20 @@ var WebRTCTransport = class {
|
|
|
1670
1743
|
this._handleControlMessage(msg, this._peerId);
|
|
1671
1744
|
}
|
|
1672
1745
|
// ── Private: Host election ──
|
|
1746
|
+
/**
|
|
1747
|
+
* Host-election rank for a peer: lower wins. Reads `metadata.hostPriority`
|
|
1748
|
+
* (advertised via player metadata / signaling presence, so every peer sees
|
|
1749
|
+
* the same value). Peers that don't advertise a priority rank last, which
|
|
1750
|
+
* preserves the legacy lowest-peerId election among them.
|
|
1751
|
+
*/
|
|
1752
|
+
_hostRank(peerId) {
|
|
1753
|
+
const meta = this._playerMap.get(peerId)?.metadata;
|
|
1754
|
+
const priority = meta?.hostPriority;
|
|
1755
|
+
return typeof priority === "number" && Number.isFinite(priority) ? priority : Number.POSITIVE_INFINITY;
|
|
1756
|
+
}
|
|
1673
1757
|
_electAndSetHost() {
|
|
1674
1758
|
const allIds = [this._peerId, ...this._peerSet];
|
|
1675
|
-
const newHostId = electHost(allIds);
|
|
1759
|
+
const newHostId = electHost(allIds, (id) => this._hostRank(id));
|
|
1676
1760
|
const changed = newHostId !== this._hostId;
|
|
1677
1761
|
this._hostId = newHostId;
|
|
1678
1762
|
this._isHost = newHostId === this._peerId;
|
|
@@ -1682,16 +1766,23 @@ var WebRTCTransport = class {
|
|
|
1682
1766
|
if (this._room) {
|
|
1683
1767
|
this._room.hostId = newHostId;
|
|
1684
1768
|
this._room.playerCount = this._playerMap.size;
|
|
1769
|
+
this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
|
|
1685
1770
|
}
|
|
1686
1771
|
if (changed) {
|
|
1687
1772
|
for (const cb of this._callbacks.onHostChanged) cb(newHostId);
|
|
1688
1773
|
}
|
|
1689
1774
|
}
|
|
1690
|
-
// ── Private: WebRTC peer management ──
|
|
1691
1775
|
_connectToPeer(peerId) {
|
|
1692
1776
|
if (this._peers.has(peerId)) return;
|
|
1693
1777
|
const peer = new PeerConnection(peerId, this._iceConfig, {
|
|
1694
1778
|
onStateChange: (state) => {
|
|
1779
|
+
if (state === "connected") {
|
|
1780
|
+
const timer = this._disconnectTimers.get(peerId);
|
|
1781
|
+
if (timer) {
|
|
1782
|
+
clearTimeout(timer);
|
|
1783
|
+
this._disconnectTimers.delete(peerId);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1695
1786
|
if (state === "connected" && this._isHost && this._room) {
|
|
1696
1787
|
const syncMsg = {
|
|
1697
1788
|
type: "sync-state",
|
|
@@ -1703,10 +1794,7 @@ var WebRTCTransport = class {
|
|
|
1703
1794
|
}, 100);
|
|
1704
1795
|
}
|
|
1705
1796
|
if (state === "failed" || state === "disconnected") {
|
|
1706
|
-
this.
|
|
1707
|
-
this._playerMap.delete(peerId);
|
|
1708
|
-
this._electAndSetHost();
|
|
1709
|
-
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1797
|
+
this._handleConnectionDrop(peerId, state);
|
|
1710
1798
|
}
|
|
1711
1799
|
},
|
|
1712
1800
|
onDataChannel: (channel) => {
|
|
@@ -1766,21 +1854,90 @@ var WebRTCTransport = class {
|
|
|
1766
1854
|
} catch {
|
|
1767
1855
|
}
|
|
1768
1856
|
};
|
|
1857
|
+
dataChannel.onopen = () => this._flushPendingSends(peerId, channelName);
|
|
1858
|
+
if (dataChannel.readyState === "open") this._flushPendingSends(peerId, channelName);
|
|
1769
1859
|
}
|
|
1770
1860
|
_sendOnChannel(channelName, data, target) {
|
|
1771
1861
|
const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
|
|
1772
1862
|
const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
|
|
1773
1863
|
for (const pid of targets) {
|
|
1774
1864
|
const peer = this._peers.get(pid);
|
|
1775
|
-
|
|
1865
|
+
if (!peer) continue;
|
|
1866
|
+
const ch = peer.getDataChannel(channelName);
|
|
1776
1867
|
if (ch?.readyState === "open") {
|
|
1777
1868
|
try {
|
|
1778
1869
|
ch.send(serialized);
|
|
1779
1870
|
} catch {
|
|
1780
1871
|
}
|
|
1872
|
+
} else {
|
|
1873
|
+
const key = pid + "\0" + channelName;
|
|
1874
|
+
const q = this._pendingSends.get(key) ?? [];
|
|
1875
|
+
if (q.length < 200) q.push(serialized);
|
|
1876
|
+
this._pendingSends.set(key, q);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
_flushPendingSends(peerId, channelName) {
|
|
1881
|
+
const key = peerId + "\0" + channelName;
|
|
1882
|
+
const q = this._pendingSends.get(key);
|
|
1883
|
+
if (!q || q.length === 0) return;
|
|
1884
|
+
const ch = this._peers.get(peerId)?.getDataChannel(channelName);
|
|
1885
|
+
if (ch?.readyState !== "open") return;
|
|
1886
|
+
this._pendingSends.delete(key);
|
|
1887
|
+
for (const msg of q) {
|
|
1888
|
+
try {
|
|
1889
|
+
ch.send(msg);
|
|
1890
|
+
} catch {
|
|
1891
|
+
break;
|
|
1781
1892
|
}
|
|
1782
1893
|
}
|
|
1783
1894
|
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Signaling presence reports a peer left. This is NOT authoritative for an
|
|
1897
|
+
* established session: a transient signaling (Firebase/MQTT) disconnect can
|
|
1898
|
+
* remove a peer's presence while the direct WebRTC link is perfectly healthy.
|
|
1899
|
+
* Keep the peer if its connection is still 'connected'; genuine departures
|
|
1900
|
+
* are also surfaced by the WebRTC connection-state machine
|
|
1901
|
+
* (failed/disconnected -> _handleConnectionDrop), which tears the peer down.
|
|
1902
|
+
*/
|
|
1903
|
+
_onStrategyPeerLeft(peerId) {
|
|
1904
|
+
const peer = this._peers.get(peerId);
|
|
1905
|
+
if (peer && peer.state === "connected") return;
|
|
1906
|
+
this._teardownPeer(peerId);
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Self-heal a dropped P2P link instead of tearing it down immediately. ICE
|
|
1910
|
+
* 'disconnected' is frequently transient and 'failed' can often recover via
|
|
1911
|
+
* an ICE restart. The deterministic initiator (lower peerId) renegotiates by
|
|
1912
|
+
* sending a fresh offer with iceRestart; the answerer replies through the
|
|
1913
|
+
* existing _handleSignal('offer') path. A grace timer is the fallback: tear
|
|
1914
|
+
* the peer down only if it doesn't return to 'connected'. Recovery to
|
|
1915
|
+
* 'connected' cancels the timer (see onStateChange in _connectToPeer).
|
|
1916
|
+
*/
|
|
1917
|
+
_handleConnectionDrop(peerId, state) {
|
|
1918
|
+
if (this._disconnectTimers.has(peerId)) return;
|
|
1919
|
+
if (this._peerId < peerId) {
|
|
1920
|
+
const peer = this._peers.get(peerId);
|
|
1921
|
+
peer?.createOffer({ iceRestart: true }).then((offer) => {
|
|
1922
|
+
if (this._peers.get(peerId) === peer) {
|
|
1923
|
+
this._strategy.signal(peerId, { type: "offer", sdp: offer });
|
|
1924
|
+
}
|
|
1925
|
+
}).catch(() => {
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
const graceMs = state === "failed" ? 8e3 : 5e3;
|
|
1929
|
+
this._disconnectTimers.set(peerId, setTimeout(() => {
|
|
1930
|
+
this._disconnectTimers.delete(peerId);
|
|
1931
|
+
const p = this._peers.get(peerId);
|
|
1932
|
+
if (p && p.state !== "connected") this._teardownPeer(peerId);
|
|
1933
|
+
}, graceMs));
|
|
1934
|
+
}
|
|
1935
|
+
_teardownPeer(peerId) {
|
|
1936
|
+
this._removePeer(peerId);
|
|
1937
|
+
this._playerMap.delete(peerId);
|
|
1938
|
+
this._electAndSetHost();
|
|
1939
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1940
|
+
}
|
|
1784
1941
|
_removePeer(peerId) {
|
|
1785
1942
|
const peer = this._peers.get(peerId);
|
|
1786
1943
|
if (peer) {
|
|
@@ -1789,6 +1946,14 @@ var WebRTCTransport = class {
|
|
|
1789
1946
|
}
|
|
1790
1947
|
this._peerSet.delete(peerId);
|
|
1791
1948
|
this._rateLimitCounters.delete(peerId);
|
|
1949
|
+
const timer = this._disconnectTimers.get(peerId);
|
|
1950
|
+
if (timer) {
|
|
1951
|
+
clearTimeout(timer);
|
|
1952
|
+
this._disconnectTimers.delete(peerId);
|
|
1953
|
+
}
|
|
1954
|
+
for (const key of [...this._pendingSends.keys()]) {
|
|
1955
|
+
if (key.startsWith(peerId + "\0")) this._pendingSends.delete(key);
|
|
1956
|
+
}
|
|
1792
1957
|
}
|
|
1793
1958
|
_checkRateLimit(peerId) {
|
|
1794
1959
|
const now = Date.now();
|
|
@@ -1970,7 +2135,7 @@ function announcementToRoom(ann) {
|
|
|
1970
2135
|
isPrivate: ann.isPrivate,
|
|
1971
2136
|
metadata: ann.metadata,
|
|
1972
2137
|
createdAt: ann.createdAt,
|
|
1973
|
-
state: "lobby"
|
|
2138
|
+
state: ann.state ?? "lobby"
|
|
1974
2139
|
};
|
|
1975
2140
|
}
|
|
1976
2141
|
function useLobby(options) {
|
|
@@ -2016,7 +2181,7 @@ function useLobby(options) {
|
|
|
2016
2181
|
hostId: strategy.selfId,
|
|
2017
2182
|
playerCount: 0,
|
|
2018
2183
|
maxPlayers: config.maxPlayers ?? 8,
|
|
2019
|
-
gameMode: config.metadata?.gameMode,
|
|
2184
|
+
gameMode: config.gameMode ?? config.metadata?.gameMode,
|
|
2020
2185
|
isPrivate: config.isPrivate ?? false,
|
|
2021
2186
|
metadata: config.metadata ?? {},
|
|
2022
2187
|
createdAt: Date.now(),
|
|
@@ -2246,8 +2411,9 @@ var HostAuthority = class {
|
|
|
2246
2411
|
/**
|
|
2247
2412
|
* Called every fixed tick by the sync engine.
|
|
2248
2413
|
* Collects entity states and decides whether to broadcast.
|
|
2414
|
+
* `hostInput` (prediction mode) is embedded in the snapshot packet as `hi`.
|
|
2249
2415
|
*/
|
|
2250
|
-
tick(currentTick, entities, delta) {
|
|
2416
|
+
tick(currentTick, entities, delta, hostInput) {
|
|
2251
2417
|
this._tick = currentTick;
|
|
2252
2418
|
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2253
2419
|
this._broadcastAccumulator += delta;
|
|
@@ -2255,16 +2421,16 @@ var HostAuthority = class {
|
|
|
2255
2421
|
if (this._broadcastAccumulator < broadcastInterval) return;
|
|
2256
2422
|
this._broadcastAccumulator -= broadcastInterval;
|
|
2257
2423
|
for (const peerId of this._transport.peers) {
|
|
2258
|
-
this._broadcastToClient(peerId, currentTick, entities);
|
|
2424
|
+
this._broadcastToClient(peerId, currentTick, entities, hostInput);
|
|
2259
2425
|
}
|
|
2260
2426
|
}
|
|
2261
2427
|
/** Force a keyframe broadcast to all clients (e.g., after host migration) */
|
|
2262
|
-
forceKeyframe(currentTick, entities) {
|
|
2428
|
+
forceKeyframe(currentTick, entities, hostInput) {
|
|
2263
2429
|
this._clientBaselines.clear();
|
|
2264
2430
|
this._clientLastKeyframeTick.clear();
|
|
2265
2431
|
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2266
2432
|
for (const peerId of this._transport.peers) {
|
|
2267
|
-
this._broadcastToClient(peerId, currentTick, entities);
|
|
2433
|
+
this._broadcastToClient(peerId, currentTick, entities, hostInput);
|
|
2268
2434
|
}
|
|
2269
2435
|
}
|
|
2270
2436
|
destroy() {
|
|
@@ -2273,7 +2439,7 @@ var HostAuthority = class {
|
|
|
2273
2439
|
this._clientBaselines.clear();
|
|
2274
2440
|
this._clientLastKeyframeTick.clear();
|
|
2275
2441
|
}
|
|
2276
|
-
_broadcastToClient(peerId, currentTick, entities) {
|
|
2442
|
+
_broadcastToClient(peerId, currentTick, entities, hostInput) {
|
|
2277
2443
|
let clientEntities = entities;
|
|
2278
2444
|
if (this._interestFilter) {
|
|
2279
2445
|
clientEntities = /* @__PURE__ */ new Map();
|
|
@@ -2297,7 +2463,8 @@ var HostAuthority = class {
|
|
|
2297
2463
|
currentTick,
|
|
2298
2464
|
needsKeyframe ? -1 : clientBaseTick ?? -1,
|
|
2299
2465
|
clientEntities,
|
|
2300
|
-
baseline
|
|
2466
|
+
baseline,
|
|
2467
|
+
hostInput
|
|
2301
2468
|
);
|
|
2302
2469
|
if (packet) {
|
|
2303
2470
|
this._snapshotChannel.send(packet, peerId);
|
|
@@ -2319,6 +2486,8 @@ var ClientReceiver = class {
|
|
|
2319
2486
|
this._packetCount = 0;
|
|
2320
2487
|
// Entity state: full accumulated state from keyframes + deltas
|
|
2321
2488
|
this._fullState = /* @__PURE__ */ new Map();
|
|
2489
|
+
// Listeners fired after each snapshot is merged into the full world state
|
|
2490
|
+
this._snapshotListeners = [];
|
|
2322
2491
|
this._transport = transport;
|
|
2323
2492
|
this._codec = codec;
|
|
2324
2493
|
this._bufferSize = options?.bufferSize ?? 3;
|
|
@@ -2405,16 +2574,21 @@ var ClientReceiver = class {
|
|
|
2405
2574
|
requestKeyframe() {
|
|
2406
2575
|
this._ackChannel.send("-1");
|
|
2407
2576
|
}
|
|
2577
|
+
/** Register a listener fired after each snapshot is merged into the full world state */
|
|
2578
|
+
onSnapshot(cb) {
|
|
2579
|
+
this._snapshotListeners.push(cb);
|
|
2580
|
+
}
|
|
2408
2581
|
destroy() {
|
|
2409
2582
|
this._snapshotChannel.close();
|
|
2410
2583
|
this._ackChannel.close();
|
|
2411
2584
|
this._buffer = [];
|
|
2412
2585
|
this._interpolatedState.clear();
|
|
2413
2586
|
this._fullState.clear();
|
|
2587
|
+
this._snapshotListeners = [];
|
|
2414
2588
|
}
|
|
2415
2589
|
_handleSnapshot(data) {
|
|
2416
2590
|
try {
|
|
2417
|
-
const { tick, baseTick, entities } = this._codec.deserializePacket(data);
|
|
2591
|
+
const { tick, baseTick, entities, hostInput } = this._codec.deserializePacket(data);
|
|
2418
2592
|
const now = performance.now();
|
|
2419
2593
|
if (baseTick === -1) {
|
|
2420
2594
|
this._fullState.clear();
|
|
@@ -2430,15 +2604,19 @@ var ClientReceiver = class {
|
|
|
2430
2604
|
}
|
|
2431
2605
|
}
|
|
2432
2606
|
}
|
|
2607
|
+
const fullStateClone = new Map(this._fullState);
|
|
2433
2608
|
this._buffer.push({
|
|
2434
2609
|
tick,
|
|
2435
|
-
entities:
|
|
2610
|
+
entities: fullStateClone,
|
|
2436
2611
|
receivedAt: now
|
|
2437
2612
|
});
|
|
2438
2613
|
while (this._buffer.length > this._bufferSize * 2) {
|
|
2439
2614
|
this._buffer.shift();
|
|
2440
2615
|
}
|
|
2441
2616
|
this._ackChannel.send(String(tick));
|
|
2617
|
+
for (const cb of this._snapshotListeners) {
|
|
2618
|
+
cb(tick, fullStateClone, hostInput);
|
|
2619
|
+
}
|
|
2442
2620
|
this._lastSnapshotTime = now;
|
|
2443
2621
|
this._packetCount++;
|
|
2444
2622
|
} catch {
|
|
@@ -2596,6 +2774,8 @@ var SnapshotSync = class {
|
|
|
2596
2774
|
constructor(transport, codec, snapshotBuffer, options) {
|
|
2597
2775
|
this._hostAuthority = null;
|
|
2598
2776
|
this._clientReceiver = null;
|
|
2777
|
+
// Snapshot listeners survive host migration (forwarder re-attached on demote)
|
|
2778
|
+
this._snapshotListeners = [];
|
|
2599
2779
|
this._transport = transport;
|
|
2600
2780
|
this._codec = codec;
|
|
2601
2781
|
this._snapshotBuffer = snapshotBuffer;
|
|
@@ -2616,6 +2796,7 @@ var SnapshotSync = class {
|
|
|
2616
2796
|
extrapolateMs: options?.extrapolateMs,
|
|
2617
2797
|
is2D: options?.is2D
|
|
2618
2798
|
});
|
|
2799
|
+
this._attachSnapshotForwarder(this._clientReceiver);
|
|
2619
2800
|
}
|
|
2620
2801
|
}
|
|
2621
2802
|
get isHost() {
|
|
@@ -2628,8 +2809,12 @@ var SnapshotSync = class {
|
|
|
2628
2809
|
return this._clientReceiver;
|
|
2629
2810
|
}
|
|
2630
2811
|
/** Host: called every fixed tick to potentially broadcast state */
|
|
2631
|
-
hostTick(tick, entities, delta) {
|
|
2632
|
-
this._hostAuthority?.tick(tick, entities, delta);
|
|
2812
|
+
hostTick(tick, entities, delta, hostInput) {
|
|
2813
|
+
this._hostAuthority?.tick(tick, entities, delta, hostInput);
|
|
2814
|
+
}
|
|
2815
|
+
/** Register a listener fired after each merged snapshot (client side). */
|
|
2816
|
+
onSnapshot(cb) {
|
|
2817
|
+
this._snapshotListeners.push(cb);
|
|
2633
2818
|
}
|
|
2634
2819
|
/** Client: called every render frame to interpolate */
|
|
2635
2820
|
clientInterpolate(renderTime) {
|
|
@@ -2667,254 +2852,516 @@ var SnapshotSync = class {
|
|
|
2667
2852
|
is2D: options?.is2D
|
|
2668
2853
|
}
|
|
2669
2854
|
);
|
|
2855
|
+
this._attachSnapshotForwarder(this._clientReceiver);
|
|
2670
2856
|
}
|
|
2671
2857
|
destroy() {
|
|
2672
2858
|
this._hostAuthority?.destroy();
|
|
2673
2859
|
this._clientReceiver?.destroy();
|
|
2674
2860
|
this._hostAuthority = null;
|
|
2675
2861
|
this._clientReceiver = null;
|
|
2862
|
+
this._snapshotListeners = [];
|
|
2863
|
+
}
|
|
2864
|
+
// ── Private ──
|
|
2865
|
+
/** Forward receiver snapshots to registered listeners (survives host migration). */
|
|
2866
|
+
_attachSnapshotForwarder(receiver) {
|
|
2867
|
+
receiver.onSnapshot((t, e, hi) => {
|
|
2868
|
+
for (const l of this._snapshotListeners) l(t, e, hi);
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
};
|
|
2872
|
+
|
|
2873
|
+
// src/core/InputBuffer.ts
|
|
2874
|
+
var InputBuffer = class {
|
|
2875
|
+
constructor(neutralInput, historySize = 120) {
|
|
2876
|
+
/** Local player tick-keyed inputs (ring buffer). */
|
|
2877
|
+
this._local = /* @__PURE__ */ new Map();
|
|
2878
|
+
/** Last-received input per remote peer. */
|
|
2879
|
+
this._remotes = /* @__PURE__ */ new Map();
|
|
2880
|
+
/** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
|
|
2881
|
+
this._peerTicks = /* @__PURE__ */ new Map();
|
|
2882
|
+
this._neutral = { ...neutralInput };
|
|
2883
|
+
this._historySize = historySize;
|
|
2884
|
+
}
|
|
2885
|
+
// ── Local input ──
|
|
2886
|
+
/** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
|
|
2887
|
+
storeTick(tick, input) {
|
|
2888
|
+
this._local.set(tick, { ...input });
|
|
2889
|
+
this._local.delete(tick - this._historySize);
|
|
2890
|
+
}
|
|
2891
|
+
/** Return the local input at `tick`, or a neutral copy if out of range. */
|
|
2892
|
+
getTick(tick) {
|
|
2893
|
+
return this._local.get(tick) ?? { ...this._neutral };
|
|
2894
|
+
}
|
|
2895
|
+
/** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
|
|
2896
|
+
hasTick(tick) {
|
|
2897
|
+
return this._local.has(tick);
|
|
2898
|
+
}
|
|
2899
|
+
/** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
|
|
2900
|
+
getJustPressedZero() {
|
|
2901
|
+
const out = {};
|
|
2902
|
+
for (const key in this._neutral) {
|
|
2903
|
+
const v = this._neutral[key];
|
|
2904
|
+
out[key] = typeof v === "boolean" ? false : v;
|
|
2905
|
+
}
|
|
2906
|
+
return out;
|
|
2907
|
+
}
|
|
2908
|
+
// ── Remote (peer) inputs ──
|
|
2909
|
+
/**
|
|
2910
|
+
* Record a remote peer's input. If `tick` is given (the sender's local tick),
|
|
2911
|
+
* also store it in the per-peer ring buffer for rollback.
|
|
2912
|
+
*/
|
|
2913
|
+
setRemote(peerId, input, tick) {
|
|
2914
|
+
this._remotes.set(peerId, input);
|
|
2915
|
+
if (tick !== void 0) {
|
|
2916
|
+
let peerMap = this._peerTicks.get(peerId);
|
|
2917
|
+
if (!peerMap) {
|
|
2918
|
+
peerMap = /* @__PURE__ */ new Map();
|
|
2919
|
+
this._peerTicks.set(peerId, peerMap);
|
|
2920
|
+
}
|
|
2921
|
+
peerMap.set(tick, input);
|
|
2922
|
+
peerMap.delete(tick - this._historySize);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
/** Last-known input for a peer, or a neutral copy if never received. */
|
|
2926
|
+
getRemote(peerId) {
|
|
2927
|
+
return this._remotes.get(peerId) ?? { ...this._neutral };
|
|
2928
|
+
}
|
|
2929
|
+
/** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
|
|
2930
|
+
allRemotes() {
|
|
2931
|
+
return new Map(this._remotes);
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* Return a peer's exact input at the given tick (for rollback accuracy),
|
|
2935
|
+
* falling back to their last-known input when history does not reach that far.
|
|
2936
|
+
*/
|
|
2937
|
+
getRemoteAtTick(peerId, tick) {
|
|
2938
|
+
return this._peerTicks.get(peerId)?.get(tick) ?? this.getRemote(peerId);
|
|
2939
|
+
}
|
|
2940
|
+
/** Override the last-known input for a peer (does NOT touch tick history). */
|
|
2941
|
+
overrideRemote(peerId, input) {
|
|
2942
|
+
this._remotes.set(peerId, input);
|
|
2943
|
+
}
|
|
2944
|
+
/** Number of currently tracked remote peers. */
|
|
2945
|
+
get peerCount() {
|
|
2946
|
+
return this._remotes.size;
|
|
2947
|
+
}
|
|
2948
|
+
/** Iterate tracked peer IDs. */
|
|
2949
|
+
peerIds() {
|
|
2950
|
+
return this._remotes.keys();
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
|
|
2954
|
+
* Call when the room's peer list changes so stale peers stop receiving input.
|
|
2955
|
+
*/
|
|
2956
|
+
setPeerIds(peerIds) {
|
|
2957
|
+
const set = peerIds instanceof Set ? peerIds : new Set(peerIds);
|
|
2958
|
+
for (const id of this._remotes.keys()) {
|
|
2959
|
+
if (!set.has(id)) {
|
|
2960
|
+
this._remotes.delete(id);
|
|
2961
|
+
this._peerTicks.delete(id);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
// ── Lifecycle ──
|
|
2966
|
+
/** Clear local history, remote last-known inputs, and per-peer tick history. */
|
|
2967
|
+
clear() {
|
|
2968
|
+
this._local.clear();
|
|
2969
|
+
this._remotes.clear();
|
|
2970
|
+
this._peerTicks.clear();
|
|
2676
2971
|
}
|
|
2677
2972
|
};
|
|
2678
2973
|
|
|
2974
|
+
// src/core/InputUtils.ts
|
|
2975
|
+
function computeJustPressed(curr, prev) {
|
|
2976
|
+
const out = {};
|
|
2977
|
+
for (const key in curr) {
|
|
2978
|
+
const c = curr[key];
|
|
2979
|
+
out[key] = typeof c === "boolean" ? c === true && prev[key] !== true : c;
|
|
2980
|
+
}
|
|
2981
|
+
return out;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/sync/Rollback.ts
|
|
2985
|
+
var IDENTITY_QUAT = { x: 0, y: 0, z: 0, w: 1 };
|
|
2986
|
+
function quatMultiply(a, b) {
|
|
2987
|
+
return {
|
|
2988
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
2989
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
2990
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
2991
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
function quatInvert(q) {
|
|
2995
|
+
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
|
|
2996
|
+
}
|
|
2997
|
+
function quatNormalize(q) {
|
|
2998
|
+
const mag = Math.hypot(q.x, q.y, q.z, q.w);
|
|
2999
|
+
if (mag < 1e-12) return { ...IDENTITY_QUAT };
|
|
3000
|
+
return { x: q.x / mag, y: q.y / mag, z: q.z / mag, w: q.w / mag };
|
|
3001
|
+
}
|
|
3002
|
+
function quatAngle(q) {
|
|
3003
|
+
return 2 * Math.acos(Math.min(1, Math.abs(q.w)));
|
|
3004
|
+
}
|
|
3005
|
+
function quatScaleAngle(q, factor) {
|
|
3006
|
+
let n = quatNormalize(q);
|
|
3007
|
+
if (n.w < 0) n = { x: -n.x, y: -n.y, z: -n.z, w: -n.w };
|
|
3008
|
+
const angle = 2 * Math.acos(Math.min(1, n.w));
|
|
3009
|
+
if (angle < 1e-6) return { ...IDENTITY_QUAT };
|
|
3010
|
+
const s = Math.sin(angle / 2);
|
|
3011
|
+
const ax = n.x / s;
|
|
3012
|
+
const ay = n.y / s;
|
|
3013
|
+
const az = n.z / s;
|
|
3014
|
+
const na = angle * factor;
|
|
3015
|
+
const ns = Math.sin(na / 2);
|
|
3016
|
+
return { x: ax * ns, y: ay * ns, z: az * ns, w: Math.cos(na / 2) };
|
|
3017
|
+
}
|
|
3018
|
+
function applyRollback(params) {
|
|
3019
|
+
const {
|
|
3020
|
+
serverTick,
|
|
3021
|
+
serverState,
|
|
3022
|
+
localTick,
|
|
3023
|
+
localPeerId,
|
|
3024
|
+
inputs,
|
|
3025
|
+
currentErrors,
|
|
3026
|
+
driver,
|
|
3027
|
+
callback,
|
|
3028
|
+
dt,
|
|
3029
|
+
driftTargetTicks,
|
|
3030
|
+
maxRewindTicks,
|
|
3031
|
+
snapThreshold
|
|
3032
|
+
} = params;
|
|
3033
|
+
const preState = driver.captureState();
|
|
3034
|
+
const preMap = /* @__PURE__ */ new Map();
|
|
3035
|
+
for (const [id, s] of preState) {
|
|
3036
|
+
const e = currentErrors.get(id);
|
|
3037
|
+
const ex = e?.x ?? 0;
|
|
3038
|
+
const ey = e?.y ?? 0;
|
|
3039
|
+
if ("z" in s) {
|
|
3040
|
+
const errQ = e ? { x: e.qx, y: e.qy, z: e.qz, w: e.qw } : { ...IDENTITY_QUAT };
|
|
3041
|
+
preMap.set(id, {
|
|
3042
|
+
x: s.x + ex,
|
|
3043
|
+
y: s.y + ey,
|
|
3044
|
+
z: s.z + (e?.z ?? 0),
|
|
3045
|
+
a: 0,
|
|
3046
|
+
q: quatMultiply(errQ, { x: s.qx, y: s.qy, z: s.qz, w: s.qw }),
|
|
3047
|
+
is3D: true
|
|
3048
|
+
});
|
|
3049
|
+
} else {
|
|
3050
|
+
preMap.set(id, {
|
|
3051
|
+
x: s.x + ex,
|
|
3052
|
+
y: s.y + ey,
|
|
3053
|
+
z: 0,
|
|
3054
|
+
a: s.a + (e?.a ?? 0),
|
|
3055
|
+
q: { ...IDENTITY_QUAT },
|
|
3056
|
+
is3D: false
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
driver.applyState(serverState.values());
|
|
3061
|
+
const targetTick = serverTick + driftTargetTicks;
|
|
3062
|
+
const tickDiff = localTick - targetTick;
|
|
3063
|
+
let newLocalTick = localTick;
|
|
3064
|
+
let snapped = false;
|
|
3065
|
+
if (Math.abs(tickDiff) > maxRewindTicks) {
|
|
3066
|
+
newLocalTick = targetTick;
|
|
3067
|
+
snapped = true;
|
|
3068
|
+
} else {
|
|
3069
|
+
for (let i = serverTick + 1; i <= localTick; i++) {
|
|
3070
|
+
if (callback) {
|
|
3071
|
+
const tickInputs = /* @__PURE__ */ new Map();
|
|
3072
|
+
const justPressed = /* @__PURE__ */ new Map();
|
|
3073
|
+
for (const peerId of inputs.peerIds()) {
|
|
3074
|
+
if (peerId === localPeerId) continue;
|
|
3075
|
+
const curr = inputs.getRemoteAtTick(peerId, i);
|
|
3076
|
+
tickInputs.set(peerId, curr);
|
|
3077
|
+
justPressed.set(
|
|
3078
|
+
peerId,
|
|
3079
|
+
computeJustPressed(curr, inputs.getRemoteAtTick(peerId, i - 1))
|
|
3080
|
+
);
|
|
3081
|
+
}
|
|
3082
|
+
const localCurr = inputs.getTick(i);
|
|
3083
|
+
tickInputs.set(localPeerId, localCurr);
|
|
3084
|
+
justPressed.set(
|
|
3085
|
+
localPeerId,
|
|
3086
|
+
computeJustPressed(localCurr, inputs.getTick(i - 1))
|
|
3087
|
+
);
|
|
3088
|
+
callback(tickInputs, justPressed, i, true, dt);
|
|
3089
|
+
}
|
|
3090
|
+
driver.stepWorld?.();
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
const postState = driver.captureState();
|
|
3094
|
+
const errors = /* @__PURE__ */ new Map();
|
|
3095
|
+
for (const [id, post] of postState) {
|
|
3096
|
+
const pre = preMap.get(id);
|
|
3097
|
+
if (!pre) continue;
|
|
3098
|
+
const errX = pre.x - post.x;
|
|
3099
|
+
const errY = pre.y - post.y;
|
|
3100
|
+
let errZ = 0;
|
|
3101
|
+
let errA = 0;
|
|
3102
|
+
let q = { ...IDENTITY_QUAT };
|
|
3103
|
+
if ("z" in post) {
|
|
3104
|
+
errZ = pre.z - post.z;
|
|
3105
|
+
let dq = quatNormalize(
|
|
3106
|
+
quatMultiply(
|
|
3107
|
+
pre.q,
|
|
3108
|
+
quatInvert({ x: post.qx, y: post.qy, z: post.qz, w: post.qw })
|
|
3109
|
+
)
|
|
3110
|
+
);
|
|
3111
|
+
if (dq.w < 0) dq = { x: -dq.x, y: -dq.y, z: -dq.z, w: -dq.w };
|
|
3112
|
+
q = dq;
|
|
3113
|
+
} else {
|
|
3114
|
+
const ad = pre.a - post.a;
|
|
3115
|
+
errA = ad - Math.PI * 2 * Math.floor((ad + Math.PI) / (Math.PI * 2));
|
|
3116
|
+
}
|
|
3117
|
+
if (Math.abs(errX) > snapThreshold || Math.abs(errY) > snapThreshold || Math.abs(errZ) > snapThreshold) {
|
|
3118
|
+
errors.set(id, { x: 0, y: 0, z: 0, a: 0, qx: 0, qy: 0, qz: 0, qw: 1 });
|
|
3119
|
+
} else {
|
|
3120
|
+
errors.set(id, {
|
|
3121
|
+
x: errX,
|
|
3122
|
+
y: errY,
|
|
3123
|
+
z: errZ,
|
|
3124
|
+
a: errA,
|
|
3125
|
+
qx: q.x,
|
|
3126
|
+
qy: q.y,
|
|
3127
|
+
qz: q.z,
|
|
3128
|
+
qw: q.w
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
return { newLocalTick, snapped, errors };
|
|
3133
|
+
}
|
|
3134
|
+
|
|
2679
3135
|
// src/sync/PredictionSync.ts
|
|
2680
|
-
var import_msgpackr2 = require("msgpackr");
|
|
2681
3136
|
var DEFAULT_OPTIONS = {
|
|
2682
3137
|
maxRewindTicks: 15,
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
3138
|
+
snapThreshold: 150,
|
|
3139
|
+
errorDecay: 0.85,
|
|
3140
|
+
maxErrorPerFrame: 0,
|
|
3141
|
+
neutralInput: {},
|
|
3142
|
+
inputHistorySize: 120,
|
|
3143
|
+
driftTargetTicks: 4
|
|
2687
3144
|
};
|
|
2688
3145
|
var PredictionSync = class {
|
|
2689
|
-
constructor(transport,
|
|
2690
|
-
//
|
|
2691
|
-
this.
|
|
2692
|
-
//
|
|
2693
|
-
this.
|
|
2694
|
-
//
|
|
2695
|
-
this.
|
|
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();
|
|
3146
|
+
constructor(transport, tickKeeper, snapshots, options) {
|
|
3147
|
+
// Local input; persists across ticks until replaced (hold-input semantics)
|
|
3148
|
+
this._currentInput = null;
|
|
3149
|
+
// Newest pending server snapshot awaiting rollback (only the newest survives)
|
|
3150
|
+
this._pending = null;
|
|
3151
|
+
// Per-entity accumulated visual error offsets
|
|
3152
|
+
this._errors = /* @__PURE__ */ new Map();
|
|
2700
3153
|
this._serverTick = 0;
|
|
2701
|
-
|
|
3154
|
+
this._lastAppliedServerTick = 0;
|
|
3155
|
+
this._worldDriver = null;
|
|
2702
3156
|
this._onPhysicsStep = null;
|
|
2703
|
-
// Own input for current tick
|
|
2704
|
-
this._currentInput = null;
|
|
2705
3157
|
this._transport = transport;
|
|
2706
|
-
this._codec = codec;
|
|
2707
3158
|
this._tickKeeper = tickKeeper;
|
|
2708
3159
|
this._options = { ...DEFAULT_OPTIONS, ...options };
|
|
2709
|
-
this.
|
|
3160
|
+
this._inputs = new InputBuffer(
|
|
3161
|
+
this._options.neutralInput,
|
|
3162
|
+
this._options.inputHistorySize
|
|
3163
|
+
);
|
|
2710
3164
|
this._inputChannel = transport.createChannel("carver:inputs", {
|
|
2711
3165
|
reliable: true,
|
|
2712
3166
|
ordered: true
|
|
2713
3167
|
});
|
|
2714
|
-
this.
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
3168
|
+
this._inputChannel.onReceive(
|
|
3169
|
+
(data, peerId) => {
|
|
3170
|
+
try {
|
|
3171
|
+
const packet = typeof data === "string" ? JSON.parse(data) : data;
|
|
3172
|
+
if (typeof packet.t === "number" && packet.i !== null && typeof packet.i === "object") {
|
|
3173
|
+
this._inputs.setRemote(peerId, packet.i, packet.t);
|
|
3174
|
+
}
|
|
3175
|
+
} catch (err) {
|
|
3176
|
+
if (typeof console !== "undefined")
|
|
3177
|
+
console.debug("[CarverJS] Malformed input packet:", err);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
);
|
|
3181
|
+
snapshots.onSnapshot((tick, entities, hostInput) => {
|
|
3182
|
+
if (this._transport.isHost) return;
|
|
3183
|
+
if (tick <= this._lastAppliedServerTick) return;
|
|
3184
|
+
this._serverTick = tick;
|
|
3185
|
+
this._tickKeeper.setServerTick(tick);
|
|
3186
|
+
if (!this._pending || tick > this._pending.t) {
|
|
3187
|
+
this._pending = { t: tick, entities, hostInput };
|
|
3188
|
+
}
|
|
2718
3189
|
});
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
ordered: true
|
|
3190
|
+
transport.onPeerLeave(() => {
|
|
3191
|
+
this._inputs.setPeerIds(this._transport.peers);
|
|
2722
3192
|
});
|
|
2723
|
-
if (this._isHost) {
|
|
2724
|
-
this._setupHostListeners();
|
|
2725
|
-
} else {
|
|
2726
|
-
this._setupClientListeners();
|
|
2727
|
-
}
|
|
2728
3193
|
}
|
|
2729
|
-
|
|
3194
|
+
// ── Wiring ──
|
|
3195
|
+
/** Set the game simulation callback (forward sim + rollback resim). */
|
|
2730
3196
|
setPhysicsStep(cb) {
|
|
2731
3197
|
this._onPhysicsStep = cb;
|
|
2732
3198
|
}
|
|
2733
|
-
/** Set the
|
|
3199
|
+
/** Set the world driver used for forward stepping and rollback. */
|
|
3200
|
+
setWorldDriver(driver) {
|
|
3201
|
+
this._worldDriver = driver;
|
|
3202
|
+
}
|
|
3203
|
+
// ── Input ──
|
|
3204
|
+
/** Set the local player's input. PERSISTS across ticks until replaced. */
|
|
2734
3205
|
setInput(input) {
|
|
2735
3206
|
this._currentInput = input;
|
|
2736
3207
|
}
|
|
3208
|
+
/** Local input stored at the given tick (neutral fallback). Used by the host to embed `hi`. */
|
|
3209
|
+
getLocalInput(tick) {
|
|
3210
|
+
return this._inputs.getTick(tick);
|
|
3211
|
+
}
|
|
3212
|
+
// ── Frame lifecycle ──
|
|
2737
3213
|
/**
|
|
2738
|
-
*
|
|
2739
|
-
*
|
|
3214
|
+
* Apply the newest pending server snapshot (full-world rollback).
|
|
3215
|
+
* Call once per render frame BEFORE tickKeeper.update().
|
|
3216
|
+
* No-op on host or when nothing is pending.
|
|
2740
3217
|
*/
|
|
2741
|
-
|
|
2742
|
-
if (this.
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
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;
|
|
3218
|
+
beginFrame() {
|
|
3219
|
+
if (this._transport.isHost || !this._pending) return;
|
|
3220
|
+
const pending = this._pending;
|
|
3221
|
+
this._pending = null;
|
|
3222
|
+
this._lastAppliedServerTick = pending.t;
|
|
3223
|
+
if (pending.hostInput !== void 0) {
|
|
3224
|
+
this._inputs.setRemote(
|
|
3225
|
+
this._transport.hostId,
|
|
3226
|
+
pending.hostInput,
|
|
3227
|
+
pending.t
|
|
3228
|
+
);
|
|
2757
3229
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
3230
|
+
if (!this._worldDriver) return;
|
|
3231
|
+
const result = applyRollback({
|
|
3232
|
+
serverTick: pending.t,
|
|
3233
|
+
serverState: pending.entities,
|
|
3234
|
+
localTick: this._tickKeeper.tick,
|
|
3235
|
+
localPeerId: this._transport.peerId,
|
|
3236
|
+
inputs: this._inputs,
|
|
3237
|
+
currentErrors: this._errors,
|
|
3238
|
+
driver: this._worldDriver,
|
|
3239
|
+
callback: this._onPhysicsStep,
|
|
3240
|
+
dt: this._tickKeeper.tickDelta,
|
|
3241
|
+
driftTargetTicks: this._options.driftTargetTicks,
|
|
3242
|
+
maxRewindTicks: this._options.maxRewindTicks,
|
|
3243
|
+
snapThreshold: this._options.snapThreshold
|
|
3244
|
+
});
|
|
3245
|
+
this._errors = result.errors;
|
|
3246
|
+
if (result.newLocalTick !== this._tickKeeper.tick) {
|
|
3247
|
+
this._tickKeeper.snapTick(result.newLocalTick);
|
|
2761
3248
|
}
|
|
2762
3249
|
}
|
|
2763
3250
|
/**
|
|
2764
|
-
*
|
|
2765
|
-
*
|
|
3251
|
+
* Run one forward fixed tick (host AND client): store + broadcast input,
|
|
3252
|
+
* build per-tick input maps, invoke the callback, then step the world.
|
|
2766
3253
|
*/
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3254
|
+
tick(tick) {
|
|
3255
|
+
const localInput = this._currentInput ?? { ...this._options.neutralInput };
|
|
3256
|
+
this._inputs.storeTick(tick, localInput);
|
|
3257
|
+
this._inputChannel.send({
|
|
3258
|
+
t: tick,
|
|
3259
|
+
i: localInput,
|
|
3260
|
+
p: this._transport.peerId
|
|
3261
|
+
});
|
|
3262
|
+
const prevTick = tick - 1;
|
|
3263
|
+
const tickInputs = this._inputs.allRemotes();
|
|
3264
|
+
tickInputs.delete(this._transport.peerId);
|
|
3265
|
+
const justPressed = /* @__PURE__ */ new Map();
|
|
3266
|
+
for (const [peerId, inp] of tickInputs) {
|
|
3267
|
+
justPressed.set(
|
|
3268
|
+
peerId,
|
|
3269
|
+
computeJustPressed(inp, this._inputs.getRemoteAtTick(peerId, prevTick))
|
|
3270
|
+
);
|
|
2779
3271
|
}
|
|
3272
|
+
tickInputs.set(this._transport.peerId, localInput);
|
|
3273
|
+
justPressed.set(
|
|
3274
|
+
this._transport.peerId,
|
|
3275
|
+
this._inputs.hasTick(prevTick) ? computeJustPressed(localInput, this._inputs.getTick(prevTick)) : this._inputs.getJustPressedZero()
|
|
3276
|
+
// suppress spurious edges after snap/rejoin
|
|
3277
|
+
);
|
|
3278
|
+
if (this._onPhysicsStep) {
|
|
3279
|
+
this._onPhysicsStep(
|
|
3280
|
+
tickInputs,
|
|
3281
|
+
justPressed,
|
|
3282
|
+
tick,
|
|
3283
|
+
false,
|
|
3284
|
+
this._tickKeeper.tickDelta
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
this._worldDriver?.stepWorld?.();
|
|
2780
3288
|
}
|
|
2781
3289
|
/**
|
|
2782
|
-
*
|
|
2783
|
-
*
|
|
3290
|
+
* Decay stored error offsets and return the portion to ADD to rendered
|
|
3291
|
+
* transforms this frame. Call exactly once per render frame.
|
|
2784
3292
|
*/
|
|
2785
|
-
|
|
3293
|
+
getRenderErrorOffsets() {
|
|
2786
3294
|
const result = /* @__PURE__ */ new Map();
|
|
2787
|
-
const decay = this._options.
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
3295
|
+
const decay = this._options.errorDecay;
|
|
3296
|
+
const maxErr = this._options.maxErrorPerFrame;
|
|
3297
|
+
for (const [id, e] of this._errors) {
|
|
3298
|
+
e.x *= decay;
|
|
3299
|
+
e.y *= decay;
|
|
3300
|
+
e.z *= decay;
|
|
3301
|
+
e.a *= decay;
|
|
3302
|
+
let q = quatScaleAngle({ x: e.qx, y: e.qy, z: e.qz, w: e.qw }, decay);
|
|
3303
|
+
if (Math.abs(e.x) < 0.1) e.x = 0;
|
|
3304
|
+
if (Math.abs(e.y) < 0.1) e.y = 0;
|
|
3305
|
+
if (Math.abs(e.z) < 0.1) e.z = 0;
|
|
3306
|
+
if (Math.abs(e.a) < 1e-3) e.a = 0;
|
|
3307
|
+
if (quatAngle(q) < 1e-3) q = { x: 0, y: 0, z: 0, w: 1 };
|
|
3308
|
+
e.qx = q.x;
|
|
3309
|
+
e.qy = q.y;
|
|
3310
|
+
e.qz = q.z;
|
|
3311
|
+
e.qw = q.w;
|
|
3312
|
+
let ax = e.x;
|
|
3313
|
+
let ay = e.y;
|
|
3314
|
+
let az = e.z;
|
|
3315
|
+
if (maxErr > 0) {
|
|
3316
|
+
const mag = Math.hypot(e.x, e.y, e.z);
|
|
3317
|
+
if (mag > maxErr) {
|
|
3318
|
+
const s = maxErr / mag;
|
|
3319
|
+
ax = e.x * s;
|
|
3320
|
+
ay = e.y * s;
|
|
3321
|
+
az = e.z * s;
|
|
3322
|
+
e.x -= ax;
|
|
3323
|
+
e.y -= ay;
|
|
3324
|
+
e.z -= az;
|
|
3325
|
+
} else {
|
|
3326
|
+
e.x = 0;
|
|
3327
|
+
e.y = 0;
|
|
3328
|
+
e.z = 0;
|
|
3329
|
+
}
|
|
2793
3330
|
}
|
|
2794
|
-
const
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3331
|
+
const quatIsIdentity = e.qx === 0 && e.qy === 0 && e.qz === 0 && e.qw === 1;
|
|
3332
|
+
if (ax !== 0 || ay !== 0 || az !== 0 || e.a !== 0 || !quatIsIdentity) {
|
|
3333
|
+
result.set(id, {
|
|
3334
|
+
x: ax,
|
|
3335
|
+
y: ay,
|
|
3336
|
+
z: az,
|
|
3337
|
+
a: e.a,
|
|
3338
|
+
qx: e.qx,
|
|
3339
|
+
qy: e.qy,
|
|
3340
|
+
qz: e.qz,
|
|
3341
|
+
qw: e.qw
|
|
3342
|
+
});
|
|
2799
3343
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
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);
|
|
3344
|
+
if (e.x === 0 && e.y === 0 && e.z === 0 && e.a === 0 && quatIsIdentity) {
|
|
3345
|
+
this._errors.delete(id);
|
|
2806
3346
|
}
|
|
2807
|
-
result.set(id, corrected);
|
|
2808
3347
|
}
|
|
2809
3348
|
return result;
|
|
2810
3349
|
}
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
}
|
|
3350
|
+
// ── State ──
|
|
3351
|
+
/** Tick of the newest RECEIVED snapshot. */
|
|
2814
3352
|
get serverTick() {
|
|
2815
3353
|
return this._serverTick;
|
|
2816
3354
|
}
|
|
3355
|
+
/** Tick of the newest APPLIED (rolled-back) snapshot, 0 initially. */
|
|
3356
|
+
get lastAppliedServerTick() {
|
|
3357
|
+
return this._lastAppliedServerTick;
|
|
3358
|
+
}
|
|
2817
3359
|
destroy() {
|
|
2818
3360
|
this._inputChannel.close();
|
|
2819
|
-
this.
|
|
2820
|
-
this.
|
|
2821
|
-
this.
|
|
2822
|
-
this.
|
|
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);
|
|
3361
|
+
this._inputs.clear();
|
|
3362
|
+
this._errors.clear();
|
|
3363
|
+
this._pending = null;
|
|
3364
|
+
this._currentInput = null;
|
|
2918
3365
|
}
|
|
2919
3366
|
};
|
|
2920
3367
|
|
|
@@ -3110,6 +3557,34 @@ function applyState3D(ref, state) {
|
|
|
3110
3557
|
}
|
|
3111
3558
|
}
|
|
3112
3559
|
}
|
|
3560
|
+
function applyStateHard2D(ref, state) {
|
|
3561
|
+
applyState2D(ref, state);
|
|
3562
|
+
if (ref.rigidBody) {
|
|
3563
|
+
try {
|
|
3564
|
+
if (typeof ref.rigidBody.setLinvel === "function") {
|
|
3565
|
+
ref.rigidBody.setLinvel({ x: state.vx, y: state.vy }, true);
|
|
3566
|
+
}
|
|
3567
|
+
if (typeof ref.rigidBody.setAngvel === "function") {
|
|
3568
|
+
ref.rigidBody.setAngvel(state.va, true);
|
|
3569
|
+
}
|
|
3570
|
+
} catch {
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
function applyStateHard3D(ref, state) {
|
|
3575
|
+
applyState3D(ref, state);
|
|
3576
|
+
if (ref.rigidBody) {
|
|
3577
|
+
try {
|
|
3578
|
+
if (typeof ref.rigidBody.setLinvel === "function") {
|
|
3579
|
+
ref.rigidBody.setLinvel({ x: state.vx, y: state.vy, z: state.vz }, true);
|
|
3580
|
+
}
|
|
3581
|
+
if (typeof ref.rigidBody.setAngvel === "function") {
|
|
3582
|
+
ref.rigidBody.setAngvel({ x: state.wx, y: state.wy, z: state.wz }, true);
|
|
3583
|
+
}
|
|
3584
|
+
} catch {
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3113
3588
|
function applyEntityState(ref, state, is2D) {
|
|
3114
3589
|
if (is2D) {
|
|
3115
3590
|
applyState2D(ref, state);
|
|
@@ -3125,6 +3600,10 @@ function applyStatesToActors(states, registry, is2D) {
|
|
|
3125
3600
|
applyEntityState(ref, state, is2D);
|
|
3126
3601
|
}
|
|
3127
3602
|
}
|
|
3603
|
+
var IDENTITY_QUAT2 = { x: 0, y: 0, z: 0, w: 1 };
|
|
3604
|
+
function isIdentityQuat(q) {
|
|
3605
|
+
return q.x === 0 && q.y === 0 && q.z === 0 && q.w === 1;
|
|
3606
|
+
}
|
|
3128
3607
|
function useMultiplayer(options = {}) {
|
|
3129
3608
|
const { networkManager } = useMultiplayerContext();
|
|
3130
3609
|
const mode = options.mode ?? networkManager.syncMode;
|
|
@@ -3140,6 +3619,7 @@ function useMultiplayer(options = {}) {
|
|
|
3140
3619
|
const [drift, setDrift] = (0, import_react8.useState)(0);
|
|
3141
3620
|
const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
|
|
3142
3621
|
const wasHostRef = (0, import_react8.useRef)(null);
|
|
3622
|
+
const appliedErrorsRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
|
|
3143
3623
|
const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
|
|
3144
3624
|
return {
|
|
3145
3625
|
broadcastRate: options.broadcastRate,
|
|
@@ -3156,6 +3636,36 @@ function useMultiplayer(options = {}) {
|
|
|
3156
3636
|
options.interpolation?.method,
|
|
3157
3637
|
options.interpolation?.extrapolateMs
|
|
3158
3638
|
]);
|
|
3639
|
+
const undoAppliedErrorOffsets = (0, import_react8.useCallback)(() => {
|
|
3640
|
+
const registry = actorRegistry.current;
|
|
3641
|
+
for (const [id, e] of appliedErrorsRef.current) {
|
|
3642
|
+
const ref = registry.get(id);
|
|
3643
|
+
if (!ref) {
|
|
3644
|
+
appliedErrorsRef.current.delete(id);
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
const pos = ref.object3D.position;
|
|
3648
|
+
const untouched = Math.abs(pos.x - e.px) < 1e-9 && Math.abs(pos.y - e.py) < 1e-9 && Math.abs(pos.z - e.pz) < 1e-9;
|
|
3649
|
+
if (untouched) {
|
|
3650
|
+
pos.x -= e.x;
|
|
3651
|
+
pos.y -= e.y;
|
|
3652
|
+
pos.z -= e.z;
|
|
3653
|
+
if (e.a !== 0) {
|
|
3654
|
+
ref.object3D.rotation.z -= e.a;
|
|
3655
|
+
}
|
|
3656
|
+
if (!isIdentityQuat(e.q)) {
|
|
3657
|
+
const inv = quatInvert(e.q);
|
|
3658
|
+
const cur = ref.object3D.quaternion;
|
|
3659
|
+
const r = quatMultiply(inv, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
|
|
3660
|
+
cur.set(r.x, r.y, r.z, r.w);
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
appliedErrorsRef.current.clear();
|
|
3665
|
+
}, []);
|
|
3666
|
+
const setInput = (0, import_react8.useCallback)((input) => {
|
|
3667
|
+
predictionSyncRef.current?.setInput(input);
|
|
3668
|
+
}, []);
|
|
3159
3669
|
(0, import_react8.useEffect)(() => {
|
|
3160
3670
|
const transport = networkManager.transport;
|
|
3161
3671
|
if (!transport) return;
|
|
@@ -3184,15 +3694,30 @@ function useMultiplayer(options = {}) {
|
|
|
3184
3694
|
);
|
|
3185
3695
|
}
|
|
3186
3696
|
if (mode === "prediction") {
|
|
3697
|
+
const driver = {
|
|
3698
|
+
captureState: () => buildEntityMap(actorRegistry.current.getNetworked(), is2DRef.current ?? true),
|
|
3699
|
+
applyState: (entities) => {
|
|
3700
|
+
const is2D = is2DRef.current ?? true;
|
|
3701
|
+
for (const state of entities) {
|
|
3702
|
+
if (state.c && state.c.__removed) continue;
|
|
3703
|
+
const ref = actorRegistry.current.get(state.id);
|
|
3704
|
+
if (!ref) continue;
|
|
3705
|
+
if (is2D) applyStateHard2D(ref, state);
|
|
3706
|
+
else applyStateHard3D(ref, state);
|
|
3707
|
+
}
|
|
3708
|
+
},
|
|
3709
|
+
...options.stepWorld ? { stepWorld: options.stepWorld } : {}
|
|
3710
|
+
};
|
|
3187
3711
|
predictionSyncRef.current = new PredictionSync(
|
|
3188
3712
|
transport,
|
|
3189
|
-
networkManager.codec,
|
|
3190
3713
|
networkManager.tickKeeper,
|
|
3714
|
+
snapshotSyncRef.current,
|
|
3191
3715
|
options.prediction
|
|
3192
3716
|
);
|
|
3193
3717
|
if (options.onPhysicsStep) {
|
|
3194
3718
|
predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
|
|
3195
3719
|
}
|
|
3720
|
+
predictionSyncRef.current.setWorldDriver(driver);
|
|
3196
3721
|
}
|
|
3197
3722
|
wasHostRef.current = networkManager.isHost;
|
|
3198
3723
|
setIsActive(true);
|
|
@@ -3221,9 +3746,10 @@ function useMultiplayer(options = {}) {
|
|
|
3221
3746
|
snapshotSyncRef.current = null;
|
|
3222
3747
|
predictionSyncRef.current = null;
|
|
3223
3748
|
networkSimulatorRef.current = null;
|
|
3749
|
+
appliedErrorsRef.current.clear();
|
|
3224
3750
|
setIsActive(false);
|
|
3225
3751
|
};
|
|
3226
|
-
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
|
|
3752
|
+
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.stepWorld, options.debug]);
|
|
3227
3753
|
(0, import_react8.useEffect)(() => {
|
|
3228
3754
|
const unsub = networkManager.onConnectionStateChange(() => {
|
|
3229
3755
|
setNetworkQuality(networkManager.networkQuality);
|
|
@@ -3243,33 +3769,67 @@ function useMultiplayer(options = {}) {
|
|
|
3243
3769
|
}
|
|
3244
3770
|
const is2D = is2DRef.current ?? true;
|
|
3245
3771
|
if (mode === "events") return;
|
|
3772
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3773
|
+
undoAppliedErrorOffsets();
|
|
3774
|
+
predictionSyncRef.current.beginFrame();
|
|
3775
|
+
}
|
|
3246
3776
|
const ticksThisFrame = tickKeeper.update(delta);
|
|
3247
3777
|
for (let i = 0; i < ticksThisFrame; i++) {
|
|
3248
3778
|
const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
|
|
3249
|
-
if (
|
|
3779
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3780
|
+
predictionSyncRef.current.tick(currentTick);
|
|
3781
|
+
if (isHost) {
|
|
3782
|
+
const entities = buildEntityMap(actorRegistry.current.getNetworked(), is2D);
|
|
3783
|
+
snapshotSyncRef.current?.hostTick(
|
|
3784
|
+
currentTick,
|
|
3785
|
+
entities,
|
|
3786
|
+
tickKeeper.tickDelta,
|
|
3787
|
+
predictionSyncRef.current.getLocalInput(currentTick)
|
|
3788
|
+
);
|
|
3789
|
+
}
|
|
3790
|
+
} else if (mode === "snapshot" && isHost) {
|
|
3250
3791
|
const networked = actorRegistry.current.getNetworked();
|
|
3251
3792
|
const entities = buildEntityMap(networked, is2D);
|
|
3252
|
-
|
|
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
|
-
}
|
|
3793
|
+
snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
3262
3794
|
}
|
|
3263
3795
|
}
|
|
3264
|
-
if (!isHost) {
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3796
|
+
if (!isHost && mode === "snapshot" && snapshotSyncRef.current) {
|
|
3797
|
+
const renderTime = performance.now();
|
|
3798
|
+
const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
|
|
3799
|
+
applyStatesToActors(interpolated, actorRegistry.current, is2D);
|
|
3800
|
+
}
|
|
3801
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3802
|
+
const offsets = predictionSyncRef.current.getRenderErrorOffsets();
|
|
3803
|
+
for (const [id, off] of offsets) {
|
|
3804
|
+
const ref = actorRegistry.current.get(id);
|
|
3805
|
+
if (!ref) continue;
|
|
3806
|
+
const pos = ref.object3D.position;
|
|
3807
|
+
pos.x += off.x;
|
|
3808
|
+
pos.y += off.y;
|
|
3809
|
+
if (!is2D) pos.z += off.z;
|
|
3810
|
+
let appliedA = 0;
|
|
3811
|
+
let appliedQ = IDENTITY_QUAT2;
|
|
3812
|
+
if (is2D) {
|
|
3813
|
+
ref.object3D.rotation.z += off.a;
|
|
3814
|
+
appliedA = off.a;
|
|
3815
|
+
} else {
|
|
3816
|
+
appliedQ = { x: off.qx, y: off.qy, z: off.qz, w: off.qw };
|
|
3817
|
+
if (!isIdentityQuat(appliedQ)) {
|
|
3818
|
+
const cur = ref.object3D.quaternion;
|
|
3819
|
+
const r = quatMultiply(appliedQ, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
|
|
3820
|
+
cur.set(r.x, r.y, r.z, r.w);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
appliedErrorsRef.current.set(id, {
|
|
3824
|
+
x: off.x,
|
|
3825
|
+
y: off.y,
|
|
3826
|
+
z: is2D ? 0 : off.z,
|
|
3827
|
+
a: appliedA,
|
|
3828
|
+
q: appliedQ,
|
|
3829
|
+
px: pos.x,
|
|
3830
|
+
py: pos.y,
|
|
3831
|
+
pz: pos.z
|
|
3832
|
+
});
|
|
3273
3833
|
}
|
|
3274
3834
|
}
|
|
3275
3835
|
if (ticksThisFrame > 0) {
|
|
@@ -3289,7 +3849,8 @@ function useMultiplayer(options = {}) {
|
|
|
3289
3849
|
tick,
|
|
3290
3850
|
serverTick,
|
|
3291
3851
|
drift,
|
|
3292
|
-
syncEngine: mode
|
|
3852
|
+
syncEngine: mode,
|
|
3853
|
+
setInput
|
|
3293
3854
|
};
|
|
3294
3855
|
}
|
|
3295
3856
|
|
|
@@ -3801,11 +4362,13 @@ var InterestManager = class {
|
|
|
3801
4362
|
0 && (module.exports = {
|
|
3802
4363
|
DebugOverlay,
|
|
3803
4364
|
FirebaseStrategy,
|
|
4365
|
+
InputBuffer,
|
|
3804
4366
|
InterestManager,
|
|
3805
4367
|
MqttStrategy,
|
|
3806
4368
|
MultiplayerBridge,
|
|
3807
4369
|
MultiplayerProvider,
|
|
3808
4370
|
NetworkSimulator,
|
|
4371
|
+
computeJustPressed,
|
|
3809
4372
|
useHost,
|
|
3810
4373
|
useLobby,
|
|
3811
4374
|
useMultiplayer,
|