首页 / 入职工作清单 / 试用角色

试用角色

序章/卡池的试用角色全链路:服务器用实体身份字段 trialConfigId 标记试用实体, 客户端按 TrialRole → TrialSuit → TrialEquip → TrialTalentGroup 四表级联把试用养成数据一次性灌进独立的 TrialRoleModel (负数 uid 快照,永不污染真实背包),UI 层 IsActive ? Trial : 真实 路由读取, 属性全量查询用串号五重校验防过期回包。

从 0 建 负数 uid 池 四表级联装配 串号五重校验 IsActive 读路由

1. 工作清单

内容性质
试用角色:序章试用角色全链路——TrialRoleModel 独立数据快照(负数 TrialUid 池)、四表级联装配(基础/装备/威能/天赋)、实体身份字段驱动进退试用、UI 读取路由、AsyncInitAvatar 后皮肤覆盖、全量属性查询串号防过期从 0 建

2. 理论:隔离、级联与异步防御

2.1 数据隔离:试用快照不进真实模型

维度真实角色试用角色
装备数据InventoryModel / EquipmentModel(uid > 0)TrialRoleModel 独立持有(TrialUid < 0)
天赋/等级服务器实体组件下发TrialRole / TrialTalentGroup 表灌入快照
属性快照roleBaseComp 等服务器组件WorldQueryEntityFullData 查回 + UpdateAttributeData
为什么必须隔离 试用数据如果直接写进 InventoryModel,退出试用时要逐条回滚, 写漏一条就变成「试用完白送装备」级别的事故。 独立快照模型让「退出」退化成一次 ResetAll—— 试用态与真实态在数据结构层面就不可能互相污染。

2.2 负数 uid 池:一个数字编码「这条数据是假的」

约定收益
TrialUid 永远 < 0(从 -1 递减)真实 uid 恒 > 0——按符号判断真伪,永不撞号
每次 EnterTrial 重置回 -1同一次试用内 uid 稳定,UI 拿去做 key 不闪跳
池只有 Next 一个静态变量无预分配、无回收——试用数据短生命周期,递减够用
这是「在值域上做隔离」 不做类型区分、不做容器区分,直接把符号位当真伪标记: 任何拿到 uid 的下游代码用 uid < 0 一行就能识别试用数据, 排查问题时看一眼 uid 正负也知道数据来源。

2.3 四表级联:一次 EnterTrial 灌完全部维度

1
ResetAll + 池重置
清旧快照、TrialUidPool.Reset——幂等,重复进试用不残留
2
TrialRole 基础
RoleLevel / SkillUnlockLevel / SkinId / BuffIdList / EquipSkillIdList
3
TrialSuit → TrialEquip
套装给 EquipIdList,逐条查 TrialEquip 明细(强化/附魔/宝石/威能/皮肤),槽位由 EquipId 反查 Equipment 表推算
4
TrialTalentGroup
天赋组给 TalentNodeList,整组写入
5
Activate + 双事件
OnTrialRoleEnter(进/退语义)+ OnTrialRoleDataChanged(刷新语义)
配置拆四张表的理由 TrialRole(角色维度)→ TrialSuit(套装维度)→ TrialEquip(单件维度)→ TrialTalentGroup(天赋维度): 套装和天赋组都能被多个 TrialRole 复用, 策划配「新角色试用 = 现成套装 + 现成天赋组」时只写一行主表。 级联查询全部收在 Controller 的私有 Lookup 方法里,Model 只暴露 setter。

2.4 异步防御:串号五重校验

进试用后客户端主动发 WorldQueryEntityFullDataCSReq 查实体属性快照, 这是异步请求——回包到达时试用可能已经切换/退出。校验链:

