Tema 10: KotlinAsteroids — Juego de asteroides 2D completo

  • Bloque: B5 — Motores de juego: análisis e introducción a LibGDX
  • Duración aproximada: 4 horas

Este tema desarrolla un proyecto guiado completo que integra todos los conceptos del Tema 9 aplicados a un juego top-down sin gravedad: Box2D con cuerpo libre, partículas, audio y múltiples pantallas. El juego sirve como referencia de arquitectura para el proyecto propio de evaluación.

  • 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-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-d Se han creado materiales para determinar las propiedades finales de la superficie de un objeto.
RA5-e Se han establecido las propiedades físicas de los objetos.
RA5-f Se ha incorporado sonido a los diferentes eventos del juego.
RA5-g Se han utilizado cámaras y configurado la iluminación.
RA5-h Se han desarrollado e implantado juegos para dispositivos móviles.
RA5-i Se han realizado pruebas de funcionamiento y optimización de los juegos desarrollados.
RA5-j Se han documentado las fases de diseño y desarrollo de los juegos creados.

1. Diseño del juego: KotlinAsteroids#

KotlinAsteroids es un juego de arcade espacial inspirado en el clásico Asteroids (Atari, 1979). El jugador controla una nave que puede rotar y desplazarse por el espacio y debe destruir asteroides a base de disparos antes de que le impacten.

1.1. Mecánicas del juego#

  • Rotación: botón táctil izquierdo o derecho rota la nave en su eje.
  • Empuje (thrust): botón táctil central aplica fuerza en la dirección que apunta la nave. La nave mantiene la inercia al soltar el botón (espacio sin gravedad).
  • Disparo: botón táctil derecho lanza una bala en la dirección actual de la nave.
  • Fragmentación: al destruir un asteroide grande aparecen dos medianos; al destruir uno mediano, dos pequeños. Los pequeños desaparecen al ser destruidos.
  • Wrapping: la nave y los asteroides aparecen por el borde opuesto al salir de la pantalla.
  • Oleadas: al destruir todos los asteroides de una oleada aparece la siguiente con uno más.
  • Vidas: el jugador dispone de tres vidas. Al perderlas todas, se muestra la pantalla de Game Over.

1.2. Características técnicas#

Característica Implementación
Física Box2D con gravedad cero, cuerpos dinámicos, sensores para balas
Fondo Campo de estrellas procedural con ShapeRenderer (sin TiledMap)
Renderizado ShapeRenderer para todas las entidades (estética vectorial retro)
Partículas Motor de empuje, explosión de asteroide
Audio Música de fondo + efectos de disparo, explosión y muerte
Pantallas Menú, Carga, Juego, Game Over
HUD Stage/Label: puntuación, oleada, vidas (dibujadas como mini-naves)

¿Por qué ShapeRenderer en lugar de TextureAtlas? El género Asteroids nació como gráficos vectoriales. Usar ShapeRenderer para dibujar triángulos y polígonos es más fiel al género, simplifica los assets necesarios y permite concentrarse en la arquitectura del código. Los efectos de partículas sí usan texturas.

¿Por qué Box2D sin TiledMap? Un juego top-down de espacio abierto no requiere nivel basado en tiles. TiledMap se estudia en profundidad en el Tema 9 y es aplicable al proyecto propio si el alumno diseña un juego con nivel estructurado. En KotlinAsteroids, Box2D se usa para física y detección de colisiones sin el complemento de un editor de mapas.


2. Conceptos de física específicos de un juego top-down#

2.1. Box2D sin gravedad#

A diferencia de un juego de plataformas, en un juego espacial top-down la gravedad es cero. El mundo de Box2D se crea con Vector2(0f, 0f):

1val world = World(Vector2(0f, 0f), true)

Sin gravedad, los cuerpos dinámicos mantienen indefinidamente su velocidad lineal una vez que se les aplica una fuerza. Para conseguir el efecto de inercia espacial del juego Asteroids original se usa una amortiguación lineal baja (linearDamping): el cuerpo frena muy lentamente, dando sensación de movimiento en el espacio:

1bodyDef.linearDamping  = 0.5f   // 0 = vacío puro; 1+ = freno notable
2bodyDef.angularDamping = 3.0f   // frenado rápido de rotación al soltar el botón

2.2. Rotación y empuje#

Box2D mide los ángulos en radianes, y el ángulo 0 corresponde al eje +X (mirando a la derecha). Un ángulo positivo es sentido antihorario.

Para rotar la nave se manipula directamente angularVelocity:

1body.angularVelocity = VELOCIDAD_ROTACION       // antihorario
2body.angularVelocity = -VELOCIDAD_ROTACION      // horario
3body.angularVelocity = 0f                       // sin rotación

Para empujar la nave en la dirección que apunta, se aplica una fuerza en el vector unitario del ángulo actual del body:

1val fx = MathUtils.cos(body.angle) * FUERZA_EMPUJE
2val fy = MathUtils.sin(body.angle) * FUERZA_EMPUJE
3body.applyForceToCenter(fx, fy, true)  // true = despertar el body si está dormido

La velocidad máxima se limita manualmente para que la nave no acelere indefinidamente:

1val vel = body.linearVelocity
2if (vel.len() > VELOCIDAD_MAX) {
3    body.linearVelocity = vel.nor().scl(VELOCIDAD_MAX)
4}

2.3. Wrapping de pantalla#

El efecto de que los objetos aparezcan por el borde opuesto al salir de la pantalla no es una función de Box2D; se implementa manualmente después de cada paso de simulación. El margen extra evita que los objetos parpadeen antes de estar completamente fuera de la vista:

 1private val MARGEN = 1.5f   // unidades de mundo 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    if (cambio) body.setTransform(x, y, body.angle)
14}

2.4. Sensores para balas#

Las balas deben detectar colisiones con los asteroides pero no empujarlos físicamente (no queremos que los asteroides salgan disparados al impactarlos). Para esto se usa una fixture de tipo sensor:

1circle(radius = RADIO_BALA) {
2    isSensor    = true          // detecta contacto, pero no genera respuesta física
3    density     = 0f
4    userData    = "bala"
5}

El ContactListener recibirá beginContact() cuando la bala y el asteroide se solapen, pero Box2D no aplicará ninguna fuerza entre ellos.

2.5. Fragmentación de asteroides en el ContactListener#

La regla crítica de Box2D es que no se puede crear ni destruir cuerpos dentro del ContactListener. La fragmentación de asteroides (destruir el asteroide padre y crear los hijos) se debe encolar en una lista y ejecutar después de world.step(). Para pasar la información necesaria a la acción diferida usamos una clase sellada de eventos:

 1sealed class GameEvent {
 2    data class AsteroidImpactado(
 3        val bodyAsteroide: Body,
 4        val bodyBala:      Body,
 5        val tamaño:        TamanyoAsteroide,
 6        val posicion:      Vector2,
 7        val velocidad:     Vector2
 8    ) : GameEvent()
 9    object NaveImpactada : GameEvent()
10}

3. Arquitectura del proyecto#

3.1. Creación con gdx-liftoff#

Configura el proyecto en gdx-liftoff con:

  • Project name: KotlinAsteroids
  • Root package: com.dam.kotlinasteroids
  • Main class: KotlinAsteroidsGame
  • Platforms: Android + Desktop
  • Extensions KTX: ktx-app, ktx-graphics, ktx-assets, ktx-math, ktx-box2d
  • Extensions LibGDX: Box2D

3.2. Estructura de archivos#

core/src/main/kotlin/com/dam/kotlinasteroids/
├── KotlinAsteroidsGame.kt         ← KtxGame principal
├── screen/
│   ├── LoadingScreen.kt           ← carga asíncrona de assets
│   ├── MenuScreen.kt              ← pantalla de inicio
│   ├── GameScreen.kt              ← pantalla principal
│   └── GameOverScreen.kt          ← fin de juego con puntuación y récord
├── entity/
│   ├── Nave.kt                    ← nave del jugador: body Box2D + estado
│   ├── Asteroide.kt               ← asteroide: body Box2D + tamaño
│   └── Bala.kt                    ← bala: sensor Box2D + tiempo de vida
├── world/
│   └── WorldManager.kt            ← World Box2D, ContactListener, eventos
├── particles/
│   └── GestorParticulas.kt        ← pool: motor de empuje + explosiones
└── ui/
    └── HUD.kt                     ← Stage: puntuación, oleada, vidas

3.3. Assets necesarios#

android/assets/
├── particles/
│   ├── motor_nave.p               ← efecto de motor al aplicar thrust
│   ├── explosion_asteroide.p      ← explosión al destruir asteroide
│   └── particula.png              ← imagen base de partículas (círculo blanco)
├── audio/
│   ├── musica_fondo.ogg           ← música ambiental en bucle
│   ├── disparo.wav                ← efecto de disparo
│   ├── explosion_grande.wav       ← explosión de asteroide grande
│   ├── explosion_pequeña.wav      ← explosión de asteroide mediano/pequeño
│   └── muerte.wav                 ← efecto al perder una vida
└── fuentes/                       ← (opcional, si se usa FreeType)
    └── orbitron.ttf

4. KotlinAsteroidsGame.kt#

 1package com.dam.kotlinasteroids
 2
 3import com.badlogic.gdx.assets.AssetManager
 4import com.badlogic.gdx.audio.Music
 5import com.badlogic.gdx.audio.Sound
 6import com.badlogic.gdx.graphics.g2d.SpriteBatch
 7import com.badlogic.gdx.utils.viewport.FitViewport
 8import com.dam.kotlinasteroids.screen.LoadingScreen
 9import ktx.app.KtxGame
10import ktx.app.KtxScreen
11
12/**
13 * Clase principal de KotlinAsteroids.
14 *
15 * Gestiona los recursos compartidos (batch, viewport, assets) y el estado
16 * de la partida que debe persistir entre pantallas (puntuación, vidas,
17 * oleada actual y récord guardado en preferencias).
18 */
19class KotlinAsteroidsGame : KtxGame<KtxScreen>() {
20
21    companion object {
22        // Mundo lógico landscape 22x14 unidades.
23        // 1 unidad ≈ 1 metro en Box2D → naves y asteroides de 0.5–3 unidades.
24        const val WORLD_WIDTH  = 22f
25        const val WORLD_HEIGHT = 14f
26    }
27
28    // SpriteBatch compartido entre todas las pantallas.
29    lateinit var batch: SpriteBatch
30
31    // FitViewport: el área de juego siempre muestra exactamente 22x14 unidades,
32    // con bandas negras en pantallas de otra proporción.
33    lateinit var viewport: FitViewport
34
35    // AssetManager centraliza la carga y liberación de todos los assets.
36    val assets = AssetManager()
37
38    // ── Estado de la partida activa ───────────────────────────────────────────
39    var puntuacion:     Int = 0
40    var vidasRestantes: Int = 3
41    var oleadaActual:   Int = 1
42    var recordPuntuacion: Int = 0   // cargado de preferencias en create()
43
44    override fun create() {
45        batch    = SpriteBatch()
46        viewport = FitViewport(WORLD_WIDTH, WORLD_HEIGHT)
47
48        // Encolar assets para carga asíncrona
49        assets.load("audio/musica_fondo.ogg",       Music::class.java)
50        assets.load("audio/disparo.wav",             Sound::class.java)
51        assets.load("audio/explosion_grande.wav",    Sound::class.java)
52        assets.load("audio/explosion_pequeña.wav",   Sound::class.java)
53        assets.load("audio/muerte.wav",              Sound::class.java)
54
55        // Cargar el récord almacenado en las preferencias del dispositivo
56        val prefs = getPreferences("KotlinAsteroidsPrefs")
57        recordPuntuacion = prefs.getInteger("record", 0)
58
59        addScreen(LoadingScreen(this))
60        setScreen<LoadingScreen>()
61    }
62
63    /** Reinicia el estado de la partida para una nueva sesión de juego. */
64    fun reiniciarPartida() {
65        puntuacion     = 0
66        vidasRestantes = 3
67        oleadaActual   = 1
68    }
69
70    /**
71     * Guarda el récord si la puntuación actual lo supera.
72     * Usar Gdx.app.getPreferences() para persistencia entre sesiones.
73     */
74    fun actualizarRecord() {
75        if (puntuacion > recordPuntuacion) {
76            recordPuntuacion = puntuacion
77            val prefs = getPreferences("KotlinAsteroidsPrefs")
78            prefs.putInteger("record", recordPuntuacion)
79            prefs.flush()   // ¡OBLIGATORIO! Sin flush() no se escribe a disco
80        }
81    }
82
83    override fun dispose() {
84        super.dispose()
85        batch.dispose()
86        assets.dispose()
87    }
88}

5. LoadingScreen y MenuScreen#

5.1. LoadingScreen.kt#

 1package com.dam.kotlinasteroids.screen
 2
 3import com.badlogic.gdx.Gdx
 4import com.badlogic.gdx.graphics.glutils.ShapeRenderer
 5import com.dam.kotlinasteroids.KotlinAsteroidsGame
 6import ktx.app.KtxScreen
 7import ktx.app.clearScreen
 8
 9/**
10 * Pantalla de carga asíncrona.
11 * Avanza el AssetManager con update() cada fotograma hasta que
12 * todos los assets estén disponibles, mostrando una barra de progreso.
13 */
14class LoadingScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
15
16    private val shapeR = ShapeRenderer()
17
18    private val barW = Gdx.graphics.width  * 0.6f
19    private val barH = 24f
20    private val barX = (Gdx.graphics.width  - barW) / 2f
21    private val barY = (Gdx.graphics.height - barH) / 2f
22
23    override fun render(delta: Float) {
24        if (game.assets.update()) {
25            game.addScreen(MenuScreen(game))
26            game.setScreen<MenuScreen>()
27            return
28        }
29        clearScreen(0f, 0f, 0f)
30        val p = game.assets.progress
31        shapeR.begin(ShapeRenderer.ShapeType.Filled)
32        shapeR.setColor(0.2f, 0.2f, 0.2f, 1f)
33        shapeR.rect(barX, barY, barW, barH)
34        shapeR.setColor(0.0f, 0.8f, 1.0f, 1f)
35        shapeR.rect(barX, barY, barW * p, barH)
36        shapeR.end()
37    }
38
39    override fun dispose() { shapeR.dispose() }
40}

5.2. MenuScreen.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.badlogic.gdx.graphics.glutils.ShapeRenderer
 7import com.badlogic.gdx.math.MathUtils
 8import com.dam.kotlinasteroids.KotlinAsteroidsGame
 9import ktx.app.KtxScreen
10import ktx.app.clearScreen
11import ktx.graphics.use
12
13/**
14 * Pantalla de menú con campo de estrellas animado y nave decorativa.
15 */
16class MenuScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
17
18    private val shapeR   = ShapeRenderer()
19    private val fuente   = BitmapFont()
20
21    // Campo de estrellas de fondo (posiciones fijas, brillo aleatorio)
22    private data class Estrella(val x: Float, val y: Float, val b: Float)
23    private val estrellas = List(200) {
24        Estrella(
25            MathUtils.random(0f, game.viewport.worldWidth),
26            MathUtils.random(0f, game.viewport.worldHeight),
27            MathUtils.random(0.2f, 1f)
28        )
29    }
30
31    override fun render(delta: Float) {
32        clearScreen(0f, 0f, 0f)
33        game.viewport.apply()
34
35        shapeR.projectionMatrix = game.viewport.camera.combined
36        shapeR.begin(ShapeRenderer.ShapeType.Filled)
37        // Estrellas de fondo
38        for (e in estrellas) {
39            shapeR.setColor(e.b, e.b, e.b, 1f)
40            shapeR.rect(e.x, e.y, 0.06f, 0.06f)
41        }
42        // Nave decorativa en el centro del menú
43        dibujarNaveDecorativa(shapeR)
44        shapeR.end()
45
46        game.batch.projectionMatrix = game.viewport.camera.combined
47        game.batch.use {
48            fuente.color = Color.CYAN
49            fuente.data.setScale(1.4f)
50            fuente.draw(it, "KOTLIN ASTEROIDS", 4f, 12f)
51            fuente.color = Color.WHITE
52            fuente.data.setScale(0.9f)
53            fuente.draw(it, "Record: ${game.recordPuntuacion}", 9f, 10f)
54            fuente.data.setScale(1f)
55            fuente.draw(it, "Toca para empezar", 7.5f, 3f)
56        }
57
58        if (Gdx.input.justTouched()) {
59            game.reiniciarPartida()
60            game.addScreen(GameScreen(game))
61            game.setScreen<GameScreen>()
62        }
63    }
64
65    private fun dibujarNaveDecorativa(sr: ShapeRenderer) {
66        // Triángulo apuntando arriba, centrado en la pantalla
67        val cx = game.viewport.worldWidth  / 2f
68        val cy = game.viewport.worldHeight / 2f
69        sr.setColor(Color.WHITE)
70        // Nariz, ala izquierda, ala derecha
71        sr.triangle(cx, cy + 1.5f, cx - 0.9f, cy - 0.7f, cx + 0.9f, cy - 0.7f)
72    }
73
74    override fun resize(w: Int, h: Int) { game.viewport.update(w, h, true) }
75    override fun dispose() { shapeR.dispose(); fuente.dispose() }
76}

