Unity上でUDP/IPを用いた通信機能(マルチプレイ)を実装する

こんちゃ!洋梨🍐です。

unityでマルチプレイをはじめとする通信機能を実装したい方、多いのではないでしょうか?

そこでUNET(廃止予定の様ですが…)、Socket.ioなどを使おうと考えた・考えている方も多いと思うのですが私は勉強の為やライセンスの問題、使いやすさの点から1から作ってみたのでそれまでの過程及びソースコードなどを記事にしました。

今回作るもの

今回はUnityの動く端末間で、オブジェクトの位置情報を共有できるようにしていきたいと思います。


PC・スマホ間でステージ内Cubeの位置情報を共有している図

応用次第ではクライアントサーバー形式・p2p形式でのマルチプレイ機能実装など様々なことに使えるのではないでしょうか?

仕様

今回はゲームオブジェクトの位置共有、つまりTransform.positionのVector3 情報を受信及び反映、送信したいとおもいます。

なお、今回行う通信はアクションゲームで使うことを推定し、高速化を図るためUDPで行います。

Tips 「TCPとUDPの違い」
TCP : 安定性は高いが低速。

>ゲームではチャットやマッチメイキングに使われる。
UDP : 不安定だが高速。

>FPSなど速さが大切なゲームの通信で使われている。
(※RUDP : 二つを組み合わせ安定性、高速を兼ねそろえたもの。実装はアプリ側。)

プロジェクト

今回作ったプロジェクト

今回はステージ内に配置したCubeの座標を共有します。椅子とかは距離感をつかみやすい様になんとなくおいた飾りです。Textには現在のCubeの座標を表示しようと思います。

(プロジェクトファイルのダウンロードは下にあります。)

スクリプト

main.cs メイン。主な動作はここに書く。
UdpSystem.cs UDP通信の動作をまとめてあるものです
MyNetwork.cs IPアドレス取得等、ネットワーク関連の基本動作を担当

(ソースファイルのダウンロードは下にあります。)

ポート番号

同じでもいいのですが今回はホスト端末Aには5001、クライアント端末Bには5002としてあります。なお、送信に使うポートは6001としました。

パケット構造

今回は位置情報の共有のみなのでVector3(x,y,z)の情報が入るパケットを作って送ります。パケット情報を管理するクラスは以下の通りです。

using System; //BitConverter
public class DATA
{
    private float x;
    private float y;
    private float z;

    public DATA(byte[] bytes)
    {
        x = BitConverter.ToSingle(bytes, 0);
        y = BitConverter.ToSingle(bytes, 4);
        z = BitConverter.ToSingle(bytes, 8);
    }
    public DATA(Vector3 vector3){
        x = vector3.x;
        y = vector3.y;
        z = vector3.z;
    }
    public byte[] ToByte()
    {
        byte[] x = BitConverter.GetBytes(this.x);
        byte[] y = BitConverter.GetBytes(this.y);
        byte[] z = BitConverter.GetBytes(this.z);
        return MargeByte(MargeByte(x, y), z);
    }
    public Vector3 ToVector3()
    {
        return new Vector3(this.x, this.y, this.z);
    }
    public static byte[] MargeByte(byte[] baseByte,byte[] addByte)
    {
        byte[] b = new byte[baseByte.Length + addByte.Length];
        for(int i = 0; i < b.Length; i++)
        {
            if (i < baseByte.Length) b[i] = baseByte[i];
            else b[i] = addByte[i - baseByte.Length];
        }
        return b;
    }
}

DATAクラスはインスタンス生成時の引数(Vector3もしくはbyte配列)に応じてfloat x,y,zの値に変換します。インスタンス生成後、ToVector3(),ToByte()で双方に変換できるようにしています。

Vector3のそれぞれの値(x,y,z)はfloat型なので4byteです。そのためパケットサイズは12Byteになります。パケットの構造は以下の通りです。

今回の送信パケットのデーター構造

※今回は受信側・送信側と1対1での通信で一つのオブジェクトの位置座標共有しかしていないためパケットに識別番号や送信元IPなど情報を含んでいません。なお、TCPなら送信元IP等がなくてもコネクションでわかりますがUDPはそうもいかないため含まないと判別できません。

ソースコード

※なお、今回紹介するソースコードは以前私が作ったマルチプレイ仕様ゲームの一部を切り取り、説明用に編集したものになります。

Main.cs

/* main.cs */
public class main : MonoBehaviour {

    public GameObject gameObject; //動かすオブジェクトをUnity上でアタッチ
    public Text text; //座標表示用

    string ipAddr = "192.168.116.73"; // ホスト側IPアドレス
    string ipAddr2 = "192.168.116.72"; //クライアント側IPアドレス

    Vector3 vector3 = new Vector3(0,0,0);
    UDPSystem udpSystem;
    char device = 'A'; // ホスト側動作はA,クライアント側動作はB

    private void Awake()
    {
        switch (device) {
            case 'A':
                udpSystem = new UDPSystem(null);
                udpSystem.Set(ipAddr, 5001, ipAddr2, 5002);
                break;
            case 'B':
                udpSystem = new UDPSystem((x) => Receive(x));
                udpSystem.Set(ipAddr2, 5002, ipAddr, 5001);
                udpSystem.Receive();
                break;
        }
    }

    // Use this for initialization
    void Start () {
		
	}
	
	// Update is called once per frame
	void Update () {
        if (device == 'A')
        {
            vector3 = gameObject.transform.position;
            DATA sendData = new DATA(vector3);
            udpSystem.Send(sendData.ToByte(),99);
        }
        if (device == 'B')
        {
            gameObject.transform.position = vector3;
        }
        text.text = "(" + vector3.x + "," + vector3.y + "," + vector3.z + ")";
    }

    void Receive(byte[] bytes)
    {
        DATA getData = new DATA(bytes);
        vector3 = getData.ToVector3();
    }
}

UdpSystem.cs

/* UdpSystem.cs - UDPを用いたネットワーク通信プログラム -
 * 
 * Copyright(c) 2018 YounashiP
 * Released under the MIT license.
 * version 0.1.1
 * なお、作成途中のものなので完全な動作をすべてで保障するものではありません。
 */
public class UDPSystem {

    /* Set : 自分と相手のIP、Port、Byte受け取り先関数を指定します
     * Receive() : 受信を開始します
     * Send(byte[]): byteを送信します。 
     * Stop(): WebSocketを閉じます。
     */
    private class IPandPort
    {
        IPandPort(string ipAddr,int port)
        {
            this.ipAddr = ipAddr;
            this.port = port;
        }
        public string ipAddr;
        public int port;
        public UdpClient udpClient;
    }
    readonly int RETRY_SEND_TIME = 10; // ms

    static byte sendTaskCount = 0;
    static List<IPandPort> recList = new List<IPandPort>();

    bool finishFlag = false;
    bool onlyFlag = false;

    int sendHostPort = 6001;
    int sendHostPortRange = 0;

    Action<byte[]> callBack;


    public string recIP , sendIP ;
    public int recPort = 5000, sendPort = 5000;

    UdpClient udpClientSend;
    UdpClient tmpReceiver; //受信終了用TMP

    public UDPSystem(Action<byte[]> callback)
    {
        callBack = callback;
        
    }
    public UDPSystem(string rec_ip, int recport,  string send_ip, int sendport,Action<byte[]> callback, bool onlyflag = false) //オーバーロード 2
    {
        /* rec,send IP == null -> AnyIP */

        recIP = rec_ip;
        sendIP = send_ip;
        recPort = recport;
        sendPort = sendport;
        callBack = callback;
        onlyFlag = onlyflag;
    }

    public void Set(string rec_ip,int recport, string send_ip, int sendport, Action<byte[]> callback = null)
    {
        recIP = rec_ip;
        sendIP = send_ip;
        recPort = recport;
        sendPort = sendport;
        if (callback != null) callBack = callback;
    }
    public void SetSendHostPort(int port,int portRange = 0) //送信用 自己ポート設定
    {
        sendHostPort = port;
        sendHostPortRange = portRange;
    }

    int GetSendHostPort()
    {
        if (sendHostPortRange == 0) return sendHostPort;
        return UnityEngine.Random.Range(sendHostPort, sendHostPort + 1);
    }
    public void Finish() //エラー時チェック項目 : Close()が2度目ではないか
    {
        if (tmpReceiver != null) tmpReceiver.Close();
        else finishFlag = true;
    }
    public void Receive() // ポートの監視を始めます。
    {
        string targetIP = recIP; //受信
        int port = recPort;

        //if (recList.Contains(new IPandPort())) ;

        UdpClient udpClientReceive;

        if(targetIP == null) udpClientReceive = new UdpClient(new IPEndPoint(IPAddress.Any, port));
        else if (targetIP == "") udpClientReceive = new UdpClient(new IPEndPoint(IPAddress.Parse(ScanIPAddr.IP[0]), port));
        else udpClientReceive = new UdpClient(new IPEndPoint(IPAddress.Parse(targetIP), port));

        udpClientReceive.BeginReceive(UDPReceive, udpClientReceive);

        if (targetIP == null) Debug.Log("受信を開始しました。 Any " + IPAddress.Any + " " + port);
        else if (targetIP == "") Debug.Log("受信を開始しました。 Me " + ScanIPAddr.IP[0] + " " + port);
        else Debug.Log("受信を開始しました。" + IPAddress.Parse(targetIP) + " " + port);

        tmpReceiver = udpClientReceive;
    }
  
    void UDPReceive(IAsyncResult res) {// CallBack ポートに着信があると呼ばれます。

        if (finishFlag)
        {
            FinishUDP(res.AsyncState as UdpClient);
            return;
        }

        UdpClient getUdp = (UdpClient) res.AsyncState;
        IPEndPoint ipEnd = null;
        byte[] getByte;

        try
        { //受信成功時アクション
            getByte = getUdp.EndReceive(res, ref ipEnd);
            if(callBack!=null) callBack(getByte);
        }
        catch(SocketException ex)
        {
            Debug.Log("Error" + ex);
            return;
        }
        catch (ObjectDisposedException) // Finish : Socket Closed
        {
            Debug.Log("Socket Already Closed.");
            return;
        }

        if (finishFlag || onlyFlag) {
            FinishUDP(getUdp);
            return;
        }


        Debug.Log("Retry");
        getUdp.BeginReceive(UDPReceive, getUdp); // Retry
        
    }
    private void FinishUDP(UdpClient udp)
    {
        udp.Close();
    }

    public void Send_NonAsync(byte[] sendByte) //同期送信を行います。(未検証&使用不要)
    {
        if(udpClientSend == null) udpClientSend = new UdpClient(new IPEndPoint(IPAddress.Parse(ScanIPAddr.IP[0]), GetSendHostPort()));
        udpClientSend.EnableBroadcast = true;

        try
        {
            udpClientSend.Send(sendByte, sendByte.Length,sendIP,sendPort);
        }
        catch (Exception e)
        {
            Debug.LogError(e.ToString());
        }
    }

    public void Send_NonAsync2(byte[] sendByte) //同期送信を始めます。(2 検証済)
    {
        string targetIP = sendIP;
        int port = sendPort;

        if (udpClientSend == null) udpClientSend = new UdpClient(new IPEndPoint(IPAddress.Parse(ScanIPAddr.IP[0]),GetSendHostPort()));

        udpClientSend.EnableBroadcast = true;
        Socket uSocket = udpClientSend.Client;
        uSocket.SetSocketOption(SocketOptionLevel.Socket,SocketOptionName.Broadcast, 1);

        if (targetIP == null)
        {
            udpClientSend.Send(sendByte, sendByte.Length, new IPEndPoint(IPAddress.Broadcast, sendPort));
            Debug.Log("送信処理しました。" + ScanIPAddr.IP[0] + " > BroadCast " + IPAddress.Broadcast + ":" + sendPort);
        }
        else
        {
            udpClientSend.Send(sendByte, sendByte.Length, new IPEndPoint(IPAddress.Parse(targetIP), sendPort));
            Debug.Log("送信処理しました。" + ScanIPAddr.IP[0] + " > " + IPAddress.Parse(targetIP) + ":" + sendPort);
        }
    }
    public void Send(byte[] sendByte,byte retryCount = 0) //非同期送信をUdpClientで開始します。(通常) <retry>
    {
        string targetIP = sendIP;
        int port = sendPort;

        if (sendTaskCount > 0)//送信中タスクの確認。 送信中有の場合、定数時間後リトライ
        {

            Debug.Log("SendTask is There.["+retryCount);
            retryCount++;

            if (retryCount > 10)
            {
                Debug.LogError("Retry OverFlow.");
                return;
            }

            Timer timer = new Timer(RETRY_SEND_TIME);
            timer.Elapsed += delegate (object obj, ElapsedEventArgs e) { Send(sendByte,retryCount); timer.Stop(); };
            timer.Start();
            return;
        }
        sendTaskCount++; //送信中タスクを増加

        if (udpClientSend == null) ;
        udpClientSend = new UdpClient(new IPEndPoint(IPAddress.Parse(ScanIPAddr.IP[0]), GetSendHostPort()));

        if (targetIP == null)
        {
            udpClientSend.BeginSend(sendByte, sendByte.Length, new IPEndPoint(IPAddress.Broadcast, sendPort),UDPSender,udpClientSend);
            Debug.Log("送信処理しました。" + ScanIPAddr.IP[0] + " > BroadCast " + IPAddress.Broadcast + ":" + sendPort);
        }
        else
        {
            udpClientSend.BeginSend(sendByte, sendByte.Length, sendIP, sendPort, UDPSender, udpClientSend);
            Debug.Log("送信処理しました。" + ScanIPAddr.IP[0] + " > " + IPAddress.Parse(targetIP) + ":" + sendPort + "["+sendByte[0]+"]["+sendByte[1]+"]...");
        }
    }

    void UDPSender(IAsyncResult res)
    {
        UdpClient udp = (UdpClient) res.AsyncState;
        try
        {
            udp.EndSend(res);
            Debug.Log("Send");
        }
        catch (SocketException ex)
        {
            Debug.Log("Error" + ex);
            return;
        }
        catch (ObjectDisposedException) // Finish : Socket Closed
        {
            Debug.Log("Socket Already Closed.");
            return;
        }

        sendTaskCount--;
        udp.Close();

    }
   

}

public class ScanIPAddr
{
    public static string[] IP { get { return Get(); } }
    public static byte[][] ByteIP { get { return GetByte(); } }

    public static string[] Get()
    {
        IPAddress[] addr_arr = Dns.GetHostAddresses(Dns.GetHostName());
        List<string> list = new List<string>();
        foreach (IPAddress address in addr_arr)
        {
            if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
            {
                list.Add(address.ToString());
            }
        }
        if (list.Count == 0) return null;
        return list.ToArray();
    }
    public static byte[][] GetByte()
    {
        IPAddress[] addr_arr = Dns.GetHostAddresses(Dns.GetHostName());
        List<byte[]> list = new List<byte[]>();
        foreach (IPAddress address in addr_arr)
        {
            if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
            {
                list.Add(address.GetAddressBytes());
            }
        }
        if (list.Count == 0) return null;
        return list.ToArray();
    }
}

ライセンスなんて書いちゃってますけどあまり気にしなくて大丈夫です。

ソースファイル説明

UdpSystem.cs の説明です。

Class : UDPSystem()

コントラクタ
UDPSystem(Callback)

インスタンス生成時、コールバック関数を引数としてとります。このコールバックは受信時発生するため送信用として生成するときはnullでも大丈夫です。

関数

Set(hostIP,hostPort,clientIP,clientPort)
自分及び相手のIP、ポートをセットします。
Receive()
受信を開始します。受信するとコールバックが呼ばれます。
Send(byte[])
byte[]を「clientIP:Port」へ送信します。
Send_NonAsync(byte[])
byte[]を同期送信します。※同期送信なので送信中はほかの動作は停止します。
Stop()
WebSocket を閉じます。

Class : ScanIPAddr()

自分のIPアドレス取得用のクラスです。
var myIpAddr = ScanIPAddr.IP[n] といった感じで使います。
nには識別番号を入れます。

~NOTE~
例えばスマートフォンの場合、Wi-fi接続時はキャリア回線(4G/3G)でのIPアドレス・Wi-fi上でのIPアドレスが存在してしまうため選ばなくてはいけません。また、PCの場合仮想PCのネットワークアダプタがあるとそのIPアドレスが存在してしまいます。どちらにおいても優先順位が高いほう(接続中)が0になっているはずなのでデフォルトでは0でいいと思います。状況に合わせて変えてください。

Class : ScanDevice()

特に現段階であまり意味ないです。気にしないでください。

ソースファイル・動作説明

・まず通信を開始するためにUdpClientクラスのインスタンスを生成します。

ホスト側 (ipAddr)
udpSystem = new UDPSystem(null);
udpSystem.Set(ipAddr, 5001, ipAddr2, 5002);

クライアント側(ipAddr2)
udpSystem = new UDPSystem((x) => Receive(x));
udpSystem.Set(ipAddr2, 5002, ipAddr, 5001);
udpSystem.Receive();

クライアント側には受信時の動作としてReceive(byte[])を指定しています。
これで指定ポートでの受信時にReceive(byte[])関数が呼ばれるようになります。(引数は受信したByte配列)

・Update()内で繰り返し行う部分を書き込みます

ホスト側
vector3 = gameObject.transform.position;
DATA sendData = new DATA(vector3);
udpSystem.Send(sendData.ToByte(),99);

ホスト側はゲームオブジェクトの座標を取得・送信を繰り返します。
SendData.ToByte()でVector3の座標情報をByte配列に変換し、その内容をudpSystem.Send()で送信しています。

またSend()で送信する際に引数99をとっているのは理由がありまして、私の作ったSend関数は送信に失敗した場合(送信処理中などで)に10回までコンテニューをするようになっているためにループで呼ぶ際は失敗するとどんどん送信待機中のデータが溜まってしまうので引数に10以上の数値を入れることで失敗しても1度しか送らないようにし、回避しています。この辺は各自でカスタマイズしてください。

クライアント側
gameObject.transform.position = vector3;

vector3に保存されている値をゲームオブジェクトに反映させています。

void Receive(byte[] bytes)
{
DATA getData = new DATA(bytes);
vector3 = getData.ToVector3();
}

クライアント側のみサーバーからデータを受信時この関数が呼び出されます。受信したByte配列のデータを使える形に変換及び読み取り用の変数vector3に代入しています。

共通部分
text.text = “(” + vector3.x + “,” + vector3.y + “,” + vector3.z + “)”;

現在のゲームオブジェクトの位置を数値として画面に表示しているだけです。


ダウンロード

「ソースコード見ただけじゃ分からんわ👼」
「いや、動かんやけど♡」

という方などに向けてプロジェクトやソースコードの配布を行います。
※なお、全ての環境における動作保証はしていません事をご理解ください。

当方が確認した動作環境

・「デスクトップPC」- (有線LAN) – (wi-fi) -「スマホ」
・「ノートPC」- (wi-fi) – 「スマホ」
・ 「デスクトップPC」- (有線LAN) -「ノートパソコン」

での通信ができることを確認しました。なお、スマホは「Android(Xperia-Z5及びX)、iphone 6」を用いて確認しました。

ソースファイル ( main.cs、UdpClient.cs )

(2019/01/18 追記) 一応これでも動きますが変な部分・書き直すべき部分があったので一部書き直しています。いつか新しいソースコードに変えておきます。

Unity プロジェクトファイルのダウンロード (zip形式)


ここまで読んでくれてありがとうございました!
沢山の人でゲーム業界・アプリ業界など盛り上げていけるといいですね!

ホームページでは他にも

・様々な記事や作った作品および過程
・ソースコード、3Dファイル
・あらゆる”モノ”の作り方

などなど随時記事を新規公開・更新していますので是非見ていってくださいね!見ていただけると本当に嬉しいです!

また、「このアプリの作り方を知りたい。この部分どうなってるの?」「身の回りのこんなもの作れるの?」などなどご意見何でも受け付けていますので是非連絡くださいね!