高階多人連線
高階 API 與 低階 API
以下說明 Godot 中高階和低階網路的差異及其基本原理。如果你想直接開始在節點中加入網路功能,可直接跳到下方的 初始化網路 章節,但建議稍後還是回來閱讀其餘內容!
Godot 一直以來都支援標準的低階網路通訊協定,如 UDP(使用者資料包協定)、 TCP(傳輸控制協定),以及更高階的 HTTP(超文本傳輸協定)、 SSL(安全通訊層) 等。這些協定非常彈性,幾乎可以用於任何場合。不過,若要手動透過這些協定來同步遊戲狀態,會需要投入相當多的開發工作。有時這是必要或值得的(例如你在後端使用自訂伺服器實作時);但大多數情況下,建議優先考慮 Godot 的高階網路 API,雖然會犧牲一些低階細節控制,但能大幅提升開發效率。
這是因為低階協定本身的限制:
TCP 能保證封包可靠且按順序到達,但因為要進行錯誤校正,通常會有較高延遲。它本身也是個相當複雜的協定,會處理所謂的「連線」,並針對某些場景(通常不適用於多人遊戲)進行最佳化。封包會被緩衝,等到累積足夠再一起發送,以減少每個封包的額外負擔,但這也會增加延遲。這對 HTTP 等用途來說很有幫助,但對遊戲來說通常不是好事。部分行為可透過設定關閉(如關閉 TCP 的「Nagle 演算法」)。
UDP 協定更簡單,只負責發送資料封包(且沒有「連線」概念)。它不做錯誤修正,所以延遲很低、速度很快,但封包可能會在傳送過程中遺失或順序錯亂。此外,UDP 的 MTU(最大封包大小)通常很小(只有數百位元組),所以傳送較大的資料時必須拆封包並重組,若有部分失敗還需重新傳送。
總結來說,TCP 屬於可靠、有序、但較慢;UDP 則是不可靠、無序且快速。兩者效能差異很大,因此遊戲常常會自己實作只需要的 TCP 功能(如部分可靠性和排序),而避開某些遊戲不需要的部分(像是壅塞/流量控制、Nagle 演算法等)。因此大多數遊戲引擎都有這類專用網路實作,Godot 也不例外。
總結來說,你可以選擇低階網路 API 來實現最大控制權、在底層協定之上自行打造一切,也可以直接利用基於 SceneTree 的高階 API,讓它自動幫你處理大部分網路細節與優化。
備註
Godot 支援的大多數平台都具備上述高階與低階網路功能。不過,網路功能往往依賴硬體與作業系統,因此有些功能在特定平台上可能不同或不可用。尤其 HTML5 版目前只支援 WebSocket 與 WebRTC,缺乏某些高階功能,也無法直接存取 TCP、UDP 這類低階協定。
備註
更多關於 TCP/IP、UDP 及網路相關知識: https://gafferongames.com/post/udp_vs_tcp/
Gaffer On Games 網站有許多實用的遊戲網路技術文章(連結),例如這一篇超完整的《遊戲網路架構簡介 <https://gafferongames.com/post/what_every_programmer_needs_to_know_about_game_networking/>`__》。
警告
在遊戲中加入網路功能也意味著更多責任。如果處理不當,應用程式可能會有安全漏洞,導致被外掛、作弊、甚至被利用。更嚴重時,攻擊者可能入侵你的伺服器,拿來發送垃圾信、攻擊他人,或竊取玩家資料。
只要是網路應用都會面臨這些問題,這與 Godot 本身無關。你可以隨意測試、練習,但只要要公開發佈網路遊戲,就務必重視安全性問題。
中階抽象
在了解如何跨網路同步遊戲之前,有必要先認識 Godot 用來同步的底層網路 API 怎麼運作。
Godot 採用了一個中階物件 MultiplayerPeer。這個物件並不是讓你直接建立,而是由多種 C++ 實作類別提供。
此物件繼承自 PacketPeer,因此擁有序列化、發送與接收資料的所有方便方法。此外,也有設定節點、傳輸模式等方法,還有用於偵測連線/斷線的 signals。
這個介面可用來抽象各式網路層、拓撲與函式庫。Godot 內建 ENet(ENetMultiplayerPeer)、WebRTC(WebRTCMultiplayerPeer)及 WebSocket(WebSocketPeer)等實作,但也可以自行擴充,像是行動裝置專用 WiFi、藍牙,或是特定主機 API。
在大多數情況下,建議不要直接用這個物件,因為 Godot 還有更高階的網路 API。只有當你真的需要低階控制時才用它。
主機託管須知
在主機託管伺服器時,同一 區域網路(LAN) 內的用戶端可以用內部 IP(通常是 192.168.*.* )連線。但這個內部 IP 不能被外部網際網路上的玩家存取。
在 Windows 上,可在命令提示字元輸入 ipconfig 查詢;macOS 請於終端機輸入 ifconfig;Linux 則輸入 ip addr。
如果你在自己電腦上架設伺服器,並希望讓外部(非區網)用戶端連線,通常必須在家用路由器上「連接埠轉發」伺服器的埠號。這是因為大多數家用連線都用 NAT(https://en.wikipedia.org/wiki/Network_address_translation)。Godot 高階多人 API 只用 UDP,所以你必須設定 UDP 連接埠轉發,不只是 TCP。
完成 UDP 連接埠轉發並確認伺服器使用該埠號後,可透過「https://icanhazip.com/」查詢你的公開 IP,然後將此 IP 提供給想連線的網路玩家。
Godot 的高階多人 API 採用改寫版的 ENet,支援完整 IPv6。
初始化網路
在 Godot 中,高階網路功能是由 SceneTree 負責管理的。
每個節點都有一個 multiplayer 屬性,這是一個由場景樹配置的 MultiplayerAPI 實例的參考。預設情況下,所有節點都指向同一個 MultiplayerAPI 物件。
你可以建立新的 MultiplayerAPI 物件,並設定給場景樹上特定的 NodePath,如此一來該節點與其所有子節點都會用這個新的 multiplayer。這讓你可以讓兄弟節點用不同的 peer,甚至可以在同一個 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 阻擋。
管理連線
每個連線節點(peer)都會被指定一個唯一 ID。伺服器的 ID 永遠是 1,客戶端會分配隨機正整數 ID。
可以藉由連接 MultiplayerAPI 的 signals 來監聽連線與斷線事件:
peer_connected(id: int)這個 signal 會在所有已連線 peer 上觸發,參數是新連線 peer 的 ID;新加入的 peer 則會收到一次所有其他 peer 的 ID。peer_disconnected(id: int)這個 signal 會在其他所有 peer 上觸發,參數是剛剛斷線的 peer ID。
下列 signals 僅會在客戶端觸發:
connected_to_server()connection_failed()server_disconnected()
取得目前 peer 的唯一 ID:
multiplayer.get_unique_id()
Multiplayer.GetUniqueId();
檢查目前 peer 是伺服器還是客戶端:
multiplayer.is_server()
Multiplayer.IsServer();
遠端程序呼叫(RPC)
遠端程序呼叫(RPC)是可以在其他 peer 執行的函式。要定義 RPC,請在函式前加 @rpc 標註。呼叫 RPC 時,使用 rpc() 會在所有 peer 執行,使用 rpc_id() 則只會指定 peer 執行。
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 不會序列化物件或函式指標(callable)。
要讓遠端呼叫成功,發送端與接收端節點的 NodePath 必須完全相同,也就是節點名稱必須一致。若是使用 add_child() 加入會用到 RPC 的節點,建議將 force_readable_name 參數設為 true。
警告
若某函式在客戶端(或伺服器端)腳本用 @rpc 標註,則必須在伺服器端(或客戶端)腳本也宣告同名函式。所有 RPC 必須有相同簽名,這會以**所有 RPC** 計算檢查碼。所有 RPC 會一次性檢查,且必須在客戶端與伺服器端腳本都宣告,即使該函式當下未被使用也一樣。
RPC 的簽名包含 @rpc() 宣告、函式名稱、回傳型別,以及 NodePath。如果 RPC 函式是在 /root/Main/Node1 這個節點上,那無論在客戶端還是伺服器端,都必須在相同路徑與節點上定義。參數內容不會影響簽名比對(例如 func sendstuff(): 和 func sendstuff(arg1, arg2): 也算一致)。
若未符合上述條件(RPC 簽名不一致),腳本可能會出現錯誤或產生非預期行為。錯誤訊息有時甚至和你正在編寫的 RPC 無關。
詳細說明與疑難排解可參考這篇討論:<https://github.com/godotengine/godot/issues/57869#issuecomment-1034215138>。
@rpc 標註可帶入多個參數(皆有預設值)。@rpc 等同於:
@rpc("authority", "call_remote", "unreliable", 0)
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false, TransferMode = MultiplayerPeer.TransferModeEnum.Unreliable, TransferChannel = 0)]
各參數說明如下:
mode:
"authority":只有 multiplayer authority 才能進行遠端呼叫。預設為伺服器,但可用 Node.set_multiplayer_authority 更改每個節點的權限。"any_peer":允許任何 peer(包含用戶端)進行遠端呼叫,通常用來傳送玩家輸入等資料。
sync:
"call_remote":此函式不會在本地 peer 上被呼叫。"call_local":此函式也會在本地 peer 執行。適用於伺服器同時也是玩家的情境。
transfer_mode:
"unreliable":資料封包不會被確認,可能遺失、亂序。"unreliable_ordered":封包會按發送順序處理。如果較晚到達的封包已被後發的封包覆蓋,則會被直接忽略。若使用不當會造成資料遺失。"reliable":會持續重送直到封包被正確收到,且封包順序固定,但效能影響較大。
transfer_channel 為通道索引。
前 3 個參數可任意順序,transfer_channel 必須最後寫。
你可以在 RPC 執行的函式內,透過 multiplayer.get_remote_sender_id() 取得發送端的唯一 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.
}
通道(Channels)
現代網路協定支援多通道(channels),即在同一連線下建立多個獨立子連線,讓多條資料流互不干擾。
例如聊天室訊息與核心遊戲資料都要可靠傳送,但遊戲資料不應該被聊天訊息延遲影響。這時就應該用不同通道分開傳送。
通道也很適合搭配不可靠有序傳輸模式。此模式下若傳遞不同大小的封包,後到的封包可能會被直接丟棄,造成資料遺失。將不同功能資料分成多個相同型態封包的資料流,就能在有序傳輸下減少資料遺失,也不用承擔可靠模式帶來的延遲。
預設的 0 號通道(index 0)實際上有三個不同的通道——各自對應不同的傳輸模式。
大廳範例實作
這是一個能處理新玩家加入與離開、透過 signals 通知 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)。詳情請見 匯出為專用伺服器。
備註
本頁範例程式碼並未針對專用伺服器設計。你需要自行修改,讓伺服器不被視為玩家,也要調整遊戲啟動流程,讓第一個加入的玩家可以開始遊戲。