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())
GameTile
common 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 }
GameTileContent
that 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; }
GameTileContent
with 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
.GameTileContent
must 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); } }
Get
with 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;
Get
with a parameter GameTileContentType
that 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; }
Game
link 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.Raycast
can provide this information using the second parameter RaycastHit
. This is an output parameter, which is indicated by the word out
in 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]; }
GameTile
common 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!"); … }
ScreenPointToRay
with Input.mousePosition
as 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);
Update
that checks whether the primary mouse button was pressed during the update. To do this, call Input.GetMouseButtonDown
with 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(); }
Game
must transfer my factory to the field. void Awake () { board.Initialize(boardSize, tileContentFactory); }
GameBoard.FindPaths
so that it calls BecomeDestination
for 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]); … }
FindPaths
will 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 false
and 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(); } }
FindPaths
after we made the tile empty. If not, then cancel the change, turning the tile into the end point again, and calling again FindPaths
to 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
, .Initialize
we can call ToggleDestination
with 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]); }
Game
call ToggleDestination
instead 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
. , .GameTileContentType
an 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; }
GameBoard
the 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(); } }
Game
main 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.GetMouseButtonDown
value 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.GrowPathTo
not 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.FindPaths
must 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; }
GameTile
generic method HidePath
that simply disables its arrow. public void HidePath () { arrow.gameObject.SetActive(false); }
GameBoard
boolean 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(); } } } }
FindPaths
should show updated paths only if visualization is enabled. bool FindPaths () { … if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } return true; }
Game
switches 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).GameBoard
the configuration field Texture2D
and select a grid texture for it. [SerializeField] Texture2D gridTexture = default;
GetComponent<MeshRenderer>
for the earth and gaining access to the material
result property . If the mesh needs to be displayed, then assign a mainTexture
mesh 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; } } }
Game
switches 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; }
SetTextureScale
material 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