📜 ⬆️ ⬇️

Designing a system for reading data from input devices (Part One)

When you work on a game engine, you want to design it right away right away - so that you don’t waste time on painful refactoring. When I was developing my own engine, in search of inspiration I looked through the sources of other game engines and came to a specific implementation (you can read it at the link at the end of the article). In the article I would like to offer a solution to the problem of designing a system that reads data from input devices.

It would seem that there is something difficult: I read the data from the mouse, keyboard, joystick and called it in the right place. So it is, and most often the similarity of such code can be found in game engines:

// ,     cotrols->Update() ... void Player::Move() { if (controls->MouseButonPressed(0)) { ... } if (controls->KeyPressed(KEY_SPACE)) { ... } if (controls->JoystickButtonPressed(0)) { ... } } 

What does not suit me in this approach? First, if we want to read data from a specific device, such as a joystick, then we use methods that receive data from a specific device. Secondly, in the code we get a hardcode, i.e. right in the game code is a survey of a particular key and a specific device. This is not good, because later, in order to make the redefinition of keys through the game menu, it will be necessary to clean everything like that and make some kind of re-mapping subsystem, with the ability to redefine the binding of keys on the fly. Thus, the simplest implementation is not so good.
')
What can be offered to solve the problem?

The solution is simple: when querying input devices, use abstract names - aliases, which are written in a separate configuration file and whose names originate from the action, and not from the name of the keys to which the action is blocked, for example: "ACTION_JUMP", "ACTION_SHOOT". In order not to work with the alias names themselves, let's add a method to get the alias identifier:

 int GetAlias(const char* name); 

The survey of states itself is reduced to just two methods:

 enum AliasAction { Active, Activated }; bool GetAliasState(int alias, AliasAction action); float GetAliasValue(int alias, bool delta); 

Let me explain why we use two methods. When querying the state of keys, the Boolean value is more than enough, but when querying the state of the stick, the joystick will need to get a numeric value. Therefore, two methods have been added. In the case of a state, in the second parameter we pass the type of action. There are only two of them: Active (the alias is active, for example, the key is clamped) or Activated (the alias has switched to the active state). For example, we need to handle the key throwing grenades. This is not a permanent action, such as walking, so you need to determine the very fact that the throwing key of the grenade was pressed, and if the key is still in the pressed state, do not react to it. When polling the numeric value of the alias, we pass the second parameter to the boolean flag, which says whether we need the value itself or the difference between the current value and the value from the previous frame.

I will give an example of code implementing the camera control:

 void FreeCamera::Init() { proj.BuildProjection(45.0f * RADIAN, 600.0f / 800.0f, 1.0f, 1000.0f); angles = Vector2(0.0f, -0.5f); pos = Vector(0.0f, 6.0f, 0.0f); alias_forward = controls.GetAlias("FreeCamera.MOVE_FORWARD"); alias_strafe = controls.GetAlias("FreeCamera.MOVE_STRAFE"); alias_fast = controls.GetAlias("FreeCamera.MOVE_FAST"); alias_rotate_active = controls.GetAlias("FreeCamera.ROTATE_ACTIVE"); alias_rotate_x = controls.GetAlias("FreeCamera.ROTATE_X"); alias_rotate_y = controls.GetAlias("FreeCamera.ROTATE_Y"); alias_reset_view = controls.GetAlias("FreeCamera.RESET_VIEW"); } void FreeCamera::Update(float dt) { if (controls.GetAliasState(alias_reset_view)) { angles = Vector2(0.0f, -0.5f); pos = Vector(0.0f, 6.0f, 0.0f); } if (controls.GetAliasState(alias_rotate_active, Controls::Active)) { angles.x -= controls.GetAliasValue(alias_rotate_x, true) * 0.01f; angles.y -= controls.GetAliasValue(alias_rotate_y, true) * 0.01f; if (angles.y > HALF_PI) { angles.y = HALF_PI; } if (angles.y < -HALF_PI) { angles.y = -HALF_PI; } } float forward = controls.GetAliasValue(alias_forward, false); float strafe = controls.GetAliasValue(alias_strafe, false); float fast = controls.GetAliasValue(alias_fast, false); float speed = (3.0f + 12.0f * fast) * dt; Vector dir = Vector(cosf(angles.x), sinf(angles.y), sinf(angles.x)); pos += dir * speed * forward; Vector dir_strafe = Vector(dir.z, 0,-dir.x); pos += dir_strafe * speed * strafe; view.BuildView(pos, pos + Vector(cosf(angles.x), sinf(angles.y), sinf(angles.x)), Vector(0, 1, 0)); render.SetTransform(Render::View, view); proj.BuildProjection(45.0f * RADIAN, (float)render.GetDevice()->GetHeight() / (float)render.GetDevice()->GetWidth(), 1.0f, 1000.0f); render.SetTransform(Render::Projection, proj); } 

Please note that the alias name uses the prefix FreeCamera. This is done in order to adhere to a certain rule of naming, which allows you to understand which object the alias belongs to. If this is not done, as the further development proceeds, the number of aliases will increase, and over time, you can get a bunch of aliases that refer to each other, and all this will not be controlled, since Finding an erroneous task will be very difficult and will take a lot of time. Therefore, the introduction of the rule of naming is necessary.

Let us turn to the most interesting part - setting up the aliases themselves. They will be stored in the json file. The file that describes the aliases for the camera looks like this:

 { "Aliases" : [ { "name" : "FreeCamera.MOVE_FORWARD", "AliasesRef" : [ { "names" : ["KEY_W"], "modifier" : 1.0 }, { "names" : ["KEY_I"], "modifier" : 1.0 }, { "names" : ["KEY_S"], "modifier" : -1.0 }, { "names" : ["KEY_K"], "modifier" : -1.0 } ]}, { "name" : "FreeCamera.MOVE_STRAFE", "AliasesRef" : [ { "names" : ["KEY_A"], "modifier" : -1.0 }, { "names" : ["KEY_J"], "modifier" : -1.0 }, { "names" : ["KEY_D"], "modifier" : 1.0 }, { "names" : ["KEY_L"], "modifier" : 1.0 } ]}, { "name" : "FreeCamera.MOVE_FAST", "AliasesRef" : [ { "names" : ["KEY_LSHIFT"] } ]}, { "name" : "FreeCamera.ROTATE_ACTIVE", "AliasesRef" : [ { "names" : ["MS_BTN1"] } ]}, { "name" : "FreeCamera.ROTATE_X", "AliasesRef" : [ { "names" : ["MS_X"] } ]}, { "name" : "FreeCamera.ROTATE_Y", "AliasesRef" : [ { "names" : ["MS_Y"] } ]}, { "name" : "FreeCamera.RESET_VIEW", "AliasesRef" : [ { "names" : ["KEY_R", "KEY_LCONTROL"] } ]} ] } 

Aliases are described quite simply: we give the name an alias (the name parameter) and an array of references to the aliases (the AliasesRef parameter). For each alias reference, you can set the modificator parameter — this parameter is used as a multiplier that is applied to the value that is obtained by calling the GetAliasValue method. The MOVE_FORWARD and MOVE_STRAFE aliases use this parameter to simulate the operation of the stick of the joystick, since it is the stick of the joystick that gives the value in the range [-1..1] for each of the two axes. So that you can specify a key combination, i.e. hotkeys, the names parameter is an array of names. The RESET_VIEW Alias ​​is an example of setting the hot key combination LCTRL + R.

Consider in more detail the occurring names in the references to aliases, for example, KEY_W, MS_BTN1. The fact is that in the work one way or another, references to specific keys are needed, such links are called hardware aliases. Thus, in our system there will be two types of aliases: custom (we work with them in the code) and hardware aliases. The methods themselves are:

 bool GetAliasState(int alias, bool exclusive, AliasAction action); float GetAliasValue(int alias, bool delta); 

Input methods accept indifiers of user aliases obtained by calling the GetAlias ​​method. This restriction was introduced in order to avoid the temptation to use hardware aliases directly and always use only custom ones.

If you need to insert a debug hotkey that includes something debugging, use one of two methods:

 bool DebugKeyPressed(const char* name, AliasAction action); bool DebugHotKeyPressed(const char* name, const char* name2, const char* name3); 

Both methods take the name of hardware aliases as input. Thus, processing debazh hotkeys uses one of two methods, so there is no difficulty in adding a setting that disables the processing of all debazh hotkeys, i.e. You do not need a separate code that disables the processing of the hot keys, since the system will turn them off. Thus, no debugging functionality will be included in the release build.

Let us turn to a more detailed description of the implementation. Only the code logic will be described below. I used DirectInput to work with the keyboard and mouse, so the code for working with DirectInput will be skipped.

Let's start with a description of the structure of hardware aliases:

 enum Device { Keyboard, Mouse, Joystick }; struct HardwareAlias { std::string name; Device device; int index; float value; }; 

Now we will describe the alias structure:

 struct AliasRefState { std::string name; int aliasIndex = -1; bool refer2hardware = false; }; struct AliasRef { float modifier = 1.0f; std::vector<AliasRefState> refs; }; struct Alias { std::string name; bool visited = false; std::vector<AliasRef> aliasesRef; }; 

And now we will start implementation of methods. Let's start with the initialization method:

 bool Controls::Init(const char* name_haliases, bool allowDebugKeys) { this->allowDebugKeys = allowDebugKeys; //Init input devices and related stuff JSONReader* reader = new JSONReader(); if (reader->Parse(name_haliases)) { while (reader->EnterBlock("keyboard")) { haliases.push_back(HardwareAlias()); HardwareAlias& halias = haliases[haliases.size() - 1]; halias.device = Keyboard; reader->Read("name", halias.name); reader->Read("index", halias.index); debeugMap[halias.name] = (int)haliases.size() - 1; reader->LeaveBlock(); } while (reader->EnterBlock("mouse")) { haliases.push_back(HardwareAlias()); HardwareAlias& halias = haliases[(int)haliases.size() - 1]; halias.device = Mouse; reader->Read("name", halias.name); reader->Read("index", halias.index); debeugMap[halias.name] = (int)haliases.size() - 1; reader->LeaveBlock(); } } reader->Release(); return true; } 

To load custom aliases, we describe the LoadAliases method. The same method is used if a file that describes aliases has been changed, for example, the user in the settings has redefined control:

 bool Controls::LoadAliases(const char* name_aliases) { JSONReader* reader = new JSONReader(); bool res = false; if (reader->Parse(name_aliases)) { res = true; while (reader->EnterBlock("Aliases")) { std::string name; reader->Read("name", name); int index = GetAlias(name.c_str()); Alias* alias; if (index == -1) { aliases.push_back(Alias()); alias = &aliases.back(); alias->name = name; aliasesMap[name] = (int)aliases.size() - 1; } else { alias = &aliases[index]; alias->aliasesRef.clear(); } while (reader->EnterBlock("AliasesRef")) { alias->aliasesRef.push_back(AliasRef()); AliasRef& aliasRef = alias->aliasesRef.back(); while (reader->EnterBlock("names")) { aliasRef.refs.push_back(AliasRefState()); AliasRefState& ref = aliasRef.refs.back(); reader->Read("", ref.name); reader->LeaveBlock(); } reader->Read("modifier", aliasRef.modifier); reader->LeaveBlock(); } reader->LeaveBlock(); } ResolveAliases(); } reader->Release(); } 

ResolveAliases () method is found in the loading code. In this method, linking of loaded aliases occurs. The link code looks like this:

 void Controls::ResolveAliases() { for (auto& alias : aliases) { for (auto& aliasRef : alias.aliasesRef) { for (auto& ref : aliasRef.refs) { int index = GetAlias(ref.name.c_str()); if (index != -1) { ref.aliasIndex = index; ref.refer2hardware = false; } else { for (int l = 0; l < haliases.size(); l++) { if (StringUtils::IsEqual(haliases[l].name.c_str(), ref.name.c_str())) { ref.aliasIndex = l; ref.refer2hardware = true; break; } } } if (index == -1) { printf("alias %s has invalid reference %s", alias.name.c_str(), ref.name.c_str()); } } } } for (auto& alias : aliases) { CheckDeadEnds(alias); } } 

In the linking code, there is the CheckDeadEnds method. The purpose of the method is to identify circular references, since such links cannot be processed and protection against them is needed.

 void Controls::CheckDeadEnds(Alias& alias) { alias.visited = true; for (auto& aliasRef : alias.aliasesRef) { for (auto& ref : aliasRef.refs) { if (ref.aliasIndex != -1 && !ref.refer2hardware) { if (aliases[ref.aliasIndex].visited) { ref.aliasIndex = -1; printf("alias %s has circular reference %s", alias.name.c_str(), ref.name.c_str()); } else { CheckDeadEnds(aliases[ref.aliasIndex]); } } } } alias.visited = false; } 

Now we go to the method of polling the state of hardwired aliases:

 bool Controls::GetHardwareAliasState(int index, AliasAction action) { HardwareAlias& halias = haliases[index]; switch (halias.device) { case Keyboard: { //code that access to state of keyboard break; } case Mouse: { //code that access to state of mouse break; } } return false; } bool Controls::GetHardwareAliasValue(int index, bool delta) { HardwareAlias& halias = haliases[index]; switch (halias.device) { case Keyboard: { //code that access to state of keyboard break; } case Mouse: { //code that access to state of mouse break; } } return 0.0f; } 

Now the polling code for the aliases themselves:

 bool Controls::GetAliasState(int index, AliasAction action) { if (index == -1 || index >= aliases.size()) { return 0.0f; } Alias& alias = aliases[index]; for (auto& aliasRef : alias.aliasesRef) { bool val = true; for (auto& ref : aliasRef.refs) { if (ref.aliasIndex == -1) { continue; } if (ref.refer2hardware) { val &= GetHardwareAliasState(ref.aliasIndex, Active); } else { val &= GetAliasState(ref.aliasIndex, Active); } } if (action == Activated && val) { val = false; for (auto& ref : aliasRef.refs) { if (ref.aliasIndex == -1) { continue; } if (ref.refer2hardware) { val |= GetHardwareAliasState(ref.aliasIndex, Activated); } else { val |= GetAliasState(ref.aliasIndex, Activated); } } } if (val) { return true; } } return false; } float Controls::GetAliasValue(int index, bool delta) { if (index == -1 || index >= aliases.size()) { return 0.0f; } Alias& alias = aliases[index]; for (auto& aliasRef : alias.aliasesRef) { float val = 0.0f; for (auto& ref : aliasRef.refs) { if (ref.aliasIndex == -1) { continue; } if (ref.refer2hardware) { val = GetHardwareAliasValue(ref.aliasIndex, delta); } else { val = GetAliasValue(ref.aliasIndex, delta); } } if (fabs(val) > 0.01f) { return val * aliasRef.modifier; } } return 0.0f; } 

And the last thing is a poll of debugging keys:

 bool Controls::DebugKeyPressed(const char* name, AliasAction action) { if (!allowDebugKeys || !name) { return false; } if (debeugMap.find(name) == debeugMap.end()) { return false; } return GetHardwareAliasState(debeugMap[name], action); } bool Controls::DebugHotKeyPressed(const char* name, const char* name2, const char* name3) { if (!allowDebugKeys) { return false; } bool active = DebugKeyPressed(name, Active) & DebugKeyPressed(name2, Active); if (name3) { active &= DebugKeyPressed(name3, Active); } if (active) { if (DebugKeyPressed(name) | DebugKeyPressed(name2) | DebugKeyPressed(name3)) { return true; } } return false; } 

There is still a function to update the states:

 void Controls::Update(float dt) { //update state of input devices } 

That's all. The system turned out quite simple, with a minimum amount of code. At the same time, it effectively solves the problem of polling the states of input devices.

Link to the example of using a working system

Also, this system was written for an engine called Atum. The repository of all the sources of the engine - they have a lot of interesting things.

Source: https://habr.com/ru/post/343258/


All Articles