Elmでテトリスを作った。この記事では実装にあたって考えたポイントをメモしておく。 コードは説明のために断片的に載せる。
製作物
ここで遊べる.
Repositryはこちら。
実装しなければいけない処理
大まかに作らなければいけないのは以下の処理.
- ボード・テトリミノのデータ構造
- テトリミノの出現・回転・落下・固定
- テトリミノの衝突判定
- ラインがそろった時に消滅する処理
- ゲームオーバー処理
- キー操作
- 画面描画
この中からいくつかの項目について説明する。
ボードのデータ構造
ボードの落とす場は10x20のブロックで構成されている。 壁をボードに含めるかどうか、上部にマージンを設けるかどうかで、実際のボードサイズは変わる。
まず、ボードのブロックをセルと呼ぶことにする。セルを次のように定義する。Color
は適当に定義しておく。
|
|
このセルを使ってボードを定義したいが、悩ましい選択が現れる。
- セルを要素に持つ
List
。セルの座標はリストの添字で判断する。 - (座標, セル)を要素に持つ
List
。 - キーを座標、値をセルとした
Dict
。
List
をArray
にした実装も考えられる。参考までに、3つは以下のように定義できる。
|
|
ボードのデータ構造によって諸々の関数の実装方法が大きく変わってくるので、どれを選ぶか慎重になる必要がある。
2つ目と3つ目のデータ構造はelm-gamesのRepositoryに載っているテトリスのコードから発見した。 それらのコードを見つけた時にはすでに1番目で作ってしまっていたので、現状の自分の実装は1番目のものである。
TEAのView関数としての扱いやすさを考えるなら、2番目の実装が一番良いと思う。例えばセルの描画関数をviewCell
として、viewBoard
は次のように書ける。
|
|
他のデータ構造で実装する場合でも、viewBoard
に渡す前に一旦{ pos, cell }
のデータ構造に変換しておいた方が書きやすい。
座標系については、SVGのものと合わせる。つまり、右向きがx軸正、下向きがy軸正のものとして扱う。
寄り道: ベクトルの定義
回転や平行移動などの計算が現れるため、ベクトルを定義しておくと便利。
|
|
適当にadd
やsub
、mul
などのベクトル演算を定義しておく。
テトリミノのデータ構造
以下のように代数的データ型で定義する。
|
|
Mino
の実際の形を定義する必要があるが、考えられるデータ構造は以下の2つ。
- ブロックそのものの2次元データ
- 相対的位置(x, y)のリスト
前者の場合もそこまで難しくはないと思うが、 後者が個人的にシンプルで良いなと思ったので後者を採用する。
後者の場合は、ブロック毎の回転数の最大値を持っていた方が良い。 例えば、Oテトリミノの場合は回転しても向きが変わらないため、回転数1である。 Super Rotation Systemを実装する場合は、O以外のテトリミノの回転数は4にする。
また、それ以外にも色の情報があるとよいので、 以下のようなデータ構造を実装することになる。
|
|
|
|
テトリミノ関連の実装
テトリミノの位置データ
落下中のテトリミノの種類、位置、回転数を持つデータ構造を作る。
|
|
テトリミノの回転
テトリミノの各座標を回転させれば良い。 (x, y) の座標変換について考える。半時計周りに90度回転させることを考えると、座標変換後は (y, -x) となる。 これは複素数平面を考えるなり、回転行列を掛けるなりして導出できる。ただし、下向きがy軸正になるため、 よくある上向き座標系とは符号が異なることに注意。
|
|
回転を複数回適用するために、applyN
関数を定義する。例えば、applyN 3 f
を評価するとf << f << f
が得られる。
|
|
テトリミノのボード上への反映
まず絶対座標を計算する関数を作る。MinoState
の情報をもとに、回転と平行移動の変換を行うたけ。
|
|
以下のputBlock
を定義しておけば、ボードと落下中のテトリミノを統合できる。
|
|
テトリミノの衝突判定
厳密には衝突というより、壁や他のテトリミノに被っているかどうかの判定を行う。 テトリミノが被るケースとして,以下の3つが考えられる.
- 左右移動: 右/左に移動させてみたら被った
- 固定: 下に移動させてみたら被った
- 回転: 回転させてみたら被った
|
|
テトリミノの固定
1つ下のラインとの衝突判定を行い、判定が真なら固定する。さらに拡張して、次のようにする。
lifeTime
というフィールドを用いることで、着地後少しの時間だけテトリミノを動かせるようにする- 回転キーが押されたら
lifeTime
を初期値に戻す。すると、回転連打している間は固定されないようになる。
キー状態の管理
長押ししているときはテトリミノが回転しないように実装する。そのためには、キーが長押しされているかどうかを判定する必要がある。
キーが押されたか、離されたかはonKeyDown
、onKeyUp
で判定できる。ところが「キーが長押しされているか否か」を判定するのは少し工夫がいる。
判定のために、キーの状態を管理するデータ型を定義する。
|
|
これを用いて、キーの状態を変更する関数を定義する。
|
|
キーが押されたかどうかのフラグとキーの状態は別々に定義しておく必要がある。
なぜなら、ゲームの1ステップのタイミングと、onKeyUp/onKeyDown
に関するMsg
が送られてくるタイミングが別だからである。
|
|
前者はonKeyDown
、onKeyUp
のときに更新され、後者はタイマーイベントの時に更新されるように実装する。
乱数の取得
テトリミノが固定されるたびに、新しいテトリミノが出現する。次の出現するテトリミノを決めるために、 elm/randomを利用する。
Cmd Msg
で取得せず、Random.step
で実装。ただし、初期のシードはRandom.independentSeed
で取得する。
Random.generate
を使わないのは、今回のケースだと実行順序が分かりづらくなるからだ。
Random.generate
を使うことで、ランダム値をMsg
として受け取ることができる。
今回はテトリミノをランダムに発生させたいが、それが起こるのは以下の太字のとき。
- テトリミノが固定される。
- 新しいテトリミノが生成される。
- そのテトリミノが画面上部におけるかチェックし、できなければゲームオーバー。
もしRandom.generate
を使いたいなら、Cmd Msg
としてTEAのランタイムに依頼することになる。
例えば擬似的に書くと以下のようになるだろうか。
|
|
そうなると、コード上で2を書く場所が分断されて読みづらくなる。
それを避けるために、Random.step
を使うことにした。すると、以下のように順番を意識して書ける。
|
|
Super Rotation System
回転させてみて衝突が起こった場合は、テトリミノを上下左右に動かせないか試してみる。 動かし方はテトリミノの回転した状態によって異なる。しかもO、Iテトリミノとそれ以外で場合分けが生じる。 これらの情報はTetris WikiのWall Kicksに載っている。
Wikiの"J, L, S, T, Z Tetromino Wall Kick Data"、“I Tetromino Wall Kick Data"に注目する。 回転前の状態と回転後の状態に依存していることが分かる。Wiki中の"0, R, L, 2"をそれぞれ次のように表現する。
|
|
Direction
の取得を行う関数を作成しておく。O以外のテトリミノのrotMax
は4であることに注意。
|
|
Wikiのテーブルの行を返す関数を返す。
|
|
Wikiのtableに書かれていることをコードに手書きするのは面倒。そこでHTMLパーサーをPythonで実装する。
まずWikiのtable
要素2つを、開発者モードやソースコードからコピーしてきて、適当なHTMLファイルに貼り付ける。ここではsample.html
としておく。
|
|
ここではBeautifulSoup4を使ってパースする(余談: 実はBeautifulSoup4を使うのはこれが初めて)。 Wikiの表記では座標系がy軸上向きに取られているため、y座標の符号を反転させる処理を施しておく。
|
|
実行すると以下のコードが出力されるので、これをElmのコードにコピペすればよい。
[J, L, S, T, Z Tetromino Wall Kick Data] (RotZero, RotRight) -> [Vec 0 0, Vec -1 0, Vec -1 -1, Vec 0 2, Vec -1 2] (RotRight, RotZero) -> [Vec 0 0, Vec 1 0, Vec 1 1, Vec 0 -2, Vec 1 -2] (RotRight, RotTwo) -> [Vec 0 0, Vec 1 0, Vec 1 1, Vec 0 -2, Vec 1 -2] (RotTwo, RotRight) -> [Vec 0 0, Vec -1 0, Vec -1 -1, Vec 0 2, Vec -1 2] (RotTwo, RotLeft) -> [Vec 0 0, Vec 1 0, Vec 1 -1, Vec 0 2, Vec 1 2] (RotLeft, RotTwo) -> [Vec 0 0, Vec -1 0, Vec -1 1, Vec 0 -2, Vec -1 -2] (RotLeft, RotZero) -> [Vec 0 0, Vec -1 0, Vec -1 1, Vec 0 -2, Vec -1 -2] (RotZero, RotLeft) -> [Vec 0 0, Vec 1 0, Vec 1 -1, Vec 0 2, Vec 1 2] [I Tetromino Wall Kick Data] (RotZero, RotRight) -> [Vec 0 0, Vec -2 0, Vec 1 0, Vec -2 1, Vec 1 -2] (RotRight, RotZero) -> [Vec 0 0, Vec 2 0, Vec -1 0, Vec 2 -1, Vec -1 2] (RotRight, RotTwo) -> [Vec 0 0, Vec -1 0, Vec 2 0, Vec -1 -2, Vec 2 1] (RotTwo, RotRight) -> [Vec 0 0, Vec 1 0, Vec -2 0, Vec 1 2, Vec -2 -1] (RotTwo, RotLeft) -> [Vec 0 0, Vec 2 0, Vec -1 0, Vec 2 -1, Vec -1 2] (RotLeft, RotTwo) -> [Vec 0 0, Vec -2 0, Vec 1 0, Vec -2 1, Vec 1 -2] (RotLeft, RotZero) -> [Vec 0 0, Vec 1 0, Vec -2 0, Vec 1 2, Vec -2 -1] (RotZero, RotLeft) -> [Vec 0 0, Vec -1 0, Vec 2 0, Vec -1 -2, Vec 2 1]
実装していて気づいたこと
asパターンマッチ
HaskellやPureScriptの@
と同じ機能を、as
として提供しているようだ。以下のように使う。
|
|
この文法について、Elmのドキュメントでは見つけられなかったが、FAQの方で情報があった
内部関数のミス
例えば以下のようなコードを書いたとする。このコード自体は以前に書いたもので、今は存在しない。
|
|
実は、fld
をfield
に書き間違えたことによって、無限再帰が発生してしまったことがあった。
このようなミスくらい自分で気づくべきかもしれないが、 ここでは「ミスを起こさないコードを書くためにはどうすればよいのか」について考える。
このミスがコンパイルエラーを引き起こさなかったのは、go
関数がより外のスコープであるfield
に参照できたからである。
よって、初めからこのようなミスを起こす余地をなくすためには、以下のように内部関数をやめてしまうのが良い。
|
|
しかし、今度は手軽さが失われてしまう。内部関数ならgo
みたいに雑な名前でも、グローバル空間を汚さないので問題ないのだが、
普通の関数として定義するとそうはしづらい。go
みたいにに命名が雑すぎると、今度はなんのための関数なのか分かりづらくなる。
結局、それぞれ一長一短がある。
ゲームの背後で行われる処理をモジュールに分割する
update
関数で行われている処理のうち、テトリスのゲームを進める部分はほんの一部である。
具体的には、タイマーイベントのMsg
であるTick
でしかゲームを進める処理を行っていない。
|
|
コードの見通しをよくするためには、以下の2つに処理を分割すべきなのかなと思った。
- テトリスのゲームを進める処理
- 乱数のシードの処理・キー操作に関する処理
後者を別モジュールに分割して、前者をMain.elm
で書けるようにすると良いのではないのかと思った。
例えば以下のように関数が定義できると良い。
|
|