Crea un juego de disparos piratas multijugador: en tu navegador
Spanish (Español) translation by Kelly Gianella (you can also view the original English article)
Crear juegos multijugador es un desafío por varias razones: pueden ser costosos de alojar, difíciles de diseñar y difíciles de implementar. Con este tutorial, espero abordar esa última barrera.
Esto está dirigido a desarrolladores que saben cómo hacer juegos y están familiarizados con JavaScript, pero nunca han hecho un juego multijugador en línea. Una vez que haya terminado, ¡debería sentirse cómodo implementando componentes de red básicos en cualquier juego y poder construir sobre él desde allí!
Esto es lo que vamos a construir:



¡Puedes probar una versión en vivo del juego aquí! W o Arriba para moverse hacia el ratón y hacer clic para disparar. (Si nadie más está en línea, intente abrir dos ventanas del navegador en la misma computadora, o una en su teléfono, para ver cómo funciona el multijugador). Si está interesado en ejecutar esto localmente, el código fuente completo también está disponible en GitHub.
Armé este juego usando los activos de arte de Kenney's Pirate Pack y el marco de juego Phaser. Usted estará asumiendo el papel de un programador de red para este tutorial. Su punto de partida será una versión para un solo jugador completamente funcional de este juego, y será su trabajo escribir el servidor en Node.js, utilizando Socket.io para la parte de red. Para mantener este tutorial manejable, me centraré en las partes multijugador y hojearé el Phaser y el Nodo.js conceptos específicos.
¡No hay necesidad de configurar nada localmente porque haremos este juego completamente en el navegador en Glitch.com! Glitch es una herramienta increíble para crear aplicaciones web, incluyendo un back-end, base de datos y todo. Es ideal para crear prototipos, enseñar y colaborar, y estoy emocionado de presentarte a lo largo de este tutorial.
Vamos a sumergirnos.
1. Configuración
He puesto el kit de inicio en Glitch.com.
Algunos consejos rápidos de interfaz: en cualquier momento, puede ver una vista previa en vivo de su aplicación haciendo clic en el botón Mostrar (arriba a la izquierda).



La barra lateral vertical de la izquierda contiene todos los archivos de la aplicación. Para editar esta aplicación, deberá "remezclarla". Esto creará una copia de ella en su cuenta (o la bifurcará en jerga git). Haga clic en el botón Remezclar este botón.



En este punto, editarás la aplicación bajo una cuenta anónima. Puede iniciar sesión (arriba a la derecha) para guardar su trabajo.
Ahora, antes de continuar, es importante familiarizarse con el código del juego en el que está tratando de agregar multijugador. Echa un vistazo al índice.html. Hay tres funciones importantes a tener en cuenta: precargar (línea 99), crear (línea 115) y GameLoop (línea 142), además del objeto del jugador (línea 35).
Si prefieres aprender haciendo, prueba estos desafíos para asegurarte de obtener la esencia de cómo funciona el juego:
- Haz que el mundo sea más grande (línea 29): ten en cuenta que hay un tamaño de mundo separado, para el mundo del juego, y un tamaño de ventana, para el lienzo real de la página.
- Haga que la barra espaciadora también empuje hacia adelante (línea 53).
- Cambia el tipo de barco del jugador (línea 129).
- Haz que las balas se muevan más lentamente (línea 155).
Instalación de Socket.io
Socket.io es una biblioteca para administrar la comunicación en tiempo real en el navegador utilizando WebSockets (en lugar de usar un protocolo como UDP si está construyendo un juego de escritorio multijugador). También tiene respaldos para asegurarse de que todavía funciona incluso cuando WebSockets no son compatibles. Por lo tanto, se encarga de los protocolos de mensajería y expone un buen sistema de mensajes basado en eventos para que lo use.
Lo primero que debemos hacer es instalar el módulo Socket.io. En Glitch, puede hacer esto yendo al archivo package.json y escribiendo el módulo que desee en las dependencias, o haciendo clic en Agregar paquete y escribiendo "socket.io".



Este sería un buen momento para señalar los registros del servidor. Haga clic en el botón Registros a la izquierda para que aparecen el registro del servidor. Debería verlo instalando Socket.io junto con todas sus dependencias. Aquí es donde iría a ver cualquier error o salida del código del servidor.



Ahora a ir servidor.js. Aquí es donde vive el código de su servidor. En este momento, solo tiene un poco de repetición básica para servir nuestro HTML. Agregue esta línea en la parte superior para incluir Socket.io:
1 |
var io = require('socket.io')(http); // Make sure to put this after http has been defined |
Ahora también necesitamos incluir Socket.io en el cliente, así que vuelva al índice.html y agregue esto en la parte superior dentro de su <head> etiqueta:</head>
1 |
<!-- Load the Socket.io networking library -->
|
2 |
<script src="/socket.io/socket.io.js"></script> |
Nota: Socket.io controla automáticamente el servicio de la biblioteca cliente en esa ruta, por lo que esta línea funciona aunque no vea el directorio /socket.io/ en sus carpetas.
¡Ahora Socket.io está incluido y listo para comenzar!
2. Detección y desove de jugadores
Nuestro primer paso real será aceptar conexiones en el servidor y generar nuevos jugadores en el cliente.
Aceptación de conexiones en el servidor
En la parte inferior del servidor.js, agregue este código:
1 |
// Tell Socket.io to start accepting connections
|
2 |
io.on('connection', function(socket){ |
3 |
console.log("New client has connected with id:",socket.id); |
4 |
})
|
Esto indica a Socket.io que escuche cualquier evento de conexión, que se activa automáticamente cuando un cliente se conecta. Creará un nuevo objeto de socket para cada cliente, donde socket.id es un identificador único para ese cliente.
Solo para asegurarse de que esto está funcionando, regrese a su cliente (índice.html) y agregue esta línea en algún lugar de la función de creación:
1 |
var socket = io(); // This triggers the 'connection' event on the server |
Si inicias el juego y luego miras el registro de tu servidor (haz clic en el botón Registros), ¡deberías verlo registrar ese evento de conexión!
Ahora, cuando un nuevo jugador se conecta, esperamos que nos envíe información sobre su estado. En este caso, necesitamos saber al menos la x, y y el ángulo para generarlos adecuadamente en la ubicación correcta.
La conexión del evento fue un evento incorporado que nos Socket.io incendios. Podemos escuchar cualquier evento definido a medida que queramos. Voy a llamar al mío nuevo jugador, y espero que el cliente lo envíe tan pronto como se conecte con información sobre su ubicación. Esto se vería así:
1 |
// Tell Socket.io to start accepting connections
|
2 |
io.on('connection', function(socket){ |
3 |
console.log("New client has connected with id:",socket.id); |
4 |
socket.on('new-player',function(state_data){ // Listen for new-player event on this client |
5 |
console.log("New player has state:",state_data); |
6 |
})
|
7 |
})
|
Todavía no verá nada en el registro del servidor si ejecuta esto. Esto se debe a que aún no le hemos dicho al cliente que emita este evento de nuevo jugador. Pero vamos a fingir que se ha solucionado por un momento, y seguir en el servidor. ¿Qué debería suceder después de haber recibido la ubicación del nuevo jugador que se unió?
Podríamos enviar un mensaje a todos los demás jugadores conectados para hacerles saber que un nuevo jugador se ha unido. Socket.io proporciona una función útil para hacer esto:
1 |
socket.broadcast.emit('create-player',state_data); |
Llamar a socket.emit simplemente enviaría el mensaje a ese cliente. Llamar a socket.broadcast.emit lo envía a todos los clientes conectados al servidor, excepto a un socket en el que se llamó.
El uso de io.emit enviaría el mensaje a todos los clientes conectados al servidor sin excepciones. No queremos hacer eso con nuestra configuración actual porque si recibes un mensaje del servidor pidiéndote que crees tu propia nave, habría un sprite duplicado, ya que ya creamos la nave del propio jugador cuando comienza el juego. Aquí hay una práctica hoja de trucos para los diferentes tipos de funciones de mensajería que usaremos en este tutorial.
El código del servidor ahora debería verse así:
1 |
// Tell Socket.io to start accepting connections
|
2 |
io.on('connection', function(socket){ |
3 |
console.log("New client has connected with id:",socket.id); |
4 |
socket.on('new-player',function(state_data){ // Listen for new-player event on this client |
5 |
console.log("New player has state:",state_data); |
6 |
socket.broadcast.emit('create-player',state_data); |
7 |
})
|
8 |
})
|
Entonces, cada vez que un jugador se conecta, esperamos que nos envíe un mensaje con sus datos de ubicación, y enviaremos esos datos a todos los demás jugadores para que puedan generar ese sprite.
Desove en el cliente
Ahora, para completar este ciclo, sabemos que necesitamos hacer dos cosas en el cliente:
- Emitir un mensaje con nuestros datos de ubicación una vez que nos conectamos.
- Escucha los eventos de create-player y genera un jugador en esa ubicación.
Para la primera tarea, después de crear el reproductor en nuestra función de creación (alrededor de la línea 135), podemos emitir un mensaje que contenga los datos de ubicación que queremos enviar de esta manera:
1 |
socket.emit('new-player',{x:player.sprite.x,y:player.sprite.y,angle:player.sprite.rotation}) |
No tiene que preocuparse por serializar los datos que envía. Puede pasar cualquier tipo de objeto y Socket.io lo manejará por usted.
Antes de seguir adelante, pruebe que esto funciona. Debería ver un mensaje en los registros del servidor que dice algo como:
1 |
New player has state: { x: 728.8180247836519, y: 261.9979387913289, angle: 0 } |
¡Sabemos que nuestro servidor está recibiendo nuestro anuncio de que un nuevo jugador se ha conectado, junto con la obtención correcta de sus datos de ubicación!
A continuación, queremos escuchar una solicitud para crear un nuevo reproductor. Podemos colocar este código justo después de nuestra emisión, y debería verse algo como:
1 |
socket.on('create-player',function(state){ |
2 |
// CreateShip is a function I've already defined to create and return a sprite
|
3 |
CreateShip(1,state.x,state.y,state.angle) |
4 |
})
|
Ahora pruébalo. Abre dos ventanas de tu juego y comprueba si funciona.
Lo que debe ver es que después de abrir dos clientes, el primer cliente tendrá dos barcos desovados, mientras que el segundo solo verá uno.
Desafío: ¿Puedes entender por qué está sucediendo esto? ¿O cómo podrías solucionarlo? Pase por la lógica cliente/servidor que hemos escrito e intente depurarla.
¡Espero que hayas tenido la oportunidad de pensarlo por ti mismo! Lo que está sucediendo es que cuando el primer jugador se conectó, el servidor envió un evento de creación de jugador a todos los demás jugadores, pero no había otro jugador para recibirlo. Una vez que el segundo jugador se conecta, el servidor vuelve a enviar su transmisión, y el jugador 1 la recibe y genera correctamente el sprite, mientras que el jugador 2 ha perdido la transmisión de conexión inicial del jugador 1.
Así que el problema está sucediendo porque el jugador 2 se une tarde en el juego y necesita saber el estado del juego. Necesitamos decirle a cualquier nuevo jugador que esté conectando lo que los jugadores ya existen (o lo que ya ha sucedido en el mundo) para que puedan ponerse al día. Antes de lanzar a arreglar esto, tengo una breve advertencia.
Una advertencia sobre la sincronización del estado del juego
Hay dos enfoques para mantener sincronizado el juego de cada jugador. La primera es enviar solo la cantidad mínima de información sobre lo que se ha cambiado a través de la red. Entonces, cada vez que un nuevo jugador se conecta, enviarías solo la información de ese nuevo jugador a todos los demás jugadores (y le enviarías a ese nuevo jugador una lista de todos los demás jugadores del mundo), y cuando se desconectaran, les dirías a todos los demás jugadores que este cliente individual se ha desconectado.
El segundo enfoque es enviar todo el estado del juego. En ese caso, solo enviaría una lista completa de todos los jugadores a todos cada vez que se produzca una conexión o desconexión.
El primero es mejor en el sentido de que minimiza la información enviada a través de la red, pero puede ser muy complicado y tiene el riesgo de que los jugadores no estén sincronizados. El segundo garantiza que los jugadores siempre estarán sincronizados, pero implica enviar más datos con cada mensaje.
En nuestro caso, en lugar de intentar enviar mensajes cuando un nuevo jugador se ha conectado para crearlos, cuando se han desconectado para eliminarlos y cuando se han movido para actualizar su posición, podemos consolidar todo eso en un evento de actualización. Este evento de actualización siempre enviará las posiciones de cada jugador disponible a todos los clientes. Eso es todo lo que el servidor tiene que hacer. El cliente es entonces responsable de mantener su mundo actualizado con ese estado que recibe.
Para implementar esto, haré lo siguiente:
- Mantenga un diccionario de jugadores, con la clave siendo su ID y el valor siendo sus datos de ubicación.
- Agregue el reproductor a este diccionario cuando se conecte y envíe un evento de actualización.
- Elimine el reproductor de este diccionario cuando se desconecte y envíe un evento de actualización.
Puede intentar implementar esto por su cuenta, ya que estos pasos son bastante simples (la hoja de trucos puede ser útil). Así es como podría verse la implementación completa:
1 |
// Tell Socket.io to start accepting connections
|
2 |
// 1 - Keep a dictionary of all the players as key/value
|
3 |
var players = {}; |
4 |
io.on('connection', function(socket){ |
5 |
console.log("New client has connected with id:",socket.id); |
6 |
socket.on('new-player',function(state_data){ // Listen for new-player event on this client |
7 |
console.log("New player has state:",state_data); |
8 |
// 2 - Add the new player to the dict
|
9 |
players[socket.id] = state_data; |
10 |
// Send an update event
|
11 |
io.emit('update-players',players); |
12 |
})
|
13 |
socket.on('disconnect',function(){ |
14 |
// 3- Delete from dict on disconnect
|
15 |
delete players[socket.id]; |
16 |
// Send an update event
|
17 |
})
|
18 |
})
|
El lado del cliente es un poco más complicado. Por un lado, solo tenemos que preocuparnos por el evento de jugadores de actualización ahora, pero por otro lado, tenemos que tener en cuenta la creación de más naves si el servidor nos envía más naves de las que conocemos, o destruir si tenemos demasiadas.
Así es como manejé este evento en el cliente:
1 |
// Listen for other players connecting
|
2 |
// NOTE: You must have other_players = {} defined somewhere
|
3 |
socket.on('update-players',function(players_data){ |
4 |
var players_found = {}; |
5 |
// Loop over all the player data received
|
6 |
for(var id in players_data){ |
7 |
// If the player hasn't been created yet
|
8 |
if(other_players[id] == undefined && id != socket.id){ // Make sure you don't create yourself |
9 |
var data = players_data[id]; |
10 |
var p = CreateShip(1,data.x,data.y,data.angle); |
11 |
other_players[id] = p; |
12 |
console.log("Created new player at (" + data.x + ", " + data.y + ")"); |
13 |
}
|
14 |
players_found[id] = true; |
15 |
|
16 |
// Update positions of other players
|
17 |
if(id != socket.id){ |
18 |
other_players[id].x = players_data[id].x; // Update target, not actual position, so we can interpolate |
19 |
other_players[id].y = players_data[id].y; |
20 |
other_players[id].rotation = players_data[id].angle; |
21 |
}
|
22 |
|
23 |
|
24 |
}
|
25 |
// Check if a player is missing and delete them
|
26 |
for(var id in other_players){ |
27 |
if(!players_found[id]){ |
28 |
other_players[id].destroy(); |
29 |
delete other_players[id]; |
30 |
}
|
31 |
}
|
32 |
|
33 |
})
|
Estoy haciendo un seguimiento de los barcos en el cliente en un diccionario llamado other_players que simplemente he definido en la parte superior de mi script (no se muestra aquí). Dado que el servidor envía los datos del jugador a todos los jugadores, tengo que agregar una verificación para que un cliente no esté creando un sprite adicional para sí mismo. (Si tiene problemas para estructurar esto, aquí está el código completo que debería estar en índice.html en este punto).
Ahora pruebe esto. ¡Debería poder crear y cerrar múltiples clientes y ver el número correcto de barcos que desovan en las posiciones correctas!
3. Sincronización de las posiciones del barco
Aquí es donde llegamos a la parte realmente divertida. Queremos sincronizar las posiciones de los barcos en todos los clientes ahora. Aquí es donde realmente se nota la simplicidad de la estructura que hemos construido hasta ahora. Ya tenemos un evento de actualización que puede sincronizar las ubicaciones de todos. Todo lo que tenemos que hacer ahora es:
- Haz que el cliente emita cada vez que se haya mudado con su nueva ubicación.
- Haga que el servidor escuche ese mensaje de movimiento y actualice la entrada de ese jugador en el diccionario de reproductores.
- Emitir un evento de actualización a todos los clientes.
¡Y eso debería ser todo! Ahora es tu turno de intentar implementar esto por tu cuenta.
Si se queda completamente atascado y necesita una pista, puede ver el proyecto final completado como referencia.
Nota sobre la minimización de datos de red
La forma más sencilla de implementar esto es actualizar a todos los jugadores con las nuevas ubicaciones cada vez que recibas un mensaje de movimiento de cualquier jugador. Esto es genial porque los jugadores siempre recibirán la información más reciente tan pronto como esté disponible, pero el número de mensajes enviados a través de la red podría crecer fácilmente a cientos por fotograma. Imagínese si tuviera 10 jugadores, cada uno enviando un mensaje de movimiento cada fotograma, que el servidor luego tiene que transmitir a los 10 jugadores. ¡Eso ya son 100 mensajes por fotograma!
Una mejor manera de hacerlo podría ser esperar hasta que el servidor haya recibido todos los mensajes de los jugadores antes de enviar una gran actualización que contenga toda la información a todos los jugadores. De esa manera, aplastas el número de mensajes que estás enviando a solo el número de jugadores que tienes en el juego (en lugar del cuadrado de ese número). El problema con eso, sin embargo, es que todos experimentarán tanto retraso como el jugador con la conexión más lenta en el juego.
Otra forma de hacerlo es simplemente hacer que el servidor envíe actualizaciones a un ritmo constante, independientemente de cuántos mensajes haya recibido de los jugadores hasta ahora. Tener la actualización del servidor alrededor de 30 veces por segundo parece un estándar común.
Independientemente de cómo decidas estructurar tu servidor, ten en cuenta cuántos mensajes estás enviando a cada fotograma desde el principio a medida que desarrollas tu juego.
4. Sincronización de viñetas
¡Ya casi llegamos! La última gran pieza será sincronizar las balas a través de la red. Podríamos hacerlo de la misma manera que sincronizamos a los jugadores:
- Cada cliente envía las posiciones de todas sus balas a cada fotograma.
- El servidor transmite eso a cada jugador.
Pero hay un problema.
Protección contra trampas
Si transmites lo que el cliente te envía como la verdadera posición de las balas, entonces un jugador podría hacer trampa modificando a su cliente para que te envíe datos falsos, como balas que se teletransportan a donde sea que estén las otras naves. Puede probar esto fácilmente usted mismo descargando la página web, modificando el JavaScript y ejecutándolo nuevamente. Esto no es solo un problema para los juegos hechos para el navegador. En general, nunca se puede confiar realmente en los datos procedentes del cliente.
Para mitigar esto, probaremos un esquema diferente:
- El cliente emite cada vez que ha disparado una bala con la ubicación y la dirección.
- El servidor simula el movimiento de las balas.
- El servidor actualiza cada cliente con la ubicación de todas las viñetas.
- Los clientes representan las viñetas en las ubicaciones recibidas por el servidor.
De esta manera, el cliente está a cargo de dónde se genera la bala, pero no qué tan rápido se mueve o a dónde va después de eso. El cliente puede cambiar la ubicación de las viñetas en su propia vista, pero no puede alterar lo que ven otros clientes.
Ahora, para implementar esto, agregaré una emisión cuando dispares. Ya no crearé el sprite real tampoco, ya que su existencia y ubicación ahora están completamente determinadas por el servidor. Nuestro nuevo código de disparo de bala en index.html ahora debería verse así:
1 |
// Shoot bullet
|
2 |
if(game.input.activePointer.leftButton.isDown && !this.shot){ |
3 |
var speed_x = Math.cos(this.sprite.rotation + Math.PI/2) * 20; |
4 |
var speed_y = Math.sin(this.sprite.rotation + Math.PI/2) * 20; |
5 |
/* The server is now simulating the bullets, clients are just rendering bullet locations, so no need to do this anymore
|
6 |
var bullet = {};
|
7 |
bullet.speed_x = speed_x;
|
8 |
bullet.speed_y = speed_y;
|
9 |
bullet.sprite = game.add.sprite(this.sprite.x + bullet.speed_x,this.sprite.y + bullet.speed_y,'bullet');
|
10 |
bullet_array.push(bullet);
|
11 |
*/
|
12 |
this.shot = true; |
13 |
// Tell the server we shot a bullet
|
14 |
socket.emit('shoot-bullet',{x:this.sprite.x,y:this.sprite.y,angle:this.sprite.rotation,speed_x:speed_x,speed_y:speed_y}) |
15 |
}
|
Ahora también puede comentar toda esta sección que actualiza las viñetas en el cliente:
1 |
/* We're updating the bullets on the server, so we don't need to do this on the client anymore
|
2 |
// Update bullets
|
3 |
for(var i=0;i<bullet_array.length;i++){
|
4 |
var bullet = bullet_array[i];
|
5 |
bullet.sprite.x += bullet.speed_x;
|
6 |
bullet.sprite.y += bullet.speed_y;
|
7 |
// Remove if it goes too far off screen
|
8 |
if(bullet.sprite.x < -10 || bullet.sprite.x > WORLD_SIZE.w || bullet.sprite.y < -10 || bullet.sprite.y > WORLD_SIZE.h){
|
9 |
bullet.sprite.destroy();
|
10 |
bullet_array.splice(i,1);
|
11 |
i--;
|
12 |
}
|
13 |
}
|
14 |
*/
|
Finalmente, necesitamos que el cliente escuche las actualizaciones de viñetas. He optado por manejar esto de la misma manera que lo hago con los jugadores, donde el servidor simplemente envía una matriz de todas las ubicaciones de viñetas en un evento llamado bullets-update, y el cliente creará o destruirá balas para mantenerlas sincronizadas. Así es como se ve:
1 |
// Listen for bullet update events
|
2 |
socket.on('bullets-update',function(server_bullet_array){ |
3 |
// If there's not enough bullets on the client, create them
|
4 |
for(var i=0;i<server_bullet_array.length;i++){ |
5 |
if(bullet_array[i] == undefined){ |
6 |
bullet_array[i] = game.add.sprite(server_bullet_array[i].x,server_bullet_array[i].y,'bullet'); |
7 |
} else { |
8 |
//Otherwise, just update it!
|
9 |
bullet_array[i].x = server_bullet_array[i].x; |
10 |
bullet_array[i].y = server_bullet_array[i].y; |
11 |
}
|
12 |
}
|
13 |
// Otherwise if there's too many, delete the extra
|
14 |
for(var i=server_bullet_array.length;i<bullet_array.length;i++){ |
15 |
bullet_array[i].destroy(); |
16 |
bullet_array.splice(i,1); |
17 |
i--; |
18 |
}
|
19 |
|
20 |
})
|
Eso debería ser todo en el cliente. Supongo que sabes dónde poner estos fragmentos y cómo armar todo en este momento, pero si te encuentras con algún problema, recuerda que siempre puedes echar un vistazo al resultado final como referencia.
Ahora, en el servidor.js, necesitamos realizar un seguimiento y simular las balas. Primero, creamos una matriz para realizar un seguimiento de las balas, de la misma manera que tenemos una para los jugadores:
1 |
var bullet_array = []; // Keeps track of all the bullets to update them on the server |
A continuación, escuchamos nuestro evento shoot bullet:
1 |
// Listen for shoot-bullet events and add it to our bullet array
|
2 |
socket.on('shoot-bullet',function(data){ |
3 |
if(players[socket.id] == undefined) return; |
4 |
var new_bullet = data; |
5 |
data.owner_id = socket.id; // Attach id of the player to the bullet |
6 |
bullet_array.push(new_bullet); |
7 |
});
|
Ahora simulamos las balas 60 veces por segundo:
1 |
// Update the bullets 60 times per frame and send updates
|
2 |
function ServerGameLoop(){ |
3 |
for(var i=0;i<bullet_array.length;i++){ |
4 |
var bullet = bullet_array[i]; |
5 |
bullet.x += bullet.speed_x; |
6 |
bullet.y += bullet.speed_y; |
7 |
|
8 |
// Remove if it goes too far off screen
|
9 |
if(bullet.x < -10 || bullet.x > 1000 || bullet.y < -10 || bullet.y > 1000){ |
10 |
bullet_array.splice(i,1); |
11 |
i--; |
12 |
}
|
13 |
|
14 |
}
|
15 |
|
16 |
}
|
17 |
|
18 |
setInterval(ServerGameLoop, 16); |
Y el último paso es enviar el evento de actualización a algún lugar dentro de esa función (pero definitivamente fuera del bucle for):
1 |
// Tell everyone where all the bullets are by sending the whole array
|
2 |
io.emit("bullets-update",bullet_array); |
¡Ahora puedes probarlo! Si todo salió bien, debería ver las viñetas sincronizándose correctamente entre los clientes. El hecho de que hayamos hecho esto en el servidor es más trabajo, pero también nos da mucho más control. Por ejemplo, cuando recibimos un evento de bala de disparo, podemos comprobar que la velocidad de la bala está dentro de un cierto rango, de lo contrario sabemos que este jugador está haciendo trampa.
5. Colisión de balas
Esta es la última mecánica central que implementaremos. Esperemos que a estas alturas ya se haya acostumbrado al procedimiento de planificación de nuestra implementación, terminando la implementación del cliente completamente primero antes de pasar al servidor (o viceversa). Esta es una forma mucho menos propensa a errores que cambiar de un lado a otro a medida que lo implementa.
Comprobar si hay colisión es una mecánica de juego crucial, por lo que nos gustaría que fuera a prueba de trampas. Lo implementaremos en el servidor de la misma manera que lo hicimos para las viñetas. Necesitaremos:
- Compruebe si una viñeta está lo suficientemente cerca de cualquier jugador en el servidor.
- Emita un evento a todos los clientes cada vez que un determinado jugador sea golpeado.
- Haga que el cliente escuche el evento de éxito y haga que la nave parpadee cuando se golpee.
Puede intentar hacer esto completamente por su cuenta. Para hacer que el reproductor parpadee cuando se golpea, simplemente configure su alfa en 0:
1 |
player.sprite.alpha = 0; |
Y volverá a la alfa completa nuevamente (esto se hace en la actualización del jugador). Para los otros jugadores, harías algo similar, pero tendrás que encargarnos de devolver su alfa a uno con algo como esto en la función de actualización:
1 |
for(var id in other_players){ |
2 |
if(other_players[id].alpha < 1){ |
3 |
other_players[id].alpha += (1 - other_players[id].alpha) * 0.16; |
4 |
} else { |
5 |
other_players[id].alpha = 1; |
6 |
}
|
7 |
}
|
La única parte difícil que podrías tener que manejar es asegurarte de que la propia bala de un jugador no pueda golpearlo (de lo contrario, siempre podrías ser golpeado con tu propia bala cada vez que dispares).
Tenga en cuenta que en este esquema, incluso si un cliente intenta hacer trampa y se niega a reconocer el mensaje de éxito que el servidor les envía, eso solo cambiará lo que ven en su propia pantalla. Todos los demás jugadores seguirán siendo golpeados.
6. Movimiento más suave
Si has seguido todos los pasos hasta este punto, me gustaría felicitarte. ¡Acabas de hacer un juego multijugador que funcione! ¡Adelante, envíalo a un amigo y mira la magia del multijugador en línea uniendo a los jugadores!
El juego es completamente funcional, pero nuestro trabajo no se detiene allí. Hay un par de problemas que podrían afectar la experiencia del jugador que debemos abordar:
- El movimiento de otros jugadores se verá realmente entrecortado a menos que todos tengan una conexión rápida.
- Las balas pueden parecer insensibles, ya que la bala no se dispara de inmediato. Espera un mensaje del servidor antes de que aparezca en la pantalla del cliente.
Podemos arreglar el primero interpolando nuestros datos de posición para los barcos en el cliente. Entonces, incluso si no estamos recibiendo actualizaciones lo suficientemente rápido, podemos mover suavemente el barco hacia donde debería estar en lugar de teletransportarlo allí.
Las balas requerirán un poco más de sofisticación. Queremos que el servidor administre las balas, porque de esa manera es a prueba de trampas, pero también queremos tener la retroalimentación inmediata de disparar una bala y verla disparar. La mejor manera es un enfoque híbrido. Tanto el servidor como el cliente pueden simular las viñetas, y el servidor sigue enviando actualizaciones de posición de viñetas. Si no están sincronizados, asuma que el servidor es correcto y anule la posición de viñeta del cliente.
La implementación del sistema de viñetas que describí anteriormente está fuera del alcance de este tutorial, pero es bueno saber que este método existe.
Hacer una simple interpolación para las posiciones de los barcos es muy fácil. En lugar de establecer la posición directamente en el evento de actualización donde recibimos por primera vez los nuevos datos de posición, simplemente guardamos la posición objetivo:
1 |
// Update positions of other players
|
2 |
if(id != socket.id){ |
3 |
other_players[id].target_x = players_data[id].x; // Update target, not actual position, so we can interpolate |
4 |
other_players[id].target_y = players_data[id].y; |
5 |
other_players[id].target_rotation = players_data[id].angle; |
6 |
}
|
Luego, dentro de nuestra función de actualización (todavía en el cliente), hacemos un bucle sobre todos los demás jugadores y los empujamos hacia este objetivo:
1 |
// Interpolate all players to where they should be
|
2 |
for(var id in other_players){ |
3 |
var p = other_players[id]; |
4 |
if(p.target_x != undefined){ |
5 |
p.x += (p.target_x - p.x) * 0.16; |
6 |
p.y += (p.target_y - p.y) * 0.16; |
7 |
// Interpolate angle while avoiding the positive/negative issue
|
8 |
var angle = p.target_rotation; |
9 |
var dir = (angle - p.rotation) / (Math.PI * 2); |
10 |
dir -= Math.round(dir); |
11 |
dir = dir * Math.PI * 2; |
12 |
p.rotation += dir * 0.16; |
13 |
}
|
14 |
}
|
De esta manera, puede hacer que su servidor le envíe actualizaciones 30 veces por segundo, pero aún así juegue el juego a 60 fps y se verá sin problemas.
Conclusión
¡Ufff! Acabamos de cubrir muchas cosas. Solo para recapitular, hemos visto cómo enviar mensajes entre un cliente y un servidor, y cómo sincronizar el estado del juego haciendo que el servidor lo transmita a todos los jugadores. Esta es la forma más sencilla de crear una experiencia multijugador en línea.
También vimos cómo puede asegurar su juego contra el engaño simulando las partes importantes en el servidor e informando a los clientes de los resultados. Cuanto menos confíes en tu cliente, más seguro será el juego.
Finalmente, vimos cómo superar el retraso interpolando en el cliente. La compensación de retraso es un tema amplio y es de crucial importancia (algunos juegos simplemente se vuelven injugables con un retraso lo suficientemente alto). Interpolar mientras se espera la próxima actualización del servidor es solo una forma de mitigarla. Otra forma es tratar de predecir los próximos fotogramas con anticipación y corregir una vez que reciba los datos reales del servidor, pero, por supuesto, esto puede ser muy complicado.
Una forma completamente diferente de mitigar el impacto del retraso es simplemente diseñar a su alrededor. El beneficio de que las naves giren lentamente para moverse actúa como una mecánica de movimiento única y también como una forma de evitar cambios repentinos en el movimiento. Entonces, incluso con una conexión lenta, aún no arruinaría la experiencia. Tener en cuenta el retraso al diseñar los elementos centrales de su juego de esta manera puede marcar una gran diferencia. A veces las mejores soluciones no son técnicas en absoluto.
Una característica final de Glitch que puede resultarle útil es que puede descargar o exportar su proyecto yendo a la configuración avanzada en la parte superior izquierda:



Si haces algo genial, ¡compártelo en los comentarios a continuación! O si tiene alguna pregunta o aclaración sobre algo, estaré más que feliz de ayudarlo.



