Как стать автором
Обновить

Интерактивная сетевая игра на HTML, CSS и JavaScript

Время на прочтение10 мин
Количество просмотров98K
Как-то поиграв в оффисе в hexbug, зародилась идея написать игрушку по схожим мотивам.
По текущему роду деятельности я веб разработчик и поэтому захотелось чтобы в игре использовался только HTML, JavaScript и CSS — средства знакомые каждому вебразработчику. Никакого вам flash или даже canvas. Звучит хардкорно, но на самом деле сейчас HTML + CSS3 это очень мощные и гибкие средства визуализации, а писать игровой код на JavaScript — одно удовольствие. Вдобавок захотелось чтобы игра была с сетевым мультиплеером, притом интерактивной — никаких там шашек, карточных игр, пошаговых стратегий, все должно быть в действии и движении.

Вот что получилось в итоге:



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



Геймплей


Задача игры — вырастить свою колонию жуков и уничтожить всех юнитов противника. Жуки хаотично бегают по игровому полю, а задача игрока — помогать им находить бонусы в виде различной еды и оказывать помощь своим юнитам, на которых было произведено нападение.

Бонусы в игре:
кекс — дает 5xp, при наборе 15xp жук размножается
яблоко — восстанавливает 50hp, если жук полностью здоров то добавляет 15 дополнительных hp
перец — увеличивает атаку на 5dm
желудь — дает 2xp и швыряется в ближайшего противника, при попадании наносит тройной урон
мухомор — дает 1хp и позволяет произвести ядовитый выстрел, при попадании наносит 1/2 урона и замедляет жертву


Играть могут от 2 до 4 человек. Можно также просто подключиться к серверу из разных вкладок браузера, и поиграть одному.
Попробовать поиграть можно здесь.
Исходники на github.
Архив с игрой.

Графика


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

Для работы с графикой в 2д игре нам понадобятся операции перемещения, вращения и маштабирования спрайтов.

Перемещаем спрайт устанавливая у него position: absolute и изменяя left и top



Для вращения спрайтов воспользуемся transform: rotate. А с помощью transform: origin можно задать ось вращения (по умолчанию она в центре спрайта).



Для маштабирования изменяем размреры спрайта с помощью свойств width и height, перед этим установив подходящее значение в background-size:



Аппаратное ускорение


Для повышения производительности и соответственно плавности анимации можно заставить браузер использовать GPU для отрисовки анимаций. Для этого нужно работать со спрайтами как с трехмерными объектами. Теперь сделаем операции перемещения, вращения и маштабирования через translate3d, rotate3d и scale3d:







Всех этих операций вполне хватило чтобы собрать графику в игре из нескольких нарисованных в «пэйнте» спрайтов.

Физика


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



Получаем формулу:



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



И еще несколько заметок:
  • Для задания угловых значений используйте радианы, а не градусы. Все угловые значения из Math возвращаются именно в них. Напомню полный оборот равняется 2 * PI радиан
  • Используйте понятие вектора для задания величин у которых есть направление. Даже положение спрайтов можно описывать вектором. Можно создать свой класс вектора или воспользоваться классом описанным в этой статье либо любым другим.
    Для примера, вектором задается скорость объектов, так как она имеет величину и направление. В этом случае чтобы увеличить скорость в двое мы просто умножаем вектор на 2, а чтобы изменить скорость в обратное направление мы инвертируем вектор (умножаем на -1).
  • Если в игре требуется сложная физика то можно посмотреть в сторону box2d-js. Эта библиотека позволит создать игровой мир с объектами различной формы, гравитацией, массой, инерцией, силой трения и прочими благами ньютоновской физики


Пример класса Вектор
		// инициализация
		function Vec (x_, y_) {
			if (typeof x_ == 'object') {
				this.setV(x_);
				return;
			}
			this.x= typeof x_ == 'number' ? x_ : 0;
			this.y= typeof y_ == 'number' ? y_ : 0;
		}

		Vec.prototype = {

			// установка в 0
			setZero: function() {
				this.x = 0.0;
				this.y = 0.0;
			},

			// установка значений x и y
			set: function(x_, y_) {this.x=x_; this.y=y_;},

			// установка значений из объекта
			setV: function(v) {
				this.x=v.x;
				this.y=v.y;
			},

			// реверс вектора
			negative: function(){
				return new Vec(-this.x, -this.y);
			},

			// копия вектора
			copy: function(){
				return new Vec(this.x,this.y);
			},

			// сложение с вектором
			add: function(v) {
				this.x += v.x; this.y += v.y;
				return this;
			},

			// вычетание вектора
			mubtract: function(v) {
				this.x -= v.x; this.y -= v.y;
				return this;
			},

			// умножение на число
			multiply: function(a) {
				this.x *= a; this.y *= a;
				return this;
			},

			// деление на число
			div: function(a) {
				this.x /= a; this.y /= a;
				return this;
			},

			// получение длины вектора
			length: function() {
				return Math.sqrt(this.x * this.x + this.y * this.y);
			},

			// нормализация вектора (приведение к вектору с длиной = 1)
			normalize: function() {
				var length = this.length();
				if (length < Number.MIN_VALUE) {
					return 0.0;
				}
				var invLength = 1.0 / length;
				this.x *= invLength;
				this.y *= invLength;

				return length;
			},

			// получение угла вектора
			angle: function () {
				var x = this.x;
				var y = this.y;
				if (x == 0) {
					return (y > 0) ? (3 * Math.PI) / 2 : Math.PI / 2;
				}
				var result = Math.atan(y/x);

				result += Math.PI/2;
				if (x < 0) result = result - Math.PI;
				return result;
			},

			// получение растояния до другого вектора (полезно если вектором задается положение спрайта)
			distanceTo: function (v) {
				return Math.sqrt((v.x - this.x) * (v.x - this.x) + (v.y - this.y) * (v.y - this.y));
			},

			// получение вектора проведенного от вершины x,y данного вектора до вершины x,y другого вектора  
			vectorTo: function (v) {
				return new Vec(v.x - this.x, v.y - this.y);
			},

			// поворот вектора на заданный угл
			rotate: function (angle) {
				var length = this.length();
				this.x = Math.sin(angle) * length;
				this.y = Math.cos(angle) * (-length);
				return this;
			}
	};



Используемые паттерны разработки


В нескольких словах игровую логику можно описать так: Есть объект класса «Game» описывающий игровой мир у которого есть массив объектов-наследников от класса «GameObject» — это все объекты игрового мира. Каждый игровой кадр Game проходится по всем игровым объектам и вызывает у каждого метод step. В методе step каждого объекта описывается что он должен сделать за этот кадр (переместиться, обработать столкновения, уничтожиться и тд.) Для реализации ООП в игре используется объект Class из Simple JavaScript Inheritance от John Resig, доработанный до поддержки миксинов и статических свойств.
Наверное один из самых удачных патернов для создания новых объектов в играх это использование фабричного метода. Суть в том что мы не будем напрямую через вызов new Создавать объекты, а воспользуемся методом который за нас это сделает. Фабричный метод избавит нас от возни с подключением нового объекта в игровой мир.

Например мы хотим создать объект класса Block включить его в игровой мир и расположить в заданном месте:
	game.create('Block', {x: 100, y: 150});


Код метода create:
		create: function (objectName, params) {

			// для удобства все классы доступные для создание через фабричный метод хранятся в Game.classes
			// Создаем объект получая его класс из Game.classes
			var object = new Game.classes[objectName](params);

			// присваиваем ему уникальный идентефикатор
			object.id = ++this.idx;

			// задаем объекту ссылку на игровой мир
			object.game = this;

			// добавляем получившийся объект в массив игровых объектов
			this.objects[object.id] = object;

			// если объект может сталкиваться с другими объектами то дополнительно
			// помещаем ссылку на него в соответствующий массив
			if (object.isColliding) this.collidingObjects[object.id] = object;

			// сообщаем объекту что он полностью подключен к игровому миру
			// с помощью вызова метода birth, в котором он может завершить инициализацию
			object.birth();

			// возвращаем готовый объект
			return object;
		},


Создание игровых карт


Итак когда игра уже написанна хочется разнообразить ее несколькими игровыми картами. Создавать все игровые объекты кодом (вызывая метод за методом) очень утомительно и ненаглядно. Писать свой редактор карт займет достаточно много времени. Но есть простой способ — можно воспользоваться текстовым редактором или своей ide для наглядного создания следующим подходом:

	!function () {

	var WIDTH = 20;
	var HEIGHT = 12;

	var B = 'Block';
	var P = 'Bonus';

	var MAP = [
		,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,
		, B,  , B,  , B, B, B,  , B,  ,  ,  , B,  ,  ,  , B, B, B,
		, B,  , B,  , B,  ,  ,  , B,  ,  ,  , B,  ,  ,  , B,  , B,
		, B, B, B,  , B, B, B,  , B,  ,  ,  , B,  ,  ,  , B,  , B,
		, B,  , B,  , B,  ,  ,  , B,  ,  ,  , B,  ,  ,  , B,  , B,
		, B,  , B,  , B, B, B,  , B, B, B,  , B, B, B,  , B, B, B,
		,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,  ,
		, B,  , B,  , B, B, B,  , B, B, B,  , B, B, B,  ,  ,  ,  ,
		, B,  , B,  , B,  , B,  , B,  , B,  , B,  , B,  ,  ,  ,  ,
		, B, B, B,  , B, B, B,  , B, B,  ,  , B, B,  ,  ,  ,  ,  ,
		, B,  , B,  , B,  , B,  , B,  , B,  , B,  , B,  ,  ,  ,  ,
		, B,  , B,  , B,  , B,  , B, B, B,  , B,  , B,  , P,  ,  ,
	];

	Game.maps['Hello'] = Game.Map.extend({

		build: function () {

			var blockSize = 20;
			for (var i = 0; i < HEIGHT; i++) {
				for (var j = 0; j < WIDTH; j++) {
					var index = WIDTH * i + j;
					if (MAP[index]) this.game.create(MAP[index], {x: blockSize * j, y: blockSize * i});
				}
			}
		}

	})
}();

Результат:



Сетевой код


Сетевой код написан с использованием вебсокетов спомощью библиотеки socket.io, сервер игры написан на nodejs.
Сделать по простому реализацию интерактивной сетевой игры да и с условием что нам доступнен только протокол TCP та еще задачка.
Сейчас для таких игр используют быстрый протокол UDP который к сожалению недоступен через socket.io, правда если есть сильное желание можно посмотреть в сторону WebRTC. Важно чтобы игра шла плавно без рывков и была синхронизированна на всех клиентах. Сервер будет простой и будет заниматься только передачей сообщений клиентов, так как только их действия влияют на ход игры. Он не будет заниматься передачай состояний игровых объектов, и вобще ничего не будет знать об игровом мире, кроме состояния игры — ожидание игроков/идет игра

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



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

Можно задаться вопросом — если мы передаем только действия клиентов, то как синхронизировать поведение объектов основанное на случайности? Ведь различные бонусы появляются в совершенно случайных местах, но у всех клиентов это должны быть одни и теже места. Жуки бегают весьма хаотично, постоянно меняя направление своего бега, и приэтом весь этот «хаос» должен быть совершенно одинаковым и идти по одному и тому же сценарию у всех. Проблемму с синхронизацией такого поведения можно решить тем, чтобы везде где используются случайные величины, не использовать для этого Math.random, а использовать свой генератор псевдослучайных чисел (ГПСЧ). Суть в следующем — перед запуском игры сервер генерирует случайное число и передает его каждому присоединившемуся клиенту. С помощью этого числа клинет инициализирует ГПСЧ котрый на всех клиентах будет выдавать одинаковую последовательность псевдослучайных чисел. Простейшая реализация такого ГПСЧ — генератор парка-миллера
Реализация на js:

	var ParkMillerGenerator = function (initializer) {
		this.a = 16807;
		this.m = 2147483647;
		this.val = initializer || Math.round(2147483647 / 3);
	}

	ParkMillerGenerator.prototype = {
		next: function () {
			this.val = (this.a * this.val) % this.m;
			return (this.val / 1000000) % 1;
		}
	}


Использование:
	var initializer = 333; // задаеем инициализирующее число, у всех клиентов оно должно быть одинаковое
	var gen = new ParkMillerGenerator(initializer); // создаем ГПСЧ
	gen.next(); // 0.5967310000000001
	gen.next(); // 0.46109599999999773
	gen.next(); // 0.07891199999994569;


Делаем сервис из nodejs приложения


Может немного не втему, но тоже полезная заметка. Когда сервер написан, неплохо бы запустить его на боевой машине в виде службы для постоянной работы. Опишу как это можно сделать на примере Ubuntu.
Переходим в /etc/init.d и создаем там шелл-скрипт с названием нашей службы, у меня будет bugsarena. Обращу внимание что блок начинающийся с «BEGIN INIT INFO» не просто коментарий, а настройки нашей службы и удалять его не стоит.

#!/bin/sh

### BEGIN INIT INFO
# Provides:          bugsarena
# Required-Start:    $local_fs $remote_fs $network $syslog
# Required-Stop:     $local_fs $remote_fs $network $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: starts the bugsarena servers
# Description:       starts the bugsarena servers
### END INIT INFO

# задаем пути и параметры к исполняемым файлам (нужно указать свои)
NODE=/usr/bin/node
DAEMON_SERVER=/home/me/projects/bugs-arena/server/server.js
SERVER_PARAMS="name=Arena-Dogfight map=Dogfight port=8090"

NAME=bugsarena
DESC="bugsarena servers"

# сервис должен принимать 3 команды - start, stop и restart.
# опишем обработчики этих комманд

start() {
	# запускаем nodejs приложение в качестве демона и сохраняем его pid в файл
	start-stop-daemon --start --make-pidfile --background --pidfile /var/run/$NAME-server.pid \
                --exec $NODE -- $DAEMON_SERVER $SERVER_PARAMS

}

stop() {
	# останавливаем nodejs приложение
	echo -n "Stopping $DESC: "
    start-stop-daemon --stop --quiet --pidfile /var/run/$NAME-server.pid
}

case "$1" in
	start)
		start
		;;
	stop)
		stop
		;;
	restart)
		stop
		sleep 1
		start
		;;
	*)
		echo "Usage: $NAME {start|stop|restart}" >&2
		exit 1
		;;
esac

exit 0


Делаем файл исполняемым.
sudo chmod +x bugsarena


Теперь можно воспользоваться командами
service bugsarena start
и
service bugsarena stop
для запуска и остановки службы.
Также можно сделать чтобы игровой сервер стартовал при запуске системы выполнив
update-rc.d bugsarena defaults


Не забываем о XSS!


На последок просто необходимо напомнить об очень простой атаке свойственной для браузерных игр. Представим что у нас есть список игроков в каком-нибудь div'e. И к нам в игру заходит игрок с именем "<script>alert('В игру заходит Вася!')</script>". Его имя добавляется в div со списком игроков, и все клиенты получают назойливое сообщение alert'ом. И это еще цветочки. Через XSS уязвимость можно спокойно подгрузить любой скрипт с любого сайта. Так что не забываем об экранировании передаваемых с клиентов данных.
Теги:
Хабы:
Всего голосов 45: ↑42 и ↓3+39
Комментарии23

Публикации

Истории

Работа

Ближайшие события

22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань