小猪学U3D—塔防-塔(TowerDefense-Towers)

上一节,我们学习了如何在地图中添加出生点、敌人,并让敌人沿着规划路径前往离他最近的目的地。本节将重点学习如何尝试建造防御塔、锁定并攻击敌人。

塔(Towers)

1.建造塔(Building a Tower)

游戏的目标是在敌人到达目的地之前消灭它们,所以我们需要在面板上建造射击塔。

1.1.瓦片内容(Tile Content)

a.塔是瓦片内容的另一种类型,因此将它们的条目添加到GameTileContent

// 瓦片内容类型
public enum GameTileContentType{
  Empty,        //路
  Destination,  //目的地
  Wall,         //墙
  SpawnPoint,   //敌人生成点
  Tower         //塔
}

b.塔需要射击,我们为了方便单独扩展,创建一个Tower类,继承GameTileContent

using UnityEngine;

// 塔(继承GameTileContent)
public class Tower : GameTileContent{
  
}

c.我们先仅支持一种塔,因此可以通过给GameTileContentFactory一个对塔架预制件的引用来实现,也可以通过Get实例化

// 瓦片内容工厂
public class GameTileContentFactory : GameObjectFactory{
    ...
    [SerializeField]
    Tower towerPrefab = default;  // 塔预制件
    ...
    // 生成不同类型瓦片
    public GameTileContent Get(GameTileContentType type){
      switch(type){
        ...
        case GameTileContentType.towerPrefab:
          return Get(towerPointPrefab);
      }
      ...
    }
    ...
}

1.2.预制体(Prefab)

a.参考墙,为塔创建一个预制件,墙体作为底座,再在上面放一个立方体来代表塔身,再在更上面放一个相同大小的立方体,代表炮塔,用于瞄准和射击。

b.塔会旋转,因为它有一个碰撞器,物理引擎需要追踪它。但我们不需要那么精确,因为我们使用塔碰撞器只是为了选择单元格。可以凑合用一个近似值。移除顶部炮塔立方体的碰撞器,调整中部塔身立方体的碰撞器,让它覆盖两个。

c.我们的塔需要会发射激光束。 有许多种方法可以可视化它,这里我们仅使用拉伸后的半透明立方体来形成光束。 每个塔将需要一个自己的光束,因此将其添加到塔的预制件中。 将其放置在炮塔内,以便默认情况下处于隐藏状态,并使其较小,例如0.1

d.给激光束适当的材质。 这里使用标准的半透明黑色材质,并关闭了所有反射,同时给其提供红色

e.确保激光束立方体没有碰撞器,同时关闭阴影投射和接收

 

f.塔预制完成后,将其添加到工厂

1.3.放置塔(Placing Towers)

a.添加和移除塔的方法

// 游戏面板对象
public class GameBoard : MonoBehaviour{
    ...
    // 添加或移除塔
    public void ToggleTower(GameTile tile){
      if(tile == null){
        return;
      }
      if(tile.Content.Type == GameTileContentType.Tower){
        tile.Content = contentFactory.Get(GameTileContentType.Empty);
        FindPaths();
      }else if(tile.Content.Type == GameTileContentType.Empty){ //在空瓦片和塔之间切换
        tile.Content = contentFactory.Get(GameTileContentType.Tower);
        FindPaths();
      }else if(tile.Content.Type == GameTileContentType.Wall){ //在墙和塔之间切换
        tile.Content = contentFactory.Get(GameTileContentType.Tower);
        FindPaths();
      }
    }
    ...
}

b.添加Shift+鼠标左键 快捷键控制

// 游戏对象
public class Game : MonoBehaviour{
    ...
    void Update(){
      if(Input.GetMouseButtonDown(0)){  //鼠标左键事件
        if(Input.GetKey(KeyCode.LeftShift)){  //同时按下了Shift
          board.ToggleTower(board.GetTile(TouchRay));  //添加或移除塔
        }else{
          board.ToggleWall(board.GetTile(TouchRay));  //添加或移除墙
        }
      }
      ...
    }

    ...
}

1.4.阻挡路径(Blocking the Path)

a.类似墙的方法。为了避免后续添加更多阻挡类型时到处判断,这里直接在GameTileContent里添加个IsBlocksPath属性来控制

// 瓦片内容自定义组件
public class GameTileContent : MonoBehaviour{
    ...
    public bool IsBlockPath => (Type == GameTileContentType.Wall || Type== GameTileContentType.Tower); //墙和塔都阻挡路径
    ...
}

