高レベルのマルチプレイヤー

高レベルAPIと低レベルAPI

以下では、Godotの高レベルネットワークと低レベルネットワークの違いといくつかの基本事項について説明します。基本をとばして最初のノードにネットワークを追加する場合は、以下の Initializing the network までスキップしてください。ただし、後で残りも読んでください!

Godotは、UDP、TCP、およびSSLやHTTPなどの一部の高レベルプロトコルを介した標準の低レベルネットワークを常にサポートしてきました。これらのプロトコルは柔軟性があり、ほとんどすべてに使用できます。ただし、ゲームの状態を手動で同期するためにそれらを使用すると、多大な作業が必要になる場合があります。バックエンドでカスタムサーバー実装を使用する場合など、その作業を回避できない場合や、そうするほうが価値がある場合があります。しかし、ほとんどの場合、使いやすさを高めるために低レベルネットワークのきめ細かな制御の一部を犠牲にしてでも、Godotの高レベルネットワークAPIを検討する価値があります。

これは、低レベルプロトコルの固有の制限によるものです:

  • TCPは、パケットが常に確実に順序どおりに到着することを保証しますが、一般に、エラー修正のために待ち時間が長くなります。また、手順に「接続(connection)」を必要とし、マルチプレイヤーゲームのようなアプリケーションには向かない目的に合わせて最適化されているので、非常に複雑なプロトコルでもあります。パケットはより大きなバッチで送信されるようにバッファリングされ、パケットごとのオーバーヘッドが少なくなり、待ち時間が長くなります。これはHTTPのようなものには便利ですが、一般的にはゲームには役立ちません。この一部は、(例えば、TCP接続の「Nagleのアルゴリズム」を無効にすることによって)設定および無効にすることができます。
  • UDPはより単純なプロトコルであり、パケットのみを送信します(「接続」の概念はありません)。エラー修正を行わないので処理がかなり速くなりますが(低遅延)、パケットは途中で失われたり、間違った順序で受信されたりする可能性があります。それに加えて、UDPのMTU(最大パケットサイズ)は一般的に小さい(数百バイトのみ)ため、大きなパケットを送信することは、パケットを分割し、再編成し、一部が失敗した場合に再試行することを意味します。

一般に、TCPは信頼性が高く、順序があり、低速であると考えることができます。それに対し、UDPは信頼性がなく、順序がなく、高速であるといえます。パフォーマンスが大きく異なるため、多くの場合、ゲームに必要なTCPの部分(オプションの信頼性とパケット順序)を再構築し、不要な部分(輻輳/トラフィック制御機能、Nagleのアルゴリズムなど)を回避することは理にかなっています。このため、ほとんどのゲームエンジンにはこのような実装が付属しており、Godotも例外ではありません。

要約すると、低レベルのネットワークAPIを使用して最大限の制御を行い、すべてを素のネットワークプロトコルの上に実装するか、または `SceneTree <class_SceneTree>`に基づいた高レベルAPIを使用するかです。これは、一般的に最適化された方法で、シーンの後ろで重い処理を引き受けます。

注釈

Godotがサポートするプラットフォームのほとんどは、言及された高レベルおよび低レベルのネットワーク機能のすべてまたはほとんどを提供します。ただし、ネットワークは常にハードウェアとオペレーティングシステムに大きく依存しているため、一部のターゲットプラットフォームでは一部の機能が変更されたり利用できない場合があります。最も顕著ものとしてHTML5プラットフォームは、現在、WebSocketサポートのみを提供しており、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の組み込みネットワークではなく、選択した低レベルネットワークライブラリを使用する場合は、次の例を参照してください: https://github.com/PerduGames/gdnet3

警告

ゲームにネットワークを追加するには、ある程度の責任が伴います。間違って実行すると、アプリケーションが脆弱になり、チートや悪用につながる可能性があります。攻撃者がアプリケーションを実行しているマシンを危険にさらし、サーバーを使用してスパムを送信したり、他のユーザーを攻撃したり、ユーザーがゲームをプレイしている場合にユーザーデータを盗んだりすることもできます。

これは、ネットワーキングが関係する場合に常に当てはまり、Godotとは関係ありません。もちろん実験はできますが、ネットワークアプリケーションをリリースするときは、考えられるセキュリティ上の問題に常に注意してください。

中レベルの抽象化

ネットワークを介してゲームを同期する方法に入る前に、同期のための基本のネットワークAPIがどのように機能するかを理解しておくと役立ちます。

Godotは、中間レベルのオブジェクト NetworkedMultiplayerPeer を使用します。このオブジェクトは直接作成するためのものではなく、いくつかの実装が提供できるように設計されています:

../../_images/nmpeer.png

このオブジェクトは PacketPeer から拡張されるため、データのシリアル化、送信、受信に役立つすべてのメソッドを継承します。さらに、ピア、転送モードなどを設定するメソッドを追加します。また、ピアが接続または切断したときに通知する信号も含まれています。

このクラスインターフェイスは、ほとんどの種類のネットワーク層、トポロジ、およびライブラリを抽象化できます。デフォルトでは、GodotはENet(NetworkedMultiplayerEnet)に基づいた実装を提供しますが、これを使用してモバイルAPI(アドホックWiFi、Bluetooth用)またはカスタムデバイス/コンソール固有のネットワークAPIを実装できます。

ほとんどの場合、Godotはさらに高度なネットワーク機能を提供するため、このオブジェクトを直接使用することはお勧めしません。 それでも、ゲームに低レベルAPIの特定のニーズがある場合に利用できます。

ネットワークの初期化

Godotでネットワークを制御するオブジェクトは、ツリー関連のすべてを制御するオブジェクトと同じです: SceneTree

高レベルのネットワークを初期化するには、SceneTreeにNetworkedMultiplayerPeerオブジェクトを提供する必要があります。

そのオブジェクトを作成するには、最初にサーバーまたはクライアントとして初期化する必要があります。

指定された最大ピア数を使用して、指定されたポートでリッスンするサーバーとして初期化します:

var peer = NetworkedMultiplayerENet.new()
peer.create_server(SERVER_PORT, MAX_PLAYERS)
get_tree().set_network_peer(peer)

クライアントとして初期化して、特定のIPとポートに接続します:

var peer = NetworkedMultiplayerENet.new()
peer.create_client(SERVER_IP, SERVER_PORT)
get_tree().set_network_peer(peer)

以前に設定したネットワーク ピアを取得します:

get_tree().get_network_peer()

ツリーがサーバーまたはクライアントとして初期化されているかどうかを確認します:

get_tree().is_network_server()

ネットワーク機能の終了:

get_tree().set_network_peer(null)

(ゲームによっては、接続を閉じたりタイムアウトしたりするのではなく、他のピアに自分が離れることを知らせるために、最初にメッセージを送信することは理にかなっています。)

接続の管理

いつでも接続を受け入れるゲームもあれば、ロビーの段階で接続するゲームもあります。 Godotは、どの時点でも接続を受け入れないように要求できます(set_refuse_new_network_connections(bool) および SceneTree の関連メソッドを参照)。 接続するユーザーを管理するために、Godot はシーンツリーで次のシグナルを提供します:

サーバー及びクライアント:

  • network_peer_connected(int id)
  • network_peer_disconnected(int id)

上記のシグナルは、新しいピアが接続または切断したときに、サーバーに接続されているすべてのピア(サーバー上を含む)で呼び出されます。 クライアントは1より大きい一意のIDで接続しますが、ネットワークピアID 1は常にサーバーです。 1未満は無効として処理されます。ローカルシステムのIDは、SceneTree.get_network_unique_id() から取得できます。これらのIDは、主にロビー管理に役立ち、接続されたピア、つまりプレイヤーを識別するため、通常は保存する必要があります。 IDを使用して、特定のピアにのみメッセージを送信することもできます。

