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);
	}
}
相关推荐
AI视觉网奇1 小时前
ue 操作 metahuman
ue5
AI视觉网奇3 小时前
ue python脚本 获取资产
笔记·ue5
AI视觉网奇5 小时前
audio2face docker方式
docker·ue5
会思考的猴子8 小时前
UE5 笔记二 GameplayAbilitySystem Dash(冲刺)
笔记·ue5
AI视觉网奇1 天前
audio2face ue插件形式实战笔记
笔记·ue5
nutriu2 天前
从UE5.6DNA 导出指定LOD层级的ARkit52个表情或者Metahuman263个表情教程 #BlendShapeExporter
ue5·数字人·arkit·blendshape·虚拟角色·meta human·dna
AI视觉网奇2 天前
nvcr.io 登录方法
docker·ue5
会思考的猴子2 天前
UE5 C++ 笔记 GameplayAbilitySystem人物角色
c++·笔记·ue5
Zhichao_973 天前
【UE5.3 C++】ARPG游戏 01-创建天空、地形和植被
ue5
zhangzhangkeji3 天前
cesium126,230719,远程工作 Editor 里看不到地形:就是 UE 编辑器用客户端登录远程服务器进行编码时,看不到地图的实时更新
ue5