지난 강의 버그 해결

- genpacket.bat 을 실행했을 때 언리얼 - network쪽에 복붙이 제대로 안되는 문제

  경로에 ..을 ...으로 잘못쓴 오타, 경로뒤에 \ 붙여줘서 디렉토리임을 알려주지 않았기 때문.

- 서버코드에서 뮤텍스 락 부분을 커스텀락에서 표쥰 mutex를 사용하도록 변경했는데 그 부분에서 문제가 생김

좌 변경전 우 변경 후

지난 게임서버(1) 강의에서는 커스텀 락을 만들어 사용했기 때문에 lock을 건 상태에서 또 lock을 해줘도 문제가 없도록 만들어줬는데 (내가 락을 걸고 또 락을 내가 시도하는거면 넘어가게 처리했던걸로 기억함. 다른쓰레드가 락을 걸때만 침범하지 못하도록), 표준 뮤텍스는 그렇게 작동하지않기 때문에 해결해야만 한다.

문제가 발생하는곳은 Session의 send 부분. 

ProcessSend 함수

WriteLock을 걸고 RegisterSend함수를 호출하는데 RegisterSend 함수 안에도 WriteLock을 쓴다. 그래서 문제가 발생한다.

강의자가 해결한 방법은 RegisterSend 안의 Lock을 제거하고 아래와 같이 코드를 수정한다.

void Session::Send(SendBufferRef sendBuffer)
{
	if (IsConnected() == false)
		return;

	bool registerSend = false;

	// 현재 RegisterSend가 걸리지 않은 상태라면, 걸어준다
	{
		WRITE_LOCK;

		_sendQueue.push(sendBuffer);

		if (_sendRegistered.exchange(true) == false)
			registerSend = true;
		if (registerSend)//lock 안쪽으로 옮김
			RegisterSend();
	}
	
	//if (registerSend) 
	//	RegisterSend();
}

void Session::ProcessSend(int32 numOfBytes)
{
	_sendEvent.owner = nullptr; // RELEASE_REF
	_sendEvent.sendBuffers.clear(); // RELEASE_REF

	if (numOfBytes == 0)
	{
		Disconnect(L"Send 0");
		return;
	}

	// 컨텐츠 코드에서 재정의
	OnSend(numOfBytes);

	WRITE_LOCK;
	if (_sendQueue.empty())
		_sendRegistered.store(false);
	else
		RegisterSend();// 이 부분은 Lock이 이미 걸려있으니 그대로 둠
}

이렇게 하면 코드가 좀 비효율적으로 돌아가지만 그냥 감안하고 간다. 자세한 내용은 아래 더보기 참조.

더보기

원래는 Send함수에서 락을 걸고 send할 버퍼를 send큐에 넣고

락을 풀고 가장 먼저 넣은 쓰레드가 실제 send작업을 하는동안

다른 쓰레드들은 send큐에 접근해서 락을 걸고 작업할거를 큐에 추가한 뒤에

락을 풀고 bool registerSend를 확인해서 Send 작업중인 쓰레드가 있으면 그냥 빠져나가는 식으로 동작했다.

 

수정 뒤에는 락을 걸고 버퍼를 샌드 큐에 넣기, send작업하기 까지 다 하고 락을 풀기때문에 하나의 쓰레드가 send작업 하나를 마칠 때 까지 lock에서 대기해야하는 상황이 생긴다. (특정 session에 대해 send할게 많아지면 심각한 성능문제를 초래할지도?)

- 클라쪽에 NetworkWorker에서 버그

아래 코드에서 payloadsize가 0인 경우 (패킷이 헤더뿐인 경우) OutPacket.AddZeroed(0) 에서 outpacket크기가 headersize(4)밖에 안되는데 outpacket[headersize] 에 접근하면서 문제가 발생할 것이다. payloadsize가 0인 경우를 예외처리 해서 해결해줬다.

bool RecvWorker::ReceivePacket(TArray<uint8>& OutPacket)
{
	// 패킷 헤더 파싱
	const int32 HeaderSize = sizeof(FPacketHeader);
	TArray<uint8> HeaderBuffer;
	HeaderBuffer.AddZeroed(HeaderSize);

	if (ReceiveDesiredBytes(HeaderBuffer.GetData(), HeaderSize) == false)
		return false;

	//Id, Size 추출
	FPacketHeader Header;
	{
		FMemoryReader Reader(HeaderBuffer);
		Reader << Header;
		UE_LOG(LogTemp, Log, TEXT("Recv PacketId : %d, PacketSize : %d"), Header.PacketID, Header.PacketSize);
	}
	//패킷 헤더 복사
	OutPacket = HeaderBuffer;
	//패킷 내용 파싱
	TArray<uint8> PayloadBuffer;
	const int32 PayloadSize = Header.PacketSize - HeaderSize;
	if (PayloadSize == 0)//추가됨
		return true;
	OutPacket.AddZeroed(PayloadSize);

	if (ReceiveDesiredBytes(&OutPacket[HeaderSize], PayloadSize))
		return true;
	return false;
}

 

12. 패킷 설계

오늘은 패킷 설계를 하고, 패킷을 사용하는데에 진짜로 익숙해 지는 시간을 가져볼 것이다.

우리가 만든 구조에서는 이제 proto파일을 만들고 이용해서 패킷을 설계할 것이다.

 

proto 파일에서는

enum과 message를 주로 쓰게 될 것인데 이것저것 알아가보자.

 

enum PlayerType
{
	PLAYER_TYPE_NONE = 0;
	PLAYER_TYPE_KNIGHT = 1;
	PLAYER_TYPE_MAGE = 2;
	PLAYER_TYPE_ARCHER = 3;
}

proto 파일에서 enum을 만들 때 이런 컨벤션을 지키면 좋은데

Protocol::PLAYER_TYPE_ARCHER;//	c++
Protocol.PlayerType.ARCHER;//	c#

c++에서는 enum을 그대로 쓰고, c# 에서는 위와같이 사용할 수 있도록 되어있기 때문이다. (근데 우린 c++만 쓸거라 의미없다)

아무튼 일관성있는 컨벤션을 지켜주도록 하자.

 

앞으로 클라 서버 연동을 할 때는 protobuf를 이용하는 경우 공통적인 enum을 웬만하면 Enum.proto 안에서 정의하는게 매우 매우 중요하다고 한다. 강의자는 proto파일을 사용하지 않는 경우엔 공용파일을 하나 둬서 하나의 헤더를 서버와 클라 양쪽에서 참조하는 식으로 작업했다고 하는데 그러다보니 클라/서버 프로그래머가 그파일을 수정하면 서로 문제가 터지는 경우가 생길 수 있었는데, proto에서 관리하면 그런 문제를 개선할 수 있고 enum이 protobuf에 속해있기 때문에 나중에 다른 proto파일에서 패킷을 만들 때 enum을 사용할 수 있다는 엄청난 장점.

 

지금은 파일이 enum protocol struct 이렇게 나뉘어있는데 꼭 이렇게 해야만 하는건 아니다.

 

우리는 protocol 파일에서 클라 - 서버 에서 대화할 패킷을 정의하고있고 (message는 c++의 구조체나 클래스같은 것)

message Player
{
	uint64 playerId = 1;
}

이런 건 마치 c++로 따지면

struct Player
{
	uint64 playeId;
}

이렇게 만든것과 같다고 한다. (proto의 =1은 그냥 첫번째 정보라는 뜻 빼먹어도 겹쳐도 안된다)

 

message S_LOGIN
{
	bool success = 1;
	repeated Player players = 2;
}

proto에서 이렇게 다른 message를 쓰는 것도 구조체 안에 구조체를 선언하는것과 비슷한 것.

repeated는 vector와 같은 것. 위와같은경우는 여러명의 플레이어에 대한 정보를 보내야한다. 하면 repeated를 쓴다.

나머지는 하면서 보자. 크게 어려운 부분은 없다고함.

요즘 protobuf를 쓰는게 대세인데, 만약 직접 protobuf같은걸 만들어서 쓰는 경우는 repeated 뿐 아니라 vector를 그대로 쓴다던지 map을 쓴다던지 다양한 기능을 넣을 수도 있다고 한다.

 

이제 우리는 protobuf를 이용해 패킷을 보내는 것도 해보고, 클라와 서버를 연동해 유의미한 무언가를 해볼것이다.

 

message PlayerInfo
{
	uint64 object_id = 1;
	float x = 2;
	float y = 3;
	float z = 4;
	float yaw = 5;
}

일단 struct.proto에서 Player를 위와 같 바꿔주자. 다양한 정보를 넣었고, 플레이어 뿐 아니라 몬스터같은데도 쓸거라서 id도 object_id로 수정했다. 나머지는 오브젝트들이 공통적으로 쓰게될것들을 넣어준 것.

 

protocol.proto 를 보면 로그인 관련 패킷들이 있는데 이부분을 보자.

message C_LOGIN
{

}

message S_LOGIN
{
	bool success = 1;
	repeated PlayerInfo players = 2;
}

(C_는 클라가 보내는 패킷, S_는 서버가 보내는 패킷이다)

뭔가 인증과정을 거친 후에 ok가 되면 서버에서 success 여부와 플레이어 인포를 보내는데, 이는 유저가 로비에서 로그인을 해서 캐릭터 선택창에 도달한 상황이라고 생각하면 될 것 같다.

 

그다음 나오는 클라의 답변과 서버의 답변

message C_ENTER_GAME
{
	uint64 playerIndex = 1;
}

message S_ENTER_GAME
{
	bool success = 1;
	PlayerInfo player = 2;
}

캐릭터 정보들 중 선택한 캐릭터의 인덱스를 전달하는 식.

인덱스를 보내던 오브젝트id를 보내던 아무튼 이런식으로 하면 된다.

그리고 서버는 성공여부와 어떤 클라가 플레이할 플레이어 데이터를 전달해준다. (보통 로비에서 보이는 캐릭터정보와 인게임 들어갈 때 쓰는 캐릭터정보는 다르다고 함. 로비쪽이 훨씬 데이터가 적음. 근데 우린 일단그냥 통일해서 쓴다.)

 

 

id라는게 여러가지가 있는데 강의자는 이전프로젝트들에서 크게 3가지가 있었다고 한다.

TemplateID - 데이터시트 id. 예를들어 아이템이라 하면 아이템에 대한 xml이건 json이건 엄청나게 방대한 파일이 있을텐데 거기서 1번은 단검 2번은 장검 ... 이런식으로 있을텐데 그 데이터 시트 안의 id가 있을건데 그걸로 구분하는것. 같은 종류의 아이템이면 같은 templateid를 갖고 있을 것.

DbID - db상에서의 id. db에서도 유니크한 id가 있을거니까.

GameID - 서버에 캐릭터든 몬스터든 드랍이든 뭘 생성했을 때 그걸 구분하기 위해서 사용하는 고유번호 id. "10번 id 에게 8데미지의 공격을 시도했다" 처럼. 그럼 몬스터는 dbid가 있을까? 없다. 몬스터는 db에 저장하는게 아니기 때문. 만약 db에 저장한다면 서버를 닫았다 열었을 때 몬스터가 서버 닫히기 전의 체력과 위치등을 똑같이 유지하고있지 않을까? 싶다.

보통 gamdid는 서버에 생성되는 순서대로 할당된다. uint64로 할당하는데 어마어마하게 많기때문에 다쓸일은 없다.

 

 

그리고 이제 이것저것 필요할 것 같은것들을 추가 해본 뒤에 컴파일, 실행해보자.

/*...*/
message C_LEAVE_GAME
{
}

message S_LEAVE_GAME
{
}

message S_SPAWN
{
	repeated PlayerInfo players = 1;
}

message S_DESPAWN
{
	repeated uint64 object_ids = 1;
}
/*...*/

그러면 컴파일에 실패할텐데, 자동화 코드에 의해 serverpackethandler.h 가 변경됐는데 .cpp에 함수를 구현해주지 않았기 때문. 클라이언트쪽도 같은 문제가 발생할테니 수정해주자.

13. Protobuf 작업

컨텐츠 작업은 그냥 순서를 따라가는게 제일 좋다.

아까 클라-서버간 패킷 전달의 흐름을 대략적으로 만들었는데 그 순서대로 만드는게 가장 편하다.

근데 만약 서버나 클라 하나의 팀에만 소속되어있다면 반대쪽이 잘 만들어질거라 상상하면서 만들어야한다.

그런데 나는 반쪽짜리만 만든거니까 테스트 하기 힘들다. 그래서 온라인게임을 만들기 힘든 것.

 

protobuf의 패킷 직렬화는 크게 두단계이다.

1. 임시로 패킷 객체를 만든다.

2. 그것을 Make~~(pkt) 함수를 호출해서 직렬화 데이터를 만든다.

이런 방법에 장단점이 있는데 장점은 작업이 편하다는 것. 단점은 임시객체를 만들었다가 변환하기 때문에 코스트가 들어간다는 점.

 

/*
message S_LOGIN
{
	bool success = 1;
	repeated PlayerInfo players = 2;
}
*/

Protocol::S_LOGIN pkt;

pkt.set_success(true);//setter
pkt.success();//getter

auto sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);

 

그리고 패킷 객체는 위와같이 pkt.어쩌구로 set이나 get을 할 수 있다.

위의 repeated PlayerInfo 도 비슷하게 어떤 함수들이 있는지 확인할 수 있다.

함수 이름으로 대충 어떤 함수들인지 확인할 수도 있겠지만, 잘 모르겠으면 c++ 프로토버프 코드 생성 가이드를 참조하도록 하자.

(그것보다 그냥 함수 주석을 읽는게 더 편한 것 같다.)

 

꿀팁

나중에 서버에서 사용할 여러가지 클래스를 만들텐데 PlayerInfo에 들어가는 정보들이 똑같이 들어가야 할것이다.

그 때 둘을 따로 관리하는게 아니라 아래와 같이 아예 클래스 안에 넣어서 관리하는것이 유용하다. 그러면 나중에 패킷을 만들어서 보낼때도 굉장히 편리해지기 때문.

class Player
{
public:
	Protocol::PlayerInfo info;
};

 

유의할 점이 있다.

/*
message S_ENTER_GAME
{
	bool success = 1;
	PlayerInfo player = 2;
}
*/

Protocol::PlayerInfo* p = new Protocol::PlayerInfo();

p->set_x(0);
p->set_y(0);
p->set_z(0);

Protocol::S_ENTER_GAME pkt1;
pkt1.set_allocated_player(p);
pkt1.release_player();

s_enter_game pkt1 안의 playerinfo를 넣을 때 (메세지 안 메세지)

위와 같이 동적할당한 playerinfo를 넣어줄 때 set_allocated_player(p) 를 사용한다.

그런데 pkt1이 소멸될 때 동시에 pkt1이 들고있는 player도 소멸시키기 때문에 외부에서 p를 사용하거나 2중으로 소멸시키는 실수를 할 수 있다. 그래서 release 함수를 사용해 소유권을 포기해서 함께 소멸되는것을 방지할 수 있다.(pkt1.player는 내용물이 텅 비고 p에는  남아있더라)

아니면 CopyFrom함수를 사용할 수도 있다.

함수들을 외우려 하진말고 그냥 무슨함수 있는지 슥 다 보고 써보고 하면 좋다.

 

repeated는 vector와 굉장히 유사한데

for (int32 i = 0; i < 3; i++)
{
	Protocol::PlayerInfo* p = pkt.add_players();
	p->set_x(10);
}
for (int32 i = 0; i < 3; i++)
{
	const Protocol::PlayerInfo& p = pkt.players(i);
}
for (const auto& p : pkt.players())
{
	Protocol::PlayerInfo* p = pkt.add_players();
	p->set_x(10);
}

이렇게 반복문을 쓰는 등 활용할 수 있다.

 

 

주의해야할 점이 있는데 지금은 proto파일에서 에러가 발생해도 배치파일 실행과정에서 그걸 잡아주지 않기 때문에 자동화코드쪽이 복사가 안된다던지 하면 그쪽 문제를 의심해 봐라.


14. Spawn #1

이제 컨텐츠를 만들어볼건데 두가지 방법이 있을것이다.

하나는 서버측에서 싹다 예상해서 작업을 하고, 클라에서 작업을 하고 붙여본다거나. 하지만 이 방법은 처음하기 힘들다.

강의자는 그냥 순서대로 하는방법이 좋다고 한다.

message C_LOGIN
{

}

message S_LOGIN
{
	bool success = 1;
	repeated PlayerInfo players = 2;
}

message C_ENTER_GAME
{
	uint64 playerIndex = 1;
}

message S_ENTER_GAME
{
	bool success = 1;
	PlayerInfo player = 2;
}

message C_LEAVE_GAME
{
}

message S_LEAVE_GAME
{
}

message S_SPAWN
{
	repeated PlayerInfo players = 1;
}

message S_DESPAWN
{
	repeated uint64 object_ids = 1;
}

프로토파일에서 정의해놓은 순서대로 컨텐츠를 구현해보자.

가장 먼저 클라이언트가 보내는 로그인 패킷이 있다.

 

로그인 패킷은 언제 보내면 좋을까?

이전에 언리얼에서 네트워크 담당자로 S1GameInstance  클래스를 만들었었는데, 거기서 서버와 접속한 직후에 보내보도록 하자.

void US1GameInstance::ConnectToGameServer()
{
	/*...*/
	if (Connected)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connection Successed"));
		// Session 
		GameServerSession = MakeShared<PacketSession>(Socket);
		GameServerSession->Run();
        
        // TEMP : 로비에서 캐릭터 선택창 등 띄우고 패킷 보내면 좋을듯?
        {
			Protocol::C_LOGIN Pkt;
			SendBufferRef SendBuffer = ClientPacketHandler::MakeSendBuffer(Pkt);
			SendPacket(SendBuffer);
		}
	}
	/*...*/
}

클라에서 서버로 패킷을 보냈으니 이제 서버에서 받은 패킷을 처리해보자.

더보기

실제로는 http 기반 인증서버가 있어서 그쪽을 통과해야지만 들어올 수 있다. 그런데 인증서버를 거쳐왔는지는 어떻게 알 수 있을까? 보통은 redis같은 nosql db에 임시로 토큰을 저장해놓고, 게임서버가 그 토큰으로 확인한다고 한다.

아니면 jwp같은 실제로 발급받는 해시형태의 토큰으로 확인하는 방법도 있다.

하지만 포폴 만들때는 싹다 무시하고 그냥 했다치고 넘어간다.

 

앞으로 패킷을 send할 일이 매우 많을테니 귀찮다면 아래와같이 pch파일에 define을 하나 만들어서 간단하게 처리하자. (서버)

#define SEND_PACKET(pkt)													\
	SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);	\
	session->Send(sendBuffer);
bool Handle_C_LOGIN(PacketSessionRef& session, Protocol::C_LOGIN& pkt)
{
	//TODO : DB에서 Account 정보를 가져온다.
	//TODO : DB에서 유저 정보 가져온다.
	Protocol::S_LOGIN loginPkt;

	for (int32 i = 0; i < 3; i++)
	{
		Protocol::PlayerInfo* player = loginPkt.add_players();
		player->set_x(0);
		player->set_y(0);
		player->set_z(0);
		player->set_yaw(0);
	}
	loginPkt.set_success(true);
	SEND_PACKET(loginPkt);
	return true;
}

로그인 성공으로 3개의 플레이어 정보를 클라이언트에게 보낸다.

 

 덤 : 랜덤함수

더보기

앞으로 자주 사용할 수학함수들을 Utils 클래스에 몰아넣도록 하자

처음엔 랜덤함수를 하나 만들어두자.

강의자도 그냥 어디서 보고 만든거라 하고, c++17부터 쓸 수 있으니까 vs를 사용중이라면 프로젝트 속성에서 수정해줘야 한다. (20으로 버전업했다.)

class Utils
{
public:
template<typename T>
static T GetRandom(T min, T max)
{
//시드값을 얻기 위한 random_device 생성
std::random_device randomDevice;
// random_device 를 통해 난수 생성 엔진을 초기화 한다
std::mt19937 generator(randomDevice());
// 균등하게 낭타나는 난수열을 생성하기 위해 균등 분포 정의;

if constexpr (std::is_integral_v<T>)
{
std::uniform_int_distribution<T> distribution(min, max);
return distribution(generator);
}
else
{
std::uniform_real_distribution<T> distribution(min, max);
return distribution(generator);
}
}
};

 

handle_s_login을 채워넣어보자.

bool Handle_S_LOGIN(PacketSessionRef& session, Protocol::S_LOGIN& pkt)
{
	for (auto& Player : pkt.players())
	{
	}
	for (int32 i = 0; i < pkt.players_size(); i++)
	{
		const Protocol::PlayerInfo& Player = pkt.players(i);
	}
	
	//로비에서 캐릭터 선택해서 인덱스 전송
	Protocol::C_ENTER_GAME EnterGamePkt;
	EnterGamePkt.set_playerindex(0);
	
	SEND_PACKET(EnterGamePkt);
	return true;
}

서버로부터 온 패킷을 위와같이 반복문으로 내 캐릭터 목록을 보는 것.

그리곤 캐릭터 하나를 선택해서 게임에 접속하겠다고 서버에게 보낸다.

 

잡다한 여기저기서 쓰일 코드를 S1.h에 집어넣자.

서버와 마찬가지로 SEND_PACKET은 자주 사용할 것 같으니 define 해주자.

class Session;
class PacketSession;

USING_SHARED_PTR(Session);
USING_SHARED_PTR(PacketSession);
USING_SHARED_PTR(SendBuffer);

#include "ClientPacketHandler.h"
#include "S1GameInstance.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/World.h"

#define SEND_PACKET(Pkt)	\
	SendBufferRef SendBuffer = ClientPacketHandler::MakeSendBuffer(Pkt); \
	Cast<US1GameInstance>(GWorld->GetGameInstance())->SendPacket(SendBuffer);

 

 

이제 클라이언트에서 보낸 C_ENTER_GAME을 서버측에서 처리해준다.

아직 서버 코드에는 플레이어 클래스도 없고, "어디에" 들어갈지 들어갈곳도 만들어두지 않았으니 이참에 만들어본다.

Player와 Room 클래스 파일을 만든다.

#pragma once

class GameSession;
class Room;

class Player : public enable_shared_from_this<Player>
{
public:
	Player();
	virtual ~Player();

public:
	Protocol::PlayerInfo* playerInfo;
	weak_ptr<GameSession> session;

public:
	atomic<weak_ptr<Room>> room;
};

이전에 배웠던 것 처럼 playerInfo를 사용해서 편리하게 사용할 수 있다. 정적할당으로 해도 상관없는데 강의자가 그냥 c#이랑 비슷하게 짠다고 이렇게 짰다고 한다.

게임세션과 룸을 들고있는데 둘다 weak_ptr로 들고있다. session과 player, 룸과 player 각각이 서로의 포인터를 들고있어야 한다면 순환문제를 해결하기위해 둘중 하나는 weak_ptr를 들어야 하는데 어떻게 들면 좋을지 생각하면서 설정하자. 강의자는 player가 둘 다 weak_ptr을 들고있도록 만들었다.

 

class Room : public enable_shared_from_this<Room>//player가 들고있어야 하니까
{
public:
	Room();
	virtual ~Room();

	//방에 플레이어 입장시키는 함수
	bool HandleEnterPlayerLocked(PlayerRef player);
private:
	bool EnterPlayer(PlayerRef player);
	USE_LOCK;

private:
	unordered_map<uint64, PlayerRef> _players;
};

extern RoomRef GRoom;//보통 이렇게 안하지만 일단 테스트용

 

오브젝트 만들때 공용공간에서 만드는게 좋다. id할당 등등의 이유.

그래서 오브젝트를 만들어주는 스태틱함수를 만든다.

class ObjectUtils
{
public:
	static PlayerRef CreatePlayer(GameSessionRef session);
private:
	static atomic<int64> s_idGenerator;
};

atomic<int64> ObjectUtils::s_idGenerator = 1;

PlayerRef ObjectUtils::CreatePlayer(GameSessionRef session)
{
	//ID 생성기
	const int64 newId = s_idGenerator.fetch_add(1);
	PlayerRef player = make_shared<Player>();
	player->playerInfo->set_object_id(newId);

	player->session = session;
	session->player.store(player);//gamesession에 저장. atomic으로 정의할거라 store 씀

	return PlayerRef();
}

 

게임세션이 유지되는 동안 캐릭터를 얼마든지 바꿀 수 있기 때문에 게임세션도 Player를 들고있어야 한다.

아토믹으로 하는 이유는 GameSession은 여러 스레드에서 접근할 가능성이 높기 때문.

처음 작업할 때 이런 부분들이 잘 안보일때가 많아서 주의해야한다. 크래시 내고 버그 만들어내면서 시행착오 겪을 수밖에 없다고 한다.

class GameSession : public PacketSession
{
/*...*/
public:
	atomic<PlayerRef> player;
};

 

bool Handle_C_ENTER_GAME(PacketSessionRef& session, Protocol::C_ENTER_GAME& pkt)
{
	//플레이어 생성
	PlayerRef player = ObjectUtils::CreatePlayer(static_pointer_cast<GameSession>(session));

	//방에 입장
	GRoom->HandleEnterPlayerLocked(player);

	return true;
}

 

방 입장을 위해 플레이어의 GRoom->HandleEnterPlayerLocked 함수 만들기

bool Room::HandleEnterPlayerLocked(PlayerRef player)
{
    WRITE_LOCK;

    bool success = EnterPlayer(player);
    //방에 입장하면 랜덤 위치에 스폰됨
    player->playerInfo->set_x(Utils::GetRandom(0.f, 100.f));
    player->playerInfo->set_y(Utils::GetRandom(0.f, 100.f));
    player->playerInfo->set_z(Utils::GetRandom(0.f, 100.f));
    player->playerInfo->set_yaw(Utils::GetRandom(0.f, 100.f));

    // 입장이 성공했다고 클라이언트에 알림
    {
        Protocol::S_ENTER_GAME enterGamePkt;
        enterGamePkt.set_success(success);

        Protocol::PlayerInfo* playerInfo = new Protocol::PlayerInfo();
        playerInfo->CopyFrom(*(player->playerInfo));

        SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(enterGamePkt);
        if (auto session = player->session.lock())
            session->Send(sendBuffer);
    }
    // 입장 사실을 다른 플레이어에게 알린다. 후추

    return success;
}

일단 여기까지 구현하고 서버가 패킷을 클라에게 보냈으니 "순서대로" 하기 위해 클라에서 패킷을 받는쪽으로 가보자.


15. Spawn #2

- 컴퓨터를 고치고 거의 3~4주만에 돌아옴

 

이제 Handle_S_ENTER_GAME으로 온다.

S_ENTER_GAME에서 처리하기보다 게임인스턴스에 떠넘기는게 수월할것

bool Handle_S_ENTER_GAME(PacketSessionRef& session, Protocol::S_ENTER_GAME& pkt)
{
	if (auto* GameInstance = Cast<US1GameInstance>(GWorld->GetGameInstance()))
	{
		GameInstance->HandleSpawn(pkt.player());
	}
	return true;
}

GWorld에게 게임인스턴스를 가져와서 우리가 만든 게임인스턴스로 다운캐스팅한다. 그리고 게임인스턴스 클래스에서 구현한 HandleSpawn을 사용해 플레이어를 스폰시킨다.

 

HandleSpawn은 아직 안만들었는데 이제부터 만들어보자.

버전은 세가지다.

public:
	void HandleSpawn(const Protocol::PlayerInfo& PlayerInfo);
	void HandleSpawn(const Protocol::S_ENTER_GAME& EnterGamePkt);
	void HandleSpawn(const Protocol::S_SPAWN& SpawnPkt);

1번은 일반적으로 플레이어 인포 넣으면 플레이어 소환해주는것

2, 3번은 enter game과 spawn에도 각각 플레이어를 소환하는 뭔가가 들어갈테니 만들어둔다. (함수 안에서 1번 버전 호출한다는 듯?)

1번 버전부터 만들어보자.

void US1GameInstance::HandleSpawn(const Protocol::PlayerInfo& PlayerInfo)
{
	if (Socket == nullptr || GameServerSession == nullptr)
		return;
	auto* World = GetWorld();
	if (World == nullptr)
		return;
	const uint64 ObjectId = PlayerInfo.object_id();
	if (Players.Find(ObjectId) != nullptr)
		return;

	FVector SpawnLocation(PlayerInfo.x(), PlayerInfo.y(), PlayerInfo.z());
	AActor* Actor = World->SpawnActor(PlayerClass, &SpawnLocation);

	Players.Add(PlayerInfo.object_id(), Actor);
}

 

 

그리고 어떤식으로건 플레이어들을 메모리에 들고있어야한다.

GameInstance.h에 추가하자.

public:
	TMap<uint64, AActor*> Players;

SpawnActor 에 넣는 PlayerClass는 언리얼 에디터로 집어넣는거다.

게임인스턴스에 아래와 같이 추가하고, 나중에 언리얼 에디터에서 블루프린트를 PlayerClass에 넣어주자.

public:
	UPROPERTY(EditAnywhere)
	TSubclassOf<AActor> PlayerClass;

 

언리얼의 TMap은 find가 iterator를 리턴하는게 아니라 value의 포인터를 리턴한다. 그래서 키가 Map에 없으면 널이 나온다.

지금은 AActor* 가 value이니 나오는건 AActor** 인것. 유의하자.

 

2번, 3번 버전도 만들어보자. 별거없다.

void US1GameInstance::HandleSpawn(const Protocol::S_ENTER_GAME& EnterGamePkt)
{
	HandleSpawn(EnterGamePkt.player());
}

void US1GameInstance::HandleSpawn(const Protocol::S_SPAWN& SpawnPkt)
{
	for (auto& Player : SpawnPkt.players())
	{
		HandleSpawn(Player);
	}
}

 

이제 언리얼에디터를 키자.

콘텐츠-Blueprints 에 TestPlayer 폰 블루프린트를 만든다. 그리고 대충 아무 매시컴포넌트를 추가하고, 콜리전은 NoColiision으로 설정한다. (스폰위치에 겹치면 스폰이 안될수도 있다고 함 해보진 않음.)

 

그리고 이제 S1GameInstance를 상속받은 블루프린트를 만들어주고, 아까 PlayerClass에 BP_TestPlayer를 넣어주자.

 

그리고 지금은 화면에 아무것도 안보이니 기본레벨을 새로하나 만들자.

먼저 현재 레벨의 블루프린트에 설정한 이벤트 그래프를 복사한다. 그리고 컨트롤 N으로 기본맵을 열고, 새 맵에 넣어주자. 이름은 저장은 기존의 DevMap을 덮어쓰자.

그리고 프로젝트 설정 - 맵&모드 에 가서 게임 인스턴스 클래스를 우리가 만든 BP_GameeInstance로 변경하자.

이제 플레이를 눌러보면 이전에 설정한대로 0~100 사이 랜덤위치에 TestPlayer가 소환된다.

 

지금 플레이어 수를 4로 설정하고 플레이를 눌러보면 4개의 플레이어 창이 뜨긴 하지만 각자 자기자신만 있고, 다른 플레이어는 안보인다. 왜냐하면 입장시에 다른 플레이어릐 존재를 아직 서버에서 안알려주기 때문.

HandleEnterPlayerLocked 에서 입장 사실을 다른 플레이어에게 알리는 코드를 추가하자.

bool Room::HandleEnterPlayerLocked(PlayerRef player)
{
    /*...*/
    // 입장 사실을 다른 플레이어에게 알린다.
    {
        Protocol::S_SPAWN   spawnPkt;
        Protocol::PlayerInfo* playerInfo = spawnPkt.add_players();
        playerInfo->CopyFrom(*player->playerInfo);

        SendBufferRef   sendBuffer = ServerPacketHandler::MakeSendBuffer(spawnPkt);
        Broadcast(sendBuffer, player->playerInfo->object_id());
    }
    return success;
}

 

broadcast 함수는 아직 없는데 Room 클래스에 만들어주자.

exeptId는 나 자신은 broadcast 대상에서 제외하기 위함이다.

void Room::Broadcast(SendBufferRef sendBuffer, uint64 exeptId)
{
    for (auto& item : _players)
    {
        PlayerRef player = item.second;
        if (player->playerInfo->object_id() == exeptId)
            continue;
        if (GameSessionRef session = player->session.lock())
            session->Send(sendBuffer);
    }
}

HandleEnterPlayerLocked 함수에서 lock을 걸고 실행하지만, 만약 Send도중에 문제가 생겨서 방에서 쫒아내려 하면 players 를 순회하던 도중에 players를 건들이는거라 문제가 생길 수 있다. 이런 문제를 방지하려면 players를 복사해놓고 쓰면 된다고 한다.

 

이제 다른 플레이어들이 spawnPkt를 받을테니 클라이언트의 Handle spawnPkt 함수에서 새로 들어온 유저를 스폰시키자.

//참고용 proto파일 내용
/*message S_SPAWN
{
	repeated PlayerInfo players = 1;
}

message PlayerInfo
{
	uint64 object_id = 1;
	float x = 2;
	float y = 3;
	float z = 4;
	float yaw = 5;
}*/

bool Handle_S_SPAWN(PacketSessionRef& session, Protocol::S_SPAWN& pkt)
{
	if (auto* GameInstance = Cast<US1GameInstance>(GWorld->GetGameInstance()))
	{
		GameInstance->HandleSpawn(pkt);
	}
	return true;
}

이제 실행해보면 잘 된다. 클라이언트를 4로 설정해서 실행해보자.

근데 누구는 4명이 다 보이고 누구는 3명만 보이고 누구는 2명만 보인다.

이유는 신입이 들어오면 들어가있던 사람들에게만 패킷을 보내기 때문. 그러니 신입에게도 "플레이어 세명이 이미 들어와있었음" 을 알려야한다.

bool Room::HandleEnterPlayerLocked(PlayerRef player)
{
    /*...*/
    // 기존에 입장해 있던 플레이어 목록을 신입에게도 보내준다.
    {
        Protocol::S_SPAWN spawnPkt;
        for (auto& item : _players)
        {
            Protocol::PlayerInfo* playerInfo = spawnPkt.add_players();
            playerInfo->CopyFrom(*item.second->playerInfo);
        }

        SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(spawnPkt);
        if (auto session = player->session.lock())
            session->Send(sendBuffer);
    }
    return success;
}

이제 정상적으로 네명이 다 보인다.


16. Despawn

지금 문제가 있다.

테스트를 여러번 해보면 이전 테스트에 접속한 객체들이 남아있는 것. 8개 12개 이렇게 불어난다. 언리얼 에디터에서 stop을 누른다고 연결이 끊기는게 아니기 때문.

mmo에서는 패킷을 만든다고 한다. 하트비트나 ping pong으로. (서버가 먼저 ping을 보내고 클라가 pong 해야하지않을까? 주기적으로)

 

아무튼 팅겨내는걸 구현해보자.

지금 proto파일에 LEAVE_GAME이 있다. 이건 클라가 접속종료를 눌러서 나가는걸 가정한다.

 

이전에 만들어둔 disconnect 함수를 보자.

void US1GameInstance::DisconnectFromGameServer()
{
	if (Socket)
	{
		ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get();
		SocketSubsystem->DestroySocket(Socket);
		Socket = nullptr;
	}
}

지금은 무식하게 소켓을 날려버린다. 근데 이건 문제가 있는게 SendWorker와 RecvWorker가 socket을 가진 상태로 계속 돌아가고 있기 때문. 아래와 같이 변경한다.

void US1GameInstance::DisconnectFromGameServer()
{
	if (Socket == nullptr || GameServerSession == nullptr)
		return;
	Protocol::C_LEAVE_GAME LeavePkt;
	SEND_PACKET(LeavePkt);
}

서버에게 "나 나갈게" 패킷을 보내고, 서버에서 퇴장처리를 한 뒤에 패킷을 보내주면, 클라에서 나가도록 처리하는 것.

 

이 패킷을 받는 서버에서의 처리도 만들어주자.

bool Handle_C_LEAVE_GAME(PacketSessionRef& session, Protocol::C_LEAVE_GAME& pkt)
{
	auto gameSession = static_pointer_cast<GameSession>(session);
	
	PlayerRef player = gameSession->player.load();
	if (player == nullptr)
		return false;

	RoomRef room = player->room.load().lock();
	if (room == nullptr)
		return false;

	room->HandleLeavePlayerLocked(player);

	return true;
}

 

HandleLeavePlayerLocked 함수는 아직 만들지 않았으니 Room에서 만들도록 하자.

class Room : public enable_shared_from_this<Room>
{
public:
	/*...*/
    bool HandleLeavePlayerLocked(PlayerRef player);
private:
	bool LeavePlayer(uint64 objectId);
	/*...*/
};
bool Room::LeavePlayer(uint64 objectId)
{
    if (_players.find(objectId) == _players.end())
        return false;

    PlayerRef player = _players[objectId];
    player->room.store(weak_ptr<Room>());

    _players.erase(objectId);

    return true;
}

bool Room::HandleLeavePlayerLocked(PlayerRef player)
{
    if (player == nullptr)
        return false;

    WRITE_LOCK;

    const uint64 objectId = player->playerInfo->object_id();
    bool success = LeavePlayer(objectId);

    // 퇴장 사실을 퇴장하는 플레이어에게 알린다.
    {
        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 session = player->session.lock())
            session->Send(sendBuffer);
    }
}

마지막의 이 부분은 LeavePlayer에서 room의 players에서 erase 되기 때문에 despawn 패킷을 당사자에게도 보내기 위함이다. 없어도 될 수 있는데, 이건 정책적인 문제. 할람할 말람말.

if (auto session = player->session.lock())
    session->Send(sendBuffer);

 

이제 서버 -> 클라 로 LeaveGamePkt와 Desapwn 패킷을 보냈으니 그에 대한 클라이언트의 처리도 구현해주자.

bool Handle_S_DESPAWN(PacketSessionRef& session, Protocol::S_DESPAWN& pkt)
{
	if (auto* GameInstance = Cast<US1GameInstance>(GWorld->GetGameInstance()))
	{
		GameInstance->HandleDespawn(pkt);
	}
	return true;
}

bool Handle_S_LEAVE_GAME(PacketSessionRef& session, Protocol::S_LEAVE_GAME& pkt)
{
	if (auto* GameInstance = Cast<US1GameInstance>(GWorld->GetGameInstance()))
	{
		// TODO : 게임종료? 로비로?
	}
	return true;
}

 

HandleDespawn

void US1GameInstance::HandleDespawn(uint64 ObjectId)
{
	if (Socket == nullptr || GameServerSession == nullptr)
		return;
	
	auto* World = GetWorld();
	if (World == nullptr)
		return;
	
	// TODO : Despawn
	
	AActor** FindActor = Players.Find(ObjectId);
	if (FindActor == nullptr)
		return;
	
	World->DestroyActor(*FindActor);
}

void US1GameInstance::HandleDespawn(const Protocol::S_DESPAWN& DespawnPkt)
{
	for (auto& ObjectId : DespawnPkt.object_ids())
	{
		HandleDespawn(ObjectId);
	}
}

 

이제 언리얼 에디터로 돌아가서 레벨 블루프린트에 아래와 같이 추가하자. 이벤트는 Keboard Q 로 검색하면 나온다.

 

이제 q를 누르면 정상적으로 오브젝트가 없어지고, disconnect 된다. disconnect 이후에는 패킷이 안오기때문에 다른애들이 나가도 확인은 안된다.


그리고 지금은 언리얼 코드에서 내 플레이어와 다른사람들이 다 똑같은 Players map에 집어넣어놨는데, 원래는 내 플레이어는 따로 관리해야한다.

 

이제 입장 퇴장을 했으니 이동 동기화만 마치면 사실상 서버는 끝이라고 한다.

'강의 수강 > 게임서버(2)' 카테고리의 다른 글

5. Job  (0) 2026.03.20
4. 이동 동기화  (0) 2026.03.19
2. 서버 연동 기초  (0) 2026.01.10
01. OT ~ 05.복습#4  (0) 2026.01.06

+ Recent posts