序論
ゲームでしばしば長押しすると連射扱いになる操作ってありますよね。
シューティングやFPS等の射撃はもちろん、それ以外にも。
例えば、リスト状に並んだ項目から選択を行うとき....
- 単押しすると カーソルが1項目だけ動く
- 長押しすると カーソルが1項目だけ動いたのち、連射でカーソルが高速に動き始める
あるいは、アイテムを使う画面にて + / -ボタンで消費量を指定するとき....
- 単押しすると 1だけ消費量が変動する
- 長押しすると 1だけ消費量が変動したのち、連射で消費量が変動し始める
こういったメニューでの長押し連射というのもよく見ますよね。というか、むしろ今回はこっちを意識していたまであります。
今回はUnityで、そういった長押し連射を行うUI用Buttonをつくってみた!という記事です。
コード全文
一旦ここにコード全文を置いておきます。
解説は下で、順を追ってやっていきます。
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秒間隔でイベントを通知、という処理を無限回行う(ボタン押下が終わるまで)
というものとして捉えました。
つまり。最初の "長押し" とその後の "連射" を同じようなものとして扱ってみたワケです。
それを踏まえて見ていきましょう。
連射を生み出す"コア"
private RapidFire currentRapidFire;
private void Update()
{
if (!isPressed) return;
// 一定秒数以上カウントしたら発火
currentRapidFire?.CountAndFire(Time.deltaTime);
}
言わずと知れたUpdate関数、毎フレーム呼ばれます。
isPressed
というのはbool型の値で、名前の通り「今ボタンが押されている状態か」を表します(詳しくは後述)。
ボタンを押していない間は早期returnしているという、まあよくあるヤツですね。
ボタンを押している間はRapidFire.CountAndFire
関数を毎フレーム呼びます。
その中身は以下の通り。
/// <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
は今フレームと前フレームの間の経過時間 [秒] ですから
counter += deltaTime;
if (counter >= interval) onRapidFire?.Invoke();
ここはまさしく連射を生み出しているコアとも言うべき箇所ですね。
ボタンを押した瞬間
で、そこで実行されているonRapidFire
の中身というのはコンストラクタで渡されていますが、RapidFireのコンストラクタを呼びだしている箇所が...
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も一応使える
最後に一応、ここにも触れておきます。
// 一応、継承元の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に置き換えるのも全然アリだと思います。その辺はお好みで。
完成形
おまけ エディタ拡張
先ほどのコードに
[SerializeField] private float firstInterval = 0.5f;
[SerializeField] private float rapidInterval = 0.1f;
という部分がありました。
「連射開始までの時間」と「連射間隔」をインスペクターから設定できるように[SerializeField]
属性をつけているのですが....
....実はUnity標準のButtonを継承したこのクラスにて独自に定義したこれらの変数はそのままだとインスペクターに表示されません。
なんてこった。
というわけで、上で載せたコード全文の末尾に以下のコードを追加してください。
// 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って凄いですねホント。
というわけで、私自身は全くエディタ拡張のことを理解しておりませんのでソコだけご留意を。
今回の記事は以上です。