Godot游戏开发实践之一:使用High Level Multiplayer API制作多人游戏(下)

2020-07-23 by Liuqingwen | Tags: Godot | Hits

一、前言

继续接着上篇介绍局域网多人游戏的开发: Godot游戏开发实践之一:使用High Level Multiplayer API制作多人游戏(上) ,本篇主要讲解代码分析与开发总结。

主要内容: 局域网多人游戏开发代码简析与开发小结
阅读时间: 12 分钟
永久链接: http://liuqingwen.me/2020/07/23/godot-game-devLog-1-making-game-with-high-level-multiplayer-api-part-2/
系列主页: http://liuqingwen.me/introduction-of-godot-series/

二、正文

本 Demo 示例源码我已经上传到 Github ,另外有兴趣的话,可以在这里体验一下游戏的粗糙程度: https://gotm.io/spkingr/bomberman ,进入游戏点击 Host Lobby ,创建服务器后可以邀请好友一起开启“疯狂炸弹”之旅。重要提醒:这个游戏的所有图形都是我自己画的,第一次画图难免垃圾到掉渣,另外背景音乐也是我花了 5 分钟搞定的,默默忍受新手带来的视听折磨吧! joy

Demo12

部分游戏代码简析

首先,在联网游戏中,最重要,也是最核心部分当是处理游戏中局域网络连接的代码。这里用的是一个单例( Singleton )脚本,在 Godot 中也叫 AutoLoad ,代码不需要绑定在节点上,关于 AutoLoad 可以查看官网文档介绍: Singletons (AutoLoad) 。处理网络连接的是 GameState.gd 单例脚本,需要在项目设置里添加、启用即可:

Godot AutoLoad

一、 GameState 代码

直接上菜:

GameState.gd
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
extends Node

# 自定义信号
signal player_list_update(players, colors) # 新玩家加入后信息更新
signal player_color_update(id, color) # 玩家颜色更新
signal player_ready_status_update(id, isReady) # 玩家准备或者取消准备
signal player_disconnected(id) # 连接断开信号
signal connection_succeeded() # 连接成功信号
signal game_ended(why) # 游戏结束信号
signal game_ready(isReady) # 游戏玩家是否已经准备好
signal game_loaded() # 游戏加载完成即将开始

# 定义端口,最大连接数量,需要加载的游戏场景,还有玩家可选颜色
const PORT := 34567
const MAX_PLAYERS := 4
const GAME_SCENE := 'res://World/Game.tscn'
const COLORS := [Color('#B0BEC5'), Color('#8D6E63'), Color('#FFAB91'), ...] # 省略

# 基本属性:联网id,名字,颜色,其他玩家的相关信息等
var myId := -1
var myName := ''
var myColor := Color.white
var otherPlayerNames := {} # id-name 字典
var otherPlayerColors := {} # id-color 字典
var isGameStarted := false

# 已经准备好的玩家和当前可用颜色,只在主场景中使用(实际是服务器)
master var readyPlayers := []
master var availableColors := []

# 这里5个信号都是 Godot High-level multiplayer API 自带信号
func _ready() -> void:
self.get_tree().connect('network_peer_connected', self, '_onNewPlayerConnected')
self.get_tree().connect('network_peer_disconnected', self, '_onPlayerDisconnected')
self.get_tree().connect('server_disconnected', self, '_onServerDisconnected')
self.get_tree().connect('connected_to_server', self, '_onConnectionSuccess')
self.get_tree().connect('connection_failed', self, '_onConnectionFail')

上面的代码是一些基本定义,在上一篇已经讨论过:所有的代码是共享通用的。所以客户端的代码也如此,每个玩家不仅要保存自己的相关信息,还要记录其他玩家的相关信息,代码中表现为变量 otherPlayerNames/otherPlayerColors 的必要性。另外 _ready() 方法中的 5 个 Godot 自带信号一般都是必备的,用于处理网络连接相关事件,具体可以参考官方文档: 管理连接 Managing connections 。我们分别研究这些信号触发的地点、调用方式以及作用:

GameState.gd
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
# 每当有新客户端连接到服务器,所有其他玩家的id都会调用该方法
# 不论当前节点是服务端还是客户端:相当于我收到了来自该id的玩家连接通知
func _onNewPlayerConnected(id : int) -> void:
if isGameStarted:
return

# 通过 rpc_id 将自己的信息远程发送给对方
self.rpc_id(id, '_addMyNameToList', myName, myColor)

# 仅【服务端】处理游戏准备事件、分配颜色
if self.get_tree().is_network_server():
self.emit_signal('game_ready', false)

var color := _getRandomColor()
self.rpc('_updateColor', id, color)

# 每当客户端id断开链接,所有其他玩家都会调用该方法
# 如果游戏已经开始,则发出 player_disconnected 的信号
# 否则仅需要移除该 id 玩家的相关信息即可(比如准备状态等)
func _onPlayerDisconnected(id : int) -> void:
if isGameStarted:
self.emit_signal('player_disconnected', id)
else:
_removeDisconnectedPlayer(id)

# 当前客户端链接成功,仅【客户端】调用
# 表明当前本地玩家进入了游戏大厅,可以准备游戏了
func _onConnectionSuccess() -> void:
self.emit_signal('connection_succeeded')

# 服务器断开,仅【客户端】调用
# 对应操作一般是退出游戏,清空网络连接等相关信息
func _onServerDisconnected() -> void:
self.emit_signal('game_ended', 'Server disconnected.')

# 客户端链接失败,仅【客户端】调用
func _onConnectionFail() -> void:
self.emit_signal('game_ended', 'Connection failed.')

# 远程方法,处理来自其他玩家的调用,添加其他玩家的信息到 otherPlayerNames
# 注意,这个方法实际是其他玩家调用(发送),或者说你通过该方法接收到了来自其他玩家的信息
remote func _addMyNameToList(playerName : String, playerColor : Color) -> void:
var id = self.get_tree().get_rpc_sender_id()
otherPlayerNames[id] = playerName
if ! otherPlayerColors.has(id):
otherPlayerColors[id] = playerColor
self.emit_signal('player_list_update', otherPlayerNames, otherPlayerColors)

# 更新颜色,颜色随机选取,仅由【服务器】决定分配,确保颜色不重复
# remotesync 表明该方法在每个玩家中都会运行,由服务器统一发起调用
remotesync func _updateColor(id : int, color : Color) -> void:
if id == myId:
myColor = color
else:
otherPlayerColors[id] = color

self.emit_signal('player_color_update', id, color)

# 省略部分代码……

我在编写这段代码的时候遇到过一个好玩的 Bug :信号 network_peer_connected 发出后加入的新玩家颜色为默认的白色!之前我并没有单独定义一个 player_color_update 颜色更新信号,只是在 _addMyNameToList 方法中更新玩家的名字、颜色。为什么会出现名字正确但是颜色错误的问题呢?原因很简单:虽然此方法会将玩家自身颜色发送到其他玩家场景中,但是如果是新玩家,其颜色很可能还没有被服务器执行分配,因此默认显示白色。解决办法正如我所说的,添加了一个更新颜色的信号,以保证每个玩家收到其他玩家的颜色值是正确的。

在进行联网之前我们首先需要创建服务器,或者作为客户端连接到已知服务器,代码部分:

GameState.gd
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
# 创建服务器,这里返回一个结果
# 如果一个 IP 被占用就会返回错误
func hostGame(playerName: String) -> bool:
myName = playerName
otherPlayerNames.clear()
otherPlayerColors.clear()
availableColors = COLORS.duplicate()
readyPlayers.clear()

var host := NetworkedMultiplayerENet.new()
var error := host.create_server(PORT, MAX_PLAYERS)
if error != OK:
return false

