Building upon what I have

In the last post I wrote some of the theory behind the framework for this behavior graph system. Now it's time to build upon that and make something that works. This post wont be exhaustive but instead I will take a few of my implementations a show how they we're built.


Currently the framework consists of nodes, that can be either

  • A state, a node that makes a character do something
  • A condition, a split node to determine what the next state will be based on some state
  • A trigger, a way to initialize (trigger) a transition between states.


Building a Idle State Node

The most basic of all possible nodes: An idle node! This node wont do anything and don't have an explicit exit transition, so triggers must be used to transition out of it.

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("State/General/Idle")]
   public class IdleState: StateBase
   {
       protected override bool OnExecute(StateInstance instance)
       {
           return false;
       }
   }
}

As stated, it doesn't do anything, don't have any enter of exit logic and always returns false meaning its not done. It doesn't get any easier than this.


Building a Delay State Node

This note is probably the next simplest implementation. It does not have any enter or exit logic but it needs to keep track of a timer and return true when a specific value has been reached. It also supports randomized delay between a min and max value.

Note: The [Limit] attribute is custom. It operates the same a Unity's builtin [Range] attribute but still displays the field as a textbox instead of a slider.

using UnityEngine;

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("State/General/Delay")]
   public class DelayState : StateBase
   {
       [Limit(0, float.MaxValue)]
       [Input(connectionType = ConnectionType.Single)]
       public float Min = 0;

       [Limit(0, float.MaxValue)]
       [Input(connectionType = ConnectionType.Single)]
       public float Max = 0;

       protected override void OnEnter(StateInstance instance, TriggerContext triggerContext)
       {
           base.OnEnter(instance, triggerContext);
           var min = GetInputValue(nameof(Min), Min);
           var max = GetInputValue(nameof(Max), Max);
           instance.CurrentTimer = Random.Range(min, max);
       }

       protected override bool OnExecute(StateInstance instance)
       {
           instance.CurrentTimer -= Time.deltaTime;
           return instance.CurrentTimer <= 0;
       }
   }
}


Building a Move Randomly State

This state picks a position at random within some given area and issues a Unit to walk there. This state will issue orders to a unit via its Nav Mesh Agent. I quickly realized lots of states would need to do this so there is an abstract base class for any NavMesh dependent state.

namespace MageQuest.AI.BehaviourGraph2
{
   public abstract class NavMeshState: StateBase
   {
       protected override void OnEnter(StateInstance instance, TriggerContext triggerContext)
       {
           instance.Actor.NavMeshObstacle.enabled = false;
           instance.Actor.NavMeshAgent.enabled = true;
           instance.Context.UnitInstance.CurrentMovement = UnitInstance.NavMeshMovement;
       }

       protected override void OnExit(StateInstance instance)
       {
           instance.Actor.NavMeshAgent.enabled = false;
           instance.Actor.NavMeshObstacle.enabled = true;
           instance.Context.UnitInstance.CurrentMovement = UnitInstance.DefaultMovement;
       }

       protected void RotateToDirection(StateInstance instance)
       {
           var direction = (instance.NavMeshAgent.steeringTarget - instance.NavMeshAgent.transform.position).normalized;
           instance.Actor.SetRotationDirection(direction);
       }

       protected void RotateFromDirection(StateInstance instance)
       {
           var direction = (instance.NavMeshAgent.steeringTarget - instance.NavMeshAgent.transform.position).normalized;
           instance.Actor.SetRotationDirection(-direction);
       }
   }
}

To make my Nav mesh movement smoother, when a Unit is moving it's a NavMesh Agent but when it's standing still its treated as a Nav Mesh obstacle instead. This change makes the combination of units standing still and other units moving behaving a lot nicer.


using UnityEngine;

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("State/Movement/Random")]
   public class MoveRandomlyState: NavMeshState
   {
       public enum InitialPositionType: int
       {
           SpawnPosition = 0,
           TransitionPosition = 1
       }

       [Tooltip("Min distance\nMinimum distance from the initial position the unit can move")]
       public float MinDistance = 0;
       [Tooltip("Max distance\nMaximum distance from the initial position the unit can move")]
       public float MaxDistance = 1;

       [Tooltip("Initial Position\nDetermines what counts as the initial position which it moves around")]
       public InitialPositionType InitialPosition = InitialPositionType.SpawnPosition;

       [Tooltip("Speed factor\nPercentage of max speed it will move")]
       public float SpeedFactor = 1f;

       [Tooltip("Finish Threshold\nDetermines how close the unit most come to its destination")]
       public float FinishThreshold = 0.5f;

       protected override void OnEnter(StateInstance instance, TriggerContext triggerContext)
       {
           base.OnEnter(instance, triggerContext);
           instance.Actor.NavMeshSpeedFactor = SpeedFactor;
           SetRandomDestination(instance); 
       }

       protected override bool OnExecute(StateInstance instance)
       {
           RotateToDirection(instance);
           return !instance.NavMeshAgent.pathPending && instance.NavMeshAgent.remainingDistance <= FinishThreshold;
       }

       public override void OnRestart(StateInstance instance)
       {
           SetRandomDestination(instance);
       }

       private Vector3 GetBasePosition(BehaviourContext behaviourContext)
       {
           if (InitialPosition == InitialPositionType.SpawnPosition) {
               return behaviourContext.InitialPosition;
           } else if (InitialPosition == InitialPositionType.TransitionPosition) {
               return behaviourContext.UnitInstance.Actor.transform.position;
           } else {
               throw new System.InvalidOperationException($"InitialPosition not of valid type: {InitialPosition}");
           }
       }

       private void SetRandomDestination(StateInstance instance)
       {
           var destination = GetBasePosition(instance.Context) + GetMovementOffset();
           instance.Context.UnitInstance.CurrentMovement.SetDestination(instance.Actor, destination);
       }

       private Vector3 GetMovementOffset()
       {
           var direction = Random.Range(0, 2 * Mathf.PI);
           var distance = Random.Range(MinDistance, MaxDistance);

           return new Vector3(Mathf.Sin(direction), 0, Mathf.Cos(direction)) * distance;
       }
   }
}


Building a Has Mana Condition

This is the simplest condition implementation. Given an abilility, it will check if the unit executing the AI behaviour has enough mana to cast it or not.

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("Condition/Has Mana")]
   public class HasManaCondition: BinaryConditionBase
   {
       [NullWarning]
       public Ability Ability;

       public override bool IsTrue(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           if (Ability == null) {
               return false;
           }

           return behaviourContext.UnitInstance.HasMana(Ability.CastManaCost);
       }
   }
}


Building a Can Cast Condition

A more exhaustive check for a Unity. Checks for lots of stuff to ensure an ability can be cast.

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("Condition/Can cast Ability")]
   public class CanCastAbility : BinaryConditionBase
   {
       [Input(connectionType = ConnectionType.Single)]
       public Ability Ability;

       public override bool IsTrue(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           var currentAbility = GetInputValue(nameof(Ability), Ability);

           if (currentAbility == null) {
               return false;
           }

           var abilityInstance = behaviourContext.UnitInstance.GetInstanceForAbility(currentAbility);
           if(abilityInstance == null) {
               return false;
           }

           if (!behaviourContext.UnitInstance.HasMana(currentAbility.CastManaCost)) {
               return false;
           }

           if(abilityInstance.CurrentCooldown > 0){
               return false;
           }

           return true;
       }
   }
}


Building a Line of Sight Condition

Returns true if the is an unhindered path between the executing unit and a selected unit. Useful before casting any form of projectiles. Can ensures there is no terrain in the way, nor any friendly units.


using UnityEngine;

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("Condition/Line of Sight")]
   public class LineOfSightCondition : BinaryConditionBase
   {
       public float MaxRange = 50;
       public TargetSelector Selector;

       public override bool IsTrue(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           var target = Selector.GetTarget(behaviourContext, triggerContext);
           if (target == null) {
               return false;
           }

           behaviourContext.CurrentUnit = target;

           var castPosition = behaviourContext.UnitInstance.Actor.GetPointForAttachmentPoint(AttachmentPointType.Body);
           var targetPosition = target.Actor.transform.position;

           var direction = (targetPosition - castPosition).normalized;

           var result = IsValidTarget(castPosition, direction, target);
           var endPosition = castPosition + direction * MaxRange;

           return result;
       }

       private bool IsValidTarget(Vector3 position, Vector3 direction, UnitInstance expectedTarget)
       {
           if (Physics.Raycast(position, direction, out var raycastHit, MaxRange, int.MaxValue, QueryTriggerInteraction.Ignore)) {
               var hitTarget = raycastHit.collider.GetComponent<Actor>();
               if (hitTarget == null) {
                   return false;
               }

               return hitTarget.ControllingInstance == expectedTarget;
           } else {
               return false;
           }
       }
   }
}


These are a few useful implementations of the AI FSM frakework I've build. Naturally there are lots more states implemented that these.