Edited at

Unityの各種イベントをIObservable<T>に変換する

この記事は、これは『Unityゆるふわサマーアドベントカレンダー2019』の21日目の記事、『UIElementsでもUniRxを使いたい』の補足記事です。


Subjectを使ってIMGUIをIObservable化

単純にButtonのクリックイベントをIObservable化します。

する前はこんな感じ。

public class ImguiSampleScene : MonoBehaviour

{
bool previousPressed;

void OnGUI()
{
var currentPressed = GUILayout.Button(Text);
if (!currentPressed && previousPressed)
{
Debug.Log("Clicked!!")
}
previousPressed = currentPressed;
}
}

これだとボタンの数だけpreviousPressedとcurrentPressedが必要になってしまいます。

そこでラッピングします。

public class IMGUIButton : IDisposable

{
private Subject<Unit> onClick = new Subject<Unit>();
public IObservable<Unit> OnClickAsObservable() => onClick;

private string Text { get; set; }
private bool previousPressed;

public IMGUIButton(string text)
{
Text = text;
}

public void Dispose()
{
onClick.Dispose();
}

public void Draw()
{
var currentPressed = GUILayout.Button(Text);
if (!currentPressed && previousPressed)
{
onClick.OnNext(Unit.Default);
}
previousPressed = currentPressed;
}
}

これを使うと

public class IMGUISampleScene : MonoBehaviour

{
private IMGUIButton button;

private void Start()
{
button = new IMGUIButton("Button");
button.AddTo(this);

button.OnClickAsObservable().Subscribe(_ => Debug.Log("Clicked!!")).AddTo(this);
}

void OnGUI()
{
button.Draw();
}
}

こうできます。


uGUIのI***HandlerをIObservable化

通常だとこんな感じでインターフェイスを作ったものをuGUIに配置すると思います。

これもいいのですが、本来このInputというのはゲームのコントローラと一緒で1つでいいものです。

しかし、シチュエーションによって、例えばフィールドであればキャラを動かす、インベントリならアイテムを入れ替えたりする、など、同じイベントで異なる挙動をする場合、このクラス1つにそれらの処理が依存し、密になってしまう可能性があります。

public class InputPresenter : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerDownHandler, IPointerUpHandler, IPointerClickHandler

{
public void OnBeginDrag(PointerEventData eventData) { }
public void OnDrag(PointerEventData eventData) { /* フィールドならキャラ移動、インベントリならアイテム移動の処理 */ }
public void OnEndDrag(PointerEventData eventData) { }
public void OnPointerDown(PointerEventData eventData) { }
public void OnPointerUp(PointerEventData eventData) { }
public void OnPointerClick(PointerEventData eventData) { }
}

そこをIObservable化することで疎にしましょう。

public interface IInputEvents

{
IObservable<PointerEventData> OnDragBegin();
IObservable<PointerEventData> OnDrag();
IObservable<PointerEventData> OnDragEnd();
IObservable<PointerEventData> OnPressPointer();
IObservable<PointerEventData> OnReleasePointer();
IObservable<PointerEventData> OnClick();
}

まず欲しいイベント類をinterfaceで定義し、それをuGUIの方で実装します。

[RequireComponent(typeof(RaycastTarget))]

