LoginSignup
10

More than 5 years have passed since last update.

VR剣戟ゲーム GARGANTUA で学ぶマルチプレイ実装

Last updated at Posted at 2018-12-12

マルチプレイ対応ゲームのネットワーク同期というと、ゲーム上の多数のオブジェクトを同期しないといけないというイメージをお持ちの方も居るかと思いますが、実際には各クライアント上でシミュレーション出来るものが数多くあります。

この記事では、剣戟VRゲーム GARGANTUA から、スコアマナ・ライフマナというオブジェクトの要素を分解し、どこを同期し、どこをシミュレーションしたか、また、それぞれ UE4 のどんな機能を使って実現したかを紹介します。

GARGANTUA とは?

株式会社よむネコ にて鋭意開発中の剣戟 VR ゲームです。
VRならではの剣戟体験の実現を目標に開発しています。


UE4 のマルチプレイ機能についておさらい

マルチプレイ機能の説明や実装方法については詳しく解説しているページが沢山あるので、ここでは本記事に関連するものを簡単に説明する。

Role について

enum ENetRole
{
    ROLE_AutonomousProxy
    ROLE_SimulatedProxy
    ROLE_Authority
}

AutonomausProxy : 自身が所有しているアクター
基本的にレプリケートされている Controller とそのコントローラーが所有している Pawn が Autonomous となる
サーバーと RPC でやりとり出来る
SimulatedProxy : 他者が所有しているアクター
自身が所有者でないレプリケートされたアクターが Simulated となる
サーバーから配信されたデータでシミュレートするのみ
Authority : アクターのパラメータ配信権限を持つ。
自身のローカル環境で変更されたパラメータを各クライアント (Proxy) へ配信できる
レプリケート対象の全てアクターはサーバーが Authority となる。
シングルプレイ時や、クライアントが勝手に生成したアクターはクライアントが Authory となる。

※UE4 のプロパティレプリケーションは サーバークライアント 方向のみ対応している。

プロパティレプリケーション

UPROPERTY(Replicated)
int32 ReplicatedInteger = 0;

UPROPERTY(ReplicatedUsing=OnRep_ReplicatedActor)
AActor* ReplicatedActor = nullptr;

UFUNCTION()
void OnRep_ReplicatedActor();

プロパティレプリケーションを有効化するには、上記のように UPROPERTY() のオプションに Replicated, ReplicatedUsing を追加する。
ReplicatedUsing に渡した UFUNCTION() な関数は対象プロパティがレプリケートされた際に呼び出される。(値を変更した側は呼び出されないので、必要であれば自分で呼び出す必要がある)

またアクターの関数として GetLifetimeReplicatedProps を定義する必要がある。
プロパティのレプリケーション | Unreal Engine

プロパティレプリケーションは、更新毎にクライアントにデータを配信するのではなく、いい具合に変更されたプロパティを配信するようになっている。またキャラクターの位置などは、更新毎にワープしないように、クライアントでいい具合に補間してくれるようになっている。
大抵のデータはプロパティレプリケートによって同期する。

RPC

UFUNCTION(Server, Reliable)
void ServerRPCFunction();

UFUNCTION(Client, Reliable)
void ClinetRPCFunction();

UFUNCTION(NetMulticast, Reliable)
void MulticastRPCFunction();

RPC の実装は UFUNCTION() のオプションに Server, Client, NetMulticast を追加することで実装可能となる。
RPC について | Unreal Engine

Server : クライアント(Autonomous) → サーバー(Authority) の呼び出し
AutonomousProxy が呼び出した場合、RPC を通して Authority 上で実行される。
SimulatedProxy が呼び出した場合、何も処理は行われない。
Authority が呼び出した場合、自身の環境(サーバー)上で実行される。
Client : サーバー(Authority) → クライアント(Autonomous) の呼び出し
AutonomousProxy が呼び出した場合、自身の環境上で実行される。
SimulatedProxy が呼び出した場合、何も処理は行われない。
Authority が呼び出した場合、RPC を通して AutonomousProxy 上で実行される。
NetMulticast : サーバー(Authority) → クライアント(Autonomous, Simulated) の呼び出し
AutonomousProxy が呼び出した場合、自身の環境上で実行される。
SimulatedProxy が呼び出した場合、自身の環境上で実行される。
Authority が呼び出した場合、自身の環境 + RPC を通して AutonomousProxy, SimulatedProxy 上で実行される。

呼び出しする度に通信が発生するので、全てのデータを RPC で同期しようとするととても重い。本当に RPC が必要かどうかを考えて使う。

UE4 のネットワークドライバーは UDP プロトコルで会話している。
UDP には通信の到達保証がないので、重要な呼び出しには Reliable を定義しておくと、届いてなさそうな場合に再送してくれるようになる。

本編

おさらいが終わったところで、いくつかの機能の実装を紹介する。
ここで上げる仕様とコードは説明のためにシンプル化したもので、実際のものとは異なります。

スコアマナ

スコアマナというのは、エネミーを倒すことで手に入るスコアを獲得できるアイテムです。
エネミーを倒すと出現し、ラストアタックを与えたプレイヤーに自動的に吸収され、スコアが加算されます。


更に詳細な仕様は以下のものとします。

  • スコアマナの量は "5"個 と決まっており、ハードコードされたデータとして全クライアントが保有しています。
  • スコアマナはエネミーが倒れた位置からランダムに飛び散ってバウンドした後に、対象キャラクターへ飛んでいきます。
  • ダメージ処理はサーバーで行われており、各キャラクターのライフポイントと最後にダメージを与えたアクターはレプリケートされている。

これらの情報から処理を大まかに分けると スコアの加算処理スコアマナ入手の演出処理 になるでしょう。

ゲームプレイの成績に関わる スコアの加算 は重要なデータの変更を行うので、サーバーで処理するのが良さそうです。
次にエネミーを倒したキャラクターが スコアマナを入手したように見える演出 の実装について必要な情報は 出現位置飛ぶ方向対象キャラクター となりますが、出現位置はエネミーの位置、対象キャラクターはレプリケートされたプロパティとして存在しています。飛ぶ方向は個々のスコアマナをレプリケートせずとも各クライアント上で適当に飛ばせても良さそうです。

以上から、RPC やスコアマナのレプリケートを行わずに既にレプリケートされているライフポイント、最後にダメージを与えたアクター、ライフポイントが 0 になった時の位置を利用してコードを起こしてみます。

Enemy.cpp
void Enemy::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    /* UPROPERTY(ReplicatedUsing=OnRep_ReplicatedLifePoint) int32 ReplicatedLifePoint */
    DOREPLIFETIME(Enemy, ReplicatedLifePoint);
    /* UPROPERTY(Replicated) AActor* LastDamageSourceActor */
    DOREPLIFETIME(Enemy, LastDamageSourceActor);
}
void Enemy::OnRep_ReplicatedLifePoint()  // サーバーは自分でこの関数を呼んでいる
{
    if (ReplicatedLifePoint == 0) {
        ProcessScoreManas();
    }
}
void Enemy::ProcessScoreManas()
{
    auto Killer = Cast<Character>(LastAttackDamageActor);
    if (!Killer) { return; }

    int AddScore = 5;
    if (HasAuthority()) {
        // サーバーではスコアの加算処理を行う
        Killer->AddScore(AddScore);
    }
    else {
        // エフェクト用のアクターのスポーンはクライアントで行う
        int NumSpawnManas = 5;
        auto SpawnTransform = GetActorTransform(); // エネミーの倒れた位置に生成
        for (int i = 0; i < NumSpawnManas; ++i) {
            sudo Mana = GetWorld()->SpawnActorDeferred<ScoreMana>(BPScoreManaClass, SpawnTransform);
            Mana->SetTargetActor(Killer);  // 移動先アクターを設定
            UGameplayStatics::FinishSpawningActor(Mana);  // スコアマナの生成 (ScoreMana の実装で適当に飛んでいってもらいます)
        }
    }
}

これで他人が入手しているスコアマナが見えるようになりました。
ちなみに、この実装だとシングルプレイ時は HasAuthority() == true となるためスコアマナアクターの生成が行われません。シングルプレイに対応するには追加の実装が必要です。

ライフマナ

ライフマナというのは、エネミーを倒すことで周囲に配置されるライフポイントを回復できるオブジェクトです。
ライフマナ毎にライフポイントの保有量があり、複数人で同時に吸収することができます。

更に詳細な仕様は以下のものとします。

  • ライフマナの量は "100" と決まっており、ハードコードされたデータとして全クライアントが保有しています。
  • ライフマナはエネミーが倒れた位置からランダムに飛び上がって、一定の高さで止まる。
  • ライフポイントの残量に比例して小さくなり、0になると消える。
  • ダメージ処理はサーバーで行われており、各キャラクターのライフポイントはレプリケートされている。

これらの情報から処理を大まかに分けると ライフポイントの加減算処理ライフマナ出現の演出処理 になるでしょう。

まずはライフマナの出現について考えてみます。
ライフマナを出現させるには、スコアマナと同じように、何処に出現し何処に飛んで行くかですが、ライフポイントの保有量が共有なので、位置を同期した方が「これ俺んだからお前あっちいけよー」みたいな感じになって面白そうです。
また、ライフポイントの出現後の動きは、プレイヤーやAIによって動くキャラクターと違って法則性があり、衝突干渉するオブジェクトは動かない背景オブジェクト (WorldStatic) のみなので、完全な位置同期をしなくとも、飛び上がる初期ベクトルさえ同じであれば、各クライアントで大きくズレることなく移動させることができます。

Enemy.cpp

void Enemy::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    /* UPROPERTY(ReplicatedUsing=OnRep_ReplicatedLifePoint) int32 ReplicatedLifePoint */
    DOREPLIFETIME(Enemy, ReplicatedLifePoint);
    /* UPROPERTY(Replicated) AActor* LastDamageSourceActor */
    DOREPLIFETIME(Enemy, LastDamageSourceActor);
}
void Enemy::OnRep_ReplicatedLifePoint()
{
    if (ReplicatedLifePoint == 0) {
        ProcessLifeManas();
    }
}
void Enemy::ProcessLifeManas()
{
    if (!HasAuthority()) { return; }
    // サーバーでのみスポーン処理を行う
    int NumSpawnManas = 5;
    int LifeValue = 100;
    auto SpawnTransform = GetActorTransform(); // エネミーの倒れた位置に生成
    for (int i = 0; i < NumSpawnManas; ++i) {
        sudo Mana = GetWorld()->SpawnActorDeferred<LifeMana>(BPLifeManaClass, SpawnTransform);
        Mana->Initialize(LifeValue);  // 保有ライフポイントを設定
        UGameplayStatics::FinishSpawningActor(Mana);  // スコアマナの生成(レプリケート設定なので各クライアントでスポーンされる)
    }
}
LifeMana.cpp
void LifeMana::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    /* UPROPERTY(Replicated) FVector InitialVector */
    DOREPLIFETIME_CONDITION(LifeMana, InitialVector, COND_InitialOnly);  // 初回のみ同期する条件付きリプリケーション
    /* UPROPERTY(Replicated) LifeMana* CurrentLifeValue */
    DOREPLIFETIME(LifeMana, CurrentLifeValue);
}
void LifeMana::Initialize(int32 LifeValue)
{
    // アクターの生成途中にサーバーで呼び出される
    InitialVector = GetRandomVector();  // ランダムなベクトルを返す関数
    CurrentLifeValue = LifeValue;
}
void LifeMana::BeginPlay()
{
    // サーバーと各クライアントでスポーン処理後に呼び出される
    Super::BeginPlay();
    InitialLifeValue = CurrentLifeValue;  // スケール計算のために初期値をコピー
}
void LifeMana::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // ライフポイントの残量によって小さくする
    float NewScale = FMath::FInterpTo(CurrentScale, CurrentLifeValue / (float)InitialLifeValue, DeltaTime, 1);
    if (NewScale <= 0) {
        // スケールが0以下になると自殺
        Destroy();
        return;
    }

    // InitialVector に従って動く処理とか
    ...
}

これで各クライアントで大体同じ位置にライフマナが停止し、ライフポイントの残量によって同じように小さくなる。
初期ベクトルの同期は、疑似乱数のシード値として同期するという方法もある。

続いてライフポイントの加減算だが、ライフポイントは言うまでもなく重要なデータに値するのでサーバーで処理するべきでしょう。そして、ライフポイントの吸収イベントはクライアントで発生するので、サーバーで処理してもらうためには RPC の実装が必要になります。吸収している間 Tick 毎に RPC を呼び出すのは乱暴すぎるので、吸収の開始と終了のイベントを RPC で通知し、吸収状態の間はサーバー側の Tick でライフポイントを加減算し、キャラクターとライフマナのライフポイント量はそれぞれのレプリケーションに任せていい具合に同期してもらうことにしましょう。

名称未設定_2.png
縦が時間軸として、ピンクの矢印が RPC、オレンジの矢印がプロパティレプリケーションによる同期処理です。

LifeMana.cpp
int32 LifeMana::DrainValue(int32 InDrainValue)
{
    // ライフマナの減算処理
    int32 Value = FMath::Min(CurrentLifeValue, InDrainValue);
    CurrentLifeValue -= Value;
    return Value;
}

ライフポイントが0になった場合に、すぐ Destroy() してしまうと、各クライアントへ存在の消滅が同期されるのはサーバー側のGCによって、このオブジェクトが回収されたあととなるため、各クライアントのオブジェクトを非表示にするには別の方法を取ったほうが良いでしょう。
この例では、CurrentLifeValue が 0 を同期することで、各クライアントの Tick により Destroy されます。

Character.cpp
UCLASS()
class Character : public ACharacter
{
    ...
    UPROPERTY(ReplicatedUsing=OnRep_HandledMana) 
    LifeMana* HandledMana = nullptr;  // 吸収しているマナ

    UFUNCTION() 
    void OnRep_HandledMana();  // サーバー側で HandledMana を変更された時に呼ばれる

    /** Client(Autonomous) => Server(Authority) の RPC 定義
     * ライフマナ吸収開始時に対象マナを引数として呼び出す
     * ライフマナ吸収終了時に nullptr を引数として呼び出す
     */
    UFUNCTION(Server, Reliable)
    SeverHandleMana(LifeMana* LifeMana);  // LifeMana アクターはレプリケート設定なので RPC で渡すことができる
};

void Character::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(Character, HandledMana);
}

void Character::ServerHandledMana_Implimentation(LifeMana* InLifeMana)
{
    HandledMana = InLifeMana;
}

void Character::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    TickDrainMana(DeltaTime);
}

void Character::TickDrainMana(float DeltaTime)
{
    if (!HandledMana || !HasAuthority()) { return; }
    // 吸収処理を行うのはサーバーのみ
    int32 DrainValue = this->GetDrainValue(DeltaTime);
    DrainValue = HandledMana->DrainValue(DrainValue);  // ライフマナから減算
    if (DrainValue == 0) {
        /* ライフポイント残量が0
         * キャラクターとライフマナの距離が離れた
         * 既にキャラクターのライフポイントが全快
         * 等の結果、吸収処理をサーバー側で中断する */
        HandledMana = nullptr;
        return;
    }
    this->AddMana(DrainValue);  // 自身のライフポイントを加算
}

void Character::OnRep_HandledMana()
{
    if (HasAuthority()) { return; }
    // 吸収エフェクトの表示
    if (HandledMana != nullptr) {
        DrainParticleComponent->Activate(true);
    } else {
        DrainParticleComponent->Deactivate();
    } 
}

これでクライアントでトリガーされたライフマナの吸収処理を RPC 越しにサーバーで開始させ、Tick 毎に変更されるライフポイントをレプリケーションにより受け取り、ライフマナの吸収処理を RPC 越しにサーバーで停止させる。という動作が可能になりました。

この実装の場合、クライアントの吸収エフェクトの発生が RPC でサーバーに渡した *LifeMana がレプリケーションで同期されるまで遅れることになります。クライアント側で先行してエフェクトを表示するために以下のような実装をしてしまうと、エフェクトを停止できないような状態になってしまうため工夫が必要です。


void Character::HandleMana(LifeMana* InLifeMana)
{
    // エフェクトを先行して表示するため自分でプロパティを設定する(クライアント)
    HandledMana = InLifeMana;
    OnRep_HandledMana();
    // サーバーRPCの呼び出し
    ServerHandleMana(InLifeMana);
}

void Character::ServerHandledMana_Implimentation(LifeMana* InLifeMana)
{
    // RPC で呼び出されたのでプロパティを書き換える(サーバー)
    HandledMana = InLifeMana;
}

void Character::TickDrainMana(float DeltaTime)
{
    // RPC を呼び出されたフレームで実行される(サーバー)
    ...
    if (DrainValue == 0) {
        // 何らかの理由により即キャンセル
        HandledMana = nullptr;
        return;
    }
    ...
}

サーバー上では、同一フレームで nullptr に戻しているので、プロパティの変更が検知されず、クライアントに nullptr がレプリケートされることはない。

まとめ

ネットワーク通信には物理的に超えられない壁(通信遅延・帯域制限)が存在し、遅延なく大量のデータを同期することは不可能です。
UE4 には便利なネットワーク機能が充実しており、手早くマルチプレイ機能を実装することができますが、それぞれの機能の特性を把握し適切に使うことが大切です。
また、 重要なものは正しく同期 し、そうでもない物や遅延を 誤魔化すためのシミュレーション をどのように工夫するかが、現在のマルチプレイの実装には重要だと思います。

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
10