注意事項
この記事では、InputSystemについて導入および基礎的な理解が進んでいる前提で話が進みますので、ご了承ください。
基礎的な理解がまだできていないという方は、こちらの前提記事がおすすめです。
動作環境
Unity 2022.3.10f1
はじめに / 今回実装するもの
InputSystemを用いたローカルマルチの実装では、とあるプレイヤーオブジェクトに対して、一意のデバイスを紐づけることで実現します。
しかし、今回例に挙げる 「キャラクター選択画面」(アウトゲームと呼ぶ)などを実装しようとすると、どうしてもアウトゲームとインゲームでシーンを分けなければいけなくなります。(無理やりひとつのシーンで実装することもできますが、管理が煩雑になりすぎます)
なので、シーンを跨いでプレイヤーにバインディングをするということをやらなければなりません。
以上を実装する方法としては
①キャラクター選択画面で「一意のデバイスが紐づいたプレイヤー」を生成
②生成した各プレイヤーの入力により、キャラクターを選択する
③インゲームに「選択したキャラクターとデバイスの情報」を渡して再生成
となります。
①と③に関しては紹介しますが、②についてはビューの領域なので割愛します。
【イメージ図】
今回のサンプルを貼っておきます。
ローカルマルチの実装方法
ローカルマルチ自体については、InputSystemのPlayerInputManager
というコンポーネントを用いることで比較的簡単に実装することが可能です。
PlayerInputManager
は、それぞれのプレイヤーをPlayerInput
コンポーネントとして管理してくれるコンポーネントです。
PlayerInput
コンポーネントがアタッチされたオブジェクトがシーン上に生成されると、自動的に入室扱いとなるため、ひとまずコイツをシーン上に配置するだけで複数プレイヤーを制御することができます。
前提:Player Input Manager の設定
-
Notification Behavior
プレイヤー入室時等のイベントを、どの形式で通知するか設定する項目です。
詳細は後述します。
-
Join Behavior
実行中にプレイヤーを生成する方法を設定する項目です。-
JoinPlayersWhenButtonIsPressed
初期設定ではこの設定になっており、これは接続されているデバイスのいずれかのキー(ボタン)が押されたとき、PlayerPrefab
に設定しているGameObjectがInstantiateされます。(すでにプレイヤーに割り当てられているデバイスからの入力は無視します。)
理由は後述しますが、非推奨です。 -
JoinPlayersWhenJoinActionIsTriggered
この設定では、任意のInputAction
が入力されたとき、PlayerPrefab
のGameObjectがInstantiateされます。上の設定より幾分か使いやすいですが、同様にこちらも非推奨です。 -
JoinPlayersManually
この設定は、スクリプトから手動でプレイヤーをInstantiateさせることができます。
多くの場合
JoinPlayersManually
を選択し、プレイヤーを入室させることになります。JoinPlayersWhenButtonIsPressed
とJoinPlayersWhenJoinActionIsTriggered
が非推奨の理由は、その自由度の低さにあります。
Prefabは1つしかアタッチできないため、プレイヤーによって生成するPrefabを変えることはできません。また、この2つの設定は入力を行うことでしかプレイヤーを生成することができないため、今回のキャラクター選択画面など、シーンを跨いだ機能の実装には向いていません。
-
-
Joining Enabled By Default
初期状態でプレイヤーの入室を有効化するかを設定する項目です。
チェックを外すとプレイヤーが入室できなくなります。なぜかgetonlyのためスクリプトから値を変更することができません。(たぶん値を変える方法があるはずだけど…)
どちらにせよ、前述したJoin BehaviorのJoinPlayersManually
を選択している場合は、この設定は無視されるため関係のない項目です。
-
Limit Number Of Players
入室可能なプレイヤーの上限数を設定する項目です。
チェックを付けることで有効となり、上限数を入力できるフィールドが出てきます。
…ですが、こちらもなぜかgetonlyなのに加え、上の項目と同じくJoinPlayersManually
を選択している場合はこの設定も無視されるため、関係のない項目です。
-
Split Screen
画面分割について設定する項目です。
今回は使用しないため割愛します。
本題①:プレイヤーを手動で生成する
PlayerInput.Instantiate()を使う
イメージ図の現在地(展開して表示)
前述したPlayerInputManager
のJoin BehaviorでJoinPlayersManually
を設定したため、プレイヤーを手動で生成するスクリプトを作ります。
基本的にはPlayerInput
コンポーネントが付いたオブジェクトをInstantiateするのですが、デバイス情報を紐づけなけらばならないため、PlayerInput.Instantiate()
という、普段使うものとは違うInstantiate関数を使います。
この関数はMonoBehaviour
のInstantiateをラップしたPlayerInput
クラスの関数で、通常の引数とは違う引数を渡すことで、デバイス情報を紐づけたPrefabを生成することができます。
PlayerInput.Instantiate()
の引数は以下の通りです。(※オーバーロードあり)
型 | 引数名 | 説明 |
---|---|---|
GameObject | prefab | 生成するプレハブ |
int | playerIndex | 生成したプレイヤーが待つ固有の番号 |
string | controlScheme | 紐づけているデバイスのスキーム。 自動で設定されるので今回は指定しない |
int | splitScreenIndex | 画面分割のインデックス。今回は指定しない |
InputDevice | pairWithDevice | 紐づけるデバイスの情報 |
InputDevice(デバイス情報)の取得
さて、上記の引数に必要なInputDevice
型ですが、こちらはInputAction
(入力)のコールバックであるInputAction.CallbackContext
構造体から取得できます。
以下はプレイヤーに紐づけるデバイス情報を取得するコードのサンプルです。
[SerializeField] private InputAction playerJoinInputAction = default;
private void Awake()
{
// InputActionを有効化(これがないと動かない)
playerJoinInputAction.Enable();
// InputAction発火時のコールバックを設定
playerJoinInputAction.performed += OnJoin;
}
private void OnJoin(InputAction.CallbackContext context)
{
// InputAction.CallbackContext構造体のcontrol.deviceプロパティからInputDeviceを取得
InputDevice device = context.control.device;
}
これで、InputAction
を発火させた(入力した)デバイス情報を取得することができました。
上記のサンプルではInputAction
型の変数をシリアライズ化し、インスペクタから受け付ける入力を設定していますが、ここはコールバックを受け取れればどんな方法でも構いません。(InputActionReference
型やInputActionAsset
型など)
【備考】
本実装ではあまりおすすめしませんが、InputAction
型はコンストラクタなどを利用することによって、スクリプト内でバインディングすることも可能です。
private InputAction playerJoinInputAction = default;
private void Awake()
{
playerJoinInputAction = new InputAction(binding: "<Gamepad>/buttonEast");
playerJoinInputAction.AddBinding("<Keyboard>/space");
}
実際にプレイヤーを生成する
イメージ図の現在地(展開して表示)
それでは、実際にプレイヤーを生成してみましょう。
using UnityEngine;
using UnityEngine.InputSystem;
/// <summary>
/// プレイヤーの入退室の管理クラス(アウトゲーム)
/// </summary>
public class PlayerJoinManager : MonoBehaviour
{
// プレイヤーがゲームにJoinするためのInputAction
[SerializeField] private InputAction playerJoinInputAction = default;
// PlayerInputがアタッチされているプレイヤーオブジェクト
[SerializeField] private PlayerInput playerPrefab = default;
// 最大参加人数
[SerializeField] private int maxPlayerCount = default;
// Join済みのデバイス情報
private InputDevice[] joinedDevices = default;
// 現在のプレイヤー数
private int currentPlayerCount = 0;
private void Awake()
{
// 最大参加可能数で配列を初期化
joinedDevices = new InputDevice[maxPlayerCount];
// InputActionを有効化し、コールバックを設定
playerJoinInputAction.Enable();
playerJoinInputAction.performed += OnJoin;
}
private void OnDestroy()
{
playerJoinInputAction.Dispose();
}
/// <summary>
/// デバイスによってJoin要求が発火したときに呼ばれる処理
/// </summary>
private void OnJoin(InputAction.CallbackContext context)
{
// プレイヤー数が最大数に達していたら、処理を終了
if (currentPlayerCount >= maxPlayerCount)
{
return;
}
// Join要求元のデバイスが既に参加済みのとき、処理を終了
foreach (var device in joinedDevices)
{
if (context.control.device == device)
{
return;
}
}
// PlayerInputを所持した仮想のプレイヤーをインスタンス化
// ※Join要求元のデバイス情報を紐づけてインスタンスを生成する
PlayerInput.Instantiate(
prefab: playerPrefab.gameObject,
playerIndex: currentPlayerCount,
pairWithDevice: context.control.device
);
// Joinしたデバイス情報を保存
joinedDevices[currentPlayerCount] = context.control.device;
currentPlayerCount++;
}
}
以上が、デバイス情報を紐づけたプレイヤーを生成する最小限のコードです。
スクリプト内でjoinedDevices
という配列を宣言していますが、このような方法で入室済みデバイスの情報を保持しておかないと、同じデバイスから何度も入室するということが可能になってしまうためこのような実装にしています。
また、PlayerInput.Instantiate()
の引数であるplayerIndex
は0から順番に振っています。
この引数は指定しなくとも勝手に割り振ってくれるのですが、今後の拡張性を考えると手動で明示的に振ってあげた方がよいと思います。(今回は割愛していますが、仕様によっては退室処理などで使うかも)
Tips: プレイヤーの入室イベント
ここでようやく、説明を飛ばしたPlayerInputManager
のNotification Behaviorが出てきます。
PlayerInputManager
にはプレイヤーが入室したときに発行されるイベントというものが存在します。それらのイベントを、どのような形式で発行するかを設定することができます。
-
Notification Behavior
-
SendMessages
PlayerInput
コンポーネントがアタッチされているオブジェクトの、"OnPlayerJoined"という名前のメソッドを実行する。パフォーマンスがよくないので非推奨。
-
BroadcastMessages
PlayerInput
コンポーネントがアタッチされているオブジェクトと、そのすべての子オブジェクトの"OnPlayerJoined"という名前のメソッドを実行する。こちらもパフォーマンスがよくないので非推奨。
-
InvokeUnityEvents
PlayerInputManager
のonPlayerJoined
プロパティに、インスペクタから実行するメソッドを登録します。
-
InvokeCSharpEvents
PlayerInputManager
のonPlayerJoined
プロパティに、任意のスクリプトから実行するメソッドを登録します。
-
基本的にはInvokeCSharpEvents
がスクリプト内で完結できて便利なので、これ一択かなと思います。
private PlayerInputManager playerInputManager = default;
private void Awake()
{
playerInputManager = GetComponent<PlayerInputManager>();
// プレイヤー入室時のコールバックを設定
playerInputManager.onPlayerJoined += OnPlayerJoined;
// プレイヤー退室時のコールバックを設定
playerInputManager.onPlayerLeft += OnPlayerLeft;
}
private void OnPlayerJoined(PlayerInput playerInput)
{
// プレイヤーが入室したときの処理
}
private void OnPlayerLeft(PlayerInput playerInput)
{
// プレイヤーが退室したときの処理
}
このプレイヤー入室時のイベントの最大の強みは、ビューとロジックを分割して管理できることだと思います。
例えば「プレイヤー生成時にUIを表示する」として、最も簡単な方法はプレイヤー生成時(PlayerJoinManager
)にその処理を記述することです。
簡単な方法:PlayerJoinManagerに処理を追記する(展開して表示)
~
private void OnJoin(InputAction.CallbackContext context)
{
if (currentPlayerCount >= maxPlayerCount)
{
return;
}
foreach (var device in joinedDevices)
{
if (context.control.device == device)
{
return;
}
}
PlayerInput.Instantiate(
prefab: playerPrefab.gameObject,
playerIndex: currentPlayerCount,
pairWithDevice: context.control.device
);
// 例えば(変数宣言は割愛)-------------------------------
Instantiate(UIObject, canvas.transform); // UIを表示する
audioSource.PlayOneShot(joinSE); // SEを再生する
// ------------------------------------------------------
joinedDevices[currentPlayerCount] = context.control.device;
currentPlayerCount++;
}
しかし見てわかる通り、「プレイヤーを生成する」というロジックの処理の中に、「UIを表示する」というビューの処理が入ってしまっているため、非常に読みにくく管理しにくいコードになってしまいます。(ピンと来ない方は、今後処理や表示したいUIなどが膨大に増えることを想像してみてください)
このような「プレイヤーを生成する」処理とは関係ないが、そのタイミングで実行したい内容は、このonPlayerJoined
プロパティのコールバックを利用するとよいと思います。
本題②:遷移先シーンにプレイヤー情報を渡す
さて、少し話が膨らみましたが、ここまでで「一意のデバイスが紐づいたプレイヤー」を生成することはできました。あとはこの情報をシーンを跨いで渡すことができればOKです。
プレイヤーの情報をまとめる
イメージ図の現在地(展開して表示)
ここで言うプレイヤーの情報とは、今回の例では「デバイス情報」と「選んだキャラの情報」のことを指します。
キャラクター選択機能については説明を割愛しますが、最終的に以下のようなデータクラスを作成することをおすすめします。
// 例えばこんなenumがあったとして
public enum CharacterType
{
Character1,
Character2,
Character3,
}
// こういうデータクラスを作る
public class PlayerInfo
{
public InputDevice PairWithDevice { get; private set; } = default;
public CharacterType SelectedCharacter { get; private set; } = default;
public PlayerInfo(InputDevice pairWithDevice, CharacterType selectedCharacter)
{
PairWithDevice = pairWithDevice;
SelectedCharacter = selectedCharacter;
}
}
遷移先シーンにプレイヤー情報を渡す
イメージ図の現在地(展開して表示)
別シーンに情報を渡す方法として、今回はこちらの記事を参考にします。
// どこかで各プレイヤーの情報を配列にしておく
private PlayerInfo[] playerInfos = default;
// 例えば「GameStart」のボタンが押されたら、シーンを遷移
private async Task GameStart()
{
// シーン遷移し、遷移先のコンポーネントを取得する
var target = await SceneLoader.Load<InGameHogeScript>("InGameScene");
// 遷移先のコンポーネントのプロパティにセット
target.SetInformation(playerInfos);
}
これにて、「アウトゲームで生成したプレイヤーが使用するキャラクターを選び、そのデータをインゲームに持ってくる」という機能を実装することができました。
あとは改めてインゲームでキャラクターを生成すればOKです。
public class InGameHogeScript : MonoBehaviour
{
[SerializeField] private GameObject character1 = default;
[SerializeField] private GameObject character2 = default;
[SerializeField] private GameObject character3 = default;
private PlayerInfo[] playerInfos = default;
public void SetInformation(PlayerInfo[] playerInfos)
{
this.playerInfos = playerInfos;
// 例えばここで生成
CreateCharacter();
}
// 改めてインゲームでキャラクターを生成
private void CreateCharacter()
{
for (int i = 0; i < playerInfos.Length; i++)
{
GameObject character = default;
switch (playerInfos[i].SelectedCharacter)
{
case CharacterType.Character1:
character = character1;
break;
case CharacterType.Character2:
character = character2;
break;
case CharacterType.Character3:
character = character3;
break;
}
PlayerInput.Instantiate(
prefab: character,
playerIndex: i,
pairWithDevice: playerInfos[i].PairWithDevice
);
}
}
}
おわりに
今回はInputSystemを使ったローカルマルチ、およびシーンを跨いだバインディングの実現について解説しました。
前提知識を要する内容が多く、少々抽象的で理解しがたかった部分もあるかもしれませんが、ローカルマルチプレイの制作のお役に立てれば幸いです。