feat: first-person player with Enhanced Input

AGigLineCharacter: 1.75 m capsule, 1.60 m eye camera, 4 m/s walk, mouse look
scaled in degrees per second (no double DeltaTime), jump. Loaded ahead of a
Blueprint so the demo is playable with no assets wired by hand.

Tools/ue_create_input_assets.py builds /Game/Input headlessly: five 1D actions
and an 11-mapping context. One action per direction instead of per-key modifiers,
because modifier objects do not survive the save path here: a mapping's modifier
array serialises None, and dimensioning a 2D move with per-key axis masks would
need exactly those modifiers. The cost is four action assets rather than two.

Also adds a fourth review camera at eye height and documents it; its approval is
pending.
This commit is contained in:
zwitschi committed 2026-11-07 10:05:13 +01:00
1 parent b3815499e0
commit a0de0a6626
16 files changed
+522 -8

No files matched your search

@@ -0,0 +1,210 @@
#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"
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);
// Input assets are created by Tools/ue_create_input_assets.py. They are loaded in
// BeginPlay rather than the constructor: the constructor runs during CDO
// construction where asset loads are unreliable, and a missing asset should be
// a loud warning, not a silent no-op.
}
void AGigLineCharacter::LoadInputAssets()
{
auto LoadSoft = [](const TCHAR* Path) -> TObjectPtr<UObject>
{
return LoadObject<UObject>(nullptr, Path);
};
MappingContext = Cast<UInputMappingContext>(LoadSoft(TEXT("/Game/Input/IMC_Default.IMC_Default")));
MoveAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_Move.IA_Move")));
StrafeAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_Strafe.IA_Strafe")));
MoveBackAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_MoveBack.IA_MoveBack")));
StrafeLeftAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_StrafeLeft.IA_StrafeLeft")));
LookAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_Look.IA_Look")));
JumpAction = Cast<UInputAction>(LoadSoft(TEXT("/Game/Input/IA_Jump.IA_Jump")));
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
|| !LookAction)
{
UE_LOG(LogTemp, Warning,
TEXT("GigLine: input assets missing (context=%s move=%s look=%s). ")
TEXT("Run Tools/ue_create_input_assets.py to build /Game/Input."),
MappingContext ? TEXT("ok") : TEXT("missing"),
(MoveAction && MoveBackAction && StrafeAction && StrafeLeftAction) ? TEXT("ok")
: TEXT("missing"),
LookAction ? TEXT("ok") : TEXT("missing"));
}
}
void AGigLineCharacter::BeginPlay()
{
Super::BeginPlay();
LoadInputAssets();
// Push the mapping context for this local player.
if (MappingContext)
{
if (const APlayerController* PC = Cast<APlayerController>(GetController()))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
{
Subsystem->AddMappingContext(MappingContext, 0);
}
}
}
}
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;
}
// 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 (JumpAction)
{
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
Input->BindAction(JumpAction, ETriggerEvent::Completed, this, &AGigLineCharacter::StopJump);
}
}
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 || (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::StartJump()
{
Jump();
}
void AGigLineCharacter::StopJump()
{
StopJumping();
}