关卡子弹功能开发
让关卡(而不是怪物 AI)能主动发射子弹:飞船的导弹、场景机关的激光、剧情里的炮击。
本模块是入口层——把「关卡想打一发子弹」这个意图,翻译成战斗组那套通用子弹框架能吃的
IBulletCreateData。核心难点有三个:关卡没有「发射者」实体(用环境空怪当替身)、
15 位 TypeId 要在编辑器里自动拼、发射器是持续物必须能销毁。
环境空怪
15 位 TypeId
宇宙编辑器 Scheme
BP 反向调用
跨组边界
1. 边界:本模块管哪一段
先划清楚,否则容易查错地方
Src/Fight/FightModules/FightBullet/ 有 25166 行,
那是战斗组的通用子弹框架(弹道积分、碰撞、CtTask 行为树、网络同步、剔除、对象池),
不是本模块。本模块负责的是把关卡意图接进那套框架的入口层,一共 5 个文件、不到 300 行。
| 层 | 文件 | 行数 | 归属 |
|---|---|---|---|
| 入口层(本模块) | Src/Game/AI/Controller/LevelBulletController.ts | 104 | Seq/关卡触发的具体发射实例 |
| Src/Game/AI/Environment/EnvMonsterController.ts | 113 | 环境空怪管理 + 子弹句柄表 | |
| Src/Game/AI/Environment/TsEnvMonsterBlueprintFunctionLibrary.ts | 37 | 暴露给蓝图的 3 个静态入口 | |
| Src/Game/LevelGamePlay/LevelEvents/LevelEventDestroyEnvBullet.ts | 14 | 关卡行为「删除关卡子弹」 | |
| 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 === true3
发射时把「从哪打」写进环境怪的 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 └ 无子弹 └ 发射器
| 段 | 位宽 | 取值范围 | 含义 |
|---|---|---|---|
| CharTypeId | 6 | 0–999999 | 发射者角色类型 Id。关卡子弹这里恒为环境怪的 ConfigId |
| BulletTypeId | 5 | 0–99999 | 子弹表的 RowName。策划在子弹表里填的那一行 |
| SubBulletId | 3 | 0–999 | 子子弹表 Id。关卡子弹恒为 000(关卡不配子子弹) |
| Config | 1 | 0 / 1 | 0 = 子弹(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; // 告警后原样返回,暴露问题而不是静默兜底
}
这里有一个容易踩的不一致
编辑器侧的
这在当前是对的——因为环境怪走的是「服务器 Id 直接查表」的路径,不经过 Normalize。 但如果哪天想让关卡子弹复用普通角色当发射者,两侧规则就会打架。 排查「子弹配置找不到」时,先把两边的 6 位段打出来比对。
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 | 环境怪 ConfigId | CharTypeId(6 位)✔ 名实相符 |
| BulletConfigId | 子弹表 RowName | BulletTypeIdStr |
| 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' |
这套模式为什么好用
真正的输入只有两个:子弹类型(0/1) 和 子弹类型 Id(表 RowName)。 三件套保证了「无论老存档、批量导入、还是手改,最终落盘的 TypeId 一定是算出来的」。
这是可以直接抄到别的 Scheme 上的范式:凡是有「衍生字段」的行为, 都应该
ReadonlyType: 'Always' 让 BulletTypeId 在界面上可见但不可编辑——
策划能看到算出来的 15 位串(方便对 bug),但改不了(避免手填错)。真正的输入只有两个:子弹类型(0/1) 和 子弹类型 Id(表 RowName)。 三件套保证了「无论老存档、批量导入、还是手改,最终落盘的 TypeId 一定是算出来的」。
这是可以直接抄到别的 Scheme 上的范式:凡是有「衍生字段」的行为, 都应该
OnModify 实时算 + ReadonlyType:'Always' 锁编辑 + OnFix 补救老数据。
4.2 完整字段表
| 字段 | 类型 | 中文名 | 说明 |
|---|---|---|---|
| BulletType | EEnvBulletType | 子弹类型 | 0=子弹(打完就没)/1=发射器(持续产出) |
| BulletConfigId | int | 子弹类型Id | 子弹表 RowName,策划唯一要填的核心字段 |
| BulletTypeId | int | 子弹服务器Id(只读) | 15 位,自动算 |
| BulletDamageSourceType | IEnvDamageSourceType | 伤害来源 | Default(环境怪) / MonsterEntity / Player |
| BulletSpawnType | IBulletPositionType | 出生位置 | Position / Entity / Filter / NoTarget |
| BulletTargetType | IBulletPositionType | 目标位置 | 同上 |
| Mark | string? | 标识符 | 见第 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.HandleMap | Map<string, bigint> | 在用,真正的句柄表 |
| LevelBulletController.HandleMap | Map<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 分支的日志。可优化点 应补一条 Warn7. 三条调用入口
7.1 蓝图入口(主路径)
export class TsEnvMonsterBlueprintFunctionLibrary {
public static GameLaunchEnvBulletActor(handle: string,
/*@cpp: int*/ typeId: number, bornTransform: UE.Transform,
target: UE.Actor | undefined): void {
ControllerHolder.EnvMonsterController.LaunchEnvBulletActor(handle, typeId, bornTransform, target);
}
public static GameLaunchEnvBulletEmitter(...): void { /* 同构 */ }
public static GameDestroyEnvBulletEntity(handle: string): void {
ControllerHolder.EnvMonsterController.DestroyEnvBulletEntity(handle);
}
}
注册在 Src/Game/ScriptedBlueprint/RegistrationStubs.ts:2572:
const tsEnvMonsterBlueprintFunctionLibrary = (
await import('../AI/Environment/TsEnvMonsterBlueprintFunctionLibrary')
)...;
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_GameDestroyEnvBulletEntity,
tsEnvMonsterBlueprintFunctionLibrary.GameDestroyEnvBulletEntity);
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_GameLaunchEnvBulletActor,
tsEnvMonsterBlueprintFunctionLibrary.GameLaunchEnvBulletActor);
register(UE.EAlternativeBlueprintFunction.CppEnvMonsterBlueprintFunctionLibrary_GameLaunchEnvBulletEmitter,
tsEnvMonsterBlueprintFunctionLibrary.GameLaunchEnvBulletEmitter);
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 !== Game.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,就是
BulletConfigId2
编辑器加「发射关卡子弹」行为
填 子弹类型(子弹/发射器)+ 子弹类型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 没配 true4
检查
BulletTypeId 是不是 15 位不是 → 编辑器没算对;是 → 用
BulletConfigUtils.FormatConfigUid 拆成
009501-00012-000-1 逐段对配置表5
grep
接受创建子弹通知 / 接收创建子弹实体信息丢失进到执行层了,问题在战斗组那边
8.4 排查「发射器停不下来」
| 检查项 | 怎么确认 |
|---|---|
| Mark 拼写一致? | 发射和删除两处的 Mark 逐字符比对。大小写敏感 |
| Mark 有没有被覆盖? | 同一 Mark 发射多次 → 只有最后一发删得掉 |
| 删除行为真的执行了? | grep LevelEventDestroyEnvBullet。注意这个类是死代码,
编辑器配的删除走服务器 |
| 子弹本身有寿命吗? | 发射器配置里的 LifeTime。配了寿命就不用手动删 |
9. 已识别的技术债清单
| # | 问题 | 位置 | 严重度 |
|---|---|---|---|
| 1 | GetBulletIdFromInfo 形参命名与语义错位 | LevelPlay.ts | 中 误导读代码的人 |
| 2 | 默认环境怪 9501 是裸魔数 | fixServerBulletId | 中 |
| 3 | LevelBulletController.HandleMap 是死字段 | LevelBulletController.ts | 低 直接删 |
| 4 | LevelEventDestroyEnvBullet 未注册,是死代码 | LevelEvents/ | 中 误导排查方向 |
| 5 | P40 多玩家时 Mark 互相覆盖 | OnP40SpawnBullet | 高 联机泄漏 |
| 6 | Emitter 未强制校验 Mark 非空 | launchBulletScheme | 中 应加进 OnSchemeCheck |
| 7 | DestroyEnvBulletEntity 查不到 handle 时无日志 | EnvMonsterController | 中 静默失败 |
| 8 | Socket 名 / 子弹 Id 硬编码 | OnP40SpawnBullet | 低 |
| 9 | BulletBornTransform 写 BP 变量,不支持并发发射 | EnvMonsterController | 中 改异步就炸 |
10. 面试怎么讲
关卡想发子弹,但关卡不是实体、没有发射者,怎么解决架构
战斗组那套通用子弹框架有个硬性前提:必须有一个真实的发射者实体,
因为子弹要从它身上取伤害属性、阵营、技能上下文。
但关卡里的飞船是个 LevelSequence 绑定的场景 Actor,不是 entity。
方案是引入「环境空怪」这个替身——在关卡里摆一只
关键细节是出生点怎么传。执行层取出生点的路径是「问发射者实体要」, 改成支持外部传参要动战斗组公共代码。所以把 Transform 写进环境怪的 BP 变量
代价是明确的:同帧不能并发发两发不同出生点的子弹,后写覆盖先写。 目前是同步执行所以安全,但这个约束必须写进文档——哪天改异步创建这里会立刻炸。
方案是引入「环境空怪」这个替身——在关卡里摆一只
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而不是位运算,是刻意的—— 十进制串肉眼可读,日志里打出
工程上编辑器侧配了三件套保证它永远算得对:
这套「衍生字段 = 实时算 + 锁编辑 + 存盘校验 + 一键修复」的范式, 在别的 Scheme 上也可以直接复用。
用字符串拼接 + padStart而不是位运算,是刻意的—— 十进制串肉眼可读,日志里打出
009501-00012-000-1
一眼就能对上配置表的哪一行。位运算虽然快,但排 bug 时得拿计算器拆。
子弹创建是低频操作,这点性能不值得换可读性。工程上编辑器侧配了三件套保证它永远算得对:
OnModify 用户改任一字段就实时重算;
字段设 ReadonlyType: 'Always',策划看得见改不了;
OnSchemeCheck 存盘时校验「必须大于 0 且长度恰好 15」;
OnFix 让老存档能一键修复。这套「衍生字段 = 实时算 + 锁编辑 + 存盘校验 + 一键修复」的范式, 在别的 Scheme 上也可以直接复用。
为什么发射器要 Mark 而子弹不用细节
因为两者生命周期语义不同:子弹是一次性的,飞完自己销毁;
发射器是持续物,不主动删就永远打下去。所以发射器必须能被反查到——
这就是 Mark 的作用,
这个设计有三个坑: ① 同 Mark 重复发射会覆盖句柄,前一发永久泄漏; ② Mark 在 Scheme 里是
第一个坑在 P40 飞船那段代码里真实存在——外层遍历所有玩家各发一发, 但 handle 都用同一个 socket 名,多人联机时只有最后一个删得掉。 修法是 handle 拼上玩家 entityId。单人测试永远发现不了这类 bug, 这也是为什么句柄类的东西要先想一句「会不会撞」。
Map<string, bigint> 从字符串标识符查回 bulletId。这个设计有三个坑: ① 同 Mark 重复发射会覆盖句柄,前一发永久泄漏; ② Mark 在 Scheme 里是
Optional,选了发射器不填 Mark 也不报错;
③ 删除时 Mark 拼错静默失败没日志。第一个坑在 P40 飞船那段代码里真实存在——外层遍历所有玩家各发一发, 但 handle 都用同一个 socket 名,多人联机时只有最后一个删得掉。 修法是 handle 拼上玩家 entityId。单人测试永远发现不了这类 bug, 这也是为什么句柄类的东西要先想一句「会不会撞」。
关卡子弹的两条链路具体是怎么回事深挖
这是一个容易看漏的岔路,排查问题时不分清会白查半天。
链路一(服务器):策划在宇宙编辑器里配的「发射关卡子弹」行为, 在
链路二(客户端):Seq 或蓝图直接调
证据是
链路一(服务器):策划在宇宙编辑器里配的「发射关卡子弹」行为, 在
Action.ts 里是 ImplementType: 'Server'——
服务器执行,下发 CombatCreateBulletNotify,
客户端 ParseCreateNotify 按 isCreateByServer 判成
ServerAuthority,只做表现。链路二(客户端):Seq 或蓝图直接调
GameLaunchEnvBulletEmitter,走 EnvMonsterController
本地创建,网络分类是 ClientAuthority。用于演出型子弹。证据是
LevelEventCenter.RegisterEvents() 里
24 条 setup 全找过,没有 LaunchBullet 也没有 DestroyEnvBullet。
所以工程里那个 LevelEventDestroyEnvBullet.ts 其实是死代码——
写了但从来没接线,大概是早期按 Client 实现、后来改成 Server 忘了删。
这一条值得记进技术债清单,因为它会误导后来人的排查方向。
这块 25000 行代码需要全都熟吗陷阱题
不需要,也不该。
关卡子弹管的是入口层,5 个文件不到 300 行: 把「关卡想打一发子弹」翻译成执行层能吃的
但边界不等于无知——联调要用到的部分必须摸透:
原则是:边界内的代码要能背,边界外的代码要能查、能对契约、能提出准确的问题。
Src/Fight/FightModules/FightBullet/ 那 25166 行是
战斗组的通用子弹框架——弹道积分、碰撞、CtTask 行为树、网络同步、剔除、对象池,
属于另一个模块的范围。关卡子弹管的是入口层,5 个文件不到 300 行: 把「关卡想打一发子弹」翻译成执行层能吃的
IBulletCreateData。
与执行层的契约只有两个函数:CreateBulletWithSync 和 DestroyBullet。但边界不等于无知——联调要用到的部分必须摸透:
EFightBulletNetType 三种分类和判定顺序(要排「只有自己看得见」)、
BulletConfigUtils 的 UID 拆装(要对配置)、
CharTypeId 规范化那层(编辑器侧不做 Normalize,
两边规则有潜在冲突)。原则是:边界内的代码要能背,边界外的代码要能查、能对契约、能提出准确的问题。