Как использовать WebSockets с Vanilla JavaScript и Rails для создания многопользовательских игр

Для моего последнего проекта с FlatIron School я разработал одностраничное веб-приложение на JavaScript Track Builder.

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

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

Для достижения вышеуказанной функциональности я использовал ActionCable Rails на моем сервере для ответа на сообщения. Чтобы включить это в производство, я использовал надстройку Redis от Heroku.

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

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

Начиная

Изначально я разработал этот проект на Ruby 2.6.1. Однако при отправке серверной части на Heroku для хостинга их последним приложениям требовалась последняя версия. Итак, если вы собираетесь работать с Heroku, я бы порекомендовал выполнить обновление сейчас, чтобы избежать этой проблемы.

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

В этом руководстве я предполагаю, что у вас есть приложение с серверной частью Rails API, инициированное командой rails new your-app-name — api.

Настройка бэкэнда для WebSockets

Встроенный класс Rails для обработки интеграции WebSocket - ActionCable. Когда сервер принимает WebSockets, создается экземпляр объекта подключения, который становится родительским для всех следующих создаваемых подписок на каналы.

ActionCable состоит из классов ApplicationCable Connection и Cable.

Класс ApplicationCable :: Connection имеет доступ к файлам cookie, отправляемым с запросами на соединение, и его можно использовать для обработки входа в систему или проверки пользователей. Однако для Track Builder этого не требовалось. Не требуя логики для проверки допустимых пользователей, я не менял класс подключения.

Класс Cable очень похож на класс ApplicationController, от которого наследуются все ваши контроллеры, и поэтому мой TrackCable был настроен следующим образом:

class TrackChannel < ApplicationCable::Channel
    def subscribed
        stream_from(params[:id])
    end
    def receive(data)
        ActionCable.server.broadcast(params[:id], {content: data})
    end
    def unsubscribed
        stop_all_streams
    end
end

Три метода subscribed, receive и unsubscribed соответствуют команде в сообщении, которое будет отправлено из внешнего интерфейса. Также в этом сообщении будет содержаться информация, которая формирует то, что будет доступно как params.

Здесь subscribed ответит на команду «подписаться» при отправке с идентификатором трека, на который потребитель (пользователь приложения) желает подписаться, а метод stream_from откроет маршрут для канала для широковещательной передачи информации.

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

track = Track.find(params[:id])
stream_for track

Когда поток открыт, данные передаются в приложении rails с использованием ActionCable.server.broadcast, как показано в методе receive. broadcast принимает два аргумента: имя трансляции и контент.

Обратите внимание: на момент написания ruby ​​требует, чтобы второй аргумент был заключен в фигурные скобки {}, в отличие от того, как написана документация.

receive отвечает на команду «сообщение», принимая атрибут, который отправляется в сообщении.

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

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

Метод unsubscribed отвечает на команду «отказаться от подписки». Затем он вызывает stop_all_streams, чтобы отписать потребителя от всех трансляций, на которые он подписан.

Документацию по этим методам можно найти здесь.

С такой конфигурацией остается только подключить сервер к маршруту, который будет ссылаться на ваш интерфейс. Добавление следующего в ваш файл config / routes.rb поможет:

mount ActionCable.server => "/cable"

Подготовка фронтенда

Чтобы отправлять и получать данные на моем интерфейсе, был создан экземпляр Vanilla JavaScript WebSocket со следующим кодом:

const socket = new WebSocket(webSocket);

Это ссылается на константу webSocket, которая была определена ранее вместе с константой порта:

const port = 'http://localhost:3000';
const webSocket = 'ws://localhost:3000/cable';

При запуске WebSocket серверу отправляется «рукопожатие», чтобы открыть соединение, на которое сервер ответит. Следующий код JavaScript можно использовать для работы аналогично прослушивателю событий:

socket.onopen = function(e) {
    // desired functionality here
}

Здесь можно было бы открывать любые вещательные каналы общего назначения.

Однако в Track Builder я хотел, чтобы пользователи подписывались на каналы только при просмотре треков, поэтому я использовал следующий код:

function requestSubscribe() {
    const message: {
        command: "subscribe",
        identifier: JSON.stringify({
            channel: "TrackChannel", id: currentTrack.id
        })
    };
    socket.send(JSON.stringify(message));
}

Как упоминалось в разделе backend, это вызовет метод subscribed TrackChannel, отправив требуемый идентификатор трека в качестве параметра.

Отправка того же сообщения, но переключение команды на «отказаться от подписки» вместо этого вызовет unsubscribed метод.

Для отправки данных на сервер в хеш сообщения добавляется дополнительный ключ данных:

function updateCarLocation() {
    const message = {
        command: "message",
        identifier: JSON.stringify({
            channel: "TrackChannel", id: currentTrack.id
        }),
        data: JSON.stringify(myCar)
    };
    socket.send(JSON.stringify(message));
}

Это отправит информацию об автомобиле пользователя на сервер, который будет транслироваться всем, кто подписан на канал трека. Я расскажу, из чего состоит информация об автомобиле, более подробно в следующем разделе.

При отправке данных на указанный сервер мы получим широковещательную передачу данных с использованием функции onmessage следующим образом:

socket.onmessage = function(e) {
    const data = JSON.parse(e.data);
    if (data.type === 'ping' || !data.message) return
    const carData = data.message.content;
    if (ip !== carData.ip) cars[carData.ip] = carData;
}

Это будет срабатывать всякий раз, когда сообщение транслируется на канал, на который подписан WebSocket. К ним относятся «пинги», которые не нужно обрабатывать в этом приложении, что составляет первую инструкцию if.

Аргумент, который неявно передается этой функции, - это событие сообщения. Свойство data события содержит полезную нагрузку сообщения; который будет установлен в receive методе вашего канала Rails. Поэтому я извлекаю .content из сообщения.

