用Unity开发一款塔防游戏(一):攻击方设计


大家好。偶尔想起了这个手把手教学的、但现已长满杂草的坑,还是来挖几铲子。
 


这一期的游戏是最常见的类型之一——塔防。

塔防游戏相信大家并不陌生,几个主要元素如下:

1、敌方士兵

2、我方防御塔

3、我方主城

emmmmmmm好像就没了。

玩法就是建立防御塔阻击前往我方主城的敌兵,可以通过视频直观感受下:

演示视频:https://www.zhihu.com/video/1110139144373776384?autoplay=false&useMSE=

人越狠,话越不多。不多说,接下来我们一步步把这几个功能做完。

素材准备:

网上随便找一些资源就行,不一定要和我一样。这里再次强调:

网上获取的资源一定不能用作商业用途!!!!!!

就本工程而言,资源有一下几种:

敌人2个,分别拥有移动,攻击,待机,死亡四种动画
 

 


防御塔3个,拥有待机,攻击两种动画
 

人形防御塔可还行


主城1个,主地形1组(内含各种杂草乱石)
 


敌人地形(敌人能用来走的路)1种,防御塔地形(防御塔能放置的地方)1种
 


箭矢1个
 

弓兵模型中自带


场景搭建:

先从简单的功能做起:让敌人从生成点走到主城,看见主城就攻击。

搭建一个简单场景:
 

为了检测敌人寻路,最好是能转弯的道路


敌人和主城有一个都有血量的属性,都会被攻击,这里为它们做能显示在头上的血条。

以主城为例,在主城的子节点层创建一个Sprite做黄血条,设为黄色,取名“BloodStrip”,调整好大小:
 


然后在BloodStrip的子节点层创建一个空物体,取名“Hp”,在Hp的子节点层再创建一个Sprite做红血条,名字“Red”,设为红色,大小和黄血条一样,把黄血色覆盖:
 


接下来就移动红血条位置,让它左边边缘与父物体Hp的Y轴重合:
 


然后再将Hp往右移动,让Y轴与黄血条左边缘重合(红血条刚好覆盖黄血条):
 


这样我们只需要设置H的X轴大小,手机靓号卖号就可以控制红血条长度了:


***这里请初学者注意,如果你选取的红血条图片资源不是纯色的、是有其他花纹的,则不能用这个方法。原因很简单,这种方法会把花纹拉长或压扁。大家可以下来想一下:这种情况下应该怎样来设置?

后面在代码中只需要将当前血量与总血量的比值赋给Hp的X轴,就可以将血量信息显示在界面上了。敌人血条做法一样。

做好后让BloodStrip处于禁用状态,受伤后才显示(这是游戏UI显示的一个约定俗成的规则)。

代码编写:

为主城与敌人创建一个基类脚本Character:
 

  1. public class Character : MonoBehaviour
  2. {
  3.     public float totalHp = 100; //总血量
  4.     float surHp; //剩余血量
  5.     protected Transform hpObj; //黄血条
  6.     protected Transform redHp; //血条红条
  7.     protected Transform mainCamera; //主摄像机
  8.     public virtual void Init() //初始化
  9.     {
  10.         surHp = totalHp;
  11.         hpObj = transform.Find("BloodStrip");
  12.         redHp = hpObj.Find("Hp");
  13.         mainCamera = GameObject.Find("Main Camera").transform;
  14.     }
  15.     public void Damage(float damage) //受伤方法,参数为受到的伤害值
  16.     {
  17.         if (surHp > damage) //当前血量大于受伤血量,正常扣血
  18.         {
  19.             surHp -= damage;
  20.             //受伤后开始显示血条
  21.             if (surHp < totalHp)
  22.                 hpObj.gameObject.SetActive(true);
  23.             Vector3 hpScale = redHp.localScale;
  24.             hpScale.x = surHp / totalHp;
  25.             redHp.localScale = hpScale;
  26.         }
  27.         else //当前血量不够,调用死亡方法         
  28.             Death();
  29.     }
  30.     public virtual void Death() //死亡方法
  31.     {
  32.         surHp = 0;
  33.         hpObj.gameObject.SetActive(false); //血条不再显示
  34.     }
  35. }
