TL;DR: WebGL Demo & UnityPackage Download
Recursive Nodes
In the past I have tackled a similar problem by attaching a node component to each grid object. Each node component in turn keeps track of its neighbor nodes in a what could be described as a recursive manner.
In my experience this approach works with thousands of nodes in a game world in a WebGL project, with no appreciable impact on performance as compared to other methods I've tried.
public class Node : Monobehaviour
{
public List<Node> neighbors;
public NodeType type; // an enum defined elsewhere
// other properties and methods as needed...
}
Populating the Nodes
You could use your raycasting method in an editor script to do a one-time data population of these node instances, or any number of other methods depending on whether performance is an issue. Once you have your nodes populated it's straightforward to set up A* or other kinds of path following logic.
Example 1: Generating Tiles & Finding Neighbors
We'll start a simple scene with just a Controller object added -- this will be used to hold the script to generate our grid and populate the neighbor data of each tile. The assets we need for the demo are some materials to differentiate the tiles, two scripts, and a hextile prefab.
The hexTile prefab has the usual usual rendering components and a convex mesh collider. Importantly, it has a HexTile component added:
In this example HexTile is the equivalent of a node as described above. For this first example the only thing that really matters is the list of neighbors which are also HexTiles. There could be all kinds of other properties and functionality, and you could derive other classes from this for specialized behavior (maybe a volcano that erupts or who knows what)... anyway:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class HexTile : MonoBehaviour
{
public List<HexTile> neighbors = new List<HexTile>();
}
Now on to the controller script. I have some variables for generating the hex grid that I can set in the inspector:
[SerializeField] private int rows;
[SerializeField] private int columns;
[SerializeField] private float xOffset;
[SerializeField] private float zOffset;
I also have a reference to the prefab, a list of "standard" materials, and a special highlight material for demo purposes.
[SerializeField] private GameObject hexTilePrefab;
[SerializeField] private Material[] hexMaterials;
[SerializeField] private Material hilightMaterial;
Finally, a keep an internal list of the hext tiles I generate:
List<HexTile> tiles = new List<HexTile>();
The code to generate the grid is simple-- a nested loop that instantiates gamebojects and adds their HexTile component to the list. I won't dwell on this as you already have your own working solution:
void CreateGrid()
{
float xPos = 0;
for (int x = 0; x < rows; x++)
{
float zPos = x % 2 == 1 ? zOffset / 2 : 0;
for (int z = 0; z < columns; z++)
{
var go = Instantiate(hexTilePrefab);
go.name = "Hex_" + x + "_" + z;
go.transform.position = new Vector3(xPos, 0, zPos);
go.GetComponent<Renderer>().sharedMaterial = hexMaterials[Random.Range(0, hexMaterials.Length)];
tiles.Add(go.GetComponent<HexTile>());
zPos += zOffset;
}
xPos += xOffset;
}
}
My code for populating each HexTile with its neighbors is dumb and inefficient -- but it works. I'm simply looping thru all the tiles and checking whether they are within a unit distance, if so they are added. There are some obvious optimizations that I'll leave for an excercise for the reader (hint: each tile only has at most 6 neighbors.) And note this has a hard-coded magic number that will break if the tiles aren't approximately 1 unit in diameter.
If this code was only used in the editor to bake values it should be fine -- if not and there are huge numbers of tiles at runtime there are no doubt other faster and more efficient approaches to tackle this...
void PopulateHexTiles()
{
foreach (HexTile tile in tiles)
{
foreach(HexTile testedTile in tiles)
{
if (testedTile == tile)
{
continue;
}
if ((tile.transform.position - testedTile.transform.position).magnitude < 1) //TODO Fix magic number
{
tile.neighbors.Add(testedTile);
}
}
}
}
Finally, I have function to call a tile and "light up" its neighbors -- for demo purposes.
void ColorNeighboringHexes(HexTile tile)
{
foreach (HexTile neighbor in tile.neighbors)
{
neighbor.gameObject.GetComponent<Renderer>().sharedMaterial = hilightMaterial;
}
}
Here's the entire script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class HexGridController : MonoBehaviour
{
[SerializeField] private int rows;
[SerializeField] private int columns;
[SerializeField] private float xOffset;
[SerializeField] private float zOffset;
[SerializeField] private GameObject hexTilePrefab;
[SerializeField] private Material[] hexMaterials;
[SerializeField] private Material hilightMaterial;
List<HexTile> tiles = new List<HexTile>();
void Start()
{
CreateGrid();
PopulateHexTiles();
//Demo accessing neighbors
ColorNeighboringHexes(tiles[Random.Range(0, tiles.Count)]);
}
void CreateGrid()
{
float xPos = 0;
for (int x = 0; x < rows; x++)
{
float zPos = x % 2 == 1 ? zOffset / 2 : 0;
for (int z = 0; z < columns; z++)
{
var go = Instantiate(hexTilePrefab);
go.name = "Hex_" + x + "_" + z;
go.transform.position = new Vector3(xPos, 0, zPos);
go.GetComponent<Renderer>().sharedMaterial = hexMaterials[Random.Range(0, hexMaterials.Length)];
tiles.Add(go.GetComponent<HexTile>());
zPos += zOffset;
}
xPos += xOffset;
}
}
void PopulateHexTiles()
{
foreach (HexTile tile in tiles)
{
foreach(HexTile testedTile in tiles)
{
if (testedTile == tile)
{
continue;
}
if ((tile.transform.position - testedTile.transform.position).magnitude < 1)
{
tile.neighbors.Add(testedTile);
}
}
}
}
void ColorNeighboringHexes(HexTile tile)
{
foreach (HexTile neighbor in tile.neighbors)
{
neighbor.gameObject.GetComponent<Renderer>().sharedMaterial = hilightMaterial;
}
}
}
And here is how the HexGridController component is configured:
Here is a sample result when the thing runs:
Example 2: Illuminating Neighbors by Tag (or Type)
Now we'll extend this to address the OPs specific problem -- getting neighbors that match a certain tag. To do that we'll create a HexTile.TileType enum for this.
public enum TileType
{
grass,
water,
sand
}
I'm also going to add a public property to hold this value for each tile...
public TileType type;
and we'll add a method that returns all neighboring tiles that match the requested type:
public List<HexTile> GetNeighborsWithTag(TileType requestedType)
{
return neighbors.FindAll(x => x.type == requestedType);
}
Here's the complete, revised HexTile script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class HexTile : MonoBehaviour
{
public enum TileType
{
grass,
water,
sand
}
public TileType type;
public List<HexTile> neighbors = new List<HexTile>();
public List<HexTile> GetNeighborsWithTag(TileType requestedType)
{
return neighbors.FindAll(x => x.type == requestedType);
}
}
Next we want to modify our controller to take tile type into account when generating tiles. The first step is to define a struct we can serialize that will associate a material with a HexTile.TileType:
[System.Serializable]
public struct HexTileData
{
public Material material;
public HexTile.TileType type;
}
Now, we need to create a set of these HexTileData that the generation script can choose from that we can configure in the inspector:
[SerializeField] private HexTileData[] tileData;
Then, a modification of our generation code to chose from these data randomly, and assign materials and tile types accordingly:
void CreateGrid()
{
float xPos = 0;
for (int x = 0; x < rows; x++)
{
float zPos = x % 2 == 1 ? zOffset / 2 : 0;
for (int z = 0; z < columns; z++)
{
var go = Instantiate(hexTilePrefab);
go.name = "Hex_" + x + "_" + z;
go.transform.position = new Vector3(xPos, 0, zPos);
var data = tileData[Random.Range(0, tileData.Length)];
go.GetComponent<Renderer>().sharedMaterial = data.material;
go.GetComponent<HexTile>().type = data.type;
tiles.Add(go.GetComponent<HexTile>());
zPos += zOffset;
}
xPos += xOffset;
}
}
We'll add a new demo test method to light up only neighboring tiles that match the requested type:
void ColorNeigborTilesMatchingType(HexTile tile, HexTile.TileType type)
{
foreach (HexTile neighbor in tile.GetNeighborsWithTag(type))
{
neighbor.gameObject.GetComponent<Renderer>().sharedMaterial = hilightMaterial;
}
}
And with that, here's the complete, modified HexGridController script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class HexGridController : MonoBehaviour
{
[SerializeField] private int rows;
[SerializeField] private int columns;
[SerializeField] private float xOffset;
[SerializeField] private float zOffset;
[SerializeField] private GameObject hexTilePrefab;
[SerializeField] private HexTileData[] tileData;
[SerializeField] private Material hilightMaterial;
List<HexTile> tiles = new List<HexTile>();
[System.Serializable]
public struct HexTileData
{
public Material material;
public HexTile.TileType type;
}
void Start()
{
CreateGrid();
PopulateHexTiles();
ColorNeigborTilesMatchingType(tiles [Random.Range(0, tiles.Count)], HexTile.TileType.sand);
}
void CreateGrid()
{
float xPos = 0;
for (int x = 0; x < rows; x++)
{
float zPos = x % 2 == 1 ? zOffset / 2 : 0;
for (int z = 0; z < columns; z++)
{
var go = Instantiate(hexTilePrefab);
go.name = "Hex_" + x + "_" + z;
go.transform.position = new Vector3(xPos, 0, zPos);
var data = tileData[Random.Range(0, tileData.Length)];
go.GetComponent<Renderer>().sharedMaterial = data.material;
go.GetComponent<HexTile>().type = data.type;
tiles.Add(go.GetComponent<HexTile>());
zPos += zOffset;
}
xPos += xOffset;
}
}
void PopulateHexTiles()
{
foreach (HexTile tile in tiles)
{
foreach(HexTile testedTile in tiles)
{
if (testedTile == tile)
{
continue;
}
if ((tile.transform.position - testedTile.transform.position).magnitude < 1)
{
tile.neighbors.Add(testedTile);
}
}
}
}
void ColorNeighboringHexes(HexTile tile)
{
foreach (HexTile neighbor in tile.neighbors)
{
neighbor.gameObject.GetComponent<Renderer>().sharedMaterial = hilightMaterial;
}
}
void ColorNeigborTilesMatchingType(HexTile tile, HexTile.TileType type)
{
foreach (HexTile neighbor in tile.GetNeighborsWithTag(type))
{
neighbor.gameObject.GetComponent<Renderer>().sharedMaterial = hilightMaterial;
}
Now the GridController game object with this controller is configured like so in the inspector:
And a sample output looks like this:
Optimization: What about all those Monobehaviours?
You might be concerned that having hundreds or thousands of objects with Monobehaviours might be a serious drag on performance. I don't think this is a big issue, assuming the HexTile behaviours aren't responding to events like Update and so on. To test this, I rewrote the scripts to leave the individual tiles "dumb" (no extra components attached) and instead stored the data in the controller. Performance was, as far I could tell, identical up to 100 x 100 tiles; by far a bigger drag is all the extra draw calls, which I demonstrated by turning off all the renderers. Optimizing the draw calls is a different subject (mesh combination, etc.) and probably off topic. But if anyone is interested I can share the version of the project that does not rely on individual Monobehaviours attached to each tile.
More Info
You can try a slightly enhanced version of this demo (mouse position determines which tiles are polled, and you can choose tile type to highlight as well as generate larger or smaller grids) here on my itch.io page. The project with code is available there as a unitypackage download, as well.