При создании искусственного интеллекта для видеоигр одним из самых важных аспектов является его расположение. Позиция ИИ-персонажа может полностью менять его типы поведения и будущие решения. В этом туториале мы разберёмся, как игровое окружение может влиять на ИИ и как правильно это использовать.

Эта статья взята из книги Practical Game AI Programming, написанной Микаэлем Даграка и опубликованной Packt Publishing. Эта книга позволяет узнать, как создать игровой ИИ и с нуля реализовать самые современные алгоритмы ИИ.

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

Один из первых примеров взаимодействия с окружениями можно найти в первой Castlevania, выпущенной в 1986 году для Nintendo Entertainment System. С самого начала игрок может использовать хлыст, чтобы уничтожать свечи и кострища, изначально являющиеся частью фона.


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

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

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

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

Создание простых взаимодействий с окружением


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


Для демонстрации одного из примеров объекта окружения, напрямую влияющего на геймплей, мы возьмём превосходный образец — франшизу Tomb Raider. В этом примере наш персонаж, Лара Крофт, должна толкать куб, пока он не встанет на отмеченную область. Это изменит окружение и откроет новый путь, позволяющий игроку двигаться дальше по уровню.

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

if(cube.transform.position == mark.transform.position)
{
  openDoor = true;
}

Теперь представим на минуту, что у Лары Крофт есть персонаж-союзник, чья основная задача — помочь ей поставить на место этот ящик. И в этой главе мы рассмотрим именно такой тип взаимодействий: ИИ-персонаж понимает, как работает окружение, и как его использовать.

Перемещение объектов окружения в Tomb Raider


Давайте сразу перейдём к этому сценарию и попробуем воссоздать ситуацию, в которой есть ИИ-персонаж, способный помочь игроку в достижении его цели. В этом примере мы представим, что игрок попал в ловушку, из которой он не может получить доступа к интерактивному объекту, способному его освободить. Создаваемый нами персонаж должен быть способен найти куб и подтолкнуть его в нужном направлении.


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


ИИ-персонаж должен проверять и подтверждать каждое действие, показанное в этом дереве поведений. Первое и самое важное для продолжения выполнения задачи — персонаж должен быть уверен, что игрок расположен на своей метке.

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

Затем он начинает толкать куб по оси Y или X, пока тот не совпадёт с позицией пометки и задача не будет выполнена.

public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform currentPlayerPosition;
public Transform currentCubePosition;

public float proximityValueX;
public float proximityValueY;
public float nearValue;

private bool playerOnMark;


void Start () {

}

void Update () {

  // Calculates the current position of the player
  currentPlayerPosition.transform.position = playerMesh.transform.position;

  // Calculates the distance between the player and the player mark of the X axis
  proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;

  // Calculates the distance between the player and the player mark of the Y axis
  proximityValueYplayerMark.transform.position.y - currentPlayerPosition.transform.position.y;

  // Calculates if the player is near of his MARK POSITION
  if((proximityValueX + proximityValueY) < nearValue)
  {
     playerOnMark = true;
  }
}

Мы начинаем добавлять в код информацию, позволяющий персонажу проверять, находится ли игрок рядом с его отмеченной позицией. Для этого мы создаём все переменные, необходимые для вычисления расстояний между игроком и позицией, в которой он должен находиться. playerMesh относится к 3D-модели игрока, из которой мы извлекаем его позицию и используем её в качестве currentPlayerPosition.

Чтобы знать, близко ли он к отметке, нам нужна переменная, представляющая позицию отметки, и в нашем примере мы создали переменную playerMark, в которую можем записать позицию, в которой должен находиться игрок. Затем мы добавили три переменные, позволяющие нам узнать, рядом ли игрок. proximityValueX будет вычислять расстояние между игроком и отметкой по оси X. proximityValueY вычисляет расстояние между игроком и отметкой по оси Y.

Далее у нас есть nearValue, в которой мы можем определить, как далеко может находиться игрок от позиции отметки, когда ИИ-персонаж может начать работать над выполнением цели. Как только игрок окажется рядом с отметкой, булева переменная playerOnMark меняет значение на true.

Для вычисления расстояния между игроком и отметкой, мы использовали следующее: расстояние между игроком и его отметкой, аналогичное (mark.position - player.position).

Теперь чтобы определить, находится ли ИИ-персонаж рядом с кубом, мы вычислим то же уравнение, рассчитав расстояние между ИИ и кубом. Кроме того, мы дополнили код позициями на обеих отметках (игрока и куба):

public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform currentPlayerPosition;
public Transform currentCubePosition;

public float proximityValueX;
public float proximityValueY;
public float nearValue;

public float cubeProximityX;
public float cubeProximityY;
public float nearCube;

private bool playerOnMark;
private bool cubeIsNear;


void Start () {

   Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
   Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
   nearValue = 0.5f;
   nearCube = 0.5f;
}

void Update () {

  // Calculates the current position of the player
  currentPlayerPosition.transform.position = playerMesh.transform.position;

  // Calculates the distance between the player and the player mark of the X axis
  proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;

  // Calculates the distance between the player and the player mark of the Y axis
  proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;

  // Calculates if the player is near of his MARK POSITION
  if((proximityValueX + proximityValueY) < nearValue)
  {
     playerOnMark = true;
  }

  cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
  cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;

  if((cubeProximityX + cubeProximityY) < nearCube)
  {
     cubeIsNear = true;
  }

  else
  {
     cubeIsNear = false;
  }
}

Теперь, когда наш ИИ-персонаж знает, находится ли он рядом с кубом, это позволяет ответить на вопрос и определить, может ли он перейти к следующей запланированной нами ветви. Но что произойдёт, когда персонаж не рядом с кубом? Ему нужно будет подойти к кубу. Поэтому мы добавим это в код:

public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform cubeMesh;
public Transform currentPlayerPosition;
public Transform currentCubePosition;

public float proximityValueX;
public float proximityValueY;
public float nearValue;

public float cubeProximityX;
public float cubeProximityY;
public float nearCube;

private bool playerOnMark;
private bool cubeIsNear;

public float speed;
public bool Finding;


void Start () {

   Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
   Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
   nearValue = 0.5f;
   nearCube = 0.5f;
   speed = 1.3f;
}

void Update () {

  // Calculates the current position of the player
  currentPlayerPosition.transform.position = playerMesh.transform.position;

  // Calculates the distance between the player and the player mark of the X axis
  proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;

  // Calculates the distance between the player and the player mark of the Y axis
  proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;

  // Calculates if the player is near of his MARK POSITION
  if((proximityValueX + proximityValueY) < nearValue)
  { 
      playerOnMark = true;
  }

  cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
  cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;

  if((cubeProximityX + cubeProximityY) < nearCube)
  {
      cubeIsNear = true;
  }

  else
  {
      cubeIsNear = false;
  }

  if(playerOnMark == true && cubeIsNear == false && Finding == false)
  {
     PositionChanging();
  }

  if(playerOnMark == true && cubeIsNear == true)
  {
     Finding = false;
  }

}

void PositionChanging () {

  Finding = true;
  Vector3 positionA = this.transform.position;
  Vector3 positionB = cubeMesh.transform.position;
  this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}

Пока наш ИИ-персонаж способен вычислять расстояние между собой и кубом; если они слишком далеко друг от друга, то он пойдёт к кубу. После завершения этой задачи он может перейти к следующей фазе и начать толкать куб. Последнее, что ему нужно вычислить — как далеко находится куб от позиции отметки, после чего он решает, в какую сторону нужно толкать с учётом того, к какой стороне куба ближе отметка.


Куб можно толкать только по осям X и Z, и его поворот нам пока неважен, потому что кнопка активируется при установке на неё куба. Учитывая всё это, ИИ-персонаж должен вычислить, как далеко куб находится от позиции отметки по X и позиции отметки по Z.

Затем он сравнит два значения по двум осям и выберет, какое из них дальше от нужной позиции, после чего начнёт толкать по этой оси. Персонаж продолжит толкать в этом направлении, пока куб не выровняется с позицией отметки, а затем переключится на другую сторону, и будет толкать его, пока он полностью не окажется поверх позиции отметки:

public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform cubeMesh;
public Transform currentPlayerPosition;
public Transform currentCubePosition;

public float proximityValueX;
public float proximityValueY;
public float nearValue;

public float cubeProximityX;
public float cubeProximityY;
public float nearCube;

public float cubeMarkProximityX;
public float cubeMarkProximityZ;

private bool playerOnMark;
private bool cubeIsNear;

public float speed;
public bool Finding;


void Start () {

        Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
        Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
        nearValue = 0.5f;
        nearCube = 0.5f;
        speed = 1.3f;
}

void Update () {

  // Calculates the current position of the player
  currentPlayerPosition.transform.position = playerMesh.transform.position;

  // Calculates the distance between the player and the player mark of the X axis
  proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;

  // Calculates the distance between the player and the player mark of the Y axis
  proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;

  // Calculates if the player is near of his MARK POSITION
  if((proximityValueX + proximityValueY) < nearValue)
  {
     playerOnMark = true;
  }

  cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
  cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;

  if((cubeProximityX + cubeProximityY) < nearCube)
  {
     cubeIsNear = true;
  }

  else
  {
     cubeIsNear = false;
  }

  if(playerOnMark == true && cubeIsNear == false && Finding == false)
  {
      PositionChanging();
  }

  if(playerOnMark == true && cubeIsNear == true)
  {
      Finding = false;
    }

   cubeMarkProximityX = cubeMark.transform.position.x - currentCubePosition.transform.position.x;
   cubeMarkProximityZ = cubeMark.transform.position.z - currentCubePosition.transform.position.z;

   if(cubeMarkProximityX > cubeMarkProximityZ)
   {
     PushX();
   }

   if(cubeMarkProximityX < cubeMarkProximityZ)
   {
     PushZ();
   }

}

void PositionChanging () {

  Finding = true;
  Vector3 positionA = this.transform.position;
  Vector3 positionB = cubeMesh.transform.position;
  this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}

После добавления в код последних действий персонаж должен научиться определять свою цель, находить и толкать куб в нужную позицию, чтобы игрок мог пройти и закончить уровень. В этом примере мы сосредоточились на том, как вычислять расстояния между объектами сцены и персонажами. Это поможет нам создавать похожие типы взаимодействий, при которых нужно поместить игровой объект в определённую позицию.

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

Объекты-препятствия в окружениях на примере Age of Empires


Как мы увидели ранее, можно использовать или перемещать объекты в игре для выполнения целей, но что случится, если какой-то объект будет препятствовать пути персонажа? Объект может быть размещён игроком или просто расположен дизайнером в этой позиции карты. В любом случае ИИ-персонаж должен иметь возможность определить, что нужно делать в этой ситуации.

Мы можем наблюдать такое поведение, например, в стратегии под названием Age of Empires II, разработанной Ensemble Studios. Каждый раз, когда персонаж игры не может добраться до вражеской территории, из-за того, что она окружена укреплёнными стенами, ИИ переключается на разрушение части стены, чтобы пройти дальше.

Такой тип взаимодействия тоже очень умный и важный, потому что в противном случае персонажи бы просто бродили вдоль стены в поисках входа, и это не выглядело бы как разумное поведение. Так как укреплённая стена создаётся игроком, она может быть поставлена где угодно и иметь любую форму. Поэтому необходимо думать об этом при разработке ИИ-противника.


Этот пример тоже относится к теме нашей статьи, потому что на этапе планирования, когда мы создаём деревья поведений, нам нужно подумать о том, что произойдёт, если что-то встанет на пути персонажа и он не сможет выполнять свои цели. Подробно этот аспект мы рассмотрим в следующих главах книги, а пока упростим ситуацию и проанализируем, как должен вести себя ИИ-персонаж, если объект окружения мешает ему выполнить его цель.


В нашем примере ИИ-персонаж должен войти в дом, но когда он приближается, то понимает, что тот окружён деревянным забором, через который нельзя пройти. Мы хотим, чтобы этом этапе персонаж выбрал цель и начал атаковать, пока эта часть забора не будет разрушена и он не сможет войти в дом.

В этом примере нам нужно вычислить, какой забор должен атаковать персонаж, учитывая расстояние и текущее состояния «здоровья» забора. Забор с низким HP должен иметь более высокий приоритет для атаки в отличие от забора с полным HP, поэтому мы учтём это в своих вычислениях.


Мы хотим задать окрестность вокруг персонажа, в пределах которой ближайшие заборы передают свою информацию искусственному интеллекту, чтобы он мог решить, какой из них проще разрушить. Это можно реализовать различными способами, или использовав распознавание коллизий заборов с игроком, или заставив их вычислять расстояние между заборами/объектами и игроком; мы задаём значение расстояния, на котором игрок начинает воспринимать состояние забора. В нашем примере мы будем вычислять расстояние и использовать его для уведомления персонажа об HP заборов.

Давайте начнём с создания кода, который будет применён к объекту забора; все они будут имет один и тот же скрипт:

public float HP;
public float distanceValue;
private Transform characterPosition;
private GameObject characterMesh;

private float proximityValueX;
private float proximityValueY;
private float nearValue;

// Use this for initialization
void Start () {

  HP = 100f;
  distanceValue = 1.5f;

  // Find the Character Mesh
  characterMesh = GameObject.Find("AICharacter");
}

// Update is called once per frame
void Update () {

  // Obtain the Character Mesh Position
  characterPosition = characterMesh.transform;

  //Calculate the distance between this object and the AI Character
  proximityValueX = characterPosition.transform.position.x - this.transform.position.x;
  proximityValueY = characterPosition.transform.position.y - this.transform.position.y;

  nearValue = proximityValueX + proximityValueY;
}

В этом скрипте мы добавили основную информацию о HP и расстояниях, которая будет использоваться для соединения с ИИ-персонажем. На этот раз мы добавляем вычисляющий расстояния скрипт не персонажу, а объектам окружения; это даёт объекту больший динамизм и позволяет нам создавать больше возможностей.

Например, если персонажи игры также занимаются созданием заборов, то те будут иметь различные состояния, например, «строится», «завершён» или «повреждён»; тогда персонаж сможет получать эту информацию и использовать её в своих целях.

Давайте зададим персонажа, взаимодействующего с объектом окружения. Его основной целью будет получение доступа к дому, но когда он приближается к нему, то осознаёт, что не может попасть внутрь из-за того, что тот окружён деревянными заборами. Мы хотим, чтобы проанализировав ситуацию, наш персонаж разрушал забор для выполнения своей цели и попадания в дом.

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

public static float fenceHP;
public static float lowerFenceHP;
public static float fencesAnalyzed;
public static GameObject bestFence;

private Transform House;

private float timeWasted;
public float speed;



void Start () {

        fenceHP = 100f;
        lowerFenceHP = fenceHP;
        fencesAnalyzed = 0;
        speed = 0.8;

        Vector3 House = new Vector3(300.2f, 83.3f, -13.3f);

}

void Update () {

        timeWasted += Time.deltaTime;

        if(fenceHP > lowerFenceHP)
        {
            lowerFenceHP = fenceHP;
        }

        if(timeWasted > 30f)
        {
            GoToFence();  
        }
}

void GoToFence() {

        Vector3 positionA = this.transform.position;
        Vector3 positionB = bestFence.transform.position;
        this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}


Мы уже добавили персонажу самую основную информацию. fenceHP будет статической переменной, в которую каждый забор, попавший в радиус окрестности персонажа, будет записывать информацию о текущем HP. Затем ИИ-персонаж анализирует полученную информацию и сравнивает её с забором с наименьшим HP, представленным lowerFenceHP.

Персонаж имеет переменную timeWasted, представляющую количество секунд, которое он уже потратил на поиск подходящего для разрушения забора. fencesAnalyzed будет использоваться для того, чтобы узнать, есть ли уже в коде забор, и если нет, то добавляется первый найденный персонажем забор; в случае, если заборы имеют одинаковое значение HP, персонаж атакует их первыми. Теперь давайте дополним код заборов, чтобы они могли получать доступ к скрипту персонажа и вводить полезную информацию.

public float HP;
public float distanceValue;
private Transform characterPosition;
private GameObject characterMesh;

private float proximityValueX;
private float proximityValueY;
private float nearValue;
void Start () {

        HP = 100f;
        distanceValue = 1.5f;

        // Find the Character Mesh
        characterMesh = GameObject.Find("AICharacter");
}

void Update () {

        // Obtain the Character Mesh Position
        characterPosition = characterMesh.transform;

        //Calculate the distance between this object and the AI Character
        proximityValueX = characterPosition.transform.position.x - this.transform.position.x;
        proximityValueY = characterPosition.transform.position.y - this.transform.position.y;

        nearValue = proximityValueX + proximityValueY;

        if(nearValue <= distanceValue){
            if(AICharacter.fencesAnalyzed == 0){
                AICharacter.fencesAnalyzed = 1;
                AICharacter.bestFence = this.gameObject;
            }

            AICharacter.fenceHP = HP;

            if(HP < AICharacter.lowerFenceHP){
                AICharacter.bestFence = this.gameObject;
            }
        }
}

Мы наконец завершили этот пример. Теперь забор сравнивает свой текущий HP с имеющимися у персонажа данными (lowerFenceHP), и если его HP ниже наименьшего значения, имеющегося у персонажа, то этот забор будет считаться bestFence.

Этот пример демонстрирует, как можно адаптировать ИИ-персонажа к различным динамическим объектам в игре; тот же самый принцип можно расширить и использовать для взаимодействия с почти любым объектом. Он также применим и полезен при использовании объектов для взаимодействия с персонажем, связывая информацию между ними.

В этом посте мы исследовали различные способы взаимодействия с окружением. Продемонстрированные в этой главе техники можно расширить на множество разных жанров игр и использовать для выполнения простых и сложных взаимодействий между ИИ-персонажами и окружением.