小猪学U3D—塔防-地图布置(TowerDefense-Maze)

上一次,我们学习了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

参考:

Object Management

Unity基础教程系列——Unity对象管理12篇

Unity Tower Defense – The Board

Unity Demo教程系列——Unity塔防游戏

欢迎关注下方“非著名资深码农“公众号进行交流~

发表评论

邮箱地址不会被公开。