LoginSignup
78
64

More than 3 years have passed since last update.

格闘ゲームのコマンド判定をReactiveに書いてみた

Last updated at Posted at 2020-05-07

動機

Rxの解説などを見ると、だいたい「イベントのLinq化」「断続的なメッセージをストリームとして」「フィルタ、合成、変換といったオペレータを」などなどの文言が並びます。初見ではなかなか理解が難しいかも知れません。私もそうでした。
ただ、下手くそながらも格闘ゲームを愛する1人として、ピンと来るモノがありました。

これ、コマンドの入力判定に使えるんじゃね?

思い立ったらやってみよう!ということで、Rxを駆使してコマンド入力判定処理を作ってみます。

更新履歴

  • レバー1回転とタメ系のコマンドに対応しました(2020/5/11)

下ごしらえ

とりあえず、Windows環境でSharpDX + Reactive Extensionsという構成で作っていきます。Unityだったら、UniRxを使えば多分同じことはできるでしょう。

まずはゲームパッドの入力をObserveできるようにします。

    public class InputManager
    {
        public struct KeyInfo
        {
            public char Key;
            public bool State;
            public int Frame;
        }

        private readonly Subject<KeyInfo> _keyStream = new Subject<KeyInfo>();
        public IObservable<KeyInfo> KeyStream => this._keyStream.AsObservable();

        private int frame = 0;
    }

入力の変化をKeyStreamというIObservableに通知することにします。通知を行うSubjectはバッキングフィールドとしてprivateにしておき、外部にはAsObservable()してプロパティとして公開します。

通知する情報はKeyInfoという型で定義しました。どのキーの情報か(Key)、押されたのか離されたのか(State)、何フレーム目に発生したのか(Frame)の3つです。後で改修したくなりそうな気もしますが、とりあえずこれで行きましょう。

Rxでは通知した値に対してタイムスタンプを付ける機能がありますが、格闘ゲームではフレーム数の方が信頼できる情報だろうということで、自前で持たせるようにしています。フレーム数のオーバーフローについてはケアする必要がありそうですが、今回は割愛します。

    public class InputManager
    {
        // 前述したクラスのメンバは省略

        private Joystick pad;
        private JoystickState prevState = new JoystickState();

        public void Update()
        {
            if (this.pad == null) return;

            this.pad.Acquire();
            this.pad.Poll();

            var state = this.pad.GetCurrentState();
            if (state == null) return;

            if (state.X != this.prevState.X || state.Y != this.prevState.Y)
            {
                var value = 5;
                if (state.X > 300) value += 1;
                if (state.X < -300) value -= 1;
                if (state.Y < -300) value += 3;
                if (state.Y > 300) value -= 3;
                this._keyStream.OnNext(new KeyInfo
                {
                    Key = value.ToString()[0],
                    State = true,
                    Frame = this.frame,
                });
            }

            char[] buttonName = { 'A', 'B', 'C', 'D' };
            for (int i = 0; i < 4; ++i)
            {
                if (state.Buttons[i] != this.prevState.Buttons[i])
                {
                    this._keyStream.OnNext(new KeyInfo
                    {
                        Key = buttonName[i],
                        State = state.Buttons[i],
                        Frame = this.frame,
                    });
                }
            }

            this.prevState = state;
            ++this.frame;
        }
    }

JoyStickの初期化はコンストラクタあたりでやっておくとして、毎フレーム状態をポーリングしつつ、変化があったらKeyStreamに対して値を発行する処理はこんな感じです。イベントだったらInvokeしていたのがOnNextに変わるだけ、という理解でも最初は十分だと思います。

レバー入力について若干変なことをしていますが、これはいわゆるテンキー表記への変換をやっています。ニュートラルを5として、左右方向の入力があったら+/-1して4か6にし、上下方向の入力があったら+/-3して789か123にする、という処理です。これにより、上下左右が8246、斜め方向を1379で表現するという、古き良き格ゲー攻略サイトなどではお馴染みの記法になります。

:arrow_upper_left: :arrow_up: :arrow_upper_right: -> 7 8 9
:arrow_left: :record_button: :arrow_right: -> 4 5 6
:arrow_lower_left: :arrow_down: :arrow_lower_right: -> 1 2 3

ボタン数は、とりあえず4ボタンを想定しておきました。実用する際には、Unityなどにも存在するような、論理ボタンを介したアクセスになると思います。

これで入力の変化を外部からObserveする仕組みが整いました。

格闘ゲームのコマンドに関する要求仕様

コマンドの代表例として、ストリートファイターシリーズの必殺技「波動拳」を考えてみます。

:arrow_down::arrow_lower_right::arrow_right::fist:と表現されるコマンドですが、これを前述したテンキー表記に直すと 236+パンチ です。とりあえずAボタンがパンチボタンだとして 236A という連続的な入力を検出することを考えます。

素早く入力する

格闘ゲームのコマンドはのんびり入力しても受け付けてくれません。ある程度素早く入力する必要があります。じゃあどの程度よ?という話ですが、カプコンさんの情報によれば、方向キーの入力完成からボタンを押すまでには10フレーム(166ms)ほど猶予があるようです。これを参考にして、コマンドの各ステップの間隔は10フレーム以内ということにしましょう。
(ゲームによってはもっと猶予のあるもの、厳しいものがあります)

多少余計な入力も許容する

では入力の間隔をチェックしつつ、入力の順番が 236A で揃えばOKかというと、それだけでは厳しいゲームになってしまいます。
もう1つ、代表的なコマンドとして「昇龍拳」を考えてみます。

:arrow_right::arrow_down::arrow_lower_right::fist:というコマンドで、テンキー表記では 623P です。
格闘ゲーム初心者にとっては鬼門となりがちなこのコマンド、先ほどの波動拳とは違って、入力方向が連続的ではありません。
波動拳はぐるっと1/4回転すればいいのですが、昇龍拳は右(6)から下(2)に一気に持って行く必要があります。
この時、ニュートラル(5)や右下(3)を経由してしまったりして、綺麗に623とは行かないケースが多く出てきます。
プロゲーマーやいにしえからのゲーマーなら常に正確な入力ができるかもしれませんが、ただでさえ格闘ゲームのコマンド入力は敷居の高さの要因にされがちです。多少は判定を甘くしておいた方が良いと思います。

余計な入力を許容するにはどうすればいいかですが、厳密な連続一致を取らず、1ステップごとの要素が順番に現れるかで判定するという方針にすれば実現できそうです。これなら、6523・6323・6123などの入力も許容できることになります。
余計な入力がたくさん挟まった場合も許容されてしまうのでは、と心配になるかもしれませんが、そこは前述した10フレーム以内の制限でふるい落とされるはずですので、気にしないことにしましょう。

Rxのオペレータを駆使してコマンドオブザーバーを作る

ではコマンドオブザーバーを組み立てていきましょう。

1. 特定の入力のみを抽出する

KeyStreamにはレバーの方向変化とボタン全てのON/OFF情報が流れてきますので、特定の操作の変化だけ取り出します。こういうときはWhereです。

    private InputManager input = new InputManager();
---
    var observe2 = this.input.KeyStream.Where(k => k.Key == '2');

これで「下方向に入力が入った瞬間に通知が飛ぶ」observerができました。

2. 次の入力をマージする

次の入力は右下方向です。右下observerを作り、前のobserverとマージ(Merge)します。

    var observe3 = this.input.KeyStream.Where(k => k.Key == '3');
    var observe23 = observer2.Merge(observe3);

これで「下方向と右下方向に入力が入った瞬間に通知が飛ぶ」observerになりました。

3. 入力の順序と間隔をチェックする

IObservableは、飛んできた複数の通知をまとめて評価することができます。いくつか手段はありますが、今は2ステップの順序と間隔を調べたいので、Bufferを使って2つの通知を取り出して比較します。

    observe23 = observer23.Buffer(2, 1)  // 通知を1つずつスライドしながら2つ取り出す
                // 後続の値はIList<KeyInfo>になるので、添え字でアクセスできる
                .Where(b => b[1].Frame - b[0].Frame < 10         // 間隔をチェック
                         && b[0].Key == '2' && b[1].Key == '3')  // 順序をチェック
                // 上記のフィルタにより、間隔と順序が正しい場合のみ後続に通知が流れる
                .Select(b => b[1]);  // 以降も同様にチェーンできるように、2つめの入力情報のみを流す

メソッドチェーンを使って、ちょっとRxらしいコードになってきました。
Bufferの引数は(取り出す個数, 先頭をスライドする個数)なので、(2, 1)とすることで新しい通知が来るたびに1つ前の通知とセットで流れてくるようになります。Pairwiseでも同様のことができます。
Selectも多用するオペレータです。Bufferした時点で流れる値の型が IList<KeyInfo> になるため、そのままでは次の入力の IObservable<KeyInfo> がマージできません。そこで2つめの入力情報を単体で後続に流すようにし、次のステップも反復的に処理できるようにします。

4. コマンド文字列からobserverを生成できるようにする

上記の手順2,3を繰り返すことで、コマンドが完成した時に通知が飛んでくるobserverになります。
というわけで、出来上がった関数がこちら。

        public IObservable<KeyInfo> CreateCommandObserver(string command)
        {
            // 指定したキーの押し下げを通知するObserverを返す
            IObservable<KeyInfo> getInputObserver(char c)
            {
                return this.input.KeyStream.Where(k => k.Key == c && k.State);
            }

            var observer = getInputObserver(command[0]);
            for (int i = 1; i < command.Length; ++i)
            {
                var index = i;  // ループカウンタをラムダ式にキャプチャするために退避
                observer = observer
                    .Merge(getInputObserver(command[index]))
                    .Buffer(2, 1)
                    // 最初のレバー入力は入れっぱなしでも許容したいので、初回のみ間隔チェックはパス
                    .Where(b => index == 1 || b[1].Frame - b[0].Frame < 10)
                    .Where(b => b[0].Key == command[index - 1] && b[1].Key == command[index])
                    .Select(b => b[1]);
            }

            return observer;
        }

手順1~3の内容を手続き化したかたちになります。
1つ付け加えている処理として、コマンドの最初の方向に入れっぱなしの状態から残りのコマンドを入力するケースを許容するため、最初のステップは間隔チェックを無条件でパスするようにしています。これにより、しゃがみっぱなしの状態から波動拳、前に歩いてから昇龍拳、という状況に対応できます。

5. コマンドオブザーバーをじゃんじゃん作って使う

            var hadohken =this.CreateCommandObserver("236A")
                .Do(_ => Debug.WriteLine("波動拳!"))
                .Subscribe();
            var shoryuken = this.CreateCommandObserver("623C")
                .Do(_ => Debug.WriteLine("昇龍拳!"))
                .Subscribe();
            var tatsumaki = this.CreateCommandObserver("214B")
                .Do(_ => Debug.WriteLine("竜巻旋風脚!"))
                .Subscribe();
            var shinkuhadoh = this.CreateCommandObserver("236236C")
                .Do(_ => Debug.WriteLine("真空波動拳!"))
                .Subscribe();

            var yaotome = this.CreateCommandObserver("2363214C")
                .Do(_ => Debug.WriteLine("遊びは終わりだ!"))
                .Subscribe();
            var powergazer = this.CreateCommandObserver("21416C")
                .Do(_ => Debug.WriteLine("パワゲイザーッ!"))
                .Subscribe();
            var razingstorm = this.CreateCommandObserver("1632143C")
                .Do(_ => Debug.WriteLine("レイジングストォーム!"))
                .Subscribe();
            var jigokugokuraku = this.CreateCommandObserver("6321463214C")
                .Do(_ => Debug.WriteLine("チョーシこいてんじゃねぇぞコラァ!"))
                .Subscribe();

楽ちんでいいですね!やはりRxで出来るんじゃないか?という直感は正しかったのだ……。
Doは流れてきた通知の値はスルーしつつ記述した処理を実行するためのオペレータです。デバッグ時のログ出力でよく用いられます。

実用の際はSubscribeに渡す関数で技のトリガーを引くような処理を書くと思います。引数には最後のキー情報が送られてくるので、コマンド成立時のフレーム時間を取得して利用することもできます。

Rxは実行コンテキストが不明瞭になりがちですが、普通にSubscribeする限りでは、実行スレッドはInputManager.Update()と同じになります。安心してご利用ください。

