WebRTC: Делаем peer to peer игру на javascript
Движок для игры
Как-то давным-давно мне попалась на глаза демка игры с симпатичной пиксельарт графикой. Игра была сделана на JavaScript-движке Impact. Про него даже как-то упоминали на Хабре.
Движок платный, я купил его еще пару лет назад, но так ничего дельного на нем и не сделал, и вот наконец-то он мне пригодился. Надо сказать, что сам по себе процесс создания игры на нем — очень увлекательное занятие, и для людей вроде меня, которые хотят быстро и недорого ощутить себя крутыми «игроделами», это то, что нужно. Определившись с технологией связи и игровым движком, можно перейти к реализации. Лично я начал с игровых комнат.
Игровые комнаты
Каким образом игрок может попасть в игру и как пригласить в нее своих друзей? Многие онлайн-игры используют так называемые комнаты или каналы, чтобы игроки могли играть друг с другом. Для этого понадобится сервер, который позволит создавать эти самые комнаты и добавлять/удалять пользователей. Схема его работы довольно простая: когда пользователь запускает игру, а в нашем случае — открывает окно браузера с адресом игры, то происходит следующее:
- новый игрок сообщает серверу имя комнаты, в которой он хотел бы играть;
- сервер в ответ отправляет список игроков этой комнаты;
- остальным игрокам приходит уведомление о появлении нового участника.
Все это достаточно просто реализовать, например, на node.js + socket.io. То, что получилось, можно посмотреть тут. После того как игрок попал в игровую комнату, он должен установить peer-to-peer соединение с каждым из присутствующих в этой комнате игроков. Но, до того как перейти к реализации peer-to-peer данных, предлагаю подумать о том, какие это в принципе будут данные.
Протокол взаимодействия
Формат и смысл сообщений, передаваемых между игроками, сильно зависит от того, что вообще будет происходить в игре. В нашем случае это простенький 2D-шутер, где игроки бегают и стреляют друг в друга. Поэтому в первую очередь нужно знать о месте расположения других игроков на карте:
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, boolean flipped);
- игрок подбирает оружие (int8 weapon_id).
Типизированные поля в сообщениях
Как вы могли заметить, каждое из полей в сообщениях имеет свой тип данных, например, int16 — для полей, представляющих координаты. Давайте сразу в этом разберемся, заодно я немного расскажу про WebRTC API. Дело в том, что для передачи данных между пирами используется объект типа RTCDataChannel, который, в свою очередь, умеет работать с данными типа USVString, BLOB, ArrayBuffer или ArrayBufferView. Как раз для того, чтобы использовать ArrayBufferView, и нужно четко понимать, какого формата будут данные.
Итак, описав все сообщения, мы готовы продолжить и перейти непосредственно к организации взаимодействия между пирами. Здесь я постараюсь описать матчасть настолько кратко, насколько смогу. Вообще, пытаться рассказать про WebRTC во всех деталях — долгое и сложное занятие, тем более что в открытом доступе есть книга Ильи Григорика, которая является просто кладезем информации на эту и другие темы, касающиеся сетевого взаимодействия. Моя же цель, как я уже сказал, — дать краткое описание основных механизмов WebRTC, с изучения которых придется начать каждому.
Установка соединения
Что нужно для того, чтобы пользователи А и Б смогли установить peer-to-peer соединение между собой? Ну, как минимум каждый из пользователей должен знать адрес и порт, по которому его оппонент слушает и может получить входящие данные. Но как А и Б сообщат друг другу эту информацию, если связь еще не установлена? Для передачи этой информации нужен сервер. В терминологии WebRTC он называется signalling-сервер. И так как уже реализован свой сервер для игровых комнат, его же можно использовать и в качестве signalling-сервера.
Также кроме адресов и портов, А и Б должны договориться о параметрах устанавливаемой сессии.Например об использовании тех или иных кодеков и их параметров в случае аудио- и видео связи. Формат данных, описывающих всевозможные свойства соединения, называется SDP — Session Description Protocol. Более подробно с ним можно познакомиться на webrtchacks.com. Итак, исходя из вышесказанного, порядок обмена данными через signalling следующий:
- пользователь А посылает запрос на соединение пользователю Б;
- пользователь Б подтверждает запрос от А;
- получив подтверждение, пользователь А определяет свой IP, порт, возможные параметры сессии и посылает их пользователю Б;
- пользователь Б в ответ посылает свой адрес, порт и параметры сессии пользователю А.
По завершении этих действий оба пользователя знают адреса и параметры друг друга и могут начать обмениваться данными. Но до того как перейти к реализации, стоит еще кое-что узнать про определение пары IP-адрес + порт.
Определение адреса и проверка доступности
Когда каждый из пользователей доступен по публичному IP-адресу или оба находятся в рамках одной подсети — все просто. Тогда каждый из них может запросить свой IP у операционной системы и отправить его через signalling своему оппоненту. Но что делать, если пользователь недоступен напрямую, а находится за NAT, и у него два адреса: один локальный, внутри подсети (192.168.1.1), второй — адрес самого NAT (50.76.44.114)? В этом случае ему каким-то образом нужно определить свой публичный адрес и порт.
Идея решения довольно проста: нужен публично доступный сервер, который, получив запрос от нас, отправит в ответ наш публичный адрес и порт.
Такие сервера называются STUN (Session Traversal Utilities for NAT). Существуют готовые решения, например, coturn, который можно развернуть в качестве своего STUN-сервера. Но можно поступить еще проще и воспользоваться уже развернутыми и доступными серверами, например от Google.
Таким образом, каждый может получить свой адрес и послать его своему оппоненту. Но этого недостаточно, ведь после получения адреса от оппонента нужно еще проверить, можем ли мы достучаться до него по этому адресу?
К счастью, задачу взаимодействия со STUN и задачу проверки доступности берет на себя ICE (Interactive Connectivity Establishment) фреймворк, встроенный в браузер. Все, что нам нужно, — обрабатывать события этого фреймворка. Итак, приступим к реализации…
Создание соединения
Поначалу может показаться, что процесс установки соединения достаточно сложный. Но, к счастью, вся сложность скрыта всего лишь за одним интерфейсом RTCPeerConnection, и на практике все проще, чем может показаться на первый взгляд. Полный код для класса, реализующего peer-to-peer соединение, можно посмотреть тут, дальше я поясню его.
Как я уже сказал, установка, мониторинг и закрытие соединения, а также работа с SDP и ICE кандидатами — все это делается через RTCPeerConnection. Более подробную информацию о конфигурации можно посмотреть, например, тут. Нам же в качестве конфигурации понадобится только адрес STUN-сервера от Google, о котором я говорил выше.
iceServers: [{
url: 'stun:stun.l.google.com:19302'
}],
connect: function() {
this.peerConnection = new RTCPeerConnection({
iceServers: this.iceServers
});
// ...
}
RTCPeerConnection предоставляет набор колбеков для различных событий жизненного цикла соединения, из которого нам понадобятся:
- icecandidate — для обработки найденного кандидата;
- iceconnectionstatechange — для отслеживания состояния соединения;
- 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, которые необходимо отправить через signalling оппоненту. RTCPeerConnection хранит как локальное описание сессии, так и удаленное, полученное через signalling от оппонента. Для установки этих полей есть методы setLocalDescription и setRemoteDescription. Итак, допустим клиент А инициирует соединение, тогда порядок действий следующий:
1. Клиент А создает SDP-offer, устанавливает локальное описание сессии в своем RTCPeerConnection, после чего отправляет его клиенту Б:
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. Клиент Б получает offer от клиента А и устанавливает удаленное описание сессии. После чего создает SDP-answer, устанавливает его в качестве локального описания сессии и отправляет клиенту А:
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();
}
});
}
4. После того как клиент А получает SDP-answer от клиента Б, он также устанавливает его в качестве удаленного описания сессии. В результате каждый из клиентов установил локальное описание сессии и удаленное, полученное от своего оппонента:
Сбор ICE-кандидатов
Каждый раз когда ICE-агент клиента А находит новую пару IP+port, которую можно использовать для связи, у RTCPeerConnection срабатывает событие icecandidate. Данные кандидата выглядят следующим образом:
candidate:842163049 1 udp 1677729535 94.221.38.159 60478 typ srflx raddr 192.168.1.157 rport 60478 generation 0 ufrag KadE network-cost 50
Вот что можно понять, глядя на эти данные:
- udp: Если ICE-агент решит использовать этот кандидат для связи, то для нее будет использован udp транспорт;
- typ srflx — это кандидат, полученный путем обращения к STUN-серверу для определения адреса NAT;
- 94.221.38.159 60478 — адрес NAT и порт, который будет использован для связи;
- raddr 192.168.1.157 rport 60478 — адрес и порт внутри NAT.
Более подробно о протоколе описания ICE-кандидатов можно почитать тут.
Эти данные нужно передать через signalling клиенту Б, чтобы он добавил их в свой RTCPeerConnection. Точно так же поступает и клиент Б, когда обнаруживает свои пары IP+port:
// When ice framework discoveres 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 можно узнать, например, тут. В данный момент будет достаточно свойства ordered = 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 peer-to-peer соединения с каждым из них. Полный код класса, отвечающего за взаимодействие со всеми оппонентами по комнате, можно посмотреть тут.
Итак, signalling-сервер c комнатами готов, формат сообщений и способ их доставки обсудили, как теперь на основе этого сделать так, чтобы игроки видели друг друга?
Синхронизация местоположения
Идея синхронизации довольно простая: нужно один раз в какой-то промежуток времени отправлять оппонентам свои координаты, тогда они на основе этих данных могут достоверно отражать твое местоположение.
Как часто нужно отправлять синхронизационные сообщения? В идеале оппонент должен видеть обновления так же часто, как и сам игрок, т.е. если игра работает с фреймрейтом 30–60 кадров в секунду, то и сообщения тоже должны отправляться с той же частотой. Но это довольно наивное решение, и многое в конечном итоге зависит от динамичности самой игры. Например, стоит ли так часто отправлять координаты, если они меняются раз в десять-двадцать секунд? Наверное, в таком случае это излишне. В моем случае анимация и положение игроков меняется довольно часто, поэтому я решил пойти простым путем и отправлять сообщения с координатами на каждый фрейм.
Отправка синхронизационного сообщения:
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) и инструкции по запуску можно посмотреть на гитхабе.
Рискну оставить тут эту ссылку, где можно попробовать поиграть. Не знаю, что там случится с моим single-core дроплетом, но будь что будет =)
Напоследок
Если вы дочитали до конца — спасибо! Значит, мой труд не пропал даром и вы нашли для себя что-то интересное. Вопросы, замечания и предложения оставляйте, пожалуйста, в комментариях.
Александр Гутников, frontend разработчик, Badoo.
Комментарии (1)
24 ноября 2016 в 10:56
0↑
↓
Странно, что вы не повесили флажок, что это туториал. Он вышел замечательным. Все просто, понятно и без воды.
Первым, что приходит в голову — добавить еще уровней, редактор уровней и парочку режимов.