Mitme mängijaga .io veebimängu loomine

Mitme mängijaga .io veebimängu loomine
Välja antud 2015. aastal Agar.io sai uue žanri eelkäijaks mängud .iomis on sellest ajast alates populaarsust kasvatanud. Olen isiklikult kogenud .io mängude populaarsuse tõusu: viimase kolme aasta jooksul olen seda kogenud lõi ja müüs kaks selle žanri mängu..

Kui te pole neist mängudest varem kuulnud, on need tasuta mitme mängijaga veebimängud, mida on lihtne mängida (kontot pole vaja). Tavaliselt seisavad nad samal areenil vastamisi paljude vastasmängijatega. Teised kuulsad .io mängud: Slither.io и Diep.io.

Selles postituses uurime, kuidas looge .io mäng nullist. Selleks piisab ainult Javascripti tundmisest: peate mõistma selliseid asju nagu süntaks ES6, märksõna this и Lubadused. Isegi kui teie teadmised Javascriptist pole täiuslikud, saate enamikust postitusest siiski aru.

.io mängu näide

Õppimise abi saamiseks viitame .io mängu näide. Proovige seda mängida!

Mitme mängijaga .io veebimängu loomine
Mäng on üsna lihtne: juhid laeva areenil, kus on teisi mängijaid. Teie laev laseb automaatselt välja mürsud ja proovite tabada teisi mängijaid, vältides samal ajal nende mürske.

1. Projekti lühiülevaade / struktuur

Ma soovitan laadige alla lähtekood näitemängu, et saaksite mind jälgida.

Näites kasutatakse järgmist:

  • Ekspress on kõige populaarsem Node.js veebiraamistik, mis haldab mängu veebiserverit.
  • pistikupesa.io - veebisocketiteek brauseri ja serveri vaheliseks andmevahetuseks.
  • Veebipakk - mooduli juht. Saate lugeda, miks Webpacki kasutada. siin.

Projekti kataloogi struktuur näeb välja järgmine:

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

avalik/

Kõik kaustas public/ esitab server staatiliselt. IN public/assets/ sisaldab meie projektis kasutatud pilte.

src /

Kogu lähtekood on kaustas src/. Pealkirjad client/ и server/ räägi enda eest ja shared/ sisaldab konstantide faili, mille impordivad nii klient kui ka server.

2. Kooste/projekti seadistused

Nagu eespool mainitud, kasutame projekti koostamiseks moodulihaldurit. Veebipakk. Vaatame oma veebipaketi konfiguratsiooni:

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

Siin on kõige olulisemad read:

  • src/client/index.js on Javascripti (JS) kliendi sisenemispunkt. Webpack alustab siit ja otsib rekursiivselt teisi imporditud faile.
  • Meie Webpacki järgu väljund-JS asub kataloogis dist/. Ma nimetan seda faili meie js pakett.
  • Me kasutame Babelja eriti konfiguratsiooni @babel/preset-env meie JS-koodi ülekandmiseks vanematele brauseritele.
  • Me kasutame pistikprogrammi, et ekstraktida kõik JS-failide viidatud CSS-id ja ühendada need ühte kohta. Ma kutsun teda meie css pakett.

Võib-olla olete märganud kummalisi pakettide failinimesid '[name].[contenthash].ext'. Need sisaldavad failinimede asendused Veebipakk: [name] asendatakse sisendpunkti nimega (meie puhul see game) ja [contenthash] asendatakse faili sisu räsiga. Me teeme seda selleks optimeerida projekti räsimiseks - saate brauseritel käskida meie JS-pakette määramata ajaks vahemällu salvestada, kuna kui pakett muutub, muutub ka selle faili nimi (muutused contenthash). Lõpptulemuseks on vaatefaili nimi game.dbeee76e91a97d0c7207.js.

fail webpack.common.js on põhikonfiguratsioonifail, mille impordime arendus- ja valmisprojekti konfiguratsioonidesse. Siin on arenduskonfiguratsiooni näide:

