Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 48 additions & 89 deletions Source/Physics/Interactions/Ladder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,9 @@ private void Awake()

private void Update()
{
if (VRSession.Instance is not { } instance)
if (VRSession.Instance is not { LocalPlayer.PlayerController: var player })
return;

var player = instance.LocalPlayer.PlayerController;

if (!player.isClimbingLadder || !isActiveLadder)
return;

Expand Down Expand Up @@ -68,8 +66,9 @@ private void Update()
totalMovement.z = 0;

var maxMovementThisFrame = MAX_CLIMB_SPEED * Time.deltaTime;
if (Mathf.Abs(totalMovement.y) > maxMovementThisFrame)
totalMovement.y = Mathf.Sign(totalMovement.y) * maxMovementThisFrame;
totalMovement.y = Mathf.Abs(totalMovement.y) > maxMovementThisFrame
? Mathf.Sign(totalMovement.y) * maxMovementThisFrame
: totalMovement.y;

if (Mathf.Abs(totalMovement.y) > 0.001f)
player.thisPlayerBody.position += totalMovement;
Expand All @@ -83,27 +82,25 @@ private void Update()
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;
}
else
{
exitPosition = ladderTrigger.topOfLadderPosition.position;
}
var exitPosition = ladderTrigger.useRaycastToGetTopPosition
? GetRaycastExitPosition(player)
: ladderTrigger.topOfLadderPosition.position;

StartCoroutine(ExitLadder(player, exitPosition));
}

private Vector3 GetRaycastExitPosition(GameNetcodeStuff.PlayerControllerB player)
{
var rayStart = player.transform.position + Vector3.up * 0.5f;
var rayEnd = ladderTrigger.topOfLadderPosition.position + Vector3.up * 0.5f;

return UnityEngine.Physics.Linecast(rayStart, rayEnd, out var hit,
StartOfRound.Instance.collidersAndRoomMaskAndDefault,
QueryTriggerInteraction.Ignore)
? hit.point
: ladderTrigger.topOfLadderPosition.position;
}

private IEnumerator ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector3 exitPosition)
{
leftHandGripPoint = null;
Expand Down Expand Up @@ -156,41 +153,36 @@ private IEnumerator ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector

public bool OnButtonPress(VRInteractor interactor)
{
var player = VRSession.Instance.LocalPlayer.PlayerController;
if (VRSession.Instance is not { LocalPlayer.PlayerController: var player })
return false;

// 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)
{
isActiveLadder = true;
climbStartTime = Time.time;
player.isClimbingLadder = true;
player.thisController.enabled = false;

player.takingFallDamage = false;
player.fallValue = 0f;
player.fallValueUncapped = 0f;
}
else
{
if (ladderTrigger == null || !ladderTrigger.interactable)
return false;
}

isActiveLadder = true;
climbStartTime = Time.time;
player.isClimbingLadder = true;
player.thisController.enabled = false;

player.takingFallDamage = false;
player.fallValue = 0f;
player.fallValueUncapped = 0f;
}
else if (player.isClimbingLadder && !isActiveLadder)
else if (!isActiveLadder)
{
return false;
}
Expand All @@ -217,7 +209,8 @@ public void OnButtonRelease(VRInteractor interactor)
if (leftHandGripPoint.HasValue || rightHandGripPoint.HasValue || !isActiveLadder)
return;

var player = VRSession.Instance.LocalPlayer.PlayerController;
if (VRSession.Instance is not { LocalPlayer.PlayerController: var player })
return;

isActiveLadder = false;
player.isClimbingLadder = false;
Expand All @@ -234,18 +227,7 @@ 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) { }
}
// Lightweight wrapper no longer needed with multi-hand InteractionManager support

[LCVRPatch]
[HarmonyPatch]
Expand All @@ -255,18 +237,11 @@ internal static class LadderPatches
[HarmonyPostfix]
private static void OnLadderStart(InteractTrigger __instance)
{
if (!__instance.isLadder)
if (!__instance.isLadder || Plugin.Config.DisableLadderClimbingInteraction.Value)
return;

if (Plugin.Config.DisableLadderClimbingInteraction.Value)
return;

var ladderComponent = __instance.gameObject.AddComponent<VRLadder>();

// 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);
// Create single collider - InteractionManager now supports multiple hands per interactable
var collider = Object.Instantiate(AssetManager.Interactable, __instance.transform);

if (__instance.topOfLadderPosition != null && __instance.bottomOfLadderPosition != null)
{
Expand All @@ -275,41 +250,25 @@ private static void OnLadderStart(InteractTrigger __instance)
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);
collider.transform.localPosition = midPoint + new Vector3(0f, 0f, 0.3f);
collider.transform.localScale = new Vector3(1.2f, 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);
collider.transform.localPosition = new Vector3(0f, 0f, 0.3f);
collider.transform.localScale = new Vector3(1.2f, 3f, 0.8f);
}

// Both colliders reference the same ladder component
leftHandCollider.AddComponent<VRLadderInteractable>().ladder = ladderComponent;
rightHandCollider.AddComponent<VRLadderInteractable>().ladder = ladderComponent;
collider.AddComponent<VRLadder>();

foreach (var collider in __instance.GetComponents<Collider>())
collider.enabled = false;
foreach (var existingCollider in __instance.GetComponents<Collider>())
existingCollider.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;
return !__instance.isLadder || Plugin.Config.DisableLadderClimbingInteraction.Value;
}
}
102 changes: 62 additions & 40 deletions Source/Player/VRInteractor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using LCVR.Assets;
using LCVR.Assets;
using LCVR.Input;
using LCVR.Physics;
using System.Collections.Generic;
Expand Down Expand Up @@ -176,88 +176,110 @@ private readonly struct Offset(Vector3 position, Vector3 scale, Quaternion rotat
}
}

/// <summary>
/// Manages VR interactions between interactors (hands) and interactables (objects).
/// Supports multiple interactors per interactable, allowing both hands to interact with the same object simultaneously.
/// </summary>
public class InteractionManager
{
private readonly Dictionary<VRInteractable, InteractableState> interactableState = [];
private readonly Dictionary<VRInteractable, Dictionary<VRInteractor, InteractableState>> interactableStates = [];

public void ReportInteractables(VRInteractor interactor, VRInteractable[] interactables)
{
foreach (var interactable in interactables)
{
if (!interactableState.ContainsKey(interactable))
{
// Check if this hand is allowed to interact with this object
if (interactor.IsRightHand && !interactable.Flags.HasFlag(InteractableFlags.RightHand))
continue;
// Initialize state dictionary for this interactable if needed
if (!interactableStates.ContainsKey(interactable))
interactableStates[interactable] = [];

if (!interactor.IsRightHand && !interactable.Flags.HasFlag(InteractableFlags.LeftHand))
continue;
var states = interactableStates[interactable];

if (interactor.isHeld && interactable.Flags.HasFlag(InteractableFlags.NotWhileHeld))
continue;
// Check if this hand is allowed to interact
if (interactor.IsRightHand && !interactable.Flags.HasFlag(InteractableFlags.RightHand))
continue;

if (!interactor.IsRightHand && !interactable.Flags.HasFlag(InteractableFlags.LeftHand))
continue;

interactableState.Add(interactable, new InteractableState(interactor, false));
if (interactor.isHeld && interactable.Flags.HasFlag(InteractableFlags.NotWhileHeld))
continue;

// Add this interactor if not already tracking
if (!states.ContainsKey(interactor))
{
states[interactor] = new InteractableState(false);
interactable.OnColliderEnter(interactor);
}

// Ignore events from hand if other hand is already interacting
if (interactableState[interactable].interactor != interactor)
continue;
var state = states[interactor];

if (!interactableState[interactable].isHeld && !interactor.isHeld && interactor.IsPressed())
// Handle button press
if (!state.isHeld && !interactor.isHeld && interactor.IsPressed())
{
var acknowledged = interactable.OnButtonPress(interactor);
interactableState[interactable].isHeld = interactor.isHeld = acknowledged;
state.isHeld = interactor.isHeld = acknowledged;
}
else if (interactableState[interactable].isHeld && !interactor.IsPressed())
// Handle button release
else if (state.isHeld && !interactor.IsPressed())
{
interactable.OnButtonRelease(interactor);
interactableState[interactable].isHeld = interactor.isHeld = false;
state.isHeld = interactor.isHeld = false;
}
}

foreach (var interactable in interactableState.Keys)
// Clean up interactors that left the collision zone
var interactablesToRemove = new List<VRInteractable>();

foreach (var (interactable, states) in interactableStates)
{
// Ignore if this state is being managed by another hand
if (interactableState[interactable].interactor != interactor)
if (!states.ContainsKey(interactor))
continue;

if (interactables.Contains(interactable))
continue;

// Ignore if button is still being held down
if (interactableState[interactable].isHeld)
var state = states[interactor];

// Release if still held
if (state.isHeld)
{
if (interactor.IsPressed())
continue;
else {
interactable.OnButtonRelease(interactor);
interactor.isHeld = false;
}

interactable.OnButtonRelease(interactor);
interactor.isHeld = false;
}

interactableState.Remove(interactable);
interactable.OnColliderExit(interactor);
states.Remove(interactor);

// Break to not make C# shit itself, if more need to be removed it'll happen next frame
break;
// Clean up empty state dictionaries
if (states.Count == 0)
interactablesToRemove.Add(interactable);
}

foreach (var interactable in interactablesToRemove)
interactableStates.Remove(interactable);
}

public void ResetState()
{
foreach (var interactable in interactableState.Keys)
foreach (var (interactable, states) in interactableStates)
{
var state = interactableState[interactable];

if (state.isHeld)
interactable.OnButtonRelease(state.interactor);

interactable.OnColliderExit(state.interactor);
foreach (var (interactor, state) in states)
{
if (state.isHeld)
interactable.OnButtonRelease(interactor);

interactable.OnColliderExit(interactor);
}
}

interactableStates.Clear();
}

private class InteractableState(VRInteractor interactor, bool isHeld)
private class InteractableState(bool isHeld)
{
public VRInteractor interactor = interactor;
public bool isHeld = isHeld;
}
}