Unreal Engine

房间渐进式迁移

这次整理的目标,是把一个以蓝图为主的 UE5 多人对战项目,逐步迁移到更清晰的 C++ 房间生命周期结构中。项目原本已经有一套可以运行的蓝图逻辑,包含玩家进入、队伍分配、出生点选择、倒计时、玩家退出和 UI 刷新。迁移过程中不能一次性推翻旧逻辑,否则很难判断问题来自原有玩法,还是来自新的 C++ 代码。因此这次采用了渐进式迁移:先保留稳定的蓝图玩法,再让 C++ 接管一个明确的职责,最后断开对应的旧蓝图链路。

一、小地图友军标记不同步

最早遇到的问题是多人进入房间后,小地图显示不一致:后加入的玩家可以看到先加入的玩家,但先加入的玩家看不到后来加入的同队玩家。

问题原因

小地图并不是每帧重新扫描所有玩家。CommonUI 创建时,会根据当时已经存在的 Shooter 角色创建小地图标记;之后 staticMiniMapTick 只更新已有标记的位置。

因此,真正的问题不是角色位置没有同步,而是新玩家出现后,旧客户端没有重新生成对应的小地图标记。

处理方式

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++ 类:

  • ARoomGameMode
  • ARoomGameState
  • ARoomPlayerController

它们分别承担房间规则、可复制的回合状态和客户端控制器相关职责。

之后将三个蓝图接入 C++ 父类:

BP_MyGamemode         -> RoomGameMode
BP_MyGameState        -> RoomGameState
BP_MyPlayerController -> RoomPlayerController

蓝图仍然保留原有的队伍分配、出生点、小地图刷新和 UI 初始化逻辑。GameStateClass 仍然指向 BP_MyGameStatePlayerControllerClass 仍然指向 BP_MyPlayerController,避免 C++ 默认类直接替换已有玩法对象。

这种结构的好处是:C++ 提供稳定的底层能力,蓝图子类保留项目已有的具体玩法。

五、倒计时迁移到 C++

原来的倒计时由蓝图定时器驱动:每秒调用 upDateTimer,修改 BP_MyGameState.RemainingTime,UI 再读取这个变量。

迁移后,由 ARoomGameMode 负责计时,由 ARoomGameState 负责复制回合状态:

ARoomGameMode.StartRound
    -> RoundState = Playing
    -> RemainingSeconds = 300
    -> C++ 定时器每秒减少一秒
    -> 时间结束后进入 Ending

ARoomGameState 中的 RoundState 包含:

  • 当前回合阶段。
  • 剩余秒数。
  • 回合 ID。

BP_MyGameStateOnRoundStateChanged 中读取 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 会:

  1. 停止回合计时器。
  2. RoundState 设置为 Ending
  3. 通知每个 ARoomPlayerController 执行 ClientReturnToMain

ARoomPlayerController 的返回流程是:

ClientReturnToMain
    -> 防止重复执行
    -> 等待结束 UI 显示
    -> 查找 GameSession
    -> 请求 DestroySession
    -> 等待成功、失败或超时
    -> ClientTravel 到 Main

为了避免玩家卡死,Session 销毁设置了超时保护。无论销毁成功、失败,还是 OnlineSubsystem 没有返回结果,最终都会进入同一个 TravelToMain 函数。

TravelToMain 使用布尔变量防止重复跳转,因为以下三个路径可能在不同时间到达:

  • Session 销毁成功回调。
  • Session 销毁失败回调。
  • 超时计时器。

Listen Server 与 Dedicated Server 的区别

Listen Server 的房主既是服务器又是本地客户端。如果房主在回合结束时同时执行 ClientTravelServerTravel,两个地图跳转会互相冲突。因此当前逻辑中:

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