본문 바로가기
💻 programming/unity

유니티에서 소켓 통신하고 이벤트 등록하기 (C# 예제 코드)

by 연구원-A 2021. 7. 30.
반응형

이 페이지는 구글링해서 이것 저것 합친 것을 정리한 것이다

 

내용이 일부 틀릴 수 있으므로 자세한 내용은 추가적으로 구글링하거나 전문 서적을 통해 찾아봐야 한다

특히 유니티는 거의 모르는 상태에서 작성한 것이라 분명 틀린 부분이 있을 것 같다

I. 단순한 소켓 프로그래밍

유니티 엔진을 단독으로 사용할 때에는 Photon과 같은 네트워크 패키지를 이용할 수 있다

 

그러나 게임을 조작하기 위해 다른 플랫폼 (C++ 또는 Python 프로그램)과 연동해야 하는 경우,

유니티 패키지를 그대로 사용할 수 없으므로 직접 소켓 프로그래밍을 작성해야 한다

 

이번에는 소켓 프로그래밍 (= 네트워크 프로그래밍)을 통해 유니티 게임을 조작하는 방법에 대해 알아본다

 

여기에 사용한 소스코드는 일부 설명을 위해 발췌한 것이고, 실제 실행해보려면 아래 github에 업데이트한 코드를 이용해야 한다

https://github.com/taemin-hwang/study-space/tree/master/unity/99_self/01_socket_programming

 

GitHub - taemin-hwang/study-space

Contribute to taemin-hwang/study-space development by creating an account on GitHub.

github.com

I-1. TCP 서버

C/C++이나 Python에서는 다음과 같은 과정을 거쳐 서버 소켓을 활성화한다.

  1. socket( )을 통해 TCP 소켓을 생성
  2. bind( )를 통해 소켓에 포트 번호를 할당
  3. listen( )을 통해 해당 포트가 연결을 받아들이도록 시스템에 알림
  4. accept( )를 통해 각 클라이언트와 통신할 새로운 소켓 획득
  5. 새롭게 생성된 클라이언트 소켓에 send( ), recv( )를 이용해 통신
  6. C#에서는 연결 요청을 수신하고 허용하는 간단한 메서드를 TcpListener 클래스를 이용해 지원하고 있으므로 TcpListener를 이용하면 간단히 TCP 서버를 생성할 수 있다.

TcpListener 클래스 (System.Net.Sockets)

I-1-1. 소스코드 (TCP 서버)

  • Awake()가 실행되면 새로운 Thread를 생성하고 ListenForIncommingRequest 함수 호출
  • ListenForIncommingRequest에서 서버 소켓을 생성하고 연결 요청을 수신
  • do-while 문에서 byte 배열을 수신 (아래 코드에는 TODO로 주석 처리함)
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;

public class Server : MonoBehaviour
{
    #region private members
    private TcpListener tcpListener;
    private Thread tcpListenerThread;
    private TcpClient connectedTcpClient;
    #endregion

    void Awake()
    {
        Debug.Log("Start Server");
        instance = this;

        // Start TcpServer background thread
        tcpListenerThread = new Thread(new ThreadStart(ListenForIncommingRequest));
        tcpListenerThread.IsBackground = true;
        tcpListenerThread.Start();
    }

    // Update is called once per frame
    void Update()
    {

    }

    // Runs in background TcpServerThread; Handles incomming TcpClient requests
    private void ListenForIncommingRequest()
    {
        try
        {
            // Create listener on 192.168.0.2 port 50001
            tcpListener = new TcpListener(IPAddress.Parse("192.168.0.11"), 50001);
            tcpListener.Start();
            Debug.Log("Server is listening");

            while (true)
            {
                using (connectedTcpClient = tcpListener.AcceptTcpClient())
                {
                    // Get a stream object for reading
                    using (NetworkStream stream = connectedTcpClient.GetStream())
                    {
                        // Read incomming stream into byte array.
                        do
                        {
                            // TODO
                        } while (true);
                    }
                }
            }
        }
        catch (SocketException socketException)
        {
            Debug.Log("SocketException " + socketException.ToString());
        }
    }
}

I-2. TCP 클라이언트

일반적인 소켓 클라이언트의 수행 과정은 다음과 같다.

  1. socket( )을 이용하여 TCP 소켓을 생성
  2. connect( )를 이용하여 서버와의 연결을 설정
  3. send( ), recv( )를 이용하여 통신을 수행
  4. close( )를 이용하여 연결을 종료

I-2-1. 소스코드 (TCP 클라이언트)

  • Start() 함수가 호출되면 ConnectToTcpServer() 함수를 호출
  • ConnectToTcpServer()에서 서버 소켓에 연결 요청
  • SendMessage(Byte[] buffer) 함수를 호출하면 byte 배열을 서버에 송신
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;

public class ClientInterface
{
    private TcpClient socketConnection;

    public void Start()
  {
        ConnectToTcpServer();
    }

    private void ConnectToTcpServer()
    {
        try
        {
            socketConnection = new TcpClient("127.0.0.1", 50001);
        }
        catch (Exception e)
        {
            Debug.Log("On client connect exception " + e);
        }
    }

    /// Send message to server using socket connection.     
    private void SendMessage(Byte[] buffer)
    {
        if (socketConnection == null)
        {
            return;
        }
        try
        {
            // Get a stream object for writing.             
            NetworkStream stream = socketConnection.GetStream();
            if (stream.CanWrite)
            {
                // Write byte array to socketConnection stream.                 
                stream.Write(buffer, 0, buffer.Length);
            }
        }
        catch (SocketException socketException)
        {
            Debug.Log("Socket exception: " + socketException);
        }
    }
}

II. byte 배열을 이용한 소켓 프로그래밍

여기서부터는 필요한 사람만 읽으면 될 것 같다 (byte 배열을 만들어서 보내야 하는 사람들)

 

소켓으로 전달되는 값은 byte 배열이다

 

많은 예제에서 "hello world"와 같은 텍스트를 전달하고 있지만

결국 텍스트를 byte 배열로 변환해서 서버 또는 클라이언트 소켓에 전달할 뿐이다

 

아래는 TCP 패킷 구조를 그림으로 그린 것인데,

 

우리가 전송하는 데이터는 맨 마지막 Data에 byte 배열에 저장되고,

헤더 (=Source Port부터 Padding 까지)에는 데이터 전송에 필요한 다양한 정보가 추가되어 전송된다

TCP 패킷 구조 (TCP packet format)

II-1. 객체의 메모리 레이아웃

C/C++에서는 구조체를 만들어서 int (4byte), char (1byte) 변수를 지정해서 바이트 배열을 쉽게 만들어 보낼 수 있었다

C#에서는 객체의 메모리 레이아웃을 이용하면 된다.

 

객체의 메모리 레이아웃에 대하여 - C# 프로그래밍 배우기 (Learn C# Programming)

 메모리 레이아웃이 필요한 이유는 서버와 클라이언트의 아키텍처가 다른 경우를 고려해야 하기 때문이다

서로 다른 아키텍처에서는 다양한 통신 문제가 발생한다

  1. Zero-padding 문제 (pragma pack = 1)
  2. Marshaling 문제 (또는 big endian, little endian 문제)

II-2. 소스코드

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using UnityEngine;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct TouchPacket
{
    [MarshalAs(UnmanagedType.I4)]
    public int typeOfService;
    [MarshalAs(UnmanagedType.I4)]
    public int displayId;
    [MarshalAs(UnmanagedType.I4)]
    public int payloadLength;
    [MarshalAs(UnmanagedType.I4)]
    public int x;
    [MarshalAs(UnmanagedType.I4)]
    public int y;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct DirectionPacket
{
    [MarshalAs(UnmanagedType.I4)]
    public int typeOfService;
    [MarshalAs(UnmanagedType.I4)]
    public int displayId;
    [MarshalAs(UnmanagedType.I4)]
    public int payloadLength;
    [MarshalAs(UnmanagedType.I4)]
    public int direction;
}

public class PacketManager
{
    // Packet to send
    private TouchPacket touchPacket = new TouchPacket();
    private DirectionPacket directionPacket = new DirectionPacket();
    private GazePacket gazePacket = new GazePacket();
    private VoicePacket voicePacket = new VoicePacket();
    private HandSkeletonPacket handSkeletonPacket = new HandSkeletonPacket();

    // Display Id
    private int displayId;

    public PacketManager(int Id)
    {
        displayId = Id;
    }

    public byte[] GetTouchPacket(int x, int y)
    {
        touchPacket.typeOfService = 0;
        touchPacket.displayId = displayId;
        touchPacket.payloadLength = 8;
        touchPacket.x = x;
        touchPacket.y = y;

        return Serialize<TouchPacket>(touchPacket);
    }

    public byte[] GetDirectionPacket(int direction)
    {
        directionPacket.typeOfService = 1;
        directionPacket.displayId = displayId;
        directionPacket.payloadLength = 4;
        directionPacket.direction = direction;

        return Serialize<DirectionPacket>(directionPacket);
    }

        // Calling this method will return a byte array with the contents
    // of the struct ready to be sent via the tcp socket.
    private byte[] Serialize<T>(T packet)
    {
        // allocate a byte array for the struct data
        var buffer = new byte[Marshal.SizeOf(typeof(T))];

        // Allocate a GCHandle and get the array pointer
        var gch = GCHandle.Alloc(buffer, GCHandleType.Pinned);
        var pBuffer = gch.AddrOfPinnedObject();

        // copy data from struct to array and unpin the gc pointer
        Marshal.StructureToPtr(packet, pBuffer, false);
        gch.Free();

        return buffer;
    }
}

III. 이벤트 핸들러

III-1. 데이터 처리 문제

서버에서 클라이언트의 요청을 처리할 때 패킷이 수신되면 특정 함수를 호출하게 하는 것까지는 좋았다

 

이제 함수가 호출되면 오브젝트의 속성을 바꾸려고 생각해보니,

하나의 오브젝트에 연결하는 건 그냥 인스턴스를 전달하면 될 것 같은데

연결할 오브젝트가 많아지면 많아질수록 인스턴스를 추가해야 하니 골치가 아팠다

 

그래서 이벤트 핸들러, 이벤트 리스너를 이용해야겠다는 생각이 들었다

 

c++이었으면 std::function을 받아서 저장했을텐데

아무튼 c#에는 delegate가 있다고 해서 아래처럼 등록하고 쓸 수 있게 해보았다

 

맞는 방법인지는 모르겠다

소스코드

  • 연결할 함수를 delegate로 선언한다
  • SetTouchCallback(CallbackTouch f)처럼 함수를 받아서 실행해야 하는 이벤트에 추가한다
  • 특정 패킷이 수신되면 delegate로 선언한 이벤트를 실행한다 (callbackTouch(x_axis, y_axis);)
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;

public delegate void CallbackTouch(int x, int y);
public delegate void CallbackDirection(int direction);
public delegate void CallbackGaze(int x, int y);
public delegate void CallbackVoice(string message);
public delegate void CallbackHandSkeleton(int x, int y);

public class Server : MonoBehaviour
{
    #region private members
    private TcpListener tcpListener;
    private Thread tcpListenerThread;
    private TcpClient connectedTcpClient;
    #endregion

    private static Server instance = null;
    CallbackTouch callbackTouch;
    CallbackDirection callbackDirection;
    CallbackGaze callbackGaze;
    CallbackVoice callbackVoice;
    CallbackHandSkeleton callbackHandSkeleton;

    void Awake()
    {
        Debug.Log("Start Server");
        instance = this;

        // Start TcpServer background thread
        tcpListenerThread = new Thread(new ThreadStart(ListenForIncommingRequest));
        tcpListenerThread.IsBackground = true;
        tcpListenerThread.Start();
    }

    // Update is called once per frame
    void Update()
    {

    }

    public static Server Instance
    {
        get
        {
            if(instance == null)
            {
                return null;
            }
            return instance;
        }
    }

    public void SetTouchCallback(CallbackTouch callback)
    {
        if(callbackTouch == null)
        {
            callbackTouch = callback;
        } else
        {
            callbackTouch += callback;
        }
    }

    public void SetDirectionCallback(CallbackDirection callback)
    {
        if(callbackDirection == null)
        {
            callbackDirection = callback;
        } else
        {
            callbackDirection += callback;
        }
    }

    public void SetGazeCallback(CallbackGaze callback)
    {
        if(callbackGaze == null)
        {
            callbackGaze = callback;
        } else
        {
            callbackGaze += callback;
        }
    }

    public void SetVoiceCallback(CallbackVoice callback)
    {
        if(callbackVoice == null)
        {
            callbackVoice = callback;
        } else
        {
            callbackVoice += callback;
        }
    }

    public void SetHandSkeletonCallback(CallbackHandSkeleton callback)
    {
        if(callbackHandSkeleton == null)
        {
            callbackHandSkeleton = callback;
        } else
        {
            callbackHandSkeleton += callback;
        }
    }

    // Runs in background TcpServerThread; Handles incomming TcpClient requests
    private void ListenForIncommingRequest()
    {
        try
        {
            // Create listener on 192.168.0.2 port 50001
            tcpListener = new TcpListener(IPAddress.Parse("192.168.0.11"), 50001);
            tcpListener.Start();
            Debug.Log("Server is listening");

            while (true)
            {
                using (connectedTcpClient = tcpListener.AcceptTcpClient())
                {
                    // Get a stream object for reading
                    using (NetworkStream stream = connectedTcpClient.GetStream())
                    {
                        // Read incomming stream into byte array.
                        do
                        {
                            Byte[] bytesTypeOfService = new Byte[4];
                            Byte[] bytesDisplayId = new Byte[4];
                            Byte[] bytesPayloadLength = new Byte[4];

                            int lengthTypeOfService = stream.Read(bytesTypeOfService, 0, 4);
                            int lengthDisplayId = stream.Read(bytesDisplayId, 0, 4);
                            int lengthPayloadLength = stream.Read(bytesPayloadLength, 0, 4);

                            if (lengthTypeOfService <= 0 && lengthDisplayId <= 0 && lengthPayloadLength <= 0)
                            {
                                break;
                            }

                            // Reverse byte order, in case of big endian architecture
                            if (!BitConverter.IsLittleEndian)
                            {
                                Array.Reverse(bytesTypeOfService);
                                Array.Reverse(bytesDisplayId);
                                Array.Reverse(bytesPayloadLength);
                            }

                            int typeOfService = BitConverter.ToInt32(bytesTypeOfService, 0);
                            int displayId = BitConverter.ToInt32(bytesDisplayId, 0);
                            int payloadLength = BitConverter.ToInt32(bytesPayloadLength, 0);

                            if (typeOfService == 3)
                            {
                                payloadLength = 1012;
                            }

                            Byte[] bytes = new Byte[payloadLength];
                            int length = stream.Read(bytes, 0, payloadLength);

                            HandleIncommingRequest(typeOfService, displayId, payloadLength, bytes);
                        } while (true);
                    }
                }
            }
        }
        catch (SocketException socketException)
        {
            Debug.Log("SocketException " + socketException.ToString());
        }
    }

    // Handle incomming request
    private void HandleIncommingRequest(int typeOfService, int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("=========================================");
        Debug.Log("Type of Service : " + typeOfService);
        Debug.Log("Display Id      : " + displayId);
        Debug.Log("Payload Length  : " + payloadLength);
        switch (typeOfService)
        {
            case 0:
                TouchHandler(displayId, payloadLength, bytes);
                break;
            case 1:
                DirectionHander(displayId, payloadLength, bytes);
                break;
            case 2:
                GazeHandler(displayId, payloadLength, bytes);
                break;
            case 3:
                VoiceHandler(displayId, payloadLength, bytes);
                break;
            case 4:
                BodySkeletonHandler(displayId, payloadLength, bytes);
                break;
            case 5:
                HandSkeletonHandler(displayId, payloadLength, bytes);
                break;
        }
    }

    // Handle Touch Signal
    private void TouchHandler(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Touch Handler");
        int x_axis = BitConverter.ToInt32(bytes, 0);
        int y_axis = BitConverter.ToInt32(bytes, 4);
        Debug.Log("X axis     : " + x_axis);
        Debug.Log("Y axis     : " + y_axis);
        if(callbackTouch != null)
        {
            callbackTouch(x_axis, y_axis);
        }
    }

    // Handle Direction Signal
    private void DirectionHander(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Direction Handler");
        int direction = BitConverter.ToInt32(bytes, 0);
        Debug.Log("Direction  : " + direction);
        if(callbackDirection != null)
        {
            callbackDirection(direction);
        }

    }

    // Handle Gaze Signal
    private void GazeHandler(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Gaze Handler");
        int x_axis = BitConverter.ToInt32(bytes, 0);
        int y_axis = BitConverter.ToInt32(bytes, 4);
        Debug.Log("X axis     : " + x_axis);
        Debug.Log("Y axis     : " + y_axis);
        if(callbackGaze != null)
        {
            callbackGaze(x_axis, y_axis);
        }
    }

    // Handle Voice Signal
    private void VoiceHandler(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Voice Handler");
        string str = Encoding.Default.GetString(bytes);
        Debug.Log("Text       : " + str);
        if(callbackVoice != null)
        {
            callbackVoice(str);
        }
    }

    // Handle Body Skeleton Signal
    private void BodySkeletonHandler(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Body Skeleton Handler");
        // TODO
    }

    // Handle Hand Skeleton Signal
    private void HandSkeletonHandler(int displayId, int payloadLength, byte[] bytes)
    {
        Debug.Log("Execute Hand Skeleton Handler");
        int x_axis = BitConverter.ToInt32(bytes, 0);
        int y_axis = BitConverter.ToInt32(bytes, 4);
        Debug.Log("X axis     : " + x_axis);
        Debug.Log("Y axis     : " + y_axis);
        if(callbackHandSkeleton != null)
        {
            callbackHandSkeleton(x_axis, y_axis);
        }
    }
}

IV. 결과

IV-1. 서버

  • 소켓을 생성하고 클라이언트 요청을 대기한다
  • 클라이언트로부터 TCP 데이터를 수신하고 데이터를 파싱한다
  • 파싱한 방향 지시 결과에 따라 공을 움직인다

IV-2. 클라이언트

  • 서버 연결을 시도한다
  • 키보드 입력 (화살표 전,후,좌,우)에 따라 패킷을 생성하고 서버에 전달한다

⭐ 이 글이 다소 복잡하게 작성된 것 같아서 아래에 UDP 소켓 통신에 관한 내용을 추가했다

2023.01.13 - [programming] - 유니티 UDP 소켓 통신 구현하기 - 예제 프로젝트 소개 <1>

2023.01.13 - [programming] - 유니티 UDP 소켓 통신 구현하기 - 유니티 UDP 서버 C# 예제 코드 <2>

2023.01.13 - [programming] - 유니티 UDP 소켓 통신 구현하기 - 파이썬 UDP 클라이언트 예제 코드 <3>

2023.01.13 - [programming] - 유니티 UDP 소켓 통신 구현하기 - C++ UDP 클라이언트 예제 코드 <4>

반응형

댓글