feat: Import Haptic System

This commit is contained in:
2026-04-13 14:56:06 +08:00
parent a04c08d4cf
commit 7b01e7f906
78 changed files with 4274 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9390e14b79696426d8527e368308b77c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c8e190729c262425ab13ea81b5b478c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,32 @@
fileFormatVersion: 2
guid: 4052800b132124e29b9627e77b348b41
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Android: Android
second:
enabled: 1
settings: {}
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 04babe37531df1741bce773dd20ddca0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1cfa7fc0db963a4897d26b3d50d11ec
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
fileFormatVersion: 2
guid: b177fc310544d6a4c94b26e23d9b31d1
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
: Any
second:
enabled: 0
settings:
Exclude Editor: 0
Exclude Linux64: 1
Exclude OSXUniversal: 1
Exclude WebGL: 1
Exclude Win: 1
Exclude Win64: 1
Exclude WindowsStoreApps: 1
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
CPU: x86_64
DefaultValueInitialized: true
OS: Windows
- first:
Standalone: Linux64
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: OSXUniversal
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win64
second:
enabled: 0
settings:
CPU: None
- first:
WebGL: WebGL
second:
enabled: 0
settings: {}
- first:
Windows Store Apps: WindowsStoreApps
second:
enabled: 0
settings:
CPU: X64
DontProcess: false
PlaceholderPath:
SDK: AnySDK
ScriptingBackend: AnyScriptingBackend
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3cc8ce9203c4a4d03b07e0ae9a3cf219
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,81 @@
fileFormatVersion: 2
guid: 3f3b3e40c5ec34183af765f15c1ce362
folderAsset: yes
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
: Any
second:
enabled: 0
settings:
Exclude Android: 1
Exclude Editor: 1
Exclude Linux64: 1
Exclude OSXUniversal: 1
Exclude Win: 1
Exclude Win64: 1
Exclude iOS: 0
- first:
Android: Android
second:
enabled: 0
settings:
CPU: ARMv7
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
- first:
Standalone: Linux64
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: OSXUniversal
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win64
second:
enabled: 0
settings:
CPU: None
- first:
iPhone: iOS
second:
enabled: 1
settings:
AddToEmbeddedBinaries: true
CPU: AnyCPU
CompileFlags:
FrameworkDependencies:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,163 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
NS_ASSUME_NONNULL_BEGIN
//! Project version number for LofeltHaptics.
FOUNDATION_EXPORT double LofeltHapticsVersionNumber;
//! Project version string for LofeltHaptics.
FOUNDATION_EXPORT const unsigned char LofeltHapticsVersionString[];
//! Custom error domain
extern NSString *_Nonnull const LofeltErrorDomain;
/*!
@class LofeltHaptics
@brief The LofeltHaptics class
@discussion Defines the API of Lofelt SDK for iOS.
The LofeltHaptics class is not thread safe and can only be used from
the main thread.
When the app is put into the background, Core Haptics will not allow
playing any haptics. LofeltHaptics will detect this situation and cease
all activity.
When the app is put into the foreground again, Core Haptics will allow
playing haptics again, and LofeltHaptics re-initalizes itself. However,
haptics that were interrupted when the app was backgrounded do not
automatically resume and need to be started again by calling @c play().
@author Joao Freire, James Kneafsey, Thomas McGuire, Tomash GHz
@copyright © 2020 Lofelt. All rights reserved.
*/
@interface LofeltHaptics : NSObject
{
void *_controller;
id<NSObject> _foregroundNotificationObserver;
id<NSObject> _backgroundNotificationObserver;
}
/*! @abstract Checks if the iPhone meets the minimum requirements
@discussion This allows for a runtime check on iPhones that won't
meet the requirements for Lofelt Haptics.
@return Whether the iPhone supports or not Lofelt Haptics
*/
+ (BOOL)deviceMeetsMinimumRequirement;
- (instancetype)init NS_UNAVAILABLE;
/*! @abstract Creates an instance of LofeltHaptics.
@discussion There should only be one instance of `LofeltHaptics` created in a given application.
@param error If the initialization fails, this will be set to a valid NSError describing the error.
*/
- (nullable instancetype)initAndReturnError:(NSError **)error API_AVAILABLE(ios(13)) NS_SWIFT_NAME(init());
/*! @abstract Loads a haptic clip from string data.
@discussion The data must be in a valid Lofelt JSON format.
If a haptic clip is currently playing, it will be stopped.
@param data The Lofelt JSON format string.
@param error If the load operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)load:(NSString *_Nonnull)data error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract A version of @c load() taking @c NSData instead of @c NSString.
@discussion This method can be faster than @c load(), as it avoids string conversions.
@param data The .haptic clip, as UTF-8 encoded JSON string without a null terminator.
@param error If the load operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)loadFromData:(NSData *_Nonnull)data error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Plays a loaded haptic clip.
@discussion The data must be preloaded using @c load() .
Only one haptic clip can play at a time.
Playback will start from the beginning of the haptic clip, or from the seek
position if seek() has been called before.
Calling play() if the clip is already playing has no effect.
@param error If the play operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)play:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Stops the haptic clip that is currently playing.
@discussion The call is ignored if no clip is loaded or no clip is playing.
@param error If the stop operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)stop:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Jumps to a time position in the haptic clip
@discussion The playback state (playing or stopped) will not be changed unless seeking
beyond the end of the haptic clip. Seeking beyond the end of the clip will stop
playback.
Seeking to a negative position will start playback after a delay.
@param time The new position within the clip, as seconds from the beginning of the clip
@param error If the seek operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)seek:(float)time error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Multiplies the amplitude of every breakpoint of the clip with the given
multiplication factor
@discussion In other words, this function applies a gain (for factors greater than 1.0)
or an attenuation (for factors less than 1.0) to the clip.
If the resulting amplitude of a breakpoint is greater than 1.0, it is
clipped to 1.0. The amplitude is clipped hard, no limiter is used.
The clip needs to be loaded with @c load() first. Loading a clip resets
the multiplication factor back to the default of 1.0.
If no clip is currently playing, the multiplication will take effect
once @c play() is called. If a clip is currently playing, the multiplication
will take effect immediately.
@param amplitudeMultiplication The factor by which each amplitude will be multiplied. This value is a
multiplication factor, it is not a dB value. The factor needs to be 0
or greater.
@param error If the operation fails, this will be set to a valid NSError describing
the error. An error can for example happen if no clip is loaded, or if
the factor is outside of the valid range.
@return Whether the operation succeeded
*/
- (BOOL)setAmplitudeMultiplication:(float)amplitudeMultiplication error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Adds the given shift to the frequency of every breakpoint in the clip,
including the emphasis.
@discussion In other words, this function shifts all frequencies of the clip.
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 @c load() first. Loading a clip resets
the shift back to the default of 0.0.
If no clip is currently playing, the shift will take effect once @c play()
is called. If a clip is currently playing, the shift will take effect
immediately.
@param shift The amount by which each frequency should be shifted. This number is added
to each frequency value. The shift needs to be between -1.0 and 1.0.
@param error If the operation fails, this will be set to a valid NSError describing
the error. An error can for example happen if no clip is loaded, or if
the shift is outside of the valid range.
@return Whether the operation succeeded
*/
- (BOOL)setFrequencyShift:(float)shift error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Sets the playback to repeat from the start at the end of the clip.
@discussion Changes done with this function are only applied when @c play() is called.
When @c load() is called, looping is always disabled.
Playback will always start at the beginning of the clip, even if
@c seek() was used to jump to a different clip position before.
@param enabled When true, looping is set enabled; false disables looping.
@param error If the loop operation fails, this will be set to a valid NSError describing the error.
@return Whether the operation succeeded
*/
- (BOOL)loop:(BOOL)enabled error:(NSError *_Nullable *_Nullable)error API_AVAILABLE(ios(13));
/*! @abstract Returns the duration of the loaded clip
@discussion It will return 0.0 for an invalid clip
@return Duration of the loaded clip
*/
- (float)getClipDuration;
@end
NS_ASSUME_NONNULL_END
Binary file not shown.
@@ -0,0 +1,6 @@
framework module LofeltHaptics {
umbrella header "LofeltHaptics.h"
export *
module * { export * }
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d7aed595fe8f44c70b4bf38214153c03
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
fileFormatVersion: 2
guid: 279c3792841e74f96b13b007d349facc
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 1
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
: Any
second:
enabled: 0
settings:
Exclude Android: 1
Exclude Editor: 0
Exclude Linux64: 1
Exclude OSXUniversal: 1
Exclude Win: 1
Exclude Win64: 1
Exclude iOS: 1
- first:
Android: Android
second:
enabled: 0
settings:
CPU: ARMv7
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: OSX
- first:
Standalone: Linux64
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: OSXUniversal
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win64
second:
enabled: 0
settings:
CPU: None
- first:
iPhone: iOS
second:
enabled: 0
settings:
AddToEmbeddedBinaries: false
CPU: AnyCPU
CompileFlags:
FrameworkDependencies:
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a6d0c35bae9a4c8aa77f808ab4f0763
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c8d35819e27b74a96b9b9358d9e9fd35
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ea1308527d88a4ad79766c4f2ef437bb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dbba4d66508f04365a91f3b7eb67d2f3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 688d27f50942c40c39cb42dc1e5eab7a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,3 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NiceVibrationTests")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0924103a050c4bbc88d415b79a67df2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca68228d4301d47fab6a64b6d285e2dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,435 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using System;
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
static GamepadRumble loadedRumble;
static 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.
static Timer rumbleTimer = new Timer();
// The index of the entry of loadedRumble that is currently being played back
static int rumbleIndex = -1;
// The total duration of rumble entries that have been played back so far
static long rumblePositionMs = 0;
// Keeps track of how much time elapsed since playback was started
static 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 static float lowFrequencyMotorSpeedMultiplication = 1.0f;
/// <summary>
/// Same as \ref lowFrequencyMotorSpeedMultiplication, but for the high frequency speed
/// motor.
/// </summary>
public static float highFrequencyMotorSpeedMultiplication = 1.0f;
static int currentGamepadID = -1;
#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
// Initialize rumbleTimer, so that ProcessNextRumble() will be called on the main thread
// when the timer is triggered.
var syncContext = System.Threading.SynchronizationContext.Current;
rumbleTimer.Elapsed += (object obj, System.Timers.ElapsedEventArgs args) =>
{
syncContext.Post(_ =>
{
ProcessNextRumble();
}, null);
};
#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 IsConnected() && rumbleLoaded && 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;
}
#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 GetGamepad(currentGamepadID) != 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
if (rumble.IsValid())
{
loadedRumble = rumble;
rumbleLoaded = true;
lowFrequencyMotorSpeedMultiplication = 1.0f;
highFrequencyMotorSpeedMultiplication = 1.0f;
}
else
{
Unload();
}
#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
if (CanPlay())
{
rumbleIndex = 0;
rumblePositionMs = 0;
playbackWatch.Restart();
ProcessNextRumble();
}
#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
if (GetGamepad(currentGamepadID) != null)
{
GetGamepad(currentGamepadID).ResetHaptics();
}
rumbleTimer.Enabled = false;
rumbleIndex = -1;
rumblePositionMs = 0;
playbackWatch.Stop();
#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
loadedRumble.highFrequencyMotorSpeeds = null;
loadedRumble.lowFrequencyMotorSpeeds = null;
loadedRumble.durationsMs = null;
rumbleLoaded = false;
Stop();
#endif
}
// Advances the position in the GamepadRumble by one.
//
// If the end of the rumble has been reached, playback is stopped and false is returned.
private static bool IncreaseRumbleIndex()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
rumblePositionMs += loadedRumble.durationsMs[rumbleIndex];
rumbleIndex++;
if (rumbleIndex == loadedRumble.durationsMs.Length)
{
Stop();
return false;
}
return true;
#else
return false;
#endif
}
// Processes the next entry in loadedRumble by setting the gamepad's motor speeds to the
// speeds stored in that entry.
//
// Afterwards, the rumbleTimer is set to call this method again, after the time stored
// in entry of loadedRumble.
private static void ProcessNextRumble()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
// rumbleIndex can be -1 after Stop() has been called after the call to
// ProcessNextRumble() has already been queued up via SynchronizationContext.
if (rumbleIndex == -1)
{
return;
}
if (rumbleIndex == loadedRumble.durationsMs.Length)
{
Stop();
return;
}
UnityEngine.Debug.Assert(loadedRumble.IsValid());
UnityEngine.Debug.Assert(rumbleLoaded);
UnityEngine.Debug.Assert(rumbleIndex >= 0 && rumbleIndex <= loadedRumble.durationsMs.Length);
// 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.
long elapsed = playbackWatch.ElapsedMilliseconds;
long durationToWait = 0;
while (true)
{
long rumbleEntryDuration = loadedRumble.durationsMs[rumbleIndex];
long error = elapsed - 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())
{
return;
}
}
float lowFrequencySpeed = loadedRumble.lowFrequencyMotorSpeeds[rumbleIndex] * Mathf.Max(lowFrequencyMotorSpeedMultiplication, 0.0f);
float highFrequencySpeed = loadedRumble.highFrequencyMotorSpeeds[rumbleIndex] * Mathf.Max(highFrequencyMotorSpeedMultiplication, 0.0f);
UnityEngine.InputSystem.Gamepad currentGamepad = GetGamepad(currentGamepadID);
// Check if gamepad was disconnected while playing
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.
rumblePositionMs += loadedRumble.durationsMs[rumbleIndex];
rumbleIndex++;
rumbleTimer.Interval = durationToWait;
rumbleTimer.AutoReset = false;
rumbleTimer.Enabled = true;
#endif
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ef20247bd5f04449293bb8ea3982f3ac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df8d044f677634e749812dc987300584
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,568 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
using UnityEngine;
using System;
using System.Timers;
#if (UNITY_ANDROID && !UNITY_EDITOR)
using System.Text;
#elif (UNITY_IOS && !UNITY_EDITOR)
using UnityEngine.iOS;
#endif
namespace Lofelt.NiceVibrations
{
/// <summary>
/// Provides haptic playback functionality.
/// </summary>
///
/// HapticController allows you to load and play <c>.haptic</c> clips, and
/// provides various ways to control playback, such as seeking, looping and
/// amplitude/frequency modulation.
///
/// If you need a <c>MonoBehaviour</c> API, use HapticSource and
/// HapticReceiver instead.
///
/// On iOS and Android, the device is vibrated, using <c>LofeltHaptics</c>.
/// On any platform, when a gamepad is connected, that gamepad is vibrated,
/// using GamepadRumbler.
///
/// Gamepads are vibrated automatically when HapticController detects that a
/// gamepad is connected, no special code is needed to support gamepads.
/// Gamepads only support Load(), Play(), Stop(), \ref clipLevel and \ref
/// outputLevel. Other features like Seek(), Loop() and \ref clipFrequencyShift
/// will have no effect on gamepads.
///
/// None of the methods here are thread-safe and should only be called from
/// the main (Unity) thread. Calling these methods from a secondary thread can
/// cause undefined behaviour and memory leaks.
public static class HapticController
{
static bool lofeltHapticsInitalized = false;
// Timer used to call HandleFinishedPlayback() when playback is complete
static Timer playbackFinishedTimer = new Timer();
// Duration of the loaded haptic clip, in seconds
static float clipLoadedDurationSecs = 0.0f;
// Whether Load() has been called before
static bool clipLoaded = false;
// The value of the last call to seek()
static float lastSeekTime = 0.0f;
// Flag indicating if the device supports playing back .haptic clips
static bool deviceMeetsAdvancedRequirements = false;
// Flag indicating if the user enabled playback looping.
// This does not necessarily mean that the currently active playback is looping, for
// example gamepads don't support looping.
static bool isLoopingEnabledByUser = false;
// Flag indicating if the currently active playback is looping
static bool isPlaybackLooping = false;
static HapticPatterns.PresetType _fallbackPreset = HapticPatterns.PresetType.None;
/// <summary>
/// The haptic preset to be played when it's not possible to play a haptic clip
/// </summary>
public static HapticPatterns.PresetType fallbackPreset
{
get { return _fallbackPreset; }
set { _fallbackPreset = value; }
}
internal static bool _hapticsEnabled = true;
/// <summary>
/// Property to enable and disable global haptic playback
/// </summary>
public static bool hapticsEnabled
{
get { return _hapticsEnabled; }
set
{
if (_hapticsEnabled)
{
Stop();
}
_hapticsEnabled = value;
}
}
internal static float _outputLevel = 1.0f;
/// <summary>
/// The overall haptic output level
/// </summary>
///
/// It can be interpreted as the "volume control" for haptic playback.
/// Output level is applied in combination with \ref clipLevel to the currently playing haptic clip.
/// The combination of these two levels and the amplitude within the loaded haptic at a given moment
/// in time determines the strength of the vibration felt on the device. \ref outputLevel is best used
/// to increase or decrease the overall haptic level in a game.
///
/// As output level pertains to all clips, unlike \ref clipLevel, it persists when a new clip is loaded.
///
/// \ref outputLevel is a multiplication factor, it is <i>not</i> a dB value. The factor needs to be
/// 0 or greater.
///
/// The combination of \ref outputLevel and \ref clipLevel can result in a gain (for factors
/// greater than 1.0) or an attenuation (for factors less than 1.0) to the clip. If the
/// combination of \ref outputLevel, \ref clipLevel and the amplitude within the loaded haptic
/// is greater than 1.0, it is clipped to 1.0. Hard clipping is performed, no limiter is used.
///
/// On Android, an adjustment to \ref outputLevel will take effect in the next call to Play().
/// On iOS, it will take effect right away.
[System.ComponentModel.DefaultValue(1.0f)]
public static float outputLevel
{
get { return _outputLevel; }
set
{
_outputLevel = value;
ApplyLevelsToLofeltHaptics();
ApplyLevelsToGamepadRumbler();
}
}
internal static float _clipLevel = 1.0f;
/// <summary>
/// The level of the loaded clip
/// </summary>
///
/// Clip level is applied in combination with \ref outputLevel, to the
/// currently playing haptic clip. The combination of these two levels and the amplitude within the loaded
/// haptic at a given moment in time determines the strength of the vibration felt on the device.
/// \ref clipLevel is best used to adjust the level of a single clip based on game state.
///
/// As clip level is specific to an individual clip, unlike \ref outputLevel, it resets to
/// 1.0 when a new clip is loaded.
///
/// \ref clipLevel is a multiplication factor, it is <i>not</i> a dB value. The factor needs to be
/// 0 or greater.
///
/// The combination of \ref outputLevel and \ref clipLevel can result in a gain (for factors
/// greater than 1.0) or an attenuation (for factors less than 1.0) to the clip.
///
/// If the combination of \ref outputLevel, \ref clipLevel and the amplitude within the loaded
/// haptic is greater than 1.0, it is clipped to 1.0. Hard clipping is performed, no limiter is used.
///
/// The clip needs to be loaded with Load() before adjusting \ref clipLevel. Loading a clip
/// resets \ref clipLevel back to the default of 1.0.
///
/// On Android, an adjustment to \ref clipLevel will take effect in the next call to Play(). On iOS,
/// it will take effect right away.
///
/// On Android, setting the clip level should be done before calling \ref Seek(), since
/// setting a clip level ignores the sought value.
///
[System.ComponentModel.DefaultValue(1.0f)]
public static float clipLevel
{
get { return _clipLevel; }
set
{
_clipLevel = value;
ApplyLevelsToLofeltHaptics();
ApplyLevelsToGamepadRumbler();
}
}
/// Action that is invoked when Load() is called
public static Action LoadedClipChanged;
/// Action that is invoked when Play() is called
public static Action PlaybackStarted;
/// <summary>
/// Action that is invoked when the playback has finished
/// </summary>
///
/// This happens either when Stop() is explicitly called, or when a non-looping
/// clip has finished playing.
///
/// This can be invoked spuriously, even if no haptics are currently playing, for example
/// if Stop() is called multiple times in a row.
public static Action PlaybackStopped;
// Applies the current clip level and output level as the amplitude multiplication to
// LofeltHaptics
private static void ApplyLevelsToLofeltHaptics()
{
if (Init())
{
LofeltHaptics.SetAmplitudeMultiplication(_outputLevel * _clipLevel);
}
}
// Applies the current clip level and output level as the motor speed multiplication to
// GamepadRumbler
private static void ApplyLevelsToGamepadRumbler()
{
#if ((!UNITY_ANDROID && !UNITY_IOS) || UNITY_EDITOR) && NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED && ENABLE_INPUT_SYSTEM && !NICE_VIBRATIONS_DISABLE_GAMEPAD_SUPPORT
GamepadRumbler.lowFrequencyMotorSpeedMultiplication = _outputLevel * _clipLevel;
GamepadRumbler.highFrequencyMotorSpeedMultiplication = _outputLevel * _clipLevel;
#endif
}
/// <summary>
/// Initializes HapticController.
/// </summary>
///
/// Calling this method multiple times has no effect and is safe.
///
/// You do not need to call this method, HapticController automatically calls this
/// method before any operation that needs initialization, such as Play().
/// However it can be beneficial to call this early during startup, so the initialization
/// time is spent at startup instead of when the first haptic is triggered during gameplay.
/// If you have a HapticReceiver in your scene, it takes care of calling
/// Init() during startup for you.
///
/// Do not call this method from a static constructor. Unity often invokes static
/// constructors from a different thread, for example during deserialization. The
/// initialization code is not thread-safe. This is the reason this method is not called
/// from the static constructor of HapticController or HapticReceiver.
///
/// <returns>Whether the device supports the minimum requirements to play haptics</returns>
public static bool Init()
{
if (!lofeltHapticsInitalized)
{
lofeltHapticsInitalized = true;
var syncContext = System.Threading.SynchronizationContext.Current;
playbackFinishedTimer.Elapsed += (object obj, System.Timers.ElapsedEventArgs args) =>
{
// Timer elapsed events are called from a separate thread, so use
// SynchronizationContext to handle it in the main thread.
syncContext.Post(_ =>
{
HandleFinishedPlayback();
}, null);
};
if (DeviceCapabilities.isVersionSupported)
{
LofeltHaptics.Initialize();
DeviceCapabilities.Init();
deviceMeetsAdvancedRequirements = DeviceCapabilities.meetsAdvancedRequirements;
}
GamepadRumbler.Init();
}
return deviceMeetsAdvancedRequirements;
}
/// <summary>
/// Loads a haptic clip given in JSON format for later playback.
/// </summary>
///
/// This overload of Load() is useful in cases there is only the JSON data of a haptic clip
/// available. Due to only having the JSON data and no GamepadRumble, gamepad playback is
/// not supported with this overload.
///
/// <param name="data">The haptic clip, which is the content of the
/// <c>.haptic</c> file, a UTF-8 encoded JSON string without a null
/// terminator</param>
public static void Load(byte[] data)
{
GamepadRumbler.Unload();
lastSeekTime = 0.0f;
clipLoaded = true;
clipLoadedDurationSecs = 0.0f;
if (Init())
{
LofeltHaptics.Load(data);
}
clipLevel = 1.0f;
LoadedClipChanged?.Invoke();
}
/// <summary>
/// Loads the given HapticClip for later playback.
/// </summary>
///
/// This is the standard way to load a haptic clip, while the other overloads of Load()
/// are for more specialized cases.
///
/// At the moment only one clip can be loaded at a time.
///
/// <param name="clip">The HapticClip to be loaded</param>
public static void Load(HapticClip clip)
{
Load(clip.json, clip.gamepadRumble);
}
/// <summary>
/// Loads the haptic clip given as JSON and GamepadRumble for later playback.
/// </summary>
///
/// This is an overload of Load() that is useful when a HapticClip is not available, and
/// both the JSON and GamepadRumble are. One such case is generating both dynamically at
/// runtime.
///
/// <param name="json">The haptic clip, which is the content of the <c>.haptic</c> file,
/// a UTF-8 encoded JSON string without a null terminator</param>
/// <param name="rumble">The GamepadRumble representation of the haptic clip</param>
public static void Load(byte[] json, GamepadRumble rumble)
{
Load(json);
GamepadRumbler.Load(rumble);
// GamepadRumbler.Load() resets the motor speed multiplication to 1.0, so the levels
// need to be applied here again
ApplyLevelsToGamepadRumbler();
// Load() only sets the correct clip duration on iOS and Android, and sets it to 0.0
// on other platforms. For the other platforms, set a clip duration based on the
// GamepadRumble here.
if (clipLoadedDurationSecs == 0.0f && rumble.IsValid())
{
clipLoadedDurationSecs = rumble.totalDurationMs / 1000.0f;
}
}
static void HandleFinishedPlayback()
{
lastSeekTime = 0.0f;
isPlaybackLooping = false;
playbackFinishedTimer.Enabled = false;
PlaybackStopped?.Invoke();
}
/// <summary>
/// Plays the haptic clip that was previously loaded with Load().
/// </summary>
///
/// If <c>Loop(true)</c> was called previously, the playback will be repeated
/// until Stop() is called. Otherwise the haptic clip will only play once.
///
/// In case the device does not meet the requirements to play <c>.haptic</c> clips, this
/// function will call HapticPatterns.PlayPreset() with the \ref fallbackPreset set. In this
/// case, functionality like seeking, looping and runtime modulation won't do anything as
/// they aren't available for haptic presets.
public static void Play()
{
if (!_hapticsEnabled)
{
return;
}
float remainingPlayDuration = 0.0f;
bool canLoop = false;
if (GamepadRumbler.CanPlay())
{
remainingPlayDuration = clipLoadedDurationSecs;
GamepadRumbler.Play();
}
else if (Init())
{
remainingPlayDuration = Mathf.Max(clipLoadedDurationSecs - lastSeekTime, 0.0f);
canLoop = DeviceCapabilities.canLoop;
LofeltHaptics.Play();
}
else if (DeviceCapabilities.isVersionSupported)
{
remainingPlayDuration = HapticPatterns.GetPresetDuration(fallbackPreset);
HapticPatterns.PlayPreset(fallbackPreset);
}
isPlaybackLooping = isLoopingEnabledByUser && canLoop;
PlaybackStarted?.Invoke();
//
// Call HandleFinishedPlayback() after the playback finishes
//
if (remainingPlayDuration > 0.0f)
{
playbackFinishedTimer.Interval = remainingPlayDuration * 1000;
playbackFinishedTimer.AutoReset = false;
playbackFinishedTimer.Enabled = !isPlaybackLooping;
}
else
{
// Setting playbackFinishedTimer.Interval needs an interval > 0, otherwise it will
// throw an exception.
// Even if the remaining play duration is 0, we still want to trigger everything
// that happens in HandleFinishedPlayback().
// A playback duration of 0 happens in the Unity editor, when loading the clip
// failed or when seeking to the end of a clip.
HandleFinishedPlayback();
}
}
/// <summary>
/// Loads and plays the HapticClip given as an argument.
/// </summary>
///
/// <param name="clip">The HapticClip to be played</param>
public static void Play(HapticClip clip)
{
Load(clip);
Play();
}
/// <summary>
/// Stops haptic playback
///
/// </summary>
public static void Stop()
{
if (Init())
{
LofeltHaptics.Stop();
}
else
{
LofeltHaptics.StopPattern();
}
GamepadRumbler.Stop();
HandleFinishedPlayback();
}
/// <summary>
/// Jumps to a time position in the haptic clip.
/// </summary>
///
/// The playback will always be stopped when this function is called.
/// This is to match the behavior between iOS and Android, since Android needs to
/// restart playback for seek to have effect.
///
/// If seeking beyond the end of the clip, Play() will not reproduce any haptics.
/// Seeking to a negative position will seek to the beginning of the clip.
///
/// <param name="time">The new position within the clip, as seconds from the beginning
/// of the clip</param>
public static void Seek(float time)
{
if (Init())
{
LofeltHaptics.Stop();
LofeltHaptics.Seek(time);
}
GamepadRumbler.Stop();
lastSeekTime = time;
}
/// <summary>
/// Adds the given shift to the frequency of every breakpoint in the clip, including the
/// emphasis.
/// </summary>
///
/// In other words, this property shifts all frequencies of the clip. The frequency shift is
/// added to each frequency value and needs to be between -1.0 and 1.0. If the resulting
/// frequency of a breakpoint is smaller than 0.0 or greater than 1.0, it is clipped to that
/// range. The frequency is clipped hard, no limiter is used.
///
/// The clip needs to be loaded with Load() first. Loading a clip resets the shift back
/// to the default of 0.0.
///
/// Setting the frequency shift has no effect on Android; it only works on iOS.
///
/// A call to this property will change the frequency shift of a currently playing clip
/// right away. If no clip is playing, the shift is applied in the next call to
/// Play().
[System.ComponentModel.DefaultValue(0.0f)]
public static float clipFrequencyShift
{
set
{
if (Init())
{
LofeltHaptics.SetFrequencyShift(value);
}
}
}
/// <summary>
/// Set the playback of a haptic clip to loop.
/// </summary>
///
/// On Android, calling this will always put the playback position at the start of the clip.
/// Also, it will only have an effect when Play() is called again.
///
/// On iOS, if a clip is already playing, calling this will leave the playback position as
/// it is and repeat when it reaches the end. No need to call Play() again for
/// changes to take effect.
///
/// <param name="enabled">If the value is <c>true</c>, looping will be enabled which results
/// in repeating the playback until Stop() is called; if <c>false</c>, the haptic
/// clip will only be played once.</param>
public static void Loop(bool enabled)
{
if (Init())
{
LofeltHaptics.Loop(enabled);
}
isLoopingEnabledByUser = enabled;
}
/// <summary>
/// Checks if the loaded haptic clip is playing.
/// </summary>
///
/// <returns>Whether the loaded clip is playing</returns>
public static bool IsPlaying()
{
if (playbackFinishedTimer.Enabled)
{
return true;
}
else
{
return isPlaybackLooping;
}
}
/// <summary>
/// Stops playback and resets the playback state.
/// </summary>
///
/// Seek position, clip level, clip frequency shift and loop are reset to the
/// default values.
/// The currently loaded clip stays loaded.
/// \ref hapticsEnabled and \ref outputLevel are not reset.
public static void Reset()
{
if (clipLoaded)
{
Seek(0.0f);
Stop();
clipLevel = 1.0f;
clipFrequencyShift = 0.0f;
Loop(false);
}
fallbackPreset = HapticPatterns.PresetType.None;
}
/// <summary>
/// Processes an application focus change event.
/// </summary>
///
/// If you have a HapticReceiver in your scene, the HapticReceiver
/// will take care of calling this method when needed. Otherwise it is your
/// responsibility to do so.
///
/// When the application loses the focus, playback is stopped.
///
/// <param name="hasFocus">Whether the application now has focus</param>
public static void ProcessApplicationFocus(bool hasFocus)
{
if (!hasFocus)
{
// While LofeltHaptics stops playback when the app loses focus,
// calling Stop() here handles additional things such as invoking
// the PlaybackStopped Action.
Stop();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eea19a9647af946678dbcea38129dd98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e98a6cfb8386a479a8a5c3ded1f05862
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ceb29a83998eb4949bc0a9c8e5662fa1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 24c63d27288824cf68c83ec01e0f3643, type: 3}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d20df93fb7de8457baa15a213a53ab19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7c1be57d46a3143daa1fe62dbc59772f, type: 3}
userData:
assetBundleName:
assetBundleVariant:
@@ -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

@@ -0,0 +1,92 @@
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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,92 @@
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:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 309cd98b547c14b48b9f1c523a6fdc26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 921537c8cf6464a24bd55f54ec8ea0d0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dfbca06fe23ec4830a998e7db8247870
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
]
}
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e4781704d93144109a483e1899b7cec6
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
]
}
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e1c679e9069854e06b54bb65f38215ff
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"version": {
"major": 1,
"minor": 0,
"patch": 0
},
"signals": {
"continuous": {
"envelopes": {
"amplitude": [ {amplitude-envelope} ]
}
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cbb8181953d2a49a49f926ebcc75626c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 290aee7a75d474f7985eaad9d87ac572
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
{
/// <summary>
/// Provides an importer for the HapticClip component.
/// </summary>
///
/// The importer takes a <c>.haptic</c> file and converts it into a HapticClip.
[ScriptedImporter(version: 3, ext: "haptic", AllowCaching = true)]
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 = 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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc84fb4fa9e67485a972c887d976d004
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 90a030b5ab0574cd9880e136f5e0261c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
{
"name": "Lofelt.NiceVibrations.Editor",
"rootNamespace": "",
"references": [
"GUID:7399982fb54df4bb981f9e015a651afa"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 67bc5fafbf62b48858241ce814d3d489
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,21 @@
{
"name": "Lofelt.NiceVibrations",
"references": [
"GUID:75469ad4d38634e559750d17036d5f7c"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [
{
"name": "com.unity.inputsystem",
"expression": "1.0",
"define": "NICE_VIBRATIONS_INPUTSYSTEM_INSTALLED"
}
],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7399982fb54df4bb981f9e015a651afa
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -130,6 +130,18 @@ public partial class AudioObject : IBinarySerializable
/// </summary> /// </summary>
public bool RandomType { get; set; } public bool RandomType { get; set; }
/// <summary>
/// 起始音量
/// dB
/// </summary>
public int Volume { get; set; }
/// <summary>
/// 音量变化幅度
/// dB
/// </summary>
public int VolumeStep { get; set; }
public void DeSerialize(BinaryReader reader) public void DeSerialize(BinaryReader reader)
{ {
@@ -166,6 +178,8 @@ public partial class AudioObject : IBinarySerializable
BlendCrossFadeType = (BlendCrossFadeType)reader.ReadByte(); BlendCrossFadeType = (BlendCrossFadeType)reader.ReadByte();
LimitRepetition = reader.ReadByte(); LimitRepetition = reader.ReadByte();
RandomType = reader.ReadBoolean(); RandomType = reader.ReadBoolean();
Volume = reader.ReadInt32();
VolumeStep = reader.ReadInt32();
} }
public void Serialize(BinaryWriter writer) public void Serialize(BinaryWriter writer)
@@ -202,6 +216,8 @@ public partial class AudioObject : IBinarySerializable
writer.Write((byte)BlendCrossFadeType); writer.Write((byte)BlendCrossFadeType);
writer.Write(LimitRepetition); writer.Write(LimitRepetition);
writer.Write(RandomType); writer.Write(RandomType);
writer.Write(Volume);
writer.Write(VolumeStep);
} }
} }
@@ -66,6 +66,11 @@ public static class AudioObjectDefinitions
{ "Chinese Number 08", 49 }, { "Chinese Number 08", 49 },
{ "Chinese Number 09", 49 }, { "Chinese Number 09", 49 },
{ "Chinese Number 10", 49 }, { "Chinese Number 10", 49 },
{ "Bar", 52 },
{ "Beat", 53 },
{ "Grid", 54 },
{ "NVDice", 55 },
{ "NVHeartbeats", 56 },
{ "sfx_amb_desert", 2000 }, { "sfx_amb_desert", 2000 },
{ "sfx_amb_forest", 2001 }, { "sfx_amb_forest", 2001 },
{ "sfx_anim_common_item_fly", 3000 }, { "sfx_anim_common_item_fly", 3000 },
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 042da115ae6c94eb59b52cb4537aa022
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 12705f55004c045a2ae82b5407a3cbbb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,111 @@
/*
* auto generated by tools(注意:千万不要手动修改本文件)
* HapticObject
*/
using System;
using System.IO;
using System.Collections.Generic;
using System.Text;
namespace OCES.Haptic
{
[Serializable]
public partial class HapticObject : IBinarySerializable
{
/// <summary>
///
/// </summary>
public uint Id { get; set; }
/// <summary>
/// 类型
/// </summary>
public HapticType Type { get; set; }
/// <summary>
/// 强度
/// </summary>
public float Amplitude { get; set; }
/// <summary>
/// 尖锐度
/// </summary>
public float Frequency { get; set; }
/// <summary>
/// 时长
/// </summary>
public float Duration { get; set; }
/// <summary>
/// 播放来源
/// </summary>
public string Payload { get; set; }
/// <summary>
/// 回退预设
/// </summary>
public string FallbackPreset { get; set; }
public void DeSerialize(BinaryReader reader)
{
Id = reader.ReadUInt32();
Type = (HapticType)reader.ReadByte();
Amplitude = reader.ReadSingle();
Frequency = reader.ReadSingle();
Duration = reader.ReadSingle();
Payload = reader.ReadString();
FallbackPreset = reader.ReadString();
}
public void Serialize(BinaryWriter writer)
{
writer.Write(Id);
writer.Write((byte)Type);
writer.Write(Amplitude);
writer.Write(Frequency);
writer.Write(Duration);
writer.Write(Payload);
writer.Write(FallbackPreset);
}
}
[Serializable]
public partial class HapticObjectConfig : IBinarySerializable
{
Dictionary<uint,HapticObject> m_hapticObjectInfos = new();
List<HapticObject> m_hapticObjectInfoList;
public List<HapticObject> HapticObjectList()
{
this.m_hapticObjectInfoList ??= new List<HapticObject>(this.m_hapticObjectInfos.Values);
return this.m_hapticObjectInfoList;
}
public void DeSerialize(BinaryReader reader)
{
int count = reader.ReadInt32();
for (int i = 0; i < count; i++)
{
HapticObject tempData = new();
tempData.DeSerialize(reader);
this.m_hapticObjectInfos.Add(tempData.Id, tempData);
}
}
public void Serialize(BinaryWriter writer)
{
writer.Write(this.m_hapticObjectInfos.Count);
foreach (HapticObject hapticObject in this.m_hapticObjectInfos.Values)
{
hapticObject.Serialize(writer);
}
}
public HapticObject QueryById(uint id)
{
return this.m_hapticObjectInfos.GetValueOrDefault(id);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 57a231d8d4f5a433caa1316cd9b055ae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9e1d12770ca114030b35c14f8c72b193
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using System.IO;
namespace OCES.Haptic
{
public interface IBinarySerializable
{
void DeSerialize(BinaryReader reader);
void Serialize(BinaryWriter writer);
}
public enum HapticType
{
Preset = 0, //播放预制
Emphasis = 1, //播放瞬时
Constant = 2, //播放连续
Advance = 3, //播放文件
}
/// <summary>
/// 文件操作类
/// </summary>
public static class FileManager
{
/// <summary>
/// 从内存流中读取二进制
/// </summary>
/// <param name="bytes"></param>
/// <param name="data"></param>
/// <returns></returns>
public static bool ReadBinaryDataFromBytes(byte[] bytes, ref IBinarySerializable data)
{
if (bytes == null)
return false;
using (MemoryStream memoryStream = new(bytes))
{
using (var br = new BinaryReader(memoryStream))
{
data.DeSerialize(br);
br.Close();
}
memoryStream.Close();
}
return true;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d7267d262bcf45e0ba1855dccdc5e87e
timeCreated: 1775705326
@@ -0,0 +1,138 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Lofelt.NiceVibrations;
using UnityEngine;
namespace OCES.Haptic
{
public class HapticSystem : MonoBehaviour
{
public static HapticSystem Instance {get; private set;}
[NonSerialized]
public bool IsHapticSupported = DeviceCapabilities.isVersionSupported;
[NonSerialized]
public bool IsMeetsAdvanceRequirements = DeviceCapabilities.meetsAdvancedRequirements;
HapticObjectConfig m_hapticObjects;
const string k_hapticConfigPath = "HapticData/";
const string k_hapticResourcesPath = "Haptics/";
public void Play(uint hapticId, bool isDirectCall = true)
{
HapticObject hapticObject = this.m_hapticObjects.QueryById(hapticId);
if (hapticObject != null)
{
Play(hapticObject);
if (isDirectCall)
{
Debug.LogWarning($"[Haptic System] Playing haptic id {hapticId} without play audio." +
"This method should only be called during debugging.");
}
}
else
{
Debug.LogError("[Haptic System] Could not find Haptic Object with id: " + hapticId);
}
}
public void Play(HapticObject hapticObject)
{
switch (hapticObject.Type)
{
case HapticType.Preset:
if (Enum.TryParse(hapticObject.Payload, out HapticPatterns.PresetType hapticPattern))
{
HapticPatterns.PlayPreset(hapticPattern);
}
else
{
Debug.LogError($"[Haptic System] Could not parse haptic pattern: {hapticObject.Payload}]");
}
break;
case HapticType.Emphasis:
if (hapticObject.Amplitude < 0f || hapticObject.Frequency < 0f)
{
Debug.LogWarning($"[Haptic System] Haptic {hapticObject.Id} have no amplitude or frequency." +
"Please check the datatable.");
break;
}
HapticPatterns.PlayEmphasis(hapticObject.Amplitude, hapticObject.Frequency);
break;
case HapticType.Constant:
if (hapticObject.Amplitude < 0f || hapticObject.Frequency < 0f || hapticObject.Duration < 0f)
{
Debug.LogWarning($"[Haptic System] Haptic {hapticObject.Id} have no amplitude, frequency or duration." +
"Please check the datatable.");
break;
}
HapticController.Stop();
HapticPatterns.PlayConstant(hapticObject.Amplitude, hapticObject.Frequency, hapticObject.Duration);
break;
case HapticType.Advance:
if (Enum.TryParse(hapticObject.FallbackPreset, out HapticPatterns.PresetType fallbackPreset))
{
HapticController.fallbackPreset = fallbackPreset;
HapticClip hapticClip = GetHapticClip(hapticObject);
if (hapticClip)
{
HapticController.Stop();
HapticController.Play(hapticClip);
}
else
{
Debug.LogError($"[Haptic System] Could not parse haptic file: {hapticObject.Payload}");
}
}
else
{
Debug.LogError($"[Haptic System] Could not parse fallback preset: {hapticObject.FallbackPreset}]");
}
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public void Stop()
{
HapticController.Stop();
}
void Awake()
{
if (Instance && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
this.m_hapticObjects = HapticConfigLoader.Load<HapticObjectConfig>(k_hapticConfigPath + "HapticObject");
}
static HapticClip GetHapticClip(HapticObject hapticObject)
{
return Resources.Load<HapticClip>(k_hapticResourcesPath + hapticObject.Payload);
}
static class HapticConfigLoader
{
internal static T Load<T>(string tableName) where T : IBinarySerializable, new()
{
TextAsset bytes = Resources.Load<TextAsset>(tableName);
if (!bytes)
Debug.LogError($"未找到表 {tableName}");
IBinarySerializable data = new T();
bool readOk = FileManager.ReadBinaryDataFromBytes(bytes.bytes, ref data);
if (readOk)
return (T)data;
Debug.LogError($"{tableName} 解析出错,类型 {typeof(T)}");
return default;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b1939bb20b5db46c1a5f7354ca0fba87
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: