AfterBlacout项目技术讲解
After Blackout:UE5 合作丧尸射击 Demo
一个以 gameplay 程序开发为重点的 UE5.8 个人项目。本文记录项目的整体架构、关键系统,以及开发过程中最值得复盘的网络同步、丧尸生命周期和性能问题。
一、项目介绍
1.1 项目是什么
After Blackout 是一款基于 Unreal Engine 5.8 开发的合作 PvE 丧尸生存射击 Demo。玩家与队友进入废弃街区,寻找补给、启动发电机、抵抗尸潮,并在完成目标后前往撤离区域。
项目采用第三人称视角,玩法包含:
- 角色移动、瞄准、射击、换弹和受击。
- 丧尸巡逻、发现玩家、追击、攻击、受击和死亡。
- 弹药箱、血包、发电机和门等交互物。
- 准备大厅、玩家选择、游戏开始、胜利、失败和重启流程。
- Listen Server 合作联机,以及服务器权威的共享状态管理。
1.2 项目技术栈
Unreal Engine 5.8 · C++ · Blueprint · Enhanced Input · UMG · Behavior Tree · Blackboard · NavMesh · RPC / Replication · DataAsset · Gameplay Tags
1.3 游戏流程
1 | |
二、系统架构
flowchart TD
UI[Widget / HUD] --> PC[ZombiePlayerController]
PC -->|Server RPC| GM[ZombieGameMode]
GM --> GS[ZombieGameState]
GM --> PS[ZombiePlayerState]
PC --> Character[BP_PlayerCharacter]
Character --> Rifle[BP_Rifle]
Rifle -->|Server damage| Zombie[BP_ZombieCharacter]
GM --> Manager[AZombieManager]
Generator[GeneratorInteractable] -->|Spawn Event Tag| Manager
Manager -->|DataAsset| SpawnData[Spawn Event / Zombie Data]
Manager -->|Acquire / Release| Pool[AvailablePool / ActiveZombies]
Manager --> Zombie
Zombie --> AI[AIController / Behavior Tree]
GS --> UI
PS --> UI
2.1 从上到下阅读这张图
玩家与 UI
Widget / HUD 不直接修改游戏规则,而是通过 ZombiePlayerController 发起本地操作。Controller 负责把 UI 操作送到服务器边界,再把复制后的状态反馈给 HUD。
游戏流程
- ZombieGameMode:服务器专属,负责开始、胜利、失败、淘汰和重启规则。
- ZombieGameState:保存并复制所有客户端都需要知道的游戏流程状态和大厅摘要。
- ZombiePlayerState:保存并复制单个玩家的准备、淘汰、外观和武器选择。
- ZombiePlayerController:连接本地 UI、玩家输入和服务器请求。
玩家战斗链
ZombiePlayerController 与 BP_PlayerCharacter 连接,角色持有 BP_Rifle,枪械的最终伤害结果作用于 BP_ZombieCharacter。
这条链上的基本原则是:
1 | |
丧尸生成链
发电机或其他关卡交互物通过 Spawn Event Tag 请求 AZombieManager 触发刷怪。管理器读取 Spawn Event / Zombie Data,选择生成点和丧尸配置,再决定走普通 Spawn/Destroy 路径还是 Object Pool 的 Acquire/Release 路径。
AZombieManager 不负责具体丧尸的攻击表现,而是通过 Blueprint Contract 调用丧尸蓝图中的 ZombieData、InitializeZombie、ActivateFromPool、DeactivateToPool 等成员。
2.2 架构图中最重要的三条边界
- 规则边界:GameMode 决定规则,GameState/PlayerState 复制结果。
- 战斗边界:角色和枪械发起请求,服务器决定弹药、命中和伤害。
- 生命周期边界:ZombieManager 决定丧尸何时生成、激活、死亡、回收或销毁,丧尸蓝图负责自身的属性、AI 和表现。
三、关键系统
3.1 角色系统:BP_PlayerCharacter
角色是玩家输入、移动、生命状态、武器和交互的组合入口。
主要职责
- 使用 Enhanced Input 处理移动、视角、跳跃、瞄准、开火、换弹和交互。
- 管理当前生命、受伤、死亡和玩家状态。
- 保存当前武器引用,并把输入转换成武器请求。
- 持有交互扫描组件,把 E 键操作送入统一交互流程。
- 为本地镜头、动画、HUD 和音效提供表现层接口。
角色本身不应该直接在客户端决定伤害或交互结果。它只表达“玩家想做什么”,最终结果由服务器规则和对应系统处理。

玩家本地输入只负责发起开火请求;服务器验证武器归属、射速、弹药和命中结果,并修改权威状态,包括当前弹匣数量、丧尸 Health、IsDead 以及 ZombieManager 的存活数量。客户端收到复制结果后,再更新 HUD、受击/死亡动画和其他表现。
3.2 枪械系统:BP_Rifle
枪械系统负责玩家的射击闭环,而不是只负责播放枪口火焰。
主要职责
- 开火间隔、持续开火和射击状态。
- 当前弹匣、备弹和换弹流程。
- 从摄像机或枪口方向发起 Line Trace。
- 根据命中结果请求 Apply Point Damage。
- 处理散布、后坐力、枪口火焰、枪声、弹道和动画。
- 通过配置数据读取伤害、射速、弹容量、散布和换弹时间。
开火路径
1 | |
核心命中逻辑-射线判定
从玩家摄像机中心发出辅助射线,用于找到准星所指的命中点,然后从枪口发射实际命中射线,定位实际命中点。


3.3 丧尸系统:BP_ZombieCharacter
丧尸角色把属性、AI、受击、死亡和对象池生命周期连接在一起。
主要职责
- 从 Zombie Data 读取生命、移动速度、攻击伤害、攻击间隔和攻击范围。
- 与 BP_ZombieAIController、Behavior Tree、Blackboard 和 NavMesh 配合。
- 处理目标、追击、攻击、Point Damage、受击硬直和死亡。
- 响应 InitializeZombie、ActivateFromPool、DeactivateToPool 等管理器接口。
- 在死亡时通知 AZombieManager,由管理器决定延迟回收或销毁。
AI 逻辑
1 | |

图 2:BP_Zombie 的 Behavior Tree。Selector 下包含攻击、玩家追击、噪音追击和巡逻四个分支;分支条件通过 Blackboard Key 和 Decorator 控制,攻击与追击分支可以在目标状态变化时中断低优先级行为。
这棵行为树的优先级可以概括为“先处理已经进入攻击范围的玩家,再追击可见玩家,其次响应噪音位置,最后进行随机巡逻”。因此丧尸不是固定执行一条“发现玩家后一直追击”的线性流程,而是根据 Blackboard 中的目标状态在多个行为之间切换。
普通、快速和坦克型丧尸复用同一套基础角色和 AI 逻辑,通过不同的 Zombie Data 调整属性,而不是复制三套完全独立的蓝图。
3.4 丧尸管理器:AZombieManager
AZombieManager 不是一个简单的“刷怪接口集合”,而是把刷怪事件、生成点选择、对象获取、死亡统计和生命周期清理串成一条 C++ 执行链。关卡蓝图只负责触发事件,具体的生成和回收由管理器统一完成。
3.4.1 从 Spawn Event 到定时生成
关卡中的发电机或其他玩法对象只需要传入 Gameplay Tag。TriggerSpawnEvent 首先拒绝客户端调用,再查找对应的 Spawn Event DataAsset,检查事件是否已经运行或是否只能触发一次,最后创建 Spawn Timer。
1 | |

图 3:TriggerSpawnEvent 的关键实现。。
代码中的关键判断可以概括为:
1 | |
这里的设计价值在于:刷怪事件只由服务器启动,生成过程被拆成多个 Timer Tick,不会因为一次事件配置了很多丧尸,就在同一帧集中创建全部 Actor。
3.4.2 SpawnNextZombie:先检查条件,再创建对象
每次 Timer 触发 SpawnNextZombie 时,管理器不会直接 SpawnActor,而是依次检查:
- 事件是否存在、生成数量是否达到上限。
- 当前总存活数量是否达到 MaxAliveZombies。
- 当前事件存活数量是否达到 MaxAliveCount。
- 是否能选到符合标签的生成点和 Zombie Data。
- 生成位置是否在 NavMesh 上、距离玩家足够远且没有碰撞。
只有全部条件通过后,才会进入 AcquireZombie。生成点失败时不增加已生成数量,下一次 Timer 继续尝试,这样配置数量和实际 Actor 数量不会失配。

图 4:SpawnNextZombie 的条件检查、生成点筛选、Zombie Data 选择和 ActiveZombies 注册。
1 | |
生成位置的选择也不是固定使用 Spawn Point 的原始 Transform。FindFreeSpawnTransform 会尝试投影到 NavMesh,检查与玩家的最小距离,并使用丧尸胶囊体进行阻挡检测;失败时把这次生成推迟到下一次 Timer。
3.4.3 AcquireZombie:对象池与普通生成的分支
AcquireZombie 是两种生命周期路径的汇合点:
1 | |

图 5:AcquireZombie 复用空闲对象、扩容和普通 Deferred Spawn 的分支。
对象池复用时,当前代码的核心顺序是:
1 | |
普通生成则使用 SpawnActorDeferred,先设置:
- SetReplicates(true)
- SetReplicateMovement(true)
- ZombieData
- PoolActive 状态
完成 FinishSpawning 后,再调用无参数的 InitializeZombie。这样 Blueprint 负责具体丧尸的初始化和表现,C++ 负责生成时机、网络契约和生命周期管理。
3.4.4 HandleZombieDied 到 ReleaseZombie
丧尸死亡后不立即由丧尸蓝图自行 Destroy,而是通过死亡委托通知 Manager:
1 | |

图 6:HandleZombieDied 和 ReleaseZombie 的死亡统计、延迟清理、对象池回收与销毁分支。
这里的关键点是两条 A/B 路径共用同一个死亡清理时机。对象池关闭时走 Destroy,开启时走 DeactivateToPool;这样性能对比中的尸体表现和 RecycleDelay 保持一致,变化因素主要集中在 Actor 是否被重复创建和销毁。
3.4.5 这个模块的 C++/Blueprint 边界
- C++:服务器权限、刷怪事件、Timer、数量统计、生成点筛选、对象池和死亡回收。
- DataAsset:Spawn Event、Zombie Data、生成数量、间隔、权重和类型配置。
- Blueprint:ZombieData 的具体应用、AI、入场动画、受击和死亡表现。
- 关卡蓝图:摆放交互物和生成点,调用 Spawn Event Tag。
AZombieManager 的价值是把关卡事件、数据配置、对象生命周期和网络规则集中到一个可复用的 C++ 模块中。
3.5 交互物:发电机、补给箱和门
项目把“扫描目标”和“执行交互”从具体交互物中抽离出来,使用 UInteractionScannerComponent 统一处理。
统一交互流程
1 | |
AInteractableBase 提供共同的交互约定,发电机、弹药箱、血包和门只实现自身的结果。这样可以避免每个交互物重复实现“找玩家、找距离、显示提示、判断合法性”的逻辑。
发电机还可以在服务器确认交互成功后,通过 Gameplay Tag 调用 AZombieManager::TriggerSpawnEvent,把“交互目标完成”和“尸潮开始”连接起来。
四、技术难点
4.1 网络同步:客户端请求,服务器裁决,客户端表现
问题
单机逻辑可以在本地直接开火、扣血、减少弹药和打开门;但多人游戏中,如果每个客户端都自行修改这些状态,就会出现:
- 两个客户端看到的弹药数量不同。
- 不同客户端对同一只丧尸得到不同的命中和死亡结果。
- 每个客户端独立刷怪,导致尸群数量倍增。
- 玩家准备、淘汰、胜利和失败状态无法统一。
设计
项目采用服务器权威 + 状态同步,而不是纯帧同步:
1 | |
选择状态同步的原因是项目同时包含 CharacterMovement、AI 导航、随机刷怪、对象池、动画和 Chaos/布娃娃。这些系统不适合依赖不同机器逐帧得到完全一致的结果。对于 2-4 人合作 PvE,服务器负责共享结果,客户端负责操作和表现,更符合系统边界。
模块分工
| 模块 | 同步职责 |
|---|---|
| ZombieGameMode | 服务器专属规则,不直接复制给客户端 |
| ZombieGameState | 复制全局游戏阶段、准备汇总和比赛状态 |
| ZombiePlayerState | 复制玩家准备、淘汰、外观和武器选择 |
| ZombiePlayerController | 接收本地 UI 操作,发送 Server RPC |
| BP_PlayerCharacter | 处理玩家输入、移动、生命和枪械请求 |
| BP_Rifle | 服务器验证归属、射速、弹药、射线和伤害 |
| AZombieManager | 服务器刷怪、AI 相关管理、数量和对象池 |
具体开火案例
开火不是“按键后直接 Apply Damage”,而是分成四个阶段:
- 请求:本地 IA_Fire 产生开火请求。
- 校验:服务器检查武器是否属于该玩家、射速是否满足、是否还有弹药。
- 裁决:服务器执行 Line Trace、扣弹药和 Apply Point Damage。
- 表现:生命值、死亡状态等持久状态通过复制同步;枪口火焰、枪声、受击动画等表现由本地或服务器事件驱动。
这条链解决了“客户端看起来打中了,但服务器没有认可”的边界问题。
具体 Bug:复制不等于本地引用完成
开发过程中出现过服务器能够使用武器、客户端本地武器引用没有准备好的情况。原因是武器引用初始化和 Authority 专属逻辑混在了一起。
处理时需要把两件事拆开:
- 本地角色必须完成自己的武器引用和 HUD 表现初始化。
- 服务器才负责 Owner、弹药、命中和伤害等权威状态。
同时在开火入口增加 IsValid(RifleRef) 检查,避免空引用把网络问题和本地初始化问题混在一起。
验证方式
请观看视频1:17到1:44。
目前双客户端玩家进入、互见、开火伤害、丧尸同步和移动已验证。
4.2 大量丧尸生成和销毁造成卡顿
问题
尸潮中如果不断执行:
1 | |
创建和销毁会把多个成本叠加到同一帧:
- Actor 和组件创建。
- Skeletal Mesh、碰撞和物理状态初始化。
- AIController、Behavior Tree 和 Blackboard 启动。
- 动画实例、导航和 Tick 注册。
- 死亡时的布娃娃和物理切换。
- Destroy 后等待 GC 和资源回收。
当大量丧尸集中出现或同时死亡时,平均 FPS 不一定马上下降,但 Game Thread 和最长帧时间可能出现明显尖峰。
设计:Timer 控制节奏,对象池负责复用
这里的重点不是把所有刷怪检查项堆在一起,而是用两层机制解决“同一帧创建过多 Actor”的问题。
1. 使用 Timer 把一次刷怪事件拆成多次生成尝试
触发 Spawn Event 时,AZombieManager 只启动一个循环 Timer;每次 Timer Tick 调用一次 SpawnNextZombie(EventTag),而不是在一个函数里连续生成整波丧尸。每次尝试最多取得一只丧尸:
1 | |
只有 AcquireZombie 成功返回 Actor 后,才会增加 SpawnedCount、EventAliveCount,并把 Actor 加入 ActiveZombies。因此生成点暂时不可用、存活数量达到上限或对象池暂时没有可用对象时,都不会错误增加已生成数量;达到 SpawnCount 后才清理 Timer。
2. 使用 AcquireZombie 统一对象池和普通生成路径
AcquireZombie 是刷怪流程的唯一取对象入口:
1 | |
复用对象时不会重新经历完整的 SpawnActor → BeginPlay → Destroy → GC 生命周期,而是重置位置和数据,调用 ActivateFromPool、InitializeZombie 以及激活表现函数,再通过 ForceNetUpdate 及时同步客户端。这样可以把频繁刷怪造成的 Actor 创建、组件注册和销毁压力,转化为有限数量对象的重复初始化。
性能验证
请观看视频1:55到结尾。
纯生命周期测试中,对象池能明显降低重复创建和销毁带来的帧耗时尖峰。 完整逻辑测试中,AI、寻路和动画占据了更多性能开销,因此平均帧率差异较小
4.3 对象池复用下的 AI、动画和网络状态时序
问题
对象池把“一次创建、一次销毁”的 Actor,变成“多次激活、战斗、回收、再激活”的 Actor。旧状态如果没有清理,就会出现:
- 丧尸刚出现就开始移动,跳过入场动画。
- 复用后继续追踪上一次的玩家。
- 上一次死亡留下的 Health、IsDead 或 Hit Stun 状态没有重置。
- 布娃娃、碰撞、Mesh 可见性和 Tick 状态残留。
- 客户端仍认为 Actor 处于休眠或非激活状态。
- AI 在 InitializeZombie 之前已经被 Possess 或开始运行。
生命周期分层
我把对象池生命周期分成 Manager、Zombie Blueprint 和 Network Presentation 三层:
| 阶段 | Manager | Zombie Blueprint | 网络/表现 |
|---|---|---|---|
| Prewarm | 创建池对象 | 进入非激活状态 | 隐藏、关闭碰撞和 AI |
| Acquire | 取出 AvailablePool,设置 Transform | ActivateFromPool、InitializeZombie | 唤醒网络状态 |
| Entrance | 等待初始化完成 | 播放起身/入场动画 | 暂停 AI 和移动 |
| Active | 加入 ActiveZombies | 进入正常行为树 | 显示、碰撞、Tick、移动有效 |
| Die | 接收 OnZombieDied | 播放死亡表现 | 同步死亡状态 |
| Release | 延迟后调用 ReleaseZombie | DeactivateToPool、清理状态 | 隐藏、休眠、回到池 |
4.4 数据驱动:把配置和运行时状态分开
问题
如果把生命、移动速度、射速、弹容量和刷怪数量全部写进角色蓝图或管理器逻辑,那么每次调数值都要改节点;新增一种丧尸时,也容易复制出一套新的蓝图。
设计
项目把数据分成三层:
1 | |
配置内容
| 数据 | 典型配置 |
|---|---|
| Player Data | 最大生命、行走速度、开火移动速度 |
| Rifle Data | 伤害、射速、弹匣容量、备弹、散布、后坐力、换弹时间 |
| Zombie Data | 角色类、生命、移动速度、攻击伤害、攻击间隔、攻击范围、缩放 |
| Spawn Event Data | 生成点标签、丧尸类型权重、数量、间隔、最大存活数 |
Gameplay Tags 用来标识刷怪事件、生成点类型和目标类别,使关卡蓝图只需要触发一个语义明确的 Tag,而不需要知道管理器内部如何选择生成点和丧尸类型。
例如:
1 | |
C++ 与 Blueprint 的边界
- C++ 管理器负责通用规则、权限、数量和生命周期。
- DataAsset 负责可调参数和资源引用。
- Blueprint 子类负责具体角色、动画、UI 和表现。
- 关卡蓝图负责摆放对象、配置 Tag 和触发玩法事件。
这种分工可以让新增丧尸类型更多地表现为“新增一份配置”,而不是复制完整的 AI 和刷怪逻辑。
结语
After Blackout 的重点不是单纯做出一个可以开枪的场景,而是把一个合作 PvE 游戏需要的 gameplay 结构拆开并重新连接:
- 角色和枪械负责玩家操作与战斗反馈。
- 丧尸和 AI 负责战斗压力。
- ZombieManager 负责刷怪规则和对象生命周期。
- 交互组件负责统一处理目标扫描和服务端请求。
- GameMode、GameState、PlayerState 和 Controller 负责多人流程边界。
- 对象池和数据驱动则分别解决高频生命周期开销与配置维护问题。
每个系统都遵循同一条开发思路:
1 | |