Using abilities in 3rd Person Combat State Machine

Hi everyone,

I’m slowly integrating bits and pieces of the RPG course into the 3rd Person course. Working really well so far, and I’ve ‘almost’ got all of the features I need working fine. I just can’t quite figure out the last piece of the Ability puzzle.

I’m using the ActionStore to handle which ActionItem is currently equipped (listening for numeric key input in the new input system).

[SerializeField] Dictionary<ActionItem, bool> unlockedAbilities = new Dictionary<ActionItem, bool>();
        public Ability[] abilities;
        [SerializeField] ActionItem currentlyEquippedActionItem = null;

And the ‘Use’ method is where I switch state (this is called from my State Machine states)

public bool Use(GameObject user)
        {
            if (currentlyEquippedActionItem)
            {
                playerStateMachine.SwitchState(new PlayerUseAbilityState(playerStateMachine, currentlyEquippedActionItem));
                return true;
            }
            return false;
        }

So after the press the UseAbility input, the player switches to the UseAbility State:

using EchoesOfEight.Abilities;
using EchoesOfEight.Inventories;
using System;
using UnityEngine;

namespace EchoesOfEight.Control
{
    public class PlayerUseAbilityState : PlayerBaseState
    {
        private ActionItem ability;

        public PlayerUseAbilityState(PlayerStateMachine stateMachine, ActionItem ability) : base(stateMachine)
        {
            this.ability = ability;
        }

        public override void Enter()
        {
            Debug.Log("Entered use ability state");
            stateMachine.InputReader.UseAbilityEvent -= OnUseAbility; // Prevent triggering another ability
            ability.Use(stateMachine.gameObject);


        }

        private void OnUseAbility()
        {

        }

        public override void Tick(float deltaTime)
        {
            Vector3 movement = CalculateMovement();
        }

        public override void Exit()
        {
            Debug.Log("Exited UseAbulity State");


            

        }

        private Vector3 CalculateMovement()
        {
            Vector3 forward = stateMachine.MainCameraTransform.forward;
            Vector3 right = stateMachine.MainCameraTransform.right;

            forward.y = 0f;
            right.y = 0f;

            forward.Normalize();
            right.Normalize();

            return forward * stateMachine.InputReader.MovementValue.y +
                right * stateMachine.InputReader.MovementValue.x;
        }

        private void FaceMovementDirection(Vector3 movement, float deltaTime)
        {
            stateMachine.transform.rotation = Quaternion.Lerp(
            stateMachine.transform.rotation,
            Quaternion.LookRotation(movement),
            deltaTime * stateMachine.RotationDamping);
        }

        private void OnAbilityDeactivated()
        {
            Debug.Log("Returned to Locomotion");
            ReturnToLocomotion();
        }
    }
}

And hangs there forever (which is to be expected!) So, my question is:

How can I use the ReturnToLocomotion function in the useability state? Or in other words, how can that state know when all of the strategies are finished doing their thing?

I’m watching the lectures again to see if I can make use of the finish() event in each strategy, but a point in the right direction would be much appreciated!

Edit to add:

I initially just had the ability be triggered in the Free look/Targeting states, and this worked fine and dandy, but I’m hoping to be able to do more with it in a dedicated useability state :slightly_smiling_face:

I haven’t done the abilities course, so I’m going by normal logic here;

First, you should really unsubscribe from the UseAbilityEvent in the Exit of the previous state instead. My rule is that if I subscribe to any event in Enter I should unsubscribe in Exit. Here you are unsubscribing a method that is not subscribed, and the one that is subscribed remains subscribed

Now, for your question

I had a look at the finished code in the repo and this is what I see; each effect calls a finished callback when it is done executing. @Brian_Trotter may have far better ideas here, but what I would do is to update Ability.cs and create a little holder for each effect.

class EffectCompletion
{
    public bool IsCompleted { get; private set; } = false;
    private Action _finishedCallback;
    public EffectCompletion(Action finishedCallback)
    {
        _finishedCallback = finishedCallback;
    }
    public void EffectComplete()
    {
        IsCompleted = true;
        _finishedCallback();
    }
}

When a target is acquired, I’d put each effect in one of these and keep a list

// add this list in the ability itself
private List<EffectCompletion> _effectCompletionList = new();

// This replaces the bits in `TargetAcquired`
_effectCompletionList.Clear();
foreach (var effect in effectStrategies)
{
    EffectCompletion completion = new EffectCompletion(EffectFinished);
    effect.StartEffect(data, completion.EffectComplete);
    _effectCompletionList.Add(completion);
}

And lastly, in EffectFinished (which is empty in the repo) add this

private void EffectFinished()
{
    _effectCompletionList.RemoveAll(completion => completion.IsCompleted);
    if (_effectCompletionList.Count == 0)
    {
        // All the effects have completed, Fire an event
    }
}

Now you can just subscribe to the event (that I didn’t add) and it will fire when all the effects have completed. The list should probably be part of data but I went with the ‘I just wrote this in post’ method. Also, this may not be 100% ideal because some effects may need to linger - for some reason - and may not finish immediately and will ‘freeze’ the character until it’s done. Unless, of course, you call the finished callback before starting the lingering bits.

(Edit: This will also not work if the ability can do multiple targets. It’s clearing the list so you may need to not clear the list in TargetAcquired, but rather in the Use method)

I personally didn’t use the ‘return to locomotion’ stuff from the 3rd person course because I have a stack-based state machine and would just ‘pop’ the state when the ability is complete but in your case, the ‘return to locomotion’ should work fine.

1 Like

I’ll take a look at this in the morning, but @bixarrio is dead right on the subscription issue. Since you’re in the UseAbility state, no subscription or unsubscription should be needed. The subscription for the UseAbilityEvent should be in classes where you can switch to the Ability… usually PlayerFreelookState and PlayerTargetingState.
That UseAbility method in those states will be responsible for switching to the PlayerUseAbility state, adn should gracefully return to the appropraiate locomotion state in the even that the ability fails.

Thanks to @bixarrio I have this working at a base level, albeit now to work on the edge cases.

Also, the event subscription was leftover from a previous iteration, and should have been a += :slight_smile:

This is my PlayerUseAbilityState, it’s working fine for now:

using EchoesOfEight.Abilities;
using EchoesOfEight.Inventories;
using System;
using UnityEngine;

namespace EchoesOfEight.Control
{

    public class PlayerUseAbilityState : PlayerBaseState
    {
        private readonly int AbilityTestHash = Animator.StringToHash("AbilityTest");
        private ActionItem abilityToUse;
        private const float CrossFadeDuration = 0.1f;

        public PlayerUseAbilityState(PlayerStateMachine stateMachine, ActionItem ability) : base(stateMachine)
        {
            abilityToUse = ability;
        }

        public override void Enter()
        {
            
            Debug.Log("Entered UseAbilityState");

            if (abilityToUse == null)
            {
                ReturnToLocomotion();
                return;
            }

            Ability abilityInstance = abilityToUse as Ability;

            if (abilityInstance != null)
            {
                abilityInstance.Use(stateMachine.gameObject);
            }

            abilityInstance.AllEffectsFinished += OnAbilityDeactivated;
            stateMachine.Animator.CrossFadeInFixedTime(AbilityTestHash, CrossFadeDuration);
        }

        public override void Tick(float deltaTime)
        {
            Vector3 movement = CalculateMovement();
        }

        public override void Exit()
        {
            Ability abilityInstance = abilityToUse as Ability;
            abilityInstance.AllEffectsFinished += OnAbilityDeactivated;
            Debug.Log("Exited UseAbility State");
        }

        private Vector3 CalculateMovement()
        {
            Vector3 forward = stateMachine.MainCameraTransform.forward;
            Vector3 right = stateMachine.MainCameraTransform.right;

            forward.y = 0f;
            right.y = 0f;

            forward.Normalize();
            right.Normalize();

            return forward * stateMachine.InputReader.MovementValue.y +
                right * stateMachine.InputReader.MovementValue.x;
        }

        private void FaceMovementDirection(Vector3 movement, float deltaTime)
        {
            stateMachine.transform.rotation = Quaternion.Lerp(
            stateMachine.transform.rotation,
            Quaternion.LookRotation(movement),
            deltaTime * stateMachine.RotationDamping);
        }

        private void OnAbilityDeactivated()
        {
            Debug.Log("Returned to Locomotion");
            ReturnToLocomotion();
        }
    }
}

And the Ability.cs (I think I implemented everything as mentioned):

using System.Collections;
using UnityEngine;
using System;
using EchoesOfEight.Inventories;
using System.Collections.Generic;

namespace EchoesOfEight.Abilities
{
    [CreateAssetMenu(fileName = "New Ability", menuName = "Abilities/MakeNewAbility", order = 0)]
    public class Ability : ActionItem
    {
        
        [SerializeField] TargetingStrategy targetingStrategy;
        [SerializeField] FilterStrategy[] filterStrategies;
        [SerializeField] EffectStrategy[] effectStrategies;
        [SerializeField] bool canTriggerInFreeLook = true;
        [SerializeField] bool canTriggerInTargeting = true;
        [SerializeField] float cooldownTime = 0;
        [SerializeField] float manaCost = 0;

        private List<EffectCompletion> _effectCompletionList = new();
        public event Action AllEffectsFinished;

        class EffectCompletion
        {
            public bool IsCompleted { get; private set; } = false;
            private Action _finishedCallback;
            public EffectCompletion(Action finishedCallback)
            {
                _finishedCallback = finishedCallback;
            }
            public void EffectComplete()
            {
                IsCompleted = true;
                _finishedCallback();
            }
        }

        public override void Use(GameObject user)
        {

            AbilityData data = new AbilityData(user);

            if (data.CooldownStore == null || data.CooldownStore.GetTimeRemaining(this) == 0)
            {
                if (data.Mana.GetMana() < manaCost)
                {
                    return;
                }
                targetingStrategy.StartTargeting(data, () => TargetAcquired(data));
            }

        }

        private void TargetAcquired(AbilityData data)
        {
            if (!data.Mana.UseMana(manaCost)) return;
            data.CooldownStore.StartCooldown(this, cooldownTime);

            foreach (var filterStrategy in filterStrategies)
            {
                data.SetTargets(filterStrategy.Filter(data.GetTargets()));

            }

            _effectCompletionList.Clear();
            foreach (var effect in effectStrategies)
            {
                EffectCompletion completion = new EffectCompletion(EffectFinished);
                effect.StartEffect(data, completion.EffectComplete);
                _effectCompletionList.Add(completion);
            }
        }

        private void EffectFinished()
        {
            _effectCompletionList.RemoveAll(completion => completion.IsCompleted);
            if (_effectCompletionList.Count == 0)
            {
                AllEffectsFinished?.Invoke();
            }
        }

        public bool CanTriggerInFreeLook()
        {
            return canTriggerInFreeLook;
        }

        public bool CanTriggerInTargeting()
        {
            return canTriggerInTargeting;
        }
    }
}

As mentioned, I moved the finished() callback in, for example, the spawnprefab effect to as soon as the effect is instantiated (as this for now is just visuals in my game, so no issues).

This works fine for immediate use abilities (i.e. abilities where the effect, animation etc all fire immediately upon using the animation)

The next step will be to integrate the triggeranimation effect, particularly with delayed press targeting. If the ability implements that strategy, then it should play the initial animation and then the actual cast animation when the ability is used.

If no one can see glaring issues with the above, I’ll soldier on :slight_smile:

The problem is your UseAbilityState is stuck because the ability’s effects are asynchronous – they don’t instantly finish. You need a way for the ability to tell the state machine it’s done.
Instead of directly calling ReturnToLocomotion() in OnAbilityDeactivated(), have your abilities raise an event when they’re finished. Then, subscribe to that event in PlayerUseAbilityState. That event would trigger ReturnToLocomotion(). Think of it like a callback system.

I’ve been playing around with different abilities from the course, and hit a snag. The Delayed Press Targeting abilities all work fine (except playing one animation when starting the target and another when the ability is used, that’s a later problem). These types of abilities reliably enter the UseAbility state, and exit once all of the effects have fired their finish() event.

I’ve just created Sam’s Self-targeting strategy and accompanying effects, for a ‘Heal’ ability (the same type effect as a Health Potion).

using System;
using UnityEngine;

namespace EchoesOfEight.Abilities.Targeting
{
    [CreateAssetMenu(fileName = "Self Targeting", menuName = "Abilities/Targeting/Self", order = 0)]

    public class SelfTargeting : TargetingStrategy
    {
        public override void StartTargeting(AbilityData data, Action finished)
        {
            data.SetTargets(new GameObject[] { data.GetUser() });
            data.SetTargetPoint(data.GetUser().transform.position);
          
            finished();

        }
    }
}

So this is an immediate use ability. Upon use, the player enters the UseAbility state, Debug logs show that all effects have fired their finished() events, yet there is no exit of the UseAbility state. I’ve tried using effects from my delayed press abilities, and had the same behaviour.

So, I changed the Targeting strategy from Self-Targeting to Delayed Press Targeting, and it worked fine. So, I’m assuming I’m just missing something on how to incorporate the Self-Targeting strategy with @bixarrio 's EffectCompletion class.

Any thoughts appreciated!

EDIT: Re-reading @Galileo007 's comment got me thinking about the effects being asynchronous and not being set up in an immediate ability trigger. So, I added a small delay in the self-targeting strategy as below (I could maybe use yield return null for a one frame wait, but the 0.1f is small enough to not be noticeable in game and also gives a bit of a buffer)

using System;
using System.Collections;
using UnityEngine;

namespace EchoesOfEight.Abilities.Targeting
{
    [CreateAssetMenu(fileName = "Self Targeting", menuName = "Abilities/Targeting/Self", order = 0)]

    public class SelfTargeting : TargetingStrategy
    {
        public override void StartTargeting(AbilityData data, Action finished)
        {
            data.SetTargets(new GameObject[] { data.GetUser() });
            data.SetTargetPoint(data.GetUser().transform.position);

            Debug.Log("SelfTargeting started. Delaying finished().");
            data.StartCoroutine(DelayedFinish(finished));
        }

        private IEnumerator DelayedFinish(Action finished)
        {
            yield return new WaitForSeconds(0.1f); // Short delay
            finished();
        }

    }
}

On the off chance anyone else is following this thread, I also added to the triggeranimation effect to ensure the animation finishes before firing the finish() event (this could also be turned into a delay composite):

using EchoesOfEight.Abilities;
using System;
using System.Collections;
using UnityEngine;

namespace EchoesOfEight.Abilities.Effects
{
    [CreateAssetMenu(fileName = "Trigger Animation Effect", menuName = "Abilities/Effects/TriggerAnimation", order = 0)]
    public class TriggerAnimationEffect : EffectStrategy
    {
        [SerializeField] string animationName;
        [SerializeField] float crossfadeDuration = 0.1f;
        [SerializeField] string animationTag = "Ability"; // Optional: specify the tag of the animation to track
        [SerializeField] float timeToDelay = 0.9f;

        public override void StartEffect(AbilityData data, Action finished)
        {
            Animator animator = data.GetUser().GetComponent<Animator>();
            if (animator == null)
            {
                Debug.LogError("Animator not found on user object.");
                finished();
                return;
            }

            // Start the animation
            animator.CrossFadeInFixedTime(animationName, crossfadeDuration);

            // Wait for the animation to complete before calling finished()
            data.StartCoroutine(WaitForAnimationToFinish(animator, finished));
        }

        private IEnumerator WaitForAnimationToFinish(Animator animator, Action finished)
        {
            // Wait until the animation with the specified tag is nearly complete
            while (GetNormalizedTime(animator) < timeToDelay)
            {
                yield return null; // Wait until the next frame to re-check
            }

            // Animation is close to complete
            finished();
        }

        private float GetNormalizedTime(Animator animator)
        {
            // Check the current and next animation states
            AnimatorStateInfo currentInfo = animator.GetCurrentAnimatorStateInfo(0);
            AnimatorStateInfo nextInfo = animator.GetNextAnimatorStateInfo(0);

            if (animator.IsInTransition(0) && nextInfo.IsTag(animationTag))
            {
                return nextInfo.normalizedTime;
            }
            else if (!animator.IsInTransition(0) && currentInfo.IsTag(animationTag))
            {
                return currentInfo.normalizedTime;
            }

            return 0; // Default if no relevant animation is playing
        }
    }
}

Privacy & Terms