Creación de un juego web multijugador del tipo .io

Creación de un juego web multijugador del tipo .io
Lanzado en 2015 Agar.io se convirtió en el precursor de un nuevo género de juegos .io, cuya popularidad ha crecido enormemente desde entonces. He experimentado el aumento de la popularidad de los juegos .io: en los últimos tres años he creado y vendido dos juegos de este género..

En caso de que nunca hayas oído hablar de estos juegos: son juegos web multijugador gratuitos en los que es fácil participar (no se requiere cuenta). Normalmente enfrentan a muchos jugadores rivales en una misma arena. Otros juegos famosos del género .io: Slither.io y Diep.io.

En este post, vamos a investigar cómo crear un juego .io desde cero. Para esto, solo necesitarás conocimientos de Javascript: debes entender cosas como la sintaxis ES6, la palabra clave this y Promises. Incluso si no dominas Javascript, aún podrás entender la mayor parte del post.

Ejemplo de juego .io

Para ayudar en el aprendizaje, nos referiremos a un ejemplo de juego .io. ¡Intenta jugarlo!

Creación de un juego web multijugador del tipo .io
El juego es bastante simple: controlas una nave en una arena donde hay otros jugadores. Tu nave dispara automáticamente proyectiles y tratas de golpear a otros jugadores mientras evitas sus proyectiles.

1. Visión general/estructura del proyecto

Recomiendo descargar el código fuente del ejemplo de juego, para que puedas seguirme.

El ejemplo utiliza lo siguiente:

  • Express es el framework web más popular para Node.js, que gestiona el servidor web del juego.
  • socket.io es una biblioteca de websocket para el intercambio de datos entre el navegador y el servidor.
  • Webpack es un gestor de módulos. Puedes leer sobre por qué usar Webpack aquí.

Así es como se ve la estructura del directorio del proyecto:

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

public/

Todo en la carpeta public/ será servido de forma estática por el servidor. En public/assets/ se encuentran las imágenes utilizadas por nuestro proyecto.

src/

Todo el código fuente se encuentra en la carpeta src/. Nombres client/ y server/ hablan por sí mismos, y shared/ contiene un archivo de constantes, que es importado tanto por el cliente como por el servidor.

2. Construcciones/parámetros del proyecto

Como se mencionó anteriormente, para construir el proyecto utilizamos un gestor de módulos. WebpackEchemos un vistazo a nuestra configuración 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',
    }),
  ],
};

Las siguientes líneas son las más importantes aquí:

  • src/client/index.js — es el punto de entrada del cliente Javascript (JS). Webpack comenzará desde aquí y buscará recursivamente otros archivos importados.
  • El JS de salida de nuestra compilación de Webpack estará ubicado en el directorio dist\/. Llamaré a este archivo nuestro paquete JS.
  • Utilizamos Babel, y en particular la configuración @babel\/preset-env para transpilar nuestro código JS para navegadores más antiguos.
  • Usamos un plugin para extraer todos los CSS a los que hacen referencia los archivos JS y para combinarlos en un solo lugar. Lo llamaré nuestro paquete CSS.

Es posible que haya notado los extraños nombres de archivos de los paquetes '[name].[contenthash].ext'. Contienen sustituciones de nombres de archivo Webpack: [name] se reemplazará por el nombre del punto de entrada (en nuestro caso es game), y [contenthash] se reemplazará por el hash del contenido del archivo. Hacemos esto para optimizar el proyecto para el hash — podemos pedir a los navegadores que almacenen en caché indefinidamente nuestros paquetes JS, porque si el paquete cambia, también cambia su nombre de archivo (cambia contenthash). El resultado final será un nombre de archivo del tipo game.dbeee76e91a97d0c7207.js.

Archivo webpack.common.js — este es el archivo de configuración básico que importamos en las configuraciones de desarrollo y del proyecto listo. Aquí, por ejemplo, la configuración de desarrollo:

webpack.dev.js

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

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

Para mayor eficiencia, usamos en el proceso de desarrollo webpack.dev.js, y cambia a webpack.prod.js, para optimizar el tamaño de los paquetes cuando se despliega en producción.

Configuración local

Recomiendo instalar el proyecto en una máquina local para que pueda seguir los pasos enumerados en esta publicación. La configuración es simple: primero, debe tener instalados Nodo y NPM. Luego, ejecute

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

¡y estás listo para trabajar! Para iniciar el servidor de desarrollo, solo necesitas ejecutar

$ npm run develop

y acceder en un navegador web a localhost:3000. El servidor de desarrollo volverá a compilar automáticamente los paquetes JS y CSS a medida que cambies el código; ¡simplemente actualiza la página para ver todos los cambios!

3. Puntos de entrada del cliente

Comencemos con el código del juego. Primero, necesitaremos una página index.html, que será cargada primero cuando se visite el sitio. Nuestra página será bastante simple:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Un ejemplo de juego .io</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Nombre de usuario" />
    <button id="play-button">JUGAR</button>
  </div>
</body>
</html>

Este ejemplo de código está ligeramente simplificado para mayor claridad; lo mismo haré con muchos otros ejemplos del artículo. El código completo siempre puede verse en Github.

Tenemos:

  • Elemento HTML5 Canvas (<canvas>), que utilizaremos para renderizar el juego.
  • <link> para añadir nuestro paquete CSS.
  • <script> para añadir nuestro paquete Javascript.
  • Menú principal con nombre de usuario <input> y el botón "JUGAR" (

Después de que la página de inicio se carga en el navegador, comenzará a ejecutarse el código Javascript, comenzando con el archivo JS de punto de 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 = () => {
    // ¡Jugar!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Esto puede parecer complicado, pero en realidad no está pasando mucho aquí:

  1. Importar varios otros archivos JS.
  2. Importar CSS (para que Webpack sepa que debe incluirlo en nuestro paquete CSS).
  3. Lanzamiento connect() para establecer una conexión con el servidor y empezar downloadAssets() para descargar imágenes necesarias para la renderización del juego.
  4. Una vez completada la etapa 3 se muestra el menú principal (playMenu).
  5. Configuración del controlador del botón "JUGAR". Cuando se presiona el botón, el código inicializa el juego y notifica al servidor que estamos listos para jugar.

La parte principal de nuestra lógica cliente-servidor se encuentra en aquellos archivos que fueron importados por el archivo index.js. Ahora los examinaremos uno por uno.

4. Intercambio de datos del cliente

En este juego, para comunicarnos con el servidor usamos una biblioteca bien conocida. socket.io. En Socket.io hay soporte integrado WebSockets, que son ideales para la comunicación bidireccional: podemos enviar mensajes al servidor y y el servidor puede enviarnos mensajes a través de la misma conexión.

Tendremos un archivo src/client/networking.js, que se encargará de todas las comunicaciones con 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('¡Conectado al servidor!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Registrar 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);
};

Este código también está ligeramente abreviado para mayor claridad.

Este archivo realiza tres acciones principales:

  • Intentamos conectarnos al servidor. connectedPromise se resuelve solo cuando hemos establecido la conexión.
  • Si la conexión se establece correctamente, registramos las funciones callback (processGameUpdate() y onGameOver()) para los mensajes que podemos recibir del servidor.
  • Exportamos play() y updateDirection(), para que puedan ser utilizados por otros archivos.

5. Renderización del cliente

¡Es hora de mostrar la imagen en la pantalla!

…pero antes de que podamos hacerlo, necesitamos descargar todas las imágenes (recursos) necesarias para ello. Escribamos 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(`Descargado ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `\/assets\/${assetName}`;
  });
}

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

¡Gestionar recursos no es tan complicado! La idea principal es almacenar un objeto assets, que vinculará la clave del nombre del archivo con el valor del objeto Imagen. Cuando el recurso se cargue, lo guardamos en el objeto assets para un acceso rápido en el futuro. Cuando se permita la descarga de cada recurso individual (es decir, los cargas de trabajo dejarán de funcionar! recursos se han cargado), permitimos downloadPromise.

Una vez descargados los recursos, podemos proceder a la renderización. Como se mencionó anteriormente, para dibujar en la página web usamos HTML5 Canvas (<canvas>). Nuestro juego es bastante simple, por lo que solo necesitamos dibujar lo siguiente:

  1. Fondo
  2. Nave del jugador
  3. Otros jugadores que están en el juego
  4. Proyectiles

Aquí hay fragmentos importantes src/client/render.js, que renderizan exactamente los cuatro puntos mencionados arriba:

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;

// Obtener el contexto gráfico del canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Hacer el canvas de pantalla completa
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Dibujar el fondo
  renderBackground(me.x, me.y);

  // Dibujar todos los proyectiles
  bullets.forEach(renderBullet.bind(null, me));

  // Dibujar todos los jugadores
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funciones auxiliares aquí excluidas

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Este código también se ha acortado para mayor claridad.

render() — es la función principal de este archivo. startRendering() y stopRendering() controlan la activación del ciclo de renderizado a 60 FPS.

Las implementaciones específicas de las funciones auxiliares de renderizado (por ejemplo renderBullet()) no son tan importantes, pero aquí hay un simple ejemplo:

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

Nota que estamos usando el método getAsset(), que ya vimos en asset.js!

Si te interesa explorar otras funciones auxiliares de renderizado, lee el resto de src/client/render.js.

6. Entrada del cliente

Es hora de hacer que el juego jugable! El esquema de control será muy simple: para cambiar la dirección del movimiento se puede usar el ratón (en la computadora) o tocar la pantalla (en el dispositivo móvil). Para implementar esto, registraremos Event Listeners para eventos de Mouse y Touch.
Todo esto lo manejará 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() y onTouchInput() — son los Event Listeners que invocan updateDirection() (de networking.js) al ocurrir un evento de entrada (por ejemplo, al mover el ratón). updateDirection() se encarga de intercambiar mensajes con el servidor, que procesa el evento de entrada y actualiza el estado del juego en consecuencia.

7. Estado del cliente

Esta sección es la más complicada de la primera parte del post. ¡No te desanimes si no lo entiendes en la primera lectura! Puedes incluso saltártela y volver a ella más tarde.

La última pieza del rompecabezas necesaria para completar el código cliente-servidor es state. ¿Recuerdas el fragmento de código de la sección ‘Renderizado del cliente’?

render.js

import { getCurrentState } from './state';

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

  // Hacer el renderizado
  // ...
}

getCurrentState() debe ser capaz de proporcionarnos el estado actual del juego en el cliente en cualquier momento basado en las actualizaciones recibidas del servidor. Aquí hay un ejemplo de actualización del juego que podría enviar el servidor:

{
  "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 actualización del juego contiene cinco campos idénticos:

  • t: una marca de tiempo del servidor que indica el momento en que se creó esta actualización.
  • me: información sobre el jugador que recibe esta actualización.
  • others: un arreglo con información de otros jugadores que participan en el mismo juego.
  • bullets: un arreglo con información sobre los proyectiles en el juego.
  • leaderboard: los datos actuales de la tabla de líderes. En este post no los vamos a considerar.

7.1 Estado ingenuo del cliente

Implementación ingenua getCurrentState() solo puede devolver directamente los datos de la última actualización del juego recibida.

naive-state.js

let lastGameUpdate = null;

// Manejar una actualización de juego recién recibida.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

¡Bonito y claro! Pero si solo fuera tan fácil. Una de las razones por las que tal implementación es problemática es: que limita la frecuencia de cuadros de renderizado a la frecuencia del servidor.

Frecuencia de cuadros (Frame Rate): la cantidad de cuadros (es decir, llamadas render()) por segundo, o FPS. En los juegos, generalmente se busca alcanzar al menos 60 FPS.

Frecuencia de reloj (Tick Rate): la frecuencia con la que el servidor envía actualizaciones del juego a los clientes. A menudo es inferior a la frecuencia de cuadros. En nuestro juego, el servidor opera a una frecuencia de 30 ciclos por segundo.

Si simplemente renderizamos la última actualización del juego, entonces los FPS nunca podrán superar los 30, porque nunca recibimos más de 30 actualizaciones del servidor por segundo. Incluso si llamamos render() 60 veces por segundo, la mitad de esas llamadas simplemente volverá a dibujar lo mismo, esencialmente haciendo nada. Otro problema de una implementación ingenua es que está sujeta a retrasos. Con una velocidad perfecta de Internet, el cliente recibirá actualizaciones del juego exactamente cada 33 ms (30 por segundo):

Creación de un juego web multijugador del tipo .io
Desafortunadamente, nada es perfecto. Un escenario más realista sería el siguiente:
Creación de un juego web multijugador del tipo .io
La implementación ingenua es prácticamente el peor de los casos en términos de retrasos. Si la actualización del juego se recibe con un retraso de 50 ms, entonces el cliente se retrasa otros 50 ms, porque sigue renderizando el estado del juego de la actualización anterior. Puedes imaginar lo incómodo que es para el jugador: debido a los retrasos arbitrarios, el juego parece entrecortado e inestable.

7.2 Estado mejorado del cliente

Haremos algunas mejoras a la implementación ingenua. Primero, utilizaremos un retraso de renderizado de 100 ms. Esto significa que el estado "actual" del cliente siempre estará rezagado respecto al estado del juego en el servidor por 100 ms. Por ejemplo, si en el servidor el tiempo es 150, entonces en el cliente se renderizará el estado en el que estaba el servidor en el tiempo 50:

Creación de un juego web multijugador del tipo .io
Esto nos da un búfer de 100 ms, permitiéndonos sobrevivir al tiempo impredecible de recepción de actualizaciones del juego:

Creación de un juego web multijugador del tipo .io
El costo de esto será un retraso de entrada (input lag) de 100 ms. Es un sacrificio menor por un proceso de juego fluido: la mayoría de los jugadores (especialmente los casuales) ni siquiera notarán este retraso. A las personas les resulta mucho más fácil adaptarse a un retraso constante de 100 ms que jugar con un retraso impredecible.

También podemos utilizar otra técnica llamada "pronosticación del lado del cliente", que maneja bien la reducción de retrasos percibidos, pero no se discutirá en este artículo.

Otra mejora que utilizamos es la interpolación lineal. Debido al retraso de renderizado, generalmente estamos al menos una actualización por delante del tiempo actual en el cliente. Cuando se llama a getCurrentState(), podemos realizar una interpolación lineal entre las actualizaciones del juego directamente antes y después de la hora actual en el cliente:

Creación de un juego web multijugador del tipo .io
Esto resuelve el problema de la frecuencia de fotogramas: ¡ahora podemos renderizar fotogramas únicos a cualquier frecuencia que necesitemos!

7.3 Implementación del estado mejorado del cliente

Ejemplo de implementación en src/client/state.js utiliza tanto el retraso de renderizado como la interpolación lineal, pero esto no durará mucho. Vamos a dividir el código en dos partes. Aquí está la primera:

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

  // Mantener solo una actualización del juego antes de la hora actual del servidor
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Devuelve el índice de la actualización base, la primera actualización del juego antes
// de la hora actual del servidor, o -1 si 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;
}

Primero debemos entender qué hace currentServerTime(). Como vimos anteriormente, cada actualización del juego incluye una marca de tiempo del servidor. Queremos usar el retraso de renderizado para renderizar la imagen, retrasándonos del servidor por 100 ms, pero nunca sabremos cuál es el tiempo actual en el servidor, porque no podemos saber cuánto tiempo ha tardado en llegar cualquiera de las actualizaciones. ¡Internet es impredecible y su velocidad puede variar enormemente!

Para sortear este problema, podemos utilizar una aproximación razonable: podemos fingir que la primera actualización llegó instantáneamente. ¡Si eso fuera cierto, sabríamos la hora del servidor en ese momento específico! Guardamos la marca de tiempo del servidor en firstServerTimestamp y guardamos nuestra marca de tiempo (del cliente) en ese mismo momento en gameStart.

Oops, espera. ¿No debería ser la hora en el servidor = la hora en el cliente? ¿Por qué diferenciamos entre 'marca de tiempo del servidor' y 'marca de tiempo del cliente'? ¡Esa es una gran pregunta! Resulta que no son lo mismo. Date.now() devolverá marcas de tiempo diferentes en el cliente y en el servidor, y esto depende de factores locales para estas máquinas. Nunca supongas que las marcas de tiempo serán iguales en todas las máquinas.

Ahora entendemos lo que hace currentServerTime(): devuelve la marca de tiempo del servidor para el tiempo actual de renderizado. En otras palabras, este es el tiempo actual del servidor (firstServerTimestamp < + (Date.now() - gameStart)) menos el retraso en la renderización (RENDER_DELAY).

Ahora vamos a entender cómo manejamos las actualizaciones del juego. Al recibir una actualización del servidor, se llama a processGameUpdate(), y guardamos la nueva actualización en el array gameUpdates. Luego, para verificar el uso de la memoria, eliminamos todas las actualizaciones antiguas hasta la actualización base, porque ya no las necesitamos.

¿Qué es exactamente una "actualización base"? Es la primera actualización que encontramos al retroceder desde el tiempo actual del servidor. ¿Recuerdas este esquema?

Creación de un juego web multijugador del tipo .io
La actualización del juego que está directamente a la izquierda de "Client Render Time" es la actualización base.

¿Para qué se utiliza la actualización base? ¿Por qué podemos descartar actualizaciones hasta la base? Para entender esto, vamos a finalmente examinar la implementación de getCurrentState():

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

Estamos manejando tres casos:

  1. base < 0 significa que no hay actualizaciones antes del tiempo actual de renderización (ver la implementación anterior de getBaseUpdate()). Esto puede suceder al principio del juego debido al retraso en la renderización. En tal caso, utilizamos la última actualización recibida.
  2. base es la última actualización que tenemos. Esto puede ocurrir debido a un retraso en la red o una mala conexión a Internet. En este caso, también usamos la última actualización que tenemos.
  3. Tenemos una actualización tanto antes como después del tiempo actual de renderización, así que podemos interpolar!

Todo lo que queda en state.js es la implementación de la interpolación lineal, que es una matemática simple (pero aburrida). Si quieres estudiarla por tu cuenta, abre state.js en Github.

Parte 2. Servidor backend

En esta parte, veremos el backend de Node.js que maneja nuestro ejemplo de juego .io.

1. Punto de entrada del servidor

Para manejar el servidor web, utilizaremos un popular marco web para Node.js llamado Express. Su configuración estará a cargo de nuestro archivo de punto de entrada del servidor src/server/server.js:

server.js, parte 1

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../webpack.dev.js');

// Configurar un servidor Express
const app = express();
app.use(express.static('public')); 

if (process.env.NODE_ENV === 'development') {
  // Configurar Webpack para desarrollo
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Servir estáticamente la carpeta dist/ en producción
  app.use(express.static('dist'));
}

// Escuchar en el puerto
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Servidor escuchando en el puerto ${port}`);

¿Recuerdas que en la primera parte hablamos de Webpack? Aquí es donde utilizaremos nuestras configuraciones de Webpack. Las aplicaremos de dos maneras:

  • Usar webpack-dev-middleware para volver a compilar automáticamente nuestros paquetes de desarrollo, o
  • Servir estáticamente la carpeta dist\/, donde Webpack escribirá nuestros archivos después de la compilación de producción.

Otra tarea importante server.js es configurar el servidor socket.io, que se conecta directamente al servidor Express:

server.js, parte 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Configurar Express
// ...
const server = app.listen(port);
console.log(`Servidor escuchando en el puerto ${port}`);

// Configurar socket.io
const io = socketio(server);

// Escuchar conexiones de socket.io
io.on('connection', socket => {
  console.log('¡Jugador conectado!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Después de establecer con éxito la conexión de socket.io con el servidor, configuramos los manejadores de eventos para el nuevo socket. Los manejadores de eventos procesan los mensajes recibidos de los clientes delegando a un objeto singleton game:

server.js, parte 3

const Game = require('./game');

// ...

// Configurar el Juego
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

Estamos creando un juego del tipo .io, por lo que solo necesitaremos una instancia de Game ('Juego') - ¡todos los jugadores juegan en una misma arena! En la siguiente sección, veremos cómo funciona esta clase. Game.

2. Servidor del Juego

Clase Game contiene la lógica más importante del lado del servidor. Tiene dos tareas principales: gestionar a los jugadores y simular el juego.

Comencemos con la primera tarea: gestionar a los jugadores.

game.js, parte 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;

    // Generar una posición para iniciar a este jugador.
    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 este juego, vamos a identificar a los jugadores por el campo id de su socket de socket.io (si te confundes, vuelve a server.js). Socket.io asigna a cada socket un único id, por lo que no necesitamos preocuparnos por eso. Lo llamaré ID del jugador.

Ten en cuenta esto y exploremos las variables de instancia en la clase Game:

  • sockets — este es un objeto que asocia la ID del jugador con el socket que está vinculado al jugador. Nos permite acceder a los sockets por su ID de jugador en tiempo constante.
  • players — este es un objeto que vincula la ID del jugador con el objeto code>Player

bullets — este es un array de objetos Bullet, que no tiene un orden definido.
lastUpdateTime — esta es una marca de tiempo del último momento en que se actualizó el juego. Pronto veremos cómo se utiliza.
shouldSendUpdate — esta es una variable auxiliar. También veremos su uso pronto.
Los métodos addPlayer(), removePlayer() y handleInput() no necesitan explicación, se usan en server.js. Si necesitas refrescar tu memoria, vuelve un poco atrás.

La última línea constructor() inicia el ciclo de actualización del juego (a una tasa de 60 actualizaciones/s):

game.js, parte 2

const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');

class Game {
  \/\/ ...

  update() {
    \/\/ Calcular el tiempo transcurrido
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Actualizar cada bala
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Destruir esta bala
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Actualizar cada jugador
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Aplicar colisiones, dar a los jugadores puntos por golpear balas
    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),
    );

    \/\/ Verificar si algún jugador está muerto
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        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;
    }
  }

  \/\/ ...
}

El método update() contiene, probablemente, la parte más importante de la lógica del lado del servidor. Enumeremos en orden todo lo que hace:

  1. Calcula cuánto tiempo dt ha pasado desde la última update().
  2. Actualiza cada proyectil y lo destruye si es necesario. Veremos la implementación de esta funcionalidad más adelante. Por ahora, basta con saber que bullet.update() devuelve true, si el proyectil debe ser destruido (ha salido de los límites de la arena).
  3. Actualiza a cada jugador y crea un proyectil si es necesario. También veremos esta implementación más adelante — player.update() puede devolver un objeto Bullet.
  4. Verifica las colisiones entre los proyectiles y los jugadores usando applyCollisions(), que devuelve un array de proyectiles que impactaron a los jugadores. Para cada proyectil devuelto, aumentamos los puntos del jugador que lo disparó (usando player.onDealtDamage()), y luego eliminamos el proyectil del array bullets.
  5. Notifica y destruye a todos los jugadores eliminados.
  6. Envía a todos los jugadores una actualización del juego cada segundo cuando se llama update(). Esto nos ayuda a rastrear la variable auxiliar mencionada anteriormente shouldSendUpdate. Así que update() se llama 60 veces/segundo, enviamos actualizaciones del juego 30 veces/segundo. Así, frecuencia de reloj del servidor es de 30 pulsos/segundo (hablamos de frecuencias de reloj en la primera parte).

¿Por qué enviar actualizaciones del juego solo cada dos veces? ? Para ahorrar ancho de banda. 30 actualizaciones de juego por segundo es mucho.

¿Por qué entonces simplemente no llamarlo update() 30 veces por segundo? Para mejorar la simulación del juego. Cuanto más a menudo se llama update(), más precisa será la simulación del juego. Pero no hay que exagerar con la cantidad de llamadas update(), porque es una tarea computacionalmente costosa: 60 por segundo es más que suficiente.

El resto de la clase Game consiste en métodos auxiliares que se utilizan en update():

game.js, parte 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)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() es bastante simple: ordena a los jugadores por cantidad de puntos, toma los cinco mejores y devuelve para cada uno el nombre de usuario y la puntuación.

createUpdate() se utiliza en update() para crear actualizaciones del juego que se envían a los jugadores. Su tarea principal es llamar a los métodos serializeForUpdate(), implementados para las clases Player y Bullet. Tenga en cuenta que solo envía a cada jugador información sobre los jugadores cercanos y proyectiles: ¡no es necesario enviar información sobre objetos del juego que están lejos del jugador!

3. Objetos del juego en el servidor

En nuestro juego, los proyectiles y los jugadores son en realidad muy similares: son objetos de juego móviles y abstractos. Para aprovechar esta similitud entre jugadores y proyectiles, empecemos implementando una clase 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 hay nada complicado. Esta clase será un buen punto de partida para expandir. Veamos cómo es la clase Bullet utiliza 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;
  }

  // Devuelve true si la bala debe ser destruida
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementación Bullet ¡muy breve! Hemos añadido a Object solo las siguientes extensiones:

  • Uso del paquete shortid para la generación aleatoria id de balas.
  • Añadiendo el campo parentID, para poder rastrear al jugador que creó esta bala.
  • Añadiendo un valor de retorno a update(), que es igual a true, si la bala se encuentra fuera del área (recuerden que hablamos de esto en la sección anterior?).

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

  // Devuelve una bala recién creada, o null.
  update(dt) {
    super.update(dt);

    // Actualizar puntaje
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Asegurarse de que el jugador se mantenga dentro de los límites
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Disparar una bala, si es necesario
    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,
    };
  }
}

Los jugadores son más complicados que las balas, por lo que esta clase debe almacenar algunos campos adicionales. Su método update() realiza más trabajo, en particular, devuelve una bala recién creada si no hay más fireCooldown ¿(recuerden que hablamos de esto en la sección anterior?). También extiende el método serializeForUpdate(), porque necesitamos incluir campos adicionales en la actualización del juego para el jugador.

Tener una clase base Object es un paso importante para evitar la repetición de código. Por ejemplo, sin la clase Object cada objeto del juego tendría que tener la misma implementación distanceTo(), y sincronizar la copia de estas implementaciones en varios archivos sería una pesadilla. Esto se vuelve especialmente importante para grandes proyectos, cuando el número de clases que se extienden aumenta. Object 4. Reconocimiento de colisiones

4. Распознавание коллизий

Lo único que nos queda es reconocer cuándo los proyectiles impactan en los jugadores. Recuerden este fragmento de código del método update() en la clase Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Aplicar colisiones, dar puntuación a los jugadores por impactar proyectiles
    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),
    );

    // ...
  }
}

Necesitamos implementar el método applyCollisions(), que devolverá todos los proyectiles que han impactado en los jugadores. Afortunadamente, no es tan complicado, porque

  • Todos los objetos en colisión son círculos, y esta es la forma más sencilla de implementar el reconocimiento de colisiones.
  • Ya tenemos un método distanceTo(), que implementamos en la clase en la sección anterior Object.

Así es como se ve nuestra implementación del reconocimiento de colisiones:

collisions.js

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

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Este simple reconocimiento de colisiones se basa en el hecho de que dos círculos colisionan si la distancia entre sus centros es menor que la suma de sus radios. Aquí hay un caso en el que la distancia entre los centros de dos círculos es exactamente igual a la suma de sus radios:

Creación de un juego web multijugador del tipo .io
Aquí debemos prestarle atención a un par de aspectos:

  • El proyectil no debe impactar en el jugador que lo creó. Esto se puede lograr comparando bullet.parentID con player.id.
  • El proyectil debe impactar solo una vez en el caso límite de colisión simultánea con varios jugadores. Resolveremos esta tarea utilizando el operador break: tan pronto como encontremos un jugador que colisiona con el proyectil, detenemos la búsqueda y pasamos al siguiente proyectil.

Fin

¡Y eso es todo! Hemos cubierto todo lo que necesitas saber para crear un juego web del género .io. ¿Qué sigue? ¡Crea tu propio juego .io!

Todo el código del ejemplo tiene código abierto y está disponible en Github.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster