Tema 9. Introducción a LibGDX

  • Bloque: B5 — Motores de juego: análisis e introducción a LibGDX
  • Duración aproximada: 3 horas
  • RA4 — Selecciona y prueba motores de juegos analizando la arquitectura de juegos 2D y 3D.
  • RA5 — Desarrolla juegos 2D y 3D sencillos utilizando motores de juegos.
Código Criterio
RA4-a Se han identificado los elementos que componen la arquitectura de un juego 2D y 3D.
RA4-b Se han analizado los componentes de un motor de juegos.
RA4-c Se han analizado entornos de desarrollo de juegos.
RA4-d Se han analizado diferentes motores de juegos, sus características y funcionalidades.
RA4-e Se han identificado los bloques funcionales de un juego existente.
RA4-f Se ha reconocido la representación lógica y espacial de una escena gráfica sobre un juego existente.
RA5-a Se ha establecido la lógica de un nuevo juego.
RA5-b Se han creado los objetos necesarios para el juego y definido sus características.
RA5-c Se han creado las escenas del juego y distribuido los objetos en las mismas.
RA5-e Se han establecido las propiedades físicas de los objetos.
RA5-g Se han utilizado cámaras y configurado la iluminación.

1. Introducción: motores de juego y LibGDX#

1.1. ¿Por qué un motor de juego?#

Cuando desarrollamos una aplicación Android convencional con Jetpack Compose, delegamos en el sistema operativo la gestión del ciclo de renderizado: el sistema decide cuándo redibujar la interfaz en respuesta a cambios de estado. Este modelo, eficiente para formularios, listas o navegación, resulta inadecuado para videojuegos, donde la pantalla debe actualizarse de forma continua —típicamente entre 30 y 60 veces por segundo— independientemente de si ha cambiado algún dato.

Un motor de juego (game engine) es un marco de trabajo especializado que proporciona, entre otros:

  • Un bucle de juego (game loop) que ejecuta la lógica y el renderizado a una frecuencia controlada.
  • Herramientas de renderizado 2D y 3D con acceso directo a OpenGL ES.
  • Gestión de assets (imágenes, audio, fuentes) de forma eficiente en memoria.
  • Abstracciones para el manejo de entrada: toque, teclado, acelerómetro.
  • Soporte para física, audio y, en algunos casos, herramientas de edición visual.

1.2. Panorama de motores para Android#

Aunque el mercado ofrece múltiples opciones, en el contexto de este módulo interesa especialmente comparar las alternativas que se pueden usar desde el entorno que ya conocemos (Android Studio, Kotlin):

Motor Lenguaje principal IDE Multiplataforma Uso típico
LibGDX 1.13.1 Java / Kotlin Android Studio 2D y 3D, indie
Unity 6 LTS C# Unity Editor 2D, 3D, industria
Godot 4.6 GDScript / C# Godot Editor 2D y 3D, indie
Defold 1.12 Lua Defold Editor 2D, móvil

LibGDX es el candidato natural para este módulo porque se integra directamente en Android Studio, usa Kotlin de manera nativa gracias a las extensiones KTX, y no requiere instalar herramientas externas adicionales. Al haber trabajado ya con Kotlin y Jetpack Compose, podremos concentrarnos en los conceptos propios del desarrollo de videojuegos.


2. Ecosistema y versiones#

2.1. Versiones de referencia#

LibGDX (https://libgdx.com) es un framework de código abierto para el desarrollo de juegos multiplataforma en Java/Kotlin. Su arquitectura separa el código del juego en un módulo core independiente de la plataforma, mientras que módulos específicos (android, desktop, ios) contienen únicamente el código de arranque para cada sistema operativo.

Las versiones de referencia para estos apuntes son:

  • LibGDX: 1.13.1
  • KTX (extensiones Kotlin): 1.13.1-rc1 (alineada con LibGDX)
  • Kotlin: 2.1.10
  • Gradle: 8.x con AGP 8.x

¿Por qué 1.13.1 y no 1.14.0? LibGDX 1.14.0 (octubre 2025) introdujo breaking changes con los que KTX aún no es compatible en su última versión estable (1.13.1-rc1). Para este módulo, la combinación 1.13.1 + KTX 1.13.1-rc1 es la más estable y pedagógicamente apropiada.

2.2. KTX: extensiones Kotlin para LibGDX#

KTX (Kotlin extensions for LibGDX) añade funciones de extensión, operadores sobrecargados y builders con tipos seguros sobre la API Java de LibGDX. Los módulos más relevantes para este bloque son:

Módulo Función principal
ktx-app KtxGame, KtxScreen, ciclo de vida
ktx-graphics use {} para SpriteBatch, utilidades de renderizado
ktx-assets AssetManager simplificado
ktx-math Operadores aritméticos para Vector2, Vector3
ktx-box2d DSL para crear cuerpos y fixtures de Box2D
ktx-tiled Utilidades para acceso a propiedades de TiledMap

3. Creación del proyecto con gdx-liftoff#

3.1. Instalación y ejecución#

gdx-liftoff (https://github.com/libgdx/gdx-liftoff) es la herramienta oficial para generar proyectos LibGDX. Descarga el .jar desde la página de releases y ejecútalo (1.13.1.):

java -jar gdx-liftoff.jar

3.2. Configuración del proyecto#

En la interfaz gráfica, configura los campos de la siguiente manera para el proyecto guiado del siguiente tema, puedes consultar aquí para más detalles sobre cada opción:

  • Project name: KotlinAsteroids
  • Root package: com.dam.kotlinasteroids
  • Main class: KotlinAsteroidsMain
  • Project folder: carpeta vacía para el proyecto
  • Platforms: Android + Desktop (Desktop para pruebas rápidas sin emulador)
  • Languages: Kotlin
  • Extensions (LibGDX): Box2D y Tools (para usar TexturePacker desde Android Studio)
  • Template: Kotlin + KTX (incluye las extensiones KTX recomendadas para desarrollo en Kotlin)
  • Extensions (KTX): ktx-app, ktx-graphics, ktx-assets, ktx-math, ktx-box2d, ktx-tiled

Al pulsar Generate, gdx-liftoff creará un proyecto Gradle multi-módulo con la estructura que se muestra a continuación.

3.3. Estructura del proyecto generado#

KotlinAsteroids/
├── android/                      ← launcher Android
│   ├── build.gradle.kts
│   ├── AndroidManifest.xml
│   └── assets/                   ← aquí van TODOS los assets del juego
├── core/                         ← código del juego (independiente de plataforma)
│   ├── build.gradle.kts
│   └── src/main/kotlin/com/dam/kotlinasteroids/
├── desktop/                      ← launcher escritorio (para pruebas, lo verás como lwjgl3)
│   └── build.gradle.kts
└── gradle.properties             ← versiones centralizadas

Desde la vista de proyecto de Android Studio, el módulo core es el que contiene la lógica del juego y las dependencias de LibGDX. Los módulos android y desktop son los launchers específicos para cada plataforma, que dependen de core y añaden las librerías nativas necesarias. Los ficheros build.gradle.kts de cada módulo gestionan sus dependencias, mientras que gradle.properties centraliza las versiones para mantener la coherencia.

Carpeta de assets: En un proyecto LibGDX multi-módulo, los assets se colocan en android/assets/. gdx-liftoff configura automáticamente Gradle para que el módulo Desktop también los lea desde esa misma carpeta, por lo que solo existe un único directorio de assets compartido por todas las plataformas.

3.4. Dependencias en gradle.properties y build.gradle.kts#

El archivo gradle.properties centraliza las versiones:

# gradle.properties
gdxVersion=1.13.1
ktxVersion=1.13.1-rc1
kotlinVersion=2.2.20

El módulo core declara las dependencias con api (no implementation) para que los módulos launcher puedan acceder a las clases de LibGDX:

 1// core/build.gradle.kts
 2dependencies {
 3    // LibGDX núcleo
 4    api("com.badlogicgames.gdx:gdx:$gdxVersion")
 5
 6    // Motor de físicas Box2D
 7    api("com.badlogicgames.gdx:gdx-box2d:$gdxVersion")
 8
 9    // KTX: extensiones Kotlin
10    api("io.github.libktx:ktx-app:$ktxVersion")
11    api("io.github.libktx:ktx-graphics:$ktxVersion")
12    api("io.github.libktx:ktx-assets:$ktxVersion")
13    api("io.github.libktx:ktx-math:$ktxVersion")
14    api("io.github.libktx:ktx-box2d:$ktxVersion")
15    api("io.github.libktx:ktx-tiled:$ktxVersion")
16}

El módulo android necesita las librerías nativas de Box2D para cada arquitectura:

 1// android/build.gradle.kts
 2dependencies {
 3    implementation(project(":core"))
 4
 5    // Librerías nativas de Box2D para Android
 6    natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-arm64-v8a")
 7    natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-armeabi-v7a")
 8    natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86")
 9    natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86_64")
10}

4. Arquitectura de una aplicación LibGDX#

Antes de escribir código de juego, es fundamental entender cómo LibGDX gestiona el ciclo de vida de la aplicación y cómo se organiza el código.

4.1. La interfaz ApplicationListener#

El punto de entrada de cualquier juego LibGDX es una clase que implementa ApplicationListener. Esta interfaz define los métodos del ciclo de vida de la aplicación:

Método Cuándo se llama
create() Una sola vez al iniciarse la aplicación. Equivale al onCreate de Android.
render() En cada fotograma. Aquí reside la lógica del bucle de juego.
resize(width, height) Al cambiar el tamaño de la ventana o la orientación del dispositivo.
pause() Al perder el foco (p.ej., el usuario minimiza la app).
resume() Al recuperar el foco.
dispose() Al cerrar la aplicación. Aquí se liberan los recursos.

4.2. KtxGame y KtxScreen#

KTX proporciona las clases KtxGame y KtxScreen que implementan ApplicationListener y Screen respectivamente, añadiendo algunas conveniencias como la gestión automática de pantallas. Una pantalla (Screen) es una unidad lógica del juego: el menú principal, la pantalla de juego, la pantalla de game over, etc.

El flujo general de una aplicación organizada en pantallas es el siguiente:

KtxGame
├── crea la pantalla inicial en create()
├── gestiona el cambio entre pantallas con setScreen<T>()
└── delega render() → Screen.render()
                       ├── lógica de actualización
                       └── renderizado gráfico

La clase KtxGame<S : KtxScreen> es genérica: el parámetro de tipo S indica cuál es el tipo base de las pantallas que gestionará el juego. En la mayoría de los casos usamos la propia interfaz KtxScreen.


5. Elementos gráficos fundamentales#

5.1. SpriteBatch: el lienzo de los sprites#

SpriteBatch es la clase central del renderizado 2D en LibGDX. Internamente almacena en un buffer las llamadas de dibujo y las envía a la GPU en un único lote (batch) para maximizar el rendimiento. Su uso sigue siempre el mismo patrón:

1// IMPORTANTE: SpriteBatch debe crearse en create() y liberarse en dispose()
2val batch = SpriteBatch()
3
4// En render():
5batch.begin()            // Inicia el lote de dibujo
6batch.draw(texture, x, y)  // Dibuja una textura en las coordenadas dadas
7batch.end()              // Envía el lote a la GPU y lo finaliza

Regla crítica: Nunca olvides llamar a batch.end() después de batch.begin(). Si la aplicación lanza una IllegalStateException con el mensaje “SpriteBatch.end must be called before begin”, significa que intentaste hacer un begin sin haber cerrado el anterior.

5.2. Texture y TextureRegion#

Una Texture representa una imagen cargada en la memoria de la GPU. TextureRegion permite recortar un área rectangular de una Texture, lo que es esencial para trabajar con spritesheets (imágenes que contienen múltiples sprites en una sola imagen).

1// Cargando una textura desde la carpeta assets/
2val texture = Texture("nave.png")
3
4// Recortando una región (x, y, ancho, alto en píxeles)
5val region = TextureRegion(texture, 0, 0, 64, 64)

Los archivos de assets se colocan en la carpeta android/assets/ del proyecto. gdx-liftoff ya configura Gradle para que esta carpeta sea accesible desde todos los módulos.

5.3. OrthographicCamera#

La cámara determina qué parte del mundo del juego es visible en pantalla. OrthographicCamera es la apropiada para juegos 2D, ya que proyecta el mundo sin perspectiva (los objetos no se ven más pequeños con la distancia).

1// Creamos una cámara con el tamaño lógico del viewport
2// Usamos unidades de mundo, no píxeles
3val camera = OrthographicCamera()
4camera.setToOrtho(false, 480f, 800f)  // ancho x alto en unidades
5
6// En render(), antes de dibujar:
7camera.update()
8batch.projectionMatrix = camera.combined  // aplica la cámara al batch

El patrón false en setToOrtho indica que el eje Y crece hacia arriba (convención matemática estándar). Si usaras true, el eje Y crecería hacia abajo (convención de pantalla). Para juegos 2D con física o movimiento, la primera opción suele ser más intuitiva.

5.4. ShapeRenderer: dibujo de primitivas geométricas#

ShapeRenderer permite dibujar formas geométricas (rectángulos, círculos, líneas) sin necesidad de imágenes. Es muy útil durante el desarrollo para visualizar hitboxes o depurar posiciones:

1val shapeRenderer = ShapeRenderer()
2
3// En render():
4shapeRenderer.projectionMatrix = camera.combined
5shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
6shapeRenderer.rect(x, y, ancho, alto)   // dibuja el contorno de un rectángulo
7shapeRenderer.end()

6. Manejo de entrada del usuario#

LibGDX unifica el acceso a todos los dispositivos de entrada a través del objeto singleton Gdx.input. Este objeto ofrece métodos tanto para consulta directa (polling) como para escucha basada en eventos.

6.1. Polling de toque#

La forma más sencilla de detectar si el usuario está tocando la pantalla es mediante polling en cada fotograma:

1// En render():
2if (Gdx.input.isTouched) {
3    // El usuario está tocando la pantalla en este fotograma
4    val touchX = Gdx.input.x.toFloat()
5    val touchY = Gdx.input.y.toFloat()
6    // ATENCIÓN: Gdx.input.y usa coordenadas de pantalla (Y hacia abajo)
7    // Necesitas convertirlas a coordenadas del mundo
8}

6.2. Conversión de coordenadas de pantalla a mundo#

Las coordenadas que devuelve Gdx.input están en coordenadas de pantalla (píxeles, con Y=0 en la parte superior). Sin embargo, las coordenadas de nuestras entidades del juego están en coordenadas del mundo (definidas por la cámara). Es fundamental convertir entre ambos sistemas:

1// Creamos un Vector3 con las coordenadas de toque en pantalla
2val touchPos = Vector3(Gdx.input.x.toFloat(), Gdx.input.y.toFloat(), 0f)
3
4// La cámara convierte de coordenadas de pantalla a coordenadas del mundo
5camera.unproject(touchPos)
6
7// Ahora touchPos.x y touchPos.y están en coordenadas del mundo
8val worldX = touchPos.x
9val worldY = touchPos.y

6.3. InputProcessor para eventos discretos#

Para acciones puntuales (un tap, pulsar una tecla) es más adecuado usar un InputProcessor, que recibe callbacks sólo cuando ocurre el evento, sin necesidad de comprobarlo en cada fotograma:

1Gdx.input.inputProcessor = object : InputAdapter() {
2    // InputAdapter implementa InputProcessor con métodos vacíos,
3    // sólo sobreescribimos los que nos interesan
4    override fun touchDown(screenX: Int, screenY: Int, pointer: Int, button: Int): Boolean {
5        // Lógica al tocar la pantalla
6        return true  // true indica que el evento fue consumido
7    }
8}

7. Gestión de assets#

Los assets (imágenes, sonidos, fuentes) ocupan memoria de la GPU y del sistema. Si no se liberan correctamente, la aplicación sufre fugas de memoria. LibGDX implementa la interfaz Disposable en todos los recursos que deben liberarse explícitamente.

7.1. AssetManager#

Para proyectos con múltiples assets, AssetManager centraliza la carga, el acceso y la liberación de recursos. Permite además la carga asíncrona (en segundo plano), lo que posibilita mostrar una pantalla de carga mientras los assets se preparan:

 1// Creación (en create() del juego)
 2val assets = AssetManager()
 3
 4// Registro de los assets a cargar (la carga no ocurre aquí todavía)
 5assets.load("nave.png", Texture::class.java)
 6assets.load("asteroide.png", Texture::class.java)
 7assets.load("explosion.wav", Sound::class.java)
 8
 9// Carga síncrona (bloquea hasta que todo está listo)
10assets.finishLoading()
11
12// Acceso a un asset ya cargado
13val nave: Texture = assets.get("nave.png")
14
15// Liberación de todos los assets al cerrar
16assets.dispose()

Regla de gestión de memoria: Todo objeto que implemente Disposable debe ser liberado con dispose() cuando ya no se necesite, normalmente en el método dispose() del juego o de la pantalla que lo creó. Los assets no liberados provocan fugas de memoria que pueden causar cierres forzosos en dispositivos con poca RAM.


8. Audio en LibGDX#

LibGDX distingue dos tipos de recursos de audio según su duración y uso:

  • Sound: para efectos de sonido cortos (explosiones, disparos, pickups). Se carga completamente en memoria, lo que permite reproducirlo múltiples veces simultáneamente con muy baja latencia.
  • Music: para pistas de audio largas (música de fondo). Se reproduce en streaming para evitar cargar el archivo completo en memoria.
 1// Efectos de sonido (archivos .wav o .ogg, preferiblemente cortos)
 2val sonidoExplosion: Sound = Gdx.audio.newSound(Gdx.files.internal("explosion.wav"))
 3sonidoExplosion.play(volume = 1.0f)  // volumen entre 0.0 y 1.0
 4sonidoExplosion.dispose()            // liberar cuando ya no se use
 5
 6// Música de fondo (archivos .mp3 u .ogg)
 7val musicaFondo: Music = Gdx.audio.newMusic(Gdx.files.internal("musica_fondo.ogg"))
 8musicaFondo.isLooping = true         // reproducir en bucle
 9musicaFondo.volume = 0.5f
10musicaFondo.play()
11// Pausar y reanudar es responsabilidad nuestra en pause()/resume()
12musicaFondo.dispose()

9. Proyecto guiado: Space Runner#

A lo largo de las secciones anteriores hemos explorado los bloques conceptuales de LibGDX de forma individual. En esta sección los integramos en un proyecto completo: Space Runner, un juego endless runner en el que el jugador controla una nave espacial que debe esquivar asteroides mientras avanza indefinidamente.

9.1. Diseño del juego#

Las reglas del juego son intencionalmente simples para que podamos concentrarnos en la arquitectura del código:

  • La nave se mueve hacia arriba de forma automática. El jugador la controla horizontalmente tocando los lados de la pantalla.
  • Los asteroides aparecen aleatoriamente desde la parte superior y se mueven hacia abajo.
  • El juego termina si la nave colisiona con un asteroide.
  • Se muestra la puntuación (distancia recorrida) en pantalla.

9.2. Estructura del proyecto#

El código del módulo core se organiza en los siguientes archivos:

core/src/main/kotlin/com/dam/kotlinasteroids/
├── KotlinAsteroidsGame.kt      ← clase principal, gestión de pantallas
├── screen/
│   ├── MenuScreen.kt       ← pantalla de menú principal
│   ├── GameScreen.kt       ← pantalla de juego principal
│   └── GameOverScreen.kt   ← pantalla de fin de juego
└── entity/
    ├── Nave.kt             ← entidad del jugador
    └── Asteroide.kt        ← entidad de obstáculo

9.3. KotlinAsteroidsGame.kt#

Esta clase extiende KtxGame<KtxScreen> y actúa como el contenedor principal. Su responsabilidad es crear los assets compartidos (batch, cámara) y gestionar las transiciones entre pantallas:

 1package com.dam.kotlinasteroids
 2
 3import com.badlogic.gdx.graphics.OrthographicCamera
 4import com.badlogic.gdx.graphics.g2d.SpriteBatch
 5import com.badlogic.gdx.utils.viewport.FitViewport
 6import ktx.app.KtxGame
 7import ktx.app.KtxScreen
 8
 9/**
10 * Clase principal del juego.
11 *
12 * Extiende KtxGame, que implementa ApplicationListener de LibGDX.
13 * Se encarga de crear los recursos compartidos entre pantallas y
14 * de gestionar las transiciones entre ellas.
15 *
16 * Las dimensiones del mundo se definen aquí como constantes
17 * para que todas las pantallas las usen consistentemente.
18 */
19class KotlinAsteroidsGame : KtxGame<KtxScreen>() {
20
21    companion object {
22        // Unidades lógicas del mundo (no son píxeles)
23        const val WORLD_WIDTH = 9f
24        const val WORLD_HEIGHT = 16f
25    }
26
27    // SpriteBatch compartido: crear un SpriteBatch es costoso,
28    // por eso lo creamos una sola vez y lo compartimos entre pantallas.
29    lateinit var batch: SpriteBatch
30    lateinit var viewport: FitViewport
31
32    override fun create() {
33        batch = SpriteBatch()
34
35        // FitViewport mantiene la proporción del mundo aunque cambie el tamaño
36        // de la pantalla, añadiendo bandas negras si es necesario.
37        viewport = FitViewport(WORLD_WIDTH, WORLD_HEIGHT)
38
39        // Añadimos y mostramos la pantalla inicial
40        addScreen(MenuScreen(this))
41        setScreen<MenuScreen>()
42    }
43
44    override fun dispose() {
45        // Liberamos los recursos compartidos al cerrar la aplicación
46        super.dispose()  // KtxGame.dispose() llama a dispose() en todas las pantallas registradas
47        batch.dispose()
48    }
49}

9.4. Nave.kt#

La entidad Nave encapsula la posición, el tamaño y la lógica de movimiento del jugador. Utilizamos Rectangle de LibGDX, que además nos servirá para la detección de colisiones:

 1package com.dam.kotlinasteroids.entity
 2
 3import com.badlogic.gdx.math.Rectangle
 4
 5/**
 6 * Entidad que representa la nave del jugador.
 7 *
 8 * Usa un Rectangle para almacenar posición (x, y) y dimensiones (width, height).
 9 * Este mismo Rectangle se usa para la detección de colisiones mediante
10 * el método overlaps() de LibGDX.
11 *
12 * @param worldWidth ancho total del mundo, para limitar el movimiento horizontal
13 */
14class Nave(private val worldWidth: Float) {
15
16    // Dimensiones de la nave en unidades de mundo
17    companion object {
18        const val ANCHO = 1f
19        const val ALTO = 1.5f
20        const val VELOCIDAD = 5f  // unidades por segundo
21    }
22
23    // Rectangle contiene posición y tamaño: rect.x, rect.y, rect.width, rect.height
24    val rect = Rectangle(
25        worldWidth / 2f - ANCHO / 2f,  // centrada horizontalmente
26        1f,                             // cerca del borde inferior
27        ANCHO,
28        ALTO
29    )
30
31    /**
32     * Actualiza la posición de la nave.
33     *
34     * @param deltaX dirección horizontal: -1f izquierda, 0f parado, 1f derecha
35     * @param delta tiempo transcurrido desde el último fotograma (en segundos)
36     */
37    fun update(deltaX: Float, delta: Float) {
38        // Multiplicamos por delta para que el movimiento sea independiente
39        // de la tasa de fotogramas (frame-rate independent movement)
40        rect.x += deltaX * VELOCIDAD * delta
41
42        // Limitamos la posición para que la nave no salga del mundo
43        rect.x = rect.x.coerceIn(0f, worldWidth - ANCHO)
44    }
45}

9.5. Asteroide.kt#

 1package com.dam.kotlinasteroids.entity
 2
 3import com.badlogic.gdx.math.MathUtils
 4import com.badlogic.gdx.math.Rectangle
 5
 6/**
 7 * Entidad que representa un asteroide (obstáculo).
 8 *
 9 * Cada asteroide aparece en una posición x aleatoria en la parte superior
10 * del mundo y desciende a una velocidad dada.
11 */
12class Asteroide(worldWidth: Float, startY: Float) {
13
14    companion object {
15        const val ANCHO = 1f
16        const val ALTO = 1f
17        const val VELOCIDAD_BASE = 3f
18    }
19
20    val rect = Rectangle(
21        MathUtils.random(0f, worldWidth - ANCHO), // posición X aleatoria
22        startY,
23        ANCHO,
24        ALTO
25    )
26
27    /**
28     * Desplaza el asteroide hacia abajo.
29     *
30     * @param delta tiempo transcurrido desde el último fotograma
31     */
32    fun update(delta: Float) {
33        rect.y -= VELOCIDAD_BASE * delta
34    }
35
36    /**
37     * Comprueba si el asteroide ha salido por el borde inferior del mundo.
38     */
39    fun fueraDePantalla(): Boolean = rect.y + ALTO < 0f
40}

9.6. GameScreen.kt#

Esta es la pantalla principal del juego. Integra todos los elementos estudiados: cámara, batch, entidades, entrada del usuario, audio y detección de colisiones:

  1package com.dam.kotlinasteroids.screen
  2
  3import com.badlogic.gdx.Gdx
  4import com.badlogic.gdx.graphics.Color
  5import com.badlogic.gdx.graphics.Texture
  6import com.badlogic.gdx.graphics.g2d.BitmapFont
  7import com.badlogic.gdx.graphics.g2d.GlyphLayout
  8import com.badlogic.gdx.utils.Array as GdxArray   // alias para evitar conflicto con kotlin.Array
  9import com.dam.kotlinasteroids.KotlinAsteroidsGame
 10import com.dam.kotlinasteroids.entity.Asteroide
 11import com.dam.kotlinasteroids.entity.Nave
 12import ktx.app.KtxScreen
 13import ktx.app.clearScreen
 14import ktx.graphics.use
 15
 16/**
 17 * Pantalla principal de juego de Space Runner.
 18 *
 19 * Implementa KtxScreen, que es la versión Kotlin-friendly de Screen de LibGDX.
 20 * KtxScreen proporciona implementaciones vacías por defecto de todos los
 21 * métodos de Screen, de modo que solo sobreescribimos los que necesitamos.
 22 *
 23 * @param game referencia al juego principal para acceder a batch, viewport
 24 *             y para cambiar de pantalla cuando el juego termine.
 25 */
 26class GameScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
 27
 28    // --- Recursos gráficos ---
 29    // En un proyecto real usaríamos AssetManager para cargar assets.
 30    // Aquí los cargamos directamente para simplificar.
 31    private val textureNave = Texture("nave.png")
 32    private val textureAsteroide = Texture("asteroide.png")
 33    private val texturaFondo = Texture("fondo_espacio.png")
 34    private val fuente = BitmapFont()  // fuente por defecto de LibGDX
 35
 36    // --- Entidades ---
 37    private val nave = Nave(KotlinAsteroidsGame.WORLD_WIDTH)
 38    private val asteroides = GdxArray<Asteroide>()  // lista dinámica de LibGDX
 39
 40    // --- Estado del juego ---
 41    private var puntuacion = 0f
 42    private var tiempoUltimoAsteroide = 0f
 43    private val intervaloAsteroides = 1.5f  // segundos entre asteroides
 44
 45    // --- Layout para texto (necesario para medir dimensiones del texto) ---
 46    private val glyphLayout = GlyphLayout()
 47
 48    override fun show() {
 49        // show() se llama cuando esta pantalla se convierte en la activa.
 50        // Configuramos el InputProcessor para detectar toques.
 51        // No es necesario aquí si usamos polling, pero se incluye como ejemplo.
 52    }
 53
 54    /**
 55     * Método principal llamado en cada fotograma.
 56     *
 57     * Sigue el patrón "Update → Render":
 58     * primero actualizamos la lógica del juego,
 59     * después dibujamos el estado actualizado.
 60     *
 61     * @param delta tiempo transcurrido desde el fotograma anterior (en segundos).
 62     *              Siempre usar delta para el movimiento, nunca valores fijos.
 63     */
 64    override fun render(delta: Float) {
 65        update(delta)
 66        draw()
 67    }
 68
 69    private fun update(delta: Float) {
 70        // 1. Determinar dirección de movimiento según el toque del usuario
 71        val movimientoX = when {
 72            Gdx.input.isTouched && Gdx.input.x < Gdx.graphics.width / 2 -> -1f  // toca lado izquierdo
 73            Gdx.input.isTouched && Gdx.input.x >= Gdx.graphics.width / 2 -> 1f  // toca lado derecho
 74            else -> 0f
 75        }
 76
 77        // 2. Actualizar posición de la nave
 78        nave.update(movimientoX, delta)
 79
 80        // 3. Generación de asteroides a intervalos regulares
 81        tiempoUltimoAsteroide += delta
 82        if (tiempoUltimoAsteroide >= intervaloAsteroides) {
 83            tiempoUltimoAsteroide = 0f
 84            // El asteroide aparece justo por encima de la pantalla visible
 85            asteroides.add(Asteroide(KotlinAsteroidsGame.WORLD_WIDTH, KotlinAsteroidsGame.WORLD_HEIGHT))
 86        }
 87
 88        // 4. Actualizar y depurar lista de asteroides
 89        val iterador = asteroides.iterator()
 90        while (iterador.hasNext()) {
 91            val asteroide = iterador.next()
 92            asteroide.update(delta)
 93
 94            // Eliminar asteroides que salen por el borde inferior
 95            if (asteroide.fueraDePantalla()) {
 96                iterador.remove()
 97                continue
 98            }
 99
100            // 5. Detección de colisión con la nave
101            // Rectangle.overlaps() devuelve true si los dos rectángulos se solapan
102            if (nave.rect.overlaps(asteroide.rect)) {
103                // Transición a la pantalla de Game Over
104                game.setScreen<GameOverScreen>()
105                return  // salimos de update() inmediatamente
106            }
107        }
108
109        // 6. Incrementar puntuación en función del tiempo jugado
110        puntuacion += delta * 10f
111    }
112
113    private fun draw() {
114        // Limpiar la pantalla con color negro antes de dibujar
115        clearScreen(red = 0f, green = 0f, blue = 0f)
116
117        // Aplicar el viewport (gestiona la relación de aspecto)
118        game.viewport.apply()
119
120        // ktx-graphics: la función de extensión `use` llama automáticamente
121        // a begin() y end() en el SpriteBatch, evitando olvidar el end()
122        game.batch.projectionMatrix = game.viewport.camera.combined
123        game.batch.use {
124            // Fondo
125            it.draw(texturaFondo, 0f, 0f, KotlinAsteroidsGame.WORLD_WIDTH, KotlinAsteroidsGame.WORLD_HEIGHT)
126
127            // Nave
128            it.draw(textureNave, nave.rect.x, nave.rect.y, nave.rect.width, nave.rect.height)
129
130            // Asteroides
131            for (asteroide in asteroides) {
132                it.draw(textureAsteroide, asteroide.rect.x, asteroide.rect.y,
133                    asteroide.rect.width, asteroide.rect.height)
134            }
135
136            // Puntuación
137            fuente.color = Color.WHITE
138            fuente.draw(it, "Puntuación: ${puntuacion.toInt()}", 0.2f, KotlinAsteroidsGame.WORLD_HEIGHT - 0.3f)
139        }
140    }
141
142    override fun resize(width: Int, height: Int) {
143        // Notificamos al viewport del nuevo tamaño de pantalla
144        // El segundo parámetro `false` indica que no centramos la cámara en (0,0)
145        game.viewport.update(width, height, true)
146    }
147
148    override fun dispose() {
149        // Liberamos los recursos creados en esta pantalla
150        textureNave.dispose()
151        textureAsteroide.dispose()
152        texturaFondo.dispose()
153        fuente.dispose()
154    }
155}

9.7. MenuScreen.kt#

 1package com.dam.kotlinasteroids.screen
 2
 3import com.badlogic.gdx.graphics.Color
 4import com.badlogic.gdx.graphics.g2d.BitmapFont
 5import com.dam.kotlinasteroids.KotlinAsteroidsGame
 6import ktx.app.KtxScreen
 7import ktx.app.clearScreen
 8import ktx.graphics.use
 9
10/**
11 * Pantalla de menú principal.
12 *
13 * Muestra el título del juego e instrucciones para comenzar.
14 * La transición a GameScreen ocurre cuando el usuario toca la pantalla.
15 */
16class MenuScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
17
18    private val fuente = BitmapFont()
19
20    override fun render(delta: Float) {
21        clearScreen(0f, 0f, 0.1f)  // azul oscuro de fondo
22
23        game.viewport.apply()
24        game.batch.projectionMatrix = game.viewport.camera.combined
25
26        game.batch.use {
27            fuente.color = Color.WHITE
28            fuente.draw(it, "SPACE RUNNER", 1.5f, 10f)
29            fuente.draw(it, "Toca para comenzar", 1.5f, 7f)
30        }
31
32        // Cualquier toque en la pantalla inicia el juego
33        if (com.badlogic.gdx.Gdx.input.justTouched()) {
34            game.addScreen(GameScreen(game))
35            game.setScreen<GameScreen>()
36        }
37    }
38
39    override fun resize(width: Int, height: Int) {
40        game.viewport.update(width, height, true)
41    }
42
43    override fun dispose() {
44        fuente.dispose()
45    }
46}

9.8. GameOverScreen.kt#

 1package com.dam.kotlinasteroids.screen
 2
 3import com.badlogic.gdx.Gdx
 4import com.badlogic.gdx.graphics.Color
 5import com.badlogic.gdx.graphics.g2d.BitmapFont
 6import com.dam.kotlinasteroids.KotlinAsteroidsGame
 7import ktx.app.KtxScreen
 8import ktx.app.clearScreen
 9import ktx.graphics.use
10
11/**
12 * Pantalla de fin de juego.
13 *
14 * Muestra el mensaje de Game Over y permite reiniciar el juego.
15 * Al reiniciar, se descarta la pantalla anterior de GameScreen y se crea una nueva,
16 * lo que garantiza que el estado del juego empiece desde cero.
17 */
18class GameOverScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
19
20    private val fuente = BitmapFont()
21
22    override fun render(delta: Float) {
23        clearScreen(0.2f, 0f, 0f)  // rojo oscuro de fondo
24
25        game.viewport.apply()
26        game.batch.projectionMatrix = game.viewport.camera.combined
27
28        game.batch.use {
29            fuente.color = Color.RED
30            fuente.draw(it, "GAME OVER", 2f, 10f)
31            fuente.draw(it, "Toca para reiniciar", 1.5f, 7f)
32        }
33
34        if (Gdx.input.justTouched()) {
35            // Eliminamos la pantalla de juego anterior y creamos una nueva
36            game.removeScreen<GameScreen>()
37            game.addScreen(GameScreen(game))
38            game.setScreen<GameScreen>()
39        }
40    }
41
42    override fun resize(width: Int, height: Int) {
43        game.viewport.update(width, height, true)
44    }
45
46    override fun dispose() {
47        fuente.dispose()
48    }
49}

10. Integración con Android: el launcher#

El módulo android/ contiene únicamente el código de arranque para Android. gdx-liftoff genera automáticamente la AndroidLauncher, pero conviene entender qué hace:

 1package com.dam.kotlinasteroids.android
 2
 3import android.os.Bundle
 4import com.badlogic.gdx.backends.android.AndroidApplication
 5import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
 6import com.dam.kotlinasteroids.KotlinAsteroidsGame
 7
 8/**
 9 * Activity de Android que actúa como punto de entrada del juego.
10 *
11 * AndroidApplication es una subclase de Activity que integra el
12 * ciclo de vida de Android con el ciclo de vida de LibGDX.
13 * Cuando Android llama a onPause(), LibGDX llama a pause() en el juego.
14 * Cuando Android llama a onResume(), LibGDX llama a resume() en el juego.
15 */
16class AndroidLauncher : AndroidApplication() {
17
18    override fun onCreate(savedInstanceState: Bundle?) {
19        super.onCreate(savedInstanceState)
20
21        val config = AndroidApplicationConfiguration().apply {
22            // Activar antialias suaviza los bordes de los sprites
23            // a costa de un pequeño impacto en rendimiento
24            useAccelerometer = false  // desactivamos lo que no usamos
25            useCompass = false
26            useGyroscope = false
27        }
28
29        // initialize() crea la vista OpenGL ES y la adjunta a la Activity
30        initialize(KotlinAsteroidsGame(), config)
31    }
32}

10.1. Coexistencia con Jetpack Compose#

En algunos proyectos puede ser necesario lanzar el juego LibGDX desde una aplicación Jetpack Compose existente. La forma más limpia de hacerlo es crear una Activity separada exclusivamente para el juego y lanzarla con un Intent:

1// En la Activity o Composable de Jetpack Compose
2Button(onClick = {
3    val intent = Intent(context, AndroidLauncher::class.java)
4    context.startActivity(intent)
5}) {
6    Text("Jugar")
7}

Es importante declarar AndroidLauncher en el AndroidManifest.xml:

1<activity
2    android:name=".android.AndroidLauncher"
3    android:screenOrientation="portrait"
4    android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
5    android:exported="false" />

¿Por qué configChanges? LibGDX gestiona internamente los cambios de configuración del sistema (rotación, tamaño de teclado) para evitar que Android destruya y recree la Activity, lo que causaría la pérdida del contexto OpenGL. Sin este atributo, al rotar el dispositivo el juego se reiniciaría.


11. Viewport: adaptación a distintas pantallas#

Los dispositivos Android tienen pantallas de tamaños y resoluciones muy variados. Los Viewports de LibGDX resuelven el problema de cómo presentar el mundo del juego (que tiene un tamaño lógico fijo) en pantallas físicas diferentes.

Los tipos de viewport más usados son:

FitViewport (el que usamos en Space Runner): mantiene la proporción del mundo, añadiendo bandas negras (letterbox) si es necesario. Es el más recomendable para juegos donde la proporción es importante.

ExtendViewport: extiende el mundo para llenar la pantalla sin dejar bandas. El mundo puede verse un poco más grande en algunas orientaciones.

ScreenViewport: trabaja directamente en píxeles de pantalla. El mundo siempre cubre toda la pantalla pero los objetos se ven a tamaños físicos distintos según el dispositivo.

La elección del viewport depende del diseño del juego. Para Space Runner, FitViewport garantiza que la experiencia de juego sea idéntica en todos los dispositivos.


12. Buenas prácticas y errores comunes#

A continuación se recogen los errores más frecuentes que aparecen al desarrollar con LibGDX por primera vez, junto con su solución:

Olvidar llamar a dispose(): LibGDX no tiene recolector de basura para recursos de GPU. Toda Texture, Sound, Music, SpriteBatch o ShapeRenderer debe liberarse explícitamente. Usar la interfaz Disposable y el patrón de declarar los recursos como propiedades de la pantalla ayuda a recordarlo.

No usar delta en el movimiento: Si mueves objetos con valores fijos (rect.x += 0.1f) en lugar de relativos al tiempo (rect.x += VELOCIDAD * delta), el juego irá más rápido o más lento según la potencia del dispositivo. Siempre multiplica la velocidad por delta.

Crear objetos dentro de render(): Crear instancias de GlyphLayout, Vector3 o similares en cada fotograma genera miles de objetos por segundo y sobrecarga al recolector de basura de Java. Estos objetos deben declararse como propiedades de la clase y reutilizarse.

No convertir coordenadas de toque: Las coordenadas de Gdx.input.x/y están en píxeles de pantalla con Y invertido. Siempre usa camera.unproject() para convertirlas a coordenadas del mundo.

Confundir SpriteBatch.draw() y coordenadas: SpriteBatch.draw(texture, x, y, width, height) dibuja la textura con su esquina inferior izquierda en (x, y). Si tu objeto parece estar desplazado, comprueba que estás usando la esquina correcta.


Referencias y recursos#

Calendar  Última modificación: viernes, 26 de junio de 2026