// Copyright (c) Meta Platforms, Inc. and affiliates. using System; using UnityEngine; using System.Globalization; namespace Lofelt.NiceVibrations { /// /// A collection of methods to play simple haptic patterns. /// /// /// Each of the methods here load and play a simple haptic clip or a /// haptic pattern, depending on the device capabilities. /// /// 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. /// /// After playback has finished, the loaded clips in this class will remain /// loaded in HapticController. public static class HapticPatterns { static String emphasisTemplate; static String constantTemplate; static NumberFormatInfo numberFormat; static private float[] constantPatternTime = new float[] { 0.0f, 0.0f }; /// /// Enum that represents all the types of haptic presets available /// public enum PresetType { Selection = 0, Success = 1, Warning = 2, Failure = 3, LightImpact = 4, MediumImpact = 5, HeavyImpact = 6, RigidImpact = 7, SoftImpact = 8, None = -1 } /// /// Structure that represents a haptic pattern with amplitude variations. /// /// /// \ref time values have be incremental to be compatible with Preset. struct Pattern { public float[] time; public float[] amplitude; static String clipJsonTemplate; static Pattern() { clipJsonTemplate = (Resources.Load("nv-pattern-template") as TextAsset).text; } public Pattern(float[] time, float[] amplitude) { this.time = time; this.amplitude = amplitude; } // Converts a Pattern to a GamepadRumble // // Each pair of adjacent entries in the Pattern create one entry in the GamepadRumble. public GamepadRumble ToRumble() { GamepadRumble result = new GamepadRumble(); if (time.Length <= 1) { return result; } Debug.Assert(time.Length == amplitude.Length); // The first pattern entry needs to have a time of 0.0 for the algorithm below to work Debug.Assert(time[0] == 0.0f); int rumbleCount = time.Length - 1; result.durationsMs = new int[rumbleCount]; result.lowFrequencyMotorSpeeds = new float[rumbleCount]; result.highFrequencyMotorSpeeds = new float[rumbleCount]; result.totalDurationMs = 0; for (int rumbleIndex = 0; rumbleIndex < rumbleCount; rumbleIndex++) { int patternDurationMs = (int)((time[rumbleIndex + 1] - time[rumbleIndex]) * 1000.0f); result.durationsMs[rumbleIndex] = patternDurationMs; result.lowFrequencyMotorSpeeds[rumbleIndex] = amplitude[rumbleIndex]; result.highFrequencyMotorSpeeds[rumbleIndex] = amplitude[rumbleIndex]; result.totalDurationMs += result.durationsMs[rumbleIndex]; } return result; } // Converts a Pattern to a haptic clip JSON string. public String ToClip() { if (clipJsonTemplate == null) { return ""; } String amplitudeEnvelope = ""; for (int i = 0; i < time.Length; i++) { float clampedAmplitude = Mathf.Clamp(amplitude[i], 0.0f, 1.0f); amplitudeEnvelope += "{ \"time\":" + time[i].ToString(numberFormat) + "," + "\"amplitude\":" + clampedAmplitude.ToString(numberFormat) + "}"; // Don't add a comma to the JSON data if we're at the end of the envelope if (i + 1 < time.Length) { amplitudeEnvelope += ","; } } return clipJsonTemplate.Replace("{amplitude-envelope}", amplitudeEnvelope); } } // A haptic preset in its different representations // // A Preset has four different representations, as there are four different playback methods. // Each representation is created at construction time, so that playing a // Preset has no further conversion cost at playback time. internal struct Preset { // For playback on iOS, using system haptics public PresetType type; // For playback on Android devices without amplitude control public float[] maximumAmplitudePattern; // For playback on Android devices with amplitude control public byte[] jsonClip; // For playback on gamepads #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT public GamepadRumble gamepadRumble; #endif public Preset(PresetType type, float[] time, float[] amplitude) { Debug.Assert(type != PresetType.None); Pattern pattern = new Pattern(time, amplitude); this.type = type; this.maximumAmplitudePattern = pattern.time; #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT this.gamepadRumble = pattern.ToRumble(); #endif this.jsonClip = System.Text.Encoding.UTF8.GetBytes(pattern.ToClip()); } public float GetDuration() { if (maximumAmplitudePattern.Length > 0) { return maximumAmplitudePattern[maximumAmplitudePattern.Length - 1]; } else { return 0f; } } } /// /// Predefined Preset that represents a "Selection" haptic preset /// internal static Preset Selection; /// /// Predefined Preset that represents a "Light" haptic preset /// internal static Preset Light; /// /// Predefined Preset that represents a "Medium" haptic preset /// internal static Preset Medium; /// /// Predefined Preset that represents a "Heavy" haptic preset /// internal static Preset Heavy; /// /// Predefined Preset that represents a "Rigid" haptic preset /// internal static Preset Rigid; /// /// Predefined Preset that represents a "Soft" haptic preset /// internal static Preset Soft; /// /// Predefined Preset that represents a "Success" haptic preset /// internal static Preset Success; /// /// Predefined Preset that represents a "Failure" haptic preset /// internal static Preset Failure; /// /// Predefined Preset that represents a "Warning" haptic preset /// internal static Preset Warning; static HapticPatterns() { emphasisTemplate = (Resources.Load("nv-emphasis-template") as TextAsset).text; constantTemplate = (Resources.Load("nv-constant-template") as TextAsset).text; numberFormat = new NumberFormatInfo(); numberFormat.NumberDecimalSeparator = "."; // Initialize presets after setting the number format, so that the correct decimal // separator is used when building the JSON representation. Selection = new Preset(PresetType.Selection, new float[] { 0.0f, 0.04f }, new float[] { 0.471f, 0.471f }); Light = new Preset(PresetType.LightImpact, new float[] { 0.000f, 0.040f }, new float[] { 0.156f, 0.156f }); Medium = new Preset(PresetType.MediumImpact, new float[] { 0.000f, 0.080f }, new float[] { 0.471f, 0.471f }); Heavy = new Preset(PresetType.HeavyImpact, new float[] { 0.0f, 0.16f }, new float[] { 1.0f, 1.00f }); Rigid = new Preset(PresetType.RigidImpact, new float[] { 0.0f, 0.04f }, new float[] { 1.0f, 1.00f }); Soft = new Preset(PresetType.SoftImpact, new float[] { 0.000f, 0.160f }, new float[] { 0.156f, 0.156f }); Success = new Preset(PresetType.Success, new float[] { 0.0f, 0.040f, 0.080f, 0.240f }, new float[] { 0.0f, 0.157f, 0.000f, 1.000f }); Failure = new Preset(PresetType.Failure, new float[] { 0.0f, 0.080f, 0.120f, 0.200f, 0.240f, 0.400f, 0.440f, 0.480f }, new float[] { 0.0f, 0.470f, 0.000f, 0.470f, 0.000f, 1.000f, 0.000f, 0.157f }); Warning = new Preset(PresetType.Warning, new float[] { 0.0f, 0.120f, 0.240f, 0.280f }, new float[] { 0.0f, 1.000f, 0.000f, 0.470f }); } /// /// Plays a single emphasis point. /// /// /// Plays a haptic clip that consists only of one breakpoint with emphasis. /// On iOS, this translates to a transient, and on Android and gamepads to /// a quick vibration. /// /// The amplitude of the emphasis, from 0.0 to 1.0 /// The frequency of the emphasis, from 0.0 to 1.0 public static void PlayEmphasis(float amplitude, float frequency) { if (emphasisTemplate == null || !HapticController.hapticsEnabled) { return; } // Use HapticController.Play() to play a .haptic clip on mobile devices // that support it, or to play a gamepad rumble if a gamepad is connected. if (HapticController.Init() || GamepadRumbler.IsConnected()) { float clampedAmplitude = Mathf.Clamp(amplitude, 0.0f, 1.0f); float clampedFrequency = Mathf.Clamp(frequency, 0.0f, 1.0f); const float duration = 0.1f; String json = emphasisTemplate .Replace("{amplitude}", clampedAmplitude.ToString(numberFormat)) .Replace("{frequency}", clampedFrequency.ToString(numberFormat)) .Replace("{duration}", duration.ToString(numberFormat)); // This preprocessor section will only run for non-mobile platforms GamepadRumble rumble = new GamepadRumble(); #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT rumble.durationsMs = new int[] { (int)(duration * 1000) }; rumble.lowFrequencyMotorSpeeds = new float[] { clampedAmplitude }; rumble.highFrequencyMotorSpeeds = new float[] { clampedFrequency }; #endif HapticController.Load(System.Text.Encoding.UTF8.GetBytes(json), rumble); HapticController.Loop(false); HapticController.Play(); } // As a fallback, play a short buzz on Android, or a preset on iOS. else if (DeviceCapabilities.isVersionSupported) { #if (UNITY_ANDROID && !UNITY_EDITOR) LofeltHaptics.PlayMaximumAmplitudePattern(new float[]{ 0.0f, 0.05f }); #elif (UNITY_IOS && !UNITY_EDITOR) PresetType preset = presetTypeForEmphasis(amplitude); LofeltHaptics.TriggerPresetHaptics((int)preset); #endif } } /// /// Automatically selects the fallback preset based on the emphasis point amplitude. /// /// /// The amplitude of the emphasis, from 0.0 to 1.0 static PresetType presetTypeForEmphasis(float amplitude) { if (amplitude > 0.5f) { return HapticPatterns.PresetType.HeavyImpact; } else if (amplitude <= 0.5f && amplitude > 0.3) { return HapticPatterns.PresetType.MediumImpact; } else { return HapticPatterns.PresetType.LightImpact; } } /// /// Plays a haptic with constant amplitude and frequency. /// /// /// On iOS and with gamepads, you can use HapticController::clipLevel to modulate the haptic /// while it is playing. iOS additional supports modulating the frequency with /// HapticController::clipFrequencyShift. /// /// When \ref DeviceCapabilities.meetsAdvancedRequirements returns false on mobile, /// the behavior of this method is different for iOS and Android: /// /// Amplitude, from 0.0 to 1.0 /// Frequency, from 0.0 to 1.0 /// Play duration in seconds public static void PlayConstant(float amplitude, float frequency, float duration) { if (constantTemplate == null || !HapticController.hapticsEnabled) { return; } float clampedAmplitude = Mathf.Clamp(amplitude, 0.0f, 1.0f); float clampedFrequency = Mathf.Clamp(frequency, 0.0f, 1.0f); float clampedDurationSecs = Mathf.Max(duration, 0.0f); String json = constantTemplate .Replace("{duration}", clampedDurationSecs.ToString(numberFormat)); // This preprocessor section will only run for non-mobile platforms GamepadRumble rumble = new GamepadRumble(); #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT int rumbleDurationMs = (int)(clampedDurationSecs * 1000); const int rumbleEntryDurationMs = 16; // One rumble entry per frame at 60 FPS, which is the limit of what GamepadRumbler can play int rumbleEntryCount = rumbleDurationMs / rumbleEntryDurationMs; rumble.durationsMs = new int[rumbleEntryCount]; rumble.lowFrequencyMotorSpeeds = new float[rumbleEntryCount]; rumble.highFrequencyMotorSpeeds = new float[rumbleEntryCount]; // Create many rumble entries instead of just one. With just one entry, changing // clipLevel while the rumble is playing would have no effect, as GamepadRumbler applies // a change only to the next rumble entry, not the one currently playing. for (int i = 0; i < rumbleEntryCount; i++) { rumble.durationsMs[i] = rumbleEntryDurationMs; rumble.lowFrequencyMotorSpeeds[i] = 1.0f; rumble.highFrequencyMotorSpeeds[i] = 1.0f; } #endif if (HapticController.Init() || GamepadRumbler.IsConnected()) { HapticController.Load(System.Text.Encoding.UTF8.GetBytes(json), rumble); HapticController.Loop(false); HapticController.clipLevel = clampedAmplitude; HapticController.clipFrequencyShift = clampedFrequency; HapticController.Play(); } else if (DeviceCapabilities.isVersionSupported) { #if (UNITY_ANDROID && !UNITY_EDITOR) constantPatternTime[1] = duration; LofeltHaptics.PlayMaximumAmplitudePattern(constantPatternTime); #elif (UNITY_IOS && !UNITY_EDITOR) HapticPatterns.PlayPreset(PresetType.HeavyImpact); #endif } } static Preset GetPresetForType(PresetType type) { Debug.Assert(type != PresetType.None); switch (type) { case PresetType.Selection: return Selection; case PresetType.LightImpact: return Light; case PresetType.MediumImpact: return Medium; case PresetType.HeavyImpact: return Heavy; case PresetType.RigidImpact: return Rigid; case PresetType.SoftImpact: return Soft; case PresetType.Success: return Success; case PresetType.Failure: return Failure; case PresetType.Warning: return Warning; } // Silence compiler warning about not all code paths returning something return Medium; } /// /// Plays a set of predefined haptic patterns. /// /// /// These predefined haptic patterns are played and represented in different ways for iOS, /// Android and gamepads. /// /// - On iOS, this function triggers system haptics that are native to iOS. Calling /// \ref HapticController.Stop() won't stop haptics. /// - On Android devices that can play .haptic clips (DeviceCapabilities.meetsAdvancedRequirements /// is true) and on gamepads, this function plays a haptic pattern that has a similar /// experience to the matching iOS system haptics. /// - On Android devices that can not play .haptic clips (DeviceCapabilities.meetsAdvancedRequirements /// is false), this function plays a haptic pattern that has a similar experience to /// the matching iOS system haptics, by turning the motor off and on at maximum amplitude. /// /// This is a "fire-and-forget" method. Other functionalities like seeking, looping, and /// runtime modulation won't work after calling this method. /// /// Type of preset represented by a \ref PresetType enum public static void PlayPreset(PresetType presetType) { if (!HapticController.hapticsEnabled || presetType == PresetType.None) { return; } Preset preset = GetPresetForType(presetType); #if (UNITY_IOS && !UNITY_EDITOR) LofeltHaptics.TriggerPresetHaptics((int)presetType); return; #else if (HapticController.Init() || GamepadRumbler.IsConnected()) { #if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT HapticController.Load(preset.jsonClip, preset.gamepadRumble); #else HapticController.Load(preset.jsonClip); #endif HapticController.Loop(false); HapticController.Play(); return; } if (DeviceCapabilities.isVersionSupported) { #if (UNITY_ANDROID && !UNITY_EDITOR) LofeltHaptics.PlayMaximumAmplitudePattern(preset.maximumAmplitudePattern); return; #endif } #endif } /// /// Returns the haptic preset duration. /// /// /// While a preset is played back in different ways on iOS, Android and gamepads, the /// duration is similar for each playback method. /// /// Type of preset represented by a \ref PresetType enum /// Returns a float with a the preset duration; if the selected preset is `None`, it returns 0 public static float GetPresetDuration(PresetType presetType) { if (presetType == PresetType.None) { return 0; } return GetPresetForType(presetType).GetDuration(); } } }