クライアント:

  • connected_to_server
  • connection_failed
  • server_disconnected

繰り返しますが、これらの機能はすべて、主にロビー管理やその場でのプレイヤーの追加/削除に役立ちます。 これらのタスクの場合、サーバーは明らかにサーバーとして機能する必要があり、既に接続されている他のプレイヤーに、新しく接続されたプレイヤーに関する情報(名前、統計など)を送信するなどのタスクを手動で実行する必要があります。

ロビーは任意の方法で実装できますが、最も一般的な方法は、すべてのピアのシーンで同じ名前のノードを使用することです。 一般的に、自動ロードされたノード/シングルトンはこれに最適で、常にアクセスできます。例 "/root/lobby"。

RPC

ピア間で通信するための最も簡単な方法は、RPC(リモートプロシージャコール)を使用することです。これは一連の関数として Node で実装されます:

  • rpc("function_name", <optional_args>)
  • rpc_id(<peer_id>,"function_name", <optional_args>)
  • rpc_unreliable("function_name", <optional_args>)
  • rpc_unreliable_id(<peer_id>, "function_name", <optional_args>)

メンバー変数の同期も可能です:

  • rset("variable", value)
  • rset_id(<peer_id>, "variable", value)
  • rset_unreliable("variable", value)
  • rset_unreliable_id(<peer_id>, "variable", value)

関数は、次の2つの方法で呼び出すことができます:

  • 信頼性: 関数呼び出しは何があっても到着しますが、失敗した場合は再送信されるため、時間がかかる場合があります。
  • 信頼性が低い: 関数呼び出しが到着しない場合、再送信されません。しかし、それが到着した場合、すぐにそれを実行します。

ほとんどの場合、信頼性が求められます。 Unreliableは、オブジェクトの位置を同期するときに主に役立ちます(同期は絶えず発生する必要があり、パケットが失われた場合でも、その後に新しいパケットが到着し、その間にオブジェクトがさらに移動したために、失われたパケットの情報は古くなっている可能性があるため、無くなってもさほど問題はありません。ほぼ、これは再送と同じ結果です)。

また、SceneTree には get_rpc_sender_id 関数があり、これを使用してRPCを送信したピア(またはピアID)を確認できます。

ロビーに戻る

ロビーに戻りましょう。サーバーに接続する各プレイヤーが、そのことを全員に伝えることを想像してみてください。

# Typical lobby implementation; imagine this being in /root/lobby.

extends Node

# Connect all functions

func _ready():
    get_tree().connect("network_peer_connected", self, "_player_connected")
    get_tree().connect("network_peer_disconnected", self, "_player_disconnected")
    get_tree().connect("connected_to_server", self, "_connected_ok")
    get_tree().connect("connection_failed", self, "_connected_fail")
    get_tree().connect("server_disconnected", self, "_server_disconnected")

# Player info, associate ID to data
var player_info = {}
# Info we send to other players
var my_info = { name = "Johnson Magenta", favorite_color = Color8(255, 0, 255) }

func _player_connected(id):
    # Called on both clients and server when a peer connects. Send my info to it.
    rpc_id(id, "register_player", my_info)

func _player_disconnected(id):
    player_info.erase(id) # Erase player from info.

func _connected_ok():
    pass # Only called on clients, not server. Will go unused; not useful here.

func _server_disconnected():
    pass # Server kicked us; show error and abort.

func _connected_fail():
    pass # Could not even connect to server; abort.

remote func register_player(info):
    # Get the id of the RPC sender.
    var id = get_tree().get_rpc_sender_id()
    # Store the info
    player_info[id] = info

    # Call function to update lobby UI here

register_player 関数で remote キーワードを使用することで、何か別のことにすでに気付いているかもしれません:

remote func register_player(info):

