Procedural labyrinth generation in Unity

Original author: Joseph Hocking
  • Transfer
  • Tutorial
image

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 programming games in Unity.

You, as a Unity developer, probably have enough experience in creating levels manually. But have you ever wanted to generate levels on the fly? The procedural generation of meshes for floors and walls, in contrast to the simple arrangement of previously created models, provides much greater flexibility and replayability of the game.

In this tutorial you will learn the following:

  • Procedurally generate levels using the example of creating a game about running in a maze.
  • Generate maze data.
  • Use maze data to build the mesh.

Getting to work


Most algorithms (such as this one and this one ) create “ideal” dense mazes, that is, those that have only one correct path and no loops. They look like labyrinths published in the newspaper sections of Puzzles.


However, most games are nicer to play when the mazes are imperfect and they have loops. They should be vast and composed of their open spaces, and not of narrow winding corridors. This is especially true for the rogue-like genre, in which procedural levels are not so much “labyrinths”, but rather dungeons.


In this tutorial, we implement one of the simplest maze algorithms described here . I chose it in order to realize the mazes in the game with a minimum amount of effort. This simple approach works well in the classic games listed here, so we can use it to create mazes in a game called Speedy Treasure Thief .

In this game, each level is a new maze in which a treasure chest is hidden. However, you do not have much time to search for him and escape before the guards return! Each level has a time limit and you can play until you get caught. The points you earn depend on the amount of treasure you have stolen.


First, create a new empty project in Unity.

Download the project blank , unzip it and import it into a new project ** proc-mazes-starter.unitypackage **. The draft content contains the following contents:

  1. Graphics folder , which contains all the graphics necessary for the game.
  2. Scene Scene is the source scene for this tutorial containing the player and the UI.
  3. Scripts folder containing two helper scripts. We will write the rest of the scripts during the execution of the tutorial.

And that’s enough to get to work. Each of these points we will consider in more detail later.

Defining the code architecture


Let's start by adding an empty project to the scene. Select GameObject ▸ Create Empty , name it Controller and place it in (X: 0, Y: 0, Z: 0). This object will be just the attachment point of the scripts that control the game.

In the project's Scripts folder , create a C # script called GameController , and then create another script and name it MazeConstructor . The first script will control the game as a whole, and the second will generate a maze.

Replace all lines in GameController with the following code:

using System;
using UnityEngine;
[RequireComponent(typeof(MazeConstructor))]               // 1
public class GameController : MonoBehaviour
{
    private MazeConstructor generator;
    void Start()
    {
        generator = GetComponent();      // 2
    }
}

I’ll briefly tell you what we just created:

  1. The attribute RequireComponentprovides the addition of the MazeConstructor component when adding this script to GameObject.
  2. A private variable holds the link returned GetComponent().

Add this script to the scene: drag the GameController script from the Project window onto the GameObject Controller in the Hierarchy window .

Note that a MazeConstructor has also been added to the Controller ; this happens automatically due to the attribute RequireComponent.

Now replace everything in the MazeConstructor with the following code:

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
    }
}

Here's what happens here:

  1. All these fields are available to us in the Inspector . showDebugswitches the debug display, and various Material links are materials for the generated models. By the way, the attribute SerializeFielddisplays the field in the Inspector , even though the variable is private.
  2. Next comes the property data. Access declarations (for example, declaring a property as public, but then assigning it private set) makes it read-only outside the class. Thus, the data of the maze can not be changed from the outside.
  3. The last piece of interesting code is in Awake(). The function initializes datawith an array of 3 x 3 of units surrounding zero. 1 means a wall, and 0 means empty space, that is, the grid by default looks like a walled room.

This is already a good foundation for the code, but for now we will not see anything!

To display the maze data and check how it looks, add the following method to the MazeConstructor :

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);
}

Consider each of the commented sections:

  1. This code checks if debug display is turned on.
  2. Initialization of several local variables: a local copy of the saved maze, the maximum row and column, and also the row.
  3. Two nested loops go through the rows and columns of a two-dimensional array. For each row / column of the array, the code will check the stored value and add "...." or "==" depending on whether the value is zero. Also, after going through all the columns in a row, the code adds a new row so that each row of the array starts with a new rowline.
  4. Finally, GUI.Label()displays the generated string. This project uses a new player output GUI system, but the old system is easier to create fast debugging messages.

Remember to enable Show Debug for the MazeConstructor component. Click Play , and the saved maze data will be displayed on the screen (which are currently the default maze):


Nice start! However, the code does not yet generate the maze itself. In the next section I will tell you how to solve this problem.

Maze data generation


Note that MazeConstructor.GenerateNewMaze()while is empty; this is a blank that we will fill in later. At the end of Start()the GameController script method, add the following line. She will call this stub method:

    generator.GenerateNewMaze(13, 15);

The "magic" numbers 13 and 15 are the parameters of the method that determine the size of the maze. Although we do not use them yet, these size options specify the number of rows and columns of the grid.

At this point, we can begin to generate data for the maze. Create a new MazeDataGenerator script ; this class encapsulates the data generation logic, and will be used in the MazeConstructor . Open a new script and replace everything with the following code:

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;
    }
}

Note that this class does not inherit from MonoBehaviour. It will not be used directly as a component, but only inside the MazeConstructor , therefore it is not required to have the functionality of MonoBehaviour.

  1. placementThresholdwill be used by the data generation algorithm to determine if space is empty. In the class constructor, this variable is assigned a default value, but it is made publicso that other code can control the settings of the generated maze.
  2. One of the methods (in this case FromDimensions()) is again empty and left with a blank, which we will fill in later.

Next, we will add several sections of code to the MazeConstructor so that it can call the stub method. First, add a private variable to store the data generator:

private MazeDataGenerator dataGenerator;

Then we create its instance in Awake(), saving the generator in a new variable by adding the next line at the top of the method Awake().

    dataGenerator = new MazeDataGenerator();

Finally, call FromDimensions()in GenerateNewMaze(), passing the grid size and saving the resulting data. Locate the GenerateNewMaze()line in which it is written // stub to fill inand 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);

A warning has been added here that it is better to use odd numbers for the dimensions, because the generated maze will be surrounded by walls.

Run the game to see the empty maze data, but with the correct dimensions:


Fine! Everything is ready to save and display the maze data! It is time to implement the FromDimensions()maze generation algorithm inside .


The algorithm described above bypasses every second cell in the grid (that is, not every cell!) By placing a wall and choosing a neighboring space to block. The algorithm programmed here will be slightly different from it, it also decides whether to skip space, which can lead to the appearance of open spaces in the maze. Since the algorithm does not have to store a lot of information or know a lot about the rest of the maze, for example, about branch points that you need to go through, the code becomes very simple.

To implement this maze generation algorithm, add the following code FromDimensions()from MazeDataGenerator , 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;
                }
            }
        }
    }

As you can see, the code gets the boundaries of the 2D array, and then bypasses it:

  1. For each grid cell, the code first checks to see if the current cell goes beyond the grid (that is, if any of the indices is on the border of the array). If so, then he sets the wall, assigning 1.
  2. Next, the code checks to see if the coordinates are divided by 2 completely in order to perform actions in every second cell. There is also an additional check on the value described above placementThresholdto randomly skip this cell and continue traversing the array.
  3. Finally, the code assigns a value of 1 to the current cell and a randomly selected neighboring cell. The code uses several ternary operations to add 0, 1 or -1 to the array index, thus obtaining the index of the neighboring cell.

Display the maze data again to see what the generated maze looks like:


Restart the game to see that the maze data is new each time. Fine!

The next major challenge is generating a 3D mesh from the 2D maze data.

Labyrinth mesh generation


Now after generating all the data of the maze, we can build a mesh based on these data.

Create another new MazeMeshGenerator script . Just as the MazeDataGenerator encapsulated the maze generation logic, the MazeMeshGenerator will contain the mesh generation logic and will be used by the MazeConstructor to complete this maze generation step.

More precisely, it will later contain the mesh generation logic. First, we simply create a textured quadrangle for the demonstration, and then change this code to generate the entire maze. To do this, we need to make small changes to the Unity editor, and only then delve into the code.

First, we need to bind the materials that will be applied to the generated mesh.

Select the Graphics folder in the Project window , then select the Hierarchy Controller in the window to display its Maze Constructor component in the Inspector . Drag materials from the Graphics folder to the Maze Constructor material slots . Use floor-mat for Material 1 and wall-mat for Material 2, and drag start and treasure to the appropriate slots. Since we are already working in the Inspector , we ’ll also add a tag



Generated : Click on the Tag menu at the top of the Inspector and select Add Tag . When generating meshes, we will assign them this tag to find them.

Having made all the necessary changes in the Unity editor, open a new script and replace everything with this code:

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 newVertices = new List();
        List newUVs = new List();
        List newTriangles = new List();
        // 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;
    }
}

Two fields at the top of the class, widthand height, are similar placementThresholdfrom MazeDataGenerator : these are the values ​​that are set by default in the constructor and used by the mesh generation code.

The bulk of the interesting code is inside FromData(); This is the method that MazeConstructor calls to generate the mesh. At the moment, this code simply creates a single quadrangle to demonstrate its work. Soon we will expand it to a whole level.

This illustration shows what the quadrangle is made of:


The code is long, but quite strongly repeating with slight variations:

  1. A mesh consists of three lists: vertices, UV coordinates, and triangles.
  2. The list of vertices stores the position of each vertex ...
  3. The UV coordinates listed correspond to the vertices in this list ...
  4. And the triangles are indices in the list of vertices (ie "this triangle consists of vertices 0, 1 and 2").
  5. Notice that two triangles are created; a quadrangle consists of two triangles. Also note that data types are used List(to join the list), but in the end for Meshis required Arrays.

MazeConstructor should create an instance of MazeMeshGenerator , and then call the mesh generation method. Also, it should also display the mesh, so we will add the following code fragments:

First, add a private field to store the mesh generator.

private MazeMeshGenerator meshGenerator;

Create an instance of it in Awake () , saving the mesh generator in a new field by adding the following line at the top of the Awake () method :

    meshGenerator = new MazeMeshGenerator();

Next, add the DisplayMaze () method :

private void DisplayMaze()
{
    GameObject go = new GameObject();
    go.transform.position = Vector3.zero;
    go.name = "Procedural Maze";
    go.tag = "Generated";
    MeshFilter mf = go.AddComponent();
    mf.mesh = meshGenerator.FromData(data);
    MeshCollider mc = go.AddComponent();
    mc.sharedMesh = mf.mesh;
    MeshRenderer mr = go.AddComponent();
    mr.materials = new Material[2] {mazeMat1, mazeMat2};
}

Finally, to call DisplayMaze (), add the following line to the end of GenerateNewMaze () :

    DisplayMaze();

By itself Mesh, it's just data. It is invisible until it is assigned to an object (more specifically, an MeshFilterobject) in the scene. Therefore, it DisplayMaze()not only calls MazeMeshGenerator.FromData(), but also inserts this call in the middle of creating an instance of a new one GameObject, setting the Generated tag , adding the MeshFiltergenerated mesh, adding MeshCollidercollisions to the mesh , and finally adding MeshRenderermaterials.

We wrote a class MazeMeshGeneratorand instantiated it in the MazeConstructor , so click Play :


We built the textured quadrangle completely with code! This is an interesting and important start, so take a break to analyze your work at this stage and understand how the code works.

Further, we refactorize quite a lot FromData(), replacing it completely with such code:

public Mesh FromData(int[,] data)
{
    Mesh maze = new Mesh();
    //3
    List newVertices = new List();
    List newUVs = new List();
    maze.subMeshCount = 2;
    List floorTriangles = new List();
    List wallTriangles = new List();
    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 newVertices,
    ref List newUVs, ref List 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);
}

Wow, what a long piece of code! But here almost the same thing is repeated again, only some numbers change. In particular, the quadrangle generation code has been moved to a separate method AddQuad()for recalling it for the floor, ceiling, and walls of each grid cell.

  1. The last three parameters AddQuad()are the same list of vertices, UVs and triangles. The first line of the method gets the index from which to start. When you add new quadrangles, the index will increase.
  2. Однако первый параметр AddQuad() — это матрица преобразований, и эта часть может быть сложной для понимания. По сути, положение/поворот/масштаб может храниться в виде матрицы, а затем применяться к вершинам. Именно это делает вызов MultiplyPoint3x4(). Таким образом, код генерирования четырёхугольника можно использовать для полов, потолков, стен и т.д. Нам достаточно лишь изменять используемую матрицу преобразований!
  3. Вернёмся к FromData(). Списки для вершин UV и треугольников создаются в верхней части. На этот раз у нас есть два списка треугольников. Объект Mesh Unity может иметь множество подмешей с различными материалами на каждом, то есть каждый список треугольников является отдельным подмешем. Мы объявляем два подмеша, чтобы можно было назначить разные материалы полу и стенам.
  4. After that, we go through a 2D array and create quadrangles for the floor, ceiling and walls in each grid cell. Each cell needs a floor and ceiling, in addition, checks are made of neighboring cells for the need for walls. Notice that it 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 in order to determine the location and size of quadrangles are used widthand height.
  5. Oh, and one more small addition: RecalculateNormals()prepares the mesh for lighting.

