Стварэнне шматкарыстальніцкай вэб-гульні ў жанры.

Стварэнне шматкарыстальніцкай вэб-гульні ў жанры.
Якая выйшла ў 2015 годзе Agar.io стала прабацькам новага жанру гульняў .io, папулярнасць якога з тых часоў моцна ўзрасла. Рост папулярнасці гульняў .io я выпрабаваў на сабе: за апошнія тры гады я стварыў і прадаў дзве гульні гэтага жанру..

На выпадак, калі вы ніколі раней не чулі пра такія гульні: гэта бясплатныя шматкарыстальніцкія вэб-гульні, у якіх лёгка ўдзельнічаць (не патрабуецца ўліковы запіс). Звычайна яны сутыкаюць на адной арэне мноства супрацьлеглых гульцоў. Іншыя знакамітыя гульні жанру .io: Slither.io и Diep.io.

У гэтым пасце мы будзем разбірацца, як з нуля стварыць гульню .io. Для гэтага дастаткова будзе толькі веданне Javascript: вам трэба разумець такія рэчы, як сінтаксіс ES6, ключавое слова this и Абяцанні. Нават калі вы ведаеце Javascript не ў дасканаласці, то ўсё роўна зможаце разабрацца ў большай частцы посту.

Прыклад гульні.

Для дапамогі ў навучанні мы будзем спасылацца на прыклад гульні.. Паспрабуйце ў згуляць у яе!

Стварэнне шматкарыстальніцкай вэб-гульні ў жанры.
Гульня даволі простая: вы кіруеце караблём на арэне, дзе ёсць іншыя гульцы. Ваш карабель аўтаматычна страляе снарадамі і вы спрабуеце патрапіць у іншых гульцоў, у той жа час пазбягаючы іх снарадаў.

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

Рэкамендую спампаваць зыходны код прыкладу гульні, каб вы маглі ісці за мной.

У прыкладзе выкарыстоўваецца наступнае:

  • Экспрэс - Самы папулярны вэб-фрэймворк для Node.js, які кіруе вэб-серверам гульні.
  • socket.io - бібліятэка websocket для абмену дадзенымі паміж браўзэрам і серверам.
  • Вэб-пакет - менеджэр модуляў. Аб тым, навошта выкарыстоўваць Webpack, можна прачытаць тут.

Вось як выглядае структура каталога праекта:

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

public/

Усё ў тэчцы public/ будзе статычна перадавацца серверам. У public/assets/ змяшчаюцца выкарыстоўваюцца нашым праектам выявы.

SRC/

Увесь зыходны код знаходзіцца ў тэчцы src/. Назвы client/ и server/ гавораць самі за сябе, а shared/ змяшчае файл канстант, які імпартуецца і кліентам, і серверам.

2. Зборкі/параметры праекта

Як сказана вышэй, для зборкі праекта мы выкарыстоўваем менеджэр модуляў. Вэб-пакет. Давайце зірнем на нашу канфігурацыю 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 для транспіляцыі (transpiling) нашага кода 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

і зайсці ў вэб-браўзэры на лакальны: 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.

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 дазваляецца толькі тады, калі мы ўстанавілі злучэнне.
  • Калі злучэнне паспяхова ўстаноўлена, мы рэгіструем callback-функцыі (processGameUpdate() и onGameOver()) для паведамленняў, якія мы можам атрымліваць ад сервера.
  • Экспартуем play() и updateDirection(), Каб іх маглі выкарыстоўваць іншыя файлы.

5. Рэндэрынг кліента

Надышоў час адлюстраваць на экране карцінку!

…але перш чым мы зможам гэта зрабіць, трэба спампаваць усе выявы (рэсурсы), якія для гэтага неабходныя. Давайце напішам менеджэр рэсурсаў:

assets.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/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": 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;
}

Прыгожа і зразумела! Але калі б усё было так проста. Адна з прычын, па якіх такая рэалізацыя праблематычная: яна абмяжоўвае частату кадраў рэндэрынгу частатой тактаў сервера.

Частата кадраў (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),
    };
  }
}

Мы апрацоўваем тры выпадкі:

  1. base < 0 азначае, што да гэтага часу рэндэрынгу абнаўленняў няма (гл. вышэй рэалізацыю getBaseUpdate()). Гэта можа здарыцца адразу ў пачатку гульні з-за затрымкі рэндэрынгу. У такім выпадку мы выкарыстоўваем самае апошняе атрыманае абнаўленне.
  2. base - Гэта самае апошняе абнаўленне, якое ў нас ёсць. Гэта можа адбыцца з-за сеткавай затрымкі ці дрэннай сувязі з Інтэрнэтам. У гэтым выпадку мы таксама выкарыстоўваем самае апошняе абнаўленне, якое ў нас ёсць.
  3. У нас ёсць абнаўленне і да, і пасля бягучага часу рэндэрынгу, таму можна інтэрпаліраваць!

Усё, што засталося ў 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:

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») - усе гульцы гуляюць на адной арэне! У наступным раздзеле мы паглядзім, як працуе гэты клас 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() утрымоўвае, мусіць, найважную частку логікі на боку сервера. Па парадку пералічым усё, што ён робіць:

  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 толькі наступныя пашырэнні:

  • Выкарыстанне пакета 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.

Крыніца: habr.com

Дадаць каментар