Unity入门开发实战——俯视角3D射击游戏项目实战

第二章_2.6_3D射击游戏实现

前言
由于工作用的电脑配置太低了,在CSDN中编写的笔记字数一多就会卡,所以这段时间的笔记就一直没有上传。
这个项目是我在学习的Unity开发中认为一个比较好且非常适合新手做的项目,所以在这里分享给大家。
本项目参考自《Unity3D 脚本编程和游戏开发》中的第二章,强烈建议大家阅读,是本很适合新手入门的书。
在这里插入图片描述

本项目的资源文件:资源下载

2.6.0 效果

实现效果

2.6.1 实现功能

  • 玩家移动
  • 摄像机跟随
  • 武器系统和子弹
  • 敌人角色
  • 子弹击中逻辑
  • 人物死亡效果

2.6.2 创建主角以及玩家移动

创建主角主要由一个胶囊体作为主体,一个方体作为面部,其效果如下:

在这里插入图片描述

另外,我们将一个平面作为地板,其大小放大四倍。

主角移动逻辑代码

代码逻辑:

  1. 获取玩家输入,将输入封装为一个向量
  2. 在玩家角色并未死亡的前提下,根据玩家输入向量设置玩家的移动速度
  3. 根据玩家的输入向量设置玩家朝向
  4. 限制玩家的移动范围,超出范围将玩家修改为范围内
    
     void Update()
    {
        inputVector = GetInputVector();
        if (!isDead)
        {
            Move();
        }
    }

    /// <summary>
    /// 获取玩家输入向量
    /// </summary>
    /// <returns>玩家输入向量</returns>
    private Vector3 GetInputVector()
    {
        float horizontalInput = Input.GetAxisRaw("Horizontal");
        float verticalInput = Input.GetAxisRaw("Vertical");
        return new Vector3(horizontalInput, 0, verticalInput);
    }

     /// <summary>
    /// 玩家移动方法
    /// </summary>
    private void Move()
    {
        //设置移动速度
        AdjustVelocity();
        //设置玩家朝向
        AdjustForward();
        //设置边界
        BorderLimit();

        /// <summary>
        /// 设置玩家速度
        /// </summary>
        void AdjustVelocity()
        {
            //将输入向量归一化,向量的模长为1,保证每个方向的速度相同
            inputVector = inputVector.normalized;
            rb.velocity = inputVector * speed;
        }

        /// <summary>
        /// 设置玩家朝向
        /// </summary>
        void AdjustForward()
        {
            //如果输入的值不是很小的话,将物体转向前进方向
            if (inputVector.magnitude > 0.1f)
            {
                transform.forward = inputVector;
            }
        }

        /// <summary>
        /// 限制玩家移动范围
        /// </summary>
        void BorderLimit()
        {
            //限制玩家的移动范围
            Vector3 temp = transform.position;
            //Math.Clamp(value,min,max) : 在最大最小范围中间取值,如果超过范围,则返回最大/最小值
            temp.x = Mathf.Clamp(temp.x, -BORDER, BORDER);
            temp.z = Mathf.Clamp(temp.z, -BORDER, BORDER);
            transform.position = temp;
        }
    }

知识点

①:向量归一化

向量归一化,即将向量的长度改为1。

假设vector为一个向量,可以使用vector.normalized来获取这个向量的归一化向量,或者使用vector.Normalize()来将这个向量归一化。

②:Mathf.Clamp(value,min,max)

使value在min和max中间取值,如果value大于最大值在返回max,如果小于最小值那么返回min

③:内部方法

在方法内部的方法,C#的特性,允许我们在一个方法的内部再编写方法。

但是编写的方法仅仅可以在方法内部被调用。

目前貌似不推荐使用这个方法,因为这样限制了方法的复用度,以后不用了。

④:transform.forward

物体面朝方向,长度为1,初始情况下是面向Z轴方向,此时transform.forward为(0,0,1)。之后随着物体旋转而改变。

2.6.3 摄像机跟随

我们创建一个2D Camera,并且将其跟随的目标设置为Player物体即可。

请添加图片描述

注意:我们必须创建2D Camera,如果创建的物体是Virtual Camera的话,摄像机会随着玩家转向也一起转向。

2.6.4 实现武器系统

