爬塔

按武将(角色)分塔、逐副本挑战、每副本按条件拿 1/2/3 星的长线玩法。 本页的重点是星级系统:从宇宙编辑器行为树里配达成条件, 到局内 HUD 实时点亮,再到局外历史最佳 starMask 位掩码存档与展示, 以及三条协议(20532 / 20533 / 37503)的接入。

位掩码 starMask 局内 20532/20533 局外 37501/37503 行为树 Scheme 多语言回退

1. 模块构成

爬塔是多条独立子系统拼起来的 Src/Game/Module/TowerClimb/ 下 16 个文件共 2624 行, 星级系统与塔入口 / 任务 / 排行榜 UI 是两套彼此独立的链路, 数据源、协议、刷新时机都不同。
grep '爬塔星级-' 前缀能把星级链路的日志单独捞出来。
子系统落点所属链路
星级配置UniverseEditor · TowerStarConfig scheme星级
局内星级实时数据DungeonModel.TowerStarInfo(20532/20533)星级
局内 HUDMainUiTaskPanel + TowerStarItem星级
局外历史最佳TowerClimbModel.TowerClimbRecordInfoMap(37501/37503)星级
局外星级展示GetTowerStarDisplayListByDungeon星级
塔入口 / 任务 / 排行榜 UITowerClimbEntryView / TaskView / RankView塔框架
爬塔子任务节点TowerClimbNode(49 行)塔框架
瞭望塔实体EtWatchtowerComponent(1029 行)塔框架

2. 星级系统全链路

1
策划在宇宙编辑器配 TowerStarConfig.Stars[]
每档一条 EdTidDesc(中文原文)+ TidDesc(多语言 key)+ SubQuests(子任务条件)
2
行为树跑到「开启星级监听」节点
EAction.StartTowerStarListen,服务器执行(ImplementType: 'Server')
3
服务器全量下发 20532
starList[] = {idx, isTrue, condition} + starsMask。 存进 DungeonModel.TowerStarInfo
4
条件达成时增量下发 20533
只推变化的那一档,Model 按 idx 找到替换/push,Emit DungeonTowerStarChange
5
HUD 刷新
MainUiTaskPanel 收事件 → GetCurrentTowerStarDisplayList 合并配置文本 → 点亮星星
6
通关后 37503 落存档
服务器把该武将该副本的历史最佳 starMask 推下来, 增量更新 TowerClimbRecordInfoMap
7
局外展示
塔详情页 / 匹配面板按 starMask 逐位判定,配文本走同一套 ITowerStarDisplayItem
一个关键的架构决定:局内局外共用同一个展示结构 ITowerStarDisplayItem 是统一出口,局内 GetCurrentTowerStarDisplayList 和局外 GetTowerStarDisplayListByDungeon 都返回它。 差别只在「达成」这个布尔值从哪来—— 局内来自 starList[i].isTrue(实时),局外来自 starMask 的第 i 位(历史最佳)。
好处是 TowerStarItem / DungeonStarTimeItem 这两个 UI 组件四处复用: HUD、匹配面板、塔详情、结算胜利界面,一个 UpdateByProgress(isAchieved, tid, ed) 打通。

3. 配置层:TowerStarConfig Scheme

3.1 三层结构

/** 单颗星:由一个子任务条件组成,完成即得这颗星 */
export interface ITowerStarItem {
    /** 星级描述-编辑器输入文本(必填) */
    EdTidDesc: string;
    /** 星级描述-多语言TID(副本内星级UI显示,客户端按档读取) */
    TidDesc: string;
    /** 子任务条件(与 ChildQuest 同源) */
    SubQuests: TChildQuestCondition;
}

/** 爬塔星级配置:上层封装一层,避免未来加新字段(如难度系数 / 提示文案)时改格式 */
export interface ITowerStarConfig {
    /** 星级列表,按顺序对应 1/2/3... 星 */
    Stars: ITowerStarItem[];
}
为什么 Stars 外面要包一层 TowerStarConfig 直接写 TowerStarConfig?: ITowerStarItem[] 也能跑,但以后要加「总星数上限」「规则版本」这类全局字段时 就得改数据格式,已存的关卡文件全部要迁移。
包一层 wrapper 后,加字段只是往 ITowerStarConfig 里加个 optional 属性,老数据自然兼容。 ITowerStarItem 同理——「封装一层,未来加新字段不必再改外层结构」。

3.2 两套 scheme:编辑用真实、校验用空壳

// levelPlayScheme(通用玩法树)里:
// 爬塔星级:校验侧用空壳(IgnoreAllFields)取消检查,
// 编辑侧由 levelPlayDungeonScheme 覆盖为真实配置 scheme
TowerStarConfig: createObjectScheme<ITowerStarConfig>({
    Name: 'TowerStarConfig',
    Optional: true,
    IgnoreAllFields: true,
    Fields: {} as never,
}),

// levelPlayDungeonScheme(副本主控树)里覆盖成真的:
export const levelPlayDungeonScheme = createObjectScheme<ILevelPlay>({
    Fields: {
        ...levelPlayScheme.Fields,
        Tree: behaviorTreeScheme,
        TowerStarConfig: towerStarConfigScheme,   // 真实 scheme
    },
});
这个双 scheme 结构曾经引发过一个「误报脏数据」事故 现象:策划配好星级后,检查工具报「TowerStarConfig 非法字段」。 第一反应是数据脏了,实际根因是编辑用 levelPlayDungeonScheme、校验用 levelPlayScheme,两者不一致—— 校验那边是空壳,看到真实字段当然认为非法。
同类报错的排查第一步:确认编辑 scheme 和校验 scheme 是同一个,别一上来就当脏数据删。
另一个附带结论:scheme 的 Tip 写着「仅副本可配」,但数据结构里没有副本类型字段, 所以这个限制只能靠「挂在 levelPlayDungeonScheme 上」来实现,做不了运行时校验。

3.3 EdTid + Tid 双字段

EdTidDesc: createEdTidTextScheme({
    CnName: '星级描述(EdTid)',
    Tip: '该档星级达成条件的描述,显示在副本内星级提示UI(必填)',
    CreateDefault: () => '完成条件',
    IsDefaultCreate: true,
    IsAlwaysShow: true,
}),
TidDesc: createTidTextScheme({
    CnName: '星级描述(Tid)',
    TextKeyPrefix: 'TowerStarDesc',
}),

这是宇宙编辑器的通用模式:策划填 EdTid(中文原文),导表工具扫出来生成 Tid(多语言 key)。 运行时优先用 Tid 查译文,查不到回退 EdTid。
边界重生模块踩过的坑正好相反——那边 EdTid 运行时不导出, 兜底代码是死的;这里客户端两个字段都能拿到,回退是真实生效的。

4. 协议清单

4.1 局内(实时星级)

号名方向作用
20532WorldDungeonTowerStarStartSCNotifyS→C全量下发本副本星级档位。行为树 StartTowerStarListen 触发后推
20533WorldDungeonTowerStarUpdateSCNotifyS→C增量单档更新,某档条件达成时推

4.2 局外(历史最佳 + 任务 + 排行)

号名方向作用
37501WorldTowerClimbDataSCNotifyS→C全量:所有武将的 bestRecords + 任务列表
37502WorldTowerClimbTaskSCNotifyS→C任务状态变更
37503WorldTowerClimbBestRecordSCNotifyS→C单副本最佳记录增量:通关后只推变化的那一条
37510WorldTowerClimbSelfRankListSCNotifyS→C自己在各塔的排名
37504 / 37505TaskList CSReq / SCRspC→S主动拉任务列表
37506 / 37507TaskTakeRewardC→S领奖
37508 / 37509SelfRankListC→S拉排名
「全量 + 增量」是这套协议的统一模式 20532/20533 是一对,37501/37503 是一对。都遵循同一套原则:
进入场景推全量建立基线,之后只推变化。
客户端两边的处理也对称——增量到达时「按主键找,有则替换,无则 push」, 不假设服务器一定先发过全量(见下节 UpdateTowerStarProgress 的空 Model 兜底)。

5. starMask 位掩码

5.1 语义

/** 场外历史最佳星级位掩码:按武将 roleId + 副本 dungeonId 取,
    第 i 位=第 i 档达成;无记录返回 0 */
public GetDungeonBestStarMask(roleId: number, dungeonId: number): number
mask二进制含义
0000一颗星都没拿到(或无记录)
1001只拿到第 0 档
5101拿到第 0、2 档,跳过第 1 档
7111满星
为什么必须用掩码而不是「星数」 因为星级档位不是递进的。三档条件可能是「3 分钟内通关」「不死亡」「击杀 50 只小怪」, 玩家完全可能拿到第 1、3 档而丢掉第 2 档。存一个「2 星」的数字就丢失了「丢的是哪一档」的信息, UI 上没法准确点亮对应的星星。

5.2 统计已点亮星数:封顶防脏数据

/** 统计 starMask 已点亮星数,上限 TowerMaxStars 防服务器脏数据超配置档数 */
private CountStarsFromMask(starMask: number): number {
    let starCount = 0;
        for (let idx = 0; idx < this.TowerMaxStars; idx++) {
        if ((starMask & (1 << idx)) !== 0) { starCount++; }
    }
    return starCount;
}

// TowerMaxStars 来自公共参数表,缺省 3
this.TowerMaxStars = configCommonParamById.GetIntConfig('tower_max_stars') ?? 3;
循环上界用配置而不是 32 位全扫 最朴素的写法是 while (mask) { count += mask & 1; mask >>= 1; },扫完所有位。 但服务器如果因为 bug 下发了 mask = 255,客户端会算出 8 星, UI 上塔的总星数瞬间对不上(GetTotalTowerStars = 副本数 × TowerMaxStars)。
用配置档数封顶,让「已得 ≤ 总数」这个不变式在客户端侧永远成立, 脏数据只会被静默截断,不会让界面显示出诡异的「12/9 星」。

5.3 塔维度累计

public GetRoleOwnTowerStars(roleId: number, towerId: number): number {
    for (const recordInfo of roleRecordInfo.bestRecords) {
        const towerInfoCfg = ConfigManager.TowerClimbConfig!
            .GetTowerClimbInfoConfigByDungeonId(recordInfo.dungeonId);
        if (towerInfoCfg && towerInfoCfg.TowerId === towerId) {
                    // 按 starMask 逐档累计,不能按"打过即满星"算
            ownTowerStars += this.CountStarsFromMask(recordInfo.starMask);
            detailList.push(`${recordInfo.dungeonId}=mask${recordInfo.starMask}/${dungeonStars}星`);
        }
    }
    Log.Debug(..., '爬塔星级-局外取塔已得星数GetRoleOwnTowerStars',
        ['ownTowerStars', ownTowerStars], ['明细', detailList.join(',')]);
}

public GetTotalTowerStars(towerId: number): number {
    return towerInfoCfgList.length * this.TowerMaxStars;   // 副本数 × 每副本档数
}

注意日志里带了「明细」字段:2010=mask5/2星,2011=mask7/3星。 线上出「星数对不上」的问题时,一行日志就能看出是哪个副本的 mask 不对, 不用逐个副本去查。这是一条值得固定下来的习惯—— 聚合数值的日志一定要附明细。

5.4 局内当前掩码:两个来源做 OR

/** 当前副本已达成星级掩码:20533 只更新单档不刷 starsMask,
    故按 starList 自算,并 OR 通关下发的 towerStarInfo 兜底 */
public GetCurrentTowerStarMask(): number {
    let mask = 0;
    const sources = [this.TowerStarInfo, this.CurrentDungeonInfo?.towerStarInfo];
    for (const info of sources) {
        if (!info) { continue; }
        mask |= info.starsMask ?? 0;
        for (const star of info.starList ?? []) {
            if (star.isTrue) { mask |= 1 << star.idx; }
        }
    }
    return mask;
}
这是一个「协议不完美时的客户端补偿」 理想情况服务器每次 20533 都同步刷新 starsMask,客户端直接读就行。 实际是 20533 只带单档 progress,不带整体 mask。
所以客户端自己从 starList 反算,同时 OR 上两个来源 (局内实时下发的 + 副本信息里通关时带的),任一来源有值都算数。
这类补偿代码务必写清注释说明「协议为什么不够用」, 否则后来人看到「既读 starsMask 又自算」会以为是冗余而删掉一半。

6. 数据层

6.1 局内:UpdateTowerStarProgress

/** 单档星级实时更新;找不到 idx 直接 push 进列表 */
public UpdateTowerStarProgress(progress: Nami.Protocol.ITowerStarProgress): void {
    Log.Warn(..., '爬塔星级-单档更新UpdateTowerStarProgress',
        ['idx', progress.idx], ['isTrue', progress.isTrue],
        ['endTimeMs', MathUtils.LongToNumber(progress.condition?.waitTimeData?.endTimeMs ?? 0)]);

    if (!this.TowerStarInfo) {   // 全量还没到就先来了增量
        this.TowerStarInfo = { starList: [progress] } as Nami.Protocol.IDungeonTowerStarInfo;
        EventSystem.Emit(EEventName.DungeonTowerStarChange);
        return;
    }
    const i = this.TowerStarInfo.starList.findIndex((s) => s.idx === progress.idx);
    if (i >= 0) { list[i] = progress; } else { list.push(progress); }
    EventSystem.Emit(EEventName.DungeonTowerStarChange);
}

三条防御:全量未到先建 Model、idx 不存在就 push、 每条路径都 Emit 事件(早期版本「提前 return」那条漏了 Emit,导致偶发首档不亮)。

6.2 局外:UpdateTowerClimbBestRecord

/** 单副本最佳记录增量更新(37503):无该武将记录先建,已有同 dungeonId 则整条替换 */
public UpdateTowerClimbBestRecord(roleId, record): void {
    let roleRecordInfo = this.TowerClimbRecordInfoMap?.get(roleId);
    if (!roleRecordInfo) {
        roleRecordInfo = new Nami.Protocol.TowerClimbRoleRecordInfo({ roleId, bestRecords: [] });
        this.TowerClimbRecordInfoMap?.set(roleId, roleRecordInfo);
    }
    const index = roleRecordInfo.bestRecords.findIndex((r) => r.dungeonId === record.dungeonId);
    if (index >= 0) { roleRecordInfo.bestRecords[index] = record; }
    else { roleRecordInfo.bestRecords.push(record); }
}

和局内完全同构:先保证容器存在,再按主键「有则更新、无则插入」。 两处主键不同(局内是 idx,局外是 dungeonId),但模式一致。

6.3 展示数据合并

/** 副本爬塔星级展示项:合并服务器 progress + 行为树多语言文本 */
export interface ITowerStarDisplayItem {
    /** 服务器下发的星级序号(对应行为树 TowerStarConfig.Stars 下标) */
    Idx: number;
    /** 是否已达成该档星级 */
    IsAchieved: boolean;
    /** 多语言 TID(找不到为空串) */
    TidDesc: string;
    /** 编辑器输入的中文原文(多语言未命中时回退显示) */
    EdDesc: string;
}
// 局内:达成来自实时 isTrue
public GetCurrentTowerStarDisplayList(levelId): ITowerStarDisplayItem[] {
    if (!info?.starList || info.starList.length === 0) { return []; }
    const configItems = this.GetCurrentTowerStarConfigItems(levelId);
        // 只认行为树配了的档(服务器可能多下发或乱序),按 idx 升序对齐 1/2/3 星
    return info.starList
        .filter((p) => !configItems || (p.idx >= 0 && p.idx < configItems.length))
        .sort((a, b) => a.idx - b.idx)
        .map((p) => ({ Idx: p.idx, IsAchieved: Boolean(p.isTrue),
                     TidDesc: configItems?.[p.idx]?.TidDesc ?? '',
                     EdDesc:  configItems?.[p.idx]?.EdTidDesc ?? '' }));
}

// 局外:达成来自 starMask 逐位
public GetTowerStarDisplayListByDungeon(dungeonId, roleId): ITowerStarDisplayItem[] {
    const levelId = configDungeonById.GetConfig(dungeonId, false)?.LevelConfigId;
    const configItems = this.GetCurrentTowerStarConfigItems(levelId);
    if (!configItems || configItems.length === 0) { Log.Warn(...); return []; }
    const starMask = ModelManager.TowerClimbModel?.GetDungeonBestStarMask(roleId, dungeonId) ?? 0;
        // 以配置为准展开全部档位,未达成的也返回(要显示灰星和条件文本)
    return configItems.map((cfg, idx) => ({
        Idx: idx,
        IsAchieved: (starMask & (1 << idx)) !== 0,
        TidDesc: cfg?.TidDesc ?? '', EdDesc: cfg?.EdTidDesc ?? '',
    }));
}
两个函数的「主导方」不同,这是刻意的 局内以服务器 starList 为主导(filter 掉配置里没有的档)—— 因为局内要显示的是「服务器正在监听的那些条件」,配置可能比服务器新。
局外以配置 configItems 为主导(map 全部档位)—— 因为局外要显示完整的 1/2/3 星面板,包括没拿到的灰星。 只按 mask 展开的话,mask=0 会返回空数组,界面上一颗星都不显示。

配置来源穿了三层表:

GetCurrentTowerStarConfigItems(levelId):
    levelId → configLevelConfigById → levelConfig.FlowId
           → LevelPlayModel.GetLevelPlayConfig(FlowId)
           → .TowerStarConfig?.Stars

7. 局内 HUD 实时显示

7.1 挂在哪

星级 HUD 没有单独开界面,而是挂在主界面追踪栏 MainUiTaskPanel(任务追踪那一块)里, 和任务列表、爬塔子任务列表共用一个面板。组件索引:

const enum EMainUiTaskType {
    QuestLayout, QuestItem, PnlCopyEntryList, PnlCopyEntry, PnlContent,
    PnlTowerStar,        // 爬塔星级 HUD 容器(非爬塔副本隐藏整块)
    TxtLevelName,        // 爬塔副本名
    TxtTime,             // 实时计时文本
    PnlTowerStarItem,    // 单档模板节点(复制它生成每一档)
    PnlTowerStarList,    // list 容器
}

7.2 动态生成 N 档:三个坑

/**
 * 生成/刷新星级 list(仿 UpdateClimbItemList):按服务器星级数据,复制 PnlTowerStarItem 模板成 N 档。
 * 已有的复用并更新,多余的隐藏;创建是异步的,靠 IsCreatingStarItems 串行防重复复制。
 */
private UpdateTowerStarList(): void {
    const list = this.GetTowerStarDisplayList();
    if (list.length === 0) { /* 全隐藏 */ return; }
        // 创建未完成时不能再进,否则 ViewList 还是旧长度会把模板重复复制一轮
    if (this.IsCreatingStarItems) { return; }
    if (list.length > this.TowerStarItemViewList.length) {
        void this.CreateTowerStarItemsAsync(list.length);
        return;
    }
    this.ApplyTowerStarList(list);
}
坑① 异步创建重入 → 档位翻倍
场景
20532 全量刚到触发一次创建,紧接着 20533 又到触发第二次。第一次的 await 还没回来,TowerStarItemViewList.length 还是 0
后果
模板被复制两轮,3 档显示成 6 条
修复
IsCreatingStarItems 布尔锁,try/finally 保证异常也会解锁;创建完在 finally 之后用最新数据统一刷一遍,补上被挡掉的那次更新
/**
 * 补齐到 targetNum 档,期间屏蔽重入;补齐后用最新数据统一刷一遍。
 * 节点在 CreateTowerStarItem 里同步复制完成故顺序有保证,await 后按序 push。
 */
private async CreateTowerStarItemsAsync(targetNum: number): Promise<void> {
    this.IsCreatingStarItems = true;
    try {
        const tasks: Promise<TowerStarItem | undefined>[] = [];
        for (let i = this.TowerStarItemViewList.length; i < targetNum; i++) {
            tasks.push(this.CreateTowerStarItem());
        }
        const views = await Promise.all(tasks);   // 顺序由 Promise.all 保证
        for (const view of views) { if (view) { view.SetUiActive(true); this.TowerStarItemViewList.push(view); } }
    } finally {
        this.IsCreatingStarItems = false;
    }
        this.ApplyTowerStarList(this.GetTowerStarDisplayList());   // 用最新数据补刷
}
节点顺序为什么有保证 Promise.all 只保证结果数组按输入顺序,不保证副作用顺序。 但这里安全,因为UI 节点的复制 LguiUtil.CopyItem 是同步的—— 在 CreateTowerStarItem 里就完成了,异步的只是后面 View 的 CreateAndShowPanelByActorAsync。 所以场景树里的兄弟节点顺序在 await 之前就定死了。注释也专门写明了这一点。
坑② prefab 预摆静态档位 → 和动态生成的叠加
现象
3 档星级显示成 5 条
根因
美术在 prefab 里除了模板节点,还预摆了几个静态档位做视觉参考
修复
HideTowerStarStaticChildren():初始化时遍历容器所有子节点全部 SetUIActive(false),只留后面动态复制出来的
/** 隐藏星级容器下 prefab 预摆的所有静态子节点,只留动态复制出来的 */
private HideTowerStarStaticChildren(): void {
    const parent = this.PnlTowerStarItem?.GetParentAsUIItem() ?? this.PnlTowerStarList;
    const childNum = parent.UIChildren.Num();
    const names: string[] = [];
    for (let i = 0; i < childNum; i++) {
        const child = parent.UIChildren.Get(i);
        names.push(child.GetDisplayName());
        child.SetUIActive(false);
    }
    Log.Warn(..., '爬塔星级-隐藏容器预摆静态节点',
        ['容器', parent.GetDisplayName()], ['子节点数', childNum], ['明细', names.join(',')]);
}

日志里打出了子节点名列表——美术改 prefab 加节点时, 一看日志就知道新加的东西被隐藏了,不用来问「摆的星星为什么不见了」。

坑③ 模板是 prefab 实例,GetItem 取不到子节点
根因
registry 索引只覆盖当前 prefab 层级,嵌套 prefab 实例内部的节点取不到
兜底
FindDescendantByName(root, name) 递归按显示名查找

7.3 显隐判定:两套标准

/** 是否爬塔星级副本:有服务器下发的星级数据即认为是 */
private IsTowerStarDungeon(): boolean {
    const info = ModelManager.DungeonModel?.GetTowerStarInfo();
    return !!info?.starList && info.starList.length > 0;
}

/** 当前是否爬塔副本:按副本配置查爬塔表,进副本即可判定,不依赖服务器星级下发时机 */
public IsInTowerClimbDungeon(): boolean {
    const dungeonConfigId = this.CurrentDungeonInfo?.dungeonConfigId;
    return ConfigManager.TowerClimbConfig?.GetTowerClimbInfoConfigByDungeonId(dungeonConfigId) !== undefined;
}
函数依据用在哪为什么
IsTowerStarDungeon有没有星级数据HUD 星级块显隐星级块要等数据才有内容,早显示是空框
IsInTowerClimbDungeon查爬塔配置表战斗 UI 状态栏、死亡复活面板这些要进副本立刻判定,等不了服务器下发
显隐不依赖 CurrentDungeonInfo,这是刻意解耦
// 显隐只由"是否有星级数据"决定,不依赖 CurrentDungeonInfo
// (两者是独立的服务器下发,副本信息可能晚到或缺)
const isTowerDungeon = this.IsTowerStarDungeon();
this.PnlTowerStar?.SetUIActive(isTowerDungeon);
副本名和计时是「有就显示、没有就留空」,缺副本信息不影响星级显示。 把两个独立下发的数据源解耦,避免一个晚到就整块 HUD 不出。

7.4 实时正向计时

// 实时正向计时:从副本开始起每 50ms 刷新
if (this.TowerClimbStartMs === 0) { this.TowerClimbStartMs = Time.ServerTimeStamp; }
this.UpdateTowerTime();
if (!TimerSystem.Has(this.TowerTimeTimer)) {
    this.TowerTimeTimer = TimerSystem.Forever(this.UpdateTowerTime, 50);
}

/** 计算并刷新爬塔实时正向计时文本(分:秒.厘秒) */
private readonly UpdateTowerTime = (): void => {
    const elapsedMs = Math.max(0, Time.ServerTimeStamp - this.TowerClimbStartMs);
    const minutes = Math.floor(elapsedMs / 1000 / 60);
    const seconds = Math.floor(elapsedMs / 1000) % 60;
    const centiseconds = Math.floor((elapsedMs % 1000) / 10);   // 厘秒,两位
    this.TxtTime?.SetText(`${mm}:${ss}.${cc}`);   // 各 padStart(2, '0')
};
决策理由
用 Time.ServerTimeStamp 不用本地时间和计时器玩法同源。本地时钟会漂,改系统时间还能作弊
50ms 一跳而不是逐帧显示到厘秒(10ms 单位),50ms 刷新肉眼看不出跳变,但比 60fps 逐帧省一半以上开销
TimerSystem.Has 判断后再建幂等。RefreshTowerStarPanel 会被多个事件反复触发,不判断会堆出一堆定时器
Math.max(0, ...)服务器时间回拨时不显示负数
OnBeforeDestroy 里 ClearTowerTimer面板销毁必须清定时器,否则回调打在已销毁的 UIText 上

8. 星级 UI 组件:两个同构实现

工程里有两个功能几乎一样的星级单档组件,接口签名完全一致:

组件路径星控件类型用在哪
TowerStarItemMainUi/Views/ChildView/UISprite局内 HUD
DungeonStarTimeItemDungeon/View/UIItem匹配面板、胜利结算界面
/** 亮星=达成显隐;文本=多语言译文,未命中则回退编辑器中文原文 */
public UpdateByProgress(isAchieved: boolean, tidDesc: string, edDesc: string): void {
    this.SprStar?.SetUIActive(isAchieved);
        // 多语言命中显译文,未命中回退中文原文(EdDesc);永不显示 key / 空
    const localized = StringUtils.IsEmpty(tidDesc) ? undefined : TextConfig.GetLocalText(tidDesc);
    this.TxtCondition?.SetText(localized ?? edDesc);
}
「永不显示 key / 空」是硬性要求 三级回退:多语言译文 → 编辑器中文原文 → 什么都不显示(不会漏出 TowerStarDesc_xxx 这种 key)。
关键在 StringUtils.IsEmpty(tidDesc) ? undefined : GetLocalText(...) 这个短路—— tid 为空时直接不查,避免 GetLocalText('') 返回空串把 ?? 短路掉 ('' 是 falsy 但不是 nullish,?? 不会兜住它,这里靠提前返回 undefined 才生效)。
技术债:两个组件应该合一 DungeonStarTimeItem 里还留着两段注释掉的旧代码(UpdateStarState 按本地时间表判定、 TimeText 显示该档要求时间),说明它是从「按通关时间判星」演化过来的。 现在两者行为一致,唯一差异是星控件的 UE 类型(UISprite vs UIItem)—— 这个差异其实可以用 UIItem 统一(UISprite 继承自它)。

9. 塔任务与排行(接口速查)

9.1 任务的两级分类

class TowerClimbDefineConstants {
    public static TowerTaskRoleType = 20;    // 武将维度任务
    public static TowerTaskTowerType = 10;   // 塔维度任务
}

TaskType(一级)+ TaskSubType(二级)。 TowerTaskTypeReg 表把 RoleId / TowerId 映射到 TypeId(= SubType), 所以「某武将的任务进度」要先查这张注册表拿到 subType,再过滤任务列表。

public GetTowerClimbTaskProgressByRoleId(roleId): ITowerClimbTaskProgress {
    const taskTypeRegCfg = ConfigManager.TowerClimbConfig!.GetTowerClimbTaskTypeRegByRoleId(roleId);
    const roleTaskType = taskTypeRegCfg?.TypeId ?? 0;
    for (const taskInfo of this.TowerClimbTaskInfoList) {
        if (roleTaskType > 0 && taskConfig.TaskSubType !== roleTaskType) { continue; }
        ++taskProgress.TotalNum;
                // 注意:只要不是 UNFINISHED 就算完成(含"已完成待领奖"和"已领奖")
        if (taskInfo.state !== TOWER_CLIMB_TASK_STATE_UNFINISHED) { ++taskProgress.FinishNum; }
    }
}

9.2 排行榜名次的模糊显示

public GetSelfDisplayRank(selfRank: number): string {
    if (selfRank <= TowerClimbConfig.MaxTopRank) { return `${selfRank}`; }   // 前 N 名显示真实名次
    const rankInfoData = ModelManager.RankModel!.GetRankRankInfoData(RANK_TYPE_TOWER);
        const rankScale = Math.ceil(selfRank / rankInfoData!.TotalCount) * 100;   // 算百分位
    const displayRule = this.TowerRankDisplayRuleList.find(
        (rule) => rankScale >= rule.RankRealMin && rankScale <= rule.RankRealMax);
    return displayRule?.ShowRank ?? `${selfRank}`;   // 显示"前 30%"这类文案
}

前 N 名(MaxTopRank 来自 RankBase 表的 TopN)显示真实名次, 之后按百分位查 RankDisplayRule 显示「前 30%」这种模糊档位。 避免玩家看到「第 128374 名」的挫败感。

这里有个真实的数据结构 bug
位置
TowerClimbModel.SetTowerClimbSelfRankInfoList
代码
selfRankMap = TowerClimbSelfRankInfoMap.get(selfRankInfo.roleId),但存的时候是 set(selfRankInfo.towerId, selfRankMap)
后果
外层 Map 的 key 用 towerId 写、用 roleId 读,永远取不到已有项 → else 分支的「排行榜存在重复角色」Log.Error 形同虚设,且同塔多角色只会存进各自独立的 Map
读取侧
GetTowerClimbSelfRankInfo(towerId, roleId) 是按 towerId 取外层的,所以只有每个 towerId 第一条能读到

10. 常见修改场景

10.1 给一个副本加星级

1
宇宙编辑器打开该副本的主控树
必须是副本主控树,普通玩法树上没有这个字段(levelPlayDungeonScheme 才挂)
2
配「爬塔星级」→ 星级列表加档
每档填 EdTidDesc(必填)+ SubQuests 条件
3
树里放「开启星级监听」节点
不放这个节点服务器不会下发 20532,HUD 一片空白
4
在 TowerClimbInfo 表里登记该副本
否则 IsInTowerClimbDungeon 为 false,局外面板不显示
5
TS 零改动
档数是动态的,加到 4 档 5 档都不用改代码

10.2 改星级上限(3 → 5)

改公共参数表 tower_max_stars。影响两处: CountStarsFromMask 的循环上界、GetTotalTowerStars 的乘数。 代码不用动。但要注意服务器侧也得同步,否则客户端封顶会把服务器给的第 4、5 档截断。

10.3 加一个新的星级达成条件类型

条件走的是 SubQuests: TChildQuestCondition,和普通子任务同源。 所以加条件类型不是爬塔的活,是往 EChildQuest 里加, 爬塔这边自动就能用上。这也是当初把 SubQuests 直接复用 ChildQuest 而不是另起一套的原因。

10.4 排查表

现象先查
HUD 星级块完全不显示grep 爬塔星级-局内收包20532。没有 → 行为树漏放「开启星级监听」节点
星级块显示但没有档GetCurrentTowerStarConfigItems 三层查表哪层断了:levelId → FlowId → LevelPlayConfig.TowerStarConfig.Stars
档数翻倍(3 显示成 6)IsCreatingStarItems 锁是否生效;或 prefab 预摆节点没被 HideTowerStarStaticChildren 清掉
条件文本显示成 key多语言表没导。检查 TidDesc 有没有 TowerStarDesc 前缀;EdTid 兜底为什么没生效
条件达成了星星不亮grep 爬塔星级-单档更新 看 idx 和 isTrue。idx 超出配置档数会被 filter 掉
局外星数对不上grep GetRoleOwnTowerStars 的「明细」字段,直接看每个副本的 mask
局外星星全灰GetDungeonBestStarMask 的 roleId 传对没——它是 RoleModel.ChooseRoleId(当前选中武将),不是玩家 uid
编辑器报 TowerStarConfig 非法字段先查编辑 scheme 与校验 scheme 是否一致,不要当脏数据
计时不走 / 走得不对TowerClimbStartMs 有没有被 ClearTowerTimer 重置;Time.ServerTimeStamp 有没有回拨

11. 技术债清单

#问题影响
1SetTowerClimbSelfRankInfoList 外层 Map 读写 key 不一致(roleId 读 / towerId 写)同塔多角色排名只存第一条
2TowerStarItem 与 DungeonStarTimeItem 两份同构实现改一处忘另一处
3DungeonStarTimeItem 残留两段注释掉的旧逻辑可读性
4GetCurrentTowerStarDisplayList 里两段被注释掉的调试 Log.Warn该删或该降级成 Debug
5大量 Log.Warn 用于常规流程日志(收包、更新)线上 Warn 噪音,稳定后应降 Debug
6scheme Tip 写「仅副本可配」但无运行时校验只能靠挂载位置约束,策划复制到普通玩法树不会报错
7TowerStarConfig 双 scheme(编辑真实 / 校验空壳)已引发过误报事故,长期应统一
8GetTowerClimbTaskTypeRegByRoleId 每次都 find 全表表小可忽略,但可以在 OnInit 建 Map

12. 面试怎么讲

星级达成状态为什么用位掩码存设计
因为星级档位之间不是递进关系。三档条件可能是「3 分钟通关」「无死亡」「击杀 50 只小怪」, 玩家完全可能拿到第 1、3 档而丢掉第 2 档。
如果只存「2 星」这个数字,就丢失了「丢的是哪一档」的信息,UI 上没法准确点亮对应星星。 用 starMask 的第 i 位表示第 i 档,mask=5(101)就精确表达了「拿到 0、2 档」。
统计星数时有个细节:循环上界用配置的 tower_max_stars 而不是扫满 32 位。 服务器万一下发脏数据 mask=255,全扫会算出 8 星, 让界面显示出「12/9 星」这种不可能的数字。用配置封顶,保证「已得 ≤ 总数」这个不变式在客户端永远成立。
局内实时和局外历史两套数据,UI 怎么复用架构
抽了一个统一的展示结构 ITowerStarDisplayItem { Idx, IsAchieved, TidDesc, EdDesc }。 局内和局外各有一个函数返回它,差别只在 IsAchieved 从哪来—— 局内读服务器实时下发的 starList[i].isTrue,局外读 starMask 的第 i 位。
这样 UI 组件只认一个 UpdateByProgress(isAchieved, tid, ed), 在 HUD、匹配面板、塔详情、结算界面四个地方复用。
有个不对称的地方值得说:两个函数的「主导方」不同。 局内以服务器 starList 为主导(filter 掉配置里没有的档),因为要显示的是服务器正在监听的条件; 局外以配置为主导(map 全部档位),因为要显示完整面板包括没拿到的灰星—— 只按 mask 展开的话,mask=0 会返回空数组,一颗星都不显示。
动态生成 N 个 UI 档位,异步创建怎么防重入实战
场景是 20532 全量刚到触发一次创建,紧接着 20533 又到触发第二次。 第一次的 await 还没回来,ViewList.length 还是 0, 于是模板被复制两轮,3 档显示成 6 条。
解法是一个 IsCreatingStarItems 布尔锁 + try/finally。 但光加锁不够——被锁挡掉的那次更新会丢。 所以创建完成后在 finally 之外重新取一次最新数据统一刷一遍, 把期间的变化补上。
还有个相关的坑:prefab 里美术除了模板节点还预摆了静态档位做视觉参考, 不清掉会和动态生成的叠加。初始化时遍历容器全部隐藏,并在日志里打出子节点名列表, 美术加节点后一看日志就知道被隐藏了,不用来问。
多语言文本的回退策略细节
三级回退:多语言译文 → 编辑器中文原文(EdTid)→ 什么都不显示。 硬性要求是永远不能漏出 TowerStarDesc_xxx 这种 key。
实现上有个容易错的点: const localized = IsEmpty(tid) ? undefined : GetLocalText(tid); SetText(localized ?? edDesc)。 必须在 tid 为空时提前返回 undefined,不能直接 GetLocalText(tid) ?? edDesc—— 因为 GetLocalText('') 返回的是空串,'' 是 falsy 但不是 nullish, ?? 兜不住它,文本就会显示成空白。
「全量 + 增量」协议,客户端要做哪些防御概念
这套模式在这个模块里出现了两次(20532/20533 局内、37501/37503 局外),处理是同构的:
① 不假设全量一定先到。增量到达时如果 Model 还是空的,就用这条增量直接建 Model。
② 按业务主键「有则更新、无则插入」,而不是按下标。局内主键是 idx,局外是 dungeonId, 找到就替换、找不到就 push。
③ 每条路径都要 Emit 刷新事件。早期版本「Model 为空」那条提前 return 漏了 Emit, 导致偶发第一档不亮。
④ 这个模块还多一层补偿:20533 只带单档不刷 starsMask, 所以客户端自己从 starList 反算 mask,并 OR 上副本信息里的那份兜底。 这类「协议不够用所以客户端补」的代码一定要写清注释, 否则后来人看到「既读 starsMask 又自算」会以为冗余删掉一半。