self.get_tree().network_peer = host
self.get_tree().refuse_new_network_connections = false

myId = self.get_tree().get_network_unique_id() # id = 1 is the server
myColor = _getRandomColor()
return true

# 创建客户端,加入游戏,需要指定 IP 地址
func joinGame(address: String, playerName: String) -> bool:
myName = playerName
otherPlayerNames.clear()
otherPlayerColors.clear()
readyPlayers.clear()

var host := NetworkedMultiplayerENet.new()
var error := host.create_client(address, PORT)
if error != OK:
return false

self.get_tree().network_peer = host

myId = self.get_tree().get_network_unique_id()
return true

# 重设网络为 null ,断开所有连接
func resetNetwork() -> void:
isGameStarted = false
otherPlayerNames.clear()
otherPlayerColors.clear()
self.get_tree().network_peer = null

这部分代码非常简单,官方文档重点有介绍。有了服务器和客户端,接下来准备开始游戏,为了让联网玩家同步游戏,这一部分代码可谓是“一波三折”:

GameState.gd
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
# 客户端调用,准备或者取消准备状态
func readyGame(isReady : bool) -> void:
self.rpc('_readyGame', isReady)

# 远程发送玩家是否处于准备状态的方法
remote func _readyGame(isReady : bool) -> void:
# 某玩家发送,其他所有玩家都会收到,更新该玩家的准备状态
var id := self.get_tree().get_rpc_sender_id()
self.emit_signal('player_ready_status_update', id, isReady)

# 这部分代码仅【服务器】端处理,可以根据玩家是否【全部准备好】来决定是否可以开始游戏
if self.get_tree().is_network_server():
if isReady:
readyPlayers.append(id)
self.emit_signal('game_ready', readyPlayers.size() == otherPlayerNames.size())
else:
readyPlayers.erase(id)
self.emit_signal('game_ready', false)

# 【服务器】端调用,房主点击开始游戏按钮
# 正式开启了:一波三折游戏开始系列!
func startGame() -> void:
self.get_tree().refuse_new_network_connections = true
readyPlayers.clear()
self.rpc('_prestartGame')

# 1. 开始游戏第一步:实例化游戏场景,并且暂停,通知服务器等待其他玩家
remotesync func _prestartGame() -> void:
isGameStarted = true
# 实例化游戏战场,并暂停,等待
var game : Node2D = load(GAME_SCENE).instance()
game.name = 'Game'
game.set_network_master(1)
self.get_parent().add_child(game)
self.get_tree().paused = true

if self.get_tree().is_network_server():
# 服务器端本地运行
_postStartGame(myId)
else:
# 1 代表服务器 id,向服务器发送可以开始了的消息
self.rpc_id(1, '_postStartGame', myId)


# 2. 开始游戏第二步:等待所有玩家全部加载、实例化游戏场景
# 由上面的调用我们知道:这个方法一定只会运行在服务器端
remote func _postStartGame(id : int) -> void:
readyPlayers.append(id)
# 确保所有玩家都已经准备好,包括自己
if readyPlayers.size() == otherPlayerNames.size() + 1:
self.rpc('_startGame')

# 3. 开始游戏第三步:全部进入游戏,开始
remotesync func _startGame() -> void:
readyPlayers.clear()
self.emit_signal('game_loaded')

代码的运作方式都在注释里进行了说明,如果还有疑问可以给我留言,我尽量解答。 smile

二、 Game 主游戏场景代码

上面的代码显示第一个实例化的节点正是游戏主场景: Game.gd 。游戏正式开始后,游戏主场景会添加所有游戏玩家(还记得上一篇吗?一个主节点玩家,其他全部为奴隶节点),当然也需要处理其他事件:玩家事件处理、发送相关消息、玩家死亡与结果、敌人的生成等,这些内容不复杂,有兴趣的朋友可以翻看源码,这里我把关键部位稍加解释:

Player.gd
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
# 初始化
func _ready() -> void:
if GameConfig.isSoundOn:
_audioPlayer.play()

_resultPopup.showPopup('Waiting for other players...', 'Waiting', true, _resultPopup.BUTTON_BACK_BIT + _resultPopup.BUTTON_STAY_BIT)

GameState.connect('game_loaded', self, '_onGameLoaded')
GameState.connect('game_ended', self, '_onGameEnded')
GameState.connect('player_disconnected', self, '_onPlayerQuit')

_setDifficulties()
_addPlayers()

GameConfig.sendMessage(GameConfig.MessageType.System, GameState.myId, 'enters the game!')
GameConfig.rpc('sendMessage', GameConfig.MessageType.System, GameState.myId, 'enters the game!')

# 添加玩家,仅一个 master 对象,其他都为 puppet
# 只有主人节点添加相关事件,注意设置对应的 master_id
# 玩家的起始位置,由玩家的 id 大小决定,确保统一
func _addPlayers() -> void:
var positions := [GameState.myId] + GameState.otherPlayerNames.keys()
positions.sort()
var player := PlayerNode.instance()
player.connect('lay_bomb', self, '_on_Player_lay_bomb')
player.connect('dead', self, '_on_Player_dead')
player.connect('damaged', self, '_on_Player_damaged')
player.connect('collect_item', self, '_on_Player_collect_item')
player.name = str(GameState.myId)
player.playerId = GameState.myId
player.playerName = GameState.myName
player.playerColor = GameState.myColor
player.global_position = _playerPositionNodes[positions.find(GameState.myId)].position
player.set_network_master(GameState.myId)
_playersContainer.add_child(player)
_allPlayers.append(GameState.myId)

for id in GameState.otherPlayerNames:
player = PlayerNode.instance()
player.name = str(id)
player.playerId = id
player.playerName = str(GameState.otherPlayerNames[id])
player.playerColor = GameState.otherPlayerColors[id]
player.global_position = _playerPositionNodes[positions.find(id)].position
player.set_network_master(id)
_playersContainer.add_child(player)
_allPlayers.append(id)

for node in _playerPositionNodes:
node.queue_free()

这段代码中,通过方法 player.set_network_master(id) 给每个玩家设置了相应的 Master ID 只有 id 等于当前玩家的 network id 才是主人节点,即 id == GameState.myId ,玩家的名字也是他们各自 ID ,确保每个玩家中所有玩家节点相统一。

Godot Master and Puppet

三、 Player 玩家代码

相信看到这里大部分的逻辑也都云雾渐开了,玩家代码 Player.gd 也并不复杂,有几个关键点稍微解释一下:

Player.gd
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
func _unhandled_input(event: InputEvent) -> void:
# 这部分代码不区分主人与非主人节点
# 主人节点、奴隶节点都显示玩家名字
if event.is_action_pressed('show_name'):
_labelName.show()
elif event.is_action_released('show_name'):
_labelName.hide()

if ! self.is_network_master():
return
# 这里的代码则只能在【主人节点】中运行:放置炸弹
if _isStuning || _isDead:
return
if event.is_action_pressed('lay_bomb'):
_layBomb()

func _physics_process(delta):
# 这里同样只能运行于主人节点中
if ! self.is_network_master():
return
if _isStuning || _isDead:
return
self.move_and_slide(_velocity)

# 更新其他场景中的对应奴隶节点的位置,这里使用 rpc_unreliable 允许丢包
self.rpc_unreliable('_updatePosition', self.position)

# 下面的方法只能运行在主人节点,代码内部再由主节点发送必要的消息到相对应奴隶节点
master func bomb(byKiller : int, damage : int) -> void:
damage(damage, Vector2.ZERO, byKiller)

master func damage(amount : float, direction : Vector2 = Vector2.ZERO, byId : int = -1) -> void:
# ...省略