书中的武器系统较为简陋,没有创建武器对应的类,不利于以后添加新的武器。所以在此我做出了一点小调整:

  • 创建Gun类,其中包括武器的相关信息(包括开火位置、射速、子弹数等)

  • 在人物物体(GameObject)之下创建一个名为WeaponBag的物体,其中子物体是武器物体。

    • 武器物体包括几个简单物体拼接而成的武器模型
    • 武器物体挂载Gun脚本的组件,以此设置武器的属性
    • 武器物品必须要有一个名为FirePosition的空物体,此物体表示该武器的开火位置,也就是“子弹从哪里打出来”
  • WeaponBag物体上挂载了一个对应的脚本,其脚本的作用是获取WeaponBag的所有子物体中的所有子物体的Gun组件

  • 所有武器的操作由WeaponController操控,WeaponController目前实现了武器开火以及武器切换这两个重要功能

  • 当然最后还有创建子弹相关的脚本以及物体

Gun类代码

代码:

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

/// <summary>
/// 枪械类
/// </summary>
public class Gun : MonoBehaviour
{
    /// <summary>
    /// 射出的子弹
    /// </summary>
    public GameObject bulletPerfab;
    /// <summary>
    /// 射击间隔
    /// </summary>
    public float fireCD;
    /// <summary>
    /// 是否持续射击
    /// </summary>
    public bool isHoldToFire;
    /// <summary>
    /// 一次射击的子弹个数
    /// </summary>
    public int onceFireBulletNumber;
    /// <summary>
    /// 子弹射出间隔角度(当子弹个数为复数时使用)
    /// </summary>
    public float bulletAngle;
    /// <summary>
    /// 每个子弹伤害
    /// </summary>
    public float damage;
    /// <summary>
    /// 子弹飞行速度
    /// </summary>
    public float bulletVelocity;
    /// <summary>
    /// 子弹持续时间
    /// </summary>
    public float bulletLifeTime;
    /// <summary>
    /// 子弹射出位置
    /// </summary>
    public Transform fireTransform;
}

一个简单的Gun物体示例:

请添加图片描述

WeaponBag功能实现

实现原理如图:

在这里插入图片描述

为了代码的灵活性,我们并不需要手动设定规定一个人物物品持有几个武器。所以就需要在WeaponBag的组件中使用GetComponentsInChildren()方法,获取子物体中的所有Gun组件,并且将他们存放在一个数组中。

实现代码如下:

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

public class WeaponBag : MonoBehaviour
{
    public List<Gun> guns;
    // Start is called before the first frame update
    void Awake()
    {
        //获取所有子物体的Gun组件
        Gun[] gunsInBag = GetComponentsInChildren<Gun>();
        guns = new List<Gun>(gunsInBag);
    }
}

如此一来,我们的WeaponController只需要获取WeaponBag组件,就可以“知道”这个人物有多少种枪械了。

WeaponController实现

WeaponController主要实现了武器开火和武器切换功能。

  • 武器切换

武器切换的逻辑:设置一个int类型的变量curGun,当切换武器时curGunweaponBagguns列表中增加,如果超出列表下标则回到最初的0下标;当武器切换时,先将当前武器(旧武器)设置为未激活状态,再等curGun增加后,将目前武器(新武器)设置为激活状态。

另外:在WeaponContoller启动时会将除了curGun之外的所有武器都设置为未激活状态。

实现代码:

    /// <summary>
    /// 切换武器
    /// </summary>
    /// <returns>当前武器序号</returns>
    /// <remarks>
    /// 将当前武器设置为未激活,然后将列表中的下一个武器激活
    /// </remarks>
    public int ChangeWeapon()
    {
        DeactiveCurrentWeapon();
        curGun = (curGun + 1) % weaponBag.guns.Count;
        ActiveCurrentWeapon();
        Debug.Log("现在装备武器 : " + weaponBag.guns[curGun].name);
        return curGun;
    }

    #region 当前武器激活与否
    /// <summary>
    /// 将当前武器设置激活
    /// </summary>
    private void ActiveCurrentWeapon()
    {
        weaponBag.guns[curGun].gameObject.SetActive(true);
    }
    /// <summary>
    /// 将当前武器设置为未激活
    /// </summary>
    private void DeactiveCurrentWeapon()
    {
        weaponBag.guns[curGun].gameObject.SetActive(false);
    }
    #endregion
  • 武器开火

我们首先要分析武器开火实现的原理:

①:根据武器类型确定武器是否可以开火。

②:根据武器射出子弹不同编写不同的射击函数。武器一次射出的子弹数量并不相同。如步枪和手枪,一次仅仅射出一发子弹;但是散弹枪一次却会射出复数个子弹。

③:生成子弹,设置子弹信息

代码:

    #region 开火相关
    public void Fire(bool fireKeyDown,bool fireKeyPressed)
    {
        if(weaponBag.guns != null && weaponBag.guns.Count > curGun)
        {
            //当前武器开火
            GunFire(weaponBag.guns[curGun], fireKeyDown, fireKeyPressed);
        }
    }

    /// <summary>
    /// 武器开火
    /// </summary>
    /// <param name="gun">开火武器</param>
    /// <param name="fireKeyDown">开火按下</param>
    /// <param name="fireKeyPressed">开火键持续按下</param>
    public void GunFire(Gun gun,bool fireKeyDown,bool fireKeyPressed)
    {
        //判断是否输入武器开火
        if (!IsFireInput(gun,fireKeyDown,fireKeyPressed))
        {
            return;
        }

        if (lastFireTime + gun.fireCD > Time.time)
        {
            return;
        }

        if (gun.onceFireBulletNumber > 1)
        {
            MultipleBulletFire(gun);
        }
        else
        {
            SingleBulletFire(gun);
        }
        lastFireTime = Time.time;

    }

    /// <summary>
    /// 判断是否有开火输入
    /// </summary>
    /// <param name="gun">武器</param>
    /// <param name="fireKeyDown">胺键按下</param>
    /// <param name="fireKeyPressed">按键持续按下</param>
    /// <returns>是否允许开火</returns>
    private bool IsFireInput(Gun gun, bool fireKeyDown, bool fireKeyPressed)
    {
        //按下瞬间,任意武器均可开火
        if (fireKeyDown)
        {
            return true;
        }
        //持续按下期间,只有设置了按下持续开火的武器可以开火
        if (gun.isHoldToFire && fireKeyPressed)
        {
            return true;
        }
        return false;
    }

    /// <summary>
    /// 射出单个子弹
    /// </summary>
    /// <param name="gun"></param>
    private void SingleBulletFire(Gun gun)
    {
        GameObject bulletGameObject = Instantiate(gun.bulletPerfab, null);
        ConfigureBulletInfo(gun, bulletGameObject.GetComponent<Bullet>());
        ConfigureBulletTransform(bulletGameObject, gun.fireTransform.position,gun.transform.forward);
    }

    /// <summary>
    /// 射出多个子弹。子弹最大散射角度范围为60,默认每个子弹间隔10°
    /// </summary>
    /// <param name="gun"></param>
    private void MultipleBulletFire(Gun gun)
    {
        float shootStartAngle, bulletAngle;
        //计算获取复数子弹的起始角度(shootStartAngle)以及每个子弹之间的间隔角度(bulletAngle)
        CalculateBulletAngle(gun, out shootStartAngle, out bulletAngle);

        for (int i = 0; i < gun.onceFireBulletNumber; i++)
        {
            GameObject bulletGameObject = Instantiate(gun.bulletPerfab, null);
            ConfigureBulletInfo(gun, bulletGameObject.GetComponent<Bullet>());
            // 计算子弹的偏离角度
            float angle = shootStartAngle + i * bulletAngle;
            Vector3 dir = Quaternion.Euler(0, angle, 0) * gun.fireTransform.forward;
            ConfigureBulletTransform(bulletGameObject, gun.fireTransform.position, dir);
        }
        
    }

    /// <summary>
    /// 计算子弹射击角度
    /// </summary>
    /// <param name="gun">当前武器(枪械)</param>
    /// <param name="shootStartAngle">射击起始角度</param>
    /// <param name="bulletAngle">每个子弹之间的间隔角度</param>
    private void CalculateBulletAngle(Gun gun, out float shootStartAngle, out float bulletAngle)
    {
        //如果子弹散射角度没有大于最大角度,那么使用默认角度;否则将子弹散射的角度保持在最大角度之中
        bulletAngle =
            (gun.onceFireBulletNumber * DEFAULT_BULLET_ANGLE > MAX_SHOOT_ANGLE)
            ? MAX_SHOOT_ANGLE / gun.onceFireBulletNumber
            : DEFAULT_BULLET_ANGLE;
        shootStartAngle = -(gun.onceFireBulletNumber * bulletAngle) / 2;
    }

    /// <summary>
    /// 设置子弹的Transform组件信息
    /// </summary>
    /// <param name="bullet">子弹</param>
    /// <param name="position">子弹初始位置</param>
    /// <param name="direction">子弹朝向</param>
    private void ConfigureBulletTransform(GameObject bullet, Vector3 position, Vector3 direction)
    {
        bullet.transform.position = position + direction * BULLET_OFFSET;
        bullet.transform.forward = direction;
    }

    /// <summary>
    /// 设置子弹(组件)信息
    /// </summary>
    /// <param name="gun"></param>
    /// <param name="bullet"></param>
    private void ConfigureBulletInfo(Gun gun, Bullet bulletComponent)
    {
        if(bulletComponent == null)
        {
            return;
        }
        bulletComponent.lifeTime = gun.bulletLifeTime;
        bulletComponent.speed = gun.bulletVelocity;
        bulletComponent.damage = gun.damage;
    }
    #endregion

其中子弹的发射逻辑为:创建一个子弹预制体,在开火时,在对应位置创建一个子弹预制体即可。之后设置子弹预制体的信息以及朝向。

可以看到我们将子弹开火根据不同情况分为了两个函数,当子弹发射颗数是单颗的时候,我们使用SingleBulletFire函数,在这个函数之中,子弹只是单纯的从枪械规定的枪口中沿强的朝向射出;当子弹射出的颗数是复数时,我们使用MultipleBulletFire,此时会计算每颗子弹沿枪口的偏离角度。

2.6.5 实现敌人行为逻辑

目前这个项目中给敌人编写的行为逻辑十分简单:走向玩家位置以及不停的开火

走向玩家位置

在实现这个功能之前我们必须知道一点:在坐标系中,有点A(Xa,Ya,Za)、点B(Xb,Yb,Zb),那么点A朝向点B向量 A B ⃗ \vec{AB} AB 的坐标即为B的坐标减去A的坐标,即为(Xb - Xa,Yb - Ya,Zb -Za)。

用更正式的数学公式来表示从点A到点B的向量 A B ⃗ \vec{AB} AB :

A B ⃗ = ( X b − X a , Y b − Y a , Z b − Z a ) \vec{AB} = (X_b - X_a, Y_b - Y_a, Z_b - Z_a) AB =(XbXa,YbYa,ZbZa)

用矩阵形式表示的话,向量 A B ⃗ \vec{AB} AB 可以写作:

A B ⃗ = ( X b − X a Y b − Y a Z b − Z a ) \vec{AB} = \begin{pmatrix} X_b - X_a \\ Y_b - Y_a \\ Z_b - Z_a \end{pmatrix} AB = XbXaYbYaZbZa

我们要求敌人走向玩家,那么就意味着敌人为“点A”,玩家为“点B”,要获取到敌人面向玩家的向量就意味着需要玩家的坐标 减去 敌人的坐标

实现代码如下:

    /// <summary>
    /// 移动方法
    /// </summary>
    private void Move()
    {
        //朝玩家移动
        input = playerTransform.position - this.transform.position;
        input = input.normalized;
        this.transform.position += input * speed * Time.deltaTime;
        //转向
        if(input.magnitude > 0.1f)
        {
            transform.forward = input;
        }
    }
敌人开火

敌人开火的功能倒是简单,只需要调用敌人的WeaponController组件,对Fire函数调用中将两个开火参数设置为true即可。

实现代码:

    /// <summary>
    /// 开火方法
    /// </summary>
    private void Fire()
    {
        weaponController.Fire(true,true);
    }

2.6.6 子弹击中逻辑

单单子弹击中的逻辑也较为简单。我们对子弹的要求为:子弹撞到同类Tag物体不消除,其余情况下子弹撞到任何东西都立马被消除。

实现代码:

    /// <summary>
    /// 碰撞检测
    /// </summary>
    /// <param name="other"></param>
    private void OnTriggerEnter(Collider other)
    {
        //同类型的子弹不进行销毁
        if (CompareTag(other.tag))
        {
            return;
        }
        //击中后销毁自身
        Destroy(this.gameObject);
    }

其余的例如子弹到生命周期之后消除自身的逻辑就更简单了:

    /// <summary>
    /// 如果超过生命时间,那么销毁物体
    /// </summary>
    private void DestoryIfLifeTimeExceeded()
    {
        if (startTime + lifeTime < Time.time)
        {
            Destroy(this.gameObject);
        }
    }
玩家被子弹击中逻辑

玩家被子弹击中后需要先判断子弹是否为敌人发射,如果是那么就进行被击中的逻辑,其中包括:扣除生命值以及死亡判断。

玩家死亡逻辑:放置死亡特效(之后会做)、隐藏玩家、游戏暂停。

#region 碰撞相关
private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag(ENEMY_BULLET_TAG))
    {
        Bullet bullet = other.GetComponent<Bullet>();
        if (bullet != null)
        {
            HandleBulletCollision(bullet);
        }
            
    }

}
/// <summary>
/// 处理子弹碰撞事件
/// </summary>
/// <param name="bullet"></param>
private void HandleBulletCollision(Bullet bullet)
{
    GetDamage(bullet);
    Debug.Log($"Current HP : {hp}");
    CheckForDeath();
}
/// <summary>
/// 检查是否死亡
/// </summary>
private void CheckForDeath()
{
    if (hp <= DEATH_HP_VALUE && !isDead)
    {
        isDead = true;
        OnDeath();
    }
}
/// <summary>
/// 死亡
/// </summary>
private void OnDeath()
{
    Instantiate(perfabBoomEffect, transform.position, transform.rotation);
    //隐藏玩家
    this.gameObject.SetActive(false);
    //停止游戏
    Time.timeScale = 0;
}

/// <summary>
/// 受到伤害
/// </summary>
/// <param name="damage">伤害值</param>
private void GetDamage(Bullet bullet)
{
    hp -= bullet.damage;
}
#endregion
敌人被子弹击中逻辑

敌人被击中逻辑和玩家相同。

代码如下:

#region 碰撞相关
    /// <summary>
    /// 碰撞函数
    /// </summary>
    /// <param name="other"></param>
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag(PLAYER_BULLET_TAG))
        {
            Bullet bullet = other.GetComponent<Bullet>();
            if(bullet != null)
            {
                HandleBulletCollision(bullet);
            }
        }
    }

    /// <summary>
    /// 处理子弹碰撞事件
    /// </summary>
    /// <param name="bullet"></param>
    private void HandleBulletCollision(Bullet bullet)
    {
        GetBulletDamaged(bullet);
        if (curHP <= DEATH_HP_VALUE && !isDead)
        {
            isDead = true;
            OnDeath();
        }
    }
    /// <summary>
    /// 子弹伤害逻辑处理
    /// </summary>
    /// <param name="bullet"></param>
    private void GetBulletDamaged(Bullet bullet)
    {
        curHP -= bullet.damage;
        //此后可以在此添加不同子弹射击后的效果
    }
    #endregion
        
            /// <summary>
    /// 死亡方法
    /// </summary>
    private void OnDeath()
    {
        Instantiate(perfabBoomEffect, transform.position, transform.rotation);
        Destroy(this.gameObject);
    }

2.6.7 人物死亡效果

人物死亡效果:在人物死亡以后将人物隐藏/删除,之后在人物位置创建N个方框,这N个方框均匀的向四周散开并且迅速减小,小到一定程度以后开始消失。

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

public class BoomEffect : MonoBehaviour
{
    /// <summary>
    /// 缩小速率
    /// </summary>
    private const float ShrinkRate = 0.9f;
    /// <summary>
    /// 移动速度
    /// </summary>
    private const int TranslateSpeed = 10;
    /// <summary>
    /// 最小缩放
    /// </summary>
    private const float MinimumScale = 0.05f;

    /// <summary>
    /// 方框个数
    /// </summary>
    public int CubeCount = 15;
    /// <summary>
    /// 特效半径
    /// </summary>
    public float EffectRadius = 1;
    /// <summary>
    /// 方框物体
    /// </summary>
    private List<Transform> cubes = new List<Transform>();

    private void Start()
    {
        // 创建特效方块
        CreateEffectCubes();
    }

    /// <summary>
    /// 创建特效方块
    /// </summary>
    private void CreateEffectCubes()
    {
        for (int i = 0; i < CubeCount; i++)
        {
            GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
            cube.transform.parent = transform;
            // 方块本地位置(绕本地位置形成一个半径为EffectRadius的圆)
            cube.transform.localPosition = new Vector3(
                Mathf.Cos(i * 2 * Mathf.PI / CubeCount) * EffectRadius,
                0,
                Mathf.Sin(i * 2 * Mathf.PI / CubeCount) * EffectRadius
            );
            // 方块朝向(沿着中心点向外的向量)
            cube.transform.forward = cube.transform.position - this.transform.position;
            cubes.Add(cube.transform);
        }
    }

    private void Update()
    {
        // 更新特效
        UpdateEffect();
    }

    /// <summary>
    /// 更新特效
    /// </summary>
    private void UpdateEffect()
    {
        // 特效方块移动
        foreach (Transform cube in cubes)
        {
            // 方块移动(Translate是基于物体的本地坐标系移动的)
            cube.Translate(0, 0, TranslateSpeed * Time.deltaTime);
            cube.localScale *= ShrinkRate;
            // 方块小到一定程度以后删除
            if (cube.localScale.x < MinimumScale)
            {
                Destroy(this.gameObject);
            }
        }
    }
}

值得一提的是:在特效方块创建的时候,是沿着物体中心使用特效方块“画”了一个圆,并且将每个方块的朝向改为圆心到物体的射线方向,此时每个物体的Z轴指向圆外。在特效更新时,使用cube.Translate()函数来移动方块,并且只移动了Z轴,这是因为Translate()函数是根据物体的本地坐标系来移动的,此时物体的Z轴指向圆外,自然就会向外移动。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值