Crea un juego web multijugador .io

imagen

Lanzado en 2015, Agar.io se convirtió en el progenitor del nuevo género de juegos .io , cuya popularidad ha crecido significativamente desde entonces. El crecimiento de la popularidad de los juegos .io que he experimentado en mí mismo: en los últimos tres años he creado y vendido dos juegos de este género. .

En caso de que nunca antes hayas oído hablar de estos juegos: estos son juegos web gratuitos para varios jugadores en los que es fácil participar (no se requiere cuenta). Por lo general, empujan a muchos jugadores opuestos en la misma arena. Otros juegos famosos del género .io son: Slither.io y Diep.io.

En esta publicación, descubriremos cómo crear un juego .io desde cero . Para esto, solo el conocimiento de Javascript será suficiente: debe comprender cosas como la sintaxis de ES6 , la this y Promises . Incluso si conoces Javascript no perfectamente, aún puedes descifrar la mayor parte de la publicación.

Ejemplo de juego .io


Para ayudarlo a aprender, nos referiremos a un ejemplo de juego .io . ¡Intenta jugarlo!


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. Descripción general / estructura del proyecto


Recomiendo descargar el código fuente de un juego de ejemplo para que pueda seguirme.

El ejemplo usa lo siguiente:

  • Express es el marco web más popular para Node.js que administra el servidor web del juego.
  • socket.io : una biblioteca websocket para intercambiar datos entre un navegador y un servidor.
  • Webpack es un administrador de módulos. Puede leer sobre por qué usar Webpack aquí .

Así es como se ve la estructura de directorios del proyecto:

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

público /


Todo en la carpeta public/ será transmitido estáticamente por el servidor. public/assets/ contiene las imágenes utilizadas por nuestro proyecto.

src /


Todo el código fuente está en la carpeta src/ . Los nombres client/ y server/ hablan por sí mismos, y shared/ contiene un archivo constante importado tanto por el cliente como por el servidor.

2. Asambleas / parámetros del proyecto


Como se indicó anteriormente, utilizamos el administrador del módulo Webpack para construir el proyecto. Echemos 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 más importantes aquí son las siguientes líneas:

  • src/client/index.js es el punto de entrada al cliente Javascript (JS). Webpack comenzará desde aquí y buscará recursivamente otros archivos importados.
  • La salida JS de nuestro ensamblaje 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.
  • Usamos el 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 archivos de paquetes extraños '[name].[contenthash].ext' . Contienen la sustitución de los nombres de los archivos Webpack: [name] será reemplazado por el nombre del punto de entrada (en nuestro caso, este es un game ), y [contenthash] será reemplazado por un hash del contenido del archivo. Hacemos esto para optimizar el proyecto para el hash : puede decirle a los navegadores que almacenen en caché nuestros paquetes JS de forma indefinida, porque si el paquete cambia, entonces su nombre de archivo cambia ( contenthash cambia). El resultado final será un nombre de archivo del formulario game.dbeee76e91a97d0c7207.js .

El archivo webpack.common.js es el archivo de configuración base que importamos en las configuraciones de desarrollo y proyecto finalizado. Por ejemplo, una 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, utilizamos webpack.dev.js en el webpack.dev.js desarrollo y webpack.prod.js a webpack.prod.js para optimizar el tamaño de los paquetes cuando se implementan en producción.

Entorno 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, Node y NPM deben instalarse en el sistema. Luego debes realizar

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

y ya estas listo! Para iniciar el servidor de desarrollo, simplemente ejecute

 $ npm run develop 

y acceda al localhost: 3000 en un navegador web. 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 del cliente


Vayamos al código del juego en sí. Primero, necesitamos la página index.html , cuando visite el sitio, el navegador la cargará primero. 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>
 <cuerpo>
   <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"> PLAY </button>
   </div>
 </body>
 </html> 

Este ejemplo de código está ligeramente simplificado para mayor claridad, haré lo mismo con muchos otros ejemplos de la publicación. El código completo siempre se puede ver en Github .

Tenemos:

  • <canvas> HTML5 Canvas ( <canvas> ) que usaremos para representar el juego.
  • <link> para agregar nuestro paquete CSS.
  • <script> para agregar nuestro paquete Javascript.
  • El menú principal con el nombre de usuario <input> y el botón "PLAY" ( <button> ).

Después de cargar la página de inicio, el código Javascript comenzará a ejecutarse en el navegador, comenzando con 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 parecer complicado, pero en realidad no hay muchas acciones sucediendo aquí:

  1. Importe varios otros archivos JS.
  2. Importar CSS (por lo que Webpack sabe incluirlos en nuestro paquete CSS).
  3. Ejecutando connect() para establecer una conexión con el servidor y ejecutando downloadAssets() para descargar las imágenes necesarias para representar el juego.
  4. Después de completar el paso 3 , se playMenu el menú principal ( 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 del cliente.


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

Tendremos un archivo src/client/networking.js que manejará 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('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 reduce ligeramente para mayor claridad.

Hay tres acciones principales en este archivo:

  • Estamos intentando conectarnos al servidor. connectedPromise solo está permitido cuando hemos establecido una conexión.
  • Si la conexión se establece con éxito, registramos las funciones de devolución de llamada ( processGameUpdate() y onGameOver() ) para los mensajes que podemos recibir del servidor.
  • Exportamos play() y updateDirection() para que otros archivos puedan usarlos.

5. Representación del cliente


¡Es hora de mostrar una imagen en la pantalla!

... pero antes de que podamos hacer esto, necesitamos descargar todas las imágenes (recursos) que se necesitan para esto. Escribamos un administrador 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ón de recursos no es tan difícil de implementar! El punto principal es almacenar el objeto assets , que vinculará la clave del nombre del archivo con el valor del objeto Image . Cuando se carga el recurso, lo guardamos en el objeto de assets para una recuperación rápida en el futuro. Cuando se permitirá la descarga para cada recurso individual (es decir, se descargarán todos los recursos), habilitamos downloadPromise .

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

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

Estos son los fragmentos importantes de src/client/render.js que representan exactamente los cuatro puntos 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 se acorta 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 de representación auxiliares individuales (por ejemplo, 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, ); } 

¡Observe que usamos el método getAsset() que se vio anteriormente en asset.js !

Si está interesado en explorar otras funciones auxiliares de representación, lea el resto de src / client / render.js .

6. Aporte del cliente


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

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 oyentes de eventos que llaman a updateDirection() (desde networking.js ) cuando ocurre un evento de entrada (por ejemplo, al mover el mouse). updateDirection() participa en la mensajería con un servidor que procesa el evento de entrada y actualiza el estado del juego en consecuencia.

7. Condición del cliente


Esta sección es la más difícil en la primera parte de la publicación. ¡No se desanime si no lo comprende desde la primera lectura! Incluso puede omitirlo y volver a él más tarde.

La última pieza del rompecabezas que se necesita para completar el código cliente-servidor es el estado . ¿Recuerdas 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 poder proporcionarnos el estado actual del juego en el cliente en cualquier momento en función de las actualizaciones recibidas del servidor. Aquí hay un ejemplo de una actualización del juego que un 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 para el momento en que se creó esta actualización.
  • yo : información sobre el jugador que recibe esta actualización.
  • otros : un conjunto de información sobre otros jugadores que participan en el mismo juego.
  • viñetas : una serie de información sobre los shells en el juego.
  • tabla de clasificación : datos actuales de la tabla de clasificación. En esta publicación no los tendremos en cuenta.

7.1 El estado ingenuo del cliente


La implementación ingenua de getCurrentState() solo puede devolver datos directamente de la actualización de juego más reciente recibida.

ingenuo-estado.js

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

Hermoso y claro! Pero si todo fuera tan simple. Una de las razones por las que esta implementación es problemática: limita la velocidad de cuadros del render a la frecuencia del reloj del servidor .

Velocidad de fotogramas : el número de fotogramas (es decir, llamadas de render() ) por segundo, o FPS. Los juegos generalmente tienden a alcanzar al menos 60 FPS.

Tick ​​Rate : 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 funciona a una frecuencia de 30 ciclos por segundo.

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


Lamentablemente, nada es perfecto. Una imagen más realista sería:

Una implementación ingenua es prácticamente el peor de los casos cuando se trata de retrasos. Si la actualización del juego se recibe con un retraso de 50 ms, el cliente se ralentiza 50 ms adicionales, ya que aún representa el estado del juego de la actualización anterior. Puedes imaginar lo inconveniente que es para un jugador: debido al frenado arbitrario, el juego parecerá nervioso e inestable.

7.2 Estado del cliente mejorado


Haremos algunas mejoras a la implementación ingenua. Primero, usamos un retraso de renderizado de 100 ms. Esto significa que el estado "actual" del cliente siempre se retrasará 100 ms respecto del estado del juego en el servidor. Por ejemplo, si el tiempo en el servidor es 150 , el estado en el que el servidor estaba en el momento 50 se representará en el cliente:


Esto nos da un búfer de 100 ms, lo que nos permite sobrevivir al tiempo impredecible para recibir actualizaciones del juego:


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

Podemos usar otra técnica llamada "pronóstico del lado del cliente", que hace un buen trabajo al reducir los retrasos percibidos, pero no se considerará en esta publicación.

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


Esto resuelve el problema con la velocidad de cuadros: ¡ahora podemos renderizar cuadros únicos con cualquier frecuencia que necesitemos!

7.3 Implementación del estado mejorado del cliente


Un ejemplo de implementación en src/client/state.js utiliza tanto el retraso de representación como la interpolación lineal, pero esto no es por mucho tiempo. Dividamos el código en dos partes. Aquí está el primero:

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

Para solucionar este problema, puede usar una aproximación razonable: fingiremos que la primera actualización llegó instantáneamente . Si esto fuera cierto, entonces sabríamos la hora del servidor en este momento en particular. Guardamos la marca de tiempo del servidor en firstServerTimestamp y firstServerTimestamp nuestra marca de tiempo local (cliente) al mismo tiempo en gameStart .

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

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

Ahora veamos cómo manejamos las actualizaciones del juego. Cuando se recibe una actualización del servidor, 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 antiguas de la actualización base , porque ya no las necesitamos.

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


La actualización del juego directamente a la izquierda de Client Render Time es una actualización básica.

¿Para qué se usa la actualización básica? ¿Por qué podemos descartar actualizaciones a la base? Para entender esto, finalmente veamos la implementación 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), }; } } 

Manejamos tres casos:

  1. base < 0significa que no hay actualizaciones en el tiempo de representación actual (ver implementación anterior getBaseUpdate()). Esto puede suceder justo al comienzo del juego debido a retrasos en el procesamiento. En este caso, utilizamos la actualización más reciente.
  2. baseEsta es la última actualización que tenemos. Esto puede deberse a la latencia de la red o la mala conexión a Internet. En este caso, también utilizamos la última actualización que tenemos.
  3. Tenemos una actualización tanto antes como después del tiempo de representación actual, para que pueda interpolar .

Todo lo que queda state.jses la implementación de la interpolación lineal, que es una matemática simple (pero aburrida). Si quieres aprenderlo tú mismo, ábrelostate.js en Github .

Parte 2. Servidor de fondo


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

1. Punto de entrada del servidor


Para controlar el servidor web, utilizaremos el popular marco web para Node.js llamado Express . Será configurado por 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'); // 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 aplicaremos de dos maneras:

  • Utilice webpack-dev-middleware para reconstruir automáticamente nuestros paquetes de desarrollo, o
  • Transfiera estáticamente la carpeta dist/en la que Webpack escribirá nuestros archivos después del ensamblaje de producción.

Otra tarea importante server.jses configurar el servidor socket.io , que simplemente se conecta al servidor Express:

server.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 procesan los mensajes recibidos de los clientes por delegación en el objeto singleton game:

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

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

2. Servidor de juegos


La clase Gamecontiene la lógica más importante del lado del servidor. Tiene dos tareas principales: gestión de jugadores y simulación de juegos .

Comencemos con la primera tarea: con la gestión de 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; // 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 de idsu socket socket.io (si está confundido, vuelva a server.js). Socket.io asigna a cada socket uno único id, por lo que no debemos preocuparnos por esto. Llamaré a su ID de jugador .

Con esto en mente, examinemos las variables de instancia en la clase Game:

  • socketsEs un objeto que une la identificación del jugador al zócalo que está asociado con el jugador. Nos permite obtener acceso a los sockets mediante sus ID de jugador durante un tiempo constante.
  • players Es un objeto que une una ID de jugador a un código> Objeto de jugador

bulletsEs una matriz de objetos Bulletque no tiene un orden específico.
lastUpdateTime- Esta es la marca de tiempo de la última actualización del juego. Pronto veremos cómo se usa.
shouldSendUpdateEs una variable auxiliar. Veremos su uso pronto también.
Métodos addPlayer(), removePlayer()y handleInput()no es necesario explicar, se utilizan en server.js. Si necesita actualizar su memoria, retroceda un poco más.

La última línea constructor()comienza el ciclo de actualización del juego (con una frecuencia de 60 actualizaciones / s):

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

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

  1. Calcula cuánto tiempo dtha pasado desde la última update().
  2. . . , bullet.update() true , ( ).
  3. . — player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).

? . 30 – !

¿Por qué simplemente no llamar update()30 veces por segundo? Para mejorar la simulación del juego. Cuanto más se llame update(), más precisa será la simulación del juego. Pero no se deje llevar por la cantidad de llamadas update(), porque es una tarea computacionalmente costosa: 60 por segundo es suficiente.

El resto de la clase Gameconsiste en métodos auxiliares utilizados 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) <= 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() – , .

createUpdate() update() , . serializeForUpdate() , Player Bullet . , – , !

3.


: . , 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 pasa nada complicado. Esta clase será un buen punto de referencia para la expansión. Veamos cómo Bulletusa la clase 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; } } 

¡La implementación es Bulletmuy corta! Agregamos Objectsolo las siguientes extensiones:

  • Usando un paquete shortid para generar aleatoriamente un idproyectil.
  • Agregar un campo parentIDpara que pueda rastrear al jugador que creó este proyectil.
  • Agregar un valor de retorno a update(), que es igual truesi el proyectil está fuera de la arena (recuerde, ¿hablamos de esto en la última sección?).

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; } // 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 deberían almacenarse algunos campos más en esta clase. Su método update()hace mucho trabajo, en particular, devuelve el shell recién creado si no se deja fireCooldown(recuerde, ¿hablamos de esto en la sección anterior?). También amplía el método serializeForUpdate(), porque necesitamos incluir campos adicionales para el jugador en la actualización del juego.

Tener una clase base Objectes un paso importante para evitar la repetibilidad del código . Por ejemplo, sin una clase, Objectcada objeto del juego debería tener la misma implementación distanceTo(), y la sincronización de copiar y pegar todas estas implementaciones en varios archivos sería una pesadilla. Esto se vuelve especialmente importante para proyectos grandes cuando Objectcrece el número de clases de extensión .

4. Reconocimiento de conflictos


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

game.js

 const applyCollisions = require('./collisions'); class Game { // ... update() { // ... // Apply collisions, give players score for hitting bullets const destroyedBullets = applyCollisions( Object.values(this.players), this.bullets, ); destroyedBullets.forEach(b => { if (this.players[b.parentID]) { this.players[b.parentID].onDealtDamage(); } }); this.bullets = this.bullets.filter( bullet => !destroyedBullets.includes(bullet), ); // ... } } 

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

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

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

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

, , . , :


:

  • . , bullet.parentID player.id .
  • . break : , , .


! , - .io. Que sigue ¡Crea tu propio juego .io!

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

Source: https://habr.com/ru/post/450574/


All Articles