- 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/