Kelių žaidėjų .io internetinio žaidimo kūrimas

Kelių žaidėjų .io internetinio žaidimo kūrimas
Išleistas 2015 m Agaras.io tapo naujo žanro pradininku games.io, kurio populiarumas nuo to laiko labai išaugo. Pats patyriau .io žaidimų populiarumo augimą: per pastaruosius trejus metus I sukūrė ir pardavė du šio žanro žaidimus..

Jei niekada anksčiau negirdėjote apie šiuos žaidimus, tai nemokami kelių žaidėjų žiniatinklio žaidimai, kuriuos lengva žaisti (nereikia paskyros). Paprastai jie toje pačioje arenoje susiduria su daugybe varžovų žaidėjų. Kiti žinomi .io žaidimai: Slither.io и giliai.io.

Šiame įraše išsiaiškinsime, kaip sukurti .io žaidimą nuo nulio. Tam pakaks tik Javascript žinių: reikia suprasti tokius dalykus kaip sintaksė ES6, raktinis žodis this и Pažadai. Net jei jūsų žinios apie Javascript nėra tobulos, vis tiek galite suprasti didžiąją įrašo dalį.

.io žaidimo pavyzdys

Norėdami padėti mokytis, remsimės žaidimo pavyzdys .io. Pabandykite žaisti!

Kelių žaidėjų .io internetinio žaidimo kūrimas
Žaidimas gana paprastas: jūs valdote laivą arenoje, kurioje yra kitų žaidėjų. Jūsų laivas automatiškai paleidžia sviedinius, o jūs bandote pataikyti į kitus žaidėjus, vengdami jų sviedinių.

1. Trumpa apžvalga/projekto struktūra

Rekomenduoti parsisiųsti šaltinio kodą žaidimo pavyzdys, kad galėtumėte sekti mane.

Pavyzdyje naudojami šie:

  • išreikšti yra populiariausia Node.js žiniatinklio sistema, valdanti žaidimo žiniatinklio serverį.
  • lizdas.io - žiniatinklio lizdų biblioteka, skirta keistis duomenimis tarp naršyklės ir serverio.
  • Internetinė pakuotė - modulio vadovas. Galite perskaityti, kodėl verta naudoti Webpack. čia.

Štai kaip atrodo projekto katalogo struktūra:

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

viešas/

Viskas yra aplanke public/ bus statiškai pateiktas serverio. IN public/assets/ yra vaizdų, naudojamų mūsų projekte.

src /

Visas šaltinio kodas yra aplanke src/. Pavadinimai client/ и server/ kalbėti už save ir shared/ yra konstantų failas, kurį importuoja ir klientas, ir serveris.

2. Surinkimai/projekto nustatymai

Kaip minėta aukščiau, projektui kurti naudojame modulio tvarkyklę. Internetinė pakuotė. Pažvelkime į mūsų žiniatinklio paketo konfigūraciją:

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

Čia pateikiamos svarbiausios eilutės:

  • src/client/index.js yra „Javascript“ (JS) kliento įvesties taškas. Webpack prasidės nuo čia ir rekursyviai ieškos kitų importuotų failų.
  • Mūsų Webpack versijos išvesties JS bus kataloge dist/. Aš pavadinsiu šį failą mūsų js paketą.
  • Mes naudojame Babelio bokštas, o ypač konfigūracija @babel/preset-env kad perkeltume JS kodą senesnėms naršyklėms.
  • Naudojame papildinį, kad ištrauktume visus JS failuose nurodytus CSS ir sujungtume juos į vieną vietą. Aš jį vadinsiu mūsų css paketą.

Galbūt pastebėjote keistus paketų failų pavadinimus '[name].[contenthash].ext'. Juose yra failo vardų keitimai Internetinė pakuotė: [name] bus pakeistas įvesties taško pavadinimu (mūsų atveju tai game), ir [contenthash] bus pakeistas failo turinio maiša. Mes tai darome optimizuoti projektą maišymui - Galite nurodyti naršyklėms saugoti mūsų JS paketus talpykloje neribotą laiką, nes pasikeitus paketui, pasikeičia ir jo failo pavadinimas (pakeitimai contenthash). Galutinis rezultatas bus peržiūros failo pavadinimas game.dbeee76e91a97d0c7207.js.

byla webpack.common.js yra bazinis konfigūracijos failas, kurį importuojame į kūrimo ir baigto projekto konfigūracijas. Štai kūrimo konfigūracijos pavyzdys:

