Приветствую %habrname%!

Во вчерашней статье для меня в общем-то было ожидаемо, что большинству не будет интереса к nodejs, многие посмотрят только демо. Но к сожалению я не учёл, что и оценивать в статье будут именно игру! Хоть мне было и обидно потратив столько времени на написание статьи (а что самое главное игра писалась именно для статьи, а не наоборот), я сегодня решил написать продолжение.
Ну что же! Проведём работу над ошибками и сделаем работающую игру со всем о чём просили, но при этом не будем отклонятся от темы блога и рассмотрим все технические моменты с которыми столкнулись при тесте игры.
Первым сообщением в топике стал первый баг-репорт от хабравчанина Assargin:
Это великолепно — я успеваю нажать раз 5-6 подряд, пока браузер и/или интернет тормозит и выигрываю автоматически после первого хода! Ни одного шанса у соперника! :)
Причем такое и в FF 3.6.24, и в хроме 15.0.874.106
А следом за ним и от Morfi:
Как так?
Здесь я упустил момент с двойным ходом, ну оно и понятно, цель тестирования игры у меня была чтобы всё работало. С другой стороны валидация была только на клиенте кто ходит. Перенесём её на сервер.
Проверка кто ходит на сервере
Открываем наш файл с сердцем игры modules/tictactoe.js в нём ищем функцию хода, которая является объектом игры:
GameItem.prototype.step = function(x, y, user, cb) {
if(this.board[x + 'x' + y] !== undefined) return; // Кстати эту ошибку "ход в одну и туже клетку поля", помог найти <hh user="theshock"></hh> за что ему отдельная благодарность!
this.board[x + 'x' + y] = this.getTurn(user);
this.steps++;
cb(this.checkWinner(x, y, this.getTurn(user)), this.getTurn(user));
}
Как видим при получении хода мы проверяли его только на занятость поля, теперь нам нужно добавить проверку на то, чей же ход, для начало введём новое свойства у объекта игры под названием turn:
var GameItem = function(user, opponent, x, y, stepsToWin) {
...
// Кто ходит
this.turn = 'X'; // По умолчанию она начинается с Х, т.к. он ходит всегда первый и он же создаёт игру.
}
Добавим проверку в step и переключения хода сопернику:
GameItem.prototype.step = function(x, y, user, cb) {
if(this.board[x + 'x' + y] !== undefined || this.getTurn(user) != this.turn) return; // Если текущий ход не равен turn то ничего не делаем в игре
this.board[x + 'x' + y] = this.getTurn(user);
this.turn = (user != this.user ? 'X' : 'O'); // Следующий ход будет противоположенный от текущего
this.steps++;
cb(this.checkWinner(x, y, this.getTurn(user)), this.getTurn(user));
}
На этом первый баг профиксили. Но увидев скрин Morfi мне в глаза бросился баг в UI, который не обновлял интерфейс, это там где 3 раза написано «Вы играете: Ноликом». Дебаг игры вывел, что при конце игры мы отключаем пользователя от персональный комнаты игры в socket.io и тем самым выводим его в общую комнату для новых игр. И тем самым подключаем пользователя к новым играм, хотя он не успел ещё нажать кнопку «новая игра». Профиксить это можно легко, добавим вход в игру по нажатию кнопки, а не как сейчас автоматически.
Конец игры без автоматического перезапуска новой
Открываем наш клиентский файлик tictactoe.js который находится в папке public и добавим на кнопку вызов события о старте игры, после чего изменим событие клика на перезагрузку страницы:
$('#reload').hide().button({icons:{primary:'ui-icon-refresh'}}).click(function() {
$('#reload').off('click').click(function(){window.location.reload();});
socket.emit('start');
});
А на серверной части index.js возьмём функцию запуска игры в отдельное событие:
socket.on('start', function () {
if(Game.users[socket.id] !== undefined) return; // так же добавим проверку, что такой пользователь не играет в другой игре
Game.start(socket.id.toString(), function(start, gameId, opponent, x, y){
...
});
});
Теперь у нас не будет проблем с перезапуском без ведома пользователя, пока он сам не нажмёт начать новую игру.
Таймаут хода
Многие стали жаловаться что никто не ходит, это было самым большим злом, возможно из-за проблем которые были у многих с игрой они просто не могли ходить и игра тупо висела открытой. Как правильно заметил Evengard нужен был таймаут хода. Вернёмся к нашему клиентскому файлу, который мы правили чуть ранее public/titactoe.js и добавим в него новую переменную и изменим функцию маски:
var TicTacToe = {
gameId: null,
turn: null,
i: false,
socket: null,
interval: null, // Добавили
...
mask: function(state) {
var mask = $('#masked'), board = $('#board-table');
clearInterval(this.interval);
$('#timer').html(15);
this.interval = setInterval(function(){
var i = parseInt($('#timer').html()); i--;
$('#timer').html(i);
}, 1000);
if(state) {
mask.show();
var p = board.position();
mask.css({
width: board.width(),
height: board.height(),
left: p.left,
top: p.top
});
} else {
mask.hide();
}
},
Сам таймер включать мы будем при старте игры и при получении хода, а выключать при конце игры и при получение хода перед тем как включать заново. Комментировать код я не буду, проще будет посмотреть исходники на github там всё понятно, нас больше интересует серверная реализация.
Серверный timeout и изучаем EventEmitter
Теперь перейдём к серверной части. Здесь нам нужно правильно установить таймер, чтобы он работал только для нужной игры, поэтому будем наращивать объект игры дальше. Но, я же обещал что эта статья будет обучающим материалом, поэтому делать мы будем не простой функцией, а изучим что такое события в NodeJS, а точнее познакомимся с EventEmitter поближе.
Для работы на нужно будет его подключать, ведь это отдельный модуль events в nodejs.
В самом начале файле models/tictactoe.js мы добавим подключение EventEmitter и Util, для чего нам нужен последний я объясню немного позднее.
var util = require('util'), EventEmitter = require("events").EventEmitter;
В данном вызове мы не только подключаем модуль но и создаём новый объект экспортируемой функции.
Немного теории
Поскольку мы уже используем socket.io, который основан на событиях, а это значит что EventEmitter уже подключён там, но не объявлен в нашем контексте. Для его использования мы пропишем его подключение по новой, при этом nodejs не будет его подключать повторно, все require кэшируются и каждый повторный вызов это всего лишь обращение к уже подключённому модулю. Что из себя представляет EventEmitter — это объект событийной машины, простым языком это некий мониторинг за вызовами событий, точно такой же как и в обычном javascript, но для работы с ним нужно сделать небольшой фокус.
Вернёмся к практики
Для начало нам нужно добавить обработчик событий в наши объекты игры, именно поэтому я подключил ещё один модуль util он содержит набор разных функций, подробное описание каждой из них можно найти в официальной документации, а нам нужна лишь одна inherits для добавление EventEmitter в TicTacToe и GameItem:
var TicTacToe = module.exports = function() {
// Инициализируем события
EventEmitter.call(this);
...
}
util.inherits(TicTacToe, EventEmitter);
var GameItem = function(user, opponent, x, y, stepsToWin) {
// Инициализируем события
EventEmitter.call(this);
...
}
util.inherits(GameItem, EventEmitter);
Помните я упомянул о неком фокусе, так вот как раз он. Таким образом наши объекты теперь могут работать со своими событиями. Именно ими мы и будем контролировать игровой таймер.
Добавим свойство для хранение ID таймер, а так же в GameItem наш первый обработчик события:
var GameItem = function(user, opponent, x, y, stepsToWin) {
...
// Таймер хода
this.timeout = null;
// Запускаем таймер
this.on('timer', function(state, user) {
if(state == 'stop') {
// сбрасываем таймер
clearTimeout(this.timeout);
this.timeout = null;
} else {
// Запускаем таймер
var game = this;
this.timeout = setTimeout(function() {
// Время вышло, вызываем другое событие
game.emit('timeout', user);
}, 15000);
}
});
Обратите внимание на один важный момент, событие timeout в GameItem нет, пока что нет, но сейчас мы его добавим и добавлять будем в файле index.js, почему мы делаем там я объясню после кода:
socket.on('start', function () {
if(Game.users[socket.id] !== undefined) return;
Game.start(socket.id.toString(), function(start, gameId, opponent, x, y){
if(start) {
// Вот и наш обработчик события timeout
Game.games[gameId].on('timeout', function(user) {
Game.end(user, function(gameId, opponent, turn) {
io.sockets.in(gameId).emit('timeout', turn);
closeRoom(gameId, opponent); // об этом далее
});
});
...
});
});
Сам обработчик события мы вынесли на 2 слоя выше, там где идёт работа с вебсокетами. Это всё нужно для того, чтобы после срабатывания события обратиться к управлению вебсокетом и объектом Game (TicTacToe).
Баг с мерцанием в опере или моя глупая ошибка со статистикой
Пользователь seriyPS написал:
Уж не знаю с чем это связано, но событий «stats» ваша «игра» присылает штук по 100 в секунду. При каждом таком событии перерисовывается значительная часть UI и все тормозит. Играть невозможно (да и не с кем). Ужос в общем.
И даже очень профессионально подошёл к проблеме, полностью изучив её. Я тут же отписал в комментарии что действительно моя ошибка, а теперь объясню в чём, это банально смотрите код:
io.sockets.on('connection', function (socket) {
io.sockets.emit('stats', [
'Всего игр: ' + countGames,
'Уникальных игроков: ' + Object.keys(countPlayers).length,
'Сейчас игр: ' + onlineGames,
'Сейчас игроков: ' + onlinePlayers
]);
setInterval(function() {
io.sockets.emit('stats', [
'Всего игр: ' + countGames,
'Уникальных игроков: ' + Object.keys(countPlayers).length,
'Сейчас игр: ' + onlineGames,
'Сейчас игроков: ' + onlinePlayers
]);
}, 5000);
...
});
После установления соединения вебсокетов мы запускаем отправку статистики в интервале каждые 5 секунд, 50 пользователей подключились в разное время 5*50 = 250 запросов статистики каждые 5 секунд посылал сервер. Нехилый такой баг.
А решить его можно было элементарно выносом кода статистики за пределы события соединения вебсокетов:
setInterval(function() {
io.sockets.emit('stats', [
'Всего игр: ' + countGames,
'Уникальных игроков: ' + Object.keys(countPlayers).length,
'Сейчас игр: ' + onlineGames,
'Сейчас игроков: ' + onlinePlayers
]);
}, 5000);
io.sockets.on('connection', function (socket) {
io.sockets.emit('stats', [
'Всего игр: ' + countGames,
'Уникальных игроков: ' + Object.keys(countPlayers).length,
'Сейчас игр: ' + onlineGames,
'Сейчас игроков: ' + onlinePlayers
]);
...
});
Рефакторинг
На этом исправление ошибок было закончено и я решил заняться рефакторингом.
В файле index.js я первым делом убрал лишний код отключение пользователей от комнат, вынес его в функцию:
io.sockets.on('connection', function (socket) {
...
function closeRoom(gameId, opponent) {
socket.leave(gameId);
io.sockets.socket(opponent).leave(gameId);
}
...
});
Счётчики онлайн игроков и игр убрал совсем, ведь они и так есть в объектах:
var countGames = 0, countPlayers = [], Game = new TicTacToe();
...
io.sockets.emit('stats', [
'Всего игр: ' + countGames,
'Уникальных игроков: ' + Object.keys(countPlayers).length,
'Сейчас игр: ' + Object.keys(Game.games).length,
'Сейчас игроков: ' + Object.keys(Game.users).length
]);
Ещё было много по мелочам разных изменений, статья и так уже получилась большая :)
Всем спасибо кто дочитал эту эпопею с первой статьи и до этой строчки!
Так же хочу добавить многие проблемы с игрой именно коммуникации, связаны с проблемами библиотеки socket.io, поэтому не всё так идеально как может показаться, частые ошибки «client not handshaken» по статистики google analytics это подтверждают.
P.S.: Приношу извинения что была активная ссылка на статью аж с 15 числа, но статья была скрыта НЛО, а я забанен на 7 дней за «кармдрочерство в постах». Поскольку обсуждать действия администрации нельзя, то я просто промолчу.
Поиграть
Теперь вы можете поиграть, для старта игры, нужно нажать «Новая игра»:
ivan.zhuravlev.name/game — поле 6х6 с 4 ходами для победы
ivan.zhuravlev.name/game3 — поле 3х3 с 3 ходами для победы
Посмотреть исходники: github.com/intech/TicTacToe
Коммит всех изменений сделал в один: github.com/intech/TicTacToe/commit/ff3c1d6e9e298a84ab660b52385c378f69f24f01