diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 32edd7a..d0e06f0 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -375,7 +375,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Define real-world baseline walking speed. Decision: baseline adult walking speed is `1.4 m/s` (`140 Unreal units/s`), with MVP tuning allowance up to about `1.6 m/s` for brisk walking. Movement speed does not scale with the `4 real hours = 1 in-game day` calendar. - [x] Define real-world baseline running speed. Decision: sustainable adult running target is `3.0 m/s` (`300 Unreal units/s`); MVP sprint is a short burst at `5.5 m/s` (`550 Unreal units/s`) with stamina limits. Movement speed does not scale with the calendar. - [x] Connect movement speed to age, condition, strength, endurance, hunger, thirst, injury, carried weight, and terrain. Implemented with live survival/inventory modifiers plus replicated age, condition, strength, endurance, and terrain hooks. -- [ ] Implement crouching if needed. +- [x] Implement crouching and prone movement stances. Decision: `C` toggles crouch at `55%` movement speed and `Z` toggles prone at `25%` movement speed. Gamepad mappings are Right Shoulder for crouch and Left Shoulder for prone. Sprint is disabled while crouched or prone. - [x] Implement jumping if needed. - [x] Implement interaction trace. - [ ] Implement interact prompt. @@ -1465,4 +1465,4 @@ Earliest incomplete foundation items: Immediate next item: -- [ ] Implement crouching if needed. +- [ ] Implement interact prompt. diff --git a/Content/Input/Actions/IA_Crouch.uasset b/Content/Input/Actions/IA_Crouch.uasset new file mode 100644 index 0000000..641ce6c Binary files /dev/null and b/Content/Input/Actions/IA_Crouch.uasset differ diff --git a/Content/Input/Actions/IA_Prone.uasset b/Content/Input/Actions/IA_Prone.uasset new file mode 100644 index 0000000..9741261 Binary files /dev/null and b/Content/Input/Actions/IA_Prone.uasset differ diff --git a/Content/Input/IMC_Default.uasset b/Content/Input/IMC_Default.uasset index d1886bc..2b57c34 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 4f1c10c..337768a 100644 Binary files a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset and b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset differ diff --git a/Docs/MovementAndTimeScaleBaseline.md b/Docs/MovementAndTimeScaleBaseline.md index ff57096..7aeabb4 100644 --- a/Docs/MovementAndTimeScaleBaseline.md +++ b/Docs/MovementAndTimeScaleBaseline.md @@ -116,6 +116,26 @@ Later movement work should separate: - injury/load/terrain modifiers; - skill, age, care history, and condition effects. +## MVP Stance Movement + +The first stance pass supports crouch and prone as low-movement modes that keep +the same real-distance philosophy as walking and sprinting. + +Current defaults: + +- crouch toggles with `C` or gamepad Right Shoulder; +- prone toggles with `Z` or gamepad Left Shoulder; +- crouch uses `55%` of the current movement speed; +- prone uses `25%` of the current movement speed; +- sprint cannot start while crouched or prone; +- entering prone exits crouch so the player has one active low stance at a + time. + +The MVP prone state is replicated and affects movement speed. Dedicated prone +animation, capsule resizing, crawl-specific collision, stealth/noise, and +surface-specific crawl penalties are reserved for later animation and traversal +passes. + ## Movement Modifier Implementation MVP movement speed is now calculated as: diff --git a/Scripts/setup_stance_input.py b/Scripts/setup_stance_input.py new file mode 100644 index 0000000..beb60a3 --- /dev/null +++ b/Scripts/setup_stance_input.py @@ -0,0 +1,101 @@ +import unreal + + +STANCE_DEFAULTS = { + "CrouchSpeedMultiplier": 0.55, + "ProneSpeedMultiplier": 0.25, +} + + +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 stance 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(): + crouch_action = create_input_action("/Game/Input/Actions/IA_Crouch") + prone_action = create_input_action("/Game/Input/Actions/IA_Prone") + set_boolean_value_type(crouch_action) + set_boolean_value_type(prone_action) + + context = load("/Game/Input/IMC_Default") + map_key(context, crouch_action, "C") + map_key(context, crouch_action, "Gamepad_RightShoulder") + map_key(context, prone_action, "Z") + map_key(context, prone_action, "Gamepad_LeftShoulder") + 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("CrouchAction", crouch_action) + character_cdo.set_editor_property("ProneAction", prone_action) + for property_name, value in STANCE_DEFAULTS.items(): + character_cdo.set_editor_property(property_name, value) + unreal.EditorAssetLibrary.save_loaded_asset(character_bp) + + unreal.log("Agrarian stance input setup complete.") + + +main() diff --git a/Scripts/verify_stance_input.py b/Scripts/verify_stance_input.py new file mode 100644 index 0000000..a907a49 --- /dev/null +++ b/Scripts/verify_stance_input.py @@ -0,0 +1,63 @@ +import math +import unreal + + +STANCE_DEFAULTS = { + "CrouchSpeedMultiplier": 0.55, + "ProneSpeedMultiplier": 0.25, +} + + +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(): + crouch_action = load("/Game/Input/Actions/IA_Crouch") + prone_action = load("/Game/Input/Actions/IA_Prone") + 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 action, key_name in [ + (crouch_action, "C"), + (crouch_action, "Gamepad_RightShoulder"), + (prone_action, "Z"), + (prone_action, "Gamepad_LeftShoulder"), + ]: + if not mapping_found(context, action, key_name): + missing.append(f"missing mapping {action.get_name()} -> {key_name}") + + if character_cdo.get_editor_property("CrouchAction") != crouch_action: + missing.append("BP_ThirdPersonCharacter CrouchAction is not IA_Crouch") + if character_cdo.get_editor_property("ProneAction") != prone_action: + missing.append("BP_ThirdPersonCharacter ProneAction is not IA_Prone") + + for property_name, expected in STANCE_DEFAULTS.items(): + actual = float(character_cdo.get_editor_property(property_name)) + if not math.isclose(actual, expected, rel_tol=0.0, abs_tol=0.01): + missing.append(f"{property_name}: expected {expected}, got {actual}") + + if missing: + raise RuntimeError("Stance input verification failed: " + "; ".join(missing)) + + unreal.log("Agrarian stance input verification complete.") + + +main() diff --git a/Source/AgrarianGame/AgrarianGameCharacter.cpp b/Source/AgrarianGame/AgrarianGameCharacter.cpp index 8f6eba1..613d56c 100644 --- a/Source/AgrarianGame/AgrarianGameCharacter.cpp +++ b/Source/AgrarianGame/AgrarianGameCharacter.cpp @@ -31,6 +31,7 @@ AAgrarianGameCharacter::AAgrarianGameCharacter() bUseControllerRotationRoll = false; // Configure character movement + GetCharacterMovement()->NavAgentProps.bCanCrouch = true; GetCharacterMovement()->bOrientRotationToMovement = true; GetCharacterMovement()->RotationRate = FRotator(0.0f, 500.0f, 0.0f); @@ -39,6 +40,7 @@ AAgrarianGameCharacter::AAgrarianGameCharacter() GetCharacterMovement()->JumpZVelocity = 500.f; GetCharacterMovement()->AirControl = 0.35f; GetCharacterMovement()->MaxWalkSpeed = WalkSpeed; + GetCharacterMovement()->MaxWalkSpeedCrouched = WalkSpeed * CrouchSpeedMultiplier; GetCharacterMovement()->MinAnalogWalkSpeed = 20.f; GetCharacterMovement()->BrakingDecelerationWalking = 2000.f; GetCharacterMovement()->BrakingDecelerationFalling = 1500.0f; @@ -95,6 +97,7 @@ void AAgrarianGameCharacter::GetLifetimeReplicatedProps(TArrayBindAction(SprintAction, ETriggerEvent::Canceled, this, &AAgrarianGameCharacter::StopSprint); } + if (CrouchAction) + { + EnhancedInputComponent->BindAction(CrouchAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::ToggleCrouchStance); + } + + if (ProneAction) + { + EnhancedInputComponent->BindAction(ProneAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::ToggleProneStance); + } + if (ToggleCameraAction) { EnhancedInputComponent->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AAgrarianGameCharacter::ToggleCameraPerspective); @@ -166,6 +179,11 @@ void AAgrarianGameCharacter::Interact() void AAgrarianGameCharacter::StartSprint() { + if (bIsProne || bIsCrouched) + { + return; + } + SetWantsToSprint(true); } @@ -174,6 +192,32 @@ void AAgrarianGameCharacter::StopSprint() SetWantsToSprint(false); } +void AAgrarianGameCharacter::ToggleCrouchStance() +{ + if (bIsProne) + { + SetProne(false); + return; + } + + SetWantsToSprint(false); + if (bIsCrouched) + { + UnCrouch(); + } + else + { + Crouch(); + } + + ApplyMovementSpeed(); +} + +void AAgrarianGameCharacter::ToggleProneStance() +{ + SetProne(!bIsProne); +} + void AAgrarianGameCharacter::ToggleCameraPerspective() { SetFirstPersonCamera(!bFirstPersonCamera); @@ -235,6 +279,8 @@ bool AAgrarianGameCharacter::CanSprint() const { return SurvivalComponent && SurvivalComponent->IsAlive() + && !bIsProne + && !bIsCrouched && SurvivalComponent->Survival.Stamina > MinSprintStamina; } @@ -253,6 +299,29 @@ float AAgrarianGameCharacter::GetCurrentMovementSpeedMultiplier() const return CalculateMovementSpeedMultiplier(); } +void AAgrarianGameCharacter::SetProne(bool bNewProne) +{ + if (bIsProne == bNewProne) + { + return; + } + + bIsProne = bNewProne; + + if (bIsProne) + { + SetWantsToSprint(false); + UnCrouch(); + } + + ApplyMovementSpeed(); + + if (!HasAuthority()) + { + ServerSetProne(bIsProne); + } +} + void AAgrarianGameCharacter::SetTerrainMovementMultiplier(float NewTerrainMovementMultiplier) { TerrainMovementMultiplier = FMath::Clamp(NewTerrainMovementMultiplier, 0.25f, 1.25f); @@ -264,7 +333,9 @@ void AAgrarianGameCharacter::ApplyMovementSpeed() if (UCharacterMovementComponent* MovementComponent = GetCharacterMovement()) { const float BaseSpeed = IsSprinting() ? SprintSpeed : WalkSpeed; - MovementComponent->MaxWalkSpeed = FMath::Max(0.0f, BaseSpeed * CalculateMovementSpeedMultiplier()); + const float FinalSpeed = FMath::Max(0.0f, BaseSpeed * CalculateMovementSpeedMultiplier()); + MovementComponent->MaxWalkSpeed = FinalSpeed; + MovementComponent->MaxWalkSpeedCrouched = FinalSpeed; } } @@ -278,6 +349,7 @@ float AAgrarianGameCharacter::CalculateMovementSpeedMultiplier() const * TraitMultiplier * CalculateSurvivalMovementMultiplier() * CalculateCarryWeightMovementMultiplier() + * CalculateStanceMovementMultiplier() * FMath::Clamp(TerrainMovementMultiplier, 0.25f, 1.25f), 0.15f, 1.35f); @@ -353,11 +425,36 @@ float AAgrarianGameCharacter::CalculateCarryWeightMovementMultiplier() const CurrentCarryWeight); } +float AAgrarianGameCharacter::CalculateStanceMovementMultiplier() const +{ + if (bIsProne) + { + return FMath::Clamp(ProneSpeedMultiplier, 0.05f, 1.0f); + } + + if (bIsCrouched) + { + return FMath::Clamp(CrouchSpeedMultiplier, 0.1f, 1.0f); + } + + return 1.0f; +} + void AAgrarianGameCharacter::OnRep_SprintState() { ApplyMovementSpeed(); } +void AAgrarianGameCharacter::OnRep_ProneState() +{ + if (bIsProne) + { + UnCrouch(); + } + + ApplyMovementSpeed(); +} + void AAgrarianGameCharacter::DoMove(float Right, float Forward) { if (GetController() != nullptr) @@ -448,6 +545,11 @@ void AAgrarianGameCharacter::ServerSetWantsToSprint_Implementation(bool bNewWant SetWantsToSprint(bNewWantsToSprint); } +void AAgrarianGameCharacter::ServerSetProne_Implementation(bool bNewProne) +{ + SetProne(bNewProne); +} + 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 7df63f0..758be0e 100644 --- a/Source/AgrarianGame/AgrarianGameCharacter.h +++ b/Source/AgrarianGame/AgrarianGameCharacter.h @@ -77,6 +77,14 @@ protected: UPROPERTY(EditAnywhere, Category="Input") UInputAction* SprintAction; + /** Toggle crouch movement stance. */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* CrouchAction; + + /** Toggle prone movement stance. */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* ProneAction; + /** Toggle between third-person and first-person camera views. */ UPROPERTY(EditAnywhere, Category="Input") UInputAction* ToggleCameraAction; @@ -101,6 +109,14 @@ protected: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement", meta = (ClampMin = "0", ClampMax = "100")) float MinSprintStamina = 5.0f; + /** Movement multiplier while crouched. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement|Stance", meta = (ClampMin = "0.1", ClampMax = "1")) + float CrouchSpeedMultiplier = 0.55f; + + /** Movement multiplier while prone. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Agrarian|Movement|Stance", meta = (ClampMin = "0.05", ClampMax = "1")) + float ProneSpeedMultiplier = 0.25f; + /** Age hook used by movement until full lifecycle/aging systems own it. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category="Agrarian|Movement|Modifiers", meta = (ClampMin = "0")) float AgeYears = 25.0f; @@ -145,6 +161,10 @@ protected: UPROPERTY(ReplicatedUsing = OnRep_SprintState, VisibleAnywhere, BlueprintReadOnly, Category="Agrarian|Movement", meta = (AllowPrivateAccess = "true")) bool bWantsToSprint = false; + /** Replicated prone stance for low crawl movement. */ + UPROPERTY(ReplicatedUsing = OnRep_ProneState, VisibleAnywhere, BlueprintReadOnly, Category="Agrarian|Movement|Stance", meta = (AllowPrivateAccess = "true")) + bool bIsProne = false; + public: /** Constructor */ @@ -175,6 +195,12 @@ protected: /** Called when sprint input is released. */ void StopSprint(); + /** Called when crouch input is pressed. */ + void ToggleCrouchStance(); + + /** Called when prone input is pressed. */ + void ToggleProneStance(); + /** Called for camera perspective toggle input */ void ToggleCameraPerspective(); @@ -196,10 +222,14 @@ protected: float CalculateAgeMovementMultiplier() const; float CalculateSurvivalMovementMultiplier() const; float CalculateCarryWeightMovementMultiplier() const; + float CalculateStanceMovementMultiplier() const; UFUNCTION() void OnRep_SprintState(); + UFUNCTION() + void OnRep_ProneState(); + public: /** Handles move inputs from either controls or UI interfaces */ @@ -230,6 +260,12 @@ public: UFUNCTION(BlueprintPure, Category="Agrarian|Movement") bool IsSprinting() const; + UFUNCTION(BlueprintPure, Category="Agrarian|Movement|Stance") + bool IsProne() const { return bIsProne; } + + UFUNCTION(BlueprintCallable, Category="Agrarian|Movement|Stance") + void SetProne(bool bNewProne); + UFUNCTION(BlueprintPure, Category="Agrarian|Movement") float GetCurrentCarryWeight() const; @@ -247,6 +283,10 @@ public: UFUNCTION(Server, Reliable) void ServerSetWantsToSprint(bool bNewWantsToSprint); + /** Server-authoritative prone stance update. */ + UFUNCTION(Server, Reliable) + void ServerSetProne(bool bNewProne); + public: /** Returns CameraBoom subobject **/