Браузерная игра про пиратов
Какой-то остров
«Йо-хо-хо!» — невольно приходит на ум при любом взаимодействии с морем, передвигаешься ли на речном трамвайчике или же сидишь в баре круизного корабля. В последнем случае еще можно приобрести бутылку рому. Море привлекает своими волнами, закатами и рассветами. А особенно прикольно, когда на море завелись злые пираты. Ну… если это мы, конечно же.
Возьмем шейдер неба и шейдер воды — атмосфера готова! Что может быть проще. И да, я буду писать игру под браузер на Javascript с использованием библиотек Three.js и Cannon.js. Первую я использую для отображения 3D графики, а вторую — в качестве легковесного скриптового физического движка.
Я заставил небесную сферу и плоскость воды двигаться вместе с кораблем. Это обеспечивает практически бесконечный мир — только по мере движения будут подгружаться разные острова. Волны привязаны к началу координат, а сама плоскость воды передвигается. В этом примере уменьшен размер плоскости, чтобы было видно, как это работает.
Ладно. Небо и землю, а точнее, небо и море, мы создали. А теперь нужен корабль! Надо его где-то реквизировать… Я взял за основу бесплатную модель и практически полностью переделал, попутно существенно убавив количество полигонов. От исходника остался только корпус и то не без трансформаций. Игровой мир включает 13 островов, на каждом из которых игрок проходит миссию. Вообще, игра называется »13 черепов». Как не сложно догадаться, на каждом острове после прохождения миссии вы получаете один череп. В игре присутствует карта, причем, в масштабе и по ней движется ваш кораблик.
Карта
Анимации
Теперь — о том, как я программировал анимации. Я не вижу смысла приводить здесь большое количество кода. Думаю, достаточно объяснить принцип, а код ведь можно написать на любом языке.
Основной момент состоит в том, что в игре одновременно работают 4 анимации: небосвод (движение солнца, смена дня и ночи), волны, движение кораблей и полет ядер при стрельбе. Последняя анимация ставится на паузу, когда никто не стреляет. Движение всех кораблей осуществляется посредством одной анимации, просто в начале своего движения каждый корабль добавляется в некий кэш (массив). И в анимации они все перебираются в цикле, где изменяются их координаты в соответствии с таймингом и углами поворота. Аналогично для ядер: общий кэш с привязкой каждого ядра к кораблю.
Технически в моей игре анимацию можно осуществить двумя способами. Первый — это, как я его назвал, глобальный таймер (globalTimer), а второй — мой движок анимации. С точки зрения результата, это одно и тоже, только в движке можно задать больше параметров. В первом варианте, то есть, в глобальном таймере используется стандартная функция Javascript setInterval, но с моей надстройкой, которая засовывает все добавляемые события в один интервал и задает единый тайминг как общий знаменатель. Такую схему я реализовал причине того, что слишком много одновременно работающих setInterval (буквально от пяти и более) сильно просаживают производительность и делают фреймрейт рваным. А так работает только один setInterval. Движок же анимации основан на requestAnimationFrame, который умеет выравнивать фреймрейт и делать движение более плавным. На глобальный таймер есть смысл повесить редкие периодические события типа движения солнца — его позиция обновляется раз в пять секунд. А на движок анимации лучше повесить полет ядер, чтобы они двигались плавно и с максимально возможной частотой кадров.
На самом деле, на глобальный таймер я повесил и обновление шейдера небосвода, и волн, и движение кораблей. А на движок анимации — только стрельбу. Параметр ti — это периодичность срабатывания события в миллисекундах. Браузер подстраивает обновление графики под 60 кадр/сек — это чуть больше 16 мс на кадр. Я задал для движения кораблей интервал 15 мс, чтобы очередная итерация обновления позиций кораблей гарантированно срабатывала бы в момент отрисовки и не выпадали бы кадры. Остальные события я сделал кратными пятнадцати. Я не вижу смысла обновлять шейдер воды чаще 30 кадров в секунду (ti=30), а покачивание корабля — оно слишком медленное и его достаточно обновлять всего 15 раз в секунду (ti=60). Ради эксперимента я ставил и более высокие частоты, но никакой разницы не заметил.
//движение кораблей
m3d.lib.globalTimerSoft.addEvent({name: 'shipmove', ti: 15, d0: Date.now(), sign: 1, f: function() {
...
}});
//вода
m3d.lib.globalTimerSoft.addEvent({name: 'water', ti: 30, d0: Date.now(), f: function() {
...
}});
//покачивание кораблей на волнах
m3d.lib.globalTimerSoft.addEvent({name: 'shipshake', ti: 60, d0: Date.now(), sign: 1, f: function() {
...
}});
Джаваскриптовый setInterval устанавливается здесь в 15, то есть, в самое маленькое кратное значение. Ну, а события с таймингом 30 просто срабатывают каждую вторую итерацию, а с таймингом 60 — каждую четвертую.
В этой системе можно принудительно менять setInterval, если, например, окно неактивно — пользователь свернул браузер или переключился на что-то другое. Тогда нет смысла молотить 60 кадров в секунду и можно сбросить частоту раз в шесть:
m3d.lib.addListner(window, "blur", function(event) {
m3d.lib.globalTimerSoft.interval = 90;
});
m3d.lib.addListner(window, "focus", function(event) {
m3d.lib.globalTimerSoft.interval = 15;
});
Сообщение при потере фокуса окна
При снижении частоты кадров игра превращается в слайд-шоу. Но, когда на нее не смотришь, то это и хорошо. Нагрузка на CPU и GPU существенно падает, почти до нуля, а все события в игре все равно выполняются, то есть, игра не ставится на паузу. И еще одна анимация — небосвод (движение солнца, смена времени суток) обновляется раз в 5 секунд. Но там у меня поверх самого события есть еще надстройка, которая позволяет задать параметры заполняющего, прямого, атмосферного освещения и плотности тумана в зависимости от высоты солнца. Поэтому данная анимация добавляется через ту надстройку.
Полет же ядер я повесил на движок анимации, частота его работы не снижается даже при снижении частоты глобального таймера. То есть, эти системы работают независимо друг от друга. Все-таки ядра должны всегда лететь плавно, с частотой 60 fps, чтобы они не проходили сквозь цели. А то может получиться так, что в предыдущей итерации ядро еще не долетело, а в следующей — уже перелетело за цель. Поэтому при движении ядра частота отрисовки кадров временно поднимается до 60 fps за счет того, что начинает работать анимация. В мой движок анимации событие добавляется так:
m3d.lib.anim.add(
'moveCannon', 1, animT, 'and', userpar,
[
{lim1: x0, lim2: x, sstart: x0, sfin: x, t: 9*1000},
],
function(){
var self = this;
var state = self.par[0].state;
...
}
);
m3d.lib.anim.play();
m3d.lib.anim.apause(1,0);
Смысл тут в том, что за некоторое время (9 секунд, это максимальное время полета ядра в игре) изменяется параметр state от lim1 до lim2. И текущий state доступен в callback-функции. Ну, а в последней уже можно двигать координаты ядер, в зависимости от прошедшего времени. По сути, в данной анимации state не важен, достаточно только обновлять координаты каждого ядра в зависимости от шага времени между итерациями. Параметр animT — это общее время работы анимации. В данном случае я задал там огромное число, равное длительности нескольких суток, так как анимация не должна прекращаться: ведь стрелять могут на протяжении всей игры. В начале анимация запускается и сразу же ставится на паузу. А когда происходит выстрел, то она возобновляется: m3d.lib.anim.acontinue (1,0). Внутри функции перебирается массив ballCache — это летящие ядра. Если он пуст, то анимация снова ставится на паузу. Также в теле функции работает физический движок Cannon.js. Вычисленная им позиция ядра (ball.phy.position) копируется в визуальную модель ядра Three.js (ball.geo.position). Это специфика работы Cannon.js.
var t = Date.now();
var dt = t - self.userpar.t0;
self.userpar.t0 = t;
apscene.canworld.step(dt / 1000, dt / 1000, 3);
for (var i = 0; i < ap.game.state.ballCache.length; i++) {
if (ap.game.state.ballCache[i].finished == false) {
//новая позиция каждого ядра
ap.game.state.ballCache[i].ball.geo.position.copy(ap.game.state.ballCache[i].ball.phy.position);
};
};
Стрельба из пушек
Горы
Я долго искал средство для того, чтобы создать более-менее красивые горы для некоторых островов. И в итоге остановил свой выбор на маленькой программке 2005-го года Nem’s Mega 3D Terrain Generator, дистрибутив которой весит всего 522 Кб. Так я создал геометрическую сетку скал. Ну, а потом в редакторе натянул текстуру и слегка оптимизировал. Получилось довольно симпатично, на мой взгляд.
Скалистый остров
Боты
В данной версии игры пока нет движущихся кораблей противника, но есть пушки, стреляющие с берега. Если ваш корабль находится в квадрате действия одной из пушек, то она открывает по вам огонь. Я сначала не мог придумать способ наведения пушек на корабль игрока. Точнее, само наведение задается легко. Если cannon — это 3D модель пушки, а target — это наш корабль, то достаточно выполнить:
cannon.lookAt (target.position.x, cannon.position.y, target.position.z);
И пушка повернется в сторону корабля. По оси «y» используется позиция не корабля, а пушки. Это для того, чтобы пушка не отклонялась от своей вертикали. Наклонять мы будем ее ствол. Проблема в том, что для каждого бота (пушки) задается дуга в радианах, в границах которой этот бот может стрелять. Это для того, чтобы она не стреляла в горы, если мы скрылись за ними, например. Однако, углы Эйлера, которые использует Three.js, не дают понять, в какую сторону повернута пушка по одной из осей.
В итоге я нашел решение, заключающееся в том, что мы создаем вектор, затем при помощи метода getWorldDirection определяем направление пушки и копируем его в вектор, а затем через арктангенс узнаем угол поворота ® вектора по вертикальной оси (y). Затем сравниваем, входит ли этот угол в диапазон, заданный для поворота пушки. И если да, то тогда она в нас стреляет.
var vec = new THREE.Vector3(0, 0, 0);
var dir = cannon.getWorldDirection(vec);
var r = Math.atan2(dir.x, dir.z);
Пушка с берега стреляет по кораблю
Примечательно, что я долго искал это решение, а когда нашел, то в этот момент за окном раздались звуки фейерверка.
Коллайдеры
Коллайдеры я расставлял в редакторе поверх модели локации, а затем сохранил их отдельным объектом. В игре они загружаются с opacity=0, то есть, невидимыми. И эти меши перебираются в цикле во время движения корабля.
Коллайдеры
Для определения пересечений корабля с коллайдерами используется рейкаст:
var originPoint = player.position.clone();
var ray = new THREE.Raycaster(originPoint, directionVector.clone().normalize());
var collisionResults = ray.intersectObjects(meshList, false);
Для корабля и препятствий это работает. Правда, я пока еще не реализовал отталкивание от препятствия. Пока корабль при встрече с коллайдером просто перестает двигаться. Но можно вырулить, если покрутить штурвал. Однако, периодически можно застревать намертво, поэтому сталкиваться со скалами пока не рекомендуется.
А вот с пушечными ядрами посложнее. С корабля их можно выпустить сразу пять. Плюс — могут стрелять боты. То есть, в кэше ядер может находиться 6 и более объектов. И для каждого ядра нужно просчитывать столкновение с коллайдерами каждого из кораблей (или пушек ботов), имеющихся в локации, то есть, всех со всеми, что приводило к заметному падению производительности. 6×6 = 36 одновременных обсчетов столкновений. Рейкаст начинал тормозить. Налицо необходимость оптимизации. О ней — ниже.
Скалы с коллайдерами
Оптимизация
1. Коллайдеры.
Первый очевидный шаг — убрать просчет столкновения ядра с коллайдером своего же корабля. Мы ведь не в себя стреляем. То есть, уже — минус шесть из тридцати шести. Итого: все еще 6×5 = 30 просчетов столкновений.
А потом я подумал, что нет необходимости проверять столкновения ядер со всеми объектами. Достаточно проверять только с ближайшими. Но поиск расстояния от летящего ядра до каждого объекта на сцене — это дорогое удовольствие, так как оно связано с интенсивными вычислениями квадратных корней. Однако Three.js можно вычислить для 3D объекта (меша) обрамляющий его бокс, ориентированный по осям координат. Для всех статичных объектов его можно предрассчитать при загрузке локации.
var helper = new THREE.BoxHelper(mesh);
helper.geometry.computeBoundingBox();
var box = helper.geometry.boundingBox;
Результат выглядит так:
box: {
"min": {
"x": -788.352,
"y": -499.999,
"z": 2858.697
},
"max": {
"x": 113.252,
"y": 500,
"z": 3000.322
}
}
Но как понять, что этот объект нужно включать в расчет коллизий, если ядро подлетает к нему практически в момент столкновения? Ведь бокс обрамляет непосредственно объект. Тут уже и само столкновение надо обрабатывать… Очевидно, можно просто расширить бокс.
var d = 450;
box.min.x -= d; box.max.x += d;
box.min.y -= d; box.max.y += d;
box.min.z -= d; box.max.z += d;
И тогда при полете ядра можно проверять, вошло ли ядро в этот «подлетный бокс» или нет, и на основании этого включать или не включать объект, находящийся в этом боксе, в список коллизий для обработки. А как проверить, вошло ли ядро в «подлетный бокс»? Тупо по координатам: x, y и z ядра должны быть больше минимальных и меньше максимальных значений координат вершин бокса. И никаких квадратных корней. Конечно, перестраивать список коллизий для каждого ядра слишком часто тоже не стоит. Можно делать это с каким-то шагом.
if ((Math.abs(position.x - old.x) > step.x) || (Math.abs(position.y - old.y) > step.y) || (Math.abs(position.z - old.z) > step.z)) {
old.x = position.x;
old.y = position.y;
old.z = position.z;
collisionsListUpdate(position, baselist, bounds);
};
baselist здесь — общий список коллайдеров всех объектов локации, а bounds — общий список предрассчитанных боксов для всех этих объектов. И да, для движущихся объектов (типа кораблей) соответствующие записи в bounds придется обновлять, так как границы и положение их в пространстве меняются. Но таких объектов очень малое количество. И этот подход имеет свой эффект. Теперь в кэше коллизий для каждого ядра болтается не по 5–6 объектов, а максимум — 1. А большую часть времени и вообще ничего.
Если рассчитанные боксы добавить на сцену, то получится примерно следующее. В игре же эти боксы никуда не добавляются, используются только данные по ним (координаты). И да, здесь визуализированы только сами боксы (границы объектов), еще не расширенные на дельту, то есть, это не «подлетные боксы». Но смысл, думаю, понятен. «Подлетные» просто чуть больше. Бокс корабля здесь такой низкий, потому что он создается не вокруг самого объекта, а вокруг коллайдера, а коллайдер для корабля я сделал низким.
Границы 3D-моделей по осям координат, основа для расчета «подлетных боксов»
Таким образом, в итоге проверяются столкновения ядер не со всем объектами, а только с теми, в «подлетных боксах» которых ядра оказываются.
2. Графика.
Ну с оптимизацией графики все и так понятно. Делаем как можно меньше полигонов и упаковываем текстуры по возможности в текстурные атласы. Еще я убрал все прозрачные объекты, так как они «отжирают» производительность, и использовал вместо этого текстуры alpha map.
Миссия «Сундук мертвеца»
3. Физика.
Физику движения ядер берет на себя Cannon.js. Для каждого ядра создается физическая модель, которая добавляется в кэш движка, где находится, пока ядро летит. На данном этапе здесь нечего оптимизировать. Но я как-то писал игру, в которой один физический объект летел над другими физическими объектами с вероятностью на них упасть и вызвать взаимодействие отталкивания. Так вот, я добавлял в физический движок только те объекты, которые оказывались под летящим объектом в определенном квадрате. А остальные, отдаленные, удалял, поскольку вероятности упасть на них не было. То есть, я точно так же перестраивал кэш физического движка с некоторым шагом. И это давало колоссальный прирост производительности. Когда в игре появятся разрушаемые береговые форты, то я воспользуюсь этой схемой и буду добавлять в физический движок только то, что находится вблизи (каждого) ядра в каком-то квадрате.
Итоги оптимизации
Думаю, достаточно упомянуть, что игра работает в браузерах на, мягко говоря, не самом мощном телефоне стоимостью 13 000 рублей. Играть вполне комфортно.
Chrome, Android
Миссии
В данный момент в игре всего две миссии — обучающая и «Сундук мертвеца». Задумка такова, что на каждом из тринадцати островов будет предложено свое задание, за выполнение которого игрок получает череп. Технически это позволяет разбить игровой мир на 13 локаций, каждая из которых загружается при приближении к ней, а та, что осталась позади, удаляется и памяти вместе со своими 3D-моделями, коллайдерами, «подлетными боксами» и т.д., прекращая оказывать нагрузку на вычисления. И мир может быть практически бескрайним. В данный момент локации без миссий заменены на стандартный островок с пальмой. У одной из локаций находится сундук, который восполняет здоровье на 100%.
В путь!
Планы
В дальнейших планах — создание миссий с кораблями противников и боем против них. Техническая база для этого есть. Также, требует доработки графика и нужно добавить визуальных эффектов. Сетевую игру тоже планирую, но это будет не королевская битва, а некий кооператив: один игрок выполняет одну миссию, другой — другую. А на то, что уже сделано, ушло чуть больше месяца свободного времени.