UNET⑪ Messageを使って情報を送信する
UNETのMessageという機能を使って、サーバー↔クライアント間で情報をやり取りする方法を学んでいきます。
公式マニュアルはこちら
Messageを全く使わなくても、ネットワークゲームを作ることは可能です。しかし、UNETの内部ではMessageを多用しており、Messageを理解していないと、UNETのソースコードを読むことは困難でしょう。そして現状、UNETを使いこなすためにはUNETのソースコードを読む必要があるので、結局、Messageの理解は必要です・・・
また、今回はNetworkManagerを使わずに、通信処理を作ります。これにより、普段お世話になっているNetworkManagerがどんな仕事をしてくれているのかが理解しやすくなるはずです。
予備知識
Messageとは、UNETの各種通信処理を実現するための、比較的低レベルな通信機能です。CommandやClientRPCやSyncVarなどは、内部的にはMessageによって実現されています。UNET利用者は、このMessageを直接利用することもできます。
動作の流れは、受信側であらかじめMessageID(ただの整数)と関数の対を登録しておき、送信側はMessageIDをネットワーク越しに送信し、受信側はMessageIDに対応する関数を実行する、というものです。MessageIDと同時にデータを送信することも可能です。
Messageを使うには、NetworkServerとNetworkClient、またはNetworkConnectionを使います。これらの関係は以下のようになります。
この図には登場しませんが、普段これらの機能をまとめ、よしなにしてくれているのがNetworkManagerです。
準備
通信結果をログで表示したいので、以前作ったログを画面に表示する機能を使います。持っていない人はこちらから入手して適当なオブジェクトにアタッチしてください。
見やすくなるように、CameraのClear FlagsはSoldColorにします。
PlayerSettingsから、Run In Backgroundをオンにします。
これをしないと、アプリにフォーカスが当たっていない時は ゲームが止まってしまい、通信も行われません。ちなみに、普段はNetworkManagerが自動的に設定してくれています。
Messageを使う
MessageIDを定義
あらかじめMessageのIDを定義する必要があります。
MyMsgという名前で新規スクリプトを作成し、次のようにしてください。
public static class MyMsg
{
public const short Hoge = MsgType.Highest + 1;
public const short Fuga = MsgType.Highest + 2;
}
ただ関数にユニークな番号を割り振るために、適当に定数を作っているだけです。番号はユニークであれば何でも良いのですが、ただし、1~47番くらいまでは、UNETが既に使用済みなので、それを避けて定義する必要があります。MsgType.HighestはUNETが使っている最も大きな番号なので、それより大きな値にすれば大丈夫です。
Messageを使う
次に、新しい空のオブジェクトを追加します。
そのオブジェクトに新規スクリプトを追加し、名前はMessageTestなどとします。
using UnityEngine.Networking;
using UnityEngine.Networking.NetworkSystem;
public class MessageTest : MonoBehaviour
{
const int Port = 7777; // ポート番号。他のプログラムと被らなければ何でも良いです
NetworkClient client;
void Start()
{
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
print("サーバー起動");
NetworkServer.Listen(Port);
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
print("Handlerを登録");
NetworkServer.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
NetworkServer.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
}
if (Input.GetKeyDown(KeyCode.Alpha3))
{
print("サーバーから全クライアントへMsgHogeを送信");
NetworkServer.SendToAll(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Alpha4))
{
print("サーバーから全クライアントへMsgFugaを送信");
NetworkServer.SendToAll(MyMsg.Fuga, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Q))
{
print("クライアント生成");
client = new NetworkClient();
}
if (Input.GetKeyDown(KeyCode.W))
{
print("client.isConnected:" + client.isConnected);
}
if (Input.GetKeyDown(KeyCode.E))
{
print("クライアントをサーバーへ接続");
client.Connect("127.0.0.1", Port);
}
if (Input.GetKeyDown(KeyCode.R))
{
print("Handlerを登録");
client.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
client.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
}
if (Input.GetKeyDown(KeyCode.T))
{
print("クライアントからサーバーへMsgHogeを送信");
client.Send(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Y))
{
print("クライアントからサーバーへMsgFugaを送信");
client.Send(MyMsg.Fuga, new EmptyMessage());
}
}
void OnHogeReceived(NetworkMessage message)
{
print("MsgHogeを受信");
}
void OnFugaReceived(NetworkMessage message)
{
print("MsgFugaを受信");
}
}
1キーでサーバーの機能が起動し、ネットワーク越しの通信を待ち受けます。
2キーでHandler(イベント受信時に呼ばれる関数)を登録します。
3キーでサーバーからクライアントに向けてメッセージHogeを送信します。
4キーでサーバーからクライアントに向けてメッセージFugaを送信します。
Qキーでクライアントのインスタンスを生成します。
Wキーでクライアントの接続状態をログに出します。
Eキーでサーバーに接続します(今回は自身のIPアドレスを決め打ちしています。ここを変えれば、別のマシンに接続します)
RキーでHandlerを登録します。
Tキーでクライアントからサーバーに向けてメッセージHogeを送信します。
Yキーでクライアントからサーバーに向けてメッセージFugaを送信します。
動作確認
ゲームを2つ以上起動してください。
片方で1を押し、サーバーを起動します。
次に2を押し、Handlerを登録します。
この状態で3や4を押しても、何も起きません。メッセージを送る相手がいないからです。
もう片方にて、まずQを押してNetworkClientのインスタンスを生成します。
Wを押して状態を確認します。まだ未接続です。
Eを押してサーバーに接続します。Wで接続されたか確認してみましょう。
RでHandlerを登録します。これで受信の準備が整いました。
サーバー側で3, 4を押したり、クライアント側でT, Yを押したりして、動作を確認してみてください。
また、Handlerを登録していない状態でメッセージを送信すると、どうなるでしょうか?
片側で、サーバーを起動後、さらにクライアントも起動することが可能です。サーバー兼クライアントとなります。つまりこれはホストです。実際、NetworkManagerを通してUNETを使う際も、内部では同様のことをやっています。
Messageの使い方
NetworkServer.RegisterHandler()またはNetworkClient.RegisterHandler()によって、あらかじめ番号と関数の対を登録しておきます。登録する関数は、引数にNetworkMessageを受け取らなければなりません(今回は引数で受け取った値を使っていませんが、それでも必要です)。
Messageを送信するには、NetworkServerかNetworkClient(またはNetworkConnection)のSend○○メソッドを使います。
情報を送信する
ネットワークの向こうにあるマシンに、何らかのイベントの発生を知らせるだけであれば、上記のようなプログラムで充分かもしれませんが、同時に情報を送信したいこともあるでしょう。Sendする際にIntegerMessageやStringMessageを使うことによって、整数や文字列を送信することができます。試してみましょう。
public static class MyMsg
{
public const short Hoge = MsgType.Highest + 1;
public const short Fuga = MsgType.Highest + 2;
public const short Int = MsgType.Highest + 3;
public const short String = MsgType.Highest + 4;
}
using UnityEngine.Networking;
using UnityEngine.Networking.NetworkSystem;
public class MessageTest : MonoBehaviour
{
const int Port = 7777;
NetworkClient client;
void Start()
{
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
print("サーバー起動");
NetworkServer.Listen(Port);
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
print("Handlerを登録");
NetworkServer.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
NetworkServer.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
}
if (Input.GetKeyDown(KeyCode.Alpha3))
{
print("サーバーから全クライアントへMsgHogeを送信");
NetworkServer.SendToAll(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Alpha4))
{
print("サーバーから全クライアントへMsgFugaを送信");
NetworkServer.SendToAll(MyMsg.Fuga, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Alpha5))
{
print("サーバーから全クライアントへ123を送信");
NetworkServer.SendToAll(MyMsg.Int, new IntegerMessage(123));
}
if (Input.GetKeyDown(KeyCode.Alpha6))
{
print("サーバーから全クライアントへ文字列を送信");
NetworkServer.SendToAll(MyMsg.String, new StringMessage("こんにちは"));
}
if (Input.GetKeyDown(KeyCode.Q))
{
print("クライアント生成");
client = new NetworkClient();
}
if (Input.GetKeyDown(KeyCode.W))
{
print("client.isConnected:" + client.isConnected);
}
if (Input.GetKeyDown(KeyCode.E))
{
print("クライアントをサーバーへ接続");
client.Connect("127.0.0.1", Port);
}
if (Input.GetKeyDown(KeyCode.R))
{
print("Handlerを登録");
client.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
client.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
client.RegisterHandler(MyMsg.Int, OnIntegerMessageReceived);
client.RegisterHandler(MyMsg.String, OnStringMessageReceived);
}
if (Input.GetKeyDown(KeyCode.T))
{
print("クライアントからサーバーへMsgHogeを送信");
client.Send(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Y))
{
print("クライアントからサーバーへMsgFugaを送信");
client.Send(MyMsg.Fuga, new EmptyMessage());
}
}
void OnHogeReceived(NetworkMessage message)
{
print("MsgHogeを受信");
}
void OnFugaReceived(NetworkMessage message)
{
print("MsgFugaを受信");
}
void OnIntegerMessageReceived(NetworkMessage message)
{
int value = message.ReadMessage<IntegerMessage>().value;
print("IntegerMessageを受信:" + value);
}
void OnStringMessageReceived(NetworkMessage message)
{
string value = message.ReadMessage<StringMessage>().value;
print("StringMessageを受信:" + value);
}
}
サーバー側で5, 6キーを押すと、クライアントに向けて整数や文字列が送信されます。クライアント側では、Handler関数の引数で受け取ったNetworkMassageのReadMessageメソッドを使って値を取り出します。
もっと色々な情報を送信する
整数や文字列よりも、もっと複雑な情報や大量の情報を送りたい場合は、MessageBaseクラスを継承した独自のクラスを作ります。試しに、int、float、string、Vector3のデータをまとめて送信してみましょう。
public static class MyMsg
{
public const short Hoge = MsgType.Highest + 1;
public const short Fuga = MsgType.Highest + 2;
public const short Int = MsgType.Highest + 3;
public const short String = MsgType.Highest + 4;
public const short Custom = MsgType.Highest + 5;
}
using UnityEngine.Networking;
using UnityEngine.Networking.NetworkSystem;
public class MessageTest : MonoBehaviour
{
const int Port = 7777;
NetworkClient client;
void Start()
{
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
print("サーバー起動");
NetworkServer.Listen(Port);
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
print("Handlerを登録");
NetworkServer.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
NetworkServer.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
NetworkServer.RegisterHandler(MyMsg.Custom, OnCustomMessageReceived);
}
if (Input.GetKeyDown(KeyCode.Alpha3))
{
print("サーバーから全クライアントへMsgHogeを送信");
NetworkServer.SendToAll(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Alpha4))
{
print("サーバーから全クライアントへMsgFugaを送信");
NetworkServer.SendToAll(MyMsg.Fuga, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Alpha5))
{
print("サーバーから全クライアントへ123を送信");
NetworkServer.SendToAll(MyMsg.Int, new IntegerMessage(123));
}
if (Input.GetKeyDown(KeyCode.Alpha6))
{
print("サーバーから全クライアントへ文字列を送信");
NetworkServer.SendToAll(MyMsg.String, new StringMessage("こんにちは"));
}
if (Input.GetKeyDown(KeyCode.Q))
{
print("クライアント生成");
client = new NetworkClient();
}
if (Input.GetKeyDown(KeyCode.W))
{
print("client.isConnected:" + client.isConnected);
}
if (Input.GetKeyDown(KeyCode.E))
{
print("クライアントをサーバーへ接続");
client.Connect("127.0.0.1", Port);
}
if (Input.GetKeyDown(KeyCode.R))
{
print("Handlerを登録");
client.RegisterHandler(MyMsg.Hoge, OnHogeReceived);
client.RegisterHandler(MyMsg.Fuga, OnFugaReceived);
client.RegisterHandler(MyMsg.Int, OnIntegerMessageReceived);
client.RegisterHandler(MyMsg.String, OnStringMessageReceived);
}
if (Input.GetKeyDown(KeyCode.T))
{
print("クライアントからサーバーへMsgHogeを送信");
client.Send(MyMsg.Hoge, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.Y))
{
print("クライアントからサーバーへMsgFugaを送信");
client.Send(MyMsg.Fuga, new EmptyMessage());
}
if (Input.GetKeyDown(KeyCode.U))
{
print("クライアントからサーバーへCustomMessageを送信");
CustomMessage m = new CustomMessage();
m.intValue = 1234;
m.floatValue = 567.8f;
m.stringValue = "あいうえお";
m.vector3Value = new Vector3(1, 2, 3);
client.Send(MyMsg.Custom, m);
}
}
void OnHogeReceived(NetworkMessage message)
{
print("MsgHogeを受信");
}
void OnFugaReceived(NetworkMessage message)
{
print("MsgFugaを受信");
}
void OnIntegerMessageReceived(NetworkMessage message)
{
int value = message.ReadMessage<IntegerMessage>().value;
print("IntegerMessageを受信:" + value);
}
void OnStringMessageReceived(NetworkMessage message)
{
string value = message.ReadMessage<StringMessage>().value;
print("StringMessageを受信:" + value);
}
class CustomMessage : MessageBase
{
public int intValue;
public float floatValue;
public string stringValue;
public Vector3 vector3Value;
}
void OnCustomMessageReceived(NetworkMessage message)
{
CustomMessage m = message.ReadMessage<CustomMessage>();
Debug.LogFormat("CustomMessageを受信:{0}, {1}, {2}, {3}", m.intValue, m.floatValue, m.stringValue, m.vector3Value);
}
}
公式マニュアルの説明↓
メッセージクラスは、ベーシックタイプ・構造体・配列のメンバや、Unity のほとんどの通常エンジンタイプ(Vector3 など)のメンバを含むことができます。複雑なクラスやジェネリックのコンテナのメンバは含むことができません。配列が送信可能であることは頼もしいです。配列の長さを一緒に送ったりする必要はなく、勝手にうまいことやってくれます。なお、MessageBaseを継承した独自クラスを作る際は、プロパティを使ってはいけません。「publicな変数は気持ち悪い、プロパティにしよう!」としてしまうと、UNETの仕様上、正しく動作しません。注意しましょう。
Messageを利用してお絵描きチャットを作る
リアルタイムなお絵描きチャットプログラムを作ってみたいと思います。別にMessageを使わなくても作れますが、せっかくMessageの使い方を覚えたので、練習のために使いたいと思います。
まずお絵描きプログラムを作成
新規プロジェクトを作成します。
UI>RawImageを追加し、次のように設定します。
画面いっぱいにRawImageが広がります。
RawImageオブジェクトに新規スクリプトを追加します。名前はOekakiChatとでもします。
using UnityEngine; using UnityEngine.UI; public class OekakiChat : MonoBehaviour { Texture2D texture; void Start() { // テクスチャーを生成 texture = new Texture2D(256, 256); // 補間無し texture.filterMode = FilterMode.Point; // 白で塗りつぶし ResetCanvas(); // RawImageにテクスチャーを設定 GetComponent<RawImage>().texture = texture; } void Update() { // Rキーでリセット if (Input.GetKeyDown(KeyCode.R)) { ResetCanvas(); } // マウス左ボタンで描画 if (Input.GetMouseButton(0)) { Vector2 screenPos = GetMousePosInScreenPoint(); DrawPoint((int)screenPos.x, (int)screenPos.y, Color.blue); } } // マウスの場所をスクリーン座標(というかテクスチャー上での座標)で取得する Vector2 GetMousePosInScreenPoint() { Vector2 mousePos = Input.mousePosition; Vector2 screenPos = new Vector2(mousePos.x / Screen.width * texture.width, mousePos.y / Screen.height * texture.height); screenPos.x = Mathf.Clamp(screenPos.x, 0, texture.width - 1); screenPos.y = Mathf.Clamp(screenPos.y, 0, texture.height - 1); return screenPos; } // リセット void ResetCanvas() { Color[] pixels = new Color[texture.width * texture.height]; for (int y = 0; y < texture.height; y++) { for (int x = 0; x < texture.width; x++) { pixels[texture.width * y + x] = Color.white; } } texture.SetPixels(pixels); texture.Apply(); // Applyしないと、変更が反映されない } // 指定した点を塗る void DrawPoint(int x, int y, Color color) { texture.SetPixel(x, y, color); texture.Apply(); } }
動作確認してみましょう。マウスで絵が描けます。Rキーでリセット(白塗りつぶし)です。
ここまでは、UNET全く関係無いです。
Messageで情報を送信
お絵描きの情報をMessageを使って送信してみましょう。
PlayerSettingsを開き、Run In Backgroundをオンにしておきます。
スクリプトを改修します。
using UnityEngine; using UnityEngine.UI; using UnityEngine.Networking; using UnityEngine.Networking.NetworkSystem; public static class MyMsg { public const short Reset = MsgType.Highest + 1; public const short DrawPoint = MsgType.Highest + 2; } class DrawPointMessage : MessageBase { public int x; public int y; } public class OekakiChat : MonoBehaviour { const int Port = 7777; NetworkClient client; Texture2D texture; void Start() { // テクスチャーを生成 texture = new Texture2D(256, 256); // 補間無し texture.filterMode = FilterMode.Point; // 白で塗りつぶし ResetCanvas(); // RawImageにテクスチャーを設定 GetComponent<RawImage>().texture = texture; } void Update() { // Rキーでリセット if (Input.GetKeyDown(KeyCode.R)) { ResetCanvas(); // リセットしたことをサーバーへ通知 client.Send(MyMsg.Reset, new EmptyMessage()); } // マウス左ボタンで描画 if (Input.GetMouseButton(0)) { Vector2 screenPos = GetMousePosInScreenPoint(); DrawPoint((int)screenPos.x, (int)screenPos.y, Color.blue); // 点を描画したことをサーバーへ通知 DrawPointMessage m = new DrawPointMessage(); m.x = (int)screenPos.x; m.y = (int)screenPos.y; client.Send(MyMsg.DrawPoint, m); } // 1キーでサーバー起動 if (Input.GetKeyDown(KeyCode.Alpha1)) { NetworkServer.Listen(Port); NetworkServer.RegisterHandler(MyMsg.Reset, OnServerResetReceived); NetworkServer.RegisterHandler(MyMsg.DrawPoint, OnServerDrawPointReceived); } // 2キーでクライアント起動 if (Input.GetKeyDown(KeyCode.Alpha2)) { client = new NetworkClient(); client.Connect("127.0.0.1", Port); client.RegisterHandler(MyMsg.Reset, OnClientResetReceived); client.RegisterHandler(MyMsg.DrawPoint, OnClientDrawPointReceived); } } // マウスの場所をスクリーン座標(というかテクスチャー上での座標)で取得する Vector2 GetMousePosInScreenPoint() { Vector2 mousePos = Input.mousePosition; Vector2 screenPos = new Vector2(mousePos.x / Screen.width * texture.width, mousePos.y / Screen.height * texture.height); screenPos.x = Mathf.Clamp(screenPos.x, 0, texture.width - 1); screenPos.y = Mathf.Clamp(screenPos.y, 0, texture.height - 1); return screenPos; } // リセット void ResetCanvas() { Color[] pixels = new Color[texture.width * texture.height]; for (int y = 0; y < texture.height; y++) { for (int x = 0; x < texture.width; x++) { pixels[texture.width * y + x] = Color.white; } } texture.SetPixels(pixels); texture.Apply(); // Applyしないと、変更が反映されない } // 指定した点を塗る void DrawPoint(int x, int y, Color color) { texture.SetPixel(x, y, color); texture.Apply(); } // サーバーがResetメッセージを受信した際のハンドラ void OnServerResetReceived(NetworkMessage message) { // リセットしたことを全クライアントへ通知 NetworkServer.SendToAll(MyMsg.Reset, new EmptyMessage()); } // サーバーがDrawPointメッセージを受信した際のハンドラ void OnServerDrawPointReceived(NetworkMessage message) { // 点を描画したことを全クライアントへ通知 NetworkServer.SendToAll(MyMsg.DrawPoint, message.ReadMessage<DrawPointMessage>()); } // クライアントがResetメッセージを受信した際のハンドラ void OnClientResetReceived(NetworkMessage message) { ResetCanvas(); } // クライアントがDrawPointメッセージを受信した際のハンドラ void OnClientDrawPointReceived(NetworkMessage message) { DrawPointMessage m = message.ReadMessage<DrawPointMessage>(); DrawPoint(m.x, m.y, Color.blue); } }
スクリプトの改修が済んだら、動作確認してみましょう。
ゲームを2つ以上起動し、1つの画面で1キーを押してサーバーを起動し、さらに2も押してクライアントも起動し、ホストとします。
他のウィンドウでは、2キーのみ押してリモートクライアントとします。
より良いものにするために
今回作ったアプリは、最低限のリアルタイムお絵かきチャットが可能ですが、いくらでも改善すべき点があります。
ネットワークの観点から
- 毎フレーム情報を送信しているので、ネットワークの無駄遣いです。もう少し送信頻度を落としたほうが良いでしょう。そのためには、描画情報を蓄えておき、溜まったら送信したり、一定の時間が経過したら送信するなどの工夫が必要
- 描画位置の座標を送信するのにint型を使っていますが、キャンバスの広さが256ピクセルしかないので、byte型で足ります。
- 直線は点の集まりではなく始点と終点のみで表現したり、ベジェ曲線などを使ったりすることによって、大幅にデータ量を削減できる可能性があります。
- ネットワークに途中参加すると、それ以前の絵が見れません。途中から参加してきたメンバーには、その瞬間のキャンバスを送信してあげる必要があるでしょう(※ただし、テクスチャのピクセル情報をそのまま送信すると、おそらく通信の制限に引っかかるでしょう)
機能の観点から
- 各フレームにおけるマウスの位置を塗る方式のため、マウスを速く動かすと線が途切れてしまいます。点と点を線でつなぎましょう(アルゴリズム)
- 描画色を切り替えられたら良いですね
- IPアドレス決め打ちのため、ローカルマシン内でしか通信できません。サーバーのIPアドレス指定して他のマシンに接続できるようにしましょう