613 lines
21 KiB
C#
Raw Normal View History

2025-09-19 14:56:58 +05:00
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;
using UnityEngine.Events;
namespace BulletHellTemplate
{
/// <summary>
/// Stores a base animation clip with speed and transition percent.
/// </summary>
[System.Serializable]
public struct CharacterAnimation
{
[Tooltip("Animation clip to play.")]
public AnimationClip animationClip;
[Tooltip("Playback speed multiplier.")]
public float speed;
[Tooltip("Crossfade transition percent (0.1 means 10%).")]
public float transitionPercent;
}
/// <summary>
/// Stores an action animation clip with speed, transition time, exit time, etc.
/// </summary>
[System.Serializable]
public struct ActionAnimation
{
[Tooltip("Action key to identify this animation.")]
public string action;
[Tooltip("Animation clip to play.")]
public AnimationClip animationClip;
[Tooltip("Playback speed multiplier.")]
public float speed;
[Tooltip("Crossfade transition (e.g., 0.1 means 0.1 seconds).")]
public float transition;
[Tooltip("If true, this action uses an AvatarMask on layer 1 (partial override).")]
public bool useAvatarMask;
[Tooltip("If true, this action fully ignores the base layer (layer 2 override).")]
public bool playCompletely;
[Tooltip("Exit time fraction (e.g., 0.8 means 80% of the clip must be played).")]
public float exitTime;
}
/// <summary>
/// Class that stores events for skill usage.
/// </summary>
[System.Serializable]
public class UseSkillEventData
{
[HideInInspector]
public string eventName;
public UnityEvent onUseSkill;
}
/// <summary>
/// CharacterModel controlling a three-layer PlayableGraph:
/// Layer 0: Base (Idle, Run)
/// Layer 1: Partial override if useAvatarMask=true, or full override if false
/// Layer 2: Exclusive override for playCompletely
/// </summary>
[AddComponentMenu("Bullet Hell Template/Character Model")]
public class CharacterModel : MonoBehaviour
{
[Header("Animation Settings")]
[Tooltip("If false, fallback to legacy Animator.")]
public bool usePlayableAnimations;
[Tooltip("Global AvatarMask used on layer 1 for partial override if useAvatarMask=true.")]
public AvatarMask actionAvatarMask;
[Header("Animator Reference")]
[Tooltip("Used if not playing via PlayableGraph.")]
public Animator animator;
[Header("Base Animations (Layer 0)")]
public CharacterAnimation idleAnimation;
[Tooltip("Run Forward animation (required).")]
public CharacterAnimation runForwardAnimation;
public CharacterAnimation runBackwardAnimation;
public CharacterAnimation runLeftAnimation;
public CharacterAnimation runRightAnimation;
public CharacterAnimation hitAnimation;
public CharacterAnimation pickUpAnimation;
public CharacterAnimation stunAnimation;
[Header("Action Animations (Layer 1 or 2)")]
public List<ActionAnimation> actions = new List<ActionAnimation>();
[Header("Basic Unity Events")]
public UnityEvent onAttack;
public UnityEvent onDash;
public UnityEvent onReceiveHit;
public UnityEvent onReceiveBuff;
public UnityEvent onReceiveDebuff;
[Header("Use Skill Events")]
public List<UseSkillEventData> useSkillEvents = new List<UseSkillEventData>();
[Header("UI Events")]
public UnityEvent onUpgradeCharacter;
public UnityEvent onCharacterInstantiate;
#region Playable Fields
private PlayableGraph playableGraph;
private AnimationLayerMixerPlayable layerMixerPlayable;
private AnimationMixerPlayable baseMixer;
private AnimationMixerPlayable partialActionMixer;
private AnimationMixerPlayable fullActionMixer;
private Dictionary<string, int> baseClipIndices;
private Dictionary<string, int> partialActionClipIndices;
private Dictionary<string, int> fullActionClipIndices;
private bool isGraphRunning;
private bool forceCompleteAction;
private string currentAction = "";
private bool useRunBlendTree;
#endregion
private void Awake()
{
if (animator == null)
{
animator = GetComponent<Animator>();
if (animator == null)
{
animator = GetComponentInChildren<Animator>();
if (animator == null)
{
animator = GetComponentInParent<Animator>();
}
}
if (animator == null)
{
Debug.LogWarning($"{name}: No Animator found in hierarchy.");
}
else
{
Debug.Log($"{name}: Found Animator -> {animator.name}");
}
}
useRunBlendTree = (runBackwardAnimation.animationClip != null
&& runLeftAnimation.animationClip != null
&& runRightAnimation.animationClip != null);
if (usePlayableAnimations)
{
SetupPlayableGraph();
}
}
public void OnEnable()
{
onCharacterInstantiate.Invoke();
}
private void OnDestroy()
{
if (isGraphRunning && playableGraph.IsValid())
{
playableGraph.Destroy();
}
}
#region Graph Setup
private void SetupPlayableGraph()
{
if (animator == null)
{
Debug.LogError($"{name}: Cannot create PlayableGraph with null Animator.");
return;
}
playableGraph = PlayableGraph.Create($"{name}_PlayableGraph");
var output = AnimationPlayableOutput.Create(playableGraph, "AnimationOutput", animator);
layerMixerPlayable = AnimationLayerMixerPlayable.Create(playableGraph, 3);
baseMixer = CreateBaseMixer();
partialActionMixer = CreateActionMixer(isFull: false);
fullActionMixer = CreateActionMixer(isFull: true);
playableGraph.Connect(baseMixer, 0, layerMixerPlayable, 0);
playableGraph.Connect(partialActionMixer, 0, layerMixerPlayable, 1);
playableGraph.Connect(fullActionMixer, 0, layerMixerPlayable, 2);
layerMixerPlayable.SetInputWeight(0, 1f);
layerMixerPlayable.SetInputWeight(1, 0f);
layerMixerPlayable.SetInputWeight(2, 0f);
if (actionAvatarMask != null)
{
layerMixerPlayable.SetLayerMaskFromAvatarMask(1, actionAvatarMask);
}
output.SetSourcePlayable(layerMixerPlayable);
playableGraph.Play();
isGraphRunning = true;
if (baseClipIndices.ContainsKey("Idle"))
{
PlayBaseAnimation("Idle");
}
}
private AnimationMixerPlayable CreateBaseMixer()
{
baseClipIndices = new Dictionary<string, int>();
var list = new List<AnimationClipPlayable>();
AddBaseAnimation(list, idleAnimation, "Idle");
AddBaseAnimation(list, runForwardAnimation, "RunForward");
AddBaseAnimation(list, runBackwardAnimation, "RunBackward");
AddBaseAnimation(list, runLeftAnimation, "RunLeft");
AddBaseAnimation(list, runRightAnimation, "RunRight");
AddBaseAnimation(list, hitAnimation, "Hit");
AddBaseAnimation(list, pickUpAnimation, "PickUp");
AddBaseAnimation(list, stunAnimation, "Stun");
var mixer = AnimationMixerPlayable.Create(playableGraph, list.Count);
for (int i = 0; i < list.Count; i++)
{
playableGraph.Connect(list[i], 0, mixer, i);
mixer.SetInputWeight(i, 0f);
}
return mixer;
}
private void AddBaseAnimation(List<AnimationClipPlayable> list, CharacterAnimation anim, string key)
{
if (anim.animationClip == null) return;
var clipPlayable = AnimationClipPlayable.Create(playableGraph, anim.animationClip);
clipPlayable.SetApplyFootIK(true);
clipPlayable.SetSpeed(anim.speed <= 0f ? 1f : anim.speed);
list.Add(clipPlayable);
baseClipIndices[key] = list.Count - 1;
}
private AnimationMixerPlayable CreateActionMixer(bool isFull)
{
Dictionary<string, int> dict = new Dictionary<string, int>();
var clips = new List<AnimationClipPlayable>();
for (int i = 0; i < actions.Count; i++)
{
var act = actions[i];
if (act.animationClip == null) continue;
bool belongsHere = act.playCompletely ? isFull : !isFull;
if (!belongsHere) continue;
var clipPlayable = AnimationClipPlayable.Create(playableGraph, act.animationClip);
clipPlayable.SetApplyFootIK(true);
clipPlayable.SetSpeed(act.speed <= 0f ? 1f : act.speed);
clips.Add(clipPlayable);
dict[act.action] = clips.Count - 1;
}
var mixer = AnimationMixerPlayable.Create(playableGraph, clips.Count);
for (int i = 0; i < clips.Count; i++)
{
playableGraph.Connect(clips[i], 0, mixer, i);
mixer.SetInputWeight(i, 0f);
}
if (isFull) fullActionClipIndices = dict;
else partialActionClipIndices = dict;
return mixer;
}
#endregion
#region Public Checks
public bool IsActionPlaying()
{
return !string.IsNullOrEmpty(currentAction);
}
public string GetCurrentAction()
{
return currentAction;
}
#endregion
#region Base Animations (Layer 0)
/// <summary>
/// Plays a base animation by key, resetting any ongoing action layers.
/// </summary>
public void PlayBaseAnimation(string animKey)
{
if (!usePlayableAnimations || !isGraphRunning) return;
if (!baseClipIndices.ContainsKey(animKey)) return;
currentAction = "";
forceCompleteAction = false;
layerMixerPlayable.SetInputWeight(0, 1f);
layerMixerPlayable.SetInputWeight(1, 0f);
layerMixerPlayable.SetInputWeight(2, 0f);
int idx = baseClipIndices[animKey];
float crossFadeTime = GetBaseTransition(animKey);
StartCoroutine(CrossFadeMixer(baseMixer, idx, crossFadeTime, $"Base:{animKey}"));
}
private float GetBaseTransition(string animKey)
{
switch (animKey)
{
case "Idle": return idleAnimation.transitionPercent;
case "RunForward": return runForwardAnimation.transitionPercent;
case "RunBackward": return runBackwardAnimation.transitionPercent;
case "RunLeft": return runLeftAnimation.transitionPercent;
case "RunRight": return runRightAnimation.transitionPercent;
case "Hit": return hitAnimation.transitionPercent;
case "PickUp": return pickUpAnimation.transitionPercent;
case "Stun": return stunAnimation.transitionPercent;
}
return 0.1f;
}
#endregion
#region Action Animations
/// <summary>
/// Trigger-like action. If playCompletely == true => layer 2, else layer 1.
/// Does not guarantee the animation to finish unless you use PlayActionAnimationOnce or respect exitTime logic.
/// </summary>
public void PlayActionAnimation(string animKey)
{
if (!usePlayableAnimations || !isGraphRunning) return;
if (forceCompleteAction) return;
if (currentAction == animKey) return;
var data = FindAction(animKey);
if (data.animationClip == null) return;
currentAction = animKey;
float crossFade = data.transition <= 0f ? 0.1f : data.transition;
if (data.playCompletely)
{
forceCompleteAction = true;
StartCoroutine(FullActionCoroutine(data, crossFade, -1f));
}
else
{
layerMixerPlayable.SetInputWeight(0, 1f);
layerMixerPlayable.SetInputWeight(2, 0f);
layerMixerPlayable.SetInputWeight(1, 1f);
if (!partialActionClipIndices.ContainsKey(animKey)) return;
int idx = partialActionClipIndices[animKey];
StartCoroutine(CrossFadeMixer(partialActionMixer, idx, crossFade, $"Partial:{animKey}"));
}
}
/// <summary>
/// Plays an action once, respecting exitTime. After that, it transitions to Idle or Run depending on speed.
/// Another action can interrupt if forceCompleteAction == false.
/// </summary>
public IEnumerator PlayActionAnimationOnce(string animKey, float manualDuration = -1f, string returnAnimKey = "Idle")
{
if (!usePlayableAnimations || !isGraphRunning) yield break;
var data = FindAction(animKey);
if (data.animationClip == null) yield break;
currentAction = animKey;
float crossFade = data.transition <= 0f ? 0.1f : data.transition;
if (data.playCompletely)
{
forceCompleteAction = true;
yield return StartCoroutine(FullActionCoroutine(data, crossFade, manualDuration, returnAnimKey));
}
else
{
layerMixerPlayable.SetInputWeight(0, 1f);
layerMixerPlayable.SetInputWeight(2, 0f);
layerMixerPlayable.SetInputWeight(1, 1f);
if (!partialActionClipIndices.ContainsKey(animKey)) yield break;
int idx = partialActionClipIndices[animKey];
yield return StartCoroutine(CrossFadeMixer(partialActionMixer, idx, crossFade, $"PartialOnce:{animKey}"));
float clipLen = data.animationClip.length / ((data.speed <= 0f) ? 1f : data.speed);
float duration = (manualDuration > 0f) ? manualDuration : clipLen;
float exitFraction = (data.exitTime <= 0f) ? 1f : Mathf.Clamp01(data.exitTime);
float exitPoint = duration * exitFraction;
float timer = 0f;
while (timer < exitPoint)
{
timer += Time.deltaTime;
yield return null;
}
if (data.transition > 0f)
yield return new WaitForSeconds(data.transition);
layerMixerPlayable.SetInputWeight(1, 0f);
currentAction = "";
forceCompleteAction = false;
PlayBaseAnimation(returnAnimKey);
}
}
/// <summary>
/// Internal coroutine for full override. After exitTime, transitions to Run or Idle automatically.
/// </summary>
private IEnumerator FullActionCoroutine(ActionAnimation data, float crossFade, float manualDuration, string returnAnimKey = "Idle")
{
layerMixerPlayable.SetInputWeight(0, 0f);
layerMixerPlayable.SetInputWeight(1, 0f);
layerMixerPlayable.SetInputWeight(2, 1f);
if (!fullActionClipIndices.ContainsKey(data.action))
{
forceCompleteAction = false;
currentAction = "";
yield break;
}
int idx = fullActionClipIndices[data.action];
yield return StartCoroutine(CrossFadeMixer(fullActionMixer, idx, crossFade, $"Full:{data.action}"));
float clipLen = data.animationClip.length;
float spd = (data.speed <= 0f) ? 1f : data.speed;
float finalDuration = (manualDuration > 0f) ? manualDuration : (clipLen / spd);
float exitFrac = (data.exitTime <= 0f) ? 1f : Mathf.Clamp01(data.exitTime);
float exitTime = finalDuration * exitFrac;
float timer = 0f;
while (timer < exitTime)
{
timer += Time.deltaTime;
yield return null;
}
if (data.transition > 0f)
yield return new WaitForSeconds(data.transition);
layerMixerPlayable.SetInputWeight(2, 0f);
layerMixerPlayable.SetInputWeight(0, 1f);
forceCompleteAction = false;
currentAction = "";
PlayBaseAnimation(returnAnimKey);
}
#endregion
#region Run Logic
/// <summary>
/// Plays a run animation based on the movement direction vs. the model forward.
/// If not all directions are available, fallback to RunForward.
/// </summary>
public void PlayRunAnimation(Vector3 moveDir, Vector3 modelForward)
{
if (moveDir.magnitude < 0.1f)
{
PlayBaseAnimation("Idle");
return;
}
if (!useRunBlendTree)
{
PlayBaseAnimation("RunForward");
return;
}
Vector3 fwd = modelForward.normalized;
Vector3 normalizedMove = moveDir.normalized;
float forwardDot = Vector3.Dot(fwd, normalizedMove);
Vector3 rightDir = Quaternion.Euler(0, 90, 0) * fwd;
float rightDot = Vector3.Dot(rightDir, normalizedMove);
if (forwardDot >= 0.5f && baseClipIndices.ContainsKey("RunForward"))
{
PlayBaseAnimation("RunForward");
}
else if (forwardDot <= -0.5f && baseClipIndices.ContainsKey("RunBackward"))
{
PlayBaseAnimation("RunBackward");
}
else if (rightDot >= 0.5f && baseClipIndices.ContainsKey("RunRight"))
{
PlayBaseAnimation("RunRight");
}
else if (rightDot <= -0.5f && baseClipIndices.ContainsKey("RunLeft"))
{
PlayBaseAnimation("RunLeft");
}
else
{
PlayBaseAnimation("RunForward");
}
}
#endregion
#region CrossFade
/// <summary>
/// Crossfades a mixer from current weights to targetIndex=1 over 'duration' seconds.
/// </summary>
private IEnumerator CrossFadeMixer(AnimationMixerPlayable mixer, int targetIndex, float duration, string dbgKey)
{
if (!mixer.IsValid()) yield break;
int count = mixer.GetInputCount();
float[] startWeights = new float[count];
for (int i = 0; i < count; i++)
{
startWeights[i] = mixer.GetInputWeight(i);
}
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
for (int i = 0; i < count; i++)
{
float endVal = (i == targetIndex) ? 1f : 0f;
float newWeight = Mathf.Lerp(startWeights[i], endVal, t);
mixer.SetInputWeight(i, newWeight);
}
yield return null;
}
for (int i = 0; i < count; i++)
{
float finalVal = (i == targetIndex) ? 1f : 0f;
mixer.SetInputWeight(i, finalVal);
}
}
#endregion
#region Find Action
private ActionAnimation FindAction(string actionKey)
{
for (int i = 0; i < actions.Count; i++)
{
if (actions[i].action == actionKey && actions[i].animationClip != null)
{
return actions[i];
}
}
return default;
}
#endregion
#region Legacy Animator Fallback
public void SetAnimatorTrigger(string triggerName)
{
if (!usePlayableAnimations && animator != null)
{
animator.SetTrigger(triggerName);
}
}
public void SetAnimatorBool(string boolName, bool value)
{
if (!usePlayableAnimations && animator != null)
{
animator.SetBool(boolName, value);
}
}
public void SetAnimatorFloat(string floatName, float value)
{
if (!usePlayableAnimations && animator != null)
{
animator.SetFloat(floatName, value);
}
}
#endregion
}
}