Cocos Creator 3.6 2D碰撞监听全解析:从组件到全局事件管理
1. 项目概述:为什么2D碰撞监听是游戏开发的核心
在Cocos Creator 3.6里做2D游戏,碰撞检测和监听是绕不开的基础功能。无论是做一个简单的平台跳跃游戏,还是复杂的弹幕射击游戏,你都需要知道“什么时候两个物体碰上了”,以及“碰上之后该干什么”。很多新手朋友在官方文档里看了BoxCollider2D和PhysicsSystem2D的API后,依然会卡在具体实现上:监听器怎么挂?回调函数怎么写?全局监听和组件监听到底用哪个?参数怎么调才不会有“鬼畜”的穿透或者抖动?
这篇文章,我就以一个踩过无数坑的“老司机”身份,带你从最基础的BoxCollider2D组件配置开始,一步步深入到PhysicsSystem2D的全局回调,把2D物理碰撞监听这件事掰开揉碎了讲清楚。我会结合一个具体的“发射子弹击中敌人”的案例,把编辑器操作、代码编写、参数调试、性能优化和常见坑点都过一遍。目标是让你看完之后,不仅能自己搭出一套稳定可靠的碰撞系统,更能理解背后的原理,做到举一反三。
2. 核心思路与方案选型:组件监听 vs 全局监听
在动手写代码之前,我们必须先理清Cocos Creator 3.6提供的两种主流碰撞监听方式,以及它们各自的适用场景。选错了方案,后期可能会带来巨大的重构成本。
2.1 组件级监听:onBeginContact 与 onEndContact
这是最直观、最常用的方式。你需要在一个挂载了碰撞体组件(如BoxCollider2D)的节点上,再挂载一个自定义脚本,并在这个脚本里实现特定的回调函数。
工作原理:当物理引擎检测到两个碰撞体发生接触或分离时,会遍历相关节点上的所有组件,寻找并调用那些定义了onBeginContact或onEndContact方法的组件。
适用场景:
- 逻辑与特定物体强绑定:比如一个“宝箱”节点,它被“玩家”碰撞后要打开并播放特效。这个“打开”的逻辑天然属于宝箱自身。
- 逻辑简单、对象明确:你很清楚是“谁”需要响应碰撞事件。
- 小型项目或原型开发:快速验证想法,逻辑直接写在相关节点上很方便。
优点:直观,逻辑内聚,易于理解。缺点:当场景中需要监听碰撞的对象很多时,每个对象上的脚本都会被频繁调用,可能引发性能问题。此外,事件逻辑分散在各个脚本中,不利于集中管理和调试。
2.2 系统级全局监听:PhysicsSystem2D.instance.on
这是更强大、更集中的管理方式。你可以在一个全局管理器(比如GameManager)或某个常驻节点脚本中,订阅物理系统发出的事件。
工作原理:PhysicsSystem2D是一个单例,它会在每一帧物理计算后,广播所有发生的碰撞事件。你只需要在一处监听这些事件,然后根据碰撞双方的标签(Tag)、分组(Group)或自定义属性进行分发和处理。
适用场景:
- 需要集中管理碰撞逻辑:比如一个“伤害计算系统”,所有攻击命中、子弹击中敌人都需要在这里统一处理伤害公式、暴击判断等。
- 对象数量庞大:比如有成百上千颗子弹和敌人,使用全局监听可以避免在每个子弹和敌人身上都挂载脚本,性能更优。
- 需要复杂的过滤和决策:比如“友军伤害是否开启”、“某种道具只对特定职业生效”等规则,在中心控制器里判断更清晰。
- 解耦需求强烈:你希望碰撞检测逻辑与具体的游戏对象实体分离,便于单元测试和模块替换。
优点:性能通常更好(尤其对象多时),逻辑集中,易于扩展和维护,解耦彻底。缺点:需要自己写事件分发逻辑,架构上稍复杂,对新手理解有一定门槛。
我的选择建议:对于中小型项目,可以从组件监听入手,快速实现功能。当项目规模扩大,或者你明确感觉到碰撞逻辑开始变得混乱时,应果断迁移到全局监听。在本次教程中,我会两种方式都详细实现,让你看到完整的对比。
3. 基础搭建:从零配置一个可碰撞的场景
理论说再多,不如动手做。我们创建一个最简单的场景:一个地面(静态),一个玩家方块(受重力下落),一个敌人方块(静态)。玩家落下后会与地面和敌人发生碰撞。
3.1 物理系统与碰撞分组配置
首先,确保物理系统已经启用并正确配置。
- 启用物理系统:打开
项目设置 -> 功能裁剪,确保物理系统(2D)和2D模块没有被裁剪掉。 - 配置物理世界:在
项目设置 -> 物理 -> 物理 2D中,设置重力。对于2D横版游戏,Y轴重力通常是负值,比如(0, -500)。允许休眠可以提升性能,但调试时可先关闭。 - 规划碰撞分组(Group):这是避免不必要碰撞、优化性能的关键。在
项目设置 -> 物理 -> 碰撞矩阵中,默认有DEFAULT分组。我们添加几个:PLAYER(分组索引 1)ENEMY(分组索引 2)GROUND(分组索引 3)BULLET(分组索引 4) 然后,在碰撞矩阵中勾选哪些分组之间需要检测碰撞。例如:PLAYER与ENEMY、GROUND需要碰撞。BULLET与ENEMY需要碰撞。BULLET与PLAYER、GROUND可能不需要(避免误伤和地面阻挡),所以不勾选。ENEMY与GROUND可能需要(如果敌人也站在地上)。 这个矩阵是性能优化的第一道关卡,务必根据游戏设计仔细规划。
3.2 创建节点与碰撞体组件
地面 (Ground):
- 创建一个空节点,重命名为
Ground。 - 添加
SpriteRenderer组件,选择一个矩形图片(如白色方块),并拉宽它,看起来像一条地面。 - 添加
BoxCollider2D组件。勾选Editing,在场景视图中将绿色线框调整到与地面精灵大小一致。 - 在
BoxCollider2D组件中,设置Group为刚才创建的GROUND。因为是静止的地面,我们不需要添加RigidBody2D组件。没有刚体的碰撞体是“静态碰撞体”,它不会移动,但可以与其他物体发生碰撞。
- 创建一个空节点,重命名为
玩家 (Player):
- 创建一个空节点,重命名为
Player。 - 添加
SpriteRenderer组件,选择一个不同颜色的方块。 - 添加
BoxCollider2D组件,调整大小匹配精灵。设置Group为PLAYER。 - 关键一步:添加
RigidBody2D组件。这是让物体参与物理运动(如重力、速度)的核心。保持其Type为Dynamic(动态刚体)。这样玩家就会受到重力下落。
- 创建一个空节点,重命名为
敌人 (Enemy):
- 类似地面,创建
Enemy节点,添加SpriteRenderer和BoxCollider2D,Group设为ENEMY。 - 为了简单,我们先不给他加
RigidBody2D,让它作为一个静态的障碍物。
- 类似地面,创建
现在运行场景,你会看到玩家方块自由落体,砸在地面上并停住。基础的物理碰撞已经生效了,但我们还“听”不到这次碰撞。
4. 实现组件级碰撞监听
让我们给玩家和敌人添加“耳朵”,让它们能听到碰撞事件。
4.1 为Player节点添加监听脚本
在Player节点上创建并挂载一个脚本,例如PlayerController.ts。
import { _decorator, Component, BoxCollider2D, Contact2DType, IPhysics2DContact, RigidBody2D, Vec2 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { // 可以通过属性检查器拖拽赋值,也可以代码获取 private _collider: BoxCollider2D | null = null; private _rigidBody: RigidBody2D | null = null; start() { // 获取组件 this._collider = this.getComponent(BoxCollider2D); this._rigidBody = this.getComponent(RigidBody2D); if (this._collider) { // 注册单个碰撞体的回调函数 this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this); this._collider.on(Contact2DType.END_CONTACT, this._onEndContact, this); // 如果需要持续接触的每一帧都回调,可以监听 STAY_CONTACT // this._collider.on(Contact2DType.STAY_CONTACT, this._onStayContact, this); } } onDestroy() { // 非常重要!组件销毁时务必取消注册,防止内存泄漏和报错 if (this._collider) { this._collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this); this._collider.off(Contact2DType.END_CONTACT, this._onEndContact, this); } } private _onBeginContact(selfCollider: BoxCollider2D, otherCollider: BoxCollider2D, contact: IPhysics2DContact | null) { // selfCollider: 本组件的碰撞体(即Player的碰撞体) // otherCollider: 与selfCollider发生碰撞的另一个碰撞体 console.log(`Player 开始接触: ${otherCollider.node.name}`); // 示例:如果碰到敌人,玩家被弹开 if (otherCollider.group === 2) { // ENEMY 分组索引是2 console.log('撞到敌人了!'); if (this._rigidBody) { // 施加一个向上的冲量 this._rigidBody.applyLinearImpulse(new Vec2(0, 500), this._rigidBody.getWorldCenter(), true); } } // 示例:根据Tag判断 // if (otherCollider.tag === 10) { ... } } private _onEndContact(selfCollider: BoxCollider2D, otherCollider: BoxCollider2D, contact: IPhysics2DContact | null) { console.log(`Player 结束接触: ${otherCollider.node.name}`); } // private _onStayContact(...) { ... } }关键点解析:
- 注册与反注册:监听器必须在
start或onEnable中注册,并在onDestroy或onDisable中取消。这是铁律,否则会导致幽灵回调或错误。 - 回调参数:
onBeginContact提供了三个参数,其中otherCollider包含了碰撞对象的所有信息,通过它的node、group、tag可以精确判断碰撞对象是谁。 - 物理操作时机:在碰撞回调函数内部,可以安全地修改刚体的速度、施加力或冲量,这是处理碰撞反馈(如反弹、击退)的标准做法。
4.2 为Enemy节点添加监听脚本
同理,给Enemy节点也加一个脚本EnemyController.ts,逻辑可以简单一些,比如打印日志。
import { _decorator, Component, BoxCollider2D, Contact2DType } from 'cc'; const { ccclass, property } = _decorator; @ccclass('EnemyController') export class EnemyController extends Component { start() { const collider = this.getComponent(BoxCollider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this._onHit, this); } } onDestroy() { const collider = this.getComponent(BoxCollider2D); if (collider) { collider.off(Contact2DType.BEGIN_CONTACT, this._onHit, this); } } private _onHit(selfCollider: BoxCollider2D, otherCollider: BoxCollider2D) { // 假设只有玩家的子弹(BULLET分组)或玩家本身能触发受伤 if (otherCollider.group === 1 || otherCollider.group === 4) { // PLAYER 或 BULLET console.log(`Enemy 被 ${otherCollider.node.name} 击中了!`); // 这里可以触发敌人受伤动画、减血、死亡等逻辑 // this.node.destroy(); // 例如直接销毁 } } }运行游戏,当玩家落到地面或碰到敌人时,控制台会打印出相应的日志,并且玩家碰到敌人时会有一个小小的反弹。组件级监听的基本流程就走通了。
5. 进阶实现:PhysicsSystem2D全局监听
现在我们来构建一个更优雅、更强大的全局碰撞管理系统。我们将创建一个PhysicsManager单例脚本,负责所有碰撞事件的接收与分发。
5.1 创建全局物理管理器
创建一个名为PhysicsManager.ts的脚本,它不挂载到任何场景节点,而是作为纯逻辑的单例。
import { _decorator, Component, PhysicsSystem2D, EPhysics2DDrawFlags, IPhysics2DContact, Collider2D } from 'cc'; import { EventTarget } from 'cc'; const { ccclass } = _decorator; // 定义一个自定义事件系统,用于分发碰撞事件 export const physicsEventTarget = new EventTarget(); // 定义事件名和事件数据接口,提高代码可读性和类型安全 export enum PhysicsEventType { PLAYER_HIT_ENEMY = 'player-hit-enemy', BULLET_HIT_ENEMY = 'bullet-hit-enemy', PLAYER_ON_GROUND = 'player-on-ground', // ... 可以定义更多事件 } export interface ICollisionEventData { selfCollider: Collider2D; otherCollider: Collider2D; contact?: IPhysics2DContact | null; } @ccclass('PhysicsManager') export class PhysicsManager extends Component { private static _instance: PhysicsManager = null!; public static get instance(): PhysicsManager { return PhysicsManager._instance; } // 可选:开启物理调试绘制,便于观察碰撞体形状 private _debugDraw: boolean = false; onLoad() { if (PhysicsManager._instance && PhysicsManager._instance !== this) { this.destroy(); return; } PhysicsManager._instance = this; // 防止切换场景时被销毁 // this.node.setParent(director.getScene()); // DontDestroyOnLoad(this.node); this._initPhysicsSystem(); } private _initPhysicsSystem() { // 开启物理系统调试绘制(可选,开发阶段非常有用) if (this._debugDraw) { PhysicsSystem2D.instance.debugDrawFlags = EPhysics2DDrawFlags.Aabb | EPhysics2DDrawFlags.Pair | EPhysics2DDrawFlags.CenterOfMass | EPhysics2DDrawFlags.Joint | EPhysics2DDrawFlags.Shape; } // 注册全局碰撞回调 if (PhysicsSystem2D.instance) { // 注意:这里注册的是物理系统的事件,不是碰撞体的事件 PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, this._onGlobalBeginContact, this); PhysicsSystem2D.instance.on(Contact2DType.END_CONTACT, this._onGlobalEndContact, this); // PhysicsSystem2D.instance.on(Contact2DType.PRE_SOLVE, this._onGlobalPreSolve, this); // PhysicsSystem2D.instance.on(Contact2DType.POST_SOLVE, this._onGlobalPostSolve, this); } } onDestroy() { if (PhysicsSystem2D.instance && PhysicsManager._instance === this) { PhysicsSystem2D.instance.off(Contact2DType.BEGIN_CONTACT, this._onGlobalBeginContact, this); PhysicsSystem2D.instance.off(Contact2DType.END_CONTACT, this._onGlobalEndContact, this); } PhysicsManager._instance = null!; } private _onGlobalBeginContact(colliderA: Collider2D, colliderB: Collider2D, contact: IPhysics2DContact | null) { // 所有碰撞事件都会进入这里 // colliderA 和 colliderB 是发生碰撞的两个碰撞体,顺序不确定。 // 1. 提取分组信息 const groupA = colliderA.group; const groupB = colliderB.group; // 2. 根据分组组合,分发到具体的游戏逻辑事件 // 示例:玩家(1) 碰到 敌人(2) if ((groupA === 1 && groupB === 2) || (groupA === 2 && groupB === 1)) { // 确定哪个是玩家,哪个是敌人 const playerCollider = groupA === 1 ? colliderA : colliderB; const enemyCollider = groupA === 2 ? colliderA : colliderB; console.log(`[全局监听] 玩家 ${playerCollider.node.name} 撞到敌人 ${enemyCollider.node.name}`); // 派发自定义事件,让游戏逻辑模块(如伤害系统、UI系统)去处理 physicsEventTarget.emit(PhysicsEventType.PLAYER_HIT_ENEMY, { selfCollider: playerCollider, otherCollider: enemyCollider, contact } as ICollisionEventData); } // 示例:子弹(4) 碰到 敌人(2) if ((groupA === 4 && groupB === 2) || (groupA === 2 && groupB === 4)) { const bulletCollider = groupA === 4 ? colliderA : colliderB; const enemyCollider = groupA === 2 ? colliderA : colliderB; console.log(`[全局监听] 子弹 ${bulletCollider.node.name} 击中敌人 ${enemyCollider.node.name}`); physicsEventTarget.emit(PhysicsEventType.BULLET_HIT_ENEMY, { selfCollider: bulletCollider, otherCollider: enemyCollider, contact } as ICollisionEventData); // 全局管理器可以直接处理一些通用逻辑,比如子弹命中后消失 // bulletCollider.node.destroy(); // 注意:在回调中直接销毁节点通常是安全的,但最好下一帧处理,避免迭代器问题。 // this.scheduleOnce(() => { bulletCollider.node.destroy(); }); } // 示例:玩家(1) 碰到 地面(3) -> 玩家落地 if ((groupA === 1 && groupB === 3) || (groupA === 3 && groupB === 1)) { const playerCollider = groupA === 1 ? colliderA : colliderB; console.log(`[全局监听] 玩家 ${playerCollider.node.name} 落地`); physicsEventTarget.emit(PhysicsEventType.PLAYER_ON_GROUND, { selfCollider: playerCollider, otherCollider: (groupA === 3 ? colliderA : colliderB), contact } as ICollisionEventData); } } private _onGlobalEndContact(colliderA: Collider2D, colliderB: Collider2D, contact: IPhysics2DContact | null) { // 处理接触结束的逻辑,比如玩家离开地面 const groupA = colliderA.group; const groupB = colliderB.group; if ((groupA === 1 && groupB === 3) || (groupA === 3 && groupB === 1)) { const playerCollider = groupA === 1 ? colliderA : colliderB; console.log(`[全局监听] 玩家 ${playerCollider.node.name} 离开地面`); } } }5.2 在场景中初始化管理器并消费事件
- 初始化管理器:在场景中创建一个空节点(如
GameManager),挂载PhysicsManager脚本。确保它在场景加载时最早运行(可以通过编辑器的执行顺序调整)。 - 消费事件:修改之前的
PlayerController和EnemyController,移除它们自身的碰撞监听注册代码(this._collider.on),改为监听全局管理器发出的事件。
例如,在EnemyController的start方法中:
start() { // 不再注册 collider.on // 改为监听全局事件 physicsEventTarget.on(PhysicsEventType.BULLET_HIT_ENEMY, this._onBulletHit, this); physicsEventTarget.on(PhysicsEventType.PLAYER_HIT_ENEMY, this._onPlayerHit, this); } onDestroy() { // 记得取消监听 physicsEventTarget.off(PhysicsEventType.BULLET_HIT_ENEMY, this._onBulletHit, this); physicsEventTarget.off(PhysicsEventType.PLAYER_HIT_ENEMY, this._onPlayerHit, this); } private _onBulletHit(data: ICollisionEventData) { // 判断被击中的敌人是不是自己 if (data.otherCollider.node === this.node) { // otherCollider 是敌人碰撞体 console.log(`Enemy(${this.node.name}) 被子弹击中,事件来自全局管理器`); // 处理受伤逻辑,比如减血 // this._takeDamage(data.selfCollider); // selfCollider 是子弹碰撞体 } } private _onPlayerHit(data: ICollisionEventData) { if (data.otherCollider.node === this.node) { console.log(`Enemy(${this.node.name}) 被玩家撞到`); } }全局监听的优势立刻体现:
- 逻辑集中:所有碰撞判断规则都在
PhysicsManager的_onGlobalBeginContact一个方法里,一目了然。 - 性能优化:
Enemy和Player脚本里没有了大量的on/off调用,每个对象只监听自己关心的一两个自定义事件。 - 高度解耦:
EnemyController不再需要知道碰撞是怎么检测的,它只关心“我被击中了”这个业务事件。伤害计算、音效播放、得分更新等模块都可以独立监听这个事件,互不干扰。 - 易于扩展:新增一种碰撞类型(如“道具碰到玩家”),只需在管理器中添加一个
if判断并派发新事件,所有相关系统就能自动响应。
6. 核心参数详解与高级配置
理解了基本流程后,我们来深入看看那些影响碰撞行为的“旋钮”该怎么调。
6.1 Collider2D 关键属性
- Sensor (感应器):勾选后,碰撞体变为“触发器”。物理引擎会检测到它与其他碰撞体的重叠/分离,并触发回调,但不会产生实际的物理碰撞效果(即不会推开物体)。常用于制作伤害区域、拾取区域、触发器机关等。
- Density (密度)、Friction (摩擦力)、Restitution (弹性系数):这三个属性只有在碰撞体附属于一个
RigidBody2D时才会生效。它们共同决定了物体的质量(密度 * 体积)和碰撞时的物理反应。调整它们可以模拟出木头、橡胶、钢铁等不同材质的感觉。Restitution为0表示完全非弹性碰撞(如橡皮泥),为1表示完全弹性碰撞(理想情况下的超级弹球)。通常设置在0到1之间。
- Offset (偏移):允许碰撞体形状相对于节点中心发生偏移。比如一个角色,碰撞体可能需要在脚部,而不是身体中心。
6.2 RigidBody2D 关键属性
- Type (类型):
Static:静态刚体,质量无限大,不受力影响,通常用于地面、墙壁。Kinematic:运动学刚体。不受力影响,但可以通过设置velocity(速度)来移动。常用于平台、移动的障碍物,或者需要完全由代码控制的角色(在一些不使用物理驱动移动的游戏中)。Dynamic:动态刚体。完全参与物理模拟,受重力、力、碰撞影响。大部分可互动的物体都用这个。Animated:动画刚体。是Kinematic的一个变种,其位置和旋转由动画系统驱动,但可以与Dynamic刚体发生碰撞并影响它们。常用于复杂的动画角色。
- Allow Sleep (允许休眠):如果开启,当一个动态刚体速度几乎为零且一段时间没有碰撞时,物理引擎会停止对其计算以节省性能。对于大量静止的物体(如掉落后堆在一起的箱子)很有用。但在需要持续检测的场景(如一个随时可能被推开的箱子)最好关闭。
- Fixed Rotation (固定旋转):防止刚体在碰撞中旋转。对于2D横版游戏的角色,通常需要勾选,防止角色被撞得东倒西歪。
6.3 碰撞过滤:Group 和 Mask
这是性能优化和逻辑控制的重中之重。前面我们在项目设置里配置了分组,但每个碰撞体还有两个相关属性:
- Group:这个碰撞体属于哪个分组。
- Mask (在Cocos Creator中通过碰撞矩阵隐式定义):这个碰撞体能与哪些分组发生碰撞。
工作原理:当两个碰撞体A和B可能碰撞时,物理引擎会检查:(A的Group 与 B的Mask) 有交集并且(B的Group 与 A的Mask) 有交集。两者都为真,才会进行昂贵的形状相交检测和回调触发。
最佳实践:
- 为所有游戏对象规划清晰的分组(如Player, Enemy, Bullet, Ground, Item, Trigger等)。
- 在
项目设置 -> 物理 -> 碰撞矩阵中,只勾选必要的交叉格。例如,Bullet和Bullet之间通常不需要碰撞检测,这能立刻减少大量的无效计算。 - 对于
Sensor类型的触发器,如果不需要物理响应,其分组设置可以更宽松,但逻辑过滤仍需在回调函数中通过group或tag进行。
7. 实战:构建一个完整的子弹射击碰撞案例
让我们用全局监听方案,实现一个玩家发射子弹击中敌人的完整例子。
创建子弹预制体 (BulletPrefab):
- 创建一个
Bullet节点,添加SpriteRenderer和CircleCollider2D(子弹用圆形更合适)。设置Group为BULLET。 - 添加
RigidBody2D,Type设为Dynamic。勾选Allow Sleep并设为false(子弹一直在飞,不应休眠),勾选Fixed Rotation。设置Gravity Scale为0(子弹通常不受重力影响)。 - 在
Bullet节点上挂载一个Bullet.ts脚本,主要控制移动和生命周期。
// Bullet.ts - 简化的子弹逻辑 import { _decorator, Component, RigidBody2D, Vec2, v2 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('Bullet') export class Bullet extends Component { @property public speed: number = 800; private _rigidBody: RigidBody2D | null = null; private _direction: Vec2 = v2(1, 0); // 默认向右 start() { this._rigidBody = this.getComponent(RigidBody2D); if (this._rigidBody) { this._rigidBody.linearVelocity = new Vec2(this._direction.x * this.speed, this._direction.y * this.speed); } // 3秒后自动销毁,防止堆积 this.scheduleOnce(() => { this.node.destroy(); }, 3); } public setDirection(dir: Vec2) { this._direction = dir.normalize(); } }- 创建一个
修改 PhysicsManager:我们已经在上一步的全局监听中处理了
BULLET_HIT_ENEMY事件。当事件触发时,我们可以在管理器中直接销毁子弹节点(或派发事件让其他系统处理)。// 在 _onGlobalBeginContact 的子弹击中敌人分支里 if ((groupA === 4 && groupB === 2) || (groupA === 2 && groupB === 4)) { // ... 派发事件 ... // 建议下一帧销毁,避免在当前物理回调迭代中修改节点树可能的问题 this.scheduleOnce(() => { bulletCollider.node.destroy(); }); }创建子弹管理器 (BulletManager):负责生成和回收子弹。可以使用对象池优化性能。
// BulletManager.ts import { _decorator, Component, Node, instantiate, Prefab, Vec3, v3 } from 'cc'; import { NodePool } from 'cc'; const { ccclass, property } = _decorator; @ccclass('BulletManager') export class BulletManager extends Component { @property(Prefab) bulletPrefab: Prefab | null = null; private _bulletPool: NodePool = new NodePool(); start() { // 初始化对象池 for (let i = 0; i < 20; ++i) { let bullet = instantiate(this.bulletPrefab!); this._bulletPool.put(bullet); } } public spawnBullet(startPos: Vec3, direction: Vec3) { let bullet: Node = null; if (this._bulletPool.size() > 0) { bullet = this._bulletPool.get(); } else { bullet = instantiate(this.bulletPrefab!); } // 初始化子弹位置、方向 bullet.setWorldPosition(startPos); bullet.parent = this.node; // 挂载到管理器节点下或场景根节点 const bulletComp = bullet.getComponent('Bullet') as any; // 获取脚本组件 if (bulletComp && bulletComp.setDirection) { bulletComp.setDirection(v3(direction.x, direction.y, 0)); } bullet.active = true; return bullet; } public recycleBullet(bullet: Node) { bullet.active = false; this._bulletPool.put(bullet); } }修改 PhysicsManager 以使用对象池:当子弹需要销毁时,通知
BulletManager回收。// 在PhysicsManager中 private _onGlobalBeginContact(...) { // ... 子弹击中敌人逻辑 ... // this.scheduleOnce(() => { bulletCollider.node.destroy(); }); // 改为回收 this.scheduleOnce(() => { // 假设你能访问到BulletManager实例 BulletManager.instance.recycleBullet(bulletCollider.node); }); }玩家射击:在
PlayerController中添加射击逻辑,调用BulletManager.instance.spawnBullet生成子弹。
至此,一个使用全局监听、对象池优化、分组过滤的完整2D碰撞射击系统就搭建完成了。这套架构清晰、高效,足以支撑中小型2D游戏的碰撞需求。
8. 避坑指南与性能优化
“鬼畜”抖动或穿透:
- 原因:通常是因为
RigidBody2D的速度过快,在一帧内穿过了另一个碰撞体。物理引擎的离散检测(Discrete Detection)可能捕捉不到。 - 解决:
- 启用连续碰撞检测 (CCD):在
RigidBody2D组件上,将Bullet属性设为true(对于高速子弹非常有效)。 - 增加物理更新频率:在
项目设置 -> 物理 -> 物理 2D中,减小Fixed Time Step(如从1/60改为1/120),但会增加CPU开销。 - 使用射线检测预判:对于特别重要的碰撞(如玩家与地面),可以在移动前先用
PhysicsSystem2D.raycast进行检测。
- 启用连续碰撞检测 (CCD):在
- 原因:通常是因为
回调函数内进行耗时操作:碰撞回调在物理线程中调用,如果在这里进行复杂的计算、加载资源、创建大量对象,会严重阻塞物理模拟,导致游戏卡顿。应将复杂的逻辑推迟到主线程更新循环中处理,例如使用
scheduleOnce或标记一个状态,在update里处理。内存泄漏:务必在
onDestroy或onDisable中取消事件监听,无论是组件监听(collider.off)还是全局监听(physicsEventTarget.off)。否则,节点销毁后,回调函数可能仍被引用,导致内存无法释放和潜在的错误。碰撞分组配置错误:这是最常见的问题之一。明明觉得应该碰撞的两个物体,却没有触发回调。第一件事就是去检查
项目设置中的碰撞矩阵,确保对应的分组交叉框被勾选上了。Sensor 与 非Sensor 的混淆:如果你希望两个物体“重叠”但不互相推开,只触发事件,那么至少其中一个的碰撞体需要勾选
Sensor。如果两个都是非Sensor,它们会尝试推开对方,这可能不是你想要的触发器效果。性能瓶颈:
- 控制碰撞体数量:每个活跃的
Dynamic刚体都会增加计算负担。对于静止的物体,使用Static类型。对于大量相同物体(如子弹),使用对象池。 - 简化碰撞形状:
PolygonCollider2D虽然精确,但比BoxCollider2D和CircleCollider2D更耗性能。尽量用简单的形状组合来近似复杂形状。 - 善用碰撞矩阵:这是最有效的优化手段,杜绝不必要的碰撞检测对。
- 控制碰撞体数量:每个活跃的
调试技巧:
- 开启
PhysicsSystem2D.instance.debugDrawFlags,可以在场景中直观地看到所有碰撞体的轮廓、AABB包围盒、刚体中心等,对于调整碰撞体位置和大小、排查碰撞问题有奇效。 - 在碰撞回调中打印详细的日志,包括碰撞双方的
name、group、tag,可以帮助你快速定位是哪个物体、哪种碰撞没有按预期触发。
- 开启
9. 常见问题排查速查表
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 碰撞没有触发任何回调 | 1. 碰撞分组未匹配。 2. 碰撞体未启用。 3. 脚本未正确注册监听。 | 1. 检查项目设置->物理->碰撞矩阵。2. 检查碰撞体组件 enabled是否为true。3. 在 start或onEnable中加断点或日志,确认on被调用。 |
| 物体直接穿过,没有碰撞效果 | 1. 速度过快(穿透)。 2. 至少一方是 Sensor。3. 刚体类型为 Kinematic且未处理碰撞。 | 1. 对高速物体启用Bullet(CCD)。2. 检查碰撞体 Sensor属性。3. Kinematic刚体需手动处理碰撞响应。 |
| 回调函数触发了,但物体表现异常(如抖动) | 1. 在回调中错误地修改了物理状态(如重复施加力)。 2. 碰撞体形状或位置有误。 | 1. 检查回调逻辑,避免每帧STAY_CONTACT时重复操作。2. 开启调试绘制,检查碰撞体是否与精灵匹配。 |
| 游戏卡顿,物体多时更明显 | 1. 动态刚体数量过多。 2. 碰撞矩阵配置太宽泛。 3. 在碰撞回调中进行了复杂操作。 | 1. 使用对象池,将静止物体设为Static。2. 收紧碰撞矩阵,取消不必要的检测。 3. 将回调中的复杂逻辑移到主线程。 |
| 节点销毁后报错(如读取null属性) | 事件监听未正确移除。 | 确保在onDestroy中调用collider.off或全局事件管理器的off。 |
onBeginContact被多次触发 | 同一对碰撞体在同一时间可能因多个接触点产生多次回调。 | 这是正常现象。如果逻辑上只需要一次,可以使用标志位(hasHandled)在第一次触发后忽略后续。 |
掌握2D碰撞监听,就像是拿到了构建游戏交互世界的钥匙。从简单的BoxCollider2D配置到复杂的PhysicsSystem2D全局事件管理,每一步都需要理解其背后的物理规则和引擎机制。我个人的经验是,在项目初期就采用全局监听架构,虽然前期会多花一点时间设计事件系统,但随着游戏逻辑的增长,你会发现这份投入在维护性和扩展性上带来了巨大的回报。最后,多利用调试绘制功能,它能让你“看见”碰撞的世界,很多问题就一目了然了。
