Такие технологии, как HTML5, Node.js и socket.io, позволяют создавать не только интерактивные и совместные приложения, но и многопользовательские игры, которые работают прямо в браузере, без каких-либо сторонних плагинов.

Эта статья основана на Frienzzle, многопользовательской игре-головоломке, которую я сейчас пишу, но общая архитектура, которую я здесь описываю, может быть применена к различным типам многопользовательских игр, включая простые RPG и RTS.

Я собираюсь сосредоточиться здесь на собственно игровом движке. Технически это страница, которая содержит большой элемент холста и загружает клиентский скрипт. Для создания работающей игры вам понадобится немного больше. Недавно я написал статью, в которой показано, как объединить традиционное веб-приложение PHP с движком Node.js. Это может быть полезно, если вы только начинаете использовать Node.js, как я.

В случае с Frienzzle клиентская часть игры состоит из одного сценария, связанного с помощью Browserify, вместе с клиентской библиотекой socket.io и одной картинки, используемой для головоломки. Для более сложных игр может потребоваться больше скриптов, изображений и других ресурсов, поэтому вам может потребоваться что-то вроде Webpack, но я не собираюсь здесь описывать это.

Архитектура высокого уровня

Начнем с диаграммы, которая показывает архитектуру как серверной, так и клиентской части игры:

Основными компонентами системы сверху вниз являются:

  • ServerController. Обрабатывает новые подключения от клиентов, выполняет аутентификацию, при необходимости создает и уничтожает игровые и игровые контроллеры. Он также отвечает за сохранение данных в базе данных, ведение журнала и другие задачи обслуживания.
  • GameController. В зависимости от типа игры существует один объект на игровой мир, игровую комнату или другое виртуальное место, где несколько пользователей могут играть вместе. Он отвечает за изменение состояния игры в ответ на действия игрока и за уведомление игроков об этих изменениях.
  • Игра. Представляет текущее состояние игры как на клиенте, так и на сервере. Я напишу об этом позже.
  • PlayerController. На каждого клиента, подключенного к серверу, приходится по одному объекту. Он обрабатывает входящие сообщения от клиента и выполняет действия в игре от имени игрока. Он также периодически отправляет клиенту сообщения, содержащие обновления состояния игры.
  • ClientController. На каждого клиента приходится по одному объекту. Он обрабатывает входящие сообщения с сервера и обновляет состояние игры. Он также обрабатывает вводимые пользователем данные и отправляет сообщения о действиях пользователя на сервер.
  • GameView. Визуализирует игру на элементе холста и передает события ввода из браузера в клиентский контроллер.

Как видите, архитектура клиентской части похожа на MVC. Основное отличие состоит в том, что контроллер обновляет модель (то есть состояние игры) на основе ввода с сервера, а не ввода пользователя. Однако на практике мы хотим, чтобы действия пользователя были видны немедленно, не дожидаясь двустороннего обмена между клиентом и сервером.

Решение - разделить модель игры на отдельные логические части:

  • Неизменяемые данные. Это данные, которые никогда не меняются во время игры. В ролевой игре это может быть местность, в случае игры-головоломки - формы отдельных частей. Эти данные необходимо отправить клиенту только один раз в начале игры, и клиент может предположить, что они всегда точны.
  • Постоянное состояние. Это данные, которые могут изменяться во время игры, например, положение персонажей или положение частей головоломки. У клиента есть снимок состояния игры, хранящийся на сервере, который может быть неточным. По этой причине клиент никогда не должен изменять это состояние непосредственно в ответ на действия пользователя, только в ответ на сообщения от сервера.
  • Временное состояние. В этом разница между постоянным состоянием и состоянием, которое видит пользователь. Это нужно только на стороне клиента. Например, когда персонаж игроков перемещается, временное местоположение изменяется немедленно, поэтому анимация движения плавная, но постоянное местоположение обновляется только тогда, когда сервер подтверждает это изменение. Сервер может отклонить изменение, например, когда другой объект тем временем преграждает путь.

Архитектура сервера также немного похожа на MVC. Для каждого игрового мира есть одна модель. Модель обновляется игровым контроллером в ответ на действия игрока и другие игровые события. Хотя традиционного представления на сервере нет, контроллеры игроков действуют аналогично представлениям - они получают обновления при изменении состояния игры, но вместо обновления экрана они передают эти обновления клиентам. Они также передают пользовательский ввод от клиентов на игровой контроллер.

Пример потока данных

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

Все шаги в хронологическом порядке:

  1. Представление игры реагирует на событие нажатия клавиши, отправленное браузером, и отправляет событие «движение вправо» на клиентский контроллер.
  2. Клиентский контроллер проверяет, может ли персонаж двигаться, основываясь на текущем снимке состояния игры. Например, если есть стена, которая преграждает путь, событие может быть отменено. Если действие возможно, клиентский контроллер обновляет временное положение персонажа и перерисовывает вид игры. Он также отправляет на сервер событие «движение вправо» с помощью веб-сокета.
  3. Контроллер игрока на сервере получает событие «движение вправо» через веб-сокет. Он передает событие игровому контроллеру вместе с информацией об игроке, чей персонаж должен быть перемещен.
  4. Игровой контроллер проверяет, может ли персонаж данного игрока двигаться вправо. На основании этой информации он может принять или отклонить действие. Если действие возможно, игровой контроллер обновляет постоянное состояние игры и отправляет событие «состояние изменено» всем контроллерам игроков, связанным с игрой.
  5. Контроллеры проигрывателя сериализуют данные, которые изменились, и отправляют их клиентам, используя их соответствующие веб-сокеты.
  6. Клиентские контроллеры, которые в настоящее время подключены к игре, получают событие «состояние изменено» с новыми данными. Они обновляют постоянное состояние игры и при необходимости перерисовывают вид.

Управление сложностью

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

К счастью, Node.js предлагает несколько инструментов, облегчающих нашу жизнь. Например, использование EventEmitter упрощает разделение компонентов системы. И вид игры, и игровой контроллер являются источниками событий; их события потребляются клиентским контроллером и контроллером игрока соответственно. Контроллеры клиента и проигрывателя также взаимодействуют с помощью событий, передаваемых через их веб-сокеты.

Использование JavaScript как для клиентского, так и для серверного кода позволяет совместно использовать большой объем кода между клиентом и сервером. Например, логика обнаружения коллизий идентична для клиентской и серверной сторон, поэтому ее не нужно дублировать.

Я обычно организую свой код JavaScript следующим образом:

lib/
    client/
    server/
    shared/
client.js
server.js

Весь код, совместно используемый клиентом и сервером, переходит в lib/shared/, код, специфичный для клиента или сервера, переходит в lib/client/ и /lib/server/ соответственно; client.js - это точка входа для клиентского пакета, а server.js - это точка входа для службы Node.js, которая работает на сервере.

Первая версия Frienzzle была официально выпущена 31 мая 2017 года. Зайдите на frienzzle.com и попробуйте!