When working on a game engine, one wants to configure it correctly from the beginning: in order not to painfully refactor it later. When I was developing my engine, for inspiration I surveyed source code of other game engines and came to the specific realization (you can find out more about it by reference in the end of the article). In the article I would like to come up with the decision how to architect a system which reads data from input devices.
In this article I will review how to architect a system that reads data from input devices. This doesn't sound complicated: read data from mouse, keyboard, and joystick and call them in a correct place. That’s true, and common input handling code might look like this:
//updating of data from input devices
controls->Update()
...
void Player::Move()
{
if (controls->MouseButonPressed(0))
{
...
}
if (controls->KeyPressed(KEY_SPACE))
{
...
}
if (controls->JoystickButtonPressed(0))
{
...
}
}
What's wrong with this approach?
First, if we want to read data from a certain device, for example from the joystick, we are using methods specific to the device type. Second, we have hard-coded inputs, i.e. we call a certain keyboard key direct in the game code and from the certain device. That’s not good because later we’ll have to expel this from code to make keys redefinition in menu. We’ll have to create a remapping system that allows us to quickly redefine key bindings. Thus, the simplest realization isn’t the best one.
What can we propose to deal with this problem?
The solution is simple: when calling input devices, use abstract names – “aliases”. They are defined in a separate file, and their names arise not from a key name with a bound action but from the action itself, for example, "ACTION_JUMP", "ACTION_SHOOT". To avoid work with aliases’ names, let’s add a method of getting alias’s identifier:
int GetAlias(const char* name);
States calling resolves into two methods:
enum AliasAction
{
Active,
Activated
};
bool GetAliasState(int alias, AliasAction action);
float GetAliasValue(int alias, bool delta);
Let me explain why I use these two methods. When one calls key state, Boolean value is enough. But when one calls joystick’s stick state, it’s necessary to get numerical value. That’s why there are two methods. In case of state, we pass type of action in the second parameter. There are two types: Active (alias is active, for example, a key is pressed) and Activated (alias passed into active state). For example, we should process key of grenade throwing. That’s not constant action like walking, so we need recognition of the fact that throwing grenade key has been pressed; and if the key is still pressed – then not to respond to that fact. When calling alias’s numerical value, we pass Boolean flag as the second parameter, and it tells us if we need the value itself or the difference between current value and value of last frame.
An example of the camera control code:
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);
}
Note that we used prefix FreeCamera in aliases’ names. It was done so as to follow the certain naming rule which helps to understand which object the alias belongs to. If we don’t do so, in case of further development the amount of aliases would increase, and eventually we would get lost among lot of aliases which refers to each other. It’s impossible to manage such a situation because finding of mistaken alias’s definition would be very difficult and take a lot of time. So, introduction of naming rule is essential.
Let’s continue with the most interesting part: definition of aliases themselves. They we’ll be held in json file. The file which describes aliases for camera looks in the following way:
{
"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 defined in a simple way: define alias’s name (parameter “name”) and an array of links to aliases (parameter “AliasesRef”). For each link to alias one may define a paramets “modificator”; this parameter will be used as a factor applying to the value which comes when calling method “GetAliasValue”. Aliases MOVE_FORWARD and MOVE_STRAFE use that parameter for joystick’s stick work imitation because joystick’s stick outputs value from range [-1..1] for each of two axes. To set keys combination (for example, hotkeys) parameter “names” is an array of names. Alias RESET_VIEW is an example of setting hotkey for a keys combination LCTRL + R.
Let’s consider encountered names in links to aliases, for example, KEY_W, MS_BTN1. The fact is that in their work one needs links to certain keys; such keys are called “hardware aliases”. Consequently, there are two types of aliases in our system: user-defined (we work with them in code) and hardware aliases. Two methods are:
bool GetAliasState(int alias, bool exclusive, AliasAction action);
float GetAliasValue(int alias, bool delta);
The methods accept identifiers of user-defined aliases gotten when calling a method “GetAlias”. Such a restriction was introduced in order not to use hardware aliases and all the time to use only user-defined aliases.
If needed to insert debug hotkey which starts something debugging, one should use one of following methods:
bool DebugKeyPressed(const char* name, AliasAction action);
bool DebugHotKeyPressed(const char* name, const char* name2, const char* name3);
Both methods accept names of hardware aliases. So, processing of debug hotkeys uses one of these two methods, and it’s simple to add setting which disables processing of all debug hotkeys; and one doesn’t need separate code to disable processing of debug hotkeys because a system will disable them by itself. Consequently, no debug functional will fall into release build.
Let’s consider description of implementation closely. Further logic of code will be described. I used DirectInput to work with a keyboard and mouse, that’s why a code for work with DirectInput will be omitted.
Let’s begin with description of hardware aliases structure:
enum Device
{
Keyboard,
Mouse,
Joystick
};
struct HardwareAlias
{
std::string name;
Device device;
int index;
float value;
};
Let’s describe aliases 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;
};
Let’s implement methods, and begin with 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;
}
For user-defined loading let’s describe method “LoadAliases”. The same method is used in case if a file, which describes aliases, was changed. For example, user redefined control scheme in settings:
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();
}
There is method “ResolveAliases()” in loading code. There is linking of loaded aliases in this method. Linking code:
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);
}
}
There is method CheckDeadEnds in linking method. The purpose of the method is to identify iterative references because such references can’t be processed, and one needs escape them.
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;
}
Let’s move to the method of getting states of hardware 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;
}
And code for definition states of 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: definition of debug keys states:
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 one more function for states update:
void Controls::Update(float dt)
{
//update state of input devices
}
That’s all. The system worked out to be quite simple, and it has minimum amount of code. Still it solves the problem of getting states of input devices effectively.
Reference to the example of well working system: github.com/ENgineE777/Controls.
Moreover, this system was created for Atum engine. Repository of all engine sources: github.com/ENgineE777/Atum - you’ll fine there more where that came from.