diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index 901d060..1a7b42d 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -88,6 +88,7 @@ FontDPI=72 [/Script/Engine.Engine] +ActiveGameNameRedirects=(OldGameName="TP_Blank",NewGameName="/Script/PHY") +ActiveGameNameRedirects=(OldGameName="/Script/TP_Blank",NewGameName="/Script/PHY") +GameViewportClientClassName=/Script/CommonUI.CommonGameViewportClient [/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] bEnablePlugin=True diff --git a/Content/AGame/Character/Player/Player_Base.uasset b/Content/AGame/Character/Player/Player_Base.uasset index 07c559e..51984f0 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/UI/Menu/Overlap/WBP_Menu_Overlap.uasset b/Content/AGame/UI/Menu/Overlap/WBP_Menu_Overlap.uasset index c6b1e6d..b4dca1a 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/PHY_Editor_Integration_Checklist.md b/PHY_Editor_Integration_Checklist.md new file mode 100644 index 0000000..90ca942 --- /dev/null +++ b/PHY_Editor_Integration_Checklist.md @@ -0,0 +1,137 @@ +# PHY 编辑器打通清单(装备/药品/通知) + +> 目标:打通你当前 C++ 已实现的链路: +> - 装备穿脱 -> 属性生效/回滚 +> - 药品使用(Server RPC)-> 扣除库存 -> 恢复属性 +> - 通知飘字(Info/Reward/Warning) + +## 0. 代码前提(已实现) + +- `APHYCharacter` 已挂载:`InventorySystem` + `EquipmentSystem`。 +- 服务器 `BeginPlay` 会初始化库存与装备系统。 +- `APHYPlayerCharacter::ServerUseConsumableByItemId` 已有严格校验: + - 必须是 `GIS.Item.Type.Consumable` + - 找不到道具/扣除失败会发 Warning 通知 +- `APHYPlayerState` 已接入 owner 通知分发(支持 DS/Listen/Standalone)。 + +## 1. GameplayTags(必须先做) + +请在 `Project Settings -> Gameplay Tags` 或 `Config/DefaultGameplayTags.ini` 中确认存在: + +### 1.1 类型标签(必须) + +- `GIS.Item.Type.Weapon` +- `GIS.Item.Type.Equipment` +- `GIS.Item.Type.Consumable` +- `GIS.Item.Type.Material` + +### 1.2 药品效果标签(必须) + +- `GIS.Attribute.Item.Consumable.RestoreHealth` +- `GIS.Attribute.Item.Consumable.RestoreInnerPower` + +### 1.3 装备属性标签(建议) + +- `GIS.Attribute.Item.Attack` +- `GIS.Attribute.Item.Defense` +- `GIS.Attribute.Item.CritRate` +- `GIS.Attribute.Item.CritDamage` +- `GIS.Attribute.Item.Hit` +- `GIS.Attribute.Item.Dodge` +- `GIS.Attribute.Item.Parry` +- `GIS.Attribute.Item.CounterChance` +- `GIS.Attribute.Item.ArmorPenetration` +- `GIS.Attribute.Item.DamageReduction` +- `GIS.Attribute.Item.LifeSteal` +- `GIS.Attribute.Item.Resilience` +- `GIS.Attribute.Item.MoveSpeed` +- `GIS.Attribute.Item.HealthRegenRate` +- `GIS.Attribute.Item.InnerPowerRegenRate` +- `GIS.Attribute.Item.MaxHealth` +- `GIS.Attribute.Item.MaxInnerPower` + +## 2. 角色蓝图组件配置 + +打开你的玩家角色蓝图(继承 `APHYPlayerCharacter`): + +1. 选中 `InventorySystem` 组件: + - 确认存在普通背包集合(如 `GIS.Collection.Inventory`) + - 确认存在装备槽集合(如 `GIS.Collection.Equipped`,SlotCollection) +2. 选中 `EquipmentSystem` 组件: + - `TargetCollectionTag` 设为装备槽集合标签(如 `GIS.Collection.Equipped`) + +> 若 `TargetCollectionTag` 不匹配,装备状态事件不会触发,属性不会生效。 + +## 3. 创建三类物品(UGIS_ItemDefinition) + +## 3.1 药品示例:`ID_Cons_HealthSmall` + +- `DisplayName`: 小还丹 +- `ItemTags`: 添加 `GIS.Item.Type.Consumable` +- 图标:设置 `Icon` +- 动态或静态属性至少配置其一: + - `GIS.Attribute.Item.Consumable.RestoreHealth = 120` + - `GIS.Attribute.Item.Consumable.RestoreInnerPower = 0` + +## 3.2 武器示例:`ID_Wpn_BronzeSword` + +- `DisplayName`: 青铜剑 +- `ItemTags`: 添加 `GIS.Item.Type.Weapon` +- 属性: + - `GIS.Attribute.Item.Attack = 35` + - `GIS.Attribute.Item.CritRate = 0.03` + +## 3.3 装备示例:`ID_Equip_ClothArmor` + +- `DisplayName`: 布甲 +- `ItemTags`: 添加 `GIS.Item.Type.Equipment` +- 属性: + - `GIS.Attribute.Item.Defense = 20` + - `GIS.Attribute.Item.DamageReduction = 0.02` + +## 4. 物品“使用”按钮绑定(UI) + +在你的物品操作逻辑中(右键菜单/快捷键): + +1. 从 `UItemData` 取 `ItemInfo.Item` +2. `Item->GetItemId()` 拿到 `FGuid` +3. `GetOwningPlayerPawn` -> Cast `APHYPlayerCharacter` +4. 调用:`ServerUseConsumableByItemId(ItemId, 1)` + +## 5. 通知验证点 + +你当前 UI 会接 `OnGameplayNotifyDelegate`,预期: + +- 使用药品成功:`Reward`(例如:使用 小还丹 x1) +- 使用非药品:`Warning` +- 数量不足/扣除失败:`Warning` +- 装备成功:`Info`(装备 xxx) +- 卸下成功:`Info`(卸下 xxx) + +## 6. 联机验收顺序(建议) + +1. Standalone +2. Listen Server + 1 Client +3. Dedicated Server + 1 Client + +每轮都检查: + +- 药品是否扣库存 +- 生命/内力是否恢复 +- 装备/卸下是否改变属性 +- 通知是否只在拥有者客户端显示 + +## 7. 常见问题 + +- **现象:药品点了无反应** + - 常见原因:缺少 `GIS.Item.Type.Consumable` +- **现象:装备后属性不变** + - 常见原因:`EquipmentSystem.TargetCollectionTag` 不匹配装备集合 +- **现象:Dedicated Server 下无提示** + - 先确认 HUD/UI 绑定是否在客户端建立;`PlayerState` 已支持 owner 通知分发 + +--- + +如果你需要,我可以继续生成第二份文档: +- `PHY_Item_Asset_Convention.md`(命名规范 + 品质色 + 图标规范 + 掉落表字段模板) + diff --git a/PHY_ItemSystem_Attributes.md b/PHY_ItemSystem_Attributes.md new file mode 100644 index 0000000..7d5e064 --- /dev/null +++ b/PHY_ItemSystem_Attributes.md @@ -0,0 +1,96 @@ +# PHY Item Attribute System (GIS) + +## 本次新增 + +- `Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.h` +- `Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.cpp` +- `Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.h` +- `Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.cpp` +- `Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.h` +- `Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.cpp` +- `Source/PHY/Private/Character/PHYCharacter.h` +- `Source/PHY/Private/Character/PHYCharacter.cpp` +- `Source/PHY/Private/Character/PHYPlayerCharacter.h` +- `Source/PHY/Private/Character/PHYPlayerCharacter.cpp` +- `Source/PHY/Private/Gameplay/Player/PHYPlayerState.h` +- `Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp` + +## 新增联动能力(12) + +### 1) 装备属性自动生效/回滚(服务器权威) + +- 在基础角色上新增 `UGIS_EquipmentSystemComponent`。 +- 服务器 `BeginPlay` 时:先初始化 `InventorySystem`,再初始化 `EquipmentSystem`。 +- `APHYPlayerCharacter` 监听 `OnEquipmentStateChangedEvent`。 +- 事件触发后通过 `APHYPlayerState::HandleItemEquippedState` 应用或回滚属性。 +- 使用 `AppliedEquipmentItems` 防止同一个 ItemId 重复叠加。 + +### 2) 药品 Server RPC 使用链路 + +- 在 `APHYPlayerCharacter` 新增: + - `UFUNCTION(Server, Reliable) ServerUseConsumableByItemId(FGuid ItemId, int32 Count=1)` +- 服务端流程: + - 按 `ItemId` 在库存中查找道具 + - 扣减库存数量(`RemoveItem`) + - 调用 `APHYPlayerState::ApplyConsumableFromItem` 应用恢复 +- 当前默认读取道具动态属性标签: + - `GIS.Attribute.Item.Consumable.RestoreHealth` + - `GIS.Attribute.Item.Consumable.RestoreInnerPower` + +## 设计 + +- 基于 GIS 的 `UGIS_ItemDefinition` + Fragment。 +- 新增 `UPHYItemFragment_PropertySet` 作为统一“武器/装备/药品”属性片段。 +- 道具实例创建时会把 `BaseFloatModifiers` / `BaseIntegerModifiers` 写入 `UGIS_ItemInstance` 动态属性。 + +## 主要能力 + +- 统一道具大类:`EPHYItemArchetype` + - `Weapon` + - `Equipment` + - `Consumable` + - `Material` + - `Quest` +- 统一装备槽:`EPHYEquipSlotType` +- 药品效果载荷:`FPHYConsumablePayload` + - `RestoreHealth` + - `RestoreInnerPower` + - `DurationSeconds` + +## GameplayTags 管理 + +标签已集中在 `Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.*`,避免代码中散落字符串: + +- `GIS.Item.Type.Weapon` +- `GIS.Item.Type.Equipment` +- `GIS.Item.Type.Consumable` +- `GIS.Item.EquipSlot.MainHand` 等 +- `GIS.Attribute.Item.Attack`、`GIS.Attribute.Item.CritRate` 等 + +## 如何创建三类物品 + +1. 在编辑器创建 `UGIS_ItemDefinition` 资产。 +2. 给该资产添加 Fragment:`PHY Item Property Settings`。 +3. 按类型配置: + - 武器:`ItemArchetype=Weapon`,`EquipSlot=MainHand/OffHand`,填写攻击/暴击等属性。 + - 装备:`ItemArchetype=Equipment`,设置对应 `EquipSlot`,填写防御/免伤/韧性等属性。 + - 药品:`ItemArchetype=Consumable`,填写 `ConsumablePayload`(回血/回内力)。 +4. 把物品加入你的掉落表/默认背包配置。 + +## 蓝图辅助 + +`UPHYItemBlueprintLibrary` 提供: + +- `GetItemArchetype` +- `GetEquipSlotType` +- `IsWeaponItem` +- `IsEquipmentItem` +- `IsConsumableItem` +- `GetConsumablePayload` + +用于 UI 显示和使用逻辑分流。 + +## 备注 + +- 当前为 MVP:先打通类型与属性承载。 +- 下一步可扩展:随机词条(Roll)、品质颜色、套装、与 GAS GameplayEffect 自动联动。 diff --git a/Source/PHY/Private/Character/PHYCharacter.cpp b/Source/PHY/Private/Character/PHYCharacter.cpp index b33583f..2b1aca1 100644 --- a/Source/PHY/Private/Character/PHYCharacter.cpp +++ b/Source/PHY/Private/Character/PHYCharacter.cpp @@ -4,6 +4,7 @@ #include "Character/PHYCharacter.h" #include "GIS_InventorySystemComponent.h" +#include "Equipping/GIS_EquipmentSystemComponent.h" #include "GMS_CharacterMovementSystemComponent.h" #include "Components/RetargeterComponent.h" @@ -14,6 +15,7 @@ APHYCharacter::APHYCharacter() PrimaryActorTick.bCanEverTick = true; InventorySystemComponent = CreateDefaultSubobject(TEXT("InventorySystem")); + EquipmentSystemComponent = CreateDefaultSubobject(TEXT("EquipmentSystem")); MovementSystemComponent = CreateDefaultSubobject(TEXT("MovementSystem")); RetargeterComponent = CreateDefaultSubobject(TEXT("Retargeter")); } @@ -27,6 +29,9 @@ void APHYCharacter::BeginPlay() if (HasAuthority() && InventorySystemComponent) { InventorySystemComponent->InitializeInventorySystem(); + if (EquipmentSystemComponent) + { + EquipmentSystemComponent->InitializeEquipmentSystem(); + } } } - diff --git a/Source/PHY/Private/Character/PHYCharacter.h b/Source/PHY/Private/Character/PHYCharacter.h index fa672b8..8dc2763 100644 --- a/Source/PHY/Private/Character/PHYCharacter.h +++ b/Source/PHY/Private/Character/PHYCharacter.h @@ -9,6 +9,7 @@ class URetargeterComponent; class UGIS_InventorySystemComponent; +class UGIS_EquipmentSystemComponent; class UGMS_CharacterMovementSystemComponent; @@ -24,6 +25,9 @@ public: UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PHY|Inventory") UGIS_InventorySystemComponent* GetInventorySystemComponent() const { return InventorySystemComponent; } + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PHY|Equipment") + UGIS_EquipmentSystemComponent* GetEquipmentSystemComponent() const { return EquipmentSystemComponent; } + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PHY|Movement") UGMS_CharacterMovementSystemComponent* GetMovementSystemComponent() const { return MovementSystemComponent; } @@ -41,6 +45,13 @@ protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "PHY|Inventory", meta = (AllowPrivateAccess = "true")) TObjectPtr InventorySystemComponent; + /** + * 角色的装备系统组件(来自 GenericInventorySystem 插件 GIS)。 + * 监听装备槽变化并驱动装备属性生效。 + */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "PHY|Equipment", meta = (AllowPrivateAccess = "true")) + TObjectPtr EquipmentSystemComponent; + /** * 角色的移动系统组件(来自 GenericMovementSystem 插件 GMS)。 */ diff --git a/Source/PHY/Private/Character/PHYPlayerCharacter.cpp b/Source/PHY/Private/Character/PHYPlayerCharacter.cpp index 4091989..e46b40d 100644 --- a/Source/PHY/Private/Character/PHYPlayerCharacter.cpp +++ b/Source/PHY/Private/Character/PHYPlayerCharacter.cpp @@ -12,6 +12,11 @@ #include "GameplayTags/InputTags.h" #include "Components/RetargeterComponent.h" #include "AbilitySystemComponent.h" +#include "GIS_InventorySystemComponent.h" +#include "Core/Items/GIS_ItemInfo.h" +#include "Core/Items/GIS_ItemInstance.h" +#include "Equipping/GIS_EquipmentInterface.h" +#include "Equipping/GIS_EquipmentSystemComponent.h" #include "AbilitySystem/PHYClassDefaults.h" #include "AbilitySystem/Attributes/PHYAttributeSet.h" #include "AbilitySystem/Effects/PHYGE_DerivedAttributes.h" @@ -77,6 +82,7 @@ void APHYPlayerCharacter::PossessedBy(AController* NewController) Super::PossessedBy(NewController); InitializeGAS(); + BindEquipmentEventsIfNeeded(); // 初始化hud InitializeHUD(); if (APHYPlayerState* PS = GetPlayerState()) @@ -107,10 +113,44 @@ void APHYPlayerCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); InitializeGAS(); + BindEquipmentEventsIfNeeded(); // 初始化hud InitializeHUD(); } +void APHYPlayerCharacter::BindEquipmentEventsIfNeeded() +{ + if (!HasAuthority() || bEquipmentEventBound) + { + return; + } + + if (UGIS_EquipmentSystemComponent* EquipmentSystem = GetEquipmentSystemComponent()) + { + EquipmentSystem->OnEquipmentStateChangedEvent.AddDynamic(this, &ThisClass::OnEquipmentStateChanged); + bEquipmentEventBound = true; + } +} + +void APHYPlayerCharacter::OnEquipmentStateChanged(UObject* Equipment, FGameplayTag SlotTag, bool bEquipped) +{ + if (!HasAuthority()) + { + return; + } + + UGIS_ItemInstance* SourceItem = nullptr; + if (IsValid(Equipment) && Equipment->GetClass()->ImplementsInterface(UGIS_EquipmentInterface::StaticClass())) + { + SourceItem = IGIS_EquipmentInterface::Execute_GetSourceItem(Equipment); + } + + if (APHYPlayerState* PS = GetPlayerState()) + { + PS->HandleItemEquippedState(SourceItem, bEquipped); + } +} + void APHYPlayerCharacter::InitializeGAS() { APHYPlayerState* PS = GetPlayerState(); @@ -120,7 +160,7 @@ void APHYPlayerCharacter::InitializeGAS() if (!ASC) return; ASC->InitAbilityActorInfo(PS, this); - + // Server applies init effects. if (HasAuthority()) { @@ -220,3 +260,59 @@ void APHYPlayerCharacter::RegenTick() Spec.Data->SetSetByCallerMagnitude(RegenTags::Tag__Data_Regen_InnerPower, FMath::Max(0.f, InnerPowerDelta)); ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get()); } + +void APHYPlayerCharacter::ServerUseConsumableByItemId_Implementation(FGuid ItemId, int32 Count) +{ + if (!HasAuthority() || !ItemId.IsValid() || Count <= 0) + { + return; + } + + UGIS_InventorySystemComponent* Inventory = GetInventorySystemComponent(); + APHYPlayerState* PS = GetPlayerState(); + if (!Inventory || !PS) + { + return; + } + + FGIS_ItemInfo SourceInfo; + for (const FGIS_ItemInfo& Info : Inventory->GetItemInfos()) + { + if (Info.IsValid() && IsValid(Info.Item) && Info.Item->GetItemId() == ItemId) + { + SourceInfo = Info; + break; + } + } + + if (!SourceInfo.IsValid() || !IsValid(SourceInfo.Item) || SourceInfo.Amount <= 0) + { + PS->NotifyOwner(EPHYNotifyType::Warning, + NSLOCTEXT("PHY", "Notify_UseConsumable_NotFound", "道具不存在或数量不足")); + return; + } + + const FGameplayTag ConsumableTag = FGameplayTag::RequestGameplayTag(FName(TEXT("GIS.Item.Type.Consumable")), false); + if (!ConsumableTag.IsValid() || !SourceInfo.Item->GetItemTags().HasTag(ConsumableTag)) + { + PS->NotifyOwner(EPHYNotifyType::Warning, + FText::Format(NSLOCTEXT("PHY", "Notify_UseConsumable_NotConsumable", "{0} 不是可使用药品"), SourceInfo.Item->GetItemName())); + return; + } + + const int32 ConsumeCount = FMath::Min(Count, SourceInfo.Amount); + FGIS_ItemInfo RemoveRequest(SourceInfo.Item, ConsumeCount, SourceInfo); + FGIS_ItemInfo Removed = Inventory->RemoveItem(RemoveRequest); + if (!Removed.IsValid() || Removed.Amount <= 0) + { + PS->NotifyOwner(EPHYNotifyType::Warning, + NSLOCTEXT("PHY", "Notify_UseConsumable_RemoveFailed", "消耗失败,请稍后重试")); + return; + } + + if (!PS->ApplyConsumableFromItem(SourceInfo.Item, Removed.Amount)) + { + PS->NotifyOwner(EPHYNotifyType::Warning, + FText::Format(NSLOCTEXT("PHY", "Notify_UseConsumable_NoEffect", "{0} 当前无法生效"), SourceInfo.Item->GetItemName())); + } +} diff --git a/Source/PHY/Private/Character/PHYPlayerCharacter.h b/Source/PHY/Private/Character/PHYPlayerCharacter.h index d6f34dc..f0b05dc 100644 --- a/Source/PHY/Private/Character/PHYPlayerCharacter.h +++ b/Source/PHY/Private/Character/PHYPlayerCharacter.h @@ -6,12 +6,15 @@ #include "PHYCharacter.h" #include "AbilitySystem/PHYCharacterClass.h" #include "Pawn/UGC_PawnInterface.h" +#include "GameplayTagContainer.h" #include "PHYPlayerCharacter.generated.h" class UGIPS_InputSystemComponent; class USpringArmComponent; class UCameraComponent; class URetargeterComponent; +class UGIS_ItemInstance; +struct FGuid; UCLASS() class PHY_API APHYPlayerCharacter : public APHYCharacter, @@ -57,6 +60,16 @@ protected: void InitializeHUD(); virtual void OnRep_PlayerState() override; + /** 服务器:按道具 ID 使用消耗品并扣除数量。 */ + UFUNCTION(Server, Reliable, BlueprintCallable, Category="PHY|Inventory") + void ServerUseConsumableByItemId(FGuid ItemId, int32 Count = 1); + + /** 装备系统事件(服务器):装备/卸下时应用或回滚属性。 */ + UFUNCTION() + void OnEquipmentStateChanged(UObject* Equipment, FGameplayTag SlotTag, bool bEquipped); + + void BindEquipmentEventsIfNeeded(); + /** Server: apply init effects. Both sides: init actor info. */ void InitializeGAS(); @@ -70,4 +83,6 @@ protected: FTimerHandle RegenTimerHandle; void RegenTick(); void StopRegen(); + + bool bEquipmentEventBound = false; }; diff --git a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp index 9b02c15..1f93a46 100644 --- a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp +++ b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.cpp @@ -11,11 +11,35 @@ #include "Gameplay/GameLevelInfo.h" #include "Net/UnrealNetwork.h" #include "Net/Core/PushModel/PushModel.h" +#include "Core/Items/GIS_ItemInstance.h" +#include "Items/GIS_ItemDefinition.h" namespace { // 你可以把这些做成配置/曲线/数据表;这里先给一个默认规则:每升1级给 1 点属性点 constexpr int32 AttributePointsPerLevel = 4; + + static FGameplayTag ReqTag(const TCHAR* Name) + { + return FGameplayTag::RequestGameplayTag(FName(Name), false); + } + + static float GetItemNumericAttribute(const UGIS_ItemInstance* Item, const FGameplayTag& Tag) + { + if (!IsValid(Item) || !Tag.IsValid()) + { + return 0.f; + } + if (Item->HasFloatAttribute(Tag)) + { + return Item->GetFloatAttribute(Tag); + } + if (Item->HasIntegerAttribute(Tag)) + { + return static_cast(Item->GetIntegerAttribute(Tag)); + } + return 0.f; + } } APHYPlayerState::APHYPlayerState() @@ -27,7 +51,7 @@ APHYPlayerState::APHYPlayerState() // 使用Minimal复制模式(推荐用于PlayerState) AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal); - + AttributeSet = CreateDefaultSubobject(TEXT("AttributeSet")); } UAbilitySystemComponent* APHYPlayerState::GetAbilitySystemComponent() const @@ -86,11 +110,10 @@ void APHYPlayerState::AddToXP(const int32 InXP) return; } - // 本地提示:获得经验(只在本地玩家的 PlayerState 上显示即可) - if (IsOwnedBy(GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr)) - { - PushGainedXPNotify(InXP); - } + // NOTE: + // XP/等级的权威逻辑走服务器;客户端通过复制拿到 XP。 + // “获得经验”的 UI 提示优先在客户端触发(OnRep_XP), + // 但 ListenServer/Standalone 的本地玩家通常不会走 OnRep,因此这里需要兜底。 MARK_PROPERTY_DIRTY_FROM_NAME(APHYPlayerState, XP, this); XP += InXP; @@ -98,6 +121,12 @@ void APHYPlayerState::AddToXP(const int32 InXP) // 只在权威端(Server/Standalone)做升级计算,客户端靠复制拿到最终 Level/XP if (HasAuthority()) { + // ListenServer/Standalone:本地玩家在权威端,OnRep_XP 不会触发,兜底弹“获得经验” + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushGainedXPNotify(InXP); + } + const int32 NewLevel = FMath::Clamp(FGameLevelInfo::GetLevelForTotalXP(XP), FGameLevelInfo::MinLevel, FGameLevelInfo::MaxLevel); if (NewLevel > Level) { @@ -116,10 +145,16 @@ void APHYPlayerState::AddToLevel(const int32 InLevel) Level += InLevel; OnLevelChangedDelegate.Broadcast(Level,true); - // 本地提示:升级 - if (IsOwnedBy(GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr)) + // UI 通知策略: + // - 纯客户端:走 OnRep_Level。 + // - DedicatedServer:没有本地玩家,不需要弹。 + // - ListenServer/Standalone:本地玩家在权威端,不会走 OnRep,因此这里兜底触发一次。 + if (HasAuthority()) { - PushLevelUpNotify(Level); + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushLevelUpNotify(Level); + } } } @@ -128,6 +163,33 @@ void APHYPlayerState::PushGameplayNotify(EPHYNotifyType Type, const FText& Messa OnGameplayNotifyDelegate.Broadcast(Type, Message, SoftIcon); } + +void APHYPlayerState::NotifyOwner(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon) +{ + if (!HasAuthority()) + { + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushGameplayNotify(Type, Message, SoftIcon); + } + return; + } + + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushGameplayNotify(Type, Message, SoftIcon); + return; + } + + ClientPushGameplayNotify(Type, Message, SoftIcon); +} + +void APHYPlayerState::ClientPushGameplayNotify_Implementation(EPHYNotifyType Type, const FText& Message, + const TSoftObjectPtr& SoftIcon) +{ + PushGameplayNotify(Type, Message, SoftIcon); +} + void APHYPlayerState::PushGainedXPNotify(int32 DeltaXP) { if (DeltaXP <= 0) return; @@ -202,11 +264,38 @@ void APHYPlayerState::SetAttributePoints(const int32 InPoints) void APHYPlayerState::OnRep_Level(int32 OldLevel) { OnLevelChangedDelegate.Broadcast(Level, true); + + // 客户端侧提示升级:DedicatedServer 不会进到这里(也不会有本地控制器)。 + if (!HasAuthority()) + { + if (Level > OldLevel) + { + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushLevelUpNotify(Level); + } + } + } } void APHYPlayerState::OnRep_XP(int32 OldXP) { OnXPChangedDelegate.Broadcast(XP); + + // 仅客户端:本地提示“获得经验”。 + // - DedicatedServer 上不会有本地玩家,因此不触发。 + // - ListenServer 的主机本地玩家会走本地分支。 + if (!HasAuthority()) + { + const int32 Delta = XP - OldXP; + if (Delta > 0) + { + if (AController* Controller = GetOwningController(); Controller && Controller->IsLocalController()) + { + PushGainedXPNotify(Delta); + } + } + } } void APHYPlayerState::OnRep_AttributePoints(int32 OldAttributePoints) @@ -252,3 +341,129 @@ int32 APHYPlayerState::GetXPRequirementForNextLevel() const FGameLevelInfo::GetProgressForTotalXP(XP, L, Into, ToNext); return ToNext; } + +void APHYPlayerState::ApplyItemAttributesWithSign(UGIS_ItemInstance* Item, const float Sign) +{ + if (!HasAuthority() || !IsValid(Item) || FMath::IsNearlyZero(Sign)) + { + return; + } + + UAbilitySystemComponent* ASC = GetAbilitySystemComponent(); + const UPHYAttributeSet* AS = GetAttributeSet(); + if (!ASC || !AS) + { + return; + } + + auto AddDelta = [ASC](const FGameplayAttribute& Attribute, const float Delta) + { + if (!Attribute.IsValid() || FMath::IsNearlyZero(Delta)) + { + return; + } + ASC->SetNumericAttributeBase(Attribute, ASC->GetNumericAttribute(Attribute) + Delta); + }; + + AddDelta(UPHYAttributeSet::GetPhysicalAttackAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Attack"))) * Sign); + AddDelta(UPHYAttributeSet::GetPhysicalDefenseAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Defense"))) * Sign); + AddDelta(UPHYAttributeSet::GetCritChanceAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.CritRate"))) * Sign); + AddDelta(UPHYAttributeSet::GetCritDamageAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.CritDamage"))) * Sign); + AddDelta(UPHYAttributeSet::GetHitChanceAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Hit"))) * Sign); + AddDelta(UPHYAttributeSet::GetDodgeChanceAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Dodge"))) * Sign); + AddDelta(UPHYAttributeSet::GetParryChanceAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Parry"))) * Sign); + AddDelta(UPHYAttributeSet::GetCounterChanceAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.CounterChance"))) * Sign); + AddDelta(UPHYAttributeSet::GetArmorPenetrationAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.ArmorPenetration"))) * Sign); + AddDelta(UPHYAttributeSet::GetDamageReductionAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.DamageReduction"))) * Sign); + AddDelta(UPHYAttributeSet::GetLifeStealAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.LifeSteal"))) * Sign); + AddDelta(UPHYAttributeSet::GetTenacityAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Resilience"))) * Sign); + AddDelta(UPHYAttributeSet::GetMoveSpeedAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.MoveSpeed"))) * Sign); + AddDelta(UPHYAttributeSet::GetHealthRegenRateAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.HealthRegenRate"))) * Sign); + AddDelta(UPHYAttributeSet::GetInnerPowerRegenRateAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.InnerPowerRegenRate"))) * Sign); + AddDelta(UPHYAttributeSet::GetMaxHealthAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.MaxHealth"))) * Sign); + AddDelta(UPHYAttributeSet::GetMaxInnerPowerAttribute(), GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.MaxInnerPower"))) * Sign); + + // 上限变更后,当前值做一次夹取,避免越界。 + ASC->SetNumericAttributeBase(UPHYAttributeSet::GetHealthAttribute(), FMath::Clamp(AS->GetHealth(), 0.f, AS->GetMaxHealth())); + ASC->SetNumericAttributeBase(UPHYAttributeSet::GetInnerPowerAttribute(), FMath::Clamp(AS->GetInnerPower(), 0.f, AS->GetMaxInnerPower())); +} + +void APHYPlayerState::HandleItemEquippedState(UGIS_ItemInstance* Item, const bool bEquipped) +{ + if (!HasAuthority() || !IsValid(Item)) + { + return; + } + + const FGuid ItemId = Item->GetItemId(); + if (!ItemId.IsValid()) + { + return; + } + + const TSoftObjectPtr Icon = Item->GetDefinition() ? Item->GetDefinition()->Icon : nullptr; + + if (bEquipped) + { + if (AppliedEquipmentItems.Contains(ItemId)) + { + return; + } + AppliedEquipmentItems.Add(ItemId); + ApplyItemAttributesWithSign(Item, +1.f); + NotifyOwner(EPHYNotifyType::Info, + FText::Format(NSLOCTEXT("PHY", "Notify_EquipItem", "装备 {0}"), Item->GetItemName()), + Icon); + } + else + { + if (!AppliedEquipmentItems.Contains(ItemId)) + { + return; + } + AppliedEquipmentItems.Remove(ItemId); + ApplyItemAttributesWithSign(Item, -1.f); + NotifyOwner(EPHYNotifyType::Info, + FText::Format(NSLOCTEXT("PHY", "Notify_UnequipItem", "卸下 {0}"), Item->GetItemName()), + Icon); + } +} + +bool APHYPlayerState::ApplyConsumableFromItem(UGIS_ItemInstance* Item, const int32 Count) +{ + if (!HasAuthority() || !IsValid(Item) || Count <= 0) + { + return false; + } + + UAbilitySystemComponent* ASC = GetAbilitySystemComponent(); + const UPHYAttributeSet* AS = GetAttributeSet(); + if (!ASC || !AS) + { + return false; + } + + const float RestoreHealth = GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Consumable.RestoreHealth"))) * static_cast(Count); + const float RestoreInnerPower = GetItemNumericAttribute(Item, ReqTag(TEXT("GIS.Attribute.Item.Consumable.RestoreInnerPower"))) * static_cast(Count); + + if (RestoreHealth <= 0.f && RestoreInnerPower <= 0.f) + { + return false; + } + + if (RestoreHealth > 0.f) + { + ASC->SetNumericAttributeBase(UPHYAttributeSet::GetHealthAttribute(), FMath::Clamp(AS->GetHealth() + RestoreHealth, 0.f, AS->GetMaxHealth())); + } + if (RestoreInnerPower > 0.f) + { + ASC->SetNumericAttributeBase(UPHYAttributeSet::GetInnerPowerAttribute(), FMath::Clamp(AS->GetInnerPower() + RestoreInnerPower, 0.f, AS->GetMaxInnerPower())); + } + + const TSoftObjectPtr Icon = Item->GetDefinition() ? Item->GetDefinition()->Icon : nullptr; + NotifyOwner(EPHYNotifyType::Reward, + FText::Format(NSLOCTEXT("PHY", "Notify_UseConsumable", "使用 {0} x{1}"), Item->GetItemName(), FText::AsNumber(Count)), + Icon); + + return true; +} diff --git a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h index 747a91d..30aa3bf 100644 --- a/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h +++ b/Source/PHY/Private/Gameplay/Player/PHYPlayerState.h @@ -11,6 +11,7 @@ class UAbilitySystemComponent; class UPHYAttributeSet; +class UGIS_ItemInstance; /** * 玩家状态类,包含GAS支持 @@ -57,7 +58,7 @@ public: UAbilitySystemComponent* GetPHYAbilitySystemComponent() const { return AbilitySystemComponent; } UFUNCTION(BlueprintCallable, Category = "Abilities") - const UPHYAttributeSet* GetAttributeSet() const { return AttributeSet; } + UPHYAttributeSet* GetAttributeSet() const { return AttributeSet; } // 原有的ReTargeterTag相关代码 FOnPlayerStatChanged OnXPChangedDelegate; @@ -84,6 +85,19 @@ public: UFUNCTION(Server, Reliable, BlueprintCallable, Category="Player|Progress") void ServerAddXP(int32 DeltaXP); + /** 服务端:根据装备穿脱状态应用/回滚道具属性。 */ + void HandleItemEquippedState(UGIS_ItemInstance* Item, bool bEquipped); + + /** 服务端:应用药品恢复(Count 为消耗数量)。 */ + bool ApplyConsumableFromItem(UGIS_ItemInstance* Item, int32 Count); + + /** 向拥有者玩家推送提示(会自动处理 DS/Listen/Standalone)。 */ + void NotifyOwner(EPHYNotifyType Type, const FText& Message, TSoftObjectPtr SoftIcon = nullptr); + + /** 仅拥有者客户端执行:显示提示。 */ + UFUNCTION(Client, Reliable) + void ClientPushGameplayNotify(EPHYNotifyType Type, const FText& Message, const TSoftObjectPtr& SoftIcon); + FGameplayTag GetReTargeterTag() const { return ReTargeterTag; } FORCEINLINE int32 GetPlayerLevel() const { return Level; } @@ -134,4 +148,10 @@ private: /** 仅本地:推送“获得物品”提示(ItemName x Count) */ void PushGainedItemNotify(const FText& ItemName, int32 Count); + + /** 服务端:把道具属性按符号(+1/-1)叠加到角色属性。 */ + void ApplyItemAttributesWithSign(UGIS_ItemInstance* Item, float Sign); + + /** 已生效的装备道具,避免重复叠加。 */ + TSet AppliedEquipmentItems; }; diff --git a/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.cpp b/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.cpp new file mode 100644 index 0000000..4811106 --- /dev/null +++ b/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.cpp @@ -0,0 +1,32 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#include "GameplayTags/PHYInventoryItemTags.h" + +namespace PHYInventoryItemTags +{ + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_Type_Weapon, "GIS.Item.Type.Weapon", "Weapon item category"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_Type_Equipment, "GIS.Item.Type.Equipment", "Equipment item category"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_Type_Consumable, "GIS.Item.Type.Consumable", "Consumable item category"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_Type_Material, "GIS.Item.Type.Material", "Material item category"); + + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_MainHand, "GIS.Item.EquipSlot.MainHand", "Main hand slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_OffHand, "GIS.Item.EquipSlot.OffHand", "Off hand slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_Head, "GIS.Item.EquipSlot.Head", "Head slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_Chest, "GIS.Item.EquipSlot.Chest", "Chest slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_Legs, "GIS.Item.EquipSlot.Legs", "Legs slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_Feet, "GIS.Item.EquipSlot.Feet", "Feet slot"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Item_EquipSlot_Accessory, "GIS.Item.EquipSlot.Accessory", "Accessory slot"); + + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Attack, "GIS.Attribute.Item.Attack", "Item Attack bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Defense, "GIS.Attribute.Item.Defense", "Item Defense bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_CritRate, "GIS.Attribute.Item.CritRate", "Item CritRate bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_CritDamage, "GIS.Attribute.Item.CritDamage", "Item CritDamage bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Hit, "GIS.Attribute.Item.Hit", "Item Hit bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Dodge, "GIS.Attribute.Item.Dodge", "Item Dodge bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Parry, "GIS.Attribute.Item.Parry", "Item Parry bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_ArmorPenetration, "GIS.Attribute.Item.ArmorPenetration", "Item ArmorPenetration bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_DamageReduction, "GIS.Attribute.Item.DamageReduction", "Item DamageReduction bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_LifeSteal, "GIS.Attribute.Item.LifeSteal", "Item LifeSteal bonus"); + UE_DEFINE_GAMEPLAY_TAG_COMMENT(Tag__GIS_Attribute_Item_Resilience, "GIS.Attribute.Item.Resilience", "Item Resilience bonus"); +} + diff --git a/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.h b/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.h new file mode 100644 index 0000000..2fe4d71 --- /dev/null +++ b/Source/PHYInventory/Private/GameplayTags/PHYInventoryItemTags.h @@ -0,0 +1,38 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "NativeGameplayTags.h" + +namespace PHYInventoryItemTags +{ + // Item category tags + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_Type_Weapon); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_Type_Equipment); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_Type_Consumable); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_Type_Material); + + // Equipment slot tags + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_MainHand); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_OffHand); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_Head); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_Chest); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_Legs); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_Feet); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Item_EquipSlot_Accessory); + + // Common item attributes used by this project + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Attack); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Defense); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_CritRate); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_CritDamage); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Hit); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Dodge); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Parry); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_ArmorPenetration); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_DamageReduction); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_LifeSteal); + UE_DECLARE_GAMEPLAY_TAG_EXTERN(Tag__GIS_Attribute_Item_Resilience); +} + diff --git a/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.cpp b/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.cpp new file mode 100644 index 0000000..1309393 --- /dev/null +++ b/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.cpp @@ -0,0 +1,62 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#include "Items/PHYItemBlueprintLibrary.h" + +#include "GIS_ItemInstance.h" + +const UPHYItemFragment_PropertySet* UPHYItemBlueprintLibrary::FindPropertyFragment(const UGIS_ItemInstance* ItemInstance) +{ + if (!IsValid(ItemInstance)) + { + return nullptr; + } + + return ItemInstance->FindFragmentByClass(); +} + +EPHYItemArchetype UPHYItemBlueprintLibrary::GetItemArchetype(const UGIS_ItemInstance* ItemInstance) +{ + if (const UPHYItemFragment_PropertySet* Fragment = FindPropertyFragment(ItemInstance)) + { + return Fragment->ItemArchetype; + } + + return EPHYItemArchetype::Material; +} + +EPHYEquipSlotType UPHYItemBlueprintLibrary::GetEquipSlotType(const UGIS_ItemInstance* ItemInstance) +{ + if (const UPHYItemFragment_PropertySet* Fragment = FindPropertyFragment(ItemInstance)) + { + return Fragment->EquipSlot; + } + + return EPHYEquipSlotType::None; +} + +bool UPHYItemBlueprintLibrary::IsWeaponItem(const UGIS_ItemInstance* ItemInstance) +{ + return GetItemArchetype(ItemInstance) == EPHYItemArchetype::Weapon; +} + +bool UPHYItemBlueprintLibrary::IsEquipmentItem(const UGIS_ItemInstance* ItemInstance) +{ + const EPHYItemArchetype Archetype = GetItemArchetype(ItemInstance); + return Archetype == EPHYItemArchetype::Weapon || Archetype == EPHYItemArchetype::Equipment; +} + +bool UPHYItemBlueprintLibrary::IsConsumableItem(const UGIS_ItemInstance* ItemInstance) +{ + return GetItemArchetype(ItemInstance) == EPHYItemArchetype::Consumable; +} + +FPHYConsumablePayload UPHYItemBlueprintLibrary::GetConsumablePayload(const UGIS_ItemInstance* ItemInstance) +{ + if (const UPHYItemFragment_PropertySet* Fragment = FindPropertyFragment(ItemInstance)) + { + return Fragment->ConsumablePayload; + } + + return FPHYConsumablePayload(); +} + diff --git a/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.h b/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.h new file mode 100644 index 0000000..34fc7a6 --- /dev/null +++ b/Source/PHYInventory/Private/Items/PHYItemBlueprintLibrary.h @@ -0,0 +1,39 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Items/PHYItemFragment_PropertySet.h" +#include "PHYItemBlueprintLibrary.generated.h" + +class UGIS_ItemInstance; + +UCLASS() +class UPHYItemBlueprintLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static EPHYItemArchetype GetItemArchetype(const UGIS_ItemInstance* ItemInstance); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static EPHYEquipSlotType GetEquipSlotType(const UGIS_ItemInstance* ItemInstance); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static bool IsWeaponItem(const UGIS_ItemInstance* ItemInstance); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static bool IsEquipmentItem(const UGIS_ItemInstance* ItemInstance); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static bool IsConsumableItem(const UGIS_ItemInstance* ItemInstance); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + static FPHYConsumablePayload GetConsumablePayload(const UGIS_ItemInstance* ItemInstance); + +private: + static const UPHYItemFragment_PropertySet* FindPropertyFragment(const UGIS_ItemInstance* ItemInstance); +}; + diff --git a/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.cpp b/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.cpp new file mode 100644 index 0000000..19e3eb9 --- /dev/null +++ b/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.cpp @@ -0,0 +1,42 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#include "Items/PHYItemFragment_PropertySet.h" + +#include "GIS_ItemInstance.h" + +void UPHYItemFragment_PropertySet::OnInstanceCreated(UGIS_ItemInstance* ItemInstance) const +{ + if (!IsValid(ItemInstance)) + { + return; + } + + for (const FGIS_GameplayTagFloat& Modifier : BaseFloatModifiers) + { + if (Modifier.Tag.IsValid()) + { + ItemInstance->SetFloatAttribute(Modifier.Tag, Modifier.Value); + } + } + + for (const FGIS_GameplayTagInteger& Modifier : BaseIntegerModifiers) + { + if (Modifier.Tag.IsValid()) + { + ItemInstance->SetIntegerAttribute(Modifier.Tag, Modifier.Value); + } + } + + Super::OnInstanceCreated(ItemInstance); +} + +bool UPHYItemFragment_PropertySet::IsEquippable() const +{ + return ItemArchetype == EPHYItemArchetype::Weapon || ItemArchetype == EPHYItemArchetype::Equipment; +} + +bool UPHYItemFragment_PropertySet::IsConsumable() const +{ + return ItemArchetype == EPHYItemArchetype::Consumable; +} + diff --git a/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.h b/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.h new file mode 100644 index 0000000..2a04d2b --- /dev/null +++ b/Source/PHYInventory/Private/Items/PHYItemFragment_PropertySet.h @@ -0,0 +1,88 @@ +// Copyright 2026 PHY. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GIS_GameplayTagFloat.h" +#include "GIS_GameplayTagInteger.h" +#include "GIS_ItemFragment.h" +#include "PHYItemFragment_PropertySet.generated.h" + +UENUM(BlueprintType) +enum class EPHYItemArchetype : uint8 +{ + Weapon, + Equipment, + Consumable, + Material, + Quest +}; + +UENUM(BlueprintType) +enum class EPHYEquipSlotType : uint8 +{ + None, + MainHand, + OffHand, + Head, + Chest, + Legs, + Feet, + Accessory +}; + +USTRUCT(BlueprintType) +struct FPHYConsumablePayload +{ + GENERATED_BODY() + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Consumable") + float RestoreHealth = 0.0f; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Consumable") + float RestoreInnerPower = 0.0f; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Consumable") + float DurationSeconds = 0.0f; +}; + +/** + * 统一道具属性片段: + * - 定义道具大类(武器/装备/药品...) + * - 定义装备槽位 + * - 定义创建实例时写入的动态属性(可用于随机词条前的基础值) + * - 定义药品基础效果载荷 + */ +UCLASS(DisplayName="PHY Item Property Settings", Category="PHY") +class UPHYItemFragment_PropertySet : public UGIS_ItemFragment +{ + GENERATED_BODY() + +public: + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Item") + EPHYItemArchetype ItemArchetype = EPHYItemArchetype::Material; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Item") + EPHYEquipSlotType EquipSlot = EPHYEquipSlotType::None; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Item", meta=(Categories="GIS.Item")) + FGameplayTagContainer ExtraItemTags; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Attributes", meta=(TitleProperty="{Tag} -> {Value}", Categories="GIS.Attribute")) + TArray BaseFloatModifiers; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Attributes", meta=(TitleProperty="{Tag} -> {Value}", Categories="GIS.Attribute")) + TArray BaseIntegerModifiers; + + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Consumable") + FPHYConsumablePayload ConsumablePayload; + + virtual void OnInstanceCreated(UGIS_ItemInstance* ItemInstance) const override; + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + bool IsEquippable() const; + + UFUNCTION(BlueprintCallable, BlueprintPure, Category="PHY|Item") + bool IsConsumable() const; +}; +