またSubscribeの返り値は、判定が不要になったら必ずDisposeして、通知の購読を解除する必要がありますので、お忘れ無くお願いします。

注意点として、重複するコマンドの優先順位などは、ハンドリングする側でよしなにしてやる必要があります。
真空波動拳は内部に波動拳と昇龍拳を内包しているので、優先度を高くする必要があります。
また、昇龍拳の勢いが良すぎて 6236 となってしまうことは良くあるため、波動拳より優先度が上になっているゲームが多いように見えます。
(そうなると前歩きからの波動拳が漏れなく昇龍拳になるので、それはそれでストレスかも……ここらへんは要調整ですね)

落ち穂拾い

ボタン順番押し系は行ける?

いわゆる瞬獄殺・ダークネスイリュージョン系のコマンドです。
基本的な考え方は同じで行けますが、CreateCommandObserverにいくつか手直しが必要です。

        public IObservable<KeyInfo> CreateCommandObserver(string command)
        {
            IObservable<KeyInfo> inputObserverSelector(char c)
                => this.input.KeyStream.Where(k => k.Key == c && k.State);

            // 方向キーを識別するための関数
            bool isDirection(char c) => return '1' <= c && c <= '9';

            var observer = inputObserverSelector(command[0]);
            for (int i = 1; i < command.Length; ++i)
            {
                var index = i;
                // 同じ入力が連続する場合、マージしてしまうと通知が重複して飛んでくるのを回避
                if (command[index] != command[index - 1])
                {
                    observer = observer.Merge(inputObserverSelector(command[index]));
                }

                observer = observer
                    .Buffer(2, 1)
                    // 最初の入力がボタンの場合は間隔チェックが必要
                    .Where(b => (index == 1 && isDirection(command[0]))
                             || b[1].Frame - b[0].Frame < 10)
                    .Where(b => b[0].Key == command[index - 1]
                             && b[1].Key == command[index])
                    .Select(b => b[1]);
            }

            return observer;
        }

これでOKです。ただ、レバー入力主体のコマンドに比べて、間隔の猶予が10フレームだと若干厳しいかもしれないです。

            var shungoku = CreateCommandObserver("AA6BC")
                .Do(_ => Debug.WriteLine("一瞬千撃!"))
                .Subscribe();

同時押しは?

Rxの得意とするところです!ABCボタンの同時押しを判定してみます。

            var hatsudo = this.input.KeyStream
                .Where(k => (k.Key == 'A' || k.Key == 'B' || k.Key == 'C') && k.State)
                .Buffer(3, 1)
                .Where(b => b.Max(k => k.Frame) - b.Min(k => k.Frame) < 2)
                .Select(b => b.Select(k => k.Key))
                .Where(a => a.Contains('A') && a.Contains('B') && a.Contains('C'))
                .Do(_ => Debug.WriteLine("じゃきーん!"))
                .Subscribe();

同時押しを判定したいキーのみが通過するフィルタをWhereで作り、
Bufferで3つ取り出し、
ボタンが押されたフレームの最大値-最小値が規定フレーム(2)以内かどうかでフィルタし、
キー情報のみの配列に変換し、
対象とするキーが全て含まれているかを判定して、
以下判定時の処理、という具合です。

RxとLinqの真骨頂ですね。同時押し猶予フレームを手軽に制御できるのも良い感じです。

連打系は?

Rxの得意とするところです!(その2)

            var hyakuretu = this.input.KeyStream
                .Where(k => k.Key == 'D' && k.State)
                .Buffer(4, 1)
                .Where(b => b[3].Frame - b[0].Frame < 30)
                .Do(_ => Debug.WriteLine("やっやっやっ"))
                .Subscribe();

30フレーム以内に4回以上連打して発動、であれば上記のようになります。
Buffer(4, 1)でボタン押し下げ4回分の通知を取り出し、先頭と末尾のフレーム差分が規定の時間内なら発動です。
これで書けるのは強いかと思います。

左右反転は?

キャラの方向が入れ替わるたびにObserverを作り直すのは効率悪いので……

  • 左右両方向用のObserverを作って使い分ける
  • キャラ方向に応じて入力方向が入れ替わるKeyStreamを作って、それからObserverを作る

のどちらかが良さそうです。後者の方が効率良さそうですね。

簡易入力のサポートは?

例えば41236を想定したコマンドを、426や16とすることで対応できますが、やりすぎると暴発しまくるので注意が必要です。
あるいは、上下左右(8246)の許容範囲を789,123,741,963にまで広げる(いわゆる左要素、下要素などでOKとする)ことで、やりやすくなるかもしれませんが、機械的に適用していいかどうかは、ゲーム性にもよるかと思われます。

タメコマンドは?レバー1回転は?

これまでの実装に拡張が必要なので、次節でまとめて解説します。

タメと1回転への対応

InputManagerに拡張が必要なため、先に追加の下ごしらえを済ませます。

追加の下ごしらえ

KeyStreamには方向とボタンの入力が混ざっているため、このままでは「方向キーのみを取り出したい」といった場合の条件式が若干書きづらいです。また、方向のタメ入力では「左要素」「下要素」のように、7 or 4 or 1や1 or 2 or 3といった複数方向の入力を許容するケースが出てきます。

それともう1点、現在ボタンを離した通知も飛んできますが、この時に「何フレーム継続して押されていたか」を含められると、タメ押し系のコマンドが楽に作れそうです。

そこで、KeyInfo型を次のように拡張します。

    public struct KeyInfo
    {
        public char Key;
        public int Frame;
        // 新たに持続フレームを保持するDurationを追加
        public int Duration;
        // これまでのStateは意味を変えず、0ならば押し下げ通知、1以上なら離し通知として扱う
        public bool State => Duration == 0;
        // 方向の入力であることを識別可能なプロパティを追加
        public bool IsDirection => '1' <= Key && Key <= '9';
        // 上下左右方向の要素を持つかを示すメソッドを追加
        public bool HasAttribute(char c)
        {
            switch (c)
            {
                case '8':
                    return Key == '7' || Key == '8' || Key == '9';
                case '2':
                    return Key == '1' || Key == '2' || Key == '3';
                case '4':
                    return Key == '7' || Key == '4' || Key == '1';
                case '6':
                    return Key == '9' || Key == '6' || Key == '3';
            }

            // 厳密なエラーチェックは割愛
            return false;
        }
    }

更にInputManagerを次のように拡張します。

    public class InputManager
    {
        // 追加と修正のあるメンバ以外は省略

        // 方向キーとボタンの押し下げ持続時間を保持するフィールド
        private int directionDuration = 0;
        private int[] buttonsDuration = new int[4];

        // テンキー表記変換をメソッド化
        private static int ToDirection(int x, int y)
        {
            var value = 5;
            if (x > 300) value += 1;
            if (x < -300) value -= 1;
            if (y < -300) value += 3;
            if (y > 300) value -= 3;
            return value;
        }

        public void Update()
        {
            if (this.pad == null) return;

            this.pad.Acquire();
            this.pad.Poll();

            var state = this.pad.GetCurrentState();
            if (state == null) return;

            if (state.X != this.prevState.X || state.Y != this.prevState.Y)
            {
                // 方向切り替わり時に持続フレームを通知
                this._keyStream.OnNext(new KeyInfo
                {
                    Key = ToDirection(this.prevState.X, this.prevState.Y).ToString()[0],
                    Duration = this.directionDuration,
                    Frame = this.frame,
                });
                // 持続フレームをリセットしつつ新たな方向を押し下げ通知
                this._keyStream.OnNext(new KeyInfo
                {
                    Key = ToDirection(state.X, state.Y).ToString()[0],
                    Duration = this.directionDuration = 0,
                    Frame = this.frame,
                });
            }
            else
            {
                // 方向に変化がなかった場合は持続フレームをインクリメント
                ++this.directionDuration;
            }

            char[] buttonName = { 'A', 'B', 'C', 'D' };
            for (int i = 0; i < 4; ++i)
            {
                if (state.Buttons[i] != this.prevState.Buttons[i])
                {
                    // 押し下げ時は0を、離し時は持続フレームを通知
                    this._keyStream.OnNext(new KeyInfo
                    {
                        Key = buttonName[i],
                        Duration = state.Buttons[i] ? 0 : this.buttonsDuration[i],
                        Frame = this.frame,
                    });
                    this.buttonsDuration[i] = 0;
                }

                if (state.Buttons[i])
                {
                    // 押し下げ継続時は持続フレームをインクリメント
                    ++this.buttonsDuration[i];
                }
            }

            this.prevState = state;
            ++this.frame;
        }
    }

これでタメとレバー1回転への対応準備ができました。

レバー1回転

では早速書いてみましょう。若干ゴリ押し感ありますが。

            const string clockWise = "89632147896321478";
            const string counterClockWise = "87412369874123698";
            var screw = this.input.KeyStream
                .Where(k => k.IsDirection && k.State)
                .Buffer(6, 1)
                .Where(b => b[5].Frame - b[1].Frame < 50)
                .Where(b =>
                {
                    var s = b.Select(k => k.Key.ToString()).Aggregate((a, c) => a + c);
                    return clockWise.Contains(s) || counterClockWise.Contains(s);
                })
                .Select(b => b.Last())
                .Merge(this.input.KeyStream.Where(k => k.Key == 'C' && k.State))
                .Buffer(2, 1)
                .Where(b => b.Last().Frame - b.First().Frame < 10)
                .Where(b => b.First().IsDirection && b.Last().Key == 'C')
                .Select(b => b.Last())
                .Do(_ => Debug.WriteLine("フンッ!どりゃあ!"))
                .Subscribe().AddTo(_cd);

まず、方向キーの押し下げのみを取り出すために Where(k => k.IsDirection && k.State) でフィルタします。

そして判定基準ですが、昨今のタイトルでは「5/8回転で1回転とみなす」という若干甘めの設定が多いようなので、それを採用します。テンキー表記だと6個分の入力になるので、 Buffer(6, 1) で取り出します。

入力間隔は、最初の方向は入れっぱなしでも良いことにして、2番目から最後の入力までがそれぞれ10フレーム以内ならOKというのを、まとめて50フレーム以内として判定しています。

次に、6個分の入力が連続した方向変化(時計回り or 反時計回り)かどうかを判断する方法として、方向を表す文字を連結して履歴を文字列化し、それが時計回りか反時計回りを表す文字列に含まれるかどうか、という判定を行っています。若干雑な気もしますが、お手軽に書けることを重視しました。

後は通常のコマンドと同じく、最後の入力を Select して後ろに流し、ボタンの入力をマージして、Buffer(2, 1) で間隔と順序をチェックして、パスしたらコマンド成立として扱います。Bufferでまとめた配列に対しては、添え字でアクセスしてもいいですが、先頭と末尾であることを明示するならFirstLastを使った方が、配列の長さに影響されなくて良いかもしれません。

タメ

タメ技といった場合、いわゆる :arrow_left:タメ:arrow_right::fist::arrow_down:タメ:arrow_up::footprints: などのコマンドが代表的ですが、ボタンを長押しして離した時に発動するコマンドも存在します。ここではその両方を扱います。

まずは簡単な方から。ボタン2つの同時押しの後、どちらかを離した時に発動するコマンドの例です。

            var turnpunch = this.input.KeyStream
                .Where(k => (k.Key == 'A' || k.Key == 'C') && k.State)
                .Buffer(2, 1)
                .Where(b => b.Max(k => k.Frame) - b.Min(k => k.Frame) < 2)
                .Where(b =>
                {
                    var a = b.Select(k => k.Key);
                    return a.Contains('A') && a.Contains('C');
                })
                .Select(b => b.Last())
                .Merge(this.input.KeyStream.Where(k => (k.Key == 'A' || k.Key == 'C') && !k.State))
                .Buffer(2, 1)
                .Where(b => b.Last().Frame - b.First().Frame > 60)
                .Where(b => b.First().State && !b.Last().State)
                .Select(b => b.Last())
                .Do(k => Debug.WriteLine(k.Duration + "フレームためたターンパンチ!"))
                .Subscribe().AddTo(_cd);

同時押しの押し下げ判定までは前述の通りですが、その後マージしている条件式が !k.State で離し通知でフィルタするようになっています。
更に、先んじて KeyInfo を拡張しておいたおかげで、k.Duration で何フレーム継続して押されていたかを取得することができます。これを使って、技の発動時に段階的に威力を変える、といったことが実現できます。

では最後に、レバーのタメを使ったコマンドです。4タメ6Aを判定してみます。

            var sonic = this.input.KeyStream
                .Where(k => k.HasAttribute('4') && k.State)
                .Take(1)
                .Concat(this.input.KeyStream.Where(k => !k.HasAttribute('4') && k.State).Take(1))
                .Buffer(2, 1)
                .Repeat()
                .Where(b => b.Last().Frame - b.First().Frame >= 40)
                .Select(b => b.Last().Key == '6'
                                ? Observable.Return(b.Last())
                                : this.input.KeyStream.Where(
                                    kk => kk.Key == '6' && kk.State && kk.Frame - b.Last().Frame < 10))
                .Switch()
                .Merge(this.input.KeyStream.Where(k => k.Key == 'A' && k.State))
                .Buffer(2, 1)
                .Where(b => b.Last().Frame - b.First().Frame < 10)
                .Where(b => b.First().IsDirection && b.Last().Key == 'A')
                .Select(b => b.Last())
                .Do(_ => Debug.WriteLine("ソニックブーム!"))
                .Subscribe().AddTo(_cd);

最後にもってきただけあって、なかなか複雑なことになっています。紐解いていきましょう。

方向変化でも持続フレームを取れるようにしましたが、やっかいなことに一般的な方向タメでは:arrow_left:と表記されていたら:arrow_upper_left::arrow_lower_left:に入っていたフレームもカウントします。当初はInputManager内で、そういった要素ごとの持続フレームを持つことを考えましたが、あまりInputManagerにゲーム固有の仕様を意識させるのもうまくありません。Rxのオペレータで書き切る方針で考えます。

やりたいことは「左要素を含む最初の通知を保持しつつ(その後左要素の方向をガシャガシャしたとしても無視して)左要素以外に入力が移った際の通知を捕まえて、タメ時間を判定できる」ところに持ち込むことです。要素単位でのキー指定には、以前に拡張したHasAttribute()を使って条件をシンプルに記述します。

最初の入力のみを受け取るのはTake(1)を付けることで実現できます。これを行うと、通知を受け取った時点でストリームはいったん「完了」状態となります。Mergeは稼働中のストリームに別のストリームを合成するオペレータでしたが、完了したストリームにはConcatで後続のストリームをつなげることができます。ここで左要素以外の初回の入力を受け取り、いつものBuffer(2, 1)でまとめます。このままだと、1回のSubscribeで一度しかこのペアを受け取ることができませんので、Repeatでこの流れを繰り返すように指示します。

後はタメ時間を判定して、次のレバー入力の判定……といきたいのですが、タメを解除した方向がぴったり次のコマンドの方向だった場合と、そうでない場合があり得るのをケアしないといけません。!k.HasAttribute('4')は 8,5,2,9,6,3 のいずれかで成立します。6 ならばその入力情報をそのままもう一度流し、そうでなければ 6 を待つストリームからの通知を待つようにする。これを実現するのが、Select内での3項演算子による分岐と、その後ろにあるSwitchの組み合わせです。

Selectは値を変換して後続にパスするだけでなく、受け取った値を使って生成したIObservable自体を後続に投げる使い方もあります。その場合、Selectの後ろには引数を取らないMergeSwitchを置いて合成を行うのがお決まりのパターンです。特にSelect&Mergeの組み合わせはSelectManyと同様の効果を持ちます。

最後の A ボタン入力待ちは、通常のコマンドと同様でOKです。コマンドオブザーバーのファクトリ関数を拡張する場合は、タメの後の方向までを1セットで扱うと、反復的に処理できて良さそうですね。

おわりに

かなり大更新になってしまいましたが、コードをきれいにしたらGitHubあたりで公開することも考えています。そうしたらまたここでお知らせします。

また、どなたかこの記事を参考に格闘ゲーム作ったらご一報ください。遊びに行きます。
Reactive Fighters 2020、お待ちしております。

78
64
1

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
78
64