高级多人游戏
高层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 使用了一个中间层级的 MultiplayerPeer 对象。不应直接创建这种对象,它被设计为由多个 C++ 实现所提供。
这个对象扩展自 PacketPeer 类,继承了所有用于序列化、发送和接收数据的方法。此外,该对象还添加了设置对等体、传输模式等方法。它还包括让你知道对等体何时连接或断开的信号。
这个类接口可以抽象出大多数类型的网络层、拓扑结构和库。默认情况下,Godot 会提供一个基于 ENet 的实现(ENetMultiplayerPeer)、一个基于 WebRTC 的实现(WebRTCMultiplayerPeer)以及一个基于WebSocket的实现(WebSocketPeer),而该类接口可以用来实现移动 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 支持。
网络初始化
High-level networking in Godot is managed by the 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.
// By default, these expressions are interchangeable.
Multiplayer; // Get the MultiplayerAPI object configured for this node.
GetTree().GetMultiplayer(); // 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
// Create client.
var peer = new ENetMultiplayerPeer();
peer.CreateClient(IPAddress, Port);
Multiplayer.MultiplayerPeer = peer;
// Create server.
var peer = new ENetMultiplayerPeer();
peer.CreateServer(Port, MaxClients);
Multiplayer.MultiplayerPeer = peer;
可以通过下述方法来停止联网功能:
multiplayer.multiplayer_peer = null
Multiplayer.MultiplayerPeer = 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.GetUniqueId();
通过下述方法来对等体是服务器还是客户端:
multiplayer.is_server()
Multiplayer.IsServer();
远程过程调用
远程过程调用(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.")
public override void _Ready()
{
if (Multiplayer.IsServer())
{
Rpc(MethodName.PrintOncePerClient);
}
}
[Rpc]
private void PrintOncePerClient()
{
GD.Print("I will be printed to the console once per each connected client.");
}
RPC 既不会序列化对象,也不会序列化可调用体。
要使远程调用成功,发送方节点和接收方节点需要具有相同的 NodePath
,也就是说,这些节点必须具有相同的节点名称。对预期使用 RPC 的节点调用 add_child()
时,请将参数 force_readable_name
设置为 true
。
警告
If a function is annotated with @rpc
on the client script (resp. server script),
then this function must also be declared on the server script (resp. client script).
Both RPCs must have the same signature which is evaluated with a checksum of all RPCs.
All RPCs in a script are checked at once, and all RPCs must be declared on both the client
scripts and the server scripts, even functions that are currently not in use.
The signature of the RPC includes the @rpc()
declaration, the function, return type,
and the NodePath. If an RPC resides in a script attached to /root/Main/Node1
, then it
must reside in precisely the same path and node on both the client script and the server
script. Function arguments are not checked for matching between the server and client code
(example: func sendstuff():
and func sendstuff(arg1, arg2):
will pass signature
matching).
如果不满足这些条件(即如果所有RPC都没有通过签名匹配),脚本则可能会打印错误,错误消息可能与你当前正在构建和测试的 RPC 函数无关;也可能会导致非预期行为的发生。
请参阅本帖的进一步解释和故障排除: 点我前往.
@rpc
注解可以采用多个参数,这些参数具有预设值,相当于:
@rpc("authority", "call_remote", "unreliable", 0)
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false, TransferMode = MultiplayerPeer.TransferModeEnum.Unreliable, TransferChannel = 0)]
其参数及作用如下:
mode
:
"authority"
: Only the multiplayer authority can call remotely. The authority is the server by default, but can be changed per-node using Node.set_multiplayer_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.
private void OnSomeInput() // Connected to some input.
{
RpcId(1, MethodName.TransferSomeInput); // Send the input only to the server.
}
// Call local is required if the server is also a player.
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = true, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void TransferSomeInput()
{
// The server knows who sent the input.
int senderId = Multiplayer.GetRemoteSenderId();
// 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
players.clear()
# 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()
using Godot;
public partial class Lobby : Node
{
public static Lobby Instance { get; private set; }
// These signals can be connected to by a UI lobby scene or the game scene.
[Signal]
public delegate void PlayerConnectedEventHandler(int peerId, Godot.Collections.Dictionary<string, string> playerInfo);
[Signal]
public delegate void PlayerDisconnectedEventHandler(int peerId);
[Signal]
public delegate void ServerDisconnectedEventHandler();
private const int Port = 7000;
private const string DefaultServerIP = "127.0.0.1"; // IPv4 localhost
private const int MaxConnections = 20;
// This will contain player info for every player,
// with the keys being each player's unique IDs.
private Godot.Collections.Dictionary<long, Godot.Collections.Dictionary<string, string>> _players = new Godot.Collections.Dictionary<long, Godot.Collections.Dictionary<string, string>>();
// 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.
private Godot.Collections.Dictionary<string, string> _playerInfo = new Godot.Collections.Dictionary<string, string>()
{
{ "Name", "PlayerName" },
};
private int _playersLoaded = 0;
public override void _Ready()
{
Instance = this;
Multiplayer.PeerConnected += OnPlayerConnected;
Multiplayer.PeerDisconnected += OnPlayerDisconnected;
Multiplayer.ConnectedToServer += OnConnectOk;
Multiplayer.ConnectionFailed += OnConnectionFail;
Multiplayer.ServerDisconnected += OnServerDisconnected;
}
private Error JoinGame(string address = "")
{
if (string.IsNullOrEmpty(address))
{
address = DefaultServerIP;
}
var peer = new ENetMultiplayerPeer();
Error error = peer.CreateClient(address, Port);
if (error != Error.Ok)
{
return error;
}
Multiplayer.MultiplayerPeer = peer;
return Error.Ok;
}
private Error CreateGame()
{
var peer = new ENetMultiplayerPeer();
Error error = peer.CreateServer(Port, MaxConnections);
if (error != Error.Ok)
{
return error;
}
Multiplayer.MultiplayerPeer = peer;
_players[1] = _playerInfo;
EmitSignal(SignalName.PlayerConnected, 1, _playerInfo);
return Error.Ok;
}
private void RemoveMultiplayerPeer()
{
Multiplayer.MultiplayerPeer = null;
_players.Clear();
}
// When the server decides to start the game from a UI scene,
// do Rpc(Lobby.MethodName.LoadGame, filePath);
[Rpc(CallLocal = true,TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void LoadGame(string gameScenePath)
{
GetTree().ChangeSceneToFile(gameScenePath);
}
// Every peer will call this when they have loaded the game scene.
[Rpc(MultiplayerApi.RpcMode.AnyPeer,CallLocal = true,TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void PlayerLoaded()
{
if (Multiplayer.IsServer())
{
_playersLoaded += 1;
if (_playersLoaded == _players.Count)
{
GetNode<Game>("/root/Game").StartGame();
_playersLoaded = 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.
private void OnPlayerConnected(long id)
{
RpcId(id, MethodName.RegisterPlayer, _playerInfo);
}
[Rpc(MultiplayerApi.RpcMode.AnyPeer,TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void RegisterPlayer(Godot.Collections.Dictionary<string, string> newPlayerInfo)
{
int newPlayerId = Multiplayer.GetRemoteSenderId();
_players[newPlayerId] = newPlayerInfo;
EmitSignal(SignalName.PlayerConnected, newPlayerId, newPlayerInfo);
}
private void OnPlayerDisconnected(long id)
{
_players.Remove(id);
EmitSignal(SignalName.PlayerDisconnected, id);
}
private void OnConnectOk()
{
int peerId = Multiplayer.GetUniqueId();
_players[peerId] = _playerInfo;
EmitSignal(SignalName.PlayerConnected, peerId, _playerInfo);
}
private void OnConnectionFail()
{
Multiplayer.MultiplayerPeer = null;
}
private void OnServerDisconnected()
{
Multiplayer.MultiplayerPeer = null;
_players.Clear();
EmitSignal(SignalName.ServerDisconnected);
}
}
游戏场景的根节点应命名为 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.
using Godot;
public partial class Game : Node3D // Or Node2D.
{
public override void _Ready()
{
// Preconfigure game.
Lobby.Instance.RpcId(1, Lobby.MethodName.PlayerLoaded); // Tell the server that this peer has loaded.
}
// Called only on the server.
public void StartGame()
{
// All peers are ready to receive RPCs in this scene.
}
}
为专用服务器导出
一旦你制作好了一款多人游戏,你可能会想将其导出到一个没有 GPU 的专用服务器上运行,对此可参见 为专用服务器导出 来获取更多信息。
备注
该页面上的范例代码并不是为了在专用服务器上运行而设计的,你必须修改这些代码来让避免系统将服务器误认为玩家,此外,你还必须修改游戏的启动机制,让第一个加入的玩家可以自行启动游戏。