Membuat Game Web .io Multipemain

Membuat Game Web .io Multipemain
Dirilis pada tahun 2015 Agar.io menjadi nenek moyang genre baru permainan .ioyang semakin populer sejak saat itu. Saya pribadi telah merasakan peningkatan popularitas game .io: selama tiga tahun terakhir, saya mengalaminya membuat dan menjual dua game bergenre ini..

Jika Anda belum pernah mendengar game ini sebelumnya, ini adalah game web multipemain gratis yang mudah dimainkan (tidak memerlukan akun). Mereka biasanya menghadapi banyak pemain lawan dalam satu arena. Game .io terkenal lainnya: Slither.io ΠΈ Diep.io.

Dalam posting ini, kita akan mengeksplorasi caranya membuat game .io dari awal. Untuk ini, pengetahuan tentang Javascript saja sudah cukup: Anda perlu memahami hal-hal seperti sintaksis ES6, kata kunci this ΠΈ Janji. Meskipun Anda tidak mengetahui Javascript dengan sempurna, Anda masih dapat memahami sebagian besar postingan.

Contoh permainan .io

Untuk bantuan pelatihan akan kami rujuk Contoh permainan .io. Cobalah untuk memainkannya!

Membuat Game Web .io Multipemain
Game ini cukup sederhana: Anda mengontrol sebuah kapal di arena yang terdapat pemain lain. Kapal Anda secara otomatis menembakkan proyektil dan Anda mencoba mengenai pemain lain sambil menghindari proyektil mereka.

1. Gambaran singkat/struktur proyek

sarankan unduh kode sumber contoh permainan sehingga Anda dapat mengikuti saya.

Contohnya menggunakan yang berikut ini:

  • Mengekspresikan adalah kerangka web paling populer untuk Node.js yang mengelola server web game.
  • soket.io - perpustakaan websocket untuk bertukar data antara browser dan server.
  • Paket web - manajer modul. Anda dapat membaca tentang alasan menggunakan Webpack. di sini.

Berikut adalah struktur direktori proyek:

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

publik/

Semuanya ada dalam satu folder public/ akan dikirimkan secara statis oleh server. DI DALAM public/assets/ berisi gambar yang digunakan oleh proyek kami.

src /

Semua kode sumber ada di folder src/... Nama client/ ΠΈ server/ berbicara sendiri dan shared/ berisi file konstanta yang diimpor oleh klien dan server.

2. Parameter rakitan/proyek

Seperti yang dinyatakan di atas, kami menggunakan manajer modul untuk membangun proyek Paket web. Mari kita lihat 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',
    }),
  ],
};

Baris terpenting di sini adalah:

  • src/client/index.js adalah titik masuk klien Javascript (JS). Webpack akan mulai dari sini dan secara rekursif mencari file lain yang diimpor.
  • Output JS dari build Webpack kami akan ditempatkan di direktori dist/. Saya akan menyebut file ini milik kami paket js.
  • Kita gunakan Babel, dan khususnya konfigurasi @babel/preset-env untuk mentranspilasi kode JS kami untuk browser lama.
  • Kami menggunakan plugin untuk mengekstrak semua CSS yang direferensikan oleh file JS dan menggabungkannya ke dalam satu tempat. Aku akan menyebutnya milik kita paket CSS.

Anda mungkin memperhatikan nama file paket yang aneh '[name].[contenthash].ext'. Mereka mengandung penggantian nama file Paket web: [name] akan diganti dengan nama titik masukan (dalam kasus kami, ini game), dan [contenthash] akan diganti dengan hash dari isi file. Kami melakukannya untuk mengoptimalkan proyek untuk hashing - kami dapat memberitahu browser untuk menyimpan paket JS kami dalam cache tanpa batas waktu karena jika suatu paket berubah, maka nama filenya juga berubah (perubahan contenthash). Hasil akhirnya akan menjadi nama file tampilan game.dbeee76e91a97d0c7207.js.

berkas webpack.common.js adalah file konfigurasi dasar yang kami impor ke dalam pengembangan dan konfigurasi proyek yang telah selesai. Misalnya, berikut konfigurasi pengembangannya:

webpack.dev.js

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

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

Untuk efisiensi, kami gunakan dalam proses pengembangan webpack.dev.js, dan beralih ke webpack.prod.js, untuk mengoptimalkan ukuran paket saat diterapkan ke produksi.

Pengaturan lokal

Saya sarankan menginstal proyek di mesin lokal sehingga Anda dapat mengikuti langkah-langkah yang tercantum dalam postingan ini. Penyiapannya sederhana: pertama, sistem harus sudah diinstal Node ΠΈ NPM. Selanjutnya yang perlu Anda lakukan

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

dan kamu siap berangkat! Untuk memulai server pengembangan, jalankan saja

$ npm run develop

dan pergi ke peramban web localhost: 3000. Server pengembangan akan secara otomatis membangun kembali paket JS dan CSS seiring perubahan kode - cukup segarkan halaman untuk melihat semua perubahan!

3. Titik masuk klien

Mari kita beralih ke kode permainan itu sendiri. Pertama kita membutuhkan halaman index.html, saat mengunjungi situs tersebut, browser akan memuatnya terlebih dahulu. Halaman kami akan sangat sederhana:

index.html

Contoh permainan .io  BERMAIN

Contoh kode ini telah sedikit disederhanakan untuk kejelasan, dan saya akan melakukan hal yang sama dengan banyak contoh postingan lainnya. Kode lengkap selalu dapat dilihat di Github.

Kita punya:

  • Elemen kanvas HTML5 (<canvas>) yang akan kita gunakan untuk merender game.
  • <link> untuk menambahkan paket CSS kami.
  • <script> untuk menambahkan paket Javascript kami.
  • Menu utama dengan nama pengguna <input> dan tombol MAINKAN (<button>).

Setelah memuat halaman beranda, browser akan mulai mengeksekusi kode Javascript, mulai dari file JS titik masuk: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Ini mungkin terdengar rumit, tetapi tidak banyak yang terjadi di sini:

  1. Mengimpor beberapa file JS lainnya.
  2. Impor CSS (sehingga Webpack tahu untuk memasukkannya ke dalam paket CSS kami).
  3. Luncurkan connect() untuk membuat koneksi dengan server dan menjalankan downloadAssets() untuk mengunduh gambar yang diperlukan untuk merender game.
  4. Setelah selesai tahap 3 menu utama ditampilkan (playMenu).
  5. Mengatur handler untuk menekan tombol "PLAY". Saat tombol ditekan, kode akan menginisialisasi permainan dan memberi tahu server bahwa kita siap bermain.

"Daging" utama dari logika klien-server kami ada pada file yang diimpor oleh file tersebut index.js. Sekarang kita akan mempertimbangkan semuanya secara berurutan.

4. Pertukaran data pelanggan

Dalam game ini, kami menggunakan perpustakaan terkenal untuk berkomunikasi dengan server soket.io. Socket.io memiliki dukungan asli WebSockets, yang cocok untuk komunikasi dua arah: kita dapat mengirim pesan ke server ΠΈ server dapat mengirim pesan kepada kami melalui koneksi yang sama.

Kami akan memiliki satu file src/client/networking.jssiapa yang akan mengurusnya setiap orang komunikasi dengan 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 ini juga telah dipersingkat sedikit agar lebih jelas.

Ada tiga tindakan utama dalam file ini:

  • Kami mencoba terhubung ke server. connectedPromise hanya diperbolehkan ketika kita telah membuat koneksi.
  • Jika koneksi berhasil, kami mendaftarkan fungsi panggilan balik (processGameUpdate() ΠΈ onGameOver()) untuk pesan yang mungkin kami terima dari server.
  • Kami mengekspor play() ΠΈ updateDirection()sehingga file lain dapat menggunakannya.

5. Rendering Klien

Saatnya menampilkan gambar di layar!

…tetapi sebelum kita dapat melakukan itu, kita perlu mengunduh semua gambar (sumber daya) yang diperlukan untuk ini. Mari kita menulis 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];

Pengelolaan sumber daya tidak terlalu sulit untuk diterapkan! Ide utamanya adalah untuk menyimpan suatu benda assets, yang akan mengikat kunci nama file dengan nilai objek Image. Saat sumber daya dimuat, kami menyimpannya dalam sebuah objek assets untuk akses cepat di masa depan. Kapan setiap sumber daya akan diizinkan untuk diunduh (yaitu, semua sumber daya), kami mengizinkan downloadPromise.

Setelah mengunduh sumber daya, Anda dapat mulai melakukan rendering. Seperti yang dikatakan sebelumnya, untuk menggambar di halaman web, kami menggunakan Kanvas HTML5 (<canvas>). Permainan kita cukup sederhana, jadi kita hanya perlu menggambar yang berikut ini:

  1. Latar belakang
  2. Kapal pemain
  3. Pemain lain dalam permainan
  4. Kerang

Berikut cuplikan pentingnya src/client/render.js, yang merender empat item yang tercantum 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);
}

Kode ini juga disingkat untuk kejelasan.

render() adalah fungsi utama dari file ini. startRendering() ΠΈ stopRendering() mengontrol aktivasi loop render pada 60 FPS.

Implementasi konkrit dari fungsi pembantu rendering individual (mis. renderBullet()) tidak terlalu penting, namun berikut ini satu contoh sederhana:

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 bahwa kami menggunakan metode ini getAsset(), yang sebelumnya terlihat di asset.js!

Jika Anda tertarik mempelajari tentang pembantu rendering lainnya, bacalah sisanya. src/klien/render.js.

6. Masukan klien

Saatnya membuat permainan dapat dimainkan! Skema kontrolnya akan sangat sederhana: untuk mengubah arah gerakan, Anda dapat menggunakan mouse (di komputer) atau menyentuh layar (di perangkat seluler). Untuk melaksanakan ini kami akan mendaftar Pendengar Acara untuk acara Mouse dan Sentuh.
Akan mengurus semua ini src/client/input.js:

masukan.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() adalah Pendengar Acara yang menelepon updateDirection() (dari networking.js) ketika peristiwa input terjadi (misalnya, ketika mouse digerakkan). updateDirection() menangani pengiriman pesan dengan server, yang menangani peristiwa input dan memperbarui status game sesuai dengan itu.

7. Status Klien

Bagian ini merupakan bagian tersulit di bagian pertama postingan. Jangan berkecil hati jika Anda belum memahaminya saat pertama kali membacanya! Anda bahkan dapat melewatinya dan kembali lagi nanti.

Bagian terakhir dari teka-teki yang diperlukan untuk menyelesaikan kode klien/server adalah negara. Ingat cuplikan kode dari bagian Rendering Klien?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() harus dapat memberi kami status permainan saat ini di klien kapan saja berdasarkan pembaruan yang diterima dari server. Berikut contoh update game yang dapat dikirimkan oleh 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
    }
  ]
}

Setiap pembaruan game berisi lima bidang yang identik:

  • t: Stempel waktu server yang menunjukkan kapan pembaruan ini dibuat.
  • me: Informasi tentang pemain yang menerima pembaruan ini.
  • orang lain: Serangkaian informasi tentang pemain lain yang berpartisipasi dalam permainan yang sama.
  • peluru: serangkaian informasi tentang proyektil di dalam game.
  • leaderboard: Data papan peringkat saat ini. Dalam posting ini, kami tidak akan mempertimbangkannya.

7.1 Status klien yang naif

Implementasi yang naif getCurrentState() hanya dapat langsung mengembalikan data pembaruan game terbaru yang diterima.

naif-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Indah dan jelas! Tapi andai saja sesederhana itu. Salah satu alasan penerapan ini bermasalah: itu membatasi kecepatan bingkai rendering ke kecepatan jam server.

Kecepatan Bingkai: jumlah frame (yaitu panggilan render()) per detik, atau FPS. Game biasanya berusaha mencapai setidaknya 60 FPS.

Nilai Centang: Frekuensi server mengirimkan pembaruan game ke klien. Seringkali lebih rendah dari kecepatan bingkai. Dalam permainan kami, server berjalan pada frekuensi 30 siklus per detik.

Jika kita hanya merender pembaruan terbaru dari game tersebut, maka FPS pada dasarnya tidak akan pernah melebihi 30, karena kami tidak pernah mendapatkan lebih dari 30 pembaruan per detik dari server. Bahkan jika kita menelepon render() 60 kali per detik, maka setengah dari panggilan ini hanya akan menggambar ulang hal yang sama, pada dasarnya tidak melakukan apa pun. Masalah lain dengan implementasi yang naif adalah hal itu rentan terhadap penundaan. Dengan kecepatan Internet ideal, klien akan menerima pembaruan game tepat setiap 33ms (30 per detik):

Membuat Game Web .io Multipemain
Sayangnya, tidak ada yang sempurna. Gambaran yang lebih realistis adalah:
Membuat Game Web .io Multipemain
Implementasi yang naif merupakan kasus terburuk dalam hal latensi. Jika pembaruan game diterima dengan penundaan 50 ms, maka kios klien tambahan 50ms karena masih merender status game dari pembaruan sebelumnya. Bisa dibayangkan betapa tidak nyamannya hal ini bagi pemain: pengereman sembarangan akan membuat permainan terasa tersentak-sentak dan tidak stabil.

7.2 Peningkatan status klien

Kami akan melakukan beberapa perbaikan pada implementasi naif ini. Pertama, kami menggunakan penundaan rendering selama 100 ms. Ini berarti status klien "saat ini" akan selalu tertinggal 100 md dari status game di server. Misalnya, jika waktu di server adalah 150, maka klien akan merender status server saat itu 50:

Membuat Game Web .io Multipemain
Ini memberi kami buffer 100 md untuk bertahan dari waktu pembaruan game yang tidak dapat diprediksi:

Membuat Game Web .io Multipemain
Imbalannya akan bersifat permanen kelambatan masukan selama 100 ms. Ini adalah pengorbanan kecil untuk kelancaran gameplay - sebagian besar pemain (terutama pemain biasa) bahkan tidak akan menyadari penundaan ini. Jauh lebih mudah bagi orang untuk menyesuaikan diri dengan latensi konstan 100 ms daripada bermain dengan latensi yang tidak dapat diprediksi.

Kita juga bisa menggunakan teknik lain yang disebut prediksi sisi klien, yang berfungsi dengan baik dalam mengurangi latensi yang dirasakan, namun tidak akan dibahas dalam postingan ini.

Peningkatan lain yang kami gunakan adalah interpolasi linier. Karena kelambatan rendering, kami biasanya memiliki setidaknya satu pembaruan lebih awal dari waktu saat ini di klien. Saat dipanggil getCurrentState(), kita bisa memenuhinya interpolasi linier antara pembaruan game sebelum dan sesudah waktu saat ini di klien:

Membuat Game Web .io Multipemain
Ini memecahkan masalah kecepatan bingkai: sekarang kita dapat merender bingkai unik pada kecepatan bingkai berapa pun yang kita inginkan!

7.3 Menerapkan status klien yang ditingkatkan

Contoh implementasi di src/client/state.js menggunakan render lag dan interpolasi linier, tetapi tidak lama. Mari kita pecahkan kodenya menjadi dua bagian. Berikut adalah yang pertama:

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

Hal pertama yang perlu Anda lakukan adalah mencari tahu apa fungsinya currentServerTime(). Seperti yang kita lihat sebelumnya, setiap pembaruan game menyertakan stempel waktu server. Kami ingin menggunakan latensi render untuk merender gambar 100 md di belakang server, namun kita tidak akan pernah tahu waktu saat ini di server, karena kami tidak dapat mengetahui berapa lama waktu yang dibutuhkan hingga pembaruan tersebut sampai kepada kami. Internet tidak dapat diprediksi dan kecepatannya bisa sangat bervariasi!

Untuk mengatasi masalah ini, kita dapat menggunakan perkiraan yang masuk akal: kita anggaplah pembaruan pertama tiba seketika. Jika ini benar, maka kami akan mengetahui waktu server pada saat ini! Kami menyimpan stempel waktu server firstServerTimestamp dan pertahankan milik kita lokal stempel waktu (klien) pada saat yang sama gameStart.

Oh tunggu. Bukankah seharusnya waktu server = waktu klien? Mengapa kita membedakan antara "cap waktu server" dan "cap waktu klien"? Ini pertanyaan yang bagus! Ternyata keduanya bukanlah hal yang sama. Date.now() akan mengembalikan stempel waktu yang berbeda di klien dan server, dan itu bergantung pada faktor lokal pada mesin ini. Jangan pernah berasumsi bahwa stempel waktu akan sama di semua mesin.

Sekarang kita mengerti apa fungsinya currentServerTime(): itu kembali stempel waktu server dari waktu render saat ini. Dengan kata lain, ini adalah waktu server saat ini (firstServerTimestamp <+ (Date.now() - gameStart)) dikurangi penundaan render (RENDER_DELAY).

Sekarang mari kita lihat bagaimana kami menangani pembaruan game. Ketika diterima dari server pembaruan, itu dipanggil processGameUpdate(), dan kami menyimpan pembaruan baru ke array gameUpdates. Kemudian, untuk memeriksa penggunaan memori, kami menghapus semua pembaruan lama sebelumnya pembaruan dasarkarena kita tidak membutuhkannya lagi.

Apa itu β€œpembaruan inti”? Ini update pertama kita temukan dengan bergerak mundur dari waktu server saat ini. Ingat diagram ini?

Membuat Game Web .io Multipemain
Pembaruan game tepat di sebelah kiri "Waktu Render Klien" adalah pembaruan dasar.

Untuk apa pembaruan dasar digunakan? Mengapa kami dapat membuang pembaruan ke basis? Untuk memahami hal ini, mari akhirnya mempertimbangkan penerapannya 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 menangani tiga kasus:

  1. base < 0 berarti tidak ada pembaruan hingga waktu render saat ini (lihat implementasi di atas getBaseUpdate()). Hal ini bisa terjadi tepat di awal permainan karena rendering lag. Dalam hal ini, kami menggunakan pembaruan terkini yang diterima.
  2. base adalah pembaruan terbaru yang kami miliki. Hal ini mungkin disebabkan oleh penundaan jaringan atau koneksi Internet yang buruk. Dalam hal ini, kami juga menggunakan pembaruan terbaru yang kami miliki.
  3. Kami memiliki pembaruan sebelum dan sesudah waktu render saat ini, jadi kami bisa menambah!

Semua yang tersisa di dalamnya state.js adalah implementasi interpolasi linier matematika yang sederhana (namun membosankan). Jika Anda ingin menjelajahinya sendiri, bukalah state.js pada Github.

Bagian 2. Server backend

Pada bagian ini, kita akan melihat backend Node.js yang mengontrol Contoh permainan .io.

1. Titik Masuk Server

Untuk mengelola server web, kita akan menggunakan kerangka web populer untuk Node.js yang disebut Mengekspresikan. Ini akan dikonfigurasi oleh file titik masuk 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}`);

Ingat di bagian pertama kita membahas Webpack? Di sinilah kita akan menggunakan konfigurasi Webpack kita. Kami akan menggunakannya dalam dua cara:

  • Menggunakan webpack-dev-middleware untuk secara otomatis membangun kembali paket pengembangan kami, atau
  • folder transfer secara statis dist/, di mana Webpack akan menulis file kita setelah produksi dibangun.

Tugas penting lainnya server.js adalah menyiapkan server soket.ioyang baru saja terhubung ke 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);
});

Setelah berhasil membuat koneksi socket.io dengan server, kami mengkonfigurasi event handler untuk soket baru. Pengendali peristiwa memproses pesan yang diterima dari klien dengan mendelegasikannya ke 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);
}

Kami sedang membuat game .io, jadi kami hanya memerlukan satu salinan Game (β€œGame”) – semua pemain bermain di arena yang sama! Di bagian selanjutnya kita akan melihat cara kerja kelas ini Game.

2. Server permainan

Kelas Game berisi logika paling penting di sisi server. Ini memiliki dua tugas utama: manajemen pemain ΠΈ simulasi permainan.

Mari kita mulai dengan tugas pertama, manajemen pemain.

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

  // ...
}

Dalam permainan ini kami akan mengidentifikasi pemain berdasarkan bidang id soket socket.io mereka (jika Anda bingung, kembali ke server.js). Socket.io sendiri memberikan setiap soket yang unik id, jadi kita tidak perlu khawatir. Saya akan menelepon dia ID Pemain.

Dengan mengingat hal tersebut, mari kita jelajahi variabel instan di sebuah kelas Game:

  • sockets adalah objek yang mengikat ID pemain ke soket yang terkait dengan pemain. Ini memungkinkan kita mengakses soket berdasarkan ID pemainnya dalam waktu yang konstan.
  • players adalah objek yang mengikat ID pemain dengan kode>objek Pemain

bullets adalah array objek Bullet, yang tidak memiliki urutan pasti.
lastUpdateTime - Ini adalah stempel waktu pembaruan game terakhir. Kita akan segera melihat bagaimana penggunaannya.
shouldSendUpdate adalah variabel bantu. Kami juga akan segera melihat penggunaannya.
Metode addPlayer(), removePlayer() ΠΈ handleInput() tidak perlu dijelaskan, mereka sudah digunakan server.js. Jika Anda perlu menyegarkan ingatan Anda, kembalilah sedikit lebih tinggi.

Baris terakhir constructor() meluncurkan siklus pembaruan permainan (dengan frekuensi 60 pembaruan/dtk):

game.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() mungkin berisi bagian terpenting dari logika sisi server. Mari kita daftar semua fungsinya secara berurutan:

  1. Menghitung berapa lama dt sudah sejak terakhir kali update().
  2. Menyegarkan setiap proyektil dan menghancurkannya jika perlu. Kita akan melihat penerapan fungsi ini nanti. Untuk saat ini, cukup kita mengetahuinya saja bullet.update() kembali truejika proyektil harus dihancurkan (dia pergi ke luar arena).
  3. Perbarui setiap pemain dan keluarkan proyektil jika perlu. Kita juga akan melihat implementasi ini nanti - player.update() dapat mengembalikan suatu objek Bullet.
  4. Memeriksa tabrakan antara proyektil dan pemain dengan applyCollisions(), yang mengembalikan serangkaian proyektil yang mengenai pemain. Untuk setiap proyektil yang dikembalikan, kami meningkatkan poin pemain yang menembakkannya (menggunakan player.onDealtDamage()) dan kemudian keluarkan proyektil dari array bullets.
  5. Memberi tahu dan menghancurkan semua pemain yang terbunuh.
  6. Mengirim pembaruan game ke semua pemain setiap detik kali ketika dipanggil update(). Ini membantu kita melacak variabel tambahan yang disebutkan di atas. shouldSendUpdate. Sebagai update() dipanggil 60 kali/dtk, kami mengirim pembaruan game 30 kali/dtk. Dengan demikian, frekuensi jam jam server adalah 30 jam/dtk (kita telah membicarakan tentang kecepatan jam di bagian pertama).

Mengapa hanya mengirim pembaruan game melewati waktu ? Untuk menyimpan saluran. 30 pembaruan game per detik itu banyak!

Mengapa tidak menelepon saja? update() 30 kali per detik? Untuk meningkatkan simulasi permainan. Semakin sering disebut update(), semakin akurat simulasi permainannya. Namun jangan terlalu terbawa dengan banyaknya tantangan. update(), karena ini adalah tugas komputasi yang mahal - 60 per detik sudah cukup.

Sisa kelas Game terdiri dari metode pembantu yang digunakan dalam update():

game.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() cukup sederhana - ini mengurutkan pemain berdasarkan skor, mengambil lima besar, dan mengembalikan nama pengguna dan skor untuk masing-masing pemain.

createUpdate() Digunakan dalam update() untuk membuat pembaruan game yang didistribusikan kepada pemain. Tugas utamanya adalah memanggil metode serializeForUpdate()diterapkan untuk kelas Player ΠΈ Bullet. Perhatikan bahwa itu hanya meneruskan data ke setiap pemain terdekat pemain dan proyektil - tidak perlu mengirimkan informasi tentang objek permainan yang jauh dari pemain!

3. Objek permainan di server

Dalam permainan kami, proyektil dan pemain sebenarnya sangat mirip: mereka adalah objek permainan yang abstrak, bulat, dan dapat digerakkan. Untuk memanfaatkan kesamaan antara pemain dan proyektil, mari kita mulai dengan mengimplementasikan kelas dasar Object:

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

Tidak ada hal rumit yang terjadi di sini. Kelas ini akan menjadi titik acuan yang baik untuk perluasan. Mari kita lihat bagaimana kelasnya Bullet menggunakan 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;
  }
}

Implementasi Bullet sangat singkat! Kami telah menambahkan Object hanya ekstensi berikut:

  • Menggunakan paket pendek untuk generasi acak id proyektil.
  • Menambahkan bidang parentIDsehingga kamu dapat melacak pemain yang membuat proyektil ini.
  • Menambahkan nilai kembalian ke update(), yang setara truejika proyektil berada di luar arena (ingat kita membicarakan hal ini di bagian terakhir?).

Mari kita lanjutkan ke Player:

pemain.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 proyektil, jadi beberapa bidang lagi harus disimpan di kelas ini. Metodenya update() melakukan banyak pekerjaan, khususnya, mengembalikan proyektil yang baru dibuat jika tidak ada yang tersisa fireCooldown (ingat kita membicarakan hal ini di bagian sebelumnya?). Ini juga memperluas metodenya serializeForUpdate(), karena kami perlu menyertakan kolom tambahan untuk pemain dalam pembaruan game.

Memiliki kelas dasar Object - langkah penting untuk menghindari pengulangan kode. Misalnya, tidak ada kelas Object setiap objek permainan harus memiliki implementasi yang sama distanceTo(), dan menyalin-menempelkan semua implementasi ini ke banyak file akan menjadi mimpi buruk. Hal ini menjadi sangat penting untuk proyek-proyek besar.ketika jumlahnya bertambah Object kelas semakin meningkat.

4. Deteksi tabrakan

Satu-satunya hal yang tersisa bagi kami adalah mengenali kapan proyektil mengenai pemain! Ingat cuplikan kode ini dari metode ini update() di 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 perlu menerapkan metode ini applyCollisions(), yang mengembalikan semua proyektil yang mengenai pemain. Untungnya, hal ini tidak terlalu sulit untuk dilakukan karena

  • Semua objek yang bertabrakan berbentuk lingkaran, dan ini adalah bentuk paling sederhana untuk menerapkan deteksi tabrakan.
  • Kami sudah punya metodenya distanceTo(), yang kita implementasikan pada bagian sebelumnya di kelas Object.

Berikut implementasi deteksi tabrakan kami:

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 sederhana ini didasarkan pada fakta bahwa dua buah lingkaran bertumbukan jika jarak kedua pusatnya lebih kecil dari jumlah jari-jarinya. Berikut adalah kasus dimana jarak antara pusat dua lingkaran sama dengan jumlah jari-jarinya:

Membuat Game Web .io Multipemain
Ada beberapa aspek lagi yang perlu dipertimbangkan di sini:

  • Proyektil tidak boleh mengenai pemain yang menciptakannya. Hal ini dapat dicapai dengan membandingkan bullet.parentID с player.id.
  • Proyektil hanya boleh mengenai satu kali jika ada beberapa pemain yang bertabrakan secara bersamaan. Kami akan menyelesaikan masalah ini menggunakan operator break: Setelah pemain yang bertabrakan dengan proyektil ditemukan, kami berhenti mencari dan melanjutkan ke proyektil berikutnya.

ΠšΠΎΠ½Π΅Ρ†

Itu saja! Kami telah membahas semua yang perlu Anda ketahui untuk membuat game web .io. Apa berikutnya? Bangun game .io Anda sendiri!

Semua kode contoh adalah open source dan diposting di Github.

Sumber: www.habr.com

Tambah komentar