lunes, 18 de marzo de 2019

RPGs de la vieja escuela - estudio de estrategias de renderizado

Este es un estudio que hice acerca de cómo renderizan los gráficos, los juegos RPG de la vieja escuela, sobre todo los de origen japonés, al menos en la pantalla de exploración.

Trato de implementar las mismas características que observo que tenían los títulos que jugué durante mi niñez. Para así tratar de recrear las mismas técnicas, que incluyen las mismas limitaciones también, que tenían los motores de esos juegos.

Todo está basado en la observación. Siendo esos títulos de código cerrado, no se puede simplemente buscarlos en github y leerlos. Existen a día de hoy implementaciones de código abierto, pero nada nos garantiza que estas se parezcan a las comerciales.

En las implementaciones que vienen a continuación, usé muchos arrays dinámicos y listas, lo que estoy seguro no hacían los títulos que trato de analizar, debido a limitaciones del hardware de las viejas consolas de sobremesa como el SNES. Así que lo que importa es analizar la técnica, y no la implementación, que además no me tomo el trabajo de optimizar para velocidad de ejecución, aunque sí me preocupé por su legibilidad.

No les garantizo que los demos que vienen a continuación se reproducirán correctamente en smartphones. Si se reproducen no podrán controlar al personaje principal puesto que no implementé controles táctiles, y creo que no lo haré en el futuro tampoco. Celulares de generaciones anteriores seguramente ni podrán mostrar esta página correctamente. (Implementé controles táctiles crudos a último momento)

Me doy cuenta que extraer el código JavaScript del estudio es todo un desafío si está mezclado con todo el código de blogger.com. Por lo tanto dejo un enlace a una versión de todo el estudio que es mucho más fácil de analizar Descargar Aquí.

Empecemos.

Un repaso de los conceptos básicos

Todo el que ha usado algún programa de edición de imágenes o dibujo, más allá del Microsoft Paint, sabe lo que son las capas.

Más relacionado con la programación, y por eso espero que sea menor el número de personas que ya lo haya oído antes, es el algoritmo del pintor.

En 3D, los objetos están representados por polígonos, en concreto triángulos. Aquí, los que trabajan en el área del diseño 3D pueden discrepar, diciendo que los polígonos no tienen por qué ser triángulos. Pero el que ha usado APIs como OpenGL o Direct3D sabe que los llamados N-Gons, deben ser partidos en triángulos para ser enviados a la gráfica. El triángulo nos garantiza que sus 3 vértices estarán en el mismo plano, aún si hubiera imprecisiones con los tipos de datos de punto flotante. Tres vértices es el mínimo necesario para decir que tenemos un plano con una superficie definida, el problema con una línea (dos vértices) es que puede pertenecer a un número infinito de planos, si agregamos un tercer vértices limitamos la figura a un plano específico y ya podemos empezar a definir la superficie de los objetos que queremos representar.

En 3D no es siempre necesario dibujar los objetos desde el fondo hacia adelante, como sí se lo haría con el algoritmo del pintor. La razón de esto es que un triángulo, viviendo en el espacio tridimensional, puede estar parcialmente intersectando a otro, por lo cual dibujar un triángulo y otro después, como se lo podría hacer con el algoritmo del pintor, no funcionaría para representar correctamente todas las posibles interacciones que pueden darse en el espacio tridimensional.

Con test de profundidad.

Algoritmo del pintor sin test de profundidad.

El Depth Buffer, o búfer de profundidad, también llamado z-buffer mientras que la técnica de emplearlo sería z-buffering, nos permite llevar un registro del valor de profundidad de cada píxel del lienzo de dibujo. Haciendo posible composiciones complejas como caras que interceptan otras caras. La precisión de este búfer está limitada al tipo de datos usado para cada valor individual, por lo que su precisión jamás será infinita, así que, según la profundidad mínima y máxima que tengamos en una escena, deberemos dar un uso inteligente a este búfer.

Sin este test de profundidad, que típicamente se hace por píxel, tendríamos que hacer algo parecido al algoritmo del pintor, dibujar los objetos lejanos primero, y los más cercanos al último. Para ciertos tipos de escenas, con el objeto de evitar el consumo extra de recursos de usar este búfer extra, todavía es posible dibujarlas correctamente, pero interacciones complejas entre la geometría de la escena no serán representadas correctamente. Además de que el ordenamiento de los objetos que componen la escena, deberá repetirse constamente por cada cuadro (frame) a dibujar, pues su orden, en 3D, variará según la posición actual de la cámara. Este ordenamiento tiene su costo computacional también.

Ahora bien, habrán notado que el dibujar, correctamente, una escena con datos tridimensionales requiere un montón de fuerza bruta. De cálculos y test de condiciones que se hacen por vértice e incluso por píxel. Es esta la razón de que el renderizado por software de estas escenas, si bien es posible, requiere de hardware especializado para realizarse lo suficientemente rápido como para dibujar en tiempo real una escena compleja como las de los juegos tripe A de hoy día.

¿Nunca les pasó, con su ordenador recień comprado, que les parece impresionante su rendimiento hasta que lo prueban con un juego de estos? Si les pasó lo más probable es que estén usando la gráfica onboard, o en la actualidad lo correcto sería decir la gráfica integrada en el procesador, que suele quedar muy por debajo en rendimiento que una gráfica comprada a parte e instalada en una ranura PCI-Express. AGP en mis tiempos. También existen diferentes gamas de tarjetas gráficas, que no vamos a discutir ahora, sólo decir que mirar a qué generación pertene una gráfica no es garantía de su superioridad.

El hardware 2D, que precedió al 3D, también ofrecía aceleración en operaciones como el mapeo de un área de la memoria de vídeo de una hubicación a otra. Esto se usa constantemente para el algoritmo del pintor.

Después de este repaso de cómo se hacen las cosas en 3D, volvamos al 2D.

Si bien en su momento existió el hardware que soportaba sprites, hoy día no sería práctico. Imponía condiciones como limitar el tamaño que el sprite podía tener. Usar una textura es lo más práctico con el hardware actual, que es lo suficientemente rápido como para componer una escena con un número cada vez mayor de texturas. Pudiendo emular los viejos sprites sin tener un juego de instrucciones y área de memoria dedicados a ellos.

Así que actualmente se usa el término sprite para referirse a objetos representados con mapas de bits, típicamente con áreas transparentes y animados, típicamente por la técnica del cuadro por cuadro, aunque ahora hay otras maneras de animar además del cuadro por cuadro. Sobre todo porque ahora los sprites son texturas mapeadas sobre un polígono, así que es barato aplicarle transformaciones a ese polígono, como rotaciones y eso. Si dividimos un sprite en partes, y rotamos esas partes, podemos tener un personaje que mueve sus extremidades sin recurrir al cuadro por cuadro.

Capas fijas

Mucho antes de que existiera esto del z-buffering, o al menos de que fuera viable en términos de velocidad de ejecución, lo único práctico era recurrir al algoritmo del pintor y dibujar los objetos empezando por el fondo.

Como ya se dijo, el ordenamiento constante de los objetos que componen la escena impone su propio costo computacional. Cuando los recursos son limitados sería mejor no ejecutar nunca dicho ordenamiento, o ejecutarlo una única vez al cargar los datos de un escenario.

En los RPGs 2D de la vieja escuela se observa una serie de interacciones entre los objetos que componen una escena que nos permite deducir cómo está implementado su código de dibujo.

En muchos de estos juegos, como los Final Fantasy del SNES, se observa que los personajes jamás pasan detrás de ciertos objetos. En las pocas situaciones que sí lo hacen, parece que la caja de colisión del objeto fuera más grande de lo que su aspecto visual nos sugiere.

En estos juegos el escenario solia estar representado en "tiles". Los tiles eran convenientes en el mundo del 2D por muchas razones. Eran baratos computacionalmente. Eran fáciles de implementar, comparados con otras opciones, lo que resultaba en menos horas de depuración. Como un mismo tile se reutilizaba muchísimas veces en un escenario, se podía tener escenarios más extensos por menos costo de memoria.

El esfuerzo artístico también estaba bien equilibrado. Aquí quiero hacer un paréntesis. En los juegos 3D de hoy día cada escenario suele ser un proyecto en sí mismo, se lo empieza de cero, y toda su geometría debe ser modelada con buena intuición artística y un mínimo de coherencia técnica. El resultado final es asombroso, si el equipo de desarrollo cuenta con las habilidades necesarias, pero require de un esfuerzo importante para su producción. Con los tiles, por otro lado, el esfuerzo artístico está volcado principalmente a la creación de los tiles. Luego, éstos son puestos en cierto orden para crear un escenario con ellos. La creación del escenario también requiere de cierto talento, o no se verá demasiado bien a pesar de que la calidad de los tiles ayude, pero creo que nadie me discutirá que el esfuerzo es mucho menor que con los escenarios en 3D.

El mundo de los juegos es complejo. No trato de generalizar a todos los juegos que existen con el párrafo anterior.

Si han jugado algún Final Fantasy del tiempo al que me refiero, notarán que los escenarios suelen ser algo raros. Se ven más como laberintos y no parecen estar modelados siguiendo conceptos arquitectónicos de ningún tipo. Las casas no tienen baño, y muchas veces no tienen habitaciones con roles bien definidos. Esto fue mejorando con el lanzamiento de cada nuevo título. Las casas del Final Fantasy 6 (el de Terra y Celes) sí parecen casas, y son bastante coherentes por dentro también. Siguen sin tener baño, claro. Se tomaron más libertades con los castillos, pero ¿quién puede decir cómo es un castillo de un mundo paralelo?

La serie Final Fantasy siempre tuvo un presupuesto muy importante, por lo que iban un poco más lejos que la competencia. Así que mejores ejemplos serían otros títulos del género, de otros desarrolladores de la época. En los que un castillo podía ser un laberinto más, sin rol aparente ninguno para sus habitaciones más que el de esconder aquel cofre de tesoros con aquella espada tan poderosa dentro.

Estos laberintos cumplían, no obstante, su función de ofrecer al jugador un desafío de exploración. Para que el juego no fuera sólo batallas aleatorias. Habían tesoros ocultos en caminos menos obvios. Que podías no encontrar si no tenías la paciencia de explorar todos los caminos. Esto rompía la linealidad del juego, y hacía más creíble la inmersión en el mundo de fantasía. Sin la exploración, por simple que fuera, el juego sería una novela visual más que un RPG. La coherencia del escenario presentado, y su correspondencia con nuestra realidad, era totalmente secundaria. No hay que olvidar lo barato que es agregar horas de juego por este medio.

Implementación 1: una capa de tiles y una de sprites

Si bien no puedo ofrecer ninguna evidencia de cómo están escritos los motores de juegos comerciales, por lo que se observa, no se espera que cada tile tenga una caja de colisión. De hecho, no se espera que estos juegos siquiera tuvieran el concepto de cajas de colisión. El propósito de los tiles es que sean baratos computacionalmente.

Sí se espera que dividieran la escena en algún tipo de estructura, con el propósito de reducir la cantidad de objetos a procesar antes de actualizar la pantalla. Probablemente un arreglo bidimensional de punteros al tipo de datos usado para representar a los personajes era la estrategia que primaba. Así era muy rápido y barato seleccionar porciones rectangulares del escenario para luego dibujarlas en pantalla sin tener que recorrer la lista entera de objetos cargados en memoria, que podía contener objetos fuera del área visible de la pantalla.

El mapa de tiles que componen lo que vendría a ser el terreno, ya es de por sí esta estructura, por lo que seleccionar porciones rectangulares de este mapa es muy fácil e intuitivo, no requieriendo más esfuerzo por nuestra parte. Pero los sprites todavía pueden necesitar de una estructura así para no tener que procesar sprites fuera del área visible de la pantalla.

Para los sprites, en este demo, como su número es muy reducido, no me tomé la molestia de implementar ninguna estructura para organizar la escena. Sólo los pongo a todos en una lista y los dibujo en su capa correspondiente sin preocuparme por su ordenación previa.

La única ordenación que se garantiza es que los tiles con que está construído el terreno se dibujarán primero. Antes que cualquier sprite.

Un ejemplo de juego comercial que pudo haber usado esta estrategia es el primer Zelda de 1986.

No voy a debatir si Zelda era o no un RPG. Para mí, su modo de exploración, que es el único modo que tiene, vale por uno de cualquier RPG. Así que nos vale.

Nótese lo siguiente. Para dar la idea de que los árboles se levantan en el eje vertical del mundo, a pesar de que ocupan un tile exacto, se dibuja al personaje, Link, con un poco de corrimiento en el eje vertical del lienzo de dibujo. Es decir, el personaje no pisa en el borde inferior de los tiles, sino cerca del medio del tile, esto permite que la cabeza de Link se superponga a los árboles y rocas, dando la idea de que tienen volumen y Link choca con ellos y por eso no los puede atravesar. No queremos que el jugador piense que están dibujados en el suelo, queremos que piense que son objetos con volúmen.

La ilusión de realismo se compromete cuando estamos al norte de un árbol. Lo esperado es poder avanzar en la dirección sur hasta donde el ancho del tronco del árbol nos permita, por lo que Link debería quedar parcialmente obstruído por el árbol. Situación inversa de lo que ocurre cuando estamos al sur del árbol. Pero esto no es posible. ¿Por qué? Porque las capas están fijas. No hay ordenamiento ninguno. El sprite de Link existe en una capa que está por encima a la de los árboles. Y esto no cambia incluso cuando Link está más al norte que un árbol. Sus capas están fijas.

Por lo tanto, nuestra primer implementación tendrá estas mismas limitaciones. La única forma de dibujar un árbol que no ocupa totalmente un tile, es que este tile de árbol ya tenga el fondo también. Esto nos obliga a tener diferentes versiones del tile de árbol por cada posible fondo (hierba, arena, barro, etcétera).

También, al igual que ocurre con los primeros Final Fantasy, y Zelda. El espacio que ocupan objetos como los árboles, cuyo ancho sugerido es menor al de un tile, se sentirán raros, pues no podremos invadir su tile, comprometiendo la ilusión de que es un objeto que se levanta verticalmente, y delatando de que en realidad está dibujado en el suelo.

Como ya se dijo, haciendo que los personajes se paren en el centro de un tile, y no en el borde inferior, lograremos una ilusión de 3D, pero que sólo se apreciará cuando le venimos a un tile por el sur. Además, debido a cómo está encajado el árbol multi tile que utilicé, este fenómeno no se apreciará con el tronco de ese árbol, así que muevan al personaje al muro de piedra superior, en el que sí podrán apreciar esto.

También, por imitación de estos títulos, aunque no es el caso de Zelda pero sí los Final Fantasy, hice que el personaje sólo pueda detenerse en el centro de un tile. Es decir, sólo podremos desplazarnos hasta el centro de un tile, no siendo posible detenerse a mitad de camino.

El motor, en su presente versión, no es capaz de dibujar cosas por encima del personaje principal como nubes o polvo, o la sombra de un dragón volando, como sí lo hacía el juego Chrono Trigger, por ejemplo. Ya llegaremos a eso luego.

Se maneja al personaje principal con las flechas del teclado. Las únicas teclas que funcionan son las flechas izquierda, derecha, arriba y abajo. No se ha implementado ningún otro control.

Está hecho con dos assets de opengameart.org, y sus licencias requieren que se acredite a los creadores.

El sprite del personaje principal vino de aquí

Bajo licensia CC-BY 4.0. Fue creado por Wind astella como un test del motor Cycles de Blender para la creación de sprites. Como fue hecho con Blender, seguramente esté matemáticamente correcto, mientras que los sprites de los títulos que estamos estudiando eran hechos a pura intuición de los artistas y muchas veces sin utilizar ninguna de las perspectivas que se enseñan en dibujo. Más sobre las perspectivas luego.

Los tiles del terreno vinieron de aquí

Bajo licensia CC-BY. Fueron creados por Gaurav Munjal para su proyecto de RPG de la vieja escuela usando HTML5 y Canvas.

En esta primera implementación, no serán capaces de colocar al personaje atrás de las hojas del árbol, como esperaríamos que ocurriera al ser un árbol que ocupa varios tiles. La razón es que esta primera versión del motor no es capaz de componer tiles complejos por superposición de tiles más simples.

La transitabilidad de los tiles del terreno, está dada por un valor numérico que cada tile tiene asignado, y que determina si se puede pasar a través de ellos o no. No hay chequeo de colisiones entre los sprites, y por ahora no hay forma de informar que un sprite ya está ocupando un tile, cosa que estos títulos sí podían hacer, pero nosotros estamos analizando cómo dibujaban, no su física.

Implementación 2: 4 capas de tiles y 1 de sprites

Hay varios problemas que títulos más modernos resolvieron de una u otra forma.

Estaba el problema de no poder pasar detrás de los objetos, era muy molesto visualmente el no poder pasar por detrás de los árboles, sobre todo si estos excedían el tile de alto. Como ya se dijo, comprometía la ilusión de que estabamos interactuando con objetos en un mundo de 3 dimensiones.

También estaba el problema de que si queríamos el tile del árbol con fondo de hierba y el mismo árbol con fondo de arena, debíamos desperdiciar memoria teniendo dos versiones del tile. Para el caso de árboles de más de un tile de alto y ancho, como el que usé para el demo, la cantidad de tiles necesarias para tener diferentes fondos crecía exponencialmente.

Ambos problemas fueron resueltos pero sólo parcialmente.

Parece ser que los desarrolladores de estos juegos, sobre todo los que tenían base en Asia, le tenían fobia a tener objetos con una propiedad z, como el z-index de CSS. O tal vez era el hardware el que le tenía fobia. Ya que no debía ser nada barato, computacionalmente, implementar un orden z para esas consolas.

Los sprites y los tiles siguieron teniendo capas fijas. Pero a partir de ciertos títulos se comenzó a apreciar que el número de capaz había aumentado, permitiendo composiciones más complejas.

En Final Fantasy V ya se podía pasar por detrás de algunos árboles.

La copa de los árboles de más de un tile de alto pasó a estar en una capa superior a la de los personajes. Mientras que el tronco permaneció en la capa inferior a la de los personajes. Esto permitió, finalmente, pasar por detrás del árbol, y defender así la ilusión de 3D, pero todavía tenía ciertas limitaciones.

Como las capas seguían fijas, ningún tile transitable podía estar por encima del personaje. El tile de la copa del árbol podía ir en la capa superior, porque el pesonaje jamás necesitaría estar sobre él, pero el tile del borde de una saliente, por ejemplo, no podía ir jamás en la capa superior, o se vería muy extraño cuando el personaje se aproximara a él por el sur.

Por lo tanto, algunos tiles siguieron estando ya con el fondo integrado, pues no era práctico intentar una composición de varias capas.

En mi implementación me tomé algunas libertades, le di dos capas por dejabo del personaje, y dos por encima. Esto lo hice para permitir tiles compuestos tanto por debajo como por encima del personaje, pero estoy seguro de que los juegos del tiempo que estamos analizando no podían tener tantas capas. Estoy muy seguro que tenían máximo dos capas, una por debajo del personaje y otra por encima. Por lo que sólo algunos tiles podían ser compuestos, como el de la copa del árbol, que no necesitaba incluir el fondo, pudiendo tener píxeles transparentes. Pero el tronco del árbol todavía debía incluir el fondo.

Para el caso de Final Fantasy V probablemente habían más de 2 capas, o bien existían casos especiales, pues ciertos escenarios tenían nubes que pasaban por encima del jugador y de todos los tiles del terreno. Sólo puedo concluír que había una segunda capa para sprites por encima de todas las demás.

Mi implementación es barata para la capacidad de los equipos de hoy día, pero no sé si fuera posible tantas capas con el hardware de aquel entonces. Por algo no se hacían así las cosas.

Sin más, el demo de la segunda implementación. Ahora podrán pasar por detrás del árbol. Sin embargo observen que no podrán pasar por detrás del terreno elevado al sur. ¿Por qué? Porque las capas son sólo para la composición visual de tiles, el terreno todavía está hecho como si fuera un mapa de dos dimensiones, si no fuera así no sería como los juegos que mencionamos.

El tile del borde de la elevación no es transitable. Ni si estamos en el terreno bajo ni si estamos sobre la elevación. Ahora mismo no hay forma de subir a esa elevación, pero sería fácil implementar un camino, si lo hiciéramos, veríamos que uno no puede pararse bien en el borde de la elevación. Ya que un tile sólo puede ser transitable o no transitable, y tuvimos que elegir hacerlo intransitable.

Por lo tanto, las capas múltiples son un beneficio sólo visual. No cambian en nada la mecánica de lo que es un mapa de tiles.

Todavía no podemos tener nubes que se superpongan a todo, eso lo dejamos para la tercera implementación.

Implementación 3: número configurable de capas de tiles y sprites

Aunque dudo que los motores de la época ofrecieran esta posibilidad, con el hardware actual es trivial implementarlo.

Esta última implementación no trata de imitar las limitaciones de los juegos de los que venimos hablando, sino que parte de lo hecho hasta ahora y se toma tantas libertades como los equipos actuales le permiten.

Ya podemos tener nubes en la capa superior o lo que sea que queramos. No estamos obligados a tener los sprites entre capas de tiles. Podemos crear las capas que necesitemos, sabiendo que cuanto más capas, menos eficiente será el motor a la hora de dibujar la escena en pantalla. Pero si no somos capaces de resolver una situación con 4 capas, pues tendremos que agregar una quinta, al menos tendremos esa opción.

Se mantienen algunas limitaciones de la implementación original, como el hecho de que la transitabilidad de un tile sigue determinándose como si todos los tiles estuvieran en la misma capa.

En la implementación anterior, la capa superior determinaba la transitabilidad de un tile. Esta vez hemos implementado un algoritmo diferente. La transitabilidad será determinada por el tile con valor más alto posible. Es decir, si un tile tiene el valor 0 (transitable normalmente) y otro tile en una capa superior pero con las mismas coordenadas [x,y] tiene el valor 1 (transitable volando) se asume que todo el tile para esa coordenada tendrá un valor de 1, o sea, transitable sólo volando. Aunque pongamos el tile con valor 1 en la capa inferior y el de valor 0 en la capa superior, el valor devuelto para esa coordenada seguiría siendo 1.

Hemos fijado la cámara para que el sprite del jugador esté siempre en medio.

Hemos expandido el escenario para permitir subir a una de las elevaciones.

Nuestro motor todavía no entiende el concepto de ordenación z, y es que llegados a este punto nos hemos dado cuenta de una cosa. Implementar la ordenación z agrega tiempo de desarrollo, con todos los costos asociados a ello. Para cumplir con la filosofía de mantenerlo simple, hemos decidido respetar el uso de capas fijas, que va de la mano con el uso de un mapa de dos dimensiones donde se dearrolla la acción.

Con esto, las elevaciones del terreno son meramente gráficas, sugestión visual para el jugador. Pero el juego opera internamente como si todo fuera un mapa plano.

Ahora que tenemos un número configurable de capas, es posible tener personajes, como enemigos gigantes, que excedan el tile de altura. Lo vamos a tener que partir al menos en dos sprites, uno en la capa del jugador y otro en la capa sobre los tiles de las copas de los árboles. Debemos hacerlo así porque sino interactuaría de forma extraña con esos tiles. Pero por lo menos es posible si alguna vez lo necesitamos.

Esto de partir los sprites grandes en partes es una solución parcial para evitar tener que pasar a implementar ordenación z. Un sprite de un troll gigante interactuará bien con el personaje principal que es de un tile de alto, pero no lo hará con otro troll gigante. Así que estamos un poco limitados a este respecto.

Ocurre que el que no se pueda pasar por los tiles del norte de una elevación, es en realidad una característica accidental de nuestro motor. El jugador ya sabe que estos juegos operan de esta manera, y no espera que haya un tesoro oculto detrás de una elevación, pues no se puede pasar detrás de una. Si cambiamos esto, puede que aumentemos el realismo pero... ¿estamos haciendo feliz al jugador?

Otra situación que podemos resolver con un número configurable de capas es la de tener un mapa de tiles sobre otro mapa de tiles, por ejemplo, un edificio con pisos de cristal, donde se debe poder ver el piso de abajo.

Una forma de lograr esto es, cargar el mapa transitable como lo haríamos normalmente y luego cargar el piso de abajo como tiles de decoración, con valor 0 para permitir la máxima transitabilidad. Si en algún momento queremos que se pueda jugar en el piso de abajo, deberemos recargar todo el escenario, por ejemplo al salir de un ascensor, pero esta vez los tiles de decoración de antes serán el mapa transitable.

Pude haber guardado la información de transitabilidad en un array separado. Este diseño habría permitido usar un mismo tile como pared y como decoración según el caso. No lo hice así, sino que la información de transitabilidad está en la paleta de tiles. Esto nos obliga o bien a modificar el diseño o bien a crear un segundo juego de tiles, que sea una copia del primero, pero con el valor de transitabilidad al más tolerante posible.

Implementación 4: Bits de elevación (Elevation Bits)

A la implementación 4 le introducimos un pequeño hack para soportar lo que he llamado Bits de elevación.

Aunque tiene un gran nombre, en realidad el nombre más honesto sería "dígito de elevación", ya que en vez de trabajar con valores hexadecimales y operadores bitshift, fui perezoso y usé base 10 y divisiones.

En general uno esperaría que bitshift sea la forma más rápida de extraer un bit de una posición concreta, pero mientras lo estaba escribiendo perdí un poco la motivación y terminé usando una división seguida de una llamada a función y luego de una multiplicación. Al menos que el motor JavaScript sepa optimizarlo, son tres operaciones versus una, así que no es la manera más óptima de hacerlo.

Pero no todo es malo. Lo bueno de extraer dígitos en vez de bits es que tenemos muchos más valores posibles.

Este Elevation Bit no creo que llegara a usarse en la época del SNES. Ni técnicas parecidas. De haberlo hecho ellos sí hubieran usado bitshift como corresponde, claro, ya que cada nanosegundo que se pudiera ahorrar contaba.

Sólo lo estoy incluyendo porque me pareció una buena idea. Y una posible evolución de las técnicas consideradas hasta ahora si se hubieran seguido trabajando.

El Elevation Bit permite saber la altura de un tile. Esto no afecta para nada el orden de dibujo, ya que los tiles se dibujan según en qué capas se encuentran. Pero un problema que resuelve el Elevation Bit es el de los espacios sin aprovechar de algunos tiles.

¿Notaron que los tiles del borde del terreno elevado no son transitables en la implementación anterior?

Esto es así porque en la implementación 3 los tiles sólo pueden ser transitables o no transitables.

Pero ahora con el Elevation Bit se introduce una nueva regla. Todavía se honra el valor de transitabilidad de un tile, pero ahora además tenemos un valor de elevación. Si un tile tiene una elevación mayor a otro tile, mientras sea mayor sólo por 1, se considera que se puede pasar a ese tile, mientras que si es mayor a 1 no se permite pasar a ese tile, aunque su valor de transitabilidad diga lo contrario.

Dicho de otra manera, la transitabilidad es tenida en cuenta cuando dos tiles están en la misma elevación, o la diferencia entre sus elevaciones es de un máximo de 1. Para tiles de diferente elevación por una diferencia a partir de 2, no importa si son transitables o no, la elevación dicta que no se podrá pasar de uno al otro.

Esto me permitió hacer una mejor utilización del espacio de los tiles. Haciendo posible que en el terreno elevado se pudiera asomar a la orilla.

Ahora bien, los juegos de la época del SNES no hacían esto, al menos ninguno que yo haya jugado. En su lugar los que dibujaban los tiles tenían mucho cuidado de hacer un uso inteligente del espacio visual.

Esto significa que si un tile representaba un muro demasiado angosto para llenar todo el tile, trataban de ponerlo en el medio para no desperdiciar tanto espacio transitable en uno de los lados, o bien creaban dos versiones de ese muro, uno más hacia el lado izquierdo y otro más hacia el lado derecho, y en el escenario colocaban el que correspondía para cada caso.

Implementación 5: Elevation Map

Igual que la implementación 4 pero se mueve el valor de elevación a un array separado.

Cuando digo igual que la implementación 4, me refiero a que tiene las mismas características, soporta el concepto de elevación, aunque lo consigue por otro método.

Pero en lo que al código fuente respecta, sería más parecido a la implementación 3. Ya que es igual pero con un pequeño hack, la inclusión de lo que llamo Elevation Map.

El Elevation Map no es más que un array en el que guardamos el valor de elevación de un tile, valor que en la implementación 4 codificamos en el mismo valor del tile a partir del dígito 4.

En JavaScript se desperdicia mucha memoria ya que el único tipo de dato que podemos usar para estos valores es "number". Si se tratara de C o C++ podríamos usar "uint8_t", lo que nos permitiría reducir el consumo de memoria del Elevation Map. Claro, esto asumiendo que nos alcanza con 256 posible alturas.

Esto es más simple de programar y quizá más rápido en su ejecución también. Ya que volvemos a poder leer cada tile sin tener que decodificar su valor de elevación. Haciendo que el bucle que dibuja los tiles tenga menos que hacer.

De nuevo, esta implementación no se observa en ningún título de SNES que me venga a la mente. La dejo aquí sólo porque es una evolución menor de las anteriores.

Nota: Está implementación la imaginé pero no llegué a escribirla. Se las debo por ahora.

Los tiles son convenientes aún hoy

En desuso en el mundo de los juegos 3D con presupuestos de películas de Hollywood. Otras plataformas más humildes les han traído de nuevo a la vida.

Los mapas creados con tiles se pueden ver hoy día en juegos de Android principalmente. Asumo que en menos medida en sus dos competidores, iOS y Windows Phone.

Es raro ver un RPG de la vieja escuela que no saque provecho de las facilidades que Android da para el desarrollo de aplicaciones. Lo más común es ver implementaciones híbridas, que usan los tiles por conveniencia, pues como ya establecimos a lo largo de esta entrada son fáciles de usar y permiten contar un sin fin de historias, o por apelar a la nostalgia de una base de jugadores ya establecida.

Pero estas implementaciones híbridas siempre agregan cosas como permitir sprites de cualquier altura, ejecutar ordenamiento z al menos para los sprites, permitir detener el movimiento en cualquier parte del recorrido, no sólo en el centro de un tile.

Para la parte de los tiles, he observado que la mayoría mantiene la limitación de la no transitabilidad por detrás de las elevaciones del terreno. Esto es así, porque un verdadero motor de tiles es un plano bidimensional, y el 3D es pura sugestión. Si no fuera así, ya no sería un motor de tiles, sería otra cosa, bastante más difícil de implementar.

En los juegos de la actualidad, intentar deducir si usan un motor de tiles clásico sólo por cómo se puede pasar por detrás de los objetos no es tan efectivo como en la época del SNES. La razón es que el juego podría ser capaz de hacer mucho más de lo que parece, y se auto limita a aparentar tener las mismas características que los RPGs de la vieja escuela. Posiblemente porque el departamento de arte todavía encuentra práctico hacer el escenario con tiles. Y, de nuevo, para apelar a la base de usuarios ya establecida para este tipo de contenido.

Desde el punto de vista de la creación de escenarios, los tiles son tan prácticos porque no tienes que pensar en 3D. Le puedes creer a tu ojo y olvidarte de la matemática. Si a tí te convence que algo está elevado, al jugador también le convencerá. No es necesario complicarse la vida más allá de eso.

La filosofía de "si no se ve tampoco existe" favorece tanto a diseñadores como a jugadores.

Cabe preguntarse si no tienen razón. Si necesitas que tu juego represente el universo físico conocido de forma tan fiel, tal vez deberías olvidarte del 2D y hacer el paso definitivo al 3D.

Posible evolución: posicionando sprites en el espacio tridimensional

No observo ningún juego del tipo RPG, de la generación que estoy analizando, que haya usado esta técnica. Sí tengo mis sospechas de que otros juegos que no son RPG llegaron a utilizarla. Creo que la razón de su escasez era principalmente su costo computacional, dado el hardware de la época.

Esta última solución híbrida, creo yo, es lo más que podemos exprimir al algoritmo del pintor. Para ir más allá ya tenemos que utilizar z-buffering, lo que a su vez implica hardware especializado, ya que como se explicó al principio de este estudio, el z-buffering por software es demasiado lento.

Llamo a esta solución "híbrida" porque el mapa de tiles seguirá funcionando exactamente de la misma forma que hasta ahora, sólo los sprites pasarán a vivir en el espacio tridimensional.

La razón para hacerlo así, es que para pasar los tiles al espacio tridimensional tendríamos que hacer que cada tile fuera independiente, cada tile sería un sprite, y sería posicionado delante o detrás de otros sprites por el mismo proceso que el resto de sprites del juego. El resultado final sería correcto pero el tiempo de ejecución no nos convence.

Actualmente, los tiles son baratos de procesar, pues el proceso que decide dónde dibujarlos es un simple bucle for que recorre el array donde los tiles están referenciados y aplica un pequeño corrimiento según la posición de la cámara en el mundo. Si los convertimos en sprites perdemos esto, lo que sería una pena. Además los sprites llevan consigo bastantes datos extra, como el alto, ancho, posición, etc, ya que pueden tener cualquier forma, mientras que los tiles tienen un tamaño fijo dado por la implementación.

Explicación de cada "clase" más algunos conceptos

El que tenga algo de experiencia con JavaScript podría argumentar, llegados aquí, que en JavaScript realmente no hay clases. Pero yo las he tratado como tales, he pretendido, como hacen muchos, que son clases. Así que tolerenme por usar la palabra clase.

AssetManager

Tiene un gran nombre pero en realidad no carga nada, eso ya lo hace el navegador web. Esta clase existe por conveniencia. Nos permite preguntarle cada frame del juego si todos los assets solicitados ya están listos para usarse. Y si no lo están podemos "congelar" el juego hasta que lo estén."

Suena simple, pero en JavaScript y con el navegador web encima debemos hacerlo con cierto cuidado. No podemos sólo trancar el bucle principal con un while, esto sería malo. Cuando digo "congelar" el juego en realidad me refiero a no hacer nada ese frame, y dejar que que la carga de los assets progrese. Dependiendo de varios factores, como la velocidad de la conexión, terminar de cargarlos puede llevar varios segundos.

Actualmente sólo usamos dos assets, una imagen png con los tiles, todos juntos uno al lado del otro, y otra con todas las posiciones del único personaje de nuestro juego. A esto se le dice un "sprite sheet".

También es una forma de evitar crear múltiples instancias de un Image. Es correcto que más de un sprite refiera a un mismo Image, no es un bug. De hecho, es exactamente lo que queremos.

Como está todo implementado actualmente, si mandamos a cargar una nueva imagen en medio del juego, no ocurrirá nada terrible, de echo esto está soportado, es incorrecto, pero está soportado. Lo que pasará es que el juego se "congelará" hasta que se termine de cargar la imagen, pero el navegador web, y la pestaña en donde corre nuestro juego, seguirán respondiendo sin problemas.

Digo que es incorrecto mandar a cargar una imagen en medio del juego porque arruina la experiencia para el jugador. Debemos planear nuestros mapas de modo que sólo se carguen cosas al inicio, durante las pantallas de carga.

Actualmente no tenemos algo como una pantalla de carga, pero con las clases con las que contamos sería trivial implementar una.

AssetManager.images:

Mantiene un mapa de todas las instancias de Image creadas durante una llamada a loadImage(). Nótese que estas instancias son creadas inmediatamente aún cuando la imagen todavía no se ha terminado de descargar.

Este mapa tiene como claves la url usada durante la llamada a loadImage. Ejemplo: si hemos hecho loadImage("img/sprite.png") luego podemos accesar la imagen haciendo assetmanager.images["img/sprite.png"].

Se desaconseja accesar las imágenes de esta forma, y en su lugar se aconseja usar AssetManager.getImage().

AssetManager.finished:

A través de esta variable se puede checar si todas las imágenes ya están cargadas. Es correcto accesar directamente esta variable mientras no se modifique su valor.

AssetManager.loadImage (url):

Crea inmediatamente una instancia de Image, la agrega a AssetManager.images y asigna la url pasada en url a Image.src para que el navegador web comience su carga.

AssetManager.getImage (url):

Devuelve una instancia de Image asociada a una url. La imagen debe haber sido cargada a través de loadImage.

Pensé en hacer que getImage llamara a loadImage si se trataba de obtener una imagen con url desconocida, pero para mantenerlo simple decidí que no. Se debe usar loadImage primero, esto nos permite rastrear intentos de cargar imágenes nuevas donde no deberíamos estar haciendo eso. Si llamamos a getImage sin haber cargado la imagen primero, obtendremos un error en la consola web del navegador, que nos permitirá identificar el intento de cargar una imagen fuera de sitio. Si hubieramos hecho que getImage llamara a loadImage, no gozaríamos de este beneficio, y nuestro pseudo motor de juego permitiría malos diseños con demasiada facilidad.

AssetManager.update ():

Devuelve true si todas las imágenes conocidas por el manager han terminado de cargarse, o false de lo contrario.

Contrariamente a lo que su nombre sugiere, esta función no avanza la carga de assets. De eso se encarga el navegador web. Esta función se llama como se llama porque se supone que se la llame durante los updates del juego para evitar que el juego progrese si todavía no puede hacer uso de los assets que solicitó durante sus pantallas de carga.

Actualmente está implementado de la forma más ineficiente posible. Con un bucle que recorre todas las imágenes. No importa, porque este tipo de juegos rara vez alcanza los miles de imágenes, normalmente no llegan ni a 100.

Idea para el lector: existe otra técnica que se puede usar, sin modificar la interfaz del AssetManager pero sí su funcionamiento interno. Podríamos haber usado los eventos onload de Image para reducir un contador en el AssetManager que previamente fue incrementado con cada llamada a loadImage.

Esto evitaría tener que recorrer todas las imágenes con un bucle durante la llamada a update. Sólo tendríamos que checar si el contador está en 0, ya que 0 significa que todas las imágenes han ejecutado su manejador onload. Recuerden, la idea de JavaScript es hacer las cosas asíncronamente.

También, ya de paso, podríamos haber usado otros eventos para responder a errores en la carga de las imágenes. Pero esto queda como un ejercicio para el lector.

Camera

Otra clase que tiene un gran nombre pero en realidad es... un rectángulo. Sí, de hecho le habríamos podido llamar Rectangle o Rect y habría sido un nombre más honesto.

No tiene funciones. Solo lleva un registro de las dimensiones del área visible de nuestro juego. Para esto se vale de cuatro variables: x, y, width y height.

x e y son la esquina superior izquierda. No el centro del rectángulo que sería la otra interpretación posible.

Todo está en píxeles.

En 3D, una cámara es típicamente una pirámide trunca. La razón de esto es que cuanto más lejos del primer plano, debemos capturar más objetos, ya que en 3D cubrimos más terreno con la distancia. La pirámide trunca simboliza esto. La relación entre el tamaño del plano lejano y el cercano depende del ángulo de visión que queramos simular.

Pero en 2D todo es más simple. Un simple rectángulo hace el trabajo. Cuando el jugador mueve la cámara, lo que está haciendo es desplazando este rectángulo. El juego se limita a dibujar cada frame todos los objetos que quedan dentro, o parcialmente dentro, de este rectángulo.

ImageRegion

En todo motor de juego es conveniente poder referirnos de alguna manera a una porción de una imagen. Sino no podríamos tener algo como un sprite sheet y cada sprite debería estar en un archivo separado.

De nuevo, sin funciones. Sólo variables.

left: y top son la esquina superior izquierda en píxeles del área rectangular que queremos tomar de una imagen.

width y height son el ancho y alto en píxeles de esa área rectangular.

TileSetItem

Un tile set es... bueno, un set de tiles. Y TileSetItem es de lo que está hecho un tile set.

Representa cada tile que forma parte de un tile set.

imgReg: Contiene una referencia a un ImageRegion. Se asume que lo más práctico es que todos los tiles estén en un único archivo de imagen y por lo tanto necesitamos especificar de alguna manera la posición y tamaño de cada uno.

type: El tipo del tile, representado por un número. Este número también determinará la "transitabilidad" del tile. El significado de este número es dependiente de la implementación de cada juego, pero se recomienda que 0 signifique pasable, 1 pasable volando, 2 pasable por fantasmas, y 255 impasable sin importar nada.

TileMap

Representa una colección de tiles así como las dimensiones de un escenario en el juego.

tiles: array de números que representan el valor de los tiles. Éste valor es la posición de cada TileSetItem en la variable tileset.

tileset: array de TileSetItem. Tile set es un concepto, no existe clase llamada TileSet, ya que un set de tiles no es más que un array de TileSetItem. No necesitamos una clase para eso.

width: ancho de un escenario en el juego, en tiles. El eje horizontal progresa de izquierda a derecha.

height: alto de un escenario en el juego, en tiles. El eje vertical progresa de arriba a abajo.

tileSize: lado de un tile cuadrado, en píxeles. Nótese que TileSetItem no tiene una variable para esta medida, la razón es que no tiene sentido especificar la medida por cada tile si sabemos que en un motor de tiles cuadrados todos los tiles tienen las mismas medidas. Así que hemos puesto esa medida aquí, y ya vale para todos los tiles de un TileMap.

TileMap.draw (canvas, ctx, cam):

El TileMap sabe dibujarse a sí mismo. El código que lo dibuja está en esta función.

canvas: el canvas sobre el que estamos dibujando nuestro juego.

ctx: el contexto de ese canvas. Sólo funciona con "2d".

cam: referencia a la cámara en uso. El TileMap tomará los tiles comprendidos dentro del rectángulo de la cámara y dibujará sólo esos tiles. Esto ya es todo lo eficiente que puede ser.

Sprite

Representa un objeto 2D visible en el mundo del juego.

Actualmente su orden de dibujo depende de en qué orden es agregado a la lista de sprites mantenida por cada implementación.

Expandirlo con una variable zIndex queda como ejercicio para el lector.

x: Posición x en el mundo del juego. En píxeles.

y: Posición y en el mundo del juego. En píxeles.

width: Ancho en píxeles en el mundo del juego, no es necesario que se corresponda con la región de la imagen usada. De hecho, usar un valor diferente es una forma de obtener un efecto de escalado en el eje horizontal.

height: Alto en píxeles en el mundo del juego, no es necesario que se corresponda con la regioń de la imagen usada. De hecho, usar un valor diferente es una forma de obtener un efecto de esacalado en el eje vertical.

imgReg: Referencia a un ImageRegion. Esto es la porción de un Image que se toma para representar los gráficos de un Sprite.

imgOffsetX: Corrimiento del ImageRegion con respecto a la posición x del sprite en el mundo. Usando este valor con creatividad podemos conseguir que las variables x e y representen una esquina, así como el centro de un ImageRegion, o cualquier otra posición que nos convenga. Valores positivos progresan hacia la derecha.

imgOffsetY: Igual que imgOffsetX pero en el eje vertical. Valores positivos progresan hacia abajo.

Sprite.draw (canvas, ctx, cam):

Los sprites saben dibujarse a sí mismos.

Las transformaciones aplicadas a la imagen dependerán de qué valores hayamos asignado a cada una de las variables del sprite.

canvas: el canvas sobre el que estamos dibujando nuestro juego.

ctx: el contexto de ese canvas. Sólo funciona con "2d".

cam: referencia a la cámara en uso. Aunque hemos diseñado una interfaz en la que a los objetos que se dibujan se les debe pasar la cámara en su función draw, lo cierto es que en el caso de los sprites esto no se usa para nada, ya que es responsabilidad de cada implementación de juego aplicar al canvas una transformación de desplazamiento según la posición de la cámara. Esto no quiere decir que el sprite no pueda aplicar más transformaciones dentro de su código de dibujo, de hecho actualmente lo hacen.

GAME

Variable global que contiene una referencia al juego actualmente en ejecución.

ASSETS

Variable global que contiene una referencia al AssetManager en uso. Como todas las implementaciones comparten el mismo AssetManager, para evitar tener que recrear constantemente el AssetManager, se decidió crearlo en la misma declaración de la global en vez de hacerlo en cada una de las implementaciones de los juegos.

TILESET

Variable global que contiene una referencia al set de tiles, es decir, al array de TileSetItem usado por las implementaciones 1, 2, 3 y 5. La implementación 4 usa otro set de tiles.

TILESET2

Variable global que contiene el set alternativo de la implementación 4. Ya que esa implemetnación requiere valores diferentes para la variable type de algunos TileSetItem. Algunos tiles, en concreto los bordes de la elevación del terreno, fueron marcados como pasables en este set. La razón es que su "transitabilidad" se determina por su bit de elevación (dígito de elevación).

TIMING

Variable global que contiene un objeto anónimo improvisado para medir el tiempo entre frames y conseguir un efecto de "tasa de cuadros fija".

Esto de la "tasa de cuadros fija" es una estrategia opuesta a "a lo que de". En lugar de dejar que cada computadora ejecute el bucle principal del juego tantas veces como le permitan sus especificaciones, y luego ajustar el desplazamiento de cada objeto según el tiempo transcurrido entre cada iteración, se ha optado por una aproximación más intuitiva.

Esta aproximación más intuitiva que me gusta llamar "tasa de cuadros fija" consiste en asumir que cada iteración representa exactamente 1 segundo dividido 60. Cuando vamos muy adelantados, paramos un poco y esperamos, cuando vamos muy atrasados, dejamos correr varias iteraciones consecutivas antes de volver a dibujar en el canvas.

Esto es muy conveniente para el programador porque si quiero que un sprite se desplace, digamos a la derecha, a una velocidad de 60 píxeles por segundo, lo único que tengo que escribir es sprite.x += 1;. No necesito preocuparme de cuánto tiempo pasó desde la última actualización del ciclo de juego, pues la lógica del objeto TIMING se encarga de ejecutar estas actualizaciones con la frecuencia correcta.

Si tienes problemas para entender de lo que estoy hablando, debes familiarizarte primero con conceptos como simulación, tasa de refresco, cuadros por segundo (FPS).

Para mí, la gran mayoría de juegos son simulaciones cuadro a cuadro. Conceptualizarlo así es conveniente.

Juegos como el buscaminas de Windows pueden ser escritos sin pensarlos como una simulación, sólo soltando controles del GUI de Windows sobre un formulario y programando lo que ocurre en el evento de hacer clic sobre ellos. También se lo podría implementar como una simulación pero sería innecesario hacerlo así.

