/// ---------------------------------------------
/// Shared Add-On for Behavior Designer Pro
/// Copyright (c) Opsive. All Rights Reserved.
/// https://www.opsive.com
/// ---------------------------------------------
namespace Opsive.BehaviorDesigner.AddOns.Shared.Runtime.Tasks
{
using Opsive.BehaviorDesigner.AddOns.Shared.Runtime.Pathfinding;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using Opsive.GraphDesigner.Runtime.Variables;
using Opsive.Shared.Utility;
using Unity.Entities;
using UnityEngine;
///
/// Base class for all Formation Pack tasks.
///
[Category("Formation Pack")]
public abstract class FormationsBase : Action
{
#if UNITY_EDITOR
private const string c_PathfinderTypeKey = "Opsive.BehaviorDesigner.AddOns.PathfinderType";
public static string PathfinderTypeKey => c_PathfinderTypeKey;
#endif
///
/// Specifies the orientation of the group.
///
public enum OrientationDirection
{
TransformDirection, // Use the leader's forward direction.
MovementDirection, // Use the direction from the target position to the Transform position.
Specified // Use the specified vector.
}
[Tooltip("The pathfinding implementation for this agent.")]
[SerializeField] [HideInInspector] protected Pathfinder m_Pathfinder;
[Tooltip("Is this formation being used in a 2D space?")]
[SerializeField] protected bool m_Is2D = false;
[Tooltip("The ID of the formation group this task will belong to.")]
[SerializeField] protected SharedVariable m_FormationGroupID;
[Tooltip("Should the agent be forced as the leader?")]
[SerializeField] protected SharedVariable m_ForceLeader;
[Tooltip("Specifies the orientation of the group.")]
[SerializeField] protected SharedVariable m_FormationDirection;
[Tooltip("The forward direction the formation should face (if not specified, will face the movement direction)")]
[SerializeField] protected SharedVariable m_SpecifiedDirection = Vector3.forward;
[Tooltip("Specifies if the agents should first move into the formation before moving to the target.")]
[SerializeField] protected SharedVariable m_MoveToInitialFormation = true;
[Tooltip("The rotation speed in degrees per second.")]
[SerializeField] protected SharedVariable m_RotationSpeed = 180f;
[Tooltip("The angle threshold in degrees at which the agent will stop rotating towards the target direction.")]
[SerializeField] protected SharedVariable m_RotationThreshold = 1f;
[Tooltip("The distance difference between the agent and the leader when the agent is considered out of range. This value must be larger than the InRnageDistanceDelta.")]
[SerializeField] protected SharedVariable m_OutOfRangeDistanceDelta = 0.6f;
[Tooltip("The distance difference between the agent and the leader when the agent is considered back in range. This value must be smaller than the OutOfRangeDistanceDelta.")]
[SerializeField] protected SharedVariable m_InRangeDistanceDelta = 0.4f;
[Tooltip("The speed multiplier when the agent is out of range.")]
[SerializeField] protected SharedVariable m_OutOfRangeSpeedMultiplier = 0.5f;
[Tooltip("The amount of time that must elapse with a zero velocity before the agent is considered stuck.")]
[SerializeField] protected SharedVariable m_StuckDuration = 0.2f;
[Tooltip("Should the task fail if any agent leaves the formation?")]
[SerializeField] protected SharedVariable m_FailOnAgentRemoval;
[Tooltip("Should the formation unit locations be updated if an agent leaves?")]
[SerializeField] protected SharedVariable m_UpdateUnitLocationsOnAgentRemoval;
[Tooltip("Should the pathfinder be stopped when the task ends?")]
[SerializeField] protected SharedVariable m_StopOnTaskEnd = true;
public Pathfinder Pathfinder { get => m_Pathfinder; set => m_Pathfinder = value; }
public bool Is2D => m_Is2D;
public bool ForceLeader => m_ForceLeader.Value;
public bool UpdateUnitLocationsOnAgentRemoval => m_UpdateUnitLocationsOnAgentRemoval.Value;
public float OutOfRangeSpeedMultiplier => m_OutOfRangeSpeedMultiplier.Value;
public bool FailOnAgentRemoval => m_FailOnAgentRemoval.Value;
public OrientationDirection FormationDirection => m_FormationDirection.Value;
public Transform Transform { get => transform; }
protected int m_FormationIndex = -1;
protected FormationsManager.FormationGroup m_Group;
private int m_GroupID = -1;
private float m_OriginalSpeed;
private Vector3 m_DesiredPosition;
private bool m_OutOfRange;
private bool m_IsStuck;
private float m_StuckTime = -1;
///
/// Should the optimal indicies be assigned? This should be set to false for random formations.
///
public virtual bool AssignOptimialIndicies => true;
///
/// Can the agents move into their initial formation?
///
public virtual bool CanMoveIntoInitialFormation => true;
///
/// Should the agents be checked to determine if they are out of range while moving?
///
public virtual bool DetermineOutOfRange => true;
///
/// Should the task determine if any agents are stuck?
///
protected virtual bool DetermineIfStuck => true;
///
/// Returns the position the formation should move towards.
///
public abstract Vector3 TargetPosition { get; }
public int FormationIndex { get => m_FormationIndex; }
public FormationsManager.FormationGroup Group { set { m_Group = value; } }
public float OriginalSpeed { get => m_OriginalSpeed; }
public Vector3 DesiredPosition { get => m_DesiredPosition; set => m_DesiredPosition = value; }
public bool OutOfRange { get => m_OutOfRange; set => m_OutOfRange = value; }
public bool IsStuck { get => m_IsStuck; }
///
/// Calculate the position for this agent in the formation.
///
/// The index of this agent in the formation.
/// The total number of agents in the formation.
/// The center position of the formation.
/// The forward direction of the formation.
/// Should the position be sampled?
/// The position for this agent.
public abstract Vector3 CalculateFormationPosition(int index, int totalAgents, Vector3 center, Vector3 forward, bool samplePosition);
///
/// The task has been initialized.
///
public override void OnAwake()
{
base.OnAwake();
if (m_Pathfinder == null) {
m_Pathfinder = new NavMeshAgentPathfinder();
m_Pathfinder.Reset();
}
m_Pathfinder.Initialize(gameObject);
m_FormationGroupID.OnValueChange += FormationGroupIDChanged;
m_OriginalSpeed = m_Pathfinder.Speed;
}
///
/// Starts the task.
///
public override void OnStart()
{
// Add this task to the formation group. The index will be set after all of the agents have been added to the group.
FormationsManager.AddTaskToGroup(m_FormationGroupID.Value, this);
m_GroupID = m_FormationGroupID.Value;
m_Group = FormationsManager.GetFormationGroup(m_GroupID);
m_Pathfinder.OnStart();
m_IsStuck = false;
m_StuckTime = -1;
}
///
/// Move the formation to the target position.
///
/// The status of the task.
public override TaskStatus OnUpdate()
{
if (m_Group == null) {
return TaskStatus.Failure;
}
if (m_Group.State == FormationsManager.FormationState.Arrived) {
return TaskStatus.Success;
} else if (m_Group.State == FormationsManager.FormationState.Failure) {
return TaskStatus.Failure;
}
// Agents should rotate towards the target direction after they have arrived in their initial positions.
if (m_Group.State == FormationsManager.FormationState.MoveToFormation && HasArrived()) {
RotateTowardsTarget();
}
// The leader manages the state of all of its members.
if (m_Group.Leader == this) {
ManageFormation();
}
// The agent may be stuck if they haven't moved in awhile.
if (DetermineIfStuck && !m_Pathfinder.HasArrived() && m_Pathfinder.Velocity.sqrMagnitude < 0.1f) {
if (m_StuckTime == -1) {
m_StuckTime = Time.time;
} else if (m_StuckTime + m_StuckDuration.Value < Time.time) {
m_IsStuck = true;
}
} else if (m_StuckTime != -1) {
m_StuckTime = -1;
m_IsStuck = false;
}
return TaskStatus.Running;
}
///
/// Rotate towards the target direction.
///
protected void RotateTowardsTarget()
{
// Calculate the amount that the agent needs to rotate.
var forward = Vector3.ProjectOnPlane(m_Transform.forward, m_Transform.up).normalized;
var targetDirection = Vector3.ProjectOnPlane(m_Group.Direction, m_Transform.up).normalized;
var angle = Vector3.SignedAngle(forward, targetDirection, transform.up);
// Stop rotating when facing the correct direction.
if (Mathf.Abs(angle) < m_RotationThreshold.Value) {
return;
}
// The agent still needs to rotate.
var rotation = Quaternion.AngleAxis(Mathf.Sign(angle) * m_RotationSpeed.Value * Time.deltaTime, m_Transform.up);
m_Transform.rotation = rotation * m_Transform.rotation;
}
///
/// Manages the pathfinding and state of the formation for all members. This should only be run by the formation Leader.
///
private void ManageFormation()
{
if (m_Group.State == FormationsManager.FormationState.Initialized) {
if (m_Group.StartTime + FormationsManager.StartDelay < Time.time) {
m_Group.State = FormationsManager.FormationState.MoveToFormation;
// Move all agents to their initial formation location.
m_Group.TargetPosition = m_Transform.position;
if (m_FormationDirection.Value == OrientationDirection.TransformDirection) {
m_Group.Direction = m_Transform.forward;
} else if (m_FormationDirection.Value == OrientationDirection.MovementDirection) {
m_Group.Direction = (TargetPosition - m_Transform.position).normalized;
} else { // Use the specified vector.
m_Group.Direction = m_SpecifiedDirection.Value.normalized;
}
FormationsManager.AssignIndices(m_FormationGroupID.Value);
}
} else if (m_Group.State == FormationsManager.FormationState.MoveToFormation) {
var arrived = true;
if (CanMoveIntoInitialFormation && m_MoveToInitialFormation.Value) {
for (int i = 0; i < m_Group.Members.Count; ++i) {
if (!m_Group.Members[i].HasArrived()) {
arrived = false;
if (m_Group.Members[i].IsStuck) {
m_Group.State = FormationsManager.FormationState.Failure;
return;
}
break;
}
}
}
if (arrived && (!CanMoveIntoInitialFormation || !m_MoveToInitialFormation.Value || HaveAllAgentsRotated())) {
m_Group.State = FormationsManager.FormationState.MoveToTarget;
m_Group.TargetPosition = TargetPosition;
for (int i = 0; i < m_Group.Members.Count; ++i) {
m_Group.Members[i].DesiredPosition = m_Group.Members[i].UpdateFormationDestination();
}
}
} else if (m_Group.State == FormationsManager.FormationState.MoveToTarget) {
var arrived = true;
var dirty = false;
var groupOutOfRange = false;
for (int i = 0; i < m_Group.Members.Count; ++i) {
if (!m_Group.Members[i].HasArrived()) {
arrived = false;
if (m_Group.Members[i].IsStuck) {
m_Group.State = FormationsManager.FormationState.Failure;
return;
}
if (DetermineOutOfRange) {
var wasOutOfRange = m_Group.Members[i].OutOfRange;
m_Group.Members[i].OutOfRange = m_Group.Members[i].IsOutOfRange();
dirty = dirty || (wasOutOfRange != m_Group.Members[i].OutOfRange);
if (m_Group.Members[i].OutOfRange) {
if (m_Group.Members[i].OutOfRangeSpeedMultiplier > 1) {
// If the multiplier is greater than 1 then only the current agent needs to speed up.
m_Group.Members[i].Pathfinder.Speed = m_Group.Members[i].OriginalSpeed * m_Group.Members[i].OutOfRangeSpeedMultiplier;
} else {
// If the multiplier is less than 1 then all of the other agents need to slow down so the current agent can catch up.
groupOutOfRange = true;
}
} else if (wasOutOfRange && m_Group.Members[i].OutOfRangeSpeedMultiplier > 1) {
m_Group.Members[i].Pathfinder.Speed = m_Group.Members[i].OriginalSpeed;
}
}
}
}
if (arrived) {
m_Group.State = FormationsManager.FormationState.Arrived;
} else if (dirty) {
for (int i = 0; i < m_Group.Members.Count; ++i) {
if (!groupOutOfRange || m_Group.Members[i].OutOfRange) {
m_Group.Members[i].Pathfinder.Speed = m_Group.Members[i].OriginalSpeed;
} else {
m_Group.Members[i].Pathfinder.Speed = m_Group.Members[i].OriginalSpeed * m_Group.Members[i].OutOfRangeSpeedMultiplier;
}
}
}
}
}
///
/// Set new pathfinding destination for this agent in the formation.
///
/// Optionally sets the desired position if it has already been computed.
/// True if the destination is valid.
public Vector3 UpdateFormationDestination(Vector3? desiredPosition = null)
{
if (m_FormationIndex == -1) {
return Vector3.zero;
}
// Calculate this agent's position in the formation and move towards it.
var position = desiredPosition.HasValue ? desiredPosition.Value : CalculateFormationPosition(m_FormationIndex, m_Group.Members.Count, m_Group.TargetPosition, m_Group.Direction, m_Group.State == FormationsManager.FormationState.MoveToTarget);
m_Pathfinder.SetDesination(position);
return position;
}
///
/// Check if all agents have rotated to face the target direction.
///
/// True if all agents are facing the target direction.
private bool HaveAllAgentsRotated()
{
var targetDirection = Vector3.ProjectOnPlane(m_Group.Direction, m_Transform.up).normalized;
for (int i = 0; i < m_Group.Members.Count; ++i) {
var forward = Vector3.ProjectOnPlane(m_Group.Members[i].Transform.forward, m_Group.Members[i].Transform.up).normalized;
if (Vector3.Angle(forward, targetDirection) > m_RotationThreshold.Value) {
return false;
}
}
return true;
}
///
/// Is the agent out of range and is falling behind the group?
///
/// True if the agent is out of range.
public bool IsOutOfRange()
{
if (m_FormationIndex == -1 || m_Group.Members.Count <= 1) {
return false;
}
// Use the desired position to determine if the agent is falling behind the group. If the agent is the leader then compare against the first follower.
var distance = (m_DesiredPosition - m_Transform.position).magnitude;
var comparisonIndex = m_Group.Leader == this ? 1 : 0;
var comparisonDistance = (m_Group.Members[comparisonIndex].DesiredPosition - m_Group.Members[comparisonIndex].transform.position).magnitude;
if (distance > comparisonDistance + (m_OutOfRange ? m_InRangeDistanceDelta.Value : m_OutOfRangeDistanceDelta.Value)) {
return true;
}
return false;
}
///
/// Update the formation index for this task.
///
/// The new index in the formation.
/// Optionally sets the desired position if it has already been computed.
public virtual void UpdateFormationIndex(int index, Vector3? desiredPosition = null)
{
m_FormationIndex = index;
if (m_Group.State == FormationsManager.FormationState.MoveToFormation || m_Group.State == FormationsManager.FormationState.MoveToTarget) {
m_DesiredPosition = UpdateFormationDestination(desiredPosition);
}
}
///
/// Has this agent arrived at its formation position?
///
/// True if the agent has arrived at its position.
public bool HasArrived()
{
return m_Pathfinder.HasArrived();
}
///
/// Sample a position to ensure it's on the NavMesh.
///
/// The position to sample and modify if necessary.
/// True if a valid position was found.
protected bool SamplePosition(ref Vector3 position)
{
return m_Pathfinder.SamplePosition(ref position);
}
///
/// The task has ended.
///
public override void OnEnd()
{
m_Pathfinder.Speed = m_OriginalSpeed;
if (m_StopOnTaskEnd.Value) {
m_Pathfinder.Stop();
}
// Remove the task from the formation.
FormationsManager.RemoveTaskFromGroup(m_FormationGroupID.Value, this);
m_FormationIndex = m_GroupID = -1;
m_Group = null;
m_Pathfinder.OnEnd();
}
///
/// The FormationGroupID SharedVariable has updated.
///
private void FormationGroupIDChanged()
{
// Store the old group's state before adding the agent to a new group.
var state = FormationsManager.FormationState.Initialized;
if (m_FormationIndex != -1 && m_Group != null) {
state = m_Group.State;
}
m_GroupID = m_FormationGroupID.Value;
// Add to new group. AddTaskToGroup will remove the agent from the old group.
m_FormationIndex = -1;
FormationsManager.AddTaskToGroup(m_FormationGroupID.Value, this);
m_Group = FormationsManager.GetFormationGroup(m_FormationGroupID.Value);
if (m_Group != null) {
if (m_Group.Leader == this && m_Group.State == FormationsManager.FormationState.Initialized && state != FormationsManager.FormationState.Initialized) {
m_Group.State = state;
// Ensure direction and indices are set.
if (m_FormationDirection.Value == OrientationDirection.TransformDirection) {
m_Group.Direction = m_Transform.forward;
} else if (m_FormationDirection.Value == OrientationDirection.MovementDirection) {
m_Group.Direction = (TargetPosition - m_Transform.position).normalized;
} else { // Use the specified vector.
m_Group.Direction = m_SpecifiedDirection.Value.normalized;
}
}
// If the group is already initialized then the task should be added at the last index.
if (m_Group.State != FormationsManager.FormationState.Initialized) {
m_Group.Members[m_Group.Members.Count - 1].UpdateFormationIndex(m_Group.Members.Count - 1);
}
}
}
///
/// Data structure for saving formation state.
///
protected class FormationSaveData
{
[Tooltip("The index of this agent in the formation.")]
public int FormationIndex;
[Tooltip("The desired position for this agent in the formation.")]
public Vector3 DesiredPosition;
[Tooltip("Indicates if this agent is out of range from the formation.")]
public bool OutOfRange;
[Tooltip("Indicates if this agent is stuck and unable to move.")]
public bool IsStuck;
[Tooltip("The time at which the agent became stuck.")]
public float StuckTime;
[Tooltip("The original movement speed of the agent before any modifications.")]
public float OriginalSpeed;
[Tooltip("The current destination that the agent is moving towards.")]
public Vector3 Destination;
[Tooltip("Indicates if the agent has a valid destination to move towards.")]
public bool HasDestination;
[Tooltip("The saved state of the formation group if this agent is the leader.")]
public FormationGroupSaveData GroupState;
}
///
/// Data structure for saving formation group state.
///
protected class FormationGroupSaveData
{
[Tooltip("The current state of the formation (Initialized, MoveToFormation, MoveToTarget, etc.).")]
public FormationsManager.FormationState State;
[Tooltip("The target position of the formation.")]
public Vector3 TargetPosition;
[Tooltip("The forward direction that the formation is facing.")]
public Vector3 Direction;
[Tooltip("The elapsed time since the formation started.")]
public float ElapsedTime;
[Tooltip("The formation indices of all members in the group.")]
public int[] MemberIndices;
}
///
/// Specifies the type of reflection that should be used to save the task.
///
/// The index of the sub-task. This is used for the task set allowing each contained task to have their own save type.
public override MemberVisibility GetSaveReflectionType(int index) { return MemberVisibility.None; }
///
/// Returns the current task state.
///
/// The DOTS world.
/// The DOTS entity.
/// The current task state.
public override object Save(World world, Entity entity)
{
if (!m_BehaviorTree.IsNodeActive(true, m_RuntimeIndex)) return null;
var saveData = new FormationSaveData();
saveData.FormationIndex = m_FormationIndex;
saveData.DesiredPosition = m_DesiredPosition;
saveData.OutOfRange = m_OutOfRange;
saveData.IsStuck = m_IsStuck;
saveData.StuckTime = m_StuckTime;
saveData.OriginalSpeed = m_OriginalSpeed;
// Only save the pathfinder destination if we have a path and haven't arrived
if (m_Pathfinder.HasPath() && !m_Pathfinder.HasArrived()) {
saveData.Destination = m_Pathfinder.Destination;
saveData.HasDestination = true;
}
// If this agent is the leader, save the group state
var group = FormationsManager.GetFormationGroup(m_FormationGroupID.Value);
if (group != null && group.Leader == this) {
saveData.GroupState = new FormationGroupSaveData();
saveData.GroupState.State = group.State;
saveData.GroupState.TargetPosition = group.TargetPosition;
saveData.GroupState.Direction = group.Direction;
saveData.GroupState.ElapsedTime = Time.time - group.StartTime;
saveData.GroupState.MemberIndices = new int[group.Members.Count];
for (int i = 0; i < group.Members.Count; ++i) {
saveData.GroupState.MemberIndices[i] = group.Members[i].FormationIndex;
}
}
return saveData;
}
///
/// Loads the previous task state.
///
/// The previous task state.
/// The DOTS world.
/// The DOTS entity.
public override void Load(object saveData, World world, Entity entity)
{
if (saveData == null) {
return;
}
var formationSaveData = (FormationSaveData)saveData;
m_FormationIndex = formationSaveData.FormationIndex;
m_DesiredPosition = formationSaveData.DesiredPosition;
m_OutOfRange = formationSaveData.OutOfRange;
m_IsStuck = formationSaveData.IsStuck;
m_StuckTime = formationSaveData.StuckTime;
m_OriginalSpeed = formationSaveData.OriginalSpeed;
if (formationSaveData.HasDestination) {
m_Pathfinder.SetDesination(formationSaveData.Destination);
}
// Restore the group state if the agent is a leader.
if (formationSaveData.GroupState != null) {
var group = FormationsManager.GetFormationGroup(m_FormationGroupID.Value);
if (group != null && group.Leader == this) {
group.State = formationSaveData.GroupState.State;
group.TargetPosition = formationSaveData.GroupState.TargetPosition;
group.Direction = formationSaveData.GroupState.Direction;
group.StartTime = Time.time - formationSaveData.GroupState.ElapsedTime;
}
}
}
///
/// The behavior tree has been stopped.
///
/// Was the behavior tree paused.
public override void OnBehaviorTreeStopped(bool paused)
{
base.OnBehaviorTreeStopped(paused);
if (m_Pathfinder.HasPath()) {
m_Pathfinder.Stop();
}
}
///
/// The behavior tree has been destroyed.
///
public override void OnDestroy()
{
m_FormationGroupID.OnValueChange -= FormationGroupIDChanged;
if (m_Pathfinder != null) {
m_Pathfinder.Stop();
}
// Remove this task from the formation.
FormationsManager.RemoveTaskFromGroup(m_GroupID, this);
m_FormationIndex = m_GroupID = -1;
m_Group = null;
}
///
/// Resets the task values back to their default.
///
public override void Reset()
{
#if UNITY_EDITOR
if (m_Pathfinder == null) {
var defaultPathfinderType = UnityEditor.EditorPrefs.GetString(c_PathfinderTypeKey);
if (!string.IsNullOrEmpty(defaultPathfinderType)) {
var pathfinderType = TypeUtility.GetType(defaultPathfinderType);
if (pathfinderType != null) {
m_Pathfinder = System.Activator.CreateInstance(pathfinderType) as Pathfinder;
}
}
// The NavMeshAgentPathfinder will always be available.
if (m_Pathfinder == null) {
m_Pathfinder = new NavMeshAgentPathfinder();
}
}
#endif
m_Pathfinder.Reset();
m_Is2D = false;
m_FormationGroupID = 0;
m_ForceLeader = false;
m_FormationDirection = OrientationDirection.TransformDirection;
m_SpecifiedDirection = Vector3.forward;
m_RotationSpeed = 180f;
m_RotationThreshold = 1f;
m_OutOfRangeDistanceDelta = 0.6f;
m_InRangeDistanceDelta = 0.4f;
m_OutOfRangeSpeedMultiplier = 0.5f;
m_StuckDuration = 0.2f;
m_FailOnAgentRemoval = false;
m_UpdateUnitLocationsOnAgentRemoval = false;
m_StopOnTaskEnd = true;
}
}
}