elm-playgroundでT-Rex Gameもどきを作る

Advent Calendar参加してます

この記事はElm2 Advent Calendar 2019の15日目の記事です。

前置き

Elmでアクション性のあるゲームを作ってみたい、と思った。
しかしフレームごとの処理を一から実装するのは大変に面倒そうだ。
そういったゲームを一度も作ったことがない自分がそういう基本的な部分から作り出しても、
ゲームとして成立するところまで到達できずに挫折するのは目に見えている。

そこで、Elm作者であるevancz氏作のゲームライブラリelm-playgroundを試してみることにした。
以下ページの「Playground」にあるサンプルを見るに、比較的簡単なコードでそれっぽいものを作れそうだ。

https://elm-lang.org/examples

今回は習作としてGoogle Chromeがオフラインのときに遊べるアレ、T-Rex Gameもどきを作ってみる。
elm-playgroundの簡単な解説も兼ねて。

バージョン

インストール

パッケージをインストールするだけ。

$ elm install evancz/elm-playground

写経

ドキュメントは以下にある。

https://package.elm-lang.org/packages/evancz/elm-playground/latest/Playground

眺めているだけではよく分からないので、とりあえずgameの一番下にあるサンプルコードを写経してみる。

module Main exposing (main)

import Playground exposing (..)

main =
  game view update (0, 0)

view computer (x, y) =
  [ square red 40
      |> move x y
  ]

update computer (x, y) =
  ( x + toX computer.keyboard
  , y + toY computer.keyboard
  )

これをelm reactorで実行する。
キーボードの十字キー入力で赤い正方形を移動させられる。たったこれだけのコードで!
実際どのコードが何の役割を果たしているのか見ていく。

一見して分かる通り、elm-playground内では通常のElm開発で使うModelCmdSubを使うことはない。
代わりにライブラリが提供する関数やデータを使っていくことになる。

まず基本になるのがmemoryで、ここにはゲームの状態が保存される。
The Elm Architectureで言うところのModelとほぼ同じ働きをすると思っていいだろう。

viewupdateに渡している(x, y)というのがmemoryで、状態としてX座標とY座標を持つということになる。
そしてドキュメントによるとgameの第三引数がゲーム初期化時のmemoryなので、
(0, 0)、つまりX座標もY座標も0というのが最初の状態となる。

一点注意が必要で、elm-playgroundでは基本的に描画領域中央が原点になる。
中央より左側は負のx値、下側は負のy値を持つことになる。
これには慣れるまで少し戸惑った。

もう一つ特徴的なのがcomputerで、
ここにはマウスやキーボードの操作状態や描画領域のサイズ、実行時間など実行時の情報が保存されている。
どんな操作をするにもまずはこれを参照することになるだろう。

ここではcomputer.keyboardで押されているキーを検出している。
toXは右キーが押されていれば1、左キーが押されていれば-1を返す。
同様にtoYは上キーが押されていれば1、下キーが押されていれば-1を返す。
これによりupdateでX座標とY座標を更新したmemoryを返している。

そしてviewではsquareで正方形を描画し、moveを使ってmemoryに保存された座標まで移動している。

これでなんとなく流れが把握できた。
応用すればキーボードで操作するゲームを作れそうだ。

実装

自機と地面

早速、目標とするT-Rex Gameもどきを作っていく。
まずは自機と地面を描画する。

rectangleで地面、circleで自機を描画してそれぞれの初期位置に配置している。
図形もやはり指定した座標を中心点として描画されるので、位置決めが面倒な場面がある。

この時点ではupdateの内容がないので操作はできない。

https://ellie-app.com/7sNNMrkMhy3a1

この時点でのコードと動作サンプルをEllieに置いた。
以下でもコードの全体像と動作サンプルはEllieを参照のこと。

ジャンプ

次に自機がジャンプできるようにする。

ジャンプするために必要なのは自機の現在位置と速度なので、memoryの型をそのように定義する。

https://ellie-app.com/7sNNph5Z9FVa1

type alias Memory =
    { height : Number
    , velocity : Number
    }


main =
    game
        view
        update
        { height = 0
        , velocity = 0
        }

高さを変化させるロジックはupdateに書く。

高さが0より上、つまり空中にいる場合は速度を常に低下させ続ける。
そして地上で上キーが押されている場合はジャンプして速度を正の値にセットする。
最後に現在の高さに速度を足したものを新しい高さとして、更新したmemoryを返す。
ただし、高さが負の値になると地面にめり込んでしまうため最小値が0となるようにしておく。

update : Computer -> Memory -> Memory
update computer memory =
    let
        velocity =
            if memory.height > 0 then
                memory.velocity - 1.3

            else if computer.keyboard.up then
                20

            else
                0

        height =
            max 0 (memory.height + velocity)
    in
    { memory | height = height, velocity = velocity }

この更新されたmemoryviewで参照するようにすれば完了。

view : Computer -> Memory -> List Shape
view computer memory =
    ...
    , circle (rgb 20 20 20) characterSize
        |> move (0 - width / 2 + 100) (groundPosition + characterSize + memory.height)

敵の配置

右側から自機に迫ってくる敵を配置する。
現時点では接触してもゲームオーバーになったりはしない。

https://ellie-app.com/7sNPCt7WrMva1

まずはmemoryenemyを追加する。

type alias Memory =
    { height : Number
    , velocity : Number
    , enemy : Enemy
    }


type alias Enemy =
    { right : Number
    , height : Number
    }


main =
    game
        view
        update
        { height = 0
        , velocity = 0
        , enemy = { right = 0, height = 0 }
        }

updateで敵の位置も更新するようにする。

update : Computer -> Memory -> Memory
update computer memory =
    ...
    { memory
        | height = height
        , velocity = velocity
        , enemy = updateEnemy computer.screen memory.enemy
    }


updateEnemy : Screen -> Enemy -> Enemy
updateEnemy screen enemy =
    let
        right =
            enemy.right + 5
    in
    { enemy | right = right }

最後に敵の位置を参照するSVGを追加する。
なにかと使いそうな各種座標やサイズ取得も関数化しておく。

getGroundY : Screen -> Number
getGroundY screen =
    screen.bottom + 100


getPlayerSize : Number
getPlayerSize =
    20


getPlayerX : Screen -> Number
getPlayerX screen =
    screen.left + 100


getPlayerY : Screen -> Number -> Number
getPlayerY screen height =
    getGroundY screen + getPlayerSize + height


getEnemySize : { x : Number, y : Number }
getEnemySize =
    { x = 40, y = 150 }


getEnemyX : Screen -> Enemy -> Number
getEnemyX screen enemy =
    screen.right + getEnemySize.x / 2 - enemy.right


getEnemyY : Screen -> Enemy -> Number
getEnemyY screen enemy =
    getGroundY screen + getEnemySize.y / 2 + enemy.height


view : Computer -> Memory -> List Shape
view computer memory =
    [ rectangle (rgb 64 64 64) computer.screen.width 2
        |> moveY (getGroundY computer.screen)
    , rectangle (rgb 64 64 64) getEnemySize.x getEnemySize.y
        |> move (getEnemyX computer.screen memory.enemy) (getEnemyY computer.screen memory.enemy)
    ...
    ]

これだと敵が画面外に出た後も永遠に左側に動き続けることになるので、
地球環境のことを考えて画面外に出たらリサイクルするようにする。

当初は初期位置に戻っていく敵が画面に映らないように一旦地下に移動してから戻すようにしよう、などと考えていたが、
moveはアニメーションでもなんでもなく、瞬時に設定された座標に移動するのだとわかったので
単に初期位置に戻すだけにした。

type EnemyState
    = Working
    | Returning

...

updateEnemy : Screen -> Enemy -> Enemy
updateEnemy screen enemy =
    let
        state =
            getEnemyState screen enemy

        right =
            case state of
                Working ->
                    enemy.right + 5

                Returning ->
                    0
    in
    { enemy | right = right }


getEnemyState : Screen -> Enemy -> EnemyState
getEnemyState screen enemy =
    if getEnemyX screen enemy <= screen.left then
        Returning

    else
        Working

そして敵が一体では寂しいのでList Enemyにして複数出した。

type alias Memory =
    { height : Number
    , velocity : Number
    , enemies : List Enemy
    }
main =
    game
        view
        update
        { height = 0
        , velocity = 0
        , enemies =
            [ { right = 0, height = 0 }
            , { right = -300, height = 0 }
            , { right = -600, height = 0 }
            , { right = -800, height = 0 }
            ]
        }
view computer memory =
    [ rectangle (rgb 64 64 64) computer.screen.width 2
        |> moveY (getGroundY computer.screen)
    ]
        ++ List.map
            (\enemy ->
                rectangle (rgb 64 64 64) getEnemySize.x getEnemySize.y
                    |> move (getEnemyX computer.screen enemy) (getEnemyY computer.screen enemy)
            )
            memory.enemies
        ++ [ circle (rgb 20 20 20) getPlayerSize
                |> move (getPlayerX computer.screen) (getPlayerY computer.screen memory.height)
           ]
        height =
            max 0 (memory.height + velocity)

        enemies =
            List.map (updateEnemy computer.screen) memory.enemies
    in
    { memory
        | height = height
        , velocity = velocity
        , enemies = enemies
    }

ゲームオーバー

今の状態だとゲーム性がなさすぎるので、敵に接触したらゲームオーバーになるようにする。

https://ellie-app.com/7sNQwyLj7PMa1

例によってまずはmemorycollidingを追加する。

type alias Memory =
    { height : Number
    , velocity : Number
    , enemies : List Enemy
    , colliding : Bool
    }

...

main =
    game
        view
        update
        { height = 0
        , velocity = 0
        , enemies =
            ...
        , colliding = False
        }

ゲームオーバー時の動作についてはどうするべきか少し迷ったが、 思い切ってゲームオーバーになったらupdatememoryをそのまま返し続けるようにした。
これで状態の更新がなくなるので画面の更新もなくなる。

update computer memory =
    let
        ...
    in
    if memory.colliding then
        memory

    else
        ...

敵への接触判定を定義する。
自機が円形なので見た目通りの判定を実装するのは面倒そうだ。
というわけでシューティングゲームっぽく自機の中心が敵に重なったら、ということにした。

isColliding : Screen -> Number -> List Enemy -> Bool
isColliding screen height enemies =
    let
        playerX =
            getPlayerX screen

        playerY =
            getPlayerY screen height
    in
    not <|
        List.isEmpty <|
            List.filter (\enemy -> isCollidingWithEnemy screen playerX playerY enemy) enemies


isCollidingWithEnemy : Screen -> Number -> Number -> Enemy -> Bool
isCollidingWithEnemy screen playerX playerY enemy =
    abs (playerX - getEnemyX screen enemy)
        < getEnemySize.x
        / 2
        && abs (playerY - getEnemyY screen enemy)
        < getEnemySize.y
        / 2

文字も使ってみたかったので、ゲームオーバーになったら大きく文字でGAME OVERと出すようにした。

view computer memory =
        ...
        ++ [ circle (rgb 20 20 20) getPlayerSize
                |> move (getPlayerX computer.screen) (getPlayerY computer.screen memory.height)
          , scale 5 <|
                words (rgb 20 20 20) <|
                    if memory.colliding then
                        "GAME OVER"

                    else
                        ""
            ]

スコア

最後に生きている限り常時スコアが加算されていくようにする。
これで最低限ゲームとしての体裁が完成するはずだ。

https://ellie-app.com/7sNQXLh5n9ga1

type alias Memory =
    {
    , velocity : Number
    , enemies : List Enemy
    , colliding : Bool
    , score : Int
    }

...

main =
    game
        view
        update
        { height = 0
        , velocity = 0
        , enemies =
            ...
        , colliding = False
        , score = 0
        }

当初はPlayground.Timeを使おうと考えていて、
どうやってここからTime.Posixを取り出そうかと悩んだりしたが、
単にupdateのたびにscoreを加算すればそれで足りるということに気付いたのでそう実装した。

update computer memory =
    ...
        { memory
            | height = height
            , velocity = velocity
            , enemies = enemies
            , colliding = isColliding computer.screen height enemies
            , score = memory.score + 1
        }

とはいえスコアの上昇があまりに激しかったので表示上は10で割っておくことにした。

           , (words (rgb 20 20 20) <|
                "Score: "
                    ++ String.fromInt (memory.score // 10)
             )
                |> move 0 (computer.screen.top - 50)
           ]

これで一旦完成!

感想

The Elm Architectureともまた違う方式で開発する必要があるため最初は少し戸惑ったが、慣れてしまえば簡単に進められた。
型さえ合わせておけば大体正しい実装になっていく、というElm特有の感触はここでも変わらない。

ただ、図形はともかく、文字も座標が中心点になるのはちょっと困った。
文字の大きさは自明でないので、左寄せや上寄せにするにも表示しながらの調整が必要になる。

また乱数を使えないというのは辛い。
Elmでは乱数を扱うときにCmdを経由する必要があるが、elm-playgroundではCmdが隠蔽されているためである。
スコア表示まで完成して記事をおおよそ書き上げた後、
もっとまともなゲームにしようと敵の配置をランダムにしようとしてここでつまずいた。

この問題はissueにも挙がっていて、
Playground.TimePlayground.Mouseを使ってランダムではないが予測困難な値を生成する方法が提案されていたりした。
もちろん本当の乱数が欲しいときはこれでは困るが、代用できるケースもあるだろうと。

https://github.com/evancz/elm-playground/issues/4
https://github.com/evancz/elm-playground/issues/7

実際、cos (spin 0.1 time)として値を生成することでそれっぽく対応できたが、
そうはいかないケースもあるだろうし、何とかなってくれたら嬉しいなあと思う次第。

今回の記事で作成できたのは最低限ゲームが成立するところまで、という程度だったが、
遊べるゲームを目指して今後もちまちま開発を続けていきたい。

https://github.com/wolf-dog/elm-run