Unreal Engine
房间渐进式迁移
这次整理的目标,是把一个以蓝图为主的 UE5 多人对战项目,逐步迁移到更清晰的 C++ 房间生命周期结构中。项目原本已经有一套可以运行的蓝图逻辑,包含玩家进入、队伍分配、出生点选择、倒计时、玩家退出和 UI 刷新。迁移过程中不能一次性推翻旧逻辑,否则很难判断问题来自原有玩法,还是来自新的 C++ 代码。因此这次采用了渐进式迁移:先保留稳定的蓝图玩法,再让 C++ 接管一个明确的职责,最后断开对应的旧蓝图链路。
一、小地图友军标记不同步
最早遇到的问题是多人进入房间后,小地图显示不一致:后加入的玩家可以看到先加入的玩家,但先加入的玩家看不到后来加入的同队玩家。
问题原因
小地图并不是每帧重新扫描所有玩家。CommonUI 创建时,会根据当时已经存在的 Shooter 角色创建小地图标记;之后 staticMiniMap 的 Tick 只更新已有标记的位置。
因此,真正的问题不是角色位置没有同步,而是新玩家出现后,旧客户端没有重新生成对应的小地图标记。
处理方式
在 BP_MyGamemode 中,玩家完成队伍分配、出生点设置后调用 all-refushmapcast,让已经在线的客户端重新刷新小地图。
玩家退出时,OnLogout 继续负责更新队伍人数和刷新其他客户端的小地图。小地图控件和友军标记仍然在各客户端本地创建,不让服务器直接创建 UMG。
这里体现了一个重要原则:
服务器同步玩法状态
客户端根据同步状态创建自己的 UI
服务器可以通知客户端刷新,但不能把某个客户端的 UMG 对象当成全局对象管理。
二、Listen Server 房主 UI 被误删
当 Listen Server 中某个客户端退出时,房主的倒计时和小地图曾经会一起消失。
问题原因
旧的 BP_MyPlayerController.EndPlay 中使用了固定索引获取 PlayerState。在 Listen Server 中,服务器和房主客户端共用一个进程,同时拥有多个玩家控制器。固定索引不一定指向正在退出的玩家,可能错误地取到房主的 PlayerState。
随后,退出清理逻辑就会把房主的 CommonUI 当成退出玩家的 UI 删除。
修复方式
将获取 PlayerState 的目标改为当前控制器自身:
Self
-> Get PlayerState
-> Cast ShooterPlayerState
-> CleanupLocalUI
这样清理操作始终属于当前控制器,不依赖玩家数组索引,也不会误删房主 UI。
这个问题说明,在 Listen Server 中不能简单把“服务器上的第一个对象”当成“当前玩家”。对象归属必须从当前控制器或当前拥有者获取。
三、staticMiniMap 的尺寸计算问题
staticMiniMap 在初始化时会使用类似下面的计算:
GetLocalSize.X / GetDesiredSize.X
控件刚创建、正在布局,或者正在销毁时,GetDesiredSize.X 可能为零,于是日志中出现过 Divide by zero。
处理方式是在除法执行前增加判断:
GetDesiredSize.X > 0.001
True -> GetLocalSize.X / GetDesiredSize.X -> Set UIScale
False -> Set UIScale = 1.0
这里需要注意,保护必须放在除法的执行路径之前,而不是只保护除法结果的赋值。否则除法节点仍可能在判断完成前被求值。
这个问题的技术本质是 UI 布局存在多个生命周期阶段,控件创建后并不代表它已经拥有有效尺寸。UI 计算需要接受“暂时没有有效几何信息”这个状态。
四、建立 C++ 房间框架
在稳定蓝图基线的基础上,新增了三个 C++ 类:
ARoomGameModeARoomGameStateARoomPlayerController
它们分别承担房间规则、可复制的回合状态和客户端控制器相关职责。
之后将三个蓝图接入 C++ 父类:
BP_MyGamemode -> RoomGameMode
BP_MyGameState -> RoomGameState
BP_MyPlayerController -> RoomPlayerController
蓝图仍然保留原有的队伍分配、出生点、小地图刷新和 UI 初始化逻辑。GameStateClass 仍然指向 BP_MyGameState,PlayerControllerClass 仍然指向 BP_MyPlayerController,避免 C++ 默认类直接替换已有玩法对象。
这种结构的好处是:C++ 提供稳定的底层能力,蓝图子类保留项目已有的具体玩法。
五、倒计时迁移到 C++
原来的倒计时由蓝图定时器驱动:每秒调用 upDateTimer,修改 BP_MyGameState.RemainingTime,UI 再读取这个变量。
迁移后,由 ARoomGameMode 负责计时,由 ARoomGameState 负责复制回合状态:
ARoomGameMode.StartRound
-> RoundState = Playing
-> RemainingSeconds = 300
-> C++ 定时器每秒减少一秒
-> 时间结束后进入 Ending
ARoomGameState 中的 RoundState 包含:
- 当前回合阶段。
- 剩余秒数。
- 回合 ID。
BP_MyGameState 在 OnRoundStateChanged 中读取 RoundState.RemainingSeconds,再写入旧的 RemainingTime。因此 CommonUI 不需要知道倒计时底层已经从蓝图迁移到了 C++。
这是一种兼容式迁移:
C++ 负责真实状态
蓝图负责把新状态接到旧 UI
原来的 upDateTimer 节点暂时保留,但启动入口已经断开,便于回退和对照。
六、空房间重开迁移到 C++
空房间重开逻辑原来由蓝图定时器负责。迁移后,计时器和重新加载地图的动作由 ARoomGameMode 管理。
当前流程是:
玩家进入
-> PostLogin 标记房间曾经有玩家
-> CancelEmptyRoomTimer
最后一名玩家离开
-> C++ Logout 检查房间人数
-> StartEmptyRoomTimer
计时结束且房间仍为空
-> ResetIfEmpty
-> RestartRound
-> ServerTravel 回到回合地图
蓝图 OnLogout 现在只负责玩家人数相关的蓝图逻辑和小地图刷新。原来从蓝图直接调用 StartEmptyRoomTimer 的执行线已经断开,避免蓝图和 C++ 同时启动两个空房计时器。
Logout 中增加了世界销毁判断:
UWorld* World = GetWorld();
// 玩家正常离开时检查是否变成空房间。
// 如果世界正在切换地图,就不要再启动计时器。
if (World && !World->bIsTearingDown)
{
StartEmptyRoomTimer();
}
这个判断保证地图切换和正常退出使用不同的生命周期路径。
七、回合结束、Session 和返回主菜单
回合倒计时结束后,ARoomGameMode::EndRound 会:
- 停止回合计时器。
- 将
RoundState设置为Ending。 - 通知每个
ARoomPlayerController执行ClientReturnToMain。
ARoomPlayerController 的返回流程是:
ClientReturnToMain
-> 防止重复执行
-> 等待结束 UI 显示
-> 查找 GameSession
-> 请求 DestroySession
-> 等待成功、失败或超时
-> ClientTravel 到 Main
为了避免玩家卡死,Session 销毁设置了超时保护。无论销毁成功、失败,还是 OnlineSubsystem 没有返回结果,最终都会进入同一个 TravelToMain 函数。
TravelToMain 使用布尔变量防止重复跳转,因为以下三个路径可能在不同时间到达:
- Session 销毁成功回调。
- Session 销毁失败回调。
- 超时计时器。
Listen Server 与 Dedicated Server 的区别
Listen Server 的房主既是服务器又是本地客户端。如果房主在回合结束时同时执行 ClientTravel 和 ServerTravel,两个地图跳转会互相冲突。因此当前逻辑中:
Listen Server
-> 房主和客户端走 ClientReturnToMain
-> 不再额外 ServerTravel
Dedicated Server
-> 客户端返回 Main
-> 服务器延迟执行 ServerTravel
这体现了网络角色的区别:Listen Server 的主机拥有本地 UI,而 Dedicated Server 没有本地菜单,不能用同一条跳转路径处理。
八、世界销毁期间创建 UI 的错误
回合结束返回主菜单时,曾经出现以下错误:
Ensure condition failed: !World->bIsTearingDown
Widget Class CommonUI_C - Attempting to be created while tearing down the world 'csgo'
日志调用链显示:
返回 Main
-> csgo 开始销毁
-> OnLogout 仍然执行
-> all-refushmapcast
-> refushMap
-> CreateWidgetAndAddToViewport(CommonUI)
问题不是 Session 销毁失败,而是旧的 OnLogout UI 刷新逻辑在世界已经开始销毁后仍然执行。
为了保持改动最小,最终没有增加复杂的全局 teardown 状态,而是在 OnLogout 的小地图刷新前增加了 Delay 0.2:
OnLogout
-> 扣除队伍人数
-> Delay 0.2
-> all-refushmapcast
世界切换时,延迟后的刷新不会再继续创建新的 CommonUI;正常玩家退出时,延迟结束后仍然可以刷新小地图。
这个修复的核心不是“让 UI 在销毁时继续工作”,而是让 UI 创建避开世界销毁窗口。UI 的创建应该只发生在有效世界中。
九、项目整理和编译问题
为了隔离旧项目中的不稳定改动,曾经重新克隆干净的 CPPLine-5.8 基线,验证 Git LFS 资源完整后,再把完整的 Source/project09 C++ 模块迁移回项目。
编译过程中遇到过 LNK1104。当时 C++ 源文件已经成功生成目标文件,失败发生在 Unreal Editor 运行中的 Live Coding 补丁链接阶段。关闭编辑器后,使用完整的 Development Editor / Win64 构建,编译成功。
之后又遇到过 OnlineSessionNames.h 找不到的问题。UE5.8 当前安装中,这个头文件位于 OnlineBase 插件的路径,项目模块不应该直接依赖这个不稳定的包含路径。最终去掉了该头文件,直接使用:
const FName GameSessionName(TEXT("GameSession"));
并在代码中说明创建、加入和销毁 Session 必须使用同一个名称。
当前 project09Editor / Win64 / Development 已成功编译。Launcher 发行版 UE5.8 不支持构建 Server Target,因此 project09Server 不能作为本机代码验证目标,这属于引擎发行版限制,不是本次 C++ 编译错误。
十、当前职责划分
BP_MyGamemode
- 玩家进入时的队伍分配
- 出生点选择
- 玩家加入和退出时的小地图刷新
- 保留部分旧蓝图状态兼容逻辑
ARoomGameMode
- 回合状态和倒计时
- 回合结束通知
- 空房计时器
- 空房后重新加载回合地图
- 玩家退出时触发空房检查
ARoomGameState
- 复制 RoundState
- 向蓝图和客户端提供回合阶段、剩余时间和回合 ID
ARoomPlayerController
- 回合结束后的本地返回流程
- Session 销毁
- 返回主菜单
- 防止重复跳转
BP_MyGameState
- 将 C++ RoundState 映射到旧的 RemainingTime
- 显示 GameOver UI
- 不再执行旧的 returntomain
十一、当前遇到的问题和解决原则
不要用固定索引代表当前玩家
Listen Server 同时拥有服务器和多个客户端对象。需要通过当前控制器、拥有者或明确的对象引用获取玩家状态,而不是使用固定索引。
不要让服务器创建客户端 UI
服务器可以复制状态、发送通知,但 UMG 的创建和销毁应该由拥有该 UI 的客户端完成。
不要在世界销毁阶段创建 UI
BeginTearingDown 之后,世界中的 UI、Actor 和对象引用都可能进入清理流程。此时再调用 CreateWidgetAndAddToViewport 属于生命周期错误。
不要让两套规则同时管理同一个计时器
蓝图和 C++ 如果同时管理倒计时或空房重开,就会出现重复计时、重复跳转和状态覆盖。迁移一个职责时,必须先接入 C++,再断开对应蓝图执行线。
网络状态和 UI 表现分层
RoundState 是网络状态,RemainingTime 是兼容层变量,CommonUI 是表现层。分层以后,底层实现可以从蓝图换成 C++,UI 不必一次性重写。
所有异步结束路径都需要统一出口
Session 销毁可能成功、失败或没有回调。让这些路径最终都进入 TravelToMain,并用一个布尔变量防止重复跳转,比在每个分支里分别写地图跳转更容易维护。
十二、当前状态与下一步
当前这一阶段已经完成:
- 小地图友军刷新修复。
- Listen Server 房主 UI 清理修复。
- C++ GameMode、GameState、PlayerController 接入。
- C++ 倒计时。
- C++ 空房间重开。
- C++ 回合结束、Session 清理和返回主菜单。
- 世界销毁期间 UI 创建错误的最小修复。
当前修改暂时没有提交 Git。远端仍停留在之前的稳定提交,工作区保留本阶段修改,方便继续调整。
下一步是把 StartRound 的触发入口从 BP_MyGamemode.OnPostLogin 迁移到 C++ PostLogin,但需要先确认蓝图变量 gamestart 是否还有其他用途。确认没有重复依赖后,再断开蓝图的 StartRound 调用,最后决定是否打开 Enable Native Round Rules。