@series-inc/rundot-syncplay 5.23.0-beta.7 → 5.23.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.
Files changed (75) hide show
  1. package/README.md +116 -15
  2. package/dist/browser.d.ts +87 -55
  3. package/dist/browser.js +47 -52
  4. package/dist/certification.d.ts +32 -3
  5. package/dist/certification.js +88 -3
  6. package/dist/cjs/browser.js +259 -87
  7. package/dist/cjs/certification.js +87 -2
  8. package/dist/cjs/collision.js +58 -0
  9. package/dist/cjs/creator.js +2 -1
  10. package/dist/cjs/engine-complete.js +213 -3
  11. package/dist/cjs/engine-parity-matrix.js +19 -4
  12. package/dist/cjs/index.js +199 -132
  13. package/dist/cjs/math.js +342 -15
  14. package/dist/cjs/movement3d.js +230 -1
  15. package/dist/cjs/node.js +5 -1
  16. package/dist/cjs/noise.js +58 -0
  17. package/dist/cjs/physics-cert.js +147 -6
  18. package/dist/cjs/physics2d.js +10 -4
  19. package/dist/cjs/physics3d-joints.js +637 -0
  20. package/dist/cjs/physics3d-shared.js +288 -0
  21. package/dist/cjs/physics3d-solver.js +1351 -0
  22. package/dist/cjs/physics3d-vehicle.js +467 -0
  23. package/dist/cjs/physics3d.js +1057 -223
  24. package/dist/cjs/random.js +49 -0
  25. package/dist/cjs/replay-bundle.js +127 -20
  26. package/dist/cjs/sample-scenes.js +3 -0
  27. package/dist/cjs/sdk-offline.js +6 -32
  28. package/dist/cjs/sdk-session.js +156 -0
  29. package/dist/cjs/session-build.js +42 -0
  30. package/dist/cjs/testing.js +107 -0
  31. package/dist/cli.js +40 -12
  32. package/dist/collision.d.ts +12 -0
  33. package/dist/collision.js +52 -0
  34. package/dist/creator.d.ts +1 -1
  35. package/dist/creator.js +1 -1
  36. package/dist/engine-complete.js +214 -4
  37. package/dist/engine-parity-matrix.js +19 -4
  38. package/dist/index.d.ts +94 -89
  39. package/dist/index.js +45 -56
  40. package/dist/math.d.ts +59 -6
  41. package/dist/math.js +342 -15
  42. package/dist/movement3d.d.ts +73 -0
  43. package/dist/movement3d.js +229 -1
  44. package/dist/node.d.ts +4 -0
  45. package/dist/node.js +2 -0
  46. package/dist/noise.d.ts +7 -0
  47. package/dist/noise.js +51 -0
  48. package/dist/physics-cert.d.ts +1 -1
  49. package/dist/physics-cert.js +147 -6
  50. package/dist/physics2d.js +10 -4
  51. package/dist/physics3d-joints.d.ts +94 -0
  52. package/dist/physics3d-joints.js +634 -0
  53. package/dist/physics3d-shared.d.ts +72 -0
  54. package/dist/physics3d-shared.js +257 -0
  55. package/dist/physics3d-solver.d.ts +197 -0
  56. package/dist/physics3d-solver.js +1346 -0
  57. package/dist/physics3d-vehicle.d.ts +84 -0
  58. package/dist/physics3d-vehicle.js +463 -0
  59. package/dist/physics3d.d.ts +108 -8
  60. package/dist/physics3d.js +1006 -177
  61. package/dist/random.d.ts +7 -0
  62. package/dist/random.js +49 -0
  63. package/dist/replay-bundle.d.ts +40 -1
  64. package/dist/replay-bundle.js +126 -20
  65. package/dist/sample-scenes.js +3 -0
  66. package/dist/sdk-offline.js +6 -32
  67. package/dist/sdk-session.d.ts +47 -0
  68. package/dist/sdk-session.js +153 -0
  69. package/dist/session-build.d.ts +21 -0
  70. package/dist/session-build.js +39 -0
  71. package/dist/testing.d.ts +52 -0
  72. package/dist/testing.js +45 -0
  73. package/dist/tools/vite-plugin.d.ts +8 -0
  74. package/dist/tools/vite-plugin.js +63 -9
  75. package/package.json +11 -3
