试用角色
序章/卡池的试用角色全链路:服务器用实体身份字段 trialConfigId 标记试用实体, 客户端按 TrialRole → TrialSuit → TrialEquip → TrialTalentGroup 四表级联把试用养成数据一次性灌进独立的 TrialRoleModel (负数 uid 快照,永不污染真实背包),UI 层 IsActive ? Trial : 真实 路由读取, 属性全量查询用串号五重校验防过期回包。
1. 工作清单
| 内容 | 性质 |
|---|---|
| 试用角色:序章试用角色全链路——TrialRoleModel 独立数据快照(负数 TrialUid 池)、四表级联装配(基础/装备/威能/天赋)、实体身份字段驱动进退试用、UI 读取路由、AsyncInitAvatar 后皮肤覆盖、全量属性查询串号防过期 | 从 0 建 |
2. 理论:隔离、级联与异步防御
2.1 数据隔离:试用快照不进真实模型
| 维度 | 真实角色 | 试用角色 |
|---|---|---|
| 装备数据 | InventoryModel / EquipmentModel(uid > 0) | TrialRoleModel 独立持有(TrialUid < 0) |
| 天赋/等级 | 服务器实体组件下发 | TrialRole / TrialTalentGroup 表灌入快照 |
| 属性快照 | roleBaseComp 等服务器组件 | WorldQueryEntityFullData 查回 + UpdateAttributeData |
2.2 负数 uid 池:一个数字编码「这条数据是假的」
| 约定 | 收益 |
|---|---|
| TrialUid 永远 < 0(从 -1 递减) | 真实 uid 恒 > 0——按符号判断真伪,永不撞号 |
| 每次 EnterTrial 重置回 -1 | 同一次试用内 uid 稳定,UI 拿去做 key 不闪跳 |
| 池只有 Next 一个静态变量 | 无预分配、无回收——试用数据短生命周期,递减够用 |
2.3 四表级联:一次 EnterTrial 灌完全部维度
2.4 异步防御:串号五重校验
进试用后客户端主动发 WorldQueryEntityFullDataCSReq 查实体属性快照, 这是异步请求——回包到达时试用可能已经切换/退出。校验链:
| 校验 | 拦什么 |
|---|---|
| querySerial === 当前串号 | 期间又发起过新查询,旧回包作废(防串号) |
| responseEntityId === 请求实体 | 回包实体对不上(防换实体) |
| trialRoleModel.IsActive | 试用已退出(防过期态) |
| TrialConfigId === 请求时 config | 试用已切到别的配置(防切档) |
| localPlayerRole.CreatureDataId 一致 | 本地角色已切换(防顶号) |
2.5 表现层:实体替换 + 皮肤覆盖时序
切换试用走 entity 替换(down 旧 + up 带 trialConfigId 的新 role entity), actor 创建时已按 roleConfigId 拉好 mesh/anim, 表现层只需在AsyncInitAvatar 完成 FirstChangeClothesByServerData 之后 覆盖 SkinId 套装——晚于这个时序点,试用皮肤会被原始服装覆盖回去。 SkinId ≤ 0 一律返回 undefined,交给 InitAvatarData 按角色兜底默认皮肤。
3. 代码片段
3.1 负数 TrialUid 池
Src\Game\Module\Role\TrialRole\TrialRoleDefine.ts:39-59
/**
* 负数 TrialUid 分配池。
*
* 约定:TrialUid 永远落在 [-1, Number.MIN_SAFE_INTEGER],且每次 EnterTrial 重置。
* 这样:
* 1) 和 InventoryModel 真实 uid (>0) 永远不冲突;
* 2) 同一次试用内 uid 稳定,UI 拿去做 key 不会跳。
*/
export class TrialUidPool {
private static Next = -1;
public static Reset(): void {
TrialUidPool.Next = -1;
}
public static Alloc(): number {
const uid = TrialUidPool.Next;
TrialUidPool.Next -= 1;
return uid;
}
}
3.2 EnterTrial:四表级联一次性灌入
Src\Game\Module\Role\TrialRole\TrialRoleController.ts:45-87
public EnterTrial(trialConfigId: number, trialRoleId: number): void {
if (trialConfigId <= 0 || trialRoleId <= 0) {
Log.Warn(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole.EnterTrial] 非法参数',
['trialConfigId', trialConfigId], ['trialRoleId', trialRoleId]);
return;
}
const model = ModelManager.TrialRoleModel;
if (!model) {
Log.Error(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole.EnterTrial] TrialRoleModel 未初始化');
return;
}
const trialRoleCfg = this.LookupTrialRoleConfig(trialConfigId);
if (!trialRoleCfg) {
Log.Error(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole.EnterTrial] 找不到 TrialRole 配置,放弃进入试用',
['trialConfigId', trialConfigId]);
return;
}
model.ResetAll();
TrialUidPool.Reset();
model.SetTrialIds(trialConfigId, trialRoleId);
model.SetRoleBasics(trialRoleCfg.RoleLevel, trialRoleCfg.SkillUnlockLevel, trialRoleCfg.SkinId);
model.SetBuffIdList(trialRoleCfg.BuffIdList);
model.SetEquipSkillIdList(trialRoleCfg.EquipSkillIdList);
this.FillEquipmentsFromSuit(trialRoleCfg.SuitId);
this.FillTalentNodesFromGroup(trialRoleCfg.TalentGroupId);
model.Activate();
EventSystem.Emit(EEventName.OnTrialRoleEnter, trialConfigId, trialRoleId);
EventSystem.Emit(EEventName.OnTrialRoleDataChanged);
}
3.3 串号五重校验:防过期回包
Src\Game\Module\PlayerRole\PlayerRoleController.ts:209-257(节选)
const creatureDataId = GameIdConversions.GetCreatureDataId(roleEntity.entityId);
const trialConfigId = roleIdentity.trialConfigId;
const querySerial = ++this.TrialRoleFullDataQuerySerial;
const request = Game.Protocol.WorldQueryEntityFullDataCSReq.create({ entityId: creatureDataId });
Net.Call(ERequestMessageId.WorldQueryEntityFullDataCSReq, request, (response) => {
if (!response || response.retcode !== Game.Protocol.Retcode.RET_SUCC) {
Log.Warn(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole] 查询试用角色实体全量数据失败',
['creatureDataId', creatureDataId], ['retcode', response?.retcode]);
return;
}
const responseEntityId = GameIdConversions.GetCreatureDataId(response.entity.entityId);
const trialRoleModel = ModelManager.TrialRoleModel;
const localPlayerRole = ModelManager.PlayerRoleModel?.GetLocalPlayerRole();
if (
querySerial !== this.TrialRoleFullDataQuerySerial ||
responseEntityId !== creatureDataId ||
!trialRoleModel?.IsActive ||
trialRoleModel.TrialConfigId !== trialConfigId ||
localPlayerRole?.CreatureDataId !== creatureDataId
) {
Log.Debug(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole] 忽略已过期的试用角色实体全量数据',
['creatureDataId', creatureDataId], ['responseEntityId', responseEntityId],
['trialConfigId', trialConfigId], ['querySerial', querySerial],
);
return;
}
const attributeComp = response.entity.compList.find((comp) => comp.attributeComp)?.attributeComp;
if (!attributeComp) {
Log.Warn(ELogModule.Role, ELogAuthor.ZYT, '[TrialRole] 试用角色实体全量数据缺少属性组件', [
'creatureDataId', creatureDataId,
]);
return;
}
ControllerHolder.TrialRoleController.UpdateAttributeData(attributeComp.attributeList);
});
3.4 身份字段分发:trialConfigId 驱动进退
Src\Game\Module\PlayerRole\PlayerRoleController.ts:260-280
public UpdateLocalTrialRoleData(roleEntity: Game.Protocol.IEntitySimpleDataMsg): void {
const roleIdentity = roleEntity.identity.role;
if (!roleIdentity) {
return;
}
const playerId = GameIdConversions.GetPlayerId(roleIdentity.belongUid);
if (playerId !== ModelManager.PlayerInfoModel!.LocalPlayerId) {
return;
}
const trialConfigId = roleIdentity.trialConfigId;
if (trialConfigId > 0) {
const trialCfg = configTrialRoleById.GetConfig(trialConfigId);
if (trialCfg) {
ControllerHolder.TrialRoleController.OnTrialConfigIdChanged(trialConfigId, trialCfg.RoleId);
}
} else {
ControllerHolder.TrialRoleController.OnTrialConfigIdChanged(0, 0);
}
}
3.5 UI 读取路由:IsActive ? Trial : 真实
Src\Game\Module\Role\RoleEquipNew\Panels\RoleInfoPanel.ts:102-129
private UpdateRoleName(): void {
const trialModel = ModelManager.TrialRoleModel;
const roleId = trialModel?.IsActive ? trialModel.TrialRoleId : ModelManager.RoleModel!.ChooseRoleId;
const roleConfig = ConfigManager.RoleConfig!.GetRoleConfig(roleId);
if (!roleConfig) {
Log.Error(ELogModule.Role, ELogAuthor.ZH, '[RoleInfoPanel.UpdateRoleName]无法获取角色配置');
return;
}
LguiUtil.SetLocalText(this.TxtName, roleConfig.Name);
}
private UpdateRoleLevel(): void {
const trialModel = ModelManager.TrialRoleModel;
if (trialModel?.IsActive) {
this.TxtLevelNum?.SetText(`${trialModel.GetRoleLevel()}`);
return;
}
const roleData = ModelManager.RoleModel!.GetPlayerRoleData();
if (!roleData) {
return;
}
const roleBaseComp = EntityInterfaceHelper.GetServerCompData(roleData.CreatureId, 'roleBaseComp');
if (!roleBaseComp) {
Log.Error(ELogModule.Role, ELogAuthor.ZH, '[RoleInfoPanel.UpdateRoleLevel]无法获取roleBaseComp');
return;
}
this.TxtLevelNum?.SetText(`${roleBaseComp.level}`);
}
3.6 皮肤覆盖:时序点写进类注释
Src\Game\World\Entity\TrialRole\TrialRoleController.ts:9-52(节选)
/**
* 试用角色表现层 Controller(Game 侧)
*
* 切换试用走 entity 替换(down 旧 + up 带 trialConfigId 的新 role entity);
* actor 创建时已按 entity.roleConfigId 拉好 mesh/anim,本 Controller 只在皮肤层覆盖 SkinId 套装。
*
* 触发点:[EntityAvatarComponent.AsyncInitAvatar] 完成 FirstChangeClothesByServerData 之后调用,确保不被原始服装覆盖。
*/
public static GetTrialRoleClothes(entityId: number, trialConfigId: number): IServerClothSelection[] | undefined {
if (trialConfigId <= 0) {
return undefined;
}
const trialCfg = configTrialRoleById.GetConfig(trialConfigId);
if (!trialCfg) {
Log.Error(ELogModule.Entity, ELogAuthor.ZZB, '[TrialRole.GetTrialRoleClothes] 找不到 TrialRole 配置',
['entityId', entityId], ['trialConfigId', trialConfigId],
);
return undefined;
}
// 只负责 SkinId 配置皮肤;SkinId<=0 交给 InitAvatarData 按角色兜底默认皮肤
if (trialCfg.SkinId <= 0) {
return undefined;
}
const clothIds = ExternalAvatarUtils.GetRoleClothIdsBySuit(trialCfg.SkinId);
if (clothIds.length === 0) {
Log.Warn(ELogModule.Entity, ELogAuthor.ZZB, '[TrialRole.GetTrialRoleClothes] SkinId 查不到 ClothIds,跳过换皮',
['entityId', entityId], ['skinId', trialCfg.SkinId],
);
return undefined;
}
4. 面试怎么讲
隔离:试用养成数据不进 InventoryModel/EquipmentModel,独立 TrialRoleModel 快照持有, 装备 uid 从负数池分配(-1 递减,EnterTrial 重置)—— 和真实 uid 恒正永不冲突,符号位就是真伪标记,退出试用 = 一次 ResetAll,零回滚。
装配:EnterTrial 按 TrialRole→TrialSuit→TrialEquip→TrialTalentGroup 四表级联, 把等级/皮肤/Buff/装备技能/装备明细/天赋一次灌入后 Activate 并广播。
驱动与路由:「是不是试用」由服务器实体身份字段 trialConfigId 随 up/down 实体到达, 客户端 UpdateLocalTrialRoleData 分发进退; UI 每个读取点判断 IsActive ? TrialModel : 真实 Model 二选一, 双源在读取点路由,写入侧永远不需要改真实数据。
所以回包校验五重:请求串号对账(querySerial)、回包实体 = 请求实体、试用仍激活(IsActive)、 试用配置未切换(TrialConfigId 一致)、本地角色未切换(CreatureDataId 一致)。 五个条件一行 || 读完就是时间线;兜底分支打 Debug 日志带全部对账参数, 误杀正常回包也能从日志定位卡在哪一重。
解法是把 GetTrialRoleClothes 的调用点钉死在 AsyncInitAvatar 完成之后, 时序约束直接写进类注释;SkinId ≤ 0 或查不到 ClothIds 时返回 undefined, 调用方跳过换皮兜回默认皮肤——三段 undefined 语义统一,永不带病渲染。