赞
踩
首先将主相机调整为正交镜头,这样可以防止模型畸变。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学习笔记目录整理
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。