爬塔
按武将(角色)分塔、逐副本挑战、每副本按条件拿 1/2/3 星的长线玩法。
本页的重点是星级系统:从宇宙编辑器行为树里配达成条件,
到局内 HUD 实时点亮,再到局外历史最佳 starMask 位掩码存档与展示,
以及三条协议(20532 / 20533 / 37503)的接入。
1. 模块构成
grep
'爬塔星级-' 前缀能把星级链路的日志单独捞出来。
| 子系统 | 落点 | 所属链路 |
|---|---|---|
| 星级配置 | UniverseEditor · TowerStarConfig scheme | 星级 |
| 局内星级实时数据 | DungeonModel.TowerStarInfo(20532/20533) | 星级 |
| 局内 HUD | MainUiTaskPanel + TowerStarItem | 星级 |
| 局外历史最佳 | TowerClimbModel.TowerClimbRecordInfoMap(37501/37503) | 星级 |
| 局外星级展示 | GetTowerStarDisplayListByDungeon | 星级 |
| 塔入口 / 任务 / 排行榜 UI | TowerClimbEntryView / TaskView / RankView | 塔框架 |
| 爬塔子任务节点 | TowerClimbNode(49 行) | 塔框架 |
| 瞭望塔实体 | EtWatchtowerComponent(1029 行) | 塔框架 |
2. 星级系统全链路
EdTidDesc(中文原文)+ TidDesc(多语言 key)+ SubQuests(子任务条件)EAction.StartTowerStarListen,服务器执行(ImplementType: 'Server')starList[] = {idx, isTrue, condition} + starsMask。
存进 DungeonModel.TowerStarInfoDungeonTowerStarChangeMainUiTaskPanel 收事件 → GetCurrentTowerStarDisplayList 合并配置文本 → 点亮星星starMask 推下来,
增量更新 TowerClimbRecordInfoMapstarMask 逐位判定,配文本走同一套 ITowerStarDisplayItemITowerStarDisplayItem 是统一出口,局内 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
},
});
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 局内(实时星级)
| 号 | 名 | 方向 | 作用 |
|---|---|---|---|
| 20532 | WorldDungeonTowerStarStartSCNotify | S→C | 全量下发本副本星级档位。行为树 StartTowerStarListen 触发后推 |
| 20533 | WorldDungeonTowerStarUpdateSCNotify | S→C | 增量单档更新,某档条件达成时推 |
4.2 局外(历史最佳 + 任务 + 排行)
| 号 | 名 | 方向 | 作用 |
|---|---|---|---|
| 37501 | WorldTowerClimbDataSCNotify | S→C | 全量:所有武将的 bestRecords + 任务列表 |
| 37502 | WorldTowerClimbTaskSCNotify | S→C | 任务状态变更 |
| 37503 | WorldTowerClimbBestRecordSCNotify | S→C | 单副本最佳记录增量:通关后只推变化的那一条 |
| 37510 | WorldTowerClimbSelfRankListSCNotify | S→C | 自己在各塔的排名 |
| 37504 / 37505 | TaskList CSReq / SCRsp | C→S | 主动拉任务列表 |
| 37506 / 37507 | TaskTakeReward | C→S | 领奖 |
| 37508 / 37509 | SelfRankList | C→S | 拉排名 |
进入场景推全量建立基线,之后只推变化。
客户端两边的处理也对称——增量到达时「按主键找,有则替换,无则 push」, 不假设服务器一定先发过全量(见下节
UpdateTowerStarProgress 的空 Model 兜底)。
5. starMask 位掩码
5.1 语义
/** 场外历史最佳星级位掩码:按武将 roleId + 副本 dungeonId 取,
第 i 位=第 i 档达成;无记录返回 0 */
public GetDungeonBestStarMask(roleId: number, dungeonId: number): number
| mask | 二进制 | 含义 |
|---|---|---|
| 0 | 000 | 一颗星都没拿到(或无记录) |
| 1 | 001 | 只拿到第 0 档 |
| 5 | 101 | 拿到第 0、2 档,跳过第 1 档 |
| 7 | 111 | 满星 |
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;
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;
}
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 ?? '',
}));
}
局外以配置 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);
}
await 还没回来,TowerStarItemViewList.length 还是 0IsCreatingStarItems 布尔锁,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 之前就定死了。注释也专门写明了这一点。
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 加节点时, 一看日志就知道新加的东西被隐藏了,不用来问「摆的星星为什么不见了」。
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
// (两者是独立的服务器下发,副本信息可能晚到或缺)
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 组件:两个同构实现
工程里有两个功能几乎一样的星级单档组件,接口签名完全一致:
| 组件 | 路径 | 星控件类型 | 用在哪 |
|---|---|---|---|
| TowerStarItem | MainUi/Views/ChildView/ | UISprite | 局内 HUD |
| DungeonStarTimeItem | Dungeon/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);
}
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 名」的挫败感。
TowerClimbModel.SetTowerClimbSelfRankInfoListselfRankMap = TowerClimbSelfRankInfoMap.get(selfRankInfo.roleId),但存的时候是 set(selfRankInfo.towerId, selfRankMap)else 分支的「排行榜存在重复角色」Log.Error 形同虚设,且同塔多角色只会存进各自独立的 MapGetTowerClimbSelfRankInfo(towerId, roleId) 是按 towerId 取外层的,所以只有每个 towerId 第一条能读到10. 常见修改场景
10.1 给一个副本加星级
levelPlayDungeonScheme 才挂)EdTidDesc(必填)+ SubQuests 条件IsInTowerClimbDungeon 为 false,局外面板不显示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. 技术债清单
| # | 问题 | 影响 |
|---|---|---|
| 1 | SetTowerClimbSelfRankInfoList 外层 Map 读写 key 不一致(roleId 读 / towerId 写) | 同塔多角色排名只存第一条 |
| 2 | TowerStarItem 与 DungeonStarTimeItem 两份同构实现 | 改一处忘另一处 |
| 3 | DungeonStarTimeItem 残留两段注释掉的旧逻辑 | 可读性 |
| 4 | GetCurrentTowerStarDisplayList 里两段被注释掉的调试 Log.Warn | 该删或该降级成 Debug |
| 5 | 大量 Log.Warn 用于常规流程日志(收包、更新) | 线上 Warn 噪音,稳定后应降 Debug |
| 6 | scheme Tip 写「仅副本可配」但无运行时校验 | 只能靠挂载位置约束,策划复制到普通玩法树不会报错 |
| 7 | TowerStarConfig 双 scheme(编辑真实 / 校验空壳) | 已引发过误报事故,长期应统一 |
| 8 | GetTowerClimbTaskTypeRegByRoleId 每次都 find 全表 | 表小可忽略,但可以在 OnInit 建 Map |
12. 面试怎么讲
如果只存「2 星」这个数字,就丢失了「丢的是哪一档」的信息,UI 上没法准确点亮对应星星。 用
starMask 的第 i 位表示第 i 档,mask=5(101)就精确表达了「拿到 0、2 档」。统计星数时有个细节:循环上界用配置的
tower_max_stars 而不是扫满 32 位。
服务器万一下发脏数据 mask=255,全扫会算出 8 星,
让界面显示出「12/9 星」这种不可能的数字。用配置封顶,保证「已得 ≤ 总数」这个不变式在客户端永远成立。
ITowerStarDisplayItem { Idx, IsAchieved, TidDesc, EdDesc }。
局内和局外各有一个函数返回它,差别只在 IsAchieved 从哪来——
局内读服务器实时下发的 starList[i].isTrue,局外读 starMask 的第 i 位。这样 UI 组件只认一个
UpdateByProgress(isAchieved, tid, ed),
在 HUD、匹配面板、塔详情、结算界面四个地方复用。有个不对称的地方值得说:两个函数的「主导方」不同。 局内以服务器 starList 为主导(filter 掉配置里没有的档),因为要显示的是服务器正在监听的条件; 局外以配置为主导(map 全部档位),因为要显示完整面板包括没拿到的灰星—— 只按 mask 展开的话,mask=0 会返回空数组,一颗星都不显示。
await 还没回来,ViewList.length 还是 0,
于是模板被复制两轮,3 档显示成 6 条。解法是一个
IsCreatingStarItems 布尔锁 + try/finally。
但光加锁不够——被锁挡掉的那次更新会丢。
所以创建完成后在 finally 之外重新取一次最新数据统一刷一遍,
把期间的变化补上。还有个相关的坑:prefab 里美术除了模板节点还预摆了静态档位做视觉参考, 不清掉会和动态生成的叠加。初始化时遍历容器全部隐藏,并在日志里打出子节点名列表, 美术加节点后一看日志就知道被隐藏了,不用来问。
TowerStarDesc_xxx 这种 key。实现上有个容易错的点:
const localized = IsEmpty(tid) ? undefined : GetLocalText(tid); SetText(localized ?? edDesc)。
必须在 tid 为空时提前返回 undefined,不能直接 GetLocalText(tid) ?? edDesc——
因为 GetLocalText('') 返回的是空串,'' 是 falsy 但不是 nullish,
?? 兜不住它,文本就会显示成空白。
① 不假设全量一定先到。增量到达时如果 Model 还是空的,就用这条增量直接建 Model。
② 按业务主键「有则更新、无则插入」,而不是按下标。局内主键是
idx,局外是 dungeonId,
找到就替换、找不到就 push。③ 每条路径都要 Emit 刷新事件。早期版本「Model 为空」那条提前 return 漏了 Emit, 导致偶发第一档不亮。
④ 这个模块还多一层补偿:20533 只带单档不刷
starsMask,
所以客户端自己从 starList 反算 mask,并 OR 上副本信息里的那份兜底。
这类「协议不够用所以客户端补」的代码一定要写清注释,
否则后来人看到「既读 starsMask 又自算」会以为冗余删掉一半。