U3D - TowerDefense

此文僅記錄塔防遊戲學習過程
參考教程:SiKi學院 如何製作塔防遊戲(基於Unity5.5)

創建地圖

  • 使用Cube作爲基礎單元(大小 4 * 4),創建一個10 * 10的基準地圖

在這裏插入圖片描述

  • 創建敵人移動路徑,起點、終點
    去掉起點、終點碰撞檢測(Box Collider),以免和敵人發生碰撞關係。

在這裏插入圖片描述

調整相機角度和移動腳本

在這裏插入圖片描述
相關腳本

public class ViewController : MonoBehaviour
{

    [Header("相機移動速度")]
    [Tooltip("視野移動速度")]
    public float Speed = 20;

    [Header("相機遠近速度")]
    [Tooltip("滾輪速度")]
    public float MouseSpeed = 60;

    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {

        // WS: 控制X軸移動
        float h = Input.GetAxis("Horizontal");
        // AD: 控制Z軸移動
        float v = Input.GetAxis("Vertical");
        // 鼠標滾輪: 控制Y軸
        float mouse = -Input.GetAxis("Mouse ScrollWheel");
        // 使用世界座標系 Space.World
        transform.Translate(new Vector3(h, mouse * MouseSpeed, v) * Time.deltaTime * Speed, Space.World);

    }
}

設置敵人(固定)移動路徑和方向

在這裏插入圖片描述
相關腳本

public class WayPoints : MonoBehaviour
{

    // 爲了方便使用, 直接將該屬性公開
    public static Transform[] points;

    // 腳本對象被創建時調用
    private void Awake()
    {
        // 構建路徑: 敵人移動方向
        points = new Transform[transform.childCount];
        for (int i = 0; i < points.Length; i++)
        {
            // 由於路徑點定義的順序正好與敵人移動方向一致
            // 這裏可以直接使用下標爲路徑點賦值
            // 如果路徑點與移動方向不一致時, 需要手動將每個路徑點和移動方向一一對應
            points[i] = transform.GetChild(i);
        }
    }
}

創建敵人並指定移動路徑

使用Sphere(球體)作爲敵人,並按照WayPoints.points指定的路徑進行移動

在這裏插入圖片描述
相關代碼

public class Enemy : MonoBehaviour
{
    [Header("速度")]
    [Tooltip("敵人移動速度")]
    public float MoveSpeed = 10;

    // 規劃好的路徑
    private Transform[] points;

    // 當前前往的路徑位置
    public int index = 0;

    // Start is called before the first frame update
    void Start()
    {
        // 初始化路徑
        // 方向是數組的索引
        points = WayPoints.points;
    }

    // Update is called once per frame
    void Update()
    {
        Move();
    }

    private void Move()
    {
        // 抵達終點(暫不處理)
        if (index >= points.Length)
        {
            return;
        }

        // 得到下個座標點和當前座標點的位置偏移向量
        Vector3 toPos = points[index].position;
        Vector3 pos = (toPos - transform.position).normalized;
        // 按指定速度和幀率更新敵人位置
        transform.Translate(pos * Time.deltaTime * MoveSpeed);

        // 到達目標後進入下一個路徑點
        if (Vector3.Distance(toPos, transform.position) < 0.2f)
        {
            index++;
        }
    }
}

這裏省略多個敵人的不同配置, 可以根據個人喜好自定義.

GameManager遊戲管理器

創建空物體來掛載遊戲邏輯

敵人孵化器

  • 定義每波敵人所需的配置類 Wave, Wave是輔助類無需集成遊戲邏輯, 也不需要掛載到遊戲物體上
// 保存每波敵人生成所需的參數
[System.Serializable] // 必須指定爲可序列化纔會顯示在Inspector面板中
public class Wave
{
    // 敵人預製體
    public GameObject prefab;
    
    // 生成數量
    public int count;

    // 本次生成中, 每個敵人生成時間間隔
    public float rate;
}
  • 給GameManager掛載EnemySpawner(孵化器), 並配置相關信息:敵人預製體、一波敵人數量、一波敵人生成速率

在這裏插入圖片描述
相關代碼

// 敵人孵化器, 管理敵人生成邏輯
public class EnemySpawner : MonoBehaviour
{

    // 地圖中敵人的數量
    public static int AliveEnemyCount = 0;

    [Header("敵人配置")]
    public Wave[] waves;

    [Header("起始位置")]
    [Tooltip("敵人出生位置")]
    public Transform StartPos;

    [Header("每波生成速率")]
    [Tooltip("每波敵人之間默認生成速率")]
    public float waveRate;

    void Start()
    {
    	// 開始協程調用
        StartCoroutine(SpawnEnemy());
    }

    // 生成敵人
    IEnumerator SpawnEnemy()
    {
        // 讀取每一波敵人生成的配置
        foreach (Wave wave in waves)
        {
            // 按照數量生成一波敵人
            for (int i = 0; i < wave.count; i++)
            {
                // 使用預製體(wave.prefab)在指定位置(StartPos.position)生成無旋轉(Quaternion.identity)遊戲物體
                GameObject.Instantiate(wave.prefab, StartPos.position, Quaternion.identity);
                // 地圖中敵人計數
                AliveEnemyCount++;
                // 一波內敵人生成間隔
                if (i < wave.count - 1)
                    yield return new WaitForSeconds(wave.rate);
            }

			// 如果不啓用這個條件, 敵人會一次性全部生成完成
			// AliveEnemyCount 的修改由 Enemy 銷燬時減少
            // 等待上一波敵人全部消失後再生成下一波敵人
            while (AliveEnemyCount > 0)
            {
                // 暫停0幀後重新判斷入口條件
                yield return 0;
            }

            // 每波敵人之間間隔時長
            yield return new WaitForSeconds(waveRate);
        }
    }

}

修改的Enemy相關代碼


public class Enemy : MonoBehaviour
{

    private void Move()
    {
		...
        if (index >= points.Length)
        {
            ReachDestination();
        }
    }

    // 抵達終點
    void ReachDestination()
    {
        // 銷燬敵人遊戲物體
        GameObject.Destroy(this.gameObject);
    }

	// 銷燬事件
    void OnDestroy()
    {
        // (負)增量敵人存活數量
        EnemySpawner.AliveEnemyCount--;
    }
}

創建炮塔預製體

獲取炮塔資源
製作步驟詳見視頻, 這裏不再說明. 後面爲基礎炮臺, 前面爲升級後炮臺
在這裏插入圖片描述

炮臺選擇UI

  1. 創建Canvas
  2. Canvas中添加一個Toggle組件,一個組件代表一種可以創建的炮臺,在每個組件的Background中選擇對應貼圖,並在Background -> Image中使用貼圖原大小,點擊Set Native Size並調整到合適大小。
  3. 使用同樣的方式創建所有炮臺選項
  4. 創建空物體並添加組件Toggle Group,並把所有炮臺選項拖動到該組件下並把Toggle -> Group指定爲當前空物體。這樣就能實現單選組互斥選擇。
    在這裏插入圖片描述

定義炮臺數據

炮臺基礎數據,目前只能升級一次

[System.Serializable]
public class TurretData
{
    [Header("原始炮臺")]
    [Tooltip("原始炮臺預製體")]
    public GameObject turretPrefab;

    [Header("原始價格")]
    public int cost;

    [Header("升級炮臺")]
    [Tooltip("升級炮臺預製體")]
    public GameObject upgradPrefab;

    [Header("升級價格")]
    public int constUpgrad;

    [Header("類型")]
    public TurretType turretType;
}

炮臺類型

[System.Serializable]
public enum TurretType
{
    LASER_TURRET,
    MISSILE_TURRET,
    STANDARD_TURRET
}

新增(炮臺)構造管理器

GameManager空物體上新增腳本BuildManagerBuildManager用來保存每種炮塔的構建數據,以及記錄(UI中)當前選中的炮塔

public class BuildManager : MonoBehaviour
{
    [Header("激光炮塔")]
    public TurretData laserTurretData;

    [Header("導彈炮塔")]
    public TurretData missileTurretData;

    [Header("普通炮塔")]
    public TurretData standardTurretData;

    // 當前選擇的(UI)炮臺
    public TurretData selectedTurretData;

    public void OnLaserSelected(bool isOn)
    {
        ToggleSelectedTurretData(isOn, laserTurretData);
    }

    public void OnMissileSelected(bool isOn)
    {
        ToggleSelectedTurretData(isOn, missileTurretData);
    }

    public void OnStandardSelected(bool isOn)
    {
        ToggleSelectedTurretData(isOn, standardTurretData);
    }

    /// <summary>
    /// 切換選擇中的UI炮塔圖標
    /// </summary>
    /// <param name="isOn">是否選中</param>
    /// <param name="turretData">選中後指定的炮塔</param>
    private void ToggleSelectedTurretData(bool isOn, TurretData turretData)
    {
        if (selectedTurretData != turretData)
        {
            if (isOn)
                selectedTurretData = turretData;
        }
        else
        {
            selectedTurretData = null;
        }
    }
}

以上定義的幾個以OnXxx()事件需要綁定到UI中對應的炮塔選擇器上
在這裏插入圖片描述

在構造管理器中獲取鼠標所在的方塊

【注意】:原視頻中MapCube層標記使用的是 **MapCube**而作者用的Load

public class BuildManager : MonoBehaviour
{
	...
    void Update()
    {
        // 按下鼠標左鍵
        bool pressLeftButton = Input.GetMouseButtonDown(0);
        // 當前鼠標點擊在遊戲物體上
        // 遊戲界面中有可能存在UI擋住遊戲物體
        bool mouseInGameObject = !EventSystem.current.IsPointerOverGameObject();

        if (pressLeftButton && mouseInGameObject)
        {
            // 從主攝像機中獲取鼠標射線
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            // 射線和指定層的碰撞結果
            RaycastHit hit;
            // 射線與指定層碰撞檢測
            bool isCollider = Physics.Raycast(ray, out hit, 1000, LayerMask.GetMask("Load"));
            if (isCollider)
            {
                // 獲取鼠標所在的MapCube(Load)
                GameObject gameObject = hit.collider.gameObject;
            }
        }
    }
    ...
}

建造經濟顯示

不論是建造、升級、出售都需要對當前玩家的經濟(遊戲幣/資源)做出調增。在UI界面新增遊戲幣顯示
在這裏插入圖片描述
由於這個遊戲比較簡單,就不單獨做遊戲經濟管理。在BuildManager中直接更新;
在這裏插入圖片描述
經濟的修改交給單獨函數去做,方便後續統一升級管理。

public class BuildManager : MonoBehaviour
{
	...
    [Header("遊戲幣UI組件")]
    public Text moneyText;

    [Header("遊戲幣")]
    public int money = 1000;
	...

    void Update()
    {
		...
		//扣除建造費用
		ChangeMoney(-selectedTurretData.cost);
		load.BuildTurret(selectedTurretData.turretPrefab);
		...
	}
	
    /// <summary>
    /// 改變Money的值
    /// </summary>
    /// <param name="incr">增量</param>
    private void ChangeMoney(int incr = 0)
    {
        money += incr;
        moneyText.text = "$ " + money;
    }
}

經濟不足提示

經濟不足通過UI閃爍提示,閃爍一輪即可需要把Loop Time取消。

由於作者(新手😓)使用Editor 2019.3.2f1與老師使用的不是同一個版本,有些操作不太一致。
就拿這個閃爍動畫來說,添加幀後無論怎樣修改,總是會還原回去。後來才知道需要先啓用Keyframe Recoding Mode,也就是Animation窗口左上角那個圓點,編輯完成後再禁用。

在這裏插入圖片描述
動畫狀態機,通過(Trigger)參數Flicker觸發動畫,動畫播放完畢後回到Empty
在這裏插入圖片描述
指定動畫組件

public class BuildManager : MonoBehaviour
{
	...
    [Header("遊戲幣不足提示")]
    public Animator lessMoneyAnimator;

    void Update()
    {
    	...
		if (isCollider)
		{
			...
			//【注意】老師的視頻中沒有此判斷,實際運行中可能出現 NullPointerException
            // 沒有選中UI中的炮塔, 不可以建造
            if (null == selectedTurretData)
                return;
                
			if (money >= selectedTurretData.cost) 
			{
				...
			}
			else 
			{
				// Flicker 與動畫狀態機中指定的參數名一致
				lessMoneyAnimator.SetTrigger("Flicker");
			}
		}
    }
}

在這裏插入圖片描述

優化建造交互

建造炮塔

基礎構建

public class Load : MonoBehaviour
{

    /// <summary>
    /// 搭載的炮臺實例
    /// </summary>
    [HideInInspector]
    [Header("炮臺")]
    public GameObject turretGo;

    /// <summary>
    /// 創建炮臺
    /// </summary>
    /// <param name="turretPrefab">炮臺預製體</param>
    public void BuildTurret(GameObject turretPrefab)
    {
        // 炮塔創建位置與當前位置保持一致且不旋轉
        turretGo = GameObject.Instantiate(turretPrefab, transform.position, Quaternion.identity);
    }

}

構建特效

特效通過粒子系統實現
細節參考視頻: 課時 19 : 18-利用粒子系統創建建造的特效在這裏插入圖片描述

public class Load : MonoBehaviour
{

    [HideInInspector]
    [Header("炮臺")]
    public GameObject turretGo;

    [Header("建造特效")]
    public GameObject buildEffectPrefab;

    /// <summary>
    /// 創建炮臺
    /// </summary>
    /// <param name="turretPrefab">炮臺預製體</param>
    public void BuildTurret(GameObject turretPrefab)
    {
        // 炮塔創建位置與當前位置保持一致且不旋轉
        turretGo = GameObject.Instantiate(turretPrefab, transform.position, Quaternion.identity);

        // 特效播放完畢後刪除遊戲物體
        GameObject effect = GameObject.Instantiate(buildEffectPrefab, transform.position, Quaternion.identity);
        GameObject.Destroy(effect, 1);
    }

}

炮塔檢測敵人

  1. 爲所有炮塔添加Layer(Turret),所有的敵人添加Layer(Enemmy)
  2. 爲所有炮塔添加剛體碰撞器,並啓用觸發器。在進入觸發器時按順序添加敵人,離開觸發器時刪除敵人
  3. 指定碰撞檢測層 Turret <-> Enemy
    在這裏插入圖片描述
public class Turret : MonoBehaviour
{

    [Header("敵人列表")]
    public List<GameObject> enemies = new List<GameObject>();

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Enemy"))
            enemies.Add(other.gameObject);
    }


    private void OnTriggerExit(Collider other)
    {
        if (other.CompareTag("Enemy"))
            enemies.Remove(other.gameObject);
    }
}

標準炮臺開火

攻擊準備

爲炮塔添加三個屬性:

  • 攻擊速率:attackRateTime,單位秒/次;N秒攻擊一次
  • 子彈初始位置firePosition,由於炮塔頭部需要瞄準敵人,所以這個位置應該在指定在炮塔頭部內部
  • 子彈預製體:爲標準炮塔創建球形子彈預製體
public class Turret : MonoBehaviour
{

    [Header("敵人列表")]
    public List<GameObject> enemies = new List<GameObject>();

    [Header("攻擊速率(秒/次)")]
    public float attackRateTime = 1;

    [Header("子彈初始位置")]
    public Transform firePosition;

    [Header("子彈")]
    [Tooltip("子彈預製體")]
    public GameObject bulletPrefab;

    // 攻擊計時器
    private float timer = 0;
    
    void Start()
    {
        // 把timer設置爲attackRateTime可以在敵人進入視野內第一時間發起攻擊
        timer = attackRateTime;
    }

    void Update()
    {
        // 累計等待攻擊時間
        timer += Time.deltaTime;

        // 範圍內發現敵人, 且子彈準備就緒
        if (timer >= attackRateTime && 0 < enemies.Count)
        {
            // 計算下次攻擊時間
            timer -= attackRateTime;
            Attack();
        }
    }

