From 2bd0f01019375ce83d7a9be733e3cfd12e3c62cd Mon Sep 17 00:00:00 2001 From: Kevin Darby Date: Sun, 23 Aug 2026 16:13:40 -0400 Subject: [PATCH 1/2] Add body-anchored holster inventory and two-handed grabbing Inventory: replace beam-raycast slot targeting with controller proximity, so storing and retrieving use the same test and neither depends on aim. Slots become holsters (two hips, one behind the back) on a body heading that only re-orients past a deadzone, positioned in world space so prefab parents cannot displace them. Stowed items no longer collide with or hurt the player, and approach, success and rejection all give haptic feedback. Two-handed grabbing (singleplayer, off by default): a second PhysGrabber built on its own object with its own beam, grab points and reticle. Hand resolution is scoped per grabber so carts, climbing and object manipulation follow the hand actually holding. Adds rebindable Off-Hand Grab and Off-Hand Interact controls, and reconciles PhysGrabObject.playerGrabbing so both hands can carry one object, which the view-id keyed grab RPCs otherwise collapse into one. Co-Authored-By: Claude Opus 5 --- Source/Assets/AssetCollection.cs | 4 + Source/Config.cs | 27 + Source/Input/OffHandControls.cs | 242 ++++++++ Source/Input/VRInputSystem.cs | 6 + Source/Patches/Item/OffHandInteractPatches.cs | 102 ++++ Source/Patches/PhysGrabObjectPatches.cs | 5 +- Source/Patches/PhysGrabberPatches.cs | 22 +- Source/Patches/Player/InventoryPatches.cs | 33 + .../Patches/Player/OffHandGrabberPatches.cs | 194 ++++++ Source/Patches/UI/CrosshairPatches.cs | 5 + .../ValuableArcticSnowBikePatches.cs | 5 +- Source/Player/VRInventory.cs | 534 +++++++++++++--- Source/Player/VRInventorySlot.cs | 50 +- Source/Player/VROffHandGrabber.cs | 572 ++++++++++++++++++ Source/Player/VRRig.cs | 92 ++- Source/UI/Crosshair.cs | 70 ++- Source/Utils.cs | 11 +- 17 files changed, 1841 insertions(+), 133 deletions(-) create mode 100644 Source/Input/OffHandControls.cs create mode 100644 Source/Patches/Item/OffHandInteractPatches.cs create mode 100644 Source/Patches/Player/OffHandGrabberPatches.cs create mode 100644 Source/Player/VROffHandGrabber.cs diff --git a/Source/Assets/AssetCollection.cs b/Source/Assets/AssetCollection.cs index ca9138b..065cb08 100644 --- a/Source/Assets/AssetCollection.cs +++ b/Source/Assets/AssetCollection.cs @@ -135,6 +135,10 @@ public static bool LoadAssets() return false; } + // Has to happen before PlayerInput takes ownership of the asset, so that saved binding overrides for the + // off-hand control can be matched up when they're loaded + Input.OffHandControls.Register(); + return true; } diff --git a/Source/Config.cs b/Source/Config.cs index 3b36792..7b75bae 100644 --- a/Source/Config.cs +++ b/Source/Config.cs @@ -47,6 +47,33 @@ public class Config(string assemblyPath, ConfigFile file) public ConfigEntry DetachedArms { get; } = file.Create("Gameplay", nameof(DetachedArms), false, "Whether your arms are attached to your body, or if they are separate"); + [ConfigDescriptor(customName: "Two-Handed Grabbing", trueText: "Enabled", falseText: "Disabled")] + public ConfigEntry TwoHandedGrab { get; } = file.Create("Gameplay", nameof(TwoHandedGrab), false, + "Gives your off hand its own grab beam, so you can hold a separate object in each hand. Singleplayer only: the off-hand grabber shares your player's network view, so it stays disabled in multiplayer sessions."); + + // Deliberately not called "Inventory*": earlier builds shipped keys by those names whose meaning has since + // changed, and BepInEx keeps whatever value is already in the config file. Renaming is what lets these defaults + // reach anyone who ran a previous version. + + [ConfigDescriptor(customName: "Holster Height", pointerSize: 0.01f, stepSize: 0.05f, suffix: "m")] + public ConfigEntry HolsterHeight { get; } = file.Create("Gameplay", nameof(HolsterHeight), -0.9f, + new ConfigDescription( + "How far below your head the inventory holsters sit. Adjust until they line up with your own waist.", + new AcceptableValueRange(-1.2f, -0.2f))); + + [ConfigDescriptor(customName: "Back Holster Height", pointerSize: 0.01f, stepSize: 0.05f, suffix: "m")] + public ConfigEntry BackHolsterHeight { get; } = + file.Create("Gameplay", nameof(BackHolsterHeight), -0.45f, + new ConfigDescription( + "How far below your head the holster behind your back sits. Raise it towards your shoulder or lower it towards your belt, depending on how you like to reach behind you.", + new AcceptableValueRange(-0.9f, -0.1f))); + + [ConfigDescriptor(customName: "Holster Reach", pointerSize: 0.005f, stepSize: 0.01f, suffix: "m")] + public ConfigEntry HolsterReach { get; } = file.Create("Gameplay", nameof(HolsterReach), 0.15f, + new ConfigDescription( + "How close your controller has to be to a holster to use it.", + new AcceptableValueRange(0.08f, 0.5f))); + [ConfigDescriptor] public ConfigEntry HapticFeedback { get; } = file.Create("Gameplay", nameof(HapticFeedback), HapticFeedbackOption.All, diff --git a/Source/Input/OffHandControls.cs b/Source/Input/OffHandControls.cs new file mode 100644 index 0000000..7f93bc9 --- /dev/null +++ b/Source/Input/OffHandControls.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RepoXR.Assets; +using RepoXR.Managers; +using UnityEngine.InputSystem; + +namespace RepoXR.Input; + +/// +/// Adds real, rebindable off-hand controls to the VR input asset and the controls menu. +/// +/// The off hand originally had no actions of its own: grabbing was inferred by mirroring "Grab" onto the opposite +/// hand, and using a held item had no off-hand control at all, so the dominant hand's "Interact" drove whatever +/// either hand was holding. Both are now proper actions the player can see and bind. +/// +/// Mirroring survives only as each action's *default*, so the controls still land somewhere sensible out of the +/// box. Anything the player binds themselves is left strictly alone. +/// +public static class OffHandControls +{ + private const string MapName = "VR Actions"; + + private const string GrabName = "Off-Hand Grab"; + private const string InteractName = "Off-Hand Interact"; + + /// + /// Fixed base for binding ids. Unity matches saved binding overrides by id, so these have to be stable across + /// launches or the player's rebind would be forgotten every time the game starts. + /// + private const string BindingIdBase = "5ac1f00d-0000-4000-8000-"; + + /// + /// Each off-hand action, and the dominant-hand action its default binding is mirrored from + /// + private static readonly (string name, string mirrorOf)[] Definitions = + [ + (GrabName, "Grab"), + (InteractName, "Interact") + ]; + + private static InputActionMap? Map => AssetCollection.VRInputs?.FindActionMap(MapName); + + /// + /// The off hand's grab control, or null if it couldn't be registered + /// + public static InputAction? Grab => Map?.FindAction(GrabName); + + /// + /// The off hand's "use the thing I'm holding" control, or null if it couldn't be registered + /// + public static InputAction? Interact => Map?.FindAction(InteractName); + + /// + /// Register the actions and their controls-menu entries. Must run before the input asset is handed to + /// PlayerInput, so that saved binding overrides can find them. + /// + public static void Register() + { + if (Map is not { } map) + { + Logger.LogError($"Couldn't find the \"{MapName}\" action map, the off hand will have no controls"); + return; + } + + for (var slot = 0; slot < Definitions.Length; slot++) + RegisterAction(map, Definitions[slot], slot); + } + + private static void RegisterAction(InputActionMap map, (string name, string mirrorOf) definition, int slot) + { + if (map.FindAction(definition.name) != null) + return; + + if (map.FindAction(definition.mirrorOf) is not { } source) + { + Logger.LogError( + $"Couldn't find \"{definition.mirrorOf}\" to derive a default for \"{definition.name}\" from"); + return; + } + + var wasEnabled = map.enabled; + if (wasEnabled) + map.Disable(); + + var action = map.AddAction(definition.name, InputActionType.Button); + + // One binding per control scheme, so that GetBindingIndex(scheme) resolves the same way it does for every + // other control. Schemes with nothing to mirror still get an (unbound) entry to keep that alignment. + var schemes = AssetCollection.VRInputs.controlSchemes; + + for (var i = 0; i < schemes.Count; i++) + { + var group = schemes[i].bindingGroup; + var index = source.GetBindingIndex(group); + var path = index >= 0 ? MirrorHand(source.bindings[index].path) : null; + + action.AddBinding(new InputBinding(path ?? "", groups: group) + { + id = new Guid($"{BindingIdBase}{slot:x4}{i:x8}") + }); + } + + if (wasEnabled) + map.Enable(); + + AssetCollection.RemappableControls.controls = + [ + ..AssetCollection.RemappableControls.controls, + new RemappableControl + { + controlName = definition.name, + headerName = "", + currentInput = InputActionReference.Create(action), + bindingIndex = 0, + toggleable = false, + defaultToggle = false + } + ]; + + Logger.LogInfo($"Registered \"{definition.name}\" across {schemes.Count} control scheme(s)"); + } + + /// + /// Re-derive the default binding for every off-hand control the player hasn't bound themselves. + /// + /// Needed because the defaults depend on which hand is dominant, and that can change at any time. Bindings are + /// edited rather than overridden, so a refreshed default still reads as "not customised" next time round and + /// never gets written into the player's saved overrides. + /// + public static void RefreshDefaults() + { + if (Map is not { } map) + return; + + var changes = new List<(InputAction action, int index, string path)>(); + + foreach (var (name, mirrorOf) in Definitions) + { + if (map.FindAction(name) is not { } action || map.FindAction(mirrorOf) is not { } source) + continue; + + for (var i = 0; i < action.bindings.Count; i++) + { + var binding = action.bindings[i]; + + // The player bound this one themselves, leave it be + if (!string.IsNullOrEmpty(binding.overridePath)) + continue; + + var index = source.GetBindingIndex(binding.groups); + if (index < 0) + continue; + + var path = MirrorHand(source.bindings[index].effectivePath); + + if (!string.IsNullOrEmpty(path) && path != binding.path) + changes.Add((action, i, path!)); + } + } + + if (changes.Count == 0) + return; + + // Bindings can't be edited while the map is live + var wasEnabled = map.enabled; + if (wasEnabled) + map.Disable(); + + foreach (var (action, index, path) in changes) + action.ChangeBinding(index).WithPath(path); + + if (wasEnabled) + map.Enable(); + + Logger.LogDebug($"Refreshed {changes.Count} default off-hand binding(s)"); + } + + /// + /// Warn about any off-hand control that shares a physical button with another action, since both would fire + /// together and the result feels broken rather than merely unbound + /// + public static void WarnAboutConflicts() + { + foreach (var (name, _) in Definitions) + { + if (Map?.FindAction(name) is not { } action) + { + Logger.LogWarning($"\"{name}\" has no control, so the off hand can't use it. " + + "Bind it under Settings > Controls."); + continue; + } + + if (FindConflict(action) is { } conflict) + Logger.LogWarning($"\"{name}\" shares a control with \"{conflict}\", so both fire at once. " + + "Rebind one of them under Settings > Controls."); + } + } + + /// + /// Take a control on the dominant hand and return the same control on the off hand + /// + private static string? MirrorHand(string? path) + { + if (string.IsNullOrEmpty(path)) + return null; + + var dominant = VRSession.IsLeftHanded ? "LeftHand" : "RightHand"; + var offHand = VRSession.IsLeftHanded ? "RightHand" : "LeftHand"; + + // Already on the off hand, nothing to mirror + if (path!.Contains(offHand)) + return path; + + return path.Contains(dominant) ? path.Replace(dominant, offHand) : null; + } + + /// + /// The name of another action bound to the same control as this one, if there is one + /// + private static string? FindConflict(InputAction action) + { + if (AssetCollection.VRInputs is not { } asset) + return null; + + var index = action.GetBindingIndex(VRInputSystem.Instance.CurrentControlScheme); + if (index < 0) + return null; + + var path = action.bindings[index].effectivePath; + if (string.IsNullOrEmpty(path)) + return null; + + return (from map in asset.actionMaps + from other in map.actions + where other.id != action.id + from binding in other.bindings + where !string.IsNullOrEmpty(binding.effectivePath) && + string.Equals(binding.effectivePath, path, StringComparison.OrdinalIgnoreCase) + select other.name).FirstOrDefault(); + } +} diff --git a/Source/Input/VRInputSystem.cs b/Source/Input/VRInputSystem.cs index 5c21627..b82101d 100644 --- a/Source/Input/VRInputSystem.cs +++ b/Source/Input/VRInputSystem.cs @@ -43,6 +43,10 @@ private void Awake() playerInput.ActivateInput(); + // The off hand's default control mirrors the dominant hand's, so it has to be resolved after the player's + // own overrides have been applied + OffHandControls.RefreshDefaults(); + inputToggle = JsonConvert.DeserializeObject>(Plugin.Config.InputToggleBindings.Value) ?? []; } @@ -203,6 +207,8 @@ private void DominantHandChanged(object sender, EventArgs e) } else playerInput.actions.LoadBindingOverridesFromJson(Plugin.Config.ControllerBindingsOverride.Value); + + OffHandControls.RefreshDefaults(); } private static void OnControlsChanged(PlayerInput input) diff --git a/Source/Patches/Item/OffHandInteractPatches.cs b/Source/Patches/Item/OffHandInteractPatches.cs new file mode 100644 index 0000000..8a5ed4a --- /dev/null +++ b/Source/Patches/Item/OffHandInteractPatches.cs @@ -0,0 +1,102 @@ +using System; +using HarmonyLib; +using RepoXR.Input; +using RepoXR.Player; +using UnityEngine.InputSystem; + +namespace RepoXR.Patches.Item; + +/// +/// Makes "use the item I'm holding" follow the hand that is actually holding it. +/// +/// is what nearly every usable item routes through - guns, lights, anything with an on/off +/// state - and it only asks two questions: is this object held by the local player, and is Interact pressed. Neither +/// question knows about hands, so with two grabbers the dominant hand's Interact drives whatever *either* hand is +/// holding, and the off hand has no way to use its own item. +/// +/// Rather than rewriting that logic, the input read itself is adjusted for the duration of an ItemToggle update. +/// Every other Interact read in the game is left exactly as it was. +/// +[RepoXRPatch] +internal static class OffHandInteractPatches +{ + private enum Source + { + /// Not an off-hand item; leave the read alone + Unchanged, + + /// Held by the off hand alone, so only its own control counts + OffHand, + + /// Carried in both hands, so either control counts + EitherHand + } + + /// + /// The item currently being updated, if any + /// + private static ItemToggle? current; + + [HarmonyPatch(typeof(ItemToggle), nameof(ItemToggle.Update))] + [HarmonyPrefix] + private static void BeginItemContext(ItemToggle __instance, out ItemToggle? __state) + { + // Saved and restored rather than cleared, in case these ever nest + __state = current; + current = __instance; + } + + [HarmonyPatch(typeof(ItemToggle), nameof(ItemToggle.Update))] + [HarmonyPostfix] + private static void EndItemContext(ItemToggle? __state) + { + current = __state; + } + + // Postfixes rather than prefixes on purpose: the original read still runs, so nothing can be lost if the + // adjustment below ever decides not to apply, and there is no skipped-original behaviour to get wrong + + [HarmonyPatch(typeof(SemiFunc), nameof(SemiFunc.InputDown))] + [HarmonyPostfix] + private static void RedirectInteractDown(InputKey key, ref bool __result) => + Apply(key, ref __result, action => action.WasPressedThisFrame()); + + [HarmonyPatch(typeof(SemiFunc), nameof(SemiFunc.InputHold))] + [HarmonyPostfix] + private static void RedirectInteractHold(InputKey key, ref bool __result) => + Apply(key, ref __result, action => action.IsPressed()); + + [HarmonyPatch(typeof(SemiFunc), nameof(SemiFunc.InputUp))] + [HarmonyPostfix] + private static void RedirectInteractUp(InputKey key, ref bool __result) => + Apply(key, ref __result, action => action.WasReleasedThisFrame()); + + private static void Apply(InputKey key, ref bool result, Func read) + { + var source = Resolve(key); + if (source == Source.Unchanged) + return; + + var offHand = OffHandControls.Interact is { } action && read(action); + + // Held only by the off hand: its control replaces the dominant one, so the dominant hand can no longer + // reach across and use it. Carried in both hands: either control works, which is what you'd expect when + // both of your hands are on the thing. + result = source == Source.OffHand ? offHand : result || offHand; + } + + /// + /// Only Interact reads made while updating an item the off hand has a grip on are adjusted. Everything else, + /// including the dominant hand's own items, is left untouched. + /// + private static Source Resolve(InputKey key) + { + if (key != InputKey.Interact || current is not { physGrabObject: { } item } || !item) + return Source.Unchanged; + + if (VROffHandGrabber.HeldByBothHands(item)) + return Source.EitherHand; + + return VROffHandGrabber.HeldByOffHandOnly(item) ? Source.OffHand : Source.Unchanged; + } +} diff --git a/Source/Patches/PhysGrabObjectPatches.cs b/Source/Patches/PhysGrabObjectPatches.cs index 7c7b19e..d39ba97 100644 --- a/Source/Patches/PhysGrabObjectPatches.cs +++ b/Source/Patches/PhysGrabObjectPatches.cs @@ -4,6 +4,7 @@ using HarmonyLib; using RepoXR.Managers; using RepoXR.Networking; +using RepoXR.Player; using UnityEngine; using static HarmonyLib.AccessTools; @@ -16,8 +17,10 @@ internal static class PhysGrabObjectPatches // Used instead of "GetHandOverrideTransform" as it is easier to use and isn't affected by overrides anyway private static Transform GetCartSteerTransform(PhysGrabber grabber) { + // Resolved through the grabber rather than straight to MainHand: with two grabbers, pushing a cart with the + // off hand would otherwise steer from wherever the dominant hand happened to be if (grabber.playerAvatar.isLocal) - return VRSession.Instance is { } session ? session.Player.MainHand : grabber.transform; + return VRSession.Instance is not null ? VROffHandGrabber.HandFor(grabber) : grabber.transform; return NetworkSystem.instance.GetNetworkPlayer(grabber.playerAvatar, out var networkPlayer) ? networkPlayer.PrimaryHand diff --git a/Source/Patches/PhysGrabberPatches.cs b/Source/Patches/PhysGrabberPatches.cs index b1599a0..b001dc0 100644 --- a/Source/Patches/PhysGrabberPatches.cs +++ b/Source/Patches/PhysGrabberPatches.cs @@ -82,7 +82,12 @@ private static void HapticFeedbackPatch(PhysGrabber __instance) if (grabbed + overcharge <= 0) return; - HapticManager.Impulse(HapticManager.Hand.Dominant, HapticManager.Type.Continuous, grabbed + overcharge); + // With two-handed grabbing the feedback has to follow the hand that's actually holding something + var hand = VROffHandGrabber.Instance is { } offHand && __instance == offHand.Grabber + ? HapticManager.Hand.Secondary + : HapticManager.Hand.Dominant; + + HapticManager.Impulse(hand, HapticManager.Type.Continuous, grabbed + overcharge); } /// @@ -168,7 +173,7 @@ private static void OnReleaseObject(PhysGrabber __instance) if (VRSession.Instance is not { } session) return; - session.Player.Rig.inventoryController.TryEquipItem(item); + session.Player.Rig.inventoryController.TryEquipItem(__instance, item); } /// @@ -250,9 +255,16 @@ private static IEnumerable HandBasedTumbleClimbPatch(IEnumerabl static Quaternion GetRotation(PhysGrabber grabber) { if (grabber.playerAvatar.isLocal) - return VRSession.InVR - ? VRSession.Instance.Player.MainHand.rotation * Quaternion.Euler(0, 180, 0) - : grabber.climbStickTransform.rotation; + { + if (!VRSession.InVR) + return grabber.climbStickTransform.rotation; + + var hand = VROffHandGrabber.Instance is { } offHand && grabber == offHand.Grabber + ? VRSession.Instance.Player.SecondaryHand + : VRSession.Instance.Player.MainHand; + + return hand.rotation * Quaternion.Euler(0, 180, 0); + } if (!NetworkSystem.instance) return grabber.climbStickTransform.rotation; diff --git a/Source/Patches/Player/InventoryPatches.cs b/Source/Patches/Player/InventoryPatches.cs index 2389ad3..2366dbb 100644 --- a/Source/Patches/Player/InventoryPatches.cs +++ b/Source/Patches/Player/InventoryPatches.cs @@ -208,6 +208,39 @@ private static void OnItemUnequip(InventorySpot __instance) session.Player.Rig.inventoryController.UnequipItem(__instance.CurrentItem); } + /// + /// Deliver a retrieved item to whichever hand actually reached into the slot. + /// + /// The game resolves "the hand that is grabbing" through the shared static, + /// which always points at the dominant grabber, so without this an item pulled out of a slot with the off hand + /// would still fly into the dominant hand. Swapping the static for the duration of the call is enough, since + /// is the point where the receiving grabber is picked. + /// + [HarmonyPatch(typeof(ItemEquippable), nameof(ItemEquippable.ForceGrab))] + [HarmonyPrefix] + private static void ForceGrabToRetrievingHand(ItemEquippable __instance, out PhysGrabber? __state) + { + __state = null; + + if (VRSession.Instance is not { Player.Rig.inventoryController: { } inventory }) + return; + + var grabber = inventory.RetrieveGrabberFor(__instance); + if (!grabber || grabber == PhysGrabber.instance) + return; + + __state = PhysGrabber.instance; + PhysGrabber.instance = grabber; + } + + [HarmonyPatch(typeof(ItemEquippable), nameof(ItemEquippable.ForceGrab))] + [HarmonyPostfix] + private static void RestoreForceGrabHand(PhysGrabber? __state) + { + if (__state) + PhysGrabber.instance = __state; + } + /// /// Hide the battery UI if it's equipped /// diff --git a/Source/Patches/Player/OffHandGrabberPatches.cs b/Source/Patches/Player/OffHandGrabberPatches.cs new file mode 100644 index 0000000..8f0ae00 --- /dev/null +++ b/Source/Patches/Player/OffHandGrabberPatches.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using RepoXR.Player; + +using static HarmonyLib.AccessTools; + +namespace RepoXR.Patches.Player; + +/// +/// Makes the shared hand-transform resolution () +/// and the shared grab-button reads (/ for +/// ) aware of which PhysGrabber instance is currently running, so the off-hand clone +/// (see ) resolves against the off-hand instead of the dominant hand. +/// +/// Note: PhysGrabber.Update() reads "is Grab held" via SemiFunc.InputHold(InputKey.Grab), which is a global read +/// completely independent of which PhysGrabber instance is asking - both the dominant-hand grabber and the +/// off-hand clone would otherwise see the exact same (dominant-hand-bound) value. This is the actual mechanism +/// that decides whether a grab starts *and* whether it continues to be held, so it has to be patched here at the +/// source rather than downstream in RayCheck (RayCheck's own "_grab" parameter is just an edge-triggered "attempt +/// a new grab this tick" pulse derived from this same read, not a continuous held state). +/// +/// Every scope below saves and restores the previous flag value rather than clearing it, since these methods nest: +/// Update() calls into RayCheck()/ObjectTurning()/StartGrabbingPhysObject(), and a postfix that unconditionally +/// cleared the flag would drop the rest of the enclosing Update() back onto the dominant hand. +/// +[RepoXRPatch] +internal static class OffHandGrabberPatches +{ + private static bool IsOffHand(PhysGrabber grabber) => + VROffHandGrabber.Instance is { } offHand && grabber == offHand.Grabber; + + private static void BeginScope(PhysGrabber grabber, out bool state) + { + state = PlayerLocalCameraExtensions.UseOffHand; + PlayerLocalCameraExtensions.UseOffHand = IsOffHand(grabber); + + // The off-hand grabber lives on an object of our own, which has to be moved into place before any of the + // grabber's own maths runs against it + if (PlayerLocalCameraExtensions.UseOffHand) + VROffHandGrabber.Instance!.SyncTransform(); + } + + private static void EndScope(bool state) + { + PlayerLocalCameraExtensions.UseOffHand = state; + } + + /// + /// Scope hand resolution to the off-hand for the duration of the off-hand grabber's own Update (and everything + /// it calls into, like RayCheck/ObjectTurning/StartGrabbingPhysObject). + /// + /// The off-hand's grab control is also sampled here rather than from VROffHandGrabber's own Update, because + /// script execution order between two components on the same GameObject is undefined and the grabber must never + /// read a stale "was pressed this frame" edge. + /// + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.Update))] + [HarmonyPrefix] + private static void UpdateBegin(PhysGrabber __instance, out bool __state) + { + BeginScope(__instance, out __state); + + if (PlayerLocalCameraExtensions.UseOffHand) + VROffHandGrabber.Instance!.PollInput(); + + // The clone claims this static in its own Start(); make sure it is pointing at the dominant grabber before + // any game code that reads it runs this frame + if (VROffHandGrabber.Instance is { } offHand && PhysGrabber.instance == offHand.Grabber && offHand.Primary) + PhysGrabber.instance = offHand.Primary; + } + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.Update))] + [HarmonyPostfix] + private static void UpdateEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.FixedUpdate))] + [HarmonyPrefix] + private static void FixedUpdateBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.FixedUpdate))] + [HarmonyPostfix] + private static void FixedUpdateEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.LateUpdate))] + [HarmonyPrefix] + private static void LateUpdateBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.LateUpdate))] + [HarmonyPostfix] + private static void LateUpdateEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.RayCheck))] + [HarmonyPrefix] + private static void RayCheckBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.RayCheck))] + [HarmonyPostfix] + private static void RayCheckEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.ObjectTurning))] + [HarmonyPrefix] + private static void ObjectTurningBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.ObjectTurning))] + [HarmonyPostfix] + private static void ObjectTurningEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.StartGrabbingPhysObject))] + [HarmonyPrefix] + private static void StartGrabbingBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.StartGrabbingPhysObject))] + [HarmonyPostfix] + private static void StartGrabbingEnd(bool __state) => EndScope(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.GrabStateClimb))] + [HarmonyPrefix] + private static void GrabStateClimbBegin(PhysGrabber __instance, out bool __state) => + BeginScope(__instance, out __state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.GrabStateClimb))] + [HarmonyPostfix] + private static void GrabStateClimbEnd(bool __state) => EndScope(__state); + + /// + /// Redirect every SemiFunc.InputHold(InputKey)/InputDown(InputKey) call made from within PhysGrabber.Update() + /// through an instance-aware wrapper. For any key other than Grab, or for any PhysGrabber that isn't our + /// off-hand clone, behavior is unchanged (falls straight through to the original SemiFunc call). + /// + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.Update))] + [HarmonyTranspiler] + private static IEnumerable RedirectGrabInputReads(IEnumerable instructions) + { + return new CodeMatcher(instructions) + .MatchForward(false, new CodeMatch(OpCodes.Call, Method(typeof(SemiFunc), nameof(SemiFunc.InputHold)))) + .Repeat(matcher => matcher + .InsertAndAdvance(new CodeInstruction(OpCodes.Ldarg_0)) + .SetInstructionAndAdvance(new CodeInstruction(OpCodes.Call, + ((Func)GetInputHold).Method))) + .Start() + .MatchForward(false, new CodeMatch(OpCodes.Call, Method(typeof(SemiFunc), nameof(SemiFunc.InputDown)))) + .Repeat(matcher => matcher + .InsertAndAdvance(new CodeInstruction(OpCodes.Ldarg_0)) + .SetInstructionAndAdvance(new CodeInstruction(OpCodes.Call, + ((Func)GetInputDown).Method))) + .InstructionEnumeration(); + } + + private static bool GetInputHold(InputKey key, PhysGrabber grabber) + { + if (key == InputKey.Grab && VROffHandGrabber.Instance is { } offHand && grabber == offHand.Grabber) + return offHand.GrabHeld; + + return SemiFunc.InputHold(key); + } + + private static bool GetInputDown(InputKey key, PhysGrabber grabber) + { + if (key == InputKey.Grab && VROffHandGrabber.Instance is { } offHand && grabber == offHand.Grabber) + return offHand.GrabDown; + + return SemiFunc.InputDown(key); + } + + /// + /// Scope hand resolution to the off-hand while the object currently being manipulated is the one held by the + /// off-hand clone, rather than whatever the dominant hand is holding. + /// + /// This needs its own scope because PhysGrabObject drives this from its own FixedUpdate, outside of any of the + /// grabber methods scoped above. + /// + [HarmonyPatch(typeof(PhysGrabObject), nameof(PhysGrabObject.PhysicsGrabbingManipulation))] + [HarmonyPrefix] + private static void BeginObjectHandContext(PhysGrabObject __instance, out bool __state) + { + __state = PlayerLocalCameraExtensions.UseOffHand; + + // Each grabber pulls on the object through its own grab point, so both hands carrying one thing works on + // its own. This flag only decides which hand is the *orientation* reference, and there can only be one + // answer - so when both hands are on it the dominant hand keeps that role. Flipping it per frame would + // make the object jitter between two reference frames. + PlayerLocalCameraExtensions.UseOffHand = VROffHandGrabber.HeldByOffHandOnly(__instance); + } + + [HarmonyPatch(typeof(PhysGrabObject), nameof(PhysGrabObject.PhysicsGrabbingManipulation))] + [HarmonyPostfix] + private static void EndObjectHandContext(bool __state) => EndScope(__state); +} diff --git a/Source/Patches/UI/CrosshairPatches.cs b/Source/Patches/UI/CrosshairPatches.cs index 72de43d..e254552 100644 --- a/Source/Patches/UI/CrosshairPatches.cs +++ b/Source/Patches/UI/CrosshairPatches.cs @@ -15,6 +15,11 @@ internal static class CrosshairPatches [HarmonyPostfix] private static void OnCrosshairCreate(Aim __instance) { + // The off-hand reticle is a copy of the finished crosshair, so the Aim inside it must not wrap itself in + // yet another canvas when it wakes up + if (__instance.GetComponentInParent()) + return; + var canvas = new GameObject("Crosshair").AddComponent(); var offset = new GameObject("Crosshair Offset") { transform = { parent = canvas.transform, localEulerAngles = new Vector3(270, 270, 0) } }; diff --git a/Source/Patches/Valuables/ValuableArcticSnowBikePatches.cs b/Source/Patches/Valuables/ValuableArcticSnowBikePatches.cs index 935c66c..3cb94c5 100644 --- a/Source/Patches/Valuables/ValuableArcticSnowBikePatches.cs +++ b/Source/Patches/Valuables/ValuableArcticSnowBikePatches.cs @@ -3,6 +3,7 @@ using System.Reflection.Emit; using HarmonyLib; using RepoXR.Managers; +using RepoXR.Player; using RepoXR.Networking; using UnityEngine; @@ -28,8 +29,10 @@ private static IEnumerable BikeHandSteeringPatch(IEnumerable +/// Body-anchored holster inventory. +/// +/// The slots sit at fixed places around the player's waist (left hip, right hip, small of the back) on a heading +/// that only re-orients once you actually turn, so they stay put while you look around and can be reached for +/// without looking at them. Everything is driven off how close a *controller* is to a holster - never off aim +/// direction, the grab beam, or collider overlap. +/// public class VRInventory : MonoBehaviour { - private const int MASK_INVENTORY = 1 << 29; + /// + /// Where each holster sits relative to the player's waist, in body space (X right, Z forward). The first three + /// are the layout proper; the rest only matter if the game ever hands us more than three inventory spots. + /// + /// + /// Each holster's place in body space (X right, Z forward), whether it hangs off the back rather than the + /// waist, and how forgiving its reach is relative to the configured radius. + /// + /// The back holster sits just right of the spine rather than out beside the shoulder, so it is found by putting + /// a hand behind your back the way most VR games expect. That is a blind reach, so it gets a wider radius than + /// the hips, which you can glance down at. + /// + private static readonly (Vector3 offset, bool back, float reach)[] HolsterLayout = + [ + (new Vector3(-0.26f, 0f, 0.02f), false, 1f), // Left hip + (new Vector3(0.26f, 0f, 0.02f), false, 1f), // Right hip + (new Vector3(0.09f, 0f, -0.20f), true, 1.6f), // Behind the back, just right of the spine + (new Vector3(-0.09f, 0f, -0.20f), true, 1.6f), // Behind the back, just left of the spine + (new Vector3(-0.30f, 0f, 0.24f), false, 1f), // Front left + (new Vector3(0.30f, 0f, 0.24f), false, 1f) // Front right + ]; + + /// + /// Storing gets a slightly wider radius than retrieving, since your hand sits further from the holster when + /// there's an object hanging off the end of the grab beam + /// + private const float StoreRadiusScale = 1.4f; + + /// + /// How long a holster stays latched after your hand leaves it, so that letting go just outside still stores + /// + private const float HoverStickyTime = 0.3f; + + /// + /// How long to wait for an equip to actually land before treating it as rejected. Generous on purpose: the + /// equip animation itself takes ~0.4s, and a missing buzz is far less confusing than a wrong one. + /// + private const float EquipConfirmTime = 1.2f; + + /// + /// How far you have to turn before the holsters re-orient to your new heading + /// + private const float BodyYawDeadzone = 45f; public Transform visualsTransform; - + [SerializeField] protected VRInventorySlot[] slots; - + [SerializeField] protected Color holdColor; [SerializeField] protected Color hoverColor; [SerializeField] protected Color equippedColor; @@ -22,139 +73,384 @@ public class VRInventory : MonoBehaviour public VRRig rig; private bool holdingItem; - private int hoveredSlot = -1; - + + // Storing + + private int storeSlot = -1; + private int stickySlot = -1; + private float stickyTimer; + + private int pendingEquipSlot = -1; + private float pendingEquipTimer; + private HapticManager.Hand pendingEquipHand; + + // Retrieving + + private PhysGrabber? retrieveGrabber; + private ItemEquippable? retrieveItem; + private float retrieveTimer; + + private int dominantReachSlot = -1; + private int offHandReachSlot = -1; + + // Body anchor + + private float bodyYaw; + private bool bodyYawValid; + + private static Transform DominantHand => VRSession.Instance.Player.MainHand; + private static Transform OffHand => VRSession.Instance.Player.SecondaryHand; + + private static float Radius => Plugin.Config.HolsterReach.Value; + private void Update() { - holdingItem = IsHoldingItem(); - - HandleHoldItem(); - HandleSlotHover(); - HandleSlotInteract(); + TickTimers(); + + UpdateBodyAnchor(); + + holdingItem = FindHoldingGrabber() != null; + + UpdateStoreTarget(); + UpdateReachTargets(); + UpdateSlotVisuals(); + } + + private void LateUpdate() + { + // Re-applied after everything else has moved for the frame, so the holsters don't trail the player by a + // frame. Deliberately does not touch the slots' own local positions, which VRInventorySlot.LateUpdate owns. + UpdateBodyAnchor(); + } + + private void TickTimers() + { + if (stickyTimer > 0) + stickyTimer -= Time.deltaTime; + + if (retrieveTimer > 0) + retrieveTimer -= Time.deltaTime; + + if (pendingEquipTimer <= 0) + return; + + pendingEquipTimer -= Time.deltaTime; + + // The equip never landed (spot cooldown, collision check, item state, ...). The base game fails these + // silently, which in VR just reads as "the inventory didn't work", so at least buzz about it + if (pendingEquipTimer <= 0) + { + HapticManager.Impulse(pendingEquipHand, HapticManager.Type.Impulse, 0.15f, 0.25f); + pendingEquipSlot = -1; + } } /// - /// The code for handling when an item is held by the player's phys grabber + /// Park the holster rig at the player's waist, on a heading that lags behind where they are looking. + /// + /// The pose is rebuilt from scratch every frame rather than adjusted in place - an earlier version rotated the + /// visuals relative to their own current rotation, which compounded every frame and span the holsters. /// - private void HandleHoldItem() + private void UpdateBodyAnchor() { - visualsTransform.localScale = Vector3.Lerp(visualsTransform.localScale, Vector3.one * (holdingItem ? 3 : 1), - 8 * Time.deltaTime); - visualsTransform.localPosition = Vector3.Lerp(visualsTransform.localPosition, - holdingItem ? new Vector3(0, -0.5f, 1.2f) : Vector3.zero, 8 * Time.deltaTime); - slots.Do(slot => slot.collider.transform.localScale = Vector3.Lerp(slot.collider.transform.localScale, - Vector3.one * (holdingItem ? 2 : 1), - 8 * Time.deltaTime)); + if (rig.head is not { } head) + return; - hoveredSlot = -1; + var headYaw = GetHeading(head); - if (!holdingItem || !GetBeamIntersect(out var hit)) - return; + if (!bodyYawValid) + { + bodyYaw = headYaw; + bodyYawValid = true; + } + + var delta = Mathf.DeltaAngle(bodyYaw, headYaw); + + // Inside the deadzone the holsters do not move at all; outside it they ease along until comfortably back + // within it, which is what makes their position learnable + if (Mathf.Abs(delta) > BodyYawDeadzone) + bodyYaw = Mathf.LerpAngle(bodyYaw, headYaw - Mathf.Sign(delta) * BodyYawDeadzone * 0.5f, + 8 * Time.deltaTime); + + var rotation = Quaternion.Euler(0, bodyYaw, 0); + var waist = head.position + Vector3.up * Plugin.Config.HolsterHeight.Value; + + // The back holster is anchored to the head rather than the waist, so tuning how low the hips sit does not + // drag the behind-the-back reach along with it + var back = head.position + Vector3.up * Plugin.Config.BackHolsterHeight.Value; + + // Kept in step with the holsters because ItemUnequipNoTeleport measures the inventory's height against the + // floor through it + visualsTransform.SetPositionAndRotation(waist, rotation); + visualsTransform.localScale = Vector3.one; for (var i = 0; i < slots.Length; i++) - if (hit.collider == slots[i].collider) - hoveredSlot = i; - - return; + { + var holster = Holster(i); - static bool GetBeamIntersect(out RaycastHit hit) + slots[i].SetAnchor((holster.back ? back : waist) + rotation * holster.offset, rotation); + } + } + + /// + /// Where a holster sits, what it hangs off, and how forgiving its reach is + /// + private (Vector3 offset, bool back, float reach) Holster(int index) + { + if (index < HolsterLayout.Length) + return HolsterLayout[index]; + + // More spots than we have a designed place for - ring the rest around the waist + return (Quaternion.Euler(0, index * 360f / slots.Length, 0) * new Vector3(0, 0, -0.3f), false, 1f); + } + + /// + /// Which way the player is facing, from the head's forward flattened onto the floor plane. + /// + /// Reading eulerAngles.y directly is not safe here: looking straight up or down flips it by 180 degrees, which + /// would whip the holsters around behind the player. + /// + private static float GetHeading(Transform head) + { + var forward = Vector3.ProjectOnPlane(head.forward, Vector3.up); + + // Looking (near enough) straight up or down - the top of the head still points somewhere useful + if (forward.sqrMagnitude < 0.001f) + forward = Vector3.ProjectOnPlane(head.up * -Mathf.Sign(head.forward.y), Vector3.up); + + if (forward.sqrMagnitude < 0.001f) + return 0; + + return Quaternion.LookRotation(forward.normalized, Vector3.up).eulerAngles.y; + } + + /// + /// Work out which holster the player is about to drop the item they are holding into + /// + private void UpdateStoreTarget() + { + var previous = storeSlot; + var hand = HapticManager.Hand.Dominant; + + storeSlot = IsHolding(PhysGrabber.instance) + ? FindHolster(DominantHand.position, StoreRadiusScale, true) + : -1; + + if (storeSlot < 0 && VROffHandGrabber.ActiveGrabber is { } offHand && IsHolding(offHand)) { - var beam = PhysGrabber.instance.physGrabBeamComponent; - var positions = new Vector3[beam.CurveResolution]; - var isOverridden = PhysGrabber.instance.overrideGrabDistance > 0; + storeSlot = FindHolster(OffHand.position, StoreRadiusScale, true); + hand = HapticManager.Hand.Secondary; + } - beam.lineRenderer.GetPositions(positions); - - for (var i = 0; i < positions.Length - (isOverridden ? 3 : 1); i++) - { - var a = positions[i]; - var b = positions[i + 1]; - var distance = Vector3.Distance(a, b) + (isOverridden ? 1 : 0); + if (storeSlot > -1) + { + stickySlot = storeSlot; + stickyTimer = HoverStickyTime; + } - if (Physics.Raycast(new Ray(a, b - a), out hit, distance, MASK_INVENTORY)) - return true; - } + if (storeSlot > -1 && storeSlot != previous) + HapticManager.Impulse(hand, HapticManager.Type.Impulse, 0.12f, 0.05f); + } - hit = default; - return false; + /// + /// Work out which holster each hand is reaching into, and act on the grab press + /// + private void UpdateReachTargets() + { + if (SemiFunc.RunIsArena() || PlayerController.instance.InputDisableTimer > 0) + { + dominantReachSlot = offHandReachSlot = -1; + return; } + + var dominant = FindHolster(DominantHand.position, 1f, false); + var offHand = FindHolster(OffHand.position, 1f, false); + + // Small buzz on approach, so a holster can be found without looking down at it + if (dominant > -1 && dominant != dominantReachSlot) + HapticManager.Impulse(HapticManager.Hand.Dominant, HapticManager.Type.Impulse, 0.12f, 0.05f); + + if (offHand > -1 && offHand != offHandReachSlot) + HapticManager.Impulse(HapticManager.Hand.Secondary, HapticManager.Type.Impulse, 0.12f, 0.05f); + + dominantReachSlot = dominant; + offHandReachSlot = offHand; + + if (dominant > -1 && Actions.Instance["Grab"].WasPressedThisFrame()) + Retrieve(dominant, PhysGrabber.instance, HapticManager.Hand.Dominant); + else if (offHand > -1 && (OffHandControls.Grab?.WasPressedThisFrame() ?? false)) + // Without two-handed grabbing there is no off-hand grabber to deliver the item to, so it goes to the + // dominant hand instead - reaching with the off hand still works, it just hands the item over + Retrieve(offHand, VROffHandGrabber.ActiveGrabber ?? PhysGrabber.instance, HapticManager.Hand.Secondary); } - private void HandleSlotHover() + /// + /// Find the holster nearest to a controller, within metres of it. + /// + /// Straight point-to-point proximity - no raycasts, no collider overlap, no aim direction involved. + /// + private int FindHolster(Vector3 hand, float scale, bool wantEmpty) { - slots.Do(slot => + var best = -1; + var bestDistance = float.MaxValue; + + for (var i = 0; i < slots.Length; i++) { + var slot = slots[i]; + + if (!slot.spot || !slot.spot.isActiveAndEnabled) + continue; + + // Storing needs a free holster, retrieving needs an occupied one + if (wantEmpty ? slot.heldItem : !slot.heldItem) + continue; + + // Checked against each holster's own radius, but ranked on raw distance, so a generous holster cannot + // steal a hand that is sitting squarely inside a tighter one + var distance = Vector3.Distance(slot.transform.position, hand); + if (distance > Radius * scale * Holster(i).reach || distance >= bestDistance) + continue; + + best = i; + bestDistance = distance; + } + + return best; + } + + private void UpdateSlotVisuals() + { + for (var i = 0; i < slots.Length; i++) + { + var slot = slots[i]; + if (!slot.spot || !slot.spot.isActiveAndEnabled) { slot.isHovered = false; + slot.isCollided = false; slot.targetColor = Color.clear; - - return; + + continue; } slot.isHolding = holdingItem; - slot.isHovered = slot.slotIndex == hoveredSlot; - - if (slot.slotIndex == hoveredSlot) + slot.isHovered = i == storeSlot; + slot.isCollided = i == dominantReachSlot || i == offHandReachSlot; + + if (slot.isHovered || slot.isCollided) slot.targetColor = hoverColor; - else if (holdingItem) + else if (holdingItem && !slot.heldItem) slot.targetColor = holdColor; else if (slot.heldItem) slot.targetColor = equippedColor; else slot.targetColor = Color.clear; - }); + } } - private void HandleSlotInteract() + private void Retrieve(int index, PhysGrabber grabber, HapticManager.Hand hand) { - if (SemiFunc.RunIsArena() || PlayerController.instance.InputDisableTimer > 0) + var slot = slots[index]; + + if (!slot.heldItem || !slot.spot || !slot.spot.isActiveAndEnabled) return; - - slots.Do(slot => - { - slot.isCollided = false; - - if (!slot.heldItem || !slot.spot.isActiveAndEnabled) - return; - - slot.isCollided = Utils.Collide(slot.collider, - VRSession.IsLeftHanded ? rig.leftHandCollider : rig.rightHandCollider); - - if (Actions.Instance["Grab"].WasPressedThisFrame() && slot.isCollided) - slot.spot.HandleInput(); - }); - } - - public void TryEquipItem(ItemEquippable item) - { - if (!holdingItem || hoveredSlot == -1) + + // Remember which hand asked, so the item is delivered there instead of always to the dominant hand + retrieveGrabber = grabber; + retrieveItem = slot.heldItem; + retrieveTimer = 1f; + + slot.spot.HandleInput(); + + HapticManager.Impulse(hand, HapticManager.Type.Impulse, 0.3f, 0.08f); + } + + /// + /// Which grabber a retrieval should deliver its item to. Falls back to the dominant grabber when the item + /// wasn't pulled out of a holster by hand (a forced unequip, for example). + /// + internal PhysGrabber RetrieveGrabberFor(ItemEquippable? item) + { + if (retrieveTimer > 0 && retrieveGrabber && (item == null || retrieveItem == item)) + return retrieveGrabber!; + + return PhysGrabber.instance; + } + + /// + /// Called when a grabber lets go of an equippable item, to store it in whichever holster it was released into + /// + public void TryEquipItem(PhysGrabber grabber, ItemEquippable item) + { + // Letting go with one hand while the other is still carrying it isn't a "put it away" gesture. This runs + // from a ReleaseObject prefix, so the releasing grabber still reports the item as held - both hands being + // on it here means the other hand keeps it. + if (item.physGrabObject && VROffHandGrabber.HeldByBothHands(item.physGrabObject)) + return; + + // Resolved fresh rather than reusing the value from Update(): this runs from a ReleaseObject prefix, and + // script execution order between this component and PhysGrabber is undefined + var target = FindHolster(VROffHandGrabber.HandFor(grabber).position, StoreRadiusScale, true); + + // Letting go just outside a holster still counts, as long as your hand was in it a moment ago + if (target < 0 && stickyTimer > 0 && stickySlot > -1 && !slots[stickySlot].heldItem) + target = stickySlot; + + if (target < 0) return; - var spot = slots[hoveredSlot].spot; - if (spot.isActiveAndEnabled) + var spot = slots[target].spot; + if (!spot || !spot.isActiveAndEnabled || spot.currentState != InventorySpot.SpotState.Empty) + return; + + var hand = VROffHandGrabber.Instance is { } offHand && grabber == offHand.Grabber + ? HapticManager.Hand.Secondary + : HapticManager.Hand.Dominant; + + pendingEquipSlot = target; + pendingEquipTimer = EquipConfirmTime; + pendingEquipHand = hand; + + stickyTimer = 0; + + // The dominant hand goes through the game's own input handler so that its cooldown and validation stay + // intact. The off hand can't: InventorySpot re-derives the item from the *dominant* grabber, so it would + // either store the wrong item or nothing at all - it gets told explicitly what to equip instead. + if (grabber == PhysGrabber.instance) spot.HandleInput(); + else + item.RequestEquip(spot.inventorySpotIndex); } public void EquipItem(ItemEquippable item) { - var slot = slots[item.equippedSpot.inventorySpotIndex]; + var slotIndex = item.equippedSpot.inventorySpotIndex; + var slot = slots[slotIndex]; if (GameDirector.instance.currentState != GameDirector.gameState.Main) item.transform.localScale = Vector3.one * 0.1667f * 2; // During state changes, the item is reduced in size by 50%, so double the current scale slot.heldItem = item; - + item.transform.parent = slot.transform; item.rb.interpolation = RigidbodyInterpolation.None; item.gameObject.SetLayerRecursively(6); - // Prevent getting hurt by item in inventory - if (item.TryGetComponent(out var melee) && melee.hurtCollider) - melee.hurtCollider.gameObject.SetActive(false); - + // Holsters sit against the player's body now, so a stowed item is physically inside them + slot.hurtColliders = item.GetComponentsInChildren(true); + SetStowed(item, true); + // Disable shadows item.GetComponentsInChildren().Do(mesh => mesh.shadowCastingMode = ShadowCastingMode.Off); + + // Confirm the equip that was requested, so it doesn't get reported as rejected + if (pendingEquipSlot != slotIndex) + return; + + pendingEquipSlot = -1; + pendingEquipTimer = 0; + + HapticManager.Impulse(pendingEquipHand, HapticManager.Type.Impulse, 0.4f, 0.06f); } public void UnequipItem(ItemEquippable item) @@ -162,6 +458,9 @@ public void UnequipItem(ItemEquippable item) var slot = slots[item.equippedSpot.inventorySpotIndex]; slot.heldItem = null; + slot.hurtColliders = []; + + SetStowed(item, false); item.transform.localPosition = Vector3.zero; item.transform.localRotation = Quaternion.identity; @@ -170,12 +469,58 @@ public void UnequipItem(ItemEquippable item) item.gameObject.SetLayerRecursively(16); item.enabled = true; - PhysGrabber.instance.OverrideGrab(item.physGrabObject, 0.25f); + RetrieveGrabberFor(item).OverrideGrab(item.physGrabObject, 0.25f); // Re-enable shadows item.GetComponentsInChildren().Do(mesh => mesh.shadowCastingMode = ShadowCastingMode.On); } + /// + /// Stop a stowed item from shoving or hurting the player. + /// + /// The old slots floated out in front of the player, so an equipped item never physically overlapped them. The + /// holsters sit on the body, so it now does: its colliders have to stop interacting with the player's, and its + /// hurt boxes have to go away entirely - the base game only ever disabled the melee one, and ItemMelee's own + /// state machine happily switches it back on while the item is stowed. + /// + private static void SetStowed(ItemEquippable item, bool stowed) + { + var players = GetPlayerColliders(); + + foreach (var collider in item.GetComponentsInChildren(true)) + { + if (!collider) + continue; + + foreach (var player in players) + if (player) + Physics.IgnoreCollision(collider, player, stowed); + } + + foreach (var hurt in item.GetComponentsInChildren(true)) + if (hurt) + hurt.gameObject.SetActive(!stowed); + } + + private static Collider[] GetPlayerColliders() + { + var colliders = new List(); + + if (PlayerController.instance is { col: { } col } && col) + colliders.Add(col); + + if (PlayerCollision.instance is { } collision) + { + if (collision.StandCollision) + colliders.AddRange(collision.StandCollision.GetComponentsInChildren(true)); + + if (collision.CrouchCollision) + colliders.AddRange(collision.CrouchCollision.GetComponentsInChildren(true)); + } + + return colliders.ToArray(); + } + public void PingSlot(int index, float amount, float frequency, float time) { slots[index].Ping(amount, frequency, time); @@ -186,14 +531,27 @@ public void HideSlots() slots.Do(slot => slot.Hide()); } - private static bool IsHoldingItem() + private static bool IsHolding(PhysGrabber? grabber) + { + return grabber && grabber!.enabled && grabber.grabbed && grabber.grabbedPhysGrabObject is { } held && + held.GetComponent(); + } + + /// + /// The grabber currently holding an equippable item, if any. Checks the off-hand grabber too, so that items + /// held in the off hand can be stored just like items held in the dominant hand. + /// + private static PhysGrabber? FindHoldingGrabber() { if (SemiFunc.RunIsArena() || PlayerController.instance.InputDisableTimer > 0) - return false; + return null; + + if (IsHolding(PhysGrabber.instance)) + return PhysGrabber.instance; - if (!PhysGrabber.instance.grabbed || PhysGrabber.instance.grabbedPhysGrabObject is not { } heldObject) - return false; + if (VROffHandGrabber.ActiveGrabber is { } offHand && IsHolding(offHand)) + return offHand; - return heldObject.GetComponent(); + return null; } -} \ No newline at end of file +} diff --git a/Source/Player/VRInventorySlot.cs b/Source/Player/VRInventorySlot.cs index 8d5bcc9..3fac5a0 100644 --- a/Source/Player/VRInventorySlot.cs +++ b/Source/Player/VRInventorySlot.cs @@ -19,6 +19,30 @@ public class VRInventorySlot : MonoBehaviour public Color targetColor = Color.clear; public ItemEquippable? heldItem; + + /// + /// Hurt boxes belonging to the stowed item, kept switched off for as long as it's in the holster + /// + public HurtCollider[] hurtColliders = []; + + private Vector3 anchorPosition; + private Quaternion anchorRotation; + private bool anchored; + + /// + /// Place this holster at a world pose. + /// + /// World rather than local on purpose: the slots sit under prefab parents whose own offsets and rotations this + /// code has no business assuming anything about, and setting local positions against them put every holster in + /// the wrong place (and two of them on top of each other). The pose is applied here in LateUpdate rather than by + /// the caller so that it always lands before the line renderer below is rebuilt from it. + /// + public void SetAnchor(Vector3 position, Quaternion rotation) + { + anchorPosition = position; + anchorRotation = rotation; + anchored = true; + } private float heightOffset; private Color currentColor = Color.clear; @@ -61,6 +85,12 @@ private void Update() Vector3.Lerp(heldItem.transform.localPosition, Vector3.zero, 5 * Time.deltaTime); heldItem.transform.localRotation = Quaternion.Slerp(heldItem.transform.localRotation, Quaternion.identity, 5 * Time.deltaTime); + + // The item's own state machine can switch its hurt boxes back on while it's sitting against the + // player's body, so they get held down rather than just disabled once on equip + foreach (var hurt in hurtColliders) + if (hurt && hurt.gameObject.activeSelf) + hurt.gameObject.SetActive(false); } } @@ -75,14 +105,24 @@ private void LateUpdate() pingTime += Time.deltaTime; } - transform.localPosition = new Vector3(transform.localPosition.x, pingValue * 0.0025f, transform.localPosition.z); + if (anchored) + transform.SetPositionAndRotation(anchorPosition + anchorRotation * (Vector3.up * (pingValue * 0.0025f)), + anchorRotation); + else + transform.localPosition = + new Vector3(transform.localPosition.x, pingValue * 0.0025f, transform.localPosition.z); + + // Holster mouth, lying flat in the slot's XZ plane. Sized to be a believable pouch you can see out of the + // corner of your eye and put a controller into, rather than the small marker this used to be. + const float halfWidth = 0.075f; + const float halfDepth = 0.11f; lineRenderer.positionCount = 4; lineRenderer.SetPositions([ - transform.TransformPoint(new Vector3(-0.045f, height, -0.07f)), - transform.TransformPoint(new Vector3(0.045f, height, -0.07f)), - transform.TransformPoint(new Vector3(0.045f, height, 0.07f)), - transform.TransformPoint(new Vector3(-0.045f, height, 0.07f)) + transform.TransformPoint(new Vector3(-halfWidth, height, -halfDepth)), + transform.TransformPoint(new Vector3(halfWidth, height, -halfDepth)), + transform.TransformPoint(new Vector3(halfWidth, height, halfDepth)), + transform.TransformPoint(new Vector3(-halfWidth, height, halfDepth)) ]); if (spot != null) diff --git a/Source/Player/VROffHandGrabber.cs b/Source/Player/VROffHandGrabber.cs new file mode 100644 index 0000000..4f86b8f --- /dev/null +++ b/Source/Player/VROffHandGrabber.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using RepoXR.Input; +using RepoXR.Managers; +using RepoXR.UI; +using UnityEngine; + +namespace RepoXR.Player; + +/// +/// Drives a second, off-hand so both hands can grab objects independently. +/// +/// Only spun up in singleplayer/offline sessions ( is false), since +/// PhysGrabber's grab state is broadcast over Photon RPCs tied to the player's single PhotonView. In singleplayer +/// the game calls the *_RPC methods directly instead of dispatching them, so a second grabber sharing that view is +/// harmless; in a real session both grabbers would resolve to the same view and stomp on each other's grab state. +/// +/// The second grabber is built onto a bare GameObject of our own rather than by cloning the player's. An earlier +/// version did clone it, and because the grabber shares its GameObject and neighbourhood with other player +/// components, waking those duplicates up tore down the originals - the arms, flashlight and map tool all went null +/// the instant the clone appeared. Everything this class creates now lives under one object it owns outright, so +/// nothing of the player's is duplicated and teardown is total. +/// +public class VROffHandGrabber : MonoBehaviour +{ + public static VROffHandGrabber? Instance { get; private set; } + + /// + /// Whether an off-hand grabber is allowed to exist right now + /// + public static bool Available => VRSession.InVR && Plugin.Config.TwoHandedGrab.Value && !SemiFunc.IsMultiplayer(); + + /// + /// The off-hand grabber, if one is currently active + /// + public static PhysGrabber? ActiveGrabber => + Instance is { Grabber: { enabled: true } grabber } ? grabber : null; + + public PhysGrabber Grabber { get; private set; } = null!; + + /// + /// The player's normal, dominant-hand grabber + /// + public PhysGrabber Primary => primaryGrabber; + + public bool GrabHeld { get; private set; } + public bool GrabDown { get; private set; } + + private PhysGrabber primaryGrabber = null!; + private List owned = []; + private int lastPollFrame = -1; + + private PhysGrabObject? offHandHeld; + private PhysGrabObject? primaryHeld; + + /// + /// The controller that drives a given grabber. Use this anywhere a hand transform is needed and the grabber is + /// known - resolving to MainHand unconditionally is what made the off hand steer carts from the wrong place. + /// + public static Transform HandFor(PhysGrabber? grabber) + { + var player = VRSession.Instance.Player; + + return Instance is { } offHand && grabber && grabber == offHand.Grabber + ? player.SecondaryHand + : player.MainHand; + } + + /// + /// The controller most likely doing the grabbing, for the cases where the game only tells us which player is + /// involved and not which grabber. Resolves to the off hand only while it is the sole hand holding anything. + /// + public static Transform ActiveHand + { + get + { + var player = VRSession.Instance.Player; + + if (ActiveGrabber is not { grabbed: true }) + return player.MainHand; + + return PhysGrabber.instance && PhysGrabber.instance.grabbed ? player.MainHand : player.SecondaryHand; + } + } + + /// + /// Whether the off hand is holding this object and the dominant hand isn't + /// + public static bool HeldByOffHandOnly(PhysGrabObject item) + { + return Holds(ActiveGrabber, item) && !Holds(PhysGrabber.instance, item); + } + + /// + /// Whether both hands are carrying this object at once + /// + public static bool HeldByBothHands(PhysGrabObject item) + { + return Holds(ActiveGrabber, item) && Holds(PhysGrabber.instance, item); + } + + private static bool Holds(PhysGrabber? grabber, PhysGrabObject item) + { + return grabber && grabber!.grabbed && grabber.grabbedPhysGrabObject == item; + } + + /// + /// Create the off-hand grabber, if the current session allows for one + /// + public static void Create() + { + if (Instance != null || !Available || VRSession.Instance is not { } session) + return; + + if (PlayerController.instance is not { } controller || + controller.playerAvatarScript is not { physGrabber: { } primary }) + return; + + try + { + Build(session, primary); + } + catch (Exception exception) + { + Logger.LogError($"Failed to create the off-hand grabber, turning two-handed grabbing off: {exception}"); + + Teardown(); + + Plugin.Config.TwoHandedGrab.Value = false; + } + } + + private static void Build(VRSession session, PhysGrabber primary) + { + Logger.LogDebug($"Off-hand grabber: primary grabber lives at {GetPath(primary.transform)}"); + + if (primary.physGrabBeamComponent) + Logger.LogDebug($"Off-hand grabber: beam lives at {GetPath(primary.physGrabBeamComponent.transform)}"); + + // Built inactive so that every reference can be fixed up before any Awake runs + var host = new GameObject("RepoXR Off-Hand Grabber") { layer = primary.gameObject.layer }; + host.SetActive(false); + host.transform.SetParent(primary.transform.parent, false); + host.transform.SetPositionAndRotation(primary.transform.position, primary.transform.rotation); + + var grabber = host.AddComponent(); + + CopyFields(primary, grabber); + + grabber.playerAvatar = primary.playerAvatar; + grabber.photonView = primary.photonView; + grabber.playerCamera = primary.playerCamera; + grabber.isLocal = true; + + // Each copy is made as a *sibling of its original*, keeping the exact parent it had on the dominant hand. + // Parenting them under our host instead put them in a different frame, and PhysGrabber does enough + // parent-relative maths on its grab points that the held object then tracked the body rather than the hand. + var beamRoot = primary.physGrabBeamComponent ? primary.physGrabBeamComponent.transform : null; + Transform? beamClone = null; + + // Every (original, copy) pair we make, so that any other field still pointing into the original can be + // redirected into our copy afterwards + var cloned = new List<(Transform original, Transform copy)>(); + + // Copies live outside our own hierarchy now, so they have to be cleaned up by hand on teardown + var owned = new List(); + + if (primary.physGrabBeamComponent is { } sourceBeam) + { + var copy = Instantiate(sourceBeam.gameObject, sourceBeam.transform.parent, true); + copy.name = "PhysGrabBeam (Off-Hand)"; + + beamClone = copy.transform; + cloned.Add((sourceBeam.transform, beamClone)); + owned.Add(copy); + + grabber.physGrabBeamComponent = copy.GetComponent(); + grabber.physGrabBeamScript = grabber.physGrabBeamComponent; + grabber.physGrabBeam = copy; + + Logger.LogDebug($"Off-hand grabber: beam copy parented to {GetPath(copy.transform.parent)}"); + } + + grabber.physGrabPoint = Detach(primary.physGrabPoint, nameof(primary.physGrabPoint)); + grabber.physGrabPointPuller = Detach(primary.physGrabPointPuller, nameof(primary.physGrabPointPuller)); + grabber.physGrabPointPlane = Detach(primary.physGrabPointPlane, nameof(primary.physGrabPointPlane)); + + // Anything else the grabber points at that lives inside something we copied (the grab point visuals, the + // rotation and grid transforms, ...) has to follow, otherwise both grabbers drive the dominant hand's + // visuals and they fight over them + RemapFields(grabber, cloned); + + if (grabber.physGrabBeamComponent is { } beam) + { + beam.playerAvatar = primary.playerAvatar; + beam.PhysGrabPoint = grabber.physGrabPoint; + beam.PhysGrabPointPuller = grabber.physGrabPointPuller; + beam.PhysGrabPointOrigin = session.Player.SecondaryHand; + beam.PhysGrabPointOriginLocal = session.Player.SecondaryHand; + } + + var offHand = host.AddComponent(); + offHand.Grabber = grabber; + offHand.primaryGrabber = primary; + offHand.owned = owned; + + Instance = offHand; + + host.SetActive(true); + + // PhysGrabber.Start() claims the shared static as soon as it wakes; hand it straight back + PhysGrabber.instance = primary; + + offHand.StartCoroutine(offHand.VerifyPlayerIntact(session)); + + Crosshair.CreateOffHand(); + + OffHandControls.WarnAboutConflicts(); + + Logger.LogInfo("Created off-hand grabber"); + + return; + + Transform Detach(Transform original, string field) + { + if (!original) + return original; + + Logger.LogDebug($"Off-hand grabber: {field} lives at {GetPath(original)}"); + + // Lives under the beam we just copied, so an equivalent already exists inside that copy + if (beamRoot && beamClone && original.IsChildOf(beamRoot!)) + return Remap(beamRoot!, beamClone!, original); + + var copy = Instantiate(original.gameObject, original.parent, true); + copy.name = $"{original.name} (Off-Hand)"; + + cloned.Add((original, copy.transform)); + owned.Add(copy); + + return copy.transform; + } + } + + /// + /// Redirect every grabber field that still points somewhere inside an original we copied at the corresponding + /// object inside our copy + /// + private static void RemapFields(PhysGrabber target, List<(Transform original, Transform copy)> cloned) + { + foreach (var field in typeof(PhysGrabber).GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + if (field.IsInitOnly || field.IsLiteral) + continue; + + try + { + var current = field.GetValue(target) switch + { + Transform transform => transform, + GameObject gameObject => gameObject.transform, + Component component => component.transform, + _ => null + }; + + if (!current) + continue; + + foreach (var (original, copy) in cloned) + { + // Already ours, or unrelated + if (!current!.IsChildOf(original)) + continue; + + var mapped = Remap(original, copy, current); + + object? value = field.FieldType == typeof(Transform) ? mapped : + field.FieldType == typeof(GameObject) ? mapped.gameObject : + mapped.GetComponent(field.FieldType); + + if (value != null) + field.SetValue(target, value); + + break; + } + } + catch (Exception exception) + { + Logger.LogDebug($"Off-hand grabber: couldn't remap field {field.Name} ({exception.GetType().Name})"); + } + } + } + + /// + /// Copy the primary grabber's serialized state onto the off-hand one. + /// + /// Collections are rebuilt rather than shared, and anything describing what is currently being held is cleared, + /// so the off hand starts empty instead of believing it is already holding the dominant hand's object. + /// + private static void CopyFields(PhysGrabber source, PhysGrabber target) + { + foreach (var field in typeof(PhysGrabber).GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + if (field.IsInitOnly || field.IsLiteral) + continue; + + try + { + field.SetValue(target, field.GetValue(source)); + } + catch (Exception exception) + { + Logger.LogDebug($"Off-hand grabber: skipped field {field.Name} ({exception.GetType().Name})"); + } + } + + target.physGrabPointVisualGridObjects = []; + target.discoverSkipList = []; + + target.grabbed = false; + target.grabbedPhysGrabObject = null; + target.grabbedObject = null; + target.grabbedObjectTransform = null; + target.grabbedStaticGrabObject = null; + target.grabbedPhysGrabObjectCollider = null; + } + + /// + /// A second grabber has broken the player's rig before, so check a frame later that everything critical is + /// still alive. If it isn't, back out and turn the setting off rather than leaving an unplayable session. + /// + private IEnumerator VerifyPlayerIntact(VRSession session) + { + yield return null; + + var rig = session.Player.Rig; + + var intact = rig && rig.head && rig.leftHandTip && rig.rightHandTip && + PlayerController.instance && PlayerController.instance.playerAvatarScript && + PlayerController.instance.playerAvatarScript.playerAvatarVisuals && + FlashlightController.Instance; + + if (intact) + yield break; + + Logger.LogError( + "Off-hand grabber: the player rig was damaged by creating a second grabber, tearing it back down and " + + "turning two-handed grabbing off. Please report this along with your log."); + + Teardown(); + + Plugin.Config.TwoHandedGrab.Value = false; + } + + /// + /// Tear down the off-hand grabber, releasing whatever it was holding + /// + public static void Teardown() + { + if (Instance == null) + return; + + var instance = Instance; + Instance = null; + + Crosshair.DestroyOffHand(); + + // Drop whatever the off hand was holding rather than leaving the object stuck to a destroyed grabber + if (instance.Grabber && instance.Grabber.grabbed) + instance.Grabber.ReleaseObject(-1); + + instance.ClearGrabberLists(); + + if (PhysGrabber.instance == instance.Grabber && instance.primaryGrabber) + PhysGrabber.instance = instance.primaryGrabber; + + Destroy(instance.gameObject); + } + + /// + /// Make sure the off-hand grabber matches the current session and configuration + /// + public static void Refresh() + { + if (Available) + Create(); + else + Teardown(); + } + + /// + /// Resolve the transform inside that corresponds to + /// under . Instantiate preserves hierarchy exactly, so sibling indices map one to one. + /// + private static Transform Remap(Transform root, Transform cloneRoot, Transform original) + { + var path = new List(); + + for (var transform = original; transform != root; transform = transform.parent) + path.Add(transform.GetSiblingIndex()); + + path.Reverse(); + + return path.Aggregate(cloneRoot, (current, index) => current.GetChild(index)); + } + + private static string GetPath(Transform transform) + { + var path = transform.name; + + for (var parent = transform.parent; parent; parent = parent.parent) + path = $"{parent.name}/{path}"; + + return path; + } + + /// + /// Temporarily suspend the off-hand grabber, used while the off hand is busy with something else (like the map) + /// + public void SetEnabled(bool enabled) + { + if (!enabled && Grabber && Grabber.grabbed) + Grabber.ReleaseObject(-1); + + if (Grabber) + Grabber.enabled = enabled; + + if (Grabber && Grabber.physGrabBeamComponent) + Grabber.physGrabBeamComponent.gameObject.SetActive(enabled); + } + + /// + /// Keep each held object's list of grabbers matching which hands are actually holding it. + /// + /// PhysGrabObject learns who is grabbing it through RPCs keyed on the grabbing player's PhotonView + /// (GrabPlayerAddRPC, GrabStartedRPC, GrabLinkRPC all take a view id). Both of our grabbers share the player's + /// single view on purpose, so when the second hand grabs, the game resolves that view, finds the grabber + /// already listed, and playerGrabbing stays one entry long - the object never learns there is a second hand on + /// it, and just follows whichever registered first. + /// + /// The list holds PhysGrabber instances though, and the physics reads it directly, so putting the real second + /// grabber into it is enough to make the object respond to both grab points the way it does for two players. + /// + private void ReconcileGrabberLists() + { + offHandHeld = Reconcile(Grabber, offHandHeld); + primaryHeld = Reconcile(primaryGrabber, primaryHeld); + } + + private static PhysGrabObject? Reconcile(PhysGrabber grabber, PhysGrabObject? previous) + { + var current = grabber && grabber.enabled && grabber.grabbed ? grabber.grabbedPhysGrabObject : null; + + // Let go of whatever this hand was holding before + if (previous && previous != current) + previous!.playerGrabbing.Remove(grabber); + + if (current && !current!.playerGrabbing.Contains(grabber)) + current.playerGrabbing.Add(grabber); + + return current; + } + + /// + /// Take this grabber back out of whatever it was holding, so a destroyed grabber is never left in an object's + /// list where the physics would keep pulling towards it + /// + private void ClearGrabberLists() + { + if (offHandHeld && Grabber) + offHandHeld!.playerGrabbing.Remove(Grabber); + + offHandHeld = null; + primaryHeld = null; + } + + /// + /// Keep our host object sitting exactly where the dominant grabber's object sits. + /// + /// The host is created once and parented alongside the primary grabber, but the primary's own transform is + /// driven every frame - so without this we sit at a stale pose forever. Anything PhysGrabber computes relative + /// to its own transform (its grab point and puller among them) is then frozen in that stale frame, which leaves + /// a grabbed object springing towards a fixed point in the world instead of towards the hand: held, but immovable. + /// + /// Driven from the patch prefixes rather than from Update so that it always lands before the grabber runs. + /// + internal void SyncTransform() + { + if (!primaryGrabber) + return; + + var source = primaryGrabber.transform; + + if (source.parent == transform.parent) + { + transform.localPosition = source.localPosition; + transform.localRotation = source.localRotation; + transform.localScale = source.localScale; + + return; + } + + transform.SetPositionAndRotation(source.position, source.rotation); + } + + /// + /// Sample the off-hand grab control. + /// + /// Driven from the off-hand PhysGrabber.Update prefix rather than from our own Update, since script execution + /// order between two components on the same GameObject is undefined and the grabber must never read a stale + /// "was pressed this frame" edge. + /// + internal void PollInput() + { + if (lastPollFrame == Time.frameCount) + return; + + lastPollFrame = Time.frameCount; + + var grab = OffHandControls.Grab; + + GrabHeld = grab?.IsPressed() ?? false; + GrabDown = grab?.WasPressedThisFrame() ?? false; + } + + private void Update() + { + // If someone joins mid-session, or two-handed grabbing gets turned off, tear ourselves down + if (!Available) + { + Teardown(); + return; + } + + // The dominant hand's crosshair may not have existed yet when we were built, so the off-hand reticle is + // created here rather than only once at startup + if (Crosshair.offHandInstance == null) + Crosshair.CreateOffHand(); + + // The grabber may re-derive these itself on its own Start(); keep them pinned to be safe + Grabber.playerAvatar = primaryGrabber.playerAvatar; + Grabber.photonView = primaryGrabber.photonView; + Grabber.isLocal = true; + + // Guard against the off hand stealing the shared static "local grabber" reference + PhysGrabber.instance = primaryGrabber; + + ReconcileGrabberLists(); + } + + private void OnDestroy() + { + if (Instance == this) + Instance = null; + + if (PhysGrabber.instance == Grabber && primaryGrabber) + PhysGrabber.instance = primaryGrabber; + + ClearGrabberLists(); + + // The beam and grab point copies sit next to the originals rather than under us, so they don't get + // destroyed along with this object + foreach (var copy in owned) + if (copy) + Destroy(copy); + + owned.Clear(); + } +} diff --git a/Source/Player/VRRig.cs b/Source/Player/VRRig.cs index a2979e3..f8fe1cb 100644 --- a/Source/Player/VRRig.cs +++ b/Source/Player/VRRig.cs @@ -87,6 +87,7 @@ private void Awake() Plugin.Config.LeftHandDominant.SettingChanged += OnDominantHandChanged; Plugin.Config.DetachedArms.SettingChanged += OnDetachedArmsChanged; + Plugin.Config.TwoHandedGrab.SettingChanged += OnTwoHandedGrabChanged; // Update on load OnDetachedArmsChanged(null!, null!); @@ -96,6 +97,9 @@ private void OnDestroy() { Plugin.Config.LeftHandDominant.SettingChanged -= OnDominantHandChanged; Plugin.Config.DetachedArms.SettingChanged -= OnDetachedArmsChanged; + Plugin.Config.TwoHandedGrab.SettingChanged -= OnTwoHandedGrabChanged; + + VROffHandGrabber.Teardown(); } private IEnumerator Start() @@ -127,6 +131,14 @@ private IEnumerator Start() // Update parents UpdateDominantTransforms(); + + // Second grab beam for the off hand (no-op unless enabled and in a singleplayer session) + VROffHandGrabber.Create(); + } + + private void OnTwoHandedGrabChanged(object sender, EventArgs args) + { + VROffHandGrabber.Refresh(); } private void LateUpdate() @@ -165,6 +177,12 @@ internal void UpdateDominantTransforms() PhysGrabber.instance.physGrabBeamComponent.PhysGrabPointOrigin = VRSession.Instance.Player.MainHand; + if (VROffHandGrabber.Instance is { Grabber.physGrabBeamComponent: { } offHandBeam }) + { + offHandBeam.PhysGrabPointOrigin = VRSession.Instance.Player.SecondaryHand; + offHandBeam.PhysGrabPointOriginLocal = VRSession.Instance.Player.SecondaryHand; + } + flashlight.transform.parent = headlampEnabled ? headLamp : VRSession.Instance.Player.SecondaryHand; flashlight.transform.localPosition = Vector3.zero; flashlight.transform.localRotation = Quaternion.identity; @@ -276,7 +294,9 @@ private void MapToolLogic() mapHeld = false; mapHeldLeftHand = false; mapTool.transform.parent.parent = map; - playerAvatar.physGrabber.enabled = true; + + SetHandGrabberEnabled(true, true); + SetHandGrabberEnabled(false, true); } mapHeld = mapTool.Active; @@ -326,12 +346,8 @@ private void MapToolLogic() VRMapTool.instance.leftHanded = false; flashlight.hideFlashlight = !headlampEnabled && VRSession.IsLeftHanded; - // Prevent picking up items while the map is opened - if (!VRSession.IsLeftHanded) - { - playerAvatar.physGrabber.ReleaseObject(-1); - playerAvatar.physGrabber.enabled = false; - } + // Prevent picking up items with the hand that's now holding the map + SetHandGrabberEnabled(false, false); } // Left hand pickup logic @@ -346,12 +362,8 @@ private void MapToolLogic() mapHeldLeftHand = true; flashlight.hideFlashlight = !headlampEnabled && !VRSession.IsLeftHanded; - // Prevent picking up items while the map is opened - if (VRSession.IsLeftHanded) - { - playerAvatar.physGrabber.ReleaseObject(-1); - playerAvatar.physGrabber.enabled = false; - } + // Prevent picking up items with the hand that's now holding the map + SetHandGrabberEnabled(true, false); } // Disable map when sprinting @@ -370,26 +382,56 @@ private void MapToolLogic() player.NetworkPlayer.UpdateMapRPC(mapHeldLeftHand, flashlight.hideFlashlight); } + /// + /// Enable or disable the grabber belonging to a specific hand. + /// + /// With two-handed grabbing both hands own a grabber, so picking the map up in either hand has to suspend that + /// hand's beam specifically rather than always suspending the dominant one. + /// + private void SetHandGrabberEnabled(bool leftHand, bool enabled) + { + if (VRSession.IsLeftHanded == leftHand) + { + if (!enabled) + playerAvatar.physGrabber.ReleaseObject(-1); + + playerAvatar.physGrabber.enabled = enabled; + + return; + } + + VROffHandGrabber.Instance?.SetEnabled(enabled); + } + /// /// Detects clipping through walls with the VR rig arms and disables grabbing and the cursor /// private void WallClipLogic() { - var mainCamera = CameraUtils.Instance.MainCamera.transform; - var direction = VRSession.Instance.Player.MainHand.position - mainCamera.position; + var player = VRSession.Instance.Player; - if (Physics.Raycast(new Ray(mainCamera.position, direction), out _, - Vector3.Distance(mainCamera.position, VRSession.Instance.Player.MainHand.position), Crosshair.LayerMask)) - { - // HIT! - Crosshair.instance.gameObject.SetActive(false); - PhysGrabber.instance.grabDisableTimer = 0.1f; - } - else + Check(player.MainHand, PhysGrabber.instance, Crosshair.instance); + + // The off hand has a beam of its own when two-handed grabbing is on, so it needs the same treatment - + // otherwise it can reach straight through a wall that the dominant hand is correctly blocked by + if (VROffHandGrabber.ActiveGrabber is { } offHand) + Check(player.SecondaryHand, offHand, Crosshair.offHandInstance); + + return; + + static void Check(Transform hand, PhysGrabber grabber, Crosshair? crosshair) { - // Not hit! + var mainCamera = CameraUtils.Instance.MainCamera.transform; + var direction = hand.position - mainCamera.position; + + var clipped = Physics.Raycast(new Ray(mainCamera.position, direction), out _, + Vector3.Distance(mainCamera.position, hand.position), Crosshair.LayerMask); + + if (clipped && grabber) + grabber.grabDisableTimer = 0.1f; - Crosshair.instance.gameObject.SetActive(true); + if (crosshair) + crosshair!.gameObject.SetActive(!clipped); } } diff --git a/Source/UI/Crosshair.cs b/Source/UI/Crosshair.cs index f8de0ec..649babb 100644 --- a/Source/UI/Crosshair.cs +++ b/Source/UI/Crosshair.cs @@ -1,4 +1,5 @@ using RepoXR.Managers; +using RepoXR.Player; using UnityEngine; namespace RepoXR.UI; @@ -6,15 +7,27 @@ namespace RepoXR.UI; public class Crosshair : MonoBehaviour { public static Crosshair instance; - + + /// + /// The off hand's own reticle, which only exists while two-handed grabbing is on + /// + public static Crosshair? offHandInstance; + public const int LayerMask = 1 << 0 | 1 << 9 | 1 << 10 | 1 << 16 | 1 << 20 | 1 << 23; - + + /// + /// Whether this reticle belongs to the off hand rather than the dominant one + /// + public bool isOffHand; + private new Transform camera; private Transform sprite; private void Awake() { - instance = this; + // The off-hand copy must not claim the static out from under the dominant hand's reticle + if (instance == null) + instance = this; camera = Camera.main!.transform; sprite = GetComponentInChildren(true).transform; @@ -22,7 +35,41 @@ private void Awake() private void OnDestroy() { - instance = null!; + if (instance == this) + instance = null!; + + if (offHandInstance == this) + offHandInstance = null; + } + + /// + /// Give the off hand a reticle of its own, copied from the dominant hand's finished one + /// + public static void CreateOffHand() + { + if (offHandInstance != null || instance == null) + return; + + var copy = Instantiate(instance.gameObject, instance.transform.parent); + copy.name = "Crosshair (Off-Hand)"; + + // The overcharge ring reads the dominant grabber's charge, so it would show the wrong value out here + foreach (var overcharge in copy.GetComponentsInChildren(true)) + Destroy(overcharge.gameObject); + + var crosshair = copy.GetComponent(); + crosshair.isOffHand = true; + + offHandInstance = crosshair; + } + + public static void DestroyOffHand() + { + if (offHandInstance == null) + return; + + Destroy(offHandInstance.gameObject); + offHandInstance = null; } private void Update() @@ -30,15 +77,24 @@ private void Update() if (VRSession.Instance is not { } session) return; - if (!Physics.Raycast(new Ray(session.Player.MainHand.position, session.Player.MainHand.forward), out var hit, - 10, LayerMask)) + var hand = isOffHand ? session.Player.SecondaryHand : session.Player.MainHand; + var grabber = isOffHand ? VROffHandGrabber.ActiveGrabber : PhysGrabber.instance; + + // An off-hand reticle is meaningless without an off-hand grabber behind it + if (isOffHand && !grabber) + { + transform.position = Vector3.down * 3000; + return; + } + + if (!Physics.Raycast(new Ray(hand.position, hand.forward), out var hit, 10, LayerMask)) { transform.position = Vector3.down * 3000; return; } // Hide the crosshair if we are holding an item (keep overcharge though) - if (PhysGrabber.instance.grabbedObject) + if (grabber && grabber!.grabbedObject) { sprite.position = Vector3.down * 3000; return; diff --git a/Source/Utils.cs b/Source/Utils.cs index 743e252..7edd3a7 100644 --- a/Source/Utils.cs +++ b/Source/Utils.cs @@ -193,6 +193,15 @@ public class WaitUntilTimeout(Func predicate, float timeout) : CustomYield public static class PlayerLocalCameraExtensions { + /// + /// While set, resolves to the off hand instead of the dominant hand. + /// + /// This is scoped around the off-hand 's own update/physics methods by + /// , since the game's grabber code has no concept of + /// "which hand is asking" - it just calls GetOverrideTransform() and gets a single answer. + /// + public static bool UseOffHand; + public static MethodInfo GetHandOverrideTransformMethod => AccessTools.Method(typeof(PlayerLocalCameraExtensions), nameof(GetHandOverrideTransform)); @@ -204,7 +213,7 @@ public static Transform GetHandOverrideTransform(this PlayerLocalCamera camera) // If we are in VR and the camera is local, return our VR hand transform if (VRSession.Instance is { } instance && camera.playerAvatar.isLocal) - return instance.Player.MainHand; + return UseOffHand ? instance.Player.SecondaryHand : instance.Player.MainHand; // If the player is a VR player, return their VR hand transform if (NetworkSystem.instance && NetworkSystem.instance.GetNetworkPlayer(camera.playerAvatar, out var player)) From ce09924d1406c455762fb087b167dc2f68d79130 Mon Sep 17 00:00:00 2001 From: Kevin Darby Date: Sun, 23 Aug 2026 16:33:35 -0400 Subject: [PATCH 2/2] Forward off-hand item grabber overrides to the correct hand Items pin how they are held by calling back into their grabber, resolved through the shared PhysGrabber.instance static that always points at the dominant hand. An item held in the off hand therefore pinned the dominant hand instead of itself: holding a gun and a hammer, the gun's fixed-distance override landed on the hammer, leaving the hammer stuck close and the gun free to push and pull. Track the item being updated and forward distance, grab point and grab strength overrides to the grabber actually holding it. Co-Authored-By: Claude Opus 5 --- .../Item/OffHandItemOverridePatches.cs | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 Source/Patches/Item/OffHandItemOverridePatches.cs diff --git a/Source/Patches/Item/OffHandItemOverridePatches.cs b/Source/Patches/Item/OffHandItemOverridePatches.cs new file mode 100644 index 0000000..08a0ab9 --- /dev/null +++ b/Source/Patches/Item/OffHandItemOverridePatches.cs @@ -0,0 +1,150 @@ +using HarmonyLib; +using RepoXR.Player; +using UnityEngine; + +namespace RepoXR.Patches.Item; + +/// +/// Makes an item's grabber overrides land on the hand that is actually holding it. +/// +/// Items reach back into their grabber to pin how they are held - a gun keeps itself at a fixed distance, melee +/// weapons adjust their grab point, and so on. Those calls resolve the grabber through the shared +/// static, which always points at the dominant hand, so an item held in the off +/// hand pins the *dominant* hand instead of itself. +/// +/// The visible result is a swap: hold a gun in one hand and a hammer in the other, and the gun's "stay close" +/// override lands on the hammer, leaving the hammer pinned and the gun free to be pushed and pulled - exactly +/// backwards. +/// +/// The item being updated is tracked, and any override addressed to the wrong grabber while an off-hand item is +/// updating is forwarded to the right one. +/// +[RepoXRPatch] +internal static class OffHandItemOverridePatches +{ + /// + /// The object belonging to the item currently being updated, if any + /// + private static PhysGrabObject? current; + + private static void Begin(PhysGrabObject? item, out PhysGrabObject? state) + { + // Saved and restored rather than cleared, in case these ever nest + state = current; + current = item; + } + + private static void End(PhysGrabObject? state) + { + current = state; + } + + [HarmonyPatch(typeof(ItemGun), nameof(ItemGun.Update))] + [HarmonyPrefix] + private static void GunUpdateBegin(ItemGun __instance, out PhysGrabObject? __state) => + Begin(__instance.physGrabObject, out __state); + + [HarmonyPatch(typeof(ItemGun), nameof(ItemGun.Update))] + [HarmonyPostfix] + private static void GunUpdateEnd(PhysGrabObject? __state) => End(__state); + + [HarmonyPatch(typeof(ItemGun), nameof(ItemGun.FixedUpdate))] + [HarmonyPrefix] + private static void GunFixedUpdateBegin(ItemGun __instance, out PhysGrabObject? __state) => + Begin(__instance.physGrabObject, out __state); + + [HarmonyPatch(typeof(ItemGun), nameof(ItemGun.FixedUpdate))] + [HarmonyPostfix] + private static void GunFixedUpdateEnd(PhysGrabObject? __state) => End(__state); + + [HarmonyPatch(typeof(ItemMelee), nameof(ItemMelee.Update))] + [HarmonyPrefix] + private static void MeleeUpdateBegin(ItemMelee __instance, out PhysGrabObject? __state) => + Begin(__instance.physGrabObject, out __state); + + [HarmonyPatch(typeof(ItemMelee), nameof(ItemMelee.Update))] + [HarmonyPostfix] + private static void MeleeUpdateEnd(PhysGrabObject? __state) => End(__state); + + [HarmonyPatch(typeof(ItemMelee), nameof(ItemMelee.FixedUpdate))] + [HarmonyPrefix] + private static void MeleeFixedUpdateBegin(ItemMelee __instance, out PhysGrabObject? __state) => + Begin(__instance.physGrabObject, out __state); + + [HarmonyPatch(typeof(ItemMelee), nameof(ItemMelee.FixedUpdate))] + [HarmonyPostfix] + private static void MeleeFixedUpdateEnd(PhysGrabObject? __state) => End(__state); + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.OverrideGrabDistance))] + [HarmonyPrefix] + private static bool GrabDistance(PhysGrabber __instance, float dist) + { + if (!ShouldForward(__instance, out var target)) + return true; + + target.OverrideGrabDistance(dist); + + return false; + } + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.OverrideMinimumGrabDistance))] + [HarmonyPrefix] + private static bool MinimumGrabDistance(PhysGrabber __instance, float dist) + { + if (!ShouldForward(__instance, out var target)) + return true; + + target.OverrideMinimumGrabDistance(dist); + + return false; + } + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.OverrideGrabPoint))] + [HarmonyPrefix] + private static bool GrabPoint(PhysGrabber __instance, Transform grabPoint) + { + if (!ShouldForward(__instance, out var target)) + return true; + + target.OverrideGrabPoint(grabPoint); + + return false; + } + + [HarmonyPatch(typeof(PhysGrabber), nameof(PhysGrabber.OverrideGrabStrength))] + [HarmonyPrefix] + private static bool GrabStrength(PhysGrabber __instance, float strength, float time) + { + if (!ShouldForward(__instance, out var target)) + return true; + + target.OverrideGrabStrength(strength, time); + + return false; + } + + /// + /// Whether this override was aimed at the wrong grabber, and which one it should go to instead. + /// + /// Forwarding is deliberately narrow: it only applies while an item is being updated, only when that item is + /// held by the off hand alone, and never to a call already addressed to the off-hand grabber - which also stops + /// the forwarded call from bouncing back here. + /// + private static bool ShouldForward(PhysGrabber instance, out PhysGrabber target) + { + target = null!; + + if (current is not { } item || !item) + return false; + + if (VROffHandGrabber.ActiveGrabber is not { } offHand || instance == offHand) + return false; + + if (!VROffHandGrabber.HeldByOffHandOnly(item)) + return false; + + target = offHand; + + return true; + } +}