Išleistas 2015 m Agaras.io tapo naujo žanro pradininku games.io, kurio populiarumas nuo to laiko labai išaugo. Pats patyriau .io žaidimų populiarumo augimą: per pastaruosius trejus metus I sukūrė ir pardavė du šio žanro žaidimus..
Jei niekada anksčiau negirdėjote apie šiuos žaidimus, tai nemokami kelių žaidėjų žiniatinklio žaidimai, kuriuos lengva žaisti (nereikia paskyros). Paprastai jie toje pačioje arenoje susiduria su daugybe varžovų žaidėjų. Kiti žinomi .io žaidimai: Slither.io и giliai.io.
Šiame įraše išsiaiškinsime, kaip sukurti .io žaidimą nuo nulio. Tam pakaks tik Javascript žinių: reikia suprasti tokius dalykus kaip sintaksė ES6, raktinis žodis this и Pažadai. Net jei jūsų žinios apie Javascript nėra tobulos, vis tiek galite suprasti didžiąją įrašo dalį.
Žaidimas gana paprastas: jūs valdote laivą arenoje, kurioje yra kitų žaidėjų. Jūsų laivas automatiškai paleidžia sviedinius, o jūs bandote pataikyti į kitus žaidėjus, vengdami jų sviedinių.
Viskas yra aplanke public/ bus statiškai pateiktas serverio. IN public/assets/ yra vaizdų, naudojamų mūsų projekte.
src /
Visas šaltinio kodas yra aplanke src/. Pavadinimai client/ и server/ kalbėti už save ir shared/ yra konstantų failas, kurį importuoja ir klientas, ir serveris.
2. Surinkimai/projekto nustatymai
Kaip minėta aukščiau, projektui kurti naudojame modulio tvarkyklę. Internetinė pakuotė. Pažvelkime į mūsų žiniatinklio paketo konfigūraciją:
Naudojame papildinį, kad ištrauktume visus JS failuose nurodytus CSS ir sujungtume juos į vieną vietą. Aš jį vadinsiu mūsų css paketą.
Galbūt pastebėjote keistus paketų failų pavadinimus '[name].[contenthash].ext'. Juose yra failo vardų keitimai Internetinė pakuotė: [name] bus pakeistas įvesties taško pavadinimu (mūsų atveju tai game), ir [contenthash] bus pakeistas failo turinio maiša. Mes tai darome optimizuoti projektą maišymui - Galite nurodyti naršyklėms saugoti mūsų JS paketus talpykloje neribotą laiką, nes pasikeitus paketui, pasikeičia ir jo failo pavadinimas (pakeitimai contenthash). Galutinis rezultatas bus peržiūros failo pavadinimas game.dbeee76e91a97d0c7207.js.
byla webpack.common.js yra bazinis konfigūracijos failas, kurį importuojame į kūrimo ir baigto projekto konfigūracijas. Štai kūrimo konfigūracijos pavyzdys:
Siekiant efektyvumo, mes naudojame kūrimo procese webpack.dev.js, ir persijungia į webpack.prod.jsoptimizuoti pakuotės dydžius diegiant gamybą.
Vietinis nustatymas
Rekomenduoju įdiegti projektą vietiniame kompiuteryje, kad galėtumėte atlikti šiame pranešime nurodytus veiksmus. Sąranka paprasta: pirma, sistema turi turėti mazgas и NPM. Toliau reikia daryti
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
ir jūs pasiruošę eiti! Norėdami paleisti kūrimo serverį, tiesiog paleiskite
$ npm run develop
ir eikite į interneto naršyklę localhost: 3000. Kūrimo serveris automatiškai atkurs JS ir CSS paketus, kai pasikeičia kodas – tiesiog atnaujinkite puslapį, kad pamatytumėte visus pakeitimus!
3. Kliento įėjimo taškai
Pereikime prie paties žaidimo kodo. Pirmiausia mums reikia puslapio index.html, kai lankotės svetainėje, naršyklė ją įkels pirmiausia. Mūsų puslapis bus gana paprastas:
index.html
io žaidimo pavyzdys ŽAISTI
Šis kodo pavyzdys buvo šiek tiek supaprastintas, kad būtų aišku, ir aš padarysiu tą patį su daugeliu kitų įrašų pavyzdžių. Visą kodą visada galima peržiūrėti adresu GitHub.
Tai gali atrodyti sudėtinga, bet čia nėra daug dalykų:
Importuokite kelis kitus JS failus.
CSS importavimas (kad „Webpack“ žinotų, kad juos įtrauks į mūsų CSS paketą).
Paleidimas connect() užmegzti ryšį su serveriu ir pradėti downloadAssets() Norėdami atsisiųsti žaidimui atkurti reikalingus vaizdus.
Baigus 3 etapą rodomas pagrindinis meniu (playMenu).
Valdiklio nustatymas mygtuko "PLAY" paspaudimui. Paspaudus mygtuką, kodas inicijuoja žaidimą ir praneša serveriui, kad esame pasiruošę žaisti.
Pagrindinė mūsų kliento-serverio logikos „mėsa“ yra tuose failuose, kurie buvo importuoti pagal failą index.js. Dabar apžvelgsime juos visus iš eilės.
4. Keitimasis klientų duomenimis
Šiame žaidime bendravimui su serveriu naudojame gerai žinomą biblioteką lizdas.io. Socket.io turi vietinį palaikymą WebSockets, kurios puikiai tinka dvipusiam ryšiui: galime siųsti žinutes į serverį и serveris gali siųsti mums pranešimus tuo pačiu ryšiu.
Turėsime vieną failą src/client/networking.jskas pasirūpins visų bendravimas su serveriu:
Išteklių valdymą įgyvendinti nėra taip sunku! Pagrindinė idėja yra saugoti objektą assets, kuris susies failo pavadinimo raktą su objekto reikšme Image. Kai resursas įkeliamas, mes jį saugome objekte assets greitai pasiekti ateityje. Kada bus leista atsisiųsti kiekvieną atskirą šaltinį (ty visi išteklių), leidžiame downloadPromise.
Atsisiuntę išteklius galite pradėti atvaizdavimą. Kaip minėta anksčiau, piešti tinklalapyje, kurį naudojame HTML5 drobė (<canvas>). Mūsų žaidimas yra gana paprastas, todėl mums tereikia nupiešti:
Bendrosios aplinkybės
Žaidėjų laivas
Kiti žaidimo žaidėjai
Korpusai
Čia yra svarbūs fragmentai src/client/render.js, kurie pateikia tiksliai keturis aukščiau išvardytus elementus:
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);
}
Šis kodas aiškumo dėlei taip pat sutrumpintas.
render() yra pagrindinė šio failo funkcija. startRendering() и stopRendering() valdyti atvaizdavimo ciklo aktyvavimą esant 60 FPS.
Konkretus atskirų atvaizdavimo pagalbinių funkcijų įgyvendinimas (pvz. renderBullet()) nėra tokie svarbūs, bet čia yra vienas paprastas pavyzdys:
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,
);
}
Atkreipkite dėmesį, kad mes naudojame metodą getAsset(), kuris anksčiau buvo matytas asset.js!
Jei norite ištirti kitas atvaizdavimo pagalbinės priemonės funkcijas, perskaitykite likusią dalį src/client/render.js.
6. Kliento įvestis
Atėjo laikas sukurti žaidimą žaisti! Valdymo schema bus labai paprasta: norint pakeisti judėjimo kryptį, galima naudoti pelę (kompiuteryje) arba liesti ekraną (mobiliajame įrenginyje). Norėdami tai įgyvendinti, užsiregistruosime Renginių klausytojai „Mouse and Touch“ renginiams.
Visa tai pasirūpins 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() yra įvykių klausytojai, kurie skambina updateDirection() (iš networking.js), kai įvyksta įvesties įvykis (pavyzdžiui, kai pajudinama pelė). updateDirection() tvarko pranešimų siuntimą su serveriu, kuris tvarko įvesties įvykį ir atitinkamai atnaujina žaidimo būseną.
7. Kliento būsena
Ši dalis yra pati sunkiausia pirmoje įrašo dalyje. Nenusiminkite, jei pirmą kartą skaitydami nesupratote! Jūs netgi galite tai praleisti ir prie jo grįžti vėliau.
Paskutinė galvosūkio dalis, reikalinga norint užpildyti kliento / serverio kodą valstybės. Prisimenate kodo fragmentą iš Kliento atvaizdavimo skyriaus?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
}
getCurrentState() turėtų mums pateikti dabartinę kliento žaidimo būseną bet kuriuo laiko momentu remiantis iš serverio gautais atnaujinimais. Štai žaidimo atnaujinimo, kurį serveris gali siųsti, pavyzdys:
Kiekviename žaidimo atnaujinime yra penki identiški laukai:
t: serverio laiko žyma, nurodanti, kada buvo sukurtas šis naujinimas.
me: Informacija apie grotuvą, gaunantį šį naujinimą.
kiti: informacijos apie kitus žaidėjus, dalyvaujančius tame pačiame žaidime, rinkinys.
kulkos: informacijos apie žaidimo sviedinius masyvas.
Iškabos: dabartiniai pirmaujančiųjų sąrašo duomenys. Šiame įraše mes jų nenagrinėsime.
7.1 Naivi kliento būsena
Naivus įgyvendinimas getCurrentState() gali tiesiogiai grąžinti duomenis tik iš paskutinio gauto žaidimo atnaujinimo.
naive-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}
Gražus ir aiškus! Bet jei tik tai būtų taip paprasta. Viena iš priežasčių, kodėl šis įgyvendinimas yra problemiškas: jis riboja atvaizdavimo kadrų dažnį iki serverio laikrodžio dažnio.
Kadrų dažnis: kadrų (t. y. skambučių) skaičius render()) per sekundę arba FPS. Žaidimai paprastai siekia pasiekti bent 60 FPS.
Tick Rate: dažnis, kuriuo serveris siunčia žaidimo naujinimus klientams. Jis dažnai yra mažesnis nei kadrų dažnis. Mūsų žaidime serveris veikia 30 ciklų per sekundę dažniu.
Jei tik pateikiame naujausią žaidimo atnaujinimą, FPS iš esmės niekada neviršys 30, nes iš serverio niekada negauname daugiau nei 30 atnaujinimų per sekundę. Net jei paskambinsime render() 60 kartų per sekundę, tada pusė šių skambučių tiesiog perbraižys tą patį, iš esmės nieko nedarydami. Kita naivaus įgyvendinimo problema yra ta galioja vėlavimai. Esant idealiam interneto greičiui, klientas gaus žaidimo atnaujinimą tiksliai kas 33 ms (30 per sekundę):
Deja, nieko nėra tobulo. Realesnis vaizdas būtų toks:
Naivus įgyvendinimas yra praktiškai blogiausias atvejis, kai kalbama apie delsą. Jei žaidimo atnaujinimas gaunamas su 50 ms vėlavimu, tada klientų prekystalių papildomi 50 ms, nes vis dar rodoma ankstesnio atnaujinimo žaidimo būsena. Galite įsivaizduoti, kaip tai nepatogu žaidėjui: dėl savavališko stabdymo žaidimas bus trūkčiojantis ir nestabilus.
7.2 Patobulinta kliento būsena
Mes šiek tiek patobulinsime naivų įgyvendinimą. Pirma, mes naudojame pateikimo vėlavimas 100 ms. Tai reiškia, kad „dabartinė“ kliento būsena visada atsiliks nuo žaidimo būsenos serveryje 100 ms. Pavyzdžiui, jei laikas serveryje yra 150, tada klientas pateiks būseną, kurioje tuo metu buvo serveris 50:
Tai suteikia mums 100 ms buferį, kad išgyventume nenuspėjamus žaidimo atnaujinimo laikus:
Atlyginimas už tai bus nuolatinis įvesties vėlavimas 100 ms. Tai nedidelė auka dėl sklandaus žaidimo – dauguma žaidėjų (ypač atsitiktiniai) net nepastebės šio vėlavimo. Žmonėms daug lengviau prisitaikyti prie pastovaus 100 ms delsos, nei žaisti su nenuspėjama delsa.
Galime naudoti kitą techniką, vadinamą kliento pusės prognozė, kuris puikiai sumažina suvokiamą delsą, tačiau šiame įraše nebus aptartas.
Kitas mūsų naudojamas patobulinimas yra tiesinė interpoliacija. Dėl pateikimo vėlavimo paprastai esame bent vienu atnaujinimu į priekį nuo esamo kliento laiko. Kai skambina getCurrentState(), galime įvykdyti tiesinė interpoliacija tarp žaidimo atnaujinimų prieš pat ir po esamo laiko kliente:
Tai išsprendžia kadrų dažnio problemą: dabar galime pateikti unikalius kadrus bet kokiu norimu kadrų dažniu!
7.3 Patobulintos kliento būsenos diegimas
Įgyvendinimo pavyzdys src/client/state.js naudoja tiek pateikimo delsą, tiek tiesinę interpoliaciją, bet neilgai. Suskaidykime kodą į dvi dalis. Štai pirmasis:
state.js, 1 dalis
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;
}
Pirmas žingsnis yra išsiaiškinti, ką currentServerTime(). Kaip matėme anksčiau, kiekviename žaidimo atnaujinime yra serverio laiko žyma. Norime naudoti pateikimo delsą, kad vaizdas būtų pateiktas 100 ms už serverio, bet mes niekada nesužinosime dabartinio laiko serveryje, nes negalime žinoti, kiek laiko užtruko, kol naujiniai mus pasiekė. Internetas yra nenuspėjamas ir jo greitis gali labai skirtis!
Norėdami išspręsti šią problemą, galime naudoti pagrįstą apytikslį skaičiavimą: mes apsimesti, kad pirmasis atnaujinimas buvo gautas akimirksniu. Jei tai būtų tiesa, mes žinotume serverio laiką šiuo konkrečiu momentu! Mes saugome serverio laiko žymą firstServerTimestamp ir išgelbėk mūsų vietinis (kliento) laiko žyma tuo pačiu momentu gameStart.
O palauk. Ar laikas serveryje neturi būti = laikas kliente? Kodėl skiriame „serverio laiko žymą“ ir „kliento laiko žymą“? Tai puikus klausimas! Pasirodo, jie nėra tas pats dalykas. Date.now() kliente ir serveryje grąžins skirtingas laiko žymas, ir tai priklauso nuo vietinių šių įrenginių veiksnių. Niekada nemanykite, kad laiko žymos visuose įrenginiuose bus vienodos.
Dabar mes suprantame, ką daro currentServerTime(): grįžta esamo pateikimo laiko serverio laiko žyma. Kitaip tariant, tai yra dabartinis serverio laikas (firstServerTimestamp <+ (Date.now() - gameStart)) atėmus pateikimo delsą (RENDER_DELAY).
Dabar pažiūrėkime, kaip tvarkome žaidimų atnaujinimus. Kai iš serverio gaunamas atnaujinimas, jis iškviečiamas processGameUpdate(), ir išsaugome naują naujinimą masyve gameUpdates. Tada, norėdami patikrinti atminties naudojimą, pašaliname visus senus atnaujinimus bazinis atnaujinimasnes mums jų nebereikia.
Kas yra „pagrindinis atnaujinimas“? Tai pirmasis atnaujinimas, kurį randame judėdami atgal nuo dabartinio serverio laiko. Prisimeni šią diagramą?
Žaidimo atnaujinimas, esantis tiesiai „Kliento pateikimo laiko“ kairėje, yra pagrindinis atnaujinimas.
Kam naudojamas bazinis naujinimas? Kodėl galime atsisakyti pradinio lygio naujinimų? Norėdami tai išsiaiškinti, leiskite pagaliau apsvarstyti įgyvendinimą getCurrentState():
state.js, 2 dalis
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),
};
}
}
Mes tvarkome tris atvejus:
base < 0 reiškia, kad iki dabartinio pateikimo laiko atnaujinimų nėra (žr. aukščiau pateiktą įgyvendinimą getBaseUpdate()). Tai gali įvykti žaidimo pradžioje dėl pateikimo delsos. Šiuo atveju naudojame naujausią gautą naujinimą.
base yra naujausias mūsų turimas atnaujinimas. Taip gali būti dėl tinklo vėlavimo arba prasto interneto ryšio. Šiuo atveju taip pat naudojame naujausią turimą naujinimą.
Turime atnaujinimą prieš ir po dabartinio pateikimo laiko, todėl galime interpoliuoti!
Viskas, kas liko viduje state.js yra tiesinės interpoliacijos įgyvendinimas, kuris yra paprasta (bet nuobodu) matematika. Jei norite tai ištirti patys, atidarykite state.js apie GitHub.
2 dalis. Backend serveris
Šioje dalyje apžvelgsime „Node.js“ pagrindinę programą, kuri valdo mūsų .io žaidimo pavyzdys.
1. Serverio įėjimo taškas
Norėdami valdyti žiniatinklio serverį, naudosime populiarią žiniatinklio sistemą, skirtą Node.js, vadinamą išreikšti. Jis bus sukonfigūruotas mūsų serverio įėjimo taško failu src/server/server.js:
server.js 1 dalis
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}`);
Prisiminkite, kad pirmoje dalyje aptarėme Webpack? Čia mes naudosime savo žiniatinklio paketo konfigūracijas. Mes juos pritaikysime dviem būdais:
Sėkmingai užmezgę socket.io ryšį su serveriu, naujam lizdui nustatėme įvykių tvarkykles. Įvykių tvarkytojai tvarko pranešimus, gautus iš klientų, deleguodami vienam objektui game:
server.js 3 dalis
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);
}
Mes kuriame .io žaidimą, todėl mums reikia tik vienos kopijos Game („Žaidimas“) – visi žaidėjai žaidžia toje pačioje arenoje! Kitame skyriuje pamatysime, kaip ši klasė veikia. Game.
2. Žaidimų serveriai
Klasė Game yra svarbiausia serverio logika. Ji turi dvi pagrindines užduotis: žaidėjų valdymas и žaidimo simuliacija.
Pradėkime nuo pirmosios užduoties – žaidėjų valdymo.
game.js 1 dalis
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);
}
}
// ...
}
Šiame žaidime mes atpažinsime žaidėjus pagal lauką id jų socket.io lizdas (jei susipainiojate, grįžkite į server.js). Pati Socket.io kiekvienam lizdui priskiria unikalų idtodėl mums nereikia dėl to jaudintis. Aš jam paskambinsiu Žaidėjo ID.
Turėdami tai omenyje, panagrinėkime klasės egzempliorių kintamuosius Game:
sockets yra objektas, susiejantis grotuvo ID su lizdu, susietu su grotuvu. Tai leidžia mums pastoviu laiku pasiekti lizdus pagal jų grotuvo ID.
players yra objektas, susiejantis žaidėjo ID su kodu>Žaidėjo objektas
bullets yra objektų masyvas Bullet, kuris neturi konkrečios tvarkos. lastUpdateTime yra paskutinio žaidimo atnaujinimo laiko žyma. Kaip jis bus naudojamas, pamatysime netrukus. shouldSendUpdate yra pagalbinis kintamasis. Netrukus pamatysime ir jo naudojimą.
Metodai addPlayer(), removePlayer() и handleInput() nereikia aiškinti, jie naudojami server.js. Jei jums reikia atnaujinimo, grįžkite šiek tiek aukščiau.
Paskutinė eilutė constructor() prasideda atnaujinimo ciklas žaidimai (60 atnaujinimų per sekundę dažnis):
game.js 2 dalis
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;
}
}
// ...
}
metodas update() yra bene svarbiausia serverio pusės logikos dalis. Išvardykime viską, ką jis daro, eilės tvarka:
Apskaičiuoja, kiek valandų dt praėjo nuo paskutinio update().
Kiekvieną sviedinį atnaujina ir prireikus sunaikina. Šios funkcijos įgyvendinimą pamatysime vėliau. Kol kas mums užtenka tai žinoti bullet.update()grįžta truejei sviedinys turėtų būti sunaikintas (jis išėjo už arenos).
Atnaujina kiekvieną žaidėją ir, jei reikia, sukuria sviedinį. Šį įgyvendinimą taip pat pamatysime vėliau – player.update()gali grąžinti daiktą Bullet.
Tikrina, ar nesusidūrė sviediniai ir besinaudojantys žaidėjai applyCollisions(), kuris grąžina daugybę sviedinių, kurie pataikė į žaidėjus. Už kiekvieną grąžintą sviedinį padidiname jį paleidusio žaidėjo taškus (naudojant player.onDealtDamage()), tada išimkite sviedinį iš matricos bullets.
Praneša ir sunaikina visus nužudytus žaidėjus.
Siunčia žaidimo atnaujinimą visiems žaidėjams kiekviena sekundė kartus, kai skambino update(). Aukščiau minėtas pagalbinis kintamasis padeda mums tai stebėti shouldSendUpdate. Kaip update() skambinama 60 kartų/s, žaidimų atnaujinimus siunčiame 30 kartų/s. Taigi, laikrodžio dažnis serverio laikrodis yra 30 clocks/s (pirmoje dalyje kalbėjome apie laikrodžių dažnius).
Kodėl siųsti tik žaidimo atnaujinimus per laiką ? Norėdami išsaugoti kanalą. 30 žaidimų atnaujinimų per sekundę yra daug!
Kodėl tada tiesiog nepaskambinus? update() 30 kartų per sekundę? Norėdami pagerinti žaidimo modeliavimą. Kuo dažniau jis vadinamas update(), tuo tikslesnis bus žaidimo modeliavimas. Tačiau nesijaudinkite dėl daugybės iššūkių update(), nes tai skaičiuojant brangi užduotis – užtenka 60 per sekundę.
Likusi klasė Game susideda iš pagalbinių metodų, naudojamų update():
getLeaderboard() gana paprasta – surūšiuoja žaidėjus pagal balą, paima penkis geriausius ir pateikia kiekvieno vartotojo vardą bei rezultatą.
createUpdate() naudojamas update() sukurti žaidimo naujinimus, kurie platinami žaidėjams. Jo pagrindinė užduotis yra iškviesti metodus serializeForUpdate()įdiegta klasėms Player и Bullet. Atkreipkite dėmesį, kad jis perduoda duomenis tik kiekvienam žaidėjui apie artimiausias žaidėjai ir sviediniai – nereikia perduoti informacijos apie žaidimo objektus, kurie yra toli nuo žaidėjo!
3. Žaidimo objektai serveryje
Mūsų žaidime sviediniai ir žaidėjai iš tikrųjų yra labai panašūs: jie yra abstraktūs apvalūs judantys žaidimo objektai. Norėdami pasinaudoti šiuo grotuvų ir sviedinių panašumu, pradėkime nuo bazinės klasės diegimo Object:
Nieko sudėtingo čia nevyksta. Ši klasė bus geras pratęsimo tvirtinimo taškas. Pažiūrėkime, kaip klasėje Bullet naudoja 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;
}
}
Vykdymas Bullet labai trumpas! Pridėjome Object tik šie plėtiniai:
Naudojant paketą trumpas atsitiktiniam generavimui id sviedinys.
Lauko pridėjimas parentID, kad galėtumėte sekti žaidėją, kuris sukūrė šį sviedinį.
Grąžinamos vertės pridėjimas prie update(), kuris yra lygus truejei sviedinys yra už arenos (pamenate, apie tai kalbėjome paskutinėje dalyje?).
Pereikime prie 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,
};
}
}
Žaidėjai yra sudėtingesni nei sviediniai, todėl šioje klasėje reikėtų saugoti dar kelis laukus. Jo metodas update() atlieka daug darbo, ypač grąžina naujai sukurtą sviedinį, jei jo nebėra fireCooldown (atsimenate, kad apie tai kalbėjome ankstesniame skyriuje?). Tai taip pat praplečia metodą serializeForUpdate(), nes į žaidimo atnaujinimą turime įtraukti papildomus žaidėjo laukus.
Turėti bazinę klasę Object – svarbus žingsnis siekiant išvengti kodo pasikartojimo. Pavyzdžiui, nėra klasės Object kiekvienas žaidimo objektas turi turėti tą patį įgyvendinimą distanceTo(), o visų šių diegimų kopijavimas keliuose failuose būtų košmaras. Tai ypač svarbu dideliems projektamskai skaičius plečiasi Object klasės auga.
4. Susidūrimo aptikimas
Mums belieka atpažinti, kada sviediniai pataikė į žaidėjus! Prisiminkite šią kodo dalį iš metodo update() klasėje 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),
);
// ...
}
}
Turime įgyvendinti metodą applyCollisions(), kuris grąžina visus žaidėjus pataikiusius sviedinius. Laimei, tai padaryti nėra taip sunku, nes
Visi susiduriantys objektai yra apskritimai, o tai yra paprasčiausia forma susidūrimo aptikimui.
Mes jau turime metodą distanceTo(), kurią įdiegėme klasėje ankstesniame skyriuje Object.
Štai kaip atrodo susidūrimo aptikimo įdiegimas:
susidūrimai.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;
}
Šis paprastas susidūrimo aptikimas pagrįstas tuo du apskritimai susiduria, jei atstumas tarp jų centrų yra mažesnis už jų spindulių sumą. Štai atvejis, kai atstumas tarp dviejų apskritimų centrų yra tiksliai lygus jų spindulių sumai:
Čia reikia apsvarstyti dar keletą aspektų:
Sviedinys neturi pataikyti į jį sukūrusį žaidėją. Tai galima pasiekti lyginant bullet.parentID с player.id.
Sviedinys turėtų pataikyti tik vieną kartą kraštutiniu atveju, kai tuo pačiu metu pataiko į kelis žaidėjus. Šią problemą išspręsime naudodami operatorių break: kai tik surandamas su sviediniu susidūręs žaidėjas, sustabdome paiešką ir pereiname prie kito sviedinio.
pabaiga
Tai viskas! Aprašėme viską, ką reikia žinoti norint sukurti .io internetinį žaidimą. Kas toliau? Sukurkite savo .io žaidimą!
Visas pavyzdinis kodas yra atvirojo kodo ir paskelbtas GitHub.