03-场景碰撞与子弹基础
1 设置地图碰撞
1.1 为瓦片添加碰撞体
来到game场景,选中左侧场景树的地面瓦片层,在右侧的检查器中点击Tile Set选项,在展开的列表中找到Physics Layers物理碰撞层设置。点击添加元素按钮。

这样我们来到最下方的面板中,在左侧面板中先选中静态瓦片图集,点击“绘制”按钮,然后展开属性编辑器列表,在下面看到“物理层0”这个选项,点击之后进入到物理层的绘制。

直接在右侧的编辑窗口中点选或者框选这几个用作障碍物的瓦片网格,让这些网格变蓝就完成了。

当然动态瓦片也是相似的操作。我们在左侧选中动态瓦片,然后我们一次点选第一列瓦片,动态瓦片的动画是按行使用的,所以我们只需要也只能设置每一行的第一列,后面的动画帧播放时也会使用相同的碰撞外形。

在完成这些内容后,点击右上角的运行按钮,再次在场景中移动的时候就可以与障碍物发生碰撞了,不过我们的装饰瓦片是没有添加碰撞的,所以玩家还是可以从这个地图漏洞中跑出去,这个是意料之中的。
但是只有一个网格宽的小道好像角色无法进入,这时因为角色默认的碰撞外形太大了。

1.2 调整角色碰撞体大小
我们来到 player 场景,直接点击这个半透明的碰撞圆形,或者在左侧场景树中点击 CollisionShape2D 节点,在右侧的检查器中点击 Shape 属性的值,就可以看到展开的内容中有一个 radius 半径属性,我们双击把它从默认的 10 改成 6,可以看到预览中的这个圆形碰撞范围明显变小,这时运行场景可以看到玩家可以通过一个网格宽度的小道

1.3 地图空气墙
那么接下来还剩下一个需要优化的要点,那就是给地图作为添加空气墙,避免玩家通过这些预留的刷怪入口除去。
当然如果想更简单一些的话,我们也可以简单粗暴的把整个地图边缘都使用我们刚刚添加了碰撞箱的瓦片来填补起来,并且把我们装饰物层的瓦片也使用相同的方式添加上碰撞。
但是有一个更通用的思路,在地图上添加空气箱边界的简单做法。
我们首先在 game 场景添加一个 Node2D 节点,然后把它改名叫做 WorldBounds, 我们在它下面添加地图地图四条边的边界节点,也就是叫做 StaticBody2D 的节点。


我们可以注意到下面的描述,StaticBody2D 是用来制作无法被外力移动的 2D 物理物体,手动移动的时候也不会影响路径上的其他物体,所以使用它来制作类似墙壁等物是最合适的,我们把这个节点命名为 TopBoundary, 也就是地图的上边界

这里还有个感叹号,点一下是说这个节点没有描述形状的节点。这个和我们之前给玩家添加碰照外形的思路是一致的,我们需要在这个节点下方挂载描述它实际碰照外形的节点,我们可以直接在添加节点的左下角这个我最近使用的节点表中找到这个 CollisionShape2D 节点双击添加到场景中。

还有一个感叹号是因为没有设置实际的形状是什么,在右侧检查器中点击 shape, 选择 SegmentShape2D, 把形状设置为直线段,可以看到场景的原点附近有一个蓝色的小线段。

拖拽着其中一个短点向右拉,紧贴地图上边缘。但是这样拖拽不好找对齐,通过编器最上方的按钮打开栅格吸附,再回到场景中拖拽出拽这个端点,可以很轻松的对齐到网格。

同样的道理,我们可以继续使用相同的操作,给地图添加剩下的三个边界,如果偷懒直接复制已有的 static body2D 节点,这样 CollisionShape2D 可能存在相同的引用,导致调整碰撞线条位置时相互干扰。


现在,玩家角色无法逃出地图。
我们就可以在“调试”中找到显示碰撞区的选项,把它打开。这样再次运行调试时就可以清楚地看到这些蓝色的碰撞形状。


2 设计并实现子弹逻辑
我们需要实现以下需求:
- 玩家可以按上下左右方向键,向着八个方向设计子弹
- 在吃到强化道具之后会向着四周发射螺旋弹幕。
- 这些子弹在命中敌人或者墙壁障碍物后会消失。
2.1 新建bullet场景
我们回到godot中在scene目录下右键选择新建场景,根节点类型依然是点选最后一个自定义选项,然后在窗户里面搜索 Area2D。Area2D 是 2D 空间里面的一个区域,能够检测其他的碰照对象的进入或者退出。给这个场景取名bullet。



几个节点的总结
截止现在我们已经见到过很多个节点,这里总结一下
- 首先是制作子弹使用的Area2D
- 子弹的功能非常单纯,只需要沿着固定的方向飞出去,在碰到障碍物或者敌人的时候完成一次命中检测,然后销毁自己。它最核心的功能不是去参加复杂的物理模拟,而是去检测碰到了谁。
- Area2D 最擅长也是最合适去做的事情,就是这种范围检测和碰撞检测。
- 可以把它理解成是一个跟着物体一起移动的检测区域,它不会像角色那样处理碰撞挤压或者阻挡移动,但它可以很方便地来判断进入、离开和重叠的事件。
- 所以在我们的游戏中呢,子弹、道具拾取,爆炸范围检定,还有受击检测,这些内容都需要接助 Area2D。
- Node2D 是一个最基础的二维节点。只有位置、旋转、缩放这些最基本的功能,本身不会对碰撞检测等功能进行特化。
- CharacterBody2D则具有物理模拟、碰撞挤压、脚本控制等功能。这些功能并不需要被子弹使用。
- RigidBody2D 适合处理完全交给物理引擎去模拟的物体,比如石头或者被炸飞的杂物,它们本身会反弹,滚动等。它强调的是完全由物理系统来决定怎么移动,而子弹远没有这样物理模拟的需求,只需要固定速度,固定方向。
- StaticBody2D 则适合不会动的障碍物,墙壁、柱子、地图边界等。
2.2 设置碰撞体和纹理
之后新建一个CollisionShape2D节点和一个Sprite2D节点。

把Sprite2D移动到CollisionShape2D前面方便观察碰撞外形。之后设置Shape为CircleShqpe

然后我们选中 Sprite2D 节点设置渲染所需要的纹理资源,在右侧的检查器面板中点击 Texture 字段,选择新建 AtlasTexture。

然后点一下这个 AtalTexture 选项,展开更详细的配置面板,点击Atlas字段,点击快速加载,就可以看到我们目录下所有合适的资产都列在这里。



双击选择道具 UI 这张图片。可以看到整张图片被贴到了场景中,这显然是不合适的。我们只需要截取其中的子弹的一小部分区域就可以了。

所以还是选中 Sprite 节点,在右侧的面板中点击编辑区域按钮。

在左上角吸附模式下拉列表中选择自动裁剪,godot会自动按照透明区域的范围,识别各个素材的区域。

这时就只需要点击选中图中小方块即可,可以看到在右侧的面板中也同步修改成功了。

回到场景编辑器中放大一些,看到这个碰撞圆形的范围远大于实际渲染大小,所以在右侧的检查器中 Shape 字段,把 Radius 半径调小点调到 3 就差不多合适了。

2.3 添加脚本
在Bullet上右键,选择添加脚本,然后点击创建。

不过在正式开始变编写代码之前,我们先想想这样几个问题。
一个是为什么我们需要单独创建一个 bullet 脚本,直接让 player 玩家去管理自己发射子弹不就行了吗?
好的思维习惯
这是一个很好的思维习惯,我们不仅要实现某个游戏的功能,还要思考为什么当下最佳实践是这样设计的。
我们把子弹自己的状态和逻辑封成独立的子弹对象类,让他自己维护自己的更新和销毁等生命周期行为。如果依赖外部的场景或者玩家脚本来进行管理,首先这些本身只和子弹有关的代码和其他场景代码交杂在一起之后不容易进行调试。
以及万一我们的创建和销毁行为全部由外部系统来管理,一旦因为我们的失误导致一调用或者重复调用,也会埋下一些不稳定的隐患。所以最好的思路就是自己单独创建一个 bullet 脚本来管理子弹自己的状态,对外只提供简单的接口,子弹在游戏中的功能由他自己来决定。
二是我们该让子弹在自己的脚本中实现哪些功能呢?
对于子弹的功能,我们肯定需要他能够保持自己诞生的时候的飞行方向和速度持续动,玩家负责射击来创建子弹,然后子弹自己在游戏更新的过程中持续飞行,而当子弹碰到障碍物或者敌人,或者飞行太久还是没有发生碰撞的时候,应该能够自行销毁。(当然这种情况在我们绘制的地图中是不会出现的,因为我们四周都已经围上了空气墙)
3 编写脚本
首先定义一个全局可识别的脚本类名
class_name Bullet
这样做好处是, 后面在别的脚本里面或者编译器里面想要引用这个脚本类型的时候,就不用总是通过脚本文件路径去识别它.
之后定义一些常量
# 标记子弹碰撞的物理层
const WORLD_COLLISION_MASK := 1
# 子弹飞行速度,单位为像素/秒
@export var speed: float = 320.0
# 自当最大存活时间,防止未命中时永久留在场景中
@export var max_lifetime : float = 2.0
# 子弹当前的飞行方向
var direction: Vector2 = Vector2.RIGHT
# 剩余存活时间,递减到 0 后自动销毁
var remaining_lifetime: float = 0.0
第一行定义了子弹碰撞的物理层。
物理层
关于物理层相关的概念呢,后面再单独拿出来讲解。可以理解成一个过滤器的作用,方便后面手动进行和场景障碍物的碰撞判断。
碰照的过滤是按二进制位计算的,这里的一代表的是第一层,而如果我们写 2 就代表的是第二层,写 4 代表的是第三层,写八代表的是第四层,也就是说如果我们想要和第 n 层的物理层发生碰撞,那么这里的数值就要写 \(2^{n-1}\) 。
之后定义两个函数,_ready和setup这两个与初始化有关的函数。分开写是因为这两个分别是不同的逻辑,也分别在不同的阶段被不同的对象所调用。
_ready()函数就像是我们绝大多数语言的构造函数,它会在实例化挂载到节点上之后才会被引擎自动调用。- 而
setup函数则是由实例化子弹对象的对象,譬如玩家角色对象来初始化子弹方向的时候调用的
# 初始化寿命,并绑定 Area2D 碰撞信号 func _ready() -> void: remaining_lifetime = max_lifetime area_entered.connect(_on_area_entered) # 由外部在生成子弹后调用,注入初始方向 func setup(initial_direction: Vector2) -> void: if initial_direction != Vector2.ZERO: direction = initial_direction.normalized() rotation = direction.angle()
大家注意我们写了一个叫做 connect 函数调用,这个是 Godot 中极为重要的信号机制,这里可以简单理解成就是注册了一个回调函数,当我们子弹这个 Area2D 与其他碰撞区域重叠的时候,就会调用我们自己定义的这个_onarea enter的函数。我们暂时还没有编写这个函数的实现,我们稍后再来写函数内容,先把入口逻辑框架搭建好。信号相关的内容比较多,我们在这里就暂时不进行展开细讲了。
函数命名细节
对于内定的一些函数,以及我们在脚本内部使用的函数都是使用了下滑线开头,而对于像是 setup 这种会暴露到外部提供调的函数呢,就像是我们在 C ++ 中的 public 方法一样,那就直接使用蛇行命名法。
不使用这样的方式去写代码也不会报错,代码也能运行,但这样的好习惯被我们养成之后,后面我们阅读代码的时候呢会更方便,在代码提示自动补权的时候呢也会更好的经筛选
下一步就是物理更新相关的内容了。
# 每帧先检测飞行路径是否会撞到世界,再更新位置并处理超时回收
func _physics_process(delta: float) -> void:
var current_position := global_position
var next_position := current_position + direction * speed * delta
if _will_hit_world(current_position, next_position):
queue_free()
return
global_position = next_position
# 没有命中任何对象,也要在超时后自动清理
remaining_lifetime -= delta
if remaining_lifetime <= 0.0:
queue_free()
- 我们首先获取了 global_position, 也就是全局的世界位置作为初始位置。
- 然后先计算了这一帧运动之后的位置,对这个新位置调用了一个
_will_hit_world的检测,虽然这里我们还没有实现这个函数。但是从函数名的我们就可以看出来,我们需要在这里去检查子弹是否和世界中的边界或者障碍物发生了碰撞,如果发生了碰撞,我们就直接销毁。 -
注意我们在这里使用的是 queue_free, 而不是直接调用 free, 这个是标记清除策略。我们可以在某一个游戏帧中对同一游戏对象调用多次 queue_free, queue_free 的作用并不是直接把游戏对象从内存里面删除,而是把它标记为需要被清除,然后在这一帧结束之后合适的时候再进行实际的删除工作。
这样做的好处有很多,- 一方面能够避免开发者因为函数在这一帧之内调用时区的问题,访问了已经被删除的游戏对象,产生底层的内存错误;
- 另一方面呢,方便引擎自己决定实际释放对象内存的时间,从而避免同一帧中同仁释放大量对象内存造成卡顿,可以分帧执行内存回收任务。
我们在日常在卡过程中如果没有必要,基本上都是使用 QF 的方式来进行对象的删除的。
当然最下面这几行代码是兜底策略,如果子弹没有命中任何对象,也要在超时之后进行清除。
再往下一步,那就是先来实现我们的 VATW 的函数了。
# 使用射线查询检测当前这一帧的飞行路径,避免子弹穿过零厚度边界或者薄墙体
func _will_hit_world(from_position: Vector2, to_position: Vector2) -> bool:
var space_state := get_world_2d().direct_space_state
if space_state == null:
return false
var query := PhysicsRayQueryParameters2D.create(
from_position,
to_position,
WORLD_COLLISION_MASK
)
query.collide_with_bodies = true
query.collide_with_areas = false
var hit_result: Dictionary = space_state.intersect_ray(query)
return not hit_result.is_empty()
为什么要手动实现与障碍物的碰撞?
明明有物理引擎,有碰照检测,但是为什么要手动实现子弹和障碍物的碰照呢?
注意这里我们进行了一个兜底的策略,子弹绝大多数的核心的碰照检测和处理逻辑,我们依然是依靠引擎自带的功能来实现的。
这里要做的是避免子弹因为自身速度过,穿过了我们的空气墙边线或者很薄的障碍物。
这里我们使用了射线检测来检查在这一帧之内的移动是否碰撞到了障碍物。把鼠标选填在上面就可以看到这个方法功能的解释说明以及各个参数的含义,我们按住 ctRl 键点击函数名,也可以跳转到 Godot 内置文档,对应的还会代码示例。

回到我们的代码,最后一步就是编写我们刚才注册的信号回调逻辑了。
# 与 Area2D 碰撞后销毁,同时忽略其他子弹
func _on_area_entered(area: Area2D) -> void:
if area is Bullet:
return
queue_free()
当子弹自身的 Area2D 和和场景中其他的 Area2D 发生碰撞的时候,就会触发这些函数逻辑的调用。在这一部分代码中,
-
我们先检查了合自己发生碰的对象是不是不子弹类型,如果是子弹,我们就直接通过 return 忽略掉,从而避免子弹和子弹发生碰照。
尽管这目前的玩法设计中几乎是不会出现这样的情况的,但是为了严谨给大家做个好点示范,我们还是要养成这样的习惯,夺去思考一下可能出现的边界情况。 -
最后还要进行 queue_free ,子弹在撞到墙壁时候的销毁,我们写到了前面的
_physics_process中,那么当和敌人等其他内容发生碰撞时,要在这里再手动调一下了。
到目前为止,我们的子弹功能基本上全部做好了。
唯一还剩下一点小问题,便是碰撞层级的设置,这部分内容在后面再讲解。当然我们目前还没有把子弹对象的声入到玩家角色的设计逻辑中,这部分内容在下期视频中讲解。
评论
如果你已登录 GitHub,就可以直接在这里评论。
