@carverjs/multiplayer 0.0.1 → 0.0.2
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/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-CBTAOVXP.mjs} +34 -3
- package/dist/chunk-CBTAOVXP.mjs.map +1 -0
- package/dist/{chunk-EO3YNPRQ.mjs → chunk-Q25TJEY4.mjs} +494 -204
- package/dist/chunk-Q25TJEY4.mjs.map +1 -0
- package/dist/{chunk-3KT73N2S.mjs → chunk-UKEFWQ76.mjs} +0 -0
- package/dist/chunk-UKEFWQ76.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 +744 -245
- 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 +33 -2
- 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;
|
|
@@ -481,6 +496,12 @@ var FirebaseStrategy = class {
|
|
|
481
496
|
}
|
|
482
497
|
subscribeToLobby(cb) {
|
|
483
498
|
this._onLobby.push(cb);
|
|
499
|
+
if (this._lobbyWired) {
|
|
500
|
+
return () => {
|
|
501
|
+
removeFromArray(this._onLobby, cb);
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
this._lobbyWired = true;
|
|
484
505
|
this._ensureInit().then(() => {
|
|
485
506
|
if (!this._db || !this._fb || this._destroyed) return;
|
|
486
507
|
const { ref, onValue } = this._fb;
|
|
@@ -508,14 +529,25 @@ var FirebaseStrategy = class {
|
|
|
508
529
|
if (!this._db || !this._fb) return;
|
|
509
530
|
const { ref, set } = this._fb;
|
|
510
531
|
const paths = firebasePaths(this._appId, announcement.roomId, "");
|
|
532
|
+
this._lastAnnouncement = announcement;
|
|
511
533
|
announcement.lastSeen = Date.now();
|
|
512
|
-
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
534
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
|
|
513
535
|
if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
|
|
514
536
|
this._lobbyAnnounceTimer = setInterval(() => {
|
|
515
537
|
announcement.lastSeen = Date.now();
|
|
516
|
-
set(ref(this._db, paths.roomLobbyEntry), announcement);
|
|
538
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
|
|
517
539
|
}, ROOM_ANNOUNCE_INTERVAL_MS);
|
|
518
540
|
}
|
|
541
|
+
updateRoomOccupancy(roomId, playerCount, state) {
|
|
542
|
+
const ann = this._lastAnnouncement;
|
|
543
|
+
if (!ann || ann.roomId !== roomId || !this._db || !this._fb) return;
|
|
544
|
+
ann.playerCount = playerCount;
|
|
545
|
+
if (state) ann.state = state;
|
|
546
|
+
ann.lastSeen = Date.now();
|
|
547
|
+
const { ref, set } = this._fb;
|
|
548
|
+
const paths = firebasePaths(this._appId, roomId, "");
|
|
549
|
+
set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(ann));
|
|
550
|
+
}
|
|
519
551
|
removeRoomAnnouncement(roomId) {
|
|
520
552
|
if (!this._db || !this._fb) return;
|
|
521
553
|
const { ref, remove } = this._fb;
|
|
@@ -618,6 +650,7 @@ function sanitizeForFirebase(obj) {
|
|
|
618
650
|
if (typeof obj === "object" && obj !== null) {
|
|
619
651
|
const result = {};
|
|
620
652
|
for (const [key, value] of Object.entries(obj)) {
|
|
653
|
+
if (value === void 0) continue;
|
|
621
654
|
result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
|
|
622
655
|
}
|
|
623
656
|
return result;
|
|
@@ -665,6 +698,11 @@ var _TickKeeper = class _TickKeeper {
|
|
|
665
698
|
this._serverTick = serverTick;
|
|
666
699
|
this._updateDriftCorrection();
|
|
667
700
|
}
|
|
701
|
+
/** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
|
|
702
|
+
snapTick(tick) {
|
|
703
|
+
this._tick = tick;
|
|
704
|
+
this._updateDriftCorrection();
|
|
705
|
+
}
|
|
668
706
|
/**
|
|
669
707
|
* Accumulate time and return the number of fixed ticks to process.
|
|
670
708
|
* Call this once per render frame with the raw frame delta.
|
|
@@ -789,15 +827,16 @@ var Codec = class {
|
|
|
789
827
|
}
|
|
790
828
|
return changed.length > 0 ? changed : null;
|
|
791
829
|
}
|
|
792
|
-
/** Serialize a delta snapshot packet */
|
|
793
|
-
serializeDelta(tick, baseTick, current, baseline) {
|
|
830
|
+
/** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
|
|
831
|
+
serializeDelta(tick, baseTick, current, baseline, hostInput) {
|
|
794
832
|
const delta = this.computeDelta(current, baseline);
|
|
795
833
|
if (!delta) return null;
|
|
796
834
|
const packet = {
|
|
797
835
|
t: tick,
|
|
798
836
|
b: baseline ? baseTick : -1,
|
|
799
837
|
// -1 = keyframe
|
|
800
|
-
s: this.serialize(delta)
|
|
838
|
+
s: this.serialize(delta),
|
|
839
|
+
...hostInput !== void 0 ? { hi: hostInput } : {}
|
|
801
840
|
};
|
|
802
841
|
return (0, import_msgpackr.pack)(packet);
|
|
803
842
|
}
|
|
@@ -807,7 +846,8 @@ var Codec = class {
|
|
|
807
846
|
return {
|
|
808
847
|
tick: packet.t,
|
|
809
848
|
baseTick: packet.b,
|
|
810
|
-
entities: this.deserialize(packet.s)
|
|
849
|
+
entities: this.deserialize(packet.s),
|
|
850
|
+
hostInput: packet.hi
|
|
811
851
|
};
|
|
812
852
|
}
|
|
813
853
|
_hasChanged(current, prev) {
|
|
@@ -1119,6 +1159,7 @@ function MultiplayerProvider({
|
|
|
1119
1159
|
}) {
|
|
1120
1160
|
const managerRef = (0, import_react2.useRef)(null);
|
|
1121
1161
|
const strategyRef = (0, import_react2.useRef)(null);
|
|
1162
|
+
const destroyTimerRef = (0, import_react2.useRef)(null);
|
|
1122
1163
|
if (!managerRef.current) {
|
|
1123
1164
|
managerRef.current = new NetworkManager();
|
|
1124
1165
|
}
|
|
@@ -1126,11 +1167,18 @@ function MultiplayerProvider({
|
|
|
1126
1167
|
strategyRef.current = createStrategy(appId, strategyConfig);
|
|
1127
1168
|
}
|
|
1128
1169
|
(0, import_react2.useEffect)(() => {
|
|
1170
|
+
if (destroyTimerRef.current) {
|
|
1171
|
+
clearTimeout(destroyTimerRef.current);
|
|
1172
|
+
destroyTimerRef.current = null;
|
|
1173
|
+
}
|
|
1129
1174
|
return () => {
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1175
|
+
destroyTimerRef.current = setTimeout(() => {
|
|
1176
|
+
destroyTimerRef.current = null;
|
|
1177
|
+
strategyRef.current?.destroy();
|
|
1178
|
+
strategyRef.current = null;
|
|
1179
|
+
managerRef.current?.destroy();
|
|
1180
|
+
managerRef.current = null;
|
|
1181
|
+
}, 0);
|
|
1134
1182
|
};
|
|
1135
1183
|
}, []);
|
|
1136
1184
|
const value = {
|
|
@@ -1326,6 +1374,11 @@ var WebRTCTransport = class {
|
|
|
1326
1374
|
this._playerMap = /* @__PURE__ */ new Map();
|
|
1327
1375
|
this._initialPeers = [];
|
|
1328
1376
|
this._strategyUnsubs = [];
|
|
1377
|
+
// ── Private: WebRTC peer management ──
|
|
1378
|
+
/** Grace timers for transient ICE 'disconnected' states */
|
|
1379
|
+
this._disconnectTimers = /* @__PURE__ */ new Map();
|
|
1380
|
+
/** Sends queued while a data channel is not yet open: key = peerIdchannel */
|
|
1381
|
+
this._pendingSends = /* @__PURE__ */ new Map();
|
|
1329
1382
|
this._strategy = strategy;
|
|
1330
1383
|
this._peerId = strategy.selfId;
|
|
1331
1384
|
this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
|
|
@@ -1626,6 +1679,7 @@ var WebRTCTransport = class {
|
|
|
1626
1679
|
case "request-room-state": {
|
|
1627
1680
|
if (!this._isHost || !this._room) break;
|
|
1628
1681
|
this._room.state = msg.state;
|
|
1682
|
+
this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
|
|
1629
1683
|
this._broadcastControlMessage({ type: "room-updated", room: this._room });
|
|
1630
1684
|
break;
|
|
1631
1685
|
}
|
|
@@ -1682,16 +1736,23 @@ var WebRTCTransport = class {
|
|
|
1682
1736
|
if (this._room) {
|
|
1683
1737
|
this._room.hostId = newHostId;
|
|
1684
1738
|
this._room.playerCount = this._playerMap.size;
|
|
1739
|
+
this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
|
|
1685
1740
|
}
|
|
1686
1741
|
if (changed) {
|
|
1687
1742
|
for (const cb of this._callbacks.onHostChanged) cb(newHostId);
|
|
1688
1743
|
}
|
|
1689
1744
|
}
|
|
1690
|
-
// ── Private: WebRTC peer management ──
|
|
1691
1745
|
_connectToPeer(peerId) {
|
|
1692
1746
|
if (this._peers.has(peerId)) return;
|
|
1693
1747
|
const peer = new PeerConnection(peerId, this._iceConfig, {
|
|
1694
1748
|
onStateChange: (state) => {
|
|
1749
|
+
if (state === "connected") {
|
|
1750
|
+
const timer = this._disconnectTimers.get(peerId);
|
|
1751
|
+
if (timer) {
|
|
1752
|
+
clearTimeout(timer);
|
|
1753
|
+
this._disconnectTimers.delete(peerId);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1695
1756
|
if (state === "connected" && this._isHost && this._room) {
|
|
1696
1757
|
const syncMsg = {
|
|
1697
1758
|
type: "sync-state",
|
|
@@ -1702,11 +1763,14 @@ var WebRTCTransport = class {
|
|
|
1702
1763
|
this._sendOnChannel(ROOM_CONTROL_CHANNEL, syncMsg, peerId);
|
|
1703
1764
|
}, 100);
|
|
1704
1765
|
}
|
|
1705
|
-
if (state === "failed"
|
|
1706
|
-
this.
|
|
1707
|
-
|
|
1708
|
-
this.
|
|
1709
|
-
|
|
1766
|
+
if (state === "failed") {
|
|
1767
|
+
this._teardownPeer(peerId);
|
|
1768
|
+
} else if (state === "disconnected" && !this._disconnectTimers.has(peerId)) {
|
|
1769
|
+
this._disconnectTimers.set(peerId, setTimeout(() => {
|
|
1770
|
+
this._disconnectTimers.delete(peerId);
|
|
1771
|
+
const p = this._peers.get(peerId);
|
|
1772
|
+
if (p && p.state !== "connected") this._teardownPeer(peerId);
|
|
1773
|
+
}, 5e3));
|
|
1710
1774
|
}
|
|
1711
1775
|
},
|
|
1712
1776
|
onDataChannel: (channel) => {
|
|
@@ -1766,21 +1830,50 @@ var WebRTCTransport = class {
|
|
|
1766
1830
|
} catch {
|
|
1767
1831
|
}
|
|
1768
1832
|
};
|
|
1833
|
+
dataChannel.onopen = () => this._flushPendingSends(peerId, channelName);
|
|
1834
|
+
if (dataChannel.readyState === "open") this._flushPendingSends(peerId, channelName);
|
|
1769
1835
|
}
|
|
1770
1836
|
_sendOnChannel(channelName, data, target) {
|
|
1771
1837
|
const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
|
|
1772
1838
|
const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
|
|
1773
1839
|
for (const pid of targets) {
|
|
1774
1840
|
const peer = this._peers.get(pid);
|
|
1775
|
-
|
|
1841
|
+
if (!peer) continue;
|
|
1842
|
+
const ch = peer.getDataChannel(channelName);
|
|
1776
1843
|
if (ch?.readyState === "open") {
|
|
1777
1844
|
try {
|
|
1778
1845
|
ch.send(serialized);
|
|
1779
1846
|
} catch {
|
|
1780
1847
|
}
|
|
1848
|
+
} else {
|
|
1849
|
+
const key = pid + "\0" + channelName;
|
|
1850
|
+
const q = this._pendingSends.get(key) ?? [];
|
|
1851
|
+
if (q.length < 200) q.push(serialized);
|
|
1852
|
+
this._pendingSends.set(key, q);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
_flushPendingSends(peerId, channelName) {
|
|
1857
|
+
const key = peerId + "\0" + channelName;
|
|
1858
|
+
const q = this._pendingSends.get(key);
|
|
1859
|
+
if (!q || q.length === 0) return;
|
|
1860
|
+
const ch = this._peers.get(peerId)?.getDataChannel(channelName);
|
|
1861
|
+
if (ch?.readyState !== "open") return;
|
|
1862
|
+
this._pendingSends.delete(key);
|
|
1863
|
+
for (const msg of q) {
|
|
1864
|
+
try {
|
|
1865
|
+
ch.send(msg);
|
|
1866
|
+
} catch {
|
|
1867
|
+
break;
|
|
1781
1868
|
}
|
|
1782
1869
|
}
|
|
1783
1870
|
}
|
|
1871
|
+
_teardownPeer(peerId) {
|
|
1872
|
+
this._removePeer(peerId);
|
|
1873
|
+
this._playerMap.delete(peerId);
|
|
1874
|
+
this._electAndSetHost();
|
|
1875
|
+
for (const cb of this._callbacks.onPeerLeave) cb(peerId);
|
|
1876
|
+
}
|
|
1784
1877
|
_removePeer(peerId) {
|
|
1785
1878
|
const peer = this._peers.get(peerId);
|
|
1786
1879
|
if (peer) {
|
|
@@ -1789,6 +1882,14 @@ var WebRTCTransport = class {
|
|
|
1789
1882
|
}
|
|
1790
1883
|
this._peerSet.delete(peerId);
|
|
1791
1884
|
this._rateLimitCounters.delete(peerId);
|
|
1885
|
+
const timer = this._disconnectTimers.get(peerId);
|
|
1886
|
+
if (timer) {
|
|
1887
|
+
clearTimeout(timer);
|
|
1888
|
+
this._disconnectTimers.delete(peerId);
|
|
1889
|
+
}
|
|
1890
|
+
for (const key of [...this._pendingSends.keys()]) {
|
|
1891
|
+
if (key.startsWith(peerId + "\0")) this._pendingSends.delete(key);
|
|
1892
|
+
}
|
|
1792
1893
|
}
|
|
1793
1894
|
_checkRateLimit(peerId) {
|
|
1794
1895
|
const now = Date.now();
|
|
@@ -1970,7 +2071,7 @@ function announcementToRoom(ann) {
|
|
|
1970
2071
|
isPrivate: ann.isPrivate,
|
|
1971
2072
|
metadata: ann.metadata,
|
|
1972
2073
|
createdAt: ann.createdAt,
|
|
1973
|
-
state: "lobby"
|
|
2074
|
+
state: ann.state ?? "lobby"
|
|
1974
2075
|
};
|
|
1975
2076
|
}
|
|
1976
2077
|
function useLobby(options) {
|
|
@@ -2016,7 +2117,7 @@ function useLobby(options) {
|
|
|
2016
2117
|
hostId: strategy.selfId,
|
|
2017
2118
|
playerCount: 0,
|
|
2018
2119
|
maxPlayers: config.maxPlayers ?? 8,
|
|
2019
|
-
gameMode: config.metadata?.gameMode,
|
|
2120
|
+
gameMode: config.gameMode ?? config.metadata?.gameMode,
|
|
2020
2121
|
isPrivate: config.isPrivate ?? false,
|
|
2021
2122
|
metadata: config.metadata ?? {},
|
|
2022
2123
|
createdAt: Date.now(),
|
|
@@ -2246,8 +2347,9 @@ var HostAuthority = class {
|
|
|
2246
2347
|
/**
|
|
2247
2348
|
* Called every fixed tick by the sync engine.
|
|
2248
2349
|
* Collects entity states and decides whether to broadcast.
|
|
2350
|
+
* `hostInput` (prediction mode) is embedded in the snapshot packet as `hi`.
|
|
2249
2351
|
*/
|
|
2250
|
-
tick(currentTick, entities, delta) {
|
|
2352
|
+
tick(currentTick, entities, delta, hostInput) {
|
|
2251
2353
|
this._tick = currentTick;
|
|
2252
2354
|
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2253
2355
|
this._broadcastAccumulator += delta;
|
|
@@ -2255,16 +2357,16 @@ var HostAuthority = class {
|
|
|
2255
2357
|
if (this._broadcastAccumulator < broadcastInterval) return;
|
|
2256
2358
|
this._broadcastAccumulator -= broadcastInterval;
|
|
2257
2359
|
for (const peerId of this._transport.peers) {
|
|
2258
|
-
this._broadcastToClient(peerId, currentTick, entities);
|
|
2360
|
+
this._broadcastToClient(peerId, currentTick, entities, hostInput);
|
|
2259
2361
|
}
|
|
2260
2362
|
}
|
|
2261
2363
|
/** Force a keyframe broadcast to all clients (e.g., after host migration) */
|
|
2262
|
-
forceKeyframe(currentTick, entities) {
|
|
2364
|
+
forceKeyframe(currentTick, entities, hostInput) {
|
|
2263
2365
|
this._clientBaselines.clear();
|
|
2264
2366
|
this._clientLastKeyframeTick.clear();
|
|
2265
2367
|
this._snapshotBuffer.store(currentTick, new Map(entities));
|
|
2266
2368
|
for (const peerId of this._transport.peers) {
|
|
2267
|
-
this._broadcastToClient(peerId, currentTick, entities);
|
|
2369
|
+
this._broadcastToClient(peerId, currentTick, entities, hostInput);
|
|
2268
2370
|
}
|
|
2269
2371
|
}
|
|
2270
2372
|
destroy() {
|
|
@@ -2273,7 +2375,7 @@ var HostAuthority = class {
|
|
|
2273
2375
|
this._clientBaselines.clear();
|
|
2274
2376
|
this._clientLastKeyframeTick.clear();
|
|
2275
2377
|
}
|
|
2276
|
-
_broadcastToClient(peerId, currentTick, entities) {
|
|
2378
|
+
_broadcastToClient(peerId, currentTick, entities, hostInput) {
|
|
2277
2379
|
let clientEntities = entities;
|
|
2278
2380
|
if (this._interestFilter) {
|
|
2279
2381
|
clientEntities = /* @__PURE__ */ new Map();
|
|
@@ -2297,7 +2399,8 @@ var HostAuthority = class {
|
|
|
2297
2399
|
currentTick,
|
|
2298
2400
|
needsKeyframe ? -1 : clientBaseTick ?? -1,
|
|
2299
2401
|
clientEntities,
|
|
2300
|
-
baseline
|
|
2402
|
+
baseline,
|
|
2403
|
+
hostInput
|
|
2301
2404
|
);
|
|
2302
2405
|
if (packet) {
|
|
2303
2406
|
this._snapshotChannel.send(packet, peerId);
|
|
@@ -2319,6 +2422,8 @@ var ClientReceiver = class {
|
|
|
2319
2422
|
this._packetCount = 0;
|
|
2320
2423
|
// Entity state: full accumulated state from keyframes + deltas
|
|
2321
2424
|
this._fullState = /* @__PURE__ */ new Map();
|
|
2425
|
+
// Listeners fired after each snapshot is merged into the full world state
|
|
2426
|
+
this._snapshotListeners = [];
|
|
2322
2427
|
this._transport = transport;
|
|
2323
2428
|
this._codec = codec;
|
|
2324
2429
|
this._bufferSize = options?.bufferSize ?? 3;
|
|
@@ -2405,16 +2510,21 @@ var ClientReceiver = class {
|
|
|
2405
2510
|
requestKeyframe() {
|
|
2406
2511
|
this._ackChannel.send("-1");
|
|
2407
2512
|
}
|
|
2513
|
+
/** Register a listener fired after each snapshot is merged into the full world state */
|
|
2514
|
+
onSnapshot(cb) {
|
|
2515
|
+
this._snapshotListeners.push(cb);
|
|
2516
|
+
}
|
|
2408
2517
|
destroy() {
|
|
2409
2518
|
this._snapshotChannel.close();
|
|
2410
2519
|
this._ackChannel.close();
|
|
2411
2520
|
this._buffer = [];
|
|
2412
2521
|
this._interpolatedState.clear();
|
|
2413
2522
|
this._fullState.clear();
|
|
2523
|
+
this._snapshotListeners = [];
|
|
2414
2524
|
}
|
|
2415
2525
|
_handleSnapshot(data) {
|
|
2416
2526
|
try {
|
|
2417
|
-
const { tick, baseTick, entities } = this._codec.deserializePacket(data);
|
|
2527
|
+
const { tick, baseTick, entities, hostInput } = this._codec.deserializePacket(data);
|
|
2418
2528
|
const now = performance.now();
|
|
2419
2529
|
if (baseTick === -1) {
|
|
2420
2530
|
this._fullState.clear();
|
|
@@ -2430,15 +2540,19 @@ var ClientReceiver = class {
|
|
|
2430
2540
|
}
|
|
2431
2541
|
}
|
|
2432
2542
|
}
|
|
2543
|
+
const fullStateClone = new Map(this._fullState);
|
|
2433
2544
|
this._buffer.push({
|
|
2434
2545
|
tick,
|
|
2435
|
-
entities:
|
|
2546
|
+
entities: fullStateClone,
|
|
2436
2547
|
receivedAt: now
|
|
2437
2548
|
});
|
|
2438
2549
|
while (this._buffer.length > this._bufferSize * 2) {
|
|
2439
2550
|
this._buffer.shift();
|
|
2440
2551
|
}
|
|
2441
2552
|
this._ackChannel.send(String(tick));
|
|
2553
|
+
for (const cb of this._snapshotListeners) {
|
|
2554
|
+
cb(tick, fullStateClone, hostInput);
|
|
2555
|
+
}
|
|
2442
2556
|
this._lastSnapshotTime = now;
|
|
2443
2557
|
this._packetCount++;
|
|
2444
2558
|
} catch {
|
|
@@ -2596,6 +2710,8 @@ var SnapshotSync = class {
|
|
|
2596
2710
|
constructor(transport, codec, snapshotBuffer, options) {
|
|
2597
2711
|
this._hostAuthority = null;
|
|
2598
2712
|
this._clientReceiver = null;
|
|
2713
|
+
// Snapshot listeners survive host migration (forwarder re-attached on demote)
|
|
2714
|
+
this._snapshotListeners = [];
|
|
2599
2715
|
this._transport = transport;
|
|
2600
2716
|
this._codec = codec;
|
|
2601
2717
|
this._snapshotBuffer = snapshotBuffer;
|
|
@@ -2616,6 +2732,7 @@ var SnapshotSync = class {
|
|
|
2616
2732
|
extrapolateMs: options?.extrapolateMs,
|
|
2617
2733
|
is2D: options?.is2D
|
|
2618
2734
|
});
|
|
2735
|
+
this._attachSnapshotForwarder(this._clientReceiver);
|
|
2619
2736
|
}
|
|
2620
2737
|
}
|
|
2621
2738
|
get isHost() {
|
|
@@ -2628,8 +2745,12 @@ var SnapshotSync = class {
|
|
|
2628
2745
|
return this._clientReceiver;
|
|
2629
2746
|
}
|
|
2630
2747
|
/** Host: called every fixed tick to potentially broadcast state */
|
|
2631
|
-
hostTick(tick, entities, delta) {
|
|
2632
|
-
this._hostAuthority?.tick(tick, entities, delta);
|
|
2748
|
+
hostTick(tick, entities, delta, hostInput) {
|
|
2749
|
+
this._hostAuthority?.tick(tick, entities, delta, hostInput);
|
|
2750
|
+
}
|
|
2751
|
+
/** Register a listener fired after each merged snapshot (client side). */
|
|
2752
|
+
onSnapshot(cb) {
|
|
2753
|
+
this._snapshotListeners.push(cb);
|
|
2633
2754
|
}
|
|
2634
2755
|
/** Client: called every render frame to interpolate */
|
|
2635
2756
|
clientInterpolate(renderTime) {
|
|
@@ -2667,254 +2788,516 @@ var SnapshotSync = class {
|
|
|
2667
2788
|
is2D: options?.is2D
|
|
2668
2789
|
}
|
|
2669
2790
|
);
|
|
2791
|
+
this._attachSnapshotForwarder(this._clientReceiver);
|
|
2670
2792
|
}
|
|
2671
2793
|
destroy() {
|
|
2672
2794
|
this._hostAuthority?.destroy();
|
|
2673
2795
|
this._clientReceiver?.destroy();
|
|
2674
2796
|
this._hostAuthority = null;
|
|
2675
2797
|
this._clientReceiver = null;
|
|
2798
|
+
this._snapshotListeners = [];
|
|
2799
|
+
}
|
|
2800
|
+
// ── Private ──
|
|
2801
|
+
/** Forward receiver snapshots to registered listeners (survives host migration). */
|
|
2802
|
+
_attachSnapshotForwarder(receiver) {
|
|
2803
|
+
receiver.onSnapshot((t, e, hi) => {
|
|
2804
|
+
for (const l of this._snapshotListeners) l(t, e, hi);
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
|
|
2809
|
+
// src/core/InputBuffer.ts
|
|
2810
|
+
var InputBuffer = class {
|
|
2811
|
+
constructor(neutralInput, historySize = 120) {
|
|
2812
|
+
/** Local player tick-keyed inputs (ring buffer). */
|
|
2813
|
+
this._local = /* @__PURE__ */ new Map();
|
|
2814
|
+
/** Last-received input per remote peer. */
|
|
2815
|
+
this._remotes = /* @__PURE__ */ new Map();
|
|
2816
|
+
/** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
|
|
2817
|
+
this._peerTicks = /* @__PURE__ */ new Map();
|
|
2818
|
+
this._neutral = { ...neutralInput };
|
|
2819
|
+
this._historySize = historySize;
|
|
2820
|
+
}
|
|
2821
|
+
// ── Local input ──
|
|
2822
|
+
/** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
|
|
2823
|
+
storeTick(tick, input) {
|
|
2824
|
+
this._local.set(tick, { ...input });
|
|
2825
|
+
this._local.delete(tick - this._historySize);
|
|
2826
|
+
}
|
|
2827
|
+
/** Return the local input at `tick`, or a neutral copy if out of range. */
|
|
2828
|
+
getTick(tick) {
|
|
2829
|
+
return this._local.get(tick) ?? { ...this._neutral };
|
|
2830
|
+
}
|
|
2831
|
+
/** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
|
|
2832
|
+
hasTick(tick) {
|
|
2833
|
+
return this._local.has(tick);
|
|
2834
|
+
}
|
|
2835
|
+
/** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
|
|
2836
|
+
getJustPressedZero() {
|
|
2837
|
+
const out = {};
|
|
2838
|
+
for (const key in this._neutral) {
|
|
2839
|
+
const v = this._neutral[key];
|
|
2840
|
+
out[key] = typeof v === "boolean" ? false : v;
|
|
2841
|
+
}
|
|
2842
|
+
return out;
|
|
2843
|
+
}
|
|
2844
|
+
// ── Remote (peer) inputs ──
|
|
2845
|
+
/**
|
|
2846
|
+
* Record a remote peer's input. If `tick` is given (the sender's local tick),
|
|
2847
|
+
* also store it in the per-peer ring buffer for rollback.
|
|
2848
|
+
*/
|
|
2849
|
+
setRemote(peerId, input, tick) {
|
|
2850
|
+
this._remotes.set(peerId, input);
|
|
2851
|
+
if (tick !== void 0) {
|
|
2852
|
+
let peerMap = this._peerTicks.get(peerId);
|
|
2853
|
+
if (!peerMap) {
|
|
2854
|
+
peerMap = /* @__PURE__ */ new Map();
|
|
2855
|
+
this._peerTicks.set(peerId, peerMap);
|
|
2856
|
+
}
|
|
2857
|
+
peerMap.set(tick, input);
|
|
2858
|
+
peerMap.delete(tick - this._historySize);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
/** Last-known input for a peer, or a neutral copy if never received. */
|
|
2862
|
+
getRemote(peerId) {
|
|
2863
|
+
return this._remotes.get(peerId) ?? { ...this._neutral };
|
|
2864
|
+
}
|
|
2865
|
+
/** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
|
|
2866
|
+
allRemotes() {
|
|
2867
|
+
return new Map(this._remotes);
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Return a peer's exact input at the given tick (for rollback accuracy),
|
|
2871
|
+
* falling back to their last-known input when history does not reach that far.
|
|
2872
|
+
*/
|
|
2873
|
+
getRemoteAtTick(peerId, tick) {
|
|
2874
|
+
return this._peerTicks.get(peerId)?.get(tick) ?? this.getRemote(peerId);
|
|
2875
|
+
}
|
|
2876
|
+
/** Override the last-known input for a peer (does NOT touch tick history). */
|
|
2877
|
+
overrideRemote(peerId, input) {
|
|
2878
|
+
this._remotes.set(peerId, input);
|
|
2879
|
+
}
|
|
2880
|
+
/** Number of currently tracked remote peers. */
|
|
2881
|
+
get peerCount() {
|
|
2882
|
+
return this._remotes.size;
|
|
2883
|
+
}
|
|
2884
|
+
/** Iterate tracked peer IDs. */
|
|
2885
|
+
peerIds() {
|
|
2886
|
+
return this._remotes.keys();
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
|
|
2890
|
+
* Call when the room's peer list changes so stale peers stop receiving input.
|
|
2891
|
+
*/
|
|
2892
|
+
setPeerIds(peerIds) {
|
|
2893
|
+
const set = peerIds instanceof Set ? peerIds : new Set(peerIds);
|
|
2894
|
+
for (const id of this._remotes.keys()) {
|
|
2895
|
+
if (!set.has(id)) {
|
|
2896
|
+
this._remotes.delete(id);
|
|
2897
|
+
this._peerTicks.delete(id);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
// ── Lifecycle ──
|
|
2902
|
+
/** Clear local history, remote last-known inputs, and per-peer tick history. */
|
|
2903
|
+
clear() {
|
|
2904
|
+
this._local.clear();
|
|
2905
|
+
this._remotes.clear();
|
|
2906
|
+
this._peerTicks.clear();
|
|
2676
2907
|
}
|
|
2677
2908
|
};
|
|
2678
2909
|
|
|
2910
|
+
// src/core/InputUtils.ts
|
|
2911
|
+
function computeJustPressed(curr, prev) {
|
|
2912
|
+
const out = {};
|
|
2913
|
+
for (const key in curr) {
|
|
2914
|
+
const c = curr[key];
|
|
2915
|
+
out[key] = typeof c === "boolean" ? c === true && prev[key] !== true : c;
|
|
2916
|
+
}
|
|
2917
|
+
return out;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// src/sync/Rollback.ts
|
|
2921
|
+
var IDENTITY_QUAT = { x: 0, y: 0, z: 0, w: 1 };
|
|
2922
|
+
function quatMultiply(a, b) {
|
|
2923
|
+
return {
|
|
2924
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
2925
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
2926
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
2927
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
function quatInvert(q) {
|
|
2931
|
+
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
|
|
2932
|
+
}
|
|
2933
|
+
function quatNormalize(q) {
|
|
2934
|
+
const mag = Math.hypot(q.x, q.y, q.z, q.w);
|
|
2935
|
+
if (mag < 1e-12) return { ...IDENTITY_QUAT };
|
|
2936
|
+
return { x: q.x / mag, y: q.y / mag, z: q.z / mag, w: q.w / mag };
|
|
2937
|
+
}
|
|
2938
|
+
function quatAngle(q) {
|
|
2939
|
+
return 2 * Math.acos(Math.min(1, Math.abs(q.w)));
|
|
2940
|
+
}
|
|
2941
|
+
function quatScaleAngle(q, factor) {
|
|
2942
|
+
let n = quatNormalize(q);
|
|
2943
|
+
if (n.w < 0) n = { x: -n.x, y: -n.y, z: -n.z, w: -n.w };
|
|
2944
|
+
const angle = 2 * Math.acos(Math.min(1, n.w));
|
|
2945
|
+
if (angle < 1e-6) return { ...IDENTITY_QUAT };
|
|
2946
|
+
const s = Math.sin(angle / 2);
|
|
2947
|
+
const ax = n.x / s;
|
|
2948
|
+
const ay = n.y / s;
|
|
2949
|
+
const az = n.z / s;
|
|
2950
|
+
const na = angle * factor;
|
|
2951
|
+
const ns = Math.sin(na / 2);
|
|
2952
|
+
return { x: ax * ns, y: ay * ns, z: az * ns, w: Math.cos(na / 2) };
|
|
2953
|
+
}
|
|
2954
|
+
function applyRollback(params) {
|
|
2955
|
+
const {
|
|
2956
|
+
serverTick,
|
|
2957
|
+
serverState,
|
|
2958
|
+
localTick,
|
|
2959
|
+
localPeerId,
|
|
2960
|
+
inputs,
|
|
2961
|
+
currentErrors,
|
|
2962
|
+
driver,
|
|
2963
|
+
callback,
|
|
2964
|
+
dt,
|
|
2965
|
+
driftTargetTicks,
|
|
2966
|
+
maxRewindTicks,
|
|
2967
|
+
snapThreshold
|
|
2968
|
+
} = params;
|
|
2969
|
+
const preState = driver.captureState();
|
|
2970
|
+
const preMap = /* @__PURE__ */ new Map();
|
|
2971
|
+
for (const [id, s] of preState) {
|
|
2972
|
+
const e = currentErrors.get(id);
|
|
2973
|
+
const ex = e?.x ?? 0;
|
|
2974
|
+
const ey = e?.y ?? 0;
|
|
2975
|
+
if ("z" in s) {
|
|
2976
|
+
const errQ = e ? { x: e.qx, y: e.qy, z: e.qz, w: e.qw } : { ...IDENTITY_QUAT };
|
|
2977
|
+
preMap.set(id, {
|
|
2978
|
+
x: s.x + ex,
|
|
2979
|
+
y: s.y + ey,
|
|
2980
|
+
z: s.z + (e?.z ?? 0),
|
|
2981
|
+
a: 0,
|
|
2982
|
+
q: quatMultiply(errQ, { x: s.qx, y: s.qy, z: s.qz, w: s.qw }),
|
|
2983
|
+
is3D: true
|
|
2984
|
+
});
|
|
2985
|
+
} else {
|
|
2986
|
+
preMap.set(id, {
|
|
2987
|
+
x: s.x + ex,
|
|
2988
|
+
y: s.y + ey,
|
|
2989
|
+
z: 0,
|
|
2990
|
+
a: s.a + (e?.a ?? 0),
|
|
2991
|
+
q: { ...IDENTITY_QUAT },
|
|
2992
|
+
is3D: false
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
driver.applyState(serverState.values());
|
|
2997
|
+
const targetTick = serverTick + driftTargetTicks;
|
|
2998
|
+
const tickDiff = localTick - targetTick;
|
|
2999
|
+
let newLocalTick = localTick;
|
|
3000
|
+
let snapped = false;
|
|
3001
|
+
if (Math.abs(tickDiff) > maxRewindTicks) {
|
|
3002
|
+
newLocalTick = targetTick;
|
|
3003
|
+
snapped = true;
|
|
3004
|
+
} else {
|
|
3005
|
+
for (let i = serverTick + 1; i <= localTick; i++) {
|
|
3006
|
+
if (callback) {
|
|
3007
|
+
const tickInputs = /* @__PURE__ */ new Map();
|
|
3008
|
+
const justPressed = /* @__PURE__ */ new Map();
|
|
3009
|
+
for (const peerId of inputs.peerIds()) {
|
|
3010
|
+
if (peerId === localPeerId) continue;
|
|
3011
|
+
const curr = inputs.getRemoteAtTick(peerId, i);
|
|
3012
|
+
tickInputs.set(peerId, curr);
|
|
3013
|
+
justPressed.set(
|
|
3014
|
+
peerId,
|
|
3015
|
+
computeJustPressed(curr, inputs.getRemoteAtTick(peerId, i - 1))
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
const localCurr = inputs.getTick(i);
|
|
3019
|
+
tickInputs.set(localPeerId, localCurr);
|
|
3020
|
+
justPressed.set(
|
|
3021
|
+
localPeerId,
|
|
3022
|
+
computeJustPressed(localCurr, inputs.getTick(i - 1))
|
|
3023
|
+
);
|
|
3024
|
+
callback(tickInputs, justPressed, i, true, dt);
|
|
3025
|
+
}
|
|
3026
|
+
driver.stepWorld?.();
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const postState = driver.captureState();
|
|
3030
|
+
const errors = /* @__PURE__ */ new Map();
|
|
3031
|
+
for (const [id, post] of postState) {
|
|
3032
|
+
const pre = preMap.get(id);
|
|
3033
|
+
if (!pre) continue;
|
|
3034
|
+
const errX = pre.x - post.x;
|
|
3035
|
+
const errY = pre.y - post.y;
|
|
3036
|
+
let errZ = 0;
|
|
3037
|
+
let errA = 0;
|
|
3038
|
+
let q = { ...IDENTITY_QUAT };
|
|
3039
|
+
if ("z" in post) {
|
|
3040
|
+
errZ = pre.z - post.z;
|
|
3041
|
+
let dq = quatNormalize(
|
|
3042
|
+
quatMultiply(
|
|
3043
|
+
pre.q,
|
|
3044
|
+
quatInvert({ x: post.qx, y: post.qy, z: post.qz, w: post.qw })
|
|
3045
|
+
)
|
|
3046
|
+
);
|
|
3047
|
+
if (dq.w < 0) dq = { x: -dq.x, y: -dq.y, z: -dq.z, w: -dq.w };
|
|
3048
|
+
q = dq;
|
|
3049
|
+
} else {
|
|
3050
|
+
const ad = pre.a - post.a;
|
|
3051
|
+
errA = ad - Math.PI * 2 * Math.floor((ad + Math.PI) / (Math.PI * 2));
|
|
3052
|
+
}
|
|
3053
|
+
if (Math.abs(errX) > snapThreshold || Math.abs(errY) > snapThreshold || Math.abs(errZ) > snapThreshold) {
|
|
3054
|
+
errors.set(id, { x: 0, y: 0, z: 0, a: 0, qx: 0, qy: 0, qz: 0, qw: 1 });
|
|
3055
|
+
} else {
|
|
3056
|
+
errors.set(id, {
|
|
3057
|
+
x: errX,
|
|
3058
|
+
y: errY,
|
|
3059
|
+
z: errZ,
|
|
3060
|
+
a: errA,
|
|
3061
|
+
qx: q.x,
|
|
3062
|
+
qy: q.y,
|
|
3063
|
+
qz: q.z,
|
|
3064
|
+
qw: q.w
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
return { newLocalTick, snapped, errors };
|
|
3069
|
+
}
|
|
3070
|
+
|
|
2679
3071
|
// src/sync/PredictionSync.ts
|
|
2680
|
-
var import_msgpackr2 = require("msgpackr");
|
|
2681
3072
|
var DEFAULT_OPTIONS = {
|
|
2682
3073
|
maxRewindTicks: 15,
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
3074
|
+
snapThreshold: 150,
|
|
3075
|
+
errorDecay: 0.85,
|
|
3076
|
+
maxErrorPerFrame: 0,
|
|
3077
|
+
neutralInput: {},
|
|
3078
|
+
inputHistorySize: 120,
|
|
3079
|
+
driftTargetTicks: 4
|
|
2687
3080
|
};
|
|
2688
3081
|
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();
|
|
3082
|
+
constructor(transport, tickKeeper, snapshots, options) {
|
|
3083
|
+
// Local input; persists across ticks until replaced (hold-input semantics)
|
|
3084
|
+
this._currentInput = null;
|
|
3085
|
+
// Newest pending server snapshot awaiting rollback (only the newest survives)
|
|
3086
|
+
this._pending = null;
|
|
3087
|
+
// Per-entity accumulated visual error offsets
|
|
3088
|
+
this._errors = /* @__PURE__ */ new Map();
|
|
2700
3089
|
this._serverTick = 0;
|
|
2701
|
-
|
|
3090
|
+
this._lastAppliedServerTick = 0;
|
|
3091
|
+
this._worldDriver = null;
|
|
2702
3092
|
this._onPhysicsStep = null;
|
|
2703
|
-
// Own input for current tick
|
|
2704
|
-
this._currentInput = null;
|
|
2705
3093
|
this._transport = transport;
|
|
2706
|
-
this._codec = codec;
|
|
2707
3094
|
this._tickKeeper = tickKeeper;
|
|
2708
3095
|
this._options = { ...DEFAULT_OPTIONS, ...options };
|
|
2709
|
-
this.
|
|
3096
|
+
this._inputs = new InputBuffer(
|
|
3097
|
+
this._options.neutralInput,
|
|
3098
|
+
this._options.inputHistorySize
|
|
3099
|
+
);
|
|
2710
3100
|
this._inputChannel = transport.createChannel("carver:inputs", {
|
|
2711
3101
|
reliable: true,
|
|
2712
3102
|
ordered: true
|
|
2713
3103
|
});
|
|
2714
|
-
this.
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
3104
|
+
this._inputChannel.onReceive(
|
|
3105
|
+
(data, peerId) => {
|
|
3106
|
+
try {
|
|
3107
|
+
const packet = typeof data === "string" ? JSON.parse(data) : data;
|
|
3108
|
+
if (typeof packet.t === "number" && packet.i !== null && typeof packet.i === "object") {
|
|
3109
|
+
this._inputs.setRemote(peerId, packet.i, packet.t);
|
|
3110
|
+
}
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
if (typeof console !== "undefined")
|
|
3113
|
+
console.debug("[CarverJS] Malformed input packet:", err);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
);
|
|
3117
|
+
snapshots.onSnapshot((tick, entities, hostInput) => {
|
|
3118
|
+
if (this._transport.isHost) return;
|
|
3119
|
+
if (tick <= this._lastAppliedServerTick) return;
|
|
3120
|
+
this._serverTick = tick;
|
|
3121
|
+
this._tickKeeper.setServerTick(tick);
|
|
3122
|
+
if (!this._pending || tick > this._pending.t) {
|
|
3123
|
+
this._pending = { t: tick, entities, hostInput };
|
|
3124
|
+
}
|
|
2718
3125
|
});
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
ordered: true
|
|
3126
|
+
transport.onPeerLeave(() => {
|
|
3127
|
+
this._inputs.setPeerIds(this._transport.peers);
|
|
2722
3128
|
});
|
|
2723
|
-
if (this._isHost) {
|
|
2724
|
-
this._setupHostListeners();
|
|
2725
|
-
} else {
|
|
2726
|
-
this._setupClientListeners();
|
|
2727
|
-
}
|
|
2728
3129
|
}
|
|
2729
|
-
|
|
3130
|
+
// ── Wiring ──
|
|
3131
|
+
/** Set the game simulation callback (forward sim + rollback resim). */
|
|
2730
3132
|
setPhysicsStep(cb) {
|
|
2731
3133
|
this._onPhysicsStep = cb;
|
|
2732
3134
|
}
|
|
2733
|
-
/** Set the
|
|
3135
|
+
/** Set the world driver used for forward stepping and rollback. */
|
|
3136
|
+
setWorldDriver(driver) {
|
|
3137
|
+
this._worldDriver = driver;
|
|
3138
|
+
}
|
|
3139
|
+
// ── Input ──
|
|
3140
|
+
/** Set the local player's input. PERSISTS across ticks until replaced. */
|
|
2734
3141
|
setInput(input) {
|
|
2735
3142
|
this._currentInput = input;
|
|
2736
3143
|
}
|
|
3144
|
+
/** Local input stored at the given tick (neutral fallback). Used by the host to embed `hi`. */
|
|
3145
|
+
getLocalInput(tick) {
|
|
3146
|
+
return this._inputs.getTick(tick);
|
|
3147
|
+
}
|
|
3148
|
+
// ── Frame lifecycle ──
|
|
2737
3149
|
/**
|
|
2738
|
-
*
|
|
2739
|
-
*
|
|
3150
|
+
* Apply the newest pending server snapshot (full-world rollback).
|
|
3151
|
+
* Call once per render frame BEFORE tickKeeper.update().
|
|
3152
|
+
* No-op on host or when nothing is pending.
|
|
2740
3153
|
*/
|
|
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;
|
|
3154
|
+
beginFrame() {
|
|
3155
|
+
if (this._transport.isHost || !this._pending) return;
|
|
3156
|
+
const pending = this._pending;
|
|
3157
|
+
this._pending = null;
|
|
3158
|
+
this._lastAppliedServerTick = pending.t;
|
|
3159
|
+
if (pending.hostInput !== void 0) {
|
|
3160
|
+
this._inputs.setRemote(
|
|
3161
|
+
this._transport.hostId,
|
|
3162
|
+
pending.hostInput,
|
|
3163
|
+
pending.t
|
|
3164
|
+
);
|
|
2757
3165
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
3166
|
+
if (!this._worldDriver) return;
|
|
3167
|
+
const result = applyRollback({
|
|
3168
|
+
serverTick: pending.t,
|
|
3169
|
+
serverState: pending.entities,
|
|
3170
|
+
localTick: this._tickKeeper.tick,
|
|
3171
|
+
localPeerId: this._transport.peerId,
|
|
3172
|
+
inputs: this._inputs,
|
|
3173
|
+
currentErrors: this._errors,
|
|
3174
|
+
driver: this._worldDriver,
|
|
3175
|
+
callback: this._onPhysicsStep,
|
|
3176
|
+
dt: this._tickKeeper.tickDelta,
|
|
3177
|
+
driftTargetTicks: this._options.driftTargetTicks,
|
|
3178
|
+
maxRewindTicks: this._options.maxRewindTicks,
|
|
3179
|
+
snapThreshold: this._options.snapThreshold
|
|
3180
|
+
});
|
|
3181
|
+
this._errors = result.errors;
|
|
3182
|
+
if (result.newLocalTick !== this._tickKeeper.tick) {
|
|
3183
|
+
this._tickKeeper.snapTick(result.newLocalTick);
|
|
2761
3184
|
}
|
|
2762
3185
|
}
|
|
2763
3186
|
/**
|
|
2764
|
-
*
|
|
2765
|
-
*
|
|
3187
|
+
* Run one forward fixed tick (host AND client): store + broadcast input,
|
|
3188
|
+
* build per-tick input maps, invoke the callback, then step the world.
|
|
2766
3189
|
*/
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3190
|
+
tick(tick) {
|
|
3191
|
+
const localInput = this._currentInput ?? { ...this._options.neutralInput };
|
|
3192
|
+
this._inputs.storeTick(tick, localInput);
|
|
3193
|
+
this._inputChannel.send({
|
|
3194
|
+
t: tick,
|
|
3195
|
+
i: localInput,
|
|
3196
|
+
p: this._transport.peerId
|
|
3197
|
+
});
|
|
3198
|
+
const prevTick = tick - 1;
|
|
3199
|
+
const tickInputs = this._inputs.allRemotes();
|
|
3200
|
+
tickInputs.delete(this._transport.peerId);
|
|
3201
|
+
const justPressed = /* @__PURE__ */ new Map();
|
|
3202
|
+
for (const [peerId, inp] of tickInputs) {
|
|
3203
|
+
justPressed.set(
|
|
3204
|
+
peerId,
|
|
3205
|
+
computeJustPressed(inp, this._inputs.getRemoteAtTick(peerId, prevTick))
|
|
3206
|
+
);
|
|
3207
|
+
}
|
|
3208
|
+
tickInputs.set(this._transport.peerId, localInput);
|
|
3209
|
+
justPressed.set(
|
|
3210
|
+
this._transport.peerId,
|
|
3211
|
+
this._inputs.hasTick(prevTick) ? computeJustPressed(localInput, this._inputs.getTick(prevTick)) : this._inputs.getJustPressedZero()
|
|
3212
|
+
// suppress spurious edges after snap/rejoin
|
|
3213
|
+
);
|
|
3214
|
+
if (this._onPhysicsStep) {
|
|
3215
|
+
this._onPhysicsStep(
|
|
3216
|
+
tickInputs,
|
|
3217
|
+
justPressed,
|
|
3218
|
+
tick,
|
|
3219
|
+
false,
|
|
3220
|
+
this._tickKeeper.tickDelta
|
|
3221
|
+
);
|
|
2779
3222
|
}
|
|
3223
|
+
this._worldDriver?.stepWorld?.();
|
|
2780
3224
|
}
|
|
2781
3225
|
/**
|
|
2782
|
-
*
|
|
2783
|
-
*
|
|
3226
|
+
* Decay stored error offsets and return the portion to ADD to rendered
|
|
3227
|
+
* transforms this frame. Call exactly once per render frame.
|
|
2784
3228
|
*/
|
|
2785
|
-
|
|
3229
|
+
getRenderErrorOffsets() {
|
|
2786
3230
|
const result = /* @__PURE__ */ new Map();
|
|
2787
|
-
const decay = this._options.
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
3231
|
+
const decay = this._options.errorDecay;
|
|
3232
|
+
const maxErr = this._options.maxErrorPerFrame;
|
|
3233
|
+
for (const [id, e] of this._errors) {
|
|
3234
|
+
e.x *= decay;
|
|
3235
|
+
e.y *= decay;
|
|
3236
|
+
e.z *= decay;
|
|
3237
|
+
e.a *= decay;
|
|
3238
|
+
let q = quatScaleAngle({ x: e.qx, y: e.qy, z: e.qz, w: e.qw }, decay);
|
|
3239
|
+
if (Math.abs(e.x) < 0.1) e.x = 0;
|
|
3240
|
+
if (Math.abs(e.y) < 0.1) e.y = 0;
|
|
3241
|
+
if (Math.abs(e.z) < 0.1) e.z = 0;
|
|
3242
|
+
if (Math.abs(e.a) < 1e-3) e.a = 0;
|
|
3243
|
+
if (quatAngle(q) < 1e-3) q = { x: 0, y: 0, z: 0, w: 1 };
|
|
3244
|
+
e.qx = q.x;
|
|
3245
|
+
e.qy = q.y;
|
|
3246
|
+
e.qz = q.z;
|
|
3247
|
+
e.qw = q.w;
|
|
3248
|
+
let ax = e.x;
|
|
3249
|
+
let ay = e.y;
|
|
3250
|
+
let az = e.z;
|
|
3251
|
+
if (maxErr > 0) {
|
|
3252
|
+
const mag = Math.hypot(e.x, e.y, e.z);
|
|
3253
|
+
if (mag > maxErr) {
|
|
3254
|
+
const s = maxErr / mag;
|
|
3255
|
+
ax = e.x * s;
|
|
3256
|
+
ay = e.y * s;
|
|
3257
|
+
az = e.z * s;
|
|
3258
|
+
e.x -= ax;
|
|
3259
|
+
e.y -= ay;
|
|
3260
|
+
e.z -= az;
|
|
3261
|
+
} else {
|
|
3262
|
+
e.x = 0;
|
|
3263
|
+
e.y = 0;
|
|
3264
|
+
e.z = 0;
|
|
3265
|
+
}
|
|
2793
3266
|
}
|
|
2794
|
-
const
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3267
|
+
const quatIsIdentity = e.qx === 0 && e.qy === 0 && e.qz === 0 && e.qw === 1;
|
|
3268
|
+
if (ax !== 0 || ay !== 0 || az !== 0 || e.a !== 0 || !quatIsIdentity) {
|
|
3269
|
+
result.set(id, {
|
|
3270
|
+
x: ax,
|
|
3271
|
+
y: ay,
|
|
3272
|
+
z: az,
|
|
3273
|
+
a: e.a,
|
|
3274
|
+
qx: e.qx,
|
|
3275
|
+
qy: e.qy,
|
|
3276
|
+
qz: e.qz,
|
|
3277
|
+
qw: e.qw
|
|
3278
|
+
});
|
|
2799
3279
|
}
|
|
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);
|
|
3280
|
+
if (e.x === 0 && e.y === 0 && e.z === 0 && e.a === 0 && quatIsIdentity) {
|
|
3281
|
+
this._errors.delete(id);
|
|
2806
3282
|
}
|
|
2807
|
-
result.set(id, corrected);
|
|
2808
3283
|
}
|
|
2809
3284
|
return result;
|
|
2810
3285
|
}
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
}
|
|
3286
|
+
// ── State ──
|
|
3287
|
+
/** Tick of the newest RECEIVED snapshot. */
|
|
2814
3288
|
get serverTick() {
|
|
2815
3289
|
return this._serverTick;
|
|
2816
3290
|
}
|
|
3291
|
+
/** Tick of the newest APPLIED (rolled-back) snapshot, 0 initially. */
|
|
3292
|
+
get lastAppliedServerTick() {
|
|
3293
|
+
return this._lastAppliedServerTick;
|
|
3294
|
+
}
|
|
2817
3295
|
destroy() {
|
|
2818
3296
|
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);
|
|
3297
|
+
this._inputs.clear();
|
|
3298
|
+
this._errors.clear();
|
|
3299
|
+
this._pending = null;
|
|
3300
|
+
this._currentInput = null;
|
|
2918
3301
|
}
|
|
2919
3302
|
};
|
|
2920
3303
|
|
|
@@ -3110,6 +3493,34 @@ function applyState3D(ref, state) {
|
|
|
3110
3493
|
}
|
|
3111
3494
|
}
|
|
3112
3495
|
}
|
|
3496
|
+
function applyStateHard2D(ref, state) {
|
|
3497
|
+
applyState2D(ref, state);
|
|
3498
|
+
if (ref.rigidBody) {
|
|
3499
|
+
try {
|
|
3500
|
+
if (typeof ref.rigidBody.setLinvel === "function") {
|
|
3501
|
+
ref.rigidBody.setLinvel({ x: state.vx, y: state.vy }, true);
|
|
3502
|
+
}
|
|
3503
|
+
if (typeof ref.rigidBody.setAngvel === "function") {
|
|
3504
|
+
ref.rigidBody.setAngvel(state.va, true);
|
|
3505
|
+
}
|
|
3506
|
+
} catch {
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
function applyStateHard3D(ref, state) {
|
|
3511
|
+
applyState3D(ref, state);
|
|
3512
|
+
if (ref.rigidBody) {
|
|
3513
|
+
try {
|
|
3514
|
+
if (typeof ref.rigidBody.setLinvel === "function") {
|
|
3515
|
+
ref.rigidBody.setLinvel({ x: state.vx, y: state.vy, z: state.vz }, true);
|
|
3516
|
+
}
|
|
3517
|
+
if (typeof ref.rigidBody.setAngvel === "function") {
|
|
3518
|
+
ref.rigidBody.setAngvel({ x: state.wx, y: state.wy, z: state.wz }, true);
|
|
3519
|
+
}
|
|
3520
|
+
} catch {
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3113
3524
|
function applyEntityState(ref, state, is2D) {
|
|
3114
3525
|
if (is2D) {
|
|
3115
3526
|
applyState2D(ref, state);
|
|
@@ -3125,6 +3536,10 @@ function applyStatesToActors(states, registry, is2D) {
|
|
|
3125
3536
|
applyEntityState(ref, state, is2D);
|
|
3126
3537
|
}
|
|
3127
3538
|
}
|
|
3539
|
+
var IDENTITY_QUAT2 = { x: 0, y: 0, z: 0, w: 1 };
|
|
3540
|
+
function isIdentityQuat(q) {
|
|
3541
|
+
return q.x === 0 && q.y === 0 && q.z === 0 && q.w === 1;
|
|
3542
|
+
}
|
|
3128
3543
|
function useMultiplayer(options = {}) {
|
|
3129
3544
|
const { networkManager } = useMultiplayerContext();
|
|
3130
3545
|
const mode = options.mode ?? networkManager.syncMode;
|
|
@@ -3140,6 +3555,7 @@ function useMultiplayer(options = {}) {
|
|
|
3140
3555
|
const [drift, setDrift] = (0, import_react8.useState)(0);
|
|
3141
3556
|
const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
|
|
3142
3557
|
const wasHostRef = (0, import_react8.useRef)(null);
|
|
3558
|
+
const appliedErrorsRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
|
|
3143
3559
|
const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
|
|
3144
3560
|
return {
|
|
3145
3561
|
broadcastRate: options.broadcastRate,
|
|
@@ -3156,6 +3572,36 @@ function useMultiplayer(options = {}) {
|
|
|
3156
3572
|
options.interpolation?.method,
|
|
3157
3573
|
options.interpolation?.extrapolateMs
|
|
3158
3574
|
]);
|
|
3575
|
+
const undoAppliedErrorOffsets = (0, import_react8.useCallback)(() => {
|
|
3576
|
+
const registry = actorRegistry.current;
|
|
3577
|
+
for (const [id, e] of appliedErrorsRef.current) {
|
|
3578
|
+
const ref = registry.get(id);
|
|
3579
|
+
if (!ref) {
|
|
3580
|
+
appliedErrorsRef.current.delete(id);
|
|
3581
|
+
continue;
|
|
3582
|
+
}
|
|
3583
|
+
const pos = ref.object3D.position;
|
|
3584
|
+
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;
|
|
3585
|
+
if (untouched) {
|
|
3586
|
+
pos.x -= e.x;
|
|
3587
|
+
pos.y -= e.y;
|
|
3588
|
+
pos.z -= e.z;
|
|
3589
|
+
if (e.a !== 0) {
|
|
3590
|
+
ref.object3D.rotation.z -= e.a;
|
|
3591
|
+
}
|
|
3592
|
+
if (!isIdentityQuat(e.q)) {
|
|
3593
|
+
const inv = quatInvert(e.q);
|
|
3594
|
+
const cur = ref.object3D.quaternion;
|
|
3595
|
+
const r = quatMultiply(inv, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
|
|
3596
|
+
cur.set(r.x, r.y, r.z, r.w);
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
appliedErrorsRef.current.clear();
|
|
3601
|
+
}, []);
|
|
3602
|
+
const setInput = (0, import_react8.useCallback)((input) => {
|
|
3603
|
+
predictionSyncRef.current?.setInput(input);
|
|
3604
|
+
}, []);
|
|
3159
3605
|
(0, import_react8.useEffect)(() => {
|
|
3160
3606
|
const transport = networkManager.transport;
|
|
3161
3607
|
if (!transport) return;
|
|
@@ -3184,15 +3630,30 @@ function useMultiplayer(options = {}) {
|
|
|
3184
3630
|
);
|
|
3185
3631
|
}
|
|
3186
3632
|
if (mode === "prediction") {
|
|
3633
|
+
const driver = {
|
|
3634
|
+
captureState: () => buildEntityMap(actorRegistry.current.getNetworked(), is2DRef.current ?? true),
|
|
3635
|
+
applyState: (entities) => {
|
|
3636
|
+
const is2D = is2DRef.current ?? true;
|
|
3637
|
+
for (const state of entities) {
|
|
3638
|
+
if (state.c && state.c.__removed) continue;
|
|
3639
|
+
const ref = actorRegistry.current.get(state.id);
|
|
3640
|
+
if (!ref) continue;
|
|
3641
|
+
if (is2D) applyStateHard2D(ref, state);
|
|
3642
|
+
else applyStateHard3D(ref, state);
|
|
3643
|
+
}
|
|
3644
|
+
},
|
|
3645
|
+
...options.stepWorld ? { stepWorld: options.stepWorld } : {}
|
|
3646
|
+
};
|
|
3187
3647
|
predictionSyncRef.current = new PredictionSync(
|
|
3188
3648
|
transport,
|
|
3189
|
-
networkManager.codec,
|
|
3190
3649
|
networkManager.tickKeeper,
|
|
3650
|
+
snapshotSyncRef.current,
|
|
3191
3651
|
options.prediction
|
|
3192
3652
|
);
|
|
3193
3653
|
if (options.onPhysicsStep) {
|
|
3194
3654
|
predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
|
|
3195
3655
|
}
|
|
3656
|
+
predictionSyncRef.current.setWorldDriver(driver);
|
|
3196
3657
|
}
|
|
3197
3658
|
wasHostRef.current = networkManager.isHost;
|
|
3198
3659
|
setIsActive(true);
|
|
@@ -3221,9 +3682,10 @@ function useMultiplayer(options = {}) {
|
|
|
3221
3682
|
snapshotSyncRef.current = null;
|
|
3222
3683
|
predictionSyncRef.current = null;
|
|
3223
3684
|
networkSimulatorRef.current = null;
|
|
3685
|
+
appliedErrorsRef.current.clear();
|
|
3224
3686
|
setIsActive(false);
|
|
3225
3687
|
};
|
|
3226
|
-
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
|
|
3688
|
+
}, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.stepWorld, options.debug]);
|
|
3227
3689
|
(0, import_react8.useEffect)(() => {
|
|
3228
3690
|
const unsub = networkManager.onConnectionStateChange(() => {
|
|
3229
3691
|
setNetworkQuality(networkManager.networkQuality);
|
|
@@ -3243,33 +3705,67 @@ function useMultiplayer(options = {}) {
|
|
|
3243
3705
|
}
|
|
3244
3706
|
const is2D = is2DRef.current ?? true;
|
|
3245
3707
|
if (mode === "events") return;
|
|
3708
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3709
|
+
undoAppliedErrorOffsets();
|
|
3710
|
+
predictionSyncRef.current.beginFrame();
|
|
3711
|
+
}
|
|
3246
3712
|
const ticksThisFrame = tickKeeper.update(delta);
|
|
3247
3713
|
for (let i = 0; i < ticksThisFrame; i++) {
|
|
3248
3714
|
const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
|
|
3249
|
-
if (
|
|
3715
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3716
|
+
predictionSyncRef.current.tick(currentTick);
|
|
3717
|
+
if (isHost) {
|
|
3718
|
+
const entities = buildEntityMap(actorRegistry.current.getNetworked(), is2D);
|
|
3719
|
+
snapshotSyncRef.current?.hostTick(
|
|
3720
|
+
currentTick,
|
|
3721
|
+
entities,
|
|
3722
|
+
tickKeeper.tickDelta,
|
|
3723
|
+
predictionSyncRef.current.getLocalInput(currentTick)
|
|
3724
|
+
);
|
|
3725
|
+
}
|
|
3726
|
+
} else if (mode === "snapshot" && isHost) {
|
|
3250
3727
|
const networked = actorRegistry.current.getNetworked();
|
|
3251
3728
|
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
|
-
}
|
|
3729
|
+
snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
|
|
3262
3730
|
}
|
|
3263
3731
|
}
|
|
3264
|
-
if (!isHost) {
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3732
|
+
if (!isHost && mode === "snapshot" && snapshotSyncRef.current) {
|
|
3733
|
+
const renderTime = performance.now();
|
|
3734
|
+
const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
|
|
3735
|
+
applyStatesToActors(interpolated, actorRegistry.current, is2D);
|
|
3736
|
+
}
|
|
3737
|
+
if (mode === "prediction" && predictionSyncRef.current) {
|
|
3738
|
+
const offsets = predictionSyncRef.current.getRenderErrorOffsets();
|
|
3739
|
+
for (const [id, off] of offsets) {
|
|
3740
|
+
const ref = actorRegistry.current.get(id);
|
|
3741
|
+
if (!ref) continue;
|
|
3742
|
+
const pos = ref.object3D.position;
|
|
3743
|
+
pos.x += off.x;
|
|
3744
|
+
pos.y += off.y;
|
|
3745
|
+
if (!is2D) pos.z += off.z;
|
|
3746
|
+
let appliedA = 0;
|
|
3747
|
+
let appliedQ = IDENTITY_QUAT2;
|
|
3748
|
+
if (is2D) {
|
|
3749
|
+
ref.object3D.rotation.z += off.a;
|
|
3750
|
+
appliedA = off.a;
|
|
3751
|
+
} else {
|
|
3752
|
+
appliedQ = { x: off.qx, y: off.qy, z: off.qz, w: off.qw };
|
|
3753
|
+
if (!isIdentityQuat(appliedQ)) {
|
|
3754
|
+
const cur = ref.object3D.quaternion;
|
|
3755
|
+
const r = quatMultiply(appliedQ, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
|
|
3756
|
+
cur.set(r.x, r.y, r.z, r.w);
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
appliedErrorsRef.current.set(id, {
|
|
3760
|
+
x: off.x,
|
|
3761
|
+
y: off.y,
|
|
3762
|
+
z: is2D ? 0 : off.z,
|
|
3763
|
+
a: appliedA,
|
|
3764
|
+
q: appliedQ,
|
|
3765
|
+
px: pos.x,
|
|
3766
|
+
py: pos.y,
|
|
3767
|
+
pz: pos.z
|
|
3768
|
+
});
|
|
3273
3769
|
}
|
|
3274
3770
|
}
|
|
3275
3771
|
if (ticksThisFrame > 0) {
|
|
@@ -3289,7 +3785,8 @@ function useMultiplayer(options = {}) {
|
|
|
3289
3785
|
tick,
|
|
3290
3786
|
serverTick,
|
|
3291
3787
|
drift,
|
|
3292
|
-
syncEngine: mode
|
|
3788
|
+
syncEngine: mode,
|
|
3789
|
+
setInput
|
|
3293
3790
|
};
|
|
3294
3791
|
}
|
|
3295
3792
|
|
|
@@ -3801,11 +4298,13 @@ var InterestManager = class {
|
|
|
3801
4298
|
0 && (module.exports = {
|
|
3802
4299
|
DebugOverlay,
|
|
3803
4300
|
FirebaseStrategy,
|
|
4301
|
+
InputBuffer,
|
|
3804
4302
|
InterestManager,
|
|
3805
4303
|
MqttStrategy,
|
|
3806
4304
|
MultiplayerBridge,
|
|
3807
4305
|
MultiplayerProvider,
|
|
3808
4306
|
NetworkSimulator,
|
|
4307
|
+
computeJustPressed,
|
|
3809
4308
|
useHost,
|
|
3810
4309
|
useLobby,
|
|
3811
4310
|
useMultiplayer,
|