Sin duda una de las aplicaciones que más famosas se han hecho con el surgimiento de los teléfonos móviles MIDP son los juegos Java. Con estos teléfonos los usuarios pueden descargar estos juegos de Internet, normalmente previo pago de una tarifa, e instalarlos en el teléfono. De esta forma podrán añadir fácilmente al móvil cualquier juego realizado en Java, sin limitarse así a tener únicamente el típico juego de "la serpiente" y similares que vienen preinstalados en determinados teléfonos.
Vamos a ver en esta sección los conceptos básicos de la programación de videojuegos y las APIs de MIDP dedicadas a esta tarea. Comenzaremos viendo una introducción a los tipos de videojuegos que normalmente encontraremos en los teléfonos móviles.
Podemos distinguir diferentes tipos de juegos:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Figura 1. Capturas de juegos para diferentes modelos de móviles
Los primeros juegos que podíamos encontrar en los móviles eran normalmente juegos muy sencillos tipo puzzle o de mesa, o en todo caso juegos de acción muy simples similares a los primeros videojuegos aparecidos antes de los 80. En los móviles con soporte para Java podremos tener juegos más complejos, como los que se podían ver en los ordenadores y consolas de 8 bits, y estos juegos irán mejorando conforme evolucionen los teléfonos móviles.
Además tenemos las ventajas de que existe una gran comunidad de programadores en Java, a los que no les costará aprender a desarrollar este tipo de juegos para móviles, por lo que el número de juegos disponible crecerá rápidamente. El poder descargar y añadir estos juegos al móvil de forma sencilla, como cualquier otra aplicación Java, hará estos juegos especialmente atractivos para los usuarios, ya que de esta forma podrán estar disponiendo continuamente de nuevos juegos en su móvil.
Los juegos que se ejecutan en un móvil tendrán distintas características que los juegos para ordenador o videoconsolas, debido a las peculiaridades de estos dispositivos.
Ante todo, estos videojuegos deben ser atractivos para los jugadores, ya que su única finalidad es entretener.
Hemos visto que los juegos son aplicaciones que deben resultar atractivas para los usuarios. Por lo tanto deben tener gráficos personalizados e innovadores. La API de bajo nivel de LCDUI nos ofrece el control suficiente sobre lo que dibujamos en pantalla para poder crear cualquier interfaz gráfica que queramos que tengan nuestros juegos. Esto, junto al control a bajo nivel que nos ofrece sobre los eventos de entrada, hace que esta API sea suficiente para desarrollar videojuegos para móviles.
Además, en MIDP 2.0 se incluyen una serie de clases adicionales en el
paquete javax.microedition.lcdui.game
que basándose en la
API a bajo nivel de LCDUI nos ofrecerán facilidades para el desarrollo
de juegos. En estas clases tendremos implementados los objetos genéricos
que solemos encontrar en todos los juegos. Si queremos desarrollar juegos con
MIDP 1.0, deberemos implementar nosotros manualmente todos estos objetos.
Los juegos se instalarán en el móvil como cualquier otra aplicación Java. En el caso concreto de los teléfonos Nokia, podemos establecer en el fichero JAD un atributo propio de esta marca con el que especificamos el tipo de aplicación:
Nokia-MIDlet-Category: Game
Especificando que se trata de un juego, cuando lo instalemos en el teléfono los guardará directamente en la carpeta juegos, y no en la carpeta aplicaciones como lo haría por defecto.
Para ilustrar las explicaciones, utilizaremos como ejemplo un clon del clásico Frogger. El mecanismo de este juego es muy sencillo:
Figura 2. Ejemplo de juego
Cuando desarrollamos juegos, será conveniente llevar a la capa de datos todo lo que podamos, dejando la parte del código lo más simple y genérica posible.
Por ejemplo, podemos crear ficheros de datos donde se especifiquen las características de cada nivel del juego, el tipo y el comportamiento de los enemigos, los textos, etc.
Normalmente los juegos consisten en una serie de niveles. Cada vez que superemos un nivel, entraremos en uno nuevo en el que se habrá incrementado la dificultad, pero la mecánica del juego en esencia será la misma. Por esta razón es conveniente que el código del programa se encargue de implementar esta mecánica genérica, que conoceremos como motor del juego, y lea de ficheros de datos todas las características de cada nivel concreto.
De esta forma, si queremos añadir o modificar niveles del juego, cambiar la inteligencia artificial de los enemigos, añadir nuevos tipos de enemigos, o cualquier otro cambio de este tipo, no tendremos que modificar el código fuente, simplemente bastará con cambiar los ficheros de datos.
Es recomendable también centralizar la carga y la gestión de los recursos en una única clase. De esta forma quedará más claro qué recursos carga la aplicación, ya que no tendremos la carga de recursos dispersa por todo el código de las clases del juego. En este mismo fichero podemos tener los textos que se muestren en pantalla, lo que nos facilitará realizar traducciones del juego, ya qué sólo tendremos que modificar este fichero.
Por ejemplo, podemos tener un clase Resources
donde se centralice
la carga de los recursos como la siguiente:
public class Resources {
// Identificadores de las imagenes public static final int IMG_TIT_TITULO = 0; public static final int IMG_SPR_CROC = 1; public static final int IMG_BG_STAGE_1 = 2; public static final int IMG_CAR_TYPE_1 = 3; public static final int IMG_CAR_TYPE_2 = 4; public static final int IMG_CAR_TYPE_3 = 5; public static final int IMG_FACE_LIVES = 6;
// Imagenes y datos del juego
public static Image[] img; public static Image splashImage; public static StageData[] stageData;
// MIDlet principal del juego public static MIDletJuego midlet;
// Nombres de los ficheros de las imagenes
private static String[] imgNames = { "/title.png", "/sprite.png", "/stage01.png", "/car01.png", "/car02.png", "/car03.png", "/face.png" }; private static String splashImageFile = "/logojava.png"; private static String stageFile = "/stages.dat";
// Obtiene una imagen
public static Image getImage(int imgIndex) { return img[imgIndex]; }
// Obtiene los datos de una fase
public static StageData getStage(int stageIndex) { return stageData[stageIndex]; } // Carga los recursos
public static void init() throws IOException { // Carga las imagenes loadCommonImages();
// Carga datos de niveles InputStream in =
stageFile.getClass().getResourceAsStream(stageFile); stageData = loadStageData(in); }
// Inicializa los recursos para la pantalla splash public static void initSplash(MIDletJuego pMidlet)
throws IOException { midlet = pMidlet; splashImage = Image.createImage(splashImageFile); }
// Carga imagenes private static void loadCommonImages() throws IOException { int nImg = imgNames.length;
img = new Image[nImg];
for (int i = 0; i < nImg; i++) { img[i] = Image.createImage(imgNames[i]); } }
// Carga los datos de los niveles
public static StageData[] loadStageData(InputStream in) throws IOException {
StageData[] stages = null;
DataInputStream dis = new DataInputStream(in);
int stageNum = dis.readInt(); stages = new StageData[stageNum]; for (int i = 0; i < stageNum; i++) { stages[i] = StageData.deserialize(dis); }
return stages; }
}
Cuando se inicie el juego, llamaremos al método init
de
esta clase para que se inicialicen los recursos, en este momento se cargarán
todas las imágenes y los datos que necesitemos. Una vez inicializado,
podremos utilizar el método getImage
para obtener las imágenes
que vayamos a utilizar desde cualquier parte del código.
Como la carga de recursos suele ser lenta, normalmente mostraremos una pantalla de presentación, llamada Splash Screen, mientras éstos se cargan. En esta pantalla se suele mostrar el logo de nuestra compañía e información sobre el copyright de la aplicación.
Por esta razón existe en la clase Resources
anterior un
método initSplash
, que hace una inicialización previa
de los recursos que necesita esta pantalla splash, que normalmente
será el logo que se vaya a mostrar. De esta forma, cuando carguemos la
aplicación, se cargarán estos recursos básicos para la
pantalla de splash, se mostrará la pantalla splash,
y mientras ésta se muestra se cargarán el resto de recursos.
También debemos destacar que proporcionamos en la inicialización el MIDlet principal de nuestra aplicación. De esta forma, a través de la clase de recursos desde cualquier lugar de la aplicación tendremos acceso a este MIDlet, que será necesario para poder cambiar de pantalla o salir de la aplicación.
Por último, como hemos comentado, es recomendable llevar a la capa de
datos toda la información posible. Esta clase Resources
tiene un método loadStageData
que la función que
realiza es cargar desde un fichero la información sobre las fases del
juego.
En el ejemplo de nuestro clon de Frogger, para cada fase consideraremos los siguientes datos:
Toda esta información será conveniente tenerla almacenada en
un fichero, de forma que simplemente editando el fichero, podremos editar las
fases del juego. Por ejemplo, podemos utilizar un objeto DataOutputStream
para codificar esta información de forma binaria con la siguiente estructura:
<int> Numero de fases Para cada fase <UTF> Titulo <byte> Número de carriles Para cada carril <byte> Velocidad <short> Separación <byte> Tipo de coche
Para leer en nuestro juego este tipo de información, podemos crear los métodos de deserialización oportunos en las clases que encapsulen estos datos.
En el método loadStageData
de la clase Resources
se lee el primer valor con el número de fases, y para cada fase deserializaremos
la información utilizando el objeto que encapsula los datos de las fases:
public class StageData {
public String title; public TrackData [] tracks; public static StageData deserialize(DataInputStream dis)
throws IOException { StageData data = new StageData();
data.title = dis.readUTF();
byte nTracks = dis.readByte();
data.tracks = new TrackData[nTracks]; for(byte i=0;i<nTracks;i++) { data.tracks[i] = TrackData.deserialize(dis); } return data; } }
De la misma forma, utilizaremos la clase que encapsula los datos de los carriles para deserializar la información de los mismos:
public class TrackData {
public byte velocity; public short distance; public byte carType;
public static TrackData deserialize(DataInputStream dis)
throws IOException { TrackData data = new TrackData();
data.velocity = dis.readByte(); data.distance = dis.readShort(); data.carType = dis.readByte();
return data; }
}
Al utilizar la API de bajo nivel para desarrollar el juego, la portabilidad de la aplicación se reducirá, ya que la implementación será dependiente de parámetros como el tamaño de la pantalla, el número de colores, el teclado del dispositivo, etc.
Para poder adaptar nuestra aplicación fácilmente a distintos tipos de dispositivos, conviene definir toda esta información como constantes centralizadas en una única clase, de forma que simplemente cambiando la información sobre las dimensiones de los distintos elementos del juego podremos adaptarlo a distintos tamaños de pantalla.
Por ejemplo, podríamos definir una clase como la siguiente:
public class CommonData {
// Numero de vidas total public static final int NUM_LIVES = 3;
// Dimensiones de la pantalla public final static int SCREEN_WIDTH = 176; public final static int SCREEN_HEIGHT = 208;
// Dimensiones del sprite public final static int SPRITE_WIDTH = 16; public final static int SPRITE_HEIGHT = 22;
// Posicion inicial y velocidad del personaje public final static int SPRITE_STEP = 2; public final static int SPRITE_INI_X = 80; public final static int SPRITE_INI_Y = 180; public final static int SPRITE_END_Y = 20;
// Datos del texto de la pantalla de titulo public static final int GAME_START_X = 88; public static final int GAME_START_Y = 150; public static final String GAME_START_TEXT =
"PULSA START PARA COMENZAR"; public static final int GAME_START_COLOR = 0x0FFFF00; public static final Font GAME_START_FONT =
Font.getFont(Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_SMALL);
... }
En ella definimos el tamaño de la pantalla, el tamaño de nuestro personaje, las coordenadas (x,y) donde ubicar distintos elementos del juego, etc. De esta forma, para hacer una nueva versión sólo tendremos que editar este fichero y dar a cada elemento el tamaño y las coordenadas adecuadas.
Los juegos deben funcionar de manera fluida y dar una respuesta rápida al usuario para que estos resulten jugables y atractivos. Por lo tanto, será conveniente optimizar el código todo lo posible, sobretodo en el caso de los dispositivos móviles en el que trabajamos con poca memoria y CPUs lentas.
No debemos cometer el error de intentar escribir un código optimizado desde el principio. Es mejor comenzar con una implementación clara, y una vez funcione esta implementación, intentar optimizar todo lo que sea posible.
Para optimizar el código deberemos detectar primero en qué lugar se está invirtiendo más tiempo. Por ejemplo, si lo que está ralentizando el juego es el volcado de los gráficos, deberemos optimizar esta parte, mientras que si es la lógica interna del juego la que requiere un tiempo alto, deberemos fijarnos en cómo optimizar este código.
El dibujado de los gráficos suele ser bastante costoso. Para optimizar
este proceso deberemos intentar dibujar sólo aquello que sea necesario,
es decir, lo que haya cambiado de un fotograma al siguiente. Muchas veces en
la pantalla se está moviendo sólo un personaje pequeño,
sería una pérdida de tiempo redibujar toda la pantalla cuando
podemos repintar únicamente la zona en la que se ha movido este personaje.
Esto lo podemos hacer con una variante del método repaint
que nos permite redibujar sólo el área de pantalla indicada.
Por otro lado, dentro del código del juego deberemos utilizar las técnicas de optimización que conocemos, propias del lenguaje Java. Es importante intentar crear el mínimo número de objetos posibles, reutilizando objetos siempre que podamos. Esto evita el tiempo que requiere la instanciación de objetos y su posterior eliminación por parte del colector de basura.
También deberemos tener en cuenta que la memoria del móvil es
muy limitada, por lo que deberemos permitir que se desechen todos los objetos
que ya no necesitamos. Para que un objeto pueda ser eliminado por el colector
de basura deberemos poner todas las referencias que tengamos a dicho objeto
a null
, para que el colector de basura sepa que ya nadie va a poder
usar ese objeto por lo que puede eliminarlo.
Cuando diseñemos un juego deberemos identificar las distintas entidades que encontraremos en él. Normalmente en los juegos de acción en 2D tendremos una pantalla del juego, que tendrá un fondo y una serie de personajes u objetos que se mueven en este escenario. Estos objetos que se mueven en el escenario se conocen como sprites. Además, tendremos un motor que se encargará de conducir la lógica interna del juego. Podemos abstraer los siguientes componentes:
Figura 3. Componentes de un juego
A continuación veremos con más detalle cada uno de estos componentes, viendo como ejemplo las clases que MIDP 2.0 nos proporciona para crear cada uno de ellos.
Esto es lo que encontraremos en la pantalla de juego, mientras dure la partida. Sin embargo los juegos normalmente constarán de varias pantallas. Las más usuales son las siguientes:
Figura 4. Mapa de pantallas típico de un juego
Hemos de decidir qué API utilizar para desarrollar cada pantalla. La pantalla de juego claramente debe realizarse utilizando la API de bajo nivel. Sin embargo con esta API hemos visto que no podemos introducir texto de una forma sencilla. Si utilizamos la API de alto nivel para la pantalla de puntuaciones más altas se nos facilitará bastante la tarea. El inconveniente es que siempre tendrá un aspecto más atractivo y propio del juego si utilizamos la API de bajo nivel, aunque nos cueste más programarla.
Es importante poder salir en todo momento del juego, cuando el usuario quiera de terminar de jugar. Durante la partida se deberá permitir volver al menú principal, o incluso salir directamente del juego. Para las acciones de salir y volver al menú se deben utilizas las teclas asociadas a las esquinas de la pantalla (soft-keys). No es recomendable utilizar estas teclas para acciones del juego.
Los sprites hemos dicho que son todos aquellos objetos que aparecen en la escena que se mueven y/o podemos interactuar con ellos de alguna forma.
Estos objetos pueden estar animados. Para ello deberemos definir los distintos fotogramas (o frames) de la animación. Podemos definir varias animaciones para cada sprite, según las acciones que pueda hacer. Por ejemplo, si tenemos un personaje podemos tener una animación para andar hacia la derecha y otra para andar hacia la izquierda.
El sprite tendrá un determinado tamaño (ancho y alto), y cada fotograma será una imagen de este tamaño. Para no tener un número demasiado elevado de imágenes lo que haremos será juntar todos los fotogramas del sprite en una misma imagen, dispuestos como un mosaico.
Figura 5. Mosaico con los frames de un sprite
Cambiando el fotograma que se muestra del sprite en cada momento podremos
animarlo. En MIDP 2.0 se proporciona la clase Sprite
que nos permite
manejar este tipo de mosaicos para definir los fotogramas del sprite
y animarlo. Podemos crear el sprite de la siguiente forma:
Sprite personaje = new Sprite(imagen, ancho_fotograma, alto_fotograma);
Proporcionamos la imagen donde tenemos el mosaico de fotogramas, y definimos las dimensiones de cada fotograma. De esta forma esta clase se encargará de separar los fotogramas que hay dentro de esta imagen.
Cada fotograma tendrá un índice que se empezará a numerar
a partir de cero. La ordenación de los frames en la imagen se
realiza por filas y de izquierda a derecha, por lo que el frame de
la esquina superior izquierda será el frame 0
.
Podemos establecer el frame a mostrar actualmente con:
personaje.setFrame(indice);
Podremos definir determinadas secuencias de frames para crear animaciones.
Además el sprite se podrá desplazar por la pantalla, por lo que deberemos tener algún método para moverlo. El sprite tendrá una cierta localización, dada en coordenadas (x,y) de la pantalla, y podremos o bien establecer una nuevas coordenadas o desplazar el sprite respecto a su posición actual:
personaje.setPosition(x, y); personaje.move(dx, dy);
Con el primer método damos la posición absoluta donde queremos posicionar el sprite. En el segundo caso indicamos un desplazamiento, para desplazarlo desde su posición actual. Normalmente utilizaremos el primer método para posicionarlo por primera vez en su posición inicial al inicio de la partida, y el segundo para moverlo durante el transcurso de la misma.
Otro aspecto de los sprites es la interacción entre ellos.
Nos interesará saber cuando somos tocados por un enemigo o una bala para
disminuir la vida, o cuando alcanzamos nosotros a nuestro enemigo. Para ello
deberemos detectar las colisiones entre sprites. La colisión
con sprites de formas complejas puede resultar costosa de calcular.
Por ello se suele realizar el cálculo de colisiones con una forma aproximada
de los sprites con la que esta operación resulte más
sencilla. Para ello solemos utilizar el bounding box, es decir, un
rectángulo que englobe el sprite. La intersección de
rectángulos es una operación muy sencilla, podremos comprobarlo
simplemente con una serie de comparaciones menor que <
y mayor
que >
.
La clase Sprite
nos permite realizar esta comprobación
de colisiones utilizando un bounding box. Podemos comprobar si nuestro
personaje está tocando a un enemigo con:
personaje.collidesWith(enemigo, false);
Con el segundo parámetro a true
le podemos decir que compruebe
la colisión a nivel de pixels, es decir, que en lugar de usar
el bounding box compruebe pixel a pixel si ambos
sprites colisionan. Esto será bastante más costoso. Si
necesitamos hacer la comprobación a este nivel, podemos comprobar primero
si colisionan sus bounding boxes para descartar así de forma
eficiente bastantes casos, y en caso de que los bounding boxes si que
intersecten, hacer la comprobación a nivel de pixels para comprobar
si realmente colisionan o no. Normalmente las implementaciones harán
esto internamente cuando comprobemos la colisión a nivel de pixels.
Vamos a ver como ejemplo la creación del sprite de nuestro personaje
para el clon del Frogger. Para cada sprite podemos crear una subclase
de Sprite
que se especialice en el tipo de sprite concreto del
que se trate.
public class CrocSprite extends Sprite {
public final static int MOVE_UP = 0; public final static int MOVE_DOWN = 1; public final static int MOVE_LEFT = 2; public final static int MOVE_RIGHT = 3; public final static int MOVE_STAY = 4; int lastMove; public CrocSprite() { super(Resources.getImage(Resources.IMG_SPR_CROC),
CommonData.SPRITE_WIDTH, CommonData.SPRITE_HEIGHT); this.defineCollisionRectangle(CommonData.SPRITE_CROP_X,
CommonData.SPRITE_CROP_Y, CommonData.SPRITE_CROP_WIDTH,
CommonData.SPRITE_CROP_HEIGHT); reset(); }
public void reset() { // Inicializa frame y movimiento actual lastMove = MOVE_STAY; this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_UP);
// Inicializa posición this.setPosition(CommonData.SPRITE_INI_X, CommonData.SPRITE_INI_Y); } public void stay() { switch(lastMove) { case MOVE_UP: this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_UP); break; case MOVE_DOWN: this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_DOWN); break; case MOVE_LEFT: this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_LEFT); break; case MOVE_RIGHT: this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_RIGHT); break; } lastMove = MOVE_STAY; } public void die() { this.setFrameSequence(null); this.setFrame(CommonData.SPRITE_STAY_DIED); lastMove = MOVE_STAY; }
public void moveUp() { if(lastMove != MOVE_UP) { this.setFrameSequence(CommonData.SPRITE_MOVE_UP); } lastMove = MOVE_UP; this.move(0,-CommonData.SPRITE_STEP); this.nextFrame(); }
public void moveDown() { if(lastMove != MOVE_DOWN) { this.setFrameSequence(CommonData.SPRITE_MOVE_DOWN); } lastMove = MOVE_DOWN; this.move(0,CommonData.SPRITE_STEP); this.nextFrame(); }
public void moveLeft() { if(lastMove != MOVE_LEFT) { this.setFrameSequence(CommonData.SPRITE_MOVE_LEFT); } lastMove = MOVE_LEFT; this.move(-CommonData.SPRITE_STEP,0); this.nextFrame(); }
public void moveRight() { if(lastMove != MOVE_RIGHT) { this.setFrameSequence(CommonData.SPRITE_MOVE_RIGHT); } lastMove = MOVE_RIGHT; this.move(CommonData.SPRITE_STEP,0); this.nextFrame(); }
}
En este ejemplo vemos como para los movimientos que requieran animación
(como por ejemplo andar en las distintas direcciones) podemos indicar una secuencia
de frames que componen esta animación con setFrameSequence
,
y mediante el método nextFrame
ir pasando al siguiente.
Si queremos volver a disponer del conjunto de frames completo deberemos llamar
al método setFrameSequence
pasándole como parámetro
null
.
Otro problema que nos puede surgir es por ejemplo que no queramos que las colisiones se calculen con el rectángulo completo de nuestro sprite, sino sólo con un subrectángulo de éste. Por ejemplo, si en nuestro juego tenemos a un cocodrilo que debe cruzar la calle, no querremos que cuando un coche pise su cola se considere que lo han atropellado, el jugador se sentirá frustrado si cada vez que un coche roza su cola pierde una vida.
La parte inferior de la imagen del cocodrilo sólo contiene la cola,
de forma que por defecto, como el rectángulo de colisión es del
tamaño de la imagen, cuando haya un coche que colisione con esta parte
inferior se considerará que nos han atropellado. Podemos establecer un
nuevo rectángulo para el cálculo de colisiones con el método
defineCollisionRectangle
, de forma que ya no consideremos toda
la imagen, sino sólo el área de la misma en la que está
el cuerpo del cocodrilo.
En los juegos normalmente tendremos un fondo sobre el que se mueven los personajes. Muchas veces los escenarios del juego son muy extensos y no caben enteros en la pantalla. De esta forma lo que se hace es ver sólo la parte del escenario donde está nuestro personaje, y conforme nos movamos se irá desplazando esta zona visible para enfocar en todo momento el lugar donde está nuestro personaje. Esto es lo que se conoce como scroll.
El tener un fondo con scroll será más costoso computacionalmente, ya que siempre que nos desplacemos se deberá redibujar toda la pantalla, debido a que se está moviendo todo el fondo. Además para poder dibujar este fondo deberemos tener una imagen con el dibujo del fondo para poder volcarlo en pantalla. Si tenemos un escenario extenso, sería totalmente prohibitivo hacer una imagen que contenga todo el fondo. Esta imagen podría llegar a ocupar el tamaño máximo permitido para los ficheros JAR es muchos móviles.
Para evitar este problema lo que haremos normalmente en este tipo de juegos es construir el fondo como un mosaico. Nos crearemos una imagen con los elementos básicos que vamos a necesitar para nuestro fondo, y construiremos el fondo como un mosaico en el que se utilizan estos elementos.
Figura 6. Mosaico de elementos del fondo
Al igual que hacíamos con los sprites, tomaremos estos distintos elementos de una misma imagen. A cada elemento se le asociará un índice con el que lo referenciaremos posteriormente. El fondo lo crearemos como un mosaico del tamaño que queramos, en el que cada celda contendrá el índice del elemento que se quiere mostrar en ella.
La clase TiledLayer
de MIDP 2.0 nos permite realizar esto. Deberemos
especificar el número de filas y columnas que va a tener el mosaico,
y después la imagen que contiene los elementos y el ancho y alto de cada
elemento.
TiledLayer fondo = new TiledLayer(columnas, filas, imagen, ancho, alto);
Figura 7. Índices de los elementos del mosaico
De esta forma el fondo tendrá un tamaño en pixels de
(columnas*ancho)x(filas*alto)
. Ahora podemos establecer el elemento
que contendrá cada celda del mosaico con el método:
fondo.setCell(columna, fila, indice);
Como indice
especificaremos el índice del elemento en la
imagen que queremos incluir en esa posición del mosaico, 0
si queremos dejar esa posición vacía con el color del fondo, o
un número negativo para crear celdas con animación.
Figura 8. Ejemplo de fondo construido con los elementos anteriores
A continuación mostramos el código que podríamos utilizar para generar la carretera de nuestro juego:
public class Background extends TiledLayer {
public Background() { super(CommonData.BG_H_TILES, CommonData.BG_V_TILES,
Resources.getImage(Resources.IMG_BG_STAGE_1),
CommonData.BG_TILE_WIDTH, CommonData.BG_TILE_HEIGHT); }
public void reset(StageData stage) { TrackData [] tracks = stage.tracks; int nTracks = tracks.length; int row; // Filas superiores de cesped for(row=0;row<CommonData.BG_V_TOP_TILES;row++) { for(int col=0;col<CommonData.BG_H_TILES; col++) { this.setCell(col, row, CommonData.BG_TILE_GRASS); } } // Margen superior de la carretera for(int col=0;col<CommonData.BG_H_TILES; col++) { this.setCell(col, row, CommonData.BG_TILE_TOP); } row++; // Parte interna de la carreteta for(;row<CommonData.BG_V_TOP_TILES + nTracks - 1;row++) { for(int col=0;col<CommonData.BG_H_TILES; col++) { this.setCell(col, row, CommonData.BG_TILE_CENTER); } }
// Margen inferior de la carretera for(int col=0;col<CommonData.BG_H_TILES; col++) { this.setCell(col, row, CommonData.BG_TILE_BOTTOM); } row++; // Hierba en la zona inferior for(;row<CommonData.BG_V_TILES;row++) { for(int col=0;col<CommonData.BG_H_TILES; col++) { this.setCell(col, row, CommonData.BG_TILE_GRASS); } } } }
En la pantalla se dibujarán todos los elementos anteriores para construir la escena del juego. De esta manera tendremos el fondo, nuestro personaje, los enemigos y otros objetos que aparezcan durante el juego, además de marcadores con el número de vidas, puntuación, etc.
La pantalla la vamos a dibujar por capas. Cada sprite y cada fondo
que incluyamos será una capa, de esta forma poniendo una capa sobre otra
construiremos la escena. Tanto la clase Sprite
como la clase TiledLayer
heredan de Layer
, que es la clase que define de forma genérica
las capas, por lo que podrán comportarse como tales. Todas las capas
podrán moverse o cambiar de posición, para mover de esta forma
su contenido en la pantalla.
Construiremos el contenido de la pantalla superponiendo una capa sobre otra.
Tenemos la clase LayerManager
en MIDP 2.0 que nos permitirá
construir esta superposición de capas. Este objeto contendrá todo
lo que se vaya a mostrar en pantalla, encargándose él internamente
de gestionar y dibujar esta superposición de capas. Podemos crear este
objeto con:
LayerManager escena = new LayerManager();
Ahora deberemos añadir por orden las capas que queramos que se muestren. El orden en el que las añadamos indicará el orden z, es decir, la profundidad de esta capa en la escena. La primera capa será la más cercana al punto de vista del usuario, mientras que la última será la más lejana. Por lo tanto, las primeras capas que añadamos quedarán por delante de las siguientes capas. Para añadir capas utilizaremos:
escena.append(personaje); escena.append(enemigo); escena.append(fondo);
También podremos insertar capas en una determinada posición de
la lista, o eliminar capas de la lista con los métodos insert
y remove
respectivamente.
El área dibujada del LayerManager
puede ser bastante extensa,
ya que abarcará por lo menos toda la extensión del fondo que hayamos
puesto. Deberemos indicar qué porción de esta escena se va a mostrar
en la pantalla, especificando la ventana de vista. Moviendo esta ventana podremos
implementar scroll. Podemos establecer la posición y tamaño
de esta ventana con:
escena.setViewWindow(x, y, ancho, alto);
La posición de esta ventana de vista es referente al sistema de coordenadas
de la escena (de la clase LayerManager
).
Debemos tener en cuenta al especificar el tamaño del visor que diferentes modelos de móviles tendrán pantallas de diferente tamaño. Podemos hacer varias cosas para que el juego sea lo más portable posible. Podríamos crear un visor del tamaño mínimo de pantalla que vayamos a considerar, y en el caso de que la pantalla sea de mayor tamaño mostrar este visor centrado. Otra posibilidad es establecer el tamaño de la ventana de vista según la pantalla del móvil, haciendo que en los móviles con pantalla más grande se vea un mayor trozo del escenario.
Una vez hemos establecido esta ventana de vista, podemos dibujarla en el contexto
gráfico g
con:
escena.paint(g, x, y);
Donde daremos las coordenadas donde dibujaremos esta vista, dentro del espacio de coordenadas del contexto gráfico indicado.
Hemos visto que los juegos suelen constar de varias pantallas. En la mayoría de los casos tenemos una pantalla de título, la pantalla en la que se desarrolla la partida, la pantalla de game over, una pantalla de demo en la que vemos el juego funcionar automáticamente como ejemplo, etc.
Normalmente lo primero que veremos será el título, de aquí podremos pasar al juego o al modo demo. Si transcurre un determinado tiempo sin que el usuario pulse ninguna tecla pasará a demo, mientras que si el usuario pulsa la tecla start comienza el juego. La demo finalizará pasado un tiempo determinado, tras lo cual volverá al título. El juego finalizará cuando el jugador pierda todas sus vidas, pasando a la pantalla de game over, y de ahí volverá al título.
Vamos a centrarnos en cómo desarrollar la pantalla en la que se desarrolla la partida. Aquí tendremos lo que se conoce como ciclo del juego (o game loop). Se trata de un bucle infinito en el que tendremos el código fuente que implementa el funcionamiento del juego. Dentro de este bucle se efectúan las siguientes tareas básicas:
La clase GameCanvas
es un tipo especial de canvas para
el desarrollo de juegos presente en MIDP 2.0. Esta clase nos ofrecerá
los métodos necesarios para realizar este ciclo del juego, ofreciéndonos
un acceso a la entrada del usuario y un método de render adecuados
para este tipo de aplicaciones. Para hacer la pantalla del juego crearemos una
subclase de GameCanvas
en la que introduciremos el ciclo del juego.
La forma en la que dibujaremos los gráficos utilizando esta clase será
distinta a la que hemos visto para la clase Canvas
. En el caso
del Canvas
utilizamos render pasivo, es decir, nosotros
definimos en el método paint
la forma en la que se dibuja
y es el sistema el que llama a este método. Ahora nos interesa poder
dibujar en cada iteración los cambios que se hayan producido en la escena
directamente desde el bucle del ciclo del juego. Es decir, seremos nosotros
los que decidamos el momento en el que dibujar, utilizaremos render
activo. Para ello en cualquier momento podremos obtener un contexto gráfico
asociado al GameCanvas
desde dentro de este mismo objeto con:
Graphics g = getGraphics();
Este contexto gráfico estará asociado a un backbuffer del canvas de juegos. De esta forma durante el ciclo del juego podremos dibujar el contenido de la pantalla en este backbuffer. Cuando queramos que este backbuffer se vuelque a pantalla, llamaremos al método:
flushGraphics();
Existe también una versión de este método en la que especificamos la región que queremos que se vuelque, de forma que sólo tenga que volcar la parte de la pantalla que haya cambiado.
Vamos a ver ahora el esqueleto de un ciclo del juego básico que podemos realizar:
Graphics g = getGraphics();
while(true) { leeEntrada();
actualizaEscena();
dibujaGraficos(g); flushGraphics(); }
Será conveniente dormir un determinado tiempo tras cada iteración
para controlar así la velocidad del juego. Vamos a considerar que CICLO
es el tiempo que debe durar cada iteración del juego. Lo que podremos
hacer es obtener el tiempo que ha durado realmente la iteración, y dormir
el tiempo restante hasta completar el tiempo de ciclo. Esto podemos hacerlo
de la siguiente forma:
Graphics g = getGraphics(); long t1, t2, td;
while(true) { t1 = System.currentTimeMillis();
leeEntrada();
actualizaEscena();
dibujaGraficos(); flushGraphics(); t2 = System.currentTimeMillis(); td = t2 - t1; td = td<CICLO?td:CICLO; try { Thread.sleep(CICLO - td); } catch(InterruptedException e) { } }
Todo este bucle donde se desarrolla el ciclo del juego lo ejecutaremos como un hilo independiente. Un buen momento para poner en marcha este hilo será el momento en el que se muestre el canvas:
public class Juego extends GameCanvas implements Runnable { public void showNotify() {
Thread t = new Thread(this);
t.start();
}
public void run() {
// Ciclo del juego
...
}
}
Hemos visto cómo implementar el ciclo del juego en el que en cada momento se lea la entrada del usuario, se actualice la escena, y se vuelquen los gráficos a la pantalla.
Sin embargo, este ciclo no siempre deberá comportarse de la misma forma. El juego podrá pasar por distintos estados, y en cada uno de ellos deberán realizarse una tareas e ignorarse otras.
Podemos modelar esto como una máquina de estados, en la que en cada momento, según el estado actual, se realicen unas funciones u otras, y cuando suceda un determinado evento, se pasará a otro estado.
Por ejemplo, en un juego podremos encontrar comúnmente los siguientes estados durante el desarrollo del mismo.
Figura 9. Diagrama de estados del juego
La clase Canvas
en MIDP 1.0 ofrece facilidades para leer la entrada
de usuario para juegos. Hemos visto en el tema anterior que asocia a los códigos
de las teclas lo que se conoce como acciones de juegos, lo cual nos facilitará
el desarrollo de aplicaciones que se controlan mediante estas acciones (arriba,
abajo, izquierda, derecha, fuego), que principalmente son juegos.
Cuando se lee una tecla en cualquier evento de pulsación de teclas (keyPressed
,
keyRepeated
o keyReleased
), se nos proporciona como
parámetro el código de dicha tecla. Podemos comprobar a qué
acción de juego corresponde dicha tecla de forma independiente a la plataforma
con el método getGameAction
como se muestra en el siguiente
ejemplo:
public void keyPressed(int keyCode) {
int action = getGameAction(keyCode);
if (action == LEFT) { moverIzquierda(); } else if (action == RIGHT) { moverDerecha();
} else if (action == FIRE) { disparar();
} }
También podemos obtener la tecla principal asociada a una acción
de juego con el método getKeyCode
, pero dado que una acción
puede estar asociada a varias teclas, si usamos este método no estaremos
considerando las teclas secundarias asociadas a esa misma acción. A pesar
de que en algunos ejemplos podamos ver código como el que se muestra
a continuación, esto no debe hacerse:
public class MiCanvas extends Canvas {
// NO HACER ESTO!
int izq, der, fuego;
public MiCanvas() { izq = getKeyCode(LEFT); der = getKeyCode(RIGHT); fuego = getKeyCode(FIRE); }
public void keyPressed(int keyCode) { if (keyCode == izq) { moverIzquierda(); } else if (keyCode = der) { moverDerecha(); } else if (keyCode = fuego) { disparar(); } }
}
Hemos de tener en cuenta que muchos modelos de móviles no nos permiten mantener pulsadas más de una tecla al mismo tiempo. Hay otros que, aunque esto se permita, el joystick no puede mantener posiciones diagonales, sólo se puede pulsar una de las cuatro direcciones básicas al mismo tiempo.
Esto provoca que si no tenemos suficiente con estas cuatro direcciones básicas y necesitamos realizar movimientos en diagonal, tendremos que definir nosotros manualmente los códigos de las teclas para cada una de estas acciones. Esto reducirá portabilidad a la aplicación, será el precio que tendremos que pagar para poder establecer una serie de teclas para movimiento diagonal.
Si definimos nosotros las acciones directamente a partir de los códigos de teclas a bajo nivel deberemos intentar respetar en la medida de lo posible el comportamiento que suele tener cada tecla en los móviles. Por ejemplo, las teclas asociadas a las esquinas de la pantalla (soft keys) deben utilizarse para terminar el juego y volver al menú principal o bien salir directamente del juego.
Hasta ahora hemos visto las facilidades que nos ofrece MIDP para leer la entrada
del usuario en los juegos desde su versión 1.0. Sin embargo, MIDP 2.0
incluye facilidades adicionales en la clase GameCanvas
.
Podremos leer el estado de las teclas en cualquier momento, en lugar de tener que definir callbacks para ser notificados de las pulsaciones de las teclas. Esto nos facilitará la escritura del código, pudiendo obtener directamente esta información desde el ciclo del juego.
Podemos obtener el estado de las teclas con el método getKeyStates
como se muestra a continuación:
int keyState = getKeyStates();
Esto nos devolverá un número entero en el que cada uno de sus
bits representa una tecla. Si el bit vale 0
la
tecla está sin pulsar, y si vale 1
la tecla estará
pulsada. Tenemos definidos los bits asociados a cada tecla como constantes,
que podremos utilizar como máscaras booleanas para extraer el
estado de cada tecla a partir del número entero de estado obtenido:
GameCanvas.LEFT_PRESSED |
Movimiento a la izquierda |
GameCanvas.RIGHT_PRESSED |
Movimiento a la derecha |
GameCanvas.UP_PRESSED |
Movimiento hacia arriba |
GameCanvas.DOWN_PRESSED |
Movimiento hacia abajo |
GameCanvas.FIRE_PRESSED |
Fuego |
Por ejemplo, para saber si están pulsadas las teclas izquierda o derecha haremos la siguiente comprobación:
if ((keyState & LEFT_PRESSED) != 0) { moverIzquierda(); } if ((keyState & RIGHT_PRESSED) != 0) { moverDerecha(); }
Vamos a ver ahora un ejemplo de ciclo de juego sencillo completo:
public class CanvasJuego extends GameCanvas implements Runnable {
private final static int CICLO = 50;
public CanvasJuego() { super(true); } public void showNotify() { Thread t = new Thread(this); t.start();
} public void run() { Graphics g = getGraphics(); long t1, t2, td;
// Carga sprites
CrocSprite personaje = new CrocSprite(); personaje.reset();
// Crea fondo Background fondo = new Background(Resources.getStage(0)); fondo.reset();
// Crea escena
LayerManager escena = new LayerManager();
escena.append(personaje);
escena.append(fondo);
while(true) { t1 = System.currentTimeMillis();
// Lee entrada del teclado int keyState = getKeyStates();
if ((keyState & LEFT_PRESSED) != 0) { personaje.moveLeft(); } else if ((keyState & RIGHT_PRESSED) != 0) { personaje.moveRight(); } else if ((keyState & UP_PRESSED) != 0) { personaje.moveUp(); } else if ((keyState & DOWN_PRESSED) != 0) { personaje.moveDown(); }
escena.paint(g); flushGraphics();
t2 = System.currentTimeMillis(); td = t2 - t1; td = td<CICLO?td:CICLO;
try { Thread.sleep(CICLO - td); } catch(InterruptedException e) {} } }
}
Una vez hemos visto todos los elementos necesarios para desarrollar nuestro juego, vamos a ver un ejemplo de motor de juego completo que utilizaremos para nuestro clon de Frogger.
La siguiente clase será la encargada de realizar el ciclo básico del juego. En ella implementaremos la posibilidad de realizar pausas en el juego, lo cual ya hemos comentado que es muy importante en juegos para móviles:
public class GameEngine extends GameCanvas implements Runnable {
// Milisegundos que transcurren entre dos frames consecutivos public final static int CICLO = 50;
// Codigos de las teclas soft
public final static int LEFT_SOFTKEY = -6; public final static int RIGHT_SOFTKEY = -7; // Escena actual Scene escena; // Estado de pausa boolean isPaused;
// Hilo del juego Thread t; // Fuente pausa Font font;
public GameEngine(Scene escena) { super(false); this.setFullScreenMode(true);
// Establece la escena actual this.escena = escena;
// Inicializa fuente para el texto de la pausa font = Font.getFont(Font.FACE_SYSTEM,
Font.STYLE_BOLD, Font.SIZE_MEDIUM); }
// Cambia la escena
public void setScene(Scene escena) { this.escena = escena; } public void showNotify() { start(); } public void hideNotify() { stop(); }
public void keyPressed(int keyCode) {
if(isPaused) { if(keyCode == LEFT_SOFTKEY) { start(); } else if(keyCode == RIGHT_SOFTKEY) { Resources.midlet.exitGame(); } } else { if(keyCode == LEFT_SOFTKEY || keyCode == RIGHT_SOFTKEY) { stop(); } } }
// Pausa el juego
public synchronized void stop() { t = null;
isPaused = true;
Graphics g = getGraphics(); render(g); flushGraphics(); }
// Reanuda el juego
public synchronized void start() { isPaused = false;
t = new Thread(this); t.start(); }
// Ciclo del juego
public void run() {
// Obtiene contexto gráfico Graphics g = getGraphics();
while (t == Thread.currentThread()) {
long t_ini, t_dif; t_ini = System.currentTimeMillis();
// Lee las teclas int keyState = this.getKeyStates();
// Actualiza la escena
escena.tick(keyState);
// Vuelca los graficos render(g); flushGraphics();
t_dif = System.currentTimeMillis() - t_ini;
// Duerme hasta el siguiente frame
if (t_dif < CICLO) { try { Thread.sleep(CICLO - t_dif); } catch (InterruptedException e) { } } } }
public void render(Graphics g) {
escena.render(g); if(isPaused) { g.setColor(0x000000); for(int i=0;i<CommonData.SCREEN_HEIGHT;i+=2) { g.drawLine(0,i,CommonData.SCREEN_WIDTH,i); }
g.setColor(0x0FFFF00); g.setFont(font); g.drawString("Reanudar", 0, CommonData.SCREEN_HEIGHT,
Graphics.LEFT | Graphics.BOTTOM); g.drawString("Salir", CommonData.SCREEN_WIDTH,
CommonData.SCREEN_HEIGHT,
Graphics.RIGHT | Graphics.BOTTOM); g.drawString("PAUSADO", CommonData.SCREEN_WIDTH/2,
CommonData.SCREEN_HEIGHT/2,
Graphics.HCENTER | Graphics.BOTTOM); }
} }
Con los método stop
y start
definidos en esta
clase podremos pausar y reanudar el juego. En el estado pausado se detiene el
hilo del ciclo del juego y en la pantalla se muestra el mensaje de que el juego
ha sido pausado.
Podemos ver también que este ciclo de juego es genérico, deberemos
proporcionar una clase que implemente la interfaz Scene
en la que
se especificará el comportamiento y el aspecto de la escena. Esta interfaz
tiene los siguientes métodos:
public interface Scene { public void tick(int k); public void render(Graphics g); }
Con tick
nos referimos a cada actualización instantánea
de la escena. En cada ciclo se invocará una vez este método tick
para que la escena se actualice según la entrada del usuario y las interacciones
entre los distintos objetos que haya en ella. Por otro lado, en render definiremos
la forma de dibujar la escena.
La escena para nuestro clon de Frogger será la siguiente:
public class GameScene implements Scene {
public static int E_INICIO = 0; public static int E_JUGANDO = 1; public static int E_MUERTO = 2; public static int E_CONSEGUIDO = 3; int numLives; int state; int timer; int stage; CrocSprite croc; Background bg; Tracks tracks; Image face; Vector cars; public GameScene() { croc = new CrocSprite(); bg = new Background(); tracks = new Tracks(); face = Resources.getImage(Resources.IMG_FACE_LIVES); cars = new Vector(); reset(); } // Reiniciado de la partida public void reset() { numLives = CommonData.NUM_LIVES; stage = 0;
reset(stage); } // Reiniciado de una fase public void reset(int nStage) { StageData stage = Resources.stageData[nStage]; croc.reset(); bg.reset(stage); tracks.reset(stage); cars.removeAllElements(); this.setState(E_INICIO); }
// Cambia el estado actual del juego public void setState(int state) { this.state = state;
if(state==E_INICIO) { timer = 50; } else if(state==E_MUERTO) { timer = 30; croc.die(); } else if(state==E_CONSEGUIDO) { timer = 50; } } public void tick(int keyState) {
// Decrementa el tiempo de espera if(state==E_INICIO || state==E_MUERTO || state==E_CONSEGUIDO) { timer--; }
// Comienza el juego if(state==E_INICIO && timer <= 0) { this.setState(E_JUGANDO); } // Reinicia el nivel o termina el juego if(state==E_MUERTO && timer <= 0) { numLives--; if(numLives<0) { Resources.midlet.showTitle(); } else { this.reset(stage); return; } }
// Pasa de fase if(state==E_CONSEGUIDO && timer <= 0) { stage++; if(stage >= Resources.stageData.length) { stage = 0; }
this.reset(stage); return; } // Permite controlar el personaje si el estado es JUGANDO
if(state==E_JUGANDO) {
// Control del sprite if( (keyState & GameCanvas.UP_PRESSED)!=0
&& croc.getY() > 0 ) { croc.moveUp(); } else if( (keyState & GameCanvas.DOWN_PRESSED)!=0
&& croc.getY() < CommonData.SCREEN_HEIGHT
- CommonData.SPRITE_HEIGHT) { croc.moveDown(); } else if( (keyState & GameCanvas.LEFT_PRESSED)!=0
&& croc.getX() > 0) { croc.moveLeft(); } else if( (keyState & GameCanvas.RIGHT_PRESSED)!=0
&& croc.getX() < CommonData.SCREEN_WIDTH
- CommonData.SPRITE_WIDTH) { croc.moveRight(); } else { croc.stay(); } } // Crea nuevos coches en los carriles si ha llegado el momento tracks.checkTracks(cars);
// Actualiza coches y comprueba colisiones Enumeration enum = cars.elements(); while(enum.hasMoreElements()) { CarSprite car = (CarSprite)enum.nextElement(); car.tick();
if(state == E_JUGANDO && car.collidesWith(croc, false)) { this.setState(E_MUERTO); } } // Ha conseguido cruzar la carretera
if(state == E_JUGANDO && croc.getY()<CommonData.SPRITE_END_Y) { this.setState(E_CONSEGUIDO); } return; }
public void render(Graphics g) {
bg.paint(g); croc.paint(g); Enumeration enum = cars.elements(); while(enum.hasMoreElements()) { CarSprite car = (CarSprite)enum.nextElement(); car.paint(g); } for(int i=0;i<this.numLives;i++) { g.drawImage(face, i*CommonData.FACE_WIDTH, 0,
Graphics.TOP | Graphics.LEFT); }
if(state==E_INICIO) { g.setFont(CommonData.STAGE_TITLE_FONT); g.setColor(CommonData.STAGE_TITLE_COLOR); g.drawString(Resources.stageData[stage].title,
CommonData.STAGE_TITLE_X, CommonData.STAGE_TITLE_Y,
Graphics.HCENTER | Graphics.TOP); }
if(state==E_CONSEGUIDO) { g.setFont(CommonData.STAGE_TITLE_FONT); g.setColor(CommonData.STAGE_TITLE_COLOR); g.drawString(CommonData.STAGE_COMPLETED_TEXT,
CommonData.STAGE_TITLE_X, CommonData.STAGE_TITLE_Y,
Graphics.HCENTER | Graphics.TOP); } }
}
Los métodos reset
nos permiten reiniciar el juego. Si no
proporcionamos ningún parámetro, se reiniciará el juego
desde el principio, nos servirá para empezar una nueva partida. Si proporcionamos
como parámetro un número de fase, comenzaremos a jugar desde esa
fase con las vidas que nos queden actualmente. Esta segunda forma nos servirá
para empezar de nuevo una fase cuando nos hayan matado, o cuando hayamos pasado
al siguiente nivel.
De esta forma no tendremos que instanciar nuevos objetos, sino que simplemente llamaremos a este método para reiniciar los valores del objeto actual.
Por otro lado podemos observar en este ejemplo cómo se ha modelado la
máquina de estados para el juego. Tenemos los estados E_INICIO
,
E_JUGANDO
, E_MUERTO
y E_CONSEGUIDO
. El
estado E_JUGANDO
durará hasta que nos maten o hayamos pasado
a la siguiente fase. Los demás estados durarán un tiempo fijo
durante el cual se mostrará algún gráfico o mensaje en
pantalla, y una vez pasado este tiempo se pasará a otro estado.
Podemos ver que en tick
es donde se implementa toda la lógica
del juego. Dentro de este método podemos ver las distintas comprobaciones
y acciones que se realizan, que variarán según el estado actual.
En render
podemos ver también algunos elementos que se
dibujarán sólo en determinados estados.