Crearea unui joc web multiplayer .io

Crearea unui joc web multiplayer .io
Lansat în 2015 Agar.io a devenit precursorul unui nou gen jocuri .iocare a crescut în popularitate de atunci. Am experimentat personal creșterea popularității jocurilor .io: în ultimii trei ani, am făcut-o a creat și vândut două jocuri din acest gen..

În cazul în care nu ați auzit niciodată de aceste jocuri până acum, acestea sunt jocuri web gratuite pentru multiplayer, ușor de jucat (nu este necesar un cont). De obicei, se confruntă cu mulți jucători adversi în aceeași arenă. Alte jocuri .io celebre: Slither.io и Diep.io.

În această postare, vom explora cum creați un joc .io de la zero. Pentru aceasta, doar cunoștințele de Javascript vor fi suficiente: trebuie să înțelegeți lucruri precum sintaxa ES6, cuvânt cheie this и promisiuni. Chiar dacă cunoștințele dvs. de Javascript nu sunt perfecte, puteți înțelege cea mai mare parte a postării.

exemplu de joc .io

Pentru asistență pentru învățare, ne vom referi la exemplu de joc .io. Încearcă să-l joci!

Crearea unui joc web multiplayer .io
Jocul este destul de simplu: controlezi o navă într-o arenă în care sunt alți jucători. Nava ta trage automat proiectile și încerci să lovești alți jucători în timp ce eviți proiectilele lor.

1. Scurtă prezentare generală / structura proiectului

recomanda descărca codul sursă exemplu de joc ca să mă poți urmări.

Exemplul folosește următoarele:

  • Expres este cel mai popular cadru web Node.js care gestionează serverul web al jocului.
  • socket.io - o bibliotecă websocket pentru schimbul de date între un browser și un server.
  • WebPACK - manager de module. Puteți citi de ce să utilizați Webpack. aici.

Iată cum arată structura directorului proiectului:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

public/

Totul într-un folder public/ vor fi transmise static de către server. ÎN public/assets/ conține imagini folosite de proiectul nostru.

src /

Tot codul sursă este în folder src/... Numele client/ и server/ vorbesc de la sine şi shared/ conține un fișier de constante care este importat atât de client, cât și de server.

2. Ansambluri/setari proiect

După cum am menționat mai sus, folosim managerul de module pentru a construi proiectul. WebPACK. Să aruncăm o privire la configurația noastră Webpack:

webpack.common.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

Cele mai importante linii de aici sunt:

  • src/client/index.js este punctul de intrare al clientului Javascript (JS). Webpack va începe de aici și va căuta recursiv alte fișiere importate.
  • Ieșirea JS a versiunii noastre Webpack va fi localizată în director dist/. Voi numi acest fișier nostru pachet js.
  • Folosim Hărmălaie, și în special configurația @babel/preset-env la transpilarea codului nostru JS pentru browsere mai vechi.
  • Folosim un plugin pentru a extrage toate CSS-urile la care fac referire fișierele JS și pentru a le combina într-un singur loc. Îl voi numi al nostru pachet css.

Este posibil să fi observat nume ciudate de fișiere de pachete '[name].[contenthash].ext'. Ele conțin înlocuiri de nume de fișier Pachetul web: [name] va fi înlocuit cu numele punctului de intrare (în cazul nostru, acesta game) și [contenthash] va fi înlocuit cu un hash al conținutului fișierului. Facem asta pentru optimizați proiectul pentru hashing - puteți spune browserelor să memoreze în cache pachetele noastre JS pe termen nelimitat, deoarece dacă un pachet se modifică, atunci se schimbă și numele fișierului acestuia (schimbări contenthash). Rezultatul final va fi numele fișierului de vizualizare game.dbeee76e91a97d0c7207.js.

fișier webpack.common.js este fișierul de configurare de bază pe care îl importăm în configurațiile de dezvoltare și proiect finalizate. Iată un exemplu de configurare de dezvoltare:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

Pentru eficiență, folosim în procesul de dezvoltare webpack.dev.js, și comută la webpack.prod.jspentru a optimiza dimensiunile pachetelor la implementarea în producție.

Setare locală

Recomand să instalați proiectul pe o mașină locală, astfel încât să puteți urma pașii enumerați în această postare. Configurarea este simplă: în primul rând, sistemul trebuie să aibă instalat Nod и NPM. Mai departe trebuie să faci

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

și ești gata de plecare! Pentru a porni serverul de dezvoltare, rulați

$ npm run develop

și accesați browserul web localhost: 3000. Serverul de dezvoltare va reconstrui automat pachetele JS și CSS pe măsură ce codul se schimbă - doar reîmprospătați pagina pentru a vedea toate modificările!

3. Puncte de intrare client

Să trecem la codul jocului în sine. Mai întâi avem nevoie de o pagină index.html, la vizitarea site-ului, browserul îl va încărca mai întâi. Pagina noastră va fi destul de simplă:

index.html

Un exemplu de joc .io  JOACA

Acest exemplu de cod a fost ușor simplificat pentru claritate și voi face același lucru cu multe dintre celelalte exemple de postare. Codul complet poate fi întotdeauna vizualizat la Github.

Avem:

  • Element de pânză HTML5 (<canvas>) pe care îl vom folosi pentru a reda jocul.
  • <link> pentru a adăuga pachetul nostru CSS.
  • <script> pentru a adăuga pachetul nostru Javascript.
  • Meniul principal cu numele de utilizator <input> și butonul PLAY (<button>).

După încărcarea paginii de pornire, browserul va începe să execute codul Javascript, pornind de la fișierul JS de la punctul de intrare: 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);
  };
});

Acest lucru poate părea complicat, dar nu se întâmplă mare lucru aici:

  1. Importul mai multor alte fișiere JS.
  2. Import CSS (deci Webpack știe să le includă în pachetul nostru CSS).
  3. lansa connect() pentru a stabili o conexiune cu serverul și a rula downloadAssets() pentru a descărca imaginile necesare redării jocului.
  4. După finalizarea etapei 3 este afișat meniul principal (playMenu).
  5. Setarea handler-ului pentru apăsarea butonului „PLAY”. Când butonul este apăsat, codul inițializează jocul și îi spune serverului că suntem gata să jucăm.

Principala „carne” a logicii noastre client-server se află în acele fișiere care au fost importate de fișier index.js. Acum le vom considera pe toate în ordine.

4. Schimb de date despre clienți

În acest joc, folosim o bibliotecă binecunoscută pentru a comunica cu serverul socket.io. Socket.io are suport nativ prize web, care sunt potrivite pentru comunicarea în două sensuri: putem trimite mesaje către server и serverul ne poate trimite mesaje pe aceeași conexiune.

Vom avea un singur dosar src/client/networking.jsde care se va ocupa cu toate comunicare cu serverul:

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);
};

Acest cod a fost, de asemenea, scurtat ușor pentru claritate.

Există trei acțiuni principale în acest fișier:

  • Încercăm să ne conectăm la server. connectedPromise permis numai atunci când am stabilit o conexiune.
  • Dacă conexiunea are succes, înregistrăm funcții de apel invers (processGameUpdate() и onGameOver()) pentru mesajele pe care le putem primi de la server.
  • Exportam play() и updateDirection()astfel încât alte fișiere să le poată folosi.

5. Redarea clientului

Este timpul să afișați imaginea pe ecran!

…dar înainte de a putea face asta, trebuie să descarcăm toate imaginile (resursele) necesare pentru aceasta. Să scriem un manager de resurse:

active.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];

Managementul resurselor nu este atât de greu de implementat! Ideea principală este de a stoca un obiect assets, care va lega cheia numelui fișierului de valoarea obiectului Image. Când resursa este încărcată, o stocăm într-un obiect assets pentru acces rapid în viitor. Când va fi permisă descărcarea fiecărei resurse individuale (adică toate resurse), permitem downloadPromise.

După descărcarea resurselor, puteți începe redarea. După cum am spus mai devreme, pentru a desena pe o pagină web, folosim Pânză HTML5 (<canvas>). Jocul nostru este destul de simplu, așa că trebuie doar să desenăm următoarele:

  1. fundal
  2. Nava jucător
  3. Alți jucători din joc
  4. Cochilii

Iată fragmentele importante src/client/render.js, care redă exact cele patru elemente enumerate mai sus:

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);
}

Acest cod este, de asemenea, scurtat pentru claritate.

render() este funcția principală a acestui fișier. startRendering() и stopRendering() controlați activarea buclei de randare de 60 FPS.

Implementări concrete ale funcțiilor individuale de ajutor de redare (de ex. renderBullet()) nu sunt atât de importante, dar iată un exemplu simplu:

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,
  );
}

Rețineți că folosim metoda getAsset(), care a fost văzut anterior în asset.js!

Dacă sunteți interesat să aflați despre alți ajutoare de randare, atunci citiți restul. src/client/render.js.

6. Intrarea clientului

E timpul să faci un joc jucabil! Schema de control va fi foarte simplă: pentru a schimba direcția de mișcare, puteți folosi mouse-ul (pe un computer) sau puteți atinge ecranul (pe un dispozitiv mobil). Pentru a implementa acest lucru, ne vom înregistra Ascultători de evenimente pentru evenimente Mouse și Touch.
Se va ocupa de toate acestea 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() sunt ascultători de evenimente care sună updateDirection() (de networking.js) când are loc un eveniment de intrare (de exemplu, când mouse-ul este mutat). updateDirection() se ocupă de mesageria cu serverul, care se ocupă de evenimentul de intrare și actualizează starea jocului în consecință.

7. Statutul clientului

Această secțiune este cea mai dificilă din prima parte a postării. Nu vă descurajați dacă nu îl înțelegeți prima dată când îl citiți! Puteți chiar să o săriți peste el și să reveniți la el mai târziu.

Ultima piesă a puzzle-ului necesară pentru a completa codul client/server este de stat. Vă amintiți fragmentul de cod din secțiunea Redare client?

render.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() ar trebui să ne poată oferi starea curentă a jocului în client în orice moment al timpului pe baza actualizărilor primite de la server. Iată un exemplu de actualizare a jocului pe care serverul o poate trimite:

{
  "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
    }
  ]
}

Fiecare actualizare a jocului conține cinci câmpuri identice:

  • t: Marca temporală a serverului care indică când a fost creată această actualizare.
  • me: Informații despre jucătorul care primește această actualizare.
  • alţii: O serie de informații despre alți jucători care participă la același joc.
  • gloanţe: o serie de informații despre proiectile din joc.
  • Leaderboard: Date curente din clasament. În această postare, nu le vom lua în considerare.

7.1 Stare client naiv

Implementare naivă getCurrentState() poate returna doar datele celei mai recente actualizări de joc primite.

naiv-state.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Frumos și clar! Dar dacă ar fi atât de simplu. Unul dintre motivele pentru care această implementare este problematică: limitează rata de redare a cadrelor la rata de ceas a serverului.

Frame Rate: numărul de cadre (adică apeluri render()) pe secundă sau FPS. De obicei, jocurile se străduiesc să atingă cel puțin 60 FPS.

Rata de bifare: Frecvența la care serverul trimite clienților actualizări de joc. Este adesea mai mică decât rata de cadre. În jocul nostru, serverul rulează la o frecvență de 30 de cicluri pe secundă.

Dacă doar redăm cea mai recentă actualizare a jocului, atunci FPS-ul nu va depăși niciodată 30, deoarece nu primim niciodată mai mult de 30 de actualizări pe secundă de la server. Chiar dacă sunăm render() De 60 de ori pe secundă, apoi jumătate dintre aceste apeluri vor redesena același lucru, în esență fără a face nimic. O altă problemă cu implementarea naivă este că aceasta predispus la întârzieri. Cu o viteză ideală de internet, clientul va primi o actualizare a jocului exact la fiecare 33 ms (30 pe secundă):

Crearea unui joc web multiplayer .io
Din păcate, nimic nu este perfect. O imagine mai realistă ar fi:
Crearea unui joc web multiplayer .io
Implementarea naivă este practic cel mai rău caz când vine vorba de latență. Dacă o actualizare a jocului este primită cu o întârziere de 50 ms, atunci standuri de clienți 50 ms în plus pentru că încă redă starea jocului de la actualizarea anterioară. Vă puteți imagina cât de incomod este pentru jucător: frânarea arbitrară va face jocul să se simtă sacadat și instabil.

7.2 Starea clientului îmbunătățită

Vom aduce câteva îmbunătățiri implementării naive. În primul rând, folosim întârziere de redare timp de 100 ms. Aceasta înseamnă că starea „actuală” a clientului va rămâne întotdeauna în urma stării jocului de pe server cu 100 ms. De exemplu, dacă ora de pe server este 150, atunci clientul va reda starea în care se afla serverul la momentul respectiv 50:

Crearea unui joc web multiplayer .io
Acest lucru ne oferă un buffer de 100 ms pentru a supraviețui timpilor imprevizibili de actualizare a jocului:

Crearea unui joc web multiplayer .io
Recompensa pentru aceasta va fi permanentă decalaj de intrare timp de 100 ms. Acesta este un sacrificiu minor pentru un joc fluid - majoritatea jucătorilor (în special jucătorii ocazionali) nici măcar nu vor observa această întârziere. Este mult mai ușor pentru oameni să se adapteze la o latență constantă de 100 ms decât să se joace cu o latență imprevizibilă.

Putem folosi și o altă tehnică numită predicție pe partea clientului, care face o treabă bună în reducerea latenței percepute, dar nu va fi tratată în această postare.

O altă îmbunătățire pe care o folosim este interpolare liniară. Din cauza decalajului de redare, de obicei avem cel puțin o actualizare înainte de ora curentă în client. Când chemat getCurrentState(), putem executa interpolare liniară între actualizările jocului chiar înainte și după ora curentă în client:

Crearea unui joc web multiplayer .io
Acest lucru rezolvă problema ratei cadrelor: acum putem reda cadre unice la orice frecvență de cadre dorim!

7.3 Implementarea stării îmbunătățite a clientului

Exemplu de implementare în src/client/state.js utilizează atât întârzierea randării, cât și interpolarea liniară, dar nu pentru mult timp. Să împărțim codul în două părți. Iată primul:

state.js partea 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // 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;
}

Primul pas este să-ți dai seama ce currentServerTime(). După cum am văzut mai devreme, fiecare actualizare a jocului include un marcaj de timp al serverului. Vrem să folosim latența de randare pentru a reda imaginea la 100 ms în spatele serverului, dar nu vom ști niciodată ora curentă pe server, pentru că nu putem ști cât timp a durat până când vreuna dintre actualizări a ajuns la noi. Internetul este imprevizibil și viteza lui poate varia foarte mult!

Pentru a ocoli această problemă, putem folosi o aproximare rezonabilă: noi pretinde că prima actualizare a sosit instantaneu. Dacă acest lucru ar fi adevărat, atunci am ști ora serverului în acest moment! Stocăm marca temporală a serverului în firstServerTimestamp și păstrează-ne local marca temporală (client) în același moment în gameStart.

Oh, așteptați. Nu ar trebui să fie ora serverului = ora clientului? De ce facem distincția între „marca temporală a serverului” și „marca temporală a clientului”? Aceasta este o întrebare grozavă! Se pare că nu sunt același lucru. Date.now() va returna marcaje temporale diferite în client și server și depinde de factorii locali ai acestor mașini. Nu presupuneți niciodată că marcajele de timp vor fi aceleași pe toate mașinile.

Acum înțelegem ce face currentServerTime(): se întoarce marca temporală a serverului pentru timpul de randare curent. Cu alte cuvinte, aceasta este ora curentă a serverului (firstServerTimestamp <+ (Date.now() - gameStart)) minus întârzierea redării (RENDER_DELAY).

Acum să aruncăm o privire la modul în care gestionăm actualizările jocului. Când este primit de la serverul de actualizare, este apelat processGameUpdate()și salvăm noua actualizare într-o matrice gameUpdates. Apoi, pentru a verifica utilizarea memoriei, eliminăm toate actualizările vechi înainte actualizare de bazăpentru că nu mai avem nevoie de ele.

Ce este o „actualizare de bază”? Acest prima actualizare o găsim prin deplasarea înapoi de la ora curentă a serverului. Îți amintești această diagramă?

Crearea unui joc web multiplayer .io
Actualizarea jocului direct din stânga „Timp de redare a clientului” este actualizarea de bază.

Pentru ce este folosită actualizarea de bază? De ce putem renunța la actualizările de bază? Pentru a înțelege asta, haideți în sfârșit luați în considerare implementarea getCurrentState():

state.js partea 2

export function getCurrentState() {
  if (!firstServerTimestamp) {
    return {};
  }

  const base = getBaseUpdate();
  const serverTime = currentServerTime();

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 0) {
    return gameUpdates[gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
    return gameUpdates[base];
  } else {
    const baseUpdate = gameUpdates[base];
    const next = gameUpdates[base + 1];
    const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
    return {
      me: interpolateObject(baseUpdate.me, next.me, r),
      others: interpolateObjectArray(baseUpdate.others, next.others, r),
      bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
    };
  }
}

Ne ocupăm de trei cazuri:

  1. base < 0 înseamnă că nu există actualizări până la momentul actual de randare (vezi implementarea de mai sus getBaseUpdate()). Acest lucru se poate întâmpla chiar la începutul jocului din cauza decalajului de redare. În acest caz, folosim cea mai recentă actualizare primită.
  2. base este cea mai recentă actualizare pe care o avem. Acest lucru se poate datora întârzierii rețelei sau a unei conexiuni slabe la internet. În acest caz, folosim și cea mai recentă actualizare pe care o avem.
  3. Avem o actualizare atât înainte, cât și după timpul curent de randare, așa că putem interpola!

Tot ce a mai rămas înăuntru state.js este o implementare a interpolării liniare care este matematică simplă (dar plictisitoare). Dacă vrei să-l explorezi singur, atunci deschide state.js pe Github.

Partea 2. Server de backend

În această parte, vom arunca o privire asupra backend-ului Node.js care ne controlează exemplu de joc .io.

1. Punct de intrare server

Pentru a gestiona serverul web, vom folosi un cadru web popular pentru Node.js numit Expres. Acesta va fi configurat de fișierul punct de intrare al serverului nostru src/server/server.js:

server.js partea 1

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}`);

Vă amintiți că în prima parte am discutat despre Webpack? Aici vom folosi configurațiile noastre Webpack. Le vom folosi în două moduri:

  • Использовать webpack-dev-middleware pentru a reconstrui automat pachetele noastre de dezvoltare sau
  • folderul de transfer static dist/, în care Webpack va scrie fișierele noastre după construirea de producție.

O altă sarcină importantă server.js este de a configura serverul socket.iocare se conectează doar la serverul Express:

server.js partea 2

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);
});

După ce am stabilit cu succes o conexiune socket.io la server, am configurat handlere de evenimente pentru noul socket. Managerii de evenimente gestionează mesajele primite de la clienți prin delegarea unui obiect singleton game:

server.js partea 3

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);
}

Creăm un joc .io, așa că avem nevoie de o singură copie Game ("Joc") - toți jucătorii joacă în aceeași arenă! În secțiunea următoare, vom vedea cum funcționează această clasă. Game.

2. Servere de jocuri

clasă Game conține cea mai importantă logică din partea serverului. Are două sarcini principale: managementul jucătorilor и simularea jocului.

Să începem cu prima sarcină, managementul jucătorilor.

game.js partea 1

const Constants = require('../shared/constants');
const Player = require('./player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // 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);
    }
  }

  // ...
}

În acest joc, vom identifica jucătorii după teren id socket-ul lor socket.io (dacă sunteți confuz, atunci reveniți la server.js). Socket.io însuși atribuie fiecărui socket un unic iddeci nu trebuie să ne facem griji pentru asta. Il voi suna ID-ul jucătorului.

Având în vedere acest lucru, haideți să explorăm variabilele de instanță dintr-o clasă Game:

  • sockets este un obiect care leagă ID-ul jucătorului la soclul care este asociat cu jucătorul. Ne permite să accesăm socket-urile prin ID-urile lor de jucător într-un timp constant.
  • players este un obiect care leagă ID-ul jucătorului de cod>Obiect jucător

bullets este o serie de obiecte Bullet, care nu are o ordine definită.
lastUpdateTime este marcajul de timp al ultimei actualizări ale jocului. Vom vedea în scurt timp cum se folosește.
shouldSendUpdate este o variabilă auxiliară. Îi vom vedea și folosirea în curând.
metode addPlayer(), removePlayer() и handleInput() nu este nevoie să explic, sunt folosite în server.js. Dacă trebuie să vă reîmprospătați memoria, întoarceți-vă puțin mai sus.

Ultima linie constructor() Pornește ciclu de actualizare jocuri (cu o frecvență de 60 de actualizări/s):

game.js partea 2

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;
    }
  }

  // ...
}

metodă update() conține poate cea mai importantă parte a logicii serverului. Iată ce face, în ordine:

  1. Calculează cât timp dt trecut de la ultimul update().
  2. Reîmprospătează fiecare proiectil și îl distruge dacă este necesar. Vom vedea implementarea acestei funcționalități mai târziu. Deocamdată este suficient să știm asta bullet.update() se intoarce truedacă proiectilul ar trebui distrus (a ieșit din arenă).
  3. Actualizează fiecare jucător și generează un proiectil dacă este necesar. Vom vedea și această implementare mai târziu − player.update() poate returna un obiect Bullet.
  4. Verifică dacă există coliziuni între proiectile și jucători cu applyCollisions(), care returnează o serie de proiectile care lovesc jucătorii. Pentru fiecare proiectil returnat, creștem punctele jucătorului care l-a tras (folosind player.onDealtDamage()) și apoi scoateți proiectilul din matrice bullets.
  5. Notifică și distruge toți jucătorii uciși.
  6. Trimite o actualizare a jocului tuturor jucătorilor fiecare secunda ori când este sunat update(). Acest lucru ne ajută să ținem evidența variabilei auxiliare menționate mai sus. shouldSendUpdate. pentru că update() apelat de 60 de ori/s, trimitem actualizări de joc de 30 de ori/s. Prin urmare, frecvența ceasului ceasul serverului este de 30 de ceasuri/s (am vorbit despre ratele de ceas în prima parte).

De ce să trimiți doar actualizări de joc prin timp ? Pentru a salva canalul. 30 de actualizări de joc pe secundă sunt multe!

De ce nu sunați update() de 30 de ori pe secundă? Pentru a îmbunătăți simularea jocului. Cu atât mai des numit update(), cu atât simularea jocului va fi mai precisă. Dar nu te lăsa prea dus de numărul de provocări. update(), deoarece aceasta este o sarcină costisitoare din punct de vedere computațional - 60 pe secundă sunt suficiente.

Restul clasei Game constă în metode de ajutor utilizate în update():

game.js partea 3

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() destul de simplu - sortează jucătorii după scor, ia primii cinci și returnează numele de utilizator și scorul pentru fiecare.

createUpdate() folosit in update() pentru a crea actualizări de joc care sunt distribuite jucătorilor. Sarcina sa principală este de a apela metode serializeForUpdate()implementate pentru clase Player и Bullet. Rețineți că transmite doar date fiecărui jucător despre cel mai apropiat jucători și proiectile - nu este nevoie să transmiteți informații despre obiectele de joc care sunt departe de jucător!

3. Obiecte de joc pe server

În jocul nostru, proiectilele și jucătorii sunt de fapt foarte asemănătoare: sunt obiecte de joc abstracte, rotunde, mobile. Pentru a profita de această similitudine între jucători și proiectile, să începem prin a implementa clasa de bază Object:

obiect.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,
    };
  }
}

Nu se întâmplă nimic complicat aici. Această clasă va fi un bun punct de ancorare pentru extindere. Să vedem cum e clasa Bullet utilizări 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;
  }
}

punerea în aplicare Bullet foarte scurt! Am adăugat la Object doar următoarele extensii:

  • Folosind un pachet scurtid pentru generare aleatorie id proiectil.
  • Adăugarea unui câmp parentIDastfel încât să puteți urmări jucătorul care a creat acest proiectil.
  • Adăugarea unei valori de returnare la update(), care este egal cu truedacă proiectilul se află în afara arenei (vă amintiți că am vorbit despre asta în ultima secțiune?).

Să trecem la 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,
    };
  }
}

Jucătorii sunt mai complexi decât proiectilele, așa că mai multe câmpuri ar trebui să fie stocate în această clasă. Metoda lui update() lucrează mult, în special, returnează proiectilul nou creat dacă nu a mai rămas niciunul fireCooldown (Îți amintești că am vorbit despre asta în secțiunea anterioară?). De asemenea, extinde metoda serializeForUpdate(), deoarece trebuie să includem câmpuri suplimentare pentru jucător în actualizarea jocului.

Având o clasă de bază Object - un pas important pentru a evita repetarea codului. De exemplu, fără clasă Object fiecare obiect de joc trebuie să aibă aceeași implementare distanceTo(), iar copierea-lipirea tuturor acestor implementări în mai multe fișiere ar fi un coșmar. Acest lucru devine deosebit de important pentru proiectele mari.când numărul de extindere Object clasele cresc.

4. Detectarea coliziunilor

Singurul lucru care ne rămâne este să recunoaștem când proiectilele lovesc jucătorii! Amintiți-vă această bucată de cod din metodă update() in clasa Game:

joc.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),
    );

    // ...
  }
}

Trebuie să implementăm metoda applyCollisions(), care returnează toate proiectilele care lovesc jucătorii. Din fericire, nu este atât de greu de făcut pentru că

  • Toate obiectele care se ciocnesc sunt cercuri, care este cea mai simplă formă pentru a implementa detectarea coliziunilor.
  • Avem deja o metodă distanceTo(), pe care l-am implementat în secțiunea anterioară din clasă Object.

Iată cum arată implementarea noastră a detectării coliziunilor:

coliziuni.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;
}

Această detectare simplă a coliziunilor se bazează pe faptul că două cercuri se ciocnesc dacă distanța dintre centrele lor este mai mică decât suma razelor lor. Iată cazul în care distanța dintre centrele a două cercuri este exact egală cu suma razelor lor:

Crearea unui joc web multiplayer .io
Mai sunt câteva aspecte de luat în considerare aici:

  • Proiectilul nu trebuie să lovească jucătorul care l-a creat. Acest lucru se poate realiza prin comparare bullet.parentID с player.id.
  • Proiectilul trebuie să lovească o singură dată în cazul limită al mai multor jucători care se ciocnesc în același timp. Vom rezolva această problemă folosind operatorul break: de îndată ce jucătorul care se ciocnește cu proiectilul este găsit, oprim căutarea și trecem la următorul proiectil.

Sfârșit

Asta e tot! Am acoperit tot ce trebuie să știți pentru a crea un joc web .io. Ce urmeaza? Construiește-ți propriul joc .io!

Tot codul eșantion este open source și postat pe Github.

Sursa: www.habr.com

Adauga un comentariu