Разработка многопользовательских игр с помощью Node.js и Socket.IO

Шапка

С выходом Ouya, Xbox One и PS4 в этом году диванно-консольный гейминг становится популярным как никогда. Несмотря на распространение многопользовательских игр, доступных на мобильных устройствах, и даже на распространение опыта многопользовательского взаимодействия в вебе, все это не заменит удовольствия от игры бок о бок с другом. Работая с Node.js и библиотекой Socket.IO, я обнаружил прекрасную возможность не только изучить что-то новое, но также поэкспериментировать с использованием веб-технологий и различных устройств (мобильные гаджеты и ноутбуки) для воссоздания опыта игр на приставках.

Эта статья даст вам краткий обзор фундаментальных основ работы библиотеки Socket.IO в контексте разработки многопользовательской игры в слова, использующей несколько экранов. Браузер на устройстве с большим экраном, таком как телевизор или компьютер, будет «приставкой», а мобильный браузер будет выступать в роли контроллера. Socket.IO и Node.js обеспечат необходимую связь между браузерами для передачи данных и обеспечения игрового взаимодействия в реальном времени.

Игра «Анаграмматикс»

Игра представляет из себя соревнование по разгадыванию анаграмм — слов, при перестановке букв в которых образуются новые слова. Геймплей достаточно прост — на основном экране появляется слово, а на каждом из контроллеров-смартфонов наших игроков появляется список похожих по написанию слов. Одно из появившихся слов — анаграмма к слову на основном экране. Игрок, который первым правильно выбрал анаграмму, получает очки за раунд. Выбор неправильного слова уменьшает суммарное количество очков игрока. После завершения 10 раундов побеждает игрок, набравший большее количество очков.

Перед тем, как скачать исходный код, чтобы поиграть самостоятельно, убедитесь, что у вас установлен Node.js. Откройте репозиторий игры на GitHub, клонируйте его с помощью команды git clone https://github.com/ericterpstra/anagrammatix.git и затем выполните команду npm install, чтобы скачать и установить все зависимости (Socket.IO и Express). После этого выполните node index.js, для запуска сервера игры. Чтобы начать непосредственно саму игру, откройте в браузере адрес http://localhost:8080. Для проверки возможности многопользовательской игры вам потребуется открыть несколько окон браузера.

