
GameBoard component, which can be initialized by setting the size in two dimensions, for which we can use the value of Vector2Int . The field should work with any size, but we will choose the size somewhere else, so we will create a common Initialize method for this. using UnityEngine; public class GameBoard : MonoBehaviour { [SerializeField] Transform ground = default; Vector2Int size; public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); } } null , but I did so explicitly to show that we simply use the default value, which is not a true reference to ground, so we use default .


Game , which will be responsible for the entire game. At this stage, this will mean that it initializes the field. We simply make the size customizable through the inspector and force the component to initialize the field when it wakes up. Let's use the default size of 11 × 11. using UnityEngine; public class Game : MonoBehaviour { [SerializeField] Vector2Int boardSize = new Vector2Int(11, 11); [SerializeField] GameBoard board = default; void Awake () { board.Initialize(boardSize); } } OnValidate method, which forcibly limits minimum values. void OnValidate () { if (boardSize.x < 2) { boardSize.x = 2; } if (boardSize.y < 2) { boardSize.y = 2; } } OnValidate is the only place in the code where values can be assigned to the component configuration fields.



GameTile with a link to their arrow. using UnityEngine; public class GameTile : MonoBehaviour { [SerializeField] Transform arrow = default; } 


GameBoard must have a link to the tile prefab. [SerializeField] GameTile tilePrefab = default; 
public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); Vector2 offset = new Vector2( (size.x - 1) * 0.5f, (size.y - 1) * 0.5f ); for (int y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++) { GameTile tile = Instantiate(tilePrefab); tile.transform.SetParent(transform, false); tile.transform.localPosition = new Vector3( x - offset.x, 0f, y - offset.y ); } } } 
GameTile[] tiles; public void Initialize (Vector2Int size) { … tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { GameTile tile = tiles[i] = Instantiate(tilePrefab); … } } } GameTile t = Instantiate(tilePrefab); tiles[i] = t; GameTile tile = t; GameTile track links to its four neighbors. GameTile north, east, south, west; GameTile to define this relationship between two tiles. public static void MakeEastWestNeighbors (GameTile east, GameTile west) { west.east = east; east.west = west; } eastTile.MakeEastWestNeighbors(westTile) or something like that. But in cases where it is not clear which of the tiles the method should be called, it is better to use static methods. Examples are the Distance and Dot methods of the Vector3 class.null , and displaying an error to the console if this is incorrect. For this you can use the Debug.Assert method. public static void MakeEastWestNeighbors (GameTile east, GameTile west) { Debug.Assert( west.east == null && east.west == null, "Redefined neighbors!" ); west.east = east; east.west = west; } false , then it displays a condition error, using the second argument for this, if it is specified. Such a call is included only in test builds, but not in release builds. Therefore, it is a good way to add checks during the development process that will not affect the final release. public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) { Debug.Assert( south.north == null && north.south == null, "Redefined neighbors!" ); south.north = north; north.south = south; } GameBoard.Initialize . If the X coordinate is greater than zero, then we can create an east-west relationship between the current and the previous tile. If the Y coordinate is greater than zero, then we can create a north-south relationship between the current tile and the tile from the previous line. for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … if (x > 0) { GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]); } if (y > 0) { GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]); } } } null .GameTile by adding a link to the next tile path. In addition, we will also save the distance to the end point, expressed as the number of tiles that must be visited before the enemy reaches the end point. This information is useless for enemies, but we will use it to find the shortest paths. GameTile north, east, south, west, nextOnPath; int distance; int.MaxValue . Add a generic ClearPath method to reset GameTile to this state. public void ClearPath () { distance = int.MaxValue; nextOnPath = null; } public void BecomeDestination () { distance = 0; nextOnPath = null; } int.MaxValue . Add a convenient getter property to check if the tile currently has a path. public bool HasPath => distance != int.MaxValue; public bool HasPath { get { return distance != int.MaxValue; } } => can also be used separately for a getter and a setter of properties, for method bodies, constructors, and in some other places.GameTile hidden method for growing the path to one of its neighbors, which is set via a parameter. The distance to the neighbor becomes one more than that of the current tile, and the path of the neighbor points to the current tile. This method should only be called for those tiles that already have a path, so let's check this with assert. void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); neighbor.distance = distance + 1; neighbor.nextOnPath = this; } null , we will check this and stop execution if it is. In addition, if the neighbor already has a path, then we should not do anything and also stop the execution. void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); if (neighbor == null || neighbor.HasPath) { return; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; } GameTile tracks its neighbors is unknown to the rest of the code. Therefore, GrowPathTo is hidden. We will add general methods that tell the tile to grow its path in a certain direction, indirectly causing GrowPathTo . But the code that searches across the entire field must keep track of which tiles have been visited. Therefore, we will make it return a neighbor or null if execution is terminated. GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor; } public GameTile GrowPathNorth () => GrowPathTo(north); public GameTile GrowPathEast () => GrowPathTo(east); public GameTile GrowPathSouth () => GrowPathTo(south); public GameTile GrowPathWest () => GrowPathTo(west); GameBoard must. We implement this by performing a breadth-first search. We start with the end point tile, and then grow the path to its neighbors, then to the neighbors of these tiles, and so on. With each step, the distance increases by one, and the paths never grow in the direction of tiles, which already have paths. This ensures that all tiles as a result will point along the shortest path to the end point.Queue . Later we will have to perform a search several times, so we will set it as a GameBoard field. using UnityEngine; using System.Collections.Generic; public class GameBoard : MonoBehaviour { … Queue<GameTile> searchFrontier = new Queue<GameTile>(); … } Initialize , but put the code in a separate FindPaths method. First of all, you need to clear the path of all the tiles, then make one tile the end point and add it to the border. Let's first select the first tile. Since tiles an array, we can use the foreach without fear of memory pollution. If we later move from an array to a list, then we will also need to replace the foreach cycles with for loops. public void Initialize (Vector2Int size) { … FindPaths(); } void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); } public void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } while (searchFrontier.Count > 0) { GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } null , but we can also postpone checking for null until after we leave the queue. GameTile tile = searchFrontier.Dequeue(); if (tile != null) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } GameTile one Quaternion static field for each of the directions. static Quaternion northRotation = Quaternion.Euler(90f, 0f, 0f), eastRotation = Quaternion.Euler(90f, 90f, 0f), southRotation = Quaternion.Euler(90f, 180f, 0f), westRotation = Quaternion.Euler(90f, 270f, 0f); ShowPath method. If the distance is zero, the tile is the end point and there’s nothing to point to, so deactivate its arrow. Otherwise, activate the arrow and set its rotation. The desired direction can be determined by comparing nextOnPath with its neighbors. public void ShowPath () { if (distance == 0) { arrow.gameObject.SetActive(false); return; } arrow.gameObject.SetActive(true); arrow.localRotation = nextOnPath == north ? northRotation : nextOnPath == east ? eastRotation : nextOnPath == south ? southRotation : westRotation; } GameBoard.FindPaths. public void FindPaths () { … foreach (GameTile tile in tiles) { tile.ShowPath(); } } 
FindPaths. tiles[tiles.Length / 2].BecomeDestination(); searchFrontier.Enqueue(tiles[tiles.Length / 2]); 
searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()) 
GameTilecommon property indicating whether the current tile is an alternative one. public bool IsAlternative { get; set; } GameBoard.Initialize. First we mark tiles as alternative if their X coordinate is even. for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.IsAlternative = (x & 1) == 0; } } tile.IsAlternative = (x & 1) == 0; if ((y & 1) == 0) { tile.IsAlternative = !tile.IsAlternative; } FindPaths , . . if (tile != null) { if (tile.IsAlternative) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()); } else { searchFrontier.Enqueue(tile.GrowPathWest()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathNorth()); } } 
GameTileContentType . public enum GameTileContentType { Empty, Destination } GameTileContentthat allows you to specify the type of its contents through the inspector, and access to it will be done through the common property-getter. using UnityEngine; public class GameTileContent : MonoBehaviour { [SerializeField] GameTileContentType type = default; public GameTileContentType Type => type; } GameTileContentwith the corresponding specified type. Let's use the blue flattened cube to designate endpoint tiles. Since it is almost flat, it does not need a collider. For the prefab of empty content, use an empty game object.

