LoginSignup
8
5

More than 5 years have passed since last update.

初心者が Elm 0.19 で 2048 を作るまで Part 2

Last updated at Posted at 2018-11-19

前回

Part 1

第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 にしちゃってるんですが、ゲームオーバー時の処理につながるので後で変えましょう。

さて Msgupdate にこれを加えていきます。

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
            )

さて確認すると…… 動きました! 汚いけどあとで直していきましょう。

2048.gif

ここまでのコミット

第7段階 初期盤面

どうやら 2048 は初期状態でタイルが2つ置かれるようですが、現状だと initPut コマンドを書いても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 ファイルを別個で取り込めるまでに至っていないので、とりあえず直書きします。

4096.gif

いい感じですね。データに応じてスタイルを変えられたり、コンポーネントとしてまとめられるのは強みではあります。

ここまでのコミット

第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)))

(前者は全方向動かすのを試しているので恐らく効率が悪いです。)

updateview もこれを使って書き換えます。これで大体完成です!

ここまでのコミット

大体完成

> 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 によるものなのか周辺環境によるものなのかはわかりませんが、相乗的なものではあると思います。

これから使ってみようかな、と思えるような言語でした。

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5