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.
1130 lines
41 KiB
C++
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();
|
|
}
|