爬塔

一期从 0 建:塔内星级判定展示 + 局外记录/排行; 二期把配置全量表驱动化(configTowerClimb* 系列表), 加塔、加层、加任务策划全部改表加行,客户端零代码。 星数数据用位掩码(starMask)传输,客户端自算 + 双源 OR 兜底。

一期从 0 建 starMask 位掩码 双协议:全量+增量 双源 OR 兜底 二期表驱动编辑器化

1. 工作清单

内容性质
爬塔一期:塔内星级判定与展示(20532 全量/20533 增量双协议)、结算星级、局外已得星数/总星数、红点从 0 建
爬塔二期:配置表驱动化(塔/层/任务全走 configTowerClimb* 表)、入口/信息/任务/排行系列 UI 完备重构 + 扩展

2. 理论:位掩码星级模型与表驱动配置

2.1 starMask:一关 N 星压进一个数字

概念载体说明
单关星级starMask(number 位掩码)第 idx 位为 1 = 第 idx 星达成;一关最多 TowerMaxStars 星
星档明细starList: [{idx, isTrue}]服务器下发的逐星明细,UI 逐星展示用
塔总星数逐关 CountStarsFromMask 累加bestRecords 里同塔各关记录的 starMask 累计
为什么用位掩码 3 星一关,100 关一塔——星档状态本质是「每星一个布尔」, 天然压位。协议只传一个 number,排行/红点/星数统计全是对 mask 的位运算。 代价是可读性,所以客户端统计和日志必须把 mask 翻译回明细 (见代码段的 detailList 明细日志)。

2.2 局内双协议 + 局外双源

1
进塔 20532 全量
TowerStarStart:starsMask + starList 明细,覆盖开局全部星档初始态
2
达成 20533 增量
TowerStarUpdate:只推单档 {idx, isTrue},客户端局部更新
3
读时 GetCurrentTowerStarMask
20533 不回推 starsMask——客户端按 starList 自算掩码,再 OR 通关数据兜底
为什么读时自算而不维护增量 mask 如果客户端维护「增量更新的 mask 缓存」,20533 只改单档不刷 starsMask, 缓存和服务器 mask 的一致性靠协议保证——一旦服务器某分支忘了推就永久漂移。 改成读时从 starList 现算(无缓存状态)+ OR 掉 20532/通关两个源, 任何一源先到/后到/重发都不影响收敛。这是「派生数据不落缓存」原则的典型应用。

2.3 二期表驱动:加内容不改代码

爬塔结构 = 塔(TowerId)→ 层(DungeonId 关卡)→ 星档任务(TaskType/TaskSubType)。 二期全部映射进配置索引:

索引键 → 值用途
TowerClimbInfoByDungeonId关卡 ID → 塔层配置IsInTowerClimbDungeon 进本即判(不依赖服务器下发时机)
TowerTaskType2SunTypesMap任务大类 → 子类 Set星档任务的归类展示
TowerClimbEntryByTowerId塔 ID → 入口列表入口 UI 按塔聚合
「编辑器化」的落点在配置索引 TowerClimb 目录没有任何 Scheme/编辑器扩展代码—— 二期的「编辑器化」= 所有结构关系进 configTowerClimb* 表 + OnInit 时构建倒排索引。 策划加第 11 座塔 = 配置表加几行,客户端不用动。 索引构建在 OnInit 一次完成,运行期全是 O(1) 查 Map。

3. 代码片段

3.1 CountStarsFromMask:位逐数星 + 上限防脏数据

Src\Game\Module\TowerClimb\TowerClimbModel.ts:299-338

