请选择 进入手机版 | 继续访问电脑版

查看: 850|回复: 34

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

[复制链接]

1278

主题

1985

帖子

2万

贡献

管理员

Rank: 9Rank: 9Rank: 9

积分
26195
QQ
发表于 2019-1-28 04:00:02 | 显示全部楼层 |阅读模式
在游戏中有二种方法构建地牢环境,第一种方法是手动创建并连接地牢空间,这种方法的优点是我们可以手动选择每个地牢空间中的内容。

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

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

00.png


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

我们提供本教程的源代码文件,你可以回复本文进行下载。
游客,如果您要查看本帖隐藏内容请回复


创建平铺地图
首先我们要为地牢空间创建Tilemap,然后在生成地牢后加载Tilemap,我们需要使用Unity提供的Tilemap生成功能。

首先下载本教程的精灵,我们配置用于Tilemap的terrains精灵。我们把Pixels Per Unit设为40,确保瓦片以正确大小出现。然后我们把Sprite Mode设为Multiple,将其切分为多个独立瓦片。

01.png

02.png


现在右键单击层级窗口,选择2D Object -> Tilemap。我们需要用terrains瓦片集来创建一个新的瓦片调色板,并将它保存在一个名为Tile Palettes的单独文件夹中。创建瓦片调色板后,我们将瓦片集拖到Tile Palette窗口。

03.png


现在我们使用瓦片调色板,开始创建地牢空间的Tilemap,选中笔刷工具并使用合适的瓦片绘制Tilemap。绘制好完成后,我们得到了下图效果。

04.png


下一步是让地牢空间的墙体可以发生碰撞,同时地板瓦片不会发生碰撞。

我们选中Tilemap对象并添加Tilemap Collider 2D组件,但这样会使所有瓦片都会发生碰撞。为了让地板瓦片无法碰撞,我们在Tile Palettes文件夹选中地板瓦片,把它的Collider Type改为None。

05.png

06.png


创建玩家对象和门预制件
我们已经创建了地牢空间,还需要制作地牢中移动的玩家和通向不同地牢空间的门。

我们从创建玩家开始,首先在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对象。

07.png


由于这是一个俯视角游戏,不需要任何重力效果,所以我们点击Edit -> Project Settings -> Physics2D中禁用重力,将Gravity的Y值改为0。

08.png


现在创建一个名为PlayerMovement脚本,并将其添加到Player对象上。该脚本只要能移动Player对象即可,所以该脚本需要speed属性作为SerializeField,我们在此实现了FixedUpdate方法来移动玩家。

