2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】InputSystemでシーンを跨いだバインディングを実現する(ローカルマルチ)

Last updated at Posted at 2024-04-11

注意事項

この記事では、InputSystemについて導入および基礎的な理解が進んでいる前提で話が進みますので、ご了承ください。

基礎的な理解がまだできていないという方は、こちらの前提記事がおすすめです。

動作環境

Unity 2022.3.10f1

はじめに / 今回実装するもの

InputSystemを用いたローカルマルチの実装では、とあるプレイヤーオブジェクトに対して、一意のデバイスを紐づけることで実現します。

しかし、今回例に挙げる 「キャラクター選択画面」(アウトゲームと呼ぶ)などを実装しようとすると、どうしてもアウトゲームとインゲームでシーンを分けなければいけなくなります。(無理やりひとつのシーンで実装することもできますが、管理が煩雑になりすぎます)

なので、シーンを跨いでプレイヤーにバインディングをするということをやらなければなりません。

以上を実装する方法としては
①キャラクター選択画面で「一意のデバイスが紐づいたプレイヤー」を生成
②生成した各プレイヤーの入力により、キャラクターを選択する
③インゲームに「選択したキャラクターとデバイスの情報」を渡して再生成
となります。

に関しては紹介しますが、②についてはビューの領域なので割愛します。


【イメージ図】


今回のサンプルを貼っておきます。

ローカルマルチの実装方法

ローカルマルチ自体については、InputSystemのPlayerInputManagerというコンポーネントを用いることで比較的簡単に実装することが可能です。

PlayerInputManagerは、それぞれのプレイヤーをPlayerInputコンポーネントとして管理してくれるコンポーネントです。
PlayerInputコンポーネントがアタッチされたオブジェクトがシーン上に生成されると、自動的に入室扱いとなるため、ひとまずコイツをシーン上に配置するだけで複数プレイヤーを制御することができます。

スクリーンショット 2024-03-13 202210.png

前提:Player Input Manager の設定

  • Notification Behavior
    プレイヤー入室時等のイベントを、どの形式で通知するか設定する項目です。
    詳細は後述します。

  • Join Behavior
    実行中にプレイヤーを生成する方法を設定する項目です。

    • JoinPlayersWhenButtonIsPressed
      初期設定ではこの設定になっており、これは接続されているデバイスのいずれかのキー(ボタン)が押されたときPlayerPrefabに設定しているGameObjectがInstantiateされます。(すでにプレイヤーに割り当てられているデバイスからの入力は無視します。)
      理由は後述しますが、非推奨です。

    • JoinPlayersWhenJoinActionIsTriggered
      この設定では、任意のInputActionが入力されたときPlayerPrefabのGameObjectがInstantiateされます。上の設定より幾分か使いやすいですが、同様にこちらも非推奨です。

    • JoinPlayersManually
      この設定は、スクリプトから手動でプレイヤーをInstantiateさせることができます。

    多くの場合 JoinPlayersManually を選択し、プレイヤーを入室させることになります。

    JoinPlayersWhenButtonIsPressedJoinPlayersWhenJoinActionIsTriggeredが非推奨の理由は、その自由度の低さにあります。
    Prefabは1つしかアタッチできないため、プレイヤーによって生成するPrefabを変えることはできません。

    また、この2つの設定は入力を行うことでしかプレイヤーを生成することができないため、今回のキャラクター選択画面など、シーンを跨いだ機能の実装には向いていません。

  • Joining Enabled By Default
    初期状態でプレイヤーの入室を有効化するかを設定する項目です。
    チェックを外すとプレイヤーが入室できなくなります。なぜかgetonlyのためスクリプトから値を変更することができません。(たぶん値を変える方法があるはずだけど…)
    どちらにせよ、前述したJoin BehaviorJoinPlayersManuallyを選択している場合は、この設定は無視されるため関係のない項目です。

  • Limit Number Of Players
    入室可能なプレイヤーの上限数を設定する項目です。
    チェックを付けることで有効となり、上限数を入力できるフィールドが出てきます。
    …ですが、こちらもなぜかgetonlyなのに加え、上の項目と同じくJoinPlayersManuallyを選択している場合はこの設定も無視されるため、関係のない項目です。

  • Split Screen
    画面分割について設定する項目です。
    今回は使用しないため割愛します。

本題①:プレイヤーを手動で生成する

PlayerInput.Instantiate()を使う

イメージ図の現在地(展開して表示)


前述したPlayerInputManagerJoin BehaviorJoinPlayersManuallyを設定したため、プレイヤーを手動で生成するスクリプトを作ります。

基本的には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構造体から取得できます。

以下はプレイヤーに紐づけるデバイス情報を取得するコードのサンプルです。

InputDeviceの取得方法
[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を設定
スクリーンショット 2024-04-01 140922.png

【備考】
本実装ではあまりおすすめしませんが、InputAction型はコンストラクタなどを利用することによって、スクリプト内でバインディングすることも可能です。

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: プレイヤーの入室イベント

ここでようやく、説明を飛ばしたPlayerInputManagerNotification Behaviorが出てきます。
PlayerInputManagerにはプレイヤーが入室したときに発行されるイベントというものが存在します。それらのイベントを、どのような形式で発行するかを設定することができます。

  • Notification Behavior

    • SendMessages
      PlayerInputコンポーネントがアタッチされているオブジェクトの、"OnPlayerJoined"という名前のメソッドを実行する。パフォーマンスがよくないので非推奨

    • BroadcastMessages
      PlayerInputコンポーネントがアタッチされているオブジェクトと、そのすべての子オブジェクトの"OnPlayerJoined"という名前のメソッドを実行する。こちらもパフォーマンスがよくないので非推奨

    • InvokeUnityEvents
      PlayerInputManageronPlayerJoinedプロパティに、インスペクタから実行するメソッドを登録します。

    • InvokeCSharpEvents
      PlayerInputManageronPlayerJoinedプロパティに、任意のスクリプトから実行するメソッドを登録します。

基本的には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を使ったローカルマルチ、およびシーンを跨いだバインディングの実現について解説しました。
前提知識を要する内容が多く、少々抽象的で理解しがたかった部分もあるかもしれませんが、ローカルマルチプレイの制作のお役に立てれば幸いです。

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?