上一次,我们学习了U3D的入门,并尝试了简单的弹跳、发射、碰撞效果制作技巧。这次,我们试图挑战下,尝试做一个粗糙版的塔防游戏:通过在敌人必经之路上建造防御塔,以在敌人到达目的地之前消灭它们。我们将通过这个简单游戏的制作来练习地图布置、敌人寻路、锁定敌人并攻击、弹道轨迹、爆炸效果、游戏场景管理、过场动画、3D模型导入、热加载等技术点。
从零开始做一个游戏不是一件容易的事情,有很多基础知识需要在这个过程中逐渐补上。后边的内容会比较长,但是不积硅步无以至千里,我们先从地图布置开始学习,大家耐心跟着一步一步学习和动手尝试即可。
游戏面板(The Board – Building a Maze)
本节主要讲解游戏地图的创建、寻路以及目的地和墙的放置。
1.面板:创建瓦片游戏面板
1.1.面板(Board)
a.创建名为TowerDefense的3D项目
b.添加名为Game Board的空对象,并创建/绑定GameBoard.cs脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameBoard : MonoBehaviour
{
[SerializeField]
Transform ground = default; //游戏面板控制对象
Vector2Int size; //游戏面板尺寸
public void Initialize(Vector2Int size){
this.size = size;
ground.localScale = new Vector3(size.x, size.y, 1f); //相对父级缩放比例
}
}
c.创建名为Ground的四边形对象,旋转X轴90度(铺成平面),并添加类似地面的材质;将Ground对象绑定到Game Board对象的groud属性上
此时的结构如下:
1.2.游戏(Game)
a.创建名为Game的空对象,并创建/绑定Game.cs,设置Board属性为Game Board
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏对象
public class Game : MonoBehaviour
{
[SerializeField]
Vector2Int boardSize = new Vector2Int(11, 11);
[SerializeField]
GameBoard board = default;
// 唤醒事件
void Awake(){
board.Initialize(boardSize); //初始化11x11的游戏面板
}
// 控制最小值
void OnValidate(){
if(boardSize.x < 2){
boardSize.x = 2;
}
if(boardSize.y < 2){
boardSize.y = 2;
}
}
}
b.调整相机为GameBoard正上方俯视
1.3.瓦片(Tile)
面板由方形瓦片组成。敌人将能够跨边缘从一个瓦片移到另一个瓦片,但是不能对角线移动。运动将始终朝着最近的目的地进行。我们用箭头可视化每个瓦片移动方向。
a.下载下方的箭头纹理文件,把它放到项目的Assets/Resources目录下
b.新建名为Tile Arrow的材质,选取上边的箭头纹理
c.新建名为Arrow的四边形对象,调整位置/旋转角度/缩放比例,并绑定Tile Arrow纹理
c.新建名为Game Tile空对象,并创建/绑定GameTile.cs,设置arrow属性为Game Arror
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 瓦片对象
public class GameTile : MonoBehaviour
{
[SerializeField]
Transform arrow = default;
}
d.调整Arrow为GameTile的子对象
1.4.布置瓦片(Laying Tiles)
a.修改GameBoard.cs,添加tilePrefab属性,并设置为GameTile
b.在Initialize方法中初始化11×11瓦片位置
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
[SerializeField]
Transform ground = default; //游戏面板控制对象
Vector2Int size; //游戏面板尺寸
[SerializeField]
GameTile tilePrefab = default; //游戏瓦片
GameTile[] tiles; // 瓦片列表
// 初始化方法
public void Initialize(Vector2Int size){
this.size = size;
ground.localScale = new Vector3(size.x, size.y, 1f); //相对父级缩放比例
tiles = new GameTile[size.x * size.y];
// 初始化11x11的瓦片位置
Vector2 offset = new Vector2(
(size.x - 1) * 0.5f, (size.y - 1) * 0.5f
);
for(int i=0, y=0; y<size.y; y++){
for(int x=0; x<size.x; x++, i++){
GameTile tile = Instantiate(tilePrefab);
tile.transform.SetParent(transform, false);
tile.transform.localPosition = new Vector3(
x - offset.x, 0f, y - offset.y
);
tiles[i] = tile; //保存到瓦片列表用于后续控制
}
}
}
}
c.运行游戏,可以看到摆放好的11×11个瓦片
2.寻路:广度优先搜索来寻找路径
此时,每个瓦片都有一个箭头,但它们都指向正Z方向,我们将其解释为北。下一步是找出每个瓦片的正确方向。我们通过找到敌人将要到达目的地的路径来做到这一点。
2.1.邻居(Tile Neighbors)
路径从一个瓦片到另一个瓦片,有东南西北四个方向。
a.为了使搜索容易,先为GameTile瓦片增加跟踪对其四个邻居的引用和设置邻居的方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 瓦片对象
public class GameTile : MonoBehaviour
{
[SerializeField]
Transform arrow = default; // 箭头
GameTile north, east, south, west; // 当前瓦片四周的邻居瓦片引用
// 设置邻居的相互引用依赖关系
public static void MakeEastWestNeighbors(GameTile east, GameTile west){ //东西邻居
Debug.Assert(west.east == null && east.west == null, "Redefined neighbors!"); //当出现引用覆盖时,打印debug信息
west.east = east;
east.west = west;
}
public static void MakeNorthSouthNeighbors(GameTile north, GameTile south){ //南北邻居
Debug.Assert(north.south == null && south.north == null, "Redefined neighbors!"); //当出现引用覆盖时,打印debug信息
south.north = north;
north.south = south;
}
}
b.在游戏面板创建瓦片时,建立瓦片邻居的相互引用关系
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
[SerializeField]
Transform ground = default; //游戏面板控制对象
Vector2Int size; //游戏面板尺寸
[SerializeField]
GameTile tilePrefab = default; //游戏瓦片
GameTile[] tiles; // 瓦片列表
// 初始化方法
public void Initialize(Vector2Int size){
this.size = size;
ground.localScale = new Vector3(size.x, size.y, 1f); //相对父级缩放比例
tiles = new GameTile[size.x * size.y];
// 初始化11x11的瓦片位置
Vector2 offset = new Vector2(
(size.x - 1) * 0.5f, (size.y - 1) * 0.5f
);
for(int i=0, y=0; y<size.y; y++){
for(int x=0; x<size.x; x++, i++){
GameTile tile = Instantiate(tilePrefab);
tile.transform.SetParent(transform, false);
tile.transform.localPosition = new Vector3(
x - offset.x, 0f, y - offset.y
);
// 保存到瓦片列表用于后续控制
tiles[i] = tile;
// 建立瓦片邻居的相互引用关系
if(x > 0){
GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]); //关联前一个的左右关系
}
if(y > 0){
GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]); //关联上一行同位置的南北关系
}
}
}
}
}
2.2.方向和距离(Direction and Distance)
我们不会让所有的敌人一直寻找路径,而是提前在每个瓦片上设置好路径信息。敌人可以查询他们所在的瓦片,以了解下一步的去向。我们在GameTile上添加对路径上下一个瓦片的引用,同时添加距离目的地的剩余距离(瓦片数量),并提供对应设置方法,我们会在找到最短路径时使用它。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
GameTile nextOnPath; // 敌人的行走路径的下一瓦片
int distance; // 距离目的地的剩余瓦片数
...
// 每次查找路径前先重置敌人路径信息
public void ClearPath(){
distance = int.MaxValue; // 默认距离无限
nextOnPath = null; // 暂时没有下一瓦片
}
// 当前tile是否有路径的getter
public bool HasPath => distance != int.MaxValue;
// 设置为目的地
public void BecameDestination(){
distance = 0; // 距离为零(相对于自己),路径在这里结束
nextOnPath = null; // 不会再有下一个瓦片了
}
}
2.3.路径成长(Growing the Path)
如果我们有一个带有路径的瓦片,则可以让它沿着路径向它的某个邻居延伸。最初唯一带有路径的瓦片是目的地,所以我们从距离0开始,并从那里开始增加距离,朝着敌人移动的相反方向前进。所以目标的所有直接邻居的距离都是1,而这些瓦片的所有其他邻居的距离都是2,以此类推。
我们添加GrowPathTo及沿特定方向扩展路径的方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 反向设置路径关系(从目的地瓦片开始)
GameTile GrowPathTo(GameTile neighbor){
if(!HasPath || neighbor == null || neighbor.HasPath){
return null;
}
neighbor.distance = distance + 1;
neighbor.nextOnPath = this;
return neighbor;
}
// 沿特定方向扩展路径
public GameTile GrowPathNorth() => GrowPathTo(north);
public GameTile GrowPathEast() => GrowPathTo(east);
public GameTile GrowPathSouth() => GrowPathTo(south);
public GameTile GrowPathWest() => GrowPathTo(west);
}
2.4.广度优先搜索(Breadth-First Search)
GameBoard有责任确保其所有瓦片都包含有效的路径数据。我们将通过执行广度优先搜索来实现这一点。我们从目标瓦片开始,然后扩展到其邻居的路径,然后再到这些瓦片的邻居,依此类推。每一步,距离增加一,路径永远不会朝着已有路径的瓦片增长。这样可以确保所有瓦片最终都沿着最短的路径指向目标。(为什么不用A*算法:A *是广度优先搜索的演变。搜索单个最短路径时,此功能很有用。但是我们需要所有最短路径,因此A *没有任何好处。有关广度优先搜索和应用于带有动画的十六进制网格的A *的示例,请参见 Hex Map 系列。)
我们利用Queue队列临时存储待搜索路径
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
Queue<GameTile> searchFrontier = new Queue<GameTile>(); // 路径搜索边界临时队列
// 初始化方法
public void Initialize(Vector2Int size){
...
// 搜索路径
FindPaths();
}
// 搜索行进路径
void FindPaths(){
// 清除所有瓦片的路径
foreach(GameTile tile in tiles){
tile.ClearPath();
}
// 将一个瓦片作为目标并将其添加到边界,这里我们就选第一个瓦片
tiles[0].BecameDestination(); // 设置为目的地
searchFrontier.Enqueue(tiles[0]); // 将目的地添加到边界队列
// 启动路径搜索(执行完毕后,所有瓦片都将被设置好distance和nextOnPath)
while(searchFrontier.Count > 0){
GameTile tile = searchFrontier.Dequeue(); //出队
if(tile != null){ //有可能生长路径入队的有null
// 将4个方向的生长路径下一节点入队,用于下一层的路径生长
searchFrontier.Enqueue(tile.GrowPathNorth());
searchFrontier.Enqueue(tile.GrowPathEast());
searchFrontier.Enqueue(tile.GrowPathSouth());
searchFrontier.Enqueue(tile.GrowPathWest());
}
}
}
}
2.5.展示路径(Showing the Paths)
现在,我们得到一个包含有效路径的游戏面板,但目前还看不到。我们需要调整箭头,使其沿着穿过瓦片的路径指向,通过旋转它们来做到这一点。
a.将静态Quaternion字段添加到GameTile,每个方向添加一个,并通过ShowPath方法旋转瓦片箭头方向
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 路径箭头方向角
static Quaternion northRotation = Quaternion.Euler(90f, 0f, 0f),
eastRotation = Quaternion.Euler(90f, 90f, 0f),
southRotation = Quaternion.Euler(90f, 180f, 0f),
westRotation = Quaternion.Euler(90f, 270f, 0f);
...
// 展示路径(旋转所有瓦片的箭头方向)
public void ShowPath(){
if(distance == 0){ // 目的地瓦片,不显示箭头
arrow.gameObject.SetActive(false);
return;
}
// 非目的地瓦片,显示并旋转箭头方向为路径方向
arrow.gameObject.SetActive(true);
arrow.localRotation =
nextOnPath == north ? northRotation :
nextOnPath == east ? eastRotation :
nextOnPath == south ? southRotation :
westRotation;
}
}
b.在GameBoard.FindPaths末尾的所有图块上调用ShowPath方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
// 搜索行进路径
void FindPaths(){
...
// 根据路径调整瓦片箭头方向
foreach(GameTile tile in tiles){
tile.ShowPath();
}
}
}
c.运行效果
2.6.交替搜索优先级(Alternating Search Priority)
事实证明,当西南角的瓦片为目的地时,所有路径都一直向西直行,直到到达面板边缘,然后向南。这是没有问题的,在不支持对角线移动的情况下,确实没有到目的地的更短路径。但是,还有许多其他相同的最短的路径。
a.为了更好地了解为什么找到这些路径,请将目的地移动到地图的中心。当使用奇数板尺寸时,这只是数组中间的瓦片。
// 搜索行进路径
void FindPaths(){
...
// 将一个瓦片作为目标并将其添加到边界,这里我们就选第一个瓦片(当使用奇数板尺寸时,tiles.Length/2是中间的瓦片)
GameTile target = tiles[tiles.Length / 2]; //tiles[0];
target.BecameDestination(); // 设置为目的地
searchFrontier.Enqueue(target); // 将目的地添加到边界队列
...
}
运行效果:
注:如果中心点多出一个箭头,可能是默认画布中的Arrow导致的,可以把拖拽上去的Arrow对象设置为不可见即可。
你需要了解搜索是如何工作的,当我们在东北-西南序上加上邻居时,北方是最优先的。当我们向后搜索时,这意味着南方是最后一个经过的方向。这就是为什么只有几个箭头指向南方,而有那么多箭头指向东方。我们可以通过调整方向优先级来更改结果。让我们交换东方和南方。那应该导致南北和东西对称。
这样看起来更好,但如果路径在自然的地方交替方向接近对角移动,那将是最好的。我们可以通过颠倒相邻块的搜索优先级来做到这一点,类似国际象棋棋盘模式。我们需要做些小的代码调整。
// 瓦片对象
public class GameTile : MonoBehaviour
{
// 瓦片在地图中的索引
public int Idx{get;set;}
}
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
// 初始化方法
public void Initialize(Vector2Int size){
...
tile.Idx = i; //记录瓦片位置序号
tiles[i] = tile;
...
}
// 搜索行进路径
void FindPaths(){
...
if(tile != null){ //有可能生长路径入队的有null
// 将4个方向的生长路径下一节点入队,用于下一层的路径生长
if((tile.Idx & 1) == 0){ // 单个“&”号是二进制AND运算符,所有偶数的二进制(x0 & 1)都=0。它对其操作数的每一对位执行逻辑与运算。因此,一对中的两个位都必须都为1,以使结果位为1。例如,10101010和00001111产生00001010。在内部,数字是二进制的。 它们仅使用0和1。 在二进制中,序列1、2、3、4分别写为1、10、11、100。如你所见,偶数的最低有效位为零。
searchFrontier.Enqueue(tile.GrowPathNorth());
searchFrontier.Enqueue(tile.GrowPathSouth());
searchFrontier.Enqueue(tile.GrowPathEast());
searchFrontier.Enqueue(tile.GrowPathWest());
}else{ // 奇数瓦片逆向搜索,以形成对角线和锯齿形的路线
searchFrontier.Enqueue(tile.GrowPathWest());
searchFrontier.Enqueue(tile.GrowPathEast());
searchFrontier.Enqueue(tile.GrowPathSouth());
searchFrontier.Enqueue(tile.GrowPathNorth());
}
...
}
}
3.改变瓦片:支持空、目标、墙体类型的瓦片
此时,所有瓦片都为空。一个用作目标的瓦片,但除了没有可见箭头外,它看起来与所有其他图块相同。我们将通过在其中放置一些东西来改变它们。
3.1.瓦片内容(Tile Content)
tile对象本身只是一种跟踪tile信息的方法。我们不直接改变这些对象。相反,我们将引入单独的内容并将其放在游戏板上,用于区分空瓦片和目标瓦片。
a.添加一个Tile内容组件脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 区分空瓦片和目标瓦片
public enum GameTileContentType{
Empty, Destination
}
// 瓦片内容自定义组件
public class GameTileContent : MonoBehaviour
{
[SerializeField]
GameTileContentType type = default; //允许通过其检查器设置内容类型
public GameTileContentType Type => type;
}
b.创建预制件
为这两种内容类型(空瓦片/目标瓦片)分别创建预制件,每种预制件都带有一个GameTileContent组件,并且其类型设置正确。让我们使用一个蓝色的扁平立方体来可视化目标瓦片。因为它几乎是平坦的,所以不需要碰撞。对于空内容预制件,请使用空的游戏对象。
我们将为空瓦片提供内容对象,因为所有瓦片将始终具有内容,这意味着我们不必检查空内容引用。
3.2.内容工厂(Content Factory)
瓦片内容预制件将用于关联到瓦片属性上,并在渲染时叠加到瓦片上方展示。我们通过内容工厂来实现。
a.创建内容工厂脚本GameTileContentFactory.cs和对应预制件GameTileContentFactory,并在预制件上绑定Destination预制件、Dmpty预制件
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
// 瓦片内容工厂
[CreateAssetMenu] //添加到unity菜单
public class GameTileContentFactory : ScriptableObject
{
// 预置配置字段
[SerializeField]
GameTileContent destinationPrefab = default;
[SerializeField]
GameTileContent emptyPrefab = default;
// 工厂的内容场景
Scene contentScene;
// 移动对象到工厂的内容场景
void MoveToFactoryScene(GameObject o){
if(!contentScene.isLoaded){
if(Application.isEditor){ //在编辑器中时,检查场景是否存在
contentScene = SceneManager.GetSceneByName(name);
if(!contentScene.isLoaded){
contentScene = SceneManager.CreateScene(name);
}
}else{
contentScene = SceneManager.CreateScene(name);
}
}
SceneManager.MoveGameObjectToScene(o, contentScene);
}
// 生产瓦片内容
GameTileContent Get(GameTileContent prefab){
GameTileContent o = Instantiate(prefab);
o.OriginFactory = this;
MoveToFactoryScene(o.gameObject);
return o;
}
// 生成不同类型瓦片
public GameTileContent Get(GameTileContentType type){
switch(type){
case GameTileContentType.Destination:
return Get(destinationPrefab);
case GameTileContentType.Empty:
return Get(emptyPrefab);
}
Debug.Assert(false, "Unsupported type: " + type);
return null;
}
//回收
public void Reclaim(GameTileContent content){
Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!");
Destroy(content.gameObject);
}
}
b.瓦片内容组建增加工厂的反向跟踪
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 区分空瓦片和目标瓦片
public enum GameTileContentType{
Empty, Destination
}
// 瓦片内容自定义组件
public class GameTileContent : MonoBehaviour
{
// 内容类型
[SerializeField] //通过其检查器设置内容类型
GameTileContentType type = default;
public GameTileContentType Type => type;
// 跟踪原始工厂
GameTileContentFactory originFactory;
public GameTileContentFactory OriginFactory{
get => originFactory;
set {
Debug.Assert(originFactory == null, "Redefined origin factory!");
originFactory = value;
}
}
// 通过跟踪原始工厂回收
public void Recycle(){
originFactory.Reclaim(this);
}
}
3.3.改变内容(Changing Content)
瓦片对象增加GameTileContent的关联属性,主要用于把内容移动到瓦片位置
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 关联到瓦片的内容(覆盖到瓦片上方)
GameTileContent content;
public GameTileContent Content{
get => content;
set{
Debug.Assert(value !=null, "Null assingned to content!");
if(content !=null){ //回收它以前的内容
content.Recycle();
}
content = value; //定位新内容
content.transform.localPosition = transform.localPosition; //把内容移动到瓦片位置
}
}
...
}
3.4.接触瓦片(Touching a Tile)
a.游戏面板增加瓦片内容的初始化,以及目的地<->非目的地的对调;改造搜索支持多目的地
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
GameTileContentFactory contentFactory; //内容工厂
// 初始化方法
public void Initialize(Vector2Int size, GameTileContentFactory contentFactory){
this.size = size;
this.contentFactory = contentFactory;
...
tile.Content = contentFactory.Get(GameTileContentType.Empty);
...
// 搜索路径
ToggleDestination(tiles[tiles.Length / 2]); //当使用奇数板尺寸时,tiles.Length/2是中间的瓦片
}
// 对调瓦片的目的地<->非目的地属性
public void ToggleDestination(GameTile tile){
if(tile == null){
return;
}
if(tile.Content.Type == GameTileContentType.Destination){
tile.Content = contentFactory.Get(GameTileContentType.Empty);
if(!FindPaths()){
tile.Content = contentFactory.Get(GameTileContentType.Destination);
FindPaths();
}
}else{
tile.Content = contentFactory.Get(GameTileContentType.Destination);
FindPaths();
}
}
// 搜索行进路径
bool FindPaths(){
// 清除所有瓦片的路径
foreach(GameTile tile in tiles){
if(tile.Content.Type == GameTileContentType.Destination){ //目的地
Debug.Log("BecameDestination Idx: " + tile.Idx);
tile.BecameDestination(); // 设置为目的地
searchFrontier.Enqueue(tile); // 将目的地添加到边界队列
}else{
tile.ClearPath();
}
}
if(searchFrontier.Count == 0){
return false;
}
...
}
...
}
b.Game添加鼠标点击切换目的地并重新搜素路径事件;在属性中绑定TileContentFactory预制
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 游戏对象
public class Game : MonoBehaviour
{
...
[SerializeField]
GameTileContentFactory tileContentFactory = default;
Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition); //主相机单击转换为射线
// 唤醒事件
void Awake(){
board.Initialize(boardSize, tileContentFactory); //初始化11x11的游戏面板
}
...
void Update(){
if(Input.GetMouseButtonDown(0)){ //鼠标事件
board.ToggleDestination(board.GetTile(TouchRay));
}
}
}
c.运行并点击生成多个目的地,可以看到会自动向多个目的地规划路径
4.墙(Walls):在运行时编辑瓦片内容
塔防游戏的目的是确保敌人不会到达目的地。这可以通过两种方式完成。首先,通过杀死它们,其次是通过放慢它们的速度来使你有更多时间杀死它们。在一块瓦片游戏面板上,给自己更多时间的主要方法是增加敌人必须移动的距离。这是通过在板上放置障碍物来完成的,障碍物通常是可以杀死敌人的塔,但是在本教程中,我们将限制为墙体。
4.1.内容(Content)
墙是另一种内容。
a.将Wall添加到GameTileContentType中
// 瓦片内容类型
public enum GameTileContentType{
Empty, //路
Destination, //目的地
Wall //墙
}
b.创建墙预制件
这次,创建一个瓦片内容游戏对象,并为其指定一个立方体子项,使其定位在棋盘顶部并填充整个瓦片。将它放高半个单位并保持其碰撞体,因为墙壁可以从视觉上遮挡住后面的瓦片。因此,当播放器接触墙壁时,将影响相应的瓦片。
c.使用代码和检查器将墙壁预制件添加到工厂
// 瓦片内容工厂
public class GameTileContentFactory : ScriptableObject
{
// 预置配置字段
...
[SerializeField]
GameTileContent wallPrefab = default; // 墙预制件
...
// 生成不同类型瓦片
public GameTileContent Get(GameTileContentType type){
switch(type){
case GameTileContentType.Destination:
return Get(destinationPrefab);
case GameTileContentType.Empty:
return Get(emptyPrefab);
case GameTileContentType.Wall:
return Get(wallPrefab);
}
Debug.Assert(false, "Unsupported type: " + type);
return null;
}
...
}
4.2.开关墙壁(Toggling Walls)
a.就像为目的地一样,向GameBoard添加墙的切换方法
我们仅支持在空瓦片和墙砖之间切换,而不允许墙直接替换目的地。因此,仅当瓦片为空时才转换为墙。另外,墙壁会应该阻碍寻路。但是每个瓦片都需要有一条通往目的地的路径,否则敌人可能会卡住。再次,我们将使用FindPaths对此进行检查,如果创建了无效的面板状态,则撤消更改。
// 游戏面板对象 public class GameBoard : MonoBehaviour { ... // 对调瓦片的墙<->非墙属性 public void ToggleWall(GameTile tile){ if(tile == null){ return; } if(tile.Content.Type == GameTileContentType.Wall){ tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); }else if(tile.Content.Type == GameTileContentType.Empty){ //仅支持在空瓦片和墙砖之间切换 tile.Content = contentFactory.Get(GameTileContentType.Wall); FindPaths(); } } ... }
b.修改Game的点击事件触发
// 游戏对象
public class Game : MonoBehaviour
{
...
void Update(){
if(Input.GetMouseButtonDown(0)){ //鼠标左键事件
board.ToggleWall(board.GetTile(TouchRay)); //切换空瓦片和墙砖
}else if(Input.GetMouseButtonDown(1)){ //鼠标右键事件
board.ToggleDestination(board.GetTile(TouchRay)); //切换空瓦片和目的地
}
}
}
c.运行测试添加墙
4.3.阻断寻路(Blocking Findpathing)
要让墙壁阻碍寻路,我们所要做的就是不要向searchFrontier添加带有Wall的瓦片。可以通过GameTile.GrowPathTo不返回带有墙的瓦片来实现。但是路径仍然应该长到墙里面去,但不会继续延伸。
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 反向设置路径关系(从目的地瓦片开始)
GameTile GrowPathTo(GameTile neighbor){
...
return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; //生长路径后,如果邻居是墙,则认为路径阻断,不再返回此瓦片到路径搜索队列
}
...
}
4.4.隐藏路径(Hiding the Paths)
路径可视化使我们能够查看寻路的工作原理并验证其确实正确,但并不打算将其显示给玩家,至少并非总是如此。因此,让我们可以隐藏箭头。
a.在GameTile中添加一个公共的HidePath方法
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 隐藏路径箭头
public void HidePath(){
arrow.gameObject.SetActive(false);
}
}
b.为GameBoard添加一个路径箭头展示开关字段(默认情况下设置为false),用于控制所有箭头的展示和隐藏
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
bool isShowPaths = true; // 路径箭头展示开关
public bool IsShowPaths{
get => isShowPaths;
set {
isShowPaths = value;
if(isShowPaths){
foreach(GameTile tile in tiles){
tile.ShowPath();
}
}else{
foreach(GameTile tile in tiles){
tile.HidePath();
}
}
}
}
...
// 搜索行进路径
bool FindPaths(){
...
// 根据路径调整瓦片箭头方向
if(IsShowPaths){
foreach(GameTile tile in tiles){
tile.ShowPath();
}
}
...
}
...
}
c.为Game添加键盘事件,控制路径的显示和隐藏
// 游戏对象
public class Game : MonoBehaviour
{
...
void Update(){
if(Input.GetKeyDown(KeyCode.V)){ //键盘V键
board.IsShowPaths = !board.IsShowPaths; // 切换路径箭头显示状态
}
}
}
d.运行并通过键盘V键隐藏路径
4.5.展示网格(Showing the Grid)
隐藏箭头时,很难看到每个瓦片的位置。让我们添加网格线使之更容易。这是带有边框的网格纹理,可用于勾勒出单个瓦片。
a.下载网格纹理并放到项目的Assets/Resources目录
b.为GameBoard添加网格纹理属性,并设定为上方的grid纹理资源
我们不会将此纹理单独添加到每个图块,而是将其应用于地面。但是我们将使网格成为可选的,就像路径可视化一样。因此,将一个Texture2D配置字段添加到GameBoard并将其设置为网格纹理。
[SerializeField]
Texture2D gridTexture = default; //网格纹理
c.为GameBoard添加网格展示开关,并在开关打开时设置为网格纹理
[SerializeField]
Texture2D gridTexture = default; //网格纹理属性
bool isShowGrid; // 网格展示开关
public bool IsShowGrid{
get => isShowGrid;
set {
isShowGrid = value;
Material m = ground.GetComponent<MeshRenderer>().material; //材质对象
if(isShowGrid){
m.mainTexture = gridTexture; //设置网格纹理
m.SetTextureScale("_MainTex", size); //缩放材质的主要纹理,使其与面盘尺寸大小匹配,形成瓦片网格
}else{
m.mainTexture = null;
}
}
}
d.为Game添加快捷键,用于控制网格的展示和隐藏
// 游戏对象
public class Game : MonoBehaviour
{
...
// 唤醒事件
void Awake(){
...
board.IsShowPaths = true; //展示路径
board.IsShowGrid = true; //展示网格
}
...
void Update(){
...
if(Input.GetKeyDown(KeyCode.G)){ //键盘G键
board.IsShowGrid = !board.IsShowGrid; // 切换网格显示状态
}
}
}
e.运行并通过G/V快捷键控制网格和箭头的展示
截止到现在,我们做好了游戏地图、目的地、墙、路径规划的准备工作。下一节,我们将学习如何在游戏面板中添加敌人,并让敌人沿着规划路径前往目的地。好期待哇~!
yan 21.8.8
参考:
Unity Tower Defense – The Board