Creación de un juego web .io multijugador

Creación de un juego web .io multijugador
Lanzado en 2015 Agar.io se convirtió en el progenitor de un nuevo género juegos .ioque ha ganado popularidad desde entonces. He experimentado personalmente el aumento de la popularidad de los juegos .io: en los últimos tres años, he Creó y vendió dos juegos de este género..

En caso de que nunca antes haya oído hablar de estos juegos, estos son juegos web multijugador gratuitos que son fáciles de jugar (no se requiere una cuenta). Por lo general, se enfrentan a muchos jugadores contrarios en la misma arena. Otros juegos .io famosos: Slither.io и Diep.io.

En esta publicación, exploraremos cómo crear un juego .io desde cero. Para esto, solo el conocimiento de Javascript será suficiente: debe comprender cosas como la sintaxis ES6palabra clave this и Promesas. Incluso si su conocimiento de Javascript no es perfecto, aún puede entender la mayor parte de la publicación.

ejemplo de juego .io

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

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

1. Breve resumen/estructura del proyecto

recomendar descargar código fuente juego de ejemplo para que puedas seguirme.

El ejemplo utiliza lo siguiente:

  • Express es el marco web Node.js más popular que administra el servidor web del juego.
  • socket.io - una biblioteca websocket para intercambiar datos entre un navegador y un servidor.
  • Webpack - administrador de módulos. Puede 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

público/

Todo en una carpeta public/ será enviado estáticamente por el servidor. EN public/assets/ contiene imágenes utilizadas por nuestro proyecto.

src /

Todo el código fuente está en la carpeta. src/. Títulos client/ и server/ hablar por sí mismos y shared/ contiene un archivo de constantes que importan tanto el cliente como el servidor.

2. Ensamblajes/configuraciones del proyecto

Como se mencionó anteriormente, usamos el administrador de módulos para construir el proyecto. Webpack. Echemos un vistazo a nuestra configuración de Webpack:

paquete web.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 líneas más importantes aquí son:

  • 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 Webpack se ubicará en el directorio dist/. Llamaré a este archivo nuestro paquete js.
  • Usamos Babel, y en particular la configuración @ babel / preset-env para transpilar nuestro código JS para navegadores más antiguos.
  • Estamos utilizando un complemento para extraer todo el CSS al que hacen referencia los archivos JS y combinarlos en un solo lugar. lo llamaré nuestro paquete css.

Es posible que haya notado nombres de archivo de paquetes extraños '[name].[contenthash].ext'. ellos contienen sustituciones de nombre de archivo Paquete web: [name] será reemplazado con el nombre del punto de entrada (en nuestro caso, este game), y [contenthash] será reemplazado con un hash del contenido del archivo. lo hacemos a optimizar el proyecto para hashing - puede decirle a los navegadores que almacenen en caché nuestros paquetes JS indefinidamente, porque si un paquete cambia, entonces su nombre de archivo también cambia (cambios contenthash). El resultado final será el nombre del archivo de vista. game.dbeee76e91a97d0c7207.js.

Expediente webpack.common.js es el archivo de configuración base que importamos en las configuraciones de desarrollo y proyecto terminado. Aquí hay una configuración de desarrollo de ejemplo:

paquete web.dev.js

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

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

Para mayor eficiencia, utilizamos en el proceso de desarrollo webpack.dev.js, y cambia a webpack.prod.jspara optimizar los tamaños de los paquetes al implementarlos 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, el sistema debe tener instalado Nodo и NPM. A continuación, debe hacer

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

y usted está listo para ir! Para iniciar el servidor de desarrollo, simplemente ejecute

$ npm run develop

e ir al navegador web localhost: 3000. El servidor de desarrollo reconstruirá automáticamente los paquetes JS y CSS a medida que cambie el código. ¡Simplemente actualice la página para ver todos los cambios!

3. Puntos de entrada de clientes

Vayamos al código del juego en sí. Primero necesitamos una página index.html, al visitar el sitio, el navegador lo cargará primero. Nuestra página será bastante simple:

index.html

Un ejemplo de juego .io  JUGAR

Este ejemplo de código se ha simplificado ligeramente para mayor claridad, y haré lo mismo con muchos de los otros ejemplos de publicaciones. El código completo siempre se puede ver en Github.

Tenemos:

  • Elemento de lienzo HTML5 (<canvas>) que usaremos para renderizar el juego.
  • <link> para agregar nuestro paquete CSS.
  • <script> para agregar nuestro paquete Javascript.
  • Menú principal con nombre de usuario <input> y el botón PLAY (<button>).

Después de cargar la página de inicio, el navegador comenzará a ejecutar el código Javascript, comenzando desde el archivo JS del 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 = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Esto puede sonar complicado, pero no hay mucho que hacer aquí:

  1. Importando varios otros archivos JS.
  2. Importación de CSS (para que Webpack sepa incluirlos en nuestro paquete CSS).
  3. Lanzar connect() para establecer una conexión con el servidor y ejecutar downloadAssets() para descargar las imágenes necesarias para renderizar el juego.
  4. Después de completar la etapa 3 El menu principal es mostrado (playMenu).
  5. Configuración del controlador para presionar el botón "PLAY". Cuando se presiona el botón, el código inicializa el juego y le dice al servidor que estamos listos para jugar.

La "carne" principal de nuestra lógica cliente-servidor está en aquellos archivos que fueron importados por el archivo index.js. Ahora los consideraremos todos en orden.

4. Intercambio de datos de clientes

En este juego, usamos una biblioteca muy conocida para comunicarnos con el servidor. socket.io. Socket.io tiene soporte nativo WebSockets, que son muy adecuados para la comunicación bidireccional: podemos enviar mensajes al servidor и el servidor puede enviarnos mensajes en la misma conexión.

Tendremos un archivo src/client/networking.jsquien se encargara de todo comunicación con el servidor:

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

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

Hay tres acciones principales en este archivo:

  • Estamos tratando de conectarnos al servidor. connectedPromise Solo se permite cuando hemos establecido una conexión.
  • Si la conexión es exitosa, registramos funciones de devolución de llamada (processGameUpdate() и onGameOver()) para los mensajes que podemos recibir del servidor.
  • exportamos play() и updateDirection()para que otros archivos puedan usarlos.

5. Representación del cliente

¡Es hora de mostrar la imagen en la pantalla!

…pero antes de que podamos hacer eso, necesitamos descargar todas las imágenes (recursos) que se necesitan para esto. Escribamos un administrador de recursos:

activos.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ón de recursos no es tan difícil de implementar! La idea principal es almacenar un objeto. assets, que vinculará la clave del nombre de archivo al valor del objeto Image. Cuando se carga el recurso, lo almacenamos en un objeto. assets para un acceso rápido en el futuro. ¿Cuándo se permitirá la descarga de cada recurso individual (es decir, todos recursos), permitimos downloadPromise.

Después de descargar los recursos, puede comenzar a renderizar. Como se dijo anteriormente, para dibujar en una página web, usamos Lienzo HTML5 (<canvas>). Nuestro juego es bastante simple, por lo que solo necesitamos dibujar lo siguiente:

  1. fondo
  2. Nave del jugador
  3. Otros jugadores en el juego
  4. Conchas

Aquí están los fragmentos importantes. src/client/render.js, que representan exactamente los cuatro elementos enumerados anteriormente:

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

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

render() es la función principal de este archivo. startRendering() и stopRendering() controlar la activación del bucle de renderizado a 60 FPS.

Implementaciones concretas de funciones auxiliares de representación individuales (p. renderBullet()) no son tan importantes, pero aquí hay un ejemplo simple:

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

Tenga en cuenta que estamos usando el método getAsset(), que se vio anteriormente en asset.js!

Si está interesado en conocer otros asistentes de representación, lea el resto. src/cliente/render.js.

6. Entrada del cliente

Es hora de hacer un juego. interpretable! El esquema de control será muy simple: para cambiar la dirección del movimiento, puede usar el mouse (en una computadora) o tocar la pantalla (en un dispositivo móvil). Para implementar esto, registraremos Oyentes de eventos para eventos Mouse y Touch.
se encargará de todo esto src/client/input.js:

entrada.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() son detectores de eventos que llaman updateDirection() (de networking.js) cuando ocurre un evento de entrada (por ejemplo, cuando se mueve el mouse). updateDirection() maneja la mensajería con el servidor, que maneja el evento de entrada y actualiza el estado del juego en consecuencia.

7. Estado del cliente

Esta sección es la más difícil de la primera parte del post. ¡No se desanime si no lo entiende la primera vez que lo lee! Incluso puede omitirlo y volver a él más tarde.

La última pieza del rompecabezas necesaria para completar el código cliente/servidor es estado. ¿Recuerda el fragmento de código de la sección Representación del cliente?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() debería ser capaz de darnos el estado actual del juego en el cliente en cualquier momento basado en las actualizaciones recibidas del servidor. Aquí hay un ejemplo de una actualización del juego que el servidor puede 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 actualización del juego contiene cinco campos idénticos:

  • t: marca de tiempo del servidor que indica cuándo se creó esta actualización.
  • me: información sobre el jugador que recibe esta actualización.
  • otros: Una matriz de información sobre otros jugadores que participan en el mismo juego.
  • balas: una variedad de información sobre proyectiles en el juego.
  • clasificación: Datos de la clasificación actual. En este post, no los consideraremos.

7.1 Estado de cliente ingenuo

Implementación ingenua getCurrentState() solo puede devolver directamente los datos de la actualización del juego recibida más recientemente.

estado-naive.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bonito y claro! Pero si tan solo fuera así de simple. Una de las razones por las que esta implementación es problemática: limita la velocidad de fotogramas de renderizado a la velocidad del reloj del servidor.

Cuadros por segundo: número de fotogramas (es decir, llamadas render()) por segundo, o FPS. Los juegos generalmente se esfuerzan por alcanzar al menos 60 FPS.

Tasa de garrapatas: La frecuencia con la que el servidor envía actualizaciones del juego a los clientes. A menudo es inferior a la velocidad de fotogramas. En nuestro juego, el servidor se ejecuta a una frecuencia de 30 ciclos por segundo.

Si solo renderizamos la última actualización del juego, entonces el FPS esencialmente nunca pasará de 30, porque nunca recibimos más de 30 actualizaciones por segundo del servidor. Incluso si llamamos render() 60 veces por segundo, entonces la mitad de estas llamadas volverán a dibujar lo mismo, esencialmente sin hacer nada. Otro problema con la implementación ingenua es que propenso a retrasos. Con una velocidad de Internet ideal, el cliente recibirá una actualización del juego exactamente cada 33ms (30 por segundo):

Creación de un juego web .io multijugador
Desafortunadamente, nada es perfecto. Una imagen más realista sería:
Creación de un juego web .io multijugador
La implementación ingenua es prácticamente el peor de los casos en lo que respecta a la latencia. Si se recibe una actualización del juego con un retraso de 50 ms, entonces puesto de clientes 50 ms adicionales porque aún muestra el estado del juego de la actualización anterior. Puedes imaginar lo incómodo que es esto para el jugador: el frenado arbitrario hará que el juego se sienta entrecortado e inestable.

7.2 Estado del cliente mejorado

Haremos algunas mejoras a la implementación ingenua. Primero, usamos retraso de renderizado durante 100 ms. Esto significa que el estado "actual" del cliente siempre estará 100 ms por detrás del estado del juego en el servidor. Por ejemplo, si la hora en el servidor es 150, entonces el cliente representará el estado en el que se encontraba el servidor en ese momento 50:

Creación de un juego web .io multijugador
Esto nos da un búfer de 100 ms para sobrevivir a los impredecibles tiempos de actualización del juego:

Creación de un juego web .io multijugador
La recompensa por esto será permanente. retraso de entrada durante 100 ms. Este es un sacrificio menor para un juego fluido: la mayoría de los jugadores (especialmente los jugadores casuales) ni siquiera notarán este retraso. Es mucho más fácil para las personas adaptarse a una latencia constante de 100 ms que jugar con una latencia impredecible.

También podemos usar otra técnica llamada predicción del lado del cliente, que hace un buen trabajo al reducir la latencia percibida, pero no se tratará en esta publicación.

Otra mejora que estamos usando es Interpolación linear. Debido al retraso en el procesamiento, generalmente estamos al menos una actualización por delante de la hora actual en el cliente. cuando se llama getCurrentState(), podemos ejecutar Interpolación linear entre las actualizaciones del juego justo antes y después de la hora actual en el cliente:

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

7.3 Implementación del estado de cliente mejorado

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

estado.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);

  // 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 paso es averiguar qué currentServerTime(). Como vimos anteriormente, cada actualización del juego incluye una marca de tiempo del servidor. Queremos usar la latencia de renderizado para renderizar la imagen 100ms detrás del servidor, pero nunca sabremos la hora actual en el servidor, porque no podemos saber cuánto tiempo tardó en llegarnos alguna de las actualizaciones. ¡Internet es impredecible y su velocidad puede variar mucho!

Para sortear este problema, podemos usar una aproximación razonable: pretender que la primera actualización llegó al instante. Si esto fuera cierto, ¡sabríamos la hora del servidor en este momento en particular! Almacenamos la marca de tiempo del servidor en firstServerTimestamp y mantener nuestro local (cliente) marca de tiempo en el mismo momento en gameStart.

Oh espera. ¿No debería ser hora del servidor = hora del cliente? ¿Por qué distinguimos entre "marca de tiempo del servidor" y "marca de tiempo del cliente"? ¡Esta es una gran pregunta! Resulta que no son lo mismo. Date.now() devolverá diferentes marcas de tiempo en el cliente y el servidor, y depende de los factores locales de estas máquinas. Nunca asuma que las marcas de tiempo serán las mismas en todas las máquinas.

Ahora entendemos lo que significa currentServerTime(): vuelve la marca de tiempo del servidor del tiempo de procesamiento actual. En otras palabras, esta es la hora actual del servidor (firstServerTimestamp <+ (Date.now() - gameStart)) menos el retraso de procesamiento (RENDER_DELAY).

Ahora echemos un vistazo a cómo manejamos las actualizaciones del juego. Cuando se recibe del servidor de actualización, se llama processGameUpdate()y guardamos la nueva actualización en una matriz gameUpdates. Luego, para verificar el uso de la memoria, eliminamos todas las actualizaciones anteriores antes actualización baseporque ya no los necesitamos.

¿Qué es una "actualización básica"? Este la primera actualización que encontramos retrocediendo desde la hora actual del servidor. ¿Recuerdas este diagrama?

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

¿Para qué sirve la actualización base? ¿Por qué podemos dejar las actualizaciones en la línea de base? Para resolver esto, vamos a finalmente considerar la implementación getCurrentState():

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

Manejamos tres casos:

  1. base < 0 significa que no hay actualizaciones hasta el tiempo de renderizado actual (ver implementación anterior getBaseUpdate()). Esto puede suceder justo al comienzo del juego debido al retraso en el renderizado. En este caso, utilizamos la última actualización recibida.
  2. base es la última actualización que tenemos. Esto puede deberse a un retraso en la red o una mala conexión a Internet. En este caso, también estamos usando la última actualización que tenemos.
  3. Tenemos una actualización tanto antes como después del tiempo de procesamiento actual, por lo que podemos interpolar!

Todo lo que queda en state.js es una implementación de interpolación lineal que es matemática simple (pero aburrida). Si desea explorarlo usted mismo, entonces abra state.js en Github.

Parte 2. Servidor back-end

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

1. Punto de entrada del servidor

Para administrar el servidor web, usaremos un marco web popular para Node.js llamado Express. Será configurado por nuestro archivo de punto de entrada del servidor src/server/server.js:

servidor.js parte 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}`);

¿Recuerdas que en la primera parte hablamos de Webpack? Aquí es donde usaremos nuestras configuraciones de Webpack. Los utilizaremos de dos formas:

  • Utilizar paquete web-dev-middleware para reconstruir automáticamente nuestros paquetes de desarrollo, o
  • carpeta de transferencia estática dist/, en el que Webpack escribirá nuestros archivos después de la compilación de producción.

Otra tarea importante server.js es configurar el servidor socket.ioque simplemente se conecta al servidor Express:

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

Después de establecer con éxito una conexión socket.io con el servidor, configuramos controladores de eventos para el nuevo socket. Los controladores de eventos manejan los mensajes recibidos de los clientes delegándolos a un objeto único game:

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

Estamos creando un juego .io, por lo que solo necesitamos una copia Game ("Juego"): ¡todos los jugadores juegan en la misma arena! En la siguiente sección, veremos cómo funciona esta clase. Game.

2. Servidores de juegos

clase Game contiene la lógica más importante del lado del servidor. Tiene dos tareas principales: gestión de jugadores и simulación de juego.

Comencemos con la primera tarea, la gestión de jugadores.

juego.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;

    // 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 este juego, identificaremos a los jugadores por el campo. id su socket socket.io (si se confunde, vuelva a server.js). El mismo Socket.io asigna a cada socket un único idasí que no tenemos que preocuparnos por eso. Lo llamaré Identificación del jugador.

Con eso en mente, exploremos las variables de instancia en una clase. Game:

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

bullets es una matriz de objetos Bullet, que no tiene un orden definido.
lastUpdateTime es la marca de tiempo de la última vez que se actualizó el juego. Veremos cómo se usa en breve.
shouldSendUpdate es una variable auxiliar. También veremos su uso en breve.
Métodos addPlayer(), removePlayer() и handleInput() no hace falta explicarlo, se usan en server.js. Si necesita refrescar su memoria, retroceda un poco más arriba.

Última línea constructor() lanzamientos ciclo de actualización juegos (con una frecuencia de 60 actualizaciones/s):

juego.js parte 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étodo update() contiene quizás la pieza más importante de la lógica del lado del servidor. Esto es lo que hace, en orden:

  1. Calcula cuanto tiempo dt pasado desde el último update().
  2. Refresca cada proyectil y los destruye si es necesario. Veremos la implementación de esta funcionalidad más adelante. Por ahora, es suficiente para nosotros saber que bullet.update() devuelve truesi el proyectil debe ser destruido (él salió de la arena).
  3. Actualiza a cada jugador y genera un proyectil si es necesario. También veremos esta implementación más adelante: player.update() puede devolver un objeto Bullet.
  4. Comprueba colisiones entre proyectiles y jugadores con applyCollisions(), que devuelve una serie de proyectiles que golpean a los jugadores. Por cada proyectil devuelto, aumentamos los puntos del jugador que lo disparó (usando player.onDealtDamage()) y luego retire el proyectil de la matriz bullets.
  5. Notifica y destruye a todos los jugadores asesinados.
  6. Envía una actualización del juego a todos los jugadores. cada segundo momentos en que se llama update(). Esto nos ayuda a realizar un seguimiento de la variable auxiliar mencionada anteriormente. shouldSendUpdate... Como update() llamado 60 veces/s, enviamos actualizaciones de juegos 30 veces/s. De este modo, frecuencia de reloj el reloj del servidor es de 30 relojes/s (hablamos de velocidades de reloj en la primera parte).

¿Por qué enviar solo actualizaciones de juegos? a través del tiempo ? Para guardar el canal. ¡30 actualizaciones de juegos por segundo es mucho!

¿Por qué no simplemente llamar? 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 te dejes llevar por la cantidad de desafíos. update(), porque esta es una tarea computacionalmente costosa: 60 por segundo es suficiente.

el resto de la clase Game consiste en métodos auxiliares utilizados en update():

juego.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) <= 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() bastante simple: ordena a los jugadores por puntuación, toma los cinco primeros y devuelve el nombre de usuario y la puntuación de cada uno.

createUpdate() utilizada en update() para crear actualizaciones de juegos que se distribuyen a los jugadores. Su tarea principal es llamar a métodos. serializeForUpdate()implementado para clases Player и Bullet. Tenga en cuenta que solo pasa datos a cada jugador sobre más cercano jugadores y proyectiles: ¡no hay necesidad de transmitir información sobre los objetos del juego que están lejos del jugador!

3. Objetos de juego en el servidor

En nuestro juego, los proyectiles y los jugadores son en realidad muy similares: son objetos de juego abstractos, redondos y móviles. Para aprovechar esta similitud entre jugadores y proyectiles, comencemos implementando la clase base Object:

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

No hay nada complicado pasando aquí. Esta clase será un buen punto de anclaje para la extensión. Vamos a ver cómo la clase Bullet usos Object:

bala.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ón Bullet ¡muy corto! hemos añadido a Object solo las siguientes extensiones:

  • usando un paquete bajito para generación aleatoria id proyectil
  • Agregar un campo parentIDpara que puedas rastrear al jugador que creó este proyectil.
  • Agregar un valor de retorno a update(), que es igual a truesi el proyectil está fuera de la arena (¿recuerdas que hablamos de esto en la última sección?).

Movámonos a Player:

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

Los jugadores son más complejos que los proyectiles, por lo que se deben almacenar algunos campos más en esta clase. Su método update() hace mucho trabajo, en particular, devuelve el proyectil recién creado si no queda ninguno fireCooldown (¿recuerdas que hablamos de esto en la sección anterior?). También extiende el método serializeForUpdate(), porque necesitamos incluir campos adicionales para el jugador en la actualización del juego.

Tener una clase base Object - un paso importante para evitar repetir código. Por ejemplo, ninguna clase. Object cada objeto del juego debe tener la misma implementación distanceTo(), y copiar y pegar todas estas implementaciones en varios archivos sería una pesadilla. Esto se vuelve especialmente importante para proyectos grandes.cuando el número de expansión Object las clases están creciendo.

4. Detección de colisiones

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

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

    // ...
  }
}

Necesitamos implementar el método. applyCollisions(), que devuelve todos los proyectiles que golpean a los jugadores. Afortunadamente, no es tan difícil de hacer porque

  • Todos los objetos que chocan son círculos, que es la forma más simple para implementar la detección de colisiones.
  • Ya tenemos un método distanceTo(), que implementamos en la sección anterior en la clase Object.

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

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

Esta simple detección de colisión se basa en el hecho de que dos circunferencias chocan si la distancia entre sus centros es menor que la suma de sus radios. Aquí está el caso donde la distancia entre los centros de dos círculos es exactamente igual a la suma de sus radios:

Creación de un juego web .io multijugador
Hay un par de aspectos más a considerar aquí:

  • El proyectil no debe golpear al jugador que lo creó. Esto se puede lograr comparando bullet.parentID с player.id.
  • El proyectil solo debe golpear una vez en el caso límite de que varios jugadores colisionen al mismo tiempo. Resolveremos este problema usando el operador break: tan pronto como se encuentra el jugador que choca con el proyectil, detenemos la búsqueda y pasamos al siguiente proyectil.

Final

¡Eso es todo! Cubrimos todo lo que necesita saber para crear un juego web .io. ¿Que sigue? ¡Construye tu propio juego .io!

Todo el código de muestra es de código abierto y se publica en Github.

Fuente: habr.com

Añadir un comentario