Haskell と SDL

この記事は Haskell Advent Calendar 2014 の 8 日目の記事です。

HaskellSDL を利用してゲーム的な何かを作成するための入門記事です。

環境構築(Haskell Platform)

Haskell の開発環境は Windows/Mac/Linux いずれでも構築可能です。僕は普段は Haskell のコーディングは Linux、特に Ubuntu 14.04 を使用することが多いですが、今回は Mac を選択しました。

Haskell Platform は最新を使用します。僕の Mac には少し古いバージョンが入っていたので:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.6.3

以下のコマンドで何がアンインストールされるかを確認し:

$ uninstall-hs only 7.6.3

以下のコマンドで実際にアンインストールします。

$ sudo uninstall-hs only 7.6.3 --remove

僕は加えて以下も行いました。

$ rm -rf ~/.cabal/ ~/.ghc/ ~/Library/Haskell/

その後 Haskell Platform の最新版をダウンロード・インストールして、バージョンを確認します。

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.8.3

本当は Haskell Platform は複数のバージョンを混在させることも可能なのですが、ハマりそうなので使用するバージョンのみにしておきます。

環境構築(Xcode

XcodeApp Store からインストールした後、Xcode Command Line Developer Tools を以下のようにインストールします。

$ xcode-select --install

これは MacPorts を使用するために必要な手続きなのですが、Mac OS X のバージョンや Xcode のバージョンによって、手続きが異なる可能性があるので注意してください。もしこの手順が抜けていても、MacPorts を使用する際に警告してくれるはずです。

環境構築(MacPorts

次は、SDL を使用する環境を構築します。

SDL は現在 SDL 1.2 と SDL 2.0 のふたつのバージョンがあり、どちらも利用可能です。僕は SDL には全く詳しくないのですが、Haskell のライブラリの対応度で言えば、SDL 1.2 の方が充実しているようで、またチュートリアルも豊富ですが、僕はゼロから始めるので SDL 2.0 を使用してみようと思います。

まずは MacSDL 2.0 のライブラリをインストールする必要があります。

僕は MacPorts を利用しているので*1、まず以下のように MacPorts 自身をアップデートして:

$ sudo port selfupdate

バージョンを確認します。

$ port version
Version: 2.3.3

libsdl がインストールされているか確認します。

$ port installed | grep libsdl

入っていないようなので以下のように確認した後:

$ port search libsdl2

以下のようにインストールします。

$ sudo port install libsdl2 libsdl2_image pkgconfig

絵を出すぐらいはしたいなぁ、ということで libsdl2_image も一緒にインストールしました。pkgconfig は Haskell のライブラリである sdl2 が依存しているので、同時に入れてしまいます。

環境構築(Cabal)

Cabal で Haskell のライブラリである sdl2 と sdl2-image をインストールします。

まずは以下のコマンドで cabal をアップデートします。

$ cabal update
Downloading the latest package list from hackage.haskell.org
Skipping download: Local and remote files match.
Note: there is a new version of cabal-install available.
To upgrade, run: cabal install cabal-install

cabal 自身のアップデートを行う必要がある場合、以下のように cabal をアップデートします。

$ cabal install cabal-install

ここで注意が必要なのは、上記はパスの通っている/usr/bin/cabal を更新したのではなく、~/Library/Haskell/bin/cabal に新しい cabal をインストールした、という点です。なので ~/Library/Haskell/bin/ にパスを通して、こちらが使用されるように .bash_profile に以下のようなコードを追加します。

if [ -d ${HOME}/Library/Haskell/bin ] ; then
  export PATH=${HOME}/Library/Haskell/bin:${PATH}
fi

読み込み直しを忘れずに。

$ source ~/.bash_profile

もう一度 cabal update します。

$ cabal update

それでは以下のコマンドで、sdl2 と sdl2-image をインストールします。

$ cabal install sdl2 sdl2-image

環境構築(まとめ)

cabal install sdl2 sdl2-image まで、うまく行ったでしょうか。

なぜ、環境構築をこんなにバカ丁寧に解説するかというと、ひとつは Haskeller 以外の人にも読んでもらいたいから、もうひとつは環境構築は結構つまづくポイントだからです。

前述したとおり、僕は HaskellUbuntu 14.04 で書くことが多いのですが、SDL2 の開発環境を Ubuntu 上に構築するのは諦めました。まず、Ubuntu では aptitude を使用して必要なライブラリ等をリポジトリからインストールするのですが、libsdl2 は 2.0.2 というバージョンがインストールされます。一方で Haskell の sdl2 は、最新の 1.3.0 では libsdl2 2.0.3 以上を要求します。libsdl2 2.0.2 で済まそうとすると、6 つ前のバージョンである sdl2 1.0.2 までさかのぼらなければなりません。

一旦はこのバージョンを使用して進めようと思ったのですが、・・・

SDL2 ことはじめ

Haskell で SDL2 を触り始めてまず思ったのが、ドキュメントに説明が一切ないので、どうするんだこれ、ということです。

そしてわかったのは、Haskell の SDL2 の API は C 言語の SDL2 の API と 1 対 1 の関係にあり、関数の名前もほとんど同じなので、関数の仕様が知りたければ https://wiki.libsdl.org/ を見よ、ということでした。

ということで、開発に必須なドキュメントは以下になります。

SDL2 自体のチュートリアルとして、以下が参考になると思います。

sdl2 第一歩

sdl2 は Graphics.UI.SDL を import する必要があります。

今回のサンプルでは、以下のようにしました。

import qualified Graphics.UI.SDL as SDL 

C/C++ の SDL2 は最初に SDL_Init を呼び出して初期化する必要があります。Haskell だとこんなかんじです。

  rc <- SDL.init SDL.SDL_INIT_EVERYTHING
  if rc == 0 then <次の処理> else <エラー処理>

そうです。Haskell の sdl2 の APIC/C++API のラッパーなので、完全に手続きを書く必要があります。

この処理はこのままだと使いにくいので関数にします。

sdlInit :: SDL.InitFlag -> IO (Either String ()) 
sdlInit flag = do
  rc <- SDL.init flag
  return $ if rc == 0
    then Right()
    else Left $ "SDL.init error:" ++ (show $ fromEnum rc) 

SDL.init に渡しているのは初期化フラグで、SDL_INIT_EVERYTHING は SDL_INIT_TIMER、SDL_INIT_AUDIO、SDL_INIT_VIDEO、SDL_INIT_JOYSTICK、SDL_INIT_HAPTIC、SDL_INIT_GAMECONTROLLER、SDL_INIT_EVENTS すべてを初期化するという意味です。本当は必要なサブシステムだけをビット演算の論理和で渡せば良いのですが、今回はサンプルをシンプルにするために全部初期化しちゃいます。

次にウィンドウを開きます。C/C++ だと SDL_CreateWindow、Haskell だと SDL.createWindow です。

これも以下の様な関数にまとめました。

sdlCreateWindow :: String -> CInt -> CInt -> IO (Either String SDL.Window)
sdlCreateWindow windowTitle width height =
  withCAString windowTitle $ \title -> do
    window <- SDL.createWindow
      title
      SDL.SDL_WINDOWPOS_UNDEFINED
      SDL.SDL_WINDOWPOS_UNDEFINED
      width
      height SDL.SDL_WINDOW_SHOWN
    return $ if window /= nullPtr
      then Right window
      else Left "SDL.createWindow returns null pointer"

ウィンドウタイトル、X 座標、Y 座標、幅、高さ、フラグを渡して Window オブジェクトを取得します。最後のフラグは、フルスクリーンモードや OpenGL を使用するモード、最大化状態や非表示状態など色々あって、同様にビット演算の論理和で複数同時に指定できるようになっています。今回はウィンドウが表示されるフラグだけを指定しています。

プログラムの終了処理は以下のようにしました。

sdlWaitEvent :: IO (CInt, SDL.Event)
sdlWaitEvent = alloca $ \ptr -> do
  rc <- SDL.waitEvent ptr 
  event <- peek ptr 
  return (rc, event)

waitQuitEvent :: IO ()
waitQuitEvent = do
  (rc, event) <- sdlWaitEvent
  if rc == 1
    then case event of
      (SDL.QuitEvent _ _) -> return ()
      _                   -> waitQuitEvent
    else print $ "SDL.waitEvent error:" ++ (show $ fromEnum rc) 

ウィンドウを閉じるとプログラムが終了するようにしてあります。

これを、main から呼び出すようにした、コード全体が以下です。

module Main where

import qualified Graphics.UI.SDL as SDL
import Data.Either
import Foreign.C.String
import Foreign.C.Types
import Foreign.Marshal.Alloc
import Foreign.Ptr
import Foreign.Storable

main :: IO ()
main = do
  sdlInit SDL.SDL_INIT_EVERYTHING >>= either sdlError return
  window <- sdlCreateWindow "hello" 640 480 >>= either sdlError return
  waitQuitEvent
  SDL.destroyWindow window
  SDL.quit

sdlInit :: SDL.InitFlag -> IO (Either String ())
sdlInit flag = do
  rc <- SDL.init flag
  return $ if rc == 0
    then Right()
    else Left $ "SDL.init error:" ++ (show $ fromEnum rc)

sdlCreateWindow :: String -> CInt -> CInt -> IO (Either String SDL.Window)
sdlCreateWindow windowTitle width height =
  withCAString windowTitle $ \title -> do
    window <- SDL.createWindow
      title
      SDL.SDL_WINDOWPOS_UNDEFINED
      SDL.SDL_WINDOWPOS_UNDEFINED
      width
      height SDL.SDL_WINDOW_SHOWN
    return $ if window /= nullPtr
      then Right window
      else Left "SDL.createWindow returns null pointer"

sdlWaitEvent :: IO (CInt, SDL.Event)
sdlWaitEvent = alloca $ \ptr -> do
  rc <- SDL.waitEvent ptr
  event <- peek ptr
  return (rc, event)

waitQuitEvent :: IO ()
waitQuitEvent = do
  (rc, event) <- sdlWaitEvent
  if rc == 1
    then case event of
      (SDL.QuitEvent _ _) -> return ()
      _                   -> waitQuitEvent
    else print $ "SDL.waitEvent error:" ++ (show $ fromEnum rc)

sdlError :: String -> IO a
sdlError message = do
  errMsg <- SDL.getError >>= peekCString
  fail (message ++ " (" ++ errMsg ++ ")")

エラー発生時に呼び出す関数として sdlError を定義しました。SDL_GetError という関数で、エラー発生時の可読可能なエラーメッセージが取得できるので、利用しています。

SDL.delay は指定ミリ秒だけ処理が止まる、いわゆるスリープ関数です。指定した期間は必ず補償されますが、OS のスケジューリングにより、多少長くなることもあります。

SDL.destroyWindow は Window オブジェクトの破棄、SDL.quit は終了処理です。

これを以下のようにコンパイルします。

$ ghc -L/usr/lib hello.hs

ポイントとなるのがライブラリディレクトリの指定 -L/usr/lib です。これを指定しないと以下の様なリンクエラーが発生します。

$ ghc hello.hs
[1 of 1] Compiling Main             ( hello.hs, hello.o )
Linking hello ...
Undefined symbols for architecture x86_64:
  "_iconv", referenced from:
      _hs_iconv in libHSbase-4.7.0.1.a(iconv.o)
     (maybe you meant: _hs_iconv, _base_GHCziIOziEncodingziIconv_iconvEncoding8_info , _base_GHCziIOziEncodingziIconv_iconvEncodingzuloc1_closure , _hs_iconv_close , _base_GHCziIOziEncodingziIconv_iconvEncoding3_info , _base_GHCziIOziEncodingziIconv_iconvEncoding10_info , _base_GHCziIOziEncodingziIconv_iconvEncoding6_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding10_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding7_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding2_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding2_info , _base_GHCziIOziEncodingziIconv_iconvEncoding8_closure , _base_GHCziIOziEncodingziIconv_iconvEncodingzuloc1_info , _base_GHCziIOziEncodingziIconv_iconvEncodingzuloc_info , _hs_iconv_open , _base_GHCziIOziEncodingziIconv_iconvEncodingzuloc_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding7_info , _base_GHCziIOziEncodingziIconv_iconvEncoding3_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding6_info , _base_GHCziIOziEncodingziIconv_iconvEncoding9_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding4_closure , _base_GHCziIOziEncodingziIconv_iconvEncoding9_info )
  "_iconv_close", referenced from:
      _hs_iconv_close in libHSbase-4.7.0.1.a(iconv.o)
     (maybe you meant: _hs_iconv_close)
  "_iconv_open", referenced from:
      _hs_iconv_open in libHSbase-4.7.0.1.a(iconv.o)
     (maybe you meant: _hs_iconv_open)
  "_locale_charset", referenced from:
      _localeEncoding in libHSbase-4.7.0.1.a(PrelIOUtils.o)
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

どうやら libiconv は /usr/lib と /opt/local/lib に存在し、/opt/local/lib の libiconv には _iconv や _iconv_close などのシンボルが定義されていないようです(代わりに _libiconv や _libiconv_close などが定義されています)。なので、リンクするライブラリを /usr/lib にしてやる必要があります。

こうしてできた実行ファイルを起動すると、以下の様な画面が表示されます。

ようやくスタート地点に立てました。

SDL2 で Hello World

SDL はどうやらデフォルトで BMP の描画ができるようです。そして、デフォルトでは BMP の描画しかできないようです。そのため、SDL2_Image で PNG などの他のフォーマットが扱えるように拡張されているようです。

せっかくライブラリをインストールしたのですが、今回は基本を抑えるということで、以下の BMP を表示しようと思います。

まずは、ウィンドウの背景を白に塗りつぶすコードを追加します。

  surface <- SDL.getWindowSurface window
  format <- liftM SDL.surfaceFormat $ peek surface
  color <- SDL.mapRGB format 0xFF 0xFF 0xFF
  SDL.fillRect surface nullPtr color
  SDL.updateWindowSurface window

次に BMP を描画するコードを追加します。

  path <- newCString "hello.bmp"
  image <- SDL.loadBMP path
  rect <- new $ SDL.Rect 170 200 300 80
  SDL.blitSurface image nullPtr surface rect

実行すると以下の様な画面が表示されます。

コード全体は以下のようになります。

module Main where

import qualified Graphics.UI.SDL as SDL
import Control.Monad
import Data.Either
import Foreign.C.String
import Foreign.C.Types
import Foreign.Marshal.Alloc
import Foreign.Marshal.Utils
import Foreign.Ptr
import Foreign.Storable

main :: IO ()
main = do
  sdlInit SDL.SDL_INIT_EVERYTHING >>= either sdlError return
  window <- sdlCreateWindow "hello" 640 480 >>= either sdlError return
  surface <- SDL.getWindowSurface window
  format <- liftM SDL.surfaceFormat $ peek surface
  color <- SDL.mapRGB format 0xFF 0xFF 0xFF
  SDL.fillRect surface nullPtr color
  path <- newCString "hello.bmp"
  image <- SDL.loadBMP path
  rect <- new $ SDL.Rect 170 200 300 80
  SDL.blitSurface image nullPtr surface rect
  SDL.updateWindowSurface window
  waitQuitEvent
  SDL.destroyWindow window
  SDL.quit

sdlInit :: SDL.InitFlag -> IO (Either String ())
sdlInit flag = do
  rc <- SDL.init flag
  return $ if rc == 0
    then Right()
    else Left $ "SDL.init error:" ++ (show $ fromEnum rc)

sdlCreateWindow :: String -> CInt -> CInt -> IO (Either String SDL.Window)
sdlCreateWindow windowTitle width height =
  withCAString windowTitle $ \title -> do
    window <- SDL.createWindow
      title
      SDL.SDL_WINDOWPOS_UNDEFINED
      SDL.SDL_WINDOWPOS_UNDEFINED
      width
      height SDL.SDL_WINDOW_SHOWN
    return $ if window /= nullPtr
      then Right window
      else Left "SDL.createWindow returns null pointer"

sdlWaitEvent :: IO (CInt, SDL.Event)
sdlWaitEvent = alloca $ \ptr -> do
  rc <- SDL.waitEvent ptr
  event <- peek ptr
  return (rc, event)

waitQuitEvent :: IO ()
waitQuitEvent = do
  (rc, event) <- sdlWaitEvent
  if rc == 1
    then case event of
      (SDL.QuitEvent _ _) -> return ()
      _                   -> waitQuitEvent
    else print $ "SDL.waitEvent error:" ++ (show $ fromEnum rc)

sdlError :: String -> IO a
sdlError message = do
  errMsg <- SDL.getError >>= peekCString
  fail (message ++ " (" ++ errMsg ++ ")")

まとめ

僕は SDL も SDL2 も初めて触れるので、あまり変わらないかなと思っていましたが、Web 上には圧倒的に SDL の解説やサンプルが多く、特にこだわりがなければ SDL を使用するという選択の方が良いのではないかと思いました。

Haskell の sdl2 ライブラリは C/C++ の SDL2 ライブラリのバインディングなので、完全に手続きを書かなければなりません。また、要所要所で Ptr や CString などが要求されるため、煩わしさもあります。エラー発生時のリソース開放なども、かなり気をつけて設計しなければリークが発生してしまうでしょう。

そのため、sdl2 を使用した更に簡単にゲーム開発ができるライブラリがいくつも存在するようです。

今後はそうしたライブラリをちら見しつつ、sdl2 は基本なのでしっかりおさえて、作りかけのポーカーゲームを完成させようと思います。

*1:MacPorts は右記よりインストールしてください:https://www.macports.org/