基于《蔚蓝(Celeste)》实现一个简易的2D物理系统(一)

作者:阿客
2022-12-22
6 4 2

一次发现

前一段时间,我在制作自己的一款游戏,定位为平台解谜。该项目会涉及到平台跳跃的基本要素,以及和推箱子相关的一些操作。我采用的引擎是 Godot,起先是用引擎自带的物理系统来处理相关的运动,即 Rigidbody 和 Collider 组合。但在测试中发现,因为项目自身的特殊需求,导致自带物理系统始终无法直接地达到理想效果。例如由于碰撞体本身具有一点微小的厚度,导致一些物品无法完全的贴合到凹槽里,突出的部分会影响角色的运动效果。

在翻阅资料过程中,看到了《蔚蓝》开发者写的技术文章,就是讲解《蔚蓝》中专门实现的物理系统。该方法本质上还是常规的碰撞检测思路,只是对于这类游戏的特点和需求,进行了很大程度地简化,所以它实现简单,拓展性也强,因此我决定尝试用此方法来定制自己的物理系统。接下来我将从三个方面展开讨论,首先是这套系统的基本思路,其次是在项目中的运用,如何拓展成整个游戏的物理系统,最后是一些优化和总结。

基本思路

在那篇技术文章中,提出了两个概念和四条约束。两个概念为SolidsActors,前者指的是在游戏环境中,固定的,不参与运动的碰撞体,例如一个浮空的平台,一个楼梯。后者则是可以运动的对象,例如角色,子弹,可推动的箱子等等。

此外,那四条约束为:

  1. 所有碰撞体都是轴对齐的边界框,即AABB 
  2. 所有碰撞体的位置,大小都为整数
  3. 除去特殊情况,Actor 和 Solid 永远不会重叠
  4. Solid 之间不相互作用

其中的第一点和第二点是这套系统的基石。AABB 碰撞检测,简单介绍的话,就是说所有碰撞体均为不可旋转的矩形,其长和宽与游戏世界 xy 轴是保持平行的。这样的做法有个极大的好处,就是碰撞的计算效率非常高,只需要利用位置和大小进行加减运算即可得出碰撞结果。有个碰撞检测之后,就要考虑碰撞对运动物体产生的影响。如下图所示,如果有两个物体相隔 5 个单位,然后其中一个物体以 4 个单位每秒的速度向另一个物体的方向移动,那么他们也就必然会相撞,或者说发生重叠。而符合我们预期的结果应当是,运动物体最终只移动了 3 个单位便停止了运动。

那么问题来了,我们该如何通过碰撞检测,得到我们最终应该移动的距离?答案是:一步步检测。正如第二点所说,所有物体的位置和碰撞大小都为整数,那也就意味着两个物体分别在 x 和 y 轴上,都相隔整数的距离,那一步步检测就意味着运动物体可以通过不断加 1 的方法,来检测当前位置是否与其他物体重叠。举个例子,一个角色要在下一帧向 x 轴的正方向移动 5 个单位,那么就是从 0 开始循环,物体的 position.x 每一次循环都加 1,便得到一个新的位置,再判断是否碰撞,如果不碰撞,继续加 1。如果碰撞了,就立即停止了。

代码实现

下面是在自己项目中实现的代码,脚本语言是引擎自己的 GDScript,实现方式和原文也有所不同。

func move(velocity: Vector2) -> void:   
    _current_velocity = velocity     
    _remainder += velocity

    _collision_result_x = null
    _collision_result_y = null

    var move = round(_remainder.x)
    if(move != 0):
        _remainder.x -= move
        global_position += _move_exact(move, _AXIS_X)
        if _collision_result_x != null and _on_collision_x != null:
            _on_collision_x.call_func()

    move = round(_remainder.y)
    if(move != 0):
        _remainder.y -= move
        global_position += _move_exact(move, _AXIS_Y)
        if _collision_result_y != null and _on_collision_y != null:
            _on_collision_y.call_func()
func _move_exact(amount: int, axis: Vector2) -> Vector2:
    var step = sign(amount)    
    var offset = axis * step
    var total_offset = Vector2.ZERO

    while(amount != 0):        
        var result = CollisionSystem.collision_detect(self, total_offset + offset)
        if result != null:            
            if axis == _AXIS_X:
                _collision_result_x = result                
                break
            else:                
                _collision_result_y = result
                break

        total_offset += offset        
        amount -= step        

    return total_offset

在第一部分代码里,move 是被运动物体用来调用,输入速度,得到位移。在这个过程中,速度会被分解为 x 和 y 轴两个独立的向量来处理。这两个向量会再通过 _move_exact 继续进行处理,得到在该方向上实际移动的距离。

在第二部分代码中,向量会被切分为长度为 1 的单位向量,一步步进行碰撞检测,调用 CollisionSystem.collision_detect 去对当前物体的新位置进行检测。如果得到碰撞结果,就立刻停止,并且返回最终得到的偏移量,即该方向上的移动距离。x 和 y 两个轴上的位移最终得到物体实际位移。

基本思路说完了,现在还有一个问题。大小位置这些的单位到底是什么?答案是:像素。首先像《蔚蓝》这样的游戏,都是以像素风格作为美术表现,所以其中的各种大小和距离都必然是整数,而我自己正在开发这款游戏,也是像素风格。其次就是 Godot 这款游戏引擎,对于 2D 游戏的处理,本身就是默认以像素为单位,所以和方案的思路是比较契合的。

在文章中还讨论了移动平台的代码实现,但由于目前项目里还未涉及相关需求,就暂时没有在这方面进行更多的挖掘。当然,对这套物理系统还有更多兴趣的朋友,还可以看看这篇文章,同样是一位国外的开发者,游戏也是像素平台跳跃。他的方法也是基于《蔚蓝》的物理系统,但还是有些不同的处理。

下一篇文章,我将展开去讨论如何基于这个设计,搭建出的一个简易 2D 物理系统。

近期点赞的会员

 分享这篇文章

阿客 

想成为很棒的游戏设计师 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. Nova-MDJryt 2022-12-22

    严格来说,应该是碰撞检测系统吧。

    • 阿客 2022-12-23

      @Nova-MDJryt:这个看个人理解和喜好吧。文中提到的两篇文章都是用Physics这个词来描述的,而我自己也觉得做的内容不仅仅是碰撞的检测,因为要去考虑运动效果而不是单纯的碰撞,所以才有全为整数和一步步检测的理念。

您需要登录或者注册后才能发表评论

登录/注册