Clockwork TD Devlog #4: Unlocks & Dynamic Build Menus

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

One thing that I knew would be of huge importance before I even started designing my game was the UI. Many games in the Tower Defense genre focus on depth and breadth of systems in lieu of a coherent UI, which makes no sense — tower defense games are meant to suck the player in and keep them hitting the Next Turn button, so why would you not concentrate on creating as frictionless an experience for your player as possible? As such, I knew that designing the UI had to be a major part of the game, and not a bolted on afterthought. This sort of thinking is what led me use FSMs for GameState and CursorState, to ensure that I have the utmost control over the player’s game experience at any given time. My UI and my GDD were created in a back-and-forth iterative process, as I worked out gameplay ideas on paper and then tried to imagine how they’d translate to a UI in Figma.

By taking the time to create a full style & color guide from the beginning, it allowed me to very easily combine building blocks in Figma to create a coherent UI language.

Like the Command Manager, the Unlock Manager is another ScriptableObject, as it has no per-frame logic to execute. Unlockables are another abstract ScriptableObject class; by providing virtual methods for OnUnlock and OnReset, we can override UnlockableBase to Unlock all sorts of objects in the game, whether a Damage Bonus or New Buildable Tower:

public abstract class UnlockableBase : ScriptableObject
{
    public UnlockableBase LockedBy;
    public bool Unlocked;
    [SerializeField] private bool unlockedToStart;

    public bool Unlockable => !Unlocked && (!LockedBy || (LockedBy && LockedBy.Unlocked));
    
    protected abstract void OnUnlock();
    protected abstract void OnReset();
    
    public void UnlockItem(bool force = false)
    {
        if (!force && !Unlockable) return;
        
        Unlocked = true;
        OnUnlock();
    }

    public void ResetItem()
    {
        Unlocked = unlockedToStart;
        OnReset();
    }

    private void OnEnable()
    {
        ResetItem();
    }
}
public class UnlockableBuildable : UnlockableBase
{
    [SerializeField] private BuildableSetup buildableSetup;

    protected override void OnUnlock()
    {
        buildableSetup.Unlock(true);
    }

    protected override void OnReset()
    {
        buildableSetup.Unlock(Unlocked);
        buildableSetup.Reset();
    }
}
public class UnlockableTowerBonus : UnlockableBase
{
    [SerializeField] private BuildableSetup buildableSetup;
    [SerializeField] private BonusTypes bonusType;
    
    protected override void OnUnlock()
    {
        switch (bonusType)
        {
            case BonusTypes.Power:
                buildableSetup.PowerBonus.initial.value++;
                break;
            case BonusTypes.Range:
                buildableSetup.RangeBonus.initial.value++;
                break;
            case BonusTypes.Speed:
                buildableSetup.SpeedBonus.initial.value++;
                break;
            case BonusTypes.Health:
                buildableSetup.HealthBonus.initial.value++;
                break;
            case BonusTypes.Armor:
                buildableSetup.ArmorBonus.initial.value++;
                break;
            case BonusTypes.Shield:
                buildableSetup.ShieldBonus.initial.value++;
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    protected override void OnReset()
    {
        buildableSetup?.Reset();
    }
}

This lets us query our list of Unlockables to find out what’s Unlocked, what’s Unlockable, and what’s still Locked; which can then be used to create the build menu using UXML classes that inherit from Button to pass data:

void ToggleBuildMenu(ClickEvent evt, BuildableType buildableType)
{
	currentBuildableType = buildableType;
	secondContainer.Clear();
	buildableButtons = new List<BottomButton>();
	foreach (var buildableSetup in buildableType.GetBuildables(gameData.BuildableSetups))
	{
		var newButton = new BottomButton();
		newButton.Init(buildableSetup);
		
		newButton.AddClickEvent(BuildableButtonClicked, buildableSetup);
		if(!buildableSetup.Unlocked) newButton.SetEnabled(false);
		
		buildableButtons.Add(newButton);
		secondContainer.Add(newButton);
	}
	buildableTypeButtons.ForEach(b => b.EnableInClassList("active", false));
	buildableTypeButtons.FirstOrDefault(b => b.ButtonType == buildableType)?.EnableInClassList("active", true);
}

This same data can also be used to unlock new cards at the end of every round (more on this in a later post, covering card pack minimum rarity levels):

The damage numbers here showing off the game’s first splash damage projectile are created with a TMPro Billboard shader I found on github, combined with the rising number effect from Accumulus. The UnlockManager calls Reset on all Unlockables at the start of each new game, ensuring that all Unlockable items start each new game fresh.

By focusing on the end result I wanted the gameplay & UI experiences to have early on in the process, I was able to use my GDD and Figma to create a coherent, functional UI in my game even this early in the development process. Come back next time for info on terrain bonuses and the enemy wave info screen!