Если вы хотите поиграть, используя мобильные устройства, вам необходимо узнать IP-адрес вашего компьютера в локальной сети. Само собой, мобильные устройства должны находиться в той же сети, что и компьютер с запущенным приложением игры. Для начала игры просто замените ‘localhost’ в адресной строке на IP-адрес компьютера (например, `http://192.168.0.5:80801). Если вы используете Windows, то вам необходимо отключить фаерволл Windows или открыть порт 8080.

Используемые технологии

Для реализации игры «Анаграмматикс» используются только HTML, CSS и JavaScript. Чтобы сохранить код игры насколько это возможно простым, использование различных сторонних библиотек и фреймворков было сведено к минимуму. Основные технологии, используемые в игре, перечислены ниже:

Архитектура

Архитектура игры

В целом, архитектура игры соответствует рекомендованной конфигурации по использованию Socket.IO вместе с Express, доступной на сайте Socket.IO. В этой конфигурации Express используется для обработки HTTP-запросов, отправляемых в Node-приложение. Модуль Socket.IO подключается к Express и начинает отслеживать подключения к тому же порту, ожидая входящих websocket-соединений.

Когда браузер подключается к веб-приложению, оно возвращает ему index.html и все необходимые для начала работы JavaScript- и CSS-файлы. Клиентская часть приложения подключается к Socket.IO и создаёт websocket-соединение. Каждое websocket-соединение обладает уникальным идентификатором для того, чтобы Node и Socket.IO могли работать с несколькими соединениями и отправлять данные соответствующему клиенту.

Разработка серверной части

Модули Express и Socket.IO не входят в Node.js. Они являются внешними зависимостями, которые должны быть загружены и установлены отдельно. Менеджер пакетов Node.js (npm) сделает это за нас при запуске команды npm install. То же самое он сделает со всеми зависимостями, перечисленными в файле package.json. Файл package.json вы можете найти в корневой директории проекта. Он содержит определяющий зависимости проекта JSON-объект:

"dependencies": {
    "express": "3.x",
    "socket.io":"0.9"
}

Файл index.js в корневой директории проекта — это точка входа для всего приложения. Первые несколько строк инициализируют все необходимые модули. Express настроен для обеспечения доступа к статическим файлам, а модуль Socket.IO настроен так, чтобы отслеживать подключения к тому же порту, что и Express. Следующие строки — основа приложения, необходимая для работы сервера.

// Создаем приложение с помощью Express
var app = express();

// Создаем HTTP-сервер с помощью модуля HTTP, входящего в Node.js. 
// Связываем его с Express и отслеживаем подключения к порту 8080. 
var server = require('http').createServer(app).listen(8080);

// Инициализируем Socket.IO так, чтобы им обрабатывались подключения 
// к серверу Express/HTTP
var io = require('socket.io').listen(server);

Весь код серверной части игры вынесен в отдельный файл agxgame.js. Этот файл подключается в приложение в качестве модуля Node с помощью следующего фрагмента кода: var agx = require('./agxgame'). Когда клиент подключается к приложению с помощью Socket.IO, модуль agxgame должен выполнять функцию initGame. За это отвечает следующий фрагмент кода:

io.sockets.on('connection', function (socket) {
    //console.log('client connected');
    agx.initGame(io, socket);
});

Функция initGame в модуле agxgame также добавит слушатель событий:

gameSocket.on('hostCreateNewGame', hostCreateNewGame);

Здесь gameSocket — это объект, созданный с помощью Socket.IO, чтобы инкапсулировать взаимодействие относительно уникального подключения через сокет между сервером и браузером. Функция on добавляет слушатель для определенного события и привязывает к нему функцию. Когда браузер передает событие hostCreateNewGame через веб-сокет, библиотека Socket.IO вызывает функцию hostCreateNewGame. Имена событий и функций не обязательно должны быть такими, мы назвали их так для наглядности.

Разработка клиентской части

Когда браузер подключается к игре, сервер отдает ему файл index.html из директории public, который содержит пустой div с id gameArea. В index.html также содержится несколько HTML сниппетов внутри тегов <script type="text/template">. Эти фрагменты помещаются внутрь gameArea в зависимости от того, что необходимо отобразить на экране в данный момент.

Для подключения браузера к серверу Socket.IO требуется клиентская библиотека Socket.io. Так как мы используем Socket.IO через Express, мы можем использовать получить файл библиотеки с сервера с помощью следующего тега:

<script src="/socket.io/socket.io.js"></script>

Большая часть логики игры и весь клиентский код расположен в файле app.js. Код заключен в самовызывающуюся функцию и организован с применением паттерна объектно-буквенного обозначения пространства имен. Это означает, что все переменные и функции, используемые в приложении, являются свойствами объектов IO и App. Структура клиентского кода выглядит примерно так:

// Функция-замыкание
function() {

    IO {
        Здесь располагается весь код, относящийся 
        к использованию Socket.IO 
    }

    App {
        Здесь располагается основная логика приложения 

        Host {
            Логика игры для основного экрана.
        }

        Player {
            Логика игры для экранов игроков.
        }
    }       
}

При первом запуске приложения после окончания загрузки документа вызываются 2 функции: IO.init() и App.init(). Первая настраивает подключение через Socket.IO, вторая показывает стартовый экран игры в браузере.

Следующая строка в IO.init() инициализирует подключение через Socket.IO между браузером и сервером:

IO.socket = io.connect();

Вслед за этим вызывается функция IO.bind(), которая добавляет слушатель событий Socket.IO на клиенте. Этот слушатель работает аналогично слушателю на стороне сервера, но в обратном направлении. Обратите внимание на следующий пример:

IO.socket.on('playerJoinedRoom', IO.playerJoinedRoom );

В приведенном фрагменте IO — это объект-контейнер, используемый для организации кода. Объект socket создается библиотекой Socket.IO и содержит свойства и методы для подключения с использованием вебсокетов, а функция on добавляет слушатель события. Когда сервер передает событие playerJoinedRoom, на клиенте выполняется функция IO.playerJoinedRoom.

Коммуникация между клиентом и сервером

Геймплей «Анаграмматикс» достаточно прост. Здесь нет большого количества файлов, нет графики или анимации, и нет ничего кроме нескольких слов на экране. То, что в действительности делает это приложение настоящей интерактивной игрой и (я надеюсь) добавляет веселья, так это взаимодействие между 3 окнами браузера. Важно отметить, что эти 3 браузера не связываются напрямую между собой, а отправляют данные на сервер, который их обрабатывает и возвращает ответ соответствующему браузеру. Каждое из событий подразумевает передачу данных, поэтому информация из клиентского браузера, такая, как имя игрока и выбранный ответ, должна передаваться на сервер, а затем другим клиентам.

Возможно, проследить за данными, передаваемыми от клиента серверу и обратно, довольно сложно, особенно при одновременном подключении сразу 3 клиентов. К счастью, в Google Chrome есть отличный инструмент, который нам в этом поможет. Если вы откроете панель инструментов разработчика в Chrome и перейдете на вкладку «Network» (Сеть), вы сможете следить за всем трафиком, идущим через вебсокеты в этом отдельном окне, выбрав пункт WebSockets на панели инструментов внизу. На открывшейся панели вебсокетов, в левой колонке, появляется список соединений. Кликнув на соединении в списке, и затем кликнув на вкладку Frames, вы увидите список сеансов обмена данными, которые производились через это отдельное соединение. Обратите внимание на следующий пример:

Панель Network в инструментах разработчика Google Chrome

Наибольший интерес для нас представляет первая колонка на вкладке Frames. Она показывает данные, которые прошли через вебсокет-соединение. На изображении выше каждый объект в колонке Data представляет передаваемые данные и названия событий, которые произошли. Каждый объект имеет свойства name и args. Значение свойства name — это название прозошедшего события, например, playerJoinedRoom или hostCheckAnswer. Значение свойства args содержит массив данных, передаваемых между клиентом и сервером.

Вкладка «Frames» в инструментах разработчика Chrome вместе с использованием console.log() позволяют проще отлаживать приложение и следить за событиями и передачей данных между клиентом и сервером.

Геймплей

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

Пользователь открывает браузер и переходит по адресу приложения

Node.js и Express отдают файлы из директории public. Файл index.html загружается вместе с app.js и необходимыми клиентскими JavaScript-библиотеками.

В public/app.js вызываются функциии IO.init() и App.init(). Инициализируется клиентская часть игры. Добавляются слушатели событий для работы с Socket.IO. Происходит инициализация кода для загрузки стартового экрана. Библиотека FastClick загружается для ускорения обработки тач-событий. Следующий фрагмент кода внутри функции App.showInitScreen переместит в div gameArea HTML-фрагмент intro-screen-template, расположенный в index.html, тем самым показав его пользователю.

App.$gameArea.html(App.$templateIntroScreen);

Функция App.bindEvents добавит несколько обработчиков события клика для кнопок, которые появляются на экране. Следующий фрагмент кода добавляет обработчики события клика для кнопок «Создать» и «Присоединиться», которые появляются на стартовом экране.

App.$doc.on('click', '#btnCreateGame', App.Host.onCreateClick);
App.$doc.on('click', '#btnJoinGame', App.Player.onJoinClick);

Пользователь кликает по кнопке «Создать»

На клиенте срабатывает событие hostCreateNewGame. Функция App.Host.onCreateClick выполняет всего одно действие — передает событие на сервер, чтобы сообщить, что необходимо создать новую игру: IO.socket.emit('hostCreateNewGame').

Сервер создает новое игровое лобби и возвращает идентификатор лобби клиенту. Концепция игрового лобби уже реализована в Socket.IO. Лобби объединяет определенные клиентские подключения и обеспечивает передачу событий только тем клиентам, которые в данный момент «находятся» в одном лобби. Данная технология отлично подходят для создания отдельных игровых партий при использовании одного сервера. Без неё будет гораздо сложнее определить, какие игроки и хосты (основные экраны) должны быть соединены друг с другом, когда некоторое количество людей пытаются запустить несколько игр на одном сервере.

Следующая функция внутри agxgame.js создаст новое игровое лобби:

function hostCreateNewGame() {
    // Создаем уникальное лобби Socket.IO
    var thisGameId = ( Math.random() * 100000 ) | 0;

    // Вернем идентификатор лобби (gameId) и идентификатор сокета (mySocketId) 
    // в браузер клиента
    this.emit('newGameCreated', {gameId: thisGameId, mySocketId: this.id});

    // Присоединяемся к лобби и ожидаем подключения других пользователей
    this.join(thisGameId.toString());
};

В приведенном фрагменте кода this — это уникальный объект Socket.IO, хранящий информацию о подключении клиента. Функция join «переносит» подключение клиента в указанное лобби. В данном случае его идентификатор определяется путем генерации случайного числа в диапазоне от 1 до 100000. Идентификатор лобби отправляется обратно клиенту и используется в качестве уникального идентификатора для игровой сессии.

Клиент (основной экран) получает с сервера событие newGameCreated и отображает идентификатор лобби как идентификатор игры на экране. Чтобы показать сгенерированный случайный идентификатор игры на экране, на клиенте (внутри public/app.js) вызываются функции App.Host.gameInit и App.Host.displayNewGameScreen. Идентификатор игры и идентификатор лобби Socket.IO совпадают.

Начало игры

Игрок подключается к игре и нажимает «Присоединиться»

В этот момент отображается HTML-шаблон join-game-template. Когда открывается новое окно, клиент, как и прежде, подключается через Socket.IO. Если игрок нажимает на кнопку «Присоединиться», появляется экран, на котором игроку необходимо ввести имя и идентификатор игры, к которой он хочет присоединиться.

Первый игрок нажимает на кнопку «Старт»

Имя игрока и идентификатор игры отправляются на сервер. Когда игрок нажимает на кнопку «Старт» после ввода его (или ее) имени и соответствующего идентификатора игры, эта информация собирается в один объект data и отправляется на сервер. За это отвечает следующий фрагмент кода в функции App.Player.onPlayerStartClick:

var data = {
    gameId : +($('#inputGameId').val()),
    playerName : $('#inputPlayerName').val() || 'anon'
};
IO.socket.emit('playerJoinGame', data);

На сервере игрок переносится в игровое лобби. Когда сервер получает событие playerJoinGame, он вызывает функцию playerJoinGame (см. agxgame.js). Эта функция переносит сокет-подключение пользователя в выбранное игроком лобби. Затем она отправляет в ответ данные (имя игрока и идентификатор сокета) клиентам в этом лобби, так основной экран получает информацию о подключенных игроках.

В приведенном ниже фрагменте кода (этот код выполняется внутри функции playerJoinGame) gameSocket относится к глобальному объекту Socket.IO. Он позволяет осуществлять поиск по всем созданным на сервере лобби. Если указанное лобби найдено, игра продолжается, иначе подключенный игрок получает сообщение об ошибке.

var sock = this;

// Поиск идентификатора лобби в объекте-менеджере Socket.IO.
var room = gameSocket.manager.rooms["/" + data.gameId];

// Если комната существует...
if( room != undefined ){
    // Добавим идентификатор сокета в объект данных.
    data.mySocketId = sock.id;

    // Переместим пользователя в лобби
    sock.join(data.gameId);

    // Вызовем событие, оповещающее клиентов о подключении игрока к лобби.
    io.sockets.in(data.gameId).emit('playerJoinedRoom', data);

} else {
    // В противном случае отправим игроку сообщение об ошибке.
    this.emit('error',{message: "Указанного лобби не существует."} );
}

Игрок видит экран ожидания. Когда клиент получает событие playerJoinedRoom, на экране отображается сообщение о подключении пользователя к игре (см. скриншот выше).

Другой игрок подключается и нажимает «Присоединиться»

Повторяются те же действия, что и в случае с первым игроком до момента получения с сервера события playerJoinedRoom. При получении клиентом, который является основным экраном, события playerJoinedRoom вызывается функция App.Host.updateWaitingScreen. Эта функция обрабатывает информацию о количестве игроков в лобби. Если игроков двое, клиент основного экрана отправляет событие hostRoomFull.

Начинается обратный отсчет

Сервер получает событие hostRoomFull и отправляет событие beginNewGame. Событие beginNewGame отправляется сервером основному экрану и двум подключенным игрокам в определенном лобби с помощью следующего кода:

function hostPrepareGame(gameId) {
    var sock = this;
    var data = {
        mySocketId : sock.id,
        gameId : gameId
    };
    // Событие отправляется только тем игрокам, которые находятся в указанном лобби
    io.sockets.in(data.gameId).emit('beginNewGame', data);
}

Обратите внимание на то, что идентификатор игры, который видят игроки, и идентификатор лобби Socket.IO, в которой хранятся подключения клиентов, совпадают. Функция in указывает на определенное лобби, а in(...).emit() отправляет событие только тем пользователям, которые находятся в этом лобби.

Хост начинает обратный отсчет, а игрокам отправляется сообщение «Приготовьтесь». На клиенте событие beginNewGame является сигналом к началу новой игры. На клиенте, который является основным экраном, выполняется функция App.Host.gameCountdown, которая загружает шаблон host-game-template и показывает 5-секундный таймер обратного отсчета. На экранах игроков функция App.Player.gameCountdown просто показывает сообщение «Приготовьтесь».

Геймплей

Игра начинается

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

Функция sendWord подготавливает данные для следующего раунда игры. В данном случае это первый раунд. Функция sendWord состоит из двух частей: собирающей данные для раунда и передающей эти данные на основной экран и игрокам в лобби.

Функция getWordData занимается тяжелой работой по выбору слов для раунда и подготовкой их к отправке клиентам. Данные для каждого раунда хранятся в следующем формате:

{
    "words"  : [ "sale","seal","ales","leas" ],
    "decoys" : [ "lead","lamp","seed","eels","lean","cels","lyse","sloe","tels","self" ]
}

Перед каждым раундом массив words перемешивается. Первый элемент выбирается в качестве слова для отображения на экране. Второй элемент выбирается в качестве правильного ответа. Массив decoys также перемешивается, и первые 5 элементов добавляются в качестве неправильных ответов.

Затем правильный ответ вставляется в случайное место в списке неправильных ответов. Эти данные отправляются обратно клиенту в следующем формате (см. функцию getWordData в agxgame.js):

wordData = {
    round: i,          // Текущий раунд игры
    word : words[0],   // Отображаемое на основном экране слово
    answer : words[1], // Правильный ответ
    list : decoys      // Список слов для игроков (массив, содержащий правильный и неправильные ответ)
};

На основном экране появляется слово. Функция App.Host.newWord выводит слово большими буквами на экране, а также хранит правильный ответ и номер текущего раунда.

Игрокам показывается список слов. Функция App.Player.newWord анализирует список вариантов ответа (который содержит и правильный ответ) и формирует ненумерованный список элементов button для отображения на экранах игроков.

Игрок выбирает вариант ответа

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

var $btn = $(this);      // Кнопка, на которую нажал пользователь
var answer = $btn.val(); // Выбранное слово

// Отправляем информацию об игроке и выбранное слово на сервер
// так, чтобы клиент основного экрана смог проверить правильность ответа
var data = {
    gameId: App.gameId,       // Отправляем также идентификатор лобби
    playerId: App.mySocketId,
    answer: answer,
    round: App.currentRound
}
IO.socket.emit('playerAnswer',data);

Событие playerAnswer обрабатывается сервером, и ответ передается на клиент основного экрана. Сервер передает событие hostCheckAnswer, а вместе с ним и ответ пользователя.

Клиент основного экрана проверяет правильность ответа

Клиент основного экрана проверяет ответ пользователя в текущем раунде. Функция App.Host.checkAnswer сначала проверяет, является ли отправленный ответ ответом на текущий раунд. Это помогает избежать проблем с отслеживанием времени, когда оба игрока выбирают правильный ответ сразу же друг за другом. Так как первый игрок выбрал правильный ответ, раунд должен завершиться, и второй игрок не должен получить очки (или штраф) за выбранный вариант.

Если игрок выбрал правильное слово, сумма очков игрока увеличивается, и вызывается событие hostNextRound. Если выбран неправильный ответ, сумма очков игрока уменьшается, и игра продолжается в текущем раунде.

Игра заканчивается, когда использован весь набор слов

Функция hostNextRound определяет, должна ли игра закончиться. Если номер текущего раунда меньше, чем количество элементов в массиве wordPool, то игра продолжается в следующем раунде. Если номер текущего раунда превышает количество доступных слов, то сервер передаст клиентам в комнате событие gameOver.

На основном экране появляется имя победителя. Когда сервер передает событие gameOver, на клиенте вызывается функция App.Host.endGame. Эта функция проверяет, у какого из игроков больше очков. Имя победившего игрока выводится на основной экран.

Значение переменной App.Host.numPlayersInRoom устанавливается в 0 (даже если технически сокет-подключения игроков все еще «находятся» в текущем лобби Socket.IO). Это сделано для того, чтобы позволить игрокам начать новую игру. Флаг App.Host.isNewGame устанавливается в значение true, чтобы показать, что начинается новая игра.

Игра окончена

Игроки начинают заново

Игрок нажимает на кнопку «Начать заново», и клиент передает событие playerRestart. Сервер отправляет клиенту основного экрана уведомление о том, что игрок хочет сыграть снова, повторно передав событие playerJoinedRoom . Функция App.Host.updateWaitingScreen проверяет значение флага App.Host.isNewGame, видит, что оно равно true, и выводит на экран шаблон create-game-template, пока другой игрок не подключится к игре.

Когда второй игрок нажимает на кнопку «Начать заново», повторяется та же последовательность действий, и игра начинается заново.

В заключение

Это приложение затрагивает только некоторые важные концепции работы с библиотекой Socket.IO и принципы разработки приложений с взаимодействием в реальном времени с помощью Node.js. Если вы никогда не работали с приложениями, реализующими взаимодействие в реальном времени, надеюсь, вы получили достаточное количество интересной информации для того, чтобы вам захотелось изучить эту тему подробнее. Вы можете найти дополнительную информацию в документации на сайте Socket.IO или в Socket.IO wiki на GitHub.