b.生成路径时,通过GameTileContent这个属性

// 瓦片对象
public class GameTile : MonoBehaviour{
    ...
    // 反向设置路径关系(从目的地瓦片开始)
    GameTile GrowPathTo(GameTile neighbor, Direction direction){
      ...
      return neighbor.Content.IsBlockPath ? null : neighbor; //生长路径后,如果邻居是墙/塔,则认为路径阻断,不再返回此瓦片到路径搜索队列
    }
    ...
}

2.锁定敌人(Targeting Enemies)

塔只有找到敌人,才能发挥作用。 一旦发现敌人,它还必须决定将目标对准敌人的哪一部分。

2.1.目标点(Target Point)

我们使用物理引擎来检测目标。 就像塔的碰撞器一样,我们不需要敌人的对撞机来完全匹配其形状。 可以用简单的碰撞器来做,比如球体。 一旦检测到,我们将使用附着有碰撞器的游戏对象的位置作为瞄准点。

我们不能将碰撞器附加到敌人的根对象上,因为碰撞器一直都与模型的位置不匹配,并且会使塔瞄准地面。 因此,我们必须将碰撞器放在模型中的某个位置。 物理引擎将为我们提供对该对象的引用,我们可以将其用于目标定位,但是我们还需要访问根对象上的Enemy组件。 让我们创建一个TargetPoint组件来简化这一过程。 给它一个属性以供私人设置和公开获取敌人组件,以及另一个属性以获取其世界位置。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 目标点组件(添加到敌人预制件上)
public class TargetPoint : MonoBehaviour
{
    // 目标点上的敌人对象
    public Enemy Enemy { get; private set;}
    // 目标点坐标
    public Vector3 Position => transform.position;

    void Awake(){
      Enemy = transform.root.GetComponent<Enemy>();
      Debug.Assert(Enemy != null, "Target point without Enemy root!", this);  //确保敌人组件存在
      Debug.Assert(GetComponent<SphereCollider>() != null, "Target point without sphere collider!", this);  // 碰撞器应与TargetPoint连接到相同的游戏对象
      Debug.Assert(gameObject.layer == 9, "Target point on wrong layer!", this);  //确保敌人在Enemy图层上
    }
}

b.添加TargetPoint组件和Sphere Collider碰撞器到敌人的Cube预制上。这将使塔瞄准立方体的中心。使用半径为0.25的球体碰撞器。由于立方体的比例为0.5,碰撞器的有效半径为0.125。这就使得敌人必须在塔成为有效目标之前就在视觉上锁定了它的射程。碰撞器的大小也会受到敌人的随机比例的影响,所以它在游戏中的大小也会发生变化。

2.2.Enemy层(Enemy Layer)

塔只关心敌人,不应该瞄准其他东西,因此我们将所有敌人放在一个专用的层上。 我们将使用第9层。通过“Layers & Tags窗口将其名称设置为Enemy,可以通过编辑器右上角的Layers下拉菜单中的Edit Layers选项打开该窗口。

该层仅用于检测敌人,不适用于物理相互作用。 让我们通过在Layer Collision Matrix中禁用它来表明这一点,你可以在“编辑-项目设置”的2D Physics物理面板下找到它。

确保目标点的游戏对象在正确的图层上。 敌方预制件的其余部分可以在其他层上,但是最好保持一致,将整个预制件放置在enemy层上。 如果你要更改根对象的层,则可以选择更改其所有子对象。

同时,播放器交互应该忽略敌人的碰撞。我们可以通过给物理添加一个layer Mask参数来做到这一点。Raycast GameBoard.GetTile。它有一个变体,以射线距离和Layermask作为附加参数。提供默认图的最大范围和layer mask,即1。

// 游戏面板对象
public class GameBoard : MonoBehaviour{
    ...
    // 获取被射线集中的瓦片
    public GameTile GetTile(Ray ray){
      //是否有物体被击中
      if(Physics.Raycast(ray, out RaycastHit hit, float.MaxValue, 1)){  //提供默认图的最大范围作为射线距离;以及layer mask,即1
        // 根据撞击点XZ坐标确定瓦片
        int x = (int)(hit.point.x + size.x * 0.5f);
        int y= (int)(hit.point.z + size.y * 0.5f);
        if(x >=0 && x < size.x && y >= 0 && y < size.y){  //瓦片坐标处于面板边界内
          return tiles[x + y * size.x];
        }
      }
      return null;
    }
    ...
}

