UE5多人MOBA+GAS 37、库存系统(四)

文章目录


装备的合成

资源管理器CAssetManager中添加新的函数用来获取合成物品

cpp 复制代码
	/**
	 * 获取指定物品能合成的所有结果物品
	 * @param Item - 作为材料的物品
	 * @return 指向结果物品集合的指针(找不到返回nullptr)
	 */
	const FItemCollection* GetCombinationForItem(const UPDA_ShopItem* Item) const;
	
	/**
	 * 获取合成指定物品所需的所有材料
	 * @param Item - 目标物品
	 * @return 指向材料物品集合的指针(找不到返回nullptr)
	 */
	const FItemCollection* GetIngredientForItem(const UPDA_ShopItem* Item) const;
cpp 复制代码
const FItemCollection* UCAssetManager::GetCombinationForItem(const UPDA_ShopItem* Item) const
{
	// 在合成映射表中查找材料对应的合成结果
	return CombinationMap.Find(Item);
}

const FItemCollection* UCAssetManager::GetIngredientForItem(const UPDA_ShopItem* Item) const
{
	// 在材料映射表中查找物品所需的材料
	return IngredientMap.Find(Item);
}

到库存组件InventoryComponent中调用并合成物品

cpp 复制代码
public:
	/**
	 * 查找合成指定物品所需的材料
	 * @param Item 目标物品
	 * @param OutIngredients 输出找到的材料
	 * @param IngredientToIgnore 需要忽略的材料列表
	 * @return 是否找到全部材料
	 */
	bool FindIngredientForItem(const UPDA_ShopItem* Item, TArray<UInventoryItem*>& OutIngredients, const TArray<const UPDA_ShopItem*>& IngredientToIgnore = TArray<const UPDA_ShopItem*>{});
	
	// 尝试获取与商店物品对应的库存物品
	UInventoryItem* TryGetItemForShopItem(const UPDA_ShopItem* Item) const;

	/*********************************************************/
	/*                   Server RPCs                         */
	/*********************************************************/
	/** 尝试物品合成 */
	bool TryItemCombination(const UPDA_ShopItem* NewItem);

购买物品的时候调用尝试合成

cpp 复制代码
bool UInventoryComponent::FindIngredientForItem(const UPDA_ShopItem* Item, TArray<UInventoryItem*>& OutIngredients,
	const TArray<const UPDA_ShopItem*>& IngredientToIgnore)
{
	// 获取物品合成所需材料
	const FItemCollection* Ingredients = UCAssetManager::Get().GetIngredientForItem(Item);
	if (!Ingredients) return false;

	bool bAllFound = true;
	// 遍历材料列表
	for (const UPDA_ShopItem* Ingredient : Ingredients->GetItems())
	{
		// 跳过忽略的素材
		if (IngredientToIgnore.Contains(Ingredient))
			continue;

		// 查找背包中是否有该物品
		UInventoryItem* FoundItem = TryGetItemForShopItem(Ingredient);
		if (!FoundItem)
		{
			// 缺一个就是找不到全部,返回false
			bAllFound = false;
			break;
		}
		// 背包中找到的物品
		OutIngredients.Add(FoundItem);
	}

	return bAllFound;
}

UInventoryItem* UInventoryComponent::TryGetItemForShopItem(const UPDA_ShopItem* Item) const
{
	if (!Item) return nullptr;

	// 遍历背包 寻找指定商品
	for (const TPair<FInventoryItemHandle, UInventoryItem*>& ItemHandlePair : InventoryMap)
	{
		// if (ItemHandlePair.Value && ItemHandlePair.Value->IsForItem(Item))
		if (ItemHandlePair.Value && ItemHandlePair.Value->GetShopItem() == Item)
		{
			return ItemHandlePair.Value;
		}
	}
	return nullptr;
}

bool UInventoryComponent::TryItemCombination(const UPDA_ShopItem* NewItem)
{
	// 仅服务器调用
	if (!GetOwner()->HasAuthority()) return false;

	// 获取可合成的物品
	const FItemCollection* CombinationItems = UCAssetManager::Get().GetCombinationForItem(NewItem);

	if (!CombinationItems) return false;

	// 遍历所有的可合成物品
	for (const UPDA_ShopItem* CombinationItem : CombinationItems->GetItems())
	{
		TArray<UInventoryItem*> Ingredients;
		// 查找合成所需材料
		if (!FindIngredientForItem(CombinationItem, Ingredients, TArray<const UPDA_ShopItem*>{NewItem}))
			continue;

		// 移除材料
		for (UInventoryItem* Ingredient : Ingredients)
		{
			RemoveItem(Ingredient);
		}

		// 合成物品
		GrantItem(CombinationItem);
		return true;
	}

	return false;
}

合成树的绘制

创建线条UI

SplineWidget

完整线条UISplineWidget代码

cpp 复制代码
#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SplineWidget.generated.h"

/**
 * 自定义样条线控件,用于在两个UI控件之间绘制贝塞尔曲线连接线
 * 常用于技能树、节点图、流程图等UI连接线绘制
 */
UCLASS()
class CRUNCH_API USplineWidget : public UUserWidget
{
	GENERATED_BODY()
public:
	/**
	 * 初始化样条线连接参数
	 * @param InStartWidget 起始控件
	 * @param InEndWidget 目标控件
	 * @param InStartPortLocalCoord 起始点在起始控件内的局部坐标(相对于控件左上角)
	 * @param InEndPortLocalCoord 目标点在目标控件内的局部坐标
	 * @param InStartPortDirection 起始点切线方向(控制曲线形状)
	 * @param InEndPortDirection 目标点切线方向(控制曲线形状)
	 */
	void SetupSpline(
		const UUserWidget* InStartWidget,
		const UUserWidget* InEndWidget,
		const FVector2D& InStartPortLocalCoord,
		const FVector2D& InEndPortLocalCoord,
		const FVector2D& InStartPortDirection,
		const FVector2D& InEndPortDirection
	);

	/**
	 * 设置样条线显示样式
	 * @param InColor 线条颜色
	 * @param InThickness 线条粗细(像素)
	 */
	void SetSplineStyle(const FLinearColor& InColor, float InThickness);
private:
	// 重写原生绘制函数
	virtual int32 NativePaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override;

	// 编辑器测试用起点位置(开发调试使用)
	UPROPERTY(EditAnywhere, Category = "Spline")
	FVector2D TestStartPos;

	// 编辑器测试用终点位置(开发调试使用)
	UPROPERTY(EditAnywhere, Category = "Spline")
	FVector2D TestEndPos = FVector2D{100.f, 100.f};

	// 样条线颜色
	UPROPERTY(EditAnywhere, Category = "Spline")
	FLinearColor Color = FLinearColor::White;

	// 样条线粗细(像素单位)
	UPROPERTY(EditAnywhere, Category = "Spline")
	float Thickness = 3.f;

	// 起始控件引用
	UPROPERTY()
	const UUserWidget* StartWidget;

	// 目标控件引用
	UPROPERTY()
	const UUserWidget* EndWidget;

	// 起始点在起始控件内的局部坐标
	FVector2D StartPortLocalCoord;

	// 目标点在目标控件内的局部坐标
	FVector2D EndPortLocalCoord;

	// 起始点切线方向(控制曲线起始斜率)
	UPROPERTY(EditAnywhere, Category = "Spline")
	FVector2D StartPortDirection;

	// 目标点切线方向(控制曲线结束斜率)
	UPROPERTY(EditAnywhere, Category = "Spline")
	FVector2D EndPortDirection;
};
cpp 复制代码
#include "SplineWidget.h"

void USplineWidget::SetupSpline(const UUserWidget* InStartWidget, const UUserWidget* InEndWidget,
	const FVector2D& InStartPortLocalCoord, const FVector2D& InEndPortLocalCoord, const FVector2D& InStartPortDirection,
	const FVector2D& InEndPortDirection)
{
	// 设置连接的起始控件和目标控件
	StartWidget = InStartWidget;
	EndWidget = InEndWidget;
    
	// 设置连接点在控件内的局部坐标(相对于控件左上角)
	StartPortLocalCoord = InStartPortLocalCoord;
	EndPortLocalCoord = InEndPortLocalCoord;
    
	// 设置样条线在起点和终点的切线方向(控制曲线形状)
	StartPortDirection = InStartPortDirection;
	EndPortDirection = InEndPortDirection;
}

void USplineWidget::SetSplineStyle(const FLinearColor& InColor, float InThickness)
{
	// 设置样条线的视觉样式
	Color = InColor;        // 线条颜色
	Thickness = InThickness; // 线条粗细(像素)
}

int32 USplineWidget::NativePaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry,
	const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId,
	const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
	// 调用父类的绘制方法(确保基础UI元素被正确绘制)
	LayerId = Super::NativePaint(Args, AllottedGeometry, MyCullingRect, 
		OutDrawElements, LayerId, InWidgetStyle, bParentEnabled);
	
	// 初始化起点和终点位置(默认使用编辑器测试位置)
	FVector2D StartPos = TestStartPos;
	FVector2D EndPos = TestEndPos;
	
	// 如果已设置起始控件和目标控件,则计算实际位置
	if (StartWidget && EndWidget)
	{
		// 将起始点局部坐标转换为当前几何空间的绝对位置
		StartPos = StartWidget->GetCachedGeometry().GetLocalPositionAtCoordinates(StartPortLocalCoord);
        
		// 将终点局部坐标转换为当前几何空间的绝对位置
		EndPos = EndWidget->GetCachedGeometry().GetLocalPositionAtCoordinates(EndPortLocalCoord);
	}
	
	// 使用Slate绘制API创建贝塞尔曲线样条线
	FSlateDrawElement::MakeSpline(
		OutDrawElements,					// 绘制元素列表
		++LayerId,							// 递增图层ID确保在顶层绘制
		AllottedGeometry.ToPaintGeometry(), // 当前控件的几何信息
		StartPos,						// 曲线起点位置
		StartPortDirection,				// 起点切线方向(控制曲线起始斜率)
		EndPos,							// 曲线终点位置
		EndPortDirection,				// 终点切线方向(控制曲线结束斜率)
		Thickness,							// 线条粗细
		ESlateDrawEffect::None,				// 无特殊绘制效果
		Color								// 线条颜色
	);
    
	// 返回最终使用的图层ID
	return LayerId;
}

创建树节点接口

创建一个接口

TreeNodeInterface

cpp 复制代码
#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "TreeNodeInterface.generated.h"

// This class does not need to be modified.
UINTERFACE(MinimalAPI)
class UTreeNodeInterface : public UInterface
{
	GENERATED_BODY()
};

/**
 * 树节点接口,用于实现树形数据结构中的节点功能
 * 适用于技能树、科技树、组织结构图等需要节点关系的系统
 */
class ITreeNodeInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	/**
	 * 获取该节点关联的UI控件
	 * @return 返回节点对应的用户控件实例
	 */
	virtual UUserWidget* GetWidget() const = 0;

	/**
	 * 获取当前节点的所有输入节点(父节点/前置节点)
	 * @return 输入节点接口指针的数组
	 */
	virtual TArray<const ITreeNodeInterface*> GetInputs() const = 0;

	/**
	 * 获取当前节点的所有输出节点(子节点/后置节点)
	 * @return 输出节点接口指针的数组
	 */
	virtual TArray<const ITreeNodeInterface*> GetOutputs() const = 0;

	/**
	 * 获取节点关联的数据对象
	 * @return 节点关联的UObject数据对象(如技能配置、科技配置等)
	 */
	virtual const UObject* GetItemObject() const = 0;	
};

