This is an old revision of the document!
Table of Contents
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
Actualmente incluso cuenta con una Wiki muy completa con muchísima documentación
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).
Renderizar
Es la acción por la cual se pinta (se renderiza) cada uno de los frames en la pantalla.
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.
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.
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.
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.
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, . . .
Estructura del framework libGDX
Estructura de módulos
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()
.
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.
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.
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.
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.
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 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 . . . } . . .
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/1970static 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á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 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 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(); . . .
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 audiolong loop()
Reproduce una pista de audio en formato bucle y devuelve elid
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 elid
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 elid
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 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).
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
Hello World!
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:
- Versión oficial de “A simple game” ver código
- Versión ampliada utilizando Screens ver código
- Versión ampliada con managers ver código
- Versión utilizando POO (Programación Orientada a Objetos) ver código
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:
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
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
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.
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 recursoshide()
Invocado cuando estaScreen
ya no es la actualpause()
Invocado cuando laScreen
pasa a segundo planorender()
Invocado como un bucle para renderizar laScreen
actualresize()
Invocado cuando se redimensiona la pantallaresume()
Invocado cuando laScreen
pasa a primer planoshow()
Invocado en el momento en que estaScreen
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 partida
Se 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(); . . . } }
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.
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.
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 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.
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.
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.
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
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
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.
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
- Caso 2: Queremos extraer el valor de una propiedad asignada a un objeto del mapa
- Caso 3: Queremos obtener el rectángulo que ocupa el objeto para comprobar si el jugador ha colisionado con él
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.
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
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
© 2016-2023 Santiago Faci