Godot游戏开发实践之一:使用High Level Multiplayer API制作多人游戏(下)
一、前言 继续接着上篇介绍局域网多人游戏的开发: 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 分钟搞定的,默默忍受新手带来的视听折磨吧!
部分游戏代码简析 首先,在联网游戏中,最重要,也是最核心部分当是处理游戏中局域网络连接的代码。这里用的是一个单例( Singleton )脚本,在 Godot 中也叫 AutoLoad ,代码不需要绑定在节点上,关于 AutoLoad 可以查看官网文档介绍: Singletons (AutoLoad) 。处理网络连接的是 GameState.gd
单例脚本,需要在项目设置里添加、启用即可:
一、 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' ), ...] var myId := -1 var myName := '' var myColor := Color.white var otherPlayerNames := {} var otherPlayerColors := {} var isGameStarted := false master var readyPlayers := [] master var availableColors := [] 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 func _onNewPlayerConnected(id : int) -> void: if isGameStarted: return 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) 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.' ) 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 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 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() myColor = _getRandomColor() return true 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 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' ) 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 : self.rpc_id(1 , '_postStartGame' , myId) remote func _postStartGame(id : int) -> void: readyPlayers.append(id) if readyPlayers.size() == otherPlayerNames.size() + 1 : self.rpc('_startGame' ) remotesync func _startGame() -> void: readyPlayers.clear() self.emit_signal('game_loaded' )
代码的运作方式都在注释里进行了说明,如果还有疑问可以给我留言,我尽量解答。
二、 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!' ) 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 ,确保每个玩家中所有玩家节点相统一。
三、 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) 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
其他的代码部分,包括炸弹爆炸、发送消息、显示游戏结果、掉落物品等处理我就不一一解释了,相信大家做游戏也都有自己的实现方式,如果不清楚,可以参考我的源码。
游戏开发小结 前前后后,游戏开发花费了我不少时间。游戏虽然简单,坑确不少,限于记忆和篇幅,这里总结一下困扰我比较久的几个典型问题吧。
1. 名字必须相同
在电脑上测试时,我发现偶尔遇到炸弹、怪物、爆炸效果等图形在“镜像端”不会消失,就像图中 Bug :
这个在电脑上测试还好,偶尔出现,但是发布到网络后这个 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: _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 ) 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
后解决了这个问题。至于是不是传递复杂数据类型导致,我暂时没有做测试,尽量保持简单的数据类型吧,也有益于提升网络速度。
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: _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 ?!
三、总结 总算是写完了,啰啰嗦嗦一大堆,这里有必要再小结一下个人开发经验:
_ready/_process/_input
等系统方法的调用要特别注意是否运行于 master
主节点中
很多事件,比如计时器 Timer 计时结束的事件,使用编辑器连接起来的方法中也要特别关注是否区分主、奴节点运行
一些公开的方法和属性,再被外部调用时要注意使用 master/puppet
关键字区分主奴运行场景
puppet
大部分场合其实等同于 remote
关键字,因为你的调用都发生在 master
中
master/puppet
相比 remote
的一个应用场景是: MasterA 触发或者调用了 PuppetB 中的方法,那么使用 master/puppet
更好
所有的新物品添加都需要使用远程调用,同理删除某个物品也需要 rpc ,比如添加怪物,或者更改地图某个 Tile 等
如果还有什么问题的欢迎加我微信或者 QQ 探讨,本篇的 Demo 以及相关代码已经上传到 Github ,地址: https://github.com/spkingr/Godot-Demos ,后续我会继续更新,原创不易 ,希望大家喜欢!
我的博客地址: http://liuqingwen.me ,我的博客即将同步至腾讯云+社区,邀请大家一同入驻: https://cloud.tencent.com/developer/support-plan?invite_code=3sg12o13bvwgc ,欢迎关注我的微信公众号:
Comments: