Creació d'un joc web multijugador .io

Creació d'un joc web multijugador .io
Publicat el 2015 Agar.io es va convertir en el progenitor d'un nou gènere jocs .ioque ha crescut en popularitat des de llavors. Personalment, he experimentat l'augment de la popularitat dels jocs .io: durant els últims tres anys, ho he fet va crear i vendre dos jocs d'aquest gènere..

En cas que mai no hagis sentit parlar d'aquests jocs abans, es tracta de jocs web gratuïts per a multijugador que són fàcils de jugar (no cal cap compte). Normalment s'enfronten a molts jugadors contraris al mateix escenari. Altres jocs .io famosos: Slither.io и Diep.io.

En aquest post, explorarem com crear un joc .io des de zero. Per a això, només serà suficient el coneixement de Javascript: cal entendre coses com la sintaxi ES6, paraula clau this и Promeses. Encara que el vostre coneixement de Javascript no sigui perfecte, encara podeu entendre la major part de la publicació.

Exemple de joc .io

Per a l'ajuda d'aprenentatge, ens referirem Exemple de joc .io. Intenta jugar-hi!

Creació d'un joc web multijugador .io
El joc és bastant senzill: controles un vaixell en una sorra on hi ha altres jugadors. La teva nau dispara automàticament projectils i tu intentes colpejar altres jugadors evitant els seus projectils.

1. Breu visió general/estructura del projecte

recomanar descarregar codi font exemple de joc perquè em puguis seguir.

L'exemple utilitza el següent:

  • Exprés és el marc web Node.js més popular que gestiona el servidor web del joc.
  • socket.io - una biblioteca websocket per intercanviar dades entre un navegador i un servidor.
  • Webpack - Gestor de mòduls. Podeu llegir per què utilitzar Webpack. aquí.

Aquí teniu l'estructura del directori del projecte:

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

públic/

Tot en una carpeta public/ serà enviat estàticament pel servidor. EN public/assets/ conté imatges utilitzades pel nostre projecte.

src /

Tot el codi font es troba a la carpeta src/. Títols client/ и server/ parlen per si mateixos i shared/ conté un fitxer de constants que és importat tant pel client com pel servidor.

2. Muntatges/Configuració del projecte

Com s'ha esmentat anteriorment, utilitzem el gestor de mòduls per construir el projecte. Webpack. Fem una ullada a la nostra configuració de 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',
    }),
  ],
};

Les línies més importants aquí són:

  • src/client/index.js és el punt d'entrada del client Javascript (JS). Webpack començarà des d'aquí i cercarà recursivament altres fitxers importats.
  • El JS de sortida de la nostra compilació de Webpack es trobarà al directori dist/. Diré aquest fitxer nostre paquet js.
  • Fem servir Babel, i en particular la configuració @babel/preset-env a transpilar el nostre codi JS per a navegadors antics.
  • Estem utilitzant un connector per extreure tots els CSS a què fan referència els fitxers JS i combinar-los en un sol lloc. El diré nostre paquet css.

Potser heu notat noms de fitxer de paquets estranys '[name].[contenthash].ext'. Contenen substitucions de nom de fitxer Paquet web: [name] se substituirà pel nom del punt d'entrada (en el nostre cas, aquest game), i [contenthash] es substituirà per un hash del contingut del fitxer. Ho fem per optimitzar el projecte per hash - podeu dir als navegadors que emmagatzemin a la memòria cau els nostres paquets JS indefinidament, perquè si un paquet canvia, el seu nom de fitxer també canvia (canvis contenthash). El resultat final serà el nom del fitxer de visualització game.dbeee76e91a97d0c7207.js.

expedient webpack.common.js és el fitxer de configuració base que importem a les configuracions del projecte de desenvolupament i acabat. Aquí teniu un exemple de configuració de desenvolupament:

webpack.dev.js

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

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

Per a l'eficiència, utilitzem en el procés de desenvolupament webpack.dev.js, i canvia a webpack.prod.jsper optimitzar la mida dels paquets quan es desplega a producció.

Configuració local

Us recomano instal·lar el projecte en una màquina local perquè pugueu seguir els passos que s'indiquen en aquesta publicació. La configuració és senzilla: primer, el sistema ha d'haver instal·lat Node и NPM. A continuació, heu de fer

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

i estàs llest per marxar! Per iniciar el servidor de desenvolupament, només cal que executeu

$ npm run develop

i aneu al navegador web localhost: 3000. El servidor de desenvolupament reconstruirà automàticament els paquets JS i CSS a mesura que canviï el codi; només cal que actualitzeu la pàgina per veure tots els canvis!

3. Punts d'entrada del client

Anem al codi del joc en si. Primer necessitem una pàgina index.html, en visitar el lloc, el navegador el carregarà primer. La nostra pàgina serà bastant senzilla:

index.html

Un exemple de joc .io  JUGAR

Aquest exemple de codi s'ha simplificat lleugerament per a més claredat, i faré el mateix amb molts dels altres exemples de publicacions. El codi complet sempre es pot veure a Github.

Tenim:

  • Element de llenç HTML5 (<canvas>) que utilitzarem per renderitzar el joc.
  • <link> per afegir el nostre paquet CSS.
  • <script> per afegir el nostre paquet Javascript.
  • Menú principal amb nom d'usuari <input> i el botó PLAY (<button>).

Després de carregar la pàgina d'inici, el navegador començarà a executar codi Javascript, començant des del fitxer JS del punt d'entrada: 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);
  };
});

Això pot semblar complicat, però aquí no hi ha gaire cosa:

  1. Importació de diversos altres fitxers JS.
  2. Importació de CSS (per tant Webpack sap incloure'ls al nostre paquet CSS).
  3. Запуск connect() per establir una connexió amb el servidor i executar downloadAssets() per descarregar les imatges necessàries per renderitzar el joc.
  4. Després de finalitzar l'etapa 3 es mostra el menú principal (playMenu).
  5. Configuració del controlador per prémer el botó "PLAY". Quan es prem el botó, el codi inicialitza el joc i indica al servidor que estem preparats per jugar.

La "carn" principal de la nostra lògica client-servidor es troba en aquells fitxers que van ser importats pel fitxer index.js. Ara els considerarem tots en ordre.

4. Intercanvi de dades de clients

En aquest joc, fem servir una biblioteca coneguda per comunicar-nos amb el servidor socket.io. Socket.io té suport natiu endolls web, que són molt adequats per a la comunicació bidireccional: podem enviar missatges al servidor и el servidor ens pot enviar missatges a la mateixa connexió.

Tindrem un arxiu src/client/networking.jsqui s'encarregarà per tots comunicació amb el servidor:

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

Aquest codi també s'ha escurçat lleugerament per a més claredat.

Hi ha tres accions principals en aquest fitxer:

  • Estem intentant connectar-nos al servidor. connectedPromise només es permet quan hem establert una connexió.
  • Si la connexió té èxit, registrem funcions de devolució de trucada (processGameUpdate() и onGameOver()) per als missatges que podem rebre del servidor.
  • Exportem play() и updateDirection()perquè altres fitxers els puguin utilitzar.

5. Representació del client

És hora de mostrar la imatge a la pantalla!

…però abans de poder fer-ho, hem de descarregar totes les imatges (recursos) necessaris per a això. Escrivim un gestor de recursos:

assets.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

La gestió de recursos no és tan difícil d'implementar! La idea principal és emmagatzemar un objecte assets, que unirà la clau del nom del fitxer amb el valor de l'objecte Image. Quan es carrega el recurs, l'emmagatzemem en un objecte assets per a un accés ràpid en el futur. Quan es permetrà descarregar cada recurs individual (és a dir, tots recursos), permetem downloadPromise.

Després de descarregar els recursos, podeu començar a renderitzar. Com hem dit abans, per dibuixar en una pàgina web, fem servir HTML5 Canvas (<canvas>). El nostre joc és bastant senzill, així que només hem de dibuixar el següent:

  1. Antecedents
  2. Nau del jugador
  3. Altres jugadors del joc
  4. Petxines

Aquí teniu els fragments importants src/client/render.js, que representen exactament els quatre elements enumerats anteriorment:

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

Aquest codi també s'escurça per a més claredat.

render() és la funció principal d'aquest fitxer. startRendering() и stopRendering() controlar l'activació del bucle de renderització a 60 FPS.

Implementacions concretes de funcions auxiliars de representació individuals (p. renderBullet()) no són tan importants, però aquí teniu un exemple senzill:

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

Tingueu en compte que estem utilitzant el mètode getAsset(), que es va veure anteriorment a asset.js!

Si esteu interessats a conèixer altres ajudants de renderització, llegiu la resta. src/client/render.js.

6. Entrada del client

És hora de fer un joc jugable! L'esquema de control serà molt senzill: per canviar la direcció del moviment, podeu utilitzar el ratolí (en un ordinador) o tocar la pantalla (en un dispositiu mòbil). Per implementar-ho, ens registrarem Oients d'esdeveniments per a esdeveniments Mouse and Touch.
S'encarregarà de tot això 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() són oients d'esdeveniments que truquen updateDirection() (de networking.js) quan es produeix un esdeveniment d'entrada (per exemple, quan es mou el ratolí). updateDirection() gestiona la missatgeria amb el servidor, que gestiona l'esdeveniment d'entrada i actualitza l'estat del joc en conseqüència.

7. Estat del client

Aquesta secció és la més difícil de la primera part del post. No us desanimeu si no ho enteneu la primera vegada que el llegiu! Fins i tot podeu saltar-lo i tornar-hi més tard.

L'última peça del trencaclosques necessària per completar el codi client/servidor és van ser. Recordeu el fragment de codi de la secció Representació del client?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() hauria de poder donar-nos l'estat actual del joc al client en qualsevol moment del temps basat en les actualitzacions rebudes del servidor. Aquí teniu un exemple d'actualització del joc que el servidor pot enviar:

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

Cada actualització del joc conté cinc camps idèntics:

  • t: marca de temps del servidor que indica quan es va crear aquesta actualització.
  • me: Informació sobre el jugador que rep aquesta actualització.
  • altres: una sèrie d'informació sobre altres jugadors que participen en el mateix joc.
  • bales: una sèrie d'informació sobre projectils del joc.
  • classificació: dades actuals de la classificació. En aquest post, no els tindrem en compte.

7.1 Estat del client ingenu

Implementació ingènua getCurrentState() només pot retornar directament les dades de l'actualització del joc més recent rebuda.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bonic i clar! Però si només fos així de senzill. Una de les raons per les quals aquesta implementació és problemàtica: limita la velocitat de fotogrames de renderització a la velocitat de rellotge del servidor.

Velocitat de fotogrames: nombre de fotogrames (és a dir, trucades render()) per segon o FPS. Els jocs solen intentar aconseguir almenys 60 FPS.

Taxa de marca: La freqüència amb què el servidor envia actualitzacions del joc als clients. Sovint és inferior a la velocitat de fotogrames. Al nostre joc, el servidor funciona a una freqüència de 30 cicles per segon.

Si acabem de renderitzar l'última actualització del joc, l'FPS essencialment mai passarà de 30, perquè mai rebem més de 30 actualitzacions per segon del servidor. Encara que truquem render() 60 vegades per segon, la meitat d'aquestes trucades només es tornarà a dibuixar el mateix, essencialment sense fer res. Un altre problema amb la implementació ingènua és que això propens a retards. Amb una velocitat d'Internet ideal, el client rebrà una actualització del joc exactament cada 33 ms (30 per segon):

Creació d'un joc web multijugador .io
Malauradament, res és perfecte. Una imatge més realista seria:
Creació d'un joc web multijugador .io
La implementació ingènua és pràcticament el pitjor cas quan es tracta de latència. Si es rep una actualització del joc amb un retard de 50 ms, aleshores parades de clients 50 ms addicionals perquè encara mostra l'estat del joc de l'actualització anterior. Us podeu imaginar com d'incòmode és per al jugador: una frenada arbitrària farà que el joc sembli desconcertat i inestable.

7.2 Millora de l'estat del client

Farem algunes millores a la implementació ingènua. Primer, fem servir retard de renderització durant 100 ms. Això vol dir que l'estat "actual" del client sempre quedarà 100 ms per darrere de l'estat del joc al servidor. Per exemple, si l'hora al servidor és 150, aleshores el client mostrarà l'estat en què es trobava el servidor en aquell moment 50:

Creació d'un joc web multijugador .io
Això ens proporciona un buffer de 100 ms per sobreviure a temps d'actualització del joc impredictibles:

Creació d'un joc web multijugador .io
El benefici per això serà permanent retard d'entrada durant 100 ms. Aquest és un sacrifici menor per a un joc fluid: la majoria dels jugadors (especialment els jugadors casuals) ni tan sols notaran aquest retard. És molt més fàcil per a la gent ajustar-se a una latència constant de 100 ms que jugar amb una latència impredictible.

També podem utilitzar una altra tècnica anomenada predicció del costat del client, que fa una bona feina per reduir la latència percebuda, però no es tractarà en aquesta publicació.

Una altra millora que estem utilitzant és interpolació lineal. A causa del retard de renderització, normalment tenim almenys una actualització per davant de l'hora actual del client. Quan es truca getCurrentState(), podem executar interpolació lineal entre les actualitzacions del joc just abans i després de l'hora actual del client:

Creació d'un joc web multijugador .io
Això resol el problema de la velocitat de fotogrames: ara podem renderitzar fotogrames únics a qualsevol velocitat de fotogrames que vulguem!

7.3 Implementació de l'estat del client millorat

Exemple d'implementació a src/client/state.js utilitza tant el retard de renderització com la interpolació lineal, però no durant molt de temps. Dividim el codi en dues parts. Aquí teniu el primer:

state.js part 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;
}

El primer pas és esbrinar què currentServerTime(). Com hem vist anteriorment, cada actualització del joc inclou una marca de temps del servidor. Volem utilitzar la latència de renderització per renderitzar la imatge 100 ms darrere del servidor, però mai sabrem l'hora actual al servidor, perquè no podem saber quant de temps va trigar a arribar cap de les actualitzacions. Internet és impredictible i la seva velocitat pot variar molt!

Per evitar aquest problema, podem utilitzar una aproximació raonable: nosaltres simula que la primera actualització va arribar a l'instant. Si això fos cert, sabríem l'hora del servidor en aquest moment concret! Emmagatzemem la marca de temps del servidor firstServerTimestamp i mantenir el nostre locals marca de temps (client) al mateix moment gameStart.

Oh espera. No hauria de ser hora del servidor = hora del client? Per què distingim entre "marca de temps del servidor" i "marca de temps del client"? Aquesta és una gran pregunta! Resulta que no són el mateix. Date.now() retornarà diferents segells de temps al client i al servidor, i depèn dels factors locals d'aquestes màquines. No assumeixis mai que les marques de temps seran les mateixes a totes les màquines.

Ara entenem què fa currentServerTime(): torna la marca de temps del servidor del temps de renderització actual. En altres paraules, aquesta és l'hora actual del servidor (firstServerTimestamp <+ (Date.now() - gameStart)) menys retard de renderització (RENDER_DELAY).

Ara fem una ullada a com gestionem les actualitzacions del joc. Quan es rep del servidor d'actualització, es crida processGameUpdate()i desem la nova actualització en una matriu gameUpdates. Aleshores, per comprovar l'ús de la memòria, eliminem totes les actualitzacions antigues anteriors actualització baseperquè ja no els necessitem.

Què és una "actualització bàsica"? Això la primera actualització que trobem en retrocedir des de l'hora actual del servidor. Recordeu aquest diagrama?

Creació d'un joc web multijugador .io
L'actualització del joc directament a l'esquerra de "Client Render Time" és l'actualització bàsica.

Per a què serveix l'actualització base? Per què podem deixar les actualitzacions a la línia de base? Per esbrinar-ho, anem finalment considerar la implementació getCurrentState():

state.js part 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),
    };
  }
}

Tractem tres casos:

  1. base < 0 significa que no hi ha actualitzacions fins al moment de renderització actual (vegeu la implementació anterior getBaseUpdate()). Això pot passar just al començament del joc a causa del retard de renderització. En aquest cas, utilitzem l'última actualització rebuda.
  2. base és l'última actualització que tenim. Això pot ser degut a un retard de la xarxa o una connexió a Internet deficient. En aquest cas, també estem utilitzant la darrera actualització que tenim.
  3. Tenim una actualització abans i després del temps de renderització actual, així que podem interpolar!

Tot el que queda dins state.js és una implementació d'interpolació lineal que és matemàtica senzilla (però avorrida). Si el voleu explorar vosaltres mateixos, obriu-lo state.js en Github.

Part 2. Servidor de fons

En aquesta part, donarem una ullada al backend de Node.js que controla el nostre Exemple de joc .io.

1. Punt d'entrada del servidor

Per gestionar el servidor web, utilitzarem un marc web popular per a Node.js anomenat Exprés. Estarà configurat pel nostre fitxer de punt d'entrada del servidor src/server/server.js:

server.js part 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}`);

Recordeu que a la primera part vam parlar de Webpack? Aquí és on utilitzarem les nostres configuracions Webpack. Els utilitzarem de dues maneres:

  • Utilitzeu webpack-dev-middleware per reconstruir automàticament els nostres paquets de desenvolupament, o bé
  • carpeta de transferència estàtica dist/, en el qual Webpack escriurà els nostres fitxers després de la construcció de producció.

Una altra tasca important server.js és configurar el servidor socket.ioque només es connecta al servidor Express:

server.js part 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);
});

Després d'establir correctament una connexió socket.io al servidor, configurem controladors d'esdeveniments per al nou sòcol. Els controladors d'esdeveniments gestionen els missatges rebuts dels clients delegant-los a un objecte singleton game:

server.js part 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);
}

Estem creant un joc .io, així que només necessitem una còpia Game ("Joc"): tots els jugadors juguen a la mateixa pista! En la següent secció, veurem com funciona aquesta classe. Game.

2. Servidors de jocs

Classe Game conté la lògica més important del costat del servidor. Té dues tasques principals: gestió de jugadors и simulació de jocs.

Comencem per la primera tasca, la gestió dels jugadors.

game.js part 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);
    }
  }

  // ...
}

En aquest joc, identificarem els jugadors pel camp id el seu sòcol socket.io (si us confoneu, torneu a server.js). El mateix Socket.io assigna a cada sòcol un únic idaixí que no ens hem de preocupar per això. el trucaré ID del jugador.

Tenint això en compte, explorem les variables d'instància en una classe Game:

  • sockets és un objecte que uneix l'identificador del jugador al sòcol associat al jugador. Ens permet accedir als endolls mitjançant els seus ID de jugador en un temps constant.
  • players és un objecte que enllaça l'identificador del jugador amb el codi>objecte del jugador

bullets és un conjunt d'objectes Bullet, que no té un ordre definit.
lastUpdateTime és la marca de temps de la darrera vegada que es va actualitzar el joc. En breu veurem com s'utilitza.
shouldSendUpdate és una variable auxiliar. També veurem el seu ús en breu.
Mètodes addPlayer(), removePlayer() и handleInput() no cal explicar, s'utilitzen en server.js. Si necessiteu refrescar la memòria, torneu una mica més enrere.

Última línia constructor() arrenca cicle d'actualització jocs (amb una freqüència de 60 actualitzacions/s):

game.js part 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;
    }
  }

  // ...
}

Mètode update() conté potser la peça més important de la lògica del servidor. Això és el que fa, per ordre:

  1. Calcula quant de temps dt passat des de l'últim update().
  2. Actualitza cada projectil i els destrueix si cal. Veurem la implementació d'aquesta funcionalitat més endavant. De moment, ens n'hi ha prou de saber-ho bullet.update() torna truesi el projectil ha de ser destruït (va sortir de l'arena).
  3. Actualitza cada jugador i genera un projectil si cal. També veurem aquesta implementació més endavant - player.update() pot retornar un objecte Bullet.
  4. Comprova si hi ha col·lisions entre projectils i jugadors amb applyCollisions(), que retorna una sèrie de projectils que afecten els jugadors. Per cada projectil retornat, augmentem els punts del jugador que l'ha disparat (utilitzant player.onDealtDamage()) i després traieu el projectil de la matriu bullets.
  5. Notifica i destrueix tots els jugadors morts.
  6. Envia una actualització del joc a tots els jugadors cada segon vegades quan es truca update(). Això ens ajuda a fer un seguiment de la variable auxiliar esmentada anteriorment. shouldSendUpdate... Perquè update() trucat 60 vegades/s, enviem actualitzacions del joc 30 vegades/s. Així, freqüència de rellotge el rellotge del servidor és de 30 rellotges/s (vam parlar de velocitats de rellotge a la primera part).

Per què enviar només actualitzacions del joc a través del temps ? Per guardar el canal. 30 actualitzacions de jocs per segon són moltes!

Per què no trucar update() 30 vegades per segon? Per millorar la simulació del joc. El més sovint anomenat update(), més precisa serà la simulació del joc. Però no us deixeu portar massa amb la quantitat de reptes. update(), perquè és una tasca computacionalment costosa: n'hi ha prou amb 60 per segon.

La resta de la classe Game consisteix en mètodes auxiliars utilitzats en update():

game.js part 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() bastant senzill: ordena els jugadors per puntuació, agafa els cinc primers i retorna el nom d'usuari i la puntuació de cadascun.

createUpdate() utilitzat en update() per crear actualitzacions del joc que es distribueixen als jugadors. La seva tasca principal és cridar mètodes serializeForUpdate()implementat per a les classes Player и Bullet. Tingueu en compte que només passa dades a cada jugador més proper jugadors i projectils: no cal transmetre informació sobre objectes del joc que estan lluny del jugador!

3. Objectes del joc al servidor

En el nostre joc, els projectils i els jugadors són realment molt semblants: són objectes de joc abstractes, rodons i mòbils. Per aprofitar aquesta similitud entre jugadors i projectils, comencem per implementar la classe base Object:

object.js

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

Aquí no hi ha res complicat. Aquesta classe serà un bon punt d'ancoratge per a l'ampliació. A veure com és la classe Bullet usos 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;
  }
}

Implementació Bullet molt curt! Hem afegit a Object només les següents extensions:

  • Utilitzant un paquet curtida per a la generació aleatòria id projectil.
  • Afegint un camp parentIDperquè pugueu seguir el jugador que va crear aquest projectil.
  • Afegint un valor de retorn a update(), que és igual a truesi el projectil està fora de l'arena (recordeu que n'hem parlat a l'últim apartat?).

Passem a 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,
    };
  }
}

Els jugadors són més complexos que els projectils, de manera que s'han d'emmagatzemar uns quants camps més en aquesta classe. El seu mètode update() fa molta feina, en particular, torna el projectil acabat de crear si no en queda cap fireCooldown (recordeu que n'hem parlat a l'apartat anterior?). També amplia el mètode serializeForUpdate(), perquè hem d'incloure camps addicionals per al jugador a l'actualització del joc.

Tenir una classe bàsica Object - un pas important per evitar la repetició del codi. Per exemple, sense classe Object cada objecte del joc ha de tenir la mateixa implementació distanceTo(), i copiar i enganxar totes aquestes implementacions en diversos fitxers seria un malson. Això esdevé especialment important per a grans projectes.quan el nombre d'expansió Object les classes creixen.

4. Detecció de col·lisions

L'únic que ens queda és reconèixer quan els projectils van colpejar els jugadors! Recordeu aquest fragment de codi del mètode update() a classe 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),
    );

    // ...
  }
}

Hem d'implementar el mètode applyCollisions(), que retorna tots els projectils que afecten als jugadors. Per sort, no és tan difícil de fer perquè

  • Tots els objectes que xoquen són cercles, i aquesta és la forma més senzilla per implementar la detecció de col·lisions.
  • Ja tenim un mètode distanceTo(), que vam implementar a l'apartat anterior a la classe Object.

A continuació es mostra com és la nostra implementació de la detecció de col·lisions:

col·lisions.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;
}

Aquesta simple detecció de col·lisions es basa en el fet que dos cercles xoquen si la distància entre els seus centres és menor que la suma dels seus radis. Aquest és el cas en què la distància entre els centres de dos cercles és exactament igual a la suma dels seus radis:

Creació d'un joc web multijugador .io
Hi ha un parell d'aspectes més a considerar aquí:

  • El projectil no ha de colpejar el jugador que l'ha creat. Això es pot aconseguir comparant bullet.parentID с player.id.
  • El projectil només ha de colpejar una vegada en el cas límit de col·lisió de diversos jugadors al mateix temps. Solucionarem aquest problema mitjançant l'operador break: tan bon punt es trobi el jugador que xoca amb el projectil, aturem la cerca i passem al següent projectil.

Конец

Això és tot! Hem cobert tot el que necessites saber per crear un joc web .io. Que segueix? Crea el teu propi joc .io!

Tot el codi de mostra és de codi obert i es publica a Github.

Font: www.habr.com

Afegeix comentari