2.3.更新瓦片内容(Updating Tile Content)

塔只有在Game.Update中触发瞄准和射击动作,我们需要实现这个机制。

a.Game.Update中触发board.GameUpdate

// 游戏对象
public class Game : MonoBehaviour{
    ...
    void Update(){
      ...
      // 更新塔,使其瞄准敌人
      board.GameUpdate();
    }
    ...
}

b.GameBoard.GameUpdate中触发GameTileContent的更新,GameTileContent列表只添加Tower塔

// 游戏面板对象
public class GameBoard : MonoBehaviour{
    ...
    List<GameTileContent> updatingContent = new List<GameTileContent>();  //瓦片内容自定义组件列表(目前只更新塔)
    ...
    // 添加或移除塔
    public void ToggleTower(GameTile tile){
      if(tile == null){
        return;
      }
      if(tile.Content.Type == GameTileContentType.Tower){
        updatingContent.Remove(tile.Content); // 只更新塔
        ...
      }else if(tile.Content.Type == GameTileContentType.Empty){ //在空瓦片和塔之间切换
        ...
        if(FindPaths()){
          updatingContent.Add(tile.Content); // 更新塔
        }
      }else if(tile.Content.Type == GameTileContentType.Wall){ //在墙和塔之间切换
        ...
        updatingContent.Add(tile.Content); // 更新塔
      }
    }
    ...
    // 触发塔的更新
    public void GameUpdate(){
      for(int i=0; i<updatingContent.Count; i++){
        updatingContent[i].GameUpdate();
      }
    }
}

c.GameTileContent添加GameUpdate虚函数,并在Tower中实现这个函数,在函数内寻找射程内的敌人

// 瓦片内容类型
public enum GameTileContentType{
    ...
    // 虚方法
    public virtual void GameUpdate(){}
}


// 塔(继承GameTileContent)
public class Tower : GameTileContent{
    // 更新游戏
    public override void GameUpdate(){
      Debug.Log("Tower GameUpdate!");
    }
}

2.4.目标范围(Targeting Range)

塔应该仅具有有限的目标范围,我们通过向塔添加属性来进行配置。 距离是从塔的瓦片中心测得的,因此0.5的范围仅覆盖其自身的瓦片。 因此,合理的最小和默认范围应为1.5,覆盖大多数相邻图块。

// 塔(继承GameTileContent)
public class Tower : GameTileContent{
    // 塔的射程范围
    [SerializeField, Range(1.5f, 10.5f)]
    float targetingRange = 1.5f;
}

我们可以用Gizmo以塔为中心绘制一个半径范围为黄色的球形线来可视化塔的攻击范围。 需要注意的是,OnDrawGizmosSelected方法,该方法仅在场景编辑视图中选定对象时被调用,在Game运行下是不显示的,所以不用在这里纳闷为什么运行时选中塔不显示了。

// 塔(继承GameTileContent)
public class Tower : GameTileContent{
    ...
    // 场景编辑视图中选中塔时,可视化其射程范围
    void OnDrawGizmosSelected () {
      Debug.Log("OnDrawGizmosSelected!");
  		Gizmos.color = Color.yellow;
  		Vector3 position = transform.localPosition;
  		position.y += 0.01f;
  		Gizmos.DrawWireSphere(position, targetingRange);
      if(target != null){
        Gizmos.DrawLine(position, target.Position);
      }
    }
}

2.5.获得并锁定目标(Acquiring a Target & Locking)

向塔中添加一个TargetPoint字段,用来跟踪其锁定的目标。

添加AcquireTarget方法,通过以塔的位置和范围来检索所有目标,并选择第一个。

为了避免每次用碰撞检测性能消耗太大,我们添加TrackTarget方法来追踪锁定的目标是否脱离射程,没脱离时就不再重新扫描射程内的所有敌人了。

当锁定敌人后,我们暂时先通过Log打印锁定的敌人位置。

// 塔(继承GameTileContent)
public class Tower : GameTileContent{
    // 塔的射程范围
    [SerializeField, Range(1.5f, 10.5f)]
    float targetingRange = 1.5f;
    // 塔的攻击目标
    TargetPoint target;
    // 敌人所在图层
    const int enemyLayerMask = 1 << 9;  //2的9次方

