この記事は、Elm2 Advent Calendar 2019の 9 日目の記事です。 明日はまだ予定が埋まってないので、参加するなら今がチャンス!
3 行まとめ
- https://g.neguse.net 以前これを作った
- がんばればもっと作り込めそうではあるものの、サーバとクライアントは同じ言語で書きたいので一旦保留
- 次似たことをやるとしたら Go でクライアントもサーバも書くかもだけど、Elm でゲーム作るのはそれとは別にやりたい
やりたかったこと
技術的な興味から、リアルタイムで状態を同期するタイプのゲームを作ってみたいと思った。 題材としてどんなのがいいかなと考えたところ、以前遊んだ Subspace Continuumというゲームを思い出した。Subspace Continuum はオンラインで多人数対戦する 2D シューティングゲームなのだけど、これを簡略化して作ってみようとした。 自機が出て、弾が出て、相手に当たるぐらいなら割と簡単につくれるのでは…と考えていた。
デスクトップ版、そしてブラウザへ…
まず C++で PC アプリケーションとして実装した。
https://github.com/neguse/omb/
SDL2 と、Redis のソースに含まれる anet という通信ライブラリ、おなじく Redis の ae というイベントライブラリを使っている。
これはこれで動いたけど、ゲームの配布方法に課題を感じていた。 「実行ファイルをダウンロードして実行してください。同じタイミングで接続している他のプレイヤーがいたら一緒に遊べます」というのは宣伝など何もしていない無名のオンラインゲームに対しては敷居が高く、まず同時接続数がゼロになることが想定された。 もっと手軽に配布して、一緒に遊べるようにしたかったため、ブラウザで遊べるようにすることを検討した。
つくったもの
https://g.neguse.net これです
キーボードか、画面に表示されているボタンで操作します。
- 上キー: 前進
- 左右キー: 方向転換
- 下キー: 弾を発射
複数人や複数タブで同時にアクセスすると、一緒に遊ぶことができます。 当たり判定はありますが、特にあたってもダメージを受けたりということはありません。
実装
詳しくはソースを読んでください。 https://github.com/neguse/son
クライアントは Elm で、サーバは Go で書いて、WebSocket で通信するようにしている。 Elm を使ったのは、JavaScript をあんまり書きたくなく、関数型でゲームを作るというアイデアに興味があったため。 Go を使ったのは、C++でサーバを書くのがしんどかったため。 WebSocket を使ったのは、そこそこ高頻度の通信を行える方法が WebSocket と WebRTC ぐらいしかなく、WebSocket のほうがお手軽なため。
いくつか実装上のポイントを以下で述べる。
同期のしかた
クライアントからサーバへは、入力の情報(弾を撃つ、方向転換、前進)を送信している。 サーバからクライアントへは、現在の機体や弾の情報(位置、速度)を送信している。
サーバは、クライアントから受け取った入力の情報を反映して 1 秒に 10 回シミュレーションを実行して、都度全クライアントに全情報を送信している。 クライアントは、サーバから受け取った最新の情報をもとに、機体や弾の位置を線形補完で外挿して毎フレーム表示している。 サーバから情報が送られる頻度は 1 秒間に 10 回と低いものの、補完して表示しているのでなめらかに見える。
クライアントとサーバをあわせてワンバイナリ化
クライアントをコンパイルした結果生成された JavaScript を Go のサーバから static データとして配信しているため、最終的にはサーバで実行ファイルを動かすだけで稼働できる。 jteeuwen/go-bindata というライブラリを使っているのだけど、これはメンテされていなかったりアカウントが別の方のものになってたりしてて、あんまりよろしくない状態となってる。直したい。
https://twitter.com/mattn_jp/status/961389640187031553
Docker 化、自動ビルド
ビルド環境が Docker 化されていて、GitHub のリポジトリにコミットがあったら自動で再ビルド、再起動するようになっている。 just-containers/s6-overlay を使って、1 コンテナ内でこれらの手順を実行している。 当時いちいちサーバに入ってビルドしなおすのが面倒で、なんとなくノリでこういう構成にしてみたが、かなり複雑な作りとなってしまった。いまなら GitHub Actions など外部の仕組みでコミットに反応してビルド・デプロイして、コンテナはシンプルにサーバを起動するだけにしておくのがよい気がする。
スマホ対応、QR コード表示
スマホならみんな持ってるし、QR コードで URL を共有することで手軽に同じ Web ページを開くことができる。 このため、ちょっと興味ありそうな人に QR コードを見せて、ササッと一緒に遊ぶということがとても簡単になった。
感想
かなり乱暴な実装だけど、思ったよりスムーズに動いた。同時接続ユーザが多くなったり弾を撃ちまくったりすると位置のずれが目立つことがある。もう少し補完を工夫すれば良くなるはず。 Elm も Go も優秀という印象を持った。静的型付け言語であるためコンパイルエラーを解消すればある程度動作することを保証できる。またどちらも WebSocket のライブラリが揃っており、簡単に通信することができる点がよかった。
今後機能を実装していく上で、クライアントとサーバの実装を共通化できないことがネックになることが懸念された。 例えば移動処理は物理エンジンに丸投げしたいが、Go と Elm で共通で使える物理エンジンは存在しない。同じ物理エンジンが使えれば両方で同じシミュレーションを実行して、サーバからクライアントへ定期的に情報を送ることでずれを補正するだけでよくなるはずだが、こういうことができない。 また、通信データの構造をサーバ・クライアント双方に記述する必要があるため、双方で定義の差異をなくするために気をつけなければならない。Protocol Buffers など多言語で定義を共通化するための仕組みはあるが、そもそもサーバもクライアントも共通の実装となっていれば差異は発生しないはずである。 また、今後例えば地形データを実装したり、弾があたった時のダメージを計算する式を実装したりといったことを考えると、サーバ・クライアント間でアセットデータや計算式を共有する必要があると思われる。 これら問題は、サーバ・クライアント双方を同じ言語で実装して、実装を共通化するのがスマートな解決方法に感じられる。 Elm でサーバを書くことはおそらくできない(Elm は意図的にできることを絞っている節を感じる)が、Go でクライアントを書くことはできる。(Ebitenという 2D ゲームライブラリもある) そのため、今回のような目的では Go でサーバ・クライアントを実装して処理を共通化するというのはありなのではないかと思う。
一方、Elm などの関数型プログラミングでゲームを実装するのは、それはそれで技術的面白さや意義があると感じている。 ゲームプログラムは、UI やリアルタイムな時間経過、ランダム性、タイミングなどさまざまな要因で結果が変わりうるため、テストやデバッグが難しいという問題がある。関数型プログラミングでゲームプログラムを記述することで、そのあたりが解消されないだろうかというのを試してみたい。 また、現代のゲームはオブジェクト指向プログラミングで実装されていることが多く、よく知られているゲームプログラミングにおける実装のパターンもオブジェクト指向プログラミングでのものがほとんどという印象がある。そのため関数型プログラミングでゲームを作る際の定石は比較的未開拓で、挑戦しがいのある鉱脈なのではないかと思う。