
1. 项目概述与核心价值在Cocos Creator中开发2D游戏物理碰撞检测是构建游戏交互逻辑的基石。无论是制作一个平台跳跃游戏里角色踩到怪物头顶还是在一个弹球游戏中处理球与挡板、砖块的碰撞亦或是RPG游戏中角色与NPC的触发对话其背后都离不开一套可靠、高效的碰撞监听系统。很多刚接触Cocos Creator 3.6的开发者尤其是从其他引擎转过来或者初次接触游戏物理的朋友常常会卡在“碰撞配置好了但为什么我的函数没被调用”这类问题上。这通常不是因为代码写错了而是对Cocos Creator 3.6中2D物理系统特别是BoxCollider2D与碰撞回调之间如何协同工作的机制理解不够透彻。今天我们就来彻底拆解这个问题。我将从一个空的场景开始手把手带你完成从添加BoxCollider2D碰撞体、配置物理分组到编写并注册四种不同类型的碰撞回调函数的全过程。我们不止步于“怎么做”更要深究“为什么这么做”比如为什么有时候需要RigidBody2D而有时候又不需要全局回调和单个碰撞体回调该如何选择以及回调函数里那个神秘的contact对象到底藏着哪些有用的信息。我会分享在实际项目中调试碰撞监听时踩过的坑和总结出的最佳实践目标是让你读完这篇教程后能独立、自信地处理项目中任何复杂的2D碰撞交互需求。2. 核心概念与系统配置2.1 物理系统选择Builtin vs. Box2D在Cocos Creator 3.6中2D物理引擎主要有两种选择Builtin内置和Box2D。理解它们的区别是正确使用碰撞监听的前提。Builtin物理系统是一个轻量级的、由Cocos Creator自己实现的物理模拟器。它的特点是简单、高效对于不需要复杂物理模拟如重力、关节、精确的动量传递的2D游戏来说完全够用比如一些点击交互、简单的触发区域检测等。它的碰撞检测是基于AABB轴对齐包围盒的计算速度快。但需要注意的是Builtin系统只支持BEGIN_CONTACT开始接触和END_CONTACT结束接触这两种碰撞回调。Box2D物理系统则是封装了著名的开源物理引擎Box2D。它提供了完整的刚体动力学模拟包括重力、密度、摩擦力、恢复系数弹性、关节等。如果你的游戏需要真实的物理效果比如物体下落、碰撞后弹开、复杂的受力分析那么Box2D是必选。Box2D支持全部四种碰撞回调BEGIN_CONTACT、END_CONTACT、PRE_SOLVE求解前和POST_SOLVE求解后这为我们提供了更精细的碰撞控制能力。实操心得对于绝大多数2D游戏如果你的碰撞只需要知道“碰上了”和“分开了”这两个事件Builtin系统因其轻量和高性能是首选。但如果你需要像“超级食肉男孩”那样精确的平台边缘判定或者“愤怒的小鸟”那样的物理破坏效果Box2D提供的PRE_SOLVE回调可以在物理引擎计算碰撞反馈前修改碰撞参数将是你的利器。如何选择和启用在Cocos Creator编辑器中打开项目设置 - 功能裁剪 - 物理。在这里你可以选择启用Builtin 2D或Box2D。重要提示两者可以同时启用但一个项目在同一时间只能激活其中一个作为当前的物理系统。你可以在代码中通过PhysicsSystem2D.instance.use来动态切换但这通常不推荐因为可能引发不可预知的状态问题。我的建议是在项目初期就根据需求确定好使用哪一个。2.2 BoxCollider2D 详解不止是矩形BoxCollider2D是2D游戏中最常用的碰撞体组件它定义一个轴对齐的矩形区域作为碰撞范围。虽然名字叫“Box”但它的配置选项决定了其行为的复杂性。在属性检查器中你会看到以下关键属性Offset偏移: 碰撞体相对于节点中心点的位置偏移。这非常有用比如你的角色精灵图脚部在图片下方你可以将碰撞体的y轴向下偏移使其更贴合角色的“脚底”。Size尺寸: 碰撞矩形的大小。这里有个常见坑点尺寸的单位是“世界单位”通常对应像素但受节点缩放影响。如果一个节点的scale是(2,2)那么Size为(50,50)的碰撞体在世界中的实际大小是(100,100)。Sensor传感器: 这是理解碰撞监听的关键。当勾选Sensor后该碰撞体将只触发碰撞事件而不产生物理反馈。也就是说物体会直接“穿过”它不会因为碰撞而被弹开或阻挡。这完美适用于触发器区域如宝物拾取点、存档点、剧情触发区域等。Density密度、Friction摩擦力、Restitution弹性系数: 这些是物理材质属性仅在附加了RigidBody2D组件且使用Box2D物理系统时生效。它们决定了物体在物理模拟中的行为。注意事项一个常见的误解是只要有BoxCollider2D就能收到碰撞回调。对于Box2D物理系统这是错误的要让Box2D的碰撞回调生效碰撞双方至少有一方必须附加RigidBody2D刚体组件。而Builtin系统则宽松得多只要双方都有碰撞体组件即可。2.3 物理分组与碰撞矩阵控制谁与谁碰撞游戏中有成千上万的物体我们不可能让所有物体都相互碰撞。比如子弹不应该打中友军玩家应该穿过某些特效。这就需要分组Group和碰撞矩阵Collision Matrix。分组Group: 在项目设置 - 物理 - 碰撞分组中你可以定义分组如DEFAULT、PLAYER、ENEMY、BULLET、ITEM等。每个碰撞体组件如BoxCollider2D的Group属性决定了它属于哪个分组。碰撞矩阵: 在同一个设置页面下方有一个矩阵表格。行和列都是你定义的分组。你可以通过勾选来决定哪个分组能与哪个分组发生碰撞。例如你可以设置PLAYER和ENEMY可以碰撞但BULLET和BULLET之间不能碰撞避免子弹互相抵消。配置逻辑规划分组根据游戏对象类型提前规划好分组列表。设置碰撞体为场景中的每个需要碰撞的对象设置其BoxCollider2D的Group属性。配置矩阵在项目设置中精确勾选需要的碰撞对。取消勾选意味着这两个分组的物体即使几何上重叠也不会触发任何碰撞事件包括回调。这个机制是性能优化和逻辑控制的重要手段。一个配置得当的碰撞矩阵可以大幅减少不必要的碰撞检测计算。3. 碰撞监听回调函数全解析3.1 回调的两种注册方式局部与全局在Cocos Creator中注册碰撞回调函数有两种主要方式它们适用于不同的场景。方式一针对单个碰撞体组件注册局部监听这种方式是最常用、最直观的。你直接在你关心的那个节点比如玩家角色的脚本中获取它的Collider2D组件BoxCollider2D是其子类然后为其注册监听。import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { start() { // 获取当前节点上的碰撞体组件 let collider this.getComponent(Collider2D); if (collider) { // 注册开始接触回调 collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); // 注册结束接触回调 collider.on(Contact2DType.END_CONTACT, this.onEndContact, this); // 如果是Box2D还可以注册以下两种 // collider.on(Contact2DType.PRE_SOLVE, this.onPreSolve, this); // collider.on(Contact2DType.POST_SOLVE, this.onPostSolve, this); } } onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { console.log(玩家撞到了: ${otherCollider.node.name}); } onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { console.log(玩家离开了: ${otherCollider.node.name}); } }优点逻辑清晰回调函数直接与特定的游戏对象绑定易于管理。selfCollider就是当前脚本所在的碰撞体otherCollider是与之发生碰撞的另一个碰撞体。方式二通过物理系统注册全局回调你可以直接监听整个物理系统的事件任何两个碰撞体之间的碰撞都会触发这个回调。start() { if (PhysicsSystem2D.instance) { PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, this.onGlobalBeginContact, this); } } onGlobalBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // 注意这里的selfCollider和otherCollider是发生碰撞的任意双方没有特定的“自我”概念。 console.log(全局碰撞: ${selfCollider.node.name} 与 ${otherCollider.node.name} 发生接触); }优点可以集中处理所有碰撞逻辑例如统一播放碰撞音效、进行伤害计算的总调度等。缺点需要在回调内部通过分组或节点名称、标签等信息来筛选你关心的碰撞对逻辑可能变得复杂。性能上如果场景中碰撞频繁全局回调可能被频繁调用。如何选择我的经验是优先使用针对单个碰撞体的局部监听。这符合组件化设计思想每个对象处理自己的碰撞逻辑。全局监听更适合做全局性的、与具体对象无关的碰撞响应如全局音效管理器或者在某些调试场合下使用。3.2 四大回调类型详解与调用时机理解每种回调的触发时机是写出正确碰撞逻辑的关键。我们假设有两个刚体A和B它们从分离状态开始相互靠近、接触、重叠、再分离。BEGIN_CONTACT开始接触触发时机当两个碰撞体的边界第一次发生重叠的那一帧。注意是“第一次”整个接触过程只触发一次。典型用途触发一次性事件。例如玩家碰到金币Sensor类型的碰撞体时播放拾取音效、增加分数、然后销毁金币节点。或者角色踩到敌人头顶时触发敌人的死亡动画。END_CONTACT结束接触触发时机当两个碰撞体的边界完全分离不再重叠的那一帧。同样整个分离过程只触发一次。典型用途清理状态或触发离开事件。例如玩家离开安全区域时屏幕上显示“危险”警告或者当两个磁铁分开时结束它们之间的吸引力计算。PRE_SOLVE求解前-仅Box2D支持触发时机在每一帧的物理更新中当两个碰撞体处于重叠状态时在物理引擎计算碰撞响应如弹开、摩擦力之前被调用。这意味着它会在接触持续的每一帧都被调用。典型用途动态修改碰撞属性。这是它最强大的地方。例如你可以在这里根据碰撞点的相对速度动态修改本次碰撞的弹性restitution或摩擦力friction实现“踩到冰面摩擦力变小”的效果。你也可以在这里通过contact.disabled true来临时禁用本次碰撞的物理响应但回调依然会触发实现“穿墙”作弊模式。POST_SOLVE求解后-仅Box2D支持触发时机在每一帧的物理更新中当两个碰撞体处于重叠状态时在物理引擎计算完碰撞响应之后被调用。典型用途获取碰撞的详细信息特别是碰撞产生的冲量impulse。例如你可以根据冲量的大小来判断这是一次“轻微刮蹭”还是一次“猛烈撞击”从而播放不同强度的撞击动画或音效。重要区别BEGIN/END_CONTACT是事件型的代表状态的变化从无到有从有到无。而PRE/POST_SOLVE是过程型的代表状态持续期间的每一帧处理。一个生动的类比BEGIN_CONTACT好比两个人第一次握手说“你好”END_CONTACT是最后挥手说“再见”而PRE/POST_SOLVE则是握手期间每一秒的力度和感觉。3.3 回调参数深度解读self, other 与 contact每个回调函数都接收三个参数(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null)。selfCollider: 对于局部监听这就是你注册监听的那个碰撞体组件。对于全局监听它是发生碰撞的双方中的任意一个与注册顺序无关你需要自行判断它是谁。otherCollider: 与selfCollider发生碰撞的另一个碰撞体。contact: 这是碰撞信息对象是宝藏所在。对于Builtin物理系统此参数为null这是很多新手困惑的地方。对于Box2D它包含了本次碰撞的几何和物理信息。IPhysics2DContact核心方法解析contact对象最重要的方法是getWorldManifold()它返回一个worldManifold对象包含 *points:世界坐标系下的碰撞点数组。大多数2D碰撞尤其是AABB碰撞通常只有一个碰撞点。对于复杂形状的碰撞可能有两个点比如两个矩形的角对撞。这是获取碰撞发生位置的关键*normal:世界坐标系下的法线向量。这个向量从selfCollider指向otherCollider并且是单位向量长度为1。它指示了将两个物体分开的“最短路径”方向。例如如果一个球从上方落到地板上法线向量大致是(0, 1)指向上方。如何利用这些信息假设你在做一个打砖块游戏球selfCollider撞到砖块otherCollider。onPostSolve(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { if (!contact) return; // Builtin系统下为null需判断 const worldManifold contact.getWorldManifold(); const collisionPoint worldManifold.points[0]; // 获取碰撞点世界坐标 const collisionNormal worldManifold.normal; // 获取碰撞法线 // 1. 在碰撞点生成一个特效比如火花 this.spawnEffectAtPosition(collisionPoint); // 2. 根据法线决定砖块的破碎方向如果法线朝左砖块可能向右飞溅 if (collisionNormal.x 0.7) { // 主要来自左侧的撞击 this.breakBrickToRight(otherCollider.node); } // 3. 计算相对速度需要获取刚体组件 const rigidBodySelf selfCollider.node.getComponent(RigidBody2D); const rigidBodyOther otherCollider.node.getComponent(RigidBody2D); if (rigidBodySelf rigidBodyOther) { // 注意getLinearVelocityFromWorldPoint 需要传入一个世界坐标点 const velSelf rigidBodySelf.getLinearVelocityFromWorldPoint(collisionPoint); const velOther rigidBodyOther.getLinearVelocityFromWorldPoint(collisionPoint); const relativeVel velSelf.subtract(velOther); const impactSpeed relativeVel.length(); console.log(撞击速度: ${impactSpeed}); } }4. 完整实战构建一个碰撞交互Demo让我们通过一个具体的例子将上述所有知识点串联起来。我们将创建一个简单的场景一个玩家方块Player可以左右移动一些地面Ground一些敌人方块Enemy碰到会受伤以及一些金币Coin传感器碰到会收集。4.1 场景与组件配置创建节点与组件:Player节点: 添加Sprite组件贴图添加RigidBody2D组件类型设为Dynamic添加BoxCollider2D组件调整Size匹配精灵Group设为PLAYER。Ground节点: 添加Sprite组件添加BoxCollider2D组件Group设为GROUND。通常地面是静态的所以不需要RigidBody2D在Box2D中静态物体可以没有刚体或者将刚体类型设为Static。Enemy节点: 类似Player添加RigidBody2DDynamic和BoxCollider2DGroup设为ENEMY。Coin节点: 添加Sprite组件添加BoxCollider2D组件务必勾选SensorGroup设为ITEM。作为传感器它不需要RigidBody2D。配置碰撞矩阵项目设置:确保PLAYER与GROUND、ENEMY、ITEM可以碰撞。确保ENEMY与GROUND可以碰撞敌人也会掉在地上。ITEM之间不需要碰撞可以取消勾选ITEM-ITEM。4.2 编写玩家控制与碰撞脚本在Player节点上挂载以下脚本PlayerController.tsimport { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact, Input, input, KeyCode, RigidBody2D, Vec2 } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { // 移动速度 property moveSpeed: number 200; // 跳跃力 property jumpForce: number 300; private _rigidBody: RigidBody2D | null null; private _isOnGround: boolean false; start() { // 获取刚体组件用于移动 this._rigidBody this.getComponent(RigidBody2D); // 注册键盘输入 input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); // 注册碰撞监听局部监听 let collider this.getComponent(Collider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); collider.on(Contact2DType.END_CONTACT, this.onEndContact, this); // 如果是Box2D可以加上PRE_SOLVE和POST_SOLVE用于高级控制 // collider.on(Contact2DType.PRE_SOLVE, this.onPreSolve, this); // collider.on(Contact2DType.POST_SOLVE, this.onPostSolve, this); } } onKeyDown(event: any) { switch(event.keyCode) { case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: this.moveLeft(); break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: this.moveRight(); break; case KeyCode.SPACE: case KeyCode.KEY_W: case KeyCode.ARROW_UP: this.jump(); break; } } moveLeft() { if (this._rigidBody) { // 设置线性速度y轴保持原有速度以实现重力效果 this._rigidBody.linearVelocity new Vec2(-this.moveSpeed, this._rigidBody.linearVelocity.y); } } moveRight() { if (this._rigidBody) { this._rigidBody.linearVelocity new Vec2(this.moveSpeed, this._rigidBody.linearVelocity.y); } } jump() { // 只有在地面上才能跳 if (this._isOnGround this._rigidBody) { this._rigidBody.applyLinearImpulseToCenter(new Vec2(0, this.jumpForce), true); this._isOnGround false; } } onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { const otherGroup otherCollider.group; switch(otherGroup) { case 1 1: // 假设GROUND分组的索引是1这里需要根据你的项目设置调整 this._isOnGround true; console.log(玩家落地); break; case 1 2: // ENEMY分组 console.log(玩家碰到敌人扣血); // 这里触发扣血逻辑比如调用GameManager break; case 1 3: // ITEM分组 (Coin) console.log(玩家吃到金币); // 销毁金币节点 otherCollider.node.destroy(); // 增加分数 break; } } onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { const otherGroup otherCollider.group; if (otherGroup (1 1)) { // GROUND // 注意离开地面不一定意味着在空中可能只是从一块地面走到另一块。 // 更精确的接地检测通常需要射线检测或记录接触地面的碰撞体数量。 // 这里简化处理在真正做平台游戏时需要更复杂的逻辑。 console.log(玩家离开地面); } } // 如果是Box2D可以添加以下函数进行更精细的控制 /* onPreSolve(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // 例如如果玩家处于无敌状态则禁用与敌人的碰撞物理响应 if (otherCollider.group (1 2) this.isInvincible) { if (contact) { contact.disabled true; } } } onPostSolve(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // 可以在这里获取碰撞冲量判断撞击力度 } */ onDestroy() { // 记得移除输入监听防止内存泄漏 input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } }4.3 敌人与金币脚本Enemy节点可以挂载一个简单脚本用于检测与玩家的碰撞从敌人视角// EnemyController.ts onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D) { if (otherCollider.group this.playerGroup) { // 假设你定义了playerGroup // 敌人碰到玩家敌人自身也可能有反应比如播放受击动画 this.playHitAnimation(); } }Coin节点作为传感器只需要一个简单的脚本用于被玩家触发后销毁自身如上例中在Player脚本里处理或者也可以在自己的脚本里监听BEGIN_CONTACT然后销毁自己并通知游戏管理器加分。5. 高级技巧与性能优化5.1 精确的接地检测上面的示例中接地检测_isOnGround是非常简化的。在实际平台跳跃游戏中你需要更可靠的方法。一个常见的做法是在玩家底部设置一个或多个“脚底传感器”BoxCollider2D并设为Sensor。在Player节点下创建一个子节点比如FootSensor。为FootSensor添加一个BoxCollider2D尺寸较小仅覆盖脚底区域并勾选Sensor。将其分组设为PLAYER_FOOT新建一个分组。在碰撞矩阵中设置PLAYER_FOOT只与GROUND分组碰撞。在Player脚本中为这个FootSensor的碰撞体单独注册BEGIN_CONTACT和END_CONTACT监听。使用一个计数器_groundContactCount来记录当前脚底传感器与地面碰撞体的接触数量。当BEGIN_CONTACT时计数器1END_CONTACT时-1。当_groundContactCount 0时玩家才被认为在地面上。这种方法避免了从一块地面边缘走到另一块时瞬间被判定为“离开地面”的问题。5.2 使用节点分组或标签进行快速筛选在碰撞回调中我们经常需要判断otherCollider是什么类型的对象。除了检查collider.group物理分组还可以利用节点的**分组Group或标签Tag**属性。分组Node Group: 在编辑器中为节点设置分组如Player,Enemy,Prop。在脚本中可以通过otherCollider.node.group获取。标签Tag: Cocos Creator 3.6支持为节点添加标签。你可以定义如Player、Enemy、Coin等标签。在回调中通过otherCollider.node.tag判断。这两种方式比比较节点名称otherCollider.node.name更高效、更灵活尤其是当你有大量同类对象时。5.3 性能注意事项避免在碰撞回调中进行耗时操作碰撞回调在物理更新线程中调用频率可能很高每帧多次。避免在这里进行复杂的计算、实例化大量对象如特效、同步网络请求等。如果必须做可以考虑将事件信息存储到一个队列中在主循环的update里进行消费。合理使用Sensor对于只触发事件而不需要物理反馈的物体如触发器、收集物务必勾选Sensor。这能显著减少物理引擎的计算量。优化碰撞体形状和数量能用矩形BoxCollider2D就不用多边形PolygonCollider2D后者计算更复杂。同时避免使用过于复杂的复合碰撞体。对于不需要碰撞的物体及时移除碰撞体组件。谨慎使用全局监听PhysicsSystem2D.instance.on会监听场景中所有碰撞。如果场景中物体很多每一对碰撞都会触发回调可能成为性能瓶颈。尽量使用针对特定碰撞体的局部监听。5.4 常见问题排查清单问题碰撞回调函数根本没有被调用。检查1Box2D碰撞双方是否至少有一方有RigidBody2D组件刚体类型是否不是Static或KinematicDynamic和Kinematic都可以。检查2碰撞双方的Group是否在项目设置的碰撞矩阵中被勾选为“可以碰撞”检查3碰撞体BoxCollider2D的Size是否设置正确是否因为节点缩放导致实际碰撞区域太小或位置不对可以在编辑器中开启物理调试绘制Gizmos - Physics 2D来可视化碰撞体。检查4脚本是否正确挂载并启用start或onEnable方法中的on监听注册是否成功执行检查5Builtin系统你是否尝试监听了PRE_SOLVE或POST_SOLVEBuiltin不支持它们。问题BEGIN_CONTACT被多次调用而不是一次。原因这通常发生在两个物体持续接触并发生微小抖动或多次物理步进内反复进入/退出接触状态时。物理引擎的离散检测可能导致这种情况。解决在回调函数内部使用一个标志位hasCollided来确保逻辑只执行一次直到END_CONTACT被调用后再重置标志位。问题在contact中获取的worldManifold.points或normal感觉不对。原因points是碰撞点但对于快速移动的物体或某些碰撞类型如两个快速移动的小物体边缘擦过碰撞点可能不在你直观认为的“接触面”上。normal是从selfCollider指向otherCollider的分离方向不一定是碰撞面的法线。调试在回调中打印出这些向量的值并结合物理调试绘制来理解。对于方向判断有时计算两个碰撞体中心点的向量差otherCollider.node.worldPosition.subtract(selfCollider.node.worldPosition)可能更直观。问题物体直接穿过了另一个物体没有触发任何回调。原因隧道效应当物体移动速度过快时在一帧内从碰撞体的一侧完全穿越到了另一侧物理引擎的离散检测可能会错过这次碰撞。解决降低物体的最大移动速度。使用连续碰撞检测CCD。在RigidBody2D组件上将Bullet属性设为true仅Box2D支持。这会显著增加计算开销只对少数高速运动的物体如子弹使用。在代码中进行基于射线或形状投射PhysicsSystem2D.instance.raycast的预测性检测。通过这篇从配置到实战的详尽指南你应该已经对Cocos Creator 3.6的2D碰撞监听有了全面而深入的理解。记住理解原理为什么和掌握工具怎么做同样重要。现在就去你的项目中实践这些知识构建出生动而可靠的游戏交互吧。如果在实践中遇到新的问题不妨回头再看看contact对象里还有什么宝藏没有被挖掘或者思考一下是否可以通过PRE_SOLVE来创造更巧妙的游戏机制。