In this blog post I will demonstrate how to use Unity’s EntityComponentSystem as well as Burst compiled Jobs to build a dungeon builder game like the good old DungeonKeeper.

This awesome old game turned out to be the perfect example to show of some techniques that will hopefully help you to get into the mindset of DOTS and EntityComponentSystems.

In the previous Part of this series we already covered how to load a GameMap and first steps of user input handling. If you have not read it yet I would strongly suggest to read it first and then come back to this post.
https://tech.innogames.com/dungeonburst-building-dungeonkeeper-with-unity-ecs-and-burst/

In this blog post we will cover the topics of:

– Creating and spawning creatures
– Implementing a PathfindingSystem (A-Star in Burst)
– Implementing a PathfindingAgentSystem (let creatures follow a path)
– Implementing an idle behavior for our creatures

Disclaimer:

This project is only for demonstration of usage of the Unity Data Oriented Tech Stack. Neither InnoGames nor I do own any rights on DungeonKeeper. This project is not developed by InnoGames.

Project setup:

Since the last blog post I updated the project to the latest Unity version and also updated some packages. Fortunately there were no major changes required to get the project running on the new versions. But the performance improvements are significant!

Using: Unity 2020.1.10f1

Packages:
– Entities 0.14.0 preview.19
– Hybrid Renderer 0.8.8 preview.19
– Unity Physics 0.5.0 preview.1
– Universal RP 9.0.0 preview.55

Creating and spawning creatures

Every unit that is able to walk through our dungeon will need some way to store information about its speed, target position and its current path. For this we will create the PathfindingAgent:IComponentData. Since a path consists of a list of positions with a varying amount of steps we will use a DynamicBuffer<PathStep> attached to every Agent.
We will use the GameObjectConversion flow to convert a Prefab of a creature into an Entity so we can make use of a AuthoringComponent that will be added to the Prefab of every creature.

PathfindingAgent

public struct PathfindingAgent : IComponentData
{
     public float Speed;
     public float3 TargetPosition;
     public int CurrentStepIndex;
     public int PathLength;
 }
 
 public struct PathStep : IBufferElementData
 {
     public float3 Position;
 }
 
 public class PathfindingAgentAuthoring : MonoBehaviour, IConvertGameObjectToEntity
 {
     public float Speed;
     public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
     {
         dstManager.AddComponentData(entity, new PathfindingAgent { Speed = Speed });
         dstManager.AddBuffer<PathStep>(entity);
     }
 }

Lets create the Prefab for our first unit – the Imp.
Its a simple GameObject with a child containing only a MeshFilter and a MeshRenderer (a simple sphere in this case). The root GameObject will have our PathfindingAgentAuthoring component.

Cheat System

Now we need a System that allows us to spawn those Imps into the gameworld. In order to keep things clean i will create a new CheatSystem:JobComponentSystem. This system will be used to implement various cheats and commands useful for debugging.

For it will do three things:

– It will change a diggable MapTile to an empty MapTile when clicking on it.
– It will spawn imps at the mouse cursors position when we press the “I” button.
– It will set the TargetPosition of all PathfindingAgents in the Map to the cursors position when we press the “p” button.

CheatSystem

[UpdateBefore(typeof(TileViewSystem))]
[AlwaysUpdateSystem]
public class CheatSystem : JobComponentSystem
{
    private BuildPhysicsWorld _buildPhysicsSystem;
    private UnityEngine.Camera _camera;
    private Entity _impPrefab;
 
    protected override void OnStartRunning()
    {
        _buildPhysicsSystem = World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<BuildPhysicsWorld>();
        _camera = UnityEngine.Camera.main;
    }
 
    // called from MapLoaderAuthoring
    public void InitImpPrefab(UnityEngine.GameObject impPrefab)
    {
        using (BlobAssetStore blobAssetStore = new BlobAssetStore())
        {
            var conversionSettings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, blobAssetStore);
            _impPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(impPrefab, conversionSettings);
        }
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        // when left mouse is clicked
        if (UnityEngine.Input.GetMouseButtonDown(0))
        {
            var collisionWorld = _buildPhysicsSystem.PhysicsWorld.CollisionWorld;
            // raycast from camera towards mouse position, returns the MapTile Entity where the raycast hit
            if (InputUtilities.GetTileAtMousePositon(_camera, collisionWorld, GetSingleton<GameMap>(), false, out Entity mouseOverTile))
            {
                if (EntityManager.HasComponent<Diggable>(mouseOverTile))
                {
                    DigMapTile(mouseOverTile);
                }
            }
        }
        if (UnityEngine.Input.GetKeyDown(UnityEngine.KeyCode.I))
        {
            int amount = UnityEngine.Input.GetKey(UnityEngine.KeyCode.LeftShift) ? 100 : 1;
            SpawnImpAtMousePosition(amount);
        }
        if (UnityEngine.Input.GetKeyDown(UnityEngine.KeyCode.P))
        {
            inputDeps = MoveAllAgentsToMousePosition(inputDeps);
        }
        return inputDeps;
    }
 
    private void DigMapTile(Entity mouseOverTile)
    {
        // get MapTile data
        var tileData = EntityManager.GetComponentData<MapTile>(mouseOverTile);
        // change MaptileType to to be empty
        tileData.Type = MapTileType.Empty;
        tileData.Owner = 0;
        // apply data to Entity
        EntityManager.SetComponentData(mouseOverTile, tileData);
        // empty tiles are no longer diggable
        EntityManager.RemoveComponent<Diggable>(mouseOverTile);
        // we need to update the changed MapTile and all 8 surrounding neighbours
        var gameMap = GetSingleton<GameMap>();
        ref TileMapBlobAsset mapData = ref gameMap.TileMap.Value;
        var position = tileData.Position;
        for (int y = position.y - 1; y <= position.y + 1; y++)
        {
            for (int x = position.x - 1; x <= position.x + 1; x++)
            {
                var tileEntity = mapData.Map[x + y * gameMap.Width];
                EntityManager.AddComponentData(tileEntity, new UpdateTileView());
            }
        }
    }
 
    private void SpawnImpAtMousePosition(int amount)
    {
        var collisionWorld = _buildPhysicsSystem.PhysicsWorld.CollisionWorld;
        if (InputUtilities.RayCastFromMousePosition(_camera, collisionWorld, out RaycastHit result))
        {
            Entity hitEntity = InputUtilities.GetTile(result, GetSingleton<GameMap>(), true);
            if (hitEntity == Entity.Null || EntityManager.GetComponentData<MapTile>(hitEntity).Type.IsSolid())
            {
                return;
            }
            var spawnPosition = result.Position + result.SurfaceNormal * 0.1f; ;
            spawnPosition.y = 0;
            // instantiate all imps to spawn
            var spawnedImps = new NativeArray<Entity>(amount, Allocator.TempJob);
            EntityManager.Instantiate(_impPrefab, spawnedImps);
            //asign data to each
            foreach (var imp in spawnedImps)
            {
                EntityManager.SetComponentData(imp, new Translation { Value = spawnPosition });
                EntityManager.AddComponentData(imp, new IdleBehavior());
            }
            spawnedImps.Dispose();
        }
    }
 
    private JobHandle MoveAllAgentsToMousePosition(JobHandle inputDeps)
    {
        var collisionWorld = _buildPhysicsSystem.PhysicsWorld.CollisionWorld;
        if (InputUtilities.RayCastFromMousePosition(_camera, collisionWorld, out RaycastHit result))
        {
            float3 targetPosition = result.Position + result.SurfaceNormal * 0.1f; ;
            targetPosition.y = 0;
            // assign targetposition to every agent on the map
            return Entities.ForEach((ref PathfindingAgent agent) =>
            {
                agent.TargetPosition = targetPosition;
            }).Schedule(inputDeps);
        }
        return inputDeps;
    }
}

Implementing Pathfinding

Pathfinding Grid System

Every unit in the game needs to be able to find its path through our dynamically growing dungeon. Since this game is build on a tile map, we will write our own A-Star pathfinding algorithm implementation for DOTS. My implementation consists of two Systems: The PathfindingGridSystem and the PathfindingSystem.

The PathfindingGridSystem will be responsible for creating and updating the Pathfinding Grid. When there are MapTiles that recently received a UpdateTileView tag, it will check if the tile changed its status from a “non walkable” to a “walkable state” (or vice versa), and adjust the grid nodes accordingly. Every pathfinding node stores information about connections to its neighbors and a roomindex.

