You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
22 KiB
C#
569 lines
22 KiB
C#
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
|
|
using UnityEngine;
|
|
using System;
|
|
using System.Timers;
|
|
|
|
#if (UNITY_ANDROID && !UNITY_EDITOR)
|
|
using System.Text;
|
|
#elif (UNITY_IOS && !UNITY_EDITOR)
|
|
using UnityEngine.iOS;
|
|
#endif
|
|
|
|
namespace Lofelt.NiceVibrations
|
|
{
|
|
/// <summary>
|
|
/// Provides haptic playback functionality.
|
|
/// </summary>
|
|
///
|
|
/// HapticController allows you to load and play <c>.haptic</c> clips, and
|
|
/// provides various ways to control playback, such as seeking, looping and
|
|
/// amplitude/frequency modulation.
|
|
///
|
|
/// If you need a <c>MonoBehaviour</c> API, use HapticSource and
|
|
/// HapticReceiver instead.
|
|
///
|
|
/// On iOS and Android, the device is vibrated, using <c>LofeltHaptics</c>.
|
|
/// On any platform, when a gamepad is connected, that gamepad is vibrated,
|
|
/// using GamepadRumbler.
|
|
///
|
|
/// Gamepads are vibrated automatically when HapticController detects that a
|
|
/// gamepad is connected, no special code is needed to support gamepads.
|
|
/// Gamepads only support Load(), Play(), Stop(), \ref clipLevel and \ref
|
|
/// outputLevel. Other features like Seek(), Loop() and \ref clipFrequencyShift
|
|
/// will have no effect on gamepads.
|
|
///
|
|
/// None of the methods here are thread-safe and should only be called from
|
|
/// the main (Unity) thread. Calling these methods from a secondary thread can
|
|
/// cause undefined behaviour and memory leaks.
|
|
public static class HapticController
|
|
{
|
|
static bool lofeltHapticsInitalized = false;
|
|
|
|
// Timer used to call HandleFinishedPlayback() when playback is complete
|
|
static Timer playbackFinishedTimer = new Timer();
|
|
|
|
// Duration of the loaded haptic clip, in seconds
|
|
static float clipLoadedDurationSecs = 0.0f;
|
|
|
|
// Whether Load() has been called before
|
|
static bool clipLoaded = false;
|
|
|
|
// The value of the last call to seek()
|
|
static float lastSeekTime = 0.0f;
|
|
|
|
// Flag indicating if the device supports playing back .haptic clips
|
|
static bool deviceMeetsAdvancedRequirements = false;
|
|
|
|
// Flag indicating if the user enabled playback looping.
|
|
// This does not necessarily mean that the currently active playback is looping, for
|
|
// example gamepads don't support looping.
|
|
static bool isLoopingEnabledByUser = false;
|
|
|
|
// Flag indicating if the currently active playback is looping
|
|
static bool isPlaybackLooping = false;
|
|
|
|
static HapticPatterns.PresetType _fallbackPreset = HapticPatterns.PresetType.None;
|
|
|
|
/// <summary>
|
|
/// The haptic preset to be played when it's not possible to play a haptic clip
|
|
/// </summary>
|
|
public static HapticPatterns.PresetType fallbackPreset
|
|
{
|
|
get { return _fallbackPreset; }
|
|
set { _fallbackPreset = value; }
|
|
}
|
|
|
|
internal static bool _hapticsEnabled = true;
|
|
|
|
/// <summary>
|
|
/// Property to enable and disable global haptic playback
|
|
/// </summary>
|
|
public static bool hapticsEnabled
|
|
{
|
|
get { return _hapticsEnabled; }
|
|
set
|
|
{
|
|
if (_hapticsEnabled)
|
|
{
|
|
Stop();
|
|
}
|
|
_hapticsEnabled = value;
|
|
}
|
|
}
|
|
|
|
internal static float _outputLevel = 1.0f;
|
|
|
|
/// <summary>
|
|
/// The overall haptic output level
|
|
/// </summary>
|
|
///
|
|
/// It can be interpreted as the "volume control" for haptic playback.
|
|
/// Output level is applied in combination with \ref clipLevel to the currently playing haptic clip.
|
|
/// The combination of these two levels and the amplitude within the loaded haptic at a given moment
|
|
/// in time determines the strength of the vibration felt on the device. \ref outputLevel is best used
|
|
/// to increase or decrease the overall haptic level in a game.
|
|
///
|
|
/// As output level pertains to all clips, unlike \ref clipLevel, it persists when a new clip is loaded.
|
|
///
|
|
/// \ref outputLevel is a multiplication factor, it is <i>not</i> a dB value. The factor needs to be
|
|
/// 0 or greater.
|
|
///
|
|
/// The combination of \ref outputLevel and \ref clipLevel can result in a gain (for factors
|
|
/// greater than 1.0) or an attenuation (for factors less than 1.0) to the clip. If the
|
|
/// combination of \ref outputLevel, \ref clipLevel and the amplitude within the loaded haptic
|
|
/// is greater than 1.0, it is clipped to 1.0. Hard clipping is performed, no limiter is used.
|
|
///
|
|
/// On Android, an adjustment to \ref outputLevel will take effect in the next call to Play().
|
|
/// On iOS, it will take effect right away.
|
|
[System.ComponentModel.DefaultValue(1.0f)]
|
|
public static float outputLevel
|
|
{
|
|
get { return _outputLevel; }
|
|
set
|
|
{
|
|
_outputLevel = value;
|
|
|
|
ApplyLevelsToLofeltHaptics();
|
|
ApplyLevelsToGamepadRumbler();
|
|
}
|
|
}
|
|
|
|
internal static float _clipLevel = 1.0f;
|
|
|
|
/// <summary>
|
|
/// The level of the loaded clip
|
|
/// </summary>
|
|
///
|
|
/// Clip level is applied in combination with \ref outputLevel, to the
|
|
/// currently playing haptic clip. The combination of these two levels and the amplitude within the loaded
|
|
/// haptic at a given moment in time determines the strength of the vibration felt on the device.
|
|
/// \ref clipLevel is best used to adjust the level of a single clip based on game state.
|
|
///
|
|
/// As clip level is specific to an individual clip, unlike \ref outputLevel, it resets to
|
|
/// 1.0 when a new clip is loaded.
|
|
///
|
|
/// \ref clipLevel is a multiplication factor, it is <i>not</i> a dB value. The factor needs to be
|
|
/// 0 or greater.
|
|
///
|
|
/// The combination of \ref outputLevel and \ref clipLevel can result in a gain (for factors
|
|
/// greater than 1.0) or an attenuation (for factors less than 1.0) to the clip.
|
|
///
|
|
/// If the combination of \ref outputLevel, \ref clipLevel and the amplitude within the loaded
|
|
/// haptic is greater than 1.0, it is clipped to 1.0. Hard clipping is performed, no limiter is used.
|
|
///
|
|
/// The clip needs to be loaded with Load() before adjusting \ref clipLevel. Loading a clip
|
|
/// resets \ref clipLevel back to the default of 1.0.
|
|
///
|
|
/// On Android, an adjustment to \ref clipLevel will take effect in the next call to Play(). On iOS,
|
|
/// it will take effect right away.
|
|
///
|
|
/// On Android, setting the clip level should be done before calling \ref Seek(), since
|
|
/// setting a clip level ignores the sought value.
|
|
///
|
|
[System.ComponentModel.DefaultValue(1.0f)]
|
|
public static float clipLevel
|
|
{
|
|
get { return _clipLevel; }
|
|
set
|
|
{
|
|
_clipLevel = value;
|
|
|
|
ApplyLevelsToLofeltHaptics();
|
|
ApplyLevelsToGamepadRumbler();
|
|
}
|
|
}
|
|
|
|
/// Action that is invoked when Load() is called
|
|
public static Action LoadedClipChanged;
|
|
|
|
/// Action that is invoked when Play() is called
|
|
public static Action PlaybackStarted;
|
|
|
|
/// <summary>
|
|
/// Action that is invoked when the playback has finished
|
|
/// </summary>
|
|
///
|
|
/// This happens either when Stop() is explicitly called, or when a non-looping
|
|
/// clip has finished playing.
|
|
///
|
|
/// This can be invoked spuriously, even if no haptics are currently playing, for example
|
|
/// if Stop() is called multiple times in a row.
|
|
public static Action PlaybackStopped;
|
|
|
|
// Applies the current clip level and output level as the amplitude multiplication to
|
|
// LofeltHaptics
|
|
private static void ApplyLevelsToLofeltHaptics()
|
|
{
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.SetAmplitudeMultiplication(_outputLevel * _clipLevel);
|
|
}
|
|
}
|
|
|
|
// Applies the current clip level and output level as the motor speed multiplication to
|
|
// GamepadRumbler
|
|
private static void ApplyLevelsToGamepadRumbler()
|
|
{
|
|
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
|
|
GamepadRumbler.lowFrequencyMotorSpeedMultiplication = _outputLevel * _clipLevel;
|
|
GamepadRumbler.highFrequencyMotorSpeedMultiplication = _outputLevel * _clipLevel;
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes HapticController.
|
|
/// </summary>
|
|
///
|
|
/// Calling this method multiple times has no effect and is safe.
|
|
///
|
|
/// You do not need to call this method, HapticController automatically calls this
|
|
/// method before any operation that needs initialization, such as Play().
|
|
/// However it can be beneficial to call this early during startup, so the initialization
|
|
/// time is spent at startup instead of when the first haptic is triggered during gameplay.
|
|
/// If you have a HapticReceiver in your scene, it takes care of calling
|
|
/// Init() during startup for you.
|
|
///
|
|
/// Do not call this method from a static constructor. Unity often invokes static
|
|
/// constructors from a different thread, for example during deserialization. The
|
|
/// initialization code is not thread-safe. This is the reason this method is not called
|
|
/// from the static constructor of HapticController or HapticReceiver.
|
|
///
|
|
/// <returns>Whether the device supports the minimum requirements to play haptics</returns>
|
|
public static bool Init()
|
|
{
|
|
if (!lofeltHapticsInitalized)
|
|
{
|
|
lofeltHapticsInitalized = true;
|
|
|
|
var syncContext = System.Threading.SynchronizationContext.Current;
|
|
playbackFinishedTimer.Elapsed += (object obj, System.Timers.ElapsedEventArgs args) =>
|
|
{
|
|
// Timer elapsed events are called from a separate thread, so use
|
|
// SynchronizationContext to handle it in the main thread.
|
|
syncContext.Post(_ =>
|
|
{
|
|
HandleFinishedPlayback();
|
|
}, null);
|
|
};
|
|
|
|
if (DeviceCapabilities.isVersionSupported)
|
|
{
|
|
LofeltHaptics.Initialize();
|
|
DeviceCapabilities.Init();
|
|
deviceMeetsAdvancedRequirements = DeviceCapabilities.meetsAdvancedRequirements;
|
|
}
|
|
|
|
GamepadRumbler.Init();
|
|
}
|
|
return deviceMeetsAdvancedRequirements;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads a haptic clip given in JSON format for later playback.
|
|
/// </summary>
|
|
///
|
|
/// This overload of Load() is useful in cases there is only the JSON data of a haptic clip
|
|
/// available. Due to only having the JSON data and no GamepadRumble, gamepad playback is
|
|
/// not supported with this overload.
|
|
///
|
|
/// <param name="data">The haptic clip, which is the content of the
|
|
/// <c>.haptic</c> file, a UTF-8 encoded JSON string without a null
|
|
/// terminator</param>
|
|
public static void Load(byte[] data)
|
|
{
|
|
GamepadRumbler.Unload();
|
|
lastSeekTime = 0.0f;
|
|
clipLoaded = true;
|
|
clipLoadedDurationSecs = 0.0f;
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.Load(data);
|
|
}
|
|
clipLevel = 1.0f;
|
|
LoadedClipChanged?.Invoke();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the given HapticClip for later playback.
|
|
/// </summary>
|
|
///
|
|
/// This is the standard way to load a haptic clip, while the other overloads of Load()
|
|
/// are for more specialized cases.
|
|
///
|
|
/// At the moment only one clip can be loaded at a time.
|
|
///
|
|
/// <param name="clip">The HapticClip to be loaded</param>
|
|
public static void Load(HapticClip clip)
|
|
{
|
|
Load(clip.json, clip.gamepadRumble);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the haptic clip given as JSON and GamepadRumble for later playback.
|
|
/// </summary>
|
|
///
|
|
/// This is an overload of Load() that is useful when a HapticClip is not available, and
|
|
/// both the JSON and GamepadRumble are. One such case is generating both dynamically at
|
|
/// runtime.
|
|
///
|
|
/// <param name="json">The haptic clip, which is the content of the <c>.haptic</c> file,
|
|
/// a UTF-8 encoded JSON string without a null terminator</param>
|
|
/// <param name="rumble">The GamepadRumble representation of the haptic clip</param>
|
|
public static void Load(byte[] json, GamepadRumble rumble)
|
|
{
|
|
Load(json);
|
|
|
|
GamepadRumbler.Load(rumble);
|
|
// GamepadRumbler.Load() resets the motor speed multiplication to 1.0, so the levels
|
|
// need to be applied here again
|
|
ApplyLevelsToGamepadRumbler();
|
|
|
|
// Load() only sets the correct clip duration on iOS and Android, and sets it to 0.0
|
|
// on other platforms. For the other platforms, set a clip duration based on the
|
|
// GamepadRumble here.
|
|
if (clipLoadedDurationSecs == 0.0f && rumble.IsValid())
|
|
{
|
|
clipLoadedDurationSecs = rumble.totalDurationMs / 1000.0f;
|
|
}
|
|
}
|
|
|
|
static void HandleFinishedPlayback()
|
|
{
|
|
lastSeekTime = 0.0f;
|
|
isPlaybackLooping = false;
|
|
playbackFinishedTimer.Enabled = false;
|
|
PlaybackStopped?.Invoke();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plays the haptic clip that was previously loaded with Load().
|
|
/// </summary>
|
|
///
|
|
/// If <c>Loop(true)</c> was called previously, the playback will be repeated
|
|
/// until Stop() is called. Otherwise the haptic clip will only play once.
|
|
///
|
|
/// In case the device does not meet the requirements to play <c>.haptic</c> clips, this
|
|
/// function will call HapticPatterns.PlayPreset() with the \ref fallbackPreset set. In this
|
|
/// case, functionality like seeking, looping and runtime modulation won't do anything as
|
|
/// they aren't available for haptic presets.
|
|
public static void Play()
|
|
{
|
|
if (!_hapticsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float remainingPlayDuration = 0.0f;
|
|
bool canLoop = false;
|
|
if (GamepadRumbler.CanPlay())
|
|
{
|
|
remainingPlayDuration = clipLoadedDurationSecs;
|
|
GamepadRumbler.Play();
|
|
}
|
|
else if (Init())
|
|
{
|
|
remainingPlayDuration = Mathf.Max(clipLoadedDurationSecs - lastSeekTime, 0.0f);
|
|
canLoop = DeviceCapabilities.canLoop;
|
|
LofeltHaptics.Play();
|
|
}
|
|
else if (DeviceCapabilities.isVersionSupported)
|
|
{
|
|
remainingPlayDuration = HapticPatterns.GetPresetDuration(fallbackPreset);
|
|
HapticPatterns.PlayPreset(fallbackPreset);
|
|
}
|
|
|
|
isPlaybackLooping = isLoopingEnabledByUser && canLoop;
|
|
PlaybackStarted?.Invoke();
|
|
|
|
//
|
|
// Call HandleFinishedPlayback() after the playback finishes
|
|
//
|
|
if (remainingPlayDuration > 0.0f)
|
|
{
|
|
playbackFinishedTimer.Interval = remainingPlayDuration * 1000;
|
|
playbackFinishedTimer.AutoReset = false;
|
|
playbackFinishedTimer.Enabled = !isPlaybackLooping;
|
|
}
|
|
else
|
|
{
|
|
// Setting playbackFinishedTimer.Interval needs an interval > 0, otherwise it will
|
|
// throw an exception.
|
|
// Even if the remaining play duration is 0, we still want to trigger everything
|
|
// that happens in HandleFinishedPlayback().
|
|
// A playback duration of 0 happens in the Unity editor, when loading the clip
|
|
// failed or when seeking to the end of a clip.
|
|
HandleFinishedPlayback();
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Loads and plays the HapticClip given as an argument.
|
|
/// </summary>
|
|
///
|
|
/// <param name="clip">The HapticClip to be played</param>
|
|
public static void Play(HapticClip clip)
|
|
{
|
|
Load(clip);
|
|
Play();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops haptic playback
|
|
///
|
|
/// </summary>
|
|
public static void Stop()
|
|
{
|
|
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.Stop();
|
|
}
|
|
else
|
|
{
|
|
LofeltHaptics.StopPattern();
|
|
}
|
|
GamepadRumbler.Stop();
|
|
HandleFinishedPlayback();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Jumps to a time position in the haptic clip.
|
|
/// </summary>
|
|
///
|
|
/// The playback will always be stopped when this function is called.
|
|
/// This is to match the behavior between iOS and Android, since Android needs to
|
|
/// restart playback for seek to have effect.
|
|
///
|
|
/// If seeking beyond the end of the clip, Play() will not reproduce any haptics.
|
|
/// Seeking to a negative position will seek to the beginning of the clip.
|
|
///
|
|
/// <param name="time">The new position within the clip, as seconds from the beginning
|
|
/// of the clip</param>
|
|
public static void Seek(float time)
|
|
{
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.Stop();
|
|
LofeltHaptics.Seek(time);
|
|
}
|
|
GamepadRumbler.Stop();
|
|
lastSeekTime = time;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the given shift to the frequency of every breakpoint in the clip, including the
|
|
/// emphasis.
|
|
/// </summary>
|
|
///
|
|
/// In other words, this property shifts all frequencies of the clip. The frequency shift is
|
|
/// added to each frequency value and needs to be between -1.0 and 1.0. If the resulting
|
|
/// frequency of a breakpoint is smaller than 0.0 or greater than 1.0, it is clipped to that
|
|
/// range. The frequency is clipped hard, no limiter is used.
|
|
///
|
|
/// The clip needs to be loaded with Load() first. Loading a clip resets the shift back
|
|
/// to the default of 0.0.
|
|
///
|
|
/// Setting the frequency shift has no effect on Android; it only works on iOS.
|
|
///
|
|
/// A call to this property will change the frequency shift of a currently playing clip
|
|
/// right away. If no clip is playing, the shift is applied in the next call to
|
|
/// Play().
|
|
[System.ComponentModel.DefaultValue(0.0f)]
|
|
public static float clipFrequencyShift
|
|
{
|
|
set
|
|
{
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.SetFrequencyShift(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the playback of a haptic clip to loop.
|
|
/// </summary>
|
|
///
|
|
/// On Android, calling this will always put the playback position at the start of the clip.
|
|
/// Also, it will only have an effect when Play() is called again.
|
|
///
|
|
/// On iOS, if a clip is already playing, calling this will leave the playback position as
|
|
/// it is and repeat when it reaches the end. No need to call Play() again for
|
|
/// changes to take effect.
|
|
///
|
|
/// <param name="enabled">If the value is <c>true</c>, looping will be enabled which results
|
|
/// in repeating the playback until Stop() is called; if <c>false</c>, the haptic
|
|
/// clip will only be played once.</param>
|
|
public static void Loop(bool enabled)
|
|
{
|
|
if (Init())
|
|
{
|
|
LofeltHaptics.Loop(enabled);
|
|
}
|
|
isLoopingEnabledByUser = enabled;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the loaded haptic clip is playing.
|
|
/// </summary>
|
|
///
|
|
/// <returns>Whether the loaded clip is playing</returns>
|
|
public static bool IsPlaying()
|
|
{
|
|
if (playbackFinishedTimer.Enabled)
|
|
{
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
return isPlaybackLooping;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops playback and resets the playback state.
|
|
/// </summary>
|
|
///
|
|
/// Seek position, clip level, clip frequency shift and loop are reset to the
|
|
/// default values.
|
|
/// The currently loaded clip stays loaded.
|
|
/// \ref hapticsEnabled and \ref outputLevel are not reset.
|
|
public static void Reset()
|
|
{
|
|
if (clipLoaded)
|
|
{
|
|
Seek(0.0f);
|
|
Stop();
|
|
clipLevel = 1.0f;
|
|
clipFrequencyShift = 0.0f;
|
|
Loop(false);
|
|
}
|
|
fallbackPreset = HapticPatterns.PresetType.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes an application focus change event.
|
|
/// </summary>
|
|
///
|
|
/// If you have a HapticReceiver in your scene, the HapticReceiver
|
|
/// will take care of calling this method when needed. Otherwise it is your
|
|
/// responsibility to do so.
|
|
///
|
|
/// When the application loses the focus, playback is stopped.
|
|
///
|
|
/// <param name="hasFocus">Whether the application now has focus</param>
|
|
public static void ProcessApplicationFocus(bool hasFocus)
|
|
{
|
|
if (!hasFocus)
|
|
{
|
|
// While LofeltHaptics stops playback when the app loses focus,
|
|
// calling Stop() here handles additional things such as invoking
|
|
// the PlaybackStopped Action.
|
|
Stop();
|
|
}
|
|
}
|
|
}
|
|
}
|