Anexo 6: Referencia rápida de LibGDX#
- Tipo: Anexo de apoyo — material de consulta y profundización
- Bloque: B5 — Motores de juego: análisis e introducción a LibGDX
Este anexo actúa como hoja de consulta rápida durante el desarrollo del proyecto. Recoge las clases, métodos y patrones más usados del Tema 9 y el Tema 10 (KotlinAsteroids), organizados por área temática. Las secciones de física están adaptadas a juegos top-down sin gravedad.
- 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.
1. Versiones y dependencias de referencia#
gradle.properties#
gdxVersion=1.13.1
ktxVersion=1.13.1-rc1
kotlinVersion=2.1.10core/build.gradle.kts — dependencias completas#
1dependencies {
2 api("com.badlogicgames.gdx:gdx:$gdxVersion")
3 api("com.badlogicgames.gdx:gdx-box2d:$gdxVersion")
4 api("com.badlogicgames.gdx:gdx-freetype:$gdxVersion") // fuentes TTF (opcional)
5
6 api("io.github.libktx:ktx-app:$ktxVersion")
7 api("io.github.libktx:ktx-graphics:$ktxVersion")
8 api("io.github.libktx:ktx-assets:$ktxVersion")
9 api("io.github.libktx:ktx-math:$ktxVersion")
10 api("io.github.libktx:ktx-box2d:$ktxVersion")
11}Diferencia respecto a un juego de plataformas: KotlinAsteroids no usa
ktx-tiledporque no hay nivel basado en tiles. El nivel es generado proceduralmente.
android/build.gradle.kts — librerías nativas de Box2D#
1dependencies {
2 implementation(project(":core"))
3
4 // Librerías nativas de Box2D para cada arquitectura Android
5 natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-arm64-v8a")
6 natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-armeabi-v7a")
7 natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86")
8 natives("com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86_64")
9
10 // FreeType (solo si se usan fuentes TTF)
11 natives("com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-arm64-v8a")
12 natives("com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-armeabi-v7a")
13 natives("com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86")
14 natives("com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86_64")
15}2. Ciclo de vida — resumen visual#
AndroidLauncher.onCreate()
│
└─► initialize(KotlinAsteroidsGame(), config)
│
▼
KtxGame.create() ← batch, viewport, encolar assets, ir a LoadingScreen
│
├─► resize(w, h) ← justo después de create() y en cada cambio de tamaño
│
│ ┌──────────────────────────────────────────────────────┐
│ │ BUCLE PRINCIPAL (~60 fps) │
│ │ render(delta) │
│ │ ├── leerEntrada() │
│ │ ├── update entidades (nave, asteroides, balas) │
│ │ ├── worldManager.step(delta, todosLosBodies) │
│ │ │ ├── world.step(TIME_STEP, 8, 3) [Box2D] │
│ │ │ └── wrapBody() para cada body │
│ │ ├── procesarEventos() ← actuar sobre GameEvents │
│ │ └── draw() │
│ └──────────────────────────────────────────────────────┘
│
├─► pause() ← Home / llamada / bloqueo de pantalla
├─► resume() ← volver a la app
│
└─► dispose() ← cerrar app (puede no ejecutarse si el SO mata el proceso)
└── liberar: batch, world, shapeRenderer, particles, stage, debugRenderer...Métodos del ciclo de vida de KtxScreen#
| Método | Se llama cuando | Acción típica en KotlinAsteroids |
|---|---|---|
show() |
La pantalla se activa | Iniciar música de fondo |
render(delta) |
Cada fotograma | update + world.step + procesarEventos + draw |
resize(w, h) |
Cambio de tamaño | viewport.update(w, h, true), hud.resize(w, h) |
pause() |
Pérdida de foco | musica.pause() |
resume() |
Recuperar foco | if (!musica.isPlaying) musica.play() |
hide() |
La pantalla se desactiva | (vacío en la mayoría de casos) |
dispose() |
Liberación explícita | worldManager.dispose(), shapeRenderer.dispose(), etc. |
3. Renderizado 2D — SpriteBatch y ShapeRenderer#
SpriteBatch con KTX#
1// Patrón recomendado: use {} garantiza que end() siempre se llama
2batch.use { b ->
3 b.draw(textura, x, y, ancho, alto)
4 b.draw(region, x, y, ancho, alto) // TextureRegion
5 fuente.draw(b, "Texto", x, y)
6 efectoPartícula.draw(b, delta) // ParticleEffect
7}ShapeRenderer — el motor de renderizado de KotlinAsteroids#
KotlinAsteroids dibuja todas sus entidades con ShapeRenderer (estética vectorial retro). No usa TextureAtlas para la geometría del juego.
1val sr = ShapeRenderer()
2sr.projectionMatrix = camera.combined // aplicar SIEMPRE antes de begin()
3
4// ── Contornos (Line) ─────────────────────────────────────────────────────────
5// Usado para: asteroides (polígono irregular), balas (círculo pequeño)
6sr.begin(ShapeRenderer.ShapeType.Line)
7sr.setColor(Color.GRAY)
8sr.line(x1, y1, x2, y2) // segmento
9sr.circle(cx, cy, radio, 12) // círculo con 12 segmentos
10sr.end()
11
12// ── Relleno (Filled) ─────────────────────────────────────────────────────────
13// Usado para: nave (triángulo), campo de estrellas (puntos), llama del motor
14sr.begin(ShapeRenderer.ShapeType.Filled)
15sr.setColor(Color.WHITE)
16sr.triangle(x1, y1, x2, y2, x3, y3) // triángulo de la nave
17sr.rect(x, y, w, h) // punto de estrella
18sr.setColor(Color.ORANGE)
19sr.triangle(mx1, my1, mx2, my2, mx3, my3) // llama del motor
20sr.end()
21
22sr.dispose() // en dispose() de la pantallaRegla crítica:
SpriteBatchyShapeRendererno pueden estar activos simultáneamente. Cierra siempre elend()de uno antes de abrir elbegin()del otro. Mezclarlos lanzaIllegalStateException.
Dibujar la nave como triángulo rotado#
La nave es un triángulo en espacio local que se transforma al espacio del mundo usando la rotación del body Box2D. El ángulo del body está en radianes:
1fun dibujarNave(sr: ShapeRenderer, posicion: Vector2, angulo: Float, empujando: Boolean) {
2 val cos = MathUtils.cos(angulo)
3 val sin = MathUtils.sin(angulo)
4
5 // tx/ty transforman un punto local (lx, ly) a coordenadas del mundo
6 fun tx(lx: Float, ly: Float) = posicion.x + cos * lx - sin * ly
7 fun ty(lx: Float, ly: Float) = posicion.y + sin * lx + cos * ly
8
9 sr.begin(ShapeRenderer.ShapeType.Filled)
10 // Nave: nariz en (0.7, 0), alas en (-0.5, ±0.4) en espacio local
11 sr.setColor(Color.WHITE)
12 sr.triangle(
13 tx(0.7f, 0f), ty(0.7f, 0f),
14 tx(-0.5f, -0.4f), ty(-0.5f, -0.4f),
15 tx(-0.5f, 0.4f), ty(-0.5f, 0.4f)
16 )
17 // Llama del motor (solo visible al empujar)
18 if (empujando) {
19 sr.setColor(Color.ORANGE)
20 sr.triangle(
21 tx(-0.5f, -0.2f), ty(-0.5f, -0.2f),
22 tx(-0.5f, 0.2f), ty(-0.5f, 0.2f),
23 tx(-1.0f, 0f), ty(-1.0f, 0f)
24 )
25 }
26 sr.end()
27}4. Cámara y Viewport — referencia rápida#
1// KotlinAsteroids usa FitViewport: área de juego fija 22×14 unidades
2// con bandas negras en pantallas de otra proporción.
3val viewport = FitViewport(WORLD_WIDTH, WORLD_HEIGHT)
4val camera = viewport.camera as OrthographicCamera
5
6// La cámara permanece estática (no sigue a ningún objeto)
7// En create()/show():
8camera.setToOrtho(false, WORLD_WIDTH, WORLD_HEIGHT) // false = Y hacia arriba
9
10// En cada render()
11viewport.apply()
12camera.update()
13batch.projectionMatrix = camera.combined
14shapeRenderer.projectionMatrix = camera.combined
15
16// En resize()
17viewport.update(width, height, true)
18
19// Para dibujar los controles táctiles en coordenadas de PANTALLA (píxeles),
20// usar una proyección ortográfica de pantalla separada:
21val orthoPanel = Matrix4().setToOrtho2D(0f, 0f, screenWidth, screenHeight)
22shapeRenderer.projectionMatrix = orthoPanel
23// ... dibujar botones ...
24// Restaurar la proyección del juego al terminar:
25shapeRenderer.projectionMatrix = camera.combined5. AssetManager — carga asíncrona#
1val assets = AssetManager()
2
3// Encolar assets (en create() del juego, ANTES de ir a LoadingScreen)
4assets.load("audio/musica_fondo.ogg", Music::class.java)
5assets.load("audio/disparo.wav", Sound::class.java)
6assets.load("audio/explosion_grande.wav", Sound::class.java)
7assets.load("audio/explosion_pequeña.wav", Sound::class.java)
8assets.load("audio/muerte.wav", Sound::class.java)
9
10// En LoadingScreen.render(): avanzar la carga un trozo por fotograma
11if (assets.update()) {
12 // Carga completa → navegar a MenuScreen
13} else {
14 val progreso: Float = assets.progress // 0.0 a 1.0
15}
16
17// Carga síncrona (si no hay pantalla de carga)
18assets.finishLoading()
19
20// Acceso a assets ya cargados (lanza excepción si no está cargado aún)
21val musica: Music = assets.get("audio/musica_fondo.ogg", Music::class.java)
22val sfx: Sound = assets.get("audio/disparo.wav", Sound::class.java)
23
24// Liberación de TODOS los assets en dispose() del juego
25assets.dispose()KotlinAsteroids no carga TextureAtlas ni TiledMap con AssetManager porque el renderizado es vectorial (ShapeRenderer). Solo carga audio.
6. Texto y fuentes#
1// ── BitmapFont (fuente por defecto de LibGDX) ────────────────────────────────
2
3val fuente = BitmapFont() // Arial 15px
4fuente.color = Color.WHITE
5fuente.data.setScale(1.4f) // escala permanente; resetear si se comparte
6
7// Dentro de batch.use {}
8fuente.draw(batch, "GAME OVER", x, y)
9fuente.draw(batch, "$puntuacion", x, y)
10
11// GlyphLayout: para centrar o medir texto antes de dibujar
12val layout = GlyphLayout(fuente, "KOTLIN ASTEROIDS")
13val centroX = (WORLD_WIDTH - layout.width) / 2f
14fuente.draw(batch, layout, centroX, y)
15
16fuente.dispose() // en dispose() de la pantalla
17
18// ── FreeType (fuentes TTF, estilo arcade) ────────────────────────────────────
19
20val generator = FreeTypeFontGenerator(Gdx.files.internal("fuentes/orbitron.ttf"))
21val params = FreeTypeFontGenerator.FreeTypeFontParameter().apply {
22 size = 28
23 color = Color.CYAN
24 borderWidth = 1.5f
25 borderColor = Color.DARK_GRAY
26 characters = FreeTypeFontGenerator.DEFAULT_CHARS + "áéíóúÁÉÍÓÚñÑ¡¿"
27}
28val fuenteArcade: BitmapFont = generator.generateFont(params)
29generator.dispose() // solo el generador; la fuente resultante sigue siendo válida7. Entrada del usuario — multi-touch y teclado#
KotlinAsteroids usa cuatro zonas táctiles en la parte inferior de la pantalla. Es necesario comprobar múltiples pointers simultáneos para que el jugador pueda rotar y disparar a la vez.
1// ── Polling multi-touch ───────────────────────────────────────────────────────
2
3// Comprobar hasta 5 puntos de toque simultáneos (pointers 0-4)
4val sw = Gdx.graphics.width.toFloat()
5val sh = Gdx.graphics.height.toFloat()
6var rotDir = 0f
7var empujar = false
8var disparar = false
9
10for (pointer in 0..4) {
11 if (!Gdx.input.isTouched(pointer)) continue
12 val tx = Gdx.input.getX(pointer).toFloat()
13 val ty = Gdx.input.getY(pointer).toFloat()
14 if (ty < sh * 0.75f) continue // ignorar toques fuera de la zona de controles
15
16 when {
17 tx < sw * 0.25f -> rotDir = -1f // rotar horario
18 tx < sw * 0.50f -> empujar = true // empuje (thrust)
19 tx < sw * 0.75f -> rotDir = 1f // rotar antihorario
20 else -> disparar = true // disparar
21 }
22}
23
24// ── Teclado (útil para pruebas en Desktop) ───────────────────────────────────
25
26if (Gdx.input.isKeyPressed(Input.Keys.LEFT) || Gdx.input.isKeyPressed(Input.Keys.A)) rotDir = -1f
27if (Gdx.input.isKeyPressed(Input.Keys.RIGHT) || Gdx.input.isKeyPressed(Input.Keys.D)) rotDir = 1f
28if (Gdx.input.isKeyPressed(Input.Keys.UP) || Gdx.input.isKeyPressed(Input.Keys.W)) empujar = true
29if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) disparar = true
30if (Gdx.input.isKeyJustPressed(Input.Keys.F1)) modoDebug = !modoDebug // debug toggle
31
32// ── InputAdapter (para eventos discretos como botón Back de Android) ─────────
33
34Gdx.input.inputProcessor = object : InputAdapter() {
35 override fun keyDown(keycode: Int): Boolean {
36 if (keycode == Input.Keys.BACK) { /* gestionar salir */ }
37 return false
38 }
39}8. Audio#
1// ── Sound: efectos cortos (< 5 s, completamente en memoria) ─────────────────
2
3val sfxDisparo: Sound = assets.get("audio/disparo.wav", Sound::class.java)
4val sfxExplosionG: Sound = assets.get("audio/explosion_grande.wav", Sound::class.java)
5val sfxExplosionP: Sound = assets.get("audio/explosion_pequeña.wav",Sound::class.java)
6val sfxMuerte: Sound = assets.get("audio/muerte.wav", Sound::class.java)
7
8sfxDisparo.play(0.7f) // volumen 0.0-1.0
9sfxExplosionG.play(0.9f)
10// pitch: 0.5 (lento/grave) a 2.0 (rápido/agudo). Pan: -1.0 (izq) a 1.0 (der)
11sfxExplosionP.play(volume = 0.8f, pitch = 1.2f, pan = 0f)
12// NO llamar dispose() si el Sound fue cargado con AssetManager
13
14// ── Music: música de fondo en streaming ──────────────────────────────────────
15
16val musica: Music = assets.get("audio/musica_fondo.ogg", Music::class.java)
17musica.isLooping = true
18musica.volume = 0.5f
19musica.play()
20
21// Gestión del ciclo de vida en la GameScreen:
22override fun pause() { musica.pause() }
23override fun resume() { if (!musica.isPlaying) musica.play() }
24// NO llamar musica.dispose(): está gestionada por AssetManager9. Box2D — física top-down sin gravedad#
Mundo con gravedad cero#
En un juego espacial top-down, la gravedad es cero. Los cuerpos mantienen su velocidad indefinidamente una vez que se les aplica una fuerza (inercia newtoniana real):
1// Crear el world con gravedad = Vector2(0,0)
2val world = World(Vector2(0f, 0f), true) // true = dormir bodies inactivos
3world.setContactListener(miListener)
4
5// Paso de simulación con tiempo FIJO (independiente del delta real)
6private val TIME_STEP = 1f / 60f
7private val VELOCITY_ITER = 8
8private val POSITION_ITER = 3
9private var accumulator = 0f
10
11fun step(delta: Float) {
12 accumulator += delta.coerceAtMost(0.25f) // prevenir la "espiral de muerte"
13 while (accumulator >= TIME_STEP) {
14 world.step(TIME_STEP, VELOCITY_ITER, POSITION_ITER)
15 accumulator -= TIME_STEP
16 }
17}
18
19world.dispose() // siempre en dispose()Crear cuerpos con KTX-Box2D#
1// ── Nave del jugador (dinámico, circular) ────────────────────────────────────
2
3val nave: Body = world.body(BodyDef.BodyType.DynamicBody) {
4 position.set(cx, cy)
5 angle = MathUtils.PI / 2f // mirando hacia arriba al inicio (en radianes)
6 linearDamping = 0.8f // inercia espacial: frena muy despacio
7 angularDamping = 4.0f // la rotación frena al soltar el botón
8
9 circle(radius = 0.5f) {
10 density = 1f
11 friction = 0f
12 restitution = 0f
13 userData = "nave"
14 }
15}
16
17// ── Asteroide (dinámico, circular, sin amortiguación) ────────────────────────
18
19val asteroide: Body = world.body(BodyDef.BodyType.DynamicBody) {
20 position.set(spawnX, spawnY)
21 linearDamping = 0f // sin frenado: se mueve eternamente en el espacio
22 angularDamping = 0f
23 angularVelocity = MathUtils.random(-0.5f, 0.5f) // rotación visual lenta
24
25 circle(radius = tamañoAsteroide.radio) {
26 density = 1f
27 friction = 0f
28 restitution = 0.3f // pequeño rebote al colisionar con otros asteroides
29 userData = tamañoAsteroide // objeto enum como userData
30 }
31}.also {
32 it.linearVelocity = velocidadInicial // aplicar velocidad tras crear
33 it.userData = tamañoAsteroide
34}
35
36// ── Bala (sensor circular, sin amortiguación) ─────────────────────────────────
37// Sensor: detecta colisiones pero NO genera respuesta física (no empuja asteroides)
38
39val bala: Body = world.body(BodyDef.BodyType.DynamicBody) {
40 position.set(spawnX, spawnY)
41 bullet = true // CCD: detección precisa para objetos rápidos
42 linearDamping = 0f
43
44 circle(radius = 0.12f) {
45 isSensor = true // ← SENSOR: no empuja, solo detecta
46 density = 0f
47 userData = "bala"
48 }
49}.also {
50 it.linearVelocity = velocidadBala
51 it.userData = "bala"
52}Rotación y empuje de la nave#
Box2D usa radianes para los ángulos. El ángulo 0 apunta al eje +X (derecha), y los ángulos positivos son antihorario:
1// Rotar: establecer angularVelocity directamente (no aplicar torque)
2// Esto da control inmediato y preciso, adecuado para un juego arcade
3body.angularVelocity = direccionRot * VELOCIDAD_ROT // rad/s; positivo = antihorario
4
5// Empuje: fuerza en la dirección que apunta el body (su ángulo actual)
6val fx = MathUtils.cos(body.angle) * FUERZA_EMPUJE
7val fy = MathUtils.sin(body.angle) * FUERZA_EMPUJE
8body.applyForceToCenter(fx, fy, true) // true = despertar el body si duerme
9
10// Limitar la velocidad máxima manualmente (Box2D no tiene límite nativo)
11val vel = body.linearVelocity
12if (vel.len2() > VELOCIDAD_MAX * VELOCIDAD_MAX) { // len2() evita la raíz cuadrada
13 body.linearVelocity = vel.nor().scl(VELOCIDAD_MAX)
14}
15
16// Obtener la punta de la nave (posición de spawn de balas)
17val puntaX = body.position.x + MathUtils.cos(body.angle) * (RADIO + 0.1f)
18val puntaY = body.position.y + MathUtils.sin(body.angle) * (RADIO + 0.1f)
19
20// Obtener el motor de la nave (posición de partículas de empuje)
21val motorX = body.position.x - MathUtils.cos(body.angle) * RADIO
22val motorY = body.position.y - MathUtils.sin(body.angle) * RADIOWrapping de pantalla#
Los objetos que salen por un borde aparecen por el opuesto. Se aplica después de world.step(), nunca durante:
1private val MARGEN = 1.6f // unidades de margen fuera del área visible
2
3fun wrapBody(body: Body) {
4 var x = body.position.x
5 var y = body.position.y
6 var cambio = false
7
8 if (x < -MARGEN) { x = WORLD_W + MARGEN; cambio = true }
9 else if (x > WORLD_W + MARGEN) { x = -MARGEN; cambio = true }
10 if (y < -MARGEN) { y = WORLD_H + MARGEN; cambio = true }
11 else if (y > WORLD_H + MARGEN) { y = -MARGEN; cambio = true }
12
13 // setTransform() mueve el body SIN alterar su velocidad ni ángulo
14 if (cambio) body.setTransform(x, y, body.angle)
15}
16
17// Aplicar a todos los bodies activos después de step()
18listaDeBodies.forEach { wrapBody(it) }Error frecuente:
body.position.x = valorno funciona en Box2D. La posición es de solo lectura. Siempre usabody.setTransform(x, y, angle).
ContactListener con GameEvents diferidos#
En KotlinAsteroids, el ContactListener no modifica el world directamente. En su lugar, genera objetos GameEvent que se procesan después de world.step():
1// Definición de eventos (clase sellada)
2sealed class GameEvent {
3 data class AsteroidImpactado(
4 val bodyAsteroide: Body,
5 val bodyBala: Body,
6 val tamaño: TamañoAsteroide,
7 val posicion: Vector2,
8 val velocidadBase: Vector2
9 ) : GameEvent()
10 object NaveImpactada : GameEvent()
11}
12
13// ContactListener que SOLO genera eventos, NUNCA modifica el world
14world.setContactListener(object : ContactListener {
15 override fun beginContact(contact: Contact) {
16 val userA = contact.fixtureA.userData
17 val userB = contact.fixtureB.userData
18
19 // Bala impacta asteroide (identificar en cualquier orden)
20 val (fBala, fAst) = when {
21 userA == "bala" && userB is TamañoAsteroide -> Pair(contact.fixtureA, contact.fixtureB)
22 userB == "bala" && userA is TamañoAsteroide -> Pair(contact.fixtureB, contact.fixtureA)
23 else -> return
24 }
25 // Encolar el evento para procesarlo fuera de step()
26 eventos.add(GameEvent.AsteroidImpactado(
27 bodyAsteroide = fAst.body,
28 bodyBala = fBala.body,
29 tamaño = fAst.userData as TamañoAsteroide,
30 posicion = fAst.body.position.cpy(),
31 velocidadBase = fAst.body.linearVelocity.cpy()
32 ))
33 }
34
35 override fun preSolve(contact: Contact, oldManifold: Manifold) {
36 // Nave choca con asteroide (preSolve se llama antes de la respuesta física)
37 val userA = contact.fixtureA.userData as? String
38 val userB = contact.fixtureB.userData
39 if ((userA == "nave" && userB is TamañoAsteroide) ||
40 (contact.fixtureB.userData as? String == "nave" && userA is TamañoAsteroide)) {
41 eventos.add(GameEvent.NaveImpactada)
42 }
43 }
44
45 // Obligatorios aunque estén vacíos
46 override fun endContact(contact: Contact) {}
47 override fun postSolve(contact: Contact, impulse: ContactImpulse) {}
48})
49
50// En GameScreen.update(), DESPUÉS de worldManager.step():
51for (evento in worldManager.eventos) {
52 when (evento) {
53 is GameEvent.AsteroidImpactado -> {
54 // Aquí sí podemos crear/destruir bodies con seguridad
55 worldManager.encolarDestruccion(evento.bodyBala)
56 worldManager.encolarDestruccion(evento.bodyAsteroide)
57 // Crear fragmentos hijos si procede
58 evento.tamaño.hijos?.let { tamHijo ->
59 repeat(2) { crearFragmento(evento.posicion, tamHijo) }
60 }
61 }
62 GameEvent.NaveImpactada -> procesarMuerteNave()
63 }
64}Fragmentación de asteroides#
Al destruir un asteroide, se crean hijos con velocidades divergentes. La velocidad base del padre se mantiene para dar continuidad al movimiento:
1fun crearFragmento(posicion: Vector2, tamaño: TamañoAsteroide) {
2 val angulo = MathUtils.random(MathUtils.PI2) // dirección aleatoria
3 val speed = MathUtils.random(1.5f, 3.5f) // algo más rápido que el padre
4 val vel = Vector2(MathUtils.cos(angulo) * speed,
5 MathUtils.sin(angulo) * speed)
6 asteroides.add(Asteroide(world, posicion.cpy(), vel, tamaño))
7}Destrucción segura de bodies (encolar post-step)#
1// Set para evitar destruir el mismo body dos veces
2private val bodiesToDestroy = mutableSetOf<Body>()
3private val accionesDiferidas = mutableListOf<() -> Unit>()
4
5fun encolarDestruccion(body: Body) {
6 if (bodiesToDestroy.add(body)) { // add() devuelve false si ya estaba
7 accionesDiferidas.add { world.destroyBody(body) }
8 }
9}
10
11// Llamar DESPUÉS de world.step(), no dentro del ContactListener
12fun ejecutarAccionesDiferidas() {
13 accionesDiferidas.forEach { it() }
14 accionesDiferidas.clear()
15 bodiesToDestroy.clear()
16}Box2DDebugRenderer#
1val debugRenderer = Box2DDebugRenderer()
2
3// En render(), siempre DESPUÉS de todo el renderizado del juego
4if (modoDebug) {
5 debugRenderer.render(world, camera.combined)
6}
7
8debugRenderer.dispose() // en dispose()10. Partículas — referencia rápida#
1// Cargar efecto (en create() o show(), NUNCA en render())
2val efecto = ParticleEffect()
3efecto.load(
4 Gdx.files.internal("particles/motor_nave.p"),
5 Gdx.files.internal("particles") // carpeta de la imagen de partícula
6)
7efecto.scaleEffect(1f / 32f) // ajustar escala de píxeles a unidades de mundo
8
9// Posicionar y activar
10efecto.setPosition(x, y)
11efecto.start()
12
13// Efecto continuo (motor de la nave): actualizar posición cada fotograma
14efecto.setPosition(nave.motorPos.x, nave.motorPos.y)
15
16// Efecto puntual (explosión): start() + esperar a isComplete
17efecto.reset()
18efecto.setPosition(x, y)
19efecto.start()
20
21// En render(), DENTRO de batch.use {}
22efecto.draw(batch, delta) // actualiza Y dibuja en un solo paso
23
24// Estado
25efecto.isComplete // true cuando todas las partículas han muerto
26efecto.isStarted // true si está en reproducción
27efecto.allowCompletion() // dejar morir las partículas existentes sin emitir nuevas
28efecto.reset() // reiniciar para reutilizar (pool de efectos)
29
30efecto.dispose() // en dispose()Pool de efectos reutilizables#
Crear ParticleEffect en cada explosión es costoso. Con un pool se reutilizan instancias:
1// Pool de 8 explosiones (tamaño según el máximo simultáneo esperado)
2private val poolExplosion = List(8) { cargarEfecto("particles/explosion_asteroide.p") }
3
4fun emitirExplosion(posicion: Vector2) {
5 val disponible = poolExplosion.firstOrNull { it.isComplete } ?: return
6 disponible.reset()
7 disponible.setPosition(posicion.x, posicion.y)
8 disponible.start()
9}
10
11private fun cargarEfecto(ruta: String) = ParticleEffect().apply {
12 load(Gdx.files.internal(ruta), Gdx.files.internal("particles"))
13 scaleEffect(1f / 32f)
14}11. Persistencia — Preferencias (récord de puntuación)#
Gdx.app.getPreferences() es la versión multiplataforma de SharedPreferences de Android. Se usa para guardar el récord entre sesiones:
1// En KotlinAsteroidsGame:
2private val PREFS_NAME = "KotlinAsteroidsPrefs"
3
4// Leer el récord al iniciar
5val prefs = Gdx.app.getPreferences(PREFS_NAME)
6var recordPuntuacion = prefs.getInteger("record", 0) // 0 = valor por defecto
7
8// Guardar si la puntuación actual supera el récord
9fun actualizarRecord() {
10 if (puntuacion > recordPuntuacion) {
11 recordPuntuacion = puntuacion
12 val prefs = Gdx.app.getPreferences(PREFS_NAME)
13 prefs.putInteger("record", recordPuntuacion)
14 prefs.flush() // ¡OBLIGATORIO! Sin flush(), los datos NO se escriben a disco
15 }
16}Error frecuente: Llamar a
putInteger()sinflush(). Los datos solo existen en memoria y se pierden al cerrar la app.
12. Scene2D — Stage y Table para HUD#
1// Stage con ScreenViewport propio (independiente de la cámara del juego)
2val stage = Stage(ScreenViewport(), batch)
3
4val fuente = BitmapFont().apply { data.setScale(1.3f) }
5val estilo = Label.LabelStyle(fuente, Color.WHITE)
6
7val labelPuntuacion = Label("0", estilo)
8val labelOleada = Label("Oleada 1", estilo)
9
10// Table: layout declarativo anclado en la parte superior
11val tabla = Table()
12tabla.top().setFillParent(true)
13tabla.add(labelPuntuacion).expandX().left().pad(12f)
14tabla.add(labelOleada).expandX().center().pad(12f)
15// La columna derecha queda libre para dibujar mini-naves con ShapeRenderer
16tabla.add().expandX().right().pad(12f)
17
18stage.addActor(tabla)
19
20// Actualizar valores en render()
21labelPuntuacion.setText("$puntuacion")
22labelOleada.setText("Oleada $oleada")
23
24// Dibujar el HUD al final de render(), DESPUÉS del juego
25stage.act() // act() sin delta actualiza las acciones Scene2D
26stage.draw()
27
28// En resize()
29stage.viewport.update(width, height, true)
30
31stage.dispose() // en dispose()Mini-naves de vidas con ShapeRenderer (en coordenadas de pantalla)#
Las vidas se dibujan como pequeños triángulos en la esquina superior derecha usando coordenadas de pantalla (píxeles), no de mundo:
1// Cambiar la proyección del ShapeRenderer a coordenadas de pantalla
2val ortho = Matrix4().setToOrtho2D(0f, 0f, screenW, screenH)
3shapeRenderer.projectionMatrix = ortho
4
5shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
6shapeRenderer.setColor(Color.WHITE)
7for (i in 0 until vidas) {
8 val cx = screenW - 28f - i * 28f // posición en píxeles
9 val cy = screenH - 18f
10 shapeRenderer.triangle(cx, cy + 12f, cx - 7f, cy - 5f, cx + 7f, cy - 5f)
11}
12shapeRenderer.end()
13
14// Restaurar la proyección de la cámara del juego
15shapeRenderer.projectionMatrix = camera.combined13. Launcher Android — AndroidManifest.xml#
KotlinAsteroids es un juego landscape (apaisado). El configChanges evita que Android destruya la Activity al rotar el dispositivo:
1<!-- AndroidManifest.xml -->
2<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 <application
4 android:icon="@drawable/ic_launcher"
5 android:label="@string/app_name">
6
7 <activity
8 android:name=".android.AndroidLauncher"
9 android:label="@string/app_name"
10 android:screenOrientation="landscape"
11 android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"
12 android:exported="true">
13 <intent-filter>
14 <action android:name="android.intent.action.MAIN" />
15 <category android:name="android.intent.category.LAUNCHER" />
16 </intent-filter>
17 </activity>
18 </application>
19</manifest> 1// android/src/.../AndroidLauncher.kt
2class AndroidLauncher : AndroidApplication() {
3 override fun onCreate(savedInstanceState: Bundle?) {
4 super.onCreate(savedInstanceState)
5 val config = AndroidApplicationConfiguration().apply {
6 useAccelerometer = false // no lo usamos → ahorra batería
7 useCompass = false
8 useGyroscope = false
9 useWakelock = true // evitar que la pantalla se apague
10 useImmersiveMode = true // pantalla completa sin barra de navegación
11 }
12 initialize(KotlinAsteroidsGame(), config)
13 }
14}14. Tabla de errores comunes y soluciones#
| Error observado | Causa raíz | Solución |
|---|---|---|
| La nave cae continuamente hacia abajo | World(Vector2(0f, -20f), ...) — gravedad activa |
Cambiar a World(Vector2(0f, 0f), true) |
| El wrapping no funciona / crash al mover body | body.position.x = valor — la posición es de solo lectura en Box2D |
Usar siempre body.setTransform(x, y, body.angle) |
Crash Box2D assertion failed al destruir un asteroide |
world.destroyBody() llamado dentro del ContactListener |
Encolar la destrucción en una lista; ejecutar después de world.step() |
| Los fragmentos aparecen en (0, 0) en lugar de la posición del asteroide | Se usa body.position directamente sin .cpy() y el body ya fue destruido |
Guardar body.position.cpy() en el GameEvent antes de destruir el body |
| La bala empuja el asteroide al impactar | La fixture de la bala no es sensor | Añadir isSensor = true a la fixture circle de la bala |
| La nave no para de girar al soltar el botón | angularDamping muy bajo o no establecido |
Aumentar angularDamping (valor 3–5 suele ser adecuado) |
| La nave acelera indefinidamente | No se limita linearVelocity tras aplicar la fuerza |
Añadir el check if (vel.len2() > MAX²) body.linearVelocity = vel.nor().scl(MAX) |
| El mismo asteroide se destruye dos veces (doble destrucción) | El ContactListener puede generar el evento dos veces para el mismo par |
Usar un Set<Body> (bodiesToDestroy) que ignora duplicados con add() |
OpenGLException / crash al arrancar |
ShapeRenderer o BitmapFont creados fuera de create() |
Declararlos con lateinit var e inicializarlos en create() o show() |
IllegalStateException: called begin() |
begin() sin end() previo, o SpriteBatch+ShapeRenderer activos a la vez |
Verificar que cada begin() tiene su end() antes del siguiente |
| Las partículas son gigantescas | ParticleEffect diseñado en píxeles; el mundo está en unidades |
efecto.scaleEffect(1f / 32f) justo después de cargar |
| El récord no se guarda entre sesiones | putInteger() sin flush() |
Añadir prefs.flush() obligatoriamente después de putInteger() |
| Música no se pausa al minimizar la app | pause() de Screen no gestiona la música |
Sobreescribir pause() y llamar a musica.pause() |
| Movimiento de asteroides dependiente del framerate | Velocidad aplicada sin delta |
En Box2D el movimiento es automático; revisar si hay código manual que no usa delta |
15. Checklist de entrega del proyecto#
Antes de presentar el proyecto, verificar que:
Físicas y colisiones
- El
Worldde Box2D se crea con gravedadVector2(0f, 0f). - El paso de simulación usa tiempo fijo (
TIME_STEP = 1/60f) con acumulador. - Las balas son fixtures sensor (
isSensor = true). -
destroyBody()nunca se llama dentro delContactListener. - Se usa un
Seto flag para evitar destruir el mismo body dos veces. - El wrapping usa
body.setTransform(), nunca asignación directa aposition.
Entidades y oleadas
- Los fragmentos hijos spawn en la posición del asteroide padre (con
.cpy()). - Las balas se auto-destruyen al caducar su tiempo de vida.
- La oleada siguiente se inicia cuando
asteroides.isEmpty() && balas.isEmpty(). - La dificultad aumenta progresivamente (más asteroides o más velocidad por oleada).
Audiovisual
- El campo de estrellas de fondo se genera proceduralmente una sola vez (no en
render()). - Las partículas del motor se desactivan cuando la nave no empuja.
- Las explosiones usan un pool de efectos reutilizables.
- La música se pausa en
pause()y se reanuda enresume().
Arquitectura y recursos
-
Box2DDebugRendererdesactivado en la build final (controlado por flag). - Todas las pantallas liberan sus propios recursos en
dispose(). - El
AssetManagerlibera todos los assets endispose()del juego. - El récord se guarda con
Preferences.flush(). - El juego se lanza sin errores en dispositivo físico o emulador Android.
- No hay crashes al minimizar y volver a la app.
Defensa oral
- Explicar por qué la gravedad es cero y qué implica para la física.
- Explicar el patrón de eventos diferidos del
ContactListener. - Explicar cómo funciona el wrapping y por qué no se puede modificar
body.positiondirectamente. - Explicar la fragmentación: enum
TamañoAsteroide, puntuación y generación de hijos.
16. Estructura de assets de KotlinAsteroids#
KotlinAsteroids no usa atlas de sprites ni mapas de tiles. Los assets se limitan a audio y partículas:
android/assets/
│
├── particles/
│ ├── motor_nave.p ← chorro continuo del motor al aplicar thrust
│ ├── explosion_asteroide.p ← explosión al destruir un asteroide
│ └── particula.png ← imagen base (círculo blanco simple)
│
├── audio/
│ ├── musica_fondo.ogg ← música ambiental en bucle (Music, streaming)
│ ├── disparo.wav ← efecto de disparo de la nave (Sound, en memoria)
│ ├── explosion_grande.wav ← explosión de asteroide grande
│ ├── explosion_pequeña.wav ← explosión de asteroide mediano o pequeño
│ └── muerte.wav ← efecto al perder una vida
│
└── fuentes/ ← (opcional, si se usa FreeType)
└── orbitron.ttf ← fuente de estilo arcade/espacial¿Por qué no hay atlas ni tileset? KotlinAsteroids renderiza todas las entidades con
ShapeRenderer(triángulos, círculos y líneas). Esto elimina la dependencia de assets gráficos externos y permite centrarse en la arquitectura del código. El Particle Editor sí genera el archivo.p, pero la imagen de partícula (particula.png) puede ser un círculo blanco simple creado con cualquier editor de imagen.
17. Herramientas externas del ecosistema LibGDX#
| Herramienta | Función | Necesaria en KotlinAsteroids |
|---|---|---|
| gdx-liftoff | Generador de proyectos LibGDX multi-módulo | ✅ Sí, para crear el proyecto |
| Particle Editor (gdx-tools) | Editor visual de efectos de partículas (.p) | ✅ Sí, para motor y explosiones |
| Hiero (gdx-tools) | Genera BitmapFont desde fuentes del sistema | ⬜ Opcional (alternativa a FreeType) |
| Box2DDebugRenderer | Visualiza bodies Box2D en tiempo real (incluido en LibGDX) | ✅ Sí, para depuración |
| Tiled Map Editor | Editor de niveles basado en tiles (.tmx) | ❌ No necesario (sin nivel de tiles) |
| TexturePacker (gdx-tools) | Empaqueta sprites en un atlas | ❌ No necesario (renderizado vectorial) |
Para ejecutar las herramientas de gdx-tools:
# Particle Editor (diseñar efectos de motor y explosión)
java -cp gdx-tools.jar com.badlogic.gdx.tools.particleeditor.ParticleEditor
# Hiero (generar BitmapFont a partir de fuentes del sistema, opcional)
java -cp gdx-tools.jar com.badlogic.gdx.tools.hiero.HieroEl archivo gdx-tools.jar se descarga desde el repositorio oficial de LibGDX:
https://github.com/libgdx/libgdx/releases (buscar gdx-tools-1.13.1.jar)