You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
399 lines
18 KiB
C#
399 lines
18 KiB
C#
3 weeks ago
|
using System;
|
||
|
using System.Collections;
|
||
|
using System.Collections.Generic;
|
||
|
using Unity.BossRoom.Gameplay.GameplayObjects.Character;
|
||
|
using Unity.BossRoom.VisualEffects;
|
||
|
using UnityEngine;
|
||
|
using UnityEngine.Serialization;
|
||
|
|
||
|
#if UNITY_EDITOR
|
||
|
using UnityEditor;
|
||
|
using UnityEditor.Animations;
|
||
|
#endif
|
||
|
|
||
|
namespace Unity.BossRoom.Gameplay.GameplayObjects.AnimationCallbacks
|
||
|
{
|
||
|
/// <summary>
|
||
|
/// Instantiates and maintains graphics prefabs and sound effects. They're triggered by entering
|
||
|
/// (or exiting) specific nodes in an Animator. (Each relevant Animator node must have an
|
||
|
/// AnimationNodeHook component attached.)
|
||
|
/// </summary>
|
||
|
public class AnimatorTriggeredSpecialFX : MonoBehaviour
|
||
|
{
|
||
|
[SerializeField]
|
||
|
[Tooltip("Unused by the game and provided only for internal dev comments; put whatever you want here")]
|
||
|
[TextArea]
|
||
|
private string DevNotes; // e.g. "this is for the tank class". Documentation for the artists, because all 4 class's AnimatorTriggeredSpecialFX components are on the same GameObject. Can remove later if desired
|
||
|
|
||
|
[Serializable]
|
||
|
internal class AnimatorNodeEntryEvent
|
||
|
{
|
||
|
[Tooltip("The name of a node in the Animator's state machine.")]
|
||
|
public string m_AnimatorNodeName;
|
||
|
[HideInInspector]
|
||
|
public int m_AnimatorNodeNameHash; // this is maintained via OnValidate() in the editor
|
||
|
|
||
|
[Header("Particle Prefab")]
|
||
|
[Tooltip("The prefab that should be instantiated when we enter an Animator node with this name")]
|
||
|
public SpecialFXGraphic m_Prefab;
|
||
|
[Tooltip("Wait this many seconds before instantiating the Prefab. (If we leave the animation node before this point, no FX are played.)")]
|
||
|
public float m_PrefabSpawnDelaySeconds;
|
||
|
[Tooltip("If we leave the AnimationNode, should we shutdown the fx or let it play out? 0 = never cancel. Any other time = we can cancel up until this amount of time has elapsed... after that, we just let it play out. So a really big value like 9999 effectively means 'always cancel'")]
|
||
|
public float m_PrefabCanBeAbortedUntilSecs;
|
||
|
[Tooltip("If the particle should be parented to a specific bone, link that bone here. (If null, plays at character's feet.)")]
|
||
|
public Transform m_PrefabParent;
|
||
|
[Tooltip("Prefab will be spawned with this local offset from the parent (Remember, it's a LOCAL offset, so it's affected by the parent transform's scale and rotation!)")]
|
||
|
public Vector3 m_PrefabParentOffset;
|
||
|
[Tooltip("Should we disconnect the prefab from the character? (So the prefab's transform has no parent)")]
|
||
|
public bool m_DeParentPrefab;
|
||
|
|
||
|
[Header("Sound Effect")]
|
||
|
[Tooltip("If we want to use a sound effect that's not in the prefab, specify it here")]
|
||
|
public AudioClip m_SoundEffect;
|
||
|
[Tooltip("Time (in seconds) before we start playing this sound. If we leave the animation node before this time, no sound plays")]
|
||
|
public float m_SoundStartDelaySeconds;
|
||
|
[Tooltip("Relative volume to play at.")]
|
||
|
public float m_VolumeMultiplier = 1;
|
||
|
[Tooltip("Should we loop the sound for as long as we're in the animation node?")]
|
||
|
public bool m_LoopSound = false;
|
||
|
}
|
||
|
[SerializeField]
|
||
|
internal AnimatorNodeEntryEvent[] m_EventsOnNodeEntry;
|
||
|
|
||
|
/// <summary>
|
||
|
/// These are the AudioSources we'll use to play sounds. For non-looping sounds we only need one,
|
||
|
/// but to play multiple looping sounds we need additional AudioSources, since each one can only
|
||
|
/// play one looping sound at a time.
|
||
|
/// (These AudioSources are typically on the same GameObject as us, but they don't have to be.)
|
||
|
/// </summary>
|
||
|
[SerializeField]
|
||
|
internal AudioSource[] m_AudioSources;
|
||
|
|
||
|
/// <summary>
|
||
|
/// cached reference to our Animator.
|
||
|
/// </summary>
|
||
|
[SerializeField]
|
||
|
private Animator m_Animator;
|
||
|
|
||
|
/// <summary>
|
||
|
/// contains the shortNameHash of all the active animation nodes right now
|
||
|
/// </summary>
|
||
|
private HashSet<int> m_ActiveNodes = new HashSet<int>();
|
||
|
|
||
|
[FormerlySerializedAs("m_ClientCharacterVisualization")]
|
||
|
[SerializeField]
|
||
|
ClientCharacter m_ClientCharacter;
|
||
|
|
||
|
private void Awake()
|
||
|
{
|
||
|
Debug.Assert(m_AudioSources != null && m_AudioSources.Length > 0, "No AudioSource plugged into AnimatorTriggeredSpecialFX!", gameObject);
|
||
|
|
||
|
if (!m_ClientCharacter)
|
||
|
{
|
||
|
m_ClientCharacter = GetComponentInParent<ClientCharacter>();
|
||
|
|
||
|
m_Animator = m_ClientCharacter.OurAnimator;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
|
||
|
{
|
||
|
Debug.Assert(m_Animator == animator); // just a sanity check
|
||
|
|
||
|
m_ActiveNodes.Add(stateInfo.shortNameHash);
|
||
|
|
||
|
// figure out which of our on-node-enter events (if any) should be triggered, and trigger it
|
||
|
foreach (var info in m_EventsOnNodeEntry)
|
||
|
{
|
||
|
if (info.m_AnimatorNodeNameHash == stateInfo.shortNameHash)
|
||
|
{
|
||
|
if (info.m_Prefab)
|
||
|
{
|
||
|
StartCoroutine(CoroPlayStateEnterFX(info));
|
||
|
}
|
||
|
if (info.m_SoundEffect)
|
||
|
{
|
||
|
StartCoroutine(CoroPlayStateEnterSound(info));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// creates and manages the graphics prefab (but not the sound effect) of an on-enter event
|
||
|
private IEnumerator CoroPlayStateEnterFX(AnimatorNodeEntryEvent eventInfo)
|
||
|
{
|
||
|
if (eventInfo.m_PrefabSpawnDelaySeconds > 0)
|
||
|
yield return new WaitForSeconds(eventInfo.m_PrefabSpawnDelaySeconds);
|
||
|
|
||
|
if (!m_ActiveNodes.Contains(eventInfo.m_AnimatorNodeNameHash))
|
||
|
yield break;
|
||
|
|
||
|
Transform parent = eventInfo.m_PrefabParent != null ? eventInfo.m_PrefabParent : m_ClientCharacter.transform;
|
||
|
var instantiatedFX = Instantiate(eventInfo.m_Prefab, parent);
|
||
|
instantiatedFX.transform.localPosition += eventInfo.m_PrefabParentOffset;
|
||
|
|
||
|
// should we have no parent transform at all? (Note that we're de-parenting AFTER applying
|
||
|
// the PrefabParent, so that PrefabParent can still be used to determine the initial position/rotation/scale.)
|
||
|
if (eventInfo.m_DeParentPrefab)
|
||
|
{
|
||
|
instantiatedFX.transform.SetParent(null);
|
||
|
}
|
||
|
|
||
|
// now we just need to watch and see if we end up needing to prematurely end these new graphics
|
||
|
if (eventInfo.m_PrefabCanBeAbortedUntilSecs > 0)
|
||
|
{
|
||
|
float timeRemaining = eventInfo.m_PrefabCanBeAbortedUntilSecs - eventInfo.m_PrefabSpawnDelaySeconds;
|
||
|
while (timeRemaining > 0 && instantiatedFX)
|
||
|
{
|
||
|
yield return new WaitForFixedUpdate();
|
||
|
timeRemaining -= Time.fixedDeltaTime;
|
||
|
if (!m_ActiveNodes.Contains(eventInfo.m_AnimatorNodeNameHash))
|
||
|
{
|
||
|
// the node we were in has ended! Shut down the FX
|
||
|
if (instantiatedFX)
|
||
|
{
|
||
|
instantiatedFX.Shutdown();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// plays the sound effect of an on-entry event
|
||
|
private IEnumerator CoroPlayStateEnterSound(AnimatorNodeEntryEvent eventInfo)
|
||
|
{
|
||
|
if (eventInfo.m_SoundStartDelaySeconds > 0)
|
||
|
yield return new WaitForSeconds(eventInfo.m_SoundStartDelaySeconds);
|
||
|
|
||
|
if (!m_ActiveNodes.Contains(eventInfo.m_AnimatorNodeNameHash))
|
||
|
yield break;
|
||
|
|
||
|
if (!eventInfo.m_LoopSound)
|
||
|
{
|
||
|
m_AudioSources[0].PlayOneShot(eventInfo.m_SoundEffect, eventInfo.m_VolumeMultiplier);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
AudioSource audioSource = GetAudioSourceForLooping();
|
||
|
if (!audioSource)
|
||
|
yield break; // we're using all our audio sources already. just give up
|
||
|
audioSource.volume = eventInfo.m_VolumeMultiplier;
|
||
|
audioSource.loop = true;
|
||
|
audioSource.clip = eventInfo.m_SoundEffect;
|
||
|
audioSource.Play();
|
||
|
while (m_ActiveNodes.Contains(eventInfo.m_AnimatorNodeNameHash) && audioSource.isPlaying)
|
||
|
{
|
||
|
yield return new WaitForFixedUpdate();
|
||
|
}
|
||
|
audioSource.Stop();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// retrieves an available AudioSource that isn't currently playing a looping sound, or null if none are currently available
|
||
|
/// </summary>
|
||
|
private AudioSource GetAudioSourceForLooping()
|
||
|
{
|
||
|
foreach (var audioSource in m_AudioSources)
|
||
|
{
|
||
|
if (audioSource && !audioSource.isPlaying)
|
||
|
return audioSource;
|
||
|
}
|
||
|
Debug.LogWarning($"{name} doesn't have enough AudioSources to loop all desired sound effects. (Have {m_AudioSources.Length}, need at least 1 more)", gameObject);
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
|
||
|
{
|
||
|
Debug.Assert(m_Animator == animator); // just a sanity check
|
||
|
|
||
|
m_ActiveNodes.Remove(stateInfo.shortNameHash);
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Precomputes the hashed values for the animator-tags we care about.
|
||
|
/// (This way we don't have to call Animator.StringToHash() at runtime.)
|
||
|
/// Also auto-initializes variables when possible.
|
||
|
/// </summary>
|
||
|
private void OnValidate()
|
||
|
{
|
||
|
if (m_EventsOnNodeEntry != null)
|
||
|
{
|
||
|
for (int i = 0; i < m_EventsOnNodeEntry.Length; ++i)
|
||
|
{
|
||
|
m_EventsOnNodeEntry[i].m_AnimatorNodeNameHash = Animator.StringToHash(m_EventsOnNodeEntry[i].m_AnimatorNodeName);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (m_AudioSources == null || m_AudioSources.Length == 0) // if we have AudioSources handy, plug them in automatically
|
||
|
{
|
||
|
m_AudioSources = GetComponents<AudioSource>();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
|
||
|
#if UNITY_EDITOR
|
||
|
/// <summary>
|
||
|
/// This adds a button in the Inspector. Pressing it validates that all the
|
||
|
/// animator node names we reference are actually used by our Animator. We
|
||
|
/// can also show informational messages about problems with the configuration.
|
||
|
/// </summary>
|
||
|
[CustomEditor(typeof(AnimatorTriggeredSpecialFX))]
|
||
|
[CanEditMultipleObjects]
|
||
|
public class AnimatorTriggeredSpecialFXEditor : UnityEditor.Editor
|
||
|
{
|
||
|
private GUIStyle m_ErrorStyle = null;
|
||
|
public override void OnInspectorGUI()
|
||
|
{
|
||
|
// let Unity do all the normal Inspector stuff...
|
||
|
DrawDefaultInspector();
|
||
|
|
||
|
// ... then we tack extra stuff on the bottom
|
||
|
var fx = (AnimatorTriggeredSpecialFX)target;
|
||
|
if (!HasAudioSource(fx))
|
||
|
{
|
||
|
GUILayout.Label("No Audio Sources Connected!", GetErrorStyle());
|
||
|
}
|
||
|
|
||
|
if (GUILayout.Button("Validate Node Names"))
|
||
|
{
|
||
|
ValidateNodeNames(fx);
|
||
|
}
|
||
|
|
||
|
// it's really hard to follow the inspector when there's a lot of these components on the same GameObject... so let's add a bit of whitespace
|
||
|
EditorGUILayout.Space(50);
|
||
|
}
|
||
|
|
||
|
private GUIStyle GetErrorStyle()
|
||
|
{
|
||
|
if (m_ErrorStyle == null)
|
||
|
{
|
||
|
m_ErrorStyle = new GUIStyle(EditorStyles.boldLabel);
|
||
|
m_ErrorStyle.normal.textColor = Color.red;
|
||
|
m_ErrorStyle.fontSize += 5;
|
||
|
}
|
||
|
return m_ErrorStyle;
|
||
|
}
|
||
|
|
||
|
private bool HasAudioSource(AnimatorTriggeredSpecialFX fx)
|
||
|
{
|
||
|
if (fx.m_AudioSources == null)
|
||
|
return false;
|
||
|
foreach (var audioSource in fx.m_AudioSources)
|
||
|
{
|
||
|
if (audioSource != null)
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
private void ValidateNodeNames(AnimatorTriggeredSpecialFX fx)
|
||
|
{
|
||
|
Animator animator = fx.GetComponent<Animator>();
|
||
|
if (!animator)
|
||
|
{
|
||
|
// should be impossible because we explicitly RequireComponent the Animator
|
||
|
EditorUtility.DisplayDialog("Error", "No Animator found on this GameObject!?", "OK");
|
||
|
return;
|
||
|
}
|
||
|
if (animator.runtimeAnimatorController == null)
|
||
|
{
|
||
|
// perfectly normal user error: they haven't plugged a controller into the Animator
|
||
|
EditorUtility.DisplayDialog("Error", "The Animator does not have an AnimatorController in it!", "OK");
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// make sure there aren't any duplicated event entries!
|
||
|
int totalErrors = 0;
|
||
|
for (int i = 0; i < fx.m_EventsOnNodeEntry.Length; ++i)
|
||
|
{
|
||
|
for (int j = i + 1; j < fx.m_EventsOnNodeEntry.Length; ++j)
|
||
|
{
|
||
|
if (fx.m_EventsOnNodeEntry[i].m_AnimatorNodeNameHash == fx.m_EventsOnNodeEntry[j].m_AnimatorNodeNameHash
|
||
|
&& fx.m_EventsOnNodeEntry[i].m_AnimatorNodeNameHash != 0
|
||
|
&& fx.m_EventsOnNodeEntry[i].m_Prefab == fx.m_EventsOnNodeEntry[j].m_Prefab
|
||
|
&& fx.m_EventsOnNodeEntry[i].m_SoundEffect == fx.m_EventsOnNodeEntry[j].m_SoundEffect)
|
||
|
{
|
||
|
++totalErrors;
|
||
|
Debug.LogError($"Entries {i} and {j} in EventsOnNodeEntry refer to the same node name ({fx.m_EventsOnNodeEntry[i].m_AnimatorNodeName}) and have the same prefab/sounds! This is probably a copy-paste error.");
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// create a map of nameHash -> useful debugging information (which we display in the log if there's a problem)
|
||
|
Dictionary<int, string> usedNames = new Dictionary<int, string>();
|
||
|
for (int i = 0; i < fx.m_EventsOnNodeEntry.Length; ++i)
|
||
|
{
|
||
|
usedNames[fx.m_EventsOnNodeEntry[i].m_AnimatorNodeNameHash] = $"{fx.m_EventsOnNodeEntry[i].m_AnimatorNodeName} (EventsOnNodeEntry index {i})";
|
||
|
}
|
||
|
|
||
|
int totalUsedNames = usedNames.Count;
|
||
|
|
||
|
// now remove all the hashes that are actually used by the controller
|
||
|
AnimatorController controller = GetAnimatorController(animator);
|
||
|
foreach (var layer in controller.layers)
|
||
|
{
|
||
|
foreach (var state in layer.stateMachine.states)
|
||
|
{
|
||
|
usedNames.Remove(state.state.nameHash);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// anything that hasn't gotten removed from usedNames isn't actually valid!
|
||
|
foreach (var hash in usedNames.Keys)
|
||
|
{
|
||
|
Debug.LogError("Could not find Animation node named " + usedNames[hash]);
|
||
|
}
|
||
|
totalErrors += usedNames.Keys.Count;
|
||
|
|
||
|
if (totalErrors == 0)
|
||
|
{
|
||
|
EditorUtility.DisplayDialog("Success", $"All {totalUsedNames} referenced node names were found in the Animator. No errors found!", "OK!");
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
EditorUtility.DisplayDialog("Errors", $"Found {totalErrors} errors. See the log in the Console tab for more information.", "OK");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Pulls the AnimatorController out of an Animator. Important: this technique can only work
|
||
|
/// in the editor. You can never reference an AnimatorController directly at runtime! (It might
|
||
|
/// seem to work while you're running the game in the editor, but it won't compile when you
|
||
|
/// try to build a standalone client, because AnimatorController is in an editor-only namespace.)
|
||
|
/// </summary>
|
||
|
private AnimatorController GetAnimatorController(Animator animator)
|
||
|
{
|
||
|
Debug.Assert(animator); // already pre-checked
|
||
|
Debug.Assert(animator.runtimeAnimatorController); // already pre-checked
|
||
|
|
||
|
// we need the AnimatorController, but there's no direct way to retrieve it from the Animator, because
|
||
|
// at runtime the actual AnimatorController doesn't exist! Only a runtime representation does. (That's why
|
||
|
// AnimatorController is in the UnityEditor namespace.) But this *isn't* runtime, so when we retrieve the
|
||
|
// runtime controller, it will actually be a reference to our real AnimatorController.
|
||
|
AnimatorController controller = animator.runtimeAnimatorController as AnimatorController;
|
||
|
if (controller == null)
|
||
|
{
|
||
|
// if it's not an AnimatorController, it must be an AnimatorOverrideController (because those are currently the only two on-disk representations)
|
||
|
var overrideController = animator.runtimeAnimatorController as AnimatorOverrideController;
|
||
|
if (overrideController)
|
||
|
{
|
||
|
// override controllers are not allowed to be nested, so the thing it's overriding has to be our real AnimatorController
|
||
|
controller = overrideController.runtimeAnimatorController as AnimatorController;
|
||
|
}
|
||
|
}
|
||
|
if (controller == null)
|
||
|
{
|
||
|
// It's neither of the two standard disk representations! ... it must be a new Unity feature or a custom variation
|
||
|
// Either way, we don't know how to get the real AnimatorController out of it, so we have to stop
|
||
|
throw new System.Exception($"Unrecognized class derived from RuntimeAnimatorController! {animator.runtimeAnimatorController.GetType().FullName}");
|
||
|
}
|
||
|
return controller;
|
||
|
}
|
||
|
|
||
|
}
|
||
|
#endif
|
||
|
}
|