
KĂ€ivitati 2015. aastal sai uue ĆŸanri , mille populaarsus on sellest ajast palju kasvanud. Olen kogenud .io mĂ€ngude populaarsuse kasvu ise: viimase kolme aasta jooksul olen .
Kui te pole kunagi varem sellistest mĂ€ngudest kuulnud: need on tasuta mitmikmĂ€ngu veebimĂ€ngud, kuhu on lihtne liituda (kasutajakonto pole vajalik). TĂŒĂŒpiliselt toovad nad kokku palju vastaseid mĂ€ngijaid ĂŒhel areenil. Teised tuntud .io ĆŸanri mĂ€ngud: ja .
Selles postituses uurime, kuidas nullist luua .io mĂ€ngu. Selleks on vaja vaid Javascripti teadmist: peate mĂ”istma selliseid asju nagu sĂŒntaks , mĂ€rksĂ”na this ja . Isegi kui te ei valda Javascripti tĂ€ielikult, saate siiski enamikus postitusest hakkama.
NÀide .io mÀngust
Koolituse toetamiseks viitame . Proovige seda mÀngida!

MĂ€ng on ĂŒsna lihtne: juhtite laeva areenil, kus on teised mĂ€ngijad. Teie laev tulistab automaatselt projekte ja proovite tabada teisi mĂ€ngijaid, samal ajal vĂ€ltides nende projekte.
1. Projekte ĂŒlevaade / struktuur
Soovitan nÀidismÀngust, et saaksite minuga jÀrgida.
NÀites kasutatakse jÀrgmist:
- on kÔige populaarsem veebiraamistik Node.js jaoks, mis haldab mÀngu veebiserverit.
- on websocketteek, mis vÔimaldab andmete vahetamist brauseri ja serveri vahel.
- on moodulihaldur. Webpacki kasutamise pÔhjustest saate lugeda .
Nii nÀeb projekti katalooge struktuur vÀlja:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
KÔik, mis on kaustas public/ edastatakse serveri poolt staatiliselt. Kaustas public/assets/ on meie projektis kasutatavad pildid.
src/
Kogu lÀhtekood asub kaustas src/. Nimetused client/ ja server/ rÀÀgivad enda eest, ning shared/ sisaldab konstantsfaili, mida impordivad nii klient kui ka server.
2. Kogumid / projekti seaded
Nagu ĂŒlal mainitud, kasutame projekti kogumiseks moodulihaldurit . Vaatame meie Webpacki konfiguratsiooni:
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',
}),
],
};Siin on kÔige olulisemad read:
src/client/index.jsâ see on JavaScripti (JS) kliendi sisenemispunkt. Webpack alustab siit ja otsib rekursiivselt teisi imporditud faile.- Meie Webpacki vĂ€ljund-JS asub kaustas
dist/. Ma nimetan seda faili meie JS paketiks. - Me kasutame , ja konkreetselt seadistust , et transpileerida meie JS kood vanematesse brauseritesse.
- Kasutame pluginat, mis ekstraktib kĂ”ik CSS-id, mida JS failid viitavad, ja ĂŒhendab need ĂŒhte kohta. Ma nimetan seda meie CSS paketiks.
VĂ”isite mĂ€rgata kummalisi failinimesid pakettide kohta '[name].[contenthash].ext'. Need to include Webpack: [name] will be replaced by the entry point name (in our case it's game), and [contenthash] will be replaced by the content hash of the file. We do this to â allowing browsers to cache our JS bundles indefinitely because if the bundle changes, its file name changes as well (it changes contenthash). The resulting file name will look like game.dbeee76e91a97d0c7207.js.
File webpack.common.js â this is the basic configuration file that we import into the development and production configurations. Here, for example, is the development configuration:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); For efficiency, we use during development webpack.dev.js, and switches to webpack.prod.js, to optimize bundle sizes for production deployment.
Local setup
I recommend setting up the project on your local machine so you can follow the steps outlined in this post. The setup is simple: first, you need to have ja installed on your system. Next, run
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installja olete valmis tööle! Arendusteenuse kÀivitamiseks piisab, kui tÀita
$ npm run developja avada veebibrauseris . Arendusteenus koostab JS ja CSS paketid automaatselt uuesti, kui koodimuudatused on tehtud â lihtsalt uuendage lehte, et nĂ€ha kĂ”iki muudatusi!
3. Klientide sisenemispunktid
Alustame mĂ€ngu koodist. Esiteks vajame lehte index.html, mille brauser laadib saidi kĂŒlastamisel esimesena. Meie leht saab olema ĂŒsna lihtne:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Näide .io mängust</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="Kasutajanimi" />
<button id="play-button">MÄNGI</button>
</div>
</body>
</html>See koodinÀide on pisut lihtsustatud arusaadavuse huvides; sama teen ka paljude teiste postituse nÀidete puhul. TÀielikku koodi saab alati vaadata .
Meil on:
- (
<canvas>), mida kasutame mĂ€ngu renderdamiseks. <link>meie CSS paketi lisamiseks.<script>meie Javascript paketi lisamiseks.- PeamenĂŒĂŒ kasutajanimega
<input>ja nupp "PLAY" ().
PÀrast esilehe laadimist hakkab brauseris kÀima Javascript-kood, alustades jÀrgmise sissepÀÀsufailiga: 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 = () => {
// MĂ€ngi!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});See vÔib tunduda keeruline, kuid tegelikult toimub siin mitte nii palju toiminguid:
- Mitme muu JS-faili importimine.
- CSS-i importimine (et Webpack teaks, et tuleb need meie CSS-paketti lisada).
- KĂ€ivita
connect()serveriga ĂŒhenduse loomiseks ja mĂ€ngu kĂ€ivitamiseks.downloadAssets()mĂ€ngu renderdamiseks vajalike piltide allalaadimiseks. - Muidugi, pĂ€rast 3. etapi lĂ”petamist kuvatakse peamenĂŒĂŒ (
playMenu). - Nuppu 'PLAY' vajutamise kÀitleja seadistamine. Nupule vajutamisel algatab kood mÀngu ja teavitab serverit, et oleme mÀngimiseks valmis.
Meie kliendi-serveri loogika peamine osa asub nendes failides, mis imporditi failist. index.js. NĂŒĂŒd vaatame neid kĂ”iki jĂ€rjekorras.
4. Kliendi andmevahetus
Selles mĂ€ngus serveriga suhtlemiseks kasutame hĂ€sti tuntud teeki . Socket.io-l on sisseehitatud tugi , mis sobivad suurepĂ€raselt kahepoolsesse suhtlemisse: saame serverile sĂ”numeid saata ja server saab meile sama ĂŒhenduse kaudu sĂ”numeid saata.
Meil on ĂŒks fail src/client/networking.js, mis tegeleb kĂ”igi serveriga suhtlemisega:
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(() => {
// Registreeri tagasiside sisestused
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);
};See kood on arusaadavuse huvides samuti veidi lĂŒhendatud.
Selles failis toimub kolm peamist tegevust:
- Proovime serveriga ĂŒhendada.
connectedPromiselahendatakse ainult siis, kui oleme ĂŒhenduse loonud. - Kui ĂŒhendus on edukalt loodud, registreerime callback-funktsioonid (
processGameUpdate()jaonGameOver()) serverilt saadud sÔnumite jaoks. - Ekspordime
play()jaupdateDirection(), et neid saaksid kasutada teised failid.
5. Kliendi renderdamine
On aeg kuvada ekraanile pilt!
âŠkuid enne seda peame alla laadima kĂ”ik pildid (ressursid), mis selleks vajalikud. Kirjutame ressursihalduri:
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(`Downloaded ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Ressursside haldamine ei ole nii keeruline! Peamine idee on sÀilitada objekt assets, mis seob faili nime vÔtme objekti vÀÀrtusega Image. Kui ressurss on alla laaditud, salvestame selle objekti assets kiireks hankimiseks tulevikus. Kui iga eraldi ressursi allalaadimine lubatakse (st ressursid on laetud), lubame kÔik ressursid), me lubame downloadPromise.
Allala saadud ressursid, saab alustada renderdamist. Nagu varem öeldud, kasutame veebilehe joonistamiseks (<canvas>). Meie mĂ€ng on ĂŒsna lihtne, seega piisab, kui joonistame vaid jĂ€rgmised elemendid:
- Taust
- MĂ€ngija laev
- Teised mÀngijad, kes on mÀngus
- KĂŒtted
Siin on olulised fragmendid src/client/render.js, mis joonistavad just nimelt ĂŒlaltoodud neli punkti:
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;
// Hangi kanva graafikakontekst
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Tee kanvas tÀisekraaniks
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Joonista taust
renderBackground(me.x, me.y);
// Joonista kĂ”ik kĂŒtted
bullets.forEach(renderBullet.bind(null, me));
// Joonista kÔik mÀngijad
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Abifunktsioonid siin vÀlja jÀetud
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}See kood on ka lĂŒhendatud selguse huvides.
render() â selle faili peamine funktsioon. startRendering() ja stopRendering() hallitsevad renderdamise tsĂŒkli aktiveerimist sagedusel 60 FPS.
Teatud renderdamise abifunktsioonide konkreetsed teostused (nt renderBullet()) ei ole nii olulised, kuid siin on ĂŒks lihtne nĂ€ide:
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,
);
} Pange tÀhele, et kasutame meetodit getAsset(), mida oleme varem nÀinud asset.js!
Kui teid huvitab teiste renderdamise abifunktsioonide uurimine, lugege ĂŒlejÀÀnud osa .
6. Klientide sisend
On aeg muuta mĂ€ng mĂ€ngitavaks! KontrollisĂŒsteem on vĂ€ga lihtne: liikumissuuna muutmiseks saab kasutada hiirt (arvutis) vĂ”i ekraani puudutamist (mobiilseadmest). Selle teostamiseks registreerime hiire ja puutetundlike sĂŒndmuste jaoks.
KÔigega tegeleb 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() ja onTouchInput() â on Event Listeners, ĐČŃĐ·ŃĐČаŃŃОД updateDirection() (allikas networking.js) ĐżŃĐž ŃĐŸĐČĐ”ŃŃĐ”ĐœĐžĐž ŃĐŸĐ±ŃŃĐžŃ ĐČĐČĐŸĐŽĐ° (ĐœĐ°ĐżŃĐžĐŒĐ”Ń, ĐżŃĐž пДŃĐ”ĐŒĐ”ŃĐ”ĐœĐžĐž ĐŒŃŃĐž). updateDirection() ĐŸŃŃŃĐ”ŃŃĐČĐ»ŃĐ”Ń ŃĐČŃĐ·Ń Ń ŃĐ”ŃĐČĐ”ŃĐŸĐŒ, ĐșĐŸŃĐŸŃŃĐč ĐŸĐ±ŃабаŃŃĐČĐ°Đ”Ń ŃĐŸĐ±ŃŃОД ĐČĐČĐŸĐŽĐ° Đž ŃĐŸĐŸŃĐČĐ”ŃŃŃĐČŃŃŃĐžĐŒ ĐŸĐ±ŃĐ°Đ·ĐŸĐŒ ĐŸĐ±ĐœĐŸĐČĐ»ŃĐ”Ń ŃĐŸŃŃĐŸŃĐœĐžĐ” ОгŃŃ.
7. ĐĐ»ĐžĐ”ĐœŃŃĐșĐŸĐ” ŃĐŸŃŃĐŸŃĐœĐžĐ”
ĐŃĐŸŃ ŃазЎДл â ŃĐ°ĐŒŃĐč ŃĐ»ĐŸĐ¶ĐœŃĐč ĐČ ĐżĐ”ŃĐČĐŸĐč ŃаŃŃĐž ĐżĐŸŃŃа. ĐĐ” ŃаŃŃŃŃаОĐČаĐčŃĐ”ŃŃ, Đ”ŃлО ĐœĐ” ĐżĐŸĐčĐŒŃŃĐ” Đ”ĐłĐŸ Ń ĐżĐ”ŃĐČĐŸĐłĐŸ ĐżŃĐŸŃŃĐ”ĐœĐžŃ! ĐĐŸĐ¶Đ”ŃĐ” ЎажД ĐżŃĐŸĐżŃŃŃĐžŃŃ Đ”ĐłĐŸ Đž ĐČĐ”ŃĐœŃŃŃŃŃ Đș ĐœĐ”ĐŒŃ ĐżĐŸĐ·Đ¶Đ”.
ĐĐŸŃĐ»Đ”ĐŽĐœĐžĐč ĐșŃŃĐŸĐș пазла, ĐșĐŸŃĐŸŃŃĐč ĐœŃĐ¶Đ”Đœ ĐŽĐ»Ń Đ·Đ°ĐČĐ”ŃŃĐ”ĐœĐžŃ ĐșĐ»ĐžĐ”ĐœŃ-ŃĐ”ŃĐČĐ”ŃĐœĐŸĐłĐŸ ĐșĐŸĐŽĐ° â ŃŃĐŸ state. ĐĐŸĐŒĐœĐžŃĐ” ŃŃĐ°ĐłĐŒĐ”ĐœŃ ĐșĐŸĐŽĐ° Оз ŃазЎДла Â«Đ Đ”ĐœĐŽĐ”ŃĐžĐœĐł ĐșĐ»ĐžĐ”ĐœŃа»?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// ĐŃĐżĐŸĐ»ĐœĐžŃĐ” ŃĐ”ĐœĐŽĐ”ŃĐžĐœĐł
// ...
} getCurrentState() peab olema suuteline andma meile mÀngu hetkeseisu kliendis igast ajast serverilt saadud vÀrskenduste pÔhjal. Siin on nÀide mÀngu vÀrskendusest, mida server vÔib saata:
{
"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
}
]
}Iga mÀngu vÀrskendus sisaldab viit sama vÀlist:
- t: serveri ajamÀrk, mis tÀhistab selle vÀrskenduse loomise aega.
- me: teave mÀngijast, kes selle vÀrskenduse saab.
- others: teiste mÀngijate teabe massiiv, kes osalevad samas mÀngus.
- bullets: mÀngus toimuvate projektide teabe massiiv.
- leaderboard: tabeli hetkeseisu andmed. Selles postituses me neid arvesse ei vÔta.
7.1 Lihtne kliendi seisund
Lihtne rakendus getCurrentState() suudab ainult vahetult tagastada kÔige vÀrskema saadud mÀngu vÀrskenduse andmed.
naive-state.js
let lastGameUpdate = null;
// Uuendatud mÀngu haldamine.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Kaunis ja arusaadav! Kuid kui vaid kĂ”ik oleks nii lihtne. Ăks pĂ”hjus, miks selline teostus on probleemne: see piirab renderdamise kaadrisagedust serveri taktsagedusega.
Kaadrisagedus (Frame Rate): kaadrite arv (st kÔnede
render()) sekundis, vĂ”i FPS. MĂ€ngudes pĂŒĂŒtakse tavaliselt saavutada vĂ€hemalt 60 FPS.
Taktsagedus (Tick Rate): sagedus, millega server saadab mÀngu uuendusi klientidele. Sageli on see madalam kui kaadrisagedus. Meie mÀngus töötab server 30 takti sekundis.
Kui me renderdame lihtsalt viimase mĂ€ngu uuenduse, ei saa FPS pĂ”himĂ”tteliselt kunagi ĂŒletada 30, sest me ei saa serverilt kunagi rohkem kui 30 uuendust sekundis. Isegi kui kutsume render() 60 korda sekundis, siis pool neist kĂ”nede lihtsalt joonistab sama uuesti, sisuliselt mitte midagi tehes. Veel ĂŒks naiivse teostuse probleem on see, et see on viivitustele tundlik. Kui Interneti kiirus on ideaalne, saab klient mĂ€ngu vĂ€rskenduse iga 33 ms jĂ€rel (30 korda sekundis):

Kahjuks ei ole miski ideaalne. Realistlikum pilt on jÀrgmine:

Naive rakendus on tegelikult halvim juhtum viivituste osas. Kui mÀngu vÀrskendus tuleb 50 ms hilinemisega, siis kliendi viivitus on 50 ms tÀiendavalt, kuna ta renderdab endiselt mÀngu olekut eelmise vÀrskenduse pÔhjal. Saate aru, kui ebamugav see mÀngijale on: juhuslike viivituste tÔttu tundub mÀng katkestusteta ja ebastabiilne.
7.2 Parandatud kliendi olek
Teeme naĂŻvesse rakendusse mĂ”ned parandused. Esiteks kasutame renderdamise viivitust 100 ms. See tĂ€hendab, et kliendi âpraeguneâ olek on alati 100 ms vĂ”rra mĂ€ngu seisust serveris maha jÀÀnud. NĂ€iteks kui serveris aeg on 150, siis renderdatakse kliendis olek, mis oli serveris ajal 50:

See annab meile 100 ms puhvrina, et taluda ettearvamatut mÀngu vÀrskenduste saamise aega:

Selle hinna eest on pidev 100 ms-nĂ€. See on vĂ€ike ohverdus sujuva mĂ€ngukogemuse nimel â enamik mĂ€ngijaid (eriti juhuslikud) ei pane seda viivitust isegi tĂ€hele. Inimestel on palju lihtsam harjuda pideva 100 ms viivitusega kui mĂ€ngida ettearvamatute viivitustega.
Saame kasutada ka teist tehnikat nimega , mis aitab tÔhusalt vÀhendada tajutavaid viivitusi, kuid seda ei kÀsitleta selles postituses.
Veel ĂŒks tĂ€iustamine, mida me kasutame, on lineaarne interpolatsioon. Renderdamise viivitusest tulenevalt oleme tavaliselt vĂ€hemalt ĂŒhe vĂ€rskenduse vĂ”rra ajast ette. Kui getCurrentState()kutsutakse vĂ€lja, saame teha mĂ€ngu vĂ€rskenduste vahel, mis toimuvad otse enne ja pĂ€rast kliendi praegust aega:

See lahendab kaadrisageduse probleemi: nĂŒĂŒd saame renderdada unikaalseid kaadreid mistahes soovitud sagedusega!
7.3 Parandatud kliendi oleku rakendamine
Rakenduse nÀidis src/client/state.js kasutab nii renderdamise viivitust kui ka lineaarset interpolatsiooni, kuid see ei kesta kaua. Jagame koodi kaheks osaks. Siin on esimene:
state.js, osa 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);
// Hoidke enne kehtiva serveri aega ainult ĂŒks mĂ€ngu vĂ€rskendus
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Tagastab baasi vÀrskenduse indeksi, esimese mÀngu vÀrskenduse enne
// praegust serveri aega vÔi -1, kui ei ole.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
} Esiteks tuleb mÔista, mida currentServerTime(). Nagu me varem nÀgime, sisaldab iga mÀngu vÀrskendus serveri ajamÀrki. Soovime kasutada renderdamise viivitust, et kuvada pilti, mis jÀÀb serverist 100 ms vÔrra maha, kuid me ei saa kunagi teada, mis on serveri praegune aeg, kuna me ei saa teada, kui kaua on vÔtnud endale iga vÀrskenduse jÔudmine meie juurde. Internet on ettearvamatu ning selle kiirus vÔib vÀga palju varieeruda!
Selle probleemi lahendamiseks saame kasutada mÔistlikku lÀhendust: me teeme nÀo, et esimene vÀrskendus saabus koheselt. Kui see oleks tÔsi, teaksime serveri aega just sel hetkel! Salvestame serveri ajamÀrgi firstServerTimestamp ja salvestame meie kohaliku (kliendi) ajamÀrgi samal hetkel gameStart.
Oi, oota. Kas serveri aeg ei peaks olema sama, mis kliendi aeg? Miks eristame 'serveri ajamĂ€rki' ja 'kliendi ajamĂ€rki'? See on suurepĂ€rane kĂŒsimus! Selgub, et need ei ole samad. Date.now() tagastab erinevad ajamĂ€rgid kliendi ja serveri jaoks ning see sĂ”ltub nende masinate kohalikest teguritest. Ărge kunagi eeldage, et ajamĂ€rgid on kĂ”igil masinatel samad.
NĂŒĂŒd mĂ”istame, mida teeb currentServerTime(): see tagastab serveri ajamĂ€rgi renderdamise hetke jaoks. TeisisĂ”nu, see on serveri praegune aeg (firstServerTimestamp <+ (Date.now() - gameStart)) miinus renderdamise viivitus (RENDER_DELAY).
NĂŒĂŒd uurime, kuidas kĂ€sitleme mĂ€ngu uuendusi. Serverilt uuenduste saamisel kutsutakse processGameUpdate(), ja salvestame uue uuenduse massiivi gameUpdates. Siis, et kontrollida mĂ€lu kasutamist, eemaldame kĂ”ik vanad uuendused kuni baasuudistus, sest nad ei ole meile enam vajalikud.
Mis on siis «pÔhivÀrskendus»? See on esimene vÀrskendus, mille leiame, liikudes tagasi serveri praegusest ajast. Kas mÀletate seda skeemi?

VÀrskendus, mis asub otse «Client Render Time» vasakul pool, on pÔhivÀrskendus.
Milleks kasutatakse pÔhivÀrskendust? Miks me saame kÔrvaldada vÀrskendused kuni pÔhini? Selle mÔistmiseks vaatame lÔpuks rakendust getCurrentState():
state.js, osa 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),
};
}
}KĂ€sitleme kolme juhtumit:
base < 0tĂ€hendab, et praeguse renderdamisaja jaoks ei ole vĂ€rskendusi (vt ĂŒlaltoodud rakendusgetBaseUpdate()). See vĂ”ib juhtuda kohe mĂ€ngu alguses renderdamise viivituse tĂ”ttu. Sellisel juhul kasutame kĂ”ige viimast saadud vĂ€rskendust.baseon kĂ”ige viimane vĂ€rskendus, mis meil on. See vĂ”ib juhtuda vĂ”rgu viivituse vĂ”i kehva Interneti-ĂŒhenduse tĂ”ttu. Sellisel juhul kasutame samuti kĂ”ige viimast vĂ€rskendust, mis meil on.- Meil on vĂ€rskendus nii enne kui ka pĂ€rast praegust renderdamise aega, seega on vĂ”imalik interpoleerida!
KĂ”ik, mis on jÀÀnud state.js â on lineaarse interpoleerimise rakendus, mis esindab lihtsat (kuid igavat) matemaatikat. Kui soovite seda ise uurida, avage state.js jĂ€rgnevaga .
Osa 2. TagakĂŒljeteenus
Selles osas vaatame Node.js tagakÀyttuuri, mis haldab meie .
1. Serveri sisenemispunkt
Veebiserveri haldamiseks kasutame populaarset Node.js veebiraamistiku, mida nimetatakse . Selle seadistamisega tegeleb meie serveri sisenemispunkti fail src/server/server.js:
server.js, osa 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Loome Expressi serveri
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Seadistame Webpacki arendamiseks
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statistiliselt serveerime dist/ kausta tootmises
app.use(express.static('dist'));
}
// Kuulame pordil
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server kuulab pordil ${port}`);Pidage meeles, et esimeses osas rÀÀkisime Webpackist? Just siin hakkame kasutama meie Webpacki konfiguratsioone. Rakendame neid kahel viisil:
- Kasutage meie arenduspakettide automaatseks uuesti kompileerimiseks, vÔi
- Statistiliselt edastada kausta
dist/, kuhu Webpack meie failid tootmisse kogumise jÀrel kirjutab.
Veel ĂŒks oluline ĂŒlesanne server.js on serveri seadistamine , mis lihtsalt ĂŒhendub Express-serveriga:
server.js, osa 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Seadista Express
// ...
const server = app.listen(port);
console.log(`Server kuulab porti ${port}`);
// Seadista socket.io
const io = socketio(server);
// Kuula socket.io ĂŒhendusi
io.on('connection', socket => {
console.log('MĂ€ngija on ĂŒhendatud!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); PĂ€rast socket.io eduka ĂŒhenduse loomist serveriga seadistame sĂŒndmuste töötlejad uue socketi jaoks. SĂŒndmuste töötlejad kĂ€sitlevad klientidelt saadud sĂ”numeid, delegeerides need singelton-objektile game:
server.js, osa 3
const Game = require('./game');
// ...
// Seadista mÀng
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Loome .io ĆŸanri mĂ€ngu, seega vajame ainult ĂŒhte eksemplari MĂ€ng (âMĂ€ngâ) â kĂ”ik mĂ€ngijad mĂ€ngivad ĂŒhel areenil! JĂ€rgmises osas vaatame, kuidas see klass toimib MĂ€ng.
2. Serveri MĂ€ng
Klass MĂ€ng on sisaldab kĂ”ige olulisemat loogikat serveripoolses osas. Sellel on kaks peamist ĂŒlesannet: mĂ€ngijate haldamine ja mĂ€ngu simuleerimine.
Alustame esimesest ĂŒlesandest â mĂ€ngijate haldamisest.
game.js, osa 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;
// Genereeri positsioon, kus see mÀngija alustab.
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);
}
}
// ...
} Selles mÀngus identifitseerime mÀngijad nende id socket.io soketi kaudu (kui olete segaduses, minge tagasi server.js). Socket.io mÀÀrab igale soketile unikaalse id, seega ei pea me selle pÀrast muretsema. Ma nimetan seda mÀngija ID-ks.
Seda meeles pidades uurime klassi MĂ€ng:
socketsâ on objekt, mis seob mĂ€ngija ID soketiga, mis on seotud mĂ€ngijaga. See vĂ”imaldab meil pideva aja jooksul ligipÀÀsu soketitele nende mĂ€ngija ID-de jĂ€rgi.mĂ€ngijadâ on objekt, mis seob mĂ€ngija ID objekti code>Player.
bullets â on objektide massiiv. Bullet, millel ei ole kindlat jĂ€rjekorda.
viimaneUuenduseAeg â on ajatemperatuur mĂ€ngu viimase uuenduse hetkest. Peagi nĂ€eme, kuidas seda kasutatakse.
peabSaatmaUuenduse â on abimuutuja. Selle kasutust nĂ€eme samuti peagi.
Meetodid lisaMÀngija(), eemaldaMÀngija() ja kÀsitleSisend() ei vaja seletamist, neid kasutatakse. server.js. Kui vajate juhtimiseks mÀlu vÀrskendamist, minge natuke kÔrgemale.
Viimane rida konstruktor() kĂ€ivitab uuenduse tsĂŒkli mĂ€ngu (kiirusel 60 uuendust/s):
game.js, osa 2
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Arvuta möödunud aeg
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Uuenda iga kuul
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// HĂ€vita see kuul
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Uuenda iga mÀngijat
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Rakenda kokkupĂ”rked, tĂŒhista mĂ€ngijad, kui nad tabavad kuule
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),
);
// Kontrolli, kas mÔni mÀngija on surnud
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);
}
});
// Saada iga mÀngijale mÀngu uuendus iga teise korra jÀrel
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;
}
}
// ...
} Meetod update() liikluslogi kĂ”ige olulisema osa serveri kĂŒljest. JĂ€rgnevalt loetleme kĂ”ik, mida see teeb:
- Arvutab, kui kaua aega
dton möödunud viimasestupdate(). - Uuendab igat mĂŒrsku ja hĂ€vitab need vajadusel. Selle funktsionaalsuse teostuseni jĂ”uame hiljem. Praegu piisab, kui teame, et
bullet.update()tagastabtrue, kui mĂŒrsk tuleb hĂ€vitada (see on vĂ€ljunud areenist). - Uuendab iga mĂ€ngijat ja loob vajadusel mĂŒrsu. Selle teostuse nĂ€eme samuti hiljem â
player.update()vĂ”ib tagastada objektiBullet. - Kontrollib kokkupĂ”rkeid mĂŒrskude ja mĂ€ngijate vahel, kasutades
applyCollisions(), mis tagastab mĂŒrskude massiivi, mis on mĂ€ngijatesse tabanud. Iga tagastatud mĂŒrsu jaoks suurendame mĂ€ngija punkte, kes selle lasi (kasutadesplayer.onDealtDamage()), ja seejĂ€rel kustutame mĂŒrsu massiivistbullets. - Teavitab ja hĂ€vitab kĂ”ik surnud mĂ€ngijad.
- Saadab kÔigile mÀngijatele mÀngu uuenduse iga teise korra, kui kutsutakse
update(). See aitab meil jÀlgida eelpool mainitud abimuutujatpeabSaatmaUuenduse. Kunaupdate()kutsutakse 60 korda/s, saadame mÀngu uuendusi 30 korda/s. Seega, taktsagedus serveri sagedus on 30 takti/s (oleme taktimugavausest rÀÀkinud esimeses osas).
Miks saata mÀngu uuendusi ainult iga teise korra ? kanali kokkuhoiu nimel. 30 mÀngu uuendust sekundis on tÔeliselt palju!
Miks mitte lihtsalt kutsuda
update()30 korda sekundis? MĂ€ngu simuleerimise parandamiseks. Mida sagedamini kutsutakseupdate(), seda tĂ€psem on mĂ€ngu simuleerimine. Kuid ei tasu liialdada kutsumiseksupdate(), kuna see on arvutuslikult kulukas ĂŒlesanne â 60 sekundis on tĂ€iesti piisav.
Klassi ĂŒlejÀÀnud osa MĂ€ng koosneb abimeetoditest, mida kasutatakse update():
game.js, osa 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) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() on ĂŒsna lihtne â see jĂ€rjestab mĂ€ngijad punktide arvu jĂ€rgi, vĂ”tab viis parimat ja tagastab igaĂŒhe kasutajanime ja skoori.
createUpdate() kasutatakse update() mĂ€ngu vĂ€rskenduste loomiseks, mis edastatakse mĂ€ngijatele. Selle peamine ĂŒlesanne on kutsuda esile meetodeid serializeForUpdate(), mis on rakendatud klassidele Player ja Bullet. Pange tĂ€hele, et ta edastab igale mĂ€ngijale andmed ainult lĂ€hedastes mĂ€ngijatest ja projektidest â pole vajalik edastada teavet mĂ€nguobjektide kohta, mis on mĂ€ngijast kaugel!
3. MĂ€nguobjektid serveris
Meie mĂ€ngus on projektid ja mĂ€ngijad tegelikult vĂ€ga sarnased: need on abstraktsed ĂŒmmargused liikuvad mĂ€nguobjektid. Selle sarnasuse Ă€ra kasutamiseks mĂ€ngijate ja projektide vahel, alustame baasklassi rakendamisega 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,
};
}
} Siin ei toimu midagi keerulist. See klass saab olema hea tugipunkt edasise arendamise jaoks. Vaatame, kuidas klass Bullet kasutab Object:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Returns true if the bullet should be destroyed
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Rakendus Bullet vĂ€ga lĂŒhike! Oleme lisanud Object ainult jĂ€rgmised laiendused:
- Paketi kasutamine juhuslikuks genereerimiseks
idkuul. - Lisa vÀli
parentID, et saaks jÀlgida mÀngijat, kes selle kuuli lÔi. - Tagastatava vÀÀrtuse lisamine
update(), mis on vÔrdnetrue, kui kuuli asukoht on areenist vÀljas (mÀletate, me rÀÀkisime sellest eelmisel korral?).
Liigume edasi 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,
};
}
} MÀngijad on keerulisemad kui kuulid, seetÔttu peab see klass sisaldama veel mitmeid vÀlju. Selle meetod update() teeb suuremat tööd, andes tagasi just loodud kuul, kui neid ei ole jÀÀnud fireCooldown (mÀletage, et me rÀÀkisime sellest eelmises osas?). Samuti laiendab ta meetodit serializeForUpdate(), kuna meil on vaja mÀngu vÀrskendusse lisada tÀiendavad vÀljad mÀngija jaoks.
Alusklassi olemasolu Object â oluline samm, mis aitab vĂ€ltida koodi korduvust. NĂ€iteks, ilma klassita Object peab iga mĂ€nguobjekt omama sama rakendust distanceTo(), ja kĂ”igi nende rakenduste kopeerimise ja kleepimise sĂŒnkroniseerimine mitmes failis oleks Ă”udusunenĂ€gu. See muutub eriti oluliseks suurtes projektides, kui laiendavate Object klasside arv kasvab.
4. KokkupÔrgete tuvastamine
Ainus, mis meil ĂŒle jÀÀb - on tuvastada, kui projektid tabavad mĂ€ngijaid! Meenutage seda koodilĂ”iku meetodist update() klassist MĂ€ng:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Rakenda kokkupÔrked, anna mÀngijatele punkt, kui nad tabavad kuulidega
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),
);
// ...
}
} Peame rakendama meetodi applyCollisions(), mis tagastab kĂ”ik kuulid, mis on mĂ€ngijatesse tabanud. Ănneks ei ole see nii keeruline, sest
- KÔik kokkupÔrked on ringid ja see on lihtsaim kuju, mida rakendamise jaoks tuvastada.
- Meil on juba meetod
distanceTo(), mille me eelmisel jaotisel klassis rakendasimeObject.
Siin on meie kokkupÔrgete tuvastamise rakendus:
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;
}See lihtne kokkupÔrgete tuvastamine pÔhineb tÔsiasjal, et kaks ringi pÔrkuvad, kui nende keskpunktide vahemaa on vÀiksem kui nende raadiuste summa.Siin on juhtum, kus kahe ringi keskpunktide vahemaa on tÀpselt nende raadiuste summa:

Siin tuleb tÀhelepanu pöörata ka mÔnele aspektile:
- KĂŒsimus ei tohi tabada mĂ€ngijat, kes selle lootis. Seda saab saavutada, vĂ”rreldes
bullet.parentIDkoosplayer.id. - KĂŒsimus peab tabama ainult kord, kui mitme mĂ€ngijaga kokkupĂ”rked esinevad. Selle ĂŒlesande lahendame
break: kui mÀngija, kes tabas projektiili, on leitud, lÔpetame otsimise ja liigume jÀrgmise projektiili juurde.
LÔpp
Ja kĂ”ik! Oleme vaadanud kĂ”ike, mida peate teadma .io ĆŸanri veebimĂ€ngu loomiseks. Mis edasi? Koge oma .io mĂ€ngu!
Kogu nÀidiskood on avatud lÀhtekoodiga ja saadaval. .
Allikas: habr.com
