Ustvarjanje spletne igre .io za več igralcev

Ustvarjanje spletne igre .io za več igralcev
Izdano leta 2015 Agar.io postal začetnik novega žanra igre.ioki je od takrat postala priljubljena. Osebno sem izkusil porast priljubljenosti iger .io: v zadnjih treh letih sem ustvaril in prodal dve igri tega žanra..

Če še nikoli niste slišali za te igre, so to brezplačne spletne igre za več igralcev, ki jih je enostavno igrati (račun ni potreben). Običajno se soočijo s številnimi nasprotnimi igralci v isti areni. Druge znane igre .io: Slither.io и Diep.io.

V tej objavi bomo raziskali, kako ustvarite igro .io iz nič. Za to bo dovolj le znanje Javascripta: razumeti morate stvari, kot je sintaksa ES6, ključna beseda this и obljube. Tudi če Javascripta ne poznate popolnoma, lahko še vedno razumete večino objave.

Primer igre .io

Za pomoč pri učenju se bomo sklicevali na Primer igre .io. Poskusite igrati!

Ustvarjanje spletne igre .io za več igralcev
Igra je precej preprosta: nadzorujete ladjo v areni, kjer so še drugi igralci. Vaša ladja samodejno izstreljuje izstrelke, vi pa poskušate zadeti druge igralce, medtem ko se izogibate njihovim izstrelkom.

1. Kratek pregled/struktura projekta

priporoči prenesite izvorno kodo primer igre, da me lahko spremljate.

Primer uporablja naslednje:

  • Hitra je najbolj priljubljeno spletno ogrodje Node.js, ki upravlja spletni strežnik igre.
  • socket.io - knjižnica spletnih vtičnic za izmenjavo podatkov med brskalnikom in strežnikom.
  • Spletni paket - vodja modula. Lahko preberete, zakaj uporabljati Webpack. tukaj.

Tukaj je videti struktura imenika projekta:

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

javno /

Vse je v mapi public/ bo statično oddal strežnik. IN public/assets/ vsebuje slike, ki jih uporablja naš projekt.

src /

Vsa izvorna koda je v mapi src/. Naslovi client/ и server/ govorijo zase in shared/ vsebuje datoteko s konstantami, ki jo uvozita tako odjemalec kot strežnik.

2. Sklopi/nastavitve projekta

Kot že omenjeno, za izdelavo projekta uporabljamo upravitelja modulov. Spletni paket. Oglejmo si konfiguracijo našega spletnega paketa:

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

Najpomembnejše vrstice tukaj so:

  • src/client/index.js je vstopna točka odjemalca Javascript (JS). Webpack se bo začel od tu in rekurzivno iskal druge uvožene datoteke.
  • Izhodni JS naše zgradbe spletnega paketa bo v imeniku dist/. To datoteko bom imenoval naša js paket.
  • Uporabljamo Babel, predvsem pa konfiguracijo @babel/preset-env do prevajanja naše kode JS za starejše brskalnike.
  • Uporabljamo vtičnik za ekstrahiranje vseh CSS, na katere se sklicujejo datoteke JS, in jih združimo na enem mestu. Klical ga bom naš css paket.

Morda ste opazili čudna imena datotek paketov '[name].[contenthash].ext'. Vsebujejo zamenjave imen datotek Spletni paket: [name] bo nadomeščeno z imenom vnosne točke (v našem primeru to game), in [contenthash] bo nadomeščen z zgoščeno vrednostjo vsebine datoteke. To počnemo, da optimizirajte projekt za zgoščevanje - brskalnikom lahko naročite, naj predpomnijo naše pakete JS za nedoločen čas, ker če se paket spremeni, se spremeni tudi njegovo ime datoteke (spremembe contenthash). Končni rezultat bo ime datoteke pogleda game.dbeee76e91a97d0c7207.js.

datoteka webpack.common.js je osnovna konfiguracijska datoteka, ki jo uvozimo v razvojne in končne konfiguracije projekta. Na primer, tukaj je razvojna konfiguracija:

webpack.dev.js

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

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

Za učinkovitost uporabljamo v procesu razvoja webpack.dev.js, in preklopi na webpack.prod.jsza optimizacijo velikosti paketov pri uvajanju v proizvodnjo.

Lokalna nastavitev

Priporočam, da projekt namestite na lokalni računalnik, da boste lahko sledili korakom, navedenim v tej objavi. Nastavitev je preprosta: najprej mora imeti sistem Node и NPM. Naprej morate storiti

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

in pripravljeni ste na odhod! Za zagon razvojnega strežnika preprosto zaženite

$ npm run develop

in pojdite v spletni brskalnik localhost: 3000. Razvojni strežnik bo samodejno obnovil pakete JS in CSS, ko pride do sprememb kode - samo osvežite stran, da vidite vse spremembe!

3. Vstopne točke odjemalca

Pojdimo k sami kodi igre. Najprej potrebujemo stran index.html, ob obisku strani brskalnik najprej naloži le-to. Naša stran bo precej preprosta:

index.html

Primer igre .io  IGRAJ

Ta primer kode je bil nekoliko poenostavljen zaradi jasnosti in enako bom naredil z mnogimi drugimi primeri objave. Celotno kodo si lahko vedno ogledate na GitHub.

Imamo:

  • Element platna HTML5 (<canvas>), ki jih bomo uporabili za upodabljanje igre.
  • <link> da dodamo naš paket CSS.
  • <script> da dodamo naš paket Javascript.
  • Glavni meni z uporabniškim imenom <input> in gumb PLAY (<button>).

Po nalaganju domače strani bo brskalnik začel izvajati kodo Javascript, začenši z vstopno točko datoteke 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 = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

To se morda sliši zapleteno, vendar se tukaj pravzaprav ne dogaja veliko:

  1. Uvažanje več drugih datotek JS.
  2. Uvoz CSS (tako da jih Webpack ve vključiti v naš paket CSS).
  3. Izstrelite connect() vzpostaviti povezavo s strežnikom in zagnati downloadAssets() za prenos slik, potrebnih za upodabljanje igre.
  4. Po zaključku 3. stopnje prikaže se glavni meni (playMenu).
  5. Nastavitev upravljalnika za pritisk na gumb "PLAY". Ko pritisnemo gumb, koda inicializira igro in strežniku sporoči, da smo pripravljeni na igranje.

Glavno »meso« naše logike odjemalec-strežnik je v tistih datotekah, ki jih je uvozila datoteka index.js. Zdaj jih bomo obravnavali vse po vrstnem redu.

4. Izmenjava podatkov strank

V tej igri uporabljamo dobro znano knjižnico za komunikacijo s strežnikom socket.io. Socket.io ima izvorno podporo WebSockets, ki so zelo primerni za dvosmerno komunikacijo: lahko pošiljamo sporočila strežniku и strežnik nam lahko pošilja sporočila na isti povezavi.

Imeli bomo eno datoteko src/client/networking.jskdo bo poskrbel vse komunikacija s strežnikom:

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

Ta koda je bila zaradi jasnosti tudi nekoliko skrajšana.

V tej datoteki so tri glavna dejanja:

  • Poskušamo se povezati s strežnikom. connectedPromise dovoljeno le, ko smo vzpostavili povezavo.
  • Če je povezava uspešna, registriramo funkcije povratnega klica (processGameUpdate() и onGameOver()) za sporočila, ki jih lahko prejmemo s strežnika.
  • Izvažamo play() и updateDirection()tako da jih lahko uporabljajo druge datoteke.

5. Upodabljanje strank

Čas je za prikaz slike na zaslonu!

...toda preden lahko to storimo, moramo prenesti vse slike (vire), ki so za to potrebne. Napišimo upravitelja virov:

