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

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/browser.d.ts CHANGED
@@ -27,6 +27,7 @@ export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js
27
27
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
28
28
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
29
29
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
30
+ export { cylinderHullVertices } from './physics3d-hull.js';
30
31
  export { createDeterministicCosmeticPhysicsWorld3D, setDeterministicCosmeticPhysicsAuthoritativeBodyProxies3D, stepDeterministicCosmeticPhysicsWorld3D } from './cosmetic-physics3d.js';
31
32
  export { stepDeterministicVehicle3D } from './physics3d-vehicle.js';
32
33
  export { createDeterministicR3fRenderAdapter as createDeterministicR3fAdapter, createDeterministicR3fRenderAdapter, interpolateDeterministicRenderNumber, interpolateDeterministicRenderVec2 } from './r3f-render-adapter.js';
@@ -60,7 +61,7 @@ export type { DeterministicAuthoritativeLagCompensation3DShotContext, Determinis
60
61
  export type { DeterministicLocalAuthoritySnapshotIdentity, DeterministicLocalAuthoritySnapshotTransfer, DeterministicLocalAuthorityWireEnvelope, DeterministicLocalAuthorityWireKind } from './local-authority-wire.js';
61
62
  export type { DeterministicFixedQuaternion, DeterministicFixedVec2, DeterministicFixedVec3, DeterministicMath } from './math.js';
62
63
  export type { DeterministicFpsAimState, DeterministicFpsInput, DeterministicKccBody, DeterministicKccInput, DeterministicKccStepResult, DeterministicLagCompensatedHitValidation, DeterministicLagCompensatedShot, DeterministicMovementCrowdMetrics, DeterministicMovementSimulationMetrics, DeterministicMovementStep, DeterministicMovementWorld, DeterministicMovingPlatform, DeterministicOneWayPlatform, DeterministicSlope, DeterministicTargetSnapshot, DeterministicTopDownBody, DeterministicTopDownBounds, DeterministicTopDownInput } from './movement.js';
63
- export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
64
+ export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccJumpTuning3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
64
65
  export type { CookedDeterministicNavigationGraph, DeterministicFlowField, DeterministicNavEdge, DeterministicNavigationAgent, DeterministicNavigationGraphSource, DeterministicNavigationStepOptions, DeterministicNavigationStepResult, DeterministicNavNode, DeterministicNavRegion, DeterministicPathQuery, DeterministicPathResult, DeterministicTilemapSource } from './navigation.js';
65
66
  export type { CookedDeterministicNavmesh, DeterministicNavmeshAgent, DeterministicNavmeshAgentStepResult, DeterministicNavmeshImportSource, DeterministicNavmeshLink, DeterministicNavmeshMovingAvoidanceObstacle, DeterministicNavmeshOffMeshTraversalState, DeterministicNavmeshOffMeshTraversalStep, DeterministicNavmeshPath, DeterministicNavmeshPoint, DeterministicNavmeshPolygon, DeterministicNavmeshSource } from './navmesh.js';
66
67
  export type { ConfirmedSyncplayPresentationFrame, KinetixRuntimeFactory, NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplayPreparationOptions, NetworkedSyncplaySessionDescriptor, NetworkedSyncplaySlotPresence, PreparedNetworkedSyncplayTransport, SyncplayPredictionMode } from './networked-client.js';
package/dist/browser.js CHANGED
@@ -28,6 +28,7 @@ export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js
28
28
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
29
29
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
30
30
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
31
+ export { cylinderHullVertices } from './physics3d-hull.js';
31
32
  export { createDeterministicCosmeticPhysicsWorld3D, setDeterministicCosmeticPhysicsAuthoritativeBodyProxies3D, stepDeterministicCosmeticPhysicsWorld3D } from './cosmetic-physics3d.js';
32
33
  export { stepDeterministicVehicle3D } from './physics3d-vehicle.js';
33
34
  export { createDeterministicR3fRenderAdapter as createDeterministicR3fAdapter, createDeterministicR3fRenderAdapter, interpolateDeterministicRenderNumber, interpolateDeterministicRenderVec2 } from './r3f-render-adapter.js';
package/dist/creator.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { SyncplaySession, SyncplaySessionPolicy } from './sdk-session.js';
14
14
  export type { InstantReplayInputFrame, InstantReplayResult, InstantReplayRunner, InstantReplayRunnerOptions, InstantReplayRunOptions, RunInstantReplayOptions, } from './instant-replay.js';
15
15
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D, } from './physics3d.js';
16
16
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
17
+ export { cylinderHullVertices } from './physics3d-hull.js';
17
18
  export { stepDeterministicVehicle3D } from './physics3d-vehicle.js';
18
19
  export type { DeterministicContactCacheEntry3D, DeterministicJointCacheEntry3D, DeterministicPhysicsActivePair3D, DeterministicPhysicsAxisLock3D, DeterministicPhysicsBody3D, DeterministicPhysicsCallbackMask3D, DeterministicPhysicsCompoundChild3D, DeterministicPhysicsCompoundChildShape3D, DeterministicPhysicsConstraint3D, DeterministicPhysicsContactDetail3D, DeterministicPhysicsContactEvent3D, DeterministicPhysicsDetailedHit3D, DeterministicPhysicsHit3D, DeterministicPhysicsHullShape3D, DeterministicPhysicsImpulseApplication3D, DeterministicPhysicsOverlapQuery3D, DeterministicPhysicsPrimitiveShape3D, DeterministicPhysicsQuaternion3D, DeterministicPhysicsQueryOptions3D, DeterministicPhysicsRaycastQuery3D, DeterministicPhysicsScheduledQuery3D, DeterministicPhysicsShape3D, DeterministicPhysicsShapeCastQuery3D, DeterministicPhysicsStepResult3D, DeterministicPhysicsVec3, DeterministicPhysicsWorld3D, DeterministicPhysicsWorldEdit3D, } from './physics3d.js';
19
20
  export type { DeterministicVoxelChunk3D, DeterministicVoxelCompoundShape3D } from './physics3d-voxel.js';
package/dist/creator.js CHANGED
@@ -11,4 +11,5 @@ export { runSyncplaySynctest } from './synctest.js';
11
11
  // stay off it — the first two are not requested, the third reads wall clock.
12
12
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D, } from './physics3d.js';
13
13
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
14
+ export { cylinderHullVertices } from './physics3d-hull.js';
14
15
  export { stepDeterministicVehicle3D } from './physics3d-vehicle.js';
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js
30
30
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
31
31
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
32
32
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
33
+ export { cylinderHullVertices } from './physics3d-hull.js';
33
34
  export { runDeterministicPhysics3DDestructionCert } from './physics3d-destruction-cert.js';
34
35
  export { createDeterministicCosmeticPhysicsWorld3D, setDeterministicCosmeticPhysicsAuthoritativeBodyProxies3D, stepDeterministicCosmeticPhysicsWorld3D } from './cosmetic-physics3d.js';
35
36
  export { runDeterministicKickRecovery3DScenario, runDeterministicMassRatio3DScenario, runDeterministicPyramid3DScenario, runDeterministicStackingSuite3D, runDeterministicTower3DScenario } from './physics3d-stacking.js';
@@ -72,7 +73,7 @@ export type { DeterministicAuthoritativeLagCompensation3DShotContext, Determinis
72
73
  export type { DeterministicLocalAuthoritySnapshotIdentity, DeterministicLocalAuthoritySnapshotTransfer, DeterministicLocalAuthorityWireEnvelope, DeterministicLocalAuthorityWireKind } from './local-authority-wire.js';
73
74
  export type { DeterministicFixedQuaternion, DeterministicFixedVec2, DeterministicFixedVec3, DeterministicMath } from './math.js';
74
75
  export type { DeterministicFpsAimState, DeterministicFpsInput, DeterministicKccBody, DeterministicKccInput, DeterministicKccStepResult, DeterministicMovementCrowdMetrics, DeterministicMovementSimulationMetrics, DeterministicMovementStep, DeterministicMovementWorld, DeterministicMovingPlatform, DeterministicOneWayPlatform, DeterministicSlope, DeterministicTargetSnapshot, DeterministicTopDownBody, DeterministicTopDownBounds, DeterministicTopDownInput } from './movement.js';
75
- export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
76
+ export type { DeterministicKccBody3D, DeterministicKccCarrierPose3D, DeterministicKccCarryResult3D, DeterministicKccCarryState3D, DeterministicKccCollisionCandidate3D, DeterministicKccCollisionFilter3D, DeterministicKccExternalImpulse3D, DeterministicKccGeometry3D, DeterministicKccInput3D, DeterministicKccJumpTuning3D, DeterministicKccPhysicsImpulse3D, DeterministicKccProcessor3D, DeterministicKccStepResult3D, DeterministicKccWorld3D, DeterministicMovingPlatform3D, DeterministicSlope3D, DeterministicStep3D } from './movement3d.js';
76
77
  export type { CookedDeterministicNavigationGraph, DeterministicFlowField, DeterministicNavEdge, DeterministicNavigationAgent, DeterministicNavigationGraphSource, DeterministicNavigationStepOptions, DeterministicNavigationStepResult, DeterministicNavNode, DeterministicNavRegion, DeterministicPathQuery, DeterministicPathResult, DeterministicTilemapSource } from './navigation.js';
77
78
  export type { CookedDeterministicNavmesh, DeterministicNavmeshAgent, DeterministicNavmeshAgentStepResult, DeterministicNavmeshImportSource, DeterministicNavmeshLink, DeterministicNavmeshMovingAvoidanceObstacle, DeterministicNavmeshOffMeshTraversalState, DeterministicNavmeshOffMeshTraversalStep, DeterministicNavmeshPath, DeterministicNavmeshPoint, DeterministicNavmeshPolygon, DeterministicNavmeshSource } from './navmesh.js';
78
79
  export type { ConfirmedSyncplayPresentationFrame, KinetixRuntimeFactory, NetworkedClientTransport, NetworkedSyncplayClient, NetworkedSyncplayClientInputDelayOptions, NetworkedSyncplayClientNetStats, NetworkedSyncplayClientOptions, NetworkedSyncplayClientPacingOptions, NetworkedSyncplayPreparationOptions, NetworkedSyncplaySessionDescriptor, PreparedNetworkedSyncplayTransport, SyncplayPredictionMode } from './networked-client.js';
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js
31
31
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
32
32
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
33
33
  export { cookDeterministicVoxelChunkCollider3D } from './physics3d-voxel.js';
34
+ export { cylinderHullVertices } from './physics3d-hull.js';
34
35
  export { runDeterministicPhysics3DDestructionCert } from './physics3d-destruction-cert.js';
35
36
  export { createDeterministicCosmeticPhysicsWorld3D, setDeterministicCosmeticPhysicsAuthoritativeBodyProxies3D, stepDeterministicCosmeticPhysicsWorld3D } from './cosmetic-physics3d.js';
36
37
  export { runDeterministicKickRecovery3DScenario, runDeterministicMassRatio3DScenario, runDeterministicPyramid3DScenario, runDeterministicStackingSuite3D, runDeterministicTower3DScenario } from './physics3d-stacking.js';
@@ -24,6 +24,20 @@ export interface DeterministicKccInput3D {
24
24
  readonly moveZ: number;
25
25
  readonly jumpPressed?: boolean;
26
26
  }
27
+ export interface DeterministicKccJumpTuning3D {
28
+ /** Upward velocity the jump sets, in units per frame. Default 10. */
29
+ readonly impulse?: number;
30
+ /**
31
+ * Immediate lift applied on the jump frame to unstick the capsule from the
32
+ * ground it is resting on. Defaults to `impulse / 10`, which keeps the
33
+ * historical 1-unit lift at the default impulse and scales with a retune.
34
+ */
35
+ readonly liftOff?: number;
36
+ /** Frames a jump stays available after leaving the ground. Default 4. */
37
+ readonly coyoteFrames?: number;
38
+ /** Frames an early jump press is held for. Minimum 1, default 4. */
39
+ readonly bufferFrames?: number;
40
+ }
27
41
  export interface DeterministicKccExternalImpulse3D {
28
42
  readonly id: string;
29
43
  readonly vx: number;
@@ -189,6 +203,23 @@ export declare function stepDeterministicKcc3D(body: DeterministicKccBody3D, inp
189
203
  readonly maxPushImpulse?: number;
190
204
  /** Enable standing on (and inheriting the velocity of) dynamic physics bodies. */
191
205
  readonly standOnDynamicBodies?: boolean;
206
+ /**
207
+ * Ground the capsule on STATIC physics bodies, including heightfields.
208
+ * Off by default: without it the physics world only blocks horizontally and
209
+ * vertical support comes from `world.slopes` / `steps` / platforms / `minY`,
210
+ * so a streaming-terrain game had to rebuild slope geometry to stand on the
211
+ * same terrain it already streams as colliders. Opt-in so shipped games keep
212
+ * bit-identical checksums.
213
+ */
214
+ readonly groundOnStaticBodies?: boolean;
215
+ /**
216
+ * Jump tuning, in the KCC's frame units. Gravity retunes through a processor
217
+ * because processors run before `vy -= 1`, but the jump impulse is applied
218
+ * AFTER them, so it cannot be scaled the same way — without these a game that
219
+ * retunes gravity must own jump, coyote and buffer itself. Every default
220
+ * reproduces the previous hardcoded behaviour bit-for-bit.
221
+ */
222
+ readonly jump?: DeterministicKccJumpTuning3D;
192
223
  }): DeterministicKccStepResult3D;
193
224
  export interface DeterministicKccCarryState3D {
194
225
  readonly bodyId: string;
@@ -1,7 +1,39 @@
1
1
  import { defaultChecksum } from './canonical.js';
2
2
  import { createDeterministicPhysicsWorld3D, overlapDeterministicPhysics3D, } from './physics3d.js';
3
- import { mulMat3Vec, quaternionFromQuarterTurns, quatMultiply, quatToMat3, rotatePointByQuat } from './physics3d-shared.js';
3
+ import { heightfieldLocalSurfaceY } from './physics3d-heightfield.js';
4
+ import { mulMat3Vec, quaternionFromQuarterTurns, quatMultiply, quatToMat3, rotatePointByQuat, solverSqrt } from './physics3d-shared.js';
5
+ function normalizeKccJumpTuning3D(tuning) {
6
+ const impulse = finiteKccJumpNumber(tuning?.impulse, 10, 'impulse');
7
+ return {
8
+ impulse,
9
+ liftOff: finiteKccJumpNumber(tuning?.liftOff, impulse / 10, 'liftOff'),
10
+ coyoteFrames: kccJumpFrameCount(tuning?.coyoteFrames, 'coyoteFrames', 0),
11
+ // A press arms the jump by writing this into jumpBufferFrames and the gate
12
+ // needs it > 0, so 0 would not mean "no buffering" — it would mean "never
13
+ // jump". 1 is the real floor: the press lives for the frame it happened on.
14
+ bufferFrames: kccJumpFrameCount(tuning?.bufferFrames, 'bufferFrames', 1),
15
+ };
16
+ }
17
+ function finiteKccJumpNumber(value, fallback, field) {
18
+ if (value === undefined) {
19
+ return fallback;
20
+ }
21
+ if (!Number.isFinite(value)) {
22
+ throw new Error(`KCC jump.${field} must be a finite number`);
23
+ }
24
+ return value;
25
+ }
26
+ function kccJumpFrameCount(value, field, minimum) {
27
+ if (value === undefined) {
28
+ return 4;
29
+ }
30
+ if (!Number.isInteger(value) || value < minimum) {
31
+ throw new Error(`KCC jump.${field} must be an integer >= ${minimum}, got ${value}`);
32
+ }
33
+ return value;
34
+ }
4
35
  export function stepDeterministicKcc3D(body, input, world, options) {
36
+ const jump = normalizeKccJumpTuning3D(options.jump);
5
37
  let next = applyProcessors(body, input, options.frame, options.processors ?? []);
6
38
  const events = [];
7
39
  let correctionCount = 0;
@@ -9,8 +41,8 @@ export function stepDeterministicKcc3D(body, input, world, options) {
9
41
  let collisionFilterCalls = 0;
10
42
  let collisionFilterRejectedHits = 0;
11
43
  const externalImpulse = combineExternalKccImpulses(options.externalImpulses ?? []);
12
- let coyoteFrames = next.grounded ? 4 : Math.max(0, next.coyoteFrames - 1);
13
- let jumpBufferFrames = input.jumpPressed === true ? 4 : Math.max(0, next.jumpBufferFrames - 1);
44
+ let coyoteFrames = next.grounded ? jump.coyoteFrames : Math.max(0, next.coyoteFrames - 1);
45
+ let jumpBufferFrames = input.jumpPressed === true ? jump.bufferFrames : Math.max(0, next.jumpBufferFrames - 1);
14
46
  let vx = input.moveX + externalImpulse.vx;
15
47
  let vz = input.moveZ + externalImpulse.vz;
16
48
  let vy = next.vy - 1 + externalImpulse.vy;
@@ -19,6 +51,7 @@ export function stepDeterministicKcc3D(body, input, world, options) {
19
51
  let z = next.z + vz;
20
52
  let grounded = false;
21
53
  let platformId = next.platformId;
54
+ let platformClaimedThisFrame = false;
22
55
  if (externalImpulse.applied) {
23
56
  events.push('external-impulse');
24
57
  }
@@ -36,6 +69,7 @@ export function stepDeterministicKcc3D(body, input, world, options) {
36
69
  vy = 0;
37
70
  grounded = true;
38
71
  platformId = platform.id;
72
+ platformClaimedThisFrame = true;
39
73
  events.push('moving-platform');
40
74
  }
41
75
  }
@@ -67,14 +101,66 @@ export function stepDeterministicKcc3D(body, input, world, options) {
67
101
  }
68
102
  }
69
103
  if (jumpBufferFrames > 0 && (next.grounded || next.coyoteFrames > 0 || grounded)) {
70
- vy = 10;
71
- y += 1;
104
+ vy = jump.impulse;
105
+ y += jump.liftOff;
72
106
  grounded = false;
73
107
  platformId = undefined;
74
108
  jumpBufferFrames = 0;
75
109
  coyoteFrames = 0;
76
110
  events.push(next.grounded ? 'jump' : 'coyote-jump');
77
111
  }
112
+ const previousFeetY = next.y - kccFeetOffset(geometry);
113
+ let groundedOnStatic = false;
114
+ let groundedOnDynamic = false;
115
+ // Resolved before the dynamic path so the higher of the two wins: a crate
116
+ // standing on a floor must not lose its platform to the floor underneath it.
117
+ const staticGround = options.physics !== undefined && options.groundOnStaticBodies === true && vy <= 0
118
+ ? staticGroundUnderKcc(options.physics, x, y, z, previousFeetY, geometry, new Set(options.ignoredPhysicsBodyIds ?? []), world.stepHeight)
119
+ : undefined;
120
+ if (options.physics !== undefined && options.standOnDynamicBodies === true && vy <= 0) {
121
+ const ground = dynamicGroundUnderKcc(options.physics, x, y, z, geometry, new Set(options.ignoredPhysicsBodyIds ?? []));
122
+ if (ground !== undefined && (staticGround === undefined || ground.topY >= staticGround.topY)) {
123
+ groundedOnDynamic = true;
124
+ platformClaimedThisFrame = true;
125
+ y = ground.topY;
126
+ vy = 0;
127
+ grounded = true;
128
+ platformId = ground.bodyId;
129
+ // Carry the rider with the crate this frame (position delta) AND inherit its
130
+ // velocity, mirroring the kinematic moving-platform path — otherwise the
131
+ // rider lags one frame and slides off a moving crate.
132
+ x += ground.deltaX;
133
+ z += ground.deltaZ;
134
+ vx += ground.deltaX;
135
+ vz += ground.deltaZ;
136
+ events.push('stand-on-dynamic');
137
+ if (ground.deltaX !== 0 || ground.deltaZ !== 0) {
138
+ events.push('platform-velocity-transfer');
139
+ }
140
+ }
141
+ }
142
+ // Applied BEFORE the horizontal slide: the slide resolves overlap horizontally
143
+ // only, so a capsule that fell into terrain would be shoved sideways across
144
+ // the surface instead of being stood on top of it. Skipped when a dynamic
145
+ // body already claimed a higher surface this frame.
146
+ // platformId carries over from the previous frame, so a rider that simply
147
+ // walked off keeps pointing at a platform it has left. Clear it once nothing
148
+ // has claimed it this frame.
149
+ if (options.standOnDynamicBodies === true && !platformClaimedThisFrame) {
150
+ platformId = undefined;
151
+ }
152
+ // `grounded` here means an earlier source (moving platform, slope, step, or a
153
+ // dynamic body) already claimed a surface this frame; static only wins if it
154
+ // is higher. platformId cannot be used for this — it carries over from the
155
+ // previous frame, so stepping off a crate onto terrain would skip grounding.
156
+ if (staticGround !== undefined && (!grounded || staticGround.topY > y)) {
157
+ platformId = undefined;
158
+ y = staticGround.topY;
159
+ vy = 0;
160
+ grounded = true;
161
+ groundedOnStatic = true;
162
+ events.push('static-ground');
163
+ }
78
164
  // The KCC's intended (pre-slide) horizontal position and velocity — push
79
165
  // impulses are computed here because the slide blocks the KCC off the body.
80
166
  const pushCandidate = { x, z, vx, vz };
@@ -90,32 +176,41 @@ export function stepDeterministicKcc3D(body, input, world, options) {
90
176
  vz = resolved.vz;
91
177
  correctionCount += resolved.correctionCount;
92
178
  events.push(resolved.swept ? 'physics-sweep' : resolved.slid ? 'physics-slide' : 'physics-correction');
179
+ // The slide moved the footprint, so the height sampled before it belongs
180
+ // to the old x/z. On sloped ground that stale height reads as penetration
181
+ // next frame and freezes movement along walls and ledges — re-sample.
182
+ // Attachment must follow the FINAL footprint. A slide can carry the
183
+ // capsule off the platform it was standing on (leaving it "grounded" in
184
+ // mid-air) or onto one it had missed.
185
+ if (groundedOnDynamic && (x !== pushCandidate.x || z !== pushCandidate.z)) {
186
+ const reattached = dynamicGroundUnderKcc(options.physics, x, y, z, geometry, new Set(options.ignoredPhysicsBodyIds ?? []));
187
+ if (reattached === undefined) {
188
+ grounded = false;
189
+ platformId = undefined;
190
+ groundedOnDynamic = false;
191
+ }
192
+ else {
193
+ y = reattached.topY;
194
+ platformId = reattached.bodyId;
195
+ }
196
+ }
197
+ if (groundedOnStatic && (x !== pushCandidate.x || z !== pushCandidate.z)) {
198
+ const reground = staticGroundUnderKcc(options.physics, x, y, z,
199
+ // The capsule has already landed, so this is a local correction from
200
+ // the landed feet — not the start-of-frame ones. Reusing those would
201
+ // reopen the fall-crossing window and let a slide under an overhang
202
+ // yank the capsule onto its roof.
203
+ y - kccFeetOffset(geometry), geometry, new Set(options.ignoredPhysicsBodyIds ?? []), world.stepHeight);
204
+ if (reground !== undefined) {
205
+ y = reground.topY;
206
+ }
207
+ }
93
208
  }
94
209
  }
95
210
  const geometryForInteraction = normalizeKccGeometry3D(next);
96
211
  let physicsImpulses = [];
97
212
  // Stand on a dynamic body directly beneath the feet: attach as a platform and
98
213
  // inherit its per-frame delta (its velocity), mirroring the kinematic path.
99
- if (options.physics !== undefined && options.standOnDynamicBodies === true && vy <= 0) {
100
- const ground = dynamicGroundUnderKcc(options.physics, x, y, z, geometryForInteraction, new Set(options.ignoredPhysicsBodyIds ?? []));
101
- if (ground !== undefined) {
102
- y = ground.topY;
103
- vy = 0;
104
- grounded = true;
105
- platformId = ground.bodyId;
106
- // Carry the rider with the crate this frame (position delta) AND inherit its
107
- // velocity, mirroring the kinematic moving-platform path — otherwise the
108
- // rider lags one frame and slides off a moving crate.
109
- x += ground.deltaX;
110
- z += ground.deltaZ;
111
- vx += ground.deltaX;
112
- vz += ground.deltaZ;
113
- events.push('stand-on-dynamic');
114
- if (ground.deltaX !== 0 || ground.deltaZ !== 0) {
115
- events.push('platform-velocity-transfer');
116
- }
117
- }
118
- }
119
214
  // Push the dynamic bodies the KCC walked into this frame.
120
215
  if (options.physics !== undefined && (options.maxPushImpulse ?? 0) > 0) {
121
216
  physicsImpulses = computeKccPushImpulses(options.physics, pushCandidate.x, y, pushCandidate.z, { vx: pushCandidate.vx, vz: pushCandidate.vz }, geometryForInteraction, options.maxPushImpulse ?? 0, new Set(options.ignoredPhysicsBodyIds ?? []));
@@ -123,7 +218,10 @@ export function stepDeterministicKcc3D(body, input, world, options) {
123
218
  events.push('push-dynamic');
124
219
  }
125
220
  }
126
- const boundsCorrection = clampBodyToBounds({ x, y, z, vx, vy, vz, grounded, coyoteFrames, jumpBufferFrames, platformId }, world);
221
+ // Spread `next` first: the capsule's radius/halfHeight/skinWidth live on the
222
+ // body, and rebuilding it from a bare literal dropped them, so a caller's
223
+ // capsule silently reverted to the 1x2 default after a single step.
224
+ const boundsCorrection = clampBodyToBounds({ ...next, x, y, z, vx, vy, vz, grounded, coyoteFrames, jumpBufferFrames, platformId }, world);
127
225
  if (boundsCorrection.corrected) {
128
226
  correctionCount += boundsCorrection.corrections;
129
227
  events.push(...boundsCorrection.events);
@@ -131,7 +229,7 @@ export function stepDeterministicKcc3D(body, input, world, options) {
131
229
  next = boundsCorrection.body;
132
230
  const finalBody = {
133
231
  ...next,
134
- coyoteFrames: next.grounded ? 4 : coyoteFrames,
232
+ coyoteFrames: next.grounded ? jump.coyoteFrames : coyoteFrames,
135
233
  jumpBufferFrames,
136
234
  };
137
235
  return {
@@ -247,7 +345,144 @@ function groundContactTopAt(body, x, z) {
247
345
  }
248
346
  return top;
249
347
  }
250
- return undefined; // mesh: static-only, not a stand-on platform
348
+ if (shape.type === 'hull') {
349
+ // Not primitiveHalfExtent: that returns symmetric max(|y|) half-extents, so
350
+ // an asymmetric hull would snap the capsule above its real top and treat
351
+ // the mirrored side of the AABB as walkable. The stand-on height is max(y)
352
+ // of the rotated vertices, and the footprint is their true XZ range.
353
+ const m = quatToMat3(q);
354
+ let minX = Infinity;
355
+ let maxX = -Infinity;
356
+ let minZ = Infinity;
357
+ let maxZ = -Infinity;
358
+ let maxY = -Infinity;
359
+ for (const vertex of shape.vertices) {
360
+ const rotated = mulMat3Vec(m, vertex);
361
+ minX = Math.min(minX, rotated.x);
362
+ maxX = Math.max(maxX, rotated.x);
363
+ minZ = Math.min(minZ, rotated.z);
364
+ maxZ = Math.max(maxZ, rotated.z);
365
+ maxY = Math.max(maxY, rotated.y);
366
+ }
367
+ const localX = x - body.x;
368
+ const localZ = z - body.z;
369
+ if (localX < minX || localX > maxX || localZ < minZ || localZ > maxZ) {
370
+ return undefined;
371
+ }
372
+ return body.y + maxY;
373
+ }
374
+ if (shape.type === 'heightfield') {
375
+ // Heightfields are static and never rotate (the solver maps them to a mesh
376
+ // in body-local space), so local == world minus the body position.
377
+ const localY = heightfieldLocalSurfaceY(shape, x - body.x, z - body.z);
378
+ return localY === undefined ? undefined : body.y + localY;
379
+ }
380
+ return undefined; // mesh: arbitrary triangle soup, not a stand-on platform
381
+ }
382
+ /**
383
+ * Highest static surface under the capsule within `snapDistance` of its feet.
384
+ *
385
+ * The overlap probe only narrows candidates (it rides the world's static query
386
+ * tree); the surface height itself comes from groundContactTopAt, so a capsule
387
+ * lands on the same triangle a projectile would hit.
388
+ */
389
+ /**
390
+ * Separation held between a grounded capsule and the surface under it, so
391
+ * resting contact never reads as penetration. Fixed rather than derived because
392
+ * skinWidth defaults to 0; a plain double is bit-identical on every peer.
393
+ */
394
+ const KCC_GROUND_CLEARANCE = 1e-4;
395
+ /**
396
+ * Extra vertical lift a round capsule bottom needs to rest ON a sloped surface
397
+ * instead of IN it.
398
+ *
399
+ * groundContactTopAt reports the height under the capsule CENTRE, but the
400
+ * bottom is a sphere, so on gradient `g` the contact sits uphill and a bare
401
+ * radius of lift buries the uphill flank by `radius * (sec θ - 1)`. Past ~1.7°
402
+ * that exceeds KCC_GROUND_CLEARANCE, every overlap probe (full, x-only AND
403
+ * z-only) hits, and resolvePhysicsSlide3D reverts the whole horizontal move.
404
+ *
405
+ * Sampled a bottom-sphere radius out: that is the footprint the sphere can
406
+ * touch, it reads a plane exactly at any angle, and it self-bounds the
407
+ * discontinuities groundContactTopAt has on some shapes (a compound's children
408
+ * can butt together at a height jump) — a jump of D reads as D/radius here
409
+ * instead of the skyward fling an epsilon probe would give. Per axis it takes
410
+ * the steeper of forward/backward so a sample straddling an edge cannot
411
+ * average its way to an under-lift and back into the freeze.
412
+ */
413
+ function kccSlopeClearance(body, x, z, top, geometry) {
414
+ const step = geometry.radius + geometry.skinWidth;
415
+ const axisGradient = (deltaX, deltaZ) => {
416
+ const ahead = groundContactTopAt(body, x + deltaX, z + deltaZ);
417
+ const behind = groundContactTopAt(body, x - deltaX, z - deltaZ);
418
+ const forward = ahead === undefined ? 0 : Math.abs(ahead - top) / step;
419
+ const backward = behind === undefined ? 0 : Math.abs(top - behind) / step;
420
+ return Math.max(forward, backward);
421
+ };
422
+ const gradientX = axisGradient(step, 0);
423
+ const gradientZ = axisGradient(0, step);
424
+ const gradientSq = gradientX * gradientX + gradientZ * gradientZ;
425
+ if (gradientSq <= 0) {
426
+ return 0;
427
+ }
428
+ // radius + skinWidth is the bottom-sphere radius physicsOverlapsKcc probes with.
429
+ return (geometry.radius + geometry.skinWidth) * (solverSqrt(1 + gradientSq) - 1);
430
+ }
431
+ /**
432
+ * Distance from the capsule's centre to the bottom of its collision shape.
433
+ * A capsule's vertical extent is halfHeight + radius (see primitiveHalfExtent),
434
+ * so grounding on `halfHeight` alone buries it by a radius and the horizontal
435
+ * slide then fights the penetration every frame — the capsule cannot walk.
436
+ * skinWidth keeps a gap so resting contact is not treated as overlap.
437
+ */
438
+ function kccFeetOffset(geometry) {
439
+ // physicsOverlapsKcc probes with radius+skinWidth AND halfHeight+skinWidth,
440
+ // and counts exact touching as an overlap — at which point the slide reverts
441
+ // the whole horizontal move, so a capsule resting flush on terrain cannot
442
+ // walk. Rest one clearance above that probe instead of on the surface.
443
+ return geometry.halfHeight + geometry.radius + 2 * geometry.skinWidth + KCC_GROUND_CLEARANCE;
444
+ }
445
+ function staticGroundUnderKcc(physics, x, y, z, previousFeetY, geometry, ignoredBodyIds, snapDistance) {
446
+ const feetY = y - kccFeetOffset(geometry);
447
+ // Gravity is unbounded (vy -= 1 per frame), so a fast fall can clear the whole
448
+ // snap band in one step. Accept a surface the feet CROSSED this frame, not
449
+ // just one they ended near — otherwise terrain tunnels at terminal velocity.
450
+ // Requiring the frame to START at or above the surface is what keeps a capsule
451
+ // walking beneath a floating static box from being yanked onto its roof.
452
+ const fallDistance = Math.max(0, previousFeetY - feetY);
453
+ const reach = snapDistance + fallDistance;
454
+ const probe = {
455
+ shape: { type: 'sphere', radius: geometry.radius + reach },
456
+ x,
457
+ y: feetY,
458
+ z,
459
+ hitStatics: true,
460
+ hitDynamics: false,
461
+ hitKinematics: false,
462
+ };
463
+ 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))
466
+ .map((body) => ({ body, top: groundContactTopAt(body, x, z) }))
467
+ .filter((candidate) => candidate.top !== undefined)
468
+ .map((candidate) => ({
469
+ ...candidate,
470
+ lift: kccSlopeClearance(candidate.body, x, z, candidate.top, geometry),
471
+ }))
472
+ // The band is measured from where the capsule actually STANDS, so a slope
473
+ // lift bigger than snapDistance cannot drop it out of its own ground the
474
+ // next frame and leave it cycling between grounded and falling.
475
+ .filter((candidate) => feetY <= candidate.top + candidate.lift + snapDistance
476
+ && previousFeetY >= candidate.top - snapDistance)
477
+ .sort((left, right) => right.top - left.top || compareStrings(left.body.id, right.body.id));
478
+ const ground = grounds[0];
479
+ if (ground === undefined) {
480
+ return undefined;
481
+ }
482
+ return {
483
+ bodyId: ground.body.id,
484
+ topY: ground.top + kccFeetOffset(geometry) + ground.lift,
485
+ };
251
486
  }
252
487
  /** World half-extents (x,y,z) of a primitive shape under rotation matrix `m`. */
253
488
  function primitiveHalfExtent(shape, m) {
@@ -283,7 +518,7 @@ function primitiveHalfExtent(shape, m) {
283
518
  }
284
519
  /** A dynamic body directly under the KCC feet, with its per-frame delta. */
285
520
  function dynamicGroundUnderKcc(physics, x, y, z, geometry, ignoredBodyIds) {
286
- const feetY = y - geometry.halfHeight - geometry.skinWidth;
521
+ const feetY = y - kccFeetOffset(geometry);
287
522
  const probe = {
288
523
  shape: { type: 'sphere', radius: geometry.radius },
289
524
  x,
@@ -308,7 +543,7 @@ function dynamicGroundUnderKcc(physics, x, y, z, geometry, ignoredBodyIds) {
308
543
  }
309
544
  return {
310
545
  bodyId: ground.body.id,
311
- topY: ground.top + geometry.halfHeight + geometry.skinWidth,
546
+ topY: ground.top + kccFeetOffset(geometry),
312
547
  deltaX: ground.body.vx,
313
548
  deltaZ: ground.body.vz,
314
549
  };
@@ -14,4 +14,14 @@ export interface HeightfieldBounds3D {
14
14
  readonly maxZ: number;
15
15
  }
16
16
  export declare function heightfieldLocalBounds(shape: HeightfieldShapeData3D): HeightfieldBounds3D;
17
+ /**
18
+ * Local surface height at a local (x, z), or undefined outside the field.
19
+ *
20
+ * MUST stay consistent with heightfieldLocalTriangles' cell split: a cell emits
21
+ * [a,b,c] and [d,c,b] with a=(col,row) b=(col,row+1) c=(col+1,row)
22
+ * d=(col+1,row+1), so the diagonal runs b–c and the first triangle is the
23
+ * u+v<=1 half. Sampling a different diagonal would let a character stand where
24
+ * a projectile passes through.
25
+ */
26
+ export declare function heightfieldLocalSurfaceY(shape: HeightfieldShapeData3D, localX: number, localZ: number): number | undefined;
17
27
  export declare function heightfieldLocalTriangles(shape: HeightfieldShapeData3D, bounds?: HeightfieldBounds3D): readonly (readonly [Point3D, Point3D, Point3D])[];
@@ -21,6 +21,39 @@ function vertex(shape, column, row) {
21
21
  z: row * shape.scale.z,
22
22
  };
23
23
  }
24
+ function heightAt(shape, column, row) {
25
+ return shape.heights[row * shape.columns + column] * shape.scale.y;
26
+ }
27
+ /**
28
+ * Local surface height at a local (x, z), or undefined outside the field.
29
+ *
30
+ * MUST stay consistent with heightfieldLocalTriangles' cell split: a cell emits
31
+ * [a,b,c] and [d,c,b] with a=(col,row) b=(col,row+1) c=(col+1,row)
32
+ * d=(col+1,row+1), so the diagonal runs b–c and the first triangle is the
33
+ * u+v<=1 half. Sampling a different diagonal would let a character stand where
34
+ * a projectile passes through.
35
+ */
36
+ export function heightfieldLocalSurfaceY(shape, localX, localZ) {
37
+ const maxX = (shape.columns - 1) * shape.scale.x;
38
+ const maxZ = (shape.rows - 1) * shape.scale.z;
39
+ if (localX < 0 || localX > maxX || localZ < 0 || localZ > maxZ) {
40
+ return undefined;
41
+ }
42
+ const columnFloat = localX / shape.scale.x;
43
+ const rowFloat = localZ / shape.scale.z;
44
+ const column = Math.min(shape.columns - 2, Math.floor(columnFloat));
45
+ const row = Math.min(shape.rows - 2, Math.floor(rowFloat));
46
+ const u = columnFloat - column;
47
+ const v = rowFloat - row;
48
+ const a = heightAt(shape, column, row);
49
+ const b = heightAt(shape, column, row + 1);
50
+ const c = heightAt(shape, column + 1, row);
51
+ if (u + v <= 1) {
52
+ return a + (b - a) * v + (c - a) * u;
53
+ }
54
+ const d = heightAt(shape, column + 1, row + 1);
55
+ return d + (c - d) * (1 - v) + (b - d) * (1 - u);
56
+ }
24
57
  export function heightfieldLocalTriangles(shape, bounds) {
25
58
  const minColumn = bounds === undefined ? 0 : Math.max(0, Math.floor(bounds.minX / shape.scale.x));
26
59
  const maxColumn = bounds === undefined
@@ -51,3 +51,17 @@ export declare function closestPointOnHullLocal(point: Point3D, hull: SolverHull
51
51
  readonly normal: Point3D;
52
52
  readonly distance: number;
53
53
  };
54
+ /**
55
+ * Vertices for a cylinder approximated as a regular prism, for trunks, stumps,
56
+ * logs, pillars and barrels.
57
+ *
58
+ * There is no cylinder primitive in Kinetix, the TypeScript solver, or the
59
+ * Box3D parity set — a cylinder is a convex hull, and hulls are at parity. The
60
+ * TypeScript hull builder accepts 4-16 vertices, so `sides` tops out at 8 (an
61
+ * octagonal prism, exactly 16). A capsule is cheaper but its rounded caps let a
62
+ * character slide off a flat top and change how a body rolls off an edge.
63
+ *
64
+ * Call this when building the world, not inside the step: the vertices are
65
+ * constant for a given (radius, halfHeight, sides).
66
+ */
67
+ export declare function cylinderHullVertices(radius: number, halfHeight: number, sides?: number): readonly Point3D[];
@@ -10,6 +10,7 @@
10
10
  * Numeric regime: add/mul/cmp plus `solverSqrt` for the final unit normals only.
11
11
  * No transcendentals (banned in src/ by the static determinism checker).
12
12
  */
13
+ import { createDeterministicMath } from './math.js';
13
14
  import { addVec3, crossVec3, dotVec3, scaleVec3, solverSqrt, subVec3, } from './physics3d-shared.js';
14
15
  const PLANE_TOL = 1e-9;
15
16
  const COPLANAR_TOL = 1e-6;
@@ -374,3 +375,47 @@ function closestPointOnSegmentLocal(point, a, b) {
374
375
  t = t < 0 ? 0 : t > 1 ? 1 : t;
375
376
  return addVec3(a, scaleVec3(ab, t));
376
377
  }
378
+ /**
379
+ * Native Math.cos/Math.sin are not bit-identical across JS engines, and these
380
+ * vertices become checksummed collision geometry — two peers building the same
381
+ * trunk must agree exactly. sinTurns/cosTurns are pure integer arithmetic.
382
+ * 840 is divisible by every allowed side count, so each vertex lands on an
383
+ * exact turn boundary.
384
+ */
385
+ const PRISM_TURN_SCALE = 840_000;
386
+ const prismMath = createDeterministicMath(PRISM_TURN_SCALE);
387
+ /**
388
+ * Vertices for a cylinder approximated as a regular prism, for trunks, stumps,
389
+ * logs, pillars and barrels.
390
+ *
391
+ * There is no cylinder primitive in Kinetix, the TypeScript solver, or the
392
+ * Box3D parity set — a cylinder is a convex hull, and hulls are at parity. The
393
+ * TypeScript hull builder accepts 4-16 vertices, so `sides` tops out at 8 (an
394
+ * octagonal prism, exactly 16). A capsule is cheaper but its rounded caps let a
395
+ * character slide off a flat top and change how a body rolls off an edge.
396
+ *
397
+ * Call this when building the world, not inside the step: the vertices are
398
+ * constant for a given (radius, halfHeight, sides).
399
+ */
400
+ export function cylinderHullVertices(radius, halfHeight, sides = 8) {
401
+ if (!Number.isFinite(radius) || radius <= 0) {
402
+ throw new Error(`cylinder hull radius must be a finite positive number, received ${radius}`);
403
+ }
404
+ if (!Number.isFinite(halfHeight) || halfHeight <= 0) {
405
+ throw new Error(`cylinder hull halfHeight must be a finite positive number, received ${halfHeight}`);
406
+ }
407
+ if (!Number.isInteger(sides) || sides < 3 || sides > 8) {
408
+ throw new Error(`cylinder hull sides must be an integer in 3..8 (2 x sides must fit the 4-16 vertex hull limit), received ${sides}`);
409
+ }
410
+ const vertices = [];
411
+ for (let index = 0; index < sides; index += 1) {
412
+ // Turns, not radians: PRISM_TURN_SCALE is divisible by every allowed side
413
+ // count, so this is an exact integer and never rounds.
414
+ const turns = (PRISM_TURN_SCALE * index) / sides;
415
+ const x = (radius * prismMath.cosTurns(turns)) / PRISM_TURN_SCALE;
416
+ const z = (radius * prismMath.sinTurns(turns)) / PRISM_TURN_SCALE;
417
+ vertices.push({ x, y: halfHeight, z });
418
+ vertices.push({ x, y: -halfHeight, z });
419
+ }
420
+ return vertices;
421
+ }