From d677d325b7270d324479e1eeb72280a632bfddcc Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Fri, 23 Oct 2026 05:23:35 +0200 Subject: [PATCH 1/6] docs: Bugs found during the HVR replacement (inventory amount 0, speech-to-text without microphone, eating a held item, culling list error) are documented in found-bugs.md instead of tickets. Co-Authored-By: Claude Sonnet 5.5 --- Docs/HVR-replacement/README.md | 2 ++ Docs/HVR-replacement/found-bugs.md | 45 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 Docs/HVR-replacement/found-bugs.md diff --git a/Docs/HVR-replacement/README.md b/Docs/HVR-replacement/README.md index ac6121bda..0946bf05a 100644 --- a/Docs/HVR-replacement/README.md +++ b/Docs/HVR-replacement/README.md @@ -22,6 +22,8 @@ Quest 2/3, Pico, PCVR, including Index / Steam Frame finger tracking. | 5 | [phase-5-hands.md](phase-5-hands.md) - **on hold**; physics hands skipped, Gothic fist meshes later | L-XL | | 6 | [phase-6-removal.md](phase-6-removal.md) - delete HVR, define, CI injection; update docs | S | +Bugs found on the way (not caused by the replacement): [found-bugs.md](found-bugs.md). + ## TL;DR - HVR 2.9.3: ~43k LoC. We use about a third of it, but that third is the hard part: **physics hands, physics diff --git a/Docs/HVR-replacement/found-bugs.md b/Docs/HVR-replacement/found-bugs.md new file mode 100644 index 000000000..049bde129 --- /dev/null +++ b/Docs/HVR-replacement/found-bugs.md @@ -0,0 +1,45 @@ +# Bugs found during the HVR replacement + +Bugs that showed up while implementing and testing the phases. None of them is caused by the XRI backend; they +exist in shared code (some on HVR as well). Kept here instead of tickets so they travel with the feature branch. +Move them to GitHub issues when the branch is merged, if they are still open. + +## Inventory: picking up a world item adds 0 (`Amount = 0`) + +- **Seen:** Phase 2a, again in 3b. Both VR backends. +- **What:** `VRPlayerService.SetGrab` adds `vobItem.Amount` items, and every world item checked has `Amount = 0`. With + `Amount = 1` set at runtime the counting works (+1 on pickup, two-hand grab counts once, drop removes it). +- **Possible fix:** `Math.Max(1, Amount)` in `VRPlayerService`, or set `Amount` when the vob is created. Check that + stacks (arrows, gold, ore) still get their real amount. +- **Test:** pick up a torch/beer at the G1 spawn and a stack of ore nuggets; open the backpack; drop them again. + +## Speech-to-text throws without a microphone + +- **Seen:** Phase 1d (simulator trigger started speech-to-text). +- **What:** `IndexOutOfRangeException` in `SpeechToTextHandler.GetMicrophoneDeviceName` when no microphone exists. +- **Expected:** one `Logger.LogWarning(..., LogCat.VR)` and speech-to-text disabled, no exception. + +## Eating a held item throws `MissingReferenceException` + +- **Seen:** Phase 2b. +- **What:** `VobMeshCullingDomain.StopTrackVobPositionUpdatesDelayed` (around line 539) still references the vob + after it was destroyed by eating it while held. Not backend specific. +- **Possible fix:** destroyed-check, or cancel the delayed call when the vob is destroyed. +- **Test:** hold berries to the mouth; no exception, nothing keeps being tracked. + +## "Couldn't find object in Culling list" when grabbing runtime-spawned chest/loot items + +- **Seen:** Phase 3c. +- **What:** grabbing an item that was spawned at runtime into a chest slot or an NPC loot slot logs + `Couldn't find object in Culling list ... (Loader)`, from `VRVobItem` -> `StartTrackVobPositionUpdates`. Shared + code of both backends, so HVR very likely has it too. +- **Related, HVR only so far:** closing NPC loot once logged `Cannot set the parent of 'ITMW_1H_SWORD_04 (Loader)' + while it is being destroyed`. +- **Maybe related:** the existing GitHub issue "Chest -> Backpack - throwing exception". +- **Test:** take an item out of a chest; loot a dead NPC, take an item, close the panel; no errors. + +## Known, not new + +- Chests with more than 12 items: pagination is missing (an error is logged, as before). +- Taking items from a chest doesn't change the chest's saved contents. +- Chest slot spheres overlap on small chests (same as with HVR). From c34b1594722e285f119725c95863d04b6f642b8d Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Fri, 23 Oct 2026 05:57:31 +0200 Subject: [PATCH 2/6] docs: Add more bugs. --- Docs/HVR-replacement/found-bugs.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Docs/HVR-replacement/found-bugs.md b/Docs/HVR-replacement/found-bugs.md index 049bde129..66437cb3e 100644 --- a/Docs/HVR-replacement/found-bugs.md +++ b/Docs/HVR-replacement/found-bugs.md @@ -38,6 +38,10 @@ Move them to GitHub issues when the branch is merged, if they are still open. - **Maybe related:** the existing GitHub issue "Chest -> Backpack - throwing exception". - **Test:** take an item out of a chest; loot a dead NPC, take an item, close the panel; no errors. +## Misc +1. Head never sticks into the wall as it is blocked from going into (poking back) +1. Marvin menu is rotated wrong (fixed later, when we have a hand and actually see, where it should be placed). + ## Known, not new - Chests with more than 12 items: pagination is missing (an error is logged, as before). From 783c7997e540d83464be4d331dca8ee3da998a43 Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Fri, 23 Oct 2026 12:42:32 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20doors=20can=20be=20opened=20on=20th?= =?UTF-8?q?e=20Xri=20backend=20-=20grabbing=20the=20door=20leaf=20turns=20?= =?UTF-8?q?it=20around=20its=20hinge=20with=20the=20hand=20(kinematic=20an?= =?UTF-8?q?gle=20solving,=20clamped=20to=200-90=C2=B0),=20after=20letting?= =?UTF-8?q?=20go=20it=20swings=20on=20and=20stops=20at=20the=20limits=20wi?= =?UTF-8?q?thout=20bouncing,=20below=205=C2=B0=20it=20closes=20softly;=20G?= =?UTF-8?q?othic=20open/close=20sounds=20play=20once=20per=20transition;?= =?UTF-8?q?=20locked=20doors=20don't=20move=20and=20unlocking=20by=20lock?= =?UTF-8?q?=20picking=20pushes=20them=20open=20a=20bit;=20a=20hand=20furth?= =?UTF-8?q?er=20than=201=20m=20away=20(e.g.=20teleporting)=20lets=20go.=20?= =?UTF-8?q?New=20backend-neutral=20VrHinge=20(IVrHinge,=20Opened/Closed=20?= =?UTF-8?q?events,=20Lock/Unlock)=20with=20GothicGrabInteractable=20+=20Xr?= =?UTF-8?q?iInteractableBridge=20on=20oCMobDoor.prefab's=20leaf;=20HvrHing?= =?UTF-8?q?eBridge=20disables=20HVRPhysicsDoor=20on=20other=20backends;=20?= =?UTF-8?q?the=20lock=20picking=20push=20force=20only=20applies=20to=20non?= =?UTF-8?q?-kinematic=20(HVR)=20doors=20(HVR=20phase=204a).=20No=20player-?= =?UTF-8?q?visible=20change=20on=20the=20default=20Hvr=20backend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../VR/Prefabs/Vobs/oCMobDoor.prefab | 172 ++++++++ .../VRContainerDoorPickingInteraction.cs | 27 +- .../Backends/Hvr/HvrHingeBridge.cs | 10 +- .../Scripts/Interaction/Components/VrHinge.cs | 374 ++++++++++++++++++ .../Interaction/Components/VrHinge.cs.meta | 2 + .../phase-4-doors-chests-ladders.md | 54 ++- 6 files changed, 619 insertions(+), 20 deletions(-) create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs.meta diff --git a/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobDoor.prefab b/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobDoor.prefab index 8d82277d7..6d1dcdb93 100644 --- a/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobDoor.prefab +++ b/Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCMobDoor.prefab @@ -18,6 +18,9 @@ GameObject: - component: {fileID: 1447025355381534221} - component: {fileID: 7842253579892008169} - component: {fileID: 87455771706928418} + - component: {fileID: 221434347929742982} + - component: {fileID: 786056569404290700} + - component: {fileID: 7188846223050807764} m_Layer: 6 m_Name: BIP01 DOORANGLE m_TagString: Untagged @@ -330,6 +333,175 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 9fca5660b7d87044b9f6309422813386, type: 3} m_Name: m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Backends.Hvr.HvrHingeBridge +--- !u!114 &221434347929742982 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 980716176992391296} + m_Enabled: 0 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6087a45bbe7ac8f469bf5110b3d64b49, type: 3} + m_Name: + m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Backends.Xri.GothicGrabInteractable + m_InteractionManager: {fileID: 0} + m_Colliders: [] + m_InteractionLayers: + m_Bits: 1 + m_DistanceCalculationMode: 1 + m_SelectMode: 0 + 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_AttachTransform: {fileID: 0} + m_SecondaryAttachTransform: {fileID: 0} + m_UseDynamicAttach: 0 + m_MatchAttachPosition: 1 + m_MatchAttachRotation: 0 + m_SnapToColliderVolume: 1 + m_ReinitializeDynamicAttachEverySingleGrab: 1 + m_AttachEaseInTime: 0.15 + m_MovementType: 1 + m_PredictedVisualsTransform: {fileID: 0} + m_VelocityDamping: 1 + m_VelocityScale: 1 + m_AngularVelocityDamping: 1 + m_AngularVelocityScale: 1 + m_TrackPosition: 0 + m_SmoothPosition: 0 + m_SmoothPositionAmount: 8 + m_TightenPosition: 0.1 + m_TrackRotation: 0 + m_SmoothRotation: 0 + m_SmoothRotationAmount: 8 + m_TightenRotation: 0.1 + m_TrackScale: 1 + m_SmoothScale: 0 + m_SmoothScaleAmount: 8 + m_TightenScale: 0.1 + m_ThrowOnDetach: 0 + m_ThrowSmoothingDuration: 0.25 + m_ThrowSmoothingCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + m_ThrowVelocityScale: 1.5 + m_ThrowAngularVelocityScale: 1 + m_ForceGravityOnDetach: 0 + m_UnparentTransformOnGrab: 0 + m_RetainTransformParent: 1 + m_StartingSingleGrabTransformers: [] + m_StartingMultipleGrabTransformers: [] + m_AddDefaultGrabTransformers: 1 + m_FarAttachMode: 0 + m_LimitLinearVelocity: 0 + m_LimitAngularVelocity: 0 + m_MaxLinearVelocityDelta: 10 + m_MaxAngularVelocityDelta: 20 + _heldInterpolation: 1 + _isOneHandOnly: 0 +--- !u!114 &786056569404290700 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 980716176992391296} + 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 &7188846223050807764 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 980716176992391296} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5bb5c68765cb1e54cbe08cc0a5a15f05, type: 3} + m_Name: + m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Components.VrHinge + _localAxis: {x: 0, y: 1, z: 0} + _isOpeningInverted: 0 + _minAngle: 0 + _maxAngle: 90 + _closeAngle: 5 + _openHysteresis: 1 + _closeOverTime: 0.25 + _soundTimeout: 0.5 + _maxHandSpeed: 360 + _swingDamping: 3 + _maxSwingSpeed: 180 + _unlockPushSpeed: 60 + _breakDistance: 1 --- !u!1 &1222579370199367537 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Gothic-VR/Scripts/Adapters/Vob/LockPicking/VRContainerDoorPickingInteraction.cs b/Assets/Gothic-VR/Scripts/Adapters/Vob/LockPicking/VRContainerDoorPickingInteraction.cs index 857ffd190..e55e35462 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/Vob/LockPicking/VRContainerDoorPickingInteraction.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/Vob/LockPicking/VRContainerDoorPickingInteraction.cs @@ -20,7 +20,6 @@ namespace Gothic.VR.Adapters.Vob.LockPicking { public class VRContainerDoorPickingInteraction : MonoBehaviour { - [SerializeField] private GameObject _rootGO; [SerializeField] private AudioSource _audioSource; @@ -40,7 +39,7 @@ public class VRContainerDoorPickingInteraction : MonoBehaviour private const string _lockInteractionColliderName = "LockPickInteraction"; private int _combinationPos = 0; - + public enum DoorLockStatus { StepSuccess, @@ -68,7 +67,8 @@ private void Start() _keyInstance = container.Key; break; default: - Logger.LogError($"VRDoorLockInteraction: No door or container found for >{_lockable.Vob.Name}<.", LogCat.VR); + Logger.LogError($"VRDoorLockInteraction: No door or container found for >{_lockable.Vob.Name}<.", + LogCat.VR); break; } @@ -85,7 +85,7 @@ private void Start() private IEnumerator StartDelayed() { yield return null; - + // Deactivate rotation _hinge?.Lock(); } @@ -136,7 +136,7 @@ private static bool IsHeldItem(GameObject heldItem, GameObject lockInteraction) var interaction = heldItem.GetComponentInChildren(); return interaction != null && interaction.gameObject == lockInteraction; } - + private void OnTriggerExit(Collider other) { if (!other.gameObject.name.Equals(_lockInteractionColliderName)) @@ -244,17 +244,22 @@ private void UnlockAndPushOpen() _hinge.Unlock(); - // Apply force in the door's forward direction (world space) - var pushDirection = _hinge.Transform.forward; - var pushForce = 10f; - _hinge.Rigidbody.AddForce(pushDirection * pushForce, ForceMode.Impulse); + // Physics hinges (HVR) get pushed open here, kinematic ones (VrHinge) push themselves in Unlock(). + var body = _hinge.Rigidbody; + if (body != null && !body.isKinematic) + { + var pushDirection = _hinge.Transform.forward; + var pushForce = 10f; + body.AddForce(pushDirection * pushForce, ForceMode.Impulse); + } + gameObject.SetActive(false); } private void PlaySound(string soundName, string fallback = null) { var clip = _audioService.GetRandomSoundClip(soundName); - + if (clip == null && fallback != null) { PlaySound(fallback); @@ -263,7 +268,7 @@ private void PlaySound(string soundName, string fallback = null) if (clip == null) return; - + _audioSource.PlayOneShot(clip); } } diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Hvr/HvrHingeBridge.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Hvr/HvrHingeBridge.cs index a144ed501..94af17dbf 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Hvr/HvrHingeBridge.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Hvr/HvrHingeBridge.cs @@ -7,7 +7,8 @@ namespace Gothic.VR.Interaction.Backends.Hvr { /// - /// Sits next to an HVRPhysicsDoor (doors, and chest lids via HvrPhysicsChest). + /// Sits next to an HVRPhysicsDoor (doors, and chest lids via HvrPhysicsChest). Switches the door off if another + /// VR backend runs. /// public class HvrHingeBridge : MonoBehaviour, IVrHinge { @@ -29,6 +30,13 @@ private HVRPhysicsDoor Door public Transform Transform => transform; public Rigidbody Rigidbody => GetComponent(); + private void Awake() + { + // The door would set up its own joint in Start and fight the hinge of another backend (VrHinge). + if (!IsActive && Door != null) + Door.enabled = false; + } + public void Lock() { Door.Lock(); diff --git a/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs b/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs new file mode 100644 index 000000000..cecd5cadf --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using Gothic.Core.Logging; +using Gothic.Core.Models.Context; +using Gothic.VR.Interaction.Models; +using Gothic.VR.Interaction.Ports; +using Reflex.Attributes; +using UnityEngine; +using Logger = Gothic.Core.Logging.Logger; + +namespace Gothic.VR.Interaction.Components +{ + /// + /// Backend-neutral hinge for doors (and later chest lids). The leaf is kinematic and rotated by our own angle + /// solver (no joint: velocity tracking against a HingeJoint/ConfigurableJoint whipped and jammed the leaf): + /// - While a hand holds the leaf, the hand's angular movement around the hinge axis (projected onto the hinge + /// plane) turns the leaf by the same angle, clamped to Min/MaxAngle. + /// - After letting go, the leaf swings on with the hand's last angular speed and slows down (SwingDamping). It + /// stops at the limits without bouncing. + /// - Angle is the opened angle in degrees (0 = closed). + /// - Closed below CloseAngle, open above CloseAngle + OpenHysteresis. Opened/Closed are raised once per change. + /// The Gothic open/close sound is played once per change, but not more often than SoundTimeout. + /// - Soft close: released below CloseAngle, the leaf closes completely within CloseOverTime. + /// - Lock() holds the leaf at 0 degrees, Unlock() frees it with a small push open (lock picking). + /// - Being kinematic, the player's CharacterController can't push the leaf through its limits. + /// - Releases the leaf when the hand gets farther than BreakDistance from it (e.g. teleporting while holding). + /// + /// The grab itself is done by the backend (GothicGrabInteractable + XriInteractableBridge, with tracking off). + /// Inactive on HVR, where HVRPhysicsDoor next to HvrHingeBridge is the hinge. + /// + [RequireComponent(typeof(Rigidbody))] + public class VrHinge : MonoBehaviour, IVrHinge + { + [Tooltip("Hinge axis in the leaf's local space.")] [SerializeField] + private Vector3 _localAxis = Vector3.up; + + [Tooltip("The leaf opens against the right-hand rotation around the axis.")] [SerializeField] + private bool _isOpeningInverted; + + [Tooltip("Smallest opened angle (degrees).")] [SerializeField] + private float _minAngle; + + [Tooltip("Largest opened angle (degrees).")] [SerializeField] + private float _maxAngle = 90f; + + [Tooltip("Below this opened angle the hinge counts as closed (degrees).")] [SerializeField] + private float _closeAngle = 5f; + + [Tooltip("Degrees above CloseAngle until the hinge counts as open again. Prevents repeated events and " + + "sounds while the leaf rattles around the threshold.")] + [SerializeField] + private float _openHysteresis = 1f; + + [Tooltip("Seconds the leaf needs to close completely after letting go below CloseAngle. 0 = off.")] + [SerializeField] + private float _closeOverTime = 0.25f; + + [Tooltip("Minimum seconds between two open/close sounds.")] [SerializeField] + private float _soundTimeout = 0.5f; + + [Tooltip("Fastest rotation while a hand drags the leaf (degrees/s). Hands can't whip it through the limits.")] + [SerializeField] + private float _maxHandSpeed = 360f; + + [Tooltip("How fast a released leaf slows down (1/s). Higher = stops sooner.")] [SerializeField] + private float _swingDamping = 3f; + + [Tooltip("Fastest rotation of a released leaf (degrees/s).")] [SerializeField] + private float _maxSwingSpeed = 180f; + + [Tooltip("Rotation speed (degrees/s) the leaf gets when it is unlocked (the push of lock picking).")] + [SerializeField] + private float _unlockPushSpeed = 60f; + + [Tooltip("A hand farther than this from the leaf (meters) lets go of it.")] [SerializeField] + private float _breakDistance = 1f; + + [Inject] private readonly IVrBackend _vrBackend; + [Inject] private readonly IVrSfxPlayer _sfxPlayer; + + private readonly List _grabbers = new(); + + private VrInteractable _interactable; + private Quaternion _closedRotation; + private Vector3 _axis; + private Vector3 _planeReference; + private AudioClip _openedClip; + private AudioClip _closedClip; + private float _angle; + private float _speed; + private float _grabHandAngle; + private float _grabLeafAngle; + private float _lastSoundTime = float.NegativeInfinity; + private bool _isStarted; + private bool _isSubscribed; + private bool _isLocked; + private bool _isOpen; + private bool _isClosed = true; + + /// + /// Raised once when the leaf leaves the closed position. + /// + public event Action Opened; + + /// + /// Raised once when the leaf is back in the closed position. + /// + public event Action Closed; + + public bool IsActive => _vrBackend != null && _vrBackend.Kind == VrInteractionBackend.Xri; + public bool IsOpen => _isOpen; + public bool IsClosed => _isClosed; + public bool IsLocked => _isLocked; + public bool IsHeld => _grabbers.Count > 0; + public Transform Transform => transform; + public Rigidbody Rigidbody => GetComponent(); + + /// + /// Opened angle in degrees. 0 = closed, MaxAngle = fully open. + /// + public float Angle => _angle; + + private void Start() + { + if (!IsActive) + { + enabled = false; + return; + } + + // Moved by transform only. The Rigidbody is kept for the colliders and the lock picking. + var body = GetComponent(); + body.isKinematic = true; + body.useGravity = false; + + _axis = _localAxis.normalized; + _closedRotation = transform.localRotation; + var worldAxis = GetWorldAxis(); + var reference = Mathf.Abs(Vector3.Dot(worldAxis, Vector3.up)) > 0.9f ? Vector3.right : Vector3.up; + _planeReference = Vector3.ProjectOnPlane(reference, worldAxis).normalized; + _isStarted = true; + + _interactable = GetComponent(); + if (_interactable == null) + { + Logger.LogWarning($"[Hinge] {name} has no VrInteractable, it can't be opened by hand.", LogCat.VR); + return; + } + + _interactable.Grabbed += OnGrabbed; + _interactable.Released += OnReleased; + _isSubscribed = true; + } + + private void OnDestroy() + { + if (!_isSubscribed || _interactable == null) + return; + + _interactable.Grabbed -= OnGrabbed; + _interactable.Released -= OnReleased; + } + + private void LateUpdate() + { + // After the XR rig has moved the hands this frame. + BreakIfHandIsFar(); + + var deltaTime = Time.deltaTime; + if (deltaTime <= 0f) + return; + + if (_isLocked) + MoveTo(0f, deltaTime); + else if (IsHeld) + FollowHand(deltaTime); + else + Swing(deltaTime); + + UpdateState(_angle); + } + + public void Lock() + { + _isLocked = true; + _speed = 0f; + if (!_isStarted) + return; + + _angle = 0f; + ApplyAngle(); + UpdateState(_angle); + } + + public void Unlock() + { + _isLocked = false; + if (!_isStarted) + return; + + // The push open of the lock picking. + _speed = _unlockPushSpeed; + RebaselineGrab(); + } + + public void SetSounds(AudioClip opened, AudioClip closed) + { + _openedClip = opened; + _closedClip = closed; + } + + private Vector3 GetWorldAxis() + { + return transform.TransformDirection(_localAxis.normalized); + } + + /// + /// Angle of the hand around the hinge axis in the hinge plane, in the opening direction (degrees). + /// + private float GetHandAngle(Vector3 handPosition) + { + var worldAxis = GetWorldAxis(); + var fromHinge = Vector3.ProjectOnPlane(handPosition - transform.position, worldAxis); + var angle = Vector3.SignedAngle(_planeReference, fromHinge, worldAxis); + return _isOpeningInverted ? -angle : angle; + } + + private void FollowHand(float deltaTime) + { + var hand = _grabbers[0]; + if (hand == null) + return; + + var handPosition = hand.transform.position; + var radius = Vector3.ProjectOnPlane(handPosition - transform.position, GetWorldAxis()).magnitude; + // A hand right at the hinge has no meaningful angle. + if (radius < 0.05f) + return; + + var handDelta = Mathf.DeltaAngle(_grabHandAngle, GetHandAngle(handPosition)); + var target = Mathf.Clamp(_grabLeafAngle + handDelta, _minAngle, _maxAngle); + var previous = _angle; + MoveTo(target, deltaTime, _maxHandSpeed); + + // Keeps its speed for the swing after letting go. + _speed = Mathf.Lerp(_speed, (_angle - previous) / deltaTime, 0.5f); + } + + private void Swing(float deltaTime) + { + if (_closeOverTime > 0f && _angle < _closeAngle && _angle > _minAngle && Mathf.Abs(_speed) < 5f) + { + _speed = 0f; + MoveTo(_minAngle, deltaTime, _closeAngle / _closeOverTime); + return; + } + + if (Mathf.Approximately(_speed, 0f)) + return; + + _speed = Mathf.Clamp(_speed * Mathf.Exp(-_swingDamping * deltaTime), -_maxSwingSpeed, _maxSwingSpeed); + if (Mathf.Abs(_speed) < 0.5f) + _speed = 0f; + + var next = Mathf.Clamp(_angle + _speed * deltaTime, _minAngle, _maxAngle); + if (Mathf.Approximately(next, _minAngle) || Mathf.Approximately(next, _maxAngle)) + _speed = 0f; + + _angle = next; + ApplyAngle(); + } + + private void MoveTo(float target, float deltaTime, float maxSpeed = float.PositiveInfinity) + { + var next = Mathf.MoveTowards(_angle, target, maxSpeed * deltaTime); + if (Mathf.Approximately(next, _angle)) + return; + + _angle = next; + ApplyAngle(); + } + + private void ApplyAngle() + { + var sign = _isOpeningInverted ? -1f : 1f; + transform.localRotation = _closedRotation * Quaternion.AngleAxis(sign * _angle, _axis); + } + + private void UpdateState(float angle) + { + if (_isClosed && angle > _closeAngle + _openHysteresis) + { + _isClosed = false; + _isOpen = true; + PlaySound(_openedClip); + Opened?.Invoke(); + } + else if (_isOpen && angle < _closeAngle) + { + _isClosed = true; + _isOpen = false; + PlaySound(_closedClip); + Closed?.Invoke(); + } + } + + private void PlaySound(AudioClip clip) + { + if (clip == null || _sfxPlayer == null || Time.time - _lastSoundTime < _soundTimeout) + return; + + _lastSoundTime = Time.time; + _sfxPlayer.Play(clip, transform.position); + } + + private void BreakIfHandIsFar() + { + if (!IsHeld || _interactable == null) + return; + + foreach (var grabber in _grabbers) + { + if (grabber == null || GetDistanceToLeaf(grabber.transform.position) <= _breakDistance) + continue; + + Logger.Log($"[Hinge] {name} released: hand is farther than {_breakDistance}m away.", LogCat.VR); + _interactable.ForceRelease(); + return; + } + } + + private float GetDistanceToLeaf(Vector3 position) + { + var distance = float.MaxValue; + foreach (var leafCollider in _interactable.Colliders) + { + if (leafCollider == null || !leafCollider.enabled) + continue; + + distance = Mathf.Min(distance, Vector3.Distance(leafCollider.ClosestPointOnBounds(position), position)); + } + + return distance; + } + + private void OnGrabbed(VrGrabArgs args) + { + if (!args.IsHandOrDistance || args.GrabberGo == null || _grabbers.Contains(args.GrabberGo)) + return; + + _grabbers.Add(args.GrabberGo); + _speed = 0f; + RebaselineGrab(); + } + + private void OnReleased(VrGrabArgs args) + { + _grabbers.Remove(args.GrabberGo); + RebaselineGrab(); + } + + /// + /// The leaf follows the first hand from where the leaf and the hand are now. + /// + private void RebaselineGrab() + { + if (!IsHeld || _grabbers[0] == null) + return; + + _grabLeafAngle = _angle; + _grabHandAngle = GetHandAngle(_grabbers[0].transform.position); + } + } +} diff --git a/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs.meta b/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs.meta new file mode 100644 index 000000000..f67033dd5 --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Components/VrHinge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5bb5c68765cb1e54cbe08cc0a5a15f05 \ 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 d00abf11a..283486563 100644 --- a/Docs/HVR-replacement/phase-4-doors-chests-ladders.md +++ b/Docs/HVR-replacement/phase-4-doors-chests-ladders.md @@ -4,11 +4,16 @@ **Size:** M (1-2 weeks) · **Depends on:** Phases 0-2 · **Parallel with:** Phase 3 (chest slots) +Split into three PRs: **4a** `VrHinge` + doors on Xri (lock picking, soft close, hand break distance), **4b** chest lid +on `VrHinge` (replaces the interim `VrChestLidToggle`), **4c** ladders + `KeepPlayerWithin`/`IsClimbing`. + ## Exit criteria -- [ ] Doors open by grabbing and pulling/pushing, are limited to 0-90°, and play the Gothic open/close sounds from the - door's MDS once per open/close. -- [ ] Locked doors don't move; lock picking unlocks them (`VRContainerDoorPickingInteraction`). +- [x] Doors open by grabbing and pulling/pushing, are limited to 0-90°, and play the Gothic open/close sounds from the + door's MDS once per open/close. (4a; the Gothic sounds are handed to the hinge by `VRVobDoor`, played once per + change with a 0.5 s timeout.) +- [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.) - [ ] 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`). @@ -51,8 +56,8 @@ hand drags the leaf physically. No position tracking of the hand → hinge, whic problem. Set `trackRotation = false` so only the position drives the leaf (feels like pushing/pulling a handle). Alternative if VelocityTracking + HingeJoint feels jittery: kinematic leaf with our own angle solve (project the hand -movement onto the hinge plane, clamp to the limits). (Decision 2026-10-22: nothing from UltimateXR is used, not even -as a code reference.) +movement onto the hinge plane, clamp to the limits). (4a: this is what `VrHinge` does, see the status below.) +(Decision 2026-10-22: nothing from UltimateXR is used, not even as a code reference.) ### Chest @@ -75,12 +80,16 @@ as a code reference.) ## Work items -- [ ] `VrHinge` (+ `IVrHinge` implementation), `VrLadderExit`. +- [x] `VrHinge` (+ `IVrHinge` implementation). (4a: `Interaction/Components/VrHinge.cs`, kinematic angle solver, no + joint. `Opened`/`Closed` C# events are on `VrHinge`, not on `IVrHinge` yet (4b adds them with the chest).) +- [ ] `VrLadderExit` (4c). - [ ] `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`.) -- [ ] **Prefabs (UnityMCP):** `oCMobDoor.prefab` (VrHinge + grab interactable + bridge), `oCMobContainer.prefab` - (lid), `oCMobLadder.prefab` (ClimbInteractable + bridge). +- [ ] **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), `oCMobLadder.prefab` (ClimbInteractable + bridge, 4c). - [ ] `VRContextInteractionService.KeepPlayerWithin` uses `IVrRig.IsClimbing`; verify with a dialog at a ladder. ## Test plan @@ -94,6 +103,35 @@ as a code reference.) gravity). 6. Teleport while holding a door → the door releases cleanly. +## Status after 4a + +Xri + simulator, programmatic hand (hand controller moved by script, `SelectEnter`/`SelectExit` on the interactor), +G1 old camp doors at (302, 4.5, 140) and (183, -2, 291, locked), editor at a few fps (so only logic was judged, not +feel): +- **Decision: kinematic angle solving instead of velocity tracking.** The first version (leaf on a `HingeJoint` / + `ConfigurableJoint` to an anchor body, hand drags it by velocity tracking) was played first: `HingeJoint.angle` has + an arbitrary zero (limits outside -180..180 break), and with the joint the leaf overshot 8° past the limit, jammed + and recoiled when released. `VrHinge` now rotates the kinematic leaf itself: the hand's angle around the hinge axis + (in the hinge plane) turns the leaf, clamped to 0-90°; after letting go it swings on with the hand's speed and + stops at the limits. `GothicGrabInteractable` on the leaf only selects (Kinematic movement, position and rotation + tracking off). Side effect: the CharacterController can't push the leaf (risk "CharacterController pushes the + door" is solved by design), no `Rigidbody` dynamics, no recoil. +- Pull open with a hand moving 25°/s: leaf follows 1:1 up to 75°, back to closed; `Opened` once, release at 3° → + soft close to 0° in 0.25 s and `Closed` once. Sweep of the hand to 125°: leaf clamps at 90°. Released while moving + at ~50°/s: swings to 90° and stays without overshoot. +- Open/close clips are set by `VRVobDoor` (`Door_open01`/`Door_close01b`), played from the state change (not heard). +- `Lock()`: leaf snaps to 0° and a hand pulling 40°/s doesn't move it; `Unlock()` gives a push open (~60°/s, comes + to rest at ~20°). The real locked door (hut at 183, -2, 291) gets `Lock()` from `VRContainerDoorPickingInteraction` + (`IsLocked` true). The picking script pushes only non-kinematic hinges (HVR), `VrHinge` + pushes itself in `Unlock()`. +- Hand farther than 1 m from the leaf (teleport) lets go of it. +- Hvr backend: `HVRPhysicsDoor` is disabled on Xri (`HvrHingeBridge.Awake`), `VrHinge` disables itself on Hvr + (checked by the components' state at runtime on Xri; the Hvr door was not played). +- Not tested: a real pick-lock sequence by hand (haptics), closing a door by hand with sound audible, a headset + (hand tracking noise: the hand angle is used raw), Hvr door play test, two hands on one door (first hand wins). +- Known differences to HVR: the leaf has no physical collision with hands (Xri hands aren't colliders), so touching + a door without grabbing doesn't move it, and the player's body doesn't push it. + ## Risks | Risk | Mitigation | From ea29f1ca6369ab527a558a8ce9a073dcd80b49f1 Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Sun, 25 Oct 2026 05:22:38 +0100 Subject: [PATCH 4/6] feat: thumbstick teleport on XRI (arc, grip cancels, no wall/roof/mid-air exploits) Right thumbstick forward shows an arc, release teleports, grip cancels. Valid only when grounded, within 1 m rise / 3 m drop, with room for the standing capsule and a free head path, so the player can't teleport through bars, over high walls, onto roofs or out of a fall. Co-Authored-By: Claude Opus 5.5 --- .../VR/Prefabs/VR - Player XRI.prefab | 348 +++++++++++++++++- .../Interaction/Backends/Xri/XriRig.cs | 33 +- .../Backends/Xri/XriTeleportAimer.cs | 215 +++++++++++ .../Backends/Xri/XriTeleportAimer.cs.meta | 2 + .../Backends/Xri/XriTeleportSettings.cs | 44 +++ .../Backends/Xri/XriTeleportSettings.cs.meta | 2 + .../phase-1-rig-input-locomotion-ui.md | 6 +- 7 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs.meta create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs create mode 100644 Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.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..5a5f53d9e 100644 --- a/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab +++ b/Assets/Gothic-VR/Resources/VR/Prefabs/VR - Player XRI.prefab @@ -416,6 +416,146 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &2970714513379137289 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2879737534919961666} + - component: {fileID: 3947543953460428864} + m_Layer: 0 + m_Name: Arc + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2879737534919961666 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2970714513379137289} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8750008290288450499} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!120 &3947543953460428864 +LineRenderer: + serializedVersion: 3 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2970714513379137289} + m_Enabled: 0 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 0 + m_LightProbeUsage: 0 + m_ReflectionProbeUsage: 0 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10306, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Positions: [] + m_Parameters: + serializedVersion: 3 + widthMultiplier: 0.015 + widthCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0.33333334 + outWeight: 0.33333334 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + colorGradient: + serializedVersion: 2 + key0: {r: 1, g: 1, b: 1, a: 1} + key1: {r: 1, g: 1, b: 1, a: 1} + key2: {r: 0, g: 0, b: 0, a: 0} + key3: {r: 0, g: 0, b: 0, a: 0} + key4: {r: 0, g: 0, b: 0, a: 0} + key5: {r: 0, g: 0, b: 0, a: 0} + key6: {r: 0, g: 0, b: 0, a: 0} + key7: {r: 0, g: 0, b: 0, a: 0} + ctime0: 0 + ctime1: 65535 + ctime2: 0 + ctime3: 0 + ctime4: 0 + ctime5: 0 + ctime6: 0 + ctime7: 0 + atime0: 0 + atime1: 65535 + atime2: 0 + atime3: 0 + atime4: 0 + atime5: 0 + atime6: 0 + atime7: 0 + m_Mode: 0 + m_ColorSpace: -1 + m_NumColorKeys: 2 + m_NumAlphaKeys: 2 + numCornerVertices: 0 + numCapVertices: 2 + alignment: 0 + textureMode: 0 + textureScale: {x: 1, y: 1} + shadowBias: 0.5 + generateLightingData: 0 + m_UseWorldSpace: 1 + m_Loop: 0 + m_ApplyActiveColorSpace: 1 --- !u!1 &3580888346999583535 GameObject: m_ObjectHideFlags: 0 @@ -1045,6 +1185,64 @@ MonoBehaviour: _fadeSpeed: 6 _teleportDistance: 1 _distanceIterations: 5 +--- !u!1 &4786995511277724993 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8750008290288450499} + - component: {fileID: 8981795712984714} + m_Layer: 0 + m_Name: Teleport + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8750008290288450499 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4786995511277724993} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2879737534919961666} + - {fileID: 2958122511468957170} + m_Father: {fileID: 1361426706454156200} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &8981795712984714 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4786995511277724993} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7f622519f5c2fe64a93dfdb569a97b2d, type: 3} + m_Name: + m_EditorClassIdentifier: Gothic.VR::Gothic.VR.Interaction.Backends.Xri.XriTeleportAimer + _settings: + StartThreshold: 0.75 + ReleaseThreshold: 0.4 + ArcSpeed: 9 + ArcGravity: 9.81 + ArcStep: 0.4 + ArcSamples: 40 + ReticleRadius: 0.25 + ValidColor: {r: 0.3, g: 0.9, b: 0.4, a: 0.9} + InvalidColor: {r: 0.9, g: 0.3, b: 0.3, a: 0.6} + _arc: {fileID: 3947543953460428864} + _reticle: {fileID: 7864087409581978865} --- !u!1 &5458298503362937332 GameObject: m_ObjectHideFlags: 0 @@ -1994,6 +2192,146 @@ MonoBehaviour: _pitchThreshold: 30 _catchUpAngle: 70 _catchUpSpeed: 90 +--- !u!1 &5943813740192046332 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2958122511468957170} + - component: {fileID: 7864087409581978865} + m_Layer: 0 + m_Name: Reticle + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2958122511468957170 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5943813740192046332} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8750008290288450499} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!120 &7864087409581978865 +LineRenderer: + serializedVersion: 3 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5943813740192046332} + m_Enabled: 0 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 0 + m_LightProbeUsage: 0 + m_ReflectionProbeUsage: 0 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10306, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Positions: [] + m_Parameters: + serializedVersion: 3 + widthMultiplier: 0.015 + widthCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0.33333334 + outWeight: 0.33333334 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + colorGradient: + serializedVersion: 2 + key0: {r: 1, g: 1, b: 1, a: 1} + key1: {r: 1, g: 1, b: 1, a: 1} + key2: {r: 0, g: 0, b: 0, a: 0} + key3: {r: 0, g: 0, b: 0, a: 0} + key4: {r: 0, g: 0, b: 0, a: 0} + key5: {r: 0, g: 0, b: 0, a: 0} + key6: {r: 0, g: 0, b: 0, a: 0} + key7: {r: 0, g: 0, b: 0, a: 0} + ctime0: 0 + ctime1: 65535 + ctime2: 0 + ctime3: 0 + ctime4: 0 + ctime5: 0 + ctime6: 0 + ctime7: 0 + atime0: 0 + atime1: 65535 + atime2: 0 + atime3: 0 + atime4: 0 + atime5: 0 + atime6: 0 + atime7: 0 + m_Mode: 0 + m_ColorSpace: -1 + m_NumColorKeys: 2 + m_NumAlphaKeys: 2 + numCornerVertices: 0 + numCapVertices: 2 + alignment: 0 + textureMode: 0 + textureScale: {x: 1, y: 1} + shadowBias: 0.5 + generateLightingData: 0 + m_UseWorldSpace: 1 + m_Loop: 1 + m_ApplyActiveColorSpace: 1 --- !u!1 &6648638844185410883 GameObject: m_ObjectHideFlags: 0 @@ -2963,6 +3301,7 @@ Transform: - {fileID: 5689876404678866823} - {fileID: 1769492897988253260} - {fileID: 229440269703855637} + - {fileID: 8750008290288450499} m_Father: {fileID: 3866233120056885002} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!143 &3989580456932340501 @@ -3277,6 +3616,7 @@ MonoBehaviour: _teleportationProvider: {fileID: 5987075695519224435} _gravityProvider: {fileID: 1435405315088257603} _jumpProvider: {fileID: 5504711397442723850} + _teleportAimer: {fileID: 8981795712984714} _moveSpeed: 1.5 _runSpeed: 3.5 _sprintStopThreshold: 0.1 @@ -4777,11 +5117,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 5582560689641485545, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_AnchoredPosition.x - value: -366.49997 + value: 0.00015258789 objectReference: {fileID: 0} - target: {fileID: 5582560689641485545, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_AnchoredPosition.y - value: 166.49922 + value: -0.015060425 objectReference: {fileID: 0} - target: {fileID: 5603077536188825030, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_AnchorMax.x @@ -4797,11 +5137,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_Size - value: 0.9514283 + value: 0.9514286 objectReference: {fileID: 0} - target: {fileID: 6272730690859954775, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_Value - value: 1.000043 + value: 10.795005 objectReference: {fileID: 0} - target: {fileID: 8278234239060405203, guid: 8348fe5825a421c42be0dad392e03669, type: 3} propertyPath: m_AnchorMax.x diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs index ea06a69bc..e5f3aee06 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs @@ -52,6 +52,10 @@ public class XriRig : MonoBehaviour, IVrRig [SerializeField] private GravityProvider _gravityProvider; [SerializeField] private JumpProvider _jumpProvider; + [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; @@ -128,7 +132,7 @@ public bool TurnEnabled /// /// Player-initiated teleporting. Teleports from code (Teleport()) always work, therefore the - /// TeleportationProvider itself stays enabled. There are no teleport interactors yet (HVR phase 2). + /// TeleportationProvider itself stays enabled. Disabling only stops the thumbstick teleport (XriTeleportAimer). /// public bool TeleportEnabled { @@ -240,6 +244,8 @@ private void Start() if (_xriInput == null) Logger.LogError("XriRig needs the XRI backend (XriInput). Sprinting and crouching won't work.", LogCat.VR); + else + CreateTeleportAimer(); } private void OnDestroy() @@ -254,6 +260,31 @@ private void Update() UpdateCrouch(); } + /// + /// Right thumbstick forward aims, releasing teleports, grip cancels. + /// + private void CreateTeleportAimer() + { + var map = _xriInput.Actions?.FindActionMap("XRI Right Locomotion"); + var stickAction = map?.FindAction("Teleport Mode"); + if (stickAction == null) + { + Logger.LogError("[XRI] Action 'XRI Right Locomotion/Teleport Mode' not found. " + + "Teleporting with the thumbstick won't work.", LogCat.VR); + return; + } + + var cancelAction = map.FindAction("Teleport Mode Cancel"); + if (cancelAction == null) + Logger.LogWarning("[XRI] Action 'XRI Right Locomotion/Teleport Mode Cancel' not found. " + + "Aiming a teleport can't be canceled.", LogCat.VR); + + // While crouching, the CharacterController is lower than the player once standing up at the target. + _teleportAimer.Init(stickAction, cancelAction, _rightController, _camera.transform, _characterController, + GetCollidingLayers(gameObject.layer), () => _teleportEnabled, () => IsGrounded, + () => CameraHeight + _crouchOffset, Teleport); + } + private void UpdateSprint() { var sprintAction = _xriInput?.GetGothicAction(XriGothicAction.Sprint) ?? default; diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs new file mode 100644 index 000000000..392d63b01 --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs @@ -0,0 +1,215 @@ +using System; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace Gothic.VR.Interaction.Backends.Xri +{ + /// + /// Player-initiated teleport: pushing the thumbstick forward shows an arc from the hand, releasing it teleports to + /// the arc's end if the player fits there. The world is created at runtime, so there are no TeleportationArea + /// interactables. The arc is checked against the physics world instead. The teleport itself is done by XRI's + /// TeleportationProvider (via the onTeleport callback). + /// Releasing at an invalid spot (red) does nothing. The cancel button (grip) cancels until the stick is released. + /// + public class XriTeleportAimer : MonoBehaviour + { + private const int _reticleSegments = 24; + + [SerializeField] private XriTeleportSettings _settings = new(); + + [Tooltip("Line of the aiming arc. Material, width and shadows are set on the component.")] + [SerializeField] private LineRenderer _arc; + + [Tooltip("Ring at the arc's end. Its points are written at runtime, so it needs Loop enabled.")] + [SerializeField] private LineRenderer _reticle; + + private Vector3[] _points; + + private InputAction _stickAction; + private InputAction _cancelAction; + private Transform _hand; + private Transform _head; + private CharacterController _character; + private LayerMask _blockingLayers; + private Func _isAllowed; + private Func _isGrounded; + private Func _getStandingHeight; + private Action _onTeleport; + private bool _isInitialized; + private bool _isAiming; + private bool _isCanceled; + private bool _hasTarget; + private Vector3 _target; + + /// + /// Called by XriRig, which owns the rig references. Aiming starts only after this. + /// + public void Init(InputAction stickAction, InputAction cancelAction, Transform hand, Transform head, + CharacterController character, LayerMask blockingLayers, Func isAllowed, Func isGrounded, + Func getStandingHeight, Action onTeleport) + { + _stickAction = stickAction; + _cancelAction = cancelAction; + _hand = hand; + _head = head; + _character = character; + _blockingLayers = blockingLayers; + _isAllowed = isAllowed; + _isGrounded = isGrounded; + _getStandingHeight = getStandingHeight; + _onTeleport = onTeleport; + + _stickAction.Enable(); + _cancelAction?.Enable(); + + _points = new Vector3[_settings.ArcSamples + 1]; + _reticle.positionCount = _reticleSegments; + SetVisible(false); + _isInitialized = true; + } + + private void Update() + { + if (!_isInitialized) + return; + + // The Sector interaction (North, Locked) performs when the stick is pushed forward first, stays performed + // when it's turned sideways and cancels once the stick returns to the center. A stick pushed sideways + // first (snap turn) never performs. + if (_stickAction.phase != InputActionPhase.Performed) + { + if (_isAiming && _hasTarget && _isAllowed() && _isGrounded()) + _onTeleport(_target, Quaternion.Euler(0f, _head.eulerAngles.y, 0f)); + Stop(); + _isCanceled = false; + return; + } + + if (_isCanceled) + return; + + if (_isAiming && _cancelAction != null && _cancelAction.WasPressedThisFrame()) + { + Stop(); + _isCanceled = true; + return; + } + + if (!_isAllowed()) + { + Stop(); + return; + } + + _isAiming = true; + UpdateArc(); + } + + private void Stop() + { + _isAiming = false; + _hasTarget = false; + SetVisible(false); + } + + private void UpdateArc() + { + _hasTarget = false; + + var origin = _hand.position; + var velocity = _hand.forward * _settings.ArcSpeed; + var gravity = Vector3.down * _settings.ArcGravity; + var step = _settings.ArcStep / _settings.ArcSpeed; + var previous = origin; + var normal = Vector3.up; + var isHit = false; + var count = 0; + _points[count++] = origin; + + for (var i = 1; i <= _settings.ArcSamples; i++) + { + var t = i * step; + var point = origin + velocity * t + 0.5f * t * t * gravity; + var segment = point - previous; + + if (Physics.SphereCast(previous, _settings.ArcRadius, segment.normalized, out var hit, + segment.magnitude, _blockingLayers, QueryTriggerInteraction.Ignore)) + { + _points[count++] = hit.point; + _target = hit.point; + normal = hit.normal; + isHit = true; + break; + } + + _points[count++] = point; + previous = point; + } + + // Mid-air (jumping, falling) the arc stays red, so that teleports can't extend jumps or cancel falls. + _hasTarget = isHit && _isGrounded() && IsValidTarget(_target, normal); + + var color = _hasTarget ? _settings.ValidColor : _settings.InvalidColor; + _arc.startColor = _arc.endColor = color; + _arc.positionCount = count; + _arc.SetPositions(_points); + _arc.enabled = true; + + if (isHit) + DrawReticle(_target, normal, color); + else + _reticle.enabled = false; + } + + /// + /// Flat enough to stand on, within the height the player could reach, reachable for the body and enough room + /// for the player's standing capsule (also while crouching, as the player stands up afterwards). + /// + private bool IsValidTarget(Vector3 point, Vector3 normal) + { + if (Vector3.Angle(normal, Vector3.up) > _character.slopeLimit) + return false; + + var feet = _character.bounds.min.y; + var heightDifference = point.y - feet; + if (heightDifference > _settings.MaxRise || -heightDifference > _settings.MaxDrop) + return false; + + var radius = _character.radius; + var height = Mathf.Max(_character.height, _getStandingHeight()); + var bottom = point + Vector3.up * (radius + _character.stepOffset); + var top = point + Vector3.up * Mathf.Max(height - radius, radius + _character.stepOffset); + + if (Physics.CheckCapsule(bottom, top, radius * 0.95f, _blockingLayers, QueryTriggerInteraction.Ignore)) + return false; + + // The arc can pass gaps and walls the body can't. The head has to get to where it'll be at the target. + var head = _head.position; + var targetHead = point + Vector3.up * (head.y - feet); + var path = targetHead - head; + + return !Physics.SphereCast(head, _settings.BodyPathRadius, path.normalized, out _, path.magnitude, + _blockingLayers, QueryTriggerInteraction.Ignore); + } + + private void DrawReticle(Vector3 center, Vector3 normal, Color color) + { + var rotation = Quaternion.FromToRotation(Vector3.up, normal); + for (var i = 0; i < _reticleSegments; i++) + { + var angle = i * Mathf.PI * 2f / _reticleSegments; + var local = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * _settings.ReticleRadius; + _reticle.SetPosition(i, center + normal * 0.02f + rotation * local); + } + + _reticle.startColor = _reticle.endColor = color; + _reticle.enabled = true; + } + + private void SetVisible(bool isVisible) + { + _arc.enabled = isVisible; + _reticle.enabled = isVisible; + } + } +} diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs.meta b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs.meta new file mode 100644 index 000000000..a6ebd5b91 --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f622519f5c2fe64a93dfdb569a97b2d \ No newline at end of file diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs new file mode 100644 index 000000000..836b005c5 --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs @@ -0,0 +1,44 @@ +using System; +using UnityEngine; + +namespace Gothic.VR.Interaction.Backends.Xri +{ + /// + /// Tuning of the player-initiated teleport (right thumbstick forward, release to teleport). + /// When aiming starts and ends is defined by the Sector interaction of 'XRI Right Locomotion/Teleport Mode'. + /// + [Serializable] + public class XriTeleportSettings + { + [Tooltip("Launch speed of the aiming arc in m/s. Higher values reach farther.")] + public float ArcSpeed = 9f; + + [Tooltip("Gravity of the aiming arc in m/s^2.")] + public float ArcGravity = 9.81f; + + [Tooltip("Distance in m between two arc samples.")] + public float ArcStep = 0.4f; + + [Tooltip("Number of arc samples. Together with ArcStep it limits the teleport distance.")] + public int ArcSamples = 40; + + [Tooltip("Radius in m of the sphere cast along the arc, so that it doesn't slip through bars and small gaps.")] + public float ArcRadius = 0.1f; + + [Tooltip("Radius in m of the sphere cast from the head to the head's position at the target. " + + "Blocks teleports through gaps the body doesn't fit through and over walls higher than the head.")] + public float BodyPathRadius = 0.15f; + + [Tooltip("Maximum height in m the target can be above the feet. Keeps roofs and walls out of reach.")] + public float MaxRise = 1f; + + [Tooltip("Maximum height in m the target can be below the feet.")] + public float MaxDrop = 3f; + + [Tooltip("Radius of the target ring in m.")] + public float ReticleRadius = 0.25f; + + public Color ValidColor = new(0.3f, 0.9f, 0.4f, 0.9f); + public Color InvalidColor = new(0.9f, 0.3f, 0.3f, 0.6f); + } +} diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs.meta b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs.meta new file mode 100644 index 000000000..63edf7751 --- /dev/null +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8f89e72ca2cc544e945c8106ebef437 \ No newline at end of file diff --git a/Docs/HVR-replacement/phase-1-rig-input-locomotion-ui.md b/Docs/HVR-replacement/phase-1-rig-input-locomotion-ui.md index cfabd2258..8c26f82fd 100644 --- a/Docs/HVR-replacement/phase-1-rig-input-locomotion-ui.md +++ b/Docs/HVR-replacement/phase-1-rig-input-locomotion-ui.md @@ -104,7 +104,11 @@ VR - Player XRI - `Gravity` maps to `GravityProvider.gravityAccelerationModifier` (relative to `Physics.gravity`), 0 disables gravity; the grounded sphere cast uses every layer the Player layer collides with. `IsGrounded` = `GravityProvider.isGrounded || CharacterController.isGrounded`. - - `TeleportEnabled` is only a flag for player-initiated teleports (no teleport interactor yet). The + - `TeleportEnabled` gates player-initiated teleports: `XriTeleportAimer` (right thumbstick forward = arc, release = + teleport, grip = cancel; driven by the `Sector` interaction of `Teleport Mode`). No `TeleportationArea`s, as the + world is runtime-built. Instead the arc is a sphere cast against the physics world, and a target is valid only when + grounded, within `MaxRise`/`MaxDrop` of the feet, flat enough, with room for the standing capsule and a free path + for the head (no teleports through bars or over high walls). Settings in `XriTeleportSettings`. The `TeleportationProvider` stays enabled, so `Teleport()` also works while the player is locked (dialogs, cinema). - `Teleport` queues `MatchOrientation.TargetUpAndForward` (the camera, not the origin, ends above the position and looks along the rotation; `default(Quaternion)` = identity). The HVR workaround of setting the rotation afterwards From 3d2735bdda81e58c838ddc1ff38d5584b2189ba8 Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Sun, 25 Oct 2026 05:39:28 +0100 Subject: [PATCH 5/6] fix: Door is in freeflow mode after grabbing. Fixed. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Adapters/Player/VRPlayerItemAdapter.cs | 7 ++++++- .../Backends/Xri/GothicGrabInteractable.cs | 11 ++++++++--- Docs/HVR-replacement/found-bugs.md | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Assets/Gothic-VR/Scripts/Adapters/Player/VRPlayerItemAdapter.cs b/Assets/Gothic-VR/Scripts/Adapters/Player/VRPlayerItemAdapter.cs index 9e50444b2..ae8a869ee 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/Player/VRPlayerItemAdapter.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/Player/VRPlayerItemAdapter.cs @@ -1,4 +1,5 @@ using Gothic.Core.Manager; +using Gothic.VR.Interaction.Components; using Gothic.VR.Interaction.Models; using Gothic.VR.Interaction.Ports; using Reflex.Attributes; @@ -46,12 +47,16 @@ private void OnBeforeGrabbed(VrGrabArgs args) if (args.Kind == VrGrabberKind.Socket || args.Interactable == null) return; + // The hand grabbers grab mobs (doors, chest lids, seats), NPCs and the backpack as well. Their physics + // stay as they are (e.g. VrHinge keeps a door leaf kinematic, XRI would restore physics on it on drop). + if (!VrSocketSlot.IsItem(args.Interactable)) + return; + // In Gothic, Items have no physics when lying around. We need to activate physics for HVR to properly move items into our hands. if (args.Interactable.TryGetComponent(out var rb)) { rb.isKinematic = false; } } - } } diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/GothicGrabInteractable.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/GothicGrabInteractable.cs index a7a03fbaa..d0b3f1103 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/GothicGrabInteractable.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/GothicGrabInteractable.cs @@ -21,12 +21,13 @@ namespace Gothic.VR.Interaction.Backends.Xri /// public class GothicGrabInteractable : XRGrabInteractable { - [Tooltip("Rigidbody interpolation while held. Restored on drop.")] - [SerializeField] private RigidbodyInterpolation _heldInterpolation = RigidbodyInterpolation.Interpolate; + [Tooltip("Rigidbody interpolation while held. Restored on drop.")] [SerializeField] + private RigidbodyInterpolation _heldInterpolation = RigidbodyInterpolation.Interpolate; [Tooltip("While a hand holds it, other hands can neither hover nor take it (the backpack: the other hand " + "takes items out of its slots).")] - [SerializeField] private bool _isOneHandOnly; + [SerializeField] + private bool _isOneHandOnly; private XriInteractableBridge _bridge; private RigidbodyInterpolation _interpolationBeforeGrab; @@ -130,6 +131,10 @@ protected override void Detach() return; } + // Kinematic leaves (doors, chest lids: VrHinge swings them on itself) aren't thrown either. + if (TryGetComponent(out var body) && body.isKinematic) + return; + base.Detach(); } diff --git a/Docs/HVR-replacement/found-bugs.md b/Docs/HVR-replacement/found-bugs.md index 97be2b5b8..8d9b8532f 100644 --- a/Docs/HVR-replacement/found-bugs.md +++ b/Docs/HVR-replacement/found-bugs.md @@ -54,3 +54,4 @@ Move them to GitHub issues when the branch is merged, if they are still open. - Simulator: Very complex to traverse. Why not having it like the one from https://github.com/AdrielHercules/hvr-simulator (aka Controller handling etc.) - Rotation + Scaling of objects inside Slots is broken - Lockpicking - finalizing the minigame should open the door directly. Currently: Nothing. +- Raycaster feels "wobbly". Should be a sharp and straight line From ce3d4844ac83ec0135d9534bd7db81a602ddf9ab Mon Sep 17 00:00:00 2001 From: JaXt0r Date: Sun, 25 Oct 2026 06:09:15 +0100 Subject: [PATCH 6/6] fix: XRI teleport uses Gothic slide angle and HVR distance checks Pull the right thumbstick back to aim, release to teleport (like HVR). Targets are valid if the ground is walkable per OpenGothic's testSlide (GIL_HUMAN slide_angle, real ground normal from a downward ray), within HVR's jump/drop distance of the capsule bottom, the standing capsule fits and the head has a free line of sight to the target. Co-Authored-By: Claude Sonnet 5.5 --- .../Resources/VR/Input/GothicVR.inputactions | 2 +- .../Interaction/Backends/Xri/XriRig.cs | 14 +++- .../Backends/Xri/XriTeleportAimer.cs | 70 +++++++++++++++---- .../Backends/Xri/XriTeleportSettings.cs | 26 ++++--- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/Assets/Gothic-VR/Resources/VR/Input/GothicVR.inputactions b/Assets/Gothic-VR/Resources/VR/Input/GothicVR.inputactions index c486c0e29..61b12c8fb 100644 --- a/Assets/Gothic-VR/Resources/VR/Input/GothicVR.inputactions +++ b/Assets/Gothic-VR/Resources/VR/Input/GothicVR.inputactions @@ -3143,7 +3143,7 @@ "name": "", "id": "62690862-4688-4010-975b-b3d9c6062157", "path": "{RightHand}/{Primary2DAxis}", - "interactions": "Sector(directions=1)", + "interactions": "Sector(directions=2)", "processors": "", "groups": "", "action": "Teleport Mode", diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs index e5f3aee06..eaa28799c 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriRig.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using Gothic.Core.Logging; +using Gothic.Core.Models.Vm; +using Gothic.Core.Services; using Gothic.VR.Adapters.Player; using Gothic.VR.Interaction.Models; using Gothic.VR.Interaction.Ports; @@ -26,6 +28,7 @@ namespace Gothic.VR.Interaction.Backends.Xri public class XriRig : MonoBehaviour, IVrRig { [Inject] private readonly IVrInput _vrInput; + [Inject] private readonly GameStateService _gameStateService; [Header("Rig")] [SerializeField] private XROrigin _origin; @@ -282,7 +285,16 @@ private void CreateTeleportAimer() // While crouching, the CharacterController is lower than the player once standing up at the target. _teleportAimer.Init(stickAction, cancelAction, _rightController, _camera.transform, _characterController, GetCollidingLayers(gameObject.layer), () => _teleportEnabled, () => IsGrounded, - () => CameraHeight + _crouchOffset, Teleport); + () => CameraHeight + _crouchOffset, GetMaxSlopeAngle, Teleport); + } + + /// + /// Slide angle (degrees) of the human guild from Gothic's GIL_Values. Steeper ground makes Gothic's player + /// slide. 0 if the script isn't loaded (the aimer then falls back to the CharacterController's slope limit). + /// + private float GetMaxSlopeAngle() + { + return _gameStateService?.GuildValues?.GetSlideAngle((int)VmGothicEnums.Guild.GIL_HUMAN) ?? 0; } private void UpdateSprint() diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs index 392d63b01..96e64a82b 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportAimer.cs @@ -5,7 +5,7 @@ namespace Gothic.VR.Interaction.Backends.Xri { /// - /// Player-initiated teleport: pushing the thumbstick forward shows an arc from the hand, releasing it teleports to + /// Player-initiated teleport: pulling the thumbstick back shows an arc from the hand, releasing it teleports to /// the arc's end if the player fits there. The world is created at runtime, so there are no TeleportationArea /// interactables. The arc is checked against the physics world instead. The teleport itself is done by XRI's /// TeleportationProvider (via the onTeleport callback). @@ -34,6 +34,7 @@ public class XriTeleportAimer : MonoBehaviour private Func _isAllowed; private Func _isGrounded; private Func _getStandingHeight; + private Func _getMaxSlopeAngle; private Action _onTeleport; private bool _isInitialized; private bool _isAiming; @@ -46,7 +47,7 @@ public class XriTeleportAimer : MonoBehaviour /// public void Init(InputAction stickAction, InputAction cancelAction, Transform hand, Transform head, CharacterController character, LayerMask blockingLayers, Func isAllowed, Func isGrounded, - Func getStandingHeight, Action onTeleport) + Func getStandingHeight, Func getMaxSlopeAngle, Action onTeleport) { _stickAction = stickAction; _cancelAction = cancelAction; @@ -57,6 +58,7 @@ public void Init(InputAction stickAction, InputAction cancelAction, Transform ha _isAllowed = isAllowed; _isGrounded = isGrounded; _getStandingHeight = getStandingHeight; + _getMaxSlopeAngle = getMaxSlopeAngle; _onTeleport = onTeleport; _stickAction.Enable(); @@ -73,7 +75,7 @@ private void Update() if (!_isInitialized) return; - // The Sector interaction (North, Locked) performs when the stick is pushed forward first, stays performed + // The Sector interaction (South, Locked) performs when the stick is pulled back first, stays performed // when it's turned sideways and cancels once the stick returns to the center. A stick pushed sideways // first (snap turn) never performs. if (_stickAction.phase != InputActionPhase.Performed) @@ -147,6 +149,9 @@ private void UpdateArc() } // Mid-air (jumping, falling) the arc stays red, so that teleports can't extend jumps or cancel falls. + if (isHit) + SnapToGround(ref _target, ref normal); + _hasTarget = isHit && _isGrounded() && IsValidTarget(_target, normal); var color = _hasTarget ? _settings.ValidColor : _settings.InvalidColor; @@ -162,17 +167,39 @@ private void UpdateArc() } /// - /// Flat enough to stand on, within the height the player could reach, reachable for the body and enough room - /// for the player's standing capsule (also while crouching, as the player stands up afterwards). + /// The sphere cast's normal is the contact normal of the sphere. On edges and steps it is tilted and says + /// nothing about the ground. A ray from above the hit (like HVR's downward ray) gives the surface the player + /// would stand on. + /// + private void SnapToGround(ref Vector3 point, ref Vector3 normal) + { + var origin = point + Vector3.up * _settings.GroundRayHeight; + if (Physics.Raycast(origin, Vector3.down, out var hit, _settings.GroundRayHeight * 2f, _blockingLayers, + QueryTriggerInteraction.Ignore)) + { + point = hit.point; + normal = hit.normal; + } + } + + /// + /// Walkable slope (OpenGothic MoveAlgo::testSlide: the NPC slides once normal.y < cos(slide_angle)), height + /// difference to the capsule's bottom (HVR CheckVerticalDistance / CheckFallDistance), enough room for the + /// player's standing capsule (also while crouching, as the player stands up afterwards) and a free line of + /// sight from the head (HVR CheckDestinationLineOfSight). /// private bool IsValidTarget(Vector3 point, Vector3 normal) { - if (Vector3.Angle(normal, Vector3.up) > _character.slopeLimit) + var maxSlopeAngle = _getMaxSlopeAngle(); + if (maxSlopeAngle <= 0f) + maxSlopeAngle = _character.slopeLimit; + if (normal.y < Mathf.Cos(maxSlopeAngle * Mathf.Deg2Rad)) return false; - var feet = _character.bounds.min.y; - var heightDifference = point.y - feet; - if (heightDifference > _settings.MaxRise || -heightDifference > _settings.MaxDrop) + // Same as HVR's GetBottom() for a CharacterController. + var capsuleBottom = _character.transform.TransformPoint(_character.center).y - _character.height * 0.5f; + if (point.y > capsuleBottom + _settings.MaxJumpDistance || + point.y < capsuleBottom - _settings.MaxDropDistance) return false; var radius = _character.radius; @@ -183,13 +210,28 @@ private bool IsValidTarget(Vector3 point, Vector3 normal) if (Physics.CheckCapsule(bottom, top, radius * 0.95f, _blockingLayers, QueryTriggerInteraction.Ignore)) return false; - // The arc can pass gaps and walls the body can't. The head has to get to where it'll be at the target. + return HasLineOfSight(point); + } + + /// + /// One free line from the head to any of the points above the target is enough, so that stairs and ledges + /// near the target don't block it, but walls between player and target do. + /// + private bool HasLineOfSight(Vector3 point) + { var head = _head.position; - var targetHead = point + Vector3.up * (head.y - feet); - var path = targetHead - head; + var samples = Mathf.Max(1, _settings.LineOfSightSamples); + + for (var i = 0; i < samples; i++) + { + var target = point + Vector3.up * (i / (float)samples * _settings.LineOfSightHeight); + var direction = target - head; + if (!Physics.Raycast(head, direction, direction.magnitude, _blockingLayers, + QueryTriggerInteraction.Ignore)) + return true; + } - return !Physics.SphereCast(head, _settings.BodyPathRadius, path.normalized, out _, path.magnitude, - _blockingLayers, QueryTriggerInteraction.Ignore); + return false; } private void DrawReticle(Vector3 center, Vector3 normal, Color color) diff --git a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs index 836b005c5..0c4af021d 100644 --- a/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs +++ b/Assets/Gothic-VR/Scripts/Interaction/Backends/Xri/XriTeleportSettings.cs @@ -4,7 +4,7 @@ namespace Gothic.VR.Interaction.Backends.Xri { /// - /// Tuning of the player-initiated teleport (right thumbstick forward, release to teleport). + /// Tuning of the player-initiated teleport (right thumbstick back, release to teleport). /// When aiming starts and ends is defined by the Sector interaction of 'XRI Right Locomotion/Teleport Mode'. /// [Serializable] @@ -25,15 +25,25 @@ public class XriTeleportSettings [Tooltip("Radius in m of the sphere cast along the arc, so that it doesn't slip through bars and small gaps.")] public float ArcRadius = 0.1f; - [Tooltip("Radius in m of the sphere cast from the head to the head's position at the target. " + - "Blocks teleports through gaps the body doesn't fit through and over walls higher than the head.")] - public float BodyPathRadius = 0.15f; + [Tooltip("Maximum height in m the target can be above the bottom of the player's capsule " + + "(HVR: MaxJumpDistance).")] + public float MaxJumpDistance = 1.5f; - [Tooltip("Maximum height in m the target can be above the feet. Keeps roofs and walls out of reach.")] - public float MaxRise = 1f; + [Tooltip("Maximum height in m the target can be below the bottom of the player's capsule " + + "(HVR: MaxDropDistance).")] + public float MaxDropDistance = 3f; - [Tooltip("Maximum height in m the target can be below the feet.")] - public float MaxDrop = 3f; + [Tooltip("Height in m above the target up to which the head needs a free line of sight " + + "(HVR: DestinationHeight).")] + public float LineOfSightHeight = 1f; + + [Tooltip("Number of lines of sight from the head to the target, spread over LineOfSightHeight. " + + "One free line is enough (HVR: DestinationIntervals).")] + public int LineOfSightSamples = 8; + + [Tooltip("Height in m above the arc's hit from where the ground is found with a downward ray. " + + "The ray reaches twice as far down.")] + public float GroundRayHeight = 0.3f; [Tooltip("Radius of the target ring in m.")] public float ReticleRadius = 0.25f;