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

Kreiranje .io web igre za više igrača
Objavljeno 2015 Agar.io postao rodonačelnik novog žanra igre .iokojoj je od tada porasla popularnost. Lično sam iskusio porast popularnosti .io igara: u protekle tri godine jesam kreirao i prodao dvije igre ovog žanra..

U slučaju da nikada ranije 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 sa mnogo protivničkih igrača u istoj areni. Ostale poznate .io igre: Slither.io и Diep.io.

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

Primjer .io igre

Za pomoć u učenju ćemo se obratiti Primjer .io igre. Pokušajte da je igrate!

Kreiranje .io web igre za više igrača
Igra je prilično jednostavna: upravljate brodom u areni u kojoj se nalaze drugi igrači. Vaš brod automatski ispaljuje projektile, a vi pokušavate pogoditi druge igrače izbjegavajući njihove projektile.

1. Kratak pregled / struktura projekta

Preporučujem preuzmi izvorni kod primjer igre tako da me možete pratiti.

Primjer koristi sljedeće:

  • izraziti je najpopularniji Node.js web framework koji upravlja web serverom igre.
  • socket.io - websocket biblioteka za razmjenu podataka između pretraživača i servera.
  • Webpack - menadžer modula. Možete pročitati zašto koristiti Webpack. ovdje.

Evo kako izgleda struktura direktorija projekta:

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

publika/

Sve u folderu public/ će server biti statički dostavljen. IN public/assets/ sadrži slike koje koristi naš projekat.

src /

Sav izvorni kod je u folderu src/. Naslovi client/ и server/ govore za sebe i shared/ sadrži datoteku konstanti koju uvoze i klijent i server.

2. Postavke sklopova/projekta

Kao što je gore spomenuto, koristimo modul menadžer za izgradnju projekta. Webpack. Pogledajmo našu konfiguraciju Webpack-a:

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

Ovdje su najvažnije linije:

  • src/client/index.js je ulazna tačka Javascript (JS) klijenta. Webpack će početi odavde i rekurzivno će tražiti druge uvezene datoteke.
  • Izlazni JS našeg Webpack build-a će se nalaziti u direktoriju dist/. Nazvat ću ovaj fajl naš js paket.
  • Koristimo Babel, a posebno konfiguraciju @babel/preset-env za transpiliranje našeg JS koda za starije pretraživače.
  • Koristimo dodatak za izdvajanje svih CSS-ova na koje upućuju JS datoteke i kombiniranje ih na jednom mjestu. Ja ću ga zvati naš css paket.

Možda ste primijetili čudna imena paketa '[name].[contenthash].ext'. Oni sadrže zamjene naziva datoteke webpack: [name] će biti zamijenjen imenom ulazne točke (u našem slučaju, ovo game), i [contenthash] će biti zamijenjen hešom sadržaja datoteke. Mi to radimo optimizirati projekat za heširanje - možete reći pretraživačima da keširaju naše JS pakete neograničeno, jer ako se paket promijeni, tada se mijenja i njegovo ime datoteke (promjene contenthash). Konačni rezultat će biti naziv datoteke prikaza game.dbeee76e91a97d0c7207.js.

fajl webpack.common.js je osnovna konfiguracijska datoteka koju uvozimo u konfiguracije razvoja i gotovih projekata. 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 efikasnost koristimo u procesu razvoja webpack.dev.js, i prelazi na webpack.prod.jsza optimizaciju veličine paketa prilikom implementacije u proizvodnju.

Lokalno podešavanje

Preporučujem instaliranje projekta na lokalnu mašinu kako biste mogli slijediti korake navedene u ovom postu. Podešavanje je jednostavno: prvo, sistem mora biti instaliran čvor и NPM. Sledeće što treba da uradite

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

i spremni ste za polazak! Da pokrenete razvojni server, samo pokrenite

$ npm run develop

i idite na web pretraživač localhost: 3000. Razvojni server će automatski ponovo izgraditi JS i CSS pakete kako se kod mijenja - samo osvježite stranicu da vidite sve promjene!

3. Ulazne tačke za klijente

Prijeđimo na sam kod igre. Prvo nam treba stranica index.html, kada posjetite stranicu, pretraživač će je prvo učitati. Naša stranica će biti prilično jednostavna:

index.html

Primjer .io igre  IGRAJ

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

Imamo:

  • HTML5 element platna (<canvas>) koji ćemo koristiti za renderiranje igre.
  • <link> da dodate naš CSS paket.
  • <script> da dodate naš Javascript paket.
  • Glavni meni sa korisničkim imenom <input> i dugme PLAY (<button>).

Nakon učitavanja početne stranice, pretraživač će početi da izvršava Javascript kod, počevši od ulazne tačke JS datoteke: 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 komplikovano, ali se ovdje nema mnogo toga:

  1. Uvoz nekoliko drugih JS datoteka.
  2. CSS uvoz (tako da Webpack zna da ih uključi u naš CSS paket).
  3. Запуск connect() da uspostavite vezu sa serverom i pokrenete downloadAssets() za preuzimanje slika potrebnih za renderiranje igre.
  4. Nakon završetka faze 3 prikazuje se glavni meni (playMenu).
  5. Podešavanje rukovaoca za pritiskanje dugmeta "PLAY". Kada se pritisne dugme, kod inicijalizira igru ​​i govori serveru da smo spremni za igru.

Glavno "meso" naše klijent-server logike je u onim datotekama koje je datoteka uvezla index.js. Sada ćemo ih sve razmotriti po redu.

4. Razmjena podataka o klijentima

U ovoj igri koristimo dobro poznatu biblioteku za komunikaciju sa serverom socket.io. Socket.io ima izvornu podršku web utičnice, koji su vrlo pogodni za dvosmjernu komunikaciju: možemo slati poruke na server и server nam može slati poruke na istoj konekciji.

Imaćemo jedan fajl src/client/networking.jsko će se pobrinuti od svih komunikacija sa serverom:

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('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.

Postoje tri glavne radnje u ovoj datoteci:

  • Pokušavamo se povezati sa serverom. connectedPromise dozvoljeno samo kada uspostavimo vezu.
  • Ako je veza uspješna, registrujemo funkcije povratnog poziva (processGameUpdate() и onGameOver()) za poruke koje možemo primiti sa servera.
  • Izvozimo play() и updateDirection()tako da ih drugi fajlovi mogu koristiti.

5. Rendering klijenta

Vrijeme je da prikažete sliku na ekranu!

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

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(`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 pohranjivanje objekta assets, koji će vezati ključ imena datoteke za 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 dozvoljeno preuzimanje (tj. sve resursa), dozvoljavamo downloadPromise.

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

  1. Pozadina
  2. Player ship
  3. Ostali igrači u igri
  4. Školjke

Evo važnih isječaka src/client/render.js, koji prikazuju tač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 render petlje pri 60 FPS.

Konkretne implementacije pojedinačnih pomoćnih funkcija za renderiranje (npr. renderBullet()) nisu toliko bitni, 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(), što je ranije viđeno u asset.js!

Ako ste zainteresirani za učenje o drugim pomoćnicima za renderiranje, pročitajte ostalo. src/client/render.js.

6. Unos klijenta

Vrijeme je da napravimo igru playable! Šema upravljanja bit će vrlo jednostavna: za promjenu smjera kretanja možete koristiti miš (na računaru) ili dodirnuti ekran (na mobilnom uređaju). Da bismo ovo sproveli, mi ćemo se registrovati Slušaoci događaja za događaje miša i dodira.
Pobrinut će se za sve ovo src/client/input.js:

input.js

import { updateDirection } from './networking';

function onMouseInput(e) {
  handleInput(e.clientX, e.clientY);
}

function onTouchInput(e) {
  const touch = e.touches[0];
  handleInput(touch.clientX, touch.clientY);
}

function handleInput(x, y) {
  const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
  updateDirection(dir);
}

export function startCapturingInput() {
  window.addEventListener('mousemove', onMouseInput);
  window.addEventListener('touchmove', onTouchInput);
}

export function stopCapturingInput() {
  window.removeEventListener('mousemove', onMouseInput);
  window.removeEventListener('touchmove', onTouchInput);
}

onMouseInput() и onTouchInput() su slušaoci događaja koji pozivaju updateDirection() (od networking.js) kada se dogodi ulazni događaj (na primjer, kada se miš pomjeri). updateDirection() upravlja razmjenom poruka sa serverom, koji upravlja ulaznim događajem i u skladu s tim ažurira stanje igre.

7. Status klijenta

Ovaj dio je najteži u prvom dijelu posta. Nemojte se obeshrabriti ako je ne razumete 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 klijent/server koda je stanje. Sjećate li se isječka koda iz odjeljka Rendering klijenta?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() treba da nam može dati trenutno stanje igre u klijentu u bilo kom trenutku na osnovu ažuriranja primljenih sa servera. Evo primjera ažuriranja igre koje server 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 servera koja pokazuje kada je ovo ažuriranje kreirano.
  • me: Informacije o igraču koji prima ovo ažuriranje.
  • drugi: Niz informacija o drugim igračima koji učestvuju u istoj igri.
  • metaka: niz informacija o projektilima u igri.
  • leaderboard: Trenutni podaci na tabeli. U ovom postu ih nećemo razmatrati.

7.1 Naivno stanje klijenta

Naivna implementacija getCurrentState() može samo direktno vratiti podatke najnovijeg primljenog ažuriranja igre.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Lepo i jasno! Ali samo da je tako jednostavno. Jedan od razloga zašto je ova implementacija problematična: ograničava brzinu prikaza kadrova na brzinu servera.

Frame Rate: broj okvira (tj. poziva render()) u sekundi, ili FPS. Igre obično nastoje postići najmanje 60 FPS.

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

Ako samo renderiramo najnovije ažuriranje igre, onda FPS u suštini nikada neće preći 30, jer nikada ne dobijamo više od 30 ažuriranja u sekundi sa servera. Čak i ako pozovemo render() 60 puta u sekundi, tada će polovina ovih poziva jednostavno precrtati istu stvar, u suštini ne radeći ništa. Još jedan problem sa naivnom implementacijom je taj što skloni kašnjenju. Uz idealnu brzinu interneta, klijent će dobiti ažuriranje igre tačno svakih 33 ms (30 u sekundi):

Kreiranje .io web igre za više igrača
Nažalost, ništa nije savršeno. Realnija slika bi bila:
Kreiranje .io web igre za više igrača
Naivna implementacija je praktički najgori slučaj kada je u pitanju kašnjenje. Ako se ažuriranje igre primi sa zakašnjenjem od 50 ms, onda klijentske tezge dodatnih 50ms jer još uvijek prikazuje stanje igre iz prethodnog ažuriranja. Možete zamisliti koliko je ovo neugodno za igrača: proizvoljno kočenje će učiniti da igra izgleda trzavo i nestabilno.

7.2 Poboljšano stanje klijenta

Napravićemo neka poboljšanja u naivnoj implementaciji. Prvo koristimo kašnjenje renderovanja za 100 ms. To znači da će "trenutno" stanje klijenta uvijek zaostajati za stanjem igre na serveru za 100 ms. Na primjer, ako je vrijeme na serveru 150, tada će klijent prikazati stanje u kojem je server bio u to vrijeme 50:

Kreiranje .io web igre za više igrača
Ovo nam daje bafer od 100 ms da preživimo nepredvidiva vremena ažuriranja igre:

Kreiranje .io web igre za više igrača
Isplata za ovo će biti trajna input lag za 100 ms. Ovo je mala žrtva za glatko igranje - većina igrača (posebno običnih igrača) neće ni primijetiti ovo kašnjenje. Ljudima je mnogo lakše da se prilagode konstantnoj latenciji od 100 ms nego da se igraju sa nepredvidivom latencijom.

Možemo koristiti i drugu tehniku ​​tzv predviđanje na strani klijenta, koji dobro radi na smanjenju uočenog kašnjenja, ali neće biti pokriven u ovom postu.

Još jedno poboljšanje koje koristimo je linearna interpolacija. Zbog kašnjenja u renderiranju, obično imamo barem jedno ažuriranje ispred trenutnog vremena u klijentu. Kada je pozvan getCurrentState(), možemo izvršiti linearna interpolacija između ažuriranja igre neposredno prije i nakon trenutnog vremena u klijentu:

Kreiranje .io web igre za više igrača
Ovo rješava problem brzine kadrova: sada možemo generirati jedinstvene okvire pri bilo kojoj brzini kadrova koju želimo!

7.3 Implementacija poboljšanog stanja klijenta

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

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

Prvi korak je da shvatite šta currentServerTime(). Kao što smo ranije vidjeli, svako ažuriranje igre uključuje vremensku oznaku servera. Želimo da koristimo kašnjenje renderiranja da bismo prikazali sliku 100 ms iza servera, ali nikada nećemo saznati trenutno vrijeme na serveru, 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 koristiti razumnu aproksimaciju: mi pretvarajte se da je prvo ažuriranje stiglo odmah. Da je ovo istina, tada bismo znali vrijeme servera u ovom konkretnom trenutku! U njega pohranjujemo vremensku oznaku servera firstServerTimestamp i zadrži naše lokalni (klijent) vremenska oznaka u istom trenutku u gameStart.

Sačekaj. Zar ne bi trebalo da bude vreme servera = vreme klijenta? Zašto pravimo razliku između "vremenske oznake servera" i "vremenske oznake klijenta"? Ovo je odlično pitanje! Ispostavilo se da nisu ista stvar. Date.now() će vratiti različite vremenske oznake na klijentu i serveru, a to zavisi od faktora koji su lokalni za ove mašine. Nikada nemojte pretpostavljati da će vremenske oznake biti iste na svim mašinama.

Sada razumijemo šta radi currentServerTime(): vraća se vremensku oznaku servera trenutnog vremena renderiranja. Drugim riječima, ovo je trenutno vrijeme servera (firstServerTimestamp <+ (Date.now() - gameStart)) minus kašnjenje renderiranja (RENDER_DELAY).

Sada pogledajmo kako postupamo s ažuriranjima igara. Kada se primi od servera za ažuriranje, poziva se processGameUpdate()a mi spremamo novo ažuriranje u niz gameUpdates. Zatim, da bismo provjerili korištenje memorije, prethodno uklanjamo sva stara ažuriranja ažuriranje bazejer nam vise ne trebaju.

Šta je "osnovno ažuriranje"? Ovo prvo ažuriranje koje pronađemo pomeranjem unazad od trenutnog vremena servera. Sjećate se ovog dijagrama?

Kreiranje .io web igre za više igrača
Ažuriranje igre direktno lijevo od "Client Render Time" je osnovno ažuriranje.

Za šta se koristi osnovno ažuriranje? Zašto možemo izbaciti ažuriranja na osnovnu vrijednost? Da to shvatimo, hajde napokon razmotriti implementaciju getCurrentState():

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

Obrađujemo tri slučaja:

  1. base < 0 znači da nema ažuriranja do trenutnog vremena renderiranja (pogledajte gornju implementaciju getBaseUpdate()). Ovo se može dogoditi odmah na početku igre zbog kašnjenja u renderiranju. U ovom slučaju koristimo najnoviju primljenu nadogradnju.
  2. base je najnovije ažuriranje koje imamo. Ovo može biti zbog kašnjenja mreže ili loše internetske veze. U ovom slučaju koristimo i najnovije ažuriranje koje imamo.
  3. Imamo ažuriranje i prije i nakon 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 da ga istražite, onda otvorite state.js na GitHub.

Dio 2. Backend server

U ovom dijelu ćemo pogledati Node.js backend koji kontrolira naš Primjer .io igre.

1. Server Entry Point

Za upravljanje web serverom koristit ćemo popularni web okvir za Node.js pod nazivom izraziti. To će biti konfigurisano našom datotekom ulazne tačke servera src/server/server.js:

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

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

  • Upotreba webpack-dev-middleware da automatski ponovo izgradimo naše razvojne pakete, ili
  • folder za statički prijenos dist/, u koji će Webpack upisivati ​​naše datoteke nakon izrade produkcije.

Još jedan važan zadatak server.js je postavljanje servera socket.iokoji se samo povezuje na Express server:

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

Nakon uspješnog uspostavljanja socket.io veze sa serverom, postavili smo rukovaoce događajima za novi socket. Rukovaoci događaja rukovaju porukama primljenim od klijenata delegiranjem na singleton objekt game:

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

Kreiramo .io igru, tako da nam je potrebna samo jedna kopija Game ("Igra") - svi igrači igraju u istoj areni! U sljedećem dijelu ćemo vidjeti kako ova klasa funkcionira. Game.

2. Serveri za igre

Класс Game sadrži najvažniju logiku na strani servera. Ima dva glavna zadatka: upravljanje igračima и simulacija igre.

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

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

  // ...
}

U ovoj igri ćemo identificirati igrače po polju id njihov socket.io socket (ako se zbunite, vratite se na server.js). Sam Socket.io svakom soketu dodjeljuje jedinstven idtako da ne treba da brinemo o tome. Ja ću ga nazvati ID igrača.

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

  • sockets je objekat koji veže ID igrača za utičnicu koja je pridružena igraču. Omogućava nam pristup utičnicama po njihovim ID-ovima igrača u stalnom vremenu.
  • players je objekt koji veže ID igrača za kod>Player objekt

bullets je niz objekata Bullet, koji nema određeni redosled.
lastUpdateTime je vremenska oznaka posljednjeg 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.

Poslednji red constructor() pokreće se ciklus ažuriranja igre (sa frekvencijom od 60 ažuriranja/s):

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

  // ...
}

Metoda update() sadrži možda najvažniji dio logike na strani servera. Evo šta 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 to dovoljno da znamo bullet.update() vraća trueako projektil bude uništen (izašao je iz arene).
  3. Ažurira svakog igrača i izbacuje projektil ako je potrebno. Videćemo i ovu implementaciju kasnije - player.update() može vratiti objekat Bullet.
  4. Provjerava sudare između projektila i igrača sa applyCollisions(), koji vraća niz projektila koji pogađaju igrače. Za svaki vraćeni projektil povećavamo bodove igrača koji ga je ispalio (koristeći player.onDealtDamage()), a 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 je pozvan update(). Ovo nam pomaže da pratimo gore spomenutu pomoćnu varijablu. shouldSendUpdate... Jer update() zovemo 60 puta/s, šaljemo ažuriranja igre 30 puta/s. dakle, frekvencija sata takt servera je 30 taktova/s (o taktovima smo govorili u prvom delu).

Zašto slati samo ažuriranja igara kroz vreme ? Da sačuvate kanal. 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 s brojem izazova. update(), jer je ovo računski skup zadatak - dovoljno je 60 u sekundi.

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

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

createUpdate() koristi se u update() da kreirate ažuriranja igara koja se distribuiraju igračima. Njegov glavni zadatak je pozivanje metoda serializeForUpdate()implementirano za nastavu Player и Bullet. Imajte na umu da samo prosljeđuje podatke svakom igraču o tome najbliži igrači i projektili - nema potrebe za prijenosom informacija o objektima igre koji su udaljeni od igrača!

3. Objekti igre na serveru

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

Ovde se ništa komplikovano ne dešava. Ova klasa će biti dobra tačka sidrišta za proširenje. Da vidimo kako je razred Bullet koristi Object:

bullet.js

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

class Bullet extends ObjectClass {
  constructor(parentID, x, y, dir) {
    super(shortid(), x, y, dir, Constants.BULLET_SPEED);
    this.parentID = parentID;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

Реализация Bullet veoma kratko! Dodali smo na Object samo sljedeće ekstenzije:

  • Korištenje paketa shortid za nasumično generisanje id projektil.
  • Dodavanje polja parentIDtako da možete pratiti igrača koji je kreirao ovaj projektil.
  • Dodavanje povratne vrijednosti u update(), što je jednako trueako je projektil izvan arene (sjećate se da smo o tome govorili u prošlom dijelu?).

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

  // 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 ovu klasu trebalo pohraniti još nekoliko polja. Njegov metod update() obavlja puno posla, posebno vraća novostvoreni projektil ako ga nema fireCooldown (sjećate se da smo o ovome govorili u prethodnom dijelu?). Takođe 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, nema klase Object svaki objekt igre mora imati istu implementaciju distanceTo(), i 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 klase rastu.

4. Detekcija sudara

Jedino što nam preostaje je da prepoznamo kada su projektili pogodili igrače! Zapamtite ovaj dio koda iz metode update() u razredu Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

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

  • Svi objekti koji se sudaraju su krugovi, što je najjednostavniji oblik za implementaciju detekcije sudara.
  • Već imamo metodu distanceTo(), koji smo implementirali u prethodnom odeljku u klasi Object.

Evo kako izgleda naša implementacija detekcije sudara:

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

Ova jednostavna detekcija sudara zasniva se na činjenici da dvije kružnice se sudaraju ako je udaljenost između njihovih centara manja od zbira njihovih polumjera. Evo slučaja kada je udaljenost između centara dvaju kružnica tačno jednaka zbroju njihovih polumjera:

Kreiranje .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 poređenjem bullet.parentID с player.id.
  • Projektil mora pogoditi samo jednom u graničnom slučaju sudara više igrača u isto vrijeme. Ovaj problem ćemo riješiti pomoću operatera break: čim se pronađe igrač koji se sudara sa projektilom, zaustavljamo potragu i prelazimo na sljedeći projektil.

Kraj

To je sve! Pokrili smo sve što trebate znati za kreiranje .io web igre. Šta je sledeće? Napravite vlastitu .io igru!

Sav uzorak koda je otvorenog koda i objavljen GitHub.

izvor: www.habr.com

Dodajte komentar