lunes, 15 de abril de 2013

Haciendo un juego en Android: lecciones aprendidas (1)

Uno de los principales motivos por los que hace unos meses me puse con el desarrollo del West Bank es porque tenía ganas de empezar a aprender algo de Android. Android te permite algo que ya se había perdido un poco, y es que puedas hacer una aplicación más o menos pequeña, pero que a la vez esa aplicación sea publicable e incluso sea útil. Y en algunos casos, hasta te haga ganar dinero. Para los que no tenemos casi tiempo libre eso es genial, claro. En este artículo cuento un poco algunas de las cosas que me fui encontrando y lecciones aprendidas..

Hacer juegos con Android es muy fácil. Incluso sin librerías

Esta es seguramente la conclusión más importante que he sacado, algo que por otra parte ya me imaginaba. En mi caso, comencé primero buscando un poco alguna librería / framework / engine de desarrollo de juegos que me facilitara la vida. La idea es que fuera una librería sencillita y enfocada al 2D, nada de polígonos ni historias. La primera que vi fue Rokon, que tenía buena pinta y encajaba en lo que buscaba, pero el autor recomendaba no usarla (imagino que acabó cansado de responder emails de gente haciéndole preguntas). Como el objetivo era complicarse la vida lo menos posible y hacer el juego rápido, la descarté.

Entonces miré libgdx, que es una librería multiplataforma. Eso es una ventaja evidente, pero a la vez para este caso también es una desventaja, porque el objetivo con el que empecé era aprender algo de Android. Cuando me di cuenta de esto, y llevándome eso a descartar sin apenas mirarlas otras librerías con muy buena pinta como AndEngine, pensé: "¿Y si lo hago directamente usando las librerías nativas de Android?".

Buscando por Internet encontré este buen tutorial de introducción de JavaCodeGeeks. Y sólo mirándolo por encima ya vi que Android de serie te da enormes facilidades para manejar gráficos, sonidos y eventos táctiles. Por otra parte, para el juego que nos ocupa no iba a necesitar ningún tipo de aceleración gráfica OpenGL ni otras mandangas de optimizaciones. Así que decidido: no usaría ninguna librería, haría la mía propia basada en la librería estándar de Android.

Gráficos en pantallas con distintas resoluciones

Esto ha sido seguramente lo que más quebraderos de cabeza me ha dado. Y lo cierto es que si lo entiendes bien y no te metes en líos, tampoco es algo que tenga por qué darte problemas. En Android a priori no sabes cuál es el tamaño de la pantalla, ni tampoco sus proporciones (screen ratio), y tienes que intentar que en todas ellas el juego se vea lo mejor posible.

Esto implica redimensionamiento de tres elementos: los gráficos, las posiciones en los que pintas los gráficos y los puntos en los que se detectan los eventos táctiles. De primeras solucioné esto creando una clase (más bien cutre) PointTranslator, con un objeto que le pasaba a todos los demás objetos del juego y al que llamaba cada vez que quería traducir una posición o pintar un bitmap.

Para solucionar el problema de las proporciones de pantalla puse unas franjas negras en la parte que sobrara de la pantalla. Otra solución hubiera sido alargar el bitmap, pero prefería no distorsionar las imágenes. Mi resolución estaba clara, como utilizaba directamente los gráficos del ZX Spectrum, la suya sería la resolución que iba a usar: 256x192 (con proporción 3:4).

Cuando ya tenía algo que pintaba los gráficos y detectaba las pulsaciones en su sitio, me di cuenta de otro problema: según el móvil en el que cargabas los bitmaps, ¡el propio bitmap podía tener un tamaño diferente al real!. Por lo que vi, esto dependía de otro parámetro: la densidad. Esto afecta sobre todo a la carga de sprites. Una de las típicas técnicas en los juegos es meter todos los gráficos de un personaje en un solo fichero. Al dibujar uno de ellos, se dibuja sólo la parte del gráfico que corresponda. Al no tener el bitmap el tamaño que se suponía iba a tener, esto se desmadraba. Ale, otra chapuza a meter al "soluciones todo-en-uno" PointTranslator.

Otro problema que tuve con esto fue el del redondeo. Normalmente los bitmaps se cargaban en su tamaño o en un tamaño múltiplo. Pero, ¡ay!, no siempre es así. Concretamente, el Nexus 7 carga los bitmaps en una escala extraña. Esto hace que en bitmaps con muchos gráficos, el redondeo fuera importante y no se cargaran bien. Concretamente, el problema era muy evidente en las fuentes bitmap que usaba para escribir los textos. Vuelvo a tocar (¡cómo no!) ese maravilloso PointTranslator para que use coma flotante para estas cosas y listo.

Lo cierto es que estos dos últimos problemas: carga de bitmaps y redondeo no tienen por qué existir. Los cuento porque creo que puede ser interesante saberlo, pero la verdad es que desde el principio fue por una metedura de pata mía. Como todo el proyecto lo he ido haciendo sobre la marcha y sin documentarme debidamente, ya que lo más importante era ir avanzando y obtener cosas funcionando lo más rápido posible (doctor, ¿me habré hecho "ágil"?), cuando probé a meter el primer gráfico lo hice en la carpeta "res\drawable-mdpi". En mi ignorancia, pensaba que estas carpetas ldpi, mdpi, hdpi servirían para cargar gráficos más o menos grandes según la resolución de tu dispositivo, y que si sólo encontraba un gráfico en una carpeta cargaría ese y ya está. Craso error. Lo primero es que no es eso lo que significan esas carpetas, no funcionan según la resolución de pantalla (en cuanto a número de pixels), sino en cuanto a la densidad, es decir, los puntos por pulgada del dispositivo. Es decir, el objetivo de usar esto es, por ejemplo, que se vea más o menos igual de grande (en cuanto a tamaño físico) un gráfico cargado en un tablet que cargado en un móvil con resolución alta o en otro con resolución baja. En definitiva, nada que a priori nos interese ni lo más mínimo cuando estamos haciendo un juego. Para más información, tenéis el artículo oficial sobre el tema (un poco ladrillete, pero es que esto es así...). ¿La solución a esto?. Muy fácil, una de estas:

  • Meter los gráficos en una carpeta drawable-nodpi
  • Si queremos cargar diferentes gráficos según la resolución de pantalla, usar carpetas de recursos drawable que dependan del tamaño en píxeles: small, normal, large, xlarge
  • Meter los gráficos como assets en lugar de como resources. Esto, además, nos permite componer de forma dinámica el nombre del bitmap a cargar, o de su carpeta

Esto último es por lo que me he decantado yo, ya que quiero cargar distintos gráficos según la configuración del juego: versión ZX Spectrum, o nueva versión con gráficos HD... ¡pronto en sus kioskos!.

De hecho, esto último me ocasiona un nuevo problema: los gráficos del juego tendrán distintos tamaños, posiciones y hasta resoluciones diferentes según su configuración. Esto me ha llevado a hacer una refactorización, eliminando alguna chapucilla y mejorando el diseño de las clases para facilitar esto. Y sí, tuve que tomar la difícil decisión de eliminar mi clase-bombero... es decir... el PointTranslator ha muerto (¡viva el PointTranslator!).

Por lo pronto, en lugar de redimensionar todos los puntos sobre la marcha dentro del propio juego, ahora lo que hago es pintar todo en un buffer intermedio, que tiene el tamaño exacto de la resolución del juego. Al terminar de dibujar todo, se pinta redimensionado en el buffer de pantalla. Para encapsular todo esto he creado una clase Camera. Cada vez que el controlador del juego quiere pedir a los objetos que se dibujen, llama a la cámara para que devuelva el Canvas (la clase estándar Android donde se pintan cosas) bien configurado. Al terminar todos de dibujar, vuelve a llamarle para que lo plasme. De esta forma, toda la complejidad del proceso se queda en las clases genéricas de mi librería, y las clases específicas del juego se despreocupan totalmente de los problemas de resolución, ellas trabajan siempre con las dimensiones que marque el juego y ya está.

Todavía me he encontrado con un problema, que ha sido a la hora de traducir las posiciones de los eventos táctiles. Para estas cosas, Android tiene una clase, MotionEvent, que la verdad es que me gusta bastante tal cual está y por tanto no tenía intención de crear otra nueva para lo mismo. Pues bien, resulta que la clase MotionEvent no permite reescalar ni modificar sus posiciones. Bueno, a partir de la versión 11 del API sí, pero quiero que el juego funcione en versiones de Android más antiguas. Lo solucioné creando mi propia clase GameMotionEvent, que utiliza el patrón de diseño delegate para encapsular la clase anterior, proporcionando un interfaz igual y llamando en cada método al equivalente a la anterior, haciendo las pertinentes traducciones de puntos cuando hagan falta a través de la clase Camera.

El bucle principal: con cuatro cosillas lo haces, ¡pero cuidado!

Como bien sabrás si has hecho un juego alguna vez, el bucle principal (el game loop) supone la estructura fundamental de cualquier juego. Es un bucle "infinito" de tres pasos: procesar entrada, actualizar estado de los objetos, dibujarlos (aunque en realidad lo de procesar la entrada no tiene por qué ir estrictamente en ese orden y se puede hacer más bien en forma de eventos). Y hacerlo además de forma que cada ciclo se haga en el mismo tiempo, gestionando condiciones como que si tardamos más tiempo de lo que deberíamos en hacer un ciclo, nos podamos saltar pasos de dibujar para que todo vaya lo más suave posible, y si tardamos menos, esperemos un ratito hasta que quede cuadrado. En realidad todo esto está muy bien explicado en el tutorial que enlacé antes, especialmente en un capítulo en el que se da un bucle básico y en otro posterior donde se mejora la implementación. Como digo en el título, hacer un bucle principal es muy sencillo teniendo en cuenta cuatro cosas que son muy conocidas y que este tutorial cuenta muy bien. Pero ojo, ¡es muy importante tenerlas en cuenta!.

Lo que no me gusta tanto del tutorial es el diseño de clases que se da en su implementación, ya que me parece bastante "sucio", haciendo que la clase principal herede directamente de Thread y obligando además al que la use a extender directamente esta misma clase para poder hacer una implementación para un juego concreto. Así que decidí cambiar este diseño y hacer uno más genérico, con una clase limpia GameController que se pueda invocar fácilmente desde el Activity y que use el thread de forma interna. Incorporé las clases Camera y GameMotionEvent que he contado en el punto anterior, para el escalado de los gráficos. Y para permitir la implementación concreta de los objetos creé primero un interface básico GameEntity, con lo mínimo que se necesita para pintar un objeto y que reaccione a eventos. Luego creé otro extendido Scenario, que me pareció interesante para facilitar la creación de "partes" del juego completamente diferenciadas entre sí.

Este es el diseño que quedó:



Continuará...

Y hasta aquí por hoy mis historias programando el juego en Android. Haré un capítulo más, en el que contaré otros refinamientos que he hecho en el diseño, sobre todo para poder tener un sistema sencillo de componentes gráficos y otras cosas como animaciones y efectos. En breve aquí mismito.


1 comentario:

  1. Pobre PointTranslator... al final se le cogía cariño :-). Descanse en paz

    ResponderEliminar

cookie consent