Files
gig-line/Game/GigLine/Source/GigLine/GigLineCharacter.cpp
T
zwitschi a17e982561 fix(placement): character and car stand on the ground, not above it
Both floats had different causes and both were measured.

Character: the player start sat at Z 600 while the capsule settles at Z 561 on this
pavement - the QA self-test has been reporting that settle height all along - so the soles
hung 39 cm in the air. The start now uses the measured 561.

Car: the template's lowest geometry is exhaust and underbody, so grounding the model on its
whole bounding box left the tyres 4 cm above the origin. It is grounded on its WHEELS now.
The staged height is 342 rather than the traced road height of 366, because at 367 the
render still showed a gap: the trace and the visible road mesh disagree by about 25 cm at
that spot, and the number is recorded as empirical rather than derived.

Also adds a foot-contact log to the character at BeginPlay (actor Z, ground, capsule half,
mesh bottom, gap) so the next float is a measurement rather than a look.
2026-11-09 15:59:52 +01:00

1130 lines
41 KiB
C++

#include "GigLineCharacter.h"
#include "Animation/AnimSequence.h"
#include "Animation/AnimSingleNodeInstance.h"
#include "Camera/CameraComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Components/CapsuleComponent.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/PlayerController.h"
#include "InputAction.h"
#include "InputMappingContext.h"
#include "Engine/World.h"
#include "TimerManager.h"
#include "Containers/Ticker.h"
#include "Camera/PlayerCameraManager.h"
#include "Engine/StaticMeshActor.h"
#include "UnrealClient.h"
#include "HAL/IConsoleManager.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/FileManager.h"
// Development-only diagnostics for the walkable demo. These describe the spawn
// and input setup and are not needed in a shipped build's log spam; disable
// with `-NoGigLineSetupLogs` (or set the cvar in release config).
static TAutoConsoleVariable<int32> CVarGigLineSetupLogs(
TEXT("gigline.SetupLogs"),
1,
TEXT("Log GigLine spawn/input setup diagnostics (0=off, 1=on)."));
AGigLineCharacter::AGigLineCharacter()
{
PrimaryActorTick.bCanEverTick = true;
// 1.75 m tall, 0.42 m wide, human scale in Unreal units (1 m = 100 uu).
GetCapsuleComponent()->InitCapsuleSize(42.0f, 87.5f);
// Move relative to where the player is looking, not where the actor points.
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = true;
bUseControllerRotationRoll = false;
UCharacterMovementComponent* Movement = GetCharacterMovement();
Movement->bOrientRotationToMovement = false;
Movement->MaxWalkSpeed = WalkSpeedMetresPerSecond * 100.0f;
Movement->JumpZVelocity = 420.0f;
Movement->AirControl = 0.2f;
Movement->BrakingDecelerationWalking = 2048.0f;
Movement->MaxStepHeight = 45.0f;
FirstPersonCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FirstPersonCamera"));
FirstPersonCamera->SetupAttachment(GetCapsuleComponent());
FirstPersonCamera->SetRelativeLocation(FVector(0.0f, 0.0f, 72.5f)); // eye height 1.60 m
FirstPersonCamera->bUsePawnControlRotation = true;
// Visible body (0.2.0). The animated import carries its own skeleton, so the
// clips resolve without a retarget step; it is hidden while the first-person
// camera is active and shown in third person.
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
TEXT("/Game/Test/Character/CharacterV6.CharacterV6"));
if (BodyFinder.Succeeded())
{
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
}
// The animated import carries no material of its own; without this the body is
// grey clay. The warm skin material was created by the character import.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SkinFinder(
TEXT("/Game/Test/Character/M_Character_Export.M_Character_Export"));
if (SkinFinder.Succeeded())
{
GetMesh()->SetMaterial(0, SkinFinder.Object);
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Idle.CharacterV6Human_rig_GigLine_Idle"));
if (IdleFinder.Succeeded())
{
IdleAnimation = IdleFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Walk.CharacterV6Human_rig_GigLine_Walk"));
if (WalkFinder.Succeeded())
{
WalkAnimation = WalkFinder.Object;
}
GetMesh()->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
GetMesh()->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetMesh()->SetHiddenInGame(true);
// Hair: a separate component that copies the body's pose, so it follows the head
// bone without a second rig or a second animation. Hidden with the body.
// The skin material is assigned at runtime: a SkeletalMesh slot cannot be repointed
// from Python, so Tools/ue_character_materials.py builds M_Character_Skin and the
// pawn binds it here. Without this the imported FBX phong material is used and the
// generated pore detail never reaches the render.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SkinMaterialFinder(
TEXT("/Game/Test/Character/M_Character_Skin.M_Character_Skin"));
if (SkinMaterialFinder.Succeeded())
{
GetMesh()->SetMaterial(0, SkinMaterialFinder.Object);
}
HairMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("HairMesh"));
HairMesh->SetupAttachment(GetCapsuleComponent());
HairMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
HairMesh->SetHiddenInGame(true);
static ConstructorHelpers::FObjectFinder<USkeletalMesh> HairFinder(
TEXT("/Game/Test/Character/CharacterHair.CharacterHair"));
if (HairFinder.Succeeded())
{
HairMesh->SetSkeletalMesh(HairFinder.Object);
HairMesh->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
HairMesh->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
HairMesh->SetLeaderPoseComponent(GetMesh());
}
// Garments: tee, jeans, shoes. Each is its own mesh with its own single material,
// attached to the capsule and posed from the body so there is no second rig.
static const TCHAR* GarmentPaths[] = {
TEXT("/Game/Test/Character/CharacterTee.CharacterTee"),
TEXT("/Game/Test/Character/CharacterJeans.CharacterJeans"),
TEXT("/Game/Test/Character/CharacterShoes.CharacterShoes"),
};
for (const TCHAR* Path : GarmentPaths)
{
USkeletalMeshComponent* Piece = CreateDefaultSubobject<USkeletalMeshComponent>(
FName(*FString::Printf(TEXT("Cloth_%s"), *FPaths::GetBaseFilename(Path))));
Piece->SetupAttachment(GetCapsuleComponent());
Piece->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Piece->SetHiddenInGame(true);
Piece->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
Piece->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
if (USkeletalMesh* Mesh = LoadObject<USkeletalMesh>(nullptr, Path))
{
Piece->SetSkeletalMesh(Mesh);
Piece->SetLeaderPoseComponent(GetMesh());
}
Clothing.Add(Piece);
}
// Third-person boom. bDoCollisionTest is the camera-collision feature: the arm
// shortens when something is between the pivot and the desired camera position.
ThirdPersonArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("ThirdPersonArm"));
ThirdPersonArm->SetupAttachment(GetCapsuleComponent());
ThirdPersonArm->SetRelativeLocation(FVector(0.0f, 0.0f, 60.0f));
ThirdPersonArm->TargetArmLength = ThirdPersonBoomLength;
ThirdPersonArm->bUsePawnControlRotation = true;
ThirdPersonArm->bDoCollisionTest = true;
ThirdPersonArm->ProbeSize = 12.0f;
ThirdPersonArm->ProbeChannel = ECollisionChannel::ECC_Camera;
ThirdPersonArm->bEnableCameraLag = true;
ThirdPersonArm->CameraLagSpeed = 8.0f;
ThirdPersonArm->SetAutoActivate(false);
ThirdPersonCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ThirdPersonCamera"));
ThirdPersonCamera->SetupAttachment(ThirdPersonArm, USpringArmComponent::SocketName);
ThirdPersonCamera->bUsePawnControlRotation = false;
ThirdPersonCamera->SetAutoActivate(false);
ThirdPersonCamera->SetFieldOfView(90.0f);
// Hard-reference the Enhanced Input assets with ConstructorHelpers so the
// cooker always includes them and they load during CDO construction. Runtime
// soft-loading them at BeginPlay was fragile: the assets were pruned from the
// pak unless /Game/Input was force-cooked, and a soft path can miss while the
// IoStore registry is still mounting. A constructor FObjectFinder is a hard
// reference (followed by the cooker) and loads deterministically.
// Assets are created by Tools/ue_create_input_assets.py.
static ConstructorHelpers::FObjectFinder<UInputMappingContext> CtxFinder(
TEXT("/Game/Input/IMC_Default.IMC_Default"));
static ConstructorHelpers::FObjectFinder<UInputAction> MoveF(
TEXT("/Game/Input/IA_Move.IA_Move"));
static ConstructorHelpers::FObjectFinder<UInputAction> StrafeF(
TEXT("/Game/Input/IA_Strafe.IA_Strafe"));
static ConstructorHelpers::FObjectFinder<UInputAction> BackF(
TEXT("/Game/Input/IA_MoveBack.IA_MoveBack"));
static ConstructorHelpers::FObjectFinder<UInputAction> LeftF(
TEXT("/Game/Input/IA_StrafeLeft.IA_StrafeLeft"));
static ConstructorHelpers::FObjectFinder<UInputAction> LookF(
TEXT("/Game/Input/IA_Look.IA_Look"));
static ConstructorHelpers::FObjectFinder<UInputAction> JumpF(
TEXT("/Game/Input/IA_Jump.IA_Jump"));
static ConstructorHelpers::FObjectFinder<UInputAction> ResetF(
TEXT("/Game/Input/IA_Reset.IA_Reset"));
static ConstructorHelpers::FObjectFinder<UInputAction> DebugF(
TEXT("/Game/Input/IA_ToggleDebug.IA_ToggleDebug"));
static ConstructorHelpers::FObjectFinder<UInputAction> CamF(
TEXT("/Game/Input/IA_ToggleCamera.IA_ToggleCamera"));
static ConstructorHelpers::FObjectFinder<UInputAction> PauseF(
TEXT("/Game/Input/IA_Pause.IA_Pause"));
MappingContext = CtxFinder.Object;
MoveAction = MoveF.Object;
StrafeAction = StrafeF.Object;
MoveBackAction = BackF.Object;
StrafeLeftAction = LeftF.Object;
LookAction = LookF.Object;
JumpAction = JumpF.Object;
ResetAction = ResetF.Object;
ToggleCameraAction = CamF.Object;
ToggleDebugAction = DebugF.Object;
PauseAction = PauseF.Object;
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
|| !LookAction)
{
UE_LOG(LogTemp, Warning,
TEXT("GigLine: input assets failed to load at construction. ")
TEXT("Run Tools/ue_create_input_assets.py to build /Game/Input."));
}
}
void AGigLineCharacter::LoadInputAssets()
{
// The assets are hard-referenced and loaded in the constructor. This pushes
// the now-valid mapping context to the local player's subsystem; called from
// SetupPlayerInputComponent at possession, when the controller exists.
if (MappingContext)
{
if (const APlayerController* PC = Cast<APlayerController>(GetController()))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
{
Subsystem->AddMappingContext(MappingContext, 0);
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine IN: pushed mapping context %s"),
*MappingContext->GetName());
}
}
else if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine IN: no enhanced input subsystem on local player"));
}
}
else if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine IN: no PlayerController at LoadInputAssets"));
}
}
}
void AGigLineCharacter::BeginPlay()
{
Super::BeginPlay();
StartLocation = GetActorLocation();
StartRotation = GetActorRotation();
// `-thirdperson` starts the pawn in the third-person rig. The first-person view
// stays the default (owner decision for 0.1.0), but the screenshot run needs the
// body visible without a human pressing V, so the review is reproducible.
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log,
TEXT("GigLine: BeginPlay thirdperson=%d body=%s idle=%s walk=%s"),
FParse::Param(FCommandLine::Get(), TEXT("thirdperson")) ? 1 : 0,
GetMesh()->GetSkeletalMeshAsset() ? TEXT("ok") : TEXT("nil"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
if (FParse::Param(FCommandLine::Get(), TEXT("thirdperson")))
{
ToggleCamera();
}
// The PlayerStart sits above grade so the capsule cannot spawn embedded, so
// the BeginPlay position is mid-air. Re-capture once the pawn has landed,
// otherwise Reset drops the player 1.4 m back into the air every press.
// Repeat until the first grounded frame. A single late timer is wrong: it can
// fire after the player (or the QA self-test) has already started walking,
// which recorded a mid-stride position as "start".
GetWorldTimerManager().SetTimer(SettleHandle, [this]()
{
if (bQATestWalking)
{
return; // keep waiting; the test walks almost immediately
}
if (const UCharacterMovementComponent* Mov = GetCharacterMovement())
{
if (Mov->IsMovingOnGround())
{
StartLocation = GetActorLocation();
StartRotation = GetActorRotation();
GetWorldTimerManager().ClearTimer(this->SettleHandle);
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: start captured on ground at Z %.0f"),
StartLocation.Z);
}
}
}
}, 0.25f, true);
// Force game input mode + focus + mouse capture so keyboard/mouse reach the
// pawn regardless of window focus quirks.
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
PC->SetInputMode(FInputModeGameOnly());
PC->bShowMouseCursor = false;
}
if (FParse::Param(FCommandLine::Get(), TEXT("qaselftest")))
{
StartQASelfTest();
}
// `-walk` holds forward input from the first frame. The screenshot run uses it
// with `-thirdperson` so a capture can show the walk clip mid-stride: an idle
// shot cannot prove the animation set, and a still cannot show a stride.
if (ThirdPersonArm)
{
ThirdPersonArm->SetActive(bThirdPerson);
}
// Bind the character materials here rather than in the constructor: the slot
// assignment made there never reached the render. LoadObject, not FObjectFinder,
// because FObjectFinder may only be used inside a constructor - putting one in
// Tick was a fatal "FObjectFinders can't be used outside of constructors".
if (GetMesh())
{
if (UMaterialInterface* Skin = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Game/Test/Character/M_Character_Skin.M_Character_Skin")))
{
GetMesh()->SetMaterial(0, Skin);
}
}
if (HairMesh)
{
if (UMaterialInterface* HairMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Game/Test/Character/M_Character_Hair_Mat.M_Character_Hair_Mat")))
{
HairMesh->SetMaterial(0, HairMat);
}
}
// Foot-contact diagnostic: how far the body's lowest point sits above the ground.
if (UWorld* World = GetWorld())
{
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineFoot"), false, this);
const bool bGround = World->LineTraceSingleByChannel(Hit, GetActorLocation(),
GetActorLocation() - FVector(0.f, 0.f, 3000.f), ECC_Visibility, Q);
const float CapsuleHalf = GetCapsuleComponent() ? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() : -1.f;
const float MeshBottom = GetMesh() ? (GetMesh()->Bounds.Origin - GetMesh()->Bounds.BoxExtent).Z : 0.f;
UE_LOG(LogTemp, Warning,
TEXT("GigLine foot: actor Z %.0f, ground %.0f, capsule half %.0f, mesh bottom %.0f, gap %.0f cm"),
GetActorLocation().Z, bGround ? Hit.ImpactPoint.Z : -9999.f, CapsuleHalf, MeshBottom,
bGround ? MeshBottom - Hit.ImpactPoint.Z : -9999.f);
}
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
bCameraTest = FParse::Param(FCommandLine::Get(), TEXT("cameratest"));
// `-perf` starts the overlay on: the performance capture and any manual frame
// check should not depend on someone pressing F3 first. The built demo has
// neither switch, so the counter stays hidden for players.
if (FParse::Param(FCommandLine::Get(), TEXT("perf")) || FParse::Param(FCommandLine::Get(), TEXT("qaselftest")))
{
if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug")))
{
Var->Set(1, ECVF_SetByConsole);
}
}
}
float AGigLineCharacter::GetThirdPersonArmLength() const
{
return ThirdPersonArm ? ThirdPersonArm->TargetArmLength : 0.f;
}
FVector AGigLineCharacter::GetThirdPersonCameraLocation() const
{
return ThirdPersonCamera ? ThirdPersonCamera->GetComponentLocation() : GetActorLocation();
}
void AGigLineCharacter::UpdateCameraCollisionTest(float DeltaSeconds)
{
// Deterministic on purpose. Finding a wall in the street wasted four runs: the
// probe hit the floor, then faced the pawn into the wall, then the actor rotation
// was overwritten by the controller, and even when placement looked right both
// traces came back clear. A spawned wall behind the pawn removes every one of
// those variables.
CameraTestElapsed += DeltaSeconds;
UWorld* World = GetWorld();
if (!World)
{
return;
}
if (!CameraTestPlaced)
{
CameraTestPlaced = true;
// A 6 m cube, 1.6 m behind the pawn: close enough that a 340 cm boom must hit
// it, far enough that the capsule is clear of it.
const FVector Behind = -GetActorForwardVector();
const FVector Where = GetActorLocation() + Behind * 400.f - FVector(0.f, 0.f, 90.f);
FActorSpawnParameters Params;
Params.ObjectFlags |= RF_Transient;
// Static mesh cube, 2 m thick, 4 m behind. The earlier failures had the pawn
// INSIDE a 6 m cube centred 1.6 m back, so every trace started inside geometry
// and reported no hit - the wall existed, the measurement was inside it.
// Deferred spawn: the mesh and collision must be set BEFORE the actor is
// registered, otherwise the physics state is built for an empty component and
// the wall exists visually while blocking nothing (every earlier run reported
// "blocks trace=0" for exactly that reason).
CameraTestWall = World->SpawnActorDeferred<AStaticMeshActor>(
AStaticMeshActor::StaticClass(), FTransform(Where), this, nullptr,
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
if (CameraTestWall)
{
UStaticMeshComponent* Comp = CameraTestWall->GetStaticMeshComponent();
Comp->SetMobility(EComponentMobility::Movable);
Comp->SetStaticMesh(LoadObject<UStaticMesh>(
nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")));
Comp->SetCollisionProfileName(TEXT("BlockAll"));
CameraTestWall->SetActorScale3D(FVector(6.f, 2.f, 6.f));
CameraTestWall->FinishSpawning(FTransform(Where));
}
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST wall spawned behind pawn at (%.0f,%.0f,%.0f)"),
Where.X, Where.Y, Where.Z);
return;
}
if (CameraTestElapsed < 2.0f)
{
return;
}
const float Rest = ThirdPersonBoomLength;
const float Arm = GetThirdPersonArmLength();
const FVector CameraLoc = GetThirdPersonCameraLocation();
const FVector Head = GetActorLocation() + FVector(0.f, 0.f, 60.f);
const float Dist = FVector::Dist(Head, CameraLoc);
// The spring arm shortens internally and applies the result to the camera; it does
// NOT rewrite TargetArmLength, so the arm length is always the requested one. The
// measurement that matters is the camera's distance from the head.
const bool bShortened = (Rest - Dist) > 40.f;
// Prove the spawned wall has collision at all before blaming the arm.
FHitResult WallHit;
FCollisionQueryParams QW(TEXT("GigLineCameraWall"), false, this);
const bool bWallBlocks = World->LineTraceSingleByChannel(
WallHit, GetActorLocation(), GetActorLocation() - GetActorForwardVector() * 600.f,
ECC_Visibility, QW);
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST spawned wall blocks trace=%d dist=%.0f"),
bWallBlocks ? 1 : 0, bWallBlocks ? WallHit.Distance : -1.f);
UE_LOG(LogTemp, Warning,
TEXT("GigLine CAMTEST wall behind pawn: boom %.0f cm (requested %.0f, shortened %.0f) -> %s"),
Dist, Rest, Rest - Dist,
bShortened ? TEXT("PASS (boom shortened)") : TEXT("FAIL (no shortening)"));
FScreenshotRequest::RequestScreenshot(TEXT("review-demo-camera-collision"), true, false);
if (CameraTestWall)
{
CameraTestWall->Destroy();
CameraTestWall = nullptr;
}
bCameraTest = false;
}
void AGigLineCharacter::ToggleDebugOverlay()
{
static IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug"));
if (Var)
{
const bool bNowOn = Var->GetInt() == 0;
Var->Set(bNowOn ? 1 : 0, ECVF_SetByConsole);
UE_LOG(LogTemp, Log, TEXT("GigLine: debug overlay %s"), bNowOn ? TEXT("on") : TEXT("off"));
}
}
void AGigLineCharacter::SetReviewWalk(bool bEnable)
{
bWalkForever = bEnable;
}
void AGigLineCharacter::ReturnToStartForReview()
{
ResetToStart();
}
void AGigLineCharacter::WalkForever()
{
if (bWalkForever && GetCharacterMovement() && GetCharacterMovement()->IsMovingOnGround())
{
AddMovementInput(GetActorForwardVector(), 1.0f);
}
}
void AGigLineCharacter::StartQASelfTest()
{
// Driven by a core ticker rather than a world timer on purpose: pausing the
// game stops world timers, so a timer-based test would freeze the moment it
// proved pause works.
struct FQAState
{
float Elapsed = 0.0f;
FVector Start = FVector::ZeroVector;
FVector WalkEnd = FVector::ZeroVector;
float Step = 0.0f;
bool bWalkMoved = false;
bool bResetReturned = false;
bool bPausedOn = false;
bool bUnpausedBack = false;
// Performance capture over the fixed walk route (1.0-3.0 s).
TArray<float> FrameMs;
bool bPerfLogged = false;
};
TSharedPtr<FQAState> State = MakeShared<FQAState>();
State->Start = GetActorLocation();
UE_LOG(LogTemp, Log, TEXT("GigLine QA: start XYZ=(%.0f,%.0f,%.0f)"),
State->Start.X, State->Start.Y, State->Start.Z);
FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateWeakLambda(this,
[this, State](float DeltaTime)
{
State->Elapsed += DeltaTime;
const float E = State->Elapsed;
// 1.0 - 3.0 s: hold forward. Reset sets the Step marker so each phase
// runs exactly once without an ever-growing if-chain.
if (E >= 1.0f && State->Step < 1.0f)
{
State->Step = 1.0f;
// Measure from the settled position, not the mid-air spawn point.
State->Start = GetActorLocation();
UE_LOG(LogTemp, Log, TEXT("GigLine QA: settled XYZ=(%.0f,%.0f,%.0f)"),
State->Start.X, State->Start.Y, State->Start.Z);
bQATestWalking = true;
}
if (bQATestWalking)
{
AddMovementInput(GetActorForwardVector(), 1.0f);
// Frame time for the perf capture (this ticker runs every frame).
State->FrameMs.Add(DeltaTime * 1000.0f);
}
// 3.0: stop and measure.
if (E >= 3.0f && State->Step < 2.0f)
{
State->Step = 2.0f;
bQATestWalking = false;
State->WalkEnd = GetActorLocation();
const float Walked = FVector::Dist2D(State->Start, State->WalkEnd);
State->bWalkMoved = Walked > 200.0f; // >2 m in 2 s at 4 m/s
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: walk moved %.0f cm in 2 s -> %s (Z %.0f -> %.0f)"),
Walked, State->bWalkMoved ? TEXT("PASS") : TEXT("FAIL"),
State->Start.Z, State->WalkEnd.Z);
// Perf: report frame-time stats over the fixed walk route.
if (!State->bPerfLogged && State->FrameMs.Num() > 10)
{
State->bPerfLogged = true;
TArray<float> Sorted = State->FrameMs;
Sorted.Sort();
const int32 N = Sorted.Num();
double Sum = 0.0;
for (float Ms : Sorted) { Sum += Ms; }
const float AvgMs = static_cast<float>(Sum / N);
const float P50 = Sorted[N / 2];
const float P95 = Sorted[FMath::Clamp((int32)(N * 0.95f), 0, N - 1)];
const float P99 = Sorted[FMath::Clamp((int32)(N * 0.99f), 0, N - 1)];
const float MaxMs = Sorted.Last();
FVector2D Res(0.0, 0.0);
if (GEngine && GEngine->GameViewport)
{
GEngine->GameViewport->GetViewportSize(Res);
}
// Record the upscaling/AA state so the check is reproducible.
auto CV = [](const TCHAR* Name)
{
IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(Name);
return Var ? Var->GetInt() : -999;
};
UE_LOG(LogTemp, Log,
TEXT("GigLine PERF: frames=%d avg=%.2fms (%.1f fps) p50=%.2f p95=%.2f p99=%.2f max=%.2f ms at %dx%d"),
N, AvgMs, AvgMs > 0.f ? 1000.f / AvgMs : 0.f, P50, P95, P99, MaxMs,
(int32)Res.X, (int32)Res.Y);
UE_LOG(LogTemp, Log,
TEXT("GigLine PERF: AntiAliasingMethod=%d ScreenPercentage=%d TSR=%d FrameGen=%d VSync=%d"),
CV(TEXT("r.AntiAliasingMethod")), CV(TEXT("r.ScreenPercentage")),
CV(TEXT("r.TSR.History.ScreenPercentage")),
CV(TEXT("r.Streamline.FrameGeneration.Mode")),
CV(TEXT("r.VSync")));
// Frame-time series, so the check has a graph source, not one sample.
{
const FString Dir = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Profiling"));
IFileManager::Get().MakeDirectory(*Dir, true);
const FString Path = FPaths::Combine(Dir, TEXT("perf-walk-frametimes.csv"));
FString Csv(TEXT("frame,ms\n"));
for (int32 i = 0; i < N; ++i)
{
Csv += FString::Printf(TEXT("%d,%.3f\n"), i, State->FrameMs[i]);
}
FFileHelper::SaveStringToFile(Csv, *Path);
UE_LOG(LogTemp, Log, TEXT("GigLine PERF: frame times written to %s"), *Path);
}
}
}
// 3.5: reset should return to the spawn point.
if (E >= 3.5f && State->Step < 3.0f)
{
State->Step = 3.0f;
ResetToStart();
}
if (E >= 4.0f && State->Step < 4.0f)
{
State->Step = 4.0f;
const float Back = FVector::Dist(State->Start, GetActorLocation());
State->bResetReturned = Back < 100.0f;
UE_LOG(LogTemp, Log, TEXT("GigLine QA: reset returned %.0f cm from spawn -> %s"),
Back, State->bResetReturned ? TEXT("PASS") : TEXT("FAIL"));
}
// 4.5: pause, then confirm the world actually stopped.
if (E >= 4.5f && State->Step < 5.0f)
{
State->Step = 5.0f;
TogglePause();
State->bPausedOn = UGameplayStatics::IsGamePaused(this);
UE_LOG(LogTemp, Log, TEXT("GigLine QA: pause -> paused=%d %s"),
State->bPausedOn ? 1 : 0, State->bPausedOn ? TEXT("PASS") : TEXT("FAIL"));
}
// 5.5: unpause and confirm it comes back.
if (E >= 5.5f && State->Step < 6.0f)
{
State->Step = 6.0f;
TogglePause();
State->bUnpausedBack = !UGameplayStatics::IsGamePaused(this);
UE_LOG(LogTemp, Log, TEXT("GigLine QA: unpause -> paused=%d %s"),
UGameplayStatics::IsGamePaused(this) ? 1 : 0,
State->bUnpausedBack ? TEXT("PASS") : TEXT("FAIL"));
}
// 6.0: ground transect - sweep both sidewalks and the carriageway and
// count holes, so "can traverse the block without falling through"
// is measured rather than assumed.
if (E >= 6.0f && State->Step < 7.0f)
{
State->Step = 7.0f;
UWorld* W = GetWorld();
int32 Holes = 0, Samples = 0;
float Lowest = 1.0e9f, Highest = -1.0e9f;
// Probed world-space (2026-11-08): N sidewalk -1912..-1053, kerb
// -1382..-1053, road -1382..+1047, kerb +718..+1047, S sidewalk
// +718..+1447. Rows include the kerb/road/sidewalk boundary Ys so
// seam gaps are sampled, not just lane centres.
static const float Rows[] = {
-1750.0f, -1500.0f, -1382.0f, -1280.0f, -1053.0f, -900.0f,
-167.0f, 0.0f, 300.0f, 800.0f, 1047.0f, 900.0f, 718.0f,
900.0f, 1250.0f, 1447.0f
};
for (float LaneY : Rows)
{
for (float X = -20000.0f; X <= -2000.0f; X += 200.0f)
{
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineTransect"), false, this);
const bool bHit = W->LineTraceSingleByChannel(Hit,
FVector(X, LaneY, 3000.f), FVector(X, LaneY, -3000.f),
ECC_Visibility, Q);
++Samples;
if (!bHit)
{
++Holes;
continue;
}
Lowest = FMath::Min(Lowest, Hit.ImpactPoint.Z);
Highest = FMath::Max(Highest, Hit.ImpactPoint.Z);
}
}
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: transect %d samples, %d holes, ground Z %.0f..%.0f -> %s"),
Samples, Holes, Lowest, Highest, Holes == 0 ? TEXT("PASS") : TEXT("FAIL"));
// Invisible-wall / blocking-mesh probe: at each sample, first find
// the ground under the start point (vertical raycast), then run a
// horizontal trace 160 cm ABOVE that ground along the street row.
// A clean street should let the 30 m trace run clear; a short hit
// flags an invisible or misplaced blocker. Starting the horizontal
// trace at a fixed Z (e.g. 160) is wrong: the street is at Z ~366
// there, so a 160 cm probe starts inside the earth (dist 0).
int32 Walls = 0, WallSamples = 0;
// Sample the street envelope only: the carriageway plus the walkable
// part of each sidewalk. Y 1000 used to be in this list and sits ~1 cm
// from the frontage building line, so it grazed the theatre wall once
// the kit's proud shopfronts went in - a probe artefact, not a blocker.
static const float TraceYs[] = {-1400.0f, -167.0f, 800.0f, -1382.0f, 718.0f};
for (float WallY : TraceYs)
{
for (float X0 = -19500.0f; X0 <= -3000.0f; X0 += 2500.0f)
{
FHitResult GroundHit;
FCollisionQueryParams GQ(TEXT("GigLineWallGround"), false, this);
if (!W->LineTraceSingleByChannel(GroundHit,
FVector(X0, WallY, 3000.f), FVector(X0, WallY, -1000.f),
ECC_Visibility, GQ))
{
continue; // no ground under start -> skip
}
const float ProbeZ = GroundHit.ImpactPoint.Z + 160.f;
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineWallProbe"), false, this);
const bool bHit = W->LineTraceSingleByChannel(Hit,
FVector(X0, WallY, ProbeZ),
FVector(X0 + 3000.f, WallY, ProbeZ),
ECC_Visibility, Q);
++WallSamples;
if (!bHit) { continue; } // 30 m clear -> good
// Starting just above the ground, a hit within 21 m = blocker.
if (Hit.Distance < 2100.f)
{
++Walls;
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: wall probe short-hit at X %.0f Y %.0f dist %.0f actor=%s"),
X0, WallY, Hit.Distance,
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("none"));
}
}
}
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: wall probe %d/%d traces with blocking hit inside 21 m -> %s"),
Walls, WallSamples,
Walls == 0 ? TEXT("PASS") : TEXT("FAIL"));
// Facade occlusion probe: walkable street is only half the story —
// the player must also NOT be able to walk through the buildings.
// From the road centre, fire 40 m sideways at walking height toward
// each frontage and require a blocking hit. A miss means the
// building has no collision (or only non-blocking collision) and the
// player would pass through the wall.
// Expect a wall only where the map actually stages a building.
// Spans below mirror the actors placed by Tools/ue_build_demo_map.py
// (frontage + hero) and the two north buildings; update them if
// that list changes. Gaps between buildings are legitimate misses.
struct FSpan { float Min, Max; };
static const FSpan SouthSpans[] = {
{-24276.f, -17700.f}, // 6922
{-17749.f, -14591.f}, // 6902
{-14638.f, -12034.f}, // 6840 hero
{-12082.f, -8210.f}, // 6838 El Capitan Theatre
{-7854.f, -7252.f}, // 6822 Budget
{-6608.f, -3307.f}, // 6806-6820
{-3320.f, -1437.f}, // 6800 Lee Drug
};
static const FSpan NorthSpans[] = {
{-27071.f, -22799.f}, // Madame Tussaud's
{-22889.f, -18442.f}, // TCL Chinese Theatre
};
auto InSpans = [](const FSpan* Spans, int32 Count, float X)
{
for (int32 i = 0; i < Count; ++i)
{
if (X >= Spans[i].Min && X <= Spans[i].Max) { return true; }
}
return false;
};
int32 Facades = 0, FacadeSamples = 0;
for (float X0 = -19000.0f; X0 <= -3000.0f; X0 += 2000.0f)
{
for (float Dir : {1.0f, -1.0f}) // +Y south frontage, -Y north side
{
const bool bExpectWall = (Dir > 0.0f)
? InSpans(SouthSpans, UE_ARRAY_COUNT(SouthSpans), X0)
: InSpans(NorthSpans, UE_ARRAY_COUNT(NorthSpans), X0);
if (!bExpectWall)
{
continue;
}
FHitResult GroundHit2;
FCollisionQueryParams GQ2(TEXT("GigLineFacadeGround"), false, this);
if (!W->LineTraceSingleByChannel(GroundHit2,
FVector(X0, 0.0f, 3000.f), FVector(X0, 0.0f, -1000.f),
ECC_Visibility, GQ2))
{
continue;
}
const float ProbeZ2 = GroundHit2.ImpactPoint.Z + 160.f;
FHitResult Hit2;
FCollisionQueryParams Q2(TEXT("GigLineFacadeProbe"), false, this);
const bool bHit2 = W->LineTraceSingleByChannel(Hit2,
FVector(X0, 0.0f, ProbeZ2),
FVector(X0, Dir * 4000.f, ProbeZ2),
ECC_Visibility, Q2);
++FacadeSamples;
const bool bBlocking = bHit2 && Hit2.GetActor() != nullptr;
if (!bBlocking)
{
++Facades;
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: facade probe miss at X %.0f dir %.0f (no blocking hit in 40 m)"),
X0, Dir);
}
}
}
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: facade probe %d/%d directions without a blocking wall -> %s"),
Facades, FacadeSamples,
Facades == 0 ? TEXT("PASS") : TEXT("FAIL"));
}
// 7.0: verdict, then stop ticking.
if (E >= 7.0f)
{
const bool bAllPass = State->bWalkMoved && State->bResetReturned
&& State->bPausedOn && State->bUnpausedBack;
UE_LOG(LogTemp, Log, TEXT("GigLine QA: RESULT walk=%d reset=%d pause=%d unpause=%d -> %s"),
State->bWalkMoved ? 1 : 0, State->bResetReturned ? 1 : 0,
State->bPausedOn ? 1 : 0, State->bUnpausedBack ? 1 : 0,
bAllPass ? TEXT("PASS") : TEXT("FAIL"));
return false;
}
return true;
}));
}
void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
UEnhancedInputComponent* Input = Cast<UEnhancedInputComponent>(PlayerInputComponent);
if (!Input)
{
UE_LOG(LogTemp, Error, TEXT("GigLine: player input component is not Enhanced Input"));
return;
}
// Push the mapping context for this controller's local player so the bound
// actions receive input (runs at possession, when the controller exists).
LoadInputAssets();
// One action per direction keeps the sign explicit: W and S differ only by
// which handler runs, not by a modifier in the asset.
if (MoveAction)
{
Input->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AGigLineCharacter::MoveForward);
}
if (MoveBackAction)
{
Input->BindAction(MoveBackAction, ETriggerEvent::Triggered, this, &AGigLineCharacter::MoveBack);
}
if (StrafeAction)
{
Input->BindAction(StrafeAction, ETriggerEvent::Triggered, this, &AGigLineCharacter::MoveStrafe);
}
if (StrafeLeftAction)
{
Input->BindAction(StrafeLeftAction, ETriggerEvent::Triggered, this, &AGigLineCharacter::MoveLeft);
}
if (LookAction)
{
Input->BindAction(LookAction, ETriggerEvent::Triggered, this, &AGigLineCharacter::Look);
}
if (ResetAction)
{
Input->BindAction(ResetAction, ETriggerEvent::Started, this, &AGigLineCharacter::ResetToStart);
}
if (PauseAction)
{
Input->BindAction(PauseAction, ETriggerEvent::Started, this, &AGigLineCharacter::TogglePause);
}
if (ToggleCameraAction)
{
Input->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleCamera);
}
if (ToggleDebugAction)
{
Input->BindAction(ToggleDebugAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleDebugOverlay);
}
if (JumpAction)
{
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
Input->BindAction(JumpAction, ETriggerEvent::Completed, this, &AGigLineCharacter::StopJump);
}
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine IN: bound %d actions; ctx=%s move=%s look=%s jmp=%s"),
(Input ? 1 : 0),
MappingContext ? TEXT("ok") : TEXT("nil"),
MoveAction ? TEXT("ok") : TEXT("nil"),
LookAction ? TEXT("ok") : TEXT("nil"),
JumpAction ? TEXT("ok") : TEXT("nil"));
}
}
namespace
{
/** One-dimensional action value; S and A arrive positive and are negated here. */
float SignedAxis(const FInputActionValue& Value, bool bNegativeKey)
{
float Axis = 0.0f;
switch (Value.GetValueType())
{
case EInputActionValueType::Boolean:
Axis = Value.Get<bool>() ? 1.0f : 0.0f;
break;
case EInputActionValueType::Axis1D:
Axis = Value.Get<float>();
break;
default:
Axis = Value.Get<FVector2D>().Y;
break;
}
return bNegativeKey ? -Axis : Axis;
}
}
void AGigLineCharacter::MoveForward(const FInputActionValue& Value)
{
ApplyMove(FMath::Abs(SignedAxis(Value, false)), 0.0f);
}
void AGigLineCharacter::MoveBack(const FInputActionValue& Value)
{
ApplyMove(-FMath::Abs(SignedAxis(Value, false)), 0.0f);
}
void AGigLineCharacter::MoveStrafe(const FInputActionValue& Value)
{
ApplyMove(0.0f, FMath::Abs(SignedAxis(Value, false)));
}
void AGigLineCharacter::MoveLeft(const FInputActionValue& Value)
{
ApplyMove(0.0f, -FMath::Abs(SignedAxis(Value, false)));
}
void AGigLineCharacter::ApplyMove(float Forward, float Strafe)
{
const AController* Controller = GetController();
if (!Controller)
{
return;
}
if (FMath::IsNearlyZero(Forward) && FMath::IsNearlyZero(Strafe))
{
return;
}
// Forward/right on the controller yaw keeps the capsule upright on slopes.
const FRotator YawRotation(0.0f, Controller->GetControlRotation().Yaw, 0.0f);
const FVector ForwardDir = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
const FVector RightDir = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
AddMovementInput(ForwardDir, Forward);
AddMovementInput(RightDir, Strafe);
}
void AGigLineCharacter::Look(const FInputActionValue& Value)
{
// Enhanced Input applies the mapping's scalar modifier, which already scales by
// DeltaTime; scaling again by DeltaTime would make the mouse frame-rate dependent.
const FVector2D Axis = Value.Get<FVector2D>();
AddControllerYawInput(Axis.X * LookSensitivity);
AddControllerPitchInput(Axis.Y * LookSensitivity * (bInvertLookY ? -1.0f : 1.0f));
}
void AGigLineCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// Speed-driven animation state: correct whether the player walked, was pushed,
// or fell, and independent of which input produced the movement.
if (bCameraTest)
{
UpdateCameraCollisionTest(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f);
}
WalkForever();
UpdateBodyAnimation();
}
void AGigLineCharacter::ToggleCamera()
{
// Both rigs exist from construction; the toggle only swaps which one is active
// and whether the body is drawn. Deactivating the other camera matters: with
// two active cameras the view is whichever was registered last.
bThirdPerson = !bThirdPerson;
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
// The ARM must be active too, not only the camera. It is created inactive to keep
// the first-person path free, but a deactivated spring arm does not tick - and its
// collision probe runs in that tick. Leaving it off was why the boom never
// shortened: with a 6 m wall spawned 1.6 m behind the pawn the camera still sat at
// its full 340 cm.
if (ThirdPersonArm)
{
ThirdPersonArm->SetActive(bThirdPerson);
}
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (HairMesh)
{
HairMesh->SetHiddenInGame(!bThirdPerson);
}
for (USkeletalMeshComponent* Piece : Clothing)
{
if (Piece)
{
Piece->SetHiddenInGame(!bThirdPerson);
}
}
if (bThirdPerson)
{
UpdateBodyAnimation();
}
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: camera %s"), bThirdPerson ? TEXT("third person") : TEXT("first person"));
}
}
void AGigLineCharacter::UpdateBodyAnimation()
{
if (!bThirdPerson || !GetMesh())
{
return;
}
const bool bMoving = GetVelocity().Size2D() > WalkAnimationThreshold;
UAnimSequence* Wanted = bMoving ? WalkAnimation.Get() : IdleAnimation.Get();
if (!Wanted)
{
static bool bWarnedMissingClip = false;
if (!bWarnedMissingClip)
{
bWarnedMissingClip = true;
UE_LOG(LogTemp, Warning,
TEXT("GigLine: body clip missing (idle=%s walk=%s) - body stays in bind pose"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
return;
}
if (Wanted != PlayingAnimation || GetMesh()->GetSingleNodeInstance() == nullptr)
{
// Only the mode setter is idempotent; calling SetAnimation unconditionally
// restarts the clip every frame, which freezes the body on frame 0 - that is
// why the walking capture showed a still figure with both legs together.
if (GetMesh()->GetAnimationMode() != EAnimationMode::AnimationSingleNode)
{
GetMesh()->SetAnimationMode(EAnimationMode::AnimationSingleNode);
}
if (Wanted != PlayingAnimation)
{
GetMesh()->SetAnimation(Wanted);
PlayingAnimation = Wanted;
GetMesh()->Play(true);
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: body clip %s, mode=%d"),
*Wanted->GetName(), static_cast<int32>(GetMesh()->GetAnimationMode()));
}
}
else
{
// Same clip but the instance was recreated after a mode change: resume it
// once rather than re-setting the animation.
GetMesh()->Play(true);
}
}
}
void AGigLineCharacter::ResetToStart()
{
// Reset is a level-restart in miniature: put the body back where it spawned,
// clear momentum so a fall does not carry over, and hand control back to the
// player if the pause menu was open.
if (UWorld* World = GetWorld())
{
SetActorLocationAndRotation(StartLocation, StartRotation, false, nullptr, ETeleportType::TeleportPhysics);
GetCharacterMovement()->StopMovementImmediately();
if (AController* Controller = GetController())
{
Controller->SetControlRotation(StartRotation);
}
UE_LOG(LogTemp, Log, TEXT("GigLine: reset to start"));
}
}
void AGigLineCharacter::TogglePause()
{
// Pause is a game-pause, not just an input mute: the world stops so physics and
// timers do not keep running behind the menu.
UWorld* World = GetWorld();
if (!World)
{
return;
}
bPaused = !bPaused;
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
PC->SetPause(bPaused);
PC->bShowMouseCursor = bPaused;
// The two input-mode structs are unrelated types, so branch rather than
// using a conditional expression.
if (bPaused)
{
PC->SetInputMode(FInputModeUIOnly());
}
else
{
PC->SetInputMode(FInputModeGameOnly());
}
}
UE_LOG(LogTemp, Log, TEXT("GigLine: paused=%s"), bPaused ? TEXT("true") : TEXT("false"));
}
void AGigLineCharacter::StartJump()
{
Jump();
}
void AGigLineCharacter::StopJump()
{
StopJumping();
}