События

Создаем игру. Часть 5: Опасный мир

2014-07-11

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

Новые объекты на сцене в Blender

Подготовим новые игровые объекты в файле blend/lava_rock.blend:

1) Три разновидности падающих глыб: rock_01, rock_02, rock_03.

2) Дымовые шлейфы для этих глыб - эмиттеры трех одинаковых систем частиц, привязанные к объектам глыб связью родитель-потомок: smoke_emitter_01, smoke_emitter_02, smoke_emitter_03.

3) Cистемы частиц для эффекта взрыва глыб: burst_emitter_01, burst_emitter_02, burst_emitter_03.

4) Метки, появляющиеся под падающими камнями: mark_01, mark_02, mark_03.

О том, как были созданы эти объекты, мы расскажем в одной из следующих статей.

Для удобства скомпонуем все эти объекты в одну группу lava_rock, и добавим ее по ссылке в основной файл game_example.blend. Увеличим количество всех объектов вдвое - сделаем копию пустого объекта с прикрепленной к нему группой. В итоге на сцене будет присутствовать пул из 6 падающих глыб, доступ к которым мы получим по именам двух пустых объектов - lava_rock и lava_rock.001.

Полоса здоровья

Добавим четыре HTML элемента для отрисовки полосы здоровья.

<div id ="life_bar">
    <div id ="life_bar_main"></div>
    <div id ="life_bar_green"></div>
    <div id ="life_bar_red"></div>
    <div id ="life_bar_mid"></div>
</div>

Эти элементы будут перемещаться при получении персонажем урона. В файл game_example.css добавим соответствующие описания стилей.

Константы и переменные

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

var ROCK_SPEED = 2;
var ROCK_DAMAGE = 20;
var ROCK_DAMAGE_RADIUS = 0.75;
var ROCK_RAY_LENGTH = 10;
var ROCK_FALL_DELAY = 0.5;

var LAVA_DAMAGE_INTERVAL = 0.01;

var MAX_CHAR_HP = 100;

var _character_hp;

var _vec3_tmp = new Float32Array(3);

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

В функции load_cb() установим переменной _character_hp значение MAX_CHAR_HP - в начале игры персонаж полностью здоров.

_character_hp = MAX_CHAR_HP;

Падение камней - инициализация

Список вызываемых функций теперь выглядит следующим образом:

var elapsed_sensor = m_ctl.create_elapsed_sensor();

setup_movement(up_arrow, down_arrow);
setup_rotation(right_arrow, left_arrow, elapsed_sensor);
setup_jumping(touch_jump);

setup_falling_rocks(elapsed_sensor);
setup_lava(elapsed_sensor);

setup_camera();

С целью улучшения производительности сенсор времени elapsed_sensor инициализируется только один раз и подается в качестве аргумента в нужные функции.

Рассмотрим новую функцию, которая инициализирует процесс падения камней:

function setup_falling_rocks(elapsed_sensor) {

    var ROCK_EMPTIES = ["lava_rock","lava_rock.001"];
    var ROCK_NAMES = ["rock_01", "rock_02", "rock_03"];

    var BURST_EMITTERS_NAMES = ["burst_emitter_01", "burst_emitter_02",
                                "burst_emitter_03"];

    var MARK_NAMES = ["mark_01", "mark_02", "mark_03"];

    var falling_time = {};

    ...
}

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

Падение камней - сенсоры

Инициализируем сенсоры, описывающие поведение каждой из падающих глыб, в двойном цикле:

for (var i = 0; i < ROCK_EMPTIES.length; i++) {

    var dupli_name = ROCK_EMPTIES[i];

    for (var j = 0; j < ROCK_NAMES.length; j++) {
        
        var rock_name  = ROCK_NAMES[j];
        var burst_name = BURST_EMITTER_NAMES[j];
        var mark_name  = MARK_NAMES[j];

        var rock  = m_scs.get_object_by_dupli_name(dupli_name, rock_name);
        var burst = m_scs.get_object_by_dupli_name(dupli_name, burst_name);
        var mark  = m_scs.get_object_by_dupli_name(dupli_name, mark_name);

        var coll_sens_lava = m_ctl.create_collision_sensor(rock, "LAVA", true);
        var coll_sens_island = m_ctl.create_collision_sensor(rock, "ISLAND", true);

        var ray_sens = m_ctl.create_ray_sensor(rock, [0, 0, 0],
                                    [0, -ROCK_RAY_LENGTH, 0], false, null);

        m_ctl.create_sensor_manifold(rock, "ROCK_FALL", m_ctl.CT_CONTINUOUS,
                                     [elapsed_sensor], null, rock_fall_cb);

        m_ctl.create_sensor_manifold(rock, "ROCK_CRASH", m_ctl.CT_SHOT,
                                     [coll_sens_island, coll_sens_lava],
                function(s){return s[0] || s[1]}, rock_crash_cb, burst);

        m_ctl.create_sensor_manifold(rock, "MARK_POS", m_ctl.CT_CONTINUOUS,
                                    [ray_sens], null, mark_pos_cb, mark);

        set_random_position(rock);
        var rock_name = m_scs.get_object_name(rock);
        falling_time[rock_name] = 0;
    }
}

Внешний цикл осуществляет перебор dupli-групп (напомню, их всего две). Внутренний цикл обрабатывает объекты камней (rock), системы частиц для взрыва (burst) и меток (mark). Управление системами частиц для дымовых шлейфов не требуется, т.к. они привязаны к падающим глыбам и следуют за ними автоматически.

Сенсоры coll_sens_lava и coll_sens_island служат для детектирования столкновения камней с поверхностью лавы и земли. Третий аргумент функции create_collision_sensor() говорит о нашем намерении получать координаты столкновения в обработчике.

Сенсор ray_sens служит для определения расстояния до объектов под камнем и используется для позиционирования метки под падающими глыбами. Созданный таким образом луч начинается в координате [0,0,0] в пространстве объекта и заканчивается на расстоянии в 10 метров под ним. Последний аргумент, равный null, означает, что столкновения будут определяться с любыми объектами независимо от их collision_id.

Падение камней - сенсорные множества

Далее используется уже знакомая нам по предыдущим статьям сенсорная модель. На основе созданных сенсоров инициализируются три сенсорных множества: ROCK_FALL отвечает за падение глыб, ROCK_CRASH обрабатывает соударение с землей и лавой, MARK_POS размещает метку под глыбой.

Также зададим каждому камню случайное положение на небольшой высоте с помощью функции set_random_position():

function set_random_position(obj) {
    var pos = _vec3_tmp;
    pos[0] = 8 * Math.random() - 4;
    pos[1] = 4 * Math.random() + 2;
    pos[2] = 8 * Math.random() - 4;
    m_trans.set_translation_v(obj, pos);
}

Последнее, что происходит в цикле - время падения каждого камня в словаре falling_time инициализируется нулем:

falling_time[rock_name] = 0;

В качестве ключей в данном объекте используются имена объектов камней. Эти имена являются уникальными, несмотря на то, что в сцене присутствуют несколько одинаковых объектов. Дело в том, что в Blend4Web результирующее имя объекта, добавленного по ссылке с использованием группы дуплицирования, комбинируется из имени группы и исходного имени объекта, например lava_rock.001*rock_03.

Обработчик для процесса падения

Обработчик rock_fall_cb() приведен ниже:

function rock_fall_cb(obj, id, pulse) {
    var elapsed = m_ctl.get_sensor_value(obj, id, 0);
    var obj_name = m_scs.get_object_name(obj);
    falling_time[obj_name] += elapsed;

    if (falling_time[obj_name] <= ROCK_FALL_DELAY)
        return;

    var rock_pos = _vec3_tmp;
    m_trans.get_translation(obj, rock_pos);
    rock_pos[1] -= ROCK_SPEED * elapsed;
    m_trans.set_translation_v(obj, rock_pos);
}

Время падения увеличивается на величину elapsed, полученную из текущего значения сенсора времени. Перед началом падения камня имеется небольшая задержка (ROCK_FALL_DELAY). Эта задержка позволит физическому движку, работающему асинхронно в независимом Web Worker'е, корректно определить высоту объекта в момент начала его падения. В дальнейшем это поможет нам правильно разместить метку под камнем.

В переменную rock_pos сохраняются текущие координаты камня. Затем координата Y изменяется на величину ROCK_SPEED * elapsed, после чего объект устанавливается в новую позицию. Для упрощения мы использовали линейную модель перемещения (без ускорения свободного падения).

Обработчик для столкновения

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

function rock_crash_cb(obj, id, pulse, burst_emitter) {
    var char_pos = _vec3_tmp;

    m_trans.get_translation(_character, char_pos);

    var sensor_id = m_ctl.get_sensor_value(obj, id, 0)? 0: 1;

    var collision_pt = m_ctl.get_sensor_payload(obj, id, sensor_id);
    var dist_to_rock = m_vec3.distance(char_pos, collision_pt);

    m_trans.set_translation_v(burst_emitter, collision_pt);
    m_anim.set_current_frame_float(burst_emitter, 0);
    m_anim.play(burst_emitter);

    set_random_position(obj);

    if (dist_to_rock < ROCK_DAMAGE_RADIUS)
        reduce_char_hp(ROCK_DAMAGE);

    var obj_name = m_scs.get_object_name(obj);
    falling_time[obj_name] = 0;
}

В данной функции последний параметр burst_emitter - это объект системы частиц, переданный нами при регистрации сенсорных множеств.

По значению sensor_id определяется, какой именно сенсор вызвал обработчик. Значение 0 соответствует соударению с поверхностью земли, а 1 - соответственно, с поверхностью лавы. Координаты точки столкновения получим с помощью вызова:

var collision_pt = m_ctl.get_sensor_payload(obj, id, sensor_id);

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

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

На основании позиции персонажа char_pos вычисляется расстояние до точки столкновения dist_to_rock. Если персонаж находится достаточно близко к месту столкновения, то у него снизится показатель жизненной энергии:

if (dist_to_rock < ROCK_DAMAGE_RADIUS)
    reduce_char_hp(ROCK_DAMAGE);

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

falling_time[obj_name] = 0;

Обработчик для метки под камнем

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

function mark_pos_cb(obj, id, pulse, mark) {
    var mark_pos = _vec3_tmp;
    var ray_dist = m_ctl.get_sensor_payload(obj, id, 0);
    var obj_name = m_scs.get_object_name(obj);

    if (falling_time[obj_name] <= ROCK_FALL_DELAY) {
        m_trans.get_translation(obj, mark_pos);
        mark_pos[1] -= ray_dist * ROCK_RAY_LENGTH - 0.01;
        m_trans.set_translation_v(mark, mark_pos);
    }

    m_trans.set_scale(mark, 1 - ray_dist);
}

В переменную ray_dist сохраним расстояние до земли. Позиционирование метки осуществляется до того, как камень начал свое движение (задержка ROCK_FALL_DELAY секунд). Обратите внимание, как смещается метка относительно камня:

mark_pos[1] -= ray_dist * ROCK_RAY_LENGTH - 0.01;

Множитель ROCK_RAY_LENGTH необходим, потому что ray_dist - относительная длина отрезка луча (от 0 до 1), в то время как его настоящая длина - 10 метров. Чтобы немного приподнять метку над поверхностью земли, вычитается величина 0.01.

В процессе падения камня метка линейно увеличивается в размерах. За это отвечает последняя строка в обработчике:

m_trans.set_scale(mark, 1 - ray_dist);

В итоге наблюдаем падение раскаленных глыб:

Урон от лавы

При попадании в лаву у персонажа с течением времени будут постепенно сокращаться очки жизни. Функция setup_lava() достаточно компактная, поэтому приведем её код целиком:

function setup_lava(elapsed_sensor) {
    var time_in_lava = 0;

    function lava_cb(obj, id, pulse, param) {
        if (pulse == 1) {

            var elapsed = m_ctl.get_sensor_value(obj, id, 1);
            time_in_lava += elapsed;

            if (time_in_lava >= LAVA_DAMAGE_INTERVAL) {

                if (elapsed < LAVA_DAMAGE_INTERVAL)
                    var damage = 1;
                else
                    var damage = Math.floor(elapsed/LAVA_DAMAGE_INTERVAL);

                reduce_char_hp(damage);
                time_in_lava = 0;
            }
        } else {
            time_in_lava = 0;
        }
    }

    var lava_ray = m_ctl.create_ray_sensor(_character, [0, 0, 0], [0, -0.25, 0],
                                           false, "LAVA");

    m_ctl.create_sensor_manifold(_character, "LAVA_COLLISION",
        m_ctl.CT_CONTINUOUS, [lava_ray, elapsed_sensor],
        function(s) {return s[0]}, lava_cb);

}

Сенсор lava_ray - луч с длиной 0.25 (чуть больше половины высоты персонажа), направленный из центра персонажа вниз. На его основе и на основе сенсора elapsed_sensor создается сенсорное множество LAVA_COLLISION. По количеству времени, проведенного персонажем в лаве (time_in_lava), мы определяем, нужно ли снижать жизненную энергию. Скорость уменьшения очков жизни регулируется константой LAVA_DAMAGE_INTERVAL. При нахождении персонажа в лаве в течение этого времени снимается 1 пункт жизни. Если же задержка между кадрами превышает заданный интервал повреждений, урон вычисляется следующим образом:

var damage = Math.floor(elapsed/LAVA_DAMAGE_INTERVAL);

Время нахождения в лаве сбрасывается в двух случаях: при получении урона от лавы или при выходе из лавы. Затем оно снова накапливается до достижения значения LAVA_DAMAGE_INTERVAL.

Очки жизни

При обработке камней и лавы мы обращались к функции reduce_char_hp(). Посмотрим, что она собой представляет:

function reduce_char_hp(amount) {

    if (_character_hp <= 0)
        return;

    _character_hp -= amount;

    var green_elem = document.getElementById("life_bar_green");
    var red_elem = document.getElementById("life_bar_red");
    var mid_elem = document.getElementById("life_bar_mid");

    var hp_px_ratio = 192 / MAX_CHAR_HP;
    var green_width = Math.max(_character_hp * hp_px_ratio, 0);
    var red_width = Math.min((MAX_CHAR_HP - _character_hp) * hp_px_ratio, 192);

    green_elem.style.width =  green_width + "px";
    red_elem.style.width =  red_width + "px";
    mid_elem.style.left = green_width + 19 + "px";

    if (_character_hp <= 0)
        kill_character();
}

Во-первых, в данной функции происходит уменьшение значения глобальной переменной _character_hp. Во-вторых, осуществляется изменение HTML-элементов полосы здоровья: для элемента life_bar_green ширина уменьшается, для элемента life_bar_red - увеличивается, а элемент life_bar_mid размещается между ними.

В случае, если здоровье достигает нуля, то вызывается функция kill_character():

function kill_character() {
    m_anim.apply(_character_body, "character_death");
    m_anim.play(_character_body);
    m_anim.set_behavior(_character_body, m_anim.AB_FINISH_STOP);
    m_phy.set_character_move_dir(_character, 0, 0);
    m_ctl.remove_sensor_manifolds(_character);
}

Смерть персонажа сопровождается запуском анимации "character_death" в режиме m_anim.AB_FINISH_STOP - анимация не циклическая. Далее останавливаем персонажа и удаляем все существующие на нём сенсорные множества.

Заключение

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

В одной из следующих статей будут подробно рассмотрены модели и материалы, разработанные для этого урока.

Ссылка на приложение в отдельном окне

Исходные файлы приложения и сцены находятся в составе бесплатного дистрибутива Blend4Web SDK.

Изменения

[2014-07-11] Изначальная публикация.

[2014-07-22] Изменен путь к приложению.