Cocos Creator 3.6 2D碰撞监听避坑指南:Sensor原理与实战
1. 项目概述为什么我们需要这份避坑指南在Cocos Creator 3.6中开发2D游戏尤其是涉及物理交互时碰撞检测是绕不开的核心功能。无论是制作一个简单的平台跳跃游戏还是一个复杂的物理解谜游戏你都需要依赖BoxCollider2D、CircleCollider2D、PolygonCollider2D这些组件来定义物体的“物理边界”。而Sensor传感器属性则是实现“触发区域”而非“物理阻挡”的关键。听起来很简单对吧但实际开发中我见过太多开发者在这里栽跟头。新手常犯的错误是以为给节点挂上碰撞体脚本里写上onBeginContact监听一切就水到渠成。结果运行时发现碰撞要么不触发要么触发得乱七八糟角色卡在墙里或者触发区域毫无反应。更头疼的是当你把Sensor勾选上期望它只触发事件而不产生物理阻挡时却发现角色依然被“无形的墙”挡住或者物理反馈变得诡异。这些问题往往不是引擎的Bug而是对碰撞系统的工作原理理解不透彻以及对一些关键属性和设置组合使用不当造成的。这份指南的目的就是把我自己以及团队在多个2D项目实战中积累的、关于Cocos Creator 3.6 2D碰撞监听的经验和教训系统性地梳理出来。我们将从最基础的BoxCollider2D组件属性讲起一直深入到Sensor模式的正确使用场景、与RigidBody2D的配合、以及如何高效可靠地监听碰撞事件。我会重点解释那些官方文档可能一笔带过但实际开发中至关重要的“为什么”并分享一系列可以直接“抄作业”的避坑技巧。无论你是刚接触Cocos Creator 2D物理的新手还是已经踩过一些坑想寻求更优解的开发者相信这篇指南都能让你对碰撞系统有全新的、更深刻的认识。2. 核心组件深度解析BoxCollider2D与它的伙伴们在开始监听碰撞之前我们必须先理解我们正在操作的对象。Cocos Creator 3.6的2D物理系统基于Box2D这是一个久经考验的2D物理引擎。我们添加的每一个碰撞体组件最终都会在底层转换为Box2D的b2Fixture对象。理解这一点对后续排查问题至关重要。2.1 BoxCollider2D不只是个矩形框BoxCollider2D是最常用的碰撞体它代表一个轴对齐的矩形区域。在属性检查器中它的核心属性远不止一个Size。关键属性拆解Size大小定义了碰撞矩形的宽和高。这里第一个坑是Size的单位是“世界单位”World Unit而不是像素。如果你的Sprite图片是100x100像素你希望碰撞体和图片一样大那么Size应该设置为多少这取决于你的“像素-世界单位”换算关系。在Cocos Creator中默认情况下一个世界单位对应设计分辨率下的一个像素具体取决于Canvas的配置。但更可靠的做法是通过脚本根据Sprite的UITransform或Sprite组件的实际尺寸来动态设置Size以确保精确匹配。Offset偏移这个属性极其有用但常被忽略。它定义了碰撞体中心点相对于节点中心点的偏移。默认是(0,0)即碰撞体中心与节点中心重合。当你需要碰撞体与节点的视觉表现如Sprite不完全对齐时就要用到它。例如一个角色的脚底碰撞体通常需要向下偏移。Density密度、Friction摩擦力、Restitution弹性系数这三个属性仅在碰撞体附着在RigidBody2D刚体组件上时才生效。它们共同决定了物体的物理特性。Density密度与碰撞体的Size或Radius一起计算刚体的质量Mass。质量 密度 * 面积。如果你希望一个物体更“重”、更难被推动就增大它的密度。Friction摩擦力范围0~1。影响物体在接触面上滑动时的阻力。0像冰面1像粗糙的水泥地。两个接触物体的摩擦力会通过某种公式如取平均值或最小值综合计算。Restitution弹性范围0~1。0表示完全非弹性碰撞如橡皮泥1表示完全弹性碰撞理想情况会永远弹跳。它决定了碰撞后能量损失的程度。避坑提示1很多开发者发现设置了Friction和Restitution却没效果首要原因就是该碰撞体所在的节点没有RigidBody2D组件。没有刚体碰撞体就是一个静态的“地形”它的密度、摩擦、弹性属性不会被物理引擎用于动力学计算。2.2 其他碰撞体CircleCollider2D与PolygonCollider2DCircleCollider2D圆形碰撞体属性很简单主要是一个Radius半径。它适用于球类、圆形角色等。同样需要注意Offset来调整圆心位置。PolygonCollider2D多边形碰撞体功能最强大也最复杂。它允许你通过一系列顶点Points来定义任意凸多边形碰撞区域。这对于匹配不规则形状的精灵如一个倾斜的岩石、一个三角形陷阱非常有用。PolygonCollider2D的巨坑凸多边形限制与自动三角剖分Box2D的b2Fixture只支持凸多边形。如果你在Points里定义了一个凹多边形Cocos Creator的物理系统实际上是底层的Box2D会**自动将其分割成多个凸多边形通常是三角形**来近似表示。这个过程对开发者是透明的但会带来一个隐蔽的问题一个PolygonCollider2D组件在底层可能对应多个b2Fixture。这意味着什么当你进行射线检测Raycast并设置检测类型为All时一条射线穿过这个凹多边形碰撞体可能会报告多次命中因为它穿过了多个三角剖分后的小凸多边形。同样在复杂形状下碰撞回调的逻辑可能需要额外处理。虽然对于大多数简单的碰撞检测和响应来说引擎已经处理得很好但如果你在做精确的射线拾取或需要知道具体碰撞到哪个“部分”时就必须意识到这一点。编辑技巧在场景编辑器中选中PolygonCollider2D并勾选Editing你可以直观地拖拽顶点来编辑形状。按住Alt键拖拽可以保持形状中心不变。对于Sprite组件点击Regenerate Points按钮可以基于精灵的透明度阈值Threshold自动生成一个轮廓多边形这是快速匹配精灵形状的神器但生成的顶点数可能较多需酌情简化以优化性能。2.3 碰撞体的通用属性Tag, Group, Sensor这三个属性是所有类型碰撞体共有的是配置碰撞行为的关键。Tag标签一个数字标识。在碰撞回调函数中你可以通过contact.collider.tag来获取碰到的是哪个物体。这是区分碰撞对象类型最直接的方式。例如你可以定义Tag.Player 1,Tag.Enemy 2,Tag.Coin 3。在监听器里通过判断tag来执行不同逻辑玩家碰到金币加分碰到敌人扣血。Group分组这是一个更强大的碰撞过滤机制。它本身不直接定义谁能碰谁而是需要与**碰撞矩阵Collision Matrix**配合使用。你可以在项目设置 - 物理 - 碰撞矩阵中配置不同分组之间是否能够发生碰撞。例如你可以设置“玩家子弹”分组和“玩家”分组不相撞避免误伤自己但“玩家子弹”和“敌人”分组可以相撞。分组是位掩码Bit Mask这意味着一个碰撞体可以属于多个分组通过位运算设置从而实现复杂的碰撞关系。Sensor传感器这是本指南的重中之重。勾选后该碰撞体就变成了一个“触发器”。它不会产生任何物理阻挡效果即物体可以穿透它但会正常触发碰撞回调事件onBeginContact,onEndContact。典型用途检测区域如陷阱触发范围、金币收集区域、剧情触发点、技能攻击范围、非物理性的交互物体如穿过一个会加血的泉水。核心误区Sensor不参与物理求解。这意味着即使一个高速运动的刚体穿过Sensor也不会像碰到普通碰撞体那样被减速、反弹或停止。它只是“感知”到有物体进入或离开。3. 物理世界的基石RigidBody2D与碰撞监听碰撞体定义了形状但要让它们“动起来”并相互影响或者让我们的代码知道它们何时接触就需要RigidBody2D刚体组件和事件监听。3.1 RigidBody2D赋予物体物理灵魂RigidBody2D组件为节点赋予了物理属性如质量、速度、角速度、阻尼等。它决定了物体如何响应力和碰撞。类型TypeStatic静态刚体。质量无限大不会因受力而运动。通常用于地面、墙壁等固定不动的物体。静态刚体之间永远不会发生碰撞。Dynamic动态刚体。完全受物理引擎控制会受力、会碰撞、会运动。你的玩家、敌人、可移动箱子通常用这个。Kinematic运动学刚体。你可以通过代码直接设置其位置或速度但它不会受重力等力的影响。它可以推动Dynamic刚体但不会被Dynamic刚体推动。常用于平台、电梯或者需要精确控制移动的敌人避免物理引擎的抖动。开启碰撞监听这是最关键的一步在RigidBody2D组件的属性中有一个Enabled Contact Listener复选框。你必须勾选它该刚体及其上的所有碰撞体才会触发碰撞回调事件。这是新手最常忽略的一点导致写了半天监听函数却毫无反应。3.2 碰撞监听的回调函数当两个都开启了Enabled Contact Listener的刚体发生碰撞时引擎会回调一系列函数。我们需要在挂载了刚体组件的节点上编写脚本并实现这些回调。主要回调函数onBeginContact当两个碰撞体开始接触的瞬间调用。只调用一次。onEndContact当两个碰撞体结束接触分离的瞬间调用。只调用一次。onPreSolve在物理引擎求解本次碰撞的接触点之前调用。每一帧可能调用多次如果接触持续。你可以在这里修改碰撞信息如禁用某个方向的碰撞。onPostSolve在物理引擎求解完本次碰撞后调用。你可以在这里获取碰撞产生的冲量Impulse信息。对于大多数游戏逻辑如拾取物品、触发陷阱、受到伤害我们只需要用到onBeginContact和onEndContact。回调函数的基本结构import { _decorator, Component, Collider2D, IPhysics2DContact } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { // 假设这个脚本挂在玩家角色带有RigidBody2D的节点上 onBeginContact (selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // selfCollider: 属于本节点的碰撞体玩家可能有多个碰撞体如身体和脚部传感器 // otherCollider: 与之发生碰撞的另一个碰撞体 // contact: 碰撞信息对象包含法线、切线等有时可能为null例如Sensor之间的接触 console.log(碰到了: ${otherCollider.node.name}); // 通过Tag判断 if (otherCollider.tag 3) { // 假设3是金币Tag this.pickCoin(otherCollider.node); } } onEndContact (selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { console.log(离开了: ${otherCollider.node.name}); } pickCoin(coinNode: Node) { // 加分、播放音效、销毁节点等 coinNode.destroy(); } }4. Sensor的正确用法与高级避坑技巧现在我们进入最核心的部分如何正确使用Sensor以及如何避免与之相关的各种坑。4.1 何时使用Sensor经典场景剖析场景一角色脚下的地面检测用于跳跃判定这是一个经典用法。你通常不会希望角色的脚部碰撞体把角色“推”离地面因为跳跃和下落是由速度/重力控制的。你只需要知道“是否在地面上”。做法在角色脚部例如从角色中心向下偏移一些的位置添加一个细长的BoxCollider2D并勾选Sensor。同时地面的碰撞体是普通的非Sensor。逻辑在脚本的onBeginContact和onEndContact中通过otherCollider的Tag判断是否是地面并维护一个isOnGround的布尔值。当isOnGround为true时才允许执行跳跃操作。避坑确保脚部Sensor的Group与地面的Group在碰撞矩阵中是允许碰撞的。并且角色主刚体身体的碰撞体不要与地面发生阻挡否则角色会浮空或卡住。通常通过分组或设置身体碰撞体为Sensor如果不需要物理阻挡来实现。场景二非物理性的触发区域例如一个宝箱的开启区域、一个存档点、一个剧情对话触发点。做法在触发区域节点上添加一个BoxCollider2D并勾选Sensor。这个节点不需要RigidBody2D组件因为它自己不动。但是想要触发它的物体如玩家必须有RigidBody2D且开启Enabled Contact Listener。逻辑在触发区域节点的脚本里监听onBeginContact。当玩家进入时显示对话或开启宝箱。避坑如果触发区域也需要移动比如一个移动的伤害区域那么它也需要一个RigidBody2D并且类型通常设为Kinematic以便通过代码控制移动同时保持Sensor属性。场景三攻击判定框对于近战攻击你可以在武器挥动的轨迹上放置一个Sensor碰撞体。做法攻击时通过代码动态激活collider.enabled true这个Sensor碰撞体持续几帧后关闭。Sensor的Group设置为与敌人分组可碰撞与玩家分组不碰撞。逻辑在Sensor的onBeginContact中判断碰到的如果是敌人通过Tag或Group则调用敌人的受伤函数。由于是Sensor它不会阻挡武器的运动也不会把敌人推开除非你在受伤逻辑里主动添加力。避坑注意一帧内多次触发。如果攻击Sensor在一帧内与同一个敌人碰撞体持续接触onBeginContact可能只调用一次。但如果Sensor快速扫过敌人可能触发一次。为了确保一次攻击只造成一次伤害通常需要在攻击逻辑中维护一个“已命中目标列表”在攻击结束后清空。4.2 Sensor与普通碰撞体的混合使用层级与顺序问题一个刚体节点可以挂载多个碰撞体组件。例如一个玩家角色可能有一个主要的BoxCollider2D非Sensor用于与墙壁、平台的物理阻挡。一个脚部的BoxCollider2DSensor用于地面检测。一个身体周围的CircleCollider2DSensor用于拾取物品。这里有一个重要的原则物理引擎处理碰撞时并不区分Sensor和非Sensor的先后顺序它只关心碰撞对pair中是否有Sensor。如果碰撞对中至少有一个是Sensor则只触发回调不产生物理冲量。如果碰撞对中两者都不是Sensor则既触发回调又产生物理冲量阻挡、反弹等。因此在上面的例子中玩家的脚部Sensor碰到地面非Sensor会触发onBeginContact但不会产生推力。玩家的身体非Sensor碰撞体碰到墙壁非Sensor会触发回调同时被墙壁挡住。4.3 常见坑点与解决方案实录坑点1Sensor勾选了但物体还是被挡住了排查步骤确认双方检查发生碰撞的两个碰撞体。是不是只把其中一个设成了Sensor如果另一个是普通碰撞体且附着在动态刚体上物理阻挡依然会发生因为碰撞对中有一个非Sensor。对于触发区域通常需要双方都是Sensor或者区域是Sensor而进入的物体是非Sensor但你不关心它的物理阻挡比如玩家他的阻挡由其他碰撞体处理。检查刚体类型如果Sensor所在的节点有RigidBody2D并且类型是Dynamic它仍然会受到重力等力的影响而下落。如果你希望一个触发区域悬浮在空中静止不动应该不添加刚体或者添加刚体并设置为Static或Kinematic。检查碰撞分组确认两个碰撞体的Group在项目设置的碰撞矩阵中允许碰撞。如果不允许连onBeginContact都不会触发。坑点2onBeginContact/onEndContact不触发排查清单刚体监听开关这是最最最常见的原因请确保发生碰撞的两个刚体节点上的RigidBody2D组件其Enabled Contact Listener属性都已勾选。少一个都不行脚本挂载与函数名确保脚本挂载在拥有刚体的节点上并且函数名拼写完全正确onBeginContact,onEndContact参数数量和类型也要正确。碰撞分组双方分组必须允许碰撞碰撞矩阵对应位置打勾。至少一方非静态两个Static类型的刚体之间永远不会产生碰撞事件。节点或组件未激活检查节点和碰撞体组件的enabled属性是否为true。坑点3Sensor的回调中contact参数为null现象在onBeginContact中第三个参数contact有时是null。原因根据Box2D的设计和Cocos Creator的封装当碰撞对中至少有一个是Sensor时contact参数可能为null。因为Sensor不产生物理接触点Manifold所以没有详细的接触信息如法线、穿透深度。解决方案如果你的逻辑不需要contact信息比如只是判断“进入了某个区域”那么直接忽略它即可。如果需要法线等信息那么这种碰撞场景可能不适合用Sensor或者你需要通过其他方式如计算两个节点的位置来推断。坑点4一帧内多次回调与性能问题当两个物体复杂交错或者有多个碰撞体时一帧内可能触发大量碰撞回调影响性能。优化精简碰撞体形状用简单的Box或Circle代替复杂的Polygon。合并逻辑在onBeginContact中避免做耗时的操作如查找节点、复杂计算。可以将碰撞信息如otherCollider.tag存入一个数组在update或lateUpdate中统一处理。使用分组过滤善用碰撞矩阵让不必要的物体之间根本不会进行碰撞检测这是最有效的优化。5. 实战构建一个完整的2D角色碰撞系统让我们通过一个具体的例子将上述所有知识串联起来。我们要创建一个2D平台跳跃角色他拥有身体碰撞体与墙壁、平台物理阻挡。脚部传感器检测是否在地面允许跳跃。头部传感器检测是否顶到天花板防止连跳时“粘”在天花板上。侧边传感器可选用于检测是否贴墙实现蹬墙跳。5.1 节点与组件结构Player (节点挂载RigidBody2D, typeDynamic, 开启Enabled Contact Listener) ├── Sprite (视觉表现) ├── BodyCollider (BoxCollider2D, 非Sensor, GroupPlayer) ├── FootSensor (BoxCollider2D, Sensortrue, GroupPlayer) └── HeadSensor (BoxCollider2D, Sensortrue, GroupPlayer)在项目设置的碰撞矩阵中确保Player分组与Ground地面、Wall墙壁分组允许碰撞。5.2 核心脚本实现import { _decorator, Component, RigidBody2D, Collider2D, Vec2, Input, input, KeyCode } from cc; const { ccclass, property } _decorator; // 定义标签常量避免魔法数字 enum Tag { GROUND 1, WALL 2, ENEMY 3, } ccclass(PlayerController) export class PlayerController extends Component { // 属性绑定 property(RigidBody2D) rigidBody: RigidBody2D | null null; property(Collider2D) footSensor: Collider2D | null null; // 在编辑器中拖拽赋值 property(Collider2D) headSensor: Collider2D | null null; // 角色状态 private _isOnGround: boolean false; private _isOnCeiling: boolean false; private _moveSpeed: number 200; private _jumpForce: number 350; // 接触到的地面/天花板碰撞体集合用于处理多个接触点 private _groundContacts: SetCollider2D new Set(); private _ceilingContacts: SetCollider2D new Set(); start() { if (this.rigidBody) { this.rigidBody.enabledContactListener true; // 确保开启 } // 初始化输入监听 input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D) { // 判断是哪个传感器触发的碰撞 if (selfCollider this.footSensor otherCollider.tag Tag.GROUND) { this._groundContacts.add(otherCollider); this._isOnGround this._groundContacts.size 0; // console.log(脚部接触地面); } if (selfCollider this.headSensor otherCollider.tag Tag.GROUND) { this._ceilingContacts.add(otherCollider); this._isOnCeiling this._ceilingContacts.size 0; // console.log(头部接触天花板); } // 可以在这里添加碰到敌人等的逻辑 if (otherCollider.tag Tag.ENEMY) { this.takeDamage(); } } onEndContact(selfCollider: Collider2D, otherCollider: Collider2D) { if (selfCollider this.footSensor otherCollider.tag Tag.GROUND) { this._groundContacts.delete(otherCollider); this._isOnGround this._groundContacts.size 0; // console.log(脚部离开地面); } if (selfCollider this.headSensor otherCollider.tag Tag.GROUND) { this._ceilingContacts.delete(otherCollider); this._isOnCeiling this._ceilingContacts.size 0; // console.log(头部离开天花板); } } onKeyDown(event: any) { switch(event.keyCode) { case KeyCode.SPACE: this.jump(); break; case KeyCode.KEY_A: this.moveLeft(); break; case KeyCode.KEY_D: this.moveRight(); break; } } jump() { // 只有在地面且不顶头时才允许跳跃 if (this._isOnGround !this._isOnCeiling this.rigidBody) { let velocity this.rigidBody.linearVelocity; velocity.y this._jumpForce; // 直接设置Y轴速度实现跳跃 this.rigidBody.linearVelocity velocity; // 或者使用 applyForce但直接设置速度更直接 // this.rigidBody.applyForceToCenter(new Vec2(0, this.jumpForce), true); } } moveLeft() { if (this.rigidBody) { let velocity this.rigidBody.linearVelocity; velocity.x -this._moveSpeed; this.rigidBody.linearVelocity velocity; } } moveRight() { if (this.rigidBody) { let velocity this.rigidBody.linearVelocity; velocity.x this._moveSpeed; this.rigidBody.linearVelocity velocity; } } takeDamage() { // 受伤逻辑例如减少生命值、播放动画、击退等 console.log(Player takes damage!); // 简单的击退效果 if (this.rigidBody) { this.rigidBody.applyLinearImpulse(new Vec2(-50, 100), this.rigidBody.getWorldCenter(), true); } } update(deltaTime: number) { // 可以在update中处理持续的逻辑例如根据输入持续移动 // 但注意直接每帧设置速度可能会与物理引擎冲突更好的做法是在FixedUpdate中处理 // 对于简单的键盘控制也可以在keyDown/keyUp中设置一个状态标志在这里应用力 } onDestroy() { // 记得移除输入监听 input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } }5.3 关键实现细节与避坑总结使用Set管理多个接触点角色可能同时站在多个地面瓦片上。用Set来存储所有当前接触的地面碰撞体只有当Set为空时_isOnGround才为false。这比简单的布尔标志更可靠避免了因为一个传感器同时接触/离开多个地面块而导致的状态错误。跳跃逻辑我们直接修改刚体的线性速度来实现跳跃这比施加力更直接、响应更快。同时我们检查了_isOnCeiling防止角色顶着头时还能起跳这会导致角色被卡住。移动逻辑同样通过直接设置速度来实现。对于更复杂的移动如加速度、摩擦应该在fixedUpdate中使用applyForce。受伤击退展示了如何在碰撞回调中应用冲量applyLinearImpulse来实现击退效果。注意冲量是瞬间的力适合表现打击感。性能与内存在onDestroy中移除事件监听是良好习惯。Set的使用也避免了数组的频繁增删操作。通过这样一个完整的例子你应该能够清晰地看到BoxCollider2D、Sensor、RigidBody2D以及碰撞监听是如何协同工作构建出一个响应灵敏、逻辑严谨的2D角色控制器的。记住理解每个组件和参数背后的物理意义是避开所有坑洞、实现精准控制的不二法门。在实际项目中你可能还需要处理更复杂的情况比如斜坡、移动平台、单向平台等但掌握了这些基础知识你就有能力去研究和实现任何复杂的碰撞交互了。