前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Godot游戏开发实践之一:使用High Level Multiplayer API制作多人游戏(下)

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

原创
作者头像
IT自学不成才
修改2020-08-03 10:57:12
1.4K0
修改2020-08-03 10:57:12
举报
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:

Demo12
Demo12

部分游戏代码简析

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

Godot AutoLoad
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
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 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:

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、前言
  • 二、正文
    • 部分游戏代码简析
      • 游戏开发小结
      • 三、总结
      相关产品与服务
      腾讯云代码分析
      腾讯云代码分析(内部代号CodeDog)是集众多代码分析工具的云原生、分布式、高性能的代码综合分析跟踪管理平台,其主要功能是持续跟踪分析代码,观测项目代码质量,支撑团队传承代码文化。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档