D20 RPG – Board

In this lesson we will look at making custom boards for our encounters that are data-driven and skinned in a Tilemap.

Overview

My very first blog project involved creating mechanics for a Tactics RPG. In that project, I created a 3D isometric board with a focus on how to randomly create non-square boards including height information. If those goals sound interesting to you, then the original might still be worth a read and you can see it here.

A lot of people asked about making 2D maps and how to use different types of tiles like water vs dirt. So I decided to keep the visual style of this project as a simple 2D game and to make my boards with a Tilemap so that I could demonstrate solutions for those questions.

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.

For this lesson I created a couple of simple images to use for tile maps. They are available in this package here. Download, unzip, then import the package.

Board Data

Let’s begin by creating a ScriptableObject that can live as a project asset and hold all the information we will need to represent a board for our combatants to fight upon. This asset will be sort of abstract data, and can be “skinned” with specific tiles at a later point.

Create a new folder inside of “Scripts”, named Board. Then create a new C# script named BoardData in that folder and add the following:

using UnityEngine;

[CreateAssetMenu]
public class BoardData : ScriptableObject
{
    public int width;
    public int height;
    public int[] tiles;
}

There are three fields defined in our simple data class. Two are for the dimensions of the board – it’s “width” and “height”. These mark the boundary of the board, and can be used for example to make sure that we don’t move a cursor outside of the board’s area. They could also be used in any case where we want to loop over all tiles based on coordinate positions. The third field is an array of integers that represent each of the board’s tiles. The int value at each index can be thought of as a “type” of tile in an abstract sense. For this demo, I will treat the values like elevations, where 0 is like sea level (water tiles), 1 is ground level (dirt tiles) and so on.

Even though our board is visually a 2D map, the “tiles” array is 1D. There are a few reasons for this, such as performance, or being able to loop over all tiles with a single loop rather than a nested one. Possibly the biggest reason though is that Unity doesn’t know how to serialize 2D arrays (int[,]) or jagged arrays (int[][]). This means that there will be a little bit of math to figure out which array index maps to what 2D position, but it isn’t too hard, and you will see that later.

Note that our class inherits from ScriptableObject and is also marked with CreateAssetMenu. This combination will enable us to create assets of this type from Unity’s menu bar.

Jump back over to Unity and create a new folder at “Assets -> Objects” named “Boards”. Then, with that folder selected, go up to the menu bar and choose “Assets -> Create -> Board Data”. This creates a new asset within the folder, which you can name “PerlinSample”. Of course you can name your assets whatever you want, but knowing the name I provided will make it easier to follow along.

Board Generator

Create a new folder inside “Scripts” named Tools. Then create a new script named BoardGenerator within the new folder and add the following:

using System;
using UnityEngine;
using UnityEngine.Tilemaps;

public class BoardGenerator : MonoBehaviour
{
    [SerializeField] BoardData data;
    [SerializeField] int width = 6;
    [SerializeField] int height = 8;
    [SerializeField] int[] tiles;
}

Note that this script has added a new “using” statement of “UnityEngine.Tilemaps”. This allows us to reference some of Unity’s classes like Tilemap and TileBase. If you aren’t already familiar with these then you might be interested in checking out Unity’s own introduction here.

To start with, I have added four serialized fields. First, is a field named “data” with a type of BoardData which is the class we just created above. The next three fields mimic the same data you would find within the BoardData object. So why duplicate them? I left them separate because the “data” reference is a reference to an actual project asset, and the other fields are a local “work in progress” set of data that we haven’t confirmed we wish to keep. At the user’s direction we will be able to load from, or save to the “data”, by copying from or to our local data as needed. In addition, I wanted to be able to swap out the asset itself, without losing the editor’s settings.

Add these fields:

[SerializeField] TileBase[] tileViews;
[SerializeField] float[] elevations = new float[] { 0.3f, 0.6f, 0.7f, 1f };

I have added an array of TileBase that will serve as the abstract visualization of our types of tiles. There are four roughly representing water, dirt, hill, and mountain types. Another array of float called “elevations” is paired with this array and represents a threshold for which each type of tile should be present. So any value from 0 to 0.3 would be a water tile and so on. The possible values range from 0 to 1, and will initially be generated by a perlin noise.

Add the following:

[SerializeField] Vector2 perlinScale = new Vector2(0.1f, 0.1f);
[SerializeField] Vector2 perlinOffset = Vector2.zero;

These fields are used to help configure our perlin noise. Modifying the scale or offset will determine the size of the various features as well as their location.

Add the following:

[SerializeField] Tilemap tilemap;
[SerializeField] Transform marker;
[SerializeField] Point markerPosition;

I will use a reference to a Tilemap to display the board data as we make it. There will be a cursor object in the scene for fine changes, and I reference it’s Transform and named it “marker”. Then I will use a Point to indicate where the marker should appear over the tile map.

Add the following:

public void Clear()
{
    tiles = null;
    tilemap.ClearAllTiles();
}

We will use a method named “Clear” to wipe our board to a fresh slate. It will remove all of a Tilemap’s tiles, and set our local array of tiles to null.

public void Generate()
{
    Clear();
    tiles = new int[width * height];
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            tilemap.SetTile(new Vector3Int(x, y, 0), tileViews[0]);
        }
    }
}

The "Generate" method initializes a board, all using the first type of tile (in this case it will be water). It will create a new array of tiles with a calculated length based on our specified width and height. Then it will loop over those dimensions and for each (X,Y) pair it will "Set" a Tile in the Tilemap.

I would probably use this method if I wanted small boards - such as a non-scrolling board on a mobile phone. It may only be 6x8 or something like that, and might be more focused on manual tile placement.

public void GeneratePerlin()
{
    Clear();
    tiles = new int[width * height];
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            var xPos = x * perlinScale.x + perlinOffset.x;
            var yPos = y * perlinScale.y + perlinOffset.y;
            var elevation = Mathf.PerlinNoise(xPos, yPos);
            var tileIndex = IndexForElevation(elevation);
            var tileView = tileViews[tileIndex];
            tilemap.SetTile(new Vector3Int(x, y, 0), tileView);
            tiles[y * width + x] = tileIndex;
        }
    }
}

The "GeneratePerlin" method is also a starting place for a new board, but it will take into account our Perlin noise settings. At each (X,Y) pair, it will generate a Perlin value and then use that value along with our thresholds to see what kind of tile should be used. Note that the index of the tile view, is also assigned as the value for our tiles array - they should always match.

I mentioned earlier that you would use some simple math to determine how to map from a 2D point to a 1D index in our array. You can see that in the above snippet when we assign a "tileIndex" to our "tiles" array. The "index" is (y * width + x). So when y is 0, (y * width) will also be zero, and the index is the same as the x position. For any higher row (y > 0), we will have added a number of tiles equal to the width of the board for each row.

public void Grow()
{
    var index = markerPosition.y * width + markerPosition.x;
    tiles[index] = Mathf.Min(tiles[index] + 1, elevations.Length - 1);
    RefreshTile(index);
}

public void Shrink()
{
    var index = markerPosition.y * width + markerPosition.x;
    tiles[index] = Mathf.Max(tiles[index] - 1, 0);
    RefreshTile(index);
}

Whether we are manually painting a board, or want to modify a procedurally generated board, these methods will allow us a way to increment or decrement the elevation of a tile at the location of the "marker". Both the "Grow" and "Shrink" methods clamp the adjustment to be within the range of 0 to the number of tile types that we support.

public void MoveMarker(Point offset)
{
    markerPosition += offset;
    UpdateMarker();
}

We will use "MoveMarker" to move the "marker" around the tile map, so that when we use "Grow" or "Shrink" it can be at different locations on the board.

public void SnapMarker()
{
    markerPosition = new Point
    {
        x = Mathf.RoundToInt(marker.transform.position.x),
        y = Mathf.RoundToInt(marker.transform.position.y)
    };
    UpdateMarker();
}

public void UpdateMarker()
{
    marker.position = markerPosition;
}

In the event that it is faster to move the "marker" to a new position using its transform handles, then we can call "SnapMarker" to snap it to a grid location and make certain that our "model" matches the "view".

We can also go the opposite route, where we first change our "model", and then move the "view" to match. In that case we would use the "UpdateMarker" method.

public void Save()
{
    if (data == null)
    {
        Debug.LogError("Missing board data - must assign first");
        return;
    }

    Undo.RecordObject(data, "Saved Board");
    data.width = width;
    data.height = height;
    data.tiles = new int[tiles.Length];
    Array.Copy(tiles, data.tiles, tiles.Length);

    EditorUtility.SetDirty(data);
}

public void Load()
{
    Clear();

    if (data == null)
    {
        Debug.LogError("Missing board data - must assign first");
        return;
    }

    width = data.width;
    height = data.height;
    tiles = new int[data.tiles.Length];
    Array.Copy(data.tiles, tiles, data.tiles.Length);
        
    RefreshBoard();
}

The "Save" method will copy our local data to whatever BoardData asset has been assigned in the inspector. If the user has forgotten to provide one, then it will log an error to the console to let them know.

The "Load" method will copy from the asset to our local data. Then it will refresh the tile map so that it shows the appropriate tiles. Like with the "Save" method, if no BoardData asset is assigned then we will log an error to the console.

void RefreshBoard()
{
    for (int i = 0; i < tiles.Length; ++i)
        RefreshTile(i);
}

void RefreshTile(int index)
{
    var x = index % width;
    var y = index / width;
    var tileView = tileViews[tiles[index]];
    tilemap.SetTile(new Vector3Int(x, y, 0), tileView);
}

The "RefreshBoard" method is called to make sure that the "view" matches the "model" - in this case that means making sure the Tilemap is showing appropriate Tiles based on the elevations of our tiles data.

The "RefreshTile" method handles the refresh for a single tile by its index. You can see some more math here, where we convert from an index to its 2D position. The x position is the remainder after dividing index by the board's width - that is what the "%" character means. The y position is the result of dividing the index by the board's width and ignoring the remainder.

int IndexForElevation(float value)
{
    for (int index = 0; index < elevations.Length; ++index)
    {
        if (value < elevations[index])
        {
            return index;
        }
    }
    return elevations.Length - 1;
}

The "IndexForElevation" method was used in our "GeneratePerlin" method. It accepts as a parameter a value that ranges from 0 to 1 (it is the result of a perlin noise). Then we loop over the array of elevations and see if the passed value is less than the threshold at that index. If so, then we return the index to use.

Board Generator Inspector

Next we will create a custom inspector script for our BoardGenerator so that we can have buttons to trigger the various methods it exposed. This is an Editor script, and these kinds of scripts need to be located in a special folder in order to work properly, so go ahead and create a new folder in "Assets" named "Editor". Then create a new C# script in the folder named "BoardGeneratorInspector" and add the following:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(BoardGenerator))]
public class BoardGeneratorInspector : Editor
{
	public override void OnInspectorGUI()
	{
		var current = (BoardGenerator)target;
		DrawDefaultInspector();

		if (GUILayout.Button("Clear"))
			current.Clear();
		if (GUILayout.Button("Generate"))
			current.Generate();
		if (GUILayout.Button("Generate Perlin"))
			current.GeneratePerlin();
		if (GUILayout.Button("Grow"))
			current.Grow();
		if (GUILayout.Button("Shrink"))
			current.Shrink();
		if (GUILayout.Button("Snap Marker"))
			current.SnapMarker();

		GUILayout.Label("");
		if (GUILayout.Button("Save"))
			current.Save();
		if (GUILayout.Button("Load"))
			current.Load();

		if (GUI.changed)
			current.UpdateMarker();
	}

	// Thanks Mr. R
	// JULY 21, 2017 AT 4:16 PM
	void OnSceneGUI()
	{
		var current = (BoardGenerator)target;
		Event e = Event.current;
		if (!e.shift)
			return;

		switch (e.type)
		{
			case EventType.KeyDown:
				{
					switch (Event.current.keyCode)
					{
						case KeyCode.LeftArrow:
							current.MoveMarker(new Point(-1, 0));
							e.Use();
							break;
						case KeyCode.RightArrow:
							current.MoveMarker(new Point(1, 0));
							e.Use();
							break;
						case KeyCode.UpArrow:
							current.MoveMarker(new Point(0, 1));
							e.Use();
							break;
						case KeyCode.DownArrow:
							current.MoveMarker(new Point(0, -1));
							e.Use();
							break;
					}
					break;
				}
		}
	}
}

Some important bits to point out are that the script inherits from Editor, and that it is marked with a special attribute: [CustomEditor(typeof(BoardGenerator))]. Assuming the script also lives in an "Editor" folder, then whenever you select an instance of "BoardGenerator" in a scene, then we can override UI that would appear in the inspector.

To show a custom UI, you override the "OnInspectorGUI" method. It is possible to let Unity show whatever it would normally show by calling "DrawDefaultInspector" and then you can add to that with other calls. For each of the public methods in my "BoardGenerator", I create a new GUILayout.Button, and the click of the button invokes the method of the same name.

I used an GUILayout.Label with an empty string as a spacer between the editing buttons and buttons for saving or loading. Finally I check for any changes in the GUI (such as a changed value in the inspector) and use it as an opportunity to call "UpdateMarker" to make sure that the cursor appears where it is supposed to.

I'd like to call out one the helpful commenters on the first project, "Mr. R", for a tip. You can use "OnSceneGUI" to intercept various events, such as keyboard input. Normally, when looking at the scene pane, you can use the arrow keys to scroll around (move a preview camera to adjust what part of the scene you are looking at). With this snippet, I grab the current event, make sure that the shift key is also pressed, and then if so, will intercept keyboard events for the arrow keys to move the cursor instead. Note that this trick only works if the inspector is "locked" and the scene pane has focus.

inspector lock

Board Generator Scene

Tip* to follow along with this portion you must have added the necessary package to your project. You can open the package manager from the menu bar: "Window -> Package Manager". You must install the package "2D Tilemap Editor". You can also choose to install the "2D Tilemap Extras" though it is not required.

Create a new empty Scene named BoardGenerator. From the menu bar choose "GameObject -> 2D Object -> Tilemap -> Rectangular". This creates a hierarchy of objects, with "Grid" at the root, and "Tilemap" as a child. Select the "Tilemap" child and then in the inspector, add our "BoardGenerator" as a component.

There is a project asset at "Assets -> Sprites -> SelectionIndicator" which is cut up into multiple sprites. Select the first one, "SelectionIndicator_0" and drag it into the scene. Note it does not need to be parented to anything. Bump up its "Order In Layer" to make sure it will render on top of the tile map.

Next, reselect the "Tilemap" object and look in the inspector at the "BoardGenerator" component. Assign the references for both "Tilemap" (on the same object) and "Marker" (the cursor sprite we added). We also need to assign the tiles to use for the "Tile Views". Assign the four "EditorMap" sprites: "EditorMap_0" to "EditorMap_3".

Save the scene. This will be a good starting place for creating future levels.

Create a Board

Let's go ahead and try out our new tool. Make sure you are looking at the Scene tab (the Game scene won't show anything since there are no cameras). With the "Tilemap" game object selected you should see the inspector for our BoardGenerator. Feel free to leave the settings at their defaults, and then hit the "Generate" button. You can hit the "F" key to center and zoom the scene view as needed to "Focus" on the tilemap.

Generate

If you lock the inspector, then click the mouse in the Scene view, you can now hold the Shift key, and use the arrow keys to move the cursor around. Then use the "Grow" button to turn a water tile into a dirt tile, or a dirt tile into a hill tile etc. If you go too far, you can also do the reverse using the "Shrink" button.

Select the "SelectionIndicator" sprite in the scene and move it to a new position in the scene using the transform handles. Then use the "Snap Marker" button in the inspector and watch how it snaps into place at the nearest grid position. You can continue with "Grow" or "Shrink" at that location if desired.

Grow And Shrink

Click the "Clear" button and the tile map will have all its tiles deleted.

Next let's try a larger map, and let it be procedurally generated. Set the board's "width" and "height" to both be 25. Then click the "Generate Perlin" button. You may wish to use the "F" key to focus once again and see the whole map.

Generate Perlin

I like the results, but let's say I want to modify it slightly. As it is, the map is divided by a vertical stretch of hill and mountain tiles. Let's assume that for some reason I want to make sure that I can reach the right side of the map by using ground tiles, and so I want to split that range. No problem, just move the marker to the hill tiles and "Shrink" them to carve your path.

Cut a path

At this point, I feel like I have a board layout that is worth saving as an asset. Assign our "PerlinSample" asset to the "Data" field in the inspector, then click the "Save" button. If you inspect the asset, you should see that its values have been updated. Another quick way to verify would be to "Clear" the board, then try clicking "Load" - once again, you will see the board just as it was when we saved it.

Array Extensions

There is a way to make it seem like a class or data type has new features, without actually modifying the original code. This is called an "extension". I want to make an extension on an Array so that I can grab a member of the array at random.

Create a new folder in "Scripts" named "Extensions", then create a new C# script named "ArrayExtensions" and add the following:

public static class ArrayExtensions
{
    public static T Random<T>(this T[] array)
    {
        var index = UnityEngine.Random.Range(0, array.Length);
        return array[index];
    }
}

With the above code in my project, I can call "Random" on any array and get a random element from it. Note that there is no handling for null or empty arrays in this example.

Tile Palette

Next I will create a new subclass of ScriptableObject that holds references to named collections of tiles that can be used to "Skin" a board. For example, an array of ground tiles where they can be picked from randomly for any "type" of ground tile in my board data.

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

using UnityEngine;
using UnityEngine.Tilemaps;

[CreateAssetMenu]
public class TilePalette : ScriptableObject
{
    public TileBase[] water;
    public TileBase[] beach;
    public TileBase[] ground;
    public TileBase[] hill;
    public TileBase[] mountain;
}

Note that this also has the "CreateAssetMenu" attribute, so we will be able to create assets for this class from the menu.

The arrays for water, ground, hill and mountain are probably obvious, since those map nicely to the four generic types we saw in the board data. A fifth type, "beach" is also present. Technically I will treat the "beach" tiles as "water" tiles, but based on the proximity to non-water tiles, will want to render them differently.

Create a new folder at "Assets -> Objects" called "TilePalettes". With that new folder selected, use the menu action "Assets -> Create -> Tile Palette" and a new asset will be created in the folder. Name the asset "LightWater" and assign the tiles like so:

  • Water: D20_Tilemap_8, D20_Tilemap_9
  • Beach: D20_Tilemap_5
  • Ground: D20_Tilemap_0, D20_Tilemap_2
  • Hill: D20_Tilemap_14
  • Mountain: D20_Tilemap_12

Duplicate the asset, and name the copy "DarkWater" then modify the following:

  • Beach: D20_Tilemap_4
  • Ground: D20_Tilemap_1, D20_Tilemap_3
  • Hill: D20_Tilemap_15
  • Mountain: D20_Tilemap_13

Duplicate the "LightWater" asset once again, and name this one "LightLava" then modify the following:

  • Water: D20_Tilemap_10, D20_Tilemap_11
  • Beach: D20_Tilemap_7

Duplicate the "DarkWater" asset and name it "DarkLava" then modify the following:

  • Water: D20_Tilemap_10, D20_Tilemap_11
  • Beach: D20_Tilemap_6

Board Skin

Given the types of tiles that should appear on a given board, you can write another script that applies the specific tile to display at each grid position. Keeping the two types of data separate offers a certain amount of flexibility. For example, you could have the same level layout, but with tiles based on different seasons or time of day. You could even make holiday versions of your levels that appear at certain times, but in each of these cases you don't actually have to recreate the levels, you simply provide a new skin. Similarly, you can modify theses skins such as by adding or removing tiles, and all created levels will automatically be "updated" without having to change any of the levels themselves. At least that is the case if you choose to apply the skin at run-time, as we will do in this lesson.

You don't have to apply skins at runtime though - you could always apply the skin in editor, and then save the already skinned board as a prefab asset and load it as-is. While it may lose some flexibility, you could take the opportunity to do a few things that might be slightly harder such as adding extra tile map layers, or adding other child game objects like particles etc that aren't necessarily constrained to a grid. Both options have their good points.

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

using UnityEngine;
using UnityEngine.Tilemaps;

public interface IBoardSkin
{
    void Load(Tilemap tilemap, BoardData boardData);
}

I created a board skin as an interface, with the idea being that there may be many different ways to skin a board, and they may require different configurations etc. However, as long as they can conform to this interface, then I can swap them out without needing any code changes, I would merely need to create the code for the new skin.

Our concrete skin example will be yet another ScriptableObject asset. It will hold references to both a dark and light tile palette so that I can render the board with a sort of checkerboard appearance. Add the following:

[CreateAssetMenu]
public class BoardSkin : ScriptableObject
{
    [SerializeField] TilePalette darkPalette;
    [SerializeField] TilePalette lightPalette;

    public void Load(Tilemap tilemap, BoardData boardData)
    {
        tilemap.ClearAllTiles();
        for (int y = 0; y < boardData.height; ++y)
        {
            for (int x = 0; x < boardData.width; ++x)
            {
                var index = y * boardData.width + x;
                var isEvenColumn = x % 2 == 0;
                var isEvenRow = y % 2 == 0;
                var isLight = isEvenColumn && isEvenRow || !isEvenColumn && !isEvenRow;
                var palette = isLight ? lightPalette : darkPalette;
                var elevation = boardData.tiles[index];

                var tileAbove = elevation;
                if (y + 1 < boardData.height)
                    tileAbove = boardData.tiles[index + boardData.width];
                
                switch (elevation)
                {
                    case 0:
                        if (tileAbove == elevation)
                            tilemap.SetTile(new Vector3Int(x, y, 0), palette.water.Random());
                        else
                            tilemap.SetTile(new Vector3Int(x, y, 0), palette.beach.Random());
                        break;
                    case 1:
                        tilemap.SetTile(new Vector3Int(x, y, 0), palette.ground.Random());
                        break;
                    case 2:
                        tilemap.SetTile(new Vector3Int(x, y, 0), palette.hill.Random());
                        break;
                    case 3:
                        tilemap.SetTile(new Vector3Int(x, y, 0), palette.mountain.Random());
                        break;
                }
            }
        }
    }
}

A board skin is an asset, so it also has the "CreateAssetMenu" attribute. The single method, "Load" starts by clearing any existing tiles that may have been present on the Tilemap. Then, it does a nested loop over the pairs of (X,Y) coordinates that could be present based on the BoardData's width and height. Based on the row and column, I will either want to set tiles from the light or dark palette. Also, I check the "tile above" the tile I am looking at so that I can see where edges are between water and any other type of tile. Finally, I switch based on the "elevation" of the tile and call "SetTile" using a "Random" element from the tile palette that is appropriate.

Head back over to Unity and create a new folder at "Assets -> Objects" named "BoardSkins". Then, with our new folder selected, use the menu action "Assets -> Create -> Board Skin". Name the skin "WaterSkin" and configure it as follows:

  • Dark Palette: DarkWater
  • Light Palette: LightWater

Either duplicate the skin, or create another from the menu. Name the new skin "LavaSkin" and configure it as follows:

  • Dark Palette: DarkLava
  • Light Palette: LightLava

Encounter

For this project, I will make an Encounter define what board data to use, and what board skin to apply to it. So go ahead and open that script and add the following to the IEncounter interface:

BoardData BoardData { get; }
BoardSkin BoardSkin { get; }

Then add this to the Encounter class:

public BoardData BoardData { get { return boardData; } }
[SerializeField] BoardData boardData;

public BoardSkin BoardSkin { get { return boardSkin; } }
[SerializeField] BoardSkin boardSkin;

Encounter Asset

Select the "Encounter_01" project asset and configure it as follows:

  • Board Data: PerlinSample
  • Board Skin: WaterSkin

You may optionally use the "LavaSkin" and I recommend you try both just to see that it works with different skins.

In addition to the above changes, we will need to adjust the spawn points for the hero and monster because the original map was centered around the origin, but my new maps are generated starting at the origin. Set the monster spawn to (5, 3) and the Hero spawn to (0, 3).

Encounter System

To use the new data, we will need to modify the EncounterSystem. Open that script and add the following line to the end of the "Setup" method:

IBoardSystem.Resolve().Load(encounter);

Board System

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

using UnityEngine;
using UnityEngine.Tilemaps;

public interface IBoardSystem : IDependency<IBoardSystem>
{
    BoardData BoardData { get; }
    void Load(IEncounter encounter);
    TileBase GetTile(Point point);
}

Here we see a new interface that knows how to load a game board based on an "Encounter" asset. In addition, we will let it keep a handy reference to the loaded "BoardData" so that information about the board can be quickly looked up. Finally, it will provide a way to determine what "TileBase" actually appears at a given "Point". While the "BoardData" tiles will hold some level info about a kind of tile, the actual TileBase may hold even more specific info such as a ground tile having a harvestable plant on it.

public class BoardSystem : MonoBehaviour, IBoardSystem
{
    public BoardData BoardData { get; private set; }
    Tilemap tilemap;

    public void Load(IEncounter encounter)
    {
        BoardData = encounter.BoardData;
        encounter.BoardSkin.Load(tilemap, BoardData);
    }

    public TileBase GetTile(Point point)
    {
        return tilemap.GetTile(new Vector3Int(point.x, point.y, 0));
    }

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

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

I have decided to implement the class that conforms to the interface as a subclass of MonoBehaviour. It will be a component on the same GameObject as the Tilemap, so it can simply grab a reference to it when it is enabled.

The "Load" method assigns the local reference of the "BoardData" and then calls "Load" on the BoardSkin and passes along the reference to the tile map and board data that the skin needs in order to do its job.

The "GetTile" method is just a wrapper for the "GetTile" method on the "Tilemap" itself. I also handle the conversion from a Point to a Vector3Int that the Tilemap expects.

Camera Follow

Up until now, our encounter appeared on a small board - no scrolling required. The board we created in this lesson is notably larger, and does require some scrolling in order to view its extents. In order to accommodate this, we will add a simple little script that causes the camera to track the selection indicator.

Create a new C# script at "Scripts -> UI" named "CameraFollow" and add the following:

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    [SerializeField] Transform target;
    [SerializeField] float speed = 0.9f;

    void Update()
    {
        if (target == null)
            return;

        var pos = transform.position;
        var lerp = Vector3.Lerp(pos, target.position, speed * Time.deltaTime);
        var result = new Vector3(lerp.x, lerp.y, pos.z);
        transform.position = result;
    }
}

Grid Prefab

Open the asset at "Assets -> Prefabs -> Grid". Select the "Tilemap" GameObject and clear all the tiles in its Tilemap Component. Next attach the "BoardSystem" script as a Component.

Encounter Scene

Open the "Encounter" scene. Select the "Main Camera" object and attach the "CameraFollow" script as a component. Assign the "Selection_Indicator" as its "Target", and set the "Speed" to 0.9.

Combatant Assets

The shadows beneath the Warrior and Rat were always kind of ugly, but they look especially bad on my new art. To help, I modified the sprite color to RGBA: 70, 83, 143, 60.

Demo

Go ahead and play the game starting from the "LoadingScreen" like normal. When you reach the encounter, note that the board is now skinned based on the tiles we specified. When you take an action to stride or strike, you can move the selection indicator, and the camera will follow it around making sure you can see what you need to see.

If you want, you could try swapping the skin that is assigned to the encounter. You may wish to try the lava skin instead of the water skin.

Encounter With Board Skin

Summary

This was a huge lesson, so great job if you've reached this far! We created a new scene that lets us generate board data where we can specify different tile types along with board width and height. Then we created assets to help us skin the board with different types of tiles, and our final skin picked randomly from light or dark tiles so the overall result had a checkerboard appearance.

Finally we made sure that the encounter could determine which board and skin to use, and hooked up systems etc so that it would all load properly. A quick little camera script made sure we could scroll around our new larger board!

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!

2 thoughts on “D20 RPG – Board

Leave a Reply

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