Море, пираты — 3D онлайн игра в браузере

Приветствую пользователей Хабра и случайных читателей. Это история разработки браузерной многопользовательской онлайн игры с low-poly 3D графикой и простейшей 2D физикой.

Позади немало браузерных 2D мини-игр, но подобный проект для меня в новинку. В gamedev решать задачи, с которыми ещё не сталкивался, может быть довольно увлекательно и интересно. Главное — не застрять со шлифовкой деталей и запустить рабочую игру пока есть желание и мотивация, поэтому не будем терять время и приступим к разработке!

lwfg5g2m2_f1stam4yolybw82lq.png


Об игре в двух словах


Бой на выживание — единственный на данный момент режим игры. Сражения от 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 менее очевидный выбор, но критерий всё тот же — быстро и просто, так как есть опыт в данных технологиях.

Получается вот такая схема:

3x0ygpm2zcqrosyronumoml1l3s.png

PHP нужен в первую очередь для отдачи клиенту веб-страничек и для редких AJAX запросов, но по большей части клиент общается всё же с игровым сервером на Node.js по веб-сокетам.

Мне совсем не хотелось связывать игровой сервер с БД, поэтому всё идет через PHP. На мой взгляд тут есть свои плюсы, хотя не уверен значимы ли они. Например, так как в Node.js приходят уже готовые данные в нужном виде, Node.js не тратит время на обработку и дополнительные запросы в БД, а занимается более важными делами — «переваривает» действия игроков и изменяет состояние игрового мира в комнатах.

Сначала модель


Разработка началась с простого и самого главного — некой модели игрового мира, описывающей морские сражения с серверной точки зрения. Обычный canvas 2D для схематичного отображения модели на экране подходит идеально.

muu4ynireimyrsbtdfep9h4utte.png

Изначально я поставил нормальную «верлетовую» физику, и учитывал различное сопротивление движению корабля в разных направлениях относительно направления корпуса. Но беспокоясь о производительности сервера, я заменил нормальную физику на простейшую, где очертания корабля остались только в визуале, физически же корабли — круглые объекты, которые даже не обладают инерцией. Вместо инерции — ограниченное ускорение движения вперед.

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

Клиентский JavaScript для визуализации модели игрового мира, обрабатывающий движение кораблей и выстрелы, я перенес на Node.js сервер почти без изменений.

Игровой сервер


Node.js WebSocket сервер состоит всего из 3 скриптов:

  • main.js — основной скрипт, который получает WS сообщения от игроков, создаёт комнаты и заставляет шестеренки этой машины крутиться
  • room.js — скрипт, отвечающий за игровой процесс внутри комнаты: обновление игрового мира, рассылка обновлений игрокам комнаты
  • funcs.js — включает класс для работы с векторами, пару вспомогательных функций и класс реализующий двусвязный список


По мере разработки добавлялись новые классы — почти все из них напрямую связаны с игровым процессом и попали в файл room.js. Порой бывает удобно работать с классами по отдельности (в отдельных файлах), но вариант всё в одном тоже неплох, пока классов не слишком много (удобно прокрутить вверх и вспомнить какие параметры принимает метод другого класса).

Актуальный список классов игрового сервера:

  • WaitRoom — комната, куда попадают игроки ожидающие начала сражения, здесь есть собственный tick метод, рассылающий свои обновления и запускающий создание игровой комнаты когда больше половины игроков готовы к бою
  • Room — игровая комната, где проходят сражения: обновляются состояния игроков/кораблей, затем обрабатываются возможные столкновения, в конце формируется и рассылается всем сообщение с актуальными данными
  • Player — по сути «обёртка» с некоторыми дополнительными свойствами и методами для следующего класса:
  • Ship — этот класс заставляет корабли плавать: реализует движение и повороты, здесь также хранятся данные о повреждениях, чтобы в конце игры зачислить очки игрокам, принимавшим участие в уничтожении корабля
  • PhysicsEngine — класс, реализующий простейшие столкновения круглых объектов
  • PhysicsBody — всё есть круглые объекты со своими координатами на карте и радиусом


Игровой цикл в классе Room выглядит примерно так
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 редакторе то, что нужно для конкретной задачи (как минимум морально :).

avsmpj9ekcha6dqcagpf7lj19yi.png

Шейдеры богу шейдеров


Основная причина, по которой мне нужны свои шейдеры — это возможность манипулирования геометрией объекта на видеокарте в процессе отрисовки, что отличается хорошей производительностью. 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) частиц урона (геометрия общая для всех, материал клонируется). Атрибутов частиц получилось довольно много, зато созданный шейдер реализует одновременно и большие медленно движущиеся облака пыли, и быстро разлетающиеся обломки, и частицы огня, активность которых зависит от степени повреждения корабля.

cbuvouef_rql9cfhfj23h_yvpfk.png

Море


Море также реализовано с помощью ShaderMaterial. Каждая вершина движется во всех 3-х направлениях по синусоиде образуя рандомные волны. Атрибуты определяют амплитуды для каждого направления движения и фазу синусоиды.

Чтобы разнообразить цвета на воде и сделать игру интереснее и приятнее глазу было решено добавить дно и острова. Цвет дна зависит от высоты/глубины и просвечивает сквозь водную поверхность создавая темные и светлые области.

Морское дно создаётся из карты высот, которая создавалась в 2 этапа: сначала в графическом редакторе было создано дно без островов (в моём случае инструментами были render → clouds и Gaussian blur), затем средствами Canvas JS онлайн на jsFiddle в случайном порядке были добавлены острова рисованием окружности и размытием. Некоторые острова низкие, через них можно стрелять в противников, другие имеют определённую высоту, через них выстрелы не проходят. Кроме самой карты высот, на выходе я получаю данные в json формате об островах (их положение и размеры) для физики на сервере.

dslkh3wwhiubvzqupt7f3hy7gxy.png

Что дальше?


Есть много планов по развитию игры. Из крупных — новые режимы игры. Более мелкие — придумать тени/отражение на воде с учетом ограничений производительности WebGL и JS. Про возможность разбудить Кракена я уже упоминал :) Ещё не реализовано объединение игроков в комнаты на основе их накопленного опыта. Очевидное, но не слишком приоритетное усовершенствование — создать несколько карт морского дна и островов и выбирать для нового сражения одну из них случайным образом.

Можно создать немало визуальных эффектов путём неоднократной прорисовки сцены «в память» и последующего совмещения всех данных в одной картинке (по сути это можно назвать постобработкой), но у меня рука не поднимается увеличивать нагрузку на клиента подобным образом, ведь клиент это всё-таки браузер, а не нативное приложение. Возможно, однажды я решусь на этот шаг.

Есть также вопросы, на которые сейчас я затрудняюсь ответить: сколько игроков онлайн выдержит дешёвый виртуальный сервер, получится ли вообще собрать хоть какое-то количество заинтересованных игроков и как это сделать.

Пасхалка


Кто не любит вспомнить старые компьютерные игры, которые дарили так много эмоций? Мне нравится перепроходить игру «Корсары 2» (она же «Sea Dogs 2») снова и снова до сих пор. Не мог не добавить в свою игру секрет и явно и косвенно напоминающий о «Корсарах 2». Не стану раскрывать все карты, но дам подсказку: моя пасхалка — это некий объект, который вы можете найти исследуя морские просторы (далеко плыть по бескрайнему морю не нужно, объект находится в пределах разумного, но всё же вероятность найти его не высока). Пасхальное яйцо полностью восстанавливает поврежденный корабль.

Что получилось


Минутное видео (тест с 2 устройств):


Ссылка на игру: https://sailfire.pw

Там же есть форма для связи, сообщения летят мне в телеграм: https://sailfire.pw/feedback/
Ссылки для желающих быть в курсе новостей и обновлений: Паблик ВК, Телеграм канал

© Habrahabr.ru