行为树工具链
入职以来覆盖面最大的工作线:行为树 Action 从十余项扩到生态 (发射子弹/毒圈/QTE/传送/集合等全在这套体系上配置), 外加数据结构升级(OnFix 字段迁移)、事件对象池、Repeater 边界完备化、 传送阵型编辑器(方形螺旋自动排点)。 一句话定位:策划在编辑器配关卡逻辑,我维护这条生产线的每个环节。
十余项 Action
四层管线
OnFix 数据迁移
事件对象池
方形螺旋阵型
1. 工作清单
| 内容 | 性质 |
|---|---|
| 编辑器扩展:Action 配置面板、参数 Scheme、保存校验体系 | 从 0 建 |
| 行为树十余项 Action(发射/毒圈/计时器/QTE/传送落点/集合等)、OnFix 字段自动迁移、Repeater 循环节点边界完备 | 批量扩展 |
| 空投/阵型类 Action、LandingConfig 结构升级迁移、事件对象池扩容上限治理 | 维护 + 升级 |
2. 理论:四层 Action 管线
2.1 一个 Action 从配置到执行的完整链路
1
接口层 IAction.ts
EAction 枚举 + 每个动作的数据接口定义(约 2500 行)——策划可配什么在此定型
2
注册层 Operation\Action.ts
每个 Action 的 Context(挂在哪类节点)+ ImplementType(Client/Server 谁执行)
3
Scheme 层 LevelPlay.ts
编辑器配置面板:字段、默认值、OnCheck 校验、OnFix 迁移(约 3000 行)
4
运行层 LevelEventCenter / LevelGeneralController
行为树执行到节点 → 取事件对象 → 分发到实现 Controller
四层各管一件事
接口层管「有什么」,注册层管「谁在哪执行」,
Scheme 层管「策划怎么配、配错了怎么拦」,运行层管「配完怎么跑」。
新加一个 Action 要同时动四层——分层让每层的变更理由独立:
加字段只动 1/3 层,改执行位置只动 2 层,策划侧体验只动 3 层。
2.2 OnFix:配置数据的自动迁移
结构升级时老关卡数据的兼容问题。Scheme 提供 OnFix 钩子:保存旧关卡时自动把旧字段迁移到新结构。
为什么迁移放 OnFix 而不是运行时兼容代码
运行时兼容(读的时候 if 旧字段)会永久留在运行链路里,
每个读取点都要写一遍,删不干净;
OnFix 是「过一遍存一次」:老数据一被触碰就升级成新结构落盘,
运行时代码里只存在新结构——兼容成本摊销在编辑期,而不是付息在运行期。
2.3 事件对象池:GetEvent 的 pop/push 语义
| 事件类别 | 池行为 | 原因 |
|---|---|---|
| 普通事件 | 共享 eventList[0],不 pop | 单例语义:同类型同时只有一个活跃 |
| Tick 事件 | pop 取出 + 用完 push 归还 | 并发语义:多个 tick 源同时各持有一个实例 |
| 池耗尽 | 按注册的 ctor 动态扩容一档 | EDITOR 下超上限打 Error 提醒扩容排查 |
对象池的收益与上限治理
关卡事件每帧创建/销毁会产生 GC 压力,
池化后 tick 类事件复用实例。但池容量无限涨等于内存泄漏——
所以扩容在编辑器下有上限(LevelMax)超限 Error:
容量异常膨胀本身就是 bug 信号(事件没归还 = 泄漏在别处)。
3. 代码片段
3.1 OnFix:ScatterRadius → LandingConfig 自动迁移
Src\UniverseEditor\EditorCommon\Scheme\Action\LevelPlay.ts:879-891
OnFix: (value): TFixResult => {
if (value.ScatterRadius === undefined) {
return 'nothing';
}
if (value.ScatterRadius > 0 && value.LandingConfig === undefined) {
value.LandingConfig = {
Type: ETeleportLandingMode.RandomInRadius,
Radius: value.ScatterRadius,
};
}
delete value.ScatterRadius;
return 'fixed';
},
四步迁移的标准姿势
① 无旧字段 → nothing(不用动);
② 有旧字段且新结构未配 → 语义映射(散落半径 → RandomInRadius 模式);
③ 旧字段永远 delete(下次不再触发);
④ 返回 'fixed' 标记数据已修改需要落盘。
策划只需要「重新保存一次老关卡」,数据就永久升级——
不需要任何工具脚本批量跑。
3.2 GetEvent:事件对象池 + 动态扩容
Src\Game\LevelGamePlay\LevelEvents\LevelEventCenter.ts:171-213
public static GetEvent(type: string): LevelEventBase | undefined {
const eventList = this.EventsMap.get(type);
if (eventList) {
if (eventList.length > 0) {
if (this.TickEventDefines.has(type)) {
// Tick事件组才需要pop和push操作
const event = eventList.pop()!;
event.Reset();
return event;
}
return eventList[0];
} else if (this.ExpendCapacityEvents.has(type)) {
// 支持动态扩容
const ctor = this.ExpendCapacityEvents.get(type)!;
for (let i = 0; i < LevelEventCenterConstants.Level1; i++) {
const event = new ctor(i);
event.Type = type;
eventList.push(event);
}
...
return eventList.pop();
}
Log.Warn(ELogModule.Level, ELogAuthor.YF, '事件组容量不足,建议扩容,请检查!!!', ['Type', type]);
}
return undefined;
}
池外取不到的兜底链
池空 → 查注册的 ctor 扩一档(Level1 个实例)→ 再 pop 一个;
扩容超上限在编辑器下 Error(提示检查泄漏),运行时 Warn 不断流。
Tick 事件取出前 Reset()——复用实例必须清旧状态,
这是对象池最容易漏的一步,漏了就是「串数据」bug。
3.3 Repeater:循环节点的全边界字段
Src\UniverseEditor\Interface\IBtPlan.ts:60-71
/** Repeater 的最大重复次数 */
MaxRepeatTimes?: int;
/** Repeater 的循环tick间隔(ms), 0为默认1000, 最小500 */
TickIntervalMs?: int;
/** Repeater 的无限循环开关, 勾选后忽略 MaxRepeatTimes */
IsInfiniteLoop?: boolean;
/** Repeater 的无限循环超时时间(ms), <=0为不超时, 仅无限循环生效 */
InfiniteLoopTimeoutMs?: int;
/** Repeater 的无限循环超时结果。true=节点失败。false=节点成功。默认成功 */
IsTimeoutFail?: boolean;
/** Repeater 的退出条件 */
ExitCondition?: unknown;
「无限循环」的三个必备护栏
策划需要循环监听类节点(循环检测玩家在不在区域),
无限循环不设防就是树永远卡住。字段设计:
无限开关 + 超时时间 + 超时语义(失败还是成功)三件套缺一不可——
只给开关不给超时,配错的树会把整个行为树执行流饿死;
超时结果可配是因为「超时」对有的玩法是失败(限时逃脱)有的是成功(驻守到期)。
注释把每个字段的默认值/最小值写死在接口层。
3.4 方形螺旋:阵型自动排点算法
Src\UniverseEditor\Editor\Common\SchemeComponent\Extend\TeleportLanding.tsx:74-103
/** 生成方形螺旋候选点偏移。 */
function GetSpiralOffset(index: number): IVectorInfo {
if (index <= 0) {
return { X: 0, Y: 0, Z: 0 };
}
const layer = Math.ceil((Math.sqrt(index + 1) - 1) / 2);
const sideLength = layer * 2;
const maxIndex = (layer * 2 + 1) ** 2 - 1;
const offset = maxIndex - index;
let gridX: number;
let gridY: number;
if (offset < sideLength) {
gridX = layer - offset;
gridY = -layer;
} else if (offset < sideLength * 2) {
gridX = -layer;
gridY = -layer + (offset - sideLength);
} else if (offset < sideLength * 3) {
gridX = -layer + (offset - sideLength * 2);
gridY = layer;
} else {
gridX = layer;
gridY = layer - (offset - sideLength * 3);
}
return {
X: gridX * TeleportLandingConstants.PointSpacingCm,
Y: gridY * TeleportLandingConstants.PointSpacingCm,
Z: 0,
};
}
逆推公式的巧劲
第 index 个点在哪一层?奇数平方数(1,9,25…)是每层的最后一个角——
layer = ceil((sqrt(index+1)-1)/2) 一行反解层数;
再用 maxIndex - offset 从层末逆着数,四段 if 对应四条边。
0 号点在正中心(和位置变量重合),1-8 号是第一圈,依此向外。
间距 PointSpacingCm 常量统一缩放。
3.5 只补齐不截断:策划数据优先原则
Src\UniverseEditor\Editor\Common\SchemeComponent\Extend\TeleportLanding.tsx:116-144
/** 判断候选点间距。 */
function IsPointFarEnough(candidate: IVectorInfo, points: IVectorInfo[]): boolean {
const minDistanceSquared = TeleportLandingConstants.PointSpacingCm ** 2;
return points.every((point) => {
const deltaX = (candidate.X ?? 0) - (point.X ?? 0);
const deltaY = (candidate.Y ?? 0) - (point.Y ?? 0);
const deltaZ = (candidate.Z ?? 0) - (point.Z ?? 0);
return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ >= minDistanceSquared;
});
}
/** 仅补齐阵型点,不截断用户已配置的点。 */
function AdjustFormationPoints(existing: IVectorInfo[], count: number, anchor: IPosA): IVectorInfo[] {
const result = existing.slice();
while (result.length < count) {
result.push(PickFormationPoint(result, anchor));
}
return result;
}
三条编辑器工具设计原则
① 只补齐不截断:策划手动拖过的点永远保留,
自动排点只补空位(截断 = 悄悄删用户数据,最败信任);
② 距离比较用平方距离(避免 sqrt),
候选点不满足间距就顺螺旋往外找下一个——已有手工点附近自动绕开;
③ 编辑器指示器数量 = max(阵型需要, 已配置),
策划配置超过自动数量时照样全显示。
4. 面试怎么讲
介绍你做的行为树工具链体系设计
四层管线:接口层(EAction 枚举 + 数据接口)→ 注册层
(Context 挂载节点 + ImplementType 定 Client/Server 执行侧)→
Scheme 层(编辑器面板 + OnCheck 校验 + OnFix 迁移)→
运行层(事件对象池分发到 Controller)。
我在上面扩了十余项 Action(子弹/毒圈/QTE/传送/集合等), 并做了三件基建:OnFix 数据迁移(结构升级不用批量脚本)、 事件对象池(tick 事件 pop/push 复用 + 扩容上限治理)、 Repeater 循环节点边界完备(无限循环必须带超时和超时语义)。
我在上面扩了十余项 Action(子弹/毒圈/QTE/传送/集合等), 并做了三件基建:OnFix 数据迁移(结构升级不用批量脚本)、 事件对象池(tick 事件 pop/push 复用 + 扩容上限治理)、 Repeater 循环节点边界完备(无限循环必须带超时和超时语义)。
配置数据结构升级时老关卡怎么兼容工程
OnFix 自动迁移:以传送落点为例,旧字段 ScatterRadius 要升级成结构化
LandingConfig——保存老关卡时 OnFix 自动把「半径 > 0」映射成
RandomInRadius 模式,然后 delete 旧字段、返回 fixed 标记落盘。
关键是迁移摊销在编辑期:老数据被触碰一次就永久升级, 运行时代码里只存在新结构,不需要每处读取都写 if 旧字段, 兼容代码不会永久堆在运行链路里。
关键是迁移摊销在编辑期:老数据被触碰一次就永久升级, 运行时代码里只存在新结构,不需要每处读取都写 if 旧字段, 兼容代码不会永久堆在运行链路里。
阵型自动排点的算法和取舍算法
方形螺旋:中心点 + 一层层方圈。第 index 点的层数用
ceil((sqrt(index+1)-1)/2) 反解(奇数平方数是每层末角),
四段 if 覆盖四条边,间距常量统一缩放。
取舍在「人机协作」:算法只负责补齐缺的点, 策划手动配置过的点永不截断;新补的点用平方距离检查与现有点的间距 (避免开方),不满足就沿螺旋外扩。编辑器工具的信任来自 「机器不悄悄改用户数据」。
取舍在「人机协作」:算法只负责补齐缺的点, 策划手动配置过的点永不截断;新补的点用平方距离检查与现有点的间距 (避免开方),不满足就沿螺旋外扩。编辑器工具的信任来自 「机器不悄悄改用户数据」。