    // 更新游戏
    public override void GameUpdate(){
      // 是否找到攻击目标
      if(TrackTarget() || AcquireTarget()){
        Debug.Log("Acquired target Position: (" + target.Enemy.TileIdx/11 + "," +target.Enemy.TileIdx%11  + ") !");
      }
    }

    // 是否找到攻击目标
    bool AcquireTarget(){
      Collider[] targets = Physics.OverlapSphere(transform.localPosition, targetingRange, enemyLayerMask);  //物理碰撞探测,返回结果是一个Collider数组,包含与球体重叠的所有碰撞体
      if(targets.Length > 0){
        target = targets[0].GetComponent<TargetPoint>();
        Debug.Assert(target != null, "Target non-enemy!", targets[0]);
        return true;
      }
      target = null;
      return false;
    }

    // 追踪目标
    bool TrackTarget(){
      if(target == null){
        return false;
      }
      // 目标是否脱离塔的攻击范围
      Vector3 a = transform.localPosition;
      Vector3 b = target.Position;
      if(Vector3.Distance(a, b) > targetingRange + 0.125f * target.Enemy.Scale){ //0.125f为碰撞器的半径
        target = null;
        return false;
      }
      return true;
    }

    // 场景视图中选中塔时,可视化其射程范围
    void OnDrawGizmosSelected () {
      Debug.Log("OnDrawGizmosSelected!");
  		Gizmos.color = Color.yellow;
  		Vector3 position = transform.localPosition;
  		position.y += 0.01f;
  		Gizmos.DrawWireSphere(position, targetingRange);
      if(target != null){
        Gizmos.DrawLine(position, target.Position);
      }
    }
}

2.6.避免内存分配(Avoiding Memory Allocations)

使用Physics.OverlapCapsule的缺点是,每次调用都会分配一个新的数组。 通过一次分配一个数组并在半径之后调用替代OverlapCapsuleNonAlloc方法(将数组作为额外的参数),可以避免这种情况。 提供的数组的长度限制了我们获得多少结果, 超出限制的任何潜在目标都将被忽略。 由于我们仍然只使用第一个元素,因此我们可以处理长度为1的数组。

// 塔(继承GameTileContent)
public class Tower : GameTileContent{
    ...
    // 目标列表
    static Collider[] targets = new Collider[1]; //每个塔只需要锁定一个目标,其他的都丢掉
    ...
    // 是否找到攻击目标
    bool AcquireTarget(){
      Vector3 a = transform.localPosition;
      Vector3 b = a;
      b.y += 1.5f;  //拉伸碰撞器高度,消除海拔高度影响
      //Collider[] targets = Physics.OverlapSphere(transform.localPosition, targetingRange, enemyLayerMask);  //物理碰撞探测,返回结果是一个Collider数组,包含与球体重叠的所有碰撞体
      //Collider[] targets = Physics.OverlapCapsule(a, b, targetingRange, enemyLayerMask);
      int hits = Physics.OverlapCapsuleNonAlloc(a, b, targetingRange, targets, enemyLayerMask); //物理碰撞探测,返回数量并将结果数组放到targets中
      if(hits > 0){ //if(targets.Length > 0){
      ...
    }
    ...
}

3.射击敌人(Shooting Enemies)

终于可以开始做射击了,等的花都快谢了吧?哈哈!射击动作涉及炮塔瞄准,发射激光并造成伤害。

3.1.瞄准(Aiming the Turret)

a.为了将炮塔指向目标,需要一个炮塔的transform引用,我们为其添加一个配置字段,并进行配置

// 塔(继承GameTileContent;塔包含底部底座、中部塔身、顶部炮塔三部分)
public class Tower : GameTileContent{
    ...
    // 炮塔
    [SerializeField]
    Transform turret = default; //顶部的炮塔
    ...
}

b.在Tower.GameUpdate中,使炮塔旋转面向锁定的目标

// 塔(继承GameTileContent;塔包含底部底座、中部塔身、顶部炮塔三部分)
public class Tower : GameTileContent{
    ...
    // 更新游戏
    public override void GameUpdate(){
      // 是否找到攻击目标
      if(TrackTarget() || AcquireTarget()){
        // 射击目标
        Shoot();
      }
    }

    // 射击敌人
    void Shoot(){
      Vector3 point = target.Position; //敌人位置
      // 旋转炮塔,面向敌人
      turret.LookAt(point);
      // 射击
      // TODO
    }
    ...
}

c.运行可以看到炮塔会旋转并面向锁定目标

3.2.发射激光(Shining the Laser)

添加激光束的transform引用,我们为其添加一个配置字段,并进行配置。并在Shoot方法中修改激光束的方向、长度、位置。

// 塔
public class Tower : GameTileContent{
    ...
    // 炮塔/激光
    [SerializeField]
    Transform turret = default, laserBeam = default; //顶部的炮塔和激光引用
    Vector3 laserBeamScale; //激光束尺寸

    void Awake(){
      laserBeamScale = laserBeam.localScale;  //保存激光束尺寸,避免每次申请内存
    }

    // 更新游戏
    public override void GameUpdate(){
      if(TrackTarget() || AcquireTarget()){ // 找到攻击目标
        // 射击目标
        Shoot();
      }else{  //射程内无目标
        // 清除激光
        laserBeam.localScale = Vector3.zero;
      }
    }
    // 射击敌人
    void Shoot(){
      Vector3 point = target.Position; //敌人位置
      // 旋转炮塔,面向敌人
      turret.LookAt(point);
      // 激光束方向与炮塔保持一致
      laserBeam.localRotation = turret.localRotation;
      // 调整激光束长度(炮塔-敌人距离)
      float d = Vector3.Distance(turret.position, point);
      laserBeamScale.z = d;
      laserBeam.localScale = laserBeamScale;
      // 调整激光束位置(塔和敌人中间点)
      laserBeam.localPosition = turret.localPosition + 0.5f * d * laserBeam.forward;
    }
    ...
}

运行测试效果:

3.3.敌人的血量(Enemy Health)

目前我们的激光束只是射向敌人,并没有对敌人造成伤害。 我们不想一下就消灭敌人,所以要给敌人一个生命值属性,并添加造成伤害的方法ApplyDamage。在GameUpdate开始时检查生命值是否耗尽,如果是则销毁敌人。

// 敌人
public class Enemy : MonoBehaviour{
    ...
    // 血量
    public float Health { get; private set; }

    // 初始化
    public void Initialize(float scale, float pathOffset){
      ...
      Health = 100f * scale;  // 血量默认100*缩放比例
    }
    ...
    // 更新敌人位置等信息(敌人死亡时返回false)
    public bool GameUpdate(){
      // 检查敌人生命
      if(Health <= 0f){
        OriginFactory.Reclaim(this);
        return false;
      }
      ...
    }
    ...
    // 对敌人造成伤害
    public void ApplyDamage(float damage){
      Debug.Assert(damage >= 0f, "Negative damage applied.");
      Health -= damage;
    }
}

3.4.DPS每秒伤害(Damage per Second)

现在需要确定激光束会造成多大的损害,我们在塔上增加每秒伤害值damagePerSecond配置,并在射击时调用敌人的造成伤害方法。

// 塔
public class Tower : GameTileContent{
    ...
    // 每秒伤害值
    [SerializeField, Range(1f, 100f)]
    float damagePerSecond = 10f;
    ...
    // 射击敌人
    void Shoot(){
      ...
      // 对敌人造成伤害(降低敌人血量)
      target.Enemy.ApplyDamage(damagePerSecond * Time.deltaTime);
    }
}

 

3.5.随机目标(Targeting Random)

因为目前总是在每个塔中选择第一个可用的目标,所以目标行为取决于物理引擎检查重叠碰撞器的顺序,而我们不了解其内部细节,实际运行看它通常会导致集中起火,但也不总是这样。我们给他添加一些随机性。

// 塔
public class Tower : GameTileContent{
    ...
    // 锁定目标列表
    static Collider[] targets = new Collider[20]; //超过定义长度的会丢掉
    ...
    // 是否找到攻击目标
    bool AcquireTarget(){
      ...
      if(hits > 0){
        target = targets[Random.Range(0, hits)].GetComponent<TargetPoint>();  //目前为从探测敌人中随机1个,后续可调整为为优先血量低的等策略
        ...
      }
      ...
    }
}

今天我们学习了如何在地图中建造防御塔、锁定射程内的敌人,并发射激光束攻击敌人。下一次,我们将学习如何让炮塔发射出迫击炮弹,形成炮弹空中抛物轨迹,并在撞击敌人后发生爆炸。

什么?觉得塔和敌人太丑?别着急,正在研究怎么导入模型和动画呢,表着急哈~

离目标又更近一步了哟~!

yan 21.8.22

参考:

Tower Defense – Towers

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

发表评论

邮箱地址不会被公开。