webpack.dev.js

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

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

Siekiant efektyvumo, mes naudojame kūrimo procese webpack.dev.js, ir persijungia į webpack.prod.jsoptimizuoti pakuotės dydžius diegiant gamybą.

Vietinis nustatymas

Rekomenduoju įdiegti projektą vietiniame kompiuteryje, kad galėtumėte atlikti šiame pranešime nurodytus veiksmus. Sąranka paprasta: pirma, sistema turi turėti mazgas и NPM. Toliau reikia daryti

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

ir jūs pasiruošę eiti! Norėdami paleisti kūrimo serverį, tiesiog paleiskite

$ npm run develop

ir eikite į interneto naršyklę localhost: 3000. Kūrimo serveris automatiškai atkurs JS ir CSS paketus, kai pasikeičia kodas – tiesiog atnaujinkite puslapį, kad pamatytumėte visus pakeitimus!

3. Kliento įėjimo taškai

Pereikime prie paties žaidimo kodo. Pirmiausia mums reikia puslapio index.html, kai lankotės svetainėje, naršyklė ją įkels pirmiausia. Mūsų puslapis bus gana paprastas:

index.html

io žaidimo pavyzdys  ŽAISTI

Šis kodo pavyzdys buvo šiek tiek supaprastintas, kad būtų aišku, ir aš padarysiu tą patį su daugeliu kitų įrašų pavyzdžių. Visą kodą visada galima peržiūrėti adresu GitHub.

Mes turime:

  • HTML5 drobės elementas (<canvas>), kurį naudosime žaidimui pateikti.
  • <link> norėdami pridėti mūsų CSS paketą.
  • <script> norėdami pridėti mūsų Javascript paketą.
  • Pagrindinis meniu su vartotojo vardu <input> ir PLAY mygtuką (<button>).

Įkėlus pagrindinį puslapį, naršyklė pradės vykdyti Javascript kodą, pradedant nuo įvesties taško JS failo: src/client/index.js.

rodyklė.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);
  };
});

Tai gali atrodyti sudėtinga, bet čia nėra daug dalykų:

  1. Importuokite kelis kitus JS failus.
  2. CSS importavimas (kad „Webpack“ žinotų, kad juos įtrauks į mūsų CSS paketą).
  3. Paleidimas connect() užmegzti ryšį su serveriu ir pradėti downloadAssets() Norėdami atsisiųsti žaidimui atkurti reikalingus vaizdus.
  4. Baigus 3 etapą rodomas pagrindinis meniu (playMenu).
  5. Valdiklio nustatymas mygtuko "PLAY" paspaudimui. Paspaudus mygtuką, kodas inicijuoja žaidimą ir praneša serveriui, kad esame pasiruošę žaisti.

Pagrindinė mūsų kliento-serverio logikos „mėsa“ yra tuose failuose, kurie buvo importuoti pagal failą index.js. Dabar apžvelgsime juos visus iš eilės.

4. Keitimasis klientų duomenimis

Šiame žaidime bendravimui su serveriu naudojame gerai žinomą biblioteką lizdas.io. Socket.io turi vietinį palaikymą WebSockets, kurios puikiai tinka dvipusiam ryšiui: galime siųsti žinutes į serverį и serveris gali siųsti mums pranešimus tuo pačiu ryšiu.

Turėsime vieną failą src/client/networking.jskas pasirūpins visų bendravimas su serveriu:

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

Šis kodas taip pat šiek tiek sutrumpintas aiškumo dėlei.

Šiame faile yra trys pagrindiniai veiksmai:

  • Bandome prisijungti prie serverio. connectedPromise leidžiama tik tada, kai užmezgame ryšį.
  • Jei ryšys sėkmingas, registruojame atgalinio skambučio funkcijas (processGameUpdate() и onGameOver()) pranešimams, kuriuos galime gauti iš serverio.
  • Eksportuojame play() и updateDirection()kad kiti failai galėtų juos naudoti.

5. Kliento atvaizdavimas

Atėjo laikas rodyti paveikslėlį ekrane!

…bet prieš tai padarydami, turime atsisiųsti visus vaizdus (išteklius), kurių tam reikia. Parašykime išteklių tvarkyklę:

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

Išteklių valdymą įgyvendinti nėra taip sunku! Pagrindinė idėja yra saugoti objektą assets, kuris susies failo pavadinimo raktą su objekto reikšme Image. Kai resursas įkeliamas, mes jį saugome objekte assets greitai pasiekti ateityje. Kada bus leista atsisiųsti kiekvieną atskirą šaltinį (ty visi išteklių), leidžiame downloadPromise.

Atsisiuntę išteklius galite pradėti atvaizdavimą. Kaip minėta anksčiau, piešti tinklalapyje, kurį naudojame HTML5 drobė (<canvas>). Mūsų žaidimas yra gana paprastas, todėl mums tereikia nupiešti:

  1. Bendrosios aplinkybės
  2. Žaidėjų laivas
  3. Kiti žaidimo žaidėjai
  4. Korpusai

Čia yra svarbūs fragmentai src/client/render.js, kurie pateikia tiksliai keturis aukščiau išvardytus elementus:

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

Šis kodas aiškumo dėlei taip pat sutrumpintas.

render() yra pagrindinė šio failo funkcija. startRendering() и stopRendering() valdyti atvaizdavimo ciklo aktyvavimą esant 60 FPS.

Konkretus atskirų atvaizdavimo pagalbinių funkcijų įgyvendinimas (pvz. renderBullet()) nėra tokie svarbūs, bet čia yra vienas paprastas pavyzdys:

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

Atkreipkite dėmesį, kad mes naudojame metodą getAsset(), kuris anksčiau buvo matytas asset.js!

Jei norite ištirti kitas atvaizdavimo pagalbinės priemonės funkcijas, perskaitykite likusią dalį src/client/render.js.

6. Kliento įvestis

Atėjo laikas sukurti žaidimą žaisti! Valdymo schema bus labai paprasta: norint pakeisti judėjimo kryptį, galima naudoti pelę (kompiuteryje) arba liesti ekraną (mobiliajame įrenginyje). Norėdami tai įgyvendinti, užsiregistruosime Renginių klausytojai „Mouse and Touch“ renginiams.
Visa tai pasirūpins 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() yra įvykių klausytojai, kurie skambina updateDirection() (iš networking.js), kai įvyksta įvesties įvykis (pavyzdžiui, kai pajudinama pelė). updateDirection() tvarko pranešimų siuntimą su serveriu, kuris tvarko įvesties įvykį ir atitinkamai atnaujina žaidimo būseną.

7. Kliento būsena

Ši dalis yra pati sunkiausia pirmoje įrašo dalyje. Nenusiminkite, jei pirmą kartą skaitydami nesupratote! Jūs netgi galite tai praleisti ir prie jo grįžti vėliau.

Paskutinė galvosūkio dalis, reikalinga norint užpildyti kliento / serverio kodą valstybės. Prisimenate kodo fragmentą iš Kliento atvaizdavimo skyriaus?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() turėtų mums pateikti dabartinę kliento žaidimo būseną bet kuriuo laiko momentu remiantis iš serverio gautais atnaujinimais. Štai žaidimo atnaujinimo, kurį serveris gali siųsti, pavyzdys:

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

Kiekviename žaidimo atnaujinime yra penki identiški laukai:

  • t: serverio laiko žyma, nurodanti, kada buvo sukurtas šis naujinimas.
  • me: Informacija apie grotuvą, gaunantį šį naujinimą.
  • kiti: informacijos apie kitus žaidėjus, dalyvaujančius tame pačiame žaidime, rinkinys.
  • kulkos: informacijos apie žaidimo sviedinius masyvas.
  • Iškabos: dabartiniai pirmaujančiųjų sąrašo duomenys. Šiame įraše mes jų nenagrinėsime.

7.1 Naivi kliento būsena

Naivus įgyvendinimas getCurrentState() gali tiesiogiai grąžinti duomenis tik iš paskutinio gauto žaidimo atnaujinimo.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Gražus ir aiškus! Bet jei tik tai būtų taip paprasta. Viena iš priežasčių, kodėl šis įgyvendinimas yra problemiškas: jis riboja atvaizdavimo kadrų dažnį iki serverio laikrodžio dažnio.

Kadrų dažnis: kadrų (t. y. skambučių) skaičius render()) per sekundę arba FPS. Žaidimai paprastai siekia pasiekti bent 60 FPS.

Tick ​​Rate: dažnis, kuriuo serveris siunčia žaidimo naujinimus klientams. Jis dažnai yra mažesnis nei kadrų dažnis. Mūsų žaidime serveris veikia 30 ciklų per sekundę dažniu.

Jei tik pateikiame naujausią žaidimo atnaujinimą, FPS iš esmės niekada neviršys 30, nes iš serverio niekada negauname daugiau nei 30 atnaujinimų per sekundę. Net jei paskambinsime render() 60 kartų per sekundę, tada pusė šių skambučių tiesiog perbraižys tą patį, iš esmės nieko nedarydami. Kita naivaus įgyvendinimo problema yra ta galioja vėlavimai. Esant idealiam interneto greičiui, klientas gaus žaidimo atnaujinimą tiksliai kas 33 ms (30 per sekundę):

Kelių žaidėjų .io internetinio žaidimo kūrimas
Deja, nieko nėra tobulo. Realesnis vaizdas būtų toks:
Kelių žaidėjų .io internetinio žaidimo kūrimas
Naivus įgyvendinimas yra praktiškai blogiausias atvejis, kai kalbama apie delsą. Jei žaidimo atnaujinimas gaunamas su 50 ms vėlavimu, tada klientų prekystalių papildomi 50 ms, nes vis dar rodoma ankstesnio atnaujinimo žaidimo būsena. Galite įsivaizduoti, kaip tai nepatogu žaidėjui: dėl savavališko stabdymo žaidimas bus trūkčiojantis ir nestabilus.

7.2 Patobulinta kliento būsena

Mes šiek tiek patobulinsime naivų įgyvendinimą. Pirma, mes naudojame pateikimo vėlavimas 100 ms. Tai reiškia, kad „dabartinė“ kliento būsena visada atsiliks nuo žaidimo būsenos serveryje 100 ms. Pavyzdžiui, jei laikas serveryje yra 150, tada klientas pateiks būseną, kurioje tuo metu buvo serveris 50:

Kelių žaidėjų .io internetinio žaidimo kūrimas
Tai suteikia mums 100 ms buferį, kad išgyventume nenuspėjamus žaidimo atnaujinimo laikus:

Kelių žaidėjų .io internetinio žaidimo kūrimas
Atlyginimas už tai bus nuolatinis įvesties vėlavimas 100 ms. Tai nedidelė auka dėl sklandaus žaidimo – dauguma žaidėjų (ypač atsitiktiniai) net nepastebės šio vėlavimo. Žmonėms daug lengviau prisitaikyti prie pastovaus 100 ms delsos, nei žaisti su nenuspėjama delsa.

Galime naudoti kitą techniką, vadinamą kliento pusės prognozė, kuris puikiai sumažina suvokiamą delsą, tačiau šiame įraše nebus aptartas.

Kitas mūsų naudojamas patobulinimas yra tiesinė interpoliacija. Dėl pateikimo vėlavimo paprastai esame bent vienu atnaujinimu į priekį nuo esamo kliento laiko. Kai skambina getCurrentState(), galime įvykdyti tiesinė interpoliacija tarp žaidimo atnaujinimų prieš pat ir po esamo laiko kliente:

Kelių žaidėjų .io internetinio žaidimo kūrimas
Tai išsprendžia kadrų dažnio problemą: dabar galime pateikti unikalius kadrus bet kokiu norimu kadrų dažniu!

7.3 Patobulintos kliento būsenos diegimas

Įgyvendinimo pavyzdys src/client/state.js naudoja tiek pateikimo delsą, tiek tiesinę interpoliaciją, bet neilgai. Suskaidykime kodą į dvi dalis. Štai pirmasis:

state.js, 1 dalis

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

Pirmas žingsnis yra išsiaiškinti, ką currentServerTime(). Kaip matėme anksčiau, kiekviename žaidimo atnaujinime yra serverio laiko žyma. Norime naudoti pateikimo delsą, kad vaizdas būtų pateiktas 100 ms už serverio, bet mes niekada nesužinosime dabartinio laiko serveryje, nes negalime žinoti, kiek laiko užtruko, kol naujiniai mus pasiekė. Internetas yra nenuspėjamas ir jo greitis gali labai skirtis!

Norėdami išspręsti šią problemą, galime naudoti pagrįstą apytikslį skaičiavimą: mes apsimesti, kad pirmasis atnaujinimas buvo gautas akimirksniu. Jei tai būtų tiesa, mes žinotume serverio laiką šiuo konkrečiu momentu! Mes saugome serverio laiko žymą firstServerTimestamp ir išgelbėk mūsų vietinis (kliento) laiko žyma tuo pačiu momentu gameStart.

O palauk. Ar laikas serveryje neturi būti = laikas kliente? Kodėl skiriame „serverio laiko žymą“ ir „kliento laiko žymą“? Tai puikus klausimas! Pasirodo, jie nėra tas pats dalykas. Date.now() kliente ir serveryje grąžins skirtingas laiko žymas, ir tai priklauso nuo vietinių šių įrenginių veiksnių. Niekada nemanykite, kad laiko žymos visuose įrenginiuose bus vienodos.

Dabar mes suprantame, ką daro currentServerTime(): grįžta esamo pateikimo laiko serverio laiko žyma. Kitaip tariant, tai yra dabartinis serverio laikas (firstServerTimestamp <+ (Date.now() - gameStart)) atėmus pateikimo delsą (RENDER_DELAY).

Dabar pažiūrėkime, kaip tvarkome žaidimų atnaujinimus. Kai iš serverio gaunamas atnaujinimas, jis iškviečiamas processGameUpdate(), ir išsaugome naują naujinimą masyve gameUpdates. Tada, norėdami patikrinti atminties naudojimą, pašaliname visus senus atnaujinimus bazinis atnaujinimasnes mums jų nebereikia.

Kas yra „pagrindinis atnaujinimas“? Tai pirmasis atnaujinimas, kurį randame judėdami atgal nuo dabartinio serverio laiko. Prisimeni šią diagramą?

Kelių žaidėjų .io internetinio žaidimo kūrimas
Žaidimo atnaujinimas, esantis tiesiai „Kliento pateikimo laiko“ kairėje, yra pagrindinis atnaujinimas.

Kam naudojamas bazinis naujinimas? Kodėl galime atsisakyti pradinio lygio naujinimų? Norėdami tai išsiaiškinti, leiskite pagaliau apsvarstyti įgyvendinimą getCurrentState():

state.js, 2 dalis

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

Mes tvarkome tris atvejus:

  1. base < 0 reiškia, kad iki dabartinio pateikimo laiko atnaujinimų nėra (žr. aukščiau pateiktą įgyvendinimą getBaseUpdate()). Tai gali įvykti žaidimo pradžioje dėl pateikimo delsos. Šiuo atveju naudojame naujausią gautą naujinimą.
  2. base yra naujausias mūsų turimas atnaujinimas. Taip gali būti dėl tinklo vėlavimo arba prasto interneto ryšio. Šiuo atveju taip pat naudojame naujausią turimą naujinimą.
  3. Turime atnaujinimą prieš ir po dabartinio pateikimo laiko, todėl galime interpoliuoti!

Viskas, kas liko viduje state.js yra tiesinės interpoliacijos įgyvendinimas, kuris yra paprasta (bet nuobodu) matematika. Jei norite tai ištirti patys, atidarykite state.js apie GitHub.

2 dalis. Backend serveris

Šioje dalyje apžvelgsime „Node.js“ pagrindinę programą, kuri valdo mūsų .io žaidimo pavyzdys.

1. Serverio įėjimo taškas

Norėdami valdyti žiniatinklio serverį, naudosime populiarią žiniatinklio sistemą, skirtą Node.js, vadinamą išreikšti. Jis bus sukonfigūruotas mūsų serverio įėjimo taško failu src/server/server.js:

server.js 1 dalis

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

Prisiminkite, kad pirmoje dalyje aptarėme Webpack? Čia mes naudosime savo žiniatinklio paketo konfigūracijas. Mes juos pritaikysime dviem būdais:

  • Naudokite webpack-dev-middleware automatiškai atkurti mūsų kūrimo paketus arba
  • statiškai perkelti aplanką dist/, į kurį Webpack įrašys mūsų failus po gamybos versijos.

Dar viena svarbi užduotis server.js susideda iš serverio nustatymo lizdas.iokuris tiesiog prisijungia prie Express serverio:

server.js 2 dalis

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

Sėkmingai užmezgę socket.io ryšį su serveriu, naujam lizdui nustatėme įvykių tvarkykles. Įvykių tvarkytojai tvarko pranešimus, gautus iš klientų, deleguodami vienam objektui game:

server.js 3 dalis

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

Mes kuriame .io žaidimą, todėl mums reikia tik vienos kopijos Game („Žaidimas“) – visi žaidėjai žaidžia toje pačioje arenoje! Kitame skyriuje pamatysime, kaip ši klasė veikia. Game.

2. Žaidimų serveriai

Klasė Game yra svarbiausia serverio logika. Ji turi dvi pagrindines užduotis: žaidėjų valdymas и žaidimo simuliacija.

Pradėkime nuo pirmosios užduoties – žaidėjų valdymo.

game.js 1 dalis

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

  // ...
}

Šiame žaidime mes atpažinsime žaidėjus pagal lauką id jų socket.io lizdas (jei susipainiojate, grįžkite į server.js). Pati Socket.io kiekvienam lizdui priskiria unikalų idtodėl mums nereikia dėl to jaudintis. Aš jam paskambinsiu Žaidėjo ID.

Turėdami tai omenyje, panagrinėkime klasės egzempliorių kintamuosius Game:

  • sockets yra objektas, susiejantis grotuvo ID su lizdu, susietu su grotuvu. Tai leidžia mums pastoviu laiku pasiekti lizdus pagal jų grotuvo ID.
  • players yra objektas, susiejantis žaidėjo ID su kodu>Žaidėjo objektas

bullets yra objektų masyvas Bullet, kuris neturi konkrečios tvarkos.
lastUpdateTime yra paskutinio žaidimo atnaujinimo laiko žyma. Kaip jis bus naudojamas, pamatysime netrukus.
shouldSendUpdate yra pagalbinis kintamasis. Netrukus pamatysime ir jo naudojimą.
Metodai addPlayer(), removePlayer() и handleInput() nereikia aiškinti, jie naudojami server.js. Jei jums reikia atnaujinimo, grįžkite šiek tiek aukščiau.

Paskutinė eilutė constructor() prasideda atnaujinimo ciklas žaidimai (60 atnaujinimų per sekundę dažnis):

game.js 2 dalis

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

  // ...
}

metodas update() yra bene svarbiausia serverio pusės logikos dalis. Išvardykime viską, ką jis daro, eilės tvarka:

  1. Apskaičiuoja, kiek valandų dt praėjo nuo paskutinio update().
  2. Kiekvieną sviedinį atnaujina ir prireikus sunaikina. Šios funkcijos įgyvendinimą pamatysime vėliau. Kol kas mums užtenka tai žinoti bullet.update() grįžta truejei sviedinys turėtų būti sunaikintas (jis išėjo už arenos).
  3. Atnaujina kiekvieną žaidėją ir, jei reikia, sukuria sviedinį. Šį įgyvendinimą taip pat pamatysime vėliau – player.update() gali grąžinti daiktą Bullet.
  4. Tikrina, ar nesusidūrė sviediniai ir besinaudojantys žaidėjai applyCollisions(), kuris grąžina daugybę sviedinių, kurie pataikė į žaidėjus. Už kiekvieną grąžintą sviedinį padidiname jį paleidusio žaidėjo taškus (naudojant player.onDealtDamage()), tada išimkite sviedinį iš matricos bullets.
  5. Praneša ir sunaikina visus nužudytus žaidėjus.
  6. Siunčia žaidimo atnaujinimą visiems žaidėjams kiekviena sekundė kartus, kai skambino update(). Aukščiau minėtas pagalbinis kintamasis padeda mums tai stebėti shouldSendUpdate. Kaip update() skambinama 60 kartų/s, žaidimų atnaujinimus siunčiame 30 kartų/s. Taigi, laikrodžio dažnis serverio laikrodis yra 30 clocks/s (pirmoje dalyje kalbėjome apie laikrodžių dažnius).

Kodėl siųsti tik žaidimo atnaujinimus per laiką ? Norėdami išsaugoti kanalą. 30 žaidimų atnaujinimų per sekundę yra daug!

Kodėl tada tiesiog nepaskambinus? update() 30 kartų per sekundę? Norėdami pagerinti žaidimo modeliavimą. Kuo dažniau jis vadinamas update(), tuo tikslesnis bus žaidimo modeliavimas. Tačiau nesijaudinkite dėl daugybės iššūkių update(), nes tai skaičiuojant brangi užduotis – užtenka 60 per sekundę.

Likusi klasė Game susideda iš pagalbinių metodų, naudojamų update():

game.js 3 dalis

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() gana paprasta – surūšiuoja žaidėjus pagal balą, paima penkis geriausius ir pateikia kiekvieno vartotojo vardą bei rezultatą.

createUpdate() naudojamas update() sukurti žaidimo naujinimus, kurie platinami žaidėjams. Jo pagrindinė užduotis yra iškviesti metodus serializeForUpdate()įdiegta klasėms Player и Bullet. Atkreipkite dėmesį, kad jis perduoda duomenis tik kiekvienam žaidėjui apie artimiausias žaidėjai ir sviediniai – nereikia perduoti informacijos apie žaidimo objektus, kurie yra toli nuo žaidėjo!

3. Žaidimo objektai serveryje

Mūsų žaidime sviediniai ir žaidėjai iš tikrųjų yra labai panašūs: jie yra abstraktūs apvalūs judantys žaidimo objektai. Norėdami pasinaudoti šiuo grotuvų ir sviedinių panašumu, pradėkime nuo bazinės klasės diegimo 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,
    };
  }
}

Nieko sudėtingo čia nevyksta. Ši klasė bus geras pratęsimo tvirtinimo taškas. Pažiūrėkime, kaip klasėje Bullet naudoja 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;
  }
}

Vykdymas Bullet labai trumpas! Pridėjome Object tik šie plėtiniai:

  • Naudojant paketą trumpas atsitiktiniam generavimui id sviedinys.
  • Lauko pridėjimas parentID, kad galėtumėte sekti žaidėją, kuris sukūrė šį sviedinį.
  • Grąžinamos vertės pridėjimas prie update(), kuris yra lygus truejei sviedinys yra už arenos (pamenate, apie tai kalbėjome paskutinėje dalyje?).

Pereikime prie 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,
    };
  }
}

Žaidėjai yra sudėtingesni nei sviediniai, todėl šioje klasėje reikėtų saugoti dar kelis laukus. Jo metodas update() atlieka daug darbo, ypač grąžina naujai sukurtą sviedinį, jei jo nebėra fireCooldown (atsimenate, kad apie tai kalbėjome ankstesniame skyriuje?). Tai taip pat praplečia metodą serializeForUpdate(), nes į žaidimo atnaujinimą turime įtraukti papildomus žaidėjo laukus.

Turėti bazinę klasę Object – svarbus žingsnis siekiant išvengti kodo pasikartojimo. Pavyzdžiui, nėra klasės Object kiekvienas žaidimo objektas turi turėti tą patį įgyvendinimą distanceTo(), o visų šių diegimų kopijavimas keliuose failuose būtų košmaras. Tai ypač svarbu dideliems projektamskai skaičius plečiasi Object klasės auga.

4. Susidūrimo aptikimas

Mums belieka atpažinti, kada sviediniai pataikė į žaidėjus! Prisiminkite šią kodo dalį iš metodo update() klasėje 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),
    );

    // ...
  }
}

Turime įgyvendinti metodą applyCollisions(), kuris grąžina visus žaidėjus pataikiusius sviedinius. Laimei, tai padaryti nėra taip sunku, nes

  • Visi susiduriantys objektai yra apskritimai, o tai yra paprasčiausia forma susidūrimo aptikimui.
  • Mes jau turime metodą distanceTo(), kurią įdiegėme klasėje ankstesniame skyriuje Object.

Štai kaip atrodo susidūrimo aptikimo įdiegimas:

susidūrimai.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;
}

Šis paprastas susidūrimo aptikimas pagrįstas tuo du apskritimai susiduria, jei atstumas tarp jų centrų yra mažesnis už jų spindulių sumą. Štai atvejis, kai atstumas tarp dviejų apskritimų centrų yra tiksliai lygus jų spindulių sumai:

Kelių žaidėjų .io internetinio žaidimo kūrimas
Čia reikia apsvarstyti dar keletą aspektų:

  • Sviedinys neturi pataikyti į jį sukūrusį žaidėją. Tai galima pasiekti lyginant bullet.parentID с player.id.
  • Sviedinys turėtų pataikyti tik vieną kartą kraštutiniu atveju, kai tuo pačiu metu pataiko į kelis žaidėjus. Šią problemą išspręsime naudodami operatorių break: kai tik surandamas su sviediniu susidūręs žaidėjas, sustabdome paiešką ir pereiname prie kito sviedinio.

pabaiga

Tai viskas! Aprašėme viską, ką reikia žinoti norint sukurti .io internetinį žaidimą. Kas toliau? Sukurkite savo .io žaidimą!

Visas pavyzdinis kodas yra atvirojo kodo ir paskelbtas GitHub.

Šaltinis: www.habr.com

Добавить комментарий