Как-то поиграв в оффисе, в 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 уязвимость можно спокойно подгрузить любой скрипт с любого сайта. Так что не забываем об экранировании передаваемых с клиентов данных.
Автор: alexclimber
На сайте superplayers1.ru есть пример карточной игры с использованием и CSS и HTML и JavaScript плюс курс по созданию карточной игры