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();
}
@@ -0,0 +1,86 @@
// First-person player character for the 0.1.0 walkable Hollywood block.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "InputActionValue.h"
#include "GigLineCharacter.generated.h"
class UCameraComponent;
class UInputAction;
class UInputMappingContext;
/**
* Walking first-person character.
*
* Movement is C++ and network-agnostic in the shapes it uses (Enhanced Input ->
* AddMovementInput), so a Blueprint subclass can retune values without touching
* code. Defaults are human-scale: 1.75 m capsule, 1.6 m eye height, 4 m/s walk.
*/
UCLASS()
class GIGLINE_API AGigLineCharacter : public ACharacter
{
GENERATED_BODY()
public:
AGigLineCharacter();
virtual void BeginPlay() override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
/** Loads the Enhanced Input assets from /Game/Input; called from BeginPlay. */
void LoadInputAssets();
/** Moves along the control yaw by the given forward/strafe amounts. */
void ApplyMove(float Forward, float Strafe);
/** Handles input actions to movement; assigned in C++ so the project runs with no assets. */
/** Forward/back and strafe are separate 1D actions, summed into one move. */
void MoveForward(const FInputActionValue& Value);
void MoveStrafe(const FInputActionValue& Value);
void MoveBack(const FInputActionValue& Value);
void MoveLeft(const FInputActionValue& Value);
void Look(const FInputActionValue& Value);
void StartJump();
void StopJump();
/** Mapping context for the gamepad and keyboard. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputMappingContext> MappingContext;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> MoveAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> MoveBackAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> StrafeAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> StrafeLeftAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> LookAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> JumpAction;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> FirstPersonCamera;
/** Degrees per second per unit of mouse input; defaults to Unreal's usual 45. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
float LookSensitivity = 45.0f;
/** Invert the pitch axis, for players who prefer it. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
bool bInvertLookY = false;
/** Walk speed in metres per second; converted to Unreal units on first use. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Movement")
float WalkSpeedMetresPerSecond = 4.0f;
};
@@ -1 +1,8 @@
#include "GigLineGameModeBase.h"
#include "GigLineCharacter.h"
AGigLineGameModeBase::AGigLineGameModeBase()
{
DefaultPawnClass = AGigLineCharacter::StaticClass();
}
@@ -6,11 +6,16 @@
/**
* Default game mode for the 0.1.0 walkable Hollywood block demo.
* First-person movement is Blueprint-driven on top of Enhanced Input;
* this class exists so the project has a valid primary game module.
*
* Sets the first-person character as the default pawn and spawns it at the start
* point, so the demo runs with no Blueprint wiring. Gameplay tuning that belongs to
* designers still lives in Blueprint subclasses on top of this.
*/
UCLASS()
class GIGLINE_API AGigLineGameModeBase : public AGameModeBase
{
GENERATED_BODY()
public:
AGigLineGameModeBase();
};