校验拦什么
querySerial === 当前串号期间又发起过新查询,旧回包作废(防串号)
responseEntityId === 请求实体回包实体对不上(防换实体)
trialRoleModel.IsActive试用已退出(防过期态)
TrialConfigId === 请求时 config试用已切到别的配置(防切档)
localPlayerRole.CreatureDataId 一致本地角色已切换(防顶号)
串号(serial)是这套校验的钥匙 每发起一次查询 ++TrialRoleFullDataQuerySerial, 回包拿着发起时的串号回来对账—— 五重校验不是防御式洁癖,是真实踩过坑的收口: 快速进出试用/连续切试用时,旧回包晚到会把 A 角色的属性灌进 B 角色的快照。 兜底分支打 Debug 日志留痕(对账参数全打),误杀正常回包也能从日志还原。

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;
    }
}
类注释就是全部设计 两条约定(永不冲突 / 试用内稳定)写在类头上, Reset 和 Alloc 合计不到 10 行—— 把一个并发/生命周期问题压进「静态递减变量 + 进试用重置」的极简实现。

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);
}
入口防御 → 重置 → 灌入 → 激活 → 广播,顺序即语义 非法参数/模型缺失/配置缺失三段前置防御全部带日志返回 (配置缺失直接放弃进入试用而不是带病继续); ResetAll 在写入之前——重复 EnterTrial 幂等; 两个事件分工明确:OnTrialRoleEnter 带 id 供进退流程订阅, OnTrialRoleDataChanged 不带参供 UI 无脑刷新。

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);
});
回包时刻的世界可能已经不是发包时刻的世界 异步回包防御的标准三件套在这里配齐: ① 串号对账(querySerial)管「请求被更新请求顶掉」; ② 实体一致性(回包实体 = 请求实体 = 当前本地角色)管换角色/顶号; ③ 状态一致性(IsActive + TrialConfigId)管试用退出/切档。 五个条件 || 连接一行读下来就是一条时间线。 兜底分支日志把对账四参数全打——误杀正常回包时能立刻看出卡在哪一重。

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);
    }
}
「是不是试用」是服务器实体身份的一部分 trialConfigId 挂在 identity.role 上,随实体 up/down 一起到达—— 客户端不需要自己记「我在试用谁」,每次实体数据更新都以身份字段为准重分发。 > 0 走 Enter(RoleId 由客户端查表补齐), 0 走 Exit:进退是同一个分发函数的两个分支,不可能只处理一边。

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}`);
}
路由收敛在读取点,而不是写入点 Controller 只管把试用数据灌进 Model、广播 OnTrialRoleDataChanged; 每个 UI 读取点自己判断 IsActive 选源。 没有「试用时把真实 Model 改掉、退出再改回来」这种写入侧路由—— 双源在读取点二选一,永远不需要回滚。 面板注册/反注册成对(UnregisterEventListeners 摘 OnTrialRoleDataChanged)。

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;
    }
「必须晚于 FirstChangeClothesByServerData」就是踩坑结论 试用皮肤覆盖和 AsyncInitAvatar 的默认换装是两条并发路径, 先到的会被后到的盖掉。解法不是加锁,是把调用点钉死在 Avatar 异步初始化完成之后——时序约束直接写进类注释, 后来者接入时第一眼就看到。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 二选一, 双源在读取点路由,写入侧永远不需要改真实数据。
试用角色属性查询为什么要做五重校验难点
WorldQueryEntityFullData 是异步请求,回包时刻的世界可能已经不是发包时刻的世界: 快速进出试用、连续切试用、切角色、顶号,旧回包晚到都会把 A 的属性灌进 B 的快照。
所以回包校验五重:请求串号对账(querySerial)、回包实体 = 请求实体、试用仍激活(IsActive)、 试用配置未切换(TrialConfigId 一致)、本地角色未切换(CreatureDataId 一致)。 五个条件一行 || 读完就是时间线;兜底分支打 Debug 日志带全部对账参数, 误杀正常回包也能从日志定位卡在哪一重。
试用皮肤覆盖有什么坑细节
坑在时序:切换试用走 entity 替换,actor 创建按 roleConfigId 拉好 mesh/anim, Avatar 组件的 AsyncInitAvatar 里有 FirstChangeClothesByServerData 默认换装—— 试用皮肤如果在此之前应用,会被默认服装盖回去。
解法是把 GetTrialRoleClothes 的调用点钉死在 AsyncInitAvatar 完成之后, 时序约束直接写进类注释;SkinId ≤ 0 或查不到 ClothIds 时返回 undefined, 调用方跳过换皮兜回默认皮肤——三段 undefined 语义统一,永不带病渲染。