6. Entidades del juego#

6.1. TamanyoAsteroide: enumeración de tamaños#

 1package com.dam.kotlinasteroids.entity
 2
 3/**
 4 * Tamaños posibles de un asteroide.
 5 * Define su radio físico, puntuación al destruirlo y
 6 * si genera fragmentos hijos al ser destruido.
 7 */
 8enum class TamanyoAsteroide(
 9    val radio: Float,
10    val puntos: Int,
11    val hijos: TamanyoAsteroide?   // null = no genera fragmentos
12) {
13    GRANDE(radio = 1.4f, puntos =  20, hijos = MEDIO),
14    MEDIO (radio = 0.8f, puntos =  50, hijos = PEQUEÑO),
15    PEQUEÑO(radio = 0.4f, puntos = 100, hijos = null)
16}

6.2. Nave.kt#

  1package com.dam.kotlinasteroids.entity
  2
  3import com.badlogic.gdx.graphics.Color
  4import com.badlogic.gdx.graphics.g2d.SpriteBatch
  5import com.badlogic.gdx.graphics.glutils.ShapeRenderer
  6import com.badlogic.gdx.math.MathUtils
  7import com.badlogic.gdx.math.Vector2
  8import com.badlogic.gdx.physics.box2d.Body
  9import com.badlogic.gdx.physics.box2d.World
 10import ktx.box2d.body
 11import ktx.box2d.circle
 12
 13/**
 14 * Nave controlada por el jugador.
 15 *
 16 * El body de Box2D gestiona la posición, el ángulo y la velocidad.
 17 * No se usa gravedad (el mundo tiene gravedad 0). La inercia es real:
 18 * la nave conserva su velocidad al dejar de empujar.
 19 *
 20 * El estado [invulnerable] se activa tras perder una vida para dar
 21 * tiempo al jugador a recolocarse. Durante ese tiempo, la nave parpadea.
 22 */
 23class Nave(world: World, spawnX: Float, spawnY: Float) {
 24
 25    companion object {
 26        const val RADIO           = 0.5f    // radio de la hitbox circular
 27        const val FUERZA_EMPUJE   = 18f     // Newton (masa 1 kg → 18 m/s² de aceleración)
 28        const val VELOCIDAD_MAX   = 9f      // límite de velocidad lineal en unidades/s
 29        const val VELOCIDAD_ROT   = 3.2f    // rad/s al presionar rotar
 30        const val COOLDOWN_DISPARO = 0.22f  // segundos mínimos entre disparos
 31    }
 32
 33    // ── Body Box2D ────────────────────────────────────────────────────────────
 34
 35    val body: Body = world.body(com.badlogic.gdx.physics.box2d.BodyDef.BodyType.DynamicBody) {
 36        position.set(spawnX, spawnY)
 37        angle         = MathUtils.PI / 2f  // apuntando hacia arriba al inicio
 38        linearDamping = 0.8f               // inercia espacial moderada
 39        angularDamping = 4.0f              // la rotación frena al soltar el botón
 40
 41        circle(radius = RADIO) {
 42            density     = 1f
 43            friction    = 0f
 44            restitution = 0f
 45            userData    = "nave"
 46        }
 47    }
 48
 49    // ── Estado ────────────────────────────────────────────────────────────────
 50
 51    var invulnerable           = true       // al inicio de la partida/respawn
 52    var tiempoInvulnerabilidad = 2.5f      // segundos de invulnerabilidad restantes
 53    var cooldownDisparo        = 0f
 54    var empujando              = false      // para activar partículas del motor
 55
 56    // ── Lógica de actualización ───────────────────────────────────────────────
 57
 58    /**
 59     * Aplica la entrada del jugador al body de Box2D.
 60     *
 61     * @param rotacionDir -1f (horario), 0f (parado), 1f (antihorario)
 62     * @param empujar     true mientras se presiona el botón de empuje
 63     * @param delta       tiempo del fotograma en segundos
 64     */
 65    fun update(rotacionDir: Float, empujar: Boolean, delta: Float) {
 66        // Rotación: establecer angularVelocity directamente
 67        body.angularVelocity = rotacionDir * VELOCIDAD_ROT
 68
 69        // Empuje: fuerza en la dirección del ángulo actual
 70        this.empujando = empujar
 71        if (empujar) {
 72            val fx = MathUtils.cos(body.angle) * FUERZA_EMPUJE
 73            val fy = MathUtils.sin(body.angle) * FUERZA_EMPUJE
 74            body.applyForceToCenter(fx, fy, true)
 75
 76            // Limitar la velocidad máxima
 77            val vel = body.linearVelocity
 78            if (vel.len2() > VELOCIDAD_MAX * VELOCIDAD_MAX) {
 79                body.linearVelocity = vel.nor().scl(VELOCIDAD_MAX)
 80            }
 81        }
 82
 83        // Cooldown de disparo
 84        if (cooldownDisparo > 0f) cooldownDisparo -= delta
 85
 86        // Invulnerabilidad temporal
 87        if (invulnerable) {
 88            tiempoInvulnerabilidad -= delta
 89            if (tiempoInvulnerabilidad <= 0f) {
 90                invulnerable = false
 91                tiempoInvulnerabilidad = 0f
 92            }
 93        }
 94    }
 95
 96    /** Devuelve true si puede disparar (cooldown completado). */
 97    fun puedeDisparar(): Boolean = cooldownDisparo <= 0f
 98
 99    /** Registra un disparo reiniciando el cooldown. */
100    fun registrarDisparo() {
101        cooldownDisparo = COOLDOWN_DISPARO
102    }
103
104    /** Posición de la punta de la nave (donde nace la bala). */
105    val puntaNave: Vector2
106        get() = Vector2(
107            body.position.x + MathUtils.cos(body.angle) * (RADIO + 0.1f),
108            body.position.y + MathUtils.sin(body.angle) * (RADIO + 0.1f)
109        )
110
111    /** Posición del motor (extremo trasero, donde aparece el chorro de partículas). */
112    val motorPos: Vector2
113        get() = Vector2(
114            body.position.x - MathUtils.cos(body.angle) * RADIO,
115            body.position.y - MathUtils.sin(body.angle) * RADIO
116        )
117
118    // ── Renderizado ───────────────────────────────────────────────────────────
119
120    /**
121     * Dibuja la nave como un triángulo vectorial con ShapeRenderer.
122     * Durante la invulnerabilidad, la nave parpadea (visible en fotogramas pares).
123     *
124     * Debe llamarse con shapeRenderer.begin() ya activo.
125     */
126    fun draw(sr: ShapeRenderer, tiempoTotal: Float) {
127        // Parpadeo durante invulnerabilidad: no dibujar en fotogramas alternos
128        if (invulnerable && (tiempoTotal * 8).toInt() % 2 == 0) return
129
130        val pos    = body.position
131        val angulo = body.angle
132        val cos    = MathUtils.cos(angulo)
133        val sin    = MathUtils.sin(angulo)
134
135        // Transformar vértices del triángulo desde espacio local a espacio mundo
136        // Espacio local: nariz en (0.7, 0), alas en (-0.5, ±0.4)
137        fun tx(lx: Float, ly: Float) = pos.x + cos * lx - sin * ly
138        fun ty(lx: Float, ly: Float) = pos.y + sin * lx + cos * ly
139
140        sr.setColor(Color.WHITE)
141        // Triángulo principal: nariz + alas
142        sr.triangle(
143            tx(0.7f, 0f),    ty(0.7f, 0f),
144            tx(-0.5f, -0.4f), ty(-0.5f, -0.4f),
145            tx(-0.5f,  0.4f), ty(-0.5f,  0.4f)
146        )
147
148        // Llama del motor (visible solo al empujar)
149        if (empujando) {
150            sr.setColor(Color.ORANGE)
151            sr.triangle(
152                tx(-0.5f, -0.2f), ty(-0.5f, -0.2f),
153                tx(-0.5f,  0.2f), ty(-0.5f,  0.2f),
154                tx(-1.0f, 0f),    ty(-1.0f, 0f)
155            )
156        }
157    }
158}

6.3. Asteroide.kt#

 1package com.dam.kotlinasteroids.entity
 2
 3import com.badlogic.gdx.graphics.Color
 4import com.badlogic.gdx.graphics.glutils.ShapeRenderer
 5import com.badlogic.gdx.math.MathUtils
 6import com.badlogic.gdx.math.Vector2
 7import com.badlogic.gdx.physics.box2d.Body
 8import com.badlogic.gdx.physics.box2d.World
 9import ktx.box2d.body
