Unreal Engine Mutable 生产级换装系统

UE Mutable 换装系统(四):蓝图驱动的换装流程实现

实现 BP_OutfitComponent 的入口函数、内部状态机、Snapshot 生成、Mutable 参数写入、异步回调、pending 合并和失败回滚。

总览

第三篇建立了装备数据合同:一件装备通过稳定 ItemID 找到数据资产,再由数据资产提供 Slot 占用、兼容规则和 Mutable 参数。本篇开始实现运行时核心组件 BP_OutfitComponent

生产级换装蓝图的重点不是节点越多越完整,而是入口少、状态集中、异步清楚、失败能回滚。UI 不应该直接操作 CustomizableObjectInstance,背包系统也不应该直接写 Mutable 参数。所有换装请求都进入 BP_OutfitComponent,由它统一校验、更新状态、应用 Mutable、广播结果。

UE Mutable 换装系统(四):蓝图驱动的换装流程实现 配图
蓝图执行蓝图:EquipItem 先生成完整 Snapshot,再写入 Mutable 并等待异步回调。

本篇目标

  1. 实现一个角色组件 BP_OutfitComponent,作为运行时换装状态的唯一入口。
  2. 提供 EquipItemUnequipSlotApplyOutfitSnapshot 等蓝图接口。
  3. 将装备 ItemID 翻译成完整 Outfit 状态,再写入 Mutable 参数。
  4. 正确处理 Mutable 异步更新,不在点击结束时假设模型已经更新。
  5. 支持失败回滚、默认装备回退和最后一次请求合并。
  6. 为第五篇 UI 预览、确认、取消提供稳定接口。

非目标

  1. 本篇不实现衣柜 UI,只提供 UI 可调用的组件接口。
  2. 本篇不实现存档写入,只产出可保存的 Outfit Snapshot。
  3. 本篇不实现多人同步,网络复制在第八篇处理。
  4. 本篇不做大规模性能优化,只建立基础异步和请求合并。
  5. 本篇不修改 Mutable 图,只调用既有参数接口。

组件结构

BP_OutfitComponent 挂在角色 Actor 上,建议只让它直接接触 Mutable Instance。角色、UI、存档系统都通过组件方法交互。

Character
  BP_OutfitComponent
    CurrentOutfit
    PreviewOutfit
    PendingRequest
    CustomizableObjectInstance
    SkeletalMeshComponent / InstanceUsage

组件职责:

  1. 保存当前正式穿搭状态。
  2. 接收装备、卸下、应用快照请求。
  3. 从数据层解析 ItemID。
  4. 校验 Slot、角色标签、冲突规则。
  5. 更新 Outfit 状态。
  6. 将完整状态写入 Mutable 参数。
  7. 等待 Mutable 异步完成。
  8. 成功后广播结果,失败后回滚。

运行时数据结构

正式状态不要保存 DataAsset 指针,建议保存稳定 ID 和必要覆盖值。DataAsset 可以随用随查或缓存,但状态本身要能被存档和网络复用。

USTRUCT(BlueprintType)
struct FOutfitSlotState
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite)
    FName SlotId;

    UPROPERTY(BlueprintReadWrite)
    FName ItemId;

    UPROPERTY(BlueprintReadWrite)
    bool bLocked = false;
};

USTRUCT(BlueprintType)
struct FOutfitSnapshot
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite)
    int32 SchemaVersion = 1;

    UPROPERTY(BlueprintReadWrite)
    TMap<FName, FOutfitSlotState> Slots;

    UPROPERTY(BlueprintReadWrite)
    TMap<FName, FLinearColor> ColorOverrides;

    UPROPERTY(BlueprintReadWrite)
    int32 Revision = 0;
};

UENUM(BlueprintType)
enum class EOutfitRequestType : uint8
{
    EquipItem,
    UnequipSlot,
    ApplySnapshot,
    RestoreSnapshot
};

USTRUCT(BlueprintType)
struct FOutfitRequest
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite)
    EOutfitRequestType Type;

    UPROPERTY(BlueprintReadWrite)
    FName ItemId;

    UPROPERTY(BlueprintReadWrite)
    FName SlotId;

    UPROPERTY(BlueprintReadWrite)
    FOutfitSnapshot Snapshot;

    UPROPERTY(BlueprintReadWrite)
    bool bPreview = false;

    UPROPERTY(BlueprintReadWrite)
    int32 RequestId = 0;
};

