Merge branch 'main' into feature/MusicCallback
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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:
|
||||
@@ -9,9 +9,12 @@ namespace OCES.Audio
|
||||
{
|
||||
public const uint GameState = 1;
|
||||
|
||||
public const uint TileMaterial = 2;
|
||||
|
||||
public static void RegisterAllGameState()
|
||||
{
|
||||
StateGroupRegistry.Register<GameState>(1);
|
||||
StateGroupRegistry.Register<TileMaterial>(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,11 @@ namespace OCES.Audio
|
||||
Bass = 6, // 测试用值
|
||||
}
|
||||
|
||||
public enum TileMaterial
|
||||
{
|
||||
Normal, // 普通牌
|
||||
Ice, // 冰
|
||||
Cloud, // 云
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ public partial class AudioObject : IBinarySerializable
|
||||
/// 0 = 随机播放
|
||||
/// 1 = 顺序播放
|
||||
/// 2 = 混合播放
|
||||
/// 3 = 切换播放
|
||||
/// </summary>
|
||||
public ContainerType ContainerType { get; set; }
|
||||
|
||||
@@ -130,6 +131,33 @@ public partial class AudioObject : IBinarySerializable
|
||||
/// </summary>
|
||||
public bool RandomType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Volume Step阈值 ms
|
||||
/// </summary>
|
||||
public uint VolumeStepThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 起始音量
|
||||
/// dB
|
||||
/// </summary>
|
||||
public int Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 音量变化幅度
|
||||
/// dB
|
||||
/// </summary>
|
||||
public int VolumeStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 要绑定的 StateGroup ID
|
||||
/// </summary>
|
||||
public uint SwitchGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配失败时的备用 AudioObject ID
|
||||
/// </summary>
|
||||
public uint DefaultSwitchId { get; set; }
|
||||
|
||||
|
||||
public void DeSerialize(BinaryReader reader)
|
||||
{
|
||||
@@ -166,6 +194,11 @@ public partial class AudioObject : IBinarySerializable
|
||||
BlendCrossFadeType = (BlendCrossFadeType)reader.ReadByte();
|
||||
LimitRepetition = reader.ReadByte();
|
||||
RandomType = reader.ReadBoolean();
|
||||
VolumeStepThreshold = reader.ReadUInt32();
|
||||
Volume = reader.ReadInt32();
|
||||
VolumeStep = reader.ReadInt32();
|
||||
SwitchGroupId = reader.ReadUInt32();
|
||||
DefaultSwitchId = reader.ReadUInt32();
|
||||
}
|
||||
|
||||
public void Serialize(BinaryWriter writer)
|
||||
@@ -202,6 +235,11 @@ public partial class AudioObject : IBinarySerializable
|
||||
writer.Write((byte)BlendCrossFadeType);
|
||||
writer.Write(LimitRepetition);
|
||||
writer.Write(RandomType);
|
||||
writer.Write(VolumeStepThreshold);
|
||||
writer.Write(Volume);
|
||||
writer.Write(VolumeStep);
|
||||
writer.Write(SwitchGroupId);
|
||||
writer.Write(DefaultSwitchId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,53 +69,40 @@ public static class AudioObjectDefinitions
|
||||
{ "Bar", 52 },
|
||||
{ "Beat", 53 },
|
||||
{ "Grid", 54 },
|
||||
{ "sfx_amb_desert", 2000 },
|
||||
{ "sfx_amb_forest", 2001 },
|
||||
{ "sfx_anim_common_item_fly", 3000 },
|
||||
{ "sfx_anim_corePlay_character_footstep_grass", 3001 },
|
||||
{ "sfx_anim_corePlay_character_footstep_sand", 3002 },
|
||||
{ "sfx_anim_corePlay_character_footstep_stone", 3003 },
|
||||
{ "sfx_anim_corePlay_fireBall", 3004 },
|
||||
{ "sfx_anim_corePlay_freeze", 3005 },
|
||||
{ "sfx_anim_corePlay_getIn_devil", 3006 },
|
||||
{ "sfx_anim_corePlay_getIn_dragon", 3007 },
|
||||
{ "sfx_anim_corePlay_getIn_ghost", 3008 },
|
||||
{ "sfx_anim_corePlay_newBoxFromStorage", 3009 },
|
||||
{ "sfx_anim_corePlay_shield_broke_wood", 3010 },
|
||||
{ "sfx_anim_corePlay_shield_broke_crystal", 3011 },
|
||||
{ "sfx_anim_corePlay_shield_broke_eggRoll", 3012 },
|
||||
{ "sfx_anim_corePlay_shield_show_crystal", 3013 },
|
||||
{ "sfx_anim_corePlay_shield_show_eggRoll", 3014 },
|
||||
{ "sfx_anim_corePlay_shield_show_wood", 3015 },
|
||||
{ "sfx_anim_corePlay_shield_underAttack_wood", 3016 },
|
||||
{ "sfx_anim_corePlay_shield_underAttack_crystal", 3017 },
|
||||
{ "sfx_anim_corePlay_shield_underAttack_eggRoll", 3018 },
|
||||
{ "sfx_anim_corePlay_slow", 3019 },
|
||||
{ "sfx_anim_corePlay_speedUp", 3020 },
|
||||
{ "sfx_anim_corePlay_useProp_", 3021 },
|
||||
{ "sfx_anim_cutScene_in", 3022 },
|
||||
{ "sfx_anim_cutScene_out", 3023 },
|
||||
{ "sfx_notice_common_negative", 4000 },
|
||||
{ "sfx_notice_corePlay_losing", 4001 },
|
||||
{ "sfx_notice_corePlay_restart", 4002 },
|
||||
{ "sfx_notice_corePlay_warning", 4003 },
|
||||
{ "sfx_notice_guide", 4004 },
|
||||
{ "sfx_notice_spinWheel_click", 4005 },
|
||||
{ "sfx_notice_spinWheel_getReward", 4006 },
|
||||
{ "sfx_notice_corePlay_levelStart_hard", 4007 },
|
||||
{ "sfx_ui_labelSwitch_home", 5000 },
|
||||
{ "sfx_ui_panel_common_close", 5001 },
|
||||
{ "sfx_ui_panel_common_open", 5002 },
|
||||
{ "sfx_ui_panel_continue_open", 5003 },
|
||||
{ "sfx_ui_panel_corePlay_guide_open", 5004 },
|
||||
{ "sfx_ui_panel_initPack_open", 5005 },
|
||||
{ "sfx_ui_panel_piggyBank_close", 5006 },
|
||||
{ "sfx_ui_panel_piggyBank_open", 5007 },
|
||||
{ "sfx_ui_panel_removeAds_open", 5008 },
|
||||
{ "0", 5009 },
|
||||
{ "sfx_ui_panel_summerPack_open", 5010 },
|
||||
{ "sfx_ui_panel_unlockItem_open", 5011 },
|
||||
{ "voice_princess_fear", 9000 },
|
||||
{ "NVDice", 55 },
|
||||
{ "NVHeartbeats", 56 },
|
||||
{ "au_sfx_notice_level_countDown_edge", 57 },
|
||||
{ "au_sfx_notice_level_countDown_time", 58 },
|
||||
{ "0,62", 59 },
|
||||
{ "1,65", 59 },
|
||||
{ "2,68", 59 },
|
||||
{ "0,63", 60 },
|
||||
{ "1,66", 60 },
|
||||
{ "2,69", 60 },
|
||||
{ "0,64", 61 },
|
||||
{ "1,67", 61 },
|
||||
{ "2,70", 61 },
|
||||
{ "au_coreplay_choose_v120_a", 62 },
|
||||
{ "au_coreplay_unchoose_v120_a", 63 },
|
||||
{ "au_coreplay_clear_v120_a", 64 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_ice1", 65 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_ice2", 65 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_ice3", 65 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_ice1", 66 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_ice2", 66 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_ice3", 66 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_ice1", 67 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_ice2", 67 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_ice3", 67 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_cloud1", 68 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_cloud2", 68 },
|
||||
{ "au_sfx_ui_button_corePlay_choose_cloud3", 68 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_cloud1", 69 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_cloud2", 69 },
|
||||
{ "au_sfx_ui_button_corePlay_unchoose_cloud3", 69 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_cloud1", 70 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_cloud2", 70 },
|
||||
{ "au_sfx_ui_button_corePlay_clear_cloud3", 70 },
|
||||
};
|
||||
|
||||
public static readonly HashSet<string> AmbiguousNames = new()
|
||||
@@ -143,6 +130,33 @@ public static class AudioObjectDefinitions
|
||||
"Chinese Number 08",
|
||||
"Chinese Number 09",
|
||||
"Chinese Number 10",
|
||||
"0,62",
|
||||
"1,65",
|
||||
"2,68",
|
||||
"0,63",
|
||||
"1,66",
|
||||
"2,69",
|
||||
"0,64",
|
||||
"1,67",
|
||||
"2,70",
|
||||
"au_sfx_ui_button_corePlay_choose_ice1",
|
||||
"au_sfx_ui_button_corePlay_choose_ice2",
|
||||
"au_sfx_ui_button_corePlay_choose_ice3",
|
||||
"au_sfx_ui_button_corePlay_unchoose_ice1",
|
||||
"au_sfx_ui_button_corePlay_unchoose_ice2",
|
||||
"au_sfx_ui_button_corePlay_unchoose_ice3",
|
||||
"au_sfx_ui_button_corePlay_clear_ice1",
|
||||
"au_sfx_ui_button_corePlay_clear_ice2",
|
||||
"au_sfx_ui_button_corePlay_clear_ice3",
|
||||
"au_sfx_ui_button_corePlay_choose_cloud1",
|
||||
"au_sfx_ui_button_corePlay_choose_cloud2",
|
||||
"au_sfx_ui_button_corePlay_choose_cloud3",
|
||||
"au_sfx_ui_button_corePlay_unchoose_cloud1",
|
||||
"au_sfx_ui_button_corePlay_unchoose_cloud2",
|
||||
"au_sfx_ui_button_corePlay_unchoose_cloud3",
|
||||
"au_sfx_ui_button_corePlay_clear_cloud1",
|
||||
"au_sfx_ui_button_corePlay_clear_cloud2",
|
||||
"au_sfx_ui_button_corePlay_clear_cloud3",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace OCES.Audio
|
||||
{
|
||||
public partial class AudioObjectConfig
|
||||
{
|
||||
Dictionary<uint, Dictionary<int, uint>> m_switchMapping;
|
||||
|
||||
internal void PreParseSwitchMappings()
|
||||
{
|
||||
this.m_switchMapping = new Dictionary<uint, Dictionary<int, uint>>();
|
||||
foreach (AudioObject audioObject in AudioObjectList())
|
||||
{
|
||||
if (audioObject.ContainerType != ContainerType.Switch) continue;
|
||||
this.m_switchMapping[audioObject.Id] = ParseSwitchMapping(audioObject);
|
||||
}
|
||||
}
|
||||
|
||||
public AudioObject GetMappingResult(uint switchContainerId, Enum enumState)
|
||||
{
|
||||
if (!this.m_switchMapping.TryGetValue(switchContainerId, out Dictionary<int, uint> switchMapping))
|
||||
return null;
|
||||
|
||||
return switchMapping.TryGetValue(enumState.GetHashCode(), out uint audioObjectId) ? QueryById(audioObjectId) : null;
|
||||
|
||||
}
|
||||
|
||||
Dictionary<int, uint> ParseSwitchMapping(AudioObject switchContainer)
|
||||
{
|
||||
Dictionary<int, uint> switchMapping = new();
|
||||
foreach (string name in switchContainer.Name)
|
||||
{
|
||||
string[] parts = name.Split(',');
|
||||
if(parts.Length != 2)
|
||||
{
|
||||
Debug.LogWarning($"[AudioSystem] 无法解析 Switch Container {switchContainer.Id} 的映射关系!请检查表");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0].Trim(), out int stateValue))
|
||||
{
|
||||
Debug.LogWarning($"[AudioSystem] 无法解析 映射关系!请查表");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!uint.TryParse(parts[1].Trim(), out uint childId))
|
||||
{
|
||||
Debug.LogWarning("");
|
||||
continue;
|
||||
}
|
||||
|
||||
switchMapping.Add(stateValue, childId);
|
||||
}
|
||||
return switchMapping;
|
||||
}
|
||||
|
||||
internal AudioObject GetDefaultSwitchOrFallback(AudioObject switchContainer)
|
||||
{
|
||||
if (switchContainer.DefaultSwitchId == 0)
|
||||
return null;
|
||||
AudioObject defaultChildAudioObject = QueryById(switchContainer.DefaultSwitchId);
|
||||
if (defaultChildAudioObject != null)
|
||||
return defaultChildAudioObject;
|
||||
|
||||
Debug.LogWarning($"[AudioSystem] DefaultSwitch AudioObject {switchContainer.DefaultSwitchId} 不存在。");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68c35969f92d4bb0ba3239d852115c53
|
||||
timeCreated: 1776244015
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
using DG.Tweening;
|
||||
@@ -10,7 +10,9 @@ namespace OCES.Audio
|
||||
public class AudioSystem : MonoBehaviour
|
||||
{
|
||||
public static AudioSystem Instance { get; private set; }
|
||||
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public IReadOnlyDictionary<Type, Enum> ActiveStates { get; private set; }
|
||||
|
||||
const string k_audioConfigPath = "AudioData";
|
||||
const string k_audioResourcePath = "Audios";
|
||||
|
||||
@@ -21,7 +23,7 @@ namespace OCES.Audio
|
||||
AudioGroupConfig m_groups;
|
||||
AudioMixer m_mixer;
|
||||
Tween m_lowpassTween;
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 公开接口
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -42,6 +44,16 @@ namespace OCES.Audio
|
||||
|
||||
public void Play(AudioObject audioObject, Action onPlay = null)
|
||||
{
|
||||
if (audioObject.ContainerType == ContainerType.Switch)
|
||||
{
|
||||
audioObject = ResolveSwitchContainer(audioObject);
|
||||
if (audioObject == null)
|
||||
{
|
||||
Debug.Log("[AudioSystem] 无法解析Switch Container,检查配置表!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.m_sfxSystem.TryPlay(audioObject, onPlay);
|
||||
}
|
||||
|
||||
@@ -49,8 +61,7 @@ namespace OCES.Audio
|
||||
{
|
||||
Play((uint)audioId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Obsolete("Use Play(uint) instead")]
|
||||
public void Play(string audioName)
|
||||
{
|
||||
@@ -114,6 +125,7 @@ namespace OCES.Audio
|
||||
public void SetState<TEnum>(TEnum state) where TEnum : Enum
|
||||
{
|
||||
this.m_musicSystem.OnStateChanged(state);
|
||||
ActiveStates = this.m_musicSystem.ActiveStates;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -139,6 +151,7 @@ namespace OCES.Audio
|
||||
AudioSourcePool sfxPool = new(sfxPoolRoot.transform); // 不传 mixer group,让 SfxSystem 自己设置
|
||||
this.m_sfxSystem = gameObject.AddComponent<SfxSystem>();
|
||||
this.m_audioObjects = AudioConfigLoader.Load<AudioObjectConfig>($"{k_audioConfigPath}/AudioObject");
|
||||
this.m_audioObjects.PreParseSwitchMappings();
|
||||
this.m_groups = AudioConfigLoader.Load<AudioGroupConfig>($"{k_audioConfigPath}/AudioGroup");
|
||||
this.m_sfxSystem.Initialize(this.m_groups, this.m_mixer, sfxPool); // 传入 pool
|
||||
|
||||
@@ -182,19 +195,67 @@ namespace OCES.Audio
|
||||
// ── 注册 StateGroup ──
|
||||
EnumIds.RegisterAllGameState();
|
||||
|
||||
ActiveStates = new Dictionary<Type, Enum>();
|
||||
|
||||
// ── 启动默认音乐与环境音 ──
|
||||
// 触发一次初始状态,让音乐系统从默认状态开始匹配
|
||||
SetState(GameState.Home);
|
||||
//SetState(GameState.Home);
|
||||
}
|
||||
|
||||
AudioObject ResolveSwitchContainer(AudioObject switchContainer)
|
||||
{
|
||||
// 遍历 ActiveStates 找到 TypeId 匹配的枚举类型
|
||||
Enum currentStateValue = null;
|
||||
bool foundGroup = false;
|
||||
foreach (KeyValuePair<Type, Enum> keyValuePair in ActiveStates)
|
||||
{
|
||||
if (StateGroupRegistry.GetTypeId(keyValuePair.Key) != switchContainer.SwitchGroupId) continue;
|
||||
currentStateValue = keyValuePair.Value;
|
||||
foundGroup = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!foundGroup)
|
||||
{
|
||||
Debug.LogWarning($"[AudioSystem] Switch Container {switchContainer.Id} 找不到 TypeId={switchContainer.SwitchGroupId} 对应的状态组。");
|
||||
return this.m_audioObjects.GetDefaultSwitchOrFallback(switchContainer);
|
||||
}
|
||||
|
||||
// 解析AudioObject对象
|
||||
AudioObject childContainer = this.m_audioObjects.GetMappingResult(switchContainer.Id, currentStateValue);
|
||||
return childContainer ?? this.m_audioObjects.GetDefaultSwitchOrFallback(switchContainer);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
static class AudioConfigLoader
|
||||
public static class AudioConfigLoader
|
||||
{
|
||||
public static Dictionary<uint, T> Load<T>(string path, Func<T, uint> keySelector)
|
||||
public static T Load<T>(string configPath, string fileName)
|
||||
where T : IBinarySerializable, new()
|
||||
{
|
||||
string json = System.IO.File.ReadAllText(path);
|
||||
var wrapper = JsonUtility.FromJson<AudioObjectArrayWrapper<T>>(json);
|
||||
return wrapper.AudioObjects.ToDictionary(keySelector);
|
||||
string path = Path.Combine(
|
||||
Application.dataPath, "Resources", configPath, fileName);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Debug.LogError($"[AudioImportTool] 找不到配置文件: {path}");
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
T config = new T();
|
||||
using MemoryStream ms = new(File.ReadAllBytes(path));
|
||||
using BinaryReader reader = new(ms);
|
||||
config.DeSerialize(reader);
|
||||
return config;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[AudioImportTool] 配置表反序列化失败 {fileName}: {e.Message}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public static T Load<T>(string tableName) where T : IBinarySerializable, new()
|
||||
|
||||
@@ -6,7 +6,7 @@ using UnityEngine.UI;
|
||||
|
||||
namespace OCES.Audio.Editor
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
public sealed class DebugInfoCollector : MonoBehaviour
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace OCES.Audio.Editor
|
||||
|
||||
public Dictionary<uint, int> ClipConcurrentCount = new();
|
||||
public List<ActiveSound> ActiveSounds = new();
|
||||
public Dictionary<Type, int> ActiveStates = new();
|
||||
public Dictionary<Type, Enum> ActiveStates = new();
|
||||
readonly StringBuilder m_stringBuilder = new();
|
||||
Text m_textComponent;
|
||||
|
||||
@@ -35,10 +35,9 @@ namespace OCES.Audio.Editor
|
||||
this.m_stringBuilder.Clear();
|
||||
|
||||
this.m_stringBuilder.AppendLine("Current States:");
|
||||
foreach (KeyValuePair<Type, int> activeState in this.ActiveStates)
|
||||
foreach (KeyValuePair<Type, Enum> activeState in this.ActiveStates)
|
||||
{
|
||||
string enumName = Enum.GetName(activeState.Key, activeState.Value) ?? activeState.Value.ToString();
|
||||
this.m_stringBuilder.AppendLine($"{activeState.Key.Name} is {enumName}");
|
||||
this.m_stringBuilder.AppendLine($"{activeState.Key.Name} is {activeState.Value}");
|
||||
}
|
||||
this.m_stringBuilder.AppendLine();
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74dabdb41aa034e7b958b53e70d9ae0d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace OCES.Audio
|
||||
{
|
||||
|
||||
public static class AudioImportTool
|
||||
{
|
||||
const string k_audioResourcePath = "Audios";
|
||||
const string k_audioConfigPath = "AudioData";
|
||||
|
||||
static string AudioAbsolutePath
|
||||
{
|
||||
get { return Path.Combine(Application.dataPath, "Resources", k_audioResourcePath); }
|
||||
}
|
||||
static string ConfigAbsolutePath
|
||||
{
|
||||
get { return Path.Combine(Application.dataPath, "Resources", k_audioConfigPath); }
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Audio/Apply Audio Import Settings")]
|
||||
public static void RunFromMenu()
|
||||
{
|
||||
if (EditorUtility.DisplayDialog(
|
||||
"Apply Audio Import Settings",
|
||||
$"将对 {AudioAbsolutePath} 下所有文件重新应用导入设置,确认继续?",
|
||||
"确认", "取消"))
|
||||
{
|
||||
Run();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the audio import tool from the command line in batch mode.
|
||||
/// This method is designed to be called via Unity's -executeMethod command line argument
|
||||
/// and serves as the entry point for automated audio import workflows.
|
||||
/// It initializes the process and delegates to the main Run method to apply
|
||||
/// audio import settings to all supported audio files in the configured directory.
|
||||
/// </summary>
|
||||
public static void RunCli()
|
||||
{
|
||||
Debug.Log("[AudioImportTool] CLI 模式启动");
|
||||
Run();
|
||||
}
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
// 1. 加载配置表
|
||||
var audioObjectConfig = AudioConfigLoader.Load<AudioObjectConfig>(ConfigAbsolutePath, "AudioObject.bytes");
|
||||
var musicSegmentConfig = AudioConfigLoader.Load<MusicSegmentConfig>(ConfigAbsolutePath, "MusicSegment.bytes");
|
||||
if (audioObjectConfig == null || musicSegmentConfig == null)
|
||||
{
|
||||
Debug.LogError("[AudioImportTool] 配置表加载失败,终止");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 扫描文件
|
||||
List<string> supportedExtensions = new() { ".wav", ".ogg" };
|
||||
DirectoryInfo dir = new(AudioAbsolutePath);
|
||||
FileInfo[] files = dir.GetFiles().Where(f => supportedExtensions.Contains(f.Extension.ToLower())).ToArray();
|
||||
int total = files.Length, processed = 0, failed = 0;
|
||||
|
||||
AssetDatabase.StartAssetEditing(); // 批量操作,暂停自动刷新
|
||||
try
|
||||
{
|
||||
foreach (FileInfo file in files)
|
||||
{
|
||||
// 转成 Assets/... 相对路径供 AssetDatabase 使用
|
||||
string assetPath = "Assets" + file.FullName
|
||||
.Replace(Application.dataPath, "")
|
||||
.Replace("\\", "/");
|
||||
|
||||
EditorUtility.DisplayProgressBar(
|
||||
"Audio Import Tool",
|
||||
$"处理: {file.Name} ({processed}/{total})",
|
||||
(float)processed / total);
|
||||
|
||||
bool ok = ProcessFile(assetPath, file.Name, audioObjectConfig, musicSegmentConfig);
|
||||
if (ok) processed++;
|
||||
else failed++;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
AssetDatabase.StopAssetEditing();
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
Debug.Log($"[AudioImportTool] 完成:{processed} 成功,{failed} 失败,共 {total} 个文件");
|
||||
}
|
||||
|
||||
static bool ProcessFile(
|
||||
string assetPath, string fileName,
|
||||
AudioObjectConfig audioObjectConfig, MusicSegmentConfig musicSegmentConfig)
|
||||
{
|
||||
AudioImporter importer = AssetImporter.GetAtPath(assetPath) as AudioImporter;
|
||||
if (!importer)
|
||||
{
|
||||
Debug.LogWarning($"[AudioImportTool] 无法获取 AudioImporter: {assetPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
AudioClip clip = AssetDatabase.LoadAssetAtPath<AudioClip>(assetPath);
|
||||
if (!clip)
|
||||
{
|
||||
Debug.LogWarning($"[AudioImportTool] 无法加载 AudioClip: {assetPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
float duration = clip.length;
|
||||
string fileNameNoExt = Path.GetFileNameWithoutExtension(fileName);
|
||||
AudioCategory category = ClassifyAudio(fileNameNoExt, audioObjectConfig, musicSegmentConfig, out AudioObject audioObject);
|
||||
|
||||
importer.ClearSampleSettingOverride("Android");
|
||||
importer.ClearSampleSettingOverride("iOS");
|
||||
|
||||
AudioImporterSampleSettings settings = importer.defaultSampleSettings;
|
||||
settings.compressionFormat = AudioCompressionFormat.Vorbis;
|
||||
|
||||
settings.sampleRateSetting = AudioSampleRateSetting.OverrideSampleRate;
|
||||
settings.preloadAudioData = audioObject?.Haptic != 0;
|
||||
switch (category)
|
||||
{
|
||||
case AudioCategory.Music:
|
||||
settings.sampleRateOverride = 44100;
|
||||
settings.quality = 0.13f;
|
||||
settings.loadType = duration switch
|
||||
{
|
||||
<= 5 => AudioClipLoadType.DecompressOnLoad,
|
||||
>= 15 => AudioClipLoadType.Streaming,
|
||||
_ => AudioClipLoadType.CompressedInMemory,
|
||||
};
|
||||
break;
|
||||
case AudioCategory.Voice:
|
||||
case AudioCategory.SFX:
|
||||
default:
|
||||
settings.sampleRateOverride = 22050; // 音效2022.3.62f3没有32kHz这一档,要是有的话这一档其实最合适
|
||||
settings.quality = 0.5f;
|
||||
settings.loadType = duration switch
|
||||
{
|
||||
>= 15 => AudioClipLoadType.Streaming,
|
||||
_ => AudioClipLoadType.DecompressOnLoad,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
importer.defaultSampleSettings = settings;
|
||||
importer.forceToMono = false;
|
||||
importer.SaveAndReimport();
|
||||
return true;
|
||||
}
|
||||
|
||||
enum AudioCategory { Music, SFX, Voice }
|
||||
|
||||
static AudioCategory ClassifyAudio(
|
||||
string fileName, // 不含扩展名
|
||||
AudioObjectConfig audioObjectConfig,
|
||||
MusicSegmentConfig musicSegmentConfig,
|
||||
out AudioObject matchedObject)
|
||||
{
|
||||
matchedObject = null;
|
||||
|
||||
// 1. MusicSegment 表命中 → Music
|
||||
MusicSegment segment = musicSegmentConfig.MusicSegmentList()
|
||||
.FirstOrDefault(musicSegment => musicSegment.Name == fileName);
|
||||
if (segment != null)
|
||||
return AudioCategory.Music;
|
||||
|
||||
// 2. AudioObject 表命中 → 按 MixingType
|
||||
AudioObject audioObject = audioObjectConfig.AudioObjectList()
|
||||
.FirstOrDefault(audioObject => audioObject.Name != null && audioObject.Name.Contains(fileName));
|
||||
if (audioObject != null)
|
||||
{
|
||||
matchedObject = audioObject;
|
||||
return audioObject.MixingType == MixingType.Voice
|
||||
? AudioCategory.Voice
|
||||
: AudioCategory.SFX; // SFX + Accent 都走 SFX 分支
|
||||
}
|
||||
|
||||
// 3. 文件名 fallback
|
||||
string lower = fileName.ToLowerInvariant();
|
||||
if (lower.Contains("music") || lower.Contains("bgm"))
|
||||
return AudioCategory.Music;
|
||||
if (lower.Contains("sfx"))
|
||||
return AudioCategory.SFX;
|
||||
if (lower.Contains("voice"))
|
||||
return AudioCategory.Voice;
|
||||
|
||||
// 4. 完全兜底
|
||||
Debug.LogWarning($"[AudioImportTool] 无法分类: {fileName},默认当作 SFX 处理");
|
||||
return AudioCategory.SFX;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 114d56912b8874446b70f4c83af327b1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -29,6 +29,7 @@ namespace OCES.Audio
|
||||
Random = 0,
|
||||
Sequence,
|
||||
Blend,
|
||||
Switch,
|
||||
}
|
||||
|
||||
public enum BlendCrossFadeType
|
||||
@@ -84,19 +85,10 @@ namespace OCES.Audio
|
||||
|
||||
public partial class MusicPath : IPathEntry { }
|
||||
public partial class AmbiencePath : IPathEntry { }
|
||||
|
||||
public partial class MusicContainerConfig
|
||||
|
||||
public class SwitchEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析拍号字符串(如 "4/4", "3/4"),返回每小节拍数。
|
||||
/// </summary>
|
||||
public static int GetBeatsPerBar(string timeSig)
|
||||
{
|
||||
if (string.IsNullOrEmpty(timeSig)) return 4;
|
||||
string[] parts = timeSig.Split('/');
|
||||
if (parts.Length >= 1 && int.TryParse(parts[0], out int beats))
|
||||
return beats;
|
||||
return 4;
|
||||
}
|
||||
public uint SwitchValue;
|
||||
public uint AudioObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace OCES.Audio
|
||||
/// </summary>
|
||||
public class MusicStateRouter
|
||||
{
|
||||
// key: StateGroup enum Type,value: 当前激活的 enum 整数值
|
||||
readonly Dictionary<Type, int> m_activeStates = new();
|
||||
|
||||
// key: StateGroup enum Type,value: 当前激活的 enum 值
|
||||
readonly Dictionary<Type, Enum> m_activeStates = new();
|
||||
|
||||
|
||||
readonly MusicPathConfig m_musicPaths;
|
||||
readonly AmbiencePathConfig m_ambiencePaths;
|
||||
@@ -18,6 +20,7 @@ namespace OCES.Audio
|
||||
// 上一次匹配到的 PathId,用于 Transition 表的 FromPathId 查询
|
||||
public uint LastMusicPathId { get; private set; }
|
||||
public uint LastAmbiencePathId { get; private set; }
|
||||
internal IReadOnlyDictionary<Type, Enum> ActiveStates { get { return this.m_activeStates; }}
|
||||
|
||||
public MusicStateRouter(MusicPathConfig musicPaths, AmbiencePathConfig ambiencePaths)
|
||||
{
|
||||
@@ -31,8 +34,8 @@ namespace OCES.Audio
|
||||
public void SetState<TEnum>(TEnum state, out uint musicContainerId, out uint ambienceContainerId)
|
||||
where TEnum : Enum
|
||||
{
|
||||
// Dictionary<Type, int> 天然保证同一 StateGroup 只保留最新值,直接覆盖即可
|
||||
this.m_activeStates[typeof(TEnum)] = Convert.ToInt32(state);
|
||||
// Dictionary<Type, Enum> 天然保证同一 StateGroup 只保留最新值,直接覆盖即可
|
||||
this.m_activeStates[typeof(TEnum)] = state;
|
||||
|
||||
musicContainerId = MatchBestPath(this.m_musicPaths.MusicPathList(), out uint musicPathId);
|
||||
ambienceContainerId = MatchBestPath(this.m_ambiencePaths.AmbiencePathList(), out uint ambiencePathId);
|
||||
@@ -99,9 +102,9 @@ namespace OCES.Audio
|
||||
}
|
||||
|
||||
bool conditionMet = false;
|
||||
foreach (KeyValuePair<Type, int> kv in this.m_activeStates)
|
||||
foreach (KeyValuePair<Type, Enum> kv in this.m_activeStates)
|
||||
{
|
||||
if (StateGroupRegistry.GetTypeId(kv.Key) != typeId || kv.Value != stateValue)
|
||||
if (StateGroupRegistry.GetTypeId(kv.Key) != typeId || Convert.ToInt32(kv.Value) != stateValue)
|
||||
continue;
|
||||
conditionMet = true;
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace OCES.Audio
|
||||
@@ -20,6 +21,11 @@ namespace OCES.Audio
|
||||
// 记录上一次两个通道各自匹配到的 PathId,用于查 Transition 表
|
||||
uint m_lastMusicPathId;
|
||||
uint m_lastAmbiencePathId;
|
||||
|
||||
internal IReadOnlyDictionary<Type, Enum> ActiveStates
|
||||
{
|
||||
get { return this.m_stateRouter.ActiveStates; }
|
||||
}
|
||||
|
||||
internal void Initialize(
|
||||
MusicSegmentConfig segments,
|
||||
|
||||
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OCES.Audio.HandWritten;
|
||||
using OCES.Haptic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
@@ -33,6 +35,7 @@ namespace OCES.Audio
|
||||
AudioSourcePool m_pool;
|
||||
AudioContainerSelector m_containerSelector;
|
||||
PitchStepResolver m_pitchStepResolver;
|
||||
VolumeStepResolver m_volumeStepResolver;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void Update()
|
||||
@@ -74,6 +77,7 @@ namespace OCES.Audio
|
||||
this.m_pool = pool;
|
||||
this.m_containerSelector = new AudioContainerSelector();
|
||||
this.m_pitchStepResolver = new PitchStepResolver();
|
||||
this.m_volumeStepResolver = new VolumeStepResolver();
|
||||
|
||||
AudioMixerGroup[] sfx = Find("Master/Regular/SFX");
|
||||
if (sfx.Length > 0) this.m_sfxGroup = sfx[0];
|
||||
@@ -173,7 +177,8 @@ namespace OCES.Audio
|
||||
|
||||
// 执行播放
|
||||
float pitch = this.m_pitchStepResolver.ResolvePitch(audioObject, now); //算一下用不用变调
|
||||
PlayNewSound(audioObject, pitch);
|
||||
float volume = this.m_volumeStepResolver.ResolveVolume(audioObject, now);
|
||||
PlayNewSound(audioObject, pitch, volume);
|
||||
this.m_lastPlayTime[audioObject.Id] = now;
|
||||
onPlay?.Invoke();
|
||||
}
|
||||
@@ -182,12 +187,13 @@ namespace OCES.Audio
|
||||
// 播放逻辑
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
void PlayNewSound(AudioObject audioObject, float pitch)
|
||||
void PlayNewSound(AudioObject audioObject, float pitch, float volume)
|
||||
{
|
||||
ActiveSound active = new()
|
||||
{
|
||||
AudioObject = audioObject,
|
||||
Pitch = pitch,
|
||||
Volume = volume,
|
||||
State = ActiveSoundState.Pending,
|
||||
StartTime = Time.realtimeSinceStartupAsDouble,
|
||||
};
|
||||
@@ -207,7 +213,7 @@ namespace OCES.Audio
|
||||
/// <summary>
|
||||
/// 连续容器播放协程(Random / Sequence 持续模式)
|
||||
/// </summary>
|
||||
IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex, float pitch)
|
||||
IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex)
|
||||
{
|
||||
bool isRandom = audioObject.ContainerType == ContainerType.Random;
|
||||
|
||||
@@ -229,7 +235,7 @@ namespace OCES.Audio
|
||||
limitRepetition);
|
||||
|
||||
// 配置并播放
|
||||
if (!SetupSource(source, audioObject, pitch, index))
|
||||
if (!SetupSource(source, chainActive, index))
|
||||
{
|
||||
Debug.LogError($"音频文件未找到:{audioObject.Name[index]}");
|
||||
yield break;
|
||||
@@ -255,6 +261,7 @@ namespace OCES.Audio
|
||||
this.m_pool.ReturnToPool(source.gameObject);
|
||||
|
||||
//Debug.Log($"[Container - Continuous] 协程正常结束: {audioObject.Name[0]}");
|
||||
// TryStopHaptic(audioObject.Haptic);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
@@ -334,8 +341,9 @@ namespace OCES.Audio
|
||||
return this.m_sfxGroup;
|
||||
}
|
||||
|
||||
bool SetupSource(AudioSource source, AudioObject audioObject, float pitch, int clipIndex = 0)
|
||||
bool SetupSource(AudioSource source, ActiveSound activeSound, int clipIndex = 0)
|
||||
{
|
||||
AudioObject audioObject = activeSound.AudioObject;
|
||||
AudioClip clip = Resources.Load<AudioClip>($"Audios/{audioObject.Name[clipIndex]}"); // TODO 抽象同一资源加载接口
|
||||
if (!clip)
|
||||
{
|
||||
@@ -347,7 +355,8 @@ namespace OCES.Audio
|
||||
source.loop = audioObject.LoopCount < 0;
|
||||
source.priority = audioObject.Priority;
|
||||
source.outputAudioMixerGroup = GetMixerGroup(audioObject.MixingType);
|
||||
source.pitch = pitch;
|
||||
source.pitch = activeSound.Pitch;
|
||||
source.volume = activeSound.Volume;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -364,6 +373,15 @@ namespace OCES.Audio
|
||||
IncrementClipCount(activeSound.AudioObject.Id);
|
||||
}
|
||||
|
||||
if (activeSound.AudioObject.ContainerType == ContainerType.Blend)
|
||||
{
|
||||
Debug.LogWarning($"[Haptic System] Blend container {activeSound.AudioObject.Id} should not have haptic feedback!");
|
||||
}
|
||||
else
|
||||
{
|
||||
TryStartHaptic(activeSound);
|
||||
}
|
||||
|
||||
activeSound.Coroutine = StartCoroutine(RemoveWhenFinished(activeSound));
|
||||
}
|
||||
|
||||
@@ -371,6 +389,7 @@ namespace OCES.Audio
|
||||
{
|
||||
AudioObject audioObject = active.AudioObject;
|
||||
float pitch = active.Pitch;
|
||||
float volume = active.Volume;
|
||||
|
||||
// =======================
|
||||
// Blend(每个clip一个ActiveSound)
|
||||
@@ -385,10 +404,11 @@ namespace OCES.Audio
|
||||
{
|
||||
AudioObject = audioObject,
|
||||
Pitch = pitch,
|
||||
State = ActiveSoundState.Playing
|
||||
Volume = volume,
|
||||
State = ActiveSoundState.Playing,
|
||||
};
|
||||
|
||||
if (!SetupSource(source, audioObject, pitch, i))
|
||||
if (!SetupSource(source, child, i))
|
||||
{
|
||||
this.m_pool.ReturnToPool(source.gameObject);
|
||||
continue;
|
||||
@@ -424,7 +444,7 @@ namespace OCES.Audio
|
||||
int start = audioObject.ContainerType == ContainerType.Random ? -1 : 0;
|
||||
|
||||
active.Coroutine = StartCoroutine(
|
||||
PlayContainerContinuous(sourceSingle, audioObject, active, start, pitch)
|
||||
PlayContainerContinuous(sourceSingle, audioObject, active, start)
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -437,12 +457,12 @@ namespace OCES.Audio
|
||||
{
|
||||
ContainerType.Random => m_containerSelector.PickShuffleIndex(audioObject),
|
||||
ContainerType.Sequence => m_containerSelector.GetNextSequenceIndex(audioObject),
|
||||
_ => 0
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (!SetupSource(sourceSingle, audioObject, pitch, index))
|
||||
if (!SetupSource(sourceSingle, active, index))
|
||||
{
|
||||
m_pool.ReturnToPool(sourceSingle.gameObject);
|
||||
this.m_pool.ReturnToPool(sourceSingle.gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -491,7 +511,17 @@ namespace OCES.Audio
|
||||
this.m_pool.ReturnToPool(active.Source.gameObject);
|
||||
}
|
||||
|
||||
static void TryStartHaptic(ActiveSound active)
|
||||
{
|
||||
uint hapticId = active.AudioObject.Haptic;
|
||||
if (hapticId == 0) return;
|
||||
HapticSystem.Instance.Play(hapticId, isDirectCall: false);
|
||||
}
|
||||
|
||||
static void TryStopHaptic(uint hapticId)
|
||||
{
|
||||
HapticSystem.Instance.Stop(hapticId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -506,6 +536,7 @@ namespace OCES.Audio
|
||||
public int CurrentLoopCount;
|
||||
public Coroutine Coroutine;
|
||||
public float Pitch;
|
||||
public float Volume;
|
||||
public ActiveSoundState State;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace OCES.Audio.HandWritten
|
||||
{
|
||||
public class VolumeStepResolver
|
||||
{
|
||||
readonly Dictionary<uint, int> m_volumeStepCounts = new();
|
||||
readonly Dictionary<uint, double> m_volumeStepLastTime = new();
|
||||
|
||||
internal float ResolveVolume(AudioObject audioObject, double time)
|
||||
{
|
||||
// 计算一下配置的音量是多少
|
||||
float baseVolume = Mathf.Pow(10,audioObject.Volume / 20f);
|
||||
|
||||
// 优化版。看看表现,要是确实Mathf.Pow造成性能卡点了就用这个。
|
||||
//float baseVolume = audioObject.Volume == 0 ? 1f : Mathf.Pow(10,audioObject.Volume / 20f);
|
||||
|
||||
if (audioObject.VolumeStepThreshold == 0) //没配置VolumeStep
|
||||
{
|
||||
return baseVolume;
|
||||
}
|
||||
|
||||
// 超时了,或者没播过
|
||||
if (!this.m_volumeStepLastTime.TryGetValue(audioObject.Id, out double lastVolumeStepTime)
|
||||
|| time - lastVolumeStepTime > audioObject.VolumeStepThreshold)
|
||||
{
|
||||
this.m_volumeStepCounts[audioObject.Id] = 0;
|
||||
this.m_volumeStepLastTime[audioObject.Id] = time;
|
||||
return baseVolume;
|
||||
}
|
||||
|
||||
//命中了
|
||||
int volumeStepCount = this.m_volumeStepCounts[audioObject.Id];
|
||||
volumeStepCount = this.m_volumeStepCounts[audioObject.Id] = volumeStepCount + 1;
|
||||
this.m_volumeStepLastTime[audioObject.Id] = time;
|
||||
return Mathf.Clamp(Mathf.Pow(10, (audioObject.Volume + audioObject.VolumeStep * volumeStepCount) / 20f), 0f, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0059d6fc851c474a97bc05f6385f9a48
|
||||
timeCreated: 1776067889
|
||||
@@ -7,7 +7,7 @@ namespace OCES
|
||||
{
|
||||
public class ButtonInvoker : MonoBehaviour
|
||||
{
|
||||
public GameState targetGameState;
|
||||
public TileMaterial targetGameState;
|
||||
public bool enableLowpass;
|
||||
|
||||
Button m_button;
|
||||
|
||||
@@ -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,140 @@
|
||||
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;
|
||||
[NonSerialized]
|
||||
public bool IsMeetsAdvanceRequirements;
|
||||
|
||||
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(uint? hapticId = null)
|
||||
{
|
||||
HapticController.Stop();
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
this.m_hapticObjects = HapticConfigLoader.Load<HapticObjectConfig>(k_hapticConfigPath + "HapticObject");
|
||||
this.IsHapticSupported = DeviceCapabilities.isVersionSupported;
|
||||
this.IsMeetsAdvanceRequirements = DeviceCapabilities.meetsAdvancedRequirements;
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using OCES.Audio;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class PlaySoundBind : MonoBehaviour
|
||||
{
|
||||
|
||||
public InputField inputField;
|
||||
|
||||
public void OnButtonPressed()
|
||||
{
|
||||
uint.TryParse(this.inputField.text, out uint hapticId);
|
||||
AudioSystem.Instance.Play(hapticId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3bdbaddf2c05142e19686a9a82ef92c3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user