这次我们直接进入第 5 部分最关心的堆叠问题。前几讲我们已经把库存系统的数据层、UI 层和交互框架搭起来了,这一讲的核心就是把“堆叠”这个功能补完,让同一类物品能合并、拆分、自动归类,而不是每个格子傻乎乎地只装 1 个。堆叠看起来就是一个数字加减,但放到可扩展的库存架构里,它牵扯到物品数据结构设计、批量插入策略、拖拽拆分、UI 刷新时机、存档迁移兼容等一系列问题。如果你直接把 StackSize 写成 int 然后到处 +=,后面做存档、做批量合成、做服务器同步时一定会返工。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 引擎版本 | 虚幻引擎 5.x(UE5) |
| 前置知识 | 数据结构、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) TSoftObjectPtr<UTexture2D> 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) TSoftObjectPtr<UTexture2D> 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 拆到 TargetSlot(TargetSlot 必须为空) UFUNCTION(BlueprintCallable, Category = "Inventory") bool SplitStack(int32 SourceSlot, int32 TargetSlot, int32 Count); protected: UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly) TArray<FInventorySlotData> Slots; };重点说一下 AddItem 的返回值设计:返回“实际放入的数量”。
为什么不是 bool?因为当库存空间不足时,你往往需要知道“放进去几个、还剩几个”,也就是拆包逻辑。比如玩家拾取 50 支箭,但每个格子最多放 99,而最后一格只够放 30,AddItem 就应返回 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 来实现。核心思想是:在拖拽开始时携带数据生成一个 UDragDropOperation,Drop 时解析数据并调用库存组件。
这里给出一段拖拽操作数据的示例:
UCLASS() class UInventoryDragDropOperation : public UDragDropOperation { GENERATED_BODY() public: UPROPERTY() int32 SourceSlotIndex = -1; UPROPERTY() int32 DragCount = 1; UPROPERTY() UInventoryComponent* InventoryComponent = nullptr; };拖拽发起时,在 NativeOnDragDetected 中创建并初始化这个 Operation:
void UInventorySlotWidget::NativeOnDragDetected( const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) { if (bIsEmpty) return; UInventoryDragDropOperation* DragOp = NewObject<UInventoryDragDropOperation>(); 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 分组 TMap<FName, TArray<int32>> 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; TArray<int32>& 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 之外额外封装一个 TryConsumeItems:
bool UInventoryComponent::TryConsumeItems(const TMap<FName, 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 拖拽层。这套组合是后续所有库存扩展的地基。