首页 / 横向专题 / 面试问答库
面试问答库
全部答案来自这套工程的真实实现,不是背的八股。 每题标了难度:L1 讲清楚是什么、 L2 讲清楚为什么这么设计、 L3 讲清楚踩过什么坑怎么修的。
怎么用这一页
不要按顺序背。 面试官问的是「做过什么」,
要能从任意一个模块切进去,讲成「背景 → 怎么做的 → 遇到什么问题 → 怎么定位 → 最后怎么解」。
第 9 章准备了 5 个可以完整讲 5 分钟的故事,那才是真正要练的。
第 9 章准备了 5 个可以完整讲 5 分钟的故事,那才是真正要练的。
1. 项目概述
简单介绍一下这套客户端的模块划分L1
一个 UE5 + puerts 的多人游戏项目,本站覆盖八个功能方向,可以分成三类:
① 位置与场景切换:传送、传送点、坠落重生、边界检查重生。 这四个的共同点是都在动玩家位置,都要处理「服务器权威位置」和「客户端表现」的同步, 也都会撞上 UE5 世界分区(WP)的流送时序。
② 关卡玩法:关卡子弹、计时器玩法。 这两个是给策划用的工具——策划在宇宙编辑器里配,运行时按配置执行。
③ 副本流程:副本结算流程、爬塔。 结算是三阶段镜头 + MVP 特写 + 全家福合影; 爬塔是星级条件系统,局内实时 HUD + 局外存档展示。
最有代表性的是传送,它是全项目最长的一条链路, 经历过一次协议流程重构,累计修过至少 8 个线上问题。
① 位置与场景切换:传送、传送点、坠落重生、边界检查重生。 这四个的共同点是都在动玩家位置,都要处理「服务器权威位置」和「客户端表现」的同步, 也都会撞上 UE5 世界分区(WP)的流送时序。
② 关卡玩法:关卡子弹、计时器玩法。 这两个是给策划用的工具——策划在宇宙编辑器里配,运行时按配置执行。
③ 副本流程:副本结算流程、爬塔。 结算是三阶段镜头 + MVP 特写 + 全家福合影; 爬塔是星级条件系统,局内实时 HUD + 局外存档展示。
最有代表性的是传送,它是全项目最长的一条链路, 经历过一次协议流程重构,累计修过至少 8 个线上问题。
这些模块里技术含量最高的是哪个L2
传送。因为它是唯一一个要同时协调四方的:
服务器(位置权威、状态机)、UE 的 WP 流送(地形什么时候可用)、 表现层(黑屏、相机、动画)、输入层(什么时候解锁移动)。
任意两方的时序错一点,症状就完全不同:
· 解锁早于地形就绪 → 穿地下坠
· 黑屏解得早于相机碰撞恢复 → 穿模裁剪
· LoadDone 漏发 → 服务器永远认为这个玩家不可主控
· 流送判定卡死 → 一分钟后地形卸载,人掉下去
这些问题共同点是「都不报错」,只能靠把多条时间线的日志摆在一起看。
服务器(位置权威、状态机)、UE 的 WP 流送(地形什么时候可用)、 表现层(黑屏、相机、动画)、输入层(什么时候解锁移动)。
任意两方的时序错一点,症状就完全不同:
· 解锁早于地形就绪 → 穿地下坠
· 黑屏解得早于相机碰撞恢复 → 穿模裁剪
· LoadDone 漏发 → 服务器永远认为这个玩家不可主控
· 流送判定卡死 → 一分钟后地形卸载,人掉下去
这些问题共同点是「都不报错」,只能靠把多条时间线的日志摆在一起看。
2. 引擎与语言层
puerts 是什么?TS 写游戏逻辑有什么坑L1
puerts 是 UE 和 JS/TS 的双向绑定层,
四个必须知道的坑:
① 出参要用
② int64 不能直接当 number 用——协议里的时间戳、大 ID 是
③ UObject 可能已经被 GC 了——TS 侧持有的引用不阻止 UE 销毁对象, 用之前要
④ 跨语言调用有开销——Tick 里每帧调几十次 UE 接口会明显掉帧, 能缓存的结果要缓存。
import * as UE from 'ue' 就能直接调 C++ 类型。四个必须知道的坑:
① 出参要用
$ref——C++ 的 Out 参数在 TS 里不能直接传变量,
要 const box = $ref(new UE.FVector()); Foo(box); $unref(box)。② int64 不能直接当 number 用——协议里的时间戳、大 ID 是
Long 对象,
必须 MathUtils.LongToNumber 转,直接拿去算会得到 NaN 或对象拼接。③ UObject 可能已经被 GC 了——TS 侧持有的引用不阻止 UE 销毁对象, 用之前要
UE.KismetSystemLibrary.IsValid()。「上一帧还好好的」在这里不成立。④ 跨语言调用有开销——Tick 里每帧调几十次 UE 接口会明显掉帧, 能缓存的结果要缓存。
为什么项目里禁止用
number | undefinedL2
这是项目自定义的 eslint 规则
本质是 V8 的隐藏类(hidden class): 一个字段一会儿是 number 一会儿是 undefined,V8 会认为对象形状变了, 之前编译好的内联缓存失效,反优化回解释执行。 在每帧跑的逻辑里这个代价很实在。
同一组规则里还有
实践上的替代:用哨兵值(
no-number-or-undefined,报错文案就写着
「会有隐藏类型切换,导致性能问题」。本质是 V8 的隐藏类(hidden class): 一个字段一会儿是 number 一会儿是 undefined,V8 会认为对象形状变了, 之前编译好的内联缓存失效,反优化回解释执行。 在每帧跑的逻辑里这个代价很实在。
同一组规则里还有
no-uninitialized-vars(成员变量必须显式初始化)——
同一个原因,未初始化字段第一次赋值时会改变对象形状。实践上的替代:用哨兵值(
-1、0),
或者用一个独立的 boolean 标记「有没有值」。
六个 Tick 阶段有什么区别,怎么选L3
ControllerBase 暴露六个 Tick,对应 UE 的 TickGroup:
PreWorldActor / PrePhysics / DuringPhysics /
EndPhysics / PostPhysics / PostUpdateWork。选择原则一句话:写位置用 PrePhysics,读物理结果用 PostPhysics。
选错的症状很典型:在
PostPhysics 里改角色位置,
这一帧的物理已经算完了,改动要等下一帧才被消费——
表现出来就是抖动或者慢半拍,而且很难联想到是 Tick 阶段的问题。项目里贴地检测用的是
PostPhysics——
需要拿到物理算完之后的真实落地状态;
而传送解锁后的位置修正放 PrePhysics。
3. 框架与架构
项目的分层是怎样的L1
业务层是 MVC + 三个静态单例注册表:
实体层是 ECS:
上面还有宇宙编辑器(
ConfigManager(47 个 Config,配置表加工与索引)→
ModelManager(136 个 Model,服务器数据缓存)→
ControllerHolder(192 个 Controller,逻辑与协议收发)。实体层是 ECS:
@RegisterComponent(EComponent.Xxx) 注册组件,
六阶段生命周期(OnCreate / OnInit / OnStart / OnActivate / OnDeactivate / OnEnd)。上面还有宇宙编辑器(
Src/UniverseEditor/),
策划用它配关卡玩法。它是独立的一套代码,用 gulp 构建,不走 npm compile。
为什么 Model 声明成可空、Controller 声明成非空L2
public static TowerClimbModel: TowerClimbModel | undefined = undefined;
public static TowerClimbController: TowerClimbController = undefined!;
因为生命周期不一样:Controller 从进游戏活到退出,中间不会被销毁,
所以直接断言非空,业务代码不用到处判空;Model 会在切场景、换模式时被销毁重建, 所以必须可空,用的时候
ModelManager.XxxModel?.。这个约定的价值在于类型系统直接告诉调用方「这东西可能没有」, 不用去翻文档。反过来说,如果哪天有人把 Model 改成非空, 就会在切场景的间隙出现难查的空指针。
Model 的
OnLeaveLevel 和 OnClear 分别清什么L3OnLeaveLevel 清关卡级数据(这一局的怪、这一局的星级进度、定时器),
OnClear 清账号级(登出时才清)。分不清的后果是真实发生过的:爬塔星级数据只在
OnClear 里清,
结果换一个副本进去,上一局的星级还亮着——
因为换关卡不触发 OnClear。判断方法:问一句「不退出游戏,重新进一次这个玩法,这个数据应该是新的还是旧的」。 应该是新的就放
OnLeaveLevel。定时器两个都要清——残留定时器会在下一局突然触发,症状极难定位。
事件系统为什么用弱引用?会带来什么问题L3
用弱引用是为了避免忘记 Remove 导致对象永远不被回收——
UI 面板关了但事件还挂着,面板对象就泄漏了。
代价是「事件处理方法已被回收」这个报错:
代价是「事件处理方法已被回收」这个报错:
// ❌ 匿名箭头函数没人持有,下一次 GC 就没了
EventSystem.Add(EEventName.X, () => { this.Refresh(); });
// ❌ bind 每次返回新对象,Remove 时传的是另一个,摘不掉
EventSystem.Add(EEventName.X, this.OnX.bind(this));
// ✅ 箭头函数属性,被 this 持有,且 Add/Remove 传的是同一个对象
private readonly OnX = (): void => { this.Refresh(); };
所以项目里回调一律写成 private readonly 箭头函数属性,
这不是风格问题,是正确性问题。
怎么防止事件循环派发L3
框架用
业务侧的解法是「脏标记 + 延迟一帧」: 收到变化时只置标记不立刻刷新,起一个
EmittingMask 位掩码:派发某事件时先置位,
如果在处理过程中又派发同一个事件,检测到位已置就打 Critical 日志并拦截。业务侧的解法是「脏标记 + 延迟一帧」: 收到变化时只置标记不立刻刷新,起一个
TimerSystem.Next 在下一帧统一刷。
这样同一帧内的 N 次触发合并成 1 次,顺便还解决了性能问题。MainUiTaskPanel 就是这么写的,
用位掩码记「哪些部分脏了」,下一帧只刷脏的那部分。
4. 网络与协议
协议怎么分类,客户端怎么用L1
三类:
命名规律:
SCNotify(服务器单向推,284 条)→ Net.Register / Net.UnRegisterCSReq + SCRsp(请求应答,252 条)→ Net.Call / Net.CallAsyncCSPush(客户端单向推,1 条)→ Net.Push命名规律:
World + 模块 + 动作 + 后缀,Req 和 Rsp 连号(14716/14717)。Protocol.d.ts 是 proto 自动生成的,手改会被下次生成覆盖。
收到一个协议包,客户端要做哪些防御L2
四条,每条都有过因为漏掉而出的线上 bug:
① Model 可能是空的——切场景间隙收到包,
② 字段可能不存在——proto3 的 optional 字段没赋值时不下发,
③ 增量要按主键「有则更新、无则插入」,不能假设顺序——
④ 写完 Model 必须 Emit 事件—— 最容易漏的是提前 return 的分支: 「数据没变化,直接 return」,结果 UI 就永远不刷了。
① Model 可能是空的——切场景间隙收到包,
ModelManager.XxxModel 是 undefined。② 字段可能不存在——proto3 的 optional 字段没赋值时不下发,
notify.starList 可能是 undefined 而不是空数组。③ 增量要按主键「有则更新、无则插入」,不能假设顺序——
findIndex 找到就替换,找不到才 push。④ 写完 Model 必须 Emit 事件—— 最容易漏的是提前 return 的分支: 「数据没变化,直接 return」,结果 UI 就永远不刷了。
同场景传送和跨场景传送的 LoadDone 有什么区别L3
是两个不同的协议,用错会让服务器状态机对不上:
·
·
服务器靠这个包决定「这个玩家能不能被设为主控」。 漏发的后果是玩家复活后永远操控不了角色—— 真实案例:REVIVE 分支里有个提前 return, 把后面的 LoadDone 发送跳过了。
客户端侧的可靠性设计:退避重试 5s/5s/10s/20s/40s,共 5 次窗口 80 秒, 靠
·
WorldClientSameSceneLoadDoneCSReq = 14716(回包 14717)—— 同一个场景内的瞬移·
WorldClientLoadDoneCSReq = 14525 —— 跨场景加载完成服务器靠这个包决定「这个玩家能不能被设为主控」。 漏发的后果是玩家复活后永远操控不了角色—— 真实案例:REVIVE 分支里有个提前 return, 把后面的 LoadDone 发送跳过了。
客户端侧的可靠性设计:退避重试 5s/5s/10s/20s/40s,共 5 次窗口 80 秒, 靠
serverMoveVersion 做幂等去重,
任一取消条件(收到回包、版本过期、离开场景)命中就停。
要删掉一个废弃协议,怎么做才干净L2
以
客户端侧要动三处:
关键的坑:
PreServerMove 的三条协议(15504/15505/15506)为例。客户端侧要动三处:
NetDefine.ts 的枚举、
所有 Net.Register / Net.UnRegister 调用点、
相关的 Model 字段和处理函数。关键的坑:
Protocol.d.ts 是 proto 生成的,
客户端单方面删掉,下次跑生成脚本会原样写回来。
所以必须同步协调服务器删 proto 定义,
在删除的地方留一句注释说明这件事,否则下一个人会以为是误删又加回来。
5. UE5 世界分区(WP)相关
WP 流送和传送有什么关系L2
大世界地形不是一次性全加载的,是按 Cell 网格动态流送。
传送到一个远处的点,那里的地形可能还没加载——
这时候如果解锁了玩家移动,人就直接穿地掉下去了。
所以传送流程里必须等流送完成。问题是「完成」的判定不可靠, 这是下面两个大坑的共同来源。
所以传送流程里必须等流送完成。问题是「完成」的判定不可靠, 这是下面两个大坑的共同来源。
讲讲
IsStreamingCompleted 假阳性那个 bugL3
现象:无缝传送解锁后,玩家偶发穿地下坠。
定位:日志显示流送判定已经返回 completed 了,位置也对,就是踩空。
根因:UE 的
修法:不信引擎的判定,自己在落点周围打 3×3 射线做贴地预检, 真的打到地面才解锁。这个方案的好处是它验证的正是真正关心的东西—— 不是「引擎认为加载好了」,而是「这里真的踩得住」。
方法论:当引擎 API 的语义和实际需求不完全一致时, 直接验证真正关心的那个条件。
定位:日志显示流送判定已经返回 completed 了,位置也对,就是踩空。
根因:UE 的
IsStreamingCompleted 判的是 Cell 状态 = Activated,
但 Activated 只代表「Actor 已经 spawn 出来了」,
不代表碰撞体(collision)已经构建完成。中间有个窗口期,
射线能穿过去。修法:不信引擎的判定,自己在落点周围打 3×3 射线做贴地预检, 真的打到地面才解锁。这个方案的好处是它验证的正是真正关心的东西—— 不是「引擎认为加载好了」,而是「这里真的踩得住」。
方法论:当引擎 API 的语义和实际需求不完全一致时, 直接验证真正关心的那个条件。
「传送后一分钟地形消失」是怎么回事L3
这是全站根因链最长的一个。
现象:同图传送后一切正常,大约一分钟后脚下地形突然卸载,人掉下去。
「固定时长」是最大的线索——固定时长基本等于「某个超时或某个定时清理」。
根因链:
1. WP 流送的
2.
3. 它卡在命令队列头部,后面所有 Cmd 被饿死
4. 其中包括
5. 玩家灯没交接到新位置,落点周围的 cell 没有源持有
6. 到了卸载周期,这些 cell 被回收 → 地形消失 → 坠落
修法(治本 + 双保险):
· 给
· 加 3×3 贴地检测作为独立的第二道保险
方法论:任何「等待」都必须有超时。 队列式架构里,一个无超时的等待就是一颗雷。
现象:同图传送后一切正常,大约一分钟后脚下地形突然卸载,人掉下去。
「固定时长」是最大的线索——固定时长基本等于「某个超时或某个定时清理」。
根因链:
1. WP 流送的
IsStreamingCompleted 永久返回 false(这次是真卡住不是假阳性)2.
SceneWaitWpStreamingCmd 是个没有超时的等待命令,永远不完成3. 它卡在命令队列头部,后面所有 Cmd 被饿死
4. 其中包括
MoveCmd——它负责「玩家灯」的交接5. 玩家灯没交接到新位置,落点周围的 cell 没有源持有
6. 到了卸载周期,这些 cell 被回收 → 地形消失 → 坠落
修法(治本 + 双保险):
· 给
SceneWaitWpStreamingCmd 加 10 秒超时放行——
单点卡死不能饿死整个队列· 加 3×3 贴地检测作为独立的第二道保险
方法论:任何「等待」都必须有超时。 队列式架构里,一个无超时的等待就是一颗雷。
6. 模块深度题
传送
「黑屏残留 13 秒」是怎么定位的L3
现象:换落点后黑屏一直不消失,大概十几秒后自己好了。
第一步是量化:不要接受「十几秒」这种描述, 从日志里量出来是 13 秒——然后去找代码里有没有一个 20 秒的超时 (13 秒是从某个中间点开始算的)。找到了:黑屏有个兜底超时。
这说明黑屏不是「正常解除」的,是被超时兜底强行解的, 也就是解除回调根本没被调用。
根因:
方法论:看到
第一步是量化:不要接受「十几秒」这种描述, 从日志里量出来是 13 秒——然后去找代码里有没有一个 20 秒的超时 (13 秒是从某个中间点开始算的)。找到了:黑屏有个兜底超时。
这说明黑屏不是「正常解除」的,是被超时兜底强行解的, 也就是解除回调根本没被调用。
根因:
SetResultCallback 是覆盖语义不是追加语义。
Helper 里设了一个解黑屏的回调,SceneLoadingController 后面又调了一次
SetResultCallback,把前面那个直接冲掉了。方法论:看到
SetXxxCallback 先确认它是覆盖还是追加。
黑屏/遮罩残留类问题,第一顺位查「解除回调有没有被覆盖」,
不要一上来就扎进渲染帧。
为什么会「卡在 Seq2 循环播放」L3
现象:map 主动传送时卡在过场动画,循环播。
根因:代码用了
修法:把 Once 的注册时机挪到
方法论:
根因:代码用了
Once 监听 EndClientLoading 事件,
但传送流程里会先加载一个过场中转关卡,
这个加载自己也会派发一次 EndClientLoading——
把 Once 提前消费掉了。真正的目标关卡加载完时,已经没人在听了。修法:把 Once 的注册时机挪到
LoadTransitionLevelAsync 之后,
避开中转关卡的那一次派发。方法论:
Once 监听的风险在于「以为的第一次」不是「真正的第一次」。
用 Once 之前先想清楚:从注册到目标事件之间,还有谁会派发同一个事件。
reason 有那么多种,怎么管理分支L2
传送有 9 种 reason(map 主动 / 复活 / GM / 集合 / 交互 / ACTION / DROP_RESET / 被动 BLACK / ROLLBACK),
每种的表现来源不一样。
有效的做法是先做一张 reason → 走哪条路 → 表现来源的对照表, 写进文档。因为这类分支代码读起来是「一堆 if 嵌套」, 但真正要回答的问题永远是「这个 reason 应该长什么样」。
关键设计:不是所有 reason 都由传送模块接管表现。 比如 REVIVE,传送模块识别到之后主动不接管,交回复活链路—— 否则两条链路都在改相机和黑屏,互相打架。
有效的做法是先做一张 reason → 走哪条路 → 表现来源的对照表, 写进文档。因为这类分支代码读起来是「一堆 if 嵌套」, 但真正要回答的问题永远是「这个 reason 应该长什么样」。
关键设计:不是所有 reason 都由传送模块接管表现。 比如 REVIVE,传送模块识别到之后主动不接管,交回复活链路—— 否则两条链路都在改相机和黑屏,互相打架。
副本结算
结算的全家福镜头,相机距离怎么算L3
先用
坑在于必须算两次:
· 用水平 FOV 算装下宽度需要的距离
· 用垂直 FOV 算装下高度需要的距离
· 取两者较大值
只算一个轴的话,另一个轴会出框—— 四人队伍时宽度是瓶颈,两人队伍时高度可能是瓶颈, 所以只用一个轴在某些队伍配置下必然穿帮。 再加一个留白系数防止贴边。
GetActorArrayBounds 取全队 ProxyActor 的整体包围盒,
再根据相机 FOV 反算「要装下这个盒子,相机得退多远」。坑在于必须算两次:
· 用水平 FOV 算装下宽度需要的距离
· 用垂直 FOV 算装下高度需要的距离
· 取两者较大值
只算一个轴的话,另一个轴会出框—— 四人队伍时宽度是瓶颈,两人队伍时高度可能是瓶颈, 所以只用一个轴在某些队伍配置下必然穿帮。 再加一个留白系数防止贴边。
「名字和模型对不上」是什么问题L3
经典的两个数组共用一个下标。
场景里的 ProxyActor 是按站位序排的(体型半径累加,从中间往两边铺), UI 上的名字是按队伍成员序排的。 代码里两个数组都用
修法:不用下标对齐,用 uid 作为主键对齐。 把
方法论:两个数组要对应, 永远用业务主键(uid / id)关联,不要用下标—— 下标相等是一个「碰巧成立的假设」,任何一边改了排序规则就崩。
场景里的 ProxyActor 是按站位序排的(体型半径累加,从中间往两边铺), UI 上的名字是按队伍成员序排的。 代码里两个数组都用
i 遍历,只要两个顺序不一致就错位。修法:不用下标对齐,用 uid 作为主键对齐。 把
GetSettlementPositionOrder 改成返回成员序索引,
建一个 ProxyActorByUid 的映射。方法论:两个数组要对应, 永远用业务主键(uid / id)关联,不要用下标—— 下标相等是一个「碰巧成立的假设」,任何一边改了排序规则就崩。
「设置了可见但角色不显示」L3
QA 报的是「
某个副本缺
两个教训:
① 「不显示」类问题第一句话要问「相机视角对不对」, 能一句话排除一半可能性。
② 资源加载失败必须打 Error 日志。 静默失败会把「资产问题」伪装成「逻辑 bug」, 让人花几倍时间在代码里找不存在的问题。
SetActorVisible=true 但角色不显示」,
听起来像可见性 bug,实际是相机根本没切过去。某个副本缺
Seq_DungeonSettlement_20100201_3 这个资产,
切 Camera3 的代码加载失败但没报错,静默返回了——
相机还停在上一个位置,角色确实显示了,只是不在画面里。两个教训:
① 「不显示」类问题第一句话要问「相机视角对不对」, 能一句话排除一半可能性。
② 资源加载失败必须打 Error 日志。 静默失败会把「资产问题」伪装成「逻辑 bug」, 让人花几倍时间在代码里找不存在的问题。
爬塔
星级用位掩码存有什么好处和风险L2
starMask 第 i 位为 1 表示第 i 档达成。好处是一个 int 表达任意组合,
而且档位之间不要求递进(可以只达成第 3 档不达成第 1 档)。风险:脏数据会让统计出的星数超过配置档位数。 所以
CountStarsFromMask 里做了封顶——
按 tower_max_stars 截断,宁可少显示也不显示不可能的值。
增量协议不刷聚合字段怎么办L3
服务器的设计是:
结果就是达成了一档,UI 上的整体星数不变。
解法(双源 OR 反算):客户端不直接用服务器的 mask, 而是
方法论:和服务器的字段契约有缺口时, 优先在客户端做「能自洽的反算」,而不是等服务器改—— 改协议要双边排期,反算当天就能上,而且反算逻辑本身也是一层校验。
20532 下发全量星级信息(含整体 mask),
20533 下发单档更新——但 20533 不重新下发整体 mask。结果就是达成了一档,UI 上的整体星数不变。
解法(双源 OR 反算):客户端不直接用服务器的 mask, 而是
GetCurrentTowerStarMask() = 全量基线 mask OR 所有收到的增量位。
这样不管服务器刷不刷,客户端算出来的都是对的。方法论:和服务器的字段契约有缺口时, 优先在客户端做「能自洽的反算」,而不是等服务器改—— 改协议要双边排期,反算当天就能上,而且反算逻辑本身也是一层校验。
「星级档位显示翻倍」L3
HUD 的档位 item 是运行时按数量
在
修法三件套:
方法论:任何带
CopyItem 复制出来的,
CopyItem 是 async。在
await 期间又收到一次数据变化,创建函数被重入,
第二次进来时 ItemList.length 还是旧的,于是又创建了一遍。修法三件套:
IsCreatingStarItems 锁防重入 +
NeedRefreshAfterCreate 脏标记记住「创建完还要再刷一次」+
finally 里解锁(异常也不会永久卡死)。方法论:任何带
await 的函数都要假设它会被重入。
只加锁不加补刷会漏掉最后一次更新,只加补刷不加锁会翻倍,
不用 finally 解锁会在异常后永久失效——三样缺一不可。
关卡子弹
15 位 TypeId 是怎么设计的L2
定长分段拼装:6 位 + 5 位 + 3 位 + 1 位,
每段有独立含义,拼起来就是唯一的子弹类型 id。
好处是可以从 id 反查出各段配置,不需要额外的映射表; 排查时看到一个 id 就能拆出它是哪套配置。
代价是定长约束很硬——某一段超出位数会静默污染相邻段。 所以拼装函数里要做范围校验。
好处是可以从 id 反查出各段配置,不需要额外的映射表; 排查时看到一个 id 就能拆出它是哪套配置。
代价是定长约束很硬——某一段超出位数会静默污染相邻段。 所以拼装函数里要做范围校验。
为什么 grep「Bullet」找不到子弹协议L3
因为关卡子弹没有专属协议,它借用环境怪(EnvMonster)系统做中转——
子弹在服务器侧就是一种特殊的环境怪。
这个设计的合理性:复用了已有的实体同步、生命周期管理、 权威判定,不用为子弹再造一套网络层。
但对排查很不友好:按功能名 grep 找不到东西。 所以文档里专门标了这一条—— 找不到协议时,先怀疑「这个功能是不是复用了别的系统」, 而不是怀疑自己 grep 错了。
这个设计的合理性:复用了已有的实体同步、生命周期管理、 权威判定,不用为子弹再造一套网络层。
但对排查很不友好:按功能名 grep 找不到东西。 所以文档里专门标了这一条—— 找不到协议时,先怀疑「这个功能是不是复用了别的系统」, 而不是怀疑自己 grep 错了。
计时器与重生
「没勾自动开始却在计时」L3
BombTimerModel.GetDisplayTime 完全无视 status,
一律按墙钟(当前服务器时间 - 起始时间戳)推进;
GetDisplayTimerName 也只过滤了 Stopped,没过滤 Paused。修法:改成白名单——只有
status === Running 才加 timeDiff。方法论:状态过滤永远用白名单(
=== Running)
不用黑名单(!== Stopped)。
黑名单在加新状态时会静默漏掉,白名单会在加新状态时强制显式处理。另外这个 case 还有一半在服务器:如果服务器下发的
status 本身就是 Running,
那是服务器忽略了 AutoStart 配置。客户端修完要同步确认服务器侧,
否则只是把症状盖住了。
边界重生那个 bug 为什么说「一个现象三个 bug」L3
现象是「边界重生的提示文本错误 / 重复」,查下来是三个独立问题叠在一起:
① 脏数据:测试地图导出的文本带
② 死代码:代码里写了「EdTid 找不到就回退到 Tid」, 但 EdTid 运行时根本不导出,这个回退分支永远走不到, 也就是说兜底逻辑看起来有实际没有
③ 时序:
方法论:修完一个原因先别急着收, 问一句「这能解释全部现象吗」。 如果只能解释一部分,说明还有别的 bug 藏在同一个症状下面。
① 脏数据:测试地图导出的文本带
test/ 前缀② 死代码:代码里写了「EdTid 找不到就回退到 Tid」, 但 EdTid 运行时根本不导出,这个回退分支永远走不到, 也就是说兜底逻辑看起来有实际没有
③ 时序:
DoSimpleRespawn 中间会插一段黑屏,
把本来一次显示的文本劈成了两段,看起来像重复方法论:修完一个原因先别急着收, 问一句「这能解释全部现象吗」。 如果只能解释一部分,说明还有别的 bug 藏在同一个症状下面。
7. 宇宙编辑器
Scheme 是什么L1
Scheme 是「一个配置项在编辑器里长什么样」的元数据描述:
字段类型、中文名、提示文案、默认值、下拉选项、校验规则、渲染方式。
策划在宇宙编辑器里看到的每一个输入框,背后都有一个 Scheme。 写 Scheme 本质上是在写策划的说明书——
策划在宇宙编辑器里看到的每一个输入框,背后都有一个 Scheme。 写 Scheme 本质上是在写策划的说明书——
CnName 和 Tip 写清楚,能省掉大量沟通。
给已有节点加一个可选参数,要注意什么L3
真实案例:「退出副本 / 传送副本 / 传送关卡」三个节点加过传送表现参数
(进入/离开分别选飞天遁地或淡入淡出)。
四个关键点:
① 必须
② Scheme 抽出来共享——三个节点用同一个
③
④ 最重要:executor 侧要真的把参数透传下去。
第 ④ 点当时就漏了:同一个 action 注册了 player 版和 entity 版两套 executor, 只在 player 版里透传了 ShowInfo, 结果通过交互触发的走 entity 版,配了没生效。
加参数之后一定要 grep 这个 action 的所有注册点。
四个关键点:
① 必须
Optional: true——
否则全项目已有的配置全部变成「缺字段」报错。② Scheme 抽出来共享——三个节点用同一个
teleportShowScheme,
以后加第三种表现只改一处。③
CreateDefault 返回默认值——不配等同于旧行为,向后兼容。④ 最重要:executor 侧要真的把参数透传下去。
第 ④ 点当时就漏了:同一个 action 注册了 player 版和 entity 版两套 executor, 只在 player 版里透传了 ShowInfo, 结果通过交互触发的走 entity 版,配了没生效。
加参数之后一定要 grep 这个 action 的所有注册点。
「非法字段」报错怎么查L3
第一顺位怀疑编辑用的 Scheme 和校验用的 Scheme 不是同一个,
而不是怀疑数据脏。
真实实例:
所以看到「非法字段」,先确认这两个 scheme 是不是同一个对象。
真实实例:
TowerStarConfig 在
levelPlayScheme(校验侧)里是 IgnoreAllFields 的空壳,
在 levelPlayDungeonScheme(编辑侧)里被覆盖成真实 scheme。
两边不一致时,校验侧的 OnBaseFix 会走到这段:if (!fields[key] && !key.startsWith('_') && !scheme.IgnoreAllFields) {
keysToRemove.push(key); // 然后 delete,只打个 warn
}
它不只是报错,是会真的把字段删掉——策划配的东西静默消失。所以看到「非法字段」,先确认这两个 scheme 是不是同一个对象。
编辑器代码怎么编译L2
Src/UniverseEditor/** 走 gulp,不走 npm run compile,
输出到 JavaScript_Raw。两条流水线的产物目录不同,所以只跑运行时那条时控制台一切正常、编辑器里却完全没变化——
JavaScript_Raw 根本没更新。日常开发用
gulp watch,另有 check_circular 做循环依赖检查。
Game 层能不能 import 编辑器代码L2
只能 import
原因很实际:
需要编辑器侧的数据怎么办?让业务方查表拿到值,然后作为参数传进来, 不要让底层反向去查编辑器。
UniverseEditor/Interface(纯类型与枚举),
其它子目录一律禁止。原因很实际:
EditorCommon 里有编辑器专用的依赖
(react-umg、immer、表解析器),
Game 层引进来会把编辑器代码打进运行时包,
而且这些代码在游戏运行环境里可能根本跑不起来。需要编辑器侧的数据怎么办?让业务方查表拿到值,然后作为参数传进来, 不要让底层反向去查编辑器。
8. 工程与流程
npm run compile 显示 0 错误,为什么改动没生效L3
因为诊断阶段是全项目的,而且失败就直接退出,根本不 emit:
应急手段:
「编译通过」和「产物更新了」是两件事。
const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length > 0) {
console.error('诊断错误', ...);
process.exit(EExitCode.DiagnosticError); // ← 到此为止,不进 emit
}
所以别人提交的一个错误,会导致自己的改动压根没写到磁盘,
游戏里测的还是旧代码。应急手段:
npm run compile-skip-diagnostic 强制 emit,
然后直接 grep 产物 .js 确认改动进去了——不要信控制台输出。「编译通过」和「产物更新了」是两件事。
跑
tsc 过了算不算改完L1
不算。 自定义编译器除了 tsc 类型检查,还会跑:
·
·
· 一堆 AST 插件变换(LogPlugin、BpConstructorPlugin 等)
tsc 过了 compile 挂掉是常态。只有
·
lint-serious-combat(战斗目录严格 lint)·
lint-top-decls(顶层声明白名单检查——只允许 class/interface/type/enum/import/export,
不允许顶层的赋值和函数调用)· 一堆 AST 插件变换(LogPlugin、BpConstructorPlugin 等)
tsc 过了 compile 挂掉是常态。只有
npm run compile 真的 EXIT=0 才算改完。
怎么保证改动不影响别人L2
① 开工前列 in-scope / out-of-scope 路径,每次改动对一遍。
最容易犯的错是「看到相似的入口顺手也改了」——
另一个入口可能有别的团队在维护,或者有意保持旧行为。
② 发现范围外的问题就报告,不动手。
③ 保持 CRLF——整文件重写时把行尾变成 LF, diff 会显示整个文件都变了,review 完全没法看。
④ P4 不加锁——
⑤ 删/改公共逻辑前 grep 全部调用点, 包括动态字符串拼出来的那种。
② 发现范围外的问题就报告,不动手。
③ 保持 CRLF——整文件重写时把行尾变成 LF, diff 会显示整个文件都变了,review 完全没法看。
④ P4 不加锁——
p4 edit 就够了,
顺手 p4 lock 会挡住同事编辑。发现已锁立即 unlock。⑤ 删/改公共逻辑前 grep 全部调用点, 包括动态字符串拼出来的那种。
9. 五个可以完整讲的故事
讲故事的模板
背景(30 秒)→ 现象(30 秒)→ 定位过程(2 分钟,重点)→ 根因(30 秒)→
解法与权衡(1 分钟)→ 沉淀(30 秒)
面试官真正在听的是「定位过程」—— 从一个模糊的现象怎么一步步收敛到根因。结论谁都会说,过程骗不了人。
面试官真正在听的是「定位过程」—— 从一个模糊的现象怎么一步步收敛到根因。结论谁都会说,过程骗不了人。
故事 1 · 一分钟后地形消失
| 亮点 | 「固定时长」这个线索的用法;队列架构的单点卡死;治本 + 双保险的取舍 |
| 讲什么 |
从「固定一分钟」判断是超时或定时清理 →
顺着 WP 流送查到 IsStreamingCompleted 永久 false →
发现等待命令没有超时,卡住整个 Cmd 队列 →
队列后面的 MoveCmd 负责玩家灯交接,被饿死 →
落点 cell 没有源持有,到周期被卸载解法:给等待命令加 10 秒超时放行(治本,让单点故障不扩散) + 3×3 贴地检测(独立第二道保险) |
| 沉淀 | 任何「等待」都要有超时;队列式架构里无超时的等待是雷 |
故事 2 · 黑屏残留 13 秒
| 亮点 | 量化模糊描述;从「13 秒」倒推到「是被超时兜底解的」;覆盖 vs 追加语义 |
| 讲什么 |
用户说「十几秒」,先从日志量出准确的 13 秒 →
找到代码里的 20 秒兜底超时,13 秒是从中间某点算起 →
推断:黑屏不是正常解除的,是被兜底强解的,说明解除回调没被调用 →
查到 SetResultCallback 是覆盖语义,
SceneLoadingController 又调了一次把 Helper 的回调冲掉了
|
| 沉淀 | 先量化再推理;SetXxxCallback 先确认覆盖还是追加 |
故事 3 · 传送同步流程重构
| 亮点 | 这是主动改进不是修 bug;删掉硬等待的勇气;重试机制的完整设计 |
| 讲什么 |
旧流程有一个 8 秒硬等待——不管实际加载多快都要等满 →
接入服务器新给的 15512 / 14716 / 14717 三条协议,
改成事件驱动 →
为了可靠性设计了退避重试 5s/5s/10s/20s/40s,
靠 serverMoveVersion 幂等,三个取消条件 →
新增 TeleportEntityBufferController 缓存乱序到达的协议 →
本地和非本地玩家分别做防下坠范围控制:只改同关卡传送,跨关卡路径一行不动 |
| 沉淀 | 用事件驱动替代硬等待;重试必须配幂等和取消条件;重构要划清边界 |
故事 4 · 「角色不显示」其实是相机没切
| 亮点 | 不被报障描述带跑;静默失败的危害;问对第一个问题能省几倍时间 |
| 讲什么 |
QA 报「SetActorVisible=true 但角色不显示」,
描述里已经给了「结论」——听起来就是可见性 bug →
先验证角色确实是可见的(打日志确认状态)→
那问题就不在可见性,而在「可见的东西在不在画面里」 →
查到 Camera3 切换失败:缺资产,加载 API 静默失败,
相机停在上一个位置
|
| 沉淀 | 报障里的「结论」要验证不要采信;资源加载失败必须打 Error; 「不显示」先问相机 |
故事 5 · 编辑器加「传送表现」参数
| 亮点 | 面向策划的功能设计;向后兼容;双实现漏改的复盘 |
| 讲什么 | 需求:策划希望某些传送不用默认的「飞天遁地」,改成淡入淡出 → 设计成可选参数(不配 = 旧行为), Scheme 抽出来给三个节点共享, 进入/离开分开配 → 上线后发现「通过交互触发的传送配了没生效」 → 查到同一个 action 注册了 player 和 entity 两套 executor, 只透传了 player 版 |
| 沉淀 | 加可选参数天然向后兼容;改 action 一定 grep 全部注册点—— 「只有一个实现」是最贵的假设 |
10. 通用与软技能
拿到一个 bug,第一步做什么L2
问五个问题,一个都不跳:
① 必现还是偶现?——偶现基本等于时序/竞态
② 有没有前置条件?——「做过一次 X 之后才有」直接指向状态没复位
③ 什么场景?——单人/组队、副本/大世界、主控/非主控
④ 有没有固定时长?——固定时长 = 某个超时或定时清理
⑤ 报的是现象还是结论?——「设置了可见但不显示」是结论,要验证
这五问经常能在读代码之前就把范围缩小一个数量级。 直接扎进代码是最慢的路径。
① 必现还是偶现?——偶现基本等于时序/竞态
② 有没有前置条件?——「做过一次 X 之后才有」直接指向状态没复位
③ 什么场景?——单人/组队、副本/大世界、主控/非主控
④ 有没有固定时长?——固定时长 = 某个超时或定时清理
⑤ 报的是现象还是结论?——「设置了可见但不显示」是结论,要验证
这五问经常能在读代码之前就把范围缩小一个数量级。 直接扎进代码是最慢的路径。
怎么查一个偶现问题L3
① 先补日志,不要先猜。
偶现问题猜的命中率极低,加一轮日志再复现一次,
拿到的信息量远大于盯着代码想半天。
② 日志要打三样:入参、当时的状态标志、走了哪个分支。 缺任何一样都还得再来一轮。
③ 把两条时间线摆在一起 grep—— 时序错配的问题,单看一条时间线是正常的, 两条穿插起来看,错位一眼就出来了。 遮罩早关而相机碰撞晚恢复那个 bug 就是这么看出来的。
④ 回归型问题不要读代码,用二分回退—— 「以前好的现在坏了」,从提交记录二分比读代码快得多。
② 日志要打三样:入参、当时的状态标志、走了哪个分支。 缺任何一样都还得再来一轮。
③ 把两条时间线摆在一起 grep—— 时序错配的问题,单看一条时间线是正常的, 两条穿插起来看,错位一眼就出来了。 遮罩早关而相机碰撞晚恢复那个 bug 就是这么看出来的。
④ 回归型问题不要读代码,用二分回退—— 「以前好的现在坏了」,从提交记录二分比读代码快得多。
怎么和服务器/策划/美术对接L2
核心是把「谁的问题」变成可验证的事实,而不是互相猜:
服务器说「我发了」→ 要协议号和字段值, 客户端 grep 对应的 Register 日志, 明确区分是没收到还是收到没处理——两者根因完全不同。
策划说「配了没生效」→ 三件事:表导出了没(
美术说「资产给了」→ 确认路径和命名严格匹配代码里的字符串, 资源缺失很多 API 是静默失败。
对上游有依赖时,在客户端加一条 Error 日志把缺失暴露出来, 下次同类问题 5 秒就能定位,不用再走一遍沟通流程。
服务器说「我发了」→ 要协议号和字段值, 客户端 grep 对应的 Register 日志, 明确区分是没收到还是收到没处理——两者根因完全不同。
策划说「配了没生效」→ 三件事:表导出了没(
ConfigTool.Gui.exe)、
主控树上有没有放对应节点、编辑与校验 scheme 是不是同一个。美术说「资产给了」→ 确认路径和命名严格匹配代码里的字符串, 资源缺失很多 API 是静默失败。
对上游有依赖时,在客户端加一条 Error 日志把缺失暴露出来, 下次同类问题 5 秒就能定位,不用再走一遍沟通流程。
这套代码里最值得沉淀的能力是什么L2
把踩过的坑变成可复用的规律。
本站的十几条模式就是这么来的: 「任何等待都要有超时」「两个数组对应用主键不用下标」 「状态过滤用白名单不用黑名单」「async 函数都要假设会被重入」 「零值当没准备好而不是就是零」。
这些不是抽象原则,每一条背后都有一个具体的线上问题。 好处是写新代码时会主动避开—— 写到一个
另外一点是排查时先量化再推理—— 「十几秒」查不出东西,「13 秒」能直接对上代码里的超时值。
本站的十几条模式就是这么来的: 「任何等待都要有超时」「两个数组对应用主键不用下标」 「状态过滤用白名单不用黑名单」「async 函数都要假设会被重入」 「零值当没准备好而不是就是零」。
这些不是抽象原则,每一条背后都有一个具体的线上问题。 好处是写新代码时会主动避开—— 写到一个
await 就该想「这里会不会被重入」,
写到一个等待就该想「超时在哪」。另外一点是排查时先量化再推理—— 「十几秒」查不出东西,「13 秒」能直接对上代码里的超时值。
有没有被证伪过的判断L3
两个典型例子:
① 飞船跳跃丢惯性:最初的判断是
教训:改了没效果要及时承认假设被证伪,回到现象重新推, 而不是在错误的方向上继续加补丁。
② 传送表现只改了一半:加可选参数时 只确认了 player 版 executor 透传, 没注意同一个 action 还注册了 entity 版。 教训:「只有一个实现」是最贵的假设, 改 action 之前先 grep 全部注册点。
① 飞船跳跃丢惯性:最初的判断是
GetActorBounds 的参数用错了,改了参数——没用。
后来才发现真根因是同帧 Spawn 的时序:
Actor 刚 spawn 出来时包围盒还是全零,
「零」被当成了「就是零」而不是「还没准备好」。
改成逐帧重试直到拿到非零包围盒才解决。教训:改了没效果要及时承认假设被证伪,回到现象重新推, 而不是在错误的方向上继续加补丁。
② 传送表现只改了一半:加可选参数时 只确认了 player 版 executor 透传, 没注意同一个 action 还注册了 entity 版。 教训:「只有一个实现」是最贵的假设, 改 action 之前先 grep 全部注册点。
11. 关键数字速记
| 数字 | 含义 |
|---|---|
| 284 / 252 / 1 | SCNotify / CSReq / CSPush 的条数 |
| 136 / 192 / 47 | Model / Controller / Config 的个数 |
| 116 | Src/Game/Module/ 下的业务模块数 |
| 15512 / 14716 / 14717 | 同步瞬移下发 / LoadDone 上报 / 回包 |
| 14525 | 跨场景 LoadDone(别和 14716 混) |
| 20528 | 副本结算协议 |
| 20532 / 20533 | 爬塔星级全量 / 单档增量 |
| 5/5/10/20/40 | LoadDone 重试退避阶梯(秒),共 80 秒窗口 |
| 3×3 | 贴地预检的射线网格 |
| 10s | WP 流送等待的超时放行阈值 |
| [20, 90000] | 定时器合法间隔(毫秒),越界静默返回 undefined |
| 15 位 | 子弹 TypeId 长度(6+5+3+1) |