データを処理することは、プログラミングのもっとも本質的な部分である。数値、文字列、あるいはデータの集まりなどを扱うために、数々の構造が考えられてきた。Haskellにおいては、「手順」もデータとして扱うことができる。これは、DSLを作るうえで非常に有用であり、ゲーム開発やデータベース操作……手続きがかかわるあらゆるものに応用できるだろう。
手順をデータとして扱う方法として、FreeモナドとOperationalモナドがある。ここでは、Operationalモナドをゲームのキャラクターの制御に用いた例を紹介する。Freeモナドの導入については、Andres Löh氏のHaskell eXchange 2013の講演がわかりやすく、おすすめである。
データを作る
アクションゲームの敵の動きとして、この二つを考えよう:
- 待機
- 接近(攻撃)
- 索敵(プレイヤーの位置を調べる)
EnemyMというモナドがあれば、Haskellの値としてそれらを定義することができる。
wait :: EnemyM () move :: Position -> EnemyM () scout :: EnemyM Position
では、EnemyMはどうやって作るのだろうか?その答えの一つはOperationalモナドだ。
{-# LANGUAGE TemplateHaskell #-} import Control.Monad.Operational.Mini data EnemyI a where Wait :: EnemyI () Move :: Position -> EnemyI () Scout :: EnemyM Position makeSingletons ''EnemyI type EnemyM = Program EnemyI
「位置pに向かって動く」という動作は、そのままMove p :: EnemyI ()という値として表現される。EnemyIは挙動の最小単位になっている。この型にProgramをかぶせることによって、それらを合成できるようになる――Programは好きなものをモナドにしてしまう力があるのだ。具体的な構成方法については、私の過去の記事*1を参照されたい。
実装
以下は、この考えに基づき実装したコードだ。もっとも基本的な関数singletonは、命令の最小単位をProgramレベルに持ち上げる。minioperationalのmakeSingletonsを使えば、singletonを適用した命令を自動生成することも可能だが、今回は使っていない。
data Tactic x where Approach :: Tactic () Wait :: Tactic () RelativePlayer :: Tactic (Maybe (V2 Float)) Put :: Enemy -> Tactic () Get :: Tactic Enemy Randomly :: Random r => (r, r) -> Tactic r type Strategy = ReifiedProgram Tactic defaultStrategy :: Strategy () defaultStrategy = do r <- singleton RelativePlayer a <- use enemyAttacked case r of Just d | a -> attacking defaultStrategy | quadrance d < 160 ^ 2 -> attacking defaultStrategy | quadrance d > 320 ^ 2 -> enemyAttacked .= False _ -> singleton Wait attacking :: Strategy a -> Strategy a attacking cont = do replicateM_ 29 $ singleton Approach replicateM_ 33 $ singleton Wait cont
ここで、ReifiedProgramはProgramの変種である。CPS変換されているかどうかの違いがあるが、説明は省略する。Enemy
は敵の状態を直接的に表す型である。敵の種類ごとにコンストラクタを作れば、Affine traversal*2で内部状態を読み書きできるため、とても便利だ。
独自のモナドを定義するメリットは、「同じプログラム(Strategy)でも、キャラクターごとの個性を表現できる」ということと、データであるため、合成、分解、検証が容易であるという点にある。
以下はプログラムを分解し、より具体的な動きに変換している関数の例である。敵の状態を扱うStateモナドの上で動いている。
exec cha (Approach :>>= cont) = do target <- lift $ use $ thePlayer . position pos <- use position case cha of Squirt -> do velocity . _x .= signum (view _x target - view _x pos) modify updateAnimation Fly -> velocity .= normalize (target - pos) * V2 3 6 return (cont ()) exec cha (Wait :>>= cont) = do velocity . _x .= 0 return (cont ()) exec _ (RelativePlayer :>>= cont) = do target <- lift $ use $ thePlayer . position pos <- use position return (cont (Just $ target - pos)) exec _ (Get :>>= cont) = cont <$> get exec _ (Put s :>>= cont) = cont <$> put s exec _ (Randomly r :>>= cont) = cont <$> liftIO (randomRIO r)
雑魚敵と飛ぶ敵で、移動の方法を変えている。また、RelativePlayerの部分の処理を変えれば、「視野」を設けるといった高度なことも可能になるだろう。
ProgramやReifiedProgramは、変換先のモナドを制限しない。そのため、ありとあらゆる環境*3にプログラムを持ち運ぶことができる。ちょうどLuaのような埋め込み言語の、さらに上位版と見てもよい*4。
この実装の全体はGitHubで公開している。
その他
- 拙作のfree-gameをゲームエンジンとして使ったが、スクロールを含むマップの描画は予想していたよりも簡単に記述することができた。その理由もまた、モナドをデータとして扱っているからである。
- 全体で約500行と、内容の貧相さの割には行数がかさんでしまった。無理してStateモナドを利用せずに、リアクティブプログラミングを取り入れたほうがよかったかもしれない。karakuriというパッケージでも、アクターの記述のよりよい方法を模索している。
- 音声関連のライブラリはまだ十分に整備されていないのが現状である。DirectSoundも公開されたが、クロスプラットフォームかつインストールしやすくかつメンテナのいるパッケージはまだない。Haskellでゲーム開発をしていくうえで、今もっとも大きな問題だと考えている。
課題
今回は非常に単純な挙動だったが、たとえば動的計画法などを用いてマップの格子と行動にスコアを割り当て、スコアが高いところに向かって行動するなど、より高度なAIも記述できるはずだ。戦略性とアクション性を両立しやすくするアプローチとして、モナドはとても革新的なツールだと確信している。
プログラムの検証については、本研究では確実な手法を見出すことはできなかった。今後も、何か面白いアプローチができないか考えていきたい。