C++游戏开发之旅 30

问题概述

接下来我们要完成按钮功能,可以响应用户的输入。

解决方案:状态模式与交互式UI

  1. 使用状态模式管理UI交互
  2. 创建UIInteractive基类
  3. 实现UI状态机
  4. 构建可复用的UIButton

第一部分:状态模式

image-20260313183836788

按钮的行为:

  • 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
			  ....

image-20260313093229171

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;
    }
}

关键就是这张状态切换表了

image-20260313183836788

我们的回调函数就是由Pressed状态中满足鼠标左键在release的时候调用。

第四部分:UIButton

现在来做个按钮UI,继承自UIInteractive,有一个回调函数对象,然后重写UIInteractiveclicked()方法。

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,"问题是一定要解决的");
    
}
posted @ 2026-03-13 19:17  wenyiGamecpp  阅读(19)  评论(0)    收藏  举报