复制代码


创建主调脚本:用于游戏初始化和记录游戏死亡,挂在一个场景物体上:
 

  1. public class GameMain : MonoBehaviour
  2. {
  3.     public static GameMain instance;
  4.     public bool gameOver;
  5.     void Start()
  6.     {
  7.         InitGame();
  8.     }
  9.     //初始化游戏
  10.     void InitGame()
  11.     {
  12.         instance = this; //单例
  13.         gameOver = false;
  14.     }
  15. }
复制代码


创建主城脚本,继承自Character脚本:
 

  1. public class MainCity : Character
  2. {
  3.     void Start()
  4.     {
  5.         Init();
  6.     }
  7.     private void Update()
  8.     {
  9.         hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
  10.     }
  11.     public override void Death() //重新死亡方法
  12.     {
  13.         base.Death();
  14.         GameMain.instance.gameOver = true; //游戏结束
  15.     }
  16. }
复制代码


敌人的脚本也继承自Charater,除了受伤和死亡之外还能攻击与移动:
 

  1. public class Enemy : Character
  2. {
  3.     Animator anim;
  4.     public float damage; //伤害
  5.     public float speed; //移动速度
  6.     MainCity target; //主城
  7.     public override void Init()
  8.     {
  9.         base.Init();
  10.         anim = GetComponent<Animator>();
  11.     }
  12.     private void Update()
  13.     {
  14.         hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
  15.     }
  16.     //前进方法
  17.     private void EnemyForward()
  18.     {
  19.     }
  20.     //攻击方法(放在攻击动画事件中)
  21.     private void EnemyAttack()
  22.     {
  23.         if (target != null)
  24.             target.Damage(damage);
  25.     }
  26.     //死亡方法
  27.     public override void Death()
  28.     {
  29.         base.Death();
  30.         anim.Play("death");
  31.     }
  32.     //尸体消失
  33.     private void DestroySelf()
  34.     {
  35.         Destroy(gameObject);
  36.     }
  37. }
复制代码


重点在移动方法上。因为敌人的移动带有寻路功能,这里没有采取Unity自带的NavMeshAgent,而是用脚本来实现,主要思路仿照盲人的行进方式,利用射线充当导盲棍,发现前方道路中断再从两边找新的行进路线:
 

拐杖就是射线


要利用好这个思路,场景中道路的搭建也有一定要求,道路都要挂上MeshCollider组件,方便射线检测。
 

所有道路的Z轴指向路线前进方向


道路的物体层设置为“Way”,主城也挂上碰撞器,物体层设为“City”。
 


在敌人模型身上创建一个空物体为眼睛,取名为“Eye”,主要作用是从此为射线起始点,位置合适即可,注意,因为所有敌人都用的相同脚本,所以所有敌人的眼睛高度距离地面相同:
 

正面看这些模型真特么惊悚


当然每个敌人也请挂上碰撞器和刚体以及Animator组件:
 


创建一个敌人状态机:
 

  1. public enum EnemyState //状态机
  2. {
  3.     forward,
  4.     attack,
  5.     death
  6. }
复制代码


重写初始化方法:
 

  1.   Animator anim;
  2.     Rigidbody rigid;
  3.     public EnemyState state;
  4.     Transform eye; //眼睛:用于观测道路和攻击目标
  5.     List<Collider> ways; //记录走过的路(不走回头路)
  6.     //重新初始化方法
  7.     public override void Init()
  8.     {
  9.         base.Init();
  10.    
  11.         anim = GetComponent<Animator>();
  12.         rigid = GetComponent<Rigidbody>();
  13.         gameObject.layer = LayerMask.NameToLayer("Enemy"); //敌人层设置为"Enemy"
  14.         state = EnemyState.forward;
  15.         eye = transform.Find("Eye");
  16.         ways = new List<Collider>();
  17.     }
