第二章_2.6_3D射击游戏实现
前言
由于工作用的电脑配置太低了,在CSDN中编写的笔记字数一多就会卡,所以这段时间的笔记就一直没有上传。
这个项目是我在学习的Unity开发中认为一个比较好且非常适合新手做的项目,所以在这里分享给大家。
本项目参考自《Unity3D 脚本编程和游戏开发》中的第二章,强烈建议大家阅读,是本很适合新手入门的书。
本项目的资源文件:资源下载
文章目录
2.6.0 效果
2.6.1 实现功能
- 玩家移动
- 摄像机跟随
- 武器系统和子弹
- 敌人角色
- 子弹击中逻辑
- 人物死亡效果
2.6.2 创建主角以及玩家移动
创建主角主要由一个胶囊体作为主体,一个方体作为面部,其效果如下:
另外,我们将一个平面作为地板,其大小放大四倍。
主角移动逻辑代码
代码逻辑:
- 获取玩家输入,将输入封装为一个向量
- 在玩家角色并未死亡的前提下,根据玩家输入向量设置玩家的移动速度
- 根据玩家的输入向量设置玩家朝向
- 限制玩家的移动范围,超出范围将玩家修改为范围内
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
,当切换武器时curGun
在weaponBag
的guns
列表中增加,如果超出列表下标则回到最初的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=(Xb−Xa,Yb−Ya,Zb−Za)
用矩阵形式表示的话,向量 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= Xb−XaYb−YaZb−Za
我们要求敌人走向玩家,那么就意味着敌人为“点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轴指向圆外,自然就会向外移动。