Ha még soha nem hallottál ezekről a játékokról, ezek ingyenes többszereplős webes játékok, amelyeket könnyű játszani (nem szükséges fiók). Általában sok ellenfél játékossal találkoznak ugyanabban az arénában. Egyéb híres .io játékok: Slither.io и Diep.io.
Ebben a bejegyzésben megvizsgáljuk, hogyan hozzon létre egy .io játékot a semmiből. Ehhez csak a Javascript ismerete elég lesz: érteni kell például a szintaxist ES6, kulcsszó this и ígéretek. Még ha a Javascript ismerete nem is tökéletes, akkor is megértheti a bejegyzés nagy részét.
.io játék példa
A tanulási segítséghez hivatkozni fogunk .io játék példa. Próbálj meg játszani!
A játék meglehetősen egyszerű: egy hajót irányítasz egy olyan arénában, ahol más játékosok is vannak. A hajód automatikusan lövedékeket lő ki, és te megpróbálsz eltalálni más játékosokat, miközben elkerülöd a lövedékeiket.
Minden egy mappában public/ statikusan küldi el a szerver. BAN BEN public/assets/ projektünkben használt képeket tartalmaz.
src /
Minden forráskód a mappában van src/. Címek client/ и server/ beszéljenek magukért és shared/ konstans fájlt tartalmaz, amelyet a kliens és a szerver is importál.
2. Összeállítások/projektbeállítások
Ahogy fentebb említettük, a projekt felépítéséhez modulkezelőt használunk webpack. Vessünk egy pillantást a Webpack konfigurációra:
src/client/index.js a Javascript (JS) kliens belépési pontja. A Webpack innen indul, és rekurzív módon keres más importált fájlokat.
A Webpack build kimeneti JS-je a könyvtárban található dist/. Ezt a fájlt a miénknek nevezem js csomag.
Használunk Babel, és különösen a konfiguráció @babel/preset-env JS kódunk régebbi böngészőkhöz való átültetéséhez.
Egy beépülő modul segítségével kibontjuk a JS-fájlok által hivatkozott összes CSS-t, és egy helyen egyesítjük őket. a miénknek fogom hívni css csomag.
Lehet, hogy furcsa csomagfájlneveket vett észre '[name].[contenthash].ext'. Tartalmaznak fájlnév-helyettesítések Webcsomag: [name] helyére a bemeneti pont neve kerül (esetünkben ez game), és [contenthash] helyére a fájl tartalmának hash-je kerül. Mi azért csináljuk optimalizálja a projektet kivonatolásra - megmondhatja a böngészőknek, hogy a JS-csomagjainkat korlátlan ideig tárolják, mert ha egy csomag megváltozik, akkor a fájl neve is megváltozik (változtatások contenthash). A végeredmény a nézetfájl neve lesz game.dbeee76e91a97d0c7207.js.
fájl webpack.common.js az alap konfigurációs fájl, amelyet importálunk a fejlesztési és a kész projekt konfigurációkba. Íme egy példa a fejlesztési konfigurációra:
A hatékonyság érdekében a fejlesztési folyamatban használjuk webpack.dev.js, és átvált a webpack.prod.jsa csomagméretek optimalizálásához éles üzembe helyezéskor.
Helyi beállítás
Azt javaslom, hogy telepítse a projektet egy helyi gépre, hogy kövesse az ebben a bejegyzésben felsorolt lépéseket. A beállítás egyszerű: először a rendszernek telepítve kell lennie Csomópont и NPM. Következő tennie kell
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
és készen állsz az indulásra! A fejlesztői kiszolgáló elindításához csak futtassa
$ npm run develop
és lépjen a webböngészőbe localhost: 3000. A fejlesztőszerver automatikusan újraépíti a JS- és CSS-csomagokat, amint a kód megváltozik – csak frissítse az oldalt az összes változás megtekintéséhez!
3. Ügyfél belépési pontjai
Térjünk rá magára a játék kódjára. Először is szükségünk van egy oldalra index.html, az oldal meglátogatásakor a böngésző először azt tölti be. Az oldalunk nagyon egyszerű lesz:
index.html
Példa .io játékra JÁTÉK
Ezt a kódpéldát kissé leegyszerűsítettük az egyértelműség kedvéért, és ugyanezt fogom tenni sok más bejegyzési példával is. A teljes kód mindig megtekinthető a címen GitHub.
Nekünk van:
HTML5 vászon elem (<canvas>), amelyet a játék megjelenítéséhez fogunk használni.
<link> CSS-csomagunk hozzáadásához.
<script> Javascript csomagunk hozzáadásához.
Főmenü felhasználónévvel <input> és a PLAY gombot (<button>).
A kezdőlap betöltése után a böngésző megkezdi a Javascript kód futtatását, a belépési pont JS fájljától kezdve: src/client/index.js.
Ez bonyolultan hangzik, de itt nem sok minden történik:
Több más JS-fájl importálása.
CSS-importálás (így a Webpack tudja, hogy ezeket bele kell foglalnia a CSS-csomagunkba).
dob connect() hogy kapcsolatot létesítsen a szerverrel és futtasson downloadAssets() a játék megjelenítéséhez szükséges képek letöltéséhez.
A 3. szakasz befejezése után megjelenik a főmenü (playMenu).
A kezelő beállítása a "PLAY" gomb megnyomására. A gomb megnyomásakor a kód inicializálja a játékot, és közli a szerverrel, hogy készen állunk a játékra.
Kliens-szerver logikánk fő "húsa" azokban a fájlokban van, amelyeket a fájl importált index.js. Most mindegyiket sorba vesszük.
4. Ügyféladatok cseréje
Ebben a játékban egy jól ismert könyvtárat használunk a szerverrel való kommunikációhoz foglalat.io. A Socket.io natív támogatással rendelkezik WebSockets, amelyek jól alkalmasak a kétirányú kommunikációra: tudunk üzeneteket küldeni a szervernek и a szerver ugyanazon a kapcsolaton tud üzeneteket küldeni nekünk.
Egy fájlunk lesz src/client/networking.jsaki gondoskodni fog mindenki kommunikáció a szerverrel:
Az erőforrás-gazdálkodást nem olyan nehéz megvalósítani! A fő ötlet egy tárgy tárolása assets, amely a fájlnév kulcsát az objektum értékéhez köti Image. Amikor az erőforrás betöltődik, egy objektumban tároljuk assets a jövőbeni gyors hozzáférés érdekében. Mikor lesz engedélyezve az egyes erőforrások letöltése (vagyis minden források), megengedjük downloadPromise.
Az erőforrások letöltése után megkezdheti a renderelést. Ahogy korábban említettük, rajzolni egy weboldalon használunk HTML5 vászon (<canvas>). A játékunk nagyon egyszerű, így csak a következőket kell rajzolnunk:
háttér
Játékos hajó
A játék többi játékosa
Kagyló
Íme a fontos részletek src/client/render.js, amelyek pontosan a fent felsorolt négy elemet jelenítik meg:
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);
}
Ez a kód is le lett rövidítve az érthetőség kedvéért.
render() ennek a fájlnak a fő funkciója. startRendering() и stopRendering() szabályozza a render ciklus aktiválását 60 FPS-en.
Az egyes renderelő segédfunkciók konkrét megvalósításai (pl. renderBullet()) nem olyan fontosak, de álljon itt egy egyszerű példa:
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,
);
}
Vegye figyelembe, hogy a módszert használjuk getAsset(), amely korábban látható volt asset.js!
Ha szeretne többet megtudni más renderelési segédekről, olvassa el a többit. src/client/render.js.
6. Ügyfél bevitele
Ideje játékot készíteni játszható! A vezérlési séma nagyon egyszerű lesz: a mozgás irányának megváltoztatásához használhatja az egeret (számítógépen) vagy érintse meg a képernyőt (mobileszközön). Ennek megvalósításához regisztrálni fogunk Eseményhallgatók Mouse and Touch eseményekhez.
Mindenről gondoskodni fog 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() Eseményfigyelők hívnak updateDirection() (tól től networking.js), amikor bemeneti esemény történik (például az egér mozgatásakor). updateDirection() üzeneteket kezel a szerverrel, amely kezeli a bemeneti eseményt, és ennek megfelelően frissíti a játék állapotát.
7. Ügyfél állapota
Ez a rész a legnehezebb a bejegyzés első részében. Ne csüggedj, ha első olvasáskor nem érted! Akár ki is hagyhatja, és később visszatérhet hozzá.
A kliens/szerver kód kitöltéséhez szükséges puzzle utolsó darabja voltak. Emlékszel a Client Rendering rész kódrészletére?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
}
getCurrentState() képesnek kell lennie arra, hogy megadja nekünk a játék aktuális állapotát az ügyfélben bármely időpontban a szervertől kapott frissítések alapján. Íme egy példa egy játékfrissítésre, amelyet a szerver küldhet:
t: A frissítés létrehozásának időpontját jelző szerver időbélyegzője.
me: Információ a frissítést kapó lejátszóról.
mások: Információk tömbje az ugyanabban a játékban részt vevő többi játékosról.
golyók: egy sor információ a játékban lévő lövedékekről.
ranglistán: Aktuális ranglista adatok. Ebben a bejegyzésben nem foglalkozunk velük.
7.1 Naív kliens állapot
Naiv megvalósítás getCurrentState() csak a legutóbb kapott játékfrissítés adatait tudja közvetlenül visszaadni.
naiv-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}
Szép és világos! De ha ez ilyen egyszerű lenne. Az egyik oka annak, hogy ez a megvalósítás problémás: a renderelési képkockasebességet a szerver órajelére korlátozza.
Filmkocka szám: képkockák száma (azaz hívások render()) másodpercenként vagy FPS. A játékok általában legalább 60 FPS elérésére törekednek.
Tick Rate: Az a gyakoriság, amellyel a szerver játékfrissítéseket küld az ügyfeleknek. Gyakran alacsonyabb, mint a képkockasebesség. A mi játékunkban a szerver 30 tick/másodperc sebességgel fut.
Ha csak a legújabb játékfrissítést jelenítjük meg, akkor az FPS lényegében soha nem lépheti túl a 30-at, mert soha nem kapunk másodpercenként 30-nál több frissítést a szervertől. Még ha hívjuk is render() 60-szor másodpercenként, akkor ezeknek a hívásoknak a fele csak újrarajzolja ugyanazt a dolgot, lényegében nem csinál semmit. A másik probléma a naiv megvalósítással az, hogy az hajlamos a késésekre. Ideális internetsebességgel a kliens pontosan 33 ms-onként kap egy játékfrissítést (30 másodpercenként):
Sajnos semmi sem tökéletes. A reálisabb kép a következő lenne:
A naiv megvalósítás gyakorlatilag a legrosszabb eset, ha a késleltetésről van szó. Ha egy játékfrissítés 50 ms késéssel érkezik, akkor ügyfél standokon plusz 50 ms, mert még mindig az előző frissítés játékállapotát jeleníti meg. Képzelheti, milyen kényelmetlen ez a játékos számára: az önkényes fékezés miatt a játék szaggatott és instabil lesz.
7.2 Továbbfejlesztett ügyfélállapot
A naiv megvalósításon néhány javítást végzünk. Először is használjuk renderelési késleltetés 100 ms-ig. Ez azt jelenti, hogy a kliens "aktuális" állapota mindig 100 ms-mal elmarad a szerveren lévő játék állapotától. Például, ha a szerveren lévő idő 150, akkor az ügyfél azt az állapotot jeleníti meg, amelyben a szerver akkoriban volt 50:
Ez 100 ms-os puffert biztosít számunkra, hogy túléljük a kiszámíthatatlan játékfrissítési időket:
Ennek megtérülése állandó lesz bemeneti késés 100 ms-ig. Ez egy kisebb áldozat a gördülékeny játékmenet érdekében – a legtöbb játékos (különösen az alkalmi játékosok) észre sem veszi ezt a késést. Az emberek sokkal könnyebben alkalmazkodnak az állandó 100 ms-os késleltetéshez, mint egy előre nem látható késleltetéssel játszani.
Használhatunk egy másik technikát, az ún ügyféloldali előrejelzés, amely jó munkát végez az észlelt késleltetés csökkentésében, de ebben a bejegyzésben nem foglalkozunk vele.
Egy másik fejlesztés, amit használunk, az lineáris interpoláció. A megjelenítési késés miatt általában legalább egy frissítéssel megelőzzük a kliens aktuális idejét. Amikor hívják getCurrentState(), végre tudjuk hajtani lineáris interpoláció játékfrissítések között, közvetlenül az aktuális idő előtt és után a kliensben:
Ez megoldja a képkockasebesség problémáját: mostantól tetszőleges képkockasebességgel renderelhetünk egyedi képkockákat!
7.3 Továbbfejlesztett ügyfélállapot megvalósítása
Megvalósítási példa itt src/client/state.js renderelési késleltetést és lineáris interpolációt is használ, de nem sokáig. Osszuk két részre a kódot. Íme az első:
state.js 1. rész
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;
}
Az első dolog, amit meg kell tennie, hogy kitalálja, mit csinál currentServerTime(). Ahogy korábban láttuk, minden játékfrissítés tartalmaz egy szerver időbélyeget. Renderelési késleltetést szeretnénk használni a kép megjelenítéséhez 100 ms-mal a szerver mögött, de soha nem fogjuk tudni az aktuális időt a szerveren, mert nem tudhatjuk, mennyi időbe telt, mire valamelyik frissítés eljutott hozzánk. Az internet kiszámíthatatlan, sebessége nagyon változó lehet!
A probléma megkerülésére egy ésszerű közelítést használhatunk: mi Tegyük fel, hogy az első frissítés azonnal megérkezett. Ha ez igaz lenne, akkor ebben a pillanatban tudnánk a szerveridőt! Ebben tároljuk a szerver időbélyegét firstServerTimestamp és tartsuk meg a miénket helyi (kliens) időbélyegzője ugyanabban a pillanatban gameStart.
Ó várj. Nem szerveridő = kliensidő kellene? Miért teszünk különbséget a "szerver időbélyegzője" és a "kliens időbélyegzője" között? Ez egy nagyszerű kérdés! Kiderült, hogy nem ugyanazok. Date.now() különböző időbélyegeket fog visszaadni a kliensben és a kiszolgálóban, és ez a gépek helyi tényezőitől függ. Soha ne feltételezze, hogy az időbélyegek minden gépen azonosak lesznek.
Most már értjük, mi az currentServerTime(): visszatér az aktuális renderelési idő szerver időbélyege. Más szavakkal, ez a szerver aktuális ideje (firstServerTimestamp <+ (Date.now() - gameStart)) mínusz megjelenítési késleltetés (RENDER_DELAY).
Most pedig nézzük meg, hogyan kezeljük a játékfrissítéseket. Amikor frissítés érkezik a kiszolgálótól, az meghívásra kerül processGameUpdate()és elmentjük az új frissítést egy tömbbe gameUpdates. Ezután a memóriahasználat ellenőrzéséhez eltávolítjuk az összes korábbi frissítést alap frissítésmert már nincs rájuk szükségünk.
Mi az "alap frissítés"? Ez az első frissítés, amelyet úgy találunk, hogy visszafelé haladunk a szerver aktuális idejétől. Emlékszel erre a diagramra?
A "Client Render Time" bal oldalán található játékfrissítés az alapfrissítés.
Mire használható az alapfrissítés? Miért vethetjük le a frissítéseket az alapszintre? Hogy ezt kitaláljuk, lássuk végül fontolja meg a megvalósítást getCurrentState():
state.js 2. rész
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),
};
}
}
Három esetet kezelünk:
base < 0 azt jelenti, hogy nincsenek frissítések az aktuális megjelenítési időpontig (lásd fent a megvalósítást getBaseUpdate()). Ez történhet közvetlenül a játék elején a renderelési késés miatt. Ebben az esetben a legújabb frissítést használjuk.
base a legújabb frissítésünk. Ennek oka lehet a hálózati késés vagy a rossz internetkapcsolat. Ebben az esetben is a legújabb frissítést használjuk.
A jelenlegi renderelési idő előtt és után is van frissítésünk, így tudunk interpolál!
Minden ami benne maradt state.js a lineáris interpoláció egyszerű (de unalmas) matematikai megvalósítása. Ha saját maga szeretné felfedezni, akkor nyissa meg state.js on GitHub.
2. rész. Háttérkiszolgáló
Ebben a részben a Node.js háttérprogramot vesszük górcső alá .io játék példa.
1. Szerver belépési pont
A webszerver kezeléséhez a Node.js nevű népszerű webes keretrendszert fogjuk használni expressz. A szerver belépési pont fájlja konfigurálja src/server/server.js:
server.js 1. rész
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}`);
Emlékszel, hogy az első részben a Webpackról beszéltünk? Itt fogjuk használni a Webpack konfigurációinkat. Kétféleképpen fogjuk használni őket:
Miután sikeresen létrehoztuk a socket.io kapcsolatot a szerverrel, eseménykezelőket állítottunk be az új sockethez. Az eseménykezelők az ügyfelektől kapott üzeneteket egyetlen objektumra delegálva kezelik game:
server.js 3. rész
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);
}
Egy .io játékot készítünk, így csak egy példányra van szükségünk Game ("Játék") - minden játékos ugyanabban az arénában játszik! A következő részben meglátjuk, hogyan működik ez az osztály. Game.
2. Játékszerverek
Osztály Game tartalmazza a szerveroldali legfontosabb logikát. Két fő feladata van: játékos menedzsment и játék szimuláció.
Kezdjük az első feladattal, a játékosmenedzsmenttel.
game.js 1. rész
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);
}
}
// ...
}
Ebben a játékban a mezőny alapján azonosítjuk a játékosokat id a socket.io aljzatukat (ha összezavarodna, térjen vissza a server.js). Maga a Socket.io minden aljzatot egyedileg rendel hozzá idígy nem kell aggódnunk emiatt. fel fogom hívni Játékos azonosító.
Ezt szem előtt tartva, vizsgáljuk meg az osztály példányváltozóit Game:
sockets egy olyan objektum, amely a játékosazonosítót a lejátszóhoz társított aljzathoz köti. Lehetővé teszi, hogy konstans időben hozzáférjünk a csatlakozókhoz a játékosazonosítóik alapján.
players egy olyan objektum, amely a játékosazonosítót a kód>Játékos objektumhoz köti
bullets objektumok tömbje Bullet, amelynek nincs határozott sorrendje. lastUpdateTime a játék legutóbbi frissítésének időbélyege. Hamarosan meglátjuk, hogyan használják. shouldSendUpdate egy segédváltozó. Hamarosan látni fogjuk a használatát is.
mód addPlayer(), removePlayer() и handleInput() nem kell magyarázni, használatosak server.js. Ha fel kell frissítenie a memóriáját, menjen vissza egy kicsit feljebb.
Utolsó sor constructor() elindul frissítési ciklus játékok (60 frissítés/s gyakorisággal):
game.js 2. rész
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;
}
}
// ...
}
módszer update() a szerveroldali logika talán legfontosabb részét tartalmazza. Íme, mit csinál, sorrendben:
Kiszámolja, meddig dt elmúlt az utolsó óta update().
Minden lövedéket frissít, és szükség esetén megsemmisíti. A későbbiekben látni fogjuk ennek a funkciónak a megvalósítását. Egyelőre elég, ha ezt tudjuk bullet.update()visszatér trueha a lövedéket meg kell semmisíteni (kilépett az arénából).
Frissít minden játékost, és szükség esetén lövedéket hoz létre. Ezt a megvalósítást is látni fogjuk később - player.update()visszaadhat egy tárgyat Bullet.
Ellenőrzi a lövedékek és a játékosok közötti ütközést applyCollisions(), amely a játékosokat eltaláló lövedékek sorát adja vissza. Minden visszaküldött lövedékért növeljük annak a játékosnak a pontjait, aki kilőtte (a player.onDealtDamage()), majd távolítsa el a lövedéket a tömbből bullets.
Értesíti és megsemmisíti az összes megölt játékost.
Játékfrissítést küld minden játékosnak minden másodperc amikor hívják update(). Ez segít nyomon követni a fent említett segédváltozót. shouldSendUpdate. Mint update() 60-szor hívják/s, játékfrissítéseket küldünk 30-szor/s. És így, órajel frekvenciája a szerver órajele 30 óra/s (az első részben az órajelekről beszéltünk).
Miért csak játékfrissítéseket kell küldeni? időn keresztül ? Csatorna mentéséhez. A másodpercenkénti 30 játékfrissítés sok!
Miért nem hívja csak update() 30-szor másodpercenként? A játékszimuláció javítása érdekében. A gyakrabban hívják update(), annál pontosabb lesz a játékszimuláció. De ne ragadjon el túlságosan a kihívások számától. update(), mert ez egy számításigényes feladat - 60 másodpercenként elég.
Az osztály többi tagja Game ban használt segítő módszerekből áll update():
getLeaderboard() nagyon egyszerű – pontszám szerint rendezi a játékosokat, kiválasztja az első ötöt, és mindegyikhez visszaadja a felhasználónevet és a pontszámot.
createUpdate() használt update() játékfrissítések létrehozásához, amelyeket a játékosok között osztanak ki. Fő feladata a metódusok meghívása serializeForUpdate()osztályokhoz valósítottuk meg Player и Bullet. Vegye figyelembe, hogy minden játékosnak csak kb legközelebbi játékosok és lövedékek – nincs szükség a játékostól távol eső játéktárgyak információinak továbbítására!
3. Játékobjektumok a szerveren
A mi játékunkban a lövedékek és a játékosok valójában nagyon hasonlóak: absztrakt, kerek, mozgatható játéktárgyak. A játékosok és lövedékek közötti hasonlóság kihasználása érdekében kezdjük az alaposztály megvalósításával Object:
Nincs itt semmi bonyolult. Ez az osztály jó rögzítési pont lesz a bővítéshez. Lássuk, hogyan alakul az osztály Bullet felhasznál 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 nagyon rövid! Hozzáadtuk Object csak a következő kiterjesztések:
Csomag használata shortid véletlenszerű generáláshoz id lövedék.
Mező hozzáadása parentIDhogy nyomon tudja követni a lövedéket létrehozó játékost.
Visszatérési érték hozzáadása ehhez update(), ami egyenlő trueha a lövedék az arénán kívül van (emlékszel, hogy az utolsó részben beszéltünk erről?).
Menjünk tovább 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,
};
}
}
A játékosok összetettebbek, mint a lövedékek, ezért ebben az osztályban még néhány mezőt kell tárolni. A módszere update() sok munkát végez, különösen visszaadja az újonnan létrehozott lövedéket, ha már nincs fireCooldown (emlékszel, beszéltünk erről az előző részben?). Ezenkívül kiterjeszti a módszert serializeForUpdate(), mert a játékfrissítésben további mezőket kell tartalmaznunk a játékos számára.
Alaposztályú Object - fontos lépés a kód ismétlődésének elkerülése érdekében. Például nincs osztály Object minden játékobjektumnak ugyanazzal a megvalósítással kell rendelkeznie distanceTo(), és ezeknek a megvalósításoknak több fájlba másolása rémálom lenne. Ez különösen nagy projekteknél válik fontossá.amikor a száma bővül Object az osztályok gyarapodnak.
4. Ütközésészlelés
Nekünk már csak az marad, hogy felismerjük, amikor a lövedékek eltalálják a játékosokat! Emlékezzen erre a kódrészletre a metódusból update() osztályban Game:
game.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),
);
// ...
}
}
Meg kell valósítanunk a módszert applyCollisions(), amely visszaadja a játékosokat eltaláló összes lövedéket. Szerencsére nem olyan nehéz megtenni, mert
Minden ütköző tárgy kör, ami a legegyszerűbb alakzat az ütközésészlelés megvalósításához.
Már van egy módszerünk distanceTo(), amelyet az előző részben az osztályban implementáltunk Object.
Így néz ki az ütközésészlelésünk megvalósítása:
ütközések.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;
}
Ez az egyszerű ütközésészlelés azon a tényen alapul, hogy két kör ütközik, ha a középpontjuk távolsága kisebb, mint a sugaruk összege. Itt van az az eset, amikor két kör középpontja közötti távolság pontosan egyenlő a sugaruk összegével:
Itt még néhány szempontot figyelembe kell venni:
A lövedék nem találhatja el azt a játékost, aki létrehozta. Ezt összehasonlítással lehet elérni bullet.parentID с player.id.
A lövedéknek csak egyszer kell eltalálnia abban az esetben, ha egyszerre több játékos ütközik. Ezt a problémát az operátor segítségével oldjuk meg break: amint megtaláljuk a lövedéknek ütköző játékost, leállítjuk a keresést és továbblépünk a következő lövedékre.
Конец
Ez minden! Mindent leírtunk, amit egy .io webjáték létrehozásához tudnia kell. Mi a következő lépés? Építsd meg saját .io játékodat!
Minden mintakód nyílt forráskódú, és közzétételre került GitHub.