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
ShapeRendererpara 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ón2.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ónPara 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á dormidoLa 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, vidas3.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.ttf4. 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>