.io жанрында көп ойыншы веб-ойын жасау

.io жанрында көп ойыншы веб-ойын жасау
2015 жылы шығарылды Agar.io жаңа жанрдың бастаушысы болды games.io, оның танымалдығы содан бері айтарлықтай өсті. Мен .io ойындарының танымалдылығының өсуін өз көзіммен көрдім: соңғы үш жылда мен осы жанрдың екі ойынын жасап, сатты..

Бұл ойындар туралы бұрын ешқашан естімеген болсаңыз, бұл ойнауға оңай (есептік жазба қажет емес) тегін көп ойыншыға арналған веб-ойындар. Олар әдетте бір аренада көптеген қарсылас ойыншылармен кездеседі. Басқа танымал .io ойындары: Slither.io и Diep.io.

Бұл мақалада біз мұны қалай жасау керектігін қарастырамыз нөлден бастап .io ойынын жасаңыз. Ол үшін тек Javascript-ті білу жеткілікті: синтаксис сияқты нәрселерді түсіну керек ES6, кілт сөз this и Уәделер. Javascript туралы біліміңіз мінсіз болмаса да, сіз әлі де жазбаның көп бөлігін түсіне аласыз.

.io ойынының мысалы

Жаттығу бойынша көмек алу үшін біз сілтеме жасаймыз .io ойынының мысалы. Ойнап көріңіз!

.io жанрында көп ойыншы веб-ойын жасау
Ойын өте қарапайым: сіз басқа ойыншылармен бірге аренадағы кемені басқарасыз. Сіздің кемеңіз снарядтарды автоматты түрде атқылайды және сіз олардың снарядтарынан аулақ бола отырып, басқа ойыншыларды соғуға тырысасыз.

1. Жобаның қысқаша шолуы / құрылымы

Мен ұсынамын бастапқы кодты жүктеп алыңыз мысалы ойын, сондықтан сіз мені қадағалай аласыз.

Мысал келесіні пайдаланады:

  • білдіру ойынның веб-серверін басқаратын Node.js үшін ең танымал веб-рамка болып табылады.
  • socket.io — браузер мен сервер арасында деректер алмасуға арналған websocket кітапханасы.
  • Веб-пакет - модуль менеджері. Неліктен 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/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 и NPM. Әрі қарай істеу керек

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

және сіз баруға дайынсыз! Әзірлеу серверін іске қосу үшін жай ғана іске қосыңыз

$ npm run develop

және веб-шолғышқа өтіңіз localhost: 3000. Әзірлеу сервері код өзгерген сайын JS және CSS бумаларын автоматты түрде қайта жасайды - барлық өзгерістерді көру үшін бетті жаңартыңыз!

3. Клиенттің кіру нүктелері

Ойын кодының өзіне тоқталайық. Алдымен бізге бет керек index.html, сайтқа кірген кезде браузер алдымен оны жүктейді. Біздің парақшамыз өте қарапайым болады:

index.html

Мысал .io ойыны  ОЙНАУ

Бұл код үлгісі түсінікті болу үшін аздап жеңілдетілген және мен басқа пост мысалдарының көпшілігімен де солай істеймін. Толық кодты әрқашан мына жерден көруге болады GitHub.

Бізде бар:

  • HTML5 кенеп элементі (<canvas>), оны біз ойынды көрсету үшін қолданамыз.
  • <link> CSS бумасын қосу үшін.
  • <script> Javascript бумасын қосу үшін.
  • Пайдаланушы аты бар негізгі мәзір <input> және ОЙНАТУ түймесі (<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-да кіріктірілген қолдау бар веб-розеткалар, олар екі жақты байланыс үшін өте қолайлы: біз серверге хабарламалар жібере аламыз и сервер бізге бір қосылымда хабарламалар жібере алады.

Бізде бір файл болады 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>). Біздің ойын өте қарапайым, сондықтан бізге мыналарды салу керек:

  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. Клиент күйі

Бұл бөлім посттың бірінші бөлігіндегі ең қиын. Алғаш оқығанда түсінбесеңіз, қапы қалмаңыз! Сіз тіпті оны өткізіп жіберіп, кейінірек қайта аласыз.

Клиент/сервер кодын толтыру үшін қажет басқатырғыштың соңғы бөлігі мемлекет. Клиентті көрсету бөліміндегі код үзіндісі есіңізде ме?

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 жетуге тырысады.

Белгілеу бағасы: Сервер клиенттерге ойын жаңартуларын жіберетін жиілік. Ол жиі кадр жиілігінен төмен. Біздің ойында сервер секундына 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-бөлім. Сервер сервері

Бұл бөлімде біз басқаратын 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-орта бағдарламалық құрал әзірлеу пакеттерімізді автоматты түрде қайта құру үшін немесе
  • қалтаны статикалық тасымалдау 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 socket.io (егер сіз шатассаңыз, қайта оралыңыз server.js). Socket.io өзі әрбір розеткаға бірегей тағайындайды 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.

Ақпарат көзі: www.habr.com

пікір қалдыру