为了实现移动效果,该脚本会获取水平方向和垂直方向的移动数值,并相应地更新速度。
[C#] 纯文本查看 复制代码
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对象碰撞。

09.png


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

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

请注意,我们需要添加SceneManagement命名空间来重启场景。
[C#] 纯文本查看 复制代码
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列表来连接相邻空间。连接空间的具体过程是先迭代每个空间的相邻坐标,然后检查网格上该坐标是否存在空间。如果是的,算法会连接二个空间。

最后我们会确保地牢中有预期数量的空间,并让所有空间相互连通,每个空间都与前一个相邻空间连接。
[C#] 纯文本查看 复制代码
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”列表。

为了创建空间,我们会随机选取其中的一个相邻空间。
[C#] 纯文本查看 复制代码
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方法。

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

[C#] 纯文本查看 复制代码
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方法将返回当前空间的所有相邻空间坐标。每个空间在四个方向都有一个相邻空间:北边、东边、南边,西边。此顺序很重要,因为顺序是游戏中初始化空间的必要信息。
[C#] 纯文本查看 复制代码
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字典中。
[C#] 纯文本查看 复制代码
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方法,它会将空间网格显示为字符串。
[C#] 纯文本查看 复制代码
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方法。创建地牢后,我们会输出地牢的字符串以供测试。
[C#] 纯文本查看 复制代码
void Start () {
		GenerateDungeon ();
		PrintGrid ();
	}


现在,我们可以尝试使用给定参数来运行游戏,并检查游戏是否正常工作。

9-1.png


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

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

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

10.png


这样我们可以在Room类添加PrefabName方法,该方法会返回当前空间的Room预制件名称。由于NeighborCoordinates方法会以正确顺序返回相邻位置,因此由PrefabName返回的名称,会匹配我们打算实例化的预制件名称。
[C#] 纯文本查看 复制代码
public string PrefabName () {
		string name = "Room_";
		foreach (KeyValuePair<string, Room> neighborPair in neighbors) {
			name += neighborPair.Key;
		}
		return name;
	}


最后我们更新DungeonGeneration脚本的Start方法,从而实例化该地牢空间的预制件。生成地牢后,它会返回初始地牢空间。然后脚本会从Resources文件夹加载并实例化预制件。
[C#] 纯文本查看 复制代码
void Start () {
		this.currentRoom = GenerateDungeon ();
		string roomPrefabName = this.currentRoom.PrefabName ();
		GameObject roomObject = (GameObject) Instantiate (Resources.Load (roomPrefabName));
	}


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

11.png


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

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

我们还要在该方法中生成地牢,如果实例已经设置好,我们会仅实例化当前空间的预制件,然后删除新建的Dungeon对象。
[C#] 纯文本查看 复制代码
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场景。
[C#] 纯文本查看 复制代码
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对象中获取相邻空间的方法。
[C#] 纯文本查看 复制代码
public void MoveToRoom(Room room) {
		this.currentRoom = room;
	}
 
	public Room CurrentRoom() {
		return this.currentRoom;
	}


[C#] 纯文本查看 复制代码
public Room Neighbor (string direction) {
		return this.neighbors [direction];
	}


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

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

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

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

为了记录空间中的可用坐标,我们会使用称为population的字符串矩阵。选取障碍物的区域后,我们会更新population矩阵。
[C#] 纯文本查看 复制代码
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循环检查区域是否可用。如果不可用,它会再次迭代寻找另一个区域,直到找到可用区域。
[C#] 纯文本查看 复制代码
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矩阵是否对所有坐标可用。
[C#] 纯文本查看 复制代码
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方法。
[C#] 纯文本查看 复制代码
[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)表示地图中心。
[C#] 纯文本查看 复制代码
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对象,以设置新属性的值,例如:可能障碍物大小和障碍物瓦片。然后运行游戏,观察游戏是否正确地创建了障碍物。

12.png


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

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

在population矩阵中,我们会用预制件名称来识别预制件。然后通过名称来实例化预制件。我们使用了name2Prefab字典。该字典会用预制件名称作为索引,并返回其预制件。
[C#] 纯文本查看 复制代码
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字典来实例化预制件。
[C#] 纯文本查看 复制代码
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方法。
[C#] 纯文本查看 复制代码
[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”标签,并将它保存为预制件。

13.png


现在,我们可以更新Dungeon对象,添加敌人数量和敌人预制件到DungeonGeneration脚本。运行游戏,检查敌人是否成功创建。

14.png


添加目标点
完成制作敌人后,我们需要一种实现通关的方式。接下来我们要在游戏中添加Goal对象,它始终会处于离起始位置最远的地牢空间,并且必须由玩家找到。当玩家碰到Goal对象时,便能完成游戏。

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

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

15.png


现在修改DungeonGeneration脚本,使它在最远地牢空间创建Goal对象。

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

我们这个在循环外为最后的空间再次调用PopulatePrefabs,但此时要使用Goal预制件而不是Enemy预制件,我们只需要在空间内创建一个Goal对象。
[C#] 纯文本查看 复制代码
[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场景。
[C#] 纯文本查看 复制代码
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方法会再次生成地牢。
[C#] 纯文本查看 复制代码
public void ResetDungeon() {
		this.currentRoom = GenerateDungeon ();
	}


现在,设置DungeonGeneration脚本中的Goal预制件参数,再次运行游戏。我们现在可以在地牢中找到Goal对象,并重启Demo场景。

16.png


完成游戏
我们最后要实现的是,只有当空间内所有敌人被打败后,玩家才能离开空间或地牢。我们需要一种打败敌人的方法。

我们给Enemy预制件添加一个名为KillEnemy新脚本,该脚本会实现OnTriggerEnter2D方法。由于这只是一个演示游戏,所以敌人会在碰到玩家时自动销毁,你也可以在后续完善游戏中添加战斗过程。
[C#] 纯文本查看 复制代码
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,这表示所有敌人已被击败。这种情况下,我们会执行已有代码。否则,我们什么也不做。
[C#] 纯文本查看 复制代码
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脚本执行同样的检查,这意味着我们只有在空间没有任何敌人时才可以重启地牢。
[C#] 纯文本查看 复制代码
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");
			}
		}
	}
}


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

17.png


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

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

0

主题

2

帖子

20

贡献

初级UU族—1级

Rank: 1

积分
20
发表于 2019-1-28 04:32:20 | 显示全部楼层
辛苦楼主了,谢谢谢

0

主题

3

帖子

125

贡献

初级UU族—2级

Rank: 2

积分
125
发表于 2019-1-28 04:48:30 | 显示全部楼层
不错.不错.正好用到..感谢分享..

0

主题

1

帖子

25

贡献

初级UU族—1级

Rank: 1

积分
25
发表于 2019-1-28 05:10:45 | 显示全部楼层
感谢,最近刚好在学此类游戏的制作

0

主题

3

帖子

40

贡献

初级UU族—1级

Rank: 1

积分
40
发表于 2019-1-28 05:15:43 | 显示全部楼层
感谢分享

0

主题

22

帖子

605

贡献

中级UU族—1级

Rank: 4

积分
605
发表于 2019-1-28 05:15:54 | 显示全部楼层
学习资源,越多越好

0

主题

2

帖子

25

贡献

初级UU族—1级

Rank: 1

积分
25
发表于 2019-1-28 05:26:11 | 显示全部楼层
谢谢大神的分享

2

主题

3

帖子

55

贡献

初级UU族—2级

Rank: 2

积分
55
发表于 2019-1-28 06:07:01 | 显示全部楼层
下载看看

2

主题

6

帖子

120

贡献

初级UU族—2级

Rank: 2

积分
120
发表于 2019-1-28 06:21:27 | 显示全部楼层
下来学习学习

0

主题

1

帖子

15

贡献

初级UU族—1级

Rank: 1

积分
15
发表于 2019-1-28 06:24:12 | 显示全部楼层
很完整的样子欸!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表