/** 统计 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;
}

public GetRoleOwnTowerStars(roleId: number, towerId: number): number {
    const roleRecordInfo = this.TowerClimbRecordInfoMap!.get(roleId);
    if (!roleRecordInfo) { return 0; }
    let ownTowerStars = 0;
    const detailList: string[] = [];
    for (const recordInfo of roleRecordInfo.bestRecords) {
        const towerInfoCfg = ConfigManager.TowerClimbConfig!.GetTowerClimbInfoConfigByDungeonId(recordInfo.dungeonId);
        if (towerInfoCfg && towerInfoCfg.TowerId === towerId) {
            // 按 starMask 逐档累计,不能按"打过即满星"算
            const dungeonStars = this.CountStarsFromMask(recordInfo.starMask);
            ownTowerStars += dungeonStars;
            detailList.push(`${recordInfo.dungeonId}=mask${recordInfo.starMask}/${dungeonStars}星`);
        }
    }
    Log.Debug(ELogModule.TowerClimb, ELogAuthor.ZZB, '爬塔星级-局外取塔已得星数GetRoleOwnTowerStars',
        ['roleId', roleId], ['towerId', towerId],
        ['ownTowerStars', ownTowerStars], ['明细', detailList.join(',')],
    );
    return ownTowerStars;
}
三个细节 ① 循环上限是 TowerMaxStars(配置档数)不是 32—— 服务器 mask 脏数据高位有 1 也数不进去,统计口径以配置为准; ② 注释写明「不能按打过即满星算」——旧数据语义陷阱,review 时留下的解释; ③ 明细日志把每关 mask → 星数展开成字符串,排行榜数字对不上时一跳日志定位。

3.2 局内双协议收包:日志即数据还原

Src\Game\Module\Dungeon\DungeonController.ts:589-616

/** 副本开始爬塔星级监听:服务器全量下发星级初始数据 */
private readonly OnDungeonTowerStarStartNotify = (
    notify: Game.Protocol.WorldDungeonTowerStarStartSCNotify,
): void => {
    Log.Warn(ELogModule.TowerClimb, ELogAuthor.ZZB, '爬塔星级-局内收包20532 TowerStarStart',
        ['starCount', notify.info?.starList?.length ?? 0],
        ['starsMask', notify.info?.starsMask ?? 0],
        ['明细', (notify.info?.starList ?? []).map((s) => `idx${s.idx}=${s.isTrue}`).join(',')],
    );
    ModelManager.DungeonModel!.SetTowerStarInfo(notify.info);
};

/** 单档星级达成实时更新 */
private readonly OnDungeonTowerStarUpdateNotify = (
    notify: Game.Protocol.WorldDungeonTowerStarUpdateSCNotify,
): void => {
    Log.Warn(ELogModule.TowerClimb, ELogAuthor.ZZB, '爬塔星级-局内收包20533 TowerStarUpdate',
        ['idx', notify.starProgress?.idx],
        ['isTrue', notify.starProgress?.isTrue],
    );
    ModelManager.DungeonModel!.UpdateTowerStarProgress(notify.starProgress);
};
20532 全量 / 20533 增量 + 日志强度 进塔一次全量(掩码+明细),局内达成逐星增量。 收包日志用 Warn 级(比 Debug 高)——星级是结算敏感数据, 「玩家说结算星数不对」工单必须能在日志里完整还原当时协议内容, 所以两个包的 payload 逐字段全打。

3.3 GetCurrentTowerStarMask:自算 + 双源 OR 兜底

Src\Game\Module\Dungeon\DungeonModel.ts:433-449

/** 当前副本已达成星级掩码: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;
}
OR 是「取并集」的安全合并 两个数据源:局内 TowerStarInfo(20532/20533 维护)+ 通关 CurrentDungeonInfo.towerStarInfo。 星是只增不减的达成型状态(撤星走服务器整包重推), 所以 OR 合并永远安全——多源早晚到、重发,结果收敛一致。 mask 的字段直译 + starList 逐位重建双路都算, 即便服务器 starsMask 忘了更新,starList 明细也能兜回正确值。

3.4 进本即判:不依赖服务器下发时机

Src\Game\Module\Dungeon\DungeonModel.ts:451-458

/** 当前是否爬塔副本:按副本配置查爬塔表,进副本即可判定,不依赖服务器星级下发时机 */
public IsInTowerClimbDungeon(): boolean {
    const dungeonConfigId = this.CurrentDungeonInfo?.dungeonConfigId;
    if (!dungeonConfigId) { return false; }
    return ConfigManager.TowerClimbConfig?.GetTowerClimbInfoConfigByDungeonId(dungeonConfigId) !== undefined;
}
类型判定用配置不用协议 「这关是不是爬塔关」是静态事实(配置表说了算), 不等 20532 下发(那要等服务器判定完才推)。 UI/结算逻辑进本立刻知道该走爬塔分支, 消掉了「先按普通副本初始化、包到了再切爬塔」的时序窗口。

3.5 二期表驱动:OnInit 构建倒排索引

Src\Game\Module\TowerClimb\TowerClimbConfig.ts:30-57(节选)

protected OnInit(): boolean {
    const rankBaseCfg = configRankBaseByRankType.GetConfig(ERankType.Tower);
    const topN = rankBaseCfg !== undefined ? rankBaseCfg.TopN : 0;
    TowerClimbConfig.MaxTopRank = topN > 0 ? topN : 100;
    this.BuildTowerTaskType2SunTypeMap();
    this.BuildTowerRankDisplayRuleList();
    return true;
}

private BuildTowerTaskType2SunTypeMap(): void {
    const taskCfgList = configTowerClimbTaskAll.GetConfigList();
    if (!taskCfgList) { return; }
    for (const taskCfg of taskCfgList) {
        let sunTypeSet = this.TowerTaskType2SunTypesMap.get(taskCfg.TaskType);
        if (!sunTypeSet) {
            sunTypeSet = new Set<number>();
            this.TowerTaskType2SunTypesMap.set(taskCfg.TaskType, sunTypeSet);
        }
        sunTypeSet.add(taskCfg.TaskSubType);
    }
}
「加载期一次构建,运行期 O(1) 查询」 把「任务大类 → 子类集合」这类关系在 OnInit 扫一遍配置表构建成 Map。 策划改表重启即生效,加塔加层加任务零代码改动; MaxTopRank 也表驱动(configRankBase 查不到时兜底 100—— 兜底值显式出现在代码里,而不是散在用点)。

4. 面试怎么讲

爬塔的星级数据是怎么设计传输的设计
位掩码模型:单关星级压成 starMask(第 idx 位 = 第 idx 星), 配 starList 明细逐星展示。协议两条——20532 进塔全量(掩码+明细)、 20533 局内单档增量(只推 {idx, isTrue})。
读取时 GetCurrentTowerStarMask 现场从 starList 自算掩码, 再 OR 20532 和通关两个数据源兜底——派生数据不落缓存, 协议时序/重发/漏推都不影响收敛,因为星是只增状态,OR 合并永远安全。
二期「编辑器化」具体指什么改造工程
把爬塔结构关系(塔→层→星档任务)全部迁进 configTowerClimb* 系列表, OnInit 扫表构建倒排索引(DungeonId→塔层配置、TaskType→子类集合、TowerId→入口列表), 运行期全部 O(1) 查 Map。
收益:加第 11 座塔 = 策划配置表加几行,客户端零代码; 进本即判 IsInTowerClimbDungeon 用配置查表,不等服务器星级下发, 消掉了「按普通副本初始化再切爬塔」的时序窗口。
如果玩家反馈结算星数不对,你怎么排查难点
星级链路的收包日志我全部拉到 Warn 级并带完整 payload: 20532 打 starsMask + starList 逐星明细,20533 打单档 idx/isTrue, 局外 GetRoleOwnTowerStars 把每关「dungeonId=mask/星数」拼成明细串。
工单来了直接按 roleId+towerId 过滤日志, 协议推了什么、客户端算出什么、累计口径是什么三层全程可见, 不用复现。数星上限钳在配置 TowerMaxStars,服务器脏数据不会污染统计口径。