一次发现
前一段时间,我在制作自己的一款游戏,定位为平台解谜。该项目会涉及到平台跳跃的基本要素,以及和推箱子相关的一些操作。我采用的引擎是Godot,起先是用引擎自带的物理系统来处理相关的运动,即Rigidbody和Collider组合。但在测试中发现,因为项目自身的特殊需求,导致自带物理系统始终无法直接地达到理想效果。例如由于碰撞体本身具有一点微小的厚度,导致一些物品无法完全的贴合到凹槽里,突出的部分会影响角色的运动效果。
在翻阅资料过程中,看到了《蔚蓝》开发者写的技术文章,就是讲解《蔚蓝》中专门实现的物理系统。该方法本质上还是常规的碰撞检测思路,只是对于这类游戏的特点和需求,进行了很大程度地简化,所以它实现简单,拓展性也强,因此我决定尝试用此方法来定制自己的物理系统。接下来我将从三个方面展开讨论,首先是这套系统的基本思路,其次是在项目中的运用,如何拓展成整个游戏的物理系统,最后是一些优化和总结。
基本思路
在那篇技术文章中,提出了两个概念和四条约束。两个概念为Solids和Actors,前者指的是在游戏环境中,固定的,不参与运动的碰撞体,例如一个浮空的平台,一个楼梯。后者则是可以运动的对象,例如角色,子弹,可推动的箱子等等。
此外,那四条约束为:
- 所有碰撞体都是轴对齐的边界框,即AABB
- 所有碰撞体的位置,大小都为整数
- 除去特殊情况,Actor和Solid永远不会重叠
- 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物理系统。
(第一次在indienova上发文章,发现居然不支持markdown,希望官方能改进这点。我现在不太清楚怎么才能是文中高亮代码,我选中了要高亮的内容,再选代码时就整段变为代码了)
我目前在制作的Platformer游戏也是运用了他的这个思路(我用的XNA也是像素坐标比较舒服),其实移动平台才是难点,还有会推动也会挤压人的石头这种也是个难点(这个问题困扰我很久直到看到他例子才处理比较好),并且还有单向平台以及移动的单向平台。
他们之一开发者Mat开源了一个Beef写的案例(https://github.com/MaddyThorson/StrawberryBF)。
我基本是把他这套抄过来再做拓展……
@sadi:对,先开始觉得要自己实现一个小的物理系统好像很麻烦,实际上从项目的需求来说,代码量真不大,写完后反而觉得有很多自己去精细操作的空间。你说的那些情况确实更加复杂,由于自己的项目目前都没有做完,所以那些都没有涉及到。另外感谢你提供的这个开源案例,找时间我也去看看。
死亡细胞的主创很久之前也写过一篇有关实现简易物理系统的文章 https://indienova.com/indie-game-development/a-simple-platformer-engine-basics/
@mnikn:谢谢分享,看完了文章,感觉和Spatial Partition其实是一样的思路,而且这个方法也可以融入到这套系统里面,因为目前的检测的时候,是遍历场上的所有entity,如果加入这个方法,也就是可以只和周围的entity做检测。
最近由 阿客 修改于:2022-12-24 02:54:57