
Izdano leta 2015 postal začetnik novega žanra ki je od takrat postala priljubljena. Osebno sem izkusil porast priljubljenosti iger .io: v zadnjih treh letih sem .
Če še nikoli niste slišali za te igre, so to brezplačne spletne igre za več igralcev, ki jih je enostavno igrati (račun ni potreben). Običajno se soočijo s številnimi nasprotnimi igralci v isti areni. Druge znane igre .io: и .
V tej objavi bomo raziskali, kako ustvarite igro .io iz nič. Za to bo dovolj le znanje Javascripta: razumeti morate stvari, kot je sintaksa , ključna beseda this и . Tudi če Javascripta ne poznate popolnoma, lahko še vedno razumete večino objave.
Primer igre .io
Za pomoč pri učenju se bomo sklicevali na . Poskusite igrati!

Igra je precej preprosta: nadzorujete ladjo v areni, kjer so še drugi igralci. Vaša ladja samodejno izstreljuje izstrelke, vi pa poskušate zadeti druge igralce, medtem ko se izogibate njihovim izstrelkom.
1. Kratek pregled/struktura projekta
priporoči primer igre, da me lahko spremljate.
Primer uporablja naslednje:
- je najbolj priljubljeno spletno ogrodje Node.js, ki upravlja spletni strežnik igre.
- - knjižnica spletnih vtičnic za izmenjavo podatkov med brskalnikom in strežnikom.
- - vodja modula. Lahko preberete, zakaj uporabljati Webpack. .
Tukaj je videti struktura imenika projekta:
javno/ sredstva/ ... src/ odjemalec/ css/ ... html/ index.html index.js ... strežnik/ strežnik.js ... deljeno/ konstante.js
javno /
Vse je v mapi public/ bo statično oddal strežnik. IN public/assets/ vsebuje slike, ki jih uporablja naš projekt.
src /
Vsa izvorna koda je v mapi src/. Naslovi client/ и server/ govorijo zase in shared/ vsebuje datoteko s konstantami, ki jo uvozita tako odjemalec kot strežnik.
2. Sklopi/nastavitve projekta
Kot že omenjeno, za izdelavo projekta uporabljamo upravitelja modulov. . Oglejmo si konfiguracijo našega spletnega paketa:
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',
}),
],
};Najpomembnejše vrstice tukaj so:
src/client/index.jsje vstopna točka odjemalca Javascript (JS). Webpack se bo začel od tu in rekurzivno iskal druge uvožene datoteke.- Izhodni JS naše zgradbe spletnega paketa bo v imeniku
dist/. To datoteko bom imenoval naša js paket. - Uporabljamo , predvsem pa konfiguracijo do prevajanja naše kode JS za starejše brskalnike.
- Uporabljamo vtičnik za ekstrahiranje vseh CSS, na katere se sklicujejo datoteke JS, in jih združimo na enem mestu. Klical ga bom naš css paket.
Morda ste opazili čudna imena datotek paketov '[name].[contenthash].ext'. Vsebujejo Spletni paket: [name] bo nadomeščeno z imenom vnosne točke (v našem primeru to game), in [contenthash] bo nadomeščen z zgoščeno vrednostjo vsebine datoteke. To počnemo, da - brskalnikom lahko naročite, naj predpomnijo naše pakete JS za nedoločen čas, ker če se paket spremeni, se spremeni tudi njegovo ime datoteke (spremembe contenthash). Končni rezultat bo ime datoteke pogleda game.dbeee76e91a97d0c7207.js.
datoteka webpack.common.js je osnovna konfiguracijska datoteka, ki jo uvozimo v razvojne in končne konfiguracije projekta. Na primer, tukaj je 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 uporabljamo v procesu razvoja webpack.dev.js, in preklopi na webpack.prod.jsza optimizacijo velikosti paketov pri uvajanju v proizvodnjo.
Lokalna nastavitev
Priporočam, da projekt namestite na lokalni računalnik, da boste lahko sledili korakom, navedenim v tej objavi. Nastavitev je preprosta: najprej mora imeti sistem и . Naprej morate storiti
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installin pripravljeni ste na odhod! Za zagon razvojnega strežnika preprosto zaženite
$ npm run developin pojdite v spletni brskalnik . Razvojni strežnik bo samodejno obnovil pakete JS in CSS, ko pride do sprememb kode - samo osvežite stran, da vidite vse spremembe!
3. Vstopne točke odjemalca
Pojdimo k sami kodi igre. Najprej potrebujemo stran index.html, ob obisku strani brskalnik najprej naloži le-to. Naša stran bo precej preprosta:
index.html
Primer igre .io IGRAJ
Ta primer kode je bil nekoliko poenostavljen zaradi jasnosti in enako bom naredil z mnogimi drugimi primeri objave. Celotno kodo si lahko vedno ogledate na .
Imamo:
- (
<canvas>), ki jih bomo uporabili za upodabljanje igre. <link>da dodamo naš paket CSS.<script>da dodamo naš paket Javascript.- Glavni meni z uporabniškim imenom
<input>in gumb PLAY (<button>).
Po nalaganju domače strani bo brskalnik začel izvajati kodo Javascript, začenši z vstopno točko datoteke 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);
};
});To se morda sliši zapleteno, vendar se tukaj pravzaprav ne dogaja veliko:
- Uvažanje več drugih datotek JS.
- Uvoz CSS (tako da jih Webpack ve vključiti v naš paket CSS).
- Izstrelite
connect()vzpostaviti povezavo s strežnikom in zagnatidownloadAssets()za prenos slik, potrebnih za upodabljanje igre. - Po zaključku 3. stopnje prikaže se glavni meni (
playMenu). - Nastavitev upravljalnika za pritisk na gumb "PLAY". Ko pritisnemo gumb, koda inicializira igro in strežniku sporoči, da smo pripravljeni na igranje.
Glavno »meso« naše logike odjemalec-strežnik je v tistih datotekah, ki jih je uvozila datoteka index.js. Zdaj jih bomo obravnavali vse po vrstnem redu.
4. Izmenjava podatkov strank
V tej igri uporabljamo dobro znano knjižnico za komunikacijo s strežnikom . Socket.io ima izvorno podporo , ki so zelo primerni za dvosmerno komunikacijo: lahko pošiljamo sporočila strežniku и strežnik nam lahko pošilja sporočila na isti povezavi.
Imeli bomo eno datoteko src/client/networking.jskdo bo poskrbel vse komunikacija s strežnikom:
networking.js
import io from 'socket.io-client';
import { processGameUpdate } from './state';
const Constants = require('../shared/constants');
const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
socket.on('connect', () => {
console.log('Connected to server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Register callbacks
socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
})
);
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};
export const updateDirection = dir => {
socket.emit(Constants.MSG_TYPES.INPUT, dir);
};Ta koda je bila zaradi jasnosti tudi nekoliko skrajšana.
V tej datoteki so tri glavna dejanja:
- Poskušamo se povezati s strežnikom.
connectedPromisedovoljeno le, ko smo vzpostavili povezavo. - Če je povezava uspešna, registriramo funkcije povratnega klica (
processGameUpdate()иonGameOver()) za sporočila, ki jih lahko prejmemo s strežnika. - Izvažamo
play()иupdateDirection()tako da jih lahko uporabljajo druge datoteke.
5. Upodabljanje strank
Čas je za prikaz slike na zaslonu!
...toda preden lahko to storimo, moramo prenesti vse slike (vire), ki so za to potrebne. Napišimo upravitelja virov:
sredstva.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]; Upravljanje virov ni tako težko izvesti! Glavna točka je shranjevanje predmeta assets, ki bo ključ imena datoteke povezal z vrednostjo objekta Image. Ko je vir naložen, ga shranimo v objekt assets za hiter dostop v prihodnosti. Kdaj bo prenos vsakega posameznega vira dovoljen (to je prenos Vsi virov), dovolimo downloadPromise.
Po prenosu virov lahko začnete upodabljati. Kot že rečeno, za risanje na spletni strani uporabljamo (<canvas>). Naša igra je precej preprosta, zato moramo narisati le naslednje:
- Ozadje
- Ladja igralca
- Drugi igralci v igri
- školjke
Tukaj so pomembni izrezki src/client/render.js, ki upodobijo točno štiri elemente, navedene zgoraj:
render.js
import { getAsset } from './assets';
import { getCurrentState } from './state';
const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;
// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Draw background
renderBackground(me.x, me.y);
// Draw all bullets
bullets.forEach(renderBullet.bind(null, me));
// Draw all players
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Helper functions here excluded
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Ta koda je zaradi jasnosti tudi skrajšana.
render() je glavna funkcija te datoteke. startRendering() и stopRendering() nadzor aktivacije zanke upodabljanja pri 60 FPS.
Konkretne izvedbe posameznih funkcij za pomoč pri upodabljanju (npr. renderBullet()) niso tako pomembni, a tukaj je preprost primer:
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,
);
} Upoštevajte, da uporabljamo metodo getAsset(), ki smo ga pred tem videli v asset.js!
Če vas zanima raziskovanje drugih funkcij za pomoč pri upodabljanju, preberite preostanek .
6. Vnos odjemalca
Čas je, da naredimo igro predvajati! Nadzorna shema bo zelo preprosta: za spremembo smeri gibanja lahko uporabite miško (na računalniku) ali se dotaknete zaslona (na mobilni napravi). Za izvedbo tega se bomo registrirali za dogodke miške in dotika.
Bo poskrbel za vse to 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() и onTouchInput() so poslušalci dogodkov, ki kličejo updateDirection() (od networking.js), ko pride do vnosnega dogodka (na primer, ko se premakne miška). updateDirection() se ukvarja z izmenjavo sporočil s strežnikom, ki obdela vhodni dogodek in ustrezno posodobi stanje igre.
7. Status stranke
Ta del je najtežji v prvem delu objave. Naj vas ne obupa, če je ne razumete že ob prvem branju! Lahko ga celo preskočite in se k njemu vrnete pozneje.
Zadnji del sestavljanke, potreben za dokončanje kode odjemalec/strežnik, je so bili. Se spomnite izrezka kode iz razdelka Upodabljanje odjemalca?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
} getCurrentState() nam mora biti sposoben dati trenutno stanje igre v odjemalcu kadarkoli v času na podlagi posodobitev, prejetih s strežnika. Tukaj je primer posodobitve igre, ki jo lahko pošlje strežnik:
{
"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
}
]
}Vsaka posodobitev igre vsebuje pet enakih polj:
- t: Časovni žig strežnika, ki označuje, kdaj je bila ustvarjena ta posodobitev.
- me: Informacije o igralcu, ki prejema to posodobitev.
- drugi: Niz informacij o drugih igralcih, ki sodelujejo v isti igri.
- krogle: nabor informacij o izstrelkih v igri.
- leaderboard: Trenutni podatki o lestvici najboljših. V tej objavi jih ne bomo upoštevali.
7.1 Stanje naivnega odjemalca
Naivna izvedba getCurrentState() lahko le neposredno vrne podatke o zadnji prejeti posodobitvi igre.
naivno-stanje.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Lepo in jasno! A ko bi le bilo tako preprosto. Eden od razlogov, zakaj je ta izvedba problematična: omejuje hitrost sličic upodabljanja na hitrost strežnika.
Hitrost sličic: število okvirjev (tj. klicev
render()) na sekundo ali FPS. Igre si običajno prizadevajo doseči vsaj 60 FPS.
Tick Rate: Pogostost, s katero strežnik pošilja posodobitve igre odjemalcem. Pogosto je nižja od hitrosti sličic. V naši igri strežnik teče s frekvenco 30 ciklov na sekundo.
Če upodabljamo samo zadnjo posodobitev igre, potem FPS v bistvu nikoli ne bo presegel 30, ker s strežnika nikoli ne prejmemo več kot 30 posodobitev na sekundo. Tudi če pokličemo render() 60-krat na sekundo, potem bo polovica teh klicev preprosto prerisala isto stvar, v bistvu pa ne bo naredila ničesar. Druga težava z naivno izvedbo je, da je nagnjeni k zamudam. Z idealno internetno hitrostjo bo odjemalec prejel posodobitev igre natanko vsakih 33 ms (30 na sekundo):

