Added Feel plugin

This commit is contained in:
journaliciouz
2025-12-11 14:49:16 +01:00
parent 97dce4aaf6
commit 1942a531d4
2820 changed files with 257786 additions and 9 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 688d27f50942c40c39cb42dc1e5eab7a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NiceVibrationTests")]

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: e0924103a050c4bbc88d415b79a67df2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/AssemblyInfo.cs
uploadId: 830868

View File

@@ -0,0 +1,253 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
#if (UNITY_IOS && !UNITY_EDITOR)
using UnityEngine.iOS;
#endif
namespace Lofelt.NiceVibrations
{
/// <summary>
/// A class containing properties that describe the current device capabilities for use with
/// Nice Vibrations
/// </summary>
///
/// This class describes the capabilities of an iOS or Android device, gamepads are not handled
/// by it.
public static class DeviceCapabilities
{
/// <summary>
/// Property that holds the current RuntimePlatform
/// </summary>
public static RuntimePlatform platform { get; }
/// <summary>
/// Property that holds the current platform version.
/// </summary>
/// iOS version on iOS, Android API level on Android or 0 otherwise.
public static int platformVersion { get; }
/// <summary>
/// Indicates if the device meets the requirements to play advanced haptics with
/// Nice Vibrations
/// </summary>
///
/// Advanced requirements means that the device can play back <c>.haptic</c> clips.
/// While devices that don't meet the advanced requirements can not play back <c>.haptic</c>
/// clips, they can still play back simpler fallback haptics as long as
/// \ref isVersionSupported is <c>true</c>.
///
/// While DeviceCapabilities.isVersionSupported only checks the OS version, this method
/// additionally checks the device capabilities.
///
/// The required device capabilities are:
/// - iOS: iPhone >= 8
/// - Android: Amplitude control for the <c>Vibrator</c>
///
/// You don't usually need to check this property. All other methods in HapticController
/// will check \ref meetsAdvancedRequirements before calling into <c>LofeltHaptics</c>.
/// In case the device does not support advanced haptics there is a possibility of fallback
/// haptics based on presets.
public static bool meetsAdvancedRequirements
{
get
{
return _meetsAdvancedRequirements;
}
}
private static bool _meetsAdvancedRequirements;
/// <summary>
/// Indicates if the OS version is high enough to play haptics with Nice Vibrations.
/// </summary>
///
/// The minimum required versions are:
/// - iOS >= 11
/// - Android API level >= 17
///
/// This only checks the minimum supported OS version in terms of API and does not guarantee
/// that advanced haptics with amplitude control can be recreated, For that check with
/// \ref meetsAdvancedRequirements.
public static bool isVersionSupported { get; }
/// <summary>
/// Indicates if the device is capable of amplitude control in order to recreate
/// advanced haptics.
/// </summary>
public static bool hasAmplitudeControl
{
get
{
return _hasAmplitudeControl;
}
}
private static bool _hasAmplitudeControl;
/// <summary>
/// Indicates if the device is capable of changing the frequency of haptic signals
/// </summary>
public static bool hasFrequencyControl
{
get
{
return _hasFrequencyControl;
}
}
private static bool _hasFrequencyControl;
/// <summary>
/// Indicates if the device is capable of real-time amplitude modulation of haptic signals
/// </summary>
public static bool hasAmplitudeModulation
{
get
{
return _hasAmplitudeModulation;
}
}
private static bool _hasAmplitudeModulation;
/// <summary>
/// Indicates if the device is capable of real-time frequency modulation of haptic signals
/// </summary>
public static bool hasFrequencyModulation
{
get
{
return _hasFrequencyModulation;
}
}
private static bool _hasFrequencyModulation;
/// <summary>
/// Indicates if the device is capable of natively reproducing emphasized haptics
/// </summary>
public static bool hasEmphasis
{
get
{
return _hasEmphasis;
}
}
private static bool _hasEmphasis;
/// <summary>
/// Indicates if the device is capable of emulating emphasized haptics
/// </summary>
public static bool canEmulateEmphasis
{
get
{
return _canEmulateEmphasis;
}
}
private static bool _canEmulateEmphasis;
/// <summary>
/// Indicates if the device is capable of looping haptic clips
/// </summary>
public static bool canLoop
{
get
{
return _canLoop;
}
}
private static bool _canLoop;
/// <summary>
/// Constructor that fills in the only the DeviceCapabilities platform version properties.
/// </summary>
/// This is separate of Init() because we need to first check the version numbers before
/// initializing <c>LofeltHaptics</c>
static DeviceCapabilities()
{
platform = Application.platform;
platformVersion = 0;
isVersionSupported = false;
#if (UNITY_ANDROID && !UNITY_EDITOR)
platformVersion = int.Parse(SystemInfo.operatingSystem.Substring(SystemInfo.operatingSystem.IndexOf("-") + 1, 3));
const int minimumSupportedAndroidSDKVersion = 17;
isVersionSupported = platformVersion >= minimumSupportedAndroidSDKVersion;
#elif (UNITY_IOS && !UNITY_EDITOR)
string versionString = Device.systemVersion;
string[] versionArray = versionString.Split('.');
platformVersion = int.Parse(versionArray[0]);
const int minimumSupportedIOSVersion = 11;
isVersionSupported = platformVersion >= minimumSupportedIOSVersion;
DeviceGeneration generation = Device.generation;
if ((generation == DeviceGeneration.iPhone3G)
|| (generation == DeviceGeneration.iPhone3GS)
|| (generation == DeviceGeneration.iPodTouch1Gen)
|| (generation == DeviceGeneration.iPodTouch2Gen)
|| (generation == DeviceGeneration.iPodTouch3Gen)
|| (generation == DeviceGeneration.iPodTouch4Gen)
|| (generation == DeviceGeneration.iPhone4)
|| (generation == DeviceGeneration.iPhone4S)
|| (generation == DeviceGeneration.iPhone5)
|| (generation == DeviceGeneration.iPhone5C)
|| (generation == DeviceGeneration.iPhone5S)
|| (generation == DeviceGeneration.iPhone6)
|| (generation == DeviceGeneration.iPhone6Plus)
|| (generation == DeviceGeneration.iPhone6S)
|| (generation == DeviceGeneration.iPhone6SPlus)
|| (generation == DeviceGeneration.iPhoneSE1Gen)
|| (generation == DeviceGeneration.iPad1Gen)
|| (generation == DeviceGeneration.iPad2Gen)
|| (generation == DeviceGeneration.iPad3Gen)
|| (generation == DeviceGeneration.iPad4Gen)
|| (generation == DeviceGeneration.iPad5Gen)
|| (generation == DeviceGeneration.iPadAir1)
|| (generation == DeviceGeneration.iPadAir2)
|| (generation == DeviceGeneration.iPadMini1Gen)
|| (generation == DeviceGeneration.iPadMini2Gen)
|| (generation == DeviceGeneration.iPadMini3Gen)
|| (generation == DeviceGeneration.iPadMini4Gen)
|| (generation == DeviceGeneration.iPadPro10Inch1Gen)
|| (generation == DeviceGeneration.iPadPro10Inch2Gen)
|| (generation == DeviceGeneration.iPadPro11Inch)
|| (generation == DeviceGeneration.iPadPro1Gen)
|| (generation == DeviceGeneration.iPadPro2Gen)
|| (generation == DeviceGeneration.iPadPro3Gen)
|| (generation == DeviceGeneration.iPadUnknown)
|| (generation == DeviceGeneration.iPodTouch1Gen)
|| (generation == DeviceGeneration.iPodTouch2Gen)
|| (generation == DeviceGeneration.iPodTouch3Gen)
|| (generation == DeviceGeneration.iPodTouch4Gen)
|| (generation == DeviceGeneration.iPodTouch5Gen)
|| (generation == DeviceGeneration.iPodTouch6Gen)
|| (generation == DeviceGeneration.iPhone6SPlus))
{
isVersionSupported = false;
}
#elif (UNITY_EDITOR)
isVersionSupported = true;
#endif
}
/// <summary>
/// Function that initializes the rest of the DeviceCapabilities properties.
/// Must be called after <c>LofeltHaptics</c> was initialized.
/// </summary>
public static void Init()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
_hasAmplitudeControl = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_canEmulateEmphasis = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_canLoop = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
#elif (UNITY_IOS && !UNITY_EDITOR)
_hasAmplitudeControl = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_hasFrequencyControl = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_hasAmplitudeModulation = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_hasFrequencyModulation = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_hasEmphasis = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
_canLoop = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
#endif
_meetsAdvancedRequirements = LofeltHaptics.DeviceMeetsMinimumPlatformRequirements();
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: ca68228d4301d47fab6a64b6d285e2dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/DeviceCapabilities.cs
uploadId: 830868

View File

@@ -0,0 +1,543 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Timers;
using UnityEngine;
// There are 3 conditions for working gamepad support in Nice Vibrations:
//
// 1. NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED - The input system package needs to be installed.
// See https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Installation.html#installing-the-package
// This is set by Nice Vibrations' assembly definition file, using a version define.
// See https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html#define-symbols
// about version defines, and see Lofelt.NiceVibrations.asmdef for the usage in Nice Vibrations.
//
// 2. ENABLE_INPUT_SYSTEM - The input system needs to be enabled in the project settings.
// See https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Installation.html#enabling-the-new-input-backends
// This define is set by Unity, see https://docs.unity3d.com/Manual/PlatformDependentCompilation.html
//
// 3. NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT - This is a user-defined define which needs to be not set.
// NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT is not set by default. It can be set by a user in the
// player settings to disable gamepad support completely. One reason to do this is to reduce the
// size of a HapticClip asset, as setting this define changes to HapticImporter to not add the
// GamepadRumble to the HapticClip. Changing this define requires re-importing all .haptic clip
// assets to update HapticClip's GamepadRumble.
//
// If any of the 3 conditions is not met, GamepadRumbler doesn't contain any calls into
// UnityEngine.InputSystem, and CanPlay() always returns false.
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
using UnityEngine.InputSystem;
#endif
namespace Lofelt.NiceVibrations
{
/// <summary>
/// Contains a vibration pattern to make a gamepad rumble.
/// </summary>
///
/// GamepadRumble contains the information on when to set what motor speeds on a gamepad
/// to make it rumble with a specific pattern.
///
/// GamepadRumble has three arrays of the same length representing the rumble pattern. The
/// entries for each array index describe for how long to turn on the gamepad's vibration
/// motors, at what speed.
[Serializable]
public struct GamepadRumble
{
/// <summary>
/// The duration, in milliseconds, that the motors will be turned on at the speed set
/// in \ref lowFrequencyMotorSpeeds and \ref highFrequencyMotorSpeeds at the same array
/// index
/// </summary>
[SerializeField]
public int[] durationsMs;
/// <summary>
/// The total duration of the GamepadRumble, in milliseconds
/// </summary>
[SerializeField]
public int totalDurationMs;
/// <summary>
/// The motor speeds of the low frequency motor
/// </summary>
[SerializeField]
public float[] lowFrequencyMotorSpeeds;
/// <summary>
/// The motor speeds of the high frequency motor
/// </summary>
[SerializeField]
public float[] highFrequencyMotorSpeeds;
/// <summary>
/// Checks if the GamepadRumble is valid and also not empty
/// </summary>
/// <returns>Whether the GamepadRumble is valid</returns>
public bool IsValid()
{
return durationsMs != null &&
lowFrequencyMotorSpeeds != null &&
highFrequencyMotorSpeeds != null &&
durationsMs.Length == lowFrequencyMotorSpeeds.Length &&
durationsMs.Length == highFrequencyMotorSpeeds.Length &&
durationsMs.Length > 0;
}
}
/// <summary>
/// Vibrates a gamepad based on a GamepadRumble rumble pattern.
/// </summary>
///
/// GamepadRumbler can load and play back a GamepadRumble pattern on the current
/// gamepad.
///
/// This is a low-level class that normally doesn't need to be used directly. Instead,
/// you can use HapticSource and HapticController to play back haptic clips, as those
/// classes support gamepads by using GamepadRumbler internally.
public static class GamepadRumbler
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
// Per-gamepad state tracking
private class GamepadState
{
public GamepadRumble loadedRumble;
public bool rumbleLoaded = false;
// This Timer is used to wait until it is time to advance to the next entry in loadedRumble.
// When the Timer is elapsed, ProcessNextRumble() is called to set new motor speeds to the
// gamepad.
public Timer rumbleTimer;
// The index of the entry of loadedRumble that is currently being played back
public int rumbleIndex = -1;
// The total duration of rumble entries that have been played back so far
public long rumblePositionMs = 0;
// Keeps track of how much time elapsed since playback was started
public Stopwatch playbackWatch = new Stopwatch();
/// <summary>
/// A multiplication factor applied to the motor speeds of the low frequency motor.
/// </summary>
///
/// The multiplication factor is applied to the low frequency motor speed of every
/// GamepadRumble entry before playing it.
///
/// In other words, this applies a gain (for factors greater than 1.0) or an attenuation
/// (for factors less than 1.0) to the clip. If the resulting speed of an entry is
/// greater than 1.0, it is clipped to 1.0. The speed is clipped hard, no limiter is
/// used.
///
/// The motor speed multiplication is reset when calling Load(), so Load() needs to be
/// called first before setting the multiplication.
///
/// A change of the multiplication is applied to a currently playing rumble, but only
/// for the next rumble entry, not the one currently playing.
public float lowFrequencyMotorSpeedMultiplication = 1.0f;
/// <summary>
/// Same as \ref lowFrequencyMotorSpeedMultiplication, but for the high frequency speed
/// motor.
public float highFrequencyMotorSpeedMultiplication = 1.0f;
public int gamepadID;
public GamepadState(int id)
{
gamepadID = id;
rumbleTimer = new Timer();
}
}
static Dictionary<int, GamepadState> gamepadStates = new Dictionary<int, GamepadState>();
static int currentGamepadID = -1;
/// <summary>
/// Gets the currently selected gamepad ID.
/// </summary>
/// <returns>The current gamepad ID, or -1 if none is set</returns>
public static int GetCurrentGamepadID()
{
return currentGamepadID;
}
#endif
/// <summary>
/// Initializes the GamepadRumbler.
/// </summary>
///
/// This needs to be called from the main thread, which is the reason why this is a method
/// instead of a static constructor: Sometimes Unity calls static constructors from a
/// different thread, and an explicit Init() method gives us more control over this.
public static void Init()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
var syncContext = System.Threading.SynchronizationContext.Current;
// Initialization is now handled per-gamepad when state is created
#endif
}
/// <summary>
/// Checks whether a call to Play() would trigger playback on a gamepad.
/// </summary>
///
/// Playing back a rumble pattern with Play() only works if a gamepad is connected and if
/// a GamepadRumble has been loaded with Load() before.
///
/// <returns>Whether a vibration can be triggered on a gamepad</returns>
public static bool CanPlay()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
return CanPlay(currentGamepadID);
#else
return false;
#endif
}
public static bool CanPlay(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (!gamepadStates.ContainsKey(gamepadID))
return false;
var state = gamepadStates[gamepadID];
return IsConnected(gamepadID) && state.rumbleLoaded && state.loadedRumble.IsValid();
#else
return false;
#endif
}
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
/// <summary>
/// Gets the Gamepad object corresponding to the specified gamepad ID.
/// </summary>
///
/// If the specified ID is out of range of the connected gamepad(s),
/// <c>InputSystem.Gamepad.current</c> will be returned.
///
/// <param name="gamepadID">The ID of the gamepad to be returned.</c> </param>
/// <returns> A <c> InputSystem.Gamepad</c> </returns>
static UnityEngine.InputSystem.Gamepad GetGamepad(int gamepadID)
{
if (gamepadID >= 0)
{
if (gamepadID >= UnityEngine.InputSystem.Gamepad.all.Count)
{
return UnityEngine.InputSystem.Gamepad.current;
}
else
{
return UnityEngine.InputSystem.Gamepad.all[gamepadID];
}
}
return UnityEngine.InputSystem.Gamepad.current;
}
static GamepadState GetOrCreateState(int gamepadID)
{
if (!gamepadStates.ContainsKey(gamepadID))
{
var state = new GamepadState(gamepadID);
var syncContext = System.Threading.SynchronizationContext.Current;
state.rumbleTimer.Elapsed += (object obj, System.Timers.ElapsedEventArgs args) =>
{
syncContext.Post(_ =>
{
ProcessNextRumble(gamepadID);
}, null);
};
gamepadStates[gamepadID] = state;
}
return gamepadStates[gamepadID];
}
#endif
/// <summary>
/// Set the current gamepad for haptics playback by ID.
/// </summary>
///
/// This method needs be called before haptics playback, e.g. \ref HapticController.Play(),
/// \ref HapticPatterns.PlayEmphasis(), \ref HapticPatterns.PlayConstant(), etc, for
/// for the gamepad to be properly selected.
///
/// If this method isn't called, haptics will be played on <c>InputSystem.Gamepad.current</c>
///
/// For example, if you have 3 controllers connected, you have to choose between values 0, 1,
/// and 2.
///
/// If the gamepad ID value doesn't match any connected gamepad, calling
/// this method has no effect.
/// <param name="gamepadID">The ID of the gamepad</param>
public static void SetCurrentGamepad(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (gamepadID < UnityEngine.InputSystem.Gamepad.all.Count)
{
currentGamepadID = gamepadID;
}
#endif
}
/// <summary>
/// Checks whether a gamepad is connected and recognized by Unity's input system.
/// </summary>
///
/// If the input system package is not installed or not enabled, the gamepad is not
/// recognized and treated as not connected here.
///
/// If the <c>NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT</c> define is set in the player settings,
/// this function pretends no gamepad is connected.
///
/// <returns>Whether a gamepad is connected</returns>
public static bool IsConnected()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
return IsConnected(currentGamepadID);
#else
return false;
#endif
}
public static bool IsConnected(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
return GetGamepad(gamepadID) != null;
#else
return false;
#endif
}
/// <summary>
/// Loads a rumble pattern for later playback.
/// </summary>
///
/// <param name="rumble">The rumble pattern to load</param>
public static void Load(GamepadRumble rumble)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
Load(rumble, currentGamepadID);
#endif
}
public static void Load(GamepadRumble rumble, int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
var state = GetOrCreateState(gamepadID);
if (rumble.IsValid())
{
state.loadedRumble = rumble;
state.rumbleLoaded = true;
state.lowFrequencyMotorSpeedMultiplication = 1.0f;
state.highFrequencyMotorSpeedMultiplication = 1.0f;
}
else
{
Unload(gamepadID);
}
#endif
}
/// <summary>
/// Plays back the rumble pattern loaded previously with Load().
/// </summary>
///
/// If no rumble pattern has been loaded, or if no gamepad is connected, this method does
/// nothing.
public static void Play()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
Play(currentGamepadID);
#endif
}
public static void Play(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (CanPlay(gamepadID))
{
var state = gamepadStates[gamepadID];
state.rumbleIndex = 0;
state.rumblePositionMs = 0;
state.playbackWatch.Restart();
ProcessNextRumble(gamepadID);
}
#endif
}
/// <summary>
/// Stops playback previously started with Play() by turning off the gamepad's motors.
/// </summary>
public static void Stop()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
Stop(currentGamepadID);
#endif
}
public static void Stop(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (GetGamepad(gamepadID) != null)
{
GetGamepad(gamepadID).ResetHaptics();
}
if (gamepadStates.ContainsKey(gamepadID))
{
var state = gamepadStates[gamepadID];
state.rumbleTimer.Enabled = false;
state.rumbleIndex = -1;
state.rumblePositionMs = 0;
state.playbackWatch.Stop();
}
#endif
}
public static void StopAll()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
foreach (var kvp in gamepadStates)
{
Stop(kvp.Key);
}
#endif
}
/// <summary>
/// Stops playback and unloads the currently loaded GamepadRumble from memory.
/// </summary>
public static void Unload()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
Unload(currentGamepadID);
#endif
}
public static void Unload(int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (gamepadStates.ContainsKey(gamepadID))
{
var state = gamepadStates[gamepadID];
state.loadedRumble.highFrequencyMotorSpeeds = null;
state.loadedRumble.lowFrequencyMotorSpeeds = null;
state.loadedRumble.durationsMs = null;
state.rumbleLoaded = false;
Stop(gamepadID);
}
#endif
}
public static void SetMotorSpeedMultiplication(float lowFreq, float highFreq)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
SetMotorSpeedMultiplication(lowFreq, highFreq, currentGamepadID);
#endif
}
public static void SetMotorSpeedMultiplication(float lowFreq, float highFreq, int gamepadID)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
if (gamepadStates.ContainsKey(gamepadID))
{
var state = gamepadStates[gamepadID];
state.lowFrequencyMotorSpeedMultiplication = lowFreq;
state.highFrequencyMotorSpeedMultiplication = highFreq;
}
#endif
}
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
private static bool IncreaseRumbleIndex(int gamepadID)
{
if (!gamepadStates.ContainsKey(gamepadID))
return false;
var state = gamepadStates[gamepadID];
state.rumblePositionMs += state.loadedRumble.durationsMs[state.rumbleIndex];
state.rumbleIndex++;
if (state.rumbleIndex == state.loadedRumble.durationsMs.Length)
{
Stop(gamepadID);
return false;
}
return true;
}
private static void ProcessNextRumble(int gamepadID)
{
if (!gamepadStates.ContainsKey(gamepadID))
return;
var state = gamepadStates[gamepadID];
if (state.rumbleIndex == -1)
{
return;
}
if (state.rumbleIndex == state.loadedRumble.durationsMs.Length)
{
Stop(gamepadID);
return;
}
// Figure out for how long the current rumble entry should be played (durationToWait).
// Due to the timer not waiting for exactly the same amount of time that we requested,
// there can be a bit of error that we need to compensate for. For example, if the timer
// waited for 3ms longer than we requested, we play the next rumble entry for a 3ms
// less to compensate for that.
// In fact, Unity triggers the timer only once per frame, so at 30 FPS, the timer
// resolution is 32ms. That means that the timing error can be bigger than the duration
// of the whole rumble entry, and to compensate for that, the entire rumble entry needs
// to be skipped. That's what the loop does: It skips rumble entries to compensate for
// timer error.
UnityEngine.Debug.Assert(state.loadedRumble.IsValid());
UnityEngine.Debug.Assert(state.rumbleLoaded);
UnityEngine.Debug.Assert(state.rumbleIndex >= 0 && state.rumbleIndex <= state.loadedRumble.durationsMs.Length);
long elapsed = state.playbackWatch.ElapsedMilliseconds;
long durationToWait = 0;
while (true)
{
long rumbleEntryDuration = state.loadedRumble.durationsMs[state.rumbleIndex];
long error = elapsed - state.rumblePositionMs;
durationToWait = rumbleEntryDuration - error;
// If durationToWait is <= 0, the current rumble entry needs to be skipped to
// compensate for timer error. Otherwise break and play the current rumble entry.
if (durationToWait > 0)
{
break;
}
// If the end of the rumble has been reached, return, as playback has stopped.
if (!IncreaseRumbleIndex(gamepadID))
{
return;
}
}
float lowFrequencySpeed = state.loadedRumble.lowFrequencyMotorSpeeds[state.rumbleIndex] *
Mathf.Max(state.lowFrequencyMotorSpeedMultiplication, 0.0f);
float highFrequencySpeed = state.loadedRumble.highFrequencyMotorSpeeds[state.rumbleIndex] *
Mathf.Max(state.highFrequencyMotorSpeedMultiplication, 0.0f);
UnityEngine.InputSystem.Gamepad currentGamepad = GetGamepad(gamepadID);
if (currentGamepad != null)
{
currentGamepad.SetMotorSpeeds(lowFrequencySpeed, highFrequencySpeed);
}
else
{
return;
}
// Set up the timer to call ProcessNextRumble() again with the next rumble entry, after
// the duration of the current rumble entry.
state.rumblePositionMs += state.loadedRumble.durationsMs[state.rumbleIndex];
state.rumbleIndex++;
state.rumbleTimer.Interval = durationToWait;
state.rumbleTimer.AutoReset = false;
state.rumbleTimer.Enabled = true;
}
#endif
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: ef20247bd5f04449293bb8ea3982f3ac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Gamepad.cs
uploadId: 830868

View File

@@ -0,0 +1,35 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
namespace Lofelt.NiceVibrations
{
/// <summary>
/// Represents an imported haptic clip asset.
/// </summary>
///
/// HapticClip contains the data of a haptic clip asset imported from a <c>.haptic</c> file,
/// in a format suitable for playing it back at runtime.
/// A HapticClip is created by <c>HapticImporter</c> when importing a haptic clip asset
/// in the Unity editor, and can be played back at runtime with e.g. HapticSource or
/// HapticController::Play().
///
/// It contains two representations:
/// - JSON, used for playback on iOS and Android
/// - GamepadRumble, used for playback on gamepads with the GamepadRumbler class
public class HapticClip : ScriptableObject
{
/// <summary>
/// The JSON representation of the haptic clip, stored as a byte array encoded in UTF-8,
/// without a null terminator
/// </summary>
[SerializeField]
public byte[] json;
/// <summary>
/// The haptic clip represented as a GamepadRumble struct
/// </summary>
[SerializeField]
public GamepadRumble gamepadRumble;
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: df8d044f677634e749812dc987300584
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/HapticClip.cs
uploadId: 830868

View File

@@ -0,0 +1,570 @@
// 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.SetMotorSpeedMultiplication(_outputLevel * _clipLevel, _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)
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
GamepadRumbler.Unload(GamepadRumbler.GetCurrentGamepadID());
#endif
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);
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
GamepadRumbler.Load(rumble, GamepadRumbler.GetCurrentGamepadID());
#endif
// 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();
}
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: eea19a9647af946678dbcea38129dd98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/HapticController.cs
uploadId: 830868

View File

@@ -0,0 +1,514 @@
// 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();
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: e98a6cfb8386a479a8a5c3ded1f05862
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/HapticPatterns.cs
uploadId: 830868

View File

@@ -0,0 +1,108 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
namespace Lofelt.NiceVibrations
{
/// <summary>
/// A <c>MonoBehaviour</c> that forwards global properties from HapticController and
/// handles events
/// </summary>
///
/// While HapticSource provides a per-clip <c>MonoBehaviour</c> API for the functionality
/// in HapticController, HapticReceiver provides a MonoBehaviour API for
/// the global functionality in HapticController.
///
/// HapticReceiver is also responsible for global event handling, such as an application
/// focus change. To make this work correctly, your scene should have exactly one
/// HapticReceiver component, similar to how a scene should have exactly one
/// <c>AudioListener</c>.
///
/// In the future HapticReceiver might receive parameters and distance to
/// HapticSource components, and can be used for global parameter control through Unity
/// Editor GUI.
[AddComponentMenu("Nice Vibrations/Haptic Receiver")]
public class HapticReceiver : MonoBehaviour, ISerializationCallbackReceiver
{
// These two fields are only used for serialization and deserialization.
// HapticController manages the output haptic level and global haptic toggle,
// HapticReceiver forwards these properties so they are available in a
// MonoBehaviour.
// To be able to serialize these properties, HapticReceiver needs to have
// fields for them. Before serialization, these fields are set to the values
// from HapticController, and after deserialization the values are restored
// back to HapticController.
[SerializeField]
[Range(0.0f, 5.0f)]
private float _outputLevel = 1.0f;
[SerializeField]
private bool _hapticsEnabled = true;
/// <summary>
/// Loads all fields from HapticController.
/// </summary>
public void OnBeforeSerialize()
{
_outputLevel = HapticController._outputLevel;
_hapticsEnabled = HapticController._hapticsEnabled;
}
/// <summary>
/// Writes all fields to HapticController.
/// </summary>
public void OnAfterDeserialize()
{
HapticController._outputLevel = _outputLevel;
HapticController._hapticsEnabled = _hapticsEnabled;
}
/// <summary>
/// Forwarded HapticController::outputLevel
/// </summary>
[System.ComponentModel.DefaultValue(1.0f)]
public float outputLevel
{
get { return HapticController.outputLevel; }
set { HapticController.outputLevel = value; }
}
/// <summary>
/// Forwarded HapticController::hapticsEnabled
/// </summary>
[System.ComponentModel.DefaultValue(true)]
public bool hapticsEnabled
{
get { return HapticController.hapticsEnabled; }
set { HapticController.hapticsEnabled = value; }
}
/// <summary>
/// Initializes HapticController.
/// </summary>
///
/// This ensures that the initialization time is spent at startup instead of when
/// the first haptic is triggered during gameplay.
void Start()
{
HapticController.Init();
}
/// <summary>
/// Forwards an application focus change event to HapticController.
/// </summary>
void OnApplicationFocus(bool hasFocus)
{
HapticController.ProcessApplicationFocus(hasFocus);
}
/// <summary>
/// Stops haptic playback on the gamepad when destroyed, to make sure the gamepad
/// stops vibrating when quitting the application.
/// </summary>
void OnDestroy()
{
GamepadRumbler.Stop();
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: ceb29a83998eb4949bc0a9c8e5662fa1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 24c63d27288824cf68c83ec01e0f3643, type: 3}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/HapticReceiver.cs
uploadId: 830868

View File

@@ -0,0 +1,262 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
namespace Lofelt.NiceVibrations
{
/// <summary>
/// Provides haptic playback functionality for a single haptic clip.
/// </summary>
///
/// HapticSource plays back the HapticClip assigned in the \ref clip property
/// when calling Play(). It also provides various ways to control playback, such as
/// seeking, looping and amplitude/frequency modulation.
///
/// When a gamepad is connected, the haptic clip will be played back on that gamepad.
/// See the HapticController documentation for more details about gamepad support.
///
/// At the moment, playback of a haptic source is not triggered automatically
/// by e.g. proximity between the HapticReceiver and the HapticSource,
/// so you need to call Play() to trigger playback.
///
/// You can place multiple HapticSource components in your scene, with a different
/// HapticClip assigned to each.
///
/// HapticSource provides a per-clip <c>MonoBehaviour</c> API for the functionality
/// in HapticController, while HapticReceiver provides a <c>MonoBehaviour</c> API
/// for the global functionality in HapticController.
///
/// <c>HapticSourceInspector</c> provides a custom editor for HapticSource for the
/// Inspector.
[AddComponentMenu("Nice Vibrations/Haptic Source")]
public class HapticSource : MonoBehaviour
{
const int DEFAULT_PRIORITY = 128;
/// The HapticClip this HapticSource loads and plays.
public HapticClip clip;
/// <summary>
/// The priority of the HapticSource
/// </summary>
///
/// This property is set by <c>HapticSourceInspector</c>. 0 is the highest priority and 256
/// is the lowest priority.
///
/// The default value is 128.
public int priority = DEFAULT_PRIORITY;
/// <summary>
/// Jump in time position of haptic source playback.
/// </summary>
///
/// Initially set to 0.0 seconds.
/// This value can only be set when using Seek().
float seekTime = 0.0f;
[SerializeField]
HapticPatterns.PresetType _fallbackPreset = HapticPatterns.PresetType.None;
/// <summary>
/// The haptic preset to be played when it's not possible to play a haptic clip
/// </summary>
[System.ComponentModel.DefaultValue(HapticPatterns.PresetType.None)]
public HapticPatterns.PresetType fallbackPreset
{
get { return _fallbackPreset; }
set { _fallbackPreset = value; }
}
[SerializeField]
bool _loop = false;
/// <summary>
/// Set the haptic source to loop playback of the haptic clip.
/// </summary>
///
/// It will only have any effect once Play() is called.
///
/// See HapticController::Loop() for further details.
[System.ComponentModel.DefaultValue(false)]
public bool loop
{
get { return _loop; }
set { _loop = value; }
}
[SerializeField]
float _level = 1.0f;
/// <summary>
/// The level of the haptic source
/// </summary>
///
/// Haptic source level is applied in combination with output level (which can be set on either
/// HapticReceiver or HapticController according to preference), 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. See
/// HapticController::clipLevel for further details.
[System.ComponentModel.DefaultValue(1.0)]
public float level
{
get { return _level; }
set
{
_level = value;
if (IsLoaded())
{
HapticController.clipLevel = _level;
}
}
}
[SerializeField]
float _frequencyShift = 0.0f;
/// <summary>
/// This shift is added to the frequency of every breakpoint in the clip, including the
/// emphasis.
/// </summary>
///
/// See HapticController::clipFrequencyShift for further details.
[System.ComponentModel.DefaultValue(0.0)]
public float frequencyShift
{
get { return _frequencyShift; }
set
{
_frequencyShift = value;
if (IsLoaded())
{
HapticController.clipFrequencyShift = _frequencyShift;
}
}
}
/// The HapticSource that is currently loaded into HapticController.
/// This can be null if nothing was ever loaded, or if HapticController::Load()
/// was called directly, bypassing HapticSource.
static HapticSource loadedHapticSource = null;
/// The HapticSource that was last played.
/// This can be null if nothing was ever player, or if HapticController::Play()
/// was called directly, bypassing HapticSource.
/// The lastPlayedHapticSource isn't necessarily playing now, lastPlayedHapticSource
/// will remain set even if playback has finished or was stopped.
static HapticSource lastPlayedHapticSource = null;
static HapticSource()
{
// When HapticController::Load() or HapticController::Play() is
// called directly, bypassing HapticSource, reset loadedHapticSource
// and lastPlayedHapticSource.
HapticController.LoadedClipChanged += () =>
{
loadedHapticSource = null;
};
HapticController.PlaybackStarted += () =>
{
lastPlayedHapticSource = null;
};
}
/// <summary>
/// Loads and plays back the haptic clip.
/// </summary>
///
/// At the moment only one haptic clip at a time can be played. If another
/// HapticSource is currently playing and has lower priority, its playback will
/// be stopped.
///
/// If a seek time within the time range of the clip has been set with Seek(),
/// it will jump to that position if \ref loop is <c>false</c>. If \ref loop
/// is <c>true</c>, seeking will have no effect.
///
/// It will loop playback in case \ref loop is <c>true</c>.
public void Play()
{
if (CanPlay())
{
//
// Load
//
HapticController.Load(clip);
loadedHapticSource = this;
//
// Apply properties like loop, modulation and seek position
//
HapticController.Loop(loop);
HapticController.clipLevel = level;
HapticController.clipFrequencyShift = frequencyShift;
if (seekTime != 0.0f && !loop)
{
HapticController.Seek(seekTime);
}
//
// Play
//
HapticController.fallbackPreset = fallbackPreset;
HapticController.Play();
lastPlayedHapticSource = this;
}
}
private bool CanPlay()
{
return (!HapticController.IsPlaying() ||
(lastPlayedHapticSource != null && priority <= lastPlayedHapticSource.priority));
}
/// <summary>
/// Checks if the current HapticSource has been loaded into HapticController.
/// </summary>
///
/// This is used to avoid triggering operations on HapticController while
/// another HapticSource is loaded.
private bool IsLoaded()
{
return Object.ReferenceEquals(this, loadedHapticSource);
}
/// <summary>
/// Stops playback that was previously started with Play().
/// </summary>
public void Stop()
{
if (IsLoaded())
{
HapticController.Stop();
}
}
/// <summary>
/// Sets the time position to jump to when Play() is called.
/// </summary>
///
/// It will only have an effect once Play() is called.
///
/// <param name="time">The position in the clip, in seconds</param>
public void Seek(float time)
{
this.seekTime = time;
}
/// <summary>
/// When a <c>GameObject</c> is disabled, stop playback if this HapticSource is
/// playing.
/// </summary>
public void OnDisable()
{
if (HapticController.IsPlaying() && IsLoaded())
{
this.Stop();
}
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: d20df93fb7de8457baa15a213a53ab19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7c1be57d46a3143daa1fe62dbc59772f, type: 3}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/HapticSource.cs
uploadId: 830868

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 729f668a205524058a62426043ee3083
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,99 @@
fileFormatVersion: 2
guid: 24c63d27288824cf68c83ec01e0f3643
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 11
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Icons/HapticReceiverIcon.png
uploadId: 830868

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,99 @@
fileFormatVersion: 2
guid: 7c1be57d46a3143daa1fe62dbc59772f
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 11
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Icons/HapticSourceIcon.png
uploadId: 830868

View File

@@ -0,0 +1,135 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
#if (UNITY_ANDROID && !UNITY_EDITOR)
using System;
using UnityEngine;
namespace Lofelt.NiceVibrations
{
// Android JNI call wrappers that are more efficient than AndroidJavaObject::Call()
//
// Calling a method via AndroidJavaObject, e.g. `lofeltHaptics.Call("play")`, is inefficient:
// - It looks up the method by name for each call
// - It allocates memory during method lookup and argument conversion
//
// JNIHelpers provides alternative Call() methods that are more efficient:
// - It allows calling by method ID rather by method name, so that the method only needs to
// be looked up once, not for every call
// - It does not allocate memory for converting the arguments to jvalue[]
//
// In addition to that, exceptions thrown in Java are handled automatically by logging them.
//
// The Call() overload here do not cover all cases that AndroidJavaObject::Call() covers. For
// example, only methods with one argument are supported, and that only for certain types. In
// addition, not all overloads are free of allocations. This however is good enough so that the
// calls triggered by common playback scenarios such as HapticController::Play() and
// HapticPatterns::PlayPreset() don't allocate.
internal static class JNIHelpers
{
// The array for the JNI arguments is created here, so that it doesn't need to be created
// for every call. This saves the allocation in each call.
// The array supports only methods with 0 or 1 argument, but that covers our needs.
static jvalue[] jniArgs = new jvalue[1];
// Returns an exception message and stack trace for the given Java exception
static String javaThrowableToString(IntPtr throwable)
{
IntPtr throwableClass = AndroidJNI.FindClass("java/lang/Throwable");
IntPtr androidUtilLogClass = AndroidJNI.FindClass("android/util/Log");
try
{
IntPtr toStringMethodId = AndroidJNI.GetMethodID(throwableClass, "toString", "()Ljava/lang/String;");
IntPtr getStackTraceStringMethodId = AndroidJNI.GetStaticMethodID(androidUtilLogClass, "getStackTraceString", "(Ljava/lang/Throwable;)Ljava/lang/String;");
string exceptionMessage = AndroidJNI.CallStringMethod(throwable, toStringMethodId, new jvalue[] { });
jniArgs[0].l = throwable;
string exceptionCallStack = AndroidJNI.CallStaticStringMethod(androidUtilLogClass, getStackTraceStringMethodId, jniArgs);
return exceptionMessage + "\n" + exceptionCallStack;
}
finally
{
if (throwable != IntPtr.Zero)
AndroidJNI.DeleteLocalRef(throwable);
if (throwableClass != IntPtr.Zero)
AndroidJNI.DeleteLocalRef(throwableClass);
if (androidUtilLogClass != IntPtr.Zero)
AndroidJNI.DeleteLocalRef(androidUtilLogClass);
}
}
public static void Call(AndroidJavaObject obj, IntPtr methodId, jvalue[] jniArgs)
{
if (methodId == IntPtr.Zero)
{
return;
}
try
{
AndroidJNI.CallVoidMethod(obj.GetRawObject(), methodId, jniArgs);
IntPtr throwable = AndroidJNI.ExceptionOccurred();
if (throwable != IntPtr.Zero)
{
AndroidJNI.ExceptionClear();
String exception = javaThrowableToString(throwable);
Debug.LogError(exception);
}
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
public static void Call(AndroidJavaObject obj, IntPtr methodId)
{
jniArgs[0].l = System.IntPtr.Zero;
Call(obj, methodId, jniArgs);
}
public static void Call(AndroidJavaObject obj, IntPtr methodId, float arg)
{
jniArgs[0].f = arg;
Call(obj, methodId, jniArgs);
}
public static void Call(AndroidJavaObject obj, IntPtr methodId, bool arg)
{
jniArgs[0].z = arg;
Call(obj, methodId, jniArgs);
}
public static void Call(AndroidJavaObject obj, IntPtr methodId, float[] arg)
{
// The allocations in the next two lines could probably be removed to optimize this
// further.
object[] args = new object[] { arg };
jvalue[] jniArgs = AndroidJNIHelper.CreateJNIArgArray(args);
try
{
JNIHelpers.Call(obj, methodId, jniArgs);
}
finally
{
AndroidJNIHelper.DeleteJNIArgArray(args, jniArgs);
}
}
// The method isn't yet optimized to reduce allocations, but unlike the other overloads of
// Call(), it supports non-void return types.
public static ReturnType Call<ReturnType>(AndroidJavaObject obj, string methodName)
{
try
{
return obj.Call<ReturnType>(methodName);
}
catch (Exception ex)
{
Debug.LogException(ex);
return default(ReturnType);
}
}
}
}
#endif

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: 309cd98b547c14b48b9f1c523a6fdc26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/JNIHelpers.cs
uploadId: 830868

View File

@@ -0,0 +1,295 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
using System;
#if (UNITY_ANDROID && !UNITY_EDITOR)
using System.Text;
using System.Runtime.InteropServices;
#elif (UNITY_IOS && !UNITY_EDITOR)
using UnityEngine.iOS;
using System.Runtime.InteropServices;
#endif
namespace Lofelt.NiceVibrations
{
/// <summary>
/// C# wrapper for the Lofelt Studio Android and iOS SDK.
/// </summary>
///
/// You should not use this class directly, use HapticController instead, or the
/// <c>MonoBehaviour</c> classes HapticReceiver and HapticSource.
///
/// The Lofelt Studio Android and iOS SDK are included in Nice Vibrations as pre-compiled
/// binary plugins.
///
/// Each method here delegates to either the Android or iOS SDK. The methods should only be
/// called if DeviceMeetsMinimumPlatformRequirements() returns true, otherwise there will
/// be runtime errors.
///
/// All the methods do nothing when running in the Unity editor.
///
/// Before calling any other method, Initialize() needs to be called.
///
/// Errors are printed and swallowed, no exceptions are thrown. On iOS, this happens inside
/// the SDK, on Android this happens with try/catch blocks in this class and in JNIHelpers.
public static class LofeltHaptics
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
static AndroidJavaObject lofeltHaptics;
static AndroidJavaObject hapticPatterns;
static long nativeController;
// Cache the most commonly used JNI method IDs during initialization.
// Calling a Java method via its method ID is faster and uses less allocations than
// calling a method by string, like e.g. 'lofeltHaptics.Call("play")'.
static IntPtr playMethodId = IntPtr.Zero;
static IntPtr stopMethodId = IntPtr.Zero;
static IntPtr seekMethodId = IntPtr.Zero;
static IntPtr loopMethodId = IntPtr.Zero;
static IntPtr setAmplitudeMultiplicationMethodId = IntPtr.Zero;
static IntPtr playMaximumAmplitudePattern = IntPtr.Zero;
[DllImport("lofelt_sdk")]
private static extern bool lofeltHapticsLoadDirect(IntPtr controller, [In] byte[] bytes, long size);
#elif (UNITY_IOS && !UNITY_EDITOR)
// imports of iOS Framework bindings
[DllImport("__Internal")]
private static extern bool lofeltHapticsDeviceMeetsMinimumRequirementsBinding();
[DllImport("__Internal")]
private static extern IntPtr lofeltHapticsInitBinding();
[DllImport("__Internal")]
private static extern bool lofeltHapticsLoadBinding(IntPtr controller, [In] byte[] bytes, long size);
[DllImport("__Internal")]
private static extern bool lofeltHapticsPlayBinding(IntPtr controller);
[DllImport("__Internal")]
private static extern bool lofeltHapticsStopBinding(IntPtr controller);
[DllImport("__Internal")]
private static extern bool lofeltHapticsSeekBinding(IntPtr controller, float time);
[DllImport("__Internal")]
private static extern bool lofeltHapticsSetAmplitudeMultiplicationBinding(IntPtr controller, float factor);
[DllImport("__Internal")]
private static extern bool lofeltHapticsSetFrequencyShiftBinding(IntPtr controller, float shift);
[DllImport("__Internal")]
private static extern bool lofeltHapticsLoopBinding(IntPtr controller, bool enable);
[DllImport("__Internal")]
private static extern float lofeltHapticsGetClipDurationBinding(IntPtr controller);
[DllImport("__Internal")]
private static extern bool lofeltHapticsReleaseBinding(IntPtr controller);
[DllImport("__Internal")]
private static extern bool lofeltHapticsSystemHapticsTriggerBinding(int type);
[DllImport("__Internal")]
private static extern bool lofeltHapticsSystemHapticsInitializeBinding();
[DllImport("__Internal")]
private static extern bool lofeltHapticsSystemHapticsReleaseBinding();
static IntPtr controller = IntPtr.Zero;
static bool systemHapticsInitialized = false;
#endif
/// <summary>
/// Initializes the iOS framework or Android library plugin.
/// </summary>
///
/// This needs to be called before calling any other method.
public static void Initialize()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
try
{
using (var unityPlayerClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var context = unityPlayerClass.GetStatic<AndroidJavaObject>("currentActivity"))
{
lofeltHaptics = new AndroidJavaObject("com.lofelt.haptics.LofeltHaptics", context);
nativeController = lofeltHaptics.Call<long>("getControllerHandle");
hapticPatterns = new AndroidJavaObject("com.lofelt.haptics.HapticPatterns", context);
playMethodId = AndroidJNIHelper.GetMethodID(lofeltHaptics.GetRawClass(), "play", "()V", false);
stopMethodId = AndroidJNIHelper.GetMethodID(lofeltHaptics.GetRawClass(), "stop", "()V", false);
seekMethodId = AndroidJNIHelper.GetMethodID(lofeltHaptics.GetRawClass(), "seek", "(F)V", false);
loopMethodId = AndroidJNIHelper.GetMethodID(lofeltHaptics.GetRawClass(), "loop", "(Z)V", false);
setAmplitudeMultiplicationMethodId = AndroidJNIHelper.GetMethodID(lofeltHaptics.GetRawClass(), "setAmplitudeMultiplication", "(F)V", false);
playMaximumAmplitudePattern = AndroidJNIHelper.GetMethodID(hapticPatterns.GetRawClass(), "playMaximumAmplitudePattern", "([F)V", false);
}
}
catch (Exception ex)
{
Debug.LogException(ex);
}
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsSystemHapticsInitializeBinding();
systemHapticsInitialized = true;
controller = lofeltHapticsInitBinding();
#endif
}
/// <summary>
/// Releases the resources used by the iOS framework or Android library plugin.
/// </summary>
public static void Release()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
try
{
lofeltHaptics.Dispose();
lofeltHaptics = null;
hapticPatterns.Dispose();
hapticPatterns = null;
}
catch (Exception ex)
{
Debug.LogWarning(ex);
}
#elif (UNITY_IOS && !UNITY_EDITOR)
if(DeviceCapabilities.isVersionSupported) {
lofeltHapticsSystemHapticsReleaseBinding();
if(controller != IntPtr.Zero) {
lofeltHapticsReleaseBinding(controller);
controller = IntPtr.Zero;
}
}
#endif
}
public static bool DeviceMeetsMinimumPlatformRequirements()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
return JNIHelpers.Call<bool>(lofeltHaptics, "deviceMeetsMinimumRequirements");
#elif (UNITY_IOS && !UNITY_EDITOR)
return lofeltHapticsDeviceMeetsMinimumRequirementsBinding();
#else
return true;
#endif
}
public static void Load(byte[] data)
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
// For performance reasons, we do *not* call into the Java API with
// `lofeltHaptics.Call("load", data)` here. Instead, we bypass the Java layer and
// call into the native library directly, saving the costly conversion from
// C#'s byte[] to Java's byte[].
//
// No exception handling needed here, lofeltHapticsLoadDirect() is a native method that
// doesn't throw an exception and instead logs the error.
lofeltHapticsLoadDirect((IntPtr)nativeController, data, data.Length);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsLoadBinding(controller, data, data.Length);
#endif
}
public static float GetClipDuration()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
return JNIHelpers.Call<float>(lofeltHaptics, "getClipDuration");
#elif (UNITY_IOS && !UNITY_EDITOR)
return lofeltHapticsGetClipDurationBinding(controller);
#else
//No haptic clip was loaded with Lofelt SDK, so it returns 0.0f
return 0.0f;
#endif
}
public static void Play()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(lofeltHaptics, playMethodId);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsPlayBinding(controller);
#endif
}
public static void PlayMaximumAmplitudePattern(float[] timings)
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(hapticPatterns, playMaximumAmplitudePattern, timings);
#endif
}
public static void Stop()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(lofeltHaptics, stopMethodId);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsStopBinding(controller);
#endif
}
public static void StopPattern()
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
try
{
hapticPatterns.Call("stopPattern");
}
catch (Exception ex)
{
Debug.LogWarning(ex);
}
#endif
}
public static void Seek(float time)
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(lofeltHaptics, seekMethodId, time);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsSeekBinding(controller, time);
#endif
}
public static void SetAmplitudeMultiplication(float factor)
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(lofeltHaptics, setAmplitudeMultiplicationMethodId, factor);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsSetAmplitudeMultiplicationBinding(controller, factor);
#endif
}
public static void SetFrequencyShift(float shift)
{
#if (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsSetFrequencyShiftBinding(controller, shift);
#endif
}
public static void Loop(bool enabled)
{
#if (UNITY_ANDROID && !UNITY_EDITOR)
JNIHelpers.Call(lofeltHaptics, loopMethodId, enabled);
#elif (UNITY_IOS && !UNITY_EDITOR)
lofeltHapticsLoopBinding(controller, enabled);
#endif
}
public static void TriggerPresetHaptics(int type)
{
#if (UNITY_IOS && !UNITY_EDITOR)
if (!systemHapticsInitialized)
{
lofeltHapticsSystemHapticsInitializeBinding();
systemHapticsInitialized = true;
}
lofeltHapticsSystemHapticsTriggerBinding(type);
#endif
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: 921537c8cf6464a24bd55f54ec8ea0d0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/LofeltHaptics.cs
uploadId: 830868

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 16fde46bee7134093b2ebf5df05edebb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,33 @@
{
"version": {
"major": 1,
"minor": 0,
"patch": 0
},
"signals": {
"continuous": {
"envelopes": {
"amplitude": [
{
"time": 0.0,
"amplitude": 1.0
},
{
"time": {duration},
"amplitude": 1.0
}
],
"frequency": [
{
"time": 0,
"frequency": 0.0
},
{
"time": {duration},
"frequency": 0.0
}
]
}
}
}
}

View File

@@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: 28f1ec0b82fdf434b8efa0ef0f9a9d37
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Resources/nv-constant-template.txt
uploadId: 830868

View File

@@ -0,0 +1,37 @@
{
"version": {
"major": 1,
"minor": 0,
"patch": 0
},
"signals": {
"continuous": {
"envelopes": {
"amplitude": [
{
"time": 0.0,
"amplitude": 0.0,
"emphasis": {
"amplitude": {amplitude},
"frequency": {frequency}
}
},
{
"time": {duration},
"amplitude": 0.0
}
],
"frequency": [
{
"time": 0,
"frequency": 1.0
},
{
"time": {duration},
"frequency": 1.0
}
]
}
}
}
}

View File

@@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: 5f7cb0ced88db4af08a2dd3945067cd7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Resources/nv-emphasis-template.txt
uploadId: 830868

View File

@@ -0,0 +1,14 @@
{
"version": {
"major": 1,
"minor": 0,
"patch": 0
},
"signals": {
"continuous": {
"envelopes": {
"amplitude": [ {amplitude-envelope} ]
}
}
}
}

View File

@@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: db8a2f512b50d437b8268f17384f58c8
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Components/Resources/nv-pattern-template.txt
uploadId: 830868

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 290aee7a75d474f7985eaad9d87ac572
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,119 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using System.IO;
using System.Runtime.InteropServices;
using System;
using UnityEngine;
using System.Text;
#if UNITY_2020_2_OR_NEWER
using UnityEditor.AssetImporters;
#elif UNITY_2019_4_OR_NEWER
using UnityEditor.Experimental.AssetImporters;
#endif
namespace Lofelt.NiceVibrations
{
[ScriptedImporter(version: 3, ext: "haptic", AllowCaching = true)]
/// <summary>
/// Provides an importer for the HapticClip component.
/// </summary>
///
/// The importer takes a <c>.haptic</c> file and converts it into a HapticClip.
public class HapticImporter : ScriptedImporter
{
#if !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
[DllImport("nice_vibrations_editor_plugin")]
private static extern IntPtr nv_plugin_convert_haptic_to_gamepad_rumble([In] byte[] bytes, long size);
[DllImport("nice_vibrations_editor_plugin")]
private static extern void nv_plugin_destroy(IntPtr gamepadRumble);
[DllImport("nice_vibrations_editor_plugin")]
private static extern UIntPtr nv_plugin_get_length(IntPtr gamepadRumble);
[DllImport("nice_vibrations_editor_plugin")]
private static extern void nv_plugin_get_durations(IntPtr gamepadRumble, [Out] int[] durations);
[DllImport("nice_vibrations_editor_plugin")]
private static extern void nv_plugin_get_low_frequency_motor_speeds(IntPtr gamepadRumble, [Out] float[] lowFrequencies);
[DllImport("nice_vibrations_editor_plugin")]
private static extern void nv_plugin_get_high_frequency_motor_speeds(IntPtr gamepadRumble, [Out] float[] highFrequencies);
// We can not use "[return: MarshalAs(UnmanagedType.LPUTF8Str)]" here, and have to use
// IntPtr for the return type instead. Otherwise the C# runtime tries to free the returned
// string, which is invalid as the native plugin keeps ownership of the string.
// We use PtrToStringUTF8() to manually convert the IntPtr to a string instead.
[DllImport("nice_vibrations_editor_plugin")]
private static extern IntPtr nv_plugin_get_last_error();
[DllImport("nice_vibrations_editor_plugin")]
private static extern UIntPtr nv_plugin_get_last_error_length();
// Alternative to Marshal.PtrToStringUTF8() which was introduced in .NET 5 and isn't yet
// supported by Unity
private string PtrToStringUTF8(IntPtr ptr, int length)
{
byte[] bytes = new byte[length];
Marshal.Copy(ptr, bytes, 0, length);
return Encoding.UTF8.GetString(bytes, 0, length);
}
#endif
public override void OnImportAsset(AssetImportContext ctx)
{
// Load .haptic clip from file
var fileName = System.IO.Path.GetFileNameWithoutExtension(ctx.assetPath);
var jsonBytes = File.ReadAllBytes(ctx.assetPath);
var hapticClip = HapticClip.CreateInstance<HapticClip>();
hapticClip.json = jsonBytes;
#if !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
// Convert JSON to a GamepadRumble struct. The conversion algorithm is inside the native
// library nice_vibrations_editor_plugin. That plugin is only used in the Unity editor, and
// not at runtime.
GamepadRumble rumble = default;
IntPtr nativeRumble = nv_plugin_convert_haptic_to_gamepad_rumble(jsonBytes, jsonBytes.Length);
if (nativeRumble != IntPtr.Zero)
{
try
{
uint length = (uint)nv_plugin_get_length(nativeRumble);
rumble.durationsMs = new int[length];
rumble.lowFrequencyMotorSpeeds = new float[length];
rumble.highFrequencyMotorSpeeds = new float[length];
nv_plugin_get_durations(nativeRumble, rumble.durationsMs);
nv_plugin_get_low_frequency_motor_speeds(nativeRumble, rumble.lowFrequencyMotorSpeeds);
nv_plugin_get_high_frequency_motor_speeds(nativeRumble, rumble.highFrequencyMotorSpeeds);
int totalDurationMs = 0;
foreach (int duration in rumble.durationsMs)
{
totalDurationMs += duration;
}
rumble.totalDurationMs = totalDurationMs;
}
finally
{
nv_plugin_destroy(nativeRumble);
}
}
else
{
var lastErrorPtr = nv_plugin_get_last_error();
var lastErrorLength = (int)nv_plugin_get_last_error_length();
var lastError = PtrToStringUTF8(lastErrorPtr, lastErrorLength);
Debug.LogWarning($"Failed to convert haptic clip {ctx.assetPath} to gamepad rumble: {lastError}");
}
hapticClip.gamepadRumble = rumble;
#endif
// Use hapticClip as the imported asset
ctx.AddObjectToAsset("com.lofelt.HapticClip", hapticClip);
ctx.SetMainObject(hapticClip);
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: dc84fb4fa9e67485a972c887d976d004
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Editor/HapticImporter.cs
uploadId: 830868

View File

@@ -0,0 +1,155 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using System.IO;
namespace Lofelt.NiceVibrations
{
[CustomEditor(typeof(HapticSource))]
[CanEditMultipleObjects]
/// <summary>
/// Provides an inspector for the HapticSource component
/// </summary>
///
/// The inspector lets you link a HapticSource to a HapticClip.
public class HapticSourceInspector : Editor
{
string hapticsDirectory;
SerializedProperty hapticClip;
SerializedProperty priority;
SerializedProperty level;
SerializedProperty frequencyShift;
SerializedProperty loop;
SerializedProperty fallbackPreset;
public static GUIContent hapticClipLabel = EditorGUIUtility.TrTextContent("Haptic Clip", "The HapticClip asset played by the HapticSource.");
public static GUIContent fallbackPresetLabel = EditorGUIUtility.TrTextContent("Haptic Preset fallback", "Set the haptic preset to play in case the device doesn't support playback of haptic clips");
public static GUIContent loopLabel = EditorGUIUtility.TrTextContent("Loop", "Set the haptic source to loop playback of the haptic clip");
void OnEnable()
{
hapticClip = serializedObject.FindProperty("clip");
priority = serializedObject.FindProperty("priority");
level = serializedObject.FindProperty("_level");
frequencyShift = serializedObject.FindProperty("_frequencyShift");
fallbackPreset = serializedObject.FindProperty("_fallbackPreset");
loop = serializedObject.FindProperty("_loop");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(hapticClip, hapticClipLabel);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(fallbackPreset, fallbackPresetLabel);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(loop, loopLabel);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
CreatePrioritySlider();
CreateLevelSlider();
CreateFrequencyShiftSlider();
serializedObject.ApplyModifiedProperties();
}
/// Helper function to create a priority slider for haptic source with High and Max text labels.
void CreatePrioritySlider()
{
Rect position = EditorGUILayout.GetControlRect(true, EditorGUIUtility.singleLineHeight);
EditorGUI.IntSlider(position, priority, 0, 256);
// Move to next line
position.y += EditorGUIUtility.singleLineHeight;
// Subtract the label
position.x += EditorGUIUtility.labelWidth;
position.width -= EditorGUIUtility.labelWidth;
// Subtract the text field width thats drawn with slider
position.width -= EditorGUIUtility.fieldWidth;
GUIStyle style = GUI.skin.label;
TextAnchor defaultAlignment = GUI.skin.label.alignment;
style.alignment = TextAnchor.UpperLeft; EditorGUI.LabelField(position, "High", style);
style.alignment = TextAnchor.UpperRight; EditorGUI.LabelField(position, "Low", style);
GUI.skin.label.alignment = defaultAlignment;
// Allow space for the High/Low labels
EditorGUILayout.Space();
EditorGUILayout.Space();
EditorGUILayout.Space();
}
/// Helper function to create a level slider for haptic
/// source with labels.
void CreateLevelSlider()
{
Rect position = EditorGUILayout.GetControlRect(true, EditorGUIUtility.singleLineHeight);
EditorGUI.Slider(position, level, 0.0f, 5.0f);
// Move to next line
position.y += EditorGUIUtility.singleLineHeight;
// Subtract the label
position.x += EditorGUIUtility.labelWidth;
position.width -= EditorGUIUtility.labelWidth;
// Subtract the text field width thats drawn with slider
position.width -= EditorGUIUtility.fieldWidth;
GUIStyle style = GUI.skin.label;
TextAnchor defaultAlignment = GUI.skin.label.alignment;
style.alignment = TextAnchor.UpperLeft; EditorGUI.LabelField(position, "0.0", style);
style.alignment = TextAnchor.UpperRight; EditorGUI.LabelField(position, "5.0", style);
GUI.skin.label.alignment = defaultAlignment;
// Allow space for the labels
EditorGUILayout.Space();
EditorGUILayout.Space();
EditorGUILayout.Space();
}
/// Helper function to create a frequency shift slider for haptic
/// source with labels.
void CreateFrequencyShiftSlider()
{
Rect position = EditorGUILayout.GetControlRect(true, EditorGUIUtility.singleLineHeight);
EditorGUI.Slider(position, frequencyShift, -1.0f, 1.0f);
// Move to next line
position.y += EditorGUIUtility.singleLineHeight;
// Subtract the label
position.x += EditorGUIUtility.labelWidth;
position.width -= EditorGUIUtility.labelWidth;
// Subtract the text field width thats drawn with slider
position.width -= EditorGUIUtility.fieldWidth;
GUIStyle style = GUI.skin.label;
TextAnchor defaultAlignment = GUI.skin.label.alignment;
style.alignment = TextAnchor.UpperLeft; EditorGUI.LabelField(position, "-1.0", style);
style.alignment = TextAnchor.UpperRight; EditorGUI.LabelField(position, "1.0", style);
GUI.skin.label.alignment = defaultAlignment;
// Allow space for the labels
EditorGUILayout.Space();
EditorGUILayout.Space();
EditorGUILayout.Space();
}
}
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: 90a030b5ab0574cd9880e136f5e0261c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Editor/HapticSourceInspector.cs
uploadId: 830868

View File

@@ -0,0 +1,17 @@
{
"name": "Lofelt.NiceVibrations.Editor",
"references": [
"GUID:57a0b9bc628ab4740af4b6f1f0b2e134"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: 67bc5fafbf62b48858241ce814d3d489
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Editor/Lofelt.NiceVibrations.Editor.asmdef
uploadId: 830868

View File

@@ -0,0 +1,165 @@
using Lofelt.NiceVibrations;
using UnityEngine;
using UnityEditor;
namespace MoreMountains.FeedbacksForThirdParty
{
/// <summary>
/// A custom drawer for haptic data used by the NV Clip feedback
/// </summary>
[CustomPropertyDrawer(typeof(NVHapticData))]
public class NVHapticDataDrawer : PropertyDrawer
{
/// <summary>
/// Property height computation
/// </summary>
/// <param name="property"></param>
/// <param name="label"></param>
/// <returns></returns>
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
NVHapticData data = property.boxedValue as NVHapticData;
if (data.Clip == null)
{
return EditorGUIUtility.singleLineHeight;
}
else
{
return EditorGUIUtility.singleLineHeight * 14;
}
}
/// <summary>
/// Drawer
/// </summary>
/// <param name="position"></param>
/// <param name="property"></param>
/// <param name="label"></param>
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
SerializedProperty rumbleData = property.FindPropertyRelative("RumbleData");
SerializedProperty totalDurationProp = rumbleData.FindPropertyRelative("totalDurationMs");
NVHapticData data = property.boxedValue as NVHapticData;
if (data.Clip == null)
{
return;
}
EditorGUI.LabelField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight),
label);
float lineHeight = EditorGUIUtility.singleLineHeight;
float y = position.y + lineHeight;
EditorGUI.PropertyField(new Rect(position.x, y, position.width, lineHeight), totalDurationProp);
y += lineHeight + 2;
// Generate curves
int[] amplitudeDurations = new int[data.AmplitudePoints.Count];
float[] amplitudeValues = new float[data.AmplitudePoints.Count];
float[] frequencyValues = new float[data.AmplitudePoints.Count];
for (int i = 0; i < data.AmplitudePoints.Count; i++)
{
var point = data.AmplitudePoints[i];
amplitudeDurations[i] = (int)(data.AmplitudePoints[i].time * 1000f);
amplitudeValues[i] = data.AmplitudePoints[i].emphasis.amplitude;
frequencyValues[i] = data.FrequencyPoints[i].frequency;
}
AnimationCurve amplitudeCurve = GenerateCurve(amplitudeDurations, amplitudeValues);
AnimationCurve emphasisCurve = GenerateCurve(amplitudeDurations, frequencyValues);
// Create rect for combined curve
Rect amplitudeCurveRect = new Rect(position.x, y, position.width, lineHeight * 5);
// Draw background
EditorGUI.DrawRect(amplitudeCurveRect, new Color(0.15f, 0.15f, 0.15f));
// Draw both curves manually
Handles.BeginGUI();
DrawCurveInRect(amplitudeCurve, amplitudeCurveRect, Color.yellow, data.SampleCount);
DrawCurveInRect(emphasisCurve, amplitudeCurveRect, Color.red, data.SampleCount);
Handles.EndGUI();
EditorGUI.LabelField(new Rect(position.x, y + amplitudeCurveRect.height, position.width, lineHeight),
"Yellow = Amplitude | Red = Frequency");
y += lineHeight * 6;
// Generate curves
AnimationCurve lowCurve = GenerateCurve(amplitudeDurations, data.RumbleData.lowFrequencyMotorSpeeds);
AnimationCurve highCurve = GenerateCurve(amplitudeDurations, data.RumbleData.highFrequencyMotorSpeeds);
// Create rect for combined curve
Rect curveRect = new Rect(position.x, y, position.width, lineHeight * 5);
// Draw background
EditorGUI.DrawRect(curveRect, new Color(0.15f, 0.15f, 0.15f));
// Draw both curves manually
Handles.BeginGUI();
DrawCurveInRect(lowCurve, curveRect, Color.green, data.SampleCount);
DrawCurveInRect(highCurve, curveRect, Color.cyan, data.SampleCount);
Handles.EndGUI();
EditorGUI.LabelField(new Rect(position.x, y + curveRect.height, position.width, lineHeight),
"Cyan = Amplitude | Green = Frequency");
}
/// <summary>
/// Curve drawing
/// </summary>
/// <param name="curve"></param>
/// <param name="rect"></param>
/// <param name="color"></param>
/// <param name="sampleCount"></param>
private void DrawCurveInRect(AnimationCurve curve, Rect rect, Color color, int sampleCount)
{
if (curve.length < 2)
return;
Handles.color = color;
Vector3[] points = new Vector3[sampleCount];
float startTime = curve.keys[0].time;
float endTime = curve.keys[curve.length - 1].time;
float timeRange = endTime - startTime;
for (int i = 0; i < points.Length; i++)
{
float t = Mathf.Lerp(startTime, endTime, i / (float)(points.Length - 1));
float val = curve.Evaluate(t);
float x = Mathf.Lerp(rect.x, rect.xMax, (t - startTime) / timeRange);
float y = Mathf.Lerp(rect.yMax, rect.y, val);
points[i] = new Vector3(x, y, 0);
}
Handles.DrawAAPolyLine(2f, points);
}
/// <summary>
/// Curve generation
/// </summary>
/// <param name="durationsProperty"></param>
/// <param name="valueProperty"></param>
/// <returns></returns>
private AnimationCurve GenerateCurve(int[] durationsProperty, float[] valueProperty)
{
AnimationCurve curve = new AnimationCurve();
if (durationsProperty == null || valueProperty == null || durationsProperty.Length != valueProperty.Length)
return curve;
float time = 0f;
for (int i = 0; i < valueProperty.Length; i++)
{
int duration = durationsProperty[i];
float speed = valueProperty[i];
curve.AddKey(time, speed);
time += duration / 1000f;
}
return curve;
}
}
}

View File

@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 89b1ab110187b0b4ebad017b364aafea
AssetOrigin:
serializedVersion: 1
productId: 183370
packageName: Feel
packageVersion: 5.9.1
assetPath: Assets/Feel/NiceVibrations/Scripts/Editor/NVHapticDataDrawer.cs
uploadId: 830868