上一篇文章我们讲了如何动态创建物体,以及如何用Prefab机制复用我们制作好的物体和逻辑,那么本篇我们将会讲解如何利用这些功能,结合Unity自带的物理引擎,实现一个简单的FPS游戏的效果。
物理组件
首先我们需要先了解如何使用Unity自带的物理引擎,在前面的系列文章里面我们其实心理都有了方法论,Unity的绝大部分功能都是靠物体上挂的组件来实现的,那么物理引擎相关的功能也不例外。
我们之前一直在操作Cube,那么剩下还有一个Box Collider组件没有讲,其实这个就是物理引擎相关的组件之一:
Collider顾名思义,就是碰撞体的意思,很显然,如果一个物体需要参与到物理计算中,那么这个物体一般需要具有一定的形状大小,这样才能提供合理的物理效果。
那么Box Collider其实也很显然,这是一个Box形状的碰撞体,我们如何去掉勾选Mesh Renderer组件,那么可以在Scene场景中看到绿色的线框,这其实就是编辑器帮我们可视化了这个Collider的形状:
除了Box的形状,我们还可以使用球形(Sphere Collider),胶囊体(Capsule Collider)等等,可以直接在Add Component里面搜索Collider就能比较快的找到所有可以用的内置Collider组件,注意带2D后缀的是专门给2D游戏用的,我们现在做的是3D游戏,不应该使用2D组件。
我们现在还是用Box来做示范,那么这里这个Box的大小就是我们这个Cube所参与物理计算的大小,这里你可能就问了,既然这个Collider组件本身有大小设置的参数,那我们是不是可以让参与物理计算的大小和实际渲染看到的大小不一致呢?
当然是可以的,我们如果稍微调整一下Collider的Size,XYZ都+1,重新勾选Mesh Renderer组件,那么可以看到绿色的线框是会出现在渲染效果(橙黄色线框)外部,像是包裹了的样子:
这也就说明了,物理计算和渲染是完全分离的,并不是你看到这个物体渲染成什么样子,物理计算就是按照这个视觉效果来,而是由独立的一个大小轮廓计算的。
当然如果你希望物理计算跟你的渲染效果强行绑定,那么Unity也提供了一个Mesh Collider,顾名思义,这个Collider是跟渲染用的Mesh绑定,会直接贴合Mesh的形状创建Collider参与物理计算:
我们将Box Collider删掉(点击右边三个点,选择Remove Component),添加Mesh Collider,Unity默认给我们设置了当前Mesh Filter组件里面的Mesh到Mesh Collider组件中,如图红框。当然你也可以设置其他Mesh作为Collider的形状,同样我们取消Mesh Renderer组件的勾选,那么可以看到绿色线框显示的虽然轮廓上跟Box Collider类似,但是明显是由三角形组成的,也就是说这个Collider是由跟Mesh一样的三角形组合而成。
当然Mesh Collider也不会给调整大小的选项,如果要调整大小,需要调整GameObject本身的Transform大小(Scale),也就是说这个Collider就和渲染效果直接绑定的。
那么你可能会问了,既然Mesh Collider这么好,为什么默认给的是Box Collider?
很显然,Collider的形状越复杂,计算的性能消耗也就越大,如果我们能用简单的形状近似我们目前渲染的形状,那么就可以减轻物理引擎计算的性能消耗,并且物理效果又不会有太多不真实。这其实就是游戏开发中常常会做的性能和效果之间的权衡,也就是大差不差就好,游戏能流畅跑才能玩的了,计算的再准确,但是帧率很低没法玩也是白搭。
说了这么半天,我们还没讲解这个Collider到底如何使用,其实很简单,只要包含了Collider组件的物体,这个物体上挂的任何组件,都会在Collider发生碰撞的时候触发特定的函数:
也即是红框里面的这个函数:
看函数名就比较清楚,三个为一组,比如OnCollisionXXX表示Enter,Stay,Exit这三个状态,这三个状态的触发时机也比较简单:
- 当Collider第一次和另外一个Collider发生碰撞时,触发OnCollisionEnter
- 两个Collider发生碰撞的本质就是形状相交了,如果是刚体,那么碰撞后一般都会弹开,但是弹开的速度可能没那么快,也可能因为设置或者代码逻辑导致继续相交,那么在触发OnCollisionEnter之后每个物理帧都会检查一次是不是还在相交,如果是的话就会触发OnCollisionStay
- 当不再相交之后,会触发一次OnCollisionExit,如果再触发相交,那么继续从1开始跑流程
这个状态流转应该还是很好懂的,OnTriggerXXX后面讲。
那么我们现在试验一下这个功能,因为发生碰撞肯定是两个物体,那么我们需要有两个Cube。
Unity里面快捷键Ctrl+D可以对当前选中的物体快速复制一个出来,我们对场景里面已有的这个Cube进行Ctrl+D,得到一个Cube(1),它的位置形状大小组件都和被复制的Cube完全一模一样。
选中这个Cube(1)我们稍微操作一下Scene视图里面,按住蓝箭头之一拖动一下,移动一下这个Cube的位置,但是不要挪太远,保持和Cube是相交的状态:
这样我们保证了这两个Collider是处于碰撞状态。
好我们现在开始写代码,新建一个组件用于处理碰撞的回调:
using UnityEngine;
public class CollisionHandler : MonoBehaviour
{
void OnCollisionEnter(Collision collision)
{
Debug.Log("OnCollisionEnter from " + collision.gameObject.name);
}
}
然后给我们任意一个Cube放上这个组件,这里我放到Cube(1)里面:
然后我们跑一下游戏看看,嗯?怎么没有看到相关的日志输出到Console窗口里面呢?
我们再看一下官方文档的说明:
Notes: Collision events are only sent if one of the colliders also has a non-kinematic rigidbody attached. Collision events will be sent to disabled MonoBehaviours, to allow enabling Behaviours in response to collisions.
这里有个Notes,告诉我们Collision事件只有当碰撞的两个Collider中间至少有一个有rigidbody才会发送。
那么rigidbody是什么呢?这里就引申出来第二个物理组件:RigidBody
直接Add Component里面搜RigidBody,就能找到同名组件,给我们的任意一个Cube加上去:
可以看到这个组件里面可以配置的参数有:
- 质量(Mass)
- 是否使用重力(Use Gravity)
等等参数,这样看起来这个组件是赋予Collider物理学的材料特性,毕竟名字也叫刚体,那是不是表示这是一个不会发生形变的材料?
但是实际情况并不是,官方的文档中也只是表明RigidBody组件只是决定这个物体是不是受物理引擎影响,而并不代表这个物体的物理材料性质,RigidBody组件里面能配置的也都是关于物体受物理引擎影响后,移动和旋转的效果。真正配置物理材料的是在Collider里面有一个Physic Material,当然我们这里不会展开讲这个。
总而言之,可以简单理解为RigidBody只是一个用于控制物体是否参与物理计算的组件。
那么我们现在加上了这个组件后,再次运行游戏看看:
解释一下:
- Cube(1)带有RigidBody组件,并且勾选了Use Gravity也就是会受重力影响,那么这个物体在开始运行的时候就会根据内置的重力加速度改变自己的位置
- Cube(1)和Cube发生碰撞,同时其中之一有RigidBody组件,所以Collision消息会发送给参与碰撞的双方,而我们Cube(1)里面有脚本接收OnCollisionEnter并打印日志,可以看到Console窗口里面有日志输出,碰撞的对方则是另外一个Cube,如果我们给Cube也加上CollisionHandler组件,那么输出的日志里面带上的名字应该就是Cube(1)
- 由于Cube(1)带有RigidBody组件,所以它也会参与真实的物理模拟,可以看到除了受重力掉下,还会因为和另外一个Cube发生碰撞后弹开(可以看到游戏开始的一瞬间向右瞬移了)
那么现在我们能理解了,如果要一个物体参与物理计算,那么需要这个物体两个条件
- 拥有Collider组件,这个决定了参与物理计算的时候的形状大小
- 给需要受到物理引擎计算改变自身位置的物体加上RigidBody组件,不需要改变自身位置的不要加这个组件,只需要Collider组件即可
物体受物理效果发生位置改变的功能则自动的由Unity内置的物理引擎完成,不需要自己处理。
如果我们要处理碰撞相关的逻辑,那么编写组件,接收OnCollisionXXX回调即可,当然要注意,只有发生碰撞的两个物体之间有至少一个拥有RigidBody组件才会触发这个回调。
FPS游戏试做
当我们初步了解物理引擎如何使用后,我们可以考虑制作FPS游戏了,但其实就是做一个发射子弹然后打到墙壁的功能。
为了制作这个功能,我们需要构思一下要做哪些事情:
- 首先是需要做一个受物理引擎影响的子弹,当然不是说要模拟加速度,空气阻力这种,主要是为了做出打到墙壁会停下来的效果
- 再做一个墙壁,带上Collider但是自己不会移动
- 最后做个发射子弹的逻辑
那么第一步,我们就拿一个Cube当作子弹,但是大小有点大,我们改小点,比如Scale设置成(0.2,0.2,0.2),就我们前面直接配好的Cube(1)就可以直接改名为Bullet,删掉一些无关组件,大概是这样了:
然后我们再做一个墙壁,墙壁其实就是Cube改了Scale变成长条而已,我们直接改Cube,因为Cube已经有BoxCollider了,直接够用了。
然后挪到我们习惯的右手边,当然用哪边都无所谓,拖着蓝色箭头挪一下,然后调整一下Scene场景的视角看向这个Wall:
选中的是我们的Bullet
好,剩下一步就是我们需要将子弹发射出去,很明显我们可以直接写个脚本挂到Bullet上:
using UnityEngine;
public class AddVelocity : MonoBehaviour
{
public Vector3 initialVelocity; // 初始速度
void Start()
{
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = initialVelocity;
}
}
}
这个脚本也比较简单理解,就是Start的时候给RigidBody组件一个速度值,这里是使用物理引擎第一个需要理解的概念,我们希望物体受物理引擎模拟的话,就不应该直接修改物体的位置信息,而是给与一些物理上的信息,比如提供一个速度,或者提供一个力之类的概念。
我们将这个组件挂到Bullet上去,然后配上速度,注意这里速度其实是一个向量,也就是说速度是有方向的,我们希望子弹顺着蓝色箭头飞出去,蓝色是Z轴(可以看Scene场景右上角那个图标快速找到颜色对应的轴是哪个),很显然我们需要配的是(0,0,速度),我们配一个(0,0,1),跑起来看看:
看到我们的子弹还没碰到墙就掉下去了,很明显是因为速度太慢,重力加速度如此之大导致还没飞到位置就掉下去了。那我们直接修改速度,加快8倍:
看起来好多了,中间卡了一下是因为启动游戏会卡一点,然后错过了最开始物体运动的一小段。
这样我们已经做好了一个“子弹”射击墙体反弹掉落的效果。
改进FPS游戏的体验
但是很显然不太爽,FPS游戏应该支持连续射击,枪林弹雨才是这类游戏的爽快点之一。
那么我们要实现这个效果,需要做哪些事情呢?
- 需要接收用户输入,比如鼠标左键,按下就定时发射一个子弹
- 既然场景内同时存在的子弹肯定不止一个的话,我们需要动态创建子弹
- 如果可以的话,希望像真正的FPS游戏一样通过鼠标控制准心进行射击
首先是第一个需求,我们需要处理用户输入,还是像之前那样,新建一个组件脚本,用来处理鼠标按下的事件,每次按下都触发一次开火逻辑,并且如果持续按下的话每隔0.1s触发一次开火逻辑。
using UnityEngine;
public class FireController : MonoBehaviour
{
private bool isMouseDown = false;
private float lastFireTime = 0f;
public float fireInterval = 0.1f;
void Update()
{
if (Input.GetButton("Fire1"))
{
if (!isMouseDown)
{
isMouseDown = true;
lastFireTime = Time.time;
Fire();
}
else if (Time.time - lastFireTime > fireInterval)
{
lastFireTime = Time.time;
Fire();
}
}
else
{
isMouseDown = false;
}
}
void Fire()
{
// 在这里实现每次触发的逻辑
Debug.Log("Fire!");
}
}
考虑到这个触发逻辑不属于Bullet也不属于Wall,那我们需要分配一个新的GameObject来承载这个逻辑,我们就新建一个Empty的GameObject叫做FireController,直接挂上这个组件:
然后我们跑起来看看,是不是左键按下和按住的时候都会打印Fire日志了?
然后是第二个需求,前面我们也学到了如何动态的创建一个GameObject,那么现在我们需要动态的创建一个已经制作好的GameObject,其实就是要利用好Prefab功能了。我们先把场景里面已经创建好的Bullet拖到Project窗口中形成一个Prefab,然后删掉场景里面原有的这个Bullet,这个Bullet由于是从场景里面拖入的,带有一定的位置信息,我们给它全部置0:
这样我们的Project窗口里面已经有了一个等待被动态创建的Bullet
还是找到第一步里面我们创建的FireController,我们需要在Fire函数里面动态创建物体,那肯定我们首先需要获取到这个Prefab,很明显我们需要修改一下代码,支持赋值一下这个Prefab:
using UnityEngine;
public class FireController : MonoBehaviour
{
private bool isMouseDown = false;
private float lastFireTime = 0f;
public float fireInterval = 0.1f;
public GameObject bullet;
void Update()
{
if (Input.GetButton("Fire1"))
{
if (!isMouseDown)
{
isMouseDown = true;
lastFireTime = Time.time;
Fire();
}
else if (Time.time - lastFireTime > fireInterval)
{
lastFireTime = Time.time;
Fire();
}
}
else
{
isMouseDown = false;
}
}
void Fire()
{
// 在这里实现每次触发的逻辑
// 创建新的子弹,每次都是从模板bullet复制一个出来
GameObject newBullet = Object.Instantiate(bullet);
}
}
我们新增了一个public的GameObject成员叫bullet,用来给到编辑器里面赋值我们需要的Bullet到底是哪个Prefab,然后在每次Fire的时候通过Object.Instantiate来创建一个Prefab的实例,其实就类似复制。
然后我们可以看到FireController面板上就会有一个地方给我们赋值GameObject:
这样很顺理成章的,我们将Bullet的Prefab拖上去赋值。赋值完毕,我们跑起来看看:
1.14 从0开始学习Unity游戏开发--物理引擎
看起来还不错
解释一下:
- 鼠标点击后,FireController在Update里面处理了鼠标左键按下和长按的逻辑,触发Fire函数
- Fire函数通过成员变量bullet拿到了我们赋值的Prefab,通过Object.Instantiate创建新的实例,每个实例都相当于这个Prefab的复制品,但是彼此的数据逻辑都独立,可以看到左边Hierarchy窗口里面每次创建都会新增一个物体,创建的实例的位置默认是原点,当然可以自己指定
- 每个实例都自己有一个AddVelocity脚本,Start函数相当于每个实例创建后第一次要执行Update函数之前会跑一次,Start函数里面给了这个GameObject的RigidBody一个Z轴上的速度。
- 每个实例会根据物理引擎的计算,飞向墙壁
- 碰到墙壁,反弹,并且会因为重力而掉下去
那其实我们已经初步实现了连射功能,但是作为一个程序员,肯定第一时间反应到,每次射击都会创建一个子弹物体,但是没看到哪里销毁啊,这样一直射击岂不是迟早内存爆炸?
没错,我们这里缺少销毁GameObject的逻辑,所以需要补一下,这里可以简单做一下,子弹创建出来后5秒消失:
using UnityEngine;
public class AddVelocity : MonoBehaviour
{
public Vector3 initialVelocity; // 初始速度
public float lifeTime = 5.0f;
private float lifeStartTime;
void Start()
{
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = initialVelocity;
}
lifeStartTime = Time.time;
}
void Update()
{
if (Time.time - lifeStartTime > lifeTime)
{
Destroy(gameObject);
}
}
}
图省事我们直接写在AddVelocity脚本里面,但是需要注意的是,Destroy传入的必须是gameObject,不能是this,因为this是代表这个AddVelocity类,也就是销毁的是AddVelocity组件,而不是整个GameObject。
下一章
OK,第二个需求也顺利完成了,那么第三个需求就是我们需要用鼠标控制视角,然后有个准心能让我们瞄准,但是在屏幕上画一个准心,这个准心其实是属于UI的部分,也就是单纯的在屏幕上显示的内容,有别于3D物体,开发UI会有所不同,但是每个游戏基本都需要做UI,所以这也是游戏开发里面很重要的部分。
所以下一章我们会讲解如何在Unity游戏中使用内置的功能制作UI界面,并且结合本章的内容制作一个准心出来。