Unity2DMulti

2026/03/11 슬라이드로 보기위한 내용 수정

개요

유니티 엔진을 이용한 멀티플레이 구현 프로젝트

  • 소켓통신을 활용해 네트워크 라이브러리를 만들어보고, 게임에 적용해 게임에서 네트워크 통신을 어떻게 적용하는지 학습하기 위한 프로젝트입니다.
  • 게임 클라이언트 유니티 엔진을 통해 제작하였으며, 외부 에셋 없이 최소한으로만 구현하였습니다.
  • 네트워크 라이브러리는 C#의 Net.Sockets 라이브러리를 이용해 만들었으며, 간편한 데이터 직렬화를 위해 Protobuf를 같이 사용했습니다.

기술 스택

C#, SocketProgramming, protobuf, Unity

깃허브 저장소

https://github.com/crusthack/Unity2DMulti

주요 구현

1. 로그인 기능

  • 이름만을 가지고 로그인 시도. 이미 서버에 같은 이름으로 로그인 되어있는 유저가 존재하면 거부.
    데이터 저장(DB연동, 파일시스템) 없습니다

2. 게임 방 열기 / 참여

  • 호스트 유저가 게임 방 제목, 비밀번호 설정. 다른 유저는 게임 방 목록을 받고서 참여.

3. 채팅 기능

  • 방 안의 유저들끼리 채팅으로 소통.

4. 게임 동기화

  • 호스트 유저가 각 유저에게 상태를 전파하면서 게임 상태를 동기화.
  • 게임 방에 참가한 유저는 rpc를 통해 이동, 스킬 시스템 사용

1. 네트워크 라이브러리(C# Socket, Protobuf) 만들기

  • 단순히 이번 프로젝트만을 위한 라이브러리가 아닌, 이전 소켓프로그래밍 경험을 토대로 유연한 라이브러리를 제작하고자 하였습니다.
  • 기본적인 메시지 클래스를 설계한 후, 실제 프로젝트에서는 Protobuf 메시지를 송수신 할 수 있도록 ProtobufMessage 클래스를 구현하였습니다.

1. 메시지 설계

  • 소켓통신을 통해 주고받을 메시지를 설계합니다.
  • 소켓통신을 수행하는 클래스 측에서 데이터를 파싱해 메시지를 생성할 수 있도록 기본 클래스 BaseMessage와 IMessageParser<T> 인터페이스를 만들었습니다.

BaseMessage

  • 기본적인 소켓통신에서 수신한 바이트 스트림을 곧바로 문자열로 출력하는 상황을 가정한 구현입니다.

주요 메서드

  • public virtual Int32 Serialize(Span<byte> buffer) 메시지를 바이트 스트림으로 직렬화해서 반환합니다.
  • public virtual Int32 Serialize(Span<byte> buffer) 메시지를 직렬화 했을 때 바이트 크기를 반환합니다. **IMessageParser<T>
  • static abstract int Parse(byte[] data, int size, out T? message) 바이트 스트림을 받아 파싱을 성공한다면, message를 내보내고 파싱에 사용한 바이트 수를 반환합니다. 음수(-1) 반환시 파싱 실패입니다.
  • static abstract Int32 GetMaxSize() 메시지의 최대 크기를 받습니다. 버퍼 크기 설정을 위해 사용합니다.

코드

c#
using System.Text;
 
namespace NetworkController.Message
{
    public class BaseMessage : IMessageParser<BaseMessage>
    {
        string Payload;
 
        public BaseMessage(string payload = "")
        {
            Payload = payload;
        }
 
        public virtual Int32 GetSize()
        {
            return Encoding.UTF8.GetByteCount(Payload);
        }
 
        public static Int32 GetMaxSize()
        {
            return 1024;
        }
 
        public virtual byte[] Serialize()
        {
            return Encoding.UTF8.GetBytes(Payload);
        }
        public virtual Int32 Serialize(Span<byte> buffer)
        {
            if(buffer.Length < Encoding.UTF8.GetByteCount(Payload))
            {
                throw new Exception("Buffer size is smaller than payload size.");
            }
 
            return Encoding.UTF8.GetBytes(Payload, buffer);
        }
 
        public static int Parse(byte[] data, Int32 size, out BaseMessage? message)
        {
            string m = Encoding.UTF8.GetString(data, 0, size);
            message = new BaseMessage(m);
 
            return size;
        }
    }
 
    public interface IMessageParser<T>
    {
        static abstract int Parse(byte[] data, int size, out T? message);
 
        static abstract Int32 GetMaxSize();
    }
 
    public interface IMessageHeader
    {
        static Int32 HeaderSize { get; }
        abstract Int32 Serialize(Span<byte> buffer);
        static abstract int Parse(byte[] buffer, int Size, out IMessageHeader? header);
    }
}
 

ProtobufMessage

  • BaseMessage와 IMessageParser를 상속받아 인터페이스를 제공합니다.
  • Protobuf 메시지를 주고받을 수 있도록 감싸는 래퍼 클래스입니다. 20바이트의 고정 길이 헤더를 사용합니다.
  • 여러개의 Protobuf 메시지를 사용하기 위해 OpCode 값을 사용해 메시지 종류를 표시합니다. 추가적인 Protobuf메시지를 등록하고자 하면, OpCode 추가 및 하단의 ProtobufParserRegistry에 메시지를 등록하면 됩니다.
  • Header의 직렬화코드는 직접 구현이 돼있고 Payload의 protobuf 메시지는 해당 protobuf message가 제공하는 메소드를 사용합니다.

코드

c#
using Google.Protobuf;
using System.Buffers.Binary;
using Protos;
 
namespace NetworkController.Message
{
    public class ProtobufMessage : BaseMessage, IMessageParser<ProtobufMessage>
    {
        public enum OpCode : Int32
        {
            System = 1,
            Chatting = 2,
        }
 
        public ProtobufMessageHeader Header;
        public Google.Protobuf.IMessage Payload;
 
        public ProtobufMessage(Google.Protobuf.IMessage payload, OpCode opCode)
        {
            Header = new ProtobufMessageHeader(payload.CalculateSize(), (Int32)opCode);
            Payload = payload;
            if (GetSize() > GetMaxSize())
            {
                throw new ArgumentException("Payload size exceeds maximum allowed size.");
            }
        }
        public override Int32 GetSize()
        {
            return ProtobufMessageHeader.HeaderSize + Payload.CalculateSize();
        }
        public new static Int32 GetMaxSize()
        {
            return 1024;
        }
 
        public override byte[] Serialize()
        {
            var headerSize = ProtobufMessageHeader.HeaderSize;
            var payloadSize = Payload.CalculateSize();
            byte[] buffer = new byte[headerSize + Payload.CalculateSize()];
 
            var offset = Header.Serialize(buffer);
            Payload.WriteTo(buffer.AsSpan<byte>(offset, payloadSize));
 
            return buffer;
        }
 
        public override Int32 Serialize(Span<byte> buffer)
        {
            Header.Serialize(buffer);
            Payload.WriteTo(buffer.Slice(ProtobufMessageHeader.HeaderSize, Payload.CalculateSize()));
 
            return GetSize();
        }
 
        public static int Parse(byte[] data, int size, out ProtobufMessage? msg)
        {
            var offset = ProtobufMessageHeader.Parse(data, size, out var header);
            if (offset == -1)
            {
                msg = null;
                return -1;
            }
            if (offset == 0 || header == null)
            {
                msg = null;
                return 0;
            }
 
            var messageHeader = header as ProtobufMessageHeader;
            if (size < ProtobufMessageHeader.HeaderSize + messageHeader!.PayloadSize)
            {
                msg = null;
                return 0;
            }
 
            var payload = ProtobufParserRegistry.Parse(
                messageHeader.OpCode, new ReadOnlySpan<byte>(data, ProtobufMessageHeader.HeaderSize, messageHeader.PayloadSize));
            msg = new ProtobufMessage(payload, (OpCode)messageHeader.OpCode);
 
            return ProtobufMessageHeader.HeaderSize + messageHeader.PayloadSize;
        }
    }
 
    public class ProtobufMessageHeader : IMessageHeader
    {
        public static Int32 HeaderSize => 20;
        public Int32 PayloadSize;       // 4
        public Int32 OpCode;            // 4
        private Int64 _TimeStamp;       // 8
        public Int64 Timestamp => _TimeStamp;
        static public Int32 CheckKey = 0x2026; // 4
 
        public ProtobufMessageHeader(Int32 payloadSize, Int32 opCode)
        {
            PayloadSize = payloadSize;
            OpCode = opCode;
            _TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        }
        ProtobufMessageHeader(Int32 p, Int32 o, Int64 t, Int32 c)
        {
            PayloadSize = p;
            OpCode = o;
            _TimeStamp = t;
            CheckKey = c;
        }
        public int Serialize(Span<byte> buffer)
        {
            if (buffer.Length < HeaderSize)
                throw new ArgumentException("Buffer too small", nameof(buffer));
 
            BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(0, 4), PayloadSize);
            BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(4, 4), OpCode);
            BinaryPrimitives.WriteInt64LittleEndian(buffer.Slice(8, 8), _TimeStamp);
            BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(16, 4), CheckKey);
 
            return HeaderSize;
        }
        static public int Parse(byte[] buffer, int size, out IMessageHeader? header)
        {
            if (size < HeaderSize)
            {
                header = null;
                return 0;
            }
 
            BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 0, 4), out Int32 payloadSize);
            BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 4, 4), out Int32 opCode);
            BinaryPrimitives.TryReadInt64LittleEndian(new ReadOnlySpan<byte>(buffer, 8, 8), out Int64 timeStamp);
            BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 16, 4), out Int32 checkKey);
            header = new ProtobufMessageHeader(payloadSize, opCode, timeStamp, checkKey);
            if (checkKey != CheckKey)
            {
                return -1;
            }
 
            return HeaderSize;
        }
    }
 
    static class ProtobufParserRegistry
    {
        static readonly Dictionary<Int32, MessageParser> Parsers = new()
        {
            { (Int32)ProtobufMessage.OpCode.System, SystemMessage.Parser},
            { (Int32)ProtobufMessage.OpCode.Chatting, ChattingMessage.Parser },
        };
 
        public static IMessage Parse(Int32 opcode, ReadOnlySpan<byte> payload)
        {
            return Parsers[opcode].ParseFrom(payload.ToArray());
        }
    }
}

Protobuf Message 예시

proto
syntax = "proto3";
 
option csharp_namespace = "Protos";
 
message SystemMessage {
  oneof payload {
    LoginRequest login_request = 1;
    LoginResponse login_response = 2;
    Heartbeat heartbeat = 3;
  }
}
 
message LoginRequest {
  string user_name = 1;
}
 
message LoginResponse {
  bool success = 1;
  string user_name = 2;
  string message = 3;
}
 
message Heartbeat {
  int64 timestamp = 1;
}
// chattingmessage
syntax = "proto3";
 
option csharp_namespace = "Protos";
 
message ChattingMessage{
	string username = 1;
	string Message = 2;
};

2. NetworkController

설계 의도

  • 클라이언트측과 서버측에서 같은 클래스를 사용하면 편할 것 같아 각각의 용도로 동작할 수 있도록 구현하였습니다. 크게 효능?은 없는거 같긴 합니다.
  • 서버 연결, 클라이언트 접속 수락 등과 같은 소켓 연결관리를 주로 수행하고, 직접적인 소켓통신(Recv, Send)은 SocketContext에서 처리합니다.
  • SocketContext가 메시지를 파싱 성공하면 NetworkController가 등록한 이벤트를 호출하여 NetworkCon의 메시지큐(ConcurrentBlockingCollection)에 메시지를 추가합니다.
  • 서버측에서는 세션번호를 통해 접속된 클라이언트를 구분합니다. 0부터 시작해 1씩 증가합니다.

주요 메서드

클라이언트

  • void Connect(IPAddress ip, UInt16 port): 해당 주소로 소켓 연결을 시도합니다.
  • void Disconnect(): 연결을 해제합니다.
  • void SendMessage(T message): 메시지를 송신합니다.

서버

  • void OpenServer(IPEndPoint endPoint): 파라미터의 endPoint로 소켓을 열어 클라이언트들의 접속을 받기 시작합니다.
  • void CloseServer(): 서버를 닫습니다.
  • void SendMessageTo(UInt32 sessionID, T message): 특정 세션번호를 향해 메시지를 송신합니다. 만약에 해당 세션이 죽었으면 에러를 일으킵니다.
  • event Action<SocketContext<T>>? OnConnect: 클라이언트가 접속했을 때 호출하는 이벤트입니다. 서버측에서 클라이언트 접속에 따른 처리를 하기 위해 제공합니다.
  • event Action<SocketContext<T>>? OnDisconnect: 접속이 끊겼을 때 호출하는 이벤트입니다. 클라이언트 측에서도 서버와의 접속이 끊겼을 때 대응하기 위해 사용할 수 있습니다.

일반

  • void SetReceiveBufferSize(int size): SocketContext에서 사용할 수신 버퍼 사이즈를 설정합니다. 기본값은 메시지 최대 크기의 *10입니다. 수신 버퍼 사이즈를 변경하면 내부적으로 SocketContext에서 사용하는 버퍼 크기도 변경해줘야 하는데 구현되지 않았습니다.
  • bool IsMessageAvailable(): 수신 메시지 큐에 메시지가 존재하는지 확인합니다.
  • T GetMessage(out SocketContext<T> context, CancellationToken cancellationToken = default):
    메시지큐에서 메시지 하나를 동기적으로 받습니다. 서버측에서 어떤 클라이언트로부터 수신된 메시지인지 구분하기 위해 context를 추가적으로 받습니다.

코드

c#
using NetworkController.Message;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
 
namespace NetworkController
{
    public class NetworkController<T> where T : BaseMessage, IMessageParser<T>
    {
        enum NetworkState { None, Client, Server }
        NetworkState State = NetworkState.None;
        Socket _Socket;
        Int32 BufferSize = T.GetMaxSize() * 10;
 
        BlockingCollection<Tuple<SocketContext<T>, T>> ReceiveMessageQueue = new();
 
        SocketContext<T>? ClientContext;
        ConcurrentStack<SocketContext<T>> ContextQueue = new();
        ConcurrentDictionary<UInt32, SocketContext<T>> ConnectedContext = new();
 
        public event Action<SocketContext<T>>? OnConnect;
        public event Action<SocketContext<T>>? OnDisconnect;
 
        UInt32 NextSessionID = 0;
 
        public NetworkController()
        {
            _Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        }
 
        public void SetReceiveBufferSize(int size)
        {
            BufferSize = size;
        }
 
        public void Connect(IPAddress ip, UInt16 port)
        {
            if (State != NetworkState.None)
            {
                throw new Exception("Connect(): NetworkController is already running.");
            }
            State = NetworkState.Client;
            _Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            _Socket.Connect(new IPEndPoint(ip, port));
 
            ClientContext = GetSocketContext();
 
            ClientContext.Reset(NextSessionID++, _Socket, ip, port);
 
            ClientContext.StartReceive();
        }
 
        public bool IsConnected()
        {
            if (State != NetworkState.Client)
            {
                return false;
            }
 
            return true;
        }
 
        SocketContext<T> GetSocketContext()
        {
            if (ContextQueue.TryPop(out var context))
            {
                return context;
            }
 
            var c = new SocketContext<T>(BufferSize);
            c.OnReceiveMessage += (context, message) =>
            {
                ReceiveMessageQueue.Add(new Tuple<SocketContext<T>, T>(context, message));
            };
 
            c.OnDisconnect += (context) =>
            {
                SocketDisconnected(context);
            };
 
            return c;
        }
 
        public void Disconnect()
        {
            if (State != NetworkState.Client)
            {
                throw new Exception("Disconnect(): NetworkController is not connected as a client.");
            }
            ClientContext!.Disconnect();
            State = NetworkState.None;
        }
 
        public void Disconnect(UInt32 sessionID)
        {
            if (State != NetworkState.Server)
            {
                throw new Exception("Disconnect(sessionID): NetworkController is not running as a server.");
            }
 
            if (ConnectedContext.TryGetValue(sessionID, out var context))
            {
                context.Disconnect();
            }
            else
            {
                throw new Exception($"Disconnect(sessionID): SessionID {sessionID} not found.");
            }
        }
 
        public void OpenServer(IPEndPoint endPoint)
        {
            _Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            _Socket.Bind(endPoint);
            _Socket.Listen();
 
            var args = new SocketAsyncEventArgs();
            args.Completed += (s, e) =>
            {
                CompletedAccept(s, e);
            };
 
            State = NetworkState.Server;
 
            StartAccept(args);
        }
 
        public void CloseServer()
        {
            if (!State.Equals(NetworkState.Server))
            {
                throw new Exception("CloseServer(): NetworkController is not running as a server.");
            }
            _Socket.Close();
 
            foreach (var context in ConnectedContext.Values)
            {
                context.Disconnect();
            }
 
            State = NetworkState.None;
        }
 
        public T GetMessage(CancellationToken cancellationToken = default)
        {
            return ReceiveMessageQueue.Take(cancellationToken).Item2;
        }
 
        public T GetMessage(out SocketContext<T> context, CancellationToken cancellationToken = default)
        {
            var tuple = ReceiveMessageQueue.Take(cancellationToken);
            context = tuple.Item1;
            return tuple.Item2;
        }
 
        public bool IsMessageAvailable()
        {
            return !ReceiveMessageQueue.IsCompleted && ReceiveMessageQueue.Count > 0;
        }
 
        public void SendMessage(T message)
        {
            if (!State.Equals(NetworkState.Client))
            {
                throw new Exception("SendMessage: NetworkController is not connected as a client.");
            }
            ClientContext!.SendMessage(message);
        }
 
        public void SendMessageTo(UInt32 sessionID, T message)
        {
            if (!State.Equals(NetworkState.Server))
            {
                throw new Exception("SendMessageTo: NetworkController is not running as a server.");
            }
            if (ConnectedContext.TryGetValue(sessionID, out var context))
            {
                context.SendMessage(message);
            }
            else
            {
                throw new Exception($"SendMessageTo: SessionID {sessionID} not found.");
            }
        }
 
        void StartAccept(SocketAsyncEventArgs e)
        {
            e.AcceptSocket = null;
            if (!_Socket.AcceptAsync(e))
            {
                CompletedAccept(this, e);
            }
        }
 
        void CompletedAccept(object? sender, SocketAsyncEventArgs e)
        {
            var clientSocket = e.AcceptSocket;
            var remoteEndPoint = clientSocket!.RemoteEndPoint as IPEndPoint;
            if (remoteEndPoint == null)
            {
                return;
            }
            var context = GetSocketContext();
            while (ConnectedContext.TryGetValue(NextSessionID, out var existingContext))
            {
                NextSessionID++;
            }
            ConnectedContext[NextSessionID] = context;
            context.Reset(NextSessionID++, clientSocket, remoteEndPoint!.Address, (UInt16)remoteEndPoint.Port);
            context.StartReceive();
 
            OnConnect?.Invoke(context);
 
            StartAccept(e);
        }
 
        void SocketDisconnected(SocketContext<T> context)
        {
            ConnectedContext.TryRemove(context.SessionID, out var _);
            OnDisconnect?.Invoke(context);
            ContextQueue.Push(context);
 
            if (State == NetworkState.Client)
            {
                Disconnect();
            }
        }
    }
}

3. SocketContext

  • 연결된 소켓과의 데이터 송수신을 처리합니다.
  • 수신 동작시 바이트스트림을 버퍼에 옮겨담으면서 파싱을 통해 메시지를 뽑아냅니다.
  • 송신시 메시지가 순차적으로 송신될 수 있도록 조절합니다.
  • 송수신 버퍼 크기는 NetworkController측에서, 메시지 하나의 최대크기 * 10으로 초기화합니다.
  • 버퍼의 뒷 부분에 남은 공간이 메시지 T가 제공하는 MaxMessageSize보다 적을 시 버퍼 내용을 맨 앞으로 옮깁니다.

주요 메서드

  • public void Reset(UInt32 sessionID, Socket socket, IPAddress ip, UInt16 port):
    소켓 컨텍스트를 새로 초기화합니다.
  • public void StartReceive(): 할당된 소켓으로부터 수신을 처리하기 시작합니다.
  • public void SendMessage(T message): 할당된 소켓에 메시지를 송신합니다. 내부적으로 송신 큐에 메시지를 큐잉하고서 차레대로 송신합니다.
  • public void Disconnect(): 연결을 해제합니다.
  • public event Action<SocketContext<T>, T>? OnReceiveMessage: 수신한 바이트스트림을 파싱성공하여 메시지를 완성하면 해당 이벤트를 호출합니다.
  • public event Action<SocketContext<T>>? OnDisconnect: 소켓 연결이 끊겼을 때 해당 이벤트를 호출합니다.

코드

c#
namespace NetworkController
{
    public class SocketContext<T> where T : BaseMessage, IMessageParser<T>
    {
        Socket? Socket = null;
        SocketAsyncEventArgs SendArgs;
        SocketAsyncEventArgs RecvArgs;
 
        public UInt32 SessionID;
        public IPAddress? RemoteAddress = null;
        public UInt16 RemotePort = 0;
 
        byte[] RecvBuf;
        int RecvBufOffset = 0;
        int RecvBufDataSize = 0;
 
        byte[] SendBuf;
        int SendBufDataSize = 0;
        bool IsSending;
        bool IsConnected = false;
        ConcurrentQueue<T> SendMessageQueue = new();
        T? MessageForSend;
 
        public event Action<SocketContext<T>, T>? OnReceiveMessage;
        public event Action<SocketContext<T>>? OnDisconnect;
 
        public SocketContext(Int32 bufferSize)
        {
            RecvBuf = new byte[bufferSize];
            SendBuf = new byte[bufferSize];
            SendArgs = new();
            RecvArgs = new();
 
            SendArgs.UserToken = this;
            SendArgs.Completed += (s, e) =>
            {
                this.CompletedSend(e.BytesTransferred);
            };
            RecvArgs.UserToken = this;
            RecvArgs.Completed += (s, e) =>
            {
                this.CompletedReceive(e.BytesTransferred);
            };
 
            SendArgs.SetBuffer(SendBuf, 0, 0);
            RecvArgs.SetBuffer(RecvBuf, 0, RecvBuf.Length);
        }
 
        public void Reset(UInt32 sessionID, Socket socket, IPAddress ip, UInt16 port)
        {
            if (Interlocked.CompareExchange(ref IsConnected, true, false) == true)
            {
                throw new Exception("Already Connected");
            }
            SessionID = sessionID;
            Socket = socket;
            RemoteAddress = ip;
            RemotePort = port;
 
            RecvBufOffset = 0;
            RecvBufDataSize = 0;
            SendBufDataSize = 0;
        }
 
        public void Disconnect()
        {
            if (Interlocked.CompareExchange(ref IsConnected, false, true) == false)
            {
                return;
            }
            Socket!.Shutdown(SocketShutdown.Both);
            Socket!.Close();
            OnDisconnect?.Invoke(this);
        }
 
        public void StartReceive()
        {
            RecvArgs.SetBuffer(RecvBuf, RecvBufOffset, RecvBuf.Length - (RecvBufOffset + RecvBufDataSize));
 
            if (!Socket!.ReceiveAsync(RecvArgs))
            {
                CompletedReceive(RecvArgs.BytesTransferred);
            }
        }
 
        void CompletedReceive(int bytesTransferred)
        {
            while (true)
            {
                if (bytesTransferred <= 0)
                {
                    Disconnect();
                    return;
                }
 
                RecvBufDataSize += bytesTransferred;
 
                while (true)
                {
                    int parsed = T.Parse(RecvBuf, RecvBufDataSize, out T? message);
 
                    if (parsed < 0)
                    {
                        Console.ForegroundColor = ConsoleColor.Red;
                        Console.WriteLine("Wrong data received...");
                        Console.ForegroundColor = ConsoleColor.White;
 
                        Disconnect();
                        return;
                    }
 
                    if (message == null)
                    {
                        break;
                    }
 
                    OnReceiveMessage?.Invoke(this, message);
 
                    RecvBufOffset += parsed;
                    RecvBufDataSize -= parsed;
                }
 
                // 수신한 데이터가 모두 파싱되어 비어있으면 offset 자동으로 0으로 옮겨줌 
                if (RecvBufDataSize == 0)
                {
                    RecvBufOffset = 0;
                }
                // 버퍼에 메시지 하나를 수신할 공간이 부족하면 현재 유효 데이터를 앞으로 옮기고 오프셋 이동
                else if (RecvBuf.Length - (RecvBufOffset + RecvBufDataSize) < T.GetMaxSize())
                {
                    Console.WriteLine($"{RecvBuf.Length}, {RecvBufOffset}, {RecvBufDataSize}");
                    Buffer.BlockCopy(RecvBuf, RecvBufOffset, RecvBuf, 0, RecvBufDataSize);
                    RecvBufOffset = RecvBufDataSize;
                }
 
                RecvArgs.SetBuffer(
                    RecvBufOffset + RecvBufDataSize,
                    RecvBuf.Length - (RecvBufOffset + RecvBufDataSize)
                );
 
                if (Socket!.ReceiveAsync(RecvArgs))
                    return;
 
                bytesTransferred = RecvArgs.BytesTransferred;
            }
        }
 
        public void SendMessage(T message)
        {
            SendMessageQueue.Enqueue(message);
 
            if (Interlocked.CompareExchange(ref IsSending, true, false) == false)
            {
                TrySend();
            }
        }
 
        void TrySend()
        {
            while (true)
            {
                if (MessageForSend != null && MessageForSend.GetSize() < SendBuf.Length - SendBufDataSize)
                {
                    MessageForSend.Serialize(SendBuf.AsSpan(SendBufDataSize));
                    SendBufDataSize += MessageForSend.GetSize();
                    MessageForSend = null;
                }
                if (MessageForSend == null)
                {
                    while (SendMessageQueue.TryDequeue(out var msg))
                    {
                        int size = msg.GetSize();
                        if (size > SendBuf.Length - SendBufDataSize)
                        {
                            MessageForSend = msg;
                            break;
                        }
 
                        msg.Serialize(SendBuf.AsSpan(SendBufDataSize));
                        SendBufDataSize += size;
                    }
                }
 
                if (SendBufDataSize == 0)
                {
                    Interlocked.Exchange(ref IsSending, false);
 
                    if (!SendMessageQueue.IsEmpty && Interlocked.CompareExchange(ref IsSending, true, false) == false)
                    {
                        continue;
                    }
                    return;
                }
 
                SendArgs.SetBuffer(SendBuf, 0, SendBufDataSize);
 
                bool pending = Socket!.SendAsync(SendArgs);
                if (pending)
                {
 
                    return;
                }
 
                // 송신이 즉시 완료되면 계속 루프 돌면서 송신 
                SendBufDataSize -= SendArgs.BytesTransferred;
 
                if (SendBufDataSize > 0)
                {
                    Buffer.BlockCopy(SendBuf, SendArgs.BytesTransferred, SendBuf, 0, SendBufDataSize);
                }
            }
        }
 
        void CompletedSend(int bytesTransferred)
        {
            SendBufDataSize -= bytesTransferred;
 
            if (SendBufDataSize > 0)
            {
                Buffer.BlockCopy(SendBuf, bytesTransferred, SendBuf, 0, SendBufDataSize);
            }
 
            TrySend();
        }
    }
}

2. 네트워크 라이브러리를 활용한 채팅 서버

  • 네트워크 라이브러리가 잘 작동하는지 확인하기 위한 간단한 채팅 프로그램을 만들어봅니다.

주요 동작

  • 로그인: 이름을 입력해 로그인을 합니다. 서버에 동일한 이름으로 이미 로그인된 유저가 존재할 경우 거부됩니다.
  • 채팅: 로그인된 유저가 채팅 메시지를 보냈을 경우, 해당 채팅을 로그인된 유저들에게 전파합니다. 로그인 되지 않은 유저가 보냈을 경우 무시됩니다.
  • 하트비트: 클라이언트가 1초마다 서버에 하트비트 메시지를 보냅니다. 서버는 접속된 클라이언트들을 확인해 근 5초간에 하트비트가 도착하지 않은 클라이언트와의 연결을 해제합니다.

클라이언트

  • NetworkController을 통해 서버에 연결하고 각종 메시지를 송신하는 간단한 예제 프로그램입니다.
  • 시작시에 계속 돌아가는 Task 2개를 생성합니다. 하나는 Netcon으로부터 메시지를 받아 처리를하고(단순 출력), 하나는 주기적으로 하트비트 메시지를 송신합니다.

클라이언트 코드

c#
namespace GameClientConsole
{
    internal class Program
    {
 
        static void Main(string[] args)
        {
            Console.InputEncoding = System.Text.Encoding.UTF8;
            Console.OutputEncoding = System.Text.Encoding.UTF8;
 
            NetworkController<ProtobufMessage> Netcon = new NetworkController<ProtobufMessage>();
            Netcon.OnDisconnect += (context) =>
            {
                Console.WriteLine($"Server disconnected");
            };
 
            var chat = new Protos.ChattingMessage
            {
                Message = "hello, world!!"
            };
 
            _ = Task.Run(() =>
            {
                while (true)
                {
                    var message = Netcon.GetMessage();
                    if (message.Payload is Protos.ChattingMessage chatMessage)
                    {
                        Console.WriteLine($"[{chatMessage.Username}]: {chatMessage.Message}");
                    }
                    else if (message.Payload is Protos.SystemMessage)
                    {
 
                    }
                }
            });
 
            bool sendHeartbeat = false;
            _ = Task.Run(() =>
            {
                while (true)
                {
                    Thread.Sleep(1000);
                    if (sendHeartbeat)
                    {
                        Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
                        {
                            Heartbeat = new Protos.Heartbeat
                            {
                                Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
                            }
                        }
                        , ProtobufMessage.OpCode.System));
                    }
                }
            });
 
            PrintCommand();
            while (true)
            {
                var input = Console.ReadLine();
                chat.Message = String.IsNullOrEmpty(input) ? "blank" : input;
 
                var message = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
                switch (input)
                {
                    case "test":
                        for (int i = 0; i < 1000; ++i)
                        {
                            Netcon.SendMessage(message);
                            continue;
                        }
                        break;
                    case "quit":
                        sendHeartbeat = false;
                        Netcon.Disconnect();
                        break;
                    case "connect":
                        Netcon.Connect(IPAddress.Parse("127.0.0.1"), 5000);
                        sendHeartbeat = true;
                        break;
                    case "system":
                        Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
                        {
                            Heartbeat = new Protos.Heartbeat
                            {
                                Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
                            }
                        }
                        , ProtobufMessage.OpCode.System));
                        break;
                    case "login":
                        Console.Write("Enter username: ");
                        var username = Console.ReadLine();
                        Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
                        {
                            LoginRequest = new Protos.LoginRequest
                            {
                                UserName = string.IsNullOrEmpty(username) ? "Guest" : username
                            }
                        },
                        ProtobufMessage.OpCode.System));
                        break;
                    case "h":
                        if (sendHeartbeat)
                        {
                            Console.WriteLine("Stop sending heartbeat.");
                            sendHeartbeat = false;
                        }
                        else
                        {
                            Console.WriteLine("Start sending heartbeat.");
                            sendHeartbeat = true;
                        }
                        break;
 
                    default:
                        if (Netcon.IsConnected())
                        {
                            Netcon.SendMessage(message);
                        }
                        else
                        {
                            PrintCommand();
                        }
                        break;
                }
            }
        }
 
        static void PrintCommand()
        {
            Console.WriteLine("Client Command List: connect, system, test, login, h, quit");
        }
    }
}

입력 명령어에 따른 동작

  • connect: 서버에 접속합니다.
  • quit: 서버와의 연결을 해제합니다.
  • system: 하트비트 메시지(시스템 메시지)를 보냅니다.
  • h: 하트비트 플래그를 끄고 켭니다. 하트비트 메시지를 보내지 않을 시 자동으로 연결이 끊기는 것을 볼 수 있습니다.
  • login: 추가적으로 이름을 입력해 로그인 요청 메시지를 보냅니다.
  • test: 서버한테 메시지를 막 날립니다.
  • default: 서버와의 연결이 수립돼있으면 채팅 메시지를 보냅니다.

채팅 메시지 수신 시 송신자의 이름과 채팅 내용을 콘솔에 출력합니다.

서버측 구현(Main함수) 및 주요 동작 흐름 설명

구현 의도

  • 학습용 프로젝트이므로 단순하게 구현하였습니다. Server는 단순히 Start(Run), Stop 두 가지 동작만 수행합니다.
  • 서버가 동작하면, 자동으로 NetworkController(이하 NetCon)을 이용하여 서버를 열고, 메시지를 하나씩 받아서 동작합니다.
  • OnConnected, OnDisconnect 이벤트를 등록하여 연결 동작에 대응합니다. GetMessage를 통해 동기적으로 NetworkController에서 메시지를 하나씩 받아옵니다.
  • 서버는 연결을 NetCon이 제공하는 SessionID로 연결을 구분합니다. 또한 서버측에서도 자체적으로 세션에 대한 상태를 관리하기 위해 ClientSession이라는 클래스를 두었습니다.
  • 수신한 메시지는 메시지 분배기(MessageProcessor)가 각 메시지 핸들러(SystemMessageHandler, ChattingMessageHandler)에 메시지를 분배합니다.
  • 각 핸들러는 최종적으로 다시 서버 객체를 참조해 서버 객체가 지니고 있는 서비스들의 메서드를 사용합니다. 서버다이어그램

Main 함수

c#
static async Task Main(string[] args)
{
    Console.InputEncoding = System.Text.Encoding.UTF8;
    Console.OutputEncoding = System.Text.Encoding.UTF8;
 
    Server server = new();
    bool running = true;
 
    PrintCommand();
    while (running)
    {
        var input = Console.ReadLine();
        if (string.IsNullOrEmpty(input))
        {
            continue;
        }
        input.ToLower();
 
        switch (input)
        {
            case "start":
                server.Start();
                break;
            case "stop":
                await server.Stop();
                break;
            case "quit":
                running = false;
                break;
            default:
                PrintCommand();
                break;
        }
    }
 
    await server.Stop();
}
 
static void PrintCommand()
{
    Console.WriteLine("Server Command List: start, stop, quit");
}

Server 클래스

  • 소켓으로 접속만 된 클라이언트들을 관리하기 위해 NetCon에 OnConnected, OnDisconnect 이벤트를 붙여 관리합니다.
  • 서버가 시작하면 바로 Netcon으로부터 메시지를 받아 MessageProcessor로 보내는 Task와 Heartbeat Task를 시작합니다.
  • Hearbeat Task는 최근 5초간 메시지 수신이 없던 클라이언트와의 연결을 해제합니다. 클라이언트측에서는 1초마다 Hearbeat를 보내도록 돼있습니다. Hearbeat 메시지를 받으면 자동으로 LastActiveTime을 갱신해줍니다.
  • 여러가지 Service들을 소유하고 있습니다. MessageHandler가 이 서버 객체의 Service들에 임의로 접근해서 사용을 합니다.

Server 코드

c#
namespace GameServer
{
    internal class Server
    {
        NetworkController<ProtobufMessage> Netcon;
        Task? ServerTask;
        Task HeartbeatTask;
        int CheckInterval = 5000; // 5sec
        bool IsRunning = false;
        CancellationTokenSource token = new();
 
        MessageProcessor.MessageProcessor Processor;
        ConcurrentDictionary<UInt32, ClientSession> Clients = new();
 
        public event Action<ClientSession>? OnConnect;
        public event Action<ClientSession>? OnDisconnect;
 
        public LoginService LoginService;
        public ChattingService ChattingService;
 
        public Server()
        {
            Netcon = new NetworkController<ProtobufMessage>();
            Netcon.OnConnect += (context) =>
            {
                OnConnected(context);
            };
 
            Netcon.OnDisconnect += (context) =>
            {
                OnDisconnected(context);
            };
            Processor = new(this);
 
            LoginService = new(this);
            ChattingService = new(this);
 
            HeartbeatTask = new Task(async () =>
            {
                Console.WriteLine("Checking heartbeat");
                while (!token.IsCancellationRequested)
                {
                    await Task.Delay(CheckInterval);
                    CheckHeartbeat();
                }
                Console.WriteLine("Heartbeat task terminated");
            });
        }
 
        public void Start()
        {
            Netcon.OpenServer(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 5000));
            Console.WriteLine($"Server start running, IP: 127.0.0.1, Port: 5000");
            IsRunning = true;
            token = new();
            HeartbeatTask.RunSynchronously();
            ServerTask = Task.Run(() =>
            {
                Console.WriteLine("Server start receiving message");
                try
                {
                    while (!token.Token.IsCancellationRequested)
                    {
                        var message = Netcon.GetMessage(out var context, token.Token);
                        if (TryGetSession(context.SessionID, out var session))
                        {
                            if (session == null)
                            {
                                continue;
                            }
                            Processor.HandleMessage(session, message);
                        }
                    }
                }
                catch (OperationCanceledException)
                {
                    Console.WriteLine("Server task terminaterd");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"ServerTask error: {ex}");
                }
                Console.WriteLine("Server work terminated");
            });
        }
 
        public async Task Stop()
        {
            if (!IsRunning)
            {
                return;
            }
 
            Netcon.CloseServer();
            IsRunning = false;
            token.Cancel();
            await ServerTask!;
        }
 
        void OnConnected(SocketContext<ProtobufMessage> context)
        {
            if (Clients.TryGetValue(context.SessionID, out var session))
            {
                Console.WriteLine("Server.OnConnected: ???");
                context.Disconnect();
                return;
            }
 
 
            var newSession = new ClientSession(context.SessionID);
            if (!Clients.TryAdd(newSession.SessionID, newSession))
            {
                Console.WriteLine("Server.OnConnected: !!!");
            }
 
            Console.WriteLine($"New client connected. SessionID:{context.SessionID}, EndPoint: {context.RemoteAddress}:{context.RemotePort}");
 
            OnConnect?.Invoke(newSession);
        }
 
        void OnDisconnected(SocketContext<ProtobufMessage> context)
        {
            if (!Clients.TryGetValue(context.SessionID, out var session))
            {
                Console.WriteLine("Server.OnDisconnected: ???");
                context.Disconnect();
                return;
            }
 
            OnDisconnect?.Invoke(session);
 
            Clients.TryRemove(context.SessionID, out var sesion);
 
            Console.WriteLine($"Client disconnected. SessionID:{context.SessionID}, EndPoint: {context.RemoteAddress}:{context.RemotePort}");
        }
 
        void CheckHeartbeat()
        {
            var copy = Clients.Values.ToArray();
            var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
            foreach (var c in copy)
            {
                if (c.LastActiveTime + CheckInterval < currentTime)
                {
                    Console.WriteLine($"SessionID: {c.SessionID}{(c.IsAuthenticated ? " " + c.UserName : "")} has no response long time. Disconnect");
                    Netcon.Disconnect(c.SessionID);
                }
            }
        }
 
        public bool TryGetSession(UInt32 sessionID, out ClientSession? session)
        {
            var ret = Clients.TryGetValue(sessionID, out session);
            return ret;
        }
 
        public void SendMessage(ClientSession session, ProtobufMessage message)
        {
            Netcon.SendMessageTo(session.SessionID, message);
        }
    }
}

ClientSession

  • 연결된 클라이언트와의 세션을 관리하기 위한 클래스입니다. 단순하게 UserName이 ""이 아니면 로그인된 상태(Authenticated)라고 봅니다.
  • 최근 메시지 수신 시간(LastActiveTime)을 저장해 Heartbeat를 관리합니다.

Session 코드

c#
internal class ClientSession
{
    public UInt32 SessionID;
    public string UserName = "";
    public bool IsAuthenticated => !string.IsNullOrEmpty(UserName);
    public Int64 LastActiveTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
 
    public ClientSession(UInt32 sessionID)
    {
        SessionID = sessionID;
    }
}

MessageProcessor(Handler) 클래스

  • Server가 받은 메시지가 MessageProcessor, MessageProcessor가 각 메시지에 대응되는 핸들러에게 분배합니다.
  • 핸들러는 메시지를 받아서 Server의 서비스를 사용합니다.

Procesor 코드

c#
namespace GameServer.MessageProcessor
{
    internal class MessageProcessor
    {
        Server Server;
        SystemMessageHandler SystemHandler;
        ChattingMessageHandler ChattingHandler;
        public MessageProcessor(Server server) 
        {
            Server = server;
            SystemHandler = new SystemMessageHandler(server);
            ChattingHandler = new ChattingMessageHandler(server);
        }
 
        public void HandleMessage(ClientSession session, ProtobufMessage message)
        {
            if (message != null)
            {
                session.LastActiveTime = message.Header.Timestamp;
                switch ((ProtobufMessage.OpCode)message.Header.OpCode)
                {
                    case ProtobufMessage.OpCode.Chatting:
                        ChattingHandler.HandleMessage(session, message);
                        break;
                    case ProtobufMessage.OpCode.System:
                        SystemHandler.HandlerMessage(session, message);
                        break;
                }
            }
        }
    }
}

핸들러 예시) SystemMessageHandler

c#
using NetworkController.Message;
using Protos;
using System;
using System.Collections.Generic;
using System.Text;
 
namespace GameServer.MessageProcessor
{
    internal class SystemMessageHandler
    {
        Server Server;
 
        public SystemMessageHandler(Server server)
        {
            Server = server;
        }
 
        public void HandlerMessage(ClientSession session, ProtobufMessage message)
        {
            if (message.Payload is not SystemMessage msg)
            {
                Console.WriteLine("Invalid ChattingMessage payload");
                return;
            }
 
            switch (msg!.PayloadCase)
            {
                case SystemMessage.PayloadOneofCase.LoginRequest:
                    HandleLogin(session, msg.LoginRequest);
                    break;
 
                case SystemMessage.PayloadOneofCase.Heartbeat:
                    HandleHeartbeat();
                    break;
            }
        }
 
 
        void HandleLogin(ClientSession session, LoginRequest loginRequest)
        {
            Server.LoginService.Login(session, loginRequest);
        }
 
        void HandleHeartbeat()
        {
        }
    }
}

설명

  • MessageProcessor가 메시지 종류에 알맞은 MessageHandler로 메시지를 전달합니다.
  • 각 MessageHandler는 내부적으로 메시지에 알맞은 동작을 처리하고 Server 객체가 지니고 있는 Service들에 접근해 알맞은 서비스들을 사용합니다.
  • 현재 동작구현 자체는 매우 날 것으로 돼있는 상태입니다.

Service

  • 서버의 핵심 기능을 담당합니다. 간단한 채팅서버를 위해 현재 로그인 서비스와 채팅 서비스 둘 만 구현 돼 있습니다.

LoginService

c#
using Protos;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
 
namespace GameServer.Service
{
    internal class LoginService
    {
        Server Server;
        ConcurrentDictionary<string, ClientSession> LoginedClients = new();
 
        public LoginService(Server server)
        {
            Server = server;
            Server.OnDisconnect += (session) =>
            {
                Logout(session);
            };
        }
 
        public bool Login(ClientSession session, LoginRequest requestMessage)
        {
            var username = requestMessage.UserName;
 
            if (LoginedClients.ContainsKey(username))
            {
                return false;
            }
            session.UserName = username;
 
            Console.WriteLine($"User {username} logged in.");
 
            return LoginedClients.TryAdd(username, session);
        }
 
        public void Logout(ClientSession session)
        {
            if (!session.IsAuthenticated)
            {
                return;
            }
            LoginedClients.TryRemove(session.UserName, out var _);
            Console.WriteLine($"User {session.UserName} logged out.");
            session.UserName = string.Empty;
        }
 
        public IReadOnlyCollection<ClientSession> GetLoggedInSessionsSnapshot()
        {
            return LoginedClients.Values.ToArray();
        }
    }
}
  • 처음 생성시에 Server에 연결해제 이벤트를 등록하여 클라이언트가 연결해제시 자동으로 로그아웃이 됩니다.
  • 현재 로그인된 유저들에 대해 검사하여 중복 로그인(중복 닉네임)을 검사해 로그인 허용/거부를 합니다.
  • 현재 로그인 되어있는 유저 목록을 리턴하는 함수가 있습니다.

ChattingService

c#
using NetworkController.Message;
using Protos;
 
namespace GameServer.Service
{
    internal class ChattingService
    {
        Server Owner;
        public ChattingService(Server owner)
        {
            Owner = owner;
        }
 
        public void ProcessChatting(ClientSession session, ChattingMessage message)
        {
            var targets = Owner.LoginService.GetLoggedInSessionsSnapshot();
            var chat = new ChattingMessage
            {
                Username = session.UserName,
                Message = message.Message
            };
            var msg = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
 
            foreach (var target in targets)
            {
                Owner.SendMessage(target, msg);
            }
        }
    }
}
  • 채팅 메시지를 보낸 사람이 로그인 된 사람이라면 해당 메시지를 전체 로그인된 유저들에게 전파합니다.

3. 게임 클라이언트(싱글플레이) 제작

게임 설명

  1. 단순하게 캐릭터를 상하좌우로 움직이며 자동 스폰되는 적들을 처치해 점수를 쌓는 게임
  2. 스킬시스템 존재. 패시브 스킬, 액티브 스킬 2개 스킬
  3. 플레이어 캐릭터는 죽지 않는 대신 적과 피격시 해당 적 소멸 및 점수 감소
  4. 싱글플레이는 로컬에 파일로 데이터를 저장합니다. (키보드 x키 누르고, 타이틀 이동시 저장)

캐릭터 선택창

선택창사진

  • 캐릭터는 원 스프라이트로 표현했습니다.
  • A 캐릭터는 캐릭터 전면(이동방향(원이라서 앞뒤구분이 잘 안 됨))에 하얀색 블럭을 일시적으로 소환한 뒤 해당 블럭에 닿는 적들에게 데미지를 줍니다. 패시브 스킬은 일시적으로 적 처치시 얻는 점수가 2배 증가하며 자동으로 발동됩니다.
  • B 캐릭터는 액티브 스킬로 일시적으로 이동속도가 증가하는 스킬이 있으며, 패시브 스킬로 주변 적에게 자동으로 검정색 블럭을 생성해 날립니다.
  • 게임 상황은 x버튼 누르고 메인메뉴 이동 시 저장되며, 마지막에 어느 맵, 어느 위치에서 종료했는지(Map_A / Map_B 및 좌표 저장)와 점수를 저장합니다.

게임 플레이 사진

사진a

게임플레이사진1

사진b

게임플레이사진1

  • 중앙의 원이 플레이어 캐릭터입니다. wasd를 이용해 상하좌우 이동을 합니다.
  • 빨강색 세모가 적 캐릭터입니다. 맵에 자동으로 생성되고 내부적으로 풀링을 활용해 관리합니다.
  • 갈색 테두리가 맵의 경계선이며 회색 타일은 장애물(바위)입니다.
  • 스킬 사용시 스킬이미지에 회색 쿨타임 바가 돌며, DOubleScoredlsk SpeedUp과 같은 버프형 스킬은 활성된 시간 동안에 위에 노란색 스크롤이 생깁니다.
  • 하얀색 캡슐을 통해 맵을 오갈 수 있습니다.(위의 진한 녹색 맵이 맵B, 아래 사진의 연한 녹색 맵이 맵A입니다)
  • 조작키: wasd(이동), k / q(액티브 스킬 사용), x (게임 정지)

4. 게임 멀티플레이 구현

  • 기존 .Net 10으로 작성한 네트워크 라이브러리를 유니티에서 사용할 수 있기 위해 VisualStudio 내장 코파일럿을 통해 .Net standard 2.0으로 수정하였습니다.
  • 게임 서버는 위에서 만든 채팅서버를 그대로 사용해 service나 messageHandler만 추가하였습니다.

0. 네트워크 라이브러리 dll 유니티 프로젝트에 추가

  • c# 라이브러리 프로젝트 빌드 산출물 dll을 유니티 에디터 화면에 끌어다놓으면 자동으로 dll이 추가되어 유니티 프로젝트에서 사용할 수 있습니다.
  • google.Protobuf dll과 제 네트워크 라이브러리 dll을 유니티 프로젝트에 추가하였습니다.

1. NetworkManager 오브젝트

c#
public class NetworkManager : MonoBehaviour
{
    private NetworkController<ProtobufMessage> Netcon;
 
    public int HeartbeatInterval = 1000; // milliseconds
    private float lastHeartbeatTime;
 
    public event Action<ProtobufMessage> OnMessageRecv;
 
    private void OnEnable()
    {
        Netcon = new NetworkController<ProtobufMessage>();
        lastHeartbeatTime = Time.time;
        Netcon.OnDisconnect += Disconnect;
    }
 
    public void Login(string userName)
    {
        if (!Netcon.IsConnected())
        {
            Netcon.Connect(IPAddress.Parse("127.0.0.1"), 5000);
        }
 
        Netcon.SendMessage(new ProtobufMessage(
            new SystemMessage
            {
                LoginRequest = new LoginRequest
                {
                    UserName = userName
                }
            },
            ProtobufMessage.OpCode.System)
        );
    }
 
    private void Update()
    {
        if (Netcon.IsConnected())
        {
            if (Netcon.IsMessageAvailable())
            {
                var message = Netcon.GetMessage();
                if (message == null)
                {
                    return;
                }
                OnMessageRecv.Invoke(message);
            }
        }
 
        HandleHeartbeat();
    }
 
    private void HandleHeartbeat()
    {
        if (HeartbeatInterval <= 0)
            return;
 
        if (!Netcon.IsConnected())
            return;
 
        float intervalSeconds = HeartbeatInterval / 1000f;
 
        if (Time.time - lastHeartbeatTime >= intervalSeconds)
        {
            SendHeartbeat();
            lastHeartbeatTime = Time.time;
        }
    }
 
    private void SendHeartbeat()
    {
        SendMessage(new ProtobufMessage(
            new SystemMessage
            {
                Heartbeat = new Heartbeat
                {
                    Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
                }
            },
            ProtobufMessage.OpCode.System)
        );
    }
 
    private void OnDestroy()
    {
        if (Netcon != null && Netcon.IsConnected())
        {
            Netcon.Disconnect();
        }
    }
 
    public void SendChat(string text)
    {
        var chat = new ChattingMessage
        {
            Message = text
        };
        var message = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
 
        SendMessage(message);
    }
 
    public void SendMessage(ProtobufMessage message)
    {
        if (!Netcon.IsConnected())
        {
            return;
        }
 
        Netcon.SendMessage(message);
    }
 
    public void Disconnect()
    {
        if (Netcon.IsConnected())
        {
            Netcon.Disconnect();
        }
    }
 
    public void Disconnect(SocketContext<ProtobufMessage> context)
    {
        Debug.Log("Server disconnected");
        Disconnect();
    }
}

설명

  • 전역객체 GameManager에 붙어 네트워크 통신을 관리하는 네트워크매니저 객체입니다.
  • 매 업데이트 마다 네트워크컨트롤러로부터 수신한 메시지를 확인한 뒤, OnMessageRecv를 구독한 이벤트들에 메시지를 전달합니다.
  • 하트비트 주기(1초)마다 하트비트 메시지를 서버에 보냅니다.
  • 다른 오브젝트들이 서버에 ProtobufMessage를 보낼 수 있도록 SendMessage 메서드가 있습니다. (일부는 SendChat와 같이 미리 준비돼있음)

2. 로그인 및 채팅기능

  • 위에서 만든 채팅 시스템을 그대로 사용하였습니다. 로그인은 메인메뉴에서 가능하고, 만약에 로그인이 돼 있다면 싱글플레이 씬에서 전체채팅을 주고받을 수 있습니다.
  • 로그인 및 전체채팅이 정상적으로 작동함을 확인했습니다. 채팅 콘솔 프로그램과 유니티 프로젝트 내 채팅이 서로 잘 보이는 것을 확인했습니다. 메인메뉴로그인 채팅

3. 게임 룸 시스템

설계

  • 호스트 유저가 게임 방을 파면 다른 유저들이 해당 방에 접속해 플레이를 합니다.
  • 게임 방은 방 제목, 호스트 유저 이름으로 구분됩니다. 참여 유저들에 대한 정보를 저장하기 위해 접속자 정보(string[])도 갖고있습니다.

RoomMessage protobuf 메시지

proto
syntax = "proto3";
 
option csharp_namespace = "Protos";
 
message RoomMessage{
	oneof Payload{
		RoomList room_list = 1;
		CreateRoom create_room = 2;
		JoinRoom join_room = 3;
	}
}
 
message RoomList{
	repeated RoomInfo Rooms = 1;
}
 
message RoomInfo{
	string RoomName = 1;
	string OwnerName = 2;
	repeated string Players = 3;
};
 
message CreateRoom{
	string RoomName = 1;
};
 
message JoinRoom{
	string RoomName = 1;
	string OwnerName = 2;
};
  • 방 메시지는 3가지 종류로 나뉩니다. 방 리스트 정보 메시지, 방 생성 메시지, 방 참가 메시지
  • 클라이언트가 서버로 빈 방 리스트 메시지를 보내면 get 요청입니다. 서버는 현재 존재하는 게임 방 정보들을 리스트 메시지로 보냅니다.
  • 클라이언트가 방 생성을 하면 CreateRoom 메시지를 서버로 보냅니다. 서버는 해당 메시지를 처리한 후 방 생성이 됐으면 정보 그대로 CreateRoom 메시지를 클라이언트로 돌려줍니다. 클라이언트는 방 생성이 잘 됐다고 판단하고 게임에 돌입합니다.
  • 클라이언트가 받은 게임 방 리스트를 통해 특정 방에 Join 메시지를 보내면 해당 호스트의 게임에 접속을 합니다. 방 리스트 방 생성

4. 게임 참가 및 동기화

시나리오

게임 참여

  • 게임 방을 만든 호스트 유저가 중심이 되어 게임 로직을 처리합니다. 호스트유저는 게임 서버 측에서 알고있는 세션번호를 읽고 유저를 구분합니다.
  • 게임에 참가한 유저(클라이언트)가 호스트 유저에 SyncMessage를 보냅니다. 호스트유저는 이 SyncMessage와 해당 클라이언트의 세션번호를 통해 현재 자신의 게임에 해당 유저가 생성되어있지 않으면, Players 딕셔너리(Disctionary<int, Player>)에 추가합니다. (클라가 호스트유저에게 보내는 SyncMessage를 게임 참여 메시지로 간주합니다)
  • 호스트가 참여 처리가 된 클라이언트에 RPC 메시지를 보내서 해당 클라이언트가 참여 처리가 됐다고, SetPlayerID를 보냅니다. (세션번호와 같은 값으로 Set)
  • PayerID가 -1인 클라이언트는 게임 참여처리가 아직 안 된 클라이언트이고, RPC 메시지를 받아 PlayerID가 설정된 클라이언트는 자신이 게임참여가 처리가됐다고 판단합니다.

게임중

  • 호스트가 1초마다 전체 플레이어의 상태를 모든 접속 유저들에게 전파합니다.
  • 클라이언트 유저는 해당 메시지를 받아 게임을 동기화합니다.
  • 클라이언트 유저가 move나 Attack(스킬사용)을 하면 해당 rpc 메시지를 서버로 보냅니다.
  • 호스트유저가 해당 rpc메시지를 받으면 처리를 한 뒤 모든 클라이언트들에게 rpc메시지를 전파합니다.

GameMessage

c#
syntax = "proto3";
 
option csharp_namespace = "Protos";
 
message GameMessage{
	int32 session_iD = 1;
	string user_name = 2;
	bool do_broadcast = 3;
	oneof Payload{
		SyncMessage game_sync = 4;
		RPC rpc = 5;
	}
};
 
message SyncMessage{
	int32 player_id = 1;
	int32 prefab_id = 2;
	int32 position_x = 3;
	int32 position_y = 4;
	int32 move_x = 5;
	int32 move_y= 6;
	string current_map = 7;
	string user_name = 8;
	int32 score = 9;
};
 
message RPC{
	int32 player_id = 1;
	string rpc_name = 2;
	repeated string values = 3;
};

GameNetworkController, 클라이언트측 게임 동기화 코드

c#
public class GameNetworkCon : MonoBehaviour
{
    public GameObject scoreboard;
    public List<GameObject> scores;
 
    int playerID = -1; // hostUser = 0, unauthorized = -1
    ConcurrentDictionary<int, GameObject> Players = new();    // key = sessionid
 
    public float SyncInterval = 1;
    private float lastSyncTime = 0;
 
    public SceneController sceneController;
 
 
    void Awake()
    {
        GameManager.Instance.NetworkCon = this;
        GameManager.Instance.NetworkManager.OnMessageRecv += HandleMessage;
    }
 
    private void OnDestroy()
    {
        GameManager.Instance.NetworkManager.OnMessageRecv -= HandleMessage;
    }
 
    void Start()
    {
        if (!GameManager.Instance.Session.IsMulti)
            return;
 
        scoreboard.SetActive(true);
 
        Debug.Log(GameManager.Instance.Session.IsHost ? "This is host" : "This is guest");
    }
 
    void Update()
    {
        if (!GameManager.Instance.Session.IsMulti)
        {
            return;
        }
 
        UpdateScoreboard();
        SendSyncMessage();
        CheckVisibility();
    }
 
    void CheckVisibility()
    {
        var localPlayer = GameManager.Instance.GamePlayer?.GetComponent<Player>();
        if (localPlayer == null) return;
 
        foreach (var obj in Players.Values)
        {
            if (obj == null) continue;
 
            var p = obj.GetComponent<Player>();
            if (p == null) continue;
 
            bool shouldActive = p.CurrentMap == localPlayer.CurrentMap;
            if (obj.activeSelf != shouldActive)
                obj.SetActive(shouldActive);
        }
    }
 
    void UpdateScoreboard()
    {
        if (scores == null || scores.Count == 0)
            return;
 
        List<Player> players = new();
 
        foreach (var obj in Players.Values)
        {
            if (obj == null) continue;
            var p = obj.GetComponent<Player>();
            if (p != null)
                players.Add(p);
        }
 
        var localPlayer = GameManager.Instance.GamePlayer?.GetComponent<Player>();
        if (localPlayer != null)
            players.Add(localPlayer);
 
        players.Sort((a, b) => b.Score.CompareTo(a.Score));
 
        foreach (var s in scores)
            if (s != null) s.SetActive(false);
 
        for (int i = 0; i < players.Count && i < scores.Count; i++)
        {
            if (scores[i] == null) continue;
 
            scores[i].transform.GetChild(0)
                .GetComponent<TMP_Text>().text =
                $"{players[i].UserName}: {players[i].Score}";
 
            scores[i].SetActive(true);
        }
    }
 
    void SendSyncMessage()
    {
        if (Time.time - lastSyncTime < SyncInterval)
            return;
 
        lastSyncTime = Time.time;
 
        // 자신 게임의 상태를 송신
        var s = GameManager.Instance.GamePlayer.GetComponent<Player>().GetSyncInfo();
        s.PlayerId = playerID;
        var g = new GameMessage
        {
            DoBroadcast = GameManager.Instance.Session.IsHost,
            GameSync = s
        };
        var message = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
        GameManager.Instance.NetworkManager.SendMessage(message);
 
        // 호스트 유저라면 자기 컴퓨터에 있는 모든 유저들의 정보를 전파
        if (GameManager.Instance.Session.IsHost)
        {
            foreach (var (i, p) in Players)
            {
                var info = p.GetComponent<Player>().GetSyncInfo();
                info.PlayerId = i;
 
                var gameMessage = new GameMessage
                {
                    DoBroadcast = true,
                    GameSync = info
                };
 
                var msg = new ProtobufMessage(gameMessage, ProtobufMessage.OpCode.Game);
                GameManager.Instance.NetworkManager.SendMessage(msg);
                Debug.Log(gameMessage.ToString());
            }
        }
    }
 
    void HandleMessage(ProtobufMessage message)
    {
        switch ((ProtobufMessage.OpCode)message.Header.OpCode)
        {
            case ProtobufMessage.OpCode.Game:
                HandleGamemessage(message.Payload as GameMessage);
                break;
        }
    }
 
    void HandleGamemessage(GameMessage message)
    {
        switch (message.PayloadCase)
        {
            case GameMessage.PayloadOneofCase.GameSync:
                if (GameManager.Instance.Session.IsHost)
                {
                    HostPlayerSyncGame(message);
                }
                else
                {
                    SyncGame(message);
                }
                break;
            case GameMessage.PayloadOneofCase.Rpc:
                HandleRPC(message.Rpc);
                break;
        }
    }
 
    // 호스트 플레이어 전용 
    void HostPlayerSyncGame(GameMessage msg)
    {
        var message = msg.GameSync;
 
        if (Players.TryGetValue(msg.SessionID, out var p))
        {
        }
        else
        {
            var newPlayer = sceneController.SpawnPlayer(message);
            newPlayer.GetComponent<Player>().UserName = message.UserName;
 
            newPlayer.GetComponent<Player>().Sync(message);
            Players.TryAdd(msg.SessionID, newPlayer);
            Debug.Log("New player: " + message.UserName + "join to game");
            InitClient(msg);
        }
    }
 
    int InitClient(GameMessage msg)
    {
        var g = new GameMessage
        {
            SessionID = msg.SessionID,
            DoBroadcast = false,
            Rpc = new RPC
            {
                PlayerId = msg.SessionID,
                RpcName = "SetPlayerID",
            }
        };
 
        Debug.Log(msg.SessionID + " New Client try To Join   " + g.Rpc.PlayerId);
        Debug.Log($"Send Set Playr ID RPC to {g.SessionID}");
        var m = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
        GameManager.Instance.NetworkManager.SendMessage(m);
        return g.Rpc.PlayerId;
    }
 
    void SyncGame(GameMessage msg)
    {
        var message = msg.GameSync;
 
        // 아직 호스트 유저로부터 번호 부여 안 받은 상태
        if (playerID == -1)
        {
            return;
        }
 
        if(playerID == message.PlayerId)
        {
            GameManager.Instance.GamePlayer.GetComponent<Player>().Sync(message);
            return;
        }
 
        if (Players.TryGetValue(message.PlayerId, out var p))
        {
            p.GetComponent<Player>().Sync(message);
        }
        else
        {
            var newPlayer = sceneController.SpawnPlayer(message);
            newPlayer.GetComponent<Player>().Sync(message);
            newPlayer.GetComponent<Player>().UserName = message.UserName + "(" + message.PlayerId.ToString() + ")";
            Players.TryAdd(message.PlayerId, newPlayer);
            Debug.Log("New player: " + message.UserName + "join to game");
        }
    }
 
    void HandleRPC(RPC message)
    {
        if (message.PlayerId == playerID)
        {
            return;
        }
 
        switch (message.RpcName)
        {
            case "SetPlayerID":
                {
                    playerID = message.PlayerId;
                    GameManager.Instance.GamePlayer.GetComponent<Player>().UserName += $"({message.PlayerId})";
                }
                Debug.Log("Set Player ID: " + playerID);
                break;
            case "Move":
                ProcessRPCMove(message);
                break;
            case "Attack":
                ProcessRPCAttack(message);
                break;
        }
    }
 
    public void RPC_Move(InputValue value)
    {
        if (!GameManager.Instance.Session.IsMulti)
        {
            return;
        }
 
        var x = value.Get<Vector2>().x;
        var y = value.Get<Vector2>().y;
 
        var g = new GameMessage
        {
            Rpc = new RPC
            {
                PlayerId = playerID,
                RpcName = "Move",
                Values = { x.ToString(), y.ToString() }
            }
        };
 
        if (GameManager.Instance.Session.IsHost)
        {
            g.DoBroadcast = true;
        }
 
        var m = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
        GameManager.Instance.NetworkManager.SendMessage(m);
    }
 
    void ProcessRPCMove(RPC message)
    {
        if (!Players.TryGetValue(message.PlayerId, out var p))
            return;
 
        if (!float.TryParse(message.Values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
            return;
 
        if (!float.TryParse(message.Values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
            return;
 
        p.GetComponent<Player>().MovDir = new Vector2(x, y);
 
        if (GameManager.Instance.Session.IsHost)
        {
            GameManager.Instance.NetworkManager.SendMessage(
                new ProtobufMessage(new GameMessage
                {
                    DoBroadcast = true,
                    Rpc = message
                }, ProtobufMessage.OpCode.Game));
        }
    }
 
    void ProcessRPCAttack(RPC message)
    {
        if (!Players.TryGetValue(message.PlayerId, out var p))
            return;
 
        p.GetComponent<Player>().Attack();
 
        if (GameManager.Instance.Session.IsHost)
        {
            GameManager.Instance.NetworkManager.SendMessage(
                new ProtobufMessage(new GameMessage
                {
                    DoBroadcast = true,
                    Rpc = message
                }, ProtobufMessage.OpCode.Game));
        }
    }
 
    public void RPC_Attack()
    {
        if (!GameManager.Instance.Session.IsMulti)
            return;
 
        var g = new GameMessage
        {
            Rpc = new RPC
            {
                PlayerId = playerID,
                RpcName = "Attack",
            },
            DoBroadcast = GameManager.Instance.Session.IsHost
        };
 
        GameManager.Instance.NetworkManager.SendMessage(
            new ProtobufMessage(g, ProtobufMessage.OpCode.Game));
    }
}

시연 영상

정리 및 요약

네트워크 라이브러리

  • 네트워크 라이브러리를 만든 후 콘솔 프로그램, 유니티 프로젝트에 적용을 해봤습니다. 동작이 완전히 무결하지는 않다고 생각하지만, 개발하는 동안엔 문제 발생이 없어 코드 수정 없이 잘 동작하였습니다. 나름 괜찮게 만들긴 한거같아서 다행입니다.
  • 현재 메시지 크기가 설정한 버퍼 크기를 초과하는 경우 그냥 에러 발생하게 해놨는데 이에 따른 정교한 구현이 필요할 것 같습니다.
  • protobuf를 사용해보았습니다. 이전에 소켓프로그래밍을 수행할 땐 직렬화 역직렬화를 제가 직접 일일이 구현했었고 그에 따라 메시지 구조가 바뀌면 매번 수정해줘야 했었는데 매우 편리했습니다. 다만 현재도 새로운 메시지를 추가할 시에 메시지를 등록한 후 OpCode라는 이넘을 사용하여 메시지 종류를 구분하는데 이 동작또한 간소화 시킬 수 있으면 좋을 것 같습니다.

서버 구현

  • 서버나 클라측에서 메시지를 소비하는 구조에 관한 고민을 많이 했습니다. 수신한 메시지를 하나씩 받아서 핸들러 -> 서비스를 통해 처리하는데, 아직 현재 구조가 좋은 구조인지는 모르겠습니다.
  • 소켓 연결과 로그인을 통합하여 처리하거나 서비스 측에서 소켓 연결된 상태를 생각하지 않게되면 좋을 것 같은데, 현재는 OnConnect, OnDisConnect, ReceiveMessage 3가지 경우에 따른 반응을 해야합니다.
  • SocketContext나 Session 관련한 현재 구조가 깔끔한지 모르겠습니다. 아무튼 작성한 코드덩어리에 의구심이 많습니다. 또한 메시지 송수신이 SessionID(정수값)을 통한 소켓 구분을 통해 처리하고 있는데, 만약에 서버측에서 특정 SessionID에 메시지 송신을 요청을 했는데 그 사이에 해당 ID에 연결된 소켓에 변동이 생길 경우와 같은 동시성문제?에 의해 동작이 이상해질 여지가 있습니다.
  • 현재 서버측에서 하나의 Task가 지속적으로 메시지를 소비하는 구조라 서비스 측면에서 레이스 컨디션 같은 문제는 없을것같은데 아무튼 뭔 문제가 생길지 두려워 각종 컨테이너를 C#에서 제공하는 Concurrent 컨테이너들로 도배했습니다.

게임 구현

  • Unity를 1년 전에 아주 가볍게 학습해보고 복습없이 막무가내로 GPT의 도움을 받아 기능들을 구현하였습니다. 하드코딩 비슷하게 되어있는 부분들이 많고 여러모로 구조상의 문제가 많다고 생각합니다. 유니티측에서 강제하는 구조같은게 없다고 생각하는데 그래서인지 되게 조잡하게 만들었다고 생각합니다. 정상 동작을 우선으로 구현하였습니다.
  • 단순 채팅을 넘어서 실제 멀티플레이를 구현하고자 하니까 간단한 게임인데도 불구하고 여러모로 송수신해야할 정보들도 많고 어떻게 설계해야할지 막막함이 있었습니다. gRPC나 RestAPI같은 고추상화된 통신을 사용한다면 좀 수월해지지 않을까 싶습니다.
  • 멀티플레이 게임에서 왜 rpc를 활용하는지 알 것 같다는 느낌이 듭니다. (유저가 자신의 상태변경을 요청을 서버측에 함수 사용을 요청하는 것으로 처리하는 느낌?)
  • 게임 만드는 도중에 에디터에서와 빌드결과물의 동작이 다르게 돌아간다든가 네트워크 쪽이 뭔가 이상하게 문제가 생기는 경우가 있었습니다. 유니티 자체에 대한 이해도 부족인 것 같습니다.
  • 정리하면서 느낀건데 게임 동기화 구조가 좀 조잡하고 막무가내입니다. 일단은 그럴싸하게 동작하는 것에 초점을 뒀습니다.

총정리

  • 약 한 달 동안 수행한 프로젝트입니다. C# 소켓프로그래밍을 통해 protobuf를 이용한 네트워크 라이브러리를 만들어봤으며, Unity 프로젝트에 적용해보았습니다.
  • 처음엔 웹 API 사용, DB 연동, 데디케이티드서버 구현 등 여러가지를 계획했지만 매우 축소된 것에 아쉬움이 있습니다.
  • 소켓프로그래밍과 게임에서의 네트워크 통신 구현의 중간지점에 대해 학습을 한 것 같고 멀티플레이 게임 구현에 도움이 많이 될 것 같습니다.
  • 이후 실제 짜임새 있는 멀티플레이 게임을 구현할 생각임.

발생한 문제점

  • 클라이언트 측에서 메시지를 받아 사용하는 구조가 단조롭다고 느낌. 웹 라이브러리에서 응답 받아 사용하듯이 메시지에 ID를 부여하고 그에 요청, 응답 하는 방식으로 기능을 고도화해야 할 것 같음
  • 게임 동기화 문제. 접속한 클라이언트 측에서 게임이 끊기듯이 동작함. Sync message를 처리할때 무조건적으로 몇 미리초 전의 상태로 동기화 처리하여 밀림이 발생함.
  • 네트워크 라이브러리 불안정성. .net standard 2.0으로 포팅되면서 AI가 코드를 어떻게 변경하였는지 검수하지 않았고 그로인해선지 후반부에 알 수 없는 문제가 여럿 발생함. 유니티 이해도 부족으로 인한 구현 실수도 영향이 있을 것 같음. 에디터에선 잘 되는데 빌드 결과물에선 동작이 이상한 경우가 생김.

AI 사용한 부분

  • 처음 네트워크 라이브러리 코드를 작성할 땐 Span이나 헤더 직렬화 코드 같은 생소한 것들은 llm과의 대화를 통해 작성하였습니다.
  • 이후 unity에 라이브러리를 이식하려할 때 .net standard 2.0으로 작성해야한다는 것을 깨닫고, Visual Studio에 내장된 코파일럿 에이전트를 사용해 전환을 하였습니다.
    블로그 포스트에 올린 코드는 그 이전 .Net 10.0으로 직접 작성한 코드입니다.