美文网首页Godot 游戏引擎
Godot3游戏引擎入门之十五:RigidBody2D刚体节点的

Godot3游戏引擎入门之十五:RigidBody2D刚体节点的

作者: spkingr | 来源:发表于2019-08-02 15:21 被阅读2次
    godot_cover.jpg

    一、前言

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

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

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

    主要内容: RigidBody2D 刚体节点的几个有趣的应用场景
    阅读时间: 10 分钟
    永久链接: http://liuqingwen.me/blog/2019/07/31/introduction-of-godot-3-part-15-several-usage-examples-of-rigidbody2d-node-in-games/
    系列主页: http://liuqingwen.me/blog/introduction-of-godot-series/

    二、正文

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

    1. 刚体节点作为普通的游戏物品或者元素
    2. 刚体节点响应鼠标事件进行拖拽
    3. 利用刚体节点实现爆破特效
    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. 爆破特效

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

    godot_15_explosion_scene.jpg

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

    # 引起爆炸的物体分组名集合,这里为玩家和子弹
    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 ,地址: https://github.com/spkingr/Godot-Demos , 后续继续更新,原创不易,希望大家喜欢! :smile:

    我的博客地址: http://liuqingwen.me ,我的博客即将同步至腾讯云+社区,邀请大家一同入驻: https://cloud.tencent.com/developer/support-plan?invite_code=3sg12o13bvwgc ,欢迎关注我的微信公众号:

    IT自学不成才

    相关文章

      网友评论

        本文标题:Godot3游戏引擎入门之十五:RigidBody2D刚体节点的

        本文链接:https://www.haomeiwen.com/subject/jsmldctx.html