D20 RPG – Targeting

“Rodents of Unusual Size? I don’t think they exist.” – Westley, The Princess Bride

Overview

In the previous lesson we made it so that the rat could only attack our hero if the hero was within reach. Our hero currently has no such restriction and can attack from anywhere, so we will need to fix that now. While we are at it, I will implement some new features. First, I will add another monster – this time a Giant Rat, to take advantage of our new Size mechanics. Then, we will be able to handle targeting with zero, one, or multiple targets. We will handle both target selection and filtering for valid targets.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

Giant Rat

Start out by duplicating the asset at Assets -> Objects -> EntityRecipe -> Rat and make the following changes on the duplicated asset:

  • Rename the asset to be “Giant Rat”
  • Enable Addressable
  • Modify the Combatant Asset Provider component’s Value to be “Giant Rat”
  • Modify the Size Provider component’s Value to be “Large”

Next, duplicate the prefab at Assets -> Prefabs -> Combatants -> Monsters -> Rat and make the following changes on it:

  • Rename the prefab to be “Giant Rat”
  • Enable Addressable
  • Inside the prefab, adjust the child GameObject “Character”:
    • Transform Position: (X: 1, Y: 2.5, Z: 0)
    • Transform Scale: (X: 2, Y: 2, Z: 2)
  • Inside the prefab, adjust the child GameObject “Shadow”:
    • Transform Position: (X: 1, Y: 0.5, Z: 0)
    • Transform Scale: (X: 1, Y: 1, Z: 1)
  • Inside the prefab, adjust the child GameObject “Combatant UI”:
    • Transform Position: (X: 0.5, Y: 0, Z: 0)

Finally, open the asset, Assets -> Objects -> Encounters -> Encounter_01. Add a second monster spawn entry with an asset name of “Giant Rat” and a position of “X: 4, Y: 1”. If you did everything right, your next battle will have a new enemy to face – one that occupies a 2×2 section of the board!

Rodent of unusual size

Entity Filter System

When we check whether or not we “can” use the attack option, we already have some simple handling in place such as making sure an opponent entity is within reach. It’s not a bad start, but now that we have more than one monster, we will need a bit more. For example, after we defeat a monster, we don’t need to attack it any more.

It would also be nice to make something reusable for any kind of action, including actions that target allies rather than opponents (like heal), or that target the dead rather than the living (like revive).

Create a new script at Scripts -> Component named EntityFilterSystem and add the following:

using System;
using System.Collections.Generic;

[Flags]
public enum EntityFilter
{
    None = 0,
    Living = 1 << 0,
    Dying = 1 << 1,
    Dead = 1 << 2,
    Opponent = 1 << 3,
    Ally = 1 << 4
}

public interface IEntityFilterSystem : IDependency<IEntityFilterSystem>
{
    List<Entity> Apply(EntityFilter filter, Entity entity, List<Entity> entities);
}

public class EntityFilterSystem : IEntityFilterSystem
{
    public List<Entity> Apply(EntityFilter filter, Entity entity, List<Entity> entities)
    {
        List<Entity> result = new List<Entity>();
        foreach (var candidate in entities)
        {
            if (filter.HasFlag(EntityFilter.Living) && candidate.HitPoints <= 0)
                continue;
            if (filter.HasFlag(EntityFilter.Dying) && candidate.Dying <= 0)
                continue;
            // TODO: Dead
            if (filter.HasFlag(EntityFilter.Opponent) && candidate.Party == entity.Party)
                continue;
            if (filter.HasFlag(EntityFilter.Ally) && candidate.Party != entity.Party)
                continue;
            result.Add(candidate);
        }
        return result;
    }
}

public static class EntityFilterExtensions
{
    public static List<Entity> Apply(this EntityFilter filter, Entity entity, List<Entity> entities)
    {
        return IEntityFilterSystem.Resolve().Apply(filter, entity, entities);
    }
}

I started by declaring a new enum named EntityFilter - it is marked with the "Flags" attribute so that I can treat it as a bit mask. This means I can have a single variable that can hold any combination of those named elements, such as a "Living Opponent" or a "Dead Ally".

Next I added a system that can apply the filter to a List of Entities with respect to another Entity - this is important for "Party" based filtering. The implementation of the filtering process looks at each flag, and when enabled, will verify that a candidate matches the requirement. So when I mark a filter with having the "Living" flag, then a candidate must have HitPoints, or they will be skipped.

For convenience I also added an extension on the EntityFilter so I could use the system method without needing to know about the system. See below for a comparison of using the feature directly, vs by the extension:

// Use directly:
var result = IEntityFilterSystem.Resolve().Apply(filter, entity, entities);

// Use by extension:
var result = filter.Apply(entity, entities);

Don't forget to inject our new system in the ComponentInjector:

IEntityFilterSystem.Register(new EntityFilterSystem());

Entity Selection System

For the user experience, rather than having to manually move the cursor around tiles to select a target, I would like a new system that automatically moves the cursor to the location of entities in the list. In addition to being a little faster, it also allows for special handling of tiny units. They can potentially occupy the same tile as other units, and so our current selection process would fail to handle this when merely selecting a tile location.

Create a new script at Scripts -> SoloAdventure -> Encounter named EntitySelectionSystem and add the following:

using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using System;

public interface IEntitySelectionSystem : IDependency<IEntitySelectionSystem>
{
    UniTask<Entity> Select(List<Entity> list);
}

public class EntitySelectionSystem : IEntitySelectionSystem
{
    public async UniTask<Entity> Select(List<Entity> list)
    {
        if (list == null || list.Count == 0)
            return new Entity(0);

        var selection = 0;
        Mark(list[selection]);

        var input = IInputSystem.Resolve();
        while (true)
        {
            await UniTask.NextFrame();
            if (input.GetKeyUp(InputAction.Confirm))
                break;

            var hor = input.GetAxisUp(InputAxis.Horizontal);
            var ver = input.GetAxisUp(InputAxis.Vertical);
            if (hor == 0 && ver == 0)
                continue;

            if (hor > 0 || ver > 0)
                selection++;
            else if (hor < 0 || ver < 0)
                selection--;

            if (selection < 0) selection += list.Count;
            if (selection >= list.Count) selection -= list.Count;

            Mark(list[selection]);
        }
        return list[selection];
    }

    void Mark(Entity entity)
    {
        var indicator = ICombatSelectionIndicator.Resolve();
        indicator.Mark(entity);
    }
}

You give this system a List of Entity and it will observe input to automatically change between them. The task completes when you provide any kind of "Confirm" input, at which point it will return the Entity that is currently marked.

Don't forget to inject the new system in the SoloAdventureInjector:

IEntitySelectionSystem.Register(new EntitySelectionSystem());

Combat Selection Indicator

Open the script at Scripts -> UI -> CombatSelectionIndicator. Add the following to the interface:

void SetSpace(int value);
void Mark(Entity entity);

Then add the following to the class:

public void SetSpace(int tiles)
{
    transform.localScale = new Vector3(tiles, tiles, tiles);
}

public void Mark(Entity entity)
{
    SetPosition(entity.Position);
    SetSpace(entity.Size.ToTiles());
}

We can use "SetSpace" to adjust the scale of the UI element so that it reflects a selection that is larger than a single tile - this is necessary for anything with a Size larger than Medium.

The "Mark" method conveniently applies both the "SetPosition" and "SetSpace" methods based on the relevant properties of the provided Entity. You may notice that I used an extension on "Size" to convert it to a number of tiles. You can add the following extension class to the SpaceSystem script file:

public static class SpaceSizeExtensions
{
    public static int ToTiles(this Size size)
    {
        return ISpaceSystem.Resolve().SpaceInTiles(size);
    }
}

Open the HeroActionFlow script and replace the line where we use "SetPosition" with the following:

// Replace this:
ICombatSelectionIndicator.Resolve().SetPosition(hero.Position);

// With this:
ICombatSelectionIndicator.Resolve().Mark(hero);

Now if the combat selection indicator had most recently marked a large creature, like our new Giant Rat, then when it comes time to show it on our Hero, it will shrink back to the correct scale.

Solo Adventure Attack

Open the script at Scripts -> SoloAdventure -> Encounter -> SoloAdventureAttack. We will be making a few changes here. First, we won't need "Linq" anymore so you can replace that "using" statement with the following:

using System.Collections.Generic;

Then we will add a new serialized field to the class:

[SerializeField] EntityFilter targetFilter;

We will use the new targetFilter in the CanPerform method. Replace the current version with the following:

public bool CanPerform(Entity entity)
{
    return targetFilter.Apply(entity, ITurnSystem.Resolve().InReach).Count > 0;
}

We will also update the SelectTarget method to pick from the new filtered targets. Note that the definition was updated so we could pass the list of targets to it. Replace the current version with the following:

async UniTask<Entity> SelectTarget(Entity entity, List<Entity> targets)
{
    if (entity.Party == Party.Monster)
        return targets[0];
    else
        return await IEntitySelectionSystem.Resolve().Select(targets);
}

In this version, we maintain a very simple A.I. which merely attacks the first valid target in the list. For human controlled players, we use our new Entity Selection System to pick from the available targets.

Next we add/replace the following at the very beginning of the Perform method:

// This part is new
var targets = targetFilter.Apply(entity, ITurnSystem.Resolve().InReach);
if (targets.Count == 0)
    return;

// This part is replaced
var attacker = entity;
var target = await SelectTarget(entity, targets);

I haven't added a way to filter the action menu yet, so for now, this bit of code will just early exit an attack if it is chosen without targets nearby. We can always implement better handling later.

Next, In the folder Assets -> Objects -> CombatAction, for both "Bite" and "Strike (Shortsword), we need to specify the new "Target Filter" value. Check both "Living" and "Opponent" for both action assets.

Back From The Dead

Now that we have multiple opponents, our entity filter has kept us from attacking them after they have been killed. However, you may notice that the reverse is an issue - after we have killed an opponent, they can still attack us! We will eventually need a better solution that works for both monsters and heroes, and which can work for a variety of statuses, but for now we can solve this simply so we can try out our progress.

Open the MonsterActionFlow script and modify the condition to the following:

if (action.CanPerform(current) && current.HitPoints > 0)

With this simple check in place, a KO'd monster will have its turn skipped. I will point out once more that this is just a temporary fix for the sake of playing a better demo. So try it out! Play the game from the LoadingScreen scene like normal. Try attacking from out of range and verify that nothing happens. Make sure you can attack the huge rat from any adjacent tile, or stand where you can reach both rats and see how the entity selection feature is working.

Summary

In this lesson we limited the hero to only be able to attack whatever is within his reach. We improved the attack targeting so that it would only pick from living opponents, and we also added a new UI experience so that you can pick between targets in range rather than having to select tiles.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *