ECS框架-输入管理信号系统

概述

之前游戏我们调用handleInput,这使得程序形成了每帧都会走一遍的轮询模式,这种方式不是我们想要的。现在我们可以修改我们的InputManager,把输入模块升级为“信号驱动”,由于我们的输入是抽象动作和按键的映射,所以对于某个动作,可以有四种状态(PRESSED、HOLD、RELEASE、INACTIVE)。

接下来看看如何实现,总体分为:

  • InputManager::update()负责检测状态变化
  • 如果某个抽象动作切换了状态从INACTIVE进入PRESSED/HOLD/RELEASED状态,就通过EnTT的sigh/sink发布信号
  • 需要响应输入就去进行订阅,就不必每轮的handleInput

比较久了,总体代码也再回顾一下

从映射开始吧!

首先,我们需要将配置文件中的输入映射转达到输入管理器。我们有两个表,一个是抽象动作与其对应的状态映射表,一个是按键输入到抽象动作的映射表

/// @brief 存储每个动作的当前状态
std::unordered_map<std::string, ActionState> action_states_;

// 按键到动作的映射, 鼠标按钮到动作的映射 --- 输入到动作的映射
std::unordered_map<std::variant<SDL_Scancode, Uint32>, std::vector<std::string>> input_to_actions_; 

在创建输入管理器的时候需要完成配置文件的读取,当然这里需要了解一下SDL底层的一些按键转换,忘了就忘了,可以翻手册查阅

InputManager::InputManager(SDL_Renderer *sdl_renderer, const engine::core::Config *config)
    : sdl_renderer_(sdl_renderer)
    {
        if(!sdl_renderer_){
            spdlog::error("输入管理器:SDL_Renderer为空指针");
            throw std::runtime_error("输入管理器:SDL_Renderer为空指针");
        }
        initializeMappings(config);
        // 获取鼠标初始位置
        float x,y;
        SDL_GetMouseState(&x,&y);
        mouse_position_ = {x,y};
        spdlog::trace("输入管理器初始化完成,鼠标位置:({},{})", mouse_position_.x, mouse_position_.y);
    }

// --- 初始化输入映射 ---
void InputManager::initializeMappings(const engine::core::Config *config)
{
    if(!config){
        spdlog::error("输入管理器:配置为空指针");
        throw std::runtime_error("输入管理器:配置为空指针");
    }
    // 将config中的输入映射关系存储到map中
    auto actions_to_keyname_map_ = config->input_mappings_;
    // 先清空映射表
    input_to_actions_.clear();
    action_states_.clear();

    // 由于配置文件中没有鼠标按钮动作,需要定义默认映射
    if(actions_to_keyname_map_.find("MouseLeftClick") == actions_to_keyname_map_.end()){
        actions_to_keyname_map_["mouse_left"] = {"MouseLeft"};
    }
    if(actions_to_keyname_map_.find("MouseRightClick") == actions_to_keyname_map_.end()){
        actions_to_keyname_map_["mouse_right"] = {"MouseRight"};
    }

    // 遍历 动作 -> 按键 映射表
    for(const auto& [action,keynames] : actions_to_keyname_map_){
        // 添加 动作状态
        action_states_[action] = ActionState::INAVTIVE;

        // 遍历 按键名  把具体的按键映射到动作上 
        for(const auto& keyname : keynames){
            // 添加 按键 -> 动作 映射
            SDL_Scancode scancode = scancodeFromString(keyname);
            Uint32 mouse_button = mouseButtonFromString(keyname);

            if(scancode != SDL_SCANCODE_UNKNOWN){
                input_to_actions_[scancode].push_back(action);
            } else if(mouse_button != 0){
                input_to_actions_[mouse_button].push_back(action);
            } else {
                spdlog::warn("输入管理器:无效的按键名:{}",keyname);
            }
        }
    }
    spdlog::trace("输入映射初始化完成");   
}

SDL_Scancode InputManager::scancodeFromString(const std::string &key_name)
{
    return SDL_GetScancodeFromName(key_name.c_str());
}

Uint32 InputManager::mouseButtonFromString(const std::string &button_name)
{
    if(button_name == "MouseLeft") return SDL_BUTTON_LEFT;
    if(button_name == "MouseMiddle") return SDL_BUTTON_MIDDLE;
    if(button_name == "MouseRight") return SDL_BUTTON_RIGHT;
    return 0;
}

创建完映射后,接下来就可以开始为特殊的动作进行信号搭建了。

// 一个动作有3个状态, 按下, 持续按下, 释放;
std::unordered_map<std::string, std::array<entt::sigh<void()>,3>> actions_to_func_; 
// 注册动作回调
entt::sink<entt::sigh<void()>> onAction(std::string_view action_name, ActionState action_state = ActionState::PRESSED);

为每个动作添加三个信号,采用懒加载的方式,注册动作回调

entt::sink<entt::sigh<void()>> InputManager::onAction(std::string_view action_name, ActionState action_state)
{
    return actions_to_func_[std::string(action_name)].at(static_cast<int>(action_state));
}

这里有个隐式转换,该函数只是提供连接的接口,不可发布。接下来看看如何进行连接,调用该函数接口,默认是pressed按下触发回调,没啥特别的,记住这个连接语法即可。

void GameScene::init()
{
    // 添加输入管理器回调函数
    context_.getInputManager().onAction("attack")
        .connect<&GameScene::attack>(this);

    context_.getInputManager().onAction("jump")
        .connect<&GameScene::jump>(this);

    Scene::init();
}

void GameScene::attack()
{
    spdlog::info("Attack!");
}
void GameScene::jump()
{
    spdlog::info("Jump!");
}

接下来就是关键的部分了,理解 InputManager::update() 的三段式流程:状态推进 → 事件处理 → 发布回调

// --- 更新和事件处理 ---
void InputManager::update()
{
    // 1.根据上一帧更新默认的动作状态  
    for(auto& [action, state] : action_states_){
        if(state == ActionState::PRESSED){
            state = ActionState::HOLD; 
        } else if(state == ActionState::RELEASED){
            state = ActionState::INAVTIVE;
        }
    }
    // 2.根据SDL_Event更新动作状态  这里需要注意如果某个键按下不动时,并不会生成SDL_Event
    SDL_Event event;
    while(SDL_PollEvent(&event)){
        processEvent(event);
    }

    // 3.触发回调
    for(auto& [action_name_id, state] : action_states_){
        if(state != ActionState::INAVTIVE){
            // 且有绑定回调函数
            if(auto it = actions_to_func_.find(action_name_id); it != actions_to_func_.end()){
                it->second.at(static_cast<int>(state)).publish();
            }
        }
    }

}

void InputManager::processEvent(const SDL_Event &e)
{
    switch(e.type){
        case SDL_EVENT_KEY_DOWN:
        case SDL_EVENT_KEY_UP:{
            SDL_Scancode scancode = e.key.scancode;
            bool is_down = e.key.down;
            bool is_repeat = e.key.repeat;

            auto it = input_to_actions_.find(scancode);
            if(it != input_to_actions_.end()){ // 找到对应的按键响应
                const std::vector<std::string>& actions = it->second;
                for(const std::string& action : actions){
                    updateActionState(action,is_down,is_repeat);
                }
            }
            break;
        }
        case SDL_EVENT_MOUSE_BUTTON_DOWN:
        case SDL_EVENT_MOUSE_BUTTON_UP:{
            Uint8 mouse_button = e.button.button;
            bool is_down = e.button.down;

            auto it = input_to_actions_.find(mouse_button);
            if(it != input_to_actions_.end()){
                const std::vector<std::string>& actions = it->second;
                for(const std::string& action : actions){
                    updateActionState(action,is_down,false);
                }
            }
            // 更新鼠标位置
            mouse_position_ = {e.button.x,e.button.y};
            break;
        }
        case SDL_EVENT_MOUSE_MOTION:{
            mouse_position_ = {e.motion.x,e.motion.y};
            break;
        }
        case SDL_EVENT_QUIT:{
            spdlog::trace("输入管理器:收到退出事件");
            should_quit_ = true;
            break;
        }
        default:
            break;
    }
}

// --- 工具函数 ---
void InputManager::updateActionState(const std::string &action_name, bool is_input_active, bool is_repeat_event)
{
    auto it = action_states_.find(action_name);
    if(it == action_states_.end()){
        spdlog::warn("输入管理器:无效的动作名:{}",action_name);
        return;
    }
    if(is_input_active){
        if(is_repeat_event){ // 重复就返回,防止操作系统重复触发KEYDOWN事件
            return;
        }
        if(it->second == ActionState::INAVTIVE){ // 按键按下判断
            it->second = ActionState::PRESSED;
        }
    }
    else {
        if(it->second == ActionState::HOLD || it->second == ActionState::PRESSED){
            it->second = ActionState::RELEASED;
        }
    }
}

还是容易理解的,

  • 状态推进,我们可以根据上一帧的动作状态得到当前帧的状态,

  • 事件处理,这块略有点复杂,主要就是判断按键是否按下,然后更新动作的状态。

    按下一个键的完整过程:

    1. 初始状态:"attack" = INAVTIVE
    2. 第一次按下J键:
    • is_input_active = true, is_repeat_event = false
    • 当前状态:INAVTIVE → 更新为:PRESSED
      3.按住不放(系统重复事件):
    • is_input_active = true, is_repeat_event = true
    • 直接返回,状态保持:PRESSED
      4.update() 被调用:// 推进
    • PRESSED → 自动变为:HOLD
      5.继续按住:
    • 没有新事件,状态保持:HOLD
      6.释放J键:
    • is_input_active = false
    • 当前状态:HOLD → 更新为:RELEASED
      7.update() 再次被调用:
    • RELEASED → 自动变为:INAVTIVE
      // 这里其实理论存在从pressed直接到released的,如果能在一帧内完成,那你就是真的牛逼
  • 发布回调,查看我们的动作”信号塔“,看看是否绑定了监听者,进行发布

这里我们依然保留了isActionPressed/Down/Released,必要时可以直接调用。

两种用法同时保留:轮询 & 订阅

这节的实现并没有把“轮询接口”删掉,而是把它变成可选项

  • 若你需要“某动作是否持续按下”这类状态查询:继续用 isActionDown(...)
  • 若你只关心“按下/抬起”这一瞬间:用 onAction(...).connect(...) 更直接

场景中订阅/解除订阅

本节用 GameScene 做了一个最小演示:把 attack 的按下、jump 的抬起分别绑定到两个成员函数。

复制// src/game/scene/game_scene.cpp
void GameScene::init() {
    auto& input_manager = context_.getInputManager();
    input_manager.onAction("attack").connect<&GameScene::onAttack>(this);
    input_manager.onAction("jump", engine::input::ActionState::RELEASED).connect<&GameScene::onJump>(this);
}

void GameScene::clean() {
    auto& input_manager = context_.getInputManager();
    input_manager.onAction("attack").disconnect<&GameScene::onAttack>(this);
    input_manager.onAction("jump", engine::input::ActionState::RELEASED).disconnect<&GameScene::onJump>(this);
}

这里的约定非常重要:谁 connect,谁 disconnect。否则场景退出后回调还在,会导致“访问已销毁对象”的风险。

posted @ 2026-03-18 20:52  wenyiGamecpp  阅读(64)  评论(0)    收藏  举报