nodeJS Быстрый веб-сервер на javascript движке V8

7Янв/122

Пишем онлайн игру на NodeJS, Express и Socket.IO

Алгоритмизация

Каждый case это отдельная проверка по разным алгоритмам, но все их объединяет одно, это обычный сдвиг по игровому полю от текущего положения и проверкой значения этих полей.
Поскольку я изначально усложнил себе задачу и игра может иметь любой размер поля, а так же любое кол-во ходов для победы, то и алгоритм у нас универсальный состоящий из следующих проверок:

  • Горизонтальный поиск

    В начале мы объявляем переменные границы лево и право для цикла, а так же минимальный и максимальный размер по оси X.
    То есть это значение текущего хода + значение с кол-во ходов для победы.
    Дальше приведём значения максимума и минимума в реальное, если минимальное меньше 1 то выставляем 1.
    Если максимум больше поля в оси X, то выставляем значение длины поля.
    Дальше в цикле мы будем проходить все соседние поля начиная с крайних и уходят дальше до необходимого кол-ва для победы.
    На каждой итерации в цикле мы будем проверять набралось ли необходимое кол-во клеток с одинаковым ходом для выигрыша, если да, то выходим и функции и передаём true - есть победитель.
    Так же будем проверять если не влево не в право уже нельзя смещение, значит цикл останавливаем и выходим из функции но уже с false - нет победителя.
    Дальше идёт проверка с начало левой стороны, можно ли проверять ячейку слева и её значение больше ли минимального, а так же имеет ли эта ячейка значение текущего хода или нет. Если все условия совпали то помечаем что у нас есть совпадение в переменной win увеличивая её значение. Если какое условие не совпало, то значит помечаем что левую сторону мы больше не будем смотреть. Ведь условия выиграша когда значения идут подряд.
    Тот же самый алгоритм мы будем использовать для проверки правой стороны, только там меняется условия проверки на максимальное значение смещения вправо.

  • Вертикальный поиск

    В начале точно так же мы объявим переменные границ и лимиты.
    Только в этот раз мы будем работать с осью Y и её значениями. Смещения у нас так же будет проходить по оси Y.
    Всё остальное ничем не отличается.

  • Диагональ сверху вниз (слева направо обычно должно быть поэтому не пишу об этом :)

    В начале точно так же мы объявим переменные границ и лимиты, но лимиты теперь у нас другие, мы будем смотреть обе оси.
    Работать будем с обоими осями при смещении. Для вверх и лево будем уменьшать X Y для смещения, а также проверять минимальное X и минимальное Y значения. А для вниз и право увеличивать X Y делая соответствующие проверки на максимальные значения смещения.

  • Диагональ снизу вверх

    Здесь практически всё тоже самое кроме одной мелочи со смещением и проверкой.
    Смещение вниз налево это уменьшение по оси X и увеличение по оси Y, что значит проверка на минимальное значение X и максимальное Y.
    А при смещении вверх и направо это увеличение по оси X и уменьшение по оси Y с проверкой на максимальное X и минимальное Y.

Мы завершили описывать наш модуль игры. Все функции готовы! Теперь повесим обработчики серверной части для взаимодействия с клиентскими обработчиками и посмотрим на всё это в действии.

Сохраняем файлик и возвращаемся к нашему главному index.js, в него мы добавим работу с socket.io добавим необходимые events, а также общие переменные игры:

// Объявим переменные для счётчиков, для глобализации их вынесем в начало, так же создадим глобальный объект коллекции игр
var countGames = onlinePlayers = onlineGames = 0, countPlayers = [], Game = new TicTacToe();
// Установим размеры поля, возможно потом сделаем это чтобы задавали наши игроки
Game.x = Game.y = 6; // Default: 6
// Необходимое кол-во занятых подряд клеток для победы
Game.stepsToWin = 4; // Default: 4
// При установки соединения через вебсокеты сработает данное событие
io.sockets.on('connection', function (socket) {
    // Выведем в консоль сообщение о подключении пользователя с его ID и IP адресом
    console.log('%s: %s - connected', socket.id.toString(), socket.handshake.address.address);
    // Вызовем событие у клиента с именем stats и передадим в него данные статистики
    io.sockets.emit('stats', [
        'Всего игр: ' + countGames,
        'Уникальных игроков: ' + Object.keys(countPlayers).length,
        'Сейчас игр: ' + onlineGames,
        'Сейчас игроков: ' + onlinePlayers
    ]);
    // Поставим вызов этого события в интервале 5 секунд, для обновления данных
    setInterval(function() {
        io.sockets.emit('stats', [
            'Всего игр: ' + countGames,
            'Уникальных игроков: ' + Object.keys(countPlayers).length,
            'Сейчас игр: ' + onlineGames,
            'Сейчас игроков: ' + onlinePlayers
        ]);
    }, 5000);
    // Вызовем функции старта игры, как ID пользователя возьмём уникальный вебсокет ID это обычный md5 хэш
    Game.start(socket.id.toString(), function(start, gameId, opponent, x, y){
        // В callback'е мы получим стартовала ли игра или нет, остальные параметры зависят от первого
        // Если игра стартовала то они будут переданы, иначе они не нужны и будут null
        if(start) {
            // Подключем к игрока в отдельную комнату её ID будет ID игры
            // Сами комнаты это стандартная плюшка socket.io
            socket.join(gameId);
            // Подключим к комнате(игре) нашего соперника обратившись к нему через вебсокеты
            io.sockets.socket(opponent).join(gameId);
            // Вызовем события у игрока о старте и параметры игры
            socket.emit('ready', gameId, 'X', x, y);
            // Вызовем событие у соперника
            io.sockets.socket(opponent).emit('ready', gameId, 'O', x, y);
            // Соберём статистику увеличив счётчики всех игр и запущенных игр
            countGames++;
            onlineGames++;
        } else {
            // ожидает соперника, вызовем события ожидания у игрока
            io.sockets.socket(socket.id).emit('wait');
        }
        // Если пользователя ещё нет в объекте уникальных ip то добавим для статистики
        if(countPlayers[socket.handshake.address.address] == undefined) countPlayers[socket.handshake.address.address] = true;
        // Счётчик игроков в сети
        onlinePlayers++;
    });
 
    // Событие сделанного хода игроком
    socket.on('step', function (gameId, id) {
        // Парсим из ID элемента координаты XxY
        var coordinates = id.split('x');
        // Передаём все данные в функцию коллекции игр, которая служит proxy для вызова аналогичной функции в самой игре
        Game.step(gameId, parseInt(coordinates[0]), parseInt(coordinates[1]), socket.id.toString(), function(win, turn) {
            // Она нам вернёт значения ходе, если победитель, а так же чем ходили всё передадим как есть в событие пользователям
            // На этот раз обратите внимание используем in() эта функция отправляет сообщения всем пользователям комнаты
            io.sockets.in(gameId).emit('step', id, turn, win);
            // Если есть победитель или ничья
            if(win) {
                // Завершаем игру и удаляем все данные, не будем забивать память
                Game.end(socket.id.toString(), function(gameId, opponent) {
                    // После удаления игры мы можем вывести из комнаты игрока
                    socket.leave(gameId);
                    // А так же соперника
                    io.sockets.socket(opponent).leave(gameId);
                });
            }
        });
    });
 
    // В случаи обрыва соединения или закрытия вкладки пользователем или просто конца игры
    socket.on('disconnect', function () {
        // Если один из игроков отключился, посылаем об этом сообщение второму
        // Отключаем обоих от игры и удаляем её, освобождаем память
        Game.end(socket.id.toString(), function(gameId, opponent) {
            // Посылаем сопернику что игрок отключён, причём наша функция Game.end возвращает независимо от того кто прервал игру, ID соперника
            io.sockets.socket(opponent).emit('exit');
            // Отключаем пользователя из комнаты
            socket.leave(gameId);
            // Отключаем соперника из комнаты
            io.sockets.socket(opponent).leave(gameId);
            // Уменьшаем счётчик запущенных игр
            onlineGames--;
        });
        // Уменьшаем счётчик играющих
        onlinePlayers--;
        // Выводим сообщение об отключении пользователя
        console.log('%s: %s - disconnected', socket.id.toString(), socket.handshake.address.address);
    });
 
});

Рассмотрим некоторые технические моменты с которыми столкнулись при тестах игры.

Таймаут хода

Многие стали жаловаться что никто не ходит, это было самым большим злом, возможно из-за проблем которые были у многих с игрой они просто не могли ходить и игра тупо висела открытой. Нужен был таймаут хода. В нашем клиентском файле 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).

Мы закончили написания нашей игры! Познакомились с NodeJS, вспомнили что такое асинхронное программирование, поработали с событиями, попробовали в работе socket.io, а так же мельком express для выдачи статики.

Так же хочу добавить, что многие проблемы с игрой именно коммуникации, связаны с проблемами библиотеки socket.io, поэтому не всё так идеально как может показаться, частые ошибки "client not handshaken" по статистики google analytics это подтверждают. Особенно проблемы наблюдались с браузером Opera.

Теперь вы можете поиграть, для старта игры, нужно нажать "Новая игра":
поле 6х6 с 4 ходами для победы
поле 3х3 с 3 ходами для победы
Посмотреть исходники: https://github.com/intech/TicTacToe
Всё работает через nginx для статики, вебсокеты стримингом на отдельном порту 1337.
Находится в облаке селектела, на виртуалке ресурсы 8 ядер 1гб оперативы.
---
Вся разработка и отладка заняла: 12 часов 22 минуты.
Всего время прошло: 3 суток, на четвёртые писалась статья

Статистика


Процессор (* работа ОС занимает обычно ~1.5)

Память (* работа ОС занимает обычно ~450-500)

Я смотрю google analytics и на другом мониторе у меня отрыты все данные по серверу.
В среднем в данный момент наблюдается от 30 до 50 человек в онлайне, при этом нагрузка на сервере практически не заметна, есть очень маленькие скачки но они действительно слишком малы чтобы назвать это нагрузкой.

Всем спасибо кто дочитал эту эпопею до этой строчки!

Комментарии (2) Пинги (0)
  1. Спасибо, хотя статья и была на Хабре :)
    А можешь показать цифры только от Node.js, а не всей системы? Буду премного благодарен!

  2. Цифр к сожалению не смогу точных показать, т.к. на тот момент ожидал великого хабра эффекта. А скачков то и не было, график плавненько поднялся немного и через пару дней отпустило) Если бы знал что придётся на спичках мерить, то профайлинг включил бы.


Оставить комментарий


Нет обратных ссылок на эту запись.