image

Steering behaviors помогают автономным персонажам реалистично двигаться благодаря применению простых сил, сочетание которых создаёт естественно выглядящее и импровизированное движение по окружению. В этом туториале я расскажу об основах теории steering behaviors, а также об их реализации.

Идеи, на которых построены такие поведения, предложены Крейгом Рейндольдсом; они не основаны на сложных стратегиях с использованием планирования пути или глобальных вычислений, а применяют локальную информацию, например, силы соседних объектов. Благодаря этому они просты в понимании и реализации, но в то же время способны создавать очень сложные паттерны движения.

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



Часть 1. Поведение Seek (поиск)


Позиция, скорость и движение


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

Хотя у вектора есть направление, мы будем игнорировать его, когда дело будет касаться позиции (будем считать, что вектор позиции указывает на текущее местоположение персонажа).


На показанном выше изображении показан персонаж, находящийся в (x, y) и имеющий скорость (a, b). Движение вычисляется с помощью метода Эйлера:

position = position + velocity

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

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

velocity = normalize(target - position) * max_velocity

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



Вычисление сил


Если задействована только сила скорости, то персонаж будет двигаться только по прямой, задаваемой направлением этого вектора. Один из принципов steering behaviors заключается в воздействии на движение персонажа добавлением сил (называемых управляющими силами). Благодаря этим силам персонаж будет двигаться в том или ином направлении.

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

В поведении seek задействуются две силы: требуемая скорость и управляющая сила:


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

Эти силы вычисляются следующим образом:

desired_velocity = normalize(target - position) * max_velocity
steering = desired_velocity - velocity



Приложение сил


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


Приложение этих сил и вычисление конечных скорости/позиции имеют следующий вид:

steering = truncate (steering, max_force)
steering = steering / mass

velocity = truncate (velocity + steering , max_speed)
position = position + velocity

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

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



Часть 2. Поведения Flee and Arrival


Убегание


Описанное выше поведение seek основано на двух силах, направляющих персонажа к цели: требуемой скорости и управляющей силе.

desired_velocity = normalize(target - position) * max_velocity
steering = desired_velocity - velocity

В этом случае desired_velocity является кратчайшим путём между персонажем и целью. Она получается вычитанием позиции цели из позиции персонажа. Результатом является вектор силы, проходящий от персонажа к цели.


В поведении flee используются те же две силы, но они настроены так, чтобы персонаж убегал от цели:


Поведение Flee

Этот новый вектор desired_velocity вычисляется вычитанием позиции персонажа из позиции цели, что даёт нам вектор, направленный от цели к персонажу.

Получающиеся силы вычисляются почти так же, как в поведении seek:

desired_velocity = normalize(position - target) * max_velocity
steering = desired_velocity - velocity

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

При сравнении вектора требуемой скорости в поведении flee с этим же вектором в поведении seek можно вывести следующее соотношение:

flee_desired_velocity = -seek_desired_velocity

Иными словами, один вектор является отрицательным по отношению к другому.



Приложение сил избегания


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


Сложение этих сил и вычисление конечных скорости/позиции выполняются тем же образом, что и раньше.

Приложение всех сил заставляет каждый персонаж плавно покидать текущий маршрут и избегать цели.

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



Arrival


Как мы видели, поведение seek заставляет персонаж двигаться к цели. Когда он достигает своей цели, управляющая сила продолжает на него воздействовать в соответствии с теми же правилами, заставляя персонаж «скакать» вперёд и назад вокруг цели.

Поведение arrival не позволяет персонажу двигаться сквозь цель. При приближении к конечной точке оно заставляет персонаж замедляться и останавливаться при достижении цели.

Это поведение состоит из двух этапов. Первый этап — когда персонаж находится далеко от цели; он работает точно так же, как поведение seek (персонаж на всей скорости движется к цели).

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


Когда персонаж входит в круг, он замедляется, пока не остановится в цели.



Замедление


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

// Если (velocity + steering) равно нулю, то движение отсутствует
velocity = truncate(velocity + steering, max_speed)
position = position + velocity

function truncate(vector:Vector3D, max:Number) :void {
	var i :Number;
	i = max / vector.length;
	i = i < 1.0 ? i : 1.0;
	vector.scaleBy(i);
}

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

// Вычисление требуемой скорости
desired_velocity = target - position
distance = length(desired_velocity)

// Проверка расстояния для определения того, находится ли персонаж
// внутри области замедления
if (distance < slowingRadius) {
    // Внутри области замедления
    desired_velocity = normalize(desired_velocity) * max_velocity * (distance / slowingRadius)
} else {
    // Снаружи области замедления
    desired_velocity = normalize(desired_velocity) * max_velocity
}

// На основании этого задаём управляющую силу
steering = desired_velocity - velocity

Если расстояние больше slowingRadius, то персонаж находится далеко от цели и его скорость должна оставаться равной max_velocity.

Если расстояние меньше slowingRadius, то персонаж вошёл в область замедления и его скорость должна снижаться.

Значение distance / slowingRadius изменяется в интервале от 1 (когда distance равно slowingRadius) до 0 (когда distance почти равно нулю). Линейное изменение заставляет скорость плавно снижаться:


Как сказано выше, движение персонажа выполняется следующим образом:

steering = desired_velocity - velocity
velocity = truncate (velocity + steering , max_speed)
position = position + velocity

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

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

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



Заключение


Поведение flee заставляет персонаж отдаляться от цели, а поведение arrival заставляет его замедляться и останавливаться в позиции цели. Оба поведения можно использовать для создания плавных паттернов убегания или следования. Кроме того, их можно сочетать, создавая ещё более сложные движения.

Часть 3. Поведение Wander


Блуждание


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

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

Управляющее поведение wander предназначено для создания реалистичного «естественного» движения, которое убедит игрока, что персонаж на самом деле живой и самостоятельно ходит.



Поиск и случайность


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

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


Интерактивное демо на Flash находится здесь.

Реализовать это можно следующим кодом:

// Вычисление силы блуждания
private function wander() :Vector3D {
   var now :Number = (new Date()).getTime();

   if (now >= nextDecision) {
	// Выбор случайной позиции для "цели"
   }

   // возврат управляющей силы, подталкивающей игрока
   // к цели (поведение seek)
   return seek(target);
}

// В игровом цикле обрабатываем силы и движение
// точно так же, как и раньше:
public function update() :void {
   steering = wander()
   steering = truncate (steering, max_force)
   steering = steering / mass
   velocity = truncate (velocity + steering , max_speed)
   position = position + velocity
}

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



Блуждание


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

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

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


Исходная точка силы смещения находится в центре круга и ограничена его радиусом. Чем больше радиус и расстояние от персонажа до круга, тем сильнее импульс, получаемый игроком в каждом кадре игры.

Эта сила смещения будет использоваться для воздействия на маршрут персонажа. Она используется для вычисления силы блуждания.



Вычисление позиции круга


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

// Константа CIRCLE_DISTANCE -
// это число, заданное где-то в другом месте кода.
// Код вычисления центра круга:
var circleCenter :Vector3D;
circleCenter = velocity.clone();
circleCenter.normalize();
circleCenter.scaleBy(CIRCLE_DISTANCE);

Вектор circleCenter — это клон (копия) вектора скорости, то есть он указывает в том же направлении. Он нормализован и умножен на скалярное значение (в нашем случае на CIRCLE_DISTANCE), что даст нам следующий вектор:




Сила смещения


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

var displacement :Vector3D;
displacement = new Vector3D(0, -1);
displacement.scaleBy(CIRCLE_RADIUS);
//
// Случайное изменение направления вектора
// изменением его текущего угла
setAngle(displacement, wanderAngle);
//
// Немного изменяем wanderAngle, чтобы
// он не имел то же значения
// в следующем кадре игры.
wanderAngle += (Math.random() * ANGLE_CHANGE) - (ANGLE_CHANGE * .5);

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

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


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



Сила блуждания


После вычисления центра круга и вектора смещения их нужно соединить, чтобы получить силу блуждания. Эта сила вычисляется сложением этих двух векторов:

var wanderForce :Vector3D;
wanderForce = circleCenter.add(displacement);

Визуально мы можем представить эти силы так:


Силу блуждания можно представить как вектор, исходящий из персонажа в точку на окружности круга. В зависимости от расположения этой точки сила блуждания будет толкать персонажа влево или вправо, сильно или слабо:


Чем больше сила блуждания сонаправлена с вектором скорости, тем меньше персонаж будет менять текущий маршрут. Сила блуждания будет действовать в точности как силы seek и flee: она будет подталкивать персонажа в нужном направлении.

Аналогично тому, как направление силы в поведениях seek и flee вычисляется на основании цели, направление блуждания вычисляется на основании случайной точки на окружности круга. Окончательный код силы блуждания выглядит так:

private function wander() :Vector3D {
   // Вычисление центра круга
   var circleCenter :Vector3D;
   circleCenter = velocity.clone();
   circleCenter.normalize();
   circleCenter.scaleBy(CIRCLE_DISTANCE);
   //
   // Вычисление силы смещения
   var displacement :Vector3D;
   displacement = new Vector3D(0, -1);
   displacement.scaleBy(CIRCLE_RADIUS);
   //
   // Случайное изменение направления вектора
   // изменением его текущего угла
   setAngle(displacement, wanderAngle);
   //
   // Немного изменяем wanderAngle, чтобы
   // он не имел то же значения
   // в следующем кадре игры.
   wanderAngle += Math.random() * ANGLE_CHANGE - ANGLE_CHANGE * .5;
   //
   // Вычисление и возврат силы блуждания
   var wanderForce :Vector3D;
   wanderForce = circleCenter.add(displacement);
   return wanderForce;
}

public function setAngle(vector :Vector3D, value:Number):void {
   var len :Number = vector.length;
   vector.x = Math.cos(value) * len;
   vector.y = Math.sin(value) * len;
}



Сложение сил


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

steering = wander()
steering = truncate (steering, max_force)
steering = steering / mass
velocity = truncate (velocity + steering , max_speed)
position = position + velocity

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


Интерактивное демо на Flash находится здесь.



Заключение


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

Часть 4. Pursuit и Evade


Что такое Pursuit?


Pursuit (преследование) — процесс следования за целью со стремлением поймать её. Важно заметить, что всё различие здесь заключается в слове «поймать». Если объект просто следует за целью, то ему достаточно повторять движения цели, следовательно, он будет идти по её следам.

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




Прогнозирование будущего


Как сказано в первой части туториала, движение вычисляется при помощи метода Эйлера:

position = position + velocity

Из этого непосредственно следует, что если известны текущие позиция и скорость персонажа, то мы можем предсказать, где он будет находиться через T обновлений игры. Допустим, персонаж движется по прямой и позиция, которую мы хотим спрогнозировать, находится находится спустя три обновления (T=3). Тогда будущая позиция персонажа будет такой:

position = position + velocity * T

Для правильного прогнозирования необходимо выбрать верное значение T. Если значение слишком большое, то преследователь будет гнаться за призраком. Если T слишком близко к нулю, то преследователь на самом деле не преследует, а просто следует за целью (прогнозирования нет).

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



Pursuing (преследование)


Поведение Pursuit работает примерно так же, как и Seek; единственное различие заключается в том, что преследователь стремится не к самой цели, а к её позиции в ближайшем будущем.

Допустим, все персонажи в игре представлены классом Boid. Тогда в следующем псевдокоде реализована основная идея поведения pursuit:

public function pursuit(t :Boid) :Vector3D {
  T :int  = 3;
  futurePosition :Vector3D = t.position + t.velocity * T;
  return seek(futurePosition);
}

После вычисления силы преследования её необходимо прибавить к вектору скорости, как и во всех предыдущих управляющих силах:

public function update() :void {
  steering = pursuit(target)
  steering = truncate (steering, max_force)
  steering = steering / mass
  velocity = truncate (velocity + steering , max_speed)
  position = position + velocity
}

На рисунке ниже показан этот процесс:


Преследователь (персонаж внизу) стремится к будущей позиции цели, следуя по траектории, описанной оранжевой кривой. Готовый результат показан ниже. Здесь в поведении pursuit используется T=30.


Интерактивно демо на Flash находится здесь.



Улучшение точности преследования


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

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

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

T = distanceBetweenTargetAndPursuer / MAX_VELOCITY

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

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

public function pursuit(t :Boid) :Vector3D {
  var distance :Vector3D = t.position - position;
  var T :int = distance.length / MAX_VELOCITY;
  futurePosition :Vector3D = t.position + t.velocity * T;
  return seek(futurePosition);
}

Поведение Pursuit использует динамическое T.


Интерактивное демо на Flash находится здесь.



Evading (избегание)


Поведение Evade противоположно поведению Pursuit. Вместо стремления к будущей позиции цели при поведении Evade будет убегать от этой позиции:


Код избегания практически аналогичен, меняется только последняя строка:

public function evade(t :Boid) :Vector3D {
  var distance :Vector3D = t.position - position;
  var updatesAhead :int = distance.length / MAX_VELOCITY;
  futurePosition :Vector3D = t.position + t.velocity * updatesAhead;
  return flee(futurePosition);
}

Интерактивное демо на Flash находится здесь.



Заключение


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

Часть 5. Менеджер движений


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



Комбинирование Steering Forces


Как говорилось ранее, каждое steering behavior создаёт результирующую силу (называемую «управляющей силой»), которая прибавляется к вектору скорости. Направление и величина этой силы направляют персонажа, заставляя его двигаться согласно паттерну (seek, flee, wander и так далее). В общем виде вычисления выглядят так:

steering = seek(); // здесь может быть любое поведение
steering = truncate (steering, max_force)
steering = steering / mass

velocity = truncate (velocity + steering , max_speed)
position = position + velocity

Так как управляющая сила является вектором, её можно прибавить к любому другому вектору (так же, как и к скорости). Однако настоящая «магия» заключается в том, что мы можем складывать разные управляющие силы. Достаточно сделать так:

steering = nothing(); // нулевой вектор, означающий "нулевую величину силы"
steering = steering + seek();
steering = steering + flee();
(...)
steering = truncate (steering, max_force)
steering = steering / mass

velocity = truncate (velocity + steering , max_speed)
position = position + velocity

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

Ниже показаны примеры скомбинированных управляющих сил, создающих единую управляющую силу:




Простое создание сложных паттернов


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

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

В steering behaviors все силы являются динамическими. Подразумевается, что они должны вычисляться при каждом обновлении игры, поэтому они естественным образом реагируют на изменения окружения.



Менеджер движений


Чтобы использовать несколько steering behaviors одновременно, полезно будет создать менеджер движений. Идея заключается в написании «чёрного ящика», который можно подключить к любой имеющейся сущности, что позволит ей выполнять эти поведения.

Менеджер имеет ссылку на сущность, к которой он подключается (на «хост»). Менеджер передаёт хосту набор методов, таких как seek() и flee(). При каждом вызове таких методов менеджер обновляет свои внутренние свойства для создания вектора управляющей силы.

После обработки всех вызовов менеджер прибавляет результирующую управляющую силу к вектору скорости хоста. Это изменяет величину и направление вектора скорости хоста в соответствии с активными поведениями.

На рисунке ниже показана архитектура:





Обобщаем элементы


У менеджера есть набор методов, каждый из которых представляет собой уникальное поведение. Для работы каждого поведения ему необходимо передавать различные фрагменты внешней информации.

Например, поведению Seek требуется точка в пространстве, используемая для вычисления управляющей силы, направленной к этому месту; для Pursuit требуется несколько фрагментов информации от цели, такие как текущая позиция и скорость. Точку в пространстве можно выразить как экземпляр Point или Vector2D. Оба они являются достаточно стандартными классами в любом фреймворке.

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

Допустим, интерфейс IBoid описывает сущность, которой может управлять менеджер движений, и любой класс в игре может использовать steering behaviors, если он реализует IBoid. Тогда этот интерфейс имеет следующую структуру:

public interface IBoid
{
	function getVelocity() :Vector3D;
	function getMaxVelocity() :Number;
	function getPosition() :Vector3D;
	function getMass() :Number;
}



Структура менеджера движений


Теперь, когда менеджер может взаимодействовать со всеми сущностями игры в обобщённом виде, можно создать его базовую структуру. Менеджер состоит их двух свойств (результирующая управляющая сила и ссылка на хост), а также из набора публичных методов, по одному на каждое поведение:

public class SteeringManager
{
	public var steering :Vector3D;
	public var host :IBoid;

	// Конструктор
	public function SteeringManager(host :IBoid) {
		this.host	= host;
		this.steering 	= new Vector3D(0, 0);
	}

	// Публичный API (по одному методу на каждое поведение)
	public function seek(target :Vector3D, slowingRadius :Number = 20) :void {}
	public function flee(target :Vector3D) :void {}
	public function wander() :void {}
	public function evade(target :IBoid) :void {}
	public function pursuit(target :IBoid) :void {}

	// Метод обновления. 
	// Должен вызываться после вызова всех поведений
	public function update() :void {}

	// Сброс внутренней управляющей силы.
	public function reset() :void {}

	// Внутренний API
	private function doSeek(target :Vector3D, slowingRadius :Number = 0) :Vector3D {}
	private function doFlee(target :Vector3D) :Vector3D {}
	private function doWander() :Vector3D {}
	private function doEvade(target :IBoid) :Vector3D {}
	private function doPursuit(target :IBoid) :Vector3D {}
}

При создании экземпляра менеджера он должен получать ссылку на хост, к которому он подключается. Она позволит менеджеру изменять вектор скорости хоста в соответствии с активными поведениями.

Каждое поведение представляется двумя методами — публичным и приватным. Возьмём для примера поведение Seek:

public function seek(target :Vector3D, slowingRadius :Number = 20) :void {}
private function doSeek(target :Vector3D, slowingRadius :Number = 0) :Vector3D {}

Публичный seek() будет вызываться, чтобы приказать менеджеру применить это конкретное поведение. У этого метода нет возвращаемого значения, а его параметры связаны с самим поведением, например, точка в пространстве. Внутри будет вызываться приватный метод doSeek(), а его возвращаемое значение, т.е. вычисленная управляющая сила этого конкретного поведения, будет прибавляться к свойству steering менеджера.

В следующем коде показана реализация seek:

// Публичный метод. 
// Получает цель и slowingRadius (используется для выполнения Arrival).
public function seek(target :Vector3D, slowingRadius :Number = 20) :void {
	steering.incrementBy(doSeek(target, slowingRadius));
}

// Сама реализация Seek (с добавленным кодом Arrival)
private function doSeek(target :Vector3D, slowingRadius :Number = 0) :Vector3D {
	var force :Vector3D;
	var distance :Number;

	desired = target.subtract(host.getPosition());

	distance = desired.length;
	desired.normalize();

	if (distance <= slowingRadius) {
		desired.scaleBy(host.getMaxVelocity() * distance/slowingRadius);
	} else {
		desired.scaleBy(host.getMaxVelocity());
	}

	force = desired.subtract(host.getVelocity());

	return force;
}

Все другие методы поведения реализуются очень похожим образом. Например, метод pursuit() будет выглядеть так:

public function pursuit(target :IBoid) :void {
	steering.incrementBy(doPursuit(target));
}

private function doPursuit(target :IBoid) :Vector3D {
	distance = target.getPosition().subtract(host.getPosition());

	var updatesNeeded :Number = distance.length / host.getMaxVelocity();

	var tv :Vector3D = target.getVelocity().clone();
	tv.scaleBy(updatesNeeded);

	targetFuturePosition = target.getPosition().clone().add(tv);

	return doSeek(targetFuturePosition);
}

Мы можем использовать код из предыдущих частей туториала. Единственное, что нужно сделать — адаптировать его в виде behavior() и doBehavior(), чтобы его можно было прибавлять к менеджеру движения.



Приложение и обновление управляющих сил


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

После вызова всех поведений менеджер должен применить текущую управляющую силу к скорости хоста, чтобы он двигался в соответствии с активными поведениями. Это выполняется в методе update() менеджера движений:

public function update():void {
	var velocity :Vector3D = host.getVelocity();
	var position :Vector3D = host.getPosition();

	truncate(steering, MAX_FORCE);
	steering.scaleBy(1 / host.getMass());

	velocity.incrementBy(steering);
	truncate(velocity, host.getMaxVelocity());

	position.incrementBy(velocity);
}

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



Применение


Допустим, у нас есть класс Prey, который должен двигаться с использованием steering behavior, но пока у него нет ни кода управления, ни менеджера движений. Его структура будет выглядеть так:

public class Prey
{
	public var position  :Vector3D;
	public var velocity  :Vector3D;
	public var mass      :Number;

	public function Prey(posX :Number, posY :Number, totalMass :Number) {
		position 	= new Vector3D(posX, posY);
		velocity 	= new Vector3D(-1, -2);
		mass	 	= totalMass;

		x = position.x;
		y = position.y;
	}

	public function update():void {
		velocity.normalize();
		velocity.scaleBy(MAX_VELOCITY);
		velocity.scaleBy(1 / mass);

		truncate(velocity, MAX_VELOCITY);
		position = position.add(velocity);

		x = position.x;
		y = position.y;
	}
}

При такой структуре экземпляры класса могут двигаться с помощью метода Эйлера. Чтобы мы могли использовать менеджер, классу нужно свойство, ссылающееся на менеджер движений, а также он должен применять интерфейс IBoid:

public class Prey implements IBoid
{
	public var position  :Vector3D;
	public var velocity  :Vector3D;
	public var mass      :Number;
	public var steering  :SteeringManager;

	public function Prey(posX :Number, posY :Number, totalMass :Number) {
		position 	= new Vector3D(posX, posY);
		velocity 	= new Vector3D(-1, -2);
		mass	 	= totalMass;
		steering 	= new SteeringManager(this);

		x = position.x;
		y = position.y;
	}

	public function update():void {
		velocity.normalize();
		velocity.scaleBy(MAX_VELOCITY);
		velocity.scaleBy(1 / mass);

		truncate(velocity, MAX_VELOCITY);
		position = position.add(velocity);

		x = position.x;
		y = position.y;
	}

	// Ниже идут методы, требуемые интерфейсу IBoid.

	public function getVelocity() :Vector3D {
		return velocity;
	}

	public function getMaxVelocity() :Number {
		return 3;
	}

	public function getPosition() :Vector3D {
		return position;
	}

	public function getMass() :Number {
		return mass;
	}
}

Метод update() нужно изменить соответствующим образом, чтобы менеджер тоже мог обновляться:

public function update():void {
	// Делаем так, чтобы prey блуждала по окружению...
	steering.wander();

	// Обновляем менеджер, чтобы он мог изменять вектор скорости prey.
	// Также менеджер будет выполнять интегрирование Эйлера, изменяя
	// вектор "позиции".
	steering.update();

	// После обновления менеджером своих внутренних структур, нам достаточно
	// обновить нашу позицию в соответствии с вектором "позиции".
	x = position.x;
	y = position.y;
}

Все поведения можно использовать одновременно, поскольку вызовы всех методов выполняются до вызова update() менеджера, который прилагает всю аккумулированную управляющую силу к вектору скорости хоста.

В коде ниже показана другая версия метода update() Prey, но на этот раз она будет стремиться (seek) к точке на карте и избегать (evade) другого персонажа (и всё это одновременно):

public function update():void {
	var destination :Vector3D = getDestination(); // место, к которому стремимся
	var hunter :IBoid = getHunter(); // получаем сущность, которая на нас охотится

	// Стремимся к цели и избегаем охотника (одновременно!)
	steering.seek(destination);
	steering.evade(hunter);

	// Обновляем менеджер, чтобы он изменил вектор скорости prey.
	// Менеджер также выполняет интегрирование Эйлера, меняя
	// вектор "позиции".
	steering.update();

	// После обновления менеджером своих внутренних структур, нам нужно только
	// обновить нашу позицию согласно вектору "позиции".
	x = position.x;
	y = position.y;
}



Пример


В показанном ниже примере представлено сложный паттерн движения, в котором сочетается несколько поведений. В сцене есть два типа персонажей: Hunter (охотник) и Prey (жертва).

Охотник будет преследовать жертву, если подберётся достаточно близко. Он преследует её, пока у него остаётся энергия (stamina). Когда энергия заканчивается, преследование прекращается и охотник начинает блуждать, пока не восстановит уровень энергии.

Вот метод update() класса Hunter:

public function update():void {
	if (resting && stamina++ >= MAX_STAMINA) {
		resting = false;
	}

	if (prey != null && !resting) {
		steering.pursuit(prey);
		stamina -= 2;

		if (stamina <= 0) {
			prey = null;
			resting = true;
		}
	} else {
		steering.wander();
		prey = getClosestPrey(position);
	}

	steering.update();

	x = position.x;
	y = position.y;
}

Жертва будет неограниченно блуждать. Если охотник подберётся слишком близко, то она будет избегать его. Если курсор мыши близко и рядом нет охотника, жертва будет стремиться к курсору.

Вот как выглядит метод update() Prey:

public function update():void {
	var distance :Number = Vector3D.distance(position, Game.mouse);

	hunter = getHunterWithinRange(position);

	if (hunter != null) {
		steering.evade(hunter);
	}

	if (distance <= 300 && hunter == null) {
		steering.seek(Game.mouse, 30);

	} else if(hunter == null){
		steering.wander();
	}

	steering.update();

	x = position.x;
	y = position.y;
}



Заключение


Менеджер движений очень полезен для одновременного управления несколькими steering behaviors. Сочетание таких поведений может создавать очень сложные паттерны движения, позволяющие игровой сущности стремиться к одному объекту и одновременно избегать другого.

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

Вторая часть статьи здесь.

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


  1. Jeka178RUS
    15.05.2018 22:33

    Спасибо, просто, но интересно!


  1. Watcover3396
    15.05.2018 22:33

    Благодарю, очень интересно и полезно =)


  1. dsapsan
    16.05.2018 11:07

    Интересно, как это всё соотносится с Unity NavMesh агентами, их использование даёт аналогичный результат, но над ними гораздо меньше контроля.


    1. findoff
      16.05.2018 14:00

      Не работал с юнити, но рискну предположить, что nav mesh в оригинальном смысле используется для получения следущей точки навигации, а движение к ней происходит примерно так как описано в статье. (Сам не читал подобного статье, и для себя рассматривал все эти операции в другом ключе, что в прочем не меняло их сути)


  1. Litovets
    16.05.2018 11:07

    Спасибо, напишу реализацию на Юнити