Stvaranje .io web igre za više igrača

Stvaranje .io web igre za više igrača
Izdano 2015 Agar.io postao rodonačelnikom novog žanra igre .iokojoj je od tada porasla popularnost. Osobno sam iskusio porast popularnosti .io igara: u posljednje tri godine, jesam stvorio i prodao dvije igre ovog žanra..

U slučaju da nikada prije niste čuli za ove igre, ovo su besplatne web igre za više igrača koje je lako igrati (nije potreban račun). Obično se suočavaju s mnogo protivničkih igrača u istoj areni. Druge poznate .io igre: Slither.io и Diep.io.

U ovom ćemo postu istražiti kako stvoriti .io igru ​​od nule. Za ovo će biti dovoljno samo poznavanje Javascripta: trebate razumjeti stvari poput sintakse ES6, ključna riječ this и Obećanja. Čak i ako vaše znanje Javascripta nije savršeno, svejedno možete razumjeti većinu posta.

.io primjer igre

Za pomoć u učenju, obratit ćemo se na .io primjer igre. Pokušajte igrati!

Stvaranje .io web igre za više igrača
Igra je vrlo jednostavna: upravljate brodom u areni u kojoj ima drugih igrača. Vaš brod automatski ispaljuje projektile, a vi pokušavate pogoditi druge igrače izbjegavajući njihove projektile.

1. Kratki pregled / struktura projekta

preporučiti preuzimanje izvornog koda primjer igre tako da me možete pratiti.

Primjer koristi sljedeće:

  • Izraziti je najpopularniji Node.js web framework koji upravlja web poslužiteljem igre.
  • utičnica.io - websocket biblioteka za razmjenu podataka između preglednika i poslužitelja.
  • webpack - voditelj modula. Možete pročitati zašto koristiti Webpack. здесь.

Evo kako izgleda struktura direktorija projekta:

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

javnost/

Sve u fasciklu public/ poslužitelj će statički poslati. U public/assets/ sadrži slike korištene u našem projektu.

src /

Sav izvorni kod je u mapi src/. Nazvanie client/ и server/ govore za sebe i shared/ sadrži datoteku konstanti koju uvoze i klijent i poslužitelj.

2. Sklopovi/Postavke projekta

Kao što je gore spomenuto, za izradu projekta koristimo upravitelj modula. webpack. Pogledajmo našu Webpack konfiguraciju:

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

Najvažnije linije ovdje su:

  • src/client/index.js je ulazna točka Javascript (JS) klijenta. Webpack će započeti odavde i rekurzivno tražiti druge uvezene datoteke.
  • Izlazni JS naše izrade Webpacka bit će smješten u direktoriju dist/. Ovu ću datoteku nazvati našom JS paket.
  • Koristimo Babel, a posebno konfiguracija @babel/preset-env do transpiliranja našeg JS koda za starije preglednike.
  • Koristimo dodatak za izdvajanje svih CSS-ova na koje upućuju JS datoteke i njihovo kombiniranje na jednom mjestu. Zvat ću ga našim css paket.

Možda ste primijetili čudne nazive datoteka paketa '[name].[contenthash].ext'. Oni sadrže zamjene imena datoteka web paket: [name] bit će zamijenjeno nazivom ulazne točke (u našem slučaju ovo game), i [contenthash] bit će zamijenjen hashom sadržaja datoteke. Mi to činimo da optimizirajte projekt za raspršivanje - možete reći preglednicima da predmemoriraju naše JS pakete na neodređeno vrijeme, jer ako se paket promijeni, mijenja se i naziv njegove datoteke (promjene contenthash). Konačni rezultat bit će naziv datoteke prikaza game.dbeee76e91a97d0c7207.js.

datoteka webpack.common.js je osnovna konfiguracijska datoteka koju uvozimo u razvojne i gotove konfiguracije projekta. Evo primjera razvojne konfiguracije:

webpack.dev.js

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

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

Za učinkovitost koristimo u procesu razvoja webpack.dev.js, i prelazi na webpack.prod.jsza optimizaciju veličina paketa prilikom postavljanja u proizvodnju.

Lokalna postavka

Preporučujem da projekt instalirate na lokalno računalo kako biste mogli slijediti korake navedene u ovom postu. Postavljanje je jednostavno: prvo, sustav mora biti instaliran Čvor и NPM. Sljedeće što trebate učiniti

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

i spremni ste za polazak! Za pokretanje razvojnog poslužitelja samo pokrenite

$ npm run develop

i idite na web preglednik localhost: 3000. Razvojni poslužitelj automatski će ponovno izgraditi JS i CSS pakete kako se kod mijenja - samo osvježite stranicu da vidite sve promjene!

3. Ulazne točke klijenta

Prijeđimo na sam kod igre. Prvo nam treba stranica index.html, prilikom posjeta stranici, preglednik će ga prvo učitati. Naša će stranica biti prilično jednostavna:

index.html

Primjer .io igre  IGRA

Ovaj primjer koda malo je pojednostavljen radi jasnoće, a ja ću učiniti isto s mnogim drugim primjerima posta. Cijeli kod uvijek se može pogledati na Github.

Imamo:

  • HTML5 element platna (<canvas>) koje ćemo koristiti za renderiranje igre.
  • <link> da dodamo naš CSS paket.
  • <script> da dodamo naš Javascript paket.
  • Glavni izbornik s korisničkim imenom <input> i gumb "PLAY" (<button>).

Nakon učitavanja početne stranice, preglednik će početi izvršavati Javascript kod, počevši od JS datoteke ulazne točke: 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);
  };
});

Ovo možda zvuči komplicirano, ali ovdje se ne događa mnogo:

  1. Uvoz nekoliko drugih JS datoteka.
  2. Uvoz CSS-a (tako da Webpack zna uključiti ih u naš CSS paket).
  3. lansiranje connect() uspostaviti vezu s poslužiteljem i pokrenuti downloadAssets() za preuzimanje slika potrebnih za renderiranje igre.
  4. Nakon završetka faze 3 prikazuje se glavni izbornik (playMenu).
  5. Podešavanje rukovatelja pritiskom tipke "PLAY". Kada se gumb pritisne, kod inicijalizira igru ​​i govori poslužitelju da smo spremni za igru.

Glavno "meso" naše logike klijent-poslužitelj je u onim datotekama koje je datoteka uvezla index.js. Sada ćemo ih sve redom razmotriti.

4. Razmjena podataka o kupcima

U ovoj igri koristimo dobro poznatu biblioteku za komunikaciju s poslužiteljem utičnica.io. Socket.io ima izvornu podršku WebSockets, koji su dobro prilagođeni za dvosmjernu komunikaciju: možemo slati poruke poslužitelju и poslužitelj nam može slati poruke na istoj vezi.

Imat ćemo jednu datoteku src/client/networking.jstko će se pobrinuti od svih komunikacija sa serverom:

umrežavanje.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);
};

Ovaj kod je također malo skraćen radi jasnoće.

Tri su glavne radnje u ovoj datoteci:

  • Pokušavamo se spojiti na poslužitelj. connectedPromise dopušteno samo kada smo uspostavili vezu.
  • Ako je veza uspješna, registriramo funkcije povratnog poziva (processGameUpdate() и onGameOver()) za poruke koje možemo primiti s poslužitelja.
  • Izvozimo play() и updateDirection()kako bi ih druge datoteke mogle koristiti.

5. Renderiranje klijenta

Vrijeme je za prikaz slike na ekranu!

...ali prije nego što to možemo učiniti, moramo preuzeti sve slike (resurse) koji su za ovo potrebni. Napišimo upravitelja resursima:

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

Upravljanje resursima nije tako teško implementirati! Glavna ideja je pohraniti predmet assets, koji će vezati ključ naziva datoteke na vrijednost objekta Image. Kada se resurs učita, pohranjujemo ga u objekt assets za brzi pristup u budućnosti. Kada će svakom pojedinačnom resursu biti dopušteno preuzimanje (tj. sve resursi), dopuštamo downloadPromise.

Nakon preuzimanja resursa, možete započeti iscrtavanje. Kao što je ranije rečeno, za crtanje na web stranici koristimo se HTML5 platno (<canvas>). Naša igra je prilično jednostavna, tako da samo trebamo nacrtati sljedeće:

  1. pozadina
  2. Igrač brod
  3. Ostali igrači u igri
  4. Školjke

Evo važnih isječaka src/client/render.js, koji prikazuju točno četiri gore navedene stavke:

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

Ovaj kod je također skraćen radi jasnoće.

render() je glavna funkcija ove datoteke. startRendering() и stopRendering() kontrolirati aktivaciju petlje renderiranja pri 60 FPS.

Konkretne implementacije pojedinačnih pomoćnih funkcija pri prikazivanju (npr. renderBullet()) nisu toliko važni, ali evo jednog jednostavnog primjera:

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

Imajte na umu da koristimo metodu getAsset(), koji je ranije viđen u asset.js!

Ako ste zainteresirani za istraživanje drugih pomoćnika za renderiranje, pročitajte ostatak src/klijent/render.js.

6. Unos klijenta

Vrijeme je da napravimo igru igriv! Shema upravljanja bit će vrlo jednostavna: za promjenu smjera kretanja možete koristiti miš (na računalu) ili dodirnuti zaslon (na mobilnom uređaju). Da bismo to proveli, registrirat ćemo se Slušatelji događaja za događaje miša i dodira.
Pobrinut ću se za sve ovo src/client/input.js:

unos.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() su slušatelji događaja koji zovu updateDirection() (iz networking.js) kada se dogodi događaj unosa (na primjer, kada se pomakne miš). updateDirection() obrađuje poruke s poslužiteljem, koji obrađuje događaj unosa i ažurira stanje igre u skladu s tim.

7. Status klijenta

Ovaj dio je najteži u prvom dijelu posta. Nemojte se obeshrabriti ako je ne razumijete prvi put kada je pročitate! Možete ga čak i preskočiti i vratiti mu se kasnije.

Posljednji dio slagalice potreban za dovršavanje koda klijent/poslužitelj je su. Sjećate se isječka koda iz odjeljka Client Rendering?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() trebao bi nam moći dati trenutno stanje igre u klijentu u bilo kojem trenutku u vremenu na temelju ažuriranja primljenih od poslužitelja. Evo primjera ažuriranja igre koje poslužitelj može poslati:

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

Svako ažuriranje igre sadrži pet identičnih polja:

  • t: Vremenska oznaka poslužitelja koja pokazuje kada je ovo ažuriranje stvoreno.
  • me: Informacije o igraču koji prima ovo ažuriranje.
  • drugi: Niz informacija o drugim igračima koji sudjeluju u istoj igri.
  • metaka: niz informacija o projektilima u igri.
  • leaderboard: Podaci o trenutnoj ploči s najboljim rezultatima. U ovom postu ih nećemo razmatrati.

7.1 Naivno stanje klijenta

Naivna implementacija getCurrentState() može samo izravno vratiti podatke o posljednjem primljenom ažuriranju igre.

naivno-stanje.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Lijepo i jasno! Ali kad bi barem bilo tako jednostavno. Jedan od razloga zašto je ova implementacija problematična: ograničava broj sličica u sekundi renderiranja na takt poslužitelja.

Okvirna stopa: broj okvira (tj. poziva render()) u sekundi ili FPS. Igre obično teže postizanju najmanje 60 FPS.

Tick ​​​​Rate: Učestalost kojom poslužitelj šalje ažuriranja igre klijentima. Često je niža od brzine kadrova. U našoj igri server radi na frekvenciji od 30 ciklusa u sekundi.

Ako renderiramo samo najnovije ažuriranje igre, tada FPS u biti nikada neće prijeći preko 30, jer nikad ne dobivamo više od 30 ažuriranja u sekundi s poslužitelja. Čak i ako nazovemo render() 60 puta u sekundi, tada će polovica ovih poziva samo ponovno nacrtati istu stvar, u biti ne radeći ništa. Još jedan problem s naivnom implementacijom je taj što sklon kašnjenju. Uz idealnu brzinu interneta, klijent će dobiti ažuriranje igre točno svakih 33 ms (30 u sekundi):

Stvaranje .io web igre za više igrača
Nažalost, ništa nije savršeno. Realnija slika bi bila:
Stvaranje .io web igre za više igrača
Naivna implementacija je praktički najgori slučaj kada je u pitanju latencija. Ako se ažuriranje igre primi s odgodom od 50 ms, tada štandovi za klijente dodatnih 50 ms jer još uvijek renderira stanje igre iz prethodnog ažuriranja. Možete zamisliti koliko je to neugodno za igrača: zbog proizvoljnog kočenja igra će se osjećati trzavo i nestabilno.

7.2 Poboljšano stanje klijenta

Napravit ćemo neka poboljšanja naivne implementacije. Prvo, koristimo kašnjenje prikazivanja za 100 ms. To znači da će "trenutno" stanje klijenta uvijek zaostajati za stanjem igre na serveru za 100ms. Na primjer, ako je vrijeme na poslužitelju 150, tada će klijent prikazati stanje u kojem je poslužitelj bio u to vrijeme 50:

Stvaranje .io web igre za više igrača
To nam daje međuspremnik od 100 ms da preživimo nepredvidiva vremena ažuriranja igre:

Stvaranje .io web igre za više igrača
Isplata za ovo će biti trajna ulazno kašnjenje za 100 ms. Ovo je mala žrtva za glatko igranje - većina igrača (osobito povremeni igrači) neće ni primijetiti ovo kašnjenje. Ljudima je mnogo lakše prilagoditi se na konstantnu latenciju od 100 ms nego igrati se s nepredvidivom latencijom.

Također možemo koristiti drugu tehniku ​​tzv predviđanje na strani klijenta, koji dobro obavlja svoj posao u smanjenju percipirane latencije, ali neće biti pokriven u ovom postu.

Još jedno poboljšanje koje koristimo je linearna interpolacija. Zbog kašnjenja renderiranja, obično smo barem jedno ažuriranje ispred trenutnog vremena u klijentu. Na poziv getCurrentState(), možemo izvršiti linearna interpolacija između ažuriranja igre prije i poslije trenutnog vremena u klijentu:

Stvaranje .io web igre za više igrača
Ovo rješava problem broja sličica u sekundi: sada možemo renderirati jedinstvene okvire pri bilo kojoj brzini sličica u sekundi!

7.3 Implementacija poboljšanog stanja klijenta

Primjer implementacije u src/client/state.js koristi i kašnjenje renderiranja i linearnu interpolaciju, ali ne zadugo. Podijelimo kôd na dva dijela. Evo prvog:

state.js 1. dio

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

Prvi korak je shvatiti što currentServerTime(). Kao što smo vidjeli ranije, svako ažuriranje igre uključuje vremensku oznaku poslužitelja. Želimo koristiti latenciju renderiranja za renderiranje slike 100ms iza poslužitelja, ali nikada nećemo znati trenutno vrijeme na poslužitelju, jer ne možemo znati koliko je vremena trebalo da bilo koje ažuriranje stigne do nas. Internet je nepredvidiv i njegova brzina može jako varirati!

Da bismo zaobišli ovaj problem, možemo upotrijebiti razumnu aproksimaciju: mi pretvarajte se da je prvo ažuriranje stiglo odmah. Da je to istina, tada bismo znali vrijeme servera u ovom trenutku! Pohranjujemo vremensku oznaku poslužitelja firstServerTimestamp i zadržimo naše lokalni (klijent) vremenska oznaka u istom trenutku u gameStart.

Čekaj. Ne bi li trebalo biti vrijeme poslužitelja = vrijeme klijenta? Zašto razlikujemo "vremensku oznaku poslužitelja" i "vremensku oznaku klijenta"? Ovo je sjajno pitanje! Ispostavilo se da nisu ista stvar. Date.now() vratit će različite vremenske oznake u klijentu i poslužitelju, a to ovisi o faktorima koji su lokalni za te strojeve. Nikada ne pretpostavljajte da će vremenske oznake biti iste na svim strojevima.

Sada razumijemo što znači currentServerTime(): vraća se vremenska oznaka poslužitelja trenutnog vremena renderiranja. Drugim riječima, ovo je trenutno vrijeme poslužitelja (firstServerTimestamp <+ (Date.now() - gameStart)) minus kašnjenje generiranja (RENDER_DELAY).

Sada pogledajmo kako postupamo s ažuriranjima igara. Kada se primi od poslužitelja za ažuriranje, poziva se processGameUpdate()i spremamo novo ažuriranje u polje gameUpdates. Zatim, kako bismo provjerili korištenje memorije, uklanjamo sva stara ažuriranja prije ažuriranje bazejer nam više ne trebaju.

Što je "osnovno ažuriranje"? Ovaj prvo ažuriranje koje nalazimo pomicanjem unatrag od trenutnog vremena poslužitelja. Sjećate li se ovog dijagrama?

Stvaranje .io web igre za više igrača
Ažuriranje igre lijevo od "Vrijeme renderiranja klijenta" je osnovno ažuriranje.

Za što se koristi osnovno ažuriranje? Zašto možemo odbaciti ažuriranja na osnovno stanje? Da bismo to shvatili, hajdemo konačno razmislite o provedbi getCurrentState():

state.js 2. dio

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

Obrađujemo tri slučaja:

  1. base < 0 znači da nema ažuriranja do trenutnog vremena renderiranja (pogledajte gornju implementaciju getBaseUpdate()). To se može dogoditi odmah na početku igre zbog kašnjenja u prikazivanju. U ovom slučaju koristimo najnovije primljeno ažuriranje.
  2. base je najnovije ažuriranje koje imamo. To može biti zbog kašnjenja mreže ili loše internetske veze. U ovom slučaju također koristimo najnovije ažuriranje koje imamo.
  3. Imamo ažuriranje i prije i poslije trenutnog vremena renderiranja, tako da možemo interpolirati!

Sve što je ostalo unutra state.js je implementacija linearne interpolacije koja je jednostavna (ali dosadna) matematika. Ako želite sami istražiti, otvorite state.js na Github.

Dio 2. Pozadinski poslužitelj

U ovom ćemo dijelu pogledati pozadinu Node.js koja kontrolira naš .io primjer igre.

1. Ulazna točka poslužitelja

Za upravljanje web poslužiteljem koristit ćemo popularni web framework za Node.js tzv Izraziti. Bit će konfiguriran našom datotekom ulazne točke poslužitelja src/server/server.js:

server.js 1. dio

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

Sjećate se da smo u prvom dijelu razgovarali o Webpacku? Ovdje ćemo koristiti naše Webpack konfiguracije. Koristit ćemo ih na dva načina:

  • Za korištenje webpack-dev-middleware za automatsku ponovnu izgradnju naših razvojnih paketa, ili
  • mapa za statički prijenos dist/, u koji će Webpack pisati naše datoteke nakon proizvodne izgradnje.

Još jedan važan zadatak server.js je postaviti poslužitelj utičnica.iokoji se samo spaja na Express poslužitelj:

server.js 2. dio

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

Nakon uspješne uspostave socket.io veze s poslužiteljem, postavljamo rukovatelje događajima za novi socket. Rukovatelji događajima obrađuju poruke primljene od klijenata delegiranjem na singleton objekt game:

server.js 3. dio

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

Stvaramo .io igru, tako da nam treba samo jedna kopija Game ("Igra") - svi igrači igraju u istoj areni! U sljedećem odjeljku ćemo vidjeti kako ova klasa radi. Game.

2. Poslužitelji za igre

Klasa Game sadrži najvažniju logiku na strani poslužitelja. Ima dva glavna zadatka: upravljanje igračima и simulacija igre.

Počnimo s prvim zadatkom, upravljanjem igračima.

igra.js 1. dio

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

  // ...
}

U ovoj igri igrače ćemo identificirati po polju id njihov socket.io socket (ako se zbunite, vratite se na server.js). Sam Socket.io svakoj utičnici dodjeljuje jedinstvenu idtako da ne trebamo brinuti o tome. Nazvat ću ga ID igrača.

Imajući to na umu, istražimo varijable instance u klasi Game:

  • sockets je objekt koji veže ID igrača na utičnicu koja je povezana s igračem. Omogućuje nam pristup utičnicama prema njihovim ID-ovima igrača tijekom vremena.
  • players je objekt koji povezuje ID igrača s kodom>Objekt igrača

bullets je niz objekata Bullet, koji nema određeni redoslijed.
lastUpdateTime je vremenska oznaka zadnjeg ažuriranja igre. Uskoro ćemo vidjeti kako će se koristiti.
shouldSendUpdate je pomoćna varijabla. Uskoro ćemo vidjeti i njegovu upotrebu.
metode addPlayer(), removePlayer() и handleInput() nema potrebe objašnjavati, koriste se u server.js. Ako trebate osvježiti pamćenje, vratite se malo više.

Zadnji redak constructor() pokreće se ciklus ažuriranja igre (s učestalošću od 60 ažuriranja / s):

igra.js 2. dio

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

  // ...
}

način update() sadrži možda najvažniji dio logike na strani poslužitelja. Evo što radi, redom:

  1. Izračunava koliko dugo dt prošlo od posljednjeg update().
  2. Osvježava svaki projektil i uništava ga ako je potrebno. Kasnije ćemo vidjeti implementaciju ove funkcionalnosti. Za sada nam je dovoljno da to znamo bullet.update() vraća trueako projektil treba uništiti (izašao je iz arene).
  3. Ažurira svakog igrača i stvara projektil ako je potrebno. Kasnije ćemo također vidjeti ovu implementaciju − player.update() može vratiti objekt Bullet.
  4. Provjerava kolizije između projektila i igrača s applyCollisions(), koji vraća niz projektila koji pogađaju igrače. Za svaki vraćeni projektil povećavamo rezultat igrača koji ga je ispalio (koristeći player.onDealtDamage()) i zatim uklonite projektil iz niza bullets.
  5. Obavještava i uništava sve ubijene igrače.
  6. Šalje ažuriranje igre svim igračima svake sekunde puta kada se zove update(). To nam pomaže da pratimo gore spomenutu pomoćnu varijablu. shouldSendUpdate. Kao update() poziva 60 puta/s, ažuriranja igre šaljemo 30 puta/s. Tako, taktna frekvencija poslužiteljski sat je 30 taktova/s (razgovarali smo o taktovima u prvom dijelu).

Zašto slati samo ažuriranja igre kroz vrijeme ? Za spremanje kanala. 30 ažuriranja igre u sekundi je puno!

Zašto jednostavno ne nazoveš update() 30 puta u sekundi? Za poboljšanje simulacije igre. Što se češće zove update(), to će simulacija igre biti preciznija. Ali nemojte se previše zanositi brojem izazova. update(), jer je to računski skup zadatak - dovoljno je 60 u sekundi.

Ostatak razreda Game sastoji se od pomoćnih metoda koje se koriste u update():

igra.js 3. dio

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() prilično jednostavno - razvrstava igrače po rezultatu, uzima prvih pet i vraća korisničko ime i rezultat za svakoga.

createUpdate() korišteno u update() za stvaranje ažuriranja igre koja se distribuiraju igračima. Njegov glavni zadatak je pozivanje metoda serializeForUpdate()implementiran za nastavu Player и Bullet. Imajte na umu da samo prenosi podatke svakom igraču o najbliži igrači i projektili - nema potrebe za prijenosom informacija o objektima igre koji su daleko od igrača!

3. Objekti igre na poslužitelju

U našoj igri, projektili i igrači su zapravo vrlo slični: oni su apstraktni, okrugli, pomični objekti igre. Kako bismo iskoristili ovu sličnost između igrača i projektila, počnimo s implementacijom osnovne klase 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,
    };
  }
}

Ovdje se ne događa ništa komplicirano. Ova će klasa biti dobro sidrište za proširenje. Da vidimo kako je klasa Bullet koristi Object:

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

Provedba Bullet Vrlo kratko! Dodali smo na Object samo sljedeća proširenja:

  • Korištenje paketa kratak za slučajno generiranje id projektil.
  • Dodavanje polja parentIDtako da možete pratiti igrača koji je napravio ovaj projektil.
  • Dodavanje povratne vrijednosti update(), što je jednako trueako je projektil izvan arene (sjećate se da smo o tome govorili u prošlom odjeljku?).

Prijeđimo na Player:

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

Igrači su složeniji od projektila, pa bi u ovoj klasi trebalo pohraniti još nekoliko polja. Njegova metoda update() radi puno posla, posebice vraća novostvoreni projektil ako ga više nema fireCooldown (sjećate se da smo o tome govorili u prethodnom odjeljku?). Također proširuje metodu serializeForUpdate(), jer moramo uključiti dodatna polja za igrača u ažuriranju igre.

Imati osnovnu klasu Object - važan korak za izbjegavanje ponavljanja koda. Na primjer, bez klase Object svaki objekt igre mora imati istu implementaciju distanceTo(), a kopiranje svih ovih implementacija u više datoteka bila bi noćna mora. Ovo postaje posebno važno za velike projekte.kada se broj širi Object razredi rastu.

4. Otkrivanje sudara

Jedino što nam preostaje je prepoznati kada su projektili pogodili igrače! Zapamtite ovaj dio koda iz metode update() U klasi Game:

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

    // ...
  }
}

Moramo implementirati metodu applyCollisions(), koji vraća sve projektile koji su pogodili igrače. Srećom, to nije tako teško učiniti jer

  • Svi objekti u sudaru su krugovi, što je najjednostavniji oblik za implementaciju otkrivanja sudara.
  • Već imamo metodu distanceTo(), koji smo implementirali u prethodnom odjeljku u razredu Object.

Evo kako izgleda naša implementacija otkrivanja sudara:

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

Ovo jednostavno otkrivanje sudara temelji se na činjenici da dva kruga se sudaraju ako je udaljenost između njihovih središta manja od zbroja njihovih polumjera. Ovo je slučaj kada je udaljenost između središta dviju kružnica točno jednaka zbroju njihovih polumjera:

Stvaranje .io web igre za više igrača
Ovdje treba razmotriti još nekoliko aspekata:

  • Projektil ne smije pogoditi igrača koji ga je stvorio. To se može postići usporedbom bullet.parentID с player.id.
  • Projektil mora pogoditi samo jednom u ograničenom slučaju sudara više igrača u isto vrijeme. Ovaj problem ćemo riješiti pomoću operatora break: čim se pronađe igrač koji se sudara s projektilom, zaustavljamo pretragu i prelazimo na sljedeći projektil.

Kraj

To je sve! Pokrili smo sve što trebate znati za stvaranje .io web igre. Što je sljedeće? Izgradite vlastitu .io igru!

Sav primjer koda je otvorenog koda i objavljen na Github.

Izvor: www.habr.com

Dodajte komentar