Buat multiplayer .io web game

gambar

Dirilis pada 2015, Agar.io menjadi nenek moyang genre baru game .io , yang popularitasnya telah tumbuh secara signifikan. Pertumbuhan popularitas game .io yang saya alami sendiri: selama tiga tahun terakhir saya telah membuat dan menjual dua game dari genre ini. .

Jika Anda belum pernah mendengar tentang game seperti ini sebelumnya: ini adalah game web multi-pemain gratis yang mudah untuk berpartisipasi (tidak perlu akun). Biasanya mereka mendorong banyak pemain lawan di arena yang sama. Game terkenal lainnya dari genre .io adalah: Slither.io dan Diep.io.

Dalam posting ini, kita akan mengetahui cara membuat game .io dari awal . Untuk ini, hanya pengetahuan Javascript akan cukup: Anda perlu memahami hal-hal seperti sintaks ES6 , this dan Janji . Bahkan jika Anda tahu Javascript tidak sempurna, Anda masih bisa mengetahui sebagian besar posting.

Contoh Game .io


Untuk membantu Anda belajar, kami akan merujuk ke contoh .io game . Coba mainkan!


Gim ini cukup sederhana: Anda mengontrol sebuah kapal di arena di mana ada pemain lain. Kapal Anda secara otomatis menembakkan peluru dan Anda mencoba mengenai pemain lain, sambil menghindari peluru mereka.

1. Gambaran umum / struktur proyek


Saya sarankan mengunduh kode sumber untuk game contoh sehingga Anda dapat mengikuti saya.

Contoh ini menggunakan yang berikut:

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

Berikut ini struktur struktur proyek:

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

publik /


Semua yang ada di public/ folder akan dikirimkan secara statis oleh server. public/assets/ berisi gambar yang digunakan oleh proyek kami.

src /


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

2. Majelis / parameter proyek


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

Yang paling penting di sini adalah baris berikut:

  • src/client/index.js adalah titik input ke klien Javascript (JS). Webpack akan mulai dari sini dan secara rekursif mencari file yang diimpor lainnya.
  • JS keluaran dari perakitan Webpack kami akan berlokasi di dist/ direktori. Saya akan memanggil file ini paket JS kami.
  • Kami menggunakan Babel , dan khususnya konfigurasi @ babel / preset-env untuk mengubah kode JS kami untuk browser yang lebih lama.
  • Kami menggunakan plugin untuk mengekstrak semua CSS yang dirujuk oleh file JS dan untuk menggabungkannya di satu tempat. Saya akan menyebutnya paket CSS kami.

Anda mungkin telah memperhatikan nama file paket aneh '[name].[contenthash].ext' . Mereka berisi substitusi nama-nama file Webpack: [name] akan diganti dengan nama titik masuk (dalam kasus kami, ini adalah game ), dan [contenthash] akan digantikan oleh hash dari isi file. Kami melakukan ini untuk mengoptimalkan proyek hashing - Anda dapat memberitahu browser untuk men-cache paket JS kami tanpa batas, karena jika paket berubah, maka nama file-nya berubah (perubahan konten). Hasil akhir akan berupa nama file form game.dbeee76e91a97d0c7207.js .

File webpack.common.js adalah file konfigurasi dasar yang kami impor dalam pengembangan dan konfigurasi proyek selesai. Misalnya, konfigurasi pengembangan:

webpack.dev.js

 const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common, { mode: 'development', }); 

Untuk efisiensi, kami menggunakan webpack.dev.js dalam webpack.dev.js pengembangan, dan beralih ke webpack.prod.js untuk mengoptimalkan ukuran paket ketika digunakan dalam produksi.

Pengaturan lokal


Saya sarankan menginstal proyek pada mesin lokal sehingga Anda dapat mengikuti langkah-langkah yang tercantum dalam posting ini. Penyiapannya sederhana: pertama, Node dan NPM harus diinstal pada sistem. Selanjutnya Anda perlu melakukan

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

dan kamu siap untuk pergi! Untuk memulai server pengembangan, jalankan saja

 $ npm run develop 

dan akses localhost: 3000 di browser web. Server pengembangan akan secara otomatis membangun kembali paket JS dan CSS saat kode berubah - cukup segarkan halaman untuk melihat semua perubahan!

3. Titik masuk pelanggan


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

index.html

  <! DOCTYPE html>
 <html>
 <head>
   <title> Contoh .io game </title>
   <link type = "text / css" rel = "stylesheet" href = "/ game.bundle.css">
 </head>
 <body>
   <canvas id = "game-canvas"> </canvas>
   <script async src = "/ game.bundle.js"> </script>
   <div id = "play-menu" class = "hidden">
     <input type = "text" id = "username-input" placeholder = "Username" />
     <button id = "play-button"> PLAY </button>
   </div>
 </body>
 </html> 

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

Kami memiliki:

  • <canvas> HTML5 Canvas ( <canvas> ) yang akan kita gunakan untuk membuat game.
  • <link> untuk menambahkan paket CSS kami.
  • <script> untuk menambahkan paket Javascript kami.
  • Menu utama dengan username <input> dan tombol β€œMAINKAN” ( <button> ).

Setelah memuat beranda, kode Javascript akan mulai dijalankan di browser, dimulai dengan file JS dari 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 terlihat rumit, tetapi dalam kenyataannya tidak banyak tindakan yang terjadi di sini:

  1. Impor beberapa file JS lainnya.
  2. Impor CSS (jadi Webpack tahu untuk memasukkannya dalam paket CSS kami).
  3. Menjalankan connect() untuk membuat koneksi ke server dan menjalankan downloadAssets() untuk mengunduh gambar yang diperlukan untuk membuat game.
  4. Setelah menyelesaikan langkah 3 , menu utama ( playMenu ) playMenu .
  5. Mengkonfigurasi pawang untuk menekan tombol MAINKAN. Ketika tombol ditekan, kode menginisialisasi permainan dan memberi tahu server bahwa kami siap bermain.

"Daging" utama dari logika client-server kami ada di file-file yang diimpor oleh file index.js . Sekarang kita akan mempertimbangkan semuanya dalam urutan.

4. Pertukaran data pelanggan


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

Kami akan memiliki satu file src/client/networking.js yang akan menangani semua komunikasi dengan server:

networking.js

 import io from 'socket.io-client'; import { processGameUpdate } from './state'; const Constants = require('../shared/constants'); const socket = io(`ws://${window.location.host}`); const connectedPromise = new Promise(resolve => { socket.on('connect', () => { console.log('Connected to server!'); resolve(); }); }); export const connect = onGameOver => ( connectedPromise.then(() => { // Register callbacks socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate); socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver); }) ); export const play = username => { socket.emit(Constants.MSG_TYPES.JOIN_GAME, username); }; export const updateDirection = dir => { socket.emit(Constants.MSG_TYPES.INPUT, dir); }; 

Kode ini juga sedikit berkurang untuk kejelasan.

Ada tiga tindakan utama dalam file ini:

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

5. Render klien


Saatnya untuk menampilkan gambar di layar!

... tetapi sebelum kita dapat melakukan ini, 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]; 

Manajemen sumber daya tidak begitu sulit untuk diterapkan! Titik utama adalah untuk menyimpan objek assets , yang akan mengikat kunci nama file ke nilai objek Image . Saat sumber daya dimuat, kami menyimpannya di objek assets untuk pengambilan cepat di masa mendatang. Saat mengunduh akan diizinkan untuk setiap sumber daya individu (yaitu, semua sumber daya akan diunduh), kami mengaktifkan downloadPromise .

Setelah mengunduh sumber daya, Anda dapat mulai render. Seperti yang dinyatakan sebelumnya, kami menggunakan HTML5 Canvas ( <canvas> ) untuk menggambar di halaman web. Game kami cukup sederhana, jadi cukup bagi kami untuk hanya menggambar berikut ini:

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

Berikut adalah cuplikan penting dari src/client/render.js yang merender empat poin 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 file ini. startRendering() dan stopRendering() mengontrol aktivasi siklus rendering pada 60 FPS.

Implementasi spesifik dari fungsi rendering tambahan individual (mis. renderBullet() ) tidak begitu penting, tetapi di sini adalah 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 getAsset() yang sebelumnya terlihat di asset.js !

Jika Anda tertarik untuk menjelajahi fungsi rendering tambahan lainnya, maka baca sisa src / client / render.js .

6. Input klien


Saatnya untuk membuat game dimainkan ! Skema kontrol akan sangat sederhana: Anda dapat menggunakan mouse (di komputer) atau menyentuh layar (di perangkat seluler) untuk mengubah arah gerakan. Untuk menerapkan ini, kami akan mendaftarkan Pendengar Acara untuk acara Mouse dan Sentuh.
src/client/input.js akan melakukan semua ini:

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() dan onTouchInput() adalah Pendengar Peristiwa yang memanggil updateDirection() (dari networking.js ) saat peristiwa input terjadi (misalnya, saat menggerakkan mouse). updateDirection() terlibat dalam pengiriman pesan dengan server yang memproses acara input dan memperbarui keadaan permainan sesuai.

7. Kondisi pelanggan


Bagian ini adalah yang paling sulit di bagian pertama posting. Jangan berkecil hati jika Anda tidak memahaminya dari bacaan pertama! Anda bahkan dapat melewatkannya dan kembali lagi nanti.

Bagian terakhir dari teka-teki yang diperlukan untuk menyelesaikan kode client-server adalah status . 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 keadaan permainan saat ini di klien pada waktu tertentu berdasarkan pembaruan yang diterima dari server. Berikut adalah contoh pembaruan game yang dapat dikirim 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 identik:

  • t : stempel waktu server untuk waktu pembaruan ini dibuat.
  • saya : Informasi tentang pemain yang menerima pembaruan ini.
  • lainnya : serangkaian informasi tentang pemain lain yang berpartisipasi dalam permainan yang sama.
  • peluru : berbagai informasi tentang kerang dalam permainan.
  • leaderboard : data papan peringkat saat ini. Dalam posting ini kami tidak akan mempertimbangkannya.

7.1 Keadaan naif klien


Implementasi naif getCurrentState() hanya dapat langsung mengembalikan data dari 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; } 

Cantik dan jelas! Tetapi jika semuanya begitu sederhana. Salah satu alasan implementasi ini bermasalah: membatasi laju frame dari render hingga frekuensi jam server .

Frame Rate : Jumlah frame (mis. render() panggilan) per detik, atau FPS. Game biasanya cenderung mencapai setidaknya 60 FPS.

Tick ​​Tick : Frekuensi server mengirim pembaruan game ke klien. Seringkali lebih rendah dari frame rate . Dalam permainan kami, server berjalan pada frekuensi 30 siklus per detik.

Jika kami hanya membuat pembaruan game terbaru, maka FPS sebenarnya tidak akan pernah bisa melebihi 30, karena kami tidak pernah menerima lebih dari 30 pembaruan per detik dari server . Bahkan jika kita memanggil render() 60 kali per detik, setengah dari panggilan ini hanya akan menggambar hal yang sama, pada dasarnya tidak melakukan apa-apa. Masalah lain dari implementasi naif adalah bahwa ia rentan terhadap penundaan . Dengan kecepatan internet yang ideal, klien akan menerima pembaruan game persis setiap 33 ms (30 per detik):


Sayangnya, tidak ada yang sempurna. Gambaran yang lebih realistis adalah:

Implementasi yang naif secara praktis adalah kasus terburuk dalam hal penundaan. Jika pembaruan game diterima dengan penundaan 50 ms, maka klien diperlambat oleh tambahan 50 ms, karena itu masih membuat keadaan permainan dari pembaruan sebelumnya. Anda dapat membayangkan betapa tidak nyamannya hal itu bagi seorang pemain: karena pengereman yang sewenang-wenang, permainan akan tampak gelisah dan tidak stabil.

7.2 Peningkatan status pelanggan


Kami akan membuat beberapa perbaikan pada implementasi yang naif. Pertama, kami menggunakan penundaan rendering 100 ms. Ini berarti bahwa "saat ini" keadaan klien akan selalu tertinggal di belakang keadaan permainan di server dengan 100 ms. Misalnya, jika waktu di server adalah 150 , maka keadaan di mana server itu pada waktu 50 akan diberikan pada klien:


Ini memberi kami buffer 100 ms, memungkinkan kami bertahan dari waktu yang tidak terduga untuk menerima pembaruan game:


Membayar untuk ini akan menjadi penundaan input konstan (input lag) dari 100 ms. Ini adalah pengorbanan kecil untuk gameplay yang lancar - sebagian besar pemain (terutama yang kasual) bahkan tidak akan melihat penundaan ini. Jauh lebih mudah bagi orang untuk beradaptasi dengan penundaan konstan 100 ms daripada bermain dengan penundaan tak terduga.

Kita dapat menggunakan teknik lain yang disebut "peramalan sisi klien," yang melakukan pekerjaan yang baik untuk mengurangi penundaan yang dirasakan, tetapi itu tidak akan dipertimbangkan dalam posting ini.

Peningkatan lain yang kami gunakan adalah interpolasi linier . Karena keterlambatan render, kami biasanya mengambil alih waktu saat ini di klien dengan setidaknya satu pembaruan. Ketika getCurrentState() dipanggil, kami dapat melakukan interpolasi linier antara pembaruan game segera sebelum dan setelah waktu saat ini di klien:


Ini menyelesaikan masalah dengan frame rate: sekarang kita dapat membuat frame unik dengan frekuensi yang kita butuhkan!

7.3 Menerapkan Status Klien yang Ditingkatkan


Contoh implementasi di src/client/state.js menggunakan kedua rendering delay dan interpolasi linier, tetapi ini tidak lama. Mari kita pecahkan kodenya menjadi dua bagian. Ini 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; } 

Langkah pertama adalah mencari tahu apa yang dilakukan currentServerTime() . Seperti yang kita lihat sebelumnya, stempel waktu server disertakan dalam setiap pembaruan game. Kami ingin menggunakan penundaan rendering untuk membuat gambar 100 ms di belakang server, tetapi kami tidak akan pernah tahu waktu saat ini di server , karena kami tidak dapat mengetahui berapa lama pembaruan tersebut sampai kepada kami. Internet tidak dapat diprediksi dan kecepatannya dapat sangat bervariasi!

Untuk mengatasi masalah ini, Anda dapat menggunakan perkiraan yang masuk akal: kami akan berpura-pura bahwa pembaruan pertama tiba secara instan . Jika ini benar, maka kita akan tahu waktu server pada saat khusus ini! Kami menyimpan stempel waktu server di firstServerTimestamp dan menyimpan stempel waktu (klien) lokal kami secara bersamaan di gameStart .

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

Sekarang kita mengerti apa yang dilakukan currentServerTime() : mengembalikan timestamp server untuk waktu rendering saat ini .Dengan kata lain, ini adalah waktu server saat ini ( firstServerTimestamp <+ (Date.now() - gameStart)) dikurangi penundaan rendering ( RENDER_DELAY).

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

Apa itu "pembaruan dasar"? Ini adalah pembaruan pertama yang kami temukan bergerak mundur dari waktu server saat ini . Ingat sirkuit ini?


Pembaruan game langsung ke kiri Client Render Time adalah pembaruan dasar.

Untuk apa pembaruan dasar digunakan? Mengapa kita bisa membuang pembaruan ke pangkalan? Untuk memahami hal ini, mari kita akhirnya melihat pelaksanaan 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 < 0berarti tidak ada pembaruan untuk waktu rendering saat ini (lihat implementasi di atas getBaseUpdate()). Ini dapat terjadi tepat di awal permainan karena penundaan render. Dalam hal ini, kami menggunakan pembaruan terbaru.
  2. baseIni adalah pembaruan terbaru yang kami miliki. Ini mungkin karena latensi 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, sehingga Anda dapat melakukan interpolasi !

Yang tersisa state.jshanyalah implementasi interpolasi linier, yang merupakan matematika sederhana (tapi membosankan). Jika Anda ingin mempelajarinya sendiri, buka state.jsdi Github .

Bagian 2. Server Backend


Pada bagian ini, kita akan melihat backend Node.js yang menjalankan contoh .io game kita .

1. Titik masuk server


Untuk mengontrol server web, kami akan menggunakan kerangka web populer untuk Node.js yang disebut Express . Ini akan dikonfigurasikan 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 bahwa pada bagian pertama kita membahas Webpack? Di sinilah kita akan menggunakan konfigurasi Webpack kami. Kami akan menerapkannya dalam dua cara:

  • Gunakan webpack-dev-middleware untuk secara otomatis membangun kembali paket pengembangan kami, atau
  • Transfer folder secara dist/statis ke mana Webpack akan menulis file kita setelah perakitan produksi.

Tugas penting lainnya server.jsadalah mengonfigurasi server socket.io , yang hanya 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 mengonfigurasikan event handler untuk socket baru. Penangan peristiwa memproses pesan yang diterima dari klien melalui delegasi 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 membuat game dengan genre .io, jadi kami hanya perlu satu instance Game("Game") - semua pemain bermain di arena yang sama! Di bagian selanjutnya, kita akan melihat bagaimana kelas ini bekerja Game.

2. Server game


Kelas Gameberisi logika sisi server yang paling penting. Ini memiliki dua tugas utama: manajemen pemain dan simulasi permainan .

Mari kita mulai dengan tugas pertama - dengan 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 para pemain dengan bidang idsoket soket mereka.io (jika Anda bingung, lalu kembali ke server.js). Socket.io sendiri memberikan setiap soket yang unik id, jadi kami tidak perlu khawatir tentang hal ini. Saya akan memanggil ID pemainnya .

Dengan mengingat hal ini, mari kita periksa variabel instan di kelas Game:

  • socketsMerupakan objek yang mengikat ID pemain ke soket yang dikaitkan dengan pemain. Hal ini memungkinkan kita untuk mendapatkan akses ke soket dengan ID pemain mereka untuk waktu yang konstan.
  • players Adalah objek yang mengikat ID pemain ke kode> Objek pemain

bulletsMerupakan larik objek Bulletyang tidak memiliki urutan tertentu.
lastUpdateTime- Ini adalah cap waktu waktu gim terakhir diperbarui. Segera kita akan melihat bagaimana ini digunakan.
shouldSendUpdateMerupakan variabel bantu. Kami akan segera melihat penggunaannya.
Metode addPlayer(), removePlayer()dan handleInput()tidak perlu dijelaskan, digunakan server.js. Jika Anda perlu menyegarkan ingatan Anda, kembali sedikit lebih tinggi.

Baris terakhir constructor()memulai siklus pembaruan game (dengan frekuensi 60 pembaruan):

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 ini update()mungkin berisi bagian terpenting dari logika sisi-server. Agar, kami mencantumkan semua yang dia lakukan:

  1. Menghitung berapa banyak waktu yang dttelah berlalu sejak yang terakhir update().
  2. . . , bullet.update() true , ( ).
  3. . β€” player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).

? . 30 – !

Lalu mengapa tidak menelepon update()30 kali per detik? Untuk meningkatkan simulasi permainan. Semakin sering dipanggil update(), semakin akurat simulasi permainannya. Tetapi jangan terlalu terbawa dengan jumlah panggilan update(), karena itu adalah tugas yang mahal secara komputasi - 60 per detik sudah cukup.

Sisa kelas Gameterdiri 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 dengan jumlah poin, mengambil lima terbaik dan kembali untuk setiap nama pengguna dan skor.

createUpdate()digunakan update()untuk membuat pembaruan game yang diteruskan ke pemain. Tugas utamanya adalah memanggil metode yang serializeForUpdate()diimplementasikan untuk Playerdan kelas Bullet. Perhatikan bahwa ia mentransfer ke setiap pemain hanya data pemain dan kerang terdekat - tidak perlu mengirimkan informasi tentang objek permainan yang terletak jauh dari pemain!

3. Objek permainan di server


Dalam permainan kami, cangkang dan pemain sebenarnya sangat mirip: mereka adalah benda-benda abstrak yang bergerak bulat. Untuk memanfaatkan kesamaan pemain dan cangkang ini, mari kita mulai dengan implementasi kelas dasar 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, }; } } 

Tidak ada yang rumit terjadi di sini. Kelas ini akan menjadi titik referensi yang baik untuk ekspansi. Mari kita lihat bagaimana kelas Bulletmenggunakan 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; } } 

Implementasinya Bulletsangat singkat! Kami menambahkan Objecthanya ke ekstensi berikut:

  • Menggunakan paket shortid untuk menghasilkan idproyektil secara acak .
  • Menambahkan bidang parentIDsehingga Anda dapat melacak pemain yang membuat proyektil ini.
  • Menambahkan nilai kembali ke update(), yang sama truejika proyektil berada di luar arena (ingat, kita membicarakan hal ini di bagian terakhir?).

Mari beralih ke 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 kerang, jadi beberapa bidang lagi harus disimpan di kelas ini. Metodenya update()melakukan banyak pekerjaan, khususnya, mengembalikan shell yang baru dibuat jika tidak dibiarkan fireCooldown(ingat, kita sudah membicarakan ini di bagian sebelumnya?). Dia juga memperluas metode serializeForUpdate(), karena kita perlu memasukkan bidang tambahan untuk pemain dalam pembaruan game.

Memiliki kelas dasar Objectadalah langkah penting dalam menghindari pengulangan kode . Sebagai contoh, tanpa kelas, Objectsetiap objek game harus memiliki implementasi yang sama distanceTo(), dan sinkronisasi copy-paste dari semua implementasi ini dalam beberapa file akan menjadi mimpi buruk. Ini menjadi sangat penting untuk proyek besar ketika jumlah Objectkelas ekstensi bertambah.

4. Pengakuan konflik


Satu-satunya hal yang tersisa bagi kita adalah mengenali kapan cangkang menabrak para pemain! Ingat kode ini dari metode 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), ); // ... } } 

Kami perlu menerapkan metode applyCollisions()yang mengembalikan semua shell yang mengenai pemain. Untungnya, ini tidak begitu sulit dilakukan, karena

  • Semua objek bertabrakan adalah lingkaran, dan ini adalah angka paling sederhana untuk menerapkan pengenalan tabrakan.
  • Kami sudah memiliki metode distanceTo()yang kami terapkan di bagian sebelumnya di kelas Object.

Begini tampilannya implementasi pengenalan tabrakan:

collisions.js

 const Constants = require('../shared/constants'); // Returns an array of bullets to be destroyed. function applyCollisions(players, bullets) { const destroyedBullets = []; for (let i = 0; i < bullets.length; i++) { // Look for a player (who didn't create the bullet) to collide each bullet with. // As soon as we find one, break out of the loop to prevent double counting a bullet. for (let j = 0; j < players.length; j++) { const bullet = bullets[i]; const player = players[j]; if ( bullet.parentID !== player.id && player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS ) { destroyedBullets.push(bullet); player.takeBulletDamage(); break; } } } return destroyedBullets; } 

Pengenalan tabrakan sederhana ini didasarkan pada fakta bahwa dua lingkaran bertabrakan jika jarak antara pusat-pusat mereka kurang dari jumlah jari-jari mereka . Inilah kasus ketika jarak antara pusat-pusat dua lingkaran persis sama dengan jumlah jari-jarinya:


Di sini Anda perlu mempertimbangkan beberapa aspek:

  • Proyektil tidak boleh jatuh ke pemain yang membuatnya. Ini dapat dicapai dengan membandingkan bullet.parentIDdengan player.id.
  • Proyektil harus mengenai hanya sekali dalam kasus ekstrim tabrakan simultan dengan beberapa pemain. Kami akan memecahkan masalah ini dengan bantuan operator break: segera setelah seorang pemain ditemukan yang telah menemukan proyektil, kami menghentikan pencarian dan melanjutkan ke proyektil berikutnya.

Akhirnya


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

Semua kode sampel adalah open source dan diposting di Github .

Source: https://habr.com/ru/post/id450574/


All Articles