高级多人游戏

高层API vs 底层 API

下面解释了 Godot 高阶、低阶网络的区别以及其一些基本原理。如果你想一头扎进去,直接为你的最初的节点添加网络功能,请跳到下面的初始化网络,但切记也要阅读一下其余部分!

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

这是底层协议的固有限制所造成的:

  • TCP 能够确保数据包始终可以可靠、有序地到达接收端,但是由于其错误纠正机制,其延迟通常会更高。TCP本身也是一个相当复杂的协议,因为它理解什么是“连接”,它优化的目标也不经常是多人游戏这种应用程序。系统会将数据包缓冲成更大的批次发送出去,用更高的延迟来换取更小的单数据包开销,对于 HTTP 之类的东西可能很有用,但对于游戏通常不太有用。其中一些可以进行配置和禁用(例如禁用 TCP 连接的“Nagle 算法”)。

  • UDP 则是一个更简单的协议,它只发送数据包(没有“连接”的概念),而且因为没有错误纠正机制,所以速度非常快(延迟低),但数据包就可能会发生丢包或以接收顺序错误等情况。此外,UDP 的 MTU(Maximum Packet Size,最大数据包大小)一般很低(只有几百字节),传输更大的数据包意味着需要对这些数据包进行分割、重组,某一部分失败时还要进行重试。

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

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

备注

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

备注

更多关于TCP/IP、UDP和网络的信息,参见: https://gafferongames.com/post/udp_vs_tcp/

Gaffer On Games有很多关于游戏中网络的有用文章( 这里 ),包括全面的 游戏中的网络模型介绍

警告

在你的游戏中,加入网络系统需要承担一定的责任。如果做不好,那么网络系统将会让你的应用程序很容易遭受网络攻击,并可能会造成网络欺骗或远程操纵等不良后果,甚至可能允许攻击者破坏你的应用程序所在的机器设备,并利用你的服务器来发送垃圾邮件,甚至还会窃取你的用户数据,如果有其他用户玩你的游戏,攻击者还会其他用户。

这种情况始终是当涉及到网络且与 Godot 无关时才需要如此考虑的。当然,你也可以进行试验,但是在发布网络应用程序时,请始终注意任何可能存在的安全问题。

中层抽象

在讨论我们希望如何跨网络同步游戏之前,先让我们来了解一下用于同步的基本网络API的运作原理,这样可能会对我们对后续内容的学习有所帮助。

Godot 使用了一个中层对象 :ref:`NetworkedMultiplayerPeer <class_NetworkedMultiplayerPeer>`来进行多人游戏中层抽象。 这个对象并不是直接创建的,但经过设计,多个 C++ 实现可以提供该对象。

这个对象扩展自 :ref:`PacketPeer <class_PacketPeer>`类,继承了所有用于序列化、发送和接收数据的方法。此外,该对象还添加了设置节点、传输模式等方法,同时还包括当节点连接或断开时会发送通知的信号。

这个类接口可以抽象出大多数类型的网络层、拓扑结构和库。默认情况下,Godot 会提供一个基于 ENet 的实现( NetworkedMultiplayerEnet)、一个基于 WebRTC 的实现( WebRTCMultiplayer)以及一个基于WebSocket的实现( WebSocketMultiplayerPeer),而该类接口可以用来实现移动API(用于特设的WiFi、蓝牙等)或自定义设备/控制台中特定的网络API。

但大多数常见情况下,不鼓励直接使用这个对象,因为 Godot 提供了更高级别的网络使用方法。只有当游戏对较低级别的API有特殊需求的情况下,才使用该对象。

服务器托管的注意事项

托管服务器时, LAN(局域网) 上的客户端可以使用内网 IP 地址进行连接,该地址通常是 192.168.*.* 格式的。 非 LAN/Internet 客户端 无法 访问此内部 IP 地址。

在 Windows 中, 你可以在命令提示符中输入 ipconfig 命令, 在 macOS 中,你可以在终端中输入 ifconfig 命令,在 Linux 中,你可以在终端中输入 ip addr 命令,来找到你的内网 IP 地址。

如果你在自己的机器上托管了服务器,并且想让非内网客户端连接,那么你可能需要将服务器端口 转发 到你的路由器,由于大多数家用网络都使用 NAT 技术,因此转发服务器端口是让你的服务器能通过互联网访问的必经步骤。Godot 的高级多人 API 只使用 UDP 协议,所以你的端口转发也必须是 UDP 协议的端口,不能只转发 TCP 协议的端口。

在转发了 UDP 端口之后,你需要确保你的服务器使用这个端口。可以前往 该网站 <https://icanhazip.com/&gt; 去查询你的公网 IP 地址,然后把这个公网 IP 地址发送给想联机到你服务器的互联网客户端即可。

Godot 的高级多人联机 API 使用的是一个修改过的 ENet,包含全 IPv6 支持。

网络初始化

在 Godot 中,高级网络由节点树: :ref:`SceneTree <class_SceneTree>`进行管理。

每个节点都有一个 multiplayer 属性,它是对场景树为其配置的 MultiplayerAPI 实例的引用。每个节点在初始化时都会配有相同预设的 MultiplayerAPI 物件。

也可以建立一个新的 MultiplayerAPI 物件,并将其分配给场景树中的 NodePath ,该操作将覆盖该路径及其所有后代节点的 multiplayer 属性,也允许兄弟节点能够配置不同的对等体,从而可以在一个 Godot 实例中同时运行多个服务端和客户端。

  1. # By default, these expressions are interchangeable.
  2. multiplayer # Get the MultiplayerAPI object configured for this node.
  3. get_tree().get_multiplayer() # Get the default MultiplayerAPI object.

要想初始化网络, 你必须先创建一个``MultiplayerPeer`` 对象,将其初始化为服务端或客户端,然后将其传给 MultiplayerAPI

  1. # Create client.
  2. var peer = ENetMultiplayerPeer.new()
  3. peer.create_client(IP_ADDRESS, PORT)
  4. multiplayer.multiplayer_peer = peer
  5. # Create server.
  6. var peer = ENetMultiplayerPeer.new()
  7. peer.create_server(PORT, MAX_CLIENTS)
  8. multiplayer.multiplayer_peer = peer

可以通过下述方法来停止联网功能:

  1. multiplayer.multiplayer_peer = null

警告

导出到 Android 时,在导出项目或使用一键部署之前,确保在 Android 导出预设中启用 INTERNET 权限。否则,Android 系统会阻止该程序任何形式的网络通信。

管理连接

系统会给每个对等体都分配一个唯一 ID(UID),服务端的 ID 永远为 1,客户端的 ID 则会被分配给一个随机的正整数。

可以通过连接到 MultiplayerAPI 的信号来响应连接或断开连接:

  • peer_connected(id: int) 此信号在每个其他对等体上与新连接的对等体 ID 一起发出,并在新对等点上多次发出,其中一次与每个其他对等点ID一起发出。

  • peer_disconnected(id:int) 当一个对等体断开连接时,剩余的每个对等体都会发出此信号。

以下信号仅在客户端上发送:

  • connected_to_server()

  • connection_failed()

  • server_disconnected()

通过下述方法来取得关联到对等体的UID:

  1. multiplayer.get_unique_id()

通过下述方法来对等体是服务端还是客户端:

  1. multiplayer.is_server()

远程过程调用

远程过程调用(RPC)是可以在其他对等方上调用的函数。要创建一个 RPC,请在函数定义之前使用 @rpc 注解。若要调用 RPC,请在每个对等体中通过 Callablerpc() 方法调用之,或使用 rpc_id() 在特定对等方中调用之。

  1. func _ready():
  2. if multiplayer.is_server():
  3. print_once_per_client.rpc()
  4. @rpc
  5. func print_once_per_client():
  6. print("I will be printed to the console once per each connected client.")

RPC 既不会序列化对象,也不会序列化可调用体。

要使远程调用成功,发送方节点和接收方节点需要具有相同的 NodePath ,也就是说,这些节点必须具有相同的节点名称。对预期使用 RPC 的节点调用 add_child() 时,请将参数 force_readable_name 设置为 true

