添加信息系统

This commit is contained in:
不明不惑
2026-03-05 01:34:47 +08:00
parent 6d0fb2c928
commit 70e3731c09
22 changed files with 1230 additions and 63 deletions

View File

@@ -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")

View File

@@ -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;

View File

@@ -0,0 +1,90 @@
//
#include "GameLevelInfo.h"
namespace
{
// TotalXPThreshold[Level]到达该等级的累计经验阈值Level=1..100)。
// - 下标与等级一致。
// - Threshold[1]=0。
// - 总经验到达100级阈值约 45,000,020。
static const TStaticArray<int32, FGameLevelInfo::MaxLevel + 1> 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<int32, FGameLevelInfo::MaxLevel + 1> 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);
}

View File

@@ -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<int32, MaxLevel + 1> TotalXPThreshold;
};

View File

@@ -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<UPHYClassDefaults> ClassDefaults;
/** 全局UI固定图标集合经验/升级等) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Config|UI", meta=(AllowPrivateAccess=true))
TObjectPtr<UPHYUIIconSet> UIIconSet;
public:
TOptional<FRetargetInfo> GetRetargetInfo(const FGameplayTag& RetargetInfoTag) const;
const UPHYClassDefaults* GetClassDefaults() const { return ClassDefaults; }
const UPHYUIIconSet* GetUIIconSet() const { return UIIconSet; }
};

View File

@@ -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<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
AttributeSet = CreateDefaultSubobject<UPHYAttributeSet>(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<class FLifetimeProperty>
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<UTexture2D> SoftIcon)
{
OnGameplayNotifyDelegate.Broadcast(Type, Message, SoftIcon);
}
void APHYPlayerState::PushGainedXPNotify(int32 DeltaXP)
{
if (DeltaXP <= 0) return;
const UPHYUIIconSet* IconSet = nullptr;
if (const UPHYGameInstance* GI = GetGameInstance<UPHYGameInstance>())
{
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<UPHYGameInstance>())
{
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;
}

View File

@@ -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<UTexture2D> /*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<UTexture2D> SoftIcon = nullptr);
/** 仅本地:推送“获得经验”的提示(并不会改动 XP改动 XP 请走 AddToXP/ServerAddXP。 */
void PushGainedXPNotify(int32 DeltaXP);
/** 仅本地:推送“升级”提示 */
void PushLevelUpNotify(int32 NewLevel);
/** 仅本地推送“获得物品”提示ItemName x Count */
void PushGainedItemNotify(const FText& ItemName, int32 Count);
};

View File

@@ -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");
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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<UWidget_CharacterInfo>(CurrentUserInfoWidget);
}
static UWidget_NotifyList* ResolveNotifyListWidget(UGameUIExtensionPointWidget* ExtensionPoint, const FGUIS_GameUIExtHandle& Handle)
{
if (!ExtensionPoint)
{
return nullptr;
}
UUserWidget* W = ExtensionPoint->FindExtensionWidgetByHandle(Handle);
return Cast<UWidget_NotifyList>(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<UPHYAttributeSet>())
{
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<UPHYAttributeSet>();
if (Attributes == nullptr) return;
// 从拓展点获取widget
if (!UserInfoExtensionPoint) return;
UUserWidget* CurrentUserInfoWidget = UserInfoExtensionPoint->FindExtensionWidgetByHandle(CharacterInfoExtHandle);
UWidget_CharacterInfo* CharacterInfoWidget = Cast<UWidget_CharacterInfo>(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<float>(PlayerState->GetXPIntoCurrentLevel());
MaxXp = static_cast<float>(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<float>(BoundPlayerState->GetXPIntoCurrentLevel());
MaxXp = static_cast<float>(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<UTexture2D> SoftIcon)
{
if (UWidget_NotifyList* NotifyList = ResolveNotifyListWidget(NotifyExtensionPoint, NotifyExtHandle))
{
NotifyList->AddNotify(Type, Message, SoftIcon);
return;
}
UE_LOG(LogTemp, Log, TEXT("[GameplayNotify][%d] %s"), static_cast<int32>(Type), *Message.ToString());
if (GEngine)
{
const FColor Color = (Type == EPHYNotifyType::LevelUp) ? FColor::Yellow : FColor::Green;
GEngine->AddOnScreenDebugMessage(-1, 2.0f, Color, Message.ToString());
}
}

View File

@@ -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<UUserWidget> CharacterInfoWidgetClass;
/** 提示列表 Widget 类(用于挂载到 UITags::Tag__UI_HUD_Notify */
UPROPERTY(EditDefaultsOnly, Category="Sub Widget Class")
TSubclassOf<UUserWidget> 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<UTexture2D> SoftIcon);
UPROPERTY(Transient)
TObjectPtr<UAbilitySystemComponent> BoundASC;
UPROPERTY(Transient)
TObjectPtr<APHYPlayerState> 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;
};

View File

@@ -0,0 +1,4 @@
// Copyright 2025 PHY. All Rights Reserved.
#include "UI/PHYUIIconSet.h"

View File

@@ -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<UTexture2D> XP;
/** 升级提示图标 */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="PHY|UI")
TSoftObjectPtr<UTexture2D> LevelUp;
};

View File

@@ -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();
}

View File

@@ -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<UTextBlock> MessageText;
UPROPERTY(meta=(BindWidgetOptional))
TObjectPtr<UImage> 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; }
};

View File

@@ -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<UTexture2D> 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<UWidget_NotifyItem>(GetWorld(), NotifyItemClass);
if (!Item)
{
return;
}
// 异步图标:先使用缓存/空图标,加载完成后再补上。
UTexture2D* Icon = nullptr;
if (!SoftIcon.IsNull())
{
const FSoftObjectPath Path = SoftIcon.ToSoftObjectPath();
if (TObjectPtr<UTexture2D> Cached = LoadedIconCache.FindRef(Path))
{
Icon = Cached;
}
else
{
// 先不阻塞,等加载完成再 SetIcon
TWeakObjectPtr<UWidget_NotifyItem> WeakItem(Item);
GetStreamable().RequestAsyncLoad(Path, FStreamableDelegate::CreateWeakLambda(this, [this, WeakItem, Path]()
{
if (!IsValid(this))
{
return;
}
UObject* Obj = Path.ResolveObject();
UTexture2D* Loaded = Cast<UTexture2D>(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<USpacer>(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<USpacer>(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<USpacer>(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<USpacer>(Container->GetChildAt(OuterSpacerIndex)))
{
OuterSpacer->SetSize(FVector2D(1.f, 0.f));
}
// 次外侧(如果存在)
if (Count >= 4)
{
const int32 SecondOuterSpacerIndex = Count - 4;
if (USpacer* SecondOuterSpacer = Cast<USpacer>(Container->GetChildAt(SecondOuterSpacerIndex)))
{
SecondOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing));
}
}
}
else
{
// 外侧是第一对的 spacer
if (USpacer* OuterSpacer = Cast<USpacer>(Container->GetChildAt(0)))
{
OuterSpacer->SetSize(FVector2D(1.f, 0.f));
}
// 次外侧(如果存在)
if (Count >= 4)
{
if (USpacer* SecondOuterSpacer = Cast<USpacer>(Container->GetChildAt(2)))
{
SecondOuterSpacer->SetSize(FVector2D(1.f, ItemSpacing));
}
}
}
}

View File

@@ -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<UVerticalBox> Container;
UPROPERTY(EditDefaultsOnly, Category="PHY|UI")
TSubclassOf<UWidget_NotifyItem> 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<UTexture2D> SoftIcon);
private:
FLinearColor ResolveColor(EPHYNotifyType Type) const;
void TrimItems();
/** 已加载图标缓存,避免反复异步加载 */
UPROPERTY(Transient)
TMap<FSoftObjectPath, TObjectPtr<UTexture2D>> LoadedIconCache;
};