C++游戏开发之旅 30
问题概述
接下来我们要完成按钮功能,可以响应用户的输入。
解决方案:状态模式与交互式UI
- 使用状态模式管理UI交互
- 创建UIInteractive基类
- 实现UI状态机
- 构建可复用的UIButton
第一部分:状态模式

按钮的行为:
- Normal(正常) --- 鼠标不悬停的时候,显示默认图片
- Hover(悬停)--- 鼠标悬停的时候,图片高亮,播放提示音
- Pressed(按下)--- 按下鼠标左键,图片播放压下效果,播放点击音
- Clicked(点击)--- 松开鼠标且在区域内,执行按钮功能
如果采用传统的if-else来管理这些状态和转换,代码会变得混乱且难以维护。
传统方法:
handleInput(){
if(鼠标移入){
if(当前精灵状态){
播放音效
切换精灵
状态转换
}
if(鼠标按下){
...
}
}
}
状态模式:
handleInput(){
state->handleinput(this)
}
NormalState::hanleInput(){
if(移入){
return 悬停状态
}
}
HoverState::hanleInput(){
if(鼠标按下){
return 按下状态
}
}
状态模式的思想:
对象的行为取决于状态 --- 状态改变 ---> 行为改变
每个状态独立处理逻辑!
我们需要为UI元素创建一个状态机,其中每一个状态都是一个独立对象,负责处理在该状态下的输入和逻辑。
第二部分:UIInteractive
我们创建了一个交互基类UIInteractive,继承自UIElement,并添加交互所需的功能。
UI元素分类:
UIElement 基类
_____|_________
静态UI 交互UI
Panel Interactive
Image |
Label Button
Slider
....

1.UIInteractive
src/engine/ui/ui_interactive.h
#pragma once
#include "ui_element.h"
#include <memory>
#include <string>
#include <unordered_map>
#include "../render/sprite.h"
namespace engine::ui::state{
class UIState;
}
namespace engine::ui{
class UIInteractive : public UIElement{
private:
engine::core::Context& context_;
std::unique_ptr<ui::state::UIState> current_state_ = nullptr;
// 存储精灵
std::unordered_map<std::string, engine::render::Sprite> sprites_;
// 存储声音 key为音效名称,value为音效文件路径
std::unordered_map<std::string, std::string> sounds_;
engine::render::Sprite* current_sprite_ = nullptr; // 缓存当前的精灵
bool is_active_ = true;
public:
UIInteractive(engine::core::Context& context,
const glm::vec2& position = {0,0},
const glm::vec2& size = {0,0}
);
~UIInteractive() override = default;
virtual void clicked(){};
void addSprite(const std::string& name, const render::Sprite& sprite);
void setSprite(const std::string& name); // 通过名称从sprites_中设置当前精灵
void addSound(const std::string& name, const std::string& path);
void playSound(const std::string& name);
// 设置当前状态
void setCurrentState(std::unique_ptr<ui::state::UIState> state);
ui::state::UIState* getCurrentState() const {return current_state_.get();}
bool isActive() const {return is_active_;}
void setActive(bool active) {is_active_ = active;}
bool handleInput(engine::core::Context& context) override;
void render(engine::core::Context& context) override;
};
}
UI交互类,我们定义了这么一个类,这个类是为按钮ui设计的,可以看到我们预先存储了相关状态的精灵,以及播放的音乐哈希映射,使用状态模式,有一个当前状态,把关键的render、handleInput函数委托给状态对应的函数。
相关说明
- clicked() : 点击回调,
virtual void clicked(){},这个函数用于点击事件,由状态对象调用 - current_sprite_ 缓存当前的状态精灵,通过函数从哈希映射中拿取
- current_state_ 当前的状态,
std::unique_ptr<engine::ui::state::UIState> state_,指向当前状态对象的智能指针,所有相关的输入都会委托给当前的状态对象
src/engine/ui/ui_interactive.cpp
#include "ui_interactive.h"
#include "../core/context.h"
#include "../render/renderer.h"
#include "../resource/resource_manager.h"
#include "../audio/audio_player.h"
#include "state/ui_state.h"
#include <spdlog/spdlog.h>
namespace engine::ui {
UIInteractive::UIInteractive(engine::core::Context &context, const glm::vec2 &position, const glm::vec2 &size)
: UIElement(position, size), context_(context){}
void UIInteractive::setCurrentState(std::unique_ptr<ui::state::UIState> state)
{
if(!state){
spdlog::debug("尝试设置空状态");
return;
}
current_state_ = std::move(state);
current_state_->enter();
}
void UIInteractive::addSprite(const std::string &name, const render::Sprite &sprite)
{
// 这里是设置UI元素作用大小的, 我们必须要有一个size_与之交互
if(size_.x == 0.0f || size_.y == 0.0f){
size_ = context_.getResourceManager().getTextureSize(sprite.getTextureId());
}
sprites_[name] = sprite;
}
void UIInteractive::setSprite(const std::string &name)
{
if(sprites_.find(name) == sprites_.end()){
spdlog::debug("尝试设置不存在的精灵: {}", name);
return;
}
current_sprite_ = &sprites_[name];
}
void UIInteractive::addSound(const std::string &name, const std::string &path)
{
sounds_[name] = path;
}
void UIInteractive::playSound(const std::string &name)
{
if(sounds_.find(name) == sounds_.end()){
spdlog::debug("尝试播放不存在的声音: {}", name);
return;
}
context_.getAudioPlayer().playSound(sounds_[name]);
}
bool UIInteractive::handleInput(engine::core::Context &context)
{
// 事件处理先处理子类
if(UIElement::handleInput(context)) return true;
if(current_state_ && is_active_){
auto new_state = current_state_->handleInput(context);
// 如果状态改变, 则设置新的状态
if(new_state){
setCurrentState(std::move(new_state));
return true;
}
}
return false;
}
void UIInteractive::render(engine::core::Context &context)
{
if(!visible_) return;
context_.getRenderer().drawUISprite(*current_sprite_, getScreenPosition(), size_);
UIElement::render(context);
}
}
第三部分:状态机实现
现在我们需要定义按钮的三种状态了,一个UIState基类,和具体的状态类
2.UIState
src/engine/ui/state/ui_state.h
#pragma once
#include <memory>
namespace engine::core{
class Context;
}
namespace engine::ui{
class UIInteractive;
}
namespace engine::ui::state{
class UIState {
friend class UIInteractive;
protected:
ui::UIInteractive* owner_ = nullptr;
public:
UIState(ui::UIInteractive* parent) : owner_(parent) {};
virtual ~UIState() = default;
// 禁止拷贝移动
UIState(const UIState&) = delete;
UIState& operator=(const UIState&) = delete;
UIState(UIState&&) = delete;
UIState& operator=(UIState&&) = delete;
protected:
virtual void enter(){};
virtual std::unique_ptr<UIState> handleInput(engine::core::Context& ) = 0;
};
}
和之前的玩家PlayerState很类似,也是需要一个owner_,用于指向所属的UI元素。两个重要函数enter、handleInput,enter进入状态的时候调用一次,我们可以用于切换交互UI当前的状态精灵,或是播放声音,handleInput就是处理状态切换相关的逻辑了,可以看到和之前玩家状态处理类似,是有一个返回值的,返回状态,这个可以用于是否切换状态。
3.三个具体状态
难度并不大,这里直接给出
// ui_normal_state.h
#pragma once
#include "ui_state.h"
namespace engine::ui::state{
class UINormalState final : public UIState{
friend class ui::UIInteractive;
public:
UINormalState(ui::UIInteractive* parent);
~UINormalState() override = default;
private:
void enter() override;
std::unique_ptr<UIState> handleInput(engine::core::Context& context) override;
};
}
// ui_normal_state.cpp
#include "ui_normal_state.h"
#include "../ui_interactive.h"
#include "ui_hover_state.h"
#include "../../core/context.h"
#include "../../input/input_manager.h"
namespace engine::ui::state{
UINormalState::UINormalState(ui::UIInteractive *parent)
: UIState(parent){}
void UINormalState::enter()
{
owner_->setSprite("normal");
}
std::unique_ptr<UIState> UINormalState::handleInput(engine::core::Context& context)
{
auto inputmanager = context.getInputManager();
// 如果鼠标移入了,则进入hover状态
if(owner_->isPointInside(inputmanager.getLogicalMousePosition())){
owner_->playSound("hover");
return std::make_unique<UIHoverState>(owner_);
}
return nullptr;
}
} // namespace engine::ui
// ui_hover_state.h
#pragma once
#include "ui_state.h"
namespace engine::ui::state {
class UIHoverState : public UIState{
public:
UIHoverState(ui::UIInteractive* parent);
~UIHoverState() override = default;
void enter() override;
std::unique_ptr<UIState> handleInput(engine::core::Context& context) override;
};
}
// ui_hover_state.cpp
#include "ui_hover_state.h"
#include "../ui_interactive.h"
#include "ui_normal_state.h"
#include "ui_pressed_state.h"
#include "../../core/context.h"
#include "../../input/input_manager.h"
namespace engine::ui::state{
UIHoverState::UIHoverState(ui::UIInteractive *parent)
: UIState(parent){}
void UIHoverState::enter()
{
owner_->setSprite("hover");
}
std::unique_ptr<UIState> UIHoverState::handleInput(engine::core::Context& context)
{
const auto& input_manager = context.getInputManager();
// 移开则进入normal状态
if(!owner_->isPointInside(input_manager.getLogicalMousePosition())){
return std::make_unique<ui::state::UINormalState>(owner_);
}
// 按下则进入press状态
if(input_manager.isActionPressed("MouseLeftClick")){
return std::make_unique<ui::state::UIPressState>(owner_);
}
return nullptr;
}
}
// ui_pressed_state.h
#pragma once
#include "ui_state.h"
namespace engine::ui::state{
class UIPressState : public UIState{
public:
UIPressState(ui::UIInteractive* parent);
~UIPressState() override = default;
void enter() override;
std::unique_ptr<UIState> handleInput(engine::core::Context& context) override;
};
}
// ui_pressed_state.cpp
#include "ui_pressed_state.h"
#include "ui_normal_state.h"
#include "ui_hover_state.h"
#include "../ui_interactive.h"
#include "../../core/context.h"
#include "../../input/input_manager.h"
namespace engine::ui::state {
UIPressState::UIPressState(ui::UIInteractive *parent)
: UIState(parent){}
void UIPressState::enter()
{
owner_->setSprite("pressed");
owner_->playSound("pressed"); // 播放按下音效
}
std::unique_ptr<UIState> UIPressState::handleInput(engine::core::Context& context)
{
const auto& input_manager = context.getInputManager();
// 鼠标在范围内且释放了鼠标左键
if(input_manager.isActionReleased("MouseLeftClick")){
// 鼠标在范围内
if(owner_->isPointInside(input_manager.getLogicalMousePosition())){
// 按下后释放,则触发点击事件
owner_->clicked();
return std::make_unique<ui::state::UIHoverState>(owner_);
} else {
return std::make_unique<ui::state::UINormalState>(owner_);
}
}
return nullptr;
}
}
关键就是这张状态切换表了

我们的回调函数就是由Pressed状态中满足鼠标左键在release的时候调用。
第四部分:UIButton
现在来做个按钮UI,继承自UIInteractive,有一个回调函数对象,然后重写UIInteractive的clicked()方法。
src/engine/ui/ui_button.h
#pragma once
#include "ui_interactive.h"
#include <functional> // for std::function
namespace engine::ui{
class UIButton : public UIInteractive{
std::function<void()> callback_; // 函数对象,用于存储按钮点击后的回调函数
public:
/**
* @brief 构造函数
* @note 需要完成以下事情
* 需要往交互类中完成3种状态精灵的初始化、声音的初始化
*/
UIButton(engine::core::Context& context,
const std::string& normal_sprite_id,
const std::string& hover_sprite_id,
const std::string& pressed_sprite_id,
const std::string& sound_hover_id,
const std::string& sound_pressed_id,
const glm::vec2& position,
const glm::vec2& size,
std::function<void()>&& callback
);
~UIButton() = default;
void clicked() override;
void setCallback(std::function<void()>&& callback) { callback_ = std::move(callback); }
std::function<void()> getCallback() const { return callback_; }
};
}
std::function
std::function<void()> callback_;
callback_是一个std::function<void()>对象,可存储任何“无参数,无返回值”的可调用对象
┌──────────────────────┐
│ 1. 普通函数 │
│ void func() {} │
├──────────────────────┤
│ 2. 静态函数 │
│ static void f() {}│
├──────────────────────┤
│ 3. Lambda表达式 ✨ │
│ []() { ... } │
├──────────────────────┤
│ 4. 成员函数绑定 │
│ bind(&Class::f) │
├──────────────────────┤
│ 5. 函数对象 │
│ struct F { │
│ void operator() │
│ }; │
└──────────────────────┘
这可以使得按钮点击行为可以由创建它的地方完全自定义。
src/engine/ui/ui_button.cpp
#include "ui_button.h"
#include "state/ui_normal_state.h"
namespace engine::ui
{
UIButton::UIButton(
engine::core::Context &context,
const std::string &normal_sprite_id,
const std::string &hover_sprite_id,
const std::string &pressed_sprite_id,
const std::string& sound_hover_id,
const std::string& sound_pressed_id,
const glm::vec2 &position,
const glm::vec2 &size,
std::function<void()>&& callback
) : UIInteractive(context, position, size), callback_(std::move(callback)){
// Add sprites
addSprite("normal", engine::render::Sprite(normal_sprite_id));
addSprite("hover", engine::render::Sprite(hover_sprite_id));
addSprite("pressed", engine::render::Sprite(pressed_sprite_id));
// 声音添加
addSound("hover", sound_hover_id);
addSound("pressed", sound_pressed_id);
// 初始化state
setCurrentState(std::make_unique<engine::ui::state::UINormalState>(this));
}
void UIButton::clicked()
{
if(callback_) {
callback_();
}
}
}
按钮在构造的时候需要完成状态精灵和声音映射表的载入,以及设置一个初始状态,然后重写之前的clicked()方法。
第五部分:测试
我们在游戏场景GameScene中测试一下吧
// src/game/scene/game_scene.h
void createTestButton();
void testButtonCallback();
// src/game/scene/game_scene.cpp
void GameScene::createTestButton()
{
auto button = std::make_unique<engine::ui::UIButton>(
context_,
"assets/textures/UI/buttons/Start1.png", // normal
"assets/textures/UI/buttons/Start2.png", // hover
"assets/textures/UI/buttons/Start3.png", // pressed
"assets/audio/button_hover.wav",
"assets/audio/button_click.wav",
glm::vec2(200, 200),
glm::vec2(0),
[this]() { this->testButtonCallback(); }
);
ui_manager_->addElement(std::move(button));
}
void GameScene::testButtonCallback()
{
spdlog::info("按钮被点击了");
}
我们写了两个测试函数,然后在UI初始化的时候创建按钮
Lambda 表达式说明
这里最后传入的函数对象使用了Lambda表达式来实现,这里要注意了,其实 this->testButtonCallback(),实际上并不是说没有函数参数,内部其实是有签名的,类似void testButtonCallback(GameScene* this),关于类的成员方法要注意
复制
[this](){ this->testButtonCallback(); }
Lambda 表达式结构:
[捕获列表](参数列表){ 函数体 }
示例分析:
[this] ← 捕获this指针,可访问成员
() ← 无参数
{ this->testButtonCallback(); } ← 调用成员函数
等价于:
void callback() {
this->testButtonCallback();
}
测试并未发现问题。
总结
这节完成了交互式UI的设计,我们采用了状态模式,通过状态机来实现按钮功能,学习了回调函数的使用。
遇到的问题
1.忘记使用逻辑坐标,而使用了普通的鼠标位置,太久了都有点忘记了
与游戏对象交互时,始终使用逻辑鼠标坐标。屏幕坐标受分辨率和缩放的影响,而逻辑坐标与您的游戏世界保持一致
2.发现了一个小问题,在UIImage.cpp中render函数应该是使用getScreenPosition(),我直接使用了position_,这会导致没有加上父节点的位置偏移。
3.UIHoverState中handleInput发生错误,起初还奇怪为什么点击没有反应,后面发现输入映射的名称搞错了。
4.回调函数的使用
这里还是要加强一下回调函数的理解。
#include <iostream>
#include <functional>
#include <string>
// 普通函数
bool function1(int a, std::string b){
std::cout<< a << b << std::endl;
return true;
}
struct MyClass{
// 成员函数
bool memberFunc(int a, std::string b){
std::cout<< a << b << std::endl;
return true;
}
}
int main() {
// 声明一个统一的 std::function 对象
std::function<bool(int,std::string)> func;
// 1.存普通函数
func = function1;
func(1,"你好");
// 2.存Lambda 表达式(匿名函数)
func = [](int a, std::string b){
std::cout<< a << b << std::endl;
return true;
}
func(2,"好你");
// 3.存成员函数(需要绑定对象)
MyClass my_obj;
func = [&my_obj](int a, std::string b){return my_obj.memberFunc(a,b);};
func(3,"问题是一定要解决的");
}

浙公网安备 33010602011771号