OnlineSubsystemEOS の動作確認: 認証の確認

前回 EOS_Platform_Tick が呼び出されている箇所を確認したので、SDK の Sample で確認したように Callback が受け取れることが分かりました。
続いて認証について確認します。

EOS_Auth_Login の呼び出し箇所

SDK の Sample で確認した際は EOS_Auth_Login を利用していたので、今回もそこを起点に確認します。

FUserManagerEOS::LoginFUserManagerEOS::LoginViaExternalAuth の二箇所で使われていますが、今回は前者のみ確認します。

bool FUserManagerEOS::Login(int32 LocalUserNum, const FOnlineAccountCredentials& AccountCredentials)
{
    LocalUserNumToLastLoginCredentials.Emplace(LocalUserNum, MakeShared<FOnlineAccountCredentials>(AccountCredentials));

    FEOSSettings Settings = UEOSSettings::GetSettings();

    // 略

    EOS_Auth_LoginOptions LoginOptions = { };
    LoginOptions.ApiVersion = EOS_AUTH_LOGIN_API_LATEST;
    LoginOptions.ScopeFlags = EOS_EAuthScopeFlags::EOS_AS_BasicProfile | EOS_EAuthScopeFlags::EOS_AS_FriendsList | EOS_EAuthScopeFlags::EOS_AS_Presence;

    FPlatformEOSHelpersPtr EOSHelpers = EOSSubsystem->GetEOSHelpers();

    FAuthCredentials Credentials;
    LoginOptions.Credentials = &Credentials;
    EOSHelpers->PlatformAuthCredentials(Credentials);

    if (AccountCredentials.Type == TEXT("exchangecode"))
    {
        // This is how the Epic launcher will pass credentials to you
        FCStringAnsi::Strncpy(Credentials.TokenAnsi, TCHAR_TO_UTF8(*AccountCredentials.Token), EOS_MAX_TOKEN_SIZE);
        Credentials.Type = EOS_ELoginCredentialType::EOS_LCT_ExchangeCode;
    }
    else if (AccountCredentials.Type == TEXT("developer"))
    {
        // This is auth via the EOS auth tool
        Credentials.Type = EOS_ELoginCredentialType::EOS_LCT_Developer;
        FCStringAnsi::Strncpy(Credentials.IdAnsi, TCHAR_TO_UTF8(*AccountCredentials.Id), EOS_OSS_STRING_BUFFER_LENGTH);
        FCStringAnsi::Strncpy(Credentials.TokenAnsi, TCHAR_TO_UTF8(*AccountCredentials.Token), EOS_MAX_TOKEN_SIZE);
    }
    else if (AccountCredentials.Type == TEXT("accountportal"))
    {
        // This is auth via the EOS Account Portal
        Credentials.Type = EOS_ELoginCredentialType::EOS_LCT_AccountPortal;
    }
    else
    {
        UE_LOG_ONLINE(Warning, TEXT("Unable to Login() user (%d) due to missing auth parameters"), LocalUserNum);
        TriggerOnLoginCompleteDelegates(LocalUserNum, false, *FUniqueNetIdEOS::EmptyId(), FString(TEXT("Missing auth parameters")));
        return false;
    }

    FLoginCallback* CallbackObj = new FLoginCallback();
    CallbackObj->CallbackLambda = [this, LocalUserNum](const EOS_Auth_LoginCallbackInfo* Data)
    {
        if (Data->ResultCode == EOS_EResult::EOS_Success)
        {
            // Continue the login process by getting the product user id for EAS only
            ConnectLoginEAS(LocalUserNum, Data->LocalUserId);
        }
        else
        {
            FString ErrorString = FString::Printf(TEXT("Login(%d) failed with EOS result code (%s)"), LocalUserNum, ANSI_TO_TCHAR(EOS_EResult_ToString(Data->ResultCode)));
            UE_LOG_ONLINE(Warning, TEXT("%s"), *ErrorString);
            TriggerOnLoginCompleteDelegates(LocalUserNum, false, *FUniqueNetIdEOS::EmptyId(), ErrorString);
        }
    };
    // Perform the auth call
    EOS_Auth_Login(EOSSubsystem->AuthHandle, &LoginOptions, (void*)CallbackObj, CallbackObj->GetCallbackPtr());
    return true;
}

まず EOS_Auth_LoginOptionsApiVersionScopeFlagsSDK の Sample と同じものが指定されています。

続いて Credentials ですが、EOS_Auth_Credentials が型になるはずですが、ここでは FAuthCredentials となっています。ただ、以下のように定義されているため同じです。

struct FAuthCredentials :
    public EOS_Auth_Credentials

    FAuthCredentials() :
        EOS_Auth_Credentials()
    {
        ApiVersion = EOS_AUTH_CREDENTIALS_API_LATEST;
        Id = IdAnsi; // 空
        Token = TokenAnsi; // 空
    }

その後で EOSHelpers->PlatformAuthCredentials(Credentials); が呼び出されていますが、実装が空なので結局は SDK の Sample と同じく ApiVersion のみ指定されたと考えてよさそうです。

続いて Callback ですが、成功時には SDK の Sample と同様に EOS_Connect_Login へと続けています。

よって、だいたいの流れは同じと考えて良さそうです。また、この辺のコードは FUserManagerEOS にまとまっているようです。

FUserManagerEOS は以下のように実装しているインターフェースが多いです。

class FUserManagerEOS
    : public IOnlineIdentity
    , public IOnlineExternalUI
    , public IOnlineFriends
    , public IOnlinePresence
    , public IOnlineUser

FUserManager を使う方法

前回見た FOnlineSubsystemEOS::Init 内で初期化されており FOnlineSubsystemEOS から呼び出すことができます。

また、上述したように実装しているインターフェースが多いので、 FOnlineSubsystemEOS の多くの Get メソッドから返される実態も UserManager になっています。

IOnlineFriendsPtr FOnlineSubsystemEOS::GetFriendsInterface() const
{
    return UserManager;
}

IOnlineExternalUIPtr FOnlineSubsystemEOS::GetExternalUIInterface() const
{
    return UserManager;
}

IOnlineIdentityPtr FOnlineSubsystemEOS::GetIdentityInterface() const
{
    return UserManager;
}

IOnlineUserPtr FOnlineSubsystemEOS::GetUserInterface() const
{
    return UserManager;
}

IOnlinePresencePtr FOnlineSubsystemEOS::GetPresenceInterface() const
{
    return UserManager;
}

ここまで見てきたように FOnlineSubsystemEOS は OnlineSubsystemEOS を使う際の公開部分なので、直接 OnlineSubsystem として取得することができます。

[UE4] 複数のOnlineSubsystemを併用する - Qiita

今回はデフォルトを EOS にしてあるので、

[OnlineSubsystem]
DefaultPlatformService=EOS

以下で取得することができます。

IOnlineSubsystem::Get();

FUserManagerEOS::Login が他に呼び出される箇所

UOnlineEngineInterfaceImpl::LoginPIEInstance で呼び出されることが確認できました。

以下から Credential 情報を取得し PIE 起動時に渡しているようです。後日ちゃんと試してみようと思います。

f:id:you1dan:20220127175900p:plain

まとめ

今回は、認証の簡単な流れを SDK の Sample と比較しつつ眺めました。当たり前かもしれませんが、SDK で説明されていることと同じように実装されているように見えました。

いくつかマクロでわからない部分があったりしたので、そこは別途拾ってみようと思います。

OnlineSubsystemEOS の動作確認: EOS_Platform_Tick の確認

前回 で動作確認用のプロジェクトのセットアップをしました。

まずは SDK で確認したような認証部分から見ていきたいと思います。

Epic Online Services サンプルの AuthAndFriends のコードリーディング ⑤ - You are done!

上記で確認した内容のうち、今回は EOS_Platform_Tick について見ていきたいと思います。
というのも、これが実行されていないと EOS へのリクエストの Callback が実行されないので、これがどこで実行されているか確認しておきたいためです。

EOS_Platform_Tick の呼び出し

EOSShared Plugin の EOSSDKManager.cpp で呼び出されています。

bool FEOSSDKManager::Tick(float)
{
    //LLM_SCOPE(ELLMTag::EOSSDK); // TODO
    for (EOS_HPlatform PlatformHandle : PlatformHandles)
    {
        QUICK_SCOPE_CYCLE_COUNTER(FEOSSDKManager_Tick);
        EOS_Platform_Tick(PlatformHandle);
    }

    return true;
}

void FEOSPlatformHandle::Tick()
{
    QUICK_SCOPE_CYCLE_COUNTER(FEOSPlatformHandle_Tick);
    EOS_Platform_Tick(PlatformHandle);
}

QUICK_SCOPE_CYCLE_COUNTER に関しては以下を参照。
統計システムの概要 | Unreal Engine ドキュメント

後者の単独の PlatformHandle を呼び出している方は、 VoiceChat の方から呼び出されているので、今回は省略します。前者の FEOSSDKManager::Tick を中心に見ていきます。

FEOSSDKManager::Tick の呼び出し

同じファイルの以下のメソッドから呼び出されています。

IEOSPlatformHandlePtr FEOSSDKManager::CreatePlatform(const EOS_Platform_Options& PlatformOptions)
{
    IEOSPlatformHandlePtr SharedPlatform;

    if (IsInitialized())
    {
        const EOS_HPlatform PlatformHandle = EOS_Platform_Create(&PlatformOptions);
        if (PlatformHandle)
        {
            PlatformHandles.Emplace(PlatformHandle);
            SharedPlatform = MakeShared<FEOSPlatformHandle, ESPMode::ThreadSafe>(*this, PlatformHandle);

            if (!TickerHandle.IsValid())
            {
                TickerHandle = FTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateRaw(this, &FEOSSDKManager::Tick), 0.0f);
            }
        }
        else
        {
            UE_LOG(LogEOSSDK, Warning, TEXT("FEOSSDKManager::CreatePlatform failed. EosPlatformHandle=nullptr"));
        }
    }
    else
    {
        UE_LOG(LogEOSSDK, Warning, TEXT("FEOSSDKManager::CreatePlatform failed. SDK not initialized"));
    }

    return SharedPlatform;
}

EOS_Platform_Create

Epic Online Services サンプルの AuthAndFriends のコードリーディング ② - You are done!

上記でも触れていますが、SDK の Sample でも EOS_Platform_Create を呼び出しています。
加えて SDK の Sample では、main ループの中で EOS_Platform_Tick も呼び出されています。

では、Unreal Engine としては、どうやってループ処理に EOS_Platform_Tick が組み込まれているのでしょうか。

FTicker

FTicker に今回の処理が追加されています。FTicker については以下のスライドの 24 ページ目に説明があります。
処理の起点となっている FEngineLoop::Tick に紐付いているようです。つまり、EOS_Platform_Tick が期待通り呼び出されていることが確認できました。

UE4プログラマー勉強会 in 大阪 -エンジンの内部挙動について

ここまでで、一旦今回の目的は果たせたのですが、ついでに OnlineSubsytemEOS Plugin の初期化に関して掴んでおきたいと思います。

FEOSSDKManager::CreatePlatform 以降の呼び出し階層

- FEOSSDKManager::CreatePlatform
  - FEOSHelpers::CreatePlatform
    - FOnlineSubsystemEOS::PlatformCreate
      - FOnlineSubsystemEOS::Init
        - FOnlineFactoryEOS::CreateSubsystem
---------------- 以降は OnlineSubsystem Plugin ------
          - FOnlineSubsystemModule::GetOnlineSubsystem

以降は気になる部分だけ見ていきます。

FOnlineSubsystemEOS::PlatformCreate

/** Common method for creating the EOS platform */
bool FOnlineSubsystemEOS::PlatformCreate()
{
    FString ArtifactName;
    FParse::Value(FCommandLine::Get(), TEXT("EpicApp="), ArtifactName);
    // Find the settings for this artifact
    FEOSArtifactSettings ArtifactSettings;
    if (!UEOSSettings::GetSettingsForArtifact(ArtifactName, ArtifactSettings))
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS::PlatformCreate() failed to find artifact settings object for artifact (%s)"), *ArtifactName);
        return false;
    }

    // Create platform instance
    FEOSPlatformOptions PlatformOptions;
    FCStringAnsi::Strncpy(PlatformOptions.ClientIdAnsi, TCHAR_TO_UTF8(*ArtifactSettings.ClientId), EOS_OSS_STRING_BUFFER_LENGTH);
    FCStringAnsi::Strncpy(PlatformOptions.ClientSecretAnsi, TCHAR_TO_UTF8(*ArtifactSettings.ClientSecret), EOS_OSS_STRING_BUFFER_LENGTH);
    FCStringAnsi::Strncpy(PlatformOptions.ProductIdAnsi, TCHAR_TO_UTF8(*ArtifactSettings.ProductId), EOS_OSS_STRING_BUFFER_LENGTH);
    FCStringAnsi::Strncpy(PlatformOptions.SandboxIdAnsi, TCHAR_TO_UTF8(*ArtifactSettings.SandboxId), EOS_OSS_STRING_BUFFER_LENGTH);
    FCStringAnsi::Strncpy(PlatformOptions.DeploymentIdAnsi, TCHAR_TO_UTF8(*ArtifactSettings.DeploymentId), EOS_OSS_STRING_BUFFER_LENGTH);
    PlatformOptions.bIsServer = IsRunningDedicatedServer() ? EOS_TRUE : EOS_FALSE;
    PlatformOptions.Reserved = nullptr;
    FEOSSettings EOSSettings = UEOSSettings::GetSettings();
    uint64 OverlayFlags = 0;
    if (!EOSSettings.bEnableOverlay)
    {
        OverlayFlags |= EOS_PF_DISABLE_OVERLAY;
    }
    if (!EOSSettings.bEnableSocialOverlay)
    {
        OverlayFlags |= EOS_PF_DISABLE_SOCIAL_OVERLAY;
    }
    PlatformOptions.Flags = IsRunningGame() ? OverlayFlags : EOS_PF_DISABLE_OVERLAY;
    // Make the cache directory be in the user's writable area

    const FString CacheDir = EOSHelpersPtr->PlatformCreateCacheDir(ArtifactName, EOSSettings.CacheDir);
    FCStringAnsi::Strncpy(PlatformOptions.CacheDirectoryAnsi, TCHAR_TO_UTF8(*CacheDir), EOS_OSS_STRING_BUFFER_LENGTH);
    FCStringAnsi::Strncpy(PlatformOptions.EncryptionKeyAnsi, TCHAR_TO_UTF8(*ArtifactSettings.EncryptionKey), EOS_ENCRYPTION_KEY_MAX_BUFFER_LEN);

#if WITH_EOS_RTC
    EOS_Platform_RTCOptions RtcOptions = { 0 };
    RtcOptions.ApiVersion = EOS_PLATFORM_RTCOPTIONS_API_LATEST;
    RtcOptions.PlatformSpecificOptions = nullptr;
    PlatformOptions.RTCOptions = &RtcOptions;
#endif

    EOSPlatformHandle = EOSHelpersPtr->CreatePlatform(PlatformOptions);
    if (EOSPlatformHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS::PlatformCreate() failed to init EOS platform"));
        return false;
    }
    
    return true;
}

UEOSSettings::GetSettingsForArtifact の部分は以下なのですが、Project Settings の EOS Settings で Artifacts に設定した内容を取得しているのが分かります。

bool UEOSSettings::GetSettingsForArtifact(const FString& ArtifactName, FEOSArtifactSettings& OutSettings)
{
    return UEOSSettings::ManualGetSettingsForArtifact(ArtifactName, OutSettings);
}

bool UEOSSettings::ManualGetSettingsForArtifact(const FString& ArtifactName, FEOSArtifactSettings& OutSettings)
{
    static TOptional<FString> CachedDefaultArtifactName;
    static TOptional<TArray<FEOSArtifactSettings>> CachedArtifactSettings;

    if (!CachedDefaultArtifactName.IsSet())
    {
        CachedDefaultArtifactName.Emplace();

        GConfig->GetString(INI_SECTION, TEXT("DefaultArtifactName"), *CachedDefaultArtifactName, GEngineIni);
    }

    if (!CachedArtifactSettings.IsSet())
    {
        CachedArtifactSettings.Emplace();

        TArray<FString> Artifacts;
        GConfig->GetArray(INI_SECTION, TEXT("Artifacts"), Artifacts, GEngineIni);
        for (const FString& Line : Artifacts)
        {
            FEOSArtifactSettings Artifact;
            Artifact.ParseRawArrayEntry(Line);
            CachedArtifactSettings->Add(Artifact);
        }
    }
// 略

以降も設定値を取得し FEOSPlatformOptions を組み立て、CreatePlatform に渡しているのが分かります。

FOnlineSubsystemEOS::Init

bool FOnlineSubsystemEOS::Init()
{
    // Determine if we are the default and if we're the platform OSS
    FString DefaultOSS;
    GConfig->GetString(TEXT("OnlineSubsystem"), TEXT("DefaultPlatformService"), DefaultOSS, GEngineIni);
    FString PlatformOSS;
    GConfig->GetString(TEXT("OnlineSubsystem"), TEXT("NativePlatformService"), PlatformOSS, GEngineIni);
    bIsDefaultOSS = DefaultOSS == TEXT("EOS");
    bIsPlatformOSS = PlatformOSS == TEXT("EOS");

    // Check for being launched by EGS
    bWasLaunchedByEGS = FParse::Param(FCommandLine::Get(), TEXT("EpicPortal"));
    FEOSSettings EOSSettings = UEOSSettings::GetSettings();
    if (!IsRunningDedicatedServer() && IsRunningGame() && !bWasLaunchedByEGS && EOSSettings.bShouldEnforceBeingLaunchedByEGS)
    {
        FString ArtifactName;
        FParse::Value(FCommandLine::Get(), TEXT("EpicApp="), ArtifactName);
        UE_LOG_ONLINE(Warning, TEXT("FOnlineSubsystemEOS::Init() relaunching artifact (%s) via the store"), *ArtifactName);
        FPlatformProcess::LaunchURL(*FString::Printf(TEXT("com.epicgames.launcher://store/product/%s?action=launch&silent=true"), *ArtifactName), nullptr, nullptr);
        FPlatformMisc::RequestExit(false);
        return false;
    }

    EOSSDKManager = IEOSSDKManager::Get();
    if (!EOSSDKManager)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS::Init() failed to get EOSSDKManager interface"));
        return false;
    }

    if (!PlatformCreate())
    {
        return false;
    }

    // Get handles for later use
    AuthHandle = EOS_Platform_GetAuthInterface(*EOSPlatformHandle);
    if (AuthHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get auth handle"));
        return false;
    }
    UserInfoHandle = EOS_Platform_GetUserInfoInterface(*EOSPlatformHandle);
    if (UserInfoHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get user info handle"));
        return false;
    }
    UIHandle = EOS_Platform_GetUIInterface(*EOSPlatformHandle);
    if (UIHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get UI handle"));
        return false;
    }
    FriendsHandle = EOS_Platform_GetFriendsInterface(*EOSPlatformHandle);
    if (FriendsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get friends handle"));
        return false;
    }
    PresenceHandle = EOS_Platform_GetPresenceInterface(*EOSPlatformHandle);
    if (PresenceHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get presence handle"));
        return false;
    }
    ConnectHandle = EOS_Platform_GetConnectInterface(*EOSPlatformHandle);
    if (ConnectHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get connect handle"));
        return false;
    }
    SessionsHandle = EOS_Platform_GetSessionsInterface(*EOSPlatformHandle);
    if (SessionsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get sessions handle"));
        return false;
    }
    StatsHandle = EOS_Platform_GetStatsInterface(*EOSPlatformHandle);
    if (StatsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get stats handle"));
        return false;
    }
    LeaderboardsHandle = EOS_Platform_GetLeaderboardsInterface(*EOSPlatformHandle);
    if (LeaderboardsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get leaderboards handle"));
        return false;
    }
    MetricsHandle = EOS_Platform_GetMetricsInterface(*EOSPlatformHandle);
    if (MetricsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get metrics handle"));
        return false;
    }
    AchievementsHandle = EOS_Platform_GetAchievementsInterface(*EOSPlatformHandle);
    if (AchievementsHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get achievements handle"));
        return false;
    }
    P2PHandle = EOS_Platform_GetP2PInterface(*EOSPlatformHandle);
    if (P2PHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get p2p handle"));
        return false;
    }
    // Disable ecom if not part of EGS
    if (bWasLaunchedByEGS)
    {
        EcomHandle = EOS_Platform_GetEcomInterface(*EOSPlatformHandle);
        if (EcomHandle == nullptr)
        {
            UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get ecom handle"));
            return false;
        }
        StoreInterfacePtr = MakeShareable(new FOnlineStoreEOS(this));
    }
    TitleStorageHandle = EOS_Platform_GetTitleStorageInterface(*EOSPlatformHandle);
    if (TitleStorageHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get title storage handle"));
        return false;
    }
    PlayerDataStorageHandle = EOS_Platform_GetPlayerDataStorageInterface(*EOSPlatformHandle);
    if (PlayerDataStorageHandle == nullptr)
    {
        UE_LOG_ONLINE(Error, TEXT("FOnlineSubsystemEOS: failed to init EOS platform, couldn't get player data storage handle"));
        return false;
    }

    SocketSubsystem = MakeShareable(new FSocketSubsystemEOS(this));
    FString ErrorMessage;
    SocketSubsystem->Init(ErrorMessage);

    UserManager = MakeShareable(new FUserManagerEOS(this));
    SessionInterfacePtr = MakeShareable(new FOnlineSessionEOS(this));
    // Set the bucket id to use for all sessions based upon the name and version to avoid upgrade issues
    SessionInterfacePtr->Init(EOSSDKManager->GetProductName() + TEXT("_") + EOSSDKManager->GetProductVersion());
    StatsInterfacePtr = MakeShareable(new FOnlineStatsEOS(this));
    LeaderboardsInterfacePtr = MakeShareable(new FOnlineLeaderboardsEOS(this));
    AchievementsInterfacePtr = MakeShareable(new FOnlineAchievementsEOS(this));
    TitleFileInterfacePtr = MakeShareable(new FOnlineTitleFileEOS(this));
    UserCloudInterfacePtr = MakeShareable(new FOnlineUserCloudEOS(this));

    // We initialized ok so we can tick
    StartTicker();

    return true;
}

見ての通りですが、PlatformCreate 後に、取得した PlatformHandle を使って、利用する EOS のインターフェースの Handle を次々に取得しています。
このクラスがかなりの依存性を一手に引き受けているようです。

この FOnlineSubsystemEOS クラスが以下のように OnlineSubsystemEOS の主要な公開 API になっているので、使い勝手として集約されているのかもしれません。(この辺の設計のプラクティスはわかっていないです)

class ONLINESUBSYSTEMEOS_API FOnlineSubsystemEOS : 
    public FOnlineSubsystemImpl

余談ですが、継承している FOnlineSubsystemImpl は以下の定義になっており FTickerObjectBase も実装する形になっています。内部では FTicker::GetCoreTicker() が使われており、ExecuteNextTick 関数を通して Callback の実行を登録できるようになっています。

/**
 * FOnlineSubsystemImpl - common functionality to share across online platforms, not intended for direct use
 */
class ONLINESUBSYSTEM_API FOnlineSubsystemImpl 
    : public IOnlineSubsystem
    , public FTickerObjectBase

まとめ

今回は OnlineSubsystemEOS の中で EOS_Platform_Tick がどのように呼び出されているかを SDK の Sample と比較しつつ確認しました。
また、初期化部分を簡単に眺めることで、FOnlineSubsystemEOS クラスがプラグインAPI として多くの役割を持っていることが確認できました。

次回は認証部分が SDK の Sample と比較してどのように実装されているかを確認していきたいと思います。

OnlineSubsystemEOS の動作確認のためのプロジェクトを作る

SDK のサンプルを眺めて来ましたが、OnlineSubsystem での動作も同様にできるかを確認してみようと思います。

まずは、動作確認というかデバッグできるような環境を作ります。

環境

お昼の仕事の関係で JetBrains 製品の方が慣れているので今回は Visual Studio ではなく Rider を使います

Rider を Unreal Editor の Source Code Editor に設定する

デフォルトだと Visual Studio になっているので、Rider に変更します。Editor Preference から変更できます。

f:id:you1dan:20220125171912p:plain

再起動すると、Unreal Editor の 「Open ・・・」が以下のように変わります。

f:id:you1dan:20220125171957p:plain

プロジェクトを作成

Unreal Editor から C++ プロジェクトを作成します。今回は EOSPlayground としました。Rider で開くと以下のようになっています。

f:id:you1dan:20220125172154p:plain

OnlineSubsytemEOS を Engine からコピーしてくる

4.27 系では OnlineSubsystemEOS はデフォルトで入っているのですが、デバッグやコードリーディングの環境が欲しいので、Engine からコピーしてきます。

Plugins を作成し Engine の Plugins から コピーしてくる
f:id:you1dan:20220125172331p:plain f:id:you1dan:20220125172318p:plain f:id:you1dan:20220125172337p:plain

Plugin を認識させる

uproject のコンテクストメニューから「Generate Visual Studio project」を実行します。 f:id:you1dan:20220125172524p:plain

すると、Rider のエクスプローラーに Plugins が表示されます。(Intermediate のコードが変更されているので、通常コード管理されないので注意) f:id:you1dan:20220125172530p:plain

プラグイン一覧に出てきているので、有効化します。 f:id:you1dan:20220125172536p:plain

.uproject に以下のような差分がでるはずです。

+       ],
+       "Plugins": [
+               {
+                       "Name": "OnlineSubsystemEOS",
+                       "Enabled": true
+               }

Rider 側でビルドしてもいいですし、有効化した時点でビルドが実行されると思うのでそのビルドに任せても大丈夫です。

EOS の設定値を反映

プロジェクト設定から反映します。

Encryption Key には 1111111111111111111111111111111111111111111111111111111111111111 を設定しておきます。( empty にできないというログがでる )

f:id:you1dan:20220125214330p:plain

DefaultEngine.ini の設定

EOS Online Subsytem (OSS) プラグイン | Unreal Engine ドキュメント

上記ドキュメントのプロジェクト設定を反映します。特に変更なく、以下を追記する形です。

[OnlineSubsystemEOS]
bEnabled=true

[OnlineSubsystem]
DefaultPlatformService=EOS

[/Script/Engine.GameEngine]
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemEOS.NetDriverEOS",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")

[/Script/OnlineSubsystemEOS.NetDriverEOS]
bIsUsingP2PSockets=true

まとめ

今回はプロジェクトの設定のみを行いました。次回は SDK のサンプルで見ていったようにまずはログインから試していければと思います。

Epic Online Services サンプルのコードリーディング: EOS Connect インターフェースについて

前回 の最後で、以下の疑問が出ました。

  • EOS Connect インターフェースでのログインはどういうことか
  • EOS_EpicAccountIdEOS_ProductUserId はどのように違うのか

今回は P2PNAT のコードリーディングを少し寄り道して、上記について見ていきたいと思います。

Connect インターフェース | Epic Online Services ドキュメンテーション

Epic アカウント

Auth インターフェース | Epic Online Services ドキュメンテーション

Auth インターフェースでは、ローカル ユーザーが Epic アカウントを使用してログインすることができます。これにより、Friends インターフェース、Presence インターフェース、UserInfo インターフェース、Ecom インターフェースなどの Epic Account Services (EAS) が提供する各種機能にアクセスできます。Auth インターフェース は、EOS との Epic アカウント関連のインタラクションを処理し、ユーザーを認証してアクセス トークンを取得する機能を提供します。

Epic Online Service とは別に Epic Account Service があり、Epic アカウントはそこに属するユーザーのようです。

ID プロバイダを管理する | Epic Online Services ドキュメンテーション

Epic Online Services (EOS) は、Identity Provider (ID プロバイダ) (または「プラットフォーム」) と呼ばれる複数のオンライン ストアおよびゲーム サービスのプレイヤー アカウントをリンクすることができます。これにより、ユーザーは使用するさまざまなプラットフォーム間でデータを共有し、マッチメイキングなどのクロスプラットフォーム機能に参加できます。 EOS がサポートする ID プロバイダー:

上記ドキュメントにあるように、EOS は Identity Provider で、EOS のアカウントと、外部のサービスをリンクすることができます。その中に 「Epic Games」も含まれており、Epic Account Services がそこに該当すると考えられます。
ドキュメントにある通り、Epic Games だけは EOS での外部プロバイダ設定が不要であるため、私の設定画面ではなにも追加されていない状態です。

f:id:you1dan:20220120073520p:plain

よって、Epic アカウントは、Steam 等のアカウントと同じと考えれば良さそうです。

Connect インターフェース | Epic Online Services ドキュメンテーション

Connect インターフェース を使用すると、外部 ID プロバイダ が Epic Online Services (EOS) エコシステムを統合して使用することができます。
Connect インターフェース では、次のことを行うことができます。

・Epic Online Services (EOS) ゲーム サービスにアクセスできる。
クロスプラットフォーム サービス内で、共通の一意のユーザー識別子を生成する。
・外部 ID プロバイダのアカウントを Epic Games のサービスにリンクさせる。

プレイヤーは、1 つまたは複数の外部ユーザー アカウントを、自分の 製品ユーザー ID (一意のプレイヤー ID) と関連付けることができます。

ここで Connect インターフェースが出てきますが、上記のように Connect インターフェースを使用することを前提として「EOS ゲームサービスにアクセスできる」というところが一つポイントです。

つまり、今回読んでいたサンプルで、私は Epic アカウントでログインをしていましたが、それだけでは EOS のサービス(例えば NATP2P など)を利用することはできません。
一方で、「外部 ID プロバイダのアカウントを Epic Games のサービスにリンクさせる」に関しては、Epic アカウントであればそもそも Epic Games のサービスを受けられるので、ここに Epic アカウントの特殊性はあるように思います。

リンクとはなにか

「リンク」に関してですが、以下の 2 つのリンク機能が存在します。

  • EOS_Connect_LinkAccount | Epic Online Services ドキュメンテーション
    • Link a set of external auth credentials with an existing product user on the Epic Online Service.

  • EOS_Auth_LinkAccount | Epic Online Services ドキュメンテーション
    • Link external account by continuing previous login attempt with a continuance token. On Desktop and Mobile platforms, the user will be presented the Epic Account Portal to resolve their identity. On Console, the user will login to their Epic Account using an external device, e.g. a mobile device or a desktop PC, by browsing to the presented authentication URL and entering the device code presented by the game on the console. On success, the user will be logged in at the completion of this action. This will commit this external account to the Epic Account and cannot be undone in the SDK.

上記は EOS の Connect インターフェースと EAS (Epic Account Service) の Auth インターフェースにそれぞれ存在しています。

これらは全く異なる機能であり、それは引数となるオプションを見ても読み取れます。

つまり、Connect インターフェースがリンクするのは「製品ユーザー ID と外部 ID」、Auth インターフェースがリンクするのは、「(Epic 以外の) 外部 ID と Epic アカウント」ということになりそうです。

製品ユーザーID については、以下のスライドの説明がわかりやすいです。 https://image.slidesharecdn.com/whatyoucandowithepiconlineservice202007-200720062123/95/epic-online-services-20-638.jpg?cb=1595241698 Epic Online Services でできること

Connect インターフェースと Auth インターフェースについて

自分でも理解できているか怪しくなってきたのでもう少し調べます。

Epic アカウント サービス(EAS)によるプレイヤー認証 - Epic Online Services

上記からの長めの引用になりますが。

よくある混乱の原因となるのが、Auth インターフェース と Connect インターフェースの使い分けです。どちらも似たようなパターンのログイン機能を提供するためです。しかし、それぞれ特定の目的を持っています。

Auth インターフェース
・Auth インターフェースは、Epic アカウントへの認証を処理します。そのため、Epic アカウントサービス が設定されている必要があります。
・Auth インターフェースを介した認証は、EOSのフレンド、プレゼンス、Eコマース機能へのアクセスを提供します。
・Auth インターフェースは一意のEpic アカウント ID を使用します。
Connect インターフェース
・Connect インターフェースは、Epic ゲームサービスへの認証を処理します。このインターフェースはプロバイダに依存しないため、さまざまな ID プロバイダ(Epic Games、Steam、Xbox Live など)で使用することができます。
・Connect インターフェースは Epic アカウントに依存しません。その代わりに、組織内の特定の製品の一意の製品ユーザーID(PUID)を使用します。

簡単に言えば、Authインターフェースは Epic アカウントと関連するソーシャルグラフAPIを処理する一方、Connect インターフェースは、ゲームに代わって作成され、外部ID とリンクする必要のある、一意のユーザーIDを処理するということです。Connect インターフェースで使用されるIDはソーシャルグラフではないため、複数のIDに接続されたクロスプレイやクロスプログレッションに使用することができ、Auth インターフェースなしで使用することができます。

さらにもう一つ引用します。

Connect インターフェースを使用して EOS ゲーム サービスにアクセスする - Epic Online Services

Auth インターフェースと Connect インターフェースの比較 (概要)
これらのインターフェース間の違いについては、Epic アカウント サービス (EAS) での認証 の記事で詳しく解説しました。その概要は、Auth インターフェース は Epic アカウントを認証して、プレゼンスやフレンド情報で Epic アカウント サービスを使用する、さらに Epic Games ストアで購入するために使用されます

Connect インターフェース は今後このシリーズで使用するもので、使用する製品の各プレイヤーに対する固有の製品ユーザー ID (PUID) を生成し、マルチプレイヤー、進行状況、ゲームの運用をカバー するゲーム サービスを使用します。これらはそれぞれ独立して使用でき、Connect インターフェースは サポート対象の ID プロバイダ とともに使用できます。Epic アカウントを使用する必要はありません。

ここまでで、EAS が Stema アカウントや PSN のアカウントと同等であることは間違いなさそうです。特にフレンド等のソーシャルグラフ、ストアでの購入情報などは各プロバイダが保有するものなので、そこは EOS がどうこうできるものではないと思われます。

そして EOS は EAS とは全く関係がなく、EAS も含めてプロバイダと製品を連携し、Epic ゲームサービスの機能を利用可能にすることができます。
よって、EOS の多くのサンプルでは EAS での例が多いですが、EAS を利用せず、Steam アカウントのみで EOS にログインすることが可能です。

最後に上記ページからもう一つ引用します。

このサンプル アプリでは、Epic アカウントを使用する Connect API インテグレーションを実装します。このコードは実装済みだからです。ただし、先に進む前に、サポート対象 ID プロバイダを使用するために、どのようなフローで実装されているのかを順に確認します。

  1. ゲームでプレイヤーに確認するのは、ID プロバイダ (A) を使用して Connect.Login() を通じて認証するかどうかです。
  2. 指定 ID プロバイダで対象プレイヤーに対する製品 ID (PUID) が見つかった場合、このコールバックの ResultCode が Result.Success になり、コールバックが PUID を LocalUserId メンバーに返します。
  3. . PUID が見つからない場合、ResultCode が Result.InvalidUser になり、コールバックは ContinuanceToken を返します。この時点で、複数の ID プロバイダをサポートしている場合に、プレイヤーが他のサポート ID プロバイダを使用して認証するかどうかをプレイヤーに確認できます。 これを実行するのは、セカンド PUID が作成され、進行状況データが新しいものとしてそれに関連付けられないようにするためです。この場合は後でアカウントをマージする必要があります。他の方法として、この ID を引き続き使用するのかを確認します (それから新しい PUID を作成)。
  4. ユーザーがこの ID をそのまま使用することを希望する場合、Connect.CreateUser() を使用して、ContinuanceToken を CreateUserOptions を通じて渡します。このとき ResultCode が Result.Success であれば、PUID が LocalUserId メンバーに見つかります。
  5. ユーザーが異なる ID プロバイダ (B) での認証を希望する場合、この ContinuanceToken を保存し、再び Connect.Login() を ID プロバイダ B を使用して呼び出します。ログインに成功した後、両方の ID をリンクするかどうかをユーザーに確認できます。Connect.LinkAccount() を呼び出して、両方のアカウントが同じ PUID に関連付けられていることを確認します。

例えば、ID プロバイダを最初は Steam を利用していたとします。
ここで、ある PC ゲームに対して、Steam でログインし、すでにプレイしていれば OK です。 (上記 1, 2)
もしそれが初回のプレイの場合は、 PUID が存在しないため、InvalidUser となります。 (上記 3)
このゲームを他のプロバイダのアカウント (Epic アカウントや PSN のアカウントなど)でプレイしたことがなければ、新規に PUID が作成されます。(上記 4)
逆に、そのゲームを Epic アカウント等の別の外部アカウントでプレイしたことがあった場合には、 その別の外部アカウントでログインするかを確認されます。(実際には、サポートするあらゆるプロバイダが一覧されるはず)
そして、その別の外部アカウントと、Steam アカウントをリンクするかを確認されます。(上記 5)

おそらく、ここでリンクしてしまえば、今後はどちらのアカウントでログインしたとしても内部的にはそのゲームで同じ PUID が利用されることになりそうです。
これができると、コンソールをまたがっていたとしても、共通の PUID が利用できるということになりそうです。(ただ、おそらくは主要なアカウントが一つあって、そこに対して認証を追加できる形になるのかなと 🤔でないと、プレイ実績はともかく、購入品の管理なんかは難しそうなので)

まとめ

では最後に当初の疑問を解消する形で締めておきます。

  • EOS Connect インターフェースでのログインはどういうことか
  • EOS_EpicAccountIdEOS_ProductUserId はどのように違うのか

前者に関しては、EOS Connect インターフェースでログインすることで、その製品(つまりゲーム)で EOS の機能を利用可能にします。
EOS にログインするためには、何らかの外部資格情報が必要になり、それが Steam アカウントであったり、Epic アカウントであったりします。

また、EOS としては、製品ユーザーID で製品とユーザーを紐付けて管理しています。これは、一つの外部資格情報のユーザーであっても製品が異なれば製品ユーザーIDは異なるということです。

正直、コードを書いてみないと腹落ちしない感じはしますが、一旦この辺にしておきます。

前回の追記

今回の整理を受けて改めて前回の EOS_P2P_ReceivePacket の引数に EpicAccountId が渡されているのはおかしいのではないかと思い見直してみましたが、単にサンプルでは以下のように TEpicAccountId という構造体をEOS_EpicAccountIdEOS_ProductUserId の両方の wrapper として利用しており、値のプロパティが AccountId であるため、読み違えてしまったというだけでした。

using FEpicAccountId = TEpicAccountId<EOS_EpicAccountId>;
using FProductUserId = TEpicAccountId<EOS_ProductUserId>;

なので、EOS_P2P_ReceivePacket の引数で渡されていたのは、EOS_ProductUserId でした。

Epic Online Services サンプルの P2PNAT のコードリーディング ②

前回 P2PNAT サンプルの初期化部分を中心にみていきました。

今回は以下の EOS のドキュメントを追いながらコードも見ていきたいと思います。

NAT P2P インターフェース | Epic Online Services ドキュメンテーション

接続をリクエストする

ローカル ユーザーが EOS_P2P_SendPacket 関数などでリモート ユーザーに情報を送信する場合、P2P インターフェースはこの 2 人のユーザー間の接続開始リクエストを自動で行い、その際に EOS_P2P_SocketId はこの接続の識別子として機能します。情報を送信するユーザーは SocketId に対して自分のリクエストを自動で承認しますが、情報を受信するユーザーはそれを承認する必要があり、通常は受信接続リクエストをリッスンして EOS_P2P_AcceptConnection 関数を使用します。

EOS_P2P_SendPacket

パケットの送信は、サンプルでは Chat メッセージの送信と同時に行われます。以下は、サンプルの Chat ウィンドウである FP2PNATDialog において、Chat の文字列を送信 (Enter を押した) 場合のイベントハンドラです。
FP2PNAT::SendMessage を呼び出しているのが分かります。 CurrentChat は前回の最後で設定している部分を見ましたが、Chat 相手の FProductUserId です。これは実態としては EOS_ProductUserId なのですが、ドキュメントにも以下の記述があります。

通常、有効な EOS_ProductUserId は両方のユーザーに必要で、データの送信で自分が誰かを示し、またデータの送信先のユーザーを指定します。

void FP2PNATDialog::OnSendMessage(const std::wstring& Value)
{
    if (CurrentChat.IsValid())
    {
        FChatWithFriendData& Chat = GetChat(CurrentChat);

        std::vector<std::wstring> NewLines;
        SplitMessageIntoLines(Value, NewLines);
        for (const std::wstring& NextLine : NewLines)
        {
            if (!NextLine.empty())
            {
                Chat.ChatLines.push_back(std::make_pair(true, NextLine));
            }
        }
        
        bChatViewDirty = true;

        FGame::Get().GetP2PNAT()->SendMessage(CurrentChat, Value);
    }

    ChatInputField->Clear();
}

続いて呼び出し先の FP2PNAT::SendMessage です。

void FP2PNAT::SendMessage(FProductUserId FriendId, const std::wstring& Message)
{
    if (!FriendId.IsValid() || Message.empty())
    {
        FDebugLog::LogError(L"EOS P2PNAT SendMessage: bad input data (account id is wrong or message is empty).");
        return;
    }

    PlayerPtr Player = FPlayerManager::Get().GetPlayer(FPlayerManager::Get().GetCurrentUser());
    if (Player == nullptr)
    {
        FDebugLog::LogError(L"EOS P2PNAT SendMessage: error user not logged in.");
        return;
    }

    EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());

    EOS_P2P_SocketId SocketId;
    SocketId.ApiVersion = EOS_P2P_SOCKETID_API_LATEST;
    strncpy_s(SocketId.SocketName, "CHAT", 5);

    EOS_P2P_SendPacketOptions Options;
    Options.ApiVersion = EOS_P2P_SENDPACKET_API_LATEST;
    Options.LocalUserId = Player->GetProductUserID();
    Options.RemoteUserId = FriendId;
    Options.SocketId = &SocketId;
    Options.bAllowDelayedDelivery = EOS_TRUE;
    Options.Channel = 0;
    Options.Reliability = EOS_EPacketReliability::EOS_PR_ReliableOrdered;

    std::string MessageNarrow = FStringUtils::Narrow(Message);

    Options.DataLengthBytes = static_cast<uint32_t>(MessageNarrow.size());
    Options.Data = MessageNarrow.data();

    EOS_EResult Result = EOS_P2P_SendPacket(P2PHandle, &Options);
    if (Result != EOS_EResult::EOS_Success)
    {
        FDebugLog::LogError(L"EOS P2PNAT SendMessage: error while sending data, code: %ls.", FStringUtils::Widen(EOS_EResult_ToString(Result)).c_str());
    }
}

FPlayerManager に関しては、ここまでで見ていないので飛ばします。ローカルのユーザーの情報を取得しているのだと思います。

まず EOS_P2P_SocketId に関してですが、socket name は application-defined となっているので、このサンプルのように CHAT のようなシンプルな名前をつけることは通常ないのかもしれません。そもそも設定用のプロパティも公開されていません。この場合だと、SocketId を保持していないからでしょうか。

続いて EOS_P2P_SendPacketOptions ですが、LocalUserIdRemoteUserId に Chat 相手と自身の FProductUserId を指定しています。Channel に関してはわかりませんでした。

接続リクエスト通知を受信する

EOS_P2P_AddNotifyPeerConnectionRequestFP2PNAT::OnGameEventUserConnectLoggedIn をハンドルする際に使われます。
この UserConnectLoggedInUserLoggedIn とは異なり、Connect インターフェース を利用した場合のイベントですが、サンプルアプリケーションは UserLoggedIn の場合に必ず Connect インターフェースでのログインも行われています。(ちょっとこの部分理解が浅いので後日調べたいと思います)

void FP2PNAT::OnGameEvent(const FGameEvent& Event)
{
    if (Event.GetType() == EGameEventType::UserConnectLoggedIn)
    {
        FProductUserId UserId = Event.GetProductUserId();
        OnUserConnectLoggedIn(UserId);
    }
    else if (Event.GetType() == EGameEventType::UserLoggedOut)
    {
        // 略
    }
}

void FP2PNAT::OnUserConnectLoggedIn(FProductUserId UserId)
{
    RefreshNATType();

    //subscribe to connection requests
    SubscribeToConnectionRequests();
}

void FP2PNAT::SubscribeToConnectionRequests()
{
    PlayerPtr Player = FPlayerManager::Get().GetPlayer(FPlayerManager::Get().GetCurrentUser());
    if (Player == nullptr || !Player->GetProductUserID().IsValid())
    {
        FDebugLog::LogError(L"EOS P2PNAT SubscribeToConnectionRequests: error user not logged in.");
        return;
    }

    if (ConnectionNotificationId == EOS_INVALID_NOTIFICATIONID)
    {
        EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());

        EOS_P2P_SocketId SocketId;
        SocketId.ApiVersion = EOS_P2P_SOCKETID_API_LATEST;
        strncpy_s(SocketId.SocketName, "CHAT", 5);

        EOS_P2P_AddNotifyPeerConnectionRequestOptions Options;
        Options.ApiVersion = EOS_P2P_ADDNOTIFYPEERCONNECTIONREQUEST_API_LATEST;
        Options.LocalUserId = Player->GetProductUserID();
        Options.SocketId = &SocketId;

        ConnectionNotificationId = EOS_P2P_AddNotifyPeerConnectionRequest(P2PHandle, &Options, nullptr, OnIncomingConnectionRequest);
        if (ConnectionNotificationId == EOS_INVALID_NOTIFICATIONID)
        {
            FDebugLog::LogError(L"EOS P2PNAT SubscribeToConnectionRequests: could not subscribe, bad notification id returned.");
        }
    }
}

void FP2PNAT::UnsubscribeFromConnectionRequests()
{
    EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());
    EOS_P2P_RemoveNotifyPeerConnectionRequest(P2PHandle, ConnectionNotificationId);
    ConnectionNotificationId = EOS_INVALID_NOTIFICATIONID;
}

ConnectionNotificationId は NAT P2P インターフェースのページに以下がある通り、通知受信の解除にも使われるため保持されています。

以下のパラメータを渡して EOS_P2P_RemoveNotifyPeerConnectionRequest 関数を使用し、ピア接続リクエスト ハンドラを削除できます。

接続を承認する

EOS_P2P_AcceptConnection は上述の EOS_P2P_AddNotifyPeerConnectionRequest の呼び出しに渡した callback 関数である OnIncomingConnectionRequest で使われます。つまり、通知を受信した際に承認するという流れのようです。

void EOS_CALL FP2PNAT::OnIncomingConnectionRequest(const EOS_P2P_OnIncomingConnectionRequestInfo* Data)
{
    if (Data)
    {
        std::string SocketName = Data->SocketId->SocketName;
        if (SocketName != "CHAT")
        {
            FDebugLog::LogError(L"EOS P2PNAT OnIncomingConnectionRequest: bad socket id.");
            return;
        }

        PlayerPtr Player = FPlayerManager::Get().GetPlayer(FPlayerManager::Get().GetCurrentUser());
        if (Player == nullptr || !Player->GetProductUserID().IsValid())
        {
            FDebugLog::LogError(L"EOS P2PNAT OnIncomingConnectionRequest: error user not logged in.");
            return;
        }
        
        EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());
        EOS_P2P_AcceptConnectionOptions Options;
        Options.ApiVersion = EOS_P2P_ACCEPTCONNECTION_API_LATEST;
        Options.LocalUserId = Player->GetProductUserID();
        Options.RemoteUserId = Data->RemoteUserId;

        EOS_P2P_SocketId SocketId;
        SocketId.ApiVersion = EOS_P2P_SOCKETID_API_LATEST;
        strncpy_s(SocketId.SocketName, "CHAT", 5);
        Options.SocketId = &SocketId;

        EOS_EResult Result = EOS_P2P_AcceptConnection(P2PHandle, &Options);
        if (Result != EOS_EResult::EOS_Success)
        {
            FDebugLog::LogError(L"EOS P2PNAT OnIncomingConnectionRequest: error while accepting connection, code: %ls.", FStringUtils::Widen(EOS_EResult_ToString(Result)).c_str());
        }
    }
    else
    {
        FDebugLog::LogError(L"EOS P2PNAT SubscribeToConnectionRequests: EOS_P2P_OnIncomingConnectionRequestInfo is NULL.");
    }
}

EOS_P2P_SendPacket と似たインターフェースになっています。

接続を終了する

ここに関しては、サンプルでは利用されていませんでした。

データを受信する

void FP2PNAT::Update()
{
    HandleReceivedMessages();
}

void FP2PNAT::HandleReceivedMessages()
{
    PlayerPtr Player = FPlayerManager::Get().GetPlayer(FPlayerManager::Get().GetCurrentUser());
    if (Player == nullptr || !Player->GetProductUserID().IsValid()) return;

    EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());

    EOS_P2P_ReceivePacketOptions Options;
    Options.ApiVersion = EOS_P2P_RECEIVEPACKET_API_LATEST;
    Options.LocalUserId = Player->GetProductUserID();
    Options.MaxDataSizeBytes = 4096;
    Options.RequestedChannel = nullptr;

    //Packet params
    FProductUserId FriendId;

    EOS_P2P_SocketId SocketId;
    SocketId.ApiVersion = EOS_P2P_SOCKETID_API_LATEST;
    uint8_t Channel = 0;

    std::vector<char> MessageData;
    MessageData.resize(Options.MaxDataSizeBytes);
    uint32_t BytesWritten = 0;

    EOS_EResult Result = EOS_P2P_ReceivePacket(P2PHandle, &Options, &FriendId.AccountId, &SocketId, &Channel, MessageData.data(), &BytesWritten);
    if (Result == EOS_EResult::EOS_NotFound)
    {
        //no more packets, just end
        return;
    }
    else if (Result == EOS_EResult::EOS_Success)
    {
        auto P2PDialog = static_cast<FMenu&>(*FGame::Get().GetMenu()).GetP2PNATDialog();
        if (P2PDialog)
        {
            std::wstring MessageWide;
            std::string MessageNarrow(MessageData.data(), BytesWritten);
            MessageWide = FStringUtils::Widen(MessageNarrow);
            P2PDialog->OnMessageReceived(MessageWide, FriendId);
        }
    }
    else
    {
        FDebugLog::LogError(L"EOS P2PNAT HandleReceivedMessages: error while reading data, code: %ls.", FStringUtils::Widen(EOS_EResult_ToString(Result)).c_str());
    }
}

引き続き Channel の用途は分かっていないのですが、Chat に利用する SocketId と、相手 Friend の id を使って受信をしています。
ただ、ここまでは ProductUserId であったのが、ここでは EOS_EpicAccountId に相当する Id が使われているようです。(ドキュメントでは EOS_ProductUserId になっているので、少し謎です)

その後 P2PDialogOnMessageReceived に受信したデータを渡しています。

まとめ

今回は公式の NAT P2P インターフェースのドキュメントをなぞる形でサンプルコードを追っていきました。

今回以下の疑問が出ました。

  • EOS Connect インターフェースでのログインはどういうことか
  • EOS_EpicAccountIdEOS_ProductUserId はどのように違うのか

おそらくこの 2 つは関連すると考えられるので、引き続きここを理解できればと思います。

Epic Online Services サンプルの P2PNAT のコードリーディング ①

前回AuthAndFriendsP2PNAT のプロジェクトにおける Shared 部分とそうでない部分の比較をしつつ、どこを読めばだいたい概要をつかめるかを整理しました。

今回はそのまま P2PNAT を見ていきます。単純に自分がこの機能を使いたいと思っているためです。

初期化部分

前回の記事に習い、まずは以下をみていきます。

  • FGame, FMenu
    • コンストラク
    • Create
    • UpdateLayout
    • Init
    • OnGameEvent (EGameEventType::CheckAutoLogin イベントのみ)

FGame

FGame::FGame() noexcept(false)
{
    Menu = std::make_unique<FMenu>(Console);
    Level = std::make_unique<FLevel>();
    P2PNATComponent = std::make_unique<FP2PNAT>();

    CreateConsoleCommands();
}

void FGame::OnGameEvent(const FGameEvent& Event)
{
    FBaseGame::OnGameEvent(Event);

    P2PNATComponent->OnGameEvent(Event);
}

FP2PNAT を利用しているようです。後ほど見ていきます。

FMenu

FMenu::FMenu(std::weak_ptr<FConsole> InConsole) noexcept(false):
    FBaseMenu(InConsole)
{
}

void FMenu::Create()
{
    CreateFriendsDialog();
    CreateP2PNATDialog();
    CreateNATDocsButton();

    FBaseMenu::Create();

    AuthDialogs->SetSingleUserOnly(true);
}

void FMenu::CreateP2PNATDialog()
{
    const float FX = 100.0f;
    const float FY = P2PDialogPositionY;
    const float Width = 300.0f;
    const float Height = 300.0f;

    P2PNATDialog = std::make_shared<FP2PNATDialog>(
        Vector2(FX, FY),
        Vector2(Width, Height),
        DefaultLayer - 2,
        NormalFont->GetFont(),
        BoldSmallFont->GetFont(),
        TinyFont->GetFont(),
        LargeFont->GetFont());

    P2PNATDialog->SetBorderColor(Color::UIBorderGrey);

    P2PNATDialog->Create();

    AddDialog(P2PNATDialog);
}

void FMenu::CreateNATDocsButton()
{
    Vector2 NATDocsButtonSize = Vector2(150.0f, 35.0f);

    NATDocsButton = std::make_shared<FButtonWidget>(
        Vector2(800.0f, 100.0f),
        NATDocsButtonSize,
        DefaultLayer - 1,
        L"DOCUMENTATION",
        assets::LargeButtonAssets,
        NormalFont->GetFont());
    NATDocsButton->SetOnPressedCallback([]()
    {
        FUtils::OpenURL(NATDocsURL);
    });
    //NATDocsButton->SetBackgroundColors(assets::DefaultButtonColors);
    NATDocsButton->SetBorderColor(Color::UIBorderGrey);
    NATDocsButton->Create();
    NATDocsButton->Hide();
}

void FMenu::OnGameEvent(const FGameEvent& Event)
{
    FBaseMenu::OnGameEvent(Event);
    if (P2PNATDialog) P2PNATDialog->OnGameEvent(Event);
}

void FMenu::UpdateLayout(int Width, int Height)
{
    // レイアウトの更新なので省略
}

f:id:you1dan:20220117212256p:plain

P2PNATDialogNATDocsButton を擁していますが、NATDocsButton に関しては、 Hide されているため表示されていません。
また、AuthDialogSetSingleUserOnly(true) が呼び出さており、アプリケーションごとに 1 ユーザーのみログイン可能に制限されているのが AuthAndFriends サンプルとの相違点です。

では、ここから、新たに登場した FP2PNATFP2PNATDialog の初期化部分も見ていきます。

FP2PNAT

FP2PNAT::FP2PNAT()
{   
}

void FP2PNAT::OnGameEvent(const FGameEvent& Event)
{
    // CheckAutoLogin は扱わないため省略
}

特にさらに依存するものはなさそうです。

FP2PNATDialog

FP2PNATDialog::FP2PNATDialog(
    Vector2 DialogPos,
    Vector2 DialogSize,
    UILayer DialogLayer,
    FontPtr DialogNormalFont,
    FontPtr DialogSmallFont,
    FontPtr DialogTinyFont,
    FontPtr DialogTitleFont) :
    FDialog(DialogPos, DialogSize, DialogLayer)
{
    HeaderLabel = std::make_shared<FTextLabelWidget>(
        DialogPos,
        Vector2(DialogSize.x, 25.0f),
        Layer - 1,
        std::wstring(L"Header"),
        L"Assets/wide_label.dds",
        FColor(1.f, 1.f, 1.f, 1.f),
        FColor(1.f, 1.f, 1.f, 1.f),
        EAlignmentType::Left
        );
    HeaderLabel->SetFont(DialogNormalFont);

    BackgroundImage = std::make_shared<FSpriteWidget>(
        DialogPos,
        DialogSize,
        DialogLayer,
        L"Assets/texteditor.dds");

    NATStatusLabel = std::make_shared<FTextLabelWidget>(
        DialogPos + Vector2(HeaderLabel->GetSize().x + HeaderLabel->GetSize().x - 170.0f, 0.0f),
        Vector2(170.0f, 25.0f),
        Layer - 1,
        L"NAT Status: Unknown",
        L"",
        FColor(1.f, 1.f, 1.f, 1.f),
        FColor(1.f, 1.f, 1.f, 1.f),
        EAlignmentType::Left
        );
    NATStatusLabel->SetFont(DialogNormalFont);

    Vector2 ChatInputFieldSize = Vector2(DialogSize.x, 30.0f);
    Vector2 ChatInputFieldPos = DialogPos + Vector2(0.0f, DialogSize.y) - Vector2(0.0f, ChatInputFieldSize.y);

    ChatInputField = std::make_shared<FTextFieldWidget>(
        ChatInputFieldPos,
        ChatInputFieldSize,
        Layer - 1,
        L"Chat...",
        L"Assets/textfield.dds",
        DialogNormalFont,
        FTextFieldWidget::EInputType::Normal,
        EAlignmentType::Left);
    ChatInputField->SetBorderColor(Color::UIBorderGrey);

    ChatInputField->SetOnEnterPressedCallback(
        [this](const std::wstring& Value)
    {
        this->OnSendMessage(Value);
    }
    );

    Vector2 ChatTextViewPos = HeaderLabel->GetPosition() + Vector2(0.0f, HeaderLabel->GetSize().y + 5.0f);
    Vector2 ChatTextViewSize = Vector2(DialogSize.x, DialogSize.y - ChatInputFieldSize.y - HeaderLabel->GetSize().y - 10.0f);

    ChatTextView = std::make_shared<FTextViewWidget>(
        ChatTextViewPos,
        ChatTextViewSize,
        Layer - 1,
        L"Choose friend to start chat...",
        L"Assets/textfield.dds",
        DialogNormalFont);

    ChatTextView->SetBorderOffsets(Vector2(10.0f, 10.0f));
    ChatTextView->SetScrollerOffsets(Vector2(5.f, 5.0f));
    ChatTextView->SetFont(DialogNormalFont);

    ChatInfoLabel = std::make_shared<FTextLabelWidget>(
        ChatTextViewPos,
        ChatTextViewSize,
        Layer - 2,
        L"",
        L"",
        FColor(1.f, 1.f, 1.f, 1.f),
        FColor(1.f, 1.f, 1.f, 1.f),
        EAlignmentType::Center
        );
    ChatInfoLabel->SetFont(DialogTitleFont);

    std::vector<std::wstring> EmptyButtonAssets = {};
    Vector2 CloseChatButtonSize = Vector2(150.0f, 35.0f);
    CloseCurrentChatButton = std::make_shared<FButtonWidget>(
        ChatTextViewPos + Vector2(ChatTextViewSize.x - CloseChatButtonSize.x - 10.0f, 10.0f),
        CloseChatButtonSize,
        Layer - 2,
        L"CLOSE CHAT",
        std::vector<std::wstring>({ L"Assets/solid_white.dds" }),
        DialogNormalFont,
        Color::UIHeaderGrey);
    CloseCurrentChatButton->SetFont(DialogNormalFont);
    CloseCurrentChatButton->SetBorderColor(Color::UIBorderGrey);
    CloseCurrentChatButton->SetOnPressedCallback([]()
    {
        static_cast<FMenu&>(*FGame::Get().GetMenu()).GetP2PNATDialog()->CloseCurrentChat();
    });
    CloseCurrentChatButton->Hide();
}

void FP2PNATDialog::Create()
{
    if (HeaderLabel) HeaderLabel->Create();
    if (BackgroundImage) BackgroundImage->Create();
    if (NATStatusLabel) NATStatusLabel->Create();
    if (ChatTextView) ChatTextView->Create();
    if (ChatInputField)   ChatTextView->Create();
    if (ChatInfoLabel) ChatInfoLabel->Create();
    if (CloseCurrentChatButton) CloseCurrentChatButton->Create();

    AddWidget(HeaderLabel);
    AddWidget(BackgroundImage);
    AddWidget(NATStatusLabel);
    AddWidget(ChatTextView);
    AddWidget(ChatInputField);
    AddWidget(ChatInfoLabel);
    AddWidget(CloseCurrentChatButton);
}

void FP2PNATDialog::OnGameEvent(const FGameEvent& Event)
{
    // CheckAutoLogin は扱わないため省略
}

上記ですでにログイン前の UI イメージを貼りましたが、以下はログインし Chat の往復をした場合のイメージです。
上記コードのコンストラクタと Create メソッドの内容がなんとなく読み取れます。

f:id:you1dan:20220117213623p:plain

FP2PNATDialog のイベントハンドラ

上記のコードからイベントハンドラ部分だけひろっておきます。

FP2PNATDialog::FP2PNATDialog(
    Vector2 DialogPos,
    Vector2 DialogSize,
    UILayer DialogLayer,
    FontPtr DialogNormalFont,
    FontPtr DialogSmallFont,
    FontPtr DialogTinyFont,
    FontPtr DialogTitleFont) :
    FDialog(DialogPos, DialogSize, DialogLayer)
{
    // 略

    ChatInputField->SetOnEnterPressedCallback(
        [this](const std::wstring& Value)
    {
        this->OnSendMessage(Value);
    }
    );

    // 略

    CloseCurrentChatButton->SetOnPressedCallback([]()
    {
        static_cast<FMenu&>(*FGame::Get().GetMenu()).GetP2PNATDialog()->CloseCurrentChat();
    });

それぞれ、Chat の入力フィールドと、Chat 開始後の Close ボタンに関するイベント Callback です。

入力メッセージに関しては、おそらく EOS SDK に関わってくると思いますが、まだ P2P 機能の初期化も出てきていないのでいったん保留します。

P2P 接続が行われるのはどこか

ログイン後、Friends もログイン状態である場合には以下のような UI になります。以下の変化があります。

  • 自身がログインすると NAT StatusUnknown から変化する(私の環境だと Moderate )
  • Friends 一覧に CHAT ボタンが表示される

f:id:you1dan:20220117215056p:plain

ログイン時

ユーザーのログイン状態に変化がある場合には、OnGameEventFAuthentication からのイベント発火が行われます。ログイン関連のイベントは多く存在しますが、P2PNATDialog がまとめて処理しています。

void FP2PNATDialog::OnGameEvent(const FGameEvent& Event)
{
    if (Event.GetType() == EGameEventType::UserLoggedIn)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::UserLoginRequiresMFA)
    {
        UpdateNATStatus();
        SetFocused(false);
    }
    else if (Event.GetType() == EGameEventType::UserLoginEnteredMFA)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::UserLoggedOut)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::ShowPrevUser)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::ShowNextUser)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::NewUserLogin)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::CancelLogin)
    {
        UpdateNATStatus();
    }
    else if (Event.GetType() == EGameEventType::StartChatWithFriend)
    {
        // 略
    }
}

void FP2PNATDialog::UpdateNATStatus()
{
    if (NATStatusLabel)
    {
        FGame::Get().GetP2PNAT()->RefreshNATType();
    }
}
void FP2PNAT::RefreshNATType()
{
    EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());

    EOS_P2P_QueryNATTypeOptions Options;
    Options.ApiVersion = EOS_P2P_QUERYNATTYPE_API_LATEST;

    EOS_P2P_QueryNATType(P2PHandle, &Options, nullptr, OnRefreshNATTypeFinished);
}

void EOS_CALL FP2PNAT::OnRefreshNATTypeFinished(const EOS_P2P_OnQueryNATTypeCompleteInfo* Data)
{
    if (Data)
    {
        if (Data->ResultCode != EOS_EResult::EOS_Success)
        {
            FDebugLog::LogError(L"EOS P2PNAT QueryNATType callback: error code %ls", FStringUtils::Widen(EOS_EResult_ToString(Data->ResultCode)).c_str());
        }
    }
    else
    {
        FDebugLog::LogError(L"EOS P2PNAT QueryNATType callback: bad data returned");
    }
}

NAT Status の更新

ついでなので NAT Status ラベルの更新情報も見ておきます。実際にはこの流れではなく、FP2PNATDialog::Update の方で処理されます。以下は、その中でラベルの更新のために NATType を取得する先の処理です。

EOS_ENATType FP2PNAT::GetNATType() const
{
    EOS_ENATType NATType = EOS_ENATType::EOS_NAT_Unknown;
    EOS_HP2P P2PHandle = EOS_Platform_GetP2PInterface(FPlatform::GetPlatformHandle());

    EOS_P2P_GetNATTypeOptions Options;
    Options.ApiVersion = EOS_P2P_GETNATTYPE_API_LATEST;

    EOS_EResult Result = EOS_P2P_GetNATType(P2PHandle, &Options, &NATType);

    if (Result == EOS_EResult::EOS_NotFound)
    {
        //NAT type has not been queried yet. (Or query is not finished)        
        return EOS_ENATType::EOS_NAT_Unknown;
    }

    if (Result != EOS_EResult::EOS_Success)
    {
        FDebugLog::LogError(L"EOS P2PNAT GetNatType: error while retrieving NAT Type: %ls", FStringUtils::Widen(EOS_EResult_ToString(Result)).c_str());
        return EOS_ENATType::EOS_NAT_Unknown;
    }

    return NATType;
}

ドキュメントには以下の記述があります。よって、API を呼び出していますが、基本は事前に Query していたものの Cache を取得するだけという形のようです。

EOS_EResult::EOS_Success - if we have cached data EOS_EResult::EOS_NotFound - If we do not have queried data cached

FriendsInfoWidget

続いて CHAT ボタンですが、以下のように Friends を表示する Widget (これは Shared にある)に分岐がありました。
そこで StartChatWithFriend のイベントが発火されていることがわかります。

void FFriendInfoWidget::Create()
{
    // 略

#elif defined(EOS_SAMPLE_P2P) || defined (EOS_SAMPLE_VOICE)
            //Start chat button (p2p demo only)
            const float ButtonSize = Size.x * 0.3f;
            Vector2 ButtonOffset(Size.x - ButtonSize - 10.0f, Size.y * 0.25f);

            FEpicAccountId FriendUserId = FriendData.UserId;
            FProductUserId FriendProductUserId = FriendData.UserProductUserId;
            std::wstring FriendName = FriendData.Name;
            if (FriendProductUserId.IsValid() && FriendData.Presence.Status != EOS_Presence_EStatus::EOS_PS_Offline)
            {
                Button1 = std::make_shared<FButtonWidget>(
                    Position + ButtonOffset,
                    Vector2(ButtonSize, Size.y / 2.0f),
                    Layer - 1,
                    L"CHAT",
                    std::vector<std::wstring>({ L"Assets/button.dds" }),
                    SmallFont,
                    ChatButtonBackgroundColors[static_cast<size_t>(FButtonWidget::EButtonVisualState::Idle)]);
                Button1->Create();
                Button1->SetOnPressedCallback([FriendUserId, FriendProductUserId, FriendName]()
                {
                    FGameEvent Event(EGameEventType::StartChatWithFriend, FriendUserId, FriendProductUserId, FriendName);
                    FGame::Get().OnGameEvent(Event);
                });
                Button1->SetBackgroundColors(ChatButtonBackgroundColors);
            }

こちらも FP2PNATDialog::OnGameEvent で処理されています。

void FP2PNATDialog::OnGameEvent(const FGameEvent& Event)
{
    // 略
    else if (Event.GetType() == EGameEventType::StartChatWithFriend)
    {
        // Open chat with friend
        CurrentChat = Event.GetProductUserId();

        bChatViewDirty = true;
        ChatTextView->Clear();
    }
}

ここに関しては、Chat の UI の準備をしているだけで API に関しては何もしていないようです。

ここまでのまとめ

P2PNAT サンプルの初期化部分を中心に眺めてきました。

NAT P2P インターフェース | Epic Online Services ドキュメンテーション

現状、ここまででは上記の資料の「NAT タイプを決定する」の部分に出てきた API ぐらいしか使っておらず、これ以降に接続やデータ送受信が出てくると思われます。

今回、上記の資料を読む前にコードを見始めてしまったので順序が逆になってしまったのですが、キャッシュに関しても以下のように記載されていました。

EOS_P2P_QueryNATType が完了するとユーザーの NAT タイプの値がキャッシュされるので、関数 EOS_P2P_GetNATType を代わりに呼び出してこの値をすぐに返すことができます。これが正常に返されるのは EOS_P2P_QueryNATType が少なくとも 1 回は完了した場合のみです。EOS_P2P_GetNATType 関数の呼び出しには以下のパラメータを指定する必要があります。

次回も引き続き P2PNAT に関してみていければと思います。

Epic Online Services サンプルの Shared 部分と各プロジェクトの読み方の整理

AuthAndFriends サンプルをざっくり読んできましたが、他のプロジェクトも読むにあたり共有コード部分を個別のプロジェクトのコードに関して読み方を自分なりに整理しておきます。

各プロジェクトのファイル共有状況

以下は VS のソリューションエクスプローラーでのツリーなので、実態のファイルの場所とは異なります。
ここでは、 AuthAndFriendsP2PNAT の 2 つのサンプルを例にとって、Shared とそうでない部分の比較をしていきます。

Samples/
  + AuthAndFriends/
    + EOSSDK/
    + SharedAssets/
    + SharedSource/
      + Graphics/GUI/Dialogs/
        + AuthDialogs.h(cpp)
        + ConsoleDialog.h(cpp)
        + FriendsDialog.h(cpp)
        + ExitDialog.h(cpp)
        + PopupDialog.h(cpp)
      + Main/
        + SDL/SDLMain.h(cpp)
        + Main.h(cpp)
      + BaseGame.h(cpp)
      + BaseMenu.h(cpp)
    + Source/
      + Game.h(cpp)
      + Menu.h(cpp)
      + CustomInvitesDialog.h(cpp)
      + CustomInvites.h(cpp)
  + P2PNAT/
    + EOSSDK/
    + SharedAssets/
    + SharedSource/
      + Graphics/GUI/Dialogs/
        + AuthDialogs.h(cpp)
        + ConsoleDialog.h(cpp)
        + FriendsDialog.h(cpp)
        + ExitDialog.h(cpp)
        + PopupDialog.h(cpp)
      + Main/
        + SDL/SDLMain.h(cpp)
        + Main.h(cpp)
      + BaseGame.h(cpp)
      + BaseMenu.h(cpp)
    + Source/
      + Game.h(cpp)
      + Menu.h(cpp)
      + P2PNATDialog.h(cpp)
      + P2PNAT.h(cpp)

上記は (SDL2 を利用した場合の) 主要なファイルのみを記載したものです。

EOSSDK, SharedAssets, SharedSource はいずれも共有コードです。(ただ、AuthAndFriends には Steam に関するコードがあったりと全く同じファイルがある訳ではありません。ただ、同名のファイルがあれば内容は同じです)

共有部分には、ゲームの起動・終了・イベントループを司る main 部分に加えて FBaseGame, FBaseMenu などの基底クラスが存在し、各プロジェクトにそれぞれの FGame, FMenu が存在しています。

また、UI 要素としても全プロジェクトに存在しているものは SharedSource/Graphics/GUI/Dialogs に配置されています。
個別のプロジェクトでのみ利用する CustomInvitesDialog, P2PNATDialog は各プロジェクトの Source フォルダに配置されています。

共有ファイルと個別ファイルの読み進め方

初期化部分

SDLMain.cppInit 関数が初期化の起点になり、そこから各プロジェクトに関する呼び出しの重要な部分は FMain::Initialize になります。(コードは注目したい部分以外は省略したりカットしています)

void FMain::Initialize(SDL_Window* Window, SDL_GLContext InGLContext, int Width, int Height)
{
    SDLWindow = Window;
    GLContext = InGLContext;

    Game = std::make_unique<FGame>();

    CreateDeviceDependentResources();
    CreateWindowSizeDependentResources();

    Game->UpdateLayout(Width, Height);
    Game->Init();
}

void FMain::CreateDeviceDependentResources()
{
    if (Game)
    {
        Game->Create();
    }
}
void FBaseGame::Create()
{
    Menu->CreateFonts();
    Menu->Create();
    Level->Create();
}

void FBaseGame::UpdateLayout(int Width, int Height)
{
    if (Menu)
    {
        Menu->UpdateLayout(Width, Height);
    }
}

void FBaseGame::Init()
{
    Metrics->Init();
    Menu->Init();
    EosUI->Init();

    FGameEvent Event(EGameEventType::CheckAutoLogin);
    OnGameEvent(Event);
}

void FBaseGame::OnGameEvent(const FGameEvent& Event)
{
    PlayerManager->OnGameEvent(Event);
    Friends->OnGameEvent(Event);
    Menu->OnGameEvent(Event);
    Authentication->OnGameEvent(Event);
    Metrics->OnGameEvent(Event);
    Level->OnGameEvent(Event);
    EosUI->OnGameEvent(Event);
}
void FBaseMenu::Create()
{
    BackgroundImage->Create();

    TitleLabel->Create();
    TitleLabel->SetFont(BoldLargeFont->GetFont());

    CreateConsoleDialog();
    CreateAuthDialogs();
    CreateExitDialog();
    CreatePopupDialog();
}

void FBaseMenu::UpdateLayout(int Width, int Height)
{
    Vector2 WindowSize = Vector2((float)Width, (float)Height);

    BackgroundImage // 略

    if (ConsoleDialog)
    {
        // 略

        if (FriendsDialog)
        {
            // 略
        }
    }

    if (PopupDialog)
    {
        // 略
    }

    if (ExitDialog)
    {
        // 略
    }

    if (AuthDialogs) AuthDialogs->UpdateLayout();
}

void FBaseMenu::Init()
{
    AuthDialogs->Init();
}

void FBaseMenu::OnGameEvent(const FGameEvent& Event)
{
    if (Event.GetType() == EGameEventType::ShowPrevUser)
    {
        // 略
    }
    else if (Event.GetType() == EGameEventType::ShowNextUser)
    {
        // 略
    }
    else if (Event.GetType() == EGameEventType::CancelLogin)
    {
        // 略
    }
    else if (Event.GetType() == EGameEventType::ToggleFPS)
    {
        // 略
    }
    else if (Event.GetType() == EGameEventType::ExitGame)
    {
        // 略
    }
    else if (Event.GetType() == EGameEventType::ShowPopupDialog)
    {
        // 略
    }

    if (FriendsDialog) // 略

    if (AuthDialogs) // 略
}

よって、 FGame, FMenu に関しては以下を確認すればいいです。プロジェクト固有の Dialog がある場合は、おそらく FMenu で確認できます。

  • コンストラク
  • Create
  • UpdateLayout
  • Init
  • OnGameEvent (EGameEventType::CheckAutoLogin イベントのみ)
  • その他 FBaseGame のメソッドを override しているもの

また呼び出し順序も、override メソッドを除けば上記の並びになっているようです。

イベントループ部分

イベントハンドル

SDLMain.cppProcessSDLEvents 関数が起点になり、そこから各プロジェクトに関する呼び出しの重要な部分は FBaseMenu::OnUIEvent になります。(コードは注目したい部分以外は省略したりカットしています)

void FBaseMenu::OnUIEvent(const FUIEvent& Event)
{
    if (Event.GetType() == EUIEventType::MousePressed)
    {
        for (DialogPtr NextDialog : Dialogs)
        {
            if (NextDialog->CheckCollision(Event.GetVector()))
            {
                NextDialog->OnUIEvent(Event);
            }
            else
            {
                NextDialog->SetFocused(false);
            }
        }
    }
    else
    {
        for (DialogPtr NextDialog : Dialogs)
        {
            NextDialog->OnUIEvent(Event);
        }
    }

    if (AuthDialogs) AuthDialogs->OnUIEvent(Event);
}

イベントハンドル部分に関しては以下を確認することになります。

  • FMenu::OnUIEvent
    • 必ずあるわけではないですが、override されている場合があります
  • プロジェクトで参照されている各 DialogOnUIEvent
    • 上記コードの Dialogs に入っているものすべてですが、各プロジェクトの FMenu 内で AddDialog されているものを探せばだいたい揃います

Tick

SDLMain.cppmain 関数内で FMain::Tick が呼び出されます。(Render は省略します)

void FMain::Tick()
{
    Timer.Tick([&]()
    {
        Update();
    });

    Render();
}

void FMain::Update()
{
    if (Game)
    {
        Game->Update();
    }
}
void FBaseGame::Update()
{
    Input->Update();

    if (Input->IsKeyPressed(FInput::InputCommands::Exit) ||
        Input->IsGamePadButtonPressed(FInput::InputCommands::Exit))
    {
        FGameEvent Event(EGameEventType::ExitGame);
        OnGameEvent(Event);
    }

    Users->Update();
    Menu->Update();
    Level->Update();
    Friends->Update();

    FPlatform::Update();
}
void FBaseMenu::Update()
{
    BackgroundImage->Update();
    TitleLabel->Update();
    FPSLabel->Update();

    std::unique_ptr<FInput> const& Input = FBaseGame::GetBase().GetInput();

    if (Input)
    {
        // check if characters were typed
        if (Input->IsAnyKeyPressed())
        {
            //Check if we need to generate repeated key press events
            if (KeyCurrentlyHeld != FInput::None)
            {
                if (!Input->IsKeyReleased(static_cast<FInput::Keys>(toupper(int(KeyCurrentlyHeld)))))
                {
                    KeyCurrentlyHeldSeconds += static_cast<float>(Main->GetTimer().GetElapsedSeconds());

                    if (KeyCurrentlyHeldSeconds >= SecondsTilKeyRepeat ||
                        (KeyCurrentlyHeld == FInput::Back && KeyCurrentlyHeldSeconds >= (SecondsTilKeyRepeat / 2.0f)) ||
                        (KeyCurrentlyHeld == FInput::Delete && KeyCurrentlyHeldSeconds >= (SecondsTilKeyRepeat / 2.0f)))
                    {
                        KeyCurrentlyHeldSeconds = 0.0f;
                        FUIEvent event(EUIEventType::KeyPressed, KeyCurrentlyHeld);
                        OnUIEvent(event);
                    }
                }
                else
                {
                    KeyCurrentlyHeld = FInput::None;
                    KeyCurrentlyHeldSeconds = 0.0f;
                }
            }
        }
    }

    for (DialogPtr NextDialog : Dialogs)
    {
        NextDialog->Update();
    }

    AuthDialogs->Update();

    UpdateFPS();
}
void FPlatform::Update()
{
    if (PlatformHandle)
    {
        EOS_Platform_Tick(PlatformHandle);
    }

    if (!bIsInit && !bIsShuttingDown)
    {
        if (!bHasShownCreateFailedError)
        {
            // 略
        }
    }

    if (bHasInvalidParamProductId ||
        bHasInvalidParamSandboxId ||
        bHasInvalidParamDeploymentId ||
        bHasInvalidParamClientCreds)
    {
        if (!bHasShownInvalidParamsErrors)
        {
            // 略
        }
    }
}

Tick 部分に関しては以下を確認することになります。

  • FGame::Update
  • プロジェクトで参照されている各 DialogUpdate

OnGameEvent

OnGameEvent は明示的なユーザー操作でトリガされる OnUIEvent とは異なり、ユーザー操作後であったり、通信の前後であったり、コンソールコマンドであったりとあらゆる状況で呼び出される可能性があります。

基本的には FGame 及び FBaseGameOnGameEvent が呼び出され、そこから dispatch されます。

void FBaseGame::OnGameEvent(const FGameEvent& Event)
{
    PlayerManager->OnGameEvent(Event);
    Friends->OnGameEvent(Event);
    Menu->OnGameEvent(Event);
    Authentication->OnGameEvent(Event);
    Metrics->OnGameEvent(Event);
    Level->OnGameEvent(Event);
    EosUI->OnGameEvent(Event);
}

上記の Menu->OnGameEvent があるため、FMenu, FBaseMenu が管理する各 Dialog の OnGameEvent にさらに伝搬するケースもあります。

よって、OnGameEvent に関しては以下を確認することになります。

  • FGame::OnGameEvent
  • FMenu::OnGameEvent
  • プロジェクトで参照されている各 DialogOnGameEvent

終了部分

SDLMain.cppmain 関数内で FMain::OnShutdown が呼び出されます。

void FMain::OnShutdown()
{
    if (Game)
    {
        Game->OnShutdown();
    }
}
void FBaseGame::OnShutdown()
{
    // Must explicitly call FEosUI::OnShutdown() before the end of destruction to allow FBaseGame::GetBase() to not throw.
    if (EosUI)
    {
        EosUI->OnShutdown();
    }

    // Must explicitly call FAuthentication::Shutdown() before the end of destruction to allow FBaseGame::GetBase() to not throw.
    if (Authentication)
    {
        Authentication->Shutdown();
    }

    Release();
}

よって、FGame::OnShutdown を確認しておけばよいと思います。

まとめ

AuthAndFriends は共有分のコードを読むのに時間がかかりましたが、他のプロジェクトを読む際はそこを省略できます。

今回は、省略して読むもののより流れを追いつつ簡単に読んでいけるようにサンプルのフレームワーク部分と、各プロジェクトの呼び出し関係を整理しました。

引き続き、他のプロジェクトも読んでいければと思います。