diff --git a/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab b/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab
index f913f740b..58accf8b2 100644
--- a/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab
+++ b/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab
@@ -571,6 +571,7 @@ GameObject:
- component: {fileID: 6560536017312742706}
- component: {fileID: 5987075695519224435}
- component: {fileID: 5504711397442723850}
+ - component: {fileID: 3506189809021101468}
m_Layer: 8
m_Name: Locomotion
m_TagString: Untagged
@@ -832,6 +833,29 @@ MonoBehaviour:
m_ManualQueueWasCompletedThisFrame: 0
m_ManualQueueValue: 0
m_ManualQueueTargetFrame: 0
+--- !u!114 &3506189809021101468
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3580888346999583535}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 496880615cd240be960d436c1c8ae570, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.XR.Interaction.Toolkit::UnityEngine.XR.Interaction.Toolkit.Locomotion.Climbing.ClimbProvider
+ m_Mediator: {fileID: 0}
+ m_TransformationPriority: 0
+ m_ProvidersToDisable: []
+ m_EnableGravityOnClimbEnd: 1
+ m_ClimbSettings:
+ m_UseConstant: 1
+ m_ConstantValue:
+ m_AllowFreeXMovement: 1
+ m_AllowFreeYMovement: 1
+ m_AllowFreeZMovement: 1
+ m_Variable: {fileID: 0}
--- !u!1 &3942069294043007131
GameObject:
m_ObjectHideFlags: 0
@@ -3620,6 +3644,7 @@ MonoBehaviour:
_teleportationProvider: {fileID: 5987075695519224435}
_gravityProvider: {fileID: 1435405315088257603}
_jumpProvider: {fileID: 5504711397442723850}
+ _climbProvider: {fileID: 3506189809021101468}
_teleportAimer: {fileID: 8981795712984714}
_moveSpeed: 1.5
_runSpeed: 3.5
@@ -5141,11 +5166,11 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3}
propertyPath: m_Size
- value: 0.9514286
+ value: 0.95142865
objectReference: {fileID: 0}
- target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3}
propertyPath: m_Value
- value: 10.795005
+ value: 1.0000467
objectReference: {fileID: 0}
- target: {fileID: 8278234239060405203, guid: 8348fe5825a421c42be0dad392e03669, type: 3}
propertyPath: m_AnchorMax.x
diff --git a/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobLadder.prefab b/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobLadder.prefab
index 68afdcac8..b5484c262 100644
--- a/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobLadder.prefab
+++ b/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobLadder.prefab
@@ -14,6 +14,10 @@ GameObject:
- component: {fileID: 2893514479669655516}
- component: {fileID: 5158607502795523759}
- component: {fileID: 1622434811052915792}
+ - component: {fileID: 7187644040872247837}
+ - component: {fileID: 2541870062922728126}
+ - component: {fileID: 5419451996241642929}
+ - component: {fileID: 2186640121397330077}
m_Layer: 0
m_Name: BIP01 LADDER
m_TagString: Untagged
@@ -212,6 +216,149 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 594f5e7857d0629419c1bef807639a86, type: 3}
m_Name:
m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Backends.Hvr.HvrInteractableBridge
+--- !u!54 &7187644040872247837
+Rigidbody:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3766033816963121094}
+ serializedVersion: 5
+ m_Mass: 1
+ m_LinearDamping: 0
+ m_AngularDamping: 0.05
+ m_CenterOfMass: {x: 0, y: 0, z: 0}
+ m_InertiaTensor: {x: 1, y: 1, z: 1}
+ m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ImplicitCom: 1
+ m_ImplicitTensor: 1
+ m_UseGravity: 0
+ m_IsKinematic: 1
+ m_Interpolate: 0
+ m_Constraints: 0
+ m_CollisionDetection: 0
+--- !u!114 &2541870062922728126
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3766033816963121094}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4e9e72fe1e3b49f8a1fee8aa4ed33fe8, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.XR.Interaction.Toolkit::UnityEngine.XR.Interaction.Toolkit.Locomotion.Climbing.ClimbInteractable
+ m_InteractionManager: {fileID: 0}
+ m_Colliders: []
+ m_InteractionLayers:
+ m_Bits: 1
+ m_DistanceCalculationMode: 1
+ m_SelectMode: 1
+ m_FocusMode: 1
+ m_CustomReticle: {fileID: 0}
+ m_AllowGazeInteraction: 0
+ m_AllowGazeSelect: 0
+ m_OverrideGazeTimeToSelect: 0
+ m_GazeTimeToSelect: 0.5
+ m_OverrideTimeToAutoDeselectGaze: 0
+ m_TimeToAutoDeselectGaze: 3
+ m_AllowGazeAssistance: 0
+ m_FirstHoverEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_LastHoverExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_HoverEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_HoverExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FirstSelectEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_LastSelectExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_SelectEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_SelectExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FirstFocusEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_LastFocusExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FocusEntered:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FocusExited:
+ m_PersistentCalls:
+ m_Calls: []
+ m_Activated:
+ m_PersistentCalls:
+ m_Calls: []
+ m_Deactivated:
+ m_PersistentCalls:
+ m_Calls: []
+ m_StartingHoverFilters: []
+ m_StartingSelectFilters: []
+ m_StartingInteractionStrengthFilters: []
+ m_AutoFindParentInteractableInHierarchy: 0
+ m_ParentInteractableObject: {fileID: 0}
+ m_ClimbProvider: {fileID: 0}
+ m_ClimbTransform: {fileID: 5485219827508946200}
+ m_FilterInteractionByDistance: 1
+ m_MaxInteractionDistance: 0.15
+ m_ClimbAssistanceTeleportVolume: {fileID: 0}
+ m_ClimbSettingsOverride:
+ m_UseConstant: 0
+ m_ConstantValue:
+ m_AllowFreeXMovement: 1
+ m_AllowFreeYMovement: 1
+ m_AllowFreeZMovement: 1
+ m_Variable: {fileID: 0}
+--- !u!114 &5419451996241642929
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3766033816963121094}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: d53defac19fbb474d81910e60e275fd7, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Backends.Xri.XriInteractableBridge
+--- !u!114 &2186640121397330077
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3766033816963121094}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 7b9a70dafc6612a4ca4de9e05baa221e, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Components.VrLadderExit
+ _headAboveTop: 0.15
+ _exitDistances:
+ - 0.5
+ - 0.8
+ _floorTolerance: 0.5
+ _retryInterval: 0.5
--- !u!1 &6503575194566409312
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriBackend.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriBackend.cs
index 7cc73bc11..04f6eaaf3 100644
--- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriBackend.cs
+++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriBackend.cs
@@ -13,8 +13,9 @@ namespace Gothic.VR.Interaction.Backends.Xri
/// per hand, GothicGrabInteractable + XriInteractableBridge on oCItem), using mobs/NPCs and the socket core
/// (VrSocketSlot + XriSocketBridge, "VR Socket Slot.prefab"), the backpack ("BackPack XRI.prefab") on the
/// shoulder and the hip/shoulder holsters (VrBodyAnchor "Shoulders"/"Waist" on the rig), chest slots
- /// (VrChestSlots, lid opened by hand: VrHinge), NPC loot slots and doors (VrHinge).
- /// Missing: ladders (4c) and hands (5).
+ /// (VrChestSlots, lid opened by hand: VrHinge), NPC loot slots, doors (VrHinge) and ladders (ClimbProvider
+ /// on the rig, ClimbInteractable + VrLadderExit on oCMobLadder).
+ /// Missing: hands (5).
///
public class XriBackend : IVrBackend
{
@@ -25,8 +26,8 @@ public void InstallBindings(ContainerBuilder containerBuilder)
{
Logger.LogWarning("XRI backend is work in progress: rig, locomotion, input, haptics, UI laser pointers, " +
"screen fades, the XR Interaction Simulator, grabbing (items, mobs, NPCs), the " +
- "backpack, holsters, chest slots (lid opens by using it) and NPC loot work. " +
- "Physical doors/chest lids/ladders (4) and hands (5) are missing.", LogCat.VR);
+ "backpack, holsters, chest slots, doors, chest lids, ladders and NPC loot work. " +
+ "Hands (5) are missing.", LogCat.VR);
containerBuilder.AddSingleton(typeof(XriInput), typeof(IVrInput), typeof(XriInput));
containerBuilder.AddSingleton(typeof(XriHaptics), typeof(IVrHaptics));
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriGrabberBridge.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriGrabberBridge.cs
index 3ed8e2daa..bc6468954 100644
--- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriGrabberBridge.cs
+++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriGrabberBridge.cs
@@ -56,6 +56,12 @@ private void Awake()
Interactor.selectEntered.AddListener(OnSelectEntered);
Interactor.selectExited.AddListener(OnSelectExited);
_isSubscribed = true;
+
+ if (Interactor.farInteractionCaster is CurveInteractionCaster farCaster)
+ {
+ farCaster.castDistance = VRConstants.DistanceGrabMaxDistance;
+ farCaster.coneCastAngle = VRConstants.DistanceGrabConeAngle;
+ }
}
private void OnDestroy()
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriInteractableBridge.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriInteractableBridge.cs
index 2fec2fb2f..556a87c6f 100644
--- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriInteractableBridge.cs
+++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriInteractableBridge.cs
@@ -8,6 +8,7 @@
using UnityEngine.XR.Interaction.Toolkit;
using UnityEngine.XR.Interaction.Toolkit.Interactables;
using UnityEngine.XR.Interaction.Toolkit.Interactors;
+using UnityEngine.XR.Interaction.Toolkit.Locomotion.Climbing;
using Logger = Gothic.Core.Logging.Logger;
namespace Gothic.VR.Interaction.Backends.Xri
@@ -94,6 +95,11 @@ private void Start()
// Gothic meshes and their colliders are created at runtime, after XRI collected the colliders in Awake.
CollectColliders();
+
+ // The climb range (ClimbInteractable.maxInteractionDistance) is measured to the ladder's body.
+ if (_xrInteractable is ClimbInteractable)
+ _xrInteractable.getDistanceOverride = GetDistanceToBounds;
+
_xrInteractable.enabled = true;
}
@@ -179,6 +185,30 @@ private void CollectColliders()
Logger.LogWarning($"[XRI] {name} has no colliders. It can't be grabbed or hovered.", LogCat.VR);
}
+ ///
+ /// Distance to the colliders' bounds. XRI's own modes measure to the collider's transform (the ladder's base)
+ /// or need Collider.ClosestPoint, which Gothic's concave mesh colliders don't support.
+ ///
+ private DistanceInfo GetDistanceToBounds(IXRInteractable interactable, Vector3 position)
+ {
+ var result = new DistanceInfo { distanceSqr = float.MaxValue, point = position };
+ foreach (var itemCollider in interactable.colliders)
+ {
+ if (itemCollider == null || !itemCollider.enabled)
+ continue;
+
+ var distanceSqr = itemCollider.bounds.SqrDistance(position);
+ if (distanceSqr >= result.distanceSqr)
+ continue;
+
+ result.distanceSqr = distanceSqr;
+ result.point = itemCollider.bounds.ClosestPoint(position);
+ result.collider = itemCollider;
+ }
+
+ return result;
+ }
+
private VrGrabArgs CreateSelectArgs(IXRInteractor interactor)
{
if (interactor is Component component && component.TryGetComponent(out XriGrabberBridge grabber))
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriLineOfSightFilter.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriLineOfSightFilter.cs
index b1b57917d..227d95506 100644
--- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriLineOfSightFilter.cs
+++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriLineOfSightFilter.cs
@@ -18,9 +18,6 @@ public class XriLineOfSightFilter : XRBaseTargetFilter
[Tooltip("Layers which block the line of sight.")]
[SerializeField] private LayerMask _blockingLayers = 1;
- [Tooltip("Targets further away (m) can't be hovered or grabbed.")]
- [SerializeField] private float _maxDistance = 10f;
-
[Tooltip("Targets closer to the hand (m) skip the check.")]
[SerializeField] private float _minCheckDistance = 0.15f;
@@ -56,7 +53,7 @@ private bool IsInLineOfSight(Vector3 origin, IXRInteractable target)
var toTarget = closestPoint - origin;
var distance = toTarget.magnitude;
- if (distance > _maxDistance)
+ if (distance > VRConstants.DistanceGrabMaxDistance)
return false;
if (distance <= _minCheckDistance)
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs
index 3cc7d522e..81c177f78 100644
--- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs
+++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs
@@ -10,7 +10,9 @@
using Unity.XR.CoreUtils;
using UnityEngine;
using UnityEngine.XR;
+using UnityEngine.XR.Interaction.Toolkit.Interactables;
using UnityEngine.XR.Interaction.Toolkit.Locomotion;
+using UnityEngine.XR.Interaction.Toolkit.Locomotion.Climbing;
using UnityEngine.XR.Interaction.Toolkit.Locomotion.Gravity;
using UnityEngine.XR.Interaction.Toolkit.Locomotion.Jump;
using UnityEngine.XR.Interaction.Toolkit.Locomotion.Movement;
@@ -30,8 +32,7 @@ public class XriRig : MonoBehaviour, IVrRig
[Inject] private readonly IVrInput _vrInput;
[Inject] private readonly GameStateService _gameStateService;
- [Header("Rig")]
- [SerializeField] private XROrigin _origin;
+ [Header("Rig")] [SerializeField] private XROrigin _origin;
[SerializeField] private CharacterController _characterController;
[SerializeField] private Camera _camera;
[SerializeField] private Transform _leftController;
@@ -39,65 +40,76 @@ public class XriRig : MonoBehaviour, IVrRig
[Header("Grabbing")]
[Tooltip("Near-Far interactor of the left hand (direct and distance grab, UI).")]
- [SerializeField] private XriGrabberBridge _leftGrabber;
+ [SerializeField]
+ private XriGrabberBridge _leftGrabber;
- [Tooltip("Near-Far interactor of the right hand (direct and distance grab, UI).")]
- [SerializeField] private XriGrabberBridge _rightGrabber;
+ [Tooltip("Near-Far interactor of the right hand (direct and distance grab, UI).")] [SerializeField]
+ private XriGrabberBridge _rightGrabber;
- [Tooltip("Fade on the camera, driven by Fade(). WallFade on the camera uses it too.")]
- [SerializeField] private ScreenFade _screenFade;
+ [Tooltip("Fade on the camera, driven by Fade(). WallFade on the camera uses it too.")] [SerializeField]
+ private ScreenFade _screenFade;
+
+ [Header("Locomotion")] [SerializeField]
+ private ContinuousMoveProvider _moveProvider;
- [Header("Locomotion")]
- [SerializeField] private ContinuousMoveProvider _moveProvider;
[SerializeField] private SnapTurnProvider _snapTurnProvider;
[SerializeField] private ContinuousTurnProvider _continuousTurnProvider;
[SerializeField] private TeleportationProvider _teleportationProvider;
[SerializeField] private GravityProvider _gravityProvider;
[SerializeField] private JumpProvider _jumpProvider;
- [Header("Teleport")]
- [Tooltip("Thumbstick teleport (arc + ring). Child of this GameObject.")]
- [SerializeField] private XriTeleportAimer _teleportAimer;
+ [Tooltip("Moves the rig while a hand holds a ClimbInteractable (ladders) and pauses gravity meanwhile.")]
+ [SerializeField]
+ private ClimbProvider _climbProvider;
+
+ [Header("Teleport")] [Tooltip("Thumbstick teleport (arc + ring). Child of this GameObject.")] [SerializeField]
+ private XriTeleportAimer _teleportAimer;
- [Header("Movement")]
- [Tooltip("Walking speed in m/s.")]
- [SerializeField] private float _moveSpeed = 1.5f;
+ [Header("Movement")] [Tooltip("Walking speed in m/s.")] [SerializeField]
+ private float _moveSpeed = 1.5f;
- [Tooltip("Speed in m/s while sprinting (sprint button, simulator: Left Shift).")]
- [SerializeField] private float _runSpeed = 3.5f;
+ [Tooltip("Speed in m/s while sprinting (sprint button, simulator: Left Shift).")] [SerializeField]
+ private float _runSpeed = 3.5f;
- [Tooltip("Thumbstick deflection below which a clicked sprint ends.")]
- [SerializeField] private float _sprintStopThreshold = 0.1f;
+ [Tooltip("Thumbstick deflection below which a clicked sprint ends.")] [SerializeField]
+ private float _sprintStopThreshold = 0.1f;
- [Tooltip("Gravity acceleration in m/s^2. 0 disables gravity (swimming, diving).")]
- [SerializeField] private float _gravity = 9.81f;
+ [Tooltip("Gravity acceleration in m/s^2. 0 disables gravity (swimming, diving).")] [SerializeField]
+ private float _gravity = 9.81f;
- [Tooltip("Terminal fall speed in m/s.")]
- [SerializeField] private float _maxFallSpeed = 20f;
+ [Tooltip("Terminal fall speed in m/s.")] [SerializeField]
+ private float _maxFallSpeed = 20f;
- [Tooltip("Keeps the player on the ground when walking downhill or down stairs (like HVR's constant downward push). "
- + "XRI's grounded check alone flickers between ground and air there.")]
- [SerializeField] private bool _snapToGround = true;
+ [Tooltip(
+ "Keeps the player on the ground when walking downhill or down stairs (like HVR's constant downward push). "
+ + "XRI's grounded check alone flickers between ground and air there.")]
+ [SerializeField]
+ private bool _snapToGround = true;
[Tooltip("Max. drop in m per frame the player is pulled down to the ground (slopes, stairs). Larger drops "
+ "(ledges) are real falls. Capped by the CharacterController's step offset.")]
- [SerializeField] private float _groundSnapDistance = 0.3f;
+ [SerializeField]
+ private float _groundSnapDistance = 0.3f;
- [Tooltip("Time in s the player still counts as grounded after losing ground contact. Hides one-frame flickers.")]
- [SerializeField] private float _groundedGraceTime = 0.12f;
+ [Tooltip(
+ "Time in s the player still counts as grounded after losing ground contact. Hides one-frame flickers.")]
+ [SerializeField]
+ private float _groundedGraceTime = 0.12f;
[Header("Height")]
[Tooltip("Eye height of the virtual player in m. Used by the Sitting and Standing comfort settings.")]
- [SerializeField] private float _eyeHeight = 1.66f;
+ [SerializeField]
+ private float _eyeHeight = 1.66f;
- [Tooltip("Camera height in m while crouching.")]
- [SerializeField] private float _crouchHeight = 0.7f;
+ [Tooltip("Camera height in m while crouching.")] [SerializeField]
+ private float _crouchHeight = 0.7f;
- [Tooltip("Speed in m/s of the camera moving down/up when crouching starts/ends.")]
- [SerializeField] private float _crouchSpeed = 1.5f;
+ [Tooltip("Speed in m/s of the camera moving down/up when crouching starts/ends.")] [SerializeField]
+ private float _crouchSpeed = 1.5f;
[Tooltip("Below this HMD height (m), the Standing setting doesn't scale (no headset, not tracked yet).")]
- [SerializeField] private float _minCalibrationHeight = 0.5f;
+ [SerializeField]
+ private float _minCalibrationHeight = 0.5f;
private XriInput _xriInput;
private List _handGrabbers;
@@ -110,13 +122,15 @@ public class XriRig : MonoBehaviour, IVrRig
private bool _isSprintLatched;
private bool _isCrouching;
private float _crouchOffset;
+ private bool _isMovementEnabled;
+ private bool _isJumpEnabled;
private float _lastGroundedTime = float.NegativeInfinity;
private bool _isTeleportQueued;
private int _lastTeleportFrame = -1;
private Vector3 _teleportPosition;
private XRBodyTransformer _bodyTransformer;
private int _collidingLayers;
- private bool _isJumpAirborne;
+ private bool _isAirborneOnPurpose;
public event Action Teleported;
@@ -140,13 +154,19 @@ public bool IsGrounded
}
}
- // Climbing comes with HVR phase 4 (ClimbProvider).
- public bool IsClimbing => false;
+ ///
+ /// A hand holds a ClimbInteractable. The ClimbProvider forces gravity off (GravityProvider lock) meanwhile.
+ ///
+ public bool IsClimbing => _climbProvider != null && _climbProvider.climbAnchorInteractable != null;
public bool MovementEnabled
{
- get => _moveProvider.enabled;
- set => _moveProvider.enabled = value;
+ get => _isMovementEnabled;
+ set
+ {
+ _isMovementEnabled = value;
+ UpdateClimbLock();
+ }
}
public bool TurnEnabled
@@ -171,8 +191,12 @@ public bool TeleportEnabled
public bool CanJump
{
- get => _jumpProvider.enabled;
- set => _jumpProvider.enabled = value;
+ get => _isJumpEnabled;
+ set
+ {
+ _isJumpEnabled = value;
+ UpdateClimbLock();
+ }
}
public bool CanCrouch
@@ -264,6 +288,11 @@ private void Awake()
_teleportationProvider.locomotionEnded += OnTeleportLocomotionEnded;
+ _isMovementEnabled = _moveProvider.enabled;
+ _isJumpEnabled = _jumpProvider.enabled;
+ if (_climbProvider == null)
+ Logger.LogWarning("[XRI] XriRig has no ClimbProvider. Ladders can't be climbed.", LogCat.VR);
+
ApplyGravity();
UpdateTurnProviders();
}
@@ -299,10 +328,22 @@ private void OnDestroy()
private void Update()
{
+ UpdateClimbLock();
UpdateSprint();
UpdateCrouch();
}
+ ///
+ /// Walking and jumping are off while climbing (the thumbstick would fight the climbing hands), and come
+ /// back to what the game set (MovementEnabled/CanJump) afterwards.
+ ///
+ private void UpdateClimbLock()
+ {
+ var isClimbing = IsClimbing;
+ _moveProvider.enabled = _isMovementEnabled && !isClimbing;
+ _jumpProvider.enabled = _isJumpEnabled && !isClimbing;
+ }
+
private void OnAfterApplyTransformations(ApplyBodyTransformationsEventArgs args)
{
SnapToGround();
@@ -325,19 +366,21 @@ private void SnapToGround()
if (_isTeleportQueued || _lastTeleportFrame == Time.frameCount)
return;
- // From the jump until landing, the player is in the air on purpose.
- if (_jumpProvider.isJumping)
+ // From the jump or climb until landing, the player is in the air on purpose. Climbing moves the body
+ // through XRI as well, snapping would pull the player back to the floor until the hand moved further
+ // than the snap distance.
+ if (_jumpProvider.isJumping || IsClimbing)
{
- _isJumpAirborne = true;
+ _isAirborneOnPurpose = true;
return;
}
- if (_isJumpAirborne)
+ if (_isAirborneOnPurpose)
{
if (!_gravityProvider.isGrounded)
return;
- _isJumpAirborne = false;
+ _isAirborneOnPurpose = false;
}
// Only snap if we were on the ground a moment ago. Otherwise we're falling for real.
@@ -523,6 +566,8 @@ public void Teleport(Vector3 position, Quaternion rotation)
if (rotation == default)
rotation = Quaternion.identity;
+ ReleaseClimb();
+
_teleportPosition = position;
_isTeleportQueued = true;
_gravityProvider.ResetFallForce();
@@ -536,6 +581,23 @@ public void Teleport(Vector3 position, Quaternion rotation)
});
}
+ ///
+ /// The ClimbProvider keeps the hand at its grab point in world space, so it would pull a teleported rig back
+ /// to the ladder. Hands holding a ladder therefore let go first.
+ ///
+ private void ReleaseClimb()
+ {
+ // Bounded: one ladder per hand at most.
+ for (var i = 0; i < 2 && IsClimbing; i++)
+ {
+ var climbable = _climbProvider.climbAnchorInteractable;
+ if (climbable.interactionManager == null)
+ return;
+
+ climbable.interactionManager.CancelInteractableSelection((IXRSelectInteractable)climbable);
+ }
+ }
+
///
/// XRI defers locomotionEnded until the body transformer applied the queued transformations.
///
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs b/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs
new file mode 100644
index 000000000..40bffcbb5
--- /dev/null
+++ b/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs
@@ -0,0 +1,213 @@
+using Gothic.Core.Logging;
+using Gothic.Core.Models.Context;
+using Gothic.Core.Services.Context;
+using Gothic.VR.Interaction.Models;
+using Gothic.VR.Interaction.Ports;
+using Gothic.VR.Services.Context;
+using Reflex.Attributes;
+using UnityEngine;
+using Logger = Gothic.Core.Logging.Logger;
+
+namespace Gothic.VR.Interaction.Components
+{
+ ///
+ /// Gets the player off a ladder at its top. Climbing itself is done by the backend (XRI: ClimbInteractable and
+ /// ClimbProvider), which lets the player hang at the top edge without ever reaching the floor.
+ ///
+ /// While a hand holds the ladder and the head rose above its top edge, the rig is put onto the floor in front of
+ /// the ladder (the way the player looks). The spot needs a floor near the top edge and room for the player's
+ /// CharacterController, otherwise the player stays on the ladder. Both hands let go of the ladder meanwhile.
+ ///
+ /// HVR has no such mantle (the player lets go and walks off by thumbstick), so this is inactive there.
+ ///
+ [RequireComponent(typeof(VrInteractable))]
+ public class VrLadderExit : MonoBehaviour
+ {
+ private const int MaxOverlaps = 16;
+
+ [Inject] private readonly IVrBackend _vrBackend;
+ [Inject] private readonly ContextInteractionService _contextInteractionService;
+
+ [Tooltip("The head needs to be this far (m) above the ladder's top edge to step off.")]
+ [SerializeField] private float _headAboveTop = 0.15f;
+
+ [Tooltip("Distances (m) in front of the head, where a floor is searched. The first free one wins.")]
+ [SerializeField] private float[] _exitDistances = { 0.5f, 0.8f };
+
+ [Tooltip("The floor may be this far (m) above or below the ladder's top edge.")]
+ [SerializeField] private float _floorTolerance = 0.5f;
+
+ [Tooltip("Seconds until the next step off is tried after a failed one.")]
+ [SerializeField] private float _retryInterval = 0.5f;
+
+ private readonly Collider[] _overlaps = new Collider[MaxOverlaps];
+ private VrInteractable _interactable;
+ private IVrRig _rig;
+ private int _holdCount;
+ private float _nextTryTime;
+ private bool _isFailureLogged;
+
+ private bool IsActive => _vrBackend != null && _vrBackend.Kind == VrInteractionBackend.Xri;
+
+ private void Awake()
+ {
+ if (!IsActive)
+ {
+ enabled = false;
+ return;
+ }
+
+ _interactable = GetComponent();
+ _interactable.Grabbed += OnGrabbed;
+ _interactable.Released += OnReleased;
+ }
+
+ private void OnDestroy()
+ {
+ if (_interactable == null)
+ return;
+
+ _interactable.Grabbed -= OnGrabbed;
+ _interactable.Released -= OnReleased;
+ }
+
+ private void OnGrabbed(VrGrabArgs args)
+ {
+ if (args.IsHandOrDistance)
+ _holdCount++;
+ }
+
+ private void OnReleased(VrGrabArgs args)
+ {
+ if (args.IsHandOrDistance)
+ _holdCount = Mathf.Max(0, _holdCount - 1);
+
+ if (_holdCount == 0)
+ _isFailureLogged = false;
+ }
+
+ private void Update()
+ {
+ if (_holdCount == 0 || Time.time < _nextTryTime)
+ return;
+
+ _rig ??= _contextInteractionService.GetImpl().Rig;
+ if (_rig == null || !_rig.IsClimbing || !TryGetTopY(out var topY))
+ return;
+
+ if (_rig.Head.position.y < topY + _headAboveTop)
+ return;
+
+ if (TryFindExit(topY, out var exitPosition))
+ {
+ var yaw = Quaternion.Euler(0f, _rig.Head.eulerAngles.y, 0f);
+ _interactable.ForceRelease();
+ _rig.Teleport(exitPosition, yaw);
+ _holdCount = 0;
+ return;
+ }
+
+ _nextTryTime = Time.time + _retryInterval;
+ if (!_isFailureLogged)
+ {
+ _isFailureLogged = true;
+ Logger.LogWarning($"[Ladder] {name}: no free floor in front of the ladder's top edge found.",
+ LogCat.VR);
+ }
+ }
+
+ private bool TryGetTopY(out float topY)
+ {
+ topY = float.NegativeInfinity;
+ foreach (var ladderCollider in _interactable.Colliders)
+ {
+ if (ladderCollider != null)
+ topY = Mathf.Max(topY, ladderCollider.bounds.max.y);
+ }
+
+ return !float.IsNegativeInfinity(topY);
+ }
+
+ private bool TryFindExit(float topY, out Vector3 position)
+ {
+ position = default;
+
+ var forward = _rig.Head.forward;
+ forward.y = 0f;
+ if (forward.sqrMagnitude < 0.01f)
+ return false;
+ forward.Normalize();
+
+ var head = _rig.Head.position;
+ foreach (var distance in _exitDistances)
+ {
+ var rayStart = new Vector3(head.x, topY + _floorTolerance, head.z) + forward * distance;
+ if (!TryFindFloor(rayStart, topY, out var floor) || !HasRoom(floor))
+ continue;
+
+ position = floor;
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryFindFloor(Vector3 rayStart, float topY, out Vector3 floor)
+ {
+ floor = default;
+
+ var hits = Physics.RaycastAll(rayStart, Vector3.down, _floorTolerance * 2f, ~0,
+ QueryTriggerInteraction.Ignore);
+ var closest = float.MaxValue;
+ foreach (var hit in hits)
+ {
+ if (hit.distance >= closest || IsOwnCollider(hit.collider) || hit.normal.y < 0.7f)
+ continue;
+
+ closest = hit.distance;
+ floor = hit.point;
+ }
+
+ return closest < float.MaxValue && Mathf.Abs(floor.y - topY) <= _floorTolerance;
+ }
+
+ ///
+ /// The player's CharacterController fits at the spot (a bit above the floor, so that the floor isn't hit).
+ ///
+ private bool HasRoom(Vector3 floor)
+ {
+ var controller = _rig.CharacterController;
+ var radius = controller.radius;
+ var bottom = floor + Vector3.up * (radius + controller.skinWidth + 0.05f);
+ var top = floor + Vector3.up * Mathf.Max(radius + 0.1f, controller.height - radius);
+
+ var mask = GetCollidingLayers(_rig.Root.gameObject.layer);
+ var count = Physics.OverlapCapsuleNonAlloc(bottom, top, radius, _overlaps, mask,
+ QueryTriggerInteraction.Ignore);
+ for (var i = 0; i < count; i++)
+ {
+ if (!IsOwnCollider(_overlaps[i]) && !_overlaps[i].transform.IsChildOf(_rig.Root))
+ return false;
+ }
+
+ return true;
+ }
+
+ private static int GetCollidingLayers(int layer)
+ {
+ var mask = 0;
+ for (var i = 0; i < 32; i++)
+ {
+ if (!Physics.GetIgnoreLayerCollision(layer, i))
+ mask |= 1 << i;
+ }
+
+ return mask;
+ }
+
+ private bool IsOwnCollider(Collider other)
+ {
+ return other.transform.IsChildOf(transform);
+ }
+ }
+}
diff --git a/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs.meta b/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs.meta
new file mode 100644
index 000000000..cd91a1ca8
--- /dev/null
+++ b/Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7b9a70dafc6612a4ca4de9e05baa221e
\ No newline at end of file
diff --git a/Assets/Gothic-VR/Scripts/VRConstants.cs b/Assets/Gothic-VR/Scripts/VRConstants.cs
index 07b41123d..a0eccb654 100644
--- a/Assets/Gothic-VR/Scripts/VRConstants.cs
+++ b/Assets/Gothic-VR/Scripts/VRConstants.cs
@@ -32,5 +32,11 @@ public static class IniNames
public static float SpectatorSmoothingLow = 0.02f;
public static float SpectatorSmoothingMedium = 0.08f;
public static float SpectatorSmoothingHigh = 0.15f;
+
+ // Distance grab (Xri backend, far ray of both hands). HVR: HVRForceGrabber.MaxRayCastDistance = 10.
+ // Max distance in meters between hand and object to highlight and pull it.
+ public const float DistanceGrabMaxDistance = 5f;
+ // Cone angle in degrees of the far ray. The wider, the less precisely you need to point at an object.
+ public const float DistanceGrabConeAngle = 3f;
}
}
diff --git a/Docs/HVR-replacement/phase-4-doors-chests-ladders.md b/Docs/HVR-replacement/phase-4-doors-chests-ladders.md
index 6cb016b22..9d0628970 100644
--- a/Docs/HVR-replacement/phase-4-doors-chests-ladders.md
+++ b/Docs/HVR-replacement/phase-4-doors-chests-ladders.md
@@ -15,7 +15,8 @@ on `VrHinge` (replaces the interim `VrChestLidToggle`), **4c** ladders + `KeepPl
- [x] Locked doors don't move; lock picking unlocks them (`VRContainerDoorPickingInteraction`). (4a: `Lock()` holds the
leaf at 0° and a hand can't pull it open; the picking script is unchanged. Unlock + push: see status below.)
- [x] Chest lids open 0-110°. The first open spawns the content (Phase 3); closing hides the slots; lock picking works.
-- [ ] Ladders can be climbed hand over hand. `IVrRig.IsClimbing` is true while climbing (used by `KeepPlayerWithin`).
+- [x] Ladders can be climbed hand over hand. `IVrRig.IsClimbing` is true while climbing (used by `KeepPlayerWithin`).
+ (4c: `ClimbInteractable` + `ClimbProvider`; stepping off at the top by `VrLadderExit`.)
## Behaviour spec (what we use from `HVRPhysicsDoor` today)
@@ -82,7 +83,7 @@ movement onto the hinge plane, clamp to the limits). (4a: this is what `VrHinge`
- [x] `VrHinge` (+ `IVrHinge` implementation). (4a: `Interaction/Components/VrHinge.cs`, kinematic angle solver, no
joint. 4b moved `Opened`/`Closed` onto `IVrHinge`; `HvrHingeBridge` raises them from `HVRPhysicsDoor.Opened/Closed`.)
-- [ ] `VrLadderExit` (4c).
+- [x] `VrLadderExit` (4c: `Interaction/Components/VrLadderExit.cs`, see status after 4c).
- [x] `VRVobContainerPhysicsChest` → `VrChestSlots` (logic) + `VrHinge` (lid); delete the subclassing on the Xri path.
(3c: `VrChestSlots` exists and has `Open()`/`Close()`; `HvrPhysicsChest` is only the HVR lid. On Xri the lid is
the interim `VrChestLidToggle` (use = swing open/closed, kinematic, `IVrHinge`) - replace it with `VrHinge`.
@@ -91,8 +92,9 @@ movement onto the hinge plane, clamp to the limits). (4a: this is what `VrHinge`
- [ ] **Prefabs (UnityMCP):** `oCMobDoor.prefab` (VrHinge + grab interactable + bridge) (4a done: the VR prefab's door
leaf got `VrHinge`, `GothicGrabInteractable` (disabled, Kinematic movement, tracking off, no unparenting) and
`XriInteractableBridge`; `HvrHingeBridge` now switches `HVRPhysicsDoor` off on other
- backends), `oCMobContainer.prefab` (lid, 4b: done, see below), `oCMobLadder.prefab` (ClimbInteractable + bridge, 4c).
-- [ ] `VRContextInteractionService.KeepPlayerWithin` uses `IVrRig.IsClimbing`; verify with a dialog at a ladder.
+ backends), `oCMobContainer.prefab` (lid, 4b: done, see below), `oCMobLadder.prefab` (ClimbInteractable + bridge, 4c: done, see below).
+- [x] `VRContextInteractionService.KeepPlayerWithin` uses `IVrRig.IsClimbing` (it already did since Phase 0/1; now `XriRig.IsClimbing`
+ is real). Not verified with a dialog at a ladder.
## Test plan
@@ -155,10 +157,54 @@ Xri + simulator, scripted hand (`TrackedPoseDriver` of the controller disabled,
- Known differences to the interim toggle: the lid has to be pulled by hand (use = grab no longer opens it by a click);
the lid has no gravity (stays where it's let go, closes softly only below 5°).
+## HVR ladders today (behaviour only)
+
+`oCMobLadder.prefab` has an `HVRGrabbable` + `HVRClimbable` on the ladder's collider (one mesh collider along the whole
+ladder, the hand can grab anywhere on it). A hand that holds a climbable makes `IsClimbing` true; the player controller
+then moves the rig by the opposite of the hands' movement (both hands: summed, clamped to the faster hand) through the
+CharacterController, and teleporting is off while climbing. Gravity does not pull meanwhile; letting go falls. There is
+no auto-mantle: at the top the player lets go and walks (or is pushed) onto the floor by hand. Not replayed on Hvr again.
+
+## Status after 4c
+
+Xri + simulator, scripted hand (the Near-Far interactor's transform moved, `SelectEnter`/`SelectExit` via the interaction
+manager), G1 `LADDER_4.ASC` at (34.7, 60.3, 323.1), 4.9 m, editor at a few fps:
+- **Rig:** `ClimbProvider` next to the other providers on the rig's "Locomotion" object, wired to `XriRig._climbProvider`
+ (it forces gravity off through the `GravityProvider` lock while a hand holds a `ClimbInteractable`).
+ `XriRig.IsClimbing` = the provider has a climb anchor. `providersToDisable` stays empty: `XriRig` itself switches the
+ move and jump providers off while climbing and restores what the game set (`MovementEnabled`/`CanJump` are stored, so
+ a dialog that disables movement is not undone by the end of a climb).
+- **Prefab:** `BIP01 LADDER` (the one collider along the ladder) got `ClimbInteractable` (disabled, multiple selection,
+ range 0.15 m), `XriInteractableBridge` (enables it at `Start`; hover label/brightness through `VRFocus` unchanged) and
+ `VrLadderExit`. `ClimbInteractable` demands a `Rigidbody` (kinematic, no gravity); for `HVRGrabbable` next to it that
+ is the same as before (it already set itself `Stationary` without a body).
+- **Distance:** XRI measures the climb range to the collider's transform (the ladder's base) or needs
+ `Collider.ClosestPoint` (not supported by concave mesh colliders). `XriInteractableBridge` therefore sets
+ `getDistanceOverride` to the distance to the collider's bounds for `ClimbInteractable` (bounds of a vertical ladder are
+ tight enough).
+- **`VrLadderExit`** (backend-neutral, inactive on Hvr): counts the hands on the ladder (`VrInteractable.Grabbed/Released`).
+ When the head is 0.15 m above the highest point of the ladder's colliders, it looks for a floor 0.5 m, then 0.8 m in
+ front of the head (ray down, normal up, within 0.5 m of the top edge) with room for the CharacterController (capsule
+ overlap against the player's colliding layers). Found: the hands let go and `IVrRig.Teleport` puts the player there
+ looking the same way; not found: stays on the ladder, a one-time warning, retry every 0.5 s. There is no
+ thumbstick-forward condition: `IVrInput` has no stick axis, and the head rising over the edge is the signal.
+- Hover on the ladder with the hand at its surface works. Grab at 1.2 m, pulled the hand down relative to the rig in
+ steps of 0.4-1.2 m: the rig follows 1:1 and is not pulled down meanwhile (gravity lock), `IsClimbing` true, move/jump
+ providers off. Released at 62.1 and at 63.0 m: `IsClimbing` false, falls with gravity (63.0 → 60.2 m within two
+ frames), providers restored.
+- At the top (test platform at the ladder's top edge, created at runtime): head over the edge → the player stands on it
+ 0.9 m further, `IsClimbing` false, grounded, move provider back on.
+- Not tested: a real hand-over-hand climb with two hands (one hand), climbing down, the Hvr ladder (not replayed), a
+ headset, thumbstick/teleport input while climbing (`TeleportEnabled` isn't touched; a teleport from code lets go of
+ the ladder first (review fix: `XriRig.ReleaseClimb()`, otherwise the ClimbProvider pulls the rig back), untested), the exit in the real world: the two ladders around the start had no adjacent floor at the top edge (they
+ belong to a different structure), so the exit rule was only run against a platform.
+- Known differences to HVR: the player steps onto the top floor automatically; the climb sticks to the hand position and
+ not to the CharacterController's collision (XRI's body transformer still stops at walls).
+
## Risks
| Risk | Mitigation |
|------|-----------|
| VelocityTracking + HingeJoint jitter | Try kinematic angle solving (see above) before adding tuning knobs. |
| The player's CharacterController pushes doors | Put door leaves on a layer the CharacterController doesn't push against, or make the leaf kinematic while not held. |
-| Ladder dismount feels worse than HVR | Playtest HVR first and record the expected behaviour in the PR. |
+| Ladder dismount feels worse than HVR | HVR has no mantle; ours steps off only with a floor and room (see status after 4c). |