バトル中とかにキャラ固有の非同期な処理をObserverパターン的に差し込みたい(Unity, UniTask)

やりたいこと

ターン制の戦闘を作っていて、ターン終了時に敵キャラ固有の処理を挟みたかった。例えば敵が回復するとか、状態異常が直るとか、攻撃力が一段階上がるとか。

どうやるか

そのプロジェクトでターン終了時がいつかということを把握しているのは、ターンをぐるぐる回しているBattleManagerみたいなクラスだった。だからBattleManagerのいい感じの行に処理を挟めばいいことになる。

ただ、キャラ固有の事情はキャラ固有のクラスでよしなにしたいので、BattleManager内に直接固有の処理を書きたくはない。また、敵キャラ用のインターフェースにOnTurnEnd()のようなメソッドを生やすほど多くの敵キャラがターン終了時に特別な何かをするわけでもない気もした。*1

しかも似たようなことをターン終了時だけでなく開始時、バトル開始時、キャラの死亡時などいろいろなタイミングでやりたくなるだろうから、際限なくメソッドが増える予感がする。しかもそのほとんどが実際には何もしないようなメソッドになる。


ので、Observerパターンみたいなのでやろうとした。具体的には、まずBattleManagerにOnTurnEndみたいな名前のIObservableなフィールドを持たせる。それで、敵キャラのクラスがそれを購読していい感じにする。この場合敵キャラにBattleManagerを渡してやる必要はある*2

ただ、IObservableに渡すObserverの処理(OnNext)は同期関数で、今やりたい処理を同期関数で書くのは厳しい。 例えば回復するときはHPゲージが動いてほしいし、「回復した!」みたいなテキストが出てボタンか何か押してテキストを送ってから次のターンが始まってほしい。同期関数であるうちは、ゲージを動かし始めたりテキストを出したりするまではできるが、ゲージが動ききるまで待ったりテキストを送るまで待ったりする部分はできない。

やったこと

要はObserver側の非同期処理を待てるObservableのようなものがあればいいはずなので、例えば

using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UniRx;

public class AsyncEventsHolder : IAsyncEventsHolder
{
    readonly List<Func<UniTask>> events = new List<Func<UniTask>>();
    
    // Subscribeみたいなの
    public IDisposable Add(Func<UniTask> evt)
    {
        events.Add(evt);
        // Exec中にRemoveされたらやばい気がするが簡単のために……
        return Disposable.Create(() => events.Remove(evt));
    }

    // UniRx.SubjectのOnNextみたいなの
    public async UniTask Exec()
    {
        foreach (Func<UniTask> evt in events)
        {
            await evt.Invoke();
        }
    }
}

// IObservableみたいなの
public interface IAsyncEventsHolder
{
    IDisposable Add(Func<UniTask> evt);
}

こういうクラスを作って、

using Cysharp.Threading.Tasks;

public class BattleManager
{
    // なんやかんやある
    
    readonly AsyncEventsHolder onTurnEnd;
    public IAsyncEventsHolder OnTurnEnd => onTurnEnd;

    public async UniTask Turns()
    {
        while (true)
        {
            // 1ターンごとの処理がはいる

            await onTurnEnd.Exec();
        }
    }
}

BattleManagerにこんな感じで持たせて、

using System;

public class HogeEnemy
{
    public HogeEnemy(BattleManager battleManager)
    {
        IDisposable onTurnEndDisposable = battleManager.OnTurnEnd.Add(async () =>
        {
            // 回復
            // 「回復した!」とか出す
            // ↑のテキストが送られたイベントみたいなのをawaitする
        });
        
        // onTurnEndDisposableを良い感じのタイミングで破棄したい
    }
}

敵からはこういう感じで回復演出とかを差し込めばいい感じになりそう……?

おわりに

こういうの、それこそUniRxやUniTaskの機能として存在するかもしれないんですが見つけられず、似たようなことを考える人はいると思うのですがどう検索すれば出てくるかわからない(IObserverとか非同期とか組み合わせて検索しても(Uni)Rx一般の話しか出てこねぇ……)。似たようなのが書いてる記事とかライブラリとかあったら教えてほしいです。あと上記の実装の粗とかそもそもコンセプトから破綻してるとかあったら教えてほしいです。

*1:敵キャラ用のインターフェース一つにメソッドをいっぱい生やすこと自体好ましくなく、OnTurnEnd()だけを宣言したインターフェースを作るのがいい気はしているのですが、そうするとどこかでそのインターフェースを実装しているかどうかで分岐する必要が生じる気がして、それはいいのかどうかよくわからなくなっています

*2:実際にはIBattleManagerのようなインターフェースを噛ませるのが適切