总览
这一系列不再把 Mutable 当成“点按钮换一件衣服”的演示,而是把它放进一个真实项目会遇到的上下文里:角色有背包和商城,装备有职业、体型、性别和资源版本限制,UI 要能预览和取消,存档要能迁移,联网要由服务端裁决,打包后还要能追踪软引用和失败回退。
本篇先定系统规划。Mutable 负责根据 CustomizableObject 和参数生成标准 UE 资源;换装系统负责把业务里的 ItemID、Slot、预览状态、保存快照、网络复制和错误处理组织起来。两者边界清楚,后面的蓝图实现才不会越写越乱。
本篇目标
- 明确生产级换装系统的目标、非目标和模块边界。
- 定义系统核心状态:正式穿搭、预览穿搭、待应用请求、上一次稳定状态。
- 规划
BP_OutfitComponent、数据资产、UI、存档、网络和 Mutable Runtime Wrapper 的职责。 - 给出第一版可落地的数据结构、蓝图函数、事件委托和主流程。
- 从第一篇开始建立失败路径和验收标准,而不是到上线前才补救。
非目标
- 本篇不展开 Mutable 图内部节点制作,资源和参数规划放到第二篇。
- 本篇不写完整衣柜 UI,预览和交互细节放到第五篇。
- 本篇不实现存档、异步队列和多人同步,分别放到第六、七、八篇。
- 本篇不讨论玩家是否拥有装备。所有权属于背包、商城或账号系统,换装系统只接收“这件外观是否允许应用”的校验结果。
- 本篇不把所有逻辑强行放进蓝图。蓝图需要清晰可读;数据校验、缓存、网络和批量构建在生产项目里更适合逐步下沉到 C++。
生产需求拆解
一个可上线的换装系统至少要覆盖这些场景:
| 场景 | 用户行为 | 系统要求 |
|---|---|---|
| 打开衣柜 | 读取当前角色外观 | UI 从组件拿正式快照,不直接读 Mutable 参数 |
| 点击装备 | 先预览,不立即保存 | 生成预览快照,异步应用 Mutable,支持取消 |
| 点击确认 | 提交正式外观 | 正式状态只在 Mutable 成功后更新 |
| 取消预览 | 恢复进入衣柜前状态 | 回放 LastStableOutfit,清空预览状态 |
| 进入游戏 | 从存档或服务器恢复 | 通过 ApplyOutfitSnapshot 走同一套应用流程 |
| 装备下架 | 老存档里仍有旧 ItemID |
查迁移表,失败则回退默认装备 |
| 参数改名 | Mutable 图重构 | 数据层负责映射,存档和网络不保存 Mutable 选项名 |
| 资源缺失 | 打包、DLC 或热更问题 | 保留旧外观或默认外观,并记录结构化错误 |
| 快速连点 | 玩家连续点十件衣服 | 合并请求,只应用最后一次有效预览 |
| 多人同步 | 其他客户端看到换装 | 网络只复制外观快照,不复制生成后的 Mesh |
这里的关键是:系统的核心不是 SetEnumParameterSelectedOption,而是“谁拥有状态、谁校验请求、谁触发表现、谁能回退”。
模块边界
生产级系统建议按下面的边界拆分:
| 模块 | 主要职责 | 不应该做 |
|---|---|---|
WBP_OutfitRoom |
展示装备、发起预览、确认、取消 | 不直接写 Mutable 参数,不保存正式穿搭 |
| UI Mediator / Controller | 把 UI 意图转成组件请求,处理提示 | 不计算 Slot 冲突,不维护装备数据 |
BP_OutfitComponent |
角色外观状态唯一入口,校验、生成快照、应用 Mutable | 不判断玩家是否拥有商品,不扫描全项目资源 |
PDA_OutfitItem |
单件装备数据:Slot、Mutable 参数、标签、回退装备 | 不保存玩家状态 |
PDA_OutfitCatalog |
ItemID -> OutfitItemData 索引 |
不承担运行时请求队列 |
| Mutable Runtime Wrapper | 封装参数写入、更新、回调、失败码 | 不理解背包、UI 和存档业务 |
| SaveGame / Profile | 保存稳定快照和版本 | 不保存 CustomizableObjectInstance 或生成 Mesh |
| Server Appearance Service | 校验请求、保存权威快照、复制外观状态 | 不生成客户端最终 SkeletalMesh |
| Appearance Subsystem | 异步加载、队列、缓存、预算、预热 | 不绑定具体 UI 页面 |
第一版可以先让 BP_OutfitComponent 直接完成大部分事情,但边界要先画出来。后续性能和多人压力上来时,才有空间把队列、缓存、校验迁到 Subsystem 或 C++。
状态模型
换装系统至少需要四份状态:
| 状态 | 说明 | 谁写入 |
|---|---|---|
CurrentOutfit |
当前正式外观,存档和网络以它为准 | Mutable 成功应用后 |
PreviewOutfit |
衣柜或商城中的临时预览 | 预览请求成功后 |
LastStableOutfit |
上一次完整成功应用的外观 | 每次正式应用成功后 |
PendingRequest |
Applying 中收到的最新请求 | 请求队列或组件入口 |
不要把“正在点选的 UI 项”“Mutable 里当前参数”“服务器权威状态”混成一个变量。它们的生命周期不同,失败处理也不同。
推荐基础结构如下:
UENUM(BlueprintType)
enum class EOutfitApplyState : uint8
{
Idle,
ResolvingData,
LoadingAssets,
ApplyingMutable,
Failed
};
USTRUCT(BlueprintType)
struct FOutfitSlotState
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName SlotId;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool bLocked = false;
};
USTRUCT(BlueprintType)
struct FOutfitSnapshot
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 SchemaVersion = 1;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TMap<FName, FOutfitSlotState> Slots;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TMap<FName, FLinearColor> ColorOverrides;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 Revision = 0;
};
USTRUCT(BlueprintType)
struct FOutfitApplyResult
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
bool bSuccess = false;
UPROPERTY(BlueprintReadOnly)
FName ErrorCode;
UPROPERTY(BlueprintReadOnly)
FText Message;
UPROPERTY(BlueprintReadOnly)
FOutfitSnapshot AppliedSnapshot;
};
Revision 是异步换装的安全绳。Mutable 更新、资源加载、网络 OnRep 都可能乱序返回,回调时必须先确认 Revision 或 RequestId 仍然有效。
蓝图组件规划
BP_OutfitComponent 是整个系统的运行时核心。它挂在角色 Actor 上,对外只暴露意图级接口,不暴露 Mutable 内部参数。
关键变量
| 变量 | 类型 | 说明 |
|---|---|---|
OutfitCatalog |
PDA_OutfitCatalog |
装备数据索引 |
SlotConfig |
PDA_OutfitSlotConfig |
Slot 顺序、默认装备、互斥关系 |
TargetCustomizableInstance |
CustomizableObjectInstance |
Mutable 运行时实例 |
TargetSkeletalMeshComponent |
SkeletalMeshComponent |
应用生成结果的 Mesh 组件 |
CurrentOutfit |
FOutfitSnapshot |
正式外观 |
PreviewOutfit |
FOutfitSnapshot |
临时预览外观 |
LastStableOutfit |
FOutfitSnapshot |
失败回退状态 |
ApplyState |
EOutfitApplyState |
当前流程状态 |
ActiveRequestId |
FGuid |
当前异步请求 |
CurrentRevision |
int32 |
当前外观版本 |
PendingRequest |
FOutfitRequest |
Applying 中保留的最新请求 |
CharacterTags |
GameplayTagContainer |
性别、体型、职业、阵营等角色条件 |
FallbackPolicy |
EOutfitFallbackPolicy |
失败时回退旧外观、默认装备或基础身体 |
对外函数
| 函数 | 输入 | 输出 | 说明 |
|---|---|---|---|
InitializeOutfitComponent |
InitialSnapshot |
Success |
初始化并应用初始外观 |
EquipItem |
ItemId, bPreview |
RequestId |
装备一件物品 |
UnequipSlot |
SlotId, bPreview |
RequestId |
卸下 Slot,实际应用默认装备 |
ApplyOutfitSnapshot |
Snapshot, bPreview |
RequestId |
应用完整快照 |
CommitPreview |
无 | Result |
将预览状态提交为正式状态 |
CancelPreview |
无 | Result |
恢复 CurrentOutfit |
CanEquipItem |
ItemId |
Result |
UI 查询可装备性 |
GetCurrentOutfit |
无 | Snapshot |
返回正式状态 |
GetOutfitBusyState |
无 | State |
UI 控制按钮 loading |
内部函数
| 函数 | 说明 |
|---|---|
ResolveItemData |
从 Catalog 查询并加载装备数据 |
ValidateEquipRequest |
校验角色标签、Slot 锁定、互斥关系 |
BuildNextSnapshotForEquip |
根据当前快照生成下一份完整快照 |
WriteSnapshotToMutable |
把完整快照翻译成 Mutable 参数 |
RequestMutableUpdate |
发起 Mutable 异步更新 |
HandleMutableUpdated |
成功回调,提交状态并广播 |
HandleMutableFailed |
失败回调,回滚并广播错误 |
ConsumePendingRequest |
当前请求结束后处理最后一次 pending |
ApplyFallbackOutfit |
按策略应用回退外观 |
事件委托
| 委托 | 参数 | 触发时机 |
|---|---|---|
OnOutfitRequestStarted |
RequestId, bPreview |
请求开始 |
OnOutfitApplying |
RequestId, Snapshot |
开始写入 Mutable |
OnOutfitApplied |
RequestId, Snapshot, bPreview |
Mutable 更新成功 |
OnOutfitApplyFailed |
RequestId, ErrorCode, Message |
请求失败 |
OnOutfitRolledBack |
Snapshot, Reason |
回滚完成 |
OnOutfitBusyChanged |
bBusy |
进入或离开更新状态 |
OnOutfitStateChanged |
Snapshot, bPreview |
外观状态变化 |
UI 只订阅事件,不直接读取或写入 Mutable。这样衣柜 UI、商城试穿 UI、角色选择界面可以共用同一组件接口。
主流程蓝图
生产链路建议用完整 Snapshot 驱动,而不是只改某个 Slot 参数。
UI Click Item
-> OutfitComponent.EquipItem(ItemId, bPreview=true)
-> Resolve Item Data
-> Validate Character Tags and Slot Rules
-> Build Next OutfitSnapshot
-> Write Full Snapshot to Mutable Parameters
-> Request Mutable Async Update
-> On Success: update PreviewOutfit and notify UI
-> On Fail: rollback to CurrentOutfit or LastStableOutfit
UI Confirm
-> CommitPreview
-> ApplyOutfitSnapshot(PreviewOutfit, bPreview=false)
-> On Success: update CurrentOutfit and LastStableOutfit
-> Save or submit to server
UI Cancel
-> CancelPreview
-> ApplyOutfitSnapshot(CurrentOutfit, bPreview=true or false)
-> Clear PreviewOutfit
“完整 Snapshot” 很重要。比如玩家装备连体衣时,需要同时卸下裤子;取消连体衣后,下装要回到默认裤子;戴全脸头盔时发型和眼镜要被隐藏。只写一个 Slot_Upper = Suit_A 会留下很多残留参数。
请求合并策略
玩家会连续点击,UI 也可能拖动颜色滑条。第一版不要把所有请求都排队,先实现“同一角色只保留最后一次请求”:
If ApplyState == ApplyingMutable:
PendingRequest = NewRequest
bHasPendingRequest = true
return RequestId
When active request finishes:
If bHasPendingRequest:
StartRequest(PendingRequest)
颜色滑条可以更进一步:拖动中只改材质预览参数,松手后再触发 Mutable 生成。否则一个 Slider 就能把角色生成队列打满。
失败路径和回退
生产级系统必须从第一天就定义失败路径:
| 失败点 | 处理策略 |
|---|---|
| Catalog 未加载 | 阻止请求,UI 显示数据未就绪 |
ItemID 不存在 |
查迁移表;失败则使用 Slot 默认装备 |
| 装备被禁用 | 拒绝请求,不改状态 |
| 角色标签不满足 | UI 显示不可装备,组件不写 Mutable |
| Slot 被锁定 | 拒绝,例如剧情装备或战斗锁定 |
| 默认装备缺失 | 回退 LastStableOutfit,记录严重错误 |
| Mutable 参数不存在 | 开发环境报错;线上回退整件装备 |
| Mutable 更新超时 | 取消请求,回滚上一次稳定外观 |
| 旧异步回调返回 | 根据 RequestId / Revision 丢弃 |
| Mesh 组件已销毁 | 丢弃结果,不广播成功 |
回退顺序建议:
Requested Item
-> ItemData.FallbackItemId
-> SlotConfig.DefaultItemsBySlot[Slot]
-> LastStableOutfit
-> Global Default Outfit
-> Base Body Mesh
线上最忌讳的是半成功:UI 显示穿上了,存档也保存了,但 Mutable 实际失败。正式状态只能在生成成功后提交。
最小可用版本
第一阶段不要追求一次做完所有功能。建议先实现:
| 范围 | 选择 |
|---|---|
| Slot | Upper、Lower、Shoes、Head |
| 参数 | 每个 Slot 一个 Enum,一个主色 Color |
| 数据 | 每件装备一个稳定 ItemID 和 FallbackItemId |
| UI | 列表、预览、确认、取消、忙碌状态 |
| 保存 | 只保存 ItemID、颜色覆盖、SchemaVersion |
| 异步 | 单角色只保留最后一次 pending 请求 |
| 回退 | Slot 默认装备 + 上一次稳定状态 |
这版足够验证核心闭环:数据能查到,状态能生成,Mutable 能应用,失败能回退,UI 能感知结果。等闭环稳定,再加多体型、Groom、布料、DLC、远端玩家降级和服务端权威。
验收标准
- UI 无法直接修改 Mutable 参数,所有换装请求都进入
BP_OutfitComponent。 CurrentOutfit、PreviewOutfit、LastStableOutfit生命周期清楚。- 任意装备请求都会先解析数据、校验规则,再写 Mutable。
- 正式状态只在 Mutable 更新成功后提交。
- 连续快速点击装备时,最终只应用最后一次有效请求。
- 失败时不会留下半套参数,能回到默认装备或上一次稳定外观。
- 初始化、预览、确认、取消、读档和未来网络 OnRep 都复用
ApplyOutfitSnapshot。 - 每个失败都有
ErrorCode、RequestId、ItemID和 fallback 结果日志。 - 第一版能在打包环境中完成一次完整 Smoke Test:进入衣柜、预览、确认、保存、重启恢复。
本篇结论
Mutable 是表现生成器,不是完整换装系统。生产级方案要先把系统边界、状态模型、蓝图接口、失败回退和验收标准定住,再去接资源和 UI。下一篇进入资源规划:角色基准模型、Mutable 子对象、参数命名、身体裁剪和材质通道应该怎么设计。