
最近接了个2D横版小游戏的需求角色要在场景里跑、跳、收集道具还要能触发机关。拿到需求后我第一反应是用 Cocos Creator 3.8 快速搭一套可复用的 2D 人物控制方案把移动、跳跃、碰撞、触发这几块一次性打通。这篇东西就是整理这次实操的过程包括为什么这么设计、关键代码怎么写、哪些地方容易埋坑。适合正在学 Cocos Creator 3.8 的同学或者从 2.4 往上升级后对 2D 物理模块还不太熟的朋友参考看完可以直接照着敲进自己的项目里。1. 项目拆解从“一个会动的小人”到“一套可复用的角色控制方案”1.1 核心需求分析项目标题里其实藏了四个关键词人物控制、移动、跳跃、碰撞触发。看着简单真正落到代码层面要拆成三层问题输入层、物理层、逻辑层。输入层解决“玩家按了按键角色要动”的问题物理层解决“角色怎么在地面上站稳、怎么跟障碍物交互”的问题逻辑层解决“撞到金币要收集、踩到机关要触发事件”的问题。很多新手上来就写this.node.setPosition()直接改坐标移动是能移动但角色会直接穿墙。因为这种写法绕过了物理引擎碰撞体跟着节点移动是没错但物理引擎里刚体的速度、受力状态完全没更新碰撞检测就会失灵。所以我这次干脆把物理方案作为底层基础所有移动、跳跃、碰撞都基于 RigidBody2D 来做。1.2 为什么选 Cocos Creator 3.8Cocos Creator 3.x 最大的变化是把 2D 和 3D 的开发管线整合到了一起2D 物理系统基于 Box2D底层非常成熟。3.8 版本对 2D 物理模块做了不少稳定性修复我自己实测下来碰撞回调和传感器触发都比 3.6 早期版本可靠得多。另外3.8 的组件化思路特别清晰一个角色节点挂上 Sprite、RigidBody2D、Collider2D、脚本组件各自职责单一调试时也方便单独开关。脚本层用 TypeScript 写编辑器面板可以直接暴露属性调参不用改代码对原型验证和游戏手感调优来说体验很好。1.3 技术方案选型刚体移动 vs Transform 移动开始写代码前必须先把技术路线定下来。我当时列过一张对比表控制方案物理模拟碰撞回调手感控制适用场景RigidBody2D linearVelocity完整物理模拟稳定可靠可即时调整速度响应快横版跑酷、平台跳跃直接修改 position破坏物理状态容易穿透手感直接但易出错塔防、棋牌、非物理游戏ApplyForce / ApplyImpulse 驱动物体完整物理模拟稳定可靠有惯性适合打飞物理解谜、弹射类结论很明确要做横版角色控制直接用RigidBody2D配合linearVelocity是最稳妥的。原因很简单第一碰撞时物理引擎能正确处理反作用力第二直接设置速度对角色操控的响应是即时的不像施加力那样有加速度滞后第三后续如果要加子弹命中击退、反弹效果刚体方案天然支持。2. 环境准备与基础场景搭建2.1 创建项目与场景用 Cocos Dashboard 新建项目时选“2D 项目”模板引擎版本选 3.8.x项目名可以叫CharacterDemo2D。新建好后编辑器默认会生成一个Scene场景我习惯把 Demo 做得干净一点直接新建一个空场景叫Game避免模板自带的那些资源干扰判断。创好场景后第一件事是确认设计分辨率和适配方式。我这次用 960x640 作为设计分辨率Canvas 组件里勾选 Fit Width 和 Fit Height。这里有个很容易被忽略的点2D 物理世界里的坐标单位和 UI 坐标默认是一致的物理引擎的重力单位也是像素每秒平方所以调整设计分辨率会直接影响物理表现最好在项目早期定死不要后期再改。2.2 角色节点树的构建我的节点层级是这样组织的Canvas ├── GameRoot空节点挂游戏逻辑 │ ├── GroundManager地面相关 │ └── ItemManager道具相关 ├── PlayerRoot角色根节点 │ ├── SpriteBody角色皮肤节点子节点 │ └── FootSensor脚底检测点子节点 └── Main CameraPlayerRoot挂RigidBody2D和BoxCollider2D同时挂角色控制脚本。这里有个关键点Sprite 不要直接挂在有刚体的节点上最好单独做成子节点。原因有两个一是后续换皮肤或做动画时不会因为物理组件导致显示异常二是角色受击闪白、放技能变色这类特效可以在子节点上单独处理不动物理参数。2.3 物理系统开启与 2D 物理设置Cocos Creator 3.8 默认同时支持 2D 和 3D 物理但 2D 物理是独立开关的。路径在菜单栏“项目” - “项目设置” - “功能裁剪”里要确保启用了 2D 物理系统。然后到“项目设置” - “物理”面板勾选“启用物理系统”把重力设为(0, -400)。重力为什么是 -400这个数值其实是我根据屏幕尺寸和跳跃手感反推的。960x640 的设计分辨率下角色如果希望跳 200 像素左右高用初中物理公式H v² / (2g)估算跳跃速度取 380 时380² / (2 * 400) ≈ 180像素视觉上刚好是一个比较轻盈的跳姿。如果重力给的太大角色会像掉进深井太小则跳跃会显得飘。这个参数在项目设置里改一次即可。碰撞矩阵也需要提前规划。我的分组很简单Player、Ground、Item、Enemy。在“项目设置” - “物理” - “碰撞矩阵”里按需勾选哪些组之间参与碰撞计算。我配置的是 Player 跟 Ground、Enemy 碰撞跟 Item 不碰撞但触发传感器事件Ground 跟 Item 不碰撞避免道具被地面刚体推走。3. 核心功能实现人物移动与跳跃3.1 移动控制用 linearVelocity 控制水平速度移动部分的核心思路是先监听玩家输入然后设置刚体水平方向的线性速度垂直方向完全交给物理引擎处理。这样角色在奔跑时不改重力逻辑跳跃下落曲线会非常自然。下面是移动控制的基础代码import { _decorator, Component, RigidBody2D, input, Input, EventKeyboard, KeyCode, Vec2 } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { property moveSpeed 220; private rigidBody: RigidBody2D null; private horizontal 0; onLoad() { this.rigidBody this.getComponent(RigidBody2D); input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.on(Input.EventType.KEY_UP, this.onKeyUp, this); } private onKeyDown(event: EventKeyboard) { if (event.keyCode KeyCode.ARROW_LEFT || event.keyCode KeyCode.KEY_A) { this.horizontal -1; } else if (event.keyCode KeyCode.ARROW_RIGHT || event.keyCode KeyCode.KEY_D) { this.horizontal 1; } } private onKeyUp(event: EventKeyboard) { if (event.keyCode KeyCode.ARROW_LEFT || event.keyCode KeyCode.KEY_A) { this.horizontal 0; } else if (event.keyCode KeyCode.ARROW_RIGHT || event.keyCode KeyCode.KEY_D) { this.horizontal 0; } } update() { const velocity this.rigidBody.linearVelocity; this.rigidBody.linearVelocity new Vec2(this.horizontal * this.moveSpeed, velocity.y); } }我有必要解释一下为什么用new Vec2()重新设置而不是直接改velocity.x。linearVelocity返回的是一个引用对象直接改 x 理论上是可行的但在物理引擎内部某些版本里可能出现同步问题最省事也最稳妥的写法就是整个 Vec2 一起set。另外跳跃阶段必须保留原来的 y 方向速度这就是为什么我在构造 Vec2 时传入了velocity.y。为了让角色跑起来不会翻跟头一定要注意设置刚体的fixedRotation为 true。如果忘了这一步角色撞到障碍物时刚体会受扭矩影响旋转画面看起来就是人物在地上打滚。这是个非常典型的“看起来小但影响巨大”的参数。3.2 跳跃实现速度、土狼时间与跳跃缓冲跳跃看着简单其实是角色控制里最难调手感的部分。我先给基础版本再叠加两个锦上添花的小技巧。基础跳跃逻辑很简单判断角色当前是否在地面上如果是就赋予刚体一个向上的速度。property jumpSpeed 380; private _isGrounded false; jump() { if (!this._isGrounded) return; const velocity this.rigidBody.linearVelocity; this.rigidBody.linearVelocity new Vec2(velocity.x, this.jumpSpeed); }判断在地面核心是靠onCollisionEnter和onCollisionExit这两个回调。我在场景里把地面刚体类型设为 Static碰撞体是 BoxCollider2D。角色落地时碰撞回调触发_isGrounded变成 true角色离开地面时变成 false。onCollisionEnter(other: Collider2D) { if (other.tag 1) { // 地面 tag 为 1 this._isGrounded true; } } onCollisionExit(other: Collider2D) { if (other.tag 1) { this._isGrounded false; } }这里我踩过一个坑onCollisionExit不仅会在角色跳起离开地面时触发角色走到平台边缘时也会触发。这本身没错但也意味着从平台边缘走到空中那一刻_isGrounded已经变成 false此时按跳跃键角色不会跳。可我们玩过的很多优秀平台跳跃游戏里离开平台边缘后约 0.1 秒内按跳跃键仍然可以跳起来这个机制叫“土狼时间”。土狼时间的实现很简单不直接用 bool 存是否在地面而是存一个计时器。每次碰撞到地面时把计时器重置为 0.1 秒每帧递减计时器大于 0 就认为可以跳。代码如下private _coyoteTime 0; private _onGroundTimer 0; update(deltaTime: number) { this._onGroundTimer - deltaTime; this.tryJump(); } tryJump() { if (this._onGroundTimer 0) { this.doJump(); } }另一个提升手感的小技巧是跳跃缓冲。玩家在角色还没落地时提前按了跳跃键我们希望角色落地瞬间立刻跳起来而不是让这次按键白白丢失。做法是记录玩家是否按了跳跃键并给这个“待处理跳跃”加一个 0.15 秒的窗口期。跳跃成功后清空缓冲标记。添加这两个机制后角色的跳跃手感会立刻从“僵硬”变成“跟手”强烈建议读者实际试一下差异非常明显。3.3 镜头跟随与动画状态切换角色跑起来之后镜头如果不动角色很快就跑出屏幕了。我习惯给 Main Camera 挂一个极简的跟随脚本用 LERP 做平滑跟随避免镜头硬跳。import { _decorator, Component, Vec3, Camera, tween } from cc; ccclass(CameraFollow) export class CameraFollow extends Component { property({ type: Camera }) camera: Camera null; property smoothTime 5; property({ type: Vec3 }) offset new Vec3(0, 0, 1000); start() { this.camera this.getComponent(Camera); } lateUpdate(deltaTime: number) { const targetNode this.node.parent; // 模拟跟随目标为父节点 const targetPos targetNode.worldPosition.clone().add(this.offset); const curPos this.camera.node.worldPosition.clone(); const newPos curPos.lerp(targetPos, this.smoothTime * deltaTime); this.camera.node.setWorldPosition(newPos); } }动画状态切换就相对简单了。角色节点下的 Sprite 子节点挂一个 Animation 组件在移动时播放 Run 动画静止时播放 Idle跳跃时播放 Jump。需要在PlayerController里保存动画引用在 update 里根据水平和垂直速度判断状态并切换播放。4. 碰撞与触发从“撞到”到“触发逻辑”4.1 碰撞体与刚体的组合关系很多新手在这里会卡住分不清 Collider2D 和 RigidBody2D 的区别。我用一句话总结RigidBody2D 负责“受力运动”Collider2D 负责“占地方和告诉引擎哪里碰到了哪里”。一个完整的物理物体必须有刚体或属于某一类静态物体而纯粹靠代码移动的节点如果只挂 Collider2D也不会产生正确的碰撞回调。刚体类型在实际使用中主要分三种我整理了个表刚体类型是否受重力是否可移动典型用途Static不受不可移动地面、墙壁、平台Dynamic受可移动玩家角色、可推动箱子Kinematic不受可通过代码移动移动平台、电梯、自动门Kinematic 刚体是最容易被忽略的类型。它不会受重力影响也不会被其他物体弹开但可以以恒定速度移动并推动 Dynamic 刚体。做移动平台时我会把平台刚体类型设为 Kinematic通过代码设置linearVelocity让平台左右移动这样站在平台上的角色就会跟随平台移动不需要额外写父子约束。4.2 碰撞检测onCollisionEnter 与 onBeginContact 的选择这里有个很容易混淆的点Cocos Creator 3.8 里碰撞事件有两套回调机制。一套是 Collider2D 组件上的onCollisionEnter、onCollisionStay、onCollisionExit它需要两个碰撞体至少一个是 Dynamic 或 Kinematic 并开启了物理接触另一套是物理系统级别的onBeginContact、onEndContact在PhysicsSystem2D上注册回调参数里带接触信息。我用下来最大的感受是做角色控制时用 Collider2D 组件上的回调就够了简洁直观如果要做弹幕和墙体的批量碰撞处理特别是需要同时处理大量碰撞对象时用onBeginContact在系统层统一处理性能更好。另外需要注意onCollisionEnter回调只有在物理引擎真正求解接触之后才触发如果物体穿透速度太快碰撞回调可能丢失这就需要后面提到的防穿透措施。4.3 触发逻辑金币收集、机关、伤害判定触发逻辑最常用的模式是“传感器”。把一个 Collider2D 勾选isSensor后它就不再是实体碰撞体不会挡住角色但仍会触发进入和离开事件。这个设计很适合做金币收集、机关触发和伤害区域。我这里用一个金币收集的例子演示。给金币节点挂一个BoxCollider2D勾选 isSensor再挂一个CoinScript。在金币的传感器碰撞回调里判断碰撞者是不是 Player是则播放收集动画、加分、销毁自身。import { _decorator, Component, Collider2D, Contact2DType, Node, tween, Vec3 } from cc; ccclass(CoinScript) export class CoinScript extends Component { onLoad() { const collider this.getComponent(Collider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } } private onBeginContact(self: Collider2D, other: Collider2D) { if (other.node.name PlayerRoot) { // 收集逻辑播放飞向计数器动画然后消失 const targetScale new Vec3(0, 0, 1); tween(this.node) .to(0.2, { scale: targetScale }) .call(() this.node.destroy()) .start(); // 加分 console.log(Coin collected 100); } } }这里有个细节值得提醒销毁节点的时候如果这个节点有刚体组件在物理引擎正在处理接触时直接 destroy可能会报一些奇怪的错误。稳妥的做法是先移除碰撞体组件或通过 node.active false 隐藏延迟一帧再销毁。我上面的代码是在 tween 结束后的回调里销毁恰好避开了物理求解的那一帧所以一直很稳。4.4 物理分组与碰撞过滤物理碰撞矩阵是一个必须提前规划的项目而不是等到项目做大了再回来配置。很多 Demo 项目图省事把所有分组都勾上碰撞结果玩家跳跃时脑袋碰到金币、子弹打到友军物理对象之间产生一堆无用的接触计算排查起来特别头大。我建议所有项目从第一天就建立明确的碰撞矩阵。在“项目设置” - “物理” - “碰撞矩阵”里行和列都对应分组名称。我的 Demo 配置如下Player 与 Ground 碰撞Player 与 Enemy 碰撞Player 与 Item 不参与物理碰撞但 Item 开传感器照样能触发事件Ground 与 Item、Enemy 都不碰撞避免噪声。这个配置既能保证角色正常落地又能让金币传感器触发拾取逻辑敌人碰到地面不会被弹开逻辑清晰性能也更好。5. 完整 Demo 脚本整合与调试5.1 PlayerController.ts 完整代码前面拆过功能这里把移动、跳跃、地面检测、土狼时间、跳跃缓冲整合到一个完整脚本里方便直接复制import { _decorator, Component, RigidBody2D, Collider2D, Contact2DType, input, Input, EventKeyboard, KeyCode, Vec2, Animation } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { property moveSpeed 220; property jumpSpeed 380; property coyoteTime 0.1; property jumpBufferTime 0.15; property groundTag 1; private rigidBody: RigidBody2D null; private horizontal 0; private onGroundTimer 0; private jumpBufferTimer 0; private anim: Animation null; onLoad() { this.rigidBody this.getComponent(RigidBody2D); this.anim this.getComponentInChildren(Animation); input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.on(Input.EventType.KEY_UP, this.onKeyUp, this); const collider this.getComponent(Collider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); collider.on(Contact2DType.END_CONTACT, this.onEndContact, this); } } onDestroy() { input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.off(Input.EventType.KEY_UP, this.onKeyUp, this); } private onKeyDown(event: EventKeyboard) { if (event.keyCode KeyCode.ARROW_LEFT || event.keyCode KeyCode.KEY_A) { this.horizontal -1; } else if (event.keyCode KeyCode.ARROW_RIGHT || event.keyCode KeyCode.KEY_D) { this.horizontal 1; } if (event.keyCode KeyCode.ARROW_UP || event.keyCode KeyCode.KEY_W || event.keyCode KeyCode.SPACE) { this.jumpBufferTimer this.jumpBufferTime; } } private onKeyUp(event: EventKeyboard) { if (event.keyCode KeyCode.ARROW_LEFT || event.keyCode KeyCode.KEY_A) { if (this.horizontal 0) this.horizontal 0; } else if (event.keyCode KeyCode.ARROW_RIGHT || event.keyCode KeyCode.KEY_D) { if (this.horizontal 0) this.horizontal 0; } } private onBeginContact(self: Collider2D, other: Collider2D) { if (other.tag this.groundTag) { this.onGroundTimer this.coyoteTime; } } private onEndContact(self: Collider2D, other: Collider2D) { // 这里不需要立即置为不可跳交给土狼时间倒计时 } update(deltaTime: number) { this.onGroundTimer - deltaTime; this.jumpBufferTimer - deltaTime; const velocity this.rigidBody.linearVelocity; this.rigidBody.linearVelocity new Vec2(this.horizontal * this.moveSpeed, velocity.y); if (this.jumpBufferTimer 0 this.onGroundTimer 0) { this.rigidBody.linearVelocity new Vec2(this.rigidBody.linearVelocity.x, this.jumpSpeed); this.jumpBufferTimer 0; this.onGroundTimer 0; } this.updateAnimation(); } private updateAnimation() { if (!this.anim) return; const velocity this.rigidBody.linearVelocity; if (Math.abs(velocity.y) 1) { this.anim.play(velocity.y 0 ? JumpUp : JumpDown); } else if (Math.abs(velocity.x) 1) { this.anim.play(Run); } else { this.anim.play(Idle); } } }有几个地方需要特别说明。首先是onEndContact我在里面什么事情都没做因为土狼时间的设计目标就是离开地面后的短时间内仍然可以跳跃如果在这里立刻把onGroundTimer置为 0土狼时间就失效了。这个函数留空是故意的注释里也写了避免后边维护时别人无端删掉。其次是跳跃方式。我没有用ApplyImpulse而是直接设置linearVelocity向上的分量这能保证每次跳跃速度恒定。如果用冲量刚体本身有重力累积跳跃高度会受到前一段垂直速度的影响手感会不稳定。5.2 场景搭建步骤清单我把整个场景从零搭建一遍照做就能跑起来新建 2D 项目新建空场景 Game把 Canvas 拖进场景。在 Canvas 下新建空节点GroundManager在其下创建地面节点命名为 Ground挂RigidBody2DType 选 Static再挂BoxCollider2D调整 Size 和 Offset 匹配画面宽度把 tag 设为 1。新建角色节点PlayerRoot挂RigidBody2DType 选 DynamicfixedRotation勾选 true挂BoxCollider2DSize 根据角色图片调整再挂PlayerController脚本。在PlayerRoot下创建子节点SpriteBody挂 Sprite 组件拖入角色图片。在场景里创建一组金币节点每个金币节点挂BoxCollider2D勾选isSensor挂CoinScript。打开项目设置里的物理面板确认启用 2D 物理重力设置为 (0, -400)。在编辑器顶部点击预览按钮用方向键或者 WASD 测试移动和跳跃用主键盘空格也测一下跳跃。5.3 调试技巧物理调试绘制Cocos Creator 3.8 编辑器自带了 2D 物理调试绘制功能。位置在菜单“物理” - “显示物理网格”或者运行时的 debug 绘制。启用后运行预览会看到角色和地面的碰撞体轮廓被画出来。调试绘制是我排查碰撞问题第一件做的事情。很多时候节点上的 Sprite 显示得很好但碰撞体跟贴图错位或者碰撞体大小明显超出视觉范围。眼睛看不出来的问题一开调试绘制就暴露了。另一个技巧是给碰撞回调的第一行加console.log确认事件到底有没有触发然后再一步步去分析为什么没触发。我做这个 Demo 时习惯在onBeginContact里打印other.node.name这样能直观看到角色每一步碰到的是哪个节点排查“踩到敌人没有反应”“金币没吃到”都能快速定位是逻辑问题还是物理配置问题。6. 常见问题与排查技巧实录6.1 问题速查表这次开发过程中我收集到一个高频问题清单把现象、原因、解决办法汇总成表遇到问题直接查现象可能原因解决方案角色完全不动2D 物理未启用刚体不生效项目设置 - 功能裁剪 - 启用 2D 物理角色跑动时原地打转RigidBody2D 未开启 fixedRotation刚体属性里勾选 fixedRotation跳跃时按空格没反应事件绑定忘了加 SPACE 键onKeyDown 里统一处理 SPACE、UP、W角色跳起来后无法二段跳但脚没落地不能跳地面 tag 没设对地面回调没触发给地面碰撞体设正确 tag检查 onBeginContact 里判断逻辑角色快速下落时穿透地面物体速度过快物理步长不够降低最大下落速度或把碰撞体做成多个小碰撞体金币收集回调不触发金币节点的 Sensor 没勾选勾选金币 Collider2D 的 isSensor传感器脚本收不到回调传感器上没挂 RigidBody2D给传感器节点挂 Static RigidBody2D再挂 Collider2D销毁金币时报错在物理接触求解期间销毁节点延迟一帧销毁或 tween 结束后销毁碰撞体跟角色图片错位节点缩放比例不一致打开物理调试绘制调整碰撞体 Offset 和 Size6.2 独家避坑经验最后一个大坑想重点说一下它来自我做过的一个移动平台关卡。Kinematic 刚体移动平台要用linearVelocity或者RigidBody2D.setLinearVelocity驱动不要直接setPosition。直接改 position 会让物理引擎认为物体是瞬移过去的站在平台上的角色不会被带着走还会出现奇怪的抖动。我实际踩坑的场景是用this.node.setPosition()来控制移动平台左右往复运动平台确实动了但角色站在上面完全不跟随来回抖动。查了半天才发现是 Kinematic 刚体没有用速度驱动。改成this.rigidBody.linearVelocity new Vec2(speed, 0)之后角色立刻稳定地站在平台上了。这个细节如果不实际做一遍很难在文档里领悟到。另一个经验是碰撞体尺寸一定要比贴图视觉尺寸小一圈。我做角色碰撞体时BoxCollider2D 的 Size 只做到贴图的 70%-80% 宽高度上给脚底留一点点余量。这样做的目的是让玩家在跳跃时有更大的容错空间明明脚差一点没踩到平台但因为碰撞体比视觉窄游戏判定为踩到了玩家永远不会抱怨“我明明够到了却不让我站上去”。这是从商业游戏手感设计里学来的技巧用在 Demo 和原型里很有效。6.3 顺带聊聊关键词里的一些扩展方向有人会问2D 人物控制和热词里的 LIVE2D、贪心算法、MD5 碰撞有什么关系我在调研时也看到了这些词。LIVE2D 本质上是一个用于 2D 角色动态表现的解决方案主要面向立绘和 UI 演出。在 Cocos Creator 3.8 里LIVE2D 可以通过自定义渲染组件或第三方 SDK 集成它解决的是“角色图像如何动起来”的问题而我这里解决的是“角色在玩法层面如何运动、碰撞”的问题两者可以结合比如把 LIVE2D 角色嵌入战斗界面时下层的物理碰撞仍然用传统 2D 物理处理。“跳跃游戏2 贪心算法”这一堆词其实指向的是一个非常经典的字节跳动的面试题或算法题在一个跳跃游戏中如何用最少的跳跃次数到达终点。做角色控制 Demo 到后期优化关卡时这类贪心思路可以用来自动化生成合理跳跃路线、检验关卡可行性比如让脚本判断角色从平台 A 到平台 B 需要跳跃几次。虽然 Demo 里没有实际用上但作为扩展方向它能帮你把“人物控制”这个模块做成数值可调、关卡可测的完整系统。“MD5 碰撞”跟角色控制本身没有直接关系它提醒我们一件事做机制命名和逻辑判断时尽量避免用毫无语义的 hash 值或魔数来标识对象。我见过有人在地面检测回调里比较的是self.uuid xxx这种写法一旦资源重导或换资源条件就失效了。命名规范、用 tag、用节点名常量都是更可靠的做法。最后再分享一个小体会。做角色控制这类功能真正花时间的不是写代码本身而是调手感。我在调跳跃高度和奔跑速度时来来回回试了很多参数重力设为 -300 和 -500 的手感差距非常大。建议你以我这组参数作为起点然后花 30 分钟把 moveSpeed、jumpSpeed、重力三个数值分别上下浮动 20% 跑一遍找到自己项目最舒服的手感组合。这个参数调节过程是制作 2D 游戏最有意思的部分也最值得花时间。