読者です 読者をやめる 読者になる 読者になる

手続き脳人間がWeb向け関数型言語elmを使ってゲームを書こうとしてみた

がまだ私には難しすぎる気がするよ……

elmはHaskellに似た構文を持つ関数型言語のAltJS。コンパイルするとJavaScriptが生成されるのでブラウザ上で動くゲームも作れる。なのでごく簡単なミニゲームをelmで作ってみた。

ゲームライブラリ相当の部分を除くと250行強というところなので、コードの分量的にはCoffeeScriptで書くのと似たようなものかちょい長めというところかなあ。でもコードを書く際には関数型言語ならではのかなり違う発想が求められるので、なかなか苦労する点も多い。elmについて、このミニゲームを書いた時に気づいた点をメモしておく。

elmいいところ

サンプルにPongがある

なんでelmを使ってみようかと思ったかというとオフィシャルにPongを作るサンプルがあるからですよ(ただし上記のサンプルまだver.0.13の書き方なので注意。今の最新のelmは0.14)。elmは単なる関数型言語ではなくて、 Functional **Reactive** Programmingのための言語で、その特性を活かしてゲームを作るためのエッセンスが、このPongの作り方に書いてある。

elmではSignalと呼ばれる、時間で変化する値を関数型言語の枠組みで扱うための仕組みがある。時間で変化する値とは、例えばゲームのプレイヤーからのキー入力など。

まだ理解が追いついてないのだが、Signalでキー入力を扱うと、時系列のキー入力状態がリストとして管理される。キーが入力されるとその情報がこのリストの末尾に追加される。

Signalにはこのリストを過去から畳み込むための関数foldpがある。過去から畳み込む!もうこの時点でお前は何を言っているんだ感があるが、要はこれら入力に応じて今から次の未来への変換を行う関数を発火させて、別のSignalを作る仕組み。まあゲームでいうところの毎フレームのUpdate処理だ。

elmのmain関数は画面上のElementのSignalとして定義されている。なのでUpdate処理で生成されるSignalからElementのSignalを作れば、ゲームの画面出力が得られる。なので必要なのは入力や時間経過のSignalをfoldpでゲーム状態のSignalにした上でそれをElementのSignalにmapする関数、とか言い始めるとよく分からなくなるのでPongのサンプルを見て下さい。とにかくPongのサンプルを見ればelmでゲームを作る方法がすぐに分かって素晴らしい。

コアライブラリで基本的な表示と入力が扱える

コアライブラリにあるGraphics.Collageでキャンバス上に書く丸や四角や線、Mouseでマウス入力、Keyboardでキー入力が扱えるので、ゲームに必要な基本的な入出力は外部ライブラリの助けを借りなくても実現できる。関数型言語のProcessingっぽい位置づけとも言える。

Keyboard.wasdっていう関数があるのが、elmでゲームを作りましょうという意図が感じられていいね。

ランタイムエラーが少なくなる

関数型言語コンパイルが通ればランタイムエラーがほとんど無いとよく言われるが、それは確かに感じた。ただそれが型のチェックのおかげなのか、関数型言語のおかげなのかはよく分からん。型チェックが欲しいだけならTypeScriptでもいいしなあ。

タイムトラベルデバッガ

elmを使って楽しいのがこのタイムトラベルデバッガ。普通にゲーム書くだけで、それを自由にポーズしたり、時間を巻き戻したりすることができる。関数出力を監視するDebug.watchと組み合わせることで、妙な挙動をしたところとその前後を簡単に調べることができる。これは便利。

elmイマイチなところ

なんでも型エラー

ささいな間違いがなんでも型エラー (Type mismatch)になる。しかもエラーメッセージが不親切。

例えば関数の引数の数を間違えたというささいな間違いに対して長大な型エラーが出力されたりする。関数に対して一部の引数を与えることができるカリー化でそういった関数も評価できてしまうせいもあるとは思うのだが、そういった間違いも無理やり評価してその結果いろんなところに型エラーを発見したことになってしまっている感じ。よく見ると本質的なエラーが書いてあるところもあるのだが、それ以外のエラー指摘に埋もれてしまっていて発見が非常に困難。

特にelmのコンパイラは型エラーはこのへんの記述に関係あります、というメッセージの下に関数まるごとが表示されたりすることが頻発するので、発見がより困難になる。

関数型への考え方の転換コスト

elmは関数型言語であるからにして代入を許さない。参照透過性を保つためだ。

これはゲームの書き方にいろんな影響を与える。Update処理はゲーム内オブジェクトを書き換えるのでは無く、1フレーム前の状態を見て今のフレームの状態を生成する処理になる。また逐次的な処理を行う際の書き方も、前状態から次状態の値を生成する関数を作り、その値を入力としてさらに次の値を生成する関数、という書き方になりがちで、その受け渡しをするための一時変数名が大量に必要になったりする。

この辺の記述の無駄さ加減がコードを書いている際に気になる。まあそのような逐次的な処理をしないような作りにしましょう、というのが正しいプラクティスなのかもしれんが、手続き脳人間にはなかなかつらい。

パースエラー

elmのパーサーこなれてないよ。特にレコード操作。elmはレコードにすでにあるフィールドをアップデートする時の構文(<-)と、フィールドを追加する時の構文(=)が違うのだが、これを間違った時にそこを指摘せずにその後ろのカンマを指摘したりする。あとアップデートはカンマで複数書けるのに追加は複数書けないとかいう構文自体の謎仕様もある(外部ライブラリのFocusを使えばマシにはなるが)。しかもその仕様を踏んだ時のエラーメッセージがまた謎。

あと型の宣言でのエラーや変数名の重複などについて、その行番号を教えてくれないのは何なんですかね。

結局Signalとは何者だったのか

Signal、結局よく分かってない。今困っているのは乱数のシードに与えるための、ゲーム開始時の時刻を取る方法が分からないこと。Time.timestampでSignal (Time, a)は取り出せるっぽいのだが、このTimeをRandom.initialSeedに流し込むのはどうすればいいんだ。

elm、書き方の発想が手続き型言語と違って書いている分にはいろいろ楽しいけど苦しみも多い。これからどうしようかなあ。もうちょっと継続して使って見るか、やめて手続き脳の山に帰るか、悩ましいところだ。