Приветствую %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

