C++游戏开发之旅 5

问题背景

现在我们已经可以渲染出画面了,截止目前,我们有Config类用于灵活的设置游戏参数解决硬编码带来的不便性,Time类控制游戏的帧率,ResourceManager资源管理类作为中转,让其中三个小弟TextureManager、AudioManager、FontManager分别管理资源,Camera类充当相机(画布)显示,Renderer类用作画笔渲染图像,接下来我们需要进行交互,让我们可以与这个世界进行对话。这一节完成输入管理模块InputManager

设计目标

我们在配置json文件中设置了一些按键映射:

image-20260119210758638

image-20260119211448966

我们需要建立一个系统,可以:

  • 抽象化输入:游戏逻辑不应该关心物理层面的按键(比如W键或是上箭头),而是关心抽象动作(向上移动)。我们需要把具体的物理按键(键盘、鼠标、手柄)映射到抽象动作上
  • 易于配置:所有的按键映射都将定义在我们的config.json文件中,玩家可以很容易的修改键位,无需改动代码
  • 精确的状态管理:区分"刚刚按下"、"持续按住"、"刚刚释放"这三种状态对于实现某些游戏机制(如跳跃、蓄力、攻击)至关重要。

第一步:构建功能完备的InputManager

1.架构设计

InputManager将是所有输入的唯一入口,需要完成的工作流程为:

阶段 说明
初始化 构造时,需要从Config模块读取input_mappings,然后两个建立映射表:一个是从SDL_Scancode(键盘按键)到动作列表,一个是从Uint32(鼠标按钮)到动作列表
每帧更新 在游戏主循环的最开始被调用,处理SDL事件队列中的事件(SDL_EVENT_KEY_DOWN),并据此更新内部所有动作的状态
状态查询 提供isActionDown()isActionPressed()isActionReleased()等接口,供游戏逻辑的其他部分查询特定动作的当前状态

2.动作状态机 ActionState

为了精确捕捉按键的每一个瞬间,定义了一个枚举类ActionState:

enum class ActionState{
	INACTIVE,
	PRESSED_THIS_FRAME,
	HELD_DOWN,
	RELEASED_THIS_FRAME
}
状态转换说明

InputManager::update()方法会负责在这个状态机之间进行正确的转换,前一帧是PRESSED_THIS_FRAME的动作状态,如果本帧没有收到"释放"事件,它的状态就会自动变为HELD_DOWN

类似的就有:

image-20260120162727785


3.实现InputManager

src/engine下创建input目录,并添加input_manager.hinput_manager.cpp

input_manager.h
#pragma once
#include <unordered_map>
#include <SDL3/SDL_render.h>
#include <string>
#include <vector>
#include <glm/vec2.hpp>

namespace engine::core {
class Config;    
}

namespace engine::input {

enum class ActionState{
    INAVTIVE,  // 动作未激活
    PRESSED_THIS_FRAME, // 动作本帧被按下
    HOLD_DOWN, // 动作被持续按下
    RELEASED_THIS_FRAME // 动作本帧被释放
};

class InputManager final {
private:
    SDL_Renderer* sdl_renderer_; // SDL渲染器
    // 抽象动作对于具体按键的映射关系
    std::unordered_map<std::string, std::vector<std::string>> actions_to_keyname_map_; // 从Config配置文件中读取的输入映射关系
    // 需要两个映射列表, 按键到动作的映射, 鼠标按钮到动作的映射
    std::unordered_map<SDL_Scancode, std::vector<std::string>> scancode_to_actions_map_; // 按键到动作的映射
    std::unordered_map<Uint32, std::vector<std::string>> mouse_button_to_actions_map_; // 鼠标按钮到动作的映射
    
    std::unordered_map<std::string, ActionState> action_states_map_; // 动作状态映射

    bool should_quit_ = false; // 退出标志
    glm::vec2 mouse_position_; // 鼠标位置

public:
    /**
     * @brief 构造函数
     * @params sdl_renderer SDL渲染器 获得logicalposition
     * @params config 配置文件 读取输入映射关系
     * throws std::runtime_error 如果有空
     */
    InputManager(SDL_Renderer* sdl_renderer, const engine::core::Config* config);
    
    void update(); // 更新输入状态,循环开始先调用

    // 动作状态查询
    bool isActionDown(const std::string& action_name) const; 
    bool isActionPressed(const std::string& action_name) const;
    bool isActionReleased(const std::string& action_name) const;

    bool shouldQuit() const; // 检查是否需要退出
    void setShouldQuit(bool should_quit); // 设置退出标志

    glm::vec2 getMousePosition() const; // 获取鼠标位置 --- 屏幕坐标
    glm::vec2 getLogicalMousePosition() const; // 获取鼠标逻辑位置 --- 逻辑坐标

private:
    void processEvent(const SDL_Event& e); // 处理事件
    void initializeMappings(const engine::core::Config* config); // 初始化映射关系

    void updateActionState(const std::string& action_name, bool is_input_active, bool is_repeat_event); // 更新动作状态
    SDL_Scancode scancodeFromString(const std::string& key_name);
    Uint32 mouseButtonFromString(const std::string& button_name);
};
}

定义了InputManager的接口,包括状态查询和私有的映射表。

input_manager.cpp
#include "input_manager.h"
#include "../core/config.h"
#include <spdlog/spdlog.h>
#include <stdexcept>    

namespace engine::input {
    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::update()
    {
        // 1.根据上一帧更新默认的动作状态  
        for(auto& [action, state] : action_states_map_){
            if(state == ActionState::PRESSED_THIS_FRAME){
                state = ActionState::HOLD_DOWN; 
            } else if(state == ActionState::RELEASED_THIS_FRAME){
                state = ActionState::INAVTIVE;
            }
        }
        // 2.根据SDL_Event更新动作状态  这里需要注意如果某个键按下不动时,并不会生成SDL_Event
        SDL_Event event;
        while(SDL_PollEvent(&event)){
            processEvent(event);
        }

    }


    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 = scancode_to_actions_map_.find(scancode);
                if(it != scancode_to_actions_map_.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 = mouse_button_to_actions_map_.find(mouse_button);
                if(it != mouse_button_to_actions_map_.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;
        }
    }

    // --- 状态查询方法 ---
    bool InputManager::isActionDown(const std::string &action_name) const
    {
        auto it = action_states_map_.find(action_name);
        if(it == action_states_map_.end()){
            spdlog::warn("输入管理器:无效的动作名:{}",action_name);
            return false;
        }
        ActionState state = it->second;
        if(state == ActionState::PRESSED_THIS_FRAME || state == ActionState::HOLD_DOWN){
            return true;
        }
        return false;
    }

    bool InputManager::isActionPressed(const std::string &action_name) const
    {
        if(auto it = action_states_map_.find(action_name); it != action_states_map_.end()){
            return it->second == ActionState::PRESSED_THIS_FRAME;
        }
        return false;
    }

    bool InputManager::isActionReleased(const std::string &action_name) const
    {
        if(auto it = action_states_map_.find(action_name); it != action_states_map_.end()){
            return it->second == ActionState::RELEASED_THIS_FRAME;
        }
        return false;
    }

    bool InputManager::shouldQuit() const
    {
        return should_quit_;
    }

    void InputManager::setShouldQuit(bool should_quit)
    {
        should_quit_ = should_quit;
    }

    glm::vec2 InputManager::getMousePosition() const
    {
        return mouse_position_;
    }

    glm::vec2 InputManager::getLogicalMousePosition() const
    {
        glm::vec2 logical_pos;
        // TODO:实现鼠标逻辑坐标转换
        SDL_RenderCoordinatesFromWindow(sdl_renderer_, mouse_position_.x, mouse_position_.y, &logical_pos.x, &logical_pos.y);
        return logical_pos;
    }


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

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

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

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

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

    // --- 工具函数 ---
    void InputManager::updateActionState(const std::string &action_name, bool is_input_active, bool is_repeat_event)
    {
        auto it = action_states_map_.find(action_name);
        if(it == action_states_map_.end()){
            spdlog::warn("输入管理器:无效的动作名:{}",action_name);
            return;
        }
        if(is_input_active){
            if(is_repeat_event){
                return;
            }
            if(it->second == ActionState::INAVTIVE){
                it->second = ActionState::PRESSED_THIS_FRAME;
            }
        }
        else {
            if(it->second == ActionState::HOLD_DOWN || it->second == ActionState::PRESSED_THIS_FRAME){
                it->second = ActionState::RELEASED_THIS_FRAME;
            }
        }
    }

    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;
    }
}
关键功能
  • initializeMappings() - 初始化映射表,我们需要从config配置文件中读取到输入映射将其保存下来,然后分别将按键对应抽象动作({SDL_Scancode,std::string}),鼠标按钮对应抽象动作保存下来({Uint32,std::string})。之后需要将抽象动作action_states_map和其动作状态进行设置。类似{"A(SDL中Scancode类型)":["move_left"],...};

  • update() - 更新状态这部分采用状态判断,可以根据上一帧来更新当前的帧的状态,比如上一帧是动作状态是PRESSED_THIS_FRAME那么接下来就是HOLD_DOWN,如果是RELEASED_THIS_FRAME就自然过渡到INACTiVE。然后进行事件队列判断,分别对按键和鼠标按钮的DOWN和UP进行状态更新。

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 = scancode_to_actions_map_.find(scancode);
        if(it != scancode_to_actions_map_.end()){ // 找到对应的按键响应
            const std::vector<std::string>& actions = it->second;
            for(const std::string& action : actions){ // 遍历对应的抽象动作更新状态
                updateActionState(action,is_down,is_repeat);
            }
        }
        break;
    }

我们应该注意的是如果按键按住的话,并不会生成SDL_Event,但是一直按住的话,可能会触发操作系统周期性的KEY_DOWN,这个时候就又进入到事件判断了,repeat就为true了。在更新的时候需要考虑这一点

  • 由于使用了逻辑分辨率,鼠标的物理窗口坐标需要通过SDL_RenderCoordinatesFromWindow转换成游戏世界中的逻辑坐标,这对于UI交互很重要

4.集成到GameApp

image-20260120203408530

初始化
//game_app.h
class InputManager;
class GameApp final {
private:
	std::unique_ptr<engine::input::InputManager> input_manager_;
    [[nodiscard]] bool initInputManager();
    void testInputManager();
};

// game_app.cpp

bool GameApp::initInputManager()
{
    try {
        input_manager_ = std::make_unique<engine::input::InputManager>(sdl_renderer_, config_.get());
    } catch (const std::exception& e){
        spdlog::error("初始化InputManager失败: {}", e.what());
        return false;
    }
    return true;
}
bool GameApp::init()
{
    if(!initConfig()) return false;
    if(!initSDL()) return false;
    if(!initTime()) return false;
    if(!initResourceManager()) return false;
    if(!initCamera()) return false;
    if(!initRenderer()) return false;
    if(!initInputManager()) return false;

    testResource();

    spdlog::info("GameApp初始化成功");
    is_running_ = true;
    return true;
}

void GameApp::handleEvents()
{
    if(input_manager_->getShouldQuit())
    {
        spdlog::trace("收到退出请求");
        is_running_ = false;
        return;
    }
    testInputManager();
}

// 更新部分
void GameApp::run()
{
    if(!init()){
        spdlog::error("GameApp初始化失败,无法进行游戏");
        return;
    }

    // 游戏循环
    while(is_running_){
        time_->update();
        float delta_time = time_->getDeltaTime();
        input_manager_->update(); // 更新输入管理器

        handleEvents();
        update(delta_time);
        render();

        //pdlog::info("delta_time: {}", delta_time);
    }
    close();
}

void GameApp::testInputManager()
{
    std::vector<std::string> actions = {
        "move_up",
        "move_down",
        "move_left",
        "move_right",
        "jump",
        "attack",
        "pause",
        "MouseLeftClick",
        "MouseRightClick",
    };

    for(auto& action : actions){
        if(input_manager_->isActionPressed(action)){
            spdlog::info("按下: {}", action);
        }
        if(input_manager_->isActionReleased(action)){
            spdlog::info("释放: {}", action);
        }
        if(input_manager_->isActionDown(action)){
            spdlog::info("持续: {}", action);
        }
    }
}

GameApp中添加initInputManager(),创建InputManager的实例。GameApp::run()的主循环中handleEvents()时需要先进行input_manager_->update()更新管理器。

事件处理方面,handleEvents()中判断是否退出,现在不需要进行SDL_PollEvent循环,而是直接向InputManager查询。

编译与运行

可以清楚的看到对应动作(按下,持续,释放)。

image-20260120210926645

第二步:使用std::variant进行重构

之前我们是采用分别对于按键和鼠标两个不同类型对应抽象动作,这样就有两个独立的映射表处理按键和鼠标输入。

// 需要两个映射列表, 按键到动作的映射, 鼠标按钮到动作的映射
    std::unordered_map<SDL_Scancode, std::vector<std::string>> scancode_to_actions_map_; // 按键到动作的映射
    std::unordered_map<Uint32, std::vector<std::string>> mouse_button_to_actions_map_; // 鼠标按钮到动作的映射

有没有这样的数据类型,可以存多种不同类型的值,来创建一个统一的映射表?

我们可以使用std::variant来实现

#include <variant>
namespace InputManager final {
private:
    std::unordered_map<std::variant<SDL_Scancode,Uint32>, std::vector<std::string>> input_to_actions_map_;
}
关键改动
  • 使用std::variant<SDL_Scancode,Uint32>定义统一的键类型
  • 将两个旧的映射表合并为一个input_to_actions_map_

之后将input_manager.cpp中的旧的映射进行修改

重构后就更灵活易于扩展了,如果需要添加新类型秩序在variant中加就可以,还统一了处理逻辑。

遇到的问题

对于输入管理类中repeat的疑问,本来以为这个变量是多余的,因为第一次按下KEY_DOWNis_down=truerepeat=false,而一直按住,有可能操作系统会触发KEY_DOWN,此时 is_down=truerepeat=true松开KEY_UPis_down=false(repeat一般也没意义)。

这个时候updateActionState()函数应该考虑这些,所以传入action、is_down、repeat,我不太希望HOLD_DOWNrepeat=true来决定,而是通过状态机持续保持,按住不一定会产生 repeat 事件(不同系统/设置/窗口焦点等都可能影响),而 HOLD_DOWN 应该是“你引擎每帧都能稳定得到的状态”。所以在更新的时候遇到repeat = truereturn

posted @ 2026-01-26 20:18  逆行的乌鸦  阅读(2)  评论(0)    收藏  举报