Skip to content

Commit c1daf88

Browse files
misterbubbDaXcess
andauthored
Add VR ladder climbing interaction with grip-based physics (#310)
* Add VR ladder climbing interaction with grip-based physics * 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 * You have to be joshin' me --------- Co-authored-by: DaXcess <daxcess@daxcess.io>
1 parent 726212b commit c1daf88

File tree

3 files changed

+305
-1
lines changed

3 files changed

+305
-1
lines changed

.github/workflows/build-debug.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ jobs:
8181
mv ./package/manifest_new.json ./package/manifest.json
8282
8383
- name: Upload build artifacts
84-
if: github.event_name == 'push'
8584
uses: actions/upload-artifact@v4
8685
with:
8786
name: LCVR-${{ steps.vars.outputs.version }}-${{ steps.vars.outputs.build_tag }}-${{ steps.vars.outputs.sha_short }}

Source/Config.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ public class Config(string assemblyPath, ConfigFile file)
198198
public ConfigEntry<bool> DisableElevatorButtonInteraction { get; } = file.Bind("Interaction",
199199
"DisableElevatorButtonInteraction", false, "Disables needing to physically press the elevator buttons");
200200

201+
public ConfigEntry<bool> DisableLadderClimbingInteraction { get; } = file.Bind("Interaction",
202+
"DisableLadderClimbingInteraction", false, "Disables needing to physically climb ladders by gripping and pulling");
203+
201204
// Car interaction configuration
202205

203206
public ConfigEntry<bool> DisableCarSteeringWheelInteraction { get; } = file.Bind("Car",
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
using HarmonyLib;
2+
using LCVR.Assets;
3+
using LCVR.Managers;
4+
using LCVR.Patches;
5+
using LCVR.Player;
6+
using UnityEngine;
7+
using Object = UnityEngine.Object;
8+
9+
namespace LCVR.Physics.Interactions;
10+
11+
public class VRLadder : MonoBehaviour, VRInteractable
12+
{
13+
private const float MAX_CLIMB_SPEED = 3.0f;
14+
private const float CLIMB_STRENGTH = 1.0f;
15+
16+
private InteractTrigger ladderTrigger;
17+
18+
private Vector3? leftHandGripPoint;
19+
private Vector3? rightHandGripPoint;
20+
21+
private VRInteractor leftHandInteractor;
22+
private VRInteractor rightHandInteractor;
23+
24+
private bool isActiveLadder;
25+
private float climbStartTime;
26+
27+
public InteractableFlags Flags => InteractableFlags.BothHands;
28+
29+
private void Awake()
30+
{
31+
ladderTrigger = GetComponentInParent<InteractTrigger>();
32+
}
33+
34+
private void Update()
35+
{
36+
if (VRSession.Instance is not { } instance)
37+
return;
38+
39+
var player = instance.LocalPlayer.PlayerController;
40+
41+
if (!player.isClimbingLadder || !isActiveLadder)
42+
return;
43+
44+
var totalMovement = Vector3.zero;
45+
var grippingHands = 0;
46+
47+
if (leftHandGripPoint.HasValue)
48+
{
49+
var leftHand = VRSession.Instance.LocalPlayer.LeftHandVRTarget;
50+
var worldGripPoint = transform.TransformPoint(leftHandGripPoint.Value);
51+
var pullVector = worldGripPoint - leftHand.position;
52+
totalMovement += pullVector;
53+
grippingHands++;
54+
}
55+
56+
if (rightHandGripPoint.HasValue)
57+
{
58+
var rightHand = VRSession.Instance.LocalPlayer.RightHandVRTarget;
59+
var worldGripPoint = transform.TransformPoint(rightHandGripPoint.Value);
60+
var pullVector = worldGripPoint - rightHand.position;
61+
totalMovement += pullVector;
62+
grippingHands++;
63+
}
64+
65+
totalMovement *= CLIMB_STRENGTH / grippingHands;
66+
totalMovement.x = 0;
67+
totalMovement.z = 0;
68+
69+
var maxMovementThisFrame = MAX_CLIMB_SPEED * Time.deltaTime;
70+
if (Mathf.Abs(totalMovement.y) > maxMovementThisFrame)
71+
totalMovement.y = Mathf.Sign(totalMovement.y) * maxMovementThisFrame;
72+
73+
if (Mathf.Abs(totalMovement.y) > 0.001f)
74+
player.thisPlayerBody.position += totalMovement;
75+
76+
if (Time.time - climbStartTime < 0.5f || ladderTrigger.topOfLadderPosition == null)
77+
return;
78+
79+
var topY = ladderTrigger.topOfLadderPosition.position.y;
80+
var playerHeadY = player.gameplayCamera.transform.position.y;
81+
82+
if (playerHeadY < topY - 0.3f)
83+
return;
84+
85+
Vector3 exitPosition;
86+
87+
if (ladderTrigger.useRaycastToGetTopPosition)
88+
{
89+
var rayStart = player.transform.position + Vector3.up * 0.5f;
90+
var rayEnd = ladderTrigger.topOfLadderPosition.position + Vector3.up * 0.5f;
91+
92+
exitPosition = UnityEngine.Physics.Linecast(rayStart, rayEnd, out var hit,
93+
StartOfRound.Instance.collidersAndRoomMaskAndDefault,
94+
QueryTriggerInteraction.Ignore)
95+
? hit.point
96+
: ladderTrigger.topOfLadderPosition.position;
97+
}
98+
else
99+
{
100+
exitPosition = ladderTrigger.topOfLadderPosition.position;
101+
}
102+
103+
ExitLadder(player, exitPosition);
104+
}
105+
106+
private void ExitLadder(GameNetcodeStuff.PlayerControllerB player, Vector3 exitPosition)
107+
{
108+
leftHandGripPoint = null;
109+
rightHandGripPoint = null;
110+
111+
if (leftHandInteractor != null)
112+
{
113+
leftHandInteractor.FingerCurler.ForceFist(false);
114+
leftHandInteractor.isHeld = false;
115+
leftHandInteractor = null;
116+
}
117+
118+
if (rightHandInteractor != null)
119+
{
120+
rightHandInteractor.FingerCurler.ForceFist(false);
121+
rightHandInteractor.isHeld = false;
122+
rightHandInteractor = null;
123+
}
124+
125+
isActiveLadder = false;
126+
127+
player.isClimbingLadder = false;
128+
player.thisController.enabled = true;
129+
player.inSpecialInteractAnimation = false;
130+
player.UpdateSpecialAnimationValue(false);
131+
132+
player.takingFallDamage = false;
133+
player.fallValue = 0f;
134+
player.fallValueUncapped = 0f;
135+
136+
// TODO: Coroutine that smoothly places player at exit position
137+
player.TeleportPlayer(exitPosition);
138+
139+
ladderTrigger.usingLadder = false;
140+
ladderTrigger.isPlayingSpecialAnimation = false;
141+
ladderTrigger.lockedPlayer = null;
142+
}
143+
144+
public bool OnButtonPress(VRInteractor interactor)
145+
{
146+
var player = VRSession.Instance.LocalPlayer.PlayerController;
147+
148+
// Store grip point in ladder's local space
149+
if (interactor.IsRightHand)
150+
{
151+
rightHandGripPoint =
152+
transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.RightHandVRTarget.position);
153+
rightHandInteractor = interactor;
154+
}
155+
else
156+
{
157+
leftHandGripPoint =
158+
transform.InverseTransformPoint(VRSession.Instance.LocalPlayer.LeftHandVRTarget.position);
159+
leftHandInteractor = interactor;
160+
}
161+
162+
if (!player.isClimbingLadder)
163+
{
164+
if (ladderTrigger != null && ladderTrigger.interactable)
165+
{
166+
isActiveLadder = true;
167+
climbStartTime = Time.time;
168+
player.isClimbingLadder = true;
169+
player.thisController.enabled = false;
170+
171+
player.takingFallDamage = false;
172+
player.fallValue = 0f;
173+
player.fallValueUncapped = 0f;
174+
}
175+
else
176+
{
177+
return false;
178+
}
179+
}
180+
else if (player.isClimbingLadder && !isActiveLadder)
181+
{
182+
return false;
183+
}
184+
185+
interactor.FingerCurler.ForceFist(true);
186+
return true;
187+
}
188+
189+
public void OnButtonRelease(VRInteractor interactor)
190+
{
191+
if (interactor.IsRightHand)
192+
{
193+
rightHandGripPoint = null;
194+
rightHandInteractor = null;
195+
}
196+
else
197+
{
198+
leftHandGripPoint = null;
199+
leftHandInteractor = null;
200+
}
201+
202+
interactor.FingerCurler.ForceFist(false);
203+
204+
if (leftHandGripPoint.HasValue || rightHandGripPoint.HasValue || !isActiveLadder)
205+
return;
206+
207+
var player = VRSession.Instance.LocalPlayer.PlayerController;
208+
209+
isActiveLadder = false;
210+
player.isClimbingLadder = false;
211+
player.thisController.enabled = true;
212+
player.inSpecialInteractAnimation = false;
213+
player.UpdateSpecialAnimationValue(false, 0, 0f, false);
214+
215+
player.takingFallDamage = false;
216+
player.fallValue = 0f;
217+
player.fallValueUncapped = 0f;
218+
}
219+
220+
public void OnColliderEnter(VRInteractor interactor) { }
221+
public void OnColliderExit(VRInteractor interactor) { }
222+
}
223+
224+
// Lightweight wrapper that forwards to the shared ladder component
225+
internal class VRLadderInteractable : MonoBehaviour, VRInteractable
226+
{
227+
public VRLadder ladder;
228+
229+
public InteractableFlags Flags => InteractableFlags.BothHands;
230+
231+
public bool OnButtonPress(VRInteractor interactor) => ladder.OnButtonPress(interactor);
232+
public void OnButtonRelease(VRInteractor interactor) => ladder.OnButtonRelease(interactor);
233+
public void OnColliderEnter(VRInteractor interactor) { }
234+
public void OnColliderExit(VRInteractor interactor) { }
235+
}
236+
237+
[LCVRPatch]
238+
[HarmonyPatch]
239+
internal static class LadderPatches
240+
{
241+
[HarmonyPatch(typeof(InteractTrigger), nameof(InteractTrigger.Start))]
242+
[HarmonyPostfix]
243+
private static void OnLadderStart(InteractTrigger __instance)
244+
{
245+
if (!__instance.isLadder)
246+
return;
247+
248+
if (Plugin.Config.DisableLadderClimbingInteraction.Value)
249+
return;
250+
251+
var ladderComponent = __instance.gameObject.AddComponent<VRLadder>();
252+
253+
// Create two separate colliders offset to left and right
254+
// This allows both hands to interact simultaneously
255+
var leftHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform);
256+
var rightHandCollider = Object.Instantiate(AssetManager.Interactable, __instance.transform);
257+
258+
if (__instance.topOfLadderPosition != null && __instance.bottomOfLadderPosition != null)
259+
{
260+
var topPos = __instance.topOfLadderPosition.localPosition;
261+
var bottomPos = __instance.bottomOfLadderPosition.localPosition;
262+
var midPoint = (topPos + bottomPos) / 2f;
263+
var height = Mathf.Abs(topPos.y - bottomPos.y);
264+
265+
// Offset left collider to the left side
266+
leftHandCollider.transform.localPosition = midPoint + new Vector3(-0.3f, 0f, 0.3f);
267+
leftHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f);
268+
269+
// Offset right collider to the right side
270+
rightHandCollider.transform.localPosition = midPoint + new Vector3(0.3f, 0f, 0.3f);
271+
rightHandCollider.transform.localScale = new Vector3(0.8f, height, 0.8f);
272+
}
273+
else
274+
{
275+
leftHandCollider.transform.localPosition = new Vector3(-0.3f, 0f, 0.3f);
276+
leftHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f);
277+
278+
rightHandCollider.transform.localPosition = new Vector3(0.3f, 0f, 0.3f);
279+
rightHandCollider.transform.localScale = new Vector3(0.8f, 3f, 0.8f);
280+
}
281+
282+
// Both colliders reference the same ladder component
283+
leftHandCollider.AddComponent<VRLadderInteractable>().ladder = ladderComponent;
284+
rightHandCollider.AddComponent<VRLadderInteractable>().ladder = ladderComponent;
285+
286+
foreach (var collider in __instance.GetComponents<Collider>())
287+
collider.enabled = false;
288+
}
289+
290+
[HarmonyPatch(typeof(InteractTrigger), nameof(InteractTrigger.Interact))]
291+
[HarmonyPrefix]
292+
private static bool PreventLadderInteract(InteractTrigger __instance)
293+
{
294+
if (!__instance.isLadder)
295+
return true;
296+
297+
if (Plugin.Config.DisableLadderClimbingInteraction.Value)
298+
return true;
299+
300+
return false;
301+
}
302+
}

0 commit comments

Comments
 (0)