WebRTC: Робимо peer to peer гру на javascript

Нещодавно мені довелося попрацювати над прототипом відеочату. Це був чудовий привід ближче познайомитися з концепціями WebRTC і випробувати їх на практиці. Як правило, коли говорять про WebRTC, мають на увазі організацію аудіо — та відеозв'язку, а ця технологія може застосовуватися і для інших цікавих речей. Я вирішив спробувати зробити peer-to-peer гру і поділитися досвідом її створення. Відео того що вийшло і подробиці реалізації під катом.







Движок для гри

Якось давним-давно мені попалася на очі демка гри з симпатичною піксельарт графікою. Гра була зроблена на JavaScript-движка Impact. Про нього навіть якось згадували на Хабре.



Движок платний, я купив його ще пару років тому, але так нічого путнього на ньому і не зробив, і ось нарешті він мені став у пригоді. Треба сказати, що сам по собі процес створення гри на ньому — дуже захоплююче заняття, і для людей на зразок мене, які хочуть швидко і недорого відчути себе крутими «игроделами», це те, що потрібно. Визначившись з технологією зв'язку та ігровим движком, можна перейти до реалізації. Особисто я почав з ігрових кімнат.

Ігрові кімнати

Яким чином гравець може потрапити в гру і як запросити своїх друзів? Багато онлайн-ігри використовують так звані кімнати або канали, щоб гравці могли грати один з одним. Для цього знадобиться сервер, який дозволить створювати ці самі кімнати і додавати/видаляти користувачів. Схема його роботи досить проста: коли користувач запускає гру, а в нашому випадку — відкриває вікно браузера з адресою гри, то відбувається наступне:

  1. новий гравець повідомляє серверу ім'я кімнати, в якій він хотів би грати;
  2. сервер у відповідь надсилає список гравців цієї кімнати;
  3. іншим гравцям приходить повідомлення про появу нового учасника.




Все це досить просто реалізувати, наприклад, на 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 наступний:

  1. А користувач надсилає запит на з'єднання користувачеві Б;
  2. користувач Б підтверджує запит від А;
  3. отримавши підтвердження, А користувач визначає свій IP, порт, можливі параметри сесії і посилає їх користувачеві Б;
  4. користувач Б у відповідь посилає свою адресу, порт і параметри сесії користувачеві А.


По завершенні цих дій обидва користувача знають адреси і параметри один одного і можуть почати обмінюватися даними. Але до того, як перейти до реалізації, варто ще дещо дізнатися про визначення пари 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 надає набір колбеков для різних подій життєвого циклу сполуки, з якого нам знадобляться:
  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, які необхідно відправити через 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. Клієнт Б отримує пропозицію від клієнта, А й встановлює віддалене опис сесії. Після чого створює SDP-answer, встановлює його в якості локального опису сесії і відправляє клієнтові А:

setSdp: function(sdp) {
var self = this;
// Create session description from data sdp
var rsd = new RTCSessionDescription(sdp);
// And set it as description for remote connection peer
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 <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-кандидатів можна почитати тут.

Ці дані потрібно передати через 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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.