尧图精选

UE5库存系统堆叠功能实现:数据结构、合并拆分与UI拖拽交互

🕒 发布时间:2026/9/7 2:22:45 📁 来源:尧图网络
这次我们直接进入第 5 部分最关心的堆叠问题。前几讲我们已经把库存系统的数据层、UI 层和交互框架搭起来了这一讲的核心就是把“堆叠”这个功能补完让同一类物品能合并、拆分、自动归类而不是每个格子傻乎乎地只装 1 个。堆叠看起来就是一个数字加减但放到可扩展的库存架构里它牵扯到物品数据结构设计、批量插入策略、拖拽拆分、UI 刷新时机、存档迁移兼容等一系列问题。如果你直接把 StackSize 写成 int 然后到处 后面做存档、做批量合成、做服务器同步时一定会返工。1. 核心能力速览能力项说明引擎版本虚幻引擎 5.xUE5前置知识数据结构、UDataTable、UUserWidget 基础、委托/事件驱动核心功能物品堆叠、数量合并、堆叠上限控制、堆叠拆分、自动堆叠交互方式拖拽堆叠、右键快速拆分、Shift拖拽批量移动数据存储UDataTable 物品行 UInventoryComponent 数据结构UI 方案ListView / WrapBox UUserWidget 单格控件扩展方向批量合成、分类排序、批量丢弃、网络同步难易程度中等偏上重点在于数据结构抽象这篇文章不会把全部库存代码贴一遍而是聚焦在“堆叠”相关的关键设计数据结构、增删合并逻辑、拖拽交互和 UI 刷新以及这些功能如何不破坏之前的可扩展架构。2. 堆叠需求分析与系统设计思路先明确需求。一个可持续迭代的库存系统里“堆叠”不是简单地给物品加一个数量字段而是要回答这几个问题什么类型的物品可以堆叠单格最大堆叠数量是多少当物品数量超过单格上限时如何自动拆分到新格子拖拽一个物品到另一个同类型物品上时是合并还是交换按住 Shift 拖拽时如何实现批量转移与自动堆叠存档读取时如何兼容新增的堆叠字段从架构角度来看最稳妥的做法是堆叠规则由“物品定义”决定而不是由“库存格子”决定。也就是说物品能不能堆叠、最大堆叠数是多少应该配置在 DataTable 或物品资产上库存系统只是读取这些规则并执行数量的增减。这样设计的好处非常明显后续新增药品、弹药类物品不需要改库存逻辑。可以针对不同物品设置不同的最大堆叠数比如箭矢 99、药水 20、钥匙 1。合成系统、商店系统可以复用同一套数量逻辑。如果你做网络同步只需要同步数量字段的增量不需要重新设计协议。3. 物品数据结构的堆叠字段设计之前我们大概率已经有一个 FInventoryItemStruct比如USTRUCT(BlueprintType) struct FInventoryItemStruct : public FTableRowBase { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly) FName ItemID; UPROPERTY(EditAnywhere, BlueprintReadOnly) FText ItemName; UPROPERTY(EditAnywhere, BlueprintReadOnly) TSoftObjectPtrUTexture2D ItemIcon; UPROPERTY(EditAnywhere, BlueprintReadOnly) UClass* ItemActorClass; };现在要加入堆叠相关字段建议从数据表层面直接定义默认规则和运行时数量分开USTRUCT(BlueprintType) struct FInventoryItemStruct : public FTableRowBase { GENERATED_BODY() // 物品唯一 ID UPROPERTY(EditAnywhere, BlueprintReadOnly) FName ItemID; // 显示名称 UPROPERTY(EditAnywhere, BlueprintReadOnly) FText ItemName; // 图标 UPROPERTY(EditAnywhere, BlueprintReadOnly) TSoftObjectPtrUTexture2D ItemIcon; // 该物品是否允许堆叠 UPROPERTY(EditAnywhere, BlueprintReadOnly) bool bCanStack true; // 单格最大堆叠数 UPROPERTY(EditAnywhere, BlueprintReadOnly, meta (EditCondition bCanStack)) int32 MaxStackSize 99; // 物品基础重量堆叠时用于计算总重量 UPROPERTY(EditAnywhere, BlueprintReadOnly) float WeightPerUnit 1.0f; };这样DataTable 里的每一行都描述了“该类物品的固有属性”而数量则存在库存格子里。接下来是库存格子的数据。推荐不要直接在一个大数组里写死“ItemID Count”而是用一个结构体包装单元格信息USTRUCT(BlueprintType) struct FInventorySlotData { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FName ItemID NAME_None; UPROPERTY(BlueprintReadOnly) int32 Count 0; UPROPERTY(BlueprintReadOnly) FInventoryItemStruct ItemInfo; bool IsEmpty() const { return ItemID NAME_None || Count 0; } };为什么需要包装因为后续要在 UI 里做选中态、拖拽起始索引、冷却显示等都是基于“槽位”进行的而不是基于“物品”进行的。把 ItemID 和 Count 放进一个 Slot 结构体里后续扩展更干净。4. 堆叠核心逻辑实现4.1 库存组件的增删改接口库存数据建议放在 UInventoryComponent 中组件挂在 PlayerCharacter 或 GameState 上。下面给出一套通用的堆叠核心函数签名UCLASS() class UInventoryComponent : public UActorComponent { GENERATED_BODY() public: // 从物品 ID 出发尝试放入指定数量 UFUNCTION(BlueprintCallable, Category Inventory) int32 AddItem(FName ItemID, int32 Count); // 从指定槽位移除指定数量 UFUNCTION(BlueprintCallable, Category Inventory) bool RemoveItemAt(int32 SlotIndex, int32 Count); // 拖拽合并把 SourceSlot 中的 Count 合并到 TargetSlot UFUNCTION(BlueprintCallable, Category Inventory) bool MergeStack(int32 SourceSlot, int32 TargetSlot, int32 Count); // 拆分把 SourceSlot 中的 Count 拆到 TargetSlotTargetSlot 必须为空 UFUNCTION(BlueprintCallable, Category Inventory) bool SplitStack(int32 SourceSlot, int32 TargetSlot, int32 Count); protected: UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly) TArrayFInventorySlotData Slots; };重点说一下 AddItem 的返回值设计返回“实际放入的数量”。为什么不是 bool因为当库存空间不足时你往往需要知道“放进去几个、还剩几个”也就是拆包逻辑。比如玩家拾取 50 支箭但每个格子最多放 99而最后一格只够放 30AddItem 就应返回 30剩下的 20 需要进一步处理掉落或留在拾取物中。int32 UInventoryComponent::AddItem(FName ItemID, int32 Count) { if (Count 0) return 0; // 1. 先拿到物品定义行 FInventoryItemStruct* ItemRow GetItemRow(ItemID); if (!ItemRow) return 0; if (!ItemRow-bCanStack) { // 不可堆叠物品一个格一个直到用完 Count 或格子用尽 return AddNonStackableItem(ItemID, Count); } int32 Remaining Count; // 2. 优先填充已有相同 ItemID 且未满的格子 for (int32 i 0; i Slots.Num() Remaining 0; i) { if (Slots[i].ItemID ! ItemID) continue; if (Slots[i].Count ItemRow-MaxStackSize) continue; int32 Space ItemRow-MaxStackSize - Slots[i].Count; int32 AddCount FMath::Min(Space, Remaining); Slots[i].Count AddCount; Remaining - AddCount; } // 3. 如果还有剩余开新格子 for (int32 i 0; i Slots.Num() Remaining 0; i) { if (!Slots[i].IsEmpty()) continue; int32 AddCount FMath::Min(ItemRow-MaxStackSize, Remaining); Slots[i].ItemID ItemID; Slots[i].ItemInfo *ItemRow; Slots[i].Count AddCount; Remaining - AddCount; } // 4. 广播刷新事件 OnInventoryUpdated.Broadcast(); // 5. 返回实际放进的数量 return Count - Remaining; }这里需要注意一个顺序问题先填已有的同类型格子再开新格子。这个顺序直接决定玩家拾取物品时是不是“优先把散落的同类物品合到一起”。如果你反过来就会看到明明前面有空位系统却开一堆新格子使用体验会很差。4.2 MergeStack 合并逻辑拖拽合并是库存系统的核心交互。处理逻辑并不复杂但要明确分支bool UInventoryComponent::MergeStack(int32 SourceSlot, int32 TargetSlot, int32 Count) { if (!Slots.IsValidIndex(SourceSlot) || !Slots.IsValidIndex(TargetSlot)) return false; if (SourceSlot TargetSlot) return false; FInventorySlotData Source Slots[SourceSlot]; FInventorySlotData Target Slots[TargetSlot]; if (Source.IsEmpty() || Count 0) return false; // 目标为空直接放置 if (Target.IsEmpty()) { FInventorySlotData NewSlot; NewSlot.ItemID Source.ItemID; NewSlot.ItemInfo Source.ItemInfo; NewSlot.Count Count; Target NewSlot; Source.Count - Count; if (Source.Count 0) { Source FInventorySlotData(); } OnInventoryUpdated.Broadcast(); return true; } // 目标不为空必须是同类型且可堆叠且未达到上限 if (Target.ItemID ! Source.ItemID) return false; FInventoryItemStruct* ItemRow GetItemRow(Source.ItemID); if (!ItemRow || !ItemRow-bCanStack) return false; int32 Space ItemRow-MaxStackSize - Target.Count; if (Space 0) return false; int32 ActualMove FMath::Min(Space, Count); Target.Count ActualMove; Source.Count - ActualMove; if (Source.Count 0) { Source FInventorySlotData(); } OnInventoryUpdated.Broadcast(); return true; }这里有一个小细节合并失败时拖拽物品应该弹回原位而不是凭空消失。所以 MergeStack 返回 bool 是必要的UI 层要根据返回值决定是否播放“非法放置”的动画提示。4.3 SplitStack 拆分逻辑拆分的场景是按住某个快捷键比如 Alt 拖拽或者右键点击有堆叠的物品弹出一个数量选择窗口然后拆出一部分放到另一个空格子里。bool UInventoryComponent::SplitStack(int32 SourceSlot, int32 TargetSlot, int32 Count) { if (!Slots.IsValidIndex(SourceSlot) || !Slots.IsValidIndex(TargetSlot)) return false; if (SourceSlot TargetSlot) return false; FInventorySlotData Source Slots[SourceSlot]; FInventorySlotData Target Slots[TargetSlot]; if (Source.IsEmpty() || Count 0 || Count Source.Count) return false; // 目标槽位必须为空拆出去的部分不能合并到已有堆叠里 if (!Target.IsEmpty()) return false; FInventorySlotData NewSlot; NewSlot.ItemID Source.ItemID; NewSlot.ItemInfo Source.ItemInfo; NewSlot.Count Count; Target NewSlot; Source.Count - Count; OnInventoryUpdated.Broadcast(); return true; }注意一个设计取舍拆分时我要求目标格子必须为空。如果允许拆到已有堆叠上从交互上讲就会和 Merge 逻辑混在一起容易产生不确定性。更稳妥的做法是拆分的本质是“把一堆物品分成两堆”而不是“把一堆物品加到另一堆上”。这个边界划清楚UI 提示也会更清晰。5. UI 层堆叠显示与交互5.1 单格控件更新如果你的库存 UI 用的是 WrapBox UUserWidget 动态生成那么每个格子控件建议维护三个关键元素物品图标Image数量文本TextBlock背景框/选中框Border每次库存数据刷新时统一调用一个 UpdateSlot(FInventorySlotData Slot) 函数而不要在每个按钮点击回调里分别改文字和图标。这样可以避免“图标更新了、数量没更新”这类不同步问题。void UInventorySlotWidget::UpdateSlot(const FInventorySlotData SlotData) { if (SlotData.IsEmpty()) { ItemIcon-SetBrushFromTexture(nullptr); ItemIcon-SetVisibility(ESlateVisibility::Collapsed); CountText-SetVisibility(ESlateVisibility::Collapsed); bIsEmpty true; return; } ItemIcon-SetBrushFromTexture(SlotData.ItemInfo.ItemIcon.LoadSynchronous()); ItemIcon-SetVisibility(ESlateVisibility::Visible); FNumberFormattingOptions Options; Options.SetMaximumIntegralDigits(6); CountText-SetText(FText::AsNumber(SlotData.Count, Options)); CountText-SetVisibility(ESlateVisibility::Visible); bIsEmpty false; }这里有一个值得注意的点不要每帧刷新 UI。堆叠数量变化只发生在拾取、拖拽、丢弃等事件中应该采用事件驱动。也就是在 UInventoryComponent 里声明一个多播委托DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryUpdated); UPROPERTY(BlueprintAssignable, Category Inventory) FOnInventoryUpdated OnInventoryUpdated;在 AddItem、RemoveItemAt、MergeStack、SplitStack 执行成功后广播一次UI 统一监听并刷新。这也是让库存系统保持可扩展的关键习惯数据层和表现层解耦。5.2 拖拽堆叠的 UI 交互UE5 的 UMG 拖拽主要使用 UUserWidget 的 NativeOnMouseButtonDown 和 NativeOnDragDetected 来实现。核心思想是在拖拽开始时携带数据生成一个 UDragDropOperationDrop 时解析数据并调用库存组件。这里给出一段拖拽操作数据的示例UCLASS() class UInventoryDragDropOperation : public UDragDropOperation { GENERATED_BODY() public: UPROPERTY() int32 SourceSlotIndex -1; UPROPERTY() int32 DragCount 1; UPROPERTY() UInventoryComponent* InventoryComponent nullptr; };拖拽发起时在 NativeOnDragDetected 中创建并初始化这个 Operationvoid UInventorySlotWidget::NativeOnDragDetected( const FGeometry InGeometry, const FPointerEvent InMouseEvent, UDragDropOperation* OutOperation) { if (bIsEmpty) return; UInventoryDragDropOperation* DragOp NewObjectUInventoryDragDropOperation(); DragOp-SourceSlotIndex SlotIndex; DragOp-DragCount 1; DragOp-InventoryComponent InventoryComponent; DragOp-DefaultDragVisual CreateDragVisual(); OutOperation DragOp; }在 Drop 集中处理时建议用一个统一的函数处理“普通拖拽”和“批量拖拽”bool UInventoryPanelWidget::HandleDropToSlot(int32 TargetSlot, UInventoryDragDropOperation* DragOp) { if (!DragOp || !DragOp-InventoryComponent) return false; // 按住 Shift 时尝试把整个堆叠全部合并过去而不是只拖 1 个 int32 MoveCount DragOp-DragCount; if (FSlateApplication::Get().GetModifierKeys().IsShiftDown()) { MoveCount GetStackCountAt(DragOp-SourceSlotIndex); } bool bSuccess DragOp-InventoryComponent-MergeStack( DragOp-SourceSlotIndex, TargetSlot, MoveCount ); return bSuccess; }这里需要注意批量移动是一个容易被忽视但用户感知极强的小功能。建议把 Shift 拖拽设为“整组移动”普通拖拽快捷设为“按当前拖取数量一般默认 1”并加上数量选择弹窗作为扩展。6. 通过 UI 快速批量移动与自动堆叠6.1 自动堆叠按钮在 MMORPG 类游戏中“自动堆叠”是一个非常高频的操作。玩家背包里散落着好几组药水一键整理就可以让同类物品尽可能集中到前面的格子里。库存组件可以提供一个整理接口void UInventoryComponent::SortAndStack() { // 按 ItemID 分组 TMapFName, TArrayint32 ItemGroups; for (int32 i 0; i Slots.Num(); i) { if (Slots[i].IsEmpty()) continue; ItemGroups.FindOrAdd(Slots[i].ItemID).Add(i); } for (auto Pair : ItemGroups) { FName ItemID Pair.Key; FInventoryItemStruct* ItemRow GetItemRow(ItemID); if (!ItemRow) continue; TArrayint32 Indexes Pair.Value; // 从后往前合并到前面的格子里 int32 WriteIndex 0; while (WriteIndex Indexes.Num()) { int32 TargetIndex Indexes[WriteIndex]; if (Slots[TargetIndex].Count ItemRow-MaxStackSize) { WriteIndex; continue; } // 从后面找一个可以合并的格子 int32 ReadIndex WriteIndex 1; while (ReadIndex Indexes.Num()) { int32 SourceIndex Indexes[ReadIndex]; int32 Space ItemRow-MaxStackSize - Slots[TargetIndex].Count; if (Space 0) break; int32 MoveCount FMath::Min(Space, Slots[SourceIndex].Count); Slots[TargetIndex].Count MoveCount; Slots[SourceIndex].Count - MoveCount; if (Slots[SourceIndex].Count 0) { Slots[SourceIndex] FInventorySlotData(); Indexes.RemoveAt(ReadIndex); continue; } ReadIndex; } WriteIndex; } } // 整理后可选的排序逻辑例如按 ItemID 重排 CompactSlots(); OnInventoryUpdated.Broadcast(); }这个整理函数设计的关键是“只对同一 ItemID 的格子做合并”不会打乱其他物品的位置用户体验比较可控。6.2 拾取时的自动堆叠策略在拾取物品时AddItem 已经实现了“先填旧格子再开新格子”的策略。如果你希望进一步优化还可以考虑“优先填到数量最多的旧格子”还是“优先填到数量最少的旧格子”。填数量最多的旧格子合并速度快但每个格子数字差距会比较大。填数量最少的旧格子背包整体更有条理但算法的遍历成本稍高。从游戏手感来看普通玩家的直觉是“能合就合”对格子整齐度要求不高。因此建议保持“遍历顺序 先到先得”即可不需要做复杂排序。7. 存档与部分物品堆叠的序列化处理这部分很关键。你已经把堆叠功能加进去了如果之前已经有一套存档系统直接读老存档会遇到一个典型问题旧存档里只有 ItemID没有 Count 字段或者 Count 都是 1。处理策略有两个一是“字段默认值兼容”。在写入存档时对每个槽位记录 SlotVersion例如{ SlotVersion: 2, ItemID: Arrow, Count: 150 }读取时如果 SlotVersion 缺失就按 1 处理。这个方案最简单也最稳妥。二是“存档迁移”。在读取完成后检测到旧版本存档就全量扫描一次调用 SortAndStack 把同类型物品合并。这样玩家在加载旧存档后第一次打开背包就会看到堆叠生效。从工程角度看推荐两种方案结合读取时先做字段默认值兼容再在内存中做一次合并整理最后写回存档。这样后续版本就不需要再做迁移逻辑。8. 性能、网络与批量操作的边界8.1 单机库存的显存与性能库存系统的 UI 性能瓶颈通常不是数据计算而是 UMG 控件数量。一个 10x10 的背包就有 100 个 SlotWidget如果每个 SlotWidget 里有复杂的动画、外边框、阴影整体开销会很大。堆叠功能本身不会额外增加太多性能负担但如果你的背包支持“无限格”或者“动态扩容”就要注意生成 SlotWidget 的时机。建议使用 ListView 或动态按需生成而不是一次性创建上千个可见控件。8.2 网络同步中的堆叠如果你做的是联机游戏堆叠字段的同步建议使用增量同步而不是每次全量发送整个数组。例如当 AddItem 新增 5 个药水时只发送FInventorySyncDelta { FName ItemID; int32 DeltaCount; }客户端收到后通过 InventoryComponent 的 AddItem 走同一套堆叠逻辑保证两端结果一致。这里的关键是不要相信客户端传来的最终数量只接受增量数据数量计算必须在服务端完成。8.3 批量合成的扩展堆叠功能最常见的联动需求是合成系统。合成时玩家可能一次性消耗 30 个材料。推荐在 AddItem 之外额外封装一个 TryConsumeItemsbool UInventoryComponent::TryConsumeItems(const TMapFName, int32 RequiredItems) { // 先检查所有材料数量是否足够 for (const auto Pair : RequiredItems) { if (GetItemCount(Pair.Key) Pair.Value) return false; } // 再逐个扣除 for (const auto Pair : RequiredItems) { RemoveItemByName(Pair.Key, Pair.Value); } return true; }这里先检查后扣除的“两阶段”策略可以避免一次性扣款到一半时发现后续材料不足的尴尬局面。在合成、商店购买、任务提交等场景都建议采用这个模式。9. 常见问题与排查方法问题现象可能原因排查方式解决方案拾取物品后数量不变AddItem 未广播 UI 刷新委托检查 OnInventoryUpdated 是否被监听在 AddItem 末尾调用广播拖拽到相同物品上不合并判断条件写成 ItemID 不相等断点检查 Source 和 Target 的 ItemID确保 Target.ItemID Source.ItemID 时执行合并最大堆叠数不生效读取的数据表行缓存过期检查 DataTable Row 是否包含最新字段重新编译 刷新 DataTable 数据拆分后原堆叠消失拆分数量等于原数量时被错误允许检查 SplitStack 中的 Count Source.Count 判断应严格要求 Count Source.Count排序后物品错乱排序算法只移动了 Slot 里的 ItemID没有同步 ItemInfo检查 Slot 赋值是否完整统一使用 FInventorySlotData 赋值老存档加载后堆叠数据丢失旧存档没有 Count 字段检查存档序列化逻辑的默认值读取时缺失字段按 1 初始化拖拽过程中 UI 卡顿拖动时生成了大量临时控件查看 Created DragVisual 的复杂度拖拽预览使用简化图标网络联机时两端数量不一致客户端直接修改了库存数据检查逻辑是否在服务端执行改为 RPC 增量同步10. 最佳实践与扩展建议库存系统是典型的“前期多花一小时设计后期少花三天重构”的功能模块。堆叠功能本身不算复杂但要把边界情况处理干净值得积累一整套可复用规范。所有数量变更统一走 InventoryComponent不要在 UI 层直接操作 Slot.Count。每个公开函数返回值设计成“实际成功/实际数量”让调用方可以做 UI 反馈。数据表里的堆叠配置要和运行时数量分离不要把 MaxStackSize 和 Count 混在一个字段里。当有多个同类格子时AddItem 必须“先填旧格再开新格”否则拾取体验会很差。UI 刷新走委托事件驱动不建议使用 Tick 逐帧扫描背包数据。拖拽操作使用自定义 UDragDropOperation 子类把 SourceIndex、DragCount、InventoryComponent 打包传过去。拆分和合并的边界要清晰合并可以到非空格拆分只能到空格。批量消耗材料时使用“先整体检查再逐项扣除”的两阶段模式。如果做联机数量变更只接受服务端增量指令不接受客户端最终值。下一步可以继续扩展的方向包括装备耐久度与堆叠的关联处理、不同容器间批量转运时的自动堆叠、右键菜单常用操作使用、拆分、丢弃、以及库存与商店购买/售卖的数量联动。堆叠做好之后这些系统都会非常顺畅地接进来。如果你正在搭建自己的 UE5 库存系统建议先把本讲的数据结构和 MergeStack/SplitStack 跑通再加上 UI 拖拽层。这套组合是后续所有库存扩展的地基。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →