@rapierphysicsplugin/server 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__/input-buffer.test.d.ts +2 -0
- package/dist/__tests__/input-buffer.test.d.ts.map +1 -0
- package/dist/__tests__/input-buffer.test.js +53 -0
- package/dist/__tests__/input-buffer.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +182 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/physics-world-collisions.test.d.ts +2 -0
- package/dist/__tests__/physics-world-collisions.test.d.ts.map +1 -0
- package/dist/__tests__/physics-world-collisions.test.js +129 -0
- package/dist/__tests__/physics-world-collisions.test.js.map +1 -0
- package/dist/__tests__/physics-world.test.d.ts +2 -0
- package/dist/__tests__/physics-world.test.d.ts.map +1 -0
- package/dist/__tests__/physics-world.test.js +164 -0
- package/dist/__tests__/physics-world.test.js.map +1 -0
- package/dist/__tests__/room.test.d.ts +2 -0
- package/dist/__tests__/room.test.d.ts.map +1 -0
- package/dist/__tests__/room.test.js +189 -0
- package/dist/__tests__/room.test.js.map +1 -0
- package/dist/__tests__/state-manager.test.d.ts +2 -0
- package/dist/__tests__/state-manager.test.d.ts.map +1 -0
- package/dist/__tests__/state-manager.test.js +122 -0
- package/dist/__tests__/state-manager.test.js.map +1 -0
- package/dist/client-connection.d.ts +18 -0
- package/dist/client-connection.d.ts.map +1 -0
- package/dist/client-connection.js +41 -0
- package/dist/client-connection.js.map +1 -0
- package/dist/clock-sync.d.ts +4 -0
- package/dist/clock-sync.d.ts.map +1 -0
- package/dist/clock-sync.js +10 -0
- package/dist/clock-sync.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/input-buffer.d.ts +11 -0
- package/dist/input-buffer.d.ts.map +1 -0
- package/dist/input-buffer.js +40 -0
- package/dist/input-buffer.js.map +1 -0
- package/dist/physics-world.d.ts +33 -0
- package/dist/physics-world.d.ts.map +1 -0
- package/dist/physics-world.js +326 -0
- package/dist/physics-world.js.map +1 -0
- package/dist/room.d.ts +60 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +393 -0
- package/dist/room.js.map +1 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +268 -0
- package/dist/server.js.map +1 -0
- package/dist/simulation-loop.d.ts +14 -0
- package/dist/simulation-loop.d.ts.map +1 -0
- package/dist/simulation-loop.js +42 -0
- package/dist/simulation-loop.js.map +1 -0
- package/dist/state-manager.d.ts +20 -0
- package/dist/state-manager.d.ts.map +1 -0
- package/dist/state-manager.js +120 -0
- package/dist/state-manager.js.map +1 -0
- package/package.json +24 -0
- package/src/__tests__/input-buffer.test.ts +64 -0
- package/src/__tests__/integration.test.ts +227 -0
- package/src/__tests__/physics-world-collisions.test.ts +155 -0
- package/src/__tests__/physics-world.test.ts +197 -0
- package/src/__tests__/room.test.ts +232 -0
- package/src/__tests__/state-manager.test.ts +152 -0
- package/src/client-connection.ts +52 -0
- package/src/clock-sync.ts +16 -0
- package/src/index.ts +40 -0
- package/src/input-buffer.ts +46 -0
- package/src/physics-world.ts +400 -0
- package/src/room.ts +487 -0
- package/src/server.ts +312 -0
- package/src/simulation-loop.ts +48 -0
- package/src/state-manager.ts +136 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createServer, type Server as HttpServer } from 'node:http';
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import type RAPIER from '@dimforge/rapier3d-compat';
|
|
4
|
+
import {
|
|
5
|
+
MessageType,
|
|
6
|
+
decodeClientMessage,
|
|
7
|
+
encodeMessage,
|
|
8
|
+
DEFAULT_PORT,
|
|
9
|
+
OPCODE_MESH_BINARY,
|
|
10
|
+
OPCODE_GEOMETRY_DEF,
|
|
11
|
+
OPCODE_MESH_REF,
|
|
12
|
+
OPCODE_MATERIAL_DEF,
|
|
13
|
+
OPCODE_TEXTURE_DEF,
|
|
14
|
+
} from '@rapierphysicsplugin/shared';
|
|
15
|
+
import type { ClientMessage } from '@rapierphysicsplugin/shared';
|
|
16
|
+
import { RoomManager } from './room.js';
|
|
17
|
+
import { ClientConnection } from './client-connection.js';
|
|
18
|
+
import { handleClockSyncRequest } from './clock-sync.js';
|
|
19
|
+
|
|
20
|
+
let clientIdCounter = 0;
|
|
21
|
+
function generateClientId(): string {
|
|
22
|
+
return `client_${++clientIdCounter}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PhysicsServer {
|
|
26
|
+
private httpServer: HttpServer | null = null;
|
|
27
|
+
private wss: WebSocketServer | null = null;
|
|
28
|
+
private connections: Map<string, ClientConnection> = new Map();
|
|
29
|
+
private roomManager: RoomManager;
|
|
30
|
+
private rapier: typeof RAPIER;
|
|
31
|
+
|
|
32
|
+
constructor(rapier: typeof RAPIER) {
|
|
33
|
+
this.rapier = rapier;
|
|
34
|
+
this.roomManager = new RoomManager(rapier);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
start(port: number = DEFAULT_PORT): Promise<void> {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
this.httpServer = createServer((_req, res) => {
|
|
40
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
41
|
+
res.end('Physics server OK');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
45
|
+
|
|
46
|
+
this.wss.on('connection', (ws: WebSocket) => {
|
|
47
|
+
this.handleConnection(ws);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.httpServer.listen(port, () => {
|
|
51
|
+
console.log(`Physics server listening on port ${port}`);
|
|
52
|
+
resolve();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private handleConnection(ws: WebSocket): void {
|
|
58
|
+
const clientId = generateClientId();
|
|
59
|
+
const conn = new ClientConnection(clientId, ws);
|
|
60
|
+
this.connections.set(clientId, conn);
|
|
61
|
+
|
|
62
|
+
console.log(`Client connected: ${clientId}`);
|
|
63
|
+
|
|
64
|
+
ws.on('message', (data: Buffer) => {
|
|
65
|
+
try {
|
|
66
|
+
const buf = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
67
|
+
|
|
68
|
+
// Intercept mesh binary — relay without full decode
|
|
69
|
+
if (buf[0] === OPCODE_MESH_BINARY) {
|
|
70
|
+
if (conn.roomId) {
|
|
71
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
72
|
+
if (room) {
|
|
73
|
+
room.relayMeshBinary(conn.id, buf);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Intercept geometry def — relay/store without full decode
|
|
80
|
+
if (buf[0] === OPCODE_GEOMETRY_DEF) {
|
|
81
|
+
if (conn.roomId) {
|
|
82
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
83
|
+
if (room) {
|
|
84
|
+
room.relayGeometryDef(conn.id, buf);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Intercept mesh ref — relay/store without full decode
|
|
91
|
+
if (buf[0] === OPCODE_MESH_REF) {
|
|
92
|
+
if (conn.roomId) {
|
|
93
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
94
|
+
if (room) {
|
|
95
|
+
room.relayMeshRef(conn.id, buf);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Intercept material def — relay/store without full decode
|
|
102
|
+
if (buf[0] === OPCODE_MATERIAL_DEF) {
|
|
103
|
+
if (conn.roomId) {
|
|
104
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
105
|
+
if (room) {
|
|
106
|
+
room.relayMaterialDef(conn.id, buf);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Intercept texture def — relay/store without full decode
|
|
113
|
+
if (buf[0] === OPCODE_TEXTURE_DEF) {
|
|
114
|
+
if (conn.roomId) {
|
|
115
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
116
|
+
if (room) {
|
|
117
|
+
room.relayTextureDef(conn.id, buf);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const message = decodeClientMessage(buf);
|
|
124
|
+
this.handleMessage(conn, message);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error(`Error processing message from ${clientId}:`, err);
|
|
127
|
+
conn.send(encodeMessage({
|
|
128
|
+
type: MessageType.ERROR,
|
|
129
|
+
message: 'Invalid message format',
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
ws.on('close', () => {
|
|
135
|
+
this.handleDisconnect(conn);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
ws.on('error', (err) => {
|
|
139
|
+
console.error(`WebSocket error for ${clientId}:`, err);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private handleMessage(conn: ClientConnection, message: ClientMessage): void {
|
|
144
|
+
switch (message.type) {
|
|
145
|
+
case MessageType.CLOCK_SYNC_REQUEST:
|
|
146
|
+
handleClockSyncRequest(conn, message);
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case MessageType.CREATE_ROOM: {
|
|
150
|
+
try {
|
|
151
|
+
const room = this.roomManager.createRoom(
|
|
152
|
+
message.roomId,
|
|
153
|
+
message.initialBodies,
|
|
154
|
+
message.gravity
|
|
155
|
+
);
|
|
156
|
+
conn.send(encodeMessage({
|
|
157
|
+
type: MessageType.ROOM_CREATED,
|
|
158
|
+
roomId: room.id,
|
|
159
|
+
}));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
conn.send(encodeMessage({
|
|
162
|
+
type: MessageType.ERROR,
|
|
163
|
+
message: (err as Error).message,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case MessageType.JOIN_ROOM: {
|
|
170
|
+
const room = this.roomManager.getRoom(message.roomId);
|
|
171
|
+
if (!room) {
|
|
172
|
+
conn.send(encodeMessage({
|
|
173
|
+
type: MessageType.ERROR,
|
|
174
|
+
message: `Room "${message.roomId}" not found`,
|
|
175
|
+
}));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Leave current room if in one
|
|
180
|
+
if (conn.roomId) {
|
|
181
|
+
const currentRoom = this.roomManager.getRoom(conn.roomId);
|
|
182
|
+
currentRoom?.removeClient(conn);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
room.addClient(conn);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case MessageType.LEAVE_ROOM: {
|
|
190
|
+
if (conn.roomId) {
|
|
191
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
192
|
+
room?.removeClient(conn);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case MessageType.CLIENT_INPUT: {
|
|
198
|
+
if (!conn.roomId) return;
|
|
199
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
200
|
+
if (room) {
|
|
201
|
+
room.bufferInput(conn.id, message.input);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case MessageType.ADD_BODY: {
|
|
207
|
+
if (!conn.roomId) return;
|
|
208
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
209
|
+
if (room) {
|
|
210
|
+
try {
|
|
211
|
+
room.addBody(message.body);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
conn.send(encodeMessage({
|
|
214
|
+
type: MessageType.ERROR,
|
|
215
|
+
message: (err as Error).message,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case MessageType.REMOVE_BODY: {
|
|
223
|
+
if (!conn.roomId) return;
|
|
224
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
225
|
+
if (room) {
|
|
226
|
+
room.removeBody(message.bodyId);
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case MessageType.START_SIMULATION: {
|
|
232
|
+
if (!conn.roomId) return;
|
|
233
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
234
|
+
if (room) {
|
|
235
|
+
room.startSimulation();
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case MessageType.BODY_EVENT: {
|
|
241
|
+
if (!conn.roomId) return;
|
|
242
|
+
// Rebroadcast body events to all clients in the room
|
|
243
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
244
|
+
if (room) {
|
|
245
|
+
// The room broadcast is handled by forwarding the message
|
|
246
|
+
// For now, we just log it
|
|
247
|
+
console.log(`Body event from ${conn.id}: ${message.eventType} on ${message.bodyId}`);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case MessageType.ADD_CONSTRAINT: {
|
|
253
|
+
if (!conn.roomId) return;
|
|
254
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
255
|
+
if (room) {
|
|
256
|
+
try {
|
|
257
|
+
room.addConstraint(message.constraint);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
conn.send(encodeMessage({
|
|
260
|
+
type: MessageType.ERROR,
|
|
261
|
+
message: (err as Error).message,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case MessageType.REMOVE_CONSTRAINT: {
|
|
269
|
+
if (!conn.roomId) return;
|
|
270
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
271
|
+
if (room) {
|
|
272
|
+
room.removeConstraint(message.constraintId);
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private handleDisconnect(conn: ClientConnection): void {
|
|
280
|
+
console.log(`Client disconnected: ${conn.id}`);
|
|
281
|
+
|
|
282
|
+
if (conn.roomId) {
|
|
283
|
+
const room = this.roomManager.getRoom(conn.roomId);
|
|
284
|
+
room?.removeClient(conn);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.connections.delete(conn.id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
stop(): void {
|
|
291
|
+
// Destroy all rooms
|
|
292
|
+
for (const roomId of this.roomManager.getAllRoomIds()) {
|
|
293
|
+
this.roomManager.destroyRoom(roomId);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Close all connections
|
|
297
|
+
for (const [, conn] of this.connections) {
|
|
298
|
+
conn.ws.close();
|
|
299
|
+
}
|
|
300
|
+
this.connections.clear();
|
|
301
|
+
|
|
302
|
+
// Close servers
|
|
303
|
+
this.wss?.close();
|
|
304
|
+
this.wss = null;
|
|
305
|
+
this.httpServer?.close();
|
|
306
|
+
this.httpServer = null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
getRoomManager(): RoomManager {
|
|
310
|
+
return this.roomManager;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { FIXED_TIMESTEP, BROADCAST_INTERVAL } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import type { Room } from './room.js';
|
|
3
|
+
|
|
4
|
+
export class SimulationLoop {
|
|
5
|
+
private running = false;
|
|
6
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
7
|
+
private lastTime = 0;
|
|
8
|
+
private accumulator = 0;
|
|
9
|
+
|
|
10
|
+
constructor(private room: Room) {}
|
|
11
|
+
|
|
12
|
+
start(): void {
|
|
13
|
+
if (this.running) return;
|
|
14
|
+
this.running = true;
|
|
15
|
+
this.lastTime = performance.now();
|
|
16
|
+
this.accumulator = 0;
|
|
17
|
+
|
|
18
|
+
// Use setInterval at ~1ms for high resolution stepping
|
|
19
|
+
this.timer = setInterval(() => this.update(), 1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
stop(): void {
|
|
23
|
+
this.running = false;
|
|
24
|
+
if (this.timer) {
|
|
25
|
+
clearInterval(this.timer);
|
|
26
|
+
this.timer = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private update(): void {
|
|
31
|
+
const now = performance.now();
|
|
32
|
+
const elapsed = (now - this.lastTime) / 1000; // Convert to seconds
|
|
33
|
+
this.lastTime = now;
|
|
34
|
+
|
|
35
|
+
// Cap elapsed time to prevent spiral of death
|
|
36
|
+
const cappedElapsed = Math.min(elapsed, FIXED_TIMESTEP * 10);
|
|
37
|
+
this.accumulator += cappedElapsed;
|
|
38
|
+
|
|
39
|
+
while (this.accumulator >= FIXED_TIMESTEP) {
|
|
40
|
+
this.room.tick();
|
|
41
|
+
this.accumulator -= FIXED_TIMESTEP;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get isRunning(): boolean {
|
|
46
|
+
return this.running;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { BodyState, RoomSnapshot } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { FIELD_POSITION, FIELD_ROTATION, FIELD_LIN_VEL, FIELD_ANG_VEL, FIELD_ALL } from '@rapierphysicsplugin/shared';
|
|
3
|
+
import type { PhysicsWorld } from './physics-world.js';
|
|
4
|
+
|
|
5
|
+
export class StateManager {
|
|
6
|
+
private lastBroadcastStates: Map<string, BodyState> = new Map();
|
|
7
|
+
private bodyIdToIndex: Map<string, number> = new Map();
|
|
8
|
+
private bodyIndexToId: Map<number, string> = new Map();
|
|
9
|
+
private nextBodyIndex = 0;
|
|
10
|
+
|
|
11
|
+
createSnapshot(world: PhysicsWorld, tick: number): RoomSnapshot {
|
|
12
|
+
const bodies = world.getSnapshot();
|
|
13
|
+
// Ensure all bodies have an index assigned
|
|
14
|
+
for (const body of bodies) {
|
|
15
|
+
this.ensureBodyIndex(body.id);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
tick,
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
bodies,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
createDelta(world: PhysicsWorld, tick: number): RoomSnapshot & { isDelta: boolean } {
|
|
25
|
+
const allBodies = world.getSnapshot();
|
|
26
|
+
const changedBodies: BodyState[] = [];
|
|
27
|
+
|
|
28
|
+
for (const body of allBodies) {
|
|
29
|
+
this.ensureBodyIndex(body.id);
|
|
30
|
+
const prev = this.lastBroadcastStates.get(body.id);
|
|
31
|
+
if (!prev) {
|
|
32
|
+
body.fieldMask = FIELD_ALL;
|
|
33
|
+
changedBodies.push(body);
|
|
34
|
+
} else {
|
|
35
|
+
// Skip comparison for sleeping bodies (their state is unchanged)
|
|
36
|
+
if (world.isBodySleeping(body.id)) continue;
|
|
37
|
+
|
|
38
|
+
const mask = getChangedFields(prev, body);
|
|
39
|
+
if (mask !== 0) {
|
|
40
|
+
body.fieldMask = mask;
|
|
41
|
+
changedBodies.push(body);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Update cache
|
|
47
|
+
for (const body of allBodies) {
|
|
48
|
+
this.lastBroadcastStates.set(body.id, body);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Remove bodies no longer in simulation
|
|
52
|
+
for (const id of this.lastBroadcastStates.keys()) {
|
|
53
|
+
if (!world.hasBody(id)) {
|
|
54
|
+
this.lastBroadcastStates.delete(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
tick,
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
bodies: changedBodies,
|
|
62
|
+
isDelta: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ensureBodyIndex(id: string): number {
|
|
67
|
+
let index = this.bodyIdToIndex.get(id);
|
|
68
|
+
if (index === undefined) {
|
|
69
|
+
index = this.nextBodyIndex++;
|
|
70
|
+
this.bodyIdToIndex.set(id, index);
|
|
71
|
+
this.bodyIndexToId.set(index, id);
|
|
72
|
+
}
|
|
73
|
+
return index;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getBodyIndex(id: string): number | undefined {
|
|
77
|
+
return this.bodyIdToIndex.get(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getIdToIndexMap(): Map<string, number> {
|
|
81
|
+
return this.bodyIdToIndex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getIndexToIdMap(): Map<number, string> {
|
|
85
|
+
return this.bodyIndexToId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getIdToIndexRecord(): Record<string, number> {
|
|
89
|
+
const record: Record<string, number> = {};
|
|
90
|
+
for (const [id, index] of this.bodyIdToIndex) {
|
|
91
|
+
record[id] = index;
|
|
92
|
+
}
|
|
93
|
+
return record;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
removeBody(id: string): void {
|
|
97
|
+
this.lastBroadcastStates.delete(id);
|
|
98
|
+
// Note: we don't remove the index mapping — indices are never reused
|
|
99
|
+
// to prevent desync with clients that haven't processed the removal yet
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
clear(): void {
|
|
103
|
+
this.lastBroadcastStates.clear();
|
|
104
|
+
this.bodyIdToIndex.clear();
|
|
105
|
+
this.bodyIndexToId.clear();
|
|
106
|
+
this.nextBodyIndex = 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EPSILON = 0.0001;
|
|
111
|
+
|
|
112
|
+
function vec3Changed(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): boolean {
|
|
113
|
+
return (
|
|
114
|
+
Math.abs(a.x - b.x) > EPSILON ||
|
|
115
|
+
Math.abs(a.y - b.y) > EPSILON ||
|
|
116
|
+
Math.abs(a.z - b.z) > EPSILON
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function quatChanged(a: { x: number; y: number; z: number; w: number }, b: { x: number; y: number; z: number; w: number }): boolean {
|
|
121
|
+
return (
|
|
122
|
+
Math.abs(a.x - b.x) > EPSILON ||
|
|
123
|
+
Math.abs(a.y - b.y) > EPSILON ||
|
|
124
|
+
Math.abs(a.z - b.z) > EPSILON ||
|
|
125
|
+
Math.abs(a.w - b.w) > EPSILON
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getChangedFields(a: BodyState, b: BodyState): number {
|
|
130
|
+
let mask = 0;
|
|
131
|
+
if (vec3Changed(a.position, b.position)) mask |= FIELD_POSITION;
|
|
132
|
+
if (quatChanged(a.rotation, b.rotation)) mask |= FIELD_ROTATION;
|
|
133
|
+
if (vec3Changed(a.linVel, b.linVel)) mask |= FIELD_LIN_VEL;
|
|
134
|
+
if (vec3Changed(a.angVel, b.angVel)) mask |= FIELD_ANG_VEL;
|
|
135
|
+
return mask;
|
|
136
|
+
}
|