@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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
|
2
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
3
|
+
import { Room, RoomManager } from '../room.js';
|
|
4
|
+
import { decodeMessage } from '@rapierphysicsplugin/shared';
|
|
5
|
+
import type { BodyDescriptor, ServerMessage } from '@rapierphysicsplugin/shared';
|
|
6
|
+
|
|
7
|
+
describe('Room', () => {
|
|
8
|
+
let rapier: typeof RAPIER;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await RAPIER.init();
|
|
12
|
+
rapier = RAPIER;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function makeBox(id: string, y: number = 5): BodyDescriptor {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
19
|
+
motionType: 'dynamic',
|
|
20
|
+
position: { x: 0, y, z: 0 },
|
|
21
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
22
|
+
mass: 1.0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it('should create a room with initial bodies', () => {
|
|
27
|
+
const room = new Room('test', rapier);
|
|
28
|
+
room.loadInitialState([makeBox('box1', 5), makeBox('box2', 10)]);
|
|
29
|
+
|
|
30
|
+
const snapshot = room.getSnapshot();
|
|
31
|
+
expect(snapshot.bodies).toHaveLength(2);
|
|
32
|
+
room.destroy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should add and remove bodies', () => {
|
|
36
|
+
const room = new Room('test', rapier);
|
|
37
|
+
room.loadInitialState([makeBox('box1')]);
|
|
38
|
+
|
|
39
|
+
const snapshot1 = room.getSnapshot();
|
|
40
|
+
expect(snapshot1.bodies).toHaveLength(1);
|
|
41
|
+
|
|
42
|
+
room.destroy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should step physics and advance tick', () => {
|
|
46
|
+
const room = new Room('test', rapier);
|
|
47
|
+
room.loadInitialState([makeBox('box1', 10)]);
|
|
48
|
+
|
|
49
|
+
expect(room.tickNumber).toBe(0);
|
|
50
|
+
room.tick();
|
|
51
|
+
expect(room.tickNumber).toBe(1);
|
|
52
|
+
room.tick();
|
|
53
|
+
expect(room.tickNumber).toBe(2);
|
|
54
|
+
|
|
55
|
+
room.destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should process buffered inputs during tick', () => {
|
|
59
|
+
const room = new Room('test', rapier);
|
|
60
|
+
room.loadInitialState([makeBox('box1', 5)]);
|
|
61
|
+
|
|
62
|
+
// Simulate a client connection by directly buffering input
|
|
63
|
+
// We need to add a mock client first
|
|
64
|
+
const mockConn = {
|
|
65
|
+
id: 'client1',
|
|
66
|
+
roomId: null as string | null,
|
|
67
|
+
ws: { readyState: 1, OPEN: 1, send: () => {} } as any,
|
|
68
|
+
send: () => {},
|
|
69
|
+
rtt: 0,
|
|
70
|
+
clockOffset: 0,
|
|
71
|
+
lastAcknowledgedTick: 0,
|
|
72
|
+
inputSequence: 0,
|
|
73
|
+
updateClockSync: () => {},
|
|
74
|
+
mapClientTickToServerTick: () => 0,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
room.addClient(mockConn as any);
|
|
78
|
+
room.bufferInput('client1', {
|
|
79
|
+
tick: 0,
|
|
80
|
+
sequenceNum: 0,
|
|
81
|
+
actions: [
|
|
82
|
+
{ type: 'applyImpulse', bodyId: 'box1', data: { impulse: { x: 10, y: 0, z: 0 } } },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
room.tick();
|
|
87
|
+
|
|
88
|
+
const state = room.getSnapshot();
|
|
89
|
+
const box1 = state.bodies.find(b => b.id === 'box1')!;
|
|
90
|
+
expect(box1.linVel.x).toBeGreaterThan(0);
|
|
91
|
+
|
|
92
|
+
room.destroy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should not auto-start simulation when a client joins', () => {
|
|
96
|
+
const room = new Room('test', rapier);
|
|
97
|
+
room.loadInitialState([makeBox('box1', 5)]);
|
|
98
|
+
|
|
99
|
+
const mockConn = {
|
|
100
|
+
id: 'client1',
|
|
101
|
+
roomId: null as string | null,
|
|
102
|
+
ws: { readyState: 1, OPEN: 1, send: () => {} } as any,
|
|
103
|
+
send: () => {},
|
|
104
|
+
rtt: 0,
|
|
105
|
+
clockOffset: 0,
|
|
106
|
+
lastAcknowledgedTick: 0,
|
|
107
|
+
inputSequence: 0,
|
|
108
|
+
updateClockSync: () => {},
|
|
109
|
+
mapClientTickToServerTick: () => 0,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
room.addClient(mockConn as any);
|
|
113
|
+
expect(room.isSimulationRunning).toBe(false);
|
|
114
|
+
|
|
115
|
+
room.destroy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should start simulation on startSimulation call', () => {
|
|
119
|
+
const room = new Room('test', rapier);
|
|
120
|
+
room.loadInitialState([makeBox('box1', 5)]);
|
|
121
|
+
|
|
122
|
+
const sentMessages: ServerMessage[] = [];
|
|
123
|
+
const mockConn = {
|
|
124
|
+
id: 'client1',
|
|
125
|
+
roomId: null as string | null,
|
|
126
|
+
ws: { readyState: 1, OPEN: 1, send: () => {} } as any,
|
|
127
|
+
send: (msg: Uint8Array) => { sentMessages.push(decodeMessage(msg) as ServerMessage); },
|
|
128
|
+
rtt: 0,
|
|
129
|
+
clockOffset: 0,
|
|
130
|
+
lastAcknowledgedTick: 0,
|
|
131
|
+
inputSequence: 0,
|
|
132
|
+
updateClockSync: () => {},
|
|
133
|
+
mapClientTickToServerTick: () => 0,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
room.addClient(mockConn as any);
|
|
137
|
+
expect(room.isSimulationRunning).toBe(false);
|
|
138
|
+
|
|
139
|
+
room.startSimulation();
|
|
140
|
+
expect(room.isSimulationRunning).toBe(true);
|
|
141
|
+
|
|
142
|
+
// Should have broadcast SIMULATION_STARTED
|
|
143
|
+
const startedMsg = sentMessages.find(m => m.type === 'simulation_started');
|
|
144
|
+
expect(startedMsg).toBeDefined();
|
|
145
|
+
|
|
146
|
+
room.destroy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should reset physics on startSimulation when already running', () => {
|
|
150
|
+
const room = new Room('test', rapier);
|
|
151
|
+
room.loadInitialState([makeBox('box1', 10)]);
|
|
152
|
+
|
|
153
|
+
const mockConn = {
|
|
154
|
+
id: 'client1',
|
|
155
|
+
roomId: null as string | null,
|
|
156
|
+
ws: { readyState: 1, OPEN: 1, send: () => {} } as any,
|
|
157
|
+
send: () => {},
|
|
158
|
+
rtt: 0,
|
|
159
|
+
clockOffset: 0,
|
|
160
|
+
lastAcknowledgedTick: 0,
|
|
161
|
+
inputSequence: 0,
|
|
162
|
+
updateClockSync: () => {},
|
|
163
|
+
mapClientTickToServerTick: () => 0,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
room.addClient(mockConn as any);
|
|
167
|
+
room.startSimulation();
|
|
168
|
+
|
|
169
|
+
// Run some ticks so physics changes
|
|
170
|
+
for (let i = 0; i < 60; i++) {
|
|
171
|
+
room.tick();
|
|
172
|
+
}
|
|
173
|
+
expect(room.tickNumber).toBeGreaterThan(0);
|
|
174
|
+
const stateBefore = room.getSnapshot();
|
|
175
|
+
const box = stateBefore.bodies.find(b => b.id === 'box1')!;
|
|
176
|
+
expect(box.position.y).not.toBeCloseTo(10);
|
|
177
|
+
|
|
178
|
+
// Reset
|
|
179
|
+
room.startSimulation();
|
|
180
|
+
expect(room.tickNumber).toBe(0);
|
|
181
|
+
const stateAfter = room.getSnapshot();
|
|
182
|
+
const boxAfter = stateAfter.bodies.find(b => b.id === 'box1')!;
|
|
183
|
+
expect(boxAfter.position.y).toBeCloseTo(10);
|
|
184
|
+
|
|
185
|
+
room.destroy();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('RoomManager', () => {
|
|
190
|
+
let rapier: typeof RAPIER;
|
|
191
|
+
|
|
192
|
+
beforeAll(async () => {
|
|
193
|
+
await RAPIER.init();
|
|
194
|
+
rapier = RAPIER;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should create and retrieve rooms', () => {
|
|
198
|
+
const manager = new RoomManager(rapier);
|
|
199
|
+
manager.createRoom('room1');
|
|
200
|
+
|
|
201
|
+
expect(manager.getRoom('room1')).toBeDefined();
|
|
202
|
+
expect(manager.roomCount).toBe(1);
|
|
203
|
+
|
|
204
|
+
manager.destroyRoom('room1');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should throw on duplicate room creation', () => {
|
|
208
|
+
const manager = new RoomManager(rapier);
|
|
209
|
+
manager.createRoom('room1');
|
|
210
|
+
expect(() => manager.createRoom('room1')).toThrow('already exists');
|
|
211
|
+
manager.destroyRoom('room1');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should destroy rooms', () => {
|
|
215
|
+
const manager = new RoomManager(rapier);
|
|
216
|
+
manager.createRoom('room1');
|
|
217
|
+
manager.destroyRoom('room1');
|
|
218
|
+
expect(manager.getRoom('room1')).toBeUndefined();
|
|
219
|
+
expect(manager.roomCount).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should list all room ids', () => {
|
|
223
|
+
const manager = new RoomManager(rapier);
|
|
224
|
+
manager.createRoom('room1');
|
|
225
|
+
manager.createRoom('room2');
|
|
226
|
+
|
|
227
|
+
expect(manager.getAllRoomIds().sort()).toEqual(['room1', 'room2']);
|
|
228
|
+
|
|
229
|
+
manager.destroyRoom('room1');
|
|
230
|
+
manager.destroyRoom('room2');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
3
|
+
import { PhysicsWorld } from '../physics-world.js';
|
|
4
|
+
import { StateManager } from '../state-manager.js';
|
|
5
|
+
import type { BodyDescriptor } from '@rapierphysicsplugin/shared';
|
|
6
|
+
import { FIELD_POSITION, FIELD_ROTATION, FIELD_LIN_VEL, FIELD_ANG_VEL, FIELD_ALL } from '@rapierphysicsplugin/shared';
|
|
7
|
+
|
|
8
|
+
describe('StateManager', () => {
|
|
9
|
+
let world: PhysicsWorld;
|
|
10
|
+
let stateManager: StateManager;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await RAPIER.init();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
world = new PhysicsWorld(RAPIER);
|
|
18
|
+
stateManager = new StateManager();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
world.destroy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function makeBox(id: string, y: number = 5): BodyDescriptor {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
29
|
+
motionType: 'dynamic',
|
|
30
|
+
position: { x: 0, y, z: 0 },
|
|
31
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
32
|
+
mass: 1.0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeStaticBox(id: string, y: number = 0): BodyDescriptor {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
shape: { type: 'box', params: { halfExtents: { x: 5, y: 0.5, z: 5 } } },
|
|
40
|
+
motionType: 'static',
|
|
41
|
+
position: { x: 0, y, z: 0 },
|
|
42
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('should create a full snapshot', () => {
|
|
47
|
+
world.addBody(makeBox('box1', 5));
|
|
48
|
+
world.addBody(makeBox('box2', 10));
|
|
49
|
+
|
|
50
|
+
const snapshot = stateManager.createSnapshot(world, 42);
|
|
51
|
+
expect(snapshot.tick).toBe(42);
|
|
52
|
+
expect(snapshot.bodies).toHaveLength(2);
|
|
53
|
+
expect(snapshot.timestamp).toBeGreaterThan(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should create delta with all bodies on first call (fieldMask = FIELD_ALL)', () => {
|
|
57
|
+
world.addBody(makeBox('box1', 5));
|
|
58
|
+
const delta = stateManager.createDelta(world, 1);
|
|
59
|
+
expect(delta.bodies).toHaveLength(1);
|
|
60
|
+
expect(delta.bodies[0].fieldMask).toBe(FIELD_ALL);
|
|
61
|
+
expect(delta.isDelta).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should create empty delta when nothing changed', () => {
|
|
65
|
+
world.addBody(makeBox('box1', 5));
|
|
66
|
+
|
|
67
|
+
// First delta includes everything
|
|
68
|
+
stateManager.createDelta(world, 1);
|
|
69
|
+
|
|
70
|
+
// No stepping, nothing changed — delta should be empty
|
|
71
|
+
const delta2 = stateManager.createDelta(world, 2);
|
|
72
|
+
expect(delta2.bodies).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should detect changes after physics step', () => {
|
|
76
|
+
world.addBody(makeBox('box1', 5));
|
|
77
|
+
|
|
78
|
+
// First delta
|
|
79
|
+
stateManager.createDelta(world, 1);
|
|
80
|
+
|
|
81
|
+
// Step physics — body should fall
|
|
82
|
+
for (let i = 0; i < 10; i++) {
|
|
83
|
+
world.step();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const delta2 = stateManager.createDelta(world, 2);
|
|
87
|
+
expect(delta2.bodies).toHaveLength(1);
|
|
88
|
+
expect(delta2.bodies[0].id).toBe('box1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should set per-field mask for position+linVel change (falling body)', () => {
|
|
92
|
+
world.addBody(makeBox('box1', 5));
|
|
93
|
+
|
|
94
|
+
// First delta
|
|
95
|
+
stateManager.createDelta(world, 1);
|
|
96
|
+
|
|
97
|
+
// Step physics — body falls (position and linVel change, rotation stays identity)
|
|
98
|
+
for (let i = 0; i < 10; i++) {
|
|
99
|
+
world.step();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const delta2 = stateManager.createDelta(world, 2);
|
|
103
|
+
expect(delta2.bodies).toHaveLength(1);
|
|
104
|
+
const mask = delta2.bodies[0].fieldMask!;
|
|
105
|
+
// A falling body should have position and linVel changed
|
|
106
|
+
expect(mask & FIELD_POSITION).toBeTruthy();
|
|
107
|
+
expect(mask & FIELD_LIN_VEL).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should assign body indices', () => {
|
|
111
|
+
world.addBody(makeBox('box1', 5));
|
|
112
|
+
world.addBody(makeBox('box2', 10));
|
|
113
|
+
|
|
114
|
+
stateManager.createSnapshot(world, 0);
|
|
115
|
+
|
|
116
|
+
expect(stateManager.getBodyIndex('box1')).toBe(0);
|
|
117
|
+
expect(stateManager.getBodyIndex('box2')).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should produce ID mapping record', () => {
|
|
121
|
+
world.addBody(makeBox('box1', 5));
|
|
122
|
+
world.addBody(makeBox('box2', 10));
|
|
123
|
+
stateManager.createSnapshot(world, 0);
|
|
124
|
+
|
|
125
|
+
const record = stateManager.getIdToIndexRecord();
|
|
126
|
+
expect(record).toEqual({ box1: 0, box2: 1 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should keep index after body removal (indices are never reused)', () => {
|
|
130
|
+
world.addBody(makeBox('box1', 5));
|
|
131
|
+
world.addBody(makeBox('box2', 10));
|
|
132
|
+
stateManager.createSnapshot(world, 0);
|
|
133
|
+
|
|
134
|
+
stateManager.removeBody('box1');
|
|
135
|
+
// box1 index is still in the map (not reused)
|
|
136
|
+
expect(stateManager.getBodyIndex('box1')).toBe(0);
|
|
137
|
+
|
|
138
|
+
// Adding a new body gets the next index
|
|
139
|
+
world.addBody(makeBox('box3', 15));
|
|
140
|
+
stateManager.ensureBodyIndex('box3');
|
|
141
|
+
expect(stateManager.getBodyIndex('box3')).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should clear all state on clear()', () => {
|
|
145
|
+
world.addBody(makeBox('box1', 5));
|
|
146
|
+
stateManager.createSnapshot(world, 0);
|
|
147
|
+
|
|
148
|
+
stateManager.clear();
|
|
149
|
+
expect(stateManager.getBodyIndex('box1')).toBeUndefined();
|
|
150
|
+
expect(stateManager.getIdToIndexRecord()).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
|
|
3
|
+
export class ClientConnection {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly ws: WebSocket;
|
|
6
|
+
roomId: string | null = null;
|
|
7
|
+
rtt = 0;
|
|
8
|
+
clockOffset = 0;
|
|
9
|
+
lastAcknowledgedTick = 0;
|
|
10
|
+
inputSequence = 0;
|
|
11
|
+
|
|
12
|
+
private rttSamples: number[] = [];
|
|
13
|
+
private offsetSamples: number[] = [];
|
|
14
|
+
private static readonly MAX_SAMPLES = 10;
|
|
15
|
+
|
|
16
|
+
constructor(id: string, ws: WebSocket) {
|
|
17
|
+
this.id = id;
|
|
18
|
+
this.ws = ws;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
send(data: Uint8Array): void {
|
|
22
|
+
if (this.ws.readyState === this.ws.OPEN) {
|
|
23
|
+
this.ws.send(data);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
updateClockSync(clientTimestamp: number, serverTimestamp: number): void {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const rtt = now - clientTimestamp;
|
|
30
|
+
|
|
31
|
+
this.rttSamples.push(rtt);
|
|
32
|
+
if (this.rttSamples.length > ClientConnection.MAX_SAMPLES) {
|
|
33
|
+
this.rttSamples.shift();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const offset = serverTimestamp - clientTimestamp - rtt / 2;
|
|
37
|
+
this.offsetSamples.push(offset);
|
|
38
|
+
if (this.offsetSamples.length > ClientConnection.MAX_SAMPLES) {
|
|
39
|
+
this.offsetSamples.shift();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Rolling average
|
|
43
|
+
this.rtt = this.rttSamples.reduce((a, b) => a + b, 0) / this.rttSamples.length;
|
|
44
|
+
this.clockOffset = this.offsetSamples.reduce((a, b) => a + b, 0) / this.offsetSamples.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
mapClientTickToServerTick(clientTick: number, serverTickRate: number): number {
|
|
48
|
+
// Use clock offset to map client tick to server tick
|
|
49
|
+
const offsetInTicks = Math.round((this.clockOffset / 1000) * serverTickRate);
|
|
50
|
+
return clientTick + offsetInTicks;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClockSyncRequestMessage, ClockSyncResponseMessage } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { MessageType, encodeMessage } from '@rapierphysicsplugin/shared';
|
|
3
|
+
import type { ClientConnection } from './client-connection.js';
|
|
4
|
+
|
|
5
|
+
export function handleClockSyncRequest(
|
|
6
|
+
conn: ClientConnection,
|
|
7
|
+
message: ClockSyncRequestMessage
|
|
8
|
+
): void {
|
|
9
|
+
const response: ClockSyncResponseMessage = {
|
|
10
|
+
type: MessageType.CLOCK_SYNC_RESPONSE,
|
|
11
|
+
clientTimestamp: message.clientTimestamp,
|
|
12
|
+
serverTimestamp: Date.now(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
conn.send(encodeMessage(response));
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DEFAULT_PORT, ComputeBackend, loadRapier } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { PhysicsServer } from './server.js';
|
|
3
|
+
|
|
4
|
+
export { PhysicsServer } from './server.js';
|
|
5
|
+
export { PhysicsWorld } from './physics-world.js';
|
|
6
|
+
export { Room, RoomManager } from './room.js';
|
|
7
|
+
export { ClientConnection } from './client-connection.js';
|
|
8
|
+
export { SimulationLoop } from './simulation-loop.js';
|
|
9
|
+
export { StateManager } from './state-manager.js';
|
|
10
|
+
export { InputBuffer } from './input-buffer.js';
|
|
11
|
+
|
|
12
|
+
async function main(): Promise<void> {
|
|
13
|
+
const backend = (process.env.PHYSICS_BACKEND as ComputeBackend) ?? ComputeBackend.WASM_SIMD;
|
|
14
|
+
console.log(`Initializing Rapier WASM (${backend})...`);
|
|
15
|
+
const RAPIER = await loadRapier({ backend });
|
|
16
|
+
console.log('Rapier initialized.');
|
|
17
|
+
|
|
18
|
+
const port = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
|
|
19
|
+
const server = new PhysicsServer(RAPIER);
|
|
20
|
+
await server.start(port);
|
|
21
|
+
|
|
22
|
+
// Graceful shutdown
|
|
23
|
+
process.on('SIGINT', () => {
|
|
24
|
+
console.log('\nShutting down...');
|
|
25
|
+
server.stop();
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
process.on('SIGTERM', () => {
|
|
30
|
+
console.log('\nShutting down...');
|
|
31
|
+
server.stop();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Only run main when executed directly
|
|
37
|
+
const isMainModule = process.argv[1]?.endsWith('index.ts') || process.argv[1]?.endsWith('index.js');
|
|
38
|
+
if (isMainModule) {
|
|
39
|
+
main().catch(console.error);
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ClientInput } from '@rapierphysicsplugin/shared';
|
|
2
|
+
import { MAX_INPUT_BUFFER } from '@rapierphysicsplugin/shared';
|
|
3
|
+
|
|
4
|
+
export class InputBuffer {
|
|
5
|
+
private buffer: Map<number, ClientInput[]> = new Map();
|
|
6
|
+
private oldestTick = 0;
|
|
7
|
+
|
|
8
|
+
addInput(input: ClientInput, serverTick: number): void {
|
|
9
|
+
// Map client tick to server tick using provided mapping
|
|
10
|
+
const targetTick = serverTick;
|
|
11
|
+
|
|
12
|
+
if (!this.buffer.has(targetTick)) {
|
|
13
|
+
this.buffer.set(targetTick, []);
|
|
14
|
+
}
|
|
15
|
+
this.buffer.get(targetTick)!.push(input);
|
|
16
|
+
|
|
17
|
+
// Clean up old entries
|
|
18
|
+
this.pruneOldEntries(targetTick);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getInputsForTick(tick: number): ClientInput[] {
|
|
22
|
+
const inputs = this.buffer.get(tick);
|
|
23
|
+
if (inputs) {
|
|
24
|
+
this.buffer.delete(tick);
|
|
25
|
+
return inputs;
|
|
26
|
+
}
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private pruneOldEntries(currentTick: number): void {
|
|
31
|
+
const cutoff = currentTick - MAX_INPUT_BUFFER;
|
|
32
|
+
for (const tick of this.buffer.keys()) {
|
|
33
|
+
if (tick < cutoff) {
|
|
34
|
+
this.buffer.delete(tick);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear(): void {
|
|
40
|
+
this.buffer.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get size(): number {
|
|
44
|
+
return this.buffer.size;
|
|
45
|
+
}
|
|
46
|
+
}
|