[技术] 在Unity中程序化生成地牢环境

------------------------------------------------本文转载于unity官方老论坛-----------------------------------------------------------

在游戏中有二种方法构建地牢环境,第一种方法是手动创建并连接地牢空间,这种方法的优点是我们可以手动选择每个地牢空间中的内容。

第二种方法是程序化生成地牢空间,使用这种方法,我们不会对每个地牢空间的内容进行太多控制,但我们可以提高游戏的随机性。

本教程将展示如何使用Unity来程序化生成地牢环境。我们会使用Unity的Tilemap功能来生成多个地牢空间,并通过伪随机方法连接它们。然后构建演示游戏,我们会在游戏中尝试使用不同数量的地牢空间,敌人和障碍物。

在这里插入图片描述
学习准备
为了充分理解本教程,我们需要熟悉以下概念:
1、C#语言和面向对象的概念
2、Unity基础概念,例如:精灵,场景和预制件

创建平铺地图
首先我们要为地牢空间创建Tilemap,然后在生成地牢后加载Tilemap,我们需要使用Unity提供的Tilemap生成功能。
首先下载本教程的精灵,我们配置用于Tilemap的terrains精灵。我们把Pixels Per Unit设为40,确保瓦片以正确大小出现。然后我们把Sprite Mode设为Multiple,将其切分为多个独立瓦片。
在这里插入图片描述
在这里插入图片描述
现在右键单击层级窗口,选择2D Object -> Tilemap。我们需要用terrains瓦片集来创建一个新的瓦片调色板,并将它保存在一个名为Tile Palettes的单独文件夹中。创建瓦片调色板后,我们将瓦片集拖到Tile Palette窗口。
在这里插入图片描述
现在我们使用瓦片调色板,开始创建地牢空间的Tilemap,选中笔刷工具并使用合适的瓦片绘制Tilemap。绘制好完成后,我们得到了下图效果。
在这里插入图片描述
下一步是让地牢空间的墙体可以发生碰撞,同时地板瓦片不会发生碰撞。

我们选中Tilemap对象并添加Tilemap Collider 2D组件,但这样会使所有瓦片都会发生碰撞。为了让地板瓦片无法碰撞,我们在Tile Palettes文件夹选中地板瓦片,把它的Collider Type改为None。
在这里插入图片描述
在这里插入图片描述
创建玩家对象和门预制件
我们已经创建了地牢空间,还需要制作地牢中移动的玩家和通向不同地牢空间的门。

我们从创建玩家开始,首先在Sprites文件夹选中“player”精灵,把Pixels Per Unit设为30,Sprite Mode设为Multiple,然后切分预制件。我们使用该精灵创建新游戏对象,命名为player,给对象添加Box Collider 2D组件和Rigidbody 2D组件。下面我们需要对组件做调整。

我们不希望玩家在在与物体碰撞时旋转,因此要勾选Rigidbody 2D组件的Freeze Rotation。此外,我们需要减小碰撞体的大小,以便玩家可以穿过门,我们可以按照下图修改Box Collider 2D的Size属性。我们还需要创建“Player”标签,并把它指定给Player对象。
在这里插入图片描述
由于这是一个俯视角游戏,不需要任何重力效果,所以我们点击Edit -> Project Settings -> Physics2D中禁用重力,将Gravity的Y值改为0
在这里插入图片描述
现在创建一个名为PlayerMovement脚本,并将其添加到Player对象上。该脚本只要能移动Player对象即可,所以该脚本需要speed属性作为SerializeField,我们在此实现了FixedUpdate方法来移动玩家。

为了实现移动效果,该脚本会获取水平方向和垂直方向的移动数值,并相应地更新速度。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
  
public class PlayerMovement : MonoBehaviour {
  
    [SerializeField]
    private float speed;
  
    // 初始化
    void Start () {
         
    }
     
    // Update每帧只调用一次
    void FixedUpdate () {
        float horizontal = Input.GetAxis ("Horizontal");
        float vertical = Input.GetAxis ("Vertical");
  
        GetComponent<Rigidbody2D> ().velocity = new Vector2 (horizontal * speed, vertical * speed);
    }
}

现在我们创建一个Door预制件,创建新游戏对象并命名为Door。该对象没有实际显示的精灵,只有用于碰撞的隐形精灵,为此我们需要给Door对象添加Box Collider 2D组件和Rigidbody 2D组件。

在Rigidbody 2D组件上,把Body Type设为Static。这会确保Door对象无法移动,并且能和Player对象碰撞。

在这里插入图片描述
创建好Door对象后,我们创建一个名为EnterDoor脚本,并将其添加到Door对象上。当Player对象碰到门口时,我们现在只会重新开始游戏,此后我们将使用它来连接地牢空间。

无论是什么功能,Door对象和Player对象之间的碰撞都会通过实现OnCollisionEnter2D方法来检测。使用这种方法,我们会检查碰撞对象是否为Player对象。如果是的,我们会重新开始Demo场景。

请注意,我们需要添加SceneManagement命名空间来重启场景。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
  
public class EnterDoor : MonoBehaviour {
  
    void OnCollisionEnter2D(Collision2D col) {
        if (col.gameObject.tag == "Player") {
            SceneManager.LoadScene ("Demo");
        }
    }
}

现在我们测试游戏,保存场景为Demo场景并运行游戏。我们可以移动玩家,并在碰到门口时重启游戏。

地牢生成算法
现在我们已经有了游戏的基础对象Player和Door,接下来实现地牢生成算法。我们首先会创建名为Dungeon的空白对象,然后给该对象附加DungeonGeneration脚本。

地牢生成算法会按以下步骤生成地牢空间:
1、创建一个空白网格,用于保存地牢空间。
2、创建一个初始地牢空间,并将其保存到rooms_to_create列表。
3、当空间数量小于设定值“n”时,重复以下步骤:
a、在rooms_to_create列表选取第一个空间
b、添加该空间到对应位置的网格
c、创建随机数量的相邻空间,然后将这些空间添加到rooms_to_create列表
4、连接相邻空间。

地牢生成算法会在下面的GenerateDungeon方法中实现。

第一个空间的坐标在网格中央生成,地牢网格会在每条轴上以地牢空间的三倍数量初始化,这样可以确保所有地牢空间符合网格大小。然后第一次循环会通过上述步骤创建地牢空间,所有创建的空间都保存在“createdRooms”列表中。。

当所有空间都创建好后,脚本会迭代createdRooms列表来连接相邻空间。连接空间的具体过程是先迭代每个空间的相邻坐标,然后检查网格上该坐标是否存在空间。如果是的,算法会连接二个空间。

最后我们会确保地牢中有预期数量的空间,并让所有空间相互连通,每个空间都与前一个相邻空间连接。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
  
public class DungeonGeneration : MonoBehaviour {
  
    [SerializeField]
    private int numberOfRooms;
  
    private Room[,] rooms;
  
    void Start () {
        this.currentRoom = GenerateDungeon ();
    }
  
    private Room GenerateDungeon() {
        int gridSize = 3 * numberOfRooms;
  
        rooms = new Room[gridSize, gridSize];
  
        Vector2Int initialRoomCoordinate = new Vector2Int ((gridSize / 2) - 1, (gridSize / 2) - 1);
  
        Queue<Room> roomsToCreate = new Queue<Room> ();
        roomsToCreate.Enqueue (new Room(initialRoomCoordinate.x, initialRoomCoordinate.y));
        List<Room> createdRooms = new List<Room> ();
        while (roomsToCreate.Count > 0 && createdRooms.Count < numberOfRooms) {
            Room currentRoom = roomsToCreate.Dequeue ();
            this.rooms [currentRoom.roomCoordinate.x, currentRoom.roomCoordinate.y] = currentRoom;
            createdRooms.Add (currentRoom);
            AddNeighbors (currentRoom, roomsToCreate);
        }
             
        foreach (Room room in createdRooms) {
            List<Vector2Int> neighborCoordinates = room.NeighborCoordinates ();
            foreach (Vector2Int coordinate in neighborCoordinates) {
                Room neighbor = this.rooms [coordinate.x, coordinate.y];
                if (neighbor != null) {
                    room.Connect (neighbor);
                }
            }
        }
  
        return this.rooms [initialRoomCoordinate.x, initialRoomCoordinate.y];
    }
         
}

我们使用Room类来创建地牢网格,我们还使用了AddNeighbors方法把相邻空间添加到“rooms_to_create”列表,因此我们要实现AddNeighbors方法。

AddNeighbors方法首先会检查哪些相邻坐标可以拥有地牢空间。只有在位置没有被其它地牢空间占用时,该位置的坐标才可以使用。在找到可用坐标后,随机数量的可用坐标会添加到“rooms_to_create”列表。

为了创建空间,我们会随机选取其中的一个相邻空间。

private void AddNeighbors(Room currentRoom, Queue<Room> roomsToCreate) {
        List<Vector2Int> neighborCoordinates = currentRoom.NeighborCoordinates ();
        List<Vector2Int> availableNeighbors = new List<Vector2Int> ();
        foreach (Vector2Int coordinate in neighborCoordinates) {
            if (this.rooms[coordinate.x, coordinate.y] == null) {
                availableNeighbors.Add (coordinate);
            }
        }
             
        int numberOfNeighbors = (int)Random.Range (1, availableNeighbors.Count);
  
        for (int neighborIndex = 0; neighborIndex < numberOfNeighbors; neighborIndex++) {
            float randomNumber = Random.value;
            float roomFrac = 1f / (float)availableNeighbors.Count;
            Vector2Int chosenNeighbor = new Vector2Int(0, 0);
            foreach (Vector2Int coordinate in availableNeighbors) {
                if (randomNumber < roomFrac) {
                    chosenNeighbor = coordinate;
                    break;
                } else {
                    roomFrac += 1f / (float)availableNeighbors.Count;
                }
            }
            roomsToCreate.Enqueue (new Room(chosenNeighbor));
            availableNeighbors.Remove (chosenNeighbor);
        }
    }

现在让我们创建Room类。Room类不属于MonoBehaviour,它只是一个常规类,所以我们需要在DungeonGeneration脚本中创建它的构造函数和方法,即NeighborCoordinates和Connect方法。

构造函数很简单,它只需要初始化空间坐标和带有相邻信息的字典即可。我们将使用字典而不是列表,因为我们希望将每个相邻空间与其方向相关联起来。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
  
public class Room
{
    public Vector2Int roomCoordinate;
    public Dictionary<string, Room> neighbors;
  
    public Room (int xCoordinate, int yCoordinate)
    {
        this.roomCoordinate = new Vector2Int (xCoordinate, yCoordinate);
        this.neighbors = new Dictionary<string, Room> ();
    }
  
    public Room (Vector2Int roomCoordinate)
    {
        this.roomCoordinate = roomCoordinate;
        this.neighbors = new Dictionary<string, Room> ();
    }

NeighborCoordinates方法将返回当前空间的所有相邻空间坐标。每个空间在四个方向都有一个相邻空间:北边、东边、南边,西边。此顺序很重要,因为顺序是游戏中初始化空间的必要信息。

public List<Vector2Int> NeighborCoordinates () {
        List<Vector2Int> neighborCoordinates = new List<Vector2Int> ();
        neighborCoordinates.Add (new Vector2Int(this.roomCoordinate.x, this.roomCoordinate.y - 1));
        neighborCoordinates.Add (new Vector2Int(this.roomCoordinate.x + 1, this.roomCoordinate.y));
        neighborCoordinates.Add (new Vector2Int(this.roomCoordinate.x, this.roomCoordinate.y + 1));
        neighborCoordinates.Add (new Vector2Int(this.roomCoordinate.x - 1, this.roomCoordinate.y));
  
        return neighborCoordinates;
    }

最后,Connect方法会检查空间的方向,并添加空间和方向到neighbors字典中。

public void Connect (Room neighbor) {
        string direction = "";
        if (neighbor.roomCoordinate.y < this.roomCoordinate.y) {
            direction = "N";
        }
        if (neighbor.roomCoordinate.x > this.roomCoordinate.x) {
            direction = "E";
        }   
        if (neighbor.roomCoordinate.y > this.roomCoordinate.y) {
            direction = "S";
        }
        if (neighbor.roomCoordinate.x < this.roomCoordinate.x) {
            direction = "W";
        }
        this.neighbors.Add (direction, neighbor);
    }

为了测试地牢是否正确生成,我们将实现PrintGrid方法,它会将空间网格显示为字符串。

private void PrintGrid() {
        for (int rowIndex = 0; rowIndex < this.rooms.GetLength (1); rowIndex++) {
            string row = "";
            for (int columnIndex = 0; columnIndex < this.rooms.GetLength (0); columnIndex++) {
                if (this.rooms [columnIndex, rowIndex] == null) {
                    row += "X";
                } else {
                    row += "R";
                }
            }
            Debug.Log (row);
        }
    }

现在,返回到DungeonGeneration脚本,我们在其Start方法中调用了GenerateDungeon方法。创建地牢后,我们会输出地牢的字符串以供测试。

void Start () {
        GenerateDungeon ();
        PrintGrid ();
    }

现在,我们可以尝试使用给定参数来运行游戏,并检查游戏是否正常工作。
在这里插入图片描述
连通地牢空间

现在生成了地牢网格,我们需要在游戏中实例化地牢空间的Tilemap,首先我们需要为所有可用空间创建Tilemap。

我们把已有的空间保存到Resources文件夹中,保存到Resources文件夹很重要,因为我们需要在运行时实例化空间。现在我们需要为所有空间做同样的处理,建议下载教程源代码素材,并从素材的Resources文件夹复制地牢空间的内容,因为制作所有空间需要一些时间。

每个可用相邻空间的配置都需要一个地牢空间,所以一共有15个可用空间。空间名称应该按照“Room_NESW”的格式命名,“NESW”表示空间的相邻位置,即北边,东边,南边,西边。

在这里插入图片描述

这样我们可以在Room类添加PrefabName方法,该方法会返回当前空间的Room预制件名称。由于NeighborCoordinates方法会以正确顺序返回相邻位置,因此由PrefabName返回的名称,会匹配我们打算实例化的预制件名称。

public string PrefabName () {
        string name = "Room_";
        foreach (KeyValuePair<string, Room> neighborPair in neighbors) {
            name += neighborPair.Key;
        }
        return name;
    }

最后我们更新DungeonGeneration脚本的Start方法,从而实例化该地牢空间的预制件。生成地牢后,它会返回初始地牢空间。然后脚本会从Resources文件夹加载并实例化预制件。

void Start () {
        this.currentRoom = GenerateDungeon ();
        string roomPrefabName = this.currentRoom.PrefabName ();
        GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
    }

现在运行游戏并检查脚本是否实例化正常的空间。尝试多次运行游戏,注意观察初始空间是否有变化。
在这里插入图片描述

现在我们实例化Room预制件并命名为预制件名称,下面开始制作连通各个空间的门。我们要重启Demo场景,把其它空间用作当前空间,然而我们不想重新生成地牢,所以我们需要使Dungeon对象保持不变,这样我们确保始终得到相同的地牢。

我们通过在Awake方法中调用DontDestroyOnLoad,这样在我们重启场景时,Dungeon对象不会被销毁。但是Unity仍会在场景启动时创建新的Dungeon对象,因此我们要保存第一个DungeonGeneration实例为一个静态属性,它会在Awake方法首次被调用时设置。

我们还要在该方法中生成地牢,如果实例已经设置好,我们会仅实例化当前空间的预制件,然后删除新建的Dungeon对象。

void Awake () {
        if (instance == null) {
            DontDestroyOnLoad (this.gameObject);
            instance = this;
            this.currentRoom = GenerateDungeon ();
        } else {
            string roomPrefabName = instance.currentRoom.PrefabName ();
            GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
            Destroy (this.gameObject);
        }
    }
  
    void Start () {
        string roomPrefabName = this.currentRoom.PrefabName ();
        GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
    }

现在我们更新EnterDoor脚本,使它以新的当前空间来重启Demo场景。

我们需要在脚本中保存门口的方向,然后在OnCollisionEnter2D方法中,我们会访问neighbors字典来获取当前空间的合适方向,从而获取下一个空间。在找到下一个空间后,我们修改地牢中的当前空间,然后重启Demo场景。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
  
public class EnterDoor : MonoBehaviour {
  
    [SerializeField]
    string direction;
  
    void OnCollisionEnter2D(Collision2D col) {
        if (col.gameObject.tag == "Player") {
            GameObject dungeon = GameObject.FindGameObjectWithTag ("Dungeon");
            DungeonGeneration dungeonGeneration = dungeon.GetComponent<DungeonGeneration> ();
            Room room = dungeonGeneration.CurrentRoom ();
            dungeonGeneration.MoveToRoom (room.Neighbor (this.direction));
            SceneManager.LoadScene ("Demo");
        }
    }
}

我们仍然需要在DungeonGeneration脚本实现CurrentRoom方法和MoveToRoom方法,以及Room对象中获取相邻空间的方法。

public void MoveToRoom(Room room) {
        this.currentRoom = room;
    }
  
    public Room CurrentRoom() {
        return this.currentRoom;
    }
public Room Neighbor (string direction) {
        return this.neighbors [direction];
    }

最后为所有空间设置门的方向值,然后再次运行游戏,此时我们可以在空间之间移动。

添加障碍物
现在我们给地牢添加更多元素,首先从障碍物开始。障碍物是我们在空间特定部分的设置的瓦片,每个空间中将随机选中障碍物数量及其位置。

我们首先在Room类编写PopulateObstacles方法,该方法会把障碍物数量和可用障碍物大小做为参数。障碍物大小的数值类型为Vector2int,它会指定X轴和Y轴上的大小。

对于每个要创建的障碍物,该方法都会在可用范围内选取随机大小,然后在空间中寻找符合大小的可用区域。

为了记录空间中的可用坐标,我们会使用称为population的字符串矩阵。选取障碍物的区域后,我们会更新population矩阵。

private string[,] population;
  
    public Room (int xCoordinate, int yCoordinate)
    {
        this.roomCoordinate = new Vector2Int (xCoordinate, yCoordinate);
        this.neighbors = new Dictionary<string, Room> ();
        this.population = new string[18, 10];
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                this.population [xIndex, yIndex] = "";
            }
        }
        this.population [8, 5] = "Player";
    }
  
    public Room (Vector2Int roomCoordinate)
    {
        this.roomCoordinate = roomCoordinate;
        this.neighbors = new Dictionary<string, Room> ();
        this.population = new string[18, 10];
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                this.population [xIndex, yIndex] = "";
            }
        }
        this.population [8, 5] = "Player";
    }
  
public void PopulateObstacles (int numberOfObstacles, Vector2Int[] possibleSizes) {
        for (int obstacleIndex = 0; obstacleIndex < numberOfObstacles; obstacleIndex += 1) {
            int sizeIndex = Random.Range (0, possibleSizes.Length);
            Vector2Int regionSize = possibleSizes [sizeIndex];
            List<Vector2Int> region = FindFreeRegion (regionSize);
            foreach (Vector2Int coordinate in region) {
                this.population [coordinate.x, coordinate.y] = "Obstacle";
            }
        }
    }

现在我们需要实现FindFreeRegion方法。该方法包含的循环会一直寻找随机区域,直到找到可用区域。

在循环的每次迭代中,它都会在瓦片的X轴坐标范围在2~15之间,Y轴坐标范围在2~7之间生成随机的中心瓦片,。通过使用这些坐标,我们使障碍物不会出现在空间的墙体上。

此后,脚本会根据障碍物大小计算剩余障碍物的坐标。最后,脚本会在while循环检查区域是否可用。如果不可用,它会再次迭代寻找另一个区域,直到找到可用区域。

private List<Vector2Int> FindFreeRegion (Vector2Int sizeInTiles) {
        List<Vector2Int> region = new List<Vector2Int>();
        do {
            region.Clear();
  
            Vector2Int centerTile = new Vector2Int(UnityEngine.Random.Range(2, 18 - 3), UnityEngine.Random.Range(2, 10 - 3));
  
            region.Add(centerTile);
  
            int initialXCoordinate = (centerTile.x - (int)Mathf.Floor(sizeInTiles.x / 2));
            int initialYCoordinate = (centerTile.y - (int)Mathf.Floor(sizeInTiles.y / 2));
            for (int xCoordinate = initialXCoordinate; xCoordinate < initialXCoordinate + sizeInTiles.x; xCoordinate += 1) {
                for (int yCoordinate = initialYCoordinate; yCoordinate < initialYCoordinate + sizeInTiles.y; yCoordinate += 1) {
                    region.Add(new Vector2Int(xCoordinate, yCoordinate));
                }
            }
        } while(!IsFree (region));
        return region;
    }

IsFree方法会迭代区域的所有坐标,检查population矩阵是否对所有坐标可用。

private bool IsFree (List<Vector2Int> region) {
        foreach (Vector2Int tile in region) {
            if (this.population [tile.x, tile.y] != "") {
                return false;
            }
        }
        return true;
    }

现在,我们需要为每个空间调用PopulateObstacles方法,该方法将在连接空间后调用。在实例化空间预制件后,我们需要在Tilemap添加障碍物瓦片。因此,我们会相应地修改Awake和Start方法。

[SerializeField]
    private TileBase obstacleTile;
  
void Awake () {
        if (instance == null) {
            DontDestroyOnLoad (this.gameObject);
            instance = this;
            this.currentRoom = GenerateDungeon ();
        } else {
            string roomPrefabName = instance.currentRoom.PrefabName ();
            GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
            Tilemap tilemap = roomObject.GetComponentInChildren<Tilemap> ();
            instance.currentRoom.AddPopulationToTilemap (tilemap, instance.obstacleTile);
            Destroy (this.gameObject);
        }
    }
  
    void Start () {
        string roomPrefabName = this.currentRoom.PrefabName ();
        GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
        Tilemap tilemap = roomObject.GetComponentInChildren<Tilemap> ();
        this.currentRoom.AddPopulationToTilemap (tilemap, this.obstacleTile);
    }

我们需要实现AddPopulationToTilemap方法,该方法会迭代population矩阵中的所有坐标,检查坐标是否是障碍物。如果是的,我们会在该坐标设置瓦片,使它显示为障碍物。

我们需要在(xIndex – 9, yIndex – 5)坐标设置瓦片,因为在population矩阵中,(0, 0)索引表示左下角,而在Tilemap中,(0, 0)表示地图中心。

public void AddPopulationToTilemap (Tilemap tilemap, TileBase obstacleTile) {
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                if (this.population [xIndex, yIndex] == "Obstacle") {
                    tilemap.SetTile (new Vector3Int (xIndex - 9, yIndex - 5, 0), obstacleTile);
                } 
            }
        }
    }

现在更新Dungeon对象,以设置新属性的值,例如:可能障碍物大小和障碍物瓦片。然后运行游戏,观察游戏是否正确地创建了障碍物。

在这里插入图片描述

添加敌人

添加敌人的方法类似添加障碍物,只不过敌人是预制件而不是瓦片,而且敌人总会是一个瓦片的大小。

我们首先添加PopulatePrefabs方法,它将使用敌人预制件来添加敌人到游戏中。该方法会迭代设定的预制件数量,并在每次迭代时从可用预制件中进行随机选取,然后找到大小为一个瓦片的可用区域,添加该区域到population矩阵。

在population矩阵中,我们会用预制件名称来识别预制件。然后通过名称来实例化预制件。我们使用了name2Prefab字典。该字典会用预制件名称作为索引,并返回其预制件。

private Dictionary<string, GameObject> name2Prefab;
  
public Room (int xCoordinate, int yCoordinate)
    {
        this.roomCoordinate = new Vector2Int (xCoordinate, yCoordinate);
        this.neighbors = new Dictionary<string, Room> ();
        this.population = new string[18, 10];
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                this.population [xIndex, yIndex] = "";
            }
        }
        this.population [8, 5] = "Player";
        this.name2Prefab = new Dictionary<string, GameObject> ();
    }
  
    public Room (Vector2Int roomCoordinate)
    {
        this.roomCoordinate = roomCoordinate;
        this.neighbors = new Dictionary<string, Room> ();
        this.population = new string[18, 10];
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                this.population [xIndex, yIndex] = "";
            }
        }
        this.population [8, 5] = "Player";
        this.name2Prefab = new Dictionary<string, GameObject> ();
    }
  
public void PopulatePrefabs (int numberOfPrefabs, GameObject[] possiblePrefabs) {
        for (int prefabIndex = 0; prefabIndex < numberOfPrefabs; prefabIndex += 1) {
            int choiceIndex = Random.Range (0, possiblePrefabs.Length);
            GameObject prefab = possiblePrefabs [choiceIndex];
            List<Vector2Int> region = FindFreeRegion (new Vector2Int(1, 1));
  
            this.population [region[0].x, region[0].y] = prefab.name;
            this.name2Prefab [prefab.name] = prefab;
        }
    }

通过这种方法,我们可以更新AddPopulationToTilemap,把预制件添加到地图中。

当坐标不是障碍物,而且既不是空白坐标也不是玩家时,这表示我们需要为该坐标实例化一个预制件,我们会通过访问name2Prefab字典来实例化预制件。

public void AddPopulationToTilemap (Tilemap tilemap, TileBase obstacleTile) {
        for (int xIndex = 0; xIndex < 18; xIndex += 1) {
            for (int yIndex = 0; yIndex < 10; yIndex += 1) {
                if (this.population [xIndex, yIndex] == "Obstacle") {
                    tilemap.SetTile (new Vector3Int (xIndex - 9, yIndex - 5, 0), obstacleTile);
                } else if (this.population [xIndex, yIndex] != "" && this.population [xIndex, yIndex] != "Player") {
                    GameObject prefab = GameObject.Instantiate (this.name2Prefab[this.population [xIndex, yIndex]]);
                    prefab.transform.position = new Vector2 (xIndex - 9 + 0.5f, yIndex - 5 + 0.5f);
                }
            }
        }
    }

添加障碍物后,我们可以从GenerateDungeon调用PopulatePrefabs方法。

[SerializeField]
    private int numberOfEnemies;
    [SerializeField]
    private GameObject[] possibleEnemies;
  
private Room GenerateDungeon() {
        int gridSize = 3 * numberOfRooms;
  
        rooms = new Room[gridSize, gridSize];
  
        Vector2Int initialRoomCoordinate = new Vector2Int ((gridSize / 2) - 1, (gridSize / 2) - 1);
  
        Queue<Room> roomsToCreate = new Queue<Room> ();
        roomsToCreate.Enqueue (new Room(initialRoomCoordinate.x, initialRoomCoordinate.y));
        List<Room> createdRooms = new List<Room> ();
        while (roomsToCreate.Count > 0 && createdRooms.Count < numberOfRooms) {
            Room currentRoom = roomsToCreate.Dequeue ();
            this.rooms [currentRoom.roomCoordinate.x, currentRoom.roomCoordinate.y] = currentRoom;
            createdRooms.Add (currentRoom);
            AddNeighbors (currentRoom, roomsToCreate);
        }
             
        foreach (Room room in createdRooms) {
            List<Vector2Int> neighborCoordinates = room.NeighborCoordinates ();
            foreach (Vector2Int coordinate in neighborCoordinates) {
                Room neighbor = this.rooms [coordinate.x, coordinate.y];
                if (neighbor != null) {
                    room.Connect (neighbor);
                }
            }
  
            room.PopulateObstacles (this.numberOfObstacles, this.possibleObstacleSizes);
            room.PopulatePrefabs (this.numberOfEnemies, this.possibleEnemies);
        }
  
        return this.rooms [initialRoomCoordinate.x, initialRoomCoordinate.y];
    }

我们还需要创建敌人预制件,在DungeonGeneration脚本中设置可用敌人。

首先把Enemy精灵的Pixels Per Unit设为40,这样它在地图中不会显得太大。然后使用Enemy精灵创建新游戏对象,给对象添加BoxCollider2D组件。最后将Enemy对象设置“Enemy”标签,并将它保存为预制件。
在这里插入图片描述
现在,我们可以更新Dungeon对象,添加敌人数量和敌人预制件到DungeonGeneration脚本。运行游戏,检查敌人是否成功创建。
在这里插入图片描述
添加目标点
完成制作敌人后,我们需要一种实现通关的方式。接下来我们要在游戏中添加Goal对象,它始终会处于离起始位置最远的地牢空间,并且必须由玩家找到。当玩家碰到Goal对象时,便能完成游戏。

首先创建Goal预制件,我们要为Goal预制件使用Portal精灵。把Portal精灵的Pixels Per Unit改为30,并使用该精灵创建一个新对象。

正如制作敌人一样,我们需要给对象添加BoxCollider2D组件,然后将它设为触发器,最后保存对象为预制件。

在这里插入图片描述
现在修改DungeonGeneration脚本,使它在最远地牢空间创建Goal对象。

我们需要找到最远的空间。我们会在迭代createdRooms列表时找到最远的空间。在每个空间中,我们会计算它和初始空间的距离,保存最后的空间。

我们这个在循环外为最后的空间再次调用PopulatePrefabs,但此时要使用Goal预制件而不是Enemy预制件,我们只需要在空间内创建一个Goal对象。

[SerializeField]
    private GameObject goalPrefab;
  
private Room GenerateDungeon() {
        int gridSize = 3 * numberOfRooms;
  
        rooms = new Room[gridSize, gridSize];
  
        Vector2Int initialRoomCoordinate = new Vector2Int ((gridSize / 2) - 1, (gridSize / 2) - 1);
  
        Queue<Room> roomsToCreate = new Queue<Room> ();
        roomsToCreate.Enqueue (new Room(initialRoomCoordinate.x, initialRoomCoordinate.y));
        List<Room> createdRooms = new List<Room> ();
        while (roomsToCreate.Count > 0 && createdRooms.Count < numberOfRooms) {
            Room currentRoom = roomsToCreate.Dequeue ();
            this.rooms [currentRoom.roomCoordinate.x, currentRoom.roomCoordinate.y] = currentRoom;
            createdRooms.Add (currentRoom);
            AddNeighbors (currentRoom, roomsToCreate);
        }
  
        int maximumDistanceToInitialRoom = 0;
        Room finalRoom = null;
        foreach (Room room in createdRooms) {
            List<Vector2Int> neighborCoordinates = room.NeighborCoordinates ();
            foreach (Vector2Int coordinate in neighborCoordinates) {
                Room neighbor = this.rooms [coordinate.x, coordinate.y];
                if (neighbor != null) {
                    room.Connect (neighbor);
                }
            }
            room.PopulateObstacles (this.numberOfObstacles, this.possibleObstacleSizes);
            room.PopulatePrefabs (this.numberOfEnemies, this.possibleEnemies);
  
            int distanceToInitialRoom = Mathf.Abs (room.roomCoordinate.x - initialRoomCoordinate.x) + Mathf.Abs(room.roomCoordinate.y - initialRoomCoordinate.y);
            if (distanceToInitialRoom > maximumDistanceToInitialRoom) {
                maximumDistanceToInitialRoom = distanceToInitialRoom;
                finalRoom = room;
            }
        }
  
        GameObject[] goalPrefabs = { this.goalPrefab };
        finalRoom.PopulatePrefabs(1, goalPrefabs);
  
        return this.rooms [initialRoomCoordinate.x, initialRoomCoordinate.y];
    }

现在,在Goal预制件中添加一个名为ReachGoal的新脚本。

在该脚本中,我们只用实现OnTriggerEnter2D方法,并在玩家与Goal对象相碰时,调用DungeonGeneration脚本中的ResetDungeon方法,并重启Demo场景。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
  
public class ReachGoal : MonoBehaviour {
  
    void OnTriggerEnter2D(Collider2D col) {
        if (col.gameObject.tag == "Player") {
            GameObject dungeon = GameObject.FindGameObjectWithTag ("Dungeon");
            DungeonGeneration dungeonGeneration = dungeon.GetComponent<DungeonGeneration> ();
            dungeonGeneration.ResetDungeon ();
            SceneManager.LoadScene ("Demo");
        }
    }
}

最后,ResetDungeon方法会再次生成地牢。

public void ResetDungeon() {
        this.currentRoom = GenerateDungeon ();
    }

现在,设置DungeonGeneration脚本中的Goal预制件参数,再次运行游戏。我们现在可以在地牢中找到Goal对象,并重启Demo场景。在这里插入图片描述
完成游戏
我们最后要实现的是,只有当空间内所有敌人被打败后,玩家才能离开空间或地牢。我们需要一种打败敌人的方法。

我们给Enemy预制件添加一个名为KillEnemy新脚本,该脚本会实现OnTriggerEnter2D方法。由于这只是一个演示游戏,所以敌人会在碰到玩家时自动销毁,你也可以在后续完善游戏中添加战斗过程。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
  
public class KillEnemy : MonoBehaviour {
  
    void OnTriggerEnter2D(Collider2D col) {
        if (col.gameObject.tag == "Player") {
            Destroy (this.gameObject);
        }
    }
}

现在,我们修改EnterDoor和ReachGoal脚本,使它们在玩家离开空间或地牢前,检查剩余敌人数量。

首先从EnterDoor脚本开始,在OnCollisionEnter2D方法中,找到带有“Enemy”标签的对象。它会返回所找到对象的数组,如果数组长度为0,这表示所有敌人已被击败。这种情况下,我们会执行已有代码。否则,我们什么也不做。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
  
public class EnterDoor : MonoBehaviour {
  
    [SerializeField]
    string direction;
  
    void OnCollisionEnter2D(Collision2D col) {
        if (col.gameObject.tag == "Player") {
            GameObject[] enemies = GameObject.FindGameObjectsWithTag ("Enemy");
            if (enemies.Length == 0) {
                GameObject dungeon = GameObject.FindGameObjectWithTag ("Dungeon");
                DungeonGeneration dungeonGeneration = dungeon.GetComponent<DungeonGeneration> ();
                Room room = dungeonGeneration.CurrentRoom ();
                dungeonGeneration.MoveToRoom (room.Neighbor (this.direction));
                SceneManager.LoadScene ("Demo");
            }
        }
    }
}

然后,我们在ReachGoal脚本执行同样的检查,这意味着我们只有在空间没有任何敌人时才可以重启地牢。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
  
public class ReachGoal : MonoBehaviour {
  
    void OnTriggerEnter2D(Collider2D col) {
        if (col.gameObject.tag == "Player") {
            GameObject[] enemies = GameObject.FindGameObjectsWithTag ("Enemy");
            if (enemies.Length == 0) {
                GameObject dungeon = GameObject.FindGameObjectWithTag ("Dungeon");
                DungeonGeneration dungeonGeneration = dungeon.GetComponent<DungeonGeneration> ();
                dungeonGeneration.ResetDungeon ();
                SceneManager.LoadScene ("Demo");
            }
        }
    }
}

现在运行游戏,我们只能在杀掉空间内所有敌人后,才可以离开空间或重启地牢。

在这里插入图片描述
小结
在Unity中程序化生成地牢环境游戏就大功告成了,我们希望你能喜欢本教程,你可以点击[阅读原文]下载源代码并动手实践,你可以继续完善本游戏,例如添加战斗过程等。

更多Unity教程,尽在Unity官方中文论坛(UnityChina.cn)。

--------------本文转载于unity官方老论坛(http://forum.china.unity3d.com/thread-33274-1-1.html)

  • 3
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值