Створюємо розраховану на багато користувачів браузерну гру. Частина перша. Клієнт-серверна архітектура


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

Розповідає Алвін Лін, розробник програмного забезпечення з Нью-Йорка


У 2014 році я вперше відвідав CodeDay в Нью-Йорку. І хоча CodeDay не зовсім хакатон, це було моє ознайомлення з подібними заходами. Там ми з моїм другом Кеннетом Лі написали розраховану на багато користувачів гру в танчики. Оскільки декілька моїх друзів запитували мене про те, як я її написав, я вирішив описати процес її створення.

У цьому пості я стисло опишу плин своїх думок і покажу, як відтворити архітектуру, а також дам деякі поради, якщо Ви захочете зробити гру самі. Цей пост розрахований на тих, хто володіє основами JavaScript і Node.js. Якщо Ви з ними не ознайомлені, то є багато онлайн-ресурсів, де можна їх вивчити.

Прим. перекл. На нашому сайті є багато пізнавальних матеріалів, як з JavaScript, так і з Node.js – зайдіть і Ви обов’язково знайдете щось відповідне.

Бекенд гри написаний на Node.js з використанням веб-сокетів, які дозволяють серверу і клієнтові спілкуватися в режимі реального часу. З боку клієнта гра відображається в HTML5-елементі Canvas. Спершу нам, звичайно, знадобиться Node.js. У цій статті описана робота з версією 6.3.1, але Ви можете використати будь-яку версію вище 0.12.

Прим. перекл. Якщо Ви не ознайомлені з веб-сокетами, рекомендуємо прочитати наш вступний матеріал.

Створення проекту

Спершу встановіть залежності. Створіть теку проекту, перейдіть до неї і запустіть наступний код:

npm initnpm install --save express socket.io

Для швидкого налаштування сервера доцільно використати фреймворк Express, а для обробки веб-сокетів на сервері – пакет socket.io. У файл server.js помістіть наступний код:

// Залежності var express = require ('express');var http = require('http');var path = require ('path');var socketIO = require ('socket.io');var app = express ();var server = http.Server#06;var io = socketIO (server);app.set ('port', 5000);app.use ('/static', express.static (__dirname + '/static'));// Маршрутыapp.get ('/', function (request, response) { response.sendFile (path.join (__dirname, 'index.html'));});
// Запуск сервера server.listen (5000, function (){ console.log ('Запускаю сервер на порту 5000');});

Це типовий код для сервера на зв’язці Node.js + Express. Він встановлює залежності й основні маршрути сервера. Для цього демонстраційного додатка використовується тільки один файл index.html і тека static. Створіть їх в кореневій теці проекту. Файл index.htmlпростий:

   

 

   

Ваш користувацький інтерфейс може містити набагато більше елементів, тому для більших проектів CSS- стилі краще розміщувати в окремий файл. Залишу CSS в коді HTML. Зверніть увагу, що я включив у код скрипт socket.io.js. Він автоматично запрацює у рамках пакета socket.io при запуску сервера.

Тепер треба налаштувати веб-сокети на сервері. Наприкінці файлу server.js додайте:

// Обробник веб-сокетів io.on ('connection', function (socket) {});

Поки що в грі немає жодних функцій, тому в обробник веб-сокетів нічого додавати не треба. Для тестування допишіть наступні рядки наприкінці файлу server.js:

setInterval (function (){ io.sockets.emit ('message', 'hi!');}, 1000);

Ця функція відправлятиме сполучення з ім’ям message і вмістом hi всім підключеним веб-сокетам. Пізніше не забудьте видалити цю частину коду, оскільки вона призначена тільки для тестування.

У теці static створіть файл з ім’ям game.js. Ви можете написати коротку функцію для реєстрації повідомлень від сервера, щоб переконатися в тому, що Ви їх отримуєте. У файлі static/game.js пропишіть наступне:

var socket = io ();socket.on ('message', function (data) { console.log (data);});

Запустіть сервер командою node server.js і в будь-якому браузері перейдіть за посиланням http://localhost:5000. Якщо Ви відкриєте вікно розробника (натиснути праву кнопку миші → Перевірити (Inspect)), то побачите, як кожну секунду надходить нове повідомлення:

Як правило, socket.emit (name, data) відправляє сполучення із заданим ім’ям і даними серверу, якщо запит йде від клієнта, і навпаки, якщо запит йде від сервера. Для отримання повідомлень за конкретним ім’ям використовується наступна команда:

socket.on ('name', function (data){
// аргумент data може містити будь-які дані, що відправляються});

За допомогою socket.emit () Ви можете відправити будь-яке повідомлення. Можна також передавати об’єкти JSON, що для нас дуже зручно. Це дозволяє миттєво передавати інформацію в грі від сервера до клієнта і назад, що є основою розрахованої на багато користувачів гри.

Тепер нехай клієнт відправляє деякі стани клавіатури. Помістіть наступний код наприкінці файлу static/game.js:

var movement ={ up: false, down: false, left: false, right: false}document.addEventListener ('keydown', function (event){ switch (event.keyCode){ case 65: // A movement.left = true; break; case 87: // W movement.up = true; break; case 68: // D movement.right = true; break; case 83: // S
movement.down = true; break; }});document.addEventListener ('keyup', function (event) { switch (event.keyCode) { case 65: // A movement.left = false; break; case 87: // W movement.up = false; break; case 68: // D movement.right = false; break; case 83: // S movement.down = false; break; }});

Це стандартний код, який дозволяє відстежувати натиснення клавіш W, A, S, D. Після цього додайте повідомлення, яке оповістить сервер про те, що в грі з’явився новий учасник, і створіть цикл, який повідомлятиме сервер про натиснення клавіш.

socket.emit ('new player');setInterval (function (){ socket.emit ('movement', movement);}, 1000 / 60);

Ця частина коду дозволить відправляти на сервер інформацію про стан клавіатури клієнта 60 разів на секунду. Тепер необхідно прописати цю ситуацію зі сторони сервера. Напркінці файлу server.js додайте наступні рядки:

var players ={};io.on ('connection', function (socket) { socket.on ('new player', function (){ players[socket.id] = { x: 300, y: 300 }; }); socket.on ('movement', function (data) { var player = players[socket.id] || {}; if (data.left) { player.x -= 5; } if (data.up) { player.y -= 5; } if (data.right) { player.x += 5; } if (data.down) { player.y += 5; } });});setInterval (function (){ io.sockets.emit ('state', players);}, 1000 / 60);

Давайте розберемося з цим кодом. Ви зберігатимете інформацію про всіх підключених користувачів у вигляді об’єктів JSON. Оскільки у кожного підключеного до сервера сокета є унікальний id, клавіша буде id сокета підключеного гравця. Значення буде іншим об’єктом JSON, що містить координати x і y.

Коли сервер отримає повідомлення про те, що приєднався новий гравець, він додасть новий вхід в об’єкт гравців за допомогою id сокета, який буде в цьому повідомленні. Коли сервер отримає повідомлення про рух, то оновить інформацію про гравця, який пов’язаний із цим сокетом, якщо він є.

io.sockets.emit () – це запит, який відправлятиме повідомлення і дані УСІМ підключеним сокетам. Сервер відправлятиме цей стан усім підключеним клієнтам 60 разів на секунду.

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

var canvas = document.getElementById ('canvas');canvas.width = 800;canvas.height = 600;var context = canvas.getContext ('2d');socket.on ('state', function (players) { context.clearRect (0, 0, 800, 600); context.fillStyle = 'green'; for (var id in players) { var player = players[id]; context.beginPath (); context.arc (player.x, player.y, 10, 0, 2 * Math.PI); context.fill (); }});

Цей код звертається до id Canvas (#canvas) і малює там. Щоразу, коли від сервера надходитиме повідомлення про стан, дані в Canvas обнулятимуться, і на ньому у вигляді зелених кружечків наново відображатимуться всі гравці.

Тепер кожен новий гравець зможе бачити стан всіх підключених гравців Canvas. Запустіть сервер командою node server.js і відкрийте у браузері два вікна. При переході за посиланням http://localhost:5000 Ви повинні будете побачити щось на кшталт:

От і все! Якщо у Вас виникли проблеми, то подивіться архів з вихідним кодом.

Деякі тонкощі

Коли розроблятимете багатофункціональну гру, доцільно поділити код на декілька файлів.

Такі розраховані на багато користувачів ігри – відмінний приклад архітектури MVC (модель-представлення-контроллер). Уся логічна частина повинна оброблятися на сервері, а все, що повинен робити клієнт – це відправляти вхідні користувацькі дані на сервер і відображати інформацію, яку отримує від сервера.

Проте в цьому демо-проекті є декілька недоліків. Оновлення гри пов’язане зі слухачем сокета. Якби я хотів вплинути на хід гри, то міг би написати в консолі браузера наступне:

while (true) { socket.emit ('movement',{ left: true });}

Тепер дані про рух вирушатимуть на сервер залежно від характеристик комп’ютера понад 60 разів на секунду. Це приведе до того, що гравець пересуватиметься неймовірно швидко. Так ми переходимо до концепції визначення повноважного сервера.

На жодному з етапів клієнт не повинен контролювати дані на сервері. Наприклад, ніколи не треба розміщувати на сервері код, який дозволить клієнтові визначати своє положення/здоров’я на основі даних, які передаються через сокет, оскільки користувач зможе легко підробити повідомлення, що виходить із сокета, як показано вище.

Коли я створював свою першу розраховану на багато користувачів гру, я написав код так, що гравець міг стріляти тоді, коли надходило повідомлення про стрільбу, яке з боку клієнта було пов’язане з натисненням кнопки миші. Вмілий гравець міг скористатися цим, вставивши рядок на JavaScript, подібний до згадуваного вище, щоб дістати майже необмежену швидкість стрільби.

Краща аналогія, яку я можу навести, полягає в тому, що клієнти повинні посилати на сервер тільки інформацію про свої наміри, які потім оброблятимуться і використовуватимуться для зміни стану гравців, якщо вони валідні.

В ідеалі цикли оновлень як у клієнта, так і на сервері не повинні залежати від сокетів. Спробуйте зробити так, щоб оновлення гри знаходилися за межами блоку socket.on (). Інакше Ви можете отримати багато дивних нелогічних дій через те, що оновлення гри буде пов’язано з оновленням сокета.

Крім того, намагайтеся уникати такого коду:

setInterval (function (){ // код ... player.x += 5; // код ...}, 1000 / 60);

У цьому фрагменті коду оновлення координати х для гравця пов’язано з частотою зміни кадрів у грі. SetInterval () не завжди гарантує дотримання інтервалу, замість цього напишіть щось подібне:

var lastUpdateTime =  (new Date()).getTime ();setInterval (function (){
// код ... var currentTime =  (new Date()).getTime (); var timeDifference = currentTime - lastUpdateTime; player.x += 5 * timeDifference; lastUpdateTime = currentTime;}, 1000 / 60);

Це не так витончено, проте забезпечить плавнішу і послідовну роботу. Ускладніть демо-проект і спробуйте зробити так, щоб оновлення здійснювалося згідно з часом, а не частотою зміни кадрів. Якщо не захочете на цьому зупинятися, спробуйте створити на сервері фізичний движок, який управлятиме рухами гравців.

Також можна зробити так, щоб з гри видалялися відключені гравці. Коли сокет відключається, автоматично вирушає повідомлення про роз’єднання. Це можна прописати так:

io.on ('connection', function (socket){ // обробник подій ... socket.on ('disconnect', function (){
// видаляємо гравця, що відключився });});

Також спробуйте створити власний фізичний движок. Це складно, але весело. Якщо захочете спробувати, то рекомендую прочитати книгу “The Nature of Code“, у якій є багато корисних ідей.

Якщо хочете подивитися на кращий приклад, ось гра, яку я зробив, а також вихідий код, якщо Ви хочете дізнатися, як це було написано. На цьому все. Дякую, що прочитали!

Переклад статті “How To Build A Multiplayer Browser Game (Part 1) “

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *