Advertisement
  1. Web Design
  2. HTML/CSS
  3. HTML

Cómo implementar y utilizar una cola de mensajes en su juego

Scroll to top
Read Time: 19 min

() 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?

Interactions in a game tend to grow in complexity very quicklyInteractions in a game tend to grow in complexity very quicklyInteractions in a game tend to grow in complexity very quickly
Las interacciones en un juego tienden a crecer en complejidad muy rápidamente.

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.

Interactions made using a message queue systemInteractions made using a message queue systemInteractions made using a message queue system
Interacciones realizadas utilizando un sistema de cola de mensajes.

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:

Structure of a messageStructure of a messageStructure of a message
Estructura de un mensaje.

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.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.