Haskellでゲーム開発 - メインループとイベント操作

そろそろHaskellでゲーム開発なんか真面目にやっていこうかなぁとか思いつつ、SDLの勉強しています。
並行してやってる色んな事を総スルーしながら、やっていきます。気分屋なのでやることがコロコロ変わるのは悪いくせですね、はい。



というわけで、HaskellSDLラッパーの使い方、色々調べてみたんですが、なかなか良い感じのチュートリアルに出会わず、結局Hackageの定義とにらめっこするのが一番手っ取り早いという結論に至りました。
SDLOpenGLもはじめて使うので試行錯誤との戦いです。

っていうか、Haskell/SDL関連の記事書いてるみなさん、型定義書きましょうよ、マジで。型推論利くし、面倒なのは解るのですが、型が書かれてるだけで全然読みやすさ違いますから。

とにかく、今回書いたコードをペロンと貼っておきます。
まっさらな画面を表示して、その画面に対するイベントを標準出力に吐き出すだけのプログラムです。
リアルタイムでイベントを監視しているのを確認するために、だいたい0.1秒毎に起動してからの経過時間を1000分の1秒単位で出力してます。

コメントいっぱい書いたんで、後述の説明読むのが面倒な人はコードだけ眺めてやって下さい。

module Main where
import Data.Word
import qualified Graphics.UI.SDL as SDL

--initしてsetVideoModeすればとりあえず画面が表示される
--setVideoModeの戻り値はSurfaceとかいう構造体で
--画面の情報なんかを保持しているらしい
gameinit :: IO () 
gameinit = do
  SDL.init [SDL.InitEverything]
  SDL.setVideoMode 640 480 32 [] >>= print

--メインループ処理
mainLoop :: Word32 -> IO ()
mainLoop ago = do
  SDL.delay 1 --1000分の1ミリ秒待機、これ入れないとCPU使用率が大変な事になる
  ev <- SDL.pollEvent --待機せずイベントを取得、waitEventとかだと処理が継続できない
  printEvent ev --イベントを出力
  --isQuitがTrueを返すまで再帰呼出し
  if isQuit ev then return () else printTicks >>= mainLoop

   where
    --NoEventを除くイベントを出力
    printEvent :: SDL.Event -> IO ()
    printEvent SDL.NoEvent = return ()
    printEvent ev = print ev 

    --適当な間隔でgetTicksを出力
    printTicks :: IO Word32
    printTicks = do
      t <- SDL.getTicks
      if ago + 100 < t then print t >> return t else return ago

    --終了条件判定
    isQuit :: SDL.Event -> Bool
    isQuit (SDL.KeyUp key) = SDL.symKey key == SDL.SDLK_ESCAPE --ESCキー押下
    isQuit ev = ev == SDL.Quit --画面が閉じられる
    isQuit _ = False

-------------------------------------------------

main :: IO ()
main = gameinit >> SDL.getTicks >>= mainLoop >> SDL.quit



まず、SDLの初期化はinit関数でやります。

*Main> :t SDL.init
SDL.init :: [SDL.InitFlag] -> IO ()
*Main> :i SDL.InitFlag
data SDL.InitFlag
  = SDL.InitTimer
  | SDL.InitAudio
  | SDL.InitVideo
  | SDL.InitCDROM
  | SDL.InitJoystick
  | SDL.InitNoParachute
  | SDL.InitEventthread
  | SDL.InitEverything
  	-- Defined in Graphics.UI.SDL.General
instance Bounded SDL.InitFlag -- Defined in Graphics.UI.SDL.General
instance Eq SDL.InitFlag -- Defined in Graphics.UI.SDL.General
instance Ord SDL.InitFlag -- Defined in Graphics.UI.SDL.General
instance Read SDL.InitFlag -- Defined in Graphics.UI.SDL.General
instance Show SDL.InitFlag -- Defined in Graphics.UI.SDL.General

起動時はぜーんぶ初期化してやりたいので、[SDL.InitEverything]を引数にしてやれば良いというワケですね。

で、setVideMode関数で画面を作ってやります。

*Main> :t SDL.setVideoMode
SDL.setVideoMode
  :: Int -> Int -> Int -> [SDL.SurfaceFlag] -> IO SDL.Surface
*Main> :i SDL.SurfaceFlag
data SDL.SurfaceFlag
  = SDL.SWSurface
  | SDL.HWSurface
  | SDL.OpenGL
  | SDL.ASyncBlit
  | SDL.OpenGLBlit
  | SDL.Resizable
  | SDL.NoFrame
  | SDL.HWAccel
  | SDL.SrcColorKey
  | SDL.RLEAccel
  | SDL.SrcAlpha
  | SDL.PreAlloc
  | SDL.AnyFormat
  | SDL.HWPalette
  | SDL.DoubleBuf
  | SDL.Fullscreen
  	-- Defined in Graphics.UI.SDL.Types
instance Bounded SDL.SurfaceFlag
  -- Defined in Graphics.UI.SDL.Types
instance Eq SDL.SurfaceFlag -- Defined in Graphics.UI.SDL.Types
instance Ord SDL.SurfaceFlag -- Defined in Graphics.UI.SDL.Types
instance Read SDL.SurfaceFlag -- Defined in Graphics.UI.SDL.Types
instance Show SDL.SurfaceFlag -- Defined in Graphics.UI.SDL.Types

引数に渡すSurfaceFlag型には色々ありますが、今後必要になりそうなのはOpenGL、DoubleBuf、Fullscreenあたりでしょうか。
うち、Fullscreenだけちょっと試してみましたが、サクッと全画面表示してくれました。
すごくどうでも良いですが、終了条件をしっかり書いておかないと、Fullscreenにした時ににっちもさっちも行かなくなってしまうので注意。
そのせいで、途中まで書いてたこの記事が一旦クリアされてしまうという酷い目にあいました。

あ、あと戻り値のSurfaceは画面関係の情報を保持した構造体みたいです。
今回はprintして(メモリアドレスが出力されます)そのまま捨てちゃってますが、ちゃんと作るときはデータもち回してやったほうが良いんじゃないかと思います。
まだ用途とか良くわからんので、SDLに詳しい方良かったら教えてください。



画面が表示されたら、いよいよメインループとイベントの処理について考えていきます。
イベントの取得にはpollEvent、waitEvent、waitEventBlocking等といった関数が用意されています。

*Main> :t SDL.pollEvent
SDL.pollEvent :: IO SDL.Event
*Main> :t SDL.waitEvent
SDL.waitEvent :: IO SDL.Event
*Main> :t SDL.waitEventBlocking
SDL.waitEventBlocking :: IO SDL.Event

三つとも型は一緒ですが、挙動は少しづつ違います。
よくあるHaskell/SDL関連の記事だと、waitEventを使ってたりするようですが、
waitEventやwaitEventBlockingはイベントキューが積まれるまで待機してしまうので、リアルタイム性のあるゲームを作るのには使えないです。

基本的には末尾再帰したメインループ関数内で、ゲームを処理しつつpollEvent関数でイベントを監視する感じになるかと思われますが、
安定して動作させるためには、時間を監視する必要があります。そこで必要になるのが、getTicks関数です。

*Main> :t SDL.getTicks
SDL.getTicks :: IO Word32

この関数は、SDLが起動してからの時間を1000分の1秒単位で返します。
1000分の16秒程度待機してから描画すれば、大体60fps付近で安定すると考えれば良いでしょうか。

それから、単純に無限ループにしてしまうと、CPUが大変な事になってしまうので、適度に待機してやる必要があります。
そのために使えそうなのがdelay関数です。

*Main> :t SDL.delay
SDL.delay :: Word32 -> IO ()

こいつは、引数で渡された時間だけ待機する関数です。これを入れてやるとCPU使用率がうんとマシになります。



ゲームの進行を管理する方法については、おそらく描画処理と計算処理は分断する事になると思われるので、StateTとかにするまでも無いんじゃないでしょうか。(むしろIOが入り乱れてややこしくなりそう)
あとは画像の処理についてアレコレ調べていけば、Haskellでゲームを作るのはそんなに大変では無いでしょう。

誰だよHaskellGUI苦手とか言った奴。