Mencipta Permainan Web .io Berbilang Pemain

Mencipta Permainan Web .io Berbilang Pemain
Dikeluarkan pada 2015 Agar.io menjadi nenek moyang genre baru permainan .ioyang semakin popular sejak itu. Saya secara peribadi telah mengalami peningkatan populariti permainan .io: sejak tiga tahun lalu, saya pernah mengalaminya mencipta dan menjual dua permainan genre ini..

Sekiranya anda tidak pernah mendengar tentang permainan ini sebelum ini, ini adalah permainan web berbilang pemain percuma yang mudah dimainkan (tiada akaun diperlukan). Mereka biasanya berhadapan dengan ramai pemain lawan di gelanggang yang sama. Permainan .io terkenal lain: Slither.io ΠΈ Diep.io.

Dalam siaran ini, kita akan meneroka bagaimana cipta permainan .io dari awal. Untuk ini, hanya pengetahuan Javascript sahaja yang mencukupi: anda perlu memahami perkara seperti sintaks ES6, kata kunci this ΠΈ janji. Walaupun pengetahuan anda tentang Javascript tidak sempurna, anda masih boleh memahami kebanyakan siaran.

contoh permainan .io

Untuk bantuan pembelajaran, kami akan merujuk kepada contoh permainan .io. Cuba mainkan!

Mencipta Permainan Web .io Berbilang Pemain
Permainan ini agak mudah: anda mengawal kapal di arena di mana terdapat pemain lain. Kapal anda secara automatik menembak peluru dan anda cuba memukul pemain lain sambil mengelakkan peluru mereka.

1. Tinjauan ringkas / struktur projek

mengesyorkan muat turun kod sumber contoh permainan supaya anda boleh mengikuti saya.

Contoh menggunakan yang berikut:

  • Express ialah rangka kerja web Node.js paling popular yang menguruskan pelayan web permainan.
  • soket.io - perpustakaan soket web untuk bertukar-tukar data antara penyemak imbas dan pelayan.
  • Webpack - pengurus modul. Anda boleh membaca tentang sebab menggunakan Webpack. di sini.

Inilah rupa struktur direktori projek:

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

awam/

Semuanya dalam folder public/ akan dihantar secara statik oleh pelayan. DALAM public/assets/ mengandungi imej yang digunakan oleh projek kami.

src /

Semua kod sumber ada dalam folder src/. Tajuk client/ ΠΈ server/ bercakap untuk diri mereka sendiri dan shared/ mengandungi fail pemalar yang diimport oleh kedua-dua klien dan pelayan.

2. Tetapan perhimpunan/projek

Seperti yang dinyatakan di atas, kami menggunakan pengurus modul untuk membina projek. Webpack. Mari lihat 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',
    }),
  ],
};

Baris yang paling penting di sini ialah:

  • src/client/index.js ialah titik masuk pelanggan Javascript (JS). Webpack akan bermula dari sini dan mencari secara rekursif untuk fail import lain.
  • Output JS binaan Webpack kami akan ditempatkan dalam direktori dist/. Saya akan memanggil fail ini sebagai kami pakej js.
  • Kami guna Babel, dan khususnya konfigurasi @babel/preset-env untuk mengalihkan kod JS kami untuk pelayar lama.
  • Kami menggunakan pemalam untuk mengekstrak semua CSS yang dirujuk oleh fail JS dan menggabungkannya di satu tempat. Saya akan memanggilnya kami pakej css.

Anda mungkin perasan nama fail pakej yang pelik '[name].[contenthash].ext'. Mereka mengandungi penggantian nama fail Beg Web: [name] akan digantikan dengan nama titik input (dalam kes kami, ini game), dan [contenthash] akan digantikan dengan cincang kandungan fail. Kami melakukannya untuk mengoptimumkan projek untuk pencincangan - anda boleh memberitahu pelayar untuk cache pakej JS kami selama-lamanya, kerana jika pakej berubah, maka nama failnya juga berubah (perubahan contenthash). Hasil akhir ialah nama fail paparan game.dbeee76e91a97d0c7207.js.

fail webpack.common.js ialah fail konfigurasi asas yang kami import ke dalam pembangunan dan konfigurasi projek siap. Berikut ialah contoh konfigurasi pembangunan:

webpack.dev.js

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

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

