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.
CrowdControl/Assets/Feel/NiceVibrations/Scripts/Components/HapticController.cs

569 lines
22 KiB
C#

3 months ago
// 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();
}
}
}
}