The road was never mis-modelled: a 279 m strip imports with ONE auto-generated convex hull, and a convex hull's top is the strip's maximum height. A downward trace therefore landed at 366 cm - a flat lid about 2 m above the visible road, which sits at roughly 152 cm there. That single fact explains every failed sidewalk fix, the 'road above its own sidewalk' reading, and why the player always ended up on Road_Hollywood. Slicing the road the way the sidewalks already were fixes it, and the cross-section is now continuous: Terrain_DEM 154 Sidewalk_North_07 158 Road_Hollywood_07 158 (was 366) Two changes had to land together: the road exported in slices, and the map stopped staging the single Road_Hollywood asset - which still sat on top of the slices and won every trace until removed. All 23 captures regenerated; the player and the car both stand on a continuous ground plane.
1245 lines
45 KiB
C++
1245 lines
45 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, one line per channel: the player stands at 561 while the
|
|
// Visibility trace reports ground at 366, so at least one channel is seeing a different
|
|
// surface than the one under foot. This names the actor and the Z for each channel.
|
|
// Foot-contact probe runs from Tick three seconds after spawn — see LogFootProbes().
|
|
// `-probes` runs the ground probes and then quits: the measurements fire three seconds
|
|
// after spawn, so a run needs seconds, not minutes. Without the switch the game keeps
|
|
// running for the capture pipeline.
|
|
bProbeAndQuit = FParse::Param(FCommandLine::Get(), TEXT("probes"));
|
|
FootProbeDelay = 3.0f;
|
|
|
|
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::LogFootProbes()
|
|
{
|
|
UWorld* World = GetWorld();
|
|
if (!World)
|
|
{
|
|
return;
|
|
}
|
|
static const TCHAR* Names[] = {TEXT("Visibility"), TEXT("WorldStatic"), TEXT("Pawn")};
|
|
static const ECollisionChannel Channels[] = {ECC_Visibility, ECC_WorldStatic, ECC_Pawn};
|
|
const float CapsuleHalf = GetCapsuleComponent()
|
|
? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() : 0.f;
|
|
// Trace from well ABOVE the head down: the first hit is the topmost surface, i.e. the
|
|
// pavement the player can see. Tracing up from the feet finds whatever is below them,
|
|
// which is how "ground 366" got reported while the pavement on screen was higher.
|
|
if (UWorld* WorldTop = GetWorld())
|
|
{
|
|
FHitResult Top;
|
|
FCollisionQueryParams QTop(TEXT("GigLineTop"), false, this);
|
|
const FVector From = GetActorLocation() + FVector(0.f, 0.f, 3000.f);
|
|
const bool bTop = WorldTop->LineTraceSingleByChannel(Top, From,
|
|
From - FVector(0.f, 0.f, 6000.f), ECC_WorldStatic, QTop);
|
|
FString MeshName = TEXT("none");
|
|
if (bTop)
|
|
{
|
|
if (const UStaticMeshComponent* HitMesh = Cast<UStaticMeshComponent>(Top.GetComponent()))
|
|
{
|
|
if (const UStaticMesh* Asset = HitMesh->GetStaticMesh())
|
|
{
|
|
MeshName = Asset->GetName();
|
|
}
|
|
}
|
|
}
|
|
const float HitDist2D = bTop
|
|
? FVector::Dist2D(FVector(Top.ImpactPoint.X, Top.ImpactPoint.Y, 0.0),
|
|
FVector(GetActorLocation().X, GetActorLocation().Y, 0.0))
|
|
: -1.f;
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine top surface under player: hit=%d Z=%.0f xy(dist %.0f) actor=%s mesh=%s (actor Z %.0f)"),
|
|
bTop ? 1 : 0, bTop ? Top.ImpactPoint.Z : -9999.f, HitDist2D,
|
|
bTop && Top.GetActor() ? *Top.GetActor()->GetName() : TEXT("none"),
|
|
*MeshName, GetActorLocation().Z);
|
|
}
|
|
|
|
const FVector Feet = GetActorLocation() - FVector(0.f, 0.f, CapsuleHalf);
|
|
// Where is the body's lowest point relative to the capsule's? A mesh whose feet sit above
|
|
// the capsule bottom looks like a floating character even when the capsule is grounded.
|
|
if (const USkeletalMeshComponent* Body = GetMesh())
|
|
{
|
|
const FBoxSphereBounds B = Body->Bounds;
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine body: mesh bottom %.0f, capsule bottom %.0f, body above capsule %.0f cm"),
|
|
(B.Origin - B.BoxExtent).Z, Feet.Z, (B.Origin - B.BoxExtent).Z - Feet.Z);
|
|
}
|
|
for (int32 Index = 0; Index < 3; ++Index)
|
|
{
|
|
FHitResult Hit;
|
|
FCollisionQueryParams Q(TEXT("GigLineFoot"), false, this);
|
|
const bool bHit = World->LineTraceSingleByChannel(Hit, Feet + FVector(0.f, 0.f, 5.f),
|
|
Feet - FVector(0.f, 0.f, 1500.f), Channels[Index], Q);
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine foot %s after settling: actor Z %.0f, feet %.0f, hit=%d Z=%.0f actor=%s"),
|
|
Names[Index], GetActorLocation().Z, Feet.Z, bHit ? 1 : 0,
|
|
bHit ? Hit.ImpactPoint.Z : -9999.f,
|
|
bHit && Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("none"));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (FootProbeDelay > 0.f)
|
|
{
|
|
FootProbeDelay -= GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f;
|
|
if (FootProbeDelay <= 0.f)
|
|
{
|
|
LogFootProbes();
|
|
if (FParse::Param(FCommandLine::Get(), TEXT("profile")) && GetWorld())
|
|
{
|
|
// Ground profile along the street: one downward trace every 20 m at the
|
|
// player's Y, naming the surface each time. Comparing the road's profile
|
|
// with the sidewalks' is what the per-mesh bounding boxes could not do.
|
|
const FVector Base = GetActorLocation();
|
|
for (int32 Step = 0; Step < 14; ++Step)
|
|
{
|
|
// Sweep ACROSS the street, not along it: the cross-section is where the
|
|
// sidewalk and road heights meet, and where they disagreed.
|
|
const float X = Base.X;
|
|
const float YOffset = (Step - 7) * 400.0f;
|
|
FHitResult Hit;
|
|
FCollisionQueryParams Q(TEXT("GigLineProfile"), false, this);
|
|
const FVector From(X, Base.Y + YOffset, Base.Z + 2000.0f);
|
|
const bool bHit = GetWorld()->LineTraceSingleByChannel(Hit, From,
|
|
From - FVector(0.f, 0.f, 4000.f), ECC_WorldStatic, Q);
|
|
FString MeshName = TEXT("none");
|
|
if (bHit)
|
|
{
|
|
if (const UStaticMeshComponent* HitMesh =
|
|
Cast<UStaticMeshComponent>(Hit.GetComponent()))
|
|
{
|
|
if (const UStaticMesh* Asset = HitMesh->GetStaticMesh())
|
|
{
|
|
MeshName = Asset->GetName();
|
|
}
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Warning, TEXT("GigLine profile y=%.0f z=%.0f mesh=%s"),
|
|
Base.Y + YOffset, bHit ? Hit.ImpactPoint.Z : -9999.f, *MeshName);
|
|
}
|
|
}
|
|
if (bProbeAndQuit && GetWorld())
|
|
{
|
|
// One frame later, so the log lines are flushed before the process exits.
|
|
GetWorld()->GetTimerManager().SetTimerForNextTick([this]()
|
|
{
|
|
if (APlayerController* PC = Cast<APlayerController>(GetController()))
|
|
{
|
|
PC->ConsoleCommand(TEXT("quit"));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|