Na žalost nič ni popolno. Bolj realna slika bi bila:

Naivna izvedba je praktično najslabši primer, ko gre za zakasnitev. Če je posodobitev igre prejeta z zakasnitvijo 50 ms, potem stojnice za stranke dodatnih 50 ms, ker še vedno upodablja stanje igre iz prejšnje posodobitve. Lahko si predstavljate, kako neprijetno je to za igralca: zaradi samovoljnega zaviranja bo igra delovala sunkovito in nestabilno.
7.2 Izboljšano stanje odjemalca
V naivno izvedbo bomo naredili nekaj izboljšav. Najprej uporabljamo zakasnitev upodabljanja za 100 ms. To pomeni, da bo "trenutno" stanje odjemalca vedno zaostajalo za stanjem igre na strežniku za 100 ms. Na primer, če je čas na strežniku 150, potem bo odjemalec upodobil stanje, v katerem je bil takrat strežnik 50:

To nam daje 100 ms medpomnilnika za preživetje nepredvidljivih časov posodobitve igre:

Izplačilo za to bo trajno za 100 ms. To je manjša žrtev za nemoteno igranje - večina igralcev (zlasti občasnih) te zamude sploh ne bo opazila. Ljudje se veliko lažje prilagodijo na konstantno zakasnitev 100 ms, kot pa se igrajo z nepredvidljivo zakasnitvijo.
Uporabimo lahko tudi drugo tehniko, imenovano , ki dobro zmanjša zaznano zakasnitev, vendar v tej objavi ne bo obravnavana.
Druga izboljšava, ki jo uporabljamo, je linearna interpolacija. Zaradi zamika upodabljanja smo običajno vsaj eno posodobitev pred trenutnim časom v odjemalcu. Ob klicu getCurrentState(), lahko izvedemo med posodobitvami igre tik pred in po trenutnem času v odjemalcu:

