// Scenarios of the UI track (DESIGN_UE.md section 9; ui_gallery, ui_hud_layout, ui_windows, ui_dash). // Their pictures include the Slate UI, so they use UiScnShot (UI/DescentUiShot) instead of S.Shot, which never shows UI. #include "Core/DescentSimSubsystem.h" #include "Debug/DescentScenario.h" #include "Debug/DescentDebugSubsystem.h" #include "TheDescent.h" #include "UI/DescentDash.h" #include "UI/DescentUiDemo.h" #include "UI/NeuIcons.h" #include "UI/NeuWidgets.h" #include "UI/DescentUiLayer.h" #include "UI/DescentUiSubsystem.h" #include "UI/DescentUiShot.h" #include "UI/NeuGallery.h" #include "AssetCompilingManager.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" #include "Dom/JsonObject.h" #include "Dom/JsonValue.h" #include "Engine/World.h" #include "HAL/IConsoleManager.h" #include "ShaderCompiler.h" #include "Styling/CoreStyle.h" #include "Widgets/Images/SImage.h" #include "Widgets/Layout/SBox.h" #include "Widgets/Layout/SScaleBox.h" #include "Widgets/SOverlay.h" namespace { bool UiScnContentPreparing() { if (GShaderCompilingManager && GShaderCompilingManager->IsCompiling()) { return true; } return FAssetCompilingManager::Get().GetNumRemainingAssets() > 0; } /** Puts a widget into the viewport inside the UI layer; the pointer lets a later step take it out again. */ struct FUiScnHolder { TSharedPtr Layer; }; void UiScnAdd(FUiScnHolder& H, const TSharedRef& Content, int32 Z) { if (GEngine && GEngine->GameViewport) { TSharedRef Layer = SNew(SDescentUiLayer)[ Content ]; H.Layer = Layer; GEngine->GameViewport->AddViewportWidgetContent(Layer, Z); } } void UiScnRemove(FUiScnHolder& H) { if (H.Layer.IsValid() && GEngine && GEngine->GameViewport) { GEngine->GameViewport->RemoveViewportWidgetContent(H.Layer.ToSharedRef()); } H.Layer.Reset(); } /** A picture of the window with the UI: waits for shaders and a few frames, requests the frame, checks it. */ void UiScnShot(FDescentScenarioBuilder& S, const FString& Label, TSharedPtr Expected = nullptr, int32 WarmupFrames = 12) { struct FState { int32 Phase = 0; int64 QuietSince = -1; double QuietTime = 0.0; bool bDone = false; Descent::Ui::FUiShotResult Result; }; TSharedRef St = MakeShared(); S.Until(FString::Printf(TEXT("UiShot(%s)"), *Label), [St, Label, WarmupFrames, Expected](FDescentScenarioContext& C) { if (St->Phase == 0) { if (UiScnContentPreparing()) { St->QuietSince = -1; return false; } if (St->QuietSince < 0) { St->QuietSince = static_cast(GFrameCounter); St->QuietTime = FPlatformTime::Seconds(); } if (static_cast(GFrameCounter) - St->QuietSince < WarmupFrames || FPlatformTime::Seconds() - St->QuietTime < 0.5) { return false; } const FString Stem = C.ScenarioName + TEXT("_") + Label; TSharedRef Keep = St; if (!Descent::Ui::RequestUiShot(Stem, [Keep](const Descent::Ui::FUiShotResult& R) { Keep->Result = R; Keep->bDone = true; }) && !St->bDone) { C.Fail(TEXT("UI screenshot request was refused")); } St->Phase = 1; return false; } if (!St->bDone) { return false; } const Descent::Ui::FUiShotResult& R = St->Result; if (!R.bOk) { C.Fail(FString::Printf(TEXT("UiShot(%s) failed: %s"), *Label, *R.Error)); return false; } UE_LOG(LogDescent, Display, TEXT("UiShot(%s): %s %dx%d mean luma %.3f non-black %.1f%%"), *Label, *R.Path, R.Size.X, R.Size.Y, R.Stats.MeanLuma, R.Stats.NonBlack * 100.0); const FIntPoint Want = Expected.IsValid() ? *Expected : C.RequestedResolution; if (R.Size != Want) { C.Fail(FString::Printf(TEXT("UiShot(%s) is %dx%d but %dx%d was expected"), *Label, R.Size.X, R.Size.Y, Want.X, Want.Y)); return false; } if (R.Stats.NonBlack < 0.001) { C.Fail(FString::Printf(TEXT("UiShot(%s) is black: the UI was not drawn"), *Label)); return false; } C.LastShotStats = R.Stats; return true; }); } /** Changes the window size through r.SetRes and waits until the viewport has it. */ void UiScnSetRes(FDescentScenarioBuilder& S, int32 W, int32 H, TSharedRef Expected) { S.Do(FString::Printf(TEXT("set resolution %dx%d"), W, H), [W, H, Expected](FDescentScenarioContext& C) { *Expected = FIntPoint(W, H); if (GEngine) { GEngine->Exec(C.World, *FString::Printf(TEXT("r.SetRes %dx%dw"), W, H)); } }); S.Until(FString::Printf(TEXT("viewport is %dx%d"), W, H), [W, H](FDescentScenarioContext&) { return GEngine && GEngine->GameViewport && GEngine->GameViewport->Viewport && GEngine->GameViewport->Viewport->GetSizeXY() == FIntPoint(W, H); }); } struct FUiScnRect { FString Name; double X = 0, Y = 0, W = 0, H = 0; }; /** Reads ui.widgets from a state snapshot; true if every visible rectangle is inside the viewport and none intersect. */ bool UiScnLayoutIsClean(const TSharedRef& Snap, FString& OutWhy, int32& OutVisible) { const TSharedPtr* UiObj = nullptr; const TSharedPtr* Widgets = nullptr; if (!Snap->TryGetObjectField(TEXT("ui"), UiObj) || !(*UiObj)->TryGetObjectField(TEXT("widgets"), Widgets)) { OutWhy = TEXT("no ui.widgets in the state dump"); return false; } const TArray>* Vp = nullptr; const TArray>* List = nullptr; if (!(*Widgets)->TryGetArrayField(TEXT("viewport_px"), Vp) || !(*Widgets)->TryGetArrayField(TEXT("list"), List) || Vp->Num() < 2) { OutWhy = TEXT("ui.widgets has no list or viewport_px"); return false; } const double VW = (*Vp)[0]->AsNumber(); const double VH = (*Vp)[1]->AsNumber(); TArray Rects; for (const TSharedPtr& V : *List) { const TSharedPtr O = V->AsObject(); if (!O.IsValid() || !O->GetBoolField(TEXT("visible")) || O->GetStringField(TEXT("name")) == TEXT("window")) { continue; } Rects.Add({ O->GetStringField(TEXT("name")), O->GetNumberField(TEXT("x")), O->GetNumberField(TEXT("y")), O->GetNumberField(TEXT("w")), O->GetNumberField(TEXT("h")) }); } OutVisible = Rects.Num(); for (int32 I = 0; I < Rects.Num(); ++I) { const FUiScnRect& A = Rects[I]; if (A.X < -0.5 || A.Y < -0.5 || A.X + A.W > VW + 0.5 || A.Y + A.H > VH + 0.5) { OutWhy = FString::Printf(TEXT("%s (%.0f,%.0f %.0fx%.0f) leaves the %.0fx%.0f viewport"), *A.Name, A.X, A.Y, A.W, A.H, VW, VH); return false; } for (int32 J = I + 1; J < Rects.Num(); ++J) { const FUiScnRect& B = Rects[J]; if (A.X < B.X + B.W && B.X < A.X + A.W && A.Y < B.Y + B.H && B.Y < A.Y + A.H) { OutWhy = FString::Printf(TEXT("%s and %s intersect"), *A.Name, *B.Name); return false; } } } return true; } void UiScnExpectCleanLayout(FDescentScenarioBuilder& S, const FString& Label) { S.Do(FString::Printf(TEXT("layout is clean: %s"), *Label), [Label](FDescentScenarioContext& C) { FString Why; int32 N = 0; if (!UiScnLayoutIsClean(C.Snapshot(), Why, N)) { C.Fail(FString::Printf(TEXT("%s: %s"), *Label, *Why)); } else { UE_LOG(LogDescent, Display, TEXT("UI layout %s: %d visible regions, none intersect, all inside the viewport"), *Label, N); } }); } void UiScnDemo(FDescentScenarioBuilder& S, bool bOn) { S.Exec(bOn ? TEXT("Descent.UI.Demo 1") : TEXT("Descent.UI.Demo 0")); } void UiScnHudMode(FDescentScenarioBuilder& S, Descent::Ui::EHudDemoMode Mode) { S.Do(TEXT("demo HUD mode"), [Mode](FDescentScenarioContext& C) { if (UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World)) { Descent::Ui::FillDemoHud(Ui->DemoHudModel(), Mode); } else { C.Fail(TEXT("no UI subsystem")); } }); } } DESCENT_SCENARIO(ui_gallery, "Every neumorphic kit widget in every state on one page (shot: page).") { S.TimeLimit(120.f); TSharedRef H = MakeShared(); S.Do(TEXT("show the gallery"), [H](FDescentScenarioContext& C) { UiScnAdd(*H, Neu::MakeGallery(), 50); if (!H->Layer.IsValid()) { C.Fail(TEXT("no game viewport to put the gallery into")); } }); UiScnShot(S, TEXT("page")); S.Do(TEXT("remove the gallery"), [H](FDescentScenarioContext&) { UiScnRemove(*H); }); S.Quit(); } DESCENT_SCENARIO(ui_hud_layout, "The HUD with demo data at 1280x720, 1000x560 and 800x450 (shots hud_*); ui.widgets must be clean at each.") { S.TimeLimit(180.f); TSharedRef Size = MakeShared(1280, 720); UiScnDemo(S, true); struct FCase { int32 W, H; const TCHAR* Label; }; const FCase Cases[] = { { 1280, 720, TEXT("hud_1280x720") }, { 1000, 560, TEXT("hud_1000x560") }, { 800, 450, TEXT("hud_800x450") } }; for (const FCase& K : Cases) { UiScnSetRes(S, K.W, K.H, Size); UiScnHudMode(S, Descent::Ui::EHudDemoMode::Foot); S.WaitFrames(8); UiScnExpectCleanLayout(S, K.Label); UiScnShot(S, K.Label, Size); } // The vehicle variants at the smallest size, where space is tightest. UiScnHudMode(S, Descent::Ui::EHudDemoMode::Sub); S.WaitFrames(8); UiScnExpectCleanLayout(S, TEXT("sub 800x450")); UiScnShot(S, TEXT("hud_800x450_sub"), Size); UiScnHudMode(S, Descent::Ui::EHudDemoMode::HelmChase); S.WaitFrames(8); UiScnExpectCleanLayout(S, TEXT("helm 800x450")); UiScnShot(S, TEXT("hud_800x450_helm"), Size); S.DumpState(TEXT("end")); UiScnDemo(S, false); S.Quit(); } DESCENT_SCENARIO(ui_windows, "One shot per window and tab at 1280x720 (win_*), then eight windows at 800x450 (small_*); ui.window.fits_viewport must hold.") { S.TimeLimit(300.f); TSharedRef Size = MakeShared(1280, 720); UiScnDemo(S, true); struct FShot { const TCHAR* Open; const TCHAR* Tab; const TCHAR* Label; }; const FShot Shots[] = { { TEXT("pda"), TEXT("inventory"), TEXT("win_pda_inventory") }, { TEXT("pda"), TEXT("map"), TEXT("win_pda_map") }, { TEXT("pda"), TEXT("logs"), TEXT("win_pda_logs") }, { TEXT("pda"), TEXT("status"), TEXT("win_pda_status") }, { TEXT("fabricator"), TEXT("submersible"), TEXT("win_fabricator_submersible") }, { TEXT("fabricator"), TEXT("mining"), TEXT("win_fabricator_mining") }, { TEXT("fabricator"), TEXT("carrier"), TEXT("win_fabricator_carrier") }, { TEXT("fabricator"), TEXT("explore"), TEXT("win_fabricator_explore") }, { TEXT("fabricator"), TEXT("build"), TEXT("win_fabricator_build") }, { TEXT("fabricator"), TEXT("weapons"), TEXT("win_fabricator_weapons") }, { TEXT("refinery"), TEXT(""), TEXT("win_refinery") }, { TEXT("packer"), TEXT(""), TEXT("win_packer") }, { TEXT("storage"), TEXT(""), TEXT("win_storage") }, { TEXT("terminal"), TEXT(""), TEXT("win_terminal") }, { TEXT("vat"), TEXT(""), TEXT("win_vat") }, { TEXT("build"), TEXT(""), TEXT("win_build") }, { TEXT("pause"), TEXT(""), TEXT("win_pause") }, { TEXT("title"), TEXT(""), TEXT("win_title") }, { TEXT("end"), TEXT(""), TEXT("win_end") }, }; auto OpenAndShoot = [&](const FShot& K) { S.Exec(FString::Printf(TEXT("Descent.UI.Open %s"), K.Open)); if (FCString::Strlen(K.Tab) > 0) { S.Exec(FString::Printf(TEXT("Descent.UI.Tab %s"), K.Tab)); } S.WaitFrames(4); S.Expect(FString::Printf(TEXT("%s fits the viewport"), K.Label), [](FDescentScenarioContext& C) { const TSharedPtr W = C.Snapshot()->GetObjectField(TEXT("ui"))->GetObjectField(TEXT("window")); return W->GetBoolField(TEXT("open")) && W->GetBoolField(TEXT("fits_viewport")); }); UiScnShot(S, K.Label, Size); S.Exec(TEXT("Descent.UI.Close")); }; for (const FShot& K : Shots) { OpenAndShoot(K); } UiScnSetRes(S, 800, 450, Size); const FShot Small[] = { { TEXT("pda"), TEXT("inventory"), TEXT("small_pda_inventory") }, { TEXT("pda"), TEXT("map"), TEXT("small_pda_map") }, { TEXT("fabricator"), TEXT("submersible"), TEXT("small_fabricator") }, { TEXT("refinery"), TEXT(""), TEXT("small_refinery") }, { TEXT("packer"), TEXT(""), TEXT("small_packer") }, { TEXT("terminal"), TEXT(""), TEXT("small_terminal") }, { TEXT("pause"), TEXT(""), TEXT("small_pause") }, { TEXT("title"), TEXT(""), TEXT("small_title") }, }; for (const FShot& K : Small) { OpenAndShoot(K); } // The Bloom slider enabled: bound to a variable this scenario registers (the real descent.Look.Bloom belongs to the look track). UiScnSetRes(S, 1280, 720, Size); S.Do(TEXT("bind the Bloom slider to a scenario variable"), [](FDescentScenarioContext& C) { if (!IConsoleManager::Get().FindConsoleVariable(TEXT("descent.UI.ScnBloom"))) { IConsoleManager::Get().RegisterConsoleVariable(TEXT("descent.UI.ScnBloom"), 0.4f, TEXT("ui_windows scenario: stands in for descent.Look.Bloom"), ECVF_Default); } if (UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World)) { Ui->SetBloomVariableName(TEXT("descent.UI.ScnBloom")); } }); S.Exec(TEXT("Descent.UI.Open pause")); S.WaitFrames(4); S.Expect(TEXT("the pause window sees the variable and the sim is paused"), [](FDescentScenarioContext& C) { const TSharedPtr W = C.Snapshot()->GetObjectField(TEXT("ui"))->GetObjectField(TEXT("window")); return W->GetBoolField(TEXT("bloom_available")) && W->GetBoolField(TEXT("paused")) && C.Sim()->IsPaused(); }); UiScnShot(S, TEXT("win_pause_bloom"), Size); S.Do(TEXT("move the slider"), [](FDescentScenarioContext& C) { if (UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World)) { Ui->SetBloomValue(0.8f); } const IConsoleVariable* V = IConsoleManager::Get().FindConsoleVariable(TEXT("descent.UI.ScnBloom")); if (!V || !FMath::IsNearlyEqual(V->GetFloat(), 0.8f, 0.001f)) { C.Fail(TEXT("the slider did not write the console variable")); } }); S.Exec(TEXT("Descent.UI.Close")); S.Expect(TEXT("closing the pause window resumes the sim"), [](FDescentScenarioContext& C) { return !C.Sim()->IsPaused(); }); S.DumpState(TEXT("end")); UiScnDemo(S, false); S.Quit(); } namespace { /** Everything the ui_dash scenario shares between its steps. */ struct FUiDashScn { FUiScnHolder Holder; TSharedRef Dash = MakeShared(); TSharedRef Cargo = MakeShared(); TWeakObjectPtr MainPanel; TWeakObjectPtr CargoPanel; int32 RedrawsAtMark = 0; }; /** Copies the subsystem's models into the shared ones the on-screen widgets read, and tells the state dump where they sit. */ void UiDashSync(FDescentScenarioContext& C, FUiDashScn& K) { if (UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World)) { *K.Dash = Ui->Models().Dash; *K.Cargo = Ui->Models().Cargo; } } void UiDashSetState(FDescentScenarioBuilder& S, TSharedRef K, const TCHAR* Label, TFunction Edit) { S.Do(FString::Printf(TEXT("dash state %s"), Label), [K, Edit](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); if (!Ui) { C.Fail(TEXT("no UI subsystem")); return; } Edit(Ui->Models()); UiDashSync(C, *K); if (UDescentUiSubsystem* U = UDescentUiSubsystem::Get(C.World)) { U->NotifyChanged(); } }); } } DESCENT_SCENARIO(ui_dash, "Both dashboards on screen (dash_*) and on panels in an otherwise dark world, lit only by themselves (world_panels).") { S.TimeLimit(240.f); TSharedRef K = MakeShared(); TSharedRef Size = MakeShared(1280, 720); UiScnDemo(S, true); S.Do(TEXT("hide the HUD behind the dashboards"), [K](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); if (!Ui) { C.Fail(TEXT("no UI subsystem")); return; } Ui->DemoHudModel().Mode = TEXT("Title"); UiDashSync(C, *K); // Both widgets on one backdrop: the main dash at 800 x 328 (scaled from 1024 x 420), the cargo display at 360 x 360 (from 512 x 512). TSharedRef Page = SNew(SOverlay) + SOverlay::Slot()[ SNew(SImage).Image(FCoreStyle::Get().GetBrush("WhiteBrush")).ColorAndOpacity(Neu::Bg()) ] + SOverlay::Slot().HAlign(HAlign_Left).VAlign(VAlign_Top).Padding(FMargin(40.f, 40.f, 0.f, 0.f)) [ SNew(SBox).WidthOverride(800.f).HeightOverride(328.f)[ SNew(SScaleBox).Stretch(EStretch::ScaleToFit)[ SNew(SDescentDash).Model(K->Dash) ] ] ] + SOverlay::Slot().HAlign(HAlign_Left).VAlign(VAlign_Top).Padding(FMargin(880.f, 40.f, 0.f, 0.f)) [ SNew(SBox).WidthOverride(360.f).HeightOverride(360.f)[ SNew(SScaleBox).Stretch(EStretch::ScaleToFit)[ SNew(SCargoDisplay).Model(K->Cargo) ] ] ]; UiScnAdd(K->Holder, Page, 50); Ui->SetExtraWidget(TEXT("dash_main"), FDescentHudRect{ TEXT("dash_main"), 40.f, 40.f, 800.f, 328.f, true }); Ui->SetExtraWidget(TEXT("cargo_display"), FDescentHudRect{ TEXT("cargo_display"), 880.f, 40.f, 360.f, 360.f, true }); }); S.WaitFrames(6); UiScnExpectCleanLayout(S, TEXT("dashboards on screen")); UiScnShot(S, TEXT("dash_screen"), Size); // A hold that is full, a depth below the rating, hull and power in the red. UiDashSetState(S, K, TEXT("full and crushing"), [](FDescentUiModels& M) { M.Dash.DepthM = 2410.0; M.Dash.HullPct = 24.f; M.Dash.PowerPct = 15.f; M.Dash.HoldUsed = 8.f; M.Dash.ToolText = TEXT("SUCTION"); M.Cargo.Order = { TEXT("ferrite"), TEXT("ferrite"), TEXT("cobalt"), TEXT("ferrite"), TEXT("pearl"), TEXT("cobalt"), TEXT("cobalt"), TEXT("pearl") }; }); S.Expect(TEXT("ui.cargo_display reports a full hold of 8 cells in arrival order"), [](FDescentScenarioContext& C) { const TSharedPtr D = C.Snapshot()->GetObjectField(TEXT("ui"))->GetObjectField(TEXT("cargo_display")); const TArray>& Cells = D->GetArrayField(TEXT("cells")); return D->GetBoolField(TEXT("full")) && Cells.Num() == 8 && Cells[0]->AsObject()->GetStringField(TEXT("ore")) == TEXT("ferrite") && Cells[4]->AsObject()->GetStringField(TEXT("ore")) == TEXT("pearl") && D->GetObjectField(TEXT("counts"))->GetNumberField(TEXT("cobalt")) == 3; }); S.WaitFrames(6); UiScnShot(S, TEXT("dash_screen_full"), Size); UiDashSetState(S, K, TEXT("empty hold"), [](FDescentUiModels& M) { M.Dash = FTenderDashModel(); M.Dash.DepthM = 1790.0; M.Cargo.Order.Reset(); M.Cargo.BioFu = 0.f; }); S.WaitFrames(6); UiScnShot(S, TEXT("dash_screen_empty"), Size); S.Do(TEXT("take the screen widgets out"), [K](FDescentScenarioContext& C) { UiScnRemove(K->Holder); if (UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World)) { Ui->ClearExtraWidgets(); } }); // The same widgets on UWidgetComponent panels in the world. Nothing else is lit; the quads are unlit and opaque. UiDashSetState(S, K, TEXT("world panels"), [](FDescentUiModels& M) { M.Dash = FTenderDashModel(); M.Dash.DepthM = 2061.0; M.Dash.HullPct = 82.f; M.Dash.PowerPct = 64.f; M.Dash.TempC = 5.1; M.Dash.SpeedMps = 1.8f; M.Dash.HoldUsed = 5.f; M.Dash.BioFu = 1.2f; M.Cargo.Order = { TEXT("ferrite"), TEXT("ferrite"), TEXT("cobalt"), TEXT("ferrite"), TEXT("pearl") }; M.Cargo.BioFu = 1.2f; }); S.Do(TEXT("spawn the panels"), [K](FDescentScenarioContext& C) { const FQuat Face(FVector::UpVector, PI); // the quad faces +X; turn it to face the camera at the origin K->MainPanel = ADescentDashPanel::Spawn(C.World, FTransform(Face, FVector(200.f, -30.f, 5.f)), EDescentDashKind::Main, 1.10f); K->CargoPanel = ADescentDashPanel::Spawn(C.World, FTransform(Face, FVector(200.f, 60.f, 5.f)), EDescentDashKind::Cargo, 0.30f); if (!K->MainPanel.IsValid() || !K->CargoPanel.IsValid()) { C.Fail(TEXT("could not spawn the dashboard panels")); return; } const UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); K->MainPanel->Dash->SetDashModel(Ui->Models().Dash); K->CargoPanel->Dash->SetCargoModel(Ui->Models().Cargo); K->RedrawsAtMark = K->MainPanel->Dash->RedrawCount(); }); S.LookAt(FVector(0.f, 0.f, 0.05f), FVector(2.f, 0.f, 0.05f), 50.f); S.WaitFrames(30); S.Expect(TEXT("the world panel redraws at about 8 Hz, not every frame"), [K](FDescentScenarioContext&) { const int32 N = K->MainPanel->Dash->RedrawCount() - K->RedrawsAtMark; UE_LOG(LogDescent, Display, TEXT("dash redraws during 30 frames: %d"), N); return N >= 3 && N <= 14; }); S.Shot(TEXT("world_panels")); S.Do(TEXT("clean up"), [K](FDescentScenarioContext& C) { if (K->MainPanel.IsValid()) { K->MainPanel->Destroy(); } if (K->CargoPanel.IsValid()) { K->CargoPanel->Destroy(); } (void)C; }); UiScnDemo(S, false); S.Quit(); }