高级多人游戏
高层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/> 去查询你的公网 IP 地址,然后把这个公网 IP 地址发送给想联机到你服务器的互联网客户端即可。
Godot 的高级多人联机 API 使用的是一个修改过的 ENet,包含全 IPv6 支持。
网络初始化
在 Godot 中,高级网络由节点树: :ref:`SceneTree <class_SceneTree>`进行管理。
每个节点都有一个 multiplayer
属性,它是对场景树为其配置的 MultiplayerAPI
实例的引用。每个节点在初始化时都会配有相同预设的 MultiplayerAPI
物件。
也可以建立一个新的 MultiplayerAPI
物件,并将其分配给场景树中的 NodePath
,该操作将覆盖该路径及其所有后代节点的 multiplayer
属性,也允许兄弟节点能够配置不同的对等体,从而可以在一个 Godot 实例中同时运行多个服务端和客户端。
# By default, these expressions are interchangeable.
multiplayer # Get the MultiplayerAPI object configured for this node.
get_tree().get_multiplayer() # Get the default MultiplayerAPI object.
要想初始化网络, 你必须先创建一个``MultiplayerPeer`` 对象,将其初始化为服务端或客户端,然后将其传给 MultiplayerAPI
。
# Create client.
var peer = ENetMultiplayerPeer.new()
peer.create_client(IP_ADDRESS, PORT)
multiplayer.multiplayer_peer = peer
# Create server.
var peer = ENetMultiplayerPeer.new()
peer.create_server(PORT, MAX_CLIENTS)
multiplayer.multiplayer_peer = peer
可以通过下述方法来停止联网功能:
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:
multiplayer.get_unique_id()
通过下述方法来对等体是服务端还是客户端:
multiplayer.is_server()
远程过程调用
远程过程调用(RPC)是可以在其他对等方上调用的函数。要创建一个 RPC,请在函数定义之前使用 @rpc
注解。若要调用 RPC,请在每个对等体中通过 Callable
的 rpc()
方法调用之,或使用 rpc_id()
在特定对等方中调用之。
func _ready():
if multiplayer.is_server():
print_once_per_client.rpc()
@rpc
func print_once_per_client():
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
注解可以采用多个参数,这些参数具有预设值,相当于:
@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。
func _on_some_input(): # Connected to some input.
transfer_some_input.rpc_id(1) # Send the input only to the server.
# Call local is required if the server is also a player.
@rpc("any_peer", "call_local", "reliable")
func transfer_some_input():
# The server knows who sent the input.
var sender_id = multiplayer.get_remote_sender_id()
# Process the input and affect game logic.
信道
现代网络协定支持信道系统。信道是网络连接内的单独连接,允许多个数据包流互不干扰。
像是游戏聊天相关信息和一些核心游戏信息等都应该可靠地发送,但游戏信息不应等待聊天信息被确认后在发送,这一点可以通过使用不同的信道来实现。
当与不可靠的有序传输模式一起使用时,信道也十分有用。使用此传输模式发送可变大小的数据包可能会导致丢包,因为迟达的数据包将会被接收方忽略。通过使用信道,将它们拆分成多个同质数据包流,可以实现有序传输,且丢包很少,不会因可靠模式而导致延迟损失。
索引为 0 的默认信道实际上是三个不同的信道——每个传输模式一个。
大厅实现示例
下面为一个示例大厅,可以处理对等体的加入和离开,通过信号来通知UI场景,并在所有客户端加载游戏场景后启动游戏。
extends Node
# Autoload named Lobby
# These signals can be connected to by a UI lobby scene or the game scene.
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id)
signal server_disconnected
const PORT = 7000
const DEFAULT_SERVER_IP = "127.0.0.1" # IPv4 localhost
const MAX_CONNECTIONS = 20
# This will contain player info for every player,
# with the keys being each player's unique IDs.
var players = {}
# This is the local player info. This should be modified locally
# before the connection is made. It will be passed to every other peer.
# For example, the value of "name" can be set to something the player
# entered in a UI scene.
var player_info = {"name": "Name"}
var players_loaded = 0
func _ready():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func join_game(address = ""):
if address.is_empty():
address = DEFAULT_SERVER_IP
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client(address, PORT)
if error:
return error
multiplayer.multiplayer_peer = peer
func create_game():
var peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CONNECTIONS)
if error:
return error
multiplayer.multiplayer_peer = peer
players[1] = player_info
player_connected.emit(1, player_info)
func remove_multiplayer_peer():
multiplayer.multiplayer_peer = null
# When the server decides to start the game from a UI scene,
# do Lobby.load_game.rpc(filepath)
@rpc("call_local", "reliable")
func load_game(game_scene_path):
get_tree().change_scene_to_file(game_scene_path)
# Every peer will call this when they have loaded the game scene.
@rpc("any_peer", "call_local", "reliable")
func player_loaded():
if multiplayer.is_server():
players_loaded += 1
if players_loaded == players.size():
$/root/Game.start_game()
players_loaded = 0
# When a peer connects, send them my player info.
# This allows transfer of all desired data for each player, not only the unique ID.
func _on_player_connected(id):
_register_player.rpc_id(id, player_info)
@rpc("any_peer", "reliable")
func _register_player(new_player_info):
var new_player_id = multiplayer.get_remote_sender_id()
players[new_player_id] = new_player_info
player_connected.emit(new_player_id, new_player_info)
func _on_player_disconnected(id):
players.erase(id)
player_disconnected.emit(id)
func _on_connected_ok():
var peer_id = multiplayer.get_unique_id()
players[peer_id] = player_info
player_connected.emit(peer_id, player_info)
func _on_connected_fail():
multiplayer.multiplayer_peer = null
func _on_server_disconnected():
multiplayer.multiplayer_peer = null
players.clear()
server_disconnected.emit()
游戏场景的根节点应命名为 Game,在其所附加的脚本中:
extends Node3D # Or Node2D.
func _ready():
# Preconfigure game.
Lobby.player_loaded.rpc_id(1) # Tell the server that this peer has loaded.
# Called only on the server.
func start_game():
# All peers are ready to receive RPCs in this scene.
为专用服务器导出
一旦你制作好了一款多人游戏,你可能会想将其导出到一个没有 GPU 的专用服务器上运行,对此可参见 为专用服务器导出 来获取更多信息。
备注
该页面上的范例代码并不是为了在专用服务器上运行而设计的,你必须修改这些代码来让避免系统将服务器误认为玩家,此外,你还必须修改游戏的启动机制,让第一个加入的玩家可以自行启动游戏。