Unity笔记-21-02-塔防游戏练习项目
场景布置
利用灰度图创造规则地形
首先选择一张地形图,例如:
(图片来自网络)
打开PhotoShop,新建-选择灰度模式,灰度模式,灰度模式重要的事情说三遍,然后选择储存为,选择文件格式:raw
格式,然后打开Unity
,新建地形,在设置栏里选择Import
导入即可,注意:地形高度不要调的太高,否则会很卡或者地形不好微调
得到图中这样的地形说明已经成功,但是因为图片的不同,灰度会导致地形的锯齿十分严重,要使用平滑工具调整
至此,地形搭建完毕
然后就是按照导航系统的流程为地形烘焙导航路径即可
至此,场景搭建完毕
炮塔及炮塔基座
炮塔基座作为炮塔的父对象,而后续的脚本,会挂在炮塔上,炮塔基座作为鼠标获取的炮塔生成点,具体模型Unity商店或者自己搭建
敌人单位动画配置
动画:移动(走或者跑);受伤;死亡;
非UI的脚本模块
敌人单位生成模块
波次数据类
[System.Serializable]
public class EnemyWave
{
[Header("波次间隔时间")]
public float waveTime;
[Header("单怪间隔时间")]
public float singleTime;
[Header("敌人总数")]
public int totalNumbers;
[Header("敌人预制体")]
public GameObject enemyPrefab;
[Header("敌人生命值")]
public int enemyHP;
[Header("敌人移动速度")]
public float moveSpeed;
[Header("敌人角速度")]
public float angularSpeed;
}
敌人波次信息,设置一个类储存波次信息,通过[System.Serializable]
序列化,将该类显示在Inspector
界面上
包括:波次间隔时间;单个敌人生成间隔时间;敌人总是;敌人预制体;敌人生命值;敌人移动速度;(敌人角速度)
当然,这样的代码会导致每波次怪的类型是一样的,如果需要更加灵活,可以将敌人个体具体的数据放置在敌人预制体脚本上,这里定义敌人预制体数组即可以及对应的不同类型敌人数量数组;
各类参数定义
/// <summary>
/// 波次
/// </summary>
public EnemyWave[] waves;
/// <summary>
/// 波次索引
/// </summary>
private int waveindex=0;
/// <summary>
/// 生成数量
/// </summary>
private int count=1;
/// <summary>
/// 出生点
/// </summary>
private Vector3 startPos;
/// <summary>
/// 终点
/// </summary>
private Vector3 endPos;
/// <summary>
/// 波次时间
/// </summary>
private float waveTime=0;
/// <summary>
/// 个体生成间隔时间
/// </summary>
private float singleTime=0;
创建单个敌人方法
private void CreateEnemy(GameObject prefab,int HP,float moveSpeed,float angularSpeed)
{
if (singleTime > waves[waveindex].singleTime)
{
GameObject go = Instantiate(prefab, startPos, Quaternion.LookRotation(Vector3.right));
go.GetComponent<EnemyInfoDemo>().Init(HP, moveSpeed,angularSpeed, endPos);
count++;
singleTime = 0;
}
else
{
singleTime += Time.deltaTime;
}
}
通过计数器,每间隔一定时间生成敌人,我这里设置了生成敌人的初始朝向,因为地图的路径朝向和我模型的朝向不同,另外由于我讲敌人个体数据放在了波次里,因此这里需要初始化敌人单位的数据,具体代码见后续
创建波次
private void CreateWave()
{
if (waveindex > waves.Length-1)
{
return;
}
if (count > waves[waveindex].totalNumbers)
{
count = 0;
waveTime = 0;
singleTime = 0;
waveindex++;
return;
}
if (waveTime > waves[waveindex].waveTime)
{
CreateEnemy(waves[waveindex].enemyPrefab, waves[waveindex].enemyHP, waves[waveindex].moveSpeed, waves[waveindex].angularSpeed);
}
else
{
waveTime += Time.deltaTime;
}
}
首先判断当前波次是否已经溢出
在判断当前敌人数量是否已经溢出,在判断计数器创建单个敌人
敌人导航模块
在敌人对象上添加组件:Nav Mesh Agent
组件,确保敌人能够正常导航
各类参数定义
/// <summary>
/// 碰撞器
/// </summary>
private CapsuleCollider capsuleCollider;
/// <summary>
/// 委托
/// </summary>
public Action<EnemyInfoDemo> eventEnemy;
/// <summary>
/// 动画控制器
/// </summary>
private Animator ani;
/// <summary>
/// 导航组件
/// </summary>
private NavMeshAgent nav;
/// <summary>
/// 敌人高度
/// </summary>
[HideInInspector]
public Vector3 EnemyHeight;
/// <summary>
/// 敌人价值
/// </summary>
[SerializeField]
private int value;
/// <summary>
/// 敌人生命值
/// </summary>
private int HP;
这里简单说明一下,获取敌人高度是因为,通常情况下敌人对象预制件的轴心会在脚底,因此炮弹朝着脚底下打会很奇怪,所以要根据高度适当调整
初始化方法
public void Init(int hp,float moveSpeed,float angularSpeed,Vector3 target)
{
nav.angularSpeed = angularSpeed;
nav.speed = moveSpeed;
nav.SetDestination(target);
HP = hp;
}
主要就是设置导航终点,终点坐标在生成模块里输入,初始化方法在生成模块里调用
受伤方法
public void Damage(int damage)
{
//这里加入伤害判断,因为如果同时多个子弹打中,且敌人已经阵亡,那么会多次执行死亡导致敌人数量扣除异常
if (HP <= 0) return;
HP -= damage;
if (HP <= 0)
{
Death();
}
else
{
nav.isStopped = true;
ani.SetTrigger("Hit");
Invoke("Resume",0.5f);
}
}
public void Resume()
{
nav.isStopped = false;
}
这里要注意:敌人被打中,需要停止移动并播放受伤动画,再恢复移动,因此,这里需要讲导航设置为停止,在通过动画控制器播放受伤动画,在延时调用恢复导航方法来实现
死亡方法
public void Death()
{
TowerBuyer.instance.aliveEnemy -= 1;
capsuleCollider.enabled = false;
nav.isStopped = true;
ani.SetTrigger("Death");
eventEnemy(this);
TowerBuyer.instance.currentMoney += value;
Destroy(this.gameObject,1);
}
说明,一旦敌人死亡,由于要播放死亡动画,但是敌人不能挡住后面的敌人移动,因此需要讲碰撞器关闭,并且停止导航,否则会出现死亡动画播放的时候还在移动的bug
,并通过单例脚本添加当前的金币数量
终点碰撞器脚本
敌人到达终点扣除游戏生命值,并销毁对象即可
public class EndPos : MonoBehaviour
{
[Header("终点生命值")]
public int GameHP;
private void OnTriggerEnter(Collider other)
{
TowerBuyer.instance.aliveEnemy -= 1;
if (GameHP <= 0) return;
if (other.tag == "Enemy")
{
GameHP--;
if (GameHP == 0)
{
Debug.Log("GameOver");
}
}
Destroy(other.gameObject);
}
}
炮塔模块
各类参数定义
[Header("炮塔价格")]
public int Cost;
[Header("炮弹伤害")]
public int damage;
[Header("炮塔旋转速度")]
public float turnSpeed;
/// <summary>
/// 待攻击敌人的队列
/// </summary>
private List<EnemyInfoDemo> enemysList;
/// <summary>
/// 炮塔Transform组件
/// </summary>
private Transform Gun;
[Header("子弹预制体")]
public GameObject Bullet;
/// <summary>
/// 子弹发射位置
/// </summary>
private Vector3 firePoint;
[Header("攻击间隔")]
public float attackInterval;
/// <summary>
/// 当前攻击时机
/// </summary>
private float attackTime=0;
/// <summary>
/// 当前攻击的敌人高度
/// </summary>
private Vector3 enemyHeight;
确定攻击队列
进入
private void OnTriggerEnter(Collider other)
{
if (other.tag == "Enemy")
{
EnemyInfoDemo enemyInfo = other.GetComponent<EnemyInfoDemo>();
enemyInfo.eventEnemy += RemoveEnemyFromList;
enemysList.Add(enemyInfo);
}
}
通过触发器,检测进入触发器的单位被加入攻击队列
离开
private void OnTriggerExit(Collider other)
{
if (other.tag == "Enemy")
{
enemysList.Remove(other.GetComponent<EnemyInfoDemo>());
}
}
离开触发器,被移除攻击队列
委托的移除队列方法
public void RemoveEnemyFromList(EnemyInfoDemo enmey)
{
enemysList.Remove(enmey);
}
这里需要说到之前的委托,如果敌人在触发器中,且正在被攻击至死亡,那么该敌人便无法离开触发器,因此会出现子弹打尸体的bug
,因此需要在敌人死亡时移除该对象,但是这个对象死亡的时机是无法获取的,因此需要通过委托,在加入攻击队列时,同时将敌人的委托绑定炮塔模块的移除队列方法,这样就可以在死亡的时候调用该移除方法将对象移除攻击队列
炮塔转向与攻击
之前写过很多遍了,这里不再解释
private void RotationTo()
{
if (enemysList.Count == 0) return;
enemyHeight = enemysList[0].GetComponent<EnemyInfoDemo>().EnemyHeight;
Quaternion relative = Quaternion.LookRotation(enemysList[0].transform.position + enemyHeight - Gun.position);
Gun.rotation = Quaternion.Lerp(Gun.rotation, relative, Time.deltaTime*turnSpeed);
Fire();
}
private void Fire()
{
if (attackTime>attackInterval)
{
GameObject bullet = Instantiate(Bullet, firePoint, Quaternion.identity);
bullet.transform.up = Gun.transform.forward;
Bullet bs = bullet.GetComponent<Bullet>();
bs.target = enemysList[0].transform;
bs.damage = damage;
bs.enemyHeight = enemyHeight;
attackTime = 0;
}
else
{
attackTime += Time.deltaTime;
}
}
子弹跟踪
public class Bullet : MonoBehaviour
{
[HideInInspector]
public Transform target;
[HideInInspector]
public int damage;
public Vector3 enemyHeight;
private void Update()
{
if (target != null)
{
Trace();
}
else
{
Destroy(this.gameObject);
}
}
private void OnTriggerEnter(Collider other)
{
if (other.tag == "Enemy")
{
other.GetComponent<EnemyInfoDemo>().Damage(damage);
}
Destroy(this.gameObject);
}
private void Trace()
{
transform.position = Vector3.Lerp(transform.position,target.position+enemyHeight,Time.deltaTime*4);
}
}
视角控制模块
控制摄像机移动,比较简单,不多说明
public class CameraController : MonoBehaviour
{
public float moveSpeed;
private Vector2 verticalLimit;
private Vector2 horizontalLimit;
//x:0-65
//z:25-70
// Start is called before the first frame update
private void Start()
{
verticalLimit = new Vector2(0,65);
horizontalLimit = new Vector2(25,75);
}
// Update is called once per frame
private void Update()
{
Control();
}
private void Control()
{
float hor = Input.GetAxis("Horizontal");
float ver = Input.GetAxis("Vertical");
Vector3 mayPos = transform.position + new Vector3(ver,0,-hor)*Time.deltaTime*moveSpeed;
transform.position = new Vector3(Mathf.Clamp(mayPos.x, verticalLimit.x, verticalLimit.y), transform.position.y, Mathf.Clamp(mayPos.z, horizontalLimit.x, horizontalLimit.y)); ;
}
}
UI搭建
创建按钮,通过按钮选择对象,并通过Grid Layout Group
排列组件将按钮网格排列
创建金币文本,记录金币数量
UI脚本
各类参数定义
/// <summary>
/// UI单例
/// </summary>
public static TowerBuyer instance;
/// <summary>
/// 炮塔预制体
/// </summary>
public GameObject[] TowerPerhabs;
/// <summary>
/// 当前选择的炮塔索引
/// </summary>
private int currentIndex;
/// <summary>
/// 初始金币
/// </summary>
public int startMoney=3000;
/// <summary>
/// 当前金币
/// </summary>
public int currentMoney;
/// <summary>
/// 射线检测碰撞对象
/// </summary>
private RaycastHit hit;
/// <summary>
/// 勾选框
/// </summary>
private Toggle toggle;
/// <summary>
/// 文本
/// </summary>
private Text moneyText;
/// <summary>
/// 存活的敌人数量
/// </summary>
public int aliveEnemy;
这里唯一要注意的就是单例,因此怪物死亡需要增加金币,通过单例能快速找到对应的数据
绑定按钮方法
public void onTowerButtonClick(int index)
{
currentIndex = index - 1;
}
选中即改变索引,由于按钮的监听器只能添加无参数的方法,因此这里只能通过拖拽将方法绑定,后续可以优化使用事件接口
购买炮塔
public void BuyTower()
{
if (currentIndex == -1) return;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if(Input.GetMouseButtonDown(0)&&Physics.Raycast(ray,out hit,1<<7))
{
if (hit.collider.tag != "Tower") return;
if (hit.collider.transform.childCount !=1) return;
GameObject towerPerhab=TowerPerhabs[currentIndex];
int cost = towerPerhab.GetComponent<TowerInfo>().Cost;
if (currentMoney < cost)
{
Debug.Log("No Money");
return;
}
currentMoney -= cost;
GameObject tower=Instantiate(TowerPerhabs[currentIndex]);
tower.transform.SetParent(hit.collider.transform);
tower.transform.localPosition = Vector3.zero;
}
}
首先判断索引是否获得
再获得由鼠标发射的屏幕射线,判断射线检测与鼠标按下
再判断检测物体的标签是否为炮塔基座
判断炮塔基座时候已经有子级(已经建过炮塔)
再创建炮塔预制体
并获得炮塔价格,判断金币是否足够
扣除金币
生成炮塔,设置炮塔父对象为炮塔基座,设置相对位置为0
这里注意,在Project Setting
里关闭射线的触发器检测,防止触发器覆盖屏幕导致无法创建
更新UI金币
private void UpdateTextMoney()
{
moneyText.text = currentMoney.ToString();
}
游戏加速
public void onToggleCheck(bool val)
{
if (val)
{
Time.timeScale = 1;
}
else
{
Time.timeScale = 2;
}
}
绑定勾选框,添加监听器,更改时间缩放
游戏胜利判断
private void IsWin()
{
if (aliveEnemy <= 0)
{
Debug.Log("Win");
}
}