格闘ゲームのコマンド検出器のモノイドとしての構成
はじめに
前回の記事にて、Haskell-Copilotで基本的な時相を扱う関数群を定義しました。今回はこれらを使用してストリートファイターなどの格闘ゲームのコマンド検出器を、モノイドとして定義してその合成から各々のコマンド実装を行いました。モノイドとすることで、コマンド検出器にモジュラリティを与えることとなります。以降で書く真空波動拳がモジュラリティを活かした良い例です。
モノイドとは
このwikipediaページにあるとおりです。雑に言えば「扱うオブジェクト型1つ」と「そのオブジェクト型どうしを合成する演算1つ」と「合成しても意味がないゼロのようなオブジェクト」が定義された演算システムです。「整数と整数の足し算」「整数と整数の掛け算」などもモノイドです。
この記事では、「Aボタンを押すコマンド検出器」と「Bボタンを押すコマンド検出器」のオブジェクトと、「コマンド検出器を足し合わせる合成」を定義して、「ABボタンを押すコマンド検出器」を作れるように代数的な定義をして行きます。これにより、コマンドを足し合わせて新たなコマンドを作るということが平易なコードで実現できます。
開発環境
前回はマイコンと電子回路で説明用のシステムを作りましたが、今回は汎用PCとUSBのゲームパッドを使用しました。
名前 | バージョン |
---|---|
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リンク
以下で実行までされます。
stack build
stack run
make
./gamepad_input
SDLが必要です。Ubuntuなら以下で行けるはず。
sudo apt-get install libsdl2-dev
単一コマンド検出器
以下は今回のモノイドの「扱うオブジェクト型」としての検出器の論理回路表現図です。図中のonce
などの定義は前回の記事をご参考ください。オブジェクトと言いつつs
とr
の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
のままですと、合成後のs
とr
が無入力のままですので、以下のように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が意図せず検出されてしまうことがわかります。この問題の解決方法については後述します。
各種コマンドの定義
瞬獄殺
今回は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")
]
以下はBを1つ余計に押しています。きちんと退けて居ることがわかります。
波動拳
おなじみ下 > 右下 > 右 > 弱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
とすることで、真空波動拳時の同時発動を防いでいます。
真空波動拳
下 > 右下 > 右 > 下 > 右下 > 右 > 弱P
は上記の波動拳で定義したdownToRight
を再利用し、以下のように定義しました。
shinKuHaDoKen :: Command
shinKuHaDoKen = downToRight
<> downToRight
<> genCommand 50 btnYOn (elseOnButtonsOr "Y")
let skhk = applyCommand shinKuHaDoKen
trigger "shin_ku_ha_do_ken" (applyCommand shinKuHaDoKen) []
真空波動拳成功後は気を抜いてコマンドを入力しています。なかなか成功しづらいことが分かるかと思います。
同時押しのモノイド
上記までのコマンド列の各構成要素は押された瞬間のみ見る必要があったのですが、巴投げなどは左 > [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) []
内部に別コマンドを挟んでしまうコマンド
真空波動拳の場合は末尾に波動拳のコマンドを含んでおり以下のように真空波動拳発動時は波動拳のキャンセルが必要でした。
let skhk = applyCommand shinKuHaDoKen
trigger "shin_ku_ha_do_ken" skhk []
trigger "ha_do_ken" ((applyCommand haDoKen) && (not skhk)) []
では末尾ではなく内部ではどうでしょうか?仮にAAA
、AAAAA
、AAAAAA
というコマンドはどうでしょうか。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'))
以下とすることで、AAA
、AAAAA
、AAAAAA
で同時発動してしまうことはなくなります。
なお、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を使うなど、、、検討中です。
以下では、遅いスパンで入力したり、4回入力したりしています。ちゃんと退けられています。
おわりに
以下が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'))
genCommand
のcancel
用の引数の構成方法はもう少し良いものがありそうな気がしています。Template Haskellとか使うのかな?
Discussion