【Unity】Input SystemのカスタムInteractionでダッシュ操作を実装する

こじゃらこじゃら

Input Systemを使っているんだけど、ダブルタップなどでキャラクターをダッシュさせる方法はないの?

このはこのは

いくつかやり方はあるけど、Interactionを使えばこの辺がスマートに実装できるわ。

Input SystemのカスタムInteractionを用いてキャラクターのダッシュ操作を実現する方法の解説記事です。

実装方法は様々ですが、Input SystemのカスタムInteractionを使うと、次のような操作をInput System側で検知・通知できるようになります。

Interactionで検知できる操作
  • WASDキーのダブルタップ(ダブルタップスプリント)
  • スティックを素早く倒す

Input SystemのInteractionはこのような特定の入力パターンを検知した時に入力を通知する機能を有しているため、今回のケースに適したものと言えるでしょう。 [1]

ダブルタップ操作のInteractionには、Multi Tap Interactionというプリセットが用意されていますが、ダブルタップ直後にダッシュキャンセルされてしまう問題があったため、今回は自作のInteractionを実装して対応することとしました。

本記事では、自作のInteractionを実装して、ダブルタップやスティック早倒しによるダッシュ操作を実現する方法を解説していきます。

この作品はユニティちゃんライセンス条項の元に提供されています

動作環境
  • Unity 2022.1.7f1
  • Input System 1.3.0

スポンサーリンク

前提条件

Input Systemがインストールされ、有効化されているものとします。

ここまでの手順が分からない方は、以下記事をご覧ください。

また、Input Actionでキャラクター操作のシーンがセットアップされているものとします。

本記事ではStarter Assets – Third Person Character Controllerというアセットのサンプルシーンのプレイヤーに対してダッシュ操作を適用していくものとします。

キャラクター操作が適用されているものであれば、Starter Assetsでなくても構いません。Starter Assetsのセットアップ方法については以下記事をご覧ください。

例ではプレイヤーモデルをユニティちゃんに差し替えていますが、デフォルトのままでも問題ありません。ユニティちゃんモデルへの差し替え方法は以下記事で解説しています。

Interactionの仕組み

特定の入力パターンを表現するものです。ActionやBinding単位で適用できます。

参考:Interactions | Input System | 1.3.0

例えば、Hold Interactionは長押しを表現するInteractionの一種です。Interactionが何も指定されない場合はDefault Interactionが暗黙的に適用されます。

Interactionが特定の入力パターンを検知すると、Performedコールバックが発火されます。

Hold Interactionの動作

参照元では、Player Inputと組み合わせて次のようなコードでコールバックを拾えば良いです。

InteractionCallbackExample.cs
public class InteractionCallbackExample : MonoBehaviour
{
    // PlayerInput側から呼ばれる
    public void OnSprint(InputAction.CallbackContext context)
    {
        // Performedコールバックだけ受け取る
        if (!context.performed) return;

        // ダッシュ処理など
    }
}

Player Inputの使い方が分からない方は以下記事をご覧ください。

また、コールバックのより詳細な挙動については、以下記事で解説しています。

ダブルタップスプリントの実装の流れ

本記事では、WASDキーのダブルタップ操作などを検知したときに「ダッシュボタンが押された」ような振る舞いをさせることを目標とします。

具体的には、ダッシュ操作を検知した時にPerformedコールバックを発火し、その後にボタンを離したらCanceledコールバックが発火するようなInteractionの実装を目指します。

本記事で紹介する実装の流れは以下のようになります。

実装の流れ
  • カスタムInteractionの実装
  • カスタムComposite Bindingの実装
  • 上記InteractionとComposite BindingをダッシュActionに適用

2つ目のカスタムComposite Bindingの実装ですが、これはスクリプト側からボタン入力として入力値を受け取りたい場合に必要になることがあります。

理由は、2軸のWASD入力がVector2型であるのに対し、ボタン入力はfloat型であり、両者の型不一致が生じるためです。

Starter Assetsのサンプルでは、ダッシュボタンの入力を次のようにisPressedプロパティから受け取っているので、カスタムComposite Bindingの実装が必要になります。

public void OnSprint(InputValue value)
{
	SprintInput(value.isPressed);
}

マルチタップ&ホールドを検知するInteractionの実装

次のような挙動をするカスタムInteractionを実装するものとします。

指定された回数だけ素早くタップし、押しっぱなしになった時にPerformedコールバックを通知します。

Performedコールバックの後に入力がなくなった場合、一定時間ウェイトを置いてからCanceledコールバックを通知することとします。これは、入力方向を切り替えた瞬間などにダッシュキャンセルにならなくするための処置です。

以下、Interactionの実装例です。

MultiTapAndHoldInteraction.cs
using UnityEngine;
using UnityEngine.InputSystem;

internal class MultiTapAndHoldInteraction : IInputInteraction
{
    // 最大のタップ時間[s]
    public float tapTime;

    // 次のタップまでの最大待機時間[s]
    public float tapDelay;

    // 必要なタップ数
    public int tapCount = 2;

    // 入力判定の閾値(0でデフォルト値)
    public float pressPoint;

    // リリース判定の閾値(0でデフォルト値)
    public float releasePoint;

    // マルチタップ&ホールド後、入力がなくなってから終了するまでの時間
    public float endDelay;

    // タップ状態の内部フェーズ
    private enum TapPhase
    {
        None,
        WaitingForNextRelease,
        WaitingForNextPress,
        WaitingForRelease,
        WaitingForEnd,
    }

    // 設定値かデフォルト値の値を格納するフィールド
    private float tapTimeOrDefault => tapTime > 0.0 ? tapTime : InputSystem.settings.defaultTapTime;
    private float tapDelayOrDefault => tapDelay > 0.0 ? tapDelay : InputSystem.settings.multiTapDelayTime;
    private float pressPointOrDefault => pressPoint > 0 ? pressPoint : InputSystem.settings.defaultButtonPressPoint;
    private float releasePointOrDefault => pressPointOrDefault * InputSystem.settings.buttonReleaseThreshold;

    // Interactionの内部状態
    private TapPhase _currentTapPhase = TapPhase.None;
    private double _currentTapStartTime;
    private double _lastTapReleaseTime;
    private int _currentTapCount;

    /// <summary>
    /// 初期化
    /// </summary>
#if UNITY_EDITOR
    [UnityEditor.InitializeOnLoadMethod]
#else
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
#endif
    public static void Initialize()
    {
        // 初回にInteractionを登録する必要がある
        InputSystem.RegisterInteraction<MultiTapAndHoldInteraction>();
    }

    /// <summary>
    /// Interactionの内部処理
    /// </summary>
    public void Process(ref InputInteractionContext context)
    {
        // タイムアウト判定
        if (context.timerHasExpired)
        {
            // 最大許容時間を超えてタイムアウトになった場合はキャンセル
            context.Canceled();
            return;
        }

        switch (_currentTapPhase)
        {
            case TapPhase.None:
                // 初期状態

                // タップされたかチェック
                if (context.ControlIsActuated(pressPointOrDefault))
                {
                    _currentTapStartTime = context.time;

                    if (++_currentTapCount >= tapCount)
                    {
                        // 必要なタップ数に達したらPerformedコールバック実行
                        _currentTapPhase = TapPhase.WaitingForRelease;
                        context.Started();
                        context.PerformedAndStayPerformed();
                    }
                    else
                    {
                        // 入力がなくなるまで待機
                        _currentTapPhase = TapPhase.WaitingForNextRelease;
                        context.Started();
                        context.SetTimeout(tapTimeOrDefault);
                    }
                }

                break;

            case TapPhase.WaitingForNextRelease:
                // 入力がなくなるまで待機している状態
                if (!context.ControlIsActuated(releasePointOrDefault))
                {
                    if (context.time - _currentTapStartTime > tapTimeOrDefault)
                    {
                        // 最大許容時間を超えたのでキャンセル
                        context.Canceled();
                        break;
                    }

                    // 次の入力待ち状態に遷移
                    _lastTapReleaseTime = context.time;
                    _currentTapPhase = TapPhase.WaitingForNextPress;
                    context.SetTimeout(tapDelayOrDefault);
                }

                break;

            case TapPhase.WaitingForNextPress:
                // 次の入力待ちの状態
                if (context.ControlIsActuated(pressPointOrDefault))
                {
                    if (context.time - _lastTapReleaseTime > tapDelayOrDefault)
                    {
                        // 最大許容時間を超えたのでキャンセル
                        context.Canceled();
                        break;
                    }

                    ++_currentTapCount;
                    _currentTapStartTime = context.time;

                    if (_currentTapCount >= tapCount)
                    {
                        // 必要なタップ数に達したので、Performedコールバック通知
                        // 終了まで待機する状態に遷移
                        _currentTapPhase = TapPhase.WaitingForRelease;
                        context.PerformedAndStayPerformed();
                    }
                    else
                    {
                        // 必要タップ数に達していないので、入力がなくなるまで待機
                        _currentTapPhase = TapPhase.WaitingForNextRelease;
                        context.SetTimeout(tapTimeOrDefault);
                    }

                    _currentTapStartTime = context.time;
                }

                break;

            case TapPhase.WaitingForRelease:
                // マルチタップ判定後、入力がなくなるまで待機している状態

                // 入力チェック
                if (!context.ControlIsActuated(releasePointOrDefault))
                {
                    // 入力がなくなったので終了
                    _currentTapPhase = TapPhase.WaitingForEnd;
                    _lastTapReleaseTime = context.time;
                    context.SetTimeout(endDelay);
                }

                break;

            case TapPhase.WaitingForEnd:
                // 入力がなくなってからInteractionを終了するまで待機している状態
                if (context.time - _lastTapReleaseTime >= endDelay)
                {
                    // 一定時間経過したので終了する
                    context.Canceled();
                }
                else if (context.ControlIsActuated(pressPointOrDefault))
                {
                    // 再び入力があった
                    // 一定時間経過していないので、継続とみなす
                    _currentTapPhase = TapPhase.WaitingForRelease;
                    context.PerformedAndStayPerformed();
                }

                break;
        }
    }

    /// <summary>
    /// Interactionの状態リセット
    /// </summary>
    public void Reset()
    {
        _currentTapPhase = TapPhase.None;
        _currentTapStartTime = 0;
        _lastTapReleaseTime = 0;
        _currentTapCount = 0;
    }
}

上記をMultiTapAndHoldInteraction.csという名前でUnityプロジェクトに保存すると、以下のようにInteractionが使用可能になります。

ダブルタップのみならず、任意回数のタップにも対応することとしました。

処理内容については、ソースコード中のコメントをご覧ください。

カスタムComposite Bindingの実装

WASDキー入力などをComposite BindingとしてActionに定義しているとき、入力値の型はVector2となります。

この場合、ボタン入力として受け取る場合はfloat型入力値となり、型不一致によるエラーとなります。この状態で入力値を取得しようとすると、次のようなエラーがログ出力されます。

InvalidOperationException: Cannot read value of type 'Single' from composite 'UnityEngine.InputSystem.Composites.Vector2Composite' bound to action 'Player/Sprint[/Keyboard/leftShift,/Keyboard/w,/Keyboard/s,/Keyboard/a,/Keyboard/d]' (composite is a 'Int32' with value type 'Vector2')

本記事のようなWASDキー入力の大きさを1軸入力(float)として扱いたい場合、4方向入力の大きさをfloat型入力とするカスタムComposite Bindingを実装して適用すれば解決できます。

以下、カスタムComposite Bindingの実装例です。

DPadMagnitudeComposite.cs
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
using UnityEngine.InputSystem.Layouts;

internal class DPadMagnitudeComposite : InputBindingComposite<float>
{
    // 4方向ボタン入力
    [InputControl(layout = "Button")] public int up = 0;
    [InputControl(layout = "Button")] public int down = 0;
    [InputControl(layout = "Button")] public int left = 0;
    [InputControl(layout = "Button")] public int right = 0;

    /// <summary>
    /// 初期化
    /// </summary>
#if UNITY_EDITOR
    [UnityEditor.InitializeOnLoadMethod]
#else
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
#endif
    private static void Initialize()
    {
        // 初回にCompositeBindingを登録する必要がある
        InputSystem.RegisterBindingComposite(typeof(DPadMagnitudeComposite), "2DVectorMagnitude");
    }
    
    /// <summary>
    /// 4方向入力からベクトルの大きさに変換して返す
    /// </summary>
    public override float ReadValue(ref InputBindingCompositeContext context)
    {
        var upValue = context.ReadValue<float>(up);
        var downValue = context.ReadValue<float>(down);
        var leftValue = context.ReadValue<float>(left);
        var rightValue = context.ReadValue<float>(right);

        return DpadControl.MakeDpadVector(upValue, downValue, leftValue, rightValue).magnitude;
    }
    
    /// <summary>
    /// 値の大きさを返す
    /// </summary>
    public override float EvaluateMagnitude(ref InputBindingCompositeContext context)
    {
        return ReadValue(ref context);
    }
}

上記スクリプトをDPadMagnitudeComposite.csという名前でUnityプロジェクトに保存すると、以下のようにカスタムComposite Bindingが選択可能になります。

Actionへの適用

該当するダッシュ操作のActionにInteractionComposite Bindingを適用します。

例では、SprintというActionに対して適用するものとします。

まず、該当Actionの下に、先ほど実装したComposite Bindingを追加します。

そして、方向キーを設定します。例ではWASDキーを上下左右の入力として設定することにします。

追加したComposite Bindingに先のカスタムInteractionを適用して設定します。

もし、大本のAction TypeがValueになっていなかったらValueに設定しておきます。

最後にSave AssetボタンをクリックしてInput Actionのアセット内容を保存します。

以上で手順は完了です。

実行結果

ここまでの手順を成功させると、WASDキーのマルチタップ&ホールド操作でダッシュできるようになります。

スティック早倒しの実装例

2つ目のダッシュ操作の例として、ゲームパッドのスティックを素早く倒したときにダッシュさせる例を紹介します。

実装の流れはダブルタップスプリントと一緒ですが、実装するInteractionとComposite Bindingの内容が異なります。

スティック早倒しを検知するInteractionの実装

次のような仕様を満たすInteractionを実装するものとします。

入力値の大きさがStart Press PointからEnd Press Pointまで時間Max Delay以内に変化したときにPerformedコールバックを通知するものとします。

時間以内に変化しなかった場合や、入力がなくなった時(Release Point以下となった時)、Canceledコールバックを通知するものとします。

以下、Interactionの実装例です。

QuickPressInteraction.cs
using UnityEngine;
using UnityEngine.InputSystem;

internal class QuickPressInteraction : IInputInteraction
{
    // スティック倒し始めの入力値の大きさ
    public float startPressPoint = 0.2f;
    
    // スティック倒し終わりの入力値の大きさ
    public float endPressPoint = 0.9f;
    
    // スティックを完全に倒し終わるまでの最大許容時間[s]
    public float maxDelay = 0.1f;
    
    // スティックを離したと判断する閾値
    public float releasePoint = 0.375f;

    private double _startPressTime;
    private bool _isFree = true;

    /// <summary>
    /// 初期化
    /// </summary>
#if UNITY_EDITOR
    [UnityEditor.InitializeOnLoadMethod]
#else
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
#endif
    public static void Initialize()
    {
        // 初回にInteractionを登録する必要がある
        InputSystem.RegisterInteraction<QuickPressInteraction>();
    }

    public void Process(ref InputInteractionContext context)
    {
        // タイムアウト判定
        if (context.timerHasExpired)
        {
            // 最大許容時間を超えてタイムアウトになった場合はキャンセル
            context.Canceled();
            return;
        }

        switch (context.phase)
        {
            case InputActionPhase.Waiting:
                // 入力待機状態

                if (!_isFree)
                {
                    // ニュートラル位置チェック
                    if (!context.ControlIsActuated(startPressPoint))
                    {
                        _isFree = true;
                    }

                    break;
                }

                // スティックがニュートラル位置なら、入力チェック
                if (context.ControlIsActuated(endPressPoint))
                {
                    // 一気にスティックが倒された場合
                    _isFree = false;
                    _startPressTime = context.time;

                    // Started、Performedコールバックを一気に発火
                    context.Started();
                    context.PerformedAndStayPerformed();
                }
                else if (context.ControlIsActuated(startPressPoint))
                {
                    // スティックが倒され始めた場合
                    _isFree = false;
                    _startPressTime = context.time;

                    // Startedコールバック発火
                    context.Started();
                    context.SetTimeout(maxDelay);
                }

                break;

            case InputActionPhase.Started:
                // スティックが倒され始めている状態

                if (context.time - _startPressTime <= maxDelay)
                {
                    // 最大許容時間内にスティックが完全に倒されたかチェック
                    if (context.ControlIsActuated(endPressPoint))
                    {
                        // 倒されたらPerformedコールバックを発火
                        context.PerformedAndStayPerformed();
                    }
                }
                else
                {
                    // 最大許容時間内にスティックが完全に倒されなければ中断とみなす
                    context.Canceled();
                }

                break;

            case InputActionPhase.Performed:
                // スティック早倒し中

                // スティックが戻されているかどうかのチェック
                if (!context.ControlIsActuated(releasePoint))
                {
                    context.Canceled();
                }
                else if (context.ControlIsActuated())
                {
                    context.PerformedAndStayPerformed();
                }

                break;
        }
    }

    public void Reset()
    {
        _startPressTime = 0;
    }
}

上記をQuickPressInteraction.csという名前で保存するとInteractionが使えるようになります。

入力値の大きさに変換するComposite Bindingの実装

スティック入力はVector2型の入力ですが、これをfloat型に変換するComposite Bindingを実装します。

MagnitudeComposite.cs
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Layouts;

internal class MagnitudeComposite : InputBindingComposite<float>
{
    // 入力
    [InputControl] public int input = 0;

    /// <summary>
    /// 初期化
    /// </summary>
#if UNITY_EDITOR
    [UnityEditor.InitializeOnLoadMethod]
#else
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
#endif
    private static void Initialize()
    {
        // 初回にCompositeBindingを登録する必要がある
        InputSystem.RegisterBindingComposite(typeof(MagnitudeComposite), "Magnitude");
    }
    
    /// <summary>
    /// 入力値の大きさに変換して返す
    /// </summary>
    public override float ReadValue(ref InputBindingCompositeContext context)
    {
        return context.EvaluateMagnitude(input);
    }
    
    /// <summary>
    /// 値の大きさを返す
    /// </summary>
    public override float EvaluateMagnitude(ref InputBindingCompositeContext context)
    {
        return ReadValue(ref context);
    }
}

上記をMagnitudeComposite.csという名前で保存しておきます。

Actionへの適用

先述のComposite BindingをActionに追加します。

追加したComposite Bindingにスティック入力などのControl Pathを設定します。

そして、Quick Press Interactionを先述のComposite Bindingに適用し、必要に応じてパラメータの設定を行います。

設定したら、忘れずにSave Assetボタンクリックで内容を保存してください。

実行結果

スティックを素早く倒すとダッシュし、ゆっくり倒すとダッシュしなくなります。

思い通りの操作にならない場合は、Quick Press Interactionのパラメータを調整すると良いです。

Interactionを適用する場合の注意点

複数のダッシュ操作を適用する際、ボタン操作とInteractionを共存させると、操作が競合して次のようなエラーが出る場合があるようです。

Value type actions should not be left in performed state

主にダブルタップなどのInteraction操作とボタン押下を同時に行った際にエラーとなります。理由は、InteractionのフェーズがPerformedのままになっているためです。

一般的に、Unity提供のプリセットでも同様の問題が起こるため、現状ではInteractionを使用する場合はAction TypeをPass Throughにしておくか、ボタン入力のBindingを消す(必要なら別Actionにしてしまう)などの対策が必要になるかもしれません。

さいごに

カスタムInteractionとComposite Bindingを実装してダッシュ操作を実現する方法を解説しました。

Input Systemのクラス仕様を理解したうえで実装しないといけない点が面倒かもしれませんが、一度実装すると使いまわせる点では良いかもしれません。

入力タイミングの検知という時系列が絡む複雑な処理をInteraction側に任せることにより、入力値を処理するロジックとキャラクター操作のロジックが綺麗に分かれるメリットがあります。

開発するコンテンツに合わせて検討すると良いでしょう。

参考サイト

スポンサーリンク