Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim Co-authored-by: Zwitschi <zwitschi82@gmail.com>
701 lines
25 KiB
C++
701 lines
25 KiB
C++
#include "GigLineCharacter.h"
|
|
|
|
#include "Camera/CameraComponent.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 "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 = false;
|
|
|
|
// 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;
|
|
|
|
// An eye-level mesh is not needed for 0.1.0; the body is a capsule with a camera.
|
|
GetMesh()->SetHiddenInGame(true);
|
|
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
|
|
|
// 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> 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;
|
|
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();
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
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 (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::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();
|
|
}
|