LoginSignup
37
28

More than 1 year has passed since last update.

[UE4] 具体的な実装例から知るマルチプレイヤーゲーム実装の基本のキ

Last updated at Posted at 2021-12-12

はじめに

UE4 はマルチプレイ(以下、ネットワーク通信を用いたリアルタイムの複数人同時プレイを指す)を想定したフレームワークで構築されており、その枠組みを使えば簡単にマルチプレイヤーゲームが作ることができます。
その一方で、公式ドキュメントを眺めてみると何やら見慣れない用語が多く、敷居が高く感じている人も多いのではないでしょうか。
というわけでここでは、マルチプレイヤーゲームにありそうなシンプルな実装ケースを交えながら、最低限押さえておきたい知識と手順をまとめていきます。

前提

本記事をお読みいただくにあたっての前提をまとめています。
実装例のポイントとしては、Blueprint ではなく C++ を用いた実装例になっています。ただ書き方が異なるだけで知識としては基本的にどちらも共通ですのでご安心ください。

本記事の対象読者

  • ある程度 UE4 を触ったことがあり、マルチプレイヤーゲーム以外の基本的なゲームプログラミングは理解している
  • UE4 C++ メインでゲームを作ったことがある……とまではいかなくても、読めば大体雰囲気は掴める

本記事で触れない内容

あくまで実践に必要な最低限の知識をコンセプトにしますので、以下のようお話は別の機会にします。

  • NetworkDriver まわりの詳しい仕組みのお話
    (NetConnection, ActorChannel, NetworkGUID, Bunch, etc…)
  • CharacterMovement と Correction まわりの深い話
    (キャラクター移動機能とその同期)
  • ReplicationGraph
    (UE4.20 から追加された Replication を効率的に行う機能)
  • OnlineSubsystem
    (プラットフォームが提供する認証やリーダーボードなどの機能を抽象化したヤツ)
  • ネットワーク最適化関連

前提知識

実装に入る前に、いくつかの前提知識を理解しておく必要があります。

UE4 が提供するネットワークモデルは Client-Server モデル である

Client-Server モデルとは、サーバー権限を持った 1 つのプロセスに各クライアントが接続して、情報を伝搬してもらうことで同期する仕組みのことをいいます。つまり、ゲームが成立するためには必ずサーバー役の偉い人が 1 人だけいて、他の人はクライアントとしてサーバー役が言うことに原則従うことで動作する形式をとります。
また、サーバーには 2 種類あり、サーバー役とクライアント役(実際のプレイヤー)を兼任する Listen Server と、サーバー役だけを専業とする Dedicated Server があることもおさえておきましょう。
image.png
よくある比較として、各端末同士が直に接続しあい、対等な関係を持つピアツーピア (PtoP, P2P) というネットワーク構造がありますが、UE4 が提供しているものはこれではないのでご注意ください。
image.png

UE4 のクライアント・サーバーは同じソースコードからビルドが生成される

1 つの UE4 プロジェクトからクライアント・サーバーの双方のビルドを生成します。つまり、例えば ThirdPersonCharacter というクラスの中で、サーバープロセスとして動くときはこの処理を行い、クライアントプロセスとして動くときはこの処理を行う……のようにコードロジック側で処理を分岐させて実装します。
image.png

作業準備

環境

項目 内容
UE4 バージョン 4.27.2-18319896
言語 C++
テンプレート First Person CPP
エディタ等 Visual Studio 2019 + Rider for Unreal Engine

※ 本記事と関係ないですが、Rider めっちゃいいですオススメ。

プロジェクト準備

First Person テンプレート (CPP版) を元に新規プロジェクトを作成します。今回はプロジェクト名を FirstPerson にしました。

実装ケース

それでは具体的なケースとその実装方法を見ていきましょう。
各ケースは次のように記載しています。

  1. 実装ゴールの確認をする
  2. 実装手順を確認しながら実装してみる
  3. 実装に関連した知識について補足する

3 つの実装ケースを順にこなすと、敵に銃弾を当てて HP を減らす FPS ゲームっぽい何かが実装できます。

キャラクターの移動を同期する

このケースでは、プレイヤーAさんがとある地点まで移動したら、プレイヤー B さんの視点でも A さんが同様に移動してみえるようにします。

実装手順

他人からの見た目を変える

FirstPersonCharacter は腕だけのスケルタルメッシュなので他人から見えづらいです。
今回は他人から見たときにカプセルコリジョンが描画されるようにして少しでも見えやすくします。

void AFirstPersonCharacter::BeginPlay()
{
    // Call the base class  
    Super::BeginPlay();

    ...(中略)

    // [追加]
    // 他人からカプセルコリジョンが見えるようにする
    GetCapsuleComponent()->SetHiddenInGame(false);
    GetCapsuleComponent()->SetOwnerNoSee(true); // 本人には見えない
}

マルチプレイで起動してみる

FirstPersonExampleMap のマップを開いたら PIE 設定の Multiplayer Options の項目を以下のように変更します。

  • Number of Players → 2 以上
  • Net Mode → Play As Listen ServerPlay As Client

image.png
続いて、いつも通り Play をクリックして PIE を開始してみてください。
すると……
移動が同期されている.gif
なんと既にキャラクターの移動が同期されています!

……はい、こちらは UE4 の Character クラスと、これが標準搭載している CharacterMovementComponent の機能でして、このコンポーネントに則って移動が実装されている限りは、プレイヤーの入力をサーバー上で再現して他のクライアントに勝手に同期してくれます。
FirstPersonCharacter クラスは Character クラスを継承しており、CharacterMovementComponent の機能で移動しているので、すでに移動が同期されるようになっているということですね。スゴいぞ UE4。

補足知識

PIE 設定の Multiplayer Options について

こちらは UE4 エディタ上でマルチプレイのテストを 1 人で行うときに非常によく使う設定です。

Number of Players

その名の通り、プレイヤーを何人分起動するかという数値で、例えば 4 を設定すると 4 人で同じマップで遊ぶことができるというものです。それぞれのクライアントプロセス上でどのように見えているかを確認することができます。

Net Mode

Play StandalonePlay As Listen ServerPlay As Client の 3 つの項目がありますが、後者 2 つが基本的にマルチプレイを想定したオプションになっています。
例えば Number of Players が 3 であるとき、Play As Listen Server は起動するプレイヤーのうち 1 人が Listen Server プロセスとして起動し、残りの 2 人はクライアントプロセスとして起動します。Play As Client は 3 人全員がクライアントプロセスとして起動し、これとは別に Dedicated Server プロセスが起動します。

発砲を同期する

キャラクターの動きは同期されているものの、発砲が同期されておらず、他のプレイヤーからは何もしていないように見えています。
ここでは、既存の発砲を処理している関数 AFirstPersonCharcter::OnFire の呼び出し方を改造して、他のプレイヤーから見ても発砲しているようにします。
銃弾が同期されていない.gif
※銃弾を撃っているが、相手からはウロウロしているだけに見えている

実装手順

そもそもなぜ発砲が同期されていないのか。それは単一のクライアントプロセス上で一連の処理が完結してしまっており、サーバーや他のクライアントで何の処理も行われていないからです。

*改造前の処理の流れ

  1. [Client A] マウスクリックで発砲の入力を行う
  2. [Client A] OnFire 関数を実行する
  3. [Client A] 銃弾 AFirstPersonProjectile が生成される

これを、次のように改造します。

*改造後の処理の流れ

  1. [Client A] マウスクリックで発砲の入力を行う
  2. [Server] OnFire 関数を実行する
  3. [Server] 銃弾 AFirstPersonProjectile が生成される
  4. [Client A][Client B] サーバーで生成された銃弾を自身のプロセス上に複製する

OnFire() をサーバーで実行する

発砲の入力検知はクライアントが行いますが、その際呼び出している OnFire 関数をサーバープロセス上で呼び出すようにします。
クライアントがサーバーに関数の呼び出しをお願いするには Server RPC という機能を使うことで実現できます。

class AFirstPersonCharacter : public ACharacter
{
    GENERATED_BODY()

    ...(中略)

protected:

    /** [追加] サーバープロセス上で OnFire() を実行する */
    UFUNCTION(Server, Reliable, WithValidation)
    void ServerOnFire();

    /** Fires a projectile. */
    void OnFire();

    ...(中略)
};

UFUNCTION(Server, Reliable, WithValidation) が「この関数は Server RPC でサーバー上で実行される」ことを意味します。この ServerOnFire 関数をクライアントプロセス上で呼び出すと、そのクライアントプロセス上では実行されず、代わりにサーバープロセス上で ServerOnFire 関数が実行されます。
OnFire 関数そのものを Server RPC 化したわけではないので、OnFire 関数をクライアントプロセス上で呼び出しても依然同じクライアントプロセス上でしか実行されないことに注意しましょう。

void AFirstPersonCharacter::ServerOnFire_Implementation()
{
    // この関数はサーバープロセス上でのみ実行される
    OnFire();
}

bool AFirstPersonCharacter::ServerOnFire_Validate()
{
    return true;
}

最後に、マウスクリックによって OnFire() が呼ばれていたところを ServerOnFire() が呼ばれるように変更しましょう。

void AFirstPersonCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
    ...(中略)

    // Bind fire event
    // [変更] Fire アクションに ServerOnFire を紐づける
    // PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AFirstPersonCharacter::OnFire);
    PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AFirstPersonCharacter::ServerOnFire);

    ...(中略)
}

これで無事サーバー上で発砲されるようになりました。

銃弾の生成を同期する

サーバー上で銃弾 AFirstPersonProjectile アクターが生成されるようになりましたが、各クライアントはまだ銃弾が生成されたことを知らされていません。サーバーから各クライアントにステートを知らせるには大きく分けてレプリケーションと RPC (Client RPC or Multicast RPC) の 2 種類の方法ありますが、ここではレプリケーションを使って実装しましょう。
レプリケーション自体はアクターに備わっている仕組みでいろんな機能がありますが、今回はその中でも「作成と破壊」機能を活用をします。これは、

  • サーバープロセス上で生成 (Spawn) されたアクターを、各クライアントプロセスに複製する
  • サーバープロセス上で破壊 (Destroy) されたアクターを、各クライアントプロセスでも破壊する

というものです。
「作成と破壊」機能によって各クライアントプロセス上に複製されたアクターは「リモートプロキシ」と呼ばれます。
MF_Replicate1.gif
「作成と破壊」機能はレプリケーションを有効にするだけで勝手に作用するようになります。これには AActor::bReplicatestrue に設定します。今回はコンストラクタで設定してしまいましょう。

AFirstPersonProjectile::AFirstPersonProjectile() 
{
    // [追加] アクターがレプリケートされるようにする
    bReplicates = true;

    ...(中略)
}

これで、サーバーで生成された銃弾が各クライアントに複製され、発砲の様子が完全に同期されるようになりました。
銃弾が同期されている.gif

補足と関連知識

ネットワークロールについて

「作成と破壊」機能によって複製されたアクターはリモートプロキシには実は 2 種類のロールが存在し、それぞれ Autonomous Proxy と Simulated Proxy といいます。

Autonomous Proxy

リモートプロキシの中でも、プレイヤーが直接制御する Player Controller とそれが所有する Pawn, PlayerState だけがこのロールに設定されます。マルチプレイにおいては前述のアクターもサーバープロセス上で生成され、レプリケーションによって複製されたものを各クライアントは利用しています。
例えば、マルチプレイで Client A と Client B が参加しているとき、サーバーはそれぞれのクライアント用に Player Controller (A) と Player Controller (B) を生成し、複製します。Client A プロセス上では Player Controller (A) は Autonomous Proxy ロールに設定されますが Player Controller (B) は Simulated Proxy ロールに設定されます。逆に、Client B プロセス上では Player Controller (A) は Simulated Proxy ロールに設定されますが Player Controller (B) は Autonomous Proxy ロールに設定されます。

(※) Player Controller (A) の各プロセス上でのロール

Server Client A Client B
Authority (本体) Autonomous Proxy (複製) Simulated Proxy (複製)

(※) Player Controller (B) の各プロセス上でのロール

Server Client A Client B
Authority (本体) Simulated Proxy (複製) Autonomous Proxy (複製)
Simulated Proxy

Autonomous Proxy になれなかったすべてのリモートプロキシがこのロールになります。クライアントプロセス上にあるほとんどのアクターはこのロールに設定されることになります。

Authority

リモートプロキシには 2 種類あるということはわかりましたが、サーバー上に存在する本体にはロールは設定されないのでしょうか。
実は本体にも Authority というロールが設定されています。
「サーバー上にあるアクターが Authority ロールである」という認識は誤りではありませんが、「生成されたプロセス上で Authority ロールが設定される」という認識がより正確です。例えば、クライアント上でアクターを生成した場合、そのクライアントプロセス上でそのアクターは Authority ロールに設定されます。もちろん、そのアクターが複製されてサーバー上にリモートプロキシが生成される……なんてことはなく、生成したクライアントプロセス上にしかそのアクターは存在しません。なぜならレプリケーションはサーバーからクライアントに複製する仕組みだからです。ゆえに、サーバー上には Authority ロールのアクターしか存在しませんが、クライアント上には Authority を含むすべてのロールが存在する可能性があります。

RPC の種類について

RPC には 3 種類存在し、それぞれ Server RPC, Client RPC, Multicast RPC です。

Server RPC

実装ケースで使用したように、クライアントからサーバーに関数の実行を要請します。サーバーからクライアントに情報を伝達する方法はレプリケーションや後述する Client RPC, Multicast RPC 等いくつかありますが、クライアントからサーバーに情報を伝達する方法はこの Server RPC しか存在しないことを覚えておくとよいでしょう。また、Server RPC はすべてのアクターが実行できるわけではなく、Autonomous Proxy ロールに設定されたアクターでしか実行できません。実装ケースでは、Fire 入力を処理する FirstPersonCharacter アクターは自分が操作している Pawn であり、Autonomous Proxy ロールに設定されているため問題なく Server RPC を実行できます。

Client RPC

サーバーから特定のクライアントに関数の実行を要請します。Client RPC に設定された関数はサーバープロセス上から呼び出すと、そのアクターの Autonomous Proxy 上 (= 特定クライアント上) で実行されます。この仕組みから、Client RPC は Autonomous Proxy になれるアクターである Player Controller, Pawn, PlayerState にしか基本的に定義されることはありません。

Multicast RPC

サーバー上と、接続されているすべてのクライアント上で関数を実行します。Multicast RPC はリモートプロキシに設定されたロールに依存せず実行されるので、どんなアクターにも定義できます。

銃弾にあたったら HP を減らす

せっかく銃弾が同期されるようになったので、身体にあたったら HP が減るようにしてみましょう。HP は初期値を 100 として、銃弾 1 つにつき 10 ダメージ食らうようにします。また、ダメージを食らったときに残り HP を PrintString で画面上に表示し、0 になったときは見た目で死んだことがわかるようにカプセルコリジョンの表示を消してみましょう。

実装手順

サーバー上で銃弾の当たり判定を行い、FirstPersonCharacter に衝突していたら対象の Health を 10 減らします。
その後、サーバー上で Health の値が変更されたことを、各クライアントに同期します。

キャラクターに HP を実装する

AFirstPersonCharacter に HP 用のメンバ変数 Health を追加します。この変数はサーバー上で値が変更されたときに各クライアントに同期したいのですが、これにはレプリケーションの機能のひとつ、「変数のレプリケーション」が活用できます。

class AFirstPersonCharacter : public ACharacter
{
    ...(中略)

private:
    // [追加]
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    int32 Health = 100;

    // [追加]
    UFUNCTION() // Health の値がレプリケートによって変更されたとき、この関数が呼ばれる
    void OnRep_Health();

};
void AFirstPersonCharacter::OnRep_Health()
{
    // Health の値がクライアント上で更新された後に呼ばれる

    // 残りの Health を画面に表示
    const FString LogString  = FString::Printf(TEXT("Health: %d"), Health);
    UKismetSystemLibrary::PrintString(GetWorld(), LogString);

    if (Health <= 0)
    {
        // Health が 0 以下なら Die! と表示
        UKismetSystemLibrary::PrintString(GetWorld(), TEXT("Die!"));

        // 死んだことを分かりやすくするためにカプセルコリジョンの表示を消す
        GetCapsuleComponent()->SetHiddenInGame(true);
    }
}

上記のように UPROPERTY(ReplicatedUsing = OnRep_Health) とすると、変数はレプリケーション用のメンバ変数に設定されます。サーバーで値の変更が検知されるとクライアントに通知され、クライアント上で値が更新された後に OnRep_Health 関数が呼ばれます。
今回は PrintString 関数で残り HP を表示し、0 になったらカプセルの表示を消すことで死亡を表現しています。

実際に UE4 C++ でレプリケーション変数を有効化するには、これに加えてさらに AActor::GetLifetimeReplicatedProps 関数をオーバーライドして変数をリストする必要があります。

class AFirstPersonCharacter : public ACharacter
{
    ...(中略)

public:
    // [追加]
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

};
void AFirstPersonCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // UPROPERTY(Replicated) とこれが揃ってはじめて変数がレプリケーションされる
    DOREPLIFETIME(AFirstPersonCharacter, Health);
}

UPROPERTY(Replicated) or UPROPERTY(ReplicatedUsing = <コールバック関数>)DOREPLIFETIME(<クラス名>, <変数名>) が揃ってはじめて変数のレプリケーションが有効になるので注意しましょう( DOREPLIFETIME を書き忘れてレプリケーションされないバグを引き起こしがち)。

サーバー上で銃弾の当たり判定を行う

銃弾 AFirstPersonProjectile はサーバー上で生成される一方で各クライアントにも複製され、全プロセス上で射出されます。
ゆえに、全プロセス上で当たり判定とそのコールバック関数 AFirstPersonProjectile::OnHit の呼び出しが発生するのですが、HP を減らす処理も全プロセスで行ってしまうと通信のラグなどによって「Client A では HP を 10 減らしたが、 Server, Client B では当たっていなかったので HP を減らさなかった」のような値の不一致が起こりえます。
なので今回は、HP を減らす処理はサーバーに一任することにします。 サーバー上で AFirstPersonProjectile::OnHit の呼び出しが行われたときのみ、衝突対象が AFirstPersonCharacter かどうかを判定し、HP を減らすことにします。
これにより、「クライアント上では敵に銃弾を当てられているが、サーバー上では当たっていなかったので敵の HP が減らなかった」のようないわゆる「弾抜け」のようなことが発生し得ますが、HP の値の不一致が起きて整合性が保てなくなってしまうよりはよいでしょう。

まずは AFirstPersonCharacter にダメージを受ける関数 OnDamaged を追加します。

class AFirstPersonCharacter : public ACharacter
{
    ...(中略)

private:
    // [追加]
    void OnDamaged(float Damage);
};
void AFirstPersonCharacter::OnDamaged(float Damage)
{
    // Damage の分だけ Health を減らす
    // ただし、Health の最低値は 0 とする
    Health = FMath::Max<int32>(Health - Damage, 0);
}

次に、AFirstPersonProjectile::OnHit で衝突判定を行い、HP を減らす処理を追加します。

void AFirstPersonProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
    ...(中略)

    // [追加]
    if (HasAuthority()) // ロールが Authority である (= サーバープロセス上で true) 
    {
        // サーバープロセス上で衝突対象が AFirstPersonCharacter のとき、HP を 10 減らす
        if (auto Character = Cast<AFirstPersonCharacter>(OtherActor))
        {
            constexpr int32 DamageAmount = 10;
            Character->OnDamaged(DamageAmount); 
        }
    }
}

これで実装完了です!

HPが同期されている.gif
すべてのクライアント上で HP の減少が同期しているのがわかります。
(※ 右のクライアントが発砲して攻撃しています)

補足と関連知識

UPROPERTY の Replicated と RelicatedUsing の違いについて

コールバック関数 (OnRep 関数) が不要な時は Replicated、必要なときは ReplicatedUsing を使用します。

変数の値がレプリケーションされるタイミングについて

結構重要なポイントなのですが、上記の通り「サーバー上で値の変更が検知されたとき」のみ、クライアントに通知されます。
より具体的なイメージを共有すると、サーバーは 1 フレームに 1 度だけ、各アクターが保持している Replicated メンバ変数の値に変更されたものがないかチェックします。このとき、1 つでも値の変更が検知されたアクターはレプリケーション候補リストに登録され、帯域に余裕があれば各クライアントに変更を同期します。候補リストに登録されるのは変数単位ではなく、アクター単位です。なので、例えば Health の他に Stamina という Replicated メンバ変数が定義されており、サーバー上の同一フレームで双方の変数の値が同時に変更されたとします。このとき、Health の値が同期された後に遅れて (別のフレームで) Stamina の値が同期される……ということは起こりません。アクター単位でまとめて変更が同期されるからです。

なるべく OnRep 関数を選ぶべき理由

処理の内容によっては OnRep 関数でも Client RPC, Multicast RPC でも実装できるケースがあります。ただ、OnRep 関数による実装は RPC を使用するよりも通信の帯域幅がかなり少なく抑えられるため、OnRep 関数で処理が実現できるなら、そちらが推奨されます。

条件付き変数レプリケーションについて

GetLifetimeReplicatedProps 関数で DOREPLIFETIME の代わりに DOREPLIFETIME_CONDITION を使用することによって、レプリケーションする対象のリモートプロキシに条件を設定することが可能になります。
例えば今回の例では DOREPLIFETIME_CONDITION(AFirstPersonCharacter, Health, COND_AutonomousOnly) とすると、サーバーで発生した HP の減少が、Autonomous Proxy (被弾した側のクライアント) にしか HP の減少が同期されなくなります(今回のケースでは、ただのバグ)。
今回は詳細に触れませんが、例えば Server RPC と組み合わせてクライアント先行で値を変更してからサーバーの値を変更する際などに、先行して値を変更したクライアント上ではレプリケーション値を無視したい……のようなケースで役立ったりします。
応用的な内容ではありますが、興味がある方は公式ドキュメントをご覧ください。
https://docs.unrealengine.com/4.27/ja/InteractiveExperiences/Networking/Actors/Properties/Conditions/

まとめ

対人 FPS ゲームの実装を模しつつ、マルチプレイヤーゲームを C++ で実装する方法を見ていきました。
用語の紹介は最小限にしたつもりです。実際にマルチプレイ化に必要なコードの量は大したことないな、と感じていただけたら幸いです。サーバーからクライアントに情報を伝える際はレプリケーション、クライアントからサーバーに情報を伝えるときは Autonomous Proxy なアクターを介して Server RPC、という基本原則で大抵の実装はマルチプレイ化できます。

実際には「Reliable な RPC は使用を減らそう」みたいな注意点とか「動きのレプリケーション (Replicate Movement)」まわりの話とかいろいろ話したいことはありますが……

この記事がマルチプレイヤーゲーム実装の足掛かりになれば幸いです。

公式ドキュメントもぜひお読みください。
https://docs.unrealengine.com/4.27/ja/InteractiveExperiences/Networking/

37
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
28