Spy vs Spy на canvas и node.js

в 9:28, , рубрики: canvas, game development, javascript, node.js, игра, метки: , , ,

Spy vs Spy на canvas и node.js

Однажды решили мы с братом (brdsoft) создать браузерную игру. Опыта создания игр мы не имели, поэтому игра должна была получиться простой. Немного посовещавшись мы решили сделать копию NES игры Spy vs Spy с мультиплеером.

Данная статья будет состоять из двух частей:
1. Общие сведения и реализация сервера
2. Реализация клиента

Выбранные инструменты

Серверная часть:
Node.js (+расширения socket.io, mysql, node-v8-clone) для реализации всей игровой механики (далее по тексту — сервер).
MySQL для хранения пользователей и карт.
PHP для регистрации пользователей, обфускации клиентского js файла, взаимодействия с API соц. сетей

Клиентская часть:
JavaScript для реализации клиентской логики (далее по тексту — клиент)
Canvas для рисования

Основные требования

  • Работоспособность во всех браузерах, поддерживающих canvas
  • Реализация всех игровых взаимодействий на сервере (для исключения читерства)
  • Возможность встроить игру в социальные сети
  • Мультиплеер
  • Минимальный трафик

Сервер

Описывать процесс регистрации в игре не вижу смысла. Единственный важный момент: после регистрации каждый пользователь получает уникальный token (token сохраняется в куки), который в будущем будет применяться для определения пользователя сервером.

При взаимодействии клиента и сервера можно выделить основные события:
Клиент подключился
Клиент получил сообщение (сервер отправил сообщение)
Сервер получил сообщение (клиент отправил сообщение)
Клиент отключился

Обо всем по порядку.

Сервер слушает порт 55705 и ждет подключение:

var io =    require('/usr/lib/node_modules/socket.io').listen(55705, {log: false});
var clone = require('/usr/lib/node_modules/node-v8-clone').clone;
var db =    require('/usr/lib/node_modules/mysql').createConnection({
	host     : 'host',
	user     : 'user',
	password : 'password',
	database : 'database'
});
db.connect();

io.sockets.on('connection', function (socket){
	//Обработчик новых подключений
});

При подключении клиента создается новый объект класса User, и на сокет вешаются события:

var users = {};
io.sockets.on('connection', function (socket){
	users[socket.id] = new User(socket);
	socket.on('auth', users[socket.id].auth);
	socket.on('createGame', createGame); //Команда на создание новой игры (это одна из многих команд отправляемых с клиента)
	//Различные игровые команды
	//...
	socket.on('disconnect', users[socket.id].disconnect);
});

В socket.io есть два основных метода для обмена данными между сервером и клиентом: emit для передачи сообщения и on для приема.

Вот как выглядит подключение со стороны клиента:

	socket = io.connect('http://spyvsspy.ru:55705');
	socket.on('connect', function() {
		socket.emit('auth', {token: token});
	});

token берется из cookie и отправляется сразу после подключения для аутентификации. Функции auth и disconnect являются методами класса User. В упрощенной форме класс User выглядит так:

function user(socket)
{
	this.socket = socket;
	this.profile = {}; //Профиль заполнится после запроса в БД
	this.isAuthorized = false;
	this.auth = function(data)
	{
		//...Валидация data и data.token
		//Запрос в таблицу пользователей
		db.query("SELECT*FROM `users` WHERE `token` = '"+data.token+"'", function(err, rows){
			if (rows[0])
			{
				for(var i in users)
				{
					if (users[i].profile.id == rows[0].id) //Если один и тот же пользователь подключается дважды
					{
						users[socket.id].socket.disconnect(); //Отключить его
						return;
					}
				}
				users[socket.id].profile = rows[0];
				users[socket.id].isAuthorized = true;
				users[socket.id].socket.join('main'); //Присоединить сокет к главной комнате (нужно для чата)
				users[socket.id].socket.emit('auth', {login: users[socket.id].profile.login, rating: users[socket.id].profile.rating, socId: users[socket.id].profile.soc_id}); //Передать некоторые данные профиля на клиент
				return;
			}
			users[socket.id].socket.disconnect();
		});
	}

	this.disconnect = function() //Функция вызывается при отключении клиента, в том числе при принудительном отключении
	{
		if (this.gameId) //Отключить игрока от игры
		{
			games[this.gameId].removeUser(socket.id);
		}
		delete users[socket.id]; //Удалить пользователя
	}
}

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

socket.on('createGame', createGame);
socket.on('joinGame', joinGame);
socket.on('startGame', startGame);
socket.on('chatReceive', chatReceive);
socket.on('openAttr', openAttr);
socket.on('toggleDoor', toggleDoor);
socket.on('goDoor', goDoor);
socket.on('moveTo', moveTo);
socket.on('exitGame', exitGame);
socket.on('setMaxPlayers', setMaxPlayers);
socket.on('kick', kick);
socket.on('getGames', getGames);
socket.on('setTool', setTool);
socket.on('hit', hit);
socket.on('chooseTool', chooseTool);

createGame — команда на создание новой игры. Клиенту возвращается id созданной игры. Игры как и пользователи хранятся в «объекте-массиве». Каждая игра существует пока в ней есть хотя бы один пользователь. Получив сообщение о создании игры, клиент отправляет joinGame. C этого момента игра становится видна остальным игрокам. Для запуска игры — startGame, для выхода из игры — exitGame.

chatReceive — сообщение в чат. В игре есть общий чат и игровой. Каждая созданная игра имеет свой roomId, который подставляется при отправке сообщения:

function chatSend(room, message, level, from)
{
	io.sockets.in(room).emit('chatReceive', {message: message, level: level, from: from});
}

Возможность создавать комнаты («румы») — очень полезная особенность socket.io.

openAttr — отправляется когда клиент кликнул по какому-нибудь шкафчику и подошел к нему. Результат выполнения функции openAttr — игрок получает содержимое шкафчика или взрывается если шкафчик был заминирован. Шкафчик минируется если у игрока в руках была бомба. Содержимое инвентаря игрока хранится на сервере. Предмет, который находится в руках также хранится на сервере. Клиент лишь отрисовывает все и отдает команды.

toggleDoor — открыть/закрыть дверь. goDoor — пройти в дверь. Сервер возвращает либо смерть, либо информацию о новой комнате.

moveTo — перемещение по комнате. Информация о перемещениях передается всем шпионам, находящимся в этой же комнате. Помимо перемещений передаются данные о предмете в руках, о действиях с дверьми и мебелью, смех, удары, смерть. Для экономии трафика данные о перемещениях передаются только в момент клика. Но благодаря нехитрой интерполяции сервер всегда знает в каком месте комнаты находится игрок, и если в момент движения в комнату войдет другой шпион, он увидит бегущего соперника.

Исходный код moveTo для примера

function isFloor(x, y)
{
	return (y > 96) && (y < 184) && (x > 192 - y) && (x < 160 + y);
}

function moveTo(data)
{
	var userId = this.id;
	if (!users[userId].isAuthorized)
		return;
	if (users[userId].locked)
		return;
	var gameId = users[userId].gameId;
	if (!games[gameId])
		return;
	if (games[gameId].status != 'game')
		return;
	if (!isFloor(data.x, data.y) || !isFloor(data.prevX, data.prevY))
		return;

	var roomId = users[userId].roomId;
	var room = games[gameId].rooms[roomId];
	
	users[userId].attrDialog = 0;
	users[userId].attrAttr = '';

	if (users[userId].tool == 'gas')
	{
		room.mined = 1;
		users[userId].stock.gas -= 1;
		users[userId].tool = '';
		users[userId].laugh();
		users[userId].setStock();
		users[userId].setTool();
		return;
	}

	users[userId].prevX = data.prevX;
	users[userId].prevY = data.prevY;
	users[userId].moveToX = data.x;
	users[userId].moveToY = data.y;
	users[userId].move();

	for (var i in games[gameId].users)
	{
		if (users[i].roomId == users[userId].roomId && i != userId)
			users[i].socket.emit('moveSpy', {color: users[userId].color, roomId: users[userId].roomId, x: data.x, y: data.y});
	}
}

Функции интерполяции:

	this.move = function(){
		var d = new Date();
		this.moveTime = d.getTime(); //Начало движения
	}

	this.getCurrentCoord = function(){
		var dx = this.moveToX - this.prevX;
		var dy = this.moveToY - this.prevY;
		if (Math.abs(dx) < Math.abs(dy))
		{
			var t = (Math.abs(dy) + 0.41 * Math.abs(dx)) * 5.714;
		}
		else
		{
			var t = (Math.abs(dx) + 0.41 * Math.abs(dy)) * 5.714;
		}
		var d = new Date();
		var k = (d.getTime() - this.moveTime) / t;
		if (k < 0) k = 0;
		if (k > 1) k = 1;
		return {x: Math.round(this.prevX + k * dx), y: Math.round(this.prevY + k * dy)};
	}

Не буду описывать оставшиеся события. Из их названий примерно понятно назначение.

Некоторые факты

Для определения силы игроков в игре используется рейтинг Эло без отрицательных значений.
В отличие от оригинальной игры, у нас можно играть впятером на одной карте.
Игра разрабатывается с 19 ноября и на момент написания статьи было потрачено примерно 250 человекочасов. server.js содержит 1480 строк кода. game.js — 1300 строк.
Компания, которой принадлежат права на игру, ничего не знает :)
В игре пока только 5 карт, но уже готов редактор карт и скоро их будет много.
Карта поддерживает до 8 этажей 8x8 каждый, можно делать серьезные лабиринты.
Можно играть одному без учета рейтинга
Мы будем совершенствовать игру пока не надоест. Мы ее тестили, но доработок хватает.
JS файл клиента был слегка обфусцирован для защиты от жуликов, но желающие могут получить его целиком или почитать наш следующий пост.

Ссылки

Сайт игры
Игра ВКонтакте
Группа ВКонтакте

Правила игры и управление описаны в самой игре. Правила мало чем отличаются от оригинальной игры.

Скриншот

Spy vs Spy на canvas и node.js

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

Спасибо за внимание

Автор: Shvonder

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js