警告

如果一个函数在客户端脚本(resp.server脚本)上用 @rpc 注解,那么该函数也必须在服务器脚本(resp.client脚本)上进行声明。两个 RPC 必须具有相同的方法签名,该方法签名使用 all RPCs 的校验和进行评估。同时系统会检查脚本中的所有 RPC,并且必须在客户端脚本和服务端脚本上声明所有 RPC ,甚至也包括不使用的方法在内

RPC 的方法签名包括 @rpc() 声明、函数、返回类型和节点路径。如果 RPC 驻留在附加到 /root/Main/Node1 的脚本中,则其也必须驻留在客户端脚本和服务端脚本上完全相同的节点中,在客户端脚本和服务端脚本上具有完全相同的路径。函数参数(例如 func sendstuff():func sendstuff(arg1, arg2): 能够成功匹配签名)。

如果不满足这些条件(即如果所有RPC都没有通过签名匹配),脚本则可能会打印错误,错误消息可能与你当前正在构建和测试的 RPC 函数无关;也可能会导致非预期行为的发生。

请参阅本帖的进一步解释和故障排除: 点我前往.

@rpc 注解可以采用多个参数,这些参数具有预设值,相当于:

  1. @rpc("authority", "call_remote", "unreliable", 0)

其参数及作用如下:

mode

  • "authority" :只有多人游戏权限端(服务端)才能远程调用该函数。

  • “any_peer” :也允许客户端进行远程调用该函数,用于传输用户输入。

sync

  • "call_remote": 让该函数不会在本地对等体上调用。

  • “call_local” :让该函数也可以在本地对等体上调用,在服务端也是玩家时非常有用。

transfer_mode

  • "unreliable" 数据包不被确认,可能丢失,并且可以按任意顺序到达接收方。

  • "unreliable_ordered" 数据包按照发送的顺序接收,透过忽略迟达的数据包(如果已经收到在这些数据包之后发送的另一个数据包)来实现的。使用不当可能会导致丢包。

  • "reliable" 发送重新传送尝试,直到数据包被确认为止,且这些数据包的顺序会被保留。具有明显的性能损失。

transfer_channel 是信道索引。

前3个参数在注解中的顺序任意,但 transfer_channel 参数必须始终位于注解中的最后。

在 RPC 所调用的函数中,可用函数 multiplayer.get_remote_sender_id() 来获取 RPC 发送方对等体的 UID。

  1. func _on_some_input(): # Connected to some input.
  2. transfer_some_input.rpc_id(1) # Send the input only to the server.
  3. # Call local is required if the server is also a player.
  4. @rpc("any_peer", "call_local", "reliable")
  5. func transfer_some_input():
  6. # The server knows who sent the input.
  7. var sender_id = multiplayer.get_remote_sender_id()
  8. # Process the input and affect game logic.

信道

现代网络协定支持信道系统。信道是网络连接内的单独连接,允许多个数据包流互不干扰。

像是游戏聊天相关信息和一些核心游戏信息等都应该可靠地发送,但游戏信息不应等待聊天信息被确认后在发送,这一点可以通过使用不同的信道来实现。

当与不可靠的有序传输模式一起使用时,信道也十分有用。使用此传输模式发送可变大小的数据包可能会导致丢包,因为迟达的数据包将会被接收方忽略。通过使用信道,将它们拆分成多个同质数据包流,可以实现有序传输,且丢包很少,不会因可靠模式而导致延迟损失。

索引为 0 的默认信道实际上是三个不同的信道——每个传输模式一个。

大厅实现示例

下面为一个示例大厅,可以处理对等体的加入和离开,通过信号来通知UI场景,并在所有客户端加载游戏场景后启动游戏。

  1. extends Node
  2. # Autoload named Lobby
  3. # These signals can be connected to by a UI lobby scene or the game scene.
  4. signal player_connected(peer_id, player_info)
  5. signal player_disconnected(peer_id)
  6. signal server_disconnected
  7. const PORT = 7000
  8. const DEFAULT_SERVER_IP = "127.0.0.1" # IPv4 localhost
  9. const MAX_CONNECTIONS = 20
  10. # This will contain player info for every player,
  11. # with the keys being each player's unique IDs.
  12. var players = {}
  13. # This is the local player info. This should be modified locally
  14. # before the connection is made. It will be passed to every other peer.
  15. # For example, the value of "name" can be set to something the player
  16. # entered in a UI scene.
  17. var player_info = {"name": "Name"}
  18. var players_loaded = 0
  19. func _ready():
  20. multiplayer.peer_connected.connect(_on_player_connected)
  21. multiplayer.peer_disconnected.connect(_on_player_disconnected)
  22. multiplayer.connected_to_server.connect(_on_connected_ok)
  23. multiplayer.connection_failed.connect(_on_connected_fail)
  24. multiplayer.server_disconnected.connect(_on_server_disconnected)
  25. func join_game(address = ""):
  26. if address.is_empty():
  27. address = DEFAULT_SERVER_IP
  28. var peer = ENetMultiplayerPeer.new()
  29. var error = peer.create_client(address, PORT)
  30. if error:
  31. return error
  32. multiplayer.multiplayer_peer = peer
  33. func create_game():
  34. var peer = ENetMultiplayerPeer.new()
  35. var error = peer.create_server(PORT, MAX_CONNECTIONS)
  36. if error:
  37. return error
  38. multiplayer.multiplayer_peer = peer
  39. players[1] = player_info
  40. player_connected.emit(1, player_info)
  41. func remove_multiplayer_peer():
  42. multiplayer.multiplayer_peer = null
  43. # When the server decides to start the game from a UI scene,
  44. # do Lobby.load_game.rpc(filepath)
  45. @rpc("call_local", "reliable")
  46. func load_game(game_scene_path):
  47. get_tree().change_scene_to_file(game_scene_path)
  48. # Every peer will call this when they have loaded the game scene.
  49. @rpc("any_peer", "call_local", "reliable")
  50. func player_loaded():
  51. if multiplayer.is_server():
  52. players_loaded += 1
  53. if players_loaded == players.size():
  54. $/root/Game.start_game()
  55. players_loaded = 0
  56. # When a peer connects, send them my player info.
  57. # This allows transfer of all desired data for each player, not only the unique ID.
  58. func _on_player_connected(id):
  59. _register_player.rpc_id(id, player_info)
  60. @rpc("any_peer", "reliable")
  61. func _register_player(new_player_info):
  62. var new_player_id = multiplayer.get_remote_sender_id()
  63. players[new_player_id] = new_player_info
  64. player_connected.emit(new_player_id, new_player_info)
  65. func _on_player_disconnected(id):
  66. players.erase(id)
  67. player_disconnected.emit(id)
  68. func _on_connected_ok():
  69. var peer_id = multiplayer.get_unique_id()
  70. players[peer_id] = player_info
  71. player_connected.emit(peer_id, player_info)
  72. func _on_connected_fail():
  73. multiplayer.multiplayer_peer = null
  74. func _on_server_disconnected():
  75. multiplayer.multiplayer_peer = null
  76. players.clear()
  77. server_disconnected.emit()

游戏场景的根节点应命名为 Game,在其所附加的脚本中:

  1. extends Node3D # Or Node2D.
  2. func _ready():
  3. # Preconfigure game.
  4. Lobby.player_loaded.rpc_id(1) # Tell the server that this peer has loaded.
  5. # Called only on the server.
  6. func start_game():
  7. # All peers are ready to receive RPCs in this scene.

为专用服务器导出

一旦你制作好了一款多人游戏,你可能会想将其导出到一个没有 GPU 的专用服务器上运行,对此可参见 为专用服务器导出 来获取更多信息。

备注

该页面上的范例代码并不是为了在专用服务器上运行而设计的,你必须修改这些代码来让避免系统将服务器误认为玩家,此外,你还必须修改游戏的启动机制,让第一个加入的玩家可以自行启动游戏。