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

2019-07-31 by Liuqingwen | Tags: Godot | Hits

一、前言

这一次,让我们来做一些轻松有趣的东西,嘿嘿。 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 场景的主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 普通模式,接着可以给刚体一个临时冲量使其运动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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

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

1
2
3
4
5
6
7
8
9
# 引起爆炸的物体分组名集合,这里为玩家和子弹
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 的代码非常简单:

1
2
3
4
5
6
7
8
9
# 设置房间的位置和大小
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

主要的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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自学不成才


Comments: