Sequential and reversible actions are common in gaming. You may want the player to be able to issue a round's worth of moves to multiple units and reverse the actions if they decide it's a bad move, or make multiple selections in a menu and only undo the latest one. Undo functions are quite common in other applications, too, for obvious reasons; they provide users an easy way to return to a previous state, reversing a decision made in error or out of curiosity.
The Command Pattern offers a straightforward means of implementing this feature.
What Is the Command Pattern?
The Command pattern is a behavioral design pattern that encapsulates an action in an object. It allows for the action to be treated like any other object; it can be passed around and modified, added to lists or queues, referenced, or constructed and executed at different times. It aids in Separation of Concerns, removing the actual logic of the action from both the sender and receiver, allowing scripts to remain unaware of each other's logic while still communicating effectively.
Implementation
This implementation is specifically designed for a turn-based strategy Unity game, but the same principles can be applied in other applications or games; it can be adapted to UI or puzzle games easily, or expanded to allow for time-reversal abilities in other games.
In this implementation, command objects are enqueued to a manager class, which executes them sequentially. This class may be an overarching game manager, a UI manager, or an individual game entity, depending on what actions it will receive. When an action is completed, it adds the command to a command history Stack
. If called to undo an action, the manager takes the last run command from the history and executes its Undo()
function.
If an irreversible action is taken, the history is cleared instead.
The Command Object
The most important part of this design is, of course, the command object. In this case, the command takes the form of an abstract GameAction
class, from which the actual actions will be derived. This could be user-provoked types like MoveAction
or AttackAction
, or internal system behaviors such as LevelUpAction
or OpenMenuAction
.
public abstract class GameAction
{
// If true, the game action can be reversed. If false,
// the player cannot undo this action.
public virtual bool IsReversible
{
get => false;
}
public virtual void Start()
{ }
public virtual void Update()
{ }
public virtual void Complete()
{ }
public virtual void Undo()
{ }
public virtual bool IsFinished()
{
return true;
}
}
This GameAction
class provides the function signatures that will be implemented by the individual actions, allowing the manager to treat them interchangeably. An example implementation of an action to move a unit looks like this...
public class MoveAction : GameAction
{
private readonly Unit unit;
private Vector3 startPoint;
private Quaternion startRotation;
private Vector3 endPoint;
private Path path;
private Stack<Tile> waypoints;
private Tile targetTile;
public MoveAction(Unit unit, Path path)
{
this.unit = unit;
this.path = path;
this.startPoint = path.startTile.transform.position;
this.endPoint = path.endTile.transform.position;
waypoints = new Stack<Tile>(path.tiles);
}
// Sets the unit to the start point (for cleanliness) and moves them
// to the first tile.
public override void Start()
{
unit.transform.position = startPoint;
startRotation = unit.transform.rotation;
targetTile = waypoints.Pop();
unit.NavMeshAgent.destination = targetTile.WorldSpacePosition;
}
/// Updates movement, checking if the unit has approached the waypoint and grabbing the next
/// in the sequence.
public override void Update()
{
if (Vector3.Distance(unit.NavMeshAgent.destination, unit.transform.position) < 0.2)
{
unit.transform.position = unit.NavMeshAgent.destination;
if (waypoints.Count > 0)
{
targetTile = waypoints.Pop();
unit.NavMeshAgent.destination = targetTile.WorldSpacePosition;
}
}
unit.Animator.SetFloat("MoveSpeed", unit.NavMeshAgent.velocity.magnitude);
}
/// Returns the unit to the start location when undoing the action.
public override void Undo()
{
unit.transform.SetPositionAndRotation(startPoint, startRotation);
unit.MoveUsedThisTurn -= path.totalCost;
}
/// Snaps the unit to the destination point on completion.
public override void Complete()
{
unit.Animator.SetFloat("MoveSpeed", 0f);
unit.MoveUsedThisTurn += path.totalCost;
unit.transform.position = endPoint;
}
/// Checks if the unit has arrived at their destination.
public override bool IsFinished()
{
return Vector3.Distance(unit.transform.position, endPoint) < 0.2;
}
}
The command object stores all information about the action in itself and acts as a liaison between the sender and receiver. The connection can be further divided with movement functions handled by the unit itself, with the MoveAction
merely handing off parameters and calling the appropriate functions on the unit.
Because this object stores the original state in its parameters, it can also be called to reverse the action later if necessary.
The Command Queue
The command objects are submitted to a queue contained by a higher-level manager class, such as your UI manager. This manager, in this example called the GameDirector
, selects an action from the queue, starts it, then repeatedly updates it until the action indicates it is finished. The GameDirector
then performs any cleanup tasks via Complete()
, places the completed action into a history, and selects the next action and repeats the cycle.
public class GameDirector : MonoBehaviour
{
private GameAction currentGameAction;
private Queue<GameAction> gameActionQueue = new();
private Stack<GameAction> actionHistory = new();
private void Update()
{
// Checks for any game actions. If there is one in the queue, select it and start it.
if (currentGameAction == null)
{
if (gameActionQueue.Count > 0)
{
currentGameAction = gameActionQueue.Dequeue();
currentGameAction.Start();
return;
}
}
else
{
// Update the current game action until it is completed.
currentGameAction.Update();
if (currentGameAction.IsFinished())
{
currentGameAction.Complete();
SaveToActionHistory(action);
currentGameAction = null;
return;
}
}
// Processing of lower priority queues, if any, can be added here
}
}
This allows for the player to queue up actions for units without interrupting the current action and to ensure that actions are performed one-after-another.
Once an action is finished, it is added to a history (implemented here as a Stack<GameAction>)
via the function SaveToActionHistory()
. Stacks are ideal for this task because they are a Last-In, First-Out collection; the most recent action is on the top, so they are pulled in reverse chronological order.
Typically, if an action is not reversible, neither should earlier actions. If attempting to save an irreversible action, the GameDirector
instead clears the action history, rendering the prior changes permanent and freeing up the memory they used.
// Adds an action to the action history if it is a reversible action, clears the history if not.
private void SaveToActionHistory(GameAction action)
{
if (action.IsReversible)
{
actionHistory.Push(action);
}
else
{
actionHistory.Clear();
actionHistory.TrimExcess();
}
}
Reversing Changes
In this implementation, reversing a change is easy. The GameDirector
pulls the last action from the history and tells it to reverse the stored changes. The command object is then discarded.
// Pulls the last action from the action history and reverses it.
public void ReverseLastAction()
{
if (actionHistory.Count > 0)
{
actionHistory.Pop().Undo();
}
}
If desired, a second Stack can be used to store undone actions the same way; this would allow for a Redo feature. In this case, you would have the GameDirector
clear the redo stack whenever a brand new action is started.
Conclusion
The command pattern is great for any game in which actions need to be both reversible and sequential. Moves in puzzle games, unit actions in strategy games, and combat in RPGs can all be implemented easily in this way. Even for other games, this design pattern is useful for UI interactions and menus where a player may want to return to their original selection or navigate back up a menu tree the way they came.
It can even be used to record player input and play it back and forth.
This design is a solid framework on which to build your user input and game flow control systems, as it is easy to use and lends itself well to the addition of new actions.
Top comments (0)