Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.
다수의 접속자 테스트를 위해 서버 측에서 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개를 바로 생성하여 연결함-> 한 명 생성 후 조금 기다릴 수 있게 처리
-> 실제 라이브 서비스에서 사용하는 방법
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);
}
}
...
더미 클라이언트들이 인게임에 입장하는 부분까지 구현
접속 Step
S_ConnectedHandler
: 접속S_LoginHandler
: 로그인S_CreatePlayerHandler
: 플레이어 생성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);
}
몬스터의 플레이어를 찾는 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만 반환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;
}