マニアックなことに挑戦する機会があったので、技術共有したいと思います。
ゲームを起動中に UMG を表示するのは簡単ですが、ゲームを起動していないときに UMG をレベルエディタに表示するのは面倒工夫が必要でした。
以下の環境で動作確認しています。
Unreal Engine 5.1.1
Visual Studio 2022
Windows 11 Home
マニアックな内容ですので、本稿では初心者向けの説明は行いません。
表題で【C++】と表記していますので、エンジニア向けです。
今回は EUW は使いません。
本稿で紹介する技術によって、エディタ機能を拡張するときの選択肢が広がると思います。
EUW を使った方が都合が良い場合は EUW を使う方が良いです。
今回のキモは 1. です。
2. と 3. はオマケなので、説明を省きます(コード参照)。
今回作成したプロジェクト名は UMGonLevelEditor です。
ヒャッハーゲージは UMG で作ります。
ウィジェットブループリントを新規作成して、キャンバスにプログレスバーを追加します。
親は UUserWidget にしておき、後で置き換えます。
以下のモジュールを追加で読み込みます。
“SlateCore”
“Slate”
“UMG”
“UnrealEd”
コードをクラス別に分けるのが面倒だったので、1つの .h ファイル、1つの .cpp ファイルに全ての処理を記載しています。
追加した .h ファイル(HyahhaaaGaugeWidget.h)
// Fill out your copyright notice in the Description page of Project Settings. #pragma once #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "Subsystems/EditorActorSubsystem.h" #include "HyahhaaaGaugeWidget.generated.h" UCLASS() class UMGONLEVELEDITOR_API UHyahhaaaWorldSubsystem final : public UTickableWorldSubsystem { GENERATED_BODY() public: // UWorldSubsystem interface virtual void Deinitialize() override; virtual void OnWorldBeginPlay(UWorld& InWorld) override; // FTickableGameObject interface virtual void Tick(float DeltaTime) override; virtual bool IsTickable() const override { return bIsTickable; } virtual bool IsTickableInEditor() const override { return bIsTickable; } private: bool CreateWidgetAndAddToEditorViewport(UWorld* World, UClass* WidgetClass); bool CreateEditorCanvas(); TSharedPtr<class SConstraintCanvas> EditorSlateCanvas; inline static bool bIsTickable { true }; }; UCLASS() class UMGONLEVELEDITOR_API UHyahhaaaEditorActorSubsystem final : public UEditorActorSubsystem { GENERATED_BODY() }; UCLASS() class UMGONLEVELEDITOR_API UHyahhaaaGaugeWidget final : public UUserWidget { GENERATED_BODY() public: static UUserWidget* GetEditorWidget() { return EditorWidget; } static void SetEditorWidget(UUserWidget* Widget) { EditorWidget = Widget; } virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; /** * ヒャッハーを1つ追加し、ヒャッハーゲージを1つ分増やす。 * ヒャッハーは1つずつしか増やすことができず、まとめて増やすことはできない。 * 千里のヒャッハーもいちヒャッハーからである。 */ UFUNCTION(BlueprintCallable) void AddHyahhaaa(); UFUNCTION(BlueprintCallable) void ClearHyahhaaa(); private: UPROPERTY(EditAnywhere, meta = (BindWidget)) class UProgressBar* HyahhaaaGauge { nullptr }; int32 OldHyahhaaaCount { 0 }; inline static UUserWidget* EditorWidget { nullptr }; };
追加した .cpp ファイル(HyahhaaaGaugeWidget.cpp)
// Fill out your copyright notice in the Description page of Project Settings. #include "HyahhaaaGaugeWidget.h" #include "Components/ProgressBar.h" #include "Blueprint/GameViewportSubsystem.h" #include "Blueprint/WidgetBlueprintLibrary.h" #include "Components/PanelWidget.h" #include "Components/TextRenderComponent.h" #include "Editor.h" #include "EditorViewportClient.h" #include "Engine/TextRenderActor.h" #include "IAssetViewport.h" #include "Kismet/GameplayStatics.h" #include "LevelEditor.h" #include "LevelEditorViewport.h" #include "Modules/ModuleManager.h" #include "SLevelViewport.h" /////////////////////////////////////////////////////////////////////////////////////////////////// namespace { bool IsInGame() { return !GIsEditor; } bool IsInPIE() { return GEditor && GEditor->PlayWorld && !GEditor->bIsSimulatingInEditor; } UClass* LoadWidgetClass() { const FString WidgetClassPath = TEXT("/Script/UMGEditor.WidgetBlueprint'/Game/WB_ViewOnLevelEditor.WB_ViewOnLevelEditor_C'"); FSoftClassPath WidgeClasstPath(WidgetClassPath); UClass* WidgetClass = WidgeClasstPath.TryLoadClass<UUserWidget>(); return WidgetClass; } } /////////////////////////////////////////////////////////////////////////////////////////////////// void UHyahhaaaWorldSubsystem::Deinitialize() { // エディタを使わないならコンパイルしないで良い #if UE_EDITOR // UEエディタ起動時、レベル読み込み前の解放時(スタンドアローンも)に呼ばれる EditorSlateCanvas.Reset(); #endif } void UHyahhaaaWorldSubsystem::OnWorldBeginPlay(UWorld& InWorld) { #if UE_EDITOR // ゲームや PIE 起動時にヒャッハーゲージを非表示にする UUserWidget* EditorWidget = UHyahhaaaGaugeWidget::GetEditorWidget(); if (IsValid(EditorWidget)) { EditorWidget->SetVisibility(ESlateVisibility::Hidden); bIsTickable = true; } #endif } bool UHyahhaaaWorldSubsystem::CreateWidgetAndAddToEditorViewport(UWorld* World, UClass* WidgetClass) { #if UE_EDITOR UUserWidget* EditorWidget = UHyahhaaaGaugeWidget::GetEditorWidget(); if (IsValid(EditorWidget) && !EditorWidget->IsVisible()) { EditorWidget->SetVisibility(ESlateVisibility::Visible); return true; } if (EditorSlateCanvas.IsValid()) { return false; } if (IsInPIE() || IsInGame()) { return false; } APlayerController* Player = UGameplayStatics::GetPlayerController(World, 0); UUserWidget* Widget = UWidgetBlueprintLibrary::Create(World, WidgetClass, Player); if (!IsValid(Widget)) { return false; } UHyahhaaaGaugeWidget::SetEditorWidget(Widget); if (!CreateEditorCanvas()) { return false; } FLevelEditorModule& LevelEditor = FModuleManager::GetModuleChecked<FLevelEditorModule>(TEXT("LevelEditor")); LevelEditor.FocusViewport(); TSharedPtr<IAssetViewport> FirstActiveViewport = LevelEditor.GetFirstActiveViewport(); if (!FirstActiveViewport.IsValid()) { return false; } TSharedRef<SConstraintCanvas> Canvas = EditorSlateCanvas.ToSharedRef(); FirstActiveViewport->AddOverlayWidget(Canvas); return true; #else return false; #endif } bool UHyahhaaaWorldSubsystem::CreateEditorCanvas() { #if UE_EDITOR if (EditorSlateCanvas.IsValid()) { return false; } if (IsInPIE() || IsInGame()) { return false; } UUserWidget* EditorWidget = UHyahhaaaGaugeWidget::GetEditorWidget(); if (!IsValid(EditorWidget)) { return false; } auto CalculateOffsetArgument = [](const FGameViewportWidgetSlot& Slot) -> TPair<FMargin, bool> { // If the size is zero, and we're not stretched, then use the desired size. FVector2D FinalSize = FVector2D(Slot.Offsets.Right, Slot.Offsets.Bottom); bool bUseAutoSize = FinalSize.IsZero() && !Slot.Anchors.IsStretchedVertical() && !Slot.Anchors.IsStretchedHorizontal(); return TPair<FMargin, bool>(Slot.Offsets, bUseAutoSize); }; FGameViewportWidgetSlot ViewportSlot; ViewportSlot.ZOrder = 0; TPair<FMargin, bool> OffsetArgument = CalculateOffsetArgument(ViewportSlot); SConstraintCanvas::FSlot* RawSlot = nullptr; TSharedPtr<SConstraintCanvas> FullScreenCanvas = SNew(SConstraintCanvas) + SConstraintCanvas::Slot() .Offset(OffsetArgument.Get<0>()) .AutoSize(OffsetArgument.Get<1>()) .Anchors(ViewportSlot.Anchors) .Alignment(ViewportSlot.Alignment) .Expose(RawSlot) [ EditorWidget->TakeWidget() ]; if (!FullScreenCanvas.IsValid()) { return false; } EditorSlateCanvas = FullScreenCanvas; return true; #else return false; #endif } void UHyahhaaaWorldSubsystem::Tick(float DeltaTime) { #if UE_EDITOR if (IsInGame() || IsInPIE()) { return; } const bool bIsEditor = GIsEditor; if (!bIsEditor) { return; } UWorld* World = GWorld; if (!IsValid(World)) { return; } UClass* WidgetClass = LoadWidgetClass(); if (!IsValid(WidgetClass)) { return; } bool bIsFailed = !CreateWidgetAndAddToEditorViewport(World, WidgetClass); if (bIsFailed) { return; } // ヒャッハーゲージを表示したので、これ以上は Tick しなくて良い bIsTickable = false; #endif } /////////////////////////////////////////////////////////////////////////////////////////////////// void UHyahhaaaGaugeWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) { #if UE_EDITOR UHyahhaaaEditorActorSubsystem* ActorSubsystem = GEditor->template GetEditorSubsystem<UHyahhaaaEditorActorSubsystem>(); if (!IsValid(ActorSubsystem)) { return; } TArray<AActor*> Actors = ActorSubsystem->GetAllLevelActors(); if (Actors.Num() == OldHyahhaaaCount) { return; } OldHyahhaaaCount = Actors.Num(); ClearHyahhaaa(); for (AActor* Actor : Actors) { if (!IsValid(Actor)) { continue; } ATextRenderActor* TextRenderActor = Cast<ATextRenderActor>(Actor); if (TextRenderActor == nullptr) { continue; } UTextRenderComponent* TextRenderComponent = TextRenderActor->GetTextRender(); if (!IsValid(TextRenderComponent)) { continue; } const FText Text = TextRenderComponent->Text; const FString TextString = Text.ToString(); const FString TargetText = TEXT("Hyahhaaa!!"); if (TextString != TargetText) { continue; } AddHyahhaaa(); } #endif } void UHyahhaaaGaugeWidget::AddHyahhaaa() { #if UE_EDITOR if (!IsValid(HyahhaaaGauge)) { return; } float Percent = HyahhaaaGauge->GetPercent(); if (Percent < 1.f) { Percent += 0.01f; HyahhaaaGauge->SetPercent(Percent); HyahhaaaGauge->SetFillColorAndOpacity(FLinearColor::Blue); } else { HyahhaaaGauge->SetFillColorAndOpacity(FLinearColor::Red); } #endif } void UHyahhaaaGaugeWidget::ClearHyahhaaa() { #if UE_EDITOR if (!IsValid(HyahhaaaGauge)) { return; } HyahhaaaGauge->SetPercent(0.f); #endif }
ビルドが通ったらエディタを起動し、新規作成したウィジェットブループリントの親クラスを UHyahhaaaGaugeWidget に変更します。
エディタでしか使わない機能を追加するので、これらを判定する必要があります。
PIE を起動したら、レベルエディタに追加した UMG を削除する必要があります。
一度レベルエディタに UMG を追加すると、PIE 開始後も残るので邪魔になります。
これらを判定する処理は、エディタの機能を追加しているとき良く使うのですが、汎用化されてません。
IsInPIE や IsInGame でソースコード検索すれば出てきます。
プラグイン用のクラスがメンバ関数として持っている場合もあれば、ソースファイルのローカルに定義されている場合もあり、流用するのがとても面倒なので、コピペしています。
bool IsInGame() { return !GIsEditor; } bool IsInPIE() { return GEditor && GEditor->PlayWorld && !GEditor->bIsSimulatingInEditor; }
UUserWidget には AddToViewport 関数がありますが、ゲーム中でないと使えません。
なので、レベルエディタのビューポートに UUserWidget を追加する処理を自分で作る必要があります。
FLevelEditorModule& LevelEditor = FModuleManager::GetModuleChecked<FLevelEditorModule>(TEXT("LevelEditor")); LevelEditor.FocusViewport(); TSharedPtr<IAssetViewport> FirstActiveViewport = LevelEditor.GetFirstActiveViewport();
↑のコードの FirstActiveViewport がレベルエディタのビューポートです。
今のところ、この手順で問題は起きていませんが、タイミングやレベルエディタの状態によっては、有効な値をうまく取れない場合があるかも知れません。
レベルエディタのビューポートにスレートを追加できれば、そのスレートに UMG を追加するだけで表示できます。
TSharedRef<SConstraintCanvas> Canvas = EditorSlateCanvas.ToSharedRef(); FirstActiveViewport->AddOverlayWidget(Canvas);
EditorSlateCanvas は CreateEditorCanvas() 関数内で生成しています。
TSharedPtr<SConstraintCanvas> FullScreenCanvas = SNew(SConstraintCanvas) + SConstraintCanvas::Slot() .Offset(OffsetArgument.Get<0>()) .AutoSize(OffsetArgument.Get<1>()) .Anchors(ViewportSlot.Anchors) .Alignment(ViewportSlot.Alignment) .Expose(RawSlot) [ EditorWidget->TakeWidget() ]; EditorSlateCanvas = FullScreenCanvas;
エディタが起動したときに呼び出してくれるイベントがあれば良いのですが、見つけられなかったので Tick を使っています。
World Subsystem がエディタ起動中に Tick させる機能を持っているので、それを利用しています。
UCLASS() class UMGONLEVELEDITOR_API UHyahhaaaWorldSubsystem final : public UTickableWorldSubsystem { GENERATED_BODY() public: // UWorldSubsystem interface virtual void Deinitialize() override; virtual void OnWorldBeginPlay(UWorld& InWorld) override; // FTickableGameObject interface virtual void Tick(float DeltaTime) override; virtual bool IsTickable() const override { return bIsTickable; } virtual bool IsTickableInEditor() const override { return bIsTickable; } private: inline static bool bIsTickable { true };
IsTickable() と IsTickableInEditor() の両方が true を返すことで、エディタ起動中に Tick() するようになります。
ヒャッハーゲージはエディタでしか使わないので、PIE 中やゲーム中に Tick するのは無駄です。
なので、bIsTickable を使って Tick するかどうかを制御しています。
前述の World Subsystem を使うことで、PIE かゲームが起動したタイミングが分かります。
OnWorldBeginPlay() をオーバーライドすると、そのタイミングで関数が呼ばれます。
Tick で IsInPIE() と IsInGame() を使って判定しても同じことができます。
そうしていないのは、Tick はエディタが起動したかどうかを判定するために使っているので、それ以上の処理をさせたくなかったのと、無駄な Tick をなくすため、処理したり・しなかったりするので、「いつどんなタイミングで Tick を処理するのか?」を把握するのが面倒だからです。
void UHyahhaaaWorldSubsystem::OnWorldBeginPlay(UWorld& InWorld) { #if UE_EDITOR // ゲームや PIE 起動時にヒャッハーゲージを非表示にする UUserWidget* EditorWidget = UHyahhaaaGaugeWidget::GetEditorWidget(); if (IsValid(EditorWidget)) { EditorWidget->SetVisibility(ESlateVisibility::Hidden); bIsTickable = true; } #endif }
void UHyahhaaaWorldSubsystem::Tick(float DeltaTime) { #if UE_EDITOR if (IsInGame() || IsInPIE()) { return; } const bool bIsEditor = GIsEditor; if (!bIsEditor) { return; } UWorld* World = GWorld; if (!IsValid(World)) { return; } UClass* WidgetClass = LoadWidgetClass(); if (!IsValid(WidgetClass)) { return; } bool bIsFailed = !CreateWidgetAndAddToEditorViewport(World, WidgetClass); if (bIsFailed) { return; } // ヒャッハーゲージを表示したので、これ以上は Tick しなくて良い bIsTickable = false; #endif }
とにかくヒャッハーするべし