// Copyright (c) Meta Platforms, Inc. and affiliates. using System; using System.Diagnostics; using System.Timers; using UnityEngine; // There are 3 conditions for working gamepad support in Nice Vibrations: // // 1. NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED - The input system package needs to be installed. // See https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Installation.html#installing-the-package // This is set by Nice Vibrations' assembly definition file, using a version define. // See https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html#define-symbols // about version defines, and see Lofelt.NiceVibrations.asmdef for the usage in Nice Vibrations. // // 2. ENABLE_INPUT_SYSTEM - The input system needs to be enabled in the project settings. // See https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Installation.html#enabling-the-new-input-backends // This define is set by Unity, see https://docs.unity3d.com/Manual/PlatformDependentCompilation.html // // 3. NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT - This is a user-defined define which needs to be not set. // NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT is not set by default. It can be set by a user in the // player settings to disable gamepad support completely. One reason to do this is to reduce the // size of a HapticClip asset, as setting this define changes to HapticImporter to not add the // GamepadRumble to the HapticClip. Changing this define requires re-importing all .haptic clip // assets to update HapticClip's GamepadRumble. // // If any of the 3 conditions is not met, GamepadRumbler doesn't contain any calls into // UnityEngine.InputSystem, and CanPlay() always returns false. #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT using UnityEngine.InputSystem; #endif namespace Lofelt.NiceVibrations { /// /// Contains a vibration pattern to make a gamepad rumble. /// /// /// GamepadRumble contains the information on when to set what motor speeds on a gamepad /// to make it rumble with a specific pattern. /// /// GamepadRumble has three arrays of the same length representing the rumble pattern. The /// entries for each array index describe for how long to turn on the gamepad's vibration /// motors, at what speed. [Serializable] public struct GamepadRumble { /// /// The duration, in milliseconds, that the motors will be turned on at the speed set /// in \ref lowFrequencyMotorSpeeds and \ref highFrequencyMotorSpeeds at the same array /// index /// [SerializeField] public int[] durationsMs; /// /// The total duration of the GamepadRumble, in milliseconds /// [SerializeField] public int totalDurationMs; /// /// The motor speeds of the low frequency motor /// [SerializeField] public float[] lowFrequencyMotorSpeeds; /// /// The motor speeds of the high frequency motor /// [SerializeField] public float[] highFrequencyMotorSpeeds; /// /// Checks if the GamepadRumble is valid and also not empty /// /// Whether the GamepadRumble is valid public bool IsValid() { return durationsMs != null && lowFrequencyMotorSpeeds != null && highFrequencyMotorSpeeds != null && durationsMs.Length == lowFrequencyMotorSpeeds.Length && durationsMs.Length == highFrequencyMotorSpeeds.Length && durationsMs.Length > 0; } } /// /// Vibrates a gamepad based on a GamepadRumble rumble pattern. /// /// /// GamepadRumbler can load and play back a GamepadRumble pattern on the current /// gamepad. /// /// This is a low-level class that normally doesn't need to be used directly. Instead, /// you can use HapticSource and HapticController to play back haptic clips, as those /// classes support gamepads by using GamepadRumbler internally. public static class GamepadRumbler { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT static GamepadRumble loadedRumble; static bool rumbleLoaded = false; // This Timer is used to wait until it is time to advance to the next entry in loadedRumble. // When the Timer is elapsed, ProcessNextRumble() is called to set new motor speeds to the // gamepad. static Timer rumbleTimer = new Timer(); // The index of the entry of loadedRumble that is currently being played back static int rumbleIndex = -1; // The total duration of rumble entries that have been played back so far static long rumblePositionMs = 0; // Keeps track of how much time elapsed since playback was started static Stopwatch playbackWatch = new Stopwatch(); /// /// A multiplication factor applied to the motor speeds of the low frequency motor. /// /// /// The multiplication factor is applied to the low frequency motor speed of every /// GamepadRumble entry before playing it. /// /// In other words, this applies a gain (for factors greater than 1.0) or an attenuation /// (for factors less than 1.0) to the clip. If the resulting speed of an entry is /// greater than 1.0, it is clipped to 1.0. The speed is clipped hard, no limiter is /// used. /// /// The motor speed multiplication is reset when calling Load(), so Load() needs to be /// called first before setting the multiplication. /// /// A change of the multiplication is applied to a currently playing rumble, but only /// for the next rumble entry, not the one currently playing. public static float lowFrequencyMotorSpeedMultiplication = 1.0f; /// /// Same as \ref lowFrequencyMotorSpeedMultiplication, but for the high frequency speed /// motor. /// public static float highFrequencyMotorSpeedMultiplication = 1.0f; static int currentGamepadID = -1; #endif /// /// Initializes the GamepadRumbler. /// /// /// This needs to be called from the main thread, which is the reason why this is a method /// instead of a static constructor: Sometimes Unity calls static constructors from a /// different thread, and an explicit Init() method gives us more control over this. public static void Init() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT // Initialize rumbleTimer, so that ProcessNextRumble() will be called on the main thread // when the timer is triggered. var syncContext = System.Threading.SynchronizationContext.Current; rumbleTimer.Elapsed += (object obj, System.Timers.ElapsedEventArgs args) => { syncContext.Post(_ => { ProcessNextRumble(); }, null); }; #endif } /// /// Checks whether a call to Play() would trigger playback on a gamepad. /// /// /// Playing back a rumble pattern with Play() only works if a gamepad is connected and if /// a GamepadRumble has been loaded with Load() before. /// /// Whether a vibration can be triggered on a gamepad public static bool CanPlay() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT return IsConnected() && rumbleLoaded && loadedRumble.IsValid(); #else return false; #endif } #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT /// /// Gets the Gamepad object corresponding to the specified gamepad ID. /// /// /// If the specified ID is out of range of the connected gamepad(s), /// InputSystem.Gamepad.current will be returned. /// /// The ID of the gamepad to be returned. /// A InputSystem.Gamepad static UnityEngine.InputSystem.Gamepad GetGamepad(int gamepadID) { if (gamepadID >= 0) { if (gamepadID >= UnityEngine.InputSystem.Gamepad.all.Count) { return UnityEngine.InputSystem.Gamepad.current; } else { return UnityEngine.InputSystem.Gamepad.all[gamepadID]; } } return UnityEngine.InputSystem.Gamepad.current; } #endif /// /// Set the current gamepad for haptics playback by ID. /// /// /// This method needs be called before haptics playback, e.g. \ref HapticController.Play(), /// \ref HapticPatterns.PlayEmphasis(), \ref HapticPatterns.PlayConstant(), etc, for /// for the gamepad to be properly selected. /// /// If this method isn't called, haptics will be played on InputSystem.Gamepad.current /// /// For example, if you have 3 controllers connected, you have to choose between values 0, 1, /// and 2. /// /// If the gamepad ID value doesn't match any connected gamepad, calling /// this method has no effect. /// The ID of the gamepad public static void SetCurrentGamepad(int gamepadID) { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT if (gamepadID < UnityEngine.InputSystem.Gamepad.all.Count) { currentGamepadID = gamepadID; } #endif } /// /// Checks whether a gamepad is connected and recognized by Unity's input system. /// /// /// If the input system package is not installed or not enabled, the gamepad is not /// recognized and treated as not connected here. /// /// If the NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT define is set in the player settings, /// this function pretends no gamepad is connected. /// /// Whether a gamepad is connected public static bool IsConnected() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT return GetGamepad(currentGamepadID) != null; #else return false; #endif } /// /// Loads a rumble pattern for later playback. /// /// /// The rumble pattern to load public static void Load(GamepadRumble rumble) { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT if (rumble.IsValid()) { loadedRumble = rumble; rumbleLoaded = true; lowFrequencyMotorSpeedMultiplication = 1.0f; highFrequencyMotorSpeedMultiplication = 1.0f; } else { Unload(); } #endif } /// /// Plays back the rumble pattern loaded previously with Load(). /// /// /// If no rumble pattern has been loaded, or if no gamepad is connected, this method does /// nothing. public static void Play() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT if (CanPlay()) { rumbleIndex = 0; rumblePositionMs = 0; playbackWatch.Restart(); ProcessNextRumble(); } #endif } /// /// Stops playback previously started with Play() by turning off the gamepad's motors. /// public static void Stop() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT if (GetGamepad(currentGamepadID) != null) { GetGamepad(currentGamepadID).ResetHaptics(); } rumbleTimer.Enabled = false; rumbleIndex = -1; rumblePositionMs = 0; playbackWatch.Stop(); #endif } /// /// Stops playback and unloads the currently loaded GamepadRumble from memory. /// public static void Unload() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT loadedRumble.highFrequencyMotorSpeeds = null; loadedRumble.lowFrequencyMotorSpeeds = null; loadedRumble.durationsMs = null; rumbleLoaded = false; Stop(); #endif } // Advances the position in the GamepadRumble by one. // // If the end of the rumble has been reached, playback is stopped and false is returned. private static bool IncreaseRumbleIndex() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT rumblePositionMs += loadedRumble.durationsMs[rumbleIndex]; rumbleIndex++; if (rumbleIndex == loadedRumble.durationsMs.Length) { Stop(); return false; } return true; #else return false; #endif } // Processes the next entry in loadedRumble by setting the gamepad's motor speeds to the // speeds stored in that entry. // // Afterwards, the rumbleTimer is set to call this method again, after the time stored // in entry of loadedRumble. private static void ProcessNextRumble() { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT // rumbleIndex can be -1 after Stop() has been called after the call to // ProcessNextRumble() has already been queued up via SynchronizationContext. if (rumbleIndex == -1) { return; } if (rumbleIndex == loadedRumble.durationsMs.Length) { Stop(); return; } UnityEngine.Debug.Assert(loadedRumble.IsValid()); UnityEngine.Debug.Assert(rumbleLoaded); UnityEngine.Debug.Assert(rumbleIndex >= 0 && rumbleIndex <= loadedRumble.durationsMs.Length); // Figure out for how long the current rumble entry should be played (durationToWait). // Due to the timer not waiting for exactly the same amount of time that we requested, // there can be a bit of error that we need to compensate for. For example, if the timer // waited for 3ms longer than we requested, we play the next rumble entry for a 3ms // less to compensate for that. // In fact, Unity triggers the timer only once per frame, so at 30 FPS, the timer // resolution is 32ms. That means that the timing error can be bigger than the duration // of the whole rumble entry, and to compensate for that, the entire rumble entry needs // to be skipped. That's what the loop does: It skips rumble entries to compensate for // timer error. long elapsed = playbackWatch.ElapsedMilliseconds; long durationToWait = 0; while (true) { long rumbleEntryDuration = loadedRumble.durationsMs[rumbleIndex]; long error = elapsed - rumblePositionMs; durationToWait = rumbleEntryDuration - error; // If durationToWait is <= 0, the current rumble entry needs to be skipped to // compensate for timer error. Otherwise break and play the current rumble entry. if (durationToWait > 0) { break; } // If the end of the rumble has been reached, return, as playback has stopped. if (!IncreaseRumbleIndex()) { return; } } float lowFrequencySpeed = loadedRumble.lowFrequencyMotorSpeeds[rumbleIndex] * Mathf.Max(lowFrequencyMotorSpeedMultiplication, 0.0f); float highFrequencySpeed = loadedRumble.highFrequencyMotorSpeeds[rumbleIndex] * Mathf.Max(highFrequencyMotorSpeedMultiplication, 0.0f); UnityEngine.InputSystem.Gamepad currentGamepad = GetGamepad(currentGamepadID); // Check if gamepad was disconnected while playing if (currentGamepad != null) { currentGamepad.SetMotorSpeeds(lowFrequencySpeed, highFrequencySpeed); } else { return; } // Set up the timer to call ProcessNextRumble() again with the next rumble entry, after // the duration of the current rumble entry. rumblePositionMs += loadedRumble.durationsMs[rumbleIndex]; rumbleIndex++; rumbleTimer.Interval = durationToWait; rumbleTimer.AutoReset = false; rumbleTimer.Enabled = true; #endif } } }