Pull to refresh

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

Node.JS
Sandbox
Tutorial

Привет %habraname%!





*** Данный материал содержит логические ошибки в самой игре, но это никак не сказывается на техническом содержании статьи, цель которой не поиграть, а разобраться как работать с указанными в заголовке инструментами. Продолжение. Доводим игру до рабочего состояния с учётом всех ошибок описанных в комментариях


Мало кто сегодня может сказать что не знает о NodeJS, последнее время о нём много говорят и пишут.
Я свой путь ознакомления с NodeJS начал полгода назад, тогда для меня это была просто интересное и новое, я и подумать не мог что уже через полгода это станет моим основным инструментом для разработки.

Поскольку весь обучающий материал это либо статьи об асинхронности, либо как написать свой сервер или чат, то ничего интересного для себя в обучающем материале не нашёл. Писал потихоньку разные мелкие приложения, которые подменяли частично в разных проектах фоновые работы php.

Но сейчас я чувствую в себе силы чтобы уже написать полноценный обучающий и не унылый материал от новичка до реального работающего приложения. Это будет не просто приложение, а онлайн игра с использованием самых популярных инструментов Express и Socket.IO, да-да, мультиплеер, который сможет сделать любой средне-статистический js разработчик.

О том, что такое Express и Socket.IO уже писали много где, поэтому описывать ещё раз я не буду, уделив больше внимания процессу разработки.

Для начало я хотел выбрать старые добрые танчики и хорошо что не выбрал, было бы грустно написать её вторым на хабре :)
Я решил не усложнять процесс разработки графикой и взять простую игру, так мой выбор пал на крестики-нолики, но чтобы усложнить себе задачу, было решено сделать универсально, с возможностью задать любой размер игрового поля и любое кол-во ходов для победы.

И так, решено! Начинаю делать крестики-нолики.

Определимся со структурой будущей игры, что нам требуется в результате:
  • Общий какой-то интерфейс с информацией об игре
  • UI игрового поля с обработчиками
  • Серверная часть со всей логикой игры


Разработку я начал как обычно с интерфейса. Я выбрал фреймворк jQuery и jQueryUI соответственно.

Создав страничку и подключив необходимые стили и библиотеки jquery, jqueryUI:
<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.16/themes/vader/jquery-ui.css">
    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>
    <script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.16/jquery-ui.min.js"></script>

Интерфейс составляет строку статуса, боковую панель статистики и само игровое поле, всё сделано простыми таблицами:

    <table border="0" width="100%">
        <thead>
            <th colspan="2" id="status" class="ui-widget ui-state-hover ui-corner-all">Подключаемся к серверу...</th>
        </thead>
        <tbody>
            <td id="stats" class="ui-widget" valign="top"><br /><button id="reload">Новая игра</button><br /><br /></td>
            <td id="board" class="ui-widget" valign="top"><div id="masked" class="ui-widget-shadow ui-corner-all ui-widget-overlay"></div>
                <table class="ui-widget ui-corner-all" cellpadding="0" cellspacing="0" align="left" id="board-table"></table>
            </td>
        </tbody>
    </table>


Тут думаю комментировать нечего, всё более чем понятно. Далее был быстро набросан CSS и я приступил к написанию клиентской обработки событий.

Весь код старался писать максимально чисто и логично с расчётом, что это обучающий материал, который должен учить только хорошему даже в мелочах :)

Был создан объект игры:
var TicTacToe = {
    gameId: null, // Данная переменная будет содержать уникальный ID игры.
    turn: null, // Чем будет ходить игрок, будет содержать X или O
    i: false, // Чей сейчас ход, игрока или соперника
    init: function() { ... }, // Здесь будут основные обработчики взаимодействия с серверной частью
    startGame: function (gameId, turn, x, y) { ... }, // Генерация игрового поля и установка всех параметров игры
    mask: function(state) { ... }, // Маска на игровое поля, чтобы нельзя было ничего нажимать когда ходит противник и просто красиво :)
    move: function (id, turn, win) { ... }, // Отметка хода на игровом поле
    endGame: function (turn, win) { ... } // Конец игры, вывод сообщения
}


Теперь рассмотрим каждую функцию по очереди, максимально комментировал, и так Init():

$(function() {
    // UI кнопки перезапуска игры
    $('#reload').button({icons:{primary:'ui-icon-refresh'}}).click(function(){window.location.reload();}); 
    // Подключаемся к серверу nodejs с socket.io
    var socket = io.connect(window.location.hostname + ':1337', {resource: 'api'});
    // Ниже идут event'ы (события) обрабатываемые библиотекой socket.io
    // Подключились
    socket.on('connect', function () {
        $('#status').html('Успешно подключились к игровому серверу');
    });
    // Переподключаемся
    socket.on('reconnect', function () {
        $('#connect-status').html('Переподключились, продолжайте игру');
    });
    // Соединение потеряно
    socket.on('reconnecting', function () {
        $('#status').html('Соединение с сервером потеряно, переподключаемся...');
    });
    // Ошибка
    socket.on('error', function (e) {
        $('#status').html('Ошибка: ' + (e ? e : 'неизвестная ошибка'));
    });

    // Далее уже идут обработчики игры посылаемые сервером
    // Ожидаем соперника
    socket.on('wait', function(){
        $('#status').append('... Ожидаем соперника...');
    });
    // Соперник отлючился
    socket.on('exit', function() {
        // Если вдруг соединение потеряно или соперник закрыл вкладку заканчиваем игру
        TicTacToe.endGame(TicTacToe.turn, 'exit');
    });
    // К нам подключился соперник, начинаем игру
    // Получаем ID игры, чем ходит данный игрок и размер поля x y
    socket.on('ready', function(gameId, turn, x, y) {
        $('#status').html('К вам подключился соперник! Игра началась! ' + (turn == 'X' ? 'Сейчас Ваш первый ход' : 'Сейчас ходит соперник') + '!');
        // Создаём поле и присваеваем игровые переменные объекту
        TicTacToe.startGame(gameId, turn, x, y);
        // Добавляем подсказку чем ходит игрок, чтобы не забыть :)
        $('#stats').append($('<div/>').attr('class', 'turn ui-state-hover ui-corner-all').html('Вы играете: <b>' + (turn=='X'?'Крестиком':'Ноликом') + '</b>'));
        // На каждое поле вешаем событие клика мышки, значит ходим
        $("#board-table td").click(function (e) {
            // Ход сделан, отправляем событие на сервер с ID игры и ID игровой клетки, которая имеет значение XxY
            if(TicTacToe.i) socket.emit('step', TicTacToe.gameId, e.target.id);
        // Для красоты наведение будем подсвечивать выделением клетки
        }).hover(function(){
                $(this).toggleClass('ui-state-hover');
            }, function(){
                $(this).toggleClass('ui-state-hover');
            });
    });
    // Получаем ход
    socket.on('step', function(id, turn, win) {
        // Получаем ID клетки, которую надо пометить и чем ходили. win передаёт значение в случаи если этот ход был решающим
        TicTacToe.move(id, turn, win);
    });
    // Статистика
    socket.on('stats', function (arr) {
        var stats = $('#stats');
        stats.find('div').not('.turn').remove();
        for(val in arr) {
            stats.prepend($('<div/>').attr('class', 'ui-state-hover ui-corner-all').html(arr[val]));
        }
    });
});


Теперь рассмотрим подробнее как стартует игра:
startGame: function (gameId, turn, x, y) {
// Уникальный ID игры
this.gameId = gameId;
// Чем ходит игрок
this.turn = turn;
// Чей сейчас ход, если X то понятно игрок в начале ходит первым :)
this.i = (turn == 'X');
// Очищаем игровое поле
var table = $('#board-table').empty();
// Создаем новое с указаными размерами
for(var i = 1; i <= y; i++) {
    var tr = $('<tr/>');
    for(var j = 0; j < x; j++) {
        // Обратив внимание что каждая клетка имеет уникальный ID вида X и Y поля (id="2x3")
        tr.append($('<td/>').attr('id', (j+1) + 'x' + i).addClass('ui-state-default').html(' '));
    }
    table.append(tr);
}
// Показываем поле
$("#board").show();
// Если первый ходит другой игрок, накладываем маску
this.mask(!this.i);
},


