Mejorando Tu Engine II: Triggers
En esta nueva entrega de la serie “Mejorando tu Engine†explicaré algo que utilizo constantemente y de lo que no he leÃdo demasiado al respecto. Hay muchas situaciones durante la programación de un juego en que debe llevarse la cuenta del tiempo transcurrido desde un determinado momento para hacer algo cuando el contador se agote. Que un Ãtem parpadee durante unos segundos pasados unos momentos desde que fue creado y posteriormente desaparezca; que el jugador vuelva a aparecer después de haber muerto y sea invulnerable durante un par de segundos; que un enemigo que está siguiendo una ruta se detenga un tiempo a mirar antes de seguir con su ruta de waypoints (como en Commandos); una transición entre menús algo compleja, etc. Las posibilidades son infinitas. En estos casos crear estos contadores, mantenerlos actualizados y sobretodo decidir qué objeto debe mantenerlos, es engorroso, y cuando se necesitan muchas de estas cosas ocurriendo simultáneamente puede complicar el código innecesariamente. Si te encuentras en esta situación, los triggers vienen en tu ayuda.
¿Qué es un trigger?
Un trigger es un elemento no visible de la escena del juego que se compone básicamente de dos propiedades: un retraso y una duración. El trigger se encuentra pausado durante un tiempo equivalente a su retraso desde que es creado y se ejecuta durante un tiempo equivalente a su duración. Derivando la clase base Trigger podemos personalizar las acciones que queremos que un trigger realice al crearse, al iniciarse (cuando ha transcurrido su retraso), al actualizarse (cada frame mientras siga activo) y al terminarse (cuando su duración se agota). También podemos terminar un trigger prematuramente enviando un señal determinado (como si de un proceso UNIX se tratara).
La palabra trigger se utiliza mucho en los editores de niveles de algunos juegos para definir este tipo de comportamiento, sólo que ahà suelen venir una serie de triggers predefinidos. De hecho saqué esta palabra del editor de niveles de StarCraft, donde podÃas definir cuándo querÃas que ocurrieran ciertas cosas, como que aparecieran nuevas unidades o enemigos en un determinado lugar y momento de la partida.
Quizá se vea mejor con un ejemplo. Estamos programando un juego de rol y queremos que al matar a un enemigo éste deje caer un Ãtem, quizá un arma, que el jugador pueda coger. También queremos que pasados cinco segundos, el Ãtem parpadee durante dos segundos, pasados los cuales desaparecerá si no ha sido cogido antes por el jugador. ¿Cómo serÃa nuestro trigger?
Nombre: PickableItem
Retraso: 500 (recordemos que en nuestro motor el tiempo se mide en ticks, y 1 segundo son 100 ticks).
Duración: 200
Acción al crearse: Crear un Ãtem y ponerlo en el sitio adecuado.
Acción al iniciarse: Iniciar un WaveformIpol de tipo Square que nos interpole un valor entre 255 y 128 con un periodo pequeño. (si esto te suena a chino échale un vistazo al artÃculo sobre sistemas de partÃculas).
Acción al actualizarse: Asignar el valor que estamos interpolando al componente alpha (transparencia) de nuestro item.
Acción al terminar: DestruÃr el Ãtem.
En caso de que el jugador colisione con el Ãtem sólo tenemos que lanzar una señal al gestor de triggers para que termine éste prematuramente.
Hay gente que llama eventos a lo que yo llamo triggers, pero yo llamo evento a otra cosa de la que quizá hable en otro artÃculo de esta serie.
Muy bien, ¿Y cómo se programa esto?
Una de las virtudes de los triggers es su simplicidad. El sistema es realmente sencillo de programar y de utilizar, ya que se basa en componentes muy básicos de los que luego derivamos a placer para implementar cosas más complejas, del mismo modo que hacÃamos con los affectors de los sistemas de partÃculas que vimos en el primer capÃtulo de esta serie.
Pasemos a ver el código y veréis como todo se entiende mucho más.
class Trigger
{
enum State
{
IDLE, // Antes de llamar a Start()
STARTED, // Después de llamar a Start()
KILLED, // Ha sido pausado manualmente y espera ser eliminado
};
Trigger(int theDelay, int theDuration, int theKillCode = -9, bool callEndOnKill = false);
virtual void Start() {}
virtual void Update() {}
virtual void UpdateF(float theFrac) {}
virtual void End() {}
int mDelay;
int mUpdateCnt;
int mDuration;
int mKillCode;
bool mCallEndOnKill;
State mState;
};
Como véis, es muy sencillo. Ya explicamos en el primer artÃculo la necesidad de tener dos métodos de actualización (Update() y UpdateF()). La propiedad mCallEndOnKill sirve para controlar si, en caso de que paremos el trigger manualmente, por ejemplo si el jugador abandona la partida, debemos llamar al método End() o no. Esto interesa o no dependiendo del trigger, por lo que es personalizable. También podemos personalizar el código que tendremos que pasarle al TriggerManager para pausar este trigger, aunque por defecto es -9 como homenaje al gestor de procesos de UNIX, del que tomé prestada la idea.
El TriggerManager es tan sencillo y obvio como cabrÃa esperar.
class TriggerManager
{
virtual void Add(Trigger* theTrigger, bool isGameTrigger = false);
virtual int Kill(int theKillCode = -9);
virtual void Update();
virtual void UpdateF(float theFrac);
TriggerList mTriggers;
TriggerList mGameTriggers;
bool mTriggersPaused;
bool mGameTriggersPaused;
};
Como varios triggers pueden tener el mismo KillCode, el método Kill() devuelve el número de triggers que se pausaron. Seguro que lo único que extraña de este código es que los triggers están separados en dos categorÃas, “game triggers†y triggers a secas. Esta separación la hice a posteriori y, como otros cambios que he ido haciendo en mi motor, fue a causa de ciertos problemas o requisitos encontrados cuando lo utilizaba para implementar un juego. Game triggers son aquellos que afectan a la jugabilidad del juego, mientras que el resto son… bueno, aquellos que no la afectan, como por ejemplo un trigger que controle una transición entre menús. ¿Por qué es necesario diferenciar entre ambos tipos? Muy sencillo, cuando estás jugando y quieres pausar toda la lógica del juego, ya no basta con simplemente poner una variable booleana a verdadero en tu bucle de juego que indique esta pausa, porque ahora nuestra aplicación funciona de otra forma. La lógica de juego está desperdigada entre muchas clases, algunas de ellas serán triggers que nosotros no controlamos directamente, se autocontrolan. Luego tenemos que proporcionar una forma de pausar esos triggers. Pero, ¿y si además de pausar la lógica de juego quiero poder utilizar triggers para otras cosas, como mover elementos de interfaz o hacer un pequeño fade a una pantalla negro translúcida con un texto que diga “PAUSED†(como en Zuma)? Recordemos que la idea es poder utilizar triggers para todo aquello que requiera un control del tiempo. De ahà la separación en dos categorÃas, de la necesidad de pausar la lógica de juego en determinados momentos.
Lo único interesante del código del manager es el algoritmo de actualización, que además es útil para conocer algo más del funcionamiento de los triggers.
void
TriggerManager::Update()
{
Iterator mIter = mTriggers.begin();
while (mIter != mTriggers.end())
{
Trigger* t = *mIter;
if (t->mState == Trigger::KILLED)
{
SafeDelete(t);
mIter = mTriggers.erase(mIter);
continue;
}
if (t->mStartDelay)
t->mStartDelay--;
if (t->mStartDelay < = 0 || t->mState == Trigger::STARTED)
{
if (t->mState == Trigger::IDLE)
{
t->Start();
t->mState = Trigger::STARTED;
}
t->Update();
t->mUpdateCnt++;
if (t->mDuration != -1 &&
t->mUpdateCnt >= t->mDuration &&
t->mState != Trigger::KILLED)
{
t->End();
SafeDelete(t);
mIter = mTriggers.erase(mIter);
continue;
}
}
++mIter;
}
}
De este código podemos sacar un par de conclusiones.
- Puede darse el caso, según los valores de retraso y duración del trigger, que en un mismo frame se llame al método Start() del mismo, luego a Update() y luego a End(); sin llegar nunca a ejecutarse el método UpdateF() al que se llama desde otro método del manager (se recorre cada trigger y si está en estado STARTED, se llama a su método UpdateF()).
- Si indicamos que la duración de un trigger es -1, este nunca morirá de “forma naturalâ€, tendremos que hacerlo manualmente o, en la mayorÃa de los casos, introduciremos una condición en la lógica del trigger para que esto ocurra (simplemente asignando su duración a cero). Por ejemplo, si creamos un trigger cuya condición para terminar no sea durar un cierto tiempo sino que el jugador entre en una determinada zona de la pantalla.
Uso del sistema
Como siempre, buscamos la facilidad de uso, y nuestro sistema de triggers es todavÃa más sencillo de utilizar que el motor de sistemas de partÃculas que hicimos en el anterior artÃculo. Aquà simplemente tenemos que añadir el trigger al manager y despreocuparnos de él, a no ser que queramos terminar con él prematuramente.
TRIGGER_MGR->Add(new MyOwnTrigger(my, custom, data));
Si luego queremos acabar con él sólo tenemos que hacer.
int numTriggersKilled = TRIGGER_MGR->Kill(MyOwnTrigger::mKillCode);
Pues esto es todo por hoy. Un sistema de trigger es algo muy sencillo de implementar pero que sin duda facilita mucho las cosas a la hora de separar luego la lógica de juego en pequeños componentes en vez de tenerlo todo en una misma clase gigantesca, donde serÃa difÃcil “seguir el hilo†del programa.
Podéis bajar el código de este artÃculo desde aquÃ.
September 23rd, 2005 a las 5:52 pm
Muy interesante, sobretodo lo de la separación de triggers de juego y otros. Yo lo hago de una forma similar pero desde otra perspectiva . Tengo dos tipos de triggers, uno normal (el que llamas tu game trigger) y otro del GUI.
El normal lo trato como una entidad más de las escena, esto es:
MainScene.AddObject(Trigger(time,callback));
lógicamnete este trigger hereda de Object y tiene los tÃpicos métodos de Update y Render (que no se usa lógicamente). Cuando se pausa el juego tb se pausa el trigger.
Luego tengo el GUITrigger que es igual pero que es gestionado por el GUIManager y que se añade como un widget más.
De esta forma separo los triggers del juego a los triggers de la lógica adicional que tanto hace falta.
el código (en python):
class Timer:
def __init__(self,world,time,callback):
self._totime = time;
self._time = 0;
self._callback = callback;
self._world = world;
world.AddObject(self);
self._cleared = 0;
def Update(self,dt):
self._time +=dt;
if(self._totime
September 25th, 2005 a las 12:02 pm
Muy interesante.
Se parece en parte a mi sistema de Tareas (Tasks) en el cual cuelgo diferentes actividades que se tienen que realizar en el bucle principal, y cada tarea tiene su variable de tiempo entre ejecución o tiempo de vida.
Si quiero hacer un trigger simplemente cuelgo la tarea en el bucle. Además me iba genial para el tema del bucle de render, porque permite ordenar las diferentes actividades por prioridades.
September 26th, 2005 a las 3:14 pm
@ethernet: la verdad es que está bien la idea de hacer que los triggers sean objetos de escena, el problema es que mi objeto de escena más básico tiene propiedades de objeto “visible”, ya que es lo único que pensé que tendrÃa la escena (posición, orientación, escalado..).
@tamat: ya me enseñaste hace tiempo tu gestor de tareas y está muy chulo. ¿Para qué más lo utilizas o podrÃas utilizarlo? ¿Una tarea es una calse derivada con una serie de métodos comunes, como los triggers? ¿Puedes poner un ejemplo de uso?