diff --git a/Config/DefaultGameplayTags.ini b/Config/DefaultGameplayTags.ini index 87e9fa4..b147beb 100644 --- a/Config/DefaultGameplayTags.ini +++ b/Config/DefaultGameplayTags.ini @@ -1,5 +1,4 @@ ;METADATA=(Diff=true, UseCommands=true) -[/Script/GameplayTags.GameplayTagsSettings] ImportTagsFromConfig=True WarnOnInvalidTags=True ClearInvalidTags=False @@ -12,3 +11,5 @@ NumBitsForContainerSize=6 NetIndexFirstBitSegment=16 +GameplayTagList=(Tag="Data.Regen.InnerPower",DevComment="SetByCaller: InnerPower regen per tick") ++GameplayTagList=(Tag="Data.Init.Primary.Agility",DevComment="Init Primary Agility") + diff --git a/Content/AGame/Character/Player/Player_Base.uasset b/Content/AGame/Character/Player/Player_Base.uasset index 8c771ee..07c559e 100644 Binary files a/Content/AGame/Character/Player/Player_Base.uasset and b/Content/AGame/Character/Player/Player_Base.uasset differ diff --git a/Content/AGame/Gameplay/GameInstance_Default.uasset b/Content/AGame/Gameplay/GameInstance_Default.uasset index f45ab33..7b6c26a 100644 Binary files a/Content/AGame/Gameplay/GameInstance_Default.uasset and b/Content/AGame/Gameplay/GameInstance_Default.uasset differ diff --git a/Content/AGame/UI/Menu/Overlap/WBP_Menu_Overlap.uasset b/Content/AGame/UI/Menu/Overlap/WBP_Menu_Overlap.uasset index 689d1fa..c6b1e6d 100644 Binary files a/Content/AGame/UI/Menu/Overlap/WBP_Menu_Overlap.uasset and b/Content/AGame/UI/Menu/Overlap/WBP_Menu_Overlap.uasset differ diff --git a/Content/AGame/UI/Menu/Overlap/Widget/WBP_CharacterInfo.uasset b/Content/AGame/UI/Menu/Overlap/Widget/WBP_CharacterInfo.uasset index 62465c6..0a6fb8b 100644 Binary files a/Content/AGame/UI/Menu/Overlap/Widget/WBP_CharacterInfo.uasset and b/Content/AGame/UI/Menu/Overlap/Widget/WBP_CharacterInfo.uasset differ diff --git a/Source/PHY/Private/Character/PHYPlayerCharacter.cpp b/Source/PHY/Private/Character/PHYPlayerCharacter.cpp index 8bc6d97..4091989 100644 --- a/Source/PHY/Private/Character/PHYPlayerCharacter.cpp +++ b/Source/PHY/Private/Character/PHYPlayerCharacter.cpp @@ -12,15 +12,15 @@ #include "GameplayTags/InputTags.h" #include "Components/RetargeterComponent.h" #include "AbilitySystemComponent.h" +#include "AbilitySystem/PHYClassDefaults.h" #include "AbilitySystem/Attributes/PHYAttributeSet.h" #include "AbilitySystem/Effects/PHYGE_DerivedAttributes.h" #include "AbilitySystem/Effects/PHYGE_InitPrimary.h" #include "AbilitySystem/Effects/PHYGE_RegenTick.h" -#include "AbilitySystem/PHYClassDefaults.h" -#include "GameplayTags/InitAttributeTags.h" #include "GameplayTags/RegenTags.h" -#include "GameFramework/CharacterMovementComponent.h" #include "UI/HUD/PHYGameHUD.h" +#include "GameplayTags/InitAttributeTags.h" + APHYPlayerCharacter::APHYPlayerCharacter() @@ -176,7 +176,7 @@ void APHYPlayerCharacter::InitializeGAS() ASC->SetNumericAttributeBase(UPHYAttributeSet::GetHealthAttribute(), AS->GetMaxHealth()); ASC->SetNumericAttributeBase(UPHYAttributeSet::GetInnerPowerAttribute(), AS->GetMaxInnerPower()); } - + StopRegen(); if (RegenInterval > 0.f) { @@ -191,7 +191,7 @@ void APHYPlayerCharacter::StopRegen() if (!HasAuthority()) return; GetWorldTimerManager().ClearTimer(RegenTimerHandle); } - + void APHYPlayerCharacter::RegenTick() { if (!HasAuthority()) return; diff --git a/Source/PHY/Private/Gameplay/GameLevelInfo.cpp b/Source/PHY/Private/Gameplay/GameLevelInfo.cpp new file mode 100644 index 0000000..9dbd5bd --- /dev/null +++ b/Source/PHY/Private/Gameplay/GameLevelInfo.cpp @@ -0,0 +1,90 @@ +// + + +#include "GameLevelInfo.h" + +namespace +{ + // TotalXPThreshold[Level]:到达该等级的累计经验阈值(Level=1..100)。 + // - 下标与等级一致。 + // - Threshold[1]=0。 + // - 总经验(到达100级阈值)约 45,000,020。 + static const TStaticArray ThresholdConst = { + 0,0, + 28500,59630,93570,130520,170660,214180,261260,312090, + 366860,425760,488980,556700,629110,706400,788760,876370,969430, + 1068120,1172620,1283130,1399830,1522910,1652560,1788970,1932320, + 2082800,2240600,2405900,2578900,2759780,2948730,3145940,3351590, + 3565870,3788970,4021080,4262390,4513080,4773340,5043360,5323320, + 5613420,5913840,6224770,6546390,6878900,7222480,7577320,7943600, + 8321520,8711260,9113010,9526960,9953290,10392200,10843870,11308490, + 11786240,12277320,12781910,13300200,13832370,14378620,14939130,15514090, + 16103690,16708110,17327540,17962170,18612190,19277790,19959150,20656460, + 21369910,22099680,22845970,23608960,24388840,25185800,26000020,26831690, + 27681000,28548140,29433290,30336650,31258400,32198730,33157820,34135860, + 35133050,36149560,37185590,38241320,39316950,40412650,41528620,42665050, + 43822120,45000020 + }; +} + +const TStaticArray FGameLevelInfo::TotalXPThreshold = ThresholdConst; + +int32 FGameLevelInfo::GetXPForNextLevel(int32 Level) +{ + Level = FMath::Clamp(Level, MinLevel, MaxLevel); + if (Level >= MaxLevel) + { + return 0; + } + // 差分:到达(Level+1)的阈值 - 到达(Level)的阈值 + return TotalXPThreshold[Level + 1] - TotalXPThreshold[Level]; +} + +int32 FGameLevelInfo::GetTotalXPToReachLevel(int32 Level) +{ + Level = FMath::Clamp(Level, MinLevel, MaxLevel); + return TotalXPThreshold[Level]; +} + +int32 FGameLevelInfo::GetLevelForTotalXP(int32 TotalXP) +{ + TotalXP = FMath::Max(0, TotalXP); + + // 阈值单调递增:TotalXPThreshold[L] 是到达 L 的最小总经验。 + // 找到最大的 L,使得 Threshold[L] <= TotalXP。 + int32 Low = MinLevel; + int32 High = MaxLevel; + while (Low < High) + { + // 上中位数,避免死循环 + const int32 Mid = (Low + High + 1) / 2; + if (TotalXPThreshold[Mid] <= TotalXP) + { + Low = Mid; + } + else + { + High = Mid - 1; + } + } + return Low; +} + +void FGameLevelInfo::GetProgressForTotalXP(int32 TotalXP, int32& OutLevel, int32& OutXPIntoLevel, int32& OutXPToNext) +{ + TotalXP = FMath::Max(0, TotalXP); + OutLevel = GetLevelForTotalXP(TotalXP); + + const int32 CurrentLevelThreshold = GetTotalXPToReachLevel(OutLevel); + OutXPToNext = GetXPForNextLevel(OutLevel); + + OutXPIntoLevel = TotalXP - CurrentLevelThreshold; + if (OutXPToNext <= 0) + { + OutXPIntoLevel = 0; + OutXPToNext = 0; + return; + } + + OutXPIntoLevel = FMath::Clamp(OutXPIntoLevel, 0, OutXPToNext); +} diff --git a/Source/PHY/Private/Gameplay/GameLevelInfo.h b/Source/PHY/Private/Gameplay/GameLevelInfo.h new file mode 100644 index 0000000..effc700 --- /dev/null +++ b/Source/PHY/Private/Gameplay/GameLevelInfo.h @@ -0,0 +1,42 @@ +// + +#pragma once + +#include "CoreMinimal.h" + +/** + * 1-100级经验表(常量内置,无需配置)。 + * - 等级从 1 开始,最高 100。 + * - XP 表示“累计经验值”(总经验),即从1级开始累计获得的经验。 + * - LevelXP[i] 表示从 i 级升到 i+1 级需要的经验(i=1..99)。 + */ +struct FGameLevelInfo +{ +public: + static constexpr int32 MinLevel = 1; + static constexpr int32 MaxLevel = 100; + + /** 从当前 Level 升到 Level+1 需要的经验(Level=1..99)。Level=100 返回 0。 */ + static int32 GetXPForNextLevel(int32 Level); + + /** 返回达到指定等级所需的“累计经验阈值”。 + * - Level=1 -> 0 + * - Level=2 -> 从1升2的经验 + */ + static int32 GetTotalXPToReachLevel(int32 Level); + + /** 通过当前累计经验反查等级(1..100)。 */ + static int32 GetLevelForTotalXP(int32 TotalXP); + + /** + * @param OutLevel 当前等级(1..100) + * @param OutXPToNext 从当前等级升到下一级所需经验(100级为0) + */ + static void GetProgressForTotalXP(int32 TotalXP, int32& OutLevel, int32& OutXPIntoLevel, int32& OutXPToNext); + +private: + /** 累计阈值表:TotalXPThreshold[Level] for Level=1..100。0 号位不用。 + * TotalXPThreshold[1] 必须为 0。 + */ + static const TStaticArray TotalXPThreshold; +}; diff --git a/Source/PHY/Private/Gameplay/PHYGameInstance.h b/Source/PHY/Private/Gameplay/PHYGameInstance.h index 7589201..59a4168 100644 --- a/Source/PHY/Private/Gameplay/PHYGameInstance.h +++ b/Source/PHY/Private/Gameplay/PHYGameInstance.h @@ -1,4 +1,4 @@ -// +// Copyright 2025 PHY. All Rights Reserved. #pragma once @@ -9,6 +9,7 @@ class UIKRetargeter; class UPHYClassDefaults; +class UPHYUIIconSet; /** * */ @@ -35,8 +36,13 @@ class PHY_API UPHYGameInstance : public UGameInstance UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Config|Attributes", meta=(AllowPrivateAccess=true)) TObjectPtr ClassDefaults; + /** 全局UI固定图标集合(经验/升级等) */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Config|UI", meta=(AllowPrivateAccess=true)) + TObjectPtr UIIconSet; + public: TOptional GetRetargetInfo(const FGameplayTag& RetargetInfoTag) const; const UPHYClassDefaults* GetClassDefaults() const { return ClassDefaults; } + const UPHYUIIconSet* GetUIIconSet() const { return UIIconSet; } }; diff --git a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp index feb1b6e..9b02c15 100644 --- a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp +++ b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp @@ -7,21 +7,27 @@ #include "Character/PHYPlayerCharacter.h" #include "Components/RetargeterComponent.h" #include "Gameplay/PHYGameInstance.h" +#include "UI/PHYUIIconSet.h" +#include "Gameplay/GameLevelInfo.h" #include "Net/UnrealNetwork.h" #include "Net/Core/PushModel/PushModel.h" +namespace +{ + // 你可以把这些做成配置/曲线/数据表;这里先给一个默认规则:每升1级给 1 点属性点 + constexpr int32 AttributePointsPerLevel = 4; +} + APHYPlayerState::APHYPlayerState() { // 创建AbilitySystemComponent AbilitySystemComponent = CreateDefaultSubobject(TEXT("AbilitySystemComponent")); - AttributeSet = CreateDefaultSubobject(TEXT("AttributeSet")); - - // 设置AbilitySystemComponent的网络复制 - AbilitySystemComponent->SetIsReplicated(true); - + AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed); + SetNetUpdateFrequency(100.f); + // 使用Minimal复制模式(推荐用于PlayerState) AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal); - + } UAbilitySystemComponent* APHYPlayerState::GetAbilitySystemComponent() const @@ -57,6 +63,9 @@ void APHYPlayerState::GetLifetimeReplicatedProps(TArray SharedParams.bIsPushBased = true; DOREPLIFETIME_WITH_PARAMS_FAST(APHYPlayerState, ReTargeterTag, SharedParams); + DOREPLIFETIME_WITH_PARAMS_FAST(APHYPlayerState, XP, SharedParams); + DOREPLIFETIME_WITH_PARAMS_FAST(APHYPlayerState, Level, SharedParams); + DOREPLIFETIME_WITH_PARAMS_FAST(APHYPlayerState, AttributePoints, SharedParams); } void APHYPlayerState::SetReTargeterTag(const FGameplayTag& NewReTargeterTag) @@ -70,6 +79,141 @@ void APHYPlayerState::SetReTargeterTag(const FGameplayTag& NewReTargeterTag) ForceNetUpdate(); } +void APHYPlayerState::AddToXP(const int32 InXP) +{ + if (InXP <= 0) + { + return; + } + + // 本地提示:获得经验(只在本地玩家的 PlayerState 上显示即可) + if (IsOwnedBy(GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr)) + { + PushGainedXPNotify(InXP); + } + + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, XP, this); + XP += InXP; + + // 只在权威端(Server/Standalone)做升级计算,客户端靠复制拿到最终 Level/XP + if (HasAuthority()) + { + const int32 NewLevel = FMath::Clamp(FGameLevelInfo::GetLevelForTotalXP(XP), FGameLevelInfo::MinLevel, FGameLevelInfo::MaxLevel); + if (NewLevel > Level) + { + const int32 Delta = NewLevel - Level; + AddToLevel(Delta); + AddToAttributePoints(Delta * AttributePointsPerLevel); + } + } + + OnXPChangedDelegate.Broadcast(XP); +} + +void APHYPlayerState::AddToLevel(const int32 InLevel) +{ + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, Level, this); + Level += InLevel; + OnLevelChangedDelegate.Broadcast(Level,true); + + // 本地提示:升级 + if (IsOwnedBy(GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr)) + { + PushLevelUpNotify(Level); + } +} + +void APHYPlayerState::PushGameplayNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon) +{ + OnGameplayNotifyDelegate.Broadcast(Type, Message, SoftIcon); +} + +void APHYPlayerState::PushGainedXPNotify(int32 DeltaXP) +{ + if (DeltaXP <= 0) return; + const UPHYUIIconSet* IconSet = nullptr; + if (const UPHYGameInstance* GI = GetGameInstance()) + { + IconSet = GI->GetUIIconSet(); + } + PushGameplayNotify(EPHYNotifyType::Reward, + FText::Format(NSLOCTEXT("PHY", "Notify_GainXP", "获得经验 {0}"), FText::AsNumber(DeltaXP)), + IconSet ? IconSet->XP : nullptr); +} + +void APHYPlayerState::PushLevelUpNotify(int32 NewLevel) +{ + const UPHYUIIconSet* IconSet = nullptr; + if (const UPHYGameInstance* GI = GetGameInstance()) + { + IconSet = GI->GetUIIconSet(); + } + PushGameplayNotify(EPHYNotifyType::LevelUp, + FText::Format(NSLOCTEXT("PHY", "Notify_LevelUp", "升级到 {0} 级"), FText::AsNumber(NewLevel)), + IconSet ? IconSet->LevelUp : nullptr); +} + +void APHYPlayerState::PushGainedItemNotify(const FText& ItemName, int32 Count) +{ + PushGameplayNotify(EPHYNotifyType::Reward, + FText::Format(NSLOCTEXT("PHY", "Notify_GainItem", "获得 {0} x{1}"), ItemName, FText::AsNumber(Count)), + nullptr); +} + +void APHYPlayerState::SetXP(const int32 InXP) +{ + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, XP, this); + XP = FMath::Max(0, InXP); + + if (HasAuthority()) + { + const int32 NewLevel = FMath::Clamp(FGameLevelInfo::GetLevelForTotalXP(XP), FGameLevelInfo::MinLevel, FGameLevelInfo::MaxLevel); + if (NewLevel != Level) + { + // 这里用 SetLevel 以避免把它当“连升触发”重复发奖励(奖励统一走 AddToXP 的路径) + SetLevel(NewLevel); + } + } + + OnXPChangedDelegate.Broadcast(XP); +} + +void APHYPlayerState::AddToAttributePoints(const int32 InPoints) +{ + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, AttributePoints, this); + AttributePoints += InPoints; + OnAttributePointsChangedDelegate.Broadcast(AttributePoints); +} + +void APHYPlayerState::SetLevel(const int32 InLevel) +{ + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, Level, this); + Level = InLevel; + OnLevelChangedDelegate.Broadcast(Level,false); +} + +void APHYPlayerState::SetAttributePoints(const int32 InPoints) +{ + MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, AttributePoints, this); + AttributePoints = InPoints; + OnAttributePointsChangedDelegate.Broadcast(AttributePoints); +} + +void APHYPlayerState::OnRep_Level(int32 OldLevel) +{ + OnLevelChangedDelegate.Broadcast(Level, true); +} + +void APHYPlayerState::OnRep_XP(int32 OldXP) +{ + OnXPChangedDelegate.Broadcast(XP); +} + +void APHYPlayerState::OnRep_AttributePoints(int32 OldAttributePoints) +{ + OnAttributePointsChangedDelegate.Broadcast(AttributePoints); +} + void APHYPlayerState::ServerSetReTargeterTag_Implementation(const FGameplayTag& NewReTargeterTag) { if (!NewReTargeterTag.IsValid() || NewReTargeterTag == ReTargeterTag) return; @@ -81,3 +225,30 @@ void APHYPlayerState::ServerSetReTargeterTag_Implementation(const FGameplayTag& } } } + +void APHYPlayerState::ServerAddXP_Implementation(int32 DeltaXP) +{ + if (DeltaXP <= 0) + { + return; + } + AddToXP(DeltaXP); +} + +int32 APHYPlayerState::GetXPIntoCurrentLevel() const +{ + int32 L = 0; + int32 Into = 0; + int32 ToNext = 0; + FGameLevelInfo::GetProgressForTotalXP(XP, L, Into, ToNext); + return Into; +} + +int32 APHYPlayerState::GetXPRequirementForNextLevel() const +{ + int32 L = 0; + int32 Into = 0; + int32 ToNext = 0; + FGameLevelInfo::GetProgressForTotalXP(XP, L, Into, ToNext); + return ToNext; +} diff --git a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h index 4407583..747a91d 100644 --- a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h +++ b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h @@ -5,6 +5,7 @@ #include "CoreMinimal.h" #include "AbilitySystemInterface.h" #include "GameplayTagContainer.h" +#include "UObject/SoftObjectPtr.h" #include "GameFramework/PlayerState.h" #include "PHYPlayerState.generated.h" @@ -15,6 +16,20 @@ class UPHYAttributeSet; * 玩家状态类,包含GAS支持 * 用于管理玩家属性、技能等游戏玩法系统 */ +DECLARE_MULTICAST_DELEGATE_OneParam(FOnPlayerStatChanged, int32 /*StatValue*/) +DECLARE_MULTICAST_DELEGATE_TwoParams(FOnLevelChanged, int32 /*StatValue*/, bool /*bLevelUp*/) + +UENUM(BlueprintType) +enum class EPHYNotifyType : uint8 +{ + Info, + Reward, + LevelUp, + Warning +}; + +DECLARE_MULTICAST_DELEGATE_ThreeParams(FOnGameplayNotify, EPHYNotifyType /*Type*/, const FText& /*Message*/, TSoftObjectPtr /*SoftIcon*/) + UCLASS() class PHY_API APHYPlayerState : public APlayerState, public IAbilitySystemInterface { @@ -45,7 +60,13 @@ public: const UPHYAttributeSet* GetAttributeSet() const { return AttributeSet; } // 原有的ReTargeterTag相关代码 - UPROPERTY(VisibleAnywhere,Category = "Config|Character",ReplicatedUsing=OnRep_ReTargeterTagChanged) + FOnPlayerStatChanged OnXPChangedDelegate; + FOnLevelChanged OnLevelChangedDelegate; + FOnPlayerStatChanged OnAttributePointsChangedDelegate; + + FOnGameplayNotify OnGameplayNotifyDelegate; + + UPROPERTY(EditDefaultsOnly,ReplicatedUsing=OnRep_ReTargeterTagChanged) FGameplayTag ReTargeterTag; UFUNCTION() @@ -58,5 +79,59 @@ public: public: UFUNCTION(Server, Reliable,BlueprintCallable) void ServerSetReTargeterTag(const FGameplayTag& NewReTargeterTag); + + /** Debug/Dev: 请求服务器增加累计经验(DeltaXP>0 时生效) */ + UFUNCTION(Server, Reliable, BlueprintCallable, Category="Player|Progress") + void ServerAddXP(int32 DeltaXP); + FGameplayTag GetReTargeterTag() const { return ReTargeterTag; } + + FORCEINLINE int32 GetPlayerLevel() const { return Level; } + FORCEINLINE int32 GetXP() const { return XP; } + FORCEINLINE int32 GetAttributePoints() const { return AttributePoints; } + + /** 当前等级内经验值(0..XPToNext),用于UI经验条 */ + UFUNCTION(BlueprintCallable, Category="Player|Progress") + int32 GetXPIntoCurrentLevel() const; + + /** 当前等级升下一级所需经验(满级返回0),用于UI经验条 */ + UFUNCTION(BlueprintCallable, Category="Player|Progress") + int32 GetXPRequirementForNextLevel() const; + + void AddToXP(int32 InXP); + void AddToLevel(int32 InLevel); + void AddToAttributePoints(int32 InPoints); + void SetXP(int32 InXP); + void SetLevel(int32 InLevel); + void SetAttributePoints(int32 InPoints); +private: + UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_Level) + int32 Level = 1; + + UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_XP) + int32 XP = 0; + + UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_AttributePoints) + int32 AttributePoints = 0; + + UFUNCTION() + void OnRep_Level(int32 OldLevel); + + UFUNCTION() + void OnRep_XP(int32 OldXP); + + UFUNCTION() + void OnRep_AttributePoints(int32 OldAttributePoints); + + /** 仅本地:派发一条提示消息(UI toast / 日志)。不会复制。 */ + void PushGameplayNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon = nullptr); + + /** 仅本地:推送“获得经验”的提示(并不会改动 XP,改动 XP 请走 AddToXP/ServerAddXP)。 */ + void PushGainedXPNotify(int32 DeltaXP); + + /** 仅本地:推送“升级”提示 */ + void PushLevelUpNotify(int32 NewLevel); + + /** 仅本地:推送“获得物品”提示(ItemName x Count) */ + void PushGainedItemNotify(const FText& ItemName, int32 Count); }; diff --git a/Source/PHY/Private/GameplayTags/UITags.cpp b/Source/PHY/Private/GameplayTags/UITags.cpp index 5dbd924..1a87b0f 100644 --- a/Source/PHY/Private/GameplayTags/UITags.cpp +++ b/Source/PHY/Private/GameplayTags/UITags.cpp @@ -1,16 +1,15 @@ -// - +// Copyright 2025 PHY. All Rights Reserved. #include "UITags.h" - namespace UITags { UE_DEFINE_GAMEPLAY_TAG(Tag__UI_Layer_Game, "UI.Layer.Game"); UE_DEFINE_GAMEPLAY_TAG(Tag__UI_Layer_GameMenu, "UI.Layer.GameMenu"); UE_DEFINE_GAMEPLAY_TAG(Tag__UI_Layer_Menu, "UI.Layer.Menu"); UE_DEFINE_GAMEPLAY_TAG(Tag__UI_Layer_Modal, "UI.Layer.Modal"); - - // 拓展点 + + // 扩展点 UE_DEFINE_GAMEPLAY_TAG(Tag__UI_HUD_CharacterInfo, "UI.HUD.CharacterInfo"); + UE_DEFINE_GAMEPLAY_TAG(Tag__UI_HUD_Notify, "UI.HUD.Notify"); } diff --git a/Source/PHY/Private/GameplayTags/UITags.h b/Source/PHY/Private/GameplayTags/UITags.h index a6ba153..94e3fc4 100644 --- a/Source/PHY/Private/GameplayTags/UITags.h +++ b/Source/PHY/Private/GameplayTags/UITags.h @@ -1,20 +1,18 @@ -// +// Copyright 2025 PHY. All Rights Reserved. #pragma once #include "CoreMinimal.h" #include "NativeGameplayTags.h" -/** - * - */ namespace UITags { UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_Layer_Game); UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_Layer_GameMenu); UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_Layer_Menu); UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_Layer_Modal); - - // 拓展点 + + // 扩展点 UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_HUD_CharacterInfo); -}; + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__UI_HUD_Notify); +} diff --git a/Source/PHY/Private/UI/HUD/PHYGameHUD.cpp b/Source/PHY/Private/UI/HUD/PHYGameHUD.cpp index 1c7aaa5..b8d19b8 100644 --- a/Source/PHY/Private/UI/HUD/PHYGameHUD.cpp +++ b/Source/PHY/Private/UI/HUD/PHYGameHUD.cpp @@ -1,5 +1,4 @@ -// - +// Copyright 2025 PHY. All Rights Reserved. #include "PHYGameHUD.h" @@ -68,6 +67,7 @@ void APHYGameHUD::OnAfterPushOverlapWidget(UCommonActivatableWidget* UserWidget) { OverlapWidgetInstance = OverlapWidget; OverlapWidgetInstance->BindAscEvents(BoundAbilitySystemComponent); + OverlapWidgetInstance->BindPlayerStateEvents(BoundPlayerState); } } diff --git a/Source/PHY/Private/UI/Menu/Menu_Overlap.cpp b/Source/PHY/Private/UI/Menu/Menu_Overlap.cpp index aeea063..74bca42 100644 --- a/Source/PHY/Private/UI/Menu/Menu_Overlap.cpp +++ b/Source/PHY/Private/UI/Menu/Menu_Overlap.cpp @@ -1,13 +1,101 @@ -// - +// Copyright 2025 PHY. All Rights Reserved. #include "Menu_Overlap.h" #include "AbilitySystemComponent.h" #include "AbilitySystem/Attributes/PHYAttributeSet.h" +#include "Gameplay/Player/PHYPlayerState.h" #include "GameplayTags/UITags.h" #include "UI/GameUIExtensionPointWidget.h" #include "UI/Widget/Widget_CharacterInfo.h" +#include "UI/Widget/Widget_NotifyList.h" + +class UWidget_NotifyList; + +namespace +{ + static UWidget_CharacterInfo* ResolveCharacterInfoWidget(UGameUIExtensionPointWidget* ExtensionPoint, const FGUIS_GameUIExtHandle& Handle) + { + if (!ExtensionPoint) + { + return nullptr; + } + UUserWidget* CurrentUserInfoWidget = ExtensionPoint->FindExtensionWidgetByHandle(Handle); + return Cast(CurrentUserInfoWidget); + } + + static UWidget_NotifyList* ResolveNotifyListWidget(UGameUIExtensionPointWidget* ExtensionPoint, const FGUIS_GameUIExtHandle& Handle) + { + if (!ExtensionPoint) + { + return nullptr; + } + UUserWidget* W = ExtensionPoint->FindExtensionWidgetByHandle(Handle); + return Cast(W); + } +} + +void UMenu_Overlap::NativeDestruct() +{ + UnbindAll(); + Super::NativeDestruct(); +} + +void UMenu_Overlap::UnbindAll() +{ + UnbindAsc(); + UnbindPlayerState(); +} + +void UMenu_Overlap::UnbindAsc() +{ + if (!bAscBound || !BoundASC) + { + bAscBound = false; + BoundASC = nullptr; + return; + } + + if (const UPHYAttributeSet* Attributes = BoundASC->GetSet()) + { + BoundASC->GetGameplayAttributeValueChangeDelegate(Attributes->GetHealthAttribute()).Remove(HealthChangedHandle); + BoundASC->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxHealthAttribute()).Remove(MaxHealthChangedHandle); + BoundASC->GetGameplayAttributeValueChangeDelegate(Attributes->GetInnerPowerAttribute()).Remove(InnerPowerChangedHandle); + BoundASC->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxInnerPowerAttribute()).Remove(MaxInnerPowerChangedHandle); + } + + HealthChangedHandle.Reset(); + MaxHealthChangedHandle.Reset(); + InnerPowerChangedHandle.Reset(); + MaxInnerPowerChangedHandle.Reset(); + + bAscBound = false; + BoundASC = nullptr; +} + +void UMenu_Overlap::UnbindPlayerState() +{ + if (!bPlayerStateBound || !BoundPlayerState) + { + bPlayerStateBound = false; + BoundPlayerState = nullptr; + return; + } + + if (BoundPlayerState) + { + BoundPlayerState->OnXPChangedDelegate.Remove(XPChangedHandle); + BoundPlayerState->OnLevelChangedDelegate.Remove(LevelChangedHandle); + BoundPlayerState->OnGameplayNotifyDelegate.Remove(GameplayNotifyHandle); + } + + XPChangedHandle.Reset(); + LevelChangedHandle.Reset(); + GameplayNotifyHandle.Reset(); + + bPlayerStateBound = false; + BoundPlayerState = nullptr; +} void UMenu_Overlap::NativePreConstruct() { @@ -29,7 +117,11 @@ void UMenu_Overlap::NativeOnInitialized() { if (CharacterInfoWidgetClass) { - CharacterInfoExtHandle = ExtensionSubsystem->RegisterExtensionAsWidget(UITags::Tag__UI_HUD_CharacterInfo,CharacterInfoWidgetClass, 0); + CharacterInfoExtHandle = ExtensionSubsystem->RegisterExtensionAsWidget(UITags::Tag__UI_HUD_CharacterInfo, CharacterInfoWidgetClass, 0); + } + if (NotifyListWidgetClass) + { + NotifyExtHandle = ExtensionSubsystem->RegisterExtensionAsWidget(UITags::Tag__UI_HUD_Notify, NotifyListWidgetClass, 0); } } } @@ -37,42 +129,152 @@ void UMenu_Overlap::NativeOnInitialized() void UMenu_Overlap::BindAscEvents(UAbilitySystemComponent* AbilitySystemComponent) { if (AbilitySystemComponent == nullptr) return; + + // 防重复绑定:如果换了 ASC 或已经绑定过,先解绑 + if (BoundASC.Get() != AbilitySystemComponent) + { + UnbindAsc(); + } + if (bAscBound) + { + return; + } + + BoundASC = AbilitySystemComponent; + const UPHYAttributeSet* Attributes = AbilitySystemComponent->GetSet(); if (Attributes == nullptr) return; - // 从拓展点获取widget - if (!UserInfoExtensionPoint) return; - UUserWidget* CurrentUserInfoWidget = UserInfoExtensionPoint->FindExtensionWidgetByHandle(CharacterInfoExtHandle); - UWidget_CharacterInfo* CharacterInfoWidget = Cast(CurrentUserInfoWidget); + + UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle); if (CharacterInfoWidget == nullptr) return; - // 同步初始属性值 + CurrentHp = Attributes->GetHealth(); MaxHp = Attributes->GetMaxHealth(); CurrentMp = Attributes->GetInnerPower(); MaxMp = Attributes->GetMaxInnerPower(); - AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetHealthAttribute()).AddLambda( - [this, CharacterInfoWidget](const FOnAttributeChangeData& Data) - { - CurrentHp = Data.NewValue; - CharacterInfoWidget->SetHp(CurrentHp, MaxHp); - }); - AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxHealthAttribute()).AddLambda( - [this, CharacterInfoWidget](const FOnAttributeChangeData& Data) - { - MaxHp = Data.NewValue; - CharacterInfoWidget->SetHp(CurrentHp, MaxHp); - }); - AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetInnerPowerAttribute()).AddLambda( - [this, CharacterInfoWidget](const FOnAttributeChangeData& Data) - { - CurrentMp = Data.NewValue; - CharacterInfoWidget->SetMp(CurrentMp, MaxMp); - }); - AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxInnerPowerAttribute()).AddLambda( - [this, CharacterInfoWidget](const FOnAttributeChangeData& Data) - { - MaxMp = Data.NewValue; - CharacterInfoWidget->SetMp(CurrentMp, MaxMp); - }); + + HealthChangedHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetHealthAttribute()).AddUObject( + this, &UMenu_Overlap::OnHealthChanged); + MaxHealthChangedHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxHealthAttribute()).AddUObject( + this, &UMenu_Overlap::OnMaxHealthChanged); + InnerPowerChangedHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetInnerPowerAttribute()).AddUObject( + this, &UMenu_Overlap::OnInnerPowerChanged); + MaxInnerPowerChangedHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(Attributes->GetMaxInnerPowerAttribute()).AddUObject( + this, &UMenu_Overlap::OnMaxInnerPowerChanged); + + bAscBound = true; + + // 初次同步 CharacterInfoWidget->SetHp(CurrentHp, MaxHp); CharacterInfoWidget->SetMp(CurrentMp, MaxMp); } + +void UMenu_Overlap::BindPlayerStateEvents(APHYPlayerState* PlayerState) +{ + if (!PlayerState) return; + + if (BoundPlayerState.Get() != PlayerState) + { + UnbindPlayerState(); + } + if (bPlayerStateBound) + { + return; + } + + BoundPlayerState = PlayerState; + + UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle); + if (CharacterInfoWidget == nullptr) return; + + CachedLevel = PlayerState->GetPlayerLevel(); + CachedTotalXP = PlayerState->GetXP(); + CurrentXp = static_cast(PlayerState->GetXPIntoCurrentLevel()); + MaxXp = static_cast(PlayerState->GetXPRequirementForNextLevel()); + + CharacterInfoWidget->SetLevel(CachedLevel); + CharacterInfoWidget->SetXp(CurrentXp, MaxXp); + + XPChangedHandle = PlayerState->OnXPChangedDelegate.AddUObject(this, &UMenu_Overlap::OnTotalXPChanged); + LevelChangedHandle = PlayerState->OnLevelChangedDelegate.AddUObject(this, &UMenu_Overlap::OnLevelChanged); + GameplayNotifyHandle = PlayerState->OnGameplayNotifyDelegate.AddUObject(this, &UMenu_Overlap::OnGameplayNotify); + + bPlayerStateBound = true; +} + +// ===== handlers (use handles, no lambdas) ===== + +void UMenu_Overlap::OnHealthChanged(const FOnAttributeChangeData& Data) +{ + CurrentHp = Data.NewValue; + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetHp(CurrentHp, MaxHp); + } +} + +void UMenu_Overlap::OnMaxHealthChanged(const FOnAttributeChangeData& Data) +{ + MaxHp = Data.NewValue; + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetHp(CurrentHp, MaxHp); + } +} + +void UMenu_Overlap::OnInnerPowerChanged(const FOnAttributeChangeData& Data) +{ + CurrentMp = Data.NewValue; + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetMp(CurrentMp, MaxMp); + } +} + +void UMenu_Overlap::OnMaxInnerPowerChanged(const FOnAttributeChangeData& Data) +{ + MaxMp = Data.NewValue; + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetMp(CurrentMp, MaxMp); + } +} + +void UMenu_Overlap::OnTotalXPChanged(int32 NewTotalXP) +{ + CachedTotalXP = NewTotalXP; + if (!BoundPlayerState) return; + + CurrentXp = static_cast(BoundPlayerState->GetXPIntoCurrentLevel()); + MaxXp = static_cast(BoundPlayerState->GetXPRequirementForNextLevel()); + + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetXp(CurrentXp, MaxXp); + } +} + +void UMenu_Overlap::OnLevelChanged(int32 NewLevel, bool bLevelUp) +{ + CachedLevel = NewLevel; + if (UWidget_CharacterInfo* CharacterInfoWidget = ResolveCharacterInfoWidget(UserInfoExtensionPoint, CharacterInfoExtHandle)) + { + CharacterInfoWidget->SetLevel(NewLevel); + } +} + +void UMenu_Overlap::OnGameplayNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon) +{ + if (UWidget_NotifyList* NotifyList = ResolveNotifyListWidget(NotifyExtensionPoint, NotifyExtHandle)) + { + NotifyList->AddNotify(Type, Message, SoftIcon); + return; + } + + UE_LOG(LogTemp, Log, TEXT("[GameplayNotify][%d] %s"), static_cast(Type), *Message.ToString()); + if (GEngine) + { + const FColor Color = (Type == EPHYNotifyType::LevelUp) ? FColor::Yellow : FColor::Green; + GEngine->AddOnScreenDebugMessage(-1, 2.0f, Color, Message.ToString()); + } +} \ No newline at end of file diff --git a/Source/PHY/Private/UI/Menu/Menu_Overlap.h b/Source/PHY/Private/UI/Menu/Menu_Overlap.h index 3656f9d..8a92ec6 100644 --- a/Source/PHY/Private/UI/Menu/Menu_Overlap.h +++ b/Source/PHY/Private/UI/Menu/Menu_Overlap.h @@ -3,12 +3,15 @@ #pragma once #include "CoreMinimal.h" +#include "AbilitySystem/Attributes/PHYAttributeSet.h" +#include "Gameplay/Player/PHYPlayerState.h" #include "UI/GUIS_ActivatableWidget.h" #include "UIExtension/GUIS_GameUIExtensionSubsystem.h" #include "Menu_Overlap.generated.h" class UAbilitySystemComponent; class UGameUIExtensionPointWidget; +class APHYPlayerState; /** * */ @@ -19,23 +22,70 @@ class PHY_API UMenu_Overlap : public UGUIS_ActivatableWidget UPROPERTY(meta=(BindWidget)) UGameUIExtensionPointWidget* UserInfoExtensionPoint; + + /** 古风 HUD 提示/飘字区域扩展点(例如右侧竖排提示) */ + UPROPERTY(meta=(BindWidgetOptional)) + UGameUIExtensionPointWidget* NotifyExtensionPoint; + UPROPERTY(EditDefaultsOnly, Category="Sub Widget Class") TSubclassOf CharacterInfoWidgetClass; + + /** 提示列表 Widget 类(用于挂载到 UITags::Tag__UI_HUD_Notify) */ + UPROPERTY(EditDefaultsOnly, Category="Sub Widget Class") + TSubclassOf NotifyListWidgetClass; + FGUIS_GameUIExtHandle CharacterInfoExtHandle; + FGUIS_GameUIExtHandle NotifyExtHandle; protected: virtual void NativePreConstruct() override; virtual void NativeOnActivated() override; virtual void NativeOnInitialized() override; - + virtual void NativeDestruct() override; + public: void BindAscEvents(UAbilitySystemComponent* AbilitySystemComponent); - + void BindPlayerStateEvents(APHYPlayerState* PlayerState); + private: + void UnbindAll(); + void UnbindAsc(); + void UnbindPlayerState(); + + // ASC handlers + void OnHealthChanged(const FOnAttributeChangeData& Data); + void OnMaxHealthChanged(const FOnAttributeChangeData& Data); + void OnInnerPowerChanged(const FOnAttributeChangeData& Data); + void OnMaxInnerPowerChanged(const FOnAttributeChangeData& Data); + + // PlayerState handlers + void OnTotalXPChanged(int32 NewTotalXP); + void OnLevelChanged(int32 NewLevel, bool bLevelUp); + void OnGameplayNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon); + + UPROPERTY(Transient) + TObjectPtr BoundASC; + UPROPERTY(Transient) + TObjectPtr BoundPlayerState; + + bool bAscBound = false; + bool bPlayerStateBound = false; + + FDelegateHandle HealthChangedHandle; + FDelegateHandle MaxHealthChangedHandle; + FDelegateHandle InnerPowerChangedHandle; + FDelegateHandle MaxInnerPowerChangedHandle; + + FDelegateHandle XPChangedHandle; + FDelegateHandle LevelChangedHandle; + FDelegateHandle GameplayNotifyHandle; + float CurrentHp = 0.f; float MaxHp = 0.f; float CurrentMp = 0.f; float MaxMp = 0.f; float CurrentXp = 0.f; float MaxXp = 0.f; -}; + int32 CachedLevel = 1; + int32 CachedTotalXP = 0; +}; \ No newline at end of file diff --git a/Source/PHY/Private/UI/PHYUIIconSet.cpp b/Source/PHY/Private/UI/PHYUIIconSet.cpp new file mode 100644 index 0000000..08a40f4 --- /dev/null +++ b/Source/PHY/Private/UI/PHYUIIconSet.cpp @@ -0,0 +1,4 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#include "UI/PHYUIIconSet.h" + diff --git a/Source/PHY/Private/UI/PHYUIIconSet.h b/Source/PHY/Private/UI/PHYUIIconSet.h new file mode 100644 index 0000000..b28a4dc --- /dev/null +++ b/Source/PHY/Private/UI/PHYUIIconSet.h @@ -0,0 +1,29 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "PHYUIIconSet.generated.h" + +class UTexture2D; + +/** + * UI 图标集合(全局固定图标)。 + * 例如:经验、升级、金币、提示等。 + */ +UCLASS(BlueprintType) +class PHY_API UPHYUIIconSet : public UDataAsset +{ + GENERATED_BODY() + +public: + /** 获得经验提示图标 */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="PHY|UI") + TSoftObjectPtr XP; + + /** 升级提示图标 */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="PHY|UI") + TSoftObjectPtr LevelUp; +}; + diff --git a/Source/PHY/Private/UI/Widget/Widget_NotifyItem.cpp b/Source/PHY/Private/UI/Widget/Widget_NotifyItem.cpp new file mode 100644 index 0000000..152b82a --- /dev/null +++ b/Source/PHY/Private/UI/Widget/Widget_NotifyItem.cpp @@ -0,0 +1,126 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#include "UI/Widget/Widget_NotifyItem.h" + +#include "Components/Image.h" +#include "Components/TextBlock.h" + +namespace +{ + static float EaseOut(float T) + { + // 轻量 ease-out + return 1.f - FMath::Square(1.f - T); + } +} + +void UWidget_NotifyItem::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) +{ + Super::NativeTick(MyGeometry, InDeltaTime); + + if (!bAnimating) + { + return; + } + + AnimTime += InDeltaTime; + + if (!bFadingOut) + { + const float D = FMath::Max(0.001f, AppearDuration); + const float Alpha = FMath::Clamp(AnimTime / D, 0.f, 1.f); + const float E = EaseOut(Alpha); + + // 从下往上浮:Y 从 FloatDistance -> 0 + SetRenderTranslation(FVector2D(0.f, FMath::Lerp(FloatDistance, 0.f, E))); + SetRenderOpacity(E); + SetRenderScale(FVector2D(FMath::Lerp(AppearStartScale, 1.f, E), FMath::Lerp(AppearStartScale, 1.f, E))); + + if (Alpha >= 1.f) + { + bAnimating = false; + AnimTime = 0.f; + } + } + else + { + const float D = FMath::Max(0.001f, FadeOutDuration); + const float Alpha = FMath::Clamp(AnimTime / D, 0.f, 1.f); + const float E = EaseOut(Alpha); + + SetRenderOpacity(1.f - E); + // 退出时再轻微上浮一点 + SetRenderTranslation(FVector2D(0.f, FMath::Lerp(0.f, -FadeOutExtraFloat, E))); + // 缩放恢复到 1 + SetRenderScale(FVector2D(1.f, 1.f)); + + if (Alpha >= 1.f) + { + bAnimating = false; + AnimTime = 0.f; + } + } +} + +void UWidget_NotifyItem::PlayAppearAnim() +{ + bAnimating = true; + bFadingOut = false; + AnimTime = 0.f; + + // 初值 + SetRenderTranslation(FVector2D(0.f, FloatDistance)); + SetRenderOpacity(0.f); + SetRenderScale(FVector2D(AppearStartScale, AppearStartScale)); +} + +void UWidget_NotifyItem::PlayExitAnim() +{ + bAnimating = true; + bFadingOut = true; + AnimTime = 0.f; + + // 退出从当前位置开始即可 +} + +void UWidget_NotifyItem::SetMessage(const FText& InText) +{ + if (MessageText) + { + MessageText->SetText(InText); + } +} + +void UWidget_NotifyItem::SetColor(const FLinearColor& InColor) +{ + if (MessageText) + { + MessageText->SetColorAndOpacity(FSlateColor(InColor)); + } +} + +void UWidget_NotifyItem::SetIcon(UTexture2D* InTexture) +{ + if (!IconImage) + { + return; + } + + if (InTexture) + { + IconImage->SetBrushFromTexture(InTexture, false); + IconImage->SetVisibility(ESlateVisibility::SelfHitTestInvisible); + } + else + { + IconImage->SetVisibility(ESlateVisibility::Collapsed); + } +} + +void UWidget_NotifyItem::SetupNotify(const FText& InText, const FLinearColor& InColor, UTexture2D* InIcon) +{ + SetMessage(InText); + SetColor(InColor); + SetIcon(InIcon); + PlayAppearAnim(); +} diff --git a/Source/PHY/Private/UI/Widget/Widget_NotifyItem.h b/Source/PHY/Private/UI/Widget/Widget_NotifyItem.h new file mode 100644 index 0000000..3aebf00 --- /dev/null +++ b/Source/PHY/Private/UI/Widget/Widget_NotifyItem.h @@ -0,0 +1,74 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Widget_NotifyItem.generated.h" + +class UTextBlock; +class UImage; +class UTexture2D; + +UCLASS() +class PHY_API UWidget_NotifyItem : public UUserWidget +{ + GENERATED_BODY() + +protected: + UPROPERTY(meta=(BindWidgetOptional)) + TObjectPtr MessageText; + + UPROPERTY(meta=(BindWidgetOptional)) + TObjectPtr IconImage; + + /** 上浮距离(像素) */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI|Anim") + float FloatDistance = 18.0f; + + /** 出现动画时长(秒) */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI|Anim") + float AppearDuration = 0.12f; + + /** 淡出动画时长(秒) */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI|Anim") + float FadeOutDuration = 0.20f; + + /** 出现时的起始缩放(1.0 为不缩放),建议 0.96~1.0 */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI|Anim") + float AppearStartScale = 0.98f; + + /** 淡出阶段额外上浮距离(像素) */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI|Anim") + float FadeOutExtraFloat = 6.0f; + + bool bAnimating = false; + bool bFadingOut = false; + float AnimTime = 0.f; + + virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; + +public: + UFUNCTION(BlueprintCallable) + void SetMessage(const FText& InText); + + UFUNCTION(BlueprintCallable) + void SetColor(const FLinearColor& InColor); + + UFUNCTION(BlueprintCallable) + void SetIcon(UTexture2D* InTexture); + + UFUNCTION(BlueprintCallable) + void SetupNotify(const FText& InText, const FLinearColor& InColor, UTexture2D* InIcon); + + /** 播放出现动画(上浮+从透明到不透明) */ + UFUNCTION(BlueprintCallable) + void PlayAppearAnim(); + + /** 播放淡出动画(淡出后自动移除由容器负责) */ + UFUNCTION(BlueprintCallable) + void PlayExitAnim(); + + UFUNCTION(BlueprintPure) + float GetFadeOutDuration() const { return FadeOutDuration; } +}; diff --git a/Source/PHY/Private/UI/Widget/Widget_NotifyList.cpp b/Source/PHY/Private/UI/Widget/Widget_NotifyList.cpp new file mode 100644 index 0000000..6cbb96f --- /dev/null +++ b/Source/PHY/Private/UI/Widget/Widget_NotifyList.cpp @@ -0,0 +1,236 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#include "UI/Widget/Widget_NotifyList.h" + +#include "Engine/Texture2D.h" +#include "Components/VerticalBox.h" +#include "Components/Spacer.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "UI/Widget/Widget_NotifyItem.h" +#include "Engine/AssetManager.h" +#include "Engine/StreamableManager.h" +#include "Gameplay/Player/PHYPlayerState.h" + +namespace +{ + static FStreamableManager& GetStreamable() + { + return UAssetManager::GetStreamableManager(); + } +} + +// SoftIcon notifications: no GameplayTag mapping + +FLinearColor UWidget_NotifyList::ResolveColor(EPHYNotifyType Type) const +{ + switch (Type) + { + case EPHYNotifyType::Info: + return Color_Info; + case EPHYNotifyType::Reward: + return Color_Reward; + case EPHYNotifyType::LevelUp: + return Color_LevelUp; + case EPHYNotifyType::Warning: + return Color_Warning; + default: + return Color_Info; + } +} + +void UWidget_NotifyList::AddNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon) +{ + if (!Container) + { + UE_LOG(LogTemp, Warning, TEXT("NotifyList: Container is null. Check WBP_NotifyList has a VerticalBox named 'Container'.")); + return; + } + if (!NotifyItemClass) + { + return; + } + + UWidget_NotifyItem* Item = CreateWidget(GetWorld(), NotifyItemClass); + if (!Item) + { + return; + } + + // 异步图标:先使用缓存/空图标,加载完成后再补上。 + UTexture2D* Icon = nullptr; + if (!SoftIcon.IsNull()) + { + const FSoftObjectPath Path = SoftIcon.ToSoftObjectPath(); + if (TObjectPtr Cached = LoadedIconCache.FindRef(Path)) + { + Icon = Cached; + } + else + { + // 先不阻塞,等加载完成再 SetIcon + TWeakObjectPtr WeakItem(Item); + GetStreamable().RequestAsyncLoad(Path, FStreamableDelegate::CreateWeakLambda(this, [this, WeakItem, Path]() + { + if (!IsValid(this)) + { + return; + } + + UObject* Obj = Path.ResolveObject(); + UTexture2D* Loaded = Cast(Obj); + if (Loaded) + { + LoadedIconCache.Add(Path, Loaded); + if (WeakItem.IsValid()) + { + WeakItem->SetIcon(Loaded); + } + } + })); + } + } + + Item->SetupNotify(Message, ResolveColor(Type), Icon); + + // 约定:Container 内部严格按 [Spacer, Item] 成对存放。 + USpacer* Spacer = NewObject(this); + Spacer->SetVisibility(ESlateVisibility::SelfHitTestInvisible); + Spacer->SetSize(FVector2D(1.f, 0.f)); // 新消息永远是最外侧,因此前导 Spacer=0 + + const int32 OldCount = Container->GetChildrenCount(); + + if (bStackFromBottom) + { + // 追加到末尾 + Container->AddChildToVerticalBox(Spacer); + Container->AddChildToVerticalBox(Item); + + // 原来的“最外侧”(旧的最后一对)的 Spacer 需要从 0 变为 ItemSpacing + if (OldCount >= 2) + { + const int32 PrevOuterSpacerIndex = OldCount - 2; + if (USpacer* PrevOuterSpacer = Cast(Container->GetChildAt(PrevOuterSpacerIndex))) + { + PrevOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing)); + } + } + } + else + { + // 插到开头 + Container->InsertChildAt(0, Item); + Container->InsertChildAt(0, Spacer); + + // 原来的“最外侧”(旧的第一对)的 Spacer 需要从 0 变为 ItemSpacing(它现在变成第二对了) + if (OldCount >= 2) + { + const int32 PrevOuterSpacerIndex = 2; // 插入了两项,新外侧在 0,原外侧 spacer 被挤到 index=2 + if (USpacer* PrevOuterSpacer = Cast(Container->GetChildAt(PrevOuterSpacerIndex))) + { + PrevOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing)); + } + } + } + + TrimItems(); + + FTimerHandle Tmp; + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(Tmp, FTimerDelegate::CreateWeakLambda(this, [this, Item, Spacer]() + { + if (!Container || !Item || !Spacer) + { + return; + } + + Item->PlayExitAnim(); + const float RemoveDelay = FMath::Max(0.01f, Item->GetFadeOutDuration()); + + FTimerHandle RemoveHandle; + if (UWorld* W = GetWorld()) + { + W->GetTimerManager().SetTimer(RemoveHandle, FTimerDelegate::CreateWeakLambda(this, [this, Item, Spacer]() + { + if (!Container) return; + + Container->RemoveChild(Item); + Container->RemoveChild(Spacer); + + TrimItems(); + }), RemoveDelay, false); + } + }), ItemLifeSeconds, false); + } +} + +void UWidget_NotifyList::TrimItems() +{ + if (!Container) return; + + // 容错:如果不成对,丢掉最后一个 + if (Container->GetChildrenCount() % 2 != 0) + { + Container->RemoveChildAt(Container->GetChildrenCount() - 1); + } + + // 删除多余的最旧 pair + while (Container->GetChildrenCount() / 2 > MaxItems) + { + if (bStackFromBottom) + { + Container->RemoveChildAt(0); + Container->RemoveChildAt(0); + } + else + { + const int32 Last = Container->GetChildrenCount() - 1; + Container->RemoveChildAt(Last); + Container->RemoveChildAt(Last - 1); + } + } + + // 重新校正外侧 / 次外侧的 Spacer: + // 外侧 Spacer = 0;次外侧 Spacer = ItemSpacing。 + const int32 Count = Container->GetChildrenCount(); + if (Count < 2) + { + return; + } + + if (bStackFromBottom) + { + // 外侧是最后一对的 spacer + const int32 OuterSpacerIndex = Count - 2; + if (USpacer* OuterSpacer = Cast(Container->GetChildAt(OuterSpacerIndex))) + { + OuterSpacer->SetSize(FVector2D(1.f, 0.f)); + } + // 次外侧(如果存在) + if (Count >= 4) + { + const int32 SecondOuterSpacerIndex = Count - 4; + if (USpacer* SecondOuterSpacer = Cast(Container->GetChildAt(SecondOuterSpacerIndex))) + { + SecondOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing)); + } + } + } + else + { + // 外侧是第一对的 spacer + if (USpacer* OuterSpacer = Cast(Container->GetChildAt(0))) + { + OuterSpacer->SetSize(FVector2D(1.f, 0.f)); + } + // 次外侧(如果存在) + if (Count >= 4) + { + if (USpacer* SecondOuterSpacer = Cast(Container->GetChildAt(2))) + { + SecondOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing)); + } + } + } +} diff --git a/Source/PHY/Private/UI/Widget/Widget_NotifyList.h b/Source/PHY/Private/UI/Widget/Widget_NotifyList.h new file mode 100644 index 0000000..3c1114c --- /dev/null +++ b/Source/PHY/Private/UI/Widget/Widget_NotifyList.h @@ -0,0 +1,64 @@ +// Copyright 2025 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "UObject/SoftObjectPtr.h" +#include "Widget_NotifyList.generated.h" + +class UWidget_NotifyItem; +class UVerticalBox; +enum class EPHYNotifyType : uint8; + +class UTexture2D; + +UCLASS() +class PHY_API UWidget_NotifyList : public UUserWidget +{ + GENERATED_BODY() + +protected: + UPROPERTY(meta=(BindWidgetOptional)) + TObjectPtr Container; + + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + TSubclassOf NotifyItemClass; + + // 古风HUD通常右侧竖排提示,保留 5 条比较舒服 + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + int32 MaxItems = 5; + + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + float ItemLifeSeconds = 2.0f; + + /** 从下往上冒:true=新消息插到末尾(底部),列表向上堆叠;false=插到顶部 */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + bool bStackFromBottom = true; + + /** 不同类型的颜色 */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + FLinearColor Color_Info = FLinearColor(0.85f, 0.85f, 0.85f); + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + FLinearColor Color_Reward = FLinearColor(0.25f, 0.95f, 0.25f); + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + FLinearColor Color_LevelUp = FLinearColor(1.0f, 0.85f, 0.2f); + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + FLinearColor Color_Warning = FLinearColor(1.0f, 0.25f, 0.25f); + + /** 两个提示条目之间的间隙(像素)。建议 4~12。 */ + UPROPERTY(EditDefaultsOnly, Category="PHY|UI") + float ItemSpacing = 6.0f; + +public: + UFUNCTION(BlueprintCallable) + void AddNotify(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon); + +private: + FLinearColor ResolveColor(EPHYNotifyType Type) const; + void TrimItems(); + + /** 已加载图标缓存,避免反复异步加载 */ + UPROPERTY(Transient) + TMap> LoadedIconCache; +};