🍣

[Unity]アニメーション中は連打を受け付けないボタン

2024/02/21に公開

はじめに

次のgifは「ボタンを押すと押下回数がカウントされていく」というとても単純なUIです。

このUIを作るにあたって、ユーザビリティを高めるために、ボタンを押下するとリアクションを取るようにしました。
押すと同時に拡縮するようになっていて、「ボタンを押した」ということが一目でわかります。
ゲームなどでは頻繁に目にする演出ですね。

しかし、この挙動には大きな問題点があります。
それは、ボタンのアニメーション中にも反応してしまっているという点です。

せっかくアニメーションを付けることで押下タイミングをわかりやすくしたのに、連打できているせいでかえって不自然な挙動になっています。

なにより、ゲーム制作において連打を許容するボタンなんて存在しないと思ってます。(暴論)
キャラのアクションボタンにしろ、装備の強化ボタンにしろ、決定ボタンにしろ、連続でイベントを送信すると基本的には不都合のほうが起こりやすいと思います。

そこで、今回はアニメーション中は反応しない(連打できない)ボタンというのを作ってみました。

コード

というわけでコードです。

OneShotButton.cs
using System;
using UnityEngine;
using UnityEngine.UI;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UniRx;
using UniRx.Triggers;

public class OneShotButton : MonoBehaviour
{
    /*
     *  @ タスク設定用
     */
    public class TaskBundle 
    {
        // タスク
        public Func<CancellationToken, UniTask> task = null;

        // CancellationToken
        public CancellationToken cancellationToken;

        // コンストラクタ
        public TaskBundle(Func<CancellationToken, UniTask> task, CancellationToken cancellationToken)
        {
            this.task = task;
            this.cancellationToken = cancellationToken;
        }
    }


    //! 押下アニメーションへ遷移させるパラメータ名
    [SerializeField]
    protected string _pressedParameterName = "Pressed";

    //! 押下アニメーションステート名
    [SerializeField]
    protected string _pressedStateName = "Pressed";

    //! アニメータ
    protected Animator _animator = null;

    //! アニメータからのTrigger
    protected ObservableStateMachineTrigger _animatorTrigger = null;

    //! ボタンコンポーネント
    protected Button _button = null;

    //! ボタンアニメーションの前に行いたい処理
    protected TaskBundle _preTaskBundle = null;

    //! ボタンアニメーションの後に行いたい処理
    protected TaskBundle _postTaskBundle = null;

    /*
     *  @ セットアップ 前処理
     */
    public void Set_PreTask(Func<CancellationToken, UniTask> preTask, CancellationToken cancellationToken)
    {
        _preTaskBundle = new TaskBundle(preTask, cancellationToken);
    }

    /*
     *  @ セットアップ 後処理
     */
    public void Set_PostTask(Func<CancellationToken, UniTask> postTask, CancellationToken cancellationToken)
    {
        _postTaskBundle = new TaskBundle(postTask, cancellationToken);
    }

    /*
     *  @ 起動時処理
     */
    void Start()
    {
        // アニメータコンポーネント取得
        _animator = GetComponent<Animator>();

        // アニメータからのTrigger取得
        _animatorTrigger = _animator.GetBehaviour<ObservableStateMachineTrigger>();

        // ボタンコンポーネント取得
        _button = GetComponent<Button>();

        // ボタンを押した時の動作
        _button.OnClickAsAsyncEnumerable().SubscribeAwait(async _ => await OnClickButton()).AddTo(this);

    }

    /*
     *  @ ボタン押下時の処理
     */
    protected async UniTask OnClickButton()
    {
        // キャンセル
        var cancellationToken = this.GetCancellationTokenOnDestroy();

        // 前処理
        if (_preTaskBundle != null)
        {
            await _preTaskBundle.task(_preTaskBundle.cancellationToken);
        }

        // アニメーション切り替え
        _animator.SetTrigger(_pressedParameterName);

        // 終了判定が飛んでくるまで待機
        await _animatorTrigger.OnStateExitAsObservable()
                .Where(x => x.StateInfo.IsName(_pressedStateName))
                .ToUniTask(useFirstValue: true, cancellationToken: cancellationToken);

        // 後処理
        if (_postTaskBundle != null)
        {
            await _postTaskBundle.task(_postTaskBundle.cancellationToken);
        }

        Debug.Log("Finish");
    }
}

上記のコードを適当なボタンにアタッチして使います。
(後述しますが、Animator側でも少し設定が必要です。)

下のgifでは記事冒頭のgifと同じようにボタンを連打していますが、拡縮のアニメーション中にカウントが増加し続けるということはありません。
(とても分かりにくいですが...)

簡単な実装説明

ボタンへイベントを登録する

Start()内の下記部分でボタン押下時に行う処理を登録しています。
SubscribeAwaitにすることで、登録した処理が完了するまで次のイベントが走りません。

// ボタンを押した時の動作
_button.OnClickAsAsyncEnumerable().SubscribeAwait(async _ => await OnClickButton()).AddTo(this);

https://qiita.com/toRisouP/items/8f66fd952eaffeaf3107#unitaskasyncenumerableiunitaskasyncenumerable

アニメーションステートの終了を検知する

事前準備として、Animatorタブにて少し手を加えます。
ボタンのアニメーションを制御しているcontrollerファイルを開き、終了タイミングを検知したいステートをクリックします。
今回はPressedというステートです。

ステートを選択した状態でInspectorタブを開きます。
下部のAddBehaviourボタンを押してObservableStateMachineTriggerを追加します。
これで準備は完了です。

続いてコードに戻ります。
下記のようにして、先ほど追加したObservableStateMachineTriggerを取得してコード内で扱えるようにしています。

    //! アニメータからのTrigger
    protected ObservableStateMachineTrigger _animatorTrigger = null;

    void Start()
    {
        // アニメータからのTrigger取得
        _animatorTrigger = _animator.GetBehaviour<ObservableStateMachineTrigger>();
    }

このTriggerを取得することで、Animatorからステートの開始や終了のタイミングを検知することができます。
Observableなので、ボタンの時と同様にイベントを結び付けたりUniTaskに変換することができます。
今回はOnStateExitAsObservable()をUniTaskに変換しawaitすることで、任意のステートを抜けるまで待機しています。
任意のステートかはWhere()でステート名を調べて判別しています。

ちなみに、OnStateEnterAsObservable()を使えばステートの開始を検知できます。

// 終了判定が飛んでくるまで待機
await _animatorTrigger.OnStateExitAsObservable()
                .Where(x => x.StateInfo.IsName(_pressedStateName))
                .ToUniTask(useFirstValue: true, cancellationToken: cancellationToken);

他処理の登録

アニメーション開始前、終了後に登録した処理を行います。
今回のテキストを変える処理は_preTaskBundleに登録した処理です。

登録側
    //!
    [SerializeField]
    protected OneShotButton _oneShotButton = null;

    [SerializeField] protected TextMeshProUGUI _countText = null;
    protected int _count = 0;

    private void Start()
    {
        // 前処理の登録
        _oneShotButton.Set_PreTask( async (ct) => 
        {
            _count++;
            _countText.text = (_count).ToString();

            Debug.Log("PreTask");

            await UniTask.CompletedTask;
        }, 
        this.GetCancellationTokenOnDestroy());

        // 後処理の登録
        _oneShotButton.Set_PostTask(async (ct) =>
        {
            Debug.Log("PostTask");

            await UniTask.CompletedTask;
        },
        this.GetCancellationTokenOnDestroy());
    }

おわりに

今回アニメーションの終了検知にObservableStateMachineTriggerを使用しましたが、他にもStateInfoのnormalizedTimeを調べる方法があります。
ただ、controllerの設定によってはうまく終了判定ができなかったりするんですよね...。
できればスクリプトだけで完結したいのですが...。何か良い方法ないんですかね..。

今回の実装が何かしらお役に立てれば幸いです。

Discussion