libGDX es lo que se conoce actualmente como motor de videojuegos (game engine). Desde el punto de vista de un programador, libGDX proporciona un framework para el desarrollo de videojuegos en lenguaje Java.
Entre sus principales características destacan las siguientes:
Actualmente incluso cuenta con una Wiki muy completa con muchísima documentación
Un frame es cada uno de los fotogramas que se visualizan en pantalla en un videojuego. Si echamos un vistazo al ciclo de vida de un videojuego vemos que éste se encuentra continuamente dentro de un bucle que se ejecuta ininterrupidamente mientras dure su ejecución. Así, en cada iteración del bucle se pintará un frame en la pantalla. Dependerá de cuantos bucles sea capaz de pintar el ordenador por segundo para determinar los FPS (frames por segundo).
Es la acción por la cual se pinta (se renderiza) cada uno de los frames en la pantalla.
Es una medida que indica cuántos Frames Por Segundo es capaz de renderizar el ordenador que ejecuta el juego. Cuántos mas FPS sea capaz de procesar, más fluido se moverá. Hay que tener en cuenta que un número alto de FPS (60, por ejemplo) significa que el ordenador es capaz de procesar y renderizar cada frame en muy poco tiempo (delta time) y eso hará que los movimientos del juego sean más suaves al no pasar mucho tiempo entre frames. Si por el contrario, al ordenador le cuesta mucho tiempo procesar y renderizar cada frame, entre dos frames diferentes habrán podido pasar muchas cosas y será molesto para el jugador, puesto que todo parece que se mueve a golpes ocurriendo demasiadas cosas entre frames ante las cuales no es posible reaccionar.
Actualmente se considera que a partir de 30 FPS se proporciona una jugabilidad fluida.
Una textura es cada una de las imágenes 2D que el ordenador tiene que cargar en memoria para poderlas renderizar en la pantalla durante la ejecución del videojuego.
Hay que tener en cuenta que el videojuego necesita tener almacenada en memoria cualquier textura que haya que renderizar durante la partida. Por eso, en muchos juegos hay un proceso de carga al inicio de su ejecución, donde se cargan todas las texturas (y demás recursos) de forma que luego sólo haya que renderizarlos y pueda hacerse en el menor tiempo posible. Si el espacio que ocupan las texturas es demasiado grande, habrá que cargar las texturas de cada nivel al inicio del mismo o incluso dividir los niveles de forma que a mitad se establezca de alguna manera un momento para cargar las necesarias en cada parte.
Representa a una textura con las coordenadas x e y para determinar su posición en la pantalla. Desde el punto de vista del programador permite que en un solo objeto tengamos la textura y las coordenadas x e y de la misma.
Es un conjunto de texturas 2D que representa la secuencia de movimientos de algún elemento del videojuego (personaje, item, enemigo, . . .). Así, cada elemento animado tendrá una animación diferente para cada movimiento que estará compuesto de un númeo variable de texturas. Cuando todas esas texturas se muevan formarán la animación.
Es un conjunto de texturas almacenadas en un mismo fichero. La idea es reducir el tiempo de carga de los recursos puesto que es mucho más rápido cargar un fichero con un número determinado de texturas que cargar ese mismo número de texturas en ficheros separados. Una vez cargado el atlas, utilizando la API de libGDX podremos acceder a cada una de las diferentes texturas por separado o bien por animaciones por lo que además de reducir el tiempo de carga nos hace más fácil el acceso a estos recursos.
Si el videojuego no tienen un número muy alto de texturas podremos crear un solo TextureAtlas y cargarlo al inicio del videojuego. De esa manera no tendremos que realizar accesos a disco durante la partida y se reducirá en gran medida el tiempo que el ordenador tarda en procesar y renderizar un frame, mejorando los FPS lo que afectará al rendimiento general del juego.
Si por el contrario, son demasiadas las texturas, siempre podremos crear varios atlas (uno por nivel, uno por mundo, . . .) y realizar la carga del mismo al inicio de cada nivel o mundo. En ningún caso acceder a disco mientras el usuario se encuentra jugando.
Es el tiempo que tarda el ordenador en procesar y renderizar un frame del videojuego. En libGDX se mide en segundos por lo que habrá que tener en cuenta que son números muy cercanos a cero.
Gráfico de BNG (Build New Games)1)
Este parámetro nos servirá para ajustar la velocidad del juego al equipo que lo ejecuta. Si no contamos con el delta time y un personaje se mueve 10px por la acción del usuario, hay que tener en cuenta que el personaje se moverá más rápido dependiendo del ordenador. Toda la lógica del juego se ejecuta dentro de un bucle (ver ciclo de vida de un juego) por lo que un ordenador potente tardará menos tiempo en hacerlo y lo ejecutará más veces, moviendo así más rápido al jugador.
. . . // El usuario mueve al personaje jugador.posicion += 10; . . .
En caso de que utilicemos delta time como coeficiente a multiplicar hará que se igualen las velocidades. Para ordenador potentes dt tendrá un valor bajo y hará que el jugador se mueva más lento. En ordenadores menos potentes dt tendrá un valor más alto y hará que se mueva más rápido. En definitiva, se equilibrarán las velocidades haciendo el juego independiente de la máquina donde se lance (siempre y cuando la máquina cumpla los requisitos mínimos)
. . . // El usuario mueve al personaje jugador.posicion += 10 * dt; . . .
El HUD hace referencia a la información del juego que se muestra al usuario en todo momento. Normalmente serán las vidas restantes, la puntuación, algunos objetos que posea el personaje en un momento dado, . . .
A continuación se muestra el ciclo de vida por el que todo videojuego desarrollado en libGDX pasa.
Básicamente el juego se desarrolla dentro del método render()
que se ejecuta por el framework de forma continua en forma de bucle. Antes de llegar a ese método se ejecutan los métodos create()
y resize()
y cuando la aplicación termina, se ejecutan los métodos pause()
y dispose()
antes de hacerlo completamente.
También se puede observar que, durante la ejecución del bucle principal con el método render()
, el juego puede ser pausado para luego continuar su ejecución, pasando por la ejecución de los métodos pause()
y resume()
. El paso a este estado de pausa y la ejecución de estos métodos no dependerá de nosotros sino que tendremos que dejar allí el código necesario para cuando se produzca el evento correspondiente. Por ejemplo, supongamos que estamos jugando a un juego en el móvil y nos llaman por teléfono, será libGDX el encargado de invocar al método pause()
en ese preciso instante para que la partida se pause mientras hablamos. De forma similar, al colgar, será libGDX quién invocará al método resume()
para continuar jugando donde lo hayamos dejado.
De forma parecida, seremos nosotros quienes tendremos que dejar el código necesario en los métodos create()
y resize()
para realizar la carga de recursos del juego antes de comenzar a ejecutarse el bucle principal en render()
y también tendremos que liberar los recursos utilizados cuando la aplicación vaya a terminar pasando por los métodos pause()
y dispose()
.
Para representar los elementos del juego 2D en pantalla, libGDX utilizar el sistema de coordenadas cartesiano que todos conocemos, utilizando dos parámetros x e y según los ejes de dicho sistema.
Hay que tener en cuenta que para cualquier recurso representado en el juego, sus coordenadas (x,y) marcarán la posición de su esquina inferior izquierda por lo que siempre habrá que tener en cuenta su altura y anchura para realizar cálculos como su colisión con otros elementos.
La clase SpriteBatch es la encargada de dibujar en pantalla las texturas, sprites o frames de una animación. También se utilizar para pintar texto en pantalla (HUD, Head-Up Display).
. . . spriteBatch.begin(); // Pinta en pantalla los elementos que sean necesarios spriteBatch.draw(personaje.getTextura() , personaje.getX(), personaje.getY()); for (Enemigo enemigo : enemigos) spriteBatch.draw(enemigo.getTextura() , enemigo.getX(), enemigo.getY()); spriteBatch.end(); . . .
Para pintar texto en pantalla tendremos que hacer uso de una fuente con la clase BitmapFont
. . . // Carga la fuente BitmapFont font = new BitmapFont(Gdx.files.internal("ui/default.fnt")); . . . spriteBatch.begin(); font.draw(spriteBatch, "Esto es un texto", xPosition, yPosition); spriteBatch.end(); . . .
La clase Texture representa una imagen en memoria. Es la forma de representarla en el código para luego, por ejemplo, pintarla en la pantalla. Solo almacena la imagen, no la posición que ésta debería ocupar en la pantalla
. . . // Carga una textura en memoria Texture texture = new Texture(Gdx.files.internal("items/gun.png")); // Pinta la textura en una posicion (x, y) determinada de la pantalla spriteBatch.draw(texture , posicionX , posicionY); . . .
La clase TextureRegion representa una región delimitada de una textura
. . . Texture texture = new Texture(Gdx.files.internal("items/weapons.png")); // "Recorta" una region de la textura indicando x, y, anchura, altura TextureRegion textureRegion = new TextureRegion(texture, 20, 20, 50, 50); // Pinta esa zona en pantalla en una posicion (x, y) determinada de la pantalla spriteBatch.draw(textureRegion , posicionX , posicionY); . . .
La clase Sprite representa una textura junto con su posición (x, y) en la pantalla
. . . Texture texture = new Texture(Gdx.files.internal("items/weapons.png")); // Crea un sprite indicando la region de la textura asignada y su anchura y altura Sprite sprite = new Sprite(texture); // Asigna posición x, y en la pantalla sprite.setPosition(10, 30); // Es posible asignar una rotación al sprite sprite.setRotation(90f); // Pinta el sprite en pantalla en su posición sprite.draw(spriteBatch); . . .
La clase Array se utiliza como sustitución de clases como ArrayList
en libGDX al estar optimizada para su uso en el desarrollo de videojuegos
. . . Array<Enemigo> enemigos = new Array<>(); . . . // Permite eliminar elementos del array mientras se recorre con un foreach for (Enemigo enemigo : enemigos) { if (. . .) enemigos.removeValue(enemigo, true); } . . .
La clase TextureAtlas representa en memoria un mapa de texturas. (Más información)
. . . // Carga el TextureAtlas en memoria TextureAtlas atlas = new TextureAtlas(Gdx.files.internal("characters/characters. pack")); // Accede a una textura del atlas por su nombre Texture texturePlayer = atlas.findRegion("player").getTexture(); // Accede a un grupo de texturas por su nombre Animation enemyAnimation = new Animation(0.25f, atlas.findRegions("enemy")); . . .
La clase Animation permite almacenar un conjunto de frames (texturas) como una animación y permite así añadir movimiento de una forma sencilla a un conjunto de texturas relacionadas.
. . . // Cargamos un mapa de texturas en el objeto atlas Animation enemyAnimation = new Animation(0.25f, atlas.findRegions("enemy")); . . . // Lo ejecutamos dentro del bucle que genera el método render // stateTime contiene el tiempo de juego TextureRegion currentFrame = enemyAnimation.getKeyFrame(stateTime, true); spriteBatch.draw(curentFrame, position.x, position.y); . . .
La clase Vector2 representa un objeto compuesto por dos valores x
e y
para almacenar la posición de algún elemento en pantalla. También se puede utilizar para almacenar otros parámetros como pueden ser la velocidad de algún elemento en ambos ejes de la pantalla. Algunos métodos útiles son:
scl(float)
Permite escalar los valores x e y con respecto a un valor (Muy útil para hacerlo con delta time)add(Vector2)
Permite sumar dos vectores. . . Vector2 position = new Vector2(100, 200); . . . // Es posible acceder directamente a los valores x e y spriteBatch.draw(texture, position.x, position.y); . . .
La clase Rectangle permite almacenar los parámetros de la representación de un rectángulo para utilizarlo como rectángulo de colisión de un elemento del juego. Se almacena su posición (x,y) y su altura y anchura.
public class Personaje { . . . Rectangle rect = new Rectangle(x, y, width, height); . . . }
Así, con el método overlaps(Rectangle)
se puede calcular si dos rectángulos colisionan
. . . if (personaje.rect.overlaps(enemigo.rect)) { // Personaje y enemigo han colisionado . . . } . . . // También podemos calcular el valor de su área y perímetro float area = personaje.rect.area(); float perimeter = personaje.rect.perimeter(); . . .
La clase Circle, de forma similar a lo que hace la clase Rectangle
, también podemos trabajar con círculos de colisión para los casos en los que esta figura geométrica se aproxime más a la forma del personaje que representa.
public class Personaje { . . . Circle circle = new Circle(x, y, radius); . . . }
Así, con el método overlaps(Circle)
se puede calcular si dos círculos colisionan
. . . if (personaje.circle.overlaps(enemigo.circle)) { // Personaje y enemigo han colisionado . . . } . . . // También podemos calcular su área y longitud de la circunferencia float area = personaje.circle.area(); float circumference = personaje.circle.circumference(); . . .
Conviene tener en cuenta que tantoRectangle
comoCircle
, ambos con el métodooverlaps
, sólo son capaces de comprobar colisiones con elementos de su mismo tipo. Si queremos comprobar la colisión entre figuras geométricas diferentes tendremos que usar alguno de los métodos que ofrece la claseIntersector
que se comenta a continuación.
La clase Intersector es una clase que ofrece numerosos métodos estáticos para detectar colisiones entre cualquier tipo de figura geométrica. Por ejemplo, ofrece métodos para detectar colisiones entre objetos de tipo Rectangle
, Circle
y entre un Rectangle
y un Circle
, entre otros:
static float distanceLinePoint(startX, startY, endX, endY, pointX, pointY)
: Calcula la distancia entre una recta y un puntostatic boolean overlaps(Rectangle, Rectangle)
Comprueba si dos rectángulos colisionanstatic boolean overlaps(Rectangle, Circle)
Comprueba si un rectángulo y un círculo colisionanstatic overlaps(Circle, Circle)
Comprueba si dos círculos colisionan. . . Rectangle rect = new Rectangle(. . .); Circle circle = new Circle(. . .); . . . if (Intersector.overlaps(rect, circle)) { // Rectángulo y círculo han colisionado . . . } . . .
La clase TimeUtils proporciona una serie de métodos de utilidad para gestionar el tiempo durante el juego:
static long millis()
: Devuelve la diferencia de tiempo en milisegundos entre el instante actual y el 1/1/1970static long timeSinceMillis(long previousTime)
: Devuelve la diferencia de tiempo en milisegundos entre el instante actual y el que se le pasa como parámetroAsí, si queremos que se ejecute cierto código cada cierto tiempo, podemos hacer lo siguiente:
. . . // Ejecuta el método doSomeThing cada 10 segundos if (TimeUtils.timeSinceMillis(previousTime) > 10000) { doSomething(); previousTime = TimeUtils.millis(); } . . .
La clase MathUtils proporciona métodos matemáticos de uso muy habitual. Estos son algunos:
static value clamp(value, min, max)
Controla que un valor entre dos valores mínimo y máximostatic value random(start, end)
Devuelve un valor aleatorio entre dos valores dadosstatic float random()
Devuelve un número aleatorio entre 0 y 1static boolean randomBoolean()
Devuelve un valor booleano aleatoriostatic int randomSign()
Devuelve -1 ó 1 de forma aleatoriaLa clase Timer permite que se ejecuten tareas futuras en el bucle de juego.
Por ejemplo, es posible programar el lanzamiento de una tarea indicando el retardo en segundos:
. . . // Ejecuta el método doSomething() pasados 3 segundos Timer.schedule(new Timer.Task() { public void run() { doSomething(); } }, 3); . . .
La clase Logger permite crear registros para mejorar la salida de mensajes durante la ejecución de la aplicación para motivos de depuración.
. . . Logger logger = new Logger("tag"); /* * Hay 4 niveles que pueden ser modificados en tiempo de ejecución * Logger.ERROR hará que se muestren por pantalla sólo los mensajes de error * Logger.INFO hará que se muestren por pantalla los mensajes que no sean de depuracion (debug) * Logger.DEBUG permite que se muestren por pantalla todos los mensajes * Logger.NONE no muestra ningún mensaje por pantalla */ logger.setLevel(Logger.DEBUG); . . . logger.debug("Esto es un mensaje"); . . .
Para la gestión del input de usuario, ya sea mediante el teclado, ratón o gamepad, libGDX proporciona una API completa a través del paquete Gdx.input
. . . if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) { // Mover al jugador a la derecha player.velocity.x = PLAYER_SPEED; player.state = Player.State.RIGHT; } . . .
. . . if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) { // El jugador salta player.jump(); } . . .
. . . if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) { // Hacer algo cuando el usuario pulse el botón izquierdo del ratón } . . .
. . . if (Gdx.input.isTouched()) { // Hacer algo } . . .
. . . public class SpriteManager implements ControllerListener { . . . public SpriteManager() { . . . Controllers.addListener(this); . . . } . . . @Override public void connected(Controller controller) { // Hacer algo cuando el usuario conecta un gamepad } @Override public void disconnected(Controller controller) { // Hacer algo cuando el usuario desconecta un gamepad } @Override public boolean buttonDown(Controller controller, int buttonCode) { // Hacer algo cuando el usuario pulsa un botón // buttonCode = 0 si el botón pulsado es X // buttonCode = 1 si el botón pulsado es A // buttonCode = 2 si el botón pulsado es B // buttonCode = 3 si el botón pulsado es Y return false; } @Override public boolean buttonUp(Controller controller, int buttonCode) { // Hacer algo cuando el usuario suelta un botón return false; } @Override public boolean axisMoved(Controller controller, int axisCode, float value) { // Hacer algo cuando el usuario pulsa el control pad // Hay que tener en cuenta que el evento salta cuando se pulsa o se suelta, no // de forma continua mientras lo tiene pulsado // axisCode = 0 si el usuario se mueve en el eje X // axisCode = 1 si el usuario se mueve en el eje Y // value = 1.0 si el usuario se mueve hacia la derecha/arriba // value = -1.0 si el usuario se mueve hacia la izquierda/abajo // value != 1.0/-1.0 cuando el usuario suelta el control pad return false; } @Override public boolean povMoved(Controller controller, int povCode, PovDirection value) { return false; } @Override public boolean xSliderMoved(Controller controller, int sliderCode, boolean value) { return false; } @Override public boolean ySliderMoved(Controller controller, int sliderCode, boolean value) { return false; } @Override public boolean accelerometerMoved(Controller controller, int accelerometerCode, Vector3 value) { return false; } } . . .
A través del API de libGDX también es posible acceder a diferentes aspectos del modo de video o relativo a los gráficos durante el juego, en tiempo de ejecución
Como ya vimos, delta time es el tiempo que tarda el juego en pintar un frame, es decir, en ejecutar una iteración completa del bucle principal del juego (método render()
).
. . . float deltaTime = Gdx.graphics.getDeltaTime(); . . .
También podemos cambiar el modo de video o la resolución en tiempo de ejecución, de forma que podremos hacer que el usuario pueda configurarlo desde el propio menú de configuración antes de empezar a jugar o bien desde un menú durante la partida.
. . . Graphics.DisplayMode mode = Gdx.graphics.getDisplayMode(); Gdx.graphics.setFullscreenMode(mode); . . .
. . . Gdx.graphics.setWindowedMode(1024, 768); . . .
También es posible cambiar la apariencia del cursor del ratón en el juego
. . . Pixmap cursorPixmap = new Pixmap(Gdx.files.internal("assets/tests/cross_arrow_cursor_mini.png")); . . . Gdx.graphics.setCursor(Gdx.graphics.newCursor(pixmap, 0, 0)); . . .
libGDX también proporciona una API para la gestión de ficheros de forma más cómoda que como se hace con la propia API de Java
Gdx.files.internal(String)
Obtiene la referencia a un fichero especificado como una cadena de textoGdx.files.getExternalStorage()
Obtiene la ruta al almacenamiento externo. En Android será la tarjeta de memoria y en un PC la carpeta personal del usuario (carpeta home)Gdx.files.getInternalStorage()
Obtiene la ruta al almacenamiento interno. En Android será el directorio privado y en un PC la carpeta donde se encuentre el .jar
Y algunos ejemplos de la clase FileHandle
que añade alguna funcionalidad sobre la clase File
que proporciona el API de Java:
. . . // Obtiene la referencia a un fichero FileHandle file = Gdx.files.internal("un_fichero.loquesea"); // Lee el contenido de un fichero como un String String content = file.readString(); // Escribe la cadena de texto en el fichero // (append:true o false, según se quiera añadir o sobrescribir) file.writeString(aString, append); // Comprueba si el fichero existe if (file.exists()) { . . . } // Elimina el fichero file.delete(); // Obtiene el nombre del fichero sin la extensión String fileName = file.nameWithoutExtension(); . . .
Para la gestión de recursos de sonido en libGDX existen las interfaces Sound y Music. Ambas permiten la reproducción de sonido para efectos de audio y música respectivamente. La clase Sound
está específicamente diseñada para reproducción de sonidos cortos como efectos de sonido mientras que la clase Music
lo está para reproducir pistas de audio de larga duración como pueden ser las bandas sonoras de los juegos o la música de cada una de las pantallas que suena de fondo durante toda la parte (en forma de bucle, por ejemplo). Hay que tener en cuenta que libGDX cargará todo el fichero completo en el caso de un objeto Sound
mientras que lo reproducirá mediante streaming en el caso de un objeto de tipo Music
.
Ambas interfaces soportan los formatos de audio .wav, .mp3 y .ogg.
Además, conviene tener en cuenta que al tratarse de interfaces no pueden ser instanciadas directamente. Es por ello que un clip de sonido o audio debe ser cargado utilizando unos métodos específicos que incluye la API de libGDX
. . . Sound aSound = Gdx.audio.newSound(Gdx.files.internal("hit.mp3")); . . . Music aMusic = Gdx.audio.newMusic(Gdx.files.internal("bso.mp3")); . . .
Ambas clases tienen métodos similares para realizar las mismas operaciones sobre el fichero de audio que tengan asociado.
A continuación se listan algunos de los métodos de los que dispone la interface Sound
:
void dispose()
Libera los recursos utilizados por la pista de audiolong loop()
Reproduce una pista de audio en formato bucle y devuelve el id
de dicha pista, o -1 en caso de fallovoid pause()
Pausa la reproducción de una pista de audiolong play()
Reproduce una pista de audio y devuelve el id
de dicha pista, o -1 en caso de fallolong play(float volume)
Reproduce una pista de audio con un volumen determinado (entre 0 y 1) y devuelve el id
de dicha pista, o -1 en caso de fallovoid resume()
Continua la reproducción de una pista de audio que haya sido pausadavoid setVolume(long soundId, float volume)
Fija el volumen de una pista de audiovoid stop()
Detiene la reproducción de una pista de Audio
En el caso de la interface Music
disponemos de algunos métodos como los que siguen:
void dispose()
Libera los recursos utilizados por la pista de audiofloat getPosition()
Devuelve la posición de la reproducción en segundosvoid pause()
Pausa la reproducción de una pista de audiovoid play()
Reproduce una pista de audiovoid setLooping(boolean isLooping)
Fija (o no) la reproducción en modo buclevoid setPosition(float position)
Fija la posición de reproducción en segundosvoid setVolume(float volume)
Fija el volumen de una pista de audiovoid stop()
Detiene la reproducción de una pista de AudioEl siguiente ejemplo muestra cómo cargar la animación de un personaje a partir de un texture atlas
. . . // Primero se obtienen los frames con los que queremos crear la animación TextureAtlas myAtlas = assetManager.get("my_texture_atlas.pack"); Array<AtlasRegion> allFrames = myAtlas.findRegions("personaje"); . . . // El primer parámetro indica el tiempo entre frames de la animación, en segundos animation = new Animation(0.15f, allFrames); . . . // Estas lineas se ejecutaran en los metodos update() y/o render() stateTime += deltaTime; TextureRegion currentFrame = animation.getKeyFrame(stateTime , true); spriteBatch.draw(currentFrame, position.x, position.y); . . .
Así, se puede observar en el código que lo que ocurre es que almacenamos la animación completa (con todos los frames que tenga) y en el método donde haya que pintar ese personaje o animación simplemente obtenemos en cada momento el siguiente frame que procede pintar en función del tiempo de juego y del tiempo por frame que hayamos asignado a dicha animación. Hay que tener en cuenta que en cada momento sólo estaremos pintando un frame de la animación.
En el proyecto animaciones se puede consultar el código de un pequeño ejemplo donde se muestra como poner en marcha las animaciones para un personaje y un enemigo cargando sus texturas desde un TextureAtlas
.
Los componentes para UI (User Interface, interfaz de usuario) son aquellos componentes que permiten diseñar e implementar menús para las diferentes partes del juego, tanto para iniciar una partida como para interactuar durante la misma (ingame menu).
libGDX proporciona una serie de componentes para el diseño de UI (User Interface) a través de la librería Scene2d con el que es posible diseñar e implementar menús de juego muy completos.
Además, recientemente ha aparecido un proyecto muy interesante que surge como una mejora sobre estos componentes, llamado VisUI que reimplementa algunos de estos componentes y añade otros que no existen, por lo que resulta una librería mucho más apropiada y completa para trabajar. Para usarlo, simplemente tenemos que añadirlo como extensión en el build.gradle
de nuestro proyecto y añadir una dependencia para descargar la librería (Más información en el enlace a la librería).
. . . ext { . . . visuiVersion = '1.3.0-SNAPSHOT' . . . } . . . project(":core") { apply plugin: "java" . . . dependencies { . . . compile "com.kotcrab.vis:vis-ui:$visuiVersion" } } . . .
El siguiente fragmento de código muestra cómo construir una UI en la que se pinta solamente un botón. En la documentación de VisUI se puede encontrar una relación de todos los componentes de esta librería con ejemplos y muestras de código de como trabajar con los mismos.
public class MainMenuScreen implements Screen { . . . Stage stage; @Override public void show() { . . . if (!VisUI.isLoaded()) VisUI.load(); stage = new Stage(); VisTable table = new VisTable(true); table.setFillParent(true); stage.addActor(table); VisTextButton playButton = new VisTextButton("PLAY"); playButton.addListener(new ClickListener() { @Override public void clicked(InputEvent event, float x, float y) { // Ir a jugar dispose(); } }); VisTextButton configButton = new VisTextButton("CONFIGURATION"); configButton.addListener(new ClickListener() { @Override public void clicked(InputEvent event, float x, float y) { // Ir a la pantalla de configuración dispose(); } }); VisTextButton quitButton = new VisTextButton("QUIT"); quitButton.addListener(new ClickListener() { @Override public void clicked(InputEvent event, float x, float y) { VisUI.dispose(); // Salir del juego } }); VisLabel aboutLabel = new VisLabel("Demo libGDX\n(c) Santiago Faci 2017"); // Añade filas a la tabla y añade los componentes table.row(); table.add(playButton).center().width(200).height(100).pad(5); table.row(); table.add(configButton).center().width(200).height(50).pad(5); table.row(); table.add(quitButton).center().width(200).height(50).pad(5); table.row(); table.add(aboutLabel).left().width(200).height(20).pad(5); Gdx.input.setInputProcessor(stage); } @Override public void render(float dt) { Gdx.gl.glClearColor(1, 0, 0, 1); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); // Pinta la UI en la pantalla stage.act(dt); stage.draw(); } @Override public void resize(int width, int height) { // Redimensiona la escena al redimensionar la ventana del juego stage.getViewport().update(width, height); } @Override public void dispose() { // Libera los recursos de la escena stage.dispose(); } . . . }
Y a continuación se muestra el set completo del que dispone la librería VisUI, que puede consultarse directamente en su demo online
Antes de comenzar, necesitaremos lanzar el starter que hay disponible en la web de libGDX donde además se explica cómo configurarlo en su guía Set up a project. Con eso conseguiremos crear la semilla de lo que será nuestro proyecto y a partir de lo cual comenzaremos a trabajar.
En la Wiki oficial de libGDX existe una guía muy interesante sobre cómo realizar un primer videojuego con este framework, se llama A simple game y es una sencilla aplicación que muestra muy bien cómo empezar a trabajar con este motor. Conviene echarle un vistazo puesto que sienta las bases de cómo trabajar con los aspectos más básicos de libGDX.
Para este curso, sobre el ejemplo básico he preparado 3 versiones más de ese primer juego utilizando estructuras más complejas para mostrar algunos conceptos más avanzados que se pueden utilizar en videojuegos más complejos:
En cuanto a la organización dentro del IDE de programación que utilicemos y su estructura de paquetes, podemos ver a continuación un ejemplo para un videojuego:
En la estructura de arriba se pueden distinguir dos módulos, el principal y el proyecto lanzadera que sólo utilizaremos para ejecutar el juego en una plataforma determinada. Así, podemos distinguir las siguientes partes en un proyecto
El módulo principal o core contiene todo el desarrollo del juego exceptuando las clases lanzadoras (donde se encontrará el método main
que lanza la ejecución del juego) y los assets o recursos del videjuego (texturas, imágenes, clips de audio, . . .).
No hay una estructura definida en el framework de libGDX por lo que a continuación se detallará una estructura habitual y conveniente que permita organizar el código de las diferentes partes de un juego desarrollado con este (y otros) frameworks.
Donde podemos organizar las clases que representen las estructuras de los caracteres del juego (personajes, enemigos, items, . . .). Es conveniente diseñar bien las clases para aprovechar al máximo la POO ya que un buen diseño, aunque a priori nos pueda costar, más adelante nos ahorrará infinidad de líneas de código y trabajo
Character
puede contener todos los métodos y atributos comunes a cualquier caracter del videojuego. La idea es que sirva de clase base para todos los personajes o enemigos animados que vayamos a implementar en adelante por lo que normalmente se definirá como abstractapublic abstract class Character { public Vector2 velocity; public Vector2 position; public float stateTime; public TextureRegion currentFrame; public Rectangle rect; public int lives; protected boolean dead; . . . public Character(. . .) { . . . } public void render(Batch batch) { batch.draw(currentFrame, position.x, position.y); } public abstract void update(float dt); public abstract void fire(); public abstract void die(); public boolean isDead() { return dead; } public void doSomething() { . . . . . . } . . . }
Así, personajes como nuestro jugador heredarán de la clase abstracta Character
para aprovechar todos los métodos ya implementados allí o la estructura ya definida en el caso de los que se definieron como abstractos.
public class Player extends Character { public int score; . . . public Player(. . .) { super(. . .); . . . } public void update(float dt) { . . . } . . . }
En el caso de otros personajes animados del juego como los enemigos, también podrán heredar de la clase Character
e implementar a su manera todas aquellas acciones (métodos) definidas como abstractas puesto que es de esperar que, aunque realicen las mismas acciones que un personaje principal, las hagan de forma diferente (moverse, disparar, morir, . . .)
public class Enemy extends Character { public EnemyType type; public boolean invicible; public Enemy(. . .) { super(. . .); . . . } public void update(float dt) { . . . } . . . }
En el paquete managers podemos encontrar las clases encargadas de gestionar cada parte del videojuego: los recursos, la lógica, el renderizado, los niveles, la configuración y la cámara. No todos son obligatorios ni tampoco tienen una estructura fija, sino que se trata de una forma de organizar el código por funcionalidades, de forma que éste sea mucho más abordable a medida que va aumentando su tamaño.
La clase ResourceManager
contiene el código que permite realizar la carga y el acceso a todos los recursos (assets
)
public class ResourceManager { public static AssetManager assets = new AssetManager(); public void loadAllResources() { . . . } public static boolean update() { return assets.update(); } public void loadSounds() { . . . } public static TextureRegion getRegion(String name) { . . . } public static Array<TextureAtlas.AtlasRegion> getRegions(String name) { . . . } public static Sound getSound(String name) { . . . } }
La clase SpriteManager
contiene todos los métodos que se encargan de gestionar la lógica del videojuego
public class SpriteManager { Player player; Array<Enemy> enemies; Array<Item> items; . . . public void handleInput() { . . . } private void updateEnemies(float dt) { . . . } private void updateItems(float dt) { . . . } private void updatePlayer(float dt) { . . . } public void update(float dt) { updateEnemies(dt); updateItems(dt); updatePlayer(dt); . . . } . . . }
La clase RenderManager
contiene el código que permite el renderizado (o pintado) de todos los elementos del juego en la pantalla. Es habitual que no contenga mucho código puesto que los cálculos de dónde pintar a cada elemento del juego los realiza el SpriteManager
y esta clase sólo tiene que pintar cada uno de ellos utilizando el método que cada uno tiene implementado
public class RenderManager { SpriteBatch batch; BitmapFont font; SpriteManager spriteManager; . . . public void drawFrame() { batch.begin(); drawHud(); spriteManager.player.render(batch); for (Enemy enemy : spriteManager.enemies) enemy.render(batch); for (Item item : spriteManager.items) item.render(batch); batch.end(); } private void drawHud() { . . . } . . . }
La clase LevelManager
se encarga de la carga de niveles (pantallas jugables) y de todos los elementos de los mismos, como pueden ser el mapa, objetos, enemigos y cualquier otro elemento que deba aparecer en la misma. Una vez que éstos son cargados en memoria, será el SpriteManager
el encargado de hacer que se muevan. El RenderManager
sólo los carga desde el fichero donde esté la información del mapa correspondiente.
También puede contener los métodos necesarios para realizar el cambio de nivel (el jugador ha llegado al final de uno), reiniciarlo (cuando muere el jugador, por ejemplo) o cualquier otra acción sobre el mismo.
public class LevelManager { TiledMap map; int currentLevel; . . . public void loadCurrentLevel() { . . . } public void restartCurrentLevel() { . . . } public void loadEnemies() { . . . } public void loadItems() { . . . } . . . }
La clase ConfigurationManager
es la encargada de almacenar y acceder a las preferencias del usuario. El usuario dispondrá de una pantalla (Screen
) de personalización del juego para fijar sus valores de forma que el juego en todo momento pueda consultarlas para comportarse como corresponda (desactivar el audio, configurar teclas, dificultad del juego, . . .)
public class ConfigurationManager { private static Preferences prefs = Gdx.app.getPreferences(Constants.APP_NAME); public static boolean isSoundEnabled() { . . . } public static int getDifficultyLevel() { . . . } . . . }
La clase CameraManager
se encarga de gestionar la cámara, para que señale siempre en el lugar que corresponda según el jugador avance por el nivel. Normalmente intentará mantener al jugador siempre centrado en la pantalla.
public class CameraManager { OrthographicCamera camera; public void init() { . . . } public void handleCamera() { . . . } . . . }
En el paquete screens
implementaremos todas las clases que representan a cada uno de los estados de nuestro videojuego. Todas ellas implementan el interface Screen
.
El interface Screen
es parte del API de libGDX y tiene los siguientes métodos, que deberán ser codificados en las clases que la implementen:
dispose()
Invocado cuando se liberan todos los recursoshide()
Invocado cuando esta Screen
ya no es la actualpause()
Invocado cuando la Screen
pasa a segundo planorender()
Invocado como un bucle para renderizar la Screen
actualresize()
Invocado cuando se redimensiona la pantallaresume()
Invocado cuando la Screen
pasa a primer planoshow()
Invocado en el momento en que esta Screen
pasa a ser la actual
Teniendo en cuenta que el método dispose()
no se invoca automáticamente
Una estructura clásica para los estados de un juego implementado con Screens sería el siguiente:
SplashScreen
: Pantalla de carga del juego (y de los assets)MainMenuScreen
: Pantalla con el menú principalGameScreen
: Pantalla donde el usuario juega la partidaConfigurationScreen
: Pantalla donde el usuario configura el juegoGameOverScreen
: Pantalla donde se indica al usuario que ha terminado la partidaSe puede ver a continuación el diseño de clases que quedaría para el ejemplo anterior:
Así, la estructura básica de cada Screen
se muestra a continuación:
public class MyScreen implements Screen { // Si es un menú private Stage stage; public MyScreen() { . . . } /* * Este método se ejecuta en el momento en que se cambia a esta Screen * Si esta Screen carga un menú es el momento de crearlo * Si es una pantalla de juego, es el momento de inicializar lo que no * se ha inicializado en el constructor */ @Override public void show() { . . . } /* * Es el bucle principal de la Screen, donde se pinta el menú o donde ocurre * la partida, según el tipo de Screen */ @Override public void render(float dt) { . . . // Cambia de pantalla (cuando ocurra algún evento) ((Game) Gdx.app.getApplicationListener).setScreen(new AnotherScreen()); dispose(); . . . // Si tiene que mostrar un menú stage.act(dt); stage.draw(); . . . // Si es la pantalla de juego, se invocará aquí a la lógica, // renderizado y demás partes del juego checkInput(); renderFrame(); checkCollisions(); . . . // O bien actualizar los diferentes managers si se ha estructurado // asi el juego spriteManager.update(dt); renderManager.render(dt); . . . } @Override public void resize(int width, int height) { . . . } @Override public void hide() { . . . } @Override public void pause() { . . . } @Override public void resume() { . . . } @Override public void dispose() { // Si es un menú stage.dispose(); . . . } }
En el paquete util
implementaremos, al menos, dos clases:
Constants
que contendrá todas las constantes que vayamos a utilizar en todo el proyecto del videojuegopublic class Constants { public static final String APP_NAME = "minijumper"; public static final int SCREEN_WIDTH = 1024; public static final int SCREEN_HEIGHT = 768; public static final int TILE_WIDTH = 32; public static final int TILE_HEIGHT = 32; . . . public static final int TILES_IN_CAMERA = 16; public static final int CAMERA_WIDTH = TILES_IN_CAMERA * TILE_WIDTH; public static final int CAMERA_HEIGHT = TILES_IN_CAMERA * TILE_HEIGHT; . . . }
Util
donde implementaremos todos los métodos transversales al proyecto que puedan ser de utilidad en cualquier parte del mismo y no tengan cabida en ninguna de las clases en las que estructuramos nuestro videojuego, normalmente como método estáticospublic class Util { . . . }
La clase principal del core contiene muy poco código. Es la clase que se ejecutará desde los proyectos lanzadera desde las diferentes plataformas desde donde el juego sea ejecutado. En nuestro caso simplemente cargará la pantalla de SplashScreen
que será la encargada de cargar el juego para acabar mostrando el menú principal al jugador, pudiendo así comenzar a interactuar con el mismo (según el diagrama de estados que hayamos definido).
public class MiniJumper extends Game { @Override public void create() { . . . ((Game) Gdx.app.getApplicationListener).setScreen(new SplashScreen()); } @Override public void render() { super.render(); } @Override public void dispose() { . . . } }
Módulo lanzadera, en este caso para PC (deskto), con una sola clase que será la encargada de lanzar la ejecución del juego
public class DesktopMiniJumper { public static void main(String[] args) { LwjglApplicationConfiguration configuration = new LwjglApplicationConfiguration(); configuration.title = "MiniJumper"; configuration.width = Constants.SCREEN_WIDTH; configuration.height = Constants.SCREEN_HEIGHT; configuration.fullscreen = true; new LwjglApplication(new MiniJumper(), configuration); } }
Además, como se puede ver en la estructura del proyecto, este módulo Lanzadera contendrá una carpeta donde se almacenarán todos los recursos (o assets) del videojuego, de los que hablaremos más adelante en Gestión de Recursos
Para terminar, a continuación se muestra un diagrama de clases de un proyecto estándar de videojuego donde se puede ver cómo se relacionan éstas.
El diagrama de estados de un videojuego define los diferentes estados en los que se puede encontrar el jugador durante la ejecución del mismo. En el caso de libGDX esto viene predefinido mediante la interface Screen
de la que debemos implementar para definir cada uno de nuestros estados o Screens
(ver Paquete screens
A continuación se muestra el diagrama básico que sigue cualquier juego desde que se carga (Loading) pasando por mostrar el logo utilizando, por ejemplo, una SplashScreen para llegar a un estado Main menu donde el jugador podrá escoger entre acceder al estado Credits para ver información sobre los autores del juego o bien pasar a Level selection para escoger un nivel y comenzar una partida si accede al estado Game. Igualmente se puede ver como es posible regresar a algunos estados anteriores, por ejemplo, desde el estado Game es posible volver atrás hasta Main menu para escoger otra acción o bien repetir.
En cualquier caso será decisión nuestra definir qué estados tendrá nuestro juego, puesto que a los que hemos visto ahora podríamos añadir otros como Game over, Preferences para mostrar la pantalla de fin de partida o bien configurar la partida, entre otros.
Y desde el punto de vista más técnico utilizando libGDX, la dependencia entre los diferentes estados utilizando el interface Screen
que libGDX nos proporciona, quedaría como sigue
La gestión de recursos de un videojuego incluye la gestión de todo aquello que forma parte del mismo que no sea código. Podríamos hablar de clips de audio, texturas de los personajes, ficheros de los niveles diseñados con alguna aplicación de terceros (Tiled, por ejemplo), texturas para el diseño de los menús de juego y cualquier otro fichero de este tipo.
Lo que haremos será crear una clase que contenga todo lo necesario para gestionar los recursos de nuestro juego, desde la carga de los mismos durante la ejecución hasta los métodos que nos permitan acceder a los mismos en cualquier instante. Todo eso lo haremos a través de lo que llamaremos el ResourceManager
:
En el caso de que almacenemos nuestras texturas como un TextureAtlas
(tal y como se hace para el ejemplo) conviene echar un vistazo a la parte donde se habla de los atlas de texturas.
public class ResourceManager { // Clase de libGDX que permite la gestión de assets public static AssetManager assets = new AssetManager(); /** * Carga todos los recursos del juego */ public static void loadAllResources() { assets.load("my_texture_atlas.pack", TextureAtlas.class); loadSounds(); loadMusics(); } /** Actualiza la carga de recursos */ public static boolean update() { return assets.update(); } /** * Carga los sonidos */ public static void loadSounds() { assets.load("sounds" + File.separator + "game_begin.wav", Sound.class); . . . } /** * Carga las músicas */ public static void loadMusics() { assets.load("musics" + File.separator + "bso.mp3", Music.class); . . . } /** * Obtiene una región de textura o la primera de una animación * @param name * @return */ public static TextureRegion getRegion(String name) { return assets.get(TEXTURE_ATLAS, TextureAtlas.class).findRegion(name); } /** * Obtiene una región de textura determinada de las que forman una animación */ public static TextureRegion getRegion(String name, int position) { return assets.get(TEXTURE_ATLAS, TextureAtlas.class).findRegion(name, position); } /** * Obtiene todas las regiones de textura que forman una misma animación * @param name * @return */ public static Array<TextureAtlas.AtlasRegion> getRegions(String name) { return assets.get(TEXTURE_ATLAS, TextureAtlas.class).findRegions(name); } /** * Obtiene un sonido determinado */ public static Sound getSound(String name) { return assets.get(name, Sound.class); } /** * Obtiene una música determinada */ public static Music getMusic(String name) { return assets.get(name, Music.class); } }
Así, teniendo nuestro propio gestor de recursos, lo más conveniente será realizar la carga de los mismos durante la carga del juego en lo que se suele conocer como SplashScreen
. De esa manera podremos mostrar al usuario información sobre el juego mientras éste se carga junto con todos los assets del mismo:
public class SplashScreen implements Screen { private Texture splashTexture; private Image splashImage; private Stage stage; private boolean splashDone = false; public SplashScreen() { splashTexture = new Texture(Gdx.files.internal("ui/splash.png")); splashImage = new Image(splashTexture); } @Override public void show() { stage = new Stage(); Table table = new Table(); table.setFillParent(true); table.center(); // Muestra la imagen de SplashScreen como una animación splashImage.addAction(Actions.sequence(Actions.alpha(0), Actions.fadeIn(1f), Actions.delay(1.5f), Actions.run(new Runnable() { @Override public void run() { splashDone = true; } }) )); table.row().height(splashTexture.getHeight()); table.add(splashImage).center(); stage.addActor(table); // Lanza la carga de recursos ResourceManager.loadAllResources(); } @Override public void render(float dt) { Gdx.gl.glClearColor(0, 0, 0, 1); Gdx.gl.glClear(GL20.GL_COLOR_CLEAR_VALUE); stage.act(dt); stage.draw(); // Comprueba si se han cargado todos los recursos if (ResourceManager.update()) { // Si la animación ha terminado se muestra ya el menú principal if (splashDone) { ((Game) Gdx.app.getApplicationListener).setScreen(new MainMenuScreen()); } } } @Override public void resize(int width, int height) { stage.setViewport(width, height); } . . . @Override public void dispose() { splashTexture.dispose(); stage.dispose(); } }
El atlas de texturas consiste en empaquetar en una sola imagen todas las texturas que se necesiten para el juego. De esa manera, desde nuestro ResourceManager
podemos cargar todas ellas minimizando el acceso a disco y podremos acceder además de una manera más directa puesto que libGDX proporciona una API para extraer del atlas las texturas por nombre y también como un Array
de todas aquellas que pertenezcan a la misma animación. Hay que tener en cuenta que al fichero .png que se genera con todas las texturas le acompañará un fichero de texto que contiene toda la información de cómo están distribuidas dichas texturas en el empaquetado.
Asi, podremos obtener cualquier textura por su nombre o bien toda la colección de texturas que formen una misma animación. E incluso algún frame suelto de aquellos que forman una animación con otros.
minijumper.png format: RGBA8888 filter: Linear,Linear repeat: none big_stone_broken rotate: false xy: 1, 53 size: 75, 74 orig: 75, 74 offset: 0, 0 index: -1 big_stone rotate: false xy: 78, 53 size: 74, 74 orig: 74, 74 offset: 0, 0 index: 1 big_stone rotate: false xy: 154, 53 size: 74, 74 orig: 74, 74 offset: 0, 0 index: 2 . . .
Además, libGDX a través del Texture packer proporciona una API para realizar el empaquetado de una serie de texturas de manera procedimental, con unas pocas líneas de código. De esa manera, lo recomendable será tener preparadas todas las texturas para empaquetar en una misma carpeta y lanzar la ejecución del siguiente fragmento de código, que cogerá todas las texturas que haya en la carpeta core/assets/textures
y las empaquetará formando un TextureAtlas
que dejará en la carpeta core/assets
al que llamará atlas.pack
(fichero de empaquetado) y al que acompañar la imagen con todas las texturas con el nombre atlas.png
public class Packer { public static void main(String[] args) { TexturePacker2.Settings settings = new TexturePacker2.Settings(); settings.maxWidth = 1024; settings.maxHeight = 1024; settings.filterMag = Texture.TextureFilter.Linear; settings.filterMin = Texture.TextureFilter.Linear; TexturePacker2.process(settings, "core/assets/textures", "core/assets", "atlas.pack"); } }
Y también es posible crear estos TextureAtlas utilizando una aplicación llamada gdx-texturepacker-gui que permite realizar el empaquetado de las imágenes de nuestro videojuego desde un sencillo interfaz de usuario.
public class ConfigurationManager { private static Preferences prefs = Gdx.app.getPreferences(Constants.APP_NAME);; /** * Comprueba si el sonido está o no activado durante el juego * @return */ public static boolean isSoundEnabled() { return prefs.getBoolean("sound"); } }
. . . if (ConfigurationManager.isSoundEnabled()) ResourceManager.getSound(SOUND + "blop.mp3").play(); . . .
Los TiledMaps son niveles diseñados como mapas donde el área se divide en tiles o baldosas (también llamados patrones) que formarán tanto el terreno como todos los elementos que puedan aparecer en el juego. Además, existe la posibilidad de añadir esos patrones como objetos de forma que se puede trabajar con ellos con mayor libertad y también incluso añadir formas geométricas como rectángulos, líneas o círculos para definir formas más complejas con las que se pueda interactuar durante la partida.
En este caso el mapa se diseña con una aplicación de terceros (en nuestro caso Tiled y desde el código, utilizando la API que libGDX nos proporciona, podremos pintar el mapa y acceder a toda la información que en él hayamos diseñado:
Asi, a continuación se muestra una captura de pantalla de la aplicación Tiled para el diseño de TiledMaps donde se pueden ver:
El siguiente fragmento de código permitiría, desde una Screen
, cargar el mapa e instanciar los objetos necesarios para moverse sobre él (la cámara) y el objeto encargado de renderizar tanto el mapa como los objetos que aparezcan durante la partida (objeto Batch
que obtenemos del mapRenderer
).
La cámara sólo será necesario en los casos en los que el mapa que diseñemos no vaya a ocupar toda la pantalla. Si todo el mapa ocupa justo una pantalla no será necesaria. En el caso de que las dimensiones del mapa sobrepasen las de la pantalla y el personaje pueda avanzar a lo largo del mismo, tendremos que enfocarle a lo largo de la partida haciendo uso de la cámara, que moveremos en función de los movimientos de dicho personaje (derecha/izquierda y/o arriba/abajo, según proceda).
. . . Batch batch; OrthographicCamera camera; TiledMap map; OrthogonalTiledMapRenderer mapRenderer; . . . @Override public void show() { . . . camera = new OrthographicCamera(); // Fija la anchura y altura de la camara en base al número de tiles que se mostrarán camera.setToOrtho(false, TILES_IN_CAMERA_WIDTH * TILE_WIDTH, TILES_IN_CAMERA_HEIGHT * TILE_WIDTH); camera.update(); map = new TmxMapLoader().load("levels/level1.tmx"); mapRenderer = new OrthogonalTiledMapRenderer(map); batch = mapRenderer.getBatch(); mapRenderer.setView(camera); . . . }
Así, para hacer el renderizado del mapa en la pantalla, podemos hacerlo directamente con el objeto mapRenderer
indicándole qué capas queremos dibujar. En este caso solamente pintamos las capas 0 y 1 (background y terrain según la captura anterior) que son las capas que dibujan el nivel. El resto de elementos del juego los instanciaremos como objetos de nuestro proyecto ya que algunos son móviles y otros pueden desaparecer de forma individual.
Si tuvieramos que pintar algún otro elemento del juego como nuestro personaje, utilizaríamos para ello el objeto Batch
que hemos obtenido del objeto mapRenderer
durante la carga del mapa. Recordad que habrá que declarar e instanciar al personaje en la carga del Screen
junto con el mapa y la cámara.
. . . @Override public void render(float dt) { . . . mapRenderer.render(new int[]{0, 1}); batch.begin(); batch.draw(player.imagen, player.posicion.x, player.posicion.y); batch.end(); . . . } . . .
Ahora sólo nos quedará permitir que la cámara (en este caso las dimensiones del mapa no caben en la pantalla) que hemos utilizado para visualizar el mapa se mueva en función de los movimientos de nuestro jugador.
. . . @Override public void render(float dt) { . . . handleCamera(); . . . } private void handleCamera() { if (player.position.x < TILES_IN_CAMERA_WIDTH * TILE_WIDTH / 2) camera.position.set(TILES_IN_CAMERA_WIDTH * TILE_WIDTH / 2, TILES_IN_CAMERA_HEIGHT * TILE_WIDTH / 2 - TILE_WIDTH, 0); else camera.position.set(player.position.x, TILES_IN_CAMERA_HEIGHT * TILE_WIDTH / 2 - TILE_WIDTH, 0); camera.update(); mapRenderer.setView(camera); } . . .
Para probar el ejemplo completo faltaría añadir una mínima gestión del input del usuario para que el personaje se pueda al menos mover de izquierda a derecha por la pantalla.
. . . @Override public void render(float dt) { . . . handleInput(); . . . } private void handleInput() { if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) player.move(new Vector2(-10, 0)); else if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) player.move(new Vector2(10, 0)); } . . .
Hay que tener en cuenta que todavía no hemos añadido colisiones ni al resto de elementos del juego (los diamantes y los enemigos). Simplemente tenemos a un personaje capaz de moverse por el mapa y la cámara siguiendo al mismo.
Ya hemos visto como crear el mapa con Tiled, cómo pintar las capas de patrones en la pantalla y cómo mover el personaje a lo largo del mismo utilizando una cámara.
En el siguiente fragmento de código se muestra cómo recorrer todos los objetos de una capa del mapa y cuáles son las operaciones que podemos hacer con ellos según queramos instanciarlos para incorporarlos a la lógica de nuestro programa o bien comprobar simplemente si el personaje colisiona con él, por ejemplo en el caso de que se trate de rectángulos que delimiten terreno.
. . . // Obtiene todos los objetos de la capa 'colision' MapLayer collisionsLayer = map.getLayers().get("colision"); for (MapObject object : collisionsLayer.getObjects()) { RectangleMapObject rectangleObject = (RectangleMapObject) object; // Caso 1: Comprueba si el objeto contiene una propiedad if (rectangleObject.getProperties().contains("enemy")) { . . . } // Caso 2: Obtiene el valor de una propiedad int score = Integer.parseInt(rectangleObject.getProperties().get("score")); . . . // Caso 3: Obtiene el rectangulo ocupado por el objeto Rectangle rect = rectangleObject.getRectangle(); if (player.rect.overlaps(rect)) { . . . } } . . .
Para el acceso a Bases de Datos, puesto que programamos en Java, podríamos utilizar, técnicamente, cualquier motor que pudiera usarse con este lenguaje. Pero, dadas las características de los videojuegos que normalmente se desarrollan, donde la necesidad de almacenamiento es algo secundario, podemos utilizar un motor ligero y sencillo de utilizar como es SQLite. Si el diseño o la mecánica de nuestro juego se centrara en una gran Base de Datos siempre podríamos pensar en otros SGBDs más grandes, pero si queremos almacenar puntuaciones, inventarios, partidas, . . . lo más interesante es utilizar un motor ligero y que además no requiere instalación puesto que apenas ocupa 1 MB
try { Class.forName("org.sqlite.JDBC"); Connection connection = null; connection = DriverManager.getConnection("jdbc:sqlite:" + Gdx.files.internal("scores.db")); String sql = "CREATE TABLE IF NOT EXISTS scores (id integer primary key autoincrement, name text, score int)"; PreparedStatement statement = connection.prepareStatement(sql); statement.executeUpdate(); sql = "INSERT INTO scores (name, score) VALUES (?, ?)"; statement = connection.prepareStatement(sql); statement.setString(1, name); statement.setInt(2, score); statement.executeUpdate(); if (statement != null) statement.close(); if (connection != null) connection.close(); } catch (ClassNotFoundException cnfe) { cnfe.printStackTrace(); } catch (SQLException sqle) { sqle.printStackTrace(); }
try { Class.forName("org.sqlite.JDBC"); Connection connection = null; connection = DriverManager.getConnection("jdbc:sqlite:" + Gdx.files.internal("scores.db")); String sql = "SELECT name, score FROM scores ORDER BY score DESC LIMIT 10"; PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery(); List<Score> scores = new ArrayList<Score>(); Score score; while (result.next()) { score = new Score(); score.name = result.getString("name"); score.score = result.getInt("score"); scores.add(score); } if (statement != null) statement.close(); if (result != null) result.close(); if (connection != null) connection.close(); return scores; } catch (ClassNotFoundException cnfe) { cnfe.printStackTrace(); } catch (SQLException sqle) { sqle.printStackTrace(); } return new ArrayList<Score>();
Se puede encontrar un driver para SQLite y Java en la web del proyecto Xerial-SQLite.
Actualmente, puesto que los proyectos de libGDX se construyen de forma más o menos automática, lo más cómodo es empaquetar el videojuego utilizando Gradle
Screen
Puede encontrarse más proyectos de ejemplo en el repositorio libgdx y unos pocos más en el repositorio multimedia-ejercicios donde están los proyectos que vamos haciendo en clase.
© 2016-2023 Santiago Faci