Note: This tutorial is written for Unity 2017.1.0 and is intended for advanced users. It is understood that you are already familiar with the programming of games in Unity.
using System; using UnityEngine; [RequireComponent(typeof(MazeConstructor))] // 1 public class GameController : MonoBehaviour { private MazeConstructor generator; void Start() { generator = GetComponent<MazeConstructor>(); // 2 } }
RequireComponent
attribute RequireComponent
component when this script is added to a GameObject.GetComponent()
.RequireComponent
attribute. using UnityEngine; public class MazeConstructor : MonoBehaviour { //1 public bool showDebug; [SerializeField] private Material mazeMat1; [SerializeField] private Material mazeMat2; [SerializeField] private Material startMat; [SerializeField] private Material treasureMat; //2 public int[,] data { get; private set; } //3 void Awake() { // default to walls surrounding a single empty cell data = new int[,] { {1, 1, 1}, {1, 0, 1}, {1, 1, 1} }; } public void GenerateNewMaze(int sizeRows, int sizeCols) { // stub to fill in } }
showDebug
switches the debug display, and various Material references are materials for the generated models. By the way, the SerializeField
attribute displays the field in the Inspector , even though the variable is private
.data
property. Access declarations (for example, declaring a property as public
, but then assigning it to a private set
) make it read-only outside the class. Thus, the maze data can not be changed from the outside.Awake()
. The function initializes data
with a 3 x 3 array of ones surrounding zero. 1 means a wall, and 0 means empty space, that is, the default grid looks like a room surrounded by a wall. void OnGUI() { //1 if (!showDebug) { return; } //2 int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); string msg = ""; //3 for (int i = rMax; i >= 0; i--) { for (int j = 0; j <= cMax; j++) { if (maze[i, j] == 0) { msg += "...."; } else { msg += "=="; } } msg += "\n"; } //4 GUI.Label(new Rect(20, 20, 500, 500), msg); }
GUI.Label()
displays the string being created. This project uses the new GUI system for outputting data to the player, but the old system is easier to create quick debug messages.MazeConstructor.GenerateNewMaze()
is empty; This is a blank that we fill out later. At the end of the Start()
method of the GameController script , add the following line. She will call this method stub: generator.GenerateNewMaze(13, 15);
using System.Collections.Generic; using UnityEngine; public class MazeDataGenerator { public float placementThreshold; // chance of empty space public MazeDataGenerator() { placementThreshold = .1f; // 1 } public int[,] FromDimensions(int sizeRows, int sizeCols) // 2 { int[,] maze = new int[sizeRows, sizeCols]; // stub to fill in return maze; } }
placementThreshold
will be used by the data generation algorithm to determine if the space is empty. In the class constructor, this variable is assigned a default value, but it is made public
so that other code can control the configuration of the generated maze.FromDimensions()
) is still empty and left with a blank, which we fill in later. private MazeDataGenerator dataGenerator;
Awake()
, saving the generator to a new variable by adding the next line at the top of the Awake()
method. dataGenerator = new MazeDataGenerator();
FromDimensions()
in GenerateNewMaze()
, passing the size of the grid and saving the resulting data. Find in GenerateNewMaze()
line that says // stub to fill in
, and replace it with the following: if (sizeRows % 2 == 0 && sizeCols % 2 == 0) { Debug.LogError("Odd numbers work better for dungeon size."); } data = dataGenerator.FromDimensions(sizeRows, sizeCols);
FromDimensions()
.FromDimensions()
of MazeDataGenerator by replacing the line with // stub to fill in
. int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { //1 if (i == 0 || j == 0 || i == rMax || j == cMax) { maze[i, j] = 1; } //2 else if (i % 2 == 0 && j % 2 == 0) { if (Random.value > placementThreshold) { //3 maze[i, j] = 1; int a = Random.value < .5 ? 0 : (Random.value < .5 ? -1 : 1); int b = a != 0 ? 0 : (Random.value < .5 ? -1 : 1); maze[i+a, j+b] = 1; } } } }
placementThreshold
value described above in order to randomly skip this cell and continue traversing the array. using System.Collections.Generic; using UnityEngine; public class MazeMeshGenerator { // generator params public float width; // how wide are hallways public float height; // how tall are hallways public MazeMeshGenerator() { width = 3.75f; height = 3.5f; } public Mesh FromData(int[,] data) { Mesh maze = new Mesh(); //1 List<Vector3> newVertices = new List<Vector3>(); List<Vector2> newUVs = new List<Vector2>(); List<int> newTriangles = new List<int>(); // corners of quad Vector3 vert1 = new Vector3(-.5f, -.5f, 0); Vector3 vert2 = new Vector3(-.5f, .5f, 0); Vector3 vert3 = new Vector3(.5f, .5f, 0); Vector3 vert4 = new Vector3(.5f, -.5f, 0); //2 newVertices.Add(vert1); newVertices.Add(vert2); newVertices.Add(vert3); newVertices.Add(vert4); //3 newUVs.Add(new Vector2(1, 0)); newUVs.Add(new Vector2(1, 1)); newUVs.Add(new Vector2(0, 1)); newUVs.Add(new Vector2(0, 0)); //4 newTriangles.Add(2); newTriangles.Add(1); newTriangles.Add(0); //5 newTriangles.Add(3); newTriangles.Add(2); newTriangles.Add(0); maze.vertices = newVertices.ToArray(); maze.uv = newUVs.ToArray(); maze.triangles = newTriangles.ToArray(); return maze; } }
width
and height
, are similar to the placementThreshold
of the MazeDataGenerator : these are the values ​​that are set by default in the constructor and used by the mesh generation code.FromData()
; this is the method that the MazeConstructor calls to generate a mesh. At the moment, this code simply creates a single quadrilateral to demonstrate its work. Soon we will expand it to a whole level.List
data types are used (to append to the list), but in the end Mesh
requires Arrays
. private MazeMeshGenerator meshGenerator;
meshGenerator = new MazeMeshGenerator();
private void DisplayMaze() { GameObject go = new GameObject(); go.transform.position = Vector3.zero; go.name = "Procedural Maze"; go.tag = "Generated"; MeshFilter mf = go.AddComponent<MeshFilter>(); mf.mesh = meshGenerator.FromData(data); MeshCollider mc = go.AddComponent<MeshCollider>(); mc.sharedMesh = mf.mesh; MeshRenderer mr = go.AddComponent<MeshRenderer>(); mr.materials = new Material[2] {mazeMat1, mazeMat2}; }
DisplayMaze();
Mesh
itself is just data. It is invisible until it is assigned to an object (if more specifically, an MeshFilter
object) in the scene. Therefore, DisplayMaze()
not only calls MazeMeshGenerator.FromData()
, but also inserts this call in the middle of creating an instance of a new GameObject
, setting the Generated tag, adding the MeshFilter
and the generated mesh, adding the MeshCollider
for collisions with the mesh, and finally adding the MeshRenderer
and materials.MazeMeshGenerator
class and created a copy of it in the MazeConstructor , so click Play :FromData()
, replacing it completely with this code: public Mesh FromData(int[,] data) { Mesh maze = new Mesh(); //3 List<Vector3> newVertices = new List<Vector3>(); List<Vector2> newUVs = new List<Vector2>(); maze.subMeshCount = 2; List<int> floorTriangles = new List<int>(); List<int> wallTriangles = new List<int>(); int rMax = data.GetUpperBound(0); int cMax = data.GetUpperBound(1); float halfH = height * .5f; //4 for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { if (data[i, j] != 1) { // floor AddQuad(Matrix4x4.TRS( new Vector3(j * width, 0, i * width), Quaternion.LookRotation(Vector3.up), new Vector3(width, width, 1) ), ref newVertices, ref newUVs, ref floorTriangles); // ceiling AddQuad(Matrix4x4.TRS( new Vector3(j * width, height, i * width), Quaternion.LookRotation(Vector3.down), new Vector3(width, width, 1) ), ref newVertices, ref newUVs, ref floorTriangles); // walls on sides next to blocked grid cells if (i - 1 < 0 || data[i-1, j] == 1) { AddQuad(Matrix4x4.TRS( new Vector3(j * width, halfH, (i-.5f) * width), Quaternion.LookRotation(Vector3.forward), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (j + 1 > cMax || data[i, j+1] == 1) { AddQuad(Matrix4x4.TRS( new Vector3((j+.5f) * width, halfH, i * width), Quaternion.LookRotation(Vector3.left), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (j - 1 < 0 || data[i, j-1] == 1) { AddQuad(Matrix4x4.TRS( new Vector3((j-.5f) * width, halfH, i * width), Quaternion.LookRotation(Vector3.right), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (i + 1 > rMax || data[i+1, j] == 1) { AddQuad(Matrix4x4.TRS( new Vector3(j * width, halfH, (i+.5f) * width), Quaternion.LookRotation(Vector3.back), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } } } } maze.vertices = newVertices.ToArray(); maze.uv = newUVs.ToArray(); maze.SetTriangles(floorTriangles.ToArray(), 0); maze.SetTriangles(wallTriangles.ToArray(), 1); //5 maze.RecalculateNormals(); return maze; } //1, 2 private void AddQuad(Matrix4x4 matrix, ref List<Vector3> newVertices, ref List<Vector2> newUVs, ref List<int> newTriangles) { int index = newVertices.Count; // corners before transforming Vector3 vert1 = new Vector3(-.5f, -.5f, 0); Vector3 vert2 = new Vector3(-.5f, .5f, 0); Vector3 vert3 = new Vector3(.5f, .5f, 0); Vector3 vert4 = new Vector3(.5f, -.5f, 0); newVertices.Add(matrix.MultiplyPoint3x4(vert1)); newVertices.Add(matrix.MultiplyPoint3x4(vert2)); newVertices.Add(matrix.MultiplyPoint3x4(vert3)); newVertices.Add(matrix.MultiplyPoint3x4(vert4)); newUVs.Add(new Vector2(1, 0)); newUVs.Add(new Vector2(1, 1)); newUVs.Add(new Vector2(0, 1)); newUVs.Add(new Vector2(0, 0)); newTriangles.Add(index+2); newTriangles.Add(index+1); newTriangles.Add(index); newTriangles.Add(index+3); newTriangles.Add(index+2); newTriangles.Add(index); }
AddQuad()
method to call it again for the floor, ceiling and walls of each grid cell.AddQuad()
are the same list of vertices, UVs and triangles. The first line of the method gets the index to start with. When you add new quadrilaterals, the index will increase.AddQuad()
is the transformation matrix, and this part can be difficult to understand. In fact, the position / rotation / scale can be stored as a matrix, and then applied to the vertices. This is what the MultiplyPoint3x4()
call does. Thus, the quad generation code can be used for floors, ceilings, walls, etc. We only need to change the transformation matrix used!FromData()
back to FromData()
. Lists for UV vertices and triangles are created at the top. This time we have two lists of triangles. The Unity Mesh
object can have many sub-mixes with different materials on each, that is, each list of triangles is a separate mix. We announce two mixes so that you can assign different materials to the floor and walls.AddQuad()
is called several times, but each time with a different transformation matrix and different lists of triangles used for floors and walls. Also note that the width
and height
are used to determine the position and size of the quadrilaterals.RecalculateNormals()
prepares the mesh for lighting. public float hallWidth { get; private set; } public float hallHeight { get; private set; } public int startRow { get; private set; } public int startCol { get; private set; } public int goalRow { get; private set; } public int goalCol { get; private set; }
DisposeOldMaze()
; as the name implies, it removes the existing maze. The code finds all objects with the Generated tag and destroys them. public void DisposeOldMaze() { GameObject[] objects = GameObject.FindGameObjectsWithTag("Generated"); foreach (GameObject go in objects) { Destroy(go); } }
FindStartPosition()
method. This code starts at 0.0 and goes through all the maze data until it finds an open space. These coordinates are then saved as the starting position of the maze. private void FindStartPosition() { int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { if (maze[i, j] == 0) { startRow = i; startCol = j; return; } } } }
FindGoalPosition()
essentially does the same thing, only starts at maximum values ​​and performs a countdown. Add this method too. private void FindGoalPosition() { int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); // loop top to bottom, right to left for (int i = rMax; i >= 0; i--) { for (int j = cMax; j >= 0; j--) { if (maze[i, j] == 0) { goalRow = i; goalCol = j; return; } } } }
PlaceStartTrigger()
and PlaceGoalTrigger()
place objects in the scene at the start and target positions. , , TriggerEventRouter ( ). , , - . . private void PlaceStartTrigger(TriggerEventHandler callback) { GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); go.transform.position = new Vector3(startCol * hallWidth, .5f, startRow * hallWidth); go.name = "Start Trigger"; go.tag = "Generated"; go.GetComponent<BoxCollider>().isTrigger = true; go.GetComponent<MeshRenderer>().sharedMaterial = startMat; TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>(); tc.callback = callback; } private void PlaceGoalTrigger(TriggerEventHandler callback) { GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); go.transform.position = new Vector3(goalCol * hallWidth, .5f, goalRow * hallWidth); go.name = "Treasure"; go.tag = "Generated"; go.GetComponent<BoxCollider>().isTrigger = true; go.GetComponent<MeshRenderer>().sharedMaterial = treasureMat; TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>(); tc.callback = callback; }
GenerateNewMaze()
: public void GenerateNewMaze(int sizeRows, int sizeCols, TriggerEventHandler startCallback=null, TriggerEventHandler goalCallback=null) { if (sizeRows % 2 == 0 && sizeCols % 2 == 0) { Debug.LogError("Odd numbers work better for dungeon size."); } DisposeOldMaze(); data = dataGenerator.FromDimensions(sizeRows, sizeCols); FindStartPosition(); FindGoalPosition(); // store values used to generate this mesh hallWidth = meshGenerator.width; hallHeight = meshGenerator.height; DisplayMaze(); PlaceStartTrigger(startCallback); PlaceGoalTrigger(goalCallback); }
GenerateNewMaze()
, , , . using System; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(MazeConstructor))] public class GameController : MonoBehaviour { //1 [SerializeField] private FpsMovement player; [SerializeField] private Text timeLabel; [SerializeField] private Text scoreLabel; private MazeConstructor generator; //2 private DateTime startTime; private int timeLimit; private int reduceLimitBy; private int score; private bool goalReached; //3 void Start() { generator = GetComponent<MazeConstructor>(); StartNewGame(); } //4 private void StartNewGame() { timeLimit = 80; reduceLimitBy = 5; startTime = DateTime.Now; score = 0; scoreLabel.text = score.ToString(); StartNewMaze(); } //5 private void StartNewMaze() { generator.GenerateNewMaze(13, 15, OnStartTrigger, OnGoalTrigger); float x = generator.startCol * generator.hallWidth; float y = 1; float z = generator.startRow * generator.hallWidth; player.transform.position = new Vector3(x, y, z); goalReached = false; player.enabled = true; // restart timer timeLimit -= reduceLimitBy; startTime = DateTime.Now; } //6 void Update() { if (!player.enabled) { return; } int timeUsed = (int)(DateTime.Now - startTime).TotalSeconds; int timeLeft = timeLimit - timeUsed; if (timeLeft > 0) { timeLabel.text = timeLeft.ToString(); } else { timeLabel.text = "TIME UP"; player.enabled = false; Invoke("StartNewGame", 4); } } //7 private void OnGoalTrigger(GameObject trigger, GameObject other) { Debug.Log("Goal!"); goalReached = true; score += 1; scoreLabel.text = score.ToString(); Destroy(trigger); } private void OnStartTrigger(GameObject trigger, GameObject other) { if (goalReached) { Debug.Log("Finish!"); player.enabled = false; Invoke("StartNewMaze", 4); } } }
MazeConstructor
, , Start()
, GenerateNewMaze()
.StartNewGame()
, . , , .StartNewMaze()
, . , , .Update()
, , , . .OnGoalTrigger()
OnStartTrigger()
— , TriggerEventRouter MazeConstructor . OnGoalTrigger()
, , . OnStartTrigger()
, , , .FromDimensions()
. ; .Source: https://habr.com/ru/post/353104/
All Articles