Създаване на мултиплейър .io уеб игра

Създаване на мултиплейър .io уеб игра
Издадена през 2015 г Agar.io стана родоначалник на нов жанр игри .ioкоято оттогава набира популярност. Лично съм преживял нарастването на популярността на .io игрите: през последните три години го направих създаде и продаде две игри от този жанр..

В случай, че никога преди не сте чували за тези игри, това са безплатни уеб игри за много играчи, които са лесни за игра (не е необходим акаунт). Те обикновено се изправят срещу много противникови играчи на една и съща арена. Други известни .io игри: Slither.io и Diep.io.

В тази публикация ще проучим как създайте .io игра от нулата. За това ще бъде достатъчно само познаване на Javascript: трябва да разбирате неща като синтаксис ES6, ключова дума this и Обещанията. Дори ако познанията ви по Javascript не са перфектни, пак можете да разберете по-голямата част от публикацията.

.io пример за игра

За помощ при обучението ще се обърнем към .io пример за игра. Опитайте да го играете!

Създаване на мултиплейър .io уеб игра
Играта е доста проста: управлявате кораб на арена, където има други играчи. Вашият кораб автоматично изстрелва снаряди и вие се опитвате да уцелите други играчи, като избягвате техните снаряди.

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

препоръчвам изтегляне на изходния код примерна игра, за да можете да ме следвате.

Примерът използва следното:

  • Експресен е най-популярната уеб рамка Node.js, която управлява уеб сървъра на играта.
  • socket.io - websocket библиотека за обмен на данни между браузър и сървър.
  • WebPACK - модул мениджър. Можете да прочетете защо да използвате Webpack. тук.

Ето как изглежда структурата на директорията на проекта:

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

обществена /

Всичко в папка public/ ще бъдат изпратени статично от сървъра. IN public/assets/ съдържа изображения, използвани от нашия проект.

src /

Целият изходен код е в папката src/... Имена client/ и server/ говорят сами за себе си и shared/ съдържа файл с константи, който се импортира както от клиента, така и от сървъра.

2. Сглобки/Настройки на проекта

Както бе споменато по-горе, ние използваме модулния мениджър за изграждане на проекта. WebPACK. Нека да разгледаме нашата конфигурация на Webpack:

webpack.common.js:

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 е входната точка на Javascript (JS) клиента. Webpack ще започне оттук и ще търси рекурсивно други импортирани файлове.
  • Изходният JS на нашата компилация на Webpack ще бъде разположен в директорията dist/. Ще нарека този файл наш js пакет.
  • Ние използваме Бабел, и по-специално конфигурацията @babel/preset-env за транспилиране на нашия JS код за по-стари браузъри.
  • Използваме плъгин, за да извлечем всички CSS, посочени от JS файловете, и да ги комбинираме на едно място. Ще го наричам наш css пакет.

Може да сте забелязали странни имена на файлове на пакети '[name].[contenthash].ext'. Те съдържат заместване на имена на файлове уеб пакет: [name] ще бъде заменено с името на входната точка (в нашия случай това game), и [contenthash] ще бъде заменен с хеш на съдържанието на файла. Правим го, за да оптимизирайте проекта за хеширане - можете да кажете на браузърите да кешират нашите JS пакети за неопределено време, защото ако пакетът се промени, името на неговия файл също се променя (промени contenthash). Крайният резултат ще бъде името на файла за преглед game.dbeee76e91a97d0c7207.js.

досие webpack.common.js е основният конфигурационен файл, който импортираме в разработката и завършените конфигурации на проекта. Ето примерна конфигурация за разработка:

webpack.dev.js

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

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

За ефективност използваме в процеса на разработка 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

и отидете на уеб браузър Localhost: 3000. Сървърът за разработка автоматично ще възстанови JS и CSS пакетите, когато кодът се промени - просто опреснете страницата, за да видите всички промени!

3. Клиентски входни точки

Нека да преминем към самия код на играта. Първо имаме нужда от страница index.html, при посещение на сайта браузърът първо ще го зареди. Нашата страница ще бъде доста проста:

index.html

Примерна .io игра  ИГРАЙТЕ

Този пример за код е леко опростен за по-голяма яснота и ще направя същото с много от другите примери за публикации. Пълният код винаги може да се види на Github.

Ние имаме:

  • HTML5 платнен елемент (<canvas>), които ще използваме за изобразяване на играта.
  • <link> за да добавите нашия CSS пакет.
  • <script> за да добавите нашия Javascript пакет.
  • Главно меню с потребителско име <input> и бутона PLAY (<button>).

След като зареди началната страница, браузърът ще започне да изпълнява Javascript код, започвайки от JS файла на входната точка: src/client/index.js.

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. Импортиране на няколко други JS файла.
  2. CSS импортиране (така че Webpack знае да ги включи в нашия CSS пакет).
  3. хвърлям connect() за установяване на връзка със сървъра и стартиране downloadAssets() за изтегляне на изображения, необходими за изобразяване на играта.
  4. След завършване на етап 3 показва се главното меню (playMenu).
  5. Настройка на манипулатора за натискане на бутон "PLAY". При натискане на бутона кодът инициализира играта и казва на сървъра, че сме готови за игра.

Основното „месо“ на нашата клиент-сървър логика е в онези файлове, които са импортирани от файла index.js. Сега ще ги разгледаме всички по ред.

4. Обмен на клиентски данни

В тази игра ние използваме добре позната библиотека за комуникация със сървъра socket.io. Socket.io има собствена поддръжка WebSockets, които са много подходящи за двупосочна комуникация: можем да изпращаме съобщения до сървъра и сървърът може да изпраща съобщения до нас по същата връзка.

Ще имаме един файл src/client/networking.jsкой ще се грижи за всички комуникация със сървъра:

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. Клиентско изобразяване

Време е да покажете картината на екрана!

... но преди да можем да направим това, трябва да изтеглим всички изображения (ресурси), които са необходими за това. Нека напишем мениджър на ресурси:

активи.js

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.

След като изтеглите ресурсите, можете да започнете рендирането. Както казахме по-рано, за да рисуваме върху уеб страница, използваме HTML5 платно (<canvas>). Нашата игра е доста проста, така че трябва само да нарисуваме следното:

  1. фон
  2. Кораб на играча
  3. Други играчи в играта
  4. боеприпаси

Ето важните фрагменти 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/клиент/render.js.

6. Въвеждане на клиента

Време е да направим игра годни за игра! Схемата за управление ще бъде много проста: за да промените посоката на движение, можете да използвате мишката (на компютър) или да докоснете екрана (на мобилно устройство). За да приложим това, ще се регистрираме Слушатели на събития за събития с мишка и докосване.
Ще се погрижи за всичко това 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() са слушатели на събития, които се обаждат updateDirection() (от networking.js), когато възникне входно събитие (например при преместване на мишката). updateDirection() обработва съобщенията със сървъра, който обработва входното събитие и съответно актуализира състоянието на играта.

7. Състояние на клиента

Този раздел е най-трудният в първата част на поста. Не се обезсърчавайте, ако не го разберете първия път, когато го прочетете! Можете дори да го пропуснете и да се върнете към него по-късно.

Последното парче от пъзела, необходимо за завършване на кода клиент/сървър, е са. Спомняте ли си кодовия фрагмент от раздела „Изобразяване на клиента“?

render.js

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() може само директно да върне данните за най-скоро получената актуализация на играта.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Хубаво и ясно! Но само ако беше толкова просто. Една от причините тази реализация да е проблематична: ограничава честотата на кадрите на рендиране до честотата на часовника на сървъра.

Честота на кадрите: брой рамки (т.е. повиквания render()) в секунда или FPS. Игрите обикновено се стремят да постигнат поне 60 FPS.

Tick ​​​​Rate: Честотата, с която сървърът изпраща актуализации на играта на клиентите. Често е по-ниска от кадровата честота. В нашата игра сървърът работи с честота от 30 цикъла в секунда.

Ако просто изобразим последната актуализация на играта, тогава FPS по същество никога няма да надхвърли 30, защото никога не получаваме повече от 30 актуализации в секунда от сървъра. Дори да се обадим render() 60 пъти в секунда, тогава половината от тези обаждания просто ще преначертаят едно и също нещо, като по същество не правят нищо. Друг проблем с наивната реализация е, че тя склонни към закъснения. С идеална интернет скорост, клиентът ще получава актуализация на играта точно на всеки 33ms (30 в секунда):

Създаване на мултиплейър .io уеб игра
За съжаление нищо не е идеално. По-реалистична картина би била:
Създаване на мултиплейър .io уеб игра
Наивното внедряване е практически най-лошият случай, когато става дума за латентност. Ако се получи актуализация на играта със закъснение от 50ms, тогава клиентски щандове допълнителни 50ms, защото все още изобразява състоянието на играта от предишната актуализация. Можете да си представите колко неудобно е това за играча: произволното спиране ще накара играта да се почувства рязка и нестабилна.

7.2 Подобрено състояние на клиента

Ще направим някои подобрения в наивната реализация. Първо, използваме забавяне на изобразяването за 100 ms. Това означава, че "текущото" състояние на клиента винаги ще изостава от състоянието на играта на сървъра със 100ms. Например, ако времето на сървъра е 150, тогава клиентът ще изобрази състоянието, в което сървърът е бил в момента 50:

Създаване на мултиплейър .io уеб игра
Това ни дава буфер от 100 ms, за да оцелеем при непредвидими времена за актуализация на играта:

Създаване на мултиплейър .io уеб игра
Изплащането за това ще бъде постоянно входно забавяне за 100 ms. Това е незначителна жертва за плавен геймплей - повечето играчи (особено обикновените играчи) дори няма да забележат това забавяне. За хората е много по-лесно да се приспособят към постоянна латентност от 100 ms, отколкото да играят с непредвидима латентност.

Можем да използваме и друга техника, наречена прогнозиране от страна на клиента, което върши добра работа за намаляване на възприеманото забавяне, но няма да бъде разгледано в тази публикация.

Друго подобрение, което използваме, е линейна интерполация. Поради забавяне на изобразяването обикновено сме поне с една актуализация пред текущото време в клиента. При повикване getCurrentState(), можем да изпълним линейна интерполация между актуализациите на играта точно преди и след текущото време в клиента:

Създаване на мултиплейър .io уеб игра
Това решава проблема с честотата на кадрите: сега можем да рендираме уникални кадри с каквато кадрова честота пожелаем!

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(). Както видяхме по-рано, всяка актуализация на играта включва клеймо за време на сървъра. Искаме да използваме забавяне на изобразяването, за да изобразим изображението 100ms зад сървъра, но никога няма да знаем текущото време на сървъра, тъй като не можем да знаем колко време е отнело някоя от актуализациите да стигнат до нас. Интернет е непредсказуем и скоростта му може да варира значително!

За да заобиколим този проблем, можем да използваме разумно приближение: ние преструвайте се, че първата актуализация е пристигнала незабавно. Ако това беше вярно, тогава щяхме да знаем времето на сървъра в този конкретен момент! Ние съхраняваме времевия печат на сървъра firstServerTimestamp и запазим нашите местен (клиент) времево клеймо в същия момент gameStart.

О, чакай. Не трябва ли да е сървърно време = клиентско време? Защо правим разлика между „времево клеймо на сървъра“ и „времево клеймо на клиента“? Това е страхотен въпрос! Оказва се, че не са едно и също нещо. Date.now() ще върне различни времеви марки в клиента и сървъра и зависи от фактори, локални за тези машини. Никога не предполагайте, че времевите клейма ще бъдат еднакви на всички машини.

Сега разбираме какво прави currentServerTime(): връща се клеймото на сървъра за текущото време на изобразяване. С други думи, това е текущото време на сървъра (firstServerTimestamp <+ (Date.now() - gameStart)) минус забавяне на изобразяването (RENDER_DELAY).

Сега нека да разгледаме как обработваме актуализациите на играта. Когато се получи от сървъра за актуализиране, той се извиква processGameUpdate()и запазваме новата актуализация в масив gameUpdates. След това, за да проверим използването на паметта, премахваме всички стари актуализации преди базова актуализациязащото вече не ни трябват.

Какво е "основна актуализация"? Това първата актуализация, която намираме, като се движим назад от текущото време на сървъра. Помните ли тази диаграма?

Създаване на мултиплейър .io уеб игра
Актуализацията на играта директно отляво на „Време за изобразяване на клиента“ е основната актуализация.

За какво се използва базовата актуализация? Защо можем да изпуснем актуализациите до базовата линия? За да разберем това, нека най-накрая обмислете изпълнението 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),
    };
  }
}

Разглеждаме три случая:

  1. base < 0 означава, че няма актуализации до текущото време за изобразяване (вижте изпълнението по-горе getBaseUpdate()). Това може да се случи точно в началото на играта поради забавяне на изобразяването. В този случай използваме последната получена актуализация.
  2. base е последната актуализация, която имаме. Това може да се дължи на забавяне на мрежата или лоша интернет връзка. В този случай ние също използваме последната актуализация, която имаме.
  3. Имаме актуализация преди и след текущото време за изобразяване, така че можем интерполирам!

Всичко останало вътре state.js е реализация на линейна интерполация, която е проста (но скучна) математика. Ако искате да го изследвате сами, отворете state.js на Github.

Част 2. Backend сървър

В тази част ще разгледаме бекенда Node.js, който контролира нашия .io пример за игра.

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-мидълуер за автоматично възстановяване на нашите пакети за разработка, или
  • папка за статично прехвърляне dist/, в който Webpack ще запише нашите файлове след производствената компилация.

Друга важна задача server.js е да настроите сървъра socket.ioкойто просто се свързва към Express сървъра:

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);
});

След успешно установяване на 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.

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

Клас 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 на играча към сокета, който е свързан с играча. Това ни позволява да осъществяваме достъп до сокети чрез техните идентификатори на играчи в постоянно време.
  • players е обект, който свързва идентификатора на играча към обекта code>Player

bullets е масив от обекти Bullet, който няма определен ред.
lastUpdateTime е клеймото за последно актуализиране на играта. Ще видим как ще се използва скоро.
shouldSendUpdate е спомагателна променлива. Скоро ще видим и използването му.
методи addPlayer(), removePlayer() и handleInput() няма нужда да обяснявам, те се използват в server.js. Ако трябва да опресните паметта си, върнете се малко по-нагоре.

Последен ред constructor() стартира цикъл на актуализиране игри (с честота 60 актуализации / s):

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

  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():

game.js част 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:

object.js

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:

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 само следните разширения:

  • Използване на пакета нисък за произволно генериране 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;
}

Това просто откриване на сблъсък се основава на факта, че две окръжности се сблъскват, ако разстоянието между центровете им е по-малко от сбора на радиусите им. Ето случая, когато разстоянието между центровете на две окръжности е точно равно на сбора от техните радиуси:

Създаване на мултиплейър .io уеб игра
Има още няколко аспекта, които трябва да имате предвид тук:

  • Снарядът не трябва да уцелва играча, който го е създал. Това може да се постигне чрез сравняване bullet.parentID с player.id.
  • Снарядът трябва да удари само веднъж в ограничаващия случай на сблъсък на няколко играча едновременно. Ще решим този проблем с помощта на оператора break: веднага щом бъде намерен играчът, който се сблъсква със снаряда, спираме търсенето и преминаваме към следващия снаряд.

Конец

Това е всичко! Разгледахме всичко, което трябва да знаете, за да създадете .io уеб игра. Какво следва? Създайте своя собствена .io игра!

Целият примерен код е с отворен код и е публикуван на Github.

Източник: www.habr.com

Добавяне на нов коментар