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:
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 = () => {
Ini mungkin terlihat rumit, tetapi dalam kenyataannya tidak banyak tindakan yang terjadi di sini:
- Impor beberapa file JS lainnya.
- Impor CSS (jadi Webpack tahu untuk memasukkannya dalam paket CSS kami).
- Menjalankan
connect()
untuk membuat koneksi ke server dan menjalankan downloadAssets()
untuk mengunduh gambar yang diperlukan untuk membuat game. - Setelah menyelesaikan langkah 3 , menu utama (
playMenu
) playMenu
. - 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(() => {
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:
- Latar belakang
- Kapal pemain
- Pemain lain dalam game
- 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;
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();
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;
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);
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();
Kami menangani tiga kasus:base < 0
berarti 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.base
Ini 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.- Kami memiliki pembaruan sebelum dan sesudah waktu render saat ini, sehingga Anda dapat melakukan interpolasi !
Yang tersisa state.js
hanyalah implementasi interpolasi linier, yang merupakan matematika sederhana (tapi membosankan). Jika Anda ingin mempelajarinya sendiri, buka state.js
di 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');
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.js
adalah mengonfigurasi server socket.io , yang hanya terhubung ke server Express:server.js, bagian 2
const socketio = require('socket.io'); const Constants = require('../shared/constants');
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');
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 Game
berisi 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;
Dalam permainan ini, kami akan mengidentifikasi para pemain dengan bidang id
soket 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
:sockets
Merupakan 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
bullets
Merupakan larik objek Bullet
yang tidak memiliki urutan tertentu.lastUpdateTime
- Ini adalah cap waktu waktu gim terakhir diperbarui. Segera kita akan melihat bagaimana ini digunakan.shouldSendUpdate
Merupakan 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 {
Metode ini update()
mungkin berisi bagian terpenting dari logika sisi-server. Agar, kami mencantumkan semua yang dia lakukan:- Menghitung berapa banyak waktu yang
dt
telah berlalu sejak yang terakhir update()
. - . . ,
bullet.update()
true
, ( ). - . β
player.update()
Bullet
. applyCollisions()
, , . , ( player.onDealtDamage()
), bullets
.- .
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 Game
terdiri dari metode pembantu yang digunakan dalam update()
:game.js, bagian 3
class Game {
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 Player
dan 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 Bullet
menggunakan 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; }
Implementasinya Bullet
sangat singkat! Kami menambahkan Object
hanya ke ekstensi berikut:- Menggunakan paket shortid untuk menghasilkan
id
proyektil secara acak . - Menambahkan bidang
parentID
sehingga Anda dapat melacak pemain yang membuat proyektil ini. - Menambahkan nilai kembali ke
update()
, yang sama true
jika 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; }
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 Object
adalah langkah penting dalam menghindari pengulangan kode . Sebagai contoh, tanpa kelas, Object
setiap 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 Object
kelas 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 {
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');
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.parentID
dengan 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 .