大家好。偶尔想起了这个手把手教学的、但现已长满杂草的坑,还是来挖几铲子。
这一期的游戏是最常见的类型之一——塔防。
塔防游戏相信大家并不陌生,几个主要元素如下:
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:
- public class Character : MonoBehaviour
- {
- public float totalHp = 100; //总血量
- float surHp; //剩余血量
- protected Transform hpObj; //黄血条
- protected Transform redHp; //血条红条
- protected Transform mainCamera; //主摄像机
- public virtual void Init() //初始化
- {
- surHp = totalHp;
- hpObj = transform.Find("BloodStrip");
- redHp = hpObj.Find("Hp");
- mainCamera = GameObject.Find("Main Camera").transform;
- }
- public void Damage(float damage) //受伤方法,参数为受到的伤害值
- {
- if (surHp > damage) //当前血量大于受伤血量,正常扣血
- {
- surHp -= damage;
- //受伤后开始显示血条
- if (surHp < totalHp)
- hpObj.gameObject.SetActive(true);
- Vector3 hpScale = redHp.localScale;
- hpScale.x = surHp / totalHp;
- redHp.localScale = hpScale;
- }
- else //当前血量不够,调用死亡方法
- Death();
- }
- public virtual void Death() //死亡方法
- {
- surHp = 0;
- hpObj.gameObject.SetActive(false); //血条不再显示
- }
- }
复制代码
创建主调脚本:用于游戏初始化和记录游戏死亡,挂在一个场景物体上:
- public class GameMain : MonoBehaviour
- {
- public static GameMain instance;
- public bool gameOver;
- void Start()
- {
- InitGame();
- }
- //初始化游戏
- void InitGame()
- {
- instance = this; //单例
- gameOver = false;
- }
- }
复制代码
创建主城脚本,继承自Character脚本:
- public class MainCity : Character
- {
- void Start()
- {
- Init();
- }
- private void Update()
- {
- hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
- }
- public override void Death() //重新死亡方法
- {
- base.Death();
- GameMain.instance.gameOver = true; //游戏结束
- }
- }
复制代码
敌人的脚本也继承自Charater,除了受伤和死亡之外还能攻击与移动:
- public class Enemy : Character
- {
- Animator anim;
- public float damage; //伤害
- public float speed; //移动速度
- MainCity target; //主城
- public override void Init()
- {
- base.Init();
- anim = GetComponent<Animator>();
- }
- private void Update()
- {
- hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
- }
- //前进方法
- private void EnemyForward()
- {
- }
- //攻击方法(放在攻击动画事件中)
- private void EnemyAttack()
- {
- if (target != null)
- target.Damage(damage);
- }
- //死亡方法
- public override void Death()
- {
- base.Death();
- anim.Play("death");
- }
- //尸体消失
- private void DestroySelf()
- {
- Destroy(gameObject);
- }
- }
复制代码
重点在移动方法上。因为敌人的移动带有寻路功能,这里没有采取Unity自带的NavMeshAgent,而是用脚本来实现,主要思路仿照盲人的行进方式,利用射线充当导盲棍,发现前方道路中断再从两边找新的行进路线:
拐杖就是射线
要利用好这个思路,场景中道路的搭建也有一定要求,道路都要挂上MeshCollider组件,方便射线检测。
所有道路的Z轴指向路线前进方向
道路的物体层设置为“Way”,主城也挂上碰撞器,物体层设为“City”。
在敌人模型身上创建一个空物体为眼睛,取名为“Eye”,主要作用是从此为射线起始点,位置合适即可,注意,因为所有敌人都用的相同脚本,所以所有敌人的眼睛高度距离地面相同:
正面看这些模型真特么惊悚
当然每个敌人也请挂上碰撞器和刚体以及Animator组件:
创建一个敌人状态机:
- public enum EnemyState //状态机
- {
- forward,
- attack,
- death
- }
复制代码
重写初始化方法:
- Animator anim;
- Rigidbody rigid;
- public EnemyState state;
- Transform eye; //眼睛:用于观测道路和攻击目标
- List<Collider> ways; //记录走过的路(不走回头路)
- //重新初始化方法
- public override void Init()
- {
- base.Init();
-
- anim = GetComponent<Animator>();
- rigid = GetComponent<Rigidbody>();
- gameObject.layer = LayerMask.NameToLayer("Enemy"); //敌人层设置为"Enemy"
- state = EnemyState.forward;
- eye = transform.Find("Eye");
- ways = new List<Collider>();
- }
复制代码
编写移动方法,并在Update中调用:
- private void Update()
- {
- hpObj.rotation = mainCamera.rotation; //血条始终面向镜头
- if (GameMain.instance.gameOver) //游戏结束播放待机动画
- anim.Play("idle");
- else if (state == EnemyState.forward)
- EnemyForward();
- }
- public int view; //视野
- Quaternion wayDir; //前进方向
- MainCity target; //主城
- Transform way; //正在走的路
- public float speed;
- //前进方法
- private void EnemyForward()
- {
- RaycastHit hit;
- //看见攻击目标则攻击
- if (Physics.Raycast(eye.position, transform.forward, out hit, view, LayerMask.GetMask("City")))
- {
- state = EnemyState.attack;
- anim.Play("attack");
- target = hit.collider.GetComponent<MainCity>();
- }
- //斜下方30°打射线检测前方道路
- if (Physics.Raycast(eye.position, Quaternion.AngleAxis(30, transform.right)
- * transform.forward, out hit, 50, LayerMask.GetMask("Way")))
- {
- Debug.DrawLine(eye.position, hit.point, Color.blue);
- //发现未走过的道路,获取该道路,朝向该路通往的方向
- if (!ways.Contains(hit.collider))
- {
- ways.Add(hit.collider);
- way = hit.transform;
- wayDir = Quaternion.LookRotation(way.forward);
- }
- }
- else //前方没路了发射球形射线检测周围是否有路
- {
- Collider[] colliders = Physics.OverlapSphere(transform.position, 8, LayerMask.GetMask("Way"));
- for (int i = 0; i < colliders.Length; i++)
- {
- //发现未走过的道路,获取该道路,朝向该路通往的方向
- if (!ways.Contains(colliders[i]))
- {
- way = colliders[i].transform;
- wayDir = Quaternion.LookRotation(way.forward);
- break;
- }
- }
- }
- //获取与脚下道路x轴上偏差值,好让自身走在路中间
- float offset = 0;
- if (way != null)
- {
- Vector3 distance = transform.position - way.position;
- offset = Vector3.Dot(distance, way.right.normalized);
- }
- //面向该路指向的方向前进
- transform.rotation = Quaternion.RotateTowards(transform.rotation, wayDir, speed * 20 * Time.deltaTime);
- transform.Translate(-offset * Time.deltaTime, 0, speed * Time.deltaTime);
- }
复制代码
暂时把初始化方法放在Start中调用(后面我们会在创建的时候初始化),然后设置好血量、视野、速度、伤害,主城也设置好血量:
先来看下寻路运行效果:
蓝线检测前方道路,红圈检测周围道路
寻路没有问题了,将攻击动画设为循环播放,然后将攻击方法放入攻击动画事件中,敌人看到主城就会自动攻击了:
敌人主要功能就已经完成。现在我们来做敌人生成器。
塔防游戏的敌人生成方式一般都是比较有规律的,比如先生成一组a敌人,跟着生成一组b敌人,每组敌人的生成间隔也恒定(当然,读者也可以自己尝试更丰富的出兵方法,比如让“某些特定敌人的血量减到某个阈值”作为触发条件等等):
为了生成方便,我们来做一个定时器,可以重复并规律地调用一个生成敌人方法:
- public class Util : MonoBehaviour
- {
- private static Util _Instance = null;
- public static Util Instance //单例模式,依附GameObject
- {
- get
- {
- if (_Instance == null)
- {
- GameObject obj = new GameObject("Util");
- _Instance = obj.AddComponent<Util>();
- }
- return _Instance;
- }
- }
- public class TimeTask //定时事件类
- {
- public Action callback; //回调函数
- public float delayTime; //延迟长度
- public float destTime; //延迟后的目标时间
- public int count; //重复次数
- }
- List<TimeTask> timeTaskList = new List<TimeTask>(); //保存所有的定时事件
- //增加定时回调的方法
- public void AddTimeTask(Action _callback, float _delayTime, int _count = 1)
- {
- timeTaskList.Add(new TimeTask()
- {
- callback = _callback,
- delayTime = _delayTime,
- destTime = Time.realtimeSinceStartup + _delayTime,
- count = _count
- });
- }
- private void Update()
- {
- for (int i = 0; i < timeTaskList.Count; i++) //实时监测所有定时事件
- {
- TimeTask task = timeTaskList[i];
- if (Time.realtimeSinceStartup >= task.destTime) //时间到了,则执行
- {
- task.callback?.Invoke();
- if (task.count == 1) //当次数为1,执行完移除该定时事件
- timeTaskList.RemoveAt(i);
- else if (task.count > 1) //当次数大于1,执行完次数减1
- task.count--;
- task.destTime += task.delayTime; //执行完一次后,重新定出下次执行时间
- }
- }
- }
- }
复制代码
把所有敌人放入一个路径中:
创建一个空物体做敌人生成器,放在敌人生成点,创建脚本挂上去:
- public class EnemySystem : MonoBehaviour
- {
- //根据名称保存所有敌人
- Dictionary<string, Enemy> enemyDict = new Dictionary<string, Enemy>();
- //初始化,放在主调脚本GameMain中执行
- public void Init()
- {
- //保存所有种类敌人,可以根据名字获取
- Enemy[] enemys = Resources.LoadAll<Enemy>("Prefab/Chara/EnemyChara");
- for (int i = 0; i < enemys.Length; i++)
- {
- if (!enemyDict.ContainsKey(enemys[i].name))
- enemyDict.Add(enemys[i].name, enemys[i]);
- }
- }
- //生成敌人,参数中设置敌人种类,生成间隔,生成数量(默认为1)
- public void CreateEnemy(string name, float delay, int count = 1)
- {
- if (GameMain.instance.gameOver == false)
- //使用定时器,生成敌人
- Util.Instance.AddTimeTask(() => Instantiate(
- enemyDict[name], transform.position, transform.rotation).Init(),
- delay, count);
- }
- //点击按钮生成敌人(挂在按钮事件中)
- public void ClickButtonDispatchTroops()
- {
- //每秒生成一个敌人,生成5次,第一次生成在1秒后执行
- CreateEnemy("Zombie1", 1, 5);
- //没0.5秒生成一个敌人,生成10次,第一次生成在5.5秒后执行
- Util.Instance.AddTimeTask(() => CreateEnemy("Zombie2", 0.5f, 10), 5);
- }
- }
复制代码
做到这一步就可以像演示视频中那样点击按钮出兵了。
放上工程链接:
https://pan.baidu.com/s/1T2nZ_FrIk9DaTvem-YH8nQ提取码:n61s
下一篇文章我们将做UI界面点击头像在场景中生成防御塔,以及不同的防御塔与敌人的交互。
有意向参与线下游戏开发学习的童鞋,欢迎访问http://levelpp.com/
作者:四五二十
专栏地址:https://zhuanlan.zhihu.com/p/65206955