Untuk kecekapan, kami gunakan dalam proses pembangunan webpack.dev.js, dan bertukar kepada webpack.prod.jsuntuk mengoptimumkan saiz pakej apabila digunakan untuk pengeluaran.

Tetapan setempat

Saya mengesyorkan memasang projek pada mesin tempatan supaya anda boleh mengikuti langkah-langkah yang disenaraikan dalam siaran ini. Persediaan adalah mudah: pertama, sistem mesti telah dipasang nod ΠΈ NPM. Seterusnya anda perlu lakukan

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

dan anda sudah bersedia untuk pergi! Untuk memulakan pelayan pembangunan, jalankan sahaja

$ npm run develop

dan pergi ke pelayar web localhost: 3000. Pelayan pembangunan akan membina semula pakej JS dan CSS secara automatik apabila kod berubah - cuma muat semula halaman untuk melihat semua perubahan!

3. Mata Masuk Pelanggan

Mari kita turun ke kod permainan itu sendiri. Mula-mula kita memerlukan halaman index.html, apabila melawat tapak, penyemak imbas akan memuatkannya terlebih dahulu. Halaman kami akan menjadi agak mudah:

index.html

Contoh permainan .io  BERMAIN

Contoh kod ini telah dipermudahkan sedikit untuk kejelasan, dan saya akan melakukan perkara yang sama dengan banyak contoh siaran lain. Kod penuh sentiasa boleh dilihat di Github.

Kami ada:

  • Elemen kanvas HTML5 (<canvas>) yang akan kami gunakan untuk membuat permainan.
  • <link> untuk menambah pakej CSS kami.
  • <script> untuk menambah pakej Javascript kami.
  • Menu utama dengan nama pengguna <input> dan butang PLAY (<button>).

Selepas memuatkan halaman utama, penyemak imbas akan mula melaksanakan kod Javascript, bermula dari fail JS titik masuk: 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);
  };
});

Ini mungkin kelihatan rumit, tetapi tidak banyak perkara yang berlaku di sini:

  1. Mengimport beberapa fail JS lain.
  2. Import CSS (jadi Webpack tahu untuk memasukkannya dalam pakej CSS kami).
  3. Pelancaran connect() untuk mewujudkan sambungan dengan pelayan dan jalankan downloadAssets() untuk memuat turun imej yang diperlukan untuk membuat permainan.
  4. Selepas tamat tahap 3 menu utama dipaparkan (playMenu).
  5. Menetapkan pengendali untuk menekan butang "MAIN". Apabila butang ditekan, kod memulakan permainan dan memberitahu pelayan bahawa kami sudah bersedia untuk bermain.

"Daging" utama logik pelayan pelanggan kami adalah dalam fail yang diimport oleh fail tersebut index.js. Sekarang kita akan mempertimbangkan semuanya mengikut urutan.

4. Pertukaran data pelanggan

Dalam permainan ini, kami menggunakan perpustakaan yang terkenal untuk berkomunikasi dengan pelayan soket.io. Socket.io mempunyai sokongan asli Soket Web, yang sangat sesuai untuk komunikasi dua hala: kami boleh menghantar mesej kepada pelayan ΠΈ pelayan boleh menghantar mesej kepada kami pada sambungan yang sama.

Kami akan mempunyai satu fail src/client/networking.jssiapa yang akan menjaga oleh semua komunikasi dengan pelayan:

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

Kod ini juga telah dipendekkan sedikit untuk kejelasan.

Terdapat tiga tindakan utama dalam fail ini:

  • Kami cuba menyambung ke pelayan. connectedPromise hanya dibenarkan apabila kami telah mewujudkan sambungan.
  • Jika sambungan berjaya, kami mendaftarkan fungsi panggil balik (processGameUpdate() ΠΈ onGameOver()) untuk mesej yang boleh kami terima daripada pelayan.
  • Kami mengeksport play() ΠΈ updateDirection()supaya fail lain boleh menggunakannya.

5. Rendering Pelanggan

Sudah tiba masanya untuk memaparkan gambar pada skrin!

…tetapi sebelum kita boleh melakukannya, kita perlu memuat turun semua imej (sumber) yang diperlukan untuk ini. Mari kita tulis pengurus sumber:

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

Pengurusan sumber tidak begitu sukar untuk dilaksanakan! Idea utama adalah untuk menyimpan objek assets, yang akan mengikat kunci nama fail kepada nilai objek Image. Apabila sumber dimuatkan, kami menyimpannya dalam objek assets untuk akses pantas pada masa hadapan. Bilakah setiap sumber individu dibenarkan untuk memuat turun (iaitu, semua sumber), kami benarkan downloadPromise.

Selepas memuat turun sumber, anda boleh mula membuat persembahan. Seperti yang dikatakan sebelum ini, untuk melukis pada halaman web, kami menggunakan Kanvas HTML5 (<canvas>). Permainan kami agak mudah, jadi kami hanya perlu melukis yang berikut:

  1. Latar Belakang
  2. Kapal pemain
  3. Pemain lain dalam permainan
  4. cengkerang

Berikut adalah coretan penting src/client/render.js, yang memberikan tepat empat item yang disenaraikan di atas:

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

Kod ini juga dipendekkan untuk kejelasan.

render() adalah fungsi utama fail ini. startRendering() ΠΈ stopRendering() mengawal pengaktifan gelung pemaparan pada 60 FPS.

Pelaksanaan konkrit bagi fungsi pembantu rendering individu (cth. renderBullet()) tidak begitu penting, tetapi berikut adalah satu contoh mudah:

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

Perhatikan bahawa kami menggunakan kaedah tersebut getAsset(), yang sebelum ini dilihat dalam asset.js!

Jika anda berminat untuk mengetahui tentang pembantu rendering lain, kemudian baca yang lain. src/client/render.js.

6. Input pelanggan

Sudah tiba masanya untuk membuat permainan boleh dimainkan! Skim kawalan akan menjadi sangat mudah: untuk menukar arah pergerakan, anda boleh menggunakan tetikus (pada komputer) atau menyentuh skrin (pada peranti mudah alih). Untuk melaksanakan ini, kami akan mendaftar Pendengar Acara untuk acara Tetikus dan Sentuh.
Akan uruskan semua ini 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() ialah Pendengar Acara yang memanggil updateDirection() (daripada networking.js) apabila peristiwa input berlaku (contohnya, apabila tetikus digerakkan). updateDirection() mengendalikan pemesejan dengan pelayan, yang mengendalikan acara input dan mengemas kini keadaan permainan dengan sewajarnya.

7. Status Pelanggan

Bahagian ini adalah yang paling sukar dalam bahagian pertama jawatan. Jangan berkecil hati jika anda tidak memahaminya pada kali pertama anda membacanya! Anda juga boleh melangkaunya dan kembali kepadanya kemudian.

Bahagian terakhir teka-teki yang diperlukan untuk melengkapkan kod klien/pelayan ialah adalah. Ingat coretan kod daripada bahagian Rendering Pelanggan?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() sepatutnya dapat memberi kami keadaan semasa permainan dalam klien pada bila-bila masa berdasarkan kemas kini yang diterima daripada pelayan. Berikut ialah contoh kemas kini permainan yang boleh dihantar oleh pelayan:

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

Setiap kemas kini permainan mengandungi lima medan yang sama:

  • t: Cap masa pelayan menunjukkan bila kemas kini ini dibuat.
  • me: Maklumat tentang pemain yang menerima kemas kini ini.
  • lain-lain: Pelbagai maklumat tentang pemain lain yang mengambil bahagian dalam permainan yang sama.
  • peluru: pelbagai maklumat tentang projektil dalam permainan.
  • papan pendahulu: Data papan pendahulu semasa. Dalam siaran ini, kami tidak akan menganggap mereka.

7.1 Keadaan pelanggan yang naif

Pelaksanaan naif getCurrentState() hanya boleh terus mengembalikan data kemas kini permainan yang paling baru diterima.

naif-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bagus dan jelas! Tetapi jika ia semudah itu. Salah satu sebab pelaksanaan ini bermasalah: ia mengehadkan kadar bingkai pemaparan kepada kadar jam pelayan.

Kadar bingkai: bilangan bingkai (iaitu panggilan render()) sesaat, atau FPS. Permainan biasanya berusaha untuk mencapai sekurang-kurangnya 60 FPS.

Kadar Tanda: Kekerapan pelayan menghantar kemas kini permainan kepada pelanggan. Ia selalunya lebih rendah daripada kadar bingkai. Dalam permainan kami, pelayan berjalan pada kekerapan 30 kitaran sesaat.

Jika kami hanya memberikan kemas kini terkini permainan, maka FPS pada dasarnya tidak akan melebihi 30, kerana kami tidak pernah mendapat lebih daripada 30 kemas kini sesaat daripada pelayan. Walaupun kita panggil render() 60 kali sesaat, kemudian separuh daripada panggilan ini hanya akan melukis semula perkara yang sama, pada dasarnya tidak melakukan apa-apa. Satu lagi masalah dengan pelaksanaan naif ialah ia terdedah kepada kelewatan. Dengan kelajuan Internet yang ideal, pelanggan akan menerima kemas kini permainan tepat setiap 33ms (30 sesaat):

Mencipta Permainan Web .io Berbilang Pemain
Malangnya, tiada yang sempurna. Gambar yang lebih realistik ialah:
Mencipta Permainan Web .io Berbilang Pemain
Pelaksanaan naif boleh dikatakan merupakan kes terburuk apabila melibatkan kependaman. Jika kemas kini permainan diterima dengan kelewatan 50ms, maka gerai pelanggan 50ms tambahan kerana ia masih memaparkan keadaan permainan daripada kemas kini sebelumnya. Anda boleh bayangkan betapa tidak selesanya ini untuk pemain: brek sewenang-wenangnya akan membuatkan permainan berasa tersentak dan tidak stabil.

7.2 Keadaan klien bertambah baik

Kami akan membuat beberapa penambahbaikan pada pelaksanaan naif. Pertama, kita gunakan kelewatan rendering untuk 100 ms. Ini bermakna bahawa keadaan "semasa" pelanggan akan sentiasa ketinggalan di belakang keadaan permainan pada pelayan sebanyak 100ms. Sebagai contoh, jika masa pada pelayan adalah 150, maka klien akan memberikan keadaan di mana pelayan berada pada masa itu 50:

Mencipta Permainan Web .io Berbilang Pemain
Ini memberi kami penimbal 100ms untuk bertahan dalam masa kemas kini permainan yang tidak dapat diramalkan:

Mencipta Permainan Web .io Berbilang Pemain
Bayaran untuk ini akan kekal ketinggalan input untuk 100 ms. Ini adalah pengorbanan kecil untuk permainan yang lancar - kebanyakan pemain (terutamanya pemain kasual) tidak akan menyedari kelewatan ini. Lebih mudah bagi orang untuk menyesuaikan diri dengan kependaman 100ms yang berterusan daripada bermain dengan kependaman yang tidak dapat diramalkan.

Kita juga boleh menggunakan teknik lain yang dipanggil ramalan pihak pelanggan, yang berfungsi dengan baik untuk mengurangkan kependaman yang dirasakan, tetapi tidak akan dibincangkan dalam siaran ini.

Satu lagi peningkatan yang kami gunakan ialah interpolasi linear. Disebabkan ketinggalan rendering, kami biasanya sekurang-kurangnya satu kemas kini lebih awal daripada masa semasa dalam pelanggan. Apabila dipanggil getCurrentState(), kita boleh laksanakan interpolasi linear antara kemas kini permainan sebelum dan selepas masa semasa dalam klien:

Mencipta Permainan Web .io Berbilang Pemain
Ini menyelesaikan isu kadar bingkai: kami kini boleh menghasilkan bingkai unik pada sebarang kadar bingkai yang kami mahu!

7.3 Melaksanakan keadaan pelanggan yang dipertingkatkan

Contoh pelaksanaan dalam src/client/state.js menggunakan kedua-dua lag render dan interpolasi linear, tetapi tidak lama. Mari pecahkan kod kepada dua bahagian. Inilah yang pertama:

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

Langkah pertama adalah untuk mengetahui apa currentServerTime(). Seperti yang kita lihat sebelum ini, setiap kemas kini permainan termasuk cap masa pelayan. Kami mahu menggunakan kependaman render untuk menjadikan imej 100ms di belakang pelayan, tetapi kami tidak akan tahu masa semasa pada pelayan, kerana kami tidak dapat mengetahui berapa lama masa yang diambil untuk sebarang kemas kini sampai kepada kami. Internet tidak dapat diramalkan dan kelajuannya boleh berbeza-beza!

Untuk mengatasi masalah ini, kita boleh menggunakan anggaran yang munasabah: kita berpura-pura kemas kini pertama tiba serta-merta. Jika ini benar, maka kami akan mengetahui masa pelayan pada masa ini! Kami menyimpan cap masa pelayan dalam firstServerTimestamp dan simpan kami tempatan (pelanggan) cap masa pada saat yang sama masuk gameStart.

Oh tunggu. Bukankah ia sepatutnya masa pelayan = masa pelanggan? Mengapakah kita membezakan antara "cap masa pelayan" dan "cap masa pelanggan"? Ini adalah soalan yang hebat! Ternyata mereka bukan perkara yang sama. Date.now() akan mengembalikan cap masa yang berbeza dalam klien dan pelayan, dan ia bergantung pada faktor setempat kepada mesin ini. Jangan sekali-kali menganggap bahawa cap masa akan sama pada semua mesin.

Sekarang kita faham apa yang dilakukan currentServerTime(): ia kembali cap masa pelayan masa pemaparan semasa. Dengan kata lain, ini ialah masa semasa pelayan (firstServerTimestamp <+ (Date.now() - gameStart)) tolak kelewatan render (RENDER_DELAY).

Sekarang mari kita lihat cara kami mengendalikan kemas kini permainan. Apabila diterima daripada pelayan kemas kini, ia dipanggil processGameUpdate()dan kami menyimpan kemas kini baharu pada tatasusunan gameUpdates. Kemudian, untuk menyemak penggunaan memori, kami mengalih keluar semua kemas kini lama sebelum ini kemas kini asaskerana kita tidak memerlukan mereka lagi.

Apakah itu "kemas kini asas"? ini kemas kini pertama yang kami temui dengan bergerak ke belakang dari masa semasa pelayan. Ingat gambar rajah ini?

Mencipta Permainan Web .io Berbilang Pemain
Kemas kini permainan terus di sebelah kiri "Masa Render Pelanggan" ialah kemas kini asas.

Untuk apa kemas kini asas digunakan? Mengapakah kita boleh menggugurkan kemas kini ke garis dasar? Untuk mengetahui perkara ini, mari akhirnya pertimbangkan pelaksanaannya getCurrentState():

state.js bahagian 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 mengendalikan tiga kes:

  1. base < 0 bermakna tiada kemas kini sehingga masa pemaparan semasa (lihat pelaksanaan di atas getBaseUpdate()). Ini boleh berlaku tepat pada permulaan permainan disebabkan oleh lag rendering. Dalam kes ini, kami menggunakan kemas kini terkini yang diterima.
  2. base adalah kemas kini terkini yang kami ada. Ini mungkin disebabkan oleh kelewatan rangkaian atau sambungan Internet yang lemah. Dalam kes ini, kami juga menggunakan kemas kini terkini yang kami ada.
  3. Kami mempunyai kemas kini sebelum dan selepas masa pemaparan semasa, jadi kami boleh interpolasi!

Semua yang tinggal dalam state.js ialah pelaksanaan interpolasi linear yang merupakan matematik yang mudah (tetapi membosankan). Jika anda ingin meneroka sendiri, kemudian buka state.js pada Github.

Bahagian 2. Pelayan Bahagian Belakang

Dalam bahagian ini, kita akan melihat bahagian belakang Node.js yang mengawal kita contoh permainan .io.

1. Titik Masuk Pelayan

Untuk mengurus pelayan web, kami akan menggunakan rangka kerja web yang popular untuk Node.js yang dipanggil Express. Ia akan dikonfigurasikan oleh fail titik masuk pelayan kami src/server/server.js:

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

Ingat bahawa pada bahagian pertama kita membincangkan Webpack? Di sinilah kami akan menggunakan konfigurasi Webpack kami. Kami akan menggunakannya dalam dua cara:

  • Guna webpack-dev-middleware untuk membina semula pakej pembangunan kami secara automatik, atau
  • pindahkan folder secara statik dist/, di mana Webpack akan menulis fail kami selepas binaan pengeluaran.

Satu lagi tugas penting server.js adalah untuk menyediakan pelayan soket.ioyang hanya menyambung ke pelayan Express:

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

Selepas berjaya mewujudkan sambungan socket.io ke pelayan, kami menyediakan pengendali acara untuk soket baharu. Pengendali acara mengendalikan mesej yang diterima daripada pelanggan dengan mewakilkan kepada objek tunggal game:

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

Kami sedang mencipta permainan .io, jadi kami hanya memerlukan satu salinan Game ("Permainan") - semua pemain bermain di arena yang sama! Dalam bahagian seterusnya, kita akan melihat bagaimana kelas ini berfungsi. Game.

2. Pelayan permainan

Kelas Game mengandungi logik yang paling penting di bahagian pelayan. Ia mempunyai dua tugas utama: pengurusan pemain ΠΈ simulasi permainan.

Mari kita mulakan dengan tugas pertama, pengurusan pemain.

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

  // ...
}

Dalam permainan ini, kami akan mengenal pasti pemain mengikut padang id soket socket.io mereka (jika anda keliru, kemudian kembali ke server.js). Socket.io sendiri memberikan setiap soket yang unik idjadi kita tidak perlu risau tentang itu. Saya akan hubungi dia ID pemain.

Dengan itu, mari kita terokai pembolehubah contoh dalam kelas Game:

  • sockets ialah objek yang mengikat ID pemain ke soket yang dikaitkan dengan pemain. Ia membolehkan kami mengakses soket dengan ID pemain mereka dalam masa yang tetap.
  • players ialah objek yang mengikat ID pemain kepada kod>objek Pemain

bullets ialah susunan objek Bullet, yang tidak mempunyai susunan yang pasti.
lastUpdateTime ialah cap masa kali terakhir permainan dikemas kini. Kita akan lihat bagaimana ia digunakan tidak lama lagi.
shouldSendUpdate ialah pembolehubah bantu. Kami juga akan melihat penggunaannya tidak lama lagi.
Kaedah addPlayer(), removePlayer() ΠΈ handleInput() tidak perlu dijelaskan, ia digunakan dalam server.js. Jika anda perlu menyegarkan ingatan anda, kembali lebih tinggi sedikit.

Baris terakhir constructor() melancarkan kitaran kemas kini permainan (dengan kekerapan 60 kemas kini / s):

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

  // ...
}

Kaedah update() mengandungi mungkin bahagian logik bahagian pelayan yang paling penting. Inilah yang dilakukannya, mengikut urutan:

  1. Mengira berapa lama dt berlalu sejak lepas update().
  2. Menyegarkan setiap peluru dan memusnahkannya jika perlu. Kita akan melihat pelaksanaan fungsi ini kemudian. Buat masa ini, cukuplah kita mengetahuinya bullet.update() pulangan truejika peluru harus dimusnahkan (dia melangkah keluar dari gelanggang).
  3. Mengemas kini setiap pemain dan mengeluarkan peluru jika perlu. Kita juga akan melihat pelaksanaan ini kemudian - player.update() boleh mengembalikan objek Bullet.
  4. Menyemak perlanggaran antara projektil dan pemain dengan applyCollisions(), yang mengembalikan pelbagai peluru yang mengenai pemain. Untuk setiap peluru yang dikembalikan, kami meningkatkan mata pemain yang melepaskannya (menggunakan player.onDealtDamage()) dan kemudian keluarkan peluru daripada tatasusunan bullets.
  5. Memberitahu dan memusnahkan semua pemain yang terbunuh.
  6. Menghantar kemas kini permainan kepada semua pemain setiap saat kali apabila dipanggil update(). Ini membantu kami menjejaki pembolehubah tambahan yang disebutkan di atas. shouldSendUpdate. Kerana update() dipanggil 60 kali/s, kami menghantar kemas kini permainan 30 kali/s. Oleh itu, kekerapan jam jam pelayan ialah 30 jam/s (kami bercakap tentang kadar jam pada bahagian pertama).

Mengapa menghantar kemas kini permainan sahaja melalui masa ? Untuk menyimpan saluran. 30 kemas kini permainan sesaat adalah banyak!

Kenapa tidak telefon sahaja update() 30 kali sesaat? Untuk menambah baik simulasi permainan. Lebih kerap dipanggil update(), lebih tepat simulasi permainan akan dibuat. Tetapi jangan terlalu terbawa-bawa dengan banyaknya cabaran. update(), kerana ini adalah tugas yang mahal dari segi pengiraan - 60 sesaat sudah memadai.

Selebihnya kelas Game terdiri daripada kaedah pembantu yang digunakan dalam update():

game.js bahagian 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() cukup mudah - ia menyusun pemain mengikut skor, mengambil lima teratas, dan mengembalikan nama pengguna dan markah untuk setiap satu.

createUpdate() digunakan dalam update() untuk membuat kemas kini permainan yang diedarkan kepada pemain. Tugas utamanya ialah memanggil kaedah serializeForUpdate()dilaksanakan untuk kelas Player ΠΈ Bullet. Ambil perhatian bahawa ia hanya menghantar data kepada setiap pemain tentang terdekat pemain dan peluru - tidak perlu menghantar maklumat tentang objek permainan yang jauh dari pemain!

3. Objek permainan pada pelayan

Dalam permainan kami, peluru dan pemain sebenarnya sangat serupa: ia adalah objek permainan abstrak, bulat dan boleh alih. Untuk memanfaatkan persamaan ini antara pemain dan peluru, mari kita mulakan dengan melaksanakan kelas asas 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,
    };
  }
}

Tiada apa-apa yang rumit berlaku di sini. Kelas ini akan menjadi titik utama yang baik untuk sambungan. Mari kita lihat bagaimana kelas Bullet kegunaan 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 sangat pendek! Kami telah menambah Object hanya sambungan berikut:

  • Menggunakan pakej pendek untuk penjanaan rawak id peluru.
  • Menambah medan parentIDsupaya anda boleh menjejaki pemain yang mencipta peluru ini.
  • Menambah nilai pulangan kepada update(), yang sama dengan truejika peluru berada di luar arena (ingat kita bercakap tentang ini di bahagian terakhir?).

Mari kita beralih kepada 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,
    };
  }
}

Pemain lebih kompleks daripada projektil, jadi beberapa medan lagi harus disimpan dalam kelas ini. kaedah beliau update() melakukan banyak kerja, khususnya, mengembalikan peluru yang baru dibuat jika tiada yang tinggal fireCooldown (ingat kita bercakap tentang ini di bahagian sebelumnya?). Juga ia memanjangkan kaedah serializeForUpdate(), kerana kami perlu memasukkan medan tambahan untuk pemain dalam kemas kini permainan.

Mempunyai kelas asas Object - langkah penting untuk mengelakkan kod berulang. Contohnya, tiada kelas Object setiap objek permainan mesti mempunyai pelaksanaan yang sama distanceTo(), dan salin-tampal semua pelaksanaan ini merentas berbilang fail akan menjadi mimpi ngeri. Ini menjadi penting terutamanya untuk projek besar.apabila bilangan mengembang Object kelas semakin berkembang.

4. Pengesanan perlanggaran

Satu-satunya perkara yang tinggal untuk kita ialah mengenali apabila peluru terkena pemain! Ingat sekeping kod ini daripada kaedah update() di dalam kelas Game:

permainan.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 perlu melaksanakan kaedah tersebut applyCollisions(), yang mengembalikan semua peluru yang mengenai pemain. Nasib baik, ia tidak begitu sukar untuk dilakukan kerana

  • Semua objek berlanggar adalah bulatan, yang merupakan bentuk paling mudah untuk melaksanakan pengesanan perlanggaran.
  • Kami sudah mempunyai kaedah distanceTo(), yang kami laksanakan dalam bahagian sebelumnya dalam kelas Object.

Berikut ialah rupa pelaksanaan pengesanan perlanggaran kami:

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

Pengesanan perlanggaran mudah ini adalah berdasarkan fakta bahawa dua bulatan berlanggar jika jarak antara pusatnya kurang daripada hasil tambah jejarinya. Berikut ialah kes di mana jarak antara pusat dua bulatan adalah betul-betul sama dengan jumlah jejari mereka:

Mencipta Permainan Web .io Berbilang Pemain
Terdapat beberapa lagi aspek yang perlu dipertimbangkan di sini:

  • Peluru tidak boleh mengenai pemain yang menciptanya. Ini boleh dicapai dengan membandingkan bullet.parentID с player.id.
  • Peluru mesti hanya terkena sekali dalam kes had berbilang pemain berlanggar pada masa yang sama. Kami akan menyelesaikan masalah ini menggunakan operator break: sebaik sahaja pemain yang berlanggar dengan peluru ditemui, kami menghentikan pencarian dan meneruskan ke peluru seterusnya.

Akhirnya

Itu sahaja! Kami telah merangkumi semua yang anda perlu ketahui untuk membuat permainan web .io. Apa yang akan datang? Bina permainan .io anda sendiri!

Semua kod sampel adalah sumber terbuka dan disiarkan pada Github.

Sumber: www.habr.com

Tambah komen