스팀으로 실행 한 두 유저(클라이언트)끼리 메시지를 송/수신하는 기능을 만들어 보자.
개발하던 게임이 StandAlone 게임이어서 Host가 나가면 방이 그대로 폭파되는 현상이 발생했다. 에픽 마켓에서 HostMigration이라는 플러그인을 구매하여 Host가 나가더라도 다음 Host를 정해서 방을 자동으로 새로 생성하고, 그 이전까지 진행했던 데이터를 다시 로드하여 게임을 지속적으로 진행할 수 있게 됐다. 하지만 플러그인에서 새 Host가 방을 생성하고 유저들을 다시 방으로 불러 들여올 때 무한반복으로 Join 시도를 하기 때문에 새 Host가 새로운 방을 완성하면 그때 이전에 같이 플레이하던 유저들에게 준비가 완료 됐으니 방에 들어와도 된다는 메시지를 보내서 이전의 유저들이 Join 할 수 있는 기능을 개발하고 싶어서 메시지 전송 기능들을 찾아봤다.
첫 번째로 UDP와 TCP로 메시지 송/수신 기능을 구현해 봤지만 유저들의 특정 IP를 알 수 없고 공유기를 사용하면서 발생하는 문제 때문에 메시지 전달이 되지 않았고, 스팀 API를 찾아보던 중 SteamNetworking을 사용해 보기로 했다.
우선 엔진에 스팀 셋업을 먼저 해 주어야 한다.
https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/Online/Steam/
Online Subsystem Steam
An overview of Online Subsystem Steam, including how to set up your project for distribution on Valve's Steam platform.
docs.unrealengine.com
위의 설정에서 마지막에 DefaultEngine.ini를 수정하는데 [OnlineSubsystemSteam]에서 bUseSteamNetworking=true로 해 주어야 스팀 네트워크 통신을 이용할 수 있다.
위와 같이 설정을 해 줬으면 우선 헤더에 스팀 콜백 함수를 설정해야 한다.
함수 정의/구현은 GameInstance에 작업했다.
스팀 네트워킹 구현 문서는 아래의 페이지를 참고하면 된다.
ISteamNetworking Interface (Steamworks Documentation)
Documentation Resources News & Updates Support
partner.steamgames.com
#pragma warning(push)
#pragma warning(disable:4996)
// #TODO check back on this at some point
#pragma warning(disable:4265) // SteamAPI CCallback< specifically, this warning is off by default but 4.17 turned it on....
#include <steam/steam_api.h>
#pragma warning(pop)
USTRUCT(BlueprintType)
struct FP2PSessionState
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly) uint8 m_bConnectionActive; // true if we've got an active open connection
UPROPERTY(BlueprintReadOnly) uint8 m_bConnecting; // true if we're currently trying to establish a connection
UPROPERTY(BlueprintReadOnly) uint8 m_eP2PSessionError; // last error recorded (see enum above)
UPROPERTY(BlueprintReadOnly) uint8 m_bUsingRelay; // true if it's going through a relay server (TURN)
UPROPERTY(BlueprintReadOnly) int32 m_nBytesQueuedForSend;
UPROPERTY(BlueprintReadOnly) int32 m_nPacketsQueuedForSend;
UPROPERTY(BlueprintReadOnly) FString m_nRemoteIP_uint64; // potential IP:Port of remote host. Could be TURN server.
UPROPERTY(BlueprintReadOnly) FString m_nRemoteIP_uint32; // potential IP:Port of remote host. Could be TURN server.
UPROPERTY(BlueprintReadOnly) int64 m_nRemotePort; // Only exists for compatibility with older authentication api's
};
class PIRATEISLAND_API UPiratesGameInstance : public UGameInstance
{
GENERATED_BODY()
private:
STEAM_CALLBACK(UPiratesGameInstance, OnSessionRequestCalled, P2PSessionRequest_t);
STEAM_CALLBACK(UPiratesGameInstance, OnSessionConnectFailCalled ,P2PSessionConnectFail_t);
public:
UFUNCTION(BlueprintCallable)
bool AcceptP2PSessionWithUser(const FBPUniqueNetId& UniqueNetId) const;
UFUNCTION(BlueprintCallable)
bool CloseP2PSessionWithUser(const FBPUniqueNetId& UniqueNetId) const;
UFUNCTION(BlueprintCallable)
bool SendP2PPacketMsg(const FBPUniqueNetId& UniqueNetId, const FString& SendMsg);
UFUNCTION(BlueprintCallable)
bool ReadP2PPacketMsg(FString& RecieveData, FBPUniqueNetId& NewHost);
UFUNCTION(BlueprintCallable)
bool GetP2PSessionState(const FBPUniqueNetId& UniqueNetId, FP2PSessionState& ConnectionState) const;
}
위의 소스는 기존에 추가 됐던 GameInstance.h 에서 기본적인 헤더는 빼고 추가적인 부분만 맞춰서 넣어주면 된다.
FP2 PSessionState는 블루프린트에서 사용하기 위해 기존의 스팀 구조체와 같은 구조체를 추가로 만들었다.
STEAM_CALLBACK메크로로 콜백 함수를 연결해 주었다. 헤더에는 선언할 필요가 없고, cpp 구현부에 선언해 주면 된다.
첫 번째 콜백은 누군가가 나에게 연결 요청 했을 때 불려지고, 두 번째 콜백은 누군가가 나와 연결을 끊었을 때 불려지게 된다.
아래의 코드는 CPP코드이다. 헤더 파일은 <memory>와 <string> 정도만 추가해 주면 될 것 같다.
#include "Interfaces/OnlineIdentityInterface.h"
#include "OnlineSubsystem.h"
void UPiratesGameInstance::OnSessionRequestCalled(P2PSessionRequest_t* pParam)
{
pParam->m_steamIDRemote;
pParam->k_iCallback;
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Session Request Callback : %d"), pParam->k_iCallback));
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Accept Session Request Callback Steam ID : %llu"), pParam->m_steamIDRemote.ConvertToUint64()));
bool bAcceptSuccess = SteamNetworking()->AcceptP2PSessionWithUser(pParam->m_steamIDRemote);
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Green, FString::Printf(TEXT("Accept Session With User Result : %s"), bAcceptSuccess?TEXT("True"):TEXT("False")));
}
void UPiratesGameInstance::OnSessionConnectFailCalled(P2PSessionConnectFail_t* pParam)
{
pParam->m_steamIDRemote;
pParam->m_eP2PSessionError;
pParam->k_iCallback;
bool bCloseSuccess = SteamNetworking()->CloseP2PSessionWithUser(pParam->m_steamIDRemote);
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Session Connect Failed Callback : %d"), pParam->k_iCallback));
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Close Session Request Callback Steam ID : %llu"), pParam->m_steamIDRemote.ConvertToUint64()));
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Green, FString::Printf(TEXT("Close Session With User Result : %s"), bCloseSuccess ? TEXT("True") : TEXT("False")));
}
bool UPiratesGameInstance::AcceptP2PSessionWithUser(const FBPUniqueNetId& UniqueNetId) const
{
if (!UMyPiratesLandFunctionLibrary::IsSubsystemSteam())
{
return true;
}
if (!UniqueNetId.UniqueNetId)
{
return false;
}
const uint64 id = *((uint64*)UniqueNetId.UniqueNetId->GetBytes());
const CSteamID SteamID = CSteamID(id);
bool bAcceptSuccess = SteamNetworking()->AcceptP2PSessionWithUser(SteamID);
return bAcceptSuccess;
}
bool UPiratesGameInstance::CloseP2PSessionWithUser(const FBPUniqueNetId& UniqueNetId) const
{
if (!UMyPiratesLandFunctionLibrary::IsSubsystemSteam())
{
return true;
}
if (!UniqueNetId.UniqueNetId)
{
return false;
}
const uint64 id = *((uint64*)UniqueNetId.UniqueNetId->GetBytes());
const CSteamID SteamID = CSteamID(id);
bool bAcceptSuccess = SteamNetworking()->CloseP2PSessionWithUser(SteamID);
return bAcceptSuccess;
}
bool UPiratesGameInstance::SendP2PPacketMsg(const FBPUniqueNetId& UniqueNetId, const FString& SendMsg)
{
if (!UMyPiratesLandFunctionLibrary::IsSubsystemSteam())
{
return false;
}
if (!UniqueNetId.UniqueNetId)
{
return false;
}
const uint64 id = *((uint64*)UniqueNetId.UniqueNetId->GetBytes());
const CSteamID SteamID = CSteamID(id);
std::string Strmsg = TCHAR_TO_ANSI(*SendMsg);
Strmsg += '\0';
const char* SendMsgChr = Strmsg.c_str();
//SteamNetworking()->CloseP2PChannelWithUser(SteamID, 0);
const bool Result = SteamNetworking()->SendP2PPacket(SteamID, SendMsgChr, Strmsg.size(), EP2PSend::k_EP2PSendReliable, 0);
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Send Result ::: : %s"), Result ? TEXT("True") : TEXT("False")));
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Send Size : %llu"), Strmsg.size()));
return Result;
}
bool UPiratesGameInstance::ReadP2PPacketMsg(FString& RecieveData, FBPUniqueNetId& NewHost)
{
if (!UMyPiratesLandFunctionLibrary::IsSubsystemSteam())
{
return true;
}
uint32 MsgSize = 0;
CSteamID steamIDRemote = CSteamID();
if(!SteamNetworking()->IsP2PPacketAvailable(&MsgSize))
{
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("P2PPacket Is Not Vailable")));
return false;
}
std::shared_ptr<char> pCharBuf = std::make_shared<char>(MsgSize);
if(!pCharBuf.get())
{
return false;
}
bool bReadSuccess = SteamNetworking()->ReadP2PPacket(pCharBuf.get(), MsgSize, &MsgSize, &steamIDRemote, 0);
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("pCharBuf Size ::: %llu"), sizeof(pCharBuf)));
if (bReadSuccess)
{
const FString RevData(pCharBuf.get());
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("P2PPacket Is Not Vailable %s"), *RevData));
UE_LOG(LogTemp, Log, TEXT("steamIDRemote Is Valid"));
RecieveData = RevData;
FBPUniqueNetId netId;
if (SteamAPI_Init())
{
const TSharedPtr<const FUniqueNetId> SteamID(new const FUniqueNetIdSteam2(steamIDRemote));
netId.SetUniqueNetId(SteamID);
NewHost = netId;
}
return true;
}
UE_LOG(LogTemp, Log, TEXT("steamIDRemote Is Not Valid"));
return false;
}
bool UPiratesGameInstance::GetP2PSessionState(const FBPUniqueNetId& UniqueNetId, FP2PSessionState& ConnectionState) const
{
if (!UMyPiratesLandFunctionLibrary::IsSubsystemSteam())
{
return true;
}
if(!UniqueNetId.UniqueNetId)
{
return false;
}
const uint64 id = *((uint64*)UniqueNetId.UniqueNetId->GetBytes());
const CSteamID SteamID = CSteamID(id);
P2PSessionState_t PConnectionState;
bool bAcceptSuccess = SteamNetworking()->GetP2PSessionState(SteamID, &PConnectionState);
if(bAcceptSuccess)
{
ConnectionState.m_bConnecting = PConnectionState.m_bConnecting;
ConnectionState.m_bConnectionActive = PConnectionState.m_bConnectionActive;
ConnectionState.m_bUsingRelay = PConnectionState.m_bUsingRelay;
ConnectionState.m_eP2PSessionError = PConnectionState.m_eP2PSessionError;
ConnectionState.m_nBytesQueuedForSend = PConnectionState.m_nBytesQueuedForSend;
ConnectionState.m_nPacketsQueuedForSend = PConnectionState.m_nPacketsQueuedForSend;
FString RemoteID = FString::Printf(TEXT("%llu"), PConnectionState.m_nRemoteIP);
ConnectionState.m_nRemoteIP_uint64 = RemoteID;
ConnectionState.m_nRemoteIP_uint32 = FString::FromInt(PConnectionState.m_nRemoteIP);
ConnectionState.m_nRemotePort = PConnectionState.m_nRemotePort;
return bAcceptSuccess;
}
ConnectionState = FP2PSessionState();
return bAcceptSuccess;
}
메시지를 전송/수신하기 위한 순서는
1. AcceptP2PSessionWithUser
2. SendP2PPacket
3. ReadP2PPacket
의 3단계로 볼 수 있다.
일단 두 유저가 세션은 Accept 하고, 메시지를 보내고 읽을 수 있는 것이다.
한 유저가 다른 유저에게 AcceptP2PSessionWithUser를 보내면 받는 유저 쪽에서는 STEAM_CALLBACK이 호출 돼서
위에 선언했던 OnSessionRequestCalled 콜백 함수가 호출이 되고, Remote유저와 세션 Accept를 하게 된다.
일단 메시지 송신 부분에서는 스트링으로만 넣어 놨다. 다른 타입을 원하면 맞춰서 넣으면 된다.
세션을 서로 Accept 했다면 메시지를 보낼 수 있게 되는데 위의 코드에서는 간단하게 스트링 메시지만 보내 봤다.
메시지를 보내면 받는 유저의 버퍼에 메시지가 채워지게 되고, ReadP2PPacket으로 버퍼에 들어온 메시지를 읽는 방식으로 이루어진 것 같다.

컴퓨터가 2대 이상 있는 상태에서 서로 다른 스팀 ID로 로그인하여 테스트하는 것이 가장 좋지만 스팀접속이 가능한 컴퓨터가 1대이므로 자기 자신에게 세션 Accept를 하고, 메시지를 보내고 읽어 들이는 테스트 로직을 구현했다.
스팀에 로그인된 상태에서 게임을 실행하면 UniqueNetID가 자동으로 스팀 쪽으로 세팅이 되고, 이 값을 사용하여 유저를 구분하는 것 같다.
프로젝트를 패키징 하고 패키징 된 게임을 스팀이 로그인된 상태에서 실행시킨다.
그다음 ke * AcceptSession 명령을 실행하면 성공/실패를 출력하게 했다.
자기 자신에게 AcceptSession을 하기 때문에 STEAM_CALLBACK은 호출되지 않는 것 같다.

다음으로 하드 코딩된 메시지(Message Send)를 보내보자
ke * sendmsg 명령을 입력해 주면 아래 그림과 같이 문자 보내는 size와 결과가 출력된다. 아마 개행문자(\n)까지 포함하여 크기가 13인 것 같다.

그럼 이제 버퍼에 메시지가 들어와 있는 상태고 이 메시지를 Read 하면 아래와 같이 받은 메시지와 메시지를 보낸 유저의 SteamID가 나온다
아래의 P2PPacket Is Not Vailable은 cpp 코드 114번째 줄인데 로그를 잘못 넣은 것이므로 로그를 고치거나 신경 쓰지 않아도 된다.

PS. 스팀 api 문서에서 ISteamNetworking이 더 이상 사용되지 않거나 지워질 것이라고 한다. 그래도 스팀을 통해 유저 간 메시지를 주고받는 기능을 개발해 봄으로써 스팀 api의 다른 네트워크 기능도 크게 다르지 않을 것이기 때문에 이 포스팅을 통해서 쉽게 메시지 송/수신을 한번 해 보고 다른 기능들도 이용하면서 네트워크에 대한 지식도 쌓아가면 좋을 것 같다.
'UnrealEngine > Steam' 카테고리의 다른 글
| UE4 SteamCloud에 데이터 저장/불러오기 (0) | 2024.02.22 |
|---|