To reši težavo s hitrostjo sličic: zdaj lahko upodabljamo edinstvene sličice s poljubno hitrostjo sličic!
7.3 Implementacija izboljšanega stanja odjemalca
Primer izvedbe v src/client/state.js uporablja tako zamik upodabljanja kot linearno interpolacijo, vendar ne za dolgo. Razčlenimo kodo na dva dela. Tukaj je prvi:
state.js 1. del
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Keep only one game update before the current server time
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
} Prvi korak je ugotoviti, kaj currentServerTime(). Kot smo videli prej, vsaka posodobitev igre vključuje časovni žig strežnika. Želimo uporabiti zakasnitev upodabljanja za upodabljanje slike 100 ms za strežnikom, vendar nikoli ne bomo izvedeli trenutnega časa na strežniku, ker ne moremo vedeti, koliko časa je trajalo, da je katera od posodobitev prispela do nas. Internet je nepredvidljiv in njegova hitrost se lahko zelo razlikuje!
Da bi se izognili tej težavi, lahko uporabimo razumen približek: mi pretvarjajte se, da je prva posodobitev prispela takoj. Če bi bilo to res, bi vedeli čas strežnika v tistem trenutku! Shranjujemo časovni žig strežnika firstServerTimestamp in obdržimo naše lokalni (stranka) časovni žig v istem trenutku v gameStart.
Oh počakaj. Ali ne bi moral biti čas strežnika = čas odjemalca? Zakaj razlikujemo med "časovnim žigom strežnika" in "časovnim žigom odjemalca"? To je odlično vprašanje! Izkazalo se je, da nista ista stvar. Date.now() vrne različne časovne žige v odjemalcu in strežniku, kar je odvisno od dejavnikov, ki so lokalni za te stroje. Nikoli ne predvidevajte, da bodo časovni žigi enaki na vseh napravah.
Zdaj razumemo, kaj počne currentServerTime(): vrne se časovni žig strežnika trenutnega časa upodabljanja. Z drugimi besedami, to je trenutni čas strežnika (firstServerTimestamp <+ (Date.now() - gameStart)) minus zakasnitev upodabljanja (RENDER_DELAY).
Zdaj pa poglejmo, kako obravnavamo posodobitve iger. Ko strežnik prejme posodobitev, se ta pokliče processGameUpdate()in novo posodobitev shranimo v polje gameUpdates. Nato za preverjanje porabe pomnilnika odstranimo vse prejšnje posodobitve osnovna posodobitevker jih ne potrebujemo več.
Kaj je "osnovna posodobitev"? to prva posodobitev, ki jo najdemo s premikanjem nazaj od trenutnega časa strežnika. Se spomnite tega diagrama?

Posodobitev igre neposredno levo od »Čas upodabljanja odjemalca« je osnovna posodobitev.
Za kaj se uporablja osnovna posodobitev? Zakaj lahko spustimo posodobitve v bazo? Da bi to razumeli, dajmo končno razmislite o izvedbi getCurrentState():
state.js 2. del
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),
};
}
}Obravnavamo tri primere:
base < 0pomeni, da do trenutnega časa upodabljanja ni posodobitev (glejte zgornjo izvedbogetBaseUpdate()). To se lahko zgodi takoj na začetku igre zaradi zakasnitve upodabljanja. V tem primeru uporabimo zadnjo prejeto posodobitev.baseje najnovejša posodobitev, ki jo imamo. To je lahko posledica zakasnitve omrežja ali slabe internetne povezave. V tem primeru uporabljamo tudi najnovejšo posodobitev, ki jo imamo.- Imamo posodobitev pred in po trenutnem času upodabljanja, tako da lahko interpolirati!
Vse, kar je ostalo noter state.js je izvedba linearne interpolacije, ki je preprosta (a dolgočasna) matematika. Če želite sami raziskati, odprite state.js o .
2. del. Zaledni strežnik
V tem delu si bomo ogledali zaledje Node.js, ki nadzoruje naše .
1. Vstopna točka strežnika
Za upravljanje spletnega strežnika bomo uporabili priljubljeno spletno ogrodje za Node.js, imenovano . Konfigurirana bo z našo datoteko vstopne točke strežnika src/server/server.js:
server.js 1. del
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Setup an Express server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Setup Webpack for development
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Static serve the dist/ folder in production
app.use(express.static('dist'));
}
// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);Se spomnite, da smo v prvem delu razpravljali o Webpacku? Tukaj bomo uporabili naše konfiguracije Webpack. Uporabili jih bomo na dva načina:
- Za uporabo za samodejno obnovo naših razvojnih paketov, oz
- mapo za statični prenos
dist/, v katerega bo Webpack zapisal naše datoteke po produkcijski gradnji.
Druga pomembna naloga server.js je nastavitev strežnika ki se samo poveže s strežnikom Express:
server.js 2. del
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
// Setup socket.io
const io = socketio(server);
// Listen for socket.io connections
io.on('connection', socket => {
console.log('Player connected!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Po uspešni vzpostavitvi povezave socket.io s strežnikom smo nastavili obdelovalce dogodkov za novo vtičnico. Obdelovalci dogodkov obravnavajo sporočila, prejeta od odjemalcev, tako da prenesejo na posamezen objekt game:
server.js 3. del
const Game = require('./game');
// ...
// Setup the Game
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Ustvarjamo igro .io, zato bomo potrebovali samo eno kopijo Game (»Igra«) – vsi igralci igrajo v isti areni! V naslednjem razdelku bomo videli, kako ta razred deluje Game.
2. Strežniki za igre
Razred Game vsebuje najpomembnejšo logiko na strani strežnika. Ima dve glavni nalogi: vodenje igralcev и simulacija igre.
Začnimo s prvo nalogo – upravljanjem igralcev.
game.js, 1. del
const Constants = require('../shared/constants');
const Player = require('./player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 / 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
// Generate a position to start this player at.
const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
this.players[socket.id] = new Player(socket.id, username, x, y);
}
removePlayer(socket) {
delete this.sockets[socket.id];
delete this.players[socket.id];
}
handleInput(socket, dir) {
if (this.players[socket.id]) {
this.players[socket.id].setDirection(dir);
}
}
// ...
} V tej igri bomo identificirali igralce po polju id njihova vtičnica socket.io (če ste zmedeni, se vrnite na server.js). Socket.io sam dodeli vsaki vtičnici edinstveno id, zato nam ni treba skrbeti. ga bom poklical ID igralca.
S tem v mislih preučimo spremenljivke primerkov v razredu Game:
socketsje objekt, ki povezuje ID igralca z vtičnico, ki je povezana s predvajalnikom. Omogoča nam dostop do vtičnic po njihovih ID-jih igralcev skozi čas.playersje objekt, ki veže ID igralca na kodo>predmet Igralec
bullets je niz predmetov Bullet, ki nima določenega reda.
lastUpdateTime je časovni žig zadnje posodobitve igre. Kmalu bomo videli, kako se bo uporabljal.
shouldSendUpdate je pomožna spremenljivka. Kmalu bomo videli tudi njegovo uporabo.
Metode addPlayer(), removePlayer() и handleInput() ni treba pojasnjevati, uporabljajo se v server.js. Če potrebujete osvežitev, se vrnite malo višje.
Zadnja vrstica constructor() zažene cikel posodabljanja igre (s frekvenco 60 posodobitev / s):
game.js, 2. del
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Calculate time elapsed
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Update each bullet
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// Destroy this bullet
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Update each player
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// Check if any players are dead
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp <= 0) {
socket.emit(Constants.MSG_TYPES.GAME_OVER);
this.removePlayer(socket);
}
});
// Send a game update to each player every other time
if (this.shouldSendUpdate) {
const leaderboard = this.getLeaderboard();
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
// ...
} Metoda update() vsebuje verjetno najpomembnejši del logike na strani strežnika. Naštejmo vse, kar počne po vrstnem redu:
- Izračuna, kako dolgo
dtminilo od zadnjegaupdate(). - Osveži vsak projektil in ga po potrebi uniči. Implementacijo te funkcionalnosti bomo videli kasneje. Za zdaj je dovolj, da to vemo
bullet.update()vrnetrue, če je treba projektil uničiti (stopil je iz arene). - Posodobi vsakega igralca in po potrebi sproži projektil. Kasneje bomo videli tudi to izvedbo -
player.update()lahko vrne predmetBullet. - Preverja trke med projektili in igralci z
applyCollisions(), ki vrne niz izstrelkov, ki so zadeli igralce. Za vsak vrnjen izstrelek povečamo točke igralca, ki ga je izstrelil (z uporaboplayer.onDealtDamage()), in nato odstranite izstrelek iz nizabullets. - Obvesti in uniči vse ubite igralce.
- Pošlje posodobitev igre vsem igralcem vsako sekundo krat ob klicu
update(). To nam pomaga slediti zgoraj omenjeni pomožni spremenljivki.shouldSendUpdate... Kotupdate()klicano 60-krat/s, pošljemo posodobitve igre 30-krat/s. torej urna frekvenca ura strežnika je 30 taktov/s (o taktih smo govorili v prvem delu).
Zakaj pošiljati samo posodobitve iger skozi čas ? Za shranjevanje kanala. 30 posodobitev igre na sekundo je veliko!
Zakaj potem preprosto ne pokličeš?
update()30-krat na sekundo? Za izboljšanje simulacije igre. Pogosteje imenovaniupdate(), bolj natančna bo simulacija igre. Vendar naj vas število izzivov ne zanese preveč.update(), ker je to računsko drago opravilo – 60 na sekundo je povsem dovolj.
Ostali v razredu Game sestavljajo pomožne metode, ki se uporabljajo v update():
game.js, 3. del
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() precej preprosto - razvrsti igralce po rezultatu, vzame najboljših pet in za vsakega vrne uporabniško ime in rezultat.
createUpdate() uporablja v update() za ustvarjanje posodobitev igre, ki se razdelijo igralcem. Njegova glavna naloga je klicanje metod serializeForUpdate()izvajajo za razrede Player и Bullet. Upoštevajte, da vsakemu igralcu posreduje le podatke o najbližji igralci in projektili - ni potrebe po posredovanju informacij o predmetih igre, ki so daleč od igralca!
3. Predmeti igre na strežniku
V naši igri so si projektili in igralci pravzaprav zelo podobni: so abstraktni okrogli premikajoči se predmeti igre. Da bi izkoristili to podobnost med igralci in projektili, začnimo z implementacijo osnovnega razreda 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,
};
}
} Tu se ne dogaja nič zapletenega. Ta razred bo dobro izhodišče za širitev. Poglejmo, kako je razred Bullet uporablja Object:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Returns true if the bullet should be destroyed
update(dt) {
super.update(dt);
return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
}
} Реализация Bullet zelo kratko! Dodali smo k Object samo naslednje razširitve:
- Uporaba paketa za naključno generiranje
idprojektil. - Dodajanje polja
parentIDtako da lahko sledite igralcu, ki je ustvaril ta projektil. - Dodajanje povratne vrednosti v
update(), kar je enakotrue, če je projektil zunaj arene (se spomnite, da smo o tem govorili v prejšnjem razdelku?).
Pojdimo naprej 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,
};
}
} Igralci so bolj zapleteni kot projektili, zato bi moral ta razred shraniti nekaj več polj. Njegova metoda update() opravi več dela, zlasti vrne novo ustvarjen projektil, če ga ni več fireCooldown (se spomnite, da smo o tem govorili v prejšnjem razdelku?). Prav tako razširja metodo serializeForUpdate(), ker moramo v posodobitev igre vključiti dodatna polja za igralca.
Imeti osnovni razred Object - pomemben korak za izogibanje ponavljanju kode. Na primer, brez razreda Object vsak predmet igre mora imeti enako izvedbo distanceTo(), kopiranje in lepljenje vseh teh izvedb v več datotekah pa bi bila nočna mora. To postane še posebej pomembno pri velikih projektih.ko se število širi Object razredi rastejo.
4. Zaznavanje trčenja
Edino, kar nam preostane, je, da prepoznamo, kdaj projektili zadenejo igralce! Zapomnite si ta delček kode iz metode update() v razredu Game:
igra.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// ...
}
} Metodo moramo izvajati applyCollisions(), ki vrne vse izstrelke, ki so zadeli igralce. Na srečo to ni tako težko narediti, ker
- Vsi trčeni predmeti so krogi in to je najpreprostejša oblika za implementacijo zaznavanja trka.
- Metodo že imamo
distanceTo(), ki smo ga izvajali v prejšnjem razdelku v razreduObject.
Tako izgleda naša implementacija zaznavanja trkov:
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;
}To preprosto zaznavanje trka temelji na dejstvu, da dva kroga trčita, če je razdalja med njunima središčema manjša od vsote njunih polmerov. Tukaj je primer, ko je razdalja med središči dveh krogov popolnoma enaka vsoti njunih polmerov:

Tukaj morate biti pozorni na še nekaj vidikov:
- Projektil ne sme zadeti igralca, ki ga je ustvaril. To lahko dosežemo s primerjavo
bullet.parentIDсplayer.id. - Projektil naj zadene samo enkrat v skrajnem primeru, ko zadene več igralcev hkrati. To težavo bomo rešili z uporabo operaterja
break: takoj ko najdemo igralca, ki trči z izstrelkom, prekinemo iskanje in preidemo na naslednji izstrelek.
End
To je vse! Pokrili smo vse, kar morate vedeti za ustvarjanje spletne igre .io. Kaj je naslednje? Zgradite svojo igro .io!
Vsa vzorčna koda je odprtokodna in objavljena na .
Vir: www.habr.com
