20. Job #1
지난번까지 이동동기화를 해봤다.
테스트해보면 알겠지만 완성도 높지 않다. 충돌이 있으면 클라 상에서의 내위치랑 다른사람이 보는 내 위치가 달라지는 문제가 발생하기 때문. 이동 동기화는 이것저것 보정이 많이 필요하다고 한다.
이번엔 이것보다 더 중요한 주제.
MMO, 오픈월드의 가장 기본기이자 핵심이라고 한다.
지금까지 우리가 만든 서버를 살펴보면 서버 구조가 어느정도 기억이 나야한다.
컨텐츠를 뚝딱 만드는건 큰 의미 없다. 서버구조가 어떻게 돌아가는지 아는게 중요하다.
asio? 를 사용하면 내부적으로는 iocp로 돌아갈 것.
게임 로직이 어떻게 실행되고있는지 이해하는게 중요하다.
이동을 하던 게임 입장을 하던 시작은 네트워크 입력을 받아서 패킷 전송받으면 그걸 조립해서 패킷핸들러로 어떤함수를 실행하는것이 중요하다.
지금은 네트워크 패킷이 들어올때 컨텐츠를 실행하는식인데, 이게 완벽하지가 않다 이걸로만 하면 게임이 만들어지기 힘들다.
나중에 NPC를 넣거나 몬스터를 넣을거면 그건 어떻게 처리해야할까? 지금은 패킷이 오고 갈때만 컨텐츠가 실행되는데?
그래서 일반적인 게임로직을 처리해줄 수 있어야한다 (게임스레드). 몬스터 움직임 이런거를 처리하고 전송 해줄 담당이 필요하다 이말.
예를 들어 이렇게 할 수도 있을것이다. 메인스레드에서 0.1초에 한번씩 GRoom->Update를 호출해줘서 처리해주는 것.
int main()
{
cout << "=== Game Sever ===" << endl;
ServerPacketHandler::Init();
ServerServiceRef service = make_shared<ServerService>(
NetAddress(L"127.0.0.1", 7777),
make_shared<IocpCore>(),
[=]() { return make_shared<GameSession>(); }, // TODO : SessionManager 등
100);
ASSERT_CRASH(service->Start());
for (int32 i = 0; i < 5; i++)
{
GThreadManager->Launch([&service]()
{
DoWorkerJob(service);
});
}
while (true)
{
this_thread::sleep_for(0.1s);
GRoom->Update();
}
GThreadManager->Join();
}
근데 문제는 그 다음이다. 게임로직 처리를 할 때 lock을 잡고 할텐데, 단순작업이면 괜찮지만 인공지능이나 복잡한건 한다거나. 브로드캐스팅으로 주변의 모든 애들에게 뿌린다거나 하는 것들이 많아지면 서버의 부담이 커지고 lock을 잡는 시간이 길어진다.
사람들이 많아질때 lock을 엄청 많이하니 거기서 서버가 멈춰있는 시간이 너무 길어지는 것.
특히 광역 도트데미지를 넣으면 심각해졌다고.
문제되는게 네트워크 스레드들이 컨텐츠단까지 처리하는게 문제니까, 게임스레드에서 세션의 것을 꺼내서 처리하면 더 나았다고 했다. 근데 이렇게 해도 완벽한 해답은 아니었다고.
모든것을 Job으로 만들어서 락걸고 넣고 락풀고 이런식으로 하면 좋다고 한다.
예를 들어 클라이언트가 EnterGame 패킷을 보냈다고 치자.
지금은 네트워크 스레드가 패킷 조립을 하고 HandleEnterPlayerLocked를 실행한다.
근데 이제 그렇게 하는게 아니라 HandleEnterPlayerLocked를 펑터로 만들어 다른스레드가 그대로 실행할 수 있게 하는 것. (내가 인증서버에서 했던거네...?)
네트워크 스레드가 직접 컨텐츠코드를 실행하는게 아니라 JobQueue에 이런 일감들을 싹 집어넣고, 처리하는것을 앞으로 사용해보자.
21. Job #2
중요한건 락을 걸었을때 왜 렉이걸리는지 이해하는것.
왜냐? 스레드가 많아졌을 때 Room의 lock을 걸면 room에 대한 작업을 하나의 스레드만 할 수 있다.
그런데 이제 수정될 코드에서든 Lock을 잡지 않는다!
Job이라는 개념과 Lock이라는 개념을 동시에 사용하면 안된다. GetSet 할때만 빼고. 안그러면 잡을 사용하는 의미가 없다
우선 Room 클래스의 모든 LOCK을 제거해준다. 전부 제거!
그리고 Room이 JobQueue를 상속받도록 변경한다. (enable_shared_from_this는 jobqueue가 상속받았음)
bool HandleEnterPlayer(PlayerRef player);
bool HandleLeavePlayer(PlayerRef player);
void HandleMove(Protocol::C_MOVE& pkt);
락 이 붙어있던 함수들도 이제 락 안쓸거니까 locked를 이름에서 빼주자
그리고 중요한게 이제 네트워크 함수에서 직접적으로 해당 함수들을 호출하면 안된다.
지금 코드는 보면 직접적으로 호출하고있다.
bool Handle_C_ENTER_GAME(PacketSessionRef& session, Protocol::C_ENTER_GAME& pkt)
{
//플레이어 생성
PlayerRef player = ObjectUtils::CreatePlayer(static_pointer_cast<GameSession>(session));
//방에 입장
GRoom->HandleEnterPlayer(player);
return true;
}
이것을 이렇게 변경한다.
bool Handle_C_ENTER_GAME(PacketSessionRef& session, Protocol::C_ENTER_GAME& pkt)
{
//플레이어 생성
PlayerRef player = ObjectUtils::CreatePlayer(static_pointer_cast<GameSession>(session));
//방에 입장
GRoom->DoAsync(&Room::HandleEnterPlayer, player);
//GRoom->HandleEnterPlayer(player);
return true;
}
(잡큐의 DoAsync의 동작방식은 나중에 자세히 확인해보자)
나머지 핸들러 함수에서도 직접 사용하는 부분을 위와 같이 변경해준다.
bool Handle_C_LEAVE_GAME(PacketSessionRef& session, Protocol::C_LEAVE_GAME& pkt)
{
/*...*/
room->DoAsync(&Room::HandleLeavePlayer, player);
//room->HandleLeavePlayer(player);
}
bool Handle_C_MOVE(PacketSessionRef& session, Protocol::C_MOVE& pkt)
{
/*...*/
room->DoAsync(&Room::HandleMove, pkt);
//room->HandleMove(pkt);
}
그런데 JobQueue의 DoAsync 함수는 잡을 넣어주는 역할만 한다. 그럼 누가 일감을 처리할까?
이전강의에서 메인스레드에서 Update 를 넣어볼까? 했던것처럼 JobQueue의 Execute를 실행하면 되지 않을까? 할 수 있다.
하지만 이런 방식은 스레드를 애초에 네트워크용 스레드, 컨텐츠코드 실행용 로직 스레드을 완전히 나눠놓는 것과 똑같다. 근데 이러면 Room이 많아졌을 때
손이 비면 실행할 수도 있어야하고 execute를 누군가 담당해서 처리해야하는데 만약 1000개가 있다하면 균일하게 놀지않고 하나의 room에서 난리친다. 그러면 업무분담이 일치하지 않는다. 예상해서 스레드배분은 힘들다.
만약 스레드 20개 쓸수있으면 네트워크/컨텐츠/db 이렇게 나눠쓸건데 뭐 8 8 4개 이렇게 나눠쓴다 하면 필연적으로 어디는 바쁘게 돌아가는데 어디는 놀고있을것이다. 그럼 가장 좋은건 유동적으로 처리되는건데, 그래서 스레드 정책을 고민해야하는데, 서버강의에서 만든 코드를 보면 Job을 push할 때 첫번째로 넣은 애가 실행까지 담당하도록 만들어져있다.
그래서 굳이 밖에서 누군가 담담스레드를 둬서 execute함수를 실행하고있을 필요 없고, 일감을 처음 넣은애가 게임로직을 처리한다. 일처리하다가 일이 더 들어오면 그것들도 처리한다.
지금 코드에서 이해해보자면
room->DoAsync로 HandleMove를 처리한다 하면, 해당 Room에 Move패킷을 가장 첫번째로 처리한 스레드가 잡을 넣고, 바로 그 잡을 처리하는데, 도중에 이 방에 대한 move패킷이 더 와서 잡에 쌓이면 그것까지 마치는 것. 다른 스레드들은 그냥 잡을 넣고 나오고. 이렇게 하면 잡을 처리하고 있는 동안 lock을 붙잡고 있지 않기 때문에 다른 스레드가 기다리지 않아도 된다.
이 방법이 어려우면 push만 하고 나오고, execute만 하는 스레드를 담당하는것도 괜찮다.
그런데 만약 렉이걸리는 상황을 생각해보자.
실행중에 누군가 일감을 계속 밀어넣으면 그 스레드는 해당 room에 해당하는 일만 계속 하게될것이다. 그런데 만약 room이 수천개가 된다면 어떤 room만 돌아가고있고 다른 room은 처리가 안되는 상황이 생길 수도 있다. 그래서 일처리를 하다가 시간이 너무 많이 지나면 while문에서 탈출해서 다른 일처리도 할 수 있게 만들어져 있다.
// 1) 일감이 너~무 몰리면?
void JobQueue::Execute()
{
LCurrentJobQueue = this;
while (true)
{
vector<JobRef> jobs;
_jobs.PopAll(OUT jobs);
const int32 jobCount = static_cast<int32>(jobs.size());
for (int32 i = 0; i < jobCount; i++)
jobs[i]->Execute();
// 남은 일감이 0개라면 종료
if (_jobCount.fetch_sub(jobCount) == jobCount)
{
LCurrentJobQueue = nullptr;
return;
}
const uint64 now = ::GetTickCount64();
if (now >= LEndTickCount)
{
LCurrentJobQueue = nullptr;
// 여유 있는 다른 쓰레드가 실행하도록 GlobalQueue에 넘긴다
GGlobalQueue->Push(shared_from_this());
break;
}
}
}
지금 테스트해보니 에러가 뮤텍스 중복 에러가 발생했는데 LockQueue.h에서 다음과같이 수정해야한다.
강의에서는 했는데 내가 안해서 생긴 문제.
#pragma once
template<typename T>
class LockQueue
{
public:
/*...*/
T PopNoLock()
{
if (_items.empty())
return T();
T ret = _items.front();
_items.pop();
return ret;
}
void PopAll(OUT vector<T>& items)
{
WRITE_LOCK;
while (T item = PopNoLock())
items.push_back(item);
}
/*...*/
};
추가 : jobqueue에 dotimer는 뭐냐?
당장 실행하는게 아니라 몇 초 후에 실행해야 한다는 등 예약처리 하고싶을 때 Timer가 들고있다가 분배하는(?) 식으로 처리되어있다고 한다. (JobQueue쪽은 다시 만들때 제대로 확인해야 할 듯 하다.)
아무튼 현업에서는 락을걸고 처리하는게 아니라 job이라는 개념을 사용해서 사용한다.
개인적으로 한스레드만 처리해도 괜찮은가? 생각을 해봤는데 원래 lock을 걸고 해야하는 작업이었으니 어차피 한번에 한 스레드가 하던 일이긴 하다. 그래서 상관 없을듯?
22. Job #3
이런 코드들을 보다보면 나올 의문이 있다. 여러 스레드가 일감 전달을 하긴 하지만 한 스레드만 일처리를 한다 이건 괜찮은가?
room이 엄청 크다면 일감이 많을텐데 다른 스레드가 도울 수 없을까? 근데 이게 이렇게 하기 엄청 힘들다. 당연히 오픈월드를 하나의 room처럼 1개의 스레드가 처리하기엔 불가능할테니 구역을 분리해서 처리해야겠지만 그 경계선을 어떻게 둘지가 문제다.
만약 게임을 하고있는데 게임 맵 경계선이 매우 명확하면 쉽겠지만 보통 그렇지 않다. 누가 무엇을 어디까지 담당할지 관리하는게 매우 어렵다고 한다. 결국 처리해야할 지역?이 겹치게 되면 lock을 걸어야 하니까 스레드를 늘리는건 똑같은 문제를 야기하기 때문.
어딘가에서는 그때그때 처리해야할 영역을 이쁘게 구분해서 스레드에 배분시켰다고 하는데 강의자는 비현실적인 소문에 불과하다 생각한다고.
업데이트 한번 하는 정도는 부담 안되는데 길찾기나 인공지능같은걸 실행하는게 어려운 부분이다(?).
애들끼리 뭔가 간단하게 하는건 큰 문제 안된다고 (?). cpu성능이라는게 생각보다 좋아서 동적 5000명을 상상해보면 그정도는 충분하다 오히려 db나 network가 못버틴다고.
게임마다 다른건데 강의자가 참여했던 게임중에는 표면적으로는 심리스처럼 이어져 보이는데 구역마다 로딩하는 구역이 있어서 안개를 끼워서 지나가는 사이 로딩을 해서 슥 다른 room으로 이동하게 만들었다고. 우리가 만든거에서는 각 지역마다 하나의 스레드가 담당할 것. (일이 없으면 아예 스레드도 안붙겠지만) 이정도만 해도 생각보다 많은걸 할 수 있다고 한다.
만약 하나의 스레드가 큰 단위를 담당한다면 이상한건 하면 안된다. 괜히 A* 길찾기를 한다거나 인공지능 돌린다거나 하는 무거운 작업.
이런 방식은 영역별로 나눠서 하는거고, 다른 방식도 있다.
구역이라는 개념이 사실상 없고, actor 단위로 jobqueue를 관리하는 방법.
모든 애들에게 jobqueue를 붙여줘서 각 유저에게 스레드가 붙어서 실행되는 장점이 있다. 그럼 영역구별이 필요없다. 근데 주변에 누가 있는지 알아야한다면 가상의 영역을 둬서 캐싱해서 쓴다. 근데 캐싱과정에서도 데이터레이스가 발생할 수 있으니 매우 빠르게 힙에 있는 데이터를 TLS에 복사해서 캐싱 데이터를 각자 들고 쓰는경우도 있다고.
이 방법도 문제가 있는데 (강의자는 이 방법은 다시 안한다 함 ㅋㅋ) 컨텐츠 개발 난이도가 매우 어렵다고. 만약 A가 B를 공격했다 하면 모든 액터가 별도스레드로 돌아가기때문에 개발난이도가 너무 높아진다고. 강의자는 이 방법은 실패한 방법이라 생각한다 함.
강의자는 만약 자기가 리드개발자가 돼서 서버를 만들어야한다면 하나의 스레드가 큰영역을 담당하게 할거라 한다. 이렇게해도 동접 500명은 버틴다고. 이정도로 만들어도 포폴로 충분할거라고.
그외
- do timer를 사용해서 반복되는 함수실행 방법
22. 계층 구조 정리
서버에서 지난번에 만들었던걸 살펴보자.
플레이어가 있고 플레이어를 갖다가 쓰고있다.
pvp는 괜찮은데 레이드 만들려니 문제생겼다고 한다.
온라인게임 네트워크 기반으로 하면 패킷이 왔을 때 연산이 시작된다. 네트워크 -> 핸들패킷 -> 컨텐츠코드.
반대로 몬스터 npc 등은 어떻게 처리하느냐? 그게 고민이었다. 방법은 여러가지. room마다 update tick을 둬서 매 틱마다 호출하게 하거나 컨텐츠 코드를 네트워크를 타지않고도 누군가가 하게 만들어준다거나.
한번 만들어보자.
class Room : public JobQueue
{
public:
/*...*/
void HandleMove(Protocol::C_MOVE pkt);
void UpdateTick();
RoomRef GetRoomRef() { return static_pointer_cast<Room>(shared_from_this()); }
private:
/*...*/
};
void Room::UpdateTick()
{
cout << "Update Room" << endl;
// TODO
DoTimer(100, &Room::UpdateTick);
}
우선 로그만 출력해보고 이걸 매 프레임마다 돌게 하고싶지만 클라마냥 60 200프레임 이렇게 도는건 말이 안되고, 0.1초로 설정해봤다. JobQueue클래스의 DoTimer를 이용해서.
그리고 메인스레드에서 한번만 호출해주면 되는 식.
int main()
{
/*...*/
for (int32 i = 0; i < 5; i++)
{
GThreadManager->Launch([&service]()
{
DoWorkerJob(service);
});
}
GRoom->DoAsync(&Room::UpdateTick);
/*...*/
GThreadManager->Join();
}

서버를 켜보면 설정한대로 0.1초마다 UpdateRoom이 호출된다.
DoAsync로 하는 이유는 내가 일감을 처음으로 넣는거면 실행하고 아니면 jobqueue넣고 마니까 안전하기 때문.
이부분은 그렇다 치고 NPC에도 UpdateTick 비슷하게 만들면 된다고 한다. 언리얼에서도 Start함수랑 Tick함수로 다 할 수 있는 것 처럼. 많이 만들어봐야 한다.
어느날 몬스터를 만들어야겠다 생각이 들면 가장먼저 해야할건 protocol을 정의하는게 일반적인 수순이다. 어떤 변수로 어떻게 설계할지 러프하게 생각하고 프로토콜을 만들어주는것. 근데지금은 PlayerType, MoveState밖에 없다. 그럼 MonsterType도 추가해야하는 것이냐? Monster 클래스도 추가하고? 그럼 Move패킷이 지금 plater를 인자로 받는데 몬스터를 인자로 받는 버전도 만들고? 이렇게 생각하다보면 좀 아니다 라는 생각이 들것이다.
이런걸 가장 깔끔하게 관리하는게 상속이다. 최상위 객체 하나를 만들어서 그걸로부터 파생된 객체들로 player, monster 등등 하는것.
Enum.proto
enum ObjectType
{
OBJECT_TYPE_NONE = 0;
OBJECT_TYPE_CREATURE = 1;
OBJECT_TYPE_PROJECTILE = 2;
OBJECT_TYPE_ENV = 3;
}
enum CreatureType
{
CREATURE_TYPE_NONE = 0;
CREATURE_TYPE_PLAYER = 1;
CREATURE_TYPE_MONSTER = 2;
CREATURE_TYPE_NPC = 3;
}
enum PlayerType
{
PLAYER_TYPE_NONE = 0;
PLAYER_TYPE_KNIGHT = 1;
PLAYER_TYPE_MAGE = 2;
PLAYER_TYPE_ARCHER = 3;
}
enum MoveState
{
MOVE_STATE_NONE = 0;
MOVE_STATE_IDLE = 1;
MOVE_STATE_RUN = 2;
MOVE_STATE_JUMP = 3;
MOVE_STATE_SKILL = 4;
}
Object와 Creature타입이 추가됐고 MoveState에 Skill도 추가했다.
또 수정할것들이 있다. 지금은 Spawn, Move에서 PlayerInfo를 넣어서 패킷을 전달하는데 이제 플레이어만 있는게 아니니까 다른 것들의 움직임도 넣어야하지 않을까 생각이 된다. 이렇게 설계를 대충하고 넘어가면 제대로 고쳐나가야한다.
이런것들은 정답이 있는건 아니지만 어떻게 하면 코드를 이쁘게 짤 수 있을까 고민을 해봐야한다. 포폴 만들때야 하나에 때려넣고 가도 되겠지만 진지하게 한다면 고민해봐야할 문제.
아무튼 지금은 플레이어인포라는 이름으로 불리는게 좀 애매해졌다. PosInfo로 변경해보자. 그리고 오브젝트 인포를 만들어 posinfo를 들고있고, 추가적인 정보를 넣자.
message PosInfo
{
uint64 object_id = 1;
float x = 2;
float y = 3;
float z = 4;
float yaw = 5;
MoveState state= 6;
}
message ObjectInfo
{
uint64 object_id = 1;
ObjectType type = 2;
PosInfo pos_info = 3;
}
이제 struct 이름이 바뀌었으니 protocol도 바꿔야한다. 아래는 변경사항이 있는것만 적었다.
message S_LOGIN
{
bool success = 1;
repeated ObjectInfo players = 2;
}
message S_ENTER_GAME
{
bool success = 1;
ObjectInfo player = 2;
}
message S_SPAWN
{
repeated ObjectInfo players = 1;
}
message C_MOVE
{
PosInfo info = 1;
}
message S_MOVE
{
PosInfo info = 1;
}
move는 원래 playerinfo를 담아보냈는데 굳이 objectinfo 전체를 보낼 필요는 없으니까 PosInfo만 보낸다.
이제 서버를 다시 빌드해보면 엄청난 오류가 뜬다. 구조체를 바꿨으니 당연. 그러니까 처음부터 패킷 설계를 이쁘게 잘 해두면 좋을 것이다. 일단 PlayerInfo 쓰는건 대부분 ObjectInfo로 바꾸고 Move쪽만 PosInfo로 바꾸자. 대충 알잘딱으로. 그리고 지금은 Player안에 ObjectInfo가 있는데 Object 클래스를 만들고 그걸 상속받은 Player가 있는 식으로 바꿔야 할 것이다.
근데 지금 ObjectInfo 안에 PosInfo라는 객체가 또 들어있는데, 마치 구조체 안에 구조체 포인터가 들어가있는 형태라 얕은복사 문제에 주의해야한다.(?? 테스트를 해봐야 할듯 함)
ObjectInfo 에 PosInfo를 적지 않고 보내면 어떻게 될까? posinfo도 나름 수십바이트짜리 크기인데 패킷에 쓸모없는 데이터가 많이 담기게 되지는 않을까? 그런데 아니라고 한다. 프로토버프에서 데이터가 안담겨있으면 알아서 헤더에 그런 정보를 적어놔서 괜찮다고 함. 그래서 ObjectInfo에 MosterInfo , NPCInfo, Achievment, BattleLog PvPHistory... 등등 옵션처럼 포함시키고, 없으면 그냥 안넣고 있으면 넣어서 보내는 식으로 사용할 수 있다고 한다. 안넣으면 메모리차지를 안하니까. 사용하기도 편하고.
무튼 정상적으로 동작하게 서버와 클라이언트 코드들을 다 바꿔주자.
서버와 클라이언트를 다 켰을때 제대로 안되길래 문제가 뭔지 파악해봤는데 move에는 PosInfo만 보내는데 PosInfo에 object_id를 안적어보냈기 때문이었다. PlayerInfo에는 id가 잘 적혀있는데 그 안에 PosInfo에는 안적혀있으니 spawn할때 objectid를 posinfo에도 적어놓으면 좋을 것 같다.
이제 서버에서 오브젝트 클래스를 만들고 크리쳐, 몬스터 클래스도 만들어보자.

플레이어만 원래 있던거고 나머진 새로 만들었다.
class Object : public enable_shared_from_this<Object>
{
public:
Object();
virtual ~Object();
bool IsPlayer() const { return _isPlayer; }
public:
Protocol::ObjectInfo* objectInfo;
public:
atomic<weak_ptr<Room>> room;
protected:
bool _isPlayer = false;
};
Object::Object()
{
objectInfo = new Protocol::ObjectInfo();
}
Object::~Object()
{
delete objectInfo;
}
class Creature : public Object
{
public:
Creature();
virtual ~Creature();
};
Creature::Creature()
{
objectInfo->set_type(Protocol::ObjectType::OBJECT_TYPE_CREATURE);
}
Creature::~Creature()
{
}
class Player : public Creature
{
public:
Player() { _isPlayer = true; }
virtual ~Player() {}
weak_ptr<GameSession> session;
};
class Monster : public Creature
{
public:
Monster() {}
virtual ~Monster() {}
};
원래 Player에 몰빵되어있던 걸 상위클래스를 만든 뒤 옮겨주었다.
이제 관심사는 room 에서 이 부분이다.
class Room : public JobQueue
{
/*...*/
private:
void Broadcast(SendBufferRef sendBuffer, uint64 exeptId = 0);
unordered_map<uint64, PlayerRef> _players;
unordered_map<uint64, MonsterRef> _monsters;
//unordered_map<uint64, ObjectRef> _objects;
};
방에 있는 플레이어와 몬스터를 위와같이 나누는게 좋을까? 아니면 주석된 부분처럼 Object로 하나로 합칠 수 있으니 하나로 관리하는게 좋을까? 장단이 있다.
- objects 한 덩어리로 관리하기
~Player ~Monster ~NPC 이런 함수를 하나하나 만들 필요없이 Object하나로 퉁칠수있으니 편하다.
브로드캐스팅 같은걸 보면 플레이어에게만 보내야하는데, 몬스터는 제외하도록 또 안에서 체크해야하는 코스트 발생한다. - 따로따로 관리하기
1과 반대 느낌쓰.
강의자는 1번 방법을 사용했다.
class Room : public JobQueue
{
public:
Room();
virtual ~Room();
bool HandleEnterPlayer(PlayerRef player);
bool HandleLeavePlayer(PlayerRef player);
void HandleMove(Protocol::C_MOVE pkt);
void UpdateTick();
RoomRef GetRoomRef() { return static_pointer_cast<Room>(shared_from_this()); }
private:
bool AddObject(ObjectRef object);
bool RemoveObject(uint64 objectId);
private:
void Broadcast(SendBufferRef sendBuffer, uint64 exeptId = 0);
unordered_map<uint64, ObjectRef> _objects;
};
player로 쓰던 부분을 Object로 바꾸고 Enter, LeavePlayer 함수는 object와 어울리게 이름을 바꿔줬다.
그리고 멤버함수 안에서 player로 쓰던 부분도 object로 변경해야한다.
void Room::HandleMove(Protocol::C_MOVE pkt)
{
const uint64 objectId = pkt.info().object_id();
if (_objects.find(objectId) == _objects.end())
return;
ObjectRef& object = _objects[objectId];
object->objectInfo->mutable_pos_info()->CopyFrom(pkt.info());
{
Protocol::S_MOVE movePkt;
{
Protocol::PosInfo* info = movePkt.mutable_info();
info->CopyFrom(pkt.info());
}
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(movePkt);
Broadcast(sendBuffer);
}
}
bool Room::AddObject(ObjectRef object)
{
//이미 방에 있으면 문제있는 상황
if (_objects.find(object->objectInfo->object_id()) != _objects.end())
return false;
_objects.insert(make_pair(object->objectInfo->object_id(), object));
object->room.store(GetRoomRef());
return true;
}
bool Room::RemoveObject(uint64 objectId)
{
if (_objects.find(objectId) == _objects.end())
return false;
ObjectRef player = _objects[objectId];
player->room.store(weak_ptr<Room>());
_objects.erase(objectId);
return true;
}
void Room::Broadcast(SendBufferRef sendBuffer, uint64 exeptId)
{
for (auto& item : _objects)
{
PlayerRef player = dynamic_pointer_cast<Player>(item.second);
if (player == nullptr)
return;
if (player->objectInfo->object_id() == exeptId)
continue;
if (GameSessionRef session = player->session.lock())
session->Send(sendBuffer);
}
}
브로드캐스트 부분의 경우는 플레이어에게만 보내야하니까 다이나믹 캐스트를 사용했다.
단 다이나믹캐스트는 스태틱캐스트에 비해 무겁다 하니 내부에서 enum같은걸로 타입을 들고있으면 스태틱캐스트로 캐스팅하고 enum으로 플레이어인지 확인하는게 좋다고 함.
오늘 핵심은 프로토콜에서 ObjectInfo 안에 PosInfo 넣는 식으로 관리하면 편한데, 이 설계를 미리 잘 해놔야 개고생을 안한다! 라는 것.
22. 마무리
지난번에 코드변경 이후에 이동하지 않는 버그가 있다고 한다.
나는 이미 문제 파악했고 대충 고쳐놨다. 근데 진짜 대충고쳐놓은 관계로 강의에서 고치는대로 수정했다.
void AS1MyPlayer::Tick(float DeltaTime)
{
/*...*/
if (MovePacketSendTimer <= 0 || ForceSendPacket)
{
MovePacketSendTimer = MOVE_PACKET_SEND_DELAY;
Protocol::C_MOVE MovePkt;
//현재 위치 정보
{
Protocol::PosInfo* Info = MovePkt.mutable_info();
Info->CopyFrom(PlayerInfo->pos_info());
//Info->set_object_id(PlayerInfo->object_id()); 여기 넣었었는데 제거하자.
Info->set_yaw(DesiredYaw);
Info->set_state(GetMoveState());
}
SEND_PACKET(MovePkt);
}
}
강의에서 수정한 부분. 내가한거보다 근본적인 해결이다.
PlayerRef ObjectUtils::CreatePlayer(GameSessionRef session)
{
//ID 생성기
const int64 newId = s_idGenerator.fetch_add(1);
PlayerRef player = make_shared<Player>();
player->objectInfo->set_object_id(newId);
player->objectInfo->mutable_pos_info()->set_object_id(newId);
player->session = session;
session->player.store(player);
return player;
}
이외에도 CopyFrom을 쓸 때의 에러도 해결한다. 이것도 이전에 이미 해결했다.
그리고 추가적으로 Room을 보면 이런식으로 있는데 플레이어만 처리하는게 아니라 Object로 통합시키자.
class Room : public JobQueue
{
public:
Room();
virtual ~Room();
bool HandleEnterPlayer(PlayerRef player);
bool HandleLeavePlayer(PlayerRef player);
void HandleMove(Protocol::C_MOVE pkt);
/*...*/
};
EnterRoom과 LeaveRoom을 추가하고 코드를 수정한다.
class Room : public JobQueue
{
/*...*/
public:
bool EnterRoom(ObjectRef object, bool randPos = true);
bool LeaveRoom(ObjectRef object);
bool HandleEnterPlayer(PlayerRef player);
bool HandleLeavePlayer(PlayerRef player);
/*...*/
};
bool Room::EnterRoom(ObjectRef object, bool randPos /*= true*/)
{
bool success = AddObject(object);
//방에 입장하면 랜덤 위치에 스폰됨
if (randPos)
{
object->objectInfo->mutable_pos_info()->set_x(Utils::GetRandom(0.f, 1000.f));
object->objectInfo->mutable_pos_info()->set_y(Utils::GetRandom(0.f, 1000.f));
object->objectInfo->mutable_pos_info()->set_z(100.f);
object->objectInfo->mutable_pos_info()->set_yaw(Utils::GetRandom(0.f, 100.f));
}
// 입장 사실을 신입 플레이어에게 알린다.
if (auto player = dynamic_pointer_cast<Player>(object))
{
Protocol::S_ENTER_GAME enterGamePkt;
enterGamePkt.set_success(success);
enterGamePkt.mutable_player()->CopyFrom(*(player->objectInfo));
//Protocol::ObjectInfo* playerInfo = new Protocol::ObjectInfo();
//playerInfo->CopyFrom(*(player->playerInfo));
//enterGamePkt.set_allocated_player(playerInfo);
//enterGamePkt.releasse_player();
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(enterGamePkt);
if (auto session = player->session.lock())
session->Send(sendBuffer);
}
// 입장 사실을 다른 플레이어에게 알린다.
{
Protocol::S_SPAWN spawnPkt;
Protocol::ObjectInfo* objectInfo = spawnPkt.add_players();
objectInfo->CopyFrom(*object->objectInfo);
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(spawnPkt);
Broadcast(sendBuffer, object->objectInfo->object_id());
}
// 기존에 입장해 있던 플레이어 목록을 신입에게도 보내준다.
if (auto player = dynamic_pointer_cast<Player>(object))
{
Protocol::S_SPAWN spawnPkt;
for (auto& item : _objects)
{
Protocol::ObjectInfo* playerInfo = spawnPkt.add_players();
playerInfo->CopyFrom(*item.second->objectInfo);
}
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(spawnPkt);
if (auto session = player->session.lock())
session->Send(sendBuffer);
}
return success;
}
bool Room::LeaveRoom(ObjectRef object)
{
if (object == nullptr)
return false;
const uint64 objectId = object->objectInfo->object_id();
bool success = RemoveObject(objectId);
// 퇴장 사실을 퇴장하는 플레이어에게 알린다.
if (auto player = dynamic_pointer_cast<Player>(object))
{
Protocol::S_LEAVE_GAME leaveGamePkt;
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(leaveGamePkt);
if (auto session = player->session.lock())
session->Send(sendBuffer);
}
// 퇴장 사실을 알린다.
{
Protocol::S_DESPAWN despawnPkt;
despawnPkt.add_object_ids(objectId);
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(despawnPkt);
Broadcast(sendBuffer, objectId);
if (auto player = dynamic_pointer_cast<Player>(object))
if (auto session = player->session.lock())
session->Send(sendBuffer);
}
}
bool Room::HandleEnterPlayer(PlayerRef player)
{
return EnterRoom(player, true);
}
bool Room::HandleLeavePlayer(PlayerRef player)
{
return LeaveRoom(player);
}
지금은 spawn같은것들도 플레이어 전용으로 만들어져있기 때문에 몬스터나 NPC도 넣을거면 제대로 처리할 수 있게 이런 작업을 protocol부터 시작해서 해야 할 것이다. 이 역시 설계를 잘 해놓고 만드는게 편할 것이다.
'강의 수강 > 게임서버(2)' 카테고리의 다른 글
| 4. 이동 동기화 (0) | 2026.03.19 |
|---|---|
| 3. 입장과 퇴장 (0) | 2026.01.19 |
| 2. 서버 연동 기초 (0) | 2026.01.10 |
| 01. OT ~ 05.복습#4 (0) | 2026.01.06 |