前阵子用 Cocos Creator 3.8 重做以前一个 2D 横版 Demo,主角要能左右跑、跳跃、踩怪触发伤害,还要被金币碰撞拾取。我心想这不就是最基础的物理控制吗,结果真动手才发现,从节点搭建、刚体参数、分组矩阵到回调监听,每一步都有“看着对、一运行就翻车”的细节。这篇文章就围绕 Cocos 3.8 里的 2D 人物控制完整流程来写,包含移动、跳跃、碰撞和碰撞触发,适合刚接触 3.8、或者从 2.x 迁移到 3.x 后对物理接口不熟的开发者。文里所有代码我都按 3.8 的 API 实测跑过,配置路径也按 3.8 的编辑器界面描述,你跟着搭一遍基本能直接用到自己的项目里。
1. 2D人物控制的核心矛盾:物理模拟与直观位移的取舍
刚开始做人物控制,最本能的做法就是拿到节点后node.setPosition(...),或者直接改node.position.x。我第一版也是这么写的:键盘按左就往左挪,按右就往右挪,空格就把 y 加上一段。问题很快就来了——角色撞到墙以后,你明明停止了物理碰撞,可主角还是能被按键推进墙里,因为这句“改坐标”直接绕过了刚体的物理模拟。碰撞体不是摆设,它只是检测,真正决定“能不能过去”的是刚体系统对碰撞的响应,你手动改位置等于把刚体的结果覆盖掉,自然穿模。
后来才意识到,在 Cocos 3.8 里做 2D 人物控制,本质上是在“物理引擎的规则”和“手写位移逻辑”之间做取舍。搞清楚这一点,后面移动、跳跃、碰撞触发的各种细节就都有了判断标准。
1.1 三种移动实现方案对比
以 Cocos 3.8 的 2D 物理体系来说,常见的移动实现有三种:
| 实现方式 | 典型做法 | 优点 | 缺点 |
|---|---|---|---|
| 直接改节点坐标 | node.position += offset | 简单直观,不受物理干扰 | 会绕过物理引擎的碰撞响应,容易穿墙;与刚体同步容易产生“瞬移”或卡入障碍 |
| 设置刚体速度 | rigidBody.linearVelocity = new Vec2(...) | 平滑、贴合物理系统,适合平台跳跃 | 速度会被物理模拟影响,需要配合地面检测和参数调优 |
| 施加力/冲量 | applyForceToCenter(...)/applyLinearImpulse(...) | 物理真实感强,适合“弹射”“击退” | 手感难调,速度不可直接预期,普通移动用它容易飘 |
我的最终选择是:移动用“设置刚体速度”,跳跃也是“设置刚体速度”(直接改 y 轴速度)。为什么要这样做?因为平台跳跃类游戏对手感的要求很高,你按下左键,角色就应该以稳定的速度向左移动;如果你用applyForce,力的累计会受质量、阻尼、碰撞影响,速度会有延迟感。而直接设置linearVelocity可以精确控制每一帧的水平速度,同时保留刚体对碰撞的响应,角色撞到墙就会被挡下来,而不是穿进去。
1.2 物理模块与碰撞矩阵:两件小事决定成败
在 Cocos 3.8 里,2D 人物控制能不能跑起来,有两个前置设置经常被忽略。
第一,检查你的工程是否启用了 2D 物理模块。新建的空工程如果没做过裁剪,一般没问题,但如果你按“最小包体”或“纯逻辑”模板创建过工程,很可能把 Physics 2D 模块裁剪掉了。路径在Project Settings -> Feature Cropping,需要确保Physics 2D被勾选。没启用的话,场景里所有 2D 碰撞体都不会产生任何回调,也不会有物理碰撞,而且编辑器控制台不一定会报明显错误,非常容易让人摸不着头脑。
第二,碰撞矩阵是“默认全不碰撞”的。这是 3.8 的一个大坑,很多初学者把两个碰撞体摆在一起,运行后发现角色直接掉穿地面,第一反应是代码问题,其实只是分组矩阵没配对。Cocos 3.8 里你可以给物体设置 Group(分组),然后在Project Settings -> Physics -> Collision Matrix里勾选“哪几个组之间允许碰撞”。如果你没手动勾选,那么即使 Player 和 Ground 都有自己的碰撞体,它们也不会互相碰撞。我在后面第 5 节会专门讲分组和碰撞矩阵怎么配,这里先记住:做人物控制前,先把这几个开关检查一遍。
2. 从零搭建人物节点:层级、刚体和碰撞体一次配好
Cocos Creator 3.8 的 2D 物理组件和 3D 物理组件是两套体系,2D 用RigidBody2D、BoxCollider2D这些带2D后缀的组件。节点结构配置得好不好,直接影响到后期写代码的复杂度。我建议直接用下面这套比较稳的结构。
2.1 推荐的节点树结构
以主角为例,我习惯这样组织节点:
Player(空节点,挂 RigidBody2D + PlayerController 脚本) ├── Sprite(子节点,只负责显示) ├── BoxCollider2D(挂在 Player 节点上,作为身体碰撞体) └── GroundCheck(子节点,挂 BoxCollider2D,勾选 Sensor)你可能会奇怪:为什么 Sprite 是子节点,碰撞体却挂在父节点上?因为RigidBody2D会统一管理绑定在它身上、以及它所有子节点上的Collider2D,碰撞体挂在父节点上可以避免随着子节点的位移、旋转出现物理区域错位。Sprite 单独做子节点,是为了后面做动画、翻转、残影效果时有个明确的“视觉层”。
GroundCheck 是地面检测用的传感器,我会在跳跃那一节详细讲。它的位置应该放在角色的脚底,比身体碰撞体略微靠下一点,大小不用太大,宽度是角色身体宽度的 60%-80% 左右,高度可以调成 10-20 像素,只要能稳定检测到地面就行。
2.2 刚体类型与关键参数
在 Player 节点上添加RigidBody2D后,需要设置几个关键参数:
Type设为Dynamic:动态刚体,受重力影响,能与静态刚体产生碰撞响应。这是角色最常用的类型。FixedRotation勾选:角色撞墙后不会旋转。如果不勾,你的人物撞到障碍物可能会像个盒子一样翻倒,除非你是做“滚滚类”游戏,一般不勾它。GravityScale默认是 1,代表正常重力。想调跳得高一点或下落快一点,可以调节这个值,后面跳跃手感部分再说。
地面、墙壁这类不动的物体,用RigidBody2D的Type = Static。Static 刚体不受任何力,也不会被撞飞,是场景地基的标准做法。如果你做移动平台,才会用Kinematic,它不像 Dynamic 那样受力,但可以通过代码移动,并且能推动站在上面的角色。
2.3 碰撞体形状怎么选
2D 碰撞体最常用的三个是BoxCollider2D、CircleCollider2D和PolygonCollider2D。
- 人物身体:大多数 2D 像素角色或 Q 版角色用
BoxCollider2D就足够,调一下Size让它贴合身体。碰撞体稍微比图像小一点,比图像大一点,手感会好很多——这里往往是调试“莫名其妙被卡住”的关键,因为很多人给角色画的碰撞体比视觉大得多。 - 圆形角色或球类:用
CircleCollider2D。 - 复杂地形、斜坡:用
PolygonCollider2D或分段 Box。
有个细节:3.8 里 2D 碰撞体组件本身就带有Tag、Group、Sensor等属性,Sensor勾上后,这个碰撞体不产生物理阻挡,只负责触发回调。我们做跳跃的 GroundCheck,就必须勾上Sensor,这样角色脚底虽然一直碰到“地面”,但不会被一层透明的小盒子撑起来。
3. 水平移动:把“移动意图”换算成刚体速度
配好节点后,就可以写控制脚本了。这一步最核心的是“输入监听”和“刚体速度”之间的配合。很多人习惯在update里直接判断“如果按了左键,就设置 x 为 -1”,这本身没错,但如果按键状态管理得不好,会出现人物转向迟钝、或者按一下跳两下的问题。
3.1 键盘输入监听与状态变量
我的做法是维护两个变量:_horizontal表示水平输入方向,取值 -1、0、1;_jumpPressed表示“当前是否刚按下过跳跃键”。监听使用input.on(Input.EventType.KEY_DOWN, ...)和KEY_UP,用一个布尔标记来接收跳跃按下事件,而不是在update里用input.isKeyDown一把梭。原因是按键的按下是一个“瞬间事件”,如果只在某一帧里检查,很容易漏掉;用事件回调把标记先存下来,下一帧执行跳跃,就不会漏。
import { _decorator, Component, input, Input, EventKeyboard, KeyCode } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { private _horizontal = 0; private _jumpPressed = false; onLoad() { input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.on(Input.EventType.KEY_UP, this.onKeyUp, this); } private onKeyDown(event: EventKeyboard) { switch (event.keyCode) { case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: this._horizontal = -1; break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: this._horizontal = 1; break; case KeyCode.SPACE: case KeyCode.KEY_W: this._jumpPressed = true; break; } } private onKeyUp(event: EventKeyboard) { switch (event.keyCode) { case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: if (this._horizontal === -1) this._horizontal = 0; break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: if (this._horizontal === 1) this._horizontal = 0; break; } } }这里有个小经验:onKeyUp里要判断_horizontal === -1再清零,而不是直接赋 0。如果你按住 A 再按 D,此时_horizontal是 -1,松开 D 后不应该影响 A 的移动方向。虽然这个例子只有单方向输入时不会触发,但养成“只在当前值是自己设置的状态下才清除”的习惯,后面做组合按键时不会踩坑。
3.2 设置 linearVelocity.x 而不是施加力
直接在update中设置刚体速度,是最稳定的平台跳跃移动方式:
update(deltaTime: number) { const rigidBody = this.getComponent(RigidBody2D); if (!rigidBody) return; const v = rigidBody.linearVelocity; const targetVx = this._horizontal * this.moveSpeed; rigidBody.linearVelocity = new Vec2(targetVx, v.y); if (this._jumpPressed) { this.tryJump(); this._jumpPressed = false; } }这代码的意图很明确:保留刚体的 y 轴速度(跳跃、重力都由物理系统算),只覆盖 x 轴速度。
为什么不推荐applyForceToCenter?因为它施加的是力,角色速度会随着时间逐步增加,停止按键后又因为阻尼逐渐减速,手感会“飘”。对平台跳跃玩家来说,他们更习惯按一下就立刻到指定速度、松开就立刻停,用直接设置linearVelocity才符合这种“即停即走”的手感。
如果你是做太空类、浮空类游戏,希望角色有惯性,那applyForce或降低linearDamping才更合适。方案没有绝对的好坏,只是场景不同。
3.3 人物翻转与视觉层级
因为 Sprite 是 Player 的子节点,翻转时可以直接改 Player 节点的scale.x,视觉效果和碰撞体方向会一起翻转。对绝大多数对称角色(比如 Q 版小生物)来说,这是最高效的写法:
// 移动到负方向时 this.node.setScale(-1, 1, 1); // 移动到正方向时 this.node.setScale(1, 1, 1);但注意:如果你用的是非对称的角色,比如一个手持长剑的人,那么整个节点翻转会导致手里的武器方向也反过来,看起来就别扭。这时候建议只翻转Sprite子节点,或者把Sprite的scale.x做反向处理。碰撞体保持物理上的稳定,视觉层只负责“看起来面向哪边”。这是渲染层和物理层分离的一个最小例子。
4. 跳跃:检测“是否能跳”比跳本身更重要
跳跃逻辑的核心其实不是“让角色跳起来”,而是“判断角色此刻能不能跳”。如果你的角色在空中也能触发跳跃,就会实现“无限跳跃”,这是平台跳跃里最不能接受的问题之一。
4.1 地面检测的通用方案:Sensor 碰撞体
我首选的检测方式是:在角色脚底放一个BoxCollider2D,勾选Sensor,挂到 GroundCheck 节点上。当这个传感器和地面碰撞体接触时,说明角色站在地面上,这时跳跃按键才有效;当传感器离开地面时,_groundCount减到 0,说明角色已经在空中。
这一段最关键的是用计数器而不是布尔值。如果你只用_isOnGround = true/false,在角色同时接触两块地面时会出问题:当你从一块地面跳到另一块地面的瞬间,onEndContact先触发,把_isOnGround置为 false,实际上角色另一只脚还踩着地,就会导致站在两块地板接缝处无法跳跃。计数器方案则不同:onBeginContact时_groundCount++,onEndContact时_groundCount--,只要计数大于 0,就认为还在地面,非常稳。
4.2 完整的跳跃实现
在 PlayerController 里加入地面检测:
import { RigidBody2D, Collider2D, Vec2 } from 'cc'; @ccclass('PlayerController') export class PlayerController extends Component { @property({ type: Collider2D }) groundCheckCollider: Collider2D = null; @property moveSpeed = 400; @property jumpSpeed = 700; @property airControlFactor = 0.7; @property enableAirControl = true; private _groundCount = 0; onLoad() { // 省略输入监听... if (this.groundCheckCollider) { this.groundCheckCollider.onBeginContact = this.onGroundBegin.bind(this); this.groundCheckCollider.onEndContact = this.onGroundEnd.bind(this); } } isOnGround(): boolean { return this._groundCount > 0; } private onGroundBegin(selfCollider: Collider2D, otherCollider: Collider2D, contact: any) { this._groundCount++; } private onGroundEnd(selfCollider: Collider2D, otherCollider: Collider2D, contact: any) { this._groundCount--; if (this._groundCount < 0) { this._groundCount = 0; } } private tryJump() { if (!this.isOnGround()) return; const v = this._rigidBody.linearVelocity; this._rigidBody.linearVelocity = new Vec2(v.x, this.jumpSpeed); } }注意:groundCheckCollider需要在编辑器里把 GroundCheck 子节点上的 Collider 拖进去。这种显式拖引用比getComponent更清晰,因为你可能有多个碰撞体,直接getComponent(Collider2D)会随机拿到任意一个,很可能拿错。
跳跃本身只有一行:把linearVelocity.y设成jumpSpeed。这里包含了“保留当前 x 速度”的逻辑,所以角色水平移动和跳跃可以同时进行,不会出现跳跃时把水平速度清零的玄学问题。
4.3 跳跃手感:参数估算与细节调优
跳跃高度可以大致估算。Cocos 3.8 的 2D 物理默认重力加速度大约是980 px/s²(像素每秒平方),假设你给角色设置的GravityScale = 1,跳跃初速度是700 px/s,那么最大跳跃高度大约是:
h ≈ v² / (2g) = 700² / (2 × 980) ≈ 250 px这个公式很实用,你不需要反复试跳跃高度,先根据场景里的地面间距估算出需要的像素高度,再反推jumpSpeed。比如你想跳 300 px,那么v ≈ sqrt(2 × 980 × 300) ≈ 767 px/s,直接填 770 就行。
手感方面,除了jumpSpeed,我还会调节:
GravityScale:想让角色“重”一点、下落快一点,可以设成 1.2-1.5;想跳得高、滞空久,可以设 0.8-0.9。不要直接在物理设置里动全局重力,因为你可能还有其他物体需要不同重力表现。airControlFactor:空中转向时的移动速度系数。平台跳跃里,我不想让角色在空中像地上一样灵活,所以空中的水平速度只乘 0.7。这个参数能解决“跳跃后控制过度”的问题,比直接禁止空中移动的手感好很多。- 跳跃缓冲:玩家按住跳跃键快要落地时,如果提前按了跳跃,系统可以先记下“玩家想跳”这个意愿,落地后的 100ms 内自动执行跳跃。这个优化能让手感明显更跟手,很多“我明明按了空格却没跳”的抱怨,都是缺这个缓冲导致的。
5. 碰撞与触发:分组矩阵、Sensor 与回调注册
人物控制到这里,角色已经能跑能跳了。接下来要面对的是碰撞触发:踩怪扣血、捡金币、触碰机关、进入传送门等等。这部分的核心不再是“物理阻挡”,而是“事件通知”。
5.1 “碰撞”和“触发”在物理上的区别
在 Cocos 3.8 的 2D 物理里,所有Collider2D组件都会产生碰撞事件,区别在于是否产生物理响应:
- 默认状态(
Sensor不勾选):两个带刚体的碰撞体接触后,会产生物理力,互相阻挡。比如角色踩到地面、撞到墙,就是这种状态。 - 勾选
Sensor:碰撞体仍然能检测“接触发生”,但不会产生实际阻挡效果。角色可以穿过它,同时你依然能收到onBeginContact/onEndContact回调。
所以做金币、奖励物品时,把金币的碰撞体勾上Sensor,玩家走过时会触发“拾取”事件,又不会被金币挡住。做出口、传送门也同理。
5.2 分组和碰撞矩阵怎么配
在 Cocos Creator 编辑器里打开Project Settings -> Physics -> Collision Matrix,你会看到一个分组矩阵。先把分组定义好,比如:
| 分组名 | 用途 |
|---|---|
| DEFAULT | 默认组,给没有特殊逻辑的物体 |
| GROUND | 地面、墙、平台 |
| PLAYER | 玩家角色 |
| ENEMY | 敌人物体 |
| TRIGGER | 触发器、道具、机关 |
在“分组管理”里新建这些组之后,回到碰撞矩阵,把允许互相碰撞的组对勾上。比如:
PLAYER与GROUND勾选:角色能站在地面上。PLAYER与ENEMY勾选:角色接触敌人会触发伤害。PLAYER与TRIGGER勾选:角色接触道具会触发拾取。
这里要记住一个关键点:如果你想让碰撞回调触发,碰撞矩阵必须允许这两个组碰撞,否则物理引擎根本不会计算它们之间的接触。我见过不少人的金币Sensor设好了,代码回调也挂了,但运行后什么都不触发,最后发现是碰撞矩阵的PLAYER和TRIGGER没有勾选。
5.3 回调注册的两种方式
Cocos 3.8 的碰撞回调有两种注册方式:
第一种是给单个碰撞体直接赋值回调,适合“这个角色由我自己控制”的场景:
const collider = this.getComponent(Collider2D); collider.onBeginContact = this.onBeginContact.bind(this); collider.onEndContact = this.onEndContact.bind(this);第二种是使用物理系统的全局回调,适合“所有碰撞都由同一套代码处理”的场景:
import { PhysicsSystem2D, Contact2DType } from 'cc'; PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, (selfCollider, otherCollider, contact) => { // 全局碰撞开始 }, this);我个人的建议是,让每个功能组件各自注册自己的回调,用otherCollider.group判断碰撞对象是谁,这样代码职责清晰,不会出现一个全局回调堆满 if else 的情况。
金币拾取的一个最小例子:
import { _decorator, Component, Collider2D } from 'cc'; const { ccclass } = _decorator; @ccclass('Coin') export class Coin extends Component { onLoad() { const collider = this.getComponent(Collider2D); if (collider) { collider.onBeginContact = this.onBeginContact.bind(this); } } private onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D) { // 金币不关心具体是谁碰到了,碰到就消失 this.node.destroy(); } }如果想做更严格的判断,比如只允许玩家拾取,可以检查otherCollider.group === PLAYER_GROUP或者otherCollider.getComponent(PlayerController) != null。
5.4 踩怪伤害:需要判断接触方向
踩怪是横版游戏最常见的机制,但很多人在刚实现时都会写错:接触敌人就扣血,结果从侧面碰一下也扣血。实际需求通常是“从上方踩到才有效,侧面碰到则玩家受伤”。
可靠判断方式:接触瞬间,看玩家的刚体速度 y 轴方向。如果linearVelocity.y < 0,说明玩家在下落过程中碰到敌人,属于“踩踏攻击”;否则就是侧面或下方碰撞,判定为受伤。
private onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: any) { if ((otherCollider.group & ENEMY_GROUP) === 0) return; const rigidBody = this.getComponent(RigidBody2D); if (rigidBody && rigidBody.linearVelocity.y < 0) { // 视为踩踏,敌人被消灭或玩家反弹 otherCollider.node.destroy(); rigidBody.linearVelocity = new Vec2(rigidBody.linearVelocity.x, this.jumpSpeed * 0.6); } else { // 玩家受伤逻辑 } }这里“玩家反弹”是额外手感优化:踩到敌人后,不是直接下落,而是给一个较小的跳跃速度,让角色弹起来,很容易产生连踩效果。注意这是基于速度判断的简单写法,更精确的做法是拿接触点坐标和玩家/敌人中心点做方向判断,毕竟某些情况下角色上升途中也可能有接触。但作为第一版,速度判断已经足够简单可用。
6. 实测踩坑:我把四类问题按“最高频”排了个序
如果你已经按上面的步骤搭完,应该能跑通基本流程了。但实际开发中真正花时间的是各种“莫名其妙”的问题。这里把我反复踩过的四类坑归纳一下,附上排查思路和解决方案。
6.1 回调就是不触发,场景里毫无反应
这是遇到最多的问题。我的排查顺序是:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 两个碰撞体完全不接触,穿透 | 2D 物理模块没启用 | 检查Project Settings -> Feature Cropping是否勾选 Physics 2D |
| 碰撞体有接触,但没有回调 | 碰撞矩阵中 Group 对没有勾选 | 打开Collision Matrix,确认两个组之间勾选了 |
| 只有一方是 Static,另一方也是 Static | 两个 Static 刚体不会产生物理接触事件 | 至少一方改成 Dynamic 或 Kinematic |
回调触发了,但otherCollider不是预期对象 | 分组设置混乱 | 给游戏物体分配独立分组,打印otherCollider.group验证 |
在调试碰撞问题时,先把编辑器的“物理调试绘制”打开,一般情况下能看到碰撞体的轮廓。Cocos 3.8 的编辑器工具栏或场景面板里有一个Physics相关的调试开关,打开后,运行场景时碰撞体会以绿色或蓝色线框显示。如果线框完全没显示,说明物理系统没生效或碰撞体被禁用;如果线框位置和视觉明显对不上,那就是碰撞体大小偏移问题。这一步能帮你把八成问题直接定位。
6.2 跳跃像是有延迟,或者按住一个键就连跳
跳跃延迟通常不是代码慢,而是物理步长和渲染帧不同步造成的。物理回调onBeginContact不一定会立刻在下一帧更新你的_groundCount,如果你在“刚好离开地面”的那一两帧里按了跳跃,角色可能已经离地但_groundCount还没归零,系统允许了第二次跳,于是看起来像“空中二段跳”。反过来的情况是:刚刚落到地面,物理回调还没到,你按跳跃没反应,就感觉延迟。
处理跳跃延迟,我建议给“跳跃意图”加一个小的等待窗口。不要把tryJump()放在按键事件的同一帧立即执行,而是记录一句“我按过跳跃了”,让它在未来 50-100ms 内持续有效;如果这段时间内isOnGround()变成 true,就执行跳跃。这样既不会误跳,也消除了物理回调时序带来的延迟。
处理“按一次跳两下”,最常见的元凶是按键长按触发。键盘事件里,如果玩家按住空格不放,系统会周期性发送KEY_DOWN,导致每周期都触发一次跳跃。我上面代码里用_jumpPressed标记,并在update消费后立即清零,就是为了避免这个问题。如果你的游戏必须支持“按住跳跃键自动连续跳”,那就不该用_jumpPressed,而是用isKeyDown配合冷却时间来控制频率。
6.3 角色像被吸在地面上,跑起来发黏
这个问题多半出在碰撞体的尺寸或物理材质上。角色身体碰撞体如果和地面接触面太大,摩擦力和法向力都会异常,导致移动速度上不去。检查两点:
- 角色身体
BoxCollider2D的Size是否明显大于视觉?如果是,把它缩小到略小于图形边缘。 - 地面和角色是否不小心加了
PhysicsMaterial2D?如果摩擦系数设得很大,角色跑动时会发黏。调试时先把摩擦系数调成 0,确认不是摩擦力问题后再慢慢加。
另一个常见情况是:不平整的地面边缘有锯齿,两个 Box 碰撞体之间产生细微的“台阶”,角色跑过去像是被一粒粒小沙子卡住。解决方法是地面尽量用长条形的 Box 而不是很多个小 Box 拼接,或者用PolygonCollider2D/ 链条碰撞体做完整的地形边。
6.4 高速移动时穿墙、穿地板
穿墙的本质是:物理步长是固定的,在一次物理步内角色位移超过了碰撞体厚度,引擎没有捕捉到碰撞。这在高跳跃速度、高移动速度、薄碰撞体的情况下最容易出现。
处理方法:
- 降低单帧最大速度,把
moveSpeed、jumpSpeed控制在一个合理范围。比如你的角色物理体高度是 100px,别让它一帧内位移超过 50px。 - 给碰撞体加一点厚度。薄到 1px 的墙几乎必穿,给关键碰撞体设成 5-10px 厚度,体验上感知不到,却能显著减少穿透。
- 如果确实需要高速移动,可以检查
RigidBody2D是否提供了bullet或连续碰撞检测相关选项。Cocos 3.8 的 2D 物理封装里,高速物体的连续碰撞检测效果有限,我的经验是优先限制速度上限,比依赖碰撞检测更可靠。
7. 从人物控制到真实玩法的落地思路
这套人物控制跑通后,你已经有了一个稳定的基础,可以往外扩散玩法了。我最后简单分享几个扩展方向,都是基于上面这套体系的自然延伸。
7.1 移动平台与传送带
移动平台可以用RigidBody2D的Kinematic类型,写脚本移动它。这样角色站在平台上时会被平台带着走,不会出现“平台走了,角色还留在原地”的割裂感。注意给平台加一个和角色接触的碰撞体,分组矩阵里做好配置。
7.2 敌人 AI
敌人本身的移动逻辑可以和玩家共用同一套“设置linearVelocity”的思路,只是在状态机里做巡逻、追击、攻击切换。碰撞伤害用前面踩怪的代码扩展,给敌人也挂上PlayerController的isOnGround配合检测,就能做“踩敌人弹跳-再踩-再弹跳”的连击手感。
7.3 渲染层与物理层的解耦
如果你后面要做受击闪白、无敌闪烁、残影、影子拖尾等效果,最好在开始写代码时就保持“物理实体节点”和“视觉表现节点”分离。PlayerController 只负责给物理节点设置速度和接收碰撞回调,视觉节点由动画/特效系统驱动。这个习惯可以避免以后为了做表现效果,在移动代码里塞入大量和物理无关的逻辑。
动手做了这个项目后,我最大的感受是:2D 人物控制的难点不在 API,而在“怎么看穿问题”。控制代码只负责把输入翻译成速度,碰撞回调只负责记录状态,所有依赖上一帧的状态变量尽量用计数而不是布尔量。你在自己的项目里如果碰到奇怪的表现,先别急着打日志,打开物理调试绘制,把碰撞体的形状和位置看清,一半问题当场就能看到答案。剩下的一半,大部分也都出在分组矩阵和回调时序上,按上文顺序排查就行。