Функцию маски описывать не буду, там всё банально.
Дальше функция получение хода игрока:
move: function (id, turn, win) {
// Получаем параметры: ID клетки куда был ход, чем ходили, решающий ли это ход
this.i = (turn != this.turn); // Чей следущий ход
$("#" + id).attr('class', 'ui-state-hover').html(turn); // Выставляем на поле ход
if (!win) { // Если победителей нет, продолжаем играть
    this.mask(!this.i); // Проверяем нужна маска на поле или нет
    $('#status').html('Сейчас ' + (this.i ? 'ваш ход' : 'ходит соперник')); // Выводим подсказку
} else {
    this.endGame(turn, win); // Если ход был решающим, завершаем игру
}
},


Завершение игры:
endGame: function (turn, win) {
// Переданные параметры: чей был ход, тип решающего хода
var text = '';
// Тип решающего хода имеет 3 вида
switch(win) {
    case 'none': text = 'Ничья!'; break; // Больше нет свободных клеток
    case 'exit': text = 'Соперник сбежал с поля боя! Игра закончена'; break; // Сокет завершил соединение
    default: text = 'Вы ' + (this.i ? 'проиграли! =(' : 'выиграли! =)'); // Есть победитель
}
// Выводим окошко с сообщением и кнопкой рестарта
$("<div/>").html(text).dialog({
    title: 'Конец игры',
    modal: true,
    closeOnEscape: false,
    resizable: false,
    buttons: { "Играть по новой": function() {
        $(this).dialog("close");
        window.location.reload();
    }},
    close: function() {
        window.location.reload();
    }
});
}


На этом всё! Все созданные файлы положим в новую папку с именем public.

Клиентская часть далась слишком легко? Серверная часть чуть сложнее!


Устанавливаем необходимые модули:
npm install express
npm install socket.io


Создаём стартовый файлик index.js, пока что простой, разберёмся с ним:

// Подключаем необходимые модули
var express = require('express'), socketio = require('socket.io');
// Создаём серверное приложение с помощью express
var app = express.createServer();
// Теперь создаём вебсокеты и подключаем его к express приложению
// это нужно чтобы всё работало вместе, на одном сервере как модули
var io = socketio.listen(app);
// Воспользуемся функциями express для отдачи статики
app.use(express.static(__dirname + '/public'));
// Обозначим порт для приложения и вебсокета
app.listen(80);
// Уровень вывода лога у socket.io для отладки можно оставить по умолчению 3, потом оно не нужно
io.set('log level', 3);
// Я изменил стандартное имя ресурса, но можно оставить стандартный /socket.io
io.set('resource', '/api');


Теперь запишем тоже самое но без комментариев и в упрощённом виде, так чище:
var express = require('express'), app = express.createServer(),
    io = require('socket.io').listen(app), TicTacToe = require('./models/tictactoe');
app.use(express.static(__dirname + '/public'));
app.listen(1337);
io.set('log level', 1);
io.set('resource', '/api');


Если вы заметили здесь я так же добавил модель
TicTacToe = require('./models/tictactoe');

именно её мы сейчас и будем писать, создаём папку рядом с index.js с названием models и в ней создаём файл tictactoe.js
Это обычный модуль в nodejs и будет использоваться экспортной функцией, в нём будет сердце нашей игры, вся логика.

Так как мы пишем онлайн игру, то у нас изначально архитектура серверного приложения должна быть в виде объектов пользователь, игра и их коллекций.

Создадим главные обекты:

// Объект коллекции всех игр, сердце мультиплеера, так же она единственная и главная экспортируемая функция модуля
var TicTacToe = module.exports = function() {
    // Массив id игры = объект игры
    this.games = [];
    // Массив подключённых пользователей = id игры
    this.users = [];
    // Массив пользователей ожидающих оппонентов для начало игры
    this.free = [];
    // Размеры поля
    this.x = 6;
    this.y = 6;
    // Шагов до победы
    this.stepsToWin = 4;
}
// Объект игры, в нём индивидуальные настройки каждой игры
var GameItem = function(user, opponent, x, y, stepsToWin) {
    // Ячейки игрового поля будут в виде объекта this.board[id игровой ячейки] = чем ходили
    this.board = [];
    // Игроки
    this.user = user; // X
    this.opponent = opponent; // O
    // Размеры поля
    this.x = x;
    this.y = y;
    // Шагов до победы
    this.stepsToWin = stepsToWin;
    // Кол-во сделанных ходов
    this.steps = 0;
}


Таким образом когда запускается наш сервер он создаёт игру TicTacToe, а при подключение пользователей мы им будем создавать внутри коллекции TicTacToe персональные игры GameItem, в которых будет видно кто с кем и на каких параметрах играет.

Теперь рассмотрим функцию создания этих самых игр в коллекции:

TicTacToe.prototype.start = function(user, cb) {
    // Размер игрового поля и кол-во ходов для победы
    // Ищем свободные игры используем Object.keys чтобы посчитать элементы в объекте
    if(Object.keys(this.free).length > 0) {
        // Если есть ожидающие игры получаем один ID
        var opponent = Object.keys(this.free).shift();
        // Создаём игру между этими игроками
        var game = new GameItem(user, opponent, this.x, this.y, this.stepsToWin);
        // Создаём уникальный ID игры из ID игроков
        var id = user + opponent;
        // Добавляем игру в список действующих
        this.games[id] = game;
        // Добавляем игрока в общей список играющих
        this.users[user] = id;
        // Добавляем его соперника так же
        this.users[opponent] = id;
        // Используем callback функция для возврата
        cb(true, id, opponent, this.x, this.y);
    } else {
        // Пока нет, значит будем ждать
        this.free[user] = true;
        // Используем callback функция для возврата
        cb(false);
    }
}


Как видите мы используем изменение через прототипы, так мы наращиваем необходимым функционалом наш модуль.
Так же я во всей игре буду использовать метод написания callback-функция (обратных вызовов) это даёт нам возможность писать асинхронный код.

Быстренько ещё раз объясню зачем это надо на простом примере, если кто-то до сих пор не прочитал одну из десятков статей про асинхронность :)

Итак предположим пользователь обратился к серверу и хочет запустить игру. Сервер с радостью ему отвечает ок, начинаю запускать твою игру и делает это вот так:

1. --> Запрос от пользователя
2. TicTacToe.start();
3. <-- Ответ пользователю


Так вот в данном случаи после шага 1, шаги 2 и 3 отработаю синхронно, то есть одновременно и пользователь получит ответ до того как игра на самом деле будет создана. Поэтому мы используем callback-функцию, изменяя логику следующим образом:


1. --> Запрос от пользователя
2. TicTacToe.start(function(){
3.     <-- Ответ пользователю
   });


Теперь после шага 1 у нас запускается только функция на шаге 2 и только после обратного вызова отрабатывает анонимная функция и переходит к шагу 3 ответу результата пользователю.

Вернёмся к нашей игре. Мы научили её определять очередь игровых мест и соединять пары пользователей для игры.

Теперь рассмотрим как у нас будет завершаться игра:

TicTacToe.prototype.end = function(user, cb) {
    // Если пользователь стоял в очереди удаляем его от туда
    delete this.free[user];
    // Если пользователь уже был удалён выходим, значит игры уже нет
    if(this.users[user] === undefined) return;
    // Получаем ID игры в которой находится пользователь
    var gameId = this.users[user];
    // Если игра уже была удалена, выходим
    if(this.games[gameId] === undefined) return;
    // Получаем объект игры по его ID
    var game = this.games[gameId];
    // Получаем соперника из этой игры
    var opponent = (user == game.user ? game.opponent : game.user);
    // Удаляем объект игры
    delete this.games[gameId];
    // Освобождаем память
    game = null;
    // Удаляем пользователя из списка
    delete this.users[user];
    // Возвращаем ID игры и ID соперника в этой игре
    cb(gameId, opponent);
}


Теперь потихоньку перейдём к самому интересному, как же работают крестики нолики.

На этот раз мы добавим функционал не только у коллекции, но и у самого объекта игры, сделаем ход игрока:

