GameObject Task

GameObject-based tasks are the recommended route for creating new tasks unless you have a large number of behavior trees or a particular task is computationally heavy and is causing a bottleneck. GameObject-based tasks have a familiar API as Unity’s MonoBehaviour component:

/// <summary>
/// Callback when the behavior tree is initialized.
/// </summary>
public virtual void OnAwake()

/// <summary>
/// Callback when the behavior tree is enabled.
/// </summary>
public virtual void OnEnable()

/// <summary>
/// Callback when the task is started.
/// </summary>
public virtual void OnStart()

/// <summary>
/// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
/// </summary>
/// <returns>The status of the task.</returns>
public virtual TaskStatus OnUpdate()

/// <summary>
/// Callback when the task stops.
/// </summary>
public virtual void OnEnd()

/// <summary>
/// Callback when the behavior tree is disabled.
/// </summary>
public virtual void OnDisable()

/// <summary>
/// Callback when the behavior tree is destroyed.
/// </summary>
public virtual void OnDestroy()

Conditional tasks can also implement OnReevaluateUpdate allow for a different flow when conditional aborts are updating the task:

/// <summary> 
/// Reevaluates the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed. 
/// </summary> 
/// <returns>The status of the task during the reevaluation phase.</returns> 
public virtual TaskStatus OnReevaluateUpdate()

If your task should receive a physics callback it should override the corresponding receive property:

ReceiveCollisionEnterCallback
ReceiveCollisionExitCallback
ReceiveCollisionEnter2DCallback
ReceiveCollisionExit2DCallback
ReceiveTriggerEnterCallback
ReceiveTriggerExitCallback
ReceiveTriggerEnter2DCallback
ReceiveTriggerExit2DCallback
ReceiveControllerColliderHitCallback
ReceiveDrawGizmosCallback
ReceiveDrawGizmosSelectedCallback

After your task has implemented the callback it can then receive the corresponding callback. This is done for efficiency reasons. An example of a conditional task returning success after a certain object has entered the trigger is below. Behavior Designer also contains a save/load system and each task can specify which objects should persist. The API is below but see the Save/Load page for more details.

/// <summary>
/// Specifies the type of reflection that should be used to save the task.
/// </summary>
/// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
public virtual MemberVisibility GetSaveReflectionType(int index)

/// <summary>
/// Returns the current task state.
/// </summary>
/// <param name="world">The DOTS world.</param>
/// <param name="entity">The DOTS entity.</param>
/// <returns>The current task state.</returns>
public virtual object Save(World world, Entity entity)

/// <summary>
/// Loads the previous task state.
/// </summary>
/// <param name="saveData">The previous task state.</param>
/// <param name="world">The DOTS world.</param>
/// <param name="entity">The DOTS entity.</param>
public virtual void Load(object saveData, World world, Entity entity)

Action Task

Your action task can implement the Action or ActionNode base class. If you implement Action then the task will be stacked with other action tasks. Alternatively, you can implement ActionNode which will not be stacked. In general you should implement Action unless there is a special use case for having the action separate from everything else (such as the Subtree Reference task).

Lets now implement an action task that moves the agent towards a random Vector3. This random vector should be within a specified radius of a center point. The agent should then move at a specified speed. For the variables we are going to use Shared Variables allowing the values to be shared across tasks. This task will also implement Save and Load allowing the random value to be saved.

To get started create a new script within your project. Behavior Designer uses assembly definitions so ensure you are referencing the BehaviorDesigner.Runtime assembly. Within your script we will start by implementing OnStart which chooses a random destination:

using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable<Vector3> m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable<float> m_Radius = 10;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }
}

This block will set a new random destination when the task start. It uses a Vector3 SharedVariable and float SharedVariable in order to determine where the random position should be located. The Opsive.GraphDesigner.Runtime.Variables namespace contains the SharedVariable system, and Opsive.BehaviorDesigner.Runtime.Tasks.Actions contains the parent Action class. Now that we have determined a random destination we should start to move the agent towards that destination:

using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;

public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable m_Radius = 10;
    [Tooltip("The speed that the agent should move towards the destination.")]
    public SharedVariable m_MoveSpeed = 5;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }

    /// <summary>
    /// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task.</returns>
    public override TaskStatus OnUpdate()
    {
        // The agent has arrived when they get close to the destination. 
        if (Vector3.Distance(transform.position, m_Destination) < 0.5f) { 
            return TaskStatus.Success;
        }

        // The agent hasn't arrived yet. Keep moving towards the destination and return a running status.
        transform.position = Vector3.MoveTowards(transform.position, m_Destination, m_MoveSpeed.Value * Time.deltaTime); return TaskStatus.Running;
        return TaskStatus.Running;
    }
}

With this revision we added a new namespace, the m_MoveSpeed variable, and the OnUpdate method. Every tick OnUpdate will be called and the method returns how the behavior tree responds to the task. As soon as the agent reaches the destination the task will return a TaskStatus.Success value, and TaskStatus.Running will be returned while the agent is moving towards the target. With this change the task functionality is now complete – a random position will be selected and the agent will move towards that destination. As soon as the agent has arrived the task will end.

Lets spruce the task up a bit. Within Behavior Designer when you click on a node you will see a task description on the bottom right. This description is added with the [NodeDescription] attribute. In addition, a unique icon can be specified with the [NodeIcon] attribute. Lets add those two attributes to the class definition:

using Opsive.GraphDesigner.Runtime;

[NodeIcon("Assets/MyIcon.png")]
[NodeDescription("Moves the agent towards a random position within the specified radius.")]
public class MoveTowardsRandomPoint : Action

If your behavior tree does not need saving and loading then this action task is complete. However, if you do plan on being able to save the behavior tree we have a few more methods that we need to implement:

    /// <summary>
    /// Specifies the type of reflection that should be used to save the task.
    /// </summary>
    /// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
    public override MemberVisibility GetSaveReflectionType(int index)
    {
        // Do not use reflection to save. This task will implement the Save and Load methods.
        return MemberVisibility.None;
    }

    /// <summary>
    /// Returns the current task state.
    /// </summary>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    /// <returns>The current task state.</returns>
    public override object Save(World world, Entity entity)
    {
        // Only save the destination.
        return m_Destination;
    }

    /// <summary>
    /// Loads the previous task state.
    /// </summary>
    /// <param name="saveData">The previous task state.</param>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    public override void Load(object saveData, World world, Entity entity)
    {
        // The saveData will only contain the objects specified by the Save method.
        m_Destination = (Vector3)saveData;
    }

The Unity.Entities namespace must be added in order for this code to compile. GetSaveReflectionType specifies how the variables should be saved using reflection:

  • MemberVisibility.All: Public and private variables will be saved with reflection.
  • MemberVisiblity.Public: Only public and serialized private variables will be saved with reflection.
  • MemberVisiblity.None: No variables will be saved with reflection. If this value is specified then the Save and Load methods need to be implemented.

Since MemberVisiblity.None was specified we need to implement the Save and Load methods. The Save method simply returns the value that we want to save (in this case the random destination), and the Load method will restore that value. The task is now complete. The entire task looks like:

using Opsive.Shared.Utility;
using Opsive.GraphDesigner.Runtime;
using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
using Unity.Entities;

[NodeIcon("Assets/MyIcon.png")]
[NodeDescription("Moves the agent towards a random position within the specified radius.")]
public class MoveTowardsRandomPoint : Action
{
    [Tooltip("The center point of the random position.")]
    public SharedVariable m_Center;
    [Tooltip("The radius that contains the random position.")]
    public SharedVariable m_Radius = 10;
    [Tooltip("The speed that the agent should move towards the destination.")]
    public SharedVariable m_MoveSpeed = 5;

    private Vector3 m_Destination;

    /// <summary>
    /// Callback when the task is started.
    /// </summary>
    public override void OnStart()
    {
        m_Destination = m_Center.Value + Random.insideUnitSphere * Random.Range(0, m_Radius.Value);
    }

    /// <summary>
    /// Executes the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task.</returns>
    public override TaskStatus OnUpdate()
    {
        // The agent has arrived when they get close to the destination. 
        if (Vector3.Distance(transform.position, m_Destination) < 0.5f) { 
            return TaskStatus.Success;
        }

        // The agent hasn't arrived yet. Keep moving towards the destination and return a running status.
        transform.position = Vector3.MoveTowards(transform.position, m_Destination, m_MoveSpeed.Value * Time.deltaTime); return TaskStatus.Running;
        return TaskStatus.Running;
    }

    /// <summary>
    /// Specifies the type of reflection that should be used to save the task.
    /// </summary>
    /// <param name="index">The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.</param>
    public override MemberVisibility GetSaveReflectionType(int index)
    {
        // Do not use reflection to save. This task will implement the Save and Load methods.
        return MemberVisibility.None;
    }

    /// <summary>
    /// Returns the current task state.
    /// </summary>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    /// <returns>The current task state.</returns>
    public override object Save(World world, Entity entity)
    {
        // Only save the destination.
        return m_Destination;
    }

    /// <summary>
    /// Loads the previous task state.
    /// </summary>
    /// <param name="saveData">The previous task state.</param>
    /// <param name="world">The DOTS world.</param>
    /// <param name="entity">The DOTS entity.</param>
    public override void Load(object saveData, World world, Entity entity)
    {
        // The saveData will only contain the objects specified by the Save method.
        m_Destination = (Vector3)saveData;
    }
}

Conditional Task

The conditional task API is the same as the Action task except there is one extra method that can be implemented for conditional aborts. Similar to Action tasks, Conditional tasks can implement the Conditional class or the ConditionalNode class. Conditional tasks can be stacked, whereas ConditionalNode classes cannot be stacked. For this example we are going to implement a conditional task that returns true as soon as the agent has entered a trigger.

public class HasEnteredTrigger : Conditional
{
    [Tooltip("The tag of the GameObject that the trigger should be checked against.")]
    [SerializeField] protected SharedVariable<string> m_Tag;
    
    protected override bool ReceiveTriggerEnterCallback => true;

    private bool m_EnteredTrigger;

    /// <summary>
    /// Returns true when the agent has entered a trigger.
    /// </summary>
    /// <returns>True when the agent has entered a trigger.</returns>
    public override TaskStatus OnUpdate()
    {
        return m_EnteredTrigger ? TaskStatus.Success : TaskStatus.Failure;
    }

    /// <summary>
    /// The agent has entered a trigger.
    /// </summary>
    /// <param name="other">The trigger that the agent entered.</param>
    protected override void OnTriggerEnter(Collider other)
    {
        if (!string.IsNullOrEmpty(m_Tag.Value) && !other.gameObject.CompareTag(m_Tag.Value)) {
            return;
        }
        m_EnteredTrigger = true;
    }
}

A lot of the same concepts from the action task applies to conditional tasks. Compared to the action task from above this conditional task:

  • Implements the Conditional base class.
  • Overrides the ReceivedTriggerEnterCallback property.
  • Implements OnTriggerEnter. If ReceivedTriggerEnterCallback was not overridden with a true status then this method would not be called.

Conditional tasks are special in that they can be reevaluated with conditional aborts. By default conditional aborts will call the OnUpdate method, but you can also implement a separate callback that has logic specific to the reevaluation:

    /// <summary>
    /// Reevaluates the task logic. Returns a TaskStatus indicating how the behavior tree flow should proceed.
    /// </summary>
    /// <returns>The status of the task during the reevaluation phase.</returns>
    public override TaskStatus OnReevaluateUpdate()
    {
        return (m_EnteredTrigger && string.Equals(m_Tag.Value, "BlueTeam")) ? TaskStatus.Success : TaskStatus.Failure;
    }

This is a contrived example but it illustrates the point well that the reevaluation update can be different from the regular update. In this example in order for the conditional abort to trigger the agent must enter the trigger and the tag must match the “BlueTeam” tag. In most cases you will not need to implement a separate OnReevaluateUpdate callback.

Composite & Decorator Tasks

The composite and decorator tasks are very similar. Composite tasks should implement the CompositeNode base class, and decorator tasks should implement DecoratorNode. Composite and decorator tasks cannot be stacked. Composite tasks can implement two extra properties:

/// <summary>
/// The maximum number of child tasks that can be parented to the current task.
/// </summary>
public virtual int MaxChildCount { get => int.MaxValue; }

/// <summary>
/// Returns the index of the next active task index.
/// </summary>
public virtual ushort NextChildIndex { get => (ushort)(Index + 1); }

The MaxChildCount property will be checked during edit time when tasks are being added to the tree. NextChildIndex is called at runtime in order to determine the next task that should start. This property is only called if the task has a status of running. If the task is not running then the child is not running. Decorators implement these two properties but their values are restricted because decorators can only have a single child. Therefore you do not need to implement these methods for a decorator task.