Að búa til fjölspilunar .io vefleik

Að búa til fjölspilunar .io vefleik
Gefið út árið 2015 Agar.io varð forfaðir nýrrar tegundar leikir .iosem hefur vaxið í vinsældum síðan þá. Ég hef persónulega upplifað aukningu í vinsældum .io leikja: undanfarin þrjú ár hef ég gert það búið til og selt tvo leiki af þessari tegund..

Ef þú hefur aldrei heyrt um þessa leiki áður, þá eru þetta ókeypis fjölspilunar vefleikir sem auðvelt er að spila (enginn reikningur krafist). Þeir mæta yfirleitt mörgum andstæðingum á sama vettvangi. Aðrir frægir .io leikir: Slither.io и Diep.io.

Í þessari færslu munum við kanna hvernig búa til .io leik frá grunni. Til þess nægir aðeins þekking á Javascript: þú þarft að skilja hluti eins og setningafræði ES6, lykilorð this и loforð. Jafnvel þó að þekking þín á Javascript sé ekki fullkomin, geturðu samt skilið megnið af færslunni.

.io leik dæmi

Fyrir námsaðstoð munum við vísa til .io leik dæmi. Reyndu að spila það!

Að búa til fjölspilunar .io vefleik
Leikurinn er frekar einfaldur: þú stjórnar skipi á vettvangi þar sem aðrir leikmenn eru. Skipið þitt skýtur sjálfkrafa skotum og þú reynir að lemja aðra leikmenn á meðan þú forðast skot þeirra.

1. Stutt yfirlit / uppbyggingu verkefnisins

Ég mæli með sækja frumkóða dæmi leik svo þú getir fylgst með mér.

Dæmið notar eftirfarandi:

  • Express er vinsælasta Node.js veframminn sem heldur utan um vefþjón leiksins.
  • fals.io - Websocket bókasafn til að skiptast á gögnum á milli vafra og netþjóns.
  • Webpack - einingastjóri. Þú getur lesið um hvers vegna á að nota Webpack. hér.

Svona lítur uppbygging verkefnaskrár út:

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

áhorfendur/

Allt í möppu public/ verður sent inn á kyrrstöðu af þjóninum. IN public/assets/ inniheldur myndir sem verkefnið okkar notar.

src /

Allur frumkóði er í möppunni src/. Titlar client/ и server/ tala fyrir sig og shared/ inniheldur fastaskrá sem er flutt inn af bæði biðlaranum og þjóninum.

2. Samsetningar/verkefnastillingar

Eins og getið er hér að ofan notum við einingastjórann til að byggja upp verkefnið. Webpack. Við skulum kíkja á uppsetningu vefpakkans okkar:

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',
    }),
  ],
};

Mikilvægustu línurnar hér eru:

  • src/client/index.js er inngangspunktur Javascript (JS) biðlarans. Webpack mun byrja héðan og leita endurkvæmt að öðrum innfluttum skrám.
  • Úttak JS af Webpack byggingu okkar verður staðsett í möppunni dist/. Ég mun kalla þessa skrá okkar js pakka.
  • Við notum Babel, og sérstaklega stillingarnar @babel/forstillt-env að umbreyta JS kóðanum okkar fyrir eldri vafra.
  • Við erum að nota viðbót til að draga út alla CSS sem JS skrárnar vísa til og sameina þær á einum stað. Ég mun kalla hann okkar css pakka.

Þú gætir hafa tekið eftir undarlegum pakkaskráarnöfnum '[name].[contenthash].ext'. Þau innihalda skráarnafnaskipti Vefpakki: [name] verður skipt út fyrir nafn inntakspunktsins (í okkar tilviki, þetta game), og [contenthash] verður skipt út fyrir kjötkássa af innihaldi skráarinnar. Við gerum það til fínstilla verkefnið fyrir hass - þú getur sagt vöfrum að vista JS pakkana okkar endalaust, vegna þess ef pakki breytist, þá breytist skráarnafn hans líka (breytingar contenthash). Lokaniðurstaðan verður nafnið á útsýnisskránni game.dbeee76e91a97d0c7207.js.

skrá webpack.common.js er grunnstillingarskráin sem við flytjum inn í þróunarstillingar og fullunnar verkefnisstillingar. Hér er dæmi um þróunarstillingar:

webpack.dev.js

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

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

Til skilvirkni notum við í þróunarferlinu webpack.dev.js, og skiptir yfir í webpack.prod.jstil að hámarka pakkningastærðir þegar þær eru settar í framleiðslu.

Staðbundið umhverfi

Ég mæli með því að setja upp verkefnið á staðbundinni vél svo þú getir fylgst með skrefunum sem taldar eru upp í þessari færslu. Uppsetningin er einföld: Í fyrsta lagi verður kerfið að hafa verið sett upp Hnút и NPM. Næst þarftu að gera

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

og þú ert tilbúinn að fara! Til að ræsa þróunarþjóninn skaltu bara keyra

$ npm run develop

og farðu í vafra localhost: 3000. Þróunarþjónninn mun sjálfkrafa endurbyggja JS og CSS pakkana þegar kóðinn breytist - endurnýjaðu bara síðuna til að sjá allar breytingarnar!

3. Aðgangsstaðir viðskiptavinar

Við skulum komast niður að leikkóðann sjálfum. Fyrst þurfum við síðu index.html, þegar þú heimsækir síðuna mun vafrinn hlaða henni fyrst. Síðan okkar verður frekar einföld:

index.html

Dæmi um .io leik  LEIKA

Þetta kóðadæmi hefur verið einfaldað örlítið til glöggvunar og ég mun gera það sama með mörgum öðrum póstdæmum. Alltaf er hægt að skoða allan kóðann á GitHub.

Við höfum:

  • HTML5 striga þáttur (<canvas>) sem við munum nota til að gera leikinn.
  • <link> til að bæta við CSS pakkanum okkar.
  • <script> til að bæta við Javascript pakkanum okkar.
  • Aðalvalmynd með notendanafni <input> og PLAY hnappinn (<button>).

Eftir að heimasíðunni hefur verið hlaðið mun vafrinn byrja að keyra Javascript kóða, byrjað á JS skránni: 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);
  };
});

Þetta hljómar kannski flókið, en það er ekki mikið að gerast hér:

  1. Flytur inn nokkrar aðrar JS skrár.
  2. CSS innflutningur (svo Webpack viti að hafa þá með í CSS pakkanum okkar).
  3. Ræstu connect() til að koma á tengingu við netþjóninn og keyra downloadAssets() til að hlaða niður myndum sem þarf til að gera leikinn.
  4. Eftir að stigi 3 er lokið aðalvalmyndin birtist (playMenu).
  5. Stilling stjórnanda fyrir að ýta á "PLAY" hnappinn. Þegar ýtt er á hnappinn frumstillir kóðinn leikinn og segir þjóninum að við séum tilbúin að spila.

Aðal "kjötið" í rökfræði viðskiptavina-miðlara okkar er í þeim skrám sem voru fluttar inn af skránni index.js. Nú munum við líta á þá alla í röð.

4. Skipti á gögnum viðskiptavina

Í þessum leik notum við vel þekkt bókasafn til að eiga samskipti við netþjóninn fals.io. Socket.io hefur innfæddan stuðning vefinnstungur, sem henta vel fyrir tvíhliða samskipti: við getum sent skilaboð á netþjóninn и þjónninn getur sent okkur skilaboð á sömu tengingu.

Við verðum með eina skrá src/client/networking.jssem mun sjá um af öllum samskipti við þjóninn:

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

Þessi kóða hefur einnig verið stytt lítillega til glöggvunar.

Það eru þrjár meginaðgerðir í þessari skrá:

  • Við erum að reyna að tengjast þjóninum. connectedPromise aðeins leyfilegt þegar við höfum komið á tengingu.
  • Ef tengingin tekst, skráum við svarhringingaraðgerðir (processGameUpdate() и onGameOver()) fyrir skilaboð sem við getum tekið á móti frá þjóninum.
  • Við flytjum út play() и updateDirection()svo að aðrar skrár geti notað þær.

5. Viðskiptavinur Rendering

Það er kominn tími til að sýna myndina á skjánum!

…en áður en við getum gert það þurfum við að hlaða niður öllum myndum (tilföngum) sem þarf til þess. Við skulum skrifa auðlindastjóra:

eignir.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];

Auðlindastjórnun er ekki svo erfið í framkvæmd! Meginhugmyndin er að geyma hlut assets, sem mun binda lykil skráarnafnsins við gildi hlutarins Image. Þegar auðlindin er hlaðin geymum við hana í hlut assets fyrir skjótan aðgang í framtíðinni. Hvenær verður leyft að hlaða niður hverri einstöku auðlind (þ.e. allt auðlindir), við leyfum downloadPromise.

Eftir að þú hefur hlaðið niður auðlindunum geturðu byrjað að túlka. Eins og áður sagði, til að teikna á vefsíðu, notum við HTML5 striga (<canvas>). Leikurinn okkar er frekar einfaldur, svo við þurfum aðeins að teikna eftirfarandi:

  1. Bakgrunnur
  2. Leikmannaskip
  3. Aðrir leikmenn í leiknum
  4. Skeljar

Hér eru mikilvægir brot src/client/render.js, sem skilar nákvæmlega þeim fjórum atriðum sem talin eru upp hér að ofan:

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

Þessi kóði er einnig styttur til glöggvunar.

render() er aðalhlutverk þessarar skráar. startRendering() и stopRendering() stjórna virkjun rendering lykkja við 60 FPS.

Ákveðnar útfærslur á einstökum hjálparaðgerðum fyrir flutning (t.d. renderBullet()) eru ekki svo mikilvæg, en hér er eitt einfalt dæmi:

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

Athugið að við erum að nota aðferðina getAsset(), sem áður sást í asset.js!

Ef þú hefur áhuga á að fræðast um aðra vinnsluhjálpara, lestu þá afganginn. src/client/render.js.

6. Inntak viðskiptavinar

Það er kominn tími til að búa til leik spilanlegur! Stýrikerfið verður mjög einfalt: til að breyta stefnu hreyfingar geturðu notað músina (í tölvu) eða snert skjáinn (í farsíma). Til að hrinda þessu í framkvæmd munum við skrá okkur Hlustendur viðburða fyrir Mouse and Touch atburði.
Mun sjá um þetta allt 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() eru atburðahlustendur sem hringja updateDirection() (af networking.js) þegar inntaksviðburður á sér stað (til dæmis þegar músin er færð). updateDirection() sér um skilaboð við netþjóninn sem sér um inntaksviðburðinn og uppfærir leikjastöðuna í samræmi við það.

7. Staða viðskiptavina

Þessi kafli er sá erfiðasti í fyrri hluta færslunnar. Ekki láta hugfallast ef þú skilur það ekki í fyrsta skipti sem þú lest það! Þú getur jafnvel sleppt því og komið aftur að því síðar.

Síðasti púslið sem þarf til að klára kóðann viðskiptavinar/miðlara er voru. Manstu eftir kóðabútinum úr hlutanum „Clubing Rendering“?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() ætti að geta gefið okkur núverandi stöðu leiksins í biðlaranum hvenær sem er byggt á uppfærslum sem berast frá þjóninum. Hér er dæmi um leikuppfærslu sem þjónninn getur sent:

{
  "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
    }
  ]
}

Hver leikuppfærsla inniheldur fimm eins reiti:

  • t: Tímastimpill miðlara sem gefur til kynna hvenær þessi uppfærsla var búin til.
  • me: Upplýsingar um spilarann ​​sem fær þessa uppfærslu.
  • aðrir: Fjöldi upplýsinga um aðra leikmenn sem taka þátt í sama leik.
  • byssukúlur: fjölda upplýsinga um skotfæri í leiknum.
  • Skilti: Núverandi stigatöflugögn. Í þessari færslu munum við ekki íhuga þá.

7.1 Naive viðskiptavinur ástand

Barnlaus útfærsla getCurrentState() getur aðeins skilað beint gögnum frá nýjustu mótteknu leikuppfærslunni.

barnalegt-ríki.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Fínt og skýrt! En ef þetta væri bara svona einfalt. Ein af ástæðunum fyrir því að þessi útfærsla er erfið: það takmarkar flutningsrammahraðann við klukkuhraða netþjónsins.

Rammahlutfall: fjöldi ramma (þ.e. símtöl render()) á sekúndu, eða FPS. Leikir leitast venjulega við að ná að minnsta kosti 60 FPS.

Merktu við Verð: Tíðnin sem þjónninn sendir leikjauppfærslur til viðskiptavina. Það er oft lægra en rammahlutfallið. Í leiknum okkar keyrir þjónninn á 30 lotum á sekúndu.

Ef við gerum bara nýjustu uppfærslu leiksins, þá mun FPS í raun aldrei fara yfir 30, vegna þess við fáum aldrei meira en 30 uppfærslur á sekúndu frá þjóninum. Jafnvel þótt við hringjum render() 60 sinnum á sekúndu, þá mun helmingur þessara símtala bara endurteikna það sama og gera í rauninni ekki neitt. Annað vandamál við barnalegu útfærsluna er að það viðkvæmt fyrir töfum. Með kjörnum internethraða mun viðskiptavinurinn fá leikuppfærslu á nákvæmlega 33 ms fresti (30 á sekúndu):

Að búa til fjölspilunar .io vefleik
Því miður er ekkert fullkomið. Raunhæfari mynd væri:
Að búa til fjölspilunar .io vefleik
Barnlausa útfærslan er nánast versta tilvikið þegar kemur að leynd. Ef leikuppfærsla er móttekin með 50ms seinkun, þá viðskiptamannabásar auka 50ms vegna þess að það er enn að skila leikjastöðu frá fyrri uppfærslu. Þú getur ímyndað þér hversu óþægilegt þetta er fyrir leikmanninn: handahófskenndar hemlun mun gera leikinn hiklausan og óstöðugan.

7.2 Bætt ástand viðskiptavinar

Við munum gera nokkrar endurbætur á barnalegu útfærslunni. Í fyrsta lagi notum við seinkun á flutningi í 100 ms. Þetta þýðir að „núverandi“ ástand viðskiptavinarins mun alltaf vera 100 ms á eftir stöðu leiksins á þjóninum. Til dæmis, ef tíminn á þjóninum er 150, þá mun viðskiptavinurinn skila því ástandi sem þjónninn var í á þeim tíma 50:

Að búa til fjölspilunar .io vefleik
Þetta gefur okkur 100ms biðminni til að lifa af ófyrirsjáanlega uppfærslutíma leiksins:

Að búa til fjölspilunar .io vefleik
Afborgunin fyrir þetta verður varanleg innsláttartöf í 100 ms. Þetta er minniháttar fórn fyrir hnökralaust spilun - flestir leikmenn (sérstaklega frjálsir spilarar) munu ekki einu sinni taka eftir þessari töf. Það er miklu auðveldara fyrir fólk að aðlagast stöðugri 100ms leynd heldur en að spila með ófyrirsjáanlega leynd.

Við getum líka notað aðra tækni sem kallast spá viðskiptavinarhliðar, sem gerir gott starf við að draga úr skynjaðri leynd, en verður ekki fjallað um í þessari færslu.

Önnur framför sem við erum að nota er línuleg innskot. Vegna seinkun á flutningi erum við venjulega að minnsta kosti einni uppfærslu á undan núverandi tíma í biðlaranum. Þegar hringt er í getCurrentState(), við getum framkvæmt línuleg innskot milli leikjauppfærslna rétt fyrir og eftir núverandi tíma í biðlaranum:

Að búa til fjölspilunar .io vefleik
Þetta leysir rammahraðamálið: við getum nú gert einstaka ramma á hvaða rammahraða sem við viljum!

7.3 Innleiðing endurbætts viðskiptavinar ástands

Útfærsludæmi í src/client/state.js notar bæði töf og línulega innskot, en ekki lengi. Við skulum skipta kóðanum í tvo hluta. Hér er sá fyrsti:

state.js hluti 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;
}

Fyrsta skrefið er að finna út hvað currentServerTime(). Eins og við sáum áðan inniheldur hver leikjauppfærsla tímastimpil netþjóns. Við viljum nota birtingartíma til að gera myndina 100ms á bak við netþjóninn, en við munum aldrei vita núverandi tíma á þjóninum, vegna þess að við getum ekki vitað hversu langan tíma það tók fyrir einhverjar uppfærslur að komast til okkar. Netið er óútreiknanlegt og hraði þess getur verið mjög mismunandi!

Til að komast í kringum þetta vandamál getum við notað hæfilega nálgun: við láta eins og fyrsta uppfærslan hafi borist samstundis. Ef þetta væri satt, þá myndum við vita hvenær netþjónninn er á þessu tiltekna augnabliki! Við geymum tímastimpil þjónsins inni firstServerTimestamp og halda okkar heimamaður (viðskiptavinur) tímastimpill á sama augnabliki í gameStart.

Ó bíddu. Ætti það ekki að vera server time = client time? Hvers vegna gerum við greinarmun á „tímastimpli miðlara“ og „tímastimpli viðskiptavinar“? Þetta er frábær spurning! Það kemur í ljós að þeir eru ekki sami hluturinn. Date.now() mun skila mismunandi tímastimplum í biðlara og þjóni, og það fer eftir staðbundnum þáttum í þessum vélum. Aldrei gera ráð fyrir að tímastimplar verði eins á öllum vélum.

Nú skiljum við hvað gerir currentServerTime(): það skilar sér tímastimpil þjóns núverandi flutningstíma. Með öðrum orðum, þetta er núverandi tími netþjónsins (firstServerTimestamp <+ (Date.now() - gameStart)) mínus töf á birtingu (RENDER_DELAY).

Nú skulum við kíkja á hvernig við meðhöndlum leikjauppfærslur. Þegar það er móttekið frá uppfærsluþjóninum er það kallað processGameUpdate()og við vistum nýju uppfærsluna í fylki gameUpdates. Síðan, til að athuga minnisnotkunina, fjarlægjum við allar gömlu uppfærslurnar áður grunnuppfærslaþví við þurfum þá ekki lengur.

Hvað er „grunnuppfærsla“? Þetta fyrsta uppfærslan sem við finnum með því að fara aftur á bak frá núverandi tíma netþjónsins. Manstu eftir þessari skýringarmynd?

Að búa til fjölspilunar .io vefleik
Leikjauppfærslan beint vinstra megin við „Client Render Time“ er grunnuppfærslan.

Til hvers er grunnuppfærslan notuð? Af hverju getum við sleppt uppfærslum í grunnlínu? Til að komast að þessu, skulum við loksins huga að framkvæmdinni getCurrentState():

state.js hluti 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),
    };
  }
}

Við tökum á þremur málum:

  1. base < 0 þýðir að það eru engar uppfærslur fyrr en á núverandi birtingartíma (sjá framkvæmd að ofan getBaseUpdate()). Þetta getur gerst strax í upphafi leiks vegna töf á flutningi. Í þessu tilviki notum við nýjustu uppfærsluna sem berast.
  2. base er nýjasta uppfærslan sem við höfum. Þetta gæti stafað af seinkun á neti eða lélegri nettengingu. Í þessu tilfelli erum við líka að nota nýjustu uppfærsluna sem við höfum.
  3. Við erum með uppfærslu bæði fyrir og eftir núverandi birtingartíma, svo við getum innskot!

Allt sem er eftir state.js er útfærsla á línulegri innskot sem er einföld (en leiðinleg) stærðfræði. Ef þú vilt kanna það sjálfur, opnaðu þá state.js á GitHub.

Part 2. Bakendaþjónn

Í þessum hluta munum við skoða Node.js bakendann sem stjórnar okkar .io leik dæmi.

1. Inngangsstaður miðlara

Til að stjórna vefþjóninum munum við nota vinsælan veframma fyrir Node.js sem heitir Express. Það verður stillt af inngöngustaðsskrá þjónsins okkar src/server/server.js:

server.js hluti 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}`);

Mundu að í fyrri hlutanum ræddum við Webpack? Þetta er þar sem við munum nota Webpack stillingar okkar. Við munum nota þau á tvo vegu:

  • Notaðu webpack-dev-middleware til að endurbyggja þróunarpakkana okkar sjálfkrafa, eða
  • statically flytja möppu dist/, þar sem Webpack mun skrifa skrárnar okkar eftir framleiðslugerðina.

Annað mikilvægt verkefni server.js er að setja upp þjóninn fals.iosem tengist bara Express netþjóninum:

server.js hluti 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);
});

Eftir að hafa komið á socket.io tengingu við netþjóninn settum við upp viðburðastjórnun fyrir nýju falsið. Atburðaráðendur meðhöndla skilaboð sem berast frá viðskiptavinum með því að framselja til einstaks hlut game:

server.js hluti 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);
}

Við erum að búa til .io leik, þannig að við þurfum aðeins eitt eintak Game ("Leikur") - allir leikmenn spila á sama vettvangi! Í næsta kafla munum við sjá hvernig þessi flokkur virkar. Game.

2. Leikjaþjónar

Class Game inniheldur mikilvægustu rökfræðina á netþjóninum. Það hefur tvö meginverkefni: leikmannastjórnun и leikja uppgerð.

Byrjum á fyrsta verkefninu, leikmannastjórnun.

game.js hluti 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);
    }
  }

  // ...
}

Í þessum leik munum við bera kennsl á leikmennina við völlinn id socket.io falsið þeirra (ef þú verður ruglaður, farðu þá aftur í server.js). Socket.io sjálft úthlutar hverri fals einstakt idsvo við þurfum ekki að hafa áhyggjur af því. Ég mun hringja í hann Auðkenni leikmanns.

Með það í huga skulum við kanna tilviksbreytur í flokki Game:

  • sockets er hlutur sem bindur auðkenni leikmanns við innstunguna sem tengist spilaranum. Það gerir okkur kleift að fá aðgang að innstungum með auðkenni leikmanna á föstu tíma.
  • players er hlutur sem bindur auðkenni leikmannsins við kóðann>Player object

bullets er fjöldi hluta Bullet, sem hefur enga ákveðna röð.
lastUpdateTime er tímastimpill síðasta skipti sem leikurinn var uppfærður. Við munum sjá hvernig það er notað fljótlega.
shouldSendUpdate er hjálparbreyta. Við munum einnig sjá notkun þess innan skamms.
Aðferðir addPlayer(), removePlayer() и handleInput() engin þörf á að útskýra, þau eru notuð í server.js. Ef þú þarft að hressa upp á minnið skaltu fara aðeins hærra til baka.

Síðasta lína constructor() byrjar uppfærsluferli leikir (með tíðni upp á 60 uppfærslur / s):

game.js hluti 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;
    }
  }

  // ...
}

Aðferð update() inniheldur ef til vill mikilvægasta hluta rökfræði miðlarahliðar. Hér er það sem það gerir, í röð:

  1. Reiknar út hversu lengi dt liðinn frá því síðast update().
  2. Endurnærir hvert skot og eyðir þeim ef þörf krefur. Við munum sjá útfærslu þessarar virkni síðar. Í bili er nóg fyrir okkur að vita það bullet.update() skilar trueef eyðileggja ætti skothylkið (hann steig út af vellinum).
  3. Uppfærir hvern spilara og hleypir af sér skotfæri ef þörf krefur. Við munum líka sjá þessa útfærslu síðar - player.update() getur skilað hlut Bullet.
  4. Athugar fyrir árekstra milli skotvopna og leikmanna með applyCollisions(), sem skilar fjölda skota sem lenda í leikmönnum. Fyrir hvert skot sem er skilað, aukum við stig leikmannsins sem skaut því (með því að nota player.onDealtDamage()) og fjarlægðu síðan skotið úr fylkinu bullets.
  5. Lætur vita og eyðileggja alla drepna leikmenn.
  6. Sendir leikuppfærslu til allra leikmanna hverri sekúndu sinnum þegar hringt er update(). Þetta hjálpar okkur að halda utan um aukabreytuna sem nefnd er hér að ofan. shouldSendUpdate... Eins og update() hringt 60 sinnum/s, við sendum leikuppfærslur 30 sinnum/s. Þannig, klukkutíðni miðlara klukka er 30 klukkur/s (við töluðum um klukkuhraða í fyrri hlutanum).

Af hverju að senda leikuppfærslur eingöngu í gegnum tíðina ? Til að vista rás. 30 leikjauppfærslur á sekúndu er mikið!

Af hverju ekki bara hringja update() 30 sinnum á sekúndu? Til að bæta uppgerð leiksins. Því oftar kallað update(), því nákvæmari verður leikjauppgerðin. En ekki láta þér líða of mikið með fjölda áskorana. update(), vegna þess að þetta er reikningslega dýrt verkefni - 60 á sekúndu er nóg.

Restin af bekknum Game samanstendur af hjálparaðferðum sem notaðar eru í update():

game.js hluti 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() frekar einfalt - það flokkar leikmenn eftir stigum, tekur fimm efstu og skilar notandanafni og skori fyrir hvern.

createUpdate() notað í update() að búa til leikjauppfærslur sem dreift er til leikmanna. Meginverkefni þess er að kalla aðferðir serializeForUpdate()útfært fyrir bekki Player и Bullet. Athugaðu að það sendir aðeins gögn til hvers leikmanns um næst leikmenn og skotfæri - það er engin þörf á að senda upplýsingar um leikhluti sem eru langt frá spilaranum!

3. Leikjahlutir á þjóninum

Í leiknum okkar eru skotfæri og leikmenn í raun mjög lík: þau eru óhlutbundin, kringlótt, hreyfanleg leikhlutir. Til að nýta þessa líkingu milli leikmanna og skotvopna, skulum við byrja á því að útfæra grunnflokkinn 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,
    };
  }
}

Hér er ekkert flókið í gangi. Þessi bekkur verður góður akkeristaður fyrir framlenginguna. Við skulum sjá hvernig bekkurinn Bullet notar 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;
  }
}

Framkvæmd Bullet mjög stutt! Við höfum bætt við Object aðeins eftirfarandi viðbætur:

  • Að nota pakka stuttur fyrir handahófskennda kynslóð id skotfæri.
  • Að bæta við reit parentIDsvo að þú getir fylgst með spilaranum sem bjó þetta skotfæri til.
  • Að bæta ávöxtunargildi við update(), sem jafngildir trueef skotið er fyrir utan leikvanginn (manstu að við töluðum um þetta í síðasta kafla?).

Við skulum halda áfram að Player:

leikmaður.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,
    };
  }
}

Spilarar eru flóknari en skotfæri og því ætti að geyma nokkra fleiri velli í þessum flokki. Aðferð hans update() vinnur mikið, sérstaklega, skilar nýstofnuðu skoti ef það er ekkert eftir fireCooldown (Manstu að við töluðum um þetta í fyrri hlutanum?). Einnig framlengir það aðferðina serializeForUpdate(), vegna þess að við þurfum að hafa fleiri reiti fyrir leikmanninn í leikuppfærslunni.

Að hafa grunnflokk Object - mikilvægt skref til að forðast að endurtaka kóða. Til dæmis enginn flokkur Object hver leikhlutur verður að hafa sömu útfærslu distanceTo(), og afrita-líma allar þessar útfærslur yfir margar skrár væri martröð. Þetta verður sérstaklega mikilvægt fyrir stór verkefni.þegar fjöldi stækka Object bekkjum fjölgar.

4. Árekstursgreining

Það eina sem eftir er fyrir okkur er að viðurkenna hvenær skotfærin lenda á leikmönnunum! Mundu þennan kóða úr aðferðinni update() í tíma Game:

leikur.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),
    );

    // ...
  }
}

Við þurfum að innleiða aðferðina applyCollisions(), sem skilar öllum skotum sem lenda á leikmönnum. Sem betur fer er það ekki svo erfitt að gera vegna þess

  • Allir hlutir sem rekast á eru hringir, sem er einfaldasta lögunin til að útfæra árekstursgreiningu.
  • Við höfum nú þegar aðferð distanceTo(), sem við útfærðum í fyrri hlutanum í bekknum Object.

Svona lítur útfærsla okkar á árekstrargreiningu út:

árekstrar.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;
}

Þessi einfalda árekstrargreining byggir á því að tveir hringir rekast á ef fjarlægðin milli miðju þeirra er minni en summan af geisla þeirra. Hér er tilfellið þar sem fjarlægðin milli miðju tveggja hringja er nákvæmlega jöfn summu geisla þeirra:

Að búa til fjölspilunar .io vefleik
Það eru nokkrir þættir í viðbót sem þarf að huga að hér:

  • Skotið má ekki lenda í leikmanninum sem bjó það til. Þetta er hægt að ná með því að bera saman bullet.parentID с player.id.
  • Skotið má aðeins lenda einu sinni í því takmarkandi tilviki að margir leikmenn rekast á sama tíma. Við munum leysa þetta vandamál með því að nota símafyrirtækið break: um leið og spilarinn sem rekst á skotið finnst, hættum við leitinni og förum yfir í næsta skot.

End

Það er allt og sumt! Við höfum farið yfir allt sem þú þarft að vita til að búa til .io vefleik. Hvað er næst? Byggðu þinn eigin .io leik!

Allur sýnishornskóði er opinn og birtur á GitHub.

Heimild: www.habr.com

Bæta við athugasemd