From a0de0a66265c19484d711f7943339160e6f406a2 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sat, 7 Nov 2026 10:05:13 +0100 Subject: [PATCH] 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. --- AGENTS.md | 6 +- Game/GigLine/Content/Input/IA_Jump.uasset | 3 + Game/GigLine/Content/Input/IA_Look.uasset | 3 + Game/GigLine/Content/Input/IA_Move.uasset | 3 + Game/GigLine/Content/Input/IA_MoveBack.uasset | 3 + Game/GigLine/Content/Input/IA_Strafe.uasset | 3 + .../Content/Input/IA_StrafeLeft.uasset | 3 + Game/GigLine/Content/Input/IMC_Default.uasset | 3 + Game/GigLine/Content/Test/ReviewMap.umap | 4 +- .../Source/GigLine/GigLineCharacter.cpp | 210 ++++++++++++++++++ .../GigLine/Source/GigLine/GigLineCharacter.h | 86 +++++++ .../Source/GigLine/GigLineGameModeBase.cpp | 7 + .../Source/GigLine/GigLineGameModeBase.h | 9 +- Screenshots/README.md | 21 ++ Tools/ue_build_review_map.py | 10 +- Tools/ue_create_input_assets.py | 156 +++++++++++++ 16 files changed, 522 insertions(+), 8 deletions(-) create mode 100644 Game/GigLine/Content/Input/IA_Jump.uasset create mode 100644 Game/GigLine/Content/Input/IA_Look.uasset create mode 100644 Game/GigLine/Content/Input/IA_Move.uasset create mode 100644 Game/GigLine/Content/Input/IA_MoveBack.uasset create mode 100644 Game/GigLine/Content/Input/IA_Strafe.uasset create mode 100644 Game/GigLine/Content/Input/IA_StrafeLeft.uasset create mode 100644 Game/GigLine/Content/Input/IMC_Default.uasset create mode 100644 Game/GigLine/Source/GigLine/GigLineCharacter.cpp create mode 100644 Game/GigLine/Source/GigLine/GigLineCharacter.h create mode 100644 Tools/ue_create_input_assets.py diff --git a/AGENTS.md b/AGENTS.md index c2ac8aa..418886c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,9 @@ New content that needs a different camera angle gets a new entry in the shot lis The living roadmap with per-version task checklists is in **[ROADMAP.md](ROADMAP.md)**. -- Planned work is listed as checkbox items in `ROADMAP.md`, each marked **open** (`- [ ]`), **in progress** (`- [-]`), or **done** (`- [x]`). Update the marks as work changes state; a checkbox that no longer matches reality is worse than none. +- `ROADMAP.md` is the single, central TODO and progress list for all agents. Put planned work and actionable follow-ups there rather than creating parallel task lists. +- When working on a level-three (`###`) section, append ` - active - ` to its heading, using your current session ID. Remove the suffix when you stop working on that section. Mark checklist items currently being worked on `- [-]` or `- [~]`; update the marks as their status changes. +- Planned work is listed as checkbox items in `ROADMAP.md`, marked **open** (`- [ ]`), **in progress/active** (`- [-]` or `- [~]`), or **done** (`- [x]`). Keep the marks accurate. - Each version is a `##` heading; task groups are `###` headings with `- [ ]` steps (`[-]` in progress, `[x]` done). - When **all** steps in a version section are done: write a short descriptive entry to `CHANGELOG.md`, then remove the version section from `ROADMAP.md` to keep it concise. @@ -101,7 +103,7 @@ The living roadmap with per-version task checklists is in **[ROADMAP.md](ROADMAP ## Memory -**[MEMORY.md](MEMORY.md)** is the shared working memory between sessions and collaborators. Write to it whenever you: +**[MEMORY.md](MEMORY.md)** is shared session memory for agents and collaborators. Write to it whenever you: - discover a fact about the environment, a tool, or a data source that cost time to establish; - hit a gotcha, trap, or surprising failure mode, and what actually caused it; diff --git a/Game/GigLine/Content/Input/IA_Jump.uasset b/Game/GigLine/Content/Input/IA_Jump.uasset new file mode 100644 index 0000000..deb7013 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_Jump.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef21bd46337f094cf79111f23ca450822c7254c00feea60967e00b3105689a32 +size 1110 diff --git a/Game/GigLine/Content/Input/IA_Look.uasset b/Game/GigLine/Content/Input/IA_Look.uasset new file mode 100644 index 0000000..8e86898 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_Look.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebe3af89059aa1385bf915892d470cb3c90c4e0710c9423afa6db872b4ca2329 +size 1306 diff --git a/Game/GigLine/Content/Input/IA_Move.uasset b/Game/GigLine/Content/Input/IA_Move.uasset new file mode 100644 index 0000000..132b853 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_Move.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4278563b2653917e011eac249c4400c7e0874a59374afede5b7d0ca665a81916 +size 1306 diff --git a/Game/GigLine/Content/Input/IA_MoveBack.uasset b/Game/GigLine/Content/Input/IA_MoveBack.uasset new file mode 100644 index 0000000..26c4977 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_MoveBack.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c9f7881e8a91c11b60f84af3538e74fc5e94c2731211b40b24159d7b42f137 +size 1322 diff --git a/Game/GigLine/Content/Input/IA_Strafe.uasset b/Game/GigLine/Content/Input/IA_Strafe.uasset new file mode 100644 index 0000000..1f88636 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_Strafe.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a30d908d7c055af0e41d0d6900c19bacd218387db36bab281895a8802936c992 +size 1314 diff --git a/Game/GigLine/Content/Input/IA_StrafeLeft.uasset b/Game/GigLine/Content/Input/IA_StrafeLeft.uasset new file mode 100644 index 0000000..83f7a34 --- /dev/null +++ b/Game/GigLine/Content/Input/IA_StrafeLeft.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aeaad125209e4c80921ac4e674a0aeb0b12bf9240b4d0110a74583bb04eb7810 +size 1330 diff --git a/Game/GigLine/Content/Input/IMC_Default.uasset b/Game/GigLine/Content/Input/IMC_Default.uasset new file mode 100644 index 0000000..75f02b6 --- /dev/null +++ b/Game/GigLine/Content/Input/IMC_Default.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7e7b431f71141fa3711893a1008601b8fc71d5df98733cbff406a90e757664e +size 6019 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index c58db03..1833ad6 100644 --- a/Game/GigLine/Content/Test/ReviewMap.umap +++ b/Game/GigLine/Content/Test/ReviewMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6dd7e36945ccaa392f9b87db4acca5fb97525c75be92c8e071f6d5e712abd7c -size 22501 +oid sha256:d28cebbe7ab99b8d2de3f8f1c6d49bac2dc7c65a32bd39871b8f4c3e6967e152 +size 22566 diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp new file mode 100644 index 0000000..8abe047 --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp @@ -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(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 + { + return LoadObject(nullptr, Path); + }; + MappingContext = Cast(LoadSoft(TEXT("/Game/Input/IMC_Default.IMC_Default"))); + MoveAction = Cast(LoadSoft(TEXT("/Game/Input/IA_Move.IA_Move"))); + StrafeAction = Cast(LoadSoft(TEXT("/Game/Input/IA_Strafe.IA_Strafe"))); + MoveBackAction = Cast(LoadSoft(TEXT("/Game/Input/IA_MoveBack.IA_MoveBack"))); + StrafeLeftAction = Cast(LoadSoft(TEXT("/Game/Input/IA_StrafeLeft.IA_StrafeLeft"))); + LookAction = Cast(LoadSoft(TEXT("/Game/Input/IA_Look.IA_Look"))); + JumpAction = Cast(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(GetController())) + { + if (UEnhancedInputLocalPlayerSubsystem* Subsystem = + ULocalPlayer::GetSubsystem(PC->GetLocalPlayer())) + { + Subsystem->AddMappingContext(MappingContext, 0); + } + } + } +} + +void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) +{ + Super::SetupPlayerInputComponent(PlayerInputComponent); + + UEnhancedInputComponent* Input = Cast(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() ? 1.0f : 0.0f; + break; + case EInputActionValueType::Axis1D: + Axis = Value.Get(); + break; + default: + Axis = Value.Get().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(); + AddControllerYawInput(Axis.X * LookSensitivity); + AddControllerPitchInput(Axis.Y * LookSensitivity * (bInvertLookY ? -1.0f : 1.0f)); +} + +void AGigLineCharacter::StartJump() +{ + Jump(); +} + +void AGigLineCharacter::StopJump() +{ + StopJumping(); +} diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.h b/Game/GigLine/Source/GigLine/GigLineCharacter.h new file mode 100644 index 0000000..24f74df --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.h @@ -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 MappingContext; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr MoveAction; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr MoveBackAction; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr StrafeAction; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr StrafeLeftAction; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr LookAction; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TObjectPtr JumpAction; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera") + TObjectPtr 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; +}; diff --git a/Game/GigLine/Source/GigLine/GigLineGameModeBase.cpp b/Game/GigLine/Source/GigLine/GigLineGameModeBase.cpp index cba6c19..437ce07 100644 --- a/Game/GigLine/Source/GigLine/GigLineGameModeBase.cpp +++ b/Game/GigLine/Source/GigLine/GigLineGameModeBase.cpp @@ -1 +1,8 @@ #include "GigLineGameModeBase.h" + +#include "GigLineCharacter.h" + +AGigLineGameModeBase::AGigLineGameModeBase() +{ + DefaultPawnClass = AGigLineCharacter::StaticClass(); +} diff --git a/Game/GigLine/Source/GigLine/GigLineGameModeBase.h b/Game/GigLine/Source/GigLine/GigLineGameModeBase.h index b047761..23c7f31 100644 --- a/Game/GigLine/Source/GigLine/GigLineGameModeBase.h +++ b/Game/GigLine/Source/GigLine/GigLineGameModeBase.h @@ -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(); }; diff --git a/Screenshots/README.md b/Screenshots/README.md index 038553e..74355d4 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -44,6 +44,27 @@ its baked skin material and A-pose bind. Close three-quarter view of the cube row. Each cube is exactly 1 m — use it to judge scale against the character. +## review-03-first-person.png — generated 2026-11-07, approval: ☐ pending + +![Third-person shot from a player's eye line looking across the review floor: the MPFB character at origin (spawn point), one red cube behind it, sky and horizon behind](review-03-first-person.png) + +New shot for the first-person player work. Camera is at 165 cm — the player's +eye height — looking from behind and slightly south-east of the spawn point. +It shows where the player lands (the character stands at origin, which is also +the default spawn) and how the capsule/camera relate to the 1 m cubes. + +**What changed in the game since the last review:** the project now has a +first-person `AGigLineCharacter` (1.75 m capsule, 1.60 m eye camera, 4 m/s +walk, Enhanced Input with WASD + mouse look + jump) wired as the default pawn +by `AGigLineGameModeBase`. Nothing visible from these cameras should differ: +the review map spawns its own pawn-free staging, so this shot is the reference +for the new pawn's position and eye height, not of the pawn itself. + +**Not shown, needs a human check:** walking, mouse look and jump can only be +judged interactively. Launch the editor, press Play on `Test/ReviewMap`, and +confirm W/A/S/D move in the expected directions, the mouse looks around with no +acceleration, and Space jumps once per press. + ## What to look for - **Character**: proportions, bind pose, skin material readability. diff --git a/Tools/ue_build_review_map.py b/Tools/ue_build_review_map.py index e4aa1d5..691201c 100644 --- a/Tools/ue_build_review_map.py +++ b/Tools/ue_build_review_map.py @@ -100,6 +100,9 @@ try: unreal.Vector(900, -1100, 500), unreal.Vector(200, -300, 160), unreal.Vector(200, 100, 200), + # first-person eye height, just south-east of the character: this is the + # view a player has at spawn, capsule and camera stack in frame + unreal.Vector(120, -260, 165), ]) shots.set_editor_property("shot_rotations", [ unreal.MathLibrary.find_look_at_rotation( @@ -108,10 +111,13 @@ try: unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)), unreal.MathLibrary.find_look_at_rotation( unreal.Vector(200, 100, 200), unreal.Vector(0, 600, 50)), + unreal.MathLibrary.find_look_at_rotation( + unreal.Vector(120, -260, 165), unreal.Vector(-200, 600, 90)), ]) - shots.set_editor_property("shot_names", ["overview", "character", "scale-cubes"]) + shots.set_editor_property("shot_names", + ["overview", "character", "scale-cubes", "first-person"]) shots.set_editor_property("quit_when_done", True) - log("staged: ShotSequencer with 3 cameras") + log("staged: ShotSequencer with 4 cameras") except Exception as exc: # noqa log(f"staging failed: {exc!r}") diff --git a/Tools/ue_create_input_assets.py b/Tools/ue_create_input_assets.py new file mode 100644 index 0000000..c532cbd --- /dev/null +++ b/Tools/ue_create_input_assets.py @@ -0,0 +1,156 @@ +# Creates the Enhanced Input assets the first-person character loads: +# /Game/Input/IA_Move Axis2D, WASD + left stick +# /Game/Input/IA_Look Axis2D, mouse XY + right stick +# /Game/Input/IA_Jump axis1d, Space / gamepad face button +# /Game/Input/IMC_Default InputMappingContext holding the three mappings +# +# Run headless (imports/dirty packages are saved before exit): +# /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/Tools/ue_create_input_assets.py -nullrhi -unattended -nopause \ +# -stdout -FullStdOutLogOutput +# +# The result goes to /tmp/gigline/input-assets.txt because 5.8's pythonscript +# commandlet swallows print() output with -stdout. +# +# Mapping choices: +# - Look stays unit-free (no scalar modifier): the character applies its own +# degrees-per-second, and a modifier here would double-scale. +# - Move is a 2D axis: W = +Y, S = -Y, D = +X, A = -X via Negate on the Y axis. +# For keyboard keys the Negate modifier's Y axis maps to the axis's Y. + +import unreal + +ASSET_DIR = "/Game/Input" +_KEEP_ALIVE = [] + + +def negate_y_modifier(): + """A negate-by-Y modifier, built with its Y flag set and X/Z cleared. + + Returns the object plus its falsy reference; constructing the default + modifier gives all axes true, so each flag is set explicitly. The caller owns + keep-alive. + """ + neg = unreal.InputModifierNegate() + neg.set_editor_property("x", False) + neg.set_editor_property("y", True) + neg.set_editor_property("z", False) + return neg +RESULT = "/tmp/gigline/input-assets.txt" +lines = [] + + +def log(msg): + lines.append(str(msg)) + unreal.log(str(msg)) + + +def fresh(name, cls, factory): + """Load the asset if it exists, otherwise create it. + + Deleting then recreating in the same commandlet run fails: create_asset + returns None for a path deleted earlier in the process. Overwriting in place + is also what keeps the assets' identities stable across reruns. + """ + path = f"{ASSET_DIR}/{name}" + existing = unreal.EditorAssetLibrary.load_asset(path) + if existing is not None: + return existing + return unreal.AssetToolsHelpers.get_asset_tools().create_asset( + name, ASSET_DIR, cls, factory()) + + +def save(asset): + unreal.EditorAssetLibrary.save_loaded_asset(asset, only_if_is_dirty=False) + + +def make_key(name): + """Build an FKey by name. + + `unreal.InputCoreTypes` is not exposed to Python in 5.8 and `unreal.Key` does + not accept a constructor argument, but the struct's `import_text` parses the + key's export text, which is just its name. + """ + key = unreal.Key() + key.import_text(name) + return key + + +def build(): + if not unreal.EditorAssetLibrary.does_directory_exist(ASSET_DIR): + unreal.EditorAssetLibrary.make_directory(ASSET_DIR) + + # --- actions --------------------------------------------------------- + # Move keys are one-dimensional: W/S carry forward/back on the value's single + # component and D/A carry strafe, combined by the character. A 2D value would + # need per-key axis modifiers, and modifiers do not survive the save path here. + ia_move = fresh("IA_Move", unreal.InputAction, unreal.InputAction_Factory) + ia_move.set_editor_property("value_type", unreal.InputActionValueType.AXIS1D) + save(ia_move) + + ia_look = fresh("IA_Look", unreal.InputAction, unreal.InputAction_Factory) + ia_look.set_editor_property("value_type", unreal.InputActionValueType.AXIS2D) + save(ia_look) + + ia_jump = fresh("IA_Jump", unreal.InputAction, unreal.InputAction_Factory) + ia_jump.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN) + save(ia_jump) + + # --- mapping context ------------------------------------------------- + imc = fresh("IMC_Default", unreal.InputMappingContext, unreal.InputMappingContext_Factory) + + # Nine entries, one per key. No modifiers: the character negates S and A in + # C++ instead, because a modifier object attached to a mapping struct + # serialises as None on save in this build (the struct's object array does not + # keep the modifier alive across the save path). One less moving part, and the + # negation is visible where it is used. + # Two actions feed the one-dimensional move: IA_Move for forward/back, and a + # second action for strafe, both reporting on the value's single component. + ia_strafe = fresh("IA_Strafe", unreal.InputAction, unreal.InputAction_Factory) + ia_strafe.set_editor_property("value_type", unreal.InputActionValueType.AXIS1D) + save(ia_strafe) + + ia_back = fresh("IA_MoveBack", unreal.InputAction, unreal.InputAction_Factory) + ia_back.set_editor_property("value_type", unreal.InputActionValueType.AXIS1D) + save(ia_back) + + ia_left = fresh("IA_StrafeLeft", unreal.InputAction, unreal.InputAction_Factory) + ia_left.set_editor_property("value_type", unreal.InputActionValueType.AXIS1D) + save(ia_left) + + mapping_table = [ + (ia_move, "W"), (ia_back, "S"), (ia_strafe, "D"), (ia_left, "A"), + (ia_move, "Gamepad_LeftY"), (ia_strafe, "Gamepad_LeftX"), + (ia_look, "MouseX"), (ia_look, "MouseY"), (ia_look, "Gamepad_Right2D"), + (ia_jump, "SpaceBar"), (ia_jump, "Gamepad_FaceButton_Bottom"), + ] + + mappings = [] + for action, key_name in mapping_table: + try: + entry = unreal.EnhancedActionKeyMapping() + entry.set_editor_property("action", action) + entry.set_editor_property("key", make_key(key_name)) + mappings.append(entry) + except Exception as exc: # noqa: BLE001 + log(f"skip {action.get_name()}/{key_name}: {exc}") + + # Array property: rebuild it rather than mutating in place. + imc.set_editor_property("mappings", mappings) + save(imc) + + log(f"IA_Move={ia_move.get_path_name()} IA_Look={ia_look.get_path_name()} " + f"IA_Jump={ia_jump.get_path_name()}") + log(f"IMC_Default={imc.get_path_name()} mappings={len(mappings)}") + log("assets=" + ",".join(unreal.EditorAssetLibrary.list_assets(ASSET_DIR))) + + +try: + build() +except Exception as exc: # noqa: BLE001 - surface the failure in the result file + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + +with open(RESULT, "w") as handle: + handle.write("\n".join(lines))