@irtio/client 0.5.2 → 0.7.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.
@@ -0,0 +1,219 @@
1
+ import {
2
+ Predictor
3
+ } from "./chunk-DABQDR3S.js";
4
+ import "./chunk-XWVXZRBS.js";
5
+
6
+ // src/physics.ts
7
+ function readPose(body, into) {
8
+ const t = body.translation();
9
+ const r = body.rotation();
10
+ const v = body.linvel();
11
+ const w = body.angvel();
12
+ into.t.x = t.x;
13
+ into.t.y = t.y;
14
+ into.t.z = t.z;
15
+ into.r.x = r.x;
16
+ into.r.y = r.y;
17
+ into.r.z = r.z;
18
+ into.r.w = r.w;
19
+ into.v.x = v.x;
20
+ into.v.y = v.y;
21
+ into.v.z = v.z;
22
+ into.w.x = w.x;
23
+ into.w.y = w.y;
24
+ into.w.z = w.z;
25
+ }
26
+ var engine;
27
+ var loading;
28
+ async function loadEngine() {
29
+ if (engine) return engine;
30
+ loading ??= (async () => {
31
+ const mod = await import("@dimforge/rapier3d-compat");
32
+ const ns = mod.default ?? mod;
33
+ await ns.init();
34
+ engine = ns;
35
+ return ns;
36
+ })();
37
+ return loading;
38
+ }
39
+ function planarLockWarning(spec) {
40
+ const boxed = (spec.colliders ?? []).some(
41
+ (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
42
+ );
43
+ if (!boxed) return void 0;
44
+ const b = spec.body;
45
+ const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
46
+ const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
47
+ const axes = ["x", "y", "z"];
48
+ for (let i = 0; i < 3; i++) {
49
+ if (t[i]) continue;
50
+ if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
51
+ const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
52
+ return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
53
+ }
54
+ return void 0;
55
+ }
56
+ var CUBOID_SHAPE = 1;
57
+ var ROUND_CUBOID_SHAPE = 12;
58
+ var RapierAdapter = class {
59
+ constructor(options) {
60
+ this.options = options;
61
+ }
62
+ options;
63
+ engineName = "@dimforge/rapier3d-compat";
64
+ optionName = "physics";
65
+ rapier;
66
+ world;
67
+ get timestep() {
68
+ return this.options.timestep;
69
+ }
70
+ async start(timestepSeconds) {
71
+ const rapier = await loadEngine();
72
+ this.rapier = rapier;
73
+ const world = new rapier.World({ ...this.options.gravity });
74
+ world.timestep = timestepSeconds;
75
+ if (this.options.setup) this.options.setup(world, rapier);
76
+ this.world = world;
77
+ }
78
+ free() {
79
+ this.world?.free();
80
+ this.world = void 0;
81
+ }
82
+ hasFactory(collection) {
83
+ return this.options.bodies?.[collection] !== void 0;
84
+ }
85
+ createBody(desc, id, record, warn) {
86
+ const world = this.world;
87
+ const rapier = this.rapier;
88
+ const factory = this.options.bodies?.[desc.name];
89
+ if (!world || !rapier || !factory) return void 0;
90
+ const spec = factory(rapier, record, id);
91
+ if (!spec || !spec.body) return void 0;
92
+ const planar = planarLockWarning(spec);
93
+ if (planar !== void 0) {
94
+ warn(`planar:${desc.name}`, `irtio: physics.bodies.${desc.name} ${planar}`);
95
+ }
96
+ const body = world.createRigidBody(spec.body);
97
+ for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
98
+ return body;
99
+ }
100
+ removeBody(body) {
101
+ this.world?.removeRigidBody(body);
102
+ }
103
+ /**
104
+ * D71: a proxy is a `KinematicPositionBased` body — driven by the position it is told to be at
105
+ * next, never by a force, an impulse or a contact. Rapier derives the velocity its contacts see
106
+ * from the move itself, which is what carries a predicted body standing on a moving platform.
107
+ *
108
+ * The body is *switched* rather than built kinematic, so its collider, mass properties and axis
109
+ * locks are the ones the shared factory produced. A proxy has to be the server's body, and the
110
+ * only way to keep that true is to run the same factory call the predicted path runs.
111
+ */
112
+ makeKinematic(handle) {
113
+ const rapier = this.rapier;
114
+ if (!rapier) return;
115
+ handle.setBodyType(rapier.RigidBodyType.KinematicPositionBased, true);
116
+ }
117
+ moveKinematic(handle, pose) {
118
+ const body = handle;
119
+ body.setNextKinematicTranslation(pose.t);
120
+ body.setNextKinematicRotation(pose.r);
121
+ }
122
+ step() {
123
+ this.world?.step();
124
+ }
125
+ /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
126
+ applyRecord(handle, channels, record) {
127
+ const body = handle;
128
+ const t = { ...body.translation() };
129
+ const r = { ...body.rotation() };
130
+ const v = { ...body.linvel() };
131
+ const w = { ...body.angvel() };
132
+ let setT = false;
133
+ let setR = false;
134
+ let setV = false;
135
+ let setW = false;
136
+ for (const [channel, field] of channels) {
137
+ const raw = record[field];
138
+ if (typeof raw !== "number") continue;
139
+ switch (channel) {
140
+ case "x":
141
+ case "y":
142
+ case "z":
143
+ t[channel] = raw;
144
+ setT = true;
145
+ break;
146
+ case "qx":
147
+ r.x = raw;
148
+ setR = true;
149
+ break;
150
+ case "qy":
151
+ r.y = raw;
152
+ setR = true;
153
+ break;
154
+ case "qz":
155
+ r.z = raw;
156
+ setR = true;
157
+ break;
158
+ case "qw":
159
+ r.w = raw;
160
+ setR = true;
161
+ break;
162
+ case "vx":
163
+ v.x = raw;
164
+ setV = true;
165
+ break;
166
+ case "vy":
167
+ v.y = raw;
168
+ setV = true;
169
+ break;
170
+ case "vz":
171
+ v.z = raw;
172
+ setV = true;
173
+ break;
174
+ case "wx":
175
+ w.x = raw;
176
+ setW = true;
177
+ break;
178
+ case "wy":
179
+ w.y = raw;
180
+ setW = true;
181
+ break;
182
+ case "wz":
183
+ w.z = raw;
184
+ setW = true;
185
+ break;
186
+ }
187
+ }
188
+ if (setT) body.setTranslation(t, true);
189
+ if (setR) body.setRotation(r, true);
190
+ if (setV) body.setLinvel(v, true);
191
+ if (setW) body.setAngvel(w, true);
192
+ }
193
+ readPose(body, into) {
194
+ readPose(body, into);
195
+ }
196
+ applyIntent(collection, body, instance) {
197
+ const hook = this.options.intents?.[collection];
198
+ const world = this.world;
199
+ const rapier = this.rapier;
200
+ if (!hook || !world || !rapier) return;
201
+ hook(body, instance, rapier, world);
202
+ }
203
+ /** Rapier velocities are per second, so one tick of error is `epsilon` when `dv = eps / dt`. */
204
+ velocityTolerance(epsilon, timestepSeconds) {
205
+ return epsilon / timestepSeconds;
206
+ }
207
+ };
208
+ var PhysicsPredictor = class extends Predictor {
209
+ constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
210
+ super(ext, store, new RapierAdapter(options), options, meOf, rttOf, tickIntervalOf, log);
211
+ }
212
+ };
213
+ function createRapierAdapter(options) {
214
+ return new RapierAdapter(options);
215
+ }
216
+ export {
217
+ PhysicsPredictor,
218
+ createRapierAdapter
219
+ };
@@ -0,0 +1,255 @@
1
+ import {
2
+ Predictor
3
+ } from "./chunk-DABQDR3S.js";
4
+ import "./chunk-XWVXZRBS.js";
5
+
6
+ // src/physics2d.ts
7
+ import {
8
+ angleFrom2d,
9
+ applyChannel2d,
10
+ channelOf2d
11
+ } from "@irtio/schema";
12
+ var MATTER_BASE_DELTA = 1 / 60;
13
+ var engine;
14
+ var loading;
15
+ async function loadEngine() {
16
+ if (engine) return engine;
17
+ loading ??= (async () => {
18
+ const mod = await import("matter-js");
19
+ const ns = mod.default ?? mod;
20
+ engine = ns;
21
+ return ns;
22
+ })();
23
+ return loading;
24
+ }
25
+ var MatterAdapter = class {
26
+ constructor(options) {
27
+ this.options = options;
28
+ }
29
+ options;
30
+ engineName = "matter-js";
31
+ optionName = "physics2d";
32
+ matter;
33
+ engine;
34
+ /** The fixed delta `Engine.update` is given, in milliseconds — the room's, never a frame's. */
35
+ stepMs = 1e3 / 30;
36
+ timestepSeconds = 1 / 30;
37
+ /** A factory's constraints, so `removeBody` takes them out with the body they pinned. */
38
+ constraints = /* @__PURE__ */ new WeakMap();
39
+ /** Reused by `readPose` and `applyRecord`, which run per body per step and must not allocate. */
40
+ state = {
41
+ x: 0,
42
+ y: 0,
43
+ angle: 0,
44
+ vx: 0,
45
+ vy: 0,
46
+ angularVelocity: 0
47
+ };
48
+ /** `applyChannel2d`'s target shape, likewise reused. */
49
+ channels = { x: 0, y: 0, qz: 0, qw: 1, vx: 0, vy: 0, wz: 0 };
50
+ /** `moveKinematic`'s position argument, reused: it runs per proxy per step (D71). */
51
+ moveTarget = { x: 0, y: 0 };
52
+ get timestep() {
53
+ return this.options.timestep;
54
+ }
55
+ async start(timestepSeconds) {
56
+ const matter = await loadEngine();
57
+ this.matter = matter;
58
+ this.timestepSeconds = timestepSeconds;
59
+ this.stepMs = timestepSeconds * 1e3;
60
+ const engine2 = matter.Engine.create();
61
+ engine2.gravity.x = this.options.gravity.x;
62
+ engine2.gravity.y = this.options.gravity.y;
63
+ if (this.options.setup) this.options.setup(engine2, matter);
64
+ this.engine = engine2;
65
+ }
66
+ free() {
67
+ if (this.matter && this.engine) this.matter.Engine.clear(this.engine);
68
+ this.engine = void 0;
69
+ }
70
+ hasFactory(collection) {
71
+ return this.options.bodies?.[collection] !== void 0;
72
+ }
73
+ createBody(desc, id, record, warn) {
74
+ const matter = this.matter;
75
+ const engine2 = this.engine;
76
+ const factory = this.options.bodies?.[desc.name];
77
+ if (!matter || !engine2 || !factory) return void 0;
78
+ const spec = factory(matter, record, id);
79
+ if (!spec || !spec.body) return void 0;
80
+ if (spec.body.isStatic && this.options.settle?.[desc.name] !== void 0) {
81
+ warn(
82
+ `settle-static:${desc.name}`,
83
+ `irtio: physics2d.settle.${desc.name} runs on static bodies, which matter.js never integrates, so the force does nothing. Drop the settle hook for that collection.`
84
+ );
85
+ }
86
+ const constraints = spec.constraints ?? [];
87
+ matter.Composite.add(engine2.world, [spec.body, ...constraints]);
88
+ if (constraints.length > 0) this.constraints.set(spec.body, constraints);
89
+ return spec.body;
90
+ }
91
+ removeBody(handle) {
92
+ const matter = this.matter;
93
+ const engine2 = this.engine;
94
+ if (!matter || !engine2) return;
95
+ const body = handle;
96
+ const pinned = this.constraints.get(body);
97
+ matter.Composite.remove(engine2.world, pinned ? [body, ...pinned] : [body]);
98
+ this.constraints.delete(body);
99
+ }
100
+ /**
101
+ * D71: matter.js has no kinematic body type. Static is the only kind it never integrates and
102
+ * never moves from an impulse, so that is what a proxy is.
103
+ *
104
+ * `Body.setStatic` also overwrites the surface properties the factory set — `friction` to 1 and
105
+ * `restitution` to 0, on every part, and on a body that was *already* static it does that without
106
+ * even stashing the old values in `_original`. A proxy has to be the server's body rather than a
107
+ * stickier copy of it (matter pairs friction as the minimum of the two, so a proxy at friction 1
108
+ * is only invisible while the other body is the grippier one), so the factory's values are
109
+ * snapshotted here and put back. Mass and inertia keep the infinities that make it immovable.
110
+ */
111
+ makeKinematic(handle) {
112
+ const matter = this.matter;
113
+ if (!matter) return;
114
+ const body = handle;
115
+ const parts = body.parts;
116
+ const surfaces = parts.map((p) => ({ friction: p.friction, restitution: p.restitution }));
117
+ matter.Body.setStatic(body, true);
118
+ for (let i = 0; i < parts.length; i++) {
119
+ const part = parts[i];
120
+ const surface = surfaces[i];
121
+ part.friction = surface.friction;
122
+ part.restitution = surface.restitution;
123
+ }
124
+ }
125
+ /**
126
+ * `updateVelocity: true` is the whole mechanism. It leaves `positionPrev` exactly one move behind
127
+ * the new position, and matter's resolver reads a body's velocity as precisely that difference
128
+ * (`Resolver.solveVelocity`: `position - positionPrev`), so a predicted body resting on the proxy
129
+ * is carried by friction the way it is on the server. Given the same pose twice the difference is
130
+ * zero and the proxy is at rest, which is what holds it still through a rebase's re-steps.
131
+ *
132
+ * The third parameter is real in matter-js 0.20 (`src/body/Body.js`) and missing from
133
+ * `@types/matter-js@0.20.2` on both setters, which is what the two casts are for.
134
+ */
135
+ moveKinematic(handle, pose) {
136
+ const matter = this.matter;
137
+ if (!matter) return;
138
+ const body = handle;
139
+ const setPosition = matter.Body.setPosition;
140
+ const setAngle = matter.Body.setAngle;
141
+ const to = this.moveTarget;
142
+ to.x = pose.t.x;
143
+ to.y = pose.t.y;
144
+ setPosition(body, to, true);
145
+ setAngle(body, angleFrom2d(pose.r.z, pose.r.w), true);
146
+ }
147
+ step() {
148
+ if (!this.matter || !this.engine) return;
149
+ this.matter.Engine.update(this.engine, this.stepMs);
150
+ }
151
+ /**
152
+ * Server record → body state, in `applyRecordToBody`'s order.
153
+ *
154
+ * Position before velocity and angle before spin, matching the server. In matter-js 0.20 the
155
+ * two orders happen to agree — `setPosition` shifts `positionPrev` by the same delta, so it
156
+ * preserves the velocity either way — but the server's order is the one to keep: it is the
157
+ * order the runtime's `applyRecordToBody` uses, and a version that stopped agreeing would break
158
+ * both sides the same way rather than only this one.
159
+ *
160
+ * Then the body is woken. A rebase writes state a sleeping body would ignore, the same reason
161
+ * Rapier's setters are called with `wakeUp = true`. matter's `enableSleeping` is off by default
162
+ * on both sides, so this is usually a no-op, and it is here for the room that turns it on.
163
+ */
164
+ applyRecord(handle, channels, record) {
165
+ const matter = this.matter;
166
+ if (!matter) return;
167
+ const body = handle;
168
+ const t = this.channels;
169
+ t.x = body.position.x;
170
+ t.y = body.position.y;
171
+ t.qz = Math.sin(body.angle / 2);
172
+ t.qw = Math.cos(body.angle / 2);
173
+ t.vx = body.velocity.x;
174
+ t.vy = body.velocity.y;
175
+ t.wz = body.angularVelocity;
176
+ for (const [channel, field] of channels) {
177
+ const raw = record[field];
178
+ if (typeof raw !== "number") continue;
179
+ applyChannel2d(channel, raw, t);
180
+ }
181
+ matter.Body.setPosition(body, { x: t.x, y: t.y });
182
+ matter.Body.setAngle(body, angleFrom2d(t.qz, t.qw));
183
+ matter.Body.setVelocity(body, { x: t.vx, y: t.vy });
184
+ matter.Body.setAngularVelocity(body, t.wz);
185
+ matter.Sleeping.set(body, false);
186
+ }
187
+ /**
188
+ * Body state → a 3D pose, through `channelOf2d` so the plane-to-channel mapping stays written
189
+ * once, in `@irtio/schema`: `t = (x, y, 0)`, `r` a quaternion about Z, `v = (vx, vy, 0)`,
190
+ * `w = (0, 0, spin)`.
191
+ */
192
+ readPose(handle, into) {
193
+ const body = handle;
194
+ const s = this.state;
195
+ s.x = body.position.x;
196
+ s.y = body.position.y;
197
+ s.angle = body.angle;
198
+ s.vx = body.velocity.x;
199
+ s.vy = body.velocity.y;
200
+ s.angularVelocity = body.angularVelocity;
201
+ const b = s;
202
+ into.t.x = channelOf2d("x", b);
203
+ into.t.y = channelOf2d("y", b);
204
+ into.t.z = channelOf2d("z", b);
205
+ into.r.x = channelOf2d("qx", b);
206
+ into.r.y = channelOf2d("qy", b);
207
+ into.r.z = channelOf2d("qz", b);
208
+ into.r.w = channelOf2d("qw", b);
209
+ into.v.x = channelOf2d("vx", b);
210
+ into.v.y = channelOf2d("vy", b);
211
+ into.v.z = channelOf2d("vz", b);
212
+ into.w.x = channelOf2d("wx", b);
213
+ into.w.y = channelOf2d("wy", b);
214
+ into.w.z = channelOf2d("wz", b);
215
+ }
216
+ applyIntent(collection, body, instance) {
217
+ this.run(this.options.intents?.[collection], body, instance);
218
+ }
219
+ settle(collection, body, instance) {
220
+ this.run(this.options.settle?.[collection], body, instance);
221
+ }
222
+ run(hook, body, instance) {
223
+ const matter = this.matter;
224
+ const engine2 = this.engine;
225
+ if (!hook || !matter || !engine2) return;
226
+ hook(body, instance, matter, engine2, this.timestepSeconds);
227
+ }
228
+ /**
229
+ * What one step of a velocity disagreement moves the body by, inverted.
230
+ *
231
+ * Rapier's velocities are per second, so its answer is `epsilon / dt`. matter's are neither per
232
+ * second nor, strictly, per step: `Body.updateVelocities` normalises `body.velocity` against
233
+ * `Body._baseDelta` at the end of every `Engine.update`, and `Body.setVelocity` reads the same
234
+ * units back, so a velocity is a displacement per sixtieth of a second whatever the room's step
235
+ * is (verified against matter-js 0.20.0's `Body.js`). One step moves a body by
236
+ * `v * dt / baseDelta`, so the tolerance divides by exactly that ratio — which is 1, and the
237
+ * answer plain `epsilon`, in the 60 Hz room this was designed for.
238
+ */
239
+ velocityTolerance(epsilon, timestepSeconds) {
240
+ const perStep = timestepSeconds / MATTER_BASE_DELTA;
241
+ return perStep > 0 ? epsilon / perStep : epsilon;
242
+ }
243
+ };
244
+ var Physics2dPredictor = class extends Predictor {
245
+ constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
246
+ super(ext, store, new MatterAdapter(options), options, meOf, rttOf, tickIntervalOf, log);
247
+ }
248
+ };
249
+ function createMatterAdapter(options) {
250
+ return new MatterAdapter(options);
251
+ }
252
+ export {
253
+ Physics2dPredictor,
254
+ createMatterAdapter
255
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/client",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -19,22 +19,31 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@irtio/schema": "0.5.2",
23
- "@irtio/protocol": "0.5.2"
22
+ "mediasoup-client": "^3.23.1",
23
+ "@irtio/protocol": "0.7.0",
24
+ "@irtio/schema": "0.7.0"
24
25
  },
25
26
  "peerDependencies": {
26
- "@dimforge/rapier3d-compat": ">=0.20.0"
27
+ "@dimforge/rapier3d-compat": ">=0.20.0",
28
+ "matter-js": ">=0.20.0"
27
29
  },
28
30
  "peerDependenciesMeta": {
29
31
  "@dimforge/rapier3d-compat": {
30
32
  "optional": true
33
+ },
34
+ "matter-js": {
35
+ "optional": true
31
36
  }
32
37
  },
33
38
  "devDependencies": {
34
- "@dimforge/rapier3d-compat": "0.20.0"
39
+ "@dimforge/rapier3d-compat": "0.20.0",
40
+ "@types/matter-js": "0.20.2",
41
+ "matter-js": "0.20.0",
42
+ "@irtio/sfu": "0.0.0"
35
43
  },
36
44
  "scripts": {
37
45
  "build": "tsup",
38
- "test": "vitest run"
46
+ "test": "vitest run",
47
+ "test:browser": "playwright test"
39
48
  }
40
49
  }