Nyieun Multiplayer .io Web Game

Nyieun Multiplayer .io Web Game
Dirilis dina 2015 Agar.io janten progenitor tina genre anyar kaulinan.ionu geus tumuwuh di popularitas saprak lajeng. Kuring geus pribadi ngalaman naékna popularitas .io kaulinan: leuwih tilu taun kaliwat, abdi gaduh dijieun jeung dijual dua kaulinan tina genre ieu..

Upami anjeun teu acan kantos nguping kaulinan ieu sateuacanna, ieu mangrupikeun kaulinan wéb multiplier gratis anu gampang dicoo (henteu peryogi akun). Aranjeunna biasana nyanghareupan loba pamaén lawan dina arene sarua. Kaulinan .io kasohor lianna: Slither.io и Diep.io.

Dina tulisan ieu, urang bakal ngajalajah kumaha nyieun kaulinan .io ti scratch. Pikeun ieu, ngan ukur pangaweruh ngeunaan Javascript anu cekap: anjeun kedah ngartos hal-hal sapertos sintaksis ES6, kecap konci this и Jangji. Sanaos pangaweruh anjeun ngeunaan Javascript henteu sampurna, anjeun masih tiasa ngartos seueur tulisan.

.io kaulinan conto

Pikeun pitulung diajar, urang bakal ngarujuk kana .io kaulinan conto. Coba maén!

Nyieun Multiplayer .io Web Game
Kaulinan anu cukup basajan: Anjeun ngadalikeun kapal dina arene dimana aya pamaén séjén. Kapal anjeun otomatis nembak projectiles jeung anjeun nyoba pencét pamaén séjén bari Ngahindarkeun projectiles maranéhanana.

1. Tinjauan ringkes / struktur proyék

nyarankeun ngundeur kode sumber conto kaulinan sangkan anjeun bisa nuturkeun kuring.

Conto ngagunakeun ieu di handap:

  • ningalikeun mangrupikeun kerangka wéb Node.js anu pang populerna anu ngatur pangladén wéb kaulinan.
  • stop kontak.io - perpustakaan websocket pikeun tukeur data antara browser jeung server.
  • Webpack - manajer modul. Anjeun tiasa maca ngeunaan naha ngagunakeun Webpack. di dieu.

Ieu kumaha struktur diréktori proyék sapertos kieu:

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

umum/

Sagalana dina polder public/ bakal statis dikintunkeun ku server. DI public/assets/ ngandung gambar anu dianggo ku proyék kami.

src /

Sadaya kode sumber aya dina polder src/. Judul-judul client/ и server/ nyarita keur diri jeung shared/ ngandung file konstanta anu diimpor ku klien sareng server.

2. Majelis / parameter proyék

Sakumaha didadarkeun di luhur, kami nganggo manajer modul pikeun ngawangun proyék. Webpack. Hayu urang tingali konfigurasi Webpack kami:

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 anu paling penting di dieu nyaéta:

  • src/client/index.js mangrupa titik éntri tina Javascript (JS) klien. Webpack bakal ngamimitian ti dieu sareng milarian sacara rekursif pikeun file impor anu sanés.
  • Output JS tina ngawangun Webpack kami bakal aya dina diréktori dist/. Kuring bakal nelepon file ieu urang pakét js.
  • Kami nganggo Babel, sarta hususna konfigurasi @babel/preset-env pikeun transpiling kode JS kami pikeun browser heubeul.
  • Kami nganggo plugin pikeun nimba sadaya CSS anu dirujuk ku file JS sareng ngagabungkeun aranjeunna dina hiji tempat. Kuring bakal nelepon anjeunna urang pakét css.

Anjeun meureun geus noticed ngaran file pakét aneh '[name].[contenthash].ext'. Aranjeunna ngandung substitusi ngaran koropak Paket wéb: [name] bakal diganti ku nami titik input (dina hal urang éta game), Jeung [contenthash] bakal diganti ku hash tina eusi file. Urang ngalakukeun eta pikeun ngaoptimalkeun proyék pikeun hashing - urang tiasa ngabejaan panyungsi ka cache bungkusan JS kami salamina sabab upami pakét robih, teras nami filena ogé robih (parobahan contenthash). Hasil ahir bakal nami file view game.dbeee76e91a97d0c7207.js.

file webpack.common.js nyaéta file konfigurasi dasar anu urang impor kana pangwangunan sareng konfigurasi proyék réngsé. Ieu conto konfigurasi pangwangunan:

webpack.dev.js

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

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

Pikeun efisiensi, kami nganggo dina prosés pangwangunan webpack.dev.js, sarta pindah ka webpack.prod.jspikeun ngaoptimalkeun ukuran pakét nalika nyebarkeun ka produksi.

Setélan lokal

Abdi nyarankeun masang proyék dina mesin lokal supados anjeun tiasa nuturkeun léngkah anu didaptarkeun dina tulisan ieu. Setélanna saderhana: kahiji, sistemna kedah dipasang titik и NPM. Satuluyna anjeun perlu ngalakukeun

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

tur anjeun siap balik! Pikeun ngamimitian server ngembangkeun, ngan ngajalankeun

$ npm run develop

tur buka web browser localhost: 3000. Pangladén pamekaran bakal otomatis ngawangun deui bungkusan JS sareng CSS nalika kodeu parobihan - ngan refresh halaman pikeun ningali sadaya parobihan!

3. titik Éntri klien

Hayu urang turun ka kode kaulinan sorangan. Mimiti urang peryogi halaman index.html, nalika anjeun nganjang ka situs, browser bakal ngamuat heula. Halaman kami bakal saderhana:

index.html

Hiji conto kaulinan .io  ULIN

conto kode ieu geus disederhanakeun rada pikeun kajelasan, sarta kuring bakal lakonan hal nu sarua jeung loba conto pos séjén. Kode pinuh salawasna bisa ditempo dina Github.

Kami gaduh:

  • Unsur Kanvas HTML5 (<canvas>) anu bakal kami pake pikeun ngajantenkeun kaulinan.
  • <link> pikeun nambahkeun pakét CSS kami.
  • <script> pikeun nambahkeun pakét Javascript urang.
  • ménu utama kalawan ngaran pamaké <input> jeung tombol PLAY (<button>).

Saatos muka halaman bumi, browser bakal ngamimitian ngajalankeun kode Javascript, mimitian ti file JS titik éntri: src/client/index.js.

indéks.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);
  };
});

Ieu sigana pajeulit, tapi henteu seueur anu lumangsung di dieu:

  1. Ngimpor sababaraha file JS anu sanés.
  2. Impor CSS (jadi Webpack terang ngalebetkeun aranjeunna dina pakét CSS kami).
  3. Ngajalankeun connect() pikeun nyieun sambungan kalawan server tur ngajalankeun downloadAssets() pikeun ngundeur gambar diperlukeun pikeun ngajadikeun kaulinan.
  4. Sanggeus réngsé tahap 3 menu utama dipintonkeun (playMenu).
  5. Nyetel panangan pikeun mencét tombol "MAIN". Nalika tombol dipencet, kode initializes kaulinan sarta ngabejaan server nu kami siap maén.

"Daging" utama logika klien-server kami aya dina file anu diimpor ku file index.js. Ayeuna urang bakal kasampak di aranjeunna sadayana dina urutan.

4. Bursa data nasabah

Dina kaulinan ieu, urang ngagunakeun perpustakaan well-dipikawanoh pikeun komunikasi sareng server stop kontak.io. Socket.io ngagaduhan dukungan asli sockets wéb, anu cocog pikeun komunikasi dua arah: urang tiasa ngirim pesen ka server и server bisa ngirim pesen ka kami dina sambungan anu sarua.

Urang bakal boga hiji file src/client/networking.jsanu bakal ngurus dulur komunikasi sareng 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 ieu ogé geus rada disingget pikeun kajelasan.

Aya tilu tindakan utama dina file ieu:

  • Urang nyobian nyambung ka server. connectedPromise ngan diwenangkeun lamun urang geus ngadegkeun sambungan.
  • Upami sambunganna suksés, urang ngadaptarkeun pungsi callback (processGameUpdate() и onGameOver()) pikeun pesen urang tiasa nampi ti server.
  • Urang ékspor play() и updateDirection()supados file anu sanés tiasa dianggo.

5. Rendering klien

Geus waktuna pikeun mintonkeun gambar dina layar!

…tapi samemeh urang bisa ngalakukeun eta, urang kudu ngundeur sakabeh gambar (sumberdaya) nu diperlukeun pikeun ieu. Hayu urang nulis manajer sumberdaya:

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

Manajemén sumberdaya henteu sesah dilaksanakeun! Gagasan utama nyaéta pikeun nyimpen hiji obyék assets, anu bakal ngabeungkeut konci nami file kana nilai obyék Image. Nalika sumberdaya dimuat, urang nyimpen eta dina hiji obyék assets pikeun aksés gancang dina mangsa nu bakal datang. Iraha unggal sumber daya bakal diidinan diunduh (nyaéta, sadaya sumberdaya), kami ngidinan downloadPromise.

Saatos ngundeur sumberdaya, Anjeun bisa ngamimitian rendering. Salaku disebutkeun tadi, ngagambar dina kaca web, urang ngagunakeun HTML5 Kanvas (<canvas>). Kaulinan urang cukup saderhana, janten urang ngan ukur kedah ngagambar ieu:

  1. Latar latar
  2. kapal pamaén
  3. pamaén séjén dina kaulinan
  4. cangkang

Ieu snippét penting src/client/render.js, nu ngajadikeun persis opat item nu didaptarkeun di luhur:

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 ieu ogé disingget pikeun kajelasan.

render() mangrupa pungsi utama file ieu. startRendering() и stopRendering() ngadalikeun aktivasina tina render loop dina 60 FPS.

Palaksanaan khusus pikeun fungsi pembantu rendering individu (contona renderBullet()) henteu penting pisan, tapi ieu mangrupikeun conto saderhana:

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

Catet yén kami nganggo metodeu getAsset(), nu saméméhna katempo dina asset.js!

Upami anjeun resep ngajalajah fungsi pembantu rendering anu sanés, teras baca sesa src/client/render.js.

6. Input klien

Geus waktuna nyieun kaulinan bisa diputer! Skéma kontrol bakal saderhana pisan: pikeun ngarobih arah gerak, anjeun tiasa nganggo beurit (dina komputer) atanapi toél layar (dina alat sélulér). Pikeun ngalaksanakeun ieu, urang bakal ngadaptar Pamireng Acara pikeun acara Mouse jeung Toel.
Bakal ngurus sadayana ieu 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() nyaeta Event Listeners nu nelepon updateDirection() (tina networking.js) nalika kajadian input lumangsung (contona, nalika beurit digerakkeun). updateDirection() handles olahtalatah jeung server, nu handles acara input sarta ngamutahirkeun kaayaan kaulinan sasuai.

7. Status klien

Bagian ieu anu paling hese dina bagian mimiti tulisan. Tong pundung upami anjeun henteu ngartos nalika mimiti maca! Anjeun malah tiasa ngalangkunganana sareng uih deui engké.

Potongan terakhir tina teka-teki anu diperyogikeun pikeun ngalengkepan kode klien / server nyaéta kaayaan. Inget snippet kode tina bagian Rendering klien?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() kudu bisa méré urang kaayaan kiwari kaulinan di klien nu iraha wae dumasar kana apdet nampi ti server. Ieu conto apdet kaulinan anu tiasa dikirimkeun ku 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
    }
  ]
}

Unggal update kaulinan ngandung lima widang idéntik:

  • t: Server timestamp nunjukkeun iraha apdet ieu dijieun.
  • me: Émbaran ngeunaan pamuter nu narima update ieu.
  • batur: A Asép Sunandar Sunarya informasi ngeunaan pamaén séjén ilubiung dina kaulinan anu sarua.
  • pélor: A Asép Sunandar Sunarya informasi ngeunaan projectiles di buruan.
  • leaderboard: Data leaderboard ayeuna. Dina tulisan ieu, urang moal nganggap aranjeunna.

7.1 kaayaan klien naif

palaksanaan naif getCurrentState() ngan bisa langsung balik data tina update game panganyarna narima.

naif-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Geulis jeung jelas! Tapi lamun ngan éta basajan. Salah sahiji alesan palaksanaan ieu masalah: eta ngawatesan laju pigura Rendering ka speed jam server.

Laju pigura: jumlah pigura (misalna telepon render()) per detik, atawa FPS. Kaulinan biasana narékahan pikeun ngahontal sahenteuna 60 FPS.

Laju centang: Frékuénsi dimana server ngirimkeun apdet kaulinan pikeun klien. Éta sering langkung handap tina laju pigura. Dina kaulinan urang, server dijalankeun dina frékuénsi 30 siklus per detik.

