【Unity】U3D TD游戏制作实例(三)相机管理器、生成敌人优化、敌人血槽小组件


相机管理器

调整相机

首先将主相机调整为正交镜头,这样可以防止模型畸变。X轴旋转角度调整为 50°。
在这里插入图片描述
创建相机控制类,并写入以下代码:

using UnityEngine;
using UnityEngine.EventSystems;

namespace TDGameDemo.Control
{
    /// <summary>
    /// 相机控制类
    /// 用于控制主相机的拖拽、放大缩小等操作
    /// </summary>
    public class CameraController : MonoBehaviour, IDragHandler
    {
        /// <summary>
        /// 拖拽速度
        /// </summary>
        public float dragSpeed;

        /// <summary>
        /// 缩放系数
        /// 拖拽时需要根据缩放系数来调整拖拽距离,避免过快或过慢
        /// </summary>
        private float scaleRatio;

        /// <summary>
        /// 地图缩小最小值
        /// </summary>
        private int scaleMin = 90;

        /// <summary>
        /// 地图放大最大值
        /// </summary>
        private int scaleMax = 25;

        public void OnDrag(PointerEventData eventData)
        {
            float h = -Input.GetAxisRaw("Mouse X") * dragSpeed * scaleRatio;
            float v = -Input.GetAxisRaw("Mouse Y") * dragSpeed * scaleRatio;

            Camera.main.transform.Translate(new Vector3(h, 0, v) * Time.deltaTime, Space.World);

            // 以下代码用于限定拖拽边缘,目前使用固定值做限制,实际上还可以根据缩放比例进一步优化
            if (Camera.main.transform.position.x < 30)
            {
                Camera.main.transform.position = new Vector3(30, Camera.main.transform.position.y, Camera.main.transform.position.z);
            }
            if (Camera.main.transform.position.x > 170)
            {
                Camera.main.transform.position = new Vector3(170, Camera.main.transform.position.y, Camera.main.transform.position.z);
            }
            if (Camera.main.transform.position.z < -50)
            {
                Camera.main.transform.position = new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, -50);
            }
            if (Camera.main.transform.position.z > 120)
            {
                Camera.main.transform.position = new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 120);
            }

        }

        void Update()
        {
            // 鼠标滚轮的效果
            // 缩小
            if (Input.GetAxis("Mouse ScrollWheel") < 0)
            {
                if (Camera.main.orthographicSize <= scaleMin)
                    Camera.main.orthographicSize += 5F;
            }
            // 放大
            if (Input.GetAxis("Mouse ScrollWheel") > 0)
            {
                if (Camera.main.orthographicSize >= scaleMax)
                    Camera.main.orthographicSize -= 5F;
            }

            // 缩放系数scaleRatio要根据正交镜头的角度变化,70°时除以45.5,50°时除以78。
            scaleRatio = Camera.main.orthographicSize / 78f;
        }
    }
}

注意:以上代码有部分为硬编码,比如左右边缘位置以及随着缩放比例变化的缩放系数 scaleRatio 的计算方式,但目前版本已经有较合理的表现,所以暂时不做修改。

下面将脚本挂到 Canvas 画布上。
在这里插入图片描述

运行游戏,使用点击鼠标按键拖拽实现拖拽地图功能,滑动滚轮实现缩放地图功能。

敌人类优化

融合导航测试代码

下面将前面讲到的用于测试导航的代码融合到本例中。将Enemy代码改为:

using TDGameDemo.GameLevel;
using UnityEngine;
using UnityEngine.AI;

namespace TDGameDemo.Enemy
{

    /// <summary>
    /// 敌人类
    /// 移动、声音、动画、寻路
    /// </summary>
    //[RequireComponent(typeof(AudioSource))]
    public class Enemy : MonoBehaviour
    {
        private NavMeshAgent agent;
        public Transform target;

        public EnemyConfig config;

        /// <summary>
        /// 动画系统
        /// </summary>
        private CharacterAnimation chAnim;

