首页 / 入职工作清单 / 行为树工具链

行为树工具链

入职以来覆盖面最大的工作线:行为树 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 循环节点边界完备(无限循环必须带超时和超时语义)。
配置数据结构升级时老关卡怎么兼容工程
OnFix 自动迁移:以传送落点为例,旧字段 ScatterRadius 要升级成结构化 LandingConfig——保存老关卡时 OnFix 自动把「半径 > 0」映射成 RandomInRadius 模式,然后 delete 旧字段、返回 fixed 标记落盘。
关键是迁移摊销在编辑期:老数据被触碰一次就永久升级, 运行时代码里只存在新结构,不需要每处读取都写 if 旧字段, 兼容代码不会永久堆在运行链路里。
阵型自动排点的算法和取舍算法
方形螺旋:中心点 + 一层层方圈。第 index 点的层数用 ceil((sqrt(index+1)-1)/2) 反解(奇数平方数是每层末角), 四段 if 覆盖四条边,间距常量统一缩放。
取舍在「人机协作」:算法只负责补齐缺的点, 策划手动配置过的点永不截断;新补的点用平方距离检查与现有点的间距 (避免开方),不满足就沿螺旋外扩。编辑器工具的信任来自 「机器不悄悄改用户数据」。