Upami urang ngan ukur masihan pembaruan pangénggalna game, maka FPS dasarna moal pernah langkung ti 30, sabab urang pernah meunang leuwih ti 30 apdet per detik ti server. Komo lamun urang nelepon render() 60 kali per detik, teras satengah telepon ieu ngan bakal ngagambar deui hal anu sami, dasarna henteu ngalakukeun nanaon. Masalah sanésna sareng palaksanaan naif nyaéta yén éta rawan reureuh. Kalayan kecepatan Internét anu idéal, klien bakal nampi apdet kaulinan persis unggal 33ms (30 per detik):

Nyieun Multiplayer .io Web Game
Hanjakal, euweuh nu sampurna. Gambar anu langkung realistis nyaéta:
Nyieun Multiplayer .io Web Game
Palaksanaan naif sacara praktis mangrupikeun kasus anu paling parah nalika datang ka latency. Lamun update game narima kalawan reureuh 50ms, lajeng lapak klien 50ms tambahan sabab masih ngajadikeun kaayaan kaulinan tina update saméméhna. Anjeun tiasa ngabayangkeun kumaha teu nyaman ieu pikeun pamaén: ngerem sawenang-wenang bakal nyieun kaulinan ngarasa jerky jeung teu stabil.

7.2 Ningkatkeun kaayaan klien

Urang bakal nyieun sababaraha perbaikan kana palaksanaan naif. Kahiji, urang ngagunakeun reureuh Rendering pikeun 100 ms. Ieu ngandung harti yén kaayaan "ayeuna" klien bakal salawasna 100ms balik kaayaan kaulinan dina server. Contona, upami waktos server 150, teras klien bakal masihan kaayaan dimana server éta dina waktos éta 50:

Nyieun Multiplayer .io Web Game
Ieu masihan kami panyangga 100ms pikeun salamet waktos update kaulinan anu teu kaduga:

Nyieun Multiplayer .io Web Game
The payoff pikeun ieu bakal permanén lag input ku 100 ms. Ieu mangrupikeun pengorbanan minor pikeun midangkeun anu lancar - kalolobaan pamaén (utamana pamaén kasual) moal aya perhatikeun telat ieu. Leuwih gampang pikeun jalma pikeun nyaluyukeun ka latency 100ms konstan ti éta maén kalawan latency unpredictable.

Urang ogé bisa ngagunakeun téhnik séjén disebut prediksi sisi klien, nu ngalakukeun pakasaban alus pikeun ngurangan latency ditanggap, tapi moal katutupan di pos ieu.

pamutahiran sejen kami nganggo nyaeta interpolasi liniér. Alatan lag Rendering, urang biasana sahanteuna hiji update payun waktu ayeuna di klien nu. Nalika ditelepon getCurrentState(), urang tiasa ngalaksanakeun interpolasi liniér antara apdet kaulinan sateuacan sareng saatos waktos ayeuna dina klien:

Nyieun Multiplayer .io Web Game
Ieu ngarengsekeun masalah laju pigura: ayeuna urang tiasa ngadamel pigura unik dina tingkat pigura anu dipikahoyong!

7.3 Ngalaksanakeun kaayaan klien ningkat

Contoh palaksanaan di src/client/state.js ngagunakeun duanana ngajadikeun lag jeung interpolasi linier, tapi teu lila. Hayu urang megatkeun kode jadi dua bagian. Ieu anu kahiji:

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

Hambalan munggaran nyaéta pikeun manggihan naon currentServerTime(). Sakumaha anu urang tingali sateuacana, unggal pembaruan kaulinan kalebet cap waktos server. Kami hoyong nganggo render latency pikeun ngajantenkeun gambar 100ms di tukangeun server, tapi urang moal terang waktos ayeuna dina server, sabab urang teu bisa nyaho sabaraha lila waktu nu diperlukeun pikeun sagala apdet nepi ka kami. Internét teu bisa diprediksi jeung speed na bisa greatly rupa-rupa!

Pikeun ngurilingan masalah ieu, urang tiasa nganggo perkiraan anu lumrah: urang pura-pura update munggaran sumping langsung. Upami ieu leres, maka urang bakal terang waktos server dina waktos khusus ieu! Urang nyimpen timestamp server di firstServerTimestamp jeung ngajaga urang lokal (klien) timestamp dina momen anu sarua dina gameStart.

Duh antosan. Henteu kedah waktos server = waktos klien? Naha urang ngabedakeun antara "server timestamp" jeung "klien timestamp"? Ieu patarosan hébat! Tétéla aranjeunna henteu hal anu sarua. Date.now() bakal balik timestamps béda dina klien tur server, sarta eta gumantung kana faktor lokal pikeun mesin ieu. Pernah nganggap yén cap waktu bakal sami dina sadaya mesin.

Ayeuna urang ngartos naon anu dilakukeun currentServerTime(): eta mulih timestamp server tina waktos render ayeuna. Dina basa sejen, ieu waktos server urang ayeuna (firstServerTimestamp <+ (Date.now() - gameStart)) dikurangan render delay (RENDER_DELAY).

Ayeuna hayu urang tingali kumaha urang nanganan apdet kaulinan. Nalika nampi ti server update, disebutna processGameUpdate()sarta kami simpen update anyar pikeun Asép Sunandar Sunarya gameUpdates. Teras, pikeun mariksa pamakean mémori, urang hapus sadaya apdet anu lami sateuacanna apdet dasarsabab urang teu butuh deui.

Naon téh "update dasar"? Ieu update munggaran urang manggihan ku pindah ka tukang ti waktos server urang ayeuna. Inget diagram ieu?

Nyieun Multiplayer .io Web Game
Pembaruan kaulinan langsung ka kénca "Klién Render Time" nyaéta pembaruan dasar.

Naon update dasar dipaké pikeun? Naha urang tiasa leupaskeun apdet kana dasar? Pikeun terang ieu, hayu urang tungtungna hayu urang tingali palaksanaan getCurrentState():

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

Kami ngadamel tilu kasus:

  1. base < 0 hartosna teu aya apdet dugi ka waktos rendering ayeuna (tingali palaksanaan di luhur getBaseUpdate()). Ieu bisa lumangsung katuhu dina mimiti kaulinan alatan lag Rendering. Dina hal ieu, kami nganggo apdet panganyarna nampi.
  2. base nyaeta update panganyarna kami boga. Ieu bisa jadi alatan reureuh jaringan atawa sambungan Internet goréng. Dina hal ieu, urang ogé ngagunakeun update panganyarna kami boga.
  3. Kami gaduh apdet sateuacan sareng saatos waktos rendering ayeuna, janten urang tiasa interpolasi!

Kabéh nu ditinggalkeun di state.js mangrupa palaksanaan interpolasi linier nu basajan (tapi boring) math. Upami anjeun hoyong ngajalajah nyalira, teras buka state.js dina Github.

Bagian 2. server Backend

Dina bagian ieu, urang bakal nyandak katingal di backend Node.js nu ngatur urang .io kaulinan conto.

1. Server Entry Point

Pikeun ngatur pangladén wéb, urang bakal nganggo kerangka wéb populér pikeun Node.js anu disebut ningalikeun. Éta bakal dikonpigurasi ku file titik éntri server kami src/server/server.js:

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

Inget yen dina bagian kahiji urang bahas Webpack? Ieu dimana urang bakal ngagunakeun konfigurasi Webpack urang. Urang bakal ngagunakeun aranjeunna dina dua cara:

  • Anggo webpack-dev-middleware pikeun otomatis ngawangun deui bungkusan pangwangunan urang, atawa
  • statik mindahkeun folder dist/, dimana Webpack bakal nyerat file kami saatos produksi produksi.

tugas penting séjén server.js nyaéta pikeun nyetél server stop kontak.ionu ngan nyambung ka server Express:

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

Saatos hasil ngadegkeun sambungan socket.io ka server, urang nyetél pawang acara pikeun stop kontak anyar. Pawang acara nanganan pesen anu ditampi ti klien ku delegasi ka hiji objek tunggal game:

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

Urang nyieun kaulinan .io, jadi urang ngan butuh hiji salinan Game ("Game") - kabéh pamaén maén dina arene sarua! Dina bagian salajengna, urang bakal ningali kumaha kelas ieu jalan. Game.

2. server kaulinan

kelas Game ngandung logika pangpentingna dina sisi server. Éta ngagaduhan dua tugas utama: manajemén pamaén и simulasi kaulinan.

Hayu urang mimitian ku tugas kahiji, manajemén pamuter.