10import ktx.box2d.circle
11
12/**
13 * Entidad asteroide.
14 *
15 * Cuerpo dinámico con velocidad inicial aleatoria. Los asteroides no
16 * frenan (linearDamping = 0): se mueven indefinidamente por el espacio.
17 * El wrapping de pantalla se aplica externamente en WorldManager.
18 *
19 * El renderizado usa un polígono irregular dibujado con ShapeRenderer
20 * para dar aspecto de roca. Los puntos del polígono son perturbaciones
21 * aleatorias del radio en 10 segmentos angulares.
22 */
23class Asteroide(
24    world: World,
25    posicion: Vector2,
26    velocidad: Vector2,
27    val tamaño: TamanyoAsteroide
28) {
29
30    val body: Body = world.body(com.badlogic.gdx.physics.box2d.BodyDef.BodyType.DynamicBody) {
31        position.set(posicion)
32        linearDamping  = 0f    // sin frenado: movimiento eterno en el espacio
33        angularDamping = 0f
34        // Rotación visual lenta y aleatoria
35        angularVelocity = MathUtils.random(-0.5f, 0.5f)
36
37        circle(radius = tamaño.radio) {
38            density     = 1f
39            friction    = 0f
40            restitution = 0.3f   // pequeño rebote al colisionar con otros asteroides
41            userData    = tamaño  // guardamos el enum directamente como userData
42        }
43    }.also {
44        it.linearVelocity = velocidad
45        it.userData = tamaño   // también en el body para fácil acceso desde GameScreen
46    }
47
48    // Puntos del polígono: 10 segmentos con radio variable (aspecto de roca)
49    private val SEGMENTOS = 10
50    private val factores = FloatArray(SEGMENTOS) { MathUtils.random(0.65f, 1.0f) }
51
52    /**
53     * Dibuja el asteroide como un polígono irregular.
54     * Debe llamarse con shapeRenderer.begin(ShapeType.Line) activo.
55     */
56    fun draw(sr: ShapeRenderer) {
57        val pos    = body.position
58        val radio  = tamaño.radio
59        val angulo = body.angle
60
61        sr.setColor(Color.GRAY)
62
63        // Dibujar líneas entre vértices consecutivos del polígono irregular
64        for (i in 0 until SEGMENTOS) {
65            val a1 = angulo + (i.toFloat()     / SEGMENTOS) * MathUtils.PI2
66            val a2 = angulo + ((i + 1f)        / SEGMENTOS) * MathUtils.PI2
67            val r1 = radio * factores[i]
68            val r2 = radio * factores[(i + 1) % SEGMENTOS]
69
70            sr.line(
71                pos.x + MathUtils.cos(a1) * r1,
72                pos.y + MathUtils.sin(a1) * r1,
73                pos.x + MathUtils.cos(a2) * r2,
74                pos.y + MathUtils.sin(a2) * r2
75            )
76        }
77    }
78}

6.4. Bala.kt#

 1package com.dam.kotlinasteroids.entity
 2
 3import com.badlogic.gdx.graphics.Color
 4import com.badlogic.gdx.graphics.glutils.ShapeRenderer
 5import com.badlogic.gdx.math.Vector2
 6import com.badlogic.gdx.physics.box2d.Body
 7import com.badlogic.gdx.physics.box2d.World
 8import ktx.box2d.body
 9import ktx.box2d.circle
10
11/**
12 * Proyectil disparado por la nave.
13 *
14 * Fixture de tipo SENSOR: detecta colisiones con asteroides sin
15 * generar respuesta física (los asteroides no son empujados).
16 *
17 * Se auto-destruye tras [TIEMPO_VIDA] segundos si no ha impactado.
18 * La destrucción real del body se encola en WorldManager.
19 */
20class Bala(world: World, posicion: Vector2, velocidad: Vector2) {
21
22    companion object {
23        const val RADIO      = 0.12f
24        const val TIEMPO_VIDA = 1.6f   // segundos máximos de vida
25        const val VELOCIDAD   = 14f    // unidades/segundo
26    }
27
28    var tiempoRestante = TIEMPO_VIDA
29    var destruida = false   // marcador para no encolar múltiples destrucciones
30
31    val body: Body = world.body(com.badlogic.gdx.physics.box2d.BodyDef.BodyType.DynamicBody) {
32        position.set(posicion)
33        bullet = true         // activar detección de colisión precisa (CCD)
34        linearDamping = 0f    // velocidad constante
35
36        circle(radius = RADIO) {
37            isSensor    = true    // ¡sensor! no empuja, solo detecta
38            density     = 0f
39            userData    = "bala"
40        }
41    }.also {
42        it.linearVelocity = velocidad
43        it.userData       = "bala"
44    }
45
46    /**
47     * Actualiza el temporizador. Devuelve true si la bala debe destruirse.
48     */
49    fun update(delta: Float): Boolean {
50        tiempoRestante -= delta
51        return tiempoRestante <= 0f || destruida
52    }
53
54    /** Dibuja la bala como un pequeño punto brillante. */
55    fun draw(sr: ShapeRenderer) {
56        sr.setColor(Color.YELLOW)
57        sr.circle(body.position.x, body.position.y, RADIO, 6)
58    }
59}

7. WorldManager.kt#

  1package com.dam.kotlinasteroids.world
  2
  3import com.badlogic.gdx.math.MathUtils
  4import com.badlogic.gdx.math.Vector2
  5import com.badlogic.gdx.physics.box2d.*
  6import com.badlogic.gdx.utils.Disposable
  7import com.dam.kotlinasteroids.KotlinAsteroidsGame
  8import com.dam.kotlinasteroids.entity.TamanyoAsteroide
  9
 10/**
 11 * Eventos del juego generados por el ContactListener.
 12 *
 13 * Se usan para comunicar colisiones al GameScreen sin modificar el
 14 * World de Box2D dentro del ContactListener (lo que causaría crash).
 15 * GameScreen lee esta lista después de cada world.step() y actúa.
 16 */
 17sealed class GameEvent {
 18    /** Una bala ha impactado un asteroide. */
 19    data class AsteroidImpactado(
 20        val bodyAsteroide: Body,
 21        val bodyBala:      Body,
 22        val tamaño:        TamanyoAsteroide,
 23        val posicion:      Vector2,
 24        val velocidadBase: Vector2
 25    ) : GameEvent()
 26
 27    /** La nave ha colisionado con un asteroide (jugador no invulnerable). */
 28    object NaveImpactada : GameEvent()
 29}
 30
 31/**
 32 * Gestiona el World de Box2D para KotlinAsteroids.
 33 *
 34 * Responsabilidades:
 35 *  - World con gravedad cero (espacio).
 36 *  - Paso de simulación con tiempo fijo.
 37 *  - ContactListener que genera GameEvents sin modificar el World.
 38 *  - Wrapping de pantalla de todos los bodies.
 39 *  - Acciones diferidas (destrucción de bodies) post-step().
 40 */
 41class WorldManager : Disposable {
 42
 43    val world: World = World(Vector2(0f, 0f), true)   // gravedad = 0
 44
 45    private val TIME_STEP     = 1f / 60f
 46    private val VELOCITY_ITER = 8
 47    private val POSITION_ITER = 3
 48    private var accumulator   = 0f
 49
 50    // Lista de eventos generados en el ContactListener del frame actual
 51    val eventos = mutableListOf<GameEvent>()
 52
 53    // Acciones a ejecutar después de world.step() (creación/destrucción de bodies)
 54    private val accionesDiferidas = mutableListOf<() -> Unit>()
 55
 56    // Set de bodies ya marcados para destruir (evita double-free)
 57    private val bodiesToDestroy = mutableSetOf<Body>()
 58
 59    init {
 60        world.setContactListener(crearContactListener())
 61    }
 62
 63    // ── Paso de simulación ────────────────────────────────────────────────────
 64
 65    /**
 66     * Avanza la simulación con paso fijo y aplica el wrapping de pantalla.
 67     *
 68     * @param delta tiempo real del fotograma
 69     * @param allBodies lista de todos los bodies activos para aplicar wrapping
 70     */
 71    fun step(delta: Float, allBodies: List<Body>) {
 72        eventos.clear()
 73
 74        accumulator += delta.coerceAtMost(0.25f)
 75        while (accumulator >= TIME_STEP) {
 76            world.step(TIME_STEP, VELOCITY_ITER, POSITION_ITER)
 77            accumulator -= TIME_STEP
 78        }
 79
 80        // Aplicar wrapping DESPUÉS del paso físico
 81        allBodies.forEach { wrapBody(it) }
 82
 83        // Ejecutar acciones diferidas (destrucción de bodies)
 84        accionesDiferidas.forEach { it() }
 85        accionesDiferidas.clear()
 86        bodiesToDestroy.clear()
 87    }
 88
 89    /**
 90     * Encola la destrucción de un body para ejecutarse después de step().
 91     * El set evita que el mismo body se destruya dos veces.
 92     */
 93    fun encolarDestruccion(body: Body) {
 94        if (bodiesToDestroy.add(body)) {
 95            accionesDiferidas.add { world.destroyBody(body) }
 96        }
 97    }
 98
 99    // ── Wrapping de pantalla ──────────────────────────────────────────────────
100
101    private val MARGEN = 1.6f
102
103    private fun wrapBody(body: Body) {
104        var x = body.position.x
105        var y = body.position.y
106        var cambio = false
107
108        if (x < -MARGEN) { x = KotlinAsteroidsGame.WORLD_WIDTH + MARGEN; cambio = true }
109        else if (x > KotlinAsteroidsGame.WORLD_WIDTH + MARGEN) { x = -MARGEN; cambio = true }
110        if (y < -MARGEN) { y = KotlinAsteroidsGame.WORLD_HEIGHT + MARGEN; cambio = true }
111        else if (y > KotlinAsteroidsGame.WORLD_HEIGHT + MARGEN){ y = -MARGEN; cambio = true }
112
113        if (cambio) body.setTransform(x, y, body.angle)
114    }
115
116    // ── ContactListener ───────────────────────────────────────────────────────
117
118    private fun crearContactListener() = object : ContactListener {
119
120        override fun beginContact(contact: Contact) {
121            val userA = contact.fixtureA.userData as? String ?: return
122            val userB = contact.fixtureB.userData as? String
123
124            // Identificar bala y asteroide (en cualquier orden de fixtures)
125            val (fixtBala, fixtAst) = when {
126                userA == "bala" && contact.fixtureB.userData is TamanyoAsteroide ->
127                    Pair(contact.fixtureA, contact.fixtureB)
128                userB == "bala" && contact.fixtureA.userData is TamanyoAsteroide ->
129                    Pair(contact.fixtureB, contact.fixtureA)
130                else -> return
131            }
132
133            val tamaño = fixtAst.userData as TamanyoAsteroide
134
135            // ¡NUNCA modificar el world aquí! Solo generamos un evento.
136            eventos.add(
137                GameEvent.AsteroidImpactado(
138                    bodyAsteroide = fixtAst.body,
139                    bodyBala      = fixtBala.body,
140                    tamaño        = tamaño,
141                    posicion      = fixtAst.body.position.cpy(),
142                    velocidadBase = fixtAst.body.linearVelocity.cpy()
143                )
144            )
145        }
146
147        override fun endContact(contact: Contact) {
148            val userA = contact.fixtureA.userData as? String ?: return
149            val userB = contact.fixtureB.userData as? String
150
151            // Nave impacta con asteroide
152            val naveImpactada = (userA == "nave" && contact.fixtureB.userData is TamanyoAsteroide) ||
153                                (userB == "nave" && contact.fixtureA.userData is TamanyoAsteroide)
154            if (naveImpactada) {
155                // endContact para nave: no la usamos aquí,
156                // la colisión real se detecta en beginContact
157            }
158        }
159
160        override fun preSolve(contact: Contact, oldManifold: Manifold) {
161            // Detectar nave vs asteroide AQUÍ (preSolve se llama antes de la respuesta física)
162            val userA = contact.fixtureA.userData as? String
163            val userB = contact.fixtureB.userData as? String
164            val naveChoca = (userA == "nave" && contact.fixtureB.userData is TamanyoAsteroide) ||
165                            (userB == "nave" && contact.fixtureA.userData is TamanyoAsteroide)
166            if (naveChoca) {
167                eventos.add(GameEvent.NaveImpactada)
168            }
169        }
170
171        override fun postSolve(contact: Contact, impulse: ContactImpulse) {}
172    }
173
174    override fun dispose() { world.dispose() }
175}

8. GestorParticulas.kt#

 1package com.dam.kotlinasteroids.particles
 2
 3import com.badlogic.gdx.Gdx
 4import com.badlogic.gdx.graphics.g2d.ParticleEffect
 5import com.badlogic.gdx.graphics.g2d.SpriteBatch
 6import com.badlogic.gdx.math.Vector2
 7import com.badlogic.gdx.utils.Disposable
 8
 9/**
10 * Gestor de efectos de partículas de KotlinAsteroids.
11 *
12 * Mantiene dos pools de efectos reutilizables:
13 *  - [poolMotor]: chorro continuo del motor de la nave al empujar.
14 *  - [poolExplosion]: explosión al destruir un asteroide.
15 *
16 * Los efectos del motor son de larga duración (continuos mientras se empuja);
17 * los de explosión son puntuales (se emiten una vez y se completan solos).
18 */
19class GestorParticulas : Disposable {
20
21    private val ESCALA_MUNDO = 1f / 32f   // ajustar de píxeles (Particle Editor) a unidades
22
23    // Motor: 2 instancias (la nave solo tiene uno pero necesitamos margen)
24    private val poolMotor     = List(2)  { cargar("particles/motor_nave.p") }
25    // Explosiones: pool de 8 para manejar oleadas con muchos asteroides
26    private val poolExplosion = List(8)  { cargar("particles/explosion_asteroide.p") }
27
28    private val activos = mutableListOf<ParticleEffect>()
29
30    // ── API pública ───────────────────────────────────────────────────────────
31
32    /**
33     * Inicia o actualiza el efecto de motor en la posición indicada.
34     * Debe llamarse cada fotograma mientras la nave está empujando.
35     */
36    fun actualizarMotor(posicion: Vector2, activo: Boolean) {
37        val efecto = poolMotor.firstOrNull() ?: return
38        if (activo) {
39            efecto.setPosition(posicion.x, posicion.y)
40            if (!efecto.isStarted) {
41                efecto.start()
42                if (efecto !in activos) activos.add(efecto)
43            }
44        } else {
45            efecto.allowCompletion()  // dejar que las partículas existentes mueran
46        }
47    }
48
49    /** Emite una explosión puntual en la posición indicada. */
50    fun emitirExplosion(posicion: Vector2) {
51        val efecto = poolExplosion.firstOrNull { it.isComplete } ?: return
52        efecto.reset()
53        efecto.setPosition(posicion.x, posicion.y)
54        efecto.start()
55        if (efecto !in activos) activos.add(efecto)
56    }
57
58    /**
59     * Actualiza y dibuja todos los efectos activos.
60     * Llamar dentro de un bloque batch.use {}.
61     */
62    fun render(batch: SpriteBatch, delta: Float) {
63        val iter = activos.iterator()
64        while (iter.hasNext()) {
65            val efecto = iter.next()
66            efecto.draw(batch, delta)
67            if (efecto.isComplete) {
68                efecto.reset(false)
69                iter.remove()
70            }
71        }
72    }
73
74    // ── Auxiliares ────────────────────────────────────────────────────────────
75
76    private fun cargar(ruta: String): ParticleEffect =
77        ParticleEffect().apply {
78            load(Gdx.files.internal(ruta), Gdx.files.internal("particles"))
79            scaleEffect(ESCALA_MUNDO)
80        }
81
82    override fun dispose() {
83        poolMotor.forEach { it.dispose() }
84        poolExplosion.forEach { it.dispose() }
85    }
86}

9. HUD.kt#

 1package com.dam.kotlinasteroids.ui
 2
 3import com.badlogic.gdx.graphics.Color
 4import com.badlogic.gdx.graphics.g2d.BitmapFont
 5import com.badlogic.gdx.graphics.g2d.SpriteBatch
 6import com.badlogic.gdx.graphics.glutils.ShapeRenderer
 7import com.badlogic.gdx.scenes.scene2d.Stage
 8import com.badlogic.gdx.scenes.scene2d.ui.Label
 9import com.badlogic.gdx.scenes.scene2d.ui.Table
10import com.badlogic.gdx.utils.Disposable
11import com.badlogic.gdx.utils.viewport.ScreenViewport
12
13/**
14 * HUD de KotlinAsteroids.
15 *
16 * Muestra puntuación y oleada actual mediante Stage/Label de Scene2D.
17 * Las vidas se dibujan como mini-naves con ShapeRenderer (más fiel al estilo retro).
18 *
19 * Stage usa ScreenViewport propio, independiente de la cámara del juego.
20 */
21class HUD(batch: SpriteBatch) : Disposable {
22
23    private val stage  = Stage(ScreenViewport(), batch)
24    private val fuente = BitmapFont().apply { data.setScale(1.3f) }
25    private val estilo = Label.LabelStyle(fuente, Color.WHITE)
26
27    private val labelPuntuacion = Label("0",      estilo)
28    private val labelOleada     = Label("Oleada 1", estilo)
29
30    init {
31        val tabla = Table().apply {
32            top().setFillParent(true)
33        }
34        tabla.add(labelPuntuacion).expandX().left().pad(12f)
35        tabla.add(labelOleada).expandX().center().pad(12f)
36        // La columna derecha queda libre para dibujar vidas con ShapeRenderer
37        tabla.add().expandX().right().pad(12f)
38        stage.addActor(tabla)
39    }
40
41    fun actualizar(puntuacion: Int, oleada: Int) {
42        labelPuntuacion.setText("$puntuacion")
43        labelOleada.setText("Oleada $oleada")
44    }
45
46    /**
47     * Dibuja el HUD: texto (Stage) + mini-naves de vidas (ShapeRenderer).
48     * Llamar DESPUÉS de dibujar el juego.
49     */
50    fun draw(shapeRenderer: ShapeRenderer, vidas: Int) {
51        // Dibujar mini-naves de vidas en la esquina superior derecha
52        val sw = stage.viewport.screenWidth.toFloat()
53        val sh = stage.viewport.screenHeight.toFloat()
54
55        shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
56        shapeRenderer.setColor(Color.WHITE)
57        for (i in 0 until vidas) {
58            val cx = sw - 28f - i * 28f   // posición en píxeles de pantalla
59            val cy = sh - 18f
60            // Mini-triángulo: nariz arriba
61            shapeRenderer.triangle(cx, cy + 12f, cx - 7f, cy - 5f, cx + 7f, cy - 5f)
62        }
63        shapeRenderer.end()
64
65        // Texto Stage
66        stage.act()
67        stage.draw()
68    }
69
70    fun resize(width: Int, height: Int) {
71        stage.viewport.update(width, height, true)
72    }
73
74    override fun dispose() { stage.dispose(); fuente.dispose() }
75}

10. GameScreen.kt#

La pantalla principal integra todos los sistemas. Este es el archivo más extenso del proyecto porque coordina la física, el renderizado, la entrada, la oleada y los eventos de colisión:

  1package com.dam.kotlinasteroids.screen
  2
  3import com.badlogic.gdx.Gdx
  4import com.badlogic.gdx.Input
  5import com.badlogic.gdx.audio.Music
  6import com.badlogic.gdx.audio.Sound
  7import com.badlogic.gdx.graphics.Color
  8import com.badlogic.gdx.graphics.g2d.BitmapFont
  9import com.badlogic.gdx.graphics.glutils.ShapeRenderer
 10import com.badlogic.gdx.math.MathUtils
 11import com.badlogic.gdx.math.Vector2
 12import com.badlogic.gdx.physics.box2d.Body
 13import com.badlogic.gdx.physics.box2d.Box2DDebugRenderer
 14import com.dam.kotlinasteroids.KotlinAsteroidsGame
 15import com.dam.kotlinasteroids.entity.Asteroide
 16import com.dam.kotlinasteroids.entity.Bala
 17import com.dam.kotlinasteroids.entity.Nave
 18import com.dam.kotlinasteroids.entity.TamanyoAsteroide
 19import com.dam.kotlinasteroids.particles.GestorParticulas
 20import com.dam.kotlinasteroids.ui.HUD
 21import com.dam.kotlinasteroids.world.GameEvent
 22import com.dam.kotlinasteroids.world.WorldManager
 23import ktx.app.KtxScreen
 24import ktx.app.clearScreen
 25import ktx.box2d.body
 26import ktx.box2d.circle
 27import ktx.graphics.use
 28
 29/**
 30 * Pantalla principal de KotlinAsteroids.
 31 *
 32 * Coordina:
 33 *  - WorldManager: física Box2D y detección de colisiones
 34 *  - Nave, Asteroides, Balas: entidades del juego
 35 *  - GestorParticulas: efectos visuales
 36 *  - HUD: información en pantalla
 37 *  - ShapeRenderer: renderizado vectorial retro de todas las entidades
 38 *  - Campo de estrellas procedural como fondo
 39 *  - Sistema de oleadas: genera nuevos asteroides al limpiar la oleada actual
 40 *  - Controles táctiles: 4 zonas en la parte inferior de la pantalla
 41 */
 42class GameScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
 43
 44    // ── Sistemas ──────────────────────────────────────────────────────────────
 45
 46    private val worldManager  = WorldManager()
 47    private val gestorPart    = GestorParticulas()
 48    private val hud           = HUD(game.batch)
 49    private val shapeRenderer = ShapeRenderer()
 50    private val fuente        = BitmapFont()
 51    private val debugRenderer = Box2DDebugRenderer()
 52    private var modoDebug     = false
 53
 54    // ── Audio ─────────────────────────────────────────────────────────────────
 55
 56    private val musica         = game.assets.get("audio/musica_fondo.ogg",    Music::class.java)
 57    private val sfxDisparo     = game.assets.get("audio/disparo.wav",          Sound::class.java)
 58    private val sfxExplosionG  = game.assets.get("audio/explosion_grande.wav", Sound::class.java)
 59    private val sfxExplosionP  = game.assets.get("audio/explosion_pequeña.wav", Sound::class.java)
 60    private val sfxMuerte      = game.assets.get("audio/muerte.wav",           Sound::class.java)
 61
 62    // ── Entidades ─────────────────────────────────────────────────────────────
 63
 64    private lateinit var nave: Nave
 65    private val asteroides  = mutableListOf<Asteroide>()
 66    private val balas       = mutableListOf<Bala>()
 67
 68    // ── Estado del juego ──────────────────────────────────────────────────────
 69
 70    private var tiempoTotal          = 0f     // para el parpadeo de invulnerabilidad
 71    private var tiempoRespawn        = 0f     // retardo antes de respawnear la nave
 72    private var naveDestruida        = false
 73    private var transicionGameOver   = false
 74
 75    // ── Fondo estrellado ──────────────────────────────────────────────────────
 76
 77    private data class Estrella(val x: Float, val y: Float, val b: Float, val tam: Float)
 78    private val estrellas = List(250) {
 79        Estrella(
 80            MathUtils.random(0f, KotlinAsteroidsGame.WORLD_WIDTH),
 81            MathUtils.random(0f, KotlinAsteroidsGame.WORLD_HEIGHT),
 82            MathUtils.random(0.15f, 1f),
 83            MathUtils.random(0.03f, 0.08f)
 84        )
 85    }
 86
 87    // ── Constantes de controles táctiles ─────────────────────────────────────
 88
 89    // Las cuatro zonas de botones ocupan el 25% inferior de la pantalla:
 90    // [0-25%] Rotar izq | [25-50%] Empuje | [50-75%] Rotar der | [75-100%] Disparar
 91    private val ZONA_CONTROLES_Y = 0.75f   // a partir del 75% de altura (medido desde arriba)
 92
 93    init {
 94        crearNave()
 95        iniciarOleada(game.oleadaActual)
 96    }
 97
 98    // ── Ciclo de vida ─────────────────────────────────────────────────────────
 99
100    override fun show() {
101        musica.isLooping = true
102        musica.volume    = 0.5f
103        musica.play()
104    }
105
106    override fun render(delta: Float) {
107        tiempoTotal += delta
108        if (!transicionGameOver) {
109            update(delta)
110        }
111        draw()
112    }
113
114    // ── Lógica de actualización ───────────────────────────────────────────────
115
116    private fun update(delta: Float) {
117        // 1. Si la nave está destruida, esperar el retardo antes de respawnear
118        if (naveDestruida) {
119            tiempoRespawn -= delta
120            if (tiempoRespawn <= 0f) respawnNave()
121            return
122        }
123
124        // 2. Leer entrada y aplicarla a la nave
125        val (rotDir, empujar, disparar) = leerEntrada()
126        nave.update(rotDir, empujar, delta)
127
128        // 3. Disparar si procede
129        if (disparar && nave.puedeDisparar()) {
130            crearBala()
131            sfxDisparo.play(0.7f)
132            nave.registrarDisparo()
133        }
134
135        // 4. Actualizar balas y retirar las caducadas
136        val balasAEliminar = mutableListOf<Bala>()
137        for (bala in balas) {
138            if (bala.update(delta)) balasAEliminar.add(bala)
139        }
140        balasAEliminar.forEach { eliminarBala(it) }
141
142        // 5. Avanzar la simulación de física + wrapping
143        val todosLosBodies = mutableListOf<Body>(nave.body)
144        asteroides.forEach { todosLosBodies.add(it.body) }
145        balas.forEach      { todosLosBodies.add(it.body) }
146        worldManager.step(delta, todosLosBodies)
147
148        // 6. Procesar eventos de colisión generados durante step()
149        procesarEventos()
150
151        // 7. Actualizar partículas del motor
152        gestorPart.actualizarMotor(nave.motorPos, empujar && !naveDestruida)
153
154        // 8. Comprobar si la oleada está completa
155        if (asteroides.isEmpty() && balas.isEmpty()) {
156            game.oleadaActual++
157            iniciarOleada(game.oleadaActual)
158        }
159
160        // 9. Actualizar HUD
161        hud.actualizar(game.puntuacion, game.oleadaActual)
162    }
163
164    // ── Entrada del usuario ───────────────────────────────────────────────────
165
166    /**
167     * Lee todos los puntos de toque activos y determina qué controles están activos.
168     *
169     * Distribución táctil de la zona inferior (25% inferior de pantalla):
170     * - x < 25%: Rotar izquierda
171     * - 25% ≤ x < 50%: Empuje
172     * - 50% ≤ x < 75%: Rotar derecha
173     * - x ≥ 75%: Disparar
174     *
175     * Se comprueban hasta 5 puntos de toque simultáneos para permitir
176     * accionar varios controles a la vez (mover + disparar, etc.).
177     */
178    private fun leerEntrada(): Triple<Float, Boolean, Boolean> {
179        var rotDir   = 0f
180        var empujar  = false
181        var disparar = false
182
183        val sw = Gdx.graphics.width.toFloat()
184        val sh = Gdx.graphics.height.toFloat()
185
186        for (pointer in 0..4) {
187            if (!Gdx.input.isTouched(pointer)) continue
188
189            val tx = Gdx.input.getX(pointer).toFloat()
190            val ty = Gdx.input.getY(pointer).toFloat()
191
192            // Solo procesar toques en la zona de controles (parte inferior)
193            if (ty < sh * ZONA_CONTROLES_Y) continue
194
195            when {
196                tx < sw * 0.25f -> rotDir   = -1f  // rotar en sentido horario
197                tx < sw * 0.50f -> empujar  = true
198                tx < sw * 0.75f -> rotDir   =  1f  // rotar en sentido antihorario
199                else            -> disparar = true
200            }
201        }
202
203        // Teclado para pruebas en Desktop
204        if (Gdx.input.isKeyPressed(Input.Keys.LEFT)  || Gdx.input.isKeyPressed(Input.Keys.A)) rotDir   = -1f
205        if (Gdx.input.isKeyPressed(Input.Keys.RIGHT) || Gdx.input.isKeyPressed(Input.Keys.D)) rotDir   =  1f
206        if (Gdx.input.isKeyPressed(Input.Keys.UP)    || Gdx.input.isKeyPressed(Input.Keys.W)) empujar  = true
207        if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) disparar = true
208        if (Gdx.input.isKeyJustPressed(Input.Keys.F1))   modoDebug = !modoDebug
209
210        return Triple(rotDir, empujar, disparar)
211    }
212
213    // ── Procesado de eventos de colisión ──────────────────────────────────────
214
215    /**
216     * Procesa la lista de GameEvent generados por el ContactListener.
217     * Aquí sí podemos crear/destruir bodies porque estamos FUERA de world.step().
218     */
219    private fun procesarEventos() {
220        for (evento in worldManager.eventos) {
221            when (evento) {
222
223                is GameEvent.AsteroidImpactado -> {
224                    // Marcar bala como destruida (puede haber llegado el mismo evento dos veces)
225                    val bala = balas.firstOrNull { it.body == evento.bodyBala } ?: continue
226                    if (bala.destruida) continue
227                    bala.destruida = true
228
229                    // Destruir bodies (encolar para ejecutar post-step)
230                    worldManager.encolarDestruccion(evento.bodyBala)
231                    worldManager.encolarDestruccion(evento.bodyAsteroide)
232
233                    // Quitar de las listas de entidades
234                    balas.removeAll      { it.body == evento.bodyBala }
235                    asteroides.removeAll { it.body == evento.bodyAsteroide }
236
237                    // Puntuación
238                    game.puntuacion += evento.tamaño.puntos
239
240                    // Efectos audiovisuales
241                    gestorPart.emitirExplosion(evento.posicion)
242                    if (evento.tamaño == TamanyoAsteroide.GRANDE) sfxExplosionG.play(0.9f)
243                    else sfxExplosionP.play(0.8f)
244
245                    // Fragmentación: crear hijos si el tamaño lo permite
246                    evento.tamaño.hijos?.let { tamañoHijo ->
247                        repeat(2) {
248                            val anguloHijo = MathUtils.random(MathUtils.PI2)
249                            val velHijo    = Vector2(
250                                MathUtils.cos(anguloHijo) * MathUtils.random(1.5f, 3.5f),
251                                MathUtils.sin(anguloHijo) * MathUtils.random(1.5f, 3.5f)
252                            )
253                            asteroides.add(Asteroide(worldManager.world, evento.posicion.cpy(), velHijo, tamañoHijo))
254                        }
255                    }
256                }
257
258                is GameEvent.NaveImpactada -> {
259                    if (!nave.invulnerable) {
260                        procesarMuerteNave()
261                    }
262                }
263            }
264        }
265    }
266
267    // ── Gestión de vidas y respawn ────────────────────────────────────────────
268
269    private fun procesarMuerteNave() {
270        sfxMuerte.play()
271        game.vidasRestantes--
272        gestorPart.emitirExplosion(nave.body.position.cpy())
273
274        if (game.vidasRestantes > 0) {
275            // Destruir el body actual y programar respawn
276            worldManager.encolarDestruccion(nave.body)
277            naveDestruida = true
278            tiempoRespawn = 2.0f   // 2 segundos de pausa antes de respawnear
279        } else {
280            game.actualizarRecord()
281            transicionGameOver = true
282            musica.stop()
283            // Pequeño retardo para que se vea la explosión antes de navegar
284            worldManager.encolarDestruccion(nave.body)
285            naveDestruida = true
286            tiempoRespawn = 1.5f
287        }
288    }
289
290    private fun respawnNave() {
291        naveDestruida = false
292        if (transicionGameOver) {
293            game.addScreen(GameOverScreen(game))
294            game.setScreen<GameOverScreen>()
295        } else {
296            crearNave()   // nueva nave en el centro con invulnerabilidad
297        }
298    }
299
300    // ── Helpers de creación de entidades ─────────────────────────────────────
301
302    private fun crearNave() {
303        nave = Nave(
304            worldManager.world,
305            KotlinAsteroidsGame.WORLD_WIDTH  / 2f,
306            KotlinAsteroidsGame.WORLD_HEIGHT / 2f
307        )
308    }
309
310    private fun crearBala() {
311        val velocidadBala = Vector2(
312            MathUtils.cos(nave.body.angle) * Bala.VELOCIDAD,
313            MathUtils.sin(nave.body.angle) * Bala.VELOCIDAD
314        )
315        // Añadir la velocidad de la nave para que la bala no quede rezagada
316        velocidadBala.add(nave.body.linearVelocity)
317
318        balas.add(Bala(worldManager.world, nave.puntaNave.cpy(), velocidadBala))
319    }
320
321    private fun eliminarBala(bala: Bala) {
322        worldManager.encolarDestruccion(bala.body)
323        balas.remove(bala)
324    }
325
326    /**
327     * Genera los asteroides iniciales de una oleada.
328     * La oleada N contiene 2+N asteroides grandes, apareciendo en los bordes.
329     * Se evita que spawnen cerca del centro (donde está la nave).
330     */
331    private fun iniciarOleada(oleada: Int) {
332        val numAsteroides = 2 + oleada
333        val factorVelocidad = 1f + oleada * 0.15f   // cada oleada un 15% más rápido
334
335        repeat(numAsteroides) {
336            // Spawnear en un borde aleatorio
337            val pos = spawnarEnBorde()
338            val angulo = MathUtils.random(MathUtils.PI2)
339            val vel = Vector2(
340                MathUtils.cos(angulo) * MathUtils.random(1.0f, 2.5f) * factorVelocidad,
341                MathUtils.sin(angulo) * MathUtils.random(1.0f, 2.5f) * factorVelocidad
342            )
343            asteroides.add(Asteroide(worldManager.world, pos, vel, TamanyoAsteroide.GRANDE))
344        }
345    }
346
347    /** Genera una posición aleatoria en uno de los cuatro bordes del mundo. */
348    private fun spawnarEnBorde(): Vector2 {
349        val W = KotlinAsteroidsGame.WORLD_WIDTH
350        val H = KotlinAsteroidsGame.WORLD_HEIGHT
351        return when (MathUtils.random(3)) {
352            0    -> Vector2(MathUtils.random(W), -1f)           // borde inferior
353            1    -> Vector2(MathUtils.random(W), H + 1f)        // borde superior
354            2    -> Vector2(-1f, MathUtils.random(H))           // borde izquierdo
355            else -> Vector2(W + 1f, MathUtils.random(H))        // borde derecho
356        }
357    }
358
359    // ── Renderizado ───────────────────────────────────────────────────────────
360
361    private fun draw() {
362        clearScreen(0f, 0f, 0f)   // fondo negro
363        game.viewport.apply()
364
365        shapeRenderer.projectionMatrix = game.viewport.camera.combined
366
367        // 1. Campo de estrellas (fondo, sin transformación)
368        shapeRenderer.begin(ShapeRenderer.ShapeType.Filled)
369        for (e in estrellas) {
370            shapeRenderer.setColor(e.b, e.b, e.b * 0.9f, 1f)
371            shapeRenderer.rect(e.x, e.y, e.tam, e.tam)
372        }
373        shapeRenderer.end()
374
375        // 2. Entidades del juego (líneas vectoriales)
376        shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
377        // Asteroides
378        for (asteroide in asteroides) asteroide.draw(shapeRenderer)
379        // Balas
380        for (bala in balas) bala.draw(shapeRenderer)
381        shapeRenderer.end()
382
383        // 3. Nave (triángulo relleno, tratamiento especial por el parpadeo)
384        shapeRenderer.begin(ShapeRenderer.ShapeType.Filled)
385        if (!naveDestruida) nave.draw(shapeRenderer, tiempoTotal)
386        shapeRenderer.end()
387
388        // 4. Partículas (dentro de batch.use)
389        game.batch.projectionMatrix = game.viewport.camera.combined
390        game.batch.use { batch ->
391            gestorPart.render(batch, Gdx.graphics.deltaTime)
392        }
393
394        // 5. Controles táctiles en pantalla (en coordenadas de pantalla)
395        dibujarControles()
396
397        // 6. Box2D debug (opcional, activar con F1)
398        if (modoDebug) {
399            debugRenderer.render(worldManager.world, game.viewport.camera.combined)
400        }
401
402        // 7. HUD (siempre encima de todo)
403        hud.draw(shapeRenderer, game.vidasRestantes)
404    }
405
406    /**
407     * Dibuja los 4 botones táctiles en la parte inferior de la pantalla.
408     * Usa coordenadas de pantalla (sin proyección de cámara).
409     */
410    private fun dibujarControles() {
411        val sw = Gdx.graphics.width.toFloat()
412        val sh = Gdx.graphics.height.toFloat()
413        val radio = sh * 0.08f
414        val cy    = sh * 0.12f
415
416        // Proyección ortográfica de pantalla sin transformación de cámara
417        shapeRenderer.projectionMatrix = shapeRenderer.projectionMatrix.also {
418            // Usar proyección simple de pantalla
419        }
420
421        // Reutilizamos ShapeRenderer con proyección de pantalla
422        val ortho = com.badlogic.gdx.math.Matrix4().setToOrtho2D(0f, 0f, sw, sh)
423        shapeRenderer.projectionMatrix = ortho
424
425        shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
426        shapeRenderer.setColor(0.5f, 0.5f, 0.5f, 0.6f)
427        shapeRenderer.circle(sw * 0.125f, cy, radio, 20)   // Rotar izquierda
428        shapeRenderer.circle(sw * 0.375f, cy, radio, 20)   // Empuje
429        shapeRenderer.circle(sw * 0.625f, cy, radio, 20)   // Rotar derecha
430        shapeRenderer.circle(sw * 0.875f, cy, radio, 20)   // Disparar
431        shapeRenderer.end()
432
433        // Etiquetas de botones
434        game.batch.projectionMatrix = ortho
435        game.batch.use {
436            fuente.color = Color.DARK_GRAY
437            fuente.data.setScale(0.7f)
438            fuente.draw(it, "◄",  sw * 0.115f, cy + radio * 0.4f)
439            fuente.draw(it, "Δ",  sw * 0.365f, cy + radio * 0.4f)
440            fuente.draw(it, "►",  sw * 0.615f, cy + radio * 0.4f)
441            fuente.draw(it, "●",  sw * 0.865f, cy + radio * 0.4f)
442        }
443
444        // Restaurar proyección de cámara del juego
445        game.batch.projectionMatrix = game.viewport.camera.combined
446        shapeRenderer.projectionMatrix = game.viewport.camera.combined
447    }
448
449    // ── Resize / Pause / Resume / Dispose ────────────────────────────────────
450
451    override fun resize(width: Int, height: Int) {
452        game.viewport.update(width, height, true)
453        hud.resize(width, height)
454    }
455
456    override fun pause()  { musica.pause() }
457    override fun resume() { if (!musica.isPlaying) musica.play() }
458
459    override fun dispose() {
460        worldManager.dispose()
461        gestorPart.dispose()
462        hud.dispose()
463        shapeRenderer.dispose()
464        fuente.dispose()
465        debugRenderer.dispose()
466        // No se hace dispose() de musica/sfx: son gestionados por AssetManager del juego
467    }
468}

11. 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.badlogic.gdx.graphics.glutils.ShapeRenderer
 7import com.dam.kotlinasteroids.KotlinAsteroidsGame
 8import ktx.app.KtxScreen
 9import ktx.app.clearScreen
10import ktx.graphics.use
11
12/**
13 * Pantalla de fin de juego.
14 * Muestra la puntuación obtenida, el récord histórico y permite reiniciar.
15 */
16class GameOverScreen(private val game: KotlinAsteroidsGame) : KtxScreen {
17
18    private val shapeR = ShapeRenderer()
19    private val fuente = BitmapFont()
20    private var delay  = 1.0f   // retardo anti-toque accidental
21
22    override fun render(delta: Float) {
23        delay -= delta
24        clearScreen(0f, 0f, 0f)
25
26        game.viewport.apply()
27        game.batch.projectionMatrix = game.viewport.camera.combined
28
29        game.batch.use {
30            fuente.color = Color.RED
31            fuente.data.setScale(1.6f)
32            fuente.draw(it, "GAME OVER", 7f, 11f)
33
34            fuente.color = Color.WHITE
35            fuente.data.setScale(1.1f)
36            fuente.draw(it, "Puntuacion:  ${game.puntuacion}",      7f, 9f)
37            fuente.draw(it, "Record:      ${game.recordPuntuacion}", 7f, 8f)
38            fuente.draw(it, "Oleada alcanzada: ${game.oleadaActual - 1}", 7f, 7f)
39
40            fuente.color = Color.GRAY
41            fuente.data.setScale(0.9f)
42            fuente.draw(it, "Toca para volver al menu", 7.5f, 4f)
43        }
44
45        if (delay <= 0f && Gdx.input.justTouched()) {
46            game.removeScreen<GameScreen>()
47            game.removeScreen<GameOverScreen>()
48            game.reiniciarPartida()
49            game.addScreen(MenuScreen(game))
50            game.setScreen<MenuScreen>()
51        }
52    }
53
54    override fun resize(w: Int, h: Int) { game.viewport.update(w, h, true) }
55    override fun dispose() { shapeR.dispose(); fuente.dispose() }
56}

12. Launcher Android#

 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
 8class AndroidLauncher : AndroidApplication() {
 9
10    override fun onCreate(savedInstanceState: Bundle?) {
11        super.onCreate(savedInstanceState)
12        val config = AndroidApplicationConfiguration().apply {
13            useAccelerometer = false
14            useCompass       = false
15            useGyroscope     = false
16            useWakelock      = true          // evitar apagado de pantalla durante el juego
17            useImmersiveMode = true          // pantalla completa sin barra de navegación
18        }
19        initialize(KotlinAsteroidsGame(), config)
20    }
21}

AndroidManifest.xml — orientación landscape para Asteroids:

 1<activity
 2    android:name=".android.AndroidLauncher"
 3    android:screenOrientation="landscape"
 4    android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"
 5    android:exported="true">
 6    <intent-filter>
 7        <action android:name="android.intent.action.MAIN" />
 8        <category android:name="android.intent.category.LAUNCHER" />
 9    </intent-filter>
10</activity>

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