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