using UnityEngine; using VRC.SDKBase; using VRC.Udon.Common; using UdonSharp; using VRC.Udon.Wrapper.Modules; namespace Superbstingray { [UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)] public class UdonPlatformHook : UdonSharp.UdonSharpBehaviour { [Tooltip("Layers that the Player will move with.")] public LayerMask hookLayerMask; [Tooltip("Vertical distance between the Player and Platform before the Script Unhooks from Colliders. You may want to increase this value if your world has a higher than average jump impulse.")] public float hookDistance = 0.75F; [Tooltip("Will make the Player keep their Velocity + the Platforms Velocity when they detach from it.")] public bool inheritVelocity = true; [Tooltip("Will Immobilize(true) players when standing still on moving platforms to prevent Avatars from having IK drift / IK walk.")] public bool reduceIKDrift = true; [Tooltip("Try Detect if the Player has their Main Menu open and stop moving them. Will help with menu interactions.")] public bool mainMenuPause = true; [Tooltip("Try Detect if the Player has their Quick Menu open and stop moving them. Will help with menu interactions.")] public bool quickMenuPause = false; BoxCollider platformOverride; Collider[] colliderArray; Collider sceneCollider; Quaternion headRotation; RaycastHit hitInfo; Transform PlayerOffset, playerTracker, hook; Vector3 playerVelocity, hookLastPos, hookOffsetPos, lastFramePos; VRCPlayerApi localPlayer; bool menuOpen; float InputMoveH, InputMoveV; int unhookThreshold, localColliders, intUI; [FieldChangeCallback(nameof(isHookedCallback))] bool isHooked; bool isHookedCallback { set { isHooked = value; if (isHooked) // isHooked=true functions { hook.localPosition = Vector3.zero; hook.eulerAngles = Vector3.zero; platformOverride.enabled = true; PlayerOffset.SetPositionAndRotation(hook.position, hook.rotation); // When hooking count the number of PlayerLocal colliders the player has // as this will help us know when the Player enters a station. localColliders = Mathf.Clamp(Physics.OverlapSphere((localPlayer.GetPosition()), 1024f, 1024).Length, 1, 100); } else // isHooked=false functions { if(inheritVelocity) { localPlayer.SetVelocity(playerVelocity); } // Sets the players velocity to their actual worldspace velocity when they Unhook. OverridesOff(); hook.localPosition = Vector3.zero; hook.eulerAngles = Vector3.zero; PlayerOffset.SetPositionAndRotation(hook.position, hook.rotation); } } } void Start() { InitializeVariables(); IgnoreSceneCollision(); } void FixedUpdate() { FixedUpdateFunctions(); } void LateUpdate() { LateUpdateFunctions(); } void FixedUpdateFunctions() { if (!VRC.SDKBase.Utilities.IsValid(localPlayer)) { return; } if (isHooked && inheritVelocity) // Average the last X frames of the players global velocity. { playerVelocity = Vector3.ClampMagnitude((playerVelocity * 3f + (localPlayer.GetPosition() - lastFramePos) / Time.deltaTime) / 4f, 100f); lastFramePos = localPlayer.GetPosition(); } if (!menuOpen) { // Override_Spherecast. Set position of override collider. Physics.SphereCast(localPlayer.GetPosition() + new Vector3(0F, .3f, 0f), 0.25f, Vector3.down, out hitInfo, 10f, hookLayerMask.value); platformOverride.center = hitInfo.point; // FixedUpdate_Spherecast. Check for valid platforms. // Add to the unhookThreshold if it misses a valid platform and unhook if unhookThreshold is greater than X. if (!Physics.SphereCast(localPlayer.GetPosition() + new Vector3(0f, .3f, 0f), 0.25f, Vector3.down, out hitInfo, hookDistance + .3f, hookLayerMask.value)) { unhookThreshold++; if (unhookThreshold > 10 && isHooked) { hook.parent = transform; SetProgramVariable(nameof(isHooked), false); SendCustomEventDelayedSeconds(nameof(OverridesOff), 0.5f); } } } } void LateUpdateFunctions() { if (!VRC.SDKBase.Utilities.IsValid(localPlayer)) { return; } #if !UNITY_EDITOR if (isHooked) // Count the number of InterntalUI colliders as a means to know if the menu is open or not. { intUI = Physics.OverlapSphereNonAlloc(localPlayer.GetPosition(), 10f, colliderArray, 524288); menuOpen = (mainMenuPause && (intUI >= 7 && intUI <= 14)) || (quickMenuPause && (intUI >= 15 && intUI <= 19)); } if (isHooked && !menuOpen) // Move the parented hook to the Players position { hookLastPos = hook.position; hook.position = localPlayer.GetPosition(); hookOffsetPos = hook.position - hookLastPos; } if (isHooked && menuOpen) { localPlayer.SetVelocity(Vector3.zero); } // Override Player Velocity to make it easier to use their menu. // Teleport the player to the new offset position only if the players PlayerLocal collider count // didn't decrease otherwise assume the player entered a station. if (isHooked && !menuOpen) { playerTracker.SetPositionAndRotation(localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Origin).position, localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Origin).rotation); PlayerOffset.SetPositionAndRotation(hook.position, hook.rotation); if (Physics.OverlapSphere(localPlayer.GetPosition(), 1024f, 1024).Length >= localColliders) { localPlayer.TeleportTo(playerTracker.position - hookOffsetPos, playerTracker.rotation, VRC_SceneDescriptor.SpawnOrientation.AlignRoomWithSpawnPoint, true); } } #else // "EditorOnly" duplicate funtion from above specifically for CyanEmu/ClientSim as Origin tracking does not behave the same in editor as in client. if (isHooked) { playerTracker.SetPositionAndRotation(localPlayer.GetPosition(), localPlayer.GetRotation()); PlayerOffset.SetPositionAndRotation(hook.position, hook.rotation); if (Physics.OverlapSphere(localPlayer.GetPosition(), 1024f, 1024).Length >= localColliders) { localPlayer.TeleportTo(playerTracker.position - hookOffsetPos, playerTracker.rotation, VRC_SceneDescriptor.SpawnOrientation.AlignPlayerWithSpawnPoint, true); hookOffsetPos = Vector3.zero; } } #endif // Players in Desktop or 3 Point tracking will have their Inverse Kinematics drag behind and "IK Walk" while being moved when they aren't intentionally locomoting. // This function is to prevent that from occuring by Immobilizing the player when they are hooked to a platform and aren't moving relative to the platform. // This is scuffed and I should optimize it. if(reduceIKDrift && isHooked) { headRotation = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).rotation; localPlayer.Immobilize(!(InputMoveH * 0.1f + InputMoveV != 0f) && Mathf.Abs(playerVelocity.x) + Mathf.Abs(playerVelocity.z) > .1f && Mathf.Abs(localPlayer.GetVelocity().x) + Mathf.Abs(localPlayer.GetVelocity().z) < .01f && Quaternion.Angle(new Quaternion(0f, headRotation.y, 0f, headRotation.w).normalized, localPlayer.GetRotation()) < 90f); } // LateUpdate_Spherecast. Check for valid platforms. if (Physics.SphereCast(localPlayer.GetPosition() + new Vector3(0f, .3f, 0f), 0.25f, Vector3.down, out hitInfo, hookDistance + .3f, hookLayerMask.value)) { unhookThreshold = 0; if (unhookThreshold < 10 && (localPlayer.IsPlayerGrounded())) // Hook to the valid platform if the Player is grounded. { hook.parent = hitInfo.transform; SetProgramVariable(nameof(isHooked), true); } } } // INPUT EVENTS: MoveVertical, MoveHorizontal, InputJump. public override void InputMoveVertical(float Value, UdonInputEventArgs InputMoveVerticalArgs) { InputMoveV = InputMoveVerticalArgs.floatValue; } public override void InputMoveHorizontal(float Value, UdonInputEventArgs InputMoveHorizontalArgs) { InputMoveH = InputMoveHorizontalArgs.floatValue; } public override void InputJump(bool outputJumpBool, UdonInputEventArgs inputJumpArgs) { if (reduceIKDrift) { localPlayer.Immobilize(false); } } // Reset prefab state and call unhook on Respawn. public override void OnPlayerRespawn(VRCPlayerApi onPlayerRespawnPlayer) { hook.parent = transform; unhookThreshold = System.Int32.MaxValue; localPlayer.SetVelocity(Vector3.zero); SetProgramVariable(nameof(isHooked), false); OverridesOff(); } // The prefab uses an additional overriding collider to prevent the VRC player controller from being affected by moving world // colliders which can affect locomotion and animations, and this is to prevent the override collider from interacting with other colliders in the scene. public void IgnoreSceneCollision() { SendCustomEventDelayedSeconds(nameof(IgnoreSceneCollision), 60f); colliderArray = Physics.OverlapSphere(Vector3.zero, System.Single.MaxValue); for (int i = 0; i < colliderArray.Length; i++) { sceneCollider = colliderArray[i]; if (VRC.SDKBase.Utilities.IsValid(sceneCollider)) { Physics.IgnoreCollision(sceneCollider, platformOverride); } } } void InitializeVariables() // Set Prefab Variables { localPlayer = Networking.LocalPlayer; if (!VRC.SDKBase.Utilities.IsValid(localPlayer)) { return; } lastFramePos = localPlayer.GetPosition(); playerTracker = transform.GetChild(0).GetChild(0); PlayerOffset = transform.GetChild(0); hook = transform.GetChild(1); platformOverride = transform.GetComponent(); platformOverride.size = new Vector3(0.5f, 0.05f, 0.5f); transform.position = Vector3.zero; if (hookLayerMask.value == -1) // Override if using "Everything" as a layermask to Everything -PlayerLocal,-MirrorReflection to prevent interference with the prefabs functionality. { hookLayerMask.value = -263369; } } // Disable Override Collider and set Immobilize state false. void OverridesOff() { if (!localPlayer.IsPlayerGrounded()) { platformOverride.enabled = false; } if (reduceIKDrift) { localPlayer.Immobilize(false); } } } }