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;
}
}
}
还是容易理解的,
-
状态推进,我们可以根据上一帧的动作状态得到当前帧的状态,
-
事件处理,这块略有点复杂,主要就是判断按键是否按下,然后更新动作的状态。
按下一个键的完整过程:
- 初始状态:"attack" = INAVTIVE
- 第一次按下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。否则场景退出后回调还在,会导致“访问已销毁对象”的风险。

浙公网安备 33010602011771号