onewinter games

brooklyn-based indie game studio

Clockwork TD Devlog #8: Enemies

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

Creating simple enemies that can combine to create complex problems for the player to solve is the aim of most Tower Defense games. For Clockwork TD, I looked at a number of games, including Rogue Tower, Tower Tactics: Liberation, and even the recent Axon TD: Uprising to come up with inspiration for the different skills and conditions that enemies can utilize. To standardize the different conditions that could trigger an Enemy Action, I started with an enum:

public enum EnemyActionTypes
{
    Spawn = 0,
    Tick = 1,
    Damage = 2,
    FirstDamage = 3,
    NearTower = 4,
    NearDeath = 5,
    Death = 6
}

Each master Enemy type contains a UDictionary (code here) keyed to EnemyActionType; and containing an EnemyAction:

public abstract class EnemyAction : ScriptableObject
{
    public string UIDescription;
    
    [SerializeField] protected float tickLength;
    
    public virtual void RunAction(Enemy enemy){ }
}

This can be used to create all sorts of behaviour via inheritance, such as running an action on the caster:

public abstract class EnemyActionSelf : EnemyAction
{
    [SerializeField] protected GameFxSetup casterFx;

    public override void RunAction(Enemy enemy)
    {
        if (enemy.ActionTimer < tickLength) return;
        
        enemy.ActionTimer = 0;

        TickSelf(enemy);

        if(casterFx) casterFx.SpawnNewFx(enemy.ColliderCenter, enemy.transform);
    }
    
    protected abstract void TickSelf(Enemy enemy);
}

or running an action on an entire group around the caster:

public abstract class EnemyActionGroup : EnemyAction
{
    [SerializeField] protected float effectRadius;
    [SerializeField] protected GameFxSetup casterFx;
    
    [SerializeField] protected LayerMask enemyLayerMask;

    public override void RunAction(Enemy enemy)
    {
        if (enemy.ActionTimer < tickLength) return;
        
        enemy.ActionTimer = 0;

        var enemies = Physics.OverlapSphere(enemy.transform.position, effectRadius, enemyLayerMask);
        foreach (var collider in enemies)
        {
            if (!collider.TryGetComponent(out Enemy nearbyEnemy)) continue;

            TickAffectedEnemy(nearbyEnemy);
        }

        if(casterFx) casterFx.SpawnNewFx(enemy.ColliderCenter, enemy.transform);
    }

    protected abstract void TickAffectedEnemy(Enemy enemy);
}

or even simply dropping currency as a reward:

public class EnemyActionDropCurrency : EnemyAction
{
    [SerializeField] private GameData gameData;
    [SerializeField] private UDictionary<Currencies, int> bonuses;
    [SerializeField] private bool multiplyByRound;
    [SerializeField] private GameFxSetup gameFxSetup;

    public override void RunAction(Enemy enemy)
    {
        foreach (var (key, value) in bonuses)
        {
            var newValue = multiplyByRound ? gameData.GameCounters.Round.value * value : value;
            gameData.GameCounters.CountersDict[(GlobalCounters)key].value += newValue;
        }

        if (gameFxSetup) gameFxSetup.SpawnNewFx(enemy.ColliderCenter, enemy.transform);
    }
}

In the Enemy class, we refer to the master enemy setup (the TypeObject) to look up if we have an EnemyAction to run when certain events occur:

    private void OnDamage()
    {
        if (TypeObject.ActionsDict.TryGetValue(EnemyActionTypes.Damage, out var action)) action.RunAction(this);
        if (damaged) return;

        damaged = true;
        if (TypeObject.ActionsDict.TryGetValue(EnemyActionTypes.FirstDamage, out var faction)) faction.RunAction(this);
    }

    private void OnSpawn()
    {
        if (TypeObject.ActionsDict.TryGetValue(EnemyActionTypes.Spawn, out var action)) action.RunAction(this);
    }

    private void OnNearDeath()
    {
        nearDeath = true;
        if (TypeObject.ActionsDict.TryGetValue(EnemyActionTypes.NearDeath, out var action)) action.RunAction(this);
    }

All of the Enemies are laid out in Notion, with their stats, special abilities, and the models I’ve chosen for them (from the awesome Quaternius’ Ultimate Monsters Pack):

At some point, as continually balancing becomes more important, I may write a utility to import these values into the game from an Excel version of the file, but there are only 20 enemies still, so it’s not a huge lift to make the changes manually. Portraits are rendered using a simple scene set up specially for it in Blender with a tuned Camera and Overhead Lighting for depth:

Enemies are laid out in a separate test scene in Unity to ensure their model is sized and positioned appropriately when attached to the base enemy prefab:

Finally, the ScriptableObject representing the Enemy is filled out using the Notion stats, Blender portrait, and Model tweaked in the test scene:

Here’s the final result in action, with the red Rushnub applying Haste as it gets Near the Tower and the blue Wallfish applying Fortify.

Leave a Reply

Mastodon

Discover more from onewinter games

Subscribe now to keep reading and get access to the full archive.

Continue reading