package/dist/cjs/node.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.stableEngineParityJson = exports.runEngineParitySuite = exports.explainEngineParityFixture = exports.runChaosCertificationSuite = exports.explainChaosFixture = exports.startLocalWebSocketProtocolServer = exports.runLocalWebSocketAdapterTest = exports.runNetworkedInputAuthoritySpike = exports.defaultNetworkedInputAuthoritySpikeOptions = exports.verifyReplay = exports.createOfflineSession = void 0;
17
+ exports.stableEngineParityJson = exports.runEngineParitySuite = exports.explainEngineParityFixture = exports.runChaosCertificationSuite = exports.explainChaosFixture = exports.startLocalWebSocketProtocolServer = exports.runLocalWebSocketAdapterTest = exports.runNetworkedInputAuthoritySpike = exports.defaultNetworkedInputAuthoritySpikeOptions = exports.verifyReplayScoreAgainstTrustedRuntime = exports.verifyReplay = exports.createSyncplaySession = exports.createOfflineSession = void 0;
18
18
  __exportStar(require("./multiplayer-service.js"), exports);
19
19
  __exportStar(require("./input-authority.js"), exports);
20
20
  __exportStar(require("./local-authority-wire.js"), exports);
@@ -23,12 +23,16 @@ __exportStar(require("./session-snapshot.js"), exports);
23
23
  __exportStar(require("./networked-client.js"), exports);
24
24
  var offline_session_js_1 = require("./offline-session.js");
25
25
  Object.defineProperty(exports, "createOfflineSession", { enumerable: true, get: function () { return offline_session_js_1.createOfflineSession; } });
26
+ var sdk_session_js_1 = require("./sdk-session.js");
27
+ Object.defineProperty(exports, "createSyncplaySession", { enumerable: true, get: function () { return sdk_session_js_1.createSyncplaySession; } });
26
28
  __exportStar(require("./instant-replay.js"), exports);
27
29
  __exportStar(require("./pause-resume.js"), exports);
28
30
  __exportStar(require("./session-promotion.js"), exports);
29
31
  var replay_js_1 = require("./replay.js");
30
32
  Object.defineProperty(exports, "verifyReplay", { enumerable: true, get: function () { return replay_js_1.verifyReplay; } });
31
33
  __exportStar(require("./replay-scoring.js"), exports);
34
+ var replay_bundle_js_1 = require("./replay-bundle.js");
35
+ Object.defineProperty(exports, "verifyReplayScoreAgainstTrustedRuntime", { enumerable: true, get: function () { return replay_bundle_js_1.verifyReplayScoreAgainstTrustedRuntime; } });
32
36
  __exportStar(require("./match-log.js"), exports);
33
37
  __exportStar(require("./authority-room.js"), exports);
34
38
  __exportStar(require("./ws-frame.js"), exports);
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hashUint32 = hashUint32;
4
+ exports.hash2 = hash2;
5
+ exports.valueNoise1D = valueNoise1D;
6
+ exports.valueNoise2D = valueNoise2D;
7
+ exports.fbm2D = fbm2D;
8
+ /** Integer avalanche hash (Murmur-style finalizer) → uint32. */
9
+ function hashUint32(x) {
10
+ let h = x | 0;
11
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
12
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
13
+ h = (h ^ (h >>> 16)) >>> 0;
14
+ return h;
15
+ }
16
+ function hash2(x, y) {
17
+ return hashUint32((Math.imul(x | 0, 0x1f1f1f1f) ^ Math.imul(y | 0, 0x8da6b343)) >>> 0);
18
+ }
19
+ /** Hash → fixed value in [-scale, scale]. */
20
+ function hashToFixed(h, fixedScale) {
21
+ return Math.trunc((h / 4294967296) * (2 * fixedScale)) - fixedScale;
22
+ }
23
+ function valueNoise1D(math, xFixed, seed) {
24
+ const scale = math.fixedScale;
25
+ const i0 = Math.floor(xFixed / scale);
26
+ const frac = xFixed - i0 * scale;
27
+ const g0 = hashToFixed(hash2(i0, seed), scale);
28
+ const g1 = hashToFixed(hash2(i0 + 1, seed), scale);
29
+ const t = math.smoothstep(0, scale, frac);
30
+ return math.lerp(g0, g1, t);
31
+ }
32
+ function valueNoise2D(math, xFixed, yFixed, seed) {
33
+ const scale = math.fixedScale;
34
+ const ix = Math.floor(xFixed / scale);
35
+ const iy = Math.floor(yFixed / scale);
36
+ const fx = xFixed - ix * scale;
37
+ const fy = yFixed - iy * scale;
38
+ const c = (gx, gy) => hashToFixed(hashUint32((hash2(ix + gx, iy + gy) ^ hashUint32(seed)) >>> 0), scale);
39
+ const tx = math.smoothstep(0, scale, fx);
40
+ const ty = math.smoothstep(0, scale, fy);
41
+ const top = math.lerp(c(0, 0), c(1, 0), tx);
42
+ const bot = math.lerp(c(0, 1), c(1, 1), tx);
43
+ return math.lerp(top, bot, ty);
44
+ }
45
+ function fbm2D(math, xFixed, yFixed, seed, octaves) {
46
+ const scale = math.fixedScale;
47
+ let sum = 0;
48
+ let amp = scale;
49
+ let freq = scale;
50
+ let norm = 0;
51
+ for (let o = 0; o < octaves; o += 1) {
52
+ sum += math.mul(valueNoise2D(math, math.mul(xFixed, freq), math.mul(yFixed, freq), seed + o), amp);
53
+ norm += amp;
54
+ amp = Math.trunc(amp / 2);
55
+ freq = freq * 2;
56
+ }
57
+ return norm === 0 ? 0 : Math.trunc((sum * scale) / norm);
58
+ }
@@ -37,6 +37,8 @@ exports.runPhysicsCert = runPhysicsCert;
37
37
  exports.runRapierPhysicsCert = runRapierPhysicsCert;
38
38
  const canonical_js_1 = require("./canonical.js");
39
39
  const collision_js_1 = require("./collision.js");
40
+ const physics3d_js_1 = require("./physics3d.js");
41
+ const physics3d_vehicle_js_1 = require("./physics3d-vehicle.js");
40
42
  const rapier2dCompatPackage = '@dimforge/rapier2d-compat';
41
43
  const physicsCertResults = {
42
44
  true: 'pass',
@@ -44,8 +46,9 @@ const physicsCertResults = {
44
46
  };
45
47
  function runPhysicsCert(options) {
46
48
  validateOptions(options);
47
- const uninterrupted = runPhysicsSimulation(options, false);
48
- const restored = runPhysicsSimulation(options, true);
49
+ const simulate = options.engine === 'builtin-rigid' ? runRigidSimulation : runPhysicsSimulation;
50
+ const uninterrupted = simulate(options, false);
51
+ const restored = simulate(options, true);
49
52
  const snapshotBytes = [...restored.snapshotBytes].sort((left, right) => left - right);
50
53
  const stepTimings = [...restored.stepTimings].sort((left, right) => left - right);
51
54
  const snapshotTimings = [...restored.snapshotTimings].sort((left, right) => left - right);
@@ -59,11 +62,11 @@ function runPhysicsCert(options) {
59
62
  snapshotHashStable;
60
63
  return {
61
64
  engine: options.engine,
62
- engineVersion: 'builtin-fixed@1',
65
+ engineVersion: options.engine === 'builtin-rigid' ? 'builtin-rigid@1' : 'builtin-fixed@1',
63
66
  adapterVersion: 'physics-cert@1',
64
67
  shape: options.shape,
65
68
  players: options.players,
66
- bodies: createInitialBodies(options).length,
69
+ bodies: options.engine === 'builtin-rigid' ? countRigidArenaBodies(options) : createInitialBodies(options).length,
67
70
  frames: options.frames,
68
71
  snapshotEvery: options.snapshotEvery,
69
72
  seed: options.seed,
@@ -267,6 +270,144 @@ function quantizeRapierNumber(value) {
267
270
  function countRapierDynamicBodies(options) {
268
271
  return options.players * (options.shape === 'fixed-arena' ? 2 : 4);
269
272
  }
273
+ function arenaVehicleWheel(localAnchor, steered) {
274
+ return {
275
+ localAnchor,
276
+ suspensionDir: { x: 0, y: -1, z: 0 },
277
+ suspensionRestLength: 0.6,
278
+ suspensionMaxTravel: 0.3,
279
+ radius: 0.3,
280
+ stiffness: 60,
281
+ damping: 220,
282
+ grip: 1,
283
+ maxSteerAngleTurns: 0.1,
284
+ isSteered: steered,
285
+ isDriven: steered,
286
+ };
287
+ }
288
+ /** Scripted arena driving: launch, weave, then brake to a stop. */
289
+ function arenaVehicleInput(frame) {
290
+ if (frame >= 400) {
291
+ return { throttle: 0, steer: 0, brake: 10 };
292
+ }
293
+ return { throttle: 10, steer: Math.trunc(frame / 32) % 2 === 0 ? 10 : -10, brake: 0 };
294
+ }
295
+ /**
296
+ * Mixed rigid arena exercising every solver feature the determinism gates must
297
+ * cover: a settling stack, a hinge door with a motor, a grab-target box, and a
298
+ * high-speed ccd projectile per player.
299
+ */
300
+ function buildRigidArena(options) {
301
+ const bodies = [{
302
+ id: 'arena-floor', kind: 'static', x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0,
303
+ shape: { type: 'box', halfX: 400, halfY: 0.5, halfZ: 400 }, layer: 1, mask: 1,
304
+ material: { friction: 10, restitution: 0 },
305
+ }];
306
+ const joints = [];
307
+ const ident = { x: 0, y: 0, z: 0, w: 1 };
308
+ for (let slot = 0; slot < options.players; slot += 1) {
309
+ const baseX = slot * 12 + (options.seed % 3);
310
+ // stack of 3 boxes
311
+ for (let layer = 0; layer < 3; layer += 1) {
312
+ bodies.push({
313
+ id: `stack-${slot}-${layer}`, kind: 'dynamic', x: baseX, y: 0.5 + 0.5 + layer, z: 0,
314
+ vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 },
315
+ shape: { type: 'box', halfX: 0.5, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1,
316
+ material: { friction: 8, restitution: 0 },
317
+ });
318
+ }
319
+ // hinge door
320
+ bodies.push({ id: `frame-${slot}`, kind: 'static', x: baseX + 3, y: 2, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.1, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1 });
321
+ bodies.push({ id: `door-${slot}`, kind: 'dynamic', x: baseX + 4, y: 2, z: 0, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 1, halfY: 0.5, halfZ: 0.1 }, layer: 1, mask: 1 });
322
+ joints.push({ id: `hinge-${slot}`, type: 'hinge', a: `frame-${slot}`, b: `door-${slot}`, anchorA: { x: 0, y: 0, z: 0 }, anchorB: { x: -1, y: 0, z: 0 }, axisA: { x: 0, y: 1, z: 0 }, motorSpeed: 0.02, maxMotorTorque: 50 });
323
+ // grab-target box
324
+ bodies.push({ id: `grab-${slot}`, kind: 'dynamic', x: baseX + 6, y: 3, z: 0, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 0.5, halfY: 0.5, halfZ: 0.5 }, layer: 1, mask: 1 });
325
+ joints.push({ id: `carry-${slot}`, type: 'grab', body: `grab-${slot}`, anchor: { x: 0, y: 0, z: 0 }, targetX: baseX + 6, targetY: 4, targetZ: 0, maxForce: 20 });
326
+ // ccd projectile toward a thin wall
327
+ bodies.push({ id: `wall-${slot}`, kind: 'static', x: baseX + 9, y: 2, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.05, halfY: 2, halfZ: 2 }, layer: 1, mask: 1 });
328
+ bodies.push({ id: `bullet-${slot}`, kind: 'dynamic', x: baseX - 20, y: 2, z: 0, vx: 60, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, ccd: true, linearDamping: 1, angularDamping: 1, shape: { type: 'box', halfX: 0.2, halfY: 0.2, halfZ: 0.2 }, layer: 1, mask: 1 });
329
+ // soft spring: bob hung 2 below a static anchor, restLength 1 → oscillates and settles
330
+ bodies.push({ id: `spring-anchor-${slot}`, kind: 'static', x: baseX + 2, y: 6, z: 3, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 0.1, halfY: 0.1, halfZ: 0.1 }, layer: 2, mask: 2 });
331
+ bodies.push({ id: `spring-bob-${slot}`, kind: 'dynamic', x: baseX + 2, y: 4, z: 3, vx: 0, vy: 0, vz: 0, mass: 1, orientation: ident, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 0.3, halfY: 0.3, halfZ: 0.3 }, layer: 2, mask: 2 });
332
+ joints.push({ id: `spring-${slot}`, type: 'spring', a: `spring-anchor-${slot}`, b: `spring-bob-${slot}`, anchorA: { x: 0, y: 0, z: 0 }, anchorB: { x: 0, y: 0, z: 0 }, restLength: 1, frequencyHz: 2, dampingRatio: 0.5 });
333
+ }
334
+ // raycast-wheel vehicle: dropped onto the floor, then driven by a scripted
335
+ // input sequence (see arenaVehicleInput), clear of the per-slot clutter
336
+ bodies.push({
337
+ id: 'arena-car', kind: 'dynamic', x: -6, y: 2.55, z: 12,
338
+ vx: 0, vy: 0, vz: 0, mass: 1200, orientation: ident, angularVel: { x: 0, y: 0, z: 0 },
339
+ shape: { type: 'box', halfX: 1, halfY: 0.25, halfZ: 2 }, layer: 1, mask: 1,
340
+ });
341
+ const vehicle = {
342
+ chassisId: 'arena-car',
343
+ driveForce: 12,
344
+ brakeForce: 40,
345
+ wheels: [
346
+ arenaVehicleWheel({ x: -0.9, y: -0.25, z: 1.5 }, true),
347
+ arenaVehicleWheel({ x: 0.9, y: -0.25, z: 1.5 }, true),
348
+ arenaVehicleWheel({ x: -0.9, y: -0.25, z: -1.5 }, false),
349
+ arenaVehicleWheel({ x: 0.9, y: -0.25, z: -1.5 }, false),
350
+ ],
351
+ };
352
+ return { world: (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies), joints, vehicle };
353
+ }
354
+ function countRigidArenaBodies(options) {
355
+ return 2 + options.players * 10; // floor + arena car + (3 stack + frame + door + grab + wall + bullet + spring anchor + spring bob)
356
+ }
357
+ function runRigidSimulation(options, withRestore) {
358
+ const { world: initialWorld, joints, vehicle } = buildRigidArena(options);
359
+ let world = initialWorld;
360
+ const collisionEvents = [];
361
+ const stepTimings = [];
362
+ const snapshotTimings = [];
363
+ const restoreTimings = [];
364
+ const snapshotBytes = [];
365
+ const snapshotChecksums = [];
366
+ let queryOrderStable = true;
367
+ for (let frame = 0; frame < options.frames; frame += 1) {
368
+ const stepStartedAt = performance.now();
369
+ const drive = (0, physics3d_vehicle_js_1.stepDeterministicVehicle3D)(vehicle, arenaVehicleInput(frame), world, { dtTicks: 1 });
370
+ world = (0, physics3d_js_1.applyDeterministicImpulses3D)(world, drive.impulses.map((impulse) => ({
371
+ bodyId: impulse.bodyId,
372
+ impulse: impulse.impulse,
373
+ worldPoint: impulse.point,
374
+ })));
375
+ const step = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: -0.02, joints, computeChecksum: false });
376
+ world = step.world;
377
+ stepTimings.push(performance.now() - stepStartedAt);
378
+ for (const contact of step.contacts) {
379
+ collisionEvents.push(`${frame + 1}:${contact.pair}:${contact.type}`);
380
+ }
381
+ if (withRestore && frame % options.snapshotEvery === 0) {
382
+ const snapshotStartedAt = performance.now();
383
+ // snapshot the WHOLE world including contactCache/jointCache — dropping the
384
+ // cache here would diverge (the negative case the spike identified).
385
+ const snapshot = JSON.stringify(world);
386
+ snapshotTimings.push(performance.now() - snapshotStartedAt);
387
+ snapshotBytes.push(Buffer.byteLength(snapshot));
388
+ snapshotChecksums.push({ first: (0, canonical_js_1.defaultChecksum)(snapshot), second: (0, canonical_js_1.defaultChecksum)(JSON.stringify(world)) });
389
+ const restoreStartedAt = performance.now();
390
+ world = JSON.parse(snapshot);
391
+ restoreTimings.push(performance.now() - restoreStartedAt);
392
+ }
393
+ const hits = (0, physics3d_js_1.raycastDeterministicPhysics3D)(world, { x: -1000, y: 2, z: 0, dx: 1, dy: 0, dz: 0, maxDistance: 5000, layerMask: 1 });
394
+ for (let index = 1; index < hits.length; index += 1) {
395
+ if (hits[index].distance < hits[index - 1].distance) {
396
+ queryOrderStable = false;
397
+ }
398
+ }
399
+ }
400
+ return {
401
+ finalChecksum: (0, canonical_js_1.defaultChecksum)(world),
402
+ collisionEvents,
403
+ queryOrderStable,
404
+ stepTimings,
405
+ snapshotTimings,
406
+ restoreTimings,
407
+ snapshotBytes,
408
+ snapshotChecksums,
409
+ };
410
+ }
270
411
  function runPhysicsSimulation(options, withRestore) {
271
412
  let state = {
272
413
  frame: 0,
@@ -396,8 +537,8 @@ function physicsCertResult(pass) {
396
537
  return physicsCertResults[String(pass)];
397
538
  }
398
539
  function validateOptions(options) {
399
- if (options.engine !== 'builtin-fixed') {
400
- throw new Error('physics certification currently supports engine=builtin-fixed');
540
+ if (options.engine !== 'builtin-fixed' && options.engine !== 'builtin-rigid') {
541
+ throw new Error('physics certification currently supports engine=builtin-fixed or engine=builtin-rigid');
401
542
  }
402
543
  validateCommonOptions(options);
403
544
  }
@@ -272,18 +272,22 @@ function separateDynamicPair(a, b) {
272
272
  const correctionA = Math.ceil(overlapX / 2);
273
273
  const correctionB = Math.ceil(overlapX / 2);
274
274
  return {
275
- a: { ...a, x: a.x + direction * correctionA, vx: normalizeZero(Math.trunc((-a.vx * material.restitution) / 10)) },
276
- b: { ...b, x: b.x - direction * correctionB, vx: normalizeZero(Math.trunc((-b.vx * material.restitution) / 10)) },
275
+ a: { ...a, x: a.x + direction * correctionA, vx: normalizeZero(Math.trunc((-a.vx * material.restitution) / 10)), vy: applyTangentFriction2D(a.vy, material.friction) },
276
+ b: { ...b, x: b.x - direction * correctionB, vx: normalizeZero(Math.trunc((-b.vx * material.restitution) / 10)), vy: applyTangentFriction2D(b.vy, material.friction) },
277
277
  };
278
278
  }
279
279
  const direction = a.y <= b.y ? -1 : 1;
280
280
  const correctionA = Math.ceil(overlapY / 2);
281
281
  const correctionB = Math.ceil(overlapY / 2);
282
282
  return {
283
- a: { ...a, y: a.y + direction * correctionA, vy: normalizeZero(Math.trunc((-a.vy * material.restitution) / 10)) },
284
- b: { ...b, y: b.y - direction * correctionB, vy: normalizeZero(Math.trunc((-b.vy * material.restitution) / 10)) },
283
+ a: { ...a, y: a.y + direction * correctionA, vy: normalizeZero(Math.trunc((-a.vy * material.restitution) / 10)), vx: applyTangentFriction2D(a.vx, material.friction) },
284
+ b: { ...b, y: b.y - direction * correctionB, vy: normalizeZero(Math.trunc((-b.vy * material.restitution) / 10)), vx: applyTangentFriction2D(b.vx, material.friction) },
285
285
  };
286
286
  }
287
+ /** Integer tangential-friction scaling, mirroring physics3d's legacy pusher semantics. */
288
+ function applyTangentFriction2D(velocity, friction) {
289
+ return normalizeZero(Math.trunc((velocity * Math.max(0, 10 - friction)) / 10));
290
+ }
287
291
  function separateFromStatic(dynamicBody, staticBody) {
288
292
  const dynamicBounds = bodyBounds(dynamicBody);
289
293
  const staticBounds = bodyBounds(staticBody);
@@ -299,6 +303,7 @@ function separateFromStatic(dynamicBody, staticBody) {
299
303
  ...dynamicBody,
300
304
  x: dynamicBody.x + direction * overlapX,
301
305
  vx: normalizeZero(Math.trunc((-dynamicBody.vx * material.restitution) / 10)),
306
+ vy: applyTangentFriction2D(dynamicBody.vy, material.friction),
302
307
  };
303
308
  }
304
309
  const direction = dynamicBody.y < staticBody.y ? -1 : 1;
@@ -306,6 +311,7 @@ function separateFromStatic(dynamicBody, staticBody) {
306
311
  ...dynamicBody,
307
312
  y: dynamicBody.y + direction * overlapY,
308
313
  vy: normalizeZero(Math.trunc((-dynamicBody.vy * material.restitution) / 10)),
314
+ vx: applyTangentFriction2D(dynamicBody.vx, material.friction),
309
315
  };
310
316
  }
311
317
  function detectContacts(bodies) {