用Unity開發一款塔防遊戲(一):攻擊方設計

遊資網發表於2019-06-11
用Unity開發一款塔防遊戲(一):攻擊方設計

大家好。偶爾想起了這個手把手教學的、但現已長滿雜草的坑,還是來挖幾鏟子。

用Unity開發一款塔防遊戲(一):攻擊方設計

這一期的遊戲是最常見的型別之一——塔防。

塔防遊戲相信大家並不陌生,幾個主要元素如下:

1、敵方士兵

2、我方防禦塔

3、我方主城

emmmmmmm好像就沒了。

玩法就是建立防禦塔阻擊前往我方主城的敵兵,可以通過視訊直觀感受下:

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

人越狠,話越不多。不多說,接下來我們一步步把這幾個功能做完。

素材準備:

網上隨便找一些資源就行,不一定要和我一樣。這裡再次強調:

網上獲取的資源一定不能用作商業用途!!!!!!

就本工程而言,資源有一下幾種:

敵人2個,分別擁有移動,攻擊,待機,死亡四種動畫

用Unity開發一款塔防遊戲(一):攻擊方設計

防禦塔3個,擁有待機,攻擊兩種動畫

用Unity開發一款塔防遊戲(一):攻擊方設計
人形防禦塔可還行

主城1個,主地形1組(內含各種雜草亂石)

用Unity開發一款塔防遊戲(一):攻擊方設計

敵人地形(敵人能用來走的路)1種,防禦塔地形(防禦塔能放置的地方)1種

用Unity開發一款塔防遊戲(一):攻擊方設計

箭矢1個

用Unity開發一款塔防遊戲(一):攻擊方設計
弓兵模型中自帶

場景搭建:

先從簡單的功能做起:讓敵人從生成點走到主城,看見主城就攻擊。

搭建一個簡單場景:

用Unity開發一款塔防遊戲(一):攻擊方設計
為了檢測敵人尋路,最好是能轉彎的道路

敵人和主城有一個都有血量的屬性,都會被攻擊,這裡為它們做能顯示在頭上的血條。

以主城為例,在主城的子節點層建立一個Sprite做黃血條,設為黃色,取名“BloodStrip”,調整好大小:

用Unity開發一款塔防遊戲(一):攻擊方設計

然後在BloodStrip的子節點層建立一個空物體,取名“Hp”,在Hp的子節點層再建立一個Sprite做紅血條,名字“Red”,設為紅色,大小和黃血條一樣,把黃血色覆蓋:

用Unity開發一款塔防遊戲(一):攻擊方設計

接下來就移動紅血條位置,讓它左邊邊緣與父物體Hp的Y軸重合:

用Unity開發一款塔防遊戲(一):攻擊方設計

然後再將Hp往右移動,讓Y軸與黃血條左邊緣重合(紅血條剛好覆蓋黃血條):

用Unity開發一款塔防遊戲(一):攻擊方設計

這樣我們只需要設定H的X軸大小,就可以控制紅血條長度了:

用Unity開發一款塔防遊戲(一):攻擊方設計

***這裡請初學者注意,如果你選取的紅血條圖片資源不是純色的、是有其他花紋的,則不能用這個方法。原因很簡單,這種方法會把花紋拉長或壓扁。大家可以下來想一下:這種情況下應該怎樣來設定?

後面在程式碼中只需要將當前血量與總血量的比值賦給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,而是用指令碼來實現,主要思路仿照盲人的行進方式,利用射線充當導盲棍,發現前方道路中斷再從兩邊找新的行進路線:

用Unity開發一款塔防遊戲(一):攻擊方設計
柺杖就是射線

要利用好這個思路,場景中道路的搭建也有一定要求,道路都要掛上MeshCollider元件,方便射線檢測。

用Unity開發一款塔防遊戲(一):攻擊方設計
所有道路的Z軸指向路線前進方向

道路的物體層設定為“Way”,主城也掛上碰撞器,物體層設為“City”。

用Unity開發一款塔防遊戲(一):攻擊方設計

在敵人模型身上建立一個空物體為眼睛,取名為“Eye”,主要作用是從此為射線起始點,位置合適即可,注意,因為所有敵人都用的相同指令碼,所以所有敵人的眼睛高度距離地面相同:

用Unity開發一款塔防遊戲(一):攻擊方設計
正面看這些模型真特麼驚悚

當然每個敵人也請掛上碰撞器和剛體以及Animator元件:

用Unity開發一款塔防遊戲(一):攻擊方設計

建立一個敵人狀態機:

  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中呼叫(後面我們會在建立的時候初始化),然後設定好血量、視野、速度、傷害,主城也設定好血量:

用Unity開發一款塔防遊戲(一):攻擊方設計

先來看下尋路執行效果:

用Unity開發一款塔防遊戲(一):攻擊方設計

藍線檢測前方道路,紅圈檢測周圍道路

尋路沒有問題了,將攻擊動畫設為迴圈播放,然後將攻擊方法放入攻擊動畫事件中,敵人看到主城就會自動攻擊了:

用Unity開發一款塔防遊戲(一):攻擊方設計
敵人主要功能就已經完成。現在我們來做敵人生成器。

塔防遊戲的敵人生成方式一般都是比較有規律的,比如先生成一組a敵人,跟著生成一組b敵人,每組敵人的生成間隔也恆定(當然,讀者也可以自己嘗試更豐富的出兵方法,比如讓“某些特定敵人的血量減到某個閾值”作為觸發條件等等):

用Unity開發一款塔防遊戲(一):攻擊方設計

為了生成方便,我們來做一個定時器,可以重複並規律地呼叫一個生成敵人方法:

  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. }
複製程式碼

把所有敵人放入一個路徑中:

用Unity開發一款塔防遊戲(一):攻擊方設計

建立一個空物體做敵人生成器,放在敵人生成點,建立指令碼掛上去:

  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. }
複製程式碼

做到這一步就可以像演示視訊中那樣點選按鈕出兵了。

放上工程連結:

https://pan.baidu.com/s/1T2nZ_FrIk9DaTvem-YH8nQ提取碼:n61s

下一篇文章我們將做UI介面點選頭像在場景中生成防禦塔,以及不同的防禦塔與敵人的互動。

有意向參與線下游戲開發學習的童鞋,歡迎訪問http://levelpp.com/

作者:四五二十
專欄地址:https://zhuanlan.zhihu.com/p/65206955

相關文章