Hero image

Utilice ScriptableObjects como canales de eventos en el código del juego

Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

¿Cómo lograr que sistemas dispares en su aplicación trabajen juntos? Una solución común es utilizar un evento para enviar mensajes entre objetos. Continúe leyendo para aprender cómo usar ScriptableObjects como canales de eventos en su proyecto de Unity.

Esta es la quinta de una serie de seis miniguías creadas para ayudar a los desarrolladores de Unity con la demostración que acompaña al libro electrónico, Crear una arquitectura de juego modular en Unity con ScriptableObjects.

La demostración está inspirada en la mecánica clásica de los juegos arcade de pelota y paleta, y muestra cómo ScriptableObjects puede ayudarle a crear componentes que sean comprobables, escalables y fáciles de usar para el diseñador.

Juntos, el libro electrónico, el proyecto de demostración y estas miniguías proporcionan las mejores prácticas para usar patrones de diseño de programación con la clase ScriptableObject en su proyecto de Unity. Estos consejos pueden ayudarle a simplificar su código, reducir el uso de memoria y promover la reutilización del código.

Esta serie incluye los siguientes artículos:

Nota importante antes de empezar

Antes de sumergirse en el proyecto de demostración ScriptableObject y en esta serie de miniguías, recuerde que, en esencia, los patrones de diseño son solo ideas. No se aplicarán a todas las situaciones. Estas técnicas pueden ayudarle a aprender nuevas formas de trabajar con Unity y ScriptableObjects.

Cada patrón tiene pros y contras. Elija sólo aquellos que beneficien significativamente su proyecto específico. ¿Sus diseñadores dependen en gran medida del Editor de Unity? Un patrón basado en ScriptableObject podría ser una buena opción para ayudarlos a colaborar con sus desarrolladores.

En última instancia, la mejor arquitectura de código es la que se adapta a su proyecto y a su equipo.

Acoplamiento débil, alta cohesión

Al crear diferentes módulos o sistemas en una aplicación, a menudo es útil pensar en ellos como "islas de código". Cada módulo puede tener varios componentes o GameObjects que trabajan juntos para un propósito común.

Por ejemplo, la paleta del jugador puede incluir un script que interprete la entrada del jugador, uno que maneje el movimiento o las colisiones, etcétera. Si estas partes tienen interdependencias entre sí, puedes usar el Inspector para realizar esas conexiones cercanas.

Sin embargo, tenga en cuenta que cada vez que agrega una dependencia a otro objeto, conlleva una pequeña cantidad de riesgo. Cuando sea posible, querrás minimizar esas dependencias con objetos externos. La comunicación con cosas que están fuera de tu módulo o sistema no será tan directa.

Podrías hacer que el script de Paddle haga referencia a la pelota en tu juego, pero eso significa que tienen una conexión. Una vez que están unidos por una dependencia, realizar un cambio en uno podría afectar potencialmente al otro.

Lo ideal es poder modificar parte de la aplicación sin romper nada más. El objetivo es mantener sus módulos cohesionados internamente pero desacoplados externamente.

Puede utilizar la clase NullRefChecker del proyecto para emitir una advertencia cortés cuando falten las referencias requeridas en el Inspector. Simplemente llame al método estático Validate en algún lugar (por ejemplo, en Awake) después de que se haya configurado o inicializado cada componente.

Agregue el atributo Opcional personalizado para ignorar la verificación si el campo puede dejarse sin configurar.

Uso de eventos

Entonces, ¿cómo lograr que estos sistemas dispares en su aplicación trabajen juntos?

Una solución es utilizar un evento para enviar mensajes entre objetos. Los eventos se adhieren al modelo transmisor-oyente, que se visualiza en la imagen de arriba.

Aquí, el objeto que escucha se suscribe al evento en el transmisor, en lugar de llamar a un método o hacer referencia a una propiedad directamente.

Los cambios realizados en un componente tienen menos impacto en los demás. Las cosas aún pueden fallar cuando modificas el código, pero los objetos no estarán tan entrelazados. El evento intermedio funciona como un amortiguador entre ellos.

A menudo describimos los objetos en esta relación emisor-oyente como débilmente acoplados.
Puede leer más sobre eventos y el patrón de observador en nuestro libro electrónico técnico, Mejore su código con patrones de programación de juegos.

evento editorial

UN SISTEMA DE EVENTOS CENTRALIZADO

Eventos centralizados

En el escenario descrito anteriormente, el transmisor solo es responsable de enviar una señal. No le importa qué objetos estén escuchando.

Sin embargo, el oyente aún necesita tener algún conocimiento del transmisor para suscribirse y cancelar la suscripción al delegado utilizando los métodos OnEnable y OnDisable.

Puedes disociar aún más al transmisor y al oyente moviendo eventos a una clase estática. Una clase general de “eventos del juego” puede ayudar a insertar una capa adicional de abstracción entre los dos. Esto puede conectar al emisor y al oyente sin que tengan conocimiento directo el uno del otro.

En este ejemplo, utilizaremos una clase GameEvents estática para simplificar. Sin embargo, en un escenario de producción del mundo real, es mejor dividirlo en clases más pequeñas y especializadas por función, como UIEvents, GameStateEvents, HealthEvents, InventoryEvents, etc.

Por ejemplo, puede crear eventos estáticos para salir de la aplicación, mostrar una pantalla de IU o cargar una escena. Al hacer que estos eventos sean estáticos, se puede acceder a ellos desde cualquier parte de la aplicación.

Por ejemplo, puedes crear GameEvents como se ve en el ejemplo siguiente.

El GameEvent estático se sitúa entre el transmisor original y el oyente como intermediario. Las modificaciones tanto del receptor como del emisor tienen menos probabilidades de afectar al otro.

En consecuencia, actualizar el código tiene menos efectos secundarios inesperados. Almacenar las definiciones de eventos en una sola ubicación también hace que sea más fácil administrarlos.

Si bien los GameEvents estáticos son efectivos, es posible que no sean muy accesibles para los diseñadores de juegos. Debido a que son estáticos, deben definirse dentro del código y no son serializables de forma nativa en el Editor.

Para un sistema más amigable con el editor, considere implementar eventos basados en ScriptableObjects.

using UnityEngine;
using System;

public static class GameEvents
{
    public static Action ExitApplication;
    public static Action HomeScreenShown;
    public static Action<float> LoadProgressUpdated;
}
eventos

LOS CANALES DE EVENTOS TRANSMITEN SEÑALES ENTRE TRANSMISORES Y OYENTES.

Configuración de canales de eventos

Los eventos basados en ScriptableObject ofrecen una alternativa gráfica a los eventos estáticos. Aunque ambos cumplen funciones similares, los ScriptableObjects tienden a ser fáciles de usar para el diseñador porque aparecen en el Inspector.

Dado que transmiten una señal de un transmisor a un oyente, podemos considerarlos como “canales de eventos”, que son análogos a una transmisión desde una torre de radio.

Cualquier ScriptableObject con lo siguiente puede funcionar como un canal de eventos:

  • Un delegado (como UnityAction o System.Action): Esto notifica a los suscriptores y pasa datos como parámetros.
  • Un método para generar eventos: Este método público invoca al delegado.

Puedes configurar cualquier cantidad de canales de eventos para determinar varios aspectos del juego.

UnityAction y System.Action son ambos delegados. Puede utilizar uno o ambos tipos en su proyecto.

UnityAction crea una experiencia más amigable para los artistas. De lo contrario, utilice el delegado System.Action.

A continuación encontrará un ejemplo de un VoidEventChannelSO del proyecto. Este es un evento basado en ScriptableObject que no pasa ningún parámetro.

Aquí utilizamos una UnityAction llamada OnEventRaised y exponemos un método RaiseEventpúblico.

using UnityEngine;
using UnityEngine.Events;

[CreateAssetMenu(menuName = "Events/Void Event Channel", 
fileName = "VoidEventChannel")]
public class VoidEventChannelSO : DescriptionSO
{
    [Tooltip("The action to perform")]
    public UnityAction OnEventRaised;

    public void RaiseEvent()
    {
        if (OnEventRaised != null)
            OnEventRaised.Invoke();
    }
}
tab8

CREAR CANALES DE EVENTOS EN EL PROYECTO.

Creación de los activos del canal de eventos

Cree el activo del canal de eventos en el proyecto para usarlo. Puede utilizar el menú Crear o duplicar un activo existente.

Cambie el nombre de cada activo y utilice el campo de descripción para identificar cada activo de ScriptableObject. Recuerde que cada canal de evento existe como un activo a nivel de proyecto. Harás referencia a estos activos en tus MonoBehaviours.

Aunque es opcional, puede etiquetar los canales de eventos basados en ScriptableObject con el sufijo _SO para diferenciarlos de otros ScriptableObjects que transportan datos (que tienen el sufijo _Data).

Las carpetas y las convenciones de nombres pueden ayudar a que su proyecto se mantenga organizado. Querrá personalizarlos según las necesidades de su proyecto. Lea Crear una guía de estilo de C# para obtener más información.

tab9

ASIGNAR EL CANAL DE EVENTO EN EL INSPECTOR.

Eventos de aumento

Cualquier objeto en tu escena ahora puede hacer referencia al canal de eventos y llamar al evento usando el método RaiseEvent . Por ejemplo, observe el ejemplo MonoBehaviour con un método TriggerEvent a continuación.

En el Inspector, el activo ScriptableObject debe asignarse al campo m_EventChannel . Cuando algo invoca TriggerEvent, el evento se ejecuta. Cualquier persona que esté escuchando recibirá una notificación.

Este mecanismo añade gran parte de la interactividad a su aplicación de juego. Cada módulo o sistema genera un evento (por ejemplo, el sistema de entrada registra una pulsación de tecla, una pelota choca contra una pared, etc.). Como respuesta, algo más reacciona a eso.

public class EventRaiser: MonoBehaviour
{
    [SerializeField]
    private VoidEventChannelSO m_EventChannel;

    public void TriggerEvent()
    {
        m_EventChannel.RaiseEvent();
    }
}
pestaña

EL ADMINISTRADOR DEL JUEGO ESCUCHA CIERTOS CANALES DE EVENTOS Y TRANSMITE EN OTROS.

Escuchando eventos

Para configurar un oyente, un MonoBehaviour u otro componente deberá suscribirse al evento OnEventRaised del canal de eventos. Normalmente esto sucede enOnEnable, como en el ejemplo siguiente.

Cuando el canal de eventos genera un evento, el método HandleEvent se ejecuta en respuesta. Este mecanismo se puede utilizar para diversos propósitos, como reproducir sonidos o efectos, modificar configuraciones, etc., dependiendo del contexto del evento.

En el proyecto PaddleBallSO , así es como configuramos el bucle de juego principal. El GameManager escucha un conjunto de canales de eventos y luego transmite en otro. Esto permite que diferentes sistemas se envíen mensajes entre sí sin tener necesariamente dependencias directas.

Por último, cancele la suscripción al evento OnEventRaised en el método OnDisable para evitar errores o pérdidas de memoria.

public class EventListener: MonoBehaviour
{
    [SerializeField]
    private VoidEventChannelSO m_EventChannel;

    private void OnEnable()
    {
        m_EventChannel.OnEventRaised += HandleEvent;
    }

    private void OnDisable()
    {
        m_EventChannel.OnEventRaised -= HandleEvent;
    }

    private void HandleEvent()
    {
        Debug.Log("Event received");
    }
}
tab10

INTERACTIVIDAD SIN CÓDIGO CONFIGURADA EN EL INSPECTOR

Agregar un oyente sin código

Si está trabajando con diseñadores, es posible que desee proporcionarles un script de propósito general preconfigurado que pueda escuchar un evento. Esto les permitirá crear interacciones de juego sin un programador.

El VoidEventChannelListener es un ejemplo de esto. Este componente genera un UnityEvent cuando recibe una señal de un canal de eventos. Simplemente agregue VoidEventChannelListener a un GameObject, luego configure el canal de eventos y la lógica UnityEvent.

Un diseñador puede entonces crear prototipos de lógica basada en eventos con sólo unas pocas configuraciones en el Inspector.

Por ejemplo, el prefab GameOverSounds escucha el canal de eventos GameOver_SO . Una vez que recibe este evento, reproduce un sonido en el AudioSource dado a través de m_Response UnityEvent.

La clase VoidEventChannelListener también incluye un retraso útil para ajustar el tiempo de cada respuesta.

Con un poco de práctica, esta es una forma sencilla de crear interacciones entre sus diferentes sistemas y módulos.

tab11

CANALES DE EVENTOS MARCADOS PARA ENVÍO Y RECEPCIÓN

Cómo ayudan los canales de eventos

Dado que existen a nivel de proyecto, los canales de eventos son accesibles globalmente. Esto les permite conectar cualquier objeto en la jerarquía de escenas y persistir a través de las cargas de escenas.

Cualquier objeto puede actuar como transmisor o como oyente: solo es cuestión de cómo interactúa con el canal de eventos. Esto le brinda mucha flexibilidad al enviar mensajes.

Nota: Es una buena práctica indicar en el Inspector si el canal es para enviar o recibir. Utilice HeaderAttribute para hacer esto.

Un beneficio adicional de usar eventos a nivel de proyecto es que a menudo pueden reemplazar la necesidad de un singleton. Los canales de eventos están disponibles globalmente, por lo que pueden conectar cualquier cosa con cualquier cosa. Permítales controlar sistemas del juego como cámaras, misiones, salud y logros, todo sin crear dependencias innecesarias.

Además, debido a que una arquitectura basada en eventos se ejecuta solo cuando es necesario, está más optimizada que los métodos de actualización de MonoBehaviour.

La firma de función de un evento base

Esta clase VoidEventChannelSO solo funciona para eventos que no necesitan ningún parámetro. A menudo, el evento generado necesita una carga adicional de datos para ser significativo.

Por ejemplo, si estás enviando un evento que aplica daño en un sistema de salud, es posible que quieras pasar un valor para el objetivo, cuánto daño enviar, qué tipo de daño, etc.

Puede cambiar la firma de la función de su evento base para que el canal de evento sea más adecuado para eso. El proyecto define un GenericEventChannelSO para ese propósito. Eche un vistazo al ejemplo a continuación.

Esta es una clase abstracta con un único parámetro genérico. Derivarás otros canales de eventos de él. Estos pueden luego pasar un único parámetro, como un float, un int o un bool.

Al igual que VoidEventChannelSO, GenericEventChannelSO presenta una UnityAction llamada OnEventRaised. Sin embargo, esta vez la acción lleva un parámetro de tipo T.

Los objetos externos invocarán el método público RaiseEvent correspondiente. Si el evento tiene oyentes, entonces se ejecuta mientras pasa un parámetro determinado.

public abstract class GenericEventChannelSO<T>: DescriptionSO
{
    public UnityAction<T> OnEventRaised;

    public void RaiseEvent(T parameter)
    {
        if (OnEventRaised == null)
            return;

        OnEventRaised.Invoke(parameter);
    }
}

Creación de canales de eventos concretos

Ahora solo necesita derivar canales de eventos concretos de GenericEventChannelSO y completar el valor de T.

Aparte del atributo CreateAssetMenu habitual, no es necesario ningún detalle de implementación explícito.

Crear un canal de eventos que lleve un flotante, FloatEventChannelSO, es sencillo. Eche un vistazo al ejemplo de código a continuación.

¡Es así de simple! Utilice este flujo de trabajo para crear flujos de trabajo adicionales para BoolEventChannelSO, IntEventChannelSO, etc.

Si necesita más de un parámetro como carga útil, defina clases genéricas adicionales (por ejemplo, GenericEventChannelSO<T,U>, GenericEventChannelSO<T,U,V>, etc.) según sea necesario.

[CreateAssetMenu(menuName = "Events/Float EventChannel", fileName = "FloatEventChannel")]
public class FloatEventChannelSO : GenericEventChannelSO<float> {}
tab11

SECUENCIA DE EVENTOS CUANDO LA PELOTA GOLPEA UN GOL DE PUNTAJE

Poniéndolo todo junto

La idea es dividir la aplicación en partes más pequeñas y modulares. Establecer límites claros a su alrededor evita que esas partes se entrelacen con dependencias y ayuda a evitar el código espagueti.

Los componentes que carecen de conocimiento directo de objetos externos no pueden manipular algo que no deberían. En cambio, se ven obligados a enviar y recibir mensajes a través de canales de eventos.

