Waypoint System Editor

The requirements of our Waypoint System Editor assignment at Futuregames:

  • In the scene view each point should have a position handle that a designer can drag around.
  • The modifications of the positions should be undoable and redoable.
  • An entity should use the waypoint system.
  • Add a position by clicking between two positions in the scene view.
  • Custom inspector where you can add positions between positions.

 

My personal additions:

  • Reorderable list
  • Highlighting handles when selected in the inspector
  • Highlighting handles when clicked in the scene view
  • Highlighting waypoint elements in the inspector when handles are clicked in the scene view
  • Shifting camera focus to the waypoint when selected in the inspector
  • Able to select adding waypoints before the first waypoint or after the last waypoint (controlled by bool)
  • Also made the requirement to add waypoints between other waypoints a bool
  • Able to get semi-accurate waypoint positions even without a terrain to place them on (could be used for in-air units)
  • The moving entity can either follow a circular movement pattern, or a forth-and-back movement pattern

(Note that the movement specific additions also require an accompanying AI behavior script to function)

Off-terrain Usage

This waypoint system editor works best with terrain, however I also put a lot of effort into making it work even without a terrain. For those of you working in 3D environments, you can probably imagine how that could cause issues when trying to place waypoints “where you click” in the scene, considering “where you click” is completely relative. In addition, if each waypoint should be placed at similar dimensions, where the user feels they should go, things can get a bit tricky. At least for a student making her first tool!

As you can see in this video, the system is not perfect in that the positions have somewhat of an offset from where you’d ideally want the waypoints to be placed but they are semi-accurate and can easily be dragged into the desired position, or have their position entered manually through the editor.

With this functionality, having your units move through the air or other non-terrain areas should hopefully be achievable a little bit easier.

Code and Mentions

First of all, I’d like to give a shout out to Dr. Penny de Byl for her amazing udemy course “The Beginner’s Guide to Artificial Intelligence in Unity”. This introduced me to waypoints before we even got the school assignment and piqued my interest for waypoints in unity. My AI unit movement scripts are based off of her course, even if I of course alter them as I go along.

Second, awesome programmer Freya Holmér helped in figuring out how to be able to place the waypoints in the scene view in a 3D no terrain setting.

Further, my husband was the most excellent rubber duck in bouncing ideas and theories off of and helped me apply some of the math Freya taught us during her lectures.

      
      	using System.Collections.Generic;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

[CustomEditor(typeof(WaypointManager))]
public class WaypointManagerEditor : Editor
{
	WaypointManager wpManager;

	SerializedObject serializedWpManager;

	SerializedProperty property;

	SerializedProperty newProperty;

	SerializedProperty propertyCircularSystem;

	SerializedProperty propertyAddWaypointBetween;

	SerializedProperty propertyAddWaypointAtEnd;

	ReorderableList waypointList;

	List<Color> handleColors = new List<Color>();

	Vector3 newWaypointPosition = default;

	int insertIndex = default;


	void OnEnable()
	{
		//How-to-use instructions
		Debug.Log("Hold down 'alt' and click in the sceneview to add a new waypoint.");
		Debug.Log("Hold down 'ctrl' and right click on a waypoint in the scene view to select and mark it in both the sceneView and the editor.");
		Debug.Log("Click on a waypoint in the editor list to select and mark it in both the sceneView and the editor.");

        SetTarget();

        SetSerializedObject();

        SetupReorderableList();

        SetupSerializedProperties();

        SceneView.duringSceneGui += DuringSceneGUI;
	}

	private void OnDisable()
	{
		SceneView.duringSceneGui -= DuringSceneGUI;
	}

    private void SetTarget()
    {
        wpManager = (WaypointManager)target;
    }

    private void SetSerializedObject()
    {
        serializedWpManager = serializedObject;
    }

    private void SetupReorderableList()
    {
		waypointList = new ReorderableList(serializedWpManager, serializedWpManager.FindProperty("waypoints"), true, true, true, true);

		waypointList.drawElementCallback = DrawListItems;
        waypointList.drawHeaderCallback = DrawHeader;
        waypointList.onSelectCallback = WhenSelectedInEditor;
    }

    private void SetupSerializedProperties()
    {
        propertyCircularSystem = serializedWpManager.FindProperty("circularWaypointSystem");
        propertyAddWaypointBetween = serializedWpManager.FindProperty("addWaypointBetweenPoints");
        propertyAddWaypointAtEnd = serializedWpManager.FindProperty("addWaypointAtEnd");
    }

    private void DrawListItems(Rect rect, int index, bool isActive, bool isFocused)
	{
        EditorGUI.LabelField(new Rect(rect.x, rect.y, 150, EditorGUIUtility.singleLineHeight), "Waypoint " + (index + 1).ToString());

		EditorGUI.PropertyField(new Rect(new Rect(rect.x + 81, rect.y, rect.width - 81, EditorGUIUtility.singleLineHeight)),
								GetElement(index), GUIContent.none);
	}

    private void DrawHeader(Rect rect)
    {
        string name = "Waypoints";
        EditorGUI.LabelField(rect, name);
    }

    private void WhenSelectedInEditor(ReorderableList list)
	{
		for (int i = 0; i < list.count; i++)
		{
            FocusViewOnSelectedWaypoint(list);

            GetPropertyValues(i);

			//to mark the selected waypoint in the scene view.
			if (i == list.index &#038;&#038; handleColors[i] != Color.red)
			{
				handleColors[i] = Color.red;
			}
			//to unmark when not selected in scene view.
			else
			{
				handleColors[i] = Color.white;
			}
			SceneView.RepaintAll();
		}
	}

	private void WhenSelectedInSceneView()
	{
		//get the index of waypoint
		//focus on element with same index in reorderablelist
		int mouseClickIndex;

		for (int i = 0; i < waypointList.count; i++)
		{
			GetPropertyValues(i);

			// get click point
			Ray ray = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition);
			Plane plane = new Plane(ray.direction, property.vector3Value);

			if (plane.Raycast(ray, out float hitDistance))
			{
				Vector3 v1 = ray.direction;
				Vector3 v2 = (property.vector3Value/*end*/ - ray.origin/*start*/).normalized;

				float dot = Vector3.Dot(v1, v2);

				if (dot > 0.999)
				{
					mouseClickIndex = i;
					handleColors[i] = Color.red;
					waypointList.index = mouseClickIndex;
				}
				else
				{
					handleColors[i] = Color.white;
				}
			}
		}
	}

	private void CreateNewWaypoint()
	{
		if(waypointList.count == 0 || waypointList == null)
		{
			AddFirstWaypoint();
		}
		else if (propertyAddWaypointBetween.boolValue == true)
		{
			propertyAddWaypointAtEnd.boolValue = false;
			Debug.Log(propertyAddWaypointAtEnd.boolValue.ToString());
			AddWaypointBetweenPoints();
		}
		else if (propertyAddWaypointAtEnd.boolValue == true)
		{
			propertyAddWaypointBetween.boolValue = false;
			Debug.Log(propertyAddWaypointBetween.boolValue.ToString());
			AddWaypointAtTheEnd();
		}
		else
		{
			AddWaypointAtTheBeginning();
		}
	}

	private void AddFirstWaypoint()
	{
		SetInsertIndex(0);

		SetNewWaypointPosition(Vector3.zero);

		if (Physics.Raycast(GetRayFromMouse(), out RaycastHit hitInfo))
		{
			SetNewWaypointPosition(hitInfo.point);
		}
		else 
		{
			Debug.LogWarning("No terrain found. Waypoint was created at position 0,0,0.");
		}

		InsertElement(insertIndex);
		handleColors.Add(Color.white);
		GetNewPropertyValues(insertIndex);
		newProperty.vector3Value = newWaypointPosition;
	}

    private void AddWaypointAtTheBeginning()
	{
		SetInsertIndex(0);

		AddWaypoint(insertIndex, insertIndex, GetPlaneInPoint(insertIndex).vector3Value);
	}

    private void AddWaypointAtTheEnd()
	{
        SetInsertIndex(waypointList.count - 1);

		AddWaypoint(insertIndex, insertIndex + 1, GetPlaneInPoint(insertIndex).vector3Value);
    }

    private void AddWaypointBetweenPoints()
	{
		SetInsertIndex(0);

		float distanceComparisonValue = 515;

        FindNearestWaypoint(ref distanceComparisonValue, ref insertIndex);

		AddWaypoint(insertIndex, insertIndex + 1, property.vector3Value);
	}

    private void AddWaypoint(int insertIndex, int modifiedIndex, Vector3 propertyVector3Value)
    {
        SetNewWaypointPosition(Vector3.zero);

        if (Physics.Raycast(GetRayFromMouse(), out RaycastHit hitInfo))
        {
            SetNewWaypointPosition(hitInfo.point);
        }
        else if (MakePlane(propertyVector3Value).Raycast(GetRayFromMouse(), out float hitDistance))
        {
            SetNewWaypointPosition(GetRayFromMouse().origin + (GetRayFromMouse().direction / 2) + (GetRayFromMouse().direction * hitDistance));
        }

        InsertElement(insertIndex);
        GetNewPropertyValues(modifiedIndex);
        newProperty.vector3Value = newWaypointPosition;

		SetHandleColor(modifiedIndex);
    }

    private void FindNearestWaypoint(ref float distanceComparisonValue, ref int firstWaypointIndex)
    {
        for (int i = 0; i < waypointList.count; i++)
        {
            GetPropertyValues(i);

            int nextIndex = i + 1;

            //same as Mathf.Repeat(), but looks cooler with %= ^^
            nextIndex %= waypointList.count;

            SerializedProperty nextProperty = waypointList.serializedProperty.GetArrayElementAtIndex(nextIndex);

            float distanceFromMouseToLine = HandleUtility.DistanceToLine(property.vector3Value, nextProperty.vector3Value);

            if (distanceComparisonValue > distanceFromMouseToLine)
            {
                distanceComparisonValue = distanceFromMouseToLine;
                firstWaypointIndex = i;
            }
        }
    }

    private SerializedProperty GetElement(int index)
    {
        return waypointList.serializedProperty.GetArrayElementAtIndex(index);
    }

    private void InsertElement(int insertIndex)
    {
        waypointList.serializedProperty.InsertArrayElementAtIndex(insertIndex);
    }

    private void FocusViewOnSelectedWaypoint(ReorderableList list)
    {
        SceneView.lastActiveSceneView.LookAt(wpManager.waypoints[list.index]);
    }

    private SerializedProperty GetPlaneInPoint(int insertIndex)
    {
        return waypointList.serializedProperty.GetArrayElementAtIndex(insertIndex);
    }

    private Ray GetRayFromMouse()
    {
        return HandleUtility.GUIPointToWorldRay(Event.current.mousePosition);
    }

    private Plane MakePlane(Vector3 vector3Value)
    {
        return new Plane(GetRayFromMouse().direction, vector3Value);
    }

    private void SetNewWaypointPosition(Vector3 newPosition)
    {
        newWaypointPosition = newPosition;
    }

    private void SetHandleColor(int handleIndex)
    {
        if (handleColors[handleIndex] == Color.red)
        {
            handleColors[handleIndex] = Color.white;
        }
    }

    private void SetInsertIndex(int listIndex)
    {
        insertIndex = listIndex;
    }

    private void DuringSceneGUI(SceneView sceneView)
	{
		//need to use Update and ApplyModifiedProperties to be able to undo/redo changes in the scene view.
		serializedWpManager.Update();

		for (int i = 0; i < waypointList.count; i++)
		{
			handleColors.Add(Color.white);
			GetPropertyValues(i);
			property.vector3Value = Handles.PositionHandle(property.vector3Value, Quaternion.identity);
			property.vector3Value = Handles.FreeMoveHandle(property.vector3Value, Quaternion.identity, 0.3f, Vector3.one, Handles.SphereHandleCap);
		}

		//note: use right mouse button to click
		bool holdingCtrl = (Event.current.modifiers &#038; EventModifiers.Control) != 0;
		if (Event.current.type == EventType.MouseDown &#038;&#038; holdingCtrl)
		{
			WhenSelectedInSceneView();
			Repaint();
			Event.current.Use();
		}

		//note: use right mouse button to click
		bool holdingAlt = (Event.current.modifiers &#038; EventModifiers.Alt) != 0;
		if (Event.current.type == EventType.MouseDown &#038;&#038; holdingAlt)
		{
			CreateNewWaypoint();
			Repaint();
			Event.current.Use();
		}

		serializedWpManager.ApplyModifiedProperties();
	}

	public override void OnInspectorGUI()
	{
		//need to use Update and ApplyModifiedProperties to be able to undo/redo changes in the editor.
		serializedWpManager.Update();

		waypointList.DoLayoutList();
		EditorGUILayout.PropertyField(propertyCircularSystem);
		EditorGUILayout.PropertyField(propertyAddWaypointBetween);
		if (propertyAddWaypointBetween.boolValue == false)
		{
			EditorGUILayout.PropertyField(propertyAddWaypointAtEnd);
		}
		serializedWpManager.ApplyModifiedProperties();

		GUILayout.Space(75);

		base.OnInspectorGUI();
	}

	private void GetPropertyValues(int i)
	{
		property = waypointList.serializedProperty.GetArrayElementAtIndex(i);
		Handles.color = handleColors[i];
	}

	private void GetNewPropertyValues(int index)
	{
		newProperty = waypointList.serializedProperty.GetArrayElementAtIndex(index);
	}
}
      
    
using System.Collections.Generic;
using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;
#endif

public class WaypointManager : MonoBehaviour
{
	[HideInInspector]
	public List<Vector3> waypoints = new List<Vector3>();

	[HideInInspector, Tooltip("NOTE: For AI movement behavior.\nCheck to use a circular waypoint system.\nUncheck to use a linear waypoint system.\n(Circular connects the last and first waypoints together)")]
	public bool circularWaypointSystem;

	[HideInInspector, Tooltip("Check to enable adding new waypoints in between existing ones.\nUncheck to add new waypoints after the last one or before the first one.")]
	public bool addWaypointBetweenPoints;

	[HideInInspector, Tooltip("Check to add new waypoints after the currently last waypoint.\nUncheck to add waypoints before the currently first waypoint.\nNote: Adding waypoint to the start is default.")]
	public bool addWaypointAtEnd;

	private void OnDrawGizmos()
	{
#if UNITY_EDITOR

		for (int i = 0; i < waypoints.Count; i++)
		{
			Handles.FreeMoveHandle(waypoints[i], Quaternion.identity, 0.3f, Vector3.one, Handles.SphereHandleCap);

			int nextIndex = i + 1;

			nextIndex %= waypoints.Count;

			if (nextIndex == 0)
			{
				Handles.color = Color.cyan;
			}
			else
			{
				Handles.color = Color.white;
			}
			if (circularWaypointSystem)
			{
				//show line between last and first waypoint
				Handles.DrawAAPolyLine(waypoints[i], waypoints[nextIndex]);
			}
			else
			{
				//don't show line between last and first waypoint
				if (i != waypoints.Count - 1)
				{
					Handles.DrawAAPolyLine(waypoints[i], waypoints[nextIndex]);
				}
			}
		}
#endif
	}
}

using UnityEngine;

public class WaypointFollow : MonoBehaviour
{
    [SerializeField, Tooltip("The WaypointManager for the unit's default movement.")]
    protected WaypointManager wpManager = default;

    [SerializeField, Tooltip("How close the character needs to be to target location before going towards new target.")]
    protected float accuracy = 0.1f;
    [SerializeField, Tooltip("Character movement speed. Note: Set a higher speed for ground units and lower speed for air units. (Around 10x more when grounded.)")]
    protected float speed = 100f;
    [SerializeField, Tooltip("Character turning/rotation speed. Set with caution; wrong values may cause bugs when turning.")]
    private float _rotationSpeed = 3.0f;

    [HideInInspector]
    public int currentWP = 0;

    protected Vector3 lookAtGoal = default;
    protected Vector3 direction = default;

    private CharacterController _controller = default;
    private bool _goingBackToStart = false;

    void Awake()
    {
        _controller = GetComponent<CharacterController>();
    }

    void LateUpdate()
    {
        if (wpManager.waypoints.Count == 0 || wpManager.waypoints == null)
        {
            return;
        }

        Move();
    }

    void Move()
    {
        SetLookAtGoal();
        SetDirection();
        SetRotation();
        SetMovementBehavior();

        //when on terrain
        _controller.SimpleMove(direction.normalized * (speed * Time.deltaTime));

        //when in the air
        //_controller.Move(direction.normalized * (speed * Time.deltaTime));        
    }

    void SetLookAtGoal()
    {
        lookAtGoal = wpManager.waypoints[currentWP];
    }

    void SetDirection()
    {
        //activate when on terrain
        lookAtGoal.y = transform.position.y;

        direction = lookAtGoal - transform.position;
    }

    void SetRotation()
    {
        transform.rotation = Quaternion.Slerp(transform.rotation,
                                                Quaternion.LookRotation(direction),
                                                Time.deltaTime * _rotationSpeed);
    }

    void SetMovementBehavior()
    {
        if (direction.magnitude > accuracy)
        {
            return;
        }

        if (wpManager.circularWaypointSystem == true)
        {
            currentWP++;
            if (currentWP >= wpManager.waypoints.Count)
            {
                currentWP = 0;
            }
        }
        else
        {
            if (currentWP >= wpManager.waypoints.Count - 1)
            {
                currentWP = wpManager.waypoints.Count - 1;
                _goingBackToStart = true;

            }
            else if (currentWP <= 0)
            {
                _goingBackToStart = false;
                currentWP = 0;
            }
            if (_goingBackToStart)
            {
                currentWP--;
            }
            else
            {
                currentWP++;
            }
        }
    }
}