Multiplayer վեբ խաղի ստեղծում .io ժանրում

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Թողարկվել է 2015թ Agar.io դարձավ նոր ժանրի նախահայր games.io, որի ժողովրդականությունը այդ ժամանակից ի վեր մեծապես աճել է: Ես ինքս զգացել եմ .io խաղերի ժողովրդականության աճը. վերջին երեք տարիների ընթացքում ես ստեղծել և վաճառել է այս ժանրի երկու խաղ:.

Եթե ​​նախկինում երբեք չեք լսել այս խաղերի մասին, դրանք անվճար, բազմախաղացող վեբ խաղեր են, որոնք հեշտ է խաղալ (հաշիվ չի պահանջվում): Նրանք սովորաբար մի ասպարեզում հավաքում են բազմաթիվ հակառակորդ խաղացողների: Այլ հայտնի .io խաղեր. Slither.io и Diep.io.

Այս գրառման մեջ մենք կպարզենք, թե ինչպես ստեղծել .io խաղ զրոյից. Դա անելու համար բավական կլինի միայն Javascript-ի իմացությունը. դուք պետք է հասկանաք այնպիսի բաներ, ինչպիսիք են շարահյուսությունը ES6, հիմնաբառ this и Խոստումներ. Նույնիսկ եթե դուք հիանալի չգիտեք Javascript-ը, այնուամենայնիվ կարող եք հասկանալ գրառման մեծ մասը:

.io խաղի օրինակ

Վերապատրաստման օգնության համար մենք կանդրադառնանք օրինակ խաղ .io. Փորձեք խաղալ այն:

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Խաղը բավականին պարզ է՝ դուք կառավարում եք նավը արենայում այլ խաղացողների հետ: Ձեր նավը ավտոմատ կերպով կրակում է արկեր, և դուք փորձում եք հարվածել այլ խաղացողներին՝ խուսափելով նրանց արկերից:

1. Համառոտ ակնարկ/նախագծի կառուցվածք

Ես խորհուրդ ներբեռնել աղբյուրի կոդը օրինակ խաղ, որպեսզի կարողանաք հետևել ինձ:

Օրինակը օգտագործում է հետևյալը.

  • Սուրհանդակ Node.js-ի համար ամենատարածված վեբ շրջանակն է, որը կառավարում է խաղի վեբ սերվերը:
  • socket.io — վեբսոկետ գրադարան բրաուզերի և սերվերի միջև տվյալների փոխանակման համար:
  • Վեբկապ - մոդուլի կառավարիչ: Դուք կարող եք կարդալ այն մասին, թե ինչու օգտագործել Webpack-ը այստեղ.

Ծրագրի գրացուցակի կառուցվածքը այսպիսին է թվում.

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

հանրային/

Ամեն ինչ թղթապանակում է public/ ստատիկ կերպով կփոխանցվի սերվերի կողմից: IN public/assets/ պարունակում է մեր նախագծի կողմից օգտագործված պատկերներ:

src /

Բոլոր սկզբնական կոդը գտնվում է թղթապանակում src/. Վերնագրեր client/ и server/ խոսում են իրենց համար և shared/ պարունակում է հաստատունների ֆայլ, որը ներմուծվում է և՛ հաճախորդի, և՛ սերվերի կողմից:

2. Հավաքումներ/նախագծի պարամետրեր

Ինչպես նշվեց վերևում, մենք օգտագործում ենք մոդուլի կառավարիչ՝ նախագիծը կառուցելու համար Վեբկապ. Եկեք նայենք մեր 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',
    }),
  ],
};

Այստեղ ամենակարևոր տողերը հետևյալն են.

  • src/client/index.js Javascript (JS) հաճախորդի մուտքի կետն է: Webpack-ը կսկսվի այստեղից և ռեկուրսիվ կերպով կփնտրի այլ ներմուծված ֆայլեր:
  • Մեր Webpack-ի ելքային JS-ը կգտնվի գրացուցակում dist/. Ես այս ֆայլը կանվանեմ մեր JS փաթեթ.
  • Մենք օգտագործում ենք Babel, և, մասնավորապես, կոնֆիգուրացիան @babel/preset-env մեր JS կոդը ավելի հին բրաուզերների համար փոխակերպելու համար:
  • Մենք օգտագործում ենք plugin՝ հանելու բոլոր CSS-ները, որոնք հղում են անում JS ֆայլերին և միավորում դրանք մեկ տեղում: Մերը կանվանեմ CSS փաթեթ.

Դուք կարող եք նկատել տարօրինակ փաթեթի ֆայլերի անուններ '[name].[contenthash].ext'. Դրանք պարունակում են ֆայլի անվան փոխարինում Վեբ փաթեթ: [name] կփոխարինվի մուտքագրման կետի անունով (մեր դեպքում դա այդպես է game), և [contenthash] կփոխարինվի ֆայլի բովանդակության հեշով: Մենք դա անում ենք օպտիմիզացնել նախագիծը հեշինգի համար - մենք կարող ենք բրաուզերներին ասել, որ քեշավորեն մեր JS փաթեթները անորոշ ժամանակով, քանի որ եթե փաթեթը փոխվում է, նրա ֆայլի անունը նույնպես փոխվում է (փոփոխություններ contenthash) Ավարտված արդյունքը կլինի դիտման ֆայլի անունը game.dbeee76e91a97d0c7207.js.

ֆայլ webpack.common.js - Սա հիմնական կազմաձևման ֆայլն է, որը մենք ներմուծում ենք մշակման և ավարտված նախագծի կազմաձևերում: Օրինակ, ահա զարգացման կոնֆիգուրացիան.

webpack.dev.js

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

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

Արդյունավետության համար մենք օգտագործում ենք զարգացման գործընթացում webpack.dev.js, և անցնում է webpack.prod.js, փաթեթի չափսերը օպտիմիզացնել արտադրության մեջ տեղակայելիս:

Տեղական կարգավորում

Ես խորհուրդ եմ տալիս նախագիծը տեղադրել ձեր տեղական մեքենայի վրա, որպեսզի կարողանաք հետևել այս գրառման մեջ նշված քայլերին: Կարգավորումը պարզ է. նախ համակարգը պետք է ունենա Հանգույց и NPM. Հաջորդը դուք պետք է անեք

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

և դու պատրաստ ես գնալու։ Զարգացման սերվերը գործարկելու համար պարզապես գործարկեք

$ npm run develop

և գնացեք ձեր վեբ բրաուզերը տեղական ցանց ՝ 3000. Զարգացման սերվերը ավտոմատ կերպով կվերակառուցի JS և CSS փաթեթները, քանի որ կոդի փոփոխությունները տեղի են ունենում. պարզապես թարմացրեք էջը՝ բոլոր փոփոխությունները տեսնելու համար:

3. Հաճախորդի մուտքի կետեր

Եկեք իջնենք հենց խաղի կոդը: Նախ մեզ անհրաժեշտ է էջ index.html, երբ այցելեք կայք, զննարկիչը նախ այն կբեռնի: Մեր էջը կլինի բավականին պարզ.

index.html

Օրինակ .io խաղ  ԽԱՂԱԼ

Այս կոդի օրինակը մի փոքր պարզեցվել է պարզության համար, և ես նույնը կանեմ գրառման մեջ ներկայացված շատ այլ օրինակների հետ: Դուք միշտ կարող եք դիտել ամբողջական կոդը այստեղ Github.

Մենք ունենք:

  • HTML5 կտավի տարր (<canvas>), որը մենք կօգտագործենք խաղը ցուցադրելու համար:
  • <link> ավելացնել մեր CSS փաթեթը:
  • <script> ավելացնել մեր Javascript փաթեթը:
  • Հիմնական մենյու օգտանունով <input> և «PLAY» կոճակը (<button>).

Գլխավոր էջը բեռնելուց հետո զննարկիչը կսկսի կատարել Javascript կոդը՝ սկսած մուտքի կետի 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);
  };
});

Սա կարող է բարդ թվալ, բայց իրականում այստեղ շատ բան չի կատարվում.

  1. Ներմուծեք մի քանի այլ JS ֆայլեր:
  2. Ներմուծեք CSS (որպեսզի Webpack-ը իմանա, որ դրանք ներառի մեր CSS փաթեթում):
  3. Գործարկել connect() սերվերի հետ կապ հաստատելու և սկսելու համար downloadAssets() խաղը ցուցադրելու համար անհրաժեշտ պատկերները ներբեռնելու համար:
  4. 3-րդ փուլն ավարտելուց հետո ցուցադրվում է հիմնական ընտրացանկը (playMenu).
  5. «PLAY» կոճակի սեղմման կարգավորիչի կարգավորում: Երբ կոճակը սեղմվում է, կոդը սկզբնավորում է խաղը և սերվերին ասում, որ մենք պատրաստ ենք խաղալ:

Մեր հաճախորդ-սերվեր տրամաբանության հիմնական «միսն» այն ֆայլերում է, որոնք ներմուծվել են ֆայլով index.js. Այժմ մենք բոլորին կդիտարկենք ըստ հերթականության։

4. Հաճախորդի տվյալների փոխանակում

Այս խաղում մենք օգտագործում ենք հայտնի գրադարան սերվերի հետ շփվելու համար socket.io. Socket.io-ն ունի ներկառուցված աջակցություն WebSockets, որոնք հարմար են երկկողմանի հաղորդակցության համար. մենք կարող ենք հաղորդագրություններ ուղարկել սերվերին и սերվերը կարող է մեզ հաղորդագրություններ ուղարկել նույն կապի միջոցով:

Մենք կունենանք մեկ ֆայլ src/client/networking.jsով կպահի բոլորին կապ սերվերի հետ.

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

Այս ծածկագիրը նույնպես մի փոքր կրճատված է պարզության համար:

Այս ֆայլում երեք հիմնական բան է տեղի ունենում.

  • Մենք փորձում ենք միանալ սերվերին: connectedPromise թույլատրվում է միայն այն դեպքում, երբ մենք կապ ենք հաստատել:
  • Եթե ​​կապը հաջող է, մենք գրանցում ենք հետադարձ կապի գործառույթներ (processGameUpdate() и onGameOver()) հաղորդագրությունների համար, որոնք մենք կարող ենք ստանալ սերվերից:
  • Մենք արտահանում ենք play() и updateDirection()որպեսզի այլ ֆայլեր կարողանան օգտագործել դրանք:

5. Հաճախորդների մատուցում

Ժամանակն է ցուցադրել նկարը էկրանին:

...սակայն դա անելուց առաջ մենք պետք է ներբեռնենք բոլոր պատկերները (ռեսուրսները), որոնք անհրաժեշտ են դրա համար: Եկեք գրենք ռեսուրսների կառավարիչ.

ակտիվներ.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];

Ռեսուրսների կառավարումն այնքան էլ դժվար չէ իրականացնել: Հիմնական կետը օբյեկտ պահելն է assets, որը կկապի ֆայլի անվան բանալին օբյեկտի արժեքին Image. Երբ ռեսուրսը բեռնված է, մենք այն պահում ենք օբյեկտում assets ապագայում արագ ստանալու համար: Ե՞րբ կթույլատրվի յուրաքանչյուր առանձին ռեսուրսի ներբեռնումը (այսինքն՝ կներբեռնվի բոլորը ռեսուրսներ), մենք թույլ ենք տալիս downloadPromise.

Ռեսուրսները ներբեռնելուց հետո կարող եք սկսել մատուցումը: Ինչպես արդեն ասվեց, վեբ էջի վրա նկարելու համար մենք օգտագործում ենք HTML5 կտավ (<canvas>) Մեր խաղը բավականին պարզ է, ուստի մեզ միայն պետք է մատուցել հետևյալը.

  1. ետին պլան
  2. Խաղացողի նավ
  3. Խաղի այլ խաղացողներ
  4. Ռումբերն

Ահա կարևոր հատվածները src/client/render.js, որոնք գծում են հենց վերը թվարկված չորս կետերը.

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

Այս ծածկագիրը նույնպես կրճատված է պարզության համար:

render() այս ֆայլի հիմնական գործառույթն է: startRendering() и stopRendering() վերահսկել մատուցման ցիկլի ակտիվացումը 60 FPS-ով:

Անհատական ​​մատուցման օգնական գործառույթների հատուկ իրականացումներ (օրինակ renderBullet()) այնքան էլ կարևոր չեն, բայց ահա մեկ պարզ օրինակ.

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

Նշենք, որ մենք օգտագործում ենք մեթոդը getAsset(), որը նախկինում երևում էր asset.js!

Եթե ​​դուք հետաքրքրված եք այլ մատուցման օգնական գործառույթների ուսումնասիրությամբ, ապա կարդացեք մնացածը src/client/render.js.

6. Հաճախորդի մուտքագրում

Ժամանակն է խաղ սարքել խաղալի! Կառավարման սխեման շատ պարզ կլինի՝ շարժման ուղղությունը փոխելու համար կարող եք օգտագործել մկնիկը (համակարգչի վրա) կամ դիպչել էկրանին (բջջային սարքի վրա): Դա իրականացնելու համար մենք կգրանցենք Իրադարձությունների ունկնդիրներ Mouse and Touch միջոցառումների համար:
Այս ամենի մասին հոգ կտանի 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() Իրադարձությունների ունկնդիրներ են, որոնք զանգահարում են updateDirection() (տեսանյութ) networking.js) երբ մուտքային իրադարձություն է տեղի ունենում (օրինակ, երբ մկնիկը տեղափոխվում է): updateDirection() զբաղվում է սերվերի հետ հաղորդագրությունների փոխանակմամբ, որը մշակում է մուտքային իրադարձությունը և համապատասխանաբար թարմացնում խաղի վիճակը։

7. Հաճախորդի կարգավիճակը

Այս բաժինը ամենադժվարն է գրառման առաջին մասում։ Մի հուսահատվեք, եթե առաջին անգամ կարդալուց չեք հասկանում այն: Դուք նույնիսկ կարող եք բաց թողնել այն և ավելի ուշ վերադառնալ դրան:

Հաճախորդ-սերվերի կոդը լրացնելու համար անհրաժեշտ գլուխկոտրուկի վերջին կտորն է էին. Հիշու՞մ եք «Client Rendering» բաժնի կոդի հատվածը:

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() պետք է կարողանա մեզ տրամադրել հաճախորդի ներկայիս խաղի վիճակը ցանկացած ժամանակ հիմնված սերվերից ստացված թարմացումների վրա: Ահա խաղի թարմացման օրինակ, որը սերվերը կարող է ուղարկել.

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

Յուրաքանչյուր խաղի թարմացում պարունակում է հինգ նույնական դաշտեր.

  • tՍերվերի ժամանակացույց, որը ցույց է տալիս, թե երբ է ստեղծվել այս թարմացումը:
  • meՏեղեկություններ այս թարմացումը ստացող խաղացողի մասին:
  • ուրիշներըՏեղեկությունների զանգված նույն խաղին մասնակցող այլ խաղացողների մասին:
  • փամփուշտներ: խաղի արկերի մասին տեղեկատվության զանգված:
  • առաջատարըԸնթացիկ առաջատարների տվյալները: Այս գրառման մեջ մենք դրանք հաշվի չենք առնի։

7.1 Հաճախորդի միամիտ վիճակը

Միամիտ իրականացում getCurrentState() կարող է ուղղակիորեն վերադարձնել տվյալներ միայն վերջին ստացված խաղի թարմացումից:

միամիտ-պետություն.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Գեղեցիկ և պարզ! Բայց եթե միայն այդքան պարզ լիներ: Այս իրականացման խնդրահարույց պատճառներից մեկը. այն սահմանափակում է մատուցման շրջանակի արագությունը սերվերի ժամացույցի արագությամբ.

Շրջանակի արագություն: շրջանակների քանակը (այսինքն՝ զանգեր render()) վայրկյանում կամ FPS: Խաղերը սովորաբար ձգտում են հասնել առնվազն 60 FPS:

Նշման տոկոսադրույքըՀաճախականությունը, որով սերվերն ուղարկում է խաղի թարմացումները հաճախորդներին: Այն հաճախ ավելի ցածր է, քան շրջանակի արագությունը. Մեր խաղում սերվերն աշխատում է վայրկյանում 30 տիզով:

Եթե ​​մենք պարզապես մատուցենք խաղի վերջին թարմացումը, ապա FPS-ն, ըստ էության, երբեք չի կարողանա գերազանցել 30-ը, քանի որ մենք երբեք սերվերից վայրկյանում 30-ից ավելի թարմացումներ չենք ստանում. Նույնիսկ եթե մենք զանգահարենք render() Վայրկյանում 60 անգամ, ապա այս զանգերի կեսը պարզապես կվերաշարադրի նույն բանը՝ ըստ էության ոչինչ չանելով: Միամիտ իրականացման մեկ այլ խնդիր այն է, որ դա ենթակա է ուշացումների. Իդեալական ինտերնետի արագությամբ հաճախորդը կստանա խաղի թարմացում ուղիղ յուրաքանչյուր 33 ms (30 վայրկյանում).

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Ցավոք, ոչինչ կատարյալ չէ: Ավելի իրատեսական պատկերը կլինի.
Multiplayer վեբ խաղի ստեղծում .io ժանրում
Միամիտ իրականացումը գրեթե ամենավատ դեպքն է, երբ խոսքը վերաբերում է ուշացմանը: Եթե ​​խաղի թարմացումը ստացվում է 50 մս ուշացումով, ապա հաճախորդը դանդաղում է հավելյալ 50 ms-ով, քանի որ այն դեռ ցուցադրում է խաղի վիճակը նախորդ թարմացումից: Դուք կարող եք պատկերացնել, թե որքան անհարմար է սա խաղացողի համար. կամայական դանդաղումների պատճառով խաղը կթվա թեթև և անկայուն:

7.2 Բարելավված հաճախորդի վիճակը

Մենք որոշակի բարելավումներ կանենք միամիտ իրականացման հարցում։ Նախ, մենք օգտագործում ենք մատուցման հետաձգում 100 ms-ով: Սա նշանակում է, որ հաճախորդի «ընթացիկ» վիճակը միշտ 100 մս-ով հետ կմնա սերվերի վրա գտնվող խաղի վիճակից: Օրինակ, եթե սերվերի ժամանակն է 150, այնուհետև հաճախորդը կցուցադրի այն վիճակը, որում գտնվում էր սերվերը տվյալ պահին 50:

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Սա մեզ տալիս է 100 ms բուֆեր՝ խաղի թարմացումների անկանխատեսելի ժամանակից գոյատևելու համար.

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Դրա գինը մշտական ​​կլինի մուտքային ուշացում 100 ms-ով: Սա աննշան զոհաբերություն է սահուն խաղի համար. խաղացողներից շատերը (հատկապես պատահականները) չեն էլ նկատի այս ուշացումը: Մարդկանց համար շատ ավելի հեշտ է հարմարվել հաստատուն 100ms հետաձգմանը, քան խաղալ անկանխատեսելի հետաձգման հետ:

Մենք կարող ենք օգտագործել մեկ այլ տեխնիկա, որը կոչվում է «հաճախորդի կողմից կանխատեսում», որը լավ աշխատանք է կատարում ընկալվող հետաձգումը նվազեցնելու համար, բայց չի քննարկվի այս գրառման մեջ:

Մեկ այլ բարելավում, որը մենք օգտագործում ենք գծային ինտերպոլացիա. Արտադրման հետաձգման պատճառով մենք սովորաբար առնվազն մեկ թարմացում ենք հաճախորդի ընթացիկ ժամանակից շուտ: Երբ կոչվում է getCurrentState(), մենք կարող ենք կատարել գծային ինտերպոլացիա Հաճախորդի ընթացիկ ժամանակից անմիջապես առաջ և հետո խաղի թարմացումների միջև.

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Սա լուծում է կադրերի արագության խնդիրը. մենք այժմ կարող ենք եզակի կադրեր ցուցադրել մեզ անհրաժեշտ ցանկացած կադրերի արագությամբ:

7.3 Բարելավված հաճախորդի վիճակի իրականացում

Իրականացման օրինակ src/client/state.js օգտագործում է և՛ մատուցման հետաձգում, և՛ գծային ինտերպոլացիա, բայց դա երկար չի տևում: Բաժանենք կոդը երկու մասի. Ահա առաջինը.

state.js, մաս 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;
}

Առաջին բանը, որ դուք պետք է անեք, պարզելն է, թե ինչ է դա անում currentServerTime(). Ինչպես տեսանք ավելի վաղ, խաղի յուրաքանչյուր թարմացում ներառում է սերվերի ժամանակացույց: Մենք ցանկանում ենք օգտագործել render latency՝ պատկերը սերվերի ետևում 100 մս ցուցադրելու համար, բայց մենք երբեք չենք իմանա սերվերի ընթացիկ ժամանակը, քանի որ մենք չենք կարող իմանալ, թե որքան ժամանակ է պահանջվել, որ թարմացումներից որևէ մեկը հասնի մեզ: Ինտերնետը անկանխատեսելի է, և դրա արագությունը կարող է շատ տարբեր լինել:

Այս խնդիրը շրջանցելու համար մենք կարող ենք օգտագործել ողջամիտ մոտարկում՝ մենք եկեք ձևացնենք, որ առաջին թարմացումը անմիջապես եկավ. Եթե ​​դա ճիշտ լիներ, ապա մենք կիմանայինք սերվերի ժամանակը տվյալ պահին: Մենք պահում ենք սերվերի ժամանակացույցը firstServerTimestamp և փրկիր մեր տեղական (հաճախորդ) ժամանակի դրոշմը նույն պահին gameStart.

Օ, մի րոպե սպասիր։ Չպե՞տք է լինի ժամանակ սերվերում = ժամանակ հաճախորդի վրա: Ինչո՞ւ ենք մենք տարբերակում «սերվերի ժամանակի դրոշմանիշը» և «հաճախորդի ժամանակի դրոշմը»: Սա հիանալի հարց է: Պարզվում է՝ սրանք նույն բանը չեն։ Date.now() հաճախորդում և սերվերում կվերադարձնի տարբեր ժամանակային դրոշմներ, և դա կախված է այս մեքենաների տեղական գործոններից: Երբեք մի ենթադրեք, որ ժամանակի դրոշմանիշները նույնը կլինեն բոլոր մեքենաների վրա:

Այժմ մենք հասկանում ենք, թե ինչ է դա անում currentServerTime():վերադառնում է մատուցման ընթացիկ ժամանակի սերվերի ժամանակացույցը. Այլ կերպ ասած, սա ընթացիկ սերվերի ժամանակն է (firstServerTimestamp <+ (Date.now() - gameStart)) հանած մատուցման ուշացումը (RENDER_DELAY).

Հիմա եկեք տեսնենք, թե ինչպես ենք մենք վարվում խաղի թարմացումների հետ: Երբ սերվերից թարմացում է ստացվում, այն կոչվում է processGameUpdate(), և մենք պահում ենք նոր թարմացումը զանգվածում gameUpdates. Այնուհետև, հիշողության օգտագործումը ստուգելու համար մենք հեռացնում ենք բոլոր հին թարմացումները բազայի թարմացումքանի որ դրանք մեզ այլևս պետք չեն:

Ի՞նչ է «հիմնական թարմացումը»: Սա առաջին թարմացումը, որը մենք գտնում ենք՝ հետ շարժվելով ընթացիկ սերվերի ժամանակից. Հիշում եք այս դիագրամը:

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Խաղի թարմացումը անմիջապես «Client Render Time»-ի ձախ կողմում բազային թարմացումն է:

Ինչի՞ համար է օգտագործվում բազային թարմացումը: Ինչո՞ւ կարող ենք թարմացումները թողնել բազա: Սա հասկանալու համար եկեք վերջապես եկեք նայենք իրականացմանը getCurrentState():

state.js, մաս 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),
    };
  }
}

Մենք զբաղվում ենք երեք գործով.

  1. base < 0 նշանակում է, որ թարմացումներ չկան մինչև մատուցման ընթացիկ ժամանակը (տես վերևում իրականացումը getBaseUpdate()). Դա կարող է տեղի ունենալ հենց խաղի սկզբում` մատուցման հետաձգման պատճառով: Այս դեպքում մենք օգտագործում ենք ամենավերջին ստացված թարմացումը:
  2. base մեր ունեցած ամենավերջին թարմացումն է: Դա կարող է տեղի ունենալ ցանցի հետաձգման կամ վատ ինտերնետ կապի պատճառով: Այս դեպքում նույնպես մենք օգտագործում ենք մեր ունեցած վերջին թարմացումը:
  3. Մենք թարմացում ունենք և՛ մատուցման ընթացիկ ժամանակից առաջ, և՛ հետո, այնպես որ կարող ենք interpolate!

Այն ամենը, ինչ մնացել է ներսում state.js գծային ինտերպոլացիայի իրականացում է, որը պարզ (բայց ձանձրալի) մաթեմատիկա է: Եթե ​​ցանկանում եք ինքներդ ուսումնասիրել այն, ապա բացեք state.js մասին Github.

Մաս 2. Backend սերվեր

Այս մասում մենք կանդրադառնանք Node.js backend-ին, որը վերահսկում է մեր .io խաղի օրինակ.

1. Սերվերի մուտքի կետ

Վեբ սերվերը կառավարելու համար մենք կօգտագործենք Node.js-ի հայտնի վեբ շրջանակը, որը կոչվում է Սուրհանդակ. Այն կկարգավորվի մեր սերվերի մուտքի կետի ֆայլի կողմից src/server/server.js:

server.js, մաս 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}`);

Հիշո՞ւմ եք, որ առաջին մասում մենք քննարկեցինք Webpack-ը: Այստեղ մենք կօգտագործենք մեր Webpack կոնֆիգուրացիան: Մենք դրանք կկիրառենք երկու եղանակով.

  • Օգտագործման համար webpack-dev-middleware ավտոմատ կերպով վերակառուցելու մեր զարգացման փաթեթները, կամ
  • Ստատիկ կերպով փոխանցեք թղթապանակ dist/, որի մեջ Webpack-ը կգրի մեր ֆայլերը արտադրության կառուցումից հետո:

Մեկ այլ կարևոր խնդիր server.js բաղկացած է սերվերի կարգավորումից socket.ioորը պարզապես միանում է Express սերվերին.

server.js, մաս 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);
});

Սերվերի հետ socket.io կապը հաջողությամբ հաստատելուց հետո մենք կարգավորում ենք իրադարձությունների մշակողները նոր վարդակից: Իրադարձությունների մշակողները մշակում են հաճախորդներից ստացված հաղորդագրությունները՝ պատվիրակելով միանգամյա օբյեկտին game:

server.js, մաս 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);
}

Մենք ստեղծում ենք .io խաղ, ուստի մեզ միայն մեկ օրինակ է անհրաժեշտ Game («Խաղ») - բոլոր խաղացողները խաղում են նույն ասպարեզում: Հաջորդ բաժնում մենք կտեսնենք, թե ինչպես է աշխատում այս դասը Game.

2. Խաղի սերվերներ

Դաս Game պարունակում է սերվերի կողմից ամենակարևոր տրամաբանությունը: Այն ունի երկու հիմնական խնդիր. խաղացողների կառավարում и խաղի սիմուլյացիա.

Սկսենք առաջին առաջադրանքից՝ կառավարել խաղացողներին:

game.js, մաս 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);
    }
  }

  // ...
}

Այս խաղում մենք խաղացողներին կբացահայտենք ըստ դաշտի id նրանց վարդակից socket.io (եթե շփոթված եք, ապա վերադարձեք server.js). Ինքը՝ Socket.io-ն յուրաքանչյուր վարդակից եզակի է տալիս id, այնպես որ մենք դրա մասին անհանգստանալու կարիք չունենք: Ես կկանչեմ նրան Խաղացողի ID.

Դա նկատի ունենալով, եկեք ուսումնասիրենք դասի օրինակի փոփոխականները Game:

  • sockets առարկա է, որը կապում է նվագարկչի ID-ն նվագարկչի հետ կապված վարդակից: Այն թույլ է տալիս մեզ ժամանակի ընթացքում մուտք գործել վարդակներ իրենց խաղացողի ID-ներով:
  • players օբյեկտ է, որը կապում է խաղացողի ID-ն կոդի հետ> Player օբյեկտի հետ

bullets առարկաների զանգված է Bullet, չունենալով կոնկրետ պատվեր։
lastUpdateTime - Սա խաղի վերջին թարմացման ժամանակացույցն է: Մենք շուտով կտեսնենք, թե ինչպես է այն օգտագործվելու:
shouldSendUpdate օժանդակ փոփոխական է: Շուտով կտեսնենք նաև դրա օգտագործումը։
Մեթոդներ addPlayer(), removePlayer() и handleInput() բացատրելու կարիք չկա, դրանք օգտագործվում են server.js. Եթե ​​թարմացման կարիք ունեք, մի փոքր ավելի բարձր գնացեք:

Վերջին տող constructor() սկսում է թարմացման ցիկլը խաղեր (60 թարմացում/վրկ հաճախականությամբ).

game.js, մաս 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;
    }
  }

  // ...
}

մեթոդ update() պարունակում է սերվերի կողմի տրամաբանության, հավանաբար, ամենակարևոր մասը: Եկեք թվարկենք այն ամենը, ինչ դա անում է հերթականությամբ.

  1. Հաշվում է ժամը քանիսն է dt դա անցյալից է update().
  2. Թարմացնում է յուրաքանչյուր արկ և անհրաժեշտության դեպքում ոչնչացնում է դրանք: Այս ֆունկցիոնալության իրականացումը մենք կտեսնենք ավելի ուշ: Առայժմ դա մեզ համար բավական է իմանալ bullet.update() վերադարձ true, եթե արկը պետք է ոչնչացվի (նա դուրս եկավ ասպարեզից դուրս):
  3. Թարմացնում է յուրաքանչյուր խաղացողի և անհրաժեշտության դեպքում ստեղծում արկ: Մենք նաև կտեսնենք այս իրականացումը ավելի ուշ. player.update() կարող է վերադարձնել օբյեկտը Bullet.
  4. Ստուգում է հրթիռների և խաղացողների միջև բախումների համար applyCollisions(), որը վերադարձնում է արկերի զանգված, որոնք հարվածում են խաղացողներին: Յուրաքանչյուր վերադարձված արկի համար մենք ավելացնում ենք այն խաղացողի գնահատականը, ով արձակել է այն (օգտագործելով player.onDealtDamage()), այնուհետև հեռացրեք արկը զանգվածից bullets.
  5. Տեղեկացնում և ոչնչացնում է բոլոր սպանված խաղացողներին:
  6. Խաղի թարմացում է ուղարկում բոլոր խաղացողներին ամեն վայրկյան անգամ, երբ կանչվում է update(). Վերը նշված օժանդակ փոփոխականն օգնում է մեզ հետևել դրան shouldSendUpdate. Ինչպես update() զանգահարել 60 անգամ/վրկ, մենք ուղարկում ենք խաղի թարմացումները 30 անգամ/վրկ: Այսպիսով, ժամացույցի հաճախականությունը սերվերը 30 ժամացույցի ցիկլ/վրկ է (մենք առաջին մասում խոսեցինք ժամացույցի հաճախականության մասին):

Ինչու ուղարկել միայն խաղի թարմացումները ժամանակի միջով ? Ալիքը պահելու համար: 30 խաղի թարմացում վայրկյանում շատ է:

Ինչու՞ պարզապես չզանգել այդ դեպքում: update() 30 անգամ վայրկյանում? Խաղի սիմուլյացիան բարելավելու համար: Որքան հաճախ է այն կոչվում update(), այնքան ավելի ճշգրիտ կլինի խաղի սիմուլյացիան։ Բայց շատ մի տարվեք մարտահրավերների քանակով update(), քանի որ սա հաշվողականորեն թանկ խնդիր է՝ վայրկյանում 60-ը միանգամայն բավարար է։

Դասարանի մնացած մասը Game բաղկացած է օգնական մեթոդներից, որոնք օգտագործվում են update():

game.js, մաս 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() Դա բավականին պարզ է՝ այն դասակարգում է խաղացողներին ըստ միավորների, վերցնում է լավագույն հնգյակը և վերադարձնում օգտվողի անունը և միավորները յուրաքանչյուրի համար:

createUpdate() օգտագործվում է update() ստեղծել խաղերի թարմացումներ, որոնք բաժանվում են խաղացողներին: Նրա հիմնական խնդիրն է մեթոդներ կանչել serializeForUpdate(), իրականացվում է դասերի համար Player и Bullet. Նկատի ունեցեք, որ այն միայն տվյալներ է փոխանցում յուրաքանչյուր խաղացողի մասին ամենամոտ խաղացողներ և արկեր - կարիք չկա տեղեկատվություն փոխանցել խաղացողից հեռու գտնվող խաղային օբյեկտների մասին:

3. Խաղի օբյեկտները սերվերի վրա

Մեր խաղում արկերը և խաղացողները իրականում շատ նման են. դրանք վերացական կլոր շարժվող խաղային առարկաներ են: Խաղացողների և արկերի միջև այս նմանությունից օգտվելու համար եկեք սկսենք իրականացնել բազային դաս 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,
    };
  }
}

Այստեղ ոչ մի բարդ բան չկա: Այս դասը լավ մեկնարկային կետ կլինի ընդլայնման համար: Տեսնենք, թե ինչպես է դասը Bullet օգտագործում 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 շատ կարճ! Մենք ավելացրել ենք Object միայն հետևյալ ընդարձակումները.

  • Օգտագործելով փաթեթը կարճահասակ պատահական սերնդի համար id արկ.
  • Դաշտի ավելացում parentID, որպեսզի կարողանաք հետևել այս արկը ստեղծած խաղացողին:
  • Վերադարձի արժեքի ավելացում update(), որը հավասար է true, եթե արկը ասպարեզից դուրս է (հիշո՞ւմ եք, որ այս մասին խոսեցինք վերջին բաժնում):

Անցնենք 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,
    };
  }
}

Խաղացողներն ավելի բարդ են, քան արկերը, ուստի այս դասը պետք է պահպանի ևս մի քանի դաշտ: Նրա մեթոդը update() ավելի շատ աշխատանք է կատարում, մասնավորապես վերադարձնում է նորաստեղծ արկը, եթե այն չի մնացել fireCooldown (հիշո՞ւմ եք, որ մենք այս մասին խոսեցինք նախորդ բաժնում): Այն նաև ընդլայնում է մեթոդը serializeForUpdate(), քանի որ մենք պետք է լրացուցիչ դաշտեր ներառենք խաղացողի համար խաղի թարմացման մեջ։

Բազային դասի առկայություն Object - կոդի կրկնությունից խուսափելու կարևոր քայլ. Օրինակ՝ առանց դասի Object յուրաքանչյուր խաղի օբյեկտ պետք է ունենա նույն իրականացումը distanceTo(), և այս բոլոր իրականացումները մի քանի ֆայլերի մեջ պատճենելը մղձավանջ կլինի: Սա հատկապես կարևոր է դառնում խոշոր նախագծերի համար, երբ ընդլայնվողների թիվը Object դասերը մեծանում են.

4. Բախումների հայտնաբերում

Մեզ մնում է միայն հասկանալ, թե երբ են արկերը հարվածել խաղացողներին: Հիշեք այս կոդի հատվածը մեթոդից update() դասարանում 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),
    );

    // ...
  }
}

Պետք է մեթոդը կյանքի կոչենք applyCollisions(), որը վերադարձնում է խաղացողներին հարվածող բոլոր արկերը: Բարեբախտաբար, դա այնքան էլ դժվար չէ անել, քանի որ

  • Բոլոր բախվող առարկաները շրջանակներ են, և սա բախման հայտնաբերումն իրականացնելու ամենապարզ ձևն է:
  • Մենք արդեն ունենք մեթոդ distanceTo(), որը մենք իրականացրել ենք նախորդ բաժնի դասարանում Object.

Բախումների հայտնաբերման մեր իրականացումը այսպիսին է թվում.

բախումներ.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;
}

Բախման այս պարզ հայտնաբերումը հիմնված է այն փաստի վրա, որ երկու շրջաններ բախվում են, եթե նրանց կենտրոնների միջև հեռավորությունը փոքր է նրանց շառավիղների գումարից. Ահա մի դեպք, երբ երկու շրջանագծերի կենտրոնների միջև հեռավորությունը ճիշտ հավասար է նրանց շառավիղների գումարին.

Multiplayer վեբ խաղի ստեղծում .io ժանրում
Այստեղ դուք պետք է մեծ ուշադրություն դարձնեք ևս մի քանի ասպեկտների.

  • Արկը չպետք է հարվածի այն խաղացողին, ով ստեղծել է այն: Դրան կարելի է հասնել համեմատելով bullet.parentID с player.id.
  • Արկը պետք է միայն մեկ անգամ հարվածի ծայրահեղ դեպքում՝ միաժամանակ մի քանի խաղացողների հարվածելու: Մենք կլուծենք այս խնդիրը՝ օգտագործելով օպերատորը breakԵրբ հայտնաբերվում է արկի հետ բախվող խաղացողը, մենք դադարեցնում ենք որոնումը և անցնում հաջորդ արկին:

Վերջ

Այսքանը: Մենք լուսաբանել ենք այն ամենը, ինչ դուք պետք է իմանաք .io վեբ խաղ ստեղծելու համար: Ի՞նչ է հաջորդը: Կառուցեք ձեր սեփական .io խաղը:

Ամբողջ օրինակի ծածկագիրը բաց կոդով է և տեղադրված է Github.

Source: www.habr.com

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