using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Animations; using UnityEngine.Events; namespace BulletHellTemplate { /// /// Stores a base animation clip with speed and transition percent. /// [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; } /// /// Stores an action animation clip with speed, transition time, exit time, etc. /// [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; } /// /// Class that stores events for skill usage. /// [System.Serializable] public class UseSkillEventData { [HideInInspector] public string eventName; public UnityEvent onUseSkill; } /// /// 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 /// [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 actions = new List(); [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 useSkillEvents = new List(); [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 baseClipIndices; private Dictionary partialActionClipIndices; private Dictionary fullActionClipIndices; private bool isGraphRunning; private bool forceCompleteAction; private string currentAction = ""; private bool useRunBlendTree; #endregion private void Awake() { if (animator == null) { animator = GetComponent(); if (animator == null) { animator = GetComponentInChildren(); if (animator == null) { animator = GetComponentInParent(); } } 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(); var list = new List(); 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 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 dict = new Dictionary(); var clips = new List(); 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) /// /// Plays a base animation by key, resetting any ongoing action layers. /// 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 /// /// 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. /// 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}")); } } /// /// Plays an action once, respecting exitTime. After that, it transitions to Idle or Run depending on speed. /// Another action can interrupt if forceCompleteAction == false. /// 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); } } /// /// Internal coroutine for full override. After exitTime, transitions to Run or Idle automatically. /// 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 /// /// Plays a run animation based on the movement direction vs. the model forward. /// If not all directions are available, fallback to RunForward. /// 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 /// /// Crossfades a mixer from current weights to targetIndex=1 over 'duration' seconds. /// 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 } }