首页 / 战斗侧 / 子弹 / 关卡子弹功能开发

关卡子弹功能开发

让关卡(而不是怪物 AI)能主动发射子弹:飞船的导弹、场景机关的激光、剧情里的炮击。 本模块是入口层——把「关卡想打一发子弹」这个意图,翻译成战斗组那套通用子弹框架能吃的 IBulletCreateData。核心难点有三个:关卡没有「发射者」实体(用环境空怪当替身)、 15 位 TypeId 要在编辑器里自动拼、发射器是持续物必须能销毁。

环境空怪 15 位 TypeId 宇宙编辑器 Scheme BP 反向调用 跨组边界

1. 边界:本模块管哪一段

先划清楚,否则容易查错地方 Src/Fight/FightModules/FightBullet/ 有 25166 行, 那是战斗组的通用子弹框架(弹道积分、碰撞、CtTask 行为树、网络同步、剔除、对象池), 不是本模块。本模块负责的是把关卡意图接进那套框架的入口层,一共 5 个文件、不到 300 行。
层文件行数归属
入口层(本模块) Src/Game/AI/Controller/LevelBulletController.ts104Seq/关卡触发的具体发射实例
Src/Game/AI/Environment/EnvMonsterController.ts113环境空怪管理 + 子弹句柄表
Src/Game/AI/Environment/TsEnvMonsterBlueprintFunctionLibrary.ts37暴露给蓝图的 3 个静态入口
Src/Game/LevelGamePlay/LevelEvents/LevelEventDestroyEnvBullet.ts14关卡行为「删除关卡子弹」
Src/UniverseEditor/.../Action/LevelPlay.ts~120编辑器 Scheme + TypeId 自动拼装
执行层(战斗组) Src/Fight/FightModules/FightBullet/**25166只需知道调用契约,不逐文件深挖

1.1 与执行层的唯一契约

入口层跟执行层之间只有一个函数:

ControllerHolder.FightBulletController.CreateBulletWithSync(createData: IBulletCreateData): bigint
ControllerHolder.FightBulletController.DestroyBullet(bulletId: bigint, reason: string): void

只要把 IBulletCreateData 填对,剩下的弹道、碰撞、同步全是执行层的事。 所以本模块的全部技术含量,就是「怎么把 createData 填对」。

2. 核心设计:环境空怪当发射者替身

2.1 问题:关卡没有实体

通用子弹框架的硬性前提 IBulletCreateData.SpawnerInfo 要求一个真实存在的发射者实体(entityId + typeId), 因为子弹要从它身上取伤害属性、阵营、技能上下文。 但「飞船打导弹」这个需求里,飞船是个 LevelSequence 绑定的场景 Actor,它不是 entity, 没有 CharTypeId、没有属性面板、没有阵营。

2.2 解法:场景里放一只看不见的怪

1
关卡里摆一只 BP_BaseEnvironmentMonster_C
没模型、没碰撞、不参与战斗,但它是一个完整的 entity:有 CharTypeId、有属性、有阵营
2
它创建时把自己注册进 EnvMonsterController
SetCurrentEnvMonster(entityId),要求 aiComp.IsEnvironment === true
3
发射时把「从哪打」写进环境怪的 BP 变量
CurrentEnvMonster.BulletBornTransform = bornTransform ——子弹的动态上下文由环境怪的 BP 本身承载
4
用环境怪的身份创建子弹
发射者是环境怪,出生点是刚写进去的 Transform。执行层完全无感知
public LaunchEnvBulletActor(bulletHandle: string, bulletTypeId: number,
    bornTransform: UE.Transform, customTarget: UE.Actor | undefined, reason = ''): void {
    if (this.CurrentEnvMonster === undefined) {
        Log.Info(ELogModule.AI, ELogAuthor.CYH, '环境怪不存在,无法触发环境子弹',
            ['typeId', bulletTypeId], ['reason', reason]);
        return;                                  // 关卡没摆环境怪 → 静默失败但有日志
    }
    // 子弹的动态上下文由环境怪的BP本身承载
    this.CurrentEnvMonster.BulletBornTransform = bornTransform;
    const bulletId = this.CreateEnvBullet(bulletTypeId, customTarget, EFightBulletConfigType.Actor);
    this.HandleMap.set(bulletHandle, bulletId);
}
为什么把 Transform 写进 BP 变量,而不是当参数传下去 因为执行层取出生点的路径是「问发射者实体要」,改成「支持外部传入」要动战斗组的公共代码。 把值塞进环境怪的 BP 变量,用现有取值路径就能读到,零侵入。 代价是:同一帧内不能并发发两发不同出生点的子弹——后写的会覆盖先写的。 实际用法是「写 → 立刻创建 → 再写下一发」,同步执行所以安全; 但如果哪天改成异步创建,这里会立刻炸。

2.3 单例语义:只有一只环境怪

// 后续数据多了再抽独立Model
protected CurrentEnvMonster: UE.BP_BaseEnvironmentMonster_C | undefined = undefined;

public SetCurrentEnvMonster(entityId: number): void {
    const aiComp = EntityInterfaceHelper.GetComponent(entityId, EComponent.CharacterAiComponent);
    const actorComp = EntityInterfaceHelper.GetComponent(entityId, EComponent.CharacterActorComponent);
    const envMonster = ObjectUtils.Cast(actorComp?.Actor, UE.BP_BaseEnvironmentMonster_C);
    if (aiComp?.IsEnvironment && envMonster !== undefined && envMonster !== this.CurrentEnvMonster) {
        if (this.CurrentEnvMonster !== undefined) {
            Log.Info(..., '添加了新的环境怪,会将老的替换掉',
                ['oldId', ...], ['newId', entityId]);
        }
        this.CurrentEnvMonster = envMonster;
    } else {
        Log.Error(..., '尝试将非环境的怪加入环境管理中', ['id', entityId]);
    }
}

public ClearEnvMonster(entityId: number): void { /* 只清「自己是当前那只」的情况 */ }
设计点取舍
全局只有一只 CurrentEnvMonster后来者覆盖前者,但打 Info 日志留痕。策划在一张图里摆两只环境怪时,靠这条日志能查出来
ClearEnvMonster 只清自己怪 A 销毁时不能把「已经接管的怪 B」清掉。经典的「只清自己」防串号写法
非环境怪走 Log.Error不是 Warn。因为这一定是配置错误(把普通怪的 AI 配成了环境管理),必须让 QA 看到红字
注释「后续数据多了再抽独立 Model」诚实标注技术债。目前只有一个字段,硬拆 Model 是过度设计

3. 15 位 TypeId 拼装规则

这是整个模块最值得讲的一段——它是客户端 TS、UE C++、编辑器三端共用的一套编码约定。

3.1 位段布局

// 15 位定长十进制串,从左到右四段
//  ┌────────── 6 位 ──────────┬─── 5 位 ───┬─ 3 位 ─┬ 1 位 ┐
//  │      CharTypeId          │ BulletTypeId│SubBullet│Config│
//  └──────────────────────────┴─────────────┴─────────┴──────┘
//   示例: 009501         00003        000       1
//         └ 环境怪 9501  └ 子弹表 3   └ 无子弹  └ 发射器
段位宽取值范围含义
CharTypeId60–999999发射者角色类型 Id。关卡子弹这里恒为环境怪的 ConfigId
BulletTypeId50–99999子弹表的 RowName。策划在子弹表里填的那一行
SubBulletId30–999子子弹表 Id。关卡子弹恒为 000(关卡不配子子弹)
Config10 / 10 = 子弹(Actor),1 = 发射器(Emitter)

3.2 CharTypeId 的规范化(执行层侧)

战斗组那边还有一层 NormalizeCharTypeId,因为策划表里的角色 Id 有 4 位和 6 位两种写法:

/** 4位(1000-9999)→ value*100+1,例如 1005 → 100501
 *  6位(>=100000)→ 原样返回,完整保留后两位,例如 700499 → 700499 */
public static NormalizeCharTypeId(charTypeId: number): number {
    if (charTypeId >= 100000 && charTypeId < 1000000) { return charTypeId; }
    if (charTypeId >= 1000 && charTypeId < 10000) { return charTypeId * 100 + 1; }
    BulletLog.Warn(..., 'CharTypeId超出预期范围', ['CharTypeId', charTypeId]);
    return charTypeId;   // 告警后原样返回,暴露问题而不是静默兜底
}
这里有一个容易踩的不一致 编辑器侧的 GetBulletIdFromInfo 不做 Normalize,直接 padStart(6, '0')。所以环境怪 9501 在编辑器里被拼成 009501, 而不是 950101。
这在当前是对的——因为环境怪走的是「服务器 Id 直接查表」的路径,不经过 Normalize。 但如果哪天想让关卡子弹复用普通角色当发射者,两侧规则就会打架。 排查「子弹配置找不到」时,先把两边的 6 位段打出来比对。

3.3 编辑器侧的拼装实现

/** 根据实体配置ID、子弹配置ID和子弹类型ID计算子弹ID
 *  实现与UE的GetBulletIdFromInfo相同的功能 */
function GetBulletIdFromInfo(EntityConfigId: number,
    BulletConfigId: number, BulletTypeId: number): number {
    const CharTypeIdStr   = EntityConfigId.toString().padStart(6, '0');
    const BulletTypeIdStr = BulletConfigId.toString().padStart(5, '0');
    const SubBulletIdStr  = '0'.padStart(3, '0');        // 恒为 '000'
    const ConfigStr       = BulletTypeId === 1 ? '1' : '0';   // 1=发射器 0=子弹
    const bulletIdStr = `${CharTypeIdStr}${BulletTypeIdStr}${SubBulletIdStr}${ConfigStr}`;
    return parseInt(bulletIdStr);   // 15 位
}
⚠️ 这个函数的形参命名是错位的,读代码时会被坑
形参名实际语义被填进哪一段
EntityConfigId环境怪 ConfigIdCharTypeId(6 位)✔ 名实相符
BulletConfigId子弹表 RowNameBulletTypeIdStr
BulletTypeId其实是 EEnvBulletType(0/1)Config 位
也就是说:形参 BulletConfigId → 变量 BulletTypeIdStr, 形参 BulletTypeId → Config 位,两个名字互相串了。 注释里说「与 UE 的 GetBulletIdFromInfo 相同」,大概率是照抄 C++ 签名时把语义带歪了。 改这段代码前务必先看调用点 fixServerBulletId 怎么传的,不要按名字理解。

3.4 自动修正 fixServerBulletId

function fixServerBulletId(to: ILaunchBullet): number {
    let typeId = to.BulletDamageSourceType?.ConfigId ?? 0;
    if (to.BulletDamageSourceType.Type === EEnvDamageSourceType.Default && typeId === 0) {
        typeId = 9501;                                  // 默认环境怪配置ID
        to.BulletDamageSourceType.ConfigId = typeId;   // 回写,让界面上也能看到
    }
    const finalTypeId = to.BulletConfigId;
    if (finalTypeId === undefined || finalTypeId === 0) { return 0; }  // 策划还没填,不拼
    const bulletType = to.BulletType !== undefined ? to.BulletType : 0;
    const bulletTypeId = GetBulletIdFromInfo(typeId, finalTypeId, bulletType);
    to.BulletTypeId = bulletTypeId;
    return bulletTypeId;
}

默认环境怪 ConfigId = 9501。这是个魔数,直接写死在函数里, 策划选「伤害来源 = 默认」时就用它。可优化点 按团队规范这里应该抽成带中文 JSDoc 的常量。

4. 编辑器 Scheme:三件套的教科书用法

launchBulletScheme 是全工程里 OnModify / OnSchemeCheck / OnFix 三件套用得最完整的一个例子,值得单独讲。

export const launchBulletScheme = createActionParamsScheme<ILaunchBullet>({
    Name: EAction.LaunchBullet, CnName: '发射关卡子弹', ShowName: true, NewLine: true,
    Fields: {
        BulletDamageSourceType: envDamageSourceScheme,
        BulletType: createConstEnumScheme<typeof EEnvBulletType>({
            CnName: '子弹类型',
            NameByValue: { [EEnvBulletType.Bullet]: '子弹', [EEnvBulletType.Emitter]: '发射器' },
        }) as Scheme<EEnvBulletType>,
        BulletConfigId: createIntScheme({ CnName: '子弹类型Id',
            Tip: '关联子弹表的RowName,关联战斗字段BulletTypeId' }),
        BulletTypeId: createIntScheme({ CnName: '子弹服务器Id(只读)',
            ReadonlyType: 'Always',
            Tip: '由BulletDamageSourceType的EntityId、BulletTypeId和BulletType自动计算' }),
        BulletSpawnType: bulletSpawnPositionScheme,
        BulletTargetType: bulletTargetPositionScheme,
        Mark: createStringScheme({ CnName: '标识符', Optional: true,
            Tip: '对于持续发射子弹的子弹发射器而言,需要有唯一标志符,来让删除子弹时找到对应的子弹',
            CreateDefault: () => '' }),
    },

    // ① 任何字段改动 → 立刻重算 TypeId
    OnModify: (from, to, type): void => { fixServerBulletId(to); },

    // ② 存盘/全量校验 → 报错但不改数据
    OnSchemeCheck: (value, messages): number => {
        let errorCount = 0;
        if (value.BulletTypeId <= 0) {
            messages.push(`子弹服务器Id计算错误: ${value.BulletTypeId}`); errorCount++;
        }
        const bulletIdStr = value.BulletTypeId.toString();
        if (bulletIdStr.length !== 15) {
            messages.push(`子弹服务器Id长度错误: ${value.BulletTypeId} (应为15位)`); errorCount++;
        }
        return errorCount;
    },

    // ③ 一键修复 → 重跑计算
    OnFix: (value): TFixResult => { fixServerBulletId(value); return 'fixed'; },
});

4.1 三件套各自的时机与职责

钩子触发时机能不能改数据本例做什么
OnModify用户在面板上改任一字段能实时重算 BulletTypeId,用户马上看到结果
OnSchemeCheck存盘 / 全量扫描不能只 push 报错信息,返回错误条数
OnFix用户点「一键修复」能重跑 fixServerBulletId,返回 'fixed'
这套模式为什么好用 ReadonlyType: 'Always' 让 BulletTypeId 在界面上可见但不可编辑—— 策划能看到算出来的 15 位串(方便对 bug),但改不了(避免手填错)。
真正的输入只有两个:子弹类型(0/1) 和 子弹类型 Id(表 RowName)。 三件套保证了「无论老存档、批量导入、还是手改,最终落盘的 TypeId 一定是算出来的」。

这是可以直接抄到别的 Scheme 上的范式:凡是有「衍生字段」的行为, 都应该 OnModify 实时算 + ReadonlyType:'Always' 锁编辑 + OnFix 补救老数据。

4.2 完整字段表

字段类型中文名说明
BulletTypeEEnvBulletType子弹类型0=子弹(打完就没)/1=发射器(持续产出)
BulletConfigIdint子弹类型Id子弹表 RowName,策划唯一要填的核心字段
BulletTypeIdint子弹服务器Id(只读)15 位,自动算
BulletDamageSourceTypeIEnvDamageSourceType伤害来源Default(环境怪) / MonsterEntity / Player
BulletSpawnTypeIBulletPositionType出生位置Position / Entity / Filter / NoTarget
BulletTargetTypeIBulletPositionType目标位置同上
Markstring?标识符见第 6 节

4.3 伤害来源与位置类型枚举

/** 关卡伤害来源类型 */
export const enum EEnvDamageSourceType {
    Default = 'Default',               // 默认 - 环境怪(ConfigId 兜底 9501)
    MonsterEntity = 'MonsterEntity',   // 指定一只怪当发射者
    Player = 'Player',                 // 玩家当发射者(走 FindRules 筛)
}

/** 子弹位置类型 */
export const enum EEnvBulletPositionType {
    Position = 'Position',   // 写死坐标
    Entity = 'Entity',       // 挂到某个实体身上
    Filter = 'Filter',       // 用筛选规则动态找
    NoTarget = 'NoTarget',   // 无目标(直线飞)
}

export interface IEnvDamageSourceType {
    Type: EEnvDamageSourceType;
    Location?: IVectorInfo; Rotation?: IRotatorInfo;   // 坐标类型时使用
    EntityId?: int; ConfigId?: int;                    // 实体类型时使用
    FindRules?: IEntityFindRules;                      // 玩家类型时使用
}
「一个接口塞三套字段」的取舍 IEnvDamageSourceType 用 Type 判别 + 全 optional 字段, 而不是三个独立接口做联合类型。好处是编辑器 Scheme 好写(字段按 Type 动态显隐)、 老存档换 Type 时数据不丢;坏处是 TS 类型系统帮不上忙, 取 ConfigId 时必须自己保证 Type 对。 宇宙编辑器里这类「判别式 + optional」是通行写法,看到别以为是没设计好。

5. 网络分类 EFightBulletNetType

关卡子弹最终会流进战斗组的网络分类逻辑。虽然是执行层的事, 但排查「只有自己看得见 / 别人看得见自己看不见」这类 bug 必须懂。

export const enum EFightBulletNetType {
    /** 非同步:两端各自本地创建/销毁,不发协议 */
    LocalOnly = 0,
    /** 客户端权威:主控端本地发起 + 发协议,其余端等 Notify */
    ClientAuthority = 1,
    /** 服务器权威:服务器发创建协议,主控端(环境怪主控端)负责回发销毁协议 */
    ServerAuthority = 2,
}

5.1 分类的决策顺序

1
根子弹(无父)→ ClientAuthority
关卡子弹全都走这条。ServerAuthority 不经本地计算,只由协议下发
2
父子弹实例缺失 → LocalOnly + Warn
链路异常时降级为本地,宁可少同步也不能错发协议
3
父链非同步 → LocalOnly
单调向下:一旦非同步,永久非同步。父都不同步,子同步了没意义
4
父链同步 → 看是不是 FireCommand 发射
是 → 读父配置 FireSub.NeedSync(默认 true);不是 → 继承 ClientAuthority
ServerAuthority 不沿链传播 父是 ServerAuthority 时,子降级为 ClientAuthority。 因为「服务器权威」表达的是「这一发是服务器创建的」,这个性质不该被子弹继承。

5.2 排查表:子弹同步异常

现象大概率原因怎么查
只有自己看得见被判成 LocalOnly搜日志 CalcNetType 的 Warn「父子弹不存在」
别人看得见自己没有协议来了但本地创建失败搜 ParseCreateNotify 的「接收创建子弹实体信息丢失」
子弹销毁不同步销毁协议没发/没收ServerAuthority 要求环境怪主控端回发销毁协议,主控端不在场就漏
寿命两端不一致endTimeMs 换算问题服务器下发的是绝对时间戳,各端自算剩余寿命,服务器时间不同步会飘

6. Mark:发射器的生命周期管理

6.1 为什么只有发射器需要 Mark

子弹(Bullet / Actor)发射器(Emitter)
行为打一发,飞完即销毁持续按频率产子弹,不会自己停
需要 Mark不需要必须
删除行为不用配必须配「删除关卡子弹」,否则永远打下去

Scheme 的 Tip 把这件事说得很清楚:

Tip: '删除标识符对应的子弹。如果该子弹是单次子弹,而非子弹发射器,' +
     '则不需要配置该行为,它会在发射后自动删除。'

6.2 句柄表 HandleMap

private readonly HandleMap: Map<string, bigint> = new Map();

// 主动销毁环境子弹/子弹发射器
public DestroyEnvBulletEntity(handle: string, reason = 'DestroyEnvBulletEntity'): void {
    if (this.HandleMap.has(handle)) {
        const bulletId = this.HandleMap.get(handle)!;
        if (bulletId !== 0n) {
            ControllerHolder.FightBulletController.DestroyBullet(bulletId, reason);
        }
        this.HandleMap.delete(handle);   // 无论 bulletId 是否有效都要清表,防泄漏
    }
}
⚠️ 有两个同名 HandleMap,类型不一样
类类型状态
EnvMonsterController.HandleMapMap<string, bigint>在用,真正的句柄表
LevelBulletController.HandleMapMap<string, number>遗留死字段,声明了但从未写入
bulletId 是 bigint(int64),number 那个一看就是早期版本没跟上 bigint 化改造留下的。清理时可以直接删掉 LevelBulletController.HandleMap。

6.3 Mark 的三个约束

1
同一 Mark 重复发射会丢失前一个句柄
HandleMap.set 直接覆盖,老的 bulletId 再也找不到,永久泄漏。 策划配同名 Mark 是最常见的「删不掉的发射器」原因
2
Mark 是 Optional: true,编辑器不强制填
选了「发射器」但没填 Mark,Scheme 不会报错。 可优化点 OnSchemeCheck 里应该加 「BulletType === Emitter 且 Mark 为空 → 报错」
3
删除时 Mark 拼错 → 静默无效
DestroyEnvBulletEntity 的 if (has) 判断为假就直接返回, 没有 else 分支的日志。可优化点 应补一条 Warn

7. 三条调用入口

7.1 蓝图入口(主路径)

export class TsEnvMonsterBlueprintFunctionLibrary {
    public static NamiLaunchEnvBulletActor(handle: string,
        /*@cpp: int*/ typeId: number, bornTransform: UE.Transform,
        target: UE.Actor | undefined): void {
        ControllerHolder.EnvMonsterController.LaunchEnvBulletActor(handle, typeId, bornTransform, target);
    }
    public static NamiLaunchEnvBulletEmitter(...): void { /* 同构 */ }
    public static NamiDestroyEnvBulletEntity(handle: string): void {
        ControllerHolder.EnvMonsterController.DestroyEnvBulletEntity(handle);
    }
}

注册在 Src/Game/ScriptedBlueprint/RegistrationStubs.ts:2572:

const tsEnvMonsterBlueprintFunctionLibrary = (
    await import('../AI/Environment/TsEnvMonsterBlueprintFunctionLibrary')
)...;
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_NamiDestroyEnvBulletEntity,
    tsEnvMonsterBlueprintFunctionLibrary.NamiDestroyEnvBulletEntity);
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_NamiLaunchEnvBulletActor,
    tsEnvMonsterBlueprintFunctionLibrary.NamiLaunchEnvBulletActor);
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_NamiLaunchEnvBulletEmitter,
    tsEnvMonsterBlueprintFunctionLibrary.NamiLaunchEnvBulletEmitter);
ScriptedBlueprintAlternative 机制 这不是普通的 puerts 导出,而是「蓝图函数的 TS 替身」: C++ 侧声明一个 EAlternativeBlueprintFunction 枚举值,蓝图里调那个 C++ 函数, 运行时被路由到 TS 实现。/*@cpp: int*/ 注解是给代码生成器看的, 告诉它这个 number 在 C++ 侧是 int32 不是 float。

7.2 关卡行为入口(只有删除)

export class LevelEventDestroyEnvBullet extends LevelEventBase {
    public override ExecuteNew(inParams: IActionParams, context: TGeneralContext, actionId?: number): void {
        const params = inParams as IDestroyEnvBullet;
        Log.Info(ELogModule.Level, ELogAuthor.TJH, 'LevelEventDestroyEnvBullet', ['Mark', params.Mark]);
        ControllerHolder.EnvMonsterController.DestroyEnvBulletEntity(params.Mark);
    }
}
已查证:这个类没有注册进 LevelEventCenter LevelEventCenter.RegisterEvents() 里 24 条 setup(...) 全列过一遍, 没有 EAction.DestroyEnvBullet,也没有 EAction.LaunchBullet。 原因在 Src/UniverseEditor/Common/Operation/Action.ts:320:
[EAction.LaunchBullet]:     { Context: ['entity', 'levelPlay', 'wildEvent'], ImplementType: 'Server' },
[EAction.DestroyEnvBullet]: { Context: ['entity', 'levelPlay', 'wildEvent'], ImplementType: 'Server' },
两条行为都是 ImplementType: 'Server'——由服务器执行, 客户端不注册执行器。LevelEventDestroyEnvBullet.ts 是写了但没接线的死代码 (可能是早期按 Client 实现、后来改成 Server 忘了删)。
所以关卡子弹的真实执行链路是这样的 不是「客户端关卡行为 → 客户端发子弹」,而是:
① 策划在编辑器里配「发射关卡子弹」行为(ImplementType: 'Server')
② 服务器执行这条行为,走服务器的子弹创建,下发 CombatCreateBulletNotify
③ 客户端 ParseCreateNotify 收到,按 isCreateByServer 判成 ServerAuthority,本地创建表现

而 LevelBulletController / EnvMonsterController 那条 BP 路径是 纯客户端表现链路(ClientAuthority), 用于 Seq 里的演出型子弹。两条链路并存、互不相干—— 这是排查「关卡子弹没出来」时第一个要分清的岔路。

7.3 具体业务实例:P40 飞船导弹

private readonly OnP40SpawnBullet = (bulletId: number, tag = 'P40SK'): void => {
    LevelBulletController.SpawnBulletStat?.Start();      // 性能埋点
    const actor = this.GetBindingPositionByTag(tag);
    const mesh = actor?.GetComponentByClass(UE.SkeletalMeshComponent.StaticClass()) as UE.SkeletalMeshComponent;
    if (mesh) {
        const key1 = 'RightMissileLauncher02';
        const bornTransform1 = mesh.GetSocketTransform(FNameUtil.GetDynamicFName(key1));
        const allEntityHandle = EntityInterfaceHelper.GetAllEntities();
        for (const entityHandle of allEntityHandle ?? []) {
            if (entityHandle.EntityType !== Nami.Protocol.EntityType.ENTITY_TYPE_ROLE) { continue; }
            const targetActor = EntityInterfaceHelper.GetActorByEntityId(entityHandle.Id);
            if (!targetActor) { continue; }
            ControllerHolder.EnvMonsterController.LaunchEnvBulletEmitter(
                key1, 12 /*右*/, bornTransform1!, targetActor,
                'LevelBulletController.OnP40SpawnBullet' + key1);
            TimerSystem.Delay(() => {                          // 左右交替 100ms 错峰
                const key2 = 'LeftMissileLauncher02';
                const bornTransform2 = mesh.GetSocketTransform(FNameUtil.GetDynamicFName(key2));
                ControllerHolder.EnvMonsterController.LaunchEnvBulletEmitter(
                    key2, 13 /*左*/, bornTransform2!, targetActor, '...' + key2);
            }, 100);
        }
    }
    LevelBulletController.SpawnBulletStat?.Stop();
};

/** 通过标签名称获取LevelSequence中绑定对象的位置 */
private GetBindingPositionByTag(bindingName: string): UE.Actor | undefined {
    return UE.KuroCollectActorComponent.GetActorWithTag(
        FNameUtil.GetDynamicFName(bindingName), UE.ECollectActorType.Default) as UE.KuroLevelSequenceActor;
}
写法用意评价
Stat.Create('OnP40SpawnBullet.GetAllEntities')性能埋点,圈住整个遍历好 遍历全实体是热点,值得埋
左右发射器错峰 100ms视觉上「左右交替发射」而不是齐射好 纯表现调优
Socket 名硬编码RightMissileLauncher02 / LeftMissileLauncher02可改 应抽常量或走配置
子弹 Id 硬编码 12 / 13右 12、左 13该改 裸魔数,看不出含义
句柄直接用 socket 名key1 当 Mark风险 多艘船同时在场会句柄互相覆盖
for 里给每个玩家各发一发每个玩家看到导弹追自己注意 人多时子弹数线性膨胀,逼近 ActiveBulletLimit=200
这段代码有个隐蔽的 Mark 覆盖 bug 外层 for 遍历所有玩家实体,但内层每次都用同一个 key1 当 handle。 所以 HandleMap.set(key1, bulletId) 会被后面的玩家覆盖, 最终表里只剩最后一个玩家的那发发射器——前面 N−1 发永远删不掉。 单人测试发现不了,多人联机才会暴露。

8. 常见修改场景

8.1 加一种新的关卡子弹(策划路径)

1
战斗组子弹表加一行
拿到 RowName,就是 BulletConfigId
2
编辑器加「发射关卡子弹」行为
填 子弹类型(子弹/发射器)+ 子弹类型Id;发射器必填 Mark
3
检查 BulletTypeId 是不是 15 位
只读框里自动出现。不是 15 位就是配错了,点「一键修复」
4
确认关卡里有环境怪
走 BP 路径时必需;走服务器路径由服务器保证
5
发射器记得配「删除关卡子弹」
Mark 要跟发射时一模一样

8.2 加一个新的 Seq 触发点(程序路径)

照抄 OnP40SpawnBullet,但把上面表格里标 该改 / 风险 的几处改掉:

/** XX 飞船左右导弹发射器的骨骼插槽名 */
const MissileSockets = ['RightMissileLauncher02', 'LeftMissileLauncher02'] as const;
/** 左右导弹的子弹表 RowName(右 12 / 左 13) */
const MissileBulletIds = [12, 13] as const;
/** 左右交替发射的错峰间隔,避免视觉上像齐射 */
const MissileStaggerMs = 100;

// handle 带上玩家 entityId,避免多目标时句柄互相覆盖
const handle = `${socketName}_${entityHandle.Id}`;

8.3 排查「关卡子弹没出来」

1
先分清走的是哪条链路
编辑器配的行为 → 服务器链路,查服务器日志; Seq/BP 触发 → 客户端链路,往下走
2
grep 环境怪不存在,无法触发环境子弹
有 → 关卡里没摆环境怪,或环境怪还没创建完(时序问题)
3
grep 尝试将非环境的怪加入环境管理中
有 → 那只怪的 aiComp.IsEnvironment 没配 true
4
检查 BulletTypeId 是不是 15 位
不是 → 编辑器没算对;是 → 用 BulletConfigUtils.FormatConfigUid 拆成 009501-00012-000-1 逐段对配置表
5
grep 接受创建子弹通知 / 接收创建子弹实体信息丢失
进到执行层了,问题在战斗组那边

8.4 排查「发射器停不下来」

检查项怎么确认
Mark 拼写一致?发射和删除两处的 Mark 逐字符比对。大小写敏感
Mark 有没有被覆盖?同一 Mark 发射多次 → 只有最后一发删得掉
删除行为真的执行了?grep LevelEventDestroyEnvBullet。注意这个类是死代码, 编辑器配的删除走服务器
子弹本身有寿命吗?发射器配置里的 LifeTime。配了寿命就不用手动删

9. 已识别的技术债清单

#问题位置严重度
1GetBulletIdFromInfo 形参命名与语义错位LevelPlay.ts中 误导读代码的人
2默认环境怪 9501 是裸魔数fixServerBulletId中
3LevelBulletController.HandleMap 是死字段LevelBulletController.ts低 直接删
4LevelEventDestroyEnvBullet 未注册,是死代码LevelEvents/中 误导排查方向
5P40 多玩家时 Mark 互相覆盖OnP40SpawnBullet高 联机泄漏
6Emitter 未强制校验 Mark 非空launchBulletScheme中 应加进 OnSchemeCheck
7DestroyEnvBulletEntity 查不到 handle 时无日志EnvMonsterController中 静默失败
8Socket 名 / 子弹 Id 硬编码OnP40SpawnBullet低
9BulletBornTransform 写 BP 变量,不支持并发发射EnvMonsterController中 改异步就炸

10. 面试怎么讲

关卡想发子弹,但关卡不是实体、没有发射者,怎么解决架构
战斗组那套通用子弹框架有个硬性前提:必须有一个真实的发射者实体, 因为子弹要从它身上取伤害属性、阵营、技能上下文。 但关卡里的飞船是个 LevelSequence 绑定的场景 Actor,不是 entity。

方案是引入「环境空怪」这个替身——在关卡里摆一只 BP_BaseEnvironmentMonster_C,没模型没碰撞不参战,但它是完整 entity, 有 CharTypeId、有属性面板。发射时以它的身份创建子弹。

关键细节是出生点怎么传。执行层取出生点的路径是「问发射者实体要」, 改成支持外部传参要动战斗组公共代码。所以把 Transform 写进环境怪的 BP 变量 BulletBornTransform, 现有取值路径就能读到,零侵入。

代价是明确的:同帧不能并发发两发不同出生点的子弹,后写覆盖先写。 目前是同步执行所以安全,但这个约束必须写进文档——哪天改异步创建这里会立刻炸。
讲讲那个 15 位 TypeId 的设计设计
它是客户端 TS、UE C++、编辑器三端共用的编码约定: 15 位定长十进制串,从左到右 CharTypeId(6) + BulletTypeId(5) + SubBulletId(3) + Config(1)。 最后一位 0 是子弹、1 是发射器。

用字符串拼接 + padStart而不是位运算,是刻意的—— 十进制串肉眼可读,日志里打出 009501-00012-000-1 一眼就能对上配置表的哪一行。位运算虽然快,但排 bug 时得拿计算器拆。 子弹创建是低频操作,这点性能不值得换可读性。

工程上编辑器侧配了三件套保证它永远算得对: OnModify 用户改任一字段就实时重算; 字段设 ReadonlyType: 'Always',策划看得见改不了; OnSchemeCheck 存盘时校验「必须大于 0 且长度恰好 15」; OnFix 让老存档能一键修复。

这套「衍生字段 = 实时算 + 锁编辑 + 存盘校验 + 一键修复」的范式, 在别的 Scheme 上也可以直接复用。
为什么发射器要 Mark 而子弹不用细节
因为两者生命周期语义不同:子弹是一次性的,飞完自己销毁; 发射器是持续物,不主动删就永远打下去。所以发射器必须能被反查到—— 这就是 Mark 的作用,Map<string, bigint> 从字符串标识符查回 bulletId。

这个设计有三个坑: ① 同 Mark 重复发射会覆盖句柄,前一发永久泄漏; ② Mark 在 Scheme 里是 Optional,选了发射器不填 Mark 也不报错; ③ 删除时 Mark 拼错静默失败没日志。

第一个坑在 P40 飞船那段代码里真实存在——外层遍历所有玩家各发一发, 但 handle 都用同一个 socket 名,多人联机时只有最后一个删得掉。 修法是 handle 拼上玩家 entityId。单人测试永远发现不了这类 bug, 这也是为什么句柄类的东西要先想一句「会不会撞」。
关卡子弹的两条链路具体是怎么回事深挖
这是一个容易看漏的岔路,排查问题时不分清会白查半天。

链路一(服务器):策划在宇宙编辑器里配的「发射关卡子弹」行为, 在 Action.ts 里是 ImplementType: 'Server'—— 服务器执行,下发 CombatCreateBulletNotify, 客户端 ParseCreateNotify 按 isCreateByServer 判成 ServerAuthority,只做表现。

链路二(客户端):Seq 或蓝图直接调 NamiLaunchEnvBulletEmitter,走 EnvMonsterController 本地创建,网络分类是 ClientAuthority。用于演出型子弹。

证据是 LevelEventCenter.RegisterEvents() 里 24 条 setup 全找过,没有 LaunchBullet 也没有 DestroyEnvBullet。 所以工程里那个 LevelEventDestroyEnvBullet.ts 其实是死代码—— 写了但从来没接线,大概是早期按 Client 实现、后来改成 Server 忘了删。 这一条值得记进技术债清单,因为它会误导后来人的排查方向。
这块 25000 行代码需要全都熟吗陷阱题
不需要,也不该。Src/Fight/FightModules/FightBullet/ 那 25166 行是 战斗组的通用子弹框架——弹道积分、碰撞、CtTask 行为树、网络同步、剔除、对象池, 属于另一个模块的范围。

关卡子弹管的是入口层,5 个文件不到 300 行: 把「关卡想打一发子弹」翻译成执行层能吃的 IBulletCreateData。 与执行层的契约只有两个函数:CreateBulletWithSync 和 DestroyBullet。

但边界不等于无知——联调要用到的部分必须摸透: EFightBulletNetType 三种分类和判定顺序(要排「只有自己看得见」)、 BulletConfigUtils 的 UID 拆装(要对配置)、 CharTypeId 规范化那层(编辑器侧不做 Normalize, 两边规则有潜在冲突)。

原则是:边界内的代码要能背,边界外的代码要能查、能对契约、能提出准确的问题。