Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Lançuar në vitin 2015 Agar.io u bë paraardhësi i një zhanri të ri lojërash .io, popullariteti i të cilit është rritur ndjeshëm që atëherë. Rritja e popullaritetit të lojërave .io e kam përjetuar vetë: në tri vitet e fundit unë krijova dhe shita dy lojëra të këtij zhanri..

Për rastin, nëse nuk keni dëgjuar ndonjëherë për këto lojëra: ato janë lojëra shumëpalëshe falas në web, në të cilat është e lehtë të merrni pjesë (nuk kërkohet llogari). Zakonisht, ato përballin shumë lojtarë në një arenë. Lojëra të tjera të njohura të zhanrit .io: Slither.io dhe Diep.io.

Në këtë post, ne do të shqyrtojmë se si nga zero të krijoni një lojë .io. Për këtë mjafton vetëm njohja e Javascript: ju duhet të kuptoni gjëra të tilla si sintaksën ES6, fjala kyçe this dhe Promises. Edhe nëse e dini Javascriptin jo në përkryer, akoma do të jeni në gjendje të kuptoni pjesën më të madhe të postit.

Shembulli i lojës .io

Për të ndihmuar në mësim, ne do të referohemi në shembullin e lojës .io. Provoni ta luani atë!

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Lojë është mjaft e thjeshtë: ju menaxhoni një anije në arenë, ku ka lojtarë të tjerë. Anija juaj sulmon automatikisht me plumba dhe ju përpiqeni të godisni lojtarët e tjerë, duke shmangur për herë njëkohësisht plumbat e tyre.

1. Përmbledhje e shkurtër/strukturë e projektit

Rekomandoj të shkarkoni kodin burimor të shembullit të lojës, në mënyrë që të mund të më ndiqni.

Në shembull përdoren këto:

  • Express — framework-u më i njohur i webit për Node.js, që menaxhon serverin e lojës.
  • socket.io — bibliotekë websocket për shkëmbim të të dhënave mes shfletuesit dhe serverit.
  • Webpack — menaxher moduli. Më shumë përse përdorim Webpack, mund të lexoni këtu.

Ja si duket struktura e katalogut të projektit:

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

public/

Gjithçka në dosjen public/ do të shpërndahet statikisht nga serveri. Në public/assets/ janë imazhet që përdor projekti ynë.

src/

I gjithë kodi burimor ndodhet në dosjen src/. Emrat client/ dhe server/ flasin vetë, dhe shared/ përmban një skedë konstantash, që importohet si nga klienti ashtu edhe nga serveri.

2. Ndërtimet/parameterët e projektit

Siç u tha më sipër, për ndërtimin e projektit ne përdorim menaxherin e modulit Webpack. Le të hedhim një vështrim në konfigurimin tonë të 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',
    }),
  ],
};

Rreshtore më të rëndësishme këtu janë rreshtat e mëposhtëm:

  • src/client/index.js — kjo është pika hyrëse e klientit Javascript (JS). Webpack do të fillojë nga këtu dhe do të kërkojë në mënyrë rekursive skedarët e tjerë të importuar.
  • JS i daljes nga ndërtimi ynë Webpack do të shtrihet në katalogun dist/. Unë do ta quaj këtë skedar paketin tonë JS.
  • Ne përdorim Babel, dhe veçanërisht konfigurimin @babel/preset-env për transpilen (transpiling) kodin tonë JS për shfletuesit e vjetër.
  • Ne përdorim një plugin për të nxjerrë të gjithë CSS-in që i referohen skedarëve JS, dhe për t'i bashkuar ato në një vend. Unë do ta quaj këtë paketën tonë CSS.

Mund të keni vënë re emrat e çuditshëm të skedarëve të paketave '[name].[contenthash].ext'. Ata përmbajnë zhvendosjet e emrave të skedarëve Webpack: [name] do të zëvendësohet me emrin e pikës hyrëse (në rastin tonë, game— emri i funksionit ( [contenthash] do të zëvendësohet me hash-in e përmbajtjes së skedarit. Ne e bëjmë këtë për të optimizuar projektin për hashimin — mund të urdhërohen shfletuesit të ruajnë pafundësisht paketat tona JS, sepse nëse paketa ndryshon, atëherë ndryshon edhe emri i skedarit (ndryshohet contenthash). Rezultati i gatshëm do të jetë emri i skedarit në format game.dbeee76e91a97d0c7207.js.

Skeda webpack.common.js — ky është skedari bazë i konfigurimit që ne e importojmë në konfigurimet e zhvillimit dhe të projektit të gatshëm. Ja, për shembull, konfigurimi i 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.js, për të optimizuar madhësitë e paketave gjatë zhvillimit në prodhim.

Konfigurimi lokal

Të rekomandoj të instalohet projekti në makinën lokale, në mënyrë që të mundesh të ndjekësh fazat e listuara në këtë postim. Konfigurimi është i thjeshtë: së pari, sistemi duhet të ketë të instaluar Nyja dhe NPM. Më pas duhet të kryeni

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

dhe jeni gati për punë! Për të nisur serverin e zhvillimit mjafton të ekzekutoni

$ npm run develop

dhe të hyni në shfletuesin e uebit në localhost:3000. Serveri i zhvillimit do të rindërtojë automatikisht paketat JS dhe CSS gjatë ndryshimit të kodit — thjesht rifreskoni faqen për të parë të gjitha ndryshimet!

3. Pikat e hyrjes të klientit

Le të kalojmë tek vetë kodi i lojës. Për fillim na nevojitet një faqe index.html, kur të vizitohet faqja, shfletuesi do ta ngarkojë atë si të parën. Faqja jonë do të jetë mjaft e thjeshtë:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Një shembull loje .io</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Username" />
    <button id="play-button">LUJAT</button>
  </div>
</body>
</html>

Ky shembull kodi është lehtë i thjeshtuar për qartësi, po e njëjtën gjë do të bëj me shumë shembuj të tjerë në post. Kodi i plotë gjithmonë mund të shikohet në Github.

Kemi:

  • Elementi HTML5 Canvas (<canvas>), i cili do të përdoret për renderimin e lojës.
  • <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 butoni "PLAY" (

Pas ngarkimit të faqes kryesore në shfletues, do të fillojë ekzekutimi i kodit Javascript, duke filluar nga skedari i hyrjes JS: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Luaj!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Kjo mund të duket e komplikuar, por në të vërtetë këtu nuk ndodhin shumë veprime:

  1. Importimi i disa skedareve të tjerë JS.
  2. Importimi i CSS (që Webpack të dijë se duhet t'i përfshijë ato në paketën tonë CSS).
  3. Nisja connect() për të krijuar një lidhje me serverin dhe për të nisur downloadAssets() për të shkarkuar imazhet e nevojshme për renderimin e lojës.
  4. Pas përfundimit të fazës 3 shfaqet menuja kryesore (playMenu).
  5. Konfigurimi i trajtuesit të shtypjes së butonit "PLAY". Kur të shtypet butoni, kodi inicializon lojën dhe njofton serverin që ne jemi gati për të luajtur.

Mishi kryesor i logjikës tonë klient-server ndodhet në ato skedare, të cilat janë importuar nga skedari index.js. Tani do t'i shqyrtojmë ato të gjitha një nga një.

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

Në këtë lojë, për të komunikuar me serverin përdorim një bibliotekë shumë të njohur socket.io. Socket.io ka mbështetje të integruar WebSockets, të cilat janë shumë të përshtatshme për komunikim në të dyja drejtimet: mund të dërgojmë mesazhe në server dhe serveri mund të na dërgojë mesazhe në të njëjtin lidhje.

Do të kemi një skedë src/client/networking.js, e cila do të merret me të gjitha komunikimet me serverin:

networking.js

import io from 'socket.io-client';
import { processGameUpdate } from '.\/state';

const Constants = require('..\/shared\/constants');

const socket = io(`ws:\/\/${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('I lidhur me serverin!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Regjistrojmë pika kthimi
    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 është pak i shkurtuar për të qenë më i qartë.

Në këtë skedë ndodhin tri veprime kryesore:

  • Ne përpiqemi të lidhemi me serverin. connectedPromise zgjidhet vetëm kur ne kemi krijuar lidhjen.
  • Nëse lidhja ka qenë e suksesshme, regjistrojmë funksionet e kthimit (processGameUpdate() dhe onGameOver()) për mesazhet që mund të marrim nga serveri.
  • Eksportojmë play() dhe updateDirection(), për t'i përdorur në skeda të tjera.

5. Rendering i klientit

Ka ardhur koha të shfaqim një imazh në ekran!

…por para se të mund ta bëjmë këtë, duhet të shkarkojmë të gjitha imazhet (burimet) që janë të nevojshme për këtë. Le të shkruajmë menaxherin e burimeve:

assets.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`E shkarkuar ${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ë! E gjithë ideja është që të ruajmë një objekt assets, i cili do të lidhet çelësi i emrit të skedës me vlerën e objektit ProHoster rəyləri. Kur burimi të ngarkohet, ne e ruajmë atë në objekt assets për qasje të shpejtë në të ardhmen. Kur të jetë e mundur shkarkimi i çdo burimi të veçantë (dmth. kur janë ngarkuar të gjithë burimet), ne lejojmë downloadPromise.

Pas shkarkimit të burimeve, mund të fillojmë renderimin. Siç u tha më parë, për të vizatuar në faqen e internetit përdorim HTML5 Canvas (<canvas>). Loja jonë është mjaft e thjeshtë, kështu që na mjafton të vizatojmë vetëm këtë:

  1. Sfondi
  2. Anija e lojtarit
  3. Lojtarët e tjerë që janë në lojë
  4. Shkrepjet

Këtu janë fragmentet e rëndësishme src/client/render.js, të cilat vizatojnë pikërisht katër pikat e përmendura 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;

// Merr kontekstin grafik të kanavacës
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Bëni kanavacën me ekran të plotë
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Vizato sfondin
  renderBackground(me.x, me.y);

  // Vizato të gjitha shkrepjet
  bullets.forEach(renderBullet.bind(null, me));

  // Vizato të gjitha lojtarët
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funksionet ndihmëse këtu janë përjashtuar

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() dhe stopRendering() kontrollojnë aktivizimin e ciklit të vizatimit me frekuencë 60 FPS.

Implementimet specifike të funksioneve ndihmëse të vizatimit (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 përdorim metodën getAsset(), e cila u pa më parë në asset.js!

Nëse jeni të interesuar të studioni funksionet e tjera ndihmëse të vizatimit, lexoni pjesën tjetër të src/client/render.js.

6. Inputi i klientit

Ka arritur koha për të bërë lojën lojëra e luajnshme! Skema e kontrollit do të jetë shumë e thjeshtë: për të ndryshuar drejtimin e lëvizjes, mund të përdorni mouse-in (në kompjuter) ose prekjen e ekranit (në pajisjet mobile). Për ta realizuar këtë, ne do të regjistrojmë Event Listeners për ngjarjet Mouse dhe Touch.
Të gjitha këto do të merret me 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() dhe onTouchInput() — janë Event Listeners, që thërrasin updateDirection() (nga networking.js) kur ndodh ngjarja e inputit (p.sh., kur lëviz mouse-in). updateDirection() merret me shkëmbimin e mesazheve me serverin që përpunon ngjarjet e hyrjes dhe përditëson gjendjen e lojës përkatësisht.

7. Gjendja e klientit

Ky seksion është më i komplikuar në pjesën e parë të postit. Mos u shqetësoni nëse nuk e kuptoni në leximin e parë! Mund ta kaloni dhe të ktheheni më vonë.

Pjesa e fundit e puzzlet që na nevojitet për të përmbyllur kodin klient-server është state. Kujtoni fragmentin e kodit nga seksioni "Renderimi i klientit"?

render.js

import { getCurrentState } from './state';

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

  // Bëni renderimin
  // ...
}

getCurrentState() duhet të jetë në gjendje të na ofrojë gjendjen aktuale të lojës në klient në çdo moment për bazë të përditësimeve që merr nga serveri. Ja një shembull i një përditësimi të lojës që mund të dërgojë serveri:

{
  "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: shenja e kohës së serverit që tregon momentin e krijimit të këtij përditësimi.
  • me: informata për lojtarin që merr këtë përditësim.
  • others: një array informacioni për lojtarët e tjerë që po marrin pjesë në të njëjtën lojë.
  • bullets: një array informacioni për projektilët në lojë.
  • leaderboard: të dhënat aktuale të renditjes. Në këtë post ne nuk do t'i marrim parasysh ato.

7.1 Gjendja naive e klientit

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

naive-state.js

let lastGameUpdate = null;

// Përpunoni një përditësim të ri të lojës.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

E bukur dhe e qartë! Por nëse do të ishte kaq e thjeshtë. Një nga arsyet pse një zbatim i tillë është problematik: ajo kufizon frekuencën e kadrave të renderimit me frekuencën e taksave të serverit.

Frekuenca e Kadrave (Frame Rate): numri i kadrave (dmth. thirrjeve render()) në sekondë, ose FPS. Në lojëra zakonisht përpiqemi të arrijmë të paktën 60 FPS.

Frekuenca e Taksave (Tick Rate): frekuenca me të cilën serveri dërgon përditësime lojërash te klientët. Shpesh ajo është më e ulët se frekuenca e kadrave. Në lojën tonë serveri operon me frekuencë prej 30 taksave në sekondë.

Nëse ne thjesht do të rendertonim azhurnimin e fundit të lojës, atëherë FPS në thelb kurrë nuk do të mund të tejkalonte 30, sepse ne kurrë nuk marrim më shumë se 30 azhurnime nga serveri në sekondë. Edhe nëse do të thërrisnim render() 60 herë në sekondë, gjysma e këtyre thirrjeve do të ishte thjesht për të rinisur të njëjtën gjë, duke mos bërë në të vërtetë asgjë. Një problem tjetër me zbatimin naive është se ai është i ndjeshëm ndaj vonesave. Në shpejtësinë ideale të internetit, klienti do të merrte azhurnimin e lojës çdo 33 ms (30 në sekondë):

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Fatkeqësisht, asgjë nuk është perfekte. Një pamje më realiste do të ishte kjo:
Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Zbatimi naive është praktikisht rasti më i keq kur bëhet fjalë për vonesat. Nëse azhurnimi i lojës pranohet me një vonesë prej 50 ms, atëherë klienti do të ngecë për 50 ms të tjera, sepse ai akoma po rendit gjendjen e lojës nga azhurnimi i mëparshëm. Mund ta imagjinoni se sa e pakëndshme është kjo për lojtarin: për shkak të ngecjeve të rastësishme, loja do të duket e kërcyer dhe e pasigurt.

7.2 Gjendja e Përmirësuar e Klientit

Do të bëjmë disa përmirësime në zbatimin naive. Së pari, ne do të përdorim vonimin e renditjes për 100 ms. Kjo do të thotë se gjendja "aktuale" e klientit gjithmonë do të vonohet për 100 ms nga gjendja e lojës në server. Për shembull, nëse koha në server është 150, atëherë në klient do të renditet gjendja që kishte serveri gjatë 50:

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Kjo na jep një tampon prej 100 ms, që na lejon të kalojmë kohën e paparashikueshme të marrjes së azhurnimeve të lojës:

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Çmimi për këtë do të jetë një vonim të hyrjes (input lag) prej 100 ms. Kjo është një sakrifice e vogël për një proces lojërash të lëvizshëm — shumica e lojtarëve (sidomos ata kasualë) madje nuk do ta vërejnë këtë vonesë. Njerëzit e kanë shumë më të lehtë të adaptohen ndaj një vonese të vazhdueshme prej 100 ms sesa të luajnë me një vonesë të paparashikueshme.

Ne mund të përdorim edhe një teknikë tjetër të quajtur "parashikimi në anën e klientit", e cila bën mirë në uljen e vonesave të perceptuara, por në këtë postim nuk do të shqyrtohet.

Një përmirësim tjetër që përdorim është interpolimi linear. Për shkak të vonimit të renditjes, zakonisht ne jemi të paktën një azhurnim përpara kohës aktuale në klient. Kur thirret getCurrentState(), ne mund të kryejmë interpolim linear mes vitrënave të lojës menjëherë para dhe pas kohës aktuale në klient:

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Kjo zgjidh problemin e frekuencës së kadrave: tani mund të renderojmë kadrin unik me çdo të nevojshme frekuencë!

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

Shembulli i zbatimit në src/client/state.js përdor vonesën e renderimit dhe interpolimin linear, por kjo është për pak. Le të ndahemi në dy pjesë. Këtu është e para:

state.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);

  // Mbajmë vetëm një përditësim të lojës para kohës aktuale të serverit
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Kthen indeksin e përditësimit bazë, përditësimi i parë i lojës para
// kohës aktuale të serverit, ose -1 nëse 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;
}

Së pari duhet të kuptojmë se çfarë bën currentServerTime(). Siç e pamë më parë, gjithçka përditësim është përfshirë një shenjë kohe të serverit. Duam të përdorim vonesën e renderimit për të renderuar pamjen, duke u vonuar nga serveri me 100 ms, por kurrë nuk do ta dimë, koha aktuale në server, sepse nuk mund ta dimë se sa kohë i ka marrë ndonjë përditësim të vijë te ne. Interneti është i paparashikueshëm dhe shpejtësia e tij mund të varirojë shumë!

Për të anashkaluar këtë problem, mund të përdorim një aproksimim të arsyeshëm: ne do të bëjmë sikur përditësimi i parë ka arritur menjëherë. Nëse kjo do të ishte e vërtetë, do të dinit kohën e serverit në atë moment të caktuar! Ruajmë shenjën e kohës së serverit në firstServerTimestamp dhe ruajmë tonën lokale (klient) shenjë kohe në të njëjtin moment në gameStart.

Oh, ndaloni pak. A nuk duhet të jetë koha në server = koha në klient? Pse ne dallojmë "shenjat e kohës së serverit" dhe "shenjat e kohës së klientit"? Kjo është një pyetje e shkëlqyer! Dukesh se nuk është e njëjtë. Date.now() do të kthejë hapa të ndryshëm të kohës në klient dhe server dhe kjo varet nga faktorë lokalë për këto makina. Kurrë mos e prisni që shenjat e kohës të jenë të njëjta në të gjitha makinat.

Tani na është e qartë se çfarë bën currentServerTime(): ai kthen shenjen e kohës së serverit aktuale të renderimit. Në fjalë të tjera, kjo është koha aktuale e serverit (firstServerTimestamp < + (Date.now() - gameStart)) minus vonesën e renderimit (RENDER_DELAY).

Tani le t'u heqim një sy, se si i përpunojmë azhornimet e lojës. Kur merrni azhornimin nga serveri, thirret processGameUpdate(), dhe ne ruajmë azhornimin e ri në array gameUpdates. Pastaj, për të verifikuar përdorimin e memories, ne fshijmë të gjitha azhornimet e vjetra deri në azhornimin bazë, sepse ato nuk na nevojiten më.

Çfarë është azhornimi i bazës? Ajo është azhornimi i parë që gjejmë, duke u lëvizur prapa nga koha aktuale e serverit. E mbani mend këtë skemë?

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Azhornimi i lojës, i cili ndodhet menjëherë majtas "Koha e Renderimit të Klientit", është azhornimi bazë.

Për çfarë përdoret azhornimi i bazës? Pse mund të përjashtojmë azhornimet deri në bazë? Për ta kuptuar këtë, le të në fund të shohim implementimin getCurrentState():

state.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 përpunojmë tri raste:

  1. base < 0 do të thotë se nuk ka azhornime deri në kohën aktuale të renderimit (shih implementimin më lart getBaseUpdate()). Kjo mund të ndodhë menjëherë në fillim të lojës për shkak të vonesës së renderimit. Në këtë rast, ne përdorim azhornimin më të fundit që kemi marrë.
  2. base — është azhornimi më i fundit që kemi. Kjo mund të ndodhë për shkak të vonesës në rrjet ose lidhjes së dobët me Internetin. Në këtë rast, ne gjithashtu përdorim azhornimin më të fundit që kemi.
  3. Kemi azhornime para dhe pas kohës aktuale të renderimit, kështu që mund të interpolojmë!

Gjithçka që mbetet në state.js — është implementimi i interpolimit linear, i cili përbën matematikë të thjeshtë (por të mërzitshme). Nëse dëshironi ta studioni atë vetë, hidhni një sy state.js në Github.

Pjesa 2. Serveri i Pasëm

Në këtë pjesë, ne do të shqyrtojmë backend-in Node.js që menaxhon shembullin e lojës .io.

1. Pika e hyrjes së serverit

Për të menaxhuar serverin web, do të përdorim framework-un e njohur web për Node.js të quajtur Express. Cilësimi i tij do të merret nga skedari ynë i hyrjes së serverit 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 kujtoni se në pjesën e parë ne diskutonim për Webpack? Pikërisht këtu do të përdorim konfigurimet tona të Webpack. Do t'i zbatojmë ato në dy mënyra:

  • Përdorni webpack-dev-middleware për rindërtimin automatike të paketimeve tona zhvillimore, ose
  • Të shërbejë statikisht dosjen dist/, ku Webpack do të shkruajë skedaret tona pas ndërtimit të prodhimit.

Një tjetër detyrë e rëndësishme server.js është konfigurimi i serverit socket.io, i cili vetëm 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('Lojtari u lidh!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Pas lidhjes së suksesshme të socket.io me serverin, konfiguroni trajtuesit e ngjarjeve për soketin e ri. Trajtuesit e ngjarjeve trajtojnë mesazhet e marra nga klientët duke deleguar në objektin singleton 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 krijojmë një lojë të zhanrit .io, prandaj na nevojitet vetëm një instancë Game («Game») – të gjithë lojtarët luajnë në një arenë! Në seksionin e ardhshëm do të shohim se si funksionon ky klas Game.

2. Game serveri

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

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

game.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ë do të identifikojmë lojtarët sipas fushës id soketit të tyre socket.io (nëse po flakni, kthehuni përsëri te server.js). Socket.io cakton një identifikues unik për çdo soket id, prandaj nuk duhet të shqetësohemi për këtë. Do ta quaj ID e lojtarit.

Duke e mbajtur këtë mend, le të shqyrtojmë variablat e instancës në klasën Game:

  • sockets — është një objekt që lidhet ID e lojtarit me soketin që është i lidhur me lojtarin. Kjo na lejon të qasam për soketet sipas ID-ve të lojtarëve në kohë konstante.
  • players — është një objekt që lidh ID e lojtarit me objektin code>Player

bullets — është një array objektesh Bullet, pa një rend të caktuar.
lastUpdateTime — është një markë e kohës së fundit të përditësimit të lojës. Së shpejti do ta shohim se si përdoret ajo.
shouldSendUpdate — është një variabël ndihmës. Do ta shohim se si përdoret së shpejti.
Metodat addPlayer(), removePlayer() dhe handleInput() nuk ka nevojë për shpjegim, ato përdoren në server.js. Nëse keni nevojë për të rifreskuar memorien, kthehuni pak lart.

Rreshti i fundit constructor() nis ciklin e përditësimit të lojës (me një frekuencë prej 60 përditësimesh/s):

game.js, pjesa 2

const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');

class Game {
  \/\/ ...

  update() {
    \/\/ Llogaritë kohën e kaluar
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Përdorimi i secilës predhë
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Shkatërro këtë predhë
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Përdorimi i secilit lojtar
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Zbatimi i kolizioneve, jepni lojtarëve pikë për goditjen e predhave
    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),
    );

    \/\/ Kontrolloni nëse ndonjë lojtar është i vdekur
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        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;
    }
  }

  \/\/ ...
}

Sanitizer.replaceElementWithChildren() update() përcjell, ndoshta, pjesën më të rëndësishme të logjikës në anën e serverit. Ne do të rendisim, hap pas hapi, gjithçka që ai bën:

  1. Llogaritë sa kohë dt ka kaluar nga hera e fundit update().
  2. Përdor secilën predhë dhe shkatërroje ato kur është e nevojshme. Ne do të shohim implementimin e kësaj funksionaliteti më vonë. Deri tani mjafton të dimë se bullet.update() false, nëse elementi që do të zëvendësohet është true, nëse predha duhet të shkatërrohet (ajo ka dalë jashtë kufijve të arenës).
  3. Përdor secilin lojtar dhe krijon një predhë kur është e nevojshme. Ne do të shohim gjithashtu këtë implementim më vonë — player.update() mund të kthejë një objekt Bullet.
  4. Kontrollon kolizionet midis predhave dhe lojtarëve me anë të applyCollisions(), e cila kthen një varg predhash që goditën lojtarët. Për secilën predhë të kthyer, ne rrisim pikët e lojtarit që e ka shkrepur atë (me ndihmën e player.onDealtDamage()), dhe pastaj e heqim predhën nga vargu 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 herë të dytë kur thirret update(). Kjo na ndihmon të ndjekim variablin ndihmës të përmendur më sipër shouldSendUpdate. Për shkak se update() Thirrjet bëhen 60 herë/s, ne dërgojmë përditësime të lojës 30 herë/s. Kështu, frekuenca e takteve të serverit është 30 takte/s (ne kemi folur për frekuencën e takteve në pjesën e parë).

Pse të dërgojmë përditësime të lojës vetëm në çdo herë të dytë? ? Për të kursyer kanalin. 30 përditësime të lojës në sekondë – është shumë!

Pse atëherë thjesht të thërrasim update() 30 herë në sekondë? Për të përmirësuar simulimin e lojës. Sa më shpesh të thirret update(), aq më saktë do të jetë simulimi i lojës. Por nuk duhet të teprojmë me numrin e thirrjeve update(), sepse kjo është një detyrë me kosto të lartë llogaritëse — 60 herë në sekondë është mjaft e mjaftueshme.

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

game.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)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() është mjaft e thjeshtë – rendit lojtarët sipas pikëve, merr pesë të parët dhe kthen për secilin emrin e përdoruesit dhe rezultatin.

createUpdate() përdoret në update() për krijimin e përditësimeve të lojës që dërgohen te lojtarët. Detyra e saj kryesore është të thërrasë metodat serializeForUpdate(), të zbatuara për klasat Player dhe Bullet. Vini re se ajo i dërgon çdo lojtar vetëm të dhënat mbi lojtarët dhe projektilët e afërt – nuk ka nevojë të dërgohet informacion mbi objektet e lojës që janë larg nga lojtari!

3. Objektet e lojës në server

Në lojën tonë, projektilët dhe lojtarët në të vërtetë janë shumë të ngjashëm: ato janë objekte të zakonshme të lëvizshme të lojës. Për të shfrytëzuar këtë ngjashmëri ndërmjet lojtarëve dhe projektilëve, le të fillojmë nga realizimi i klasës bazë 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,
    };
  }
}

Nuk ka asgjë të komplikuar këtu. Këto klasë do të jetë një pikë e mirë mbështetje për zgjerim. Le të shohim se si klasa Bullet përdor 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;
  }

  // Kthen true nëse plumbi duhet të shkatërrohet
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementimi Bullet shumë e shkurtër! Ne e kemi shtuar Object vetëm zgjatjet e mëposhtme:

  • Përdorimi i paketës shortid për gjenerimin e rastësishëm id të plumbit.
  • Shtimi i fushës parentID, që do të mundësojë ndjekjen e lojtarit që e ka krijuar këtë plumb.
  • Shtimi i një vlere kthyese në update(), e cila është true, nëse plumbi është jashtë arenës (mos harroni, ne folëm për këtë në seksionin e kaluar?).

Të kalojmë në Player:

player.js

const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');

class Player extends ObjectClass {
  constructor(id, username, x, y) {
    super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
    this.username = username;
    this.hp = Constants.PLAYER_MAX_HP;
    this.fireCooldown = 0;
    this.score = 0;
  }

  // Kthen një plumb të krijuar rishtazi, ose null.
  update(dt) {
    super.update(dt);

    // Përditësoni rezultatin
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Sigurohuni që lojtarit të mbetet brenda kufijve
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Gjuani një plumb, nëse është nevoja
    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;
  }

  merrDëmtimPlumbit() {
    this.hp -= Constants.BULLET_DAMAGE;
  }

  nëDëmtiminEshteShtuar() {
    this.score += Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate() {
    return {
      ...(super.serializeForUpdate()),
      direction: this.direction,
      hp: this.hp,
    };
  }
}

Lojtarët janë më të komplikuar se plumbat, kështu që ky klasë duhet të ruajë disa fusha të tjera. Metoda e tij update() bën më shumë punë, duke veçuar vetëm plumbin e sapokrijuar, nëse nuk ka mbetur fireCooldown (mos harroni, ne folëm për këtë në seksionin e kaluar?). Gjithashtu, ai zgjeron metodën serializeForUpdate(), sepse na nevojitet të përfshijmë në përditësimin e lojës fusha të tjera për lojtarin.

Të qenit me një klasë themelore Object është një hap i rëndësishëm për të shmangur përsëritjen e kodit. Për shembull, pa klasën Object çdo objekt lojë duhet të ketë një implementim të njëjtë distanceTo(), dhe sinkronizimi i kopjes së të gjitha këtyre implementimeve në disa skedarë do të ishte një makth. Kjo bëhet veçanërisht e rëndësishme për projektet e mëdha, kur numri i klasave që zgjerohen rritet. Object 4. Njihe ngacmimi

kolizionet

E vetmja e fundit që na ka mbetur është të njohim kur predhat godasin lojtarët! Kujtoni këtë fragment kodi nga metoda update() në klasën Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Aplikoni përplasjet, jepni lojtarëve pikë për goditjen e predhave
    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),
    );

    // ...
  }
}

Na nevojitet të implementojmë metodën applyCollisions(), e cila kthen të gjitha predhat që godasin lojtarët. Fatmirësisht, kjo nuk është kaq e vështirë për t'u bërë sepse

  • Të gjitha objektet që përplasen janë rreth, dhe kjo është figura më e thjeshtë për të realizuar njohjen e përplasjeve.
  • Ne tashmë kemi metodën distanceTo(), e cila e kemi implementuar në seksionin e kaluar në klasën Object.

Ja si duket implementimi ynë i njohjes së përplasjeve:

collisions.js

const Constants = require('../shared/constants');

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Kjo njohje e thjeshtë e përplasjeve bazohet në faktin se dy rrethat përplasen nëse distanca mes qendrave të tyre është më e vogël se shuma e rrezatimeve të tyre. Ja rasti kur distanca midis qendrave të dy rrethave është saktësisht e barabartë me shumën e rrezatimeve të tyre:

Krijimi i një loje të shumëpërdorueshëm në internet në zhanrin .io
Këtu duhet të kuptoni edhe një çift aspektesh:

  • Predha nuk duhet të godasë lojtarin që e krijoi atë. Kjo mund të realizohet duke krahasuar bullet.parentID me player.id.
  • Predha duhet të godasë vetëm një herë në rastin e limitit të përplasjes me disa lojtarë. Këtë detyrë do ta zgjidhim me ndihmën e operatorit break: sa herë që kemi gjetur një lojtar që është përplasur me predhën, ne ndalojmë kërkimin dhe kalojmë te predha tjetër.

Fund

Kaq është! Ne e shqyrtuam gjithçka që duhet të dini për të krijuar një lojë në internet në stilin .io. Çfarë të bëjmë më pas? Krijoni lojën tuaj të vetme .io!

I gjithë kodi i shembullit është me burim të hapur dhe është publikuar në Github.

Burimi: habr.com

Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS 🔥 Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS | ProHoster