public class InputPresenter : MonoBehaviour, IInputEvents, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerDownHandler, IPointerUpHandler, IPointerClickHandler
{
public float ClickThrottleTime = 0.25f;
private float lastPointerDownTime;
private bool isDrag;

private Subject<PointerEventData> beginDrag = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnDragBegin() => beginDrag;

private Subject<PointerEventData> drag = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnDrag() => drag;

private Subject<PointerEventData> endDrag = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnDragEnd() => endDrag;

private Subject<PointerEventData> pointerDown = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnPressPointer() => pointerDown;

private Subject<PointerEventData> pointerUp = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnReleasePointer() => pointerUp;

private Subject<PointerEventData> pointerClick = new Subject<PointerEventData>();
public IObservable<PointerEventData> OnClick() => pointerClick;

public void OnDestroy()
{
this.beginDrag.Dispose();
this.drag.Dispose();
this.endDrag.Dispose();
this.pointerDown.Dispose();
this.pointerUp.Dispose();
this.pointerClick.Dispose();
}

public void OnBeginDrag(PointerEventData eventData)
{
beginDrag.OnNext(eventData);
}

public void OnDrag(PointerEventData eventData)
{
isDrag = true;
drag.OnNext(eventData);
}

public void OnEndDrag(PointerEventData eventData)
{
endDrag.OnNext(eventData);
}

public void OnPointerDown(PointerEventData eventData)
{
isDrag = false;
lastPointerDownTime = Time.realtimeSinceStartup;

pointerDown.OnNext(eventData);
}

public void OnPointerUp(PointerEventData eventData)
{
pointerUp.OnNext(eventData);
}

public void OnPointerClick(PointerEventData eventData)
{
// 長押しやドラッグがクリックと誤検知されるのを防ぐ
if (isDrag || Time.realtimeSinceStartup - lastPointerDownTime > ClickThrottleTime)
{
return;
}

pointerClick.OnNext(eventData);
}
}

これを使う場合は、

public class CharacterMovementModel

{
private Vector2 startPosition;

public CharacterMovementModel(IInputEvents input, CompositeDisposable gameObjectLifeTime)
{
var dragDirection = Vector2.zero;

// ドラッグで移動
input.OnDragBegin()
.Do(x => startPosition = x.position) // ドラッグ開始時に指の位置を始点として確保
.Select(_ => input.OnDrag()) // ドラッグ中に繋げる
.Switch()
.Subscribe(x => dragDirection = (x.position - startPosition).normalized) // ↑の始点との差で方向を取って、スティック入力風にする
.AddTo(gameObjectLifeTime);
input.OnDragEnd()
.Subscribe(x => dragDirection = Vector2.zero) // ドラッグ終了時にお掃除
.AddTo(gameObjectLifeTime);

// クリックでジャンプ
input.OnClick()
.Subscribe(x => Jump()) // 割愛
.AddTo(gameObjectLifeTime);
}
}

という感じで実装することができます。


UIElementsのManipulatorについて

調べたところ、Unity 2019.1.14f1では以下のEventBaseが継承された各種イベントがあるようです。

UnityEngine.UIElements.AttachToPanelEvent

UnityEngine.UIElements.BlurEvent
UnityEngine.UIElements.ChangeEvent<T>
UnityEngine.UIElements.CommandEventBase<T>
UnityEngine.UIElements.ContextClickEvent
UnityEngine.UIElements.ContextualMenuPopulateEvent
UnityEngine.UIElements.CustomStyleResolvedEvent
UnityEngine.UIElements.DetachFromPanelEvent
UnityEngine.UIElements.DragAndDropEventBase<T>
UnityEngine.UIElements.DragEnterEvent
UnityEngine.UIElements.DragExitedEvent
UnityEngine.UIElements.DragLeaveEvent
UnityEngine.UIElements.DragPerformEvent
UnityEngine.UIElements.DragUpdatedEvent
UnityEngine.UIElements.EventBase<T>
UnityEngine.UIElements.ExecuteCommandEvent
UnityEngine.UIElements.FocusEvent
UnityEngine.UIElements.FocusEventBase<T>
UnityEngine.UIElements.FocusInEvent
UnityEngine.UIElements.FocusOutEvent
UnityEngine.UIElements.GeometryChangedEvent
UnityEngine.UIElements.IMGUIEvent
UnityEngine.UIElements.InputEvent
UnityEngine.UIElements.KeyboardEventBase<T>
UnityEngine.UIElements.KeyDownEvent
UnityEngine.UIElements.KeyUpEvent
UnityEngine.UIElements.MouseCaptureEvent
UnityEngine.UIElements.MouseCaptureEventBase<T>
UnityEngine.UIElements.MouseCaptureOutEvent
UnityEngine.UIElements.MouseDownEvent
UnityEngine.UIElements.MouseEnterEvent
UnityEngine.UIElements.MouseEnterWindowEvent
UnityEngine.UIElements.MouseEventBase<T>
UnityEngine.UIElements.MouseLeaveEvent
UnityEngine.UIElements.MouseLeaveWindowEvent
UnityEngine.UIElements.MouseMoveEvent
UnityEngine.UIElements.MouseOutEvent
UnityEngine.UIElements.MouseOverEvent
UnityEngine.UIElements.MouseUpEvent
UnityEngine.UIElements.PanelChangedEventBase<T>
UnityEngine.UIElements.TooltipEvent
UnityEngine.UIElements.ValidateCommandEvent
UnityEngine.UIElements.WheelEvent

ふむむ、IMGUIEventなるものまで、あるんですねぇ…

前日の@kingyo222さんの『Unity:UI Elements でいくつかサンプル書いたよ!』によりますと、Manipulatorなるものがあり、これを継承してVisualElementにAddManipulatorするといい感じにイベントを制御できるそうです。

class MyManipulator : Manipulator

{
protected override void RegisterCallbacksOnTarget()
{
// EventBaseなものを登録するとそれが取れるらしい
target.RegisterCallback<ContextClickEvent>(OnMouseUpEvent);
}

protected override void UnregisterCallbacksFromTarget()
{
target.UnregisterCallback<ContextClickEvent>(OnMouseUpEvent);
}

void OnMouseUpEvent(EventBase<ContextClickEvent> evt)
{
UnityEngine.Debug.Log("Nya");
}
}

// 上のManipulatorをこんな感じで追加するとイベントが取れる

var button = labelFromUXML.Q<Button>("button");
button.AddManipulator(new MyManipulator());

個人的には素直にObservable.FromEventで以下のようにする手段の方が好きなんですが、個別にこのOnHogeAsObservableみたいなものを用意しないといけないので、それはそれで面倒です。

そういう意味では標準で用意されているManipulatorは使い勝手がいいと思います。

public static IObservable<Unit> OnClickAsObservable(this Button source)

{
return Observable.FromEvent(
h => h,
h => source.clickable.clicked += h,
h => source.clickable.clicked -= h);
}

//==

var button = labelFromUXML.Q<Button>("button");
button.OnClickAsObservable()
.Subscribe(_ => UnityEngine.Debug.Log("Nya"))
.AddTo(LifeTimeDisposable);


IManipulatorについて

基本イベントを取るだけであればManipulatorを継承しておけばいいと思いますが、AddManipulatorはIManipulatorを引数に取るので、本来はManipulatorより自由度が高いです。

public interface IManipulator

{
VisualElement target { get; set; }
}

というかんじで、Manipulator(操縦者)の通り、VisualElementをとりあえず操作するだけっぽいです。

UnityEditor上だとWindowが開かれたときにtargetのsetterが呼ばれるようになってます。

なので、UIアニメーションなど(がUIElements上でどう実装するのか、Animatorなのか、他なのかは置いといて)はこのManipulatorで操作してやるのかしら、という印象です。

class ScaleAnimation : IManipulator

{
private VisualElement _target;
public VisualElement target
{
get { return _target; } // getは素直に返しましょう
set
{
// Manipulatorの方はここでRegister/Unregisterを実装
this._target = value;
}
}

private float start;
private float end;
public ScaleAnimation(float start = 1f, float end = 1f) { // 割愛 }

public async UniTask PlayAsync() { // 割愛 }
}

// ==

var button = labelFromUXML.Q<Button>("button");
var scaleAnimation = new ScaleAnimation(1f, 2f);
button.AddManipulator(scaleAnimation);

await scaleAnimation.PlayAsync(); // 任意のタイミングでPlayできるし、終了も待てる

おそらく上記のようなやり方だとRxだとやりづらくて、イベントとは別に明確に棲み分けができるものだと思います。