C++游戏开发之旅 28
问题概述
这节实现UI管理器与UI元素,上一节我们实现了文本绘制函数,但是我们不可能在GameScene的render函数中调用大量的函数进行UI绘制,这没有框架,且难以维护和复用,而且有一个输入混乱的问题,就是输入会穿透,导致体验很差。
解决方案
我们会采用一个类似自组合+继承的方式来实现对UI的管理。

核心思想:
- 建立UI元素的层级树结构
- 创建统一的UIElement基类
- 用UIManager管理整个UI树
- 优先处理UI输入事件
UI框架架构:
场景Scene 顶层
update()
render()
handleInput()
↓ 持有
UIManager 管理层
root_element_
addElement()
↓ 管理
UIElement 基类
position_
children_
update()
render()
↓ 继承
UIPannel UIText UIButton 具体控件
容器面板 文本标签 交互按钮
章节目标
UI架构设计 --- 学习如果将UI系统作为独立模块
创建UIElement基类 --- 设计所有UI控件的通用基类
实现UIManager --- 管理UI树的更新、渲染、输入
构建UIPannel控件 --- 实现一个UI容器并测试
第一部分:架构设计
设计思想:
每个场景Scene对象都应该拥有并管理自己的一套UI,GameScene 有它的游戏界面(HUD),之后的主菜单MenuScene会有它的按钮,这种设计可以天然的将不同场景的UI隔开。
场景UI隔离:
GameScene --- ui_manager_ HUD、生命值、得分
MenuScene --- ui_manager_ 标题、开始按钮、退出按钮
各自独立,互不干扰
1.在Scene中集成UIManager
在Scene类中添加一个UIManager成员,显然它的生命周期就是Scene类的生命周期。我们使用智能指针进行管理。Scene创建 ---> UIManager 创建、Scene销毁 ---> UIManager 自动销毁、Scene的构造函数会自动创建UIManager实例,接着,我们在Scene的核心循环方法中,委托ui管理器来处理UI相关的逻辑。
- update() --- 先更新游戏对象后更新UI管理器
- render() --- 先渲染游戏对象后渲染UI管理器,这样UI不会被游戏对象覆盖
- handleInput() --- 先处理UI管理器,如果输入事件被UI处理则返回,不再处理游戏对象输入,防止事件穿透
输入事件优先级
注意我们场景中的handleInput逻辑,我们会先让UIManager优先处理输入事件,如果UI消耗了这个事件(用户点击按钮),函数就直接返回true,输入事件不会再传递给游戏对象中的对象。
第二部分:UIElement 基类
基类UIElement
UIElement 是UI框架的基类,是一个抽象类,定义了所有UI控件共有属性行为,位置,大小,可见性,层级关系。
2.UIElement
#pragma once
#include <glm/vec2.hpp>
#include <vector>
#include <memory>
#include "../utils/math.h"
namespace engine::core{
class Context;
}
namespace engine::ui {
class UIElement {
protected:
glm::vec2 position_; // 相对父元素的局部位置
glm::vec2 size_;
bool visible_ = true;
bool need_remove_ = false;
UIElement* parent_ = nullptr; // 指向父节点的非拥有指针
std::vector<std::unique_ptr<UIElement>> children_; // 子元素列表
public:
explicit UIElement(const glm::vec2& position = {0.0f, 0.0f}, const glm::vec2& size = {0.0f, 0.0f});
virtual ~UIElement() = default;
// 禁止拷贝和移动
UIElement(const UIElement&) = delete;
UIElement& operator=(const UIElement&) = delete;
UIElement(UIElement&&) = delete;
UIElement& operator=(UIElement&&) = delete;
// 核心虚方法 没有使用init和clean 注意构造函数和析构函数的使用
virtual bool handleInput(engine::core::Context& context);
virtual void update(float deltaTime, engine::core::Context& context);
virtual void render(engine::core::Context& context);
// 核心接口
void addChild(std::unique_ptr<UIElement> child); // 添加子元素
std::unique_ptr<UIElement> removeChild(UIElement* child); // 返回指向被移除元素的unique_ptr
void removeAllChildren(); // 移除所有子元素
// 辅助方法
engine::utils::Rect getBounds() const; // 获取元素在屏幕上的边界
glm::vec2 getScreenPosition() const; // 获取计算元素在屏幕上的位置
bool isPointInside(const glm::vec2& point) const; // 判断一个点是否在元素内部
// get set
const glm::vec2& getPosition() const { return position_; }
const glm::vec2& getSize() const { return size_; }
bool isVisible() const { return visible_; }
bool isNeedRemove() const { return need_remove_; }
UIElement* getParent() const { return parent_; }
const std::vector<std::unique_ptr<UIElement>>& getChildren() const { return children_; }
void setPosition(const glm::vec2& position) { position_ = position; }
void setSize(const glm::vec2& size) { size_ = size; }
void setVisible(bool visible) { visible_ = visible; }
void setNeedRemove(bool need_remove) { need_remove_ = need_remove; }
void setParent(UIElement* parent) { parent_ = parent; }
};
}
UI树结构,我们的UI有层级关系,每个UIElement可以有父节点和多个子节点,构成一个树结构。
子元素相对父元素有相对位移,计算一个元素的坐标可以通过递归的方式得到pos = 自身位置 + 所有父节点位置,再处理ui元素逻辑、渲染、更新的时候就可以通过遍历这个树。
抽象基类声明及定义
基本成员变量
- position, size
- parent, children
基本方法
管理树结构 - addChild()、removeChild()
工具函数 --- 获取位置 - getScreenPosition()
获得边界 getBounds() 一个矩形,{位置,大小}
点判断 isPointInside()
虚函数方法 - handleInput()、update()、render()
核心方法说明
handleInput() 输入处理
| 从根到叶遍历
| 子元素先响应
update() 逻辑更新
| 更新自身
| 更新所有子元素
render() 渲染绘制
| 渲染自身
| 渲染所有子元素
getScreenPosition() 坐标转换 相对->绝对
| 递归累加父节点位置
isPointInside() 碰撞检测
| 检测点是否在范围内
事件分发流程
UIManager::handleInput()
↓
UIPanel::handleInput() (根节点)
↓
Button::handleInput() (子元素)
↓
检测点击
↓
返回 true (按钮被点击)
↓
向上冒泡,一路返回 true
3.UIManager实现
ui管理器,我们需要这个类实现ui管理,其会存一个根节点,作为场景与UI系统交互的接口。
src/engine/ui/ui_manager.h
#pragma once
#include <memory>
#include <vector>
#include <glm/vec2.hpp>
namespace engine::core {
class Context;
}
namespace engine::ui {
class UIElement;
class UIPanel; // UIPanel 将作为根元素
}
namespace engine::ui {
/**
* @brief 管理特定场景中的UI元素集合。
*
* 负责UI元素的生命周期管理(通过根元素)、渲染调用和输入事件分发。
* 每个需要UI的场景(如菜单、游戏HUD)应该拥有一个UIManager实例。
*/
class UIManager final {
private:
std::unique_ptr<UIPanel> root_element_; // UIPanel作为根节点(UI元素)
public:
UIManager(); ///< @brief 构造函数将创建默认的根节点。
~UIManager();
[[nodiscard]] bool init(const glm::vec2& window_size); ///< @brief 初始化UI管理器,设置根元素的大小。
void addElement(std::unique_ptr<UIElement> element); ///< @brief 添加一个UI元素到根节点的child_容器中。
UIPanel* getRootElement() const; ///< @brief 获取根UIPanel元素的指针。
void clearElements(); ///< @brief 清除所有UI元素,通常用于重置UI状态。
// --- 核心循环方法 ---
bool handleInput(engine::core::Context&); ///< @brief 处理输入事件,如果事件被处理则返回true。
void update(float delta_time, engine::core::Context&);
void render(engine::core::Context&);
// 禁止拷贝和移动构造/赋值
UIManager(const UIManager&) = delete;
UIManager& operator=(const UIManager&) = delete;
UIManager(UIManager&&) = delete;
UIManager& operator=(UIManager&&) = delete;
};
} // namespace engine::ui
设计方法:
uimanager框架:
root_element_ 根节点 UIPanel
|
——————————————
| |
Panel UI2 所有UI挂在根下
|
Button
ui管理器负责管理根节点,转发循环调用,提供根节点下的子元素添加、清除。
#include "ui_manager.h"
#include "ui_element.h"
#include "ui_panel.h"
#include <spdlog/spdlog.h>
namespace engine::ui {
UIManager::UIManager()
{
root_element_ = std::make_unique<UIPanel>(glm::vec2(0, 0), glm::vec2(0, 0));
spdlog::trace("UI管理器构造完成");
}
UIManager::~UIManager() = default;
bool UIManager::init(const glm::vec2 &window_size)
{
root_element_->setSize(window_size);
spdlog::trace("UI管理器初始化根面板完成");
return true;
}
void UIManager::addElement(std::unique_ptr<UIElement> element)
{
if(root_element_){
root_element_->addChild(std::move(element));
} else {
spdlog::error("UI管理器未初始化");
}
}
UIPanel *UIManager::getRootElement() const
{
return root_element_.get();
}
void UIManager::clearElements()
{
if(root_element_){
root_element_->removeAllChildren();
}
spdlog::trace("UI管理器清空所有元素完成");
}
bool UIManager::handleInput(engine::core::Context &context)
{
if(root_element_ && root_element_->isVisible()){
if(root_element_->handleInput(context)) return true;
}
return false;
}
void UIManager::update(float delta_time, engine::core::Context &context)
{
if(root_element_ && root_element_->isVisible()){
root_element_->update(delta_time, context);
}
}
void UIManager::render(engine::core::Context &context)
{
if(root_element_ && root_element_->isVisible()){
root_element_->render(context);
}
}
}
UIManager 实现流程:
↓
1. 构造函数
创建根节点 UIPanel
↓
2. init()
设置根节点大小为窗口大小
↓
3. addElement()
添加UI元素到根节点
↓
4. 循环方法
转发给根节点
├─ handleInput()
├─ update()
└─ render()
↓
5. clearElements()
清除根节点的所有子元素
具体实现:
Scene调用 ui_manager->render() --转发--> UIManager 转发 root_element->render() --遍历树--> UIPanel 及子元素 遍历整个UI树
第四部分:控件 UIPanel
容器控件:UIPanel,这是一个类似画板的容器,可以选择背景色,用于组织和布局其他UI元素
4.UIPanel 实现
src/engine/ui/ui_panel.h
// ui_panel.h
#pragma once
#include "ui_element.h"
#include "../utils/math.h"
#include <optional>
#include <glm/vec2.hpp>
namespace engine::ui {
class UIPanel final : public UIElement {
std::optional<engine::utils::FColor> background_color_; // 可选背景色
public:
/**
* @brief 构造函数
* @param position 位置
* @param size 大小
* @param background_color 背景色
*/
explicit UIPanel(glm::vec2 position = {0.0f, 0.0f}, glm::vec2 size = {0.0f, 0.0f},
std::optional<engine::utils::FColor> background_color = std::nullopt);
void setBackgroundColor(std::optional<engine::utils::FColor> color) { background_color_ = color; }
const std::optional<engine::utils::FColor>& getBackgroundColor() const { return background_color_; }
void render(engine::core::Context& ) override;
};
}
// ui_pannel.cpp
#include "ui_panel.h"
#include "../core/context.h"
#include "../render/renderer.h"
#include <spdlog/spdlog.h>
namespace engine::ui {
UIPanel::UIPanel(glm::vec2 position, glm::vec2 size,
std::optional<engine::utils::FColor> background_color)
: UIElement(std::move(position), std::move(size)),
background_color_(std::move(background_color)) {
spdlog::trace("UIPanel 构造完成");
}
void UIPanel::render(engine::core::Context &context)
{
if(!visible_) return;
if (background_color_.has_value()) {
context.getRenderer().drawUIFilledRect(getBounds(),background_color_.value());
}
UIElement::render(context); // 渲染子节点
}
}
UIPanel可以选择颜色,创建一个画板共其他UI布局,继承自UIElement,有一个Element容器可包含子元素,值得注意的是,我们重写了render方法,UIElement是正常渲染,而UIPanel会先检查是否有背景色,之后调用渲染器绘制一个填充矩形,然后调用基类render。
为了实现透明色,在初始化SDL需要进行一个设置,表明支持Alpha混合
// in GameApp::initSDL()
// 设置渲染器支持透明色
SDL_SetRenderDrawBlendMode(sdl_renderer_, SDL_BLENDMODE_BLEND);
5.验证测试
最后,在GameScene中创建一个initUI函数,然后,在其中添加一个半透明的矩形面板。
// game_scene.h
class GameScene : public engine::scene::Scene {
// ...
private:
bool initUI();
// ...
};
// game_scene.cpp
void GameScene::init() {
// ...
if (!initUI()) {
spdlog::error("UI初始化失败...");
return;
}
// ...
}
bool GameScene::initUI()
{
if(!ui_manager_->init(glm::vec2(640.0f, 360.0f))) return false;
// 创建一个UIpanel
ui_manager_->addElement(std::make_unique<engine::ui::UIPanel>(
glm::vec2(100.f, 100.f),
glm::vec2(200.f, 100.f),
engine::utils::FColor {1.0f, 1.0f, 0.0f, 0.5f}
));
return true;
}
我们可以看到一个半黄色的矩形出现在屏幕内。
章节总结
UI系统完整框架
┌──────────────────────────┐
│ GameApp │ ← 顶层应用
└────────┬─────────────────┘
│ 创建
↓
┌──────────────────────────┐
│ Scene │ ← 场景层
│ ui_manager_ │ 持有UIManager
└────────┬─────────────────┘
│ 转发
↓
┌──────────────────────────┐
│ UIManager │ ← 管理层
│ root_element_ │ 持有根节点
│ • addElement() │
│ • handleInput() │
│ • update() │
│ • render() │
└────────┬─────────────────┘
│ 管理
↓
┌──────────────────────────┐
│ UIElement │ ← 基类层
│ • position_, size_ │ 树结构
│ • parent_, children_ │
│ • getScreenPosition() │
│ • virtual方法 │
└────────┬─────────────────┘
│ 继承
┌────┴────┬─────────┐
│ │ │
↓ ↓ ↓
┌────────┐┌────────┐┌────────┐
│UIPanel ││UIText ││UIButton│ ← 具体控件层
│面板容器││文本显示││交互按钮│ 可扩展
└────────┘└────────┘└────────┘
设计模式
1. 单一职责原则
UIManager 只负责:
- 统一管理 UI 元素
- 分发事件
- 协调更新和渲染
UIElement 负责具体逻辑
2. 外观模式
场景代码不需要知道 UIElement 的细节
↓
通过 UIManager 统一访问
↓
UIManager 内部处理复杂逻辑
3.转发模式
Scene 有uimanager管理器
uimanager调用root节点的逻辑、事件、渲染更新
遇到的问题
1.std::remove_if的使用,这里和之前一样,比如我们需要删除一个根节点的子元素。
std::unique_ptr<UIElement> UIElement::removeChild(UIElement *child_ptr)
{
// std::remove_if 和 lambda 表达式删除指定元素
auto it = std::remove_if(children_.begin(), children_.end(),
[child_ptr](const std::unique_ptr<UIElement> &p){return p.get() == child_ptr;});
if(it != children_.end()){
std::unique_ptr<UIElement> remove_child = std::move(*it);
children_.erase(it);
remove_child->setParent(nullptr); // 将父节点设置为 nullptr
return remove_child; // 返回被删除的子节点
}
return nullptr;
}
其内部相关逻辑是这样的
// 伪代码展示 remove_if 的内部逻辑
for (auto it = begin; it != end; ++it) {
// 对每个元素调用 lambda,p 就是 *it
if (lambda(*it)) { // 返回 true,表示要"删除"
// 标记这个位置
} else {
// 保留这个元素,移到前面
}
}
2.ui_manager_->addElement(std::make_unique<engine::ui::UIPanel>(...));调用时发生错误,说无法将直接转换
在GameScene中忘记引入了#include "../../engine/ui/ui_panel.h",只引入了ui_manager.h,
// 编译器看到这段代码
auto panel = std::make_unique<engine::ui::UIPanel>(...);
ui_manager_->addElement(std::move(panel));
// 编译器想:
// "我需要一个 unique_ptr<UIElement>"
// "你给了我一个 unique_ptr<UIPanel>"
// "UIPanel 能转换为 UIElement 吗?"
// "让我看看... UIPanel 的定义在哪里?"
// "没找到!我不知道 UIPanel 继承自什么"
// "转换失败!报错!"

浙公网安备 33010602011771号