@rapierphysicsplugin/client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/clock-sync.test.d.ts +2 -0
- package/dist/__tests__/clock-sync.test.d.ts.map +1 -0
- package/dist/__tests__/clock-sync.test.js +63 -0
- package/dist/__tests__/clock-sync.test.js.map +1 -0
- package/dist/__tests__/interpolator.test.d.ts +2 -0
- package/dist/__tests__/interpolator.test.d.ts.map +1 -0
- package/dist/__tests__/interpolator.test.js +82 -0
- package/dist/__tests__/interpolator.test.js.map +1 -0
- package/dist/__tests__/state-reconciler.test.d.ts +2 -0
- package/dist/__tests__/state-reconciler.test.d.ts.map +1 -0
- package/dist/__tests__/state-reconciler.test.js +86 -0
- package/dist/__tests__/state-reconciler.test.js.map +1 -0
- package/dist/clock-sync.d.ts +17 -0
- package/dist/clock-sync.d.ts.map +1 -0
- package/dist/clock-sync.js +63 -0
- package/dist/clock-sync.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/input-manager.d.ts +18 -0
- package/dist/input-manager.d.ts.map +1 -0
- package/dist/input-manager.js +62 -0
- package/dist/input-manager.js.map +1 -0
- package/dist/interpolator.d.ts +35 -0
- package/dist/interpolator.d.ts.map +1 -0
- package/dist/interpolator.js +198 -0
- package/dist/interpolator.js.map +1 -0
- package/dist/networked-rapier-plugin.d.ts +82 -0
- package/dist/networked-rapier-plugin.d.ts.map +1 -0
- package/dist/networked-rapier-plugin.js +698 -0
- package/dist/networked-rapier-plugin.js.map +1 -0
- package/dist/rapier-body-ops.d.ts +27 -0
- package/dist/rapier-body-ops.d.ts.map +1 -0
- package/dist/rapier-body-ops.js +208 -0
- package/dist/rapier-body-ops.js.map +1 -0
- package/dist/rapier-collision-ops.d.ts +6 -0
- package/dist/rapier-collision-ops.d.ts.map +1 -0
- package/dist/rapier-collision-ops.js +200 -0
- package/dist/rapier-collision-ops.js.map +1 -0
- package/dist/rapier-constraint-ops.d.ts +29 -0
- package/dist/rapier-constraint-ops.d.ts.map +1 -0
- package/dist/rapier-constraint-ops.js +286 -0
- package/dist/rapier-constraint-ops.js.map +1 -0
- package/dist/rapier-plugin.d.ts +145 -0
- package/dist/rapier-plugin.d.ts.map +1 -0
- package/dist/rapier-plugin.js +263 -0
- package/dist/rapier-plugin.js.map +1 -0
- package/dist/rapier-shape-ops.d.ts +21 -0
- package/dist/rapier-shape-ops.d.ts.map +1 -0
- package/dist/rapier-shape-ops.js +314 -0
- package/dist/rapier-shape-ops.js.map +1 -0
- package/dist/rapier-types.d.ts +58 -0
- package/dist/rapier-types.d.ts.map +1 -0
- package/dist/rapier-types.js +4 -0
- package/dist/rapier-types.js.map +1 -0
- package/dist/state-reconciler.d.ts +28 -0
- package/dist/state-reconciler.d.ts.map +1 -0
- package/dist/state-reconciler.js +119 -0
- package/dist/state-reconciler.js.map +1 -0
- package/dist/sync-client.d.ts +110 -0
- package/dist/sync-client.d.ts.map +1 -0
- package/dist/sync-client.js +514 -0
- package/dist/sync-client.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/clock-sync.test.ts +72 -0
- package/src/__tests__/interpolator.test.ts +98 -0
- package/src/__tests__/state-reconciler.test.ts +102 -0
- package/src/clock-sync.ts +77 -0
- package/src/index.ts +9 -0
- package/src/input-manager.ts +72 -0
- package/src/interpolator.ts +256 -0
- package/src/networked-rapier-plugin.ts +909 -0
- package/src/rapier-body-ops.ts +251 -0
- package/src/rapier-collision-ops.ts +229 -0
- package/src/rapier-constraint-ops.ts +327 -0
- package/src/rapier-plugin.ts +364 -0
- package/src/rapier-shape-ops.ts +369 -0
- package/src/rapier-types.ts +60 -0
- package/src/state-reconciler.ts +151 -0
- package/src/sync-client.ts +640 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BodyDescriptor,
|
|
3
|
+
BodyState,
|
|
4
|
+
CollisionEventData,
|
|
5
|
+
ConstraintDescriptor,
|
|
6
|
+
RoomSnapshot,
|
|
7
|
+
InputAction,
|
|
8
|
+
ClientMessage,
|
|
9
|
+
ServerMessage,
|
|
10
|
+
ClientInput,
|
|
11
|
+
} from '@rapierphysicsplugin/shared';
|
|
12
|
+
import {
|
|
13
|
+
MessageType,
|
|
14
|
+
encodeMessage,
|
|
15
|
+
decodeServerMessage,
|
|
16
|
+
FIELD_POSITION,
|
|
17
|
+
FIELD_ROTATION,
|
|
18
|
+
FIELD_LIN_VEL,
|
|
19
|
+
FIELD_ANG_VEL,
|
|
20
|
+
OPCODE_MESH_BINARY,
|
|
21
|
+
decodeMeshBinary,
|
|
22
|
+
OPCODE_GEOMETRY_DEF,
|
|
23
|
+
OPCODE_MESH_REF,
|
|
24
|
+
OPCODE_MATERIAL_DEF,
|
|
25
|
+
OPCODE_TEXTURE_DEF,
|
|
26
|
+
decodeGeometryDef,
|
|
27
|
+
decodeMeshRef,
|
|
28
|
+
decodeMaterialDef,
|
|
29
|
+
decodeTextureDef,
|
|
30
|
+
} from '@rapierphysicsplugin/shared';
|
|
31
|
+
import type { MeshBinaryMessage, GeometryDefData, MeshRefData, MaterialDefData, TextureDefData } from '@rapierphysicsplugin/shared';
|
|
32
|
+
import { ClockSyncClient } from './clock-sync.js';
|
|
33
|
+
import { StateReconciler } from './state-reconciler.js';
|
|
34
|
+
import { Interpolator } from './interpolator.js';
|
|
35
|
+
import { InputManager } from './input-manager.js';
|
|
36
|
+
|
|
37
|
+
type StateUpdateCallback = (state: RoomSnapshot) => void;
|
|
38
|
+
type BodyAddedCallback = (body: BodyDescriptor) => void;
|
|
39
|
+
type BodyRemovedCallback = (bodyId: string) => void;
|
|
40
|
+
type SimulationStartedCallback = (snapshot: RoomSnapshot) => void;
|
|
41
|
+
type CollisionEventsCallback = (events: CollisionEventData[]) => void;
|
|
42
|
+
type ConstraintAddedCallback = (constraint: ConstraintDescriptor) => void;
|
|
43
|
+
type ConstraintRemovedCallback = (constraintId: string) => void;
|
|
44
|
+
type MeshBinaryCallback = (msg: MeshBinaryMessage) => void;
|
|
45
|
+
type GeometryDefCallback = (data: GeometryDefData) => void;
|
|
46
|
+
type MeshRefCallback = (data: MeshRefData) => void;
|
|
47
|
+
type MaterialDefCallback = (data: MaterialDefData) => void;
|
|
48
|
+
type TextureDefCallback = (data: TextureDefData) => void;
|
|
49
|
+
|
|
50
|
+
export class PhysicsSyncClient {
|
|
51
|
+
private ws: WebSocket | null = null;
|
|
52
|
+
private clockSync: ClockSyncClient;
|
|
53
|
+
private reconciler: StateReconciler;
|
|
54
|
+
private inputManager: InputManager;
|
|
55
|
+
private clientId: string | null = null;
|
|
56
|
+
private roomId: string | null = null;
|
|
57
|
+
|
|
58
|
+
// Body ID mapping for numeric wire format
|
|
59
|
+
private indexToId: Map<number, string> = new Map();
|
|
60
|
+
private idToIndex: Map<string, number> = new Map();
|
|
61
|
+
|
|
62
|
+
// Full state map — merges partial delta updates into complete body states
|
|
63
|
+
private fullStateMap: Map<string, BodyState> = new Map();
|
|
64
|
+
|
|
65
|
+
private _simulationRunning = false;
|
|
66
|
+
private _bytesSent = 0;
|
|
67
|
+
private _bytesReceived = 0;
|
|
68
|
+
private stateUpdateCallbacks: StateUpdateCallback[] = [];
|
|
69
|
+
private bodyAddedCallbacks: BodyAddedCallback[] = [];
|
|
70
|
+
private bodyRemovedCallbacks: BodyRemovedCallback[] = [];
|
|
71
|
+
private simulationStartedCallbacks: SimulationStartedCallback[] = [];
|
|
72
|
+
private collisionEventsCallbacks: CollisionEventsCallback[] = [];
|
|
73
|
+
private constraintAddedCallbacks: ConstraintAddedCallback[] = [];
|
|
74
|
+
private constraintRemovedCallbacks: ConstraintRemovedCallback[] = [];
|
|
75
|
+
private meshBinaryCallbacks: MeshBinaryCallback[] = [];
|
|
76
|
+
private geometryDefCallbacks: GeometryDefCallback[] = [];
|
|
77
|
+
private meshRefCallbacks: MeshRefCallback[] = [];
|
|
78
|
+
private materialDefCallbacks: MaterialDefCallback[] = [];
|
|
79
|
+
private textureDefCallbacks: TextureDefCallback[] = [];
|
|
80
|
+
|
|
81
|
+
private connectResolve: (() => void) | null = null;
|
|
82
|
+
private connectReject: ((err: Error) => void) | null = null;
|
|
83
|
+
private joinResolve: ((snapshot: RoomSnapshot) => void) | null = null;
|
|
84
|
+
private createResolve: (() => void) | null = null;
|
|
85
|
+
|
|
86
|
+
constructor() {
|
|
87
|
+
this.clockSync = new ClockSyncClient();
|
|
88
|
+
const interpolator = new Interpolator();
|
|
89
|
+
this.reconciler = new StateReconciler(interpolator);
|
|
90
|
+
this.inputManager = new InputManager();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
connect(url: string): Promise<void> {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
this.connectResolve = resolve;
|
|
96
|
+
this.connectReject = reject;
|
|
97
|
+
|
|
98
|
+
this.ws = new WebSocket(url);
|
|
99
|
+
this.ws.binaryType = 'arraybuffer';
|
|
100
|
+
|
|
101
|
+
this.ws.onopen = () => {
|
|
102
|
+
this.clockSync.start((data) => {
|
|
103
|
+
this._bytesSent += data.byteLength;
|
|
104
|
+
this.ws?.send(data);
|
|
105
|
+
});
|
|
106
|
+
this.connectResolve?.();
|
|
107
|
+
this.connectResolve = null;
|
|
108
|
+
this.connectReject = null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.ws.onmessage = async (event) => {
|
|
112
|
+
let buf: Uint8Array;
|
|
113
|
+
if (event.data instanceof ArrayBuffer) {
|
|
114
|
+
buf = new Uint8Array(event.data);
|
|
115
|
+
} else if (event.data instanceof Blob) {
|
|
116
|
+
buf = new Uint8Array(await event.data.arrayBuffer());
|
|
117
|
+
} else {
|
|
118
|
+
console.warn('[SyncClient] Unexpected message data type:', typeof event.data);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this._bytesReceived += buf.byteLength;
|
|
122
|
+
try {
|
|
123
|
+
// Intercept mesh binary messages directly — skip normal decode path
|
|
124
|
+
if (buf[0] === OPCODE_MESH_BINARY) {
|
|
125
|
+
const decoded = decodeMeshBinary(buf);
|
|
126
|
+
const msg: MeshBinaryMessage = { type: MessageType.MESH_BINARY, ...decoded };
|
|
127
|
+
for (const cb of this.meshBinaryCallbacks) {
|
|
128
|
+
cb(msg);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Intercept geometry def messages
|
|
134
|
+
if (buf[0] === OPCODE_GEOMETRY_DEF) {
|
|
135
|
+
const decoded = decodeGeometryDef(buf);
|
|
136
|
+
for (const cb of this.geometryDefCallbacks) {
|
|
137
|
+
cb(decoded);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Intercept mesh ref messages
|
|
143
|
+
if (buf[0] === OPCODE_MESH_REF) {
|
|
144
|
+
const decoded = decodeMeshRef(buf);
|
|
145
|
+
for (const cb of this.meshRefCallbacks) {
|
|
146
|
+
cb(decoded);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Intercept material def messages
|
|
152
|
+
if (buf[0] === OPCODE_MATERIAL_DEF) {
|
|
153
|
+
const decoded = decodeMaterialDef(buf);
|
|
154
|
+
for (const cb of this.materialDefCallbacks) {
|
|
155
|
+
cb(decoded);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Intercept texture def messages
|
|
161
|
+
if (buf[0] === OPCODE_TEXTURE_DEF) {
|
|
162
|
+
const decoded = decodeTextureDef(buf);
|
|
163
|
+
for (const cb of this.textureDefCallbacks) {
|
|
164
|
+
cb(decoded);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const message = decodeServerMessage(buf, this.indexToId);
|
|
169
|
+
this.handleMessage(message);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn(
|
|
172
|
+
`[SyncClient] Failed to decode server message (${buf.byteLength} bytes, opcode=0x${buf[0]?.toString(16)}):`,
|
|
173
|
+
err,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.ws.onclose = () => {
|
|
179
|
+
this.clockSync.stop();
|
|
180
|
+
this.inputManager.stop();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
this.ws.onerror = (event) => {
|
|
184
|
+
this.connectReject?.(new Error('WebSocket connection failed'));
|
|
185
|
+
this.connectResolve = null;
|
|
186
|
+
this.connectReject = null;
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
createRoom(roomId: string, initialBodies: BodyDescriptor[], gravity?: { x: number; y: number; z: number }): Promise<void> {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
if (!this.ws) {
|
|
194
|
+
reject(new Error('Not connected'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.createResolve = resolve;
|
|
199
|
+
|
|
200
|
+
this.send({
|
|
201
|
+
type: MessageType.CREATE_ROOM,
|
|
202
|
+
roomId,
|
|
203
|
+
initialBodies,
|
|
204
|
+
gravity,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
joinRoom(roomId: string): Promise<RoomSnapshot> {
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
if (!this.ws) {
|
|
212
|
+
reject(new Error('Not connected'));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.joinResolve = resolve;
|
|
217
|
+
|
|
218
|
+
this.send({
|
|
219
|
+
type: MessageType.JOIN_ROOM,
|
|
220
|
+
roomId,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
leaveRoom(): void {
|
|
226
|
+
if (!this.ws || !this.roomId) return;
|
|
227
|
+
|
|
228
|
+
this.send({ type: MessageType.LEAVE_ROOM });
|
|
229
|
+
this.roomId = null;
|
|
230
|
+
this.inputManager.stop();
|
|
231
|
+
this.reconciler.clear();
|
|
232
|
+
this.fullStateMap.clear();
|
|
233
|
+
this.indexToId.clear();
|
|
234
|
+
this.idToIndex.clear();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
sendInput(actions: InputAction[]): void {
|
|
238
|
+
for (const action of actions) {
|
|
239
|
+
this.inputManager.queueAction(action);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
addLocalBody(bodyId: string): void {
|
|
244
|
+
this.reconciler.addLocalBody(bodyId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
removeLocalBody(bodyId: string): void {
|
|
248
|
+
this.reconciler.removeLocalBody(bodyId);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
addBody(body: BodyDescriptor): void {
|
|
252
|
+
if (!this.ws) return;
|
|
253
|
+
this.send({ type: MessageType.ADD_BODY, body });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
removeBody(bodyId: string): void {
|
|
257
|
+
if (!this.ws) return;
|
|
258
|
+
this.send({ type: MessageType.REMOVE_BODY, bodyId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
addConstraint(constraint: ConstraintDescriptor): void {
|
|
262
|
+
if (!this.ws) return;
|
|
263
|
+
this.send({ type: MessageType.ADD_CONSTRAINT, constraint });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
removeConstraint(constraintId: string): void {
|
|
267
|
+
if (!this.ws) return;
|
|
268
|
+
this.send({ type: MessageType.REMOVE_CONSTRAINT, constraintId });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
onStateUpdate(callback: StateUpdateCallback): void {
|
|
272
|
+
this.stateUpdateCallbacks.push(callback);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onBodyAdded(callback: BodyAddedCallback): void {
|
|
276
|
+
this.bodyAddedCallbacks.push(callback);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onBodyRemoved(callback: BodyRemovedCallback): void {
|
|
280
|
+
this.bodyRemovedCallbacks.push(callback);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
onSimulationStarted(callback: SimulationStartedCallback): void {
|
|
284
|
+
this.simulationStartedCallbacks.push(callback);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
onCollisionEvents(callback: CollisionEventsCallback): void {
|
|
288
|
+
this.collisionEventsCallbacks.push(callback);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
onConstraintAdded(callback: ConstraintAddedCallback): void {
|
|
292
|
+
this.constraintAddedCallbacks.push(callback);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
onConstraintRemoved(callback: ConstraintRemovedCallback): void {
|
|
296
|
+
this.constraintRemovedCallbacks.push(callback);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
onMeshBinary(callback: MeshBinaryCallback): void {
|
|
300
|
+
this.meshBinaryCallbacks.push(callback);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
onGeometryDef(callback: GeometryDefCallback): void {
|
|
304
|
+
this.geometryDefCallbacks.push(callback);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
onMeshRef(callback: MeshRefCallback): void {
|
|
308
|
+
this.meshRefCallbacks.push(callback);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
onMaterialDef(callback: MaterialDefCallback): void {
|
|
312
|
+
this.materialDefCallbacks.push(callback);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
onTextureDef(callback: TextureDefCallback): void {
|
|
316
|
+
this.textureDefCallbacks.push(callback);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Send pre-encoded binary mesh data directly over the WebSocket (no msgpackr wrapping). */
|
|
320
|
+
sendMeshBinary(encoded: Uint8Array): void {
|
|
321
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
322
|
+
this._bytesSent += encoded.byteLength;
|
|
323
|
+
this.ws.send(encoded);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Send pre-encoded GEOMETRY_DEF directly over the WebSocket. */
|
|
328
|
+
sendGeometryDef(encoded: Uint8Array): void {
|
|
329
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
330
|
+
this._bytesSent += encoded.byteLength;
|
|
331
|
+
this.ws.send(encoded);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Send pre-encoded MESH_REF directly over the WebSocket. */
|
|
336
|
+
sendMeshRef(encoded: Uint8Array): void {
|
|
337
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
338
|
+
this._bytesSent += encoded.byteLength;
|
|
339
|
+
this.ws.send(encoded);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Send pre-encoded MATERIAL_DEF directly over the WebSocket. */
|
|
344
|
+
sendMaterialDef(encoded: Uint8Array): void {
|
|
345
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
346
|
+
this._bytesSent += encoded.byteLength;
|
|
347
|
+
this.ws.send(encoded);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Send pre-encoded TEXTURE_DEF directly over the WebSocket. */
|
|
352
|
+
sendTextureDef(encoded: Uint8Array): void {
|
|
353
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
354
|
+
this._bytesSent += encoded.byteLength;
|
|
355
|
+
this.ws.send(encoded);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
startSimulation(): void {
|
|
360
|
+
if (!this.ws) return;
|
|
361
|
+
this.send({ type: MessageType.START_SIMULATION });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
get simulationRunning(): boolean {
|
|
365
|
+
return this._simulationRunning;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Total number of bodies the client knows about (including sleeping/unchanged ones) */
|
|
369
|
+
get totalBodyCount(): number {
|
|
370
|
+
return this.fullStateMap.size;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
disconnect(): void {
|
|
374
|
+
this.clockSync.stop();
|
|
375
|
+
this.inputManager.stop();
|
|
376
|
+
this.reconciler.clear();
|
|
377
|
+
this.fullStateMap.clear();
|
|
378
|
+
this.indexToId.clear();
|
|
379
|
+
this.idToIndex.clear();
|
|
380
|
+
this.ws?.close();
|
|
381
|
+
this.ws = null;
|
|
382
|
+
this.roomId = null;
|
|
383
|
+
this.clientId = null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private initBodyIdMap(bodyIdMap: Record<string, number>): void {
|
|
387
|
+
this.indexToId.clear();
|
|
388
|
+
this.idToIndex.clear();
|
|
389
|
+
for (const [id, index] of Object.entries(bodyIdMap)) {
|
|
390
|
+
this.indexToId.set(index, id);
|
|
391
|
+
this.idToIndex.set(id, index);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private addBodyIdMapping(id: string, index: number): void {
|
|
396
|
+
this.indexToId.set(index, id);
|
|
397
|
+
this.idToIndex.set(id, index);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private initFullState(bodies: BodyState[]): void {
|
|
401
|
+
this.fullStateMap.clear();
|
|
402
|
+
for (const body of bodies) {
|
|
403
|
+
this.fullStateMap.set(body.id, { ...body });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Merge partial delta bodies into the full state map.
|
|
409
|
+
* Returns the merged (complete) body states for the bodies that were in the delta.
|
|
410
|
+
*/
|
|
411
|
+
private mergeDelta(bodies: BodyState[]): BodyState[] {
|
|
412
|
+
const merged: BodyState[] = [];
|
|
413
|
+
for (const body of bodies) {
|
|
414
|
+
const existing = this.fullStateMap.get(body.id);
|
|
415
|
+
if (existing) {
|
|
416
|
+
const mask = body.fieldMask;
|
|
417
|
+
if (mask !== undefined) {
|
|
418
|
+
if (mask & FIELD_POSITION) existing.position = body.position;
|
|
419
|
+
if (mask & FIELD_ROTATION) existing.rotation = body.rotation;
|
|
420
|
+
if (mask & FIELD_LIN_VEL) existing.linVel = body.linVel;
|
|
421
|
+
if (mask & FIELD_ANG_VEL) existing.angVel = body.angVel;
|
|
422
|
+
} else {
|
|
423
|
+
// No fieldMask = full update
|
|
424
|
+
existing.position = body.position;
|
|
425
|
+
existing.rotation = body.rotation;
|
|
426
|
+
existing.linVel = body.linVel;
|
|
427
|
+
existing.angVel = body.angVel;
|
|
428
|
+
}
|
|
429
|
+
merged.push(existing);
|
|
430
|
+
} else {
|
|
431
|
+
// New body — add to map
|
|
432
|
+
const newBody: BodyState = { ...body };
|
|
433
|
+
delete newBody.fieldMask;
|
|
434
|
+
this.fullStateMap.set(body.id, newBody);
|
|
435
|
+
merged.push(newBody);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return merged;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private handleMessage(message: ServerMessage): void {
|
|
442
|
+
switch (message.type) {
|
|
443
|
+
case MessageType.CLOCK_SYNC_RESPONSE:
|
|
444
|
+
this.clockSync.handleResponse(message);
|
|
445
|
+
break;
|
|
446
|
+
|
|
447
|
+
case MessageType.ROOM_CREATED:
|
|
448
|
+
this.createResolve?.();
|
|
449
|
+
this.createResolve = null;
|
|
450
|
+
break;
|
|
451
|
+
|
|
452
|
+
case MessageType.ROOM_JOINED:
|
|
453
|
+
this.roomId = message.roomId;
|
|
454
|
+
this.clientId = message.clientId;
|
|
455
|
+
this._simulationRunning = message.simulationRunning;
|
|
456
|
+
|
|
457
|
+
// Initialize body ID mapping
|
|
458
|
+
if (message.bodyIdMap) {
|
|
459
|
+
this.initBodyIdMap(message.bodyIdMap);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Initialize full state from snapshot
|
|
463
|
+
this.initFullState(message.snapshot.bodies);
|
|
464
|
+
|
|
465
|
+
// Start input manager
|
|
466
|
+
this.inputManager.start(
|
|
467
|
+
(input) => this.sendClientInput(input),
|
|
468
|
+
() => this.clockSync.getServerTick()
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// Notify about existing constraints
|
|
472
|
+
if (message.constraints) {
|
|
473
|
+
for (const c of message.constraints) {
|
|
474
|
+
for (const cb of this.constraintAddedCallbacks) {
|
|
475
|
+
cb(c);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Replay body descriptors for late joiners
|
|
481
|
+
if (message.bodies) {
|
|
482
|
+
for (const b of message.bodies) {
|
|
483
|
+
if (message.bodyIdMap && message.bodyIdMap[b.id] !== undefined) {
|
|
484
|
+
this.addBodyIdMapping(b.id, message.bodyIdMap[b.id]);
|
|
485
|
+
}
|
|
486
|
+
for (const cb of this.bodyAddedCallbacks) {
|
|
487
|
+
cb(b);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
this.joinResolve?.(message.snapshot);
|
|
493
|
+
this.joinResolve = null;
|
|
494
|
+
break;
|
|
495
|
+
|
|
496
|
+
case MessageType.ROOM_STATE: {
|
|
497
|
+
// Merge partial delta into full state map
|
|
498
|
+
const mergedBodies = this.mergeDelta(message.bodies);
|
|
499
|
+
|
|
500
|
+
const snapshot: RoomSnapshot = {
|
|
501
|
+
tick: message.tick,
|
|
502
|
+
timestamp: message.timestamp,
|
|
503
|
+
bodies: mergedBodies,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// Process through reconciler
|
|
507
|
+
this.reconciler.processServerState(snapshot);
|
|
508
|
+
|
|
509
|
+
// Notify listeners
|
|
510
|
+
for (const cb of this.stateUpdateCallbacks) {
|
|
511
|
+
cb(snapshot);
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
case MessageType.ADD_BODY:
|
|
517
|
+
// Update body ID mapping
|
|
518
|
+
if (message.bodyIndex !== undefined) {
|
|
519
|
+
this.addBodyIdMapping(message.body.id, message.bodyIndex);
|
|
520
|
+
}
|
|
521
|
+
for (const cb of this.bodyAddedCallbacks) {
|
|
522
|
+
cb(message.body);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
|
|
526
|
+
case MessageType.REMOVE_BODY:
|
|
527
|
+
this.fullStateMap.delete(message.bodyId);
|
|
528
|
+
for (const cb of this.bodyRemovedCallbacks) {
|
|
529
|
+
cb(message.bodyId);
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
|
|
533
|
+
case MessageType.SIMULATION_STARTED: {
|
|
534
|
+
this._simulationRunning = true;
|
|
535
|
+
this.reconciler.clear();
|
|
536
|
+
|
|
537
|
+
// Re-initialize body ID mapping if provided
|
|
538
|
+
const simMsg = message as ServerMessage & { bodyIdMap?: Record<string, number> };
|
|
539
|
+
if (simMsg.bodyIdMap) {
|
|
540
|
+
this.initBodyIdMap(simMsg.bodyIdMap);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Re-initialize full state from fresh snapshot
|
|
544
|
+
this.initFullState(message.snapshot.bodies);
|
|
545
|
+
|
|
546
|
+
// Notify about constraints included in reset
|
|
547
|
+
const simConstraints = (message as ServerMessage & { constraints?: ConstraintDescriptor[] }).constraints;
|
|
548
|
+
if (simConstraints) {
|
|
549
|
+
for (const c of simConstraints) {
|
|
550
|
+
for (const cb of this.constraintAddedCallbacks) {
|
|
551
|
+
cb(c);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Replay body descriptors included in reset
|
|
557
|
+
const simBodies = (message as ServerMessage & { bodies?: BodyDescriptor[] }).bodies;
|
|
558
|
+
if (simBodies) {
|
|
559
|
+
for (const b of simBodies) {
|
|
560
|
+
for (const cb of this.bodyAddedCallbacks) {
|
|
561
|
+
cb(b);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const startSnapshot = message.snapshot;
|
|
567
|
+
for (const cb of this.simulationStartedCallbacks) {
|
|
568
|
+
cb(startSnapshot);
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
case MessageType.COLLISION_EVENTS:
|
|
574
|
+
for (const cb of this.collisionEventsCallbacks) {
|
|
575
|
+
cb(message.events);
|
|
576
|
+
}
|
|
577
|
+
break;
|
|
578
|
+
|
|
579
|
+
case MessageType.ADD_CONSTRAINT:
|
|
580
|
+
for (const cb of this.constraintAddedCallbacks) {
|
|
581
|
+
cb(message.constraint);
|
|
582
|
+
}
|
|
583
|
+
break;
|
|
584
|
+
|
|
585
|
+
case MessageType.REMOVE_CONSTRAINT:
|
|
586
|
+
for (const cb of this.constraintRemovedCallbacks) {
|
|
587
|
+
cb(message.constraintId);
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
|
|
591
|
+
case MessageType.ERROR:
|
|
592
|
+
console.error(`Server error: ${message.message}`);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private sendClientInput(input: ClientInput): void {
|
|
598
|
+
this.reconciler.addPendingInput(input);
|
|
599
|
+
this.send({
|
|
600
|
+
type: MessageType.CLIENT_INPUT,
|
|
601
|
+
input,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private send(message: ClientMessage): void {
|
|
606
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
607
|
+
const encoded = encodeMessage(message);
|
|
608
|
+
this._bytesSent += encoded.byteLength;
|
|
609
|
+
this.ws.send(encoded);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
getReconciler(): StateReconciler {
|
|
614
|
+
return this.reconciler;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
getClockSync(): ClockSyncClient {
|
|
618
|
+
return this.clockSync;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
getInputManager(): InputManager {
|
|
622
|
+
return this.inputManager;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
getClientId(): string | null {
|
|
626
|
+
return this.clientId;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
getRoomId(): string | null {
|
|
630
|
+
return this.roomId;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
get bytesSent(): number {
|
|
634
|
+
return this._bytesSent;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
get bytesReceived(): number {
|
|
638
|
+
return this._bytesReceived;
|
|
639
|
+
}
|
|
640
|
+
}
|