🤖

Godot と Phoenix Channels で WebSocket した

2021/04/13に公開

WebSocket で Online Multipayer

ゲーム制作をしていると、オンラインマルチプレイを実装したいと思うことがあると思います。

特に、リアルタイムなインタラクションがあるとすごく面白いと思います。

そこで、 WebSocket を使ったサーバー・クライアント通信を実装したので手順を紹介したいと思います。

https://youtu.be/o31bJ168bsM

前置き : 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, ShoutButtonpressed 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

https://youtu.be/o31bJ168bsM

この実装の問題点

  • room:lobby しか参加できない
    • 一部屋だけ、つまり部屋機能がないということ
    • join関数で"room:" <> room_id のようなパターンマッチを使うともっとすごいらしい
  • ping と shout しかイベントを定義してない
    • 個人通話とか、クラン通話とかもっと面白いものを実装できるかも
  • エラーハンドリングがない
    • print するだけ

まとめ

  • 簡単な WebSocket Chat を実装した
    • 応用してイベントやメッセージの種類を増やせば、ターンベースや同期頻度の少ないリアルタイム通信ができるであろう
  • Phoenix Channels 使い始めるのは超簡単だった (Scaffold でコード書く量少ない)
  • クライアント側は、言語によっては Phoenix Channels 用のライブラリがないこともあるかも
  • 簡単に ErlangVM のパワーを使えるので、皆も Elixir やろう!

(追記: 近年、筆者は仕事でTypescriptを使ってるので、こんな文を書いたけど別にElixirエキスパートとかではない...)

補足

WebSocket は、 TCP 上に成り立っているプロトコルであり、通信の信頼性を担保するために通信速度が犠牲になっているので、実際の所はRealTime Multiplayerに最適というわけではない。

追記:
また、Websocketはスケールアウトが難しいらしい。
自分の作ろうとするゲームにリアルタイム性が本当に必要かを考えて、かわりに Long Polling を採用することも十分あり。
(この記事を書いて1年、心境の変化...)

Discussion