diff --git a/Content/Input/Actions/IA_Sprint.uasset b/Content/Input/Actions/IA_Sprint.uasset new file mode 100644 index 0000000..c163c91 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 4dddf94..57c1ef7 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 ce3b0a3..f187494 100644 Binary files a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset and b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset differ diff --git a/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp b/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp index 9519f1b..4f80a44 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp +++ b/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp @@ -11,6 +11,7 @@ #include "EnhancedInputSubsystems.h" #include "InputActionValue.h" #include "KingshearthLegacy.h" +#include "StaminaComponent.h" AKingshearthLegacyCharacter::AKingshearthLegacyCharacter() { @@ -50,11 +51,41 @@ AKingshearthLegacyCharacter::AKingshearthLegacyCharacter() // are set in the derived blueprint asset named ThirdPersonCharacter (to avoid direct content references in C++) } +void AKingshearthLegacyCharacter::BeginPlay() +{ + Super::BeginPlay(); + + BaseWalkSpeed = GetCharacterMovement()->MaxWalkSpeed; + StaminaComponent = FindComponentByClass(); +} + +void AKingshearthLegacyCharacter::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + if (!StaminaComponent) + { + return; + } + + const bool bIsMoving = GetVelocity().SizeSquared() > FMath::Square(SprintMovementThreshold); + + if (bWantsToSprint && bIsMoving && StaminaComponent->GetStamina() > 0.0f) + { + GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed * SprintSpeedMultiplier; + StaminaComponent->RemoveStamina(SprintStaminaDrainRate * DeltaSeconds); + } + else + { + GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed; + } +} + void AKingshearthLegacyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) { // Set up action bindings if (UEnhancedInputComponent* EnhancedInputComponent = Cast(PlayerInputComponent)) { - + // Jumping EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump); EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping); @@ -65,6 +96,10 @@ void AKingshearthLegacyCharacter::SetupPlayerInputComponent(UInputComponent* Pla // Looking EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &AKingshearthLegacyCharacter::Look); + + // Sprinting + EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &AKingshearthLegacyCharacter::StartSprint); + EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &AKingshearthLegacyCharacter::StopSprint); } else { @@ -131,3 +166,25 @@ void AKingshearthLegacyCharacter::DoJumpEnd() // signal the character to stop jumping StopJumping(); } + +void AKingshearthLegacyCharacter::Jump() +{ + const bool bCouldJump = CanJump(); + + Super::Jump(); + + if (bCouldJump && StaminaComponent) + { + StaminaComponent->RemoveStamina(JumpStaminaCost); + } +} + +void AKingshearthLegacyCharacter::StartSprint() +{ + bWantsToSprint = true; +} + +void AKingshearthLegacyCharacter::StopSprint() +{ + bWantsToSprint = false; +} diff --git a/Source/KingshearthLegacy/KingshearthLegacyCharacter.h b/Source/KingshearthLegacy/KingshearthLegacyCharacter.h index 91cec80..20cf8e6 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyCharacter.h +++ b/Source/KingshearthLegacy/KingshearthLegacyCharacter.h @@ -10,6 +10,7 @@ class USpringArmComponent; class UCameraComponent; class UInputAction; +class UStaminaComponent; struct FInputActionValue; DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All); @@ -49,16 +50,42 @@ protected: UPROPERTY(EditAnywhere, Category="Input") UInputAction* MouseLookAction; + /** Sprint Input Action */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* SprintAction; + + /** Walk speed is multiplied by this while sprinting */ + UPROPERTY(EditAnywhere, Category="Sprint") + float SprintSpeedMultiplier = 1.6f; + + /** Stamina drained per second while sprinting */ + UPROPERTY(EditAnywhere, Category="Sprint") + float SprintStaminaDrainRate = 25.0f; + + /** Minimum speed (cm/s) for the character to be considered "moving" for sprint purposes. */ + UPROPERTY(EditAnywhere, Category="Sprint") + float SprintMovementThreshold = 10.0f; + + /** Stamina consumed by each successful jump. */ + UPROPERTY(EditAnywhere, Category="Stamina") + float JumpStaminaCost = 15.0f; + public: /** Constructor */ - AKingshearthLegacyCharacter(); + AKingshearthLegacyCharacter(); protected: + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + /** Initialize input action bindings */ virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override; + /** Overridden to charge JumpStaminaCost whenever a jump actually starts. */ + virtual void Jump() override; + protected: /** Called for movement input */ @@ -67,6 +94,20 @@ protected: /** Called for looking input */ void Look(const FInputActionValue& Value); + /** Called when the sprint input is pressed */ + void StartSprint(); + + /** Called when the sprint input is released */ + void StopSprint(); + + /** Cached from the CharacterMovementComponent's MaxWalkSpeed in BeginPlay, restored whenever not sprinting. */ + float BaseWalkSpeed = 0.0f; + + bool bWantsToSprint = false; + + UPROPERTY() + TObjectPtr StaminaComponent; + public: /** Handles move inputs from either controls or UI interfaces */ diff --git a/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp b/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp index f53545f..0d87dbd 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp +++ b/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp @@ -10,6 +10,7 @@ #include "Widgets/Input/SVirtualJoystick.h" #include "UI/PlayerHUDWidget.h" #include "HealthComponent.h" +#include "StaminaComponent.h" void AKingshearthLegacyPlayerController::BeginPlay() { @@ -87,7 +88,7 @@ void AKingshearthLegacyPlayerController::OnPossess(APawn* InPawn) if (PlayerHUD) { - PlayerHUD->InitializeHUD(InPawn->FindComponentByClass()); + PlayerHUD->InitializeHUD(InPawn->FindComponentByClass(), InPawn->FindComponentByClass()); } } diff --git a/Source/KingshearthLegacy/StaminaComponent.cpp b/Source/KingshearthLegacy/StaminaComponent.cpp new file mode 100644 index 0000000..b3aaca4 --- /dev/null +++ b/Source/KingshearthLegacy/StaminaComponent.cpp @@ -0,0 +1,115 @@ +#include "StaminaComponent.h" + +UStaminaComponent::UStaminaComponent() +{ + PrimaryComponentTick.bCanEverTick = true; +} + +void UStaminaComponent::BeginPlay() +{ + Super::BeginPlay(); + + CurrentStamina = MaxStamina; +} + +void UStaminaComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + + TimeSinceLastStaminaSpend += DeltaTime; + + const float TimeSinceRegenStarted = TimeSinceLastStaminaSpend - StaminaRegenDelay; + if (TimeSinceRegenStarted > 0.0f && CurrentStamina < GetEffectiveMaxStamina()) + { + const float RampedRegenRate = StaminaRegenRampTime > 0.0f + ? FMath::Min(TimeSinceRegenStarted / StaminaRegenRampTime, 1.0f) * StaminaRegenRate + : StaminaRegenRate; + + SetStamina(CurrentStamina + RampedRegenRate * DeltaTime); + + // Fatigue only builds while Stamina is actively regaining - never while draining, never + // while idle. It never recovers on its own; only an explicit SetFatigue call does. + SetFatigue(Fatigue + FatigueGainRateWhileRegaining * DeltaTime); + } +} + +float UStaminaComponent::GetStamina() const +{ + return CurrentStamina; +} + +float UStaminaComponent::GetMaxStamina() const +{ + return MaxStamina; +} + +float UStaminaComponent::GetFatigue() const +{ + return Fatigue; +} + +float UStaminaComponent::GetEffectiveMaxStamina() const +{ + return MaxStamina * (1.0f - Fatigue); +} + +void UStaminaComponent::SetMaxStamina(float NewMaxStamina) +{ + MaxStamina = FMath::Max(NewMaxStamina, 0.0f); + + CurrentStamina = FMath::Clamp(CurrentStamina, 0.0f, GetEffectiveMaxStamina()); +} + +void UStaminaComponent::SetStamina(float NewStamina) +{ + const float PreviousStamina = CurrentStamina; + + CurrentStamina = FMath::Clamp(NewStamina, 0.0f, GetEffectiveMaxStamina()); + + if (CurrentStamina < PreviousStamina) + { + TimeSinceLastStaminaSpend = 0.0f; + } + + OnStaminaChanged.Broadcast(CurrentStamina); + + // Only trigger the exhaustion signal when crossing from not-empty to empty. + if (PreviousStamina > 0.0f && CurrentStamina <= 0.0f) + { + OnExhausted.Broadcast(); + } +} + +void UStaminaComponent::AddStamina(float Amount) +{ + if (Amount <= 0.0f) + { + return; + } + + SetStamina(CurrentStamina + Amount); +} + +void UStaminaComponent::RemoveStamina(float Amount) +{ + if (Amount <= 0.0f) + { + return; + } + + SetStamina(CurrentStamina - Amount); +} + +void UStaminaComponent::SetFatigue(float NewFatigue) +{ + Fatigue = FMath::Clamp(NewFatigue, 0.0f, 1.0f); + + const float ClampedStamina = FMath::Clamp(CurrentStamina, 0.0f, GetEffectiveMaxStamina()); + if (!FMath::IsNearlyEqual(ClampedStamina, CurrentStamina)) + { + CurrentStamina = ClampedStamina; + OnStaminaChanged.Broadcast(CurrentStamina); + } + + OnFatigueChanged.Broadcast(Fatigue); +} diff --git a/Source/KingshearthLegacy/StaminaComponent.h b/Source/KingshearthLegacy/StaminaComponent.h new file mode 100644 index 0000000..9ad6d34 --- /dev/null +++ b/Source/KingshearthLegacy/StaminaComponent.h @@ -0,0 +1,93 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "StaminaComponent.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnStaminaChanged, float, NewStamina); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFatigueChanged, float, NewFatigue); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnExhausted); + +UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) +class KINGSHEARTHLEGACY_API UStaminaComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UStaminaComponent(); + +protected: + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + +public: + // Stamina + UFUNCTION(BlueprintPure, Category = "Stamina") + float GetStamina() const; + + /** Nominal cap, unaffected by Fatigue. Feeds the stamina bar's total width. */ + UFUNCTION(BlueprintPure, Category = "Stamina") + float GetMaxStamina() const; + + /** 0-1 fraction of MaxStamina currently locked/unusable due to over-extension. */ + UFUNCTION(BlueprintPure, Category = "Stamina") + float GetFatigue() const; + + UFUNCTION(BlueprintCallable, Category = "Stamina") + void SetMaxStamina(float NewMaxStamina); + + UFUNCTION(BlueprintCallable, Category = "Stamina") + void SetStamina(float NewStamina); + + UFUNCTION(BlueprintCallable, Category = "Stamina") + void AddStamina(float Amount); + + UFUNCTION(BlueprintCallable, Category = "Stamina") + void RemoveStamina(float Amount); + + UFUNCTION(BlueprintCallable, Category = "Fatigue") + void SetFatigue(float NewFatigue); + +protected: + /** MaxStamina minus whatever Fatigue currently locks away - what Stamina is actually clamped against. */ + float GetEffectiveMaxStamina() const; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stamina") + float MaxStamina = 100.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stamina") + float CurrentStamina = 100.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Fatigue") + float Fatigue = 0.0f; + + /** Regen rate once fully ramped up, in stamina/sec. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stamina") + float StaminaRegenRate = 20.0f; + + /** Seconds after the last stamina spend before regen starts at all. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stamina") + float StaminaRegenDelay = 1.0f; + + /** Seconds for the regen rate to ramp from 0 up to StaminaRegenRate once regen starts, instead of kicking in at full speed immediately. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stamina") + float StaminaRegenRampTime = 1.5f; + + /** Seconds since Stamina was last reduced by a spend (RemoveStamina/SetStamina decrease). Resets the regen delay/ramp whenever stamina is spent. */ + float TimeSinceLastStaminaSpend = 0.0f; + + /** Fatigue gained per second while Stamina is actively regaining (ramping back up). Never recovers on its own otherwise - only an explicit SetFatigue call (e.g. a debug key) reduces it. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Fatigue") + float FatigueGainRateWhileRegaining = 0.1f; + +public: + UPROPERTY(BlueprintAssignable, Category = "Stamina") + FOnStaminaChanged OnStaminaChanged; + + UPROPERTY(BlueprintAssignable, Category = "Fatigue") + FOnFatigueChanged OnFatigueChanged; + + /** Broadcast when Stamina is driven from a positive value to empty. */ + UPROPERTY(BlueprintAssignable, Category = "Stamina") + FOnExhausted OnExhausted; +}; diff --git a/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp b/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp index c73553b..619baa4 100644 --- a/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp +++ b/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp @@ -2,12 +2,15 @@ #include "UI/HealthBarWidget.h" #include "UI/StaminaBarWidget.h" -void UPlayerHUDWidget::InitializeHUD(UHealthComponent* InHealthComponent) +void UPlayerHUDWidget::InitializeHUD(UHealthComponent* InHealthComponent, UStaminaComponent* InStaminaComponent) { if (HealthBar) { HealthBar->InitializeHealthBar(InHealthComponent); } - // StaminaBar self-initializes from hardcoded defaults in its own NativeConstruct. + if (StaminaBar) + { + StaminaBar->InitializeStaminaBar(InStaminaComponent); + } } diff --git a/Source/KingshearthLegacy/UI/PlayerHUDWidget.h b/Source/KingshearthLegacy/UI/PlayerHUDWidget.h index 234c5ac..4fc6c20 100644 --- a/Source/KingshearthLegacy/UI/PlayerHUDWidget.h +++ b/Source/KingshearthLegacy/UI/PlayerHUDWidget.h @@ -7,6 +7,7 @@ class UHealthBarWidget; class UStaminaBarWidget; class UHealthComponent; +class UStaminaComponent; /** * Top-level player HUD. Owns a health bar and a stamina bar and forwards @@ -20,7 +21,7 @@ class UPlayerHUDWidget : public UUserWidget public: UFUNCTION(BlueprintCallable, Category = "HUD") - void InitializeHUD(UHealthComponent* InHealthComponent); + void InitializeHUD(UHealthComponent* InHealthComponent, UStaminaComponent* InStaminaComponent); protected: UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) diff --git a/Source/KingshearthLegacy/UI/StaminaBarWidget.cpp b/Source/KingshearthLegacy/UI/StaminaBarWidget.cpp index ace3dac..c7cfa02 100644 --- a/Source/KingshearthLegacy/UI/StaminaBarWidget.cpp +++ b/Source/KingshearthLegacy/UI/StaminaBarWidget.cpp @@ -1,11 +1,39 @@ #include "StaminaBarWidget.h" +#include "StaminaComponent.h" -void UStaminaBarWidget::NativeConstruct() +void UStaminaBarWidget::InitializeStaminaBar(UStaminaComponent* InStaminaComponent) { - Super::NativeConstruct(); + if (!InStaminaComponent) + { + return; + } - SetStatValue(DefaultCurrentStamina, DefaultMaxStamina); - SetFatigue(DefaultFatigue); + if (StaminaComponent) + { + StaminaComponent->OnStaminaChanged.RemoveDynamic(this, &UStaminaBarWidget::HandleStaminaChanged); + StaminaComponent->OnFatigueChanged.RemoveDynamic(this, &UStaminaBarWidget::HandleFatigueChanged); + } + + StaminaComponent = InStaminaComponent; + + StaminaComponent->OnStaminaChanged.AddDynamic(this, &UStaminaBarWidget::HandleStaminaChanged); + StaminaComponent->OnFatigueChanged.AddDynamic(this, &UStaminaBarWidget::HandleFatigueChanged); + + SetStatValue(StaminaComponent->GetStamina(), StaminaComponent->GetMaxStamina()); + SetFatigue(StaminaComponent->GetFatigue()); +} + +void UStaminaBarWidget::HandleStaminaChanged(float NewStamina) +{ + if (StaminaComponent) + { + SetStatValue(NewStamina, StaminaComponent->GetMaxStamina()); + } +} + +void UStaminaBarWidget::HandleFatigueChanged(float NewFatigue) +{ + SetFatigue(NewFatigue); } void UStaminaBarWidget::SetFatigue(float NewFatigue) diff --git a/Source/KingshearthLegacy/UI/StaminaBarWidget.h b/Source/KingshearthLegacy/UI/StaminaBarWidget.h index aa8bc09..cf363ff 100644 --- a/Source/KingshearthLegacy/UI/StaminaBarWidget.h +++ b/Source/KingshearthLegacy/UI/StaminaBarWidget.h @@ -4,16 +4,12 @@ #include "UI/StatBarWidget.h" #include "StaminaBarWidget.generated.h" +class UStaminaComponent; + /** - * Stat bar for stamina. Currently self-initializes from hardcoded defaults, - * since there is no stamina/fatigue gameplay component (DataModel) yet. - * - * TODO: once a UStaminaComponent exists (mirroring UHealthComponent), replace - * the hardcoded NativeConstruct initialization with an InitializeStaminaBar( - * UStaminaComponent* InStaminaComponent) call bound to that component's own - * OnStaminaChanged/OnFatigueChanged delegates, following the exact pattern - * used by UHealthBarWidget::InitializeHealthBar. SetFatigue/OnFatigueChanged - * below are already shaped for that hookup. + * Stat bar bound to a live UStaminaComponent. Call InitializeStaminaBar once the + * component is known (typically from the owning PlayerController on possession), + * mirroring UHealthBarWidget::InitializeHealthBar. */ UCLASS(abstract) class UStaminaBarWidget : public UStatBarWidget @@ -21,6 +17,9 @@ class UStaminaBarWidget : public UStatBarWidget GENERATED_BODY() public: + UFUNCTION(BlueprintCallable, Category = "Stamina") + void InitializeStaminaBar(UStaminaComponent* InStaminaComponent); + UFUNCTION(BlueprintCallable, Category = "Fatigue") void SetFatigue(float NewFatigue); @@ -28,22 +27,18 @@ public: float GetFatigue() const { return Fatigue; } protected: - virtual void NativeConstruct() override; + UFUNCTION() + void HandleStaminaChanged(float NewStamina); - /** Hook for Blueprint-side fatigue visuals (e.g. a darkened "locked" portion of the bar). Unused until a real fatigue value is fed in. */ + UFUNCTION() + void HandleFatigueChanged(float NewFatigue); + + /** Hook for Blueprint-side fatigue visuals (e.g. a darkened "locked" portion of the bar). */ UFUNCTION(BlueprintImplementableEvent, Category = "Fatigue") void OnFatigueChanged(float NewFatigue); - /** Placeholder starting values until a real UStaminaComponent DataModel exists. */ - UPROPERTY(EditDefaultsOnly, Category = "Stamina") - float DefaultCurrentStamina = 75.0f; - - UPROPERTY(EditDefaultsOnly, Category = "Stamina") - float DefaultMaxStamina = 100.0f; - - /** 0-1 starting fatigue applied in NativeConstruct, e.g. 0.1 = 10% of the bar locked/unusable. */ - UPROPERTY(EditDefaultsOnly, Category = "Stamina") - float DefaultFatigue = 0.1f; + UPROPERTY(BlueprintReadOnly, Category = "Stamina") + TObjectPtr StaminaComponent; /** 0-1 exhaustion amount. Drives the bar's penalty segment via SetPenaltyPercent. */ UPROPERTY(BlueprintReadOnly, Category = "Fatigue")