筆者:村田
こんにちは。
今回はへびを動かすところから始めましょう。ちょっと復習です。へびは位置のリストで表していますので、例えば・・・
+----+
|* |
|* |
|** |
| |
| |
+----+
という上記盤面のへびは[(2,3); (1,3); (1,2); (1,1)]と表されます。頭は(2,3)です。さて、このへびが下に移動すると・・・
+----+
| |
|* |
|** |
| * |
| |
+----+
上記のようになります。おお!どちらもテトリミノの形してますが、テトリスじゃなくへびゲーですよ。んで、移動後のへびをリストで表すと[(2,4); (2,3); (1,3); (1,2)]となります。移動前のへびの頭は(2,3)でしたので、一つ下に移動するとY座標が+1されて(2,4)になります。次に胴体部分(1,3)は一つ右に移動するのでX座標が+1されて(2,3)になります。このように残りの胴体部分を順番に移動して新しいリストを作ればへび全体を移動することができます。
各胴体は頭に引っ張られて移動します。そのため上下左右どちらに移動するかを計算するためには、一つ前の胴体がどこにあったかを覚えて・・・って面倒ですね。先程の移動前のへびリストと移動後のへびリストをずらして並べてみましょう。
移動前: (2,3) (1,3) (1,2) (1,1)
移動後:(2,4) (2,3) (1,3) (1,2)
アルゴリズムが見えてきますね。頭を(2,3)から(2,4)に移動して元のリストの先頭に加えて、尻尾を一つ取り除けば移動したことになります。まずは移動したい方向に頭を追加する関数を作りましょう。関数名はadd_headで引数は移動方向とへび、戻り値は新しい頭を付けたへびを返します。移動方向は前回定義したmove型(Up,Down,Right,Left)を使います。というわけで書き出しはこんな感じ。
let add_head move snake = ...
次に移動対象となるへびの頭を取り出しましょう。へびはリストですのでList.hd関数を使ってList.hd snakeとすれば頭のタプルを取り出せます。ですが、前回扱ったパターンマッチを使って取り出してみることにしましょう。
let add_head move snake =
match snake with
[] -> []
| (head :: body) -> ...
パターンマッチの注意点その1を覚えていますか?パターンに漏れが無いように!です。まあ漏れていたらコンパイラが教えてくれます。というわけでリストをパターン分けましょう。3行目は空リストの場合です。なおゲーム開始時に必ず頭を1つ持つのでへびが空リストになることはあり得ません。しかし、add_head関数はそんなこと分かりませんので空リストのパターンも正しく書いてあげる必要があります。そして、4行目の(head::body)パターンで頭と胴体を変数に束縛しています。おさらいになりますが、間のコロン2つはコンスを表し、headには先頭要素が束縛され、bodyには残りのリストが束縛されるのでした。ゲーム初期状態の頭だけのへび[(1,1)]を渡すとheadには(1,1)が束縛されます。ではbodyには?・・はい、空リスト[]が束縛されます。
それと、へびの頭のx,y座標を計算に使う必要がありそうなので、headをさらに細かくパターンマッチしておきましょう。あとbody部分は使わないので、変数の代わりにワイルドカードパターン_(アンダースコア)にしましょう。ワイルドカードパターンにマッチした部分は後から参照できません。パターンのごみ捨て場ですね。というわけで以下のようになります。
let add_head move snake =
match snake with
[] -> []
| ((x,y) :: _) -> ...
次に、移動方向moveを見て新しい頭の座標を作ります。Upならy座標を-1、Downならy座標を+1ですね。moveによる場合分けはif式でできます。なお恒等検査は=でできます。
if move = Up then
...
else if move = Down then
...
しかし、ここもパターンマッチを使った方がすっきり書けます。こんな感じですね。
let add_head move snake =
match snake with
[] -> []
| ((x,y) :: _) -> match move with
Up -> (x,y-1) ...
| Down -> (x,y+1) ...
それでは新しい頭を既存のへびリストの先頭にくっつけて完成です。
let add_head move snake =
match snake with
[] -> []
| ((x, y) :: _) ->
match move with
Up -> (x,y-1) :: snake
| Down -> (x,y+1) :: snake
| Right -> (x+1,y) :: snake
| Left -> (x-1,y) :: snake;;
そして尻尾を一つ切り落とす関数も必要ですね。リストの尻尾を一つ切り落とす標準関数はないかな〜とListモジュールを探してみましたが見つかりませんでした。自作することにします。再帰ループやforループ式で書くこともできますが、さっきListモジュールを眺めていたら便利な関数があったので、関数の組み合わせで書けそうです。それに関数の組み合わせで書いた方が関数型プログラミングっぽくなりそう:) 使えそうな関数はList.tl関数とList.rev関数です。List.tl関数はリストの「頭を除いた残り部分」を取り出します。(”tl”の読み方はトルじゃなく、テイル(tail)です)
# List.tl [1; 2; 3];;
- : int list = [2; 3]
もう一つの関数、List.rev関数はリストを逆順にします。
# List.rev [1; 2; 3];;
- : int list = [3; 2; 1]
さて、関数の組み合わせが浮かんできませんか?
こんな風にしたらどうでしょう。まずリストを逆順にしてから頭を除くことで尻尾を切ったことになりますね。それからもう一度逆順にして向きを戻します。試してみましょう。
# List.rev (List.tl (List.rev [1; 2; 3]));;
- : int list = [1; 2]
尻尾切り落とせましたね^^b さてこれも関数にしておきましょうか。
let drop_tail snake =
List.rev (List.tl (List.rev snake));;
試してみましょう。
# drop_tail [1;2;3];;
- : int list = [1; 2]
うまくいきましたが、空リストを渡すと・・
# drop_tail [];;
Exception: Failure "tl".
『全域関数と部分関数』
List.tl関数でエラーです。空リストの頭を除くなんてできないからですね。どうしましょう。前述の通り、へびの仕様として空リストはあり得ないので、この問題をほっておいてもいいのですが、パターンマッチで空リストかどうかチェックすることで堅牢な関数にできます。なお、どんな引数でもちゃんと値を返してくれる関数を全域関数(total function)と言います。逆に今回の空リストのように引数によっては値を返してくれず、例外が発生したり、処理が終わらなくなってしまう関数のことを部分関数(partial function)と言います。ではdrop_tailを全域関数にしてみましょう。
let drop_tail = function
[] -> [];
| snake -> List.rev (List.tl (List.rev snake));;
前回登場させたfunction式を使ってみました。空リストのときは空リストを返すだけです。試してみましょう。
# drop_tail [];;
- : 'a list = []
うまくいきましたね。次はadd_head関数とdrop_tail関数を組み合わせて、へびを移動させるmove_snake関数を作ります。
let move_snake move snake =
drop_tail (add_head move snake);;
では試してみましょう。まずは適当にへびを定義して・・・
# let snake = [(2,3); (1,3); (1,2); (1,1)];;
val snake : (int * int) list = [(2, 3); (1, 3); (1, 2); (1, 1)]
move_snake関数を使ってみましょう。
# move_snake Down snake;;
- : (int * int) list = [(2, 4); (2, 3); (1, 3); (1, 2)]
OKOKうまく移動してくれました。次はどの部分に取りかかろうかな。へびはリンゴを食べると尻尾が伸びるのでした。これも難しくありません。へびがリンゴを食べるのは移動したときです。何もしないでいきなりリンゴを口にすることはできないのです。そんなにこの世界は甘くないのです。んで、先程作ったへびの移動関数は最後に尻尾を切っていますね。この関数をちょっと直して、移動した先にリンゴがあった場合は、尻尾を切らないようにすれば以下のように尻尾が伸びたように見えます。
移動前: (2,3) (1,3) (1,2) (1,1)
移動後:(2,4) (2,3) (1,3) (1,2) (1,1)
では、へびの頭がリンゴを口にしたかどうかをtrue/false判定する関数を作りましょう。is_eated関数はリンゴの位置とへびリストを引数にします。snakeの頭(head)とリンゴの位置が一致するかどうかをtrue/falseで返します。それと空リストにも対応して全域関数にしています。
let is_eated apple snake =
match snake with
[] -> false
| (head :: body) -> head = apple;;
# is_eated (1,1) [(1,1); (1,2); (1,3)];;
- : bool = true
# is_eated (2,2) [(1,1); (1,2); (1,3)];;
- : bool = false
テストもOKです。ではこれをmove_snake関数に組み込みましょう。move_snake関数の引数を増やしてリンゴの位置も教えてあげます。う〜ん、move_snakeの引数にappleか〜、あんまりいい設計じゃない気もしますが、いっちゃえ。
let move_snake move snake apple = ...
まずは、普通に頭を伸ばしましょう。頭を伸ばした新しいへびはsnake’にローカル束縛します。
let move_snake move snake apple =
let snake’ = add_head move snake in
...
頭を伸ばしたへびがリンゴを食べたかどうかで、尻尾を切り落とすかを決めます。
let move_snake move snake apple =
let snake' = add_head move snake in
if is_eated apple snake' then
snake'
else
drop_tail snake';;
REPLで試してみましょう。snake変数はさっきの定義が残っているものとします。へびの移動先(2,4)にリンゴを置いておきます。
# move_snake Down snake (2,4);;
- : (int * int) list = [(2, 4); (2, 3); (1, 3); (1, 2); (1, 1)]
OK。ちゃんとへびがリンゴを食べて成長しています。リンゴを食べられない位置に変えると・・・
# move_snake Down snake (2,5);;
- : (int * int) list = [(2, 4); (2, 3); (1, 3); (1, 2)]
こちらもOK。ちゃんとへびが移動しています。次はどうしましょうかね。リンゴを食べちゃったら新しいリンゴをランダムに出現させることにしよう。ランダムな値を作る便利な関数ないかな〜とライブラリを探すと、ありました。Random.int関数です。Random.intに5を渡すと0〜4をランダムに返してくれます。引数が同じでも結果が違うので副作用関数でありますな。Haskellではちょっと面倒なRandom処理ですが、OCamlでは副作用が書きやすくて簡単に使えます。
# Random.int 5;;
- : int = 4
# Random.int 5;;
- : int = 0
# Random.int 5;;
- : int = 2
これで新しいリンゴを生成するnew_apple関数が書けそうです。ただし、既にへびの頭や胴体がある部分にリンゴは出現できません。引数にsnakeを渡してもらってへびの位置をチェックしましょう。もし新しいリンゴがへびの位置が被ったら、ループしてもう一度リンゴを作り直しましょう。再帰ループを書くのでlet recから始めますよ。
let rec new_apple snake =
let x = (Random.int 5) + 1 and
y = (Random.int 5) + 1 in
if List.mem (x,y) snake then
new_apple snake
else
(x,y);;
一気に書きました。2,3行目で新しいx,y座標を作り出しています。+1しているのは0〜4のランダム値を1〜5に変えるためですね。あ、List.mem関数(member)を説明していませんでした。第1引数の要素が第2引数のリストに含まれていればtrueを返す関数です。ちょっと試してみますか。
# List.mem 2 [1; 2; 3];;
- : bool = true
# List.mem 0 [1; 2; 3];;
- : bool = false
そんなわけで(x,y)というリンゴがsnakeリストに含まれる場合はnew_appleを再帰呼び出しして作り直します。それではnew_apple関数を試してみます。snake変数は前に定義したものを使い回しています。
# snake;;
- : (int * int) list = [(2, 3); (1, 3); (1, 2); (1, 1)]
# new_apple snake;;
- : int * int = (2, 5)
# new_apple snake;;
- : int * int = (1, 5)
リンゴを作れました。ではリンゴを食べちゃったら新しいリンゴを作りましょう。リンゴを食べたかどうか分かるポイントってmove_snake関数の中か・・・う〜ん、move_snakeの中でリンゴを作る関数を呼び出すのか〜・・ますます関数名と処理が乖離してきたな。こいつはちょっとリファクタリングした方が良さそうですね。へびの位置状態やリンゴの位置状態が変わる関数・・要はゲームの状態が変わる関数なんですよね。うん、関数名を変えるだけでいいかな:P move_snake関数改めupdate_state関数とします。リンゴを食べるかどうか判定するところまでは同じです。
let update_state move snake apple =
let snake' = add_head move snake in
if is_eated apple snake' then
snake'
...
リンゴを食べちゃったなら、新しいリンゴを作って返しましょう。新しいへびと新しいリンゴをタプルにしてセットで返します。
let update_state move snake apple =
let snake' = add_head move snake in
if is_eated apple snake' then
(snake', new_apple snake')
else
...
elseの方はどうしましょう。リンゴを食べれなかったので、へびの余分な尻尾を切り、りんごの位置は変化しません。
let update_state move snake apple =
let snake' = add_head move snake in
if is_eated apple snake' then
(snake', new_apple snake')
else
(drop_tail snake', apple);;
試してみましょう。snakeは定義済みですね。へびの頭の下(2,4)にリンゴを置いておき、へびを移動させて食べさせます。
# snake;;
- : (int * int) list = [(2, 3); (1, 3); (1, 2); (1, 1)]
# update_state Down snake (2,4);;
- : (int * int) list * (int * int) =
([(2, 4); (2, 3); (1, 3); (1, 2); (1, 1)], (1, 5))
へびは伸び、新しいリンゴ(1,5)も返ってきていますので成功です。
『ゲームの状態の扱い』
それにしてもこの関数は引数が多いですね。他の言語ではmove,snake,appleといったゲームの中心となる状態はグローバル変数に置くか、変更可能オブジェクト(構造体やクラスのインスタンス)にまとめて、それらを関数から参照したいところですよね。しかし、関数型プログラミングではグローバル変数も変更可能オブジェクトも使えません。状態はすべからく引数で渡す必要があり、また新しい状態は戻り値で返すしかないのです。したがって、複雑なゲームを関数型プログラミングで作る場合は、状態引数が増加し手に負えなくなってきます。ではゲームは関数型プログラミングに向いていないのでしょうか?そんなことはありま温泉。いやオブジェクト指向言語の方が楽でしょうけど、なんとかなるさ!HaskellではStateモナドという仕組みを使って、今回のように状態を引数に羅列しなくても、まるで「グローバル領域がある」かのように、状態の取り出しや格納が行えます。いつか紹介できればいいなと思います。OCamlではオブジェクトにしてもいいし、グローバル変数も使えますのでゲーム状態を関数引数からひっぺがすことは雑作もないことです。ただまあ、今回はゲームの状態を変える関数がupdate_stateだけですし、関数型っぽくいきたいのでこのままにしておこうと思います。
さてと、ゲームの中心ロジックもできました。表示関数や入力関数も前回作ったし、あとは「入力〜ロジック〜表示」のゲームループを作るだけですが、今週はこの辺で。
以上