复制代码


编写移动方法,并在Update中调用:
 

  1. private void Update()
  2.     {
  3.         hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
  4.         if (GameMain.instance.gameOver) //游戏结束播放待机动画
  5.             anim.Play("idle");
  6.         else if (state == EnemyState.forward)
  7.             EnemyForward();
  8.     }
  9.     public int view; //视野
  10.     Quaternion wayDir; //前进方向
  11.     MainCity target; //主城
  12.     Transform way; //正在走的路
  13.     public float speed;
  14.     //前进方法
  15.     private void EnemyForward()
  16.     {
  17.         RaycastHit hit;
  18.         //看见攻击目标则攻击
  19.         if (Physics.Raycast(eye.position, transform.forward, out hit, view, LayerMask.GetMask("City")))
  20.         {
  21.             state = EnemyState.attack;
  22.             anim.Play("attack");
  23.             target = hit.collider.GetComponent<MainCity>();
  24.         }
  25.         //斜下方30°打射线检测前方道路
  26.         if (Physics.Raycast(eye.position, Quaternion.AngleAxis(30, transform.right)
  27.             * transform.forward, out hit, 50, LayerMask.GetMask("Way")))
  28.         {
  29.             Debug.DrawLine(eye.position, hit.point, Color.blue);
  30.             //发现未走过的道路,获取该道路,朝向该路通往的方向
  31.             if (!ways.Contains(hit.collider))
  32.             {
  33.                 ways.Add(hit.collider);
  34.                 way = hit.transform;
  35.                 wayDir = Quaternion.LookRotation(way.forward);
  36.             }
  37.         }
  38.         else //前方没路了发射球形射线检测周围是否有路
  39.         {
  40.             Collider[] colliders = Physics.OverlapSphere(transform.position, 8, LayerMask.GetMask("Way"));
  41.             for (int i = 0; i < colliders.Length; i++)
  42.             {
  43.                 //发现未走过的道路,获取该道路,朝向该路通往的方向
  44.                 if (!ways.Contains(colliders[i]))
  45.                 {
  46.                     way = colliders[i].transform;
  47.                     wayDir = Quaternion.LookRotation(way.forward);
  48.                     break;
  49.                 }
  50.             }
  51.         }
  52.         //获取与脚下道路x轴上偏差值,好让自身走在路中间
  53.         float offset = 0;
  54.         if (way != null)
  55.         {
  56.             Vector3 distance = transform.position - way.position;
  57.             offset = Vector3.Dot(distance, way.right.normalized);
  58.         }
  59.         //面向该路指向的方向前进
  60.         transform.rotation = Quaternion.RotateTowards(transform.rotation, wayDir, speed * 20 * Time.deltaTime);
  61.         transform.Translate(-offset * Time.deltaTime, 0, speed * Time.deltaTime);
  62.     }
复制代码


暂时把初始化方法放在Start中调用(后面我们会在创建的时候初始化),然后设置好血量、视野、速度、伤害,主城也设置好血量:
 


先来看下寻路运行效果:
 


蓝线检测前方道路,红圈检测周围道路

寻路没有问题了,将攻击动画设为循环播放,然后将攻击方法放入攻击动画事件中,敌人看到主城就会自动攻击了:
 

敌人主要功能就已经完成。现在我们来做敌人生成器。


塔防游戏的敌人生成方式一般都是比较有规律的,比如先生成一组a敌人,跟着生成一组b敌人,每组敌人的生成间隔也恒定(当然,读者也可以自己尝试更丰富的出兵方法,比如让“某些特定敌人的血量减到某个阈值”作为触发条件等等):
 