Revision 用来处理异步回调乱序。每次发起 Mutable 更新时递增 Revision,回调回来时只接受当前 Revision 对应的结果。

蓝图变量清单

BP_OutfitComponent 建议包含以下变量:

变量 类型 说明
OutfitCatalog PDA_OutfitCatalog 装备数据索引
SlotConfig PDA_OutfitSlotConfig 默认装备和 Slot 规则
TargetCustomizableInstance CustomizableObjectInstance 引用 Mutable 参数实例
TargetSkeletalMeshComponent SkeletalMeshComponent 引用 接收生成结果的组件
CurrentOutfit FOutfitSnapshot 正式穿搭
PreviewOutfit FOutfitSnapshot 预览穿搭
LastStableOutfit FOutfitSnapshot 上一次成功应用的状态
ApplyState EOutfitApplyState 当前换装状态
PendingRequest FOutfitRequest Applying 中收到的最后一个请求
bHasPendingRequest bool 是否有待处理请求
CurrentRevision int32 当前状态版本
ActiveRequestId int32 当前异步请求 ID
bAllowQueueLatestOnly bool Applying 时是否只保留最后一次请求
bUseDefaultFallback bool 失败时是否回退默认装备
CharacterTags GameplayTagContainer 角色体型、职业、状态标签
LastError FOutfitError 最近一次失败原因

蓝图函数清单

对外接口:

函数 输入 输出 说明
InitializeOutfitComponent InitialSnapshot Success 初始化组件并应用初始穿搭
EquipItem ItemId, bPreview RequestId 装备一件物品
UnequipSlot SlotId, bPreview RequestId 卸下 Slot,实际应用默认装备
ApplyOutfitSnapshot Snapshot, bPreview RequestId 应用完整快照
CommitPreview Success 将预览状态提交为正式状态
CancelPreview Success 恢复到正式状态
GetCurrentOutfit Snapshot 获取正式状态
GetPreviewOutfit Snapshot 获取预览状态
CanEquipItem ItemId Result UI 查询可装备性

内部函数:

函数 输入 输出 说明
StartRequest FOutfitRequest 统一请求入口
ResolveItemData ItemId ItemData, Result 从 Catalog 查装备
ValidateEquipRequest ItemData, BaseSnapshot Result 校验角色标签、Slot、冲突
BuildNextSnapshotForEquip ItemData, BaseSnapshot NextSnapshot 计算装备后的完整状态
BuildNextSnapshotForUnequip SlotId, BaseSnapshot NextSnapshot 卸下时替换默认装备
ApplySnapshotInternal Snapshot, bPreview 应用完整状态
WriteSnapshotToMutable Snapshot Result 写入 Mutable 参数
SetMutableParam FOutfitMutableParam Result 按类型设置单个参数
RequestMutableUpdate Revision 发起 Mutable 异步更新
HandleMutableUpdated Revision Mutable 成功回调
HandleMutableFailed Revision, Reason Mutable 失败处理
RollbackToLastStable Reason 回滚到上次成功状态
ConsumePendingRequest 当前请求结束后处理最后一次请求

事件与委托清单

委托 参数 触发时机
OnOutfitRequestStarted RequestId, bPreview 请求开始
OnOutfitDataResolved RequestId, ItemId 数据解析完成
OnOutfitApplying RequestId, Snapshot 开始写 Mutable
OnOutfitApplied RequestId, Snapshot, bPreview Mutable 更新成功
OnOutfitApplyFailed RequestId, ErrorCode, Message 请求失败
OnOutfitRolledBack Snapshot, Reason 回滚完成
OnOutfitStateChanged Snapshot, bPreview 状态变更后
OnOutfitBusyChanged bBusy 进入或离开 Applying
OnPendingOutfitRequestChanged bHasPending 合并请求变化

UI 只订阅这些委托,不直接读取 Mutable Instance。衣柜界面可以在 OnOutfitBusyChanged(true) 时禁用确认按钮,在 OnOutfitApplied 后再允许保存。

主流程:EquipItem

EquipItem(ItemId, bPreview)
 -> Build FOutfitRequest
 -> StartRequest
 -> If ApplyState is ApplyingMutable:
      Save as PendingRequest
      Return RequestId
 -> Set ApplyState = ResolvingData
 -> ResolveItemData(ItemId)
 -> ValidateEquipRequest
 -> BuildNextSnapshotForEquip
 -> ApplySnapshotInternal
 -> WriteSnapshotToMutable
 -> RequestMutableUpdate
 -> Wait Mutable Updated
 -> On Success: Commit runtime state, broadcast OnOutfitApplied
 -> On Failed: RollbackToLastStable, broadcast OnOutfitApplyFailed
 -> ConsumePendingRequest if exists

这里最重要的是:EquipItem 不直接写一个参数,也不只改一个 Slot。它先生成完整 FOutfitSnapshot,再把完整状态写入 Mutable。这样可以避免卸下连体衣后 Lower Slot 没恢复、头盔换掉后发型参数残留这类问题。

生成下一份 Snapshot

BuildNextSnapshotForEquip 是换装逻辑的核心。它不碰 Mutable,只做状态计算。

步骤:

  1. 复制一份 BaseSnapshotNextSnapshot
  2. 读取装备的所有 SlotClaims
  3. 找到主 Slot,把主 Slot 的 ItemId 设置为当前装备。
  4. 对额外占用 Slot,清除原装备或标记为被主装备占用。
  5. 根据 BlockedSlots 卸下冲突装备。
  6. 对被清空的 Slot 写入默认装备。
  7. 递增 Revision
  8. 返回完整 NextSnapshot

推荐不要让一个 Slot 处于“空”。即使玩家视觉上没有戴头盔,Head Slot 也可以是 eq_head_none。空值越少,存档、同步和 Mutable 参数默认值越稳定。

ApplySnapshotInternal

ApplySnapshotInternal 接收的是完整状态。它不关心状态来自点击装备、取消预览、读档恢复还是网络同步。

ApplySnapshotInternal(Snapshot, bPreview)
 -> Save PreviousSnapshot = Active outfit
 -> Set ApplyState = ApplyingMutable
 -> ActiveRequestId = RequestId
 -> CurrentRevision = Snapshot.Revision
 -> WriteSnapshotToMutable(Snapshot)
 -> Request Mutable Async Update

如果 bPreview=true,成功后写入 PreviewOutfit。如果 bPreview=false,成功后写入 CurrentOutfitLastStableOutfit。失败时不应该更新正式状态。

写入 Mutable 参数

写参数前建议先写默认装备,再写当前装备。更简单的方案是保证 Snapshot 中每个 Slot 都已经有默认 ItemID,然后逐 Slot 写入装备参数。

WriteSnapshotToMutable(Snapshot)
 -> For each Slot in SlotConfig.SlotOrder
      ItemId = Snapshot.Slots[Slot].ItemId
      ItemData = GetItemDataById(ItemId)
      For each Param in ItemData.MutableParams
          SetMutableParam(Param)
 -> Apply hidden body region params
 -> Apply color overrides
 -> Return Success

SetMutableParam 根据 EOutfitParamType 分发:

ParamType Mutable 操作
Enum 设置 Enum 选项
Bool 设置 Bool
Int 设置 Int
Float 设置 Float
Color 设置 Color
Texture 设置 Texture 或资源参数

蓝图里不要为每个 Slot 拉一套重复节点。应该是 ForEach Slot -> Get ItemData -> ForEach MutableParams -> SetMutableParam。这样新增 Slot 时只改数据,不改流程图。

Mutable 异步完成

Mutable 更新是异步的。按钮点击结束不代表 Skeletal Mesh 已经更新。组件至少要区分这些阶段:

状态 含义
Idle 可以接收并立即执行请求
ResolvingData 正在解析数据或加载资产
ApplyingMutable 已写参数,正在等待 Mutable 更新
Failed 最近一次请求失败,通常马上回滚到 Idle

成功回调处理:

HandleMutableUpdated(Revision)
 -> If Revision != CurrentRevision: ignore stale callback
 -> If request is preview:
      PreviewOutfit = AppliedSnapshot
    Else:
      CurrentOutfit = AppliedSnapshot
      LastStableOutfit = AppliedSnapshot
 -> ApplyState = Idle
 -> Broadcast OnOutfitApplied
 -> ConsumePendingRequest

失败回调处理:

HandleMutableFailed(Revision, Reason)
 -> If Revision != CurrentRevision: ignore stale callback
 -> RollbackToLastStable(Reason)
 -> ApplyState = Idle
 -> Broadcast OnOutfitApplyFailed
 -> ConsumePendingRequest

如果项目当前拿不到明确失败回调,也要通过超时保护兜底。例如 5 秒内没有收到更新完成事件,就视为失败并回滚,同时记录日志。

Applying 中的新请求

玩家会快速点击装备。不能每次点击都把 Mutable 更新塞进队列。推荐先实现“只保留最后一次请求”:

If ApplyState == ApplyingMutable:
    PendingRequest = NewRequest
    bHasPendingRequest = true
    Broadcast OnPendingOutfitRequestChanged(true)
    return

当前请求完成后:

ConsumePendingRequest
 -> If bHasPendingRequest:
      LocalRequest = PendingRequest
      Clear PendingRequest
      StartRequest(LocalRequest)

这能避免队列爆炸,也符合 UI 预览直觉:玩家连续点了十件衣服,最终只关心最后一件。

失败路径和回退

失败点 处理
Catalog 未加载 阻止请求,广播 DataNotReady
ItemID 不存在 查迁移或 fallback;失败则广播 ItemNotFound
ItemData 未加载 异步加载,成功后重试当前请求
装备被禁用 阻止请求,UI 显示不可用
角色标签不满足 阻止请求,不改状态
Slot 被锁定 阻止请求,例如剧情装备或战斗锁定
默认装备缺失 使用上一次稳定状态,记录严重错误
Mutable 参数写入失败 不发起更新,立即回滚
Mutable 更新超时 回滚 LastStableOutfit
异步回调过期 忽略,不广播成功
回滚也失败 应用全局默认套装,并隐藏确认入口

回退优先级建议如下:

Requested Item
 -> ItemData.FallbackItemId
 -> SlotConfig.DefaultItemsBySlot[Slot]
 -> LastStableOutfit
 -> Global Default Outfit

线上不要停在半应用状态。只要 Mutable 没成功,正式 CurrentOutfit 就不能被提交。

预览与正式状态

第四篇可以先实现 bPreview,但不展开 UI。状态规则要提前定好:

操作 写入状态
EquipItem(ItemId, true) 成功后写 PreviewOutfit
EquipItem(ItemId, false) 成功后写 CurrentOutfitLastStableOutfit
CommitPreview() PreviewOutfit 作为正式状态重新应用或直接提交
CancelPreview() 应用 CurrentOutfit,清空 PreviewOutfit

如果预览使用独立 Preview Actor,那么 Preview Actor 上也可以有自己的 BP_OutfitComponent。主角组件不需要知道 UI 怎么展示,只保证接口一致。

验收标准

  1. UI 或测试蓝图调用 EquipItem(ItemId, false) 后,角色最终显示对应装备。
  2. CurrentOutfit 只在 Mutable 更新成功后改变,失败时保持旧值。
  3. 调用 EquipItem(ItemId, true) 不会修改正式 CurrentOutfit
  4. 连续快速调用多次 EquipItem 时,只应用最后一次 pending 请求。
  5. 装备不存在、条件不满足、Slot 冲突时不会写入半状态。
  6. 卸下 Slot 时会应用默认装备,而不是留下空参数。
  7. 连体衣、头盔隐藏发型等多 Slot 占用能正确替换冲突装备。
  8. Mutable 回调乱序时,旧 Revision 不会覆盖新状态。
  9. Mutable 更新超时时会回滚到 LastStableOutfit
  10. 所有成功和失败都能通过委托通知 UI,UI 不需要访问 Mutable Instance。
  11. ApplyOutfitSnapshot 可以用于初始化、读档、取消预览和后续网络同步。
  12. 蓝图中不存在“某个按钮直接 Set Mutable 参数”的旁路。

本篇结论

蓝图驱动的换装流程应该围绕 BP_OutfitComponent 收束:外部只提交 ItemID 或 Snapshot,组件内部完成数据解析、状态计算、Mutable 参数写入、异步等待和失败回滚。这样系统不会因为 UI 复杂、装备数量增长或 Mutable 图调整而失控。

参考资料