PONOS Advent Calendar 2020の17日目の記事です。
昨日は、@honeniqさんのM1 Macが届いたので触ってみたでした。
はじめに
PONOS Advent Calendar 2019の17日目と22日目の記事で、RustとWebSocketを使用してUnityとの連携やチャット機能を作成しました。今回は、**ゲームオブジェクトのXYZ座標を共有できるようなサーバ側(Rust + WebSocket)、クライアント側(Unity)の実装をしてみました。**本記事で実装したものは2ユーザに対して1つの空間にログインし座標情報のみをリアルタイムで通信することを目指します。
実装
RustでWebSocketを実現したサーバとUnityでクライアントを実装していきます。実装環境としてはローカル(macOS)で実施することを前提しています。
開発環境 | バージョン |
---|---|
macOS | 10.15.7 |
Rust(rustc、cargo) | 1.48.0 |
WS-RS | 0.9.1 |
Unity | 2020.1.16f1 |
サーバサイド側の実装
サーバ側はRustを導入しなくてはいけませんが、導入する方法は2019年アドベントカレンダーの17日目の記事で記載しておりますので、今回は省略します。
必要なパッケージを導入する
[dependencies]
ws = "0.9.1"
chrono = "0.4.19"
base64 = "0.13.0"
serde = "1.0.118"
serde_derive = "1.0.118"
serde_json = "1.0.60"
- ws: WebSocketのライブラリ
- chrono: ログ出力で日時を扱うためのライブラリ
- base64: 認証で使用しているBase64変換を扱うためのライブラリ
- serde: データ構造(今回はJSON)を扱うためのライブラリ
実装する
Unityから送られてくるゲームオブジェクトの座標をクライアント同士で共有させるためにサーバサイドの実装をする。各種実装の説明については「各種実装について」に記述いたしますのでコメントは記入していません。
use chrono::{Local, DateTime};
use ws::{listen, CloseCode, Sender, Handler, Handshake, Message, Result};
use base64;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
fn main() {
listen("127.0.0.1:3012", |out| {
Server {
out: out,
user_status: UserStatus { method: "".to_string(), hashed_key: "".to_string(), user_name: "".to_string(), transform_x: 0.0, transform_y: 0.0, transform_z: 0.0}
}
}).unwrap();
#[derive(Serialize, Deserialize, Debug)]
struct UserStatus {
method: String,
hashed_key: String,
user_name: String,
transform_x: f64,
transform_y: f64,
transform_z: f64
}
struct Server {
out: Sender,
user_status: UserStatus
}
impl Handler for Server {
fn on_open(&mut self, handshake: Handshake) -> Result<()> {
let hashed_key: String = handshake.request.hashed_key().unwrap();
let headers: &Vec<(String, Vec<u8>)> = handshake.request.headers();
let mut user_name: String = "".to_string();
for (k, v) in headers {
if k == "Authorization" {
let auth_value: String = String::from_utf8(v.to_vec()).unwrap();
let encode_auths: Vec<&str> = auth_value.split_whitespace().collect();
let decode_auth: Vec<u8> = base64::decode(encode_auths[1]).unwrap();
let auth_str: String = String::from_utf8(decode_auth.to_vec()).unwrap();
let auths: Vec<&str> = auth_str.split(":").collect();
user_name = auths[0].to_string();
let _: String = auths[1].to_string();
}
}
println!("[{}] {} Connected. hash_key: {}", str_datetime(), user_name, hashed_key);
self.user_status.method = "Login".to_string();
self.user_status.hashed_key = hashed_key;
self.user_status.user_name = user_name;
let user_status_json: String = serde_json::to_string(&self.user_status).unwrap();
return self.out.broadcast(user_status_json);
}
fn on_message(&mut self, message: Message) -> Result<()> {
let send_message: String = format!("[{}] {}: {}", str_datetime(), self.user_status.user_name, message);
println!("{}", send_message);
return self.out.broadcast(message);
}
fn on_close(&mut self, code: CloseCode, reason: &str) {
println!("[{}] {} Disconnected for ({:?}) {}", str_datetime(), self.user_status.user_name, code, reason);
let send_message: String = format!("[{}] {} Left the Chat Room.", str_datetime(), self.user_status.user_name);
let _ = self.out.broadcast(send_message);
}
}
fn str_datetime() -> String {
let local_datetime: DateTime<Local> = Local::now();
let formatted_local_datetime: String = local_datetime.format("%Y-%m-%d %T").to_string();
return formatted_local_datetime;
}
}
実行
$ cargo run
※ 実行した際に不足しているパッケージは自動でビルドされます。
各種実装について
サーバサイドの実装として上記に記述したソースコードが全てとなります。そのソースコードの一部を切り取り説明して行こうかと思います。
1行目 - 7行目
use chrono::{Local, DateTime};
use ws::{listen, CloseCode, Sender, Handler, Handshake, Message, Result};
use base64;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
上記実装部分は、プロジェクトに必要なモジュールやクレートを取得しています。モジュールというのは、プログラムを分割する単位のひとつであり、必要な機能をuse
で取得しています。クレートというのは、Rustプログラムの構成単位(ライブラリ)になり、外部ライブラリを使用するためにはextern create
を使用します。マクロインポートする際は、外部ライブラリとして読み込み#[macro_use]
を記述します。
12行目 - 17行目
listen("127.0.0.1:3012", |out| {
Server {
out: out,
user_status: UserStatus { method: "".to_string(), hashed_key: "".to_string(), user_name: "".to_string(), transform_x: 0.0, transform_y: 0.0, transform_z: 0.0}
}
}).unwrap();
listen関数でWebSocketのアドレスおよびハンドラ(通信関連処理で呼び出される関数)を記述します。アドレス先のソケットはLISTEN(待機)状態になります。ソケットはサーバとなりパッシブオープンの状態に移行します。Server
構造体は別途定義してあります。
19行目 - 32行目
#[derive(Serialize, Deserialize, Debug)]
struct UserStatus {
method: String,
hashed_key: String,
user_name: String,
transform_x: f64,
transform_y: f64,
transform_z: f64
}
struct Server {
out: Sender,
user_status: UserStatus
}
UserStatus
構造体は、クライアント側で実装するユーザの座標情報をJSONのデータ構造で通信するための定義をします。method
はログインしているのかを判断するために、hashed_key
はログインしたユーザを識別するために定義してあります。その他キーはユーザ情報になります。WS-RS
はHandler
というtrait
が定義されており、Server
構造体上で実装することにします。
36行目 - 79行目
impl Handler for Server {
fn on_open(&mut self, handshake: Handshake) -> Result<()> {...}
fn on_message(&mut self, message: Message) -> Result<()> {...}
fn on_close(&mut self, code: CloseCode, reason: &str) {...}
}
fn str_datetime() -> String {
let local_datetime: DateTime<Local> = Local::now();
let formatted_local_datetime: String = local_datetime.format("%Y-%m-%d %T").to_string();
return formatted_local_datetime;
}
Handler
というtrait
定義されており、それを使用してその中に実装されているメソッドが通信関連のイベントで呼び出されます。今回作成したのはソケットがオープンされた時に呼び出されるon_open
とメッセージが送られた時に呼び出されるon_message
とソケットがクローズされた時に呼び出されるon_close
があります。
on_open
では、ユーザがログインした際にユーザ認証し、だれがログインしてきたかをすでに接続しているユーザにブロードキャストするように実装しています。
on_message
では、UserStatus
構造体でJSONデータにシリアライズされたデータが送信されたときにすでに接続しているユーザにブロードキャストするように実装しています。
on_close
では、接続を切断したことを接続しているユーザにブロードキャストするように実装しています。
str_datetime
のメソッドは、通信状態をコンソール上に表示の際に時刻を良い感じに表示するために実装しています。
クライアント側の実装
クライアント側はUnity
で実装し、C#用のWebSocketライブラリであるwebsocket-sharp
を使用して実装しました。なお、websocket-sharp
を導入する方法は2019年アドベントカレンダーの17日目の記事に記述していますので、今回は省略いたします。
WebSocket関連の実装
今回記事に記載するのは、WebSocket周りの実装とします。プレイヤーを動作させたり、カメラを移動したりなどのソースコードは記載しません。なお、今回よりオンラインゲーム感をプレスするために、フィールドのアセットバンドルであるNature Starter Kit 2を導入しています。
using WebSocketSharp;
using UnityEngine;
public class SocketIO: MonoBehaviour
{
[SerializeField]
private Game game = null;
/// <summary>
/// WebSocket
/// </summary>
private WebSocket ws;
private void Start()
{
// 接続先のURLとポート番号を指定する
ws = new WebSocket("ws://localhost:3012/");
ws.SetCredentials(game.UserName, "password", true);
// WebSocketの接続を開始した時に実行されるイベント
ws.OnOpen += (sender, e) => Debug.Log("WebSocket Open");
// メッセージを受信した時に実行されるイベント
ws.OnMessage += (sender, e) => {
game.receivedMessage(e.Data);
};
// 接続に失敗した時に実行されるイベント
ws.OnError += (sender, e) => Debug.Log("WebSocket Error Message: " + e.Message);
// 接続を終了した時に実行されるイベント
ws.OnClose += (sender, e) => Debug.Log("WebSocket Close");
// WebSocketの接続を開始
ws.Connect();
}
/// <summary>
/// メッセージの送信
/// </summary>
public void Send(string message)
{
ws.Send(message);
}
/// <summary>
/// WebSocketの切断
/// </summary>
private void OnDestroy()
{
ws.Close();
ws = null;
}
}
プレイヤーの移動処理を記述しているコンポーネントなどからSocketIO.Send()
を使用し、プレイヤーの座標をフレームごとに送信するように使用します。今回はJSONでデータをやり取りするために、サーバ側に情報を送る実装として以下を記述します。
socketIO.Send(JsonUtility.ToJson(playerUserStatus));
動作確認
上記で貼り付けている動画のように、Unityでビルドした2台のクライアント端末同士がWebSocketを介して双方向通信をし、片方のクライアントがメッセージを送信したら他のクライアントに配信していることがわかりました。
おわりに
今回は、Unityで作成したクライアント端末からオブジェクトの座標を双方通信しリアルタイムに反映していく仕組みを作成しました。本記事はサーバサイドの方をメインに取り上げていますが、クライアント側の実装の方が実は時間をかけておりましてユーザの移動や通信タイミングなどリアルタイム通信を使用したクライアント側の実装は、考えることが多いのかなと感じました。サーバサイド担当者だったとしてもクライアント側の実装も知識として入れておくとよりサーバサイドの実装で必要なことがわかってくるのかなと思いました。
次回は、これからサーバサイド側の実装としてはユーザIDやパスワードを入力しユーザ認証をさせてログインさせる仕組みを作成していければと考えています。より一層複雑な実装になってくると思いますので、もしより良い実装など意見がありましたらコメントしていただけると助かります!
明日は@sunagimo_1111さんになります!