Проектирование системы для считывания данных с устройств ввода (Часть первая)

в 8:01, , рубрики: controls, game engine, Gamedev, input, разработка игр

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

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

//обновления данных, полученных с устройств ввода
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. Репозиторий всех исходников движка — в них много чего интересного.

Автор: ENgineE

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js