高階多人連線

高階 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.

初始化網路時,需先建立 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 = OfflineMultiplayerPeer.new()

警告

匯出到 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()

檢查目前 peer 是伺服器還是客戶端:

multiplayer.is_server()

遠端程序呼叫(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.")

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)

各參數說明如下:

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.

通道(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 = 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()

遊戲場景的根節點應命名為 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)。詳情請見 匯出為專用伺服器

備註

本頁範例程式碼並未針對專用伺服器設計。你需要自行修改,讓伺服器不被視為玩家,也要調整遊戲啟動流程,讓第一個加入的玩家可以開始遊戲。