kaulinan.js bagian 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);
    }
  }

  // ...
}

Dina pertandingan ieu, urang bakal ngaidentipikasi pamaén ku lapangan id socket socket.io maranéhanana (upami anjeun bingung, teras balik deui ka server.js). Socket.io sorangan nangtukeun unggal stop kontak unik idjanten urang teu kedah hariwang ngeunaan éta. Kuring bakal nelepon anjeunna ID pamuter.

Kalayan éta dina pikiran, hayu urang ngajalajah variabel conto di kelas Game:

  • sockets mangrupa obyék anu meungkeut ID pamuter ka stop kontak nu pakait sareng pamuter nu. Hal ieu ngamungkinkeun urang pikeun ngakses sockets ku ID pamuter maranéhanana dina waktu konstan.
  • players mangrupa obyék anu meungkeut ID pamuter kana kode> obyék Player

bullets mangrupa susunan objék Bullet, nu teu boga urutan pasti.
lastUpdateTime nyaeta timestamp panungtungan waktu kaulinan ieu diropéa. Urang bakal ningali kumaha éta dianggo sakedap.
shouldSendUpdate mangrupa variabel bantu. Urang ogé bakal ningali pamakéan na pas.
Métode addPlayer(), removePlayer() и handleInput() teu perlu ngajelaskeun, aranjeunna dipaké dina server.js. Lamun perlu refresh memori Anjeun, balik saeutik leuwih luhur.

Baris panungtungan constructor() dimimitian nepi siklus update kaulinan (kalayan frékuénsi 60 apdet / s):

kaulinan.js bagian 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;
    }
  }

  // ...
}

Metode update() ngandung meureun bagian pangpentingna tina logika server-sisi. Hayu urang daptar sadayana anu dilakukeun dina urutan:

  1. Ngitung sabaraha lila dt kaliwat saprak panungtungan update().
  2. Refreshes unggal projectile sarta ngancurkeun aranjeunna lamun perlu. Urang bakal ningali palaksanaan fungsionalitas ieu engké. Pikeun ayeuna, cukup pikeun urang terang éta bullet.update() mulih truelamun projectile kudu ancur (manehna kaluar ti arena).
  3. Apdet unggal pamuter sarta spawns projectile a lamun perlu. Urang ogé bakal ningali palaksanaan ieu engké - player.update() bisa balik hiji obyék Bullet.
  4. Cék tabrakan antara projectiles jeung pamaén kalawan applyCollisions(), nu mulih Asép Sunandar Sunarya ti projectiles nu pencét pamaén. Pikeun unggal projectile balik, urang ningkatkeun titik pamaén anu dipecat (ngagunakeun player.onDealtDamage()) lajeng cabut projectile ti Asép Sunandar Sunarya dina bullets.
  5. Ngabéjaan sareng ngancurkeun sadaya pamaén tiwas.
  6. Ngirimkeun apdet kaulinan ka sadaya pamaén unggal detik kali nalika disebut update(). Ieu ngabantosan urang ngalacak variabel bantu anu disebatkeun di luhur. shouldSendUpdate. Salaku update() disebut 60 kali / s, urang ngirim apdet kaulinan 30 kali / s. Ku kituna, frékuénsi jam jam server nyaeta 30 jam / s (urang ngobrol ngeunaan ongkos jam dina bagian kahiji).

Naha ngirim apdet kaulinan wungkul ngaliwatan waktu ? Pikeun nyimpen saluran. 30 apdet kaulinan per detik téh loba!

Naha henteu ngan nelepon update() 30 kali per detik? Pikeun ngaronjatkeun simulasi kaulinan. Beuki sering disebut update(), beuki akurat simulasi kaulinan bakal. Tapi ulah kabawa teuing ku jumlah tantangan. update(), sabab ieu tugas komputasi mahal - 60 per detik cukup cukup.

Sesa kelas Game diwangun ku métode helper dipaké dina update():

kaulinan.js bagian 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() geulis basajan - eta sorts pamaén dumasar skor, nyokot luhureun lima, sarta mulih ngaran pamaké sarta skor pikeun tiap.

createUpdate() dipaké dina update() pikeun nyieun apdet kaulinan anu disebarkeun ka pamaén. Tugas utami nyaéta nyauran metodeu serializeForUpdate()dilaksanakeun pikeun kelas Player и Bullet. Catet yén éta ngan mindahkeun data ka unggal pamuter ngeunaan pangdeukeutna pamaén sarta projectiles - aya teu kudu ngirimkeun informasi ngeunaan objék kaulinan anu tebih ti pamuter nu!

3. objék kaulinan dina server

Dina kaulinan urang, projectiles jeung pamaén sabenerna pisan sarupa: aranjeunna abstrak, buleud, objék kaulinan movable. Pikeun ngamangpaatkeun kamiripan ieu antara pamaén sarta projectiles, hayu urang mimitian ku nerapkeun kelas dasar Object:

objék.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,
    };
  }
}

Henteu aya anu rumit di dieu. kelas ieu bakal titik jangkar alus keur extension nu. Hayu urang tingali kumaha kelas Bullet ngagunakeun 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 pondok pisan! Kami geus ditambahkeun kana Object ngan ekstensi handap:

  • Ngagunakeun pakét pondok pikeun generasi acak id proyéktil.
  • Nambahkeun widang parentIDsupados anjeun tiasa ngalacak pamuter anu nyiptakeun projectile ieu.
  • Nambahkeun nilai balik ka update(), nu sarua jeung truelamun projectile di luar arene (inget urang ngobrol ngeunaan ieu dina bagian panungtungan?).

Hayu urang ngaléngkah ka 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,
    };
  }
}

Pamaén anu leuwih kompleks tinimbang projectiles, jadi sababaraha widang kudu disimpen di kelas ieu. Metoda na update() ngalakukeun loba karya, hususna, mulih projectile karek dijieun lamun euweuh ditinggalkeun fireCooldown (inget urang ngobrol ngeunaan ieu dina bagian saméméhna?). Ogé éta ngalegaan métode serializeForUpdate(), sabab urang kudu ngawengku widang tambahan pikeun pamaén dina update game.

Gaduh kelas dasar Object - léngkah penting pikeun nyegah kode ulang. Contona, euweuh kelas Object unggal objék kaulinan kudu palaksanaan sarua distanceTo(), sareng nyalin-némpelkeun sadaya palaksanaan ieu dina sababaraha file bakal janten ngimpina. Ieu janten utamana penting pikeun proyék-proyék badag.nalika jumlah ngembangna Object kelas tumuwuh.

4. deteksi tabrakan

Hiji-hijina hal anu tinggaleun pikeun urang nyaéta ngakuan nalika projectiles pencét pamaén! Inget sapotong kode ieu ti métode update() di kelas Game:

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

    // ...
  }
}

Urang kudu nerapkeun métode applyCollisions(), nu mulih sadayana projectiles nu pencét pamaén. Untungna, éta henteu sesah pikeun ngalakukeunana

  • Sadaya objék tabrakan mangrupikeun bunderan, anu mangrupikeun bentuk pangbasajanna pikeun ngalaksanakeun deteksi tabrakan.
  • Urang geus boga métode distanceTo(), nu urang dilaksanakeun dina bagian saméméhna di kelas Object.

Ieu kumaha palaksanaan deteksi tabrakan urang sapertos kieu:

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 basajan ieu dumasar kana kanyataan yén dua bunderan tabrakan lamun jarak antara puseur maranéhanana kurang ti jumlah jari-jari maranéhanana. Ieu kasus dimana jarak antara puseur dua bunderan persis sarua jeung jumlah radii maranéhanana:

Nyieun Multiplayer .io Web Game
Aya sababaraha deui aspék anu kedah dipertimbangkeun di dieu:

  • Proyektil teu kedah pencét pamuter anu nyiptakeunana. Ieu bisa dihontal ku ngabandingkeun bullet.parentID с player.id.
  • Projectile ngan kudu pencét sakali dina kasus watesan sababaraha pamaén colliding dina waktos anu sareng. Urang bakal ngajawab masalah ieu ngagunakeun operator break: pas pamuter colliding kalawan projectile nu kapanggih, urang ngeureunkeun pilarian sarta ngaléngkah ka projectile salajengna.

ahir

Éta hungkul! Kami parantos nutupan sadayana anu anjeun kedah terang pikeun nyiptakeun kaulinan wéb .io. Naon salajengna? Bangun kaulinan .io anjeun sorangan!

Sadaya kode conto nyaéta open source sareng dipasang dina Github.

sumber: www.habr.com

Tambahkeun komentar