Креирање на веб-игра со повеќе играчи во жанрот .io

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

Во случај да не сте слушнале за овие игри досега, тие се бесплатни веб-игри со повеќе играчи кои се лесни за играње (не е потребна сметка). Тие обично ставаат многу противнички играчи во една арена. Други познати .io игри: Slither.io и Diep.io.

Во овој пост ќе дознаеме како креирајте .io игра од нула. За да го направите ова, само познавање на Javascript ќе биде доволно: треба да разберете работи како синтакса ES6, клучен збор this и ветувања. Дури и ако не го знаете Javascript совршено, сепак можете да го разберете најголемиот дел од објавата.

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

За помош за обука ќе се повикаме пример игра .io. Обидете се да го играте!

Креирање на веб-игра со повеќе играчи во жанрот .io
Играта е прилично едноставна: вие контролирате брод во арена со други играчи. Вашиот брод автоматски пука проектили и вие се обидувате да ги погодите другите играчи додека ги избегнувате нивните проектили.

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

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

Примерот го користи следново:

  • Експрес е најпопуларната веб-рамка за Node.js која управува со веб-серверот на играта.
  • приклучок.io — библиотека на веб-сокет за размена на податоци помеѓу прелистувачот и серверот.
  • Веб-пакет - менаџер на модули. Можете да прочитате зошто да користите Webpack тука.

Вака изгледа структурата на проектниот директориум:

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

публика/

Сè е во папката 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 да го транспилираме нашиот 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, за да се оптимизираат големини на пакувања при распоредување на производство.

Локално поставување

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

$ 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.

индекс.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. Размена на податоци за клиентот

Во оваа игра користиме добро позната библиотека за да комуницираме со серверот приклучок.io. Socket.io има вградена поддршка Веб -приклучоци, кои се добро прилагодени за двонасочна комуникација: можеме да испраќаме пораки до серверот и серверот може да ни испраќа пораки преку истата врска.

Ќе имаме една датотека src/client/networking.jsкој ќе се грижи за сите комуникација со серверот:

вмрежување.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, кои ги црпат точно четирите точки наведени погоре:

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

рендер.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. Внесување на клиентот

Време е да се направи игра може да се репродуцира! Контролната шема ќе биде многу едноставна: за да ја промените насоката на движење, можете да го користите глувчето (на компјутер) или да го допрете екранот (на мобилен уред). За да го спроведеме ова ќе се регистрираме Слушатели на настани за настани со глувче и допир.
Ќе се погрижи за сето ова src/client/input.js:

влез.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. Статус на клиентот

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

Последниот дел од сложувалката потребен за комплетирање на кодот клиент-сервер е беа. Се сеќавате на фрагментот од кодот од делот Претставување клиент?

рендер.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() може директно да враќа податоци од најновото примено ажурирање на играта.

наивна-држава.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.

Стапка на штиклирање: Фреквенцијата со која серверот испраќа ажурирања на играта до клиентите. Често е пониска од стапката на слики. Во нашата игра, серверот работи со 30 крлежи во секунда.

Ако само го направиме најновото ажурирање на играта, тогаш FPS во суштина никогаш нема да може да надмине 30 затоа што никогаш не добиваме повеќе од 30 ажурирања во секунда од серверот. Дури и да се јавиме render() 60 пати во секунда, тогаш половина од овие повици едноставно ќе го прецртаат истото, во суштина не правејќи ништо. Друг проблем со наивна имплементација е тоа што предмет на одложувања. При идеална брзина на Интернет, клиентот ќе добива ажурирање на играта точно на секои 33 ms (30 во секунда):

Креирање на веб-игра со повеќе играчи во жанрот .io
За жал, ништо не е совршено. Пореална слика би била:
Креирање на веб-игра со повеќе играчи во жанрот .io
Наивната имплементација е прилично најлошиот случај кога станува збор за латентност. Ако ажурирањето на играта се прими со задоцнување од 50 ms, тогаш клиентот е забавен за дополнителни 50 ms бидејќи сè уште ја прикажува состојбата на играта од претходното ажурирање. Можете да замислите колку е незгодно ова за играчот: поради произволни забавувања, играта ќе изгледа откачена и нестабилна.

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(). Како што видовме претходно, секое ажурирање на играта вклучува временски печат на серверот. Сакаме да користиме латентност на рендерирање за да ја прикажеме сликата 100 ms зад серверот, но никогаш нема да го знаеме моменталното време на серверот, бидејќи не можеме да знаеме колку време било потребно за некое од ажурирањата да стигне до нас. Интернетот е непредвидлив и неговата брзина може многу да варира!

За да го заобиколиме овој проблем, можеме да користиме разумно приближување: ние ајде да се преправаме дека првото ажурирање пристигнало веднаш. Ако ова беше вистина, тогаш би го знаеле времето на серверот во тој одреден момент! Го складираме временскиот печат на серверот во firstServerTimestamp и спаси го нашиот локални (клиент) временски печат во истиот момент во gameStart.

О, почекај малку. Зарем не треба да има време на серверот = време на клиентот? Зошто правиме разлика помеѓу „временски печат на сервер“ и „временски печат на клиентот“? Ова е одлично прашање! Излегува дека тоа не се иста работа. Date.now() ќе врати различни временски ознаки во клиентот и серверот и тоа зависи од факторите локални на овие машини. Никогаш не претпоставувајте дека временските ознаки ќе бидат исти на сите машини.

Сега разбираме што прави currentServerTime(): се враќа временски печат на серверот на тековното време на рендерирање. Со други зборови, ова е моменталното време на серверот (firstServerTimestamp <+ (Date.now() - gameStart)) минус доцнење на рендерирање (RENDER_DELAY).

Сега да погледнеме како се справуваме со ажурирањата на играта. Кога ќе се прими ажурирање од серверот, се повикува processGameUpdate(), и го зачувуваме новото ажурирање во низа gameUpdates. Потоа, за да ја провериме употребата на меморијата, ги отстрануваме сите стари ажурирања на ажурирање на базатазатоа што веќе не ни требаат.

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

Креирање на веб-игра со повеќе играчи во жанрот .io
Ажурирањето на играта директно лево од „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 што ја контролира нашата пример на .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-middleware автоматски да ги обновиме нашите развојни пакети, или
  • Статички префрлете папка dist/, во кој Webpack ќе ги запише нашите датотеки по изработката на производството.

Друга важна задача server.js се состои од поставување на серверот приклучок.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, така што не треба да се грижиме за тоа. ќе му се јавам ИД на играч.

Имајќи го тоа на ум, ајде да ги испитаме променливите на пример во класата Game:

  • sockets е објект што го врзува ID-то на играчот со штекерот што е поврзан со плеерот. Ни овозможува да пристапиме до сокетите според нивните ID на плеери со текот на времето.
  • players е објект што го врзува ID на играчот со кодот>Плеер објект

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:

објект.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:

куршум.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:

плеер.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:

игра.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.

Вака изгледа нашата имплементација на откривање судир:

судири.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

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