- PVSM.RU - https://www.pvsm.ru -
Когда работаешь над игровым движком, хочется сразу спроектировать его правильно — так, чтобы позднее не тратить время на мучительный рефакторинг. Когда я разрабатывал свой движок, в поисках вдохновения я просматривал исходники других игровых движков и пришел к определенной реализации (ознакомиться с ней можно по ссылке в конце статьи). В статье я бы хотел предложить решение задачи по проектированию системы, считывающей данные с устройств ввода.
Казалось бы, что тут сложного: считал данные с мышки, клавиатуры, джойстика и вызвал их в нужном месте. Так оно и есть, и чаще всего подобие такого кода можно встретить в игровых движках:
//обновления данных, полученных с устройств ввода
cotrols->Update()
...
void Player::Move()
{
if (controls->MouseButonPressed(0))
{
...
}
if (controls->KeyPressed(KEY_SPACE))
{
...
}
if (controls->JoystickButtonPressed(0))
{
...
}
}
Что меня не устраивает в таком подходе? Во-первых, если мы хотим считать данные с конкретного устройства, например джойстика, то мы используем методы, которые получают данные от определенного устройства. Во-вторых, в коде получаем хардкод, т.е. прямо в игровом коде идет опрос конкретной клавиши и у конкретного устройства. Это не хорошо, потому что позднее, чтобы сделать переопределение клавиш через игровое меню, надо будет все подобное вычищать и делать некую подсистему ремапинга, с возможностью переопределять биндинг клавиш на лету. Таким образом, с самой простой реализацией не все так хорошо.
Что можно предложить для решения проблемы?
Решение простое: при опросе устройств ввода использовать абстрактные имена — алиасы, которые прописываются в отдельном файле конфигурации и имена которых происходят от действия, а не от имени клавиш, на которое забинжено действие, например: «ACTION_JUMP», «ACTION_SHOOT». Чтобы не работать с самими именами алиасов, добавим метод получения идентификатора алиаса:
int GetAlias(const char* name);
Сам опрос стейтов сводится всего к двум методам:
enum AliasAction
{
Active,
Activated
};
bool GetAliasState(int alias, AliasAction action);
float GetAliasValue(int alias, bool delta);
Поясню, почему используем два метода. При опросе стейта клавиш булевского значения более чем достаточно, но при опросе стейта стика джойстика нужно будет получать числовое значение. Поэтому добавлено два метода. В случае стейта, во втором параметре передаем тип действия. Их всего два: Active (алиас активен, например, клавиша зажата) или Activated (алиас перешел в состояние активного). Например, нам надо обработать клавишу кидания гранаты. Это не постоянное действие, как, например, ходьба, поэтому нужно определение самого факта, что клавиша кидания гранаты была нажата, и если клавиша продолжает находиться в нажатом состоянии — не реагировать на это. При опросе числового значения алиаса передаем вторым параметром булевский флаг, который говорит, нужно ли нам само значение или нужна разница между текущим значением и значением от прошлого кадра.
Приведу пример кода, реализующего управление камерой:
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);
}
Обратите внимание, что в именах алиасов используется префикс FreeCamera. Это сделано для того, чтобы придерживаться определенного правила нейминга, которое позволяет понимать, к какому объекту относится алиас. Если этого не сделать, то по мере дальнейшей разработки количество алиасов будет увеличиваться, и со временем можно получить кучу алиасов, которые ссылаются друг на друга, и все это не будет поддаваться контролю, т.к. поиск ошибочного задания будет очень сложен и будет отнимать много времени. Поэтому введение правила нейминга необходимо.
Перейдем к самой интересной части — настройке самих алиасов. Они будут храниться в json файле. Файл, описывающий алиасы для камеры, выглядит так:
{
"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"] }
]}
]
}
Описываются алиасы достаточно просто: задаем имя алиасу (параметр name) и массив ссылок на алиасы (параметр AliasesRef). Для каждой ссылки на алиас можно задавать параметр modificator — этот параметр используется как множитель, применяемый к значению, которое получается при вызове метода GetAliasValue. Алиасы MOVE_FORWARD и MOVE_STRAFE используют этот параметр для имитации работы стика джойстика, т.к. именно стик джойстика выдает значение в диапазоне [-1..1] для каждой из двух осей. Чтобы можно было задавать комбинацию клавиш, т.е. хоткеи, параметр names является массивом имен. Алиас RESET_VIEW является примером задания хоткея комбинации клавиш LCTRL + R.
Более подробно рассмотрим встречающиеся имена в ссылках на алиасы, например, KEY_W, MS_BTN1. Дело в том, что в работе так или иначе нужны ссылки на конкретные клавиши, такие ссылки называются хардварными алиасами. Таким образом, в нашей системе будет два типа алиасов: пользовательские (с ними работаем в коде) и хардварные алиасы. Сами методы — это:
bool GetAliasState(int alias, bool exclusive, AliasAction action);
float GetAliasValue(int alias, bool delta);
Методы на вход принимают индификаторы пользовательских алиасов, полученных при вызове метода GetAlias. Такое ограничение введено для того, чтобы не было соблазна использовать хардварные алиасы напрямую и всегда использовались только пользовательские.
Если нужно вставить дебажный хоткей, который включает что-либо отладочное, используется один из двух методов:
bool DebugKeyPressed(const char* name, AliasAction action);
bool DebugHotKeyPressed(const char* name, const char* name2, const char* name3);
Оба метода принимают на вход имя хардварных алиасов. Таким образом, обработка дебажных хоткеев использует один из двух методов, поэтому нет никакой сложности в том, чтобы добавить настройку, которая отключит обработку всех дебажных хоткеев, т.е. не нужен отдельный код, отключающий обработку дебажных хоткеев, т.к. система сама их отключит. Таким образом, никакой дебажный функционал в релизный билд не попадет.
Перейдем к более подробному описанию реализации. Ниже будет описана только логика кода. Для работы с клавиатурой и мышкой я использовал DirectInput, поэтому код по работе с DirectInput будет пропущен.
Начнем с описания структуры хардварных алиасов:
enum Device
{
Keyboard,
Mouse,
Joystick
};
struct HardwareAlias
{
std::string name;
Device device;
int index;
float value;
};
Теперь опишим структуру алиасов:
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;
};
А теперь приступим к реализации методов. Начнем с метода инициализации:
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;
}
Для загрузки пользовательских алиасов опишем метод LoadAliases. Этот же метод используется в случае, если был изменен файл, описывающий алиасы, например, пользователь в настройках переопределил управление:
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(). В этом методе происходит линковка загруженных алиасов. Код линковки выглядит так:
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);
}
}
В коде линковки встречается метод CheckDeadEnds. Цель метода — выявить циклические ссылки, т.к. такие ссылки не могут быть обработаны и нужна защита от них.
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;
}
Теперь переходим к методу опрашивания состояния хардварных алиасов:
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;
}
Теперь код опроса самих алиасов:
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;
}
И последнее — это опрос дебажных клавиш:
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;
}
Остается еще функция обновления стейтов:
void Controls::Update(float dt)
{
//update state of input devices
}
На этом все. Система получилась достаточно простой, с минимальным количеством кода. При этом она эффективно решает задачу опроса состояний устройств ввода.
Ссылка на пример использования работающей системы [1]
Также эта система была написана для движка под названием Atum. Репозиторий всех исходников движка [2] — в них много чего интересного.
Автор: ENgineE
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/gamedev/269200
Ссылки в тексте:
[1] Ссылка на пример использования работающей системы: https://github.com/ENgineE777/Controls
[2] Репозиторий всех исходников движка: https://github.com/ENgineE777/Atum
[3] Источник: https://habrahabr.ru/post/343258/
Нажмите здесь для печати.