@series-inc/rundot-syncplay 5.26.0-beta.4 → 5.26.0-beta.6

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.
@@ -409,12 +409,34 @@ const KCC_GROUND_CLEARANCE = 1e-4;
409
409
  * instead of the skyward fling an epsilon probe would give. Per axis it takes
410
410
  * the steeper of forward/backward so a sample straddling an edge cannot
411
411
  * average its way to an under-lift and back into the freeze.
412
+ *
413
+ * A sample that lands outside the selected body's own footprint falls back to
414
+ * the highest neighbouring static surface at that point. Without this a
415
+ * capsule straddling the seam between two abutting bodies (two heightfield
416
+ * chunks) is lifted only for the chunk under its centre: crossing a CONVEX
417
+ * crease downhill, the shallow chunk's lift collapses while the bottom sphere
418
+ * is still riding the steep chunk behind, the capsule is snapped down into
419
+ * that chunk, and the slide reverts the move — frozen exactly at the seam.
420
+ * Only undefined samples consult the fallback, so a single unbroken body (or
421
+ * a capsule beside a raised prop on continuous ground) behaves as before.
422
+ *
423
+ * The fallback is gated on continuity at the shared edge (see
424
+ * continuousNeighbourTopAt): streamed chunks meet their neighbours there, a
425
+ * wall or prop abutting the ground does not. Without the gate such a prop
426
+ * reads as an extreme slope and the lift snaps the capsule up its side.
412
427
  */
