总览
第三篇建立了装备数据合同:一件装备通过稳定 ItemID 找到数据资产,再由数据资产提供 Slot 占用、兼容规则和 Mutable 参数。本篇开始实现运行时核心组件 BP_OutfitComponent。
生产级换装蓝图的重点不是节点越多越完整,而是入口少、状态集中、异步清楚、失败能回滚。UI 不应该直接操作 CustomizableObjectInstance,背包系统也不应该直接写 Mutable 参数。所有换装请求都进入 BP_OutfitComponent,由它统一校验、更新状态、应用 Mutable、广播结果。
本篇目标
- 实现一个角色组件
BP_OutfitComponent,作为运行时换装状态的唯一入口。 - 提供
EquipItem、UnequipSlot、ApplyOutfitSnapshot等蓝图接口。 - 将装备
ItemID翻译成完整 Outfit 状态,再写入 Mutable 参数。 - 正确处理 Mutable 异步更新,不在点击结束时假设模型已经更新。
- 支持失败回滚、默认装备回退和最后一次请求合并。
- 为第五篇 UI 预览、确认、取消提供稳定接口。
非目标
- 本篇不实现衣柜 UI,只提供 UI 可调用的组件接口。
- 本篇不实现存档写入,只产出可保存的 Outfit Snapshot。
- 本篇不实现多人同步,网络复制在第八篇处理。
- 本篇不做大规模性能优化,只建立基础异步和请求合并。
- 本篇不修改 Mutable 图,只调用既有参数接口。
组件结构
BP_OutfitComponent 挂在角色 Actor 上,建议只让它直接接触 Mutable Instance。角色、UI、存档系统都通过组件方法交互。
Character
BP_OutfitComponent
CurrentOutfit
PreviewOutfit
PendingRequest
CustomizableObjectInstance
SkeletalMeshComponent / InstanceUsage
组件职责:
- 保存当前正式穿搭状态。
- 接收装备、卸下、应用快照请求。
- 从数据层解析 ItemID。
- 校验 Slot、角色标签、冲突规则。
- 更新 Outfit 状态。
- 将完整状态写入 Mutable 参数。
- 等待 Mutable 异步完成。
- 成功后广播结果,失败后回滚。
运行时数据结构
正式状态不要保存 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,只做状态计算。
步骤:
- 复制一份
BaseSnapshot为NextSnapshot。 - 读取装备的所有
SlotClaims。 - 找到主 Slot,把主 Slot 的 ItemId 设置为当前装备。
- 对额外占用 Slot,清除原装备或标记为被主装备占用。
- 根据
BlockedSlots卸下冲突装备。 - 对被清空的 Slot 写入默认装备。
- 递增
Revision。 - 返回完整
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,成功后写入 CurrentOutfit 和 LastStableOutfit。失败时不应该更新正式状态。
写入 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) |
成功后写 CurrentOutfit 和 LastStableOutfit |
CommitPreview() |
把 PreviewOutfit 作为正式状态重新应用或直接提交 |
CancelPreview() |
应用 CurrentOutfit,清空 PreviewOutfit |
如果预览使用独立 Preview Actor,那么 Preview Actor 上也可以有自己的 BP_OutfitComponent。主角组件不需要知道 UI 怎么展示,只保证接口一致。
验收标准
- UI 或测试蓝图调用
EquipItem(ItemId, false)后,角色最终显示对应装备。 CurrentOutfit只在 Mutable 更新成功后改变,失败时保持旧值。- 调用
EquipItem(ItemId, true)不会修改正式CurrentOutfit。 - 连续快速调用多次
EquipItem时,只应用最后一次 pending 请求。 - 装备不存在、条件不满足、Slot 冲突时不会写入半状态。
- 卸下 Slot 时会应用默认装备,而不是留下空参数。
- 连体衣、头盔隐藏发型等多 Slot 占用能正确替换冲突装备。
- Mutable 回调乱序时,旧 Revision 不会覆盖新状态。
- Mutable 更新超时时会回滚到
LastStableOutfit。 - 所有成功和失败都能通过委托通知 UI,UI 不需要访问 Mutable Instance。
ApplyOutfitSnapshot可以用于初始化、读档、取消预览和后续网络同步。- 蓝图中不存在“某个按钮直接 Set Mutable 参数”的旁路。
本篇结论
蓝图驱动的换装流程应该围绕 BP_OutfitComponent 收束:外部只提交 ItemID 或 Snapshot,组件内部完成数据解析、状态计算、Mutable 参数写入、异步等待和失败回滚。这样系统不会因为 UI 复杂、装备数量增长或 Mutable 图调整而失控。