From 9229c78de2fac78a37348ee5b47810ac022b5edf Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 30 Apr 2026 00:15:42 +0200 Subject: [PATCH] feat: Implement core player climbing logic and surface detection --- src/runtime-three/player-climbing.ts | 364 +++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/runtime-three/player-climbing.ts diff --git a/src/runtime-three/player-climbing.ts b/src/runtime-three/player-climbing.ts new file mode 100644 index 00000000..5a6a1df5 --- /dev/null +++ b/src/runtime-three/player-climbing.ts @@ -0,0 +1,364 @@ +import { Euler, MathUtils, Quaternion, Vector3 } from "three"; + +import type { Vec3 } from "../core/vector"; + +import type { PlayerStartActionInputState } from "./player-input-bindings"; +import { + getFirstPersonPlayerEyeHeight, + type FirstPersonPlayerShape +} from "./player-collision"; +import type { + RuntimeBrushColliderFace, + RuntimeBrushTriMeshCollider, + RuntimeSceneDefinition +} from "./runtime-scene-build"; + +export const CLIMB_INPUT_ACTIVE_THRESHOLD = 0.5; +export const CLIMB_SPEED_METERS_PER_SECOND = 2.4; +export const CLIMB_DETECT_DISTANCE_METERS = 0.85; +export const CLIMB_KEEP_DISTANCE_METERS = 1.05; +export const CLIMB_WALL_MAX_ABS_NORMAL_Y = 0.35; +const CLIMB_MIN_FACING_DOT = 0.35; +const VECTOR_EPSILON = 1e-6; + +export interface RuntimePlayerClimbSurface { + brushId: string; + faceId: string; + point: Vec3; + normal: Vec3; + distance: number; +} + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function addVec3(left: Vec3, right: Vec3): Vec3 { + return { + x: left.x + right.x, + y: left.y + right.y, + z: left.z + right.z + }; +} + +function subtractVec3(left: Vec3, right: Vec3): Vec3 { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z + }; +} + +function scaleVec3(vector: Vec3, scalar: number): Vec3 { + return { + x: vector.x * scalar, + y: vector.y * scalar, + z: vector.z * scalar + }; +} + +function dotVec3(left: Vec3, right: Vec3): number { + return left.x * right.x + left.y * right.y + left.z * right.z; +} + +function crossVec3(left: Vec3, right: Vec3): Vec3 { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x + }; +} + +function lengthVec3(vector: Vec3): number { + return Math.sqrt(dotVec3(vector, vector)); +} + +function normalizeVec3(vector: Vec3): Vec3 | null { + const length = lengthVec3(vector); + + if (length <= VECTOR_EPSILON) { + return null; + } + + return scaleVec3(vector, 1 / length); +} + +function projectOntoPlane(vector: Vec3, normal: Vec3): Vec3 { + return subtractVec3(vector, scaleVec3(normal, dotVec3(vector, normal))); +} + +function rotateVec3(vector: Vec3, rotationDegrees: Vec3): Vec3 { + const rotated = new Vector3(vector.x, vector.y, vector.z).applyQuaternion( + new Quaternion().setFromEuler( + new Euler( + MathUtils.degToRad(rotationDegrees.x), + MathUtils.degToRad(rotationDegrees.y), + MathUtils.degToRad(rotationDegrees.z), + "XYZ" + ) + ) + ); + + return { + x: rotated.x, + y: rotated.y, + z: rotated.z + }; +} + +function transformLocalPointToWorld( + point: Vec3, + collider: RuntimeBrushTriMeshCollider +): Vec3 { + return addVec3(rotateVec3(point, collider.rotationDegrees), collider.center); +} + +function transformLocalVectorToWorld( + vector: Vec3, + collider: RuntimeBrushTriMeshCollider +): Vec3 { + return normalizeVec3(rotateVec3(vector, collider.rotationDegrees)) ?? { + x: 0, + y: 0, + z: 1 + }; +} + +function resolveClimbProbeOrigin( + feetPosition: Vec3, + shape: FirstPersonPlayerShape +): Vec3 { + return { + x: feetPosition.x, + y: feetPosition.y + getFirstPersonPlayerEyeHeight(shape) * 0.55, + z: feetPosition.z + }; +} + +function isPointInTriangle(point: Vec3, a: Vec3, b: Vec3, c: Vec3): boolean { + const v0 = subtractVec3(c, a); + const v1 = subtractVec3(b, a); + const v2 = subtractVec3(point, a); + const dot00 = dotVec3(v0, v0); + const dot01 = dotVec3(v0, v1); + const dot02 = dotVec3(v0, v2); + const dot11 = dotVec3(v1, v1); + const dot12 = dotVec3(v1, v2); + const denominator = dot00 * dot11 - dot01 * dot01; + + if (Math.abs(denominator) <= VECTOR_EPSILON) { + return false; + } + + const inverseDenominator = 1 / denominator; + const u = (dot11 * dot02 - dot01 * dot12) * inverseDenominator; + const v = (dot00 * dot12 - dot01 * dot02) * inverseDenominator; + + return u >= -VECTOR_EPSILON && v >= -VECTOR_EPSILON && u + v <= 1 + VECTOR_EPSILON; +} + +function isPointOnFace( + point: Vec3, + face: RuntimeBrushColliderFace, + worldVertices: Vec3[] +): boolean { + return face.triangles.some((triangle) => + isPointInTriangle( + point, + worldVertices[triangle[0]], + worldVertices[triangle[1]], + worldVertices[triangle[2]] + ) + ); +} + +export function isClimbableWallNormal(normal: Vec3): boolean { + const normalizedNormal = normalizeVec3(normal); + + return ( + normalizedNormal !== null && + Math.abs(normalizedNormal.y) <= CLIMB_WALL_MAX_ABS_NORMAL_Y + ); +} + +export function shouldEnterClimbing(options: { + climbInput: number; + surface: RuntimePlayerClimbSurface | null; + jumpPressed: boolean; +}): boolean { + return ( + options.climbInput > CLIMB_INPUT_ACTIVE_THRESHOLD && + options.surface !== null && + !options.jumpPressed + ); +} + +export function shouldExitClimbing(options: { + climbInput: number; + surface: RuntimePlayerClimbSurface | null; + jumpPressed: boolean; +}): boolean { + return ( + options.climbInput <= CLIMB_INPUT_ACTIVE_THRESHOLD || + options.surface === null || + options.jumpPressed + ); +} + +export function computeClimbPlaneMovement(options: { + normal: Vec3; + input: PlayerStartActionInputState; + speedMetersPerSecond?: number; + dt: number; +}): { motion: Vec3; inputMagnitude: number } { + const normal = normalizeVec3(options.normal); + + if (normal === null || options.dt <= 0) { + return { + motion: { + x: 0, + y: 0, + z: 0 + }, + inputMagnitude: 0 + }; + } + + const up = + normalizeVec3(projectOntoPlane({ x: 0, y: 1, z: 0 }, normal)) ?? { + x: 0, + y: 1, + z: 0 + }; + const right = + normalizeVec3(crossVec3(up, normal)) ?? { + x: 1, + y: 0, + z: 0 + }; + const inputX = options.input.moveRight - options.input.moveLeft; + const inputY = options.input.moveForward - options.input.moveBackward; + const rawMagnitude = Math.hypot(inputX, inputY); + const inputMagnitude = Math.min(1, rawMagnitude); + + if (rawMagnitude <= VECTOR_EPSILON) { + return { + motion: { + x: 0, + y: 0, + z: 0 + }, + inputMagnitude + }; + } + + const speed = options.speedMetersPerSecond ?? CLIMB_SPEED_METERS_PER_SECOND; + const distance = Math.max(0, speed) * options.dt; + const normalizedX = inputX / rawMagnitude; + const normalizedY = inputY / rawMagnitude; + const direction = addVec3( + scaleVec3(right, normalizedX), + scaleVec3(up, normalizedY) + ); + + return { + motion: scaleVec3(direction, distance), + inputMagnitude + }; +} + +export function resolvePlayerClimbSurface(options: { + runtimeScene: RuntimeSceneDefinition; + feetPosition: Vec3; + facingDirection: Vec3; + shape: FirstPersonPlayerShape; + previousSurface?: RuntimePlayerClimbSurface | null; +}): RuntimePlayerClimbSurface | null { + const rayDirection = normalizeVec3(options.facingDirection); + + if (rayDirection === null || options.shape.mode === "none") { + return null; + } + + const probeOrigin = resolveClimbProbeOrigin( + options.feetPosition, + options.shape + ); + const maxDistance = + options.previousSurface === null || options.previousSurface === undefined + ? CLIMB_DETECT_DISTANCE_METERS + : CLIMB_KEEP_DISTANCE_METERS; + let bestSurface: RuntimePlayerClimbSurface | null = null; + + for (const collider of options.runtimeScene.staticColliders) { + if (collider.source !== "brush") { + continue; + } + + for (const face of collider.faces) { + if (!face.climbable) { + continue; + } + + const normal = transformLocalVectorToWorld(face.normal, collider); + + if (!isClimbableWallNormal(normal)) { + continue; + } + + const facingDot = dotVec3(rayDirection, normal); + + if (facingDot >= -CLIMB_MIN_FACING_DOT) { + continue; + } + + const worldVertices = face.vertices.map((vertex) => + transformLocalPointToWorld(vertex, collider) + ); + const planePoint = worldVertices[0]; + const distance = + dotVec3(subtractVec3(planePoint, probeOrigin), normal) / facingDot; + + if (distance < 0 || distance > maxDistance) { + continue; + } + + const point = addVec3(probeOrigin, scaleVec3(rayDirection, distance)); + + if (!isPointOnFace(point, face, worldVertices)) { + continue; + } + + const candidate: RuntimePlayerClimbSurface = { + brushId: collider.brushId, + faceId: face.faceId, + point, + normal, + distance + }; + + if ( + bestSurface === null || + candidate.distance < bestSurface.distance || + (options.previousSurface !== null && + options.previousSurface !== undefined && + candidate.brushId === options.previousSurface.brushId && + candidate.faceId === options.previousSurface.faceId) + ) { + bestSurface = candidate; + } + } + } + + return bestSurface === null + ? null + : { + ...bestSurface, + point: cloneVec3(bestSurface.point), + normal: cloneVec3(bestSurface.normal) + }; +}