昨天讲本地化,讲的是「让更多人买」;今天回到最容易被忽略、又最容易让人抓狂的一层:碰撞配置。
做 2D 游戏的人几乎都经历过这种夜晚:子弹飞过去了,敌人一动不动;body_entered 明明连了,打印出来是空的;玩家站在地刺上,血条不掉。你去问 DeepSeek,它给你一大段「信号连接没问题」的代码——可问题从来不在代码里,在 Inspector 里那两个不起眼的数字:collision_layer 和 collision_mask。
这两个数字不懂,你写到第 80 天照样会卡住;懂了,它就是一张用 32 位表达的游戏世界关系表,配一次,后面所有玩法都省事。
今日速览
- layer = 我在哪层,mask = 我要找哪层。layer 是「我是谁」(别人查得到的身份),mask 是「我关心谁」(我要扫描的目标)。两者完全独立,一个物体可以待在好几层、也可以同时查好几层。
- 判定成立的条件是「查的人 mask 里有被查者的 layer」,单方向就够。子弹要打中敌人 → 子弹的 mask 必须含敌人的 layer,敌人的 mask 里有没有子弹无所谓。
- Area2D 有两个开关:
monitoring(我能检测别人)+monitorable(别人能检测我)。默认都是 true,一旦为了性能关掉,另一方怎么配 mask 都收不到信号。 - 层名一定要在 Project Settings → Layer Names → 2D Physics 里命名。不命名的话你半年后看到「Layer 7」只会想重启项目。
- 物理回调里不能直接改碰撞形状,必须
set_deferred("disabled", true),否则控制台甩你一句「Can’t change this state while flushing queries」。
核心学习内容
一、两个数字,一张世界关系表
Godot 的每个 CollisionObject2D(Area2D、CharacterBody2D、StaticBody2D、RigidBody2D)都带两个 32 位整数:
| 属性 | 回答的问题 | 类比 |
|---|---|---|
collision_layer | 我属于哪些层? | 我的身份证 / 我站在哪个房间 |
collision_mask | 我扫描哪些层? | 我的雷达扫哪个频段 |
新手最常犯的错是把两者当同一个东西用——「我要撞墙,那我把自己的 layer 设成墙的层」。错。撞墙需要的是:你的 mask 里勾上「World」层,墙自己 layer 勾「World」、mask 全空即可。
一个能撑到上架的分层方案,第一版就该定下来(32 层够用一辈子):
| 层 | 名称 | 谁在这个层 | 建议 mask |
|---|---|---|---|
| 1 | World | 地面、墙、平台 | 空 |
| 2 | Player | 玩家本体 | World, Enemy, Hazard, Pickup |
| 3 | Enemy | 敌人本体 | World, Player |
| 4 | PlayerHitbox | 玩家攻击判定(Area2D) | Enemy |
| 5 | EnemyHitbox | 敌人攻击判定(Area2D) | Player |
| 6 | PlayerBullet | 玩家子弹 | World, Enemy |
| 7 | EnemyBullet | 敌方子弹 | World, Player |
| 8 | Pickup | 金币、道具 | 空(靠玩家去扫它) |
| 9 | Trigger | 关卡触发器、剧情区 | Player |
注意两件事:① 攻击判定和本体分不同层——这样「敌人的身体挡住玩家」和「玩家的刀砍到敌人」是两套关系,互不干扰。② 拾取物 mask 留空,因为它不需要主动找任何人;是玩家的 mask 里去勾 Pickup。谁主动,谁配 mask。
二、判定成立,只要三个条件同时为真
一颗子弹打中敌人,实际发生的是:
- 子弹的
Area2D(或RayCast2D)的 mask 含 Enemy 层 → 引擎愿意把它俩放进同一个碰撞对; - 双方的 CollisionShape2D 都有形状且未 disabled → 有东西可撞(形状缩放成
(0.5, 0.5)也会出怪事,永远用尺寸手柄改); - 两个对象在同一个 canvas 里(同一个 Viewport / CanvasLayer)→ 跨 canvas 的碰撞行为是未定义的。
这三条里任何一条不成立,你在代码里写什么都不会触发。所以排查顺序永远是:先看 Inspector,再看信号,最后才看代码。
顺带一个反直觉的点:body_entered 对同一个物体只在进入时发一次,body_exited 才对上。如果子弹用「进入即消失」,那是正确用法;但如果你指望「停留期间每帧扣血」,就得用 get_overlapping_bodies() 在 _physics_process 里自己轮询——这也是持续伤害类地形(岩浆、毒池)的标准做法。
三、运行时改掩码:正确姿势与两个坑
敌人死亡后要变成「不可被再次命中」,有两种写法:
# 写法 A:改层(推荐,一行搞定,还能顺手关掉物理)
set_deferred("collision_layer", 0)
# 写法 B:按位改,可读性最好,注意 layer_number 是 1 开始
set_collision_mask_value(Layers.ENEMY, false)
坑一:在物理回调里直接改会报错。原因是此刻物理服务器正在 flush 查询队列,任何结构变更都得排队到下一帧——所以改 collision_layer / disabled 时请统一加 set_deferred,或者 call_deferred。
坑二:改形状也同理。$CollisionShape2D.disabled = true 写在 body_entered 里,一半概率无效一半概率报错。写成 $CollisionShape2D.set_deferred("disabled", true)。
最后一条工程习惯:把层号写成一个常量脚本,永远不要在图里看到魔法数字。
# res://scripts/layers.gd (class_name Layers)
class_name Layers
const WORLD := 1
const PLAYER := 2
const ENEMY := 3
const PLAYER_HITBOX := 4
const ENEMY_HITBOX := 5
const PLAYER_BULLET := 6
const ENEMY_BULLET := 7
const PICKUP := 8
const TRIGGER := 9
🎯 DeepSeek 提示词
提示词 1:一次性设计整套碰撞层关系表
适用场景:项目刚起步,或已经乱了想重构。把这张表贴进项目 README,以后每次加实体都照表勾。
你是一位有 10 年经验的 Godot 4 技术策划(technical designer),擅长把玩法关系抽象成物理层配置。
【我的游戏】俯视角 2D 动作游戏,玩家有近战和远程攻击,敌人会射弹,地图有可破坏箱子、地刺、金币、Boss 房间触发区。
【任务】为这个游戏设计一套 2D 物理碰撞层方案,要求:
1. 先用 32 层表(层号 1-32)列出:层名、谁属于这层、这一层的物体 mask 应该勾哪些层;
2. 严格遵守「谁主动谁配 mask」原则,被动方 mask 可以留空;
3. 把「本体」与「攻击判定」分离到不同层,并说明为什么这样设计能避免哪些 bug;
4. 给出 GDScript 常量文件(class_name Layers),层号常量命名用大写下划线风格;
5. 最后列出 5 个新手在这个配置下最容易犯的错,每条一句话。
【输出格式】Markdown 表格 + GDScript 代码块 + 编号列表。不要解释 Godot 是什么,不要写多余的教程段落。
提示词 2:让 DeepSeek 写「命中一次就失效」的子弹脚本
适用场景:需要一发子弹只结算一次伤害、并且不会打到自己人的标准实现。
你是 Godot 4.x 的 GDScript 专家。请写一个玩家子弹脚本(Area2D 为基础),严格要求:
【行为】
- 沿 direction 方向以 speed 匀速飞行,超出屏幕/超过 lifetime 秒后 queue_free()
- 撞击到 mask 中的对象时,只结算一次伤害(用 _has_hit: bool 防重复),然后播放命中特效并消失
- 如果命中 World 层,只消失、不结算伤害
- 命中后调用目标的 take_damage(amount),用 has_method() 判断,避免目标没实现时报错
- 不销毁目标本身,只负责传伤害
【约束】
- Godot 4.x 语法,使用 @export 暴露 speed / damage / lifetime
- 所有信号在 _ready() 里用代码连接,不依赖编辑器连线
- 任何在物理回调中修改碰撞形状或 layer 的地方,必须用 set_deferred
- 不要用 get_node("...") 硬编码路径,用 @onready + $ 或 @export 注入
- 每个不显然的写法加一行中文注释,说明为什么
【输出】一段完整可粘贴的 .gd 文件内容,不要分多段,不要额外解释。
提示词 3:「子弹打不到敌人」故障诊断器
适用场景:任何「明明连了信号却没反应」的时刻。把节点树和配置贴进去,比你自己盯半小时快。
你是 Godot 4 的调试专家。我的子弹打不到敌人,请按顺序帮我定位。
【现象】子弹飞过去穿过敌人,body_entered 信号不触发,没有报错。
【我的配置】
- 子弹节点树:(粘贴,例如 Bullet(Area2D) > Sprite2D, CollisionShape2D)
- 子弹 collision_layer = ___,collision_mask = ___
- 敌人节点树:(粘贴)
- 敌人 collision_layer = ___,collision_mask = ___,Area2D.monitoring = ___,monitorable = ___
- 信号连接方式:(编辑器连线 / 代码 connect)
【任务】按「最可能出错 → 最不可能」的顺序输出排查清单,每条包含:
1. 检查项(具体到哪个属性/哪个面板)
2. 为什么它会导致这个现象
3. 一句话验证方法(例如临时打印 get_overlapping_areas().size())
最后给出一份「正确的最小配置」示例,用 GDScript 写出子弹与敌人各自该设置的 layer/mask 数值。
【约束】不要泛泛建议「检查信号连接」,每条必须可执行;不要超过 8 条。
学习心得
做到第 81 天,我越来越确信一件事:用 DeepSeek 做游戏,瓶颈不在生成代码,而在「你能不能描述清楚世界规则」。碰撞层就是最好的例子——你没法向 AI 描述「子弹应该打中敌人」,除非你先想清楚「子弹属于哪层、扫描哪层、敌人的本体和受击判定是不是同一个东西」。这套抽象能力,AI 替不了你。
好消息是,一旦你把这张表写下来,DeepSeek 的代码通过率会明显变高:因为层号成了常量,它不再需要猜 collision_mask = 4 是什么意思,也不用在脚本里塞魔法数字。上一篇文章讲的「项目宪法」,落到物理层就是这九个常量。
对一款要上 Steam 的游戏来说,这类配置还有个隐性收益:手感 bug 有一大半伪装成「物理问题」。玩家抱怨「被卡住」「攻击判定太窄」,往往不是你数值没调好,而是层配错导致本该死的敌人没死、本该穿过的门把你挡住了。先把关系表理干净,再谈手感。
明日关注
- 在自己项目里把 Layer Names 全部命名,导出成
layers.gd常量,替换掉所有魔法数字 - 用提示词 3 跑一遍现有项目的「命中链路」,记录每个实体的 layer/mask 是否与表格一致
- 给持续伤害地形(地刺/岩浆)加一条「停留每 0.5 秒扣血」的轮询实现,验证
get_overlapping_bodies()的用法
本文由自动化学习流水线每日生成,坚持即学即记。
评论