Море, пираты — 3D онлайн игра в браузере
Приветствую пользователей Хабра и случайных читателей. Это история разработки браузерной многопользовательской онлайн игры с low-poly 3D графикой и простейшей 2D физикой.
Позади немало браузерных 2D мини-игр, но подобный проект для меня в новинку. В gamedev решать задачи, с которыми ещё не сталкивался, может быть довольно увлекательно и интересно. Главное — не застрять со шлифовкой деталей и запустить рабочую игру пока есть желание и мотивация, поэтому не будем терять время и приступим к разработке!
Об игре в двух словах
Бой на выживание — единственный на данный момент режим игры. Сражения от 2 до 6 кораблей без перерождений, где последний выживший игрок считается победителем и получает х3 очков и золота.
Аркадное управление: кнопки W, A, D или стрелки для движения, пробел для залпа по вражеским кораблям. Прицеливаться не нужно, промахнуться нельзя, урон зависит от рандома и угла выстрела. Больший урон сопровождается медалькой «точно в цель».
Зарабатываем золото занимая первые места в рейтингах игроков за 24 часа и за 7 дней (сброс в 00:00 по МСК) и выполняя ежедневные задания (на день выдается одно из трех, по очереди). Золото за сражения тоже есть, но меньше.
Тратим золото устанавливая черные паруса на свой корабль на 24 часа. В планах добавить возможность разбудить Кракена, который утащит на дно любой вражеский корабль своими гигантскими щупальцами :)
ПВП или зассал трус? Особенность, которую я хотел реализовать ещё до выбора темы пиратов — это возможность сразиться с друзьями в пару кликов. Без регистрации и лишних телодвижений можно отправить пригласительную ссылку друзьям и подождать когда они войдут в игру по ссылке: приватная комната, которую можно открыть для всех, создаётся автоматически, когда кто-то переходит по ссылке, при условии что «автор» ссылки не начал другое сражение.
Стек технологий
Three.js — одна из самых популярных библиотек для работы с 3D в браузере с хорошей документацией и большим количеством различных примеров. Кроме того, я использовал Three.js и раньше — выбор очевиден.
Отсутствие игрового движка обусловлено отсутствием соответствующего опыта и желания изучать то, без чего и так всё неплохо работает :)
Node.js потому что просто, быстро и удобно, хотя опыта непосредственно в Node.js не имел. В качестве альтернативы рассматривал Java, проводил пару локальных экспериментов в том числе с веб-сокетами, но сложно ли запустить «джаву» на VPS выяснять не решился. Ещё один вариант — Go, его синтаксис вгоняет меня в уныние — не продвинулся в его изучении ни на йоту.
Для веб-сокетов используется модуль ws в Node.js.
PHP и MySQL менее очевидный выбор, но критерий всё тот же — быстро и просто, так как есть опыт в данных технологиях.
Получается вот такая схема:
PHP нужен в первую очередь для отдачи клиенту веб-страничек и для редких AJAX запросов, но по большей части клиент общается всё же с игровым сервером на Node.js по веб-сокетам.
Мне совсем не хотелось связывать игровой сервер с БД, поэтому всё идет через PHP. На мой взгляд тут есть свои плюсы, хотя не уверен значимы ли они. Например, так как в Node.js приходят уже готовые данные в нужном виде, Node.js не тратит время на обработку и дополнительные запросы в БД, а занимается более важными делами — «переваривает» действия игроков и изменяет состояние игрового мира в комнатах.
Сначала модель
Разработка началась с простого и самого главного — некой модели игрового мира, описывающей морские сражения с серверной точки зрения. Обычный canvas 2D для схематичного отображения модели на экране подходит идеально.
Изначально я поставил нормальную «верлетовую» физику, и учитывал различное сопротивление движению корабля в разных направлениях относительно направления корпуса. Но беспокоясь о производительности сервера, я заменил нормальную физику на простейшую, где очертания корабля остались только в визуале, физически же корабли — круглые объекты, которые даже не обладают инерцией. Вместо инерции — ограниченное ускорение движения вперед.
Выстрелы и попадания сводятся к простым операциям с векторами направления корабля и направления выстрела. Здесь нет снарядов. Если dot продукт нормализованных векторов вписывается в допустимые значения с учетом расстояния до цели, то быть выстрелу и попаданию, если при этом игрок нажал кнопку.
Клиентский JavaScript для визуализации модели игрового мира, обрабатывающий движение кораблей и выстрелы, я перенес на Node.js сервер почти без изменений.
Игровой сервер
Node.js WebSocket сервер состоит всего из 3 скриптов:
- main.js — основной скрипт, который получает WS сообщения от игроков, создаёт комнаты и заставляет шестеренки этой машины крутиться
- room.js — скрипт, отвечающий за игровой процесс внутри комнаты: обновление игрового мира, рассылка обновлений игрокам комнаты
- funcs.js — включает класс для работы с векторами, пару вспомогательных функций и класс реализующий двусвязный список
По мере разработки добавлялись новые классы — почти все из них напрямую связаны с игровым процессом и попали в файл room.js. Порой бывает удобно работать с классами по отдельности (в отдельных файлах), но вариант всё в одном тоже неплох, пока классов не слишком много (удобно прокрутить вверх и вспомнить какие параметры принимает метод другого класса).
Актуальный список классов игрового сервера:
- WaitRoom — комната, куда попадают игроки ожидающие начала сражения, здесь есть собственный tick метод, рассылающий свои обновления и запускающий создание игровой комнаты когда больше половины игроков готовы к бою
- Room — игровая комната, где проходят сражения: обновляются состояния игроков/кораблей, затем обрабатываются возможные столкновения, в конце формируется и рассылается всем сообщение с актуальными данными
- Player — по сути «обёртка» с некоторыми дополнительными свойствами и методами для следующего класса:
- Ship — этот класс заставляет корабли плавать: реализует движение и повороты, здесь также хранятся данные о повреждениях, чтобы в конце игры зачислить очки игрокам, принимавшим участие в уничтожении корабля
- PhysicsEngine — класс, реализующий простейшие столкновения круглых объектов
- PhysicsBody — всё есть круглые объекты со своими координатами на карте и радиусом
let upd = {p: [], t: this.gamet};
let t = Date.now();
let dt = t - this.lt;
let nalive = 0;
for (let i in this.players) {
this.players[i].tick(t, dt);
}
this.physics.run(dt);
for (let i in this.players) {
upd.p.push(this.players[i].getUpd());
}
this.chronology.addLast(clone(upd));
if (this.chronology.n > 30) this.chronology.remFirst();
let updjson = JSON.stringify(upd);
for (let i in this.players) {
let pl = this.players[i];
if (pl.ship.health > 0) nalive++;
if (pl.deadLeave) continue;
pl.cl.ws.send(updjson);
}
this.lt = t;
this.gamet += dt;
if (nalive <= 1) return false;
return true;
Кроме классов, есть такие функции, как получить данные пользователя, обновить ежедневное задание, получить награду, купить скин. Эти функции в основном отправляют https запросы в PHP, который выполняет один или несколько MySQL запросов и возвращает результат.
Сетевые задержки
Компенсация сетевых задержек — важная часть разработки онлайн игры. По этой теме я не раз перечитывал серию статей здесь на Хабре. В случае сражения парусных кораблей компенсация лагов может быть простой, но всё равно приходится идти на компромиссы.
На клиенте постоянно осуществляется интерполяция — вычисление состояния игрового мира между двумя моментами во времени, данные для которых уже получены. Есть небольшой запас времени, уменьшающий вероятность резких скачков, а при существенных сетевых задержках и отсутствии новых данных интерполяция сменяется экстраполяцией. Экстраполяция даёт не слишком корректные результаты, зато дёшево обходится для процессора и не зависит от того, как реализовано движение кораблей на сервере, и конечно, иногда может спасти ситуацию.
При решении проблемы лагов очень многое зависит от игры и её темпа. Я жертвую быстрым откликом на действия игрока в пользу плавности анимации и точного соответствия картинки состоянию игрового мира в некий момент времени. Единственное исключение — залп из пушек воспроизводится незамедлительно по нажатию кнопки. Остальное можно списать на законы вселенной и излишек рома у команды корабля :)
Фронт-энд
К сожалению здесь нет четкой структуры или иерархии классов и методов. Весь JS разбит на объекты со своими функциями, которые в каком то смысле являются равноправными. Почти все мои предыдущие проекты были устроены более логично чем этот. Отчасти так вышло потому что сначала целью было отладить модель игрового мира на сервере и сетевое взаимодействие не обращая внимания на интерфейс и визуальную составляющую игры. Когда пришло время добавлять 3D я в буквальном смысле его добавил в существующую тестовую версию, грубо говоря, заменил 2D функцию drawShip на точно такую же, но 3D, хотя по-хорошему стоило пересмотреть всю структуру и подготовить основу для будущих изменений.
3D корабль
Three.js поддерживает использование готовых 3D моделей различных форматов. Я выбрал для себя GLTF / GLB формат, где могут быть вшиты текстуры и анимация, т.е. разработчик не должен задаваться вопросом «все ли текстуры загрузились?».
Раньше я ни разу не имел дела с 3D редакторами. Логичным шагом было обратиться к специалисту на фриланс бирже с задачей создания 3D модели парусного корабля с вшитой анимацией залпа из пушек. Но я не удержался от мелких изменений в готовой модели специалиста своими силами, а закончилось это тем, что я создал свою модель с нуля в Blender. Создать low-poly модель почти без текстур — просто, сложно без готовой модели от специалиста изучить в 3D редакторе то, что нужно для конкретной задачи (как минимум морально :).
Шейдеры богу шейдеров
Основная причина, по которой мне нужны свои шейдеры — это возможность манипулирования геометрией объекта на видеокарте в процессе отрисовки, что отличается хорошей производительностью. Three.js не только позволяет создавать свои шейдеры, но и может брать часть работы на себя.
Механизм или способ, который я использовал при создании системы частиц для анимации повреждения корабля, динамичной водной поверхности или статичного морского дна один и тот же: специальный материал ShaderMaterial предоставляет упрощённый интерфейс использования своего шейдера (своего кода GLSL), BufferGeometry позволяет создавать геометрию из произвольных данных.
Пустая заготовка, структура кода, которую мне было удобно копировать, дополнять и изменять для создания своего 3D объекта подобным образом:
let vs = `
attribute vec4 color;
varying vec4 vColor;
void main(){
vColor = color;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
// gl_PointSize = 5.0; // for particles
}
`;
let fs = `
uniform float opacity;
varying vec4 vColor;
void main() {
gl_FragColor = vec4(vColor.xyz, vColor.w * opacity);
}
`;
let material = new THREE.ShaderMaterial( {
uniforms: {
opacity: {value: 0.5}
},
vertexShader: vs,
fragmentShader: fs,
transparent: true
});
let geometry = new THREE.BufferGeometry();
//let indices = [];
let vertices = [];
let colors = [];
/* ... */
//geometry.setIndex( indices );
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'color', new THREE.Float32BufferAttribute( colors, 4 ) );
let mesh = new THREE.Mesh(geometry, material);
Повреждение корабля
Анимация повреждения корабля — это движущиеся, изменяющие свой размер и цвет частицы, поведение которых определяется их атрибутами и GLSL кодом шейдера. Генерация частиц (геометрия и материал) происходит заранее, затем для каждого корабля создаётся свой экземпляр (Mesh) частиц урона (геометрия общая для всех, материал клонируется). Атрибутов частиц получилось довольно много, зато созданный шейдер реализует одновременно и большие медленно движущиеся облака пыли, и быстро разлетающиеся обломки, и частицы огня, активность которых зависит от степени повреждения корабля.
Море
Море также реализовано с помощью ShaderMaterial. Каждая вершина движется во всех 3-х направлениях по синусоиде образуя рандомные волны. Атрибуты определяют амплитуды для каждого направления движения и фазу синусоиды.
Чтобы разнообразить цвета на воде и сделать игру интереснее и приятнее глазу было решено добавить дно и острова. Цвет дна зависит от высоты/глубины и просвечивает сквозь водную поверхность создавая темные и светлые области.
Морское дно создаётся из карты высот, которая создавалась в 2 этапа: сначала в графическом редакторе было создано дно без островов (в моём случае инструментами были render → clouds и Gaussian blur), затем средствами Canvas JS онлайн на jsFiddle в случайном порядке были добавлены острова рисованием окружности и размытием. Некоторые острова низкие, через них можно стрелять в противников, другие имеют определённую высоту, через них выстрелы не проходят. Кроме самой карты высот, на выходе я получаю данные в json формате об островах (их положение и размеры) для физики на сервере.
Что дальше?
Есть много планов по развитию игры. Из крупных — новые режимы игры. Более мелкие — придумать тени/отражение на воде с учетом ограничений производительности WebGL и JS. Про возможность разбудить Кракена я уже упоминал :) Ещё не реализовано объединение игроков в комнаты на основе их накопленного опыта. Очевидное, но не слишком приоритетное усовершенствование — создать несколько карт морского дна и островов и выбирать для нового сражения одну из них случайным образом.
Можно создать немало визуальных эффектов путём неоднократной прорисовки сцены «в память» и последующего совмещения всех данных в одной картинке (по сути это можно назвать постобработкой), но у меня рука не поднимается увеличивать нагрузку на клиента подобным образом, ведь клиент это всё-таки браузер, а не нативное приложение. Возможно, однажды я решусь на этот шаг.
Есть также вопросы, на которые сейчас я затрудняюсь ответить: сколько игроков онлайн выдержит дешёвый виртуальный сервер, получится ли вообще собрать хоть какое-то количество заинтересованных игроков и как это сделать.
Пасхалка
Кто не любит вспомнить старые компьютерные игры, которые дарили так много эмоций? Мне нравится перепроходить игру «Корсары 2» (она же «Sea Dogs 2») снова и снова до сих пор. Не мог не добавить в свою игру секрет и явно и косвенно напоминающий о «Корсарах 2». Не стану раскрывать все карты, но дам подсказку: моя пасхалка — это некий объект, который вы можете найти исследуя морские просторы (далеко плыть по бескрайнему морю не нужно, объект находится в пределах разумного, но всё же вероятность найти его не высока). Пасхальное яйцо полностью восстанавливает поврежденный корабль.
Что получилось
Минутное видео (тест с 2 устройств):
Ссылка на игру: https://sailfire.pw
Там же есть форма для связи, сообщения летят мне в телеграм: https://sailfire.pw/feedback/
Ссылки для желающих быть в курсе новостей и обновлений: Паблик ВК, Телеграм канал