
Lançuar në vitin 2015 u bë paraardhësi i një zhanri të ri , popullariteti i të cilit është rritur ndjeshëm që nga ajo kohë. Unë e kam provuar rritjen e popullaritetit të lojërave .io vetë: gjatë tre viteve të fundit kam .
Nëse rastësisht nuk keni dëgjuar më parë për këto lojëra: ato janë lojëra web për shumë lojtarë që janë falas dhe kanë lehtësi për t'u luajtur (nuk kërkohet një llogari). Në përgjithësi, ato përballin shumë lojtarë përballë njëri-tjetrit në një arenë. Lojërat e tjera të njohura në zhanrin .io: dhe .
Në këtë postim ne do të shqyrtojmë se si të krijojmë një lojë .io nga zero. Mjafton vetëm njohuri të Javascript-it: duhet të kuptoni disa konsepete, si sintaksën , fjalën kyçe this dhe . Edhe nëse nuk e njihni Javascript-in për mrekulli, akoma do të jeni në gjendje të kuptoni pjesën më të madhe të postit.
Shembulli i lojës .io
Për ndihmë në mësim, ne do të referohemi në . Provoni të luani atë!

Lojë është mjaft e thjeshtë: ju kontrolloni një anije në një arenë ku ka lojtarë të tjerë. Anija juaj godet automatikisht me predha dhe përpiqeni të godisni lojtarët e tjerë, duke shmangur në të njëjtën kohë predhat e tyre.
1. Përmbledhje e shkurtër/struktura e projektit
Rekomandoj të shembujt të lojës, në mënyrë që të mund të më ndiqni.
Në shembull përdoren të siguiente:
- — frameworku më popullor i web-it për Node.js, që menaxhon serverin e lojës.
- — biblioteka websocket për shkëmbimin e të dhënave midis shfletuesit dhe serverit.
- — menaxher moduli. Mund të lexoni për arsyen e përdorimit të Webpack .
Ja si duket struktura e katalogut të projektit:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublik/
Gjithçka në dosjen publik/ do të transmetohet statikisht nga serveri. Në publik/assets/ gjenden imazhet që përdor projekti ynë.
src/
Të gjithë kodin burimor e kemi në dosjen src/. Emrat klient/ dhe server/ flasin për veten, dhe ndarë/ përmban skedarin e konstantave, i importuar si nga klienti ashtu edhe nga serveri.
2. Ndërtimi/parameteret e projektit
Siç u tha më parë, për ndërtimin e projektit ne përdorim menaxherin e moduli . Le të shikojmë konfigurimin tonë të Webpack:
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',
}),
],
};Elementët më të rëndësishëm këtu janë radhët e mëposhtme:
src/client/index.js— është pika hyrëse e klientit JavaScript (JS). Webpack do të fillojë nga këtu dhe do të kërkojë në mënyrë rekurzive skedarët e tjerë të importuar.- JS i daljes së ndërtimit tonë Webpack do të vendoset në katalogun
dist/. Unë do ta quaj këtë skedarin tonë paketë JS. - Ne përdorim , dhe veçanërisht konfigurimin për transpilarjen (transpiling) e kodit tonë JS për shfletuesit më të vjetër.
- Ne përdorim një plugin për të nxjerrë të gjithë CSS-në që referohen skedarët JS, dhe për t'i bashkuar ato në një vend. Unë do ta quaj këtë skedarin tonë paketë CSS.
Mund të keni vënë re emra të çuditshëm skedarësh paketash '[name].[contenthash].ext'. Ato përmbajnë Webpack: [name] do të zëvendësohet me emrin e pikës hyrëse (në rastin tonë kjo është lojë), ndërsa [contenthash] do të zëvendësohet me hash-in e përmbajtjes së skedarit. Ne e bëjmë këtë për — mund të urdhërohen shfletuesit të ruajnë përfundimisht paketat tona JS, sepse nëse paketa ndryshon, ndryshon edhe emri i skedarit të saj (ndryshohet contenthash). Rezultati i gatshëm do të jetë emri i skedarit si game.dbeee76e91a97d0c7207.js.
Skedari webpack.common.js — ky është skedari themelor i konfigurimit që ne e importojmë në konfigurimet e zhvillimit dhe projektit të gatshëm. Ja, për shembull, konfigurimi i zhvillimit:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Për efikasitetin, ne përdorim në procesin e zhvillimit webpack.dev.js, dhe kalon në webpack.prod.js, për të optimizuar madhësitë e pakove gjatë shpërndarjes në prodhim.
Konfigurimi lokal
Të rekomandoj të instalossh projektin në makinën lokale, në mënyrë që të mund të ndiqni hapat e përmendur në këtë postim. Konfigurimi është i thjeshtë: së pari, në sistem duhet të jenë instaluar dhe . Më pas duhet të ekzekutoni
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installdhe jeni gati për punë! Për të filluar serverin e zhvillimit, mjafton të ekzekutoni
$ npm run developdhe të shkoni në shfletuesin e uebit në . Serveri i zhvillimit do të ribëhet automatikisht përsëri për paketat JS dhe CSS gjatë ndryshimeve në kod - thjesht rifreskoni faqen për të parë të gjitha ndryshimet!
3. Pikë e hyrjes së klientit
Le të fillojmë me kodin e lojës. Së pari, ne do të na nevojitet faqja index.html, kur të vizitohet faqja e internetit, shfletuesi do ta ngarkojë atë si të parën. Faqja jonë do të jetë mjaft e thjeshtë:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Një shembull loje .io</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="Emri i përdoruesit" />
<button id="play-button">LUJAT</button>
</div>
</body>
</html>Ky shembull kodi është pak i thjeshtuar për qartësi, të njëjtën gjë do ta bëj edhe me shumë shembuj të tjerë. Kodi i plotë gjithmonë mund të shikohet në .
Kemi:
- (
<canvas>), i cili do ta përdorim për renderimin e lojës. <link>për të shtuar paketën tonë CSS.<script>për të shtuar paketën tonë Javascript.- Menuja kryesore me emrin e përdoruesit
<input>dhe butoni “PLAY” ().
Pasi të ngarkohet faqe kryesore në shfletues, do të fillojë të ekzekutohet kodi Javascript, duke filluar nga skedari i hyrjes JS: 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);
};
});Kjo mund të duket e komplikuar, por në të vërtetë këtu nuk po ndodhin aq shumë veprime:
- Importimi i disa skedarëve të tjerë JS.
- Importimi i CSS (që Webpack të dijë se duhet t'i përfshijë ato në paketën tonë CSS).
- Fillimi
connect()për të vendosur një lidhje me serverin dhe nisjendownloadAssets()për të shkarkuar imazhet e nevojshme për renditjen e lojës. - Pasi përfundon faza 3 shfaqet menaxheri kryesor (
playMenu). - Konfigurimi i trajtuesit të klikimit të butonit "LUJ": Kur klikoni butonin, kodi inicializon lojën dhe i thotë serverit se jemi gatim për të luajtur.
Pjesa kryesore e logjikës sonë klient-server gjendet në ato skedarë që u importuan nga skedari index.js. Tani do t'i shqyrtojmë të gjitha radhazi.
4. Shkëmbimi i të dhënave të klientit
Në këtë lojë për komunikimin me serverin përdorim një bibliotekë të njohur . Në Socket.io ka mbështetje të integruar , e cila është shumë e përshtatshme për komunikim në dy drejtime: ne mund të dërgojmë mesazhe serverit dhe serveri mund të na dërgojë mesazhe përmes të njëjtit lidhje.
Do të kemi një skedar src/client/networking.js, i cili do merret me të gjitha komunikimet me serverin:
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('I lidhur me serverin!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Regjistro hollësi
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);
};Ky kod është pak i shkurtuar për qartësi.
Në këtë skedar ndodhin tre veprime kryesore:
- Ne përpiqemi të lidhim me serverin.
connectedPromiselirohet vetëm kur ne kemi vendosur lidhjen. - Nëse lidhja është vendosur me sukses, ne regjistrojmë funksionet e kthimit (
processGameUpdate()dheonGameOver()) për mesazhet që mund të marrim nga serveri. - Eksportojmë
play()dheupdateDirection(), në mënyrë që të përdoren nga skedarë të tjerë.
5. Renderimi i klientit
Arritëm kohën për të shfaqur imazhin në ekran!
…por para se ta bëjmë këtë, duhet të shkarkojmë të gjitha imazhet (burimet) që janë të nevojshme për këtë. Le të shkruajmë menaxherin e burimeve:
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(`E shkarkuar ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Menaxhimi i burimeve nuk është aq i komplikuar! Kjo do të thotë kryesisht të ruash një objekt assets, i cili do të lidhë çelësin e emrit të skedarit me vlerën e objektit Image. Kur burimi të shkarkohet, ne e ruajmë atë në objekt assets për marrjen e shpejtë në të ardhmen. Kur të lejohet shkarkimi i secilit burim të veçantë (dmth. të gjitha все burimet), ne e lejojmë downloadPromise.
Pas dëgjua resurset, mund të filloni me renderimin. Siç u tha më parë, për të vizatuar në faqen e internetit, ne përdorim (<canvas>). Loja jonë është mjaft e thjeshtë, prandaj na mjafton të vizatojmë vetëm të siguiente:
- Sfondi
- Anija e lojtarit
- Lojtarët e tjerë që janë në lojë
- Projektilët
Këto janë fragmente të rëndësishme src/client/render.js, të cilat vizatojnë pikërisht katër pikat e sipërpërmendura:
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;
// Merrni kontekstin grafik të kanavacës
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Bëjeni kanavacën me ekran të plotë
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Vizato sfondin
renderBackground(me.x, me.y);
// Vizato të gjitha projektilët
bullets.forEach(renderBullet.bind(null, me));
// Vizato të gjithë lojtarët
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Funksionet ndihmëse këtu janë përjashtuar
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Ky kod gjithashtu është përcaktuar për qartësi.
render() — funksioni kryesor i këtij skedari. startRendering() dhe stopRendering() menaxhojnë aktivizimin e ciklit të renderimit me një frekuencë prej 60 FPS.
Implementimet specifike të funksioneve ndihmëse të rendering-ut (p.sh. renderBullet()) nuk janë aq të rëndësishme, por ja një shembull i thjeshtë:
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,
);
} Vini re se ne përdorim metodën getAsset(), e cila është parë më parë në asset.js!
Nëse jeni të interesuar të shkoni në detaje për funksionet e tjera ndihmëse të rendering-ut, lexoni pjesën tjetër të .
6. Input-i i klientëve
Ka ardhur koha ta bëjmë lojën lojë të luajtshme! Skema e kontrollit do të jetë shumë e thjeshtë: për të ndryshuar drejtimin e lëvizjes mund të përdorni mouse-in (në kompjuter) ose prekjen e ekranit (në pajisje mobile). Për ta realizuar këtë, ne do të regjistrojmë për ngjarjet Mouse dhe Touch.
Të gjitha këto do të kujdeset 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() dhe onTouchInput() — janë Event Listeners që thërrasin updateDirection() (nga networking.js) kur ngjarja e hyrjes ndodh (p.sh., kur lëvizet sytë). updateDirection() merret me komunikimin me serverin, i cili përpunon ngjarjen e hyrjes dhe përditëson gjendjen e lojës përkatësisht.
7. Gjendja e klientit
Ky seksion është më kompleks në pjesën e parë të postit. Mos u shqetësoni nëse nuk e kuptoni menjëherë! Mund të kaloni edhe më tej dhe të ktheheni më vonë.
Pjesa e fundit e puzzle-it që nevojitet për të përfunduar kodin klient-server është gjendja. A e mbani mend fragmentin e kodit nga seksioni 'Renderimi i klientit'?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Bëni renderimin
// ...
} getCurrentState() duhet të jetë në gjendje të na ofrojë statusin aktual të lojës në klient në çdo moment të caktuar ndërsa merr përditësime nga serveri. Këtu është një shembull përditësimi të lojës që mund të dërgojë serveri:
{
"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
}
]
}Çdo përditësim i lojës përmban pesë fusha identike:
- t: marka e kohës së serverit, e cila tregon momentin e krijimit të kësaj përditësimi.
- me: informacion mbi lojtarin që merr këtë përditësim.
- others: një listë informacioni për lojtarët e tjerë që marrin pjesë në të njëjtën lojë.
- bullets: një listë informacioni për projektilët në lojë.
- leaderboard: të dhënat aktuale të tabelës së liderëve. Në këtë postim ne nuk do t’i marrim parasysh ato.
7.1 Gjendja naive e klientit
Implementimi naiv getCurrentState() mund vetëm të kthejë drejtpërdrejt të dhënat e përditësimit më të fundit të marrë të lojës.
naive-state.js
let lastGameUpdate = null;
// Përdorimi i përditësimit të lojës së sapo pranuar.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}E bukur dhe e qartë! Por nëse do të kishte qenë kaq e thjeshtë. Një nga arsyet për të cilat një zbatim i tillë është problematik: ajo kufizon frekuencën e kadrove të renderimit me frekuencën e taktave të serverit.
Frekuenca e kadrove (Frame Rate): numri i kadrove (dmth. thirrjeve
render()) në sekondë, ose FPS. Në lojëra, zakonisht synohet të arrihet të paktën 60 FPS.
Frekuenca e taksave (Tick Rate): frekuenca me të cilën serveri dërgon përditësime loje tek klientët. Shpesh ajo është më e ulët se frekuenca e kadrove. Në lojën tonë, serveri punon me një frekuencë prej 30 taktash në sekondë.
Nëse vetëm do të donim të renderonim përditësimin e fundit të lojës, atëherë FPS në thelb asnjëherë nuk do të mund të kalonte 30, sepse ne asnjëherë nuk marrim më shumë se 30 përditësime nga serveri në sekondë. Edhe nëse do të thërrisnim render() 60 herë në sekondë, gjysma e këtyre thirrjeve do të ishte thjesht rrisje e të njëjtës gjë, në thelb duke mos bërë asgjë. Një tjetër problem i realizimit naiv është se ajo është e ndjeshme ndaj vonesave. Me një shpejtësi ideale të internetit, klienti do të merrte përditësimin e lojës çdo 33 ms (30 herë në sekondë):

Fatkeqësisht, asgjë nuk është perfekte. Një pamje më realiste do të jetë:

Implementimi naiv është gati rastin më të keq kur bëhet fjalë për vonesat. Nëse përditësimi i lojës pranohet me një vonesë prej 50 ms, atëherë klienti ngadalësohet për 50 ms të tjerë, sepse ai vazhdon të renderizojë gjendjen e lojës nga përditësimi i mëparshëm. Mund ta imagjinoni sa e padëshirueshme është kjo për lojtarin: për shkak të ngadalësimeve të rastësishme, loja do të duket e ndarë dhe e paqëndrueshme.
7.2 Gjendja e përmirësuar e klientit
Do të bëjmë disa përmirësime në implementimin naiv. Së pari, do të përdorim vonimin e renderizimit prej 100 ms. Kjo do të thotë që gjendja "aktuale" e klientit gjithmonë do të vonohet nga gjendja e lojës në server për 100 ms. Për shembull, nëse në server koha është 150, atëherë në klient do të renderizohet gjendja në të cilën ishte serveri në momentin 50:

Kjo na jep një buffer prej 100 ms, duke na lejuar të përballojmë kohët e pamatura të marrjes së përditësimeve të lojës:

Kostoja për këtë do të jetë vonesë e vazhdueshme në 100 ms. Kjo është një sakrificë e vogël për një përvojë loje të qetë — shumica e lojtarëve (sidomos ata casual) ndoshta as nuk do ta vënë re këtë vonesë. Njerëzit gjithmonë i përshtaten më lehtë një vonese konstante prej 100 ms, sesa të luajnë me vonesa të paparashikueshme.
Ne mund të përdorim edhe një teknikë tjetër të quajtur , e cila përballet mirë me uljen e vonesave të perceptuara, por nuk do të diskutohet në këtë postim.
Një përmirësim tjetër që përdorim është interpolimi linear. Për shkak të vonesës së renderimit, ne zakonisht e kalojmë aktualisht të paktën një përditësim përpara. Kur thirret getCurrentState(), ne mund të kryejmë midis përditësimeve të lojës menjëherë përpara dhe pas kohës aktuale në klient:

Kjo zgjidh problemin me frekuencën e kadrëve: tani ne mund të renderojmë kadrat unikë me çdo frekuencë që na nevojitet!
7.3 Zbatimi i gjendjes së përmirësuar të klientit
Një shembull i zbatimit në src/client/state.js përdor si vonesën e renderimit ashtu edhe interpolimin linear, por kjo është e përkohshme. Le ta ndajmë kodin në dy pjesë. Ja e para:
state.js, pjesa 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);
// Mbajmë vetëm një përditësim të lojës para kohës aktuale të serverit
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Kthen indeksin e përditësimit bazë, përditësimin e parë të lojës para
// kohës aktuale të serverit, ose -1 nëse s'ka.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
} Së pari duhet të kuptojmë se çfarë bën currentServerTime(). Siç e kemi parë më parë, çdo përditësim i lojës përfshin një shenjë kohore të serverit. Ne duam të përdorim vonesën në renderim për të renderuar pamjen, duke u vonuar nga serveri me 100 ms, por ne kurrë nuk do ta dimë, koha aktuale në server, sepse nuk e dimë se sa kohë ka marrë çdo përditësim për të arritur te ne. Interneti është i paparashikueshëm dhe shpejtësia e tij mund të ndryshojë shumë!
Për të anashkaluar këtë problem, mund të përdorim një afrim të arsyeshëm: ne do të bëjmë sikur përditësimi i parë ka ardhur menjëherë. Nëse kjo do të ishte e vërtetë, ne do ta dinim timestamp-in e serverit në atë moment të caktuar! Ne ruajmë timestamp-in e serverit në firstServerTimestamp dhe ruajmë timestamp-in tonë (klientit) në të njëjtin moment në gameStart.
Ooh, ndaloni pak. A nuk duhet të jetë koha në server = koha në klient? Pse e ndajmë 'timestamp-in e serverit' dhe 'timestamp-in e klientit'? Kjo është një pyetje e shkëlqyer! Në fakt, ato nuk janë të njëjta. Date.now() do të kthejë timestamp-e të ndryshme në klient dhe server, dhe kjo varet nga faktorët lokalë të këtyre makinave. Mos supozoni kurrë se timestamp-et do të jenë të njëjta në të gjitha makinat.
Tani është e qartë se çfarë bën currentServerTime(): kthen timestamp-in aktual të serverit në kohën e renderimit. Në fjalë të tjera, kjo është koha aktuale e serverit (firstServerTimestamp <+ (Date.now() - gameStart)) minus vonesën e renderimit (RENDER_DELAY).
Tani le të shqyrtojmë se si i trajtojmë përditësimet e lojës. Kur marrim një përditësim nga serveri, ne thërrasim processGameUpdate(), dhe ne ruajmë përditësimin e ri në array gameUpdates. Pastaj, për të kontrolluar përdorimin e memories, ne fshijmë të gjitha përditësimet e vjetra deri në përditësimin bazë, sepse ata nuk na nevojiten më.
Çfarë është “përditësimi bazë”? Ky është përditësimi i parë që gjejmë, duke lëvizur prapa nga koha aktuale e shërbimit. A e mbani mend këtë diagram?

Përditësimi i lojës ndodhet direkt në të majtë të “Koha e Renderimit të Klientit” dhe është një përditësim bazë.
Për çfarë përdoret përditësimi bazë? Pse mund të hedhim poshtë përditësimet deri në bazë? Për ta kuptuar këtë, le të më në fund shikojmë implementimin getCurrentState():
state.js, pjesa 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),
};
}
}Ne trajtojmë tri raste:
base < 0do të thotë që deri në kohën aktuale të renderimit nuk ka përditësimeve (shihni implementimin më sipër tëgetBaseUpdate()). Kjo mund të ndodhë menjëherë në fillim të lojës për shkak të vonesës në renderim. Në këtë rast, përdorim përditësimin më të fundit që kemi marrë.bazë— është përditësimi më i fundit që kemi. Kjo mund të ndodhë për shkak të vonesës në rrjet ose lidhjes së dobët me internetin. Në këtë rast, gjithashtu përdorim përditësimin më të fundit që kemi.- Ne kemi përditësim para dhe pas kohës aktuale të renderimit, kështu që mund të interpolojmë!
Gjithçka që mbetet në state.js — është realizimi i interpolimit linear, që përfaqëson matematikë të thjeshtë (por të mërzitshme). Nëse dëshironi ta studioni vetë, hapni state.js në .
Pjesa 2. Serveri i backend-it
Në këtë pjesë ne do të shqyrtojmë backend-in Node.js që menaxhon .
1. Pikënisja e serverit
Për të menaxhuar serverin web, ne do të përdorim një framework të njohur web për Node.js të quajtur . Konfigurimi i tij do të përfaqësohet nga skedari ynë i pikënisjes së serverit src/server/server.js:
server.js, pjesa 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Vendosni një server Express
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Vendosni Webpack për zhvillim
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Shërbimi statik i folderit dist/ në prodhim
app.use(express.static('dist'));
}
// Dëgjoni në port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);Mos harroni, që në pjesën e parë ne diskutuam për Webpack? Këtu do të përdorim konfigurimet tona të Webpack. Ne do t'i aplikojmë ato në dy mënyra:
- Përdorni për rinovimin automatik të paketave tona të zhvillimit, ose
- Shërbimi statik i folderit
dist/, në të cilin Webpack do të regjistrojë skedarët tanë pas ndërtimit të prodhimit.
Një detyrë tjetër e rëndësishme server.js është konfigurimi i serverit , i cili thjesht lidhet me serverin Express:
server.js, pjesa 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Konfiguro Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
// Konfiguro socket.io
const io = socketio(server);
// Dëgjo lidhjet socket.io
io.on('connection', socket => {
console.log('Lojtari u lidh!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Pas një lidhjeje të suksesshme të socket.io me serverin, ne konfigurimi i trajtuesve të ngjarjeve për soketët e rinj. Trajtuesit e ngjarjeve trajtojnë mesazhet e marra nga klientët duke deleguar te objekti singleton lojë:
server.js, pjesa 3
const Game = require('./game');
// ...
// Konfiguro lojën
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Ne krijojmë një lojë të zhanrit .io, prandaj na nevojitet vetëm një instancë Game («Game») – të gjithë lojtarët luajnë në një arenë! Në seksionin e ardhshëm do të shohim si funksionon kjo klasë Game.
2. Serveri i Gamës
Klasa Game përmban logjikën më të rëndësishme në anën e serverit. Ka dy detyra kryesore: menaxhimi i lojtarëve dhe simulimi i lojës.
Le të fillojmë me detyrën e parë – menaxhimin e lojtarëve.
game.js, pjesa 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;
// Gjeneroni një pozicioni për ta nisur këtë lojtar.
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);
}
}
// ...
} Në këtë lojë ne do të identifikojmë lojtarët sipas fushës id soketit të tyre socket.io (nëse je ngatërruar, kthehu sërish tek server.js). Socket.io vetë cakton çdo soket një idunike, prandaj nuk duhet të shqetësohemi për këtë. Do ta quaj ID e lojtarit.
Duke e mbajtur mend këtë, le të shqyrtojmë variablat e instancës në klasën Game:
sockets— është një objekt që lidh ID-në e lojtarit me socket-in që është i lidhur me lojtarin. Ai na lejon të aksesojmë sockets sipas ID-ve të lojtarëve në kohë konstante.lojtarët— është një objekt që lidh ID-në e lojtarit me objektin code>Player
bullets — është një array objektesh Bullet, i cili nuk ka një rend specifik.
kohaEUpdate — është një timestamp që tregonte momentin e fundit të përditësimit të lojës. Së shpejti do të shohim se si përdoret.
duhetDërguarUpdate — është një variabël ndihmës. Përdorimi i saj do të shohim përsëri së shpejti.
Metodat shtoLojtar(), heqLojtar() dhe përdorInput() nuk ka nevojë të shpjegohet, ato përdoren në server.js. Nëse ju nevojitet një rikujtim, kthehuni pak lart.
Rreshti i fundit konstruktori() aktivizon cikli i përditësimit të lojës (me frekuencë 60 përditësime/s):
game.js, pjesa 2
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Llogaritni kohëzgjatjen
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Përditëso secilën plumb
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// Shkatërro këtë plumb
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Përditëso secilin lojtar
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Apliko përplasjet, jep pikët lojtarëve për goditjen e plumbave
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),
);
// Kontrolloni nëse ndonjë lojtar është i vdekur
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);
}
});
// Dërgoni një përditësim të lojës për secilin lojtar çdo herë tjetër
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;
}
}
// ...
} Metoda update() përmban, ndoshta, pjesën më të rëndësishme të logjikës në server. Me radhë, do të numërojmë gjithçka që ai bën:
- Llogarit se sa kohë
dtka kaluar që nga e funditupdate(). - Përditëson çdo plumb dhe, nëse është e nevojshme, i zhduka ata. Ne do ta shohim implementimin e kësaj funksionaliteti më vonë. Tani është e mjaftueshme të dimë se
bullet.update()kthene vërtetë, nëse plumbi duhet të zhduket (ai ka dalë jashtë kufijve të arenës). - Përditëson çdo lojtar dhe, nëse është e nevojshme, krijon një plumb. Ne do ta shohim këtë implementim gjithashtu më vonë —
player.update()mund të kthejë një objektBullet. - Kontrollon kolizionet midis plumbave dhe lojtarëve me anë të
applyCollisions(), e cila kthen një varg plumbash që kanë goditur lojtarët. Për çdo plumb të kthyer, ne rrisim pikët e lojtarit që e lëshoi atë (me anë tëplayer.onDealtDamage()), e pastaj e heqim plumbin nga vargubullets. - Njofton dhe shkatërron të gjithë lojtarët e vrarë.
- Dërgon të gjithë lojtarëve një përditësim të lojës çdo të dyta herë në thirrje
update(). Kjo na ndihmon të ndjekim variablën ndihmëse të përmendur më lartduhetDërguarUpdate. Pasiqëupdate()thirret 60 herë/s, ne dërgojmë përditësime të lojës 30 herë/s. Kështu, frekuenca e takteve Shërbimi i serverit është 30 takt/s (folëm për frekuencën e takteve në pjesën e parë).
Pse të dërgoj vetëm përditësime të lojës në çdo herë ? Për të kursyer bandën e fundit. 30 përditësime të lojës në sekondë janë shumë!
Pse thjesht nuk e thërrasim
update()30 herë në sekondë? Për të përmirësuar simulimin e lojës. Sa më shpesh të thirretupdate(), aq më preciz do të jetë simulimi i lojës. Por nuk duhet të mbivlerësojmë numrin e thirrjeveupdate(), sepse kjo është një detyrë e shpenzueshme në mënyrë të konsiderueshme — 60 në sekondë është mjaft.
Pjesa tjetër e klasës Game përbëhet nga metoda ndihmëse që përdoren në update():
game.js, pjesa 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() mjaft e thjeshtë – rendit lojtarët sipas numrit të pikëve, merr pesë të parët dhe kthen për secilin emrin e përdoruesit dhe rezultatin.
createUpdate() përdoret në update() për të krijuar përditësimet e lojës që dërgohen te lojtarët. Detyra e saj kryesore është të thërrasë metodat serializeForUpdate(), të implementuara për klasat Player dhe Bullet. Vini re se ajo i dërgon çdo lojtar vetëm të dhëna për lojtarët e afërt dhe municionet – nuk ka nevojë të dërgoni informacion rreth objekteve të lojës që janë larg nga lojtari!
3. Objektet e lojës në server
Në lojën tonë, municionet dhe lojtarët në të vërtetë janë shumë të ngjashëm: ato janë objekte abstrakte rrethore dhe të lëvizshme të lojës. Për të përfituar nga kjo ngjashmëri e lojtarëve dhe municioneve, le të fillojmë me implementimin e klasës bazë 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,
};
}
} Këtu nuk ka asgjë të komplikuar. Kjo klasë do të jetë një pikë e mirë mbështetje për zgjerim. Le të shohim si funksionon klasa Bullet përdor 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;
}
// Kthehet e vërtetë nëse projektili duhet të shkatërrohet
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Realizimi Bullet shumë e shkurtër! Ne e kemi shtuar në Object vetëm këto zgjerime:
- Përdorimi i paketës për gjenerim të rastësishëm
idprojektili. - Shtimi i fushës
parentID, që munden të ndjekin lojtarin që krijoi këtë projektil. - Shtimi i një vlerë kthimi në
update(), që ështëe vërtetë, nëse projektili është jashtë arenës (mos harroni, bëmë fjalë për këtë në seksionin e kaluar?).
Le të kalojmë te 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,
};
}
} Lojtarët janë më të komplikuar se projektilet, prandaj ky klasë duhet të mbajë edhe disa fusha të tjera. Metoda e tij update() bën më shumë punë, përkatësisht, kthen vetëm një plumb të sapo krijuar, nëse nuk ka mbetur fireCooldown (mos harroni, ne e thamë këtë në seksionin e mëparshëm?). Ai gjithashtu zgjeron metodën serializeForUpdate(), sepse na nevojitet të përfshijmë në përditësimin e lojës fusha shtesë për lojtarin.
Prania e klasës bazë Object — një hap i rëndësishëm që ndihmon në shmangien e përsëritjes së kodit. Për shembull, pa klasën Object çdo objekt lojërash duhet të ketë një implementim të njëjtë distanceTo(), dhe sinkronizimi i kopjeve të të gjithë këtyre implementimeve në disa skedarë do të ishte një makth. Kjo bëhet veçanërisht e rëndësishme për projektet e mëdha, kur numri i klasave të zgjeruara Object rritet.
4. Njohja e kolizioneve
E vetmja gjë që mbetet të bëjmë është të njihni kur projektilet godasin lojtarët! Kujtoni këtë fragment kodi nga metoda update() në klasën Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Aplikoni kolizionet, jepni lojtarëve pikë për goditjen e projektileve
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),
);
// ...
}
} Na nevojitet të implementojmë metodën applyCollisions(), e cila kthej gjithsej projektilet që godasin lojtarët. Fatmirësisht, kjo nuk është aq e vështirë për t'u realizuar, sepse
- Të gjitha objektet që përplasen janë rrethana, dhe kjo është figura më e thjeshtë për të implementuar njohjen e kolizioneve.
- Kemi tashmë një metodë
distanceTo(), të cilin kemi implementuar në seksionin e kaluar në klasëObject.
Ja si duket integrimi ynë i njohjes së përplasjeve:
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;
}Kjo njohje e thjeshtë e përplasjeve bazohet në faktin se dy rrethe përplasen nëse distanca mes qendrave të tyre është më e vogël se shuma e rrezeve të tyre. Ja një rast kur distanca mes qendrave të dy rretheve është pikërisht e baraslarguar me shumën e rrezeve të tyre:

Këtu duhet të kemi parasysh edhe disa aspekte të tjera:
- Projektili nuk duhet të godasë lojtarin që e krijoi. Kjo mund të arrihet duke krahasuar
bullet.parentIDmeplayer.id. - Projektili duhet të godasë vetëm një herë në rastin e përplasjes me disa lojtarë. Këtë problem do ta zgjidhim me operatorin
break: sa herë që të gjendet një lojtar që është përplasur me projektilin, ne ndalojmë kërkimin dhe kalojmë te projektili tjetër.
Fund
Këtu jemi! Ne mësuam gjithçka që duhej të dinim për krijimin e një loje në stilin .io. Çfarë ndodh më pas? Krijoni lojën tuaj .io!
I gjithë kodi i shembullit ka burime të hapura dhe është publikuar në .
Burimi: habr.com
