FPS 波次生存 Demo:UE5.8 纯蓝图项目的技术复盘

用纯蓝图完成一个 FPS 生存玩法闭环:射击 → 受击反馈 → 死亡流程 → 波次生存 → 灯光信号 → 击杀掉落。本文是技术向复盘,写给同样在用蓝图做游戏的人。

技术栈

UE 5.8,零 C++ 纯蓝图。用到的系统:Enhanced Input、Animation Montage + AnimNotify、MetaSound、Timeline、UMG、AI MoveTo + NavMesh、Sphere Trigger。所有自研逻辑在 Content/Code 下按 Enemy / Pickup / Player / Weapon 分层;资产来自商城包。

十个技术亮点

1. 手搓 FPS 相机臂(Enhanced Input + 每帧同步控制旋转)

没有用现成的 Character 视角,而是自己搭了第一人称架构:七个 IA(移动/视角/奔跑/瞄准/开火/换弹/重开),IA_Look 的 X→AddControllerYawInput、Y→AddControllerPitchInput;Event Tick 每帧 SetWorldRotation(Arm) ← 控制旋转的 Pitch/Yaw;相机挂在 Arm 下,带旋转基线与缩放补偿。

关键点在于移动与视角分离:AddMovementInput 只取控制 Yaw 的 forward/right,相机姿态由 Tick 同步——鼠标灵敏度、上下限视角、武器臂动画互不干扰。

2. 双层血幕:状态层 + 事件层分离

WBP_BloodOverlay 拆成两层:BloodBase 由血量驱动(UpdateBlood,持续反映当前血量比),BloodFlash 由受击事件泵送(E_Blood_Screen,每次受击打一个脉冲)。两个通道各自独立更新,避免单一透明度数值”既要持续又要脉冲”打架——UI 反馈拆成”状态”和”事件”两个维度。

3. MetaSound 心跳

心跳不是播一个循环音频,而是用 MetaSound合成(在商城找不到免费的资源包让ai合成的)。

4. AnimNotify 命中帧结算

Whisper 的攻击判定不在 Tick 里做,而是挂在攻击蒙太奇 Anim_Whisper_Attack2_MontageAN_WhisperAttackHit 通知帧上:命中帧触发事件 E_WhisperAttackHit,事件链带 Dead 守卫 + 距离判断(GetDistanceTo ≤ AttackRange)再结算伤害。判定时机与动画严格同步,不吃帧率、不出现”空气刀”,还天然支持用动画做前摇/后摇。

5. 死亡演出:单 Timeline 三通道并行

死亡不是播一个动画,而是一个 Timeline 三通道并行 Lerp:相机位置 Z Lerp(-70)、相机 Roll Lerp(基线→+75)、枪 Z Lerp(基线−90),分别模拟倒地、视线倾斜、武器脱手。Timeline Finished → 创建 WBP_DeathScreen(ZOrder=100)→ 播 5s 渐入 → P 键 OpenLevel(Get Current Level Name) 全量重置。

踩过的转轴 bug 暴露了一个通用技术点:Lerp 旋转前先存相对旋转基线(DeathRollStart ← Get Camera → Get Relative Rotation → Break Rotator → Roll),否则把绝对世界旋转当增量用,相机初始朝向一变整个效果就歪。

6. 波次系统:自定义事件解耦 + 并行分支的”必走”设计

Whisper 不认识 WaveManager。死亡链末尾接一个 Sequence 三路并行:Then0 → Random<0.4 → 掉弹药;Then1 → <0.08 → 掉血包;Then2 → Get All Actors of Class(WaveManager) → Get(0) → 调 E_EnemyDied。波次计数走”必走”分支,掉落走概率旁路——并行分支里必须有一条确定执行的路径承载核心逻辑,这是事件解耦时最容易忘的设计。

WaveManager 侧:E_StartNextWave 重置计数、WaveActive 守卫防死后重复计数、EnemiesAlive ≤ 0 → Delay 5 → 下一波

7. 生成点过滤:Visibility Trace + 兜底

波次生成点不是随机的:距离 ≥25m + Visibility Trace 视线死角过滤 + Shuffle 保证同波不重复,候选不足时兜底回退全点(防”一波 0 只”)。两个引擎级细节:

  • 引擎源码确认 TraceTypeQuery1 = Visibility(内置 trace 类型只有 Visibility + Camera);
  • Pawn 胶囊的 Visibility 响应是 Ignore,所以视线检测根本不用 ActorsToIgnore 排除玩家自己

8. 掉落物生命周期:用数据标记代替类型枚举

掉落物和固定补给共用父类 BP_Pickup(Sphere 触发 + Switch PickupType),差异只在一个 bool bIsDrop。波间清理时 ForEach 全部 Pickup → Branch(bIsDrop) → Destroy——固定补给自然保留。以后加新掉落物只需加子类,清理逻辑零改动。用数据字段区分生命周期,比硬编码类列表干净得多。

9. Clamp 的两种妙用

  • 备弹上限:AddAmmo 的 Clamp 上限从 60 修到 180——原来满弹药(60/60)捡包会被”补到上限 60”直接砍没,这是拾取系统的隐藏 bug;
  • 血包回满:Amount 填 9999,Clamp 到 MaxHealth = 溢出即回满。不用读当前血量算差值,”溢出 + Clamp”两行节点解决”回满”语义。

10. 灯光三态:定时器取模闪烁 + 布尔语义核对

路灯信号三个状态全用一套组件控制:波次中 E_LampSetLit 只留加油站灯;预警/结束用 E_FlashTick 定时器 0.25s × 20 拍,Modulo B2 → Equal B1 奇偶切换明灭——定时器 + 取模是蓝图里做闪烁最干净的方式。另外踩了一个语义坑:E_LampSetAll 的 Visible 直连的是 SetActorHiddenInGame 的 bNewHidden(“是否隐藏“),语义反转导致 SetAll(true)=全隐藏,修法是 Visible → NOT → 两个 bNewHidden。

踩过的坑

  1. Auto Possess AI 默认 “Placed in World”:SpawnActor 刷出的怪没有 AI 控制器,站着不动——改为 “Placed in World or Spawned”。叠加第二坑:NavMesh 是旧数据,新摆的生成点不在导航网上,重建 Build Paths 才解决。
  2. UE 5.8 的空数组编译错误:旧版 Set 节点数组输入悬空=清空,5.8 直接编译报错,清空必须接空 Make Array。更大教训:编译面板有红错时,PIE 出的任何运行时错误都不作数
  3. exec 引脚双线残留:exec 输入引脚可同时进多根线,拖新线不会顶掉旧线——旧线残留=双重触发(血包掉两个、波次提前结束)。Alt+点击断旧线。
  4. ForEach 后续要挂 Completed:挂循环体内=每迭代跑一遍/空数组零遍(第一波不刷怪就是这么查出来的)。
  5. Set 的执行顺序:Set 存某节点的输出,执行线必须在该节点之后——数据引脚只在执行那一刻取值。把 Set AllPoints 放在 Get All Actors 之前,存进去的是空数组。
  6. OFPA 地图的”隐形”实例:实例在 __ExternalActors__ 的独立 uasset 里,umap 本体搜不到;验证”摆没摆进地图”要去扫外部文件。

工程方法

  • 存档式提交:每完成一个功能提交一次,7 个存档点全部可回退;
  • 引脚级验证工作流:蓝图是二进制、无法做代码审查,而空对象调用会被静默跳过——我把关键图导出成txt文本,逐引脚检查 LinkedTo 字段,抓到过两次致命漏连(Clamp→Set Health、CreateWidget→Set BloodOverlay)。这是纯蓝图项目最可靠的验证手段;
  • 踩坑记录:每个 bug 记现象、根因、修法,后期排查复用率极高。

不足与下一步

  • 纯蓝图的天花板:现在的规模 OK,但继续加系统(背包/AI 行为树/存档)连线复杂度会指数级上升,下一个项目准备上 C++;
  • 工程债:Intermediate/Saved 被 git 跟踪、无远程备份、调试打印未清;
  • 搁置功能:脚步音效、加油机打爆 AOE。