// Scenarios of the player's cybernetic arm. // arm_showcase the arm alone on the debug rig's stand: every named pose from two sides, and the finger curls. // arm_jack the debug rig's first-person camera with the arm mounted: the whole jack-in against a port on a wall. // arm_character the arm on the real character: on foot in the carrier, swimming, and not there in a Tender. // The rig stands far outside the claim and far below the sun, so the background is dark; the water, fog and exposure // are ADescentEnvironment's, as everywhere. Run: Tools/run_scenario.ps1 -Name arm_showcase #include "Debug/DescentScenario.h" #include "Debug/DescentDebugSubsystem.h" #include "Core/Tuning/DescentTune_Arm.h" #include "Core/Tuning/DescentTune_Player.h" #include "DescentUnits.h" #include "Player/Arm/DescentArmDebugRig.h" #include "Player/Arm/DescentArmViewmodelComponent.h" #include "Player/DescentCharacter.h" #include "Player/DescentPlayerController.h" #include "TheDescent.h" #include "Camera/CameraComponent.h" #include "Components/StaticMeshComponent.h" #include "Engine/World.h" #include "GameFramework/PlayerController.h" namespace { namespace ArmScnTune = Descent::Tune::Arm; /** Where the rig stands, metres: 6 km east of the claim and 400 m down, where nothing is designed and the sun does not reach * (near the "abyss" place the pearl ore of the floor showed magenta behind the arm). */ const FVector GArmScnBaseM(6000.0, 900.0, -400.0); /** The shoulder of the showcase stand, metres. */ const FVector GArmScnShoulderM = GArmScnBaseM + FVector(0.0, 0.0, ArmScnTune::RigShowcaseShoulderHeightCm / 100.0); ADescentArmDebugRig* ArmScnRig(FDescentScenarioContext& C) { return Cast(C.GetNamed(TEXT("ArmRig"))); } UDescentArmViewmodelComponent* ArmScnArm(FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ArmScnRig(C); return Rig ? Rig->GetArm() : nullptr; } /** The state section `arm`, entry of the component owned by OwnerClassPrefix (what Descent.DumpState would write). */ TSharedPtr ArmScnState(FDescentScenarioContext& C, const TCHAR* OwnerPrefix) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Section = nullptr; const TArray>* Items = nullptr; if (Snap->TryGetObjectField(TEXT("arm"), Section) && Section && (*Section)->TryGetArrayField(TEXT("items"), Items) && Items) { for (const TSharedPtr& V : *Items) { const TSharedPtr O = V->AsObject(); if (O.IsValid() && O->GetStringField(TEXT("owner")).StartsWith(OwnerPrefix)) { return O; } } } return nullptr; } void ArmScnSpawnRig(FDescentScenarioBuilder& S, EDescentArmRigMode Mode) { S.Do(TEXT("spawn the arm rig"), [Mode](FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ADescentArmDebugRig::FindOrSpawn(C.World, Mode, Descent::MetresToUU(GArmScnBaseM)); if (!Rig || !Rig->GetArm()) { C.Fail(TEXT("could not spawn the arm rig")); return; } C.SetNamed(TEXT("ArmRig"), Rig); // A partial arm must not "pass": every segment needs its mesh. if (Rig->GetArm()->CountPartsWithMesh() != 20) { C.Fail(FString::Printf(TEXT("%d of 20 arm segments have a mesh: run Tools/export_arm.ps1 and Tools/import_assets.ps1"), Rig->GetArm()->CountPartsWithMesh())); } }); } /** Two pictures of the stand: from outside (right, front, above: the back of the hand) and from inside (left, front, below: the palm). */ void ArmScnTwoViews(FDescentScenarioBuilder& S, const FString& Label, const FVector& CentreRelM, double Distance, float Fov) { const FVector Centre = GArmScnShoulderM + CentreRelM; S.LookAt(Centre + FVector(0.50, 0.72, 0.42).GetSafeNormal() * Distance, Centre, Fov); S.Shot(Label + TEXT("_outer")); S.Expect(Label + TEXT("_outer shows a lit arm on a dark background"), [](FDescentScenarioContext& C) { return C.LastShotStats.NonBlack > 0.01 && C.LastShotStats.MeanLuma < 0.35 && C.LastShotStats.P99Luma > 0.25; }); S.LookAt(Centre + FVector(0.50, -0.74, -0.34).GetSafeNormal() * Distance, Centre, Fov); S.Shot(Label + TEXT("_inner")); S.Expect(Label + TEXT("_inner shows a lit arm on a dark background"), [](FDescentScenarioContext& C) { return C.LastShotStats.NonBlack > 0.01 && C.LastShotStats.MeanLuma < 0.35 && C.LastShotStats.P99Luma > 0.2; }); } /** Steps the rig's arm (which does not tick itself in arm_jack) to time T of the running jack-in. */ void ArmScnJackTo(FDescentScenarioBuilder& S, double T, const TCHAR* ExpectedPhase) { const FString Phase(ExpectedPhase); S.Do(FString::Printf(TEXT("jack-in to %.2f s (%s)"), T, ExpectedPhase), [T, Phase](FDescentScenarioContext& C) { UDescentArmViewmodelComponent* Arm = ArmScnArm(C); if (!Arm) { C.Fail(TEXT("no arm")); return; } Arm->Advance(T - Arm->GetJackTime()); const FString Got = Descent::Arm::JackPhaseName(Arm->GetJackPhase()); if (Got != Phase) { C.Fail(FString::Printf(TEXT("at %.2f s the jack-in is in phase %s, expected %s"), T, *Got, *Phase)); } }); } } DESCENT_SCENARIO(arm_showcase, "The cybernetic arm alone on a stand, lit, on a dark background: every named pose from two sides, the finger curls and a fist.") { S.TimeLimit(420.f); ArmScnSpawnRig(S, EDescentArmRigMode::Showcase); S.WaitFrames(3); // The model as the joints leave it at zero: straight ahead, palm down, fingers straight. S.Exec(TEXT("Descent.Arm.Pose zero 0")); ArmScnTwoViews(S, TEXT("zero"), FVector(0.40, 0.0, 0.0), 1.25, 40.f); // The seven named poses. Hidden hangs below the shoulder (on the camera it is below the view and not drawn at all). // Each is framed on the middle of its own shoulder, elbow and fingertips. for (int32 I = 0; I < static_cast(EDescentArmPose::Count); ++I) { const EDescentArmPose Pose = static_cast(I); const FString Name(Descent::Arm::PoseName(Pose)); const FDescentArmPose& P = Descent::Arm::NamedPose(Pose); FDescentArmTransforms W; Descent::Arm::ComputeWorld(P, W); const FVector CentreCm = (W[static_cast(EDescentArmPart::UpperArm)].GetLocation() + W[static_cast(EDescentArmPart::Forearm)].GetLocation() + Descent::Arm::EffectorPosition(P, EDescentArmEffector::Fingertip)) / 3.0; S.Exec(FString::Printf(TEXT("Descent.Arm.Pose %s 0"), *Name)); S.Expect(FString::Printf(TEXT("the state says pose %s, all 20 meshes"), *Name), [Name](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); return A.IsValid() && A->GetStringField(TEXT("pose")) == Name && A->GetNumberField(TEXT("parts_with_mesh")) == 20.0; }); ArmScnTwoViews(S, Name, CentreCm / 100.0, Pose == EDescentArmPose::Hidden ? 2.3 : 1.2, 40.f); } // Blending: half way from idle to grip the arm is between the two (a picture, and the state's blend numbers). S.Exec(TEXT("Descent.Arm.Pose idle 0")); S.Do(TEXT("stop the arm's own clock"), [](FDescentScenarioContext& C) { if (UDescentArmViewmodelComponent* Arm = ArmScnArm(C)) { Arm->bTickSelf = false; } }); S.Exec(TEXT("Descent.Arm.Pose grip 0.4")); S.Do(TEXT("advance the blend by half its time"), [](FDescentScenarioContext& C) { if (UDescentArmViewmodelComponent* Arm = ArmScnArm(C)) { Arm->Advance(0.2); } }); S.Expect(TEXT("mid-blend: weight 0.5, still blending"), [](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); const TSharedPtr* Blend = nullptr; return A.IsValid() && A->TryGetObjectField(TEXT("blend"), Blend) && (*Blend)->GetBoolField(TEXT("blending")) && FMath::IsNearlyEqual((*Blend)->GetNumberField(TEXT("weight")), 0.5, 0.001); }); ArmScnTwoViews(S, TEXT("blend_idle_grip"), FVector(0.26, -0.03, -0.09), 1.15, 40.f); S.Do(TEXT("restart the arm's own clock"), [](FDescentScenarioContext& C) { if (UDescentArmViewmodelComponent* Arm = ArmScnArm(C)) { Arm->bTickSelf = true; } }); // Finger curl, one finger at a time: a fan from open (index) to closed (pinky), then a fist. Close on the hand. S.Exec(TEXT("Descent.Arm.Pose zero 0")); S.Exec(TEXT("Descent.Arm.Curl thumb 0.5 0")); S.Exec(TEXT("Descent.Arm.Curl index 0.25 0")); S.Exec(TEXT("Descent.Arm.Curl middle 0.5 0")); S.Exec(TEXT("Descent.Arm.Curl ring 0.75 0")); S.Exec(TEXT("Descent.Arm.Curl pinky 1 0")); ArmScnTwoViews(S, TEXT("curl_fan"), FVector(0.68, 0.0, -0.02), 0.52, 40.f); // A fist: the four fingers closed, the thumb beside them (fully tucked it would be where their tips are). S.Exec(TEXT("Descent.Arm.Curl all 1 0")); S.Exec(FString::Printf(TEXT("Descent.Arm.Curl thumb %.2f 0"), ArmScnTune::FistThumbCurl)); ArmScnTwoViews(S, TEXT("fist"), FVector(0.66, 0.0, -0.02), 0.52, 40.f); // The same closed hand looked at along the forearm from in front and straight up at the palm: is any finger inside the palm, // is the thumb inside a finger? Then the thumb alone, half and fully tucked. { const FVector Hand = GArmScnShoulderM + FVector(0.65, 0.0, -0.01); auto PalmViews = [&S, Hand](const TCHAR* Label) { S.LookAt(Hand + FVector(0.42, -0.05, -0.04), Hand, 30.f); S.Shot(FString(Label) + TEXT("_front")); S.LookAt(Hand + FVector(0.04, -0.03, -0.42), Hand, 30.f); S.Shot(FString(Label) + TEXT("_palm")); }; PalmViews(TEXT("fist")); S.Exec(TEXT("Descent.Arm.Curl all 0 0")); S.Exec(TEXT("Descent.Arm.Curl thumb 0.45 0")); PalmViews(TEXT("thumb_half")); S.Exec(TEXT("Descent.Arm.Curl thumb 1 0")); PalmViews(TEXT("thumb_tucked")); } // A reach by command: the fingertip on a point 60 cm ahead, 10 cm left, 15 cm up, fingers forward, thumb up. S.Exec(TEXT("Descent.Arm.Pose plug 0")); S.Exec(TEXT("Descent.Arm.Reach 60 -10 15 1 0 0 90")); S.WaitSeconds(0.6f); S.Expect(TEXT("the commanded reach landed within the stated accuracy"), [](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); const TSharedPtr* Reach = nullptr; if (!A.IsValid() || !A->TryGetObjectField(TEXT("reach"), Reach)) { return false; } UE_LOG(LogDescent, Display, TEXT("arm_showcase reach: error %.4f cm, reachable %d, hand direction error %.2f deg, twist error %.2f deg"), (*Reach)->GetNumberField(TEXT("error_cm")), (*Reach)->GetBoolField(TEXT("reachable")) ? 1 : 0, (*Reach)->GetNumberField(TEXT("hand_dir_error_deg")), (*Reach)->GetNumberField(TEXT("twist_error_deg"))); return (*Reach)->GetBoolField(TEXT("reachable")) && (*Reach)->GetNumberField(TEXT("error_cm")) <= ArmScnTune::ReachToleranceCm && A->GetStringField(TEXT("pose")) == TEXT("custom"); }); ArmScnTwoViews(S, TEXT("reach_command"), FVector(0.34, -0.04, 0.04), 1.20, 40.f); // ...and one it cannot make: 1.5 m away. Not a failed command; the state says so. S.Exec(TEXT("Descent.Arm.Reach 150 0 0")); S.Expect(TEXT("a target 1.5 m away is reported out of range"), [](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); const TSharedPtr* Reach = nullptr; return A.IsValid() && A->TryGetObjectField(TEXT("reach"), Reach) && !(*Reach)->GetBoolField(TEXT("reachable")) && (*Reach)->GetNumberField(TEXT("error_cm")) > 60.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(arm_jack, "First person on the debug rig: the arm mounted on an eye-height camera runs the whole 4.8 s jack-in against a port on a wall; a shot at each phase.") { S.TimeLimit(420.f); ArmScnSpawnRig(S, EDescentArmRigMode::FirstPerson); S.Do(TEXT("look through the rig's eye; the scenario owns the arm's clock"), [](FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ArmScnRig(C); if (!Rig) { C.Fail(TEXT("no rig")); return; } Rig->TakeView(); Rig->GetArm()->bTickSelf = false; }); S.WaitFrames(3); S.Shot(TEXT("0_before")); // the wall and the port; the arm is hidden S.Expect(TEXT("before: the arm is hidden and mounted on the camera"), [](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); return A.IsValid() && A->GetStringField(TEXT("pose")) == TEXT("hidden") && A->GetBoolField(TEXT("mounted_on_camera")) && A->GetBoolField(TEXT("first_person_rendering")); }); S.Exec(TEXT("Descent.Arm.Jack")); ArmScnJackTo(S, 0.29, TEXT("raise")); S.Shot(TEXT("1_raise")); ArmScnJackTo(S, 0.55, TEXT("reach")); S.Shot(TEXT("2_reach")); ArmScnJackTo(S, 0.86, TEXT("plug")); S.Shot(TEXT("3_plug")); ArmScnJackTo(S, 2.50, TEXT("feed")); S.Shot(TEXT("4_feed")); S.Expect(TEXT("feeding: the fingertip is on the socket within the stated accuracy, along the port's axis"), [](FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ArmScnRig(C); UDescentArmViewmodelComponent* Arm = ArmScnArm(C); if (!Rig || !Arm) { return false; } const double WorldErrCm = (Arm->EffectorWorld(EDescentArmEffector::Fingertip) - Rig->GetPortWorldCm()).Size(); const FDescentArmJackFrame& F = Arm->GetLastJackFrame(); UE_LOG(LogDescent, Display, TEXT("arm_jack feed: fingertip %.4f cm from the socket in the world, solver error %.4g cm, hand direction error %.3f deg, port %.1f cm from the shoulder"), WorldErrCm, F.ReachErrorCm, F.HandDirErrorDeg, Arm->GetLastReachTargetArm().Size()); return Arm->IsJackFeeding() && F.bReachable && WorldErrCm <= ArmScnTune::ReachToleranceCm + 0.001 && F.HandDirErrorDeg < 1.0; }); S.DumpState(TEXT("feed")); // The same moment from the side, to see the fingers inside the port's collar (another camera: let it see the arm). S.Do(TEXT("let other cameras see the arm"), [](FDescentScenarioContext& C) { if (UDescentArmViewmodelComponent* Arm = ArmScnArm(C)) { Arm->bOnlyOwnerSeeWhenMounted = false; Arm->SetFirstPersonRendering(true); } }); { const FVector Port = GArmScnBaseM + FVector(ArmScnTune::RigWallDistanceCm / 100.0, 0.0, ArmScnTune::RigPortHeightCm / 100.0); S.LookAt(Port + FVector(-0.42, -0.62, 0.22), Port + FVector(-0.16, 0.0, -0.02), 40.f); S.Shot(TEXT("4_feed_side")); S.LookAt(Port + FVector(-0.30, 0.50, -0.42), Port + FVector(-0.14, 0.02, -0.04), 40.f); S.Shot(TEXT("4_feed_below")); } S.Do(TEXT("back to the rig's eye"), [](FDescentScenarioContext& C) { if (UDescentArmViewmodelComponent* Arm = ArmScnArm(C)) { Arm->bOnlyOwnerSeeWhenMounted = true; Arm->SetFirstPersonRendering(true); } if (ADescentArmDebugRig* Rig = ArmScnRig(C)) { Rig->TakeView(); } }); ArmScnJackTo(S, 4.28, TEXT("withdraw")); S.Shot(TEXT("5_withdraw")); ArmScnJackTo(S, 4.52, TEXT("lower")); // early in the lowering: a moment later the hand is under the picture S.Shot(TEXT("6_lower")); ArmScnJackTo(S, 4.85, TEXT("done")); S.Expect(TEXT("after 4.8 s the sequence is over and the arm is hidden again"), [](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentArmDebugRig")); const TSharedPtr* Jack = nullptr; return A.IsValid() && A->GetStringField(TEXT("pose")) == TEXT("hidden") && A->TryGetObjectField(TEXT("jack"), Jack) && !(*Jack)->GetBoolField(TEXT("active")); }); // Once more with the game's own clock (fixed 1/30 s steps): it must take 4.8 s and hold the socket through the feed. S.Do(TEXT("give the arm its own clock back and start again"), [](FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ArmScnRig(C); if (!Rig) { C.Fail(TEXT("no rig")); return; } Rig->GetArm()->bTickSelf = true; if (!Rig->StartJack()) { C.Fail(TEXT("the second jack-in did not start")); } }); { struct FLive { double StartTime = -1.0; double WorstFeedCm = 0.0; int32 FeedFrames = 0; }; TSharedRef Live = MakeShared(); S.Until(TEXT("the live jack-in runs to its end"), [Live](FDescentScenarioContext& C) { ADescentArmDebugRig* Rig = ArmScnRig(C); UDescentArmViewmodelComponent* Arm = ArmScnArm(C); if (!Rig || !Arm) { C.Fail(TEXT("no arm")); return true; } if (Live->StartTime < 0.0) { Live->StartTime = C.World->GetTimeSeconds(); } if (Arm->IsJackFeeding()) { ++Live->FeedFrames; Live->WorstFeedCm = FMath::Max(Live->WorstFeedCm, (Arm->EffectorWorld(EDescentArmEffector::Fingertip) - Rig->GetPortWorldCm()).Size()); } if (Arm->IsJackActive()) { return false; } const double Took = C.World->GetTimeSeconds() - Live->StartTime; UE_LOG(LogDescent, Display, TEXT("arm_jack live: took %.3f s of world time, %d feed frames, worst fingertip error during the feed %.4f cm"), Took, Live->FeedFrames, Live->WorstFeedCm); if (!FMath::IsNearlyEqual(Took, ArmScnTune::JackTotal, 0.08)) { C.Fail(FString::Printf(TEXT("the live jack-in took %.3f s, expected 4.8"), Took)); } if (Live->FeedFrames < 85 || Live->WorstFeedCm > ArmScnTune::ReachToleranceCm + 0.001) { C.Fail(FString::Printf(TEXT("feed: %d frames, worst error %.4f cm"), Live->FeedFrames, Live->WorstFeedCm)); } return true; }); } S.DumpState(TEXT("end")); S.Quit(); } // --------------------------------------------------------------------------------------------------------- // The arm on the real character // --------------------------------------------------------------------------------------------------------- namespace { ADescentCharacter* ArmScnCharacter(FDescentScenarioContext& C) { APlayerController* PC = C.World ? C.World->GetFirstPlayerController() : nullptr; return PC ? Cast(PC->GetPawn()) : nullptr; } FString ArmScnPlayerMode(FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); // kept alive while its parts are read const TSharedPtr* Player = nullptr; FString Mode; if (Snap->TryGetObjectField(TEXT("player"), Player) && Player) { (*Player)->TryGetStringField(TEXT("mode"), Mode); } return Mode; } /** The state of the character's arm must say this pose, settled, mounted on the camera with the first-person settings. */ void ArmScnExpectCharacterPose(FDescentScenarioBuilder& S, const TCHAR* Pose, const TCHAR* Mode) { const FString WantPose(Pose), WantMode(Mode); S.Expect(FString::Printf(TEXT("in mode %s the character's arm is %s"), Mode, Pose), [WantPose, WantMode](FDescentScenarioContext& C) { const TSharedPtr A = ArmScnState(C, TEXT("DescentCharacter")); const TSharedPtr* Blend = nullptr; if (!A.IsValid() || !A->TryGetObjectField(TEXT("blend"), Blend)) { return false; } UE_LOG(LogDescent, Display, TEXT("arm_character: mode %s, arm pose %s, blending %d, mounted %d"), *ArmScnPlayerMode(C), *A->GetStringField(TEXT("pose")), (*Blend)->GetBoolField(TEXT("blending")) ? 1 : 0, A->GetBoolField(TEXT("mounted_on_camera")) ? 1 : 0); return ArmScnPlayerMode(C) == WantMode && A->GetStringField(TEXT("pose")) == WantPose && !(*Blend)->GetBoolField(TEXT("blending")) && A->GetBoolField(TEXT("mounted_on_camera")) && A->GetBoolField(TEXT("first_person_rendering")) && A->GetNumberField(TEXT("parts_with_mesh")) == 20.0; }); } /** * How deep into the picture the arm is DRAWN (cm from the eye along the view): the farthest corner of any segment's bounds, * times the camera's first-person scale. It must stay short of whatever the body can be pressed against. */ double ArmScnDrawnDepthCm(ADescentCharacter* Ch) { if (!Ch || !Ch->Arm || !Ch->Camera) { return -1.0; } const FTransform Eye = Ch->Camera->GetComponentTransform(); double Deepest = 0.0; for (int32 I = 1; I < Descent::Arm::PartCount; ++I) { const UStaticMeshComponent* M = Ch->Arm->GetPartMesh(static_cast(I)); if (!M) { continue; } const FDescentArmPartDef& D = Descent::Arm::Def(static_cast(I)); for (int32 Corner = 0; Corner < 8; ++Corner) { const FVector Local((Corner & 1) ? D.LocalBoundsMaxCm.X : D.LocalBoundsMinCm.X, (Corner & 2) ? D.LocalBoundsMaxCm.Y : D.LocalBoundsMinCm.Y, (Corner & 4) ? D.LocalBoundsMaxCm.Z : D.LocalBoundsMinCm.Z); Deepest = FMath::Max(Deepest, Eye.InverseTransformPosition(M->GetComponentTransform().TransformPosition(Local)).X); } } const double Scale = Ch->Camera->bEnableFirstPersonScale ? Ch->Camera->FirstPersonScale : 1.0; return Deepest * Scale; } } DESCENT_SCENARIO(arm_character, "The cybernetic arm on the real character: first person on foot in the galley (idle and the other poses), pressed against a wall, swimming outside the hull, and gone in a Tender.") { S.TimeLimit(420.f); // Since 2026-10-11 the player's view is the Twinblast body and the arm is kept Hidden on the character. This scenario is about the // arm, so it takes the switch that gives the old view back (Docs/status/player_body.md). S.Exec(TEXT("Descent.Body.View arm")); // ---- on foot ---- S.Exec(TEXT("Descent.Player.Place galley")); S.Do(TEXT("view through the character"), [](FDescentScenarioContext& C) { ADescentCharacter* Ch = ArmScnCharacter(C); if (!Ch || !Ch->Arm) { C.Fail(TEXT("the player's pawn is not a character with an arm")); return; } C.World->GetFirstPlayerController()->SetViewTarget(Ch); }); S.WaitSim(1.0); // the arm comes up (0.5 s) ArmScnExpectCharacterPose(S, TEXT("idle"), TEXT("Foot")); S.Expect(TEXT("inside the hull the arm takes no light from the sea (lighting channel 0 off)"), [](FDescentScenarioContext& C) { ADescentCharacter* Ch = ArmScnCharacter(C); const UStaticMeshComponent* Hand = Ch ? Ch->Arm->GetPartMesh(EDescentArmPart::Hand) : nullptr; // fix list after M3: in dry air the arm takes only channel 2, the lamps of the room the player stands in (not channel 1, where the // shadowless lamps of the neighbouring rooms reach through the walls) return Hand && Ch->Arm->IsInDryAir() && !Hand->LightingChannels.bChannel0 && !Hand->LightingChannels.bChannel1 && Hand->LightingChannels.bChannel2; }); S.Shot(TEXT("foot_idle")); // The bridge is lit blue: the hand must take the room's colour, not the sun's. S.Exec(TEXT("Descent.Player.Place bridge")); S.WaitSim(0.3); S.Shot(TEXT("foot_idle_bridge")); S.Exec(TEXT("Descent.Player.Place galley")); S.WaitSim(0.3); S.Exec(TEXT("Descent.Player.Look 0 -25")); S.WaitFrames(3); S.Shot(TEXT("foot_idle_look_down")); S.Exec(TEXT("Descent.Player.Look 0 0")); for (const TCHAR* Pose : { TEXT("grip"), TEXT("hold_tool"), TEXT("point"), TEXT("reach") }) { S.Exec(FString::Printf(TEXT("Descent.Arm.Pose %s 0"), Pose)); S.WaitFrames(3); S.Shot(FString::Printf(TEXT("foot_%s"), Pose)); } // ---- against a wall: the plain port-side wall of the galley (carrier-local y -10), walked into until the capsule stops ---- S.Exec(TEXT("Descent.Player.PlaceLocal 22 -9.2 -3.0 -90")); S.Exec(TEXT("Descent.Player.Intent 1 0 0 0 0 1.5")); S.WaitSim(1.8); S.Expect(TEXT("pressed against the wall, the reaching arm is drawn short of it"), [](FDescentScenarioContext& C) { ADescentCharacter* Ch = ArmScnCharacter(C); if (!Ch) { return false; } // Walked into the wall until the capsule stopped: the wall's face is then one capsule radius from the eye. const double RadiusCm = Descent::MetresToUU(Descent::Tune::Player::CapsuleRadiusM); const double SpeedCmS = Ch->GetVelocity().Size(); const double FeetY = Ch->GetFeetLocalM().Y; const double DrawnCm = ArmScnDrawnDepthCm(Ch); UE_LOG(LogDescent, Display, TEXT("arm_character wall: stopped at carrier-local y %.3f (speed %.1f cm/s), wall face %.1f cm from the eye; the arm (pose reach, the longest) is drawn to %.1f cm, first-person scale %.2f"), FeetY, SpeedCmS, RadiusCm, DrawnCm, Ch->Camera->bEnableFirstPersonScale ? Ch->Camera->FirstPersonScale : 1.f); return FeetY < -9.3 && SpeedCmS < 1.0 && DrawnCm > 0.0 && DrawnCm < RadiusCm - 1.0; }); S.Shot(TEXT("foot_wall_reach")); S.Exec(TEXT("Descent.Arm.Pose idle 0")); S.WaitFrames(3); S.Shot(TEXT("foot_wall_idle")); S.DumpState(TEXT("foot")); // ---- swimming, outside the hull on the port side, looking along it with the lamp on ---- S.Exec(TEXT("Descent.Player.Swim 20 -24 -33")); S.Exec(TEXT("Descent.Player.Look 70 4")); S.Do(TEXT("lamp on"), [](FDescentScenarioContext& C) { if (ADescentCharacter* Ch = ArmScnCharacter(C)) { Ch->SetFlashlight(true); } }); S.WaitSim(1.0); ArmScnExpectCharacterPose(S, TEXT("idle"), TEXT("Swim")); S.Expect(TEXT("in the water the arm is lit by the sea too (lighting channels 0 and 1)"), [](FDescentScenarioContext& C) { ADescentCharacter* Ch = ArmScnCharacter(C); const UStaticMeshComponent* Hand = Ch ? Ch->Arm->GetPartMesh(EDescentArmPart::Hand) : nullptr; return Hand && !Ch->Arm->IsInDryAir() && Hand->LightingChannels.bChannel0 && Hand->LightingChannels.bChannel1; }); S.Shot(TEXT("swim_idle")); S.Do(TEXT("lamp off"), [](FDescentScenarioContext& C) { if (ADescentCharacter* Ch = ArmScnCharacter(C)) { Ch->SetFlashlight(false); } }); // ---- in a Tender: no arm ---- S.Exec(TEXT("Descent.Player.Place bay")); S.WaitSim(0.3); S.Exec(TEXT("Descent.Tender.Board")); S.Until(TEXT("docked in the Tender"), [](FDescentScenarioContext& C) { if (C.SecondsInStep() > 40.0) { C.Fail(FString::Printf(TEXT("still in mode %s 40 s after boarding"), *ArmScnPlayerMode(C))); return true; } return ArmScnPlayerMode(C) == TEXT("Docked"); }); S.WaitSim(1.0); ArmScnExpectCharacterPose(S, TEXT("hidden"), TEXT("Docked")); S.Expect(TEXT("no arm segment is drawn in the Tender"), [](FDescentScenarioContext& C) { ADescentCharacter* Ch = ArmScnCharacter(C); if (!Ch) { return false; } for (int32 I = 1; I < Descent::Arm::PartCount; ++I) { const UStaticMeshComponent* M = Ch->Arm->GetPartMesh(static_cast(I)); if (!M || M->IsVisible()) { return false; } } return true; }); S.Shot(TEXT("tender_docked")); S.DumpState(TEXT("end")); S.Quit(); }