純粋関数型技術メモ

内容は副作用を含みます

Functional Reactive Programming でテトリスを書いてみる(1) [Haskell]

FRP(Functional Reactive Programming)というスタイルがある.入力から出力を得る(あらゆる)プログラムを関数風に表現する手法らしい.遅延ストリームは純粋関数型言語にぴったりだと感じるが,あまり流行っていないような気がする.

HaskellではFRPのライブラリが複数あるそうだが,よく分からないのでボトムアップに自作してみよう.ゲームといえば入出力と内部状態の塊,楽しそうなのでCUIで動くテトリスを題材とする.

基本

FRPにはEventとBehaviorという概念がある.
Eventは,時間と値の組のリストで,離散的なイベントを表す.例えば,キーが押されたというイベント,画面が更新されたというイベントなど.
Behaviorは時間の関数で,連続的な値を表す.例えば,経過時間や現在のスコアなど.

type Time = Double
newtype Event a = Event [(Time, a)]
newtype Behavior a = Behavior (Time -> a)

Event

テトリスに必要なのは文字列の入出力程度なので,プログラムは文字のイベントとして表現する.例えば ,

[(0, 'A'), (1000, 'B'), (2000, 'C')]

このようなイベントを実行すると,まず'A',1000ms後に'B',2000ms後に'C'が出力されるものとしよう.
実行する関数を作る.
 

import Control.Concurrent
import Control.Monad
import System.IO

data Output = Put Char

