0.2.3 is about camera clipping, and nothing had ever exercised the spring arm's collision test. This adds a harness: find the nearest wall from the spawn, stand the pawn 70 cm off it facing away, wait for the boom, then report the arm length, whether the boom ends inside geometry, and a back-trace from camera to head. Inconclusive, and the setup is the suspect rather than the engine: every run reports the arm at its resting 340 cm, but BOTH traces come back clear, so nothing was between the pawn and the boom at all. The placement is wrong, not necessarily the collision. Three wrong turns recorded in TODO.md because each cost a run: the probe accepted the floor as a wall, then faced the pawn into the wall by negating the impact normal (which points away from it), then set actor rotation only - which a possessed pawn discards in favour of control rotation. Next: look at the placement screenshot before trusting the measurement.
1118 lines
40 KiB
C++
1118 lines
40 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 "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.
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
// A dedicated harness rather than a step inside -qaselftest: the QA timeline is
|
|
// tightly timed and an added teleport perturbed the reset check by 105 cm.
|
|
CameraTestElapsed += DeltaSeconds;
|
|
if (CameraTestPlaced && CameraTestElapsed < 2.0f)
|
|
{
|
|
return;
|
|
}
|
|
if (!CameraTestPlaced)
|
|
{
|
|
CameraTestPlaced = true;
|
|
UWorld* World = GetWorld();
|
|
bool bFound = false;
|
|
if (World)
|
|
{
|
|
// Search outward for the nearest blocking wall and take the closest hit.
|
|
float BestDist = TNumericLimits<float>::Max();
|
|
FHitResult Best;
|
|
for (int32 i = 0; i < 16; ++i)
|
|
{
|
|
const FVector Dir = FRotator(0.f, i * 22.5f, 0.f).Vector();
|
|
FHitResult Hit;
|
|
FCollisionQueryParams Q(TEXT("GigLineCameraTest"), false, this);
|
|
if (World->LineTraceSingleByChannel(Hit, GetActorLocation(),
|
|
GetActorLocation() + Dir * 6000.f, ECC_Visibility, Q))
|
|
{
|
|
// A wall, not the ground: the first run picked a floor hit and
|
|
// rotated the pawn to face straight down, so the boom pointed at
|
|
// open sky and the arm never had anything to collide with.
|
|
if (FMath::Abs(Hit.ImpactNormal.Z) > 0.5f)
|
|
{
|
|
continue;
|
|
}
|
|
if (Hit.Distance < BestDist)
|
|
{
|
|
BestDist = Hit.Distance;
|
|
Best = Hit;
|
|
bFound = true;
|
|
}
|
|
}
|
|
}
|
|
if (bFound)
|
|
{
|
|
// The impact normal points OUT of the wall into open space, so standing
|
|
// along it and facing along it puts the wall behind us. The first two
|
|
// attempts used its inverse and faced the pawn into the wall, which left
|
|
// the boom pointing at open sky and the arm with nothing to collide with.
|
|
SetActorLocation(Best.ImpactPoint + Best.ImpactNormal * 70.f);
|
|
LastWallNormal = Best.ImpactNormal;
|
|
const FRotator Facing = Best.ImpactNormal.Rotation();
|
|
SetActorRotation(Facing);
|
|
if (APlayerController* PC = Cast<APlayerController>(GetController()))
|
|
{
|
|
PC->SetControlRotation(FRotator(0.f, Facing.Yaw, 0.f));
|
|
}
|
|
}
|
|
}
|
|
if (!bThirdPerson)
|
|
{
|
|
ToggleCamera();
|
|
}
|
|
CameraTestPlaced = true;
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine CAMTEST placed wall=%d fwd=(%.2f,%.2f) normal=(%.2f,%.2f) collisionTest=%d"),
|
|
bFound ? 1 : 0, GetActorForwardVector().X, GetActorForwardVector().Y,
|
|
LastWallNormal.X, LastWallNormal.Y,
|
|
ThirdPersonArm && ThirdPersonArm->bDoCollisionTest ? 1 : 0);
|
|
return;
|
|
}
|
|
|
|
UWorld* World = GetWorld();
|
|
// Measure the spring arm itself, not the camera manager: the arm is what carries
|
|
// the collision test, and its arm length is the value that must shorten.
|
|
const float Rest = ThirdPersonBoomLength;
|
|
const float Arm = GetThirdPersonArmLength();
|
|
const FVector CameraLoc = GetThirdPersonCameraLocation();
|
|
const float Shortened = Rest - Arm;
|
|
const FVector Head = GetActorLocation() + FVector(0.f, 0.f, 60.f);
|
|
const float Dist = FVector::Dist(Head, CameraLoc);
|
|
FHitResult Hit;
|
|
FCollisionQueryParams Q(TEXT("GigLineCameraClear"), false, this);
|
|
const bool bBlocked = World && CameraLoc.SizeSquared() > 1.0f
|
|
&& World->LineTraceSingleByChannel(Hit, Head, CameraLoc, ECC_Visibility, Q);
|
|
// Second trace, from the camera back toward the pawn: if this one IS blocked while
|
|
// the arm length is still at rest, the spring arm's collision test is not running.
|
|
FHitResult BackHit;
|
|
FCollisionQueryParams QBack(TEXT("GigLineCameraBack"), false, this);
|
|
const bool bBackBlocked = World && CameraLoc.SizeSquared() > 1.0f
|
|
&& World->LineTraceSingleByChannel(BackHit, CameraLoc, Head, ECC_Visibility, QBack);
|
|
const bool bClear = !bBlocked || Hit.Distance > Dist - 25.f;
|
|
const bool bShortened = Shortened > 30.f;
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine CAMTEST arm %.0f of %.0f cm (shortened %.0f), cam %.0f cm from head, "
|
|
"blocked=%d -> %s"),
|
|
Arm, Rest, Shortened, Dist, bBlocked ? 1 : 0,
|
|
(bClear || bShortened) ? TEXT("PASS") : TEXT("FAIL"));
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("GigLine CAMTEST back-trace blocked=%d dist=%.0f (arm rest %.0f)"),
|
|
bBackBlocked ? 1 : 0, bBackBlocked ? BackHit.Distance : -1.f, Rest);
|
|
FScreenshotRequest::RequestScreenshot(TEXT("review-demo-camera-collision"), true, false);
|
|
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);
|
|
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();
|
|
}
|