    /// <summary>
    /// 發起攻擊
    /// </summary>
    private void Attack()
    {
        // 生成子彈
        // 子彈朝向和旋轉與開火位置一致
        GameObject.Instantiate(bulletPrefab, firePosition.position, firePosition.rotation);
    }

    /// <summary>
    /// 進入攻擊範圍
    /// </summary>
    /// <param name="other"></param>
    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Enemy"))
            enemies.Add(other.gameObject);
    }

    /// <summary>
    /// 離開攻擊範圍
    /// </summary>
    /// <param name="other"></param>
    void OnTriggerExit(Collider other)
    {
        if (other.CompareTag("Enemy"))
            enemies.Remove(other.gameObject);
    }
}

讓子彈飛

爲子彈預製體添加子彈腳本Bullet

/// <summary>
/// 標準炮塔子彈
/// </summary>
public class Bullet : MonoBehaviour
{
    [Header("傷害")]
    public int damage = 50;

    [Header("速度")]
    [Tooltip("子彈出膛速度")]
    public float speed = 120;

    [Header("目標位置")]
    private Transform target;

    /// <summary>
    /// 設置攻擊對象
    /// </summary>
    /// <param name="target">目標</param>
    public void SetTarget(Transform target)
    {
        this.target = target;
    }

    void Update()
    {
        // 面向敵人
        transform.LookAt(target);

        // 飛向敵人
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
    }
}

創建子彈後需要給子彈設置攻擊目標

public class Turret : MonoBehaviour
{
	...
    /// <summary>
    /// 發起攻擊
    /// </summary>
    private void Attack()
    {
        // 生成子彈
        // 子彈朝向和旋轉與開火位置一致
        GameObject go = GameObject.Instantiate(bulletPrefab, firePosition.position, firePosition.rotation);
        Bullet bullet = go.GetComponent<Bullet>();
        bullet.SetTarget(enemies[0].transform);
    }
	...
}

【注意】:由於作者地形較小,剛好測試時建造的炮塔覆蓋了終點,從而發現一個小問題;後面會修正

MissingReferenceException: The object of type 'GameObject' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.
Turret.Attack () (at Assets/Scripts/Turret.cs:54)
Turret.Update () (at Assets/Scripts/Turret.cs:41)

在這裏插入圖片描述

【修復Bug】:長時間未攻擊敵人時,一次會發射多發子彈:

用求模運算(timer %= attackRateTime)代替減法運算 (timer -= attackRateTime)

public class Turret : MonoBehaviour
{
	...
    void Update()
    {
        // 累計等待攻擊時間
        timer += Time.deltaTime;

        // 範圍內發現敵人, 且子彈準備就緒
        if (timer >= attackRateTime && 0 < enemies.Count)
        {
            // 計算下次攻擊時間
            // 這裏使用取模運算, 防止timer長時間不攻擊時, 一次發射多枚炮彈
            timer %= attackRateTime;
            Attack();
        }
    }
}

子彈命中敵人

  • 命中敵人後播放子彈爆炸特效
    給子彈添加剛體並將碰撞器設置爲碰撞器
public class Bullet : MonoBehaviour
{
	...
    [Header("爆炸特效")]
    [Tooltip("命中敵人或敵人消失時爆炸特效")]
    public GameObject explosionEffectPrefab;

    void OnTriggerEnter(Collider other)
    {
        if ("Enemy" == other.tag)
        {
            // 敵人掉血
            other.GetComponent<Enemy>().TakeDamage(damage);

            // 自身銷燬
            GameObject.Destroy(this.gameObject);

            // 播放爆炸特效
            GameObject effect = GameObject.Instantiate(explosionEffectPrefab, transform.position, transform.rotation);
            GameObject.Destroy(effect, 1);
        }
    }
}

直接使用觸發器無法檢測到與敵人的碰撞(速度太快導致檢測不到碰撞?),修改爲子彈與敵人的距離檢測

public class Bullet : MonoBehaviour
{
	...
    [Header("命中距離")]
    [Tooltip("子彈與敵人小於該距離時檢測爲擊中目標")]
    public float distanceArriveTarget = 1;

    void Update()
    {
		...
        CheckHitEnemy();
    }

    /// <summary>
    /// 擊中敵人檢測
    /// </summary>
    private void CheckHitEnemy()
    {
        Vector3 dir = target.position - transform.position;
        if (dir.magnitude < distanceArriveTarget)
        {
            // 敵人掉血
            target.GetComponent<Enemy>().TakeDamage(damage);
            Die();
        }
    }
    
    /// <summary>
    /// 子彈摧毀子彈
    /// </summary>
    private void Die()
    {
        // 自身銷燬
        GameObject.Destroy(this.gameObject);

        // 播放爆炸特效
        GameObject effect = GameObject.Instantiate(explosionEffectPrefab, transform.position, transform.rotation);
        GameObject.Destroy(effect, 1);
    }
}

在這裏插入圖片描述

【修復Bug】:子彈攻擊的敵人已經銷燬,程序報NullPointerException

public class Bullet : MonoBehaviour
{
	...
	void Update()
    {
        // 當前被攻擊的敵人已經銷燬
        // 或被其他子彈打死
        // 或抵達最終點自行銷燬
        if (null == target)
        {
            Die();
            return;
        }
        ...
    }
}

【修復Bug】:炮塔攻擊的敵人已經銷燬,程序報NullPointerException

public class Turret : MonoBehaviour
{
	...
    /// <summary>
    /// 發起攻擊
    /// </summary>
    private void Attack()
    {
        // 攻擊的敵人已經消失
        // 且沒有可攻擊對現象時等待下一輪攻擊
        if (!CheckEnemies()) 
            return;
        ...
	}

    /// <summary>
    /// 檢查是否還有可攻擊目標
    /// </summary>
    /// <returns>true-已找到攻擊目標, false-未找到攻擊目標</returns>
    private bool CheckEnemies()
    {
        // 已經沒有可攻擊敵人
        if (0 == enemies.Count)
            return false;

        // 目標敵人可以被攻擊
        if (null != enemies[0])
            return true;

        // 目標敵人不可被攻擊
        // 清空無效敵人
        for (int i = enemies.Count - 1; i >= 0; i--)
            if (null == enemies[i])
                enemies.RemoveAt(i);

        // 是否還存在有效敵人
        return 0 < enemies.Count && null != enemies[0];
    }
}
  • 敵人死亡特效
    給敵人添加生命值(HealthPoint),和死亡特效
public class Enemy : MonoBehaviour
{
	...
    [Header("生命值")]
    [Tooltip("生命值")]
    public int healthPoint = 150;

    [Header("死亡特效")]
    [Tooltip("死亡瞬間特效")]
    public GameObject explosionEffect;
	...
    /// <summary>
    /// 受到傷害
    /// </summary>
    /// <param name="damage">傷害值</param>
    public void TakeDamage(int damage)
    {
        // 最後一擊有可能被多枚子彈命中
        // 死亡特效播放一次即可
        if (0 >= healthPoint)
            return;

        // 扣除傷害
        healthPoint -= damage;

        // 死亡檢測
        if (0 >= healthPoint)
        {
            Die();
        }
    }

    /// <summary>
    /// 敵人死亡處理
    /// </summary>
    private void Die()
    {
        // 死亡特效
        GameObject effect = GameObject.Instantiate(explosionEffect, transform.position, transform.rotation);
        GameObject.Destroy(effect, 1.5f);

        // 銷燬自身
        GameObject.Destroy(this.gameObject);
    }
}

在這裏插入圖片描述

炮塔指向敵人

標準炮塔頭部中心點位置通過添加空物體調整
在這裏插入圖片描述

public class Turret : MonoBehaviour
{
	...
    [Header("炮頭")]
    [Tooltip("可旋轉炮頭")]
    public Transform head;
	
	...
	void Update()
    {
        // 炮頭指向敵人
        if (0 < enemies.Count && null != enemies[0])
        {
            Vector3 targetPos = enemies[0].transform.position;
            // 把位置中高度與炮塔保持一致, 避免炮塔擡頭或低頭
            targetPos.y = transform.position.y;
            head.LookAt(targetPos);
        }
    	...
    }
}

2020年05月07日, 由於工作需要暫停學習

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章