首页 / 总览 / 框架与分层总纲

框架与分层总纲

这一页是所有模块页的公共前置知识。八大功能模块里出现的每一个基类、每一个生命周期钩子、 每一种注册/注销配对,规则都在这里。看模块页遇到不认识的基类,回到这里查。

7404
TS 文件
143 万
代码行
116
业务模块
768+
全局事件
549
协议号

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 才拿得到值
puerts 的两个高频坑 ① UE 对象会被 GC 回收。TS 侧持有的 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/          // 编辑器工具脚本
硬性依赖规则:Game 层禁止 import UniverseEditor 的非 Interface 目录
// ✅ 允许
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 {}
}
六个 Tick 阶段对应 UE 的 TickGroup 选错阶段会导致「读到上一帧的物理结果」。规律:
要在物理之前改位置 → 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
    }
}
「注册在 Init、注销在 Clear」是铁律 每个 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整个游戏退出 / 重登全部数据,包括账号级
最常见的 Model bug:该在 LeaveLevel 清的东西写在了 Clear 里 后果是换个副本还留着上一局的数据。判断标准很简单: 「这个数据换关卡后还有意义吗?」没意义就放 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数量访问写法
ModelManager136ModelManager.XxxModel?.Foo()
ControllerHolder192ControllerHolder.XxxController?.Foo()
ConfigManager47ConfigManager.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;
}
Add 的第三个参数 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 同名事件时不会二次触发(虽然会被重入检查先拦下)
看到「事件重复派发」Critical 怎么办 说明有事件环:A 的监听里改了状态 → 触发 B → B 的监听里又触发 A。 emitting 字段打出的位掩码能看出当前栈上有哪些事件。
标准解法是把内层 Emit 延迟一帧(TimerSystem.Next), 或者引入脏标记合并刷新—— 主界面追踪栏就是这么做的:ETrackDirtyFlag 位运算攒标记,下一帧统一刷。

4.3 六组 API 对照

全局带目标说明
AddAddWithTarget重复注册会打 Critical
TryAddTryAddWithTarget已存在则静默跳过,不报错
OnceOnceWithTarget触发一次自动摘除
RemoveRemoveWithTarget不存在会打 Critical
TryRemoveTryRemoveWithTarget不存在则静默跳过
EmitEmitWithTarget / EmitWithTargets带目标版只通知该对象上的监听
什么时候用 Try 系列 只在注册时机确实不确定时用(比如可能被多个入口重复初始化)。 常规路径应该用 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数量
SCNotifyS→C 单向通知Register / UnRegister284
CSReq → SCRspC→S 请求应答Call / CallAsync252
CSPushC→S 单向推送Push—
协议里的 int64 必须过 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 组件生命周期:六个钩子,两两配对

1
OnCreate(params) 数据层
类似构造函数,接服务器协议数据。 只能访问 CreatureDataComponent,不能碰任何表现层组件
2
OnInitData(params) 数据层
同 OnCreate 的约束,处理服务器数据 + 配置表
3
OnInit() 表现层
加载自身表现(如 Actor)。 不能访问其他组件 OnInit 的结果(顺序不保证),不能碰战斗数据
4
OnStart() 表现层
这里才能拿到其他组件 OnInit 的产物。 A.OnInit 加载了 Actor,B.OnStart 就能取到。仍不能碰战斗数据
5
OnActivate(params) 战斗层
实体已创建完毕。Buff / 属性 / GameplayTag / 碰撞激活 / 地面检测都在这
6
OnActivateAfter(params)
收尾,处理依赖「所有组件已 Activate」的逻辑
起始终止配对语义
OnCreateOnClear数据层:清 OnCreate/OnInit 产生的数据
OnStartOnEnd表现层:清 OnStart 产生的表现
OnEnableOnDisable(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);
}
控件索引是位置索引,不是名字 美术在 prefab 里调整控件顺序会静默错位—— 拿到的 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:这个参数决定日志能不能按来源批量筛选, 一个文件里挂着多条独立链路时,靠它把日志分流。
技术债提醒:调试期的 Warn 上线前要降级 爬塔星级链路目前收包、更新全是 Log.Warn, 稳定后应该降到 Debug,否则线上 Warn 通道被常规流程刷屏, 真正的告警会被淹没。

10. 编码约定速查

项规则违反后果
行尾一律 CRLFWrite 整文件改成 LF 会让整个文件在 diff 里全红
命名类/方法/属性 PascalCase,局部变量 camelCase自定义编译器会报 naming-convention
注释最多 2 行,中文 JSDoc—
常量抽出的命名常量必须加中文 JSDoc 说明含义和用途—
Scheme export一个 PascalCase export 一个 .ts 文件运行时 can not find XxxScheme
依赖Game 层只能 import UniverseEditor/Interfaceeslint 报错
回调成员回调写成 private readonly X = () => {}普通方法丢 this

10.1 编译流水线

1
npm run lint-fix(多文件改动时)
prettier 格式矫正。本地 compile 通过 ≠ 自动测试通过, 多行函数签名最容易被 prettier 判错
2
npm run compile
tsc 不算编译。自定义编译器还会跑顶层声明检查、PascalCase 检查等项目规则
3
看到「0 错误」也未必真 emit 了
全项目任意文件有诊断错误就会在 emit 前直接退出, 磁盘上的 .js 可能压根没更新
4
卡住时用 compile-skip-diagnostic 强制 emit
然后直接 grep 产物 .js 确认改动落盘
编辑器代码不走 npm compile Src/UniverseEditor/** 的改动要走 gulp 构建,输出到 JavaScript_Raw。 两条流水线互不影响:npm run compile 通过与编辑器里能否看到 scheme 变化没有因果关系。

11. 反复出现的通用模式

模式解决什么案例
全量 + 增量协议进场建基线,之后只推变化爬塔 20532/20533
脏标记 + 延迟一帧合并多个事件同帧触发导致重复重建 UIETrackDirtyFlag 位运算
异步创建布尔锁await 期间被重入导致重复创建IsCreatingStarItems
多层超时兜底任一环节卡住整个流程锁死结算 10s/15s/60s 三层
自维护集合而非全量重扫中途增删导致恢复不全HiddenEntities
双源 OR 兜底单一数据源不完整GetCurrentTowerStarMask
统一展示结构同一 UI 复用于多种数据来源ITowerStarDisplayItem
三级文本回退多语言缺失时不漏 keyTid → EdTid → 空
幂等建定时器刷新函数被反复调用堆积定时器if (!TimerSystem.Has(h))
逐帧重试直到拿到有效值同帧 Spawn 时序拿不到包围盒飞船平台跳跃惯性

12. 面试怎么讲架构

这套项目的分层是怎么划的总览
UE5 + puerts,TS 侧约 143 万行、7400 个文件。分三大块:
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」必须严格配对。
ECS 组件的六个生命周期钩子,怎么决定代码放哪个实战
分三层,约束是硬的不是建议:
数据层(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 评分)用于分帧调度—— 一帧内批量刷怪时按权重攒够预算就切帧,避免单帧卡死。
puerts 环境下有哪些和纯 TS 不一样的注意点踩坑
① out 参数要 $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 产物验证。