このキーワードには主に2つの用途があります。 1つ目は、この関数をRPCから呼び出すことができることをGodotに知らせることです。キーワードが追加されていない場合、Godotはセキュリティのために関数を呼び出す試みをブロックします。これにより、セキュリティが非常に簡単になります(したがって、クライアントは別のクライアントのシステム上のファイルを削除する関数を呼び出すことができません)。

2番目の用途は、RPCを介して関数を呼び出す方法を指定することです。 4つの異なるキーワードがあります:

  • remote
  • remotesync
  • master
  • puppet

remote キーワードは、rpc() 呼び出しがネットワークを経由してリモートで実行されることを意味します。

remotesync キーワードは、rpc() 呼び出しがネットワーク経由でリモートで実行されることを意味しますが、ローカルでも実行されます(通常の関数呼び出しを行います)。

その他については、さらに下で説明します。SceneTreeget_rpc_sender_id 関数を使用して、実際にどのピアが register_player にRPC呼び出しを行ったかを確認することもできます。

これにより、ロビー管理について多少説明する必要があります。ゲームを開始したら、セキュリティを追加して、クライアントがおかしなことをしないようにします(時々、またはゲーム開始前に送信する情報を検証します)。話を簡単にするため、また各ゲームは異なる情報を共有するため、ここでは具体例を示していません。

ゲームを開始する

十分な数のプレイヤーがロビーに集まったら、サーバーはおそらくゲームを開始するはずです。これ自体は特別なことではありませんが、この時点であなたの人生をもっと楽にするためにできるいくつかの素晴らしいトリックを説明します。

プレイヤーシーン

ほとんどのゲームでは、各プレイヤーが独自のシーンを持っているでしょう。これはマルチプレイヤーゲームであるため、すべてのピアで それに接続されているプレイヤーごとに1つのシーン をインスタンス化する必要があることに注意してください。 4プレイヤーゲームの場合、各ピアは4プレイヤーノードをインスタンス化する必要があります。

それでは、そのようなノードにどのように名前を付けるのでしょうか? Godotでは、ノードに一意の名前を付ける必要があります。また、プレイヤーがどのノードを各プレイヤーIDを表すかを比較的簡単に判断できる必要があります。

解決策は、インスタンス化されたプレイヤーシーンの ルートノードにnetwork ID という名前を付けるだけです。このように、それらはすべてのピアで同じになり、RPCはうまく機能します!次に例を示します:

remote func pre_configure_game():
    var selfPeerID = get_tree().get_network_unique_id()

    # Load world
    var world = load(which_level).instance()
    get_node("/root").add_child(world)

    # Load my player
    var my_player = preload("res://player.tscn").instance()
    my_player.set_name(str(selfPeerID))
    my_player.set_network_master(selfPeerID) # Will be explained later
    get_node("/root/world/players").add_child(my_player)

    # Load other players
    for p in player_info:
        var player = preload("res://player.tscn").instance()
        player.set_name(str(p))
        player.set_network_master(p) # Will be explained later
        get_node("/root/world/players").add_child(player)

    # Tell server (remember, server is always ID=1) that this peer is done pre-configuring.
    rpc_id(1, "done_preconfiguring", selfPeerID)

注釈

pre_configure_game()を実行するタイミングによっては、シーンの作成中に SceneTree がロックされているため(例: _ready が呼び出されている場合)、call_deferred() を介して遅延されるように add_child() への呼び出しを変更する必要がある場合があります。

ゲームの開始を同期する

プレイヤーのセットアップには、遅延、ハードウェアの違い、またはその他の理由により、ピアごとに異なる時間がかかる場合があります。全員の準備が整ったときにゲームが実際に開始されるようにするには、すべてのプレイヤーの準備が整うまでゲームを一時停止すると便利です:

remote func pre_configure_game():
    get_tree().set_pause(true) # Pre-pause
    # The rest is the same as in the code in the previous section (look above)

サーバーは、すべてのピアからOKを取得すると、たとえば次のように開始するように指示できます:

var players_done = []
remote func done_preconfiguring(who):
    # Here are some checks you can do, for example
    assert(get_tree().is_network_server())
    assert(who in player_info) # Exists
    assert(not who in players_done) # Was not added yet

    players_done.append(who)

    if players_done.size() == player_info.size():
        rpc("post_configure_game")

remote func post_configure_game():
    get_tree().set_pause(false)
    # Game starts now!

ゲームの同期

ほとんどのゲームでは、マルチプレイヤーネットワーキングの目標は、ゲームをプレイするすべてのピアで同期して実行することです。 RPCとリモートメンバー変数セットの実装を提供することに加えて、Godotはネットワークマスターの概念を追加します。

ネットワークマスター

ノードのネットワークマスターは、そのノードに対する最終的な権限を持つピアです。

明示的に設定しない場合、ネットワーク マスタは親ノードから継承され、変更されていない場合は常にサーバー(ID 1)になります。したがって、サーバーにはデフォルトですべてのノードに対する権限があります。

ネットワークマスターは、次の関数で設定できます Node.set_network_master(id, recursive) (デフォルトでは再帰は true で、ネットワークマスターは同様にノードのすべての子ノードに再帰的に設定されます)。

Checking that a specific node instance on a peer is the network master for this node for all connected peers is done by calling Node.is_network_master(). This will return true when executed on the server and false on all client peers.

前の例に注意を払っていた場合、各ピアがサーバーではなく独自の プレイヤー(ノード)のネットワークマスター権限を持つように設定されていることに気付くかもしれません:

[...]
# Load my player
var my_player = preload("res://player.tscn").instance()
my_player.set_name(str(selfPeerID))
my_player.set_network_master(selfPeerID) # The player belongs to this peer; it has the authority.
get_node("/root/world/players").add_child(my_player)

# Load other players
for p in player_info:
    var player = preload("res://player.tscn").instance()
    player.set_name(str(p))
    player.set_network_master(p) # Each other connected peer has authority over their own player.
    get_node("/root/world/players").add_child(player)
[...]

このコードが各ピアで実行されるたびに、ピアはそれが制御するノード上でマスターになり、他のすべてのノードはサーバがネットワーク マスターである Puppet として残ります。

明確にするために、これが bomber demo でどのように見えるかの例を示します:

../../_images/nmms.png

マスターとパペットのキーワード

このモデルの本当の利点は、GDScriptの master/puppet キーワード(またはC#とVisual Scriptの同等のもの)で使用する場合です。remote キーワードと同様に、関数にもそれらのタグを付けることができます:

爆弾のコード例:

for p in bodies_in_area:
    if p.has_method("exploded"):
        p.rpc("exploded", bomb_owner)

プレイヤーのコード例:

puppet func stun():
    stunned = true

master func exploded(by_who):
    if stunned:
        return # Already stunned

    rpc("stun")
    stun() # Stun myself, could have used remotesync keyword too.

上記の例では、爆弾がどこかで爆発します(おそらく、マスターである人によって管理されます)。爆弾はそのエリアの死体を知っているので、彼らはそれらをチェックし、それらが exploded 関数を含んでいることをチェックします。

そうした場合、爆弾は 爆発(exploded) します。ただし、プレイヤーの exploded メソッドには master キーワードがあります。これは、そのインスタンスのマスターであるプレイヤーのみが実際に関数を取得することを意味します。

次に、このインスタンスは、同じプレイヤーの同じインスタンス(ただし、異なるピア)で stun メソッドを呼び出し、puppetとして設定されているインスタンスのみを呼び出して、すべてのピアでプレイヤーをびっくりさせます(および現在のマスターも)。

rpc_id(<id>, "exploded", bomb_owner) を使用して、特定のプレイヤーにのみ stun() メッセージを送信することもできます。これは、爆弾のような影響範囲の場合にはあまり意味をなさないかもしれませんが、他の場合では、単一のターゲットにダメージを与えるなどに使えます。

rpc_id(TARGET_PEER_ID, "stun") # Only stun the target peer