前言
版本对应于godot3.2.2
版本对应Godot3.2.2
老蘩这几天都在研究Godot引擎内置的多人联机API,发现还是非常好用的。在强大的gdscript加持下,做一个小型局域网联机游戏几乎没有问题。特此根据个人理解翻译一下Godot官方的API文档,供各位游戏开发爱好者们参考参考。限于本人的学识与能力,如有翻译不好的地方,也请多多海涵。
高级vs低级API
接下来将解释Godot中高级和低级网络功能的区别和一些基本原理。
如果你想马上给你的节点添加联网功能,那么就跳到下面的初始化网络部分。但还是建议先了解原理然后再阅读后面那部分!
Godot总是支持标准的低级网络功能,包括UDP,TCP和一些更高级的协议比如SSL和HTTP。
这些协议是很灵活的并且可以用来做任何事情。
然而,手动地使用他们来同步游戏状态将会需要很大的工作量。
但有时候是不得不这样做或者说这样做是值得的,比如说与一个自建的服务器后端进行交互。
但在大多数情况下,可以考虑使用Godot的高级网络API。
为了使用起来更简单,它牺牲了一些对低级网络精细的控制(fine-gained control)。
这是由于低级网络协议固有的局限性:
-
TCP协议能保证数据包总是可靠地按顺序抵达目标,但也因为它的错误纠正能力而导致它的延迟通常更高。
它同时还是一个相当复杂的协议,因为它能理解一个“连接”是什么,然而它对一些功能的优化通常不适用于一些应用比如说多人游戏。
在一个大的处理队列中,要被发送的数据包会被缓存,以此减少每个数据包的开销但增加了延迟。
这可能对于HTTP来说很有用,但对游戏来说就没这么有用了。
其中一些可以配置或者禁用(比如对TCP连接禁用“Nagle算法”)。
-
UDP是一个更简单对协议。它只能发送数据包(对“连接”没有概念)。
没有错误纠正对特性使得它相当地快(低延迟),但数据包可能会在传输路上丢失或者接受的顺序与发送的顺序不一致。
除此之外,对于UDP来说MTU(最大数据包大小(maximum packet size))通常很小(只有几百字节),所以传输更大的数据包意味着要先拆分它们,然后重新组织它们才能传输,并且如果某部分发送失败了还需要重新发送。
一般来说,TCP可以认为是可靠的,严格依照顺序的,但是慢;UDP则是不可靠的,不严格依照顺序的,但是快。
因为它们在性能上有如此大的区别,为了游戏而对TCP中想要的一些部分进行重建是说得通对(可选对可靠性和数据包顺序),同时去掉不想要的部分(拥塞/传输控制特性,Nagle算法等)。
正因如此,大多数游戏引擎都会有一套这样的实现,Godot也不例外。
总的来说,你可以使用低级网络API来获得最大的控制并且在裸露的网络协议之上实现任何东西;或者使用基于场景树(SceneTree)的高级API,它在背后以一种通常优化过的方式来做大多数繁重的任务。
> 注
大多数Godot支持的平台提供所有或者大多数上面提到的高级、低级网络功能。
然而联网总是深度依赖硬件和操作系统的,一些功能可能会变化或者在一些平台上不可用。
显然HTML5平台现在只提供WebSocket支持,并且缺乏一些更高级的特性以及对低级协议如TCP和UDP的原生访问的支持。
> 注
这个网站包含了很多关于联机游戏的文章,包括对联机游戏模型的综合介绍。
> 警告
给你的游戏添加联网功能会带来一些后果。
如果某些代码逻辑有问题,它会让你的应用更容易被攻击,导致作弊的发生或漏洞的利用。
它甚至可能会允许一个攻击者控制你的应用所运行的电脑来发送病毒邮件,来攻击其他人或者盗取玩你的游戏的用户的数据。
这些在联机游戏中都是很常见的例子,并且与Godot没有任何关系。
你当然可以做一些试验,但当你发行一个联网应用但时候,记得一定要细心对待任何可能发生的安全问题。
中级(mid-level)抽象
在介绍如何通过网络同步游戏之前,了解一下用来做同步工作的基础网络API是很有用的。
Godot使用一个中级对象NetworkedMultiplayerPeer。这个对象不能直接创建,而是由一些C++实现来提供。
这个对象继承自PacketPeer,所以它继承了所有对序列化、发送和接受数据很有用的方法。
最重要的是,它添加了一些用来设置一个peer,传输模式等的方法。
它也包含了当有客户端(peer)连接或断开连接时会通知你的信号。
这个类的接口能抽象大多数类型的网络层,技术和库。
默认情况下,Godot提供一个基于ENet(NetworkedMultiplayerEnet)的实现,一个基于WebRTC(WebRTCMultiplayer)的实现,一个基于WebSocket(WebSocketMultiplayerPeer)的实现。除此之外,它也能用来实现移动端的API(如临时WiFi(ad hoc WiFi),蓝牙)或者自定义的设备/特定主机的网络API。
对于大多数常见的情况,直接使用这个对象是不推荐的,因为Godot提供更加高层的网络设施。
但是如果游戏对于低级API有特殊要求,也可以使用它。
初始化网络
在Godot中控制联网的对象和控制其他任何与树有关的东西的对象是相同的:场景树(SceneTree)。
为了初始化高级联网功能,需要向场景树提供一个NetworkedMultiplayerPeer对象。
为了创建这个对象,需要将它初始化为一个服务器或者一个客户端。
要初始化为一个服务器,需要指定一个端口来让它监听,同时需要指定一个最大可以连接的客户端数量:
“`
var peer = NetworkedMultiplayerENet.new()
peer.create_server(SERVER_PORT, MAX_PLAYERS)
get_tree().network_peer = peer
“`
要初始化为一个客户端,需要给定一个IP和端口来让它连接到服务器:
“`
var peer = NetworkedMultiplayerENet.new()
peer.create_client(SERVER_IP, SERVER_PORT)
get_tree().network_peer = peer
“`
获取之前设置好的peer:
“`
get_tree().get_network_peer()
“`
检查场景树是否初始化为一个服务器或者一个客户端:
“`
get_tree().is_network_server()
“`
关掉联网功能:
“`gdscript
get_tree().network_peer = null
“`
(虽然先发送一个信息来告知其他连接端你正在断开连接而不是直接让连接断开或者超时比较更说得通,但还是取决于你的游戏)
管理连接
一些游戏在任何时候都会接受连接,而其他则是在大厅(lobby)阶段会接受连接。
Godot可以在任何时候设置成不再接受任何连接(参见场景树中的set_refuse_new_network_connections(bool)和其他相关但方法)。为了管理谁连进来了,Godot在场景树中提供了以下信号:
在服务器和客户端上可用的信号:
-
network_peer_connected(int id)
-
network_peer_disconnected(int id)
每个连接到服务器的peer(包括服务器)当有一个新的peer连进服务器或者与服务器的连接断开时,以上相应信号就会发出。
连接到服务器的客户端会拥有一个独一无二的ID,并且这个ID大于1,因为peer的ID为1的总是服务器。
任何小于1的ID都会视为无效的。
你可以通过SceneTree.get_network_unique_id()来获取本地系统的ID。
这些ID对于大厅管理会很有用,应该妥善储存,因为它们能标识每个已连接的peer即玩家。
你也可以用这些ID来对某些peer发送消息。
仅客户端可以用的信号:
-
connected_to_server
-
connection_failed
-
server_disconnected
再次说明,所有这些函数主要用于大厅管理和即时添加/移除玩家。
为了完成这些任务,服务器显然需要像一个服务者一样工作,并且你需要手动执行一些任务比如向一个新连接进来的玩家发送其他已经连接的玩家的信息(比如他们的名字,统计信息等)。
你可以用任何你想用的方法来实现大厅,但最常见的方法是在所有peer的场景中使用一个名字相同的节点。
通常,一个自动加载节点/单例就很合适,因为你总是可以访问它,比如它的路径是“/root/lobby”。
远程过程调用(RPC)
为了在peer间进行通信,最简单方法是使用RPC(远程过程调用(remote procedure call)。
在节点中它被实现为一组函数:
-
rpc(“function_name”, <optional_args>)
-
rpc_id(<peer_id>,”function_name”, <optional_args>)
-
rpc_unreliable(“function_name”, <optional_args>)
-
rpc_unreliable_id(<peer_id>, “function_name”, <optional_args>)
同步成员变量也是可行的:
-
rset(“variable”, value)
-
rset_id(<peer_id>, “variable”, value)
-
rset_unreliable(“variable”, value)
-
rset_unreliable_id(<peer_id>, “variable”, value)
可以用两种方式调用函数:
-
可靠的:函数调用无论如何总是会抵达目标,但消耗的时间更长因为当传输失败时它会重新传输。
-
不可靠的:如果函数调用没有抵达目标,它也不会重新发送;但如果它能抵达目标,它能很快地做到。
在大多数情况下,可靠的方式是需要的。
不可靠的方式主要用于同步对象的坐标位置(同步必须是持续的,并且如果一个数据包丢失了,影响也不大,因为一个新的数据包最终会抵达,并且那个丢失的数据包已经过时了,因为与此同时这个对象移动得更远了)。
在场景树(SceneTree)中同样提供了 get_rpc_sender_id 函数,它能用来获知哪个peer(即哪个peer id)发送了一个远程过程调用。
回到大厅
让我们回到大厅。
假设每个连接到服务器的玩家会通知所有人。
“`
# 典型的大厅实现; 假设该节点位于 /root/lobby (自动加载)。
extends Node
# 连接所有的信号
func _ready():
get_tree().connect(“network_peer_connected”, self, “_player_connected”)
get_tree().connect(“network_peer_disconnected”, self, “_player_disconnected”)
get_tree().connect(“connected_to_server”, self, “_connected_ok”)
get_tree().connect(“connection_failed”, self, “_connected_fail”)
get_tree().connect(“server_disconnected”, self, “_server_disconnected”)
# 玩家信息, 将ID与数据相关联
var player_info = {}
# 我们要发送给其他玩家的信息
var my_info = { name = “Johnson Magenta”, favorite_color = Color8(255, 0, 255) }
func _player_connected(id):
# 当一个peer连进来时会在客户端和服务器上调用。 发送我的信息给它。
rpc_id(id, “register_player”, my_info)
func _player_disconnected(id):
player_info.erase(id) # 将这个玩家的信息删掉
func _connected_ok():
pass # 只会在客户端调用,不会在服务器上调用。在这里不会用到;在这里没有用。
func _server_disconnected():
pass # 服务器将我们踢掉了;显示错误并且终止。
func _connected_fail():
pass # 甚至不能连接到服务器;终止。
remote func register_player(info):
# 获取远程过程调用发送者的id
var id = get_tree().get_rpc_sender_id()
# 储存信息
player_info[id] = info
# 在这里调用更新大厅UI的函数
“`
你可能已经注意到了一些不一样的地方,即用在register_player函数的remote关键词:
“`
remote func register_player(info):
“`
这个关键词有两种主要用途。
第一种是让Godot知道这个函数可以被远程调用。
如果一个函数没有添加这个关键词,为了安全Godot会阻止任何尝试对这个函数的远程调用。
这也让安保工作更简单了。(所以一个客户端不能调用一个函数来删除在另一个客户端上的文件)。
第二种用途是指定这个函数会怎样被远程调用。
除此之外,还有四种不同的关键词:
-
remote
-
remotesync
-
master
-
puppet
remote关键词用来指定rpc()函数会通过网络调用远程的函数。
remotesync关键词用来指定rpc()函数不仅会通过网络调用远程函数,同时会调用本地函数(即在本地执行一个普通的函数调用)
剩下的关键词会在之后再介绍。
注意到你也可以使用场景树的 get_rpc_sender_id 函数来获知是哪个peer发起的远程调用来执行 register_player 函数。
有了以上的介绍,对于大厅管理或多或少有些理解了。
一旦你让你的游戏运行起来,你肯定会想添加一些额外的安全性来确保客户端不会做一些滑稽的事情(只需不时验证他们发来的信息,或者在游戏开始之前做这件事情)。
为了确保足够简单,以及因为每个游戏都有不同的信息会用来共享,所以这里就不演示怎么做验证了。
开始游戏
一旦足够的玩家已经在大厅集结好了,服务器应该开始游戏了。
对于它本身来说没有什么独特的,但我们会介绍一些可以用在这里的奇技淫巧来让你的生活更轻松。
玩家场景
在大多数游戏中,每个玩家总会有它自己的场景。
记住这是一个多人联机游戏,所以在每个peer中你需要为每个已经连接进来的玩家实例化一个场景。
对于一个4人联机游戏,每个peer需要实例化4个玩家节点。
所以,怎么给这些节点命名呢?
在Godot中,节点需要有独一无二的名字。
玩家需要相对容易地辨识哪个节点代表哪个玩家ID。
解决方案是简单地给实例化的玩家场景的根结点命名为网络ID。
通过这种方式,他们在每个peer的名字都是相同的,并且远程调用会进行得很顺利!
举一个例子:
“`
remote func pre_configure_game():
var selfPeerID = get_tree().get_network_unique_id()
# 加载世界
var world = load(which_level).instance()
get_node(“/root”).add_child(world)
# 加载我的玩家
var my_player = preload(“res://player.tscn”).instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # 会在之后解释
get_node(“/root/world/players”).add_child(my_player)
# 加载其他玩家
for p in player_info:
var player = preload(“res://player.tscn”).instance()
player.set_name(str(p))
player.set_network_master(p) # Will be explained later
get_node(“/root/world/players”).add_child(player)
# 告诉服务器 (记住, 服务器的ID总是为1) 这个peer已经完成了预配置
# 服务器可以调用 get_tree().get_rpc_sender_id() 来获知是谁完成了。
rpc_id(1, “done_preconfiguring”)
“`
> 注
取决于你何时执行 pre_configure_game(),你可能需要使用 call_deferred() 的方式来调用 add_child(),因为场景树在场景正在被创建的时候会被锁住(locked)(比如在 _ready() 中是不能调用 add_child() 的)。
同步游戏开始
由于网络通信是有延迟的,以及不同的硬件或者其他原因,配置好每个玩家可能需要一些时间。
为了确保游戏在所有玩家就绪后才开始,暂停游戏直到所有玩家都就绪会很有用:
“`
remote func pre_configure_game():
get_tree().set_pause(true) # 暂停
# 剩下的代码和之前的部分一样 (看上面)
“`
当服务器从所有peer收到OK后,它会告诉他们正式开始游戏,例子:
“`
var players_done = []
remote func done_preconfiguring():
var who = get_tree().get_rpc_sender_id()
# 这里你可以做一些检查,比如
assert(get_tree().is_network_server())
assert(who in player_info) # 存在
assert(not who in players_done) # 还没有添加
players_done.append(who)
if players_done.size() == player_info.size():
rpc(“post_configure_game”)
remote func post_configure_game():
# 只有服务器才能通知客户端取消暂停
if 1 == get_tree().get_rpc_sender_id():
get_tree().set_pause(false)
# 现在游戏正式开始!
“`
同步游戏
在大多数游戏中,多人联机的目标是在所有的peer上游戏是同步运行的。
除了支持远程过程调用和远程成员变量赋值,Godot还添加了网络master的概念。
网络master
一个节点的网络master是一个对其拥有终极权限的peer。
若没有显式设置,网络master会继承自父节点,如果父节点也没有显示设置的话,则网络master则总会是服务器(ID为1)。
因此默认情况下服务器拥有控制所有节点的权限。
可以使用 Node.set_network_master(id, recursive) (recursive 默认为 true, 意味着会递归地设置该节点的所有子节点(包括Node本身)的网络master为 id )。
要检查某个在某个peer实例化的节点的网络master是否为该peer可以通过调用 Node.is_network_master() 来完成。
如果在服务器上调用,它会返回 true;如果在客户端调用,则会繁华 false。
如果你有注意到前一个例子,你应该能看到每个peer都被设置成它们自己的玩家(节点)的网络master,而不是将服务器设置成它们的网络master:
“`
[…]
# 加载我的玩家
var my_player = preload(“res://player.tscn”).instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # 这玩家属于这个peer; 它得到了控制该节点的权限.
get_node(“/root/world/players”).add_child(my_player)
# 加载其他玩家
for p in player_info:
var player = preload(“res://player.tscn”).instance()
player.set_name(str(p))
player.set_network_master(p) # ,每个其他已连接的peer都拥有它们自己的玩家节点的控制权限。
get_node(“/root/world/players”).add_child(player)
[…]
“`
每次在每个peer上执行这段代码,该peer都会将它所控制的节点的网络master设置成自己,其余的节点的master为服务器,被该peer视为傀儡(puppet)。
作为演示,这里有一个bomb的例子:
master和puppet关键词
当master/puppet关键词同时使用时才能真正显示出该模型的优势。
与remote关键词一样,也可以给函数声明前面加上以上两个关键词:
bomb代码示例:
“`
for p in bodies_in_area:
if p.has_method(“exploded”):
p.rpc(“exploded”, bomb_owner)
“`
玩家代码示例:
“`
puppet func stun():
stunned = true
master func exploded(by_who):
if stunned:
return # 已经被惊吓了
rpc(“stun”)
# 对我自己的玩家实例也要惊吓; 也可以在上面用
# remotesync关键词(替换puppet关键词) 来实现这个目的。
stun()
“`
在以上的例子中,一个bomb在某个地方exploded(可能被某个该bomb节点的master控制的,比如主机)。
该bomb直到该区域的身体(玩家节点),所以它会在调用玩家节点的exploded方法之前检查一下这个方法是否存在。
回顾一下,每个peer都有完整的所有玩家节点的实例,一个实例对应一个玩家(包括peer自己和主机(host))。
每个peer都将自己的玩家节点的master设置为自己,并且该peer将其他peer的玩家节点的master设置为相应的peer。
现在,回到调用exploded方法这里,在主机中的bomb节点远程地调用在区域内所有的身体中存在的exploded方法。然而,该方法在一个玩家节点中,并且被标上来master关键词。
玩家节点中的exploded方法被标上master关键词表示了它如何是如何被调用。
首先,从调用它的peer(即主机)的视角来看,发起调用的peer只会向该玩家节点的master peer尝试发起远程调用该方法。
其次,从主机对其发起调用的peer的视角来看,该peer只它是远程调用的方法的拥有者(即玩家节点)的master时才接受该调用。
只要所有的peer同意谁是某节点的master,这些远程过程调用会被妥善地处理。
上述步骤表示,在被主机的bomb远程地指示这样做之后,只有拥有被影响到的身体的peer才有责任告知其他peer它的身体被惊吓到了。
拥有该身体的peer因此(仍然在exploded方法里面)告诉其他所有peer它的玩家节点被惊到了。
该peer通过远程调用所有(在其他peer上的)该peer的玩家实例的惊吓(stun)方法来实现这个目标。
因为惊吓方法有puppet关键词,所以只有没设置他自己作为该节点的master的peer才会调用该方法(换句话说,由于不是该节点的master,所以这些peer被视为该节点的傀儡)。
调用惊吓方法的结果是使该玩家在所有peer的屏幕上看起来被惊吓到了,包括master peer(因为在远程调用惊吓方法(rpc(“stun”))之后还有一个本地调用)。
对所有区域内的身体,bomb的master(即主机)会重复上述步骤,于是乎所有在这个bomb影响范围内的玩家的实例在所有peer的屏幕上会显示出被惊吓到。
注意到你也可以通过使用rpc_id(<id>, “exploded”, bomb_owner) 来只对某个特定的玩家发送stun()消息。
这可能对于像bomb这种区域影响的例子说不太通,但可用在其他情况,比如单一目标伤害。
“`
rpc_id(TARGET_PEER_ID, “stun”) # 只惊吓目标peer
“`