@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,227 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
|
2
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { PhysicsServer } from '../server.js';
|
|
5
|
+
import {
|
|
6
|
+
MessageType,
|
|
7
|
+
encodeMessage,
|
|
8
|
+
decodeServerMessage,
|
|
9
|
+
} from '@rapierphysicsplugin/shared';
|
|
10
|
+
import type { ServerMessage, RoomJoinedMessage, RoomStateMessage } from '@rapierphysicsplugin/shared';
|
|
11
|
+
|
|
12
|
+
const TEST_PORT = 9876;
|
|
13
|
+
|
|
14
|
+
function connectClient(port: number): Promise<WebSocket> {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const ws = new WebSocket(`ws://localhost:${port}`);
|
|
17
|
+
ws.on('open', () => resolve(ws));
|
|
18
|
+
ws.on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function waitForMessage(ws: WebSocket, type: MessageType, timeoutMs = 5000): Promise<ServerMessage> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
|
25
|
+
|
|
26
|
+
const handler = (data: Buffer) => {
|
|
27
|
+
const msg = decodeServerMessage(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
28
|
+
if (msg.type === type) {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
ws.off('message', handler);
|
|
31
|
+
resolve(msg);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
ws.on('message', handler);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('Integration: Server with WebSocket clients', () => {
|
|
39
|
+
let server: PhysicsServer;
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
await RAPIER.init();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
server?.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should allow a client to create and join a room', async () => {
|
|
50
|
+
server = new PhysicsServer(RAPIER);
|
|
51
|
+
await server.start(TEST_PORT);
|
|
52
|
+
|
|
53
|
+
const ws = await connectClient(TEST_PORT);
|
|
54
|
+
|
|
55
|
+
// Create room
|
|
56
|
+
ws.send(encodeMessage({
|
|
57
|
+
type: MessageType.CREATE_ROOM,
|
|
58
|
+
roomId: 'test-room',
|
|
59
|
+
initialBodies: [
|
|
60
|
+
{
|
|
61
|
+
id: 'box1',
|
|
62
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
63
|
+
motionType: 'dynamic',
|
|
64
|
+
position: { x: 0, y: 5, z: 0 },
|
|
65
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
66
|
+
mass: 1.0,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const created = await waitForMessage(ws, MessageType.ROOM_CREATED);
|
|
72
|
+
expect(created.type).toBe(MessageType.ROOM_CREATED);
|
|
73
|
+
|
|
74
|
+
// Join room
|
|
75
|
+
ws.send(encodeMessage({
|
|
76
|
+
type: MessageType.JOIN_ROOM,
|
|
77
|
+
roomId: 'test-room',
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const joined = await waitForMessage(ws, MessageType.ROOM_JOINED) as RoomJoinedMessage;
|
|
81
|
+
expect(joined.roomId).toBe('test-room');
|
|
82
|
+
expect(joined.snapshot.bodies).toHaveLength(1);
|
|
83
|
+
expect(joined.snapshot.bodies[0].id).toBe('box1');
|
|
84
|
+
|
|
85
|
+
ws.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should send state updates after starting simulation', async () => {
|
|
89
|
+
server = new PhysicsServer(RAPIER);
|
|
90
|
+
await server.start(TEST_PORT + 1);
|
|
91
|
+
|
|
92
|
+
const ws = await connectClient(TEST_PORT + 1);
|
|
93
|
+
|
|
94
|
+
ws.send(encodeMessage({
|
|
95
|
+
type: MessageType.CREATE_ROOM,
|
|
96
|
+
roomId: 'state-room',
|
|
97
|
+
initialBodies: [
|
|
98
|
+
{
|
|
99
|
+
id: 'ball',
|
|
100
|
+
shape: { type: 'sphere', params: { radius: 0.5 } },
|
|
101
|
+
motionType: 'dynamic',
|
|
102
|
+
position: { x: 0, y: 10, z: 0 },
|
|
103
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
104
|
+
mass: 1.0,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}));
|
|
108
|
+
await waitForMessage(ws, MessageType.ROOM_CREATED);
|
|
109
|
+
|
|
110
|
+
ws.send(encodeMessage({
|
|
111
|
+
type: MessageType.JOIN_ROOM,
|
|
112
|
+
roomId: 'state-room',
|
|
113
|
+
}));
|
|
114
|
+
await waitForMessage(ws, MessageType.ROOM_JOINED);
|
|
115
|
+
|
|
116
|
+
// Start simulation (no longer auto-starts on join)
|
|
117
|
+
ws.send(encodeMessage({ type: MessageType.START_SIMULATION }));
|
|
118
|
+
await waitForMessage(ws, MessageType.SIMULATION_STARTED);
|
|
119
|
+
|
|
120
|
+
// Wait for a state update — should arrive within a few seconds
|
|
121
|
+
const stateMsg = await waitForMessage(ws, MessageType.ROOM_STATE) as RoomStateMessage;
|
|
122
|
+
expect(stateMsg.tick).toBeGreaterThan(0);
|
|
123
|
+
expect(stateMsg.bodies.length).toBeGreaterThanOrEqual(1);
|
|
124
|
+
|
|
125
|
+
ws.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should allow two clients to share a room', async () => {
|
|
129
|
+
server = new PhysicsServer(RAPIER);
|
|
130
|
+
await server.start(TEST_PORT + 2);
|
|
131
|
+
|
|
132
|
+
const ws1 = await connectClient(TEST_PORT + 2);
|
|
133
|
+
const ws2 = await connectClient(TEST_PORT + 2);
|
|
134
|
+
|
|
135
|
+
// Client 1 creates room
|
|
136
|
+
ws1.send(encodeMessage({
|
|
137
|
+
type: MessageType.CREATE_ROOM,
|
|
138
|
+
roomId: 'shared-room',
|
|
139
|
+
initialBodies: [
|
|
140
|
+
{
|
|
141
|
+
id: 'shared-box',
|
|
142
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
143
|
+
motionType: 'dynamic',
|
|
144
|
+
position: { x: 0, y: 5, z: 0 },
|
|
145
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
146
|
+
mass: 1.0,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
await waitForMessage(ws1, MessageType.ROOM_CREATED);
|
|
151
|
+
|
|
152
|
+
// Both clients join
|
|
153
|
+
ws1.send(encodeMessage({ type: MessageType.JOIN_ROOM, roomId: 'shared-room' }));
|
|
154
|
+
const joined1 = await waitForMessage(ws1, MessageType.ROOM_JOINED) as RoomJoinedMessage;
|
|
155
|
+
expect(joined1.snapshot.bodies).toHaveLength(1);
|
|
156
|
+
|
|
157
|
+
ws2.send(encodeMessage({ type: MessageType.JOIN_ROOM, roomId: 'shared-room' }));
|
|
158
|
+
const joined2 = await waitForMessage(ws2, MessageType.ROOM_JOINED) as RoomJoinedMessage;
|
|
159
|
+
expect(joined2.snapshot.bodies).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
// Start simulation (no longer auto-starts on join)
|
|
162
|
+
ws1.send(encodeMessage({ type: MessageType.START_SIMULATION }));
|
|
163
|
+
await waitForMessage(ws1, MessageType.SIMULATION_STARTED);
|
|
164
|
+
await waitForMessage(ws2, MessageType.SIMULATION_STARTED);
|
|
165
|
+
|
|
166
|
+
// Client 1 sends input
|
|
167
|
+
ws1.send(encodeMessage({
|
|
168
|
+
type: MessageType.CLIENT_INPUT,
|
|
169
|
+
input: {
|
|
170
|
+
tick: 0,
|
|
171
|
+
sequenceNum: 0,
|
|
172
|
+
actions: [
|
|
173
|
+
{ type: 'applyImpulse', bodyId: 'shared-box', data: { impulse: { x: 20, y: 0, z: 0 } } },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// Both should receive state updates
|
|
179
|
+
const state1 = await waitForMessage(ws1, MessageType.ROOM_STATE) as RoomStateMessage;
|
|
180
|
+
const state2 = await waitForMessage(ws2, MessageType.ROOM_STATE) as RoomStateMessage;
|
|
181
|
+
|
|
182
|
+
expect(state1.bodies.length).toBeGreaterThanOrEqual(1);
|
|
183
|
+
expect(state2.bodies.length).toBeGreaterThanOrEqual(1);
|
|
184
|
+
|
|
185
|
+
ws1.close();
|
|
186
|
+
ws2.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should handle clock sync requests', async () => {
|
|
190
|
+
server = new PhysicsServer(RAPIER);
|
|
191
|
+
await server.start(TEST_PORT + 3);
|
|
192
|
+
|
|
193
|
+
const ws = await connectClient(TEST_PORT + 3);
|
|
194
|
+
|
|
195
|
+
const clientTimestamp = Date.now();
|
|
196
|
+
ws.send(encodeMessage({
|
|
197
|
+
type: MessageType.CLOCK_SYNC_REQUEST,
|
|
198
|
+
clientTimestamp,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
const response = await waitForMessage(ws, MessageType.CLOCK_SYNC_RESPONSE);
|
|
202
|
+
expect(response.type).toBe(MessageType.CLOCK_SYNC_RESPONSE);
|
|
203
|
+
if (response.type === MessageType.CLOCK_SYNC_RESPONSE) {
|
|
204
|
+
expect(response.clientTimestamp).toBe(clientTimestamp);
|
|
205
|
+
expect(response.serverTimestamp).toBeGreaterThan(0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ws.close();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should return error for non-existent room', async () => {
|
|
212
|
+
server = new PhysicsServer(RAPIER);
|
|
213
|
+
await server.start(TEST_PORT + 4);
|
|
214
|
+
|
|
215
|
+
const ws = await connectClient(TEST_PORT + 4);
|
|
216
|
+
|
|
217
|
+
ws.send(encodeMessage({
|
|
218
|
+
type: MessageType.JOIN_ROOM,
|
|
219
|
+
roomId: 'nonexistent',
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
const errorMsg = await waitForMessage(ws, MessageType.ERROR);
|
|
223
|
+
expect(errorMsg.type).toBe(MessageType.ERROR);
|
|
224
|
+
|
|
225
|
+
ws.close();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
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 type { BodyDescriptor } from '@rapierphysicsplugin/shared';
|
|
5
|
+
|
|
6
|
+
describe('PhysicsWorld collision events', () => {
|
|
7
|
+
let world: PhysicsWorld;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await RAPIER.init();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
world = new PhysicsWorld(RAPIER);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
world.destroy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function makeGround(): BodyDescriptor {
|
|
22
|
+
return {
|
|
23
|
+
id: 'ground',
|
|
24
|
+
shape: { type: 'box', params: { halfExtents: { x: 50, y: 0.5, z: 50 } } },
|
|
25
|
+
motionType: 'static',
|
|
26
|
+
position: { x: 0, y: -0.5, z: 0 },
|
|
27
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeBox(id: string, y: number): BodyDescriptor {
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
35
|
+
motionType: 'dynamic',
|
|
36
|
+
position: { x: 0, y, z: 0 },
|
|
37
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
38
|
+
mass: 1.0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeSensor(id: string, y: number): BodyDescriptor {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
shape: { type: 'box', params: { halfExtents: { x: 2, y: 2, z: 2 } } },
|
|
46
|
+
motionType: 'static',
|
|
47
|
+
position: { x: 0, y, z: 0 },
|
|
48
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
49
|
+
isTrigger: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('should return empty events when no collisions occur', () => {
|
|
54
|
+
world.addBody(makeBox('box1', 100));
|
|
55
|
+
const events = world.step();
|
|
56
|
+
expect(events).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should detect collision when box falls onto ground', () => {
|
|
60
|
+
world.addBody(makeGround());
|
|
61
|
+
world.addBody(makeBox('box1', 1.5));
|
|
62
|
+
|
|
63
|
+
// Step many times until box reaches ground
|
|
64
|
+
let allEvents: ReturnType<typeof world.step> = [];
|
|
65
|
+
for (let i = 0; i < 120; i++) {
|
|
66
|
+
const events = world.step();
|
|
67
|
+
allEvents.push(...events);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const collisionStarted = allEvents.filter(e => e.type === 'COLLISION_STARTED');
|
|
71
|
+
expect(collisionStarted.length).toBeGreaterThan(0);
|
|
72
|
+
|
|
73
|
+
const event = collisionStarted[0];
|
|
74
|
+
// Should involve box1 and ground (order may vary)
|
|
75
|
+
const ids = [event.bodyIdA, event.bodyIdB].sort();
|
|
76
|
+
expect(ids).toEqual(['box1', 'ground']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should have contact point and normal for COLLISION_STARTED', () => {
|
|
80
|
+
world.addBody(makeGround());
|
|
81
|
+
world.addBody(makeBox('box1', 1.5));
|
|
82
|
+
|
|
83
|
+
let collisionEvent = null;
|
|
84
|
+
for (let i = 0; i < 120; i++) {
|
|
85
|
+
const events = world.step();
|
|
86
|
+
const started = events.find(e => e.type === 'COLLISION_STARTED');
|
|
87
|
+
if (started) {
|
|
88
|
+
collisionEvent = started;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(collisionEvent).not.toBeNull();
|
|
94
|
+
// Contact point should exist for non-sensor collisions
|
|
95
|
+
// Note: contact point may be null if Rapier doesn't provide it on the first frame
|
|
96
|
+
// but normal should typically be available
|
|
97
|
+
if (collisionEvent!.point) {
|
|
98
|
+
expect(typeof collisionEvent!.point.x).toBe('number');
|
|
99
|
+
expect(typeof collisionEvent!.point.y).toBe('number');
|
|
100
|
+
expect(typeof collisionEvent!.point.z).toBe('number');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should detect TRIGGER_ENTERED when box enters sensor', () => {
|
|
105
|
+
world.addBody(makeSensor('sensor1', 2));
|
|
106
|
+
world.addBody(makeBox('box1', 10));
|
|
107
|
+
|
|
108
|
+
let allEvents: ReturnType<typeof world.step> = [];
|
|
109
|
+
for (let i = 0; i < 120; i++) {
|
|
110
|
+
const events = world.step();
|
|
111
|
+
allEvents.push(...events);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const triggerEntered = allEvents.filter(e => e.type === 'TRIGGER_ENTERED');
|
|
115
|
+
expect(triggerEntered.length).toBeGreaterThan(0);
|
|
116
|
+
|
|
117
|
+
const event = triggerEntered[0];
|
|
118
|
+
const ids = [event.bodyIdA, event.bodyIdB].sort();
|
|
119
|
+
expect(ids).toEqual(['box1', 'sensor1']);
|
|
120
|
+
|
|
121
|
+
// Sensor events should not have contact info
|
|
122
|
+
expect(event.point).toBeNull();
|
|
123
|
+
expect(event.normal).toBeNull();
|
|
124
|
+
expect(event.impulse).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should map body IDs correctly for collision events', () => {
|
|
128
|
+
world.addBody(makeGround());
|
|
129
|
+
world.addBody(makeBox('alpha', 1.5));
|
|
130
|
+
world.addBody(makeBox('beta', 5));
|
|
131
|
+
|
|
132
|
+
let allEvents: ReturnType<typeof world.step> = [];
|
|
133
|
+
for (let i = 0; i < 200; i++) {
|
|
134
|
+
const events = world.step();
|
|
135
|
+
allEvents.push(...events);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// All events should reference known body IDs
|
|
139
|
+
for (const event of allEvents) {
|
|
140
|
+
expect(['alpha', 'beta', 'ground']).toContain(event.bodyIdA);
|
|
141
|
+
expect(['alpha', 'beta', 'ground']).toContain(event.bodyIdB);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should return events from step() without modifying previous results', () => {
|
|
146
|
+
world.addBody(makeGround());
|
|
147
|
+
world.addBody(makeBox('box1', 1.5));
|
|
148
|
+
|
|
149
|
+
const firstEvents = world.step();
|
|
150
|
+
const secondEvents = world.step();
|
|
151
|
+
|
|
152
|
+
// Each call returns its own array
|
|
153
|
+
expect(firstEvents).not.toBe(secondEvents);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
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 type { BodyDescriptor } from '@rapierphysicsplugin/shared';
|
|
5
|
+
|
|
6
|
+
describe('PhysicsWorld', () => {
|
|
7
|
+
let world: PhysicsWorld;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await RAPIER.init();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
world = new PhysicsWorld(RAPIER);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
world.destroy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function makeBox(id: string, y: number = 5): BodyDescriptor {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
|
|
25
|
+
motionType: 'dynamic',
|
|
26
|
+
position: { x: 0, y, z: 0 },
|
|
27
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
28
|
+
mass: 1.0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('should add a body', () => {
|
|
33
|
+
const id = world.addBody(makeBox('box1'));
|
|
34
|
+
expect(id).toBe('box1');
|
|
35
|
+
expect(world.bodyCount).toBe(1);
|
|
36
|
+
expect(world.hasBody('box1')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should throw when adding duplicate body', () => {
|
|
40
|
+
world.addBody(makeBox('box1'));
|
|
41
|
+
expect(() => world.addBody(makeBox('box1'))).toThrow('already exists');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should remove a body', () => {
|
|
45
|
+
world.addBody(makeBox('box1'));
|
|
46
|
+
world.removeBody('box1');
|
|
47
|
+
expect(world.bodyCount).toBe(0);
|
|
48
|
+
expect(world.hasBody('box1')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should get body state', () => {
|
|
52
|
+
world.addBody(makeBox('box1', 10));
|
|
53
|
+
const state = world.getBodyState('box1');
|
|
54
|
+
expect(state).not.toBeNull();
|
|
55
|
+
expect(state!.id).toBe('box1');
|
|
56
|
+
expect(state!.position.y).toBeCloseTo(10);
|
|
57
|
+
expect(state!.rotation.w).toBeCloseTo(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return null for non-existent body', () => {
|
|
61
|
+
expect(world.getBodyState('nonexistent')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should step the physics world', () => {
|
|
65
|
+
world.addBody(makeBox('box1', 10));
|
|
66
|
+
const stateBefore = world.getBodyState('box1')!;
|
|
67
|
+
|
|
68
|
+
// Step multiple times so gravity takes effect
|
|
69
|
+
for (let i = 0; i < 60; i++) {
|
|
70
|
+
world.step();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const stateAfter = world.getBodyState('box1')!;
|
|
74
|
+
// Box should have fallen due to gravity
|
|
75
|
+
expect(stateAfter.position.y).toBeLessThan(stateBefore.position.y);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should get snapshot of all bodies', () => {
|
|
79
|
+
world.addBody(makeBox('box1', 5));
|
|
80
|
+
world.addBody(makeBox('box2', 10));
|
|
81
|
+
|
|
82
|
+
const snapshot = world.getSnapshot();
|
|
83
|
+
expect(snapshot).toHaveLength(2);
|
|
84
|
+
expect(snapshot.map(s => s.id).sort()).toEqual(['box1', 'box2']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should apply force to a body', () => {
|
|
88
|
+
world.addBody(makeBox('box1', 5));
|
|
89
|
+
world.applyForce('box1', { x: 0, y: 100, z: 0 });
|
|
90
|
+
world.step();
|
|
91
|
+
|
|
92
|
+
const state = world.getBodyState('box1')!;
|
|
93
|
+
// Should have upward velocity from the applied force
|
|
94
|
+
expect(state.linVel.y).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should apply impulse to a body', () => {
|
|
98
|
+
world.addBody(makeBox('box1', 5));
|
|
99
|
+
world.applyImpulse('box1', { x: 10, y: 0, z: 0 });
|
|
100
|
+
world.step();
|
|
101
|
+
|
|
102
|
+
const state = world.getBodyState('box1')!;
|
|
103
|
+
expect(state.linVel.x).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should set body velocity', () => {
|
|
107
|
+
world.addBody(makeBox('box1', 5));
|
|
108
|
+
world.setBodyVelocity('box1', { x: 5, y: 0, z: 0 });
|
|
109
|
+
|
|
110
|
+
const state = world.getBodyState('box1')!;
|
|
111
|
+
expect(state.linVel.x).toBeCloseTo(5);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should add a sphere body', () => {
|
|
115
|
+
const descriptor: BodyDescriptor = {
|
|
116
|
+
id: 'sphere1',
|
|
117
|
+
shape: { type: 'sphere', params: { radius: 1 } },
|
|
118
|
+
motionType: 'dynamic',
|
|
119
|
+
position: { x: 0, y: 5, z: 0 },
|
|
120
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
121
|
+
};
|
|
122
|
+
world.addBody(descriptor);
|
|
123
|
+
expect(world.hasBody('sphere1')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should add a static body', () => {
|
|
127
|
+
const descriptor: BodyDescriptor = {
|
|
128
|
+
id: 'ground',
|
|
129
|
+
shape: { type: 'box', params: { halfExtents: { x: 50, y: 0.5, z: 50 } } },
|
|
130
|
+
motionType: 'static',
|
|
131
|
+
position: { x: 0, y: -0.5, z: 0 },
|
|
132
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
133
|
+
};
|
|
134
|
+
world.addBody(descriptor);
|
|
135
|
+
|
|
136
|
+
// Static body shouldn't move
|
|
137
|
+
world.step();
|
|
138
|
+
const state = world.getBodyState('ground')!;
|
|
139
|
+
expect(state.position.y).toBeCloseTo(-0.5);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should apply input actions', () => {
|
|
143
|
+
world.addBody(makeBox('box1', 5));
|
|
144
|
+
world.applyInput({
|
|
145
|
+
type: 'applyForce',
|
|
146
|
+
bodyId: 'box1',
|
|
147
|
+
data: { force: { x: 0, y: 50, z: 0 } },
|
|
148
|
+
});
|
|
149
|
+
world.step();
|
|
150
|
+
|
|
151
|
+
const state = world.getBodyState('box1')!;
|
|
152
|
+
expect(state.linVel.y).toBeGreaterThan(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should load state from body descriptors', () => {
|
|
156
|
+
world.loadState([
|
|
157
|
+
makeBox('box1', 5),
|
|
158
|
+
makeBox('box2', 10),
|
|
159
|
+
makeBox('box3', 15),
|
|
160
|
+
]);
|
|
161
|
+
expect(world.bodyCount).toBe(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should reset world with new body descriptors', () => {
|
|
165
|
+
world.loadState([makeBox('box1', 5), makeBox('box2', 10)]);
|
|
166
|
+
expect(world.bodyCount).toBe(2);
|
|
167
|
+
|
|
168
|
+
// Step to move bodies
|
|
169
|
+
for (let i = 0; i < 10; i++) {
|
|
170
|
+
world.step();
|
|
171
|
+
}
|
|
172
|
+
const stateBeforeReset = world.getBodyState('box1')!;
|
|
173
|
+
expect(stateBeforeReset.position.y).not.toBeCloseTo(5);
|
|
174
|
+
|
|
175
|
+
// Reset with same descriptors
|
|
176
|
+
world.reset([makeBox('box1', 5), makeBox('box2', 10)]);
|
|
177
|
+
expect(world.bodyCount).toBe(2);
|
|
178
|
+
|
|
179
|
+
// Bodies should be back at initial positions
|
|
180
|
+
const stateAfterReset = world.getBodyState('box1')!;
|
|
181
|
+
expect(stateAfterReset.position.y).toBeCloseTo(5);
|
|
182
|
+
const box2State = world.getBodyState('box2')!;
|
|
183
|
+
expect(box2State.position.y).toBeCloseTo(10);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should reset with different body descriptors', () => {
|
|
187
|
+
world.loadState([makeBox('box1', 5)]);
|
|
188
|
+
expect(world.bodyCount).toBe(1);
|
|
189
|
+
|
|
190
|
+
world.reset([makeBox('boxA', 3), makeBox('boxB', 7), makeBox('boxC', 12)]);
|
|
191
|
+
expect(world.bodyCount).toBe(3);
|
|
192
|
+
expect(world.hasBody('box1')).toBe(false);
|
|
193
|
+
expect(world.hasBody('boxA')).toBe(true);
|
|
194
|
+
expect(world.hasBody('boxB')).toBe(true);
|
|
195
|
+
expect(world.hasBody('boxC')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|