Click Play to see how the entire mesh is generated:


Congratulations, this is where we ended up generating the maze and the main part of the programming needed for Speedy Treasure Thief ! In the next section, we will look at the rest of the game.

Finish the game


We need to make other additions and changes to the code, but first, let's use what was in the draft project. As I mentioned in the introduction, there are two scripts in the draft project, a scene with a player and a UI, as well as all the graphics for playing with a maze. The FpsMovement script is just a one-script version of the character controller from my book , and TriggerEventRouter is an auxiliary code that is convenient for working with game triggers.

The player is already configured in the scene, including the FpsMovement component and a
directional light source attached to the camera. In addition, in the Lighting Settings windowskybox and ambient light are disabled. Finally, there is a UI canvas in the scene with marks for points and time.

And that’s all there is in the draft project. Now we will write the remaining code for the game.

Let's start with the MazeConstructor . First, add the following properties to store the dimensions and coordinates:

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;
}

Now you need to add new methods. The first is DisposeOldMaze(); as the name implies, it deletes 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);
    }
}

Next we will add a method FindStartPosition(). This code starts at 0,0 and goes through all the data in the maze until it finds open space. Then these coordinates are saved as the initial 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;
            }
        }
    }
}

Similarly, FindGoalPosition()in essence, it does the same, only starts with 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. Their collider is a trigger, the corresponding material is applied, and then the TriggerEventRouter is added (from the project blank). This component receives an event handling function that is called when something enters the scope of the trigger. Add these two methods.

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().isTrigger = true;
    go.GetComponent().sharedMaterial = startMat;
    TriggerEventRouter tc = go.AddComponent();
    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().isTrigger = true;
    go.GetComponent().sharedMaterial = treasureMat;
    TriggerEventRouter tc = go.AddComponent();
    tc.callback = callback;
}

Finally, replace the entire method with the GenerateNewMaze()following code:

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);
}

The rewritten one GenerateNewMaze()invokes the new methods we just added for operations such as deleting the old mesh and arranging the triggers.

We have already added a lot to the MazeConstructor , great work! Fortunately, we are done with this class. There is one more piece of code left.

Now add the new code to GameController . Replace the entire contents of the file with the following:

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();
        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);
        }
    }
}

  1. The first thing we added was serialized fields for objects in the scene.
  2. Added several private variables for tracking the timer and game points, as well as whether the target is found in the maze.
  3. MazeConstructorinitialized as before, but now Start()uses new methods that do not just call GenerateNewMaze().
  4. StartNewGame() используется для запуска всей игры сначала, а не для переключения уровней внутри игры. Таймеру присваиваются исходные значения, очки сбрасываются, после чего создаётся лабиринт.
  5. StartNewMaze() переходит к новому уровню, не перезапуская заново всю игру. Кроме создания нового лабиринта, этот метод располагает игрока в начальной точке, сбрасывает цель и снижает лимит времени.
  6. Update() проверяет, активен ли игрок, а затем обновляет время, оставшееся на прохождение уровня. После завершения времени игрок деактивируется и начинается новая игра.
  7. OnGoalTrigger() и OnStartTrigger() — это функции обработки событий, передаваемые TriggerEventRouter в MazeConstructor. OnGoalTrigger() записывает, что цель была найдена, а затем увеличивает количество очков. OnStartTrigger() проверяет, найдена ли цель, и если это так, то деактивирует игрока и запускает новый лабиринт.

And this is all the code. Now back to the scene in Unity. To get started, select Canvas in the Hierarchy window and enable it in the Inspector. Canvas has been disabled so as not to interfere with the display of debugging when creating the maze code. Remember that serialized fields have been added, so drag their scene objects ( Player , Time label from Canvas, and Score label ) into the slots in the Inspector. You can also turn off Show Debug , and then click on Play:


Great job! Procedurally generating mazes can be a daunting task, but as a result we get an exciting and dynamic gameplay.

Where to go next?


If you repeated after me, then you have already created a finished game. If you want, you can download the finished Unity project from here .

Next, you can explore other maze generation algorithms by replacing the code in FromDimensions(). You can also try to generate other environments; start by exploring cave generation with cellular automata .

Random generation of objects and enemies on the map can be a very interesting activity!

Also popular now: