UNET⑦ CommandとClientRPCでチャットを作る

今回は、UNETのCommandとClientRPCを使って、簡易的なチャットシステムを作ります。

準備作業

  1. Unityを起動し、新規プロジェクトを作成します。
  2. 空のオブジェクトを作成し、NetworkManagerという名前にします。
  3. 上記のオブジェクトにNetworkManagerコンポーネントとNetworkManagerHUDコンポーネントをアタッチします。

UIの作成

HierarchyビューのCreate>UI>Canvasを押してCanvasを追加します。
CanvasScalerコンポーネントの UI Scale Mode は Scale With Screen Size にしておいたほうが良いかと思います。

f:id:motoyamablog:20171028220041p:plain

Canvas配下にTextを追加します。
オブジェクト名は「ChatHistory」としておいてください(後でプログラムから検索するので、厳守でお願いします)。

Canvas配下にInputFieldを追加します。
オブジェクト名は「ChatInputField」としておいてください(これも後でプログラムから検索するので、厳守でお願いします)。

レイアウトは自由な感じで構いませんが、私は次のようにしました。

f:id:motoyamablog:20171028220046p:plain

Textを左上のほうに置くと、NetworkManagerHUDと被るので注意です・・・
あ、あとChatHistoryのtextは空にしておいてください。

以上でUIの作成は完了です。

プレイヤーの作成

今回はゲームというより、純粋なチャットのようなものを作るので、画面に「プレイヤーキャラクター」は登場しないのですが、UNETではCommandを使えるのがプレイヤーオブジェクトだけなので、プレイヤーを作ります。(Command以外の仕組みを使って情報の送信を行えばプレイヤーオブジェクトは不要ですが、それはまた別の機会に・・・)

まず、空のオブジェクトを作成します。オブジェクト名は「Player」とします。

PlayerオブジェクトにNetworkIdentityコンポーネントをアタッチします。

PlayerオブジェクトにAdd Component>New Scriptで新規スクリプトをアタッチします。スクリプト名は「Player」とします。

using UnityEngine;
using UnityEngine.Networking; // NetworkBehaviourを使うのに必要
using UnityEngine.UI; // UGUIを使うのに必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承する
{
    // チャットのメッセージ履歴を表示するText
    Text m_ChatHistory;
    // メッセージの入力欄
    InputField m_ChatInputField;

    void Start()
    {
        // メッセージ履歴を表示するTextを検索して取得
        m_ChatHistory = GameObject.Find("ChatHistory").GetComponent<Text>();
    }

    // ローカルプレイヤーが初期化される際に呼ばれる。
    public override void OnStartLocalPlayer()
    {
        // メッセージ入力欄のInputFieldを検索して取得
        m_ChatInputField = GameObject.Find("ChatInputField").GetComponent<InputField>();
    }

    void Update()
    {
        // 他人のプレイヤーに対しては何も行わない
        if (!isLocalPlayer)
            return;

        // Enterキーが押されて
        if (Input.GetKeyDown(KeyCode.Return))
        {
            // 文字列が入力されていたら
            if (m_ChatInputField.text.Length > 0)
            {
                // Commandを使って入力された文字列をサーバーへ送信
                CmdPost(m_ChatInputField.text);

                // 入力欄を空にする
                m_ChatInputField.text = "";
            }
        }
    }

    // 文字列をサーバーに送信する。
    [Command]
    void CmdPost(string text)
    {
        RpcPost(text);
    }

    // ClientRpcは各クライアントで実行される。
    // チャット履歴表示欄に文字列を追加する。
    [ClientRpc]
    void RpcPost(string text)
    {
        m_ChatHistory.text += text + System.Environment.NewLine;
    }
}

Playerオブジェクトをプレハブ化し、Sceneからは削除します。

NetworkManagerのSpawnInfo>PlayerPrefab欄にPlayerプレハブを登録します。

f:id:motoyamablog:20171028220055p:plain

以上でチャットの完成です。2個以上ゲームを起動し、動作確認してみてください。

f:id:motoyamablog:20171028220103g:plain

仕組みの解説

今回は、UNETのCommandとClientRPCという機能を使って、チャットを実装しました。
チャットのような仕組みを作る時、UNET(というかクライアントサーバーシステム)の考え方に慣れていないと、次の図のように、他のクライアントに直接メッセージを送信しようと考えてしまうのではないでしょうか?

f:id:motoyamablog:20171028220441g:plain

UNETでは、基本的にこのようなP2P的な情報の送信はできません。

UNETで、あるクライアントの操作をその他のクライアントに反映させるには、以下のようにします。

f:id:motoyamablog:20171028220502g:plain

  1. クライアント上で何らかの操作が行われる(今回でいえばチャットのメッセージの送信)
  2. Commandを使って、情報をサーバーに送信
  3. サーバーはClientRPCを使って各クライアントに情報を配信
  4. 情報を受信した各クライアントは、情報を画面に反映させる

ClientRPCという単語が出てきました。これは、各クライアントに処理を実行させたり、各クライアントに情報を配信するために使うUNETの機能です。詳細は後述します。

スクリプトの解説

今回、チャットのための2つのUIコンポーネントの参照をプログラムで保持しています。メッセージ履歴を表示するためのTextと、入力欄のInputFieldです。この2つ、参照を取得するタイミングが違うのですが、その理由を説明します。

メッセージ履歴を表示するText(m_ChatHistory)のほうは、Startメソッドで取得しています。Unityではごくありふれたことですね。これに対して、入力欄のInputField(m_ChatInputField)は、OnStartLocalPlayerメソッドで参照を取得しています。このメソッドは、基底クラスであるNetworkBehaviourのメソッドで、overrideしておくと、オブジェクトがローカルプレイヤーであるときのみ、最初に呼ばれます。ローカルプレイヤーにのみ必要な初期化処理はここで行うと良いでしょう。

何故、入力欄の参照の取得はローカルプレイヤーのみで行うのかというと、入力欄を使うのはローカルプレイヤーのみだからです。というかプレイヤー(人間)といったほうが良いでしょうか。入力欄を使うのは、操作している人間です。で、入力内容を送信するために、プレイヤー(人間)の分身であるローカルプレイヤーオブジェクトが、処理を行います。他人のプレイヤーは、自分の画面の中においては、入力欄とは関わりがありません。なので、入力欄の参照を取得する必要がありません。

メッセージ履歴を表示するTextについては、話が別です。こちらは、各プレイヤーがメッセージを投稿してきます。よって、各プレイヤーオブジェクトは表示欄と関係アリというわけで、全プレイヤーオブジェクトにTextの参照を持たせています。

なお、OnStartLocalPlayerメソッドを使わなくても、Startメソッドで

    void Start()
    {
        if (isLocalPlayer)
        {
            ローカルプレイヤーでのみ行いたい処理
        }
    }

とやっても同じことができます。どちらを使うかは、好みの問題です。たぶん。

今回、チャットのメッセージをサーバーに送信するためにCommandを使っています(CmdPostという関数)。Commandの詳しい説明は以前の記事を見てください。

Commandによって投稿内容を受け取ったサーバーは、ClientRPCという機能を使って、各クライアントへ投稿内容を配信しています。スクリプト中のRpcPostという関数です。

ClientRPC

ClientRPCはサーバーが実行できる機能で、各クライアントで処理を実行させたり、各クライアントに情報を配信したりできます。もう少し具体的にいうと、サーバー側で関数を呼び出すと、各クライアントで関数が実行されます。Commandの逆バージョンと思ってもらえれば大体大丈夫かと思います。

f:id:motoyamablog:20171028015158g:plain

使い方やポイントを下記に記します。

[ClientRpc]属性をつける

ClientRPCとして機能させたい関数には[ClientRpc]属性を付けます。
[RPC]という属性もあるので、間違えないでください。これは昔使われていたもので、現在は非推奨の機能です。

関数名はRpcで始める

ClientRPCとして機能させたい関数は、必ずRpcから始まる名前にしなくてはいけません。そういうお約束です。試しに今回作ったRpcPost関数の名前をPostなどに変えてみるとわかりますが、ビルドエラーとなります。C#の文法上の問題ではなく、UNETのビルドの仕組みがエラーを吐きます。

引数で情報を渡せる

既に、しれっと使っていますが、ClientRPCの関数には自由に引数を渡せます(引数なしでももちろん構いません)。Commandと同じです。使える型もCommandと同じです:

  • 基本型 (byte、int、float、string、など)
  • 基本型の配列
  • Unity にビルトインしている数学に関する型 (Vector3、Quaternion、など)
  • NetworkIdentity
  • NetworkInstanceId
  • NetworkHash128
  • NetworkIdentity コンポーネントがアタッチされたGameObject
  • 上記のみを含む自作の構造体

他にもあるかもしれませんが、こんなところです。まあ、これだけ使えれば十分でしょう。

サーバーでしか呼べない

ClientRpcの関数は、サーバー(ホスト)でしか呼び出せません。ホストではないリモートクライアントで呼び出すと、何も起きずにエラーログが出ます。

どのオブジェクトからも呼べる

Commandはローカルプレイヤーオブジェクトしか使えませんが、ClientRPCはサーバー上であれば、どのオブジェクトからでも実行できます。

全クライアントで実行される

サーバーにて呼び出されたClientRPCの関数は、全クライアントで実行されます。特定のクライアントでのみ実行するということはできません。そういうことがしたい場合は、TargetRPCやMessageという機能を使います。

ローカルクライアントでもちゃんと実行される

ホスト上でClientRPCの関数を呼び出すと、全てのクライアント上で、該当の関数が実行されます。全てのクライアント上で、です。ホスト上のクライアントつまりローカルクライアントも例外ではありません。ちゃんと呼ばれます。上図で、丸い矢印がぐるぐる回ってるのがそれを表しています。なので、開発者はローカルクライアントとリモートクライアントを特に区別する必要はありません。

ちなみにRPCとはRemote Procedure Call(リモートプロシージャコール)の略で、ネットワークで接続された別のコンピューターのプログラムを呼び出して処理を行わせることです。

余談:脆弱性について

ここから先はUNETとは無関係の話ですが、ネットワークゲームというくくりでは無関係ではないので、書いておきたいと思います。

今回のサンプルには脆弱性があります。セキュリティ的に問題があって、悪いヤツにハックされる余地があるということです。

その手順は簡単です。チャットの入力欄に次のように入力してみてください。

<size=40>任意の文字列</size>

 

デカい文字が表示されちゃいますよね?

これはRichText機能の悪用です。

UGUIのTextにはRichTextという機能があって、HTMLのような特殊なタグで文字列を囲むことによって装飾ができるようになっています。上記のように<size>というタグで囲むと文字サイズが変えられるし、<color>というタグで囲むと色が変えられます。便利な機能ですが、ユーザーがタグを使って好きなサイズで文章を投稿できては駄目でしょう。巨大な文字が画面を埋め尽くし、他のユーザーの迷惑になります。「荒らし」ですね。

この問題の対策はどうしたら良いのでしょうか?

簡単な対策として、RichText機能を無効にするという方法があります。TextコンポーネントのRichTextのチェックボックスをOFFにするだけです。

f:id:motoyamablog:20171028220536p:plain

しかし当然ですが、これではRictTextの機能が使えなくなります。チャットでは、プレイヤーによって文字の色を変えたりしたいということも多いでしょう。RichTextの機能を有効にしたまま、ユーザーによる悪用を防ぐには、どうしたら良いのでしょうか?

RichTextを有効にしたままの対策として、タグ文字(<)を無害な文字に置換するという方法があります。エスケープとかサニタイジング(無害化)とか呼ばれます。
特殊な意味のある文字を文字列の中に普通の文字として含めたいときのために、エスケープという手段が大抵用意されています。何を言ってるのかわからねーと思うが・・・例えば、C#ソースコードにおいてダブルクォーテーションは特別な文字ですが、文字列の中にダブルクォーテーションを含めたいときは、ダブルクォーテーションの前に¥(バックスラッシュ)を書けばOKですね。

string message = "\"アレ\"だよ、\"アレ\"";

これで、

"アレ"だよ、"アレ"

という文字列になりますよね?これがエスケープです。
で、こういったエスケープの方法がRichTextにも当然用意されていると思って調べたのですが、なんと・・・見つかりませんでした!!😭

一応、対処療法的な代替手段は見つけましたが・・・モヤモヤするんだよなあ・・・

やり方は、次のように、文字列を置換します。タイミングは、サーバーで受け取ったタイミングか、画面に表示する直前のいずれかです。

text = text.Replace("<", "<<b></b>");

これは、 < の後に、<b></b>を挿入することによって、 < とその後のタグ名を分離させ、タグとして無効化するという手法です。<b> は太字タグです。<b></b> は太字で結局何も表示しないということです。RichTextの構文解析の仕組みをハックしたものですね。あ、ちなみに<b>じゃなくても、別になんのタグでも良いです。短いから<b>を使ってるだけです。

他には、 < を < に置換するという対策などもありました。・・・意味わかりますか?

半角の < を全角の<に置換するということです。全角の<はタグとして機能しないので安全ということです。・・・しかし、別の文字になっちゃってるわけだし、見た目も若干違うので、キモチワルイ😓

 

ちなみに、こういった脆弱性は、ネットワークゲーム特有のものではなく、インターネット関連のサービスでは定番のものです。「クロスサイトスクリプティング」とか「SQLインジェクション」とか「コマンドインジェクション」とか様々な攻撃手法がありますが、原理的には全部同じです。
不特定多数のユーザーからの入力は信用してはならない、どんなデータが送られてきても誤動作しないようにする、ということが大切です。

また、今回は、文字列の長さをチェックしていませんが、本当はこれもチェックしなければなりません。InputFieldにCharacterLimitという文字数制限のプロパティがあるので、これを使えばOKかと思いがちですが、それだけでは不完全です。ユーザーが不正(ゲームの改造やチートツール)して、ありえない文字列を送りつけてくる可能性もあるので、クライアント上での文字数制限に加えて、サーバーでも文字数チェックは必要です。

ちょっと余談が長くなりましたが、インターネットを使ったゲームを作るということは、こういったところまで気を使う必要があるのです。めんどくさいですね😂

 

次へ進む

目次へ