From 8faf10192d9bdb0c89f09040876fe4a80784f3b7 Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Fri, 23 Oct 2026 13:15:33 +0200 Subject: [PATCH 1/3] feat: ladders can be climbed on the Xri backend - grabbing a ladder anywhere moves the player with the hand (hand over hand), gravity, walking and jumping pause while holding on and come back after letting go (falling mid-way works); when the head rises above the ladder's top edge and there is floor in front, the player is placed onto it. ClimbInteractable + XriInteractableBridge (grab distance measured to the ladder's bounds, concave mesh colliders have no ClosestPoint) and new backend-neutral VrLadderExit on oCMobLadder.prefab, ClimbProvider on the XRI rig; XriRig.IsClimbing while a hand holds a ladder (used by KeepPlayerWithin), keeps the game's MovementEnabled/CanJump while climbing and releases the ladder before a code teleport (HVR phase 4c). Completes Phase 4. No player-visible change on the default Hvr backend. Co-Authored-By: Claude Opus 5.5 --- .../VR/Prefabs/VR - Player XRI.prefab | 29 ++- .../VR/Prefabs/Vobs/oCMobLadder.prefab | 147 ++++++++++++ .../Interaction/Backends/Xri/XriBackend.cs | 9 +- .../Backends/Xri/XriInteractableBridge.cs | 30 +++ .../Interaction/Backends/Xri/XriRig.cs | 65 +++++- .../Interaction/Components/VrLadderExit.cs | 213 ++++++++++++++++++ .../Components/VrLadderExit.cs.meta | 2 + .../phase-4-doors-chests-ladders.md | 56 ++++- 8 files changed, 534 insertions(+), 17 deletions(-) create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Components/VrLadderExit.cs.meta 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 58ac8c5b0..734c8e913 100644 --- a/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab +++ b/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab @@ -431,6 +431,7 @@ GameObject: - component: {fileID: 6560536017312742706} - component: {fileID: 5987075695519224435} - component: {fileID: 5504711397442723850} + - component: {fileID: 3506189809021101468} m_Layer: 8 m_Name: Locomotion m_TagString: Untagged @@ -692,6 +693,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 @@ -3277,6 +3301,7 @@ MonoBehaviour: _teleportationProvider: {fileID: 5987075695519224435} _gravityProvider: {fileID: 1435405315088257603} _jumpProvider: {fileID: 5504711397442723850} + _climbProvider: {fileID: 3506189809021101468} _moveSpeed: 1.5 _runSpeed: 3.5 _sprintStopThreshold: 0.1 @@ -4797,11 +4822,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_Size - value: 0.9514283 + value: 0.95142865 objectReference: {fileID: 0} - target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_Value - value: 1.000043 + 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/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/XriRig.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs index ea06a69bc..f253b1162 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs @@ -8,7 +8,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; @@ -52,6 +54,9 @@ public class XriRig : MonoBehaviour, IVrRig [SerializeField] private GravityProvider _gravityProvider; [SerializeField] private JumpProvider _jumpProvider; + [Tooltip("Moves the rig while a hand holds a ClimbInteractable (ladders) and pauses gravity meanwhile.")] + [SerializeField] private ClimbProvider _climbProvider; + [Header("Movement")] [Tooltip("Walking speed in m/s.")] [SerializeField] private float _moveSpeed = 1.5f; @@ -92,6 +97,8 @@ public class XriRig : MonoBehaviour, IVrRig private bool _isSprintLatched; private bool _isCrouching; private float _crouchOffset; + private bool _isMovementEnabled; + private bool _isJumpEnabled; private bool _isTeleportQueued; private Vector3 _teleportPosition; @@ -107,13 +114,19 @@ public class XriRig : MonoBehaviour, IVrRig public float CameraHeight => _origin.CameraInOriginSpaceHeight; public bool IsGrounded => _gravityProvider.isGrounded || _characterController.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 @@ -138,8 +151,12 @@ public bool TeleportEnabled public bool CanJump { - get => _jumpProvider.enabled; - set => _jumpProvider.enabled = value; + get => _isJumpEnabled; + set + { + _isJumpEnabled = value; + UpdateClimbLock(); + } } public bool CanCrouch @@ -230,6 +247,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(); } @@ -250,10 +272,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 UpdateSprint() { var sprintAction = _xriInput?.GetGothicAction(XriGothicAction.Sprint) ?? default; @@ -376,6 +410,8 @@ public void Teleport(Vector3 position, Quaternion rotation) if (rotation == default) rotation = Quaternion.identity; + ReleaseClimb(); + _teleportPosition = position; _isTeleportQueued = true; _gravityProvider.ResetFallForce(); @@ -389,6 +425,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/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). | From 2b8f4597c7891644e7e5a4b635a1bf6b273ddc8d Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Sun, 25 Oct 2026 09:03:50 +0100 Subject: [PATCH 2/3] refactor: Improve remote grab distance. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/Interaction/Backends/Xri/XriGrabberBridge.cs | 6 ++++++ .../Interaction/Backends/Xri/XriLineOfSightFilter.cs | 5 +---- Assets/Gothic-VR/Scripts/VRConstants.cs | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) 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/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/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; } } From 44961fb74168805e08d12b6444e8e8c295c1c210 Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Sun, 25 Oct 2026 09:12:50 +0100 Subject: [PATCH 3/3] fix: Climbing a ladder lifts the player right away instead of after a long pull. The ground snap (against hopping when walking downhill) pulled the player back to the floor while climbing. It now pauses while a hand holds a ladder and until the player is grounded again. Co-Authored-By: Claude Opus 5.5 --- .../Interaction/Backends/Xri/XriRig.cs | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs index 7588f29ae..81c177f78 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs @@ -32,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; @@ -41,16 +40,18 @@ 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; @@ -58,50 +59,57 @@ public class XriRig : MonoBehaviour, IVrRig [SerializeField] private JumpProvider _jumpProvider; [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; + [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; @@ -122,7 +130,7 @@ public class XriRig : MonoBehaviour, IVrRig private Vector3 _teleportPosition; private XRBodyTransformer _bodyTransformer; private int _collidingLayers; - private bool _isJumpAirborne; + private bool _isAirborneOnPurpose; public event Action Teleported; @@ -358,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.