master func collect(itemIndex : int) -> void:
# ...省略

一般来说,像 _process 或者 _physics_process 等虚拟方法尽量确保只在主人节点中运行相关逻辑,接着由主人节点来更新其他玩家场景中对应奴隶节点的行为,比如:玩家朝向、当前的动画、当前位置等。反过来说,因为这些方法的运行会因机器性能而异,如果不保证同步,那么联机游戏也就成了单机游戏了,如何保证网络游戏高效地同步确实是一个难题。

以上代码基本上是游戏中的核心部分了,其他部分则比较简单,希望通过这些代码能够让大家避免不少坑,快速开发出自己喜欢的游戏,嘿嘿。

四、 其他示例代码

首先是怪物场景的脚本 Enemy.gd ,因为 _physics_process 方法逻辑稍微复杂,为了方便更新同步 puppet 奴隶节点,我添加了 _process 方法,代码很简单,核心是最后一行,用于更新其他场景中怪物的奴隶节点位置、图形以及动画:

Enemy.gd
1
2
3
4
5
6
`func _process(delta: float) -> void:
if self.get_tree().network_peer == null || ! self.is_network_master():
return
if _isDead || _isPaused:
return
self.rpc_unreliable('_puppetSet', self.position, _sprite.flip_h, _animationPlayer.current_animation)

还有一个就是后面我加上去的,服务器踢人功能的实现,非常简单,让服务发送消息给被踢玩家的 id 通知其调用退出游戏的方法即可:

LobbyUI.gd
1
2
3
4
5
6
7
8
# 运行于服务器
func _onPlayerBeKickedOut(id : int) -> void:
self.rpc_id(id, '_kickedOut')

# 运行于客户端
remote func _kickedOut() -> void:
# ...省略
self.get_tree().network_peer = null

其他的代码部分,包括炸弹爆炸、发送消息、显示游戏结果、掉落物品等处理我就不一一解释了,相信大家做游戏也都有自己的实现方式,如果不清楚,可以参考我的源码。 smile

游戏开发小结

前前后后,游戏开发花费了我不少时间。游戏虽然简单,坑确不少,限于记忆和篇幅,这里总结一下困扰我比较久的几个典型问题吧。

1. 名字必须相同

在电脑上测试时,我发现偶尔遇到炸弹、怪物、爆炸效果等图形在“镜像端”不会消失,就像图中 Bug :

Bug of deletion

这个在电脑上测试还好,偶尔出现,但是发布到网络后这个 Bug 就非常频繁地触发了。刚开始我以为是游戏中的延迟导致不同步,进而造成方法调用失效造成的,改了方法调用顺序并没有解决这个问题,后来根据控制台的错误日志才就恍然大悟:

E 0:00:11.206 _process_get_node: Failed to get cached path from RPC: Game/Enemies/Enemy123456.

这个错误说明了一个问题:对应 Master 和 Puppet 的节点名字(也就是 Godot 中的 path 路径)根本就对不上!知道了问题所在,解决方案很简单,对于任何生成的对象,需要统一一个唯一的名字,然后在各端生产即可,比如生成的物品、炸弹、怪物等对名字命名进行计数,保证唯一且统一。举例,游戏中生成的怪物代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 生成敌人
func _spawnEnemy() -> void:
# ......
# 定义一个整数字段,每生成一个敌人加 1 ,保证每个敌人名字【唯一】
_enemyNameIndex += 1
var pos := _tileMap.map_to_world(tile) + _tileMap.cell_size / 2
var name := 'Enemy' + str(_enemyNameIndex)
# 将名字作为数据发送到其他客户端,保证名字相同【一致】
self.rpc('_addEnemy', pos, name)

# 远程添加敌人的方法
remotesync func _addEnemy(pos : Vector2, name : String) -> void:
var enemy = enemyScene.instance()
enemy.name = name
enemy.set_network_master(1) # 以服务器端的对象作为 master
enemy.global_position = pos
_enemiesContainer.add_child(enemy)

2. 不要传递复杂数据

这个问题也困惑了我好一会。在主场景中生成一个简单的物品,然后将这个物品相关信息发送到其他 Puppet 场景,但是在其他场景确得到了空数据!我猜测,会不会是因为远程方法中传递的数据是复杂数据类型导致的呢?我改了一下代码,转为传递物品的路径字符串代替:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 修改前的代码:
self.rpc('_addItem', GameState.myId, item)
remotesync func _addItem(id : int, item : GameConfig.ItemData) -> void:
var power : Node = load(item.data).instance()
power.set_network_master(id)
self.add_child(power)

# 修改后的代码:
self.rpc('_addItem', GameState.myId, item.data)
remotesync func _addItem(id : int, data : String) -> void:
var power : Node = load(data).instance()
power.set_network_master(id)
self.add_child(power)

比较修改前后的代码,后面的代码是能正常运行的。而修改前的代码中,远程传递的是 ItemData 复杂数据类型,改成 String 后解决了这个问题。至于是不是传递复杂数据类型导致,我暂时没有做测试,尽量保持简单的数据类型吧,也有益于提升网络速度。 smiley

3. 确保处于连接状态

还有一个小小的问题,虽然不会影响游戏运行,但是报错还是让我感觉不爽:

E 0:00:01.821 get_network_unique_id: No network peer is assigned. Unable to get unique network ID.

主要原因是偶然的网络断开,导致调用这句代码: self.is_network_master() 后出现报错,解决方法就很简单了,加一个判断即可。

1
2
3
4
func _physics_process(delta: float) -> void:
if self.get_tree().network_peer == null || ! self.get_tree().is_network_server():
return
# ......

4. 确保重要数据同步

服务端和客户端共享一套代码,那么有些数据的初始化既可以由服务器发送,也可以各自初始化。对于复杂点的数据来说,显然没有必要霸占远程调用的网络资源,比如地图相关的数据,那么请别忘记进行必要的初始化,以保证数据的同步与共享:

1
2
3
4
5
6
func _ready() -> void:
# 这里会运行在服务器端和客户端,保证 _brokenTiles 同步
_navigation = self.get_parent()
for tile in self.get_used_cells():
if self.get_cellv(tile) == GameConfig.GRASS_TILE_ID:
_brokenTiles.append(tile)

5. 其他的小问题

我还发现一个小问题,即使服务器设置了 get_tree().refuse_new_network_connections = false 但是客户端依然还是能加入,不过这个新加入的客户端在其他主机上看不到任何 id 信息,包括服务器,所以也不会正常参与游戏,算是轻度无伤大雅的 BUG 吧。

或许,这是 Godot 的一个 BUG ?!

三、总结

总算是写完了,啰啰嗦嗦一大堆,这里有必要再小结一下个人开发经验:

  1. _ready/_process/_input 等系统方法的调用要特别注意是否运行于 master 主节点中
  2. 很多事件,比如计时器 Timer 计时结束的事件,使用编辑器连接起来的方法中也要特别关注是否区分主、奴节点运行
  3. 一些公开的方法和属性,再被外部调用时要注意使用 master/puppet 关键字区分主奴运行场景
  4. puppet 大部分场合其实等同于 remote 关键字,因为你的调用都发生在 master
  5. master/puppet 相比 remote 的一个应用场景是: MasterA 触发或者调用了 PuppetB 中的方法,那么使用 master/puppet 更好
  6. 所有的新物品添加都需要使用远程调用,同理删除某个物品也需要 rpc ,比如添加怪物,或者更改地图某个 Tile 等

如果还有什么问题的欢迎加我微信或者 QQ 探讨,本篇的 Demo 以及相关代码已经上传到 Github ,地址: https://github.com/spkingr/Godot-Demos ,后续我会继续更新,原创不易,希望大家喜欢! smile

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


Comments: