【UE】Unity エディタと Unreal エディタでローカルマルチプレイしよう!~UE 編~

Unreal Engine

はじめに

Unreal Engine (UE) Advent Calendar 2025 の4日目の記事です!
この記事では誰もが一度は夢見た Unity エディタとのローカルマルチプレイを実現する方法を解説します。

今年の4月頃に Unity エディタと Unreal エディタとのローカルマルチプレイについてツイートポストしたのですが、この時は各エディタがプレイヤーの座標と角度をローカルにある JSON ファイルに書き込んでそれを互いに読み取るという、このポストに書かれている通りの「ゴリ押し実装」をしていました。

この時の実装のイメージはこんな感じです。

この記事では TCP 通信を使用してもう少しゴリ押し度を下げた実装を施します。

この記事での環境は以下の通りです。

  • Windows 11
  • Unreal Engine 5.6.1(ランチャー版)
  • Json Blueprint Utilities (Beta) 1.0

そして、この記事の Unity 版の記事もあるのでそれと読み比べてみるとかなり面白いかと思います!

  • マルチプレイゲーム開発においては TCP 通信よりも UDP 通信を使用した方が良いかと思いますが、この記事では TCP 通信を使用します。
  • (実装の美しさやオブジェクトの管理方法、ソケットの扱いなどはさておき、)「Unity と Unreal Engine で全く同じ内容のゲームを制作するときはこういう違いがあるんだなぁ」という視点で読んでいただけると幸いです。

手順

ここからは実際の手順を解説します。
(ここから先は Unity 版と比較しやすいように作業の内容や流れなどをその記事とある程度揃えてあります!)

プロジェクトの作成

まずはプロジェクトを作成します。
Epic Games Launcher を開いて「Unreal Engine > Library」に行き、使用したいバージョンの「Launch」を押します。

Unreal Editor が立ち上がるので「GAMES」からテンプレートやプロジェクトの種類(「BLUEPRINT」or「C++」)、プロジェクト名などを設定して右下の「Create」を押します。
今回は「Blank」という空のテンプレートを選択してプロジェクトの種類は「C++」にしました。

プラグインの有効化

今回はブループリントで JSON を扱いたいので「Json Blueprint Utilities」というプラグインを有効化します。

新規プロジェクトが開いたら「Edit > Plugins > ALL PLUGINS」で全てのプラグインが表示されている状態にします。
検索バーで「json」などと検索して「Json Blueprint Utilities」にチェックを付けます。
右下に「Restart Now」と表示されるのでそのボタンを押してエディタを再起動します。

これで「Json Blueprint Utilities」が有効になり、ブループリントで JSON を扱えるようになりました。

3Dモデルのインポート

次はプレイヤーが操作するキャラクターの3Dモデルをインポートします。
今回は Ready Player Me というサービスを使用してアバターを作成しました。
ここで作成したアバターは GLB ファイルとしてダウンロードできます。

Ready Player Me

コンテンツドロワーで上部の「Import」を押すか、コンテンツドロワー内で右クリックして「Import to Current Folder」を選択します。

コンテンツドロワー上部の「Import」ボタン
コンテンツドロワー内で右クリックしたときのメニュー

ダイアログが開くのでダウンロードした3Dモデルを選択します。
インポート設定は特に変更せずに右下の「Import」を押します。

マテリアルやスケルタルメッシュ、スケルトン、テクスチャなどのアセットが作成されるので以下のように Materials、Meshes、Textures というフォルダに分けてファイル名も修正しました。

Materials フォルダ
Meshes フォルダ
Textures フォルダ

アニメーションのインポート

次はキャラクターの待機アニメーションと歩行アニメーション(前後左右)をインポートします。

今回はそれらのアニメーションを Mixamo から FBX ファイルとしてダウンロードしました。

Mixamo

再びコンテンツドロワーで右クリックして「Import to Current Folder」を選択し、その FBX ファイルをインポートします。

インポート時に表示されるウィンドウで「Import Animations」と「Import Only Animations」にチェックを付けます。
先ほどキャラクターの3Dモデルをインポートした時に生成されたスケルトンを「Skeleton」に設定します。
この状態で右下の「Import」を押してアニメーションをインポートします。

インポートした待機と歩行の Animation Sequence のファイル名は以下のようにしました。

アニメーションを UE にインポートする際、既存のスケルトンを設定するとエラーが発生して正しくインポートできないことがありましたが、自分の場合は以下の手順で解決しました。

  1. キャラクターの3Dモデルをインポートした時に生成されたスケルタルメッシュを FBX としてエクスポートする。
    (コンテンツドロワーでスケルタルメッシュを右クリックして「Asset Actions > Export…」を選択)
  2. エクスポートした FBX ファイルを Mixamo にアップロードする。
  3. メッシュが付属している状態(With Skin)でアニメーションをダウンロードする。
  4. ダウンロードした FBX ファイルを UE にインポートする。

Animation Blueprint の作成

次はアニメーションの制御に必要な Animation Blueprint を作成します。

コンテンツドロワーで右クリックして「Animation Blueprint」を選択します。

先ほどキャラクターの3Dモデルをインポートした時に生成されたスケルトンを選択して「Create」を押し、Animation Blueprint を作成します。


作成した Animation Blueprint の名前を「ABP_PlayerCharacter」などに変更します。

Idle ステートの作成

作成した Animation Blueprint をダブルクリックして開きます。
EventGraph を開いている場合は「My Blueprint > AnimGraph」をダブルクリックして AnimGraph に切り替えます。
(上部のタブから切り替えてもいいです)

AnimGraph 内で右クリックして「state machine」などと検索し、ステートマシンをグラフに追加します。
追加したステートマシンの名前を「MainStateMachine」などに変更します。

ステートマシンのピンと「Output Pose」のピンを接続します。

そのステートマシンをダブルクリックして中を開き、そこで右クリックして「Add State」を選択します。

追加したステートの名前を「Idle」などに変更して「Entry」と接続します。

追加したステートをダブルクリックして中を開き、そこで右クリックして「play {待機アニメーションの名前}」などと検索して待機用の Animation Sequence を再生するノードを追加します。

追加したノードのピンと「Output Animation Pose」のピンを接続します。

Walk ステートの作成

Idle ステートを作成した時と同じ手順で「Walk」という名前のステートを作成します。

この GIF のようにして Idle ステートと Walk ステートを双方向で接続します。

Walk ステートをダブルクリックして中を開き、そこで右クリックして「blend space」などと検索します。
上の方にある「Blend Space」を選択してグラフに追加し、名前を「WalkBlendSpace」などに変更します。

追加した Blend Space のピンと「Output Animation Pose」のピンを接続します。

Blend Space の編集

各軸で使用する変数を作成して設定する

「My Blueprint > VARIABLES」の右のプラスボタンを押して変数を追加します。

追加した変数の名前を「ForwardSpeed」などに変更し、以下のようにしてその変数の型を「Float」に変更します。

同様にして「RightwardSpeed」という名前の変数も追加します。

「AnimGraph > MainStateMachine > Walk」にある WalkBlendSpace を選択して Details から各軸の名前(Name)と最小値(Minimum Axis Value)、最大値(Maximum Axis Value)をそれぞれ以下のように変更します。

少し下にスクロールすると Coordinates というカテゴリがあり、そこに各軸の項目があります。
各軸の名前の右の「Pin」を押して先ほど作成した変数をそれぞれ選択します。

下の画像の状態になっていれば OK です。

これで RightwardSpeed の値がX軸の値として、ForwardSpeed の値がY軸の値として使用されるようになりました。

歩行用のアニメーションをグラフに配置する

Asset Browser というタブにある歩行用の Animation Sequence をグラフにドラッグ & ドロップして Blend Sample を4つ作成します。

Asset Browser というタブがない場合は「Window > Asset Browser」から追加できます。

Details で各 Blend Sample の値を以下のように変更します。

これで Blend Space の設定は完了です。

Idle ⇔ Walk 間の遷移条件の追加

今の状態では Idle ステートから Walk ステートに遷移してくれないので、Idle ⇔ Walk 間の遷移条件を追加します。

まずは My Blueprint で Boolean 型の「bIsWalking」という変数を追加します。

「AnimGraph > MainStateMachine」に戻って Idle から Walk に繋がっている矢印の上の白い円のようなマークをダブルクリックして中を開きます。

bIsWalking 変数をグラフにドラッグ & ドロップして Result に接続します。

Idle → Walk

これで bIsWalking が true のときに Idle から Walk に遷移するようになりました。

同様にして Walk から Idle に繋がっている矢印の中も以下のように変更します。
bIsWalking と Result の間には NOT Boolean を挟んで結果を逆転させます。

Walk → Idle

これで bIsWalking が false のときに Walk から Idle に遷移するようになりました。

各変数の更新処理の作成

次は ForwardSpeed と RightwardSpeed、bIsWalking の値を更新する処理を作成します。

「My Blueprint > EventGraph」をダブルクリックして EventGraph に切り替えます。
(上部のタブから切り替えてもいいです)

Unity 編のコードを真似して以下の処理を作ってみました。

ForwardSpeed と RightwardSpeed、bIsWalking 以外で追加した変数は以下の通りです。

それぞれの処理を拡大するとこんな感じです。
その処理に該当する Unity 編のコードがコメントノードに書かれてます。

これでキャラクターの移動速度に応じて適切なアニメーションが再生されるようになりました。

ベースとなるブループリントの作成

次はプレイヤーが操作するキャラクターのベースとなるブループリントを作成します。

コンテンツドロワーで右クリックして「Blueprint Class」を選択します。

親クラスを選択するウィンドウが開くので「Character」を選択します。

作成したブループリントの名前を「BP_PlayerCharacterBase」などに変更して、そのブループリントをダブルクリックして開きます。

Components というタブに「Mesh (CharacterMesh0)」という名前のコンポーネントがあるので、それを選択して Details の「Mesh > Skeletal Mesh Asset」にキャラクターのスケルタルメッシュをアサインします。

このままではまだアニメーションが再生されないので「Animation > Anim Class」に ABP_PlayerCharacter をアサインします。

これでベースとなるブループリントを作成できました。

この状態で BP_PlayerCharacterBase をレベルにドラッグ & ドロップして追加します。
ゲームを開始して F8 キーでイジェクトし、BP_PlayerCharacterBase を掴んで移動させると、しっかりアニメーションしてくれているのが確認できるかと思います。

Input Action と Input Mapping Context の作成

次はプレイヤーからの入力を受け取るのに必要な Input Action と Input Mapping Context を作成します。

コンテンツドロワーで右クリックして「Input > Input Action」を選択します。

Input Action が作成されるので、それの名前を「IA_Move」などに変更します。

作成した移動用の Input Action をダブルクリックして開き、Value Type を「Axis2D (Vector2D)」に変更します。
他の項目は初期値のままで大丈夫です。

同様にして「IA_Look」という視点操作用の Input Action を作成します。
Value Type は IA_Move と同じく「Axis2D (Vector2D)」に変更します。
他の項目は初期値のままで大丈夫です。

コンテンツドロワーで右クリックして「Input > Input Mapping Context」を選択します。

Input Mapping Context が作成されるので、それの名前を「IMC_Main」などに変更します。

作成した Input Mapping Context をダブルクリックして開き、以下のように変更します。
今回は WASD で移動し、マウスで視点操作できるようにします。

Tips ですが、キーを設定するときはキーボードのアイコンのボタンを押した状態で、設定したいキーを押すとそのキーが設定されるので便利です。

これで移動と視点操作の入力を受け取れるようになりました。

ローカルプレイヤー用のブループリントとその移動処理の作成

これでプレイヤーからの入力を受け取れるようになったので、次はローカルプレイヤー用のブループリントを作成します。

コンテンツドロワーにある BP_PlayerCharacterBase を右クリックして「Create Child Blueprint Class」を選択します。

BP_PlayerCharacterBase をもとにしたブループリント(BP_PlayerCharacterBase の子クラス)が作成されるので、それの名前を「BP_LocalPlayerCharacter」などに変更し、それをダブルクリックして開きます。

子ブループリント(AActor 派生のブループリント)ではもととなったブループリントの構造を受け継いでいるので、Components ではキャラクターの3Dモデルがアサインされた Skeletal Mesh Component などのコンポーネントが確認できるかと思います。

Viewport から EventGraph に切り替えて以下のような処理を追加します。
この移動処理は Third Person Template のものとほぼ同じです。

「EnhancedInputAction IA_Move」の部分の拡大画像↓

これで WASD でキャラクターを移動させられるようになりました。

三人称視点カメラの作成

次は三人称視点カメラを作成します。

EventGraph からViewport に戻って Spring Arm Component と Camera Component を Components に追加します。
Camera Component は Spring Arm Component の子供にします。

Spring Arm Component を選択している状態で Details で Target Arm Length を 400cm くらいに変更して Use Pawn Control Rotation にチェックを付けます。

Unity では距離の単位は「m」ですが、Unreal Engine では「cm」です。

再び Viewport から EventGraph に切り替えて以下のような処理を追加します。
この視点操作の処理も Third Person Template のものとほぼ同じです。

これで三人称視点でカメラを操作できるようになりました。

ついでにカメラ操作中もマウスカーソルを表示させたいので BP_LocalPlayerCharacter.BeginPlay() に以下の処理を追加しました。

ローカルプレイヤーの座標と角度を送信する

ここからはローカルプレイヤーの座標と角度を一定時間ごとに TCP で送信する処理を作成します。

構造体の作成

プレイヤーの座標と角度は JSON 形式の文字列として送受信したいので、まずは F_PlayerTransform という構造体を作成します。

コンテンツドロワーで右クリックして「Blueprint > Structure」を選択します。

作成されたアセットの名前を「F_PlayerTransform」などに変更し、ダブルクリックして開きます。

「+ Add Variable」を押して「worldPosition」と「worldEulerAngles」という Vector 型の変数を追加します。

Details で編集可能な private 変数の追加

ゲーム開始とともにメッセージの送信を開始するのではなく、エディタでのプレイ中に Details で値を変更するとメッセージの送信が開始されるようにしたいので、Boolean 型の bSendPlayerTransform というメンバ変数を BP_LocalPlayerCharacter に追加します。

そのメンバ変数を選択している状態で Details の Instance Editable と Private にチェックを付けます。
これでこのメンバ変数はレベルエディタの Details に公開され、他のクラスから参照されないようにもなりました。

レベルエディタの Details に公開された bSendPlayerTransform 変数

TCP でメッセージを送信する非同期ノード(Latent ノード)の作成

次は指定のIPアドレスとポートにメッセージを送信する非同期ノード(以下「Latent ノード」)を作成します。

Latent ノードとは Delay や Async Load Asset のように右上に時計マークが付いている、非同期で実行されるノードのことです。

Latent ノードの基本的な作り方に関してはこちらの記事が参考になるかと思います。

まずは「Tools > New C++ Class…」を選択します。

ブループリントクラスを作成するときのように親クラスを選択するウィンドウが開くので「All Classes」に切り替えて「blueprint async」などと検索し、「BlueprintAsyncActionBase」を親クラスに選択します。
親クラスを選択したら「Next>」を押します。

クラスの種類(「Public」or「Private」)や名前、ファイルのパスなどを設定して「Create Class」を押します。
今回は「AsyncActionSendMessage」というクラス名にしました。
(C++ では「UAsyncActionSendMessage」)

Visual Studio や Rider などの IDE でヘッダーファイルとソースファイルを開いて以下のように変更します。
{プロジェクト名}.Build.cs には “Sockets” と “Networking” を追加しました。

AsyncActionSendMessage.h
#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "AsyncActionSendMessage.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCompleted, bool, bSucceeded);

UCLASS()
class LOCALCONNECTSAMPLE_API UAsyncActionSendMessage : public UBlueprintAsyncActionBase
{
    GENERATED_BODY()
	
public:
    UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", WorldContext = "WorldContextObject"))
    static UAsyncActionSendMessage* AsyncSendMessage(UObject* WorldContextObject, const FString& InMessage, const FString& InAddress = TEXT("127.0.0.1"),  const int32 InPort = 8000);

    //~ Begin UBlueprintAsyncActionBase Interface
    virtual void Activate() override;
    //~ End UBlueprintAsyncActionBase Interface

private:
    UPROPERTY(BlueprintAssignable)
    FOnCompleted OnCompleted;

    FString Message;
    FString Address;
    int32 Port;

    void CallOnCompleted(const bool bSucceeded);
};
AsyncActionSendMessage.cpp
#include "AsyncActionSendMessage.h"
#include "Sockets.h"
#include "SocketSubsystem.h"
#include "Interfaces/IPv4/IPv4Address.h"
#include "Interfaces/IPv4/IPv4Endpoint.h"

UAsyncActionSendMessage* UAsyncActionSendMessage::AsyncSendMessage(UObject* WorldContextObject, const FString& InMessage, const FString& InAddress, const int32 InPort)
{
    check(WorldContextObject);
    UAsyncActionSendMessage* Action = NewObject<UAsyncActionSendMessage>();

    Action->Message = InMessage;
    Action->Address = InAddress;
    Action->Port = InPort;

    Action->RegisterWithGameInstance(WorldContextObject);

    return Action;
}

void UAsyncActionSendMessage::Activate()
{
    TWeakObjectPtr<UAsyncActionSendMessage> WeakThis = this;
    AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [WeakThis]()
    {
        if (!WeakThis.IsValid())
        {
            return;
        }
		
        bool bSucceeded = true;
		
        if (ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM))
        {
            const FString Message = WeakThis->Message;
            const FString Address = WeakThis->Address;
            const int32 Port = WeakThis->Port;
		
            FIPv4Address ParsedAddress;
            if (FIPv4Address::Parse(Address, ParsedAddress))
            {
                if (FSocket* ClientSocket = SocketSubsystem->CreateSocket(NAME_Stream, TEXT("ClientSocket")))
                {
                    ClientSocket->SetNonBlocking(false);
                    const FIPv4Endpoint Endpoint(ParsedAddress, Port);

                    if (ClientSocket->Connect(*Endpoint.ToInternetAddr()))
                    {
                        const FTCHARToUTF8 Utf8Message(*Message);
                        int32 BytesSent = 0;

                        if (!ClientSocket->Send(reinterpret_cast<const uint8*>(Utf8Message.Get()), Utf8Message.Length(), BytesSent) || BytesSent != Utf8Message.Length())
                        {
                            UE_LOG(LogTemp, Error, TEXT("Failed to send message. Error: %s"), SocketSubsystem->GetSocketError(SocketSubsystem->GetLastErrorCode()));
                            bSucceeded = false;
                        }
                    }
                    else
                    {
                        UE_LOG(LogTemp, Error, TEXT("Failed to connect to %s:%d. Error: %s"), *Address, Port, SocketSubsystem->GetSocketError(SocketSubsystem->GetLastErrorCode()));
                        bSucceeded = false;
                    }
			
                    ClientSocket->Close();
                }
                else
                {
                    UE_LOG(LogTemp, Error, TEXT("Failed to create client socket."));
                    bSucceeded = false;
                }
            }
            else
            {
                UE_LOG(LogTemp, Error, TEXT("Failed to parse %s."), *Address);
                bSucceeded = false;
            }
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to get SocketSubsystem."));
            bSucceeded = false;
        }

        AsyncTask(ENamedThreads::GameThread, [WeakThis, bSucceeded]()
        {
            if (WeakThis.IsValid())
            {
                WeakThis->CallOnCompleted(bSucceeded);
            }
        });
    });
}

void UAsyncActionSendMessage::CallOnCompleted(const bool bSucceeded)
{
    OnCompleted.Broadcast(bSucceeded);
    SetReadyToDestroy();
}

コードを書き終えたら Unreal エディタを閉じて IDE でビルドし、再び Unreal エディタを開きます。

これでこの画像のノードが使えるようになりました。

一定時間ごとにメッセージを送信する処理の作成

次は一定時間ごとにプレイヤーの座標と角度を JSON 形式の文字列に変換して送信する処理を BP_LocalPlayerController.BeginPlay() に追加します。

追加した処理の全体像はこんな感じです。

まず、BP_LocalPlayerController.BeginPlay() で Set Timer by Event を使用して SendPlayerTransform() というカスタムイベントを0.1秒ごとに呼び出すようにしています。

Unity 編では Observable.Interval() でこのような挙動を実現していますが、UE の Set Timer by Event に最も近い Unity C# のメソッドは InvokeRepeating() かと思います。

SendPlayerTransform() の冒頭で bSendPlayerTransform が true のときだけ次の処理に進むようにしています。

bSendPlayerTransform を Branch に繋げて条件分岐させても良いですが、Boolean 型の Get ノードには「Convert to Branch」というオプションがあるので今回はそれを使用しています。

次はプレイヤーの座標と角度が変化していなかったら次に進まないという処理です。
Unity 編では DistinctUntilChanged() を挟むことでこの挙動を実現していますが、今回はプレイヤーの座標と角度をメンバ変数に記録してそれを現在の値と比較して条件分岐しています。

Unity 編では SubscribeAwait() の第二引数に AwaitOperation.Drop を渡すことで、メッセージ送信中に再び別のメッセージを送信しようとするといったことを防いでいますが、このブループリントでは Do Once でそれを実現しています。
Reset ピンには先ほど定義した AsyncSendMessage の OnCompleted ピンを接続しています。

Do Once は名前の通り、続きの処理を一度しか実行しない(2回目以降の実行をブロックする)のですが、Reset を実行すると続きの処理を再び実行させることが可能になります。

次は自身の座標と角度を取得してそれを JSON 形式の文字列に変換しています。
Unity と UE ではYとZが逆だったり、Xの正負が逆だったり、距離の単位が m と cm で違ったりするのでその辺りの修正も行っています。

最後は先ほど定義した AsyncSendMessage を呼び出しています。
Unity 側のポートは 8000 番にしています。

リモートプレイヤー用のブループリントとその移動処理の作成

次は非ローカルプレイヤー(この記事では「リモートプレイヤー」とよびます)用のブループリントを作成します。

まずはローカルプレイヤー用のブループリントを作成したときと同様にコンテンツドロワーの BP_PlayerCharacterBase で右クリックして「Create Child Blueprint Class」を選択し、「BP_RemotePlayerCharacter」という名前のブループリントを作成します。

次は TCP でメッセージの受信を待機する Latent ノードを作成します。
このノードの作成にあたっては Model Context Protocol for Unreal Engine を参考にさせていただきました。

UAsyncActionSendMessage を作成したときと同様にして UAsyncActionStartListening というクラスを作成します。
そして FRunnable を継承した FListenerRunnable というクラスも作成します。
(以下のコードはなんとか頑張って作りましたが、PIE 終了時にときどきクラッシュするので、もしかしたらどこかがよろしくないことになってしまっているのかもしれません…)

AsyncActionStartListening.h
#pragma once

#include "CoreMinimal.h"
#include "Interfaces/IPv4/IPv4Address.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "AsyncActionStartListening.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnReceivedMessage, const FString&, ReceivedMessage);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnFailed);

UCLASS()
class LOCALCONNECTSAMPLE_API UAsyncActionStartListening : public UBlueprintAsyncActionBase, public FTickableGameObject
{
    GENERATED_BODY()
	
public:
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", WorldContext = "WorldContextObject"))
    static UAsyncActionStartListening* AsyncStartListening(UObject* WorldContextObject, const FString& ListenerAddress = TEXT("127.0.0.1"),  const int32 ListenerPort = 8000);

    //~ Begin UBlueprintAsyncActionBase Interface
    virtual void Activate() override;
    //~ End UBlueprintAsyncActionBase Interface
	
    //~ Begin FTickableObjectBase Interface
    virtual void Tick(float DeltaTime) override;
    virtual TStatId GetStatId() const override;
    virtual ETickableTickType GetTickableTickType() const override;
    //~ End FTickableObjectBase Interface

    //~ Begin UObject Interface
    virtual void BeginDestroy() override;
    //~ End UObject Interface
	
    void CallOnReceivedMessage(const FString& ReceivedMessage) const;
    void CallOnFailed();
	
private:
    UPROPERTY(BlueprintAssignable)
    FOnReceivedMessage OnReceivedMessage;
	
    UPROPERTY(BlueprintAssignable)
    FOnFailed OnFailed;
	
    bool bIsListening;
	
    FIPv4Address ListenerIPv4Address;
    FString ListenerAddress;
    int32 ListenerPort;
	
    TSharedPtr<FSocket> ListenerSocket;
    FRunnableThread* ListenerThread;
    TWeakObjectPtr<UObject> WorldContextObject;

    void StopListening();
};
AsyncActionStartListening.cpp
#include "AsyncActionStartListening.h"
#include "ListenerRunnable.h"
#include "Sockets.h"
#include "SocketSubsystem.h"
#include "Interfaces/IPv4/IPv4Endpoint.h"

UAsyncActionStartListening* UAsyncActionStartListening::AsyncStartListening(UObject* WorldContextObject, const FString& ListenerAddress, const int32 ListenerPort)
{
    check(WorldContextObject);
    UAsyncActionStartListening* Action = NewObject<UAsyncActionStartListening>();

    Action->ListenerAddress = ListenerAddress;
    Action->ListenerPort = ListenerPort;
    Action->WorldContextObject = WorldContextObject;

    Action->RegisterWithGameInstance(WorldContextObject);

    return Action;
}

void UAsyncActionStartListening::Activate()
{
    if (ListenerPort < 0 || 65535 < ListenerPort)
    {
        UE_LOG(LogTemp, Error, TEXT("Listener port %d is incorrect value."), ListenerPort);
        CallOnFailed();
		
        return;
    }

    ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);

    if (!SocketSubsystem)
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to get SocketSubsystem."));
        CallOnFailed();
		
        return;
    }
	
    const TSharedPtr<FSocket> CreatedListenerSocket = MakeShareable(SocketSubsystem->CreateSocket(NAME_Stream, TEXT("ListenerSocket")));

    if (!CreatedListenerSocket.IsValid())
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to create listener socket."));
        CallOnFailed();
		
        return;
    }
	
    CreatedListenerSocket->SetReuseAddr(true);
    CreatedListenerSocket->SetNonBlocking(true);
	
    if (!FIPv4Address::Parse(ListenerAddress, ListenerIPv4Address))
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to parse %s."), *ListenerAddress);
        CallOnFailed();
		
        return;
    }
	
    const FIPv4Endpoint Endpoint(ListenerIPv4Address, ListenerPort);
    const TSharedRef<FInternetAddr> InternetAddress = Endpoint.ToInternetAddr();
    
    if (!CreatedListenerSocket->Bind(*InternetAddress))
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to bind listener socket to %s:%d."), *ListenerIPv4Address.ToString(), ListenerPort);
        CallOnFailed();
		
        return;
    }
	
    if (!CreatedListenerSocket->Listen(1))
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to start listening."));
        CallOnFailed();
		
        return;
    }
	
    ListenerSocket = CreatedListenerSocket;
    bIsListening = true;

    FListenerRunnable* ListenerRunnable = new FListenerRunnable(this, ListenerSocket);
    ListenerThread = FRunnableThread::Create(ListenerRunnable, TEXT("ListenerThread"));
	
    if (!ListenerThread)
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to create listener thread."));
        CallOnFailed();
    }
}

void UAsyncActionStartListening::CallOnReceivedMessage(const FString& ReceivedMessage) const
{
    OnReceivedMessage.Broadcast(ReceivedMessage);
}

