미니난투 온라인 - Google Play 앱

[ 게임 소개 ] 미니난투 온라인은 실시간으로 다른 유저들 대결을 할 수 있는 공간입니다. 전략적으로 장비를 선택해, PvP 전투를 준비하세요. 현재 개인전, 팀전이 준비 돼 있습니다.(추후 새로운 모드 업데이트 예정) [ 핵심 컨텐츠 ] - 미니난투 : 6명이서 개인전 난투. - 팀전 : 3:3 전투 - 장비 : 무기, 방어구, 신발, 악세서리 - 아이템 : 포션, 폭탄, 토템 등 [ 전략요소 ] - 장비마다 스킬이 포함 돼 있어, 전략적으로 전투를 할

play.google.com

 

 

 

지난 포스팅에 서버 간 통신을 위한 패킷에 대해 설명했다. 

2019/12/17 - [프로그래밍/C# 서버] - c# 실시간 소켓 서버 만들기 1 - 패킷

 

 

이번 포스팅에서는 클리이언트에서 서버로 접속하고, 패킷을 전송하는 과정을 살펴 볼 예정이다. 여기서 사용하는 함수는 비동기 함수이다.

비동기를 사용하는 이유는, 서버에서 유저별로 스레드를 생성하는 것 보다, 하나의 스레드로 관리하는게 서버리소드도 적고, 프로그래밍도 더 편하다고 생각돼서 이다.

 

1. 서버 접속 

 jdNetwork 클래스를 생성해, 네트워크와 관련된 코드들을 넣어놨다. 현재는 접속과 관련된 코드만 오픈해놨는데, 추후에 나오는 메소드 코드 역시 jdNetwork안에서 동작한다고 보면 된다.

public class jdNetwork
{
	//서버 연결 소켓
    Socket                  m_socket;
    .........
    

    public jdNetwork()
    {   
    }

	.....

    public void Connetct(string address, int port)
    {
    	//tcp통신으로 연결합니다.
        m_socket = new Socket(AddressFamily.InterNetwork, 
                                     SocketType.Stream, 
                                     ProtocolType.Tcp);
                                     
        //버퍼에 데이터를 쌓아서 한번에 전송하는게 아니라, 그때그때 전송한다.
        //이렇게하지 않으면, 렉이 많다.
        m_socket.NoDelay = true;


        //연결할 서버의 ip 및 포트 설정
        IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse(address), port);
    
        // 비동기 접속을 위한 event args.
        // 비동기로 통신 합니다.
        SocketAsyncEventArgs event_arg = new SocketAsyncEventArgs();
        event_arg.Completed += onConected;
        event_arg.RemoteEndPoint = endpoint;

        bool pending = m_socket.ConnectAsync(event_arg);

        //비동기 함수로 연결을 시도 했지만, 동기적으로 바로 응답이 온 경우, pending결과가 false이다
        if (!pending)
          onConected(null, event_arg);
    }
    
    void onConected(object sender, SocketAsyncEventArgs e)
    {        
        if(e.SocketError == SocketError.Success)
        {
        	//연결 성공
			....
        }            
        else
        {
            //연결 실패
            ....
        }
            
    }
}

 

 앞 서 말했듯이, 비동기 함수를 이용해 서버에 연결을 한다. 하지만 ConnectAsync함수가 동기적으로 동작하는 경우가 있다. 그때는 결과를 false를 반환하는데, 이때는 따로 처리해 주면 된다. 뭐 코드를 봐서 알겠지만 어려울 게 없다. 그냥 따라하면 된다.

 

2. 메시지 전송 

 jdNetwork 클래스 내에 send함수를 생성한다. 

//파라미터로 이전 포스팅에서 만든 패킷을 사용한다.
public void Send(jdPacket packet)
{	
    //소켓에 연결이 안된 상태라면, 종료한다.
    if(m_socket == null || !m_socket.Connected)
        return;

    //네트워크 전송이 빈번해 SocketAsyncEventArgs 객체를 풀로 만들어 쓰고 있다.
    //new SocketAsyncEventArgs()로 생성해도 된다.
    SocketAsyncEventArgs send_event_args    = SocketAsyncEventArgsPool.Instance.Pop();
    if(send_event_args == null)
    {
        LogManager.Debug("SocketAsyncEventArgsPool::Pop() result is null");
        return;
    }

    //전송 완료 됐을 때 이벤트를 등록한다.
    send_event_args.Completed               += onSendComplected;
    send_event_args.UserToken               = this;

	
    //전송할 데이터 byte배열을 SocketAsyncEventArgs객체 버퍼에 복사한다.
    byte[] send_data = packet.GetSendBytes();
    send_event_args.SetBuffer(send_data,0,send_data.Length);

    //앞서 연결과 마찬가지로, 비동기 함수로 SocketAsyncEventArgs객체를 전송한다.
    //비동기 함수지만, 동기적으로 동작하는 경우가 있으므로 체크 해 준다.
    bool pending = m_socket.SendAsync(send_event_args);
    if (!pending)
        onSendComplected(null, send_event_args);
}


void onSendComplected(object sender, SocketAsyncEventArgs e)
{
    if(e.SocketError == SocketError.Success)
    {
		//전송 성공
    }
    else
    {
       //전송 실패
    }

    
    //사용했던 SocketAsyncEventArgs객체를 풀에 다시 넣어준다.
    //풀에 넣기 전에 해당 객체를 초기화 해 준다.
    e.Completed -= onSendComplected;
    SocketAsyncEventArgsPool.Instance.Push(e);
}

 

 Send() 메소드의 파라미터로 jdPacket을 보낸다. jdPacket은 전송하려던 메시지 byte배열을 인자고 담고 있다. 이전 포스팅을 참고 바란다.

SocketAsyncEventArgs 클래스가 계속 보이는데, SocketAsyncEventArgs 네트워크 통신에 도움이 되는 기능을 가지고 있다고만 알고 있다. SocketAsyncEventArgs내부에 버퍼가 있고 해당 버퍼에 메시지를 담아 전송한다. 연결 할 때와 마찬가지고 비동기함수가 동기적으로 동작할 경우, false를 반환하는데 이때를 체크해 주자.

 

3. 메시지 받기

 서버로 메시지를 전송했으면, 그에 대한 응답을 받을 차례다. 지금까지와는 다르게 받는 부분은 쬐금 더 복잡하다. 그치만 순서대로 잘 따라하면 이해 될 것이다. 우선 메시지를 받아보자.

 

//jdNetwork클래스에 아래와 같은 멤버 변수들을 선언한다.
SocketAsyncEventArgs    m_receive_event_args;
jdMessageResolver       m_message_resolver;
LinkedList<jdPacket>	m_receive_packet_list;
GamePacketHandler       m_game_packet_handler;
byte[]                  m_receive_buffer;

public void Init()
{    
    //받은 byte배열을 패킷으로 만들어, 리스트에 넣고, GamePacketHandler에서 처리한다.
    //뒤에서 설명할 예정이다.
    m_receive_packet_list             = new LinkedList<jdPacket>();
    m_receive_buffer                  = new byte[jdCommonDefine.SOCKET_BUFFER_SIZE];
    m_message_resolver                = new jdMessageResolver();
    
    //전송된 패킷을 처리하는 부분이다. 추후에 설명할 예정이니 넘어가자
    m_game_packet_handler			  = new GamePacketHandler();
    m_game_packet_handler.Init(this);
    

    //메시지를 받을 버퍼를 설정한다. 여기서는 4K로 한정했다.
    //만약 패킷 크기가 10K라면, [4,4,2]로 3번에 나눠서 이벤트 발생한다.
    m_receive_event_args              = new SocketAsyncEventArgs();
    m_receive_event_args.Completed    += onReceiveComplected;
    m_receive_event_args.UserToken    = this;
    m_receive_event_args.SetBuffer(m_receive_buffer, 0, 1024*4);
}


//메시지 받기를 시작하는 함수
public void StartReceive()
{
    //서버가 연결된 상태에서 이 함수를 호출해, 메시지가 오기를 기다리자
    //메시지가 오면 onReceiveComplected 콜백함수가 호출된다.   
    bool pending = m_socket.ReceiveAsync(m_receive_event_args);
    if (!pending)
        onReceiveComplected(this, m_receive_event_args);
}


//메시지가 왔을 때 동작하는 콜백 함수.
void onReceiveComplected(object sender, SocketAsyncEventArgs e)
{
    if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
    {
        //전송 성송
        //byte배열의 데이터를 다시 패킷으로 만들어 준다.
        //아래에서 설명할 것이다.
        m_message_resolver.OnReceive(e.Buffer, e.Offset, e.BytesTransferred, onMessageComplected);

        //receive message
        //새로운 메시지를 받는다.
        StartReceive();
    }
    else
    {
        //실패.
        //서버가 닫혔거나, 통신이 불가능 할 때
    }
}

 

 확실히 전송하는 부분보다 코드가 늘었다. 여기서는 SocketAsyncEventArgs 객체를 멤버변수로 선언해 재사용하고 있다.  위의 코드는 서버로 부터 byte배열 형태의 데이터를 받아 온 것이다. 이제부터는 이 byte배열을 jdPacket의 객체로 만들고, 이것을 원래 데이터 객체로 역직렬화 하는 것이다. 위에 메시지 콜백함수가 성공적으로 오고, jdMessageResolver로 데이터넘기는 것을 볼 수 있다.

 

e.Buffer 은 전송된 데이터 배열이다.

e.Offset 은 전체 데이터에서의 위치이다. 10K짜리 패킷 데이터인데, 앞서 4K메시지가 왔고, 현재 또 4K가 전송됬다면, offset위치는 4K일것이다.

e.Transferred 는 현재 전송된 데이터 양이다.

 

using System;

public class jdMessageResolver
{
    //전체 데이터 전송이 완료 됐을 때 호출 할 콜백 함수.
    public delegate void CompletedMessageCallback(jdPacket packet);

    int         m_message_size;
    byte[]      m_message_buffer	= new byte[1024 * 2000]; //2000K
    byte[]      m_header_buffer 	= new byte[4];	//4byte
    byte[]      m_type_buffer 		= new byte[2];	//2byte


    jdPacketType m_pre_type;

    int			m_head_position;
    int			m_type_position;
    int			m_current_position;

    short		m_message_type; 
    int			m_remain_bytes;

    bool		m_head_completed;
    bool		m_type_complected;
    bool		m_completed;

    CompletedMessageCallback	m_complete_callback;

    public jdMessageResolver()
    {
        ClearBuffer();
    }


    //StartReceive()함수로 들어온 데이터를 처리하는 함수이다.
    public void OnReceive(byte[] buffer, int offset, int transffered, CompletedMessageCallback callback)
    {
        //현재 들어온 데이터의 위치를 저장한다.
        int src_position		= offset;

        //메시지가 완성되었다면, 콜백함수를 호출해 준다.       
        m_complete_callback 	= callback;

        //처리해야할 메시지 양을 저장한다.
        m_remain_bytes 			= transffered;


        if(!m_head_completed)
        {
            //패킷의 헤더 데이터를 완성하지 않았다면, 읽어 온 데이터로 헤더를 완성한다.
            m_head_completed = readHead(buffer, ref src_position);

            //읽어온 데이터로도 헤더를 완성하지 못했다면, 다음 데이터 전송을 기다린다.
            if(!m_head_completed)
                return;


            //헤더를 완성했으면, 헤더 정보에 있는 데이터의 전체 양을 확인한다.
            m_message_size = getBodySize();

            //잘못된 데이터 인지 확인하는 코드이다.
            //현재 20K까지만 받을 수 있다
            if (m_message_size < 0 || 
                m_message_size > jdCommonDefine.COMPLETE_MESSAGE_SIZE_CLIENT)
            {
                return;
            }
        }


        if (!m_type_complected)
        {
            //남은 데이터가 있다면, 타입 정보를 완성한다.
            m_type_complected = readType(buffer, ref src_position);

            //타입 정보를 완성하지 못했다면, 다음 메시지 전송을 기다린다.
            if(!m_type_complected)
                return;

            //타입 정보를 완성했다면, 패킷 타입을 정의한다. (enum type)
            m_message_type = BitConverter.ToInt16(m_type_buffer, 0);


            //잘못된 데이터인지 확인
            if(m_message_type < 0 || 
               m_message_type > (int)jdPacketType.PACKET_COUNT - 1)
            {
                return;
            }

            //데이터가 미완성일 경우, 다음에 전송되었을 때를 위해 저장해 둔다.
            m_pre_type = (jdPacketType)m_message_type;
        }


        if(!m_completed)
        {
            //남은 데이터가 있다면, 데이터 완성과정을 진행한다.
            m_completed = readBody(buffer, ref src_position);
            if(!m_completed)
                return;
        }

        //데이터가 완성 되었다면, 패킷으로 만든다.
        jdPacket packet = new jdPacket();
        packet.m_type = m_message_type;
        packet.SetData(m_message_buffer, m_message_size);

        //패킷이 완성 되었음을 알린다.
        m_complete_callback(packet);

        //패킷을 만드는데, 사용한 버퍼를 초기화 해준다.
        ClearBuffer();
    }

    public void ClearBuffer()
    {
        Array.Clear(m_message_buffer, 0, m_message_buffer.Length);
        Array.Clear(m_header_buffer, 0, m_header_buffer.Length);
        Array.Clear(m_type_buffer, 0, m_type_buffer.Length);

        m_message_size			= 0;
        m_head_position			= 0;
        m_type_position			= 0;
        m_current_position		= 0;
        m_message_type			= 0;
        //m_remain_bytes			= 0;

        m_head_completed		= false;
        m_type_complected		= false;
        m_completed				= false;
    }

    private bool readHead(byte[] buffer, ref int src_position)
    {
        return readUntil(buffer,ref src_position, m_header_buffer, ref m_head_position, 4);
    }

    private bool readType(byte[] buffer, ref int src_position)
    {
        return readUntil(buffer,ref src_position, m_type_buffer, ref m_type_position, 2);
    }

    private bool readBody(byte[] buffer, ref int src_position)
    {
        return readUntil(buffer,ref src_position, m_message_buffer, ref m_current_position, m_message_size);
    }


    bool readUntil(byte[] buffer, ref int src_position, byte[] dest_buffer, ref int dest_position, int to_size)
    {
        //남은 데이터가 없다면, 리턴
        if(m_remain_bytes < 0)
            return false;

        int copy_size = to_size - dest_position;
        if (m_remain_bytes < copy_size)
            copy_size = m_remain_bytes;

        Array.Copy(buffer, src_position, dest_buffer, dest_position, copy_size);

        //시작 위치를 옮겨준다.
        src_position 		+= copy_size;
        dest_position 		+= copy_size;
        m_remain_bytes 		-= copy_size;

        return !(dest_position < to_size);
    }

    //헤더로부터 데이터 전체 크기를 읽어온다.
    int getBodySize()
    {
        Type type = jdCommonDefine.HEADER_SIZE.GetType();
        if (type.Equals(typeof(Int16)))
        {
            return BitConverter.ToInt16(m_header_buffer, 0);
        }

        return BitConverter.ToInt32(m_header_buffer, 0);
    }
}

 

좀 복잡해 보이는데, 핵심은 byte배열로 jdPacket객체를 만든다는 것이다. 15K의 데이터라면 4번의 호출로 jdPacket객체가 만들어 질 것이다. 이제 만들어진 패킷을 onMessageComplected 함수로 전달하고, 이 함수에서는 GamePacketHandler객체로 넘겨 처리하면 된다.

void onMessageComplected(jdPacket packet)
{
	//패킷 리스트에 넣는다.
    PushPacket(packet);
}

private void PushPacket(jdPacket packet)
{
	//패킷 완성하는 스레드가 메인스레드가 아닐 수 있다.
    //서로 다른 스레드 인 경우가 있어, 뮤텍스로 락을 걸어 처리했다.
    lock(m_mutext_receive_packet_list)
    {
        m_receive_packet_list.AddLast(packet);
    }
}

public void ProcessPackets()
{
    lock(m_mutext_receive_packet_list)
    {
    	//GamePacketHandler 객체에서 패킷을 처리한다.
        foreach(jdPacket packet in m_receive_packet_list)
            m_game_packet_handler.ParsePacket(packet);
        m_receive_packet_list.Clear();
    }
}

 

 패킷을 받아오는 스레드와 메인스레드가 서로 다를 수 있다. 아마 다를 것이다. 유니티의 경우 메인스레드가 아닌 것에서 ui를 고치면 에러가 발생한다. 패킷을 리스트에 넣고, 메이스레드에서 이를 매 루프 확인해 처리하도록 하자.

public class GamePacketHandler
{
    jdNetwork 		m_network;

    public void Init(jdNetwork network)
    {
        m_network = network;
    }

    public void ParsePacket(jdPacket packet)
    {
        //LogManager.Debug("packet is "+ ((jdPacketType)packet.m_type).ToString());
        switch((jdPacketType)packet.m_type)
        {

            case jdPacketType.TEST_PACKET_RES:
                TestPacketRes(packet);
                break;
            .....

          }
    }
    
    
    public void TestPacketRes(jdPacket packet)
    {
    	//역직렬화 해서, 원래 데이터로 만든다.
        TestPacketRes notify = jdData<TestPacketRes>.Deserialize(packet.m_data);
		...
    }
}

 

 패킷을 원래 데이터로 복구하는 과정이다. 이전 포스팅에서 jdData<T> 내부에 Deserialize함수로 역직렬화해서 원래 데이터로 되돌린다.

 

 여기까지가 클라이언트 단에서 데이터를 전송하고 받는 부분이다. 서버도 사실 큰 차이가 없다. 다음 포스팅에 서버에서 유저를 받고, 데이터를 받고 응답하는 부분을 살펴보도록 하겠다.

 

2019/12/17 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 1 - 패킷

2019/12/22 - [게임을 만들자/게임 서버(C#)] - c# 실시간 게임 서버 만들기 3 - 서버

이세계 용병 온라인

댓글을 달아 주세요

  1. 비밀댓글입니다

    • 사용자 여름빙수
      2021.04.03 18:52 신고

      현재 제가 서비스하고 있는 코드들이 있어서 직접 코드를 보여드리기는 안될 것 같습니다. 블로그에 정리된 코드는 딱 패킷 주고받는 기능만 있다고 보시면 돼요. 이후 게임마다 추가 되는 것들이 있는데, 패킷 주고받는거 따라해보시다가 잘 안되는거 있음, 따로 댓글 달아주세요~