// 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
}
}
}