void UAsyncActionStartListening::CallOnFailed()
{
    StopListening();
    OnFailed.Broadcast();
    SetReadyToDestroy();
}

void UAsyncActionStartListening::Tick(float DeltaTime)
{
    if (!WorldContextObject.IsValid() && bIsListening)
    {
        StopListening();
        SetReadyToDestroy();
    }
}

TStatId UAsyncActionStartListening::GetStatId() const
{
    RETURN_QUICK_DECLARE_CYCLE_STAT(UAsyncActionStartListening, STATGROUP_Tickables);
}

ETickableTickType UAsyncActionStartListening::GetTickableTickType() const
{
    return IsTemplate() ? ETickableTickType::Never : ETickableTickType::Always;
}

void UAsyncActionStartListening::BeginDestroy()
{
    StopListening();
    Super::BeginDestroy();
}

void UAsyncActionStartListening::StopListening()
{
    if (!bIsListening)
    {
        return;
    }

    if (ListenerThread)
    {
        ListenerThread->Kill(false);
        delete ListenerThread;
        ListenerThread = nullptr;
    }
	
    if (ListenerSocket.IsValid())
    {
        if (ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM))
        {
            ListenerSocket->Close();
	    SocketSubsystem->DestroySocket(ListenerSocket.Get());
            ListenerSocket.Reset();
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to get SocketSubsystem."));
        }
    }
	
    bIsListening = false;
}
ListenerRunnable.h
#pragma once

#include "CoreMinimal.h"
#include "HAL/Runnable.h"

class UAsyncActionStartListening;

class FListenerRunnable : public FRunnable
{
public:
    explicit FListenerRunnable(const TWeakObjectPtr<UAsyncActionStartListening> InAsyncActionStartListening, const TSharedPtr<FSocket>& InListenerSocket);

    //~ Begin FRunnable Interface
    virtual bool Init() override;
    virtual uint32 Run() override;
    virtual void Stop() override;
    virtual void Exit() override;
    //~ End FRunnable Interface

private:
    bool bListenerThreadIsRunning;
    TWeakObjectPtr<UAsyncActionStartListening> AsyncActionStartListening;
	
    TSharedPtr<FSocket> ListenerSocket;
    TSharedPtr<FSocket> ClientSocket;
};
ListenerRunnable.cpp
#include "ListenerRunnable.h"
#include "AsyncActionStartListening.h"
#include "Sockets.h"
#include "SocketSubsystem.h"

FListenerRunnable::FListenerRunnable(const TWeakObjectPtr<UAsyncActionStartListening> InAsyncActionStartListening, const TSharedPtr<FSocket>& InListenerSocket)
    : bListenerThreadIsRunning(true)
    , AsyncActionStartListening(InAsyncActionStartListening)
    , ListenerSocket(InListenerSocket)
{

}

bool FListenerRunnable::Init()
{
    return true;
}

uint32 FListenerRunnable::Run()
{
    while (bListenerThreadIsRunning && AsyncActionStartListening.IsValid())
    {
        bool bHasPendingConnection = false;
        if (!ListenerSocket->HasPendingConnection(bHasPendingConnection) || !bHasPendingConnection)
        {
            FPlatformProcess::Sleep(0.1f);
            continue;
        }
        
        ClientSocket = MakeShareable(ListenerSocket->Accept(TEXT("ClientSocket")));
        
        if (!ClientSocket.IsValid())
        {
            FPlatformProcess::Sleep(0.1f);
            continue;
        }
        
        int32 SocketBufferSize = 65536;
        ClientSocket->SetNoDelay(true);
        ClientSocket->SetSendBufferSize(SocketBufferSize, SocketBufferSize);
        ClientSocket->SetReceiveBufferSize(SocketBufferSize, SocketBufferSize);
        
        uint8 Buffer[8192];
        
        while (bListenerThreadIsRunning && AsyncActionStartListening.IsValid())
        {
            int32 BytesRead = 0;
            if (ClientSocket->Recv(Buffer, sizeof(Buffer), BytesRead))
            {
                if (BytesRead == 0 || !AsyncActionStartListening.IsValid())
                {
                    break;
                }

                Buffer[BytesRead] = '\0';
                const FString ReceivedMessage = UTF8_TO_TCHAR(Buffer);
                
                AsyncActionStartListening->CallOnReceivedMessage(ReceivedMessage);
            }

            ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
            const ESocketErrors LastErrorCode = SocketSubsystem->GetLastErrorCode();
            
            bool bShouldBreak = true;
            
            if (LastErrorCode == SE_EWOULDBLOCK) 
            {
                bShouldBreak = false;
                FPlatformProcess::Sleep(0.01f);
            }
            else if (LastErrorCode == SE_EINTR)
            {
                bShouldBreak = false;
            }
            
            if (bShouldBreak)
            {
                break;
            }
        }

        if (ClientSocket.IsValid())
        {
            ClientSocket->Close();
        }
    }
     
    if (AsyncActionStartListening.IsValid() && bListenerThreadIsRunning)
    {
        AsyncActionStartListening->CallOnFailed();
    }

    return 0;
}

void FListenerRunnable::Stop()
{
    bListenerThreadIsRunning = false;
}

void FListenerRunnable::Exit()
{

}

そして、Unity エディタからプレイヤーの座標と角度を受信したら、0.1秒かけて等速で移動 & 回転する処理を BP_RemotePlayerCharacter.BeginPlay() に追加します。
ここで追加した処理の全体像はこんな感じになりました。

追加した変数は以下の通りです。

まず最初に先ほど作成した AsyncStartListening を呼び出して、Unity エディタからメッセージを受信したら bApplyReceivedPlayerTransform をチェックしています。
bApplyReceivedPlayerTransform が true なら次に進みます。

移動 & 回転する前に自身の Transform を記録しておきます。
そして Unity 編では受け取った文字列を JsonUtility.FromJson() で自作の構造体に変換していましたが、ここではその文字列から JsonObject というものをロードしています。

その後はロードした JsonObject から GetField で worldPosition と worldEulerAngles を取得しています。
Unity と UE での軸や距離の単位の違いなどはここで修正しています。

上の画像のブループリントで取得した座標と角度をもとに、移動 & 回転後の Transform を作成しています。
その後はタイムラインで自身の Transform を0.1秒間、毎フレーム更新しています。

Unity 編では DOMove() や DORotate() に対して Ease.Linear を SetEase() することによって線形補間していましたが、このブループリントではタイムラインを使用してそれを実現しています。
タイムラインの中では0秒の時点に0の値のキーを、0.1秒の時点に1の値のキーを設定しています。

Ease.Linear をわかりやすく可視化すると下の画像のグラフのようになりますが、今回の実装ではタイムラインの中身がこのグラフのようになっています。

引用元:DOTweenのイージング一覧を世界一詳しく&分かりやすく説明する

また、Unity 編では SubscribeAwait() の第二引数に AwaitOperation.Switch を渡すことで、移動 & 回転中に新たな座標と角度を受け取った際、その新たに受け取った方を優先する(非同期処理を途中で止めて新しい値で非同期処理を初めから開始する)といった処理を実現していますが、ここではタイムラインの Play from Start ピンに接続することでそれを実現しています。

これで TCP で受信したメッセージをもとに BP_RemotePlayerCharacter が移動 & 回転できるようになりました。

Unity エディタ操作中も滑らかに動作させる

これで Unity エディタとローカルマルチプレイできる状態になりましたが、このままでは Unity エディタを操作中に Unreal エディタの動きがカクカクしてしまうのでエディタ設定を少し変更します。

「Edit > Editor Preferences… > Performance > Editor Performance > Use Less CPU when in Background」のチェックを外します。

これで Unity エディタの操作中でも Unreal エディタが滑らかに動作するようになりました。

Unity エディタとローカルマルチプレイ

ここからは実際に Unity エディタとローカルマルチプレイしてみます。

まずはレベルに設定する GameMode を作成します。
コンテンツドロワーで右クリックして「Blueprint Class」を選択します。
親クラスを選択するウィンドウが開くので「Game Mode Base」を選択します。

名前を「BP_MainGameMode」などに変更し、ダブルクリックして開きます。
「Classes > Default Pawn Class」を BP_LocalPlayerCharacter に変更します。

レベルエディタに戻って「World Settings > GameMode Override」を今作成した BP_MainGameMode に変更します。

World Settings というタブがない場合は「Window > World Settings」から追加できます。

レベルに BP_RemotePlayerCharacter や PlayerStart、床の Static Mesh Actor などを配置してゲームを実行します。
(BP_LocalPlayerCharacter はゲーム開始時に PlayerStart の位置に自動的にスポーンするので、ここでは BP_LocalPlayerCharacter は配置しなくても大丈夫です。)

Details で BP_LocalPlayerCharacter.bSendPlayerTransform と BP_RemotePlayerCharacter.bApplyReceivedPlayerTransform にチェックを付けます。
Unity エディタでもプレイを開始し、 LocalPlayerController.sendPlayerTransform と RemotePlayerController.applyReceivedPlayerTransform にチェックを付けるとこの GIF のようにローカルマルチプレイできるはずです!

左が UE で右が Unity

最後に

参考記事

お問い合わせ

    タイトルとURLをコピーしました