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

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

シューティングやFPS等の射撃はもちろん、それ以外にも。
例えば、リスト状に並んだ項目から選択を行うとき....
>* 単押しすると カーソルが1項目だけ動く
>* 長押しすると カーソルが1項目だけ動いたのち、連射でカーソルが高速に動き始める

あるいは、アイテムを使う画面にて + / -ボタンで消費量を指定するとき....
>* 単押しすると 1だけ消費量が変動する
>* 長押しすると 1だけ消費量が変動したのち、連射で消費量が変動し始める

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

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

# コード全文
一旦ここにコード全文を置いておきます。
解説は下で、順を追ってやっていきます。
```c#: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秒間隔でイベントを通知、という処理を無限回行う（ボタン押下が終わるまで）
```
というものとして捉えました。
つまり。最初の "長押し" とその後の "連射" を**同じようなもの**として扱ってみたワケです。
それを踏まえて見ていきましょう。
## 連射を生み出す"コア"
```c#:RapidButton.cs
private RapidFire currentRapidFire;

private void Update()
{
    if (!isPressed) return;
    
    // 一定秒数以上カウントしたら発火
    currentRapidFire?.CountAndFire(Time.deltaTime);
}
```
言わずと知れたUpdate関数、毎フレーム呼ばれます。
`isPressed`というのはbool型の値で、名前の通り「今ボタンが押されている状態か」を表します（詳しくは後述）。
ボタンを押していない間は早期returnしているという、まあよくあるヤツですね。

ボタンを押している間は`RapidFire.CountAndFire`関数を毎フレーム呼びます。
その中身は以下の通り。
```c#: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 deltaTime`を`counter`に積んでいき、その値が`interval`を超えていたら`onRapidFire?.Invoke();`....すなわち**登録された処理を実行**します。

先ほどのコードで引数として渡されていた`Time.deltaTime`は**今フレームと前フレームの間の経過時間 [秒]** ですから
```c#
counter += deltaTime;
if (counter >= interval) onRapidFire?.Invoke();
```
ここはまさしく連射を生み出しているコアとも言うべき箇所ですね。
## ボタンを押した瞬間
で、そこで実行されている`onRapidFire`の中身というのはコンストラクタで渡されていますが、RapidFireのコンストラクタを呼びだしている箇所が...
```c#: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`関数が呼ばれます**。

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

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

コードを見ての通り、それは2箇所あります。
まず1箇所目は`OnPointerDown`関数内にて。`onRapidFire`には **`OnIntroHold`** 関数を渡しています。
```c#
public override void OnPointerDown(PointerEventData _)
{
    currentRapidFire = new RapidFire(firstInterval, OnIntroHold);
}
```
そして2箇所目は **`OnIntroHold`** 関数内にて。`onRapidFire`には **`OnSubsequentHold`** 関数を渡しています。
```c#
void OnIntroHold()
{
    currentRapidFire = new RapidFire(rapidInterval, OnSubsequentHold);
}
```
教授！これは......？
<br>
───すなわち、これは**マトリョーシカ方式でRapidFireをつくっている**わけです
<br>
改めて、最初に述べた大雑把な仕組みはこんな感じでした
```
n秒間隔でイベントを通知、という処理を1回だけ行う
↓
m秒間隔でイベントを通知、という処理を無限回行う（ボタン押下が終わるまで）
```
n秒というのが`firstInterval`で、
m秒というのが`rapidInterval`にあたります。

n秒間隔で呼ばれるUnityActionの中に「`currentRapidFire`をm秒間隔でイベント通知するものに更新する」という処理を渡しています。つまり`currentRapidFire`を更新する処理を`currentRapidFire`自身が呼ぶ構図になっています。
## ボタンを離した瞬間
`IPointerUpHandler`, `IPointerExitHandler`も、`IPointerDownHandler`同様に特別なインターフェースで、それぞれ
 * 「Buttonが押し上げられた（ボタンを離した）瞬間」にイベント通知する`OnPointerUp`
 * 「ポインター（マウスや指）がボタン範囲から退出した瞬間」にイベント通知する`OnPointerExit`

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

`isPressed`についての処理が出揃ったので一応まとめると、
 * ボタンが押し下げられたときはtrueになる
 * 押し下げていたのが離されるとfalseになる
 * ずっと長押ししていてもタッチしている点がボタン範囲を出るとfalseになる

ということになります。`isPressed`という名を体がしっかり表していますね。
## 継承元のUnitiyEventも一応使える
最後に一応、ここにも触れておきます。
```c#: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3780101/24fedf32-9b91-c6ea-10f2-bda24e382819.gif)

# おまけ　エディタ拡張
先ほどのコードに
```c#:RapidButton.cs
[SerializeField] private float firstInterval = 0.5f;
[SerializeField] private float rapidInterval = 0.1f;
```
という部分がありました。
「連射開始までの時間」と「連射間隔」をインスペクターから設定できるように`[SerializeField]`属性をつけているのですが....
....実はUnity標準のButtonを継承したこのクラスにて独自に定義したこれらの変数はそのままだと**インスペクターに表示されません**。

なんてこった。

というわけで、上で載せたコード全文の末尾に以下のコードを追加してください。
```c#: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
```
このエディタ拡張があれば、ちゃーんと２つの変数がインスペクターからいじれるようになります。
なお、こちらのコードは純度100%のChatGPT産コードです。AIって凄いですねホント。
というわけで、私自身は全くエディタ拡張のことを理解しておりませんのでソコだけご留意を。

今回の記事は以上です。