Креирање веб игре за више играча у .ио жанру

Креирање веб игре за више играча у .ио жанру
Објављено 2015 Агар.ио постао родоначелник новог жанра гамес.иокојој је од тада порасла популарност. Лично сам искусио пораст популарности .ио игара: у последње три године јесам креирао и продао две игре у овом жанру..

У случају да никада раније нисте чули за ове игре, ово су бесплатне веб игре за више играча које је лако играти (није потребан налог). Обично се суочавају са много противничких играча у истој арени. Друге познате .ио игре: Слитхер.ио и Диеп.ио.

У овом посту ћемо истражити како креирајте .ио игру од нуле. За ово ће бити довољно само познавање Јавасцрипт-а: потребно је да разумете ствари попут синтаксе ЕСКСНУМКС, кључна реч this и Обећања. Чак и ако ваше познавање Јавасцрипт-а није савршено, и даље можете разумети већину поста.

Пример игре .ио

За помоћ у учењу ћемо се позвати на Пример игре .ио. Покушајте да га играте!

Креирање веб игре за више играча у .ио жанру
Игра је прилично једноставна: ви контролишете брод у арени са другим играчима. Ваш брод аутоматски испаљује пројектиле и ви покушавате да погодите друге играче избегавајући њихове пројектиле.

1. Кратак преглед / структура пројекта

Препоручити преузми изворни код пример игре тако да можете да ме пратите.

Пример користи следеће:

  • Експресни је најпопуларнији Ноде.јс веб оквир који управља веб сервером игре.
  • утичница.ио - вебсоцкет библиотека за размену података између претраживача и сервера.
  • Вебпацк - менаџер модула. Можете прочитати зашто користити Вебпацк. овде.

Ево како изгледа структура директоријума пројекта:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

јавно/

Све је у фасцикли public/ ће сервер бити статички послат. ИН public/assets/ садржи слике које користи наш пројекат.

срц /

Сав изворни код је у фасцикли src/... Имена client/ и server/ говоре за себе и shared/ садржи датотеку константи коју увозе и клијент и сервер.

2. Поставке склопова/пројекта

Као што је горе поменуто, користимо менаџер модула за изградњу пројекта. Вебпацк. Хајде да погледамо нашу конфигурацију веб пакета:

вебпацк.цоммон.јс:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

Најважније линије овде су:

  • src/client/index.js је улазна тачка Јавасцрипт (ЈС) клијента. Вебпацк ће почети одавде и рекурзивно ће тражити друге увезене датотеке.
  • Излазни ЈС нашег Вебпацк буилд-а ће се налазити у директоријуму dist/. Назваћу овај фајл наш ЈС пакет.
  • Користимо Бабел, а посебно конфигурацију @бабел/пресет-енв за транспилирање нашег ЈС кода за старије прегледаче.
  • Користимо додатак за издвајање свих ЦСС-а на које упућују ЈС датотеке и комбиновање на једном месту. зваћу га нашим цсс пакет.

Можда сте приметили чудна имена датотека пакета '[name].[contenthash].ext'. Они садрже замене имена датотеке Вебпацк: [name] биће замењено именом улазне тачке (у нашем случају, ово game), и [contenthash] ће бити замењен хешом садржаја датотеке. Ово радимо да оптимизовати пројекат за хеширање - можемо рећи претраживачима да кеширају наше ЈС пакете на неодређено време јер ако се пакет промени, мења се и његово име датотеке (Промене contenthash). Коначни резултат ће бити име датотеке приказа game.dbeee76e91a97d0c7207.js.

фајл webpack.common.js је основна конфигурациона датотека коју увозимо у конфигурације развоја и готових пројеката. Ево примера развојне конфигурације:

вебпацк.дев.јс

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

За ефикасност користимо у процесу развоја webpack.dev.js, и прелази на webpack.prod.jsза оптимизацију величине пакета приликом постављања у производњу.

Локално подешавање

Препоручујем да инсталирате пројекат на локалну машину како бисте могли да пратите кораке наведене у овом посту. Подешавање је једноставно: прво, систем мора да има Чвор и НПМ. Следеће што треба да урадите

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

и спремни сте за полазак! Да бисте покренули развојни сервер, само покрените

$ npm run develop

и идите на свој веб претраживач лоцалхост: КСНУМКС. Развојни сервер ће аутоматски поново изградити ЈС и ЦСС пакете како се код мења - само освежите страницу да бисте видели све промене!

3. Улазне тачке за клијенте

Хајде да се спустимо на сам код игре. Прво нам треба страница index.html, када посетите сајт, претраживач ће га прво учитати. Наша страница ће бити прилично једноставна:

индек.хтмл

Пример .ио игре  ИГРА

Овај пример кода је мало поједностављен ради јасноће, а ја ћу учинити исто са многим другим примерима у посту. Увек можете погледати цео код на Гитхуб.

Имамо:

  • ХТМЛ5 елемент платна (<canvas>), који ћемо користити за приказивање игре.
  • <link> да додате наш ЦСС пакет.
  • <script> да додате наш Јавасцрипт пакет.
  • Главни мени са корисничким именом <input> и дугме ПЛАИ (<button>).

Када се почетна страница учита, претраживач ће почети да извршава Јавасцрипт код, почевши од ЈС датотеке улазне тачке: src/client/index.js.

индек.јс

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Ово можда звучи компликовано, али се овде не дешава много:

  1. Увоз неколико других ЈС датотека.
  2. ЦСС увоз (тако да Вебпацк зна да их укључи у наш ЦСС пакет).
  3. Покретање connect() да успостави везу са сервером и покрене downloadAssets() да преузмете слике потребне за приказивање игре.
  4. Након завршетка фазе 3 је приказан главни мени (playMenu).
  5. Подешавање руковаоца за притискање дугмета "ПЛАИ". Када се притисне дугме, код иницијализује игру и говори серверу да смо спремни за игру.

Главно „месо“ наше клијент-сервер логике је у оним датотекама које је датотека увезла index.js. Сада ћемо их све погледати по реду.

4. Размена података о клијентима

У овој игри користимо добро познату библиотеку за комуникацију са сервером утичница.ио. Соцкет.ио има изворну подршку ВебСоцкетс, који су веома погодни за двосмерну комуникацију: можемо да шаљемо поруке серверу и сервер може да нам шаље поруке преко исте везе.

Имаћемо један фајл src/client/networking.jsко ће се старати сви комуникација са сервером:

нетворкинг.јс

import io from 'socket.io-client';
import { processGameUpdate } from './state';

const Constants = require('../shared/constants');

const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register callbacks
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

Овај код је такође мало скраћен ради јасноће.

Постоје три главне радње у овој датотеци:

  • Покушавамо да се повежемо са сервером. connectedPromise дозвољено само када успоставимо везу.
  • Ако је веза успешна, региструјемо функције повратног позива (processGameUpdate() и onGameOver()) за поруке које можемо да примамо са сервера.
  • Извозимо play() и updateDirection()тако да други фајлови могу да их користе.

5. Рендеринг клијента

Време је да се слика прикаже на екрану!

…али пре него што то урадимо, морамо да преузмемо све слике (ресурсе) који су потребни за ово. Хајде да напишемо менаџера ресурса:

средства.јс

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

Управљање ресурсима није тако тешко имплементирати! Главна идеја је складиштење предмета assets, који ће повезати кључ имена датотеке за вредност објекта Image. Када се ресурс учита, чувамо га у објекту assets за брзи приступ у будућности. Када ће сваком појединачном ресурсу бити дозвољено преузимање (тј. све ресурса), дозвољавамо downloadPromise.

Након преузимања ресурса, можете почети са рендеровањем. Као што је раније речено, за цртање на веб страници користимо ХТМЛ5 Цанвас (<canvas>). Наша игра је прилично једноставна, тако да треба само да нацртамо следеће:

  1. Позадина
  2. Плаиер схип
  3. Остали играчи у игри
  4. шкољке

Ево важних исечака src/client/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 ФПС.

Конкретне имплементације појединачних помоћних функција за рендеровање (нпр. renderBullet()) нису толико важни, али ево једног једноставног примера:

рендер.јс

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!

Ако сте заинтересовани да сазнате више о другим помоћницима за рендеровање, прочитајте остало. срц/цлиент/рендер.јс.

6. Унос клијента

Време је да направимо игру плаиабле! Контролна шема ће бити врло једноставна: да бисте променили правац кретања, можете користити миш (на рачунару) или додирнути екран (на мобилном уређају). Да бисмо ово спровели, регистроваћемо се Слушаоци догађаја за догађаје миша и додира.
Побринут ће се за све ово src/client/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() су слушаоци догађаја који позивају updateDirection() (од networking.js) када се догоди улазни догађај (на пример, када се миш помери). updateDirection() управља размјеном порука са сервером, који управља улазним догађајем и у складу с тим ажурира стање игре.

7. Држава клијента

Овај део је најтежи у првом делу поста. Немојте се обесхрабрити ако га не разумете први пут када га прочитате! Можете га чак и прескочити и вратити му се касније.

Последњи део слагалице потребан за довршавање клијент-сервер кода је били су. Сећате се исечка кода из одељка Рендеринг клијента?

рендер.јс

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() требало би да нам може дати тренутно стање игре у клијенту у свако доба на основу ажурирања примљених са сервера. Ево примера ажурирања игре које сервер може да пошаље:

{
  "t": 1555960373725,
  "me": {
    "x": 2213.8050880413657,
    "y": 1469.370893425012,
    "direction": 1.3082443894581433,
    "id": "AhzgAtklgo2FJvwWAADO",
    "hp": 100
  },
  "others": [],
  "bullets": [
    {
      "id": "RUJfJ8Y18n",
      "x": 2354.029197099604,
      "y": 1431.6848318262666
    },
    {
      "id": "ctg5rht5s",
      "x": 2260.546457727445,
      "y": 1456.8088728920968
    }
  ],
  "leaderboard": [
    {
      "username": "Player",
      "score": 3
    }
  ]
}

Свако ажурирање игре садржи пет идентичних поља:

  • t: Временска ознака сервера која показује када је ово ажурирање креирано.
  • me: Информације о играчу који прима ово ажурирање.
  • други: Низ информација о другим играчима који учествују у истој игри.
  • меци: низ информација о пројектилима у игри.
  • леадербоард: Тренутни подаци на табели. У овом посту их нећемо разматрати.

7.1 Наивно стање клијента

Наивна имплементација getCurrentState() може директно да врати податке из најновијег ажурирања игре.

наиве-стате.јс

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Лепо и јасно! Али само да је тако једноставно. Један од разлога зашто је ова имплементација проблематична: ограничава брзину приказа кадрова на брзину сервера.

Број слика: број оквира (тј. позива render()) у секунди, или ФПС. Игре обично теже да постигну најмање 60 ФПС.

Стопа тикета: Учесталост којом сервер клијентима шаље ажурирања игара. Често је нижа од брзине кадрова. У нашој игри, сервер ради на фреквенцији од 30 циклуса у секунди.

Ако само рендерујемо најновије ажурирање игре, онда ФПС у суштини никада неће прећи 30, јер никада не добијамо више од 30 ажурирања у секунди са сервера. Чак и ако позовемо render() 60 пута у секунди, тада ће половина ових позива једноставно прецртати исту ствар, у суштини не радећи ништа. Још један проблем са наивном имплементацијом је тај што подложно кашњењу. Са идеалном брзином Интернета, клијент ће добијати ажурирање игре тачно сваких 33 мс (30 у секунди):

Креирање веб игре за више играча у .ио жанру
Нажалост, ништа није савршено. Реалнија слика би била:
Креирање веб игре за више играча у .ио жанру
Наивна имплементација је практично најгори случај када је у питању кашњење. Ако се ажурирање игре прими са закашњењем од 50 мс, онда тезге за клијенте додатних 50 мс јер још увек приказује стање игре из претходног ажурирања. Можете да замислите колико је ово непријатно за играча: произвољно кочење ће учинити да игра изгледа трзаво и нестабилно.

7.2 Побољшано стање клијента

Направићемо нека побољшања у наивној имплементацији. Прво, користимо одлагање рендеровања за 100 мс. То значи да ће „тренутно“ стање клијента увек бити 100 мс иза стања игре на серверу. На пример, ако је време сервера 150, тада ће клијент приказати стање у којем је сервер био у то време 50:

Креирање веб игре за више играча у .ио жанру
Ово нам даје бафер од 100 мс да преживимо непредвидива времена ажурирања игре:

Креирање веб игре за више играча у .ио жанру
Исплата за ово ће бити трајна улазно кашњење за 100 мс. Ово је мала жртва за глатку игру - већина играча (посебно обичних играча) неће ни приметити ово кашњење. Људима је много лакше да се прилагоде константној латенцији од 100 мс него да се играју са непредвидивим кашњењем.

Можемо користити другу технику тзв предвиђање на страни клијента, који добро ради на смањењу уоченог кашњења, али неће бити разматран у овом посту.

Још једно побољшање које користимо је линеарна интерполација. Због кашњења у рендеровању, обично имамо најмање једно ажурирање испред тренутног времена у клијенту. Када се зове getCurrentState(), можемо извршити линеарна интерполација између ажурирања игре непосредно пре и после тренутног времена у клијенту:

Креирање веб игре за више играча у .ио жанру
Ово решава проблем брзине кадрова: сада можемо да прикажемо јединствене кадрове при било којој брзини кадрова коју желимо!

7.3 Имплементација побољшаног стања клијента

Пример имплементације у src/client/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. Затим, да бисмо проверили коришћење меморије, уклањамо све старе исправке за ажурирање базејер нам они више нису потребни.

Шта је „основно ажурирање“? Ово прво ажурирање које пронађемо померањем уназад од тренутног времена сервера. Сећате се овог дијаграма?

Креирање веб игре за више играча у .ио жанру
Ажурирање игре директно лево од „Времена рендеровања клијента“ је основно ажурирање.

За шта се користи основно ажурирање? Зашто можемо да избацимо ажурирања у базу? Да бисмо ово разумели, хајде да коначно размотрити имплементацију getCurrentState():

стате.јс део 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),
    };
  }
}

Обрађујемо три случаја:

  1. base < 0 значи да нема ажурирања до тренутног времена рендеровања (погледајте горњу имплементацију getBaseUpdate()). Ово се може десити одмах на почетку игре због кашњења у рендеровању. У овом случају користимо најновију примљену исправку.
  2. base је најновије ажурирање које имамо. Ово може бити због кашњења мреже или лоше интернет везе. У овом случају користимо и најновије ажурирање које имамо.
  3. Имамо ажурирање и пре и после тренутног времена рендеровања, тако да можемо интерполирати!

Све што је остало унутра state.js је имплементација линеарне интерполације која је једноставна (али досадна) математика. Ако желите сами да га истражите, онда отворите state.js на Гитхуб.

Део 2. Бацкенд сервер

У овом делу ћемо погледати позадину Ноде.јс која контролише наш пример .ио игре.

1. Улазна тачка сервера

За управљање веб сервером користићемо популарни веб оквир за Ноде.јс под називом Експресни. Биће конфигурисан нашом датотеком улазне тачке сервера src/server/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}`);

Сећате се да смо у првом делу разговарали о Вебпацк-у? Овде ћемо користити наше Вебпацк конфигурације. Користићемо их на два начина:

  • Употреба вебпацк-дев-миддлеваре да аутоматски поново направимо наше развојне пакете, или
  • фасциклу за статички пренос dist/, у који ће Вебпацк уписати наше датотеке након израде продукције.

Још један важан задатак server.js је постављање сервера утичница.иокоји се само повезује са Екпресс сервером:

сервер.јс, део 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Након успешног успостављања соцкет.ио везе са сервером, конфигуришемо руковаоце догађајима за нову утичницу. Руковаоци догађаја обрађују поруке примљене од клијената делегирањем на синглетон објекат game:

сервер.јс, део 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);
}

Правимо .ио игру, тако да нам је потребна само једна копија Game ("Игра") - сви играчи играју у истој арени! У следећем одељку ћемо видети како ова класа функционише. Game.

2. Сервери за игре

Класа Game садржи најважнију логику на страни сервера. Има два главна задатка: управљање играчима и симулација игре.

Почнимо са првим задатком, управљањем играчима.

гаме.јс, 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 њихов соцкет.ио соцкет (ако се збуните, вратите се на server.js). Сам Соцкет.ио сваком сокету додељује јединствен idтако да не треба да бринемо о томе. Зваћу га ИД играча.

Имајући то на уму, хајде да истражимо променљиве инстанце у класи Game:

  • sockets је објекат који везује ИД играча за утичницу која је повезана са плејером. Омогућава нам да приступимо утичницама преко њихових ИД-ова играча у сталном времену.
  • players је објекат који везује ИД играча за код>Плаиер објекат

bullets је низ објеката Bullet, који нема одређени редослед.
lastUpdateTime - Ово је временска ознака последњег ажурирања игре. Ускоро ћемо видети како ће се користити.
shouldSendUpdate је помоћна варијабла. Ускоро ћемо видети и његову употребу.
Методе addPlayer(), removePlayer() и handleInput() нема потребе објашњавати, они се користе у server.js. Ако вам треба освежење, вратите се мало више.

Последњи ред constructor() покреће се циклус ажурирања игре (са фреквенцијом од 60 ажурирања / с):

гаме.јс, 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() садржи можда најважнији део логике на страни сервера. Ево шта ради, редом:

  1. Израчунава колико дуго dt прошло од последњег update().
  2. Освежава сваки пројектил и уништава их ако је потребно. Видећемо имплементацију ове функционалности касније. За сада нам је довољно да то знамо bullet.update() враћа trueако би пројектил требало да буде уништен (изашао је из арене).
  3. Ажурира сваког играча и покреће пројектил ако је потребно. Видећемо и ову имплементацију касније - player.update() може вратити објекат Bullet.
  4. Проверава сударе између пројектила и играча са applyCollisions(), који враћа низ пројектила који погађају играче. За сваки враћени пројектил повећавамо поене играча који га је испалио (користећи player.onDealtDamage()), а затим уклоните пројектил из низа bullets.
  5. Обавештава и уништава све убијене играче.
  6. Шаље ажурирање игре свим играчима сваки други пута када је позван update(). Ово нам помаже да пратимо горе поменуту помоћну променљиву. shouldSendUpdate. Јер update() зовемо 60 пута/с, шаљемо ажурирања игре 30 пута/с. Тако, тактна фреквенција сервер је 30 тактова/с (о фреквенцији такта смо говорили у првом делу).

Зашто слати само ажурирања игара кроз време ? Да сачувате канал. 30 ажурирања игре у секунди је много!

Зашто онда једноставно не позовеш? update() 30 пута у секунди? Да побољшамо симулацију игре. Што се чешће зове update(), то ће симулација игре бити прецизнија. Али немојте се превише заносити бројем изазова. update(), јер је ово рачунски скуп задатак - довољно је 60 у секунди.

Остатак разреда Game састоји се од помоћних метода које се користе у update():

гаме.јс, 3. део

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() прилично једноставно - сортира играче по резултату, узима првих пет и враћа корисничко име и резултат за сваког.

createUpdate() се користи у update() да креирате ажурирања игара која се дистрибуирају играчима. Његов главни задатак је позивање метода serializeForUpdate()имплементиран за наставу Player и Bullet. Имајте на уму да преноси само податке сваком играчу о најближи играчи и пројектили - нема потребе за преносом информација о објектима игре који се налазе далеко од играча!

3. Објекти игре на серверу

У нашој игри пројектили и играчи су заправо веома слични: они су апстрактни, округли, покретни објекти игре. Да бисмо искористили ову сличност између играча и пројектила, почнимо са имплементацијом основне класе Object:

објецт.јс

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

Овде се ништа компликовано не дешава. Ова класа ће бити добра тачка сидрења за проширење. Да видимо како је разред Bullet користи Object:

буллет.јс

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 само следеће екстензије:

  • Коришћење пакета схортид за насумично генерисање id пројектил.
  • Додавање поља parentID, тако да можете пратити играча који је направио овај пројектил.
  • Додавање повратне вредности у update(), што је једнако trueако је пројектил ван арене (сећате се да смо о томе говорили у последњем одељку?).

Пређимо на Player:

плаиер.јс

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:

гаме.јс

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.

Овако изгледа наша имплементација детекције колизије:

цоллисионс.јс

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: Када се пронађе играч који се судара са пројектилом, престајемо да тражимо и прелазимо на следећи пројектил.

Крај

То је све! Покрили смо све што треба да знате да бисте креирали .ио веб игру. Шта је следеће? Направите сопствену .ио игру!

Сви примери кода су отвореног кода и постављени на Гитхуб.

Извор: ввв.хабр.цом

Додај коментар