前回
第5段階 スライド操作
いよいよゲーム本体の記述に入ります。
おそらく状態の遷移をちゃんと捉えることが重要なので、2048 で考えてみると、
次のターンへの遷移: 特定の方向にスライド → ランダムにタイルを置く
のように分解できます。
まずスライド操作を分解していきます。とりあえず 2048 の挙動を確認すると、右にずらしたときに
_ _ 2 2 => _ _ _ 4
_ 2 2 2 => _ _ 2 4
_ 4 2 2 => _ _ 4 4
2 2 2 2 => _ _ 4 4
のようになります。これは単に map
するとかでは難しいので、fold
を利用することを考えて、以下のような遷移を考えてみます。
次のセル | 待ち | 完了 | |
---|---|---|---|
(初期状態) | |||
2 | → | 2 | |
2 | → | 4 | |
2 | → | 2 | 4 |
Empty | → | 2 | 4 |
2 | → | 4, 4 | |
4 | → | 4 | 4, 4 |
... |
つまり、やってくる次のセルに対して
- Empty は無視
- Tile だったら
- 受け入れ可能か
- マージ可能か
といった処理を考えればよいので、
type Accumulator
= Waiting Cell (List Cell)
| Done (List Cell)
accumulate : Cell -> Accumulator -> Accumulator
accumulate cell acc =
if cell == Empty then
acc
else
case acc of
Waiting waiting done ->
if waiting == cell then
Done (mergeCell cell waiting :: done)
else
Waiting cell (waiting :: done)
Done done ->
Waiting cell done
mergeCell
(s?) は2つのセルが Tile
なら中身を足して返す関数です。(Maybe.map2 (+)
の再生産ですね)
次は方向によって場合分けして fold
していきますが、個人的にはこれが一番難関でした。
とりあえず動くところまでいったコードは、List の List に変換して、右方向なら上のやつを素直に適用、左なら revserse して右にたらい回し、上下なら transpose してたらい回し…… という感じなんですが、結構 dirty になってしまったのでコミットのリンクだけ貼っておきます。いい方法が浮かばないんですよね、そのうちリファクタリングしたい。
第6段階 乱数の扱い
さて次は「ランダムな場所にランダムなタイルを置く」をやります。が、ランダムの扱いはまた面倒になります。というのも、純粋関数型言語では関数は同じ引数が与えられたら同じ結果を返さなければならず、Elm 自身はそのようなランダム性を扱えないからです(参考:https://qiita.com/sand/items/4efd8eeafd2c33778e08)。
というわけで Msg
にこれを足していきますが、0.19 からライブラリが core から外されたので、インストールしないと使えません。
> elm install elm/random
import Random
-- タイルを置ける座標を集める
emptyPositionList : Board -> List Position
emptyPositionList board =
board
|> Array.map Array.toList
|> Array.toList
|> List.indexedMap (\i -> List.indexedMap (\j -> Tuple.pair ( i, j )))
|> List.concat
|> List.filterMap
(\( position, cell ) ->
case cell of
Tile n ->
Nothing
Empty ->
Just position
)
-- タイルを置く場所をランダムに選ぶ
randomPosition : Board -> Maybe (Random.Generator Position)
randomPosition board =
let
positionList =
emptyPositionList board
in
case positionList of
[] ->
Nothing
head :: tail ->
Just (Random.uniform head tail)
-- 置くタイルをランダムに選ぶ
randomTile : Random.Generator Cell
randomTile =
Random.uniform (Tile 2) [ Tile 4 ]
Random.uniform
が、空リストで Maybe
になるのを防ぐために a -> List a -> Generator a
になっているのは面白いですね。まあ結局 Maybe
にしちゃってるんですが、ゲームオーバー時の処理につながるので後で変えましょう。
さて Msg
と update
にこれを加えていきます。
type Msg
= Slide Direction
| Put ( Position, Cell )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Slide direction ->
let
slidedBoard =
slideBoard direction model.board
cmd =
if model.board == slidedBoard then
Cmd.none
else
randomPosition slidedBoard
|> Maybe.map (\position -> Random.pair position randomTile)
|> Maybe.map (Random.generate Put)
|> Maybe.withDefault Cmd.none
in
( { model
| board = slidedBoard
}
, cmd
)
Put ( position, cell ) ->
( { model
| board = setBoard position cell model.board
}
, Cmd.none
)
さて確認すると…… 動きました! 汚いけどあとで直していきましょう。
第7段階 初期盤面
どうやら 2048 は初期状態でタイルが2つ置かれるようですが、現状だと init
に Put
コマンドを書いても1つしか置けません。
そこで最初に考えたのは Msg
を2回送るという方法ですが、色々見る限りこれは悪い方法らしいので、置ける場所をランダムに複数とってくるようにします。
とりあえず、リストから n 個の要素をランダムに取ってくる補助関数を作ります。
sample : Int -> List a -> Random.Generator (List a)
sample n list =
if n <= 0 then
Random.constant []
else
let
indexedList =
List.indexedMap Tuple.pair list
in
case indexedList of
[] ->
Random.constant []
head :: tail ->
Random.uniform head tail
|> Random.andThen
(\( index, element ) ->
let
omitted =
List.take index list ++ List.drop (index + 1) list
in
Random.map ((::) element) (sample (n - 1) omitted)
)
これは時間計算量 O(n * (length list)) くらいになるけど、多分大丈夫でしょう。
あとは位置生成器とタイル生成器を統合する関数を作り
randomPositionedTiles : Int -> Board -> Random.Generator (List ( Position, Cell ))
randomPositionedTiles n board =
let
positionList =
sample n (emptyPositionList board)
tileList =
Random.list n randomTile
in
Random.map2 (List.map2 Tuple.pair) positionList tileList
init
を変更します。
init : () -> ( Model, Cmd Msg )
init _ =
let
emptyBoard =
Array.repeat 4 <| Array.repeat 4 <| Empty
in
( { board = emptyBoard
}
, randomPositionedTiles 2 emptyBoard
|> Random.generate PutMany
)
PutMany (List ( Position, Cell ))
も適宜作ります。
これでやっと、初期状態でランダムな2つのタイルが置かれました。大したことないだろうと思ってたら割と詰まって、考えの改めが必要な回でした。
第8段階 スコア
次はスコアを記録していきます。といってもここはあまり難しくないです。各関数の返り値にスコアの増分に関する情報を付けます。差分を貼っておきます。とりあえず Tuple で返すようにしましたが、 Elm において Tuple を多用するのはあまり良くないらしい?ので、まあそのうち変えましょう。
第9段階 CSS
0.19 からスタイル指定が変更になって、
div
[ style
[ ("hoge", "100px")
, ("fuga", "100px")
]
]
[]
だったのが
div
[ style "hoge" "100px"
style "fuga" "100px"
]
[]
に変更になりました。このように Elm 内に直接スタイルを記述する方針は CSS in Elm と呼ばれており、メリットもデメリットもそれぞれあるようですが、今のところ大した記述量じゃないし、CSS ファイルを別個で取り込めるまでに至っていないので、とりあえず直書きします。
いい感じですね。データに応じてスタイルを変えられたり、コンポーネントとしてまとめられるのは強みではあります。
第10段階 終了条件
- これ以上動かせなくなった
- 2048 ができあがった
の2つの条件を判定していきます。
type alias Model =
{ board : Board
, score : Int
+ , over : Bool
+ , won : Bool
}
stuck : Board -> Bool
stuck board =
[ Left, Right, Up, Down ]
|> List.all
(\direction ->
Tuple.first (slideBoard direction board)
== board
)
won : Board -> Bool
won board =
toListBoard board
|> List.any (List.any ((==) (Tile 2048)))
(前者は全方向動かすのを試しているので恐らく効率が悪いです。)
update
と view
もこれを使って書き換えます。これで大体完成です!
大体完成
> elm make src/Main.elm --optimize --output=main.html
で HTML を吐くことができます。
これで遊べるようにはなりました。まだファイル分割や Html.Keyed
を使ったアニメーションなど課題は考えられますが、一旦完成ということにします。
感想
良いと思った点
- JavaScript を意識しなくてよい。
- 関数型といっても、関数型言語の中では学習コストが低いように思える。
- 実行時エラーが無かった。あるとしたらロジックのミスぐらいだったので、ロジックに集中できる。
- Elm Architecture に従って作っていけば、結構ごちゃごちゃしない。
- 標準で VDOM だし、パッケージを探せば割と色々な便利機能がある。
- reactor などの周辺ツールが整っている。公式に formatter がある。
悩んだ点
- 逆に JavaScript で楽だったことが Elm で極端に面倒になることはある。特に JSON や Random の扱いとか。
- CSS はどうするのか、ただこれは JavaScript のフレームワーク全般で課題になっていることではありそう。
- なんだかんだで webpack とかと組み合わせなきゃいけないのかなあというもやもや。
- (言語の問題ではないが、) elm-format するときにしょっちゅう
Access violation in generated code when reading ~~~
みたいなエラーが出てきて、何故かソースコードに追記されるので消さなければならずイライラした。Windows のせいらしいが未だよくわかってない。 - バージョンアップが破壊的(個人的にはそこまで気にしてはいない)
結局この良さが言語自体によるものなのか Elm Architecture によるものなのか周辺環境によるものなのかはわかりませんが、相乗的なものではあると思います。
これから使ってみようかな、と思えるような言語でした。