Создание игры на Blend4Web. Зачатки интеллекта

Даже самый примитивный игровой персонаж должен обладать хоть какими-нибудь “мозгами”. Рыбки априори не блещут интеллектом, но кое-что они все же должны уметь — передвигаться, “смотреть”, убегать или нападать. От них не требуется искать укрытия или “морщить лоб” для умной ответной фразы. Выглядит просто, но легко ли сделать?

Разговор пойдет о реализации AI силами JavaScript и Blend4Web. Поставленные задачи, способы их решения или вынужденные пути обхода — все это на примере разрабатываемого живого, игрового проекта.

Теоретические рассуждения


Начну со вступления. Разрабатываемая игра — это горизонтальный скроллер, где главный персонаж перемещается в одном и том же направлении (слева-направо). В качестве героя выступает рыбка, противостоят ей остальные морские твари. Причем игрок не влияет на героя, а лишь помогает ему в решении имеющихся задач. Поэтому все персонажи должны обладать мало-мальским разумением, чтобы играть было интересно. Впрочем, от них требуется не так много. Я даже замечу, что враги гораздо умнее главного героя. Собственно, что взять с простой золотой рыбки! Эта статья посвящена отнюдь не ей, а скромным и голодным обитателям глубин.

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

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

7d75e70f0265469f9d6126a0cdbd30c5.jpg

То, что на рисунке — это мое решение по производительности. Как видите, игровое поле поделено на зоны. При старте игры все объекты загружаются в память, раскидываются по спаунам и “замораживаются”. В зависимости от нахождения главного героя, происходит активация объектов конкретного сектора. Допустим, на рисунке главный персонаж изображен в нулевой зоне. Соответственно активируются объекты отсеков 2 и 3. Затем герой переплывает в сектор 1 — подключается номер четыре и т.д. Активация двух зон одновременно, вызвана необходимостью дать юнитам время, хотя бы для отплытия от мест генерации. Так же происходит и отключение. Тут, правда, немного хитрее, ведь нужно учитывать, что хищники в порыве азарта могут пересечь границы своей зоны.

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

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

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

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

Что же касается агрессивных персонажей, то атаковать они могут только в пределах определенного времени. В дальнейшем они “теряют” интерес и уплывают в любом направлении.

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

Практический подход


Теория — дело хорошее. Однако, зачастую на практике оказывается совсем не так безоблачно и многие, казалось бы, правильные решения, требуют значительной корректировки. Многое зависит от функциональности API движка и, разумеется, профессионализма программиста. Последним я, увы, похвастаться не могу, поэтому большое спасибо тем разработчикам Blend4Web, что выслушивали мои иногда странные вопросы и помогали в меру возможного.

Итак, используемый движок — Blend4Web, язык программирования JavaScript, целевая платформа — веб. Это не первая моя статья на тему создания игры. Ознакомьтесь с предыдущими материалами, если что-то будет непонятно (список в порядке времени публикации):

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

Обработка AI осуществляется независимо от остального кода и, по сути, требует только инициализации — запроса на генерацию объектов. В основном файле game_app.js имеется несколько соответствующих строк:

…
var m_fish = require("game_fish");
var m_game_config  = require("game_config");
...
var number = 10;     
var type_fish = m_game_config.FISH1;
m_fish.new_fishes(number, type_fish);
...


С помощью m_fish.new_fishes отсылается запрос на создание рыб определенного типа (переменная type_fish) и нужного количества (number). Собственно, от game_app больше ничего не требуется, поэтому переходим к теме разговора.

Итак, сначала происходит вызов функции new_fishes, ответственной за запуск механизма по генерации рыб:

var _type_fish;
var _elapsed_sensor;
var APP_ASSETS_PATH = m_cfg.get_std_assets_path() + "waterhunter/";

exports.new_fishes = function(number, type_fish) {
    _type_fish = type_fish;
    _elapsed_sensor = m_ctl.create_elapsed_sensor();
    //load fish
    var i;
    for (i = 0; i < number; i++) {
        m_data.load(APP_ASSETS_PATH +type_fish, fish_loaded_cb,null,true,true);
    }
}


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

Константа APP_ASSETS_PATH содержит путь к ресурсам игры (json, медиа и т.д.), которую использует функция m_data.load для загрузки модели рыбы. Подробней узнать о тонкостях работы с load можно из прошлого урока. Добавлю, что объект после загрузки отключается и становится невидимым.

Вообще, для многочисленных одинаковых объектов принято выполнять копирование (инстансинг) уже загруженного экземпляра. Совсем недавно, в API Blend4Web появились соответствующие функции. Вот только они ограничиваются простым копированием геометрии и не позволяют работать с более сложной модельной иерархией. Это вынуждает использовать функцию load. Хотя она имеет хорошие возможности для управления процессом загрузки, но работает достаточно медленно. Ради интереса, я попытался загрузить с ее помощью 50 копий рыб — пришлось ждать около 7 секунд, что для динамично создаваемых объектов очень плохо.

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

Следующая функция m_ctl.create_elapsed_sensor() выглядит очень интересно. С её помощью создается специальный объект-сенсор, который генерирует событие с определенной периодичностью и способен выдавать время между рендером текущего кадра и предыдущего. Проще говоря, это пригодится для движения или вращения объекта с одинаковой скоростью, вне зависимости от мощности системы. Но, чтобы работать с ним, нужно сначала “подписаться” на данное событие. Об этом чуть дальше.

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

function fish_loaded_cb (data_id) {
    //генерация случайного числа из 4
    var spawn_number = Math.floor(Math.random() * (5 - 1)) + 1;

    //поиск в сцене объекта-маркера 
    var spawn = m_scenes.get_object_by_name (m_game_config.SPAWN_FISH[spawn_number-1],0);
  
  // координаты spawn
    var spawn_coord = new Float32Array(3);
    spawn_coord = m_trans.get_translation (spawn, spawn_coord);

   //поиск корневого объекта модели
    var obj_fish = m_scenes.get_object_by_name ("fish", data_id);

 //перенос модели в точку spawn
    m_trans.set_translation_v (obj_fish,spawn_coord);
...


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

Итак, в строке с Math.random генерируется случайное число от 1 до 4 и сохраняется в переменной spawn_number (в тестовом примере только 4 спаун-объекта). В файле game_config.js находится массив spawn-объектов:

exports.SPAWN_FISH = ["spawn1","spawn2","spawn3","spawn4"];


Функция get_object_by_name ищет в базовой сцене объект с именем, хранящемся в массиве:

var spawn = m_scenes.get_object_by_name (m_game_config.SPAWN_FISH[spawn_number-1],0);


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

В API движка имеются функции show_object(obj) и enable_simulation(obj). Они, как раз и активируют объект, указанный в скобках. При этом совершенно не учитывается иерархия. Это происходит из-за особенностей создания и настройки персонажа (подробно рассказывается в предыдущей статье). К примеру, иерархия модели может выглядеть так:

  • Корневой объект-коллайдер (физическое тело)
  • сама модель
  • вспомогательные объекты Empty
  • дочерние модели со собственной иерархией

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

Было бы неплохо добавить в API функции, выполняющие тоже самое, только со всей иерархией сразу. Указал корневой объект и получил результат.

Так как этого нет, то приходится перебирать все объекты и активировать их в цикле:

//загружаем массив всех объектов в сцене с номером ID
var objs = m_scenes.get_all_objects("ALL", data_id);
    for (var i = 0; i < objs.length; i++) {
        var obj = objs[i];
//включаем визуализацию для mesh
        if (m_obj.is_mesh(obj)) m_scenes.show_object(obj);
//включаем физику для физического тела
        if (m_phy.has_physics(obj))  m_phy.enable_simulation(obj);
    }
...


Теперь физика и визуализация включены. Осталось сделать главное — добавить рыбкам немного “мозгов”.

Я решил поступить следующим образом. Сделать шаблон для хранения локальных данных персонажа и уже создавать на его основе рабочий экземпляр. Такая заготовка содержит ссылки на объекты в иерархии персонажа, его логический статус (state) и даже некоторые временные переменные. В качестве data_id передается номер загруженной сцены-модели.

//заготовка
function template_fish (data_id) {
    this.root = m_scenes.get_object_by_name ("fish", data_id);
    this.body = m_scenes.get_object_by_name ("body", data_id);
    this.state = m_game_config.STATE_FISH_INI;
 ...
}


Собственно объект создается следующим образом:

var clone_fish = new template_fish(data_id); 


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

m_ctl.create_sensor_manifold(clone_fish, "FISH", m_ctl.CT_CONTINUOUS, [_elapsed_sensor], null, fish_ai_cb);

//сохраняем ссылку на объект в массиве
_fishes.push (clone_fish);


Обратите внимание, что данное множество (sensor_manifold) вызывает функцию fish_ai_cb, при наступлении события. Именно в ней сконцентрирована основная часть логики. Переключение действий выполняется в соответствии с локальной переменной объекта state:

function fish_ai_cb (clone_fish) {
    case m_game_config.STATE_FISH_INI:
            break;

    case m_game_config.STATE_FISH_MOVE:
            break;

    case m_game_config.STATE_FISH_ROTATE:
            break;
...  
    default:
            break;
    } 


Все возможные значения state для удобства вынесены в конфигурационный файл:

//State fish
    exports.STATE_FISH_INI = 0; 
    exports.STATE_FISH_MOVE = 1; 
    exports.STATE_FISH_ROTATE = 5; 
    exports.STATE_FISH_WAIT = 3; 
...


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

Луч генерируется только при движении рыбы вперед. Во всех остальных случаях, в этом нет необходимости.

Итак, есть следующий код, заключенный в case STATE_FISH_MOVE:

…
//перемещение персонажа вперед
m_phy.set_character_move_dir(clone_fish.root,1, 0);
…
//запуск ray test
var to = new Float32Array(3);
var trans = new Float32Array(3);
m_trans.get_translation(clone_fish.root, trans);
to = [0,0,1];
clone_fish.ray_id = m_phy.append_ray_test(clone_fish.root, trans, to, "ANY", ray_test_cb, true);
...


В соответствие с назначением логического блока, здесь происходит перемещение объекта. Вообще, Blend4Web предлагает для этой цели два модуля: physics (с использованием физики) и transform (обычное изменение векторов). Так как, я использую коллайдеры, а также функцию raycast, то все перемещения и вращения объектов нужно выполнять только с помощью физики.

Функция set_character_move_dir(clone_fish.root,1, 0) как раз и перемещает объект в указанном направлении (вперед). Причем это происходит независимо от основного потока. То есть, вызвав единожды эту функцию, вы получите бесконечное перемещение (скорость и другие параметры физики устанавливаются в Blender. См. урок “Подготовка персонажа для Blend4Web”).

Дальше по коду стоит генерация raycast. Функция append_ray_test требует ряд параметров:

  • Ссылка на объект, генерирующего луч.
  • Начальная позиция. Совпадает с координатами объекта.
  • Вектор направления. Вперед по локальной оси с ограничением длины в единицу.
  • Идентификатор коллайдеров. В данном случае, реагирование на все варианты.
  • Название функции, возвращающей результат сканирования.
  • Режим работы сканера (TRUE — единичный вызов, FALSE — бесконечное сканирование).

В соответствие с указанными параметрами происходит следующее. Функция “отсылает” луч в указанном направлении (to). При обнаружении любого объекта происходит вызов ray_test_cb. После этого, raycast прекращает свою работу. Учтите, что append_ray_test также работает в асинхронном режиме.

Итак, пока установлен режим state = “move”, рыба двигается вперед и одновременно “прощупывает” пространство перед носом. Если обнаружено препятствие, то движение прекращается, статус меняется на “rotate” и, соответственно, выполняется разворот.

Практически это решено с использованием костылей. Проблема была в том, что колбэк ray_test_cb не возвращает ссылку на вызывавший raycast объект. А ведь именно для него нужно изменить режим state. Хорошо, что при создании raycast создается идентификационный номер “луча”, который затем передается в колбэке.

Пришлось создавать специальную переменную ray_id для объекта, чтобы хранить id функции append_ray_test. Путем простого перебирания id_ray всех рыб, находится виновник и устанавливается state:

function ray_test_cb (id, hit_fract, obj_hit) {
        for (var i = 0; i < _fishes.length; i++) {
            if (_fishes[i].ray_id ==id) _fishes[i].state = m_game_config.STATE_FISH_ROTATE;
        }    
}


Сам разворот выполняется так:

...
var elapsed = m_ctl.get_sensor_value(clone_fish, "FISH", 0);
m_phy.character_rotation_inc(clone_fish.root, elapsed * -4, 0);    
...


Функция character_rotation_inc(obj, h_angle, v_angle)
поворачивает объект на указанный угол, где h_angle — значение угла по горизонтали, v_angle — вертикали. И вот, внимание! Для одинаковой скорости вращения, вне зависимости от мощности компьютера, необходимо использовать возвращаемое время от сенсора elapsed_sensor (первая строка). Умножьте полученное значение на требуемый угол поворота.

Итог работы


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

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

Неплохо было бы создать константы направления векторов, типа Forward, Back, Left, Right. Дело в том, что координатные оси Blender и WebGL не совпадают. Быстрее написать Vec3.Forward, нежели экспериментировать и искать подходящее сочетание [0,0,1].

Генератор случайных чисел. Да, можно написать Math.floor(Math.random() * (5 — 1)) + 1, но было бы проще брать уже готовую функцию.

Тем не менее, код был написан, рыбки плавают и даже немного “шевелят мозгами”. Продолжение следует…

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

© Habrahabr.ru