        private AudioSource _audioSource;

        /// <summary>
        /// 敌人生成时的声音
        /// </summary>
        public AudioClip _generateClip;

        /// <summary>
        /// 敌人受到攻击时的声音
        /// </summary>
        public AudioClip _underAttackClip;

        /// <summary>
        /// 敌人走到终点时的声音
        /// </summary>
        public AudioClip _finishClip;

        /// <summary>
        /// 敌人移动时的声音
        /// </summary>
        public AudioClip _moveClip;

        void Start()
        {
            // 获取组件
            agent = GetComponent<NavMeshAgent>();
            chAnim = GetComponent<CharacterAnimation>();
            initEnemy();
        }

        /// <summary>
        /// 初始化敌人
        /// </summary>
        public void initEnemy()
        {
            if (agent != null)
            {
                agent.speed = config.EnemySpeed;
            }
        }

        void Update()
        {
            // 敌人到达终点
            if (Vector3.Distance(transform.position, target.position) < 10)
            {
                ReachDestination();
                return;
            }
            if (agent != null)
            {
                bool flag = agent.SetDestination(target.position);
                chAnim.PlayAnimation("run");
            }
            else
            {
                chAnim.PlayAnimation("idle");
            }
        }
 
        /// <summary>
        /// 敌人到达终点
        /// </summary>
        void ReachDestination()
        {
            Destroy(gameObject);
        }

        private void OnDestroy()
        {
            LevelManager.EnemyAliveCount--;
        }
    }

}

将原来 NavTest 中的 Update 代码放到 Enemy 中,删除原来的导航测试类即可。

敌人移动速度

上述代码中的 initEnemy 方法用于初始化敌人,可以将配置文件中配置的敌人速度设置到 Agent 上,为了使移动更合理,我重新修改了预制件中的导航代理,如下图:

在这里插入图片描述

将代理的角速度和加速度调大,敌人刷新出来以后速度比较稳定,更适用于炮台防守游戏。移动速度上限则与配置文件相同,相应的代码在 initEnemy 方法中。

销毁对象

当敌人到达终点后销毁敌人,目前只做简单的销毁,后续再将造成伤害的代码加进去。该部分内容在 Update 方法和 ReachDestination 方法中。

加载敌人配置

Enemy代码中新增了一个配置对象 config ,相应的需要修改敌人生成器的代码。

using TDGameDemo.GameLevel;
using UnityEngine;

namespace TDGameDemo.Enemy
{

    /// <summary>
    /// 敌人创建器
    /// </summary>
    public class EnemyGenerator : MonoBehaviour
    {

        /// <summary>
        /// 生成敌人
        /// </summary>
        /// <param name="parent">父对象</param>
        /// <param name="enemy">敌人配置对象</param>
        /// <param name="target">导航目标</param>
        /// <returns></returns>
        public bool GenerateEnemy(EnemyConfig config, Transform parent, string enemy, Transform target, Transform mainCamera)
        {
            try
            {
                GameObject enemyPrefab = Resources.Load<GameObject>(enemy);
                GameObject o = Instantiate(enemyPrefab, parent, true);
                o.GetComponent<Enemy>().target = target;
                o.GetComponent<Enemy>().config = config;
                o.transform.Find("UICanvas").GetComponent<EnemyUICanvas>().MainCamera = mainCamera;
            }
            catch (System.Exception)
            {
                return false;
                throw;
            }
            return true;
        }
    }
}

敌人生成方式优化

优化前面的生成敌人方法,将 Update 的方式改为协程。让敌人按照回合的方式生成,上一回合所有敌人都销毁以后再生成本回合的敌人。 LevelManager 代码如下:

using Excel;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using TDGameDemo.Enemy;
using TDGameDemo.Game;
using UnityEngine;
using UnityEngine.UI;

namespace TDGameDemo.GameLevel
{
    /// <summary>
    /// 关卡管理器
    /// 加载关卡数据、加载场景、生成敌人
    /// </summary>
    public class LevelManager : MonoBehaviour
    {
        /// <summary>
        /// 游戏对象
        /// </summary>
        private GameMain _gameMain;
        public Transform _mainCamera;

        /// <summary>
        /// 存活敌人数量
        /// </summary>
        public static int EnemyAliveCount;

        /// <summary>
        /// 敌人生成器
        /// </summary>
        private EnemyGenerator _enemyGenerator;

        /// <summary>
        /// 生成点 ***************TODO****************
        /// </summary>
        public Transform _generatePoint;

        /// <summary>
        /// 目标点 ***************TODO****************
        /// </summary>
        public Transform _target;

        /// <summary>
        /// 关卡配置对象
        /// </summary>
        private LevelConfig _levelConfig;

        void Start()
        {
            _gameMain = GetComponent<GameMain>();
            _enemyGenerator = GetComponent<EnemyGenerator>();
        }

        /// <summary>
        /// 开始关卡
        /// </summary>
        public IEnumerator LevelStart()
        {
            for (int i = 0; i < _levelConfig.RoundCount; i++)
            {
                StartCoroutine(RoundStart(i));
                while (EnemyAliveCount > 0)
                {
                    yield return 0;
                }
                yield return new WaitForSeconds(2);
            }
        }

        /// <summary>
        /// 关卡暂停
        /// 用于游戏暂停
        /// </summary>
        public void LevelPause()
        {

        }

        /// <summary>
        /// 解除暂停
        /// </summary>
        public void LevelUnPause()
        {

        }

        /// <summary>
        /// 完成关卡
        /// </summary>
        public void LevelFinish()
        {

        }

        /// <summary>
        /// 开始刷新一轮敌人
        /// </summary>
        IEnumerator RoundStart(int roundIndex)
        {
            for (int i = 0; i < _levelConfig.EnemyConfigs[roundIndex].EnemyCount; i++)
            {
                _enemyGenerator.GenerateEnemy(
                    _levelConfig.EnemyConfigs[roundIndex],
                    _generatePoint,
                    Level.ENEMY_PREFAB_PREFIX + _levelConfig.EnemyConfigs[roundIndex].PrefabPath,
                    _target,
                    _mainCamera
                );
                EnemyAliveCount++;
                if (i != _levelConfig.EnemyConfigs[roundIndex].EnemyCount - 1)
                {
                    yield return new WaitForSeconds(_levelConfig.EnemyConfigs[roundIndex].GenInterval);
                }
            }
        }

        public void InitLevel(string configPath)
        {
            // 创建关卡配置对象
            _levelConfig = new LevelConfig();
            //FileStream f = File.
            // 解析Excel
            FileStream fs = new FileStream(Application.streamingAssetsPath + configPath, FileMode.Open, FileAccess.Read);
            // 创建Excel读取类
            //IExcelDataReader excelReader = ExcelReaderFactory.CreateOpenXmlReader(fs);
            IExcelDataReader excelReader = ExcelReaderFactory.CreateOpenXmlReader(fs);
            // 读取
            int index = 0;
            // 移动到第四行
            for (; index < 4; index++)
            {
                excelReader.Read();
            }
            _levelConfig.LevelCode = excelReader.GetString(1);
            _levelConfig.RoundCount = excelReader.GetInt32(2);

            // 跳过空白行和标题行
            excelReader.Read();
            excelReader.Read();
            _levelConfig.EnemyConfigs = new List<EnemyConfig>();
            for (; index <= 4 + _levelConfig.RoundCount; index++)
            {
                excelReader.Read();
                EnemyConfig emConfig = new EnemyConfig();
                emConfig.RoundCount = excelReader.GetInt32(1);
                emConfig.PrefabPath = excelReader.GetString(2);
                emConfig.EnemyCount = excelReader.GetInt32(3);
                emConfig.GenInterval = excelReader.GetFloat(4);
                emConfig.EnemyHP = excelReader.GetFloat(5);
                emConfig.EnemyAttack = excelReader.GetFloat(6);
                emConfig.EnemySpeed = excelReader.GetFloat(7);
                _levelConfig.EnemyConfigs.Add(emConfig);
            }
        }
    }
}

GameMain 代码如下:

using TDGameDemo.GameLevel;
using UnityEngine;

namespace TDGameDemo.Game
{

    /// <summary>
    /// 游戏主程序
    /// 加载关卡、游戏的开始、暂停、通关、失败
    /// </summary>
    public class GameMain : MonoBehaviour
    {
        private LevelManager _levelManager;

        void Start()
        {
            _levelManager = GetComponent<LevelManager>();
            _levelManager.InitLevel("/Configs/LevelConfig/Level_1001.xlsx");
        }

        public void GameStart()
        {
            StartCoroutine(_levelManager.LevelStart());
        }

        // Update is called once per frame
        void Update()
        {
            //按 B 键开始游戏
            if (Input.GetKeyDown(KeyCode.B))
            {
                GameStart();
            }
        }
    }
}

上面两个脚本将关卡的基本环节定义出来(如:关卡开始、暂停、结束等),并使用协程的方式生成了敌人。

血槽组件

在敌人的预制件下面建立一个画布,用于显示敌人的血量以及伤害数值等。
在这里插入图片描述

UICanvas 设置:
在这里插入图片描述

HPBarTool 设置:
在这里插入图片描述

HPBar 设置:
在这里插入图片描述

HPOffset 设置:
在这里插入图片描述
UIElements 是一个空节点,暂时不做处理,后续将用于展现防御塔造成的伤害等。

EnemyUICanvas 代码:

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

public class EnemyUICanvas : MonoBehaviour
{

    private Transform mainCamera;

    public Transform MainCamera { get => mainCamera; set => mainCamera = value; }

    void Start()
    {
        
    }

    void Update()
    {
    	// 使画布一直面向镜头方向
    	// 注:由于是正交镜头,所以此处所说的镜头方向并不是直接LookAt镜头物体,而是根据镜头的俯仰角度动态改变朝向
        Quaternion q = MainCamera.rotation;
        float siny_cosp = 2 * (q.w * q.x + q.z * q.y);
        float cosy_cosp = 1 - 2 * (q.y * q.y + q.x * q.x);
        float radian = Mathf.Atan2(siny_cosp, cosy_cosp); //求出弧度
        transform.LookAt(new Vector3(transform.position.x, transform.position.y + 10f, transform.position.z - (10f / Mathf.Tan(radian))));
    }
}

由于是正交镜头,所以此处画布朝向不是直接指向镜头位置的,而是要根据镜头俯仰角度做运算,找到相应的点位,然后LookAt这个点位,如下图:我们已知镜头的俯视角为 x ,再设 a 边为 10 ,计算 c 边,进而得到 p 点的位置,最后使画布朝向 p 点。
在这里插入图片描述

计算方式是使用 tan(x) = a / b 的公式通过对边 a 算出临边 b 。如下图:
在这里插入图片描述

以上方法能够使节点正对相机正交视角,但是有时候我们需要背对相机视角,此时可以将最后一行 LookAt 代码改为:

transform.LookAt(new Vector3(transform.position.x, -transform.position.y - 10f, transform.position.z + (10f / Mathf.Tan(radian))));

关于几何计算的详细内容可以参见我的另一篇文章:【Unity】Unity 几何知识、弧度、三角函数、向量运算、点乘、叉乘

这其中还涉及到一个四元数到轴角的转换计算,详细内容可以参考:【Unity】Unity 欧拉角、四元数、万向节死锁、四元数转轴角

演示视频:Unity制作炮台防守游戏

Unity制作炮台防守游戏


更多内容请查看总目录【Unity】Unity学习笔记目录整理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值