Krijimi i një loje ueb me shumë lojtarë .io

Krijimi i një loje ueb me shumë lojtarë .io
Lëshuar në 2015 Agar.io u bë paraardhësi i një zhanri të ri lojëra .ioe cila është rritur në popullaritet që atëherë. Unë personalisht kam përjetuar rritjen e popullaritetit të lojërave .io: gjatë tre viteve të fundit, kam përjetuar krijoi dhe shiti dy lojëra të këtij zhanri..

Në rast se nuk keni dëgjuar më parë për këto lojëra, këto janë lojëra uebi falas me shumë lojtarë që luhen lehtë (nuk kërkohet llogari). Ata zakonisht përballen me shumë lojtarë kundërshtarë në të njëjtën arenë. Lojëra të tjera të famshme .io: Slither.io и Diep.io.

Në këtë postim, ne do të shqyrtojmë se si krijoni një lojë .io nga e para. Për këtë, do të mjaftojë vetëm njohja e Javascript: duhet të kuptoni gjëra të tilla si sintaksa ES6, fjalë kyçe this и premtimet. Edhe nëse njohuritë tuaja për Javascript nuk janë perfekte, prapëseprapë mund ta kuptoni pjesën më të madhe të postimit.

Shembull i lojës .io

Për ndihmë mësimore, do t'i referohemi Shembull i lojës .io. Mundohuni ta luani!

Krijimi i një loje ueb me shumë lojtarë .io
Loja është mjaft e thjeshtë: ju kontrolloni një anije në një arenë ku ka lojtarë të tjerë. Anija juaj lëshon automatikisht predha dhe ju përpiqeni të godisni lojtarët e tjerë duke shmangur predhat e tyre.

1. Vështrim i shkurtër / struktura e projektit

Unë rekomandoj shkarko kodin burim lojë shembull që të mund të më ndiqni.

Shembulli përdor sa vijon:

  • Ekspres është korniza më e popullarizuar e uebit Node.js që menaxhon ueb serverin e lojës.
  • fole.io - një bibliotekë websocket për shkëmbimin e të dhënave midis një shfletuesi dhe një serveri.
  • Pako Uebi - menaxher i modulit. Mund të lexoni se pse të përdorni Webpack. këtu.

Ja se si duket struktura e drejtorisë së projektit:

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

publike/

Gjithçka në një dosje public/ do të dorëzohet në mënyrë statike nga serveri. NË public/assets/ përmban imazhe të përdorura nga projekti ynë.

src /

I gjithë kodi burim është në dosje src/. Titujt client/ и server/ flasin vetë dhe shared/ përmban një skedar konstante që importohet si nga klienti ashtu edhe nga serveri.

2. Asambletë/konfigurimet e projektit

Siç u përmend më lart, ne përdorim menaxherin e modulit për të ndërtuar projektin. Pako Uebi. Le të hedhim një vështrim në konfigurimin tonë të paketës së uebit:

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

Linjat më të rëndësishme këtu janë:

  • src/client/index.js është pika hyrëse e klientit Javascript (JS). Paketa e uebit do të fillojë nga këtu dhe do të kërkojë në mënyrë rekursive për skedarë të tjerë të importuar.
  • Dalja JS e ndërtimit tonë të Uebpack do të gjendet në drejtori dist/. Unë do ta quaj këtë skedar tonën js paketën.
  • Ne përdorim Babel, dhe në veçanti konfigurimin @babel/paravendosur-env për të transpiluar kodin tonë JS për shfletuesit më të vjetër.
  • Ne po përdorim një shtojcë për të nxjerrë të gjitha CSS-të e referuara nga skedarët JS dhe për t'i kombinuar ato në një vend. Unë do ta quaj atë tonën paketën css.