TicTacToe.prototype.step = function(gameId, x, y, user, cb) {
    // Данная функция служит как proxy для обращения к нужно игре из коллекции и передачи параметров в неё
    this.games[gameId].step(x, y, user, cb);
}
GameItem.prototype.step = function(x, y, user, cb) {
    // Проверяем что в этой клетке ничего нет
    if(this.board[x + 'x' + y] !== undefined) return;
    // Получаем параметры X и Y куда был сделан ход, добавляем в объект ходов на эти координаты кто пошёл
    this.board[x + 'x' + y] = this.getTurn(user);
    // Увеличиваем счётчик сделанных ходов
    this.steps++;
    // Обратный вызов у нас срабатывает после выполнения функции проверки на победителя
    cb(this.checkWinner(x, y, this.getTurn(user)), this.getTurn(user));
}

Остановимся и рассмотрим по подробнее данную функцию. В ней есть вызовы двух других функция это this.getTurn() она отвечает за возврат чем ходит пользователь который сделал ход, в неё передаём ID пользователя, а вот и сама функция, которая добавлена к объекту игры:

GameItem.prototype.getTurn = function(user) {
    return (user == this.user ? 'X' : 'O');
}


Всё просто, если это пользователь создавший игру, то очевидно что он ходит X, а если соперник то O.

Вторая функция вызываемая в callback'e это проверка на победителя:

GameItem.prototype.checkWinner = function(x, y, turn) {
    // Проверка на ничью, если нет больше свободных полей
    if(this.steps == (this.x * this.y)) {
        // Ничья
        return 'none';
        // Проверка на победителя
    } else if(
        // Проверка комбинаций на победу пользователя
        this.checkWinnerDynamic('-', x, y, turn)
            || this.checkWinnerDynamic('|', x, y, turn)
            || this.checkWinnerDynamic('\\', x , y, turn)
            || this.checkWinnerDynamic('/', x, y, turn)
        ) {
        // есть победитель
        return true;
    } else {
        // нет победителя
        return false;
    }
}


Мы опять добавили функционал нашему объекту игры. В функцию мы опять получаем координаты хода и чем ходили. Сразу проверяем сколько сделано ходов вместе с этим и смотрим сколько всего доступно ходов подсчитывая это перемножая размеры поля между собой.

Если у нас ещё есть свободные клетки, то игра ещё не закончена, возможно есть победитель. Проверка победителя это самая сложная функция во всей игре, её мы рассмотрим чуть ниже. В случаи если победителя нет, то возвращаем false.

Теперь о функции проверки на победителя, она имеет 4 параметра:
1 — Алгоритм поиска, может иметь значения -, |, / и \ (да один обратный слеш, т.к. в коде экранирование кавычки) почему значки спросите вы, всё очень просто это направляющие по которым идёт проверка, для наглядности решил использовать именно их.
2,3 — координаты хода
4 — чем ходили

Теперь сама могучая и неповторимая функция, советую для начало просмотреть её и обратить внимание что каждый case имеет практически шаблонный вид, а в чём его разница я расскажу уже после кода:

GameItem.prototype.checkWinnerDynamic = function(a, x, y, turn) {
    // будем проверять динамически 4 комбинации: горизонталь, вертикаль и 2 диагонали
    // при этом мы не знаем на какой позиции текущий ход,, проверять будем во всех 4 направлениях
    var win = 1;
    switch(a) {

        // поиск по горизонтали
        case '-':
            var toLeft = toRight = true,
                min = x - this.stepsToWin, max = x + this.stepsToWin;
            min = (min < 1) ? 1 : min;
            max = (max > this.x) ? this.x : max;
            for(var i = 1; i <= this.stepsToWin; i++) {
                if(win >= this.stepsToWin) return true;
                if(!toLeft && !toRight) return false;
                if(toLeft && min <= (x-i) && this.board[(x-i) + 'x' + y] == turn) { win++; } else { toLeft = false; }
                if(toRight && (x+i) <= max && this.board[(x+i) + 'x' + y] == turn) { win++; } else { toRight = false; }
            }
            break;

        // поиск по вертикали
        case '|':
            var toUp = toDown = true,
                min = y - this.stepsToWin, max = y + this.stepsToWin;
            min = (min < 1) ? 1 : min;
            max = (max > this.y) ? this.y : max;
            for(var i = 1; i <= this.stepsToWin; i++) {
               if(win >= this.stepsToWin) return true;
               if(!toUp && !toDown) return false;
               if(toUp && min <= (y-i) && this.board[x + 'x' + (y-i)] == turn) { win++; } else { toUp = false; }
               if(toDown && (y+i) <= max && this.board[x + 'x' + (y+i)] == turn) { win++; } else { toDown = false; }
            }
        break;

        // поиск по диагонали сверху вниз
        case '\\':
            var toUpLeft = toDownRight = true,
                minX = x - this.stepsToWin, maxX = x + this.stepsToWin,
                minY = y - this.stepsToWin, maxY = y + this.stepsToWin;
            minX = (minX < 1) ? 1 : minX;
            maxX = (maxX > this.x) ? this.x : maxX;
            minY = (minY < 1) ? 1 : minY;
            maxY = (maxY > this.y) ? this.y : maxY;
            for(var i = 1; i <= this.stepsToWin; i++) {
               if(win >= this.stepsToWin) return true;
               if(!toUpLeft && !toDownRight) return false;
               if(toUpLeft && minX <= (x-i) && minY <= (y-i) && this.board[(x-i) + 'x' + (y-i)] == turn) { win++; } else { toUpLeft = false; }
               if(toDownRight && (x+i) <= maxX && (y+i) <= maxY && this.board[(x+i) + 'x' + (y+i)] == turn) { win++; } else { toDownRight = false; }
            }
        break;

        // поиск по диагонали снизу вверх
        case '/':
            var toDownLeft = toUpRight = true,
                minX = x - this.stepsToWin, maxX = x + this.stepsToWin,
                minY = y - this.stepsToWin, maxY = y + this.stepsToWin;
            minX = (minX < 1) ? 1 : minX;
            maxX = (maxX > this.x) ? this.x : maxX;
            minY = (minY < 1) ? 1 : minY;
            maxY = (maxY > this.y) ? this.y : maxY;
            for(var i = 1; i <= this.stepsToWin; i++) {
                if(win >= this.stepsToWin) return true;
                if(!toDownLeft && !toUpRight) return false;
                if(toDownLeft && minX <= (x-i) && (y+i) <= maxY && this.board[(x-i) + 'x' + (y+i)] == turn) { win++; } else { toDownLeft = false; }
                if(toUpRight && (x+i) <= maxX && (y-i) <= maxY && this.board[(x+i) + 'x' + (y-i)] == turn) { win++; } else { toUpRight = false; }
            }
        break;

        default: return false; break;
    }
    return(win >= this.stepsToWin);
}


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


Каждый 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);
    });

});


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

Теперь вы можете поиграть, для старта игры, нужно нажать «Новая игра»:
ivan.zhuravlev.name/game — поле 6х6 с 4 ходами для победы
ivan.zhuravlev.name/game3 — поле 3х3 с 3 ходами для победы
Посмотреть исходники: github.com/intech/TicTacToe
Я максимально подготовился к хабру-эффекту, поставил proxy nginx для статики, вебсокеты будут работать на отдельном порту 1337
Надеюсь ничего не упадёт, всё находится в облаке селектела, на виртуалке ресурсы 8 ядер 1гб оперативы должно хватить, если увеличу :)
— Вся разработка и отладка заняла: 12 часов 22 минуты.
Всего время прошло: 3 суток, на четвёртые писалась статья

Статистика


Процессор

Память

Я смотрю google analytics и на другом мониторе у меня отрыты все данные по серверу.
В среднем в данный момент наблюдается от 30 до 50 человек в онлайне, при этом нагрузка на сервере практически не заметна, есть очень маленькие скачки но они действительно слишком малы чтобы назвать это нагрузкой.
Tags:nodejsexpresssocket.ioонлайн игракрестики нолики
Hubs: Node.JS
Total votes 48: ↑41 and ↓7 +34
Views39.1K

Popular right now

Backend-разработчик (Node.js)
to 120,000 ₽vc.ruRemote job
Разработчик Fullstack (node.js; vue)
from 70,000 to 140,000 ₽Финэктор группМоскваRemote job
NodeJS (Middle или Junior+)
from 90,000 to 120,000 ₽idPowersRemote job
Разработчик / Программист Backend NodeJS
from 70,000 to 100,000 ₽iQtecoRemote job
Node.js Developer
from 150,000 to 250,000 ₽BotHelpRemote job

Top of the last 24 hours