Советы по повышению производительности в многопользовательской игровой среде

Здесь я расскажу о некоторых приемах, которые я использовал, чтобы обеспечить плавное переключение каналов в этом приложении. Если вы используете WebSockets для функций чата или для обновления страниц с изменениями базы данных в реальном времени, не стесняйтесь пропустить этот раздел.

Если вы посетили сайт и проверили страницу, вы заметили, что анимация визуализируется с использованием объекта холста JavaScript. Для этого используется функция window.requestAnimationFrame(), которая вызывает себя после рендеринга.

Обычно это происходит около 60 раз в секунду. Таким образом, я использовал его как точку в моем коде, где я бы назвал ранее определенный updateCarLocation(). Это означает, что транслируются очень регулярные обновления, чтобы информация о каждом автомобиле была в хорошем состоянии для всех пользователей.

Были отправлены данные о местонахождении автомобиля, ракурсе, цвете, IP-адресе пользователя, чей автомобиль это был, и был ли он активен или нет. Это означало, что когда пользователь получил информацию об автомобиле, он мог сравнить IP-адрес, и, если это была не информация, отправленная от него, они добавили бы автомобиль к объекту cars, который будет отображаться в следующем кадре анимации.

Хотя такая высокая частота обновлений служила для поддержания точной даты всех автомобилей в канале, она действительно начинала вызывать нежелательное поведение, когда пользователь переключал канал. Это нежелательное поведение заключалось в том, что автомобиль не мог быть правильно удален (для его атрибута active было установлено значение false) из оставленного канала.

Это произошло из-за того, что кадры приложения визуализировались после отправки запроса на отказ от подписки на канал. Чтобы бороться с этим, я добавил константу «ready», которая будет установлена ​​в значение false в течение 30 миллисекунд сразу после отказа от подписки, и, таким образом, никакие обновления не будут отправляться на предыдущий канал.

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

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

И последнее замечание: чтобы удалить пользователя при обновлении браузера или закрытии вкладки, я добавил в окно прослушиватель событий:

window.addEventListener('beforeunload', (event) => {
    event.preventDefault();
    removeCar(channel);
    unsubscribeFrom(channel);
});

Где removeCar() отправит на канал обновление о том, что автомобиль неактивен.

Обновление до производства

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

Я разместил свой бэкэнд на Heroku, используя их документацию здесь, и внешний интерфейс к Netlify, следуя их инструкциям.

После перехода от разработки к производству в ваш код потребуется внести ряд изменений. Сначала мы начнем с бэкэнда.

Бэкэнд - Heroku и Redis

Для того, чтобы Heroku правильно запускал ваш внутренний сервер, Procfile должен быть добавлен в корневой каталог вашей серверной файловой структуры.

Если вы знакомы с серверами хостинга, особенно с Sinatra, возможно, вы использовали thin для запуска сервера. Однако Thin не отправляет правильные заголовки в ответ на сообщения WebSocket, и поэтому следует использовать Puma. Ваш результирующий Профиль должен содержать следующее:

web: bundle exec puma -C config/puma.rb
release: bundle exec rake db:migrate

Это зависит от того, инициализируется ли проект с помощью rails new, что сгенерирует необходимый файл puma.rb.

Чтобы ваше приложение могло читать файл Procfile, гем мастера должен быть добавлен в ваш файл гема:

gem 'foreman'

При переходе от разработки к производству адаптер для ActionCable будет обновлен до redis с async.

Я решил использовать (и рекомендую) бесплатное дополнение Redis от Heroku, которое можно инициализировать в bash с помощью команды Heroku:

heroku addons:create heroku-redis:hobby-dev -a your-app-name

Обратите внимание, что для этого вам нужно будет привязать банковскую карту к своей учетной записи Heroku. Но вы можете подтвердить, что надстройка бесплатна, введя следующую команду после создания:

heroku addons:info your-app-name

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

heroku config | grep REDIS

Здесь важен REDIS_URL, и нам нужно будет изменить стандартный файл config / cable.yml Rails, чтобы он выглядел следующим образом:

development:
    adapter: async
test:
    adapter: test
production:
    adapter: redis
    url: <%= ENV.fetch("REDIS_URL") %>
    channel_prefix: backend_track_builder_production

Следующий гем также необходим в гем-файле и должен быть готов для комментирования как часть новой команды Rails:

gem 'redis', '~> 4.0'

Последний этап подготовки серверной части - обновить файл config / cors.rb, чтобы он принимал запросы только от вашего внешнего интерфейса. После того, как вы разместились на Netlify, вернитесь сюда, чтобы внести поправки здесь:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
    allow do
        origins 'https://your-app-name.netlify.app'
        resource '*',
        headers: :any,
        methods: [
            :get, :post, :put, :patch, :delete, :options, :head
        ]
    end
end

Интерфейс - Netlify

Когда бэкэнд работает, единственные изменения, необходимые для внешнего интерфейса, - это обновление вашего порта и переменных веб-сокета, чтобы они отражали ваш URL-адрес Heroku:

const port = 'https://your-app-name.herokuapp.com';
const webSocket = 'wss://your-app-name.herokuapp.com/cable';

Обратите внимание на важность wss. В разработке ws отлично работает с локальным хостом; однако на производстве требуется безопасное соединение.

Через линию

Следуя этому руководству, вы должны иметь полностью работоспособное приложение в производственной среде! Использование ActionCable с redis и предоставление каналов для отправки обновлений в реальном времени на ваш интерфейс.

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

Весь код можно найти на моем github frontend и backend.

Обязательно дайте мне знать, как у вас дела с собственными приложениями, и во время простоя постройте трассу и бросьте вызов своим друзьям, чтобы пройти самый быстрый круг!