Son una herramienta fundamental para controlar la lógica temporal en videojuegos, desde mecánicas básicas hasta sistemas complejos, y por tanto son esenciales en muchos aspectos del diseño y desarrollo de videojuegos.
Son muchas sus utilidades y casos de uso. Veamos algunos de los ejemplos más destacados:
- Control de eventos temporales: Como, por ejemplo, ejecutar acciones después de un tiempo específico, como activar una animación, generar enemigos (spawning), o mostrar un mensaje en pantalla.
- Mecánicas de juego basadas en tiempo: Por ejemplo, implementar límites de tiempo para completar una misión, resolver un puzzle o sobrevivir a una oleada de enemigos.
- Sincronización de animaciones y efectos: Para coordinar animaciones para que ocurran en el momento adecuado, como explosiones, transiciones o efectos visuales.
- Gestión de cooldowns: Para controlar el tiempo de reutilización (cooldown) de habilidades, ataques especiales, recarga de armas o acciones del jugador.
- Frecuencia de actualización de sistemas: Con el fin de regular la frecuencia con la que ciertos sistemas (como la IA de enemigos o la generación de objetos) se actualizan para optimizar el rendimiento
- Eventos cíclicos o repetitivos: Para crear patrones que se repiten en intervalos regulares, como oleadas de enemigos, cambios climáticos en el juego o ciclos día/noche.
- Sincronización en multijugador: Se usan para asegurar que los eventos en juegos multijugador estén sincronizados entre todos los clientes, como el inicio de una partida o la actualización de estados.
- Efectos de retardo o espera: Con ellos se busca introducir pausas intencionadas para mejorar la jugabilidad o la narrativa, como esperar antes de mostrar un diálogo o activar un evento.
Teniendo en cuenta lo anterior, podemos distinguir los siguientes tipos de temporizadores (no excluyentes entre si):
- Ligados al tiempo de juego: Su avance trascurre al mismo ritmo que el tiempo del juego. Por ejemplo, si el tiempo del juego está ralentizado, por encontrarse en tiempo-bala, entonces el avance del temporizador se ralentizará de igual manera.
- Ligados al tiempo real: Toman su referencia del reloj del sistema y avanzarán de manera uniforme independientemente de que el tiempo de juego trascurra más lento.
- Cuenta regresiva: Comienzan con un valor y disminuyen hasta cero, desencadenando un evento al finalizar.
- Temporizadores cíclicos: Se reinician automáticamente para eventos repetitivos, como disparos automáticos o regeneración de vida.
Su implementación general es sencilla. Un temporizador no es más que una variable o sistema que registra el paso del tiempo, ya sea en segundos, milisegundos o ciclos de juego (frames). En la práctica, suelen implementarse como contadores que se actualizan en cada iteración del bucle del juego (game loop) o mediante funciones específicas proporcionadas por los motores de juego. Vamos a ver, algunas sus posibles implementaciones.
Implementación general
Todos los engines ofrecen una función que se ejecuta periódicamente. Unreal la llama Tick(), Unity Update() y Godot C# _Process(). A esas funciones se le suele pasar un valor, denominado deltaTime, que suele ser transcurrido entre esa llamada a la función y la anterior.
Suponiendo que llamásemos a esa función Actualizar(), podríamos utilizarla para crear un temporizador con el siguiente pseudocódigo:
Muchos engines ofrecen dos versiones diferentes de esa función periódica: una que se ejecuta al renderizarse un nuevo fotograma gráfico, y otra que se ejecuta tras periodos fijos de tiempo. El momento en el que se ejecuta la función que depende de los fotogramas se denomina idle frame, mientras que el momento en el que se ejecuta la función que sigue periodos fijos se denomina physics frame (porque esta última función periódica se usa para cálculos relacionados con la física del juego, mientras que la primera función suele ir más ligada a operaciones visuales).
Ten en cuenta que nuestra tarjeta gráfica no genera fotogramas a un ritmo constante. Por eso, a la hora de implementar temporizadores, es mejor utilizar la función basada en periodos fijos.
Implementación en Godot
Siguiendo su filosofía de "un nodo para cada tarea", Godot cuenta con un nodo temporizador especializado que, ¡oh, sorpresa!, se llama Timer. Este nodo es ideal, para usar temporizadores sin necesidad de implementarlos desde cero.
El nodo ofrece los siguientes campos para su configuración:
- Process Callback: Define si queremos que el avance del Timer se contabilice al generar los idle frames o los physics frames, según lo que vimos en el apartado anterior.
- Wait Time: Es el número de segundos de duración del temporizador.
- One Shot: Si está marcado el temporizador sólo funcionará una vez y se desactivará. Para que vuelva a contar, habrá que arrancarlo manualmente. Si marcamos esta casilla, el temporizador volverá a empezar desde cero, una vez que alcance el tiempo marcado, repitiendo ese ciclo indefinidamente.
- AutoStart: Si está marcado, el nodo empezará a contar conforme se introduzca en la jerarquía de la escena principal del juego. De otra manera, el nodo estará desactivado hasta que lo arranquemos manualmente.
- Ignore Time Scale: Si lo marcamos, el temporizador se ligará al tiempo real, mientras que si lo dejamos sin marcar estará ligado al tiempo de juego.
El nodo ofrece la señal timeout(), la cual se dispara cuando el temporizador alcanza el tiempo marcado en el campo Wait Time.
Señal timeout |
Configurando los campos del nodo Timer desde el inspector, y enlazando a su señal timeout() aquel método de otro nodo que queramos ejecutar al finalizar el tiempo, ya tendremos cubierta la mayor parte de los casos de uso de este nodo.
Este nodo también puede ser manipulado desde el código. Vamos a ver un ejemplo de uso. Imaginemos que queremos programar un cooldown, según el cual una vez que se ejecute un determinado método, este no debe poder ejecutarse hasta que finalice el tiempo del temporizador. Supongamos que ese método se denomina GetSteering() y que se llame desde el método _Process() de otro nodo.
Para arrancar el cooldown, podríamos llamar a una función de activación justo al final del método GetSteering().
Ejemplo de arranque de un cooldown |
La función de activación del cooldown se llama en este caso StartAvoidanceTimer() y se limita a arrancar la cuenta del nodo Timer y a fijar a verdadero una bandera:
Implementación del método de arranque del cooldown |
Si un Timer no tiene marcado el campo AutoStart, tendremos que llamar a su método Start() para que arranque, como se puede ver en la línea 235.
La bandera fijada a verdadero en la línea 236 se puede usar a modo de guarda al comienzo del método cuya ejecución queramos limitar.
Uso de la bandera como guarda para controlar si se ejecuta o no el método |
El ejemplo de la captura puede ser un poco alambicado si no se conoce el contexto del desarrollo en el que estoy trabajando, pero viene a funcionar calculando la ruta hacia un objetivo (línea 157). Si no hay otro agente con el que podamos chocar, y no hay activo un temporizador, se sigue la ruta calculada (línea 160). Si por el contrario hay un temporizador en marcha, se continua con la ruta de escape que se calculó en la anterior llamada a GetSteering() (línea 162). El método sólo se ejecuta por completo si se ha detectado una potencial colisión y no hay activo un temporizador, en cuyo caso se continua calculando una ruta de escape para evitar la colisión (línea 164 en adelante).
Sin un cooldown, mi agente detectaría una posible colisión por delante de él, calcularía un ruta de escape y giraría para evitar la colisión. El problema sería que en el siguiente fotograma ya no tendría el obstáculo delante de él (al haber girado antes para evitarlo) por lo que volvería a calcular la ruta más corta a su objetivo y giraría para encararlo, encontrándose de nuevo justo enfrente con el obstáculo que pretendíamos evitar. A partir de entonces se repetiría el ciclo. Para evitarlo, se calcula una ruta de escape y esta se sigue durante un tiempo (el de cooldown) para que el agente cambie sensiblemente de posición, evitando la colisión; entonces, y sólo entonces, se vuelve a reevaluar la ruta para alcanzar el objetivo.
La configuración del Timer se puede hacer desde el código del método _Ready() del nodo del que cuelgue el Timer.
Configuración de un Timer en el método _Ready |
Ignora las líneas previas a la 83, no tienen nada que ver con el Timer, pero las dejo para que veas un ejemplo real de uso de un Timer.
En la línea 83, cargo una referencia al nodo Timer que cuelga del nodo que ejecuta este script. Con esa referencia podríamos configurar todos los campos disponibles en el inspector del Timer. En mi caso no ha hecho falta, porque no tenía que variar la configuración del Timer que ya había metido en el inspector, pero sí que he enlazado manualmente un método a la señal del Timer (línea 84). En este caso, el método enlazado es OnAvoidanceTimeout(). Podría haberlo enlazado usando la pestaña "Node" del inspector, pero en este caso he preferido hacerlo por código.
El método OnAvoidanceTimeout() no puede ser más sencillo, ya que se limita a poner a falso el flag, para que en el próximo fotograma el método GetSteering() sepa que ya no queda ningún temporizador activo.
Callback enlazado a la señal del Timer |
Implementación en Unity
Unity no cuenta con componente especializado, como el nodo Timer de Godot, pero crearse uno es sencillísimo, haciendo uso de corutinas.
Vamos a ver cómo se podría crear un componente que emulase la funcionalidad ofrecida por el nodo de Godot. Podríamos llamar a nuestro componente CustomTimer:
Comienzo de nuestra clase Timer para Unity |
Los campos que podría ofrecer este componente serían los mismos que los del citado nodo.
Campos de la clase, ofrecidos al inspector |
Con esto, lo que veríamos desde el inspector sería lo siguiente:
Vista desde el inspector de nuestra clase CustomTimer |
Si autoStart estuviera fijado a verdadero, el componente se limitaría a llamar al método StartTimer(), desde el Awake(), conforme se arrancase el script.
Arranque del temporizador |
El método llamado en la línea 50 del método Awake se limita a arrancar una corutina y a guardar una referencia a ella para poder controlarla desde fuera (línea 55).
El cuerpo de la corutina lanzada es muy sencillo:
Cuerpo de la corutina del Timer |
La corutina se limita a esperar los segundos que hayamos fijado en el waitTime. En función del tipo de tiempo que queramos usar (tiempo de juego o tiempo real) se esperan segundos de juego (línea 65) o segundos de tiempo real (línea 68).
Pasado ese tiempo, se lanza el evento de timeout (línea 72).
Si el temporizador es de un sólo uso se finaliza la corutina (línea 74) o, en caso contrario, se vuelve a repetir el ciclo (línea 60).
¿Para qué sirve la referencia que guardamos en la línea 55 del método StartTimer()? Pues sirve para parar la corutina antes de tiempo, si fuese necesario, usando el método StopCoroutine.
Código para la parada anticipada del temporizador |
Un GameObject que tuviese un componente CustomTimer como el descrito podría hacer uso él, desde otro script de GameObject, sacando una referencia al componente:
Obtención de una referencia a CustomTimer |
A partir de esa referencia, el uso del Timer sería calcado al que ya vimos con el nodo de Godot.
Uso del componente CustomTimer |
Como el ejemplo es la versión en Unity de lo ya visto para Godot, no me voy a extender más.
Implementación en C#
¿Qué tienen en común Godot C# y Unity? Efectivamente, su lenguaje de programación: C#. Este lenguaje es multipropósito y viene con "pilas incluidas", es decir con un montón de módulos y librerías para hacer múltiples cosas. Uno de esos módulos ofrece un temporizador con funcionalidades muy similares a las ya vistas.
Si C# ya ofrece un temporizador, ¿por qué es tan habitual ver implementaciones en Godot y en Unity como las de antes? ¿por qué no usan directamente el temporizador de C#?
Creo que la respuesta más sencilla es para el caso de Godot. El lenguaje por defecto de Godot es GDScript y este no ofrece viene con "pilas incluidas" o, mejor dicho, estas son los miles de nodos que ofrece Godot. Por eso, Godot C# hereda el acceso al nodo Timer gracias a su disponibilidad para el código en GDScript.
El caso de Unity es más complicado de responder y creo que se debe al desconocimiento de lo que ofrece C# por si mismo. Además, ya hemos visto que crear un temporizador personalizado es muy sencillo si usamos un mecanismo tan familiar para los desarrolladores de Unity como lo son las corutinas. Creo que esa sencillez para crearse uno mismo su propio temporizador ha hecho que muchos no llegasen a indagar y a conocer lo que le ofrecía C# en este sentido.
El temporizador que ofrece C# es System.Timers.Timer y su uso es muy similar al que ya hemos visto en el caso de Unity y Godot.
Uso del temporizador nativo de C# |
En la línea 103 de la captura se puede ver que hay que pasarle a su constructor el tiempo que queremos que dure el temporizador.
El campo AutoReset de la línea 105 es completamente equivalente Autostart del nodo de Godot.
En la línea 106 se puede ver que el evento que lanza este temporizador es Elapsed (equivalente a la señal timeout del nodo de Godot). A dicho evento se pueden subscribir callbacks (como OnTimerTimeout) de manera similar a como hacíamos en las versiones anteriores para Godot y Unity.
Por último, este temporizador de C# también tiene métodos Start() (línea 117) y Stop(), para arrancarlo y pararlo.
La única precaución que hay que tener con este temporizador es que nunca debemos llamar a su método Start() si no estamos seguros de que haya acabado la cuenta anterior. Si lo que necesitamos es iniciar una nueva cuenta sin que haya acabado la anterior, primero deberemos llamar a Stop() y luego Start(). El problema es que este temporizador está optimizado para ser utilizado en entornos multihilo, así que cuando llamas a varios Start() seguidos no va finalizando las cuentas anteriores, sino que las va acumulando en hilos distintos, haciendo que las cuentas continúen en paralelo y que los eventos vayan saltando conforme dichas cuentas vayan finalizando (aunque el efecto visible será que los eventos saltarán sin una periodicidad evidente, por ser de cuentas diferentes).
Conclusión
Si programas en C# tanto en Godot, como en Unity, hay muy pocos argumentos para no utilizar el temporizador de C#. Es una versión ligera, eficiente y, al ser multiplataforma, te permitirá reutilizar código entre Unity y Godot con mayor facilidad.
La única razón que se me ocurre para seguir utilizando el nodo de Godot es para que pueda ser manipulado desde el inspector al integrarse con otros elementos del juego; pero aparte de eso, no veo la utilidad de seguir usando ni el nodo de Godot, ni los temporizadores basados en corutinas de Unity.