高级多人游戏

高级与低级API

下面解释了Godot中高、低级别网络的区别以及一些基本原理。如果您想先跳到头上并向第一个节点添加网络,请跳到 Initializing the network 下面。但以后一定要读其他的!

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

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

  • TCP确保数据包总是可靠有序地到达,但是由于错误纠正,延迟通常更高。它也是一个相当复杂的协议,因为它了解什么是“连接”,并针对通常不适合多人游戏等应用程序的目标进行优化。数据包被缓冲成大批量发送,每包的开销越小,延迟越长。这对HTTP之类的东西很有用,但一般不适用于游戏。其中一些可以配置和禁用(例如,通过禁用TCP连接的“nagle算法”)。

  • UDP是一个简单的协议,它只发送数据包(并且没有“连接”的概念)。没有纠错可以使它相当快(低延迟),但数据包可能会在途中丢失或以错误的顺序接收。此外,UDP的MTU(最大数据包大小)通常较低(只有几百个字节),因此传输较大的数据包意味着拆分数据包、重新组织数据包以及在部件出现故障时重试。

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

总之,您可以使用低级网络API进行最大程度的控制,并在裸网络协议之上实现所有内容,或者使用基于 SceneTree 这可以以一种通常优化的方式在幕后完成大部分繁重的工作。

注解

Godot支持的大多数平台都提供所有或大部分提到的高级别和低级别网络功能。然而,由于网络在很大程度上依赖于硬件和操作系统,一些功能可能会改变,或者在某些目标平台上不可用。最值得注意的是,HTML5平台目前只提供WebSocket支持,缺少一些高级功能,以及对低级协议(如TCP和UDP)的原始访问。

注解

有关TCP/IP、UDP和网络的详细信息:https://gafferingames.com/post/udp_vs_tcp/

游戏失手有很多关于游戏中网络的有用文章 (here _包括综合 introduction to networking models in games .

如果要使用低级网络库而不是Godot的内置网络,请参阅此处以获取示例:https://github.com/perdugames/gdnet3

警告

在你的游戏中加入网络是有责任的。如果操作错误,它会使应用程序易受攻击,并可能导致欺骗或利用。它甚至允许攻击者破坏应用程序运行的机器,并使用服务器发送垃圾邮件、攻击他人或在用户玩游戏时窃取用户数据。

当涉及到网络时,这种情况总是存在的,与Godot无关。当然,您可以进行实验,但是当您发布一个联网的应用程序时,一定要注意任何可能的安全问题。

中级抽象

在讨论如何在网络上同步游戏之前,了解用于同步的基本网络API的工作原理是很有帮助的。

Godot使用一个中级对象 NetworkedMultiplayerPeer . 此对象不是直接创建的,而是设计为多个实现可以提供它:

../../_images/nmpeer.png

此对象扩展自 PacketPeer 因此,它继承了序列化、发送和接收数据的所有有用方法。除此之外,它还添加了设置对等机、传输模式等的方法。它还包含一些信号,这些信号将在对等机连接或断开连接时通知您。

这个类接口可以抽象大多数类型的网络层、拓扑和库。默认情况下,godot提供了一个基于enet的实现 (NetworkedMultiplayerEnet ,但这可以用于实现移动API(用于特殊WiFi、蓝牙)或自定义设备/控制台特定的网络API。

对于大多数常见的情况,不鼓励直接使用这个对象,因为Godot提供了更高级别的网络设施。不过,如果游戏对较低级别的API有特定的需求,则可以使用它。

初始化网络

在Godot中控制网络的对象与控制所有与树相关的对象相同: SceneTree .

要初始化高级网络,必须为scenetree提供networkedmultiplayerpeer对象。

要创建该对象,首先必须将其初始化为服务器或客户机。

以服务器身份初始化,在给定端口上侦听,具有给定的最大对等数:

var peer = NetworkedMultiplayerENet.new()
peer.create_server(SERVER_PORT, MAX_PLAYERS)
get_tree().set_network_peer(peer)

作为客户端初始化,连接到给定的IP和端口:

var peer = NetworkedMultiplayerENet.new()
peer.create_client(SERVER_IP, SERVER_PORT)
get_tree().set_network_peer(peer)

获取以前设置的网络对等端:

get_tree().get_network_peer()

正在检查树是否初始化为服务器或客户端:

get_tree().is_network_server()

正在终止网络功能:

get_tree().set_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仅向某些对等方发送消息。

客户:

  • connected_to_server

  • connection_failed

  • server_disconnected

同样,所有这些功能主要用于大厅管理或在飞行中添加/删除玩家。对于这些任务,服务器显然必须作为服务器工作,您必须手动执行任务,例如发送有关其他已连接的玩家的新连接的玩家信息(例如他们的姓名、状态等)。

Lobbie可以以任何方式实现,但最常见的方法是在所有对等端的场景中使用具有相同名称的节点。通常情况下,自动加载的节点/singleton非常适合这种情况,可以随时访问,例如“/root/halloy”。

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)

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

  • 可靠:函数调用无论什么都会到达,但可能需要更长的时间,因为一旦失败,它将被重新传输。

  • 不可靠:如果函数调用没有到达,它将不会被重新传输;但是如果它到达,它将很快完成。

在大多数情况下,需要可靠。不可靠在同步对象位置时最有用(同步必须经常发生,如果数据包丢失,这并不是很糟糕,因为新的数据包最终会到达,而且很可能会过时,因为对象在此期间移动得更远,即使它被可靠地重新发送)。

还有 get_rpc_sender_id 中的函数 SceneTree ,可用于检查发送RPC的对等机(或对等机ID)。

回到大厅

我们回大厅吧。假设每个连接到服务器的播放器都会告诉每个人关于它的信息。

# 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", player_name)

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

您可能已经注意到一些不同的东西,这就是 remote 关键字 register_player 功能:

remote func register_player(info):

这个关键字有两个主要用途。第一个是让Godot知道这个函数可以从RPC调用。如果没有添加关键字,godot将阻止任何调用函数以实现安全性的尝试。这使得安全性工作更加容易(因此客户机不能调用函数来删除另一客户机系统上的文件)。

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

  • remote

  • remotesync

  • master

  • puppet

这个 remote 关键字表示 rpc() 呼叫将通过网络进行并远程执行。

这个 remotesync 关键字表示 rpc() 调用将通过网络远程执行,但也将在本地执行(执行正常的函数调用)。

其他的将被进一步解释。请注意,您也可以使用 get_rpc_sender_id 作用于 SceneTree 检查哪个对等机实际调用了RPC register_player .

有了这一点,大堂管理应该或多或少地得到解释。一旦你的游戏开始,你很可能会想增加一些额外的安全性,以确保客户不做任何有趣的事情(只是验证他们不时发送的信息,或在游戏开始之前)。为了简单起见,由于每个游戏共享不同的信息,这里不显示这一点。

开始游戏

一旦足够多的玩家聚集在大厅里,服务器就应该开始游戏了。这本身并没有什么特别的,但我们将解释一些在这一点上可以做的好技巧,使你的生活更容易。

玩家场景

在大多数游戏中,每个玩家都有自己的场景。记住,这是一个多人游戏,所以在每一个同伴你需要实例 每个连接到它的玩家一个场景 . 对于一个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.
    rpc_id(1, "done_preconfiguring", selfPeerID)

注解

根据执行pre_configure_game()的时间,可能需要将任何调用更改为 add_child() 延期通过 call_deferred() ,因为场景创建时场景被锁定(例如 _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(who):
    # 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():
    get_tree().set_pause(false)
    # Game starts now!

同步游戏

在大多数游戏中,多人联网的目标是在所有玩它的同龄人上同步运行游戏。除了提供一个RPC和远程成员变量集实现之外,Godot还增加了网络主机的概念。

网络主机

节点的网络主节点是对其具有最终权限的对等节点。

如果未显式设置,则网络主节点从父节点继承,如果不更改,则该节点将始终是服务器(ID 1)。因此,默认情况下,服务器拥有对所有节点的权限。

网络主机可以通过功能设置 Node.set_network_master(id, recursive) (默认情况下,递归为真,意味着网络主节点也递归地设置在节点的所有子节点上)。

通过调用 Node.is_network_master() . 在服务器上执行时返回true,在所有客户端对等端上返回false。

如果您注意到前面的示例,可能会注意到每个对等机都被设置为拥有自己的播放机(节点)的网络主权限,而不是服务器:

[...]
# 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)
[...]

每次在每个对等机上执行这段代码时,对等机都会在其控制的节点上使自己成为主节点,而所有其他节点都将保持为傀儡,而服务器则是它们的网络主节点。

为了澄清,这里有一个例子说明 bomber demo

../../_images/nmms.png

主关键字和伪关键字

该模型的真正优点是与 master /` gdscript中的puppet`关键字(或在C#和视觉脚本中的等效关键字)。类似于 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() # Stun myself, could have used remotesync keyword too.

在上面的例子中,炸弹在某个地方爆炸(很可能是由谁掌握的)。炸弹知道该地区的尸体,所以它会检查它们,并检查它们是否含有 exploded 功能。

如果他们这样做,炸弹就会响 exploded 在上面。但是, exploded 播放器中的方法具有 master 关键字。这意味着只有在该实例中是大师的玩家才能真正获得该函数。

然后,此实例调用 stun 方法在同一个玩家的同一个实例中(但在不同的同龄人中),仅设置为木偶,使该玩家在所有同龄人(以及当前的,大师级的)中看起来都很震惊。

请注意,您也可以使用rpc_id(<id>,“exploted”,bomb_owner)只将stun()消息发送给特定的播放器。这对于像炸弹这样的效果区域来说可能没有多大意义,但在其他情况下,比如单个目标的伤害。

rpc_id(TARGET_PEER_ID, "stun") # Only stun the target peer