using System.Collections; using UnityEngine; using UnityEngine.Assertions; using HFSM; using static BulletHellTemplate.Core.FSM.AnimationEvent; using static BulletHellTemplate.Core.FSM.AnimationState; using System; namespace BulletHellTemplate.Core.FSM { /// /// High-level FSM that drives character animations using UnityHFSM. /// No external triggers are required for Locomotion; it evaluates /// movement each frame via a dynamic condition. /// [DisallowMultipleComponent] public sealed class CharacterAnimationFSM : MonoBehaviour { #region Inspector [Tooltip("Component that implements ICharacterAnimationContext.")] private MonoBehaviour contextProvider; [Tooltip("Animator layer index for upper-body (masked) skills.")] [SerializeField] private int skillLayerIndex = 1; #endregion private ICharacterAnimationContext ctx; private StateMachine root; private StateMachine< AnimationState, LocomotionSubState, AnimationEvent> locomotion; private Coroutine _actionRoutine; private Vector2 currentDir; private int pendingSkillIdx; private bool _isMoving; private bool _actionDone; private float _lastSkillLen; /*── health tracking ──────────────────*/ private Func _isAlive; // returns true while HP > 0 private bool _prevAlive; // cached previous value /*──────────────────────────────────────────────*/ #region Unity lifecycle private void Awake() { ctx = contextProvider != null ? contextProvider as ICharacterAnimationContext : GetComponent(); /* 2. Health checker ------------------------------------------*/ Assert.IsNotNull(ctx, "No component implementing ICharacterAnimationContext was found on this GameObject."); if (TryGetComponent(out MonsterHealth mh)) _isAlive = () => mh.CurrentHp > 0; else if (TryGetComponent(out CharacterStatsComponent ch)) _isAlive = () => ch.CurrentHP > 0; else _isAlive = () => true; // fallback: always alive _prevAlive = _isAlive(); BuildFsm(); root.Init(); } /// /// Tick the FSM every frame so timers/conditions are processed /// even when the character is idle. /// private void Update() { bool alive = _isAlive(); if (alive != _prevAlive) { if (!alive) { root.Trigger(OnDie); } else { root.RequestStateChange(Locomotion); ctx.Animator.SetLayerWeight(skillLayerIndex, 0); _actionDone = false; } _prevAlive = alive; } root.OnLogic(); } #endregion /*──────────────────────────────────────────────*/ #region Public API /// Called every frame by gameplay layer. public void SetMove(Vector2 dir) { currentDir = dir; bool wasMoving = _isMoving; _isMoving = dir.sqrMagnitude >= 0.01f; if (root.ActiveStateName.Equals(Locomotion) && locomotion.ActiveStateName.Equals(LocomotionSubState.Move)) { UpdateBlend(currentDir); } } public void PlayAttack() { root.Trigger(OnAttack); } public void PlaySkill(int idx) { pendingSkillIdx = idx; root.Trigger(OnSkill); } public void PlayReceiveDamage() { root.Trigger(OnReceiveDamage); } public void PlayDeath() { root.Trigger(OnDie); } #endregion /*──────────────────────────────────────────────*/ #region FSM setup private void BuildFsm() { root = new(); /*──────────────── Locomotion (sub-FSM) ────────────────*/ BuildLocomotionSubFsm(); root.AddState(Locomotion, locomotion); /*──────────────── ATTACK ──────────────────────────*/ root.AddState(Attack, onEnter: s => { PlaySkill(ctx.Attack, "Attack"); _actionDone = false; StartActionTimer(ClipLength(ctx.Attack)); }, onExit: s => { ctx.Animator.SetLayerWeight(skillLayerIndex, 0); StopActionTimer(); }); root.AddTransition(Attack, Locomotion, _ => _actionDone); /*──────────────── SKILL ───────────────────────────*/ root.AddState(Skill, onEnter: s => { PlaySkillInternal(); _actionDone = false; StartActionTimer(_lastSkillLen); }, onExit: s => { ctx.Animator.SetLayerWeight(skillLayerIndex, 0); StopActionTimer(); }); root.AddTransition(Skill, Locomotion, _ => _actionDone); /*───── Receive Damage & Death ─────────────────────────*/ root.AddState(ReceiveDamage, onEnter: s => Play("ReceiveDamage", ctx.ReceiveDamage)); float dmgLen = ClipLength(ctx.ReceiveDamage); root.AddTransition( new TransitionAfter(ReceiveDamage, Locomotion, dmgLen)); root.AddState(Dead, onEnter: s => Play("Death", ctx.Death), isGhostState: true); /*───── Global triggers (Attack overrides Skill etc.) ──*/ AddGlobal(OnAttack, Attack); AddGlobal(OnSkill, Skill); AddGlobal(OnReceiveDamage, ReceiveDamage); AddGlobal(OnDie, Dead); /*───── Start ──────────────────────────────────────────*/ root.SetStartState(Locomotion); } private void BuildLocomotionSubFsm() { locomotion = new(); locomotion.AddState( LocomotionSubState.Idle, onEnter: s => PlayDirectional("Idle_Blend", ctx.IdleSet.Forward.Speed)); locomotion.AddState( LocomotionSubState.Move, onEnter: s => PlayDirectional("Run_Blend", ctx.MoveSet.Forward.Speed)); /* Dynamic transitions evaluated every OnLogic() */ locomotion.AddTransition( LocomotionSubState.Idle, LocomotionSubState.Move, _ => _isMoving); locomotion.AddTransition( LocomotionSubState.Move, LocomotionSubState.Idle, _ => !_isMoving); locomotion.SetStartState(LocomotionSubState.Idle); } private void AddGlobal(AnimationEvent trig, AnimationState to) { root.AddTriggerTransitionFromAny(trig, new Transition(default, to, _ => true)); } #endregion /*──────────────────────────────────────────────*/ #region Animation helpers private const string X = "MoveX", Y = "MoveY"; private static float ClipLength(AnimClipData d) => d.Clip ? d.Clip.length / Mathf.Max(0.1f, d.Speed) : 0.5f; private static float ClipLength(SkillAnimData d) => d.Clip ? d.Clip.length / Mathf.Max(0.1f, d.Speed) : 0.5f; private void Play(string state, AnimClipData d, int layer = 0) { if (state == null) return; ctx.Animator.speed = d.Speed <= 0 ? 1 : d.Speed; ctx.Animator.CrossFadeInFixedTime(state, d.Transition, layer); } private void PlayDirectional(string state, float speed) { ctx.Animator.speed = speed <= 0 ? 1 : speed; ctx.Animator.CrossFadeInFixedTime(state, 0.1f, 0); } private void UpdateBlend(Vector2 dir) { ctx.Animator.SetFloat(X, dir.x, 0.1f, Time.deltaTime); ctx.Animator.SetFloat(Y, dir.y, 0.1f, Time.deltaTime); } private void PlaySkill(SkillAnimData d, string state) { int layer = (d.UseAvatarMask && d.Mask) ? skillLayerIndex : 0; ctx.Animator.SetLayerWeight(skillLayerIndex, layer == skillLayerIndex ? 1 : 0); ctx.Animator.speed = d.Speed <= 0 ? 1 : d.Speed; ctx.Animator.CrossFadeInFixedTime(state, d.Transition, layer); } private void PlaySkillInternal() { if (!ctx.TryGetSkill(pendingSkillIdx, out var d)) return; PlaySkill(d, $"Skill_{pendingSkillIdx}"); _lastSkillLen = ClipLength(d); } /// Starts a one-shot timer that will set _actionDone = true. private void StartActionTimer(float seconds) { StopActionTimer(); _actionRoutine = StartCoroutine(ActionTimerRoutine(seconds)); } private void StopActionTimer() { if (_actionRoutine != null) StopCoroutine(_actionRoutine); _actionRoutine = null; } private IEnumerator ActionTimerRoutine(float t) { yield return new WaitForSeconds(t); _actionDone = true; } #endregion } }