413
- function kccSlopeClearance(body, x, z, top, geometry) {
428
+ function kccSlopeClearance(body, x, z, top, geometry, neighbourTopAt) {
414
429
  const step = geometry.radius + geometry.skinWidth;
430
+ const sampleTopAt = (sampleX, sampleZ) => {
431
+ const own = groundContactTopAt(body, sampleX, sampleZ);
432
+ if (own !== undefined || neighbourTopAt === undefined) {
433
+ return own;
434
+ }
435
+ return continuousNeighbourTopAt(body, x, z, sampleX, sampleZ, neighbourTopAt);
436
+ };
415
437
  const axisGradient = (deltaX, deltaZ) => {
416
- const ahead = groundContactTopAt(body, x + deltaX, z + deltaZ);
417
- const behind = groundContactTopAt(body, x - deltaX, z - deltaZ);
438
+ const ahead = sampleTopAt(x + deltaX, z + deltaZ);
439
+ const behind = sampleTopAt(x - deltaX, z - deltaZ);
418
440
  const forward = ahead === undefined ? 0 : Math.abs(ahead - top) / step;
419
441
  const backward = behind === undefined ? 0 : Math.abs(top - behind) / step;
420
442
  return Math.max(forward, backward);
@@ -428,6 +450,55 @@ function kccSlopeClearance(body, x, z, top, geometry) {
428
450
  // radius + skinWidth is the bottom-sphere radius physicsOverlapsKcc probes with.
429
451
  return (geometry.radius + geometry.skinWidth) * (solverSqrt(1 + gradientSq) - 1);
430
452
  }
453
+ /**
454
+ * How far two abutting bodies' surfaces may differ at their shared edge before
455
+ * the neighbour stops counting as walkable continuation. Fixed rather than
456
+ * derived from skinWidth (which defaults to 0): it only has to swallow
457
+ * triangulation and bisection noise at a streamed-terrain seam — millimetres
458
+ * at any sane gradient — while staying well under anything a game would call
459
+ * a step (`world.stepHeight`, typically 0.5+).
460
+ */
461
+ const KCC_SEAM_CONTINUITY_TOLERANCE = 0.05;
462
+ /** Ray-bisection resolution for the footprint edge: 8 halvings of a sample step. */
463
+ const KCC_SEAM_EDGE_BISECTIONS = 8;
464
+ /**
465
+ * Neighbour surface at a sample point off the selected body's footprint, or
466
+ * undefined when no neighbour CONTINUES the selected surface there.
467
+ *
468
+ * Continuity is judged at the shared edge, not by extrapolating the selected
469
+ * surface — at a crease the two gradients differ by design, so any
470
+ * extrapolation test rejects exactly the seams this exists for. Bisect the
471
+ * centre→sample ray for the footprint edge, then compare the selected surface
472
+ * just inside it with the neighbour surface just outside: streamed chunks
473
+ * from one source interpolate the same shared edge row, so they match to
474
+ * float noise at ANY gradient, while a wall, prop, or cliff abutting the edge
475
+ * misses by its full height and is rejected. Fixed iteration count and plain
476
+ * double arithmetic keep it bit-identical on every peer.
477
+ */
478
+ function continuousNeighbourTopAt(body, x, z, sampleX, sampleZ, neighbourTopAt) {
479
+ // The centre is on the selected body (its top is what kccSlopeClearance was
480
+ // given) and the sample is off it, so the ray crosses a footprint edge.
481
+ let inside = 0;
482
+ let outside = 1;
483
+ for (let i = 0; i < KCC_SEAM_EDGE_BISECTIONS; i += 1) {
484
+ const mid = (inside + outside) / 2;
485
+ if (groundContactTopAt(body, x + (sampleX - x) * mid, z + (sampleZ - z) * mid) === undefined) {
486
+ outside = mid;
487
+ }
488
+ else {
489
+ inside = mid;
490
+ }
491
+ }
492
+ const innerTop = groundContactTopAt(body, x + (sampleX - x) * inside, z + (sampleZ - z) * inside);
493
+ const outerTop = neighbourTopAt(x + (sampleX - x) * outside, z + (sampleZ - z) * outside);
494
+ if (innerTop === undefined || outerTop === undefined) {
495
+ return undefined;
496
+ }
497
+ if (Math.abs(innerTop - outerTop) > KCC_SEAM_CONTINUITY_TOLERANCE) {
498
+ return undefined;
499
+ }
500
+ return neighbourTopAt(sampleX, sampleZ);
501
+ }
431
502
  /**
432
503
  * Distance from the capsule's centre to the bottom of its collision shape.
433
504
  * A capsule's vertical extent is halfHeight + radius (see primitiveHalfExtent),
@@ -461,13 +532,29 @@ function staticGroundUnderKcc(physics, x, y, z, previousFeetY, geometry, ignored
461
532
  hitKinematics: false,
462
533
  };
463
534
  const hitIds = new Set(overlapDeterministicPhysics3D(physics, probe));
464
- const grounds = physics.bodies
465
- .filter((body) => body.kind === 'static' && hitIds.has(body.id) && !ignoredBodyIds.has(body.id))
535
+ const hitBodies = physics.bodies
536
+ .filter((body) => body.kind === 'static' && hitIds.has(body.id) && !ignoredBodyIds.has(body.id));
537
+ const grounds = hitBodies
466
538
  .map((body) => ({ body, top: groundContactTopAt(body, x, z) }))
467
539
  .filter((candidate) => candidate.top !== undefined)
468
540
  .map((candidate) => ({
469
541
  ...candidate,
470
- lift: kccSlopeClearance(candidate.body, x, z, candidate.top, geometry),
542
+ // Where the selected body has no surface (a sample past its edge), read
543
+ // the highest neighbouring static surface instead — abutting terrain
544
+ // chunks share an edge, and the bottom sphere rides BOTH across a seam.
545
+ lift: kccSlopeClearance(candidate.body, x, z, candidate.top, geometry, (sampleX, sampleZ) => {
546
+ let neighbourTop;
547
+ for (const neighbour of hitBodies) {
548
+ if (neighbour.id === candidate.body.id) {
549
+ continue;
550
+ }
551
+ const top = groundContactTopAt(neighbour, sampleX, sampleZ);
552
+ if (top !== undefined) {
553
+ neighbourTop = neighbourTop === undefined ? top : Math.max(neighbourTop, top);
554
+ }
555
+ }
556
+ return neighbourTop;
557
+ }),
471
558
  }))
472
559
  // The band is measured from where the capsule actually STANDS, so a slope
473
560
  // lift bigger than snapDistance cannot drop it out of its own ground the
package/dist/physics3d.js CHANGED
@@ -761,7 +761,11 @@ export function raycastDeterministicPhysics3DDetailed(world, query) {
761
761
  */
762
762
  function bodyOverlapsQueryShape(body, shape, x, y, z, queryBounds) {
763
763
  if (body.shape.type === 'heightfield') {
764
- return bodyOverlapsQueryShape({ ...body, shape: heightfieldMeshShape(body.shape, localBoundsForHeightfield(body, queryBounds)) }, shape, x, y, z, queryBounds);
764
+ const mesh = heightfieldMeshShape(body.shape, localBoundsForHeightfield(body, queryBounds));
765
+ if (mesh.vertices.length === 0) {
766
+ return false;
767
+ }
768
+ return bodyOverlapsQueryShape({ ...body, shape: mesh }, shape, x, y, z, queryBounds);
765
769
  }
766
770
  if (shape.type === 'heightfield') {
767
771
  return bodyOverlapsQueryShape(body, heightfieldMeshShape(shape), x, y, z, queryBounds);
@@ -3100,6 +3104,9 @@ function rayBodyDistance(body, query) {
3100
3104
  }
3101
3105
  if (body.shape.type === 'heightfield') {
3102
3106
  const mesh = heightfieldMeshShape(body.shape, localBoundsForHeightfield(body, raySweepBounds(query)));
3107
+ if (mesh.vertices.length === 0) {
3108
+ return undefined;
3109
+ }
3103
3110
  return rayMeshDistance(meshPose({ ...bodyPose(body), shape: mesh }, mesh), query);
3104
3111
  }
3105
3112
  if (body.shape.type === 'hull') {
@@ -3163,6 +3170,9 @@ function shapeCastBodyDistance(body, query) {
3163
3170
  }
3164
3171
  if (body.shape.type === 'heightfield') {
3165
3172
  const mesh = heightfieldMeshShape(body.shape, localBoundsForHeightfield(body, shapeCastSweepBounds(query)));
3173
+ if (mesh.vertices.length === 0) {
3174
+ return undefined;
3175
+ }
3166
3176
  return rayExpandedMeshDistance(meshPose({ ...bodyPose(body), shape: mesh }, mesh), query, query.shape.radius);
3167
3177
  }
3168
3178
  if (body.shape.type === 'sphere') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/rundot-syncplay",
3
- "version": "5.26.0-beta.4",
3
+ "version": "5.26.0-beta.6",
4
4
  "description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
5
5
  "repository": {
6
6
  "type": "git",