需求:
在虚幻引擎里用C++实现一个菜单, 在游戏中按esc就可弹出, 并且会立即暂停游戏,这是一个双人游戏本地分屏游戏, 我希望在屏幕中间显示这个, 而不是在任意一边屏幕显示;
有三个按钮:重新开始;返回主菜单;返回游戏


解决方案:
这是一个非常经典的需求。在虚幻引擎(Unreal Engine)中实现这个功能,最好的架构是:使用 C++ 处理逻辑(按钮点击、暂停、关卡切换),使用 UMG (Widget Blueprint) 处理视觉布局。
针对你的"双人分屏"且要求"菜单居中显示在整个屏幕上(而不是某个玩家的半个屏幕)"的需求,核心技巧在于使用 AddToViewport() 而不是 AddToPlayerScreen()。
以下是完整的实现步骤:
第一步:创建 C++ Widget 类
首先,我们需要创建一个继承自 UUserWidget 的 C++ 类,用来处理三个按钮的逻辑。
- 在编辑器中新建 C++ 类,父类选择 UserWidget,命名为 MyPauseMenuWidget。
MyPauseMenuWidget.h
cpp
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "MyPauseMenuWidget.generated.h"
/**
* 暂停菜单 Widget 类
*/
UCLASS()
class YOURGAME_API UMyPauseMenuWidget : public UUserWidget
{
GENERATED_BODY()
public:
// 初始化函数,用于绑定按钮事件
virtual bool Initialize() override;
// 提供一个设置所属 PlayerController 的方法,方便后续操作
void Setup();
// 关闭菜单逻辑
void Teardown();
protected:
// 使用 meta = (BindWidget) 将 C++ 变量与 UMG 中的按钮自动绑定
// 注意:UMG 里的按钮名字必须和这里变量名完全一致
UPROPERTY(meta = (BindWidget))
class UButton* Btn_Resume; // 返回游戏
UPROPERTY(meta = (BindWidget))
class UButton* Btn_Restart; // 重新开始
UPROPERTY(meta = (BindWidget))
class UButton* Btn_MainMenu; // 返回主菜单
private:
// 按钮点击回调函数
UFUNCTION()
void OnResumeClicked();
UFUNCTION()
void OnRestartClicked();
UFUNCTION()
void OnMainMenuClicked();
};
MyPauseMenuWidget.cpp
cpp
#include "MyPauseMenuWidget.h"
#include "Components/Button.h"
#include "Kismet/GameplayStatics.h"
bool UMyPauseMenuWidget::Initialize()
{
if (!Super::Initialize()) return false;
// 绑定点击事件
if (Btn_Resume)
{
Btn_Resume->OnClicked.AddDynamic(this, &UMyPauseMenuWidget::OnResumeClicked);
}
if (Btn_Restart)
{
Btn_Restart->OnClicked.AddDynamic(this, &UMyPauseMenuWidget::OnRestartClicked);
}
if (Btn_MainMenu)
{
Btn_MainMenu->OnClicked.AddDynamic(this, &UMyPauseMenuWidget::OnMainMenuClicked);
}
return true;
}
void UMyPauseMenuWidget::Setup()
{
// 1. 添加到视口
// 关键点:使用 AddToViewport() 会覆盖整个游戏窗口(跨越分屏)
// 如果使用 AddToPlayerScreen() 则只会显示在玩家1的半个屏幕里
this->AddToViewport(9999); // ZOrder设高一点确保在最上层
// 2. 设置输入模式为 UI Only,这样鼠标可以点击,且键盘操作不会传给角色
APlayerController* PC = GetOwningPlayer();
if (PC)
{
FInputModeUIOnly InputData;
InputData.SetWidgetToFocus(this->TakeWidget());
InputData.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
PC->SetInputMode(InputData);
PC->SetShowMouseCursor(true);
// 3. 暂停游戏
UGameplayStatics::SetGamePaused(GetWorld(), true);
}
}
void UMyPauseMenuWidget::Teardown()
{
// 1. 移除 UI
this->RemoveFromParent();
// 2. 恢复输入模式
APlayerController* PC = GetOwningPlayer();
if (PC)
{
FInputModeGameOnly InputData;
PC->SetInputMode(InputData);
PC->SetShowMouseCursor(false);
// 3. 恢复游戏
UGameplayStatics::SetGamePaused(GetWorld(), false);
}
}
void UMyPauseMenuWidget::OnResumeClicked()
{
Teardown(); // 执行关闭菜单逻辑即可
}
void UMyPauseMenuWidget::OnRestartClicked()
{
// 恢复时间(防止重开后游戏还是暂停状态)
Teardown();
// 获取当前关卡名称并重新加载
FString CurrentLevelName = UGameplayStatics::GetCurrentLevelName(this);
UGameplayStatics::OpenLevel(this, FName(*CurrentLevelName));
}
void UMyPauseMenuWidget::OnMainMenuClicked()
{
Teardown();
// 填写你的主菜单关卡名字,例如 "MainMenuMap"
UGameplayStatics::OpenLevel(this, FName("MainMenuMap"));
}
第二步:在 PlayerController 中触发菜单
通常我们只允许 玩家1 (Player 0) 按 ESC 暂停游戏。
-
打开你的 PlayerController C++ 类(如果没有自定义的,请创建一个)。
-
确保你在项目设置 (Project Settings) -> Input 中绑定了 Action Mapping,名字叫 PauseGame,按键设为 Escape。
MyPlayerController.h
cpp
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MyPauseMenuWidget.h" // 引用上面的头文件
#include "MyPlayerController.generated.h"
UCLASS()
class YOURGAME_API AMyPlayerController : public APlayerController
{
GENERATED_BODY()
protected:
virtual void SetupInputComponent() override;
// 触发暂停函数
void TogglePauseMenu();
public:
// 在编辑器里指定我们做好的 WBP
UPROPERTY(EditAnywhere, Category = "UI")
TSubclassOf<UMyPauseMenuWidget> PauseMenuClass;
private:
// 保存实例化的 Widget 指针
UPROPERTY()
UMyPauseMenuWidget* PauseMenuInstance;
};
MyPlayerController.cpp
cpp
#include "MyPlayerController.h"
#include "Kismet/GameplayStatics.h"
void AMyPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
// 绑定 ESC 键
InputComponent->BindAction("PauseGame", IE_Pressed, this, &AMyPlayerController::TogglePauseMenu);
}
void AMyPlayerController::TogglePauseMenu()
{
// 如果是分屏游戏,通常只允许主玩家(Player 0)控制暂停
// IsLocalController() 检查是否是本地玩家
// UGameplayStatics::GetPlayerControllerID 检查是否是 ID 0
if (IsLocalController() && UGameplayStatics::GetPlayerControllerID(this) == 0)
{
if (PauseMenuClass)
{
// 如果菜单还没创建,就创建一个
if (!PauseMenuInstance)
{
PauseMenuInstance = CreateWidget<UMyPauseMenuWidget>(this, PauseMenuClass);
}
// 如果菜单已经在显示(IsInViewport),就关闭它;否则就打开它
if (PauseMenuInstance->IsInViewport())
{
PauseMenuInstance->Teardown(); // 再次按 ESC 返回游戏
}
else
{
PauseMenuInstance->Setup(); // 显示并暂停
}
}
}
}
第三步:制作 UMG 界面 (Widget Blueprint)
这是视觉部分,对应你提供的图片。
-
在 Unreal 编辑器中,右键 -> User Interface -> Widget Blueprint。
-
命名为 WBP_PauseMenu。
-
打开它,点击右上角的 Graph,点击 Class Settings(类设置)。
-
在 Details 面板的 Parent Class 中,搜索并选择我们刚写的 C++ 类:MyPauseMenuWidget。
-
回到 Designer 视图:
-
删除默认的 Canvas Panel 或者保留它。
-
拖入一个 Border (或者 Image) 到屏幕中间,这就是你的菜单背景框。
-
重要: 选中这个 Border,在 Details 面板的 Anchors (锚点) 选择正中间那个(Center)。将 Position X/Y 设为 0,Alignment X/Y 设为 0.5,这样它就会完美居中。
-
在 Border 里面放一个 Vertical Box。
-
在 Vertical Box 里拖入 3 个 Button。
-
关键步骤: 将这 3 个 Button 的名称(在左侧 Hierarchy 面板中重命名)必须 改为:
-
Btn_Restart
-
Btn_MainMenu
-
Btn_Resume
(如果名字不匹配,C++代码里的 BindWidget 会报错或无法绑定)。
-
-
在每个 Button 里面拖入 Text 控件,分别修改文字为 "重新开始"、"返回主菜单"、"返回游戏"。
-
第四步:配置游戏模式
最后一步是把这些连起来。
-
创建一个自定义的 GameMode Base (如果还没有)。
-
在 GameMode 的设置里,将 Player Controller Class 选为你刚才写的 MyPlayerController (或者基于它的蓝图)。
-
打开你的 MyPlayerController 的蓝图子类(如果没有,右键 C++ MyPlayerController 创建一个蓝图类)。
-
在蓝图的 Details 面板里找到 UI 分类下的 Pause Menu Class,选择你刚才做的 WBP_PauseMenu。
总结要点
-
C++ 类 UserWidget:处理核心逻辑(Resume/Restart/LevelLoad)。
-
BindWidget:这是连接 C++ 逻辑和 UMG 视觉的桥梁。
-
PlayerController:处理输入(ESC键)。
-
AddToViewport vs AddToPlayerScreen:
-
AddToPlayerScreen 会把 UI 限制在分屏的那个小格子里。
-
AddToViewport 会把 UI 放在整个显示器屏幕上(无视分屏边界),这就是你想要的效果。
-
-
SetGamePaused:确保游戏逻辑停止。
问题1:
为什么不把按键ESC触发菜单的逻辑写在Tank玩家类里?
这是一个非常好的架构问题。在虚幻引擎中,确实可以在 Pawn(坦克类)里写 ESC 逻辑,但将系统级按键(如暂停、菜单)写在 PlayerController 里是行业标准做法。
主要原因有以下几点,其中第一点对你的坦克游戏尤为致命:
1. "坦克炸了怎么办?"(生存周期问题)
这是最核心的原因。
Pawn (坦克) 是肉体。当坦克被摧毁(Destroyed)或者重生时,这个 C++ 对象通常会被销毁并在内存中清除。
PlayerController 是灵魂/大脑。无论坦克死多少次,PlayerController 一直存在,直到玩家退出关卡。
如果你把 ESC 逻辑写在 Tank 类里:
当玩家的坦克被打爆了,还没重生的时候,玩家想按 ESC 退出游戏或者重新开始------但他做不到。因为坦克对象没了,依附在上面的代码也就无法执行了。玩家会陷入"死机"状态,必须强制关闭窗口。
如果你写在 PlayerController 里:
即使坦克炸了,PlayerController 依然活着,玩家依然可以按 ESC 呼出菜单点击"重新开始"。
2. 逻辑分层(关注点分离)
Tank 类 (Pawn) 应该只关心和游戏世界内的交互:比如开火、移动、炮塔旋转、受到伤害。这是"角色行为"。
PlayerController 类 应该关心玩家与系统的交互:比如打开菜单、调整设置、鼠标显示隐藏、切换输入模式。这是"系统行为"。
让坦克去管理"UI显示"是不符合面向对象设计的。坦克只是一辆车,它不应该知道"主菜单"是什么。
3. 附身与载具切换 (Possession)
假设你的游戏以后加了一个功能:坦克坏了,驾驶员可以跳出来跑,或者驾驶员可以切换到一架无人机上。
如果你把 ESC 写在坦克里,当你跳出坦克变成"驾驶员Pawn"时,ESC 键就失效了(除非你在驾驶员类里再写一遍)。
如果你写在 PlayerController 里,无论你控制的是坦克、步兵还是无人机,ESC 键永远有效,因为控制器没变。
4. UI 与 输入模式的归属权
在代码中你需要调用 SetInputMode 和 SetShowMouseCursor。
这些函数本身就是 PlayerController 的成员函数。
如果在 PlayerController 里写,直接 this->SetShowMouseCursor(true) 很顺手。
如果在 Tank 里写,你还得先 GetController(),强转成 PlayerController,然后再调用。这从代码结构上就暗示了这些功能本来就不属于 Tank。