框架与分层总纲
这一页是所有模块页的公共前置知识。八大功能模块里出现的每一个基类、每一个生命周期钩子、 每一种注册/注销配对,规则都在这里。看模块页遇到不认识的基类,回到这里查。
1. 技术栈:UE5 + puerts
import * as UE from 'ue';
import type { $Undefinable } from 'puerts';
import { $ref, $unref } from 'puerts';
puerts 把 UE 的 C++ 反射类型全量映射成 TypeScript 声明(ue.d.ts),
TS 里可以直接 new UE.Vector(...)、调 Actor.SetActorLocation(...)。
底层是 V8 引擎,脚本和 C++ 在同一进程内互调。
| 符号 | 含义 | 什么时候用 |
|---|---|---|
| $ref(x) | 把值包成引用容器 | C++ 侧是 out / & 参数时 |
| $unref(r) | 取出引用容器里的值 | 调用返回后读 out 参数 |
| $Undefinable<T> | T | undefined | 标注可能为空的 UE 对象 |
// 典型的 out 参数调用:GetActorBounds(bOnlyColliding, Origin, BoxExtent)
const origin = $ref(new UE.Vector());
const extent = $ref(new UE.Vector());
actor.GetActorBounds(false, origin, extent);
const box = $unref(extent); // 必须 unref 才拿得到值
UE.Actor 引用不阻止 C++ 侧销毁,
拿到手要判 IsValid(),跨帧持有更要小心野指针。② 字符串枚举名找不到就是 scheme 没注册。运行时报 can not find XxxScheme,多半是 PascalCase export 和别的 export 共用了一个 .ts 文件—— scheme export 必须一个文件一个。
2. 目录分层
Src/
├── Core/ // 引擎无关的基础设施,不含任何业务
│ ├── Framework/ // ControllerBase / ModelBase / ConfigBase 及其 Manager
│ ├── Event/ // 通用事件类(弱引用 + 防重入)
│ ├── Net/ // Net 静态门面 + NetConnection + 协议定义
│ ├── Entity/ // ECS:Entity / EntityComponent / 组件注册
│ ├── Timer/ // TimerSystem 优先队列定时器
│ ├── Config/ // 配置表加载
│ └── Utils/ // 数学 / 字符串 / 命令
├── Game/ // 全部游戏业务
│ ├── Module/ // 116 个功能模块,一个目录一个模块
│ ├── World/ // 实体世界:Entity 组件实现、Model
│ ├── Ui/ // UI 基类与管理器
│ ├── Manager/ // ModelManager / ControllerHolder / ConfigManager
│ ├── LevelGamePlay/ // 关卡玩法、行为树节点执行器
│ └── Common/ // EventDefine / EventSystem
├── UniverseEditor/ // 宇宙编辑器(关卡编辑工具)
│ ├── Interface/ // 纯数据接口,Game 层唯一可 import 的子目录
│ ├── Common/ // 操作定义
│ └── EditorCommon/// Scheme 定义(编辑器 UI 生成规则)
├── Fight/ // 战斗系统
└── Editor/ // 编辑器工具脚本
// ✅ 允许
import type { ITowerStarConfig } from '../../UniverseEditor/Interface/ILevelPlay';
// ❌ 禁止(eslint import/no-restricted-paths 会报错)
import { towerStarConfigScheme } from '../../UniverseEditor/EditorCommon/Scheme/Var/LevelPlay';
Interface/ 是纯类型声明,编译后不产生运行时代码;
EditorCommon/ 里全是编辑器才用的 scheme 构造函数,打进客户端包是纯浪费。需要编辑器里的数据?让业务方查表后把值传进来,不要在 Game 层直接读 scheme。
3. MVC 三件套
每个业务模块标准是三个类:Controller(逻辑与协议)+ Model(数据)+ Config(配置表加工), UI 另有 View / Panel。三者都是进程级单例,通过静态 Manager 访问。
3.1 ControllerBase
export abstract class ControllerBase {
public Init(): boolean { return this.OnInit(); }
public Clear(): boolean { return this.OnClear(); }
public LeaveLevel(): boolean { return this.OnLeaveLevel(); }
// tick接口使用下面的进行override
protected OnPreWorldActorTick(delta: number): void {}
protected OnPrePhysicsTick(delta: number): void {}
protected OnDuringPhysicsTick(delta: number): void {}
protected OnEndPhysicsTick(delta: number): void {}
protected OnPostPhysicsTick(delta: number): void {}
protected OnPostUpdateWorkTick(delta: number): void {}
}
要在物理之前改位置 →
PrePhysics;
要读物理结算后的位置(如贴地检测、碰撞查询)→ PostPhysics;
纯 UI 刷新 → PostUpdateWork。传送的 3×3 贴地预检就必须在 PostPhysics 之后跑, 否则读到的碰撞体还是上一帧的。
3.2 UiControllerBase:四对注册/注销
export abstract class UiControllerBase extends ControllerBase {
public Init(): boolean {
const ret = super.Init(); // ← 先跑 OnInit
this.OnRegisterNetEvent(); // ① 注册协议
this.OnAddEvents(); // ② 注册事件
this.OnAddOpenViewCheckFunction(); // ③ 注册开界面检查
return ret;
}
public Clear(): boolean {
this.OnUnRegisterNetEvent(); // 逆序清理
this.OnRemoveEvents();
this.OnRemoveOpenViewCheckFunction();
return super.Clear(); // ← 最后跑 OnClear
}
}
OnAddEvents 里的 EventSystem.Add,
必须在 OnRemoveEvents 里有一行对应的 Remove。
漏了会在关卡切换时触发 「生命周期结束时没有解除注册的全局事件处理函数」 警告
(CheckDanglingEventHandlers 打的),严重时回调打在已销毁对象上直接崩。注意
Init 里是先 OnInit 后注册,Clear 里是先注销后 OnClear——
保证注册期间 Model 已就绪、注销之后才清数据。
3.3 ModelBase:多了一个 ChangeMode
export abstract class ModelBase {
protected OnInit(): boolean { return true; }
protected OnClear(): boolean { return true; }
protected OnLeaveLevel(): boolean { return true; }
protected OnChangeMode(): boolean { return true; }
}
| 钩子 | 触发时机 | 该清什么 |
|---|---|---|
| OnInit | 游戏启动 / 模块首次装载 | 读配置表、建索引 Map |
| OnLeaveLevel | 离开当前关卡 | 关卡级数据(副本进度、场内实体缓存) |
| OnChangeMode | 切换游戏模式(大世界 ↔ 副本) | 模式相关的临时状态 |
| OnClear | 整个游戏退出 / 重登 | 全部数据,包括账号级 |
OnLeaveLevel。反向的坑也有:爬塔 Config 的
OnClear
只 clear 了两个成员里的一个,另一个跨重登残留。
3.4 Manager 单例
export class ModelManager extends ModelManagerBase {
public static DungeonModel: DungeonModel | undefined = undefined;
public static TowerClimbModel: TowerClimbModel | undefined = undefined;
// ... 共 136 个
}
| Manager | 数量 | 访问写法 |
|---|---|---|
| ModelManager | 136 | ModelManager.XxxModel?.Foo() |
| ControllerHolder | 192 | ControllerHolder.XxxController?.Foo() |
| ConfigManager | 47 | ConfigManager.XxxConfig!.Foo() |
| undefined 而不是非空
因为这些字段在模块装载时才赋值,赋值顺序由 ModelManagerCreator 决定。
在别的 Model 的 OnInit 里访问一个还没装载的 Model 就是 undefined。所以工程里的惯例是:跨模块访问一律
?.,只有在确定时序的地方才用 !。
爬塔 Model 的 ConfigManager.TowerClimbConfig! 用 ! 是因为 Config 先于 Model 装载。
// ModelManagerBase 的批量生命周期驱动
public static Init(): boolean {
for (const model of this.Models) {
if (!model.Init()) {
Log.Error(..., '模块初始化失败,请往上查看具体出错模块日志解决问题',
['model', model.constructor.name]);
return false; // 任一失败整体中断
}
}
return true;
}
返回 false 会中断整个初始化链,游戏起不来。
所以 OnInit 里返回 false 要慎重——查表失败这类问题应该 Log.Error + 返回 true 降级,
只有真正无法继续(比如必需的配置表整个缺失)才返回 false。
4. 事件系统
EventSystem(静态门面)→ Event<EEventName, IEventType>(实现)。
768+ 个事件名定义在 EEventName 枚举,每个事件的参数签名定义在 IEventType 映射表里:
// EventDefine.ts
export enum EEventName {
/** 创建实体(表现层还没有准备好) */
EntityCreated,
/** 实体已创建,而且初始化完成(包括表现层的都准备好了) */
EntityInitialized,
...
}
export interface IEventType {
[EEventName.EntityCreated]: (creatureData: Readonly<CreatureDataComponent>,
handle: Readonly<EntityHandle>) => void;
}
好处是 Emit 的参数编译期强类型检查,
Add 的回调签名写错直接编译不过。
4.1 弱引用持有:事件系统不阻止 GC
/** 事件系统不真正持有 handle */
private static readonly HandlerToWeakHandler = new WeakMap<TEventHandler, WeakRef<TEventHandler>>();
private readonly Handlers = new Map<TName, Map<WeakRef<TEventHandler>, EHandlerType>>();
存的是 WeakRef,Emit 时 deref()。回调所属对象被回收后 deref 返回 undefined,
系统会打一条 Critical 并自动摘除:
if (!handler) {
Log.Critical(ELogModule.Event, ..., '事件处理方法已被回收',
['name', this.EventNames[name]],
['source', this.HandlerSources.get(name)?.get(weakHandler) ?? '未知']);
handlers.delete(weakHandler);
continue;
}
source 是给这条日志用的
EventSystem.Add(EEventName.Foo, this.OnFoo, 'TowerClimbController')因为所有回调为了保
this 都写成箭头函数,是匿名的,
出问题时光看函数名定位不到泄漏方。source 是唯一线索。
排查「事件处理方法已被回收」时,第一件事就是看这个字段——没传就只能显示「未知」。
4.2 防重入:位掩码 + Pending 队列
public Emit(name, ...params): boolean {
if (this.IsEmitting(name)) {
Log.Critical(..., '事件重复派发,请检查事件链是否产生循环调用',
['name', ...], ['emitting', this.EmittingMask]);
return false;
}
this.SetEmitting(name, true);
// ... 遍历 handlers 逐个调用,每个 try/catch 包住 ...
this.SetEmitting(name, false);
// 派发期间的增删在这里统一生效
for (const h of pendingRemoveHandles) { this.DoRemove(name, h); }
for (const h of pendingAddHandles) { this.DoAdd(name, ...); }
}
| 机制 | 解决什么 |
|---|---|
| EmittingMask 位掩码(每 32 个事件名一个 int) | A 的回调里又 Emit A → 直接拒绝并打 Critical,防止无限递归爆栈 |
| PendingAdd / PendingRemove | 回调里 Add/Remove 同一事件 → 不改动正在遍历的 Map,攒到派发结束再生效 |
| 每个 handler 独立 try/catch | 一个监听者抛异常不影响其它监听者,异常带堆栈打 ErrorWithStack |
| Once 在调用前就摘除 | 回调里再 Emit 同名事件时不会二次触发(虽然会被重入检查先拦下) |
emitting 字段打出的位掩码能看出当前栈上有哪些事件。标准解法是把内层 Emit 延迟一帧(
TimerSystem.Next),
或者引入脏标记合并刷新——
主界面追踪栏就是这么做的:ETrackDirtyFlag 位运算攒标记,下一帧统一刷。
4.3 六组 API 对照
| 全局 | 带目标 | 说明 |
|---|---|---|
| Add | AddWithTarget | 重复注册会打 Critical |
| TryAdd | TryAddWithTarget | 已存在则静默跳过,不报错 |
| Once | OnceWithTarget | 触发一次自动摘除 |
| Remove | RemoveWithTarget | 不存在会打 Critical |
| TryRemove | TryRemoveWithTarget | 不存在则静默跳过 |
| Emit | EmitWithTarget / EmitWithTargets | 带目标版只通知该对象上的监听 |
Add/Remove——它们的 Critical 日志是**有价值的**,
无脑改成 Try 系列等于把配对错误藏起来。带 target 的版本走
WeakMap<object, Event>,
目标对象被回收时整个事件表自动消失,适合实体级事件。
5. 协议层
// Net 是纯静态门面,内部转发给 NetConnection
Net.Register(ENotifyMessageId.WorldDungeonTowerStarStartSCNotify, this.OnStart);
Net.UnRegister(ENotifyMessageId.WorldDungeonTowerStarStartSCNotify);
Net.Call(ERequestMessageId.XxxCSReq, message, (rsp) => { ... }, timeout);
const rsp = await Net.CallAsync(ERequestMessageId.XxxCSReq, message);
Net.Push(EPushMessageId.XxxCSPush, message);
| 后缀 | 方向 | API | 数量 |
|---|---|---|---|
| SCNotify | S→C 单向通知 | Register / UnRegister | 284 |
| CSReq → SCRsp | C→S 请求应答 | Call / CallAsync | 252 |
| CSPush | C→S 单向推送 | Push | — |
MathUtils.LongToNumber
// ❌ 直接用会拿到 Long 对象而不是 number
const t = progress.condition.waitTimeData.endTimeMs;
// ✅
const t = MathUtils.LongToNumber(progress.condition?.waitTimeData?.endTimeMs ?? 0);
protobuf 的 int64 在 JS 里映射成 Long 对象(high/low 双 32 位),
直接参与算术会得到 NaN 或字符串拼接。时间戳、uid、大数值属性都要转。
Protocol.d.ts 是 proto 自动生成的
手动删改会在下次生成时被写回。要删协议必须协调服务器同步删 .proto,
否则「删干净了、编译过了」,下次同步又冒出来。
(传送模块清理 15504/05/06 三条废协议时踩过。)
6. ECS 实体组件
@RegisterComponent(EComponent.BoundaryRespawnComponent)
export class EtBoundaryRespawnComponent extends EntityComponent {
protected override OnStart(): boolean { ... }
protected override OnEnd(): boolean { ... }
}
@RegisterComponent(id) 装饰器只做一件事:type.Id = id。
组件靠这个数字 id 在 EComponent 枚举里唯一标识,
entity.GetComponent(EComponent.Xxx) 按 id 查。
6.1 组件生命周期:六个钩子,两两配对
| 起始 | 终止 | 配对语义 |
|---|---|---|
| OnCreate | OnClear | 数据层:清 OnCreate/OnInit 产生的数据 |
| OnStart | OnEnd | 表现层:清 OnStart 产生的表现 |
| OnEnable | OnDisable(reason) | 激活/禁用,可反复切换 |
OnInit 里访问别的组件 = 拿到 undefined 或半初始化对象。
执行顺序由 EExecutedFunctions 位掩码逐阶段推进,同阶段内组件间顺序不保证。判断放哪一层的口诀:
「只用服务器数据」→ OnCreate;「只用自己」→ OnInit; 「要别人的 Actor」→ OnStart;「要 Buff/属性/碰撞」→ OnActivate。
const enum EExecutedFunctions {
None = 0,
Created = 0b1,
Initialized = 0b10,
Started = 0b100,
Activated = 0b1000,
Ended = 0b10000,
Cleared = 0b100000,
}
每个组件用一个位掩码记录「已执行过哪些阶段」。
清理时可以据此跳过未配对的终止函数
(开关 EntityComponentConstants.SkipUnpairedEventFunctions,当前设为 false,即全部都跑)。
6.2 分帧调度:InitComplexity
/**
* 组件 Init 复杂度评分 (0-10),用于分帧调度预算控制。
* 子类可 override 此属性声明自身的 Init 开销权重。默认值 2(低复杂度)。
*/
public get InitComplexity(): number { return EComponentInitComplexity.Low; }
export const enum EComponentInitComplexity {
None = 0, Min = 1, Low = 2, Medium = 3,
High = 5, VeryHigh = 8, Extreme = 10,
}
一帧内批量创建实体(比如刷一波怪)时,调度器按这个权重攒够预算就切帧, 避免单帧卡顿。写重初始化逻辑(加载资源、建大表)的组件应该 override 提高评分。
6.3 从外部访问实体数据
// 拿服务器同步的组件数据(不需要实体表现层就绪)
const comp = EntityInterfaceHelper.GetServerCompData(creatureDataId, 'cumulativeValueComp');
// 遍历全部实体
for (const handle of EntityInterfaceHelper.GetAllEntities()) { ... }
GetAllEntities 的返回值不能长期持有
结算模块隐藏全场实体时踩过:
用 GetAllEntities 隐藏、结束时再 GetAllEntities 恢复,
中途被销毁的实体不在第二次列表里,永久处于隐藏状态。解法是自己维护一个
HiddenEntities 集合,
按集合恢复而不是按当下的全量列表。
7. 定时器
TimerSystem.Next(action) // 下一帧
TimerSystem.Delay(action, ms) // 单次
TimerSystem.Loop(action, ms, loopCount) // 循环 N 次
TimerSystem.Forever(action, ms) // 永久循环
TimerSystem.Has(handle) // 判存活
TimerSystem.Remove(handle) // 移除
handle.Pause() / .Resume() / .ChangeDilation(d) // 句柄上的便捷方法
class TimerSystemConstants {
/** 最小定时器时间 */ public static readonly MinTime = 20;
/** 最大定时器时间 */ public static readonly MaxTime = 90000;
/** 最多循环次数 */ public static readonly MaxLoop = 10;
/** 永久计时器 */ public static readonly Forever = 0;
}
undefined,不是抛异常
interval < 20 或 > 90000、loop > 10、dilation <= 0
→ 直接返回 undefined,定时器根本没建,也不会报错。所以「定时器不触发」的第一个排查点是参数是否越界。 需要超过 90 秒的等待,传
needCheckMaxInterval = false 绕过检查
(结算的 60s UI 超时就这么写的)。
7.1 三条铁律
// ① 建之前先判存活,否则会堆出一堆重复定时器
if (!TimerSystem.Has(this.Timer)) {
this.Timer = TimerSystem.Forever(this.OnTick, 50);
}
// ② 销毁时必须清,否则回调打在已销毁的 UI 上
protected override OnBeforeDestroy(): void {
if (TimerSystem.Has(this.Timer)) {
TimerSystem.Remove(this.Timer!);
this.Timer = undefined;
}
super.OnBeforeDestroy();
}
// ③ 回调必须是箭头函数成员,不能是普通方法(会丢 this)
private readonly OnTick = (): void => { ... };
reason 参数值得传
TimerSystem.Forever(action, 50, 1.0, undefined, '爬塔HUD计时')和事件系统的
source 同理——出问题时这是唯一能定位到业务方的线索。
8. UI 层
8.1 UiPanelBase 生命周期
OnRegisterComponent() // 声明控件索引表,最先执行
OnBeforeCreate() // 创建前
OnStart() // 首次启动
OnBeforeShow() // 每次显示前 ← 取控件引用、刷数据都在这
OnAfterShow() // 每次显示后
OnBeforeHide() // 每次隐藏前
OnAfterHide() // 每次隐藏后
OnPrepareDestroy() // 准备销毁
OnBeforeDestroy() // 销毁前 ← 清定时器、解事件都在这
OnAfterDestroy() // 销毁后
/** UiItem_TowerStar 组件索引:0=亮星、1=条件文本 */
const enum ETowerStarComps { SprStar, TxtCondition }
protected override OnRegisterComponent(): void {
this.ComponentRegisterInfos = [
[ETowerStarComps.SprStar, UE.UISprite],
[ETowerStarComps.TxtCondition, UE.UIText],
];
}
protected override OnBeforeShow(): void {
this.SprStar = this.GetSprite(ETowerStarComps.SprStar);
this.TxtCondition = this.GetText(ETowerStarComps.TxtCondition);
}
UISprite 其实是另一个控件,不报错但显示全乱。
所以 const enum 的注释里一定要写清每个下标对应什么。嵌套 prefab 实例内部的节点取不到时,兜底方案是
FindDescendantByName(root, name) 递归按显示名查。
8.2 LGUI 常用 API
| API | 作用 |
|---|---|
| item.SetUIActive(bool) | 显隐(不是 SetVisibility) |
| item.UIChildren.Num() / .Get(i) | 遍历子节点 |
| item.GetDisplayName() | 取节点名,打日志必备 |
| item.GetParentAsUIItem() | 取父节点 |
| LguiUtil.CopyItem(tpl, parent) | 同步复制模板节点 |
| LguiUtil.SetLocalTextNew(txt, key) | 设多语言文本 |
| LguiUtil.ProjectWorldToUiLocation(...) | 世界坐标 → UI 坐标 |
9. 日志规范
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(',')]);
签名固定四段:模块 + ELogAuthor 来源标记 + 消息 + N 组 [键, 值]。
| 级别 | 用途 |
|---|---|
| Log.Critical | 框架级违规(事件重复注册/派发、处理方法被回收) |
| Log.Error / ErrorWithStack | 业务错误,带异常对象时用后者 |
| Log.Warn | 需要关注但能继续;调试期挂功能日志也常用这级(方便过滤) |
| Log.Info / Debug / Verbose | 常规流程 |
'爬塔星级-'、'传送'。
测试和策划复测时只会 grep 关键字,没前缀就等于没日志。② 聚合数值必附明细:打「已得 5 星」没用, 要打
2010=mask5/2星,2011=mask7/3星,一行定位到具体副本。③ 如实填
ELogAuthor:这个参数决定日志能不能按来源批量筛选,
一个文件里挂着多条独立链路时,靠它把日志分流。
Log.Warn,
稳定后应该降到 Debug,否则线上 Warn 通道被常规流程刷屏,
真正的告警会被淹没。
10. 编码约定速查
| 项 | 规则 | 违反后果 |
|---|---|---|
| 行尾 | 一律 CRLF | Write 整文件改成 LF 会让整个文件在 diff 里全红 |
| 命名 | 类/方法/属性 PascalCase,局部变量 camelCase | 自定义编译器会报 naming-convention |
| 注释 | 最多 2 行,中文 JSDoc | — |
| 常量 | 抽出的命名常量必须加中文 JSDoc 说明含义和用途 | — |
| Scheme export | 一个 PascalCase export 一个 .ts 文件 | 运行时 can not find XxxScheme |
| 依赖 | Game 层只能 import UniverseEditor/Interface | eslint 报错 |
| 回调 | 成员回调写成 private readonly X = () => {} | 普通方法丢 this |
10.1 编译流水线
Src/UniverseEditor/** 的改动要走 gulp 构建,输出到 JavaScript_Raw。
两条流水线互不影响:npm run compile 通过与编辑器里能否看到 scheme 变化没有因果关系。
11. 反复出现的通用模式
| 模式 | 解决什么 | 案例 |
|---|---|---|
| 全量 + 增量协议 | 进场建基线,之后只推变化 | 爬塔 20532/20533 |
| 脏标记 + 延迟一帧合并 | 多个事件同帧触发导致重复重建 UI | ETrackDirtyFlag 位运算 |
| 异步创建布尔锁 | await 期间被重入导致重复创建 | IsCreatingStarItems |
| 多层超时兜底 | 任一环节卡住整个流程锁死 | 结算 10s/15s/60s 三层 |
| 自维护集合而非全量重扫 | 中途增删导致恢复不全 | HiddenEntities |
| 双源 OR 兜底 | 单一数据源不完整 | GetCurrentTowerStarMask |
| 统一展示结构 | 同一 UI 复用于多种数据来源 | ITowerStarDisplayItem |
| 三级文本回退 | 多语言缺失时不漏 key | Tid → EdTid → 空 |
| 幂等建定时器 | 刷新函数被反复调用堆积定时器 | if (!TimerSystem.Has(h)) |
| 逐帧重试直到拿到有效值 | 同帧 Spawn 时序拿不到包围盒 | 飞船平台跳跃惯性 |
12. 面试怎么讲架构
Core 是引擎无关的基础设施——框架基类、事件、网络、ECS、定时器,不含任何业务;
Game 是全部业务,
Module/ 下 116 个功能模块,每个模块标准三件套
Controller(逻辑与协议)+ Model(数据)+ Config(配置表加工),都是进程级单例;UniverseEditor 是关卡编辑器,其中只有
Interface/(纯类型声明)允许被 Game 层 import,
其余目录 eslint 直接禁止——scheme 那些编辑器构造函数打进客户端包是纯浪费。实体侧走 ECS:
@RegisterComponent 装饰器 + 六阶段生命周期,
数据层和表现层严格分开。
① 弱引用持有:内部存的是
WeakRef,事件系统不阻止回调所属对象被 GC。
对象被回收后 deref() 返回 undefined,系统打一条 Critical 并自动摘除。
Add 有个 source 参数专门给这条日志用——
因为回调为了保 this 都是箭头函数,匿名的,没有 source 就定位不到泄漏方。② 位掩码防重入:每 32 个事件名占一个 int 的一位, Emit 前检查该位,已经在派发中就拒绝并打 Critical,防止事件环爆栈。
③ Pending 队列:派发期间如果回调里 Add/Remove 了同一个事件, 不直接改正在遍历的 Map,而是攒到 PendingAdd/PendingRemove,派发结束统一生效。
另外每个 handler 独立 try/catch,一个监听者抛异常不影响其它人。
还有一个收尾机制
CheckDanglingEventHandlers,
生命周期结束时统计没解注册的监听并打警告——
这就是为什么「注册在 Init、注销在 Clear」必须严格配对。
数据层(OnCreate / OnInitData):只处理服务器协议数据和配置表, 除了 CreatureDataComponent 什么组件都不能碰。
表现层(OnInit / OnStart):OnInit 只加载自己的东西(比如 Actor), 不能访问其它组件 OnInit 的结果,因为同阶段内组件顺序不保证; 要用别人的产物必须等到 OnStart。
战斗层(OnActivate / OnActivateAfter):Buff、属性、GameplayTag、碰撞激活、地面检测。
口诀是「只用服务器数据 → OnCreate;只用自己 → OnInit;要别人的 Actor → OnStart;要 Buff/属性 → OnActivate」。
清理侧两两配对:OnCreate ↔ OnClear(清数据)、OnStart ↔ OnEnd(清表现)。 框架用一个
EExecutedFunctions 位掩码记录每个组件走到了哪一阶段,
可以据此跳过未配对的终止函数。还有个
InitComplexity(0-10 评分)用于分帧调度——
一帧内批量刷怪时按权重攒够预算就切帧,避免单帧卡死。
$ref / $unref。
C++ 的 & 参数在 TS 侧要包成引用容器,调完 $unref 取值,忘了就拿到容器对象本身。② protobuf 的 int64 是 Long 对象不是 number。 时间戳、uid、大数值属性必须过
MathUtils.LongToNumber,
直接参与算术会 NaN 或变成字符串拼接。③ UE 对象会被 C++ 侧 GC。TS 持有引用不阻止销毁,跨帧持有要判
IsValid()。④ scheme export 必须一个文件一个。 多个 PascalCase export 共用一个 .ts,运行时会报
can not find XxxScheme——
这个错很隐蔽,编译期完全看不出来。⑤ 编译不是 tsc。项目有自定义编译器跑额外规则, 而且全项目任意文件有诊断错误就会在 emit 前退出—— 所以「显示 0 错误」也可能磁盘上的 .js 根本没更新,卡住时要直接 grep 产物验证。