C++游戏开发之旅 28

问题概述

这节实现UI管理器与UI元素,上一节我们实现了文本绘制函数,但是我们不可能在GameScenerender函数中调用大量的函数进行UI绘制,这没有框架,且难以维护和复用,而且有一个输入混乱的问题,就是输入会穿透,导致体验很差。

解决方案

我们会采用一个类似自组合+继承的方式来实现对UI的管理。

image-20260309173910622

核心思想:

  • 建立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 继承自什么"
// "转换失败!报错!"
posted @ 2026-03-10 16:44  wenyiGamecpp  阅读(53)  评论(0)    收藏  举报