webpack.dev.js

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

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

Tõhususe huvides kasutame arendusprotsessis webpack.dev.jsja lülitub sisse webpack.prod.jsPakendi suuruse optimeerimiseks tootmisse juurutamisel.

Kohalik seadistus

Soovitan installida projekti kohalikku masinasse, et saaksite järgida selles postituses loetletud samme. Seadistamine on lihtne: esiteks peab süsteem olema installitud sõlme и NPM. Järgmisena peate tegema

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

ja olete valmis minema! Arendusserveri käivitamiseks lihtsalt käivitage

$ npm run develop

ja minge veebibrauserisse localhost: 3000. Arendusserver ehitab koodi muutudes automaatselt uuesti JS- ja CSS-paketid – kõigi muudatuste nägemiseks värskendage lehte!

3. Kliendi sisenemispunktid

Läheme mängu koodi enda juurde. Kõigepealt vajame lehte index.html, kui külastate saiti, laadib brauser selle kõigepealt. Meie leht saab olema üsna lihtne:

index.html

io mängu näide  MÄNGI

Seda koodinäidet on selguse huvides veidi lihtsustatud ja ma teen sama paljude teiste postitusnäidetega. Täielikku koodi saab alati vaadata aadressil Github.

Meil on:

  • HTML5 lõuendi element (<canvas>), mida kasutame mängu renderdamiseks.
  • <link> et lisada meie CSS-pakett.
  • <script> meie Javascripti paketi lisamiseks.
  • Peamenüü koos kasutajanimega <input> ja nuppu PLAY (<button>).

Pärast avalehe laadimist hakkab brauser käivitama Javascripti koodi, alustades sisestuspunkti JS-failist: 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);
  };
});

See võib tunduda keeruline, kuid siin pole palju tegemist:

  1. Mitme muu JS-faili importimine.
  2. CSS-i import (nii et Webpack teab neid meie CSS-i paketti lisada).
  3. Käivita connect() serveriga ühenduse loomiseks ja käivitamiseks downloadAssets() mängu renderdamiseks vajalike piltide allalaadimiseks.
  4. Pärast 3. etapi lõpetamist kuvatakse peamenüü (playMenu).
  5. Käsitseja seadistamine nupu "PLAY" vajutamiseks. Nupu vajutamisel initsialiseerib kood mängu ja teatab serverile, et oleme mängimiseks valmis.

Meie klient-server loogika peamine "liha" on nendes failides, mis faili imporditi index.js. Nüüd kaalume neid kõiki järjekorras.

4. Kliendiandmete vahetamine

Selles mängus kasutame serveriga suhtlemiseks tuntud teeki pistikupesa.io. Socket.io-l on algne tugi WebSockets, mis sobivad hästi kahepoolseks suhtluseks: saame saata sõnumeid serverisse и server saab meile sama ühenduse kaudu sõnumeid saata.

Meil on üks fail src/client/networking.jskes hoolitseb kõigi poolt side serveriga:

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

Seda koodi on selguse huvides ka veidi lühendatud.

Selles failis on kolm peamist toimingut:

  • Püüame serveriga ühendust luua. connectedPromise lubatud ainult siis, kui oleme ühenduse loonud.
  • Kui ühendus õnnestub, registreerime tagasihelistamisfunktsioonid (processGameUpdate() и onGameOver()) sõnumite jaoks, mida saame serverilt vastu võtta.
  • Ekspordime play() и updateDirection()et teised failid saaksid neid kasutada.

5. Kliendi renderdamine

On aeg pilt ekraanile kuvada!

…aga enne kui saame seda teha, peame alla laadima kõik selleks vajalikud pildid (ressursid). Kirjutame ressursihalduri:

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

Ressursihaldust pole nii raske rakendada! Põhiidee on objekti hoiustamine assets, mis seob failinime võtme objekti väärtusega Image. Kui ressurss on laaditud, salvestame selle objekti assets edaspidiseks kiireks juurdepääsuks. Millal lubatakse iga üksiku ressursi allalaadimine (st kõik ressursse), lubame downloadPromise.

Pärast ressursside allalaadimist võite alustada renderdamist. Nagu varem öeldud, kasutame veebilehele joonistamiseks HTML5 lõuend (<canvas>). Meie mäng on üsna lihtne, seega peame joonistama ainult järgmise:

  1. Фон
  2. Mängija laev
  3. Teised mängijad mängus
  4. Kestad

Siin on olulised väljavõtted src/client/render.js, mis renderdavad täpselt neli ülaltoodud üksust:

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

Seda koodi on ka selguse huvides lühendatud.

render() on selle faili põhifunktsioon. startRendering() и stopRendering() juhtida renderdustsükli aktiveerimist kiirusel 60 kaadrit sekundis.

Üksikute renderdusabifunktsioonide konkreetsed teostused (nt. renderBullet()) pole nii olulised, kuid siin on üks lihtne näide:

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

Pange tähele, et me kasutame meetodit getAsset(), mida oli varem nähtud aastal asset.js!

Kui olete huvitatud muude renderdusabiliste kohta õppimisest, lugege ülejäänud osa. src/client/render.js.

6. Kliendi sisend

On aeg teha mäng mängitav! Juhtimisskeem saab olema väga lihtne: liikumissuuna muutmiseks võite kasutada hiirt (arvutis) või puudutada ekraani (mobiilseadmes). Selle rakendamiseks registreerime Ürituse kuulajad Mouse and Touch sündmuste jaoks.
Hoolitseb selle kõige eest 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() on sündmuste kuulajad, kes helistavad updateDirection() (kohta networking.js), kui toimub sisendsündmus (näiteks kui hiirt liigutatakse). updateDirection() haldab sõnumivahetust serveriga, mis tegeleb sisendsündmusega ja värskendab vastavalt mängu olekut.

7. Kliendi staatus

See osa on postituse esimeses osas kõige raskem. Ärge heitke meelt, kui te sellest esimesel lugemisel aru ei saa! Võite selle isegi vahele jätta ja selle juurde hiljem tagasi tulla.

Viimane pusletükk, mis on vajalik kliendi/serveri koodi täitmiseks, on riik. Kas mäletate koodilõiku jaotisest Kliendi renderdamine?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() peaks suutma anda meile kliendi mängu hetkeseisu igal ajahetkel serverist saadud uuenduste põhjal. Siin on näide mänguvärskendusest, mida server saab saata:

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

Iga mängu värskendus sisaldab viit identset välja:

  • t: serveri ajatempel, mis näitab selle värskenduse loomise aega.
  • me: teave selle värskenduse saava mängija kohta.
  • teised: hulk teavet teiste samas mängus osalevate mängijate kohta.
  • täppe: hulk teavet mängu mürskude kohta.
  • edetabel: praegused edetabeli andmed. Selles postituses me neid ei käsitle.

7.1 Naiivne kliendi olek

Naiivne teostus getCurrentState() saab otse tagastada ainult viimati saadud mängu värskenduse andmed.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Kena ja selge! Aga kui see vaid nii lihtne oleks. Üks põhjusi, miks see rakendamine on problemaatiline: see piirab renderdamise kaadrisagedust serveri taktsagedusega.

Kaadrisagedus: kaadrite (st kõnede) arv render()) sekundis ehk FPS. Mängud püüavad tavaliselt saavutada vähemalt 60 kaadrit sekundis.

Tick ​​Rate: sagedus, millega server saadab klientidele mänguvärskendusi. Sageli on see kaadrisagedusest madalam. Meie mängus töötab server sagedusega 30 tsüklit sekundis.

Kui me lihtsalt renderdame mängu uusima värskenduse, ei lähe FPS sisuliselt kunagi üle 30, sest me ei saa serverist kunagi rohkem kui 30 värskendust sekundis. Isegi kui helistame render() 60 korda sekundis, siis pooled neist kõnedest lihtsalt joonistavad sama asja ümber, sisuliselt mitte midagi tegemata. Naiivse rakendamise teine ​​probleem on see kalduvus viivitustele. Ideaalse Interneti-kiiruse korral saab klient mängu värskenduse täpselt iga 33 ms järel (30 sekundis):

Mitme mängijaga .io veebimängu loomine
Kahjuks pole miski täiuslik. Reaalsem pilt oleks:
Mitme mängijaga .io veebimängu loomine
Naiivne rakendamine on latentsuse osas praktiliselt halvim juhtum. Kui mängu värskendus saabub 50 ms viivitusega, siis kliendi kioskites 50 ms lisaaega, sest see renderdab endiselt eelmise värskenduse mängu olekut. Võite ette kujutada, kui ebamugav see mängija jaoks on: meelevaldne pidurdamine muudab mängu tõmblevaks ja ebastabiilseks.

7.2 Parem kliendi olek

Teeme naiivsesse juurutusse mõned parandused. Esiteks kasutame renderdamise viivitus 100 ms jaoks. See tähendab, et kliendi "praegune" olek jääb alati 100 ms võrra maha serveri mängu olekust. Näiteks kui serveris olev aeg on 150, siis renderdab klient oleku, milles server sel ajal oli 50:

Mitme mängijaga .io veebimängu loomine
See annab meile 100 ms puhvri ettearvamatute mänguvärskendusaegade üleelamiseks:

Mitme mängijaga .io veebimängu loomine
Tasu selle eest on püsiv sisendi viivitus 100 ms jaoks. See on väike ohver mängu sujuvaks mängimiseks – enamik mängijaid (eriti juhuslikud mängijad) ei pane seda viivitust isegi tähele. Inimestel on palju lihtsam kohaneda pideva 100 ms latentsusega kui mängida ettearvamatu latentsusega.

Võime kasutada ka teist tehnikat, mida nimetatakse kliendipoolne ennustus, mis vähendab hästi tajutavat latentsust, kuid seda selles postituses ei käsitleta.

Teine parendus, mida me kasutame, on lineaarne interpolatsioon. Renderdusviivituse tõttu oleme kliendis tavaliselt vähemalt ühe uuenduse praegusest ajast ees. Kui kutsutakse getCurrentState(), saame teostada lineaarne interpolatsioon mänguvärskenduste vahel vahetult enne ja pärast kliendi praegust kellaaega:

Mitme mängijaga .io veebimängu loomine
See lahendab kaadrisageduse probleemi: saame nüüd renderdada kordumatuid kaadreid mis tahes soovitud kaadrisagedusega!

7.3 Täiustatud kliendi oleku rakendamine

Rakenduse näide sisse src/client/state.js kasutab nii renderdusviivitust kui ka lineaarset interpolatsiooni, kuid mitte kaua. Jagame koodi kaheks osaks. Siin on esimene:

state.js 1. osa

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

Esimene samm on välja mõelda, mida currentServerTime(). Nagu varem nägime, sisaldab iga mängu värskendus serveri ajatemplit. Tahame kasutada renderdamislatentsi, et renderdada pilt 100 ms serverist tagapool, kuid me ei saa kunagi teada praegust kellaaega serveris, sest me ei saa teada, kui kaua kulus, enne kui mõni värskendus meieni jõudis. Internet on ettearvamatu ja selle kiirus võib olla väga erinev!

Sellest probleemist mööda hiilimiseks võime kasutada mõistlikku ligikaudset arvestust: me oletada, et esimene värskendus saabus kohe. Kui see oleks tõsi, siis me teaksime serveri aega sellel konkreetsel hetkel! Salvestame serveri ajatempli sisse firstServerTimestamp ja hoia meie kohalik (kliendi) ajatempel samal hetkel sisse gameStart.

Oh oota. Kas see ei peaks olema serveri aeg = kliendi aeg? Miks me eristame "serveri ajatemplit" ja "kliendi ajatemplit"? See on suurepärane küsimus! Selgub, et need pole samad asjad. Date.now() tagastab kliendis ja serveris erinevad ajatemplid ning see sõltub nende masinate kohalikest teguritest. Ärge kunagi eeldage, et ajatemplid on kõigil masinatel ühesugused.

Nüüd saame aru, mis teeb currentServerTime(): see naaseb praeguse renderdusaja serveri ajatempel. Teisisõnu, see on serveri praegune aeg (firstServerTimestamp <+ (Date.now() - gameStart)) miinus renderdusviivitus (RENDER_DELAY).

Nüüd vaatame, kuidas me mängude värskendusi käsitleme. Värskendusserverist vastuvõtmisel kutsutakse see välja processGameUpdate()ja salvestame uue värskenduse massiivi gameUpdates. Seejärel eemaldame mälukasutuse kontrollimiseks kõik vanad värskendused baasvärskendussest me ei vaja neid enam.

Mis on "põhivärskendus"? See esimene värskendus, mille leiame serveri praegusest kellaajast tagasi liikudes. Kas mäletate seda diagrammi?

Mitme mängijaga .io veebimängu loomine
Mängu värskendus otse "Client Render Time" vasakul pool on põhivärskendus.

Milleks baasvärskendust kasutatakse? Miks saame värskendused algtasemele loobuda? Selle välja selgitamiseks teeme lõpuks kaaluge rakendamist getCurrentState():

state.js 2. osa

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

Käsitleme kolme juhtumit:

  1. base < 0 tähendab, et praeguse renderdusajani pole värskendusi (vt ülaltoodud rakendust getBaseUpdate()). See võib renderdusviivituse tõttu juhtuda kohe mängu alguses. Sel juhul kasutame uusimat saadud värskendust.
  2. base on meie uusim värskendus. Selle põhjuseks võib olla võrgu viivitus või halb Interneti-ühendus. Sel juhul kasutame ka uusimat värskendust.
  3. Meil on värskendus nii enne kui ka pärast praegust renderdusaega, nii et saame interpoleerida!

Kõik, mis sisse jääb state.js on lineaarse interpolatsiooni teostus, mis on lihtne (kuid igav) matemaatika. Kui soovite seda ise uurida, siis avage state.js edasi Github.

2. osa. Taustaserver

Selles osas heidame pilgu Node.js-i taustaprogrammile, mis kontrollib meie .io mängu näide.

1. Serveri sisenemispunkt

Veebiserveri haldamiseks kasutame Node.js jaoks populaarset veebiraamistikku nimega Ekspress. Selle konfigureerib meie serveri sisenemispunkti fail src/server/server.js:

server.js 1. osa

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

Kas mäletate, et esimeses osas arutasime Webpacki üle? Siin kasutame oma veebipaketi konfiguratsioone. Kasutame neid kahel viisil:

  • Kasutage webpack-dev-middleware meie arenduspakettide automaatseks ümberehitamiseks või
  • staatiliselt teisaldada kaust dist/, millesse Webpack pärast tootmisjärgu meie failid kirjutab.

Teine oluline ülesanne server.js on serveri seadistamine pistikupesa.iomis lihtsalt ühendub Express serveriga:

server.js 2. osa

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

Pärast edukat socket.io ühenduse loomist serveriga seadistasime uue pesa jaoks sündmuste töötlejad. Sündmuste töötlejad töötlevad klientidelt saadud sõnumeid, delegeerides need ühele objektile game:

server.js 3. osa

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

Loome .io mängu, seega vajame ainult ühte eksemplari Game ("Mäng") – kõik mängijad mängivad samal areenil! Järgmises osas näeme, kuidas see klass töötab. Game.

2. Mänguserverid

Klass Game sisaldab serveripoolset kõige olulisemat loogikat. Sellel on kaks peamist ülesannet: mängija juhtimine и mängu simulatsioon.

Alustame esimese ülesandega, mängijate juhtimisega.

game.js 1. osa

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

  // ...
}

Selles mängus selgitame välja mängijad id nende socket.io pesa (kui jääte segadusse, minge tagasi server.js). Socket.io ise määrab igale pistikupesale ainulaadse idnii et me ei pea selle pärast muretsema. ma helistan talle Mängija ID.

Seda silmas pidades uurime klassis esinevaid muutujaid Game:

  • sockets on objekt, mis seob mängija ID mängijaga seotud pesaga. See võimaldab meil konstantse aja jooksul juurdepääsu pistikupesadele nende mängija ID-de järgi.
  • players on objekt, mis seob mängija ID koodiga>Mängija objekt

bullets on objektide hulk Bullet, millel pole kindlat järjekorda.
lastUpdateTime on mängu viimase värskendamise ajatempel. Vaatame varsti, kuidas seda kasutatakse.
shouldSendUpdate on abimuutuja. Peagi näeme ka selle kasutamist.
Meetodid addPlayer(), removePlayer() и handleInput() pole vaja seletada, neid kasutatakse server.js. Kui teil on vaja mälu värskendada, minge veidi kõrgemale tagasi.

Viimane rida constructor() käivitab värskendustsükkel mängud (sagedusega 60 värskendust sekundis):

game.js 2. osa

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

  // ...
}

Meetod update() sisaldab võib-olla kõige olulisemat osa serveripoolsest loogikast. Järjekorras on see, mida see teeb.

  1. Arvutab, kui kaua dt möödunud viimasest update().
  2. Värskendab iga mürsku ja vajadusel hävitab. Selle funktsiooni rakendamist näeme hiljem. Praegu piisab, kui me seda teame bullet.update() naaseb truekui mürsk tuleks hävitada (ta astus areenilt välja).
  3. Värskendab iga mängijat ja loob vajadusel mürsu. Seda rakendamist näeme ka hiljem − player.update() saab objekti tagastada Bullet.
  4. Kontrollib kokkupõrkeid mürskude ja mängijate vahel applyCollisions(), mis tagastab hulga mängijaid tabanud mürske. Iga tagastatud mürsu eest suurendame selle tulistanud mängija punkte (kasutades player.onDealtDamage()) ja seejärel eemaldage mürsk massiivist bullets.
  5. Teatab ja hävitab kõik tapetud mängijad.
  6. Saadab mängu värskenduse kõigile mängijatele iga sekund kordi, kui helistati update(). See aitab meil ülalmainitud abimuutujat jälgida. shouldSendUpdate... Sest update() helistatakse 60 korda/s, saadame mänguvärskendusi 30 korda/s. Seega kella sagedus serveri kell on 30 kella/s (kellasagedustest rääkisime esimeses osas).

Miks saata ainult mänguvärskendusi läbi aja ? Kanali salvestamiseks. 30 mänguvärskendust sekundis on palju!

Miks mitte lihtsalt helistada update() 30 korda sekundis? Mängu simulatsiooni täiustamiseks. Mida sagedamini kutsutakse update(), seda täpsem on mängu simulatsioon. Kuid ärge laske väljakutsete arvust liiga vaimustuda. update(), sest see on arvutuslikult kulukas ülesanne – piisab 60 sekundis.

Ülejäänud klass Game koosneb aastal kasutatud abimeetoditest update():

game.js 3. osa

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() üsna lihtne – see sorteerib mängijad skoori järgi, võtab esiviisiku ning tagastab igaühe kasutajanime ja skoori.

createUpdate() kasutatakse update() mänguvärskenduste loomiseks, mida mängijatele jagatakse. Selle peamine ülesanne on kutsuda meetodeid serializeForUpdate()klasside jaoks rakendatud Player и Bullet. Pange tähele, et see edastab igale mängijale andmeid ainult umbes lähim mängijad ja mürsud - pole vaja edastada teavet mängijast kaugel asuvate mänguobjektide kohta!

3. Mänguobjektid serveris

Meie mängus on mürsud ja mängijad tegelikult väga sarnased: need on abstraktsed, ümmargused, liigutatavad mänguobjektid. Mängijate ja mürskude sarnasuse ärakasutamiseks alustame baasklassi rakendamisega 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,
    };
  }
}

Midagi keerulist siin ei toimu. See klass on laienduse jaoks hea tugipunkt. Vaatame, kuidas klass Bullet kasutab 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 väga lühike! Oleme lisanud Object ainult järgmised laiendused:

  • Pakendi kasutamine lühike juhuslikuks genereerimiseks id mürsk.
  • Välja lisamine parentIDet saaksite jälgida mängijat, kes selle mürsu lõi.
  • Tagastusväärtuse lisamine update(), mis on võrdne truekui mürsk on väljaspool areeni (mäletate, me rääkisime sellest viimases lõigus?).

Liigume edasi 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,
    };
  }
}

Mängijad on keerukamad kui mürsud, seega tuleks sellesse klassi salvestada veel paar välja. Tema meetod update() teeb palju tööd, eelkõige tagastab vastloodud mürsu, kui seda alles pole fireCooldown (mäletate, et rääkisime sellest eelmises osas?). Samuti laiendab see meetodit serializeForUpdate(), sest peame mänguvärskendusse lisama mängija jaoks täiendavaid välju.

Põhiklassi omamine Object - oluline samm koodi kordamise vältimiseks. Näiteks ei mingit klassi Object igal mänguobjektil peab olema sama teostus distanceTo(), ja kõigi nende rakenduste kopeerimine-kleepimine mitmesse faili oleks õudusunenägu. See muutub eriti oluliseks suurte projektide puhul.kui arv laieneb Object klassid kasvavad.

4. Kokkupõrke tuvastamine

Meil jääb üle vaid ära tunda, kui mürsud mängijaid tabavad! Pidage meeles seda kooditükki meetodist update() klassis 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),
    );

    // ...
  }
}

Peame meetodi rakendama applyCollisions(), mis tagastab kõik mängijaid tabanud mürsud. Õnneks pole seda nii raske teha, sest

  • Kõik põrkuvad objektid on ringid, mis on kõige lihtsam kuju kokkupõrke tuvastamiseks.
  • Meil on juba meetod distanceTo(), mida rakendasime klassis eelmises jaotises Object.

Meie kokkupõrketuvastuse rakendus näeb välja järgmine:

kokkupõrked.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;
}

See lihtne kokkupõrke tuvastamine põhineb asjaolul, et kaks ringi põrkuvad, kui nende keskpunktide vaheline kaugus on väiksem kui nende raadiuste summa. Siin on juhtum, kus kahe ringi keskpunktide vaheline kaugus on täpselt võrdne nende raadiuste summaga:

Mitme mängijaga .io veebimängu loomine
Siin tuleb arvestada veel paari aspektiga:

  • Mürsk ei tohi tabada mängijat, kes selle lõi. Seda on võimalik saavutada võrdlemise teel bullet.parentID с player.id.
  • Mürsk tohib tabada ainult üks kord, kui mitu mängijat põrkuvad samaaegselt kokku. Lahendame selle probleemi operaatori abil break: niipea kui mürsuga kokku põrganud mängija leitakse, peatame otsingu ja liigume edasi järgmise mürsu juurde.

End

See on kõik! Oleme käsitlenud kõike, mida pead teadma .io veebimängu loomiseks. Mis järgmiseks? Ehitage oma .io mäng!

Kogu näidiskood on avatud lähtekoodiga ja postitatud Github.

Allikas: www.habr.com

Lisa kommentaar