Ju mund të keni vënë re emra të çuditshëm të skedarëve të paketave '[name].[contenthash].ext'. Ato përmbajnë zëvendësimet e emrit të skedarit Paketimi në internet: [name] do të zëvendësohet me emrin e pikës hyrëse (në rastin tonë, kjo game), dhe [contenthash] do të zëvendësohet me një hash të përmbajtjes së skedarit. Ne e bëjmë atë për të optimizoni projektin për hash - ju mund t'u thoni shfletuesve të ruajnë në memorie paketat tona JS për një kohë të pacaktuar, sepse nëse një paketë ndryshon, atëherë ndryshon edhe emri i skedarit të saj (ndryshon contenthash). Rezultati përfundimtar do të jetë emri i skedarit të pamjes game.dbeee76e91a97d0c7207.js.

skedar webpack.common.js është skedari i konfigurimit bazë që ne importojmë në konfigurimet e zhvillimit dhe të projektit të përfunduar. Këtu është një shembull i konfigurimit të zhvillimit:

webpack.dev.js

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

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

Për efikasitet, ne përdorim në procesin e zhvillimit webpack.dev.js, dhe kalon në webpack.prod.jspër të optimizuar madhësitë e paketave gjatë vendosjes në prodhim.

Vendosja lokale

Unë rekomandoj instalimin e projektit në një makinë lokale në mënyrë që të mund të ndiqni hapat e renditur në këtë postim. Konfigurimi është i thjeshtë: së pari, sistemi duhet të jetë i instaluar Nyjë и NPM. Tjetra duhet të bëni

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

dhe ju jeni gati për të shkuar! Për të nisur serverin e zhvillimit, thjesht ekzekutoni

$ npm run develop

dhe shkoni te shfletuesi i internetit localhost: 3000. Serveri i zhvillimit do të rindërtojë automatikisht paketat JS dhe CSS ndërsa kodi ndryshon - thjesht rifresko faqen për të parë të gjitha ndryshimet!

3. Pikat e hyrjes së klientit

Le të zbresim në vetë kodin e lojës. Së pari na duhet një faqe index.html, kur vizitoni sajtin, shfletuesi do ta ngarkojë fillimisht. Faqja jonë do të jetë mjaft e thjeshtë:

index.html

Një shembull i lojës .io  LUAJ

Ky shembull kodi është thjeshtuar pak për qartësi, dhe unë do të bëj të njëjtën gjë me shumë nga shembujt e tjerë të postimit. Kodi i plotë mund të shihet gjithmonë në Github.

Ne kemi:

  • Elementi i kanavacës HTML5 (<canvas>) që do të përdorim për të dhënë lojën.
  • <link> për të shtuar paketën tonë CSS.
  • <script> për të shtuar paketën tonë Javascript.
  • Menuja kryesore me emrin e përdoruesit <input> dhe butonin PLAY (<button>).

Pas ngarkimit të faqes kryesore, shfletuesi do të fillojë të ekzekutojë kodin Javascript, duke filluar nga skedari JS i pikës hyrëse: src/client/index.js.

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

Kjo mund të tingëllojë e ndërlikuar, por këtu nuk po ndodh shumë:

  1. Importimi i disa skedarëve të tjerë JS.
  2. Importi CSS (kështu që Webpack di t'i përfshijë ato në paketën tonë CSS).
  3. lëshim connect() për të krijuar një lidhje me serverin dhe për të ekzekutuar downloadAssets() për të shkarkuar imazhet e nevojshme për të dhënë lojën.
  4. Pas përfundimit të fazës 3 shfaqet menyja kryesore (playMenu).
  5. Vendosja e mbajtësit për shtypjen e butonit "PLAY". Kur shtypet butoni, kodi inicializon lojën dhe i thotë serverit që jemi gati për të luajtur.

"Mishi" kryesor i logjikës sonë klient-server është në ato skedarë që janë importuar nga skedari index.js. Tani do t'i konsiderojmë të gjitha në rregull.

4. Shkëmbimi i të dhënave të klientit

Në këtë lojë, ne përdorim një bibliotekë të njohur për të komunikuar me serverin fole.io. Socket.io ka mbështetje amtare priza ueb, të cilat janë të përshtatshme për komunikim të dyanshëm: ne mund të dërgojmë mesazhe në server и serveri mund të na dërgojë mesazhe në të njëjtën lidhje.

Do të kemi një dosje src/client/networking.jskush do të kujdeset për të gjithë komunikimi me serverin:

rrjetëzimi.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);
};

