Clockwork TD Devlog #3: CursorState FSM
This is the third post in an ongoing series about the development of my thesis project, Clockwork TD.
Rather than define separate game states Building, Moving, Selling, etc., I chose to create a separate FSM for the cursor state. Like the game states, each of the cursor states is strongly defined as a ScriptableObject:
[Header("Game States")]
public MainMenuState StateMainMenu;
public GameStartState StateGameStart;
public PlayerPreparationState StatePlayerPreparation;
public EnemyAttackState StateEnemyAttack;
public GameOverState StateGameOver;
[Header("Cursor States")]
public CursorDefault CursorStateDefault;
public CursorBuilding CursorStateBuilding;
public CursorSelling CursorStateSelling;
public CursorMoving CursorStateMoving;
public CursorUpgrading CursorStateUpgrading;
This makes it easy to add new game & cursor states and also to access them from anywhere in the project.
The base CursorState includes abstract & virtual methods to override, depending on the desired behavior of the current state:
public abstract void TileClicked(Tile tile);
public abstract void TileEntered(Tile tile);
public abstract void TileExited(Tile tile);
public abstract void BuildableClicked(Buildable buildable);
public abstract void BuildableEntered(Buildable buildable);
public abstract void BuildableExited(Buildable buildable);
protected void Enter()
{
if(gameData.CurrentCursor) gameData.CurrentCursor.Exit();
gameData.CurrentCursor = this;
if (debug) Debug.Log(this + "::Enter() (" + gameData.CurrentCursor + ")");
OnEnter();
Entered?.Invoke();
}
protected virtual void OnEnter(){ }
private void Exit()
{
if (debug) Debug.Log(this + "::Exit() (" + gameData.CurrentCursor + ")");
OnExit();
Exited?.Invoke();
}
protected virtual void OnExit(){ }
protected virtual void Cancel()
{
gameData.CursorStateDefault.Enter();
}
As in the GameStates, CursorStates have Entered and Exited events that can be subscribed to, as well as OnEnter() and OnExit() methods for inheriting CursorStates to override. Buildable and Tile both implement the “IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler” interfaces, calling back to the current CursorState during those mouse events. This allows the code for say, Building to be fairly straightforward (incomplete code but you can get the gist — oh I just got why github named it that):
public void SetNewBuildable(BuildableSetup newTower)
{
currentBuildable = newTower;
ShowInfoPanel(newTower);
if (gameData.CurrentCursor != this) Enter();
}
protected override void OnEnter()
{
base.OnEnter();
InputManager.EscapePressed += Cancel;
InputManager.RightClickPressed += Cancel;
}
protected override void OnExit()
{
base.OnExit();
HideGhostTower();
HideBonusText();
HideRangefinder();
HideInfoPanel();
currentBuildable = null;
InputManager.EscapePressed -= Cancel;
InputManager.RightClickPressed -= Cancel;
}
public override void TileClicked(Tile tile)
{
if (currentBuildable.Cost > currentBuildable.Currency.Value) return;
var buildCommand = new BuildCommand(currentBuildable, tile);
gameData.CommandManage.AddCommand(buildCommand);
HideGhostTower();
HideBonusText();
HideRangefinder();
if(!InputManager.ShiftHeld) Cancel();
}
public override void TileEntered(Tile tile)
{
ShowGhostTower(currentBuildable, tile);
ShowBonusText(tile);
ShowRangefinder(tile, currentBuildable.AttackRange.value * (1 + (tile.Bonus / 100f)));
}
public override void TileExited(Tile tile)
{
HideGhostTower();
HideBonusText();
HideRangefinder();
}
public override void BuildableClicked(Buildable buildable) { }
public override void BuildableEntered(Buildable buildable) { }
public override void BuildableExited(Buildable buildable) { }
Here’s the initial results for the various states:


The Undo queue is a fairly simple Command Stack, with everything routing through a ScriptableObject CommandManager (for Managers that don’t require pertick functionality, I tend to take the extra step to make them ScriptableObjects). Here’s that class, along with the MoveCommand:
public class CommandManager : ScriptableObject
{
public event Action QueueChanged;
public int Count => commandsBuffer.Count;
private Stack<ICommand> commandsBuffer = new();
public void AddCommand(ICommand command)
{
command.Execute();
commandsBuffer.Push(command);
QueueChanged?.Invoke();
}
// undoes the last command
public void UndoCommand()
{
if(commandsBuffer.Count == 0) return;
commandsBuffer.Pop().Undo();
QueueChanged?.Invoke();
}
// undoes all the commands in reverse order
public void UndoAllCommands()
{
if(commandsBuffer.Count == 0) return;
while (commandsBuffer.Count > 0)
{
commandsBuffer.Pop().Undo();
}
QueueChanged?.Invoke();
}
// executes each tower's cleanup command and clears the stack to finalize the commands
// (only used to cleanup after sell commands right now)
public void FinalizeCommands()
{
while (commandsBuffer.Count > 0)
{
commandsBuffer.Pop().Commit();
}
ClearCommands();
}
public void ClearCommands()
{
commandsBuffer.Clear();
QueueChanged?.Invoke();
}
}
public interface ICommand
{
void Execute();
void Undo();
void Commit();
}
public class MoveCommand : ICommand
{
private readonly Buildable commandObject;
private readonly Tile originalTile;
private readonly Tile newTile;
public MoveCommand(Buildable buildable, Tile original, Tile target)
{
commandObject = buildable;
originalTile = original;
newTile = target;
}
// move the Buildable, cleanup the Tile statuses
public void Execute()
{
originalTile.MarkBuilt(false);
commandObject.transform.position = newTile.GetTopOfTile();
commandObject.HomeTile = newTile;
commandObject.TileBonus.initial.value = newTile.Bonus;
newTile.MarkBuilt(true);
}
// move the Buildable back, revert the Tile statuses
public void Undo()
{
originalTile.MarkBuilt(true);
commandObject.transform.position = originalTile.GetTopOfTile();
commandObject.HomeTile = originalTile;
commandObject.TileBonus.initial.value = originalTile.Bonus;
newTile.MarkBuilt(false);
}
// no cleanup necessary
public void Commit() {}
}

Stay tuned for the next installment, which will probably cover some UI elements.