PathfindingGridSystem 1/2

public class PathfindingGridSystem : JobComponentSystem
{
    public struct Node
    {
        public float Weight;
        public int Room;
        public int south, north, west, east;
        public int southWest, southEast, northWest, northEast;
    }
 
    public NativeArray<Node> PathfindingNodes;
    public int MapWidth;
 
    protected override void OnCreate()
    {
        // only update this system when there are maptiles that need updating
        RequireForUpdate(GetEntityQuery(typeof(UpdateTileView), typeof(MapTile)));
    }
 
    protected override void OnDestroy()
    {
        if (PathfindingNodes.IsCreated)
        {
            PathfindingNodes.Dispose();
        }
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        if (!PathfindingNodes.IsCreated)
        {
            // the first time we are running this system we will initialize the full grid from scratch
            InitializeGrid();
        }
        else
        {
            // update only grid nodes that require updating
            JobHandle jobHandle = UpdateGridNodes(inputDeps);
 
            // complete the jobhandle so that other systems can safely access our PathfindingNodes
            jobHandle.Complete();
            return default;
        }
        return inputDeps;
    }
 
    private void InitializeGrid()
    {
        var gameMap = GetSingleton<GameMap>();
 
        // we store all our nodes in a persistent array that can be accessed from other systems
        PathfindingNodes = new NativeArray<Node>(gameMap.Width * gameMap.Height, Allocator.Persistent);
        MapWidth = gameMap.Width;
 
        var mapTileData = GetComponentDataFromEntity<MapTile>(true);
        ref var mapData = ref gameMap.TileMap.Value;
 
        // assign a weight to all walkable pathfinding nodes
        int walkableMapFieldCount = 0;
        for (int t = 0; t < gameMap.Width * gameMap.Height; t++)
        {
            Entity tileEntity = mapData.Map[t];
            MapTile tileData = mapTileData[tileEntity];
            bool isWalkable = !tileData.Type.IsSolid();
            if (isWalkable)
            {
                PathfindingNodes[t] = new Node { Weight = 1f };
                walkableMapFieldCount++;
            }
        }
        // find all connections with neighbors
        for (int y = 1; y < gameMap.Height - 1; y++)
        {
            for (int x = 1; x < MapWidth - 1; x++)
            {
                UpdateNodeConnections(x, y, MapWidth, PathfindingNodes);
            }
        }
        // assign all connected nodes to rooms
        InitRooms(walkableMapFieldCount, PathfindingNodes);
    }
 
    private JobHandle UpdateGridNodes(JobHandle inputDeps)
    {
        int mapWidth = MapWidth;
        var mapNodes = PathfindingNodes;
 
        // iterate over all maptiles with UpdateTileView tag and evaluate if the node grid needs to be updated
        JobHandle jobHandle = Entities.WithAll<UpdateTileView>().ForEach((in MapTile mapTile) =>
        {
            bool isWalkable = !mapTile.Type.IsSolid();
            int x = mapTile.Position.x;
            int y = mapTile.Position.y;
            int tileIndex = x + y * mapWidth;
            Node node = mapNodes[tileIndex];
            if (node.Weight > 0 != isWalkable)
            {
                if (isWalkable)
                {
                    // node became walkable
                    node.Weight = 1f;
                    mapNodes[tileIndex] = node;
                    UpdateNodeConnections(x, y, mapWidth, mapNodes);
                    // find connecting rooms
                    AssignRoom(x, y, mapWidth, mapNodes);
                }
                else
                {
                    // node became non walkable
                    node.Weight = 0f;
                    node.Room = -(mapNodes.Length + tileIndex + 1); //TODO find safe negative room index
                    mapNodes[tileIndex] = node;
                    //TODO check if a room was split because of the removal of this tile
                }
                // update connections of all surrounding neighbours
                UpdateNodeConnections(x - 1, y, mapWidth, mapNodes);
                UpdateNodeConnections(x + 1, y, mapWidth, mapNodes);
                UpdateNodeConnections(x, y - 1, mapWidth, mapNodes);
                UpdateNodeConnections(x, y + 1, mapWidth, mapNodes);
                UpdateNodeConnections(x - 1, y - 1, mapWidth, mapNodes);
                UpdateNodeConnections(x + 1, y - 1, mapWidth, mapNodes);
                UpdateNodeConnections(x - 1, y + 1, mapWidth, mapNodes);
                UpdateNodeConnections(x + 1, y + 1, mapWidth, mapNodes);
            }
        }).Schedule(inputDeps);
        return jobHandle;
    }
 
    private static void UpdateNodeConnections(int x, int y, int mapWidth, NativeArray<Node> mapNodes)
    {
        int nodeIndex = x + y * mapWidth;
        if (!IsWalkable(nodeIndex, mapNodes))
        {
            return;
        }
        Node node = mapNodes[nodeIndex];
 
        // assign neigbour indices when neighbour is walkable
        node.south = GetWalkableIndex(x, y - 1, mapWidth, mapNodes);
        node.north = GetWalkableIndex(x, y + 1, mapWidth, mapNodes);
        node.west = GetWalkableIndex(x - 1, y, mapWidth, mapNodes);
        node.east = GetWalkableIndex(x + 1, y, mapWidth, mapNodes);
        node.southWest = (node.south != 0 && node.west != 0) ? GetWalkableIndex(x - 1, y - 1, mapWidth, mapNodes) : 0;
        node.southEast = (node.south != 0 && node.east != 0) ? GetWalkableIndex(x + 1, y - 1, mapWidth, mapNodes) : 0;
        node.northWest = (node.north != 0 && node.west != 0) ? GetWalkableIndex(x - 1, y + 1, mapWidth, mapNodes) : 0;
        node.northEast = (node.north != 0 && node.east != 0) ? GetWalkableIndex(x + 1, y + 1, mapWidth, mapNodes) : 0;
 
        mapNodes[nodeIndex] = node;
    }
 
    private static int GetWalkableIndex(int x, int y, int mapWidth, NativeArray<Node> mapNodes)
    {
        int nodeIndex = x + y * mapWidth;
        if (IsWalkable(nodeIndex, mapNodes))
        {
            return nodeIndex;
        }
        return 0;
    }
 
    private static bool IsWalkable(int nodeIndex, NativeArray<Node> mapNodes)
    {
        var node = mapNodes[nodeIndex];
        return node.Weight > 0f;
    }
 
    private static void InitRooms(int walkableMapFieldCount, NativeArray<Node> mapNodes) { ... }
 
    private static void TryAssignRoomAndAddToOpenList(int nodeIndex, int roomIndex, NativeArray<Node> mapNodes, NativeQueue<int> openList) { ... }
 
    private static void AssignRoom(int x, int y, int mapWidth, NativeArray<Node> mapNodes) { ... }
 
    private static void OverrideRooms(int targetRoom, int room1, int room2, int room3, int room4, NativeArray<Node> mapNodes) { ... }
 
}

The roomindex is the same for all MapTiles that are potentially connected to each other, this is very useful for quick checks if a certain MapTile can be reached by checking the start and destination tiles roomindices. If they are the same we can be sure a path will be found, otherwise we don’t even need to try finding a path.

Room 2 and 19 are not connected

As soon as we connect both rooms the higher roomnumber will be assigned

PathfindingGridSystem 2/2

private static void InitRooms(int walkableMapFieldCount, NativeArray<Node> mapNodes)
{
    var assignedRoomCount = 0;
    int roomIndex = 0;
    var nextTile = 0;
 
    NativeQueue<int> openList = new NativeQueue<int>(Allocator.Temp);
 
    while (assignedRoomCount < walkableMapFieldCount)
    {
        roomIndex++;
 
        // find first unassigned tile for new room
        for (; nextTile < mapNodes.Length; nextTile++)
        {
            var node = mapNodes[nextTile];
            if (node.Weight > 0 && node.Room == 0)
            {
                node.Room = roomIndex;
                mapNodes[nextTile] = node;
                openList.Enqueue(nextTile);
                break;
            }
        }
 
        // floodfill all direct neighbours
        while (openList.TryDequeue(out int nodeIndex))
        {
            var node = mapNodes[nodeIndex];
            assignedRoomCount++;
            TryAssignRoomAndAddToOpenList(node.south, roomIndex, mapNodes, openList);
            TryAssignRoomAndAddToOpenList(node.north, roomIndex, mapNodes, openList);
            TryAssignRoomAndAddToOpenList(node.west, roomIndex, mapNodes, openList);
            TryAssignRoomAndAddToOpenList(node.east, roomIndex, mapNodes, openList);
        }
    }
    openList.Dispose();
 
    // assign unique negative room ids to all non walkable nodes
    for (int t = 0; t < mapNodes.Length; t++)
    {
        var node = mapNodes[t];
        if (node.Room == 0)
        {
            node.Room = -(++roomIndex);
            mapNodes[t] = node;
        }
    }
}
 
private static void TryAssignRoomAndAddToOpenList(int nodeIndex, int roomIndex, NativeArray<Node> mapNodes, NativeQueue<int> openList)
{
    if (nodeIndex != 0)
    {
        var nnode = mapNodes[nodeIndex];
        if (nnode.Room == 0)
        {
            nnode.Room = roomIndex;
            mapNodes[nodeIndex] = nnode;
            openList.Enqueue(nodeIndex);
        }
    }
}
 
private static void AssignRoom(int x, int y, int mapWidth, NativeArray<Node> mapNodes)
{
    int nodeIndex = x + y * mapWidth;
    var node = mapNodes[nodeIndex];
    int northRoom = mapNodes[node.north].Room;
    int eastRoom = mapNodes[node.east].Room;
    int southRoom = mapNodes[node.south].Room;
    int westRoom = mapNodes[node.west].Room;
    // pick the highest room number from all neighbours
    int maxRoom = math.max(math.max(northRoom, eastRoom), math.max(southRoom, westRoom));
    if (maxRoom > 0)
    {
        node.Room = maxRoom;
        // if this room connects multiple rooms combine
        OverrideRooms(maxRoom, northRoom, eastRoom, southRoom, westRoom, mapNodes);
    }
    else
    {
        node.Room = -node.Room;
    }
    mapNodes[nodeIndex] = node;
}
 
private static void OverrideRooms(int targetRoom, int room1, int room2, int room3, int room4, NativeArray<Node> mapNodes)
{
    bool checkRoom1 = room1 > 0 && room1 != targetRoom;
    bool checkRoom2 = room2 > 0 && room2 != targetRoom;
    bool checkRoom3 = room3 > 0 && room3 != targetRoom;
    bool checkRoom4 = room4 > 0 && room4 != targetRoom;
    for (int n = 0; n < mapNodes.Length; n++)
    {
        var node = mapNodes[n];
        int currentRoom = node.Room;
        //if node is one of the 4 provided room ids
        if ((checkRoom1 && currentRoom == room1)
        || (checkRoom2 && currentRoom == room2)
        || (checkRoom3 && currentRoom == room3)
        || (checkRoom4 && currentRoom == room4))
        {
            //assign targetRoom
            node.Room = targetRoom;
            mapNodes[n] = node;
        }
    }
}

Pathfinding System

The PathfindingSystem will handle all path requests for the game. It will update all entities that have a PathRequest:IComponentData attached by doing pathfinding and assigning the found path back to the Entity. For that it uses the Pathfinding Grid from the PathfindingGridSystem and do some A-Star to find paths.

PathfindingSystem

[UpdateAfter(typeof(PathfindingGridSystem))]
public class PathfindingSystem : JobComponentSystem
{
    private PathfindingGridSystem _gridSystem;
    private EndSimulationEntityCommandBufferSystem _commandBufferSystem;
    private const float StraightCost = 1f;
    private static readonly float DiagonalCost = math.sqrt(StraightCost * StraightCost + StraightCost * StraightCost);
 
    public struct PathfindingRequest : IComponentData
    {
        public float3 Start;
        public float3 Target;
        public RequestStatus Status;
    }
 
    private struct NodeEntry
    {
        public int Index;
        public float TotalCost;
    }
 
    public enum RequestStatus
    {
        Pending,
        Unreachable
    }
 
    protected override void OnCreate()
    {
        _gridSystem = World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<PathfindingGridSystem>();
        _commandBufferSystem = World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var commandBuffer = _commandBufferSystem.CreateCommandBuffer().AsParallelWriter();
        var mapNodes = _gridSystem.PathfindingNodes;
        int mapWidth = _gridSystem.MapWidth;
        // iterate over all PathfindingAgents that have a PathfindingRequest
        var jobHandle = Entities.ForEach((int entityInQueryIndex, Entity entity, ref DynamicBuffer<PathStep> pathBuffer, ref PathfindingAgent agent, ref PathfindingRequest request) =>
        {
            if (FindPath(request, ref agent, pathBuffer, mapWidth, mapNodes))
            {
                commandBuffer.RemoveComponent<PathfindingRequest>(entityInQueryIndex, entity);
            }
            else
            {
                request.Status = RequestStatus.Unreachable;
            }
        }).WithReadOnly(mapNodes).Schedule(inputDeps);
        _commandBufferSystem.AddJobHandleForProducer(jobHandle);
        return jobHandle;
    }
 
    public static bool IsReachable(int2 start, int2 target, int mapWidth, NativeArray<PathfindingGridSystem.Node> mapNodes)
    {
        int startNodeIndex = start.x + start.y * mapWidth;
        int targetNodeIndex = target.x + target.y * mapWidth;
        // compare start and target room index
        return mapNodes[startNodeIndex].Room == mapNodes[targetNodeIndex].Room;
    }
 
    private static bool FindPath(PathfindingRequest request, ref PathfindingAgent agent, DynamicBuffer<PathStep> pathBuffer, int mapWidth, NativeArray<PathfindingGridSystem.Node> mapNodes)
    {
        if (!IsReachable((int2)request.Start.xz, (int2)request.Target.xz, mapWidth, mapNodes))
        {
            return false;
        }
        int startNodeIndex = (int)request.Start.x + (int)request.Start.z * mapWidth;
        int targetNodeIndex = (int)request.Target.x + (int)request.Target.z * mapWidth;
        var buffer = pathBuffer.Reinterpret<float3>();
        if (startNodeIndex == targetNodeIndex)
        {
            // unit is moving on the same tile
            buffer.Clear();
            buffer.Add(request.Start);
            buffer.Add(request.Target);
            agent.PathLength = 2;
            return true;
        }
 
        int nodeCount = mapNodes.Length;
        var cameFrom = new NativeHashMap<int, int>(nodeCount, Allocator.Temp);
        var costs = new NativeHashMap<int, float>(nodeCount, Allocator.Temp);
        var openNodes = new NativeList<NodeEntry>(Allocator.Temp);
 
        // add start node to open list
        openNodes.Add(new NodeEntry { Index = startNodeIndex, TotalCost = 0 });
        cameFrom.TryAdd(startNodeIndex, startNodeIndex);
        costs.TryAdd(startNodeIndex, 0);
        while (openNodes.Length > 0)
        {
            // remove lowest cost node from openlist
            int nextIndex = FindLowestCostNode(ref openNodes);
            int currentNodeIndex = openNodes[nextIndex].Index;
            openNodes.RemoveAtSwapBack(nextIndex);
 
            if (currentNodeIndex == targetNodeIndex)
            {
                // we arrived at target positon
                buffer.Clear();
                // reconstruct path and add pathsteps to the buffer
                int currentResult = targetNodeIndex;
                while (currentResult != startNodeIndex)
                {
                    float3 position = new float3(currentResult % mapWidth, 0, currentResult / mapWidth);
                    buffer.Insert(0, position + new float3(0.5f, 0f, 0.5f));
                    cameFrom.TryGetValue(currentResult, out currentResult);
                }
                buffer[pathBuffer.Length - 1] = request.Target;
                agent.PathLength = pathBuffer.Length;
                costs.Dispose();
                cameFrom.Dispose();
                openNodes.Dispose();
                return true;
            }
 
            var currentNode = mapNodes[currentNodeIndex];
            costs.TryGetValue(currentNodeIndex, out float currentCost);
            var straight = currentCost + currentNode.Weight * StraightCost;
            var diag = currentCost + currentNode.Weight * DiagonalCost;
 
            // update all neighbours
            CheckNeighbour(currentNodeIndex, currentNode.south, straight, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.north, straight, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.west, straight, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.east, straight, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
 
            CheckNeighbour(currentNodeIndex, currentNode.southWest, diag, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.southEast, diag, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.northWest, diag, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
            CheckNeighbour(currentNodeIndex, currentNode.northEast, diag, request.Target, ref cameFrom, ref costs, ref openNodes, mapWidth);
        }
 
        // no path was found dispose maps
        costs.Dispose();
        cameFrom.Dispose();
        openNodes.Dispose();
        return false;
    }
 
    private static void CheckNeighbour(int currentIndex, int neighbourIndex, float newCost, float3 target, ref NativeHashMap<int, int> cameFrom, ref NativeHashMap<int, float> costs, ref NativeList<NodeEntry> openNodes, int mapWidth)
    {
        if (neighbourIndex == 0)
        {
            return;
        }
        // if neighbour was not visited yet or it is cheaper to visit neighbour from current node
        bool costFound = costs.TryGetValue(neighbourIndex, out float costSoFar);
        if (!costFound || newCost < costSoFar)
        {
            if (costFound)
            {
                costs.Remove(neighbourIndex);
                cameFrom.Remove(neighbourIndex);
            }
 
            // update costs
            costs.TryAdd(neighbourIndex, newCost);
            cameFrom.TryAdd(neighbourIndex, currentIndex);
 
            // add neighbour to open list
            float3 neighbourPosition = new float3(neighbourIndex % mapWidth, 0, neighbourIndex / mapWidth);
            float totalCost = newCost + math.distance(target, neighbourPosition);
            openNodes.Add(new NodeEntry { Index = neighbourIndex, TotalCost = totalCost });
        }
    }
 
    private static int FindLowestCostNode(ref NativeList<NodeEntry> openNodes)
    {
        int nextIndex = openNodes.Length - 1;
        float minCost = openNodes[nextIndex].TotalCost;
        for (int o = nextIndex - 1; o >= 0; o--)
        {
            float totalCost = openNodes[o].TotalCost;
            if (totalCost < minCost)
            {
                nextIndex = o;
                minCost = totalCost;
            }
        }
        return nextIndex;
    }
}

Implementing path following AI

With the data and pathfinding systems prepared we can write our PathfindingAgentSystem. It will iterate over all PathfindingAgents and request a path if it needs to move to a target position. if a path is assigned it will move the agent along the path until the target position is reached.

PathfindingAgentSystem

[UpdateBefore(typeof(PathfindingSystem))]
[UpdateBefore(typeof(TransformSystemGroup))]
public class PathfindingAgentSystem : JobComponentSystem
{
    private EndSimulationEntityCommandBufferSystem _commandBufferSystem;
 
    protected override void OnCreate()
    {
        _commandBufferSystem = World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var commandBuffer = _commandBufferSystem.CreateCommandBuffer().AsParallelWriter();
        float deltaTime = Time.DeltaTime;
        // iterate over all our PathfindingAgents
        var jobHandle = Entities.ForEach((int entityInQueryIndex, Entity entity, ref DynamicBuffer<PathStep> pathBuffer, ref PathfindingAgent agent, ref Translation translation, ref Rotation rotation) =>
        {
            // if we dont have a targetposition
            if (agent.TargetPosition.Equals(float3.zero))
            {
                return;
            }
            // if we arrived at targetposition
            if (math.distancesq(translation.Value.xz, agent.TargetPosition.xz) < 0.01f)
            {
                agent.TargetPosition = float3.zero;
                ResetPath(ref agent, ref pathBuffer);
                return;
            }
            var path = pathBuffer.Reinterpret<float3>();
            // if we dont have a path or a path to a wrong destination
            if (agent.PathLength == 0 || !path[path.Length - 1].Equals(agent.TargetPosition))
            {
                // request new path
                commandBuffer.AddComponent(entityInQueryIndex, entity, new PathfindingSystem.PathfindingRequest
                {
                    Start = translation.Value,
                    Target = agent.TargetPosition
                });
                ResetPath(ref agent, ref pathBuffer);
                return;
            }
            // move along the path
            float movementDist = agent.Speed * deltaTime;
            while (movementDist > 0)
            {
                var targetPosition = path[agent.CurrentStepIndex];
                var offset = new float3(targetPosition.x, translation.Value.y, targetPosition.z) - translation.Value;
                float distanceToNextPostion = math.length(offset);
                bool isLastStep = agent.CurrentStepIndex + 1 >= path.Length;
                // while on the path we only need to get close to current path step
                if (!isLastStep && distanceToNextPostion < 0.75f)
                {
                    agent.CurrentStepIndex++;
                    continue;
                }
                var direction = math.normalize(offset);
                rotation.Value = quaternion.LookRotation(direction, new float3(0, 1, 0));
                if (movementDist < distanceToNextPostion)
                {
                    translation.Value += direction * movementDist;
                    break;
                }
                // we arrived at current path step
                translation.Value += direction * distanceToNextPostion;
                if (isLastStep)
                {
                    agent.TargetPosition = float3.zero;
                    ResetPath(ref agent, ref pathBuffer);
                    break;
                }
                movementDist -= distanceToNextPostion;
                agent.CurrentStepIndex++;
            }
        }).Schedule(inputDeps);
        _commandBufferSystem.AddJobHandleForProducer(jobHandle);
        return jobHandle;
    }
 
    private static void ResetPath(ref PathfindingAgent agent, ref DynamicBuffer<PathStep> pathBuffer)
    {
        if (agent.PathLength > 0)
        {
            pathBuffer.Clear();
            agent.CurrentStepIndex = 0;
            agent.PathLength = 0;
        }
    }
}

Implementing a simple Idle Behavior AI

For demonstration purposes, lets create our first simple Idle Behavior. 

Every unit with this behavior will move to a random position within the dungeon and wait there for a while, before it searches the next random position.
Since we have a roomindex, we can pick one random tile from the room where the creature is currently located. We can assign this behavior to all Imps we spawn in the CheatSystem.

IdleBehaviorSystem

public struct IdleBehavior : IComponentData
{
    public float IdleDuration;
}
 
[UpdateAfter(typeof(PathfindingGridSystem))]
public class IdleBehaviorSystem : JobComponentSystem
{
    private PathfindingGridSystem _gridSystem;
 
    protected override void OnCreate()
    {
        _gridSystem = World.DefaultGameObjectInjectionWorld.GetOrCreateSystem<PathfindingGridSystem>();
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var deltaTime = Time.DeltaTime;
        uint seed = (uint)(UnityEngine.Time.timeSinceLevelLoad * 1000);
        var mapNodes = _gridSystem.PathfindingNodes;
        int mapWidth = _gridSystem.MapWidth;
        // iterate over all idle pathfinding agents
        var updateIdleBehavioursHandle = Entities.ForEach((int entityInQueryIndex, Entity entity, ref PathfindingAgent pathfindingAgent, ref IdleBehavior behaviour, in Translation translation) =>
        {
            // when unit has no targetposition
            if (pathfindingAgent.TargetPosition.Equals(float3.zero))
            {
                // wait
                behaviour.IdleDuration -= deltaTime;
                if (behaviour.IdleDuration <= 0f)
                {
                    behaviour.IdleDuration = 3f;
                    // after a while search a random target
                    var random = new Random(seed + (uint)entityInQueryIndex * 333);
                    pathfindingAgent.TargetPosition = GetRandomTargetPosition(translation.Value, mapWidth, mapNodes, random);
                }
            }
        }).WithReadOnly(mapNodes).Schedule(inputDeps);
        return updateIdleBehavioursHandle;
    }
 
    private static float3 GetRandomTargetPosition(float3 start, int mapWidth, NativeArray<PathfindingGridSystem.Node> pathfindingNodes, Random random)
    {
        int startNodeIndex = (int)start.x + (int)start.z * mapWidth;
        // find all tiles of the creatures room
        int roomId = pathfindingNodes[startNodeIndex].Room;
        NativeList<int2> targetPositions = new NativeList<int2>(pathfindingNodes.Length, Allocator.Temp);
        for (int n = 0; n < pathfindingNodes.Length; n++)
        {
            if (pathfindingNodes[n].Room == roomId)
            {
                targetPositions.Add(new int2(n % mapWidth, n / mapWidth));
            }
        }
        // pick random tile
        int2 randomTarget = targetPositions[random.NextInt(targetPositions.Length - 1)];
        targetPositions.Dispose();
        // return position in tile
        float2 offset = random.NextFloat2(0.2f, 0.8f);
        return new float3(randomTarget.x + offset.x, 0, randomTarget.y + offset.y);
    }
}

I included the whole project in this unitypackage

  • InnoGames is hiring! Check out open positions and join our awesome international team in Hamburg at the certified Great Place to Work®.