sredstva.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 virov ni tako težko izvesti! Glavna točka je shranjevanje predmeta assets, ki bo ključ imena datoteke povezal z vrednostjo objekta Image. Ko je vir naložen, ga shranimo v objekt assets za hiter dostop v prihodnosti. Kdaj bo prenos vsakega posameznega vira dovoljen (to je prenos Vsi virov), dovolimo downloadPromise.

Po prenosu virov lahko začnete upodabljati. Kot že rečeno, za risanje na spletni strani uporabljamo Platno HTML5 (<canvas>). Naša igra je precej preprosta, zato moramo narisati le naslednje:

  1. Ozadje
  2. Ladja igralca
  3. Drugi igralci v igri
  4. školjke

Tukaj so pomembni izrezki src/client/render.js, ki upodobijo točno štiri elemente, navedene zgoraj:

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

Ta koda je zaradi jasnosti tudi skrajšana.

render() je glavna funkcija te datoteke. startRendering() и stopRendering() nadzor aktivacije zanke upodabljanja pri 60 FPS.

Konkretne izvedbe posameznih funkcij za pomoč pri upodabljanju (npr. renderBullet()) niso tako pomembni, a tukaj je preprost primer:

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

Upoštevajte, da uporabljamo metodo getAsset(), ki smo ga pred tem videli v asset.js!

Če vas zanima raziskovanje drugih funkcij za pomoč pri upodabljanju, preberite preostanek src/client/render.js.

6. Vnos odjemalca

Čas je, da naredimo igro predvajati! Nadzorna shema bo zelo preprosta: za spremembo smeri gibanja lahko uporabite miško (na računalniku) ali se dotaknete zaslona (na mobilni napravi). Za izvedbo tega se bomo registrirali Poslušalci dogodkov za dogodke miške in dotika.
Bo poskrbel za vse to 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() so poslušalci dogodkov, ki kličejo updateDirection() (od networking.js), ko pride do vnosnega dogodka (na primer, ko se premakne miška). updateDirection() se ukvarja z izmenjavo sporočil s strežnikom, ki obdela vhodni dogodek in ustrezno posodobi stanje igre.

7. Status stranke

Ta del je najtežji v prvem delu objave. Naj vas ne obupa, če je ne razumete že ob prvem branju! Lahko ga celo preskočite in se k njemu vrnete pozneje.

Zadnji del sestavljanke, potreben za dokončanje kode odjemalec/strežnik, je so bili. Se spomnite izrezka kode iz razdelka Upodabljanje odjemalca?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() nam mora biti sposoben dati trenutno stanje igre v odjemalcu kadarkoli v času na podlagi posodobitev, prejetih s strežnika. Tukaj je primer posodobitve igre, ki jo lahko pošlje strežnik:

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

Vsaka posodobitev igre vsebuje pet enakih polj:

  • t: Časovni žig strežnika, ki označuje, kdaj je bila ustvarjena ta posodobitev.
  • me: Informacije o igralcu, ki prejema to posodobitev.
  • drugi: Niz informacij o drugih igralcih, ki sodelujejo v isti igri.
  • krogle: nabor informacij o izstrelkih v igri.
  • leaderboard: Trenutni podatki o lestvici najboljših. V tej objavi jih ne bomo upoštevali.

7.1 Stanje naivnega odjemalca

Naivna izvedba getCurrentState() lahko le neposredno vrne podatke o zadnji prejeti posodobitvi igre.

naivno-stanje.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Lepo in jasno! A ko bi le bilo tako preprosto. Eden od razlogov, zakaj je ta izvedba problematična: omejuje hitrost sličic upodabljanja na hitrost strežnika.

Hitrost sličic: število okvirjev (tj. klicev render()) na sekundo ali FPS. Igre si običajno prizadevajo doseči vsaj 60 FPS.

Tick ​​​​Rate: Pogostost, s katero strežnik pošilja posodobitve igre odjemalcem. Pogosto je nižja od hitrosti sličic. V naši igri strežnik teče s frekvenco 30 ciklov na sekundo.

Če upodabljamo samo zadnjo posodobitev igre, potem FPS v bistvu nikoli ne bo presegel 30, ker s strežnika nikoli ne prejmemo več kot 30 posodobitev na sekundo. Tudi če pokličemo render() 60-krat na sekundo, potem bo polovica teh klicev preprosto prerisala isto stvar, v bistvu pa ne bo naredila ničesar. Druga težava z naivno izvedbo je, da je nagnjeni k zamudam. Z idealno internetno hitrostjo bo odjemalec prejel posodobitev igre natanko vsakih 33 ms (30 na sekundo):

Ustvarjanje spletne igre .io za več igralcev
Na žalost nič ni popolno. Bolj realna slika bi bila:
Ustvarjanje spletne igre .io za več igralcev
Naivna izvedba je praktično najslabši primer, ko gre za zakasnitev. Če je posodobitev igre prejeta z zakasnitvijo 50 ms, potem stojnice za stranke dodatnih 50 ms, ker še vedno upodablja stanje igre iz prejšnje posodobitve. Lahko si predstavljate, kako neprijetno je to za igralca: zaradi samovoljnega zaviranja bo igra delovala sunkovito in nestabilno.

7.2 Izboljšano stanje odjemalca

V naivno izvedbo bomo naredili nekaj izboljšav. Najprej uporabljamo zakasnitev upodabljanja za 100 ms. To pomeni, da bo "trenutno" stanje odjemalca vedno zaostajalo za stanjem igre na strežniku za 100 ms. Na primer, če je čas na strežniku 150, potem bo odjemalec upodobil stanje, v katerem je bil takrat strežnik 50:

Ustvarjanje spletne igre .io za več igralcev
To nam daje 100 ms medpomnilnika za preživetje nepredvidljivih časov posodobitve igre:

Ustvarjanje spletne igre .io za več igralcev
Izplačilo za to bo trajno vnosni zamik za 100 ms. To je manjša žrtev za nemoteno igranje - večina igralcev (zlasti občasnih) te zamude sploh ne bo opazila. Ljudje se veliko lažje prilagodijo na konstantno zakasnitev 100 ms, kot pa se igrajo z nepredvidljivo zakasnitvijo.

Uporabimo lahko tudi drugo tehniko, imenovano napoved na strani odjemalca, ki dobro zmanjša zaznano zakasnitev, vendar v tej objavi ne bo obravnavana.

Druga izboljšava, ki jo uporabljamo, je linearna interpolacija. Zaradi zamika upodabljanja smo običajno vsaj eno posodobitev pred trenutnim časom v odjemalcu. Ob klicu getCurrentState(), lahko izvedemo linearna interpolacija med posodobitvami igre tik pred in po trenutnem času v odjemalcu:

Ustvarjanje spletne igre .io za več igralcev
To reši težavo s hitrostjo sličic: zdaj lahko upodabljamo edinstvene sličice s poljubno hitrostjo sličic!

7.3 Implementacija izboljšanega stanja odjemalca

Primer izvedbe v src/client/state.js uporablja tako zamik upodabljanja kot linearno interpolacijo, vendar ne za dolgo. Razčlenimo kodo na dva dela. Tukaj je prvi:

state.js 1. del

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 ugotoviti, kaj currentServerTime(). Kot smo videli prej, vsaka posodobitev igre vključuje časovni žig strežnika. Želimo uporabiti zakasnitev upodabljanja za upodabljanje slike 100 ms za strežnikom, vendar nikoli ne bomo izvedeli trenutnega časa na strežniku, ker ne moremo vedeti, koliko časa je trajalo, da je katera od posodobitev prispela do nas. Internet je nepredvidljiv in njegova hitrost se lahko zelo razlikuje!

Da bi se izognili tej težavi, lahko uporabimo razumen približek: mi pretvarjajte se, da je prva posodobitev prispela takoj. Če bi bilo to res, bi vedeli čas strežnika v tistem trenutku! Shranjujemo časovni žig strežnika firstServerTimestamp in obdržimo naše lokalni (stranka) časovni žig v istem trenutku v gameStart.

Oh počakaj. Ali ne bi moral biti čas strežnika = čas odjemalca? Zakaj razlikujemo med "časovnim žigom strežnika" in "časovnim žigom odjemalca"? To je odlično vprašanje! Izkazalo se je, da nista ista stvar. Date.now() vrne različne časovne žige v odjemalcu in strežniku, kar je odvisno od dejavnikov, ki so lokalni za te stroje. Nikoli ne predvidevajte, da bodo časovni žigi enaki na vseh napravah.

Zdaj razumemo, kaj počne currentServerTime(): vrne se časovni žig strežnika trenutnega časa upodabljanja. Z drugimi besedami, to je trenutni čas strežnika (firstServerTimestamp <+ (Date.now() - gameStart)) minus zakasnitev upodabljanja (RENDER_DELAY).

Zdaj pa poglejmo, kako obravnavamo posodobitve iger. Ko strežnik prejme posodobitev, se ta pokliče processGameUpdate()in novo posodobitev shranimo v polje gameUpdates. Nato za preverjanje porabe pomnilnika odstranimo vse prejšnje posodobitve osnovna posodobitevker jih ne potrebujemo več.

Kaj je "osnovna posodobitev"? to prva posodobitev, ki jo najdemo s premikanjem nazaj od trenutnega časa strežnika. Se spomnite tega diagrama?

Ustvarjanje spletne igre .io za več igralcev
Posodobitev igre neposredno levo od »Čas upodabljanja odjemalca« je osnovna posodobitev.

Za kaj se uporablja osnovna posodobitev? Zakaj lahko spustimo posodobitve v bazo? Da bi to razumeli, dajmo končno razmislite o izvedbi getCurrentState():

state.js 2. del

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

Obravnavamo tri primere:

  1. base < 0 pomeni, da do trenutnega časa upodabljanja ni posodobitev (glejte zgornjo izvedbo getBaseUpdate()). To se lahko zgodi takoj na začetku igre zaradi zakasnitve upodabljanja. V tem primeru uporabimo zadnjo prejeto posodobitev.
  2. base je najnovejša posodobitev, ki jo imamo. To je lahko posledica zakasnitve omrežja ali slabe internetne povezave. V tem primeru uporabljamo tudi najnovejšo posodobitev, ki jo imamo.
  3. Imamo posodobitev pred in po trenutnem času upodabljanja, tako da lahko interpolirati!

Vse, kar je ostalo noter state.js je izvedba linearne interpolacije, ki je preprosta (a dolgočasna) matematika. Če želite sami raziskati, odprite state.js o GitHub.

2. del. Zaledni strežnik

V tem delu si bomo ogledali zaledje Node.js, ki nadzoruje naše Primer igre .io.

1. Vstopna točka strežnika

Za upravljanje spletnega strežnika bomo uporabili priljubljeno spletno ogrodje za Node.js, imenovano Hitra . Konfigurirana bo z našo datoteko vstopne točke strežnika src/server/server.js:

server.js 1. del

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

Se spomnite, da smo v prvem delu razpravljali o Webpacku? Tukaj bomo uporabili naše konfiguracije Webpack. Uporabili jih bomo na dva načina:

  • Za uporabo webpack-dev-middleware za samodejno obnovo naših razvojnih paketov, oz
  • mapo za statični prenos dist/, v katerega bo Webpack zapisal naše datoteke po produkcijski gradnji.

Druga pomembna naloga server.js je nastavitev strežnika socket.ioki se samo poveže s strežnikom Express:

server.js 2. del

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

Po uspešni vzpostavitvi povezave socket.io s strežnikom smo nastavili obdelovalce dogodkov za novo vtičnico. Obdelovalci dogodkov obravnavajo sporočila, prejeta od odjemalcev, tako da prenesejo na posamezen objekt game:

server.js 3. del

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

Ustvarjamo igro .io, zato bomo potrebovali samo eno kopijo Game (»Igra«) – vsi igralci igrajo v isti areni! V naslednjem razdelku bomo videli, kako ta razred deluje Game.

2. Strežniki za igre

Razred Game vsebuje najpomembnejšo logiko na strani strežnika. Ima dve glavni nalogi: vodenje igralcev и simulacija igre.

Začnimo s prvo nalogo – upravljanjem igralcev.

game.js, 1. del

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

  // ...
}

V tej igri bomo identificirali igralce po polju id njihova vtičnica socket.io (če ste zmedeni, se vrnite na server.js). Socket.io sam dodeli vsaki vtičnici edinstveno id, zato nam ni treba skrbeti. ga bom poklical ID igralca.

S tem v mislih preučimo spremenljivke primerkov v razredu Game:

  • sockets je objekt, ki povezuje ID igralca z vtičnico, ki je povezana s predvajalnikom. Omogoča nam dostop do vtičnic po njihovih ID-jih igralcev skozi čas.
  • players je objekt, ki veže ID igralca na kodo>predmet Igralec

bullets je niz predmetov Bullet, ki nima določenega reda.
lastUpdateTime je časovni žig zadnje posodobitve igre. Kmalu bomo videli, kako se bo uporabljal.
shouldSendUpdate je pomožna spremenljivka. Kmalu bomo videli tudi njegovo uporabo.
Metode addPlayer(), removePlayer() и handleInput() ni treba pojasnjevati, uporabljajo se v server.js. Če potrebujete osvežitev, se vrnite malo višje.

Zadnja vrstica constructor() zažene cikel posodabljanja igre (s frekvenco 60 posodobitev / s):

game.js, 2. del

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() vsebuje verjetno najpomembnejši del logike na strani strežnika. Naštejmo vse, kar počne po vrstnem redu:

  1. Izračuna, kako dolgo dt minilo od zadnjega update().
  2. Osveži vsak projektil in ga po potrebi uniči. Implementacijo te funkcionalnosti bomo videli kasneje. Za zdaj je dovolj, da to vemo bullet.update() vrne true, če je treba projektil uničiti (stopil je iz arene).
  3. Posodobi vsakega igralca in po potrebi sproži projektil. Kasneje bomo videli tudi to izvedbo - player.update() lahko vrne predmet Bullet.
  4. Preverja trke med projektili in igralci z applyCollisions(), ki vrne niz izstrelkov, ki so zadeli igralce. Za vsak vrnjen izstrelek povečamo točke igralca, ki ga je izstrelil (z uporabo player.onDealtDamage()), in nato odstranite izstrelek iz niza bullets.
  5. Obvesti in uniči vse ubite igralce.
  6. Pošlje posodobitev igre vsem igralcem vsako sekundo krat ob klicu update(). To nam pomaga slediti zgoraj omenjeni pomožni spremenljivki. shouldSendUpdate... Kot update() klicano 60-krat/s, pošljemo posodobitve igre 30-krat/s. torej urna frekvenca ura strežnika je 30 taktov/s (o taktih smo govorili v prvem delu).

Zakaj pošiljati samo posodobitve iger skozi čas ? Za shranjevanje kanala. 30 posodobitev igre na sekundo je veliko!

Zakaj potem preprosto ne pokličeš? update() 30-krat na sekundo? Za izboljšanje simulacije igre. Pogosteje imenovani update(), bolj natančna bo simulacija igre. Vendar naj vas število izzivov ne zanese preveč. update(), ker je to računsko drago opravilo – 60 na sekundo je povsem dovolj.

Ostali v razredu Game sestavljajo pomožne metode, ki se uporabljajo v update():

game.js, 3. del

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() precej preprosto - razvrsti igralce po rezultatu, vzame najboljših pet in za vsakega vrne uporabniško ime in rezultat.

createUpdate() uporablja v update() za ustvarjanje posodobitev igre, ki se razdelijo igralcem. Njegova glavna naloga je klicanje metod serializeForUpdate()izvajajo za razrede Player и Bullet. Upoštevajte, da vsakemu igralcu posreduje le podatke o najbližji igralci in projektili - ni potrebe po posredovanju informacij o predmetih igre, ki so daleč od igralca!

3. Predmeti igre na strežniku

V naši igri so si projektili in igralci pravzaprav zelo podobni: so abstraktni okrogli premikajoči se predmeti igre. Da bi izkoristili to podobnost med igralci in projektili, začnimo z implementacijo osnovnega razreda 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,
    };
  }
}

Tu se ne dogaja nič zapletenega. Ta razred bo dobro izhodišče za širitev. Poglejmo, kako je razred Bullet uporablja 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 zelo kratko! Dodali smo k Object samo naslednje razširitve:

  • Uporaba paketa shortid za naključno generiranje id projektil.
  • Dodajanje polja parentIDtako da lahko sledite igralcu, ki je ustvaril ta projektil.
  • Dodajanje povratne vrednosti v update(), kar je enako true, če je projektil zunaj arene (se spomnite, da smo o tem govorili v prejšnjem razdelku?).

Pojdimo naprej 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,
    };
  }
}

Igralci so bolj zapleteni kot projektili, zato bi moral ta razred shraniti nekaj več polj. Njegova metoda update() opravi več dela, zlasti vrne novo ustvarjen projektil, če ga ni več fireCooldown (se spomnite, da smo o tem govorili v prejšnjem razdelku?). Prav tako razširja metodo serializeForUpdate(), ker moramo v posodobitev igre vključiti dodatna polja za igralca.

Imeti osnovni razred Object - pomemben korak za izogibanje ponavljanju kode. Na primer, brez razreda Object vsak predmet igre mora imeti enako izvedbo distanceTo(), kopiranje in lepljenje vseh teh izvedb v več datotekah pa bi bila nočna mora. To postane še posebej pomembno pri velikih projektih.ko se število širi Object razredi rastejo.

4. Zaznavanje trčenja

Edino, kar nam preostane, je, da prepoznamo, kdaj projektili zadenejo igralce! Zapomnite si ta delček kode iz metode update() v razredu 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),
    );

    // ...
  }
}

Metodo moramo izvajati applyCollisions(), ki vrne vse izstrelke, ki so zadeli igralce. Na srečo to ni tako težko narediti, ker

  • Vsi trčeni predmeti so krogi in to je najpreprostejša oblika za implementacijo zaznavanja trka.
  • Metodo že imamo distanceTo(), ki smo ga izvajali v prejšnjem razdelku v razredu Object.

Tako izgleda naša implementacija zaznavanja trkov:

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

To preprosto zaznavanje trka temelji na dejstvu, da dva kroga trčita, če je razdalja med njunima središčema manjša od vsote njunih polmerov. Tukaj je primer, ko je razdalja med središči dveh krogov popolnoma enaka vsoti njunih polmerov:

Ustvarjanje spletne igre .io za več igralcev
Tukaj morate biti pozorni na še nekaj vidikov:

  • Projektil ne sme zadeti igralca, ki ga je ustvaril. To lahko dosežemo s primerjavo bullet.parentID с player.id.
  • Projektil naj zadene samo enkrat v skrajnem primeru, ko zadene več igralcev hkrati. To težavo bomo rešili z uporabo operaterja break: takoj ko najdemo igralca, ki trči z izstrelkom, prekinemo iskanje in preidemo na naslednji izstrelek.

End

To je vse! Pokrili smo vse, kar morate vedeti za ustvarjanje spletne igre .io. Kaj je naslednje? Zgradite svojo igro .io!

Vsa vzorčna koda je odprtokodna in objavljena na GitHub.

Vir: www.habr.com

Dodaj komentar