Pull to refresh

Пишем онлайн игру часть 2 или работа над ошибками

Node.JS *
Tutorial

Приветствую %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
Tags:
Hubs:
Total votes 32: ↑24 and ↓8 +16
Views 6.8K
Comments Comments 13