
Objavljena 2015. godine postala je pretka novog žanra , čija popularnost je od tada značajno porasla. Porast popularnosti igara .io osjetio sam na vlastitoj koži: u posljednje tri godine .
Ako nikada prije niste čuli za takve igre: to su besplatne višekorisničke web igre u kojima je lako sudjelovati (nije potrebna registracija). Obično okupljaju brojne protivničke igrače na jednoj areni. Druge poznate igre iz žanra .io su: i .
U ovom postu istražit ćemo kako od nule stvoriti igru .io. Za to će vam biti potrebno samo znanje Javascripta: trebate razumjeti stvari kao što su sintaksa , ključna riječ this i . Čak i ako ne znate Javascript savršeno, moći ćete razumjeti veći dio posta.
Primjer igre .io
Za pomoć u učenju ćemo se oslanjati na . Pokušajte igrati!

Igra je prilično jednostavna: upravljate brodom na areni gdje su drugi igrači. Vaš brod automatski ispaljuje projektile i pokušavate pogoditi druge igrače, istovremeno izbjegavajući njihove projektile.
1. Kratak pregled/struktura projekta
Preporučujem primjera igre, tako da možete pratiti uz mene.
U primjeru se koristi sljedeće:
- — najpopularniji web okvir za Node.js koji upravlja web serverom igre.
- — websocket biblioteka za razmjenu podataka između preglednika i poslužitelja.
- — upravitelj modula. O razlozima za korištenje Webpacka možete pročitati .
Ovako izgleda struktura direktorija projekta:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Sve u mapi public/ bit će statički posluženo od strane servera. U public/assets/ nalaze se slike koje koristi naš projekt.
src/
Sav izvorni kod nalazi se u mapi src/. Imena client/ i server/ govore sama za sebe, a shared/ sadrži datoteku konstanti koja se uvozi i na klijentu i na poslužitelju.
2. Izgradnje/parametri projekta
Kao što je navedeno, za izgradnju projekta koristimo upravitelja modula . Pogledajmo našu Webpack konfiguraciju:
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',
}),
],
};Najvažnije su sljedeće linije:
src/client/index.js— ovo je ulazna točka klijenta Javascript (JS). Webpack će započeti odavde i rekurzivno tražiti druge importirane datoteke.- Izlazni JS naše Webpack gradnje bit će smješten u direktoriju
dist\. Nazvat ću tu datoteku našom JS bundlom. - Koristimo , a posebno konfiguraciju za transpiling našeg JS koda za starije preglednike.
- Koristimo plugin za izvlačenje svih CSS-a na koje se pozivaju JS datoteke i za njihovo spajanje na jednom mjestu. Nazvat ću ga našim CSS bundlom.
Možda ste primijetili čudna imena datoteka bundla '[name].[contenthash].ext'. Ona sadrže Webpack: [name] bit će zamijenjen imenom ulazne točke (u našem slučaju to je game), a [contenthash] bit će zamijenjen hashom sadržaja datoteke. Radimo to kako bismo — možemo naložiti preglednicima da beskonačno keširaju naše JS bundlove, jer ako se bundl promijeni, promijenit će se i njegovo ime datoteke (mijenja se contenthash). Konačni rezultat bit će ime datoteke u obliku game.dbeee76e91a97d0c7207.js.
Datoteka webpack.common.js — to je osnovna konfiguracijska datoteka koju uvozim u razvojne i produkcijske konfiguracije. Evo, na primjer, razvojna konfiguracija:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('.\/webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Za učinkovitost koristimo webpack.dev.js, i prebacuje se na webpack.prod.js, kako bismo optimizirali veličine paketa prilikom implementacije u produkciju.
Lokalna postavka
Preporučujem da projekt postavite na lokalnom računalu kako biste mogli pratiti korake navedene u ovom postu. Postavka je jednostavna: prvo, sustav mora imati instalirane i . Zatim treba izvršiti
$ git clone https:\/\/github.com\/vzhou842\/example-.io-game.git
$ cd example-.io-game
$ npm installi spremni ste za rad! Za pokretanje razvojnog servera dovoljno je izvršiti
$ npm run developi otvoriti u web pregledniku . Razvojni server će automatski ponovo sastaviti JS i CSS pakete tijekom promjena koda — samo osvježite stranicu da biste vidjeli sve promjene!
3. Točke pristupa klijentu
Započnimo s pravim kodom igre. Na početku će nam trebati stranica index.html, prilikom posjeta web stranici, preglednik će je učitati prvu. Naša stranica će biti prilično jednostavna:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Primjer .io igre</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="Korisničko ime" />
<button id="play-button">IGRAJ</button>
</div>
</body>
</html>Ovaj primjer koda je malo pojednostavljen za razumijevanje, isto ću učiniti i s mnogim drugim primjerima u postu. Potpuni kod uvijek možete pregledati na .
Imamo:
- (
<canvas>), koji ćemo koristiti za renderiranje igre. <link>za dodavanje našeg CSS paketa.<script>za dodavanje našeg Javascript paketa.- Glavni izbornik s korisničkim imenom
<input>i gumb „PLAY” ().
Nakon učitavanja početne stranice, Javascript kod će se početi izvršavati, počevši od JS datoteke ulazne točke: 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 = () => {
// Igraj!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});To se može činiti složenim, ali zapravo ovdje se ne događa puno akcije:
- Uvoz nekoliko drugih JS datoteka.
- Uvoz CSS-a (da bi Webpack znao da ih treba uključiti u naš CSS paket).
- Pokretanje
connect()za uspostavljanje veze s serverom i pokretanjedownloadAssets()za preuzimanje slika potrebnih za renderiranje igre. - Nakon završetka faze 3 prikazuje se glavni izbornik (
playMenu). - Postavljanje handlera za klik na gumb „PLAY”. Kada se gumb pritisne, kod inicijalizira igru i javlja serveru da smo spremni igrati.
Glavna "srž" naše klijent-server logike nalaze se u onim datotekama koje su uvezene pomoću datoteke index.js. Sada ćemo ih pregledati redom.
4. Razmjena podataka klijenta
U ovoj igri za komunikaciju sa serverom koristimo dobro poznatu biblioteku . Socket.io ima ugrađenu podršku za , koji su prikladni za dvosmjernu komunikaciju: možemo slati poruke serveru i server može slati poruke nama kroz istu vezu.
Imat ćemo jednu datoteku src/client/networking.js, koja će se baviti svim komunikacijama sa serverom:
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('Povezani na server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
\/\/ Registriraj callback funkcije
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);
};Ovaj kod je također malo skraćen radi razumljivosti.
U ovoj datoteci se odvijaju tri osnovne radnje:
- Pokušavamo se povezati na server.
connectedPromisese izvršava samo kada uspostavimo vezu. - Ako je veza uspješno uspostavljena, registriramo callback funkcije (
processGameUpdate()ionGameOver()) za poruke koje možemo primati sa servera. - Eksportujemo
play()iupdateDirection(), kako bi ih mogli koristiti ostale datoteke.
5. Rendiranje klijenta
Došlo je vrijeme da prikažemo sliku na ekranu!
…ali prije nego što to možemo učiniti, potrebno je preuzeti sve slike (resurse) koji su potrebni za to. Napišimo upravitelja resursa:
assets.js
const ASSET_NAMES = ['ship.svg', 'bullet.svg'];
const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));
function downloadAsset(assetName) {
return new Promise(resolve => {
const asset = new Image();
asset.onload = () => {
console.log(`Preuzeto ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `\/assets\/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Upravljačka funkcija resursima nije tako teška za implementaciju! Glavna poanta je čuvati objekt assets, koji povezuje ključ imena datoteke sa vrijednošću objekta Slika. Kada se resurs učita, spremamo ga u objekt assets za brzi pristup u budućnosti. Kada se dopušta preuzimanje svakog pojedinog resursa (odnosno kada su učitani svi resursi), dopuštamo downloadPromise.
Nakon preuzimanja resursa, možemo preći na rendiranje. Kao što je ranije navedeno, za crtanje na web stranici koristimo (<canvas>). Naša igra je prilično jednostavna, tako da je dovoljno samo prikazati sljedeće:
- Pozadina
- Igračev brod
- Ostale igrače koji se nalaze u igri
- Granate
Evo važnih dijelova src/client/render.js, koji prikazuju točno četiri stavke navedene iznad:
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;
// Dobijte grafički kontekst platna
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Postavite platno na cijeli ekran
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Prikazivanje pozadine
renderBackground(me.x, me.y);
// Prikaz svih granata
bullets.forEach(renderBullet.bind(null, me));
// Prikaz svih igrača
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Pomoćne funkcije ovdje isključene
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Ovaj kod je također skraćen radi razumljivosti.
render() — glavna funkcija ovog datoteke. startRendering() i stopRendering() kontroliraju aktivaciju petlje prikazivanja pri brzini od 60 FPS.
Specifične implementacije pojedinih pomoćnih funkcija prikazivanja (na primjer renderBullet()) nisu toliko važne, ali evo jednostavnog primjera:
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,
);
} Primijetite da koristimo metodu getAsset(), koju smo ranije vidjeli u asset.js!
Ako ste zainteresirani za istraživanje drugih pomoćnih funkcija prikazivanja, pročitajte ostatak .
6. Klijentski unos
Došlo je vrijeme da igru učinimo igrivom! Kontrolna shema će biti vrlo jednostavna: za promjenu smjera kretanja možete koristiti miš (na računalu) ili dodir na ekranu (na mobilnom uređaju). Da bismo to implementirali, registrirat ćemo za događaje Miša i Dodira.
Svime tim će se baviti src/client/input.js:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
} onMouseInput() i onTouchInput() — to su Event Listener-i koji pozivaju updateDirection() (iz networking.js) prilikom događaja unosa (na primjer, kada se pomiče miš). updateDirection() razmjenjuje poruke sa serverom koji obrađuje ulazne događaje i prema tome ažurira stanje igre.
7. Stanje klijenta
Ovaj dio je najkompleksniji u prvom dijelu posta. Ne brinite ako ga ne razumijete iz prve! Čak možete preskočiti i vratiti se kasnije.
Posljednji komad slagalice koji vam treba za završavanje klijentsko-server koda je stanje. Sjećate li se dijela koda iz odjeljka 'Renderiranje klijenta'?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Izvrši renderiranje
// ...
} getCurrentState() treba imati mogućnost pružiti nam trenutno stanje igre na klijentu u bilo kojem trenutku na temelju ažuriranja primljenih od servera. Evo primjera ažuriranja igre koje server može slati:
{
"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": "Igrač",
"score": 3
}
]
}Svako ažuriranje igre sadrži pet istih polja:
- t: vremenska oznaka servera koja označava trenutak kada je ovo ažuriranje kreirano.
- me: informacije o igraču koji prima ovo ažuriranje.
- others: niz informacija o drugim igračima koji sudjeluju u istoj igri.
- bullets: niz informacija o projektilima u igri.
- leaderboard: trenutni podaci o ljestvici. U ovom postu ih nećemo uzimati u obzir.
7.1 Naivno stanje klijenta
Naivna implementacija getCurrentState() može samo izravno vraćati podatke o najnovijem primljenom ažuriranju igre.
naive-state.js
let lastGameUpdate = null;
// Obradi novo primljeno ažuriranje igre.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Jednostavno i jasno! No, da sve može biti tako jednostavno. Jedan od razloga zbog kojih je takva implementacija problematična: ograničava frekvenciju kadrova renderiranjem na frekvenciju takta servera.
Frekvencija kadrova: broj kadrova (tj. poziva
render()) u sekundi, ili FPS. U igrama se obično nastoji postići najmanje 60 FPS.
Frekvencija takta: frekvencija s kojom server šalje ažuriranja igre klijentima. Često je to niže od frekvencije kadrova. U našoj igri server radi na frekvenciji od 30 takta u sekundi.
Ako budemo samo renderirali posljednje ažuriranje igre, FPS nikada neće moći premašiti 30, jer nikada ne primamo od servera više od 30 ažuriranja u sekundi.Čak i ako pozivamo render() 60 puta u sekundi, polovina tih poziva će samo ponovo prikazivati isto, zapravo ne radeći ništa. Jos jedan problem naivne implementacije je u tome što je ona podložna kašnjenju.Pri idealnoj brzini interneta, klijent će primati ažuriranje igre svakih 33 ms (30 u sekundi):

Nažalost, ništa nije idealno. Realnija slika bi bila:

Naivna implementacija je u suštini najgori slučaj kada je u pitanju kašnjenje. Ako se ažuriranje igre prima s kašnjenjem od 50 ms, tada klijent će biti usporen za dodatnih 50 ms, jer i dalje renderira stanje igre iz prethodnog ažuriranja. Možete zamisliti koliko to može biti frustrirajuće za igrača: zbog slučajnih usporavanja igra će izgledati trzavo i nestabilno.
7.2 Poboljšano stanje klijenta
Provest ćemo neka poboljšanja u naivnoj implementaciji. Prvo, koristit ćemo kašnjenje renderiranja od 100 ms. To znači da će "trenutno" stanje klijenta uvijek biti zaostajalo za stanjem igre na serveru za 100 ms. Na primjer, ako je vrijeme na serveru 150, tada će na klijentu biti renderirano stanje koje je server imao u trenutku 50:

To nam daje buffer od 100 ms, omogućavajući izdržavanje nepredvidivog vremena primanja ažuriranja igre:

Cijena za to bit će konstantna od 100 ms. To je mala žrtva za glatku igru — većina igrača (osobito povremenih) čak neće primijetiti ovo kašnjenje. Ljudi se lakše prilagođavaju konstantnom kašnjenju od 100 ms nego igranju s nepredvidivim kašnjenjem.
Možemo koristiti i drugu tehniku poznatu kao , koja dobro funkcioniše u smanjenju percipiranih kašnjenja, ali u ovom postu ona neće biti razmatrana.
Još jedno poboljšanje koje koristimo je linearna interpolacija.Zbog kašnjenja renderiranja, obično zaostajemo najmanje za jedno ažuriranje u klijentu. Kada se poziva getCurrentState(), možemo izvršiti između ažuriranja igre neposredno prije i nakon trenutnog vremena na klijentu:

To rješava problem s brzinom kadrova: sada možemo renderirati jedinstvene kadrove s bilo kojom potrebnom brzinom!
7.3 Implementacija poboljšanog stanja klijenta
Primjer implementacije u src/client/state.js koristi i kašnjenje renderiranja i linearnu interpolaciju, ali to nije dugotrajno. Razdvojimo kod na dva dijela. Ovo je prvi:
state.js, dio 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);
// Zadrži samo jedno ažuriranje igre prije trenutnog vremena servera
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Vraća indeks osnovnog ažuriranja, prvog ažuriranja igre prije
// trenutnog vremena servera, ili -1 ako 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;
} Prvo moramo shvatiti što radi currentServerTime(). Kao što smo ranije vidjeli, svako ažuriranje igre uključuje oznaku vremena servera. Želimo koristiti kašnjenje renderiranja kako bismo rendali sliku s kašnjenjem od 100 ms, ali nikada nećemo saznati trenutno vrijeme na serveru, jer ne možemo znati koliko dugo je trajalo da nam stigne bilo koje od ažuriranja. Internet je nepredvidiv i brzina može značajno varirati!
Da bismo zaobišli ovaj problem, možemo koristiti razumnu aproksimaciju: pretpostavit ćemo da je prvo ažuriranje stiglo trenutno. Da je to točno, znali bismo vrijeme servera u tom konkretnom trenutku! Čuvamo oznaku vremena servera u firstServerTimestamp i čuvamo našu lokalnu (klijentsku) oznaku vremena u istom trenutku u gameStart.
Oh, pričekajte. Zar vrijeme na serveru ne bi trebalo biti isto kao i vrijeme na klijentu? Zašto razlikujemo "označavanje vremena servera" i "označavanje vremena klijenta"? To je odlično pitanje! Ispostavlja se da to nije isto. Date.now() će vraćati različite oznake vremena na klijentu i serveru, a to ovisi o lokalnim faktorima tih mašina. Nikada ne pretpostavljajte da će oznake vremena biti identične na svim mašinama.
Sada razumijemo što radi currentServerTime(): vraća označavanje vremena servera trenutnog vremena renderiranja. Drugim riječima, ovo je trenutno vrijeme poslužitelja (firstServerTimestamp < + (Date.now() - gameStart)) minus kašnjenje renderiranja (RENDER_DELAY).
Sada hajde da razjasnimo kako obrađujemo ažuriranja igre. Kada primimo ažuriranje sa servera, pozivamo processGameUpdate(), i spremamo novo ažuriranje u niz gameUpdates. Tada, da bismo provjerili korištenje memorije, uklanjamo sve stare ažuriranja do osnovnog ažuriranja, jer nam više nisu potrebna.
Što je zapravo „osnovno ažuriranje“? To je prvo ažuriranje koje nalazimo, krećući se unatrag od trenutnog vremena poslužitelja. Sjećate li se ovog dijagrama?

Ažuriranje igre neposredno lijevo od „Client Render Time“ je osnovno ažuriranje.
Zašto se koristi osnovno ažuriranje? Zašto možemo odbaciti ažuriranja do osnovnog? Kako bismo to razjasnili, hajde da napokon razmotrimo implementaciju getCurrentState():
state.js, dio 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),
};
}
}Obrađujemo tri slučaja:
base < 0znači da do trenutnog vremena renderiranja nema ažuriranja (vidi gornju implementacijugetBaseUpdate()). To se može dogoditi odmah na početku igre zbog kašnjenja renderiranja. U tom slučaju koristimo najnovije primljeno ažuriranje.baseje najnovije ažuriranje koje imamo. To se može dogoditi zbog mrežnog kašnjenja ili loše veze s internetom. U tom slučaju također koristimo najnovije ažuriranje koje imamo.- Imamo ažuriranje i prije i nakon trenutnog vremena renderiranja, tako da možemo interpolirati!
Sve što je ostalo u state.js je implementacija linearne interpolacije, koja predstavlja jednostavnu (ali dosadnu) matematiku. Ako želite istražiti to sami, otvorite state.js na .
Dio 2. Backend server
U ovom dijelu ćemo razmotriti backend Node.js, koji upravlja našim .
1. Ulazna točka poslužitelja
Za upravljanje web poslužiteljem koristit ćemo popularni web okvir za Node.js pod nazivom . Njegovu postavku će obaviti naš datoteka ulazne točke poslužitelja src/server/server.js:
server.js, dio 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Postavite Express server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Postavite Webpack za razvoj
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Staticki poslužite dist/ mapu u proizvodnji
app.use(express.static('dist'));
}
// Slušajte na portu
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server sluša na portu ${port}`);Zapamtite, govorili smo o Webpacku u prvom dijelu? Ovo je mjesto gdje ćemo koristiti naše Webpack konfiguracije. Primijenit ćemo ih na dva načina:
- Koristiti za automatsko ponovno sastavljanje naših razvojnih paketa, ili
- Staticki prenositi mapu
dist\, u koju će Webpack zapisivati naše datoteke nakon sastavljanja produkcije.
Još jedan važan zadatak server.js je konfiguriranje servera , koji se jednostavno povezuje s Express serverom:
server.js, dio 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Postavite Express
// ...
const server = app.listen(port);
console.log(`Server sluša na portu ${port}`);
// Postavite socket.io
const io = socketio(server);
// Slušajte za socket.io veze
io.on('connection', socket => {
console.log('Igrač povezan!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Nakon uspješnog uspostavljanja socket.io veze s serverom, postavljamo rukovatelje događaja za novu socket vezu. Rukovatelji događaja upravljaju porukama primljenim od klijenata delegirajući ih objektu singletonu game:
server.js, dio 3
const Game = require('./game');
// ...
// Postavite igru
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Kreiramo .io igru, pa nam treba samo jedan primjerak Game ("Igra") – svi igrači igraju na jednoj areni! U sljedećem dijelu pogledat ćemo kako radi ova klasa Game.
2. Igra servera
Klasa Game sadrži najvažniju logiku na strani servera. Ima dva glavna zadatka: upravljanje igračima i simulacija igre.
Krenimo s prvim zadatkom – upravljanje igračima.
game.js, dio 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;
\/\/ Generirajte poziciju na kojoj će ovaj igrač započeti.
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);
}
}
\/\/ ...
} U ovoj igri ćemo identificirati igrače prema polju id njihovog socket.io (ako ste zbunjeni, vratite se na server.js). Socket.io automatski dodeljuje svakom socket-u jedinstveni id, tako da se o tome ne trebamo brinuti. Nazivaću ga ID igrača.
Zapamtili to, hajde da proučimo instance varijable u klasi Game:
sockets— to je objekat koji povezuje ID igrača sa socket-om koji je povezan s igračem. Omogućava nam da pristupimo socket-ima prema njihovim ID-ima igrača u konstantnom vremenu.players— to je objekat koji povezuje ID igrača s objektom code>Player
bullets — to je niz objekata Bullet, koji nije u određenom redosledu.
lastUpdateTime — to je vremenska oznaka trenutnog poslednjeg ažuriranja igre. Uskoro ćemo videti kako se koristi.
shouldSendUpdate — to je pomoćna varijabla. Njenu upotrebu ćemo takođe uskoro videti.
Metode addPlayer(), removePlayer() i handleInput() ne trebaju dalja objašnjenja, koriste se u server.js. Ako vam treba podsećanje, vratite se malo gore.
Poslednja linija constructor() pokreće ciklus ažuriranja igre (uz učestalost od 60 ažuriranja/s):
game.js, deo 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Izračunaj proteklo vrijeme
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Ažuriraj svaki metak
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Uništi ovaj metak
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Ažuriraj svakog igrača
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Primijeni kolizije, dodijeli igračima bodove za pogođene metke
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),
);
\/\/ Provjeri jesu li igrači mrtvi
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp {
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;
}
}
\/\/ ...
} Metoda ažurirati() sadrži, vjerojatno, najbitniji dio logike na strani servera. Redom ćemo navesti sve što on radi:
- Izračunava koliko je vremena
dtprošlo od zadnjegažurirati(). - Ažurira svaki metak i uništava ih po potrebi. Implementaciju ove funkcionalnosti ćemo vidjeti kasnije. Za sada nam je dovoljno znati da
bullet.update()vraćatrue, ako metak treba biti uništen (izašao je izvan granica arene). - Ažurira svakog igrača i po potrebi kreira metak. Ovu implementaciju ćemo također vidjeti kasnije —
player.update()može vratiti objektBullet. - Provjerava kolizije između metaka i igrača pomoću
applyCollisions(), koji vraća niz metaka koji su pogodili igrače. Za svaki vraćeni metak povećavamo bodove igrača koji ga je ispalio (putemplayer.onDealtDamage()), a zatim uklanjamo metak iz nizabullets. - Obavještava i uništava sve ubijene igrače.
- Šalje svima igračima ažuriranje igre svaki drugi put kada se pozove
ažurirati(). Ovo nam pomaže pratiti gore spomenutu pomoćnu varijablushouldSendUpdate. Tako daažurirati()poziva se 60 puta u sekundi, šaljemo ažuriranja igre 30 puta u sekundi. Tako učestalost takta poslužitelja iznosi 30 takteva u sekundi (o učestalosti takta smo govorili u prvom dijelu).
Zašto slati ažuriranja igre samo svakog drugog ? Kako bismo uštedjeli propusnost. 30 ažuriranja igre u sekundi – to je jako puno!
Zašto jednostavno ne pozivati
ažurirati()30 puta u sekundi? Kako bismo poboljšali simulaciju igre. Što se poziva češće,ažurirati()simulacija igre će biti točnija. No, ne treba pretjerivati s brojem poziva,ažurirati()jer je to računski zahtjeva zadatak – 60 u sekundi je sasvim dovoljno.
Ostatak klase Game se sastoji od pomoćnih metoda koje se koriste u ažurirati():
game.js, dio 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() je prilično jednostavna – sortira igrače prema broju bodova, uzima pet najboljih i vraća za svakog ime korisnika i rezultat.
createUpdate() koristi se u ažurirati() za stvaranje ažuriranja igre koja se šalju igračima. Njegova glavna zadaća je pozvati metode serializeForUpdate(), implementirane za klase Player i Bullet. Imajte na umu da svakom igraču šalje samo podatke o najbližim igračima i projektilima – nema potrebe slati informacije o igrama objektima koji su daleko od igrača!
3. Igrački objekti na poslužitelju
U našoj igri, projektili i igrači zapravo su vrlo slični: to su apstraktni okrugli pokretni igrači objekti. Kako bismo iskoristili ovu sličnost igrača i projektila, počnimo s implementacijom osnovne klase 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,
};
}
} Ovdje se ne događa ništa složeno. Ova klasa će predstavljati dobru osnovu za proširenje. Pogledajmo kako klasa Bullet koristi 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;
}
// Vraća true ako bi metak trebao biti uništen
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementacija Bullet veoma kratak! Dodali smo Object samo sljedeće proširenje:
- Korištenje paketa za slučajnu generaciju
idmetka. - Dodavanje polja
parentID, kako bismo mogli pratiti igrača koji je stvorio ovaj metak. - Dodavanje povratne vrijednosti u
ažurirati(), koja je jednakatrue, ako je metak izvan arene (sjećate li se da smo o tome govorili u prethodnoj sekciji?).
Pređimo na 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;
}
// Vraća novokreirani metak ili null.
update(dt) {
super.update(dt);
// Ažuriraj rezultat
this.score += dt * Constants.SCORE_PER_SECOND;
// Osigurajte da igrač ostane u okviru
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Ispali metak, ako je potrebno
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,
};
}
} Igrači su složeniji od metaka, stoga ova klasa mora imati još nekoliko polja. Njegova metoda ažurirati() obavlja većinu posla, posebno, vraća upravo kreirani metak, ako nema više fireCooldown (sjećate li se da smo o tome govorili u prethodnoj sekciji?). Također proširuje metodu serializeForUpdate(), jer trebamo uključiti dodatna polja za igru u ažuriranje.
Imati osnovnu klasu Object je važan korak koji pomaže u izbjegavanju ponavljanja koda. Na primjer, bez klase Object svaki objekt igre bi trebao imati istu implementaciju distanceTo(), a usklađivanje kopiranja svih tih implementacija u nekoliko datoteka bila bi noćna mora. To postaje posebno važno za velike projekte, kada se broj proširujućih Object klasa povećava.
4. Prepoznavanje kolizija
Jedino što nam preostaje je prepoznati kada metci pogađaju igrače! Podsjetite se ovog isječka koda iz metode ažurirati() u klasi Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Primijenite sudare, dodajte igračima bodove za pogađanje metaka
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),
);
// ...
}
} Moramo implementirati metodu applyCollisions(), koja vraća sve metke koji su pogodili igrače. Srećom, to nije tako teško napraviti, jer
- Svi sudarajući objekti su krugovi, što je najjednostavniji oblik za implementaciju prepoznavanja sudara.
- Već imamo metodu
distanceTo(), koju smo u prethodnom odjeljku implementirali u klasiObject.
Evo kako izgleda naša implementacija prepoznavanja sudara:
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;
}Ovo jednostavno prepoznavanje sudara temelji se na činjenici da dva kruga sudaraju se ako je udaljenost između njihovih središta manja od zbroja njihovih radijusa. Evo slučaja kada je udaljenost između središta dva kruga točno jednaka zbroju njihovih radijusa:

Tu treba pažljivo obratiti pažnju na još nekoliko aspekata:
- Metak ne smije pogoditi igrača koji ga je stvorio. To možemo postići uspoređujući
bullet.parentIDsplayer.id. - Metak treba pogoditi samo jedanput u ograničenom slučaju simultanog sudara s više igrača. Ovu situaciju ćemo riješiti pomoću operatora
break: čim pronađemo igrača koji se sudara s metkom, prestajemo s potragom i prelazimo na sljedeći metak.
Kraj
To je to! Razmotrili smo sve što trebate znati kako biste stvorili web igru žanra .io. Što dalej? Napravite vlastitu .io igru!
Sav kod primjera ima otvorene izvore i dostupan je na .
Izvor: habr.com
