Goal

MageQuest needs a visual AI tool that's easy to read, work with, expand upon can be serialized and handled in Git. Time to get working!


Basic idea

The AI system will be a behavior graph system, easily visualized as a nodes in a graph with edges as flows between the states. The first step is to create the node system. I don't want to spend lots of time building the graphs so I'll start with a fork of xNode, an open source graph system for Unity.

github.com/Siccity/xNode


The graphs will consist of the following components:

States: Nodes that will make the unit do something.

Conditions: Makes the unit transition in the graph based on the state of its environment and itself.

Triggers: Ways to initialize a transition between states.

Flow nodes: Just helpers nodes for readability. Includes the StartNode, a component every behavior graph requires.


To follow a nice and clean design pattern, the design of the AI system will use a few abstract base classes as possible and allow them to share as mush code as possible and have a nice abstraction to make the graph making easier.


Framework design

Simple and elegant. All the heavy lifting of creating graphs and nodes and connecting the will be handled by xNode with the Node and NodeGraph class. The actual AI implementation is based on three classes, the NodeState class that's resposible for finding a StateBase to execute, the StateBase (which is itself inheriting from NodeState) that can be executed upon and TriggerBase that determines how something is triggered and what will happen.


Apart from these classes that is immutable once the game starts running there will be a BehaviourContext class per UnitInstance to keep track of where it is in a given behavior graph and a TriggerContext that's instanciated upon when a trigger is triggered and only last while that trigger is being evaluated.


Lets get coding!

The NodeState.cs class is purely abstract.

using System.Collections.Generic;
using UnityEngine;
using XNode;

namespace MageQuest.AI.BehaviourGraph2
{
   public abstract class NodeState: Node
   {
       public abstract void Initialize(BehaviourGraph2 behaviourGraph);
       public abstract StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext);
   }
}


TriggerBase.cs handles how a trigger can be "triggered", is resposible for finding what the next state would be and sets of the transition.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;

namespace MageQuest.AI.BehaviourGraph2
{
   [NodeTint("#ff5a1e")]
   public abstract class TriggerBase : Node
   {
       [NodeAllowedTypes(typeof(StateBase), typeof(AnyState))]
       [Input(ShowBackingValue.Never, ConnectionType.Multiple)]
       public Node[] In;

       [NodeAllowedTypes(typeof(NodeState))]
       [Output(ShowBackingValue.Never, ConnectionType.Single)]
       public NodeState Next;

       private NodeState NextState;

       public virtual bool ShouldTrigger(BehaviourContext behaviourContext, TriggerContext triggerContext) { return true; }
       public abstract void SetupTrigger(BehaviourContext behaviourContext);
       public abstract void ClearTrigger(BehaviourContext behaviourContext);

       public virtual void Initialize()
       {
           NextState = GetOutputPort(nameof(Next)).Connection?.node as NodeState;
       }

       public void Trigger(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           if (behaviourContext.UnitInstance.IsRegardedDead()) {
               return;
           }

           if(!ShouldTrigger(behaviourContext, triggerContext)){
               return;
           }

           var stateToTrigger = GetNextState(behaviourContext, triggerContext);
           if(stateToTrigger == null) {
               return;
           }

           behaviourContext.UnitInstance.LogInfo($"{behaviourContext.UnitInstance.LogId} triggered {name}");
           behaviourContext.TransitionToState(stateToTrigger, triggerContext);
       }

       public StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           return NextState?.GetNextState(behaviourContext, triggerContext);
       }
   }
}


The StartNode signals that this is the entry point of the behaviour graph. Whenever an EndNode is reached, they too will look for the StartNode and continues the execution from there.

StartNode.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;
using static XNode.Node;

namespace MageQuest.AI.BehaviourGraph2
{
   [DisallowMultipleNodes]
   [CreateNodeMenu("Flow/Start node")]
   [NodeWidth(96)]
   public class StartNode: NodeState
   {
       [NodeAllowedTypes(typeof(NodeState))]
       [Output(ShowBackingValue.Never, connectionType = ConnectionType.Single)]
       public NodeState Next;

       private NodeState NextState;

       public override void Initialize(BehaviourGraph2 behaviourGraph)
       {
           NextState = GetOutputPort(nameof(Next)).Connection?.node as NodeState;
       }

       public override StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           if (NextState != null) {
               return NextState.GetNextState(behaviourContext, triggerContext);
           } else { 
               return null;
           }
       }
   }
}


EndNode.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;
using static XNode.Node;

namespace MageQuest.AI.BehaviourGraph2
{
   [CreateNodeMenu("Flow/End Node")]
   [NodeWidth(96)]
   public class EndNode: NodeState
   {
       [NodeAllowedTypes(typeof(NodeState), typeof(TriggerBase))]
       [Input(ShowBackingValue.Never, connectionType = ConnectionType.Multiple)]
       public Node[] In;

       private NodeState StartNode;

       public override void Initialize(BehaviourGraph2 behaviourGraph)
       {
           StartNode = behaviourGraph.StartNode;
       }

       public override StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           return StartNode.GetNextState(behaviourContext, triggerContext);
       }
   }
}


ConditionBase.cs just hold a pure abstract implementation of how a condition will work.

This base class is here to be future proof.

using UnityEngine;
using XNode;

namespace MageQuest.AI.BehaviourGraph2
{
   [NodeTint("#9a1eff")]
   public abstract class ConditionBase: NodeState
   {
       [NodeAllowedTypes(typeof(NodeState), typeof(TriggerBase))]
       [Input(ShowBackingValue.Never, ConnectionType.Multiple)]
       public Node[] Input;
   }
}


For the time being all conditions will inherit from BinaryConditionBase. This will act as you general If statement in any programming language.

namespace MageQuest.AI.BehaviourGraph2
{
   public abstract class BinaryConditionBase: ConditionBase
   {
       [NodeAllowedTypes(typeof(NodeState))]
       [Output(ShowBackingValue.Never, ConnectionType.Single)]
       public NodeState True;

       [NodeAllowedTypes(typeof(NodeState))]
       [Output(ShowBackingValue.Never, ConnectionType.Single)]
       public NodeState False;

       private NodeState TrueState;
       private NodeState FalseState;

       public abstract bool IsTrue(BehaviourContext behaviourContext, TriggerContext triggerContext);

       public override void Initialize(BehaviourGraph2 behaviourGraph)
       {
           TrueState = GetOutputPort(nameof(True)).Connection?.node as NodeState;
           FalseState = GetOutputPort(nameof(False)).Connection?.node as NodeState;
       }

       public override StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           if (IsTrue(behaviourContext, triggerContext)) {
               return TrueState?.GetNextState(behaviourContext, triggerContext);
           } else {
               return FalseState?.GetNextState(behaviourContext, triggerContext);
           }
       }
   }
}


The StateBase.cs is a bit more bulky as its the class responsible for actually doing something. The main areas if concern are the callbacks

OnEnter(): what will happend when we transitions to this state.

OnExecute(): called every frame this state is being the current state. Returns true when the state considered itself to be "done"

OnExit(): what will happend when we transition away from this state.


using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;

namespace MageQuest.AI.BehaviourGraph2
{
   [NodeTint("#3fb807")]
   public abstract class StateBase: NodeState
   {
       [NodeAllowedTypes(typeof(NodeState), typeof(TriggerBase))]
       [Input(ShowBackingValue.Never)]
       public Node[] In;

       [NodeAllowedTypes(typeof(NodeState))]
       [Output(ShowBackingValue.Never, ConnectionType.Single)]
       public NodeState Next;

       [NodeAllowedTypes(typeof(TriggerBase))]
       [Output(ShowBackingValue.Never)]
       public Node[] Transitions;

       private NodeState NextState;
       private List<TriggerBase> Triggers;

       public virtual bool IsStateValid()
       {
           return true;
       }

       public virtual StateInstance CreateInstance(BehaviourContext context)
       {
           var result = new StateInstance(context, this);

           return result;
       }

       public bool Execute(StateInstance instance)
       {
           var result = OnExecute(instance);
           if (result) {
               instance.OnStateFinished.Invoke(instance.Context, TriggerContext.Empty);

               // State finished might have changed triggered a change. If is hasnt, its a restart
               if(instance.Context.CurrentState == instance) {
                   var transitionToState = NextState.GetNextState(instance.Context, TriggerContext.Empty);
                   if (transitionToState != null) {
                       instance.Context.TransitionToState(transitionToState, TriggerContext.Empty);
                   } else {
                       instance.State.OnRestart(instance);
                   }
               }
           }

           return false;
       }

       public void Enter(StateInstance instance, TriggerContext triggerContext)
       {
           foreach (var trigger in Triggers) {
               if (trigger == null) {
                   continue;
               }

               trigger.SetupTrigger(instance.Context);
           }

           OnEnter(instance, triggerContext);
       }

       public void Exit(StateInstance instance)
       {
           foreach (var trigger in Triggers) {
               if (trigger == null) {
                   continue;
               }

               trigger.ClearTrigger(instance.Context);
           }

           OnExit(instance);
       }

       public override StateBase GetNextState(BehaviourContext behaviourContext, TriggerContext triggerContext)
       {
           return this;
       }

       public override void Initialize(BehaviourGraph2 behaviourGraph)
       {
           NextState = GetOutputPort(nameof(Next)).Connection?.node as NodeState;

           var transitionsPort = GetOutputPort(nameof(Transitions));

           Triggers = new List<TriggerBase>();
           foreach(var connection in transitionsPort.GetConnections()) {
               if(connection != null) {
                   Triggers.Add(connection.node as TriggerBase);
               }
           }
       }

       protected virtual bool OnExecute(StateInstance instance) { return false; }
       protected virtual void OnEnter(StateInstance instance, TriggerContext triggerContext) { }
       protected virtual void OnExit(StateInstance instance) { }
       public virtual void OnRestart(StateInstance instance) { }
   }
}


There is also a instance associated with the execution of a state. When we transition into a state, it will create a StateInstance for it to hold current executing data.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace MageQuest.AI.BehaviourGraph2
{
   public class StateInstance
   {
       public BehaviourContext Context;
       public StateBase State;
       public Actor Actor;
       public NavMeshAgent NavMeshAgent;

       public UnitInstance Target;
       public Vector3 TargetPosition;
       public float CurrentTimer;

       public GenericUnitEvent OnStateFinished;

       public StateInstance(BehaviourContext context, StateBase state)
       {
           Context = context;
           State = state;
           Actor = context.UnitInstance.Actor;
           NavMeshAgent = Actor.NavMeshAgent;

           OnStateFinished = new GenericUnitEvent();
       }
   }
}


Conclusion

This is the end of the theory for this AI system. It cant do anything yet as every single class is abstract and cant be instantiated. It's a framework that can be built upon.