diff --git a/src/runtime-three/player-edge-assist.ts b/src/runtime-three/player-edge-assist.ts index 895920c9..36149add 100644 --- a/src/runtime-three/player-edge-assist.ts +++ b/src/runtime-three/player-edge-assist.ts @@ -10,6 +10,11 @@ const EDGE_ASSIST_FORWARD_STEPS = 3; const EDGE_ASSIST_VERTICAL_STEPS = 6; const EDGE_ASSIST_MIN_TOP_OUT_HEIGHT_METERS = 0.04; const EDGE_ASSIST_GROUND_PROBE_DISTANCE_METERS = 0.35; +const LEDGE_GRAB_FORWARD_PADDING_METERS = 0.18; +const LEDGE_GRAB_FORWARD_STEPS = 4; +const LEDGE_GRAB_VERTICAL_STEPS = 8; +const LEDGE_GRAB_HEAD_REACH_PADDING_METERS = 0.18; +const LEDGE_GRAB_HANG_DROP_METERS = 1.05; const VECTOR_EPSILON = 1e-6; export interface RuntimePlayerEdgeAssistResult { @@ -18,6 +23,15 @@ export interface RuntimePlayerEdgeAssistResult { forwardDistance: number; } +export interface RuntimePlayerLedgeGrabTarget { + hangFeetPosition: Vec3; + standFeetPosition: Vec3; + direction: Vec3; + topOutHeight: number; + forwardDistance: number; + topSurfaceY: number; +} + function dotVec3(left: Vec3, right: Vec3): number { return left.x * right.x + left.y * right.y + left.z * right.z; } @@ -56,6 +70,44 @@ function getPlayerShapeHorizontalRadius(shape: FirstPersonPlayerShape): number { } } +function getPlayerShapeUpperReachHeight(shape: FirstPersonPlayerShape): number { + switch (shape.mode) { + case "capsule": + return Math.max( + shape.eyeHeight + LEDGE_GRAB_HEAD_REACH_PADDING_METERS, + shape.height - shape.radius * 0.35 + ); + case "box": + return Math.max( + shape.eyeHeight + LEDGE_GRAB_HEAD_REACH_PADDING_METERS, + shape.size.y - 0.1 + ); + case "none": + return 0; + } +} + +function resolveHangFeetPosition(options: { + feetPosition: Vec3; + standFeetPosition: Vec3; + shape: FirstPersonPlayerShape; + canOccupyShape(feetPosition: Vec3, shape: FirstPersonPlayerShape): boolean; +}): Vec3 | null { + const hangFeetY = Math.max( + options.feetPosition.y, + options.standFeetPosition.y - LEDGE_GRAB_HANG_DROP_METERS + ); + const hangFeetPosition = { + x: options.feetPosition.x, + y: hangFeetY, + z: options.feetPosition.z + }; + + return options.canOccupyShape(hangFeetPosition, options.shape) + ? hangFeetPosition + : null; +} + export function shouldAttemptPlayerEdgeAssist(options: { enabled: boolean; pushToTopHeight: number; @@ -169,3 +221,122 @@ export function resolvePlayerEdgeAssistTopOut(options: { return null; } + +export function resolvePlayerLedgeGrabTarget(options: { + feetPosition: Vec3; + shape: FirstPersonPlayerShape; + direction: Vec3; + pushToTopHeight: number; + canOccupyShape(feetPosition: Vec3, shape: FirstPersonPlayerShape): boolean; + probeGround( + feetPosition: Vec3, + shape: FirstPersonPlayerShape, + maxDistance: number + ): PlayerGroundProbeResult; +}): RuntimePlayerLedgeGrabTarget | null { + if ( + options.shape.mode === "none" || + options.pushToTopHeight < EDGE_ASSIST_MIN_TOP_OUT_HEIGHT_METERS + ) { + return null; + } + + const direction = normalizePlanarDirection(options.direction); + + if (direction === null) { + return null; + } + + const minLedgeHeight = + options.pushToTopHeight + EDGE_ASSIST_MIN_TOP_OUT_HEIGHT_METERS; + const maxLedgeHeight = getPlayerShapeUpperReachHeight(options.shape); + + if (maxLedgeHeight <= minLedgeHeight + VECTOR_EPSILON) { + return null; + } + + const horizontalRadius = getPlayerShapeHorizontalRadius(options.shape); + const maxForwardDistance = + horizontalRadius + LEDGE_GRAB_FORWARD_PADDING_METERS; + const maxGroundProbeDistance = Math.max( + EDGE_ASSIST_GROUND_PROBE_DISTANCE_METERS, + (maxLedgeHeight - minLedgeHeight) / LEDGE_GRAB_VERTICAL_STEPS + + EDGE_ASSIST_MIN_TOP_OUT_HEIGHT_METERS + ); + + for ( + let verticalStep = 1; + verticalStep <= LEDGE_GRAB_VERTICAL_STEPS; + verticalStep += 1 + ) { + const lift = + minLedgeHeight + + ((maxLedgeHeight - minLedgeHeight) * verticalStep) / + LEDGE_GRAB_VERTICAL_STEPS; + + for ( + let forwardStep = 1; + forwardStep <= LEDGE_GRAB_FORWARD_STEPS; + forwardStep += 1 + ) { + const forwardDistance = + (maxForwardDistance * forwardStep) / LEDGE_GRAB_FORWARD_STEPS; + const raisedCandidate = { + x: options.feetPosition.x + direction.x * forwardDistance, + y: options.feetPosition.y + lift, + z: options.feetPosition.z + direction.z * forwardDistance + }; + + if (!options.canOccupyShape(raisedCandidate, options.shape)) { + continue; + } + + const ground = options.probeGround( + raisedCandidate, + options.shape, + maxGroundProbeDistance + ); + + if (!ground.grounded || ground.distance === null) { + continue; + } + + const standFeetPosition = { + x: raisedCandidate.x, + y: raisedCandidate.y - ground.distance, + z: raisedCandidate.z + }; + const topOutHeight = standFeetPosition.y - options.feetPosition.y; + + if ( + topOutHeight <= minLedgeHeight + VECTOR_EPSILON || + topOutHeight > maxLedgeHeight + VECTOR_EPSILON || + !options.canOccupyShape(standFeetPosition, options.shape) + ) { + continue; + } + + const hangFeetPosition = resolveHangFeetPosition({ + feetPosition: options.feetPosition, + standFeetPosition, + shape: options.shape, + canOccupyShape: options.canOccupyShape + }); + + if (hangFeetPosition === null) { + continue; + } + + return { + hangFeetPosition, + standFeetPosition, + direction, + topOutHeight, + forwardDistance, + topSurfaceY: standFeetPosition.y + }; + } + } + + return null; +}