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

от автора

Как-то поиграв в оффисе, в 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 

Теперь можно воспользоваться командами

service bugsarena start

и

service bugsarena stop

для запуска и остановки службы.
Также можно сделать чтобы игровой сервер стартовал при запуске системы выполнив

update-rc.d bugsarena defaults

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

На последок просто необходимо напомнить об очень простой атаке свойственной для браузерных игр. Представим что у нас есть список игроков в каком-нибудь div’e. И к нам в игру заходит игрок с именем "<script>alert(‘В игру заходит Вася!’)</script>". Его имя добавляется в div со списком игроков, и все клиенты получают назойливое сообщение alert’ом. И это еще цветочки. Через XSS уязвимость можно спокойно подгрузить любой скрипт с любого сайта. Так что не забываем об экранировании передаваемых с клиентов данных.

ссылка на оригинал статьи http://habrahabr.ru/post/225669/