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

2020-08-03 10:57:12 浏览数 (1)

Godot游戏开发实践之一Godot游戏开发实践之一

一、前言

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

主要内容: 局域网多人游戏开发代码简析与开发小结

阅读时间: 12 分钟undefined永久链接: 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:

Demo12Demo12

部分游戏代码简析

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

Godot AutoLoadGodot AutoLoad

一、 GameState 代码

直接上菜:

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

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

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

代码语言:txt复制
# 创建服务器,这里返回一个结果
# 如果一个 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

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

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

代码语言:txt复制
# 初始化
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 PuppetGodot Master and Puppet

三、 Player 玩家代码

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

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

代码语言:txt复制
`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 通知其调用退出游戏的方法即可:

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

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

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

代码语言:txt复制
func _physics_process(delta: float) -> void:
    if self.get_tree().network_peer == null || ! self.get_tree().is_network_server():
        return
    # ......

4. 确保重要数据同步

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

代码语言:txt复制
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:

0 人点赞