run :: Event Output -> IO ()
run (Event o) = foldM_
    (\t (t', Put c) -> do
        threadDelay . truncate $ (t' - t) * 1000
        putChar c
        hFlush stdout
        return t'
    ) 0 o

main :: IO ()
main = run $ Event [(0, Put 'A'), (1000, Put 'B'), (2000, Put 'C')]
ABC

時間と文字のリストを表示していく.一つ前のイベントとの差分時間だけ待機したのち,文字を出力するのを繰り返す.
例えばイベントが,

main :: IO ()
main = run $ Event [(t * 1000, Put 'A') | t <- [0..]]
AAAAA ...

このように無限リストでも問題ない.遅延評価のおかげで1秒ごとに'A'を延々表示し続けることができる.

使いやすいように,いくつか関数を定義しておこう.

{-# LANGUAGE TupleSections #-}

puts :: Event String -> Event Output
puts (Event xs) = Event $ foldr (\(t, s) ys -> map ((t,) . Put) s ++ ys) [] xs

atTime :: Time -> Event ()
atTime t = Event [(t, ())]

atTimes :: [Time] -> Event ()
atTimes ts = Event $ map (, ()) ts

putsは,文字列のイベントを変換して文字出力に使えるようにする.
atTimeは一回きり,atTimesは複数回の時間から何もしないイベントを生成する.EventをFunctorのインスタンスとし.これに値を被せれば,

import Control.Arrow

instance Functor Event where
    f `fmap` Event xs = Event $ map (second f) xs

main :: IO ()
main = run $ const (Put 'A') <$> atTimes [1,1000..]
AAAAA ...

Behavior

ゲームをプログラムに落とし込む時,普通なら一定間隔で画面を書き換えると考えるかもしれない.だがここでは,ゲーム画面は連続的な状態をとり,それをサンプリングして表示するようにして実装を分離しよう.表示部分はEventだが,ゲーム自体はBehaviorとなる.

instance Functor Behavior where
    f `fmap` Behavior g = Behavior $ f . g

snapshot :: Behavior b -> Event a -> Event (a, b)
snapshot (Behavior f) (Event xs) = Event $ map (\(t, b) -> (t, (b, f t))) xs

time :: Behavior Time
time = Behavior id


frames :: Event ()
frames = atTimes [0,100..]

view :: Behavior String
view = (++ "\n") . show <$> time

main :: IO ()
main = run . puts $ snd <$> view `snapshot` frames
0.0
100.0
200.0
300.0
...

snapshotは,BehaviorをEventでサンプリングし,結果をタプルのイベントとして返す.
timeは現在の時間を表すBehaviorだ.
ゲーム画面は文字列のBehaviorとし,フレームのイベントでサンプリングする度に出力する.
ここでは,ゲームは経過時間を表示するBehaviorとなっている.

Eventの合成

今は出力がだらだらと表示されるが,ゲームなのでフレームごとに画面をクリアしたい.コマンドを追加する.

import System.Console.ANSI

data Output = Put Char
            | Clear

run :: Event Output -> IO ()
run (Event o) = foldM_
    (\t (t', o) -> do
        threadDelay . truncate $ (t' - t) * 1000
        case o of
            Put c -> do
                putChar c
                hFlush stdout
            Clr -> clearScreen
        return t'
    ) 0 o

clearView :: Event Output
clearView = const Clr <$> frames 

フレーム毎に画面をクリアするclearViewイベントができた.
これを先ほどのゲーム画面を出力するイベントと合成したい.

2つのEventを合成すると,時間順に並んだひとつのEventになれば便利だ.EventをMonoidのインスタンスにする.

instance Monoid (Event a) where
    mempty = Event []
    Event as `mappend` Event bs = Event $ go as bs
        where
            go ((t, a):xs) ((t', b):ys) =
                if t <= t' then (t,  a): go xs ((t', b):ys)
                           else (t', b): go ((t, a):xs) ys

clearDisplay :: Event Output
clearDisplay = const Clr <$> frames

gameDisplay :: Event Output
gameDisplay = puts $ snd <$> view `snapshot` frames

main :: IO ()
main = run $ clearDisplay `mappend` gameDisplay

同時のイベントは左辺が先になる.フレーム毎に,画面がクリアされてから新しい画面が出力されるようになった.

テトリスのデータ

フィールド

テトリスのフィールドを表示させる.これまでの関数を使って,

data Tetris = Tetris {field :: [[Cell]],
                      score :: Integer}

instance Show Tetris where
    show (Tetris f s) = "score: " ++ show s ++ "\n" ++ 
                        unlines (map (concatMap show) f)

data Cell = E | B | W deriving Eq -- Empty, Block, Wall

instance Show Cell where
    show E = "  "
    show B = "██"
    show W = "▒▒"

fieldWidth  = 10
fieldHeight = 20

initialField = replicate fieldHeight ([W] ++ replicate fieldWidth E ++ [W])
               ++ [replicate (fieldWidth + 2) W]

game :: Behavior Tetris
game = Tetris initialField . truncate <$> time

view :: Behavior String
view = show <$> game
score: 0
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

テトリミノ

テトリミノ(ブロック)を表示,落下させる.
左上が原点で,横軸がx・縦軸がyとする.

data Tetris = Tetris {field :: [[Cell]],
                      tetrimino :: [[Cell]],
                      pos :: (Int, Int),
                      score :: Int}
instance Show Tetris where
    show (Tetris f t p s) =
        "score: " ++ show s ++ "\n" ++
        unlines (map (concatMap show) $ merge p t f)

merge :: (Int, Int) -> [[Cell]] -> [[Cell]] -> [[Cell]]
merge (x,y) t = let ps = map ((+) x *** (+) y) $ blocks t
                in mapWithPos (\p c -> if p `elem` ps then B else c)
    where
        blocks :: [[Cell]] -> [(Int, Int)]
        blocks cs = concatMap (\(y, xs) -> map (,y) xs) $
            indexed $ map (map fst . filter ((== B) . snd) . indexed) cs

        indexed :: [a] -> [(Int, a)]
        indexed = zip [0..]

        mapWithPos :: ((Int, Int) -> a -> b) -> [[a]] -> [[b]]
        mapWithPos f ass = map (\(y, as) ->
                               map (\(x, a) -> f (x, y) a) $ zip [0..] as)
                           $ zip [0..] ass

initialField = replicate fieldHeight
                    ([W] ++ replicate fieldWidth E ++ [W])
               ++ [replicate (fieldWidth + 2) W]

tetriminoS = [[E,E,E,E],
              [E,B,B,E],
              [B,B,E,E],
              [E,E,E,E]]

game :: Behavior Tetris
game = (\t -> Tetris initialField tetriminoS (5, truncate (t / 1000) - 2) 0) 
       <$> time
score: 0
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒          ████      ▒▒
▒▒        ████        ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒                    ▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

y座標を時間に対応させて,S字のテトリミノが落ちていくようになった.
ゲーム状態のBehaviorからshow関数でゲーム画面を得ている.

それぞれの関数は純粋でも,状態変化を扱うことができた.

今回はここまで.

> 次回(2)

今回のソース