阅读前请先下载项目源码,边读边看源码以加深理解和实操,
源码地址已放于文章末尾!
前方高能!欢迎来到火力最猛的第四章!咱们的主角队伍已经能在场景里风骚走位了,但作为一款“激战突围”游戏,光会走位可不行,必须得有强大的火力!今天,咱们就来给英雄们配上神兵利器,并揭秘那满屏飞舞的“biubiubiu”到底是怎么实现的!

这一章有点硬核,但别怕,跟着我这个老司机,保证让你轻松拿下,顺便还能get到游戏开发中一个极其重要的性能优化神技——合批渲染与对象池!
第一步:武器库探秘 —— WeaponCfg
在咱们游戏里,武器不是一个独立的“类”,它的所有属性都被清晰地定义在了 Init/Config/GlobalConfig.ts 的 WeaponCfg 静态对象里。这种数据驱动的做法非常常见,好处是策划小哥想调整数值时,我们只需要改改配置表,而不用深入到代码逻辑里去“大海捞针”。
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!” 这种做法让角色逻辑和特效/子弹逻辑完美解耦,代码清晰多了。
第三步:弹药库管理员 —— MergeEffectLayer 与 MergeGroup
谁在监听这个“火力覆盖”的请求呢?就是我们之前在 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); // 通知对象池“我回来了”
}
}
代码解读:
一个子弹的生命是短暂而光荣的:
- 出生:被
MergeGroup从对象池中激活,设置好初始位置和速度。 - 飞行:在
customUpdate中,不断根据速度向量更新自己的位置。 - 索敌:每一帧都通过
onCheckEnemys在一个粗糙的网格系统CollisionManager里检查有没有敌人。 - 命中与牺牲:一旦
CollisionManager通知它发生了碰撞 (onCheckColliderCb),它会命令敌人掉血,然后调用自己的reset方法,光荣地“牺牲”自己,回到对象池等待下一次召唤。
今日总结
今天我们装备了整个武器库,把游戏从“走路模拟器”变成了真正的“射击游戏”!我们学习了:
- 数据驱动:如何用
WeaponCfg这样的配置文件来灵活地管理武器数值。 - 事件驱动:角色开火是通过发送事件来解耦的,这让
Role和Bullet的逻辑互不干扰。 - 对象池大法:掌握了游戏开发中最重要的性能优化技巧之一,理解了如何通过复用对象来避免性能瓶颈。
- 合批渲染:了解了其基本原理,以及它在处理大量同类对象(如子弹)时的巨大优势。
我们的英雄们现在已经火力全开了!但是,没有敌人的靶场是寂寞的。是时候给他们找点“乐子”了!
下一期,我们将召唤出各式各样的敌人,让它们“悍不畏死”地向我们冲来。我们将一起探索敌人的AI设计、路径规划以及动态生成机制。准备好迎接挑战了吗?
敬请期待:《手把手教你写抖音爆火小游戏之激战突围(五):反派驾到 —— 敌人的AI与生成》!不见不散!
游戏源码下载地址:
https://wwuj.lanzoul.com/i4N1n33e9dib
验证码hack

被折叠的 条评论
为什么被折叠?



