[.Net Core] MMO 컨텐츠 구현(DB,대형구조,라이브) - 더미 테스트

Yijun Jeon·2022년 12월 12일
0
post-thumbnail

Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.


DummyClient - 접속 구현

다수의 접속자 테스트를 위해 서버 측에서 DummyClient 프로젝트로 생성

필요한 파일

  • 패킷
    1) ClientPacketmanager.cs - batch 파일로 생성
    2) Protocol.cs - batch 파일로 생성
    3) PacketHandler.cs

  • 세션
    1) ServerSession.cs
    2) SessionManager.cs

패킷

GenProto.bat

protoc.exe -I=./ --csharp_out=./ ./Protocol.proto 
IF ERRORLEVEL 1 PAUSE

START ../../../Server/PacketGenerator/bin/PacketGenerator.exe ./Protocol.proto
XCOPY /Y Protocol.cs "../../../Client/Assets/Scripts/Packet"
XCOPY /Y Protocol.cs "../../../Server/Server/Packet"
XCOPY /Y Protocol.cs "../../../Server/DummyClient/Packet"
XCOPY /Y ClientPacketManager.cs "../../../Client/Assets/Scripts/Packet"
XCOPY /Y ClientPacketManager.cs "../../../Server/DummyClient/Packet"
XCOPY /Y ServerPacketManager.cs "../../../Server/Server/Packet" 
  • PacketHandler : 기존 Client의 PacketHandler를 그대로 복붙해서 컨텐츠 관련 내용은 일단 전부 삭제

세션

  • ServerSession : 기존 Client의 ServerSession 그대로 복붙해서 Unity 관련 내용만 전부 삭제

  • SessionManager : Client의 SessionManager와 달리 하나만 관리하는 것이 아니라 여러 클라이언트가 각각 들고 있어야 함

SessionManager.cs

public class SessionManager
{
    public static SessionManager Instance { get; } = new SessionManager();

    // 연결된 세션들
    HashSet<ServerSession> _sessions = new HashSet<ServerSession>();
    object _lock = new object();
    int _dummyId = 1;

    public ServerSession Generate()
    {
        lock(_lock)
        {
            ServerSession session = new ServerSession();
            session.DummyId = _dummyId;
            _dummyId++;

            _sessions.Add(session);
            Console.WriteLine($"Connected ({_sessions.Count}) Players");
            return session;
        }
    }

    public void Remove(ServerSession session)
    {
        lock(_lock)
        {
            _sessions.Remove(session);
            Console.WriteLine($"Connected ({_sessions.Count}) Players");
        }
    }
}

연결

  • Program.cs : Client의 NetworkManager.Init() 부분 그대로 복붙해서 사용

Program.cs

static int DummyClientCount { get; } = 500;

static void Main(string[] args)
{
	// 서버 시작까지 5초 기다림
	Thread.Sleep(5000);

	// DNS (Domain Name System)
	string host = Dns.GetHostName();
	IPHostEntry ipHost = Dns.GetHostEntry(host);
	IPAddress ipAddr = ipHost.AddressList[0];
	IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

	Connector connector = new Connector();

	connector.Connect(endPoint,
		() => { return SessionManager.Instance.Generate(); },
		Program.DummyClientCount);

	while(true)
    {
		Thread.Sleep(10000);
    }
}
  • 기존 ServerCore의 Connect 방식으로는 한 번에 500개를 바로 생성하여 연결함
    -> 손님 500명이 식당에 한꺼번에 들어오는 것과 같음

-> 한 명 생성 후 조금 기다릴 수 있게 처리
-> 실제 라이브 서비스에서 사용하는 방법

ServerCore.Connector.cs

public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory, int count = 1)
{
	for (int i = 0; i < count; i++)
	{
		// 휴대폰 설정
		Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
		_sessionFactory = sessionFactory;

		SocketAsyncEventArgs args = new SocketAsyncEventArgs();
		args.Completed += OnConnectCompleted;
		args.RemoteEndPoint = endPoint;
		args.UserToken = socket;

		RegisterConnect(args);

		// TEMP
		Thread.Sleep(10);
	}
}

ServerCore의 모든 소캣, 패킷 싱크와 연결 등에 관한 부분은 try~catch 문으로 예외처리를 잡아줘야 혹시 모를 충돌이 일어나지 않음

-> Connector, Listener

Connector.cs

void RegisterConnect(SocketAsyncEventArgs args)
{
	Socket socket = args.UserToken as Socket;
	if (socket == null)
		return;

	try
    {
		bool pending = socket.ConnectAsync(args);
		if (pending == false)
			OnConnectCompleted(null, args);
	}
	catch (Exception e)
	{
		Console.WriteLine(e);
	}	
}
...


DummyClient - 입장 테스트

더미 클라이언트들이 인게임에 입장하는 부분까지 구현

접속 Step

  1. S_ConnectedHandler : 접속
  2. S_LoginHandler : 로그인
  3. S_CreatePlayerHandler : 플레이어 생성
  4. S_EnterGameHandler : 인게임 입장

패킷 처리

PacketHandler.cs

// 인게임 입장 Step 1
public static void S_ConnectedHandler(PacketSession session, IMessage packet)
{
    C_Login loginPacket = new C_Login();
    ServerSession serverSession = (ServerSession)session;

    loginPacket.UniqueId = $"DummyClient_{serverSession.DummyId.ToString("0000")}";
    serverSession.Send(loginPacket);
}

// 인게임 입장 Step 2
public static void S_LoginHandler(PacketSession session, IMessage packet)
{
    S_Login loginPacket = (S_Login)packet;
    ServerSession serverSession = (ServerSession)session;

    // 플레이어가 없는 경우 생성
    if (loginPacket.Players == null || loginPacket.Players.Count == 0)
    {
        C_CreatePlayer createPacket = new C_CreatePlayer();
        createPacket.Name = $"Player_{serverSession.DummyId.ToString("0000")}";
        serverSession.Send(createPacket);
    }
    else
    {
        // 일단 무조건 첫 번째 플레이어로 접속
        LobbyPlayerInfo info = loginPacket.Players[0];
        C_EnterGame enterGamePacket = new C_EnterGame();
        enterGamePacket.Name = info.Name;
        serverSession.Send(enterGamePacket);
    }
}

// 인게임 입장 Step 3
public static void S_CreatePlayerHandler(PacketSession session, IMessage packet)
{
    S_CreatePlayer createOkPlayer = (S_CreatePlayer)packet;
    ServerSession serverSession = (ServerSession)session;

    // 플레이어 생성 재송신
    if (createOkPlayer.Player == null)
    {
        C_CreatePlayer createPacket = new C_CreatePlayer();
        createPacket.Name = $"Player_{serverSession.DummyId.ToString("0000")}";
        serverSession.Send(createPacket); 
    }
    else
    {
        C_EnterGame enterGamePacket = new C_EnterGame();
        enterGamePacket.Name = createOkPlayer.Player.Name;
        serverSession.Send(enterGamePacket);
    }

}

// 인게임 입장 Step 4
public static void S_EnterGameHandler(PacketSession session, IMessage packet)
{
    S_EnterGame enterGamePacket = packet as S_EnterGame;
}

패킷 모아보내기

서버에서 패킷을 예약하고 기다렸다가 특정 시간이 지나거나 일정 바이트 이상이 모였을 때 전부 전송하도록 처리

Server.ClientSession.cs

// 보내려고 예약한 바이트
int _reservedSendBytes = 0;
// 마지막으로 보낸 시간
long _lastSendTick = 0;

// Send할 목록을 예약만 함
public void Send(IMessage packet)
{
	// packet id 추출
	string msgName = packet.Descriptor.Name.Replace("_", String.Empty); // '_' 제거한 이름 추출
	MsgId msgId = (MsgId)Enum.Parse(typeof(MsgId), msgName); // 이름이 같은 enum 값을 반환

    ushort size = (ushort)packet.CalculateSize();
    byte[] sendBuffer = new byte[size + 4];
    Array.Copy(BitConverter.GetBytes((ushort)(size + 4)), 0, sendBuffer, 0, sizeof(ushort));
    Array.Copy(BitConverter.GetBytes((ushort)msgId), 0, sendBuffer, 2, sizeof(ushort));
    Array.Copy(packet.ToByteArray(), 0, sendBuffer, 4, size);

	lock(_lock)
    {
		// 일단 예약만 하고 넘겨줌
		_reserveQueue.Add(sendBuffer);
		_reservedSendBytes += sendBuffer.Length;
	}
}
		
// 실제 Network IO 보내는 부분
public void FlushSend()
{
	List<ArraySegment<byte>> sendList = null;
	lock(_lock)
    {
		// 0.1초가 지났거나, 패킷이 1만 바이트 이상 모였을 때만 전송
		long delta = (System.Environment.TickCount64 - _lastSendTick);
		if (delta < 100 && _reservedSendBytes < 10000)
			return;

		// 패킷 모아 보내기
		_reservedSendBytes = 0;
		_lastSendTick = System.Environment.TickCount64;

		// 복사만 해주고 초기화함
		if (_reserveQueue.Count == 0)
			return;

		sendList = _reserveQueue;
		_reserveQueue = new List<ArraySegment<byte>>();
    }

	Send(sendList);
}

Test

  • 클라이언트 500
  • 몬스터 500


몬스터 AI 개선

몬스터의 플레이어를 찾는 AI 개선 - 서버쪽 부담은 늘어남

현재 몬스터의 타겟 플레이어를 찾는 부분 : 모든 플레이어를 스캔하므로 부하가 큼

GameRoom.cs

public Player FindPlayer(Func<GameObject, bool> condition)
{
    // 모든 플레이어를 스캔하는 무식한 방법
    foreach(Player player in _players.Values)
    {
        if (condition.Invoke(player))
            return player;
    }
    return null;
}

플레이어 찾기 개선

인접한 존을 활용하여 최대한 가까이 있는 플레이어를 찾는 방식으로 변환

  • 기존 GetAdjacentZone() : 자신의 Zone만 반환
    -> Monster도 활용할 수 있도록 range에 따라 좌측 상단 Zone 부터 우측 하단 Zone 까지 반환하도록 수정

GameRoom.cs

// 내 영역에 속하는 Zone 리스트
public List<Zone> GetAdjacentZones(Vector2Int cellPos, int range = GameRoom.VisionCells) // 반경
{
    HashSet<Zone> zones = new HashSet<Zone>();

    int maxY = cellPos.y + range;
    int minY = cellPos.y - range;
    int maxX = cellPos.x + range;
    int minX = cellPos.x - range;

    // 좌측 상단
    Vector2Int leftTop = new Vector2Int(minX, maxY);

    // Grid 좌표
    int minIndexY = (Map.MaxY - leftTop.y) / ZoneCells;
    int minIndexX = (leftTop.x - Map.MinX) / ZoneCells;


    // 우측 하단
    Vector2Int rightBot = new Vector2Int(maxX, minY);
    int maxIndexY = (Map.MaxY - rightBot.y) / ZoneCells;
    int maxIndexX = (rightBot.x - Map.MinX) / ZoneCells;


    for(int x = minIndexX; x<= maxIndexX; x++)
    {
        for(int y = minIndexY; y<= maxIndexY; y++)
        {
            Zone zone = GetZone(y, x);
            if (zone == null)
                continue;

            zones.Add(zone);
        }
    }

    return zones.ToList();
}

-> 가까운 플레이어를 찾을 수 있도록 처리

GameRoom.cs

// 가장 가까이 있는 플레이어 찾음
// 살짝 부담스러운 함수
public Player FindClosestPlayer(Vector2Int pos, int range)
{
    List<Player> players =  GetAdjacentPlayers(pos, range);

    players.Sort((left, right) =>
    {
        int leftDist = (left.CellPos - pos).cellDistFromZero;
        int rightDist = (right.CellPos - pos).cellDistFromZero;
        return leftDist - rightDist;
    });

    // 플레이어에게 갈 수 있는지 astar로 체크
    foreach(Player player in players)
    {
        List<Vector2Int> path = Map.FindPath(pos, player.CellPos, checkObject: true);
        if (path.Count < 2 || path.Count > range)
            continue;

        return player;
    }

    return null;
}

-> 몬스터 Idle 상태 처리

Monster.cs

protected virtual void UpdateIdle()
{
    // 아직 search tick이 되지 않음
    if (_nextSearchTick > Environment.TickCount64)
        return;
    _nextSearchTick = Environment.TickCount64 + 1000;// 1초마다 통과함

    Player target = Room.FindClosestPlayer(CellPos,_searchCellDist);

    if (target == null)
        return;

    _target = target;
    State = CreatureState.Moving;
}

업로드중..

0개의 댓글