让商店物品继承树节点接口,并实现接口的函数

cpp 复制代码
UCLASS()
class UShopItemWidget : public UItemWidget, 
						public IUserObjectListEntry,
						public ITreeNodeInterface
{
	GENERATED_BODY()
public:
	//~ Begin ITreeNodeInterface 接口实现
	// 获取当前控件实例(树节点接口要求)
	virtual UUserWidget* GetWidget() const override;
	
	// 获取输入节点(合成树中的材料项)
	virtual TArray<const ITreeNodeInterface*> GetInputs() const override;
	
	// 获取输出节点(合成树中的产出项)
	virtual TArray<const ITreeNodeInterface*> GetOutputs() const override;
	
	// 获取关联的数据对象(商店物品资产)
	virtual const UObject* GetItemObject() const override;
	//~ End ITreeNodeInterface 接口实现

private:
	// 从另一个商店物品控件复制数据(用于列表项重用)
	void CopyFromOther(const UShopItemWidget* OtherWidget);
	
	// 使用商店物品数据初始化控件
	void InitWithShopItem(const UPDA_ShopItem* NewShopItem);
	
	/**
	 * 将商店物品数组转换为树节点接口数组
	 * @param Items 商店物品数组
	 * @return 对应的树节点接口数组
	 */
	TArray<const ITreeNodeInterface*> ItemsToInterfaces(const TArray<const UPDA_ShopItem*>& Items) const;

	// 父级ListView控件(此物品所属的列表视图)
	UPROPERTY()
	TObjectPtr<const class UListView> ParentListView;
};
cpp 复制代码
UUserWidget* UShopItemWidget::GetWidget() const
{
	// 创建一样的商店物品UI
	UShopItemWidget* Copy = CreateWidget<UShopItemWidget>(GetOwningPlayer(), GetClass());

	// 复制商店物品UI数据
	Copy->CopyFromOther(this);
	return Copy;
}

TArray<const ITreeNodeInterface*> UShopItemWidget::GetInputs() const
{
	// 获取可合成该物品的材料列表
	const FItemCollection* Collection = UCAssetManager::Get().GetCombinationForItem(GetShopItem());

	if (Collection)
	{
		// 将物品转换为树节点
		return ItemsToInterfaces(Collection->GetItems());
	}

	return TArray<const ITreeNodeInterface*>{};
}

TArray<const ITreeNodeInterface*> UShopItemWidget::GetOutputs() const
{
	// 获取合成该物品所需的材料列表
	const FItemCollection* Collection = UCAssetManager::Get().GetIngredientForItem(GetShopItem());

	if (Collection)
	{
		// 将物品转换为树节点
		return ItemsToInterfaces(Collection->GetItems());
	}

	return TArray<const ITreeNodeInterface*>{};
}

const UObject* UShopItemWidget::GetItemObject() const
{
	return ShopItem;
}

void UShopItemWidget::NativeOnListItemObjectSet(UObject* ListItemObject)
{
	IUserObjectListEntry::NativeOnListItemObjectSet(ListItemObject);

	// ShopItem = Cast<UPDA_ShopItem>(ListItemObject);
	// if (!ShopItem) return;
	//
	// SetIcon(ShopItem->GetIcon());
	//
	// SetToolTipWidget(ShopItem);
	// 使用新数据初始化控件
	InitWithShopItem(Cast<UPDA_ShopItem>(ListItemObject));
	
	// 获取所属的ListView控件
	ParentListView = Cast<UListView>(IUserListEntry::GetOwningListView());
}

void UShopItemWidget::CopyFromOther(const UShopItemWidget* OtherWidget)
{
	// 复制委托绑定
	OnItemPurchaseIssued = OtherWidget->OnItemPurchaseIssued;
	OnShopItemClicked = OtherWidget->OnShopItemClicked;

	// 复制父列表
	ParentListView = OtherWidget->ParentListView;

	// 使用商店物品数据初始化界面
	InitWithShopItem(OtherWidget->GetShopItem());
}

void UShopItemWidget::InitWithShopItem(const UPDA_ShopItem* NewShopItem)
{
	// 设置商店物品数据
	ShopItem = NewShopItem;
	if (!ShopItem) return;
	// 设置图标
	SetIcon(ShopItem->GetIcon());
	// 创建并设置提示信息
	SetToolTipWidget(ShopItem);
}

TArray<const ITreeNodeInterface*> UShopItemWidget::ItemsToInterfaces(const TArray<const UPDA_ShopItem*>& Items) const
{
	// 创建用来存储的树节点的数组
	TArray<const ITreeNodeInterface*> RetInterfaces;

	if (!ParentListView) return RetInterfaces;

	// 遍历物品数组,把它们从 ListView 中转换为 Widget(然后作为树节点返回)
	for (const UPDA_ShopItem* Item : Items)
	{
		// 从ParentListView列表视图中获取与Item数据项关联的UShopItemWidget控件
		// ParentListView存储的就是商店的那个框框
		const UShopItemWidget* ItemWidget = ParentListView->GetEntryWidgetFromItem<UShopItemWidget>(Item);
		if (ItemWidget)
		{
			// 将对应的树节点添加到数组中
			RetInterfaces.Add(ItemWidget);
		}
	}
	return RetInterfaces;
}

ParentListView就是获取到商店UI的ShopItemList

合成树的绘制

继承UserWidget创建ItemTreeWidget

合成树的节点遍历类似于二叉树的前序遍历顺序(点击前往观看前序遍历),这边使用的是多叉树的前序遍历。

完整ItemTreeWidget代码

cpp 复制代码
#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ItemTreeWidget.generated.h"


class UCanvasPanelSlot;
class ITreeNodeInterface;
class UCanvasPanel;

/**
 * @class UItemTreeWidget
 * @brief 合成树的绘制
 */
UCLASS()
class CRUNCH_API UItemTreeWidget : public UUserWidget
{
	GENERATED_BODY()
public:
	/**
	 * 从指定的树节点接口开始,绘制整棵树。
	 * @param NodeInterface - 树的"根节点",实现了 ITreeNodeInterface 的对象(通常代表一个 ShopItem)。
	 */
	void DrawFromNode(const ITreeNodeInterface* NodeInterface);
private:
	/**
	 * 递归绘制一支"上游"或"下游"分支。
	 * @param bUpperStream			- true 表示绘制"上游"(输入源),false 表示"下游"(输出目标)。
	 * @param StartingNodeInterface - 当前分支的起始节点接口。
	 * @param StartingNodeWidget	- 当前节点的UI控件,用于连线起点或终点。
	 * @param StartingNodeSlot		- 当前节点的画布中的插槽。
	 * @param StartingNodeDepth		- 深度(层级),用于垂直位置计算。
	 * @param NextLeafXPosition		- 引用参数,记录绘制下一个叶子节点时应该使用的水平偏移。
	 * @param OutStreamSlots		- 所有节点槽的集合,后续做居中调整。
	 */
	void DrawStream(
		bool bUpperStream,
		const ITreeNodeInterface* StartingNodeInterface,
		UUserWidget* StartingNodeWidget,
		UCanvasPanelSlot* StartingNodeSlot,
		int32 StartingNodeDepth,
		float& NextLeafXPosition,
		TArray<UCanvasPanelSlot*>& OutStreamSlots
	);
	
	/** 清空当前画布上的所有节点和连线 */
	void ClearTree();

	/**
	 * 根据一个节点接口创建对应的 Widget 并添加到 CanvasPanel,
	 * 同时返回该 Widget 在 CanvasPanel 上的 Slot 以便后续定位。
	 * @param Node - 要显示的数据节点接口。
	 * @param OutCanvasSlot - 输出参数,生成的 CanvasPanelSlot。
	 * @return 新创建的 UUserWidget*,或 nullptr。
	 */
	UUserWidget* CreateWidgetForNode(const ITreeNodeInterface* Node, UCanvasPanelSlot*& OutCanvasSlot);

	/**
	 * 创建一条从 "From" Widget 到 "To" Widget 的连线(SplineWidget)。
	 * @param From - 起始节点 Widget。
	 * @param To - 目标节点 Widget。
	 */
	void CreateConnection(const UUserWidget* From, UUserWidget* To);

	// 画布根节点
	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UCanvasPanel> RootPanel;

	/** 用来记录当前树的中心物品,避免重复绘制同一个树 */
	UPROPERTY()
	TObjectPtr<const UObject> CurrentCenterItem;

	/** 节点尺寸(宽高),可在编辑器中调整 */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D NodeSize = FVector2D{ 60.f };

	/** 节点之间的水平和垂直间隔 */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D NodeGap = FVector2D{ 16.f, 30.f};

	/** 连线颜色 */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FLinearColor ConnectionColor = FLinearColor{0.8f, 0.8f, 0.8f, 1.f};

	/** 连线粗细 */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	float ConnectionThickness = 3.f;

	/** 连线起点相对于节点大小的本地坐标(百分比) */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D SourcePortLocalPos = FVector2D{ 0.5f, 0.9f };

	/** 连线终点相对于节点大小的本地坐标(百分比) */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D DestinationPortLocalPos = FVector2D{ 0.5f, 0.1f };

	/** 起点连线方向(角度) */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D SourcePortDirection = FVector2D{ 0.f, 90.f };

	/** 终点连线方向(角度) */
	UPROPERTY(EditDefaultsOnly, Category = "Tree")
	FVector2D DestinationPortDirection = FVector2D{ 0.f, 90.f };
};
cpp 复制代码
#include "ItemTreeWidget.h"

#include "SplineWidget.h"
#include "TreeNodeInterface.h"
#include "Components/CanvasPanel.h"
#include "Components/CanvasPanelSlot.h"

void UItemTreeWidget::DrawFromNode(const ITreeNodeInterface* NodeInterface)
{
	if (!NodeInterface) return;

	// 避免重复绘制:检查是否与当前中心节点相同
	if (CurrentCenterItem == NodeInterface->GetItemObject()) return;

	// 清空画布
	ClearTree();
	// 记录中心物品
	CurrentCenterItem = NodeInterface->GetItemObject();

	// 用于计算叶子节点的水平位置
	float NextLeafXPos = 0.0f;
	UCanvasPanelSlot* CenterWidgetPanelSlot = nullptr;
	// 创建中心节点的UI
	UUserWidget* CenterWidget = CreateWidgetForNode(NodeInterface, CenterWidgetPanelSlot);

	// 分别绘制下游节点和上游节点
	TArray<UCanvasPanelSlot*> LowerStreamSlots, UpperStreamSlots;

	// 绘制下游
	DrawStream(
		false,					// bUpperStream = false (下游)
		NodeInterface,          // 起始节点接口
		CenterWidget,           // 中心节点控件
		CenterWidgetPanelSlot,  // 中心节点位置槽
		0,                      // 起始深度
		NextLeafXPos,       // 叶节点位置计数器
		LowerStreamSlots    // 输出:下游节点槽集合
		);
	// 计算总宽度,然后将其居中
	float LowerStreamXMax = NextLeafXPos - NodeSize.X - NodeGap.X;
	// 居中偏移量
	float LowerMoveAmt = 0.f - LowerStreamXMax / 2.0f;
	// 应用下游分支偏移(水平居中)
	for (UCanvasPanelSlot* StreamSlot : LowerStreamSlots)
	{
		StreamSlot->SetPosition(StreamSlot->GetPosition() + FVector2D(LowerMoveAmt, 0.f));
	}

	// 刷新水平位置
	NextLeafXPos = 0.f;
	// 向上绘制
	DrawStream(
		true,					// bUpperStream = true (上游)
		NodeInterface,          // 起始节点接口
		CenterWidget,           // 中心节点控件
		CenterWidgetPanelSlot,  // 中心节点位置槽
		0,                      // 起始深度
		NextLeafXPos,       // 叶节点位置计数器
		UpperStreamSlots    // 输出:上游节点槽集合
		);
	// 计算上游分支最大X位置
	float UpperStreamXMax = NextLeafXPos - NodeSize.X - NodeGap.X;
	// 计算上游分支居中偏移量
	float UpperMoveAmt = 0.f - UpperStreamXMax / 2.0f;
	// 应用上游分支偏移(水平居中)
	for (UCanvasPanelSlot* StreamSlot : UpperStreamSlots)
	{
		StreamSlot->SetPosition(StreamSlot->GetPosition() + FVector2D{UpperMoveAmt, 0.f});
	}

	// 将中心节点置于坐标系原点 (0,0)
	CenterWidgetPanelSlot->SetPosition(FVector2D::Zero());
}

void UItemTreeWidget::DrawStream(bool bUpperStream, const ITreeNodeInterface* StartingNodeInterface,
	UUserWidget* StartingNodeWidget, UCanvasPanelSlot* StartingNodeSlot, int32 StartingNodeDepth,
	float& NextLeafXPosition, TArray<UCanvasPanelSlot*>& OutStreamSlots)
{
	// 节点的生成方式类似于多叉树遍历的前序遍历:采用深度优先策略来遍历,一路走到黑
	
	// 如果bUpperStream为True 获取合成的目标,将向上递归(生成树往上画),否则获取合成的材料物品,向下进行递归(生成树往下画)
	TArray<const ITreeNodeInterface*> NextTreeNode = bUpperStream ? StartingNodeInterface->GetInputs() : StartingNodeInterface->GetOutputs();
	
	// 计算当前节点的垂直位置 = (节点的高度 + 节点之间的垂直间隔) * 当前的深度 * (向上连接的话乘负数,向下连接乘正数)
	float StartingNodeYPos = (NodeSize.Y + NodeGap.Y) * StartingNodeDepth * (bUpperStream ? -1 : 1);

	// 递归的退出条件(遇到叶子节点)
	if (NextTreeNode.Num() == 0)
	{
		// 设置节点的位置
		StartingNodeSlot->SetPosition(FVector2D{NextLeafXPosition, StartingNodeYPos});

		// 更新下一个叶节点的位置:向右移动(节点的宽度 + 水平间距)(用于将父类节点或者是将后面遍历到的兄弟节点往右边移动)
		NextLeafXPosition += NodeSize.X + NodeGap.X;
		return;
	}
	// 累加子节点水平位置
	float NextNodeXPosSum = 0;
	// 遍历所有子节点
	for (const ITreeNodeInterface* NextTreeNodeInterface : NextTreeNode)
	{
		// 子节点的画布插槽
		UCanvasPanelSlot* NextWidgetSlot;
		// 创建子节点UI
		UUserWidget* NextWidget = CreateWidgetForNode(NextTreeNodeInterface, NextWidgetSlot);
		OutStreamSlots.Add(NextWidgetSlot); // 添加节点槽

		// 创建线条连接两个UI
		if (bUpperStream)
		{
			// 如果向上连接的话,由下一个节点(NextWidget)连接到当前节点(StartingNodeWidget)
			CreateConnection(NextWidget, StartingNodeWidget);
		}else
		{
			// 向下连接,当前节点指向下一个节点
			CreateConnection(StartingNodeWidget, NextWidget);
		}

		// 深度优先遍历: 处理子节点的子树
		DrawStream(
			bUpperStream,           // 保持分支方向
			NextTreeNodeInterface,  // 子节点接口
			NextWidget,             // 子节点控件
			NextWidgetSlot,         // 子节点位置槽
			StartingNodeDepth + 1,  // 深度 + 1
			NextLeafXPosition,   // 传递叶节点位置(引用修改)
			OutStreamSlots       // 继续收集节点槽
		);

		// 累加子节点水平位置(累加的时候,该节点已经经过了循环外面的位置计算了,所以这里的值是有的)
		NextNodeXPosSum += NextWidgetSlot->GetPosition().X;
	}
	
	// ===== 位置计算 =====
	// 计算当前节点的水平位置 = 所有子节点位置的平均值
	float AvgXNodePos = NextNodeXPosSum / NextTreeNode.Num();
    
	// 设置当前节点位置(水平居中于子节点, 垂直按深度排列)
	StartingNodeSlot->SetPosition(FVector2D{AvgXNodePos, StartingNodeYPos});
}

void UItemTreeWidget::ClearTree()
{
	// 清空画布
	RootPanel->ClearChildren();
}

UUserWidget* UItemTreeWidget::CreateWidgetForNode(const ITreeNodeInterface* Node, UCanvasPanelSlot*& OutCanvasSlot)
{
	if (!Node) return nullptr;

	// 创建节点的Widget
	UUserWidget* NodeWidget = Node->GetWidget();
	// 将生成的控件添加到Canvas根面板中
	// AddChildToCanvas返回值为画布插槽指针,用于调整布局参数
	OutCanvasSlot = RootPanel->AddChildToCanvas(NodeWidget);
	if (OutCanvasSlot)
	{
		// 指定它的大小、锚点在中心、对齐方式也居中、层级为 1
		OutCanvasSlot->SetSize(NodeSize);
		OutCanvasSlot->SetAnchors(FAnchors(0.5f));
		OutCanvasSlot->SetAlignment(FVector2D(0.5f));
		OutCanvasSlot->SetZOrder(1);
	}
	
	return NodeWidget;
}

void UItemTreeWidget::CreateConnection(const UUserWidget* From, UUserWidget* To)
{
	if (!From || !To) return;

	// 创建线条UI
	USplineWidget* Connection = CreateWidget<USplineWidget>(GetOwningPlayer());
	// 创建画布插槽,并将线条UI添加到画布插槽中,再添加到画布中
	UCanvasPanelSlot* ConnectionSlot = RootPanel->AddChildToCanvas(Connection);
	if (ConnectionSlot)
	{
		// 设置连线底层,确保在节点下面
		ConnectionSlot->SetAnchors(FAnchors{0.f});
		ConnectionSlot->SetAlignment(FVector2D{0.f});
		ConnectionSlot->SetPosition(FVector2D::Zero());
		ConnectionSlot->SetZOrder(0);
	}
	
	// 设置线条的起点、终点、本地偏移和方向
	Connection->SetupSpline(
		From, To,
		SourcePortLocalPos, DestinationPortLocalPos,
		SourcePortDirection, DestinationPortDirection
	);
	// 设置颜色和粗细
	Connection->SetSplineStyle(ConnectionColor, ConnectionThickness);
}

将合成树添加到商店中

cpp 复制代码
	// 物品合成树控件
	UPROPERTY(meta=(BindWidget))
	TObjectPtr<UItemTreeWidget> CombinationTree;

	// 显示指定物品的合成树
	void ShowItemCombination(const UShopItemWidget* ItemWidget);
cpp 复制代码
void UShopWidget::ShopItemWidgetGenerated(UUserWidget& NewWidget)
{
	// 转换为商店物品控件
	UShopItemWidget* ItemWidget = Cast<UShopItemWidget>(&NewWidget);
	if (ItemWidget)
	{
		// 绑定购买事件到库存系统
		if (OwnerInventoryComponent)
		{
			ItemWidget->OnItemPurchaseIssued.AddUObject(
				OwnerInventoryComponent,
				&UInventoryComponent::TryPurchase);
		}
		
		// 绑定选择事件(鼠标左键)到合成树显示
		ItemWidget->OnShopItemClicked.AddUObject(
			this,
			&UShopWidget::ShowItemCombination);
		
		// 添加到物品映射表
		ItemsMap.Add(ItemWidget->GetShopItem(), ItemWidget);;
	}
}

void UShopWidget::ShowItemCombination(const UShopItemWidget* ItemWidget)
{
	if (CombinationTree)
	{
		// 绘制合成树,以传入物品为Root
		CombinationTree->DrawFromNode(ItemWidget);
	}
}

创建合成树的蓝图

放入一个画布面板再用尺寸框包裹住,设置一个大小

用水平框包裹住原本的包裹着商店的垂直框,并将创建好的合成树画布放进来并改名

物品的子节点都在这里配置(自由随意搭配,这里也都是乱搞的,感觉奇奇怪怪的)

暂且先告一段落,明天我应该就能把库存组件做完了

完整商店物品ShopItemWidget代码

cpp 复制代码
#pragma once

#include "CoreMinimal.h"
#include "TreeNodeInterface.h"
#include "Blueprint/IUserObjectListEntry.h"
#include "Inventory/PDA_ShopItem.h"
#include "UI/Common/ItemWidget.h"
#include "ShopItemWidget.generated.h"


class UShopItemWidget;

// 声明委托:当物品购买请求发出时触发(参数:商店物品数据资产)
DECLARE_MULTICAST_DELEGATE_OneParam(FOnItemPurchaseIssused, const UPDA_ShopItem*);

// 声明委托:当商店物品被选中时触发(参数:商店物品控件实例)
DECLARE_MULTICAST_DELEGATE_OneParam(FOnShopItemSelected, const UShopItemWidget*);

/**
 * 商店物品UI控件
 * 功能:
 *   - 继承基础物品控件功能
 *   - 实现列表项接口(用于ListView)
 *   - 实现树节点接口(用于合成树展示)
 *   - 处理购买和选择事件
 * 使用场景:商店界面、合成系统界面
 */
UCLASS()
class UShopItemWidget : public UItemWidget, 
						public IUserObjectListEntry,
						public ITreeNodeInterface
{
	GENERATED_BODY()
public:
	// 委托:物品购买请求发出时广播
	FOnItemPurchaseIssused OnItemPurchaseIssued;
	
	// 委托:物品被点击选择时广播
	FOnShopItemSelected OnShopItemClicked;

	//~ Begin ITreeNodeInterface 接口实现
	// 获取当前控件实例(树节点接口要求)
	virtual UUserWidget* GetWidget() const override;
	
	// 获取输入节点(合成树中的材料项)
	virtual TArray<const ITreeNodeInterface*> GetInputs() const override;
	
	// 获取输出节点(合成树中的产出项)
	virtual TArray<const ITreeNodeInterface*> GetOutputs() const override;
	
	// 获取关联的数据对象(商店物品资产)
	virtual const UObject* GetItemObject() const override;
	//~ End ITreeNodeInterface 接口实现
	
	
	//~ Begin IUserObjectListEntry 接口实现
	// 当列表项绑定数据对象时调用(通常为UPA_ShopItem实例)
	virtual void NativeOnListItemObjectSet(UObject* ListItemObject) override;
	//~ End IUserObjectListEntry 接口实现

	// 获取当前绑定的商店物品数据
	FORCEINLINE const UPDA_ShopItem* GetShopItem() const { return ShopItem; }
private:
	// 从另一个商店物品控件复制数据(用于列表项重用)
	void CopyFromOther(const UShopItemWidget* OtherWidget);
	
	// 使用商店物品数据初始化控件
	void InitWithShopItem(const UPDA_ShopItem* NewShopItem);
	
	/**
	 * 将商店物品数组转换为树节点接口数组
	 * @param Items 商店物品数组
	 * @return 对应的树节点接口数组
	 */
	TArray<const ITreeNodeInterface*> ItemsToInterfaces(const TArray<const UPDA_ShopItem*>& Items) const;

	// 父级ListView控件(此物品所属的列表视图)
	UPROPERTY()
	TObjectPtr<const class UListView> ParentListView;
	
	// 当前绑定的商店物品数据资产
	UPROPERTY()
	TObjectPtr<const UPDA_ShopItem> ShopItem;

	//~ Begin UItemWidget 重写
	// 右键点击处理:触发购买委托
	virtual void RightButtonClicked() override;
	
	// 左键点击处理:触发选择委托
	virtual void LeftButtonClicked() override;
	//~ End UItemWidget 重写
};
cpp 复制代码
#include "ShopItemWidget.h"

#include "Components/ListView.h"
#include "Framework/CAssetManager.h"

UUserWidget* UShopItemWidget::GetWidget() const
{
	// 创建一样的商店物品UI
	UShopItemWidget* Copy = CreateWidget<UShopItemWidget>(GetOwningPlayer(), GetClass());

	// 复制商店物品UI数据
	Copy->CopyFromOther(this);
	return Copy;
}

TArray<const ITreeNodeInterface*> UShopItemWidget::GetInputs() const
{
	// 获取可合成该物品的材料列表
	const FItemCollection* Collection = UCAssetManager::Get().GetCombinationForItem(GetShopItem());

	// 如果找到了材料,就把它们转换为树节点返回
	if (Collection)
	{
		return ItemsToInterfaces(Collection->GetItems());
	}

	return TArray<const ITreeNodeInterface*>{};
}

TArray<const ITreeNodeInterface*> UShopItemWidget::GetOutputs() const
{
	// 获取合成该物品所需的材料列表
	const FItemCollection* Collection = UCAssetManager::Get().GetIngredientForItem(GetShopItem());

	// 转换为树节点(例如合成树中的"箭头"方向)
	if (Collection)
	{
		return ItemsToInterfaces(Collection->GetItems());
	}

	return TArray<const ITreeNodeInterface*>{};
}

const UObject* UShopItemWidget::GetItemObject() const
{
	return ShopItem;
}

void UShopItemWidget::NativeOnListItemObjectSet(UObject* ListItemObject)
{
	IUserObjectListEntry::NativeOnListItemObjectSet(ListItemObject);

	// ShopItem = Cast<UPDA_ShopItem>(ListItemObject);
	// if (!ShopItem) return;
	//
	// SetIcon(ShopItem->GetIcon());
	//
	// SetToolTipWidget(ShopItem);
	// 使用新数据初始化控件
	InitWithShopItem(Cast<UPDA_ShopItem>(ListItemObject));
	
	// 获取所属的ListView控件
	ParentListView = Cast<UListView>(IUserListEntry::GetOwningListView());
}

void UShopItemWidget::CopyFromOther(const UShopItemWidget* OtherWidget)
{
	// 拷贝事件绑定(点击购买、点击选中)
	OnItemPurchaseIssued = OtherWidget->OnItemPurchaseIssued;
	OnShopItemClicked = OtherWidget->OnShopItemClicked;

	// 复制父列表
	ParentListView = OtherWidget->ParentListView;

	// 使用商店物品数据初始化界面
	InitWithShopItem(OtherWidget->GetShopItem());
}

void UShopItemWidget::InitWithShopItem(const UPDA_ShopItem* NewShopItem)
{
	// 设置商店物品数据
	ShopItem = NewShopItem;
	if (!ShopItem) return;
	// 设置图标
	SetIcon(ShopItem->GetIcon());
	// 创建并设置提示信息
	SetToolTipWidget(ShopItem);
}

TArray<const ITreeNodeInterface*> UShopItemWidget::ItemsToInterfaces(const TArray<const UPDA_ShopItem*>& Items) const
{
	// 创建用来存储的树节点的数组
	TArray<const ITreeNodeInterface*> RetInterfaces;

	if (!ParentListView) return RetInterfaces;

	// 遍历物品数组,把它们从 ListView 中转换为 Widget(然后作为树节点返回)
	for (const UPDA_ShopItem* Item : Items)
	{
		// 从ParentListView列表视图中获取与Item数据项关联的UShopItemWidget控件
		// ParentListView存储的就是商店的那个框框
		const UShopItemWidget* ItemWidget = ParentListView->GetEntryWidgetFromItem<UShopItemWidget>(Item);
		if (ItemWidget)
		{
			// 将对应的树节点添加到数组中
			RetInterfaces.Add(ItemWidget);
		}
	}
	return RetInterfaces;
}

void UShopItemWidget::RightButtonClicked()
{
	// 购买
	OnItemPurchaseIssued.Broadcast(GetShopItem());
}

void UShopItemWidget::LeftButtonClicked()
{
	// 选中
	OnShopItemClicked.Broadcast(this);
}

完整商店UIShopWidget代码

cpp 复制代码
#pragma once

#include "CoreMinimal.h"
#include "ShopItemWidget.h"
#include "Blueprint/UserWidget.h"
#include "Components/TileView.h"
#include "Inventory/InventoryComponent.h"
#include "Inventory/PDA_ShopItem.h"
#include "ShopWidget.generated.h"

class UItemTreeWidget;

/**
 * 商店界面主控件
 * 功能:
 *   - 显示可购买的商品列表
 *   - 展示物品的合成关系树
 *   - 管理商店物品加载和显示
 *   - 处理物品选择和合成展示
 */
UCLASS()
class CRUNCH_API UShopWidget : public UUserWidget
{
	GENERATED_BODY()
public:
	virtual void NativeConstruct() override;

private:
	// 商店物品列表
	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UTileView> ShopItemList;

	// 物品合成树控件
	UPROPERTY(meta=(BindWidget))
	TObjectPtr<UItemTreeWidget> CombinationTree;
	
	// 加载商店物品
	void LoadShopItems();

	// 商店物品加载完成
	void ShopItemLoadFinished();

	// 商店物品生成
	void ShopItemWidgetGenerated(UUserWidget& NewWidget);
	
	// 商店物品到控件的映射表
	// 用途:快速查找物品对应的控件实例
	UPROPERTY()
	TMap<const UPDA_ShopItem*, const UShopItemWidget*> ItemsMap;

	// 库存组件:获取玩家的库存
	UPROPERTY()
	TObjectPtr<UInventoryComponent> OwnerInventoryComponent;

	// 显示指定物品的合成树
	void ShowItemCombination(const UShopItemWidget* ItemWidget);
};
cpp 复制代码
#include "ShopWidget.h"

#include "ItemTreeWidget.h"
#include "Framework/CAssetManager.h"

void UShopWidget::NativeConstruct()
{
	Super::NativeConstruct();
	// 设置可聚焦
	SetIsFocusable(true);
	// 加载物品
	LoadShopItems();

	// 绑定列表项生成事件
	ShopItemList->OnEntryWidgetGenerated().AddUObject(this, &UShopWidget::ShopItemWidgetGenerated);

	// 获取玩家的库存组件并存储起来
	if (APawn* OwnerPawn = GetOwningPlayerPawn())
	{
		OwnerInventoryComponent = OwnerPawn->GetComponentByClass<UInventoryComponent>();
	}
}

void UShopWidget::LoadShopItems()
{
	// 调用资产管理器的异步加载方法
	// 加载完成后触发 ShopItemLoadFinished 回调
	UCAssetManager::Get().LoadShopItems(
		FStreamableDelegate::CreateUObject(this, &UShopWidget::ShopItemLoadFinished)
		);
}

void UShopWidget::ShopItemLoadFinished()
{
	// 获取所有已加载的商店物品
	TArray<const UPDA_ShopItem*> ShopItems;
	if (UCAssetManager::Get().GetLoadedShopItems(ShopItems))
	{
		// 添加商店物品
		for (const UPDA_ShopItem* ShopItem : ShopItems)
		{
			ShopItemList->AddItem(const_cast<UPDA_ShopItem*>(ShopItem));
		}
	}
}

void UShopWidget::ShopItemWidgetGenerated(UUserWidget& NewWidget)
{
	// 转换为商店物品控件
	UShopItemWidget* ItemWidget = Cast<UShopItemWidget>(&NewWidget);
	if (ItemWidget)
	{
		// 绑定购买事件到库存系统
		if (OwnerInventoryComponent)
		{
			ItemWidget->OnItemPurchaseIssued.AddUObject(
				OwnerInventoryComponent,
				&UInventoryComponent::TryPurchase);
		}
		
		// 绑定选择事件(鼠标左键)到合成树显示
		ItemWidget->OnShopItemClicked.AddUObject(
			this,
			&UShopWidget::ShowItemCombination);
		
		// 添加到物品映射表
		ItemsMap.Add(ItemWidget->GetShopItem(), ItemWidget);;
	}
}

void UShopWidget::ShowItemCombination(const UShopItemWidget* ItemWidget)
{
	if (CombinationTree)
	{
		// 绘制合成树,以传入物品为Root
		CombinationTree->DrawFromNode(ItemWidget);
	}
}
相关推荐
DoomGT11 小时前
Physics Simulation - UE中Projectile相关事项
ue5·游戏引擎·虚幻·虚幻引擎·unreal engine
右弦GISer2 天前
【UE5医学影像可视化】读取本地Dicom生成VolumeTexture,实现2D显示和自动翻页
ue5·dicom·医学图像
小梦白2 天前
RPG增容3:尝试使用MVC结构搭建玩家升级UI(一)
游戏·ui·ue5·mvc
AgilityBaby3 天前
解决「CPU Virtualization Technology 未开启或被占用」弹窗问题
ue5·游戏引擎·无畏契约·cpu 虚拟化技术
幻雨様5 天前
UE5多人MOBA+GAS 番外篇:同时造成多种类型伤害
ue5
幻雨様5 天前
UE5多人MOBA+GAS 番外篇:同时造成多种类型伤害,以各种属性值的百分比来应用伤害(版本二)
java·前端·ue5
AA陈超5 天前
虚幻引擎5 GAS开发俯视角RPG游戏 #06-11:游戏后效果执行
c++·游戏·ue5·游戏引擎·虚幻
幻雨様7 天前
UE5多人MOBA+GAS 番外篇:将冷却缩减属性应用到技能冷却中
ue5
幻雨様7 天前
UE5多人MOBA+GAS 32、制作商店系统(一)
ue5