Tag: Game State

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.

2023-07-14T19:43:46-04:00July 11th, 2023|Categories: Devlog, General|Tags: , , , , , , , , , |1 Comment

Clockwork TD Devlog #2: Gameplay Progression & FSMs

This is the second post in an ongoing series about the development of my thesis project, Clockwork TD.

In the last entry, we covered A* Pathfinding for the enemies to find their way to the grid center (check out my Hex Grid Framework here). A major part of my thesis was a Tower Defense game where the enemy spawn point changed throughout the course of the game, requiring the player to counter that somehow. Using the Hex grid math I learned at redblobgames.com, I came up with a system where the spawner will move around each ring of the game map–similar to a clock–working its way from inside to out, giving the player the longer stretch of enemy path they’ll need as the game goes on and the number of enemies multiplies.

In this early version, the spawner simply lasts for one turn at each location, then the tile it’s standing on changes from a path tile to a buildable tile, giving the player an extra location to place a tower. When the spawn tile moves to a new ring, it spawns at the location closest to the last ring’s opening for the first round, to get the player acquainted to the change in the game.

I also built upon the camera system I created for Accumulus, which builds on Unity’s Cinemachine follow camera to move an invisible GameObject around the map with the player’s keyboard input. Here I added a mouse scrollwheel function for Zoom, which simultaneously moves the camera down and forward. Camera control is a huge sticking point for me in top-down games like this, so making sure I nailed my camera was very important to me. The camera control hooks into the GameState and CursorState systems to ensure it’s only active during the correct game phases.

The GameState and CursorState systems are both ScriptableObject-based FSMs; each State has Entered() and Exited() events that game managers and other objects can subscribe to, such as deleting all objects OnNewGameStart, or changing the bottom buttons (not shown yet in these gifs) when the player clicks the OnPlayerPrepared button to start the enemy attack phase.

public abstract class GameState : ScriptableObject
{
    public event Action Entered, Exited;
    [SerializeField] private bool debug;

    public void Enter(GameData gameData)
    {
        if(gameData.CurrentState) gameData.CurrentState.Exit(gameData);
        
        gameData.CurrentState = this;

        if (debug) Debug.Log(this + "::Enter() (" + gameData.CurrentState + ")");
        OnEnter(gameData);
        Entered?.Invoke();
    }

    public virtual void Execute(GameData gameData)
    { }

    protected virtual void OnEnter(GameData gameData)
    { }

    private void Exit(GameData gameData)
    {
        if (debug) Debug.Log(this + "::Exit() (" + gameData.CurrentState + ")");
        OnExit(gameData);
        Exited?.Invoke();
    }

    protected virtual void OnExit(GameData gameData)
    { }

    public void Return(GameData gameData)
    {
        gameData.PreviousState.Enter(gameData);
    }
}

By inheriting from the base state, strongly typed states can be defined that allow each state to have unique behavior and hold any necessary data. For example, the MainMenu state can automatically pause and resume the game when exited and entered:

[CreateAssetMenu (fileName = "State_MainMenu", menuName = "Game States/Main Menu", order = 51)]
public class MainMenuState : GameState
{
    private float oldTimeScale;
    
    protected override void OnEnter(GameData gameData)
    {
        base.OnEnter(gameData);
        
        oldTimeScale = Time.timeScale;
        Time.timeScale = 0;
    }

    protected override void OnExit(GameData gameData)
    {
        base.OnExit(gameData);
        Time.timeScale = oldTimeScale;
    }
}

In the next entry, I’ll cover how this same technique is used to define each of the states the game cursor can be in, which allows for straightforward, durable code flexible enough to handle Building, Moving, Selling, Upgrading, Hovering Over, and Clicking On the game’s Buildables.

2023-07-14T19:44:08-04:00July 10th, 2023|Categories: Devlog, General|Tags: , , , , , , , |2 Comments

About My Work

Phasellus non ante ac dui sagittis volutpat. Curabitur a quam nisl. Nam est elit, congue et quam id, laoreet consequat erat. Aenean porta placerat efficitur. Vestibulum et dictum massa, ac finibus turpis.

Recent Works

Recent Posts