Unity笔记-21-02-塔防游戏练习项目

场景布置

利用灰度图创造规则地形

首先选择一张地形图,例如:
(图片来自网络)

unity怎么添加草地 unity怎么加地皮_unity怎么添加草地

打开PhotoShop,新建-选择灰度模式灰度模式灰度模式重要的事情说三遍,然后选择储存为,选择文件格式:raw格式,然后打开Unity,新建地形,在设置栏里选择Import

unity怎么添加草地 unity怎么加地皮_unity怎么添加草地_02

导入即可,注意:地形高度不要调的太高,否则会很卡或者地形不好微调

unity怎么添加草地 unity怎么加地皮_unity怎么添加草地_03

得到图中这样的地形说明已经成功,但是因为图片的不同,灰度会导致地形的锯齿十分严重,要使用平滑工具调整

至此,地形搭建完毕

然后就是按照导航系统的流程为地形烘焙导航路径即可

至此,场景搭建完毕

炮塔及炮塔基座

炮塔基座作为炮塔的父对象,而后续的脚本,会挂在炮塔上,炮塔基座作为鼠标获取的炮塔生成点,具体模型Unity商店或者自己搭建

敌人单位动画配置

动画:移动(走或者跑);受伤;死亡;

unity怎么添加草地 unity怎么加地皮_c#_04

非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");
        }
    }