На выпадак, калі вы ніколі раней не чулі пра такія гульні: гэта бясплатныя шматкарыстальніцкія вэб-гульні, у якіх лёгка ўдзельнічаць (не патрабуецца ўліковы запіс). Звычайна яны сутыкаюць на адной арэне мноства супрацьлеглых гульцоў. Іншыя знакамітыя гульні жанру .io: Slither.io и Diep.io.
У гэтым пасце мы будзем разбірацца, як з нуля стварыць гульню .io. Для гэтага дастаткова будзе толькі веданне Javascript: вам трэба разумець такія рэчы, як сінтаксіс ES6, ключавое слова this и Абяцанні. Нават калі вы ведаеце Javascript не ў дасканаласці, то ўсё роўна зможаце разабрацца ў большай частцы посту.
Прыклад гульні.
Для дапамогі ў навучанні мы будзем спасылацца на прыклад гульні.. Паспрабуйце ў згуляць у яе!
Гульня даволі простая: вы кіруеце караблём на арэне, дзе ёсць іншыя гульцы. Ваш карабель аўтаматычна страляе снарадамі і вы спрабуеце патрапіць у іншых гульцоў, у той жа час пазбягаючы іх снарадаў.
Усё ў тэчцы public/ будзе статычна перадавацца серверам. У public/assets/ змяшчаюцца выкарыстоўваюцца нашым праектам выявы.
SRC/
Увесь зыходны код знаходзіцца ў тэчцы src/. Назвы client/ и server/ гавораць самі за сябе, а shared/ змяшчае файл канстант, які імпартуецца і кліентам, і серверам.
2. Зборкі/параметры праекта
Як сказана вышэй, для зборкі праекта мы выкарыстоўваем менеджэр модуляў. Вэб-пакет. Давайце зірнем на нашу канфігурацыю Webpack:
src/client/index.js – гэта ўваходны пункт кліента Javascript (JS). Webpack будзе пачынаць адсюль і будзе рэкурсіўна шукаць іншыя імпартаваныя файлы.
Выхадны JS нашай зборкі Webpack будзе размяшчацца ў каталогу dist/. Я буду называць гэты файл нашым пакетам JS.
мы выкарыстоўваем Гамарня, і ў прыватнасці канфігурацыю @babel/preset-env для транспіляцыі (transpiling) нашага кода JS для старых браўзэраў.
Мы выкарыстоўваем убудову для вымання ўсіх CSS, на якія спасылаюцца файлы JS, і для аб'яднання іх у адным месцы. Я буду называць яго нашым пакетам CSS.
Вы маглі заўважыць дзіўныя імёны файлаў пакетаў '[name].[contenthash].ext'. У іх змяшчаюцца падстаноўкі імёнаў файлаў Вэб-пакет: [name] будзе заменены на імя ўваходнай кропкі (у нашым выпадку гэта game), А [contenthash] будзе заменены на хэш змесціва файла. Мы робім гэта, каб аптымізаваць праект для хэшавання - можна загадаць браўзэрам бясконца кэшаваць нашы пакеты JS, таму што калі пакет змяняецца, тое змяняецца і яго імя файла (змяняецца contenthash). Гатовым вынікам будзе імя файла выгляду game.dbeee76e91a97d0c7207.js.
файл webpack.common.js - Гэта базавы файл канфігурацыі, які мы імпартуем у канфігурацыі распрацоўкі і гатовага праекта. Вось, напрыклад, канфігурацыя распрацоўкі:
Для эфектыўнасці мы выкарыстоўваем у працэсе распрацоўкі webpack.dev.js, і перамыкаецца на webpack.prod.js, каб аптымізаваць памеры пакетаў пры разгортванні ў прадакшэн.
Лакальная настройка
Рэкамендую ўсталёўваць праект на лакальнай машыне, каб вы маглі ісці за этапамі, пералічанымі ў гэтым пасце. Настройка простая: па-першае, у сістэме павінны быць устаноўлены вузел и NPM. Далей трэба выканаць
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
і вы гатовыя да працы! Для запуску сервера распрацоўкі дастаткова выканаць
$ npm run develop
і зайсці ў вэб-браўзэры на лакальны: 3000. Сервер распрацоўкі будзе аўтаматычна перазбіраць нанова пакеты JS і CSS падчас змены кода - проста абновіце старонку, каб убачыць усе змены!
3. Уваходныя кропкі кліента
Давайце прыступім да самога коду гульні. Для пачатку нам спатрэбіцца старонка index.html, пры наведванні сайта браўзэр будзе загружаць яе першай. Наша старонка будзе даволі простай:
index.html
An example .io game PLAY
Гэты прыклад кода злёгку спрошчаны для зразумеласці, тое ж самае я зраблю і з многімі іншымі прыкладамі посту. Поўны код заўсёды можна паглядзець на Github.
У нас ёсць:
Элемент HTML5 Canvas (<canvas>), які мы будзем выкарыстоўваць для рэндэрынгу гульні.
<link> для дадання нашага пакета CSS.
<script> для дадання нашага пакета Javascript.
Галоўнае меню з імем карыстальніка <input> і кнопкай «PLAY» (<button>).
Пасля загрузкі хатняй старонкі ў браўзэры пачне выконвацца Javascript-код, пачынальна з файла JS уваходнай кропкі: src/client/index.js.
Гэта можа здацца складаным, але насамрэч тут адбываецца не так шмат дзеянняў:
Імпарт некалькіх іншых JS-файлаў.
Імпарт CSS (каб Webpack ведаў, што трэба ўключыць іх у наш пакет CSS).
запуск connect() для ўстаноўкі злучэння з серверам і запуск downloadAssets() для спампоўкі малюнкаў, неабходных для рэндэрынгу гульні.
Пасля завяршэння этапа 3 адлюстроўваецца галоўнае меню (playMenu).
Настройка апрацоўшчыка націску кнопкі "PLAY". Пры націску кнопкі код ініцыялізуе гульню і паведамляе серверу, што мы гатовы гуляць.
Асноўнае "мяса" нашай кліент-сервернай логікі знаходзіцца ў тых файлах, якія былі імпартаваныя файлам index.js. Цяпер мы разгледзім іх усё па парадку.
4. Абмен дадзенымі кліента
У гэтай гульні для зносін з серверам мы выкарыстоўваем добра вядомую бібліятэку socket.io. У Socket.io ёсць убудаваная падтрымка WebSockets, якія добра падыходзяць для двухбаковай камунікацыі: мы можам адпраўляць паведамленні серверу и сервер можа адпраўляць паведамленні нам па тым жа злучэнні.
У нас будзе адзін файл src/client/networking.js, які зоймецца усімі камунікацыямі з серверам:
Гэты код для зразумеласці таксама злёгку скарочаны.
У гэтым файле адбываюцца тры асноўныя дзеянні:
Мы спрабуем падключыцца да сервера. connectedPromise дазваляецца толькі тады, калі мы ўстанавілі злучэнне.
Калі злучэнне паспяхова ўстаноўлена, мы рэгіструем callback-функцыі (processGameUpdate() и onGameOver()) для паведамленняў, якія мы можам атрымліваць ад сервера.
Экспартуем play() и updateDirection(), Каб іх маглі выкарыстоўваць іншыя файлы.
5. Рэндэрынг кліента
Надышоў час адлюстраваць на экране карцінку!
…але перш чым мы зможам гэта зрабіць, трэба спампаваць усе выявы (рэсурсы), якія для гэтага неабходныя. Давайце напішам менеджэр рэсурсаў:
Упраўленне рэсурсамі рэалізаваць не так складана! Асноўны сэнс заключаецца ў тым, каб захоўваць аб'ект assets, які будзе прывязваць ключ імя файла да значэння аб'екта Image. Калі рэсурс загрузіцца, мы захоўваем яго ў аб'ект assets для хуткага атрымання ў будучыні. Калі будзе дазволена спампоўка кожнага асобнага рэсурсу (гэта значыць будуць загружаны ўсё рэсурсы), мы дазваляем downloadPromise.
Запампаваўшы рэсурсы, можна прыступаць да рэндэрынгу. Як сказана раней, для малявання на вэб-старонцы мы выкарыстоўваем Палатно HTML5 (<canvas>). Наша гульня даволі простая, таму нам дастаткова адмалёўваць толькі наступнае:
фон
Карабель гульца
Іншых гульцоў, якія знаходзяцца ў гульні
Снарады
Вось важныя фрагменты src/client/render.js, якія адмалёўваюць менавіта пералічаныя вышэй чатыры пункты:
render.js
import { getAsset } from './assets';
import { getCurrentState } from './state';
const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;
// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Draw background
renderBackground(me.x, me.y);
// Draw all bullets
bullets.forEach(renderBullet.bind(null, me));
// Draw all players
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Helper functions here excluded
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}
Гэты код таксама скарочаны для зразумеласці.
render() - асноўная функцыя гэтага файла. startRendering() и stopRendering() кіруюць актывацыяй цыклам рэндэрынгу з частатой 60 FPS.
Канкрэтныя рэалізацыі асобных дапаможных функцый рэндэрынгу (напрыклад renderBullet()) не так важныя, але вось адзін просты прыклад:
render.js
function renderBullet(me, bullet) {
const { x, y } = bullet;
context.drawImage(
getAsset('bullet.svg'),
canvas.width / 2 + x - me.x - BULLET_RADIUS,
canvas.height / 2 + y - me.y - BULLET_RADIUS,
BULLET_RADIUS * 2,
BULLET_RADIUS * 2,
);
}
Заўважце, што мы выкарыстоўваем метад getAsset(), які раней бачылі ў asset.js!
Калі вам цікава вывучыць іншыя дапаможныя функцыі рэндэрынгу, то прачытайце астатнюю частку src/client/render.js.
6. Кліенцкі ўвод
Надышоў час зрабіць гульню іграбельнай! Схема кіравання будзе вельмі просты: для змены кірунку руху можна выкарыстоўваць мыш (на кампутары) ці дотык экрана (на мабільнай прыладзе). Каб рэалізаваць гэта, мы зарэгіструем Слухачы мерапрыемства для падзей Mouse і Touch.
Усім гэтым зоймецца src/client/input.js:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
}
onMouseInput() и onTouchInput() - Гэта Event Listeners, якія выклікаюць updateDirection() (з networking.js) пры здзяйсненні падзеі ўводу (напрыклад, пры перамяшчэнні мышы). updateDirection() займаецца абменам паведамленнямі з серверам, які апрацоўвае падзею ўводу і якая адпавядае выявай абнаўляе стан гульні.
7. Стан кліента
Гэты раздзел - самы складаны ў першай частцы посту. Не хвалюйцеся, калі не зразумееце яго з першага чытання! Можаце нават прапусціць яго і вярнуцца да яго пазней.
Апошні кавалак пазла, які патрэбен для завяршэння кліент-сервернага кода - гэта былі. Памятаеце фрагмент кода з раздзела «Рэндэрынг кліента»?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
}
getCurrentState() павінен мець магчымасць даць нам бягучы стан гульні ў кліенце у любы момант часу на падставе абнаўленняў, якія атрымліваюцца ад сервера. Вось прыклад абнаўлення гульні, якое можа адпраўляць сервер:
Кожнае абнаўленне гульні змяшчае пяць аднолькавых палёў:
t: пазнака часу сервера, якая абазначае момант стварэння гэтага абнаўлення.
me: інфармацыя аб гульцу, які атрымлівае гэтае абнаўленне.
іншыя: масіў інфармацыі аб іншых гульцах, якія ўдзельнічаюць у той жа гульні.
кулі: масіў інфармацыі аб снарадах у гульні.
лідэраў: бягучыя дадзеныя табліцы лідэраў. У гэтым пасце мы іх улічваць не будзем.
7.1 Наіўны стан кліента
Наіўная рэалізацыя getCurrentState() можа толькі непасрэдна вяртаць дадзеныя самага апошняга атрыманага абнаўлення гульні.
naive-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}
Прыгожа і зразумела! Але калі б усё было так проста. Адна з прычын, па якіх такая рэалізацыя праблематычная: яна абмяжоўвае частату кадраў рэндэрынгу частатой тактаў сервера.
Частата кадраў (Frame Rate): колькасць кадраў (г.зн. выклікаў render()) у секунду, або FPS. У гульнях звычайна імкнуцца дасягнуць не менш за 60 FPS.
Частата тактаў (Tick Rate): частата, з якой сервер адпраўляе абнаўленні гульні кліентам. Часта яна ніжэй, чым частата кадраў. У нашай гульні сервер працуе з частатой 30 тактаў у секунду.
Калі мы проста будзем рэндэрыць апошняе абнаўленне гульні, то FPS па сутнасці ніколі не зможа перавысіць 30, таму што мы ніколі не атрымліваем ад сервера больш за 30 абнаўленняў у секунду. Нават калі мы будзем выклікаць render() 60 раз у секунду, то палова гэтых выклікаў будзе проста перамалёўваць тое ж самае, па сутнасці не робячы нічога. Яшчэ адна праблема наіўнай рэалізацыі заключаецца ў тым, што яна схільная да затрымак. Пры ідэальнай хуткасці Інтэрнэту кліент будзе атрымліваць абнаўленне гульні роўна праз кожныя 33 мс (30 у секунду):
Нажаль, нішто не ідэальна. Больш рэалістычнай будзе такая карціна:
Наіўная рэалізацыя - гэта практычна найгоршы выпадак, калі справа даходзіць да затрымак. Калі абнаўленне гульні прымаецца з затрымкай 50 мс, то кліент затарможваецца на лішнія 50 мс, таму што ён па-ранейшаму рэндэрыт стан гульні з папярэдняга абнаўлення. Можаце ўявіць, наколькі гэта нязручна для гульца: з-за адвольных тармажэнняў гульня будзе здавацца дерганной і нестабільнай.
7.2 Палепшаны стан кліента
Мы ўнясём у наіўную рэалізацыю некаторыя паляпшэнні. Па-першае, мы выкарыстоўваем затрымку рэндэрынгу на 100 мс. Гэта азначае, што "бягучы" стан кліента заўсёды будзе адставаць ад стану гульні на серверы на 100 мс. Напрыклад, калі на серверы час роўна 150, то на кліенце будзе рэндэрыцца стан, у якім быў сервер падчас 50:
Гэта дае нам буфер у 100 мс, які дазваляе перажыць непрадказальны час атрымання абнаўленняў гульні:
Расплатай за гэта будзе сталая затрымка ўводу (input lag) на 100 мс. Гэта нязначная ахвяра за плаўны гульнявы працэс - большасць гульцоў (асабліва казуальных) нават не заўважыць гэтай затрымкі. Людзям значна прасцей прыстасавацца да сталай затрымкі ў 100 мс, чым гуляць з непрадказальнай затрымкай.
Мы можам выкарыстоўваць і іншую тэхніку пад назвай "прагназаванне на баку кліента", якая добра спраўляецца са зніжэннем успрыманых затрымак, але ў гэтым пасце яна разглядацца не будзе.
Яшчэ адно паляпшэнне, якое мы выкарыстоўваем - гэта лінейная інтэрпаляцыя. З-за затрымкі рэндэрынгу мы звычайна як мінімум на адно абнаўленне абганяем бягучы час у кліенце. Калі выклікаецца getCurrentState(), мы можам выканаць лінейную інтэрпаляцыю паміж абнаўленнямі гульні непасрэдна перад і пасля бягучым часам у кліенце:
Гэта вырашае праблему з частатой кадраў: зараз мы можам рэндэрыць унікальныя кадры з любой патрэбнай нам частатой!
7.3 Рэалізацыя палепшанага стану кліента
Прыклад рэалізацыі ў src/client/state.js выкарыстоўвае і затрымку рэндэрынгу, і лінейную інтэрпаляцыю, але гэта ненадоўга. Давайце разаб'ём код на дзве часткі. Вось першая:
state.js, частка 1
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Keep only one game update before the current server time
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
}
Перш за ўсё трэба разабрацца з тым, што робіць currentServerTime(). Як мы бачылі раней, у кожнае абнаўленне гульні ўключаецца серверная пазнака часу. Мы хочам выкарыстоўваць затрымку рэндэрынгу, каб рэндэрыць карцінку, адстаючы ад сервера на 100 мс, але мы ніколі не даведаемся, бягучы час на серверы, таму што не можам ведаць, як доўга дабіралася да нас любое з абнаўленняў. Інтэрнэт непрадказальны і яго хуткасць можа вельмі моцна вар'іравацца!
Каб абысці гэтую праблему, можна выкарыстоўваць разумную апраксімацыю: мы прыкінемся, што першае абнаўленне прыбыло імгненна. Калі б гэта было дакладна, то мы ведалі б час сервера ў гэты канкрэтны момант! Мы захоўваем пазнаку часу сервера ў firstServerTimestamp і захоўваем нашу лакальную (кліенцкую) пазнаку часу ў той жа момант у gameStart.
Ой, пачакайце. Няўжо не павінна быць час на серверы = чакай у кліенце? Чаму мы адрозніваем "пазнаку часу сервера" і "пазнаку часу кліента"? Гэта выдатнае пытанне! Аказваецца, гэта не адно і тое ж. Date.now() будзе вяртаць розныя пазнакі часу ў кліенце і сервера і гэта залежыць ад лакальных для гэтых машын фактараў. Ніколі не дапушчайце, што пазнакі часу будуць аднолькавымі на ўсіх машынах.
Цяпер нам зразумела, што робіць currentServerTime(): ён вяртае пазнаку часу сервера бягучага часу рэндэрынгу. Іншымі словамі, гэта цяперашні час сервера (firstServerTimestamp <+ (Date.now() - gameStart)) мінус затрымка рэндэрынгу (RENDER_DELAY).
Цяпер давайце разбяромся, як мы апрацоўваем абнаўлення гульні. Пры атрыманні з сервера абнаўлення выклікаецца processGameUpdate(), і мы захоўваем новае абнаўленне ў масіў gameUpdates. Затым, каб правяраць выкарыстанне памяці мы выдаляем усе старыя абнаўлення да базавага абнаўлення, таму што яны нам больш не патрэбныя.
Што ж такое "базавае абнаўленне"? Гэта першае абнаўленне, якое мы знаходзім, рухаючыся назад ад бягучага часу сервера. Памятаеце гэтую схему?
Абнаўленне гульні непасрэдна злева ад "Client Render Time" і з'яўляецца базавым абнаўленнем.
Для чаго выкарыстоўваецца базавае абнаўленне? Чаму мы можам адкідаць абнаўленні да базавага? Каб разабрацца ў гэтым, давайце нарэшце-то разгледзім рэалізацыю getCurrentState():
state.js, частка 2
export function getCurrentState() {
if (!firstServerTimestamp) {
return {};
}
const base = getBaseUpdate();
const serverTime = currentServerTime();
// If base is the most recent update we have, use its state.
// Else, interpolate between its state and the state of (base + 1).
if (base < 0) {
return gameUpdates[gameUpdates.length - 1];
} else if (base === gameUpdates.length - 1) {
return gameUpdates[base];
} else {
const baseUpdate = gameUpdates[base];
const next = gameUpdates[base + 1];
const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
return {
me: interpolateObject(baseUpdate.me, next.me, r),
others: interpolateObjectArray(baseUpdate.others, next.others, r),
bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
};
}
}
Мы апрацоўваем тры выпадкі:
base < 0 азначае, што да гэтага часу рэндэрынгу абнаўленняў няма (гл. вышэй рэалізацыю getBaseUpdate()). Гэта можа здарыцца адразу ў пачатку гульні з-за затрымкі рэндэрынгу. У такім выпадку мы выкарыстоўваем самае апошняе атрыманае абнаўленне.
base - Гэта самае апошняе абнаўленне, якое ў нас ёсць. Гэта можа адбыцца з-за сеткавай затрымкі ці дрэннай сувязі з Інтэрнэтам. У гэтым выпадку мы таксама выкарыстоўваем самае апошняе абнаўленне, якое ў нас ёсць.
У нас ёсць абнаўленне і да, і пасля бягучага часу рэндэрынгу, таму можна інтэрпаліраваць!
Усё, што засталося ў state.js - Гэта рэалізацыя лінейнай інтэрпаляцыі, якая ўяўляе сабой простую (але сумную) матэматыку. Калі вы хочаце вывучыць яе самастойна, то адкрыйце state.js на Github.
Частка 2. Бэкенд-сервер
У гэтай частцы мы разгледзім бэкэнд Node.js, які кіруе нашым прыкладам гульні..
1. Уваходная кропка сервера
Для кіравання вэб-серверам мы будзем выкарыстоўваць папулярны вэб-фрэймворк для Node.js пад назвай Экспрэс. Яго наладай зоймецца наш файл уваходнай кропкі сервера src/server/server.js:
server.js, частка 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Setup an Express server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Setup Webpack for development
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Static serve the dist/ folder in production
app.use(express.static('dist'));
}
// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
Памятайце, што ў першай частцы мы абмяркоўвалі Webpack? Менавіта тут мы будзем выкарыстоўваць нашы канфігурацыі Webpack. Мы будзем прымяняць іх двума спосабамі:
выкарыстоўваць webpack-dev-middleware для аўтаматычнай перазборкі нашых пакетаў распрацоўкі, або
Статычна перадаваць тэчку dist/, У якую Webpack будзе запісваць нашы файлы пасля зборкі прадакшэна.
Яшчэ адна важная задача server.js заключаецца ў наладзе сервера socket.io, які проста падключаецца да сервера Express:
Пасля паспяховай усталёўкі злучэння socket.io з серверам мы наладжваем апрацоўшчыкі падзей для новага сокета. Апрацоўшчыкі падзей апрацоўваюць атрыманыя ад кліентаў паведамленні дэлегаваннем аб'екту-сінглтану game:
server.js, частка 3
const Game = require('./game');
// ...
// Setup the Game
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
}
Мы ствараем гульню жанру .io, таму нам спатрэбіцца толькі адзін экзэмпляр. Game («Game») - усе гульцы гуляюць на адной арэне! У наступным раздзеле мы паглядзім, як працуе гэты клас Game.
2. Game сервера
Клас Game змяшчае самую важную логіку на баку сервера. Ён мае дзве асноўныя задачы: кіраванне гульцамі и сімуляцыя гульні.
Давайце пачнем з першай задачы - з кіравання гульцамі.
game.js, частка 1
const Constants = require('../shared/constants');
const Player = require('./player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 / 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
// Generate a position to start this player at.
const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
this.players[socket.id] = new Player(socket.id, username, x, y);
}
removePlayer(socket) {
delete this.sockets[socket.id];
delete this.players[socket.id];
}
handleInput(socket, dir) {
if (this.players[socket.id]) {
this.players[socket.id].setDirection(dir);
}
}
// ...
}
У гэтай гульні мы будзем ідэнтыфікаваць гульцоў па полі id іх сокета socket.io (калі вы заблыталіся, то зноў вярніцеся да server.js). Socket.io сам прызначае кожнаму сокету ўнікальны id, таму нам пра гэта турбавацца не трэба. Я буду называць яго ID гульца.
Запомніўшы гэта, давайце вывучым зменныя асобніка ў класе. Game:
sockets - Гэта аб'ект, які прывязвае ID гульца да сокету, які звязаны з гульцом. Ён дазваляе нам за сталы час атрымліваць доступ да сокетаў па іх ID гульцоў.
players - гэта аб'ект, які прывязвае ID гульца да аб'екта code>Player
bullets - Гэта масіў аб'ектаў Bullet, які не мае пэўнага парадку. lastUpdateTime - Гэта пазнака часу моманту апошняга абнаўлення гульні. Неўзабаве мы ўбачым, як яна выкарыстоўваецца. shouldSendUpdate - Гэта дапаможная пераменная. Яе выкарыстанне мы таксама ўбачым неўзабаве.
метады addPlayer(), removePlayer() и handleInput() тлумачыць не трэба, яны выкарыстоўваюцца ў server.js. Калі вам трэба асвяжыць памяць, вярніцеся крыху вышэй.
Апошні радок constructor() запускае цыкл абнаўлення гульні (з частатой 60 абнаўленняў/з):
game.js, частка 2
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Calculate time elapsed
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Update each bullet
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// Destroy this bullet
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Update each player
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// Check if any players are dead
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp <= 0) {
socket.emit(Constants.MSG_TYPES.GAME_OVER);
this.removePlayer(socket);
}
});
// Send a game update to each player every other time
if (this.shouldSendUpdate) {
const leaderboard = this.getLeaderboard();
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
// ...
}
метад update() утрымоўвае, мусіць, найважную частку логікі на боку сервера. Па парадку пералічым усё, што ён робіць:
Вылічае, колькі часу dt прайшло з апошняга update().
Абнаўляе кожны снарад і пры неабходнасці знішчае іх. Рэалізацыю гэтага функцыяналу мы ўбачым пазней. Пакуль нам дастаткова ведаць, што bullet.update()вяртае true, калі снарад павінен быць знішчаны (ён выйшаў за межы арэны).
Абнаўляе кожнага гульца і пры неабходнасці ствараем снарад. Гэтую рэалізацыю мы таксама ўбачым пазней. player.update()можа вярнуць аб'ект Bullet.
Правярае калізіі паміж снарадамі і гульцамі з дапамогай applyCollisions(), Які вяртае масіў снарадаў, якія патрапілі ў гульцоў. Для кожнага вернутага снарада мы павялічваем ачкі гульца, які яго выпусціў (з дапамогай player.onDealtDamage()), а затым выдаляем снарад з масіва bullets.
Апавяшчае і знішчае ўсіх забітых гульцоў.
Адпраўляе ўсім гульцам абнаўленне гульні кожны другі раз пры выкліку update(). Гэта нам дапамагае адсочваць згаданая вышэй дапаможная зменная shouldSendUpdate. так як update() выклікаецца 60 разоў/с, мы адпраўляем абнаўленні гульні 30 разоў/с. Такім чынам, частата тактаў сервера роўная 30 тактам/з (мы казалі аб частаце тактаў у першай частцы).
Навошта адпраўляць абнаўленні гульні толькі праз раз ? Для эканоміі канала. 30 абнаўленняў гульні ў секунду - гэта вельмі шмат!
Чаму б тады проста не выклікаць update() 30 разоў у секунду? Для паляпшэння сімуляцыі гульні. Чым часцей выклікаецца update(), Тым дакладней будзе сімуляцыя гульні. Але не варта занадта захапляцца колькасцю выклікаў update(), таму што гэта вылічальна затратная задача - 60 у секунду цалкам дастаткова.
Пакінутая частка класа Game складаецца з дапаможных метадаў, якія выкарыстоўваюцца ў update():
getLeaderboard() даволі просты - ён сартуе гульцоў па колькасці ачкоў, бярэ пяць лепшых і вяртае для кожнага імя карыстальніка і рахунак.
createUpdate() выкарыстоўваецца ў update() для стварэння абнаўленняў гульні, якія перадаюцца гульцам. Яго асноўная задача заключаецца ў выкліку метадаў serializeForUpdate(), рэалізаваных для класаў Player и Bullet. Заўважце, што ён перадае кожнаму гульцу дадзеныя толькі аб бліжэйшых гульцах і снарадах - няма неабходнасці перадаваць інфармацыю аб гульнявых аб'ектах, якія знаходзяцца далёка ад гульца!
3. Гульнявыя аб'екты на серверы
У нашай гульні снарады і гульцы насамрэч вельмі падобныя: гэта абстрактныя круглыя рухомыя гульнявыя аб'екты. Каб скарыстацца гэтым падабенствам гульцоў і снарадаў, давайце пачнем з рэалізацыі базавага класа Object:
Тут не адбываецца нічога складанага. Гэты клас стане добрай апорнай кропкай для пашырэння. Давайце паглядзім, як клас Bullet выкарыстоўвае Object:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Returns true if the bullet should be destroyed
update(dt) {
super.update(dt);
return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
}
}
Рэалізацыя Bullet вельмі кароткая! Мы дадалі да Object толькі наступныя пашырэнні:
Выкарыстанне пакета shortid для выпадковай генерацыі id снарада.
Даданне поля parentID, каб можна было адсочваць гульца, які стварыў гэты снарад.
Даданне якое вяртаецца значэння ў update(), якое роўна true, калі снарад знаходзіцца за межамі арэны (памятаеце, мы казалі пра гэта ў мінулым раздзеле?).
Пяройдзем да Player:
player.js
const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');
class Player extends ObjectClass {
constructor(id, username, x, y) {
super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
this.username = username;
this.hp = Constants.PLAYER_MAX_HP;
this.fireCooldown = 0;
this.score = 0;
}
// Returns a newly created bullet, or null.
update(dt) {
super.update(dt);
// Update score
this.score += dt * Constants.SCORE_PER_SECOND;
// Make sure the player stays in bounds
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Fire a bullet, if needed
this.fireCooldown -= dt;
if (this.fireCooldown <= 0) {
this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
return new Bullet(this.id, this.x, this.y, this.direction);
}
return null;
}
takeBulletDamage() {
this.hp -= Constants.BULLET_DAMAGE;
}
onDealtDamage() {
this.score += Constants.SCORE_BULLET_HIT;
}
serializeForUpdate() {
return {
...(super.serializeForUpdate()),
direction: this.direction,
hp: this.hp,
};
}
}
Гульцы складаней, чым снарады, таму ў гэтым класе павінна захоўвацца яшчэ некалькі палёў. Яго метад update() выконвае вялікую працу, у прыватнасці, вяртае толькі што створаны снарад, калі не засталося fireCooldown (падушыце, мы казалі аб гэтым у папярэдняй частцы?). Таксама ён пашырае метад serializeForUpdate(), таму што нам трэба ўключыць у абнаўленне гульні дадатковыя палі для гульца.
Наяўнасць базавага класа Object - важны крок, які дазваляе пазбегнуць паўтаранасці кода. Напрыклад, без класа Object кожны гульнявы аб'ект павінен мець аднолькавую рэалізацыю distanceTo(), і сінхранізацыя капіпасты ўсіх гэтых рэалізацыі ў некалькіх файлах была б кашмарам. Гэта становіцца асабліва важна для буйных праектаў, калі колькасць пашыраюць Object класаў расце.
4. Распазнаванне калізій
Адзінае, што нам засталося - распазнаваць, калі снарады трапляюць у гульцоў! Успомніце гэты фрагмент кода з метаду update() у класе Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// ...
}
}
Нам трэба рэалізаваць метад applyCollisions(), Які вяртае ўсе снарады, якія трапілі ў гульцоў. На шчасце, гэта не так цяжка зрабіць, таму што
Усе якія сутыкаюцца аб'екты з'яўляюцца кругамі, а гэта найпростая для рэалізацыі распазнання калізій постаць.
У нас ужо ёсць метад distanceTo(), які мы ў папярэднім раздзеле рэалізавалі ў класе Object.
Вось як выглядае наша рэалізацыя распазнання калізій:
collisions.js
const Constants = require('../shared/constants');
// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
const destroyedBullets = [];
for (let i = 0; i < bullets.length; i++) {
// Look for a player (who didn't create the bullet) to collide each bullet with.
// As soon as we find one, break out of the loop to prevent double counting a bullet.
for (let j = 0; j < players.length; j++) {
const bullet = bullets[i];
const player = players[j];
if (
bullet.parentID !== player.id &&
player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
) {
destroyedBullets.push(bullet);
player.takeBulletDamage();
break;
}
}
}
return destroyedBullets;
}
Гэтае простае распазнанне калізій заснавана на тым факце, што два кругі сутыкаюцца, калі адлегласць паміж іх цэнтрамі меншая за суму іх радыусаў.. Вось выпадак, калі адлегласць паміж цэнтрамі двух кругоў сапраўды роўна суме іх радыусаў:
Тут трэба ўважліва паставіцца яшчэ да пары аспектаў:
Снарад не павінен пападаць у які стварыў яго гульца. Гэтага можна дасягнуць, параўноўваючы bullet.parentID с player.id.
Снарад павінен пападаць толькі адзін раз у лімітавым выпадку адначасовага сутыкнення з некалькімі гульцамі. Гэтую задачу мы вырашым з дапамогай аператара break: як толькі знойдзены гулец, які сутыкнуўся са снарадам, мы спыняем пошук і пераходзім да наступнага снарада.
Канец
Вось і ўсё! Мы разгледзелі ўсё, што неабходна ведаць для стварэння вэб-гульні жанру. Што далей? Збярыце ўласную гульню .io!
Увесь код прыкладу мае адчыненыя зыходнікі і выкладзены на Github.