#include "Vehicles/KazahTender.h" #include "Vehicles/CarrierCarousel.h" #include "Vehicles/DescentCarrier.h" #include "Vehicles/TenderFlight.h" #include "TheDescent.h" #include "DescentUnits.h" #include "Core/DescentMath.h" #include "Core/DescentSimSubsystem.h" #include "Core/Tuning/DescentTune_Carrier.h" #include "Core/Tuning/DescentTune_Tender.h" #include "Debug/DescentStateRegistry.h" #include "Player/DescentCharacter.h" #include "Player/DescentPlayerController.h" #include "Render/DescentLamps.h" #include "UI/DescentDash.h" #include "UI/DescentHudModel.h" #include "Camera/CameraComponent.h" #include "Components/BoxComponent.h" #include "Components/PointLightComponent.h" #include "Components/SpotLightComponent.h" #include "Components/StaticMeshComponent.h" #include "Engine/StaticMesh.h" #include "EngineUtils.h" #include "Engine/World.h" #include "Framework/Application/SlateApplication.h" #include "Materials/MaterialInstanceDynamic.h" // The generated table: pivots and local bounds of every part, in cm, Unreal axes. #include "Vehicles/KazahLayout.inl" namespace { namespace KTune = Descent::Tune::Tender; const TCHAR* const KazahContentDir = TEXT("/Game/Vehicles/Kazah/"); const FName NoseLensPart(TEXT("Nose_Lens")); const FName RotorPortPart(TEXT("Rotor_Port")); const FName RotorStbdPart(TEXT("Rotor_Stbd")); const FName CanopyLidPart(TEXT("Canopy_Lid")); const FName CanopyGlassPart(TEXT("Canopy_Glass")); const FName HatchLidPart(TEXT("Hatch_Lid")); const FName ToolHousingPart(TEXT("Tool_Housing")); /** Makes a dynamic instance of every slot of Comp whose material name contains Token, and hands each to Fn. */ void KazahForEachSlot(UStaticMeshComponent* Comp, const TCHAR* Token, TFunctionRef Fn) { if (!Comp) { return; } for (int32 I = 0; I < Comp->GetNumMaterials(); ++I) { const UMaterialInterface* M = Comp->GetMaterial(I); if (M && M->GetName().Contains(Token)) { if (UMaterialInstanceDynamic* MID = Comp->CreateDynamicMaterialInstance(I)) { Fn(MID); } } } } } TConstArrayView AKazahTender::GetPartDefs() { return TConstArrayView(GKazahPartDefs); } FVector AKazahTender::GetModelBoundsMinCm() { return GKazahBoundsMinCm; } FVector AKazahTender::GetModelBoundsMaxCm() { return GKazahBoundsMaxCm; } AKazahTender::AKazahTender() { PrimaryActorTick.bCanEverTick = true; PrimaryActorTick.bStartWithTickEnabled = true; Root = CreateDefaultSubobject(TEXT("Root")); SetRootComponent(Root); // One static-mesh component per part, created from the generated table so the actor can never drift out // of step with the exported model. The mesh asset is assigned in OnConstruction, not here, so the class // itself has no hard dependency on content that may not have been imported yet. for (const FKazahPartDef& Def : GetPartDefs()) { UStaticMeshComponent* C = CreateDefaultSubobject(Def.Name); C->SetupAttachment(Root); C->SetRelativeLocation(Def.PivotCm); // The meshes never collide: rock is the density field (flight component), the player meets the three boxes below. C->SetCollisionEnabled(ECollisionEnabled::NoCollision); C->SetGenerateOverlapEvents(false); C->SetMobility(EComponentMobility::Movable); Parts.Add(Def.Name, C); } // The one tool sits on the starboard side (the user's decision, BRIEF section 3): the port housing of the model is mirrored // across the centre plane without touching the .blend. if (UStaticMeshComponent* Tool = GetPart(ToolHousingPart)) { FVector P = Tool->GetRelativeLocation(); P.Y = KTune::ToolSide * FMath::Abs(P.Y); Tool->SetRelativeLocation(P); Tool->SetRelativeScale3D(FVector(1.0, -1.0, 1.0)); } Headlight = CreateDefaultSubobject(TEXT("Headlight")); Headlight->SetupAttachment(Root); // The Tender headlamp of DESIGN 7.5 (R 90 m, 14.5 / 26.4 degrees, #e6f6ff, intensity 3.2, no inverse-square falloff, exponent 3, shadows, // volumetric scattering 1.0): one function sets it up for every lamp of the game. Section 4.4: at (3.74, 0, 0), aimed 3 degrees down. Descent::Look::ConfigureSpot(Headlight, Descent::Look::ELamp::TenderHeadlamp); Headlight->SetRelativeLocation(Descent::MetresToUU(FVector(KTune::HeadlampX, 0.0, 0.0))); Headlight->SetRelativeRotation(FRotator(-KTune::HeadlampDownDeg, 0.0, 0.0)); Flight = CreateDefaultSubobject(TEXT("Flight")); // Section 4.6: the eye. In Sub the view is rigid to the hull; in Docked the camera turns (SetCockpitLook). CockpitCamera = CreateDefaultSubobject(TEXT("CockpitCamera")); CockpitCamera->SetupAttachment(Root); CockpitCamera->SetRelativeLocation(Descent::MetresToUU(FVector(KTune::EyeX, KTune::EyeY, KTune::EyeZ))); CockpitCamera->SetFieldOfView(KTune::CockpitFovDeg); CockpitCamera->bUsePawnControlRotation = false; CockpitCamera->bConstrainAspectRatio = false; // Section 4.4: a faint cyan glow at the dash. CockpitGlow = CreateDefaultSubobject(TEXT("CockpitGlow")); CockpitGlow->SetupAttachment(Root); CockpitGlow->SetRelativeLocation(Descent::MetresToUU(FVector(KTune::EyeX + KTune::DashMainDX - 0.25, 0.0, KTune::EyeZ + KTune::DashMainDZ + 0.2))); CockpitGlow->bUseInverseSquaredFalloff = false; CockpitGlow->SetLightFalloffExponent(3.f); CockpitGlow->SetAttenuationRadius(static_cast(Descent::MetresToUU(KTune::CockpitGlowRadiusM))); CockpitGlow->SetIntensity(KTune::CockpitGlowIntensity); CockpitGlow->SetLightColor(FLinearColor(0.25f, 0.9f, 1.0f)); CockpitGlow->SetCastShadows(false); CockpitGlow->SetVolumetricScatteringIntensity(0.f); DrillGlow = CreateDefaultSubobject(TEXT("DrillGlow")); DrillGlow->SetupAttachment(Root); DrillGlow->bUseInverseSquaredFalloff = false; DrillGlow->SetLightFalloffExponent(3.f); DrillGlow->SetAttenuationRadius(static_cast(Descent::MetresToUU(KTune::DrillGlowRadiusM))); DrillGlow->SetIntensity(2.0f); DrillGlow->SetLightColor(FLinearColor(1.0f, 0.8f, 0.55f)); DrillGlow->SetCastShadows(false); DrillGlow->SetVisibility(false); // Section 4.1: three boxes so the player cannot pass through a Tender (walking in the bay, swimming at sea). const FVector BoxC[3] = { FVector(0.2, 0.0, 0.2), FVector(-0.1, 1.0, -0.4), FVector(-0.1, -1.0, -0.4) }; const FVector BoxSize[3] = { FVector(6.6, 1.9, 2.0), FVector(3.0, 0.9, 0.9), FVector(3.0, 0.9, 0.9) }; for (int32 I = 0; I < 3; ++I) { UBoxComponent* B = CreateDefaultSubobject(*FString::Printf(TEXT("Collision_%d"), I)); B->SetupAttachment(Root); B->SetRelativeLocation(Descent::MetresToUU(BoxC[I])); B->InitBoxExtent(Descent::MetresToUU(BoxSize[I] * 0.5)); B->SetCollisionEnabled(ECollisionEnabled::QueryOnly); B->SetCollisionObjectType(ECC_WorldDynamic); B->SetCollisionResponseToAllChannels(ECR_Ignore); B->SetCollisionResponseToChannel(ECC_Pawn, ECR_Block); B->SetGenerateOverlapEvents(false); B->SetCanEverAffectNavigation(false); CollisionBoxes.Add(B); } // Section 4.4: the top beacon is an emissive blink, no light component. Beacon = CreateDefaultSubobject(TEXT("Beacon")); Beacon->SetupAttachment(Root); Beacon->SetRelativeLocation(Descent::MetresToUU(FVector(KTune::BeaconX, 0.0, KTune::BeaconZ))); Beacon->SetRelativeScale3D(FVector(0.09)); Beacon->SetCollisionEnabled(ECollisionEnabled::NoCollision); Beacon->SetCastShadow(false); Beacon->SetVisibility(false); } void AKazahTender::OnConstruction(const FTransform& Transform) { Super::OnConstruction(Transform); for (const FKazahPartDef& Def : GetPartDefs()) { UStaticMeshComponent* C = GetPart(Def.Name); if (!C || C->GetStaticMesh()) { continue; } const FString Path = FString(KazahContentDir) + Def.MeshAsset + TEXT(".") + Def.MeshAsset; UStaticMesh* Mesh = LoadObject(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet); if (Mesh) { C->SetStaticMesh(Mesh); } else if (!bLoggedMissingMesh) { bLoggedMissingMesh = true; UE_LOG(LogDescent, Error, TEXT("KazahTender: mesh %s is missing. Run Tools\\export_kazah.ps1 then Tools\\import_assets.ps1."), *Path); } } ApplyHeadlight(); ApplyRotorPose(); } void AKazahTender::EndPlay(const EEndPlayReason::Type Reason) { if (UDescentInteractionRegistry* Reg = UDescentInteractionRegistry::Get(GetWorld())) { Reg->Unregister(this); } Super::EndPlay(Reason); } UStaticMeshComponent* AKazahTender::GetPart(FName PartName) const { const TObjectPtr* Found = Parts.Find(PartName); return Found ? Found->Get() : nullptr; } void AKazahTender::SetHeadlight(bool bOn) { bHeadlightOn = bOn; if (Flight && FleetIndex >= 0) { Flight->bLamp = bOn && Flight->IsPowered(); bHeadlightOn = Flight->bLamp; } ApplyHeadlight(); } void AKazahTender::ApplyHeadlight() { if (Headlight) { Descent::Look::ConfigureSpot(Headlight, Descent::Look::ELamp::TenderHeadlamp, HeadlightIntensityScale); Headlight->SetVisibility(bHeadlightOn); } if (LensMID) { LensMID->SetScalarParameterValue(TEXT("EmissiveStrength"), bHeadlightOn ? KTune::LensGlowOn : KTune::LensGlowOff); } bLensOn = bHeadlightOn; } void AKazahTender::AdvanceRotors(float DeltaSeconds) { // RPM -> degrees per second is x 6 (360 / 60). const float StepDeg = RotorRPM * 6.f * DeltaSeconds; RotorTotalDeg += FMath::Abs(StepDeg); RotorAngleDeg = FMath::Fmod(RotorAngleDeg + StepDeg, 360.f); ApplyRotorPose(); } void AKazahTender::ApplyRotorPose() { // The rotors' pivots sit on their hub axes and the axes run along X, so the spin is a roll. They are // opposite-handed props, hence opposite signs. if (UStaticMeshComponent* P = GetPart(RotorPortPart)) { P->SetRelativeRotation(FRotator(0.f, 0.f, -RotorAngleDeg)); } if (UStaticMeshComponent* S = GetPart(RotorStbdPart)) { S->SetRelativeRotation(FRotator(0.f, 0.f, RotorAngleDeg)); } } void AKazahTender::ApplyLids() { // Hinges run along Y at the rear edge of each lid, the lids reach forward from them: a positive pitch lifts the front edge. // The canopy (lid and glass share one pivot) turns about the top of its rear hoop, see Tune::Tender::CanopyHingeX. const FRotator Canopy(KTune::CanopyOpenDeg * Canopy01, 0.0, 0.0); const FVector HingeCm = Descent::MetresToUU(FVector(KTune::CanopyHingeX, 0.0, KTune::CanopyHingeZ)); for (const FKazahPartDef& Def : GetPartDefs()) { const FName PartName(Def.Name); if (PartName == CanopyLidPart || PartName == CanopyGlassPart) { if (UStaticMeshComponent* C = GetPart(PartName)) { C->SetRelativeLocationAndRotation(HingeCm + Canopy.RotateVector(Def.PivotCm - HingeCm), Canopy); } } } if (UStaticMeshComponent* H = GetPart(HatchLidPart)) { H->SetRelativeRotation(FRotator(KTune::HatchOpenDeg * Hatch01, 0.0, 0.0)); } } void AKazahTender::SetCockpitLook(double YawDeg, double PitchDeg) { CockpitLook.X = FMath::Clamp(YawDeg, -KTune::DockedLookYawDeg, KTune::DockedLookYawDeg); CockpitLook.Y = FMath::Clamp(PitchDeg, -KTune::DockedLookPitchDownDeg, KTune::DockedLookPitchUpDeg); CockpitCamera->SetRelativeRotation(FRotator(CockpitLook.Y, CockpitLook.X, 0.0)); } void AKazahTender::ApplyRenderChannels() { TInlineComponentArray Prims(this); for (UPrimitiveComponent* P : Prims) { if (P->IsA()) { continue; } // In the bay the bay lamps (channel 1) light it and PP_Water spares it for a dry camera (stencil 1); at sea neither applies, // or the lamps of a room would light a Tender passing outside the hull. P->SetLightingChannels(true, bInBay, false); P->SetRenderCustomDepth(bInBay); P->SetCustomDepthStencilValue(bInBay ? Descent::Tune::Carrier::InteriorStencilValue : 0); } if (CockpitGlow) { CockpitGlow->SetLightingChannels(true, bInBay, false); } } void AKazahTender::SetInBay(bool bIn) { if (bInBay != bIn) { bInBay = bIn; ApplyRenderChannels(); } } void AKazahTender::InitFleet(int32 Index) { FleetIndex = Index; Flight->SpecIndex = Index; Flight->ResetAsNew(100.0); Flight->State = ETenderState::Cradle; RotorRPM = 0.f; // The model is a single-sided shell: from the pilot's eye every hull and canopy-frame face is a back face. The parts around the // cockpit get the two-sided twins of their materials (Tools/ue/assets_36_tender_inside.py), so the frame is there from inside. int32 Twins = 0, Slots = 0; for (const FName PartName : { FName(TEXT("Body")), FName(TEXT("Cockpit_Interior")), CanopyLidPart, CanopyGlassPart, HatchLidPart }) { UStaticMeshComponent* Part = GetPart(PartName); for (int32 I = 0; Part && I < Part->GetNumMaterials(); ++I) { const UMaterialInterface* M = Part->GetMaterial(I); if (!M) { continue; } ++Slots; const FString TwinName = M->GetName() + TEXT("_2S"); const FString TwinPath = FString(KazahContentDir) + TEXT("Materials/") + TwinName + TEXT(".") + TwinName; if (UMaterialInterface* Twin = LoadObject(nullptr, *TwinPath, nullptr, LOAD_NoWarn | LOAD_Quiet)) { Part->SetMaterial(I, Twin); ++Twins; } } } if (Twins < Slots) { UE_LOG(LogDescent, Error, TEXT("KazahTender: %d of %d two-sided material twins are missing (the cockpit shows no canopy frame). Run Tools/import_assets.ps1."), Slots - Twins, Slots); } // Paint (section 4.1): the scheme overrides the "Pearl ceramic lacquer" colour; BT-01 stays as modelled. if (Index > 0) { const FLinearColor Paint = Flight->Spec().Paint; for (const FName PartName : { FName(TEXT("Body")), HatchLidPart }) { KazahForEachSlot(GetPart(PartName), TEXT("Pearl_ceramic"), [&Paint](UMaterialInstanceDynamic* MID) { MID->SetVectorParameterValue(TEXT("BaseColor"), Paint); }); } } KazahForEachSlot(GetPart(NoseLensPart), TEXT("Headlight_crystal"), [this](UMaterialInstanceDynamic* MID) { LensMID = MID; }); if (UStaticMesh* Sphere = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"))) { Beacon->SetStaticMesh(Sphere); if (UMaterialInterface* Parent = LoadObject(nullptr, TEXT("/Game/Materials/M_DescentSurface.M_DescentSurface"))) { BeaconMID = UMaterialInstanceDynamic::Create(Parent, this); BeaconMID->SetVectorParameterValue(TEXT("BaseColor"), FLinearColor(0.02f, 0.02f, 0.02f)); BeaconMID->SetVectorParameterValue(TEXT("EmissiveColor"), FLinearColor(1.0f, 0.45f, 0.1f)); BeaconMID->SetScalarParameterValue(TEXT("EmissiveStrength"), 4.0f); Beacon->SetMaterial(0, BeaconMID); } } // Section 4.6: the dashboards in the cockpit (the UI track's widget components), fed in the Dashboards late step. if (FSlateApplication::IsInitialized() && !DashMain) { const FVector Eye(KTune::EyeX, KTune::EyeY, KTune::EyeZ); DashMain = NewObject(this, TEXT("DashMain")); DashMain->SetupAttachment(Root); DashMain->RegisterComponent(); DashMain->Setup(EDescentDashKind::Main); DashMain->SetWorldWidthM(static_cast(KTune::DashMainWidthM)); // A widget component shows its face toward its own +X: yaw 180 turns it to the pilot, the pitch leans its top away from him. DashMain->SetRelativeLocationAndRotation(Descent::MetresToUU(Eye + FVector(KTune::DashMainDX, 0.0, KTune::DashMainDZ)), FRotator(KTune::DashMainPitchBackDeg, 180.0, 0.0)); DashMain->SetCollisionEnabled(ECollisionEnabled::NoCollision); DashMain->SetCastShadow(false); DashCargo = NewObject(this, TEXT("DashCargo")); DashCargo->SetupAttachment(Root); DashCargo->RegisterComponent(); DashCargo->Setup(EDescentDashKind::Cargo); DashCargo->SetWorldWidthM(static_cast(KTune::DashCargoWidthM)); DashCargo->SetRelativeLocationAndRotation(Descent::MetresToUU(Eye + FVector(KTune::DashCargoDX, KTune::DashCargoDY, KTune::DashCargoDZ)), FRotator(KTune::DashMainPitchBackDeg, 180.0 + KTune::DashCargoYawDeg, 0.0)); DashCargo->SetCollisionEnabled(ECollisionEnabled::NoCollision); DashCargo->SetCastShadow(false); } if (UDescentInteractionRegistry* Reg = UDescentInteractionRegistry::Get(GetWorld())) { Reg->Register(this); } if (UDescentTenderFleet* Fleet = UDescentTenderFleet::Get(GetWorld())) { Fleet->Add(this); } bInBay = false; SetInBay(true); ApplyHeadlight(); } void AKazahTender::UpdateDashboards() { if (DashMain) { DashMain->SetDashModel(Flight->MakeDashModel()); } if (DashCargo) { DashCargo->SetCargoModel(Flight->MakeCargoModel()); } } void AKazahTender::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); // Cosmetics only (section 1.4): rotors, lids, beacon, lamp. if (FleetIndex >= 0) { RotorRPM = static_cast(KTune::RotorRpmPerThrust * Flight->Thrust); const bool bWantLamp = Flight->bLamp && Flight->IsOut(); if (bWantLamp != bHeadlightOn || bLensOn != bHeadlightOn) { bHeadlightOn = bWantLamp; ApplyHeadlight(); } // the glow of the drill at the hit point; white on dense basalt const bool bGlow = Flight->bDrilling && Flight->bDrillHit; DrillGlow->SetVisibility(bGlow); if (bGlow) { DrillGlow->SetWorldLocation(Descent::MetresToUU(Flight->DrillHitM) - GetActorForwardVector() * 30.0); DrillGlow->SetLightColor(Flight->bBasalt ? FLinearColor::White : FLinearColor(1.0f, 0.8f, 0.55f)); } BeaconClock += DeltaSeconds; Beacon->SetVisibility(FMath::Fmod(BeaconClock, KTune::BeaconPeriodSec) < KTune::BeaconOnSec); } const double C0 = Canopy01, H0 = Hatch01; Canopy01 = FMath::Clamp(Canopy01 + (bCanopyTarget ? 1.0 : -1.0) * DeltaSeconds / KTune::CanopyOpenSec, 0.0, 1.0); Hatch01 = FMath::Clamp(Hatch01 + (bHatchTarget ? 1.0 : -1.0) * DeltaSeconds / KTune::HatchOpenSec, 0.0, 1.0); if (C0 != Canopy01 || H0 != Hatch01) { ApplyLids(); } AdvanceRotors(DeltaSeconds); } // ---- interaction: boarding from the bay ------------------------------------------------------------------------------ FString AKazahTender::GetPrompt() const { return FString::Printf(TEXT("Board %s %s"), Flight->Spec().Id, Flight->Spec().Name); } FVector AKazahTender::GetFocusPointUU() const { return ToWorldUU(FVector(0.6, 0.0, 0.3)); } double AKazahTender::GetRangeM() const { // Only the Tender that hangs at BOARD with nothing moving can be boarded; a range of 0 keeps the others out of the focus search. const ADescentCarrier* Carrier = UDescentCarrierSubsystem::GetCarrier(GetWorld()); return (Carrier && Carrier->Carousel && Carrier->Carousel->TenderAtBoard() == this) ? KTune::BoardRangeM : 0.0; } void AKazahTender::Use(ADescentCharacter* User) { if (ADescentPlayerController* PC = User ? Cast(User->GetController()) : nullptr) { PC->BeginBoard(this); } } // ---- the fleet ----------------------------------------------------------------------------------------------------- UDescentTenderFleet* UDescentTenderFleet::Get(const UWorld* World) { return World ? World->GetSubsystem() : nullptr; } void UDescentTenderFleet::Add(AKazahTender* T) { Tenders.AddUnique(T); Tenders.Sort([](const TWeakObjectPtr& A, const TWeakObjectPtr& B) { return (A.IsValid() ? A->GetFleetIndex() : 99) < (B.IsValid() ? B->GetFleetIndex() : 99); }); } void UDescentTenderFleet::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); // Registered here, not in Initialize: a test world that never begins play has no fleet callback. if (UDescentSimSubsystem* Sim = UDescentSimSubsystem::Get(&InWorld)) { FDescentSimRegisterOptions Opt; Opt.Owner = TEXT("TenderFleet"); StepHandle = Sim->RegisterStep(EDescentSimStep::Tenders, [this](const FDescentSimFrame& F) { const UDescentSimSubsystem* S = UDescentSimSubsystem::Get(F.World); for (const TWeakObjectPtr& W : Tenders) { AKazahTender* T = W.Get(); if (!T) { continue; } switch (T->Flight->State) { case ETenderState::Cradle: T->Flight->StepDocked(F.Dt); break; case ETenderState::Piloted: { const FDescentIntent I = (S && Piloted.Get() == T) ? S->GetIntent() : FDescentIntent(); T->Flight->StepFlight(&I, F.Dt, HungerMul); break; } case ETenderState::Abandoned: T->Flight->StepFlight(nullptr, F.Dt, HungerMul); break; default: break; // launching and docking are the carousel's animations } } }, Opt); // Step 5: the tool of every Tender that is out (the intent of the pilot; nobody aboard = off). ToolHandle = Sim->RegisterStep(EDescentSimStep::TenderTool, [this](const FDescentSimFrame& F) { const UDescentSimSubsystem* S = UDescentSimSubsystem::Get(F.World); for (const TWeakObjectPtr& W : Tenders) { AKazahTender* T = W.Get(); if (!T) { continue; } if (!T->Flight->IsOut()) { T->Flight->bDrilling = T->Flight->bSucking = false; continue; } const FDescentIntent I = (S && Piloted.Get() == T) ? S->GetIntent() : FDescentIntent(); T->Flight->StepTool(Piloted.Get() == T ? &I : nullptr, F.Dt); } }, Opt); Opt.RateHz = 8.0; // section 1.4: dashboards at 8 Hz DashHandle = Sim->RegisterLateStep(EDescentLateStep::Dashboards, [this](const FDescentSimFrame&) { for (const TWeakObjectPtr& W : Tenders) { if (AKazahTender* T = W.Get()) { T->UpdateDashboards(); } } }, Opt); } } void UDescentTenderFleet::Deinitialize() { if (UDescentSimSubsystem* Sim = UDescentSimSubsystem::Get(GetWorld())) { Sim->Unregister(StepHandle); Sim->Unregister(DashHandle); Sim->Unregister(ToolHandle); } Super::Deinitialize(); } // --------------------------------------------------------------------------------------------------------- // State-dump section "tenders" (section 9.4): `items` lists the fleet first (BT-01, BT-02, BT-03), then any other Tender in the world. // --------------------------------------------------------------------------------------------------------- static FDescentStateProviderRegistrar GTenderStateSection(TEXT("tenders"), [](UWorld* World, FJsonObject& Out) { using namespace Descent::Json; TArray> List; TArray Ordered; if (World) { if (const UDescentTenderFleet* Fleet = UDescentTenderFleet::Get(World)) { for (const TWeakObjectPtr& W : Fleet->All()) { if (W.IsValid()) { Ordered.Add(W.Get()); } } } for (TActorIterator It(World); It; ++It) { Ordered.AddUnique(*It); } } for (const AKazahTender* T : Ordered) { TSharedRef E = MakeShared(); const UTenderFlightComponent* F = T->Flight; const bool bFleet = T->GetFleetIndex() >= 0; const FRotator R = T->GetActorRotation(); const FVector P = Descent::UUToMetres(T->GetActorLocation()); E->SetStringField(TEXT("actor"), T->GetName()); E->SetBoolField(TEXT("fleet"), bFleet); E->SetStringField(TEXT("id"), bFleet ? F->Spec().Id : TEXT("")); E->SetStringField(TEXT("name"), bFleet ? F->Spec().Name : *T->GetName()); E->SetStringField(TEXT("state"), TenderStateName(F->State)); E->SetBoolField(TEXT("docked"), bFleet && F->State == ETenderState::Cradle); E->SetField(TEXT("pos_m"), Vec3(P)); E->SetField(TEXT("location_m"), Vec3(P)); E->SetField(TEXT("rotation_deg_pyr"), Rot3(R)); E->SetNumberField(TEXT("yaw_deg"), Round(R.Yaw)); E->SetNumberField(TEXT("pitch_deg"), Round(R.Pitch)); E->SetNumberField(TEXT("speed_mps"), Round(F->VelMps.Size())); E->SetField(TEXT("vel_mps"), Vec3(F->VelMps)); const Descent::Tender::FTenderStats St = F->Stats(); E->SetNumberField(TEXT("hull"), Round(F->HullHP)); E->SetNumberField(TEXT("hull_max"), Round(St.HullMax)); E->SetNumberField(TEXT("power"), Round(F->PowerPct)); E->SetNumberField(TEXT("rating_m"), Round(St.Rating)); E->SetNumberField(TEXT("depth_m"), Round(F->DepthM())); E->SetNumberField(TEXT("contacts"), F->Contacts); E->SetNumberField(TEXT("contacts_total"), static_cast(F->ContactsTotal)); E->SetNumberField(TEXT("last_hit_mps"), Round(F->LastHitMps)); E->SetBoolField(TEXT("lamp"), T->bHeadlightOn); E->SetBoolField(TEXT("drilling"), F->bDrilling); E->SetBoolField(TEXT("sucking"), F->bSucking); E->SetBoolField(TEXT("basalt"), F->bBasalt); E->SetNumberField(TEXT("drill_removed_total"), static_cast(F->DrillRemovedTotal)); { // U10: one tool, on the starboard side; the port hardpoint is free. TSharedRef Tool = MakeShared(); Tool->SetStringField(TEXT("side"), KTune::ToolSide > 0.0 ? TEXT("starboard") : TEXT("port")); Tool->SetArrayField(TEXT("functions"), { MakeShared(TEXT("drill")), MakeShared(TEXT("suction")) }); Tool->SetStringField(TEXT("head"), F->bLaserHead ? TEXT("laser") : TEXT("drill")); E->SetObjectField(TEXT("tool"), Tool); TSharedRef Hard = MakeShared(); Hard->SetStringField(TEXT("starboard"), KTune::ToolSide > 0.0 ? TEXT("drill_suction") : TEXT("empty")); Hard->SetStringField(TEXT("port"), KTune::ToolSide > 0.0 ? TEXT("empty") : TEXT("drill_suction")); E->SetObjectField(TEXT("hardpoints"), Hard); } E->SetBoolField(TEXT("in_bay"), T->IsInBay()); E->SetBoolField(TEXT("in_honest_void"), F->bInHonestVoid); E->SetNumberField(TEXT("thrust"), Round(F->Thrust)); E->SetNumberField(TEXT("canopy_open"), Round(T->GetCanopyOpen01())); E->SetNumberField(TEXT("hatch_open"), Round(T->GetHatchOpen01())); TSharedRef Hold = MakeShared(); Hold->SetNumberField(TEXT("cap"), St.HoldCap); Hold->SetNumberField(TEXT("used"), F->HoldOrder.Num()); TArray> Order; for (const FName& O : F->HoldOrder) { Order.Add(MakeShared(O.ToString())); } Hold->SetArrayField(TEXT("order"), Order); E->SetObjectField(TEXT("hold"), Hold); E->SetNumberField(TEXT("bio_fu"), Round(F->BioFu)); E->SetNumberField(TEXT("rotor_rpm"), Round(T->RotorRPM)); E->SetNumberField(TEXT("rotor_angle_deg"), Round(T->GetRotorAngleDeg())); E->SetNumberField(TEXT("rotor_total_deg"), Round(T->GetRotorTotalDeg())); E->SetBoolField(TEXT("headlight_on"), T->bHeadlightOn); // The dash model: the same values the cockpit widgets and the HUD read (section 4.6, one source of truth). const FTenderDashModel D = F->MakeDashModel(); const SDescentDash::FReadout RO = SDescentDash::MakeReadout(D); TSharedRef Dash = MakeShared(); Dash->SetStringField(TEXT("depth_text"), RO.DepthText); Dash->SetStringField(TEXT("rating_text"), RO.RatingText); Dash->SetNumberField(TEXT("hull_pct"), Round(D.HullPct)); Dash->SetNumberField(TEXT("power_pct"), Round(D.PowerPct)); Dash->SetNumberField(TEXT("temp_c"), Round(D.TempC)); Dash->SetNumberField(TEXT("speed"), Round(D.SpeedMps)); Dash->SetStringField(TEXT("hold_text"), RO.Line2); Dash->SetBoolField(TEXT("crush"), RO.bCrush); Dash->SetBoolField(TEXT("mounted"), T->GetDashMain() != nullptr); E->SetObjectField(TEXT("dash"), Dash); // Where each part currently is, so a scenario can assert the model is assembled correctly. TSharedRef PartsJson = MakeShared(); for (const FKazahPartDef& Def : AKazahTender::GetPartDefs()) { if (const UStaticMeshComponent* C = T->GetPart(Def.Name)) { TSharedRef PJ = MakeShared(); PJ->SetBoolField(TEXT("has_mesh"), C->GetStaticMesh() != nullptr); PJ->SetField(TEXT("pivot_world_m"), Vec3(Descent::UUToMetres(C->GetComponentLocation()))); if (C->GetStaticMesh()) { const FBoxSphereBounds B = C->Bounds; PJ->SetField(TEXT("bounds_size_m"), Vec3(Descent::UUToMetres(B.BoxExtent * 2.0))); } PartsJson->SetObjectField(Def.Name, PJ); } } E->SetObjectField(TEXT("parts"), PartsJson); List.Add(MakeShared(E)); } Out.SetNumberField(TEXT("count"), List.Num()); Out.SetArrayField(TEXT("items"), List); });