Haskellで作る音ゲー

View project on GitHub

Haskellで作る音ゲー(翻訳: @stefafafan)

Fumiaki Kinoshita (IIJ-II) fumiexcel@gmail.com

Original English version

イントロダクション

リズムゲームは日本で人気なゲームジャンルの1つです。1998年にDance Dance Revolution (DDR) がコナミから発表されてジャンルの中で最も成功している。他に__太鼓の達人__も幅広い年代の人に愛されています。今日も次々と色んな種類のリズムゲームが発表されています。

しかし、この類のゲームのチュートリアルは少なく、あったとしても古すぎたりWindowsでしか動作しなかったりします。

このチュートリアルは苦痛無くリズムゲームを作れるようにと書きました。驚く無かれ、Haskellを使えばできます。

このチュートリアルは3つのパートに分かれています: * パートIはパートIIとIIIで必要となってくる環境構築について。 * パートIIではシンプルなリズムゲームを作ります。Callエンジンを利用します。 * パートIIIはパートIIを支える技術的な背景(グラフィック、オーディオ等)を紹介します。

このチュートリアルによりゲームを作りたいという意欲が出たら嬉しいです。

パート I: 準備

まずはGHCをインストールしなければなりません。Haskell Platform でインストールするのが楽です。

UnixかMacでは libportaudio19 をインストールしてください。

注意:現時点で Call はMac OS Xにてビットマップをちゃんと描画してくれません。原因を分かる人が居れば教えて下さい。

$ sudo <your-package-manager> install libportaudio19

このチュートリアルで使われるソースコードは rhythm-game-tutorial パッケージにあります。以下のコマンドでダウンロードとセットアップが出来ます:

$ cabal update
$ cabal unpack rhythm-game-tutorial
$ cd rhythm-game-tutorial-<version>
$ cabal install --only-dependencies
% cabal configure
$ cabal build

cabal install --only-dependencies で様々なパッケージがインストールされます。中でも objectivecall は重要なパッケージです。

  • objective はステートフルなオブジェクトの抽象化をしてくれます。必要ではないが状態を扱うときの苦痛を和らいでくれます。
  • call はクロスプラットフォームなマルチメディアライブラリです。軽量でシンプルでありながら、ゲームで使う様々な媒体(2D/3Dグラフィックス、オーディオ、キーボード・マウス・ゲームパッドからの入力等)に対応しています。
  • binding-portaudio は低水準なオーディオのAPIです。

Windowsにて

bindings-portaudio はインストールを楽にするため、ビルトインのソースを含んでいます。残念ながらGHCのバグにより時折不安定です。

$ cabal install bindings-portaudio -fBundle -fWASAPI

よくわからないエラーを投げてきた場合は私に報告してください。

パート II: ゲームを作る

さあ始まるドン! -- 和田どん、 「太鼓の達人」

シンプルなゲームを思い浮かべてください:画面下に丸があって、他の丸が上から迫ってきます。ちょうど重なったタイミングでスペースキーを押すゲームです。

tutorial-passive

tutorial-passive

どのようにして実装すればいいでしょうか?プログラムの構造は以下の要素から成り立ちます:

  • 音楽: ゲーム中に音楽が流れています。
  • グラフィックス: 時間によってグラフィックスが変わります。
  • インタラクション: プレイヤーがスペースキーを押した時にスコアが更新されます。

順に説明していきます。

音楽を再生する

グルーヴは大事です。音楽を再生しましょう。最初のゲームです(src/music-only.hs):

main = runSystemDefault $ do
  music <- prepareMusic "assets/Monoidal Purity.wav"
  playMusic music
  stand

実行しましょう:

$ dist/build/music-only/music-only

音楽が聴こえますか?音楽をロードするのに少々時間かかります。

コードを見てみましょう。以下の関数が Call エンジンによる定義されています。

runSystemDefault :: (forall s. System s a) -> IO a
stand :: System s ()

Call ではSystem s モナドにアクションが実行されます。runSystemDefaultSystem sIO へと変換します。stand は何もしませんがプログラムの終了を止めます。

prepareMusicplayMusic のシグネチャは以下の通りです:

type Music s = Instance (StateT Deck (System s)) (System s)

prepareMusic :: FilePath -> System s (Music s)
playMusic :: Music s -> System s ()

これらの関数は後ほど定義します。

画像の描画

ゲームのグラフィカルな部分を作っていきましょう。

main = runSystemDefault $ do
  allTimings <- liftIO $ parseScore (60/160*4) <$> readFile "assets/Monoidal Purity.txt"
  linkPicture $ \_ -> renderLane allTimings <$> getTime
  stand

linkPicture :: (Time -> System s Picture) -> System () がCallで定義されている唯一の何かを描画するための関数です。linkPicture f が繰り返し f を呼びその結果をウィンドウに描画します。f の引数はフレーム間の時間ですが普通は考えなくてよいです。

ゲームシステムの仕様のため、タイミング等を設定しないといけません。ここでただの数字の羅列よりも読みやすいタイミングの表記法を紹介します。

この表記法はいくつかのパケットによって成り立っていて、複数の小節を表しています。一つのパケットごとに複数の列を含んでいます。小節は列の長さにより分割されます。'.' は音符、'-' は休符です。

----.-----------
.-----------.---
--------.-------

パーサーの実装はあまり見て面白いものではありません。

parseScore :: Time -> String -> [Set Time]
parseScore d = map (Set.fromAscList . concat . zipWith (map . (+)) [0,d..]) . Data.List.transpose . map (map f) . splitWhen (=="") . lines where
  f l = [t | (t, c) <- zip [0, d/fromIntegral (length l)..] l, c == '.']

タイミングと丸の"寿命"があれば現在の時刻から丸の位置を計算できます。

phases :: Set Time -- ^ タイミング
    -> Time -- ^ 寿命
    -> Time -- ^ 現在時刻
    -> [Float] -- ^ フェーズ
phases s len t = map ((/len) . subtract t) -- [0, 1]の範囲に変換
  $ Set.toList
  $ fst $ Set.split (t + len) s -- リミットより前

丸を描画する関数を作る。Picture はモノイドなので foldMapmconcat を使って画像を組み合わせることができます。translate (V2 x y) を使って画像を (x, y) の座標へシフトさせます。bitmap b を使って BitmapPicture に変換します。

unsafePerformIO の型は IO a -> a であって見た感じとても見慣れない感じでしょう。unsafePerformIO の使用は getArgsreadBitmap のような__コンスタント__な操作にのみ限定されるべきです。

circle_png :: Bitmap
circle_png = unsafePerformIO $ readBitmap "assets/circle.png"

circles :: [Float] -> Picture
circles = foldMap (\p -> V2 320 ((1 - p) * 480) `translate` bitmap circle_png)

renderLanephases の結果を circles に渡します。 color で画像の色を指定します。

renderLane :: Set Time -> Time -> Picture
renderLane ts t = mconcat [color blue $ circles (phases ts 1 t)
    , V2 320 480 `translate` color black (bitmap circle_png) -- 基準
    ]

現時点での main はこんな感じです。

main = runSystemDefault $ do
  music <- prepareMusic "assets/Monoidal-Purity.wav"
  allTimings <- fmap (!!0) $ liftIO $ loadTimings "assets/Monoidal-Purity.txt"
  linkPicture $ \_ -> renderLane allTimings <$> getTime
  playMusic music
  stand

このプログラムにはまだ重要な問題点があります。なんらかの問題でプログラムがつまずくと画像と音楽が__ズレる__可能性があります。実際の時間ではなく音楽の時間を元にタイミングをとらないといけません。

コンポーネント: prepareMusic

リズムゲームにおいて音楽は欠かせません。

type Music s = Instance (StateT Deck (System s)) (System s)

prepareMusic :: FilePath -> System s (Music s)
prepareMusic path = do
  wav <- readWAVE path
  i <- new $ variable $ source .~ sampleSource wav $ Deck.empty
  linkAudio $ playbackOf i
  return i

readWAVE.wav ファイルから音楽をロードします。 source .~ sampleSource wav $ Deck.empty の部分が少々トリッキーです。

Deck は音楽を再生するためのユーティリティです。 sourceLens で純粋で関数型なアクセサの表現です。 new $ variable $ v が音楽を初期化します。 linkAudio $ playbackOf i は今はおまじないとでも思っててください。

コンポーネント: getPosition と playMusic

getPositionplayMusic の実装は以下の通りです:

getPosition :: Music s -> System s Time
getPosition m = m .- use pos

playMusic :: Music s -> System s ()
playMusic m = m .- playing .= True

ここで新しく2つの演算子が登場します: use.= です。これらは lens ライブラリからです。このパッケージは様々のアクセサを扱うための型やユーティリティを含んでいます。

posplayingLens です。 Lens' s a では a の値の取得の操作を s から出来ます。

pos :: Lens' Deck Time
playing :: Lens' Deck Bool

use(.=) はステートフルモナドに対して使える値を取得・設定するための演算子です。

use :: MonadState s m => Lens' s a -> m a
(.=) :: MonadState s m => Lens' s a -> a -> m ()

lens を利用すればオブジェクトの一部へのアクセスを容易にできて、オブジェクト指向言語でいうメンバー変数のようなものを操作が出来ます。ですが deck のステートは gameMainmusic にパックされているので直接は操作できません。 objective パッケージの (.-) 演算子は特定の操作を実行できます。

getPosition m は音楽 m からの経過時間を秒数で正確に返します。

ここまでのソースをまとめたのがこれです src/tutorial-passive.hs

$ dist/build/tutorial-passive/tutorial-passive

ですがまだゲームではありません。スコアもインタラクションもないです。

入力処理

入力の処理をしていきましょう。ここで新たに2つのコンポーネントを紹介します、 ratehandle です。

rate :: Time -> Int
rate dt
  | dt < 0.05 = 4
  | dt < 0.1 = 2
  | otherwise = 1

handle :: Time -> Set Time -> (Int, Set Time)
handle t ts = case viewNearest t ts of
  Nothing -> (0, ts) -- 曲は終了
  Just (t', ts') -> (rate $ abs (t - t'), ts')

rate はタイムラグを元にスコアを計算します。 handle はスコアと更新されたタイミングを返します。 viewNearest :: (Num a, Ord a) => a -> Set a -> (a, Set a) はセットから最も近い値を返す関数です。もし一番近い値を返すのに失敗したらでたらめにボタンを押すことで予期せぬスコアの増加を許してしまいます。

data Chatter a = Up a | Down a

以下のコードがイベントの処理をします。

linkKeyboard $ \ev -> case ev of
  Down KeySpace -> do
    t <- getPosition
    ts <- timings .- get
    (sc, ts') <- handle t ts
    timings .- put ts'
    score .- modify (+sc)
  _ -> return () -- 他のイベントは無視する

いくつかの変数が初期化されています。

timings <- new $ variable (allTimings !! 0)
score <- new $ variable 0

linkKeyboard が呼ばれる時、エンジンはキーボードのイベントを Key へ送ります。 Key はキーが押されてるか離されたかを識別するため Chatter によりラップされています。スペースキーが押された時、タイムラグを一番近いタイミングから計算し、正確度によってスコアを増加させます。

プレイヤーに現在のスコアを表示するために フォント もロードしないといけません。 Call.Util.Text.simple は与えられたテキストを描画する関数を返してくれます。

text <- Text.simple defaultFont 24 -- text :: String -> Picture

renderGametext (show sc) を追加するだけです。現時点のインタラクションを追加したソースはこちらです src/tutorial-active.hs 。ゲームですね!わーい!

$ dist/build/tutorial-passive/tutorial-active
tutorial-active

tutorial-active

ゲームを拡張する

実際に遊んでみるとガッカリしちゃうでしょう。インタラクションがまだあまりよくないからです。もっとかっこいいエフェクトとかあれば楽しくなります。最近のリズムゲームは判定をすぐさま表示します。そうすればプレイヤーは自分がちゃんとプレイが上手いかどうかすぐわかります。

純粋関数型なデザインのおかげさまでレーンを簡単に拡張できます( tutorial-extended.hs )!

extended

extended

ix i は リストの i番目の項目を指す lens です。 forM の結果を translate を使って配置すればよいです。

他に面白いのが transit でアニメーションを作るのに便利です。

type Effect m = Mortal (Request Time Picture) m ()

pop :: Monad m => Bitmap -> Effect m
pop bmp = Control.Object.transit 0.5 $ \t -> translate (V2 320 360)
  $ translate (V2 0 (-80) ^* t)
  $ color (V4 1 1 1 (realToFrac $ 1 - t))
  $ bitmap bmp

引数 t は0.5秒間隔で0から1の間まで変化します。初期化するにはこのオブジェクトをリストに追加してください:

effects <- new $ variable []
effects .- modify (pop _perfect_png:)

effects .- gatherFst id (apprises (request dt)) は使われなくなったアニメーションを破棄しながら Picture を返します。 objective のおかげで色々得しています。以下が linkPicture の部分です:

linkPicture $ \_ -> do
  [l0, l1, l2] <- forM [0..2] $ \i -> renderLane <$> (timings .- use (ix i)) <*> getPosition music
  s <- score .- get
  ps <- effects .- gatherFst id (apprises (request dt))
  return $ translate (V2 (-120) 0) l0
    <> translate (V2 0 0) l1
    <> translate (V2 120 0) l2
    <> color black (translate (V2 240 40) (text (show s)))
    <> ps

入力周りは難しいところはありません。

let touchLane i = do
      ((sc, obj), ts') <- handle <$> getPosition music <*> (timings .- use (ix i))
      effects .- modify (obj:)
      timings .- ix i .= ts'
      score .- modify (+sc)

linkKeyboard $ \case
  Down KeySpace -> touchLane 1
  Down KeyF -> touchLane 0
  Down KeyJ -> touchLane 2
  _ -> return () -- 他のイベントは無視する

GHC拡張の LambdaCase のおかげで \ev -> case ev of\case と置き換えることができます。

ゲーム全体でたったの120行です!

$ wc -l src\tutorial-extended.hs
120
$ dist/build/tutorial-passive/tutorial-extended

パート III: 技術的背景

グラフィックス

合成可能な物で "empty" を含む物のことをモノイドと呼びます。空の画像 やオーバーレイすることにより 画像の合成 が可能なので画像もモノイドです。標準ライブラリの base はモノイドの型クラスを定義しています:

class Monoid a where
  mempty :: a
  mappend :: a -> a -> a

Call は 自由モノイド を利用して画像を表現しています。

CPS(継続渡しスタイル)ではなく表現すると、

data Scene = Empty
  | Combine Scene Scene
  | Primitive Bitmap PrimitiveMode (Vector Vertex) -- プリミティブを描画する
  | VFX (VFX Scene) -- 視覚エフェクトを適用する
  | Transform (M44 Float) Scene -- 行列を使って `Scene` を変換する

モノイドインスタンスは単純です。

instance Monoid Scene where
  mempty = Empty
  mappend = Combine

自由モノイドを利用すると描画の部分を Scene と切り分けることができます。 drawScene :: Scene -> IO () はAPIを利用してSceneを描画します。空の画像の場合何もしません。 Combine a bdrawScene a >> drawScene b を呼ぶのと同義です。

drawScene の実装は以下のようになります:

drawScene Empty = return ()
drawScene (Combine a b) = drawScene a >> drawScene b
drawScene (Primitive b m vs) = drawPrimitive b m vs
drawScene (VFX v) = drawScene (applyVFX v)
drawScene (Transform mat s) = withMatrix mat (drawScene s)

drawPrimitiveapplyVFXwithMatrix は環境依存です。

自由な構造はドメイン固有言語の一種でプログラムの再利用を促進します。Andres Löh氏の Monads for free!は自由な構造について勉強したいならオススメです。

Call は様々な変換を Affine クラスに定義しています。型族のおかげさまで同じ変換を2Dと3Dで利用できます。 Normal は法線ベクトルで3Dでは三次元ベクトルですが2Dではただの Float です。

class Affine a where
  type Vec a :: *
  type Normal a :: *
  rotateOn :: Normal a -> a -> a
  scale :: Vec a -> a -> a
  translate :: Vec a -> a -> a

オーディオ

現在、多くの環境で簡単にインストールできるようなオーディオのパッケージは少ないです。その中で私は様々なバックエンドをサポートする portaudio を選択しました。

人間は音に敏感でほんの20ミリ秒の誤差でも気づきます。故に、音ズレは特に最小限に抑えたいです。これが Call がコールバックを利用する最大の理由です。Callは軽量で丈夫なライブラリを目指していて、抽象化は objective に任せています。

謝辞

山本和彦さんにこのチュートリアルの設計を手伝っていただきました。