Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Iliyotolewa mwaka 2015 Agar.io akawa mtangulizi wa aina mpya michezo.io, ambaye umaarufu wake umeongezeka sana tangu wakati huo. Nimejionea ongezeko la umaarufu wa michezo ya .io mimi mwenyewe: katika kipindi cha miaka mitatu iliyopita, I iliunda na kuuza michezo miwili katika aina hii..

Iwapo hujawahi kusikia kuhusu michezo hii hapo awali, ni michezo ya wavuti ya wachezaji wengi isiyolipishwa ambayo ni rahisi kucheza (hakuna akaunti inayohitajika). Kawaida huwakutanisha wachezaji wengi wapinzani kwenye uwanja mmoja. Michezo mingine maarufu ya .io: Slither.io ΠΈ Diep.io.

Katika chapisho hili tutajua jinsi gani tengeneza mchezo wa .io kuanzia mwanzo. Ili kufanya hivyo, ujuzi tu wa Javascript utatosha: unahitaji kuelewa vitu kama syntax ES6, neno kuu this ΠΈ ahadi. Hata kama hujui Javascript kikamilifu, bado unaweza kuelewa machapisho mengi.

Mfano wa mchezo wa .io

Kwa msaada wa mafunzo tutarejelea mfano mchezo .io. Jaribu kuicheza!

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Mchezo ni rahisi sana: unadhibiti meli kwenye uwanja na wachezaji wengine. Meli yako huwasha makombora kiotomatiki na unajaribu kuwagonga wachezaji wengine huku ukiepuka kurusha makombora yao.

1. Muhtasari mfupi/muundo wa mradi

Kupendekeza pakua msimbo wa chanzo mfano mchezo ili uweze kunifuata.

Mfano hutumia yafuatayo:

  • Express ndio mfumo maarufu wa wavuti wa Node.js ambao unasimamia seva ya wavuti ya mchezo.
  • tundu.io - maktaba ya soketi ya wavuti kwa kubadilishana data kati ya kivinjari na seva.
  • Ukurasa wa Wavuti - meneja wa moduli. Unaweza kusoma kuhusu kwa nini utumie Webpack hapa.

Hivi ndivyo muundo wa saraka ya mradi unavyoonekana:

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

umma /

Kila kitu kiko kwenye folda public/ itatumwa kwa takwimu na seva. KATIKA public/assets/ ina picha zinazotumiwa na mradi wetu.

src /

Msimbo wote wa chanzo uko kwenye folda src/. Majina client/ ΠΈ server/ kujisemea na shared/ ina faili ya vidhibiti iliyoingizwa na mteja na seva.

2. Vigezo vya makusanyiko / mradi

Kama ilivyoelezwa hapo juu, tunatumia meneja wa moduli kujenga mradi Ukurasa wa Wavuti. Wacha tuangalie usanidi wetu wa Webpack:

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

Mistari muhimu zaidi hapa ni yafuatayo:

  • src/client/index.js ni sehemu ya kuingilia ya mteja wa Javascript (JS). Webpack itaanza kutoka hapa na kutafuta faili zingine zilizoletwa kwa kujirudia.
  • Pato la JS la muundo wetu wa Webpack litapatikana kwenye saraka dist/. Nitaita faili hii yetu Kifurushi cha JS.
  • Tunatumia Babeli, na haswa usanidi @babel/preset-env kusambaza msimbo wetu wa JS kwa vivinjari vya zamani.
  • Tunatumia programu-jalizi kutoa CSS zote zilizorejelewa na faili za JS na kuzichanganya katika sehemu moja. Nitaiita yetu Kifurushi cha CSS.

Huenda umegundua majina ya faili za kifurushi cha ajabu '[name].[contenthash].ext'. Zina uingizwaji wa jina la faili Kifurushi cha wavuti: [name] itabadilishwa na jina la mahali pa kuingiza (kwa upande wetu ni game), na [contenthash] itabadilishwa na heshi ya yaliyomo kwenye faili. Tunafanya hivi boresha mradi kwa hashing - tunaweza kuwaambia vivinjari kuweka akiba ya vifurushi vyetu vya JS kwa muda usiojulikana kwa sababu kifurushi kinabadilika, jina la faili yake pia hubadilika (mabadiliko contenthash) Matokeo ya kumaliza yatakuwa jina la faili la mwonekano game.dbeee76e91a97d0c7207.js.

