1. 项目概述:为什么2D碰撞监听是游戏开发的核心技能?
如果你正在用CocosCreator做2D游戏,无论是休闲小游戏还是更复杂的平台跳跃、弹幕射击,碰撞检测绝对是绕不开的核心功能。想象一下,你的角色碰到敌人要掉血,子弹击中目标要爆炸,玩家捡到金币要加分——所有这些交互的底层逻辑,都依赖于一套可靠、高效的碰撞监听系统。我见过不少新手开发者,把精灵节点拖到场景里,加上碰撞体组件,以为万事大吉,结果运行时要么检测不到碰撞,要么回调函数死活不触发,调试起来一头雾水。
这个教程要解决的,就是让你彻底搞懂CocosCreator 3.6版本中,2D碰撞监听从配置到代码的完整链路。我们会聚焦于最基础也最常用的BoxCollider2D矩形碰撞体,因为它简单、高效,能满足大部分2D游戏的碰撞需求。整个过程就像搭积木:先正确地把“碰撞积木”(碰撞体)摆好、设置好属性,再给它们装上“感应器”(回调函数),最后让它们之间能“对话”(处理碰撞逻辑)。听起来简单,但魔鬼全在细节里。比如,为什么我的碰撞体明明重叠了却没有触发回调?onBeginContact和onEndContact到底该用哪个?回调函数里的参数contact到底包含了什么信息?这些坑,我都踩过,今天就把最清晰的路径画给你看。
2. 核心组件拆解:BoxCollider2D的配置艺术
在CocosCreator里,BoxCollider2D是2D物理系统的基础构件。它不是一个视觉组件,而是一个无形的、用于物理计算的区域。配置它,远不止调整大小和位置那么简单,你需要理解每个参数背后的物理意义和游戏逻辑影响。
2.1 添加与基础属性设置
首先,你需要在需要进行碰撞检测的节点上添加BoxCollider2D组件。在属性检查器中点击“添加组件” -> “Physics 2D” -> “Box Collider 2D”即可。添加后,你会看到几个关键属性:
- Offset(偏移): 这是碰撞体相对于节点自身锚点的偏移量。默认是(0, 0),意味着碰撞体中心和节点中心重合。如果你的角色图片脚部在中心,但你想让碰撞体从脚底开始(更符合地面碰撞的直觉),就可以将Offset的y值设为负值。一个常见的技巧是:将精灵的锚点设置在脚部(如(0.5, 0)),然后碰撞体Offset保持(0,0),这样碰撞体就会自然以脚部为基准,简化后续的位置计算。
- Size(尺寸): 定义碰撞体矩形区域的大小。这里有个极易出错的地方:Size的单位是“世界单位”,通常对应你场景中的米(如果使用了物理系统)。而你的精灵纹理大小单位可能是像素。你需要根据你的设计分辨率(Design Resolution)和
cc.view的缩放策略,手动计算或调试出一个合适的Size值。一个实用的方法是:先在场景编辑器中粗略调整Size,然后在游戏运行时,开启物理调试绘制(后面会讲),观察碰撞框是否与精灵视觉轮廓匹配。 - Tag(标签): 一个整数标识符。这是用于在代码中快速识别碰撞体类型的利器。比如,你可以定义
Tag.Player = 1,Tag.Enemy = 2,Tag.Bullet = 3,Tag.Coin = 4。在回调函数中,通过contact.tag就能立刻知道撞到的是什么,无需通过节点名去字符串比较,效率高且不易出错。 - Group(分组): 属于物理碰撞分组。这是控制“谁和谁可以碰撞”的核心机制。CocosCreator的2D物理引擎(通常是Builtin或Box2D)通过碰撞矩阵(Collision Matrix)来管理。在项目设置 -> 物理 -> 碰撞矩阵中,你可以定义不同分组之间是否检测碰撞(
collide)以及是否检测触发器(sensor)。必须牢记的规则是:两个碰撞体之间要产生碰撞回调,必须同时满足:1. 它们都启用了碰撞检测;2. 在碰撞矩阵中,它们对应的分组之间的“碰撞”复选框被勾选。
2.2 高级属性:Sensor、Density、Friction与Restitution
对于更复杂的物理模拟,你还需要了解这些属性:
- Sensor(传感器): 这是区分“物理碰撞”和“逻辑触发”的关键。当勾选
Sensor后,该碰撞体将不会产生物理反馈(即不会推开其他刚体),但依然会触发碰撞回调函数。这完美适用于收集品(金币、血包)、陷阱区域、剧情触发点等。你希望玩家“穿过”它但能收到一个“捡到东西”的事件。配置心得:对于纯逻辑交互的物体,务必勾选Sensor,可以避免不必要的物理计算干扰角色移动。 - Density(密度)、Friction(摩擦力)、Restitution(弹性系数): 这三个属性只有在碰撞体所在的节点同时拥有
RigidBody2D(2D刚体)组件时才会真正生效。它们共同决定了物体在物理引擎中的运动特性。密度影响质量,摩擦力影响滑动,弹性系数影响反弹。对于平台跳跃游戏,调整地面的摩擦力和角色的弹性系数能极大地影响手感。一个实用建议:在项目初期,可以为所有动态物体(玩家、敌人)添加RigidBody2D并设置为Dynamic类型,静态物体(地面、墙壁)设置为Static类型,然后通过微调这三个参数来获得满意的物理手感。如果游戏不需要复杂的物理运动(如纯逻辑的ARPG),则可以不用刚体,完全通过代码控制移动,碰撞体只负责检测。
2.3 可视化调试:让碰撞体“现形”
调试碰撞体是开发中的高频操作。CocosCreator提供了强大的调试绘制功能。在项目设置 -> 物理中,找到调试绘制(Debug Draw)选项。通常建议勾选“形状”(Shape),这样在游戏运行时,所有碰撞体都会以绿色的线框显示出来。你可以在代码中通过cc.director.getPhysicsManager().enabled = true;来确保物理系统启用。通过调试视图,你可以一目了然地看到碰撞体的实际位置、大小,以及它们是否发生了重叠,这是排查“为何碰撞没触发”问题的第一步,也是最有效的一步。
3. 物理世界与碰撞回调的桥梁搭建
配置好碰撞体只是准备好了“硬件”,要让碰撞事件被我们的游戏逻辑感知,就需要建立“软件”连接——即注册碰撞回调函数。这个过程需要理解CocosCreator 2D物理事件的生命周期。
3.1 碰撞回调的三种类型
CocosCreator的2D物理系统主要提供了三种碰撞回调,它们在不同的碰撞阶段被触发:
onBeginContact(碰撞开始): 当两个碰撞体首次发生接触(即边缘开始重叠)的下一帧,此回调会被触发。这是最常用的回调,用于处理碰撞发生的瞬时逻辑,如玩家受伤、子弹命中、拾取物品。
onBeginContact(contact: cc.PhysicsContact, selfCollider: cc.BoxCollider2D, otherCollider: cc.BoxCollider2D) { // contact: 包含碰撞点、法向量等详细信息的接触对象 // selfCollider: 当前脚本所在节点的碰撞体 // otherCollider: 与之发生碰撞的另一个碰撞体 }onEndContact(碰撞结束): 当两个碰撞体分离(即不再重叠)的下一帧,此回调会被触发。适用于需要知道接触结束的场景,比如玩家离开地面(判断是否可跳跃)、离开危险区域。
onEndContact(contact: cc.PhysicsContact, selfCollider: cc.BoxCollider2D, otherCollider: cc.BoxCollider2D) { // 参数与onBeginContact一致 }onPreSolve(接触求解前)与onPostSolve(接触求解后): 这两个是更底层的回调,在物理引擎计算碰撞响应(如弹开、摩擦)前后触发。
onPreSolve中甚至可以动态修改碰撞属性(如摩擦力),onPostSolve中可以获取到碰撞产生的冲量信息。对于大多数游戏逻辑而言,onBeginContact和onEndContact已经足够,除非你需要实现非常特殊的物理效果。
关键区别与选择:onBeginContact和onEndContact是成对出现的。一次完整的接触(从重叠到分离)会触发一次Begin和一次End。如果你的逻辑只关心“碰到那一刻”,就用onBeginContact;如果还需要知道“什么时候离开”,就需要同时使用onEndContact。务必注意,它们都是在物理步骤之后,渲染步骤之前被调用的。
3.2 注册回调函数的正确姿势
要让这些回调函数生效,你必须确保以下几点:
- 脚本挂载: 包含这些回调函数的TypeScript脚本,必须挂载在拥有
BoxCollider2D组件的节点上,或者该节点的父节点上(CocosCreator的事件是沿节点树向上冒泡的,但通常建议直接挂在碰撞体所在节点,逻辑更清晰)。 - 函数名严格一致: 函数名必须一字不差地写成
onBeginContact、onEndContact等。这是引擎内部通过反射机制来调用的,大小写错误或拼写错误都会导致回调无法触发。 - 参数类型声明: 建议明确写出参数类型(
cc.PhysicsContact,cc.BoxCollider2D),这有助于TypeScript提供代码提示,也能让代码意图更清晰。
一个常见的初始化陷阱: 有时你需要在onLoad或start生命周期里根据业务逻辑动态启用/禁用碰撞检测。请注意,cc.Collider2D组件有一个enabled属性。如果你在onLoad中将其设为false,那么在该帧,碰撞体不会被注册到物理世界,自然也无法触发任何回调。正确的做法是,如果需要在运行时控制,确保在合适的时机(如角色复活时)再将其设为true。
4. 实战:编写健壮的碰撞处理逻辑
理论说再多,不如一行代码。让我们通过几个典型的游戏场景,来编写实实在在的碰撞处理回调函数。
4.1 场景一:玩家与金币(Sensor触发器)
假设玩家碰撞体Tag为1,金币碰撞体Tag为2,且金币的Sensor属性为true。
玩家角色脚本片段:
// PlayerCtrl.ts import { _decorator, Component, Node, BoxCollider2D, PhysicsContact } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerCtrl') export class PlayerCtrl extends Component { @property public score: number = 0; onBeginContact(contact: PhysicsContact, selfCollider: BoxCollider2D, otherCollider: BoxCollider2D) { // 1. 通过Tag进行快速筛选 if (otherCollider.tag === 2) { // 撞到的是金币 // 2. 获取金币节点,进行逻辑处理 const coinNode = otherCollider.node; // 增加分数 this.score += 100; console.log(`当前分数: ${this.score}`); // 播放金币收集音效(假设有AudioSource组件) // this.node.getComponent(cc.AudioSource).play(); // 3. 禁用金币的碰撞体和渲染,或直接销毁节点 // 方案A:禁用(可用于对象池回收) otherCollider.enabled = false; coinNode.active = false; // 方案B:直接销毁(简单直接) // coinNode.destroy(); // 注意:如果销毁,otherCollider将变为null,后续不能再使用 } // 可以继续用else if判断其他Tag,如敌人、陷阱等 // else if (otherCollider.tag === 3) { ... } } }关键点:
- 使用
Tag进行第一时间筛选,效率高。 - 对于
Sensor类型的金币,我们只关心onBeginContact。因为玩家会“穿过”金币,不会产生onEndContact(除非你设计了特殊的离开逻辑)。 - 处理完碰撞逻辑后,立即将金币“消失”。通常不是立即
destroy(),而是先active = false,以便放入对象池复用,这对性能优化至关重要。
4.2 场景二:子弹与敌人(非Sensor物理碰撞)
假设子弹(动态刚体)Tag为3,敌人(可能是动态或静态刚体)Tag为4。两者Sensor均为false,会发生物理碰撞。
子弹脚本片段:
// Bullet.ts import { _decorator, Component, Node, BoxCollider2D, PhysicsContact, RigidBody2D, Vec2 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('Bullet') export class Bullet extends Component { @property public damage: number = 10; onBeginContact(contact: PhysicsContact, selfCollider: BoxCollider2D, otherCollider: BoxCollider2D) { if (otherCollider.tag === 4) { // 击中敌人 // 1. 对敌人造成伤害 const enemy = otherCollider.node.getComponent('Enemy'); // 获取敌人逻辑脚本 if (enemy) { enemy.takeDamage(this.damage); } // 2. 播放击中特效(例如,在碰撞点生成一个火花动画) this.playHitEffect(contact.getWorldManifold().points[0]); // 获取第一个碰撞点 // 3. 子弹自身逻辑:销毁或反弹等 // 对于普通子弹,通常直接销毁 this.node.destroy(); // 重要:对于会发生物理碰撞的物体,有时需要禁用碰撞响应 // 可以在销毁前,将刚体类型改为Kinematic或禁用碰撞体,避免销毁过程中的意外碰撞 const rb = this.node.getComponent(RigidBody2D); if (rb) { rb.type = RigidBody2D.Type.Kinematic; } selfCollider.enabled = false; } else if (otherCollider.tag === 5) { // 击中墙壁 // 子弹击中墙壁,可能播放不同特效,或者反弹 // this.handleWallHit(contact); } // 注意:如果子弹同时击中了多个敌人(在一帧内),此回调可能会被调用多次 } playHitEffect(worldPos: Vec2) { // 实例化一个预制体,设置位置,播放动画 // const effect = cc.instantiate(this.hitEffectPrefab); // effect.setPosition(worldPos); // effect.parent = this.node.parent; // effect.getComponent(cc.Animation).play(); } }关键点:
- 非Sensor碰撞会产生物理效果。子弹可能被弹开,这取决于刚体的属性。如果你希望子弹击中后立即消失,需要在回调中立即处理(如销毁),并可能需要临时修改刚体或碰撞体属性,以防止在销毁前的一瞬间物理引擎仍然计算其碰撞响应,导致奇怪的现象(如敌人被死掉的子弹推开一下)。
contact.getWorldManifold().points可以获取到世界坐标系下的碰撞点数组,这对于在精确位置生成击中特效非常有用。- 注意一帧多碰的情况。如果子弹速度极快,一帧内穿过了多个敌人,
onBeginContact可能会被顺序调用多次。确保你的伤害逻辑和销毁逻辑能正确处理这种情况(比如用一个_isHit标志位防止重复计算伤害)。
4.3 场景三:玩家与地面(持续接触检测)
对于平台跳跃游戏,我们需要知道玩家是否“站在地面上”,以决定是否可以起跳。这需要用到onBeginContact和onEndContact的组合。
玩家角色脚本(补充地面检测逻辑):
// PlayerCtrl.ts (补充地面检测部分) export class PlayerCtrl extends Component { // ... 其他属性 ... private _groundContacts: number = 0; // 记录与地面碰撞体的接触数量 onBeginContact(contact: PhysicsContact, selfCollider: BoxCollider2D, otherCollider: BoxCollider2D) { // ... 处理金币、敌人等逻辑 ... // 地面检测 if (otherCollider.group === PhysicsGroup.GROUND) { // 假设地面分组为GROUND this._groundContacts++; this.updateGroundedState(); } } onEndContact(contact: PhysicsContact, selfCollider: BoxCollider2D, otherCollider: BoxCollider2D) { if (otherCollider.group === PhysicsGroup.GROUND) { this._groundContacts--; this.updateGroundedState(); } } private updateGroundedState() { const wasGrounded = this.isGrounded; this.isGrounded = this._groundContacts > 0; // 如果刚从空中落到地面,可以触发落地特效或音效 if (!wasGrounded && this.isGrounded) { this.playLandingEffect(); } } // 在跳跃函数中检查 jump() { if (this.isGrounded) { // 执行跳跃逻辑... } } }关键点:
- 使用一个计数器
_groundContacts来管理同时与多个地面物体的接触(比如角色同时踩在两块砖上)。只有当计数器大于0时,才认为角色在地面上。 onBeginContact增加计数,onEndContact减少计数。这种模式非常稳健,能处理复杂的接触情况。- 在
updateGroundedState中判断状态变化,可以精准地在“刚落地”或“刚离地”的瞬间执行特定逻辑。
5. 深度排查:碰撞不触发的十大“坑”与解决方案
即使按照教程一步步做,碰撞回调有时还是会“沉默”。下面是我总结的常见问题清单,你可以像查手册一样对照排查。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 运行时完全无碰撞回调 | 1. 物理系统未启用。 2. 脚本未挂载或函数名错误。 3. 碰撞体 enabled为false。 | 1. 在main.ts或游戏初始化代码中,确保cc.director.getPhysicsManager().enabled = true;。2. 检查脚本是否挂载到正确节点,检查 onBeginContact等函数名拼写和大小写是否完全正确。3. 检查属性检查器或代码中,碰撞体的 enabled属性是否为true。 |
| 特定物体之间无碰撞 | 1. 碰撞分组设置错误。 2. 其中一方是 Sensor但分组未勾选“传感器”检测。 | 1. 打开项目设置 -> 物理 -> 碰撞矩阵,确认两个碰撞体所属分组对应的行和列,“碰撞”复选框是否勾选。 2. 如果一方是 Sensor,除了“碰撞”要勾选,“传感器”也需要勾选,否则不会触发回调。 |
| 回调只触发一次,或时有时无 | 1. 在回调中销毁了自身或对方节点,影响了后续接触。 2. 物理刚体速度过快导致“隧道效应”。 | 1. 检查回调函数逻辑,是否在第一次碰撞后就将collider.enabled设为false或销毁了节点。如果需要持续碰撞,避免过早禁用。2. 对于高速运动的物体(如子弹),增大碰撞体尺寸,或使用连续碰撞检测(CCD)。在 RigidBody2D组件上设置bullet属性为true(注意性能开销)。 |
onEndContact不触发 | 1. 在onBeginContact中销毁了节点。2. 碰撞体在接触结束前被禁用或销毁。 | onEndContact是成对出现的。如果碰撞体A和B接触后,A在onBeginContact中被立即销毁,那么A就不存在了,自然不会触发onEndContact。如果业务需要onEndContact,请确保碰撞体在接触期间持续存在。 |
回调函数中的otherCollider为null | 在回调被调用前,otherCollider所在的节点已被销毁。 | 这是一种常见的异步问题。在回调中操作otherCollider.node之前,务必先检查otherCollider和otherCollider.node的有效性。if (otherCollider && otherCollider.node && otherCollider.node.isValid) { ... }。 |
| 碰撞体位置/大小与预期不符 | 1.Offset和Size设置错误。2. 父级节点缩放影响。 | 1. 开启物理调试绘制,在游戏运行时观察绿色线框。调整Offset和Size直到匹配视觉。2. 注意,碰撞体的 Size受节点自身缩放影响,但不受父节点缩放影响(除非碰撞体组件设置了applyScale)。检查节点及其父链的缩放值是否为(1,1)。 |
| 多个碰撞体在同一节点,回调混乱 | 一个节点挂了多个BoxCollider2D,回调无法区分。 | 在回调函数中,selfCollider参数指向的是具体触发回调的那个碰撞体组件。你可以通过selfCollider === this.getComponent(cc.BoxCollider2D)来判断是哪个碰撞体,或者给不同的碰撞体设置不同的Tag。 |
| 性能问题,回调卡顿 | 1. 回调函数内逻辑过于复杂或每帧调用过多。 2. 物理世界内碰撞体过多。 | 1. 优化回调函数:避免在回调中进行复杂的查找、实例化或循环操作。必要时将逻辑延迟到update中处理。2. 使用碰撞分组,让不必要的物体之间不检测碰撞。对不再需要的碰撞体,及时 enabled = false或从父节点移除。 |
contact信息获取不到或不准 | contact对象只在当前回调帧有效。 | cc.PhysicsContact对象及其方法(如getWorldManifold())返回的数据,仅在本次回调函数执行期间是有效的。不要存储contact对象或其返回的Vec2数组以备后用。如果需要碰撞点信息,应该立即拷贝出来:let point = contact.getWorldManifold().points[0].clone();。 |
| 编辑器预览正常,真机/打包后异常 | 1. 项目物理设置(如重力、缩放)在真机与编辑器不同。 2. 真机性能差异导致物理步长不同。 | 1. 确认项目设置 -> 物理中的参数(如重力、速度迭代次数等)在打包后保持一致。检查是否在代码中动态修改了这些全局设置。 2. 对于对物理模拟一致性要求高的游戏,考虑使用固定时间步长(Fixed Timestep)进行物理更新,可以在项目物理设置中配置。 |
6. 性能优化与高级技巧
当游戏中的碰撞体越来越多时,性能就会成为瓶颈。除了上面提到的使用分组过滤和及时禁用碰撞体外,还有几个进阶技巧。
1. 使用简化碰撞体(Proxy Collider)对于形状复杂的精灵(比如一个不规则的角色),用一个大的BoxCollider2D包裹整个身体可能不够精确,用多个小盒子又太耗性能。一个折中的方案是:为角色创建几个关键的、大小不一的BoxCollider2D,分别代表身体、攻击范围、受击区域等。在代码中通过不同的Tag或分组来区分它们。这样既保证了检测精度,又控制了碰撞体数量。
2. 动态启用/禁用碰撞检测对于远离屏幕、暂时不参与游戏的物体(如关卡后段的敌人、背景装饰物),可以将其碰撞体的enabled设为false。你可以根据物体与摄像机(主角)的距离,或者在特定的游戏阶段(如Boss战)来动态管理。这能显著减少物理引擎需要处理的碰撞对数量。
3. 理解物理步长与更新频率物理世界的更新(包括碰撞检测和回调触发)默认每帧进行一次,频率与游戏帧率相同。如果游戏卡顿导致帧率下降,物理更新也会变慢,这可能导致高速物体更容易穿过障碍物(隧道效应)。在项目设置 -> 物理中,可以设置固定时间步长(Fixed Timestep),例如0.016667秒(对应60FPS)。这样物理引擎会以固定频率更新,与渲染帧率解耦,使物理模拟更加稳定,但会增加CPU负担。对于动作要求精确的2D游戏,开启固定步长是值得的。
4. 利用碰撞矩阵进行精细控制碰撞矩阵是你管理碰撞关系的总开关。不要只使用默认的“default”分组。为你的游戏对象创建逻辑清晰的分组,如Player,Enemy,PlayerBullet,EnemyBullet,Ground,Item等。然后在矩阵中精确配置:玩家的子弹只与敌人碰撞,敌人的子弹只与玩家碰撞,玩家和敌人都与地面碰撞,物品与所有角色碰撞但都是传感器模式。这种配置方式从引擎底层避免了不必要的碰撞计算,是最高效的优化手段之一。
最后,关于网络热词中提到的“麻将 cocoscreator”,这很可能指的是用CocosCreator开发麻将游戏。在麻将游戏中,2D碰撞检测的应用可能不那么典型,但依然有用武之地。例如,检测玩家点击(拖拽)牌张(可以用带碰撞体的牌节点结合鼠标事件),或者判断牌张是否被放入有效的出牌区、吃碰杠区域(这些区域可以用隐藏的、带Sensor的碰撞体来定义)。其核心原理与本教程完全一致:配置碰撞体作为感应区域,在回调函数中处理“牌张进入区域”这一逻辑事件。