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:
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 = () => {
Esto puede parecer complicado, pero en realidad no hay muchas acciones sucediendo aquí:
- Importe varios otros archivos JS.
- Importar CSS (por lo que Webpack sabe incluirlos en nuestro paquete CSS).
- Ejecutando
connect()
para establecer una conexión con el servidor y ejecutando downloadAssets()
para descargar las imágenes necesarias para representar el juego. - Después de completar el paso 3 , se
playMenu
el menú principal ( playMenu
). - 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(() => {
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:
- Antecedentes
- Nave del jugador
- Otros jugadores en el juego.
- 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;
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();
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;
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);
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();
Manejamos tres casos:base < 0
significa 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.base
Esta 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.- Tenemos una actualización tanto antes como después del tiempo de representación actual, para que pueda interpolar .
Todo lo que queda state.js
es 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');
¿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.js
es 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');
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');
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 Game
contiene 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;
En este juego, identificaremos a los jugadores por el campo de id
su 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
:sockets
Es 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
bullets
Es una matriz de objetos Bullet
que 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.shouldSendUpdate
Es 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 {
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:- Calcula cuánto tiempo
dt
ha pasado desde la última update()
. - . . ,
bullet.update()
true
, ( ). - . —
player.update()
Bullet
. applyCollisions()
, , . , ( player.onDealtDamage()
), bullets
.- .
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 Game
consiste en métodos auxiliares utilizados en update()
:game.js, parte 3
class Game {
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 Bullet
usa 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; }
¡La implementación es Bullet
muy corta! Agregamos Object
solo las siguientes extensiones:- Usando un paquete shortid para generar aleatoriamente un
id
proyectil. - Agregar un campo
parentID
para que pueda rastrear al jugador que creó este proyectil. - Agregar un valor de retorno a
update()
, que es igual true
si 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; }
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 Object
es un paso importante para evitar la repetibilidad del código . Por ejemplo, sin una clase, Object
cada 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 Object
crece 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 {
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');
,
, . , :
:
- . ,
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 .