file webpack.common.js - Hii ni faili ya msingi ya usanidi ambayo tunaingiza kwenye usanidi na kumaliza usanidi wa mradi. Kwa mfano, hapa kuna usanidi wa maendeleo:

webpack.dev.js

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

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

Kwa ufanisi, tunatumia katika mchakato wa maendeleo webpack.dev.js, na swichi kwa webpack.prod.js, ili kuboresha ukubwa wa kifurushi wakati wa kupeleka kwenye uzalishaji.

Mpangilio wa ndani

Ninapendekeza kusakinisha mradi kwenye mashine yako ya karibu ili uweze kufuata hatua zilizoorodheshwa katika chapisho hili. Kuweka ni rahisi: kwanza, mfumo lazima uwe nao Node ΠΈ NPM. Ifuatayo unahitaji kufanya

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

na uko tayari kwenda! Ili kuanza seva ya ukuzaji, endesha tu

$ npm run develop

na uende kwenye kivinjari chako cha wavuti localhost: 3000. Seva ya ukuzaji itaunda upya vifurushi vya JS na CSS kiotomatiki mabadiliko ya msimbo yanapotokea - onyesha ukurasa upya ili kuona mabadiliko yote!

3. Pointi za kuingia kwa mteja

Wacha tuangalie nambari ya mchezo yenyewe. Kwanza tunahitaji ukurasa index.html, unapotembelea tovuti, kivinjari kitapakia kwanza. Ukurasa wetu utakuwa rahisi sana:

index.html

Mfano wa mchezo wa .io  CHEZA

Mfano huu wa nambari umerahisishwa kidogo kwa uwazi, na nitafanya vivyo hivyo na mifano mingine mingi kwenye chapisho. Unaweza kutazama msimbo kamili kila wakati Github.

Tuna:

  • Kipengele cha turubai cha HTML5 (<canvas>), ambayo tutatumia kutoa mchezo.
  • <link> ili kuongeza kifurushi chetu cha CSS.
  • <script> kuongeza kifurushi chetu cha Javascript.
  • Menyu kuu yenye jina la mtumiaji <input> na kitufe cha "PLAY" (<button>).

Mara tu ukurasa wa nyumbani unapopakia, kivinjari kitaanza kutekeleza msimbo wa Javascript, kuanzia na sehemu ya kuingia faili ya 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);
  };
});

Hii inaweza kuonekana kuwa ngumu, lakini kwa kweli hakuna mengi yanayoendelea hapa:

  1. Ingiza faili zingine kadhaa za JS.
  2. Ingiza CSS (ili Webpack ijue kuzijumuisha kwenye kifurushi chetu cha CSS).
  3. Uzindua connect() kuanzisha muunganisho kwa seva na kuanza downloadAssets() kupakua picha zinazohitajika kutoa mchezo.
  4. Baada ya kumaliza hatua ya 3 menyu kuu inaonyeshwa (playMenu).
  5. Kuweka kidhibiti cha kubofya kitufe cha "PLAY". Kitufe kinapobonyezwa, msimbo huanzisha mchezo na kuwaambia seva kuwa tuko tayari kucheza.

"Nyama" kuu ya mantiki ya seva ya mteja iko kwenye faili zilizoletwa na faili index.js. Sasa tutawaangalia wote kwa utaratibu.

4. Ubadilishanaji wa data ya mteja

Katika mchezo huu tunatumia maktaba inayojulikana kuwasiliana na seva tundu.io. Socket.io ina usaidizi wa ndani Mifuko ya Wavuti, ambayo yanafaa kwa mawasiliano ya njia mbili: tunaweza kutuma ujumbe kwa seva ΠΈ seva inaweza kutuma ujumbe kwetu kupitia muunganisho sawa.

Tutakuwa na faili moja src/client/networking.jsnani atamtunza kila mtu mawasiliano na seva:

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

Nambari hii pia imefupishwa kidogo kwa uwazi.

Kuna mambo matatu kuu yanayotokea katika faili hii:

  • Tunajaribu kuunganisha kwenye seva. connectedPromise inaruhusiwa tu wakati tumeanzisha muunganisho.
  • Ikiwa muunganisho umefanikiwa, tunasajili kazi za kurudi nyuma (processGameUpdate() ΠΈ onGameOver()) kwa ujumbe ambao tunaweza kupokea kutoka kwa seva.
  • Tunasafirisha nje play() ΠΈ updateDirection()ili faili zingine ziweze kuzitumia.

5. Utoaji wa mteja

Ni wakati wa kuonyesha picha kwenye skrini!

...lakini kabla ya kufanya hivi, tunahitaji kupakua picha zote (rasilimali) zinazohitajika kwa hili. Wacha tuandike msimamizi wa rasilimali:

assets.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

Usimamizi wa rasilimali sio ngumu sana kutekeleza! Jambo kuu ni kuhifadhi kitu assets, ambayo itafunga ufunguo wa jina la faili kwa thamani ya kitu Image. Wakati rasilimali imepakiwa, tunaihifadhi kwa kitu assets kwa risiti ya haraka katika siku zijazo. Ni lini upakuaji wa kila rasilimali ya mtu binafsi utaruhusiwa (yaani, itapakuliwa wote rasilimali), tunaruhusu downloadPromise.

Baada ya kupakua rasilimali, unaweza kuanza kutoa. Kama ilivyosemwa hapo awali, kuchora kwenye ukurasa wa wavuti tunayotumia Turubai ya HTML5 (<canvas>) Mchezo wetu ni rahisi sana, kwa hivyo tunahitaji tu kutoa yafuatayo:

  1. Asili
  2. Meli ya wachezaji
  3. Wachezaji wengine kwenye mchezo
  4. Magamba

Hapa kuna vijisehemu muhimu src/client/render.js, ambayo huchota alama nne zilizoorodheshwa hapo juu:

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

Msimbo huu pia umefupishwa kwa uwazi.

render() ndio kazi kuu ya faili hii. startRendering() ΠΈ stopRendering() kudhibiti uanzishaji wa mzunguko wa uwasilishaji kwa ramprogrammen 60.

Utekelezaji mahususi wa utendakazi wa usaidizi wa utoaji wa mtu binafsi (kwa mfano renderBullet()) sio muhimu sana, lakini hapa kuna mfano mmoja rahisi:

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

Kumbuka kuwa tunatumia mbinu getAsset(), ambayo ilionekana hapo awali asset.js!

Ikiwa ungependa kuchunguza vitendaji vingine vya usaidizi wa uwasilishaji, basi soma zingine src/client/render.js.

6. Ingizo la mteja

Ni wakati wa kufanya mchezo inaweza kuchezwa! Mpango wa udhibiti utakuwa rahisi sana: unaweza kutumia panya (kwenye kompyuta) au kugusa skrini (kwenye kifaa cha simu) ili kubadilisha mwelekeo wa harakati. Ili kutekeleza hili tutasajili Wasikilizaji wa hafla kwa matukio ya Kipanya na Kugusa.
Itashughulikia haya yote 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() ni Wasikilizaji wa Tukio wanaopiga simu updateDirection() (ya networking.js) tukio la pembejeo linapotokea (kwa mfano, panya inapohamishwa). updateDirection() inahusika na ubadilishanaji wa ujumbe na seva, ambayo huchakata tukio la ingizo na kusasisha hali ya mchezo ipasavyo.

7. Hali ya mteja

Sehemu hii ndiyo ngumu zaidi katika sehemu ya kwanza ya chapisho. Usikate tamaa ikiwa huelewi mara ya kwanza unapoisoma! Unaweza hata kuiruka na kurudi nayo baadaye.

Kipande cha mwisho cha fumbo kinachohitajika ili kukamilisha msimbo wa seva ya mteja ni walikuwa. Je, unakumbuka kijisehemu cha msimbo kutoka sehemu ya Utoaji wa Mteja?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() inapaswa kuwa na uwezo wa kutupa hali ya sasa ya mchezo katika mteja wakati wowote kulingana na sasisho zilizopokelewa kutoka kwa seva. Huu hapa ni mfano wa sasisho la mchezo ambalo seva inaweza kutuma:

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

Kila sasisho la mchezo lina sehemu tano zinazofanana:

  • t: Muhuri wa muda wa seva inayoonyesha wakati sasisho hili liliundwa.
  • me: Taarifa kuhusu mchezaji kupokea sasisho hili.
  • wengine: Msururu wa taarifa kuhusu wachezaji wengine wanaoshiriki katika mchezo sawa.
  • risasi: safu ya habari kuhusu projectiles katika mchezo.
  • leaderboard: Data ya sasa ya ubao wa wanaoongoza. Hatutazizingatia katika chapisho hili.

7.1 Hali ya ujinga ya mteja

Utekelezaji wa ujinga getCurrentState() inaweza tu kurejesha data moja kwa moja kutoka kwa sasisho la mchezo uliopokea hivi majuzi.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Mzuri na wazi! Lakini ikiwa tu ingekuwa rahisi. Moja ya sababu za utekelezaji huu ni shida: inapunguza kasi ya utoaji kwa kasi ya saa ya seva.

Kiwango cha Fremu: idadi ya fremu (yaani simu render()) kwa sekunde, au FPS. Michezo kwa kawaida hujitahidi kufikia angalau ramprogrammen 60.

Kiwango cha Jibu: Mara kwa mara ambayo seva hutuma masasisho ya mchezo kwa wateja. Mara nyingi ni ya chini kuliko kasi ya fremu. Katika mchezo wetu, seva inaendesha kwa kupe 30 kwa sekunde.

Ikiwa tutatoa tu sasisho la hivi punde la mchezo, basi FPS kimsingi haitaweza kuzidi 30 kwa sababu hatuwahi kupokea zaidi ya sasisho 30 kwa sekunde kutoka kwa seva. Hata tukipiga simu render() Mara 60 kwa sekunde, kisha nusu ya simu hizi zitachora upya kitu kile kile, kimsingi bila kufanya chochote. Shida nyingine na utekelezaji wa ujinga ni kwamba chini ya ucheleweshaji. Kwa kasi nzuri ya mtandao, mteja atapokea sasisho la mchezo haswa kila 33 ms (30 kwa sekunde):

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Kwa bahati mbaya, hakuna kitu kamili. Picha ya kweli zaidi itakuwa:
Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Utekelezaji wa ujinga ndio kesi mbaya zaidi linapokuja suala la latency. Ikiwa sasisho la mchezo litapokelewa kwa kuchelewa kwa 50ms, basi mteja hupunguzwa kasi kwa 50ms za ziada kwa sababu bado inatoa hali ya mchezo kutoka kwa sasisho la awali. Unaweza kufikiria jinsi hii ilivyo ngumu kwa mchezaji: kwa sababu ya kushuka kwa kiholela, mchezo utaonekana kuwa mbaya na usio na utulivu.

7.2 Hali ya mteja iliyoboreshwa

Tutafanya maboresho kadhaa kwa utekelezaji wa kijinga. Kwanza, tunatumia ucheleweshaji wa utoaji kwa 100 ms. Hii inamaanisha kuwa hali ya "sasa" ya mteja daima itakuwa 100ms nyuma ya hali ya mchezo kwenye seva. Kwa mfano, ikiwa wakati wa seva ni 150, basi mteja atatoa hali ambayo seva ilikuwa wakati huo 50:

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Hii inatupa bafa ya 100ms ili kustahimili muda usiotabirika wa masasisho ya mchezo:

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Bei ya hii itakuwa ya kudumu pembejeo lag kwa 100 ms. Hii ni dhabihu ndogo kwa uchezaji laini - wachezaji wengi (haswa wa kawaida) hawataona ucheleweshaji huu. Ni rahisi zaidi kwa watu kuzoea hali ya utulivu ya 100ms kuliko kucheza na muda usiotabirika.

Tunaweza kutumia mbinu nyingine inayoitwa "utabiri wa upande wa mteja", ambayo hufanya kazi nzuri ya kupunguza latency inayoonekana, lakini haitajadiliwa katika chapisho hili.

Uboreshaji mwingine tunaotumia ni tafsiri ya mstari. Kwa sababu ya kuchelewa kwa uwasilishaji, kwa kawaida sisi huwa angalau sasisho moja kabla ya wakati wa sasa katika mteja. Akiitwa getCurrentState(), tunaweza kutimiza tafsiri ya mstari kati ya sasisho za mchezo mara moja kabla na baada ya wakati wa sasa katika mteja:

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Hili hutatua tatizo la kasi ya fremu: sasa tunaweza kutoa fremu za kipekee kwa kasi yoyote tunayohitaji!

7.3 Utekelezaji wa hali ya mteja iliyoboreshwa

Mfano wa utekelezaji katika src/client/state.js hutumia ucheleweshaji wa utoaji na ukalimani wa mstari, lakini hii haidumu kwa muda mrefu. Hebu tugawanye kanuni katika sehemu mbili. Hii hapa ya kwanza:

state.js, sehemu ya 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Jambo la kwanza unahitaji kufanya ni kujua inafanya nini currentServerTime(). Kama tulivyoona hapo awali, kila sasisho la mchezo linajumuisha muhuri wa saa wa seva. Tunataka kutumia latency kutoa picha 100ms nyuma ya seva, lakini hatutawahi kujua wakati wa sasa kwenye seva, kwa sababu hatuwezi kujua ilichukua muda gani kwa masasisho yoyote kutufikia. Mtandao hautabiriki na kasi yake inaweza kutofautiana sana!

Ili kuzunguka tatizo hili, tunaweza kutumia makadirio yanayofaa: sisi tujifanye kuwa sasisho la kwanza limefika mara moja. Ikiwa hii ni kweli, basi tungejua wakati wa seva wakati huo huo! Tunahifadhi muhuri wa muda wa seva ndani firstServerTimestamp na kuokoa yetu mtaa (mteja) muhuri wa wakati kwa wakati mmoja ndani gameStart.

Oh, ngoja kidogo. Je! haipaswi kuwa na wakati kwenye seva = wakati kwa mteja? Kwa nini tunatofautisha kati ya "muhuri wa saa wa seva" na "muhuri wa muda wa mteja"? Hili ni swali kubwa! Inatokea kwamba haya si kitu kimoja. Date.now() itarudisha mihuri tofauti ya saa kwenye mteja na seva na hii inategemea mambo ya ndani ya mashine hizi. Usifikirie kamwe kuwa mihuri ya muda itakuwa sawa kwenye mashine zote.

Sasa tunaelewa kile kinachofanya currentServerTime(): inarudi muhuri wa muda wa seva wa wakati wa sasa wa uwasilishaji. Kwa maneno mengine, huu ni wakati wa sasa wa seva (firstServerTimestamp <+ (Date.now() - gameStart)) toa ucheleweshaji wa utoaji (RENDER_DELAY).

Sasa hebu tuangalie jinsi tunavyoshughulikia sasisho za mchezo. Wakati sasisho linapokelewa kutoka kwa seva, inaitwa processGameUpdate(), na tunahifadhi sasisho jipya kwa safu gameUpdates. Kisha, ili kuangalia matumizi ya kumbukumbu, tunaondoa sasisho zote za zamani kwa sasisho la msingikwa sababu hatuzihitaji tena.

"Sasisho la msingi" ni nini? Hii sasisho la kwanza tunapata kwa kusonga nyuma kutoka kwa wakati wa sasa wa seva. Je, unakumbuka mchoro huu?

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Sasisho la mchezo moja kwa moja upande wa kushoto wa "Wakati wa Kutoa Mteja" ndio sasisho la msingi.

Sasisho la msingi linatumika kwa nini? Kwa nini tunaweza kuacha sasisho kwa msingi? Ili kuelewa hili, hebu mwishowe tuangalie utekelezaji getCurrentState():

state.js, sehemu ya 2

export function getCurrentState() {
  if (!firstServerTimestamp) {
    return {};
  }

  const base = getBaseUpdate();
  const serverTime = currentServerTime();

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 0) {
    return gameUpdates[gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
    return gameUpdates[base];
  } else {
    const baseUpdate = gameUpdates[base];
    const next = gameUpdates[base + 1];
    const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
    return {
      me: interpolateObject(baseUpdate.me, next.me, r),
      others: interpolateObjectArray(baseUpdate.others, next.others, r),
      bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
    };
  }
}

Tunashughulikia kesi tatu:

  1. base < 0 inamaanisha kuwa hakuna masasisho hadi wakati wa sasa wa uwasilishaji (tazama utekelezaji hapo juu getBaseUpdate()) Hili linaweza kutokea mwanzoni mwa mchezo kwa sababu ya kuchelewa kwa matokeo. Katika kesi hii, tunatumia sasisho la hivi karibuni lililopokelewa.
  2. base ni sasisho la hivi karibuni tulilo nalo. Hili linaweza kutokea kutokana na muda wa kusubiri wa mtandao au muunganisho duni wa intaneti. Katika kesi hii pia tunatumia sasisho la hivi punde tulilo nalo.
  3. Tuna sasisho kabla na baada ya muda wa sasa wa kutoa, ili tuweze interpolate!

Yote iliyobaki ndani state.js ni utekelezaji wa ukalimani wa mstari ambao ni hesabu rahisi (lakini ya kuchosha). Ikiwa unataka kuchunguza mwenyewe, kisha ufungue state.js juu ya Github.

Sehemu ya 2. Seva ya nyuma

Katika sehemu hii tutaangalia nyuma ya Node.js ambayo inadhibiti yetu mfano wa mchezo wa .io.

1. Sehemu ya kuingilia ya seva

Ili kudhibiti seva ya wavuti tutatumia mfumo maarufu wa wavuti wa Node.js unaoitwa Express. Itasanidiwa na faili yetu ya sehemu ya ingizo ya seva src/server/server.js:

seva.js, sehemu ya 1

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

Kumbuka kwamba katika sehemu ya kwanza tulijadili Webpack? Hapa ndipo tutatumia usanidi wetu wa Webpack. Tutazitumia kwa njia mbili:

  • Tumia webpack-dev-middleware kuunda upya vifurushi vyetu vya usanidi kiotomatiki, au
  • Hamisha folda moja kwa moja dist/, ambayo Webpack itaandika faili zetu baada ya ujenzi wa uzalishaji.

Kazi nyingine muhimu server.js inajumuisha kusanidi seva tundu.ioambayo inaunganisha kwa seva ya Express:

seva.js, sehemu ya 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Baada ya kufanikiwa kuanzisha muunganisho wa socket.io na seva, tunasanidi vidhibiti vya tukio kwa soketi mpya. Vidhibiti vya matukio huchakata ujumbe uliopokewa kutoka kwa wateja kwa kukabidhi kwa kitu cha singleton game:

seva.js, sehemu ya 3

const Game = require('./game');

// ...

// Setup the Game
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

Tunaunda mchezo wa .io, kwa hivyo tutahitaji nakala moja pekee Game ("Mchezo") - wachezaji wote wanacheza kwenye uwanja mmoja! Katika sehemu inayofuata tutaona jinsi darasa hili linavyofanya kazi Game.

2. Seva za mchezo

Hatari Game ina mantiki muhimu zaidi ya upande wa seva. Ina kazi kuu mbili: usimamizi wa wachezaji ΠΈ simulation ya mchezo.

Wacha tuanze na kazi ya kwanza - kusimamia wachezaji.

mchezo.js, sehemu ya 1

const Constants = require('../shared/constants');
const Player = require('./player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Generate a position to start this player at.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

Katika mchezo huu tutawatambua wachezaji kwa uwanja id soketi yao ya soketi.io (ikiwa umechanganyikiwa, basi rudi kwa server.js) Socket.io yenyewe inapeana kila tundu kipekee id, kwa hivyo hatuhitaji kuwa na wasiwasi juu yake. nitamwita Kitambulisho cha mchezaji.

Kwa kuzingatia hilo, wacha tuchunguze vigeu vya mfano darasani Game:

  • sockets ni kitu kinachofunga kitambulisho cha mchezaji kwenye tundu ambalo linahusishwa na kichezaji. Inaturuhusu kufikia soketi kwa vitambulisho vyao vya wachezaji kwa wakati.
  • players ni kitu kinachofunga kitambulisho cha mchezaji kwenye msimbo>Kifaa cha kicheza

bullets ni safu ya vitu Bullet, kutokuwa na utaratibu maalum.
lastUpdateTime - Huu ndio muhuri wa muda wa sasisho la mwisho la mchezo. Tutaona jinsi inavyotumika hivi karibuni.
shouldSendUpdate ni kigezo kisaidizi. Pia tutaona matumizi yake hivi karibuni.
Njia addPlayer(), removePlayer() ΠΈ handleInput() hakuna haja ya kueleza, hutumiwa katika server.js. Ikiwa unahitaji kiboreshaji, rudi juu kidogo.

Mstari wa mwisho constructor() yazindua mzunguko wa sasisho michezo (na marudio ya visasisho 60 kwa sekunde):

mchezo.js, sehemu ya 2

const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

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

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  // ...
}

Mbinu update() pengine ina sehemu muhimu zaidi ya mantiki ya upande wa seva. Wacha tuorodheshe kila kitu kinachofanya kwa mpangilio:

  1. Huhesabu ni saa ngapi dt imekuwa tangu mwisho update().
  2. Huonyesha upya kila projectile na kuziharibu ikihitajika. Tutaona utekelezaji wa utendakazi huu baadaye. Kwa sasa inatosha sisi kujua hilo bullet.update() anarudi true, ikiwa projectile lazima iharibiwe (akatoka nje ya uwanja).
  3. Inasasisha kila mchezaji na kuunda projectile ikiwa ni lazima. Pia tutaona utekelezaji huu baadaye - player.update() inaweza kurudisha kitu Bullet.
  4. Hukagua migongano kati ya projectile na wachezaji wanaotumia applyCollisions(), ambayo hurejesha safu ya projectiles ambazo ziligonga wachezaji. Kwa kila projectile iliyorejeshwa, tunaongeza alama ya mchezaji aliyeifuta (kwa kutumia player.onDealtDamage()), na kisha uondoe projectile kutoka kwa safu bullets.
  5. Inaarifu na kuharibu wachezaji wote waliouawa.
  6. Hutuma sasisho la mchezo kwa wachezaji wote kila sekunde nyakati zinapoitwa update(). Tofauti msaidizi iliyotajwa hapo juu inatusaidia kufuatilia hili shouldSendUpdate. Kwa sababu update() inaitwa mara 60 / s, tunatuma sasisho za mchezo mara 30 / s. Hivyo, mzunguko wa saa seva ni mzunguko wa saa 30 / s (tulizungumza juu ya mzunguko wa saa katika sehemu ya kwanza).

Kwa nini utume masasisho ya mchezo pekee kupitia wakati ? Ili kuhifadhi kituo. Masasisho 30 ya mchezo kwa sekunde ni mengi!

Kwa nini usipige simu tu basi? update() Mara 30 kwa sekunde? Ili kuboresha simulation ya mchezo. Mara nyingi zaidi inaitwa update(), jinsi simulation ya mchezo itakuwa sahihi zaidi. Lakini usichukuliwe sana na idadi ya changamoto update(), kwa sababu hii ni kazi ya gharama kubwa - 60 kwa pili inatosha kabisa.

Wengine wa darasa Game inajumuisha njia za msaidizi zinazotumiwa katika update():

mchezo.js, sehemu ya 3

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Ni rahisi sana - inapanga wachezaji kwa alama, inachukua tano bora, na kurudisha jina la mtumiaji na alama kwa kila mmoja.

createUpdate() kutumika katika update() kuunda masasisho ya mchezo ambayo yanasambazwa kwa wachezaji. Kazi yake kuu ni kupiga simu mbinu serializeForUpdate(), kutekelezwa kwa madarasa Player ΠΈ Bullet. Kumbuka kuwa inahamisha data kwa kila mchezaji kuhusu karibu zaidi wachezaji na projectiles - hakuna haja ya kusambaza taarifa kuhusu vitu mchezo iko mbali na mchezaji!

3. Vitu vya mchezo kwenye seva

Katika mchezo wetu, makombora na wachezaji wanafanana sana: ni vitu vya mchezo vinavyosonga pande zote. Ili kufaidika na usawa huu kati ya wachezaji na projectiles, hebu tuanze kwa kutekeleza darasa la msingi 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,
    };
  }
}

Hakuna kitu ngumu kinachoendelea hapa. Darasa hili litakuwa mahali pazuri pa kuanzia kwa upanuzi. Wacha tuone jinsi darasa Bullet hutumia 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;
  }
}

Utekelezaji Bullet fupi sana! Tumeongeza kwa Object tu viendelezi vifuatavyo:

  • Kutumia kifurushi mfupi kwa kizazi cha nasibu id projectile.
  • Kuongeza shamba parentID, ili uweze kufuatilia mchezaji aliyeunda projectile hii.
  • Kuongeza thamani ya kurejesha update(), ambayo ni sawa true, ikiwa projectile iko nje ya uwanja (kumbuka tulizungumza kuhusu hili katika sehemu ya mwisho?).

Hebu tuendelee Player:

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

Wachezaji ni changamano zaidi kuliko makadirio, kwa hivyo darasa hili linapaswa kuhifadhi nyuga chache zaidi. Mbinu yake update() hufanya kazi zaidi, haswa kurudisha projectile mpya iliyoundwa ikiwa hakuna iliyobaki fireCooldown (kumbuka tulizungumza juu ya hili katika sehemu iliyopita?). Pia huongeza njia serializeForUpdate(), kwa sababu tunahitaji kujumuisha sehemu za ziada za mchezaji kwenye sasisho la mchezo.

Upatikanaji wa darasa la msingi Object - hatua muhimu ili kuepuka kurudia msimbo. Kwa mfano, bila darasa Object kila kitu cha mchezo lazima kiwe na utekelezaji sawa distanceTo(), na kunakili-kubandika utekelezaji huu wote kwenye faili nyingi itakuwa ndoto. Hii inakuwa muhimu hasa kwa miradi mikubwa, wakati idadi ya kupanua Object madarasa yanaongezeka.

4. Utambuzi wa mgongano

Kitu pekee kilichobaki kwetu ni kutambua wakati makombora yanapogonga wachezaji! Kumbuka kijisehemu hiki cha msimbo kutoka kwa mbinu update() darasani Game:

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

    // ...
  }
}

Tunahitaji kutekeleza mbinu applyCollisions(), ambayo inarudisha projectiles zote ambazo ziligonga wachezaji. Kwa bahati nzuri, hii sio ngumu kufanya kwa sababu

  • Vitu vyote vinavyogongana ni miduara, na hii ndiyo sura rahisi zaidi ya kutekeleza utambuzi wa mgongano.
  • Tayari tunayo mbinu distanceTo(), ambayo tulitekeleza darasani katika sehemu iliyopita Object.

Hivi ndivyo utekelezaji wetu wa ugunduzi wa mgongano unavyoonekana:

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

Ugunduzi huu rahisi wa mgongano unatokana na ukweli kwamba duru mbili zinagongana ikiwa umbali kati ya vituo vyao ni chini ya jumla ya radii zao. Hapa kuna kesi ambapo umbali kati ya vituo vya miduara miwili ni sawa na jumla ya radii zao:

Kuunda mchezo wa wavuti wa wachezaji wengi katika aina ya .io
Hapa unahitaji kulipa kipaumbele kwa mambo kadhaa zaidi:

  • Kombora haipaswi kumpiga mchezaji aliyeiunda. Hii inaweza kupatikana kwa kulinganisha bullet.parentID с player.id.
  • Kombora linapaswa kugonga mara moja tu katika hali mbaya zaidi ya kupiga wachezaji wengi kwa wakati mmoja. Tutatatua tatizo hili kwa kutumia operator break: Mara tu mchezaji anayegongana na projectile anapatikana, tunaacha kutafuta na kwenda kwenye projectile inayofuata.

Mwisho

Ni hayo tu! Tumeshughulikia kila kitu unachohitaji kujua ili kuunda mchezo wa wavuti wa .io. Nini kinafuata? Jenga mchezo wako mwenyewe wa .io!

Nambari zote za mfano ni chanzo wazi na hutumwa Github.

Chanzo: mapenzi.com

Kuongeza maoni