碰撞问题往往不是物理引擎错了
Godot 的物理系统已经把很多底层细节封装好了:RigidBody、CharacterBody、Area、CollisionShape、RayCast、PhysicsServer。真正让项目出问题的,通常不是物理引擎算错,而是层和掩码没有设计。角色能撞到金币,子弹打到自己的触发区,敌人射线被装饰物挡住,交互 Area 同时触发战斗伤害,这些都来自碰撞矩阵混乱。
原型阶段,大家会把所有东西放在默认层里。角色、墙、道具、怪物、攻击框、拾取框都能互相检测。功能少时看不出问题,项目一复杂,任何新对象都可能误触旧逻辑。客户端需要一张明确的碰撞矩阵,并把它变成项目规范,而不是靠每个开发自己记。
flowchart TD
A[对象类型定义] --> B[物理层 Layer]
A --> C[检测目标 Mask]
B --> D[碰撞矩阵]
C --> D
D --> E[CharacterBody 移动]
D --> F[Area 触发]
D --> G[RayCast/ShapeCast]
H[调试可视化] --> D
I[构建前校验] --> D
先按语义命名层
Godot 的层本质是 bit,但项目里不能让大家记第 7 层是什么。应该从项目开始就定义语义名称:World、PlayerBody、EnemyBody、PlayerHitbox、EnemyHitbox、Pickup、Interactable、Trigger、Projectile、Sensor。2D 和 3D 项目都一样,先有语义,再映射到 bit。
命名要区分“身体”和“检测”。玩家身体用于移动阻挡,玩家攻击框用于打敌人,玩家拾取范围用于捡道具。它们不应该共用同一层。否则敌人子弹可能打到玩家拾取范围,或者拾取 Area 被墙阻挡。
层数量要克制。Godot 给的层够多,但不是越细越好。每增加一层,矩阵复杂度都会上升。能用对象状态判断的,不一定要新建层;会影响物理查询结果的,才值得建层。
Body、Area、RayCast 的目标不同
CharacterBody 的碰撞主要回答“我能不能走过去”。Area 主要回答“我进入了某个触发范围”。RayCast 主要回答“这条线看到了什么”。三者使用同一套层和掩码,但业务意义不同。不要为了让 Area 检测到某个对象,就把 Body 的 mask 也改掉。
例如玩家角色的 CharacterBody 应该撞 World 和动态阻挡,不必撞 Pickup;玩家拾取 Area 应该检测 Pickup,不必检测 World;玩家交互射线应该检测 Interactable 和 NPC,不必检测 EnemyHitbox。每个节点只打开自己需要的 mask。
射线尤其容易出错。瞄准射线、地面检测射线、交互射线、AI 视线射线,目标完全不同。最好给每种 RayCast 一个封装或配置,而不是复制节点后手动改 mask。
攻击框和受击框要独立
动作游戏里,hitbox 和 hurtbox 的层要清楚。玩家攻击框检测敌人受击框,敌人攻击框检测玩家受击框。角色身体层不应该直接承担受击判定,否则墙角、位移和碰撞体变化都会影响命中。
攻击框通常是 Area,生命周期很短,由动画事件打开和关闭。受击框可以跟随角色骨骼或节点。它们的层和 mask 应该在场景模板里预设好,避免每个技能复制时出错。
友军伤害、阵营切换、无敌状态也不要随便改层。频繁改层会让调试复杂。可以在检测到对象后,再由战斗逻辑判断阵营和状态。层负责粗筛,逻辑负责细判。
动态对象要有状态切换规则
有些对象会改变碰撞状态:门从关闭到打开,平台从实体到半透明,角色死亡后变成尸体,子弹命中后失效。状态切换时要同时更新 layer、mask、monitoring、disabled 等属性。只关掉可见性,不代表碰撞消失。
建议给这类对象封装方法:set_door_open(bool)、set_character_dead()、set_projectile_active(bool)。方法里统一修改碰撞和视觉。不要让外部脚本直接改 CollisionShape 的 disabled,否则状态很容易不一致。
Godot 里修改碰撞 shape 有时需要注意物理帧时机。批量启停攻击框时,最好在固定流程里做,避免同一帧信号顺序不可预期。
调试矩阵要可视化
碰撞问题必须可视化。Godot 编辑器可以显示碰撞形状,但层和 mask 仍不直观。项目可以做一个调试面板,选中节点时显示它所在 layer、mask、最近碰撞对象、最近 Area 进入事件、最近 RayCast 命中。
还可以生成碰撞矩阵表:行是对象类型,列是目标类型,格子表示是否应该检测。构建前扫描关键场景,发现对象层设置不符合矩阵就报错。比如 Pickup 不应该被 EnemyHitbox 检测,若某个金币场景配置错,工具能提前发现。
线上问题也需要日志。玩家反馈“按钮点不到”或“怪物隔墙打我”时,如果测试包能记录射线命中对象和层信息,定位会快很多。
小结
Godot 物理层的关键是工程纪律。先定义语义层,再给 Body、Area、RayCast 分配不同检测目标,攻击框和受击框独立,动态状态切换封装,最后用可视化和构建校验保证矩阵不漂。碰撞矩阵一旦清楚,很多看似玄学的物理 bug 都会变成可解释的配置问题。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
我会把碰撞矩阵写成一份机器可读配置,编辑器插件和运行时调试都读取它。这样文档、工具和实际场景使用同一份规则,避免 Wiki 写一套、项目里又是另一套。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。