Hello大家好,我是Nowpaper,一爸学游戏,越来越有趣,本帖是B站视频的论坛文稿版本
前言
我本人是一个射击游戏的爱好者,从最早的Doom到Quake,再后来一路CS、荣誉勋章、使命召唤、守望先锋,FPS游戏一直让我痴迷其中,对于射击游戏而言,一个好的子弹射击效果,绝对是射击游戏核心体验,目前我最喜欢的射击感、速度感和打击感的游戏,非《守望先锋》莫属,子弹射出和打击到墙面瞬间的细节,虽然不起眼,但绝对是提升游戏品质的关键,这种体验在游戏开发中,如何实现的呢?今天一爸我就尝试一下,让我们在CocosCreator中复刻一下守望先锋的枪弹射击效果
《守望先锋》的美术和TA肯定不是我这半吊子能比的,因此我想在本视频中,能做出一个75分的效果即可,主要是讲解和研究,在Creator3中如何实现,《守望先锋》里的武器都太科幻,我们只借鉴它的枪弹表现力
目录
本视频当中将会分成两个大部分来讲解,第一个部分是特效,第二个部分是代码逻辑
- 效果
- 特效原理
- 枪口的火焰
- 飞行的子弹
- 击中的特效
- 代码逻辑
- 功能需求分解
- 子弹算法原理
- 子弹处理
- 枪械逻辑
- 命中表现
- 注意事项
项目素材和源码
炫酷枪火打击实现 | Cocos Store
(注意:仅为视频中的资产,不包含靶场、第一人称、第三人称、官方人物结合的有关内容)
在线测试版地址,请选择《3D射击效果》
http://www.pktgame.com/
效果
让我们先来看看成品大概是什么样子
这是一个模拟的靶场,滑杆调整角度,设置界面可以调整参数,可设置项有子弹速度、偏移、弹容量,重填时间、射速、单次子弹数这六个参数,基本可以涵盖各种常规的射击枪械,为了演示,枪械方面没作太复杂的模型,直接是方块代替
在第一人称和第三人称的测试场景中,可以更加清晰的看到实际应用效果
特效原理
在特效方面我们作一下拆解,如果实现这样的子弹射击效果,需要以下几个方面,枪口喷射的火焰,子弹飞行的轨迹,击中目标后的特效,如果有条件的话还需要音效
枪口的火焰
枪口喷射的火光,我们参考一下实际效果
它是由外散火焰和一个散射的外圈组成,并且喷射时候会带上一个光晕,这么来看它至少有两个粒子系统来表现,使用一个粒子系统来制作喷射火光,参数中的核心数据是Bursts,这个火光粒子的生命周期实际上很短,因此要用Bursts来表现它的短暂张力,后面的所有特效也是同样的处理,注意Bursts模块在3.3.0的版本中有bug,不能显示count数量,因此需要3.3.2以后的版本才能制作
具体的参数就不列举了,这是一个非常消耗时间的工作,通过慢速给大家看一下它的具体组成, 枪口火焰是一个交叉的面片,给与一个粒子材质随机旋转,并使用贴图动画模块切换纹理,飞溅火焰是由一个喇叭型的模型,从小变大的动画过程,而光晕则使用了一大号爆发粒子,瞬间闪烁造成的视觉效果
飞行的子弹
子弹飞行的轨迹相对简单,它是两个主要部分组成,一个冲击的粒子,一个是拖尾粒子,冲击粒子由一个喇叭模型表现子弹破空效果,这和枪火那个喷射粒子基本一致,只不过它是以循环闪烁的方式表现,拖尾粒子是在Z轴上拉长的单个循环粒子,同样也是用Bursts产生,来表示飞行中不稳定光感波动
击中的特效
击中墙壁效果,是所有粒子效果中最为复杂的,它由这样几个部分组成,炸裂、火花、烟雾、斑痕、光晕,通过分解挨个说一下原理,炸裂效果是命中时的溅射,使用两个开口模型粒子实现,采用和枪火喷射一样的处理即可,只不过它是缩小了一圈而已,火花这个是最难的,我使用的是圆锥型喷射模块,随机飞溅出几个粒子,并且它还得带有重力的物理特性,除此之外大小也是一个难题,太大显得不真实,太小又看不清楚,调它的时候着实费了不少力气
烟雾的表现还好,只需要一个简单上升粒子即可,虽然如此但它的数值想调得自然还是比较难。。。,命中斑痕经过研究后,发现很多游戏表现手法都是双层重叠,命中点一层,扩散点一层,命中点很快消失,扩散点会逐步消失,更细一点作法是,依据物体表面材质,用不同贴图表示瘢痕,有得对此还使用了消解效果的shader,这方面我不想增加复杂度,因此就不用shader了,直接以渐变消失的粒子效果处理
代码逻辑
在写代码之前,我们先做一下功能的需求分析,用下面的脑图来表示需要什么
最基础的就是枪和子弹,枪械代码主要的功能是发射子弹,它通过Prefab来创建子弹,从发射点发射出去,发射过程需要扳机控制,对应的会产生喷射特效,枪火特效可以重复使用一个粒子特效,不用每次都产生
如果想做出真实的枪械射击感,我们需要对枪械的参数进行细分,让我们来看看射击游戏各种参数到底有多丰富
这是一款吃鸡游戏的参数列表,各种参数组合就成了各种不同的枪械,在这里,我只用了最具代表性的五个射击参数,和一个射击偏移物理参数,这些组合足够我们做出大部分的常规枪械了
子弹的需求就不用这么细分了,仅仅需要速度、移动方向向量、存在时间,它的最主要的功能就是处理移动和进行碰撞检查
子弹算法原理
我们先来想想在游戏开发中,开枪射击的两种常规开发方式
- 第一种是射线检查
- 第二种是物理碰撞
先说第一种射线检查思路,当射击后枪械指向方向会出一条射线,射线命中模型的点,就是击中点,然后我们在这个基础上做出两种方案
一是直接命中,没有子弹的事,也就是说开枪的瞬间直接命中了目标,完全没有考虑速度问题,这种对于近距离是没问题的,但是远距离的话。。。如果想看到弹道,那就是不可能的
二是在世界中产生一个子弹,依据发射点和命中点的距离,和子弹的飞行速度,计算一个插值运动,让飞行粒子沿着它飞到目标即可,但是你会发现一个致命问题,如果子弹速度过慢,在它的弹道中间突然出现了物体,也不会击中物体的
第一种射线检查似乎不太完美,毕竟子弹命中目标,不是和开火同一个时间发生,那么使用子弹碰撞是否可以呢?
子弹在飞行中碰到什么就是什么,但是碰撞在高速移动的物理世界中,并不能简简单单的这么处理,因为游戏世界不是真实世界,就比如可能会穿模,也可能碰撞点和预期击中点不一致
这么看起来似乎到了死胡同
那么,那种更加合适呢?如果是你怎么做呢?
最佳的处理方案是,两者结合,准确的说是各自取了一部分,在开火的时候,我们仍然让子弹产生,并且按照预定的轨迹飞行,当然了,这个子弹可以可见,也可以不可见,通常为了游戏体验,我们都会弄一个粒子特效让飞行过程可见,子弹飞行的过程中,要用物理碰撞检查吗?
其实不然,应该采用射线检查,没错就是让子弹进行射线检查,而不是发射器发射出去的射线,为什么这么说,我们这样来看,子弹在飞行的时候,它的下一个点的轨迹是可以预测的,从当前帧的点到下一个帧的点,这就是一条射线,如果这条射线命中了任何符合条件的碰撞体,就可以判定是命中了,由于射线检查可以明确的得到碰撞点信息(PhysicsRayResult),因此它完全可以作为下一帧的子弹命中点,当然了,也可以加入物理碰撞体来增加真实度,比如子弹重力和风力影响,这需要作额外的运算,有机会再填坑
有了这个思路,我们就可以按照它写代码了
子弹代码
关于子弹组件的脚本代码,需要speed、vector变量作为计算处理,其中vector我们做一下处理,在被赋值的时候,对速度进行一次计算,标记它在一个单位时间内应该走多远,这样做是为了避免额外的计算量,在Update里面加入向量移动,并且在移动之后检查下一帧是否会碰撞到任何刚体,我们写一个检查方法,按照前面说的原理,通过步长长度和向量的计算,引出一条射线,用它到物理世界中检查它前方是否碰撞,如果有碰撞,则处理碰撞逻辑
BulletSc.ts 的代码
import { _decorator, Component, Vec3, v3, geometry, physics, RigidBody, game } from 'cc';
import { AutoRecycleSc } from './AutoRecycleSc';
import { ImpactHelperSc } from './ImpactHelperSc';
const { ccclass, property } = _decorator;
@ccclass('BulletSc')
export class BulletSc extends Component {
private _speed: number = 200;
public get speed(): number {
return this._speed;
}
public set speed(v: number) {
this._speed = v;
if (this._vector) {
this._vector = this._vector.normalize().multiplyScalar(this.speed);
}
}
private _vector: Vec3 = null;
setVector(v: Vec3) {
this._vector = v.clone().multiplyScalar(this.speed);
this.node.forward = v;
BulletSc.preCheck(this, this.speed / 60);
}
private _vec3 = v3();
update(deltaTime: number) {
if (this._vector) {
Vec3.multiplyScalar(this._vec3, this._vector, deltaTime);
this.node.position = this.node.position.add(this._vec3);
BulletSc.preCheck(this, this._vec3.length());
}
}
private static preCheck(b: BulletSc, len: number) {
const p = b.node.worldPosition;
const v = b._vector;
const ray = geometry.Ray.create(p.x, p.y, p.z, v.x, v.y, v.z);
const phy = physics.PhysicsSystem.instance;
if (phy.raycast(ray, 0xffffff, len)) {
if (phy.raycastResults.length > 0) {
let result = phy.raycastResults[0];
game.emit(ImpactHelperSc.AddImpactEvent,b,result);
if (result.collider.getComponent(RigidBody)) {
result.collider.getComponent(RigidBody).applyForce(b._vector, result.hitPoint);
}
if (b.getComponent(AutoRecycleSc))
b.getComponent(AutoRecycleSc).recycle();
}
}
}
}
处理碰撞判定这个计算方法,不是每个对象独有的,因此使用静态方法会是一个不错的选择,在设置向量的位置也要进行一次判定,这是因为有时候速度很快,它创建的时候,在下一帧先进行了移动,直接飞到了很远的地方,再去检查的时候可能就不对了,所以在子弹生成的瞬间就要进行判定,避免穿模
另外我们再建立一个自动回收的脚本,将来可以挂在子弹、瘢痕、弹壳等上面,通过一个延迟时间变量,在合适的时机自动回收掉物体,有了这个脚本,以后可以很方便的扩展出对象池回收站的功能,在本文中就不多赘述了
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AutoRecycleSc')
export class AutoRecycleSc extends Component {
@property
deltaTime = 5;
update (dt: number) {
this.deltaTime -= dt;
if(this.deltaTime <=0){
this.recycle();
}
}
recycle(){
this.deltaTime = 1000;
this.node.destroy();
}
}
枪械逻辑
枪械的组件脚本,利用ccclass制作一个配置项GunOverView,包含枪械的概述,包含子弹速度、弹夹大小、射击速度、重填时间、同时子弹数,以及偏移震动的范围参数,通过可外部引用属性,来获取到枪火特效,子弹发射点,子弹的预制体,这些是从场景或者项目中需要获得的对应的引用
@ccclass("gun_overview")
export class GunOverView {
@property
bulletSpeed = 200;
@property
ammoPerMag: number = 10;
@property
timeBetweenShots: number = 0.3;
@property
timeReload: number = 1;
@property
meanwhile: number = 1;
@property
speadValue: number = 1;
}
3个变量来处理射击状态、计算缓存,一个计数器,计数器是用来计算射击、子弹消耗、重填计时
isShotting = true;
private vec3: Vec3 = v3();
private timer: Timer = new Timer();
// other class
class Timer {
shot: number = 0;
ammo: number = 0;
reload: number = 0;
}
射击方法里,同时射出的子弹数量循环创建子弹,封装一下createBullet
createBullet() {
// to create bullet
}
枪口的朝向这个向量,就是子弹要沿着飞行的向量,当子弹创建的同时,设置起始位置,设置速度,飞行向量需要重新计算一下,因为我们还有一个重要的体验参数就是震动,按照角度随机将飞行向量做一下旋转,这里是用向量变换和四元数相乘,获得新的向量,新向量就是子弹的朝向方向,因此我们把它设置到子弹脚本里的向量即可
// ... Part
@property(GunOverView)
gunOverview: GunOverView = new GunOverView();
@property(Node)
fireEffect: Node = null;
@property(Prefab)
bullet: Prefab = null;
@property(Node)
muzzleNode: Node = null;
// ... Part
createBullet() {
this.vec3 = this.muzzleNode.forward.clone();
const b = instantiate(this.bullet);
director.getScene().addChild(b);
b.setWorldPosition(this.muzzleNode.worldPosition);
b.setWorldRotation(this.muzzleNode.worldRotation);
b.getComponent(BulletSc).speed = this.gunOverview.bulletSpeed;
let rot = this._quat;
const speadValue = this.gunOverview.speadValue;
Quat.fromEuler(rot, (Math.random() * 2 - 1) * speadValue,
(Math.random() * 2 - 1) * speadValue, (Math.random() * 2 - 1) * speadValue);
Vec3.transformQuat(this.vec3,this.vec3.normalize(),rot);
b.forward = this.vec3;
b.getComponent(BulletSc).setVector(b.forward);
b.worldScale = this.muzzleNode.worldScale;
}
// ... Part
Update中计算计时器,按照射击条件发射,当子弹的数量足够的时候,计算射击冷却时间,产生发射行为,子弹随之消耗增加,当达到最大的时候触发reload,整体的流程就是这样,其中很多代码可以提取出来,比如射击、创建子弹、重置状态等等
update(dt:number){
if(!this.isShotting)return;
if(this.timer.ammo > 0){
this.timer.shot += dt;
if(this.timer.shot >= this.gunOverview.timeBetweenShots){
this.timer.shot = 0;
this.shot();
this.timer.ammo -= 1;
this.timer.reload = 0;
if(this.timer.ammo <=0){
//重填
}
}
}else{
if(this.timer.reload >= this.gunOverview.timeReload){
this.timer.ammo = this.gunOverview.ammoPerMag;
this.timer.reload = 0;
}
this.timer.reload += dt;
}
}
resetState(){
this.timer.shot = this.timer.ammo = this.timer.reload = 0;
}
射击方法里我们会尝试调用粒子系统,目前我用了一种遍历子节点的方式,播放粒子特效,所以还要写一个粒子效果帮助者的类,这个帮助类可以在多个地方使用
ParticleEffectHelper.ts
import { ParticleSystem,Node } from "cc";
export module ParticleEffectHelper{
export function Play(node:Node){
const arr = node.getComponentsInChildren(ParticleSystem);
for(let a of arr){
a.stop();
a.play();
}
}
}
命中表现
正如我们前面提到的,当命中的时候,我们可以获得碰撞点,在碰撞点的位置上生成瘢痕特效,除此外还需要依据碰撞面的法线,来确定生成面的朝向旋转
为此,需要写一个命中点管理组件脚本,它的作用是为合适的碰撞点添加击中效果,比如游戏中,命中到墙壁之类的要处理瘢痕,命中敌人就直接飙液体了,所以这个组件脚本,我们通过监听一个添加碰撞消息,来处理碰撞事件,在事件接收参数中包含子弹信息,和物理命中点的射线信息,在此,计算和处理命中点的特效位置和朝向,射线命中测试中包含了命中法线信息,命中特效的朝向跟着法线指向即可,最终将生成的特效添加到目标物体上,现在回到子弹的脚本中,为它的命中时添加事件派发,告诉命中帮助脚本击中目标了
ImpactHelperSc.ts
import { _decorator, Component, Node, Prefab, game, PhysicsRayResult, instantiate } from 'cc';
import { BulletSc } from './BulletSc';
const { ccclass, property } = _decorator;
@ccclass('ImpactHelperSc')
export class ImpactHelperSc extends Component {
public static AddImpactEvent:string = "AddImpactEvent";
@property(Prefab)
impact1:Prefab = null;
start () {
game.on(ImpactHelperSc.AddImpactEvent,<any>this.onAddImpactEvent,this);
}
private onAddImpactEvent(b:BulletSc,e:PhysicsRayResult) {
const impact = instantiate(this.impact1);
impact.worldPosition = e.hitPoint.add(e.hitNormal.multiplyScalar(0.01));
impact.forward=e.hitNormal.multiplyScalar(-1);
impact.scale = b.node.scale;
impact.setParent(e.collider.node,true);
}
}
现在返回到Creator中,简单拼接一下枪械射击点,然后放置一个墙面,由于Creator默认场景实在一言难尽,我只得自己手动调整一下,达到令人满意的效果,制作一个简单的枪械发射器,模样看起来差不多就可以,做一个子弹Prefab,放上特效,并且挂子弹组件脚本,以及自动回收脚本,之后稍微花一些时间修正一下
将这个帮助脚本ImpactHelperSc,添加到一个场景节点上,再把命中点的prefab添加给它引用项
完成现在试试效果,给摄像机加入了自由控制脚本,飞近一点看看如何,为了确认弹坑点位置和朝向的准确性,弄一个圆球,可以看出命中点的特效还是很不错的,虽然和守望先锋有很大的差距,但是已经提供了特效思路,其实其中还有更多的优化空间
注意事项
请注意,本特效的视频制作时,使用的是Creator3.3.2版本,由于粒子的shader运算问题,官方引擎中的代码块,在处理模型粒子的时候,不支持跟随节点转动,这个问题对我来本来无解,但Creator技术群内的大佬炫烨,给出了及时帮助,提供了一个正确的代码块,否则的话,我根本无法完成它,在此特别表示道谢
3.4版本,本视频中提到的粒子特效不能跟着旋转的问题,已经解决了,这次粒子系统更新,能够让粒子指定参照坐标系,因此不需要替换代码块
结束
我是Nowpaper,一个混迹游戏行业的老爸,感谢的阅读,如果您喜欢这篇文章,在B站和cocos论坛支持一下,那就是对我莫大的鼓励,我们下次再见!
本人喜欢研究各种有趣的玩法,以下是往期制作,可以移步研究
时间倒放的有趣实现,在Creator中作物理回溯,开发《时空幻境》一样的倒退玩法
用RenderTexture实现Sprite版小地图和炫酷的传送门