Создаем игру. Часть 11. Искусственный интеллект
2015-04-20
Что собой представляет «Сказ о Пятигоре» с точки зрения геймплея? Это аркадная игра, в которой персонаж старается избегать различных опасностей, возникающих на локации, и в то же время сражается с врагами, которые всеми правдами и неправдами хотят его одолеть. Чтобы этот процесс был достаточно интересным, оппоненты должны иметь какое-то поведение, и игрок должен приложить определенные усилия, чтобы победить их. Давайте посмотрим, каким образом мы наделили големов собственным интеллектом.
Жизненный цикл голема
Перед тем как приступить к программированию, нужно определиться с тем, какие действия будут требоваться от наших монстров. Для этого разберём жизненный цикл голема.
- Вначале происходит рождение голема. Пока он полностью не вылезет из лавы, его интеллект не начнёт работу.
- Обосновавшись на острове, голем начинает патрулирование. Мы хотим, чтобы он постоянно курсировал из одной точки на острове в другую и при этом не падал в лаву. Если на остров попадает игрок, то голем тут же переключается на него и пытается сократить дистанцию для нанесения урона. Если персонажа нет на острове, то последнее, что может отвлечь голема — это магические камни. Если обелиск рядом с големом частично или полностью заполнен камнями, то голем пойдёт к обелиску с намерением разрушить камни, чтобы не дать персонажу захватить остров.
- Если в области действия голема находятся персонаж или обелиск с камнями, то он произведёт атаку.
- Окончить свой жизненный цикл голем может только одним способом — погибнуть от руки игрока.
Затем голем помечается как свободный, и цикл повторяется заново.
Приступим к реализации этих пунктов!
Составные части голема
Чтобы лучше понимать, как подойти к воссозданию нужного поведения, рассмотрим, из каких частей состоит голем.
- Арматура для скелетной анимации
- Тело голема для рендеринга
- Физический объект для расчетов
Bсе перечисленные объекты в свою очередь являются дочерними для Empty-объекта, к которому они привязаны на сцене, поскольку на нём включена опция Relative Group Coords. Таким образом, если перемещать этот Empty - за ним автоматически будут следовать и все нужные объекты. Значит, только его позицию мы и будем изменять с помощью API.
Инициализация големов
Вся работа с големами вынесена в отдельный модуль golems.js. Для удобства работы данные каждого голема запаковываются в специальный объект-обертку и сохраняются в глобальном массиве _golems_wrappers:
function init_golem_wrapper(body, rig, empty) {
return {
body: body, // Физический объект для тела голема
rig: rig, // Объект-арматура для анимации
empty: empty, // Еmpty-объект, к которому привязана группа голема
...
state: m_conf.GS_NONE,
...
}
}
Мы не будем рассматривать все свойства этого объекта. Здесь важно обратить внимание на то, что в первых трёх полях обертка хранит ссылки на реальные объекты, относящиеся к данному голему.
Сюда входят: физический объект голема body, арматура rig и объект empty, к которому привязаны остальные объекты. Таким образом, имея данную обертку, можно совершать все действия, связанные с физикой, анимацией и перемещением конкретного голема.
Ещё одно важное свойство, state - состояние, в котором находится голем. У него может быть четыре значения: GS_GETTING_OUT - голем вылезает из лавы, GS_WALKING - голем патрулирует, GS_ATTACKING - голем в процессе атаки, GS_NONE - голем в неактивном состоянии.
Каркас для модели поведения
Первым делом, зарегистрируем для каждого голема сенсорное множество:
m_ctl.create_sensor_manifold(golem_wrapper, "GOLEM", m_ctl.CT_CONTINUOUS,
[elapsed_sensor], null, golem_ai_cb);
Обработчик golem_ai_cb() будет вызываться каждый кадр. Рассмотрим его:
function golem_ai_cb(golem_wrapper, id) {
if (golem_wrapper.hp <= 0) {
kill(golem_wrapper);
return;
}
switch (golem_wrapper.state) {
case m_conf.GS_ATTACKING:
process_attack(golem_wrapper);
break;
case m_conf.GS_WALKING:
var elapsed = m_ctl.get_sensor_value(golem_wrapper, id, 0);
move(golem_wrapper, elapsed);
break;
default:
break;
}
}
В случае падения HP голема ниже нуля запускается функция обработки его смерти kill(). В ней сбрасываются все параметры обертки голема и запускаются соответствующие анимация и звуки. Её код мы не будем рассматривать в виду его тривиальности. Второй блок - это ключевая часть интеллекта нашего голема. Здесь происходит обработка его перемещения или атаки в зависимости от значения golem_wrapper.state. Если голем вылезает из лавы или находится в неактивном состоянии, никакой обработки в этой функции не произойдет.
Рождение голема
Создадим сенсорное множество, отвечающее за перерождение големов:
m_ctl.create_sensor_manifold(null, "GOLEMS_SPAWN", m_ctl.CT_CONTINUOUS,
[elapsed_sensor], null, golems_spawn_cb);
Его обработчик будет иметь следующий вид:
function golems_spawn_cb(obj, id) {
var golem_wrapper = get_first_free_golem();
if (!golem_wrapper) // нет доступных големов
return;
var elapsed = m_ctl.get_sensor_value(obj, id, 0);
_golems_spawn_timer -= elapsed;
if (_golems_spawn_timer <= 0) {
_golems_spawn_timer = m_conf.GOLEMS_SPAWN_INTERVAL;
var island_id = get_random_available_island();
if (island_id == null) // нет свободных островов
return;
spawn(golem_wrapper, island_id, spawn_points, spawn_quats);
}
}
Всего на сцене одновременно может находиться до трех големов. Для того, чтобы очередной голем поднялся из лавы, должны выполняться следующие условия:
- Остров не должен быть захвачен игроком
- На острове не должно быть другого голема
- Должен быть хотя бы один свободный голем
С помощью функции get_first_free_golem() получаем обертку доступного голема, если такие имеются. Затем снижаем счетчик времени для перерождения големов, и, если он падает ниже нуля, сбрасываем его и проверяем, есть ли подходящий остров с помощью функции get_random_available_island(). Когда мы убедимся, что эти условия выполнены, - запускаем процесс рождения голема.
Нам известен только остров, на котором мы хотим разместить голема. Нужно решить, в какую именно точку его поместить. Для этого расставим на каждом острове специальные маркеры в виде Empty-объектов. Мы будем выбирать из них один произвольный и помещать в его позицию нового голема. Эта информация вычисляется один раз при инициализации и заносится в соответствующие массивы spawn_points и spawn_quats, которые затем пересылаются в функцию spawn():
function spawn(golem_wrapper, island_id, spawn_points, spawn_quats) {
var golem_empty = golem_wrapper.empty;
var num_spawns = spawn_points.length / m_conf.NUM_ISLANDS;
var spawn_id = Math.floor(Math.random() * num_spawns);
var spawn_pt_id = num_spawns * island_id + spawn_id;
var spawn_point = spawn_points[spawn_pt_id];
var spawn_quat = spawn_quats[spawn_pt_id];
m_trans.set_translation_v(golem_empty, spawn_point);
m_trans.set_rotation_v(golem_empty, spawn_quat);
set_state(golem_wrapper, m_conf.GS_GETTING_OUT)
m_sfx.play_def(golem_wrapper.getout_speaker);
golem_wrapper.island_id = island_id;
golem_wrapper.dest_pos.set(spawn_point);
}
Как видим, golem_wrapper в этой функции отлично справляется со своей задачей. С помощью модуля transform (m_trans) Empty-объект голема позиционируется в произвольно выбранной точке рождения на нужном острове. Затем состояние переключается на GS_GETTING_OUT . В функции set_state() голему назначится анимация, и, как только она завершится, состояние сменится на GS_WALKING и запустится анимация ходьбы. В завершение проигрывается звук вылезания голема, и назначаются свойства по-умолчанию для обертки. Голем родился и готов к бою!
Патрулирование
Исходя из обозначенных требований к перемещению голема, можно выделить два варианта его движения.
- Голем просто курсирует по острову в разных направлениях
- Голем движется к цели для дальнейшей атаки
Первый пункт предполагает несколько возможных реализаций:
- Перед големом располагается одна или несколько точек, которые постоянно "стреляют" вниз лучами и пытаются определить тип поверхности, на которой окажется голем. Основываясь на этой информации, голем может принять решение об изменении направления. Этот вариант нам не подходит, поскольку велика вероятность ошибки, и этот метод предполагает серьезную вычислительную нагрузку.
- Метод аналогичен предыдущему, но для расчета используется физический объем голема. При соприкосновении со специальным невидимым объектом по периметру острова голем отворачивает от точки столкновения. Этот метод быстрее, но также может приводить к непредсказуемым результатам вследствие того, что с помощью API мы можем получить только первую точку соударения.
- И наконец выбранный нами метод. Использовать заранее проставленные позиции и выбирать из них одну случайным образом. Хоть в таком случае голем и не будет на самом деле "решать", куда ему идти, и потребуется немного метаинформации (дополнительные координаты), этот метод гораздо быстрее и исключает возможные ошибки, связанные с физикой, а стабильность - это то, что нас интересует в первую очередь.
Схематично такое поведение можно описать следующим анимированным изображением:
Добавим с помощью новых Empty-объектов опорные точки для траектории перемещения голема. Для оптимизации возьмем одну точку из уже имеющихся на островах координат перерождения. После этих манипуляций у нас будет следующий набор позиций (обозначены красными кругами):
Следует учитывать, что если расставить точки слишком близко друг к другу, могут возникнуть неприятные ошибки, когда голем будет стараться попасть в точку, находящуюся у него прямо под боком, и начнет просто вращаться на месте, не достигая цели.
Функция, отвечающая за перемещение голема, имеет следующий вид:
function move(golem_wrapper, elapsed) {
var char_wrapper = m_char.get_wrapper();
var island_id = golem_wrapper.island_id;
if (char_wrapper.island == island_id && char_wrapper.hp > 0) {
attack_target(golem_wrapper, char_wrapper.phys_body, elapsed);
golem_wrapper.last_target = m_conf.GT_CHAR;
} else if (m_obelisks.num_gems(island_id)) {
var obelisk = get_obelisk_by_island_id(island_id);
attack_target(golem_wrapper, obelisk, elapsed);
golem_wrapper.last_target = m_conf.GT_OBELISK;
} else {
patrol(golem_wrapper, elapsed);
}
}
Здесь отчетливо видна придуманная ранее стратегия. Голем будет атаковать игрока, если он находится на одном острове с големом или же обелиск, если в нём есть магические камни. В остальных случаях будет вызываться функция patrol(), и голем будет перемещаться между опорными точками на острове. Посмотрим именно на эту функцию:
function patrol(golem_wrapper, elapsed) {
set_dest_point(golem_wrapper);
rotate_to_dest(golem_wrapper, elapsed);
translate(golem_wrapper, elapsed);
}
Вначале с помощью функции set_dest_point() устанавливается текущая точка, в которую следует голем. Если голем достаточно близко к точке назначения, то будет выбрана новая, в противном случае, он сохранит старую точку. После того как нужная позиция записана в поле golem_wrapper.dest_pos, голем поворачивается к ней с помощью функции rotate_to_dest() и затем просто перемещается в направлении своего взгляда функцией translate(). Все эти функции не имеют прямого отношения к интеллекту големов, поэтому их мы рассматривать не будем. Просто будем иметь в виду, что при патрулировании происходит постоянное изменение расположения Empty-объекта.
Атака
Снова обратим своё внимание на функцию move() и посмотрим, каким образом голем атакует выбранные цели. Эти действия описаны в функции attack_target(), которая является общей для атаки игрока и обелисков. Отличаются только значения last_target - GT_CHAR для персонажа или GT_OBELISK для обелиска:
function attack_target(golem_wrapper, target, elapsed) {
var golem_empty = golem_wrapper.empty;
var trans = _vec3_tmp;
var targ_trans = _vec3_tmp_2;
m_trans.get_translation(golem_empty, trans);
m_trans.get_translation(target, targ_trans);
targ_trans[1] = trans[1];
golem_wrapper.dest_pos.set(targ_trans);
var dist_to_targ = m_vec3.distance(trans, targ_trans);
var angle_to_targ = rotate_to_dest(golem_wrapper, elapsed);
if (dist_to_targ >= m_conf.GOLEM_ATTACK_DIST)
translate(golem_wrapper, elapsed);
else if (angle_to_targ < 0.05 * Math.PI)
perform_attack(golem_wrapper);
}
Модуль transform и функция поворота rotate_to_dest() помогают найти расстояние и угол до выбранной цели. Обратите внимание, что поскольку мы приравниваем Y компоненту targ_trans к Y компоненте trans, можно сказать, что мы работаем с двумерными координатами, и голем ориентируется на плоскости. Если расстояние превышает дальность атаки голема GOLEM_ATTACK_DIST, то голем будет двигаться к цели, используя уже известную функцию translate(). Если дистанция достаточно маленькая, и при этом угол angle_to_targ меньше заданного предела, голем произведет атаку. Посмотрим на код функции perform_attack():
function perform_attack(golem_wrapper) {
var golem_empty = golem_wrapper.empty;
var at_pt = golem_wrapper.attack_point;
var trans = _vec3_tmp;
var cur_dir = _vec3_tmp_2;
golem_wrapper.attack_done = false;
set_state(golem_wrapper, m_conf.GS_ATTACKING)
m_trans.get_translation(golem_empty, trans);
m_vec3.scaleAndAdd(trans, cur_dir, m_conf.GOLEM_ATTACK_DIST, at_pt);
at_pt[1] += 0.3; // немного приподнимем точку атаки
if (m_sfx.is_play(golem_wrapper.walk_speaker))
m_sfx.stop(golem_wrapper.walk_speaker);
m_sfx.play_def(golem_wrapper.attack_speaker);
}
В поле golem_wrapper.attack_point записываются координаты точки, которую атакует голем. Их мы обработаем в дальнейшем. Поле golem_wrapper.attack_done нам также понадобится в обработчике атаки. set_state() изменяет текущее состояние и устанавливает нужную анимацию. В завершение обрабатываются звуки: отключается звук ходьбы walk_speaker, если он проигрывался, и включается звук удара attack_speaker.
Теперь вспомним об основном обработчике интеллекта. Там мы видели следующий вызов:
switch (golem_wrapper.state) {
case m_conf.GS_ATTACKING:
process_attack(golem_wrapper);
...
}
Таким образом, при изменении состояния голема на атакующее каждый кадр будет вызываться функция process_attack() до тех пор, пока его состояние не изменится на какое-либо другое. Посмотрим на обработку атаки:
function process_attack(golem_wrapper) {
if (!golem_wrapper.attack_done) {
var frame = m_anim.get_frame(golem_wrapper.rig);
if (frame >= m_conf.GOLEM_ATTACK_ANIM_FRAME) {
if (golem_wrapper.last_target == m_conf.GT_CHAR)
process_golem_char_attack(golem_wrapper);
else if (golem_wrapper.last_target == m_conf.GT_OBELISK)
process_golem_obelisk_attack(golem_wrapper);
golem_wrapper.attack_done = true;
}
}
}
В качестве основного критерия используется флаг attack_done. Анимация атаки имеет определенную длину, а мы хотим, чтобы обработка удара происходила только один раз в определенный момент. Для этого мы проверяем достижение анимацией нужного кадра GOLEM_ATTACK_ANIM_FRAME и затем, в зависимости от типа атакуемой цели last_target вызывается либо функция process_golem_char_attack() либо process_golem_obelisk_attack(). Содержимое этих функций для нас не очень важно, и, пожалуй, в этом уроке уже достаточно кода. В первой функции проверяется успешность атаки по игроку, исходя из его расстояния до точки, в которую бьет голем, и при положительном результате HP персонажа снижается. Во второй функции не требуется даже проверки, потому что обелиск не может никуда переместиться.
Вот таким образом организовано поведение големов. Можно его разнообразить, добавив какие-то интересные способности и обрабатывая их как новые типы состояния golem_wrapper.state, но мы пока остановимся на том, что есть, поскольку наши големы не обладают никакими сверхестественными навыками и умеют только ходить и бить. Но и этого достаточно, чтобы подпортить жизнь игроку.
Ссылка на приложение в отдельном окне
Исходные файлы моделей находятся в составе бесплатного дистрибутива Blend4Web SDK.
Изменения
[2015-04-20] Изначальная публикация.