Last updated on

Creating a Grid Framework for Unreal Engine (Part 5)


Creating a Utility Library

In order to highlight a set of tiles, we need to be able to select a set of tiles. And in order to achieve this, we start with implementing three methods to select different shapes of tiles:

  • GetTilesInsideCircle
  • GetTilesInsideSquare
  • GetTilesInsideDiamond

For these function we create a class inheriting from UBlueprintFunctionLibrary. I called mine UGridUtils. All functions inside this class must be static. So, we do not need to instantiate this class in order to use any of its functions. If a function cannot be made static and still work properly, then this function should rather be moved to another class.

UFUNCTION(BlueprintPure)
static void GetTilesInsideCircle(UGridTile* CenterTile, float Radius, TArray<UGridTile*>& OutTiles);
UFUNCTION(BlueprintPure)
static void GetTilesInsideSquare(UGridTile* CenterTile, int32 HalfExtent, TArray<UGridTile*>& OutTiles);
UFUNCTION(BlueprintPure)
static void GetTilesInsideDiamond(UGridTile* CenterTile, int32 HalfExtent, TArray<UGridTile*>& OutTiles);

Every function is passed a starting tile and a parameter related to the concrete function. They all have a Array parameter that is passed by reference(indicated by the &). This means that when you call one of these functions, you have to pass the array parameter to the function. And then, the function will fill that array, and you can use the array after the function call.

Tip

When your function returns an array the entire array is copied from inside the function to the outer scope of the caller. By passing an empty array by reference and modifying it inside the function, we avoid this kind of overhead.

void UGridUtils::GetTilesInsideCircle(UGridTile* CenterTile, const float Radius, TArray<UGridTile*>& OutTiles) {
    const int32 Top = FMath::CeilToInt32(CenterTile->GetCoordinates().Y - Radius);
    const int32 Bottom = FMath::FloorToInt32(CenterTile->GetCoordinates().Y + Radius);
    const int32 Left = FMath::CeilToInt32(CenterTile->GetCoordinates().X - Radius);
    const int32 Right = FMath::FloorToInt32(CenterTile->GetCoordinates().X + Radius);

    OutTiles.Empty();
    for (int32 Y = Top; Y <= Bottom; Y++) {
        for (int32 X = Left; X <= Right; X++) {
            if (IsInsideCircle(CenterTile->GetCoordinates(), FTileCoords(X, Y), Radius)) {
                if (UGridTile* Temp = CenterTile->GetGrid()->GetTileByCoords(FTileCoords(X, Y))) {
                    OutTiles.Add(Temp);
                }
            }
        }
    }
}

void UGridUtils::GetTilesInsideSquare(UGridTile* CenterTile, int32 HalfExtent, TArray<UGridTile*>& OutTiles) {
    const int32 Top = CenterTile->GetCoordinates().Y - HalfExtent;
    const int32 Bottom = CenterTile->GetCoordinates().Y + HalfExtent;
    const int32 Left = CenterTile->GetCoordinates().X - HalfExtent;
    const int32 Right = CenterTile->GetCoordinates().X + HalfExtent;

    OutTiles.Empty();
    for (int32 Y = Top; Y <= Bottom; Y++) {
        for (int32 X = Left; X <= Right; X++) {
            if (UGridTile* Temp = CenterTile->GetGrid()->GetTileByCoords(FTileCoords(X, Y))) {
                OutTiles.Add(Temp);
            }
        }
    }
}

void UGridUtils::GetTilesInsideDiamond(UGridTile* CenterTile, int32 HalfExtent, TArray<UGridTile*>& OutTiles) {
    const int32 Top = CenterTile->GetCoordinates().Y - HalfExtent;
    const int32 Bottom = CenterTile->GetCoordinates().Y + HalfExtent;
    const int32 Left = CenterTile->GetCoordinates().X - HalfExtent;
    const int32 Right = CenterTile->GetCoordinates().X + HalfExtent;

    OutTiles.Empty();
    for (int32 Y = Top; Y <= Bottom; Y++) {
        for (int32 X = Left; X <= Right; X++) {
            if (IsInsideDiamond(CenterTile->GetCoordinates(), FTileCoords(X, Y), HalfExtent)) {
                if (UGridTile* Temp = CenterTile->GetGrid()->GetTileByCoords(FTileCoords(X, Y))) {
                    OutTiles.Add(Temp);
                }
            }
        }
    }
}

// ====== Helper function ========

bool UGridUtils::IsInsideCircle(const FTileCoords Center, const FTileCoords TileCoords, const float Radius) {
    const int32 Dx = Center.X - TileCoords.X;
    const int32 Dy = Center.Y - TileCoords.Y;

    const int32 DistanceSquared = Dx * Dx + Dy * Dy;
    return DistanceSquared <= Radius * Radius;
}

bool UGridUtils::IsInsideDiamond(const FTileCoords Center, const FTileCoords TileCoords, const float Radius) {
    const int32 Dx = Center.X - TileCoords.X;
    const int32 Dy = Center.Y - TileCoords.Y;

    const float CorrectedRadius = Radius * 0.9f;

    const int32 DistanceSquared = Dx * Dx + Dy * Dy;
    if (DistanceSquared == Radius * Radius) {
        return true;
    }
    return DistanceSquared < CorrectedRadius * CorrectedRadius;
}

As you can see, the code always calculates the bounds for the overall shape and then iterates through all the tiles inside, and checks if that specific tile still meets the shape’s requirements.

Highlighting a set of Tiles

Now, that we can get a set of tiles, it is time to highlight them. There are of course numerous ways to achieve this: You could, for example, spawn a single actor for each tile that has some kind of visual representation. I am going to use a procedural approach, where I generate the geometry at runtime. Unreal offers two components that are capable of mesh generation at runtime:

  • UDynamicMeshComponent
  • UProceduralMeshComponent

Warning

Both components are still being labeled as experimental and might change in future releases.

While you can use both to achieve the same result, I used thy UDynamicMeshComponent, because you can edit the mesh using the extensive UGeometryScriptLibrary. So, I created a new actor class called ABaseAreaHighlightActor and added a UDynamicMeshComponent. I then, created two functions: One to create the geometry and one two clear it.

void ABaseAreaHighlightActor::CreateAreaHighlight(TArray<UGridTile*> InTiles) {
    if (InTiles.IsEmpty()) {
        return;
    }

    UDynamicMesh* DynamicMesh = DynMeshComponent->GetDynamicMesh();
    DynamicMesh->Reset();

    for (UGridTile* Tile : InTiles) {
        FVector TileCenter = Tile->GetTileCenter();

        TArray<int32> Vertices;
        int32 VertexIndex;
        FVector Location = Tile->GetCornerLocationAtIndex(0);
        FVector Direction = Location - TileCenter;
        Location = TileCenter + Direction * TileMeshScale;
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddVertexToMesh(DynamicMesh, Location, VertexIndex);
        Vertices.Add(VertexIndex);

        Location = Tile->GetCornerLocationAtIndex(1);
        Direction = Location - TileCenter;
        Location = TileCenter + Direction * TileMeshScale;
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddVertexToMesh(DynamicMesh, Location, VertexIndex);
        Vertices.Add(VertexIndex);

        Location = Tile->GetCornerLocationAtIndex(2);
        Direction = Location - TileCenter;
        Location = TileCenter + Direction * TileMeshScale;
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddVertexToMesh(DynamicMesh, Location, VertexIndex);
        Vertices.Add(VertexIndex);

        Location = Tile->GetCornerLocationAtIndex(3);
        Direction = Location - TileCenter;
        Location = TileCenter + Direction * TileMeshScale;
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddVertexToMesh(DynamicMesh, Location, VertexIndex);
        Vertices.Add(VertexIndex);

        int32 TriAId, TriBId;
        bool bValid;
        FGeometryScriptUVTriangle UVA;
        UVA.UV0 = FVector2D(0, 0);
        UVA.UV1 = FVector2D(0, 1);
        UVA.UV2 = FVector2D(1, 1);
        FGeometryScriptUVTriangle UVB;
        UVB.UV0 = FVector2D(0, 1);
        UVB.UV1 = FVector2D(0, 0);
        UVB.UV2 = FVector2D(1, 1);
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddTriangleToMesh(DynamicMesh, FIntVector(Vertices[2], Vertices[0], Vertices[1]), TriAId);
        UGeometryScriptLibrary_MeshBasicEditFunctions::AddTriangleToMesh(DynamicMesh, FIntVector(Vertices[3], Vertices[2], Vertices[1]), TriBId);
        UGeometryScriptLibrary_MeshUVFunctions::SetMeshTriangleUVs(DynamicMesh, 0, TriAId, UVA, bValid);
        UGeometryScriptLibrary_MeshUVFunctions::SetMeshTriangleUVs(DynamicMesh, 0, TriBId, UVB, bValid);
    }
    TArray<UMaterialInterface*> Materials;
    Materials.Add(Material);
    DynMeshComponent->ConfigureMaterialSet(Materials);
}

So, this code iterates all given tiles and creates on square polygon (made of two triangles) for each tile. First, we get the center location of our tile. We us this as a reference for future calculations. Starting in line 12 we get the corner locations of our current tile and add a vertex to the dynamic mesh using this location. I also scale the Direction vector by a scalar TileMeshScale. This way, I can scale the corner points inward and outward. For the example I set the scale to 0.9 to scale them corners slightly inward. The vertex index is then stored in an array.

After adding all four corners to the dynamic mesh, we define the two triangles (starting in line 38). If you want to learn more about how to create triangles and why the order in which you add the vertices to the triangle array matters, click here. We also create the UVs for our two triangles, so we can display a proper texture if necessary. The last three lines add a material to the mesh, that I exposed as an Uproperty to the editor.

Square and Diamond shape Left: Square shape with half extend of 1, Right: Diamond shape with a half extend of 2

Circle shape Circle shape with a radius of 2.5

When it comes to the circle, you will get diamond-shaped results, if you use whole numbers as the radius. If you use values like 1.5, 2.5, etc., you will get shapes like in the picture above.

Next, we will take a look into path finding and movement.