[.Net Core] MMO 컨텐츠 구현(DB,대형구조,라이브) - 대형 구조 관리

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

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


Job 예약 취소

현재 몬스터들도 일괄적으로 한꺼번애 Update 되고 있음
-> 자기가 각각 필요한 틱에 따라서 Update 될 수 있게 개선

현재 사용 중인 몬스터의 Update 방식은 여러 틱을 사용함

  • _nextSearchTick
  • _nextMoveTick
  • _coolTick

단순하게 Push(~) Job 방식으로 바꿔주기만 하면 꼬일 위험이 큼

  • Unity의 코루틴처럼 예약했던 일감을 취소하는 로직이 필요함
  • 기존 Push를 예약을 마치고 그 IJob을 return 하도록 처리

JobSerializer.cs

public IJob PushAfter(int tickAfter, IJob job)
{
	_timer.Push(job, tickAfter);
	return job;
}
public IJob PushAfter(int tickAfter, Action action) { return PushAfter(tickAfter, new Job(action)); }
...
  • 취소할 지 여부인 Cancel 추가
    -> 외부에서 Cancel만 true로 바꿔주면 그 Job의 Execute()가 실행되지 않게 됨

Job.cs

public abstract class IJob
{
    // 실행
    public abstract void Execute();
    public bool Cancel { get; set; }
}

// 인자가 없는 Job
public class Job : IJob
{
    Action _action;

    public Job(Action action)
    {
        _action = action;
    }
    public override void Execute()
    {
    	if(Cancel == false)
        	_action.Invoke();
    }
}
...

몬스터 개선

Monster.cs

IJob _job;
// FSM (Finite State Machine)
public override void Update()
{
    switch(State)
    {
        case CreatureState.Idle:
            UpdateIdle();
            break;
        case CreatureState.Moving:
            UpdateMoving();
            break;
        case CreatureState.Skill:
            UpdateSkill();
            break;
        case CreatureState.Dead:
            UpdateDead();
            break;
    }

    // 5프레임 (0.2 초마다 한번씩 Update)
    if (Room != null)
        _job = Room.PushAfter(200, Update);
}

public override void OnDead(GameObject attacker)
{
    // 죽었다면 예약해둔 일감 취소
    if(_job != null)
    {
        _job.Cancel = true;
        _job = null;
    }
    ...
}

대형 구조 관리

대현 구조 관리를 위한 이론

현재까지 하나의 방에서의 멀티 플레이 동기화 OK

  • 지역별로 관리하였을 때


  • Broadcasting의 부하가 N^N 으로 늘어나기 때문에

유저가 두 영역을 계속 넘나든다면

  • 두 영역에서 같은 연산을 공유하게 됐을 때 치명적일 수 있음

두 플레이어가 전투를 한다고 가정

  • 다른 쓰레드의 정보를 믿을 수가 없음

  1. 공간 단위로 따로 처리

    • 상위 문제들을 모두 해결해야 함
  2. JobSerializer 모든 객체에 둠 - Object 하나하나가 GameRoom이 됨

    • 같은 Object의 실행 순서 유지

게임 구조 변경

GameRoom보다 더 넓은 영역을 관리할 수 있도록 서버의 구조를 변경

현재 사용자들이 뭉쳤을 때 부하가 굉장히 커질 수 있는 부분

  • Broadcasting 하는 부분
    -> Send
  • Monster AI

쓰레드 분배 상황
1. Recv (N개) - 서빙
2. RoomManager (1개) - 요리사
3. DB (1개) - 결제/장부

GameLogic

RoomManager.cs -> GameLogic.cs

  • 게임 로직과 관련된 부분은 GameLogic 에서 모두 처리하도록 수정
    -> Tick으로 GameRoom 보다 더 큰 단위인 GameLogic을 Update 하게됨
  • 기존의 Add, Remove, Find의 lock 사용 제거
    -> JobQueue에 push 방식 사용

GameLogic.cs

public class GameLogic : JobSerializer
{
    public static GameLogic Instance { get; } = new GameLogic();

    Dictionary<int, GameRoom> _rooms = new Dictionary<int, GameRoom>();
    int _roomId = 1;

    public void Update()
    {
        Flush();

        // 가진 모든 방을 돌면서 Update
        foreach(GameRoom room in _rooms.Values)
        {
            room.Update();
        }
    }

    // GameRoom 생성
    public GameRoom Add(int mapId)
    {
        GameRoom gameRoom = new GameRoom();
        //gameRoom.Init(mapId);
        gameRoom.Push(gameRoom.Init, mapId);

        gameRoom.RoomId = _roomId;
        _rooms.Add(_roomId, gameRoom);
        _roomId++;

        return gameRoom;
    }

    // GameRoom 제거
    public bool ReMove(int roomId)
    {
        return _rooms.Remove(roomId);
    }

    // GameRoom 찾음
    public GameRoom Find(int roomId)
    {
        GameRoom room = null;
        if (_rooms.TryGetValue(roomId, out room))
            return room;
        return null;
    }
} 

->

ClientSession_PreGame.cs

public void HandleEnterGame(C_EnterGame enterGamePacket)
{
	...
	// GameLogic을 담당하는 Thread에게 일감을 넘김
	GameLogic.Instance.Push(() =>
	{
		// 1번방에 플레이어 입장
		GameRoom room = GameLogic.Instance.Find(1);
		room.Push(room.EnterGame, MyPlayer);
	});
}

Send

GameLogic을 하나의 Thread로만 관리하기 때문에 일감이 밀릴 수 있는 부분이 있으면 모든 로직이 밀릴 수 있음

  • Broadcast의 Send 자체도 다른 Thread로 떠넘겨줌

ClientSession.cs

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

// Send할 목록을 예약만 함
public void Send(IMessage packet)
{
	...
	lock(_lock)
    {
		// 일단 예약만 하고 넘겨줌
		_reserveQueue.Add(sendBuffer);
	}
	//Send(new ArraySegment<byte>(sendBuffer));
}

// 실제 Network IO 보내는 부분
public void FlushSend()
{
	List<ArraySegment<byte>> sendList = null;
	lock(_lock)
    {
		// 복사만 해주고 초기화함
		if (_reserveQueue.Count == 0)
			return;

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

	Send(sendList);
}

Main

  • 기존의 Tick, Timer 관련 부분 전부 삭제

Program.cs

static void GameLogicTask()
{
	while(true)
    {
		GameLogic.Instance.Update();
		Thread.Sleep(0);
    }
}

static void DbTask()
{
	while (true)
	{
		// 일단 무식하게 무한루프로 Flush
		DbTransaction.Instance.Flush();
		// 실행권을 잠시 넘겨줘서 CPU 낭비 방지
		Thread.Sleep(0);
	}
}

static void NetworkTask()
{
	while(true)
    {
		List<ClientSession> sessions = SessionManager.Instance.GetSessions();

		foreach (ClientSession session in sessions)
		{
			session.FlushSend();
		}
		Thread.Sleep(0);
    }
}

static void Main(string[] args)
{
	...
    Console.WriteLine("Listening...");

    // GameLogicTask
    {
		// 쓰레드 풀링X -> 별도로 하나를 더 파줌
		Task gameLogicTask = new Task(GameLogicTask, TaskCreationOptions.LongRunning);
		gameLogicTask.Start();
    }
    // NetworkTask
	{
		// 쓰레드 풀링X -> 별도로 하나를 더 파줌
		Task networkTask = new Task(NetworkTask, TaskCreationOptions.LongRunning);
		networkTask.Start();
	}
	DbTask();
}

쓰레드 방식

Program.cs

static void Main(string[] args)
{
	...
	Console.WriteLine("Listening...");

	// DbTask
	{
		Thread t = new Thread(DbTask);
		t.Name = "DB";
		t.Start();
    }
	// NetworkTask
	{
		Thread t = new Thread(NetworkTask);
		t.Name = "Network Send";
		t.Start();
	}

	// GameLogic
	Thread.CurrentThread.Name = "GameLogic";
	GameLogicTask();
}

Thread 분배 상황

  1. Recv (N개) - 서빙
  2. GameLogic (1개) - 요리사
  3. Send (1개) - 서빙
  4. DB (1개) - 결제/장부

+ Monster AI (1개) 분배 필요


A* 개선

기존 Map.csFindPath()가 맵이 어마어마하게 커질 경우 부하가 엄청나게 됨

  • 기존에 쓰던 단순 2차 배열은 부하가 심함
    -> 모두 HashSet or Dictionary로 변경
    ->필요한 정보만 들고 있을 수 있도록 HashSet 사용

HashSet<Pos>의 경우 Pos에 대한 비교가 필요하게 됨

Map.cs

public struct Pos
{
    public Pos(int y, int x) { Y = y; X = x; }
    public int Y;
    public int X;

    public static bool operator==(Pos lhs, Pos rhs)
    {
        return lhs.Y == rhs.Y && lhs.X == rhs.X;
    }

    public static bool operator!=(Pos lhs, Pos rhs)
    {
        return !(lhs == rhs);
    }

    public override bool Equals(object obj)
    {
        return (Pos)obj == this;
    }

    public override int GetHashCode()
    {
        // X와 Y가 같은 경우 같은 HashCode를 return해야함
        // X와 Y를 합침
        long value = (Y << 32) | X;
        return value.GetHashCode();
    }

    public override string ToString()
    {
        return base.ToString();
    }
}

public List<Vector2Int> FindPath(Vector2Int startCellPos, Vector2Int destCellPos, bool checkObject = false)
{
    List<Pos> path = new List<Pos>();

    // 점수 매기기
    // F = G + H
    // F = 최종 점수 (작을 수록 좋음, 경로에 따라 달라짐)
    // G = 시작점에서 해당 좌표까지 이동하는데 드는 비용 (작을 수록 좋음, 경로에 따라 달라짐)
    // H = 목적지에서 얼마나 가까운지 (작을 수록 좋음, 고정)

    // (y, x) 이미 방문했는지 여부 (방문 = closed 상태)
    //bool[,] closed = new bool[SizeY, SizeX]; // CloseList
    HashSet<Pos> closeList = new HashSet<Pos>(); // CloseList

    // (y, x) 가는 길을 한 번이라도 발견했는지
    // 발견X => MaxValue
    // 발견O => F = G + H
    //int[,] open = new int[SizeY, SizeX]; // OpenList
    Dictionary<Pos, int> openList = new Dictionary<Pos, int>(); // OpenList

    /*for (int y = 0; y < SizeY; y++)
        for (int x = 0; x < SizeX; x++)
            open[y, x] = Int32.MaxValue;*/

    //Pos[,] parent = new Pos[SizeY, SizeX];

    Dictionary<Pos, Pos> parent = new Dictionary<Pos, Pos>();

    // 오픈리스트에 있는 정보들 중에서, 가장 좋은 후보를 빠르게 뽑아오기 위한 도구
    PriorityQueue<PQNode> pq = new PriorityQueue<PQNode>();

    // CellPos -> ArrayPos
    Pos pos = Cell2Pos(startCellPos);
    Pos dest = Cell2Pos(destCellPos);

    // 시작점 발견 (예약 진행)
    //open[pos.Y, pos.X] = 10 * (Math.Abs(dest.Y - pos.Y) + Math.Abs(dest.X - pos.X));
    openList.Add(pos, 10 * (Math.Abs(dest.Y - pos.Y) + Math.Abs(dest.X - pos.X)));

    pq.Push(new PQNode() { F = 10 * (Math.Abs(dest.Y - pos.Y) + Math.Abs(dest.X - pos.X)), G = 0, Y = pos.Y, X = pos.X });
    //parent[pos.Y, pos.X] = new Pos(pos.Y, pos.X);
    parent.Add(pos, pos);

    while (pq.Count > 0)
    {
        // 제일 좋은 후보를 찾는다
        //PQNode node = pq.Pop();
        PQNode pqNode = pq.Pop();
        Pos node = new Pos(pqNode.Y, pqNode.X);

        // 동일한 좌표를 여러 경로로 찾아서, 더 빠른 경로로 인해서 이미 방문(closed)된 경우 스킵
        if (closeList.Contains(node))
            continue;

        // 방문한다
        //closed[node.Y, node.X] = true;
        closeList.Add(node);

        // 목적지 도착했으면 바로 종료
        if (node.Y == dest.Y && node.X == dest.X)
            break;

        // 상하좌우 등 이동할 수 있는 좌표인지 확인해서 예약(open)한다
        for (int i = 0; i < _deltaY.Length; i++)
        {
            Pos next = new Pos(node.Y + _deltaY[i], node.X + _deltaX[i]);

            // 유효 범위를 벗어났으면 스킵
            // 벽으로 막혀서 갈 수 없으면 스킵
            if (next.Y != dest.Y || next.X != dest.X)
            {
                if (CanGo(Pos2Cell(next), checkObject) == false) // CellPos
                    continue;
            }

            // 이미 방문한 곳이면 스킵
            if (closeList.Contains(next))
                continue;

            // 비용 계산
            int g = 0;// node.G + _cost[i];
            int h = 10 * ((dest.Y - next.Y) * (dest.Y - next.Y) + (dest.X - next.X) * (dest.X - next.X));
            // 다른 경로에서 더 빠른 길 이미 찾았으면 스킵
            /*if (open[next.Y, next.X] < g + h)
                continue;*/

            int value = 0;
            if (openList.TryGetValue(next, out value) == false)
                value = Int32.MaxValue;

            if (value < g + h)
                continue;

            // 예약 진행
            //open[next.Y, next.X] = g + h;
            if (openList.TryAdd(next, g + h) == false)
                openList[next] = g + h;

            pq.Push(new PQNode() { F = g + h, G = g, Y = next.Y, X = next.X });

            //parent[next.Y, next.X] = new Pos(node.Y, node.X);
            if (parent.TryAdd(next, node) == false)
                parent[next] =  node;
        }
    }

    return CalcCellPathFromParent(parent, dest);
}

Zone - 1

GameRoom을 Zone 단위로 나눠서 보다 효율적으로 Broadcasting 할 수 있도록 작업

Zone 장점

GameRoom이 어마어마하게 커졌다고 했을 때 Broadcasting 범위를 어떻게 설정해야 할까

GameRoom 방식

노란색 박스 - 자신의 영역(관심 영역)

  • 자신의 영역 내에 있을 경우 서로 Broadcasting을 주고 받아야 함

  • 플레이어가 움직일 때마다 자신과 모든 상대방의 위치를 비교하여 영역 안에 있을 경우 통신해야 함
    -> 굉장히 비효율적

Zone 방식

GameRoom을 더 작은 가상 단위인 Zone으로 쪼개서 관리

  1. 플레이어가 움직일 때마다 자신이 어느 Zone에 속하는지 검사
  2. 그 존에 있는 상대 플레이어들만 추출
  3. 상대 플레이어들 중 내 영역 안에 있을 경우 통신
  • 내 Zone이나 영역에 아무도 없을 경우 Broadcasting을 아예 하지 않아도 됨

다수의 존에 걸칠 경우
-> 최대 4개까지 검사하긴 해야 함
-> 4개 이상은 겹치지 못하도록 영역과 Zone의 크기를 조정

Zone 설계

Zone

Zone.cs

public class Zone
{
    public int IndexY { get; private set; }
    public int IndexX { get; private set; }

    // Zone에 있는 플레이어들
    public HashSet<Player> Players { get; set; } = new HashSet<Player>();

    public Zone(int y, int x)
    {
        IndexY = y;
        IndexX = x;
    }
        
    // 조건에 맞는 한 플레이어 서치
    public Player FindOne(Func<Player, bool> condition)
    {
        foreach(Player player in Players)
        {
            if (condition.Invoke(player))
                return player;
        }
        return null;
    }

    // 조건에 맞는 모든 플레이어 서치
    public List<Player> FindAll(Func<Player, bool> condition)
    {
        List<Player> findList = new List<Player>();
        foreach (Player player in Players)
        {
            if (condition.Invoke(player))
                findList.Add(player);
        }
        return findList;
    }
}

GameRoom

GameRoom.cs

public Zone[,] Zones { get; private set; }
// 하나의 존의 범위
public int ZoneCells { get; private set; }

public Zone GetZone(Vector2Int cellPos)
{
    // Cell 좌표에서 Grid 좌표로 변환
    // Grid 좌표에서 ZoneCell 단위로 변환
    int x = (cellPos.x - Map.MinX) / ZoneCells;
    int y = (Map.MaxY - cellPos.y) / ZoneCells;

    if (x < 0 || x >= Zones.GetLength(1))
        return null;

    if (y < 0 || y >= Zones.GetLength(0))
        return null;

    return Zones[y, x];
            
}

public void Init(int mapId,int zoneCells)
{
    Map.LoadMap(mapId,"../../../../../Common/MapData");

    // Zone 초기화
    ZoneCells = zoneCells;

    // Ex) 10 zoneCells
    // 1~10칸 = 1존
    // 11~20칸 = 2존
    // 21~30칸 = 3존
    int countY = (Map.SizeY + zoneCells - 1) / zoneCells;
    int countX = (Map.SizeX + zoneCells - 1) / zoneCells;
    Zones = new Zone[countY, countX];

    for(int y = 0; y < countY; y++)
    {
        for(int x = 0; x<countX; x++)
        {
            Zones[y, x] = new Zone(y, x);
        }
    }
    ...
}

EnterGame() : 처음 위치 맞는 Zone에 플레이어 배치 필요
LeaveGame() : 플레이어가 있던 Zone에서 제거 필요

Broadcast() : 내 영역의 사각형 네 모서리가 속한 Zone에 대해서만 전송


public void Broadcast(Vector2Int pos,IMessage packet)
{
    List<Zone> zones = GetAdjacentZones(pos);
    foreach(Zone zone in zones)
    {
        foreach(Player p in zone.Players)
        {
            p.Session.Send(packet);
        }
    }
    
    or

    foreach(Player p in zones.SelectMany( z => z.Players))
    {
        p.Session.Send(packet);
    }
}

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

    int[] delta = new int[2] { -cells, +cells };
    foreach(int dy in delta)
    {
        foreach(int dx in delta)
        {
            int y = cellPos.y + dy;
            int x = cellPos.x + dx;
            Zone zone = GetZone(new Vector2Int(x, y));
            if (zone == null)
                continue;

            zones.Add(zone);
        }
    }
    return zones.ToList();
}

Map

  • 플레이어가 이동함에 따라 해당 위치에 맞는 Zone 설정

Map.cs

public bool ApplyMove(GameObject gameObject, Vector2Int dest)
{
    // 오브젝트가 원래 있던 위치 비워줌
    ...
            
    // 목표 위치로 이전
    ...

    // 이동 위치에 맞는 Zone 설정
    Player p = gameObject as Player;
    if(p != null)
    {
        Zone now = gameObject.Room.GetZone(p.CellPos);
        Zone after = gameObject.Room.GetZone(dest);
        if(now != after)
        {
            if (now != null)
                now.Players.Remove(p);
            if (after != null)
                after.Players.Add(p);
        }
    }

    // 실제 좌표 변경
    posInfo.PosX = dest.x;
    posInfo.PosY = dest.y;
    return true;
}

  • 몬스터와 화살이 내 영역에서 벗어나서 동작이 멈추는 것처럼 보임

Zone - 2

플레이어의 시야각 안에 있는지에 따라 몬스터와 투사체도 보이거나 보이지 않아야 함

플레이어의 시야를 계속해서 추적하는 시야 클래스로 관리가 필요함

  • Cube, Area of Interest 등 이름이 다양함

VisionCube

플레이어마다 VisionCube로 직전에 보였던 객체들과 현재 보이는 객체를 계속해서 검사함

기존 Zone에서 이제 Player 목록 외에 Monster, Projectile 목록도 필요함

Zone.cs

// Zone에 있는 플레이어들
public HashSet<Player> Players { get; set; } = new HashSet<Player>();
// Zone에 있는 몬스터들
public HashSet<Monster> Monsters { get; set; } = new HashSet<Monster>();
// Zone에 있는 투사체들
public HashSet<Projecttile> Projecttiles { get; set; } = new HashSet<Projecttile>();

Player.cs

public VisionCube Vision { get; private set; }
...
public Player()
{
    ObjectType = GameObjectType.Player;
    Vision = new VisionCube(this);
}
...

설계

VisionCube.cs

public Player Owner { get; private set; }
// 이전 시야에서 보인 객체들
public HashSet<GameObject> PreviousObjects { get; private set; } = new HashSet<GameObject>();

public VisionCube(Player owner)
{
    Owner = owner;
}

// 현재 시야각의 객체들 긁어옴
public HashSet<GameObject> GatherObjects()
{
    if (Owner == null || Owner.Room == null)
        return null;

    HashSet<GameObject> objects = new HashSet<GameObject>();

    // 현재 Zone을 특정
    Vector2Int cellPos = Owner.CellPos;
    List<Zone> zones = Owner.Room.GetAdjacentZones(cellPos);

    foreach(Zone zone in zones)
    {
        foreach(Player player in zone.Players)
        {
            int dx = cellPos.x - player.CellPos.x;
            int dy = cellPos.y - player.CellPos.y;
            // 시야각 밖에 위치
            if (Math.Abs(dx) > GameRoom.VisionCells)
                continue;
            if (Math.Abs(dy) > GameRoom.VisionCells)
                continue;

            objects.Add(player);
        }

        foreach (Monster monster in zone.Monsters)
        {...
        }

        foreach (Projecttile projecttile in zone.Projecttiles)
        {...
        }
    }

    return objects;
}

Update

  • 내 시야에서 벗어나면 몬스터의 행동이 보이지 않음 - despawn
  • 내 시야에 다시 들어옴 - spawn

VisionCube.cs

// 이전과 현재 시야각 객체들 비교
public void Update()
{
    if (Owner == null || Owner.Room == null)
        return;

    HashSet<GameObject> currentObjects = GatherObjects();

    // 기존엔 없었는데 새로 생긴 애들 Spawn 처리
    List<GameObject> added = currentObjects.Except(PreviousObjects).ToList();
    if(added.Count > 0)
    {
        S_Spawn spawnPacket = new S_Spawn();

        foreach(GameObject gameObject in added)
        {
            ObjectInfo info = new ObjectInfo();
            info.MergeFrom(gameObject.Info);
            spawnPacket.Objects.Add(info);
        }

        Owner.Session.Send(spawnPacket);
    }
    // 기존엔 있었는데 사라진 애들 Despawn 처리
    List<GameObject> removed = PreviousObjects.Except(currentObjects).ToList();
    if (removed.Count > 0)
    {
        S_Despawn despawnPacket = new S_Despawn();

        foreach (GameObject gameObject in removed)
        {
            despawnPacket.ObjectIds.Add(gameObject.Id);
        }

        Owner.Session.Send(despawnPacket);
    }

    PreviousObjects = currentObjects;

    // 0.1초에 한번씩 검사
    Owner.Room.PushAfter(100, Update);
}

Map

Map.ApplyMove() : 타입별로 구분하여 이동 위치에 맞는 Zone 설정

Map.cs

public bool ApplyMove(GameObject gameObject, Vector2Int dest)
{
    ...
    // 이동 위치에 맞는 Zone 설정
    if (type == GameObjectType.Player)
    {
        Player player = (Player)gameObject;
        if (player != null)
        {
            Zone now = gameObject.Room.GetZone(player.CellPos);
            Zone after = gameObject.Room.GetZone(dest);
            if (now != after)
            {
                now.Players.Remove(player);
                after.Players.Add(player);
            }
        }
    }
    else if(type == GameObjectType.Monster)
    {
        Monster monster = (Monster)gameObject;
        if (monster != null)
        {
            Zone now = gameObject.Room.GetZone(monster.CellPos);
            Zone after = gameObject.Room.GetZone(dest);
            if (now != after)
            {
                now.Monsters.Remove(monster);
                after.Monsters.Add(monster);
            }
        }
    }
    else if(type == GameObjectType.Projecttile)
    {
        Projecttile projecttile = (Projecttile)gameObject;
        if (projecttile != null)
        {
            Zone now = gameObject.Room.GetZone(projecttile.CellPos);
            Zone after = gameObject.Room.GetZone(dest);
            if (now != after)
            {
                now.Projecttiles.Remove(projecttile);
                after.Projecttiles.Add(projecttile);
            }
        }
    }

    // 실제 좌표 변경
    posInfo.PosX = dest.x;
    posInfo.PosY = dest.y;
    return true;
}

GameRoom

EnterGame() : 처음 위치 맞는 Zone에 몬스터, 투사체 배치 필요
LeaveGame() : 몬스터, 투사체가 있던 Zone에서 제거 필요

이제 VisionCube에서 Spawn, Despawn 모두 보내줌

EnterGame()에서 S_Spawn 패킷을 보내줄 필요가 없어짐 -> 삭제
LeaveGame()에서 S_Despawn 패킷을 보내줄 필요가 없어짐 -> 삭제

  • Broadcast에서도 시야각 체크
public void Broadcast(Vector2Int pos,IMessage packet)
{
    List<Zone> zones = GetAdjacentZones(pos);

    foreach (Player p in zones.SelectMany(z => z.Players))
    {
        int dx = pos.x - p.CellPos.x;
        int dy = pos.y - p.CellPos.y;
        // 시야각 밖에 위치
        if (Math.Abs(dx) > GameRoom.VisionCells)
            continue;
        if (Math.Abs(dy) > GameRoom.VisionCells)
            continue;

        p.Session.Send(packet);
    }
}

  • 몬스터, 투사체가 플레이어 사방 5칸 이내에 있을 때만 보이게 됨
  • 기존 S_Spawn, S_Despawn 전송 부분 삭제로 인해 버그 존재

Zone - 디버그

버그 리스트

  1. 시작점 근처에서 Monster가 죽었을 때 HP가 0으로 부활
  2. Arrow 삭제가 되지 않음
  3. (0,0)에서 부활할 때 몬스터가 보이지 않음

1번 버그

시작점 근처에서 몬스터가 죽으면 다시 부활하면서 VisionCube에서 Update로 pre와 current의 예외처리가 되지 않음

원인 :

EnterGame()에서 S_Spawn 패킷을 보내줄 필요가 없어짐 -> 삭제
LeaveGame()에서 S_Despawn 패킷을 보내줄 필요가 없어짐 -> 삭제

  • 삭제한 부분을 다시 복구
  • 서버말고 클라이언트에서 중복 처리

Client.ObjectManager.cs

public void Add(ObjectInfo info, bool myPlayer = false)
{
	if (MyPlayer != null && MyPlayer.Id == info.ObjectId)
		return;
	if (_objects.ContainsKey(info.ObjectId))
		return;
    ...
}

public void Remove(int id)
{
	if (MyPlayer != null && MyPlayer.Id == id)
		return;
	if (_objects.ContainsKey(id) == false)
		return;
    ...
}

2번 버그

Arrow는 ApplyMove()를 통한 이동이 아니라 Update에서 직접 이동시키고 있었음
-> 충돌에 포함되지 않기 때문

  • 기존 VisionCube에서 계속 added에만 속하기 때문에 사라지지 않음

화살도 ApplyMove()로 이동하도록 처리

Arrow.cs

public override void Update()
{
	if (Data == null || Data.projecttile == null || Owner == null || Room == null)
    	return;

	int tick = (int)(1000 / Data.projecttile.speed);
	Room.PushAfter(tick, Update);

	Vector2Int destPos = GetFrontCellPos();
	if(Room.Map.ApplyMove(this,destPos, collision : false))
	{
    	S_Move movePacket = new S_Move();
	    movePacket.ObjectId = Id;
   	 	movePacket.PosInfo = PosInfo;
    	Room.Broadcast(CellPos,movePacket);
	}
    ...
}
  • 오브젝트 체크 여부, 충돌 적용 여부를 bool로 받아줌

Map.cs

public bool ApplyMove(GameObject gameObject, Vector2Int dest, bool checkObjects = true, bool collision = true)
{
    if (gameObject.Room == null)
        return false;
    if (gameObject.Room.Map != this)
        return false;

    PositionInfo posInfo = gameObject.Info.PosInfo;
    if (CanGo(dest, checkObjects) == false)
        return false;
            
    // 목표 위치로 이전
    if(collision) // 화살은 제외
    {
        // 오브젝트가 원래 있던 위치 비워줌
        {
            int x = posInfo.PosX - MinX;
            int y = MaxY - posInfo.PosY;
            if (_objects[y, x] == gameObject)
                _objects[y, x] = null;
        }
        {
            int x = dest.x - MinX;
            int y = MaxY - dest.y;
            _objects[y, x] = gameObject;
        }
    }
    ...
}

LeaveGame() : 몬스터, 투사체가 있던 Zone에서 제거 필요
-> Map.ApplyLeave()에서 관리될 수 있도록 변경

Map.cs

public bool ApplyLeave(GameObject gameObject)
{
    ...
    // Zone
    Zone zone = gameObject.Room.GetZone(gameObject.CellPos);
    zone.Remove(gameObject);
	...
}

Zone.cs

public void Remove(GameObject gameObject)
{
    GameObjectType type = ObjectManager.GetObjectTypeById(gameObject.Id);

    switch (type)
    {
        case GameObjectType.Player:
            Players.Remove((Player)gameObject);
            break;
        case GameObjectType.Monster:
            Monsters.Remove((Monster)gameObject);
            break;
        case GameObjectType.Projecttile:
            Projecttiles.Remove((Projecttile)gameObject);
            break;
    }
}

3번 버그

부활할 때 (0,0)에 누군가 있다면 ApplyMove에서 return이 돼서 충돌, Zone 처리 모두 실행되지 않게 됨

  • 정책 상 같은 칸에는 두 명이 절대 겹칠 수 없게 수정
  • 부활 좌표를 (0,0)이 아니라 랜덤으로 정해줌

GameRoom.cs

Random _rand = new Random();

public void EnterGame(GameObject gameObject, bool randomPos)
{
    if (gameObject == null)
        return;
            
    if(randomPos)
    {
        Vector2Int respawnPos;
        while (true)
        {
            respawnPos.x = _rand.Next(Map.MinX, Map.MaxX + 1);
            respawnPos.y = _rand.Next(Map.MinY, Map.MaxY + 1);
            if (Map.Find(respawnPos) == null)
            {
                gameObject.CellPos = respawnPos;
                break;
            }
        }
    }
    ...
}


대형구조 마무리

더미 클라이언트 테스트을 위한 사전 작업

맵 확장

몬스터 관리

  1. 몬스터가 많이 스폰되게 처리
    GameRoom.cs
public void Init(int mapId,int zoneCells)
{
	...
    // Test
    for(int i=0;i<500;i++)
    {
        Monster monster = ObjectManager.Instance.Add<Monster>();
        monster.Init(1);

        EnterGame(monster, randomPos: true);
    }            
}
  1. 몬스터들이 서로서로의 충돌을 감안하지 않고 플레이어를 쫒아감
    -> Map.FindPath의 충돌 검사 여부를 켜줌

Monster.UpdateMoving()

...
List<Vector2Int> path =  Room.Map.FindPath(CellPos, _target.CellPos,checkObject: true);
if(path.Count < 2 || path.Count > _chaseCellDist)
{
    _target = null;
    State = CreatureState.Idle;
    BroadcastMove();
    return;
}
...
  1. 플레이어의 사방이 막혔을 때 몬스터들이 더 이상 진행하지 않음
    -> 길을 못 찾았을 때 갈 수 있는 곳 중 그나마 플레이어에게 가장 가까운 곳으로 이동

Map.cs

List<Vector2Int> CalcCellPathFromParent(Dictionary<Pos,Pos> parent, Pos dest)
{
    List<Vector2Int> cells = new List<Vector2Int>();

    // Player에게 갈 수 있는 길이 없는 경우
    if(parent.ContainsKey(dest) == false)
    {
        // 갈 수 있는 곳 중 그나마 플레이어에게 가장 가까운 곳으로 이동
        Pos best = new Pos();
        int bestDist = Int32.MaxValue;

        foreach(Pos pos in parent.Keys)
        {
            int dist = Math.Abs(dest.X - pos.X) + Math.Abs(dest.Y - pos.Y);
            // 제일 우수한 후보를 뽑는다
            if(dist < bestDist)
            {
                best = pos;
                bestDist = dist;
            }
        }
        // 목적지를 우수한 후보로 변경
        dest = best;
    }
    {
        Pos pos = dest;

        while (parent[pos] != pos)
        {
            cells.Add(Pos2Cell(pos));
            pos = parent[pos];
        }
        cells.Add(Pos2Cell(pos));
        cells.Reverse();

        return cells;
    }
}

종료 체크

클라가 강제 종료를 했을 때, 서버는 클라가 활동 중인지 연결이 끊긴 것인지 확인이 불가함

  • 서버에서 주기적으로 클라가 강제 종료를 했는지 검사해 주어야 함

패킷 설계

  1. 서버에서 클라에게 물어봄 - S_Ping
  2. 클라가 서버에게 대답함 - C_Pong

Protocol.proto

enum MsgId {
  ...
  S_PING = 21;
  C_PONG = 22;
}
// 내용은 없어도 됨
message S_Ping{
}

message C_Pong{
}

Ping

ClientSession.cs

...
// 클라에서 최근 마지막으로 응답을 준 시간
long _pingpongTick = 0;
public void Ping()
{
	if(_pingpongTick > 0)
    {
		long delta = (System.Environment.TickCount64 - _pingpongTick);
		// 마지막 응답부터 30초가 지남
		if(delta > 30 * 1000)
        {
            Console.WriteLine("Disconnected by PingCheck");
			Disconnect();
			return;
        }
    }

	// 응답 통과
	S_Ping pingPacket = new S_Ping();
	Send(pingPacket);

	GameLogic.Instance.PushAfter(5000, Ping);
}

public void HandlePong()
{
	_pingpongTick = System.Environment.TickCount64;
}
...
public override void OnConnected(EndPoint endPoint)
{
	Console.WriteLine($"OnConnected : {endPoint}");

    // 클라에게 연결 됐다고 알려줌
    {
		S_Connected connectedPacket = new S_Connected();
		Send(connectedPacket);
    }

	GameLogic.Instance.PushAfter(5000, Ping);
}

Server.PacketHandler.cs

public static void C_PongHandler(PacketSession session, IMessage packet)
{
	ClientSession clientSession = (ClientSession)session;
	clientSession.HandlePong();
}

Pong

Client.PacketHandler.cs

public static void S_PingHandler(PacketSession session, IMessage packet)
{
    C_Pong pongPacket = new C_Pong();
    Debug.Log("[Server] PingCheck");
    Managers.Network.Send(pongPacket);
}

  • 라이브 출시를 위해서 꼭 필요한 기능
  • 다만, 개발 단계에서는 디버깅에 불편할 수도 있음

0개의 댓글