くーこのプログラマメモ

UE4などゲーム開発に関するメモ

ローカライゼーションのCommandlet紹介

使用バージョン:UE4.24.3

はじめに

UE4のローカライゼーションダッシュボードを利用してローカライズ対応を行う場合があるかと思います。
通常はテキストの追加や編集を行った際にローカライゼーションダッシュボードからテキスト収集やpoファイルのインポート、コンパイルを行うかと思いますが、これらの操作についてはCommandletが用意されているため、まとめて実行することで操作を簡略化することができます。

ローカライゼーションについては下記の資料が大変参考になります。

www.unrealengine.com

下記のコード中に出てくるExecuteCommandletの実装については以下を参照ください。

shama-coo.hatenablog.com

準備

以下のモジュールを依存関係として追加する必要があります。

  • Localization
  • UnrealEd

GatherTextCommandlet

GatherText というCommandletがあり、このCommandletから各操作を行います。
実行する際に引数として、{ProjectDir}/config/Localization以下に生成されるゲームターゲットのiniファイルを指定します。
この時に指定するiniファイル内の設定を元に内部で適したCommandletが実行されます。

iniファイル 動作
[ゲームターゲット]_Gather.ini テキスト収集
[ゲームターゲット]_Import.ini poファイルインポート
[ゲームターゲット]_Export.ini poファイルエクスポート
[ゲームターゲット]_GenerateReports.ini WordCountやStatus等のレポート生成
[ゲームターゲット]_Compile.ini コンパイル

iniファイルパスの取得

LocalizationConfigurationScript に各iniファイルを相対パスで取得する関数があります。
しかし、取得したパスをそのままCommandletの引数へ渡すと、相対パスだった場合はプロジェクトもしくはエンジンのパスが付与されてしまうため、絶対パスに変換してから渡します。

テキスト収集

#include "Misc/Paths.h"
#include "LocalizationModule.h"
#include "LocalizationTargetTypes.h"
#include "LocalizationConfigurationScript.h"
#include "Commandlets/GatherTextCommandletBase.h"

void GatherText(const FString& GameTargetName)
{
    // ゲームターゲット名からLocalizationTargetを取得.
    ILocalizationModule& LocalizationModule = ILocalizationModule::Get();
    ULocalizationTarget* LocalizationTarget = LocalizationModule.GetLocalizationTargetByName(GameTargetName, false);
    check(::IsValid(LocalizationTarget));
    // [ゲームターゲット]_GatherText.iniの相対パスを取得.
    FString ConfigPath = LocalizationConfigurationScript::GetGatherTextConfigPath(LocalizationTarget);
    // 相対パス->絶対パス.
    ConfigPath = FPaths::ConvertRelativePathToFull(ConfigPath);

    // Commandlet実行.
    ExecuteCommandlet(TEXT("GatherTextCommandlet"), FString::Format(TEXT("-config=\"{0}\""), { ConfigPath }));
}

poファイルインポート

#include "Misc/Paths.h"
#include "LocalizationModule.h"
#include "LocalizationTargetTypes.h"
#include "LocalizationConfigurationScript.h"
#include "Commandlets/GatherTextCommandletBase.h"

void ImportPO(const FString& GameTargetName, const TOptional<FString> CultureName)
{
    // ゲームターゲット名からLocalizationTargetを取得.
    ILocalizationModule& LocalizationModule = ILocalizationModule::Get();
    ULocalizationTarget* LocalizationTarget = LocalizationModule.GetLocalizationTargetByName(GameTargetName, false);
    check(::IsValid(LocalizationTarget));
    // [ゲームターゲット]_Import.iniの相対パスを取得.
    FString ConfigPath = LocalizationConfigurationScript::GetImportTextConfigPath(LocalizationTarget, CultureName);
    // 相対パス->絶対パス.
    ConfigPath = FPaths::ConvertRelativePathToFull(ConfigPath);

    // Commandlet実行.
    ExecuteCommandlet(TEXT("GatherTextCommandlet"), FString::Format(TEXT("-config=\"{0}\""), { ConfigPath }));
}

poファイルエクスポート

#include "Misc/Paths.h"
#include "LocalizationModule.h"
#include "LocalizationTargetTypes.h"
#include "LocalizationConfigurationScript.h"
#include "Commandlets/GatherTextCommandletBase.h"

void ExportPO(const FString& GameTargetName, const TOptional<FString> CultureName)
{
    // ゲームターゲット名からLocalizationTargetを取得.
    ILocalizationModule& LocalizationModule = ILocalizationModule::Get();
    ULocalizationTarget* LocalizationTarget = LocalizationModule.GetLocalizationTargetByName(GameTargetName, false);
    check(::IsValid(LocalizationTarget));
    // [ゲームターゲット]_Export.iniの相対パスを取得.
    FString ConfigPath = LocalizationConfigurationScript::GetExportTextConfigPath(LocalizationTarget, CultureName);
    // 相対パス->絶対パス.
    ConfigPath = FPaths::ConvertRelativePathToFull(ConfigPath);

    // Commandlet実行.
    ExecuteCommandlet(TEXT("GatherTextCommandlet"), FString::Format(TEXT("-config=\"{0}\""), { ConfigPath }));
}

コンパイル

#include "Misc/Paths.h"
#include "LocalizationModule.h"
#include "LocalizationTargetTypes.h"
#include "LocalizationConfigurationScript.h"
#include "Commandlets/GatherTextCommandletBase.h"

void Compile(const FString& GameTargetName, const TOptional<FString> CultureName)
{
    // ゲームターゲット名からLocalizationTargetを取得.
    ILocalizationModule& LocalizationModule = ILocalizationModule::Get();
    ULocalizationTarget* LocalizationTarget = LocalizationModule.GetLocalizationTargetByName(GameTargetName, false);
    check(::IsValid(LocalizationTarget));
    // [ゲームターゲット]_Compile.iniの相対パスを取得.
    FString ConfigPath = LocalizationConfigurationScript::GetCompileTextConfigPath(LocalizationTarget, CultureName);
    // 相対パス->絶対パス.
    ConfigPath = FPaths::ConvertRelativePathToFull(ConfigPath);

    // Commandlet実行.
    ExecuteCommandlet(TEXT("GatherTextCommandlet"), FString::Format(TEXT("-config=\"{0}\""), { ConfigPath }));
}

翻訳済みテキスト数を更新してWord Countに反映

#include "Misc/Paths.h"
#include "LocalizationModule.h"
#include "LocalizationTargetTypes.h"
#include "LocalizationConfigurationScript.h"
#include "Commandlets/GatherTextCommandletBase.h"

void UpdateWordCount(const FString& GameTargetName)
{
    // ゲームターゲット名からLocalizationTargetを取得.
    ILocalizationModule& LocalizationModule = ILocalizationModule::Get();
    ULocalizationTarget* LocalizationTarget = LocalizationModule.GetLocalizationTargetByName(GameTargetName, false);
    check(::IsValid(LocalizationTarget));
    // [ゲームターゲット]_GenerateReports.iniの相対パスを取得.
    FString ConfigPath = LocalizationConfigurationScript::GetWordCountReportConfigPath(LocalizationTarget);
    // 相対パス->絶対パス.
    ConfigPath = FPaths::ConvertRelativePathToFull(ConfigPath);

    // Commandlet実行.
    ExecuteCommandlet(TEXT("GatherTextCommandlet"), FString::Format(TEXT("-config=\"{0}\""), { ConfigPath }));

    // 生成したレポートを元に更新.
    LocalizationTarget->UpdateStatusFromConflictReport();
    LocalizationTarget->UpdateWordCountsFromCSV();
}

実行例

上記の処理をまとめて呼び出す例です。
※テキスト収集の前に収集対象のStringTable等を更新すると自動化が捗ります。

void Example(const FString& GameTargetName)
{
    // テキスト収集.
    GatherText(GameTargetName);
    // POファイルをインポート.
    ImportPO(GameTargetName);
    // コンパイル.
    Compile(GameTargetName);
    // WordCount更新.
    UpdateWordCount(GameTargetName);
}

おまけ

GatherTextの引数は;区切りで複数のiniファイルを指定することができるようになっているようです。
個別に実行した場合はソースコントロールに関する処理が都度呼ばれるだけのようです。
特に問題がないようなら都合のよいやり方を選択して構わないと思います。

最後に

最初にゲームターゲットの設定をしてしまえば、それ以降の追加や更新の作業については簡略化することが可能となり確認作業がしやすくなります。
また、会社によっては自社でテキスト管理ツールを作成されているところもあるかと思います。
そういった場合にも少しの作業で連携することが可能になるのではないかと思います。

C++からCommandletを実行する方法

使用バージョン:UE4.24.3

目的

何かしらのバッチ処理や自作ツールからCommandletを実行したい場合があります。
コマンドライン引数-run=[Commandlet名]で実行することができますが、複数のCommandletを実行したい場合は実行する度にエディタの起動と終了が実行されてしまうため、時間がかかってしまうことがあります。
そういった場合は複数のCommandletを実行する処理を用意することになるかと思います。

コード

#include "Commandlets/Commandlet.h"

void ExecuteCommandlet(const FString& CommaneletName, const FString& Params)
{
    UClass* CommandletClass = FindObject<UClass>(ANY_PACKAGE, *CommaneletName, false);
    check(::IsValid(CommandletClass));
    UCommandlet* Commandlet = NewObject<UCommandlet>(GetTransientPackage(), CommandletClass);
    check(::IsValid(Commandlet));
    Commandlet->Main(Params);
}

プロパティ(構造体)やクラスのレイアウトカスタマイズの登録/解除

使用バージョン:UE4.24.3

目的

エディタ上でのプロパティ(構造体)やクラスのレイアウトカスタマイズを使用する際の登録/解除の処理をFDetailCustomizationsModuleより抜粋したメモです。

準備

以下のモジュールを依存関係として追加する必要があります。

  • PropertyEditor

コード

モジュールh

#pragma once

#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
#include "PropertyEditorDelegates.h"


class FExampleModule : public IModuleInterface
{
private:
    /** 登録クラスリスト */
    TSet<FName>                               RegisteredClassNames;
    /** 登録プロパティリスト */
    TSet<FName>                               RegisteredPropertyTypes;
public:

    /** IModuleInterface implementation */
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;

private:
    /** クラスレイアウトカスタマイズ登録をまとめた関数 */
    void   RegisterCustomClassLayouts();
    /** プロパティレイアウトカスタマイズ登録をまとめた関数 */
    void   RegisterCustomProperties();

    /** クラスレイアウトカスタマイズ登録 */
    void   RegisterCustomClassLayout(FName ClassName, FOnGetDetailCustomizationInstance DetailLayoutDelegate);
    /** プロパティレイアウトカスタマイズ登録 */
    void   RegisterCustomPropertyTypeLayout(FName PropertyTypeName, FOnGetPropertyTypeCustomizationInstance PropertyTypeLayoutDelegate);
    /** 全てのレイアウトカスタマイズ登録解除 */
    void   UnregisterAllCustomLayouts();
};

モジュールcpp

#include "ExampleModule.h"
#include "PropertyEditorModule.h"

#define LOCTEXT_NAMESPACE "FExampleModule"



void FExampleModule::StartupModule()
{
    if (!IsRunningGame() && !IsRunningDedicatedServer())
    {
        this->RegisterCustomClassLayouts();
        this->RegisterCustomProperties();

        // カスタマイズモジュールへ変更を通知.
        FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
        PropertyModule.NotifyCustomizationModuleChanged();
    }
}
//---

void FExampleModule::ShutdownModule()
{
    this->UnregisterAllCustomLayouts();
}
//---



void
FExampleModule::RegisterCustomClassLayouts()
{
    // クラスレイアウトカスタマイズ登録.
}
//---

void
FExampleModule::RegisterCustomProperties()
{
    // プロパティレイアウトカスタマイズ登録.
}
//---


void
FExampleModule::RegisterCustomClassLayout(FName ClassName, FOnGetDetailCustomizationInstance DetailLayoutDelegate)
{
    check(ClassName != NAME_None);

    this->RegisteredClassNames.Add(ClassName);

    static FName PropertyEditor("PropertyEditor");
    FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>(PropertyEditor);
    PropertyModule.RegisterCustomClassLayout(ClassName, DetailLayoutDelegate);
}
//---

void
FExampleModule::RegisterCustomPropertyTypeLayout(FName PropertyTypeName, FOnGetPropertyTypeCustomizationInstance PropertyTypeLayoutDelegate)
{
    check(PropertyTypeName != NAME_None);

    this->RegisteredPropertyTypes.Add(PropertyTypeName);

    static FName PropertyEditor("PropertyEditor");
    FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>(PropertyEditor);
    PropertyModule.RegisterCustomPropertyTypeLayout(PropertyTypeName, PropertyTypeLayoutDelegate);
}
//---

void
FExampleModule::UnregisterAllCustomLayouts()
{
    if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
    {
        FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");

        // Unregister all classes customized by name
        for (auto It = this->RegisteredClassNames.CreateConstIterator(); It; ++It)
        {
            if (It->IsValid())
            {
                PropertyModule.UnregisterCustomClassLayout(*It);
            }
        }

        // Unregister all structures
        for (auto It = this->RegisteredPropertyTypes.CreateConstIterator(); It; ++It)
        {
            if (It->IsValid())
            {
                PropertyModule.UnregisterCustomPropertyTypeLayout(*It);
            }
        }

        PropertyModule.NotifyCustomizationModuleChanged();
    }
}
//---


#undef LOCTEXT_NAMESPACE
    
IMPLEMENT_MODULE(FExampleModule, Example)

IInputProcessorによるキー入力処理について

使用バージョン:UE4.24.3

はじめに

キー入力処理は通常ではPlayerControllerLevelActorWidget等で行っているかと思います。
個々のキー入力処理を行う場合であればこれで問題ないと思いますが、例えば「最後に入力されたデバイス(キーボードorゲームパッドorマウス)によって操作アイコンを変更したい」といった場合だと状況によってはキー入力処理が呼ばれない場合や複数箇所に処理が必要な可能性があり色々と不都合があります。
そういった場合はIInputProcessorを用いてキー入力処理を行う方法がよさそうです。

docs.unrealengine.com

キー入力処理が呼ばれない状況とは

PlayerControllerSetInputMode()を用いることでキー入力処理を制限することができます。
例えば、InputModeUIOnlyに設定している場合はPlayerControllerのキー入力処理が行われなくなります。
これ以外にも、以降のキー入力処理が行われないように入力の消費設定を行っている場合もあります。

準備

以下のモジュールを依存関係として追加する必要があります。

  • Slate

使い方

IInputProcessorを継承したクラスを用意し、FSlateApplicationへ登録することで呼び出されるようになります。 登録するIInputProcessor継承クラスはGameInstance等で保持させておき、必要なタイミングで登録/解除を行います。

IInputProcessor継承クラス

#pragma once

#include "CoreMinimal.h"
#include "Framework/Application/IInputProcessor.h"

#include "InputProcessor.generated.h"


class FInputProcessor : public IInputProcessor
{
    using Super = IInputProcessor;
public:
    virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override
    {
    }

    virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override
    {
        // 任意のキー押下イベント.
        return Super::HandleKeyDownEvent(SlateApp, InKeyEvent);
    }

    virtual bool HandleKeyUpEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override
    {
        // 任意のキー離上イベント.
        return Super::HandleKeyUpEvent(SlateApp, InKeyEvent);
    }

    virtual bool HandleAnalogInputEvent(FSlateApplication& SlateApp, const FAnalogInputEvent& InAnalogInputEvent) override
    {
        // 任意のアナログ入力イベント.
        return Super::HandleAnalogInputEvent(SlateApp, InAnalogInputEvent);
    }
};

FSlateApplicationの登録/解除

#pragma once
#include "Framework/Application/SlateApplication.h"

class FExample
{
private:
    TSharedPtr<FInputProcessor> InputProcessor = MakeShareable(new FInputProcessor());

public:
    /** InputProcessorを登録して有効にします */
    void EnableInputProcessor()
    {
        FSlateApplication& SlateApplication = FSlateApplication::Get();
        SlateApplication.RegisterInputPreProcessor(this->InputProcessor);
    }

    /** InputProcessorを登録解除して無効にします */
    void DisableInputProcessor()
    {
        FSlateApplication& SlateApplication = FSlateApplication::Get();
        SlateApplication.UnregisterInputPreProcessor(this->InputProcessor);
    }
};

最後に

今回紹介したIInputProcessorを用いることで独自にキー入力処理することができます。
必ずしも利用される機能ではないかもしれませんが、状況を選ばずキー入力処理を行う必要がある場合は利用してみてもいいかと思います。

C++から変更アセットを保存する方法

使用バージョン:UE4.24.2

はじめに

エディタ上でアセットに対して何かしらの編集処理を行った際に自動で保存を実行したい場合があります。
そういった場合に対象アセットを保存するためのラッパー関数を用意する機会がありましたので、その備忘録となります。

準備

エディタ用のコードとなるため、エディタ用モジュールを用意しそちらへ実装します。

実装方法

依存モジュール

以下のモジュールを依存関係として追加する必要があります。

  • UnrealEd

ヘッダー

もし、BPから呼び出したい場合はUFUNCTIONでBPへ公開するよう設定すれば動作するかと思います。

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"

#include "AssetUtilityFunctionLibrary.generated.h"


UCLASS()
class XXX_API UAssetUtilityFunctionLibrary: public UBlueprintFunctionLibrary
{
    GENERATED_BODY()
public:
    /**
    * アセットの保存を実行します
    *
    * @param [in]  InTargets           対象アセット
    * @param [in]  bCheckDirty         trueの場合は変更(Dirty)アセットのみが保存されます
    * @param [in]  bPromptToSave       trueの場合はアセットの保存確認を求められます。falseの場合は全て保存されます。
    */
    static void ExecuteSaveAssets(const TArray<UObject*>&  InTargets,
                                  bool                     bCheckDirty = true,
                                  bool                     bPromptToSave = false);
};

実装

#include "AssetUtilityFunctionLibrary.h"

#if WITH_EDITOR
#include "FileHelpers.h"
#endif // WITH_EDITOR

void
UAssetUtilityFunctionLibrary::ExecuteSaveAssets(const TArray<UObject*>&  InTargets,
                                                const bool                bCheckDirty,
                                                const bool                bPromptToSave)
{
    TArray<UPackage*> PackagesToSave;
    for (UObject* Obj : InTargets)
    {
        if (IsValid(Obj))
        {
            if (Obj->HasAnyFlags(RF_Transient))
            {
                continue;
            }

            UPackage* Package = Obj->GetOutermost();
            if (IsValid(Package))
            {
                PackagesToSave.Add(Package);
            }
        }
    }

    if (0 < PackagesToSave.Num())
    {
        FEditorFileUtils::PromptForCheckoutAndSave(
            PackagesToSave,
            bCheckDirty,
            bPromptToSave);
    }
}
//---

最後に

直接FEditorFileUtils::PromptForCheckoutAndSave()を呼び出しても構わないのですが、個人的にUObjectで渡す方が使い勝手がいいと思ったので、簡単なラッパー関数を用意して使用しています。

MarkPackageDirty()の呼び出しだけだと保存まではされないため、保存したい場合はこのような処理を行う必要があります。
専用アセットを用意したり、アセットの変更を検知して別のアセットの変更を行ったりする場合に利用する機会があるかもしれません。

構造体のMake/BreakノードのNative実装

使用バージョン:UE4.24.2

はじめに

USTRUCTメタデータを使用することで構造体のMake/Breakノードで指定の関数を呼び出すことができるようになります。 このときに指定する関数は、UBlueprintFunctionLibrary等でBlueprintCallableとしてUFUNCTION定義したものになります。

Makeノード

メタデータHasNativeMake=[関数パス([モジュール名].[クラス名].[関数名])]を設定します。

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"

#include "Example.generated.h"


/**
 * Makeノード実装
 */
USTRUCT(BlueprintType
    , meta = (HasNativeMake="Example.ExampleFunctionLibrary.MakeExample")
struct FExample
{
    GENERATED_BODY()

    UPROPERTY()
    int32       Value = 0;
};


UCLASS()
class UExampleFunctionLibrary : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()
public:
    /** FExampleのMakeノード用関数 */
    UFUNCTION(BlueprintPure, meta = (NativeMakeFunc))
    static FExample MakeExample()
    {
        FExample Example = { 1 };
        return Example;
    }
};

Breakノード

メタデータHasNativeBreak=[関数パス([モジュール名].[クラス名].[関数名])]を設定します。

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"

#include "Example.generated.h"


/**
 * Breakノード実装
 */
USTRUCT(BlueprintType
    , meta = (HasNativeBreak= "Example.ExampleFunctionLibrary.BreakExample"))
struct FExample
{
    GENERATED_BODY()

    UPROPERTY()
    int32       Value = 0;
};


UCLASS()
class UExampleFunctionLibrary : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()
public:
    /** FExampleのBreakノード用関数 */
    UFUNCTION(BlueprintPure, meta = (NativeBreakFunc))
    static void BreakExample(const FExample& InTarget, int32& Value)
    {
        Value = InTarget.Value;
    }
};

最後に

通常は、プロパティをブループリント非公開の場合はMake/Breakノード自体がノードリストに表示されませんが、この方法を利用した場合はノードリストに表示され使用することができます。

個人的な利用方法として、構造体をハンドルとして利用する際にプロパティをブループリント非公開にし、デフォルトのMake/Breakノードを使用せず、必要であればMakeノードの実装や関数を用意し必要最低限の機能だけを利用できるようにしています。

修正

  • 関数パスのクラス名にプレフィックスついた状態だと、PythonScriptPluginを利用した際にエディタ起動時にREPORT_PYTHON_GENERATION_ISSUE()が発生してしまうのもあり、エンジン内を確認し該当箇所を修正いたしました。

    UExampleFunctionLibrary -> ExampleFunctionLibrary
    

デフォルトのLevelScriptActorを変更する方法

使用バージョン:UE4.24.2

f:id:shama-coo:20210113132910p:plain:w300

はじめに

レベルを作成した際の親クラスとして設定されるLevelScriptActorをプロジェクト設定より変更することができます。
例えばサブレベルの管理など各レベルに共通処理を実装したい場合に、独自のLevelScriptActorを設定することで対応することも可能です。

準備

LevelScriptActorを継承したクラスを作成します。

BPの場合

  1. プロジェクト設定 > エンジン - 基本設定を選択します。
  2. デフォルトクラスLevelScriptActorClass+アイコンを選択します。
    f:id:shama-coo:20210113133642p:plain:w300
  3. 任意の名前をつけて保存します。
    f:id:shama-coo:20210113133734p:plain:w180
  4. 作成したブループリントが自動で開きますのでコンパイルし保存します。
    f:id:shama-coo:20210113133915p:plain:w300

C++の場合

#pragma once

#include "CoreMinimal.h"
#include "Engine/LevelScriptActor.h"

#include "CustomLevelScriptActor.generated.h"


/**
 * カスタムLevelScriptActor
 */
UCLASS()
class ACustomLevelScriptActor : public ALevelScriptActor
{
    GENERATED_BODY()
};

デフォルトのLevelScriptActorを変更

  1. プロジェクト設定 > エンジン - 基本設定を選択します。
  2. デフォルトクラスLevelScriptActorClassに作成したLevelScriptActor を設定します。
    ※BPを作成した場合は自動で設定されます。
    f:id:shama-coo:20210113134041p:plain:w300
  3. エディタを再起動します。
    検証時はエディタ再起動することで新規レベル作成時に親クラスとして設定されるようになりました。
    新規レベル作成し、親クラスがLevelScriptActorのままだった場合はエディタを再起動してみてください。

注意

ULevelからLevelScriptActorを取得した際に、nullだった場合は該当レベルをコンパイルしてみてください。
※内部的にLevelScriptActorが生成されるのは、該当レベルのBPをコンパイルしたときのようです。

最後に

各レベルの共通処理の対応方法は他にもありますが、サブレベルの管理や何かしらの管理機能の初期化を待つ必要がある場合など状況によってはこの方法を検討していいかなと思います。