手把手教你写抖音爆火小游戏之激战突围(四):火力全开!biubiubiu的子弹系统

阅读前请先下载项目源码,边读边看源码以加深理解和实操,
源码地址已放于文章末尾!

前方高能!欢迎来到火力最猛的第四章!咱们的主角队伍已经能在场景里风骚走位了,但作为一款“激战突围”游戏,光会走位可不行,必须得有强大的火力!今天,咱们就来给英雄们配上神兵利器,并揭秘那满屏飞舞的“biubiubiu”到底是怎么实现的!
在这里插入图片描述

这一章有点硬核,但别怕,跟着我这个老司机,保证让你轻松拿下,顺便还能get到游戏开发中一个极其重要的性能优化神技——合批渲染对象池

第一步:武器库探秘 —— WeaponCfg

在咱们游戏里,武器不是一个独立的“类”,它的所有属性都被清晰地定义在了 Init/Config/GlobalConfig.tsWeaponCfg 静态对象里。这种数据驱动的做法非常常见,好处是策划小哥想调整数值时,我们只需要改改配置表,而不用深入到代码逻辑里去“大海捞针”。

assets/Init/Config/GlobalConfig.ts - WeaponCfg 节选:

export const WeaponCfg = {
    // 手枪
    [GlobalEnum.WeaponType.Pistol]: {
        atk: 5,         // 攻击力
        atkSpd: 5,      // 攻击速度(频率, 值越大射得越快)
        bulletSpd: 25,  // 子弹飞行速度
        bullet: GlobalEnum.MergeEffectType.MergePistolBullet, // 子弹“皮肤”的枚举值
    },
    // 机枪
    [GlobalEnum.WeaponType.MachineGun]: {
        atk: 4,
        atkSpd: 15,
        bulletSpd: 35,
        bullet: GlobalEnum.MergeEffectType.MergeMachineGunBullet,
    },
    // 散弹枪
    [GlobalEnum.WeaponType.Shotgun]: {
        atk: 8,
        atkSpd: 2,
        bulletSpd: 20,
        bullet: GlobalEnum.MergeEffectType.MergeShotgunBullet,
    },
    // ... 其他武器配置 ...
}

代码解读
每个武器都像是一个“套餐”,规定了它的攻击力、射速、子弹速度和子弹类型。当玩家通过吃道具切换武器时,RoleLayer 会拿到这个道具的武器类型,然后调用所有 Role 实例的 setWeapon 方法,更新他们的 curWeapon 属性,后续的攻击逻辑就会自动切换。

第二步:扣下扳机 —— Role.onAtk 方法

角色是怎么知道该开火的呢?答案藏在 Role.ts 的状态机里。当角色处于 Move(移动射击)或 StandShoot(站立射击)等可以攻击的状态时,它的 update 方法会被持续调用。在状态的 update 里,会有一个计时器,根据当前武器配置的 atkSpd(攻击速度)来决定何时调用 onAtk 方法。

onAtk 方法就是“扣下扳机”的那个瞬间,它负责计算弹道、组装子弹数据,然后把发射任务“广播”出去。

assets/Game/Script/Custom/Role.ts - onAtk 核心代码:

onAtk() {
    // 1. 从配置表中获取当前武器的详细数据
    const wpCfg = WeaponCfg[this.curWeapon];
    
    // 2. 计算子弹发射的基础旋转角度(弧度)
    // this._rotVec 是角色当前的旋转向量, -1.57 是为了将模型朝向转为正前方
    let rotY = this._rotVec.y - 1.57;

    // 3. 根据武器类型,执行不同的发射逻辑
    switch (this.curWeapon) {
        // 散弹枪:一次发射多颗角度不同的子弹
        case GlobalEnum.WeaponType.Shotgun:
            // this.shotBulletRot 是一个预设的角度数组, 如 [-15, 0, 15]
            for (let i = 0; i < this.shotBulletRot.length; i++) {
                // 将角度转为弧度
                const r = this.shotBulletRot[i] * 0.01745; 
                this.tmpV.set(1, 0, 0); // 创建一个基础方向向量
                // 核心:让基础向量绕Y轴旋转,得到最终的发射方向
                Vec3.rotateY(this.tmpV, this.tmpV, Vec3.ZERO, rotY + r);
                // 乘以子弹速度,得到最终的速度向量
                this.tmpV.multiplyScalar(wpCfg.bulletSpd);

                // 设置子弹的初始位置(角色位置上方一点)
                this.tmpP.set(this.curPos).add3f(0, 0.5, 0);
                
                // 组装数据并发送事件
                this.atkData.pos.set(this.tmpP);
                this.atkData.lineVec.set(this.tmpV);
                this.atkData.atkRate = this.atkRate; // 攻击力倍率
                EventManager.emit(EventTypes.EffectEvents.showMergeEffect, wpCfg.bullet, this.atkData);
            }
            break;
        
        // 其他直线型武器(手枪、机枪等)
        default:
            this.tmpV.set(1, 0, 0);
            Vec3.rotateY(this.tmpV, this.tmpV, Vec3.ZERO, rotY);
            this.tmpV.multiplyScalar(wpCfg.bulletSpd);

            this.tmpP.set(this.curPos).add3f(0, 0.5, 0);
            
            this.atkData.pos.set(this.tmpP);
            this.atkData.lineVec.set(this.tmpV);
            this.atkData.atkRate = this.atkRate;
            EventManager.emit(EventTypes.EffectEvents.showMergeEffect, wpCfg.bullet, this.atkData);
            break;
    }
}

代码解读

  • 弹道计算:核心是向量数学 Vec3.rotateY。通过旋转一个基础向量,我们可以精确地计算出每颗子弹的飞行方向。对于散弹枪,就在基础旋转上再叠加一个偏移角度。
  • 事件驱动:这是最关键的一点!Role 类本身并不负责创建子弹。它只是组装好子弹所需的数据(初始位置 pos、速度向量 lineVec 等),然后通过 EventManager.emit 发送了一个 showMergeEffect 事件。这就好比,士兵扣下扳机后,通过对讲机吼了一嗓子:“请求火力覆盖!坐标xxx,方向xxx!” 这种做法让角色逻辑和特效/子弹逻辑完美解耦,代码清晰多了。

第三步:弹药库管理员 —— MergeEffectLayerMergeGroup

谁在监听这个“火力覆盖”的请求呢?就是我们之前在 LevelManager 里提到的 Game/Script/Custom/mergeEffect/MergeEffectLayer.ts。这个类是所有“可合批”特效的总管,当然也包括我们的子弹。

小科普:什么是“合批” (Batching)?
在游戏渲染中,每次向GPU发送一个绘制指令(Draw Call)都是有开销的。如果你有100个子弹,就发送100次指令,那GPU可就忙坏了。合批就是一种优化技术,它把使用相同材质的多个物体的顶点数据“合并”起来,一次性发送给GPU,从而把100次Draw Call变成1次。这个项目中的 MergeEffectLayer 就是专门用来处理这种优化渲染的。

MergeEffectLayer 听到 showMergeEffect 事件后,会找到管理具体某一类子弹的 MergeGroup,然后命令它去“激活”一个子弹。

这个 MergeGroup 就是我们心心念念的**对象池(Object Pool)**的实现者!

我来画个图解释一下这个精妙的流程:

graph TD
    subgraph Role.ts
        A[onAtk: 扣扳机] --> B{EventManager.emit("showMergeEffect")};
    end

    subgraph MergeEffectLayer.ts
        C{EventManager.on("showMergeEffect")} --> D[showEffect: 收到请求];
        D --> E[找到对应的MergeGroup];
    end

    subgraph MergeGroup.ts (对象池)
        E --> F[show: 激活一个子弹];
        F --> G{有没有空闲的子弹?};
        G -- Yes --> H[从“空闲”列表里拿一个];
        G -- No --> I[创建一个新的];
        H --> J[设置新位置、新方向];
        I --> J;
        J --> K[把它加入“活动”列表];
    end

    subgraph BasicBullet.ts
        L[update: 每帧移动] --> M{碰到敌人或飞出屏幕?};
        M -- Yes --> N[reset: 重置自己];
        N --> O[把自己从“活动”列表挪到“空闲”列表];
    end

    K -.-> L;

对象池的精髓

  • 分组管理MergeGroup 会维护两个数组,一个放着所有“正在天上飞”的子弹(活动列表 _actArr),一个放着所有“待命”的子弹(空闲列表 _unActArr)。
  • 复用代替销毁:当一个子弹 BasicBullet 完成了它的使命(比如击中敌人),它不会被销毁 (destroy),而是调用 reset 方法重置自己的状态,然后把自己从“活动”列表移动到“空闲”列表,等待下一次被“激活”。
  • 按需创建:只有当“空闲”列表里一个子弹都没有的时候,系统才会真正地 new 一个新的子弹实例。

这种做法,在需要频繁创建和销毁同类对象的场景(比如子弹、特效、敌人),能够极大地提升游戏性能,避免因为内存的频繁分配和回收(GC)导致的卡顿。这是一个游戏开发者的必备核心技能!

第四步:子弹的使命 —— BasicBullet.ts

Game/Script/Custom/bullet/BasicBullet.ts 是子弹的“灵魂”。它是一个非常纯粹的逻辑类,继承自 MergeNode,负责:

assets/Game/Script/Custom/bullet/BasicBullet.ts 核心代码:

export class BasicBullet extends MergeNode {
    // ...
    isFinish: boolean = false; // 是否已完成使命
    weaponType: GlobalEnum.WeaponType;
    atkRate: number = 1;

    // 每帧更新
    customUpdate(dt: any) {
        if (!this.isFinish) {
            // 1. 移动
            this.pos.x += this.lineSpd.x * dt;
            this.pos.y += this.lineSpd.y * dt;
            this.pos.z += this.lineSpd.z * dt;

            // 2. 检查是否飞出屏幕边界
            if (this.pos.z < GlobalTmpData.MapSize.z || this.pos.z > 0 ||
                this.pos.x < GlobalTmpData.MapSize.x || this.pos.x > GlobalTmpData.MapSize.y) {
                this.isFinish = true;
                this.reset(); // 回收
                return;
            }

            // 3. 碰撞检测
            this.onCheckEnemys();
        }
    }

    // 碰撞检测的回调,由CollisionManager调用
    onCheckColliderCb(groupId: number, cldId: number) {
        if (!this.isFinish) {
            // 从碰撞管理器中获取被撞到的对象
            let target = CollisionManager.allColliderRecs[cldId];
            // 确保撞到的是敌人
            if (target && groupId == ColliderGroup.Enemy) {
                let enemy = target.targetCmp as Enemy;
                // 计算最终攻击力
                let atk = WeaponCfg[this.weaponType].atk * this.atkRate;
                enemy.byHit(atk); // 命令敌人掉血
                
                // 根据武器类型,触发不同的命中特效
                // ...
                
                this.isFinish = true;
                this.reset(); // 使命完成,光荣回收
                return;
            }
        }
    }

    // 回收方法
    public reset() {
        this.isFinish = true;
        this.group.unActive(this); // 通知对象池“我回来了”
    }
}

代码解读
一个子弹的生命是短暂而光荣的:

  1. 出生:被 MergeGroup 从对象池中激活,设置好初始位置和速度。
  2. 飞行:在 customUpdate 中,不断根据速度向量更新自己的位置。
  3. 索敌:每一帧都通过 onCheckEnemys 在一个粗糙的网格系统 CollisionManager 里检查有没有敌人。
  4. 命中与牺牲:一旦 CollisionManager 通知它发生了碰撞 (onCheckColliderCb),它会命令敌人掉血,然后调用自己的 reset 方法,光荣地“牺牲”自己,回到对象池等待下一次召唤。

今日总结

今天我们装备了整个武器库,把游戏从“走路模拟器”变成了真正的“射击游戏”!我们学习了:

  1. 数据驱动:如何用 WeaponCfg 这样的配置文件来灵活地管理武器数值。
  2. 事件驱动:角色开火是通过发送事件来解耦的,这让 RoleBullet 的逻辑互不干扰。
  3. 对象池大法:掌握了游戏开发中最重要的性能优化技巧之一,理解了如何通过复用对象来避免性能瓶颈。
  4. 合批渲染:了解了其基本原理,以及它在处理大量同类对象(如子弹)时的巨大优势。

我们的英雄们现在已经火力全开了!但是,没有敌人的靶场是寂寞的。是时候给他们找点“乐子”了!

下一期,我们将召唤出各式各样的敌人,让它们“悍不畏死”地向我们冲来。我们将一起探索敌人的AI设计、路径规划以及动态生成机制。准备好迎接挑战了吗?

敬请期待:《手把手教你写抖音爆火小游戏之激战突围(五):反派驾到 —— 敌人的AI与生成》!不见不散!

游戏源码下载地址:
https://wwuj.lanzoul.com/i4N1n33e9dib
验证码hack

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

THMAIL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值