📚

Elmで倉庫番ゲームを作成してみた

2022/11/21に公開

Elmで倉庫番というゲームを作成してみました。倉庫番は、2次元のグリッド上で荷物を目的の位置まで運ぶゲームです。

https://github.com/tekihei2317/elm-sokoban-game

https://i.gyazo.com/feb74a06af49a73de0f0121ed470a4b3.png

Elmを使ってみた理由は、現在TypeScriptフルスタックでアプリを開発していて、バックエンドを関数型っぽく実装することの参考になると思ったからです。ちょうどプログラミングElmがKindle Unlimitedになっていたので、読んでElmで何かを作ってみようと思いました。

TypeScript による GraphQL バックエンド開発 - Speaker Deck

GitHubを見ていたらReactで作られた倉庫番[1]があったので、それを参考にElmで実装してみることにしました。

ecyrbe/sokoban: sokoban with react hooks and typescript

苦戦したところ

  • 盤面の状態持ち方
  • Maybe・Resultの扱い方
  • ありがちな処理のきれいな書き方が分からない

盤面の状態の持ち方

参考にしたコードでは、TypeScriptのEnumでマスがとりうる状態を持っていました。Enumを使っていた理由は、足し引きで状態遷移を簡潔に書けるからだと思います。その部分がわかりにくかったこともあり、素直に直和型で持つことにしました。プレイヤーと荷物はゴールの上に乗ることができるので、その状態も持っています。

type alias OnObjective =
    Bool

type Cell
    = Empty
    | Objective
    | Wall
    | Player OnObjective
    | Box OnObjective

type alias Stage =
    Array (Array Cell)

最初はListを使っていましたが、途中でインデックスが必要なことに気がついてArrayに変更しました

Maybe・Resultの扱い方

Elmは、実行時例外が原則起きないようなつくりになっています。JavaScriptを使っていると、しばしば「Cannot read property ‘x’ of undefined」というエラーを見ると思います。

そのようなエラーを防ぐために、Elmにはそもそもnullやundefinedが無く、代わりにMaybeという「ないかもしれない」ことを表す型があります。同様に、例外の代わりに「失敗するかもしれない」ことを表すResult型があります。

Maybeの定義は以下のようになっており、値がある場合はJustというコンストラクタに包まれます。

type Maybe a
    = Just a
    | Nothing

最初はMaybeの処理を主にパターンマッチで書いていましたが、パターンマッチを使うとネストが深くなう問題がありました。

例えば、衝突判定ではパターンマッチが3重になっていました。なぜかというと、衝突判定は3つのマスを見る必要があり、マスをArray.getで取得するとMaybeが返ってくるからです。

これはMaybe.map3を使うと簡潔に書けました。

-- before
case cells.current of
    Nothing ->
        noChange
    Just cell ->
        case cells.next of
            Nothing ->
                noChange
            Just nextCell ->
                case cells.afterNext of
                    Nothing ->
                        noChange
                    Just afterNextCell ->
                        updateJustNeighborhood cell nextCell afterNextCell

-- after
Maybe.map3 updateJustNeighborhood cells.current cells.next cells.afterNext
    |> Maybe.withDefault noChange

他にも、複数のMaybeでタプルを作ってパターンマッチする方法も便利でした。

case ( cell, nextCell, afterNextCell ) of
    -- 隣のマスが空マスの場合
    ( Player onObjective, Empty, _ ) ->
        { neighborhood =
            wrapCellsWithJust
                (cellAfterPlayerMoved onObjective)
                (Player False)
                afterNextCell
        , isPlayerMoved = True
        , openedBoxCountDiff = 0
        }
    -- 省略

ありがちな処理のきれいな書き方が分からない

例えば、以下のような計算で苦戦しました。

  • 2次元の盤面から、プレイヤーの初期位置を求める
  • 2次元の盤面から、荷物の数を数える

今は冗長な実装になっているので、簡潔な書き方にしたいです。

https://github.com/tekihei2317/elm-sokoban-game/blob/1b545491b4daa0a2d9e47768bc0340a896834379/src/Sokoban.elm#L170-L188

https://github.com/tekihei2317/elm-sokoban-game/blob/1b545491b4daa0a2d9e47768bc0340a896834379/src/Sokoban.elm#L191-L208

感想

ElmはWebフロントエンドを開発するための環境が整っていたので、すぐに開発を進められました。

コンパイルが通ったらほぼ正しく作れている、という安心感が良かったです。

最初は、elm-formatの積極的に改行をしたり空行を入れるスタイルにとまどいましたが、ちゃんと理由があることがわかって少しずつ受け入れられるようになりました。コードのDiffを最小化しようとしていることと、純粋な関数の集まりであれば行数が多くなってもあまり問題にならないということです。

関数型言語を使って自分で考えてコードを書くことは初めてだったので、新鮮で楽しかったです。

参考

脚注
  1. sokobanというタグがあってビックリしました ↩︎

GitHubで編集を提案

Discussion