Puedes ver cómo funciona esto si trazas una pequeña secuencia del juego de pádel. Por ejemplo, imaginemos qué sucede cuando una pelota choca con un ScoreGoal:

El componente ScoreGoal registra una colisión. Después de detectar la pelota, genera un evento en el canal de eventos GoalHit_SO . Esto pasa el ID del jugador que puntúa.

El canal de eventos notifica al GameManager, que en respuesta genera otro canal de eventos llamado PointsScored_SO. Esto también pasa el ID del jugador.

Este canal notifica al ScoreManager, que incrementa la puntuación (almacenada en un objeto separado) y actualiza los componentes de la interfaz de usuario. Luego, pasa los puntajes de ambos jugadores a través del canal de evento ScoreManagerUpdated_SO .

Como respuesta, el objetivo ScoreObjective_SO verifica si un jugador ha alcanzado la puntuación objetivo.

Si se alcanza una condición de victoria, el juego termina. De lo contrario, el GameManager reinicia la ronda y la pelota vuelve al juego.

A primera vista, puede parecer mucho trabajo extra incrementar el valor de una puntuación en un punto. Sin embargo, la intención es aislar todas las piezas involucradas: La Pelota, el ScoreManager, el GameManager, el ObjectiveManager, etc.

Cada parte de la aplicación tiene cierta autonomía y eso hace que cada una sea más fácil de probar. Añadir nuevos sistemas no necesita alterar la lógica existente. De hecho, la jugabilidad original puede pasarles completamente desapercibida.

Imagina que quieres agregar efectos secundarios como sonidos y animaciones para acompañar el proceso de puntuación. Podrías crear nuevos componentes que escuchen los eventos correctos y respondan apropiadamente. La lógica subyacente y el flujo del juego pueden permanecer inalterados, incluso a medida que se agregan los nuevos sistemas.

Recuerde que el mantra en la programación SOLID es “abierto para extensión, cerrado para modificación”. Desea tener la capacidad de agregar nueva funcionalidad a su software sin tener que cambiar el código existente. El uso de canales de eventos como este proporciona escalabilidad.

inspector

LOS SCRIPTS DEL EDITOR PUEDEN AYUDAR A DEPURAR EVENTOS.

Depuración de eventos

La arquitectura basada en eventos facilita la depuración y el mantenimiento. Las partes más pequeñas son más fáciles de probar, ya sea que esté escribiendo pruebas unitarias automatizadas con Unity Test Framework o simplemente solucionando problemas de manera informal. Esto le permite centrarse en un problema específico y realizar pruebas de forma aislada.

En este caso, las secuencias de comandos del editor personalizado pueden resultar de ayuda. PaddleBallSO demuestra algunas herramientas que ayudan a rastrear el flujo de su aplicación al utilizar canales de eventos:

  • La mayoría de los canales de eventos en el proyecto PaddleBallSO muestran una lista de oyentes en el Inspector. Haga clic en el nombre de cada oyente para resaltarlo en la jerarquía.
  • Un botón RaiseEvent personalizado puede invocar un evento simulado a voluntad (usando el valor predeterminado de T si lleva una carga útil). Mientras la aplicación se esté ejecutando, simplemente actívela manualmente con un solo clic.

Al solucionar problemas de canales de eventos, seleccione el activo ScriptableObject. Pruebe el evento manualmente según sea necesario. El Inspector puede guiarle a través de los objetos que podrían estar escuchando. Seleccione los oyentes que desea inspeccionar con más detalle.

Si ha etiquetado los canales de eventos con HeaderAttribute, puede rastrear varios eventos para comprender el flujo de la lógica.

Outro con guión

Más recursos de ScriptableObject

Esperamos que los canales de eventos y la arquitectura basada en eventos puedan beneficiar sus proyectos nuevos y futuros.

Lea más sobre patrones de diseño con ScriptableObjects en nuestro libro electrónico técnico, Cree una arquitectura de juego modular en Unity con ScriptableObjects. También puede obtener más información sobre los patrones de diseño de desarrollo de Unity comunes en Mejore su código con patrones de programación de juegos.

¿Te gustó este contenido?