高级多人游戏

高层与低层API

下面解释了Godot高级, 低级网络的区别以及一些基本原理. 如果您等不及了且将网络添加到您的第一个节点中, 请跳到下面的 初始化网络 . 但是请确保稍后阅读其余部分!

Godot始终支持通过UDP, TCP 和一些更高级别的协议(如SSL和HTTP)进行标准的低级网络连接. 这些协议非常灵活, 几乎可以用于任何事情. 然而, 使用它们来手动同步游戏状态可能需要大量的工作. 有时这种工作是无法避免的, 或者是值得的, 例如在后台使用自定义服务器实现时. 但在大多数情况下, 考虑Godot的高级网络API是值得的, 它牺牲了对低级网络的一些细粒度控制, 以获得更大的易用性.

这是由于低级协议的固有限制:

  • TCP确保包总是可靠地, 有序地到达, 但是由于错误纠正, 延迟通常更高. 它也是一个相当复杂的协议, 因为它理解什么是 连接, 并针对通常不适合多人游戏等应用程序的目标进行优化. 包被缓冲成更大的批发送, 每包开销更少, 延迟更高. 这对于HTTP之类的东西可能很有用, 但对于游戏通常不太有用. 其中一些可以配置和禁用(例如禁用TCP连接的 "Nagle's algorithm" ).

  • UDP 是一个更简单的协议, 它只发送数据包(没有 "连接" 的概念). 没有错误纠正使其非常快(低延迟), 但是包可能在过程中丢失或以错误的顺序接收. 此外,UDP 的 MTU (最大数据包大小)通常很低(只有几百字节), 因此传输更大的数据包意味着对它们进行分割, 重新组织它们, 并在部分失败时重试.

一般来说,TCP 可以被认为是可靠的, 有序的和缓慢的; UDP则是不可靠, 无序, 但是速度快. 由于性能上的巨大差异, 在避免不需要的部分(拥塞/流量控制特性, Nagle算法等)的同时, 重新构建游戏所需的TCP部分(可选的可靠性和包顺序)通常是合理的. 正因为如此, 大多数游戏引擎都带有这样的实现,Godot也不例外.

综上所述, 您可以使用低级网络API来实现最大限度的控制, 并在完全裸露的网络协议之上实现所有功能, 或者使用基于 SceneTree 的高级API, 后者以通常以一种比较优化的方式在后台完成大部分繁重的工作.

注解

Godot支持的大多数平台都提供所有或大部分上述的高, 低网络功能. 但是, 由于网络在很大程度上依赖于硬件和操作系统, 一些特性可能会改变, 或者在某些目标平台上不可用. 最值得注意的是,HTML5平台目前只提供WebSocket支持, 缺乏一些高级功能, 以及对TCP和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

(更加合理的做法是, 首先发送消息让其他节点知道您正在离开, 而不是直接让连接关闭或让连接超时, 但这也取决于您的游戏设计.)

管理连接

有些游戏在任何时候都可以接受连接, 也有游戏只在大厅阶段接受连接. 可以请求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)

可以用两种方式来调用函数:

  • 可靠的: 函数调用无论如何都会到达, 但是可能需要更长的时间, 因为在发生故障时它将被重新发送.

  • 不可靠: 如果函数调用没有到达, 它将不会被重新传送;但如果它到达了, 将迅速完成.

在大多数情况下, 需要可靠的调用. 当同步对象位置时, 不可靠的调用才很有用(因为同步必须持续发生, 如果包丢失, 这并不那么糟糕, 因为新的包最终会到达;同时包很可能会过时, 因为对象在此期间进一步移动了, 即使它被可靠地怨恨).

SceneTree 中还有 get_rpc_sender_id 函数, 可以用来检查哪个对等体(或对等体ID)发送了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):

这个关键字有两个主要用途. 第一是让Godot知道这个函数可以从RPC调用. 如果不加任何关键字,Godot将阻止任何试图调用函数的行为, 以保证安全. 这使安全工作变得更加容易(所以一个客户端不能调用一个函数来删除另一个客户端系统上的文件).

第二个用途是指定如何通过RPC调用该函数. 这里又有四个不同的关键字:

  • remote

  • remotesync

  • master

  • puppet

remote 关键字意味着 rpc() 调用将通过网络并远程执行.

remotesync 关键字意味着 rpc() 调用将通过网络并远程执行, 但也会在本地执行, 当做一个正常的函数调用.

其他的将会进一步解释. 请注意, 你也可以使用 SceneTree 上的 get_rpc_sender_id 函数来检查哪个对等体实际上对 register_player 进行了RPC调用.

有了这个, 大厅管理就应该或多或少的解释一下. 一旦你开始开发游戏, 很可能想增加一些额外的安全性, 以确保客户不做任何有趣的事情(只是不时地验证他们发送的信息, 或在游戏开始前验证). 为了简单起见, 并且因为每个游戏将分享不同的信息, 所以就不写这方面了.

开始游戏

一旦有足够的游戏角色聚集在大厅时, 服务器应该开始游戏. 这本身没有什么特别的, 但是我们将解释一些很好的技巧, 这些技巧可以在这点上让您的生活更容易.

游戏角色场景

在大多数游戏中, 每个游戏角色都可能有自己的场景. 请记住, 这是一个多人游戏, 所以在每个客户端中, 您需要为连接到它的每个游戏角色实例化 一个场景 . 对于一个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)
[...]

每当在客户端上执行这段代码时, 客户端就使得它控制的节点上成为主人, 同时其他所有节点仍然保持为傀儡(服务器是它们的网络主人).

为了阐明这点, 可以看看这个 轰炸机演示 :

../../_images/nmms.png

主人和傀儡关键词

这种模式的真正优势在于与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的专用服务器上运行. 参见 导出专用服务 获取更多信息.

注解

这个页面上的代码样本并不是为了在专用服务器上运行而设计的. 必须修改它们, 使服务器不被认为是一个玩家, 还必须修改游戏启动机制, 使第一个加入的玩家可以启动游戏.

注解

The bomberman example here is largely for illustrational purposes, and does not do anything on the host-side to handle the case where a peer uses a custom client to cheat by for example refusing to stun itself. In the current implementation such cheating is perfectly possible because each client is the network master of its own player, and the network master of a player is the one which decides whether to call the I-was-stunned method (stun) on all of the other peers and itself.