// Headless tests of the player's body (Player/Body): the Paragon Twinblast mesh on the character, the guns switch, the leg material, // hidden in a Tender, the old-arm switch, and the animation state following the movement. Pictures are the scenario `player_body`. #include "Tests/DescentTestCommon.h" #include "Core/Tuning/DescentTune_Body.h" #include "Core/Tuning/DescentTune_Player.h" #include "Player/Arm/DescentArmViewmodelComponent.h" #include "Player/Body/DescentBodyAnim.h" #include "Player/Body/DescentBodyProfile.h" #include "Player/Body/DescentPlayerBodyComponent.h" #include "Player/DescentCharacter.h" #include "Player/DescentCharacterMovement.h" #include "Vehicles/KazahTender.h" #include "Animation/AnimSequence.h" #include "Components/CapsuleComponent.h" #include "Components/SkeletalMeshComponent.h" #include "Engine/SkeletalMesh.h" #include "Materials/MaterialInterface.h" #if WITH_DEV_AUTOMATION_TESTS namespace { namespace BodyTestTune = Descent::Tune::Body; /** Steps the pure state for Seconds at 30 Hz. */ void BodyTestRun(FDescentBodyAnimState& S, const FDescentBodyAnimInput& In, double Seconds, const FDescentBodyLengths& Len) { for (double T = 0.0; T < Seconds - 1e-9; T += 1.0 / 30.0) { S.Step(In, 1.0 / 30.0, Len); } } /** Weight of one sequence in the frame's clip list, and the list's sum. */ double BodyTestWeight(const FDescentBodyAnimState& S, EDescentBodySeq Seq, double* OutSum = nullptr) { FDescentBodyClipList Clips; S.BuildClips(Clips); double W = 0.0, Sum = 0.0; for (const FDescentBodyClip& C : Clips) { Sum += C.Weight; if (C.Seq == Seq) { W += C.Weight; } } if (OutSum) { *OutSum = Sum; } return W; } /** A character with its body set up, in a throw-away world (no BeginPlay there: Setup is called by hand). */ ADescentCharacter* BodyTestSpawn(FDescentTestWorld& TW) { ADescentCharacter* Ch = TW.Get() ? TW.Get()->SpawnActor(FVector(0.0, 0.0, 500.0), FRotator::ZeroRotator) : nullptr; if (Ch && Ch->Body) { Ch->Body->Setup(); } return Ch; } /** The switches are console variables: a test leaves them as it found them. */ struct FBodyTestSwitchGuard { const bool bArm = UDescentPlayerBodyComponent::IsOldArmView(); const bool bGuns = UDescentPlayerBodyComponent::AreGunsWanted(); ~FBodyTestSwitchGuard() { UDescentPlayerBodyComponent::SetOldArmView(bArm); UDescentPlayerBodyComponent::SetGunsWanted(bGuns); } }; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodyAnimStateTest, "Descent.Body.AnimState", DESCENT_TEST_FLAGS) bool FDescentBodyAnimStateTest::RunTest(const FString& Parameters) { using ESeq = EDescentBodySeq; using EMotion = EDescentBodyMotion; const FDescentBodyLengths Len; FDescentBodyAnimState S; FDescentBodyAnimInput In; double Sum = 0.0; // standing BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("standing still is Idle, the Idle clip alone"), S.Motion() == EMotion::Idle && BodyTestWeight(S, ESeq::Idle, &Sum) > 0.999 && FMath::IsNearlyEqual(Sum, 1.0, 1e-9)); // walking forward at the game's walk speed: the jog set (the pack's Jog is 3.4 m/s, its Walk 1.4 m/s), slowed to match In.VelLocalCmS = FVector2D(Descent::Tune::Player::WalkSpeedMps * 100.0, 0.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("2.9 m/s forward is Moving on Jog_Fwd"), S.Motion() == EMotion::Moving && S.Loco.Cur == ESeq::JogFwd && BodyTestWeight(S, ESeq::JogFwd, &Sum) > 0.99); TestEqual(TEXT("the play rate matches the feet to the speed (290 / 340)"), S.LocoRate, 290.0 / BodyTestTune::JogAnimSpeedCmS, 1e-6); TestEqual(TEXT("weights sum to 1"), Sum, 1.0, 1e-9); // the four directions In.VelLocalCmS = FVector2D(0.0, 290.0); BodyTestRun(S, In, 0.6, Len); TestTrue(TEXT("to the right: Jog_Right"), S.Loco.Cur == ESeq::JogRight && BodyTestWeight(S, ESeq::JogRight) > 0.99); In.VelLocalCmS = FVector2D(0.0, -290.0); BodyTestRun(S, In, 0.6, Len); TestTrue(TEXT("to the left: Jog_Left"), S.Loco.Cur == ESeq::JogLeft); In.VelLocalCmS = FVector2D(-290.0, 0.0); BodyTestRun(S, In, 0.6, Len); TestTrue(TEXT("backward: Jog_Bwd"), S.Loco.Cur == ESeq::JogBwd); // a diagonal does not flicker between two clips In.VelLocalCmS = FVector2D(205.0, 205.0); BodyTestRun(S, In, 0.6, Len); const ESeq Diagonal = S.Loco.Cur; int32 Changes = 0; for (int32 I = 0; I < 60; ++I) { In.VelLocalCmS = FVector2D(205.0 + ((I & 1) ? 6.0 : -6.0), 205.0); S.Step(In, 1.0 / 30.0, Len); Changes += S.Loco.Cur != Diagonal ? 1 : 0; } TestEqual(TEXT("a diagonal with a wobbling velocity keeps one clip"), Changes, 0); // slow: the walk set In.VelLocalCmS = FVector2D(140.0, 0.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("1.4 m/s forward: Walk_Fwd at about its own rate"), S.Loco.Cur == ESeq::WalkFwd && FMath::IsNearlyEqual(S.LocoRate, 140.0 / BodyTestTune::WalkAnimSpeedCmS, 1e-6)); // sprint: still the jog set, faster In.VelLocalCmS = FVector2D(Descent::Tune::Player::SprintSpeedMps * 100.0, 0.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("4.8 m/s: Jog_Fwd at 480 / 340"), S.Loco.Cur == ESeq::JogFwd && FMath::IsNearlyEqual(S.LocoRate, 480.0 / BodyTestTune::JogAnimSpeedCmS, 1e-6)); // a jump: start > apex > fall loop > landing > back on the ground In.VelLocalCmS = FVector2D::ZeroVector; BodyTestRun(S, In, 1.0, Len); In.bInAir = true; In.VerticalCmS = 440.0; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("leaving the ground upward starts Jump_Start"), S.Motion() == EMotion::Jumping && S.Act.Cur == ESeq::JumpStart); BodyTestRun(S, In, 0.2, Len); TestTrue(TEXT("after 0.23 s still Jump_Start, the action channel has taken over"), S.Act.Cur == ESeq::JumpStart && S.ActionAlpha > 0.9); BodyTestRun(S, In, 0.15, Len); TestTrue(TEXT("then Jump_Apex"), S.Motion() == EMotion::Jumping && S.Act.Cur == ESeq::JumpApex); In.VerticalCmS = -200.0; BodyTestRun(S, In, 0.35, Len); TestTrue(TEXT("then the fall loop"), S.Motion() == EMotion::Falling && S.Act.Cur == ESeq::FallLoop && BodyTestWeight(S, ESeq::FallLoop, &Sum) > 0.5); TestEqual(TEXT("weights sum to 1 in the air"), Sum, 1.0, 1e-9); In.bInAir = false; In.VerticalCmS = 0.0; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("touching down plays Jump_Land"), S.Motion() == EMotion::Landing && S.Act.Cur == ESeq::Land); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("and ends in Idle with the action channel empty"), S.Motion() == EMotion::Idle && S.ActionAlpha < 0.01 && BodyTestWeight(S, ESeq::Idle) > 0.99); // walking off an edge: no jump start, the fall loop after 0.12 s In.bInAir = true; In.VerticalCmS = -10.0; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("one frame off the ground is not yet a fall"), S.Motion() == EMotion::Idle); BodyTestRun(S, In, 0.2, Len); TestTrue(TEXT("0.23 s off the ground without a jump is Falling"), S.Motion() == EMotion::Falling && S.Act.Cur == ESeq::FallLoop); In.bInAir = false; BodyTestRun(S, In, 1.0, Len); // a placement drop of a few centimetres (two frames in the air) shows neither a fall nor a landing In.bInAir = true; BodyTestRun(S, In, 2.0 / 30.0, Len); In.bInAir = false; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("a two-frame drop is nothing"), S.Motion() == EMotion::Idle && S.ActionAlpha < 0.01); // swimming: the pack has no swim; the fall loop, slowed In.bSwimming = true; In.VelLocalCmS = FVector2D(200.0, 0.0); const double T0 = S.Act.CurTime; BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("swimming is the slowed fall loop, whatever the speed"), S.Motion() == EMotion::Swimming && S.Act.Cur == ESeq::FallLoop && BodyTestWeight(S, ESeq::FallLoop) > 0.99); TestEqual(TEXT("at the swim rate"), S.Act.CurTime - T0, BodyTestTune::SwimPlayRate * 1.0, 0.02); // the ladder, death, the helm: held still In.bHeld = true; BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("held (helm, ladder, death, jack-in) is the Idle clip whatever the velocity says"), S.Motion() == EMotion::Held && BodyTestWeight(S, ESeq::Idle) > 0.99); In.bRelaxed = true; BodyTestRun(S, In, 2.0, Len); TestTrue(TEXT("held at the helm (relaxed): Idle_Relaxed takes over from Idle"), S.Motion() == EMotion::Held && BodyTestWeight(S, ESeq::IdleRelaxed, &Sum) > 0.99 && FMath::IsNearlyEqual(Sum, 1.0, 1e-9)); In = FDescentBodyAnimInput(); BodyTestRun(S, In, 0.5, Len); TestTrue(TEXT("released on the ground: Idle"), S.Motion() == EMotion::Idle); // one-shots stop, loops wrap TestTrue(TEXT("Jump_Start, Jump_Apex and Jump_Land are one-shots, the rest loop"), !Descent::Body::SeqLoops(ESeq::JumpStart) && !Descent::Body::SeqLoops(ESeq::JumpApex) && !Descent::Body::SeqLoops(ESeq::Land) && Descent::Body::SeqLoops(ESeq::Idle) && Descent::Body::SeqLoops(ESeq::JogFwd) && Descent::Body::SeqLoops(ESeq::FallLoop)); return true; } namespace { /** Puts descent.Body.AnimSet back when the test ends. */ struct FBodyTestSetGuard { int32 Was = UDescentBodyAnimInstance::WantedSet(); ~FBodyTestSetGuard() { UDescentBodyAnimInstance::SetWantedSet(Was); } }; } // 2026-10-12: the retargeted animation set, its switch, what plays when, the view bob's limits, the arms-up default. IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodyAnimSetTest, "Descent.Body.AnimSetAndView", DESCENT_TEST_FLAGS) bool FDescentBodyAnimSetTest::RunTest(const FString& Parameters) { using ESeq = EDescentBodySeq; namespace BT = Descent::Tune::Body; namespace PT = Descent::Tune::Player; // ---- the switches' defaults ---- TestEqual(TEXT("descent.Body.AnimSet defaults to 1 (the retargeted set)"), BT::AnimSetDefault, 1); TestEqual(TEXT("descent.Body.ArmsUp defaults to 0: the arms hang"), UDescentPlayerBodyComponent::ArmsUp(), 0.0); TestEqual(TEXT("descent.View.Bob defaults to 1"), ADescentCharacter::ViewBobStrength(), 1.0); { FBodyTestSetGuard SetGuard; UDescentBodyAnimInstance::SetWantedSet(0); TestEqual(TEXT("the switch goes to the pack's set"), UDescentBodyAnimInstance::WantedSet(), 0); UDescentBodyAnimInstance::SetWantedSet(1); TestEqual(TEXT("and back"), UDescentBodyAnimInstance::WantedSet(), 1); } const FDescentBodyProfile& Profile = FDescentBodyProfile::Get(); TestTrue(TEXT("set 0 paths are the model's own pack's, set 1 paths are the baked role names in the profile's folder"), UDescentBodyAnimInstance::SeqAssetPath(0, ESeq::JogFwd) == Profile.LegacyPath(TEXT("JogFwd")) && UDescentBodyAnimInstance::SeqAssetPath(0, ESeq::JogFwd).StartsWith(Profile.LegacyAnimFolder) && UDescentBodyAnimInstance::SeqAssetPath(1, ESeq::JogFwd) == Profile.AnimFolder + TEXT("/A_Body_JogFwd.A_Body_JogFwd") && UDescentBodyAnimInstance::SeqAssetPath(1, ESeq::SwimFwd).EndsWith(TEXT("A_Body_SwimFwd")) && UDescentBodyAnimInstance::SeqAssetPath(1, ESeq::Touch).EndsWith(TEXT("A_Body_Touch"))); for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { const FString Path = UDescentBodyAnimInstance::SeqAssetPath(1, static_cast(I)); TestNotNull(*FString::Printf(TEXT("retargeted sequence %s exists"), *Path), LoadObject(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet)); } // ---- what plays when, retargeted set (pure state) ---- const FDescentBodyLengths Len(1), Pack(0); FDescentBodyAnimState S; FDescentBodyAnimInput In; In.VelLocalCmS = FVector2D(290.0, 0.0); BodyTestRun(S, In, 1.0, Len); const double JogSpeed = BT::JogSpeedSet1CmS; TestTrue(TEXT("2.9 m/s forward: Jog_Fwd at 290 / the clip's speed"), S.Loco.Cur == ESeq::JogFwd && FMath::IsNearlyEqual(S.LocoRate, 290.0 / JogSpeed, 1e-6)); // the view bob walking: measured over two seconds of 30 Hz frames, as the character applies it auto MeasureBob = [&](double SpeedCmS, double& OutP2P, double& OutStep) { In.VelLocalCmS = FVector2D(SpeedCmS, 0.0); BodyTestRun(S, In, 1.0, Len); double Lo = 1e9, Hi = -1e9, Prev = 0.0; OutStep = 0.0; for (int32 F = 0; F < 60; ++F) { S.Step(In, 1.0 / 30.0, Len); double Signal = 0.0, Hz = 0.0; TestTrue(TEXT("the retargeted set drives the bob"), S.ViewBob(Len, 0.0, Signal, &Hz)); const double Cm = Signal * ADescentCharacter::ViewBobAmplitudeCm(SpeedCmS / 100.0, Hz); Lo = FMath::Min(Lo, Cm); Hi = FMath::Max(Hi, Cm); if (F > 0) { OutStep = FMath::Max(OutStep, FMath::Abs(Cm - Prev)); } Prev = Cm; } OutP2P = Hi - Lo; }; double P2P = 0.0, Step = 0.0; MeasureBob(PT::WalkSpeedMps * 100.0, P2P, Step); AddInfo(FString::Printf(TEXT("view bob walking: %.2f cm peak to peak, %.2f cm largest step per frame"), P2P, Step)); TestTrue(TEXT("walking bob is between 0.5 and 1.0 cm peak to peak, no frame moves it more than 0.35 cm"), P2P >= 0.5 && P2P <= 1.0 && Step <= 0.35); MeasureBob(PT::SprintSpeedMps * 100.0, P2P, Step); AddInfo(FString::Printf(TEXT("view bob sprinting: %.2f cm peak to peak, %.2f cm largest step per frame"), P2P, Step)); TestTrue(TEXT("sprinting bob is between 0.8 and 1.5 cm peak to peak, no frame moves it more than 0.8 cm"), P2P >= 0.8 && P2P <= 1.5 && Step <= 0.8); double Signal = 1.0; FDescentBodyAnimState S0; BodyTestRun(S0, In, 1.0, Pack); TestTrue(TEXT("the pack's set has no measured footfalls: the bob falls back to distance walked"), !S0.ViewBob(Pack, 0.0, Signal) && Signal == 0.0); // backward: the forward jog played backward In.VelLocalCmS = FVector2D(-290.0, 0.0); BodyTestRun(S, In, 0.8, Len); const double Before = S.Loco.CurTime; S.Step(In, 0.1, Len); TestTrue(TEXT("backward: Jog_Bwd, the clip runs in reverse"), S.Loco.Cur == ESeq::JogBwd && Len.bReversed[static_cast(ESeq::JogBwd)] && FMath::IsNearlyEqual(FMath::Fmod(Before - S.Loco.CurTime + Len[ESeq::JogBwd], Len[ESeq::JogBwd]), 0.1 * S.LocoRate, 1e-6)); // own view: the upper body is calmer while moving, not standing, not in another view In.bOwnView = true; BodyTestRun(S, In, 0.5, Len); TestTrue(TEXT("own view, moving: the upper body gives up its share of the locomotion"), FMath::IsNearlyEqual(S.UpperCalm, BT::UpperBodyCalmOwnView, 0.01)); In.bOwnView = false; BodyTestRun(S, In, 0.1, Len); TestEqual(TEXT("another view: the whole locomotion"), S.UpperCalm, 0.0); // swimming: treading, a stroke forward, to the right In = FDescentBodyAnimInput(); In.bSwimming = true; BodyTestRun(S, In, 1.5, Len); TestTrue(TEXT("swimming still: Swim_Idle"), S.Motion() == EDescentBodyMotion::Swimming && S.Act.Cur == ESeq::SwimIdle && S.SwimAlpha > 0.99 && S.SwimMove < 0.01); In.VelLocalCmS = FVector2D(250.0, 0.0); BodyTestRun(S, In, 1.5, Len); TestTrue(TEXT("swimming forward: Swim_Fwd"), S.Act.Cur == ESeq::SwimFwd && S.SwimMove > 0.99 && BodyTestWeight(S, ESeq::SwimFwd) > 0.99); In.VelLocalCmS = FVector2D(20.0, 250.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("swimming to the right: Swim_Right"), S.Act.Cur == ESeq::SwimRight); FDescentBodyAnimState SwimPack; BodyTestRun(SwimPack, In, 1.0, Pack); TestTrue(TEXT("the pack's set still swims on the fall loop, without a shift"), SwimPack.Act.Cur == ESeq::FallLoop && SwimPack.SwimAlpha == 0.0); // the jack-in: out in 1 s, held at the clip's furthest reach, back by 4.8 s; "use" pushes a button In = FDescentBodyAnimInput(); In.bHeld = true; In.JackSec = 2.5; BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("mid-feed: Touch held at its furthest reach on the upper body"), S.OverSeq == ESeq::Touch && S.OverAlpha > 0.99 && FMath::IsNearlyEqual(S.OverTime, BT::TouchPeakSec, 1e-6)); In.JackSec = 4.79; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("at the end the arm is back at the clip's start"), S.OverTime < 0.01); In.JackSec = -1.0; BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("no jack-in: no overlay"), S.OverAlpha < 0.01); In.bHeld = false; In.UseSec = 0.3; BodyTestRun(S, In, 0.3, Len); TestTrue(TEXT("use: Push_Button at twice its speed"), S.OverSeq == ESeq::PushButton && S.OverAlpha > 0.9 && FMath::IsNearlyEqual(S.OverTime, 0.3 * BT::UsePlayRate, 1e-6)); BodyTestRun(S, In, 0.3, Pack); // ---- second task of 2026-10-12: the body under the camera ---- // the lean that brings the legs into the picture when he looks down: none ahead, all at 60 degrees, less again straight down const double Lean = BT::LookDownLeanDeg; TestEqual(TEXT("no lean looking ahead"), UDescentPlayerBodyComponent::LookDownLeanDeg(0.0, Lean), 0.0); TestEqual(TEXT("no lean at 20 degrees down"), UDescentPlayerBodyComponent::LookDownLeanDeg(BT::LeanFromDownDeg, Lean), 0.0); TestEqual(TEXT("the whole lean at 60 degrees down"), UDescentPlayerBodyComponent::LookDownLeanDeg(BT::LeanFullDownDeg, Lean), Lean, 1e-9); TestEqual(TEXT("a share of it at the pitch limit"), UDescentPlayerBodyComponent::LookDownLeanDeg(PT::PitchLimitDeg, Lean), Lean * BT::LeanAtLimitShare, 1e-9); TestTrue(TEXT("it grows from 20 to 60 degrees"), UDescentPlayerBodyComponent::LookDownLeanDeg(45.0, Lean) > UDescentPlayerBodyComponent::LookDownLeanDeg(30.0, Lean) && UDescentPlayerBodyComponent::LookDownLeanDeg(45.0, Lean) < Lean); // (that the knees and feet are in the picture at -60 and the feet in its lower half at -80 needs evaluated bones and a camera: // scenario body_view_probe asserts it, lines LOOKDOWN) // the bob did not change with it: the numbers of the first task FDescentBodyAnimState SB; In = FDescentBodyAnimInput(); S = SB; // Third task: the play rate is matched to the ground, so the walk's footfalls (and the bob with them) come 3.9 a second instead // of 2.9. The bob's SPEED is held at what it was (0.27 cm a frame walking, 0.68 sprinting); the walk's size gives way: 0.66 cm. MeasureBob(PT::WalkSpeedMps * 100.0, P2P, Step); AddInfo(FString::Printf(TEXT("view bob walking now: %.2f cm peak to peak, %.2f cm largest step per frame"), P2P, Step)); TestTrue(TEXT("walking bob: not larger than the 0.90 cm it was, not faster than 0.28 cm a frame"), P2P > 0.5 && P2P <= 0.93 && Step <= 0.28); MeasureBob(PT::SprintSpeedMps * 100.0, P2P, Step); AddInfo(FString::Printf(TEXT("view bob sprinting now: %.2f cm peak to peak, %.2f cm largest step per frame"), P2P, Step)); TestTrue(TEXT("sprinting bob: not larger than the 1.40 cm it was, not faster than 0.68 cm a frame"), P2P > 0.8 && P2P <= 1.44 && Step <= 0.69); // the jack-in's reach for the IK: all the way at the port mid-feed, back at the end In = FDescentBodyAnimInput(); In.bHeld = true; In.JackSec = 2.5; BodyTestRun(S, In, 0.5, Len); TestEqual(TEXT("mid-feed the hand is all the way at the port"), S.JackReach, 1.0, 1e-9); In.JackSec = 4.79; S.Step(In, 1.0 / 30.0, Len); TestTrue(TEXT("at 4.8 s it is back"), S.JackReach < 0.01); return true; } // 2026-10-12, third task: the model's profile and the animation table (Config/DescentBody.json), the run's rate matching, the swim arms. IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodyProfileTest, "Descent.Body.ProfileRunSwim", DESCENT_TEST_FLAGS) bool FDescentBodyProfileTest::RunTest(const FString& Parameters) { using ESeq = EDescentBodySeq; namespace BT = Descent::Tune::Body; // ---- the profile and the table load, and say everything the code asks for ---- FDescentBodyProfile::Reload(); const FDescentBodyProfile& P = FDescentBodyProfile::Get(); if (!TestTrue(*FString::Printf(TEXT("the active model's profile is valid (%s)"), *P.Error), P.bValid)) { return false; } TestEqual(TEXT("the active model is the file's \"model\""), P.Name, FDescentBodyProfile::ActiveModel()); TestTrue(TEXT("mesh, animation folder, eye socket, scale"), P.MeshPath.StartsWith(TEXT("/Game/")) && P.AnimFolder.StartsWith(TEXT("/Game/Player/")) && !P.EyeSocket.IsNone() && P.Scale > 0.5 && P.Scale < 1.5); TestNotNull(TEXT("the profile's mesh exists"), LoadObject(nullptr, *P.MeshPath, nullptr, LOAD_NoWarn | LOAD_Quiet)); TestTrue(TEXT("the head and the neck are among the bones hidden in the own view"), P.OwnViewHiddenBones.Contains(P.Bones.Head) && P.OwnViewHiddenBones.Contains(P.Bones.Neck)); const FDescentBodyProfile Nobody = FDescentBodyProfile::Load(TEXT("NoSuchModel")); TestTrue(TEXT("an unknown model is refused with a reason"), !Nobody.bValid && !Nobody.Error.IsEmpty()); // every role the code plays has a row, a baked sequence and (walking and running) a measured speed; the table has no row the code does not know TSet Roles; for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { const ESeq Seq = static_cast(I); const FString Role = Descent::Body::SeqRoleName(Seq); Roles.Add(Role); const FDescentBodyAnimRow* Row = P.FindRow(Role); if (!TestNotNull(*FString::Printf(TEXT("the table has a row for role %s"), *Role), Row)) { continue; } TestEqual(*FString::Printf(TEXT("%s: the table's loop flag is what the code plays"), *Role), Row->bLoop, Descent::Body::SeqLoops(Seq)); TestTrue(*FString::Printf(TEXT("%s: baked as A_Body_%s in the profile's folder"), *Role, *Role), P.BakedPath(Role) == P.AnimFolder / (TEXT("A_Body_") + Role) + TEXT(".A_Body_") + Role && LoadObject(nullptr, *P.BakedPath(Role), nullptr, LOAD_NoWarn | LOAD_Quiet) != nullptr); if (Role.StartsWith(TEXT("Walk")) || Role.StartsWith(TEXT("Jog")) || Role.StartsWith(TEXT("Run"))) { const FDescentBodyAnimMeasure* M = P.FindMeasure(Role); TestTrue(*FString::Printf(TEXT("%s: measured by the script (speed, footfalls)"), *Role), M && M->SpeedCmS > 50.0 && M->BobSteps > 0 && M->Length > 0.3); } } for (const FDescentBodyAnimRow& Row : P.Rows) { TestTrue(*FString::Printf(TEXT("table row %s is a role the code plays"), *Row.Role), Roles.Contains(Row.Role)); } TestTrue(TEXT("flags: the backward jog is the reversed one, the reach and the button push are upper-body rows"), P.FindRow(TEXT("JogBwd")) && P.FindRow(TEXT("JogBwd"))->bReversed && P.FindRow(TEXT("Touch")) && P.FindRow(TEXT("Touch"))->bUpperBody && P.FindRow(TEXT("PushButton")) && P.FindRow(TEXT("PushButton"))->bUpperBody && P.FindRow(TEXT("JogFwd")) && !P.FindRow(TEXT("JogFwd"))->bReversed); // ---- the run: its own clip above 3.85 m/s, the play rate from the speed the clip's legs cover the ground at ---- FDescentBodyLengths Len(1); Len.ApplyProfile(P); const int32 Run = static_cast(ESeq::RunFwd), Jog = static_cast(ESeq::JogFwd); const FDescentBodyAnimMeasure* RunM = P.FindMeasure(TEXT("RunFwd")); TestTrue(TEXT("the profile's measured speeds are in the lengths, scaled by the model's scale"), RunM && FMath::IsNearlyEqual(Len.SpeedCmS[Run], RunM->SpeedCmS * P.Scale, 1e-6) && Len.RootSpeedCmS[Run] > Len.SpeedCmS[Run]); FDescentBodyAnimState S; FDescentBodyAnimInput In; In.VelLocalCmS = FVector2D(480.0, 0.0); BodyTestRun(S, In, 1.0, Len); AddInfo(FString::Printf(TEXT("sprint 480 cm/s: run clip's legs %.0f cm/s, rate %.3f; walk 290 cm/s on the jog: legs %.0f cm/s, rate %.3f"), Len.SpeedCmS[Run], 480.0 / Len.SpeedCmS[Run], Len.SpeedCmS[Jog], 290.0 / Len.SpeedCmS[Jog])); TestTrue(TEXT("4.8 m/s: Run_Fwd, rate = 480 / the legs' speed, within 0.9 and 1.15"), S.bRun && S.Loco.Cur == ESeq::RunFwd && FMath::IsNearlyEqual(S.LocoRate, 480.0 / Len.SpeedCmS[Run], 1e-6) && S.LocoRate > 0.9 && S.LocoRate < 1.15 && FMath::Abs(S.LowerTurnDeg) < 0.5 && !S.bLocoBackward); In.VelLocalCmS = FVector2D(290.0, 0.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("2.9 m/s: the jog, rate = 290 / its legs' speed"), !S.bRun && S.Loco.Cur == ESeq::JogFwd && FMath::IsNearlyEqual(S.LocoRate, 290.0 / Len.SpeedCmS[Jog], 1e-6)); // sideways and diagonal: the same clip, the legs turned toward where he goes In.VelLocalCmS = FVector2D(0.0, 480.0); BodyTestRun(S, In, 1.5, Len); TestTrue(TEXT("sprinting right: Run_Fwd forward, the lower body turned 90 degrees to the right"), S.Loco.Cur == ESeq::RunFwd && !S.bLocoBackward && FMath::IsNearlyEqual(S.LowerTurnDeg, 90.0, 0.5)); In.VelLocalCmS = FVector2D(340.0, -340.0); BodyTestRun(S, In, 1.5, Len); TestTrue(TEXT("sprinting forward-left: turned 45 degrees to the left"), !S.bLocoBackward && FMath::IsNearlyEqual(S.LowerTurnDeg, -45.0, 0.5)); In.VelLocalCmS = FVector2D(-480.0, 0.0); BodyTestRun(S, In, 1.5, Len); const double Before = S.Loco.CurTime; S.Step(In, 0.05, Len); TestTrue(TEXT("sprinting backward: the run clip in reverse, the legs straight"), S.bLocoBackward && FMath::Abs(S.LowerTurnDeg) < 0.5 && FMath::IsNearlyEqual(FMath::Fmod(Before - S.Loco.CurTime + Len[ESeq::RunFwd], Len[ESeq::RunFwd]), 0.05 * S.LocoRate, 1e-6)); In.VelLocalCmS = FVector2D(-340.0, 340.0); BodyTestRun(S, In, 1.5, Len); TestTrue(TEXT("backward-right: still reversed, the legs turned 45 degrees to the left (away from where he goes)"), S.bLocoBackward && FMath::IsNearlyEqual(S.LowerTurnDeg, -45.0, 0.5)); In.VelLocalCmS = FVector2D::ZeroVector; BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("standing: no turn left"), !S.bRun && FMath::Abs(S.LowerTurnDeg) < 0.5); // the debug switch that plays it as before: no run clip, the rate from the root's travel Len.bOldLoco = true; In.VelLocalCmS = FVector2D(480.0, 0.0); BodyTestRun(S, In, 1.0, Len); TestTrue(TEXT("descent.Body.DebugOldLoco: the jog at 480 / its root speed"), !S.bRun && S.Loco.Cur == ESeq::JogFwd && FMath::IsNearlyEqual(S.LocoRate, 480.0 / Len.RootSpeedCmS[Jog], 1e-6)); Len.bOldLoco = false; // ---- the swim arms ---- TestEqual(TEXT("descent.Body.SwimArms defaults to 1"), UDescentPlayerBodyComponent::SwimArms(), 1.0); if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("descent.Body.SwimArms"))) { Var->Set(0, ECVF_SetByConsole); TestEqual(TEXT("the switch turns the code-made arms off"), UDescentPlayerBodyComponent::SwimArms(), 0.0); Var->Set(1, ECVF_SetByConsole); TestEqual(TEXT("and on again"), UDescentPlayerBodyComponent::SwimArms(), 1.0); } else { AddError(TEXT("console variable descent.Body.SwimArms is missing")); } // the hands' places are in the eye's frame, inside a 72 x 44.5 degree picture, by construction const double TanH = FMath::Tan(FMath::DegreesToRadians(36.0)), TanV = TanH * 9.0 / 16.0; auto InPicture = [&](const FVector& At) { return At.X > 10.0 && FMath::Abs(At.Y) < TanH * At.X && FMath::Abs(At.Z) < TanV * At.X; }; int32 PhasesIn = 0; for (int32 K = 0; K < 20; ++K) { const FVector R = FDescentBodyAnimState::SwimHandEyeCm(K / 20.0, 1.0, 0.0, false), L = FDescentBodyAnimState::SwimHandEyeCm(K / 20.0, 1.0, 0.0, true); TestTrue(TEXT("the left hand mirrors the right"), FMath::IsNearlyEqual(L.Y, -R.Y, 1e-9) && L.X == R.X && L.Z == R.Z); PhasesIn += (InPicture(R) && InPicture(L)) ? 1 : 0; } TestEqual(TEXT("forward stroke: both hands inside the picture at all 20 sampled places of the cycle"), PhasesIn, 20); const FVector Glide = FDescentBodyAnimState::SwimHandEyeCm(0.1, 1.0, 0.0, false), Sweep = FDescentBodyAnimState::SwimHandEyeCm(0.45, 1.0, 0.0, false); TestTrue(TEXT("the glide has the hands together ahead, the sweep takes them out and back"), Glide.Y < 8.0 && Glide.X > 40.0 && Sweep.Y > 15.0 && Sweep.X < Glide.X); for (int32 K = 0; K < 12; ++K) { const FVector Scull = FDescentBodyAnimState::SwimHandEyeCm(0.0, 0.0, K * 0.13, false); TestTrue(TEXT("treading: the hand sculls in the picture, in its lowest fifth, right of the middle"), InPicture(Scull) && -Scull.Z > 0.6 * TanV * Scull.X && Scull.Y > 10.0); } // the stroke's clock runs with the swim speed, and only with the retargeted set FDescentBodyAnimState W; In = FDescentBodyAnimInput(); In.bSwimming = true; In.VelLocalCmS = FVector2D(280.0, 0.0); BodyTestRun(W, In, 1.0, Len); const double Stroke0 = W.SwimStroke; W.Step(In, 0.1, Len); TestTrue(TEXT("swimming at full speed the stroke runs at its fastest"), FMath::IsNearlyEqual(FMath::Frac(W.SwimStroke - Stroke0 + 1.0), 0.1 * BT::SwimStrokeMaxHz, 1e-6)); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodySetupTest, "Descent.Body.Setup", DESCENT_TEST_FLAGS) bool FDescentBodySetupTest::RunTest(const FString& Parameters) { FBodyTestSwitchGuard Guard; FBodyTestSetGuard SetGuard; UDescentBodyAnimInstance::SetWantedSet(0); // this test is about the pack's own set and its measured lengths UDescentPlayerBodyComponent::SetOldArmView(false); UDescentPlayerBodyComponent::SetGunsWanted(false); FDescentTestWorld TW; ADescentCharacter* Ch = BodyTestSpawn(TW); if (!TestNotNull(TEXT("character"), Ch) || !TestNotNull(TEXT("body component"), Ch->Body.Get())) { return false; } UDescentPlayerBodyComponent* Body = Ch->Body; USkeletalMeshComponent* Mesh = Ch->GetMesh(); if (!TestTrue(TEXT("the body is set up (the pack is under Content/ParagonTwinblast)"), Body->IsSetUp())) { return false; } // the mesh: the pack's default (red-armed) Twinblast, on the character's own mesh component const USkeletalMesh* Asset = Mesh->GetSkeletalMeshAsset(); if (!TestNotNull(TEXT("mesh asset"), Asset)) { return false; } const FDescentBodyProfile& Profile = FDescentBodyProfile::Get(); TestEqual(TEXT("the mesh is the profile's (Twinblast: the pack's default TwinBlast)"), Asset->GetPathName(), Profile.MeshPath); TestEqual(TEXT("feet on the capsule bottom"), Mesh->GetRelativeLocation().Z, -static_cast(Ch->GetCapsuleComponent()->GetUnscaledCapsuleHalfHeight()), 1e-6); TestEqual(TEXT("scale (the model's own set: its legacy scale)"), Mesh->GetRelativeScale3D().X, Profile.LegacyScale, 1e-6); TestEqual(TEXT("turned to face the character's forward"), Mesh->GetRelativeRotation().Yaw, Profile.YawDeg, 1e-6); TestTrue(TEXT("no collision of its own"), Mesh->GetCollisionEnabled() == ECollisionEnabled::NoCollision); TestTrue(TEXT("drawn on foot"), Body->IsBodyShown() && Mesh->IsVisible()); TestFalse(TEXT("no player's view here, so the head is not hidden"), Body->IsHeadHidden()); // animation: our instance, the pack's sequences const UDescentBodyAnimInstance* Anim = Body->GetAnim(); if (!TestNotNull(TEXT("the animation instance is UDescentBodyAnimInstance"), Anim)) { return false; } TestEqual(TEXT("all 14 sequences of the pack are loaded"), Anim->CountLoadedSequences(), Descent::Body::SeqCount); const FDescentBodyLengths Measured; for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { const EDescentBodySeq Seq = static_cast(I); const UAnimSequence* Sequence = Anim->GetSequence(Seq); if (!Sequence) { AddError(FString::Printf(TEXT("sequence %s is missing"), Descent::Body::SeqAssetName(Seq))); continue; } TestEqual(*FString::Printf(TEXT("length of %s as measured"), Descent::Body::SeqAssetName(Seq)), Anim->GetLengths()[Seq], Measured[Seq], 0.002); TestTrue(*FString::Printf(TEXT("%s belongs to the mesh's skeleton"), Descent::Body::SeqAssetName(Seq)), Sequence->GetSkeleton() == Asset->GetSkeleton()); } // no guns by default; one switch brings them back const int32 Pistol = Profile.GunSlots.Num() > 0 ? Mesh->GetMaterialIndex(Profile.GunSlots[0]) : INDEX_NONE; TestTrue(TEXT("the mesh has the pistol material slot"), Pistol != INDEX_NONE); TestTrue(TEXT("guns hidden by default: the pistol section is not drawn in any LOD"), !Body->AreGunsShown() && !Body->IsPistolSectionShown()); for (int32 Lod = 0; Lod < Mesh->GetNumLODs(); ++Lod) { TestFalse(*FString::Printf(TEXT("LOD %d: pistol material hidden"), Lod), Mesh->IsMaterialSectionShown(Pistol, Lod)); TestTrue(*FString::Printf(TEXT("LOD %d: the arms are still drawn"), Lod), Mesh->IsMaterialSectionShown(Mesh->GetMaterialIndex(TEXT("M03_Arms")), Lod)); } UDescentPlayerBodyComponent::SetGunsWanted(true); Body->Refresh(0.0); TestTrue(TEXT("descent.Body.Guns 1 draws them"), Body->AreGunsShown() && Body->IsPistolSectionShown() && Mesh->IsMaterialSectionShown(Pistol, 0)); UDescentPlayerBodyComponent::SetGunsWanted(false); Body->Refresh(0.0); TestTrue(TEXT("and 0 hides them again"), !Body->AreGunsShown() && !Body->IsPistolSectionShown()); // machine legs: our material on the lower-body slot of the component; the pack's asset keeps its own const int32 Lower = Profile.MaterialOverrides.Num() > 0 ? Mesh->GetMaterialIndex(Profile.MaterialOverrides[0].Key) : INDEX_NONE; TestTrue(TEXT("the mesh has the lower-body material slot"), Lower != INDEX_NONE); TestTrue(TEXT("the leg override is in place (run Tools\\import_assets.ps1 if not)"), Body->IsLegMaterialApplied()); const UMaterialInterface* OnComponent = Lower != INDEX_NONE ? Mesh->GetMaterial(Lower) : nullptr; TestTrue(TEXT("the override is the project's material under /Game/Player/Twinblast"), OnComponent && Profile.MaterialOverrides.Num() > 0 && OnComponent->GetPathName() == Profile.MaterialOverrides[0].Value); const UMaterialInterface* OnAsset = (Lower != INDEX_NONE && Asset->GetMaterials().IsValidIndex(Lower)) ? Asset->GetMaterials()[Lower].MaterialInterface.Get() : nullptr; TestTrue(TEXT("the pack's mesh asset still has its own lower-body material"), OnAsset && OnAsset->GetPathName().StartsWith(TEXT("/Game/ParagonTwinblast/"))); for (int32 I = 0; I < Mesh->GetNumMaterials(); ++I) { if (I == Lower) { continue; } const UMaterialInterface* M = Mesh->GetMaterial(I); TestTrue(*FString::Printf(TEXT("material slot %d is the pack's own"), I), M && M->GetPathName().StartsWith(TEXT("/Game/ParagonTwinblast/"))); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodySeatedTest, "Descent.Body.SeatedAndSwitch", DESCENT_TEST_FLAGS) bool FDescentBodySeatedTest::RunTest(const FString& Parameters) { FBodyTestSwitchGuard Guard; UDescentPlayerBodyComponent::SetOldArmView(false); FDescentTestWorld TW; ADescentCharacter* Ch = BodyTestSpawn(TW); if (!TestNotNull(TEXT("character"), Ch) || !TestTrue(TEXT("body set up"), Ch->Body && Ch->Body->IsSetUp())) { return false; } UDescentPlayerBodyComponent* Body = Ch->Body; USkeletalMeshComponent* Mesh = Ch->GetMesh(); TestTrue(TEXT("on foot the body is drawn"), Body->IsBodyShown() && Mesh->IsVisible()); // seated in a Tender: hidden; released: back AKazahTender* Tender = TW.Get()->SpawnActor(FVector(1000.0, 0.0, 500.0), FRotator::ZeroRotator); if (TestNotNull(TEXT("a Tender to sit in"), Tender)) { Ch->SetSeatedIn(Tender); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("seated in a Tender: in the Tender, body not drawn"), Ch->IsSeated() && Ch->IsInTender() && !Body->IsBodyShown() && !Mesh->IsVisible()); Ch->SetSeatedIn(nullptr); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("released: drawn again"), !Ch->IsSeated() && !Ch->IsInTender() && Body->IsBodyShown() && Mesh->IsVisible()); // held at the helm (any actor holds him): he stands there, drawn, idle Ch->SetHeldBy(Tender); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("held at the helm: seated but not in a Tender, drawn"), Ch->IsSeated() && !Ch->IsInTender() && Body->IsBodyShown() && Mesh->IsVisible()); if (UDescentBodyAnimInstance* Anim = Body->GetAnim()) { Anim->StepState(1.0 / 30.0); TestTrue(TEXT("held at the helm he stands idle (motion: held), arms down (Idle_Relaxed asked for)"), Anim->Input.bHeld && Anim->Input.bRelaxed && Anim->GetState().Motion() == EDescentBodyMotion::Held); } Ch->SetHeldBy(nullptr); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("helm released: free again"), !Ch->IsSeated() && Body->IsBodyShown()); } // frozen (ladder, death, the jack-in): held still Ch->SetFrozen(true); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("frozen: the animation input says held (the ready stance, not the relaxed one)"), Body->GetAnim() && Body->GetAnim()->Input.bHeld && !Body->GetAnim()->Input.bRelaxed && Body->IsBodyShown()); Ch->SetFrozen(false); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("unfrozen: not held"), Body->GetAnim() && !Body->GetAnim()->Input.bHeld); // the old arm is one switch away: the body goes, and comes back UDescentPlayerBodyComponent::SetOldArmView(true); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("descent.Body.OldArm 1: the body is not drawn"), !Body->IsBodyShown() && !Mesh->IsVisible()); TestNotNull(TEXT("the arm component is still on the character"), Ch->Arm.Get()); UDescentPlayerBodyComponent::SetOldArmView(false); Body->Refresh(1.0 / 30.0); TestTrue(TEXT("descent.Body.OldArm 0: the body is back"), Body->IsBodyShown() && Mesh->IsVisible()); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBodyFollowsMovementTest, "Descent.Body.FollowsMovement", DESCENT_TEST_FLAGS) bool FDescentBodyFollowsMovementTest::RunTest(const FString& Parameters) { FBodyTestSwitchGuard Guard; UDescentPlayerBodyComponent::SetOldArmView(false); FDescentTestWorld TW; ADescentCharacter* Ch = BodyTestSpawn(TW); if (!TestNotNull(TEXT("character"), Ch) || !TestTrue(TEXT("body set up"), Ch->Body && Ch->Body->IsSetUp())) { return false; } UDescentPlayerBodyComponent* Body = Ch->Body; UDescentBodyAnimInstance* Anim = Body->GetAnim(); UDescentCharacterMovement* Move = Cast(Ch->GetCharacterMovement()); if (!TestNotNull(TEXT("animation instance"), Anim) || !TestNotNull(TEXT("movement component"), Move)) { return false; } // The real path: the movement component's velocity and mode > UDescentPlayerBodyComponent::Refresh > the animation input > the state. auto Run = [&](double Seconds) { for (double T = 0.0; T < Seconds - 1e-9; T += 1.0 / 30.0) { Body->Refresh(1.0 / 30.0); Anim->StepState(1.0 / 30.0); } }; Ch->SetActorRotation(FRotator(0.0, 90.0, 0.0)); // facing world +Y: the velocity must be read in the body's frame Move->SetMovementMode(MOVE_Walking); Move->Velocity = FVector::ZeroVector; Run(0.5); TestTrue(TEXT("standing: idle"), Anim->GetState().Motion() == EDescentBodyMotion::Idle); Move->Velocity = FVector(0.0, 290.0, 0.0); // forward for a body facing +Y Run(0.5); TestTrue(TEXT("walking forward: moving, Jog_Fwd"), Anim->GetState().Motion() == EDescentBodyMotion::Moving && Anim->GetState().Loco.Cur == EDescentBodySeq::JogFwd); TestEqual(TEXT("the speed the animation sees is the movement's"), Anim->GetState().SpeedCmS, 290.0, 1e-6); Move->Velocity = FVector(-290.0, 0.0, 0.0); // to the body's right (world -X when facing +Y) Run(0.5); TestTrue(TEXT("strafing right: Jog_Right"), Anim->GetState().Loco.Cur == EDescentBodySeq::JogRight); Move->Velocity = FVector(0.0, 0.0, 440.0); Move->SetMovementMode(MOVE_Falling); Run(1.0 / 30.0); TestTrue(TEXT("in the air going up: jumping"), Anim->Input.bInAir && Anim->GetState().Motion() == EDescentBodyMotion::Jumping); Move->Velocity = FVector(0.0, 0.0, -300.0); Run(1.0); // the retargeted set's jump clip is 0.87 s long (the pack's 0.3 s): at this moment it is still the jump, the fall loop comes after it TestTrue(TEXT("still in the air: falling (retargeted set: still on the long jump clip)"), Anim->GetState().Motion() == EDescentBodyMotion::Falling || (Anim->ActiveSet() == 1 && Anim->GetState().Motion() == EDescentBodyMotion::Jumping)); Move->Velocity = FVector::ZeroVector; Move->SetMovementMode(MOVE_Walking); Run(1.0 / 30.0); TestTrue(TEXT("on the ground again: landing"), Anim->GetState().Motion() == EDescentBodyMotion::Landing); Run(1.0); TestTrue(TEXT("then idle"), Anim->GetState().Motion() == EDescentBodyMotion::Idle); // the own-view arm turn is not applied when no player looks through him TestTrue(TEXT("no player's view: the arms keep the pack's pose"), !Body->IsOwnView() && Anim->Input.ArmRaiseLeftDeg == 0.0 && Anim->Input.ArmInRightDeg == 0.0); return true; } #endif // WITH_DEV_AUTOMATION_TESTS