游戏中射击的最酷实现,Creator3如何作出《守望先锋》同级的枪弹射击体验

Hello大家好,我是Nowpaper,一爸学游戏,越来越有趣,本帖是B站视频的论坛文稿版本

前言

我本人是一个射击游戏的爱好者,从最早的Doom到Quake,再后来一路CS、荣誉勋章、使命召唤、守望先锋,FPS游戏一直让我痴迷其中,对于射击游戏而言,一个好的子弹射击效果,绝对是射击游戏核心体验,目前我最喜欢的射击感、速度感和打击感的游戏,非《守望先锋》莫属,子弹射出和打击到墙面瞬间的细节,虽然不起眼,但绝对是提升游戏品质的关键,这种体验在游戏开发中,如何实现的呢?今天一爸我就尝试一下,让我们在CocosCreator中复刻一下守望先锋的枪弹射击效果


《守望先锋》的美术和TA肯定不是我这半吊子能比的,因此我想在本视频中,能做出一个75分的效果即可,主要是讲解和研究,在Creator3中如何实现,《守望先锋》里的武器都太科幻,我们只借鉴它的枪弹表现力

目录

本视频当中将会分成两个大部分来讲解,第一个部分是特效,第二个部分是代码逻辑

  • 效果
  • 特效原理
    • 枪口的火焰
    • 飞行的子弹
    • 击中的特效
  • 代码逻辑
    • 功能需求分解
    • 子弹算法原理
    • 子弹处理
    • 枪械逻辑
    • 命中表现
  • 注意事项

项目素材和源码

炫酷枪火打击实现 | Cocos Store
(注意:仅为视频中的资产,不包含靶场、第一人称、第三人称、官方人物结合的有关内容)

在线测试版地址,请选择《3D射击效果》
http://www.pktgame.com/

效果

让我们先来看看成品大概是什么样子
动画
这是一个模拟的靶场,滑杆调整角度,设置界面可以调整参数,可设置项有子弹速度、偏移、弹容量,重填时间、射速、单次子弹数这六个参数,基本可以涵盖各种常规的射击枪械,为了演示,枪械方面没作太复杂的模型,直接是方块代替

在第一人称和第三人称的测试场景中,可以更加清晰的看到实际应用效果

动画2

特效原理

在特效方面我们作一下拆解,如果实现这样的子弹射击效果,需要以下几个方面,枪口喷射的火焰,子弹飞行的轨迹,击中目标后的特效,如果有条件的话还需要音效

动画3

枪口的火焰

枪口喷射的火光,我们参考一下实际效果

b21c8701a18b87d6164d7a71070828381f30fd55

它是由外散火焰和一个散射的外圈组成,并且喷射时候会带上一个光晕,这么来看它至少有两个粒子系统来表现,使用一个粒子系统来制作喷射火光,参数中的核心数据是Bursts,这个火光粒子的生命周期实际上很短,因此要用Bursts来表现它的短暂张力,后面的所有特效也是同样的处理,注意Bursts模块在3.3.0的版本中有bug,不能显示count数量,因此需要3.3.2以后的版本才能制作

具体的参数就不列举了,这是一个非常消耗时间的工作,通过慢速给大家看一下它的具体组成, 枪口火焰是一个交叉的面片,给与一个粒子材质随机旋转,并使用贴图动画模块切换纹理,飞溅火焰是由一个喇叭型的模型,从小变大的动画过程,而光晕则使用了一大号爆发粒子,瞬间闪烁造成的视觉效果

飞行的子弹

子弹飞行的轨迹相对简单,它是两个主要部分组成,一个冲击的粒子,一个是拖尾粒子,冲击粒子由一个喇叭模型表现子弹破空效果,这和枪火那个喷射粒子基本一致,只不过它是以循环闪烁的方式表现,拖尾粒子是在Z轴上拉长的单个循环粒子,同样也是用Bursts产生,来表示飞行中不稳定光感波动

击中的特效

击中墙壁效果,是所有粒子效果中最为复杂的,它由这样几个部分组成,炸裂、火花、烟雾、斑痕、光晕,通过分解挨个说一下原理,炸裂效果是命中时的溅射,使用两个开口模型粒子实现,采用和枪火喷射一样的处理即可,只不过它是缩小了一圈而已,火花这个是最难的,我使用的是圆锥型喷射模块,随机飞溅出几个粒子,并且它还得带有重力的物理特性,除此之外大小也是一个难题,太大显得不真实,太小又看不清楚,调它的时候着实费了不少力气

烟雾的表现还好,只需要一个简单上升粒子即可,虽然如此但它的数值想调得自然还是比较难。。。,命中斑痕经过研究后,发现很多游戏表现手法都是双层重叠,命中点一层,扩散点一层,命中点很快消失,扩散点会逐步消失,更细一点作法是,依据物体表面材质,用不同贴图表示瘢痕,有得对此还使用了消解效果的shader,这方面我不想增加复杂度,因此就不用shader了,直接以渐变消失的粒子效果处理

代码逻辑

在写代码之前,我们先做一下功能的需求分析,用下面的脑图来表示需要什么

最基础的就是枪和子弹,枪械代码主要的功能是发射子弹,它通过Prefab来创建子弹,从发射点发射出去,发射过程需要扳机控制,对应的会产生喷射特效,枪火特效可以重复使用一个粒子特效,不用每次都产生

如果想做出真实的枪械射击感,我们需要对枪械的参数进行细分,让我们来看看射击游戏各种参数到底有多丰富

这是一款吃鸡游戏的参数列表,各种参数组合就成了各种不同的枪械,在这里,我只用了最具代表性的五个射击参数,和一个射击偏移物理参数,这些组合足够我们做出大部分的常规枪械了

子弹的需求就不用这么细分了,仅仅需要速度、移动方向向量、存在时间,它的最主要的功能就是处理移动和进行碰撞检查

子弹算法原理

我们先来想想在游戏开发中,开枪射击的两种常规开发方式

  • 第一种是射线检查
  • 第二种是物理碰撞

先说第一种射线检查思路,当射击后枪械指向方向会出一条射线,射线命中模型的点,就是击中点,然后我们在这个基础上做出两种方案

一是直接命中,没有子弹的事,也就是说开枪的瞬间直接命中了目标,完全没有考虑速度问题,这种对于近距离是没问题的,但是远距离的话。。。如果想看到弹道,那就是不可能的

二是在世界中产生一个子弹,依据发射点和命中点的距离,和子弹的飞行速度,计算一个插值运动,让飞行粒子沿着它飞到目标即可,但是你会发现一个致命问题,如果子弹速度过慢,在它的弹道中间突然出现了物体,也不会击中物体的

第一种射线检查似乎不太完美,毕竟子弹命中目标,不是和开火同一个时间发生,那么使用子弹碰撞是否可以呢?

子弹在飞行中碰到什么就是什么,但是碰撞在高速移动的物理世界中,并不能简简单单的这么处理,因为游戏世界不是真实世界,就比如可能会穿模,也可能碰撞点和预期击中点不一致

动画5

这么看起来似乎到了死胡同

那么,那种更加合适呢?如果是你怎么做呢?

最佳的处理方案是,两者结合,准确的说是各自取了一部分,在开火的时候,我们仍然让子弹产生,并且按照预定的轨迹飞行,当然了,这个子弹可以可见,也可以不可见,通常为了游戏体验,我们都会弄一个粒子特效让飞行过程可见,子弹飞行的过程中,要用物理碰撞检查吗?

其实不然,应该采用射线检查,没错就是让子弹进行射线检查,而不是发射器发射出去的射线,为什么这么说,我们这样来看,子弹在飞行的时候,它的下一个点的轨迹是可以预测的,从当前帧的点到下一个帧的点,这就是一条射线,如果这条射线命中了任何符合条件的碰撞体,就可以判定是命中了,由于射线检查可以明确的得到碰撞点信息(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添加给它引用项

完成现在试试效果,给摄像机加入了自由控制脚本,飞近一点看看如何,为了确认弹坑点位置和朝向的准确性,弄一个圆球,可以看出命中点的特效还是很不错的,虽然和守望先锋有很大的差距,但是已经提供了特效思路,其实其中还有更多的优化空间

动画6

注意事项

请注意,本特效的视频制作时,使用的是Creator3.3.2版本,由于粒子的shader运算问题,官方引擎中的代码块,在处理模型粒子的时候,不支持跟随节点转动,这个问题对我来本来无解,但Creator技术群内的大佬炫烨,给出了及时帮助,提供了一个正确的代码块,否则的话,我根本无法完成它,在此特别表示道谢

3.4版本,本视频中提到的粒子特效不能跟着旋转的问题,已经解决了,这次粒子系统更新,能够让粒子指定参照坐标系,因此不需要替换代码块

结束

我是Nowpaper,一个混迹游戏行业的老爸,感谢的阅读,如果您喜欢这篇文章,在B站和cocos论坛支持一下,那就是对我莫大的鼓励,我们下次再见!

动画7

本人喜欢研究各种有趣的玩法,以下是往期制作,可以移步研究

时间倒放的有趣实现,在Creator中作物理回溯,开发《时空幻境》一样的倒退玩法

摄像机视角的有趣玩法,实现《饥荒》同款视觉表现,一毛一样

Raycast射线实现3D世界交互,如何实现立体界面UI

用RenderTexture实现Sprite版小地图和炫酷的传送门

好玩的编队代码,魔性队伍排列惊喜不断完全停不下来

手撸三个有关Bundle详细教程,大厅+子游戏模式从入门到进阶

Cocos3D《病毒传播模拟器》游戏版本1 开发日志和总结

案例开发 四图猜词 Part1~4 全集教程

46赞

楼主在直播说的最后感言令人动容,666666

源码不放下吗?不如直接show code

1赞

store现在打不开

守望先锋混子老手,请求添加好友

nowpaper@163.com

没事,下次我会找机会继续“喷”中国引擎

此锅不背,是官方素材 :rofl: :rofl: :rofl:

太赞了,顶了。个人觉得那个impact应该改名为impactEfx, 我总以为那个是在添加物理冲力。

厉害了 :nerd_face:

干货满满啊!

体验到了,感谢分享。

现在玩守望先锋会被一众末日铁拳教做人,被锤的找不到北,玩个半藏总听到基佬拉我裤链,被秀的头皮发麻,年纪大了反应也跟不上了

点赞大佬!

很赞大佬,谢谢分享~

感谢大佬分享,点赞!

确实是,命名没那么严谨,见谅见谅

赞。
mark。

虽然看不太明白,但是效果非常棒,不过东西比较多的话可能性能会有点影响