- はじめに
- 手順
- 最後に
はじめに
Unity Advent Calendar 2025 の4日目の記事です!
この記事では誰もが一度は夢見た Unreal エディタとのローカルマルチプレイを実現する方法を解説します。
今年の4月頃に Unity エディタと Unreal エディタとのローカルマルチプレイについてツイートポストしたのですが、この時は各エディタがプレイヤーの座標と角度をローカルにある JSON ファイルに書き込んでそれを互いに読み取るという、このポストに書かれている通りの「ゴリ押し実装」をしていました。
この時の実装のイメージはこんな感じです。

この記事では TCP 通信を使用してもう少しゴリ押し度を下げた実装を施します。
この記事での環境は以下の通りです。
- Windows 11
- Unity 6000.2.6f2
- DOTween (HOTween v2) 1.2.765
- ObservableCollections.R3 3.3.4
- R3 1.3.0
- UniTask 2.5.10
そして、この記事の UE 版の記事もあるのでそれと読み比べてみるとかなり面白いかと思います!
手順
ここからは実際の手順を解説します。
(ここから先は UE 版と比較しやすいように作業の内容や流れなどをその記事とある程度揃えてあります!)
プロジェクトの作成
まずはプロジェクトを作成します。
Unity Hub を開いて右上の「+ New project」を押します。

エディタのバージョンやテンプレート、プロジェクト名などを設定して右下の「+ Create project」を押します。
今回は「Universal 3D」というテンプレートを選択しました。

パッケージのインポート
新規プロジェクトが開いたら「Window > Package Management > Package Manager」を選択して必要なパッケージをインポートします。

今回は以下のパッケージをインポートしました。
- DOTween (HOTween v2)
- ObservableCollections.R3
- R3
- UniTask
また、R3 のインポートに関しては以下の記事を参考にさせていただきました。
3Dモデルのインポート
次はプレイヤーが操作するキャラクターの3Dモデルをインポートします。
今回は Ready Player Me というサービスを使用してアバターを作成しました。
ここで作成したアバターは GLB ファイルとしてダウンロードできます。

プロジェクトウィンドウで右クリックして「Import New Asset…」を選択します。

ダイアログが開くのでダウンロードした3Dモデルを選択して Unity にインポートします。
マテリアルやメッシュ、テクスチャなどのアセットが作成されるので以下のように Materials、Meshes、Textures というフォルダに分けてファイル名も修正しました。



アニメーションのインポート
次はキャラクターの待機アニメーションと歩行アニメーション(前後左右)をインポートします。
今回はそれらのアニメーションを Mixamo から FBX ファイルとしてダウンロードしました。

再びプロジェクトウィンドウで右クリックして「Import New Asset…」を選択し、その FBX ファイルをインポートします。
インポート後の FBX ファイルはこの画像のように、その中に Animation Clip が含まれているのでそれを取り出します。

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

Animator Controller の作成
次はアニメーションの制御に必要な Animator Controller を作成します。
プロジェクトウィンドウで右クリックして「Create > Animation > Animator Controller」を選択して Animator Controller を作成します。

作成した Animator Controller の名前を「PlayerCharacterAnimatorController」などに変更します。
Idle ステートの作成
作成した Animator Controller をダブルクリックして開き、グラフ内で右クリックして「Create State > Empty」を選択します。

作成したステートを選択している状態でインスペクターからそのステートの名前を「Idle」などに変更し、待機用の Animation Clip を Motion にアサインします。

Walk ステートの作成
グラフ内で右クリックして「Create State > From New Blend Tree」を選択します。
作成したステートの名前を「Walk」などに変更します。

Idle ステートで右クリックして「Make Transition」を選択し、矢印を Walk ステートに接続します。

同様にして Walk ステートから Idle ステートにも矢印を接続します。
最終的にこの画像のようになっていれば OK です。

Blend Tree の編集
各軸で使用するパラメータを作成して設定する
Walk ステートをダブルクリックして中を開き、Blend Tree を選択します。
インスペクターでその Blend Tree の名前を「WalkBlendTree」などに変更して Blend Type を「2D Simple Direction」に変更します。

「Parameters > +▼」を押して「Float」を選択します。

追加した Float 型のパラメータの名前を「ForwardSpeed」などに変更します。
同様にして「RightwardSpeed」という名前のパラメータも追加します。
WalkBlendTree を選択している状態でインスペクターの Parameters を以下のように変更します。

これで RightwardSpeed の値がX軸の値として、ForwardSpeed の値がY軸の値として使用されるようになりました。
歩行用のアニメーションをグラフに配置する
インスペクターに Motion という項目があるので「+ > Add Motion Field」を4回押して要素を4つ追加します。

各要素に前後左右の歩行用の Animation Clip をアサインして Pos X と Pos Y の値は以下のようにします。

これらの設定を終えるとグラフ上での WalkBlendTree の見た目は下の画像のようになっているはずです。

これで Blend Tree の設定は完了です。
Idle ⇔ Walk 間の遷移条件の追加
今の状態では Idle ステートから Walk ステートに遷移してくれないので、Idle ⇔ Walk 間の遷移条件を追加します。
まずは Parameters で Bool 型の「IsWalking」というパラメータを追加します。

Base Layer に戻って Idle から Walk に繋がっている矢印を選択します。
インスペクターで「Has Exit Time」のチェックを外します。
これでアニメーションの再生途中でもステートを切り替えられるようになりました。
(参考「【Unity】Animatorのモーション切り替えが即座に行われないときの対処」)
Conditions に要素を追加して「IsWalking」と「true」を選択します。

これで IsWalking が true のときに Idle から Walk に遷移するようになりました。
同様にして Walk から Idle に繋がっている矢印の設定も以下のように変更します。

これで IsWalking が false のときに Walk から Idle に遷移するようになりました。
各パラメータの更新処理の作成
次は ForwardSpeed と RightwardSpeed、IsWalking の値を更新する処理を作成します。
プロジェクトウィンドウで右クリックして「Create > MonoBehaviour Script」を選択し、PlayerAnimationController というスクリプトを作成します。
作成したスクリプトをダブルクリックして開き、以下のようなコードを追加します。
using UnityEngine;
[RequireComponent(typeof(Animator))]
public class PlayerAnimationController : MonoBehaviour
{
[SerializeField]
private float maxWalkSpeed = 6f; // m/s
[SerializeField]
private float walkSpeedThreshold = 0.01f; // m/s
private Vector3 _previousWorldPosition = Vector3.zero;
private Animator _animator;
private void Start()
{
_animator = GetComponent<Animator>();
}
private void Update()
{
if (_previousWorldPosition != Vector3.zero)
{
//速度を取得する
var worldVelocity = (transform.position - _previousWorldPosition) / Time.deltaTime;
var localVelocity = transform.InverseTransformDirection(worldVelocity);
// Animator Controller で設定した各パラメータの値を更新する
_animator.SetBool("IsWalking", localVelocity.magnitude > walkSpeedThreshold);
_animator.SetFloat("RightwardSpeed", Mathf.Clamp((localVelocity / maxWalkSpeed).x, -1f, 1f));
_animator.SetFloat("ForwardSpeed", Mathf.Clamp((localVelocity / maxWalkSpeed).z, -1f, 1f));
}
_previousWorldPosition = transform.position;
}
}
これでキャラクターの移動速度に応じて適切なアニメーションが再生されるようになりました。
ベースとなるプレハブの作成
次はプレイヤーが操作するキャラクターのベースとなるプレハブを作成します。
ヒエラルキーで右クリックして「Create Empty」を選択します。

空の GameObject が作成されるので、それの名前を「PlayerCharacterBase」などに変更します。
キャラクターの3Dモデルをプロジェクトウィンドウからヒエラルキーにドラッグ & ドロップしてその GameObject に親子付けします。
(UE 編では Skeletal Mesh Component の名前が「Mesh」になっているので、ここで親子付けした3Dモデルの GameObject の名前も「Mesh」に変更しました。)

このままではまだアニメーションが再生されないので「Animator > Controller」に PlayerCharacterAnimatorController をアサインします。
(3Dモデルの GameObject に Animator がアタッチされていない場合はアタッチします。)

先ほど作成したアニメーション制御用のクラス(PlayerAnimationController)をその GameObject にアタッチします。

ヒエラルキーの PlayerCharacterBase をプロジェクトウィンドウにドラッグ & ドロップしてプレハブ化します。
これでベースとなるプレハブを作成できました。

この状態でゲームを実行し、シーンビューで PlayerCharacterBase を掴んで移動させると、しっかりアニメーションしてくれているのが確認できるかと思います。

Input Actions の作成
次はプレイヤーからの入力を受け取るのに必要な Input Actions を作成します。
プロジェクトウィンドウで右クリックして「Create > Input Actions」を選択します。

Input Actions が作成されるので、それの名前を「MainInputActions」などに変更します。
「Action Maps」の右のプラスボタンを押して Action Map を追加し、名前を「MainActionMap」などに変更します。
同様に「Actions」の右のプラスボタンを押して Action を追加し、名前を「MoveAction」などに変更します。
その移動用の Action の Action Type を「Value」に、Control Type を「Vector 2」に変更します。

MoveAction の右のプラスボタンを押して「Add Up\Down\Left\Right Composite」を選択します。

Up、Down、Left、Right の Path をそれぞれ設定します。
今回は WASD で移動できるようにします。

Tips ですが、Path を設定するときは「Listen」というボタンを押した状態で、設定したいキーを押すとそのキーがヒットするので便利です。

変更を保存してインスペクターで「Generate C# Class」にチェックを付けます。
生成する C# ファイルのパスや名前、名前空間などを設定して右下の「Apply」を押します。

これで移動の入力を受け取れるようになりました。
ローカルプレイヤー用のプレハブとその移動処理の作成
これでプレイヤーからの入力を受け取れるようになったので、次はローカルプレイヤー用のプレハブを作成します。
プロジェクトウィンドウにある PlayerCharacterBase を右クリックして「Create > Prefab Variant」を選択します。

PlayerCharacterBase をもとにした Prefab Variant が作成されるので、それの名前を「LocalPlayerCharacter」などに変更し、それをダブルクリックして開きます。
Prefab Variant ではもととなったプレハブの構造を受け継いでいるので、ヒエラルキーではキャラクターの3Dモデルの GameObject が確認できるかと思います。

プロジェクトウィンドウで右クリックして「Create > MonoBehaviour Script」を選択し、LocalPlayerController というスクリプトを作成し、以下のようなコードを追加します。
using UnityEngine;
[RequireComponent(typeof(CharacterController))]
public class LocalPlayerController : MonoBehaviour
{
[SerializeField]
private float maxWalkSpeed = 6f; // m/s
private MainInputActions _mainInputActions;
private CharacterController _characterController;
private void Awake()
{
// MainInputActions をインスタンス化して有効化する
_mainInputActions = new MainInputActions();
_mainInputActions.Enable();
}
private void Start()
{
_characterController = GetComponent<CharacterController>();
}
private void Update()
{
//プレイヤーからの入力を受け付けて移動する
var movementInputValue = _mainInputActions.MainActionMap.MoveAction.ReadValue<Vector2>();
var moveDirection = (transform.forward * movementInputValue.y + transform.right * movementInputValue.x).normalized;
_characterController.Move(moveDirection * (maxWalkSpeed * Time.deltaTime));
}
private void OnDestroy()
{
// MainInputActions を破棄する
_mainInputActions.Dispose();
}
}
プレハブのルートの GameObject にこの LocalPlayerController をアタッチします。

先ほどのコードでは RequireComponent で CharacterController を指定しているので、LocalPlayerController をアタッチすると同時に CharacterController というコンポーネントも追加されるかと思います。
これで WASD でキャラクターを移動させられるようになりました。
三人称視点カメラの作成
次は三人称視点カメラを作成します。
今回、三人称視点カメラの作成にあたっては Cinemachine を使用しました。

Cinemachine を使用した三人称視点カメラの作成方法については解説するとかなり長くなってしまいそうなので、この記事では割愛させていただきます…
Cinemachine を使用した三人称視点カメラの作成方法についてはこじゃら様の「【Unity】三人称視点のカメラワークを簡単に実装する方法」という記事がとても参考になります。
今回はキャラクターが常にカメラの向いている方向を向くようにしたいので LocalPlayerController.Start() に以下のコードを追加しました。
// using R3;
// using R3.Triggers;
private void Start()
{
_characterController = GetComponent<CharacterController>();
//追加
var mainCamera = Camera.main;
//追加
this.UpdateAsObservable()
.Select(_ => mainCamera.transform.eulerAngles.y)
.DistinctUntilChanged()
.Subscribe(cameraYaw => transform.eulerAngles = new Vector3(transform.eulerAngles.x, cameraYaw, transform.eulerAngles.z))
.AddTo(this);
}
これで三人称視点でカメラを操作できるようになりました。
ローカルプレイヤーの座標と角度を送信する
ここからはローカルプレイヤーの座標と角度を一定時間ごとに TCP で送信する処理を作成します。
構造体の作成
プレイヤーの座標と角度は JSON 形式の文字列として送受信したいので、まずは PlayerTransform という構造体を定義します。
プロジェクトウィンドウで右クリックして「Create > Scripting > Empty C# Script」を選択します。

作成したスクリプトの名前を「PlayerTransform」などに変更し、以下のコードのようにして PlayerTransform という構造体を定義します。
using UnityEngine;
public struct PlayerTransform
{
public Vector3 worldPosition;
public Vector3 worldEulerAngles;
}
インスペクターで編集可能な private 変数の追加
ゲーム開始とともにメッセージの送信を開始するのではなく、エディタでのプレイ中にインスペクターで値を変更するとメッセージの送信が開始されるようにしたいので、bool 型の sendPlayerTransform というメンバ変数を LocalPlayerController で定義します。
以下のコードでは SerializeField でこのメンバ変数をインスペクターに公開しています。
また、private を付けて他のクラスから参照されないようにしています。
[SerializeField]
private bool sendPlayerTransform = false;

TCP でメッセージを送信する非同期メソッドの作成
次は指定のIPアドレスとポートにメッセージを送信する以下のコードのような非同期メソッドを LocalPlayerController で定義します。
// using System.Net.Sockets;
// using System.Text;
// using System.Threading;
// using Cysharp.Threading.Tasks;
private static async UniTask SendMessageAsync(string message, CancellationToken token, string address = "127.0.0.1", int port = 8000)
{
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(address, port);
await using var stream = tcpClient.GetStream();
var messageData = Encoding.UTF8.GetBytes(message);
await stream.WriteAsync(messageData, 0, messageData.Length, token);
await stream.FlushAsync(token);
}
UE 編ではこの画像のような非同期ノード(Latent ノード)を作成しています。

一定時間ごとにメッセージを送信する処理の作成
次は一定時間ごとにプレイヤーの座標と角度を JSON 形式の文字列に変換して送信する以下のようなコードを LocalPlayerController.Start() に追加します。
// using System;
private void Start()
{
//既存の処理
//追加
Observable.Interval(TimeSpan.FromSeconds(0.1f)) // 0.1 秒間隔で定期的に発火する
.Where(_ => sendPlayerTransform) // sendPlayerTransform が true のときだけ次に進む
.Select(_ => (position: transform.position, eulerAngles: transform.eulerAngles)) //プレイヤーの座標と角度を流す
.DistinctUntilChanged() //プレイヤーの座標と角度が変化しなかったら無視する
.SubscribeAwait(async (positionAndEulerAngles, token) =>
{
var playerTransform = new PlayerTransform()
{
worldPosition = positionAndEulerAngles.position,
worldEulerAngles = positionAndEulerAngles.eulerAngles
};
//プレイヤーの座標と角度を JSON 形式の文字列に変換して送信する
var playerTransformString = JsonUtility.ToJson(playerTransform);
await SendMessageAsync(playerTransformString, token, port: 8001); // UE 側のポート番号は 8001 番にしました
},
AwaitOperation.Drop) //メッセージ送信中に次の値が流れてきても無視する
.AddTo(this);
}
リモートプレイヤー用のプレハブとその移動処理の作成
次は非ローカルプレイヤー(この記事では「リモートプレイヤー」とよびます)用のプレハブを作成します。
まずはローカルプレイヤー用のプレハブを作成したときと同様にプロジェクトウィンドウの PlayerCharacterBase で右クリックして「Create > Prefab Variant」を選択し、「RemotePlayerCharacter」という名前の Prefab Variant を作成します。
次は TCP でメッセージの受信を待機する Observable を作成します。
R3で Observable.Create() を使って Observable を自作する基本的な方法についてはこちらの記事が参考になるかと思います。
次にプロジェクトウィンドウで右クリックして「Create > MonoBehaviour Script」を選択し、RemotePlayerController というスクリプトを作成します。
そして以下のコードのようにして OnReceivedMessageObservable() という、TCP でメッセージを受信する Observable を定義します。
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using R3;
using UnityEngine;
public class RemotePlayerController : MonoBehaviour
{
private static Observable<string> OnReceivedMessageObservable(string listenerAddress = "127.0.0.1", int listenerPort = 8000)
{
return Observable.Create<string>(async (observer, token) =>
{
if (listenerPort is < 0 or > 65535)
{
var incorrectPortException = new ArgumentOutOfRangeException(nameof(listenerPort), "Incorrect port number.");
observer.OnCompleted(incorrectPortException);
return;
}
TcpListener tcpListener = null;
try
{
var listenerIpAddress = IPAddress.Parse(listenerAddress);
tcpListener = new TcpListener(listenerIpAddress, listenerPort);
tcpListener.Start();
// TcpListener.AcceptTcpClientAsync() 中にキャンセルされた際に ObjectDisposedException をスローさせるために必要
token.Register(() => tcpListener?.Stop());
var buffer = new byte[8192];
while (!token.IsCancellationRequested)
{
using var tcpClient = await tcpListener.AcceptTcpClientAsync();
await using var stream = tcpClient.GetStream();
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token);
if (bytesRead == 0)
{
break;
}
var receivedMessage = Encoding.UTF8.GetString(buffer, 0, bytesRead);
observer.OnNext(receivedMessage);
}
}
//キャンセルされた
observer.OnCompleted();
}
catch (OperationCanceledException)
{
// NetworkStream.ReadAsync() 中にキャンセルされた
observer.OnCompleted();
}
catch (ObjectDisposedException) when (token.IsCancellationRequested)
{
// TcpListener.AcceptTcpClientAsync() 中にキャンセルされた
observer.OnCompleted();
}
catch (Exception exception)
{
//予期しない例外がスローされた
observer.OnCompleted(exception);
}
finally
{
//キャンセル以外の理由で終了するときはここで TcpListener を止める
if (!token.IsCancellationRequested)
{
tcpListener?.Stop();
}
}
});
}
}
LocalPlayerController.sendPlayerTransform のときと同様に applyReceivedPlayerTransform というメンバ変数を RemotePlayerController に定義します。
[SerializeField]
private bool applyReceivedPlayerTransform = false;
そして RemotePlayerController.Start() に以下のようなコードを追加します。
このコードでは Unreal エディタからプレイヤーの座標と角度を受信したら、0.1秒かけて等速で移動 & 回転しています。
// using System.Collections.Generic;
// using DG.Tweening;
// using Cysharp.Threading.Tasks;
private void Start()
{
OnReceivedMessageObservable()
.Where(_ => applyReceivedPlayerTransform)
.SubscribeAwait(async (receivedMessage, token) =>
{
var playerTransform = JsonUtility.FromJson<PlayerTransform>(receivedMessage);
await new List<UniTask>()
{
// 0.1 秒かけて等速で移動する
transform.DOMove(playerTransform.worldPosition, 0.1f)
.SetEase(Ease.Linear)
.ToUniTask(cancellationToken: token),
// 0.1 秒かけて等速で回転する
transform.DORotate(playerTransform.worldEulerAngles, 0.1f)
.SetEase(Ease.Linear)
.ToUniTask(cancellationToken: token)
};
},
AwaitOperation.Switch) //移動 & 回転中に次の値が流れてきたら、新たに流れてきた方を優先する
.AddTo(this);
}
このコードの「await new List<UniTask>()」の部分は UniTask.WhenAll() です。
「await UniTask.WhenAll()」と書かなくても「await new List<UniTask>()」と省略して書けるそうです。
(参考「【Unity】UniTaskのWhenAllは省略して書ける」)
このコードでは DOMove() や DORotate() に対して Ease.Linear を SetEase() していますが、これは線形補間です。
この SetEase() で指定できる Ease に関してはゲームUIネット様の「DOTweenのイージング一覧を世界一詳しく&分かりやすく説明する」という記事が世界一わかりやすいです。
このコードでは SubscribeAwait() の第二引数で AwaitOperation.Switch を渡していますが、これにより、移動 & 回転中に新たな値が流れてきた際、その新たに流れてきた値の方を優先しています。
(参考「次世代Rx「R3」解説」)
リモートプレイヤーの移動処理はこれで完成です。
作成した RemotePlayerController をリモートプレイヤー用のプレハブのルートの GameObject にアタッチします。

これで TCP で受信したメッセージをもとに RemotePlayerCharacter が移動 & 回転できるようになりました。
Unreal エディタ操作中も滑らかに動作させる
これで Unreal エディタとローカルマルチプレイできる状態になりましたが、このままでは Unreal エディタを操作中に Unity エディタの動きが止まってしまうのでプロジェクト設定を少し変更します。
「Edit > Project Settings… > Player > Resolution and Presentation > Resolution > Run In Background」にチェックを付けます。

これで Unreal エディタ操作中でも Unity エディタが動作するようになりました。
Unreal エディタとローカルマルチプレイ
ここからは実際に Unreal エディタとローカルマルチプレイしてみます。
シーンに LocalPlayerCharacter や RemotePlayerCharacter、床の GameObject などを配置してゲームを実行します。

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

最後に
参考記事
- R3をUnityNuGetとOpenUPMで楽々インストール
- R3を入れるときにCannot connect to ‘unitynuget-registry.azurewebsites.net’
- Unityにgltf glbファイルをインポートする方法
- 【Unity】Input Actionの基本的な使い方
- 【Unity】三人称視点のカメラワークを簡単に実装する方法
- 【Unity】UniTaskのWhenAllは省略して書ける
- DOTweenのイージング一覧を世界一詳しく&分かりやすく説明する
- 次世代Rx「R3」解説
- Unityが非アクティブ時でもバックグラウンドで動作させる方法
- 【Unity】Animatorのモーション切り替えが即座に行われないときの対処