null.GameTileContentmust track your original factory, which should only be set once, and send yourself back to the factory in the method Recycle. GameTileContentFactory originFactory; … public GameTileContentFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } GameTileContentFactory, so we will create a scriptable object type for this with a required method Recycle. At this stage, we are not going to bother creating a fully functional factory that disposes the contents, so we’ll make it just destroy the contents. Later it will be possible to add object reuse to the factory without changing the rest of the code. using UnityEngine; using UnityEngine.SceneManagement; [CreateAssetMenu] public class GameTileContentFactory : ScriptableObject { public void Reclaim (GameTileContent content) { Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(content.gameObject); } } Getwith a prefab as a parameter. Here we again miss the multiple use of objects. It creates an instance of the object, sets its original factory, moves it to the factory stage, and returns it. GameTileContent Get (GameTileContent prefab) { GameTileContent instance = Instantiate(prefab); instance.OriginFactory = this; MoveToFactoryScene(instance.gameObject); return instance; } Scene contentScene; … void MoveToFactoryScene (GameObject o) { if (!contentScene.isLoaded) { if (Application.isEditor) { contentScene = SceneManager.GetSceneByName(name); if (!contentScene.isLoaded) { contentScene = SceneManager.CreateScene(name); } } else { contentScene = SceneManager.CreateScene(name); } } SceneManager.MoveGameObjectToScene(o, contentScene); } [SerializeField] GameTileContent destinationPrefab = default; [SerializeField] GameTileContent emptyPrefab = default; Getwith a parameter GameTileContentTypethat receives an instance of the corresponding prefab. public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 
Gamelink to the factory. [SerializeField] GameTileContentFactory tileContentFactory = default; 
Game , , , GameBoard .GameBoard GetTile , null ( , ). public GameTile GetTile (Ray ray) { return null; } Physics.Raycast, specifying the ray as an argument. It returns information about whether there was an intersection. If so, then we will be able to return the tile, even though we still don’t know which one, so for now we’ll return it null. public GameTile TryGetTile (Ray ray) { if (Physics.Raycast(ray) { return null; } return null; } Physics.Raycastcan provide this information using the second parameter RaycastHit. This is an output parameter, which is indicated by the word outin front of it. This means that a method call can assign a value to a variable that we pass to it. RaycastHit hit; if (Physics.Raycast(ray, out hit) { return null; } if (Physics.Raycast(ray, out RaycastHit hit) { return null; } if (Physics.Raycast(ray, out RaycastHit hit)) { int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); return tiles[x + y * size.x]; } int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); if (x >= 0 && x < size.x && y >= 0 && y < size.y) { return tiles[x + y * size.x]; } GameTilecommon property Content. Its getter simply returns the content, and the setter recycles the previous content, if it was, and places the new content. GameTileContent content; public GameTileContent Content { get => content; set { if (content != null) { content.Recycle(); } content = value; content.transform.localPosition = transform.localPosition; } } null, because initially we have no content. To ensure we run assert so that the setter is not called from null. set { Debug.Assert(value != null, "Null assigned to content!"); … } ScreenPointToRaywith Input.mousePositionas an argument. The call must be made for the main camera, access to which can be obtained through Camera.main. Add for this property in Game. Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition); Updatethat checks whether the primary mouse button was pressed during the update. To do this, call Input.GetMouseButtonDownwith zero as an argument. If the key was pressed, we process the player's touch, that is, we take the tile from the field, and set the end point as its content, taking it from the factory. void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } } void HandleTouch () { GameTile tile = GetTile(TouchRay); if (tile != null) { tile.Content = tileContentFactory.Get(GameTileContentType.Destination); } } 
GameBoard, so let's pass on to it the duty of defining the contents of the tile. To do this, give it a link to the content factory through its method Intialize, and use it to give all tiles an instance of empty content. GameTileContentFactory contentFactory; public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { this.size = size; this.contentFactory = contentFactory; ground.localScale = new Vector3(size.x, size.y, 1f); tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.Content = contentFactory.Get(GameTileContentType.Empty); } } FindPaths(); } Gamemust transfer my factory to the field. void Awake () { board.Initialize(boardSize, tileContentFactory); } GameBoard.FindPathsso that it calls BecomeDestinationfor each one and adds them all to the border. And that's all it takes to support multiple endpoints. All other tiles are cleared as usual. Then we remove the hard end point in the center. void FindPaths () { foreach (GameTile tile in tiles) { if (tile.Content.Type == GameTileContentType.Destination) { tile.BecomeDestination(); searchFrontier.Enqueue(tile); } else { tile.ClearPath(); } } //tiles[tiles.Length / 2].BecomeDestination(); //searchFrontier.Enqueue(tiles[tiles.Length / 2]); … } FindPathswill not be able to perform its task. This happens when the border is empty after initializing the paths for all cells. We denote this as an invalid state of the field, returning falseand terminating the execution; otherwise return at the end true. bool FindPaths () { foreach (GameTile tile in tiles) { … } if (searchFrontier.Count == 0) { return false; } … return true; } GameBoard, so we will give him a general method ToggleDestination, the parameter of which is a tile. If the tile is the end point, then we make it empty and call it FindPaths. Otherwise, make it the end point and also call FindPaths. public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } FindPathsafter we made the tile empty. If not, then cancel the change, turning the tile into the end point again, and calling again FindPathsto return to the previous correct state. if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } FindPaths , .Initializewe can call ToggleDestinationwith the central tile as an argument, instead of explicitly calling FindPaths. This is the only time we start with the wrong state of the field, but we are guaranteed to end with the correct state. public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { … //FindPaths(); ToggleDestination(tiles[tiles.Length / 2]); } Gamecall ToggleDestinationinstead of setting the content of the tile itself. void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { //tile.Content = //tileContentFactory.Get(GameTileContentType.Destination); board.ToggleDestination(tile); } } 
Game . , .GameTileContentTypean element to them. public enum GameTileContentType { Empty, Destination, Wall } 


[SerializeField] GameTileContent wallPrefab = default; … public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); case GameTileContentType.Wall: return Get(wallPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 
GameBoardthe on / off method for walls, as we did for the end point. Initially, we will not check the invalid state of the field. public void ToggleWall (GameTile tile) { if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Wall); FindPaths(); } } FindPaths, and discard changes if they created an invalid field state. else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Wall); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } } Gamemain touch is performed. The end points can be switched by an additional touch (usually the right mouse button), which can be recognized by transferring to the Input.GetMouseButtonDownvalue 1. void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } else if (Input.GetMouseButtonDown(1)) { HandleAlternativeTouch(); } } void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleDestination(tile); } } void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleWall(tile); } } 
public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } GameTile.GrowPathTonot to return tiles with walls. But the path should still grow in the direction of the wall so that all tiles on the field have a path. This is necessary because there is a possibility that the tile with the enemies will suddenly turn into a wall. GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } GameBoard.FindPathsmust check this after the search is completed. If this is not the case, then the state of the field is incorrect and must be returned false. You do not need to update the path visualization for invalid states, because the field will return to the previous state. bool FindPaths () { … foreach (GameTile tile in tiles) { if (!tile.HasPath) { return false; } } foreach (GameTile tile in tiles) { tile.ShowPath(); } return true; } 

GameTilegeneric method HidePaththat simply disables its arrow. public void HidePath () { arrow.gameObject.SetActive(false); } GameBoardboolean field, equal by default false, to track its status, as well as a common property as a getter and setter. Setter must show or hide paths on all tiles. bool showPaths; public bool ShowPaths { get => showPaths; set { showPaths = value; if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } else { foreach (GameTile tile in tiles) { tile.HidePath(); } } } } FindPathsshould show updated paths only if visualization is enabled. bool FindPaths () { … if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } return true; } Gameswitches the rendering state when the key is pressed. It would be logical to use the P key, but it is also a hot key to enable or disable the game mode in the Unity editor. As a result, the visualization will switch when the hot key to exit the game mode is used, which does not look very nice. So let's use the V key (short for visualization).

GameBoardthe configuration field Texture2Dand select a grid texture for it. [SerializeField] Texture2D gridTexture = default; 
GetComponent<MeshRenderer>for the earth and gaining access to the materialresult property . If the mesh needs to be displayed, then assign a mainTexturemesh texture to the material property . Otherwise, assign him null. Note that when you change the texture of the material, duplicate copies of the material will be created, so it becomes independent of the asset of the material. bool showGrid, showPaths; public bool ShowGrid { get => showGrid; set { showGrid = value; Material m = ground.GetComponent<MeshRenderer>().material; if (showGrid) { m.mainTexture = gridTexture; } else { m.mainTexture = null; } } } Gameswitches the visualization of the grid with the G key. void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } } Awake. void Awake () { board.Initialize(boardSize, tileContentFactory); board.ShowGrid = true; } 
SetTextureScalematerial method with the name of the texture property ( _MainTex ) and a two-dimensional size. We can directly use the size of the field, which is indirectly converted to a value Vector2. if (showGrid) { m.mainTexture = gridTexture; m.SetTextureScale("_MainTex", size); } 

Source: https://habr.com/ru/post/449798/
All Articles