Nggawe Game Web .io Multiplayer

Nggawe Game Web .io Multiplayer
Dirilis ing 2015 Agar.io dadi progenitor saka genre anyar game .iokang wis thukul ing popularitas wiwit iku. Aku pribadi wis ngalami munggah ing popularitas saka .io game: liwat telung taun kepungkur, aku wis digawe lan didol loro game ing genre iki..

Yen sampeyan durung tau krungu babagan game kasebut, game web multiplier gratis lan gampang dimainake (ora ana akun sing dibutuhake). Padha biasane ngadhepi akeh pemain lawan ing arene padha. Game .io liyane sing misuwur: Slither.io ΠΈ Diep.io.

Ing kirim iki, kita bakal njelajah carane nggawe game .io saka ngeruk. Kanggo iki, mung kawruh babagan Javascript sing cukup: sampeyan kudu ngerti babagan sintaksis ES6, tembung kunci this ΠΈ Promises. Sanajan kawruh Javascript sampeyan ora sampurna, sampeyan isih bisa ngerti babagan kiriman kasebut.

conto game .io

Kanggo bantuan sinau, kita bakal ngrujuk .io game contone. Coba muter!

Nggawe Game Web .io Multiplayer
Game iki cukup prasaja: sampeyan ngontrol kapal ing arena sing ana pemain liyane. Kapal sampeyan kanthi otomatis murub projectiles lan sampeyan nyoba kanggo mencet pemain liyane nalika ngindari projectiles sing.

1. Ringkesan ringkes / struktur proyek

Aku nyaranake download kode sumber conto game supaya sampeyan bisa tindakake kula.

Conto nggunakake ing ngisor iki:

  • nyebut punika framework web Node.js paling populer sing ngatur server web game.
  • soket.io - perpustakaan websocket kanggo ijol-ijolan data antarane browser lan server.
  • Webpack - modul manager. Sampeyan bisa maca babagan kenapa nggunakake Webpack kene.

Mangkene struktur direktori proyek:

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

umum/

Kabeh ing folder public/ bakal dikirim kanthi statis dening server. ING public/assets/ ngemot gambar sing digunakake dening proyek kita.

src /

Kabeh kode sumber ana ing folder kasebut src/. Irah-irahan client/ ΠΈ server/ ngomong kanggo awake dhewe lan shared/ ngemot file konstanta sing diimpor dening klien lan server.

2. Paramèter majelis / proyek

Kaya sing kasebut ing ndhuwur, kita nggunakake manajer modul kanggo mbangun proyek kasebut. Webpack. Ayo ndeleng konfigurasi Webpack kita:

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

Garis sing paling penting ing kene yaiku:

  • src/client/index.js minangka titik entri saka klien Javascript (JS). Webpack bakal diwiwiti saka kene lan goleki file impor liyane kanthi rekursif.
  • Output JS saka Webpack build kita bakal ana ing direktori dist/. Aku bakal nelpon file iki kita Paket JS.
  • Kita nggunakake Babel, lan utamanΓ© konfigurasi @babel/preset-env kanggo transpiling kode JS kita kanggo browser lawas.
  • Kita nggunakake plugin kanggo ngekstrak kabeh CSS sing dirujuk dening file JS lan gabungke ing sak panggonan. Aku bakal nelpon wong kita paket css.

Sampeyan bisa uga wis ngeweruhi jeneng file paket aneh '[name].[contenthash].ext'. Padha ngemot substitusi jeneng berkas Paket Web: [name] bakal diganti karo jeneng titik input (ing kasus kita game), lan [contenthash] bakal diganti karo hash saka isi file. Kita nindakake kanggo ngoptimalake proyek kanggo hashing - sampeyan bisa ngandhani browser supaya cache paket JS kita tanpa wates, amarga yen paket diganti, jeneng file uga ganti (malih contenthash). Asil pungkasan bakal dadi jeneng file tampilan game.dbeee76e91a97d0c7207.js.

berkas webpack.common.js iku file konfigurasi dhasar sing diimpor menyang pembangunan lan konfigurasi project rampung. Mangkene conto konfigurasi pangembangan:

webpack.dev.js

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

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

Kanggo efisiensi, kita digunakake ing proses pangembangan webpack.dev.js, lan ngalih menyang webpack.prod.jskanggo ngoptimalake ukuran paket nalika nyebarake menyang produksi.

Setelan lokal

Aku nyaranake nginstal proyek ing mesin lokal supaya sampeyan bisa tindakake langkah sing kadhaptar ing kirim iki. Persiyapan iku prasaja: pisanan, sistem kudu diinstal Node ΠΈ NPM. Sabanjure sampeyan kudu nindakake

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

lan sampeyan siyap kanggo pindhah! Kanggo miwiti server pangembangan, mung mbukak

$ npm run develop

lan pindhah menyang browser web localhost: 3000. Server pangembangan bakal kanthi otomatis mbangun maneh paket JS lan CSS minangka owah-owahan kode - mung refresh kaca kanggo ndeleng kabeh owah-owahan!

3. Titik Entri Klien

Ayo dadi mudhun kanggo kode game dhewe. Pisanan kita butuh kaca index.html, nalika ngunjungi situs kasebut, browser bakal mbukak dhisik. Kaca kita bakal cukup prasaja:

index.html

Tuladha game .io  PLAY

Contone kode iki wis simplified rada kanggo gamblang, lan aku bakal nindakake padha karo akeh conto kirim liyane. Kode lengkap bisa tansah dideleng ing GitHub.

Kita duwe:

  • Elemen Kanvas HTML5 (<canvas>), sing bakal digunakake kanggo nggawe game.
  • <link> kanggo nambah paket CSS kita.
  • <script> kanggo nambah paket Javascript kita.
  • Menu utama karo jeneng panganggo <input> lan tombol PLAY (<button>).

Sawise mbukak kaca ngarep, browser bakal miwiti ngeksekusi kode Javascript, diwiwiti saka file JS titik entri: src/client/index.js.

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

Iki bisa uga katon rumit, nanging ora akeh sing kedadeyan ing kene:

  1. Ngimpor sawetara file JS liyane.
  2. Impor CSS (supaya Webpack ngerti yen kalebu ing paket CSS kita).
  3. Bukak connect() kanggo nggawe sambungan menyang server lan miwiti downloadAssets() kanggo download gambar needed kanggo nerjemahake game.
  4. Sawise ngrampungake tahap 3 menu utama ditampilake (playMenu).
  5. Nyetel pawang kanggo mencet tombol "PLAY". Nalika tombol dipencet, kode initializes game lan ngandhani server sing kita siyap kanggo muter.

"Daging" utama logika klien-server kita ana ing file sing diimpor dening file kasebut index.js. Saiki kita bakal nimbang kabeh supaya.

4. Ijol-ijolan data klien

Ing game iki, kita nggunakake perpustakaan kondhang kanggo komunikasi karo server soket.io. Socket.io duwe dhukungan asli WebSocket, sing cocok kanggo komunikasi rong arah: kita bisa ngirim pesen menyang server ΠΈ server bisa ngirim pesen kanggo kita liwat sambungan padha.

Kita bakal duwe siji file src/client/networking.jssing bakal ngurus kabeh wong komunikasi karo server:

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

Kode iki uga wis dicekak rada kanggo gamblang.

Ana telung tumindak utama ing file iki:

  • Kita nyoba nyambung menyang server. connectedPromise mung diijini nalika kita wis nggawe sambungan.
  • Yen sambungan kasil, kita ndhaftar fungsi callback (processGameUpdate() ΠΈ onGameOver()) kanggo pesen sing bisa ditampa saka server.
  • Kita ngekspor play() ΠΈ updateDirection()supaya file liyane bisa digunakake.

5. Rendering klien

Wektu kanggo nampilake gambar ing layar!

...nanging sadurunge bisa nindakake iki, kita kudu ngundhuh kabeh gambar (sumber daya) sing dibutuhake kanggo iki. Ayo nulis manajer sumber daya:

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

Manajemen sumber daya ora angel ditindakake! Titik utama yaiku kanggo nyimpen obyek assets, sing bakal ngiket kunci jeneng berkas menyang nilai obyek kasebut Image. Nalika sumber daya dimuat, kita nyimpen ing obyek assets kanggo akses cepet ing mangsa. Nalika saben sumber daya individu diijini ngundhuh (yaiku, kabeh sumber daya), kita ngidini downloadPromise.

Sawise ngundhuh sumber daya, sampeyan bisa miwiti rendering. Kaya sing wis dingerteni sadurunge, kanggo nggambar ing kaca web, kita nggunakake Kanvas HTML5 (<canvas>). Game kita cukup prasaja, mula kita mung kudu nggambar ing ngisor iki:

  1. Latar mburi
  2. Kapal pemain
  3. pemain liyane ing game
  4. Cangkang

Punika cuplikan penting src/client/render.js, sing nggawe persis papat item ing ndhuwur:

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

Kode iki uga dicekak kanggo kajelasan.

render() minangka fungsi utama file iki. startRendering() ΠΈ stopRendering() ngontrol aktivasi siklus rendering ing 60 FPS.

Implementasi spesifik fungsi helper rendering individu (contone renderBullet()) ora pati penting, nanging ing ngisor iki minangka conto prasaja:

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

Elinga yen kita nggunakake cara getAsset(), sing sadurunge katon ing asset.js!

Yen sampeyan kasengsem ing njelajah fungsi helper rendering liyane, banjur maca liyane saka src/client/render.js.

6. Input klien

Iku wektu kanggo nggawe game bisa diputer! Skema kontrol bakal gampang banget: kanggo ngganti arah gerakan, sampeyan bisa nggunakake mouse (ing komputer) utawa ndemek layar (ing piranti seluler). Kanggo ngleksanakake iki, kita bakal ndhaftar Pamireng Acara kanggo acara Mouse lan Tutul.
Bakal ngurus kabeh iki 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() iku Event Listeners sing nelpon updateDirection() (saka networking.js) nalika ana acara input (contone, nalika mouse dipindhah). updateDirection() tawaran karo ijol-ijolan pesen karo server, kang proses acara input lan nganyari negara game patut.

7. Status klien

Bagean iki paling angel ing bagean pisanan kiriman. Aja kesusu yen sampeyan ora ngerti nalika pisanan maca! Sampeyan malah bisa nglewati lan bali menyang mengko.

Piece pungkasan teka-teki sing dibutuhake kanggo ngrampungake kode klien / server yaiku negara. Elinga potongan kode saka bagean Rendering Klien?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() kudu bisa menehi kita negara saiki game ing klien ing sembarang titik ing wektu adhedhasar nganyari sing ditampa saka server. Iki minangka conto nganyari game sing bisa dikirim server:

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

Saben nganyari game ngemot limang kolom sing padha:

  • t: Stempel wektu server sing nuduhake nalika nganyari iki digawe.
  • me: Informasi bab pamuter nampa nganyari iki.
  • wong: Uploaded informasi bab pemain liyane sing melu ing game padha.
  • peluru: Uploaded informasi bab projectiles ing game.
  • leaderboard: Data leaderboard saiki. Ing kirim iki, kita ora bakal nimbang.

7.1 Negara klien naif

Implementasine naif getCurrentState() mung bisa langsung bali data saka nganyari game paling mentas ditampa.

naif-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Cantik lan cetha! Nanging yen mung sing prasaja. Salah sawijining sebab implementasine masalah iki: iku matesi tingkat pigura Rendering kanggo tingkat jam server.

Frame Rate: jumlah pigura (yaiku telpon render()) per detik, utawa FPS. Game biasane ngupayakake paling ora 60 FPS.

Tick ​​Rate: Frekuensi ing server ngirim nganyari game kanggo klien. Asring luwih murah tinimbang tingkat pigura. Ing game kita, server mlaku kanthi frekuensi 30 siklus per detik.

Yen kita mung menehi update paling anyar saka game, banjur FPS ateges ora bakal ngluwihi 30, amarga kita tau nampa luwih saka 30 nganyari saben detik saka server. Malah yen kita nelpon render() 60 kaping per detik, banjur setengah saka telpon iki mung bakal redraw bab sing padha, ateges ora nindakake apa-apa. Masalah liyane karo implementasine naif iku rawan kanggo telat. Kanthi kacepetan Internet sing cocog, klien bakal nampa nganyari game persis saben 33ms (30 per detik):

Nggawe Game Web .io Multiplayer
Sayange, ora ana sing sampurna. Gambar sing luwih nyata yaiku:
Nggawe Game Web .io Multiplayer
Implementasine naif sacoro prakteke kasus paling awon nalika nerangake latensi. Yen nganyari game ditampa kanthi wektu tundha 50ms, banjur kios klien 50ms ekstra amarga isih Rendering negara game saka nganyari sadurungΓ©. Sampeyan bisa mbayangno carane ora nyaman iki kanggo pamuter: braking sembarang bakal nggawe game aran jerky lan ora stabil.

7.2 Apik negara klien

Kita bakal nggawe sawetara dandan kanggo implementasine naif. Pisanan, kita nggunakake wektu tundha rendering dening 100 ms. Iki tegese "saiki" negara klien bakal tansah 100ms konco negara game ing server. Contone, yen wektu server 150, banjur klien bakal nerjemahake negara ing ngendi server kasebut ing wektu kasebut 50:

Nggawe Game Web .io Multiplayer
Iki menehi buffer 100ms supaya bisa urip ing wektu nganyari game sing ora bisa ditebak:

Nggawe Game Web .io Multiplayer
Payoff kanggo iki bakal permanen input lag kanggo 100 ms. Iki minangka pengorbanan cilik kanggo gamelan sing lancar - umume pemain (utamane pemain kasual) ora bakal ngerti wektu tundha iki. Luwih gampang kanggo wong nyetel latensi 100ms sing tetep tinimbang muter karo latensi sing ora bisa ditebak.

Kita bisa nggunakake teknik liya sing diarani prediksi sisih klien, sing nindakake tugas sing apik kanggo nyuda latensi sing dirasakake, nanging ora bakal dibahas ing kiriman iki.

Dandan liyane sing digunakake yaiku interpolasi linear. Amarga lag Rendering, kita biasane paling siji nganyari ahead saka wektu saiki ing klien. Nalika diarani getCurrentState(), kita bisa eksekusi interpolasi linear antarane nganyari game sadurunge lan sawise wektu saiki ing klien:

Nggawe Game Web .io Multiplayer
Iki ngrampungake masalah tingkat pigura: saiki kita bisa nggawe pigura unik ing tingkat pigura sing dikarepake!

7.3 Ngleksanakake negara klien ditingkatake

Tuladha Implementasi ing src/client/state.js nggunakake render lag lan interpolasi linear, nanging ora suwe. Ayo dadi break kode dadi rong bagΓ©an. Iki sing pertama:

state.js part 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;
}

Wangsulan: Bab ingkang pisanan sing kudu dilakoni iku ngerti apa iku currentServerTime(). Kaya sing wis dingerteni sadurunge, saben nganyari game kalebu stempel wektu server. Kita pengin nggunakake latensi render kanggo nerjemahake gambar 100ms ing mburi server, nanging kita ora bakal ngerti wektu saiki ing server, amarga kita ora ngerti suwene wektu kanggo nganyari apa wae. Internet ora bisa ditebak lan kacepetan bisa beda-beda!

Kanggo ngatasi masalah iki, kita bisa nggunakake perkiraan sing cukup: kita ndalang nganyari pisanan teka langsung. Yen iki bener, mula kita bakal ngerti wektu server ing wektu tartamtu iki! Kita nyimpen timestamp server ing firstServerTimestamp lan nyimpen kita lokal (klien) stempel wektu ing wektu sing padha gameStart.

Oh ngenteni. Apa ora kudu wektu server = wektu klien? Napa kita mbedakake antarane "cap wektu server" lan "cap wektu klien"? Iki pitakonan gedhe! Pranyata ora padha. Date.now() bakal bali timestamp beda ing klien lan server, lan gumantung ing faktor lokal kanggo mesin iki. Aja nganggep manawa cap wektu bakal padha ing kabeh mesin.

Saiki kita ngerti apa sing ditindakake currentServerTime(): mulih stempel wektu server wektu render saiki. Ing tembung liyane, iki wektu server saiki (firstServerTimestamp <+ (Date.now() - gameStart)) minus render delay (RENDER_DELAY).

Saiki ayo goleki carane nangani nganyari game. Nalika ditampa saka server nganyari, diarani processGameUpdate()lan kita nyimpen nganyari anyar menyang Uploaded gameUpdates. Banjur, kanggo mriksa panggunaan memori, kita mbusak kabeh nganyari lawas kanggo nganyari dhasaramarga kita ora butuh maneh.

Apa "nganyari inti"? Iki nganyari pisanan kita temokake kanthi mundur saka wektu server saiki. Elinga diagram iki?

Nggawe Game Web .io Multiplayer
Nganyari game langsung ing sisih kiwa "Wektu Render Klien" yaiku nganyari dhasar.

Apa nganyari dhasar digunakake kanggo? Napa kita bisa nyelehake nganyari menyang basis? Kanggo mangerteni iki, ayo pungkasane ayo dideleng ing implementasine getCurrentState():

state.js part 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),
    };
  }
}

Kita nangani telung kasus:

  1. base < 0 tegese ora ana nganyari nganti wektu render saiki (ndeleng implementasine ing ndhuwur getBaseUpdate()). Iki bisa kedadeyan ing wiwitan game amarga lag rendering. Ing kasus iki, kita nggunakake update paling anyar sing ditampa.
  2. base punika nganyari paling anyar kita duwe. Iki bisa kedadeyan amarga latensi jaringan utawa sambungan internet sing ora apik. Ing kasus iki, kita uga nggunakake update paling anyar sing kita duwe.
  3. Kita duwe nganyari sadurunge lan sawise wektu render saiki, supaya bisa interpolasi!

Kabeh sing isih ana state.js punika implementasine saka interpolasi linear sing prasaja (nanging mboseni) math. Yen sampeyan pengin njelajah dhewe, banjur mbukak state.js ing GitHub.

Part 2. Backend server

Ing bagean iki, kita bakal njupuk dipikir ing backend Node.js sing kontrol kita .io game contone.

1. Titik entri server

Kanggo ngatur server web, kita bakal nggunakake framework web populer kanggo Node.js disebut nyebut. Iku bakal diatur dening file titik entri server kita src/server/server.js:

server.js bagean 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}`);

Elinga yen ing bagean pisanan kita ngrembug Webpack? Iki ngendi kita bakal nggunakake konfigurasi Webpack kita. Kita bakal nggunakake rong cara:

  • Gunakake webpack-dev-middleware kanggo mbangun maneh paket pembangunan kanthi otomatis, utawa
  • folder transfer statis dist/, menyang Webpack bakal nulis file kita sawise mbangun produksi.

Tugas penting liyane server.js yaiku kanggo nyetel server soket.iosing mung nyambung menyang server Express:

server.js bagean 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);
});

Sawise sukses nggawe sambungan socket.io menyang server, kita nyiyapake panangan acara kanggo soket anyar. Penanganan acara nangani pesen sing ditampa saka klien kanthi delegasi menyang obyek tunggal game:

server.js bagean 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);
}

Kita nggawe game .io, dadi mung butuh siji salinan Game ("Game") - kabeh pemain main ing arene sing padha! Ing bagean sabanjure kita bakal weruh cara kerja kelas iki Game.

2. Server game

Класс Game ngandhut logika paling penting ing sisih server. Wis rong tugas utama: manajemen pamuter и simulasi game.

Ayo dadi miwiti karo tugas pisanan, Manajemen pamuter.

game.js part 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);
    }
  }

  // ...
}

Ing game iki, kita bakal ngenali pemain kanthi lapangan id socket socket.io (yen sampeyan bingung, banjur bali menyang server.js). Socket.io dhewe menehi saben soket sing unik iddadi ora perlu kuwatir bab kuwi. Aku bakal nelpon dheweke ID pamuter.

Kanthi atine, ayo njelajah variabel conto ing kelas Game:

  • sockets iku obyek sing njiret ID pamuter kanggo soket sing digandhengake karo pamuter. Iki ngidini kita ngakses soket kanthi ID pamuter sajrone wektu.
  • players iku obyek sing njiret ID pamuter kanggo kode> obyek Player

bullets yaiku susunan obyek Bullet, sing ora duwe urutan tartamtu.
lastUpdateTime iku timestamp saka wektu pungkasan game dianyari. Kita bakal weruh carane iku digunakake sakcepete.
shouldSendUpdate minangka variabel bantu. Kita uga bakal weruh panggunaane rauh.
Tata cara addPlayer(), removePlayer() ΠΈ handleInput() ora perlu kanggo nerangake, padha digunakake ing server.js. Yen sampeyan perlu kanggo refresh memori, bali sethitik luwih dhuwur.

baris pungkasan constructor() diwiwiti munggah nganyari siklus game (kanthi frekuensi 60 nganyari / s):

game.js part 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;
    }
  }

  // ...
}

Cara update() ngandhut mbokmenawa bagΓ©an paling penting saka logika server-sisih. Ayo dhaptar kabeh sing ditindakake kanthi urutan:

  1. Ngetung suwene dt liwati wiwit pungkasan update().
  2. Nyegerake saben projectile lan ngrusak yen perlu. Kita bakal weruh implementasine fungsi iki mengko. Kanggo saiki, cukup kanggo kita ngerti bullet.update() bali true, yen projectile kudu numpes (dheweke metu saka arena).
  3. Nganyari saben pemain lan ngasilake projectile yen perlu. Kita uga bakal weruh implementasine mengko - player.update() bisa bali obyek Bullet.
  4. Priksa tabrakan antarane projectiles lan pemain nggunakake applyCollisions(), sing ngasilake macem-macem proyektil sing nyerang pemain. Kanggo saben projectile bali, kita nambah TCTerms pemain sing murub iku (nggunakake player.onDealtDamage()) banjur copot projectile saka array bullets.
  5. Ngabari lan ngrusak kabeh pemain sing mateni.
  6. Ngirim nganyari game kanggo kabeh pemain saben detik kaping nalika disebut update(). Iki mbantu kita nglacak variabel tambahan sing kasebut ing ndhuwur. shouldSendUpdate. Minangka update() disebut 60 kaping / s, kita ngirim nganyari game 30 kaping / s. Mangkono, frekuensi jam jam server punika 30 jam / s (kita ngedika bab tarif jam ing bagean pisanan).

Apa ngirim nganyari game mung liwat wektu ? Kanggo nyimpen saluran. 30 nganyari game per detik iku akeh!

Apa ora mung nelpon banjur? update() 30 kaping per detik? Kanggo nambah simulasi game. Sing luwih kerep diarani update(), sing luwih akurat simulasi game bakal. Nanging aja lali karo akeh tantangan. update(), amarga iki tugas komputasi larang - 60 per detik cukup.

Liyane saka kelas Game kasusun saka cara helper digunakake ing update():

game.js part 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() Iku cukup prasaja - ngurutake pemain miturut skor, njupuk limang ndhuwur, lan ngasilake jeneng pangguna lan skor kanggo saben.

createUpdate() digunakake ing update() kanggo nggawe nganyari game sing mbagekke kanggo pemain. Tugas utamane yaiku nelpon metode serializeForUpdate()dileksanakake kanggo kelas Player ΠΈ Bullet. Elinga yen mung nransfer data kanggo saben pamuter babagan paling cedhak pemain lan projectiles - ana ora perlu kanggo ngirim informasi bab obyek game sing adoh saka pamuter!

3. obyek Game ing server

Ing game kita, projectiles lan pemain bener banget padha: padha abstrak, bunder, obyek game movable. Kanggo njupuk kauntungan saka podho iki antarane pemain lan projectiles, ayo miwiti kanthi ngleksanakake kelas dhasar Object:

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

Ora ana sing rumit ing kene. Kelas iki bakal dadi titik anchor apik kanggo extension. Ayo ndeleng carane kelas Bullet nggunakake Object:

peluru.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 cendhak banget! Kita wis ditambahake menyang Object mung ekstensi ing ngisor iki:

  • Nggunakake paket cendhak kanggo generasi acak id proyektil.
  • Nambah lapangan parentIDsupaya sampeyan bisa nglacak pamuter sing nggawe projectile iki.
  • Nambahake nilai bali menyang update(), kang padha karo trueyen projectile njaba arene (elinga kita ngedika bab iki ing bagean pungkasan?).

Ayo pindhah menyang Player:

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

Pemain luwih rumit tinimbang proyektil, mula kelas iki kudu nyimpen sawetara lapangan liyane. Caranipun update() nindakake akeh karya, utamane, ngasilake proyektil sing mentas digawe yen ora ana sing isih ana fireCooldown (elinga kita ngomong babagan iki ing bagean sadurunge?). Iku uga ngluwihi cara serializeForUpdate(), amarga kita kudu kalebu lapangan tambahan kanggo pamuter ing nganyari game.

Duwe kelas dhasar Object - langkah penting supaya kode mbaleni. Contone, tanpa kelas Object saben obyek game kudu implementasine padha distanceTo(), lan nyalin-paste kabeh implementasine ing pirang-pirang file bakal dadi ngipi elek. Iki dadi penting banget kanggo proyek gedhe., nalika nomer ngembangaken Object kelas akeh.

4. Deteksi tabrakan

Siji-sijine perkara sing isih ana kanggo kita yaiku ngerteni nalika proyektil nyerang para pemain! Elinga potongan kode iki saka metode kasebut update() ing kelas 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),
    );

    // ...
  }
}

Kita kudu ngetrapake metode kasebut applyCollisions(), kang ngasilake kabeh projectiles sing kenek pemain. Untunge, ora angel ditindakake amarga

  • Kabeh obyek tabrakan bunder, lan iki wangun paling gampang kanggo ngleksanakake deteksi tabrakan.
  • Kita wis duwe metode distanceTo(), sing ditindakake ing kelas ing bagean sadurunge Object.

Iki minangka implementasine deteksi tabrakan:

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

Deteksi tabrakan prasaja iki adhedhasar kasunyatan sing loro bunderan tabrakan yen jarak antarane pusate kurang saka jumlah radii sing. Punika kasus ing ngendi jarak antarane pusat rong bunderan persis padha karo jumlah radius:

Nggawe Game Web .io Multiplayer
Ana sawetara aspek liyane sing kudu ditimbang ing kene:

  • Proyektil kudu ora kena pemain sing nggawe. Iki bisa digayuh kanthi mbandhingake bullet.parentID с player.id.
  • Projectile mung kudu mencet sapisan ing kasus watesan sawetara pemain tabrakan ing wektu sing padha. Kita bakal ngatasi masalah iki nggunakake operator break: sanalika pemain tabrakan karo projectile ketemu, kita mungkasi panelusuran lan pindhah menyang projectile sabanjurΓ©.

Wekasane

Iku kabeh! Kita wis nutupi kabeh sing sampeyan kudu ngerti kanggo nggawe game web .io. Apa sabanjure? Gawe game .io sampeyan dhewe!

Kabeh kode sampel mbukak sumber lan dikirim ing GitHub.

Source: www.habr.com

Add a comment