User Tools

Site Tools


apuntes:libgdx

Desarrollo de videojuegos con libGDX

¿Qué es libGDX

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:

  • Es multiplataforma por lo que el juego desarrollado se puede ejecutar en PC, Android, iOS, Web y BlackBerry
  • Se integra con algunos de los IDEs más conocidos (IntelliJ, Eclipse y NetBeans)
  • Dispone de APIs de alto nivel tanto para 2D como para 3D
  • Incopora dos motores de físicas muy extendidos (Box2D para 2D y Bullet para 3D)
  • Dispone de librería para trabajar con niveles diseñados como TiledMaps (.tmx)
  • Dispone de un API para gestionar el almacenamiento proporcionando un nivel de abstracción para el programador en el acceso a recursos, preferencias, . . . en las diferentes plataformas
  • Dispone de un API para la gestión del input de usuario (teclado, ratón, pantalla táctil, acelerómetro, . . .) que proporciona un nivel de abstracción para las diferentes plataformas
  • Al tratarse de software libre dispone de una amplía comunidad de desarrolladores (muy en auge actualmente)
  • Su desarrollo sigue activo en la actualidad


Algunos conceptos de videojuegos

Frame

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).

Frame 1 Frame 2 Frame 3 Frame 4
Figure 1: Frames de un juego

Renderizar

Es la acción por la cual se pinta (se renderiza) cada uno de los frames en la pantalla.

Bucle de un juego
Figure 2: Bucle de un juego

FPS (Frames Per Second)

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.

Textura

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.

Textura
Figure 3: Textura de un personaje

Sprite

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.

Animación (2D)

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.

Animación
Figure 4: Frames que forman una animación

TextureAtlas

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.

TextureAtlas
Figure 5: TextureAtlas

Delta Time (dt)

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.

Independencia de FPS
Figure 6: Relación entre dt y FPS en un juego

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;
. . .

HUD (Head-up display)

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, . . .

Figure 7: Head-up display

Estructura del framework libGDX

Estructura de módulos

Estructura de módulos en libGDX
Figure 8: Estructura de módulos en libGDX

Ciclo de vida de un videojuego

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().

Ciclo de vida de un videojuego en libGDX
Figure 9: Ciclo de vida de un videojuego en libGDX

Componentes libGDX

API 2D

Sistema de coordenadas

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.

Figure 10: Sistema ortogonal (cartesiano)

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.

SpriteBatch

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();
. . .

Texture

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);
. . .

TextureRegion

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);
. . .

Sprite

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);
. . .

Array

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);
}
. . .

TextureAtlas

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"));
. . .

Animation

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);
. . .

Vector2

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);
. . .

Rectangle

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.

Figure 11: Rectángulos de colisión
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();
. . .

Circle

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.

Figure 12: Círculo de colisión
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 tanto Rectangle como Circle, ambos con el método overlaps, 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 clase Intersector que se comenta a continuación.

Intersector

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 punto
  • static boolean overlaps(Rectangle, Rectangle) Comprueba si dos rectángulos colisionan
  • static boolean overlaps(Rectangle, Circle) Comprueba si un rectángulo y un círculo colisionan
  • static 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
  . . .
}
. . .

TimeUtils

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/1970
  • static long timeSinceMillis(long previousTime): Devuelve la diferencia de tiempo en milisegundos entre el instante actual y el que se le pasa como parámetro

Así, 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();
}
. . .

MathUtils

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áximo
  • static value random(start, end) Devuelve un valor aleatorio entre dos valores dados
  • static float random() Devuelve un número aleatorio entre 0 y 1
  • static boolean randomBoolean() Devuelve un valor booleano aleatorio
  • static int randomSign() Devuelve -1 ó 1 de forma aleatoria

Timer

La 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);
. . .

Logger

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");
. . .

Gestión del input de usuario

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

Teclado

  • Comprobar si el usuario está pulsando una tecla (al estar en el bucle principal de juego la acción se ejecuta de forma continuada mientras el jugador mantiene pulsada la tecla)
. . .
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
  // Mover al jugador a la derecha
  player.velocity.x = PLAYER_SPEED;
  player.state = Player.State.RIGHT; 
}
. . .
  • Comprobar si el usuario acaba de pulsar una tecla
. . .
if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) {
  // El jugador salta
  player.jump();
}
. . .

Ratón

  • Comprobar si el usuario está pulsando algún botón del ratón
. . .
if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) {
  // Hacer algo cuando el usuario pulse el botón izquierdo del ratón
}
. . .
  • Comprobar si el usuario pulsa en la pantalla (o bien hace click en alguna parte de la misma)
. . .
if (Gdx.input.isTouched()) {
  // Hacer algo
}
. . .

Gamepad

  • Inicializar y gestionar el control de un gamepad
. . .
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;
    }
}
. . .

Gestión del modo de video

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

Obtener el delta time

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();
. . .

Cambiar el modo de video

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.

  • Por ejemplo, podemos cambiar el modo de video a pantalla completa:
. . .
Graphics.DisplayMode mode = Gdx.graphics.getDisplayMode();
Gdx.graphics.setFullscreenMode(mode);
. . .
  • Y también simplemente a modo ventana fijando una resolución determinada
. . .
Gdx.graphics.setWindowedMode(1024, 768);
. . .

Cambiar el cursor

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));
. . .

Gestión de ficheros

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 texto
  • Gdx.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();
. . .

Sonido y música

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 audio
  • long loop() Reproduce una pista de audio en formato bucle y devuelve el id de dicha pista, o -1 en caso de fallo
  • void pause() Pausa la reproducción de una pista de audio
  • long play() Reproduce una pista de audio y devuelve el id de dicha pista, o -1 en caso de fallo
  • long 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 fallo
  • void resume() Continua la reproducción de una pista de audio que haya sido pausada
  • void setVolume(long soundId, float volume) Fija el volumen de una pista de audio
  • void 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 audio
  • float getPosition() Devuelve la posición de la reproducción en segundos
  • void pause() Pausa la reproducción de una pista de audio
  • void play() Reproduce una pista de audio
  • void setLooping(boolean isLooping) Fija (o no) la reproducción en modo bucle
  • void setPosition(float position) Fija la posición de reproducción en segundos
  • void setVolume(float volume) Fija el volumen de una pista de audio
  • void stop() Detiene la reproducción de una pista de Audio

Animaciones

El 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.


Componentes UI

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).

Figure 13: Menú de juego

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

Figure 14: Componentes VisUI

Hello World!

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:

Figure 15: Estructura proyecto v1
Figure 16: Estructura proyecto v2
Figure 17: Estructura proyecto v3
  • Versión utilizando POO (Programación Orientada a Objetos) ver código
Figure 18: Estructura proyecto con POO
Figure 19: A simple game

Estructura de un videojuego

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:

Estructura de un videojuego
Figure 20: Estructura de 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

Módulo principal (core)

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.

Paquete characters

Figure 21: Diagrama de clases del paquete characters

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

  • La clase 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 abstracta
public 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) {
    . . .
  }
 
  . . .
}

Paquete managers

Figure 22: Diagrama de clases del paquete managers

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.

ResourceManager

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) { . . . }
}

SpriteManager

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);
    . . .
  }
 
  . . .
}

RenderManager

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() { . . . }
 
  . . .
}

LevelManager

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() { . . . }
  . . .
}
ConfigurationManager

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() { . . . }
  . . .
}

CameraManager

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.

Figure 23: Cámara
public class CameraManager {
  OrthographicCamera camera;
 
  public void init() { . . . }
  public void handleCamera() { . . . }
  . . .
}

Paquete screens

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 recursos
  • hide() Invocado cuando esta Screen ya no es la actual
  • pause() Invocado cuando la Screen pasa a segundo plano
  • render() Invocado como un bucle para renderizar la Screen actual
  • resize() Invocado cuando se redimensiona la pantalla
  • resume() Invocado cuando la Screenpasa a primer plano
  • show() 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ú principal
  • GameScreen: Pantalla donde el usuario juega la partida
  • ConfigurationScreen: Pantalla donde el usuario configura el juego
  • GameOverScreen: Pantalla donde se indica al usuario que ha terminado la partida

Se puede ver a continuación el diseño de clases que quedaría para el ejemplo anterior:

Figure 24: Diagrama de clases del paquete screens

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();
    . . .
  }
}

Paquete util

En el paquete util implementaremos, al menos, dos clases:

  • La clase Constants que contendrá todas las constantes que vayamos a utilizar en todo el proyecto del videojuego
public 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;
 
  . . .
}
  • La clase 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áticos
public class Util {
 
  . . .
}

La clase principal

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 (desktop)

Módulo lanzadera, en este caso para PC (deskto), con una sola clase que será la encargada de lanzar la ejecución del juego

La clase DesktopMiniJumper

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

Diagrama de clases de un proyecto de videojuego

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.

Estructura de clases
Figure 25: Estructura de clases básica

Estados de un videojuego

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.

Estados de un videojuego
Figure 26: Estados de un videojuego

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

Gestión de pantallas o escenas
Figure 27: Gestión de pantallas o escenas 2)


Gestión de recursos

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.

Gestión de recursos
Figure 28: Gestión de recursos

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();
  }
}

Atlas de texturas (TextureAtlas)

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.

Figure 29: Atlas de texturas para un juego de plataformas. Debajo su fichero de empaquetado
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.

Figure 30: gdx-texturepacker-gui

Gestión de preferencias

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();
. . .

Colisiones

Figure 31: Colisiones por rectángulos
Figure 32: Colisiones por círculos
Figure 33: Escoger la forma más apropiada
Figure 34: Múltiples dominios de colisión


Creación y gestión de niveles

TiledMaps

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:

  • Podemos diseñar el mapa en varias capas por lo que podremos decidir cuáles de ellas queremos que se pinten en cada momento
  • Las capas permiten decidir qué elementos están por delante/por detrás de los demás
  • Utilizaremos las capas de patrones normalmente para definir el terreno y fondo del mapa. Lo habitual será que sean las capas que pintaremos siempre en cada frame (a no ser que queramos ocultar alguna en algún momento)
  • Utilizaremos las capas de objetos para diseñar elementos animados (enemigos, por ejemplo) y objetos del juego. Estos elementos los cargaremos al principio del nivel para animarlos como objetos de nuestro proyecto (en nuestro código, como clases y objetos que habremos implementado para ellos)
  • Cada patrón u objeto podrá tener una serie de características a las que podremos acceder desde el proyecto para parametrizar dichos elementos

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 diseño del mapa
  • Las capas utilizadas
  • Los conjuntos de patrones (tilesets) de donde se cogen las baldosas para pintar el mapa
  • La sección donde se añaden los atributos (características) a cada uno de los patrones u objetos del mapa
Figure 35: TiledMap editado con la aplicación Tiled

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.

Figure 36: Movimiento de cámara sobre un TiledMap


Gestión de objetos de TiledMaps

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)) {
    . . .
  }
}
. . .
  • Caso 1: Queremos comprobar el tipo de objeto. Suponemos que lo hemos 'marcado' en el mapa asignándole una propiedad sin valor
Figure 37: Objeto marcado con una propiedad
  • Caso 2: Queremos extraer el valor de una propiedad asignada a un objeto del mapa
Figure 38: Objeto y sus propiedades
  • Caso 3: Queremos obtener el rectángulo que ocupa el objeto para comprobar si el jugador ha colisionado con él
Figure 39: Rectángulos como objetos para delimitar zonas de colisión

Acceso a Bases de Datos: SQLite

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

Almacenar información

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();
}

Consultar información

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 y un enlace directo de descarga del fichero jar en Descargas

Empaquetar el videojuego

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


Ejercicios


Proyectos de ejemplo

  • DropGame I Juego sencillo con colisiones. Es la implementación del juego de ejemplo de libGDX
  • DropGame II Versión ampliada utilizando estados utilizando la interfaz Screen
  • DropGame III Versión ampliada utilizando Managers
  • DropGame IV (POO) Versión mejorada utilizando Programación Orientada a Objetos
  • Animaciones Proyecto de ejemplo para el uso de animaciones
  • Robin2dx (juego tipo RPG) Proyecto de ejemplo para el uso de TiledMaps

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.

Juegos de ejemplo

Figure 40: Jumper2dx, juego de plataformas
Figure 41: JFighter2dx, juego de naves
Figure 42: JBombermanx, juego de mapas
Figure 43: Arkanoidx, juego de puzzles
Figure 44: Minijumper2dx, juego de plataformas

Práctica 2.1

Objetivos

Desarrollo de un videojuego 2D

Enunciado

Se debe desarrollar un videojuego 2D que cumpla con los requisitos descritos a continuación. La temática del juego será elección del alumno. Se podrán usar tanto elementos gráficos como de audio que no hayan sido creados por uno mismo.

Será requisito indispensable que el juego se programe siguiendo el paradigma de Programación Orientada a Objetos.

Requisitos (1 pto cada uno)

  • El videojuego debe contar con un personaje principal que el jugador podrá manejar y debe tener un inicio, un final y un objetivo claro. Hay que preparar al menos dos niveles de juego claramente diferenciados.
  • El videojuego mostrará información en pantalla al usuario (puntuación, energía, pantalla actual) y ésta se actualizará cuando corresponda.
  • Utilizar menús para iniciar/configurar/terminar la partida y mostrar las instrucciones del juego. Hay que tener en cuenta que se tendrá que poder jugar otra partida sin necesidad de salir del juego. Añadir, al menos, dos opciones de juego configurables que afecten al desarrollo del mismo.
  • El juego dispondrá de características de sonido y animaciones en todos los caracteres con movimiento.
  • El juego dispondrá de NPCs (Non-Playable Characters) (al menos 3 diferentes) que deberán interactuar con el personaje del jugador.

Otras funcionalidades (1 pto cada una)

  • Subir el videojuego, como repositorio, a GitHub y gestionar el proyecto con dicha herramienta (gestionar algunas issues, crear alguna release, editar la wiki con información sobre el proyecto y las instrucciones del juego).
  • Al finalizar la partida se almacenará la puntuación del jugador (con su nombre) y se mostrará el top 10 puntuaciones.
  • Añadir algún tipo de Inteligencia Artificial a algún NPC del juego.
  • Es posible mostrar/ocultar un menú durante el juego con opciones (activar/desactivar sonido, volver al menú principal, salir del juego y continuar con la partida).
  • Añadir dos niveles más al juego
  • Añadir opciones de multijugador (al menos dos jugadores) local o en red.
  • Añadir dos nuevos tipos de NPCs (al menos otros 3).
  • Crear un generador de niveles (utilizando TiledMaps o cualquier otro tipo de fichero de entrada) de forma que sea posible incorporar niveles nuevos para el juego de forma sencilla sin necesidad de tocar el código.
  • Guardar y cargar partidas para continuar donde se dejó.
  • Modificar el comportamiento o características del jugador durante el juego (más energía, más vidas, mejor armamento, más velocidad, . . .) de forma permanente o temporal (powerups). Añadir al menos 3 powerups diferentes.
  • El usuario podrá escoger entre diferentes personajes del juego (al menos 3) con características diferentes.
  • Trasladar el juego a más de una plataforma (PC, móvil, web, . . .).

© 2016-2019 Santiago Faci

apuntes/libgdx.txt · Last modified: 06/02/2019 20:12 by Santiago Faci