Pero casi cualquier otro juego será una simulación de un pequeño mundo o universo con reglas bien definidas que serán válidas para todo el juego.

SingleTileLayerSingleSpriteLayer

Implementación 1.

La interfaz definida en esta implementación será la misma para todas las otras implementaciones.

Las implementaciones tienen todas casi las mismas funciones, de modo que un mismo Controller funcione con cualquiera de ellas.

Cada implementación ya define el escenario que va a utilizar durante la creación de una de sus instancias. Es decir, tiene el escenario "hardcodeado".

Esta implementación trabaja con una capa de tiles y sobre ella una capa de sprites. Es la más simple y produce el mismo resultado que el primer juego de Zelda de SNES.

SingleTileLayerSingleSpriteLayer.getTileType (x, y):

Retorna un número que contiene el tipo de tile. El tipo de tile determina su "transitabilidad", siendo el valor 0 el tile más transitable de todos y 255 el tile menos transitable de todos. Nota: dar un significado diferente al tipo de tile implica modificar tanto la implementación del juego como el PlayerSpriteController.

Hace lo necesario para decodificar el tipo de tile presente en una coordenada (x,y) en el escenario cargado actualmente.

Esta función no hace nada interesante en esta primera implementación. Ver otras implementaciones para una lectura más entretenida.

Ele eje horizontal progresa hacia la derecha. El eje vertical progresa hacia abajo. Las coordenadas son en tiles, no en píxeles.

SingleTileLayerSingleSpriteLayer.draw ():

Dibuja sobre el canvas de la página todo lo que es visible por la cámara.

SingleTileLayerSingleSpriteLayer.update ():

El objeto anónimo TIMING se encarga de ejecutar esta función 60 veces por segundo.

Esta función se encarga de revisar si todos los assets han terminado de cargarse, si no lo están el juego no avanza hasta que lo estén.

También se encarga de ejecutar la función update de todos los "controladores" registrados. Así como de eliminar a aquellos controladores que estén marcados para ser eliminados.

MultiTileLayersSingleSpriteLayer

Implementación 2.

Hereda la función update de SingleTileLayerSingleSpriteLayer pero define su propia versión de getTileType y draw.

Su getTileType debe revisar todas las capas de tiles. El tipo de tile en las capas superiores será el que determine la "transitabilidad" de esa coordenada (x,y) del escenario cargado actualmente. Con la excepción de que la búsqueda no considera los tiles con valor 0.

Esto significa que si tenemos un tile con valor 1 en la capa 0, y otro con valor 0 en la capa 1, 2 y 3, el tipo del tile de la capa 0 determinará la transitabilidad de esa coordenada (x,y), mientras que si tenemos un tile con valor 1 en la capa 0, y otro con valor 3 en la capa 1, y otros con valor 0 en las capas 2 y 3, será el tipo del tile en la capa 1 el que determina la transitabilidad de esa coordenada (x,y) ya que los tiles con valor 0 son ignorados.

MultiLayerTileSprite

Implementación 3.

Hereda la función update de SingleTileLayerSingleSpriteLayer pero define su propia versión de getTileType y draw.

Su función getTileType hace lo mismo que en la implementación anterior, pero debió ser reescrita para funcionar con un número variable de capas de tiles. Pero las reglas para decidir qué tipo de tile se devuelve para una coordenada (x,y) concreta son las mismas que en la implementación anterior.

PlayerSpriteController

Controlador para el sprite del jugador.

Usado en las implementaciones 1, 2 y 3.

Cumple con la interfaz que se espera que tengan los controladores. Esto es, presenta la variable alive usada por las implementaciones para decidir si un controlador debe ser removido. Y las funciones update, initialize y finalize.

Todas las demás funciones son para su uso interno.

Interfaz para controladores

Un controlador es una clase que controla un pequeño aspecto del juego. Hace posible modularizar las implementaciones y reutilizar código entre ellas. Por ejemplo, el PlayerSpriteController es usado en las implementaciones 1, 2 y 3 y sólo la implementación 4 necesitó redefinirlo como PlayerSpriteController2 ya que la inclusión del Elevation Bit obligó a modificar este controlador, ya que es en su función update donde se decide si se permite al jugador pasar de un tile a otro.

Otro controlador que se recomienda leer es el TeleportPlayerController. Un controlador que transporta al jugador a una posición (x,y) en el escenario de juego y luego se marca así mismo para ser eliminado.

El TrackingCameraController actúa sobre la cámara, manteniéndola centrada en el sprite del jugador. Se decidió poner esto en un controlador aparte del PlayerSpriteController para modularizar, esta decisión se tomó antes de crear el PlayerSpriteController2 y de imaginar la implementación 4, pero la decisión resultó correcta y conveniente, ya que el mismo TrackingCameraController es usado para ambos PlayerSpriteController.

Nótese que nuestra API está muy cruda aún. No tenemos una forma reutilizable de crear y eliminar controladores durante la ejecución de las implementaciones. Sino que actualmente creamos los controladores antes de poner a andar las implementaciones. Esto nos alcanza para lo que queríamos desmostrar, pero no es un diseño completo que se pueda usar en un juego para liberar al público, hay espacio para crecer.

Los controladores deben tener una variable de tipo "boolean" llamada alive que será revisada por las implementaciones durante la ejecución de su función update para decidir si el controlador debe ser removido. Los controladores no deben manipular la lista de controladores gestionada por las implementaciones, ni deben asumir que saben cómo cada implementación llamará a esta lista.

Y deben tener 3 funciones cuyos prototipos se exponen a continuación.

update (game, delta_time):

game es una referencia a la implementación actual que está llamando a la función update del controlador.

delta_time es el tiempo, en segundos, transcurrido desde la llamada anterior a update. Aunque estamos usando "tasa de cuadros fija", los controladores deben poder funcionar correctamente aún si no fuera así, por eso existe este parámetro.

initialize (game):

Sólo los controladores que estén presentes antes de que la implementación ejecute por primera vez su método update tendrán la chance de que esta función sea ejecutada.

Se admite que este no es el mejor diseño, pero controladores creados y registrados durante la ejecución del juego no tendrán chance de ejecutar esta función, por lo que se debe tener cuidado de qué código se pone aquí. Si el controlador está pensado para ser eliminado y recreado varias veces durante la ejecución de un juego, su código de inicialización debería estar en update con alguna forma de detectar si algo necesita ocurrir sólo durante la primera llamada a update.

finalize (game):

Todo controlador a punto de ser removido primero tendrá la chance de hacer algo durante esta función.

Nota: documentación del código fuente incompleta. Eventualmente la completaré.

0 comentarios:

Publicar un comentario