Attention: Here be dragons
This is the latest
(unstable) version of this documentation, which may document features
not available in or compatible with released stable versions of Godot.
Checking the stable version of the documentation...
高级多人游戏
高层与底层 API
下面解释了 Godot 高层与低层网络的区别以及一些基本原理。如果你想一头扎进,为第一个节点添加网络功能,请跳到下面的初始化网络。但稍后请务必阅读其余部分!
Godot 始终支持通过 UDP、TCP 和一些更高级别的协议(如 HTTP 和 SSL)进行标准的低层网络连接。这些协议非常灵活,几乎可以用于任何用途。然而,使用这些协议来手动同步游戏状态可能需要做大量的工作。这些工作有时是无法避免的,有时也是值得去做的,比如在后台使用自定义服务器实现时。但在大多数情况下,值得考虑使用 Godot 的高层网络 API,它牺牲了对低层网络的一些细粒度控制,换来了更强的易用性。
这是低层协议的固有限制所造成的:
TCP 能够确保数据包始终可以可靠、有序地到达接收端,但是由于其错误纠正机制,延迟通常会更高。TCP 本身也是一个相当复杂的协议,因为它理解什么是“连接”,它优化的目标经常不适合多人游戏这类应用程序。系统会将数据包缓冲成更大的批次发送出去,用更高的延迟来换取更小的单数据包开销。这对于 HTTP 之类的可能很有用,但游戏通常不然。一部分这些机制可以进行配置和禁用(例如禁用 TCP 连接的“Nagle 算法”)。
UDP 则是一个更简单的协议,它只发送数据包(没有“连接”的概念),而且因为没有错误纠正机制,所以速度非常快(延迟低),但数据包可能会发生丢包或接收顺序错误等情况。此外,UDP 的 MTU(最大数据包大小)一般很低(只有几百字节),所以传输比较大的数据包意味着需要对数据包进行分割、重组,当某一部分传输失败时还要进行重试。
一般认为,TCP 可靠、有序但速度缓慢;UDP 不可靠、无序,但是速度很快。由于二者在性能上的巨大差异,通常合理的做法是重新构建 TCP 中游戏想要的部分(可选的可靠性和包顺序),同时避免无用的部分(拥塞/流量控制特性、Nagle 算法等)。正因如此,大多数游戏引擎都带有这样的实现,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 的实现(WebSocketMultiplayerPeer),而该类接口可以用来实现移动 API(用于临时 WiFi、蓝牙等)或自定义设备/主机特定的网络 API。
在大多数常见情况下,不鼓励直接使用这个对象,因为 Godot 提供了甚至更高级别的网络使用方法。该对象存在是为了以防万一,有的游戏对较低层的 API 有特殊需求。
服务器托管的注意事项
托管服务器时,LAN 上的客户端可以使用内网 IP 地址进行连接,该地址的格式通常是 192.168.*.*。非 LAN/Internet 客户端无法访问此内部 IP 地址。
要查找内网 IP 地址,在 Windows 中,你可以在命令提示符中输入 ipconfig 命令;在 macOS 中,可以在终端中输入 ifconfig 命令;在 Linux 中,可以在终端中输入 ip addr 命令。
如果你在自己的机器上托管了服务器,并且想让非局域网客户端连接,那么你可能需要在路由器上转发服务器端口。由于大多数家用网络都使用 NAT 技术,这是让你的服务器能通过互联网访问的必经步骤。Godot 的高层多人 API 只使用 UDP 协议,所以你也必须转发 UDP 协议的端口,而不仅仅是 TCP。
在转发 UDP 端口并确保你的服务器使用该端口后,可以前往这个网站查询你的公网 IP 地址,然后将这个公网 IP 地址提供给想要连接服务器的互联网客户端。
Godot 的高层多人联机 API 使用的是修改过的 ENet,完整支持 IPv6。
网络初始化
在 Godot 中,高层网络由场景树进行管理。
每个节点都有一个 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 = OfflineMultiplayerPeer.new()
Multiplayer.MultiplayerPeer = null;
警告
导出到 Android 时,在导出项目或使用一键部署之前,确保在 Android 导出预设中启用 INTERNET 权限。否则,Android 系统会阻止该程序的任何网络通信。
管理连接
每个对等端都会被分配一个唯一 ID。服务器的 ID 始终为 1,客户端则被分配一个随机的正整数。
可以通过连接到 MultiplayerAPI 的信号来响应连接或断开连接:
peer_connected(id: int)该信号会在其他每个对等端上与新连接的对等端 ID 一起发出,也会在新对等端上多次发出(每个其他对等端 ID 发出一次)。peer_disconnected(id: int)当一个对等端断开连接时,剩余的每个对等端都会发出此信号。
其余信号仅在客户端上发出:
connected_to_server()connection_failed()server_disconnected()
若要获取关联对等端的唯一 ID:
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(远程过程调用)无法序列化 Object(对象)或 Callable(可调用函数)。
要使远程调用成功,发送方节点和接收方节点需要具有相同的 NodePath,也就是说,这些节点必须具有相同的节点名称。对预期使用 RPC 的节点调用 add_child() 时,请将参数 force_readable_name 设置为 true。
警告
如果一个函数在客户端脚本(或服务器脚本)上用 @rpc 注解,那么该函数也必须在服务器脚本(或客户端脚本)上声明。两个 RPC 必须具有相同的签名,该签名是使用所有 RPC的校验和计算的。脚本中所有的 RPC 会一次性检查,并且必须在客户端脚本和服务器脚本上都声明所有 RPC,甚至包括当前未使用的方法。
RPC 的签名包括 @rpc() 声明、函数、返回类型以及节点路径。如果 RPC 位于附加到 /root/Main/Node1 的脚本中,则它也必须位于客户端脚本和服务器脚本上完全相同的路径和节点中。不会检查函数参数在服务器和客户端代码之间是否匹配(例如:func sendstuff(): 和 func sendstuff(arg1, arg2): 将会通过签名匹配)。
如果不满足这些条件(即所有 RPC 没有通过签名匹配),脚本可能会打印错误,也可能会导致非预期行为。错误消息可能与你当前正在构建和测试的 RPC 函数无关。
进一步的解释和故障排除请参阅本帖。
注解可以接受多个参数,这些参数具有默认值。@rpc 相当于:
@rpc("authority", "call_remote", "reliable", 0)
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable, TransferChannel = 0)]
参数及其功能如下:
mode:
"authority":只有多人游戏控制方可以远程调用。默认情况下,服务器是控制方,但可以通过 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 发送方对等端的唯一 ID。
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.
}
备注
RPC 方法必须定义在继承自 Node 的类上。如果试图在仅定义于非 Node 类(比如 Resource 资源类)的方法上使用高层级的 RPC 调用,将会导致运行时错误。
信道
现代网络协议支持信道,即连接内的独立连接。这使得多个数据包流可以互不干扰。
例如,游戏聊天相关消息和一些核心游戏玩法消息都应该可靠地发送,但游戏玩法消息不应等待聊天消息被确认后才发送。可以通过使用不同的信道来实现这一点。
信道在与不可靠有序传输模式一起使用时也很有用。使用此传输模式发送大小善变的数据包可能会导致丢包,因为迟达的数据包会被忽略。通过使用信道将它们拆分成多个均一大小的数据包流可以实现有序传输,丢包很少,且没有可靠模式带来的延迟损失。
索引为 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 = OfflineMultiplayerPeer.new()
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():
remove_multiplayer_peer()
func _on_server_disconnected():
remove_multiplayer_peer()
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 的专用服务器上运行。更多信息请参见为专用服务器导出。
备注
该页面上的示例代码并不是为了在专用服务器上运行而设计的。你必须修改这些代码,避免系统将服务器误认为玩家。此外,还必须修改游戏的启动机制,让第一个加入的玩家可以启动游戏。
身份验证
在将你的游戏托管上线面对公众玩家之前,你可能需要考虑添加身份验证功能,并保护你的 RPC(远程过程调用)免受未授权访问。你可以直接使用 SceneMultiplayer 内置的身份验证机制来实现这一点。
在服务器端:
# This goes after `multiplayer.multiplayer_peer = peer`.
multiplayer.auth_timout = 3
multiplayer.auth_callback = func(peer_id: int, payload: PackedByteArray):
var auth_data: Dictionary = JSON.parse_string(payload.get_string_from_utf8())
# Your authentication logic (such as checking the supplied username/password against a database)
# Tell the MultiplayerAPI that the authentication was successful
if authentication_successful:
multiplayer.complete_auth(peer_id)
在客户端:
# This goes after `multiplayer.multiplayer_peer = peer`.
multiplayer.auth_callback = func:
# We have to set this on the client for the `peer_authenticating`
# signal to emit.
pass
multiplayer.peer_authenticating.connect(func(peer_id: int):
var auth_data = {
"username": "username",
"password": "password",
}
multiplayer.send_auth(1, JSON.stringify(auth_data).to_utf8_buffer())
# Tell the MultiplayerAPI that the authentication was successful.
multiplayer.complete_auth(peer_id)
一旦客户端和服务器端都调用了 complete_auth() 方法,连接就会被视为正式建立,此时 connected_to_server 和 peer_connected 这两个信号就会触发。
安全多人联机架构
Godot 的高层级多人联机 API 确实让制作网络游戏变得更轻松了,但它并不会自动帮你把游戏逻辑变得绝对安全。因此,在开发竞技类或需要长期运营的多人游戏时,一定要把所有来自客户端的输入都视为不可信的。
一个常见的错误,就是让客户端去权威性地决定重要的游戏状态,比如玩家的位置、战斗结果、背包变动或者比赛胜负。这样做不仅会让作弊变得容易得多,还会导致更频繁的同步丢失(也就是常说的 "不同步" )。
总的来说,建议优先采用以下几种模式:
对于涉及核心玩法的关键判定,必须使用服务器端权威逻辑。
在将 RPC 的参数应用到游戏状态之前,一定要先验证这些参数是否合法。
在不做任何检查的情况下,不要轻信客户端上报的位置、计时器、技能冷却时间或资源数值。
对于那些可以被频繁触发的操作,一定要加上安全检查(safety checks)和速率限制(rate limits)。
简而言之,在设计网络架构时,你必须确保服务器始终是重要游戏状态的‘唯一真相来源’。
举个例子,与其直接接受客户端发来的最终位置,不如改为将玩家的输入或移动意图发送给权威方(服务器),然后由服务器来验证并应用最终的结果。这样做确实会有一些取舍(比如由于需要实现客户端预测,会导致服务器端的性能开销和复杂度增加),但这会让攻击者通过发送伪造数据来作弊变得极其困难。
关于不同的多人联机网络模型及其各自的安全隐患,你可以查阅这篇文章了解更多详细信息:为你的多人游戏选择合适的网络模型 。