Көп оюнчу .io веб оюнун түзүү

Көп оюнчу .io веб оюнун түзүү
2015-жылы чыккан Agar.io жаны жанрдын башатчысы болуп калды оюндар .ioошондон бери популярдуулугу өстү. Мен жеке өзүм .io оюндарынын популярдуулугунун жогорулашына күбө болдум: акыркы үч жылдын ичинде менде ушул жанрдагы эки оюнду жараткан жана саткан..

Эгер сиз бул оюндар жөнүндө эч качан укпасаңыз, булар ойноого оңой болгон акысыз көп оюнчу веб оюндары (эч кандай эсеп талап кылынбайт). Алар, адатта, бир эле аренада көптөгөн каршы оюнчулар менен беттешет. Башка белгилүү .io оюндары: Slither.io и Diep.io.

Бул макалада биз кантип изилдейбиз нөлдөн баштап .io оюнун түзүү. Бул үчүн Javascript боюнча билим гана жетиштүү болот: синтаксис сыяктуу нерселерди түшүнүү керек ES6, ачкыч сөз this и убадалар. Javascript боюнча билимиңиз кемчиликсиз болсо дагы, сиз посттун көбүн түшүнө аласыз.

.io оюнунун мисалы

Окуу боюнча жардам алуу үчүн биз кайрылабыз .io оюнунун мисалы. Аны ойноого аракет кыл!

Көп оюнчу .io веб оюнун түзүү
Оюн абдан жөнөкөй: сиз башка оюнчулар бар аренада кемени башкарасыз. Сиздин кемеңиз автоматтык түрдө снаряддарды аткылайт жана сиз башка оюнчулардын снаряддарынан качканга аракет кыласыз.

1. Долбоордун кыскача баяндамасы/структурасы

сунуш кылуу булак кодун жүктөп алуу мисалы оюн, ошондуктан сиз мени ээрчий аласыз.

Мисал төмөнкүнү колдонот:

  • билдирүү оюндун веб серверин башкарган эң популярдуу Node.js веб алкагы.
  • socket.io - браузер менен сервердин ортосунда маалымат алмашуу үчүн вебсокет китепканасы.
  • webpack - модулдун менеджери. Эмне үчүн Webpack колдонуу керектиги жөнүндө окуй аласыз. бул жерде.

Долбоордун каталогунун түзүлүшү бул жерде:

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

коомдук/

Баары папкада public/ сервер тарабынан статикалык түрдө тапшырылат. IN public/assets/ биздин долбоор тарабынан колдонулган сүрөттөрдү камтыйт.

СИБ /

Бардык булак коду папкада src/. Титулдар client/ и server/ өздөрү үчүн сүйлө жана shared/ кардар жана сервер тарабынан импорттолгон туруктуу файлды камтыйт.

2. Ассамблеялар/долбоор орнотуулары

Жогоруда айтылгандай, долбоорду куруу үчүн модулдун менеджерин колдонобуз. webpack. Келгиле, биздин 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 кодду көчүрүү үчүн.
  • JS файлдары тарабынан шилтемеленген бардык CSS файлдарын чыгарып, аларды бир жерге бириктирүү үчүн плагинди колдонуп жатабыз. Мен аны биздики деп коём 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өндүрүшкө жайылтууда пакеттин өлчөмдөрүн оптималдаштыруу.

Жергиликтүү жөндөө

Бул постто көрсөтүлгөн кадамдарды аткаруу үчүн мен долбоорду жергиликтүү машинаңызга орнотууну сунуштайм. Орнотуу жөнөкөй: биринчиден, система болушу керек Node и КЭУБ. Кийинки сиз кылышыңыз керек

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

жана сиз кетүүгө даярсыз! Иштеп чыгуу серверин баштоо үчүн жөн гана иштетиңиз

$ npm run develop

жана веб браузерге өтүңүз көрүү .xrf: 3000. Өнүктүрүү сервери JS жана CSS пакеттерин коддун өзгөрүшүнө жараша автоматтык түрдө калыбына келтирет - бардык өзгөрүүлөрдү көрүү үчүн жөн гана баракты жаңыртыңыз!

3. Кардардын кирүү пункттары

Келгиле, оюн кодунун өзүнө келели. Биринчиден, бизге баракча керек index.html, сайтка киргенде, браузер аны биринчи жүктөйт. Биздин баракча абдан жөнөкөй болот:

index.html

Мисал .io оюну  ОЙНОО

Бул код үлгүсү түшүнүктүү болуу үчүн бир аз жөнөкөйлөштүрүлгөн жана мен башка көптөгөн пост мисалдары менен да ушундай кылам. Толук кодду ар дайым бул жерден көрүүгө болот Github.

Бизде бар:

  • HTML5 кенеп элементи (<canvas>) аны биз оюнду көрсөтүү үчүн колдонобуз.
  • <link> биздин CSS топтомун кошуу үчүн.
  • <script> Javascript пакетибизди кошуу үчүн.
  • Колдонуучунун аты менен негизги меню <input> жана PLAY баскычы (<button>).

Башкы бетти жүктөгөндөн кийин, браузер JS файлынын кирүү чекитинен баштап Javascript кодун аткара баштайт: 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. Кардарларды көрсөтүү

Сүрөттү экранга чыгарууга убакыт келди!

...бирок муну жасоодон мурун, бул үчүн зарыл болгон бардык сүрөттөрдү (ресурстарды) жүктөп алышыбыз керек. Келгиле, ресурс менеджерин жазалы:

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

Ресурстарды башкарууну ишке ашыруу анчалык деле кыйын эмес! Негизги нерсе - объектти сактоо assets, бул файлдын аталышынын ачкычын объекттин маанисине байлайт Image. Ресурс жүктөлгөндө, биз аны объектте сактайбыз assets келечекте тез жетүү үчүн. Качан ар бир жеке ресурсту жүктөп алууга уруксат берилет (б.а., бардык ресурстар), биз уруксат беребиз downloadPromise.

Ресурстарды жүктөп алгандан кийин, сиз көрсөтүүнү баштасаңыз болот. Жогоруда айтылгандай, веб-баракчага тартуу үчүн биз колдонобуз HTML5 Canvas (<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. Кардардын киргизүүсү

Оюн жасоого убакыт келди ойноого болот! Башкаруу схемасы абдан жөнөкөй болот: кыймылдын багытын өзгөртүү үчүн, чычканды (компьютерде) колдонсоңуз болот же экранга тийип (мобилдик түзүлүштө) болот. Муну ишке ашыруу үчүн биз каттайбыз Иш-чараны угуучулар Чычкан жана тийүү окуялары үчүн.
Мунун баарын колго алат 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() акыркы алынган оюн жаңыртуу маалыматтарын түз гана кайтара алат.

naive-state.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 жетүү үчүн аракет кылышат.

Tick ​​Rate: Сервер кардарларга оюн жаңыртууларын жөнөткөн жыштыгы. Ал көбүнчө кадр ылдамдыгынан төмөн. Биздин оюнда сервер секундасына 30 цикл жыштыгы менен иштейт.

Эгерде биз жөн гана оюндун акыркы жаңыртылышын көрсөтсө, анда FPS эч качан 30дан ашпайт, анткени биз эч качан серверден секундасына 30дан ашык жаңыртууну албайбыз. Чакырсак да render() Секундасына 60 жолу, андан кийин бул чалуулардын жарымы бир эле нерсени кайра тартат, негизинен эч нерсе кылбайт. Наив ишке ашыруунун дагы бир көйгөйү - бул кечиктирүүгө жакын. Идеалдуу интернет ылдамдыгы менен кардар ар бир 33 мс (секундасына 30) оюн жаңыртууларын алат:

Көп оюнчу .io веб оюнун түзүү
Тилекке каршы, эч нерсе идеалдуу эмес. Бир кыйла реалдуу сүрөт болот:
Көп оюнчу .io веб оюнун түзүү
Наив ишке ашыруу кечиктирүүгө келгенде иш жүзүндө эң начар учур. Эгер оюн жаңыртуу 50 мс кечигүү менен кабыл алынса, анда кардар жайлады кошумча 50 мс, анткени ал дагы эле мурунку жаңыртуудагы оюндун абалын көрсөтүп жатат. Бул оюнчу үчүн канчалык ыңгайсыз экенин элестете аласыз: өзүм билемдик менен тормоздоо оюнду курч жана туруксуз кылат.

7.2 Кардардын абалы жакшырды

Биз жөнөкөй ишке ашыруу үчүн кээ бир жакшыртууларды жасайбыз. Биринчиден, биз колдонобуз көрсөтүү кечигүү 100 мс үчүн. Бул кардардын "учурдагы" абалы сервердеги оюндун абалынан ар дайым 100 мс артта калат дегенди билдирет. Мисалы, сервердеги убакыт болсо 150, анда кардар ошол убакта сервер болгон абалды көрсөтөт 50:

Көп оюнчу .io веб оюнун түзүү
Бул бизге оюндун күтүлбөгөн жаңыртуу убактысын сактап калуу үчүн 100 мс буферди берет:

Көп оюнчу .io веб оюнун түзүү
Мунун баасы туруктуу болот киргизүү кечигүү 100 мс үчүн. Бул жылмакай ойноо үчүн бир аз курмандык - көпчүлүк оюнчулар (айрыкча, жөнөкөй оюнчулар) бул кечиктирүүнү байкашпайт. Адамдар үчүн күтүлбөгөн күтүү менен ойногонго караганда, туруктуу 100 мс күтүү убактысына көнүү бир топ жеңил.

деп аталган дагы бир ыкманы колдоно алабыз кардар тарабынан болжолдоо, бул кабыл алынган кечиктирүүнү азайтуу үчүн жакшы иштейт, бирок бул постто каралбайт.

Биз колдонуп жаткан дагы бир жакшыртуу болуп саналат сызыктуу интерполяция. Рендердик артта калгандыктан, биз адатта кардардагы учурдагы убакыттан кеминде бир жаңыртабыз. Чакырганда getCurrentState(), биз аткара алабыз сызыктуу интерполяция кардардагы учурдагы убакытка чейин жана андан кийинки оюн жаңыртууларынын ортосунда:

Көп оюнчу .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(). Мурда көргөнүбүздөй, ар бир оюн жаңыртылышы сервердин убакыт белгисин камтыйт. Биз сүрөттү серверден 100 мс артта көрсөтүү үчүн көрсөтүү күтүү убактысын колдонгубуз келет, бирок биз сервердеги учурдагы убакытты эч качан биле албайбыз, анткени биз жаңыртуулардын бирине канча убакыт өткөнүн биле албайбыз. Интернет күтүүсүз жана анын ылдамдыгы абдан өзгөрүшү мүмкүн!

Бул көйгөйдү айланып өтүү үчүн, биз акылга сыярлык жакындоону колдоно алабыз: биз биринчи жаңыртуу заматта келди деп коёлу. Эгер бул чын болсо, анда сервердин убактысын ушул учурда билмекпиз! Биз сервердин убакыт белгисин сактайбыз firstServerTimestamp жана биздин жергиликтүү (кардар) ошол эле учурда убакыт белгиси gameStart.

Ой күт. Бул сервер убактысы = кардар убактысы болушу керек эмеспи? Эмне үчүн биз "сервердин убакыт белгисин" жана "кардардын убакыт белгисин" айырмалайбыз? Бул сонун суроо! Көрсө, алар бир эле нерсе эмес. Date.now() кардар менен серверде ар кандай убакыт белгилерин кайтарып берет жана бул бул машиналар үчүн жергиликтүү факторлорго көз каранды. Эч качан убакыт белгилери бардык машиналарда бирдей болот деп ойлобоңуз.

Эми биз эмне экенин түшүнөбүз currentServerTime(): кайтып келет учурдагы көрсөтүү убакытынын сервердик убакыт белгиси. Башкача айтканда, бул сервердин учурдагы убактысы (firstServerTimestamp <+ (Date.now() - gameStart)) минус көрсөтүү кечигүү (RENDER_DELAY).

Эми биз оюн жаңыртууларын кантип иштетээрибизди карап көрөлү. Жаңыртуу серверинен алынганда, ал деп аталат processGameUpdate()жана жаңы жаңыртууну массивге сактайбыз gameUpdates. Андан кийин, эстутумдун колдонулушун текшерүү үчүн, биз мурунку бардык эски жаңыртууларды алып салабыз базалык жаңыртууанткени алар бизге мындан ары керек эмес.

"Негизги жаңыртуу" деген эмне? Бул биринчи жаңыртууну биз сервердин учурдагы убактысынан артка жылдыруу аркылуу табабыз. Бул диаграмма эсиңиздеби?

Көп оюнчу .io веб оюнун түзүү
Түздөн-түз "Кардар көрсөтүү убактысынын" сол жагындагы оюн жаңыртуу негизги жаңыртуу болуп саналат.

Негизги жаңыртуу эмне үчүн колдонулат? Эмне үчүн жаңыртууларды баштапкы абалга калтыра алабыз? Муну түшүнүү үчүн, келгиле акыры ишке ашырылышын карап чыгуу 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. Бизде учурдагы көрсөтүү убактысына чейин да, андан кийин да жаңыртуу бар интерполяциялоо!

Мунун баары калды state.js жөнөкөй (бирок кызыксыз) математика болгон сызыктуу интерполяцияны ишке ашыруу. Эгер сиз аны өзүңүз изилдегиңиз келсе, анда ачыңыз state.js боюнча Github.

2-бөлүк. Backend Server

Бул бөлүктө биздин башкарган Node.js серверин карап чыгабыз .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Бул жөн гана Экспресс серверге туташат:

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 оюнчу идентификаторун оюнчу менен байланышкан розеткага байлаган объект. Бул бизге туруктуу убакытта алардын оюнчу идентификаторлору боюнча розеткаларга кирүү мүмкүнчүлүгүн берет.
  • players оюнчу идентификаторун код>Оюнчу объектисине байлаган объект

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:

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.

Бул жерде биздин кагылышууларды аныктоону ишке ашыруу кандай болот:

collisions.js

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

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Бул жөнөкөй кагылышууну аныктоо фактысына негизделген эки айлана кагылышып, эгерде алардын борборлорунун ортосундагы аралык алардын радиустарынын суммасынан аз болсо. Бул жерде эки чөйрөнүн борборлорунун ортосундагы аралык алардын радиустарынын суммасына так барабар болгон жагдай:

Көп оюнчу .io веб оюнун түзүү
Бул жерде дагы бир нече аспектилерди карап чыгуу керек:

  • Снаряд аны жараткан оюнчуга тийбеши керек. Бул салыштыруу аркылуу жетишүүгө болот bullet.parentID с player.id.
  • Снаряд бир эле учурда бир нече оюнчулар кагылышып калганда бир гана жолу тийиши керек. Биз бул маселени оператор аркылуу чечебиз break: снаряд менен кагылышкан оюнчу табылар замат издөөнү токтотуп, кийинки снарядга өтөбүз.

Бир мезгилдин акырына карата

Баары болду! Биз .io желе оюнун түзүү үчүн билишиңиз керек болгон нерселердин баарын камттык. Кийинкиси эмне? Өзүңүздүн .io оюнуңузду түзүңүз!

Бардык үлгү коду ачык булак жана жайгаштырылган Github.

Source: www.habr.com

Комментарий кошуу