🥊

格闘ゲームのコマンド検出器のモノイドとしての構成

に公開

はじめに

前回の記事にて、Haskell-Copilotで基本的な時相を扱う関数群を定義しました。今回はこれらを使用してストリートファイターなどの格闘ゲームのコマンド検出器を、モノイドとして定義してその合成から各々のコマンド実装を行いました。モノイドとすることで、コマンド検出器にモジュラリティを与えることとなります。以降で書く真空波動拳がモジュラリティを活かした良い例です。

モノイドとは

このwikipediaページにあるとおりです。雑に言えば「扱うオブジェクト型1つ」と「そのオブジェクト型どうしを合成する演算1つ」と「合成しても意味がないゼロのようなオブジェクト」が定義された演算システムです。「整数と整数の足し算」「整数と整数の掛け算」などもモノイドです。

この記事では、「Aボタンを押すコマンド検出器」と「Bボタンを押すコマンド検出器」のオブジェクトと、「コマンド検出器を足し合わせる合成」を定義して、「ABボタンを押すコマンド検出器」を作れるように代数的な定義をして行きます。これにより、コマンドを足し合わせて新たなコマンドを作るということが平易なコードで実現できます。

開発環境

前回はマイコンと電子回路で説明用のシステムを作りましたが、今回は汎用PCとUSBのゲームパッドを使用しました。

amazonリンク

名前 バージョン
OS Ubuntu 20.04
CPU Intel(R) Core(TM) i5-8400 CPU @ 2.80GHz
ghcup 0.1.30.0
stack 3.3.1

C言語側インターフェイス

SDLでゲームパッドの各種ボタンの押し離しの瞬間にTrueになるboolの更新と、検出時の文字列表示関数、時相部の反映呼び出し程度を用意する必要があります。

インターフェースとなるグローバル変数の一部です。

bool btnUp_on = false, btnUp_off = false;
bool btnDown_on = false, btnDown_off = false;
bool btnLeft_on = false, btnLeft_off = false;
bool btnRight_on = false, btnRight_off = false;

Copilotが生成するCコードは以下でincludeできます。C側からstep()を呼び出すことで、Copilotで定義したStreamの処理が進みます。

#include "copilot_cords.h"

以下がmain内の上記bool値更新ループです。

    printf("started!\n");
    SDL_Event event;
    while (true) {
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) goto end;
            if (event.type == SDL_CONTROLLERBUTTONDOWN || event.type == SDL_CONTROLLERBUTTONUP)
                handleButtonEvent(event.cbutton);
        }

        step();

        resetFlags();  // Reset all one-shot flags
        SDL_Delay(10);
    }

Copilot側で以下のように書くと、

    trigger "syun_goku_satsu" (applyCommand syunGokuSatsu') []

条件成立時にC言語側に書いた以下が呼ばれます。

void syun_goku_satsu() {
    printf("Syun Goku Satsu!\n");
}

その他は以下のGitHubリンクのmain.cppを参照されてください。

GitHubリンク

https://github.com/yosukeueda33/game_command_detector

以下で実行までされます。

stack build
stack run
make
./gamepad_input

SDLが必要です。Ubuntuなら以下で行けるはず。

sudo apt-get install libsdl2-dev

単一コマンド検出器

以下は今回のモノイドの「扱うオブジェクト型」としての検出器の論理回路表現図です。図中のonceなどの定義は前回の記事をご参考ください。オブジェクトと言いつつsrの2つの引数がある関数ですが、条件を満たせば関数ですらモノイドのオブジェクトとできるのです。このブロック単一の挙動は、前段のコマンド成立後に当段の要求コマンドが満たされた場合に猶予期間中ONになるというものです。sが前段の成立/不成立、rが以降のコマンド全成立時のリセット入力、wantedが当段で要求する入力、cancelが当段で検出したら強制OFFとしたい入力(以降、反コマンド)です。

これは以下のようにcopilotのコードとして表現します。lenは当段成立時のON猶予期間です。

data Command = Command (Stream Bool -> Stream Bool -> Stream Bool)

genCommand :: Stream Int32 -> Stream Bool -> Stream Bool -> Command
genCommand len wanted cancel = Command $ \s r ->
    let
      o = [False] ++ o'
      ps = sP s
      o' = srsFF (ps && wanted) (  r
                               || cancel
                               || (oneShotFall . once' len $ ps && wanted)
                               || ((sP o) && wanted)) 
    in o

以下のようにしてAを押す(btnAOn)単一コマンドを作成できます。50ステップ=当例では0.5秒を猶予期間としています。この結果は上記の通りs,rを引数とするCommand関数オブジェクトとなります。elseOnButtonsOr "A"は反コマンドであり、btnAOn以外のON入力すべてのORを表しています。

genCommand 50 btnAOn (elseOnButtonsOr "A")

なお、elseOnButtonsOrの定義は以下です。あまり好きでは無い書き方です。

elseOnButtonsOr
onButtons = [ ("A", btnAOn)
            , ("B", btnBOn)
            , ("X", btnXOn)
            , ("Y", btnYOn)
            , ("Up", btnUpOn)    
            , ("Down", btnDownOn)  
            , ("Left", btnLeftOn)
            , ("Right", btnRightOn)
            ]

elseOnButtonsOr :: String -> Stream Bool
elseOnButtonsOr n = P.foldl1 (||) . P.map snd $ P.filter (\(t, x) -> n P./= t) onButtons

二項演算の定義

例えば、Aを押した後Bというコマンドを作りたい場合は上記のCommandをAとB用に作った後、以下のように接続します。

この"接続"をモノイドにおける"合成"として以下のように二項演算<>として定義しました。
※実はモノイドの前提の半群(Semi Group)の演算子として定義しています。

(Command c1) <> (Command c2) = Command $ \s r -> c2 (sP $ c1 s r) r

この<>を使用することでAを押した後B及びAを押した後B、そのあとAは以下のように表現できます。abaにてabを再利用できています。

ab, aba :: Command
ab = genCommand 50 btnAOn (elseOnButtonsOr "A")
     <> genCommand 50 btnBOn (elseOnButtonsOr "B")

aba = ab <> genCommand 50 btnAOn (elseOnButtonsOr "A")

Commandのままですと、合成後のsrが無入力のままですので、以下のようにapplyCommandでターミネータ的に埋めます。

applyCommand :: Command -> Stream Bool
applyCommand (Command cmd) = oneShotRise r
    where r = [False] ++ cmd true (sP r)

これにより以下で、Aを押した後B及びAを押した後B、そのあとAに対応するStream Boolができます。

applyCommand ab
applyCommand aba

abaはabを含んでいるため、abaを目的にコマンドを入力してしまうとabが意図せず検出されてしまうことがわかります。この問題の解決方法については後述します。
https://youtu.be/ewwHIomud-Q?t=14

各種コマンドの定義

瞬獄殺

今回はY > Y > 右 > B > Xとしました。syun_goku_satsuはC言語側でprintfするように関数定義しています。

syunGokuSatsu :: Command
syunGokuSatsu =    genCommand 50 btnYOn (elseOnButtonsOr "Y")
                <> genCommand 50 btnYOn (elseOnButtonsOr "Y")
                <> genCommand 50 btnRightOn (elseOnButtonsOr "Right")
                <> genCommand 50 btnBOn (elseOnButtonsOr "B")
                <> genCommand 50 btnXOn (elseOnButtonsOr "X")

trigger "syun_goku_satsu" (applyCommand syunGokuSatsu) []

以下のようにも定義できます。mconcatはモノイドを要素とするリストを全合成する関数です。以降のコマンドもmconcatで表現可能ですが見た目がすっきりするわけでは無いので<>をつかいます。

syunGokuSatsu' =  mconcat [ genCommand 50 btnYOn (elseOnButtonsOr "Y")
                          , genCommand 50 btnYOn (elseOnButtonsOr "Y")
                          , genCommand 50 btnRightOn (elseOnButtonsOr "Right")
                          , genCommand 50 btnBOn (elseOnButtonsOr "B")
                          , genCommand 50 btnXOn (elseOnButtonsOr "X")
                          ]

https://youtu.be/ewwHIomud-Q?t=20

以下はBを1つ余計に押しています。きちんと退けて居ることがわかります。
https://youtu.be/ewwHIomud-Q?t=87

波動拳

おなじみ下 > 右下 > 右 > 弱Pですが、今回は以下のように下 > 右 > 下離し > Yとして実装しました。

elseOnAllOr :: Stream Bool -- なにかボタンが押された瞬間にON
elseOnAllOr = P.foldl1 (||) $ P.map snd onButtons

downToRight :: Command
downToRight =  genCommand 50 btnDownOn (elseOnButtonsOr "Down")
            <> genCommand 50 btnRightOn (elseOnButtonsOr "Right")
            <> genCommand 50 btnDownOff elseOnAllOr
haDoKen :: Command
haDoKen = downToRight
          <> genCommand 50 btnYOn (elseOnButtonsOr "Y")

let skhk = applyCommand shinKuHaDoKen
trigger "ha_do_ken" ((applyCommand haDoKen) && (not skhk)) []

一旦downToRightとして定義して合成することで、以下の真空波動拳で再利用しています。真空波動拳中には波動拳のコマンドも含んでしまっているので、applyCommand後に&& notとすることで、真空波動拳時の同時発動を防いでいます。

https://youtu.be/ewwHIomud-Q?t=29

真空波動拳

下 > 右下 > 右 > 下 > 右下 > 右 > 弱Pは上記の波動拳で定義したdownToRightを再利用し、以下のように定義しました。

shinKuHaDoKen  :: Command
shinKuHaDoKen =  downToRight
              <> downToRight
              <> genCommand 50 btnYOn (elseOnButtonsOr "Y")
let skhk = applyCommand shinKuHaDoKen
trigger "shin_ku_ha_do_ken" (applyCommand shinKuHaDoKen) []

https://youtu.be/ewwHIomud-Q?t=32

真空波動拳成功後は気を抜いてコマンドを入力しています。なかなか成功しづらいことが分かるかと思います。

同時押しのモノイド

上記までのコマンド列の各構成要素は押された瞬間のみ見る必要があったのですが、巴投げなどは左 > [YB同時]です。この場合、各ボタンが押され続けているという状態を検知しANDを取る必要があり、この構成要素もモノイドとみなせます。単一の同時押し、矛盾しているようですが要は1つのボタンを押している間ONになる要素Simul及びそれらの合成(ただのAND)は以下のように表せます。
※この構造だとYを予めおして左 > Bでも動いてしまうため改良が必要です。多分SRs-FFのrに追加するだけ。いつか書く。

data Simul = Simul (Stream Bool)

instance Semigroup Simul where
    (Simul c1) <> (Simul c2) = Simul $ c1 && c2

instance Monoid Simul where
    mempty = Simul true

genSimul :: Stream Bool -> Stream Bool -> Simul
genSimul on off = Simul $ srsFF on off

また、applyCommand同様にStream BoolにするためのapplySimulは以下です。

applySimul :: Simul -> Stream Bool
applySimul (Simul s) = oneShotRise s

同時押し含むコマンドの定義

巴投げ

以下のようになります。wPKがYとB同時押しを表しています。

tomoeNage :: Command
tomoeNage = genCommand 50 btnLeftOn (btnUpOn || btnDownOn || btnRightOn)
            <> genCommand 50 wPK (btnXOn || btnAOn)
    where
        wPK = applySimul $  genSimul btnYOn btnYOff
                         <> genSimul btnBOn btnBOff 

trigger "tomoe_nage" (applyCommand tomoeNage) []

https://youtu.be/ewwHIomud-Q?t=49

内部に別コマンドを挟んでしまうコマンド

真空波動拳の場合は末尾に波動拳のコマンドを含んでおり以下のように真空波動拳発動時は波動拳のキャンセルが必要でした。

let skhk = applyCommand shinKuHaDoKen
trigger "shin_ku_ha_do_ken" skhk []
trigger "ha_do_ken" ((applyCommand haDoKen) && (not skhk)) []

では末尾ではなく内部ではどうでしょうか?仮にAAAAAAAAAAAAAAというコマンドはどうでしょうか。AAAAAを押すために3回押した瞬間にAAAが反応します。AAAAAAも同様です。これをやるためには、AAA及びAAAAA後に猶予時間を設け、その時間内に新たなボタンが押されたらAAAをキャンセルということをしないといけません。猶予時間で入力がなければやっとAAAのコマンドを発動するわけです。これには先述のapplyCommandを以下のように修正する必要があります。

以下がコードです。

applyCommand' :: Stream Int32 -> Stream Bool -> Command -> Stream Bool
applyCommand' len cancel (Command cmd) = o''
    where
        o = [False] ++ cmd true (sP o)
        o' = oneShotRise o
        o'' = oneShotRise
            . alwaysBeen' len
            $ srsFF o' (cancel || (oneShotFall $ once' (len+1) o'))

以下とすることで、AAAAAAAAAAAAAAで同時発動してしまうことはなくなります。
なお、stimes nはモノイド(正確には半群)をn回繰り返す関数です。

cmdA = genCommand 30 btnAOn (elseOnButtonsOr "A")
aaa    = stimes 3 cmdA
aaaaa  = stimes 5 cmdA
aaaaaa = stimes 6 cmdA

let apf = applyCommand' 30 elseOnAllOr
trigger "aaa"    (apf aaa && (not $ apf aaaaaa)) []
trigger "aaaaa"  (apf aaaaa) []
trigger "aaaaaa" (apf aaaaaa) []

aaaにおいては変わらずaaaaaa時のキャンセルが必要です。これも真空波動拳とともにスッキリとした解法があるかもしれません。Ordを使うなど、、、検討中です。

https://youtu.be/ewwHIomud-Q?t=56

以下では、遅いスパンで入力したり、4回入力したりしています。ちゃんと退けられています。
https://youtu.be/ewwHIomud-Q?t=73

おわりに

以下がCommandモノイドの全行です。ほとんど半群としての用例になってしまいましたがね、、、。
状態遷移などを用いること無く、汎用的な構成をここまで少ない行数で実現できているのは良い点かと思います。

Command.hs
import Lib
import Data.Monoid
import Data.Semigroup
import Language.Copilot hiding (alwaysBeen, since)

data Command = Command (Stream Bool -> Stream Bool -> Stream Bool)

instance Semigroup Command where
    (Command c1) <> (Command c2) = Command $ \s r -> c2 (c1 s r) r

instance Monoid Command where
    mempty = Command $ \s r -> s && not r  -- sでも良さそう

genCommand :: Stream Int32 -> Stream Bool -> Stream Bool -> Command
genCommand len wanted cancel = Command $ \s r ->
    let
      o = [False] ++ o'
      ps = sP s
      o' = srsFF (ps && wanted) (  r
                               || cancel
                               || (oneShotFall . once' len $ ps && wanted)
                               || ((sP o) && wanted)) 
    in o

applyCommand :: Command -> Stream Bool
applyCommand (Command cmd) = oneShotRise r
    where r = [False] ++ cmd true (sP r)

applyCommand' :: Stream Int32 -> Stream Bool -> Command -> Stream Bool
applyCommand' len cancel (Command cmd) = o''
    where
        o = [False] ++ cmd true (sP o)
        o' = oneShotRise o
        o'' = oneShotRise
            . alwaysBeen' len
            $ srsFF o' (cancel || (oneShotFall $ once' (len+1) o'))

genCommandcancel用の引数の構成方法はもう少し良いものがありそうな気がしています。Template Haskellとか使うのかな?

Discussion