为了生成方便,我们来做一个定时器,可以重复并规律地调用一个生成敌人方法:
 

  1. public class Util : MonoBehaviour
  2. {
  3.     private static Util _Instance = null;
  4.     public static Util Instance //单例模式,依附GameObject
  5.     {
  6.         get
  7.         {
  8.             if (_Instance == null)
  9.             {
  10.                 GameObject obj = new GameObject("Util");
  11.                 _Instance = obj.AddComponent<Util>();
  12.             }
  13.             return _Instance;
  14.         }
  15.     }
  16.     public class TimeTask //定时事件类
  17.     {
  18.         public Action callback; //回调函数
  19.         public float delayTime; //延迟长度
  20.         public float destTime; //延迟后的目标时间
  21.         public int count; //重复次数
  22.     }               
  23.     List<TimeTask> timeTaskList = new List<TimeTask>(); //保存所有的定时事件   
  24.     //增加定时回调的方法
  25.     public void AddTimeTask(Action _callback, float _delayTime, int _count = 1)     
  26.     {
  27.         timeTaskList.Add(new TimeTask()
  28.         {
  29.             callback = _callback,
  30.             delayTime = _delayTime,
  31.             destTime = Time.realtimeSinceStartup + _delayTime,
  32.             count = _count
  33.         });
  34.     }
  35.     private void Update()
  36.     {
  37.         for (int i = 0; i < timeTaskList.Count; i++) //实时监测所有定时事件
  38.         {
  39.             TimeTask task = timeTaskList[i];
  40.             if (Time.realtimeSinceStartup >= task.destTime) //时间到了,则执行
  41.             {
  42.                 task.callback?.Invoke();
  43.                 if (task.count == 1) //当次数为1,执行完移除该定时事件
  44.                     timeTaskList.RemoveAt(i);
  45.                 else if (task.count > 1) //当次数大于1,执行完次数减1
  46.                     task.count--;
  47.                 task.destTime += task.delayTime; //执行完一次后,重新定出下次执行时间
  48.             }
  49.         }
  50.     }
  51. }
复制代码


把所有敌人放入一个路径中:
 


创建一个空物体做敌人生成器,放在敌人生成点,创建脚本挂上去:
 

  1. public class EnemySystem : MonoBehaviour
  2. {
  3.     //根据名称保存所有敌人
  4. Dictionary<string, Enemy> enemyDict = new Dictionary<string, Enemy>();
  5. //初始化,放在主调脚本GameMain中执行
  6.     public void Init()
  7.     {
  8.         //保存所有种类敌人,可以根据名字获取
  9.         Enemy[] enemys = Resources.LoadAll<Enemy>("Prefab/Chara/EnemyChara");
  10.         for (int i = 0; i < enemys.Length; i++)
  11.         {
  12.             if (!enemyDict.ContainsKey(enemys[i].name))
  13.                 enemyDict.Add(enemys[i].name, enemys[i]);
  14.         }
  15.     }
  16.     //生成敌人,参数中设置敌人种类,生成间隔,生成数量(默认为1)
  17.     public void CreateEnemy(string name, float delay, int count = 1)
  18.     {
  19.         if (GameMain.instance.gameOver == false)
  20.             //使用定时器,生成敌人
  21.             Util.Instance.AddTimeTask(() => Instantiate(
  22.             enemyDict[name], transform.position, transform.rotation).Init(),
  23.             delay, count);
  24. }
  25.     //点击按钮生成敌人(挂在按钮事件中)
  26.     public void ClickButtonDispatchTroops()
  27.     {
  28.         //每秒生成一个敌人,生成5次,第一次生成在1秒后执行
  29.         CreateEnemy("Zombie1", 1, 5);
  30.         //没0.5秒生成一个敌人,生成10次,第一次生成在5.5秒后执行
  31.         Util.Instance.AddTimeTask(() => CreateEnemy("Zombie2", 0.5f, 10), 5);
  32.     }
  33. }
复制代码


做到这一步就可以像演示视频中那样点击按钮出兵了。
 

  • 2
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值