elm-playgroundでT-Rex Gameもどきを作る
Advent Calendar参加してます
この記事はElm2 Advent Calendar 2019の15日目の記事です。
前置き
Elmでアクション性のあるゲームを作ってみたい、と思った。
しかしフレームごとの処理を一から実装するのは大変に面倒そうだ。
そういったゲームを一度も作ったことがない自分がそういう基本的な部分から作り出しても、
ゲームとして成立するところまで到達できずに挫折するのは目に見えている。
そこで、Elm作者であるevancz氏作のゲームライブラリelm-playground
を試してみることにした。
以下ページの「Playground」にあるサンプルを見るに、比較的簡単なコードでそれっぽいものを作れそうだ。
今回は習作としてGoogle Chromeがオフラインのときに遊べるアレ、T-Rex Gameもどきを作ってみる。
elm-playground
の簡単な解説も兼ねて。
バージョン
- Elm 0.19.1
- elm-playground 1.0.2
インストール
パッケージをインストールするだけ。
$ 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開発で使うModel
やCmd
、Sub
を使うことはない。
代わりにライブラリが提供する関数やデータを使っていくことになる。
まず基本になるのがmemory
で、ここにはゲームの状態が保存される。
The Elm Architectureで言うところのModel
とほぼ同じ働きをすると思っていいだろう。
view
とupdate
に渡している(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 }
この更新されたmemory
をview
で参照するようにすれば完了。
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
まずはmemory
にenemy
を追加する。
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
例によってまずはmemory
にcolliding
を追加する。
type alias Memory =
{ height : Number
, velocity : Number
, enemies : List Enemy
, colliding : Bool
}
...
main =
game
view
update
{ height = 0
, velocity = 0
, enemies =
...
, colliding = False
}
ゲームオーバー時の動作についてはどうするべきか少し迷ったが、
思い切ってゲームオーバーになったらupdate
でmemory
をそのまま返し続けるようにした。
これで状態の更新がなくなるので画面の更新もなくなる。
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.Time
やPlayground.Mouse
を使ってランダムではないが予測困難な値を生成する方法が提案されていたりした。
もちろん本当の乱数が欲しいときはこれでは困るが、代用できるケースもあるだろうと。
https://github.com/evancz/elm-playground/issues/4
https://github.com/evancz/elm-playground/issues/7
実際、cos (spin 0.1 time)
として値を生成することでそれっぽく対応できたが、
そうはいかないケースもあるだろうし、何とかなってくれたら嬉しいなあと思う次第。
今回の記事で作成できたのは最低限ゲームが成立するところまで、という程度だったが、
遊べるゲームを目指して今後もちまちま開発を続けていきたい。