0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

長押し⇒連射 変換Button【つくってみた】

Posted at

序論

ゲームでしばしば長押しすると連射扱いになる操作ってありますよね。

シューティングやFPS等の射撃はもちろん、それ以外にも。
例えば、リスト状に並んだ項目から選択を行うとき....

  • 単押しすると カーソルが1項目だけ動く
  • 長押しすると カーソルが1項目だけ動いたのち、連射でカーソルが高速に動き始める

あるいは、アイテムを使う画面にて + / -ボタンで消費量を指定するとき....

  • 単押しすると 1だけ消費量が変動する
  • 長押しすると 1だけ消費量が変動したのち、連射で消費量が変動し始める

こういったメニューでの長押し連射というのもよく見ますよね。というか、むしろ今回はこっちを意識していたまであります。

今回はUnityで、そういった長押し連射を行うUI用Buttonをつくってみた!という記事です。

コード全文

一旦ここにコード全文を置いておきます。
解説は下で、順を追ってやっていきます。

RapidButton.cs
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UniRx;
using UnityEditor; // 最後の「おまけ」項のエディタ拡張用

public class RapidButton : Button, IPointerDownHandler, IPointerUpHandler, IPointerExitHandler
{
    /// <summary>
    /// 連射処理を扱う
    /// </summary>
    private class RapidFire
    {
        private float counter = 0;
        readonly private float interval;
        readonly private UnityAction onRapidFire;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public RapidFire(float interval, UnityAction onRapidFire)
        {
            this.interval = interval;
            this.onRapidFire = onRapidFire;
        }

        /// <summary>
        /// カウントし、インターバルだけ数えていたらイベント通知
        /// </summary>
        public void CountAndFire(float deltaTime)
        {
            counter += deltaTime;
            if (counter >= interval) onRapidFire?.Invoke();

            // インターバルを超えたらカウントをリセット
            counter = counter % interval;
        }
    }
    
    [SerializeField] private float firstInterval = 0.5f;
    [SerializeField] private float rapidInterval = 0.1f;

    /// <summary> 連射でOnNextを発行 </summary>
    public IObservable<Unit> OnRapidFire => onRapidFire;
    private readonly Subject<Unit> onRapidFire = new Subject<Unit>();

    private bool isPressed;
    private RapidFire currentRapidFire;
    
    private void Update()
    {
        if (!isPressed) return;
        
        // 一定秒数以上カウントしたら発火
        currentRapidFire?.CountAndFire(Time.deltaTime);
    }

    public override void OnPointerDown(PointerEventData _)
    {
        base.OnPointerDown(_);
        isPressed = true;
        onRapidFire.OnNext(Unit.Default); // 押し下げた瞬間にも1回OnNext
        currentRapidFire = new RapidFire(firstInterval, OnIntroHold);

        // 押下から連射開始まで適用
        void OnIntroHold()
        {
            onRapidFire.OnNext(Unit.Default);
            currentRapidFire = new RapidFire(rapidInterval, OnSubsequentHold);
        }

        // 連射開始から押上まで適用
        void OnSubsequentHold() => onRapidFire.OnNext(Unit.Default);
    }

    // ボタンを離したり退出したりしたらリセット
    public override void OnPointerUp(PointerEventData _)
    {
        base.OnPointerUp(_);
        isPressed = false;
        currentRapidFire = null;
    }
    public override void OnPointerExit(PointerEventData _)
    {
        base.OnPointerExit(_);
        isPressed = false;
        currentRapidFire = null;
    }

    // 一応、継承元のonClickにAddListenerしても通知するようにしてある
    protected override void Awake()
    {
        base.Awake();
        onRapidFire.Subscribe(_ => onClick?.Invoke()).AddTo(this);
    }

    // 継承元の通常OnPointerClickは処理が被るので握り潰す
    public override void OnPointerClick(PointerEventData _){}
}

連射の大雑把な仕組み

まず第一に。
私はこの処理を考えるにあたって、この長押し連射を

n秒長押し
↓
m秒間隔でイベントを通知し続ける処理を開始

というものではなく

n秒間隔でイベントを通知、という処理を1回だけ行う
↓
m秒間隔でイベントを通知、という処理を無限回行う(ボタン押下が終わるまで)

というものとして捉えました。
つまり。最初の "長押し" とその後の "連射" を同じようなものとして扱ってみたワケです。
それを踏まえて見ていきましょう。

連射を生み出す"コア"

RapidButton.cs
private RapidFire currentRapidFire;

private void Update()
{
    if (!isPressed) return;
    
    // 一定秒数以上カウントしたら発火
    currentRapidFire?.CountAndFire(Time.deltaTime);
}

言わずと知れたUpdate関数、毎フレーム呼ばれます。
isPressedというのはbool型の値で、名前の通り「今ボタンが押されている状態か」を表します(詳しくは後述)。
ボタンを押していない間は早期returnしているという、まあよくあるヤツですね。

ボタンを押している間はRapidFire.CountAndFire関数を毎フレーム呼びます。
その中身は以下の通り。

RapidFire.cs
/// <summary>
/// 連射処理を扱う
/// </summary>
private class RapidFire
{
    private float counter = 0;
    readonly private float interval;
    readonly private UnityAction onRapidFire;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public RapidFire(float interval, UnityAction onRapidFire)
    {
        this.interval = interval;
        this.onRapidFire = onRapidFire;
    }

    /// <summary>
    /// カウントし、インターバルだけ数えていたらイベント通知
    /// </summary>
    public void CountAndFire(float deltaTime)
    {
        counter += deltaTime;
        if (counter >= interval) onRapidFire?.Invoke();

        // インターバルを超えたらカウントをリセット
        counter = counter % interval;
    }
}

CountAndFire関数は呼ばれるたびに引数のfloat deltaTimecounterに積んでいき、その値がintervalを超えていたらonRapidFire?.Invoke();....すなわち登録された処理を実行します。

先ほどのコードで引数として渡されていたTime.deltaTime今フレームと前フレームの間の経過時間 [秒] ですから

counter += deltaTime;
if (counter >= interval) onRapidFire?.Invoke();

ここはまさしく連射を生み出しているコアとも言うべき箇所ですね。

ボタンを押した瞬間

で、そこで実行されているonRapidFireの中身というのはコンストラクタで渡されていますが、RapidFireのコンストラクタを呼びだしている箇所が...

RapidFire.cs
public override void OnPointerDown(PointerEventData _)
{
    base.OnPointerDown(_);
    isPressed = true;
    onRapidFire.OnNext(Unit.Default); // 押し下げた瞬間にも1回OnNext
    currentRapidFire = new RapidFire(firstInterval, OnIntroHold);

    // 押下から連射開始まで適用
    void OnIntroHold()
    {
        onRapidFire.OnNext(Unit.Default);
        currentRapidFire = new RapidFire(rapidInterval, OnSubsequentHold);
    }

    // 連射開始から押上まで適用
    void OnSubsequentHold() => onRapidFire.OnNext(Unit.Default);
}

こちらです。
OnPointerDown関数というのは IPointerDownHandlerインターフェースが実装する関数です。

簡単に触れておくと....
IPointerDownHandlerインターフェースはUnityのイベントシステムによってある種"特別扱い"を受けているインターフェースで、
IPointerDownHandlerインターフェースを実装したコンポーネント』を持つゲームオブジェクトがImageコンポーネントを同時に持っていると、Imageの形状通りの当たり判定でボタンを押した瞬間にOnPointerDown関数が呼ばれます

base.OnPointerDown(_);は継承元であるUnity標準のButtonのOnPointerDownを呼び出しています。特に意味のあることをしてはいませんが、継承元のButtonにてボタンを押した瞬間にボタンの色が変化するという処理が書かれているので呼んでいます。完全にビジュアル目的。

閑話休題。
連射時の処理として実行されているUnityAction onRapidFireに、中身を渡している箇所を見てみようという話でしたね。

コードを見ての通り、それは2箇所あります。
まず1箇所目はOnPointerDown関数内にて。onRapidFireには OnIntroHold 関数を渡しています。

public override void OnPointerDown(PointerEventData _)
{
    currentRapidFire = new RapidFire(firstInterval, OnIntroHold);
}

そして2箇所目は OnIntroHold 関数内にて。onRapidFireには OnSubsequentHold 関数を渡しています。

void OnIntroHold()
{
    currentRapidFire = new RapidFire(rapidInterval, OnSubsequentHold);
}

教授!これは......?


───すなわち、これはマトリョーシカ方式でRapidFireをつくっているわけです


改めて、最初に述べた大雑把な仕組みはこんな感じでした

n秒間隔でイベントを通知、という処理を1回だけ行う
↓
m秒間隔でイベントを通知、という処理を無限回行う(ボタン押下が終わるまで)

n秒というのがfirstIntervalで、
m秒というのがrapidIntervalにあたります。

n秒間隔で呼ばれるUnityActionの中に「currentRapidFireをm秒間隔でイベント通知するものに更新する」という処理を渡しています。つまりcurrentRapidFireを更新する処理をcurrentRapidFire自身が呼ぶ構図になっています。

ボタンを離した瞬間

IPointerUpHandler, IPointerExitHandlerも、IPointerDownHandler同様に特別なインターフェースで、それぞれ

  • 「Buttonが押し上げられた(ボタンを離した)瞬間」にイベント通知するOnPointerUp
  • 「ポインター(マウスや指)がボタン範囲から退出した瞬間」にイベント通知するOnPointerExit

を実装する代物です。
今回両者とも関数の中身の記述はほぼ同じで、継承元版の同名関数を(ビジュアル目的で)呼び、またisPressedフラグをfalseにしています。

isPressedについての処理が出揃ったので一応まとめると、

  • ボタンが押し下げられたときはtrueになる
  • 押し下げていたのが離されるとfalseになる
  • ずっと長押ししていてもタッチしている点がボタン範囲を出るとfalseになる

ということになります。isPressedという名を体がしっかり表していますね。

継承元のUnitiyEventも一応使える

最後に一応、ここにも触れておきます。

RapidButton.cs
// 一応、継承元のonClickにAddListenerしても通知するようにしてある
protected override void Awake()
{
    base.Awake();
    onRapidFire.Subscribe(_ => onClick?.Invoke()).AddTo(this);
}

// 継承元の通常OnPointerClickは処理が被るので握り潰す
public override void OnPointerClick(PointerEventData _){}

Unity標準のButtonが処理購読のために公開しているonClickプロパティ。外部から処理を購読する際にこちらを使っても、一応ちゃんと連射されるようにしています。

(今更ながら)今回の実装、そもそもUniRxに馴染みがないんじゃい使いにくいんじゃいという方がおられれば、UnityEventに置き換えるのも全然アリだと思います。その辺はお好みで。

完成形

適当に動かしてみました。
RapidFire_forQiita.gif

おまけ エディタ拡張

先ほどのコードに

RapidButton.cs
[SerializeField] private float firstInterval = 0.5f;
[SerializeField] private float rapidInterval = 0.1f;

という部分がありました。
「連射開始までの時間」と「連射間隔」をインスペクターから設定できるように[SerializeField]属性をつけているのですが....
....実はUnity標準のButtonを継承したこのクラスにて独自に定義したこれらの変数はそのままだとインスペクターに表示されません

なんてこった。

というわけで、上で載せたコード全文の末尾に以下のコードを追加してください。

RapidButton.cs
// RapidButton.csの末尾にこのコードをそのまま追加すればよい
#if UNITY_EDITOR
[CustomEditor(typeof(RapidButton))]
public class RapidButtonEditor : UnityEditor.UI.ButtonEditor
{
    SerializedProperty firstIntervalProp;
    SerializedProperty rapidIntervalProp;

    protected override void OnEnable()
    {
        base.OnEnable();
        firstIntervalProp = serializedObject.FindProperty("firstInterval");
        rapidIntervalProp = serializedObject.FindProperty("rapidInterval");
    }

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
        serializedObject.Update();
        EditorGUILayout.PropertyField(firstIntervalProp, new GUIContent("First Interval"));
        EditorGUILayout.PropertyField(rapidIntervalProp, new GUIContent("Rapid Interval"));
        serializedObject.ApplyModifiedProperties();
    }
}
#endif

このエディタ拡張があれば、ちゃーんと2つの変数がインスペクターからいじれるようになります。
なお、こちらのコードは純度100%のChatGPT産コードです。AIって凄いですねホント。
というわけで、私自身は全くエディタ拡張のことを理解しておりませんのでソコだけご留意を。

今回の記事は以上です。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?