美文网首页Godot 游戏引擎
Godot游戏开发实践之一:使用High Level Multi

Godot游戏开发实践之一:使用High Level Multi

作者: spkingr | 来源:发表于2020-07-27 09:13 被阅读0次
    Godot游戏开发实践之一

    一、前言

    继续接着上篇介绍局域网多人游戏的开发: 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 代码

    直接上菜:

    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 。我们分别研究这些信号触发的地点、调用方式以及作用:

    # 每当有新客户端连接到服务器,所有其他玩家的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 方法中更新玩家的名字、颜色。为什么会出现名字正确但是颜色错误的问题呢?原因很简单:虽然此方法会将玩家自身颜色发送到其他玩家场景中,但是如果是新玩家,其颜色很可能还没有被服务器执行分配,因此默认显示白色。解决办法正如我所说的,添加了一个更新颜色的信号,以保证每个玩家收到其他玩家的颜色值是正确的。

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

    # 创建服务器,这里返回一个结果
    # 如果一个 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
    

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

    # 客户端调用,准备或者取消准备状态
    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 。游戏正式开始后,游戏主场景会添加所有游戏玩家(还记得上一篇吗?一个主节点玩家,其他全部为奴隶节点),当然也需要处理其他事件:玩家事件处理、发送相关消息、玩家死亡与结果、敌人的生成等,这些内容不复杂,有兴趣的朋友可以翻看源码,这里我把关键部位稍加解释:

    # 初始化
    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 也并不复杂,有几个关键点稍微解释一下:

    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 方法,代码很简单,核心是最后一行,用于更新其他场景中怪物的奴隶节点位置、图形以及动画:

    `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 通知其调用退出游戏的方法即可:

    # 运行于服务器
    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 路径)根本就对不上!知道了问题所在,解决方案很简单,对于任何生成的对象,需要统一一个唯一的名字,然后在各端生产即可,比如生成的物品、炸弹、怪物等对名字命名进行计数,保证唯一且统一。举例,游戏中生成的怪物代码如下:

    # 生成敌人
    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 场景,但是在其他场景确得到了空数据!我猜测,会不会是因为远程方法中传递的数据是复杂数据类型导致的呢?我改了一下代码,转为传递物品的路径字符串代替:

    # 修改前的代码:
    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() 后出现报错,解决方法就很简单了,加一个判断即可。

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

    4. 确保重要数据同步

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

    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自学不成才

    相关文章

      网友评论

        本文标题:Godot游戏开发实践之一:使用High Level Multi

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