#include "UI/DescentDash.h" #include "Core/Tuning/DescentTune_UI.h" #include "UI/DescentHudModel.h" #include "UI/NeuIcons.h" #include "UI/NeuWidgets.h" #include "Framework/Application/SlateApplication.h" namespace UiTune = Descent::Tune::UI; namespace { /** "BT-01 TENDER · DRILL": U+00B7 is a middle dot. */ const TCHAR* DashDot() { return TEXT(" · "); } float DashPulse(double Time) { return 0.55f + 0.45f * FMath::Sin(static_cast(Time) * 7.f); } } // --------------------------------------------------------------------------------------------------------- // SDescentDash // --------------------------------------------------------------------------------------------------------- void SDescentDash::Construct(const FArguments& InArgs) { Model = InArgs._Model.IsValid() ? InArgs._Model : MakeShared(); SetCanTick(false); } FVector2D SDescentDash::ComputeDesiredSize(float) const { return FVector2D(UiTune::DashDrawWidthPx, UiTune::DashDrawHeightPx); } SDescentDash::FReadout SDescentDash::MakeReadout(const FTenderDashModel& M) { FReadout R; R.TitleText = M.Title + (M.ToolText.IsEmpty() ? FString() : FString(DashDot()) + M.ToolText); R.DepthText = FDescentHudModel::FormatDepth(M.DepthM); R.bCrush = M.IsCrushing(); const FString Rating = FDescentHudModel::FormatThousands(M.RatingM) + TEXT(" m"); R.RatingText = R.bCrush ? FString(TEXT("!! CRUSH DEPTH ")) + Rating : FString(TEXT("RATING ")) + Rating; R.Line1 = FString::Printf(TEXT("TEMP %.1f °C SPD %.1f m/s"), M.TempC, M.SpeedMps); R.Line2 = FString::Printf(TEXT("HOLD %d/%d%sBIO %.1f/%.1f"), FMath::RoundToInt(M.HoldUsed), FMath::RoundToInt(M.HoldCap), DashDot(), M.BioFu, M.BioCapFu); R.bHoldFull = M.IsHoldFull(); const double Span = UiTune::DashDepthBarMaxM - UiTune::DashDepthBarMinM; R.DepthBar = static_cast(FMath::Clamp((M.DepthM - UiTune::DashDepthBarMinM) / Span, 0.0, 1.0)); R.RatingTick = static_cast(FMath::Clamp((M.RatingM - UiTune::DashDepthBarMinM) / Span, 0.0, 1.0)); R.Hull01 = FMath::Clamp(M.HullPct / 100.f, 0.f, 1.f); R.Power01 = FMath::Clamp(M.PowerPct / 100.f, 0.f, 1.f); R.bHullRed = M.HullPct < UiTune::DashHullRedPct; R.bPowerRed = M.PowerPct < UiTune::DashPowerRedPct; return R; } int32 SDescentDash::OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect&, FSlateWindowElementList& Out, int32 LayerId, const FWidgetStyle& Style, bool) const { FNeuPainter P(Out, Geo, LayerId, Style.GetColorAndOpacityTint().A); const FVector2f S = Geo.GetLocalSize(); const FReadout R = MakeReadout(*Model); const float Pulse = DashPulse(Args.GetCurrentTime()); // Opaque backdrop (the quad in the world is opaque and unlit) with the dashboard as one raised panel on it. P.RoundRect(FVector2f::ZeroVector, S, 0.f, Neu::Bg()); P.Extruded(FVector2f(16.f, 12.f), S - FVector2f(32.f, 24.f), 30.f); P.TextBox(R.TitleText, FVector2f(S.X * 0.5f - 320.f, 24.f), FVector2f(640.f, 32.f), ENeuFont::Bold, 22.f, Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center, 14.f); // The two arcs: hull on the left (cyan, red under 30), power on the right (amber, red under 20). Neu::FGaugeStyle G; G.Diameter = 300.f; G.Thickness = 14.f; G.ArcRadius = 130.f; G.ValueTextPx = 58.f; G.bGlow = true; const FVector2f HullC(210.f, 232.f); const FVector2f PowerC(814.f, 232.f); Neu::PaintGauge(P, HullC, G, R.Hull01, FString::Printf(TEXT("%d%%"), FMath::RoundToInt(R.Hull01 * 100.f)), FString(), R.bHullRed ? Neu::Red() : Neu::Cyan()); Neu::PaintGauge(P, PowerC, G, R.Power01, FString::Printf(TEXT("%d%%"), FMath::RoundToInt(R.Power01 * 100.f)), FString(), R.bPowerRed ? Neu::Red() : Neu::Amber()); P.TextBox(TEXT("HULL"), HullC + FVector2f(-80.f, 66.f), FVector2f(160.f, 30.f), ENeuFont::Bold, 22.f, Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center); P.TextBox(TEXT("POWER"), PowerC + FVector2f(-80.f, 66.f), FVector2f(160.f, 30.f), ENeuFont::Bold, 22.f, Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center); // Depth in the middle: 86 px bold, then the rating line, which turns into the crush warning. P.TextBox(R.DepthText, FVector2f(332.f, 62.f), FVector2f(360.f, 104.f), ENeuFont::Bold, 86.f, R.bCrush ? Neu::Red() : Neu::Txt(), ENeuAlign::Center, ENeuVAlign::Center, 54.f); P.TextBox(R.RatingText, FVector2f(312.f, 166.f), FVector2f(400.f, 30.f), ENeuFont::Bold, 22.f, R.bCrush ? Neu::WithAlpha(Neu::Red(), 0.55f + 0.45f * Pulse) : Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center, 14.f); // Depth bar, 1,740..3,400 m, with the red tick at the rating. const FVector2f BarPos(362.f, 208.f); const FVector2f BarSize(300.f, 14.f); Neu::PaintTrack(P, BarPos, BarSize, R.DepthBar, R.bCrush ? Neu::Red() : Neu::Cyan()); const float TickX = BarPos.X + 7.f + R.RatingTick * (BarSize.X - 14.f); P.Line(FVector2f(TickX, BarPos.Y - 6.f), FVector2f(TickX, BarPos.Y + BarSize.Y + 6.f), 3.f, Neu::Red()); P.TextBox(R.Line1, FVector2f(312.f, 246.f), FVector2f(400.f, 32.f), ENeuFont::Bold, 24.f, Neu::Txt(), ENeuAlign::Center, ENeuVAlign::Center, 16.f); P.TextBox(R.Line2, FVector2f(312.f, 288.f), FVector2f(400.f, 32.f), ENeuFont::Bold, 24.f, R.bHoldFull ? Neu::WithAlpha(Neu::Red(), 0.6f + 0.4f * Pulse) : Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center, 16.f); return P.Layer + 1; } // --------------------------------------------------------------------------------------------------------- // SCargoDisplay // --------------------------------------------------------------------------------------------------------- void SCargoDisplay::Construct(const FArguments& InArgs) { Model = InArgs._Model.IsValid() ? InArgs._Model : MakeShared(); SetCanTick(false); } FVector2D SCargoDisplay::ComputeDesiredSize(float) const { return FVector2D(UiTune::CargoDrawSizePx, UiTune::CargoDrawSizePx); } bool SCargoDisplay::IsAnimating(double NowSeconds) const { return (NowSeconds - DropStart) < UiTune::CargoDropSec + 0.05 || (Model.IsValid() && Model->IsFull()); } SCargoDisplay::FCellLayout SCargoDisplay::LayoutCells(int32 Cap, const FVector2f& TankSize) { FCellLayout L; const int32 Cols = UiTune::CargoColumns; L.Rows = FMath::Max(1, FMath::DivideAndRoundUp(FMath::Max(1, Cap), Cols)); const float Pad = 16.f; const float Gap = 8.f; L.Cell = FMath::Min3(84.f, (TankSize.X - 2.f * Pad - (Cols - 1) * Gap) / Cols, (TankSize.Y - 2.f * Pad - (L.Rows - 1) * Gap) / L.Rows); const float GridW = Cols * L.Cell + (Cols - 1) * Gap; const float GridH = L.Rows * L.Cell + (L.Rows - 1) * Gap; L.Origin = FVector2f((TankSize.X - GridW) * 0.5f, TankSize.Y - Pad - GridH); return L; } FVector2f SCargoDisplay::CellPosition(const FCellLayout& L, int32 Index, const FVector2f& TankSize) { const int32 Cols = UiTune::CargoColumns; const float Gap = 8.f; const int32 Col = Index % Cols; const int32 Row = Index / Cols; // 0 = bottom row (void)TankSize; const float GridH = L.Rows * L.Cell + (L.Rows - 1) * Gap; return FVector2f(L.Origin.X + Col * (L.Cell + Gap), L.Origin.Y + GridH - (Row + 1) * L.Cell - Row * Gap); } int32 SCargoDisplay::OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect&, FSlateWindowElementList& Out, int32 LayerId, const FWidgetStyle& Style, bool) const { FNeuPainter P(Out, Geo, LayerId, Style.GetColorAndOpacityTint().A); const FVector2f S = Geo.GetLocalSize(); const double Now = Args.GetCurrentTime(); const FCargoDisplayModel& M = *Model; const int32 Used = M.Used(); const bool bFull = M.IsFull(); const float Pulse = DashPulse(Now); // A new chunk starts its drop the first time we paint a higher count. if (LastUsed >= 0 && Used > LastUsed) { DropStart = Now; DropIndex = Used - 1; } LastUsed = Used; const float DropT = FMath::Clamp(static_cast((Now - DropStart) / UiTune::CargoDropSec), 0.f, 1.f); P.RoundRect(FVector2f::ZeroVector, S, 0.f, Neu::Bg()); P.Extruded(FVector2f(14.f, 14.f), S - FVector2f(28.f, 28.f), 28.f); // Header: CARGO n/cap, or HOLD FULL in red. if (bFull) { P.TextBox(TEXT("HOLD FULL"), FVector2f(40.f, 30.f), FVector2f(330.f, 44.f), ENeuFont::Bold, 32.f, Neu::WithAlpha(Neu::Red(), 0.6f + 0.4f * Pulse), ENeuAlign::Left, ENeuVAlign::Center); } else { P.TextBox(FString::Printf(TEXT("CARGO %d/%d"), Used, M.Cap), FVector2f(40.f, 30.f), FVector2f(330.f, 44.f), ENeuFont::Bold, 32.f, Neu::Txt(), ENeuAlign::Left, ENeuVAlign::Center); } if (M.bUnloading) { Neu::PaintChip(P, FVector2f(S.X - 40.f - 130.f, 38.f), 28.f, TEXT("UNLOADING"), Neu::Amber(), true); } // The tank: a cut-away well just tall enough for its rows plus room above for a new chunk to fall through. // Its outline turns red and pulses when the hold is full. const float TankW = 340.f; const FCellLayout Probe = LayoutCells(M.Cap, FVector2f(TankW, 330.f)); const float TankH = FMath::Min(330.f, Probe.Rows * Probe.Cell + (Probe.Rows - 1) * 8.f + 32.f + 56.f); const FVector2f TankPos(40.f, 94.f); const FVector2f TankSize(TankW, TankH); P.Inset(TankPos, TankSize, 24.f); if (bFull) { P.RoundRectOutline(TankPos + FVector2f(1.f, 1.f), TankSize - FVector2f(2.f, 2.f), 23.f, 3.f, Neu::WithAlpha(Neu::Red(), 0.5f + 0.5f * Pulse)); } else { P.RoundRectOutline(TankPos + FVector2f(1.f, 1.f), TankSize - FVector2f(2.f, 2.f), 23.f, 1.5f, Neu::WithAlpha(Neu::Dim(), 0.45f)); } const FCellLayout L = LayoutCells(M.Cap, TankSize); for (int32 I = 0; I < M.Cap; ++I) { FVector2f Pos = TankPos + CellPosition(L, I, TankSize); const FVector2f CS(L.Cell, L.Cell); if (!M.Order.IsValidIndex(I)) { P.Inset(Pos, CS, 14.f, true); continue; } if (I == DropIndex && DropT < 1.f) { // Falls from the top of the tank, accelerating. Pos.Y -= (1.f - DropT * DropT) * (Pos.Y - TankPos.Y + 10.f); } const FName Ore = M.Order[I]; const FLinearColor C = Neu::OreColor(Ore); P.Inset(Pos, CS, 14.f, true); const FVector2f Ctr = Pos + CS * 0.5f; const float Rd = L.Cell * 0.36f; const float Jit[6] = { 1.0f, 0.88f, 1.0f, 0.92f, 1.0f, 0.86f }; TArray Hex; for (int32 K = 0; K < 6; ++K) { const float A = FMath::DegreesToRadians(-90.f + 60.f * K + 8.f * ((I + K) % 3 - 1)); Hex.Add(Ctr + FVector2f(FMath::Cos(A), FMath::Sin(A)) * Rd * Jit[(K + I) % 6]); } P.Polygon(Hex, C); P.Polygon({ Hex[0], Hex[1], Ctr }, FLinearColor(FMath::Min(1.f, C.R * 1.2f), FMath::Min(1.f, C.G * 1.2f), FMath::Min(1.f, C.B * 1.2f), 1.f)); P.Polygon({ Hex[1], Hex[2], Hex[3], Ctr }, FLinearColor(C.R * 0.78f, C.G * 0.78f, C.B * 0.78f, 1.f)); P.Polygon({ Hex[3], Hex[4], Hex[5], Ctr }, FLinearColor(C.R * 0.62f, C.G * 0.62f, C.B * 0.62f, 1.f)); P.TextBox(Neu::OreSymbol(Ore), Pos, CS, ENeuFont::Bold, L.Cell * 0.27f, Neu::Bg(), ENeuAlign::Center, ENeuVAlign::Center); } // How full, as a bar under the tank: the answer at a glance. const float BarY = TankPos.Y + TankH + 16.f; const float Frac = M.Cap > 0 ? FMath::Clamp(static_cast(Used) / M.Cap, 0.f, 1.f) : 0.f; Neu::PaintTrack(P, FVector2f(TankPos.X, BarY), FVector2f(TankW, 30.f), Frac, bFull ? Neu::Red() : Neu::Cyan()); P.TextBox(FString::Printf(TEXT("%d %%"), FMath::RoundToInt(Frac * 100.f)), FVector2f(TankPos.X, BarY), FVector2f(TankW, 30.f), ENeuFont::Bold, 18.f, Neu::Txt(), ENeuAlign::Center, ENeuVAlign::Center); // The biotank: a vertical green bar on the right, as tall as the tank and the bar under it. const FVector2f BarPos(S.X - 40.f - 46.f, TankPos.Y); const FVector2f BarSize(46.f, TankH + 46.f); P.Inset(BarPos, BarSize, 23.f, true); const float Fill = M.BioCapFu > 0.f ? FMath::Clamp(M.BioFu / M.BioCapFu, 0.f, 1.f) : 0.f; if (Fill > 0.01f) { const float Inner = 6.f; const float H = (BarSize.Y - 2.f * Inner) * Fill; P.RoundRectGradient(BarPos + FVector2f(Inner, BarSize.Y - Inner - H), FVector2f(BarSize.X - 2.f * Inner, H), 17.f, Neu::Green(), Neu::Green() * 0.55f); } P.TextBox(TEXT("BIO"), BarPos + FVector2f(-20.f, BarSize.Y + 6.f), FVector2f(BarSize.X + 40.f, 24.f), ENeuFont::Bold, 18.f, Neu::Green(), ENeuAlign::Center, ENeuVAlign::Center); P.TextBox(FString::Printf(TEXT("%.1f/%.1f"), M.BioFu, M.BioCapFu), BarPos + FVector2f(-30.f, BarSize.Y + 30.f), FVector2f(BarSize.X + 60.f, 24.f), ENeuFont::Bold, 15.f, Neu::Dim(), ENeuAlign::Center, ENeuVAlign::Center, 11.f); // Footer: three chips with the ore counts. float X = 40.f; const TCHAR* Ores[3] = { TEXT("ferrite"), TEXT("cobalt"), TEXT("pearl") }; for (const TCHAR* O : Ores) { X += Neu::PaintChip(P, FVector2f(X, BarY + 48.f), 40.f, FString::Printf(TEXT("%s %d"), *Neu::OreSymbol(O), M.CountOf(O)), Neu::OreColor(O)) + 12.f; } return P.Layer + 1; } // --------------------------------------------------------------------------------------------------------- // UDescentDashComponent, ADescentDashPanel // --------------------------------------------------------------------------------------------------------- UDescentDashComponent::UDescentDashComponent() { PrimaryComponentTick.bCanEverTick = true; PrimaryComponentTick.bStartWithTickEnabled = true; SetWidgetSpace(EWidgetSpace::World); } void UDescentDashComponent::Setup(EDescentDashKind InKind) { Kind = InKind; DashModel = MakeShared(); CargoModel = MakeShared(); SetDrawSize(Kind == EDescentDashKind::Main ? FVector2D(UiTune::DashDrawWidthPx, UiTune::DashDrawHeightPx) : FVector2D(UiTune::CargoDrawSizePx, UiTune::CargoDrawSizePx)); SetBlendMode(EWidgetBlendMode::Opaque); SetTwoSided(false); SetManuallyRedraw(true); SetTickWhenOffscreen(true); if (Kind == EDescentDashKind::Main) { SetSlateWidget(SNew(SDescentDash).Model(DashModel)); } else { SAssignNew(CargoWidget, SCargoDisplay).Model(CargoModel); SetSlateWidget(CargoWidget); } RequestRenderUpdate(); } void UDescentDashComponent::SetWorldWidthM(float WidthM) { const float Px = static_cast(GetDrawSize().X); const float Scale = Px > 0.f ? WidthM * 100.f / Px : 1.f; SetRelativeScale3D(FVector(1.f, Scale, Scale)); } void UDescentDashComponent::SetDashModel(const FTenderDashModel& Model) { if (DashModel.IsValid()) { *DashModel = Model; } } void UDescentDashComponent::SetCargoModel(const FCargoDisplayModel& Model) { if (CargoModel.IsValid()) { const bool bGrew = Model.Used() > CargoModel->Used(); *CargoModel = Model; if (bGrew) { RequestRenderUpdate(); // do not wait for the next 8 Hz tick to start the drop } } } void UDescentDashComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); SinceRedraw += DeltaTime; bool bNeed = SinceRedraw >= 1.f / UiTune::DashRedrawHz; if (Kind == EDescentDashKind::Main && DashModel.IsValid()) { const FString Text = FDescentHudModel::FormatDepth(DashModel->DepthM); if (Text != LastDepthText) { LastDepthText = Text; bNeed = true; // the rounded depth changed: redraw at once (section 4.6) } } if (Kind == EDescentDashKind::Cargo && CargoWidget.IsValid() && FSlateApplication::IsInitialized() && CargoWidget->IsAnimating(FSlateApplication::Get().GetCurrentTime())) { bNeed = true; } if (bNeed) { RequestRenderUpdate(); SinceRedraw = 0.f; ++Redraws; } } ADescentDashPanel::ADescentDashPanel() { PrimaryActorTick.bCanEverTick = false; Dash = CreateDefaultSubobject(TEXT("Dash")); SetRootComponent(Dash); } ADescentDashPanel* ADescentDashPanel::Spawn(UWorld* World, const FTransform& Transform, EDescentDashKind Kind, float WidthM) { if (!World) { return nullptr; } ADescentDashPanel* Panel = Cast(World->SpawnActor(ADescentDashPanel::StaticClass(), &Transform)); if (Panel) { Panel->Dash->Setup(Kind); Panel->Dash->SetWorldWidthM(WidthM); } return Panel; }