Ky kod gjithashtu është shkurtuar pak për qartësi.

Ekzistojnë tre veprime kryesore në këtë skedar:

  • Ne po përpiqemi të lidhemi me serverin. connectedPromise lejohet vetëm kur kemi krijuar një lidhje.
  • Nëse lidhja është e suksesshme, ne regjistrojmë funksionet e kthimit të thirrjes (processGameUpdate() и onGameOver()) për mesazhet që mund të marrim nga serveri.
  • Ne eksportojmë play() и updateDirection()në mënyrë që skedarët e tjerë t'i përdorin ato.

5. Klient Rendering

Është koha për të shfaqur foton në ekran!

…por përpara se ta bëjmë këtë, ne duhet të shkarkojmë të gjitha imazhet (burimet) që nevojiten për këtë. Le të shkruajmë një menaxher burimesh:

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

Menaxhimi i burimeve nuk është aq i vështirë për t'u zbatuar! Ideja kryesore është ruajtja e një objekti assets, i cili do të lidhë çelësin e emrit të skedarit me vlerën e objektit Image. Kur burimi është i ngarkuar, ne e ruajmë atë në një objekt assets për akses të shpejtë në të ardhmen. Kur do të lejohet të shkarkohet çdo burim individual (d.m.th. të gjithë burimet), ne lejojmë downloadPromise.

Pas shkarkimit të burimeve, mund të filloni të jepni. Siç u tha më herët, për të vizatuar në një faqe interneti, ne përdorim Kanavacë HTML5 (<canvas>). Loja jonë është mjaft e thjeshtë, kështu që na duhet vetëm të vizatojmë sa vijon:

  1. sfond
  2. Anija e lojtarëve
  3. Lojtarët e tjerë në lojë
  4. predha

Këtu janë fragmentet e rëndësishme src/client/render.js, të cilat japin saktësisht katër artikujt e listuar më sipër:

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

Ky kod është gjithashtu i shkurtuar për qartësi.

render() është funksioni kryesor i këtij skedari. startRendering() и stopRendering() kontrolloni aktivizimin e ciklit render në 60 FPS.

Implementime konkrete të funksioneve ndihmëse të paraqitjes individuale (p.sh. renderBullet()) nuk janë aq të rëndësishme, por këtu është një shembull i thjeshtë:

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

Vini re se ne po përdorim metodën getAsset(), e cila ishte parë më parë në asset.js!

Nëse jeni të interesuar të mësoni rreth ndihmësve të tjerë të paraqitjes, atëherë lexoni pjesën tjetër. src/client/render.js.

6. Të dhëna të klientit

Është koha për të bërë një lojë të luajtshme! Skema e kontrollit do të jetë shumë e thjeshtë: për të ndryshuar drejtimin e lëvizjes, mund të përdorni miun (në një kompjuter) ose të prekni ekranin (në një pajisje celulare). Për ta zbatuar këtë, ne do të regjistrohemi Dëgjuesit e ngjarjeve për ngjarjet e miut dhe prekjes.
Do të kujdeset për të gjitha këto src/client/input.js:

hyrje.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() janë dëgjues të ngjarjeve që telefonojnë updateDirection() (nga networking.js) kur ndodh një ngjarje hyrëse (për shembull, kur miu lëvizet). updateDirection() trajton mesazhet me serverin, i cili trajton ngjarjen hyrëse dhe përditëson gjendjen e lojës në përputhje me rrethanat.

7. Statusi i klientit

Ky seksion është më i vështiri në pjesën e parë të postimit. Mos u dekurajoni nëse nuk e kuptoni herën e parë që e lexoni! Ju madje mund ta kaloni atë dhe t'i ktheheni më vonë.

Pjesa e fundit e enigmës që nevojitet për të plotësuar kodin e klientit/serverit është ishin. E mbani mend fragmentin e kodit nga seksioni "Përkthimi i klientit"?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() duhet të jetë në gjendje të na japë gjendjen aktuale të lojës në klient në çdo moment në kohë bazuar në përditësimet e marra nga serveri. Këtu është një shembull i një përditësimi të lojës që serveri mund të dërgojë:

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

Çdo përditësim i lojës përmban pesë fusha identike:

  • t: Vula kohore e serverit që tregon se kur është krijuar ky përditësim.
  • me: Informacion rreth lojtarit që merr këtë përditësim.
  • të tjerët: Një grup informacioni rreth lojtarëve të tjerë që marrin pjesë në të njëjtën lojë.
  • plumba: një grup informacioni rreth predhave në lojë.
  • Fituesit: Të dhënat aktuale të tabelës së drejtuesve. Në këtë postim, ne nuk do t'i konsiderojmë ato.

7.1 Gjendja naive e klientit

Zbatim naiv getCurrentState() mund të kthejë vetëm drejtpërdrejt të dhënat e përditësimit të lojës më të fundit të marrë.

naiv-shtet.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

E bukur dhe e qartë! Por sikur të ishte kaq e thjeshtë. Një nga arsyet pse ky zbatim është problematik: ai kufizon shpejtësinë e kuadrit të interpretimit në shpejtësinë e orës së serverit.

Shpejtësia e kornizës: numri i kornizave (d.m.th. thirrjet render()) për sekondë, ose FPS. Lojërat zakonisht përpiqen të arrijnë të paktën 60 FPS.

Shkalla e shënimit: Frekuenca në të cilën serveri u dërgon klientëve përditësimet e lojës. Shpesh është më e ulët se shpejtësia e kornizës. Në lojën tonë, serveri funksionon me një frekuencë prej 30 ciklesh në sekondë.

Nëse thjesht japim përditësimin më të fundit të lojës, atëherë FPS në thelb nuk do të kalojë kurrë mbi 30, sepse ne kurrë nuk marrim më shumë se 30 përditësime në sekondë nga serveri. Edhe sikur të thërrasim render() 60 herë në sekondë, atëherë gjysma e këtyre thirrjeve thjesht do të rivizatojnë të njëjtën gjë, në thelb duke mos bërë asgjë. Një problem tjetër me zbatimin naiv është se ai të prirur për vonesa. Me një shpejtësi ideale të internetit, klienti do të marrë një përditësim të lojës saktësisht çdo 33 ms (30 për sekondë):

Krijimi i një loje ueb me shumë lojtarë .io
Fatkeqësisht, asgjë nuk është perfekte. Një pamje më realiste do të ishte:
Krijimi i një loje ueb me shumë lojtarë .io
Zbatimi naiv është praktikisht rasti më i keq kur bëhet fjalë për latente. Nëse një përditësim i lojës merret me një vonesë prej 50 ms, atëherë tezgat e klientëve një shtesë prej 50 ms sepse është ende duke e dhënë gjendjen e lojës nga përditësimi i mëparshëm. Ju mund të imagjinoni se sa e pakëndshme është kjo për lojtarin: frenimi arbitrar do ta bëjë lojën të ndihet e vrullshme dhe e paqëndrueshme.

7.2 Përmirësimi i gjendjes së klientit

Do të bëjmë disa përmirësime në zbatimin naiv. Së pari, ne përdorim vonesa e paraqitjes për 100 ms. Kjo do të thotë që gjendja "aktuale" e klientit gjithmonë do të mbetet prapa gjendjes së lojës në server me 100ms. Për shembull, nëse koha në server është 150, atëherë klienti do të japë gjendjen në të cilën serveri ishte në atë kohë 50:

Krijimi i një loje ueb me shumë lojtarë .io
Kjo na jep një tampon 100ms për t'i mbijetuar kohërave të paparashikueshme të përditësimit të lojës:

Krijimi i një loje ueb me shumë lojtarë .io
Shpërblimi për këtë do të jetë i përhershëm vonesa në hyrje për 100 ms. Kjo është një sakrificë e vogël për një lojë të qetë - shumica e lojtarëve (veçanërisht lojtarët e rastësishëm) as që do ta vërejnë këtë vonesë. Është shumë më e lehtë për njerëzit që të përshtaten me një vonesë konstante prej 100 ms sesa të luajnë me një vonesë të paparashikueshme.

Mund të përdorim edhe një teknikë tjetër të quajtur parashikimi nga ana e klientit, i cili bën një punë të mirë për të reduktuar vonesën e perceptuar, por nuk do të mbulohet në këtë postim.

Një përmirësim tjetër që po përdorim është interpolimi linear. Për shkak të vonesës së paraqitjes, zakonisht jemi të paktën një përditësim përpara kohës aktuale në klient. Kur thirret getCurrentState(), ne mund të ekzekutojmë interpolimi linear ndërmjet përditësimeve të lojës pak para dhe pas kohës aktuale në klient:

Krijimi i një loje ueb me shumë lojtarë .io
Kjo zgjidh çështjen e shpejtësisë së kuadrove: tani mund të japim korniza unike me çdo shpejtësi të kuadrove që duam!

7.3 Zbatimi i gjendjes së përmirësuar të klientit

Shembulli i zbatimit në src/client/state.js përdor si vonesën e renderit ashtu edhe interpolimin linear, por jo për një kohë të gjatë. Le ta ndajmë kodin në dy pjesë. Këtu është i pari:

shtet.js pjesa 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;
}

Hapi i parë është të kuptoni se çfarë currentServerTime(). Siç e pamë më herët, çdo përditësim i lojës përfshin një vulë kohore të serverit. Ne duam të përdorim vonesën e renderit për të dhënë imazhin 100ms pas serverit, por ne kurrë nuk do ta dimë kohën aktuale në server, sepse nuk mund ta dimë se sa kohë u desh që ndonjë nga përditësimet të na arrinte. Interneti është i paparashikueshëm dhe shpejtësia e tij mund të ndryshojë shumë!

Për të kapërcyer këtë problem, ne mund të përdorim një përafrim të arsyeshëm: ne pretendoni se përditësimi i parë mbërriti menjëherë. Nëse kjo do të ishte e vërtetë, atëherë ne do ta dinim kohën e serverit në këtë moment të veçantë! Ne ruajmë vulën kohore të serverit në firstServerTimestamp dhe mbajeni tonën lokal vula kohore (klienti) në të njëjtin moment në gameStart.

Ah prisni. A nuk duhet të jetë koha e serverit = koha e klientit? Pse bëjmë dallimin midis "vulës kohore të serverit" dhe "vulës kohore të klientit"? Kjo është një pyetje e madhe! Rezulton se nuk janë e njëjta gjë. Date.now() do të kthejë stampa të ndryshme kohore në klient dhe server, dhe kjo varet nga faktorë lokalë për këto makina. Asnjëherë mos supozoni se vulat kohore do të jenë të njëjta në të gjitha makinat.

Tani e kuptojmë se çfarë bën currentServerTime(): kthehet vulën kohore të serverit të kohës aktuale të paraqitjes. Me fjalë të tjera, kjo është koha aktuale e serverit (firstServerTimestamp <+ (Date.now() - gameStart)) minus vonesa e paraqitjes (RENDER_DELAY).

Tani le të hedhim një vështrim se si i trajtojmë përditësimet e lojës. Kur merret nga serveri i përditësimit, thirret processGameUpdate()dhe ne e ruajmë përditësimin e ri në një grup gameUpdates. Më pas, për të kontrolluar përdorimin e memories, ne heqim të gjitha përditësimet e vjetra më parë përditësimi bazësepse nuk na duhen më.

Çfarë është një "përditësim bazë"? Kjo përditësimin e parë e gjejmë duke lëvizur prapa nga koha aktuale e serverit. E mbani mend këtë diagram?

Krijimi i një loje ueb me shumë lojtarë .io
Përditësimi i lojës direkt në të majtë të "Client Render Time" është përditësimi bazë.

Për çfarë përdoret përditësimi bazë? Pse mund t'i hedhim përditësimet në bazë? Për ta kuptuar këtë, le ta kuptojmë me ne fund konsideroni zbatimin getCurrentState():

shtet.js pjesa 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),
    };
  }
}

Ne trajtojmë tre raste:

  1. base < 0 do të thotë që nuk ka përditësime deri në kohën aktuale të renderimit (shih zbatimin e mësipërm getBaseUpdate()). Kjo mund të ndodhë pikërisht në fillim të lojës për shkak të vonesës së paraqitjes. Në këtë rast, ne përdorim përditësimin më të fundit të marrë.
  2. base është përditësimi më i fundit që kemi. Kjo mund të jetë për shkak të vonesës së rrjetit ose lidhjes së dobët të internetit. Në këtë rast, ne po përdorim edhe përditësimin më të fundit që kemi.
  3. Ne kemi një përditësim para dhe pas kohës aktuale të renderimit, kështu që mundemi interpoloj!

Gjithçka që ka mbetur brenda state.js është një zbatim i interpolimit linear që është matematikë e thjeshtë (por e mërzitshme). Nëse dëshironi ta eksploroni vetë, atëherë hapeni state.js mbi Github.

Pjesa 2. Serveri Backend

Në këtë pjesë, ne do t'i hedhim një vështrim bazës Node.js që kontrollon tonën Shembull i lojës .io.

1. Pika hyrëse e serverit

Për të menaxhuar serverin në internet, ne do të përdorim një kornizë të njohur të uebit për Node.js të quajtur Ekspres. Do të konfigurohet nga skedari i pikës së hyrjes së serverit tonë src/server/server.js:

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

Mos harroni se në pjesën e parë diskutuam Webpack? Këtu do të përdorim konfigurimet tona të paketës së uebit. Ne do t'i përdorim ato në dy mënyra:

  • për t'u përdorur webpack-dev-middleware për të rindërtuar automatikisht paketat tona të zhvillimit, ose
  • transferimi statik i dosjes dist/, në të cilën Webpack do të shkruajë skedarët tanë pas ndërtimit të prodhimit.

Një detyrë tjetër e rëndësishme server.js është konfigurimi i serverit fole.ioi cili thjesht lidhet me serverin Express:

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

Pas vendosjes me sukses të një lidhjeje socket.io me serverin, ne konfiguruam mbajtësit e ngjarjeve për prizën e re. Trajtuesit e ngjarjeve trajtojnë mesazhet e marra nga klientët duke i deleguar në një objekt të vetëm game:

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

Ne po krijojmë një lojë .io, kështu që na duhet vetëm një kopje Game ("Lojë") - të gjithë lojtarët luajnë në të njëjtën arenë! Në pjesën tjetër, do të shohim se si funksionon kjo klasë. Game.

2. Serverët e lojërave

Klasë Game përmban logjikën më të rëndësishme në anën e serverit. Ajo ka dy detyra kryesore: menaxhimi i lojtarëve и simulimi i lojës.

Le të fillojmë me detyrën e parë, menaxhimin e lojtarëve.

loja.js pjesa 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);
    }
  }

  // ...
}

Në këtë lojë, ne do të identifikojmë lojtarët sipas fushës id priza e tyre socket.io (nëse hutoheni, atëherë kthehuni te server.js). Vetë Socket.io i cakton çdo fole një të veçantë idkështu që ne nuk duhet të shqetësohemi për këtë. Unë do ta thërras atë ID e lojtarit.

Me këtë në mendje, le të eksplorojmë variablat e shembullit në një klasë Game:

  • sockets është një objekt që lidh ID-në e luajtësit me folenë që është e lidhur me luajtësin. Na lejon të qasemi në bazat me ID-të e lojtarëve të tyre në një kohë konstante.
  • players është një objekt që lidh ID-në e lojtarit me kodin>Objekt lojtari

bullets është një grup objektesh Bullet, e cila nuk ka një rend të caktuar.
lastUpdateTime është vula kohore e herës së fundit që loja është përditësuar. Do të shohim se si do të përdoret së shpejti.
shouldSendUpdate është një variabël ndihmës. Ne do të shohim gjithashtu përdorimin e tij së shpejti.
metodat addPlayer(), removePlayer() и handleInput() nuk ka nevojë për shpjegim, ato përdoren në server.js. Nëse keni nevojë të rifreskoni kujtesën, kthehuni pak më lart.

Rreshti i fundit constructor() fillon cikli i përditësimit lojëra (me një frekuencë prej 60 përditësime / s):

loja.js pjesa 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() përmban ndoshta pjesën më të rëndësishme të logjikës nga ana e serverit. Ja çfarë bën, sipas renditjes:

  1. Llogarit sa kohë dt kaluar që nga e fundit update().
  2. Rifreskon çdo predhë dhe i shkatërron nëse është e nevojshme. Ne do të shohim zbatimin e këtij funksioni më vonë. Për momentin, mjafton ta dimë këtë bullet.update() kthehet truenëse predha duhet të shkatërrohet (ai doli nga arena).
  3. Përditëson çdo lojtar dhe nxjerr një predhë nëse është e nevojshme. Ne gjithashtu do ta shohim këtë zbatim më vonë - player.update() mund të kthejë një objekt Bullet.
  4. Kontrollon për përplasje midis predhave dhe lojtarëve me applyCollisions(), i cili kthen një sërë predhash që godasin lojtarët. Për çdo predhë të kthyer, ne rrisim pikët e lojtarit që e ka gjuajtur (duke përdorur player.onDealtDamage()) dhe më pas hiqni predhën nga grupi bullets.
  5. Njofton dhe shkatërron të gjithë lojtarët e vrarë.
  6. Dërgon një përditësim të lojës për të gjithë lojtarët çdo sekond herë kur thirret update(). Kjo na ndihmon të mbajmë gjurmët e ndryshores ndihmëse të përmendur më sipër. shouldSendUpdate... Si update() thirrur 60 herë/s, ne dërgojmë përditësime të lojës 30 herë/s. Kështu, frekuenca e orës Ora e serverit është 30 orë/s (kemi folur për normat e orës në pjesën e parë).

Pse të dërgoni vetëm përditësime të lojës Përmes kohës ? Për të ruajtur kanalin. 30 përditësime të lojës në sekondë janë shumë!

Pse jo thjesht telefononi update() 30 herë në sekondë? Për të përmirësuar simulimin e lojës. Më shpesh quhet update(), aq më i saktë do të jetë simulimi i lojës. Por mos u hutoni shumë me numrin e sfidave. update(), sepse kjo është një detyrë llogaritëse e shtrenjtë - mjaftojnë 60 për sekondë.

Pjesa tjetër e klasës Game përbëhet nga metoda ndihmëse të përdorura në update():

loja.js pjesa 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() shumë e thjeshtë - i rendit lojtarët sipas rezultateve, merr pesëshen e parë dhe kthen emrin e përdoruesit dhe pikët për secilin.

createUpdate() përdorur në update() për të krijuar përditësime të lojës që u shpërndahen lojtarëve. Detyra e tij kryesore është të thërrasë metoda serializeForUpdate()zbatohet për klasa Player и Bullet. Vini re se ai i kalon të dhëna vetëm secilit lojtar rreth më i afërt lojtarët dhe predha - nuk ka nevojë të transmetoni informacione për objektet e lojës që janë larg lojtarit!

3. Objektet e lojës në server

Në lojën tonë, predha dhe lojtarët janë në të vërtetë shumë të ngjashëm: ato janë objekte abstrakte, të rrumbullakëta, të lëvizshme të lojës. Për të përfituar nga kjo ngjashmëri midis lojtarëve dhe predhave, le të fillojmë duke zbatuar klasën bazë Object:

objekt.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,
    };
  }
}

Nuk ka asgjë të komplikuar këtu. Kjo klasë do të jetë një pikë e mirë ankorimi për zgjerimin. Le të shohim se si klasa Bullet përdor Object:

plumb.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;
  }
}

Zbatimi Bullet shume shkurt! Ne kemi shtuar në Object vetëm shtesat e mëposhtme:

  • Duke përdorur një paketë i shkurtër për gjenerim të rastësishëm id predhë.
  • Shtimi i një fushe parentIDnë mënyrë që të mund të gjurmoni lojtarin që krijoi këtë predhë.
  • Shtimi i një vlere kthimi në update(), e cila është e barabartë me truenëse predha është jashtë arenës (kujtoni që folëm për këtë në pjesën e fundit?).

Le të kalojmë në Player:

lojtar.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,
    };
  }
}

Lojtarët janë më kompleks se predha, kështu që disa fusha të tjera duhet të ruhen në këtë klasë. Metoda e tij update() bën shumë punë, në veçanti, kthen predhën e krijuar rishtazi nëse nuk ka mbetur fireCooldown (ju kujtohet se folëm për këtë në pjesën e mëparshme?). Gjithashtu zgjeron metodën serializeForUpdate(), sepse duhet të përfshijmë fusha shtesë për lojtarin në përditësimin e lojës.

Të kesh një klasë bazë Object - një hap i rëndësishëm për të shmangur përsëritjen e kodit. Për shembull, nuk ka klasë Object çdo objekt loje duhet të ketë të njëjtin zbatim distanceTo(), dhe kopjimi-ngjitja e të gjitha këtyre zbatimeve nëpër skedarë të shumtë do të ishte një makth. Kjo bëhet veçanërisht e rëndësishme për projektet e mëdha.kur numri i zgjerimit Object klasat po rriten.

4. Zbulimi i përplasjeve

E vetmja gjë që na mbetet është të njohim se kur predha goditën lojtarët! Mbani mend këtë pjesë të kodit nga metoda update() në klasë Game:

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

    // ...
  }
}

Duhet të zbatojmë metodën applyCollisions(), i cili kthen të gjitha predhat që godasin lojtarët. Për fat të mirë, nuk është aq e vështirë të bëhet sepse

  • Të gjitha objektet që përplasen janë rrathë dhe kjo është forma më e thjeshtë për të zbatuar zbulimin e përplasjeve.
  • Ne tashmë kemi një metodë distanceTo(), të cilin e zbatuam në pjesën e mëparshme në klasë Object.

Ja se si duket zbatimi ynë i zbulimit të përplasjeve:

përplasjet.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;
}

Ky zbulim i thjeshtë i përplasjes bazohet në faktin se dy rrathë përplasen nëse distanca ndërmjet qendrave të tyre është më e vogël se shuma e rrezeve të tyre. Këtu është rasti kur distanca midis qendrave të dy rrathëve është saktësisht e barabartë me shumën e rrezeve të tyre:

Krijimi i një loje ueb me shumë lojtarë .io
Ka disa aspekte të tjera për t'u marrë parasysh këtu:

  • Predha nuk duhet të godasë lojtarin që e ka krijuar atë. Kjo mund të arrihet duke krahasuar bullet.parentID с player.id.
  • Predha duhet të godasë vetëm një herë në rastin kufizues të përplasjes së shumë lojtarëve në të njëjtën kohë. Ne do ta zgjidhim këtë problem duke përdorur operatorin break: sapo të gjendet lojtari që përplaset me predhën, ndalojmë kërkimin dhe kalojmë në predhën tjetër.

Fund

Kjo eshte e gjitha! Ne kemi mbuluar gjithçka që ju duhet të dini për të krijuar një lojë në internet .io. Ç'pritet më tej? Ndërtoni lojën tuaj .io!

I gjithë kodi i mostrës është me burim të hapur dhe postohet në Github.

Burimi: www.habr.com

Shto një koment