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

Https://www.youtube.com/watch?v=-kKUFLyCyJw#action=share

Игровой движок

Некоторое время назад я видел демо-версию игры с красивой пиксельной графикой. Игра была написана на движке JavaScript Impact.

Двигатель стоит денег, купил пару лет назад, но ничего полезного с ним не сделал. Теперь, наконец, он пригодился. Я должен сказать, что сам по себе процесс создания игры с использованием этого движка очень увлекателен, и для таких людей, как я, которые хотят - быстро и недорого - почувствовать себя серьезными «создателями игр», это как раз то, что вам нужно. Определившись с тем, какую коммуникационную технологию и игровой движок использовать, можно переходить к стадии реализации. Что касается меня, то я начал с игровых комнат.

Игровые комнаты

Как игрок попадает в игру и как он может пригласить своих друзей? Во многих онлайн-играх используются так называемые игровые комнаты или каналы, чтобы игроки могли играть друг с другом. Для этого требуется сервер, который позволяет вам создавать рассматриваемые комнаты и добавлять / удалять пользователей. Это довольно простая настройка: когда пользователь запускает игру, а в нашем случае открывает URL-адрес игры в окне браузера, происходит следующее:

1. Новый игрок сообщает серверу название комнаты, в которой он хотел бы играть;

2. Сервер отвечает, отправляя обратно список игроков в рассматриваемой комнате;

3. Остальные игроки получают сообщение о появлении нового участника.

Все это довольно просто реализовать, например с помощью node.js + socket.io. Как это получилось, вы можете увидеть здесь. После того, как игрок присоединился к игровой комнате, он должен установить одноранговое соединение с каждым из игроков, присутствующих в комнате. Однако, прежде чем мы перейдем к реализации одноранговых данных, я предлагаю нам подумать о том, какими в принципе будут эти данные.

Протокол взаимодействия

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

message PlayerPosition {
      int16 x;
      int16 y;
}

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

message PlayerPositionAndAnimation {
      int16 x;
      int16 y;
      int8 anim;
      int8 animFrame;
      bool flipped;
}

Превосходно! Какие еще сообщения нам нужны? В зависимости от того, что вы планируете делать в игре, у вас будет собственный набор сообщений. Вот в основном мой набор:

● Игрок умирает ();

● Игрок родился (int16 x, int16 y);

● Игрок стреляет (int16 x, int16 y, логическое перевернутое);

● Игрок выбирает оружие (int8 weapon_id).

Стандартизированные поля в сообщениях

Как вы могли заметить, каждое из полей в этих сообщениях имеет свой собственный тип данных, например int16 - для полей, которые определяют координаты. Давайте сначала рассмотрим это, а попутно я расскажу вам немного о WebRTC API. Дело в том, что для передачи данных между одноранговыми узлами используется такой объект, как RTCDataChannel, который, в свою очередь, умеет работать с такими данными, как USVString, BLOB, ArrayBuffer или ArrayBufferView. А чтобы использовать ArrayBufferView, вам нужно четко понимать, в каком формате будут данные.

Итак, описав все сообщения, мы готовы продолжить и перейти к собственно организации взаимодействия между сверстниками. Здесь я постараюсь максимально кратко описать техническую сторону. На самом деле, попытки подробно обсудить каждый аспект WebRTC - долгий и сложный процесс, особенно в свете того факта, что Книга Ильи Григорика доступна в открытом доступе - настоящая сокровищница информации об этом и других субъекты сетевого взаимодействия. Моя цель, как я уже сказал, - вкратце описать основные принципы работы WebRTC - их изучение является отправной точкой для всех.

Настройка подключения

Что нужно пользователям A и B, чтобы установить между собой одноранговое соединение? Что ж, каждый из пользователей должен знать хотя бы адрес и порт, на котором его оппонент слушает и может принимать входящие данные. Но как A и B могут передавать эту информацию друг другу, если соединение еще не установлено? Для передачи этой информации требуется сервер. На жаргоне WebRTC это называется сигнальным сервером. И поскольку сервер уже настроен для игровых комнат, этот же сервер также может использоваться в качестве сервера сигнализации.

Кроме того, помимо адресов и портов, A и B должны согласовать параметры настраиваемого сеанса (например, в отношении использования различных кодеков и их параметров в случае аудио и видео соединений). Формат данных, описывающих всевозможные различные характеристики соединения, называется SDP - Session Description Protocol. Подробнее об этом можно узнать на сайте webrtchacks.com. Правильно, исходя из того, что мы сказали выше, процедура обмена данными через сигнализацию выглядит следующим образом:

1. Пользователь A отправляет запрос на подключение пользователю B;

2. Пользователь B подтверждает запрос от A;

3. Получив подтверждение, пользователь A идентифицирует свой IP, порт, любые параметры сеанса и отправляет их пользователю B;

4. Пользователь B отвечает, отправляя свой адрес, порт и параметры сеанса пользователю A.

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

Идентификация адреса и проверка доступности

Когда каждый из пользователей доступен через публичный IP-адрес или оба находятся в одной подсети - все просто. В этом случае каждый из них может запросить собственный IP-адрес у операционной системы и отправить его по сигнализации своему противнику. Но что делать, если пользователь недоступен напрямую, но находится за NAT, и у него два адреса: один локальный в подсети (192.168.1.1), а второй, а именно адрес NAT (50.76.44.114)? В этом случае они должны каким-то образом идентифицировать свой публичный адрес и порт.

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

Эти серверы называются STUN (Утилиты обхода сеансов для NAT). Существуют готовые к использованию решения, такие как coTURN, которые можно включить в качестве вашего STUN-сервера. Но, что еще проще, вы можете использовать уже включенные и доступные серверы, например, от Google.

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

К счастью, интегрированный в браузер фреймворк ICE (Interactive Connectivity Establishment) берет на себя задачу взаимодействия с STUN и проверки доступности. Все, что нам нужно сделать, это обработать события этого фреймворка. Хорошо, перейдем к этапу реализации…

Настройка подключения

Поначалу может показаться, что процесс настройки подключения довольно сложный. Однако, к счастью, сложность ограничивается интерфейсом RTCPeerConnection и на практике все проще, чем может показаться на первый взгляд. Посмотреть полный код класса, устанавливающего одноранговое соединение, можно здесь. Теперь я объясню это.

Как я уже сказал, настройка, мониторинг и закрытие соединения, а также работа с кандидатами SDP и ICE - все это делается через RTCPeerConnection. Более подробную информацию о конфигурации вы можете получить здесь. Однако с точки зрения конфигурации нам нужен только адрес сервера Google STUN, о котором я говорил ранее.

iceServers: [{
      url: 'stun:stun.l.google.com:19302'
}],
connect: function() {
      this.peerConnection = new RTCPeerConnection({
      iceServers: this.iceServers
      });
      // ...
}

RTCPeerConnection предлагает ряд обратных вызовов для различных событий в жизненном цикле соединения, из которых нам необходимо следующее:

1. icecandidate - для обработки найденного кандидата;

2. iceconnectionstatechange - для контроля состояния соединения;

3. datachannel - для обработки открытого канала данных.

init: function(socket, peerUser, isInitiator) {
      // …
      this.peerHandlers = {
      ‘icecandidate’: this.onLocalIceCandidate,
      ‘iceconnectionstatechange’: this.onIceConnectionStateChanged,
      ‘datachannel’: this.onDataChannel
      };
      this.connect();
},
connect: function() {
      // …
      Events.listen(this.peerConnection, this.peerHandlers, this);
      // ….
}

Отправка запроса на подключение

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

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

Для получения параметров сеанса в RTCPeerConnection createOffer есть методы для инициирующей стороны для создания предложения и createAnswer для отвечающей стороны для создать ответ. Эти методы генерируют данные в формате SDP, которые должны быть отправлены противнику посредством сигнализации. RTCPeerConnection сохраняет как описание локального сеанса, так и описание удаленного сеанса, полученное посредством сигнализации от оппонента. Для настройки этих полей доступны методы setLocalDescription и setRemoteDescription. Хорошо, допустим, клиент A инициирует соединение. Список операций будет следующим:

1. Клиент A создает предложение SDP, устанавливает описание локального сеанса в своем RTCPeerConnection, после чего отправляет его клиенту B:

connect: function() {
      // …
      if (this.isInitiator) {
      this.setLocalDescriptionAndSend();
      }
},
  setLocalDescriptionAndSend: function() {
      var self = this;
      self.getDescription()
      .then(function(localDescription) {
      self.peerConnection.setLocalDescription(localDescription)
      .then(function() {
            self.log(‘Sending SDP’, ‘green’);
            self.sendSdp(self.peerUser.userId, localDescription);
      });
      })
      .catch(function(error) {
      self.log(‘onSdpError: ‘ + error.message, ‘red’);
      });
},
getDescription: function() {
      return this.isInitiator ?
      this.peerConnection.createOffer() :
      this.peerConnection.createAnswer();
}

2. Клиент B получает предложение от клиента A и устанавливает описание удаленного сеанса. После этого они создают ответ SDP, устанавливают его как описание локальной сессии и отправляют клиенту A:

setSdp: function(sdp) {
      var self = this;
      // Create session description from sdp data
      var rsd = new RTCSessionDescription(sdp);
      // And set it as remote description for peer connection
  self.peerConnection.setRemoteDescription(rsd)
      .then(function() {
      self.remoteDescriptionReady = true;
      self.log(‘Got SDP from remote peer’, ‘green’);
      // Add all received remote candidates
      while (self.pendingCandidates.length) {
         self.addRemoteCandidate(self.pendingCandidates.pop());
      }
      // Got offer? send answer
      if (!self.isInitiator) {
         self.setLocalDescriptionAndSend();
      }
      });
}

Сбор кандидатов ICE

Каждый раз, когда агент ICE от клиента A находит новую пару IP + порт, которая может использоваться для соединения, RTCPeerConnection запускает событие icecandidate. Данные кандидата выглядят так:

candidate:842163049 1 <b>udp</b> 1677729535 <b>94.221.38.159 60478 typ srflx raddr 192.168.1.157 rport 60478</b> generation 0 ufrag KadE network-cost 50

Вот что мы можем почерпнуть из этих данных:

1. udp: если агент ICE решит использовать этого кандидата для соединения, то для соединения будет использоваться транспорт udp;

2. typ srflx - это кандидат, полученный путем запроса сервера STUN на определение адреса NAT;

3. 94.221.38.159 60478 - адрес NAT и порт, который будет использоваться для подключения;

4. raddr 192.168.1.157 rport 60478 - адрес и порт внутри NAT.

Более подробно с протоколом описания кандидатов ICE Вы можете ознакомиться здесь.

Эти данные необходимо передать через сигнализацию клиенту B, чтобы он мог добавить их в свое соединение RTCPeerConnection. Клиент B делает то же самое, когда обнаруживает свои собственные пары IP + порт:

// When ice framework discovers new ice candidate, we should send it
  // to opponent, so he knows how to reach us
  onLocalIceCandidate: function(event) {
      if (event.candidate) {
      this.log(‘Send my ICE-candidate: ‘ + event.candidate.candidate, ‘gray’);
      this.sendIceCandidate(this.peerUser.userId, event.candidate);
      } else {
      this.log(‘No more candidates’, ‘gray’);
      }
}
addRemoteCandidate: function(candidate) {
      try {
      this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      this.log(‘Added his ICE-candidate:’ + candidate.candidate, ‘gray’);
      } catch (err) {
      this.log(‘Error adding remote ice candidate’ + err.message,  ‘red’);
      }
}

Создание канала данных

Последнее, на что следует обратить внимание, - это RTCDataChannel. Этот интерфейс предлагает нам API, который помогает нам передавать случайные данные, а также настраивать параметры передачи данных:

● Полная или частичная гарантия доставки сообщений;
● Заказная или не заказанная доставка сообщений.

Вы можете узнать больше о конфигурации RTCDataChannel, например, здесь. На данный момент достаточно настроить параметр Order = false, чтобы сохранить семантику UDP при передаче ваших данных. Как и RTCPeerConnection, RTCDataChannel предлагает ряд событий, описывающих жизненный цикл канала данных. Из них open, close и message необходимы для открытия и закрытия канала и получения сообщения соответственно:

init: function(socket, peerUser, isInitiator) {
      // …
      this.dataChannelHandlers = {
      ‘open’: this.onDataChannelOpen,
      ‘close’: this.onDataChannelClose,
      ‘message’: this.onDataChannelMessage
      };
      this.connect();
  },
  connect: function() {
      // …
      if (this.isInitiator) {
      this.openDataChannel(
        this.peerConnection.createDataChannel(this.CHANNEL_NAME, {
      ordered: false
      }));
      }
  },
  openDataChannel: function(channel) {
      this.dataChannel = channel;
      Events.listen(this.dataChannel, this.dataChannelHandlers, this);
}

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

Больше игроков

Мы рассмотрели, как установить связь между двумя игроками, и этого в принципе достаточно, если вы играете один на один. Но что, если мы хотим, чтобы в одной комнате было несколько игроков? Что это меняет? На самом деле это ничего не меняет; просто у каждой пары игроков должно быть собственное соединение. Это означает, что если вы играете в комнате с 3 другими игроками, у вас должно быть 3 одноранговых соединения - по одному для каждого из них. Посмотреть полный код класса, отвечающего за взаимодействие со всеми противниками в комнате, можно здесь.

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

Синхронизация местоположения

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

Как часто вам нужно отправлять синхронизированные сообщения? В идеале противник должен видеть обновления так же часто, как и сам игрок, т.е. если игра работает с частотой 30–60 кадров в секунду, сообщения должны отправляться с той же частотой. Однако это довольно наивное решение, и в конечном итоге многое зависит от динамики самой игры. Например, стоит ли отправлять координаты так часто, если они меняются каждые 10–20 секунд? В таком случае, вероятно, оно того не стоит. В моем случае анимация и положение игроков меняются относительно часто, поэтому я выбрал простой ответ: отправку сообщения с координатами для каждого кадра.

Отправка синхронизированного сообщения:

update: function() {
      // …
      // Broadcast state
   this.connection.broadcastMessage(MessageBuilder.createMessage
(MESSAGE_STATE)
      .setX(this.player.pos.x * 10)
      .setY(this.player.pos.y * 10)
      .setVelX((this.player.pos.x — this.player.last.x) * 10)
      .setVelY((this.player.pos.y — this.player.last.y) * 10)
      .setFrame(this.player.getAnimFrame())
      .setAnim(this.player.getAnimId())
      .setFlip(this.player.currentAnim.flip.x ? 1 : 0));
       // …
}

Получение синхронизированного сообщения:

onPeerMessage: function(message, user, peer) {
  // …
  switch (message.getType()) {
      case MESSAGE_STATE:
      this.onPlayerState(remotePlayer, message);
      break;
       
      // …
      }
},
  onPlayerState: function(remotePlayer, message) {
      remotePlayer.setState(message);
},
  
  // in RemotePlayer class:
  setState: function(state) {
      var x = state.getX() / 10;
      var y = state.getY() / 10;
      this.dx = state.getVelX() / 10;
      this.dy = state.getVelY() / 10;
      this.pos = {
      x: x,
      y: y
      };
      this.currentAnim = this.getAnimById(state.getAnim());
      this.currentAnim.frame = state.getFrame();
      this.currentAnim.flip.x = !!state.getFlip();
      this.stateUpdated = true;
}

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

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

Координатная экстраполяция

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

На практике все обстоит иначе. Интервалы между сообщениями распределены неравномерно, из-за чего анимация «прыгает» и координаты меняются:

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

Движение было бы гораздо более непрерывным, если бы, когда сообщения задерживаются, координаты игрока изменились бы пропорционально, даже если они не всегда надежно точны:

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

setState: function(state) {
      var x = state.getX() / 10;
      var y = state.getY() / 10;
      this.dx = state.getVelX() / 10;
      this.dy = state.getVelY() / 10;
      this.pos = {
           x: x,
           y: y
      };
      this.currentAnim = this.getAnimById(state.getAnim());
      this.currentAnim.frame = state.getFrame();
      this.currentAnim.flip.x = !!state.getFlip();
      this.stateUpdated = true;
},
update: function() {
      if (this.stateUpdated) {
          this.stateUpdated = false;
      } else {
          this.pos.x += this.dx;
          this.pos.y += this.dy;
      }
      if( this.currentAnim ) {
          this.currentAnim.update();
      }
}

А вот как это выглядит после экстраполяции:

Конечно, у этого метода много недостатков, и, если соединение очень медленное, может произойти следующее:

Однако выполнение экстраполяции выходит далеко за рамки данной статьи, поэтому я предлагаю на этом остановиться.

Прочие игровые действия

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

Как все сложилось

Посмотреть код (кроме исходного кода самого ImpactJS) и инструкцию по его запуску можно на github.

Рискну дать ссылку, где можно попробовать сыграть здесь. Я не знаю, что будет с моей одноядерной каплей, но que sera, sera =)

Если вы дочитали это до конца - спасибо! Это означает, что моя работа не была потрачена зря, и вы нашли для себя что-то интересное. Не стесняйтесь писать любые вопросы, отзывы и предложения в разделе комментариев.

Александр Гутников, Frontend-разработчик.