高级多人游戏
高层与底层 API
下面解释了Godot高级, 低级网络的区别以及一些基本原理. 如果您等不及了且将网络添加到您的第一个节点中, 请跳到下面的 初始化网络 . 但是请确保稍后阅读其余部分!
Godot始终支持通过UDP, TCP 和一些更高级别的协议(如SSL和HTTP)进行标准的低级网络连接. 这些协议非常灵活, 几乎可以用于任何事情. 然而, 使用它们来手动同步游戏状态可能需要大量的工作. 有时这种工作是无法避免的, 或者是值得的, 例如在后台使用自定义服务器实现时. 但在大多数情况下, 考虑Godot的高级网络API是值得的, 它牺牲了对低级网络的一些细粒度控制, 以获得更大的易用性.
这是底层协议的固有限制所造成的:
TCP 能够确保数据包总是可靠、有序地到达,但是由于错误纠正,延迟通常更高。它也是一个相当复杂的协议,因为它理解什么是“连接”,它优化的目标也经常不是多人游戏这种应用程序。数据包会被缓冲成更大的批次发送,用更高的延迟来交换更小的单数据包开销。这对于 HTTP 之类的东西可能很有用,但对于游戏通常不太有用。其中一些可以配置和禁用(例如禁用 TCP 连接的“Nagle 算法”)。
UDP 是一个更简单的协议,它只发送数据包(没有“连接”的概念)。因为没有错误纠正,所以非常快(低延迟),但数据包就可能在发生丢失或以错误的顺序接收的情况。此外,UDP 的 MTU(Maximum Packet Size,最大数据包大小)通常很低(只有几百字节),因此传输更大的数据包意味着需要对它们进行分割、重新组织、某一部分失败时还要进行重试。
一般来说,TCP 可以被认为是可靠的, 有序的和缓慢的; UDP则是不可靠, 无序, 但是速度快. 由于性能上的巨大差异, 在避免不需要的部分(拥塞/流量控制特性, Nagle算法等)的同时, 重新构建游戏所需的TCP部分(可选的可靠性和包顺序)通常是合理的. 正因为如此, 大多数游戏引擎都带有这样的实现,Godot也不例外.
综上所述, 您可以使用低级网络API来实现最大限度的控制, 并在完全裸露的网络协议之上实现所有功能, 或者使用基于 SceneTree 的高级API, 后者以通常以一种比较优化的方式在后台完成大部分繁重的工作.
备注
Most of Godot’s supported platforms offer all or most of the mentioned high- and low-level networking features. As networking is always largely hardware and operating system dependent, however, some features may change or not be available on some target platforms. Most notably, the HTML5 platform currently offers WebSockets and WebRTC support but lacks some of the higher-level features, as well as raw access to low-level protocols like TCP and UDP.
备注
更多关于TCP/IP, UDP和网络的信息: https://gafferongames.com/post/udp_vs_tcp/
Gaffer On Games有很多关于游戏中网络的有用文章( 这里 ), 包括全面的 游戏中的网络模型介绍 .
如果您想使用您选择的底层网络库来代替Godot的内置网络, 请参阅这里的示例 : https://github.com/PerduGames/gdnet3
警告
在您的游戏中加入社交网络需要承担一定的责任. 如果做错了, 它会使您的应用程序很容易受到攻击, 并可能导致欺骗或利用. 它甚至可能允许攻击者破坏您的应用程序运行在的机器, 并使用您的服务器发送垃圾邮件, 攻击其他人或窃取您的用户数据, 如果他们玩您的游戏.
当涉及到网络而与Godot无关时, 情况总是如此. 当然, 您可以进行试验, 但是在发布网络应用程序时, 请始终注意任何可能的安全问题.
中间层的抽象
在讨论我们希望如何跨网络同步游戏之前, 了解用于同步的基本网络API是如何工作的可能会有所帮助.
Godot使用了一个中层对象 NetworkedMultiplayerPeer. 这个对象并不是直接创建的, 但经过设计, 以便多个C++实现可以提供它.
这个对象扩展自 PacketPeer, 因此它继承了所有用于序列化, 发送和接收数据的方法. 除此之外, 它还添加了设置节点, 传输模式等的方法. 它同时还包括当节点连接或断开时将通知您的信号.
这个类接口可以抽象出大多数类型的网络层, 拓扑结构和库。默认情况下,Godot提供了一个基于ENet的实现( NetworkedMultiplayerEnet), 一个基于WebRTC的实现( WebRTCMultiplayer), 还有一个基于WebSocket的实现( WebSocketMultiplayerPeer),但这可以用来实现移动API(用于特设的WiFi, 蓝牙)或自定义设备/控制台特定的网络API。
大多数常见情况下, 不鼓励直接使用这个对象, 因为Godot提供了更高级别的网络使用. 只有当游戏对较低级别的API有特殊需求的情况下, 才使用它.
初始化网络
在Godot中, 控制联网的对象与控制所有与树相关的东西的对象是相同的: SceneTree.
为了初始化高级网络, 必须向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
获取之前设置的网络客户端:
get_tree().get_network_peer()
检查树是否被初始化为服务器或客户端:
get_tree().is_network_server()
停止联网功能:
get_tree().network_peer = null
(更加合理的做法是, 首先发送消息让其他节点知道您正在离开, 而不是直接让连接关闭或让连接超时, 但这也取决于您的游戏设计.)
警告
当导出到 Android 时,在导出项目或使用一键部署之前,确保在 Android 导出预设中启用 INTERNET
权限。否则,任何形式的网络通信都会被 Android 系统阻止。
管理连接
有些游戏在任何时候都可以接受连接,也有游戏只在大厅阶段接受连接。在任何时间点都可以让 Godot 不再接受连接(请参阅 set_refuse_new_network_connections(bool)
和 SceneTree 的相关方法)。为了管理连接,Godot 在 SceneTree 中提供了以下信号:
服务器和客户端:
network_peer_connected(int id)
network_peer_disconnected(int id)
当一个新的对等体连接或断开连接时, 上述信号在每个连接到服务器的对等体上被调用, 包括服务器上. 客户端将以大于1的唯一ID连接, 而网络对等体ID 1始终是服务器. 任何低于1的东西都应该被当作无效处理. 你可以通过 SceneTree.get_network_unique_id() 检索到本地系统的ID. 这些ID主要对大厅管理有用, 一般来说应该被储存起来, 因为它们可以识别连接的同伴, 从而识别玩家. 你也可以使用ID只向某些对等体发送消息.
客户端:
connected_to_server
connection_failed
server_disconnected
同样, 所有这些功能主要用于大厅管理或即时添加/删除玩家. 对于这些任务, 服务器显然必须作为一个服务工作, 你必须手动执行任务, 例如向新连接的玩家发送关于其他已经连接的玩家的信息(例如他们的名字, 统计信息等).
您可以用任何您想要的方式实现大厅, 但是最常见的方式是用一个在所有游戏角色的场景中具有相同名字的节点. 通常, 一个自动加载的节点/单例非常适合于此, 这样就可以在任何时候访问它, 例如 /root/lobby
.
RPC
为了在节点之间进行通信, 最简单的方法是使用RPC(远程过程调用). 它是靠一组 Node 的函数实现的:
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)
可以用两种方式来调用函数:
Reliable: when the function call arrives, an acknowledgement will be sent back; if the acknowledgement isn’t received after a certain amount of time, the function call will be re-transmitted.
Unreliable: the function call is sent only once, without checking to see if it arrived or not, but also without any extra overhead.
在大多数情况下, 需要可靠的调用. 当同步对象位置时, 不可靠的调用才很有用(因为同步必须持续发生, 如果包丢失, 这并不那么糟糕, 因为新的包最终会到达;同时包很可能会过时, 因为对象在此期间进一步移动了, 即使它被可靠地怨恨).
There is also SceneTree.get_rpc_sender_id(), which can be used to check which peer (or peer ID) sent an RPC.
回到大厅
让我们回到大厅. 想象一下, 连接到服务器的每个游戏角色都会将他的到来告诉其他每一个人.
# Typical lobby implementation; imagine this being in /root/lobby.
extends Node
# Connect all functions
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")
# Player info, associate ID to data
var player_info = {}
# Info we send to other players
var my_info = { name = "Johnson Magenta", favorite_color = Color8(255, 0, 255) }
func _player_connected(id):
# Called on both clients and server when a peer connects. Send my info to it.
rpc_id(id, "register_player", my_info)
func _player_disconnected(id):
player_info.erase(id) # Erase player from info.
func _connected_ok():
pass # Only called on clients, not server. Will go unused; not useful here.
func _server_disconnected():
pass # Server kicked us; show error and abort.
func _connected_fail():
pass # Could not even connect to server; abort.
remote func register_player(info):
# Get the id of the RPC sender.
var id = get_tree().get_rpc_sender_id()
# Store the info
player_info[id] = info
# Call function to update lobby UI here
你可能已经注意到了一些不同的东西, 那就是在 register_player
函数上使用了 remote
关键字:
remote func register_player(info):
This keyword is one of many that allow a function to be called by a remote procedure call (RPC). There are six of them total:
remote
remotesync
puppet
puppetsync
master
mastersync
Each of them designate who can call the rpc, and optionally sync
if the RPC can be called locally.
备注
If no rpc keywords are added, Godot will block any attempts to call functions remotely. This makes security work a lot easier (so a client can’t call a function to delete a file on another client’s system).
The remote
keyword can be called by any peer, including the server and all clients. The puppet
keyword means a call can be made from the network master to any network puppet. The master
keyword means a call can be made from any network puppet to the network master.
If sync
is included, the call can also be made locally. For example, to allow the network master to change the player’s position on all peers:
puppetsync func update_position(new_position):
position = new_position
小技巧
You can also use SceneTree.get_rpc_sender_id() to have more advanced rules on how an rpc can be called.
These keywords are further explained in Synchronizing the game.
有了这个, 大厅管理就应该或多或少的解释一下. 一旦你开始开发游戏, 很可能想增加一些额外的安全性, 以确保客户不做任何有趣的事情(只是不时地验证他们发送的信息, 或在游戏开始前验证). 为了简单起见, 并且因为每个游戏将分享不同的信息, 所以就不写这方面了.
开始游戏
一旦有足够的游戏角色聚集在大厅时, 服务器应该开始游戏. 这本身没有什么特别的, 但是我们将解释一些很好的技巧, 这些技巧可以在这点上让您的生活更容易.
游戏角色场景
在大多数游戏中, 每个游戏角色都可能有自己的场景. 请记住, 这是一个多人游戏, 所以在每个客户端中, 您需要为连接到它的每个游戏角色实例化 一个场景 . 对于一个4人游戏, 每个客户端需要4个游戏角色节点实例.
那么, 如何命名这样的节点呢?在Godot中, 节点需要有一个独特的名字. 对于玩家来说, 也必须比较容易分辨出哪个节点代表每个玩家的ID.
解决方案是简单地将 实例化后的游戏角色场景的根节点命名为它的网络ID . 这样, 它们在每一个客户端中都是一样的,RPC调用也会很容易!下面是一个示例:
remote func pre_configure_game():
var selfPeerID = get_tree().get_network_unique_id()
# Load world
var world = load(which_level).instance()
get_node("/root").add_child(world)
# Load my player
var my_player = preload("res://player.tscn").instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # Will be explained later
get_node("/root/world/players").add_child(my_player)
# Load other players
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)
# Tell server (remember, server is always ID=1) that this peer is done pre-configuring.
# The server can call get_tree().get_rpc_sender_id() to find out who said they were done.
rpc_id(1, "done_preconfiguring")
备注
根据执行pre_configure_game()的时间, 您可能需要将对 add_child()
的任何调用更改为通过 call_deferred()
进行延迟, 因为SceneTree在创建场景时被锁定(例如, 当 _ready()
被调用).
同步游戏开始
由于延迟, 不同的硬件或其他原因, 设置游戏角色在每个客户端上花费的时间可能不同. 为了确保游戏会在每个人都准备好的时候真正开始, 有必要暂停游戏直到所有的游戏角色都准备好:
remote func pre_configure_game():
get_tree().set_pause(true) # Pre-pause
# The rest is the same as in the code in the previous section (look above)
当服务器从所有客户端获得OK时, 它才告诉他们开始游戏, 例如:
var players_done = []
remote func done_preconfiguring():
var who = get_tree().get_rpc_sender_id()
# Here are some checks you can do, for example
assert(get_tree().is_network_server())
assert(who in player_info) # Exists
assert(not who in players_done) # Was not added yet
players_done.append(who)
if players_done.size() == player_info.size():
rpc("post_configure_game")
remote func post_configure_game():
# Only the server is allowed to tell a client to unpause
if 1 == get_tree().get_rpc_sender_id():
get_tree().set_pause(false)
# Game starts now!
同步游戏
在大多数游戏中, 多人联网的目标是让游戏在所有对等玩家身上同步运行. 除了提供RPC和远程成员变量集的实现,Godot还增加了网络主机的概念.
网络主人
一个节点的网络主人是对该节点具有终极权限的客户端.
当没有明确设置时, 网络主控就会从父节点继承, 如果不改变的话, 父节点总是会成为服务器(ID 1). 因此, 服务器默认拥有对所有节点的权限.
可以使用函数 Node.set_network_master(id, recursive) 来设置网络主人(默认情况下recursive(递归)为 true
, 这意味着在节点的所有子节点上也递归地设置了网络主人).
通过调用 Node.is_network_master() 来检查客户端上的特定节点实例是否是该节点用于所有连接的客户端的网络主人. 这在服务器上执行时将返回 true
, 在所有客户端上将返回 false
.
如果你有留意前面的例子, 你有可能注意到了每个客户端(peer)都被设置为拥有自己玩家(节点)的网络主权限, 而不是服务器:
[...]
# Load my player
var my_player = preload("res://player.tscn").instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # The player belongs to this peer; it has the authority.
get_node("/root/world/players").add_child(my_player)
# Load other players
for p in player_info:
var player = preload("res://player.tscn").instance()
player.set_name(str(p))
player.set_network_master(p) # Each other connected peer has authority over their own player.
get_node("/root/world/players").add_child(player)
[...]
每当在客户端上执行这段代码时, 客户端就使得它控制的节点上成为主人, 同时其他所有节点仍然保持为傀儡(服务器是它们的网络主人).
为了阐明这点, 可以看看这个 轰炸机演示 :
master 和 puppet 关键词
这种模式的真正优势在于与GDScript中的 master
/puppet
关键字(或C#和Visual Script中的相应关键字)一起使用时. 与 remote
关键字类似, 函数也可以用它们来标记:
炸弹代码示例:
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 # Already stunned
rpc("stun")
# Stun this player instance for myself as well; could instead have used
# the remotesync keyword above (in place of puppet) to achieve this.
stun()
在上面的例子中, 一个炸弹在某个地方爆炸了(很可能是由这个炸弹节点的管理者管理的, 比如主机). 炸弹知道该地区的机构(玩家节点), 所以它在调用之前会检查它们是否包含一个 exploded
的方法.
回顾一下, 每个对等体都有一套完整的玩家节点实例, 每个对等体(包括自己和主机)都有一个实例. 每个对等体都将自己设定为与自己对应的实例的主控, 并将其他每个实例的主控设定为不同的对等体.
现在, 回到对 exploded
方法的调用, 主机上的炸弹已经远程调用了该区域内所有拥有该方法的机体. 不过, 这个方法是在玩家节点中的, 而且有一个 master
关键词.
玩家节点中的 exploded
方法上的 master
关键字对于如何进行这种调用有两个意思. 第一, 从调用对等体(主机)的角度来看, 调用对等体只会尝试远程调用它设定为相关玩家节点网络主控的对等体上的方法. 其次, 从宿主发送调用的对等体的角度来看, 只有当对等体将自己设置为被调用方法的玩家节点的网络主控(该节点有 master
关键字)时, 它才会接受调用. 只要所有的对等体都同意谁是怎样的主控, 工作状态将最好.
上述设置意味着, 只有拥有受影响身体的对等体, 才会负责告诉所有其他对等体它的身体被眩晕了, 因为主机的炸弹远程信号指示它这样做. 即, 拥有的对等体仍采用 exploded
方法告诉所有其他对等体, 它的玩家节点被眩晕了. 对等体通过远程调用该玩家节点的所有实例, 在其他对等体上的 stun
方法来实现. 因为 stun
方法有 puppet
关键字, 所以只有没有将自己设置为节点的网络主控的对等体才会调用它, 换句话说, 这些对等体由于不是该节点的网络主控而被设置为该节点的傀儡.
这个调用 stun
的结果是让玩家在屏幕上看起来眩晕了所有对等体, 包括当前的网络主控(由于在 rpc("stun")
后本地调用 stun
).
炸弹的主人(主机)对区域内的每一个物体重复上述步骤, 这样, 炸弹区域内任何玩家的所有实例都会在所有对等体的屏幕上被眩晕.
注意, 你也可以通过使用 rpc_id(<id>, "exploded", bomb_owner)
只向特定的玩家发送 stun()
消息. 这对于像炸弹这样的区域效果来说可能没有什么意义, 但在其他情况下可能会有意义, 比如单目标伤害.
rpc_id(TARGET_PEER_ID, "stun") # Only stun the target peer
为专用服务器导出
一旦你制作了一个多人游戏, 你可能会想导出它到一个没有GPU的专用服务器上运行. 参见 为专用服务器导出 获取更多信息.
备注
这个页面上的代码样本并不是为了在专用服务器上运行而设计的. 必须修改它们, 使服务器不被认为是一个玩家, 还必须修改游戏启动机制, 使第一个加入的玩家可以启动游戏.
备注
这里的炸弹人例子主要是以演示为目的,没有在主机端处理任何作弊的情形,比如玩家的自定义客户端可能不会做击晕自己的操作。目前的实现中,这样的作弊行为是可行的,因为每个客户端都是各自玩家的网络主控,而决定是否让其它玩家调用“我被击晕了”方法( stun
)的也正是玩家的网络主控。