// 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 { /// /// Provides haptic playback functionality. /// /// /// HapticController allows you to load and play .haptic clips, and /// provides various ways to control playback, such as seeking, looping and /// amplitude/frequency modulation. /// /// If you need a MonoBehaviour API, use HapticSource and /// HapticReceiver instead. /// /// On iOS and Android, the device is vibrated, using LofeltHaptics. /// 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; /// /// The haptic preset to be played when it's not possible to play a haptic clip /// public static HapticPatterns.PresetType fallbackPreset { get { return _fallbackPreset; } set { _fallbackPreset = value; } } internal static bool _hapticsEnabled = true; /// /// Property to enable and disable global haptic playback /// public static bool hapticsEnabled { get { return _hapticsEnabled; } set { if (_hapticsEnabled) { Stop(); } _hapticsEnabled = value; } } internal static float _outputLevel = 1.0f; /// /// The overall haptic output level /// /// /// 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 not 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; /// /// The level of the loaded clip /// /// /// 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 not 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; /// /// Action that is invoked when the playback has finished /// /// /// 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 } /// /// Initializes HapticController. /// /// /// 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. /// /// Whether the device supports the minimum requirements to play haptics 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; } /// /// Loads a haptic clip given in JSON format for later playback. /// /// /// 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. /// /// The haptic clip, which is the content of the /// .haptic file, a UTF-8 encoded JSON string without a null /// terminator 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(); } /// /// Loads the given HapticClip for later playback. /// /// /// 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. /// /// The HapticClip to be loaded public static void Load(HapticClip clip) { Load(clip.json, clip.gamepadRumble); } /// /// Loads the haptic clip given as JSON and GamepadRumble for later playback. /// /// /// 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. /// /// The haptic clip, which is the content of the .haptic file, /// a UTF-8 encoded JSON string without a null terminator /// The GamepadRumble representation of the haptic clip 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(); } /// /// Plays the haptic clip that was previously loaded with Load(). /// /// /// If Loop(true) 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 .haptic 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(); } } /// /// Loads and plays the HapticClip given as an argument. /// /// /// The HapticClip to be played public static void Play(HapticClip clip) { Load(clip); Play(); } /// /// Stops haptic playback /// /// public static void Stop() { if (Init()) { LofeltHaptics.Stop(); } else { LofeltHaptics.StopPattern(); } GamepadRumbler.Stop(); HandleFinishedPlayback(); } /// /// Jumps to a time position in the haptic clip. /// /// /// 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. /// /// The new position within the clip, as seconds from the beginning /// of the clip public static void Seek(float time) { if (Init()) { LofeltHaptics.Stop(); LofeltHaptics.Seek(time); } GamepadRumbler.Stop(); lastSeekTime = time; } /// /// Adds the given shift to the frequency of every breakpoint in the clip, including the /// emphasis. /// /// /// 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); } } } /// /// Set the playback of a haptic clip to loop. /// /// /// 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. /// /// If the value is true, looping will be enabled which results /// in repeating the playback until Stop() is called; if false, the haptic /// clip will only be played once. public static void Loop(bool enabled) { if (Init()) { LofeltHaptics.Loop(enabled); } isLoopingEnabledByUser = enabled; } /// /// Checks if the loaded haptic clip is playing. /// /// /// Whether the loaded clip is playing public static bool IsPlaying() { if (playbackFinishedTimer.Enabled) { return true; } else { return isPlaybackLooping; } } /// /// Stops playback and resets the playback state. /// /// /// 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; } /// /// Processes an application focus change event. /// /// /// 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. /// /// Whether the application now has focus 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(); } } } }