簡単なゲームサーバーを一瞬で作るための技術
この記事で作るのは2人プレイのゲームのWebSocketサーバーです。マッチング機能とプレイヤー間通信機能の2機能を一瞬で作ってみます。
短い記事になると思います。なぜなら、書くコードは全て記事に載せますがどれも極めて少量ですし、コードは直感的なので説明も少なくなるはずだからです。
WebSocketサーバーを作る
今回はElixir製WebアプリケーションフレームワークPhoenixのChannels機能を使います。PhoenixはcowboyというHTTPサーバーライブラリの薄いラッパーですが、その薄い部分のおかげでRailsのような使い勝手になるのでおすすめです。
Phoenixを使わずにcowboyを直接使ってもこの記事と同じやり方で目的は達成できますがPhoenix ChannelsのAPIの方が直感的なのでこちらを使います。
ElixirやPhoenixのインストール手順は省きますが、こちらの通り二、三コマンドを叩くだけで全てインストールできます。インストールしたらmix phx.new
コマンドを使ってプロジェクトを作成しましょう。
まずはプロジェクトのディレクトリに移動したら、WebSocket通信に必要なSocketモジュールとChannelモジュールを以下のコマンドで生成します。
mix phx.gen.socket User
mix phx.gen.channel Player
実行するとそれぞれ2、3行のコードを所定の場所にコピーして欲しいと言われるのでそのようにします。
これでWebSocketサーバーの準備が完了です。mix phx.server
でサーバーを起動すれば、クライアントからWebSocket接続ができる状態です。
Cizenとイベント
この記事ではCizenというライブラリを使用します。Cizenを使うと、パターンマッチでイベントの購読ができたり、サガと呼ばれるプロセスを組み合わせてアプリケーションを構築できたりします。
外部依存になるのでmix.exs
ファイルを開いて依存の部分に{:cizen, "~> 0.18"}
を追加してください。
また、今回使用するイベントを先に定義しておきましょう。イベントとしてはどんなデータ構造も使えるので単に連想配列であるMapを使うだけでもいいのですが、コードの可読性が上がるのと、静的にフィールド名のチェックが行えるのでElixirの構造体の仕組みを使います。
プロジェクトのディレクトリのlib
以下の適当な場所にevents.ex
ファイルを作成して以下の内容を記述します。
# クライアントから送られてくるJSON
defmodule Input do
defstruct [:id, :payload]
end
# クライアントに送信するJSON
defmodule Output do
defstruct [:id, :payload]
end
PlayerChannelの処理
次はPhoenix Channelsの機能を使ってクライアントと通信する部分を書いていきます。ここがこの記事で一番複雑なところです。
さきほどのコマンドでPlayerChannel
というモジュールが生成されました。それを以下のように書き換えます。
defmodule YourGameWeb.PlayerChannel do
use YourGameWeb, :channel
alias Cizen.{Dispatcher, Pattern, Saga}
require Pattern
@impl true
def join(_topic, _payload, socket) do
# Playerサガの開始
{:ok, id} = Saga.start_link(%Player{}, return: :saga_id, lifetime: self())
# 自分向けのOutputイベントを購読
Dispatcher.listen(Pattern.new(%Output{id: ^id}))
# PlayerのIDを保存しておく
socket = assign(socket, :id, id)
{:ok, socket}
end
# 購読しているOutputイベントが届いた時に呼ばれる
@impl true
def handle_info(%Output{payload: payload}, socket) do
# クライアントにpayloadを送信する
push(socket, "output", payload)
{:noreply, socket}
end
# クライアントでchannel.push("input", json)とJSONを送信したときに呼ばれる
@impl true
def handle_in("input", json, socket) do
# Inputイベントを発火
Dispatcher.dispatch(%Input{id: socket.assigns.id, payload: json})
{:noreply, socket}
end
end
コメントである程度何をしているかがわかると思います。クライアントからChannelに接続する度にその接続に対応するPlayerChannel
プロセスが起動されます。join/3
[1]関数はプロセスの起動時に、handle_in/3
関数はクライアントからのJSONの受け取り時に、handle_info/2
関数は購読しているイベントの受け取り時に呼び出されます。
おおまかにやっていることは以下の4つです。
- 接続時に接続に対応する
Player
サガを起動する。 -
Output
イベントを購読して、受け取ったらpayloadをクライアントに送信する。 - クライアントからJSONを受け取ったら
Input
イベントを発火する。
Player
サガを起動する時にlifetime: self()
とすることで、WebSocket接続が切れた時にPlayerサガも終了するようにしています。また、return: :saga_id
としたため返り値はサガに対応する一意のサガIDになっています。この記事ではこのサガIDをプレイヤーのIDとして色々なところで使用します。
Playerサガの処理
次はPlayerサガを作ります。lib
以下の適当な場所にplayer.ex
を作り、以下のコードを書きます。
defmodule Player do
defstruct []
use Cizen.Saga
@impl true
def on_start(_), do: nil
@impl true
def handle_event(_, _), do: nil
end
短いですね。要するになにもしないサガです。Elixirの裏で動くErlang VMでは軽量なプロセスを大量に起動してもサクサク動くので、このような何もしない無用なプロセスを起動してもパフォーマンスには影響を与えません[2]。
ここで、後からも出てくるon_start/1
とhandle_event/2
を説明しておきます。名前の通りではありますが、on_start/1
はサガの起動時に呼ばれる関数で、handle_event/2
はサガがイベントを受け取った時に呼ばれる関数です。
マッチング処理
次はマッチングをするためのMatching
サガを作りましょう。これはシングルトンなプロセスです。適当な場所にmatching.ex
を作り以下のコードを記述します。
defmodule Matching do
defstruct []
use Cizen.Saga
alias Cizen.{Dispatcher, Pattern, Saga}
require Pattern
@impl true
def on_start(_saga) do
# 参加したいときのInputメッセージを購読
Dispatcher.listen(Pattern.new(%Input{payload: %{"type" => "Join"}}))
# まだだれもいないのでstateをnilにする
nil
end
# 誰もマッチング待ちではない時(stateがnilの時)
@impl true
def handle_event(%Input{id: id, payload: %{"type" => "Join"}}, nil) do
# マッチング待ちに入るためにstateをidにする
id
end
# マッチング待ちの人がいる時(stateにIDが入っている時)
@impl true
def handle_event(%Input{id: id, payload: %{"type" => "Join"}}, other) do
# Roomサガを起動
{:ok, _} = Saga.start(%Room{id1: id, id2: other}, lifetime: id)
# 双方のプレイヤーにマッチングに成功したことを伝える
Dispatcher.dispatch(%Output{id: id, payload: %{"type" => "Joined"}})
Dispatcher.dispatch(%Output{id: other, payload: %{"type" => "Joined"}})
# マッチングしたのでstateをnilにする
nil
end
end
こんどはちゃんと処理がありますね。handle_event/2
関数の第2引数はサガの状態が渡ってきます。サガの状態というのは、最後に呼ばれたon_start/1
やhandle_event/2
の返り値[3]です。このサガではサガの状態を
- マッチング待ちの人がいない時は
nil
- マッチング待ちの人がいる時はその人のID
とすることでマッチングを行っています。
マッチングが成功した後はOutput
イベントを使ってクライアントに参加できたことを伝えます(実際にクライアントに送信するのはOutput
イベントを購読しているPlayerChannel
です)。
プロセスリークを防ぐ
Room
サガを起動する時にlifetime: id
を渡しています。これはid
でもother
でも良いのですが、渡したIDのPlayer
サガが終了した時(プレイヤーがWebSocket接続を終了した時)にRoom
サガが終了するようになっています。こうすることで使われなくなったRoom
サガが終了するのでメモリリークならぬプロセスリークを防いでいます。
プレイヤー間通信の実装
ほとんど最後の実装になりますが、Room
サガでプレイヤー間通信を実装します。適当な場所にroom.ex
を作って以下のように記述します。
defmodule Room do
defstruct [:id1, :id2]
use Cizen.Saga
alias Cizen.{Dispatcher, Pattern}
require Pattern
@impl true
def on_start(%Room{id1: id1, id2: id2} = saga) do
# 二人のプレイヤーから送られてくるPushを購読
Dispatcher.listen(Pattern.new(%Input{id: ^id1, payload: %{"type" => "Push"}}))
Dispatcher.listen(Pattern.new(%Input{id: ^id2, payload: %{"type" => "Push"}}))
saga
end
# プレイヤー1からPushを受け取った時
@impl true
def handle_event(%Input{
id: id1,
payload: %{"type" => "Push", payload: payload}
}, %Room{id1: id1, id2: id2} = saga) do
# プレイヤー2に転送
Dispatcher.dispatch(%Output{
id: id2,
payload: %{"type" => "Push", "payload" => payload}
})
saga
end
# プレイヤー2からPushを受け取った時
@impl true
def handle_event(%Input{
id: id2,
payload: %{"type" => "Push", payload: payload}
}, %Room{id1: id1, id2: id2} = saga) do
# プレイヤー1に転送
Dispatcher.dispatch(%Output{
id: id1,
payload: %{"type" => "Push", "payload" => payload}
})
saga
end
end
これもまた意外と単純でした。やっていることはプレイヤー1からのPushをプレイヤー2に転送し、逆にプレイヤー2からのPushはプレイヤー1に転送しているだけです。パターンマッチをすることでプレイヤー1から来たかプレイヤー2から来たかを判別しています。Elixirのパターンマッチはこういう時に便利です。
最後に
実装はこれで終わりです。しかし最後に一つだけやることがあります。Player
サガやRoom
サガは起動するコードを書きましたが、Matching
サガだけは起動するコードを書いていません。そのため、lib
以下の浅めのところにあるapplication.ex
を開いて以下を追加します。YourGameWeb.Endpoint
などが入っている配列の中です。
%{
id: Matching,
start: {Cizen.Saga, :start_link, [%Matching{}]}
},
こうすることでMatching
サガはアプリケーションの起動時にシングルトンとして起動するようになります。これでゲームサーバーは完成です!
Discussion