From 74df44c7c5c535fae1de88e9df4311fe9b5dddd0 Mon Sep 17 00:00:00 2001 From: misterbubb Date: Tue, 20 Jan 2026 17:31:58 -0600 Subject: [PATCH 1/3] Add VR ladder climbing interaction with grip-based physics --- Source/Config.cs | 3 + Source/Physics/Interactions/Ladder.cs | 308 ++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 Source/Physics/Interactions/Ladder.cs diff --git a/Source/Config.cs b/Source/Config.cs index 2c809f0..32ec983 100644 --- a/Source/Config.cs +++ b/Source/Config.cs @@ -198,6 +198,9 @@ public class Config(string assemblyPath, ConfigFile file) public ConfigEntry DisableElevatorButtonInteraction { get; } = file.Bind("Interaction", "DisableElevatorButtonInteraction", false, "Disables needing to physically press the elevator buttons"); + public ConfigEntry DisableLadderClimbingInteraction { get; } = file.Bind("Interaction", + "DisableLadderClimbingInteraction", false, "Disables needing to physically climb ladders by gripping and pulling"); + // Car interaction configuration public ConfigEntry DisableCarSteeringWheelInteraction { get; } = file.Bind("Car", diff --git a/Source/Physics/Interactions/Ladder.cs b/Source/Physics/Interactions/Ladder.cs new file mode 100644 index 0000000..15e0940 --- /dev/null +++ b/Source/Physics/Interactions/Ladder.cs @@ -0,0 +1,308 @@ +using HarmonyLib; +using LCVR.Assets; +using LCVR.Managers; +using LCVR.Patches; +using LCVR.Player; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace LCVR.Physics.Interactions; + +public class VRLadder : MonoBehaviour, VRInteractable +{ + private InteractTrigger ladderTrigger; + + private Vector3? leftHandGripPoint; + private Vector3? rightHandGripPoint; + + private VRInteractor leftHandInteractor; + private VRInteractor rightHandInteractor; + + private bool isActiveLadder; + private float climbStartTime; + + private const float MAX_CLIMB_SPEED = 3.0f; + private const float CLIMB_STRENGTH = 1.0f; + + public InteractableFlags Flags => InteractableFlags.BothHands; + + private void Awake() + { + ladderTrigger = GetComponentInParent(); + } + + private void Update() + { + if (VRSession.Instance?.LocalPlayer?.PlayerController == null) + return; + + var player = VRSession.Instance.LocalPlayer.PlayerController; + + if (!player.isClimbingLadder || !isActiveLadder) + return; + + Vector3 totalMovement = Vector3.zero; + int grippingHands = 0; + + if (leftHandGripPoint.HasValue) + { + var leftHand = VRSession.Instance.LocalPlayer.LeftHandVRTarget; + var worldGripPoint = transform.TransformPoint(leftHandGripPoint.Value); + Vector3 pullVector = worldGripPoint - leftHand.position; + totalMovement += pullVector; + grippingHands++; + } + + if (rightHandGripPoint.HasValue) + { + var rightHand = VRSession.Instance.LocalPlayer.RightHandVRTarget; + var worldGripPoint = transform.TransformPoint(rightHandGripPoint.Value); + Vector3 pullVector = worldGripPoint - rightHand.position; + totalMovement += pullVector; + grippingHands++; + } + + if (grippingHands > 1) + totalMovement /= grippingHands; + + totalMovement *= CLIMB_STRENGTH; + totalMovement.x = 0; + totalMovement.z = 0; + + float maxMovementThisFrame = MAX_CLIMB_SPEED * Time.deltaTime; + if (Mathf.Abs(totalMovement.y) > maxMovementThisFrame) + totalMovement.y = Mathf.Sign(totalMovement.y) * maxMovementThisFrame; + + if (Mathf.Abs(totalMovement.y) > 0.001f) + { + player.thisPlayerBody.position += totalMovement; + } + + if (Time.time - climbStartTime >= 0.5f && ladderTrigger.topOfLadderPosition != null) + { + var topY = ladderTrigger.topOfLadderPosition.position.y; + var playerHeadY = player.gameplayCamera.transform.position.y; + + if (playerHeadY >= topY - 0.3f) + { + Vector3 exitPosition; + + if (ladderTrigger.useRaycastToGetTopPosition) + { + var rayStart = player.transform.position + Vector3.up * 0.5f; + var rayEnd = ladderTrigger.topOfLadderPosition.position + Vector3.up * 0.5f; + + if (UnityEngine.Physics.Linecast(rayStart, rayEnd, out RaycastHit hit, + StartOfRound.Instance.collidersAndRoomMaskAndDefault, + QueryTriggerInteraction.Ignore)) + { + exitPosition = hit.point; + } + else + { + exitPosition = ladderTrigger.topOfLadderPosition.position; + } + } + else + { + exitPosition = ladderTrigger.topOfLadderPosition.position; + } + + ExitLadder(player, exitPosition); + } + } + } + + private void ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector3 exitPosition) + { + leftHandGripPoint = null; + rightHandGripPoint = null; + + if (leftHandInteractor != null) + { + leftHandInteractor.FingerCurler.ForceFist(false); + leftHandInteractor.isHeld = false; + leftHandInteractor = null; + } + if (rightHandInteractor != null) + { + rightHandInteractor.FingerCurler.ForceFist(false); + rightHandInteractor.isHeld = false; + rightHandInteractor = null; + } + + isActiveLadder = false; + + player.isClimbingLadder = false; + player.thisController.enabled = true; + player.inSpecialInteractAnimation = false; + player.UpdateSpecialAnimationValue(false, 0, 0f, false); + + player.takingFallDamage = false; + player.fallValue = 0f; + player.fallValueUncapped = 0f; + + player.TeleportPlayer(exitPosition, false, 0f, false, true); + + ladderTrigger.usingLadder = false; + ladderTrigger.isPlayingSpecialAnimation = false; + ladderTrigger.lockedPlayer = null; + } + + public bool OnButtonPress(VRInteractor interactor) + { + var player = VRSession.Instance.LocalPlayer.PlayerController; + + // Store grip point in ladder's local space + if (interactor.IsRightHand) + { + rightHandGripPoint = transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.RightHandVRTarget.position); + rightHandInteractor = interactor; + } + else + { + leftHandGripPoint = transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.LeftHandVRTarget.position); + leftHandInteractor = interactor; + } + + if (!player.isClimbingLadder) + { + if (ladderTrigger != null && ladderTrigger.interactable) + { + isActiveLadder = true; + climbStartTime = Time.time; + player.isClimbingLadder = true; + player.thisController.enabled = false; + + player.takingFallDamage = false; + player.fallValue = 0f; + player.fallValueUncapped = 0f; + } + else + { + return false; + } + } + else if (player.isClimbingLadder && !isActiveLadder) + { + return false; + } + + interactor.FingerCurler.ForceFist(true); + return true; + } + + public void OnButtonRelease(VRInteractor interactor) + { + if (interactor.IsRightHand) + { + rightHandGripPoint = null; + rightHandInteractor = null; + } + else + { + leftHandGripPoint = null; + leftHandInteractor = null; + } + + interactor.FingerCurler.ForceFist(false); + + if (!leftHandGripPoint.HasValue && !rightHandGripPoint.HasValue && isActiveLadder) + { + var player = VRSession.Instance.LocalPlayer.PlayerController; + + isActiveLadder = false; + player.isClimbingLadder = false; + player.thisController.enabled = true; + player.inSpecialInteractAnimation = false; + player.UpdateSpecialAnimationValue(false, 0, 0f, false); + + player.takingFallDamage = false; + player.fallValue = 0f; + player.fallValueUncapped = 0f; + } + } + + public void OnColliderEnter(VRInteractor interactor) { } + public void OnColliderExit(VRInteractor interactor) { } +} + +// Lightweight wrapper that forwards to the shared ladder component +internal class VRLadderInteractable : MonoBehaviour, VRInteractable +{ + public VRLadder ladder; + + public InteractableFlags Flags => InteractableFlags.BothHands; + + public bool OnButtonPress(VRInteractor interactor) => ladder.OnButtonPress(interactor); + public void OnButtonRelease(VRInteractor interactor) => ladder.OnButtonRelease(interactor); + public void OnColliderEnter(VRInteractor interactor) { } + public void OnColliderExit(VRInteractor interactor) { } +} + +[LCVRPatch] +[HarmonyPatch] +internal static class LadderPatches +{ + [HarmonyPatch(typeof(InteractTrigger), nameof(InteractTrigger.Start))] + [HarmonyPostfix] + private static void OnLadderStart(InteractTrigger __instance) + { + if (!__instance.isLadder) + return; + + if (Plugin.Config.DisableLadderClimbingInteraction.Value) + return; + + var ladderComponent = __instance.gameObject.AddComponent(); + + // Create two separate colliders offset to left and right + // This allows both hands to interact simultaneously + var leftHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform); + var rightHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform); + + if (__instance.topOfLadderPosition != null && __instance.bottomOfLadderPosition != null) + { + var topPos = __instance.topOfLadderPosition.localPosition; + var bottomPos = __instance.bottomOfLadderPosition.localPosition; + var midPoint = (topPos + bottomPos) / 2f; + var height = Mathf.Abs(topPos.y - bottomPos.y); + + // Offset left collider to the left side + leftHandCollider.transform.localPosition = midPoint + new Vector3(-0.3f, 0f, 0.3f); + leftHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f); + + // Offset right collider to the right side + rightHandCollider.transform.localPosition = midPoint + new Vector3(0.3f, 0f, 0.3f); + rightHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f); + } + else + { + leftHandCollider.transform.localPosition = new Vector3(-0.3f, 0f, 0.3f); + leftHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f); + + rightHandCollider.transform.localPosition = new Vector3(0.3f, 0f, 0.3f); + rightHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f); + } + + // Both colliders reference the same ladder component + leftHandCollider.AddComponent().ladder = ladderComponent; + rightHandCollider.AddComponent().ladder = ladderComponent; + + foreach (var collider in __instance.GetComponents()) + collider.enabled = false; + } + + [HarmonyPatch(typeof(InteractTrigger), nameof(InteractTrigger.Interact))] + [HarmonyPrefix] + private static bool PreventLadderInteract(InteractTrigger __instance) + { + if (!__instance.isLadder) + return true; + + if (Plugin.Config.DisableLadderClimbingInteraction.Value) + return true; + + return false; + } +} From ee3ad5868b9f822029bee6e8754e8a07a031c9df Mon Sep 17 00:00:00 2001 From: DaXcess Date: Thu, 22 Jan 2026 09:42:14 +0100 Subject: [PATCH 2/3] Few nits: - Move constants to top of class - Use destructure pattern for VRSession.Instance - Replace types with var - Remove if statement on gripping hands (dividing by one is optimized to a no-op anyways) - Some more guard clauses to reduce indenting - Ternary instead of if --- Source/Physics/Interactions/Ladder.cs | 180 +++++++++++++------------- 1 file changed, 87 insertions(+), 93 deletions(-) diff --git a/Source/Physics/Interactions/Ladder.cs b/Source/Physics/Interactions/Ladder.cs index 15e0940..15cd6cb 100644 --- a/Source/Physics/Interactions/Ladder.cs +++ b/Source/Physics/Interactions/Ladder.cs @@ -10,20 +10,20 @@ namespace LCVR.Physics.Interactions; public class VRLadder : MonoBehaviour, VRInteractable { + private const float MAX_CLIMB_SPEED = 3.0f; + private const float CLIMB_STRENGTH = 1.0f; + private InteractTrigger ladderTrigger; - + private Vector3? leftHandGripPoint; private Vector3? rightHandGripPoint; - + private VRInteractor leftHandInteractor; private VRInteractor rightHandInteractor; - + private bool isActiveLadder; private float climbStartTime; - - private const float MAX_CLIMB_SPEED = 3.0f; - private const float CLIMB_STRENGTH = 1.0f; - + public InteractableFlags Flags => InteractableFlags.BothHands; private void Awake() @@ -33,22 +33,22 @@ private void Awake() private void Update() { - if (VRSession.Instance?.LocalPlayer?.PlayerController == null) + if (VRSession.Instance is not { } instance) return; - - var player = VRSession.Instance.LocalPlayer.PlayerController; - + + var player = instance.LocalPlayer.PlayerController; + if (!player.isClimbingLadder || !isActiveLadder) return; - Vector3 totalMovement = Vector3.zero; - int grippingHands = 0; + var totalMovement = Vector3.zero; + var grippingHands = 0; if (leftHandGripPoint.HasValue) { var leftHand = VRSession.Instance.LocalPlayer.LeftHandVRTarget; var worldGripPoint = transform.TransformPoint(leftHandGripPoint.Value); - Vector3 pullVector = worldGripPoint - leftHand.position; + var pullVector = worldGripPoint - leftHand.position; totalMovement += pullVector; grippingHands++; } @@ -57,93 +57,85 @@ private void Update() { var rightHand = VRSession.Instance.LocalPlayer.RightHandVRTarget; var worldGripPoint = transform.TransformPoint(rightHandGripPoint.Value); - Vector3 pullVector = worldGripPoint - rightHand.position; + var pullVector = worldGripPoint - rightHand.position; totalMovement += pullVector; grippingHands++; } - if (grippingHands > 1) - totalMovement /= grippingHands; - - totalMovement *= CLIMB_STRENGTH; + totalMovement *= CLIMB_STRENGTH / grippingHands; totalMovement.x = 0; totalMovement.z = 0; - float maxMovementThisFrame = MAX_CLIMB_SPEED * Time.deltaTime; + var maxMovementThisFrame = MAX_CLIMB_SPEED * Time.deltaTime; if (Mathf.Abs(totalMovement.y) > maxMovementThisFrame) totalMovement.y = Mathf.Sign(totalMovement.y) * maxMovementThisFrame; if (Mathf.Abs(totalMovement.y) > 0.001f) - { player.thisPlayerBody.position += totalMovement; + + if (Time.time - climbStartTime < 0.5f || ladderTrigger.topOfLadderPosition == null) + return; + + var topY = ladderTrigger.topOfLadderPosition.position.y; + var playerHeadY = player.gameplayCamera.transform.position.y; + + if (playerHeadY < topY - 0.3f) + return; + + Vector3 exitPosition; + + if (ladderTrigger.useRaycastToGetTopPosition) + { + var rayStart = player.transform.position + Vector3.up * 0.5f; + var rayEnd = ladderTrigger.topOfLadderPosition.position + Vector3.up * 0.5f; + + exitPosition = UnityEngine.Physics.Linecast(rayStart, rayEnd, out var hit, + StartOfRound.Instance.collidersAndRoomMaskAndDefault, + QueryTriggerInteraction.Ignore) + ? hit.point + : ladderTrigger.topOfLadderPosition.position; } - - if (Time.time - climbStartTime >= 0.5f && ladderTrigger.topOfLadderPosition != null) + else { - var topY = ladderTrigger.topOfLadderPosition.position.y; - var playerHeadY = player.gameplayCamera.transform.position.y; - - if (playerHeadY >= topY - 0.3f) - { - Vector3 exitPosition; - - if (ladderTrigger.useRaycastToGetTopPosition) - { - var rayStart = player.transform.position + Vector3.up * 0.5f; - var rayEnd = ladderTrigger.topOfLadderPosition.position + Vector3.up * 0.5f; - - if (UnityEngine.Physics.Linecast(rayStart, rayEnd, out RaycastHit hit, - StartOfRound.Instance.collidersAndRoomMaskAndDefault, - QueryTriggerInteraction.Ignore)) - { - exitPosition = hit.point; - } - else - { - exitPosition = ladderTrigger.topOfLadderPosition.position; - } - } - else - { - exitPosition = ladderTrigger.topOfLadderPosition.position; - } - - ExitLadder(player, exitPosition); - } + exitPosition = ladderTrigger.topOfLadderPosition.position; } + + ExitLadder(player, exitPosition); } - + private void ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector3 exitPosition) { leftHandGripPoint = null; rightHandGripPoint = null; - + if (leftHandInteractor != null) { leftHandInteractor.FingerCurler.ForceFist(false); leftHandInteractor.isHeld = false; leftHandInteractor = null; } + if (rightHandInteractor != null) { rightHandInteractor.FingerCurler.ForceFist(false); rightHandInteractor.isHeld = false; rightHandInteractor = null; } - + isActiveLadder = false; - + player.isClimbingLadder = false; player.thisController.enabled = true; player.inSpecialInteractAnimation = false; - player.UpdateSpecialAnimationValue(false, 0, 0f, false); - + player.UpdateSpecialAnimationValue(false); + player.takingFallDamage = false; player.fallValue = 0f; player.fallValueUncapped = 0f; - - player.TeleportPlayer(exitPosition, false, 0f, false, true); - + + // TODO: Coroutine that smoothly places player at exit position + player.TeleportPlayer(exitPosition); + ladderTrigger.usingLadder = false; ladderTrigger.isPlayingSpecialAnimation = false; ladderTrigger.lockedPlayer = null; @@ -152,19 +144,21 @@ private void ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector3 exitP public bool OnButtonPress(VRInteractor interactor) { var player = VRSession.Instance.LocalPlayer.PlayerController; - + // Store grip point in ladder's local space if (interactor.IsRightHand) { - rightHandGripPoint = transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.RightHandVRTarget.position); + rightHandGripPoint = + transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.RightHandVRTarget.position); rightHandInteractor = interactor; } else { - leftHandGripPoint = transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.LeftHandVRTarget.position); + leftHandGripPoint = + transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.LeftHandVRTarget.position); leftHandInteractor = interactor; } - + if (!player.isClimbingLadder) { if (ladderTrigger != null && ladderTrigger.interactable) @@ -173,7 +167,7 @@ public bool OnButtonPress(VRInteractor interactor) climbStartTime = Time.time; player.isClimbingLadder = true; player.thisController.enabled = false; - + player.takingFallDamage = false; player.fallValue = 0f; player.fallValueUncapped = 0f; @@ -207,20 +201,20 @@ public void OnButtonRelease(VRInteractor interactor) interactor.FingerCurler.ForceFist(false); - if (!leftHandGripPoint.HasValue && !rightHandGripPoint.HasValue && isActiveLadder) - { - var player = VRSession.Instance.LocalPlayer.PlayerController; - - isActiveLadder = false; - player.isClimbingLadder = false; - player.thisController.enabled = true; - player.inSpecialInteractAnimation = false; - player.UpdateSpecialAnimationValue(false, 0, 0f, false); - - player.takingFallDamage = false; - player.fallValue = 0f; - player.fallValueUncapped = 0f; - } + if (leftHandGripPoint.HasValue || rightHandGripPoint.HasValue || !isActiveLadder) + return; + + var player = VRSession.Instance.LocalPlayer.PlayerController; + + isActiveLadder = false; + player.isClimbingLadder = false; + player.thisController.enabled = true; + player.inSpecialInteractAnimation = false; + player.UpdateSpecialAnimationValue(false, 0, 0f, false); + + player.takingFallDamage = false; + player.fallValue = 0f; + player.fallValueUncapped = 0f; } public void OnColliderEnter(VRInteractor interactor) { } @@ -231,9 +225,9 @@ public void OnColliderExit(VRInteractor interactor) { } internal class VRLadderInteractable : MonoBehaviour, VRInteractable { public VRLadder ladder; - + public InteractableFlags Flags => InteractableFlags.BothHands; - + public bool OnButtonPress(VRInteractor interactor) => ladder.OnButtonPress(interactor); public void OnButtonRelease(VRInteractor interactor) => ladder.OnButtonRelease(interactor); public void OnColliderEnter(VRInteractor interactor) { } @@ -260,18 +254,18 @@ private static void OnLadderStart(InteractTrigger __instance) // This allows both hands to interact simultaneously var leftHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform); var rightHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform); - + if (__instance.topOfLadderPosition != null && __instance.bottomOfLadderPosition != null) { var topPos = __instance.topOfLadderPosition.localPosition; var bottomPos = __instance.bottomOfLadderPosition.localPosition; var midPoint = (topPos + bottomPos) / 2f; var height = Mathf.Abs(topPos.y - bottomPos.y); - + // Offset left collider to the left side leftHandCollider.transform.localPosition = midPoint + new Vector3(-0.3f, 0f, 0.3f); leftHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f); - + // Offset right collider to the right side rightHandCollider.transform.localPosition = midPoint + new Vector3(0.3f, 0f, 0.3f); rightHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f); @@ -280,29 +274,29 @@ private static void OnLadderStart(InteractTrigger __instance) { leftHandCollider.transform.localPosition = new Vector3(-0.3f, 0f, 0.3f); leftHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f); - + rightHandCollider.transform.localPosition = new Vector3(0.3f, 0f, 0.3f); rightHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f); } - + // Both colliders reference the same ladder component leftHandCollider.AddComponent().ladder = ladderComponent; rightHandCollider.AddComponent().ladder = ladderComponent; - + foreach (var collider in __instance.GetComponents()) collider.enabled = false; } - + [HarmonyPatch(typeof(InteractTrigger), nameof(InteractTrigger.Interact))] [HarmonyPrefix] private static bool PreventLadderInteract(InteractTrigger __instance) { if (!__instance.isLadder) return true; - + if (Plugin.Config.DisableLadderClimbingInteraction.Value) return true; - + return false; } -} +} \ No newline at end of file From 947e3ed8cc3f90a0d933854c658717f32a9bc433 Mon Sep 17 00:00:00 2001 From: DaXcess Date: Thu, 22 Jan 2026 10:23:47 +0100 Subject: [PATCH 3/3] You have to be joshin' me --- .github/workflows/build-debug.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-debug.yaml b/.github/workflows/build-debug.yaml index 59928dd..48bc60f 100644 --- a/.github/workflows/build-debug.yaml +++ b/.github/workflows/build-debug.yaml @@ -81,7 +81,6 @@ jobs: mv ./package/manifest_new.json ./package/manifest.json - name: Upload build artifacts - if: github.event_name == 'push' uses: actions/upload-artifact@v4 with: name: LCVR-${{ steps.vars.outputs.version }}-${{ steps.vars.outputs.build_tag }}-${{ steps.vars.outputs.sha_short }}