Когда работаешь над игровым движком, хочется сразу спроектировать его правильно — так, чтобы позднее не тратить время на мучительный рефакторинг. Когда я разрабатывал свой движок, в поисках вдохновения я просматривал исходники других игровых движков и пришел к определенной реализации (ознакомиться с ней можно по ссылке в конце статьи). В статье я бы хотел предложить решение задачи по проектированию системы, считывающей данные с устройств ввода.

Казалось бы, что тут сложного: считал данные с мышки, клавиатуры, джойстика и вызвал их в нужном месте. Так оно и есть, и чаще всего подобие такого кода можно встретить в игровых движках:

//обновления данных, полученных с устройств ввода
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
}

На этом все. Система получилась достаточно простой, с минимальным количеством кода. При этом она эффективно решает задачу опроса состояний устройств ввода.

Ссылка на пример использования работающей системы

Также эта система была написана для движка под названием Atum. Репозиторий всех исходников движка — в них много чего интересного.

Комментарии (13)


  1. Sinatr
    27.11.2017 11:24

    Язык по видимому C++/CLI (net)?

    Определение alias — это pseudonym, т.к. все переменные — это имена, то можно было это слово вообще не использовать. Просто уберите его из всего: Action, GetState, action_forward, и т.д.

    с минимальным количеством кода
    Программирую на C#, как-то стремно выглядит загрузка json (в C# это две строчки) и инициализация, не врете насчет «минимального»? Трижды вложенные циклы — это тоже нормально, Dictionary в С++ нет чтоли? Не критикую (т.к. языком не владею), а скорее интересуюсь.


    1. ENgineE Автор
      27.11.2017 11:49

      Имена это вкусовщина, и не может быть предметом обсуждения. Хочется поразбиратся в С++, то просьба перейти на соответввующие обучающие ресурсы по С++.

      Суть статьи донести идею лежащую в основе системы, само собой моя имплементация, это одна из вариантов имплементации, в вашем исполнения она будет другой, и это нормально.

      Просьба давать коментарии по существу.


  1. Sinatr
    27.11.2017 12:14

    Ни на один из моих вопросов вы не ответили прямо. Чем ваш велосипед лучше/хуже других?

    в поисках вдохновения я просматривал исходники других игровых движков
    Каких именно? В статье отсылок нет. Было бы интересно почитать/сравнить с тем, как делают другие. А так статья — просто реализация, в стиле «смотрите как я могу», причем вы сами говорите, что «в вашем исполнения она (имплементация) будет другой».

    Я объясню чем именно вызван мой интерес к статье: Destiny 2, и вообще все Bethesda игры — многоплатформенные игры, в которых полно косяков, один из самый раздражающих — это, как вы выразились, «хардкод» — непереопределяемый клавиши. И мне не понятно, почему это так сложно это сделать для компаний у которых сотни программистов. Может быть ВЫ пойдете работать в Bungie и научите их как надо? ;)


  1. cyberonix
    27.11.2017 12:16

    создавать велосипед, я в этом не вижу ничего плохого, главное что бы этот велосипед был с педалями


  1. GrimMaple
    27.11.2017 14:36

    А почему бы не сделать так?

    typedef void ( __stdcall *btn_event_t)(param1, param2, ...)

    А потом просто подписываться на сообщения и получать радость? Такой код получится компактнее и практичнее, имхо.


    1. ENgineE Автор
      27.11.2017 16:32

      Это вопрос кому что больше нравится, кому эвенты, кому опрос стейтов. Насчет компактее очень спорный вопрос, ибо по факту код просто из одного места перетекает в другое. В случае со стейтами, проверка на стейт, далее код в блоке, соответвующий дейсвию. В случае с эвентами, условие заменяется подписью калбека на эвент, и тот же самый код, соотвевующий действию, переезжает в калбек. То есть вопрос вкусовщины, не более, кода ни больше, ни меньше не становится.


      1. GrimMaple
        27.11.2017 16:42

        Ну, еще есть небольшая вероятность того, что с проверкой состояний вы просто «проморгаете» нажатие, если оно произошло достаточно быстро, и стейты поменялись с «нажат» на «не нажат» быстрее, чем ваш код успел на это отреагировать :) С событиями, как только поменялось состояние — сразу же вызвался код, что гарантирует обработку нажатия.
        И маленькое имхо: подход с событиями кажется более объектно-ориентированным, чем дёрганье состояний, которое выглядит как Сишный код.


        1. qw1
          27.11.2017 18:21

          У колбеков свои минусы: непонятно, в каком потоке он вызван и нужна синхронизация, чтобы, например, сдвиг игрока при падении вниз и кнопкой «вперёд» не привели к гонке.

          Безопаснее всего в колбеке включить флажок, что кнопка нажималась, а где-то в игровой логике этот флажок проверить (и не забывать эти флажки сбрасывать, чтобы нажатие «граната» в конце раунда не выстрелило в начале следующего).


  1. ENgineE Автор
    27.11.2017 17:03

    Вы описываете сценарий, когда апдейст стейтов происходит в одном потоке, а код, опрашивающий стейты, происходит в другом потоке. В однопоточной реализации, как это сделано в коде статьи, подобный сценарий невозможен. Тема работы с многопоточностью, это отдельная тема. К тому же, использование эвентов не спасает от необходимости синхронизации, так как если будет дергаться код по обработки действия из потока, в котором обновляются контролсы, то надо этот код оборачивать критическими секциями, чтобы ничего не сломать. В общем, вопросы работы с мультипоточностью не являються темой статьи.


    1. GrimMaple
      27.11.2017 17:10

      В таком случае, подобный сценарий все еще возможен — из-за того, что сам код, опрашивающий киборду и джойстики, не успеет отреагировать на очень быстрый тап клавиши. Но это уже не проблема кода обработчика, это проблема кода, опрашивающего устройства.


      1. ENgineE Автор
        27.11.2017 17:23

        Если расматривать не штатные условия, что игра/приложение адски тормозит и выдает пол кадра в секунду, тормозя тем самым всю систему, то конечно до драйвера не дойдет понимание, чтобы была нажата кнопка, но это совсем другая история, и проблема не в эвентах или стейтак, а в том, что все тормозит :)


        1. GrimMaple
          27.11.2017 18:53

          Вы недооцениваете лапы геймеров :) Я протестировал себя, и, как выяснилось, могу тапать кнопки со временем нажатия 20мс, чего будет достаточно, чтобы проворонить нажатие при чуть меньше 50фпс (1000/20). Еще не забываем, что для многих игр выдавать всего 30 кадров в секунду (33,3мс) вполне нормально. Так что проблема более обширная, нежели вы думаете. Я еще и не самый адский мэшер в плане клавиатуры, многие наверняка могут и побыстрее.

          Скриншот
          image


      1. qw1
        27.11.2017 18:26

        В хардкорных играх, в файтингах, например, есть буфер команд. При вводе комбо некоторые последующие движения можно ввести, пока не закончена анимация предыдущего удара. В-общем, обсуждать «абстрактный игровой движок» мало смысла, детали зависят даже от жанра игры.