关卡子弹
从 0 做的模块:关卡演出用的弹幕/弹道(非战斗伤害主链路)。 核心决策是不另造轮子——复用战斗子弹系统(FightBulletController), 自己只做「编辑器配置 → 环境空怪承载 → 发射调度」这一层。 后续扩展空投类演出,再补上ID 合法性校验(出包前拦截配置错误)和配置预加载。
复用战斗子弹
15 位 TypeId 编码
保存期校验
预加载前移
1. 工作清单
| 内容 | 性质 |
|---|---|
| 关卡子弹功能:编辑器 Action 发射配置、发射/销毁运行时链路、P40 演出弹(多发射器齐射) | 从 0 建 |
| 空投/投掷类演出:投兵炮抛物线弹道(把玩家当子弹投出去) | 扩展 |
| 子弹类型 ID 合法性检查(编辑器保存期校验)+ 子弹配置预加载优化 | 质量 + 性能 |
2. 理论:三层架构与 15 位 ID 编码
2.1 链路:编辑器 Action → 环境空怪 → 战斗子弹
1
编辑器配置
策划在行为树配 LaunchBullet Action,保存时拼装 15 位服务器子弹 ID
2
环境空怪承载
Action 标记 ImplementType:'Server',服务端用「环境怪 BP」承载出生 Transform
3
战斗子弹系统
统一走 FightBulletController.CreateBulletWithSync 创建,动画/弹道/命中表现全复用
「复用」的判断标准
关卡子弹要的东西(弹道表现、特效、命中反馈)战斗子弹系统全有,
自己重写等于维护两套弹道。唯一的差异是发射上下文(不是角色而是关卡点位)——
用「环境空怪」这个薄实体当发射者,差异被吸收在桥接层。
面试讲架构决策时,这是「什么时候该复用」的现成例子。
2.2 TypeId 编码:把参数编码进 ID
15 位服务器子弹 ID 的分段:
| 段 | 位数 | 含义 |
|---|---|---|
| 环境怪 ConfigId | 6 位 | 发射者(缺省兜底 9501 默认环境怪) |
| BulletConfigId | 5 位 | 子弹配置表行(0 = 子弹,1 = 发射器) |
| SubBulletId | 3 位 | 子弹序号,默认 000 |
| Config | 1 位 | 类型标志 |
为什么把参数编进 ID 而不是多字段下发
服务器协议只认一个数字。编码进 ID 后策划在编辑器改配置即改 ID,
运行时零解析成本;代价是 ID 的语义要靠校验保住——
所以保存期必须查表(见 3.2),否则一个不存在的 BulletConfigId 会静默打出无效弹。
3. 代码片段
3.1 TypeId 拼装 + 9501 兜底
Src\UniverseEditor\EditorCommon\Scheme\Action\LevelPlay.ts:2444-2482
function GetBulletIdFromInfo(EntityConfigId: number, BulletConfigId: number, BulletTypeId: number): number {
// 确保CharTypeId是6位数字
const CharTypeIdStr = EntityConfigId.toString().padStart(6, '0');
// 确保BulletTypeId(子弹类型ID)是5位数字
const BulletTypeIdStr = BulletConfigId.toString().padStart(5, '0');
// SubBulletId默认使用0,确保是3位数字
const SubBulletIdStr = '0'.padStart(3, '0');
// Config使用BulletTypeId参数(0=子弹,1=发射器)
const ConfigStr = BulletTypeId === 1 ? '1' : '0';
const bulletIdStr = `${CharTypeIdStr}${BulletTypeIdStr}${SubBulletIdStr}${ConfigStr}`;
return parseInt(bulletIdStr);
}
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 bulletTypeId = GetBulletIdFromInfo(typeId, finalTypeId, to.BulletType ?? 0);
to.BulletTypeId = bulletTypeId;
return bulletTypeId;
}
字符串 padStart 拼装再 parseInt
用字符串分段保证每段位数固定(padStart 补零),
比位运算直观,分段边界随时可调。9501 是「未配伤害来源时的默认环境怪」——
兜底值有注释、有唯一数字,排查日志时一眼认出「这条弹没配来源」。
3.2 保存期合法性校验:错误拦截在出包前
Src\UniverseEditor\EditorCommon\Scheme\Action\LevelPlay.ts:2415-2429
OnSchemeCheck: (value, messages): number => {
let errorCount = 0;
if (value.BulletTypeId <= 0) {
messages.push(`子弹服务器Id计算错误: ${value.BulletTypeId}`);
errorCount++;
}
// 检查子弹服务器ID长度是否为15位
const bulletIdStr = value.BulletTypeId.toString();
if (bulletIdStr.length !== 15) {
messages.push(`子弹服务器Id长度错误: ${value.BulletTypeId} (应为15位)`);
errorCount++;
}
errorCount += checkBulletConfigId(value, messages); // DataTableRowExists 查表
return errorCount;
},
三道校验对应三类错误
≤0 = 拼装失败;≠15 位 = 分段溢出(环境怪 ID 超过 6 位等);
查表失败 = 配了一个不存在的子弹行。
策划保存关卡时立即弹错误清单,不用等到测试跑关卡才发现弹打不出来。
这项工作的价值主张就一句:把配置错误从「运行期静默失败」前移到「保存期显式报错」。
3.3 运行时发射:桥接战斗子弹系统
Src\Game\AI\Environment\EnvMonsterController.ts:93-108
private CreateEnvBullet(
typeId: number, customTarget: UE.Actor | undefined, configType: EFightBulletConfigType,
): bigint {
const spawnerEntityId = ActorUtils.GetLocalEntityId(this.CurrentEnvMonster);
const spawnerTypeId = ActorUtils.GetTypeId(this.CurrentEnvMonster);
const targetEntityId = customTarget ? ActorUtils.GetLocalEntityId(customTarget) : 0;
const createData: IBulletCreateData = {
ConfigType: configType,
TypeId: typeId,
SpawnerInfo: new BulletSpawnerInfo(spawnerEntityId, spawnerTypeId, 0, 0),
TargetEntityId: targetEntityId > 0 ? targetEntityId : undefined,
};
return ControllerHolder.FightBulletController.CreateBulletWithSync(createData);
}
返回 bigint 句柄支持事后销毁
发射返回的是 bigint bulletId——关卡演出结束要销毁弹幕时,
另一个 Action(DestroyEnvBullet)拿 Mark 找回实体销毁。
「发射返回句柄、销毁按句柄」是演出资源生命周期管理的最小闭环。
3.4 预加载:查表成本前移到加载期
Src\Fight\FightModules\FightBullet\Config\BulletConfigManager.ts:109-146(节选)
/**
* 预加载角色的子弹配置。已缓存则跳过。SubBullet 在此阶段就与 Actor 合并。
* ctDataPath 为角色专属 CTDA 路径(空则引用通用);通用 CTDA 先于角色转换加载。
*/
public PreloadCharacterBullets(
charTypeId: number,
actorTable: UE.DataTable | undefined, emitterTable: UE.DataTable | undefined,
subTable: UE.DataTable | undefined, ctDataPath: string | undefined,
): void {
if (this.Cache.HasCharacter(charTypeId)) {
BulletLog.Info(EBulletLogCategory.Config,
`[BulletConfigManager] 角色子弹配置已缓存,跳过: CharTypeId=${charTypeId}`);
return;
}
this.CacheCharacterTables(charTypeId, actorTable, emitterTable, subTable);
const commonCtData = this.LoadCommonCtData();
const roleCtData = this.LoadCharacterCtData(charTypeId, ctDataPath);
const startTime = Date.now();
const counts = this.LoadTables(charTypeId, actorTable, emitterTable, subTable, roleCtData, commonCtData);
const elapsed = Date.now() - startTime;
BulletLog.Key(EBulletLogCategory.Config,
`[BulletConfigManager] 预加载角色子弹配置完成 CharTypeId=${charTypeId}, ` +
`Actor=${counts.Actor}, Emitter=${counts.Emitter}, Sub=${counts.Sub}, Time=${elapsed}ms`);
}
预加载 = 转换与合并提前 + 打点量收益
按 charTypeId 分角色把 Actor/Emitter/Sub 三表预烘焙,SubBullet 在加载期就完成合并;
运行时发射只做查缓存。Key 级日志输出条目数与耗时——
优化有没有效,日志直接给数字,这也是汇报优化工作的标准姿势。
4. 面试怎么讲
关卡子弹为什么不直接做成独立系统设计
因为需求本质是演出复用:关卡弹幕要的弹道、特效、命中反馈,
战斗子弹系统全都有,重写等于维护两套弹道。
差异只在发射上下文——不是角色而是关卡点位。
解法是用「环境空怪」这个薄实体当发射者,
桥接层只有一个 CreateBulletWithSync 调用,
发射返回 bigint 句柄支持演出结束时销毁。
另外 Action 层标记 ImplementType:'Server', 发射裁定在服务端,多人游戏里每个玩家看到的弹幕一致。
另外 Action 层标记 ImplementType:'Server', 发射裁定在服务端,多人游戏里每个玩家看到的弹幕一致。
子弹 ID 校验这个工作具体做了什么工程
子弹的 15 位服务器 ID 是拼装出来的:环境怪 6 位 + 子弹配置 5 位 + 序号 3 位 + 类型 1 位。
配置错一个字段,运行期表现为「弹打不出来」——静默失败,排查极难。
我把校验加到编辑器保存期:ID ≤0、位数 ≠15、配置行查表不存在,三类错误 策划保存时立即收到错误清单。 核心价值一句话:把配置错误从运行期静默失败,前移到保存期显式报错, 这类「工具链质量」工作对团队效率的收益比修十个运行期 bug 大。
我把校验加到编辑器保存期:ID ≤0、位数 ≠15、配置行查表不存在,三类错误 策划保存时立即收到错误清单。 核心价值一句话:把配置错误从运行期静默失败,前移到保存期显式报错, 这类「工具链质量」工作对团队效率的收益比修十个运行期 bug 大。
预加载优化是怎么做的性能
发射时才查子弹配置表(DataTable 行读取 + SubBullet 合并)有运行期毛刺。
优化是把这步前移到角色加载阶段:按 charTypeId 预烘焙
Actor/Emitter/Sub 三表并完成 SubBullet 合并,运行时发射只查缓存,
已缓存的角色直接跳过。
预加载完成时打 Key 级日志输出条目数与耗时(如 Actor=32, Emitter=8, Sub=64, Time=12ms), 优化收益有数字可汇报,不是拍脑袋的「感觉变快了」。
预加载完成时打 Key 级日志输出条目数与耗时(如 Actor=32, Emitter=8, Sub=64, Time=12ms), 优化收益有数字可汇报,不是拍脑袋的「感觉变快了」。