Cómo implementar y utilizar una cola de mensajes en su juego
() translation by (you can also view the original English article)
Un juego generalmente se hace de varias entidades diferentes que interactúan entre sí. Esas interacciones tienden a ser muy dinámicas y profundamente conectadas con el juego. Este
tutorial cubre el concepto y la implementación de un sistema de cola de
mensajes que puede unificar las interacciones de las entidades,
haciendo que su código sea manejable y fácil de mantener a medida que
crece en complejidad.
Introducción
Una bomba puede interactuar con un personaje explotando y causando daño, un kit médico puede sanar a una entidad, una llave puede abrir una puerta, y así sucesivamente. Las interacciones en un juego son interminables, pero ¿cómo podemos mantener el código del juego manejable mientras somos capaces de manejar todas esas interacciones? ¿Cómo podemos garantizar que el código puede cambiar y seguir funcionando cuando surgen nuevas e inesperadas interacciones?



A medida que se agregan interacciones (especialmente las inesperadas), su código se verá cada vez más desordenado. Una implementación ingenua le llevará rápidamente a hacer preguntas como:
"Esta
es la entidad A, por lo que debería llamar al método damage()
en él,
¿verdad? o ¿es damageByItem()
? Tal vez este método damageByWeapon()
es
el correcto?"
Imagínese que el caos desordenado se extiende a todas sus entidades de juego, porque todos interactúan entre sí de maneras diferentes y peculiares. Por suerte, hay una manera mejor, más simple y más manejable de hacerlo.
Cola de mensajes
Introduzca la cola de mensajes. La idea básica detrás de este concepto es implementar todas las interacciones del juego como un sistema de comunicación (que todavía está en uso hoy en día): el intercambio de mensajes. Las personas se han comunicado a través de mensajes (cartas) durante siglos porque es un sistema eficaz y simple.
En nuestros servicios reales, el contenido de cada mensaje puede diferir, pero la forma en que se envían y reciben físicamente sigue siendo la misma. Un remitente pone la información en un sobre y la dirige a un destino. El destino puede responder (o no) siguiendo el mismo mecanismo, simplemente cambiando los campos "desde / hacia" en el sobre.



Al aplicar esa idea a tu juego, todas las interacciones entre entidades pueden ser vistas como mensajes. Si una entidad de juego desea interactuar con otra (o con un grupo de ellas), todo lo que tiene que hacer es enviar un mensaje. El destino se ocupará o reaccionará al mensaje basándose en su contenido y en quién es el remitente.
En este enfoque, la comunicación entre entidades de juego se vuelve unificada. Todas las entidades pueden enviar y recibir mensajes. No importa cuán compleja o peculiar sea la interacción o mensaje, el canal de comunicación siempre permanece igual.
A lo largo de las siguientes secciones, describiré cómo puedes implementar realmente este enfoque de cola de mensajes en tu juego.
Diseño de una Envoltura (Mensaje)
Comencemos diseñando la envoltura, que es el elemento más básico en el sistema de cola de mensajes.
Una envoltura puede describirse como en la siguiente figura:



Los dos primeros campos (sender
y destination
) son referencias a la entidad que creó ya la entidad que recibirá este mensaje, respectivamente. Usando esos campos, tanto el remitente como el receptor pueden decir a dónde va el mensaje y de dónde vino.
Los otros dos campos (type
y data
) trabajan juntos para asegurar que el mensaje se maneja correctamente. El campo type
describe de qué se trata este mensaje; Por ejemplo, si el tipo es "damage"
, el receptor manejará este mensaje como un orden para disminuir sus puntos de salud; Si el tipo es "pursue"
, el receptor lo tomará como una instrucción para perseguir algo—y así sucesivamente.
El campo data
se conecta directamente al campo type
. Utilizando
los ejemplos anteriores, si el tipo de mensaje es "damage"
, entonces el
campo data
contendrá un número—digamos, 10
—que describe la cantidad
de daño que el receptor debe aplicar a sus puntos de salud. Si el tipo de mensaje es "pursue"
, data
contendrá un objeto que describa el objetivo que se debe perseguir.
El campo data
puede contener cualquier información, lo que hace que el sobre sea un medio versátil de comunicación. Cualquier cosa se puede colocar en ese campo: números enteros, flotadores, cadenas, e incluso otros objetos. La regla es que el receptor debe saber cuál está en el campo data
basado en cuál está en el campo type
.
Toda esa teoría se puede traducir en una clase muy simple llamada Message
. Contiene cuatro propiedades, una para cada campo:
1 |
Message = function (to, from, type, data) { |
2 |
// Properties
|
3 |
this.to = to; // a reference to the entity that will receive this message |
4 |
this.from = from; // a reference to the entity that sent this message |
5 |
this.type = type; // the type of this message |
6 |
this.data = data; // the content/data of this message |
7 |
};
|
Como
ejemplo de esto en uso, si una entidad A
quiere enviar un mensaje de
"damage"
a la entidad B
, todo lo que tiene que hacer es instanciar un
objeto de la clase Message
, establecer la propiedad to
a B
, establecer la
propiedad from
(Entidad A
), establecer type
a "damage"
y, finalmente, establecer data
a algún número (10
, por ejemplo):
1 |
// Instantiate the two entities
|
2 |
var entityA = new Entity(); |
3 |
var entityB = new Entity(); |
4 |
|
5 |
// Create a message to entityB, from entityA,
|
6 |
// with type "damage" and data/value 10.
|
7 |
var msg = new Message(); |
8 |
|
9 |
msg.to = entityB; |
10 |
msg.from = entityA; |
11 |
msg.type = "damage"; |
12 |
msg.data = 10; |
13 |
|
14 |
// You can also instantiate the message directly
|
15 |
// passing the information it requires, like this:
|
16 |
var msg = new Message(entityB, entityA, "damage", 10); |
Ahora que tenemos una manera de crear mensajes, es hora de pensar en la clase que los almacenará y entregará.
Implementación de una cola
La clase responsable de almacenar y entregar los mensajes se llamará MessageQueue
. Funcionará
como una oficina de correos: todos los mensajes se entregan a esta
clase, lo que garantiza que serán enviados a su destino.
Por ahora, la clase MessageQueue
tendrá una estructura muy simple:
1 |
/**
|
2 |
* This class is responsible for receiving messages and
|
3 |
* dispatching them to the destination.
|
4 |
*/
|
5 |
MessageQueue = function () { |
6 |
this.messages = []; // list of messages to be dispatched |
7 |
};
|
8 |
|
9 |
// Add a new message to the queue. The message must be an
|
10 |
// instance of the class Message.
|
11 |
MessageQueue.prototype.add = function(message) { |
12 |
this.messages.push(message); |
13 |
};
|
La propiedad messages
es un array. Almacena todos los mensajes que están a punto de ser entregados por MessageQueue
. El método add()
recibe un objeto de la clase Message
como un parámetro, y agrega ese objeto a la lista de mensajes.
He
aquí cómo nuestro ejemplo anterior de la entidad A
de la entidad de
mensajería B
sobre el daño funcionaría usando la clase MessageQueue
:
1 |
// Instantiate the two entities and the message queue
|
2 |
var entityA = new Entity(); |
3 |
var entityB = new Entity(); |
4 |
var messageQueue = new MessageQueue(); |
5 |
|
6 |
// Create a message to entityB, from entityA,
|
7 |
// with type "damage" and data/value 10.
|
8 |
var msg = new Message(entityB, entityA, "damage", 10); |
9 |
|
10 |
// Add the message to the queue
|
11 |
messageQueue.add(msg); |
Ahora tenemos una manera de crear y almacenar mensajes en una cola. Es hora de que lleguen a su destino.
Entrega de mensajes
Para
que la clase MessageQueue
realmente despache los mensajes publicados,
primero debemos definir cómo las entidades manejarán y recibirán
mensajes. La forma más fácil es agregar un método denominado onMessage()
a cada entidad capaz de recibir mensajes:
1 |
/**
|
2 |
* This class describes a generic entity.
|
3 |
*/
|
4 |
Entity = function () { |
5 |
// Initialize anything here, e.g. Phaser stuff
|
6 |
};
|
7 |
|
8 |
// This method is invoked by the MessageQueue
|
9 |
// when there is a message to this entity.
|
10 |
Entity.prototype.onMessage = function(message) { |
11 |
// Handle new message here
|
12 |
};
|
La clase MessageQueue
invocará el método onMessage()
de cada entidad que debe recibir un mensaje. El
parámetro pasado a ese método es el mensaje que está siendo entregado
por el sistema de colas (y que está siendo recibido por el destino).
La clase MessageQueue
enviará los mensajes en su cola de una vez, en el método dispatch()
:
1 |
/**
|
2 |
* This class is responsible for receiving messages and
|
3 |
* dispatching them to the destination.
|
4 |
*/
|
5 |
MessageQueue = function () { |
6 |
this.messages = []; // list of messages to be dispatched |
7 |
};
|
8 |
|
9 |
MessageQueue.prototype.add = function(message) { |
10 |
this.messages.push(message); |
11 |
};
|
12 |
|
13 |
// Dispatch all messages in the queue to their destination.
|
14 |
MessageQueue.prototype.dispatch = function() { |
15 |
var i, entity, msg; |
16 |
|
17 |
// Iterave over the list of messages
|
18 |
for(i = 0; this.messages.length; i++) { |
19 |
// Get the message of the current iteration
|
20 |
msg = this.messages[i]; |
21 |
|
22 |
// Is it valid?
|
23 |
if(msg) { |
24 |
// Fetch the entity that should receive this message
|
25 |
// (the one in the 'to' field)
|
26 |
entity = msg.to; |
27 |
|
28 |
// If that entity exists, deliver the message.
|
29 |
if(entity) { |
30 |
entity.onMessage(msg); |
31 |
}
|
32 |
|
33 |
// Delete the message from the queue
|
34 |
this.messages.splice(i, 1); |
35 |
i--; |
36 |
}
|
37 |
}
|
38 |
};
|
Este
método itera sobre todos los mensajes de la cola y, para cada mensaje,
el campo to
se utiliza para buscar una referencia al receptor. El
método onMessage()
del receptor se invoca, con el mensaje actual como
un parámetro, y el mensaje entregado se quita de la lista MessageQueue
. Este proceso se repite hasta que se envíen todos los mensajes.
Uso de una cola de mensajes
Es hora de ver todos los detalles de esta implementación trabajando juntos. Utilizemos
nuestro sistema de colas de mensajes en una demo muy simple compuesta
por unas pocas entidades móviles que interactúan entre sí. En aras de la simplicidad, vamos a trabajar con tres entidades: Healer
, Runner
y Hunter
.
El Runner
tiene una barra de salud y se mueve alrededor aleatoriamente. El Healer
curará a cualquier Runner
que pase cerca; Por otro lado, el Hunter
infligirá daño a cualquier Runner
cercano. Todas las interacciones serán manejadas usando el sistema de cola de mensajes.
Agregar la cola de mensajes
Empecemos
por crear el PlayState
que contiene una lista de entidades (curanderos,
corredores y cazadores) y una instancia de la clase MessageQueue
:
1 |
var PlayState = function() { |
2 |
var entities; // list of entities in the game |
3 |
var messageQueue; // the message queue (dispatcher) |
4 |
|
5 |
this.create = function() { |
6 |
// Initialize the message queue
|
7 |
messageQueue = new MessageQueue(); |
8 |
|
9 |
// Create a group of entities.
|
10 |
entities = this.game.add.group(); |
11 |
};
|
12 |
|
13 |
this.update = function() { |
14 |
// Make all messages in the message queue
|
15 |
// reach their destination.
|
16 |
messageQueue.dispatch(); |
17 |
};
|
18 |
};
|
En
el bucle de juego, representado por el método update()
, se invoca el
método dispatch()
de colas de mensajes, por lo que todos los mensajes
se entregan al final de cada marco de juego.
Añadiendo los Runners
La clase Runner
tiene la siguiente estructura:
1 |
/**
|
2 |
* This class describes an entity that just
|
3 |
* wanders around.
|
4 |
*/
|
5 |
Runner = function () { |
6 |
// initialize Phaser stuff here...
|
7 |
};
|
8 |
|
9 |
// Invoked by the game on each frame
|
10 |
Runner.prototype.update = function() { |
11 |
// Make things move here...
|
12 |
}
|
13 |
|
14 |
// This method is invoked by the message queue
|
15 |
// to make the runner deal with incoming messages.
|
16 |
Runner.prototype.onMessage = function(message) { |
17 |
var amount; |
18 |
|
19 |
// Check the message type so it's possible to
|
20 |
// decide if this message should be ignored or not.
|
21 |
if(message.type == "damage") { |
22 |
// The message is about damage.
|
23 |
// We must decrease our health points. The amount of
|
24 |
// this decrease was informed by the message sender
|
25 |
// in the 'data' field.
|
26 |
amount = message.data; |
27 |
this.addHealth(-amount); |
28 |
|
29 |
} else if (message.type == "heal") { |
30 |
// The message is about healing.
|
31 |
// We must increase our health points. Again the amount of
|
32 |
// health points to increase was informed by the message sender
|
33 |
// in the 'data' field.
|
34 |
amount = message.data; |
35 |
this.addHealth(amount); |
36 |
|
37 |
} else { |
38 |
// Here we deal with messages we are not able to process.
|
39 |
// Probably just ignore them :)
|
40 |
}
|
41 |
};
|
La
parte más importante es el método onMessage()
, invocado por la cola de
mensajes cada vez que hay un mensaje nuevo para esta instancia. Como se explicó anteriormente, el campo type
en el mensaje se utiliza para decidir de qué se trata esta comunicación.
Basándose en el tipo de mensaje, se realiza la acción correcta: si el tipo de mensaje es "damage"
, los puntos de salud se reducen; Si el tipo de mensaje es "heal"
, los puntos de salud se incrementan. El número de puntos de salud para aumentar o disminuir por es definido por el remitente en el campo data
del mensaje.
En PlayState
, añadimos algunos corredores a la lista de entidades:
1 |
var PlayState = function() { |
2 |
// (...)
|
3 |
|
4 |
this.create = function() { |
5 |
// (...)
|
6 |
|
7 |
// Add runners
|
8 |
for(i = 0; i < 4; i++) { |
9 |
entities.add(new Runner(this.game, this.game.world.width * Math.random(), this.game.world.height * Math.random())); |
10 |
}
|
11 |
};
|
12 |
|
13 |
// (...)
|
14 |
};
|
El resultado son cuatro corredores que se mueven al azar:
Añadiendo el Cazador
La clase Hunter
tiene la siguiente estructura:
1 |
/**
|
2 |
* This class describes an entity that just
|
3 |
* wanders around hurting the runners that pass by.
|
4 |
*/
|
5 |
Hunter = function (game, x, y) { |
6 |
// initialize Phaser stuff here
|
7 |
};
|
8 |
|
9 |
// Check if the entity is valid, is a runner, and is within the attack range.
|
10 |
Hunter.prototype.canEntityBeAttacked = function(entity) { |
11 |
return entity && entity != this && |
12 |
(entity instanceof Runner) && |
13 |
!(entity instanceof Hunter) && |
14 |
entity.position.distance(this.position) <= 150; |
15 |
};
|
16 |
|
17 |
// Invoked by the game during the game loop.
|
18 |
Hunter.prototype.update = function() { |
19 |
var entities, i, size, entity, msg; |
20 |
|
21 |
// Get a list of entities
|
22 |
entities = this.getPlayState().getEntities(); |
23 |
|
24 |
for(i = 0, size = entities.length; i < size; i++) { |
25 |
entity = entities.getChildAt(i); |
26 |
|
27 |
// Is this entity a runner and is it close?
|
28 |
if(this.canEntityBeAttacked(entity)) { |
29 |
// Yeah, so it's time to cause some damage!
|
30 |
msg = new Message(entity, this, "damage", 2); |
31 |
|
32 |
// Send the message away!
|
33 |
this.getMessageQueue().add(msg); // or just entity.onMessage(msg); if you want to bypass the message queue for some reasong. |
34 |
}
|
35 |
}
|
36 |
};
|
37 |
|
38 |
// Get a reference to the game's PlayState
|
39 |
Hunter.prototype.getPlayState = function() { |
40 |
return this.game.state.states[this.game.state.current]; |
41 |
};
|
42 |
|
43 |
// Get a reference to the game's message queue.
|
44 |
Hunter.prototype.getMessageQueue = function() { |
45 |
return this.getPlayState().getMessageQueue(); |
46 |
};
|
Los cazadores también se moverán, pero causarán daño a todos los corredores que están cerca. Este
comportamiento se implementa en el método update()
, donde todas las
entidades del juego son inspeccionadas y los corredores reciben mensajes
sobre el daño.
El mensaje de daño se crea de la siguiente manera:
1 |
msg = new Message(entity, this, "damage", 2); |
El
mensaje contiene la información sobre el destino (entity
, en este
caso, que es la entidad que se analiza en la iteración actual), el
remitente (this
, que representa al cazador que está realizando el
ataque), el tipo del mensaje ("damage"
) y la cantidad de daño (2
, en este caso, asignado al campo data
del mensaje).
El
mensaje se contabiliza en el destino mediante el comando this.getMessageQueue().add(msg)
, que agrega el mensaje recién creado a
la cola de mensajes.
Finalmente, agregamos Hunter
a la lista de entidades en el PlayState
:
1 |
var PlayState = function() { |
2 |
// (...)
|
3 |
|
4 |
this.create = function() { |
5 |
// (...)
|
6 |
|
7 |
// Add hunter at position (20, 30)
|
8 |
entities.add(new Hunter(this.game, 20, 30)); |
9 |
};
|
10 |
|
11 |
// (...)
|
12 |
};
|
El resultado es que unos cuantos corredores se mueven, recibiendo mensajes del cazador a medida que se acercan:
He añadido los sobres volantes como una ayuda visual para ayudar a mostrar lo que está pasando.
Añadiendo el Curador
La clase Healer
tiene la siguiente estructura:
1 |
/**
|
2 |
* This class describes an entity that is
|
3 |
* able to heal any runner that passes nearby.
|
4 |
*/
|
5 |
Healer = function (game, x, y) { |
6 |
// Initializer Phaser stuff here
|
7 |
};
|
8 |
|
9 |
Healer.prototype.update = function() { |
10 |
var entities, i, size, entity, msg; |
11 |
|
12 |
// The the list of entities in the game
|
13 |
entities = this.getPlayState().getEntities(); |
14 |
|
15 |
for(i = 0, size = entities.length; i < size; i++) { |
16 |
entity = entities.getChildAt(i); |
17 |
|
18 |
// Is it a valid entity?
|
19 |
if(entity) { |
20 |
// Check if the entity is within the healing radius
|
21 |
if(this.isEntityWithinReach(entity)) { |
22 |
// The entity can be healed!
|
23 |
// First of all, create a new message regaring the healing
|
24 |
msg = new Message(entity, this, "heal", 2); |
25 |
|
26 |
// Send the message away!
|
27 |
this.getMessageQueue().add(msg); // or just entity.onMessage(msg); if you want to bypass the message queue for some reasong. |
28 |
}
|
29 |
}
|
30 |
}
|
31 |
};
|
32 |
|
33 |
// Check if the entity is neither a healer nor a hunter and is within the healing radius.
|
34 |
Healer.prototype.isEntityWithinReach = function(entity) { |
35 |
return !(entity instanceof Healer) && !(entity instanceof Hunter) && entity.position.distance(this.position) <= 200; |
36 |
};
|
37 |
|
38 |
// Get a reference to the game's PlayState
|
39 |
Healer.prototype.getPlayState = function() { |
40 |
return this.game.state.states[this.game.state.current]; |
41 |
};
|
42 |
|
43 |
// Get a reference to the game's message queue.
|
44 |
Healer.prototype.getMessageQueue = function() { |
45 |
return this.getPlayState().getMessageQueue(); |
46 |
};
|
El código y la estructura son muy similares a la clase Hunter
, a excepción de algunas diferencias. De
forma similar a la implementación del cazador, el método update()
del
curandero itera sobre la lista de entidades en el juego, enviando
mensajes a cualquier entidad dentro de su alcance de curación:
1 |
msg = new Message(entity, this, "heal", 2); |
El
mensaje también tiene un destino (entity
), un remitente (this
, que es
el sanador que realiza la acción), un tipo de mensaje ("heal"
) y el
número de puntos de curación (2
, asignados en el campo data
del
mensaje).
Añadimos
el Healer
a la lista de entidades en el PlayState
de la misma manera
que hicimos con el Hunter
y el resultado es una escena con corredores,
un cazador y un curandero:
¡Y eso es! Tenemos tres entidades diferentes que interactúan entre sí intercambiando mensajes.
Discusión sobre flexibilidad
Este sistema de colas de mensajes es una forma versátil de gestionar interacciones en un juego. Las interacciones se realizan a través de un canal de comunicación que está unificado y tiene una única interfaz que es fácil de usar e implementar.
A medida que su juego crece en complejidad, nuevas interacciones podrían ser necesarias. Algunos de ellos pueden ser completamente inesperados, por lo que debe adaptar su código para hacer frente a ellos. Si está utilizando un sistema de cola de mensajes, se trata de agregar un nuevo mensaje en algún lugar y manejarlo en otro.
Por ejemplo, imagina que quieres hacer que el Hunter
interactúe con el Healer
; Sólo
tienes que hacer que el Hunter
envíe un mensaje con la nueva
interacción—por ejemplo, "flee"
—y asegúrate de que el Healer
pueda
manejarlo en el método onMessage
:
1 |
// In the Hunter class:
|
2 |
Hunter.prototype.someMethod = function() { |
3 |
// Get a reference to a nearby healer
|
4 |
var healer = this.getNearbyHealer(); |
5 |
|
6 |
// Create message about fleeing a place
|
7 |
var place = {x: 30, y: 40}; |
8 |
var msg = new Message(entity, this, "flee", place); |
9 |
|
10 |
// Send the message away!
|
11 |
this.getMessageQueue().add(msg); |
12 |
};
|
13 |
|
14 |
// In the Healer class:
|
15 |
Healer.prototype.onMessage = function(message) { |
16 |
if(message.type == "flee") { |
17 |
// Get the place to flee from the data field in the message
|
18 |
var place = message.data; |
19 |
|
20 |
// Use the place information
|
21 |
flee(place.x, place.y); |
22 |
}
|
23 |
};
|
¿Por qué no sólo enviar mensajes directamente?
Aunque
el intercambio de mensajes entre las entidades puede ser útil, podrías estar pensando por qué el MessageQueue
es necesario después de
todo. ¿No puede simplemente invocar el método
onMessage()
del receptor usted mismo en lugar de depender de
MessageQueue
, como en el código de abajo?
1 |
Hunter.prototype.someMethod = function() { |
2 |
// Get a reference to a nearby healer
|
3 |
var healer = this.getNearbyHealer(); |
4 |
|
5 |
// Create message about fleeing a place
|
6 |
var place = {x: 30, y: 40}; |
7 |
var msg = new Message(entity, this, "flee", place); |
8 |
|
9 |
// Bypass the MessageQueue and directly deliver
|
10 |
// the message to the healer.
|
11 |
healer.onMessage(msg); |
12 |
};
|
Definitivamente podría implementar un sistema de mensajes como ese, pero el uso de un MessageQueue
tiene algunas ventajas.
Por ejemplo, al centralizar el envío de mensajes, puede implementar algunas funciones interesantes como mensajes retrasados, la capacidad de enviar mensajes a un grupo de entidades e información de depuración visual (como los sobres volantes utilizados en este tutorial).
Hay espacio para la creatividad en la clase MessageQueue
, depende de usted y de los requisitos de su juego.
Conclusion
Manejar las interacciones entre las entidades del juego usando un sistema de cola de mensajes es una manera de mantener su código organizado y listo para el futuro. Las nuevas interacciones pueden agregarse fácil y rápidamente, incluso sus ideas más complejas, siempre y cuando se encapsulen como mensajes.
Como
se explica en el tutorial, puede ignorar el uso de una cola de mensajes
central y simplemente enviar mensajes directamente a las entidades. También
puede centralizar la comunicación mediante un despacho (la clase
MessageQueue
en nuestro caso) para dejar espacio para nuevas
características en el futuro, como mensajes retrasados.
Espero que encuentre útil este método y lo agregue a su cinturón de utilidad para desarrolladores de juegos. El método puede parecer un exceso para los proyectos pequeños, pero sin duda le ahorrará algunos dolores de cabeza en el largo plazo para los juegos más grandes.