C++游戏开发之旅 29

问题概述

这章完成游戏UI的绘制,绘制生命UI和得分UI。

完成的任务:

  • 创建UIImage控件显示图标
  • 创建UILabel控件显示文本
  • 组合控件构建生命值显示
  • 实现数据绑定动态更新

第一部分:UI控件扩展

上一章完成了UIPanel,这节完成最常用的两个控件UIImageUILabel

1.UIImage

精灵UI实现的功能很简单,就是在UI层级中目标区域绘制一个精灵。

src/engine/ui/ui_image.h

#pragma once 
#include "ui_element.h"
#include "../render/sprite.h"
#include <string>
#include <optional>
#include <SDL3/SDL_rect.h>

namespace engine::ui {

class UIImage : public UIElement {
private:
    engine::render::Sprite sprite_;
public:
    /**
     * @brief 精灵ui的构造函数
     * @param texture_id 精灵的纹理id
     * @param position 精灵的位置
     * @param size 精灵的大小 {0, 0} 表示使用纹理源矩形的大小
     * @param src_rect 精灵的纹理源矩形
     * @param is_flipped 是否翻转
     */
    UIImage(const std::string& texture_id,
        const glm::vec2& position = {0, 0},
        const glm::vec2& size = {0, 0},
        const std::optional<SDL_FRect>& src_rect = std::nullopt,
        bool is_flipped = false
    );
    ~UIImage() override = default;

    void render(engine::core::Context& context) override;

    // get set
    const engine::render::Sprite& getSprite() const { return sprite_; }
    void setSprite(const engine::render::Sprite& sprite) { sprite_ = sprite; }

};

}

UIImage主要是持有一个sprite精灵,并且有一个关键函数render,绘制ui精灵。

可实现的功能:

  • 根据size来决定绘制的图片大小,如果是{0,0}则绘制源矩形大小

  • 位置是相对于父节点的位置计算得到

  • sprite精灵可决定是否裁剪以及缩放等功能

src/engine/ui/ui_image.cpp

#include "ui_image.h"
#include <spdlog/spdlog.h>
#include "../core/context.h"
#include "../render/renderer.h"

namespace engine::ui{
    UIImage::UIImage(
        const std::string &texture_id,
        const glm::vec2 &position, 
        const glm::vec2 &size,
        const std::optional<SDL_FRect> &src_rect,
        bool is_flipped
    )
    : UIElement(position, size), 
      sprite_(render::Sprite(texture_id, src_rect, is_flipped))
    {
        spdlog::trace("UIImage构造成功");
    }

    void UIImage::render(engine::core::Context &context)
    {
        if(!visible_ || sprite_.getTextureId() == "") return; // 如果不可见或没有纹理,则不渲染

        auto position = getScreenPosition();
        if(size_ == glm::vec2(0, 0)){
            context.getRenderer().drawUISprite(sprite_, position_);
        } else {
            context.getRenderer().drawUISprite(sprite_, position_, size_);
        }

        UIElement::render(context);
    }
}

2.UILabel

UILabel同样继承于``UIElement,其封装了TextRenderer`的功能,使其成为一个正式UI控件。

src/engine/ui/ui_label.h

#pragma once 
#include "ui_element.h"
#include <string>

namespace engine::render{
class TextRenderer;
}


namespace engine::ui{
class UILabel : public UIElement{
private:
    render::TextRenderer* text_renderer_;
    std::string text_; // 文本内容
    std::string font_id_; // 字体
    int font_size_; // 字体大小
    engine::utils::FColor color_; // 字体颜色 

public:
    UILabel(render::TextRenderer* text_renderer,
            const std::string& text_,
            const std::string& font_id_,
            int font_size_ = 16,
            const glm::vec2& position = {0,0},
            const engine::utils::FColor& color = {1,1,1,1}
    );

    void render(engine::core::Context& context) override;

    // get set 
    const std::string& getText() const { return text_; }
    const std::string& getFontId() const { return font_id_; }
    int getFontSize() const { return font_size_; }
    const engine::utils::FColor& getColor() const { return color_; }
    
    void setText(const std::string& text);
    void setFontId(const std::string& font_id);
    void setFontSize(int font_size);
    void setColor(const engine::utils::FColor& color);


};
}

UILabel需要实现:

  • 文本绘制功能
  • 支持动态文本
  • 文本改变时可以更新大小
  • 颜色可修改

src/engine/ui/ui_label.cpp

#include "ui_label.h"
#include "../render/text_renderer.h"
#include <spdlog/spdlog.h>

namespace engine::ui {

    UILabel::UILabel(
        render::TextRenderer *text_renderer,
        const std::string& text_, 
        const std::string& font_id_, 
        int font_size_, 
        const glm::vec2& position, 
        const engine::utils::FColor& color
    ) : UIElement(position), text_renderer_(text_renderer), 
        text_(text_), font_id_(font_id_), font_size_(font_size_), color_(color)
    {
        if(text_renderer_ == nullptr){
            spdlog::error("UILabel缺少TextRenderer");
            return;
        }
        spdlog::trace("UILabel创建成功");
    }

    void UILabel::render(engine::core::Context &context)
    {
        if(!visible_ || text_.empty()) return;

        auto position = getScreenPosition();
        text_renderer_->drawUIText(text_, font_id_, font_size_, position, color_);

        UIElement::render(context);
    }

    void UILabel::setText(const std::string &text)
    {
        // 这里需要注意,我们的修改文本,这个label组件的size_是会改变的
        text_ = text; 
        size_ = text_renderer_->getTextSize(text, font_id_, font_size_);
    }

    void UILabel::setFontId(const std::string &font_id)
    {
        font_id_ = font_id;
        size_ = text_renderer_->getTextSize(text_, font_id_, font_size_);
    }
    
    void UILabel::setFontSize(int font_size)
    {
        font_size_ = font_size;
        size_ = text_renderer_->getTextSize(text_, font_id_, font_size_);
    }
    
    void UILabel::setColor(const engine::utils::FColor &color)
    {
        color_ = color;
    }
}

这里主要是一个render函数比较重要,这是我们是否可以显示文字的关键,我们调用了textrenderer的文字显示;还有几个较为重要的点就是,在动态改变文本内容、修改字体font、字体大小的时候都需要对UILabel的size大小进行设置.

第二部分:构建游戏HUD

接下来,我们需要去GameScene中绘制UI界面了。

3.GameScene中绘制HUD

namespace engine::ui {
    class UILabel;
    class UIPanel;
}

class GameScene final: public engine::scene::Scene {
    // ...
    // UI元素生命周期由UIManager管理,因此使用裸指针缓存
    engine::ui::UILabel* score_label_ = nullptr;
    engine::ui::UIPanel* health_panel_ = nullptr;
    // ...
private:
    // --- UI 相关函数 ---
    void createScoreUI();
    void createHealthUI();
    // ...
};

这里GameScene中缓存了两个指针,score_label_*、health_panel_*,这里的两个指针只是用于访问,不负责释放,因为我们有关UI的部分都会加到ui_manager_中的root_element_形成树结构,由ui管理器进行统一的释放。

这里使用裸指针是安全的,因为这些UI元素的所有权在 UIManagerroot_element_ 中,它们的生命周期与 GameScene 相同。我们只持有指针用于访问,不负责管理内存。

4.得分UI创建

void GameScene::createScoreUI()
    {
        // 创建分数文本text
        auto score_text = "Score: " + std::to_string(game_session_data_->getCurrentScore());
        // 创建分数uilabel
        std::unique_ptr<engine::ui::UILabel> ui_score = std::make_unique<engine::ui::UILabel>(
            &context_.getTextRenderer(), score_text,"assets/fonts/VonwaonBitmap-16px.ttf",16
        );
        // 缓存裸指针
        score_label_ = ui_score.get();

        // 获得label的尺寸, 调整位置
        auto score_label_size = ui_score->getSize();
        score_label_->setPosition(
        glm::vec2(context_.getCamera().getViewportSize().x - score_label_size.x - 10, 5));
        // 添加到UI管理器
        ui_manager_->addElement(std::move(ui_score));
    }

5.创建生命值UI

void GameScene::createHealthUI()
    {
        int max_health = game_session_data_->getMaxHealth();
        int cur_health = game_session_data_->getCurrentHealth();

        int startx = 10;
        int starty = 5;
        int icon_width = 20;
        int icon_height = 18;
        int icon_gap = 5;

        // 创建血量面板
        std::unique_ptr<engine::ui::UIPanel> panel_heal = std::make_unique<engine::ui::UIPanel>();
        // 缓存指针
        health_panel_ = panel_heal.get();

        // 创建血量背景
        for(int i = 0; i < game_session_data_->getMaxHealth(); ++i){
        std::unique_ptr<engine::ui::UIImage> ui_heart = std::make_unique<engine::ui::UIImage>(
            "assets/textures/UI/Heart-bg.png",
            glm::vec2(startx + (icon_width + icon_gap) * i,starty),
            glm::vec2(icon_width, icon_height));
            panel_heal->addChild(std::move(ui_heart));
        }
        // 创建血量前景
        for(int i = 0; i < game_session_data_->getCurrentHealth(); ++i){
            std::unique_ptr<engine::ui::UIImage> ui_heart = std::make_unique<engine::ui::UIImage>(
            "assets/textures/UI/Heart.png",
            glm::vec2(startx + (icon_width + icon_gap) * i,starty),
            glm::vec2(icon_width, icon_height));
            panel_heal->addChild(std::move(ui_heart));
        }
        // 添加到UI管理器
        ui_manager_->addElement(std::move(panel_heal));
    }

第三部分:数据绑定

我们在进行分数增加或是血量变化的时候要更新UI。

我们需要实现血量ui、分数score的创建以及更新。

void GameScene::createScoreUI()
{
    // 创建分数文本text
    auto score_text = "Score: " + std::to_string(game_session_data_->getCurrentScore());
    // 创建分数uilabel
    std::unique_ptr<engine::ui::UILabel> ui_score = std::make_unique<engine::ui::UILabel>(
        &context_.getTextRenderer(), score_text,"assets/fonts/VonwaonBitmap-16px.ttf",16
    );
    // 缓存裸指针
    score_label_ = ui_score.get();

    // 获得label的尺寸, 调整位置
    auto score_label_size = ui_score->getSize();
    score_label_->setPosition(
        glm::vec2(context_.getCamera().getViewportSize().x - score_label_size.x - 40, 5));
    // 添加到UI管理器
    ui_manager_->addElement(std::move(ui_score));
}

void GameScene::createHealthUI()
{
    int max_health = game_session_data_->getMaxHealth();
    int cur_health = game_session_data_->getCurrentHealth();

    int startx = 10;
    int starty = 5;
    int icon_width = 20;
    int icon_height = 18;
    int icon_gap = 5;

    // 创建血量面板
    std::unique_ptr<engine::ui::UIPanel> panel_heal = std::make_unique<engine::ui::UIPanel>();
    // 缓存指针
    health_panel_ = panel_heal.get();

    // 创建血量背景
    for(int i = 0; i < max_health; ++i){
        std::unique_ptr<engine::ui::UIImage> ui_heart = std::make_unique<engine::ui::UIImage>(
            "assets/textures/UI/Heart-bg.png",
            glm::vec2(startx + (icon_width + icon_gap) * i,starty),
            glm::vec2(icon_width, icon_height));
        panel_heal->addChild(std::move(ui_heart));
    }
    // 创建血量前景
    for(int i = 0; i < cur_health; ++i){
        std::unique_ptr<engine::ui::UIImage> ui_heart = std::make_unique<engine::ui::UIImage>(
            "assets/textures/UI/Heart.png",
            glm::vec2(startx + (icon_width + icon_gap) * i,starty),
            glm::vec2(icon_width, icon_height));
        panel_heal->addChild(std::move(ui_heart));
    }
    // 添加到UI管理器
    ui_manager_->addElement(std::move(panel_heal));
}

void GameScene::healWithUI(int heal)
{
    auto player_hc = player_->getComponent<engine::component::HealthComponent>();
    player_hc->heal(heal);
    updateHealthUI();
}

void GameScene::updateHealthUI()
{
    // 更新血量
    auto cur_health = player_->getComponent<engine::component::HealthComponent>()->getCurrentHealth();
    game_session_data_->setCurrentHealth(cur_health); // 更新数据
    auto max_health = game_session_data_->getMaxHealth();

    // 更新UI
    // 这里只需要考虑后半部分child成员,因为前半部分是背景
    for(int i = max_health; i < health_panel_->getChildren().size() ; ++i){
        bool visible = i - max_health < cur_health;
        health_panel_->getChildren()[i]->setVisible(visible);
    }

}

void GameScene::addScoreWithUI(int score)
{
    game_session_data_->addScore(score);
    auto score_text = "Score: " + std::to_string(game_session_data_->getCurrentScore());
    score_label_->setText(score_text);
}

一个关卡内需要实现数据的同步

组件层 实际生命值 current_health / 数据会话层 current_health 记录游戏状态 / UI 视觉显示层,视觉呈现

章节总结

这节实现了生命值ui和分数ui,通过UIImage和UILabel两个控件实现了场景中ui的绘制,此外对数据与会话层进行了绑定以及实时更新ui。

遇到的问题

1.这是上一节的问题,我们讲讲UIElement的生命周期。

因为项目遵从的是RAII特性,也就是资源获取即初始化,我们声明~UIElement() = default;(默认析构),或者完全不写析构函数,编译器会自动生成一个析构函数,这个自动生成的析构函数会做两件关键事:

  1. 按「成员变量声明的逆序」,调用每个非静态成员变量的析构函数;
  2. 调用基类的析构函数(如果有继承)。

简化版拆解UIElement的销毁流程。

// 简化版UIElement
class UIElement {
private:
    // 成员1:普通变量(无析构逻辑)
    int pos_x_ = 0;
    // 成员2:容器(有自定义析构)
    std::vector<std::unique_ptr<UIElement>> children_;

public:
    ~UIElement() = default;
};

当我们执行了delete ui;(销毁堆上的UIElement对象)时,编译器自动生成的析构逻辑如下:

// 编译器自动生成的析构逻辑
UIElement::~UIElement() {
    // 按逆序销毁成员变量(先销毁最后声明的成员)
    // 销毁 children_(vector容器)
    children_.~vector(); 
    // 销毁 pos_x_(int,无析构,仅标记内存可用)
    // (int是内置类型,无析构函数,编译器仅释放其占用的内存)
    
    // 步骤3:调用基类析构(如果有,比如UIElement继承自BaseUI,则调用~BaseUI())
}

成员变量的析构会触发 链式销毁

重点在「成员变量的析构函数」会做额外工作,这才是资源释放的核心:

销毁 children_(vector 容器)

vector 的析构函数会做两件事:

  • 遍历容器内的所有元素(每个 unique_ptr<UIElement>),调用每个元素的析构函数;
  • 释放 vector 内部动态分配的缓冲区(存储这些 unique_ptr 的堆内存)。

容器内的 unique_ptr 析构

每个 unique_ptr<UIElement> 的析构函数会执行:

// unique_ptr自动生成的析构逻辑
template<class T>
unique_ptr<T>::~unique_ptr() {
    if (ptr_) { // ptr_是unique_ptr内部存储的指针
        delete ptr_; // 释放指向的UIElement对象(堆上)
    }
}

这一步会触发「子 UIElement 对象的析构」(递归执行上述所有步骤)。

释放 UIElement 对象本身的内存

所有成员变量销毁后,编译器会通知操作系统:「这块堆内存(UIElement 对象)可以回收了」,最终释放整个对象占用的内存。

posted @ 2026-03-12 21:00  wenyiGamecpp  阅读(16)  评论(0)    收藏  举报