本記事は Elm2(完全版) Advent Calendar 2018 の19日目の記事です.

ライフゲームを Elm で作りました。 ウェブアプリケーション(?)としては,鉄板中の鉄板ですね. 完全に一発ネタ+思いつきでやったのですが,Elm の最新バージョンによる違いもあり,いくつか躓いたのでそのメモ書きです(巷の資料の多くは旧バージョン). まぁそれでも2日ぐらいでできるので Elm は便利ですね.

完成品はコレで,コードは GitHub においてある.

ライフゲーム

ライフゲームのルールは:

  • 囲碁や将棋のような NxM マスの盤上(今回は正方形 NxN)
  • マスの状態は「生」と「死」がある
  • 状態の更新の規則は以下の3つ
    1. 生の状態の回りに生の状態のマスが2つか3つならば生のまま
    2. 死の状態の回りに生の状態のマスが3つならば生になる
    3. それ以外は死の状態になる

ここでいう「回り」というのは,自身のマスの周囲8マスのことを指す.

作ったもの

一般的なライフゲームに加えて,次のようなことを実現した.

  1. レンジスライダーで盤面の粒度をコントロール
  2. レンジスライダーで盤面の更新間隔をコントロール
  3. URLのクエリから生と死の画像を上書き
  4. スマホでも動作するように Touch イベントをいい感じに

実装について

次の記事を参考にした:

記憶に新しいのでステップバイステップにまとめる.

盤面の描写

まずはモデルを考える. 適当にパッケージを探して見たが, Elm 0.19 に対応している良さげなものはなかったので自作することにした:

今回は正方形を想定するので size は一辺のマス数にする. つまり初期化関数は次のようになる.

次に盤面をどうやって描写するかを考えた. テーブルでゴリゴリ書くのもいいかなと思ったが,あんまりエレガントではない気がした. ヒントを得るために GitHub をブラブラしてたら個人ページの左下の組織アカウント一覧に目が行った. HTMLを見てみると,これは直列に繋いだ div を適当なタイミングで折り返しているようだ. このやり方なら cellssize 個ごとに行へとする必要がなく,完全にCSSだけでなんとかなる.

結果こんな感じ

プログラムの中で割り算を記述するのは気がひけるが,まぁ上手く描写されているのでよしとする.

粒度スライダーの導入

スライダーには次のパッケージを利用した:

今回は SingleSlider を使いたい. SingleSlider の中に ModelMsg などが定義されているので,それらを適切に使えば良い.

結果こんな感じ

Model の初期化関数,updateview メソッドがそれぞれあって,それを呼び出して map するだけ. こういう風に細かいパーツを呼び出すだけでできるデザインいいですよね.

状態と入力

現状はまだ全セルが死んでいる状態なので,何らかの入力を受け取って好きなセルを生きてる状態にできるようにする必要がある. まずはPCだけ考えるとして,できればセルを一個一個クリックして更新する形にはしたくない(めんどくさいから). 生状態にできるかどうかのフラグと,オンの時だけマウスオーバーで生状態にするようにしたい. なので,まずはフラグを Model に追加した:

planting が真のときだけマウスオーバーでセルを生状態にできる(ようにする). したがって「planting のオンオフ」と「セルを生状態にする」の二つの Msg が必要だ:

そして,盤上をクリックして planting のオンオフをし,マウスオーバーで生状態にするように viewMsg を追加する:

マウスイベントには,おいおいスマホ対応もできるように mpizenberg/elm-pointer-events パッケージを利用した. あとは main 側を書き換えれば出来上がり:

結果こんな感じ

更新を追加

いよいよライフゲーム化. まず,上述した状態変化の定義を関数(nextCell)にする:

countAroundAliveCell は「回り」の生状態のセル数を返す想定. ここで少し大変. cells を2次元配列ではなく,1次元配列にしてCSSで折りたたむようにしてしまったので,壁際にあるかどうかの判定をインデックスと盤面のサイズから導く必要があった:

これで更新部分はできた. 次に nextBoard 関数を呼び出すタイミングを subscriptionsMsg で定義する:

結果こんな感じ

時間スライダーの導入

ついでに更新間隔の時間もスライダーで設定できるようにした. やり方は簡単で,Model にもう一つ SingleSlider を生やせばいい:

これでスライダーが増えた. あとは subscriptions のところを書き換えるだけ:

簡単ですね. 結果こんな感じ

URLパーサー

生状態や死状態の画像を好きなのに変えたいなと思った. そこで,ちょうど elm/url の勉強をしたので,url のクエリから指定できるようにしようと考えた. まずは状態の画像のリンクを Board に持たせる:

次は URL から値を取得する. URL を取得するには Browser.application を使う必要がある:

.onUrlRequest.onUrlChange は SPA 内で URL を変更して遷移した場合に使う. 今回はおそらく不要だが適当にそれっぽい Msg を生やした:

さぁいよいよ URL のパーサーだ:

今回の要件ではクエリしか必要ないので { url | path = "" } とパースする前にした. 現状の全体のコードはこんな感じ(ellie は application を動かせない). これで https://matsubara0507.github.io/lifegame?alive=http://4.bp.blogspot.com/-_A6aKYIGbf8/UOJXnVPCmQI/AAAAAAAAKH0/CHFd0OPz0Hk/s180-c/virus_character.png などで状態の画像が指定できるようになった.

スマホ対応

最後にスマホでもできるようにした. 色々試行錯誤してみたが,マウスのような onOver を使うことはできない. マウスのように一筆書きのみたいに入力するには Touch.onMove を使うしかなく,このためには ModelTouch.onMove イベントで取得した値を保持させる必要があった:

.touchPos を更新するために BoardMsgview を書き換える:

確か .preventDefaultTrue にするとスワイプ(?)で画面が動いてしまうのを止めてくれるらしい. さて問題はここから. cells を1次元配列にしてしまった弊害パート2で,この .touchPos からなんとかして配列のインデックスを出さなきゃいけない. 幸いなことにセル一つの大きさは相対サイズにしていたので,盤全体の実際の大きさとセル数がわかれば逆算できる. 盤全体の大きさを得るには Dom.getElement を使う必要があり,そのためには BoardMsg を追加する必要があった:

これで完成. ちなみに,最初は全てのセルの Dom.getElement して,element.width を比較する全探索方式でやってみたが,遅すぎて使い物にならなかったので,逆算するようにした. まぁ多少誤差があったってもともと指でなぞってるだけなのでいいでしょう.

ちなみに,.touchPos みたいな要素を盤面の Model に入れるべきか?って気がするが,今回はやっつけなので大目にみてください.

おしまい

無駄にコードを貼りまくったせいで長くなってしまった. できたアプリ,意外と気に入ってます.