Godot と Phoenix Channels で WebSocket した
WebSocket で Online Multipayer
ゲーム制作をしていると、オンラインマルチプレイを実装したいと思うことがあると思います。
特に、リアルタイムなインタラクションがあるとすごく面白いと思います。
そこで、 WebSocket を使ったサーバー・クライアント通信を実装したので手順を紹介したいと思います。
前置き : Elixir & Phoenix, そして Phoenix Channels
- Elixirは拡張性と保守性の高いアプリケーションを構築するためにデザインされた、動的で関数型のプログラミング言語です。
Elixir は ErlangVM (BEAM) で動く、動的型付けの関数型言語です。
普段、仕事では静的型付けの関数型言語を使ってるので、型のサポートとか補完が弱くて心もとないこともあるけれど、 dialyzer で静的解析して型エラーとか指摘してくれるのでとりあえずは安心して書ける言語だと思います。
なにより、Ericsson社により通信分野で30年以上使用され続ける Battle Tested (このフレーズ好き) な ErlangVM で動くバイトコードにトランスパイルされ、軽量プロセスによる高い並列性がありスケーリングを意識した設計をもつという特徴があります。
そして Elixir には Phoenix というキラーライブラリが存在します。
フルスタックなフレームワーク (Ruby における Ruby on Rails といえる存在) で、Web Application はこれ使っとけばとりあえず何でも作れるという安心感を与えてくれるので、安心して使えますね!
さらにさらに、Phoenix には Phoenix Channels という WebSocket を簡単に扱える機能があります。
例えば、ゲームのマッチとかチャットルームとかを実装するとき、トピックがどうこうとか Pub/Sub がどうこうとかを生の WebSocket を使って書くわけですが、これを使えば何もしなくてもとりあえず使える Out of the Box なものが作れるというわけです。
今回は、この Phoenix Channels を使ってぱぱっとチャットを作ります。
環境
- macOS 11.2.3
- Elixir 1.11.4
- Erlang/OTP 23
- Node.js 15.8.0
- Phoenix 1.5.8
- Godot 3.2.3
0. Erlang, Elixir, Phoenix のインストール
まだ Elixir インストールしてない人は、インストールしましょう!
asdf といういろんなプログラミング言語のランタイムを管理できるマネージャーがおすすめですよ
Node, Deno, Python, Ruby, etc... もちろん今回は Elixir と Erlang。
何でも管理できますので nvm, rbenv, pyenv 等は全てアンインストールしてしまいましょう。
# install erlang & elixir with asdf
brew install asdf
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git
asdf install erlang latest
asdf global erlang <installed-version>
asdf install elixir latest
asdf global elixir <installed-version>
# install phoneix
mix archive.install hex phx_new 1.5.8
1. プロジェクトの準備
# プロジェクトをセットアップ
mkdir godot_phx_websocket && cd godot_phx_websocket
touch project.godot
mix phx.new phx_ws_server --no-ecto # 今回は DB いらないので --no-ecto で
# 依存をダウンロード (phx.new の後にしてたら不要)
cd phx_ws_server && mix deps.get
cd assets && npm install
2. GodotPhoenixClient をダウンロード
有志の方が作成された、Godot 専用の Phoenix Channels Client がありますので、それをダウンロードして使いましょう!
余談ですが、Phoenix Channels の公式ドキュメント にもこのクライアントが記載されてます。
https://github.com/alfredbaudisch/GodotPhoenixChannels
cd <project_root>
git clone https://github.com/alfredbaudisch/GodotPhoenixChannels.git
mv GodotPhoenixChannels/Phoenix ./Phoenix
repogitory の /Phoenix
という dir にクライアントの gdscript が入っています。
適当にプロジェクトのルートに移動しておきましょう。
(/Demo
に入ってるデモシーンは使い方の参考になります!)
3. Channel の作成
cd ws_phx_server
mix phx.gen.channel Room
これをやると、 ws_phx_server/lib/phx_ws_server_web/room_channel.ex
が生成されます。
mix phx.gen.*
コマンドは、Rails の Scaffolding の精神を受け継ぐコマンド集で色んなものを自動生成しますが、これもその一つ。
自動生成は便利ですが、中身わからずに使いまくってると偉い人に怒られるかもしれないので、後でドキュメントはちゃんと読んでおきましょう。
このデモでは中身をいじる必要はありませんが、一応見ておくと
defmodule PhxWsServerWeb.RoomChannel do
use PhxWsServerWeb, :channel
@impl true
def join("room:lobby", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
@impl true
def handle_in("ping", payload, socket) do
{:reply, {:ok, payload}, socket}
end
@impl true
def handle_in("shout", payload, socket) do
broadcast(socket, "shout", payload)
{:noreply, socket}
end
defp authorized?(_payload) do
true
end
end
中身を見ると、以下の関数が定義されているのが見えます。
join/3
handle_in/3
-
authorized?/1
: 常に true = 認証なしと同じ
引数のパターンマッチにより、初期状態では room:lobby
という topic を Pub/Sub できて、
ping
イベントを送る payload がエコーされ、shout
イベントを送ると payload が topic を購読しているすべてのクライアントにブロードキャストされる、という挙動になることを理解しておきましょう。(これらのイベントは、自分で実装して増やせる)
4. Socket から Channel への Routing
Phoenix でプロジェクトを作成したときに、最初から存在する lib/{project_web}/channels/user_socket.ex
というファイルがあります。
phonix project のルートで、以下のコマンドを実行すると ws://{url}/socket/websocket
のルートが そのファイルの中にあるPhxWsServerWeb.UserSocket
モジュールにルーティングされているのが分かります。
> mix phx.routes
# Generated phx_ws_server app
# page_path GET / PhxWsServerWeb.PageController :index
# live_dashboard_path GET /dashboard Phoenix.LiveView.Plug :home
# live_dashboard_path GET /dashboard/:page Phoenix.LiveView.Plug :page
# live_dashboard_path GET /dashboard/:node/:page Phoenix.LiveView.Plug :page
# websocket WS /live/websocket Phoenix.LiveView.Socket
# longpoll GET /live/longpoll Phoenix.LiveView.Socket
# longpoll POST /live/longpoll Phoenix.LiveView.Socket
# websocket WS /socket/websocket PhxWsServerWeb.UserSocket
UserSocket
モジュールに来たリクエストは、更に Channel モジュールにルーティングする必要があるので、次のように書きましょう。
# phx_ws_server/lib/phx_ws_server_web/channels/user_socket.ex
defmodule PhxWsServerWeb.UserSocket do
use Phoenix.Socket
channel "room:*", PhxWsServerWeb.RoomChannel
...
channel マクロをこのように書くと、 room:
で始まる topic へのメッセージはすべてPhxWsServerWeb.RoomChannel
に送られるようになります。
5. Chat シーンの実装
UI の配置を説明するのはなかなか難しいのでスクショで (笑)
ポイントを解説するならば、
- 縦横配置は
HBoxContainer
,VBoxContainer
を使う (CSS FlexBox の要領で) - MailBox は
TextEdit
ノードを Read only モードにする- Read only モードだと文字色と背景がグレーアウトするので、Theme, Custom Color で色を変更
- デフォルトのフォントがしょぼいので、Google Noto Sans をフォントに設定
6. Chat.gd スクリプトを実装
先程作った、Chat.tscn のトップにスクリプトをアタッチ。
socket のホスト名には localhost ではなく 127.0.0.1 を書きます。
何故かわかりませんが、localhost だと繋がらないです(Godotの問題?)
とりあえず以下を書く
- 子ノードへのパスを用意
- channel 用の変数を用意
-
_ready
で socket の signal にメソッドを接続し、socket.connect_socket()
を実行
extends Control
var socket := PhoenixSocket.new("ws://127.0.0.1:4000/socket", {params = {}})
var presence = PhoenixPresence.new()
var channel : PhoenixChannel = null
onready var join_button = $HBoxContainer/VBoxContainer/JoinButton
onready var ping_button = $HBoxContainer/VBoxContainer/PingButton
onready var shout_button = $HBoxContainer/VBoxContainer/ShoutButton
onready var name_edit = $HBoxContainer/VBoxContainer/NameInput/LineEdit
onready var text_edit = $HBoxContainer/VBoxContainer/TextEdit
onready var mail_box = $HBoxContainer/MailBox
func _ready():
# 接続が完了するまで、ボタンを押せないようにする
ping_button.disabled = true
shout_button.disabled = true
self.call_deferred("add_child", socket, true)
socket.connect("on_open", self, "_on_Socket_open")
socket.connect("on_error", self, "_on_Socket_error")
socket.connect("on_close", self, "_on_Socket_close")
socket.connect("on_connecting", self, "_on_Socket_connecting")
presence.connect("on_join", self, "_on_Presence_join")
presence.connect("on_leave", self, "_on_Presence_leave")
socket.connect_socket()
print(socket.get_is_connected())
次に、シグナルに対するハンドラーを実装します
Socket Signals
特にやってることはないけれど、接続の進行具合を print しておく。
# Socket Signal
func _on_Socket_open(params):
print("open params: ", params)
func _on_Socket_error(error):
print(error)
func _on_Socket_close():
print("close")
func _on_Socket_connecting(is_connecting):
if is_connecting:
print("is_connecting ...")
else:
print("connected!")
Channel Signals
PhoenixSocket の接続が完了したら、次はチャンネルシグナルへのハンドラを書く。
主に、チャットサーバーから受け取ったメッセージを MailBox へ出力するのが役割。
# Channel Signal
func _on_Channel_event(event_name, payload, status):
print("_on_Channel_event: ", event_name, ", ", status, ", ", payload)
mail_box.text += "%s < %s \n" % [payload.name, payload.message]
func _on_Channel_join_result(status, result):
print("_on_Channel_join_result: ", status, ", ", result)
if status == PhoenixChannel.STATUS.ok:
mail_box.text += "- JOINED! -\n"
# チャンネルへ参加出来たら、ping ボタンと shout ボタンを押せるようにする
join_button.disabled = true
ping_button.disabled = false
shout_button.disabled = false
func _on_Channel_error(error):
print("_on_Channel_error: " + str(error))
func _on_Channel_close(closed):
print("_on_Channel_close: " + str(closed))
Button Signals
UIからの入力をハンドリングする。
主にチャンネルへの参加、メッセージの送信などを実行する。
先程作ったUIの、JoinButton, PingButton, ShoutButton の pressed
signal を Chat.gd に接続しておこう。
# Button Signal
func _on_JoinButton_pressed():
print("join button pressed")
if channel == null:
# 接続された socket から、channelを取得し変数へセット
channel = socket.channel("room:lobby", {}, presence) # room:lobby トピックをセット
channel.join()
# 各種 signal をハンドラーに接続
channel.connect("on_event", self, "_on_Channel_event")
channel.connect("on_join_result", self, "_on_Channel_join_result")
channel.connect("on_error", self, "_on_Channel_error")
channel.connect("on_close", self, "_on_Channel_close")
elif not channel.is_closed():
channel.close()
channel.join()
elif channel.is_closed():
channel.join()
func _on_PingButton_pressed():
channel.push("ping", {"name": "server", "message" : "pong"})
func _on_ShoutButton_pressed():
var message = text_edit.text
var name = name_edit.text
text_edit.text = ""
if message == "": return
channel.push("shout", {"name": name, "message": message})
7. Server と Client を起動
ここまでくれば完成です!
あとは、サーバーとクライアントを起動してチャットを試しましょう
server
cd phx_ws_server && mix phx.server
client (複数起動してもよい)
alias godot="path/to/godot" # .zshrc に設定しておくと捗る
godot -d Chat.tscn
この実装の問題点
-
room:lobby
しか参加できない- 一部屋だけ、つまり部屋機能がないということ
- join関数で
"room:" <> room_id
のようなパターンマッチを使うともっとすごいらしい
- ping と shout しかイベントを定義してない
- 個人通話とか、クラン通話とかもっと面白いものを実装できるかも
- エラーハンドリングがない
- print するだけ
まとめ
- 簡単な WebSocket Chat を実装した
- 応用してイベントやメッセージの種類を増やせば、ターンベースや同期頻度の少ないリアルタイム通信ができるであろう
- Phoenix Channels 使い始めるのは超簡単だった (Scaffold でコード書く量少ない)
- クライアント側は、言語によっては Phoenix Channels 用のライブラリがないこともあるかも
- Unity用(https://github.com/Mazyod/PhoenixSharp)探したらあった
- 簡単に ErlangVM のパワーを使えるので、皆も Elixir やろう!
(追記: 近年、筆者は仕事でTypescriptを使ってるので、こんな文を書いたけど別にElixirエキスパートとかではない...)
補足
WebSocket は、 TCP 上に成り立っているプロトコルであり、通信の信頼性を担保するために通信速度が犠牲になっているので、実際の所はRealTime Multiplayerに最適というわけではない。
追記:
また、Websocketはスケールアウトが難しいらしい。
自分の作ろうとするゲームにリアルタイム性が本当に必要かを考えて、かわりに Long Polling を採用することも十分あり。
(この記事を書いて1年、心境の変化...)
Discussion