Godot3游戏引擎入门之十五:RigidBody2D刚体节点的几种应用场景及示例

522 阅读10分钟

Godot3游戏引擎入门之十五:RigidBody2D刚体节点的几种应用场景及示例

一、前言

这一次,让我们来做一些轻松有趣的东西,嘿嘿。 :grin:

在上一篇 Godot3游戏引擎入门之十四:刚体RidigBody2D节点的使用以及简单的FSM状态机介绍的文章中,我们主要讨论了刚体节点 RigidBody2D 的一些常用属性以及在游戏中的简单使用,利用刚体节点开发了一个简单的太空飞船射击小游戏,这一章我们继续探讨刚体节点,研究一下刚体节点的其他几个重要属性,并在场景中做一些简单应用。

除此之外,我还会穿插着介绍一下 Godot 引擎自带的 AStar 最短路径寻路 API 的简单使用。

主要内容: RigidBody2D 刚体节点的几个有趣的应用场景
阅读时间: 10 分钟
永久链接: liuqingwen.me/blog/2019/0…
系列主页: liuqingwen.me/blog/introd…

二、正文

废话不多说,由于自己知识和经验的局限性,暂时我能想到的 RigidBody2D 的应用场景主要有这几个:

  1. 刚体节点作为普通的游戏物品或者元素
  2. 刚体节点响应鼠标事件进行拖拽
  3. 利用刚体节点实现bao破特效
  4. 随机生成地图的应用

注:为了缩短文章篇幅,涉及到的代码只提供核心部分,其他部分代码将省略,有兴趣的朋友可以直接到我的 Github 仓库下载项目的全部源码查看。

1. 普通元素

上一篇文章中,我们使用刚体节点制作了太空飞船和太空岩石,由于是在太空,它们都不会受到重力的影响。实际应用场景中,刚体默认会受到重力的作用,在重力影响下刚体会发生一些有趣的碰撞反馈,我们可以充分利用 RigidBody2D 刚体节点的物理特性,无需手动编写代码即可实现一些简单的特效。

result_1.gif

在这个场景中,木箱子和子弹球都是刚体模型,与我们之前游戏中使用 Area2D 作为根节点的“子弹”场景不同,使用 RigidBody2D 作为根节点,“子弹”可以直接和游戏世界中的其他物体产生碰撞互动。另外,游戏场景中玩家根节点为 KinematicBody2D 节点,能与刚体产生直接互动。从上图中可以看出来,勾选和不勾选 player infinite inertia 选项,玩家和其他刚体的碰撞效果完全不一样,我们先看下玩家 Player 场景的主要代码:

var _velocity := Vector2.ZERO
var _isInfInertia := true

func _physics_process(delta):
    var hDir := int(Input.is_action_pressed('ui_right')) - int(Input.is_action_pressed('ui_left'))
    var vDir := int(Input.is_action_pressed('ui_down')) - int(Input.is_action_pressed('ui_up'))
    var velocity := Vector2(hDir, vDir if isTopDown else 0).normalized() * moveSpeed
    if !isTopDown:
        velocity.y = _velocity.y + gravity * delta
    _velocity = self.move_and_slide(velocity, FLOOR_NORMAL, true, 4, PI / 2, _isInfInertia)

    # 省略代码……

func _shoot() -> void:
    if ! bulletScene || ! _canShoot:
        return
    _canShoot = false
    _timer.start()
    var ball := bulletScene.instance() as RigidBody2D
    ball.position = _bulletPosition.global_position
    ball.apply_central_impulse(bulletForce * _bulletPosition.transform.x)
    self.get_parent().add_child(ball)

# 设置玩家是否为无限惯性力
func setInfiniteInertia(value : bool) -> void:
    _isInfInertia = value

影响玩家与刚体碰撞反馈核心方法是 KinematicBody2D 的方法 move_and_slide() ,这个方法在 Godot 3.1 版本中新增加了一个参数,即最后一个参数 infinite_inertia ,表示玩家是否为无限惯性。如果玩家具有无限惯性属性,那么玩家移动时可以推动刚体,甚至挤压物体,但是不会检测与刚体的碰撞;如果玩家非无限惯性,那么刚体就像静态碰撞体一样会阻止玩家的移动。参数默认值为 true 表示无限惯性。其他的都比较简单了,之前的文章也有讨论。

2. 鼠标拖拽

另一个有意思的应用场景是:我们可以使用鼠标来拖拽刚体进行移动,同时与其他刚体进行交互,最后使用鼠标将其“抛”出去。

result_2.gif

实现这个效果不难,这里我们需要使用到刚体的另一个重要的属性: Mode 属性,即刚体的模式。在刚体属性面板中,我们会发现该属性有 4 种取值设置:

  • Rigid 即普通刚体模式,为默认值
  • Static 静态模式,刚体表现和静态碰撞体一样
  • Kinematic 图形学模式,和 KinematicBody2D 一样
  • Character 人物模式,和普通刚体一样,但是不会发生旋转

利用这一点,我们可以找到实现刚体拖拽的思路:拖拽开始时刻设置刚体的模式为 MODE_STATIC 静态模式,同时控制刚体的全局位置跟随鼠标移动,拖拽结束即松开鼠标后,复原刚体的模式为 MODE_RIGID 普通模式,接着可以给刚体一个临时冲量使其运动。

export var mouseSensitivity := 0.25
export var deadPosition := 800.0

var _isPicked := false  # 判断当前刚体是否被鼠标拖拽

func _input_event(viewport, event, shape_idx):
    # 右键按下时拖拽箱子
    var e : InputEventMouseButton = event as InputEventMouseButton
    if e && e.button_index == BUTTON_RIGHT && e.pressed:
        pickup()

func _unhandled_input(event):
    # 右键松开时抛掉箱子
    var e : InputEventMouseButton = event as InputEventMouseButton
    if e && e.button_index == BUTTON_RIGHT && ! e.pressed:
        # 传入鼠标的移动速度
        var v := Input.get_last_mouse_speed() * mouseSensitivity
        drop(v)

func _physics_process(delta):
    # 更新拖拽盒子的位置,跟随鼠标移动
    if _isPicked:
        self.global_transform.origin = self.get_global_mouse_position()

    # 盒子掉出地图之外删除
    if self.position.y > deadPosition:
        self.queue_free()

func pickup() -> void:
    if _isPicked:
        return
    _isPicked = true
    self.mode = RigidBody2D.MODE_STATIC   # 拾起盒子,更改为静态模式

func drop(velocity: Vector2 = Vector2.ZERO) -> void:
    if ! _isPicked:
        return
    _isPicked = false
    self.mode = RigidBody2D.MODE_RIGID   # 抛掉盒子,更改为刚体模式
    # self.sleeping = false              # 防止刚体睡眠
    self.apply_central_impulse(velocity) # 给盒子一个抛力

核心部分为 pickup()drop() 这两个方法,实现起来非常简单,这里需要提醒的是,对于 RigidBody2D 刚体节点,如果需要响应鼠标事件,即 _input_event() 方法的正常调用,我们必须勾选设置刚体节点的 Pickable 属性

godot_15_pickable.jpg

另外,在代码中有一个值得注意的地方是,松开鼠标后,复原刚体模式为普通模式的同时不能让其进入默认的睡眠状态。阻止刚体睡眠状态有两种方法:

  • sleeping = false 即设置睡眠属性
  • apply_central_impulse(Vector2.ZERO) 给刚体添加一个冲量,大小为 0 也可以

鼠标松开后,我们给物体一个抛力使其运动,所以我们选择第二种方式即可。

3. bao破特效

“物品bao破”特效在游戏中很常见,可以直接使用动画实现,这里我讲的是通过代码来实现物体的bao破特效。我使用了 Github 上一个开源库,非常容易地实现了bao破效果,开源库链接地址: Godot-3-2D-Destructible-Objects 。如何使用这个开源库在其主页上有详细的说明,实际使用过程中,我遇到了的一个问题,如下图所示的场景结构图:特效代码不能直接放在需要bao破的子场景中,而应该放在子场景实例化后的节点上!

godot_15_explosion_scene.jpg

另外,源代码中自带的控制bao zha的方式是鼠标左键点击事件,这里我稍微修改了一下源码,让效果只有在bao zha体与玩家或者子弹碰撞后才会触发,部分代码如下:

# 引起bao zha的物体分组名集合,这里为玩家和子弹
export(Array, String) var triggerGroups := ['player', 'bullet']

func _on_Area2D_area_or_body_entered(area_or_body):
    for group in triggerGroups:
        if area_or_body.is_in_group(group):
            $Explode.explode()
            $Area2D.queue_free()
            return

大家可以自己尝试,效果图如下:

result_3.gif

4. 随机地图

在游戏中随机生成地图是一个非常“巨大”、非常“深入”的话题,不过本篇中我要介绍的随机地图生成只是涉及到其中的一点点皮毛,对这个话题感兴趣的朋友可以到网上找找相关的资料。怎么生成一个随机的地图呢?我的思路大概是这样的:

  • 地图由一个一个的小房间构成
  • 房间之间没有重叠,就像刚体不能互相交叉渗入一样
  • 房间个数、大小、位置都随机
  • 房间之间有路径可达,整个地图必须有一条完整的路径

如何实现这个特别的“房间”呢?其实很简单,我们可以使用 RigidBody2D 节点作为房间场景的根节点,充分利用其物理特性,这里最重要的一点就是设置刚体节点的 Mode 模式属性为 Character 人物模式,以保证其不会发生旋转:

godot_15_room_property.jpg

同时,不需要考虑重力因素,设置重力影响系数设为 0 即可,房间场景 Room 的代码非常简单:

# 设置房间的位置和大小
func makeRoom(pos: Vector2, size: Vector2) -> void:
    self.position = pos
    _size = size

# 获取房间的位置尺寸,可以传入一个偏差值
func getRect(tolerance : float = 0.0) -> Rect2:
    var s = _size - Vector2(tolerance, tolerance)
    return Rect2(self.position - s / 2, s)

接下来我们主要分三步实现随机地图的轮廓。第一步,我们在主场景中生成一定数量的大小随机的房间,利用“人物”刚体模式的特性,房间添加到场景后会自动彼此分开;第二步,我们随机地删除一些房间,让地图显得更加随机;第三步,使用 AStar 寻路算法将我们产生的房间之间的最短路劲找出来。最后一步,肯定是替换“房间”为真正的“地图”,这一步我就没有介绍了,大家完全可以动手实现一个,或者参考我后面给出的相关资料。好了,我们看下效果:

result_4.gif

主要的代码如下:

export var roomScene : PackedScene = null  # 房间子场景
export var roomCount : int = 25            # 房间总数量
export var tileSize : int = 32             # 地图瓦片单元尺寸
export var minSize : int = 4               # 房间最小尺寸,乘以瓦片尺寸
export var maxSize : int = 10              # 房间最大尺寸,乘以瓦片尺寸
export(float, 0.0, 1.0) var cullTolerance : float = 0.4  # 剔除部分房间,系数

onready var _roomContainer := $RoomContainer
onready var _camera := $Camera2D
onready var _windowSize : Vector2 = self.get_viewport_rect().size

var _isWorking := false                    # 是否正在进行生成中
var _astarPath : AStar = null              # AStar算法实例
var _zoom : Vector2 = Vector2.ONE          # 相机缩放
var _offset : Vector2 = Vector2.ZERO       # 相机偏移

# 随机地图生成方法,可以拆分为多个函数,这里分4步
func generateRooms() -> void:
    if ! roomScene || _isWorking:
        return

    # 标记,删除旧房间
    _isWorking = true
    _astarPath = null
    for room in _roomContainer.get_children():
        room.queue_free()

    # 随机生成新的房间,尺寸随机
    randomize()
    for i in range(roomCount):
        var room : Room = roomScene.instance()
        var width := randi() % (maxSize - minSize) + minSize
        var height := randi() % (maxSize - minSize) + minSize
        var size := Vector2(width, height) * tileSize
        room.makeRoom(Vector2.ZERO, size)
        _roomContainer.add_child(room)
    print('Step 1 is done.') # 第一步完成

    # 停留1秒,让生成的房间有足够时间分散开
    yield(self.get_tree().create_timer(1.0), 'timeout')

    # 随机删除一部分房间,把房间的位置全部添加到数组,注意时 Vector3 类型
    var allPoints : Array = []
    for room in _roomContainer.get_children():
        if randf() < cullTolerance:
            room.queue_free()
        else:
            room.mode = RigidBody2D.MODE_STATIC
            allPoints.append(Vector3(room.position.x, room.position.y, 0.0))
    print('Step 2 is done.') # 第二步完成

    # 创建新的AStar算法,添加第一个点
    _astarPath = AStar.new()
    _astarPath.add_point(_astarPath.get_available_point_id(), allPoints.pop_front())
    # 循环所有【未添加的点】,循环所有AStar中【已添加的点】
    # 找出【未添加点】与【已添加点】的距离中,【最短】的距离点,并添加到AStar中
    # 同时将该点从【未添加点集合】中删除
    while allPoints:
        var minDistance : float = INF
        var minDistancePosition : Vector3
        var minDistancePositionIndex : int
        var currentPointId :int = -1
        for point in _astarPath.get_points():
            for index in range(allPoints.size()):
                var pos = allPoints[index]
                var distance = _astarPath.get_point_position(point).distance_to(pos)
                if distance < minDistance:
                    minDistance = distance
                    minDistancePosition = pos
                    minDistancePositionIndex = index
                    currentPointId = point
        var id = _astarPath.get_available_point_id()
        _astarPath.add_point(id, minDistancePosition)
        _astarPath.connect_points(currentPointId, id)
        allPoints.remove(minDistancePositionIndex)
    print('Step 3 is done.') # 第三步完成

    # 等待一帧的时间,用于等待被删除的房间被彻底移除
    yield(self.get_tree(), 'idle_frame')
    if _roomContainer.get_child_count() == 0:
        return

    # 找出所有房间最左上角和最右下角的两个坐标,确定摄像机的缩放和位移
    var minPos := Vector2(_roomContainer.get_child(0).position.x, _roomContainer.get_child(0).position.y)
    var maxPos := minPos
    for room in _roomContainer.get_children():
        var rect := room.getRect() as Rect2
        if rect.position.x < minPos.x:
            minPos.x = rect.position.x
        if rect.end.x > maxPos.x:
            maxPos.x = rect.end.x
        if rect.position.y < minPos.y:
            minPos.y = rect.position.y
        if rect.end.y > maxPos.y:
            maxPos.y = rect.end.y
    _zoom = Vector2.ONE * ceil(max((maxPos.x - minPos.x) / _windowSize.x, (maxPos.y - minPos.y) / _windowSize.y))
    _offset = (maxPos + minPos) / 2
    print('Step 4 is done.') # 第四步完成

    _isWorking = false

代码虽然有点长,不过并不难,相信大家很容易就能看懂,你完全可以把 generateRooms() 方法拆分为多个子方法来实现,这里关于 AStar 的用法我已经在注释中作了简要说明,形象一点,可以参考下图:

Astar.gif

另外,随机生成房间的时候,你可以设置一下房间的坐标位置,比如放置在同一条水平线上等。这里我给大家看下最终的实现效果:

godot_dungeon_generation.gif

相关内容可以参考如下链接:

三、总结

简单的介绍了 RigidBody2D 节点的几个应用场景,不知道大家感觉怎样?有没有更好玩的点子?期待大家的留言,哈哈。

本篇的 Demo 以及相关代码已经上传到 Github ,地址: github.com/spkingr/God… , 后续继续更新,原创不易,希望大家喜欢! :smile:

我的博客地址: liuqingwen.me ,欢迎关注我的微信公众号:

IT自学不成才