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/HapticPatterns.cs

515 lines
21 KiB
C#

// Copyright (c) Meta Platforms, Inc. and affiliates.
using System;
using UnityEngine;
using System.Globalization;
namespace Lofelt.NiceVibrations
{
/// <summary>
/// A collection of methods to play simple haptic patterns.
/// </summary>
///
/// 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 };
/// <summary>
/// Enum that represents all the types of haptic presets available
/// </summary>
public enum PresetType
{
Selection = 0,
Success = 1,
Warning = 2,
Failure = 3,
LightImpact = 4,
MediumImpact = 5,
HeavyImpact = 6,
RigidImpact = 7,
SoftImpact = 8,
None = -1
}
/// <summary>
/// Structure that represents a haptic pattern with amplitude variations.
/// </summary>
///
/// \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;
}
}
}
/// <summary>
/// Predefined Preset that represents a "Selection" haptic preset
/// </summary>
internal static Preset Selection;
/// <summary>
/// Predefined Preset that represents a "Light" haptic preset
/// </summary>
internal static Preset Light;
/// <summary>
/// Predefined Preset that represents a "Medium" haptic preset
/// </summary>
internal static Preset Medium;
/// <summary>
/// Predefined Preset that represents a "Heavy" haptic preset
/// </summary>
internal static Preset Heavy;
/// <summary>
/// Predefined Preset that represents a "Rigid" haptic preset
/// </summary>
internal static Preset Rigid;
/// <summary>
/// Predefined Preset that represents a "Soft" haptic preset
/// </summary>
internal static Preset Soft;
/// <summary>
/// Predefined Preset that represents a "Success" haptic preset
/// </summary>
internal static Preset Success;
/// <summary>
/// Predefined Preset that represents a "Failure" haptic preset
/// </summary>
internal static Preset Failure;
/// <summary>
/// Predefined Preset that represents a "Warning" haptic preset
/// </summary>
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 });
}
/// <summary>
/// Plays a single emphasis point.
/// </summary>
///
/// 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.
///
/// <param name="amplitude">The amplitude of the emphasis, from 0.0 to 1.0</param>
/// <param name="frequency">The frequency of the emphasis, from 0.0 to 1.0</param>
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
}
}
/// <summary>
/// Automatically selects the fallback preset based on the emphasis point amplitude.
/// </summary>
///
/// <param name="amplitude">The amplitude of the emphasis, from 0.0 to 1.0</param>
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;
}
}
/// <summary>
/// Plays a haptic with constant amplitude and frequency.
/// </summary>
///
/// 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:
/// <ul>
/// <li>On iOS, it will play the preset <c>HapticPatterns.PresetType.HeavyImpact</c>. </li>
///
/// <li>On Android, it will play a pattern with maximum amplitude for the set <c>duration</c>
/// since there is no amplitude control.</li>
///
/// </ul>
/// <param name="amplitude">Amplitude, from 0.0 to 1.0</param>
/// <param name="frequency">Frequency, from 0.0 to 1.0</param>
/// <param name="duration">Play duration in seconds</param>
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;
}
/// <summary>
/// Plays a set of predefined haptic patterns.
/// </summary>
///
/// 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 <c>.haptic</c> clips (DeviceCapabilities.meetsAdvancedRequirements
/// is <c>true</c>) 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 <c>.haptic</c> clips (DeviceCapabilities.meetsAdvancedRequirements
/// is <c>false</c>), 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.
///
/// <param name="presetType">Type of preset represented by a \ref PresetType enum</param>
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
}
/// <summary>
/// Returns the haptic preset duration.
/// </summary>
///
/// While a preset is played back in different ways on iOS, Android and gamepads, the
/// duration is similar for each playback method.
///
/// <param name="presetType"> Type of preset represented by a \ref PresetType enum </param>
/// <returns>Returns a float with a the preset duration; if the selected preset is `None`, it returns 0</returns>
public static float GetPresetDuration(PresetType presetType)
{
if (presetType == PresetType.None)
{
return 0;
}
return GetPresetForType(presetType).GetDuration();
}
}
}