D20 RPG – Pathfinding

“If you find a path with no obstacles, it probably doesn’t lead anywhere.” – Frank A. Clark

Overview

In my first blog project, the Tactics RPG, I included a lesson on pathfinding over a 3D board. In that project, we had some unique challenges thanks to a non-square board where tiles were optional. In addition we had to consider tile heights – and whether or not a unit could jump as high as needed. Finally I implemented different movement types such as walk, fly and teleport. If those topics sound interesting, you might like to read about it here.

In contrast, the pathfinding in this lesson will be for a 2D square board with no missing tiles. However, it will include one of most requested features that the previous lesson was missing – tiles of different terrain types, and how that can affect move cost. In addition, the pathfinding rules for Pathfinder are far more complex and varied than those found in games like Final Fantasy Tactics.

You can read more about Pathfinder’s movement rules here. Some of the mechanics include:

  • Various movement actions: stride, step, swim, climb and jump
  • Feat and magic movements: burrow and fly
  • Tiles with different move costs:
    • Difficult terrain (for land)
    • Strong currents (for water)
  • Diagonal movement rules
  • Creatures of different sizes:
    • large monster would occupy 10x10ft (2×2 squares)
    • tiny creatures can share tiles with other tiny or larger creatures (up to 4 tiny per square)
  • Can move through willing creature’s spaces
  • Can attempt to tumble through unwilling creature’s spaces

While I would love to solve for each of these features in one fell swoop, I think I will take a more iterative approach. For the features that we do add, I will take extra steps to make sure that they are composable in hopes that it will be easier to add the extra features over time.

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.

Next, download and unzip then import this package. It holds a single sprite that is filled with white pixels. I created a tile from it, which we can use as a tile highlighter to show the movement range of our hero.

Board Highlight System

Let’s start out by making a system that will highlight tiles. We will accomplish this by using a second tile map that renders on top of the board tile map. The tiles that it displays will be the simple white tile we imported in the unity package, but the tile map itself will use a tint color that is partially transparent. The system itself will be responsible for setting or clearing the tiles to this tilemap as needed.

Create a new C# script at Scripts -> Board named BoardHighlightSystem and add the following:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public interface IBoardHighlightSystem : IDependency<IBoardHighlightSystem>
{
    void Highlight(List<Point> points, Color color);
    void ClearHighlights();
}

public class BoardHighlightSystem : MonoBehaviour, IBoardHighlightSystem
{
    [SerializeField] TileBase highlight;
    Tilemap tilemap;

    public void Highlight(List<Point> points, Color color)
    {
        ClearHighlights();
        foreach (Point point in points)
            tilemap.SetTile(new Vector3Int(point.x, point.y, 0), highlight);
        tilemap.color = color;
    }

    public void ClearHighlights()
    {
        tilemap.ClearAllTiles();
        tilemap.color = Color.white;
    }

    private void OnEnable()
    {
        tilemap = GetComponent<Tilemap>();
        IBoardHighlightSystem.Register(this);
    }

    private void OnDisable()
    {
        IBoardHighlightSystem.Reset();
    }
}

Here I have created a MonoBehaviour based dependency, so I can use OnEnable and OnDisable to register and reset the dependency as needed. In addition, I use OnEnable to grab a reference to the Tilemap that we will be using to set the highlight tiles on. Note that the other field, for the tile, will need to be assigned in the inspector.

There are two simple methods on the system interface, one for setting tiles that should be highlighted, and one for clearing all the highlight tiles.

Grid Prefab

Next, we will modify the prefab that holds the game board. Select the Assets -> Prefabs -> Grid and then “Open” it for editing. Select the Tilemap GameObject and duplicate it. On the duplicated object, look in the Inspector at the Tilemap Renderer and then set the Order in Layer to “-99”. Then delete the BoardSystem script. Finally add the BoardHighlightSystem. Assign the Highlight tile (imported in our package) to the Highlight field of the system. Save the changes.

Speed System

At the moment our hero can stride anywhere on the board. Even if he had to move around obstacles, there is little value in pathfinding when range is not limited in some way. Pathfinder uses a stat called “Speed” that is used to determine how far a hero or monster can go while using a move related action.

By default a single tile on our board represents a “movement cost” of 5 (feet). So a combatant with a speed of 30 (feet) could move across 6 normal squares in a stride action. Tiles that are considered difficult terrain cost an additional 5 feet per tile, so the same combatant could only cross 3 such tiles in a single stride action.

Create a new C# script in Scripts -> Component named SpeedSystem and add the following:

public partial class Data
{
    public CoreDictionary<Entity, int> speed = new CoreDictionary<Entity, int>();
}

public interface ISpeedSystem : IDependency<ISpeedSystem>, IEntityTableSystem<int>
{

}

public class SpeedSystem : EntityTableSystem<int>, ISpeedSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.speed;
}

public partial struct Entity
{
    public int Speed
    {
        get { return ISpeedSystem.Resolve().Get(this); }
        set { ISpeedSystem.Resolve().Set(this, value); }
    }
}

This is the standard pattern we have been using, to add game data, and an Entity Table System to manage it. For consistency, we will also update the ComponentInjector‘s Inject, SetUp, and TearDown methods for our new system:

// Inject
ISpeedSystem.Register(new SpeedSystem());

// SetUp
ISpeedSystem.Resolve().SetUp();

// TearDown
ISpeedSystem.Resolve().TearDown();

Speed Provider

We will use a Provider to assign an initial speed stat to our Hero and Monster. Create a new C# script at Scripts -> AttributeProvider named SpeedProvider and add the following:

using UnityEngine;

public class SpeedProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] int value;

    public void Setup(Entity entity)
    {
        entity.Speed = value;
    }
}

Entity Recipe

Now we need to add our new provider script to the assets found at Assets -> Objects -> EntityRecipe. Add a SpeedProvider to both assets. Give the “Hero” a speed “value” of 30, and the “Rat” a speed value of 20.

Board System

Let’s add a couple of methods to the board system. First, a method to verify that a given point is “on” the board, and second, to be able to get the “type” of tile which was used to randomly place tiles from our skin. We can use the same value to determine the difference between the normal ground and difficult terrain, and also what is not walkable (the water and mountain types).

Add the following to the IBoardSystem interface:

bool IsPointOnBoard(Point point);
int GetTileType(Point point);

Then implement them in the BoardSystem class:

public bool IsPointOnBoard(Point point)
{
    return point.x >= 0 && point.y >= 0 &&
        point.x < BoardData.width && point.y < BoardData.height;
}

public int GetTileType(Point point)
{
    var index = point.y * BoardData.width + point.x;
    return BoardData.tiles[index];
}

Path Node

Our path finding algorithm will create "nodes" that hold information about what "path" is used to reach any given position on our board. Create a new C# script at Scripts -> Board named PathNode and add the following:

public class PathNode
{
    public Point point;
    public int moveCost;
    public bool diagonalActive;
    public PathNode previous;

    public PathNode(Point point, int moveCost, bool diagonalActive, PathNode previous)
    {
        this.point = point;
        this.moveCost = moveCost;
        this.diagonalActive = diagonalActive;
        this.previous = previous;
    }
}

This is just a simple model object. The fields are for tracking purposes, and are as follows:

  • point - The position on the board that this node represents
  • moveCost - The amount of the "Speed" stat that is required to reach the given point
  • diagonalActive - In pathfinder, diagonal movement is allowed. The first diagonal move costs the same as normal, but the second costs an extra 5 feet of speed, then it repeats. This flag is used to track this alternating cost.
  • previous - The node leading to this node in the path from the starting position

Path Map

When you have a PathNode for all the tiles on the board (or at least for all tiles within a desired range) then you have a "map" of all the reachable locations starting from a specified point on the board. You will not only know what you can reach, but will also know the most optimal path to reach it, and how much it costs to move there. This "collection" of nodes will be called a PathMap. Create a new C# script at Scripts -> Board named PathMap and add the following:

using System.Collections.Generic;

public interface IPathMap
{
    List<Point> GetPathToPoint(Point point);
    List<Point> AllPoints();
}

public class PathMap : IPathMap
{
    Dictionary<Point, PathNode> map;

    public PathMap(Dictionary<Point, PathNode> map)
    {
        this.map = map;
    }

    public List<Point> GetPathToPoint(Point point)
    {
        List<Point> result = new List<Point>();
        var node = map.ContainsKey(point) ? map[point] : null;
        while (node != null)
        {
            result.Add(node.point);
            node = node.previous;
        }
        result.Reverse();
        return result;
    }

    public List<Point> AllPoints()
    {
        return new List<Point>(map.Keys);
    }
}

The interface, IPathMap defines two simple methods. The first, GetPathToPoint, will return a "Path" from the starting position that the map was generated with, to the point that the user requests. If the map doesn't contain a node for the requested position, then we will simply return an empty list of points - such a tile was either out of range, or was not traversable (either because of the type of terrain, or because of obstacles etc). This method works by getting a "PathNode" based on the "point" parameter, and then in a loop it adds that node's "previous" node until some node no longer has a previous node - that node would be the start tile. Once I have reached that point, I can reverse the list, and now have the Path in order.

The second method, AllPoints, simply returns all the Point positions that the map has reached. This will be used in combination with my board highlight system to show all tiles within reach of a move action. It will make it easier for a human player to know what moves are considered legal.

Pathfinding System

When you hear the topic of pathfinding, you might expect to see the A* (pronounced "A-Star") algorithm. It is ideal for a scenario where you specifically want to go from one point to another, while only examining the least amount of tiles possible. If that is what you want to do, then it is a great algorithm to use. The use-case for pathfinding in my game is a little different though. Imagine the following scenarios:

  • When we choose the stride action, I want to highlight all of the tiles within reach of the hero. This will make it easy for the player to pick a tile that can be reached.
  • When I start implementing A.I. I will want to be able to make informed decisions about how to reach target locations on the board. Having the whole board pre mapped out will enable me to make quick comparison decisions, like "what all opponents can I reach?" Or "what is the nearest opponent by actual traversable path, rather than by a simple linear distance?"

In both of the above examples, I do not want to seek from one point to another. Rather, I want to seek from one point to many other points - often to every reachable point within a specified range. It is more like doing a flood fill in a graphics program, and gives a basic understanding of the approach our pathfinding algorithm will be taking.

Create a new C# script at Scripts -> Board named PathfindingSystem and add the following:

using System.Collections.Generic;

public interface ITraverser
{
    bool TryMove(Point fromPoint, Point toPoint, out int cost);
}

public interface IPathfindingSystem : IDependency<IPathfindingSystem>
{
    IPathMap Map(Point start, int range, ITraverser traverser);
}

These two interfaces show how I hope to compose all the possible logic related to the various complex rules of pathfinding. The first interface, ITraverser, is responsible for knowing what moves are legal from one point on the board to another, and how much it would cost to make the move. In this case, a "Traverser" will not be a one-size fits all kind of system. Rather, there will be a traverser for specific scenarios like a land based traverser which knows about what tiles are considered walkable, and how some terrain might be considered "difficult". I could have separate traverser objects for swimming, or flying etc. In addition, I may need to make compound traversers so that I can do things like determine whether I want to treat enemies as obstacles that affect the path I am looking for.

The second interface, IPathfindingSystem queries the first interface to do our "flood" search outward from a "start" point until it either runs out of tiles it can reach or exhausts the specified "range". The result of running this second system will be an output IPathMap which holds information for all the possible routes that were found.

Add the following:

public class PathfindingSystem : IPathfindingSystem
{
    Point[] offsets = new Point[]
    {
        new Point(0, 1),
        new Point(1, 0),
        new Point(0, -1),
        new Point(-1, 0),
        new Point(1, 1),
        new Point(1, -1),
        new Point(-1, -1),
        new Point(-1, 1)
    };

    public IPathMap Map(Point start, int range, ITraverser traverser)
    {
        List<Point> checkNow = new List<Point>();
        HashSet<Point> checkNext = new HashSet<Point>();
        Dictionary<Point, PathNode> map = new Dictionary<Point, PathNode>();
        map[start] = new PathNode(start, 0, false, null);
        checkNow.Add(start);

        while (checkNow.Count > 0)
        {
            foreach (var point in checkNow)
            {
                var node = map[point];
                foreach (var offset in offsets)
                {
                    var nextPoint = point + offset;

                    int moveCost;
                    if (!traverser.TryMove(point, nextPoint, out moveCost))
                        continue;

                    var isDiagonal = offset.x != 0 && offset.y != 0;
                    var diagonalPenalty = isDiagonal && node.diagonalActive;
                    var diagonalActive = isDiagonal ? !node.diagonalActive : node.diagonalActive;
                    if (diagonalPenalty)
                        moveCost += 5;

                    moveCost += node.moveCost;
                    if (moveCost > range)
                        continue;

                    if (!map.ContainsKey(nextPoint))
                    {
                        map[nextPoint] = new PathNode(nextPoint, moveCost, diagonalActive, node);
                        checkNext.Add(nextPoint);
                    }
                    else if (moveCost < map[nextPoint].moveCost)
                    {
                        map[nextPoint].moveCost = moveCost;
                        map[nextPoint].diagonalActive = diagonalActive;
                        map[nextPoint].previous = node;
                        checkNext.Add(nextPoint);
                    }
                }
            }

            checkNow.Clear();
            checkNow.AddRange(checkNext);
            checkNext.Clear();
        }
        return new PathMap(map);
    }
}

Here we have the class PathfindingSystem to implement the IPathfindingSystem interface. It defines an array of "offsets" - an array of points that represent a movement in each of the eight directions you can move (4 cardinal and 4 diagonal). I list the 4 cardinal directions first, because they are the "ideal" way to reach tiles in a path since they do not incur a diagonal movement penalty. Ideally a diagonal movement will only be used in special cases to be able to reach just a little further than would normally be possible.

Next, take a look at the Map method. It is a bit complicated to understand because there are multiple nested loops. Let's break the whole thing down a little at a time and hopefully it will make sense. I showed the class in its entirety so it is easier to see how it all fits together and so you can just copy and paste it. The following snippets are from the above example - you don't need to add them again, they are just for reference. First, we will examine the local fields:

List<Point> checkNow = new List<Point>();
HashSet<Point> checkNext = new HashSet<Point>();
Dictionary<Point, PathNode> map = new Dictionary<Point, PathNode>();
  • checkNow - a list of points representing tiles on the board that we are currently evaluating. Initially, this will only hold the "start" point. Then you can imagine it will hold "layers" of points - those surrounding the start point, then the points surrounding those points and so on. If this list is ever empty, then we have completed the search.
  • checkNext - a set of points that are adjacent to whatever point(s) we are currently evaluating. We put them in a "set" rather than a "list" because a "set" requires its elements to be unique. In other words, I can attempt to "insert" the same point more than once but it will still only hold one of that point. This will help prevent us from re-evaluating the same point too many times.
  • map - a dictionary that lets us look up a "node" for a point on the board.
map[start] = new PathNode(start, 0, false, null);
checkNow.Add(start);

We assign the start point a node that has zero move cost and a "null" previous node which helps identify this node as the start position. We also add the start point to the "checkNow" list to sort of prime the pump of our loop.

while (checkNow.Count > 0)
{
    // ... Inner loops here ...

    checkNow.Clear();
    checkNow.AddRange(checkNext);
    checkNext.Clear();
}

The outer most loop is the "while" loop. The looping condition here is that the list of points "checkNow" is not empty. When the list IS empty, then our search is complete. Within the body of the loop, after having completed the nested loops, we will clear the list of points in "checkNow" then re-fill it will whatever points were held in the "checkNext" set. We will then also clear the "checkNext" set so we are ready for another "layer" of exploration.

foreach (var point in checkNow)
{
    var node = map[point];
    // ... Inner loop here ...
}

The next inner loop is a "foreach" loop that will iterate over every point in the "checkNow" list. At this level we grab a reference to the "node" for that point. This will tell us how much of a move cost has been used so far. The starting tile will not have any move cost, but each consecutive "layer" of points will have a higher move cost, usually from 5 to 10, then 15 etc.

foreach (var offset in offsets)
{
    var nextPoint = point + offset;
    // ... logic for "next" point here ...
}

The third loop is a "foreach" loop over each offset (the 8 directions you can move from any given position). We add the offset to the position of the node that is being checked "now" to get a position that will be checked "next". Then we perform a series of checks on each of the "next" points.

int moveCost;
if (!traverser.TryMove(point, nextPoint, out moveCost))
    continue;

Now we define a local "moveCost" that needs to be initialized in our traverser. We call the TryMove method using the information we have accumulated so far, including what point we are moving from, what point we are moving to, and an "out" reference to our "moveCost". The method actually returns a "bool" which indicates whether we can move over the tile at the specified tile. If not, then we will skip this point.

var isDiagonal = offset.x != 0 && offset.y != 0;
var diagonalPenalty = isDiagonal && node.diagonalActive;
var diagonalActive = isDiagonal ? !node.diagonalActive : node.diagonalActive;
if (diagonalPenalty)
    moveCost += 5;

Next you see the "isDiagonal" flag - this is based on the current "offset" that we are looking at. If this is a diagonal movement, and the node we are moving from has already used a diagonal movement in its path, then we will need to apply a "penalty" to the cost of this additional diagonal movement. Finally, we have "diagonalActive" - if our current offset is a diagonal movement, then we will toggle the value from whatever the previous node held. Otherwise, we will persist the value that the previous node held. That way, even if it was several tiles ago that a diagonal was used, it will still carry through.

moveCost += node.moveCost;
if (moveCost > range)
    continue;

To get the "total" move cost, we now add the cost that had already accumulated in the node. If the total is greater than our allowed range, we will skip this point.

if (!map.ContainsKey(nextPoint))
{
    map[nextPoint] = new PathNode(nextPoint, moveCost, diagonalActive, node);
    checkNext.Add(nextPoint);
}

Next we check to see if our "map" holds an entry for a given "next" point, and if not, then we add one using all of the values we have calculated so far. We also specify the newly added position as a position that should be added to our "checkNext" set of points so that pathfinding can continue from there.

else if (moveCost < map[nextPoint].moveCost)
{
    map[nextPoint].moveCost = moveCost;
    map[nextPoint].diagonalActive = diagonalActive;
    map[nextPoint].previous = node;
    checkNext.Add(nextPoint);
}

If the "map" did hold an entry for the given "next" point, then there is still a possibility we have found a different route that is more efficient. For example, maybe using a diagonal movement avoided some difficult terrain and so reached it with less of a move cost. In this special case, we update the node's data and will also mark the point as needing to be checked next. Even if we had already looked "forward" from that point before, we would need to look "forward" from there again using the more efficient route as a base.

If the node existed in our "map" and we didn't improve on the move cost, then we take no action, and it is NOT added to our set of points to check next. This keeps us from an infinite loop of checking the same points over and over again.

return new PathMap(map);

After having exhausted all reachable tiles, or our range, we will have exited all of the loops and will create and return a PathMap using the "map" Dictionary we had built up so far.

Board Injector

Until now all of the scripts in our Board folder haven't needed to be injected. Now we have one, so go ahead and create a new C# script at Scripts -> Board named BoardInjector and add the following:

public static class BoardInjector
{
    public static void Inject()
    {
        IPathfindingSystem.Register(new PathfindingSystem());
    }

    public static void SetUp()
    {
        IPathfindingSystem.Resolve().SetUp();
    }

    public static void TearDown()
    {
        IPathfindingSystem.Resolve().TearDown();
    }
}

Open the Injector script and make sure to plug in the various BoardInjector methods to it.

Land Traverser

Next we need to add our first "traverser" - something that understands a specific set of rules for a specific type of movement. We will make one that understands rules around navigating on land, such as for the "Stride" action. It will need to know what type(s) of tiles can be walked on, and which of those are considered "difficult".

Create a new C# script at Scripts -> Board named LandTraverser and add the following:

using System.Collections.Generic;

public struct LandTraverser : ITraverser
{
    const int landTile = 1;
    const int hillTile = 2;

    HashSet<Point> obstacles;

    public LandTraverser(HashSet<Point> obstacles)
    {
        this.obstacles = obstacles;
    }

    public bool TryMove(Point fromPoint, Point toPoint, out int cost)
    {
        var system = IBoardSystem.Resolve();
        var hitObstacle = obstacles != null && obstacles.Contains(toPoint);
        if (system.IsPointOnBoard(toPoint) && !hitObstacle)
        {
            var type = system.GetTileType(toPoint);
            if (type == landTile || type == hillTile)
            {
                cost = (type == landTile) ? 5 : 10;
                return true;
            }
        }
        cost = int.MaxValue;
        return false;
    }
}

When we created our example board, there were four tile-types used to build up our board data:

  • 0 - Water (or lava)
  • 1 - Land (normal walkable terrain)
  • 2 - Hill (or difficult terrain)
  • 3 - Mountain (or some impassable object like a wall)

In a more feature-rich game, you may wish for additional types, and you can add as many as you want. You may also wish to find ways to identify what each index represents in a way that can be shared among your code, such as an enum. For now, I simply added a couple of "const" fields to give names to the indices that our system cares about.

I also added a constructor that accepts a set of Point named "obstacles". The purpose of this set is so that we can pass the positions of obstacles that aren't based on the tile type. For example, if we are striding rather than tumbling, then we would prefer our pathfinder to try and go around enemy positions rather than to go through them. It is fine to pass a "null" set if preferred.

The TryMove method works closely with our IBoardSystem. It first verifies that the "toPoint" is actually a point on the game board, and also that we have either not provided a set of obstacle locations or that the "toPoint" is not in that set. Assuming we have met those conditions, we next query for the tile "type" from the board system. As long as we are on ground or hill, then the pathfinding can continue, although the hill is treated as difficult terrain and so incurs a greater cost.

For points falling outside the board, hitting an obstacle, or being the wrong type of terrain, we provide a movement cost of "int.MaxValue". Ultimately the movement cost in this case shouldn't matter because we return "false" and so the pathfinder should not move onto the tile regardless of cost.

Stride

At the moment, I think that the movement action itself is a good place to handle the flow of pathfinding, highlighting, etc. For example, even a character with wings, who could normally fly, may decide to "stride" while indoors or in a cave with low ceiling height. Therefore I would want that action to determine what kind of rules apply rather than the traits of the entity who used the action.

Open the Stride script and replace the Perform method with the following:

public async UniTask Perform(Entity entity)
{
    // TODO: differentiate between user-controlled and AI controlled entities

    // TODO: can pass dynamic obstacle positions, such as of enemy units
    var traverser = new LandTraverser(null);

    var map = IPathfindingSystem.Resolve().Map(
        entity.Position,
        entity.Speed,
        traverser);

    IBoardHighlightSystem.Resolve().Highlight(
        map.AllPoints(),
        new Color(0, 1, 1, 0.5f));

    var position = await IPositionSelectionSystem.Resolve().Select(entity.Position);
    IBoardHighlightSystem.Resolve().ClearHighlights();

    var info = new StrideInfo
    {
        entity = entity,
        path = map.GetPathToPoint(position)
    };

    await IStrideSystem.Resolve().Apply(info);
}

I create an instance of the LandTraverser (it's just a struct so its no concern) - for now I just pass "null" to the constructor, but I left a TODO indicating that it would be a good place to add the positions of enemy units as obstacles.

Next I obtain a "map" by calling the resolved pathfinding system's Map method. The "start" point for the pathfinding is the Entity's position. The "range" is the Entity's new speed stat, and the traverser will be the LandTraverser.

Next we grab the highlight system and tell it to highlight all of the positions found by the pathfinder. Since it was constrained to a range of the Entity's speed, we know that all of the tiles it contains are in range of the action.

After selecting a position (note that validation is still not implemented) we clear the highlights, and then pass the "Path" to the selected position in the "StrideInfo" (this requires changes to that struct - shown next).

Stride System

Open the StrideSystem script. We will need to modify the StrideInfo struct so that instead of a single "destination", we will pass the whole "path". There are two reasons to make this change - one is that there are rules around stride that come into place as you leave each tile (act of opportunity).

using System.Collections.Generic;

public struct StrideInfo
{
    public Entity entity;
    public List<Point> path;
}

The second reason is that it would be nice to see the character animate according to the actual traversal path, instead of tweaking in a straight line from the start point to the destination point. To obtain that result, we will also want to pass the path to the StridePresentationInfo. In the Present method, change its creation to look like this:

var presentInfo = new StridePresentationInfo
{
    entity = info.entity,
    path = info.path
};

The last change we need to make is to modify the Perform method, and how we assign the entity's Position:

entity.Position = info.path[info.path.Count - 1];

Stride Presenter

Open the StridePresenter script. We will need to modify the StridePresentationInfo struct so that it takes a path, instead of "from" and "to" positions:

using System.Collections.Generic;

public struct StridePresentationInfo
{
    public Entity entity;
    public List<Point> path;
}

Next, we will make a couple of changes to the StridePresenter class. Instead of a single tween from a start to end position, we will tween to each of the points along the path. We no longer need the "speedMultiplier" field, and will replace it with a "moveSpeed" (a tween duration per tile):

[SerializeField] float moveSpeed = 0.25f;

Finally we will change the Present method:

public async UniTask Present(StridePresentationInfo info)
{
    var view = IEntityViewProvider.Resolve().GetView(info.entity, ViewZone.Combatant);
    var combatant = view.GetComponent<CombatantView>();
    ICombatantViewSystem.Resolve().SetAnimation(combatant, CombatantAnimation.Walk);

    var previous = info.path[0];
    for (int i = 1; i < info.path.Count; ++i)
    {
        var next = info.path[i];
        await view.transform.MoveTo(next, moveSpeed).Play();
        previous = next;
    }

    ICombatantViewSystem.Resolve().SetAnimation(combatant, CombatantAnimation.Idle);
}

Demo

We're done! Make sure to save all your hard work, and then give it a try! When you reach the encounter scene, choose the stride action and note that tiles within range of our hero are now highlighted. Choose one of the indicated locations, and observe how the character animates in a path from tile to tile until reaching the destination.

Summary

This was a huge lesson, if you've reached this far you should definitely pat yourself on the back! We created a way to highlight tiles, which we use to show the move range of the Stride action, we implemented pathfinding, and we updated Stride so it uses both. You can even see the character move along the path, instead of doing a straight tween from its starting point to the target point.

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 *