diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 2042922..0d62653 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -371,7 +371,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Decide first-person, third-person, or hybrid camera. Decision: hybrid camera with third person as default and optional first-person toggle. - [x] Implement first/third-person camera toggle. - [x] Implement movement. -- [ ] Implement sprinting. +- [x] Implement sprinting. - [ ] Define real-world baseline walking speed. - [ ] Define real-world baseline running speed. - [ ] Connect movement speed to age, condition, strength, endurance, hunger, thirst, injury, carried weight, and terrain. @@ -1465,4 +1465,4 @@ Earliest incomplete foundation items: Immediate next item: -- [ ] Implement sprinting. +- [ ] Define real-world baseline walking speed. diff --git a/Content/Input/Actions/IA_Sprint.uasset b/Content/Input/Actions/IA_Sprint.uasset new file mode 100644 index 0000000..deaf266 Binary files /dev/null and b/Content/Input/Actions/IA_Sprint.uasset differ diff --git a/Content/Input/IMC_Default.uasset b/Content/Input/IMC_Default.uasset index de77680..d1886bc 100644 Binary files a/Content/Input/IMC_Default.uasset and b/Content/Input/IMC_Default.uasset differ diff --git a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset index bdd5c22..4f1c10c 100644 Binary files a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset and b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset differ diff --git a/Scripts/setup_sprint_input.py b/Scripts/setup_sprint_input.py new file mode 100644 index 0000000..3bea140 --- /dev/null +++ b/Scripts/setup_sprint_input.py @@ -0,0 +1,88 @@ +import unreal + + +def load(path): + asset = unreal.EditorAssetLibrary.load_asset(path) + if not asset: + raise RuntimeError(f"Could not load {path}") + return asset + + +def create_input_action(path): + existing = unreal.EditorAssetLibrary.load_asset(path) + if existing: + return existing + + template_path = "/Game/Input/Actions/IA_Interact" + template = unreal.EditorAssetLibrary.load_asset(template_path) + if not template: + template_path = "/Game/Input/Actions/IA_Jump" + template = unreal.EditorAssetLibrary.load_asset(template_path) + if not template: + raise RuntimeError("Could not load an input action template") + + action = unreal.EditorAssetLibrary.duplicate_asset(template_path, path) + if not action: + raise RuntimeError(f"Could not create {path}") + return action + + +def set_boolean_value_type(action): + action.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN) + try: + action.set_editor_property("triggers", []) + except Exception as exc: + unreal.log_warning(f"Could not clear sprint triggers; keeping template defaults: {exc}") + unreal.EditorAssetLibrary.save_loaded_asset(action) + + +def mapping_exists(context, action, key_name): + mapping_data = context.get_editor_property("default_key_mappings") + for mapping in list(mapping_data.get_editor_property("mappings")): + mapping_key = mapping.get_editor_property("key") + if ( + mapping.get_editor_property("action") == action + and str(mapping_key.get_editor_property("key_name")) == key_name + ): + return True + return False + + +def map_key(context, action, key_name): + if mapping_exists(context, action, key_name): + unreal.log(f"Mapping already exists: {action.get_name()} -> {key_name}") + return + + key = unreal.Key() + key.set_editor_property("key_name", key_name) + + mapping_data = context.get_editor_property("default_key_mappings") + mappings = list(mapping_data.get_editor_property("mappings")) + new_mapping = unreal.EnhancedActionKeyMapping() + new_mapping.set_editor_property("action", action) + new_mapping.set_editor_property("key", key) + mappings.append(new_mapping) + mapping_data.set_editor_property("mappings", mappings) + context.set_editor_property("default_key_mappings", mapping_data) + + unreal.log(f"Added mapping: {action.get_name()} -> {key_name}") + + +def main(): + sprint_action = create_input_action("/Game/Input/Actions/IA_Sprint") + set_boolean_value_type(sprint_action) + + context = load("/Game/Input/IMC_Default") + map_key(context, sprint_action, "LeftShift") + map_key(context, sprint_action, "Gamepad_LeftThumbstick") + unreal.EditorAssetLibrary.save_loaded_asset(context) + + character_bp = load("/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter") + character_cdo = unreal.get_default_object(character_bp.generated_class()) + character_cdo.set_editor_property("SprintAction", sprint_action) + unreal.EditorAssetLibrary.save_loaded_asset(character_bp) + + unreal.log("Agrarian sprint input setup complete.") + + +main() diff --git a/Scripts/verify_sprint_input.py b/Scripts/verify_sprint_input.py new file mode 100644 index 0000000..5aaef7a --- /dev/null +++ b/Scripts/verify_sprint_input.py @@ -0,0 +1,44 @@ +import unreal + + +def load(path): + asset = unreal.EditorAssetLibrary.load_asset(path) + if not asset: + raise RuntimeError(f"Could not load {path}") + return asset + + +def mapping_found(context, action, key_name): + mapping_data = context.get_editor_property("default_key_mappings") + for mapping in list(mapping_data.get_editor_property("mappings")): + mapping_key = mapping.get_editor_property("key") + if ( + mapping.get_editor_property("action") == action + and str(mapping_key.get_editor_property("key_name")) == key_name + ): + return True + return False + + +def main(): + action = load("/Game/Input/Actions/IA_Sprint") + context = load("/Game/Input/IMC_Default") + character_bp = load("/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter") + character_cdo = unreal.get_default_object(character_bp.generated_class()) + + missing = [] + for key_name in ["LeftShift", "Gamepad_LeftThumbstick"]: + if not mapping_found(context, action, key_name): + missing.append(f"missing mapping {key_name}") + + assigned_action = character_cdo.get_editor_property("SprintAction") + if assigned_action != action: + missing.append("BP_ThirdPersonCharacter SprintAction is not IA_Sprint") + + if missing: + raise RuntimeError("Sprint input verification failed: " + "; ".join(missing)) + + unreal.log("Agrarian sprint input verification complete.") + + +main() diff --git a/Source/AgrarianGame/AgrarianGameCharacter.cpp b/Source/AgrarianGame/AgrarianGameCharacter.cpp index 1d423a8..b359142 100644 --- a/Source/AgrarianGame/AgrarianGameCharacter.cpp +++ b/Source/AgrarianGame/AgrarianGameCharacter.cpp @@ -16,9 +16,12 @@ #include "EnhancedInputSubsystems.h" #include "InputActionValue.h" #include "AgrarianGame.h" +#include "Net/UnrealNetwork.h" AAgrarianGameCharacter::AAgrarianGameCharacter() { + PrimaryActorTick.bCanEverTick = true; + // Set size for collision capsule GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f); @@ -35,7 +38,7 @@ AAgrarianGameCharacter::AAgrarianGameCharacter() // instead of recompiling to adjust them GetCharacterMovement()->JumpZVelocity = 500.f; GetCharacterMovement()->AirControl = 0.35f; - GetCharacterMovement()->MaxWalkSpeed = 500.f; + GetCharacterMovement()->MaxWalkSpeed = WalkSpeed; GetCharacterMovement()->MinAnalogWalkSpeed = 20.f; GetCharacterMovement()->BrakingDecelerationWalking = 2000.f; GetCharacterMovement()->BrakingDecelerationFalling = 1500.0f; @@ -60,6 +63,37 @@ AAgrarianGameCharacter::AAgrarianGameCharacter() // are set in the derived blueprint asset named ThirdPersonCharacter (to avoid direct content references in C++) } +void AAgrarianGameCharacter::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + if (!HasAuthority() || !bWantsToSprint) + { + return; + } + + if (!CanSprint()) + { + SetWantsToSprint(false); + return; + } + + if (GetVelocity().SizeSquared2D() > KINDA_SMALL_NUMBER && SurvivalComponent) + { + SurvivalComponent->SpendStamina(SprintStaminaCostPerSecond * DeltaSeconds); + if (!CanSprint()) + { + SetWantsToSprint(false); + } + } +} + +void AAgrarianGameCharacter::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + DOREPLIFETIME(AAgrarianGameCharacter, bWantsToSprint); +} + void AAgrarianGameCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) { // Set up action bindings @@ -81,6 +115,13 @@ void AAgrarianGameCharacter::SetupPlayerInputComponent(UInputComponent* PlayerIn EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::Interact); } + if (SprintAction) + { + EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::StartSprint); + EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &AAgrarianGameCharacter::StopSprint); + EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Canceled, this, &AAgrarianGameCharacter::StopSprint); + } + if (ToggleCameraAction) { EnhancedInputComponent->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::ToggleCameraPerspective); @@ -115,6 +156,16 @@ void AAgrarianGameCharacter::Interact() TryInteract(); } +void AAgrarianGameCharacter::StartSprint() +{ + SetWantsToSprint(true); +} + +void AAgrarianGameCharacter::StopSprint() +{ + SetWantsToSprint(false); +} + void AAgrarianGameCharacter::ToggleCameraPerspective() { SetFirstPersonCamera(!bFirstPersonCamera); @@ -155,6 +206,48 @@ void AAgrarianGameCharacter::SetFirstPersonCamera(bool bEnableFirstPerson) } } +void AAgrarianGameCharacter::SetWantsToSprint(bool bNewWantsToSprint) +{ + const bool bAllowedSprintIntent = bNewWantsToSprint && CanSprint(); + if (bWantsToSprint == bAllowedSprintIntent) + { + return; + } + + bWantsToSprint = bAllowedSprintIntent; + ApplyMovementSpeed(); + + if (!HasAuthority()) + { + ServerSetWantsToSprint(bAllowedSprintIntent); + } +} + +bool AAgrarianGameCharacter::CanSprint() const +{ + return SurvivalComponent + && SurvivalComponent->IsAlive() + && SurvivalComponent->Survival.Stamina > MinSprintStamina; +} + +bool AAgrarianGameCharacter::IsSprinting() const +{ + return bWantsToSprint && CanSprint(); +} + +void AAgrarianGameCharacter::ApplyMovementSpeed() +{ + if (UCharacterMovementComponent* MovementComponent = GetCharacterMovement()) + { + MovementComponent->MaxWalkSpeed = IsSprinting() ? SprintSpeed : WalkSpeed; + } +} + +void AAgrarianGameCharacter::OnRep_SprintState() +{ + ApplyMovementSpeed(); +} + void AAgrarianGameCharacter::DoMove(float Right, float Forward) { if (GetController() != nullptr) @@ -240,6 +333,11 @@ void AAgrarianGameCharacter::TryInteract() } } +void AAgrarianGameCharacter::ServerSetWantsToSprint_Implementation(bool bNewWantsToSprint) +{ + SetWantsToSprint(bNewWantsToSprint); +} + void AAgrarianGameCharacter::ServerInteract_Implementation(AActor* TargetActor) { if (!TargetActor || !TargetActor->GetClass()->ImplementsInterface(UAgrarianInteractable::StaticClass())) diff --git a/Source/AgrarianGame/AgrarianGameCharacter.h b/Source/AgrarianGame/AgrarianGameCharacter.h index 346d3cb..d94fe23 100644 --- a/Source/AgrarianGame/AgrarianGameCharacter.h +++ b/Source/AgrarianGame/AgrarianGameCharacter.h @@ -73,6 +73,10 @@ protected: UPROPERTY(EditAnywhere, Category="Input") UInputAction* InteractAction; + /** Hold to sprint while stamina allows it. */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* SprintAction; + /** Toggle between third-person and first-person camera views. */ UPROPERTY(EditAnywhere, Category="Input") UInputAction* ToggleCameraAction; @@ -81,6 +85,22 @@ protected: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Interaction", meta = (ClampMin = "100")) float InteractionDistance = 450.0f; + /** Baseline movement speed before sprint, skill, injury, load, and terrain modifiers. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement", meta = (ClampMin = "0")) + float WalkSpeed = 500.0f; + + /** Short-burst movement speed used by the first sprinting pass. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement", meta = (ClampMin = "0")) + float SprintSpeed = 750.0f; + + /** Stamina spent each second while sprinting and moving. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement", meta = (ClampMin = "0")) + float SprintStaminaCostPerSecond = 18.0f; + + /** Minimum stamina required to start or continue sprinting. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement", meta = (ClampMin = "0", ClampMax = "100")) + float MinSprintStamina = 5.0f; + /** Third-person spring arm distance used when returning from first person. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Camera", meta = (ClampMin = "0")) float ThirdPersonCameraDistance = 400.0f; @@ -93,6 +113,10 @@ protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Agrarian|Camera", meta = (AllowPrivateAccess = "true")) bool bFirstPersonCamera = false; + /** Replicated player intent to sprint; actual sprinting also depends on stamina and alive state. */ + UPROPERTY(ReplicatedUsing = OnRep_SprintState, VisibleAnywhere, BlueprintReadOnly, Category="Agrarian|Movement", meta = (AllowPrivateAccess = "true")) + bool bWantsToSprint = false; + public: /** Constructor */ @@ -100,6 +124,9 @@ public: protected: + virtual void Tick(float DeltaSeconds) override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + /** Initialize input action bindings */ virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override; @@ -114,12 +141,30 @@ protected: /** Called for interaction input */ void Interact(); + /** Called when sprint input is pressed. */ + void StartSprint(); + + /** Called when sprint input is released. */ + void StopSprint(); + /** Called for camera perspective toggle input */ void ToggleCameraPerspective(); /** Applies local camera presentation state. */ void SetFirstPersonCamera(bool bEnableFirstPerson); + /** Applies the requested sprint intent locally and, when needed, on the server. */ + void SetWantsToSprint(bool bNewWantsToSprint); + + /** Returns true when the character has enough survival state to sprint. */ + bool CanSprint() const; + + /** Applies current walk or sprint speed to character movement. */ + void ApplyMovementSpeed(); + + UFUNCTION() + void OnRep_SprintState(); + public: /** Handles move inputs from either controls or UI interfaces */ @@ -146,10 +191,18 @@ public: UFUNCTION(BlueprintPure, Category="Agrarian|Camera") bool IsFirstPersonCamera() const { return bFirstPersonCamera; } + /** Returns true when sprint intent and current stamina allow sprinting. */ + UFUNCTION(BlueprintPure, Category="Agrarian|Movement") + bool IsSprinting() const; + /** Server-authoritative interaction entry point. */ UFUNCTION(Server, Reliable) void ServerInteract(AActor* TargetActor); + /** Server-authoritative sprint intent update. */ + UFUNCTION(Server, Reliable) + void ServerSetWantsToSprint(bool bNewWantsToSprint); + public: /** Returns CameraBoom subobject **/