
Lanzado en 2015 se convirtió en el precursor de un nuevo género , cuya popularidad ha crecido enormemente desde entonces. He experimentado el aumento de la popularidad de los juegos .io: en los últimos tres años he .
En caso de que nunca hayas oído hablar de estos juegos: son juegos web multijugador gratuitos en los que es fácil participar (no se requiere cuenta). Normalmente enfrentan a muchos jugadores rivales en una misma arena. Otros juegos famosos del género .io: y .
En este post, vamos a investigar cómo crear un juego .io desde cero. Para esto, solo necesitarás conocimientos de Javascript: debes entender cosas como la sintaxis , la palabra clave this y . Incluso si no dominas Javascript, aún podrás entender la mayor parte del post.
Ejemplo de juego .io
Para ayudar en el aprendizaje, nos referiremos a un . ¡Intenta jugarlo!

El juego es bastante simple: controlas una nave en una arena donde hay otros jugadores. Tu nave dispara automáticamente proyectiles y tratas de golpear a otros jugadores mientras evitas sus proyectiles.
1. Visión general/estructura del proyecto
Recomiendo del ejemplo de juego, para que puedas seguirme.
El ejemplo utiliza lo siguiente:
- es el framework web más popular para Node.js, que gestiona el servidor web del juego.
- es una biblioteca de websocket para el intercambio de datos entre el navegador y el servidor.
- es un gestor de módulos. Puedes leer sobre por qué usar Webpack .
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.jspublic/
Todo en la carpeta public/ será servido de forma estática por el servidor. En public/assets/ se encuentran las imágenes utilizadas por nuestro proyecto.
src/
Todo el código fuente se encuentra en la carpeta src/. Nombres client/ y server/ hablan por sí mismos, y shared/ contiene un archivo de constantes, que es importado tanto por el cliente como por el servidor.
2. Construcciones/parámetros del proyecto
Como se mencionó anteriormente, para construir el proyecto utilizamos un gestor de módulos. 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 siguientes líneas son las más importantes aquí:
src/client/index.js— es el punto de entrada del cliente Javascript (JS). Webpack comenzará desde aquí y buscará recursivamente otros archivos importados.- El JS de salida de nuestra compilación de Webpack estará ubicado en el directorio
dist\/. Llamaré a este archivo nuestro paquete JS. - Utilizamos , y en particular la configuración para transpilar nuestro código JS para navegadores más antiguos.
- Usamos un plugin para extraer todos los CSS a los que hacen referencia los archivos JS y para combinarlos en un solo lugar. Lo llamaré nuestro paquete CSS.
Es posible que haya notado los extraños nombres de archivos de los paquetes '[name].[contenthash].ext'. Contienen Webpack: [name] se reemplazará por el nombre del punto de entrada (en nuestro caso es game), y [contenthash] se reemplazará por el hash del contenido del archivo. Hacemos esto para — podemos pedir a los navegadores que almacenen en caché indefinidamente nuestros paquetes JS, porque si el paquete cambia, también cambia su nombre de archivo (cambia contenthash). El resultado final será un nombre de archivo del tipo game.dbeee76e91a97d0c7207.js.
Archivo webpack.common.js — este es el archivo de configuración básico que importamos en las configuraciones de desarrollo y del proyecto listo. Aquí, por ejemplo, la configuración de desarrollo:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('.\/webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Para mayor eficiencia, usamos en el proceso de desarrollo webpack.dev.js, y cambia a webpack.prod.js, para optimizar el tamaño de los paquetes cuando se despliega en producción.
Configuración local
Recomiendo instalar el proyecto en una máquina local para que pueda seguir los pasos enumerados en esta publicación. La configuración es simple: primero, debe tener instalados y . Luego, ejecute
$ git clone https:\/\/github.com\/vzhou842\/example-.io-game.git
$ cd example-.io-game
$ npm install¡y estás listo para trabajar! Para iniciar el servidor de desarrollo, solo necesitas ejecutar
$ npm run developy acceder en un navegador web a . El servidor de desarrollo volverá a compilar automáticamente los paquetes JS y CSS a medida que cambies el código; ¡simplemente actualiza la página para ver todos los cambios!
3. Puntos de entrada del cliente
Comencemos con el código del juego. Primero, necesitaremos una página index.html, que será cargada primero cuando se visite el sitio. Nuestra página será bastante simple:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Un ejemplo de juego .io</title>
<link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
<canvas id="game-canvas"></canvas>
<script async src="/game.bundle.js"></script>
<div id="play-menu" class="hidden">
<input type="text" id="username-input" placeholder="Nombre de usuario" />
<button id="play-button">JUGAR</button>
</div>
</body>
</html>Este ejemplo de código está ligeramente simplificado para mayor claridad; lo mismo haré con muchos otros ejemplos del artículo. El código completo siempre puede verse en .
Tenemos:
- (
<canvas>), que utilizaremos para renderizar el juego. <link>para añadir nuestro paquete CSS.<script>para añadir nuestro paquete Javascript.- Menú principal con nombre de usuario
<input>y el botón "JUGAR" ().
Después de que la página de inicio se carga en el navegador, comenzará a ejecutarse el código Javascript, comenzando con el archivo JS de punto de entrada: src/client/index.js.
index.js
import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';
import './css/main.css';
const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');
Promise.all([
connect(),
downloadAssets(),
]).then(() => {
playMenu.classList.remove('hidden');
usernameInput.focus();
playButton.onclick = () => {
// ¡Jugar!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Esto puede parecer complicado, pero en realidad no está pasando mucho aquí:
- Importar varios otros archivos JS.
- Importar CSS (para que Webpack sepa que debe incluirlo en nuestro paquete CSS).
- Lanzamiento
connect()para establecer una conexión con el servidor y empezardownloadAssets()para descargar imágenes necesarias para la renderización del juego. - Una vez completada la etapa 3 se muestra el menú principal (
playMenu). - Configuración del controlador del botón "JUGAR". Cuando se presiona el botón, el código inicializa el juego y notifica al servidor que estamos listos para jugar.
La parte principal de nuestra lógica cliente-servidor se encuentra en aquellos archivos que fueron importados por el archivo index.js. Ahora los examinaremos uno por uno.
4. Intercambio de datos del cliente
En este juego, para comunicarnos con el servidor usamos una biblioteca bien conocida. . En Socket.io hay soporte integrado , que son ideales para la comunicación bidireccional: podemos enviar mensajes al servidor y y el servidor puede enviarnos mensajes a través de la misma conexión.
Tendremos un archivo src/client/networking.js, que se encargará de todas las comunicaciones con el servidor:
networking.js
import io from 'socket.io-client';
import { processGameUpdate } from '.\/state';
const Constants = require('..\/shared\/constants');
const socket = io(`ws:\/\/`${window.location.host}`);
const connectedPromise = new Promise(resolve => {
socket.on('connect', () => {
console.log('¡Conectado al servidor!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
\/\/ Registrar callbacks
socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
})
);
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};
export const updateDirection = dir => {
socket.emit(Constants.MSG_TYPES.INPUT, dir);
};Este código también está ligeramente abreviado para mayor claridad.
Este archivo realiza tres acciones principales:
- Intentamos conectarnos al servidor.
connectedPromisese resuelve solo cuando hemos establecido la conexión. - Si la conexión se establece correctamente, registramos las funciones callback (
processGameUpdate()yonGameOver()) para los mensajes que podemos recibir del servidor. - Exportamos
play()yupdateDirection(), para que puedan ser utilizados por otros archivos.
5. Renderización del cliente
¡Es hora de mostrar la imagen en la pantalla!
…pero antes de que podamos hacerlo, necesitamos descargar todas las imágenes (recursos) necesarias para ello. Escribamos un gestor de recursos:
assets.js
const ASSET_NAMES = ['ship.svg', 'bullet.svg'];
const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));
function downloadAsset(assetName) {
return new Promise(resolve => {
const asset = new Image();
asset.onload = () => {
console.log(`Descargado ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `\/assets\/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; ¡Gestionar recursos no es tan complicado! La idea principal es almacenar un objeto assets, que vinculará la clave del nombre del archivo con el valor del objeto Imagen. Cuando el recurso se cargue, lo guardamos en el objeto assets para un acceso rápido en el futuro. Cuando se permita la descarga de cada recurso individual (es decir, los cargas de trabajo dejarán de funcionar! recursos se han cargado), permitimos downloadPromise.
Una vez descargados los recursos, podemos proceder a la renderización. Como se mencionó anteriormente, para dibujar en la página web usamos (<canvas>). Nuestro juego es bastante simple, por lo que solo necesitamos dibujar lo siguiente:
- Fondo
- Nave del jugador
- Otros jugadores que están en el juego
- Proyectiles
Aquí hay fragmentos importantes src/client/render.js, que renderizan exactamente los cuatro puntos mencionados arriba:
render.js
import { getAsset } from './assets';
import { getCurrentState } from './state';
const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;
// Obtener el contexto gráfico del canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Hacer el canvas de pantalla completa
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Dibujar el fondo
renderBackground(me.x, me.y);
// Dibujar todos los proyectiles
bullets.forEach(renderBullet.bind(null, me));
// Dibujar todos los jugadores
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Funciones auxiliares aquí excluidas
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Este código también se ha acortado para mayor claridad.
render() — es la función principal de este archivo. startRendering() y stopRendering() controlan la activación del ciclo de renderizado a 60 FPS.
Las implementaciones específicas de las funciones auxiliares de renderizado (por ejemplo renderBullet()) no son tan importantes, pero aquí hay un simple ejemplo:
render.js
function renderBullet(me, bullet) {
const { x, y } = bullet;
context.drawImage(
getAsset('bullet.svg'),
canvas.width / 2 + x - me.x - BULLET_RADIUS,
canvas.height / 2 + y - me.y - BULLET_RADIUS,
BULLET_RADIUS * 2,
BULLET_RADIUS * 2,
);
} Nota que estamos usando el método getAsset(), que ya vimos en asset.js!
Si te interesa explorar otras funciones auxiliares de renderizado, lee el resto de .
6. Entrada del cliente
Es hora de hacer que el juego jugable! El esquema de control será muy simple: para cambiar la dirección del movimiento se puede usar el ratón (en la computadora) o tocar la pantalla (en el dispositivo móvil). Para implementar esto, registraremos para eventos de Mouse y Touch.
Todo esto lo manejará src/client/input.js:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
} onMouseInput() y onTouchInput() — son los Event Listeners que invocan updateDirection() (de networking.js) al ocurrir un evento de entrada (por ejemplo, al mover el ratón). updateDirection() se encarga de intercambiar mensajes con el servidor, que procesa el evento de entrada y actualiza el estado del juego en consecuencia.
7. Estado del cliente
Esta sección es la más complicada de la primera parte del post. ¡No te desanimes si no lo entiendes en la primera lectura! Puedes incluso saltártela y volver a ella más tarde.
La última pieza del rompecabezas necesaria para completar el código cliente-servidor es state. ¿Recuerdas el fragmento de código de la sección ‘Renderizado del cliente’?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Hacer el renderizado
// ...
} getCurrentState() debe ser capaz de proporcionarnos el estado actual del juego en el cliente en cualquier momento basado en las actualizaciones recibidas del servidor. Aquí hay un ejemplo de actualización del juego que podría enviar el servidor:
{
"t": 1555960373725,
"me": {
"x": 2213.8050880413657,
"y": 1469.370893425012,
"direction": 1.3082443894581433,
"id": "AhzgAtklgo2FJvwWAADO",
"hp": 100
},
"others": [],
"bullets": [
{
"id": "RUJfJ8Y18n",
"x": 2354.029197099604,
"y": 1431.6848318262666
},
{
"id": "ctg5rht5s",
"x": 2260.546457727445,
"y": 1456.8088728920968
}
],
"leaderboard": [
{
"username": "Player",
"score": 3
}
]
}Cada actualización del juego contiene cinco campos idénticos:
- t: una marca de tiempo del servidor que indica el momento en que se creó esta actualización.
- me: información sobre el jugador que recibe esta actualización.
- others: un arreglo con información de otros jugadores que participan en el mismo juego.
- bullets: un arreglo con información sobre los proyectiles en el juego.
- leaderboard: los datos actuales de la tabla de líderes. En este post no los vamos a considerar.
7.1 Estado ingenuo del cliente
Implementación ingenua getCurrentState() solo puede devolver directamente los datos de la última actualización del juego recibida.
naive-state.js
let lastGameUpdate = null;
// Manejar una actualización de juego recién recibida.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}¡Bonito y claro! Pero si solo fuera tan fácil. Una de las razones por las que tal implementación es problemática es: que limita la frecuencia de cuadros de renderizado a la frecuencia del servidor.
Frecuencia de cuadros (Frame Rate): la cantidad de cuadros (es decir, llamadas
render()) por segundo, o FPS. En los juegos, generalmente se busca alcanzar al menos 60 FPS.
Frecuencia de reloj (Tick Rate): la frecuencia con la que el servidor envía actualizaciones del juego a los clientes. A menudo es inferior a la frecuencia de cuadros. En nuestro juego, el servidor opera a una frecuencia de 30 ciclos por segundo.
Si simplemente renderizamos la última actualización del juego, entonces los FPS nunca podrán superar los 30, porque nunca recibimos más de 30 actualizaciones del servidor por segundo. Incluso si llamamos render() 60 veces por segundo, la mitad de esas llamadas simplemente volverá a dibujar lo mismo, esencialmente haciendo nada. Otro problema de una implementación ingenua es que está sujeta a retrasos. Con una velocidad perfecta de Internet, el cliente recibirá actualizaciones del juego exactamente cada 33 ms (30 por segundo):

Desafortunadamente, nada es perfecto. Un escenario más realista sería el siguiente:

La implementación ingenua es prácticamente el peor de los casos en términos de retrasos. Si la actualización del juego se recibe con un retraso de 50 ms, entonces el cliente se retrasa otros 50 ms, porque sigue renderizando el estado del juego de la actualización anterior. Puedes imaginar lo incómodo que es para el jugador: debido a los retrasos arbitrarios, el juego parece entrecortado e inestable.
7.2 Estado mejorado del cliente
Haremos algunas mejoras a la implementación ingenua. Primero, utilizaremos un retraso de renderizado de 100 ms. Esto significa que el estado "actual" del cliente siempre estará rezagado respecto al estado del juego en el servidor por 100 ms. Por ejemplo, si en el servidor el tiempo es 150, entonces en el cliente se renderizará el estado en el que estaba el servidor en el tiempo 50:

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

El costo de esto será un de 100 ms. Es un sacrificio menor por un proceso de juego fluido: la mayoría de los jugadores (especialmente los casuales) ni siquiera notarán este retraso. A las personas les resulta mucho más fácil adaptarse a un retraso constante de 100 ms que jugar con un retraso impredecible.
También podemos utilizar otra técnica llamada , que maneja bien la reducción de retrasos percibidos, pero no se discutirá en este artículo.
Otra mejora que utilizamos es la interpolación lineal. Debido al retraso de renderizado, generalmente estamos al menos una actualización por delante del tiempo actual en el cliente. Cuando se llama a getCurrentState(), podemos realizar entre las actualizaciones del juego directamente antes y después de la hora actual en el cliente:

Esto resuelve el problema de la frecuencia de fotogramas: ¡ahora podemos renderizar fotogramas únicos a cualquier frecuencia que necesitemos!
7.3 Implementación del estado mejorado del cliente
Ejemplo de implementación en src/client/state.js utiliza tanto el retraso de renderizado como la interpolación lineal, pero esto no durará mucho. Vamos a dividir el código en dos partes. Aquí está la primera:
state.js, parte 1
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Mantener solo una actualización del juego antes de la hora actual del servidor
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Devuelve el índice de la actualización base, la primera actualización del juego antes
// de la hora actual del servidor, o -1 si N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
} Primero debemos entender qué hace currentServerTime(). Como vimos anteriormente, cada actualización del juego incluye una marca de tiempo del servidor. Queremos usar el retraso de renderizado para renderizar la imagen, retrasándonos del servidor por 100 ms, pero nunca sabremos cuál es el tiempo actual en el servidor, porque no podemos saber cuánto tiempo ha tardado en llegar cualquiera de las actualizaciones. ¡Internet es impredecible y su velocidad puede variar enormemente!
Para sortear este problema, podemos utilizar una aproximación razonable: podemos fingir que la primera actualización llegó instantáneamente. ¡Si eso fuera cierto, sabríamos la hora del servidor en ese momento específico! Guardamos la marca de tiempo del servidor en firstServerTimestamp y guardamos nuestra marca de tiempo (del cliente) en ese mismo momento en gameStart.
Oops, espera. ¿No debería ser la hora en el servidor = la hora en el cliente? ¿Por qué diferenciamos entre 'marca de tiempo del servidor' y 'marca de tiempo del cliente'? ¡Esa es una gran pregunta! Resulta que no son lo mismo. Date.now() devolverá marcas de tiempo diferentes en el cliente y en el servidor, y esto depende de factores locales para estas máquinas. Nunca supongas que las marcas de tiempo serán iguales en todas las máquinas.
Ahora entendemos lo que hace currentServerTime(): devuelve la marca de tiempo del servidor para el tiempo actual de renderizado. En otras palabras, este es el tiempo actual del servidor (firstServerTimestamp < + (Date.now() - gameStart)) menos el retraso en la renderización (RENDER_DELAY).
Ahora vamos a entender cómo manejamos las actualizaciones del juego. Al recibir una actualización del servidor, se llama a processGameUpdate(), y guardamos la nueva actualización en el array gameUpdates. Luego, para verificar el uso de la memoria, eliminamos todas las actualizaciones antiguas hasta la actualización base, porque ya no las necesitamos.
¿Qué es exactamente una "actualización base"? Es la primera actualización que encontramos al retroceder desde el tiempo actual del servidor. ¿Recuerdas este esquema?

La actualización del juego que está directamente a la izquierda de "Client Render Time" es la actualización base.
¿Para qué se utiliza la actualización base? ¿Por qué podemos descartar actualizaciones hasta la base? Para entender esto, vamos a finalmente examinar la implementación de getCurrentState():
state.js, parte 2
export function getCurrentState() {
if (!firstServerTimestamp) {
return {};
}
const base = getBaseUpdate();
const serverTime = currentServerTime();
// If base is the most recent update we have, use its state.
// Else, interpolate between its state and the state of (base + 1).
if (base < 0) {
return gameUpdates[gameUpdates.length - 1];
} else if (base === gameUpdates.length - 1) {
return gameUpdates[base];
} else {
const baseUpdate = gameUpdates[base];
const next = gameUpdates[base + 1];
const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
return {
me: interpolateObject(baseUpdate.me, next.me, r),
others: interpolateObjectArray(baseUpdate.others, next.others, r),
bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
};
}
}Estamos manejando tres casos:
base < 0significa que no hay actualizaciones antes del tiempo actual de renderización (ver la implementación anterior degetBaseUpdate()). Esto puede suceder al principio del juego debido al retraso en la renderización. En tal caso, utilizamos la última actualización recibida.basees la última actualización que tenemos. Esto puede ocurrir debido a un retraso en la red o una mala conexión a Internet. En este caso, también usamos la última actualización que tenemos.- Tenemos una actualización tanto antes como después del tiempo actual de renderización, así que podemos interpolar!
Todo lo que queda en state.js es la implementación de la interpolación lineal, que es una matemática simple (pero aburrida). Si quieres estudiarla por tu cuenta, abre state.js en .
Parte 2. Servidor backend
En esta parte, veremos el backend de Node.js que maneja nuestro .
1. Punto de entrada del servidor
Para manejar el servidor web, utilizaremos un popular marco web para Node.js llamado . Su configuración estará a cargo de nuestro archivo de punto de entrada del servidor src/server/server.js:
server.js, parte 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../webpack.dev.js');
// Configurar un servidor Express
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Configurar Webpack para desarrollo
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Servir estáticamente la carpeta dist/ en producción
app.use(express.static('dist'));
}
// Escuchar en el puerto
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Servidor escuchando en el puerto ${port}`);¿Recuerdas que en la primera parte hablamos de Webpack? Aquí es donde utilizaremos nuestras configuraciones de Webpack. Las aplicaremos de dos maneras:
- Usar para volver a compilar automáticamente nuestros paquetes de desarrollo, o
- Servir estáticamente la carpeta
dist\/, donde Webpack escribirá nuestros archivos después de la compilación de producción.
Otra tarea importante server.js es configurar el servidor , que se conecta directamente al servidor Express:
server.js, parte 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Configurar Express
// ...
const server = app.listen(port);
console.log(`Servidor escuchando en el puerto ${port}`);
// Configurar socket.io
const io = socketio(server);
// Escuchar conexiones de socket.io
io.on('connection', socket => {
console.log('¡Jugador conectado!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Después de establecer con éxito la conexión de socket.io con el servidor, configuramos los manejadores de eventos para el nuevo socket. Los manejadores de eventos procesan los mensajes recibidos de los clientes delegando a un objeto singleton game:
server.js, parte 3
const Game = require('./game');
// ...
// Configurar el Juego
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Estamos creando un juego del tipo .io, por lo que solo necesitaremos una instancia de Game ('Juego') - ¡todos los jugadores juegan en una misma arena! En la siguiente sección, veremos cómo funciona esta clase. Game.
2. Servidor del Juego
Clase Game contiene la lógica más importante del lado del servidor. Tiene dos tareas principales: gestionar a los jugadores y simular el juego.
Comencemos con la primera tarea: gestionar a los jugadores.
game.js, parte 1
const Constants = require('..\/shared\/constants');
const Player = require('.\/player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 \/ 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
// Generar una posición para iniciar a este jugador.
const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
this.players[socket.id] = new Player(socket.id, username, x, y);
}
removePlayer(socket) {
delete this.sockets[socket.id];
delete this.players[socket.id];
}
handleInput(socket, dir) {
if (this.players[socket.id]) {
this.players[socket.id].setDirection(dir);
}
}
// ...
} En este juego, vamos a identificar a los jugadores por el campo id de su socket de socket.io (si te confundes, vuelve a server.js). Socket.io asigna a cada socket un único id, por lo que no necesitamos preocuparnos por eso. Lo llamaré ID del jugador.
Ten en cuenta esto y exploremos las variables de instancia en la clase Game:
sockets— este es un objeto que asocia la ID del jugador con el socket que está vinculado al jugador. Nos permite acceder a los sockets por su ID de jugador en tiempo constante.players— este es un objeto que vincula la ID del jugador con el objeto code>Player
bullets — este es un array de objetos Bullet, que no tiene un orden definido.
lastUpdateTime — esta es una marca de tiempo del último momento en que se actualizó el juego. Pronto veremos cómo se utiliza.
shouldSendUpdate — esta es una variable auxiliar. También veremos su uso pronto.
Los métodos addPlayer(), removePlayer() y handleInput() no necesitan explicación, se usan en server.js. Si necesitas refrescar tu memoria, vuelve un poco atrás.
La última línea constructor() inicia el ciclo de actualización del juego (a una tasa de 60 actualizaciones/s):
game.js, parte 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Calcular el tiempo transcurrido
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Actualizar cada bala
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Destruir esta bala
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Actualizar cada jugador
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Aplicar colisiones, dar a los jugadores puntos por golpear balas
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
\/\/ Verificar si algún jugador está muerto
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
\/\/ ...
} El método update() contiene, probablemente, la parte más importante de la lógica del lado del servidor. Enumeremos en orden todo lo que hace:
- Calcula cuánto tiempo
dtha pasado desde la últimaupdate(). - Actualiza cada proyectil y lo destruye si es necesario. Veremos la implementación de esta funcionalidad más adelante. Por ahora, basta con saber que
bullet.update()devuelvetrue, si el proyectil debe ser destruido (ha salido de los límites de la arena). - Actualiza a cada jugador y crea un proyectil si es necesario. También veremos esta implementación más adelante —
player.update()puede devolver un objetoBullet. - Verifica las colisiones entre los proyectiles y los jugadores usando
applyCollisions(), que devuelve un array de proyectiles que impactaron a los jugadores. Para cada proyectil devuelto, aumentamos los puntos del jugador que lo disparó (usandoplayer.onDealtDamage()), y luego eliminamos el proyectil del arraybullets. - Notifica y destruye a todos los jugadores eliminados.
- Envía a todos los jugadores una actualización del juego cada segundo cuando se llama
update(). Esto nos ayuda a rastrear la variable auxiliar mencionada anteriormenteshouldSendUpdate. Así queupdate()se llama 60 veces/segundo, enviamos actualizaciones del juego 30 veces/segundo. Así, frecuencia de reloj del servidor es de 30 pulsos/segundo (hablamos de frecuencias de reloj en la primera parte).
¿Por qué enviar actualizaciones del juego solo cada dos veces? ? Para ahorrar ancho de banda. 30 actualizaciones de juego por segundo es mucho.
¿Por qué entonces simplemente no llamarlo
update()30 veces por segundo? Para mejorar la simulación del juego. Cuanto más a menudo se llamaupdate(), más precisa será la simulación del juego. Pero no hay que exagerar con la cantidad de llamadasupdate(), porque es una tarea computacionalmente costosa: 60 por segundo es más que suficiente.
El resto de la clase Game consiste en métodos auxiliares que se utilizan en update():
game.js, parte 3
class Game {
// ...
getLeaderboard() {
return Object.values(this.players)
.sort((p1, p2) => p2.score - p1.score)
.slice(0, 5)
.map(p => ({ username: p.username, score: Math.round(p.score) }));
}
createUpdate(player, leaderboard) {
const nearbyPlayers = Object.values(this.players).filter(
p => p !== player && p.distanceTo(player) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() es bastante simple: ordena a los jugadores por cantidad de puntos, toma los cinco mejores y devuelve para cada uno el nombre de usuario y la puntuación.
createUpdate() se utiliza en update() para crear actualizaciones del juego que se envían a los jugadores. Su tarea principal es llamar a los métodos serializeForUpdate(), implementados para las clases Player y Bullet. Tenga en cuenta que solo envía a cada jugador información sobre los jugadores cercanos y proyectiles: ¡no es necesario enviar información sobre objetos del juego que están lejos del jugador!
3. Objetos del juego en el servidor
En nuestro juego, los proyectiles y los jugadores son en realidad muy similares: son objetos de juego móviles y abstractos. Para aprovechar esta similitud entre jugadores y proyectiles, empecemos implementando una clase base Object:
object.js
class Object {
constructor(id, x, y, dir, speed) {
this.id = id;
this.x = x;
this.y = y;
this.direction = dir;
this.speed = speed;
}
update(dt) {
this.x += dt * this.speed * Math.sin(this.direction);
this.y -= dt * this.speed * Math.cos(this.direction);
}
distanceTo(object) {
const dx = this.x - object.x;
const dy = this.y - object.y;
return Math.sqrt(dx * dx + dy * dy);
}
setDirection(dir) {
this.direction = dir;
}
serializeForUpdate() {
return {
id: this.id,
x: this.x,
y: this.y,
};
}
} Aquí no hay nada complicado. Esta clase será un buen punto de partida para expandir. Veamos cómo es la clase Bullet utiliza Object:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Devuelve true si la bala debe ser destruida
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementación Bullet ¡muy breve! Hemos añadido a Object solo las siguientes extensiones:
- Uso del paquete para la generación aleatoria
idde balas. - Añadiendo el campo
parentID, para poder rastrear al jugador que creó esta bala. - Añadiendo un valor de retorno a
update(), que es igual atrue, si la bala se encuentra fuera del área (recuerden que hablamos de esto en la sección anterior?).
Pasemos a Player:
player.js
const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');
class Player extends ObjectClass {
constructor(id, username, x, y) {
super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
this.username = username;
this.hp = Constants.PLAYER_MAX_HP;
this.fireCooldown = 0;
this.score = 0;
}
// Devuelve una bala recién creada, o null.
update(dt) {
super.update(dt);
// Actualizar puntaje
this.score += dt * Constants.SCORE_PER_SECOND;
// Asegurarse de que el jugador se mantenga dentro de los límites
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Disparar una bala, si es necesario
this.fireCooldown -= dt;
if (this.fireCooldown <= 0) {
this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
return new Bullet(this.id, this.x, this.y, this.direction);
}
return null;
}
takeBulletDamage() {
this.hp -= Constants.BULLET_DAMAGE;
}
onDealtDamage() {
this.score += Constants.SCORE_BULLET_HIT;
}
serializeForUpdate() {
return {
...(super.serializeForUpdate()),
direction: this.direction,
hp: this.hp,
};
}
} Los jugadores son más complicados que las balas, por lo que esta clase debe almacenar algunos campos adicionales. Su método update() realiza más trabajo, en particular, devuelve una bala recién creada si no hay más fireCooldown ¿(recuerden que hablamos de esto en la sección anterior?). También extiende el método serializeForUpdate(), porque necesitamos incluir campos adicionales en la actualización del juego para el jugador.
Tener una clase base Object es un paso importante para evitar la repetición de código. Por ejemplo, sin la clase Object cada objeto del juego tendría que tener la misma implementación distanceTo(), y sincronizar la copia de estas implementaciones en varios archivos sería una pesadilla. Esto se vuelve especialmente importante para grandes proyectos, cuando el número de clases que se extienden aumenta. Object 4. Reconocimiento de colisiones
4. Распознавание коллизий
Lo único que nos queda es reconocer cuándo los proyectiles impactan en los jugadores. Recuerden este fragmento de código del método update() en la clase Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Aplicar colisiones, dar puntuación a los jugadores por impactar proyectiles
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// ...
}
} Necesitamos implementar el método applyCollisions(), que devolverá todos los proyectiles que han impactado en los jugadores. Afortunadamente, no es tan complicado, porque
- Todos los objetos en colisión son círculos, y esta es la forma más sencilla de implementar el reconocimiento de colisiones.
- Ya tenemos un método
distanceTo(), que implementamos en la clase en la sección anteriorObject.
Así es como se ve nuestra implementación del reconocimiento de colisiones:
collisions.js
const Constants = require('../shared/constants');
// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
const destroyedBullets = [];
for (let i = 0; i < bullets.length; i++) {
// Look for a player (who didn't create the bullet) to collide each bullet with.
// As soon as we find one, break out of the loop to prevent double counting a bullet.
for (let j = 0; j < players.length; j++) {
const bullet = bullets[i];
const player = players[j];
if (
bullet.parentID !== player.id &&
player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
) {
destroyedBullets.push(bullet);
player.takeBulletDamage();
break;
}
}
}
return destroyedBullets;
}Este simple reconocimiento de colisiones se basa en el hecho de que dos círculos colisionan si la distancia entre sus centros es menor que la suma de sus radios. Aquí hay un caso en el que la distancia entre los centros de dos círculos es exactamente igual a la suma de sus radios:

Aquí debemos prestarle atención a un par de aspectos:
- El proyectil no debe impactar en el jugador que lo creó. Esto se puede lograr comparando
bullet.parentIDconplayer.id. - El proyectil debe impactar solo una vez en el caso límite de colisión simultánea con varios jugadores. Resolveremos esta tarea utilizando el operador
break: tan pronto como encontremos un jugador que colisiona con el proyectil, detenemos la búsqueda y pasamos al siguiente proyectil.
Fin
¡Y eso es todo! Hemos cubierto todo lo que necesitas saber para crear un juego web del género .io. ¿Qué sigue? ¡Crea tu propio juego .io!
Todo el código del ejemplo tiene código abierto y está disponible en .
Fuente: habr.com
