In this post I will describe my simple system for moving platforms and other objects in Unity. What is great is, that it is simple, flexible and easily extensible.
- moves in "loop" way,
- stops at first node,
- moves in gravity way from second to third node,
- moves in "cos" way from third to fourth node
1. Timer class
First we will create small helper class called Timer. If you often need to track time in your Update method you are probably used to create float variable holding some amount of time and adjust it with Time.delta or Time.smoothDeltaTime. Doing this again and again is boring so we will create class that can:
- hold current time,
- increase time from zero to specified time or decrease time from initial time to zero,
- return current time,
- return current time as percent from initial or maximum time,
- say if timer expired
Here is listing for Timer.cs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| using UnityEngine;
public class Timer
{
private float mTime;
private float mCurrentTime;
private bool mRunning;
private bool mReversed;
// -------------------------------------------------------------------------
public void Set(float aInitialTime, bool aReversed = false)
{
mTime = aInitialTime;
mCurrentTime = aReversed ? aInitialTime : 0.0f;
mReversed = aReversed;
mRunning = (aInitialTime > 0.0f);
}
// -------------------------------------------------------------------------
public float Update(float aDeltaTime)
{
if (!mRunning)
return GetActual();
if (mReversed)
mCurrentTime -= aDeltaTime;
else
mCurrentTime += aDeltaTime;
if (mCurrentTime < 0 || mCurrentTime > mTime)
{
mCurrentTime = Mathf.Clamp(mCurrentTime, 0.0f, mTime);
mRunning = false;
}
return mCurrentTime;
}
// -------------------------------------------------------------------------
public float GetActual()
{
return mCurrentTime;
}
// -------------------------------------------------------------------------
public float GetActualRatio()
{
float ratio = mCurrentTime / mTime;
if (ratio < 0.0f)
ratio = 0.0f;
else if (ratio > 1.0f)
ratio = 1.0f;
return ratio;
}
// -------------------------------------------------------------------------
public bool IsRunning()
{
return mRunning;
}
// -------------------------------------------------------------------------
public bool IsReversed()
{
return mReversed;
}
}
|
To use this class create Timer object and first call Set on it. You can call it like this:
- Set(5.0f); ... timer will start with 0 and increase until it reaches 5 seconds,
- Set(5.0f, false); ... timer will has its initial value 5 seconds and will decrease towards zero
In Update method of parent object then call Update(Time.deltaTime) on Timer object to adjust it. To check if timer is finnished simply check if IsRunning is false.
2. Path node, path definition and path follower
Now we can focus on real subject. Our path following system will have three parts:
- PathNode - class that defines single node in path,
- PathDefinition - is object managing two or more path nodes. As having only two nodes is very common we will make PathDefinition class abstract and build two implementations: SimplePathDefinition for two nodes only and LongPathDefinition for unlimited number of nodes. In fact, LongPathDefinition can also handle two nodes without problem. But the main reason is that SimplePathDefinition component has easier input interface for user (two fields for node 1 and 2 instead of nodes array),
- PathFollower - is object that will hold PathDefinition and will move object from node to node. This is script is then added in component view to platform or log or any other object that shell follow the path.
2.1. PathNode class
Path node is single node the path consists of. The position of node is given with its Transform component. Beside position the node holds other information:
- delay in seconds - how long the moving object will pause its move in this node,
- movement type - type of movement from this node to next. Supported moves are: linear, cos, quadratic,
- speed modifier - is in % and affects speed of moving object for move from current node to next one. For example, if set to 0,5 (50%) then distance to next move is overcome in half of the time. So, numbers below zero means faster move.
- OnArriveEvent, OnLeaveEvent - two events fired when leaving current node and when arrived to next node.
Currently the events are little bit confusing, because in OnArriveEvent node says which event will be triggered when next node is reached. The order of processing for single node is like this:
----- wait (delay) ---- trigger OnLeaveEvent ----- move to next node ----- trigger OnArriveEvent -----
For future improvements it would be better to bind the OnArriveEvent event to node that defines it and trigger it as first when object reaches node like this:
----- trigger OnArriveEvent ----- wait (delay) ----- trigger OnLeaveEvent ----- move to next node -----
Currently events are implemented only as some enum values.
Update: read next part of this tutorial here - enum values are replaced with event objects that can be chained to create series of events, have delays and are easily extensible (also source download is available there).
Here is listing for core of PathNode class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| using UnityEngine;
public class PathNode : MonoBehaviour
{
public enum ePathNodeEventType
{
Nothing,
Hit
}
public enum eMovementType
{
Linear,
Cos,
Qudratic
}
public eMovementType mMovementType = eMovementType.Linear;
// delay in this point in seconds
public float mDelay = 0.0f;
public ePathNodeEventType mOnArriveEvent;
public ePathNodeEventType mOnLeaveEvent;
public float mSpeedModifier = 1.0f;
|
Using enums is convenient for users as they can select from it in editor then. Here is picture how the component looks like in editor. Notice that PathNode has no visual representation. It is just point in space with some additional parameters. So to visualise it add some visual tag to it (see small yellow square in top left of image):
Rest of the class are only get properties, but to make it complete here is the listing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| // -------------------------------------------------------------------------
public float Delay
{
get
{
return mDelay;
}
}
// -------------------------------------------------------------------------
public eMovementType MovementType
{
get
{
return mMovementType;
}
}
// -------------------------------------------------------------------------
public ePathNodeEventType ArriveEvent
{
get
{
return mOnArriveEvent;
}
}
// -------------------------------------------------------------------------
public ePathNodeEventType LeaveEvent
{
get
{
return mOnLeaveEvent;
}
}
// -------------------------------------------------------------------------
public float SpeedModifier
{
get
{
return mSpeedModifier;
}
}
}
|
2.2 PathDefinition class
Path definition will manage PathNodes and it will be abstract class. The abstaract method will be method:
public abstract IEnumerator<PathNode> GetPathEnumerator();
This method is:
- abstract, so some other class will have to define implementation for this method,
- returns IEnumerator, which means that implementation will manage set of PathNodes and on request it will return correct PathNode or report that there are no more nodes.
PathDefinition class looks simple like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public abstract class PathDefinition : MonoBehaviour
{
public enum ePathRepeatType
{
Once,
PingPong,
Loop
}
public ePathRepeatType mMovementType;
// -------------------------------------------------------------------------
public abstract IEnumerator<PathNode> GetPathEnumerator();
|
Notice the ePathRepeatType enum. This enum says what type of path it is. It can be traveled by the object only once or back and forth forever (ping-pong) or in loop when from last node the object will continue to the first node again.
As it would be very pleasant to visuailise the path between nodes, we will implment OnDrawGizmos method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // -------------------------------------------------------------------------
public void OnDrawGizmos()
{
IEnumerator<PathNode> nodes = GetPathEnumerator();
if (!nodes.MoveNext())
return;
Gizmos.color = Color.grey;
PathNode firstNode = nodes.Current;
PathNode node1 = firstNode;
PathNode node2 = null;
while (nodes.MoveNext())
{
node2 = nodes.Current;
Gizmos.DrawLine(node1.transform.position, node2.transform.position);
if (node2 == firstNode)
break;
node1 = node2;
}
}
}
|
As you can see we are asking for enumerator which is provided by the abstract method. We draw debug lines between nodes until enumerator is giving us nodes. In case the enumerator is returning nodes in "loop" mode, we check if loop is closed and exit the method.
Now we can provide concrete implementation in class that will be able to hold only two PathNodes. Let's call it SimplePathDefinition:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| using UnityEngine;
using System.Collections.Generic;
public class SimplePathDefinition : PathDefinition
{
public PathNode mPathNode1;
public PathNode mPathNode2;
// -------------------------------------------------------------------------
public override IEnumerator<PathNode> GetPathEnumerator()
{
// check if both path nodes entered
if (mPathNode1 == null || mPathNode2 == null)
yield break;
// else set pointer to current node
PathNode currentNode = mPathNode1;
// return points based on movement type
// in case of simple path loop and ping pong are the same
while (true)
{
yield return currentNode;
// swap to next node
if (currentNode == mPathNode1)
currentNode = mPathNode2;
else
currentNode = mPathNode1;
// if move only once mode and returned to node1 then exit
if (mMovementType == ePathRepeatType.Once && currentNode == mPathNode1)
yield break;
}
}
}
|
We are not inheriting directly form MonoBehaviour here but from PathDefinition. It means in editor our parent's OnDrawGizmos will be called and only thing we have to do is provide GetPathEnumerator method. This class will be the component you can add to GameObject in editor and you will also have to give it two PathNodes:
In case of "ping-pong" or "loop" modes, the iterator never runs out of nodes. It just switches between nodes and return first or second in turn. In case of "once" move mode the iterator check if reached second node and if yes it terminates (yield break;)
Now you can go to editor and create three empty objects. Add PathNode component to two of them and add SimplePathDefinition to the last one. Then drag PathNode objects to SimplePathDefinition object as Path Node 1 and Path Node 2 and you should see line between them (if you see nothing, check if the position of PathNodes is different).
Now, we will create another class - LongPathDefinition. This class will have different GetPathEnumerator implementation and also different way how to store PathNodes. PathNodes will be stored in array and we will also add one additional feature that had no sense for only two nodes. This feature is ReverseDirection and it says whether the move should be from first to last node or from last to first node. Here stars LongPathDefinition listing:
1
2
3
4
5
6
7
| using UnityEngine;
using System.Collections.Generic;
public class LongPathDefinition : PathDefinition
{
public bool mReverseDirection;
public PathNode[] mPathNodes;
|
GetPathEnumerator is only last thing we have to write:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| // -------------------------------------------------------------------------
public override IEnumerator<PathNode> GetPathEnumerator()
{
// check if nodes array is valid entered and longer than 2 nodes (1 node = no path)
if (mPathNodes == null || mPathNodes.Length < 2)
yield break;
// set index to start
int currentNodeIndex = 0;
int directionAdjustment = mReverseDirection ? -1 : 1;
// if move type is only once and direction is reversed, start from last node
if (mReverseDirection && mMovementType == ePathRepeatType.Once)
currentNodeIndex = mPathNodes.Length - 1;
// return points based on movement type
// in case of simple path loop and ping pong are the same
while (true)
{
yield return mPathNodes[currentNodeIndex];
// adjust in move direction
currentNodeIndex += directionAdjustment;
if (currentNodeIndex < 0 || currentNodeIndex >= mPathNodes.Length)
{
if (mMovementType == ePathRepeatType.Once)
{
yield break;
}
else if (mMovementType == ePathRepeatType.PingPong)
{
directionAdjustment *= -1;
// adjust twice - first return to current node, second move in new direction
currentNodeIndex += directionAdjustment * 2;
}
else if (mMovementType == ePathRepeatType.Loop)
{
if (currentNodeIndex < 0)
currentNodeIndex = mPathNodes.Length - 1;
else
currentNodeIndex = 0;
}
}
}
}
}
|
In case of "once" move we have to set the last node as first one. In case of "ping-pong" the reversing has no sense and in case of "loop" the first node is still the first one, but we start to move to last one instead to second one if direction is reversed.
The rest is just infinite enumerator loop (except for "once", which terminates when last (first if reversed) node is reached).
As with SimplePathDefinition, you can now go to editor and create new GameObject, add LongPathDefinition script component to it and create and add several PathNodes to it:
2.3 PathFollwer class
PathFollower is last piece into mosaic. This script will be the component that you add to object you want to move. This component will hold some path definition (Simple or Long) which in turn holds set of path nodes.
This class is the longest one so I will go step by step. First, listing shows variables. The public ones are those you can set in editor. Beside PathDefinition (Simple/Long) it has general speed in Unity units. 1 = 1 Unity unit in second. Next public variable - mStopped - says whether the move is stopped.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| using UnityEngine;
using System.Collections.Generic;
public class PathFollower : MonoBehaviour
{
// path definition
public PathDefinition mPathDefinition;
// speed in units per second
public float mSpeed = 1.0f;
// stopped or in action?
public bool mStopped = false;
private enum ePathFollowerState
{
Waiting,
Moving
}
// enumerator
private IEnumerator<PathNode> mPathNodes;
// inner state of follower
private ePathFollowerState mState = ePathFollowerState.Waiting;
// inner waiting time
private Timer mTimer = new Timer();
// curren path node, object is in (or going from)
private PathNode mCurrentPathNode;
// target path node (or node object is going to)
private PathNode mTargetPathNode;
// finished
private bool mFinished;
|
Private variables then holds inner state of the PathFollower. If it is currently moving on waiting in actual node. It also stores concrete IEnumerator implementation. Here we also finally utilize the Timer class to simplify all timings.
Next we store current and target node and also whether the path following is finished (in case of "once" movetype).
In Start method we initialize enumerator and also we ask it for current and next node. We read delay from path node (can be 0) and set inner state into "waiting".
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| // -------------------------------------------------------------------------
public void Start()
{
if (mSpeed == 0.0f)
Debug.LogError("Speed cannot be zero!");
Reset();
}
// -------------------------------------------------------------------------
public void Reset()
{
if (mPathDefinition == null)
{
Debug.LogError(gameObject + ": path definition cannot be null");
return;
}
mPathNodes = mPathDefinition.GetPathEnumerator();
mPathNodes.MoveNext();
mCurrentPathNode = mPathNodes.Current;
mPathNodes.MoveNext();
mTargetPathNode = mPathNodes.Current;
mTimer.Set(mCurrentPathNode.Delay);
mState = ePathFollowerState.Waiting;
mFinished = false;
}
|
Here is Update method where most of the things take place:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| // -------------------------------------------------------------------------
public void Update()
{
if (mStopped || mFinished)
return;
// adjust timer (regardless it is for waiting or moving)
mTimer.Update(Time.smoothDeltaTime);
// if not waiting (moving) then adjust position of object
if (mState == ePathFollowerState.Moving)
{
AdjustPosition(mTimer.GetActualRatio());
}
// if timer finished its running - do next steps
if (!mTimer.IsRunning())
{
if (mState == ePathFollowerState.Waiting)
{
// any event on start of move from current position?
OnLeaveEvent(mCurrentPathNode.LeaveEvent);
MoveToTargetNode();
}
else if (mState == ePathFollowerState.Moving)
{
// any event in the end of move?
OnArriveEvent(mCurrentPathNode.ArriveEvent);
// if no new nodes we are finished
if (!PrepareNextTargetNode())
{
mFinished = true;
return;
}
// set waiting state (if any)
float waitingTime = mCurrentPathNode.Delay;
if (waitingTime > 0.0f)
{
mTimer.Set(waitingTime);
mState = ePathFollowerState.Waiting;
}
else
{
OnLeaveEvent(mCurrentPathNode.LeaveEvent);
MoveToTargetNode();
}
}
}
}
|
We first check if still running and not paused. If running, we update timer - we are not interested what it is timing now - it is either moving or waiting. If moving, we adjust position of object.
Then we check if timer expired and if yes we change inner state and get next node if needed.
Now I will list explain how moving works. When two nodes are read (current and target), distance between then is calculated in Unity units. This is divided with speed in Unity units and you get time how long it should take to the object to travel from current to target node. This time is then affected by PathNode's speed modifier (0,5 = 50% ... time should be only half). this is all calculated in CalculateMoveTime method.
Timer is then set to calculated value (MoveToTargetNode). As the timer has GetActualRatio method, we can ask it in which distance between two nodes the moving object should be. As the result is between 0 and 1 it is ideal entry for LERP.
Look into AdjustPosition method and you will see that it is called with value returned from Timer.GetActualRatio. If movement type is linear we just calculate new position as Vector3.Lerp between current and target nodes positions. If movement type is quadratic, we power the ratio by 2. In case of cos we do this:
- cos (ratio * PI) => gives 1 to -1 values,
- -cos (ratio * PI) => gives -1 to 1,
- -cos (ratio * PI) + 1 => gives 0 to 2,
- (-cos (ratio * PI) + ) / 2 => gives 0 to 1 ... correct entry for lerp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| // -------------------------------------------------------------------------
private void MoveToTargetNode()
{
// calculate time to move based on nodes positions and speed per unit
mTimer.Set(CalculateMoveTime());
// set object as moving
mState = ePathFollowerState.Moving;
}
// -------------------------------------------------------------------------
private bool PrepareNextTargetNode()
{
// set target node as current node
mCurrentPathNode = mTargetPathNode;
// read new target node - if no nodes we are finished (only resetting can start it again
if (!mPathNodes.MoveNext())
{
mCurrentPathNode = null;
mTargetPathNode = null;
return false;
}
mTargetPathNode = mPathNodes.Current;
return true;
}
// -------------------------------------------------------------------------
private void AdjustPosition(float aRatio)
{
PathNode.eMovementType movementType = mCurrentPathNode.MovementType;
// adjust ratio based on movement type
if (movementType == PathNode.eMovementType.Cos)
{
// return cos from 0 to 1 in PI range (1...-1 => 0...1)
aRatio = (-Mathf.Cos(Mathf.PI * aRatio) + 1) / 2.0f;
}
else if (movementType == PathNode.eMovementType.Qudratic)
{
aRatio *= aRatio;
}
transform.position = Vector3.Lerp(mCurrentPathNode.transform.position,
mTargetPathNode.transform.position,
aRatio);
}
// -------------------------------------------------------------------------
private float CalculateMoveTime()
{
Vector3 distance = mTargetPathNode.transform.position -
mCurrentPathNode.transform.position;
return (distance.magnitude / mSpeed) * mCurrentPathNode.SpeedModifier;
}
|
And finally, here are two last methods for handling events:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // -------------------------------------------------------------------------
public virtual void OnLeaveEvent(PathNode.ePathNodeEventType aEvent)
{
if (aEvent != PathNode.ePathNodeEventType.Nothing)
Debug.Log("Leave event: " + aEvent);
}
// -------------------------------------------------------------------------
public virtual void OnArriveEvent(PathNode.ePathNodeEventType aEvent)
{
if (aEvent != PathNode.ePathNodeEventType.Nothing)
Debug.Log("Arrive event: " + aEvent);
}
|
These methods are only "default implementation" and you can change them in derived classes.
We are finished. You can go to editor and add new game object. Give it some appearance through SpriteRenderer, add PathFollower component to it and attach some PathDefinition. then you can pres Play and see how the object is moving along defined path!
Conclusion
This is end of my first Unity tutorial. I hope you made it here and now you have also this small system working.