Tema 10: KotlinAsteroids — Juego de asteroides 2D completo
- Exposición aproximada
- 4 h
- Aplicación de referencia
- KotlinAsteroids · Juego de asteroides 2D completo
- Resultados de aprendizaje
- RA4RA5
- 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.
Criterios de evaluación que se trabajan (12)
| 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. |
Información
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.
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) |
Nota
¿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.
Nota
¿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:
edu.apellido1Nombre.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#
Ver la estructura de archivos
core/src/main/kotlin/edu/apellido1Nombre/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#
Ver la estructura de assets
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#
Ver KotlinAsteroidsGame.kt
1package edu.apellido1Nombre.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 edu.apellido1Nombre.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#
Ver LoadingScreen.kt
1package edu.apellido1Nombre.kotlinasteroids.screen
2
3import com.badlogic.gdx.Gdx
4import com.badlogic.gdx.graphics.glutils.ShapeRenderer
5import edu.apellido1Nombre.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#
Ver MenuScreen.kt
1package edu.apellido1Nombre.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 edu.apellido1Nombre.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 edu.apellido1Nombre.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#
Ver Nave.kt
1package edu.apellido1Nombre.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#
Ver Asteroide.kt
1package edu.apellido1Nombre.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#
Ver Bala.kt
1package edu.apellido1Nombre.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#
Ver WorldManager.kt
1package edu.apellido1Nombre.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 edu.apellido1Nombre.kotlinasteroids.KotlinAsteroidsGame
8import edu.apellido1Nombre.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 // Ejecutar PRIMERO las acciones diferidas (destrucción de bodies) encoladas
73 // por GameScreen.procesarEventos() en el fotograma anterior. Así esos bodies
74 // ya no participan en la simulación de este paso ni generan nuevos eventos,
75 // y un mismo body no puede encolarse de nuevo tras vaciarse el Set.
76 accionesDiferidas.forEach { it() }
77 accionesDiferidas.clear()
78 bodiesToDestroy.clear()
79
80 eventos.clear()
81
82 accumulator += delta.coerceAtMost(0.25f)
83 while (accumulator >= TIME_STEP) {
84 world.step(TIME_STEP, VELOCITY_ITER, POSITION_ITER)
85 accumulator -= TIME_STEP
86 }
87
88 // Aplicar wrapping DESPUÉS del paso físico
89 allBodies.forEach { wrapBody(it) }
90 }
91
92 /**
93 * Encola la destrucción de un body; se ejecuta al inicio del siguiente step(),
94 * siempre fuera de world.step().
95 * El set evita que el mismo body se destruya dos veces.
96 */
97 fun encolarDestruccion(body: Body) {
98 if (bodiesToDestroy.add(body)) {
99 accionesDiferidas.add { world.destroyBody(body) }
100 }
101 }
102
103 // ── Wrapping de pantalla ──────────────────────────────────────────────────
104
105 private val MARGEN = 1.6f
106
107 private fun wrapBody(body: Body) {
108 var x = body.position.x
109 var y = body.position.y
110 var cambio = false
111
112 if (x < -MARGEN) { x = KotlinAsteroidsGame.WORLD_WIDTH + MARGEN; cambio = true }
113 else if (x > KotlinAsteroidsGame.WORLD_WIDTH + MARGEN) { x = -MARGEN; cambio = true }
114 if (y < -MARGEN) { y = KotlinAsteroidsGame.WORLD_HEIGHT + MARGEN; cambio = true }
115 else if (y > KotlinAsteroidsGame.WORLD_HEIGHT + MARGEN){ y = -MARGEN; cambio = true }
116
117 if (cambio) body.setTransform(x, y, body.angle)
118 }
119
120 // ── ContactListener ───────────────────────────────────────────────────────
121
122 private fun crearContactListener() = object : ContactListener {
123
124 override fun beginContact(contact: Contact) {
125 val userA = contact.fixtureA.userData as? String ?: return
126 val userB = contact.fixtureB.userData as? String
127
128 // Identificar bala y asteroide (en cualquier orden de fixtures)
129 val (fixtBala, fixtAst) = when {
130 userA == "bala" && contact.fixtureB.userData is TamanyoAsteroide ->
131 Pair(contact.fixtureA, contact.fixtureB)
132 userB == "bala" && contact.fixtureA.userData is TamanyoAsteroide ->
133 Pair(contact.fixtureB, contact.fixtureA)
134 else -> return
135 }
136
137 val tamaño = fixtAst.userData as TamanyoAsteroide
138
139 // ¡NUNCA modificar el world aquí! Solo generamos un evento.
140 eventos.add(
141 GameEvent.AsteroidImpactado(
142 bodyAsteroide = fixtAst.body,
143 bodyBala = fixtBala.body,
144 tamaño = tamaño,
145 posicion = fixtAst.body.position.cpy(),
146 velocidadBase = fixtAst.body.linearVelocity.cpy()
147 )
148 )
149 }
150
151 override fun endContact(contact: Contact) {
152 val userA = contact.fixtureA.userData as? String ?: return
153 val userB = contact.fixtureB.userData as? String
154
155 // Nave impacta con asteroide
156 val naveImpactada = (userA == "nave" && contact.fixtureB.userData is TamanyoAsteroide) ||
157 (userB == "nave" && contact.fixtureA.userData is TamanyoAsteroide)
158 if (naveImpactada) {
159 // endContact para nave: no la usamos aquí,
160 // la colisión real se detecta en beginContact
161 }
162 }
163
164 override fun preSolve(contact: Contact, oldManifold: Manifold) {
165 // Detectar nave vs asteroide AQUÍ (preSolve se llama antes de la respuesta física)
166 val userA = contact.fixtureA.userData as? String
167 val userB = contact.fixtureB.userData as? String
168 val naveChoca = (userA == "nave" && contact.fixtureB.userData is TamanyoAsteroide) ||
169 (userB == "nave" && contact.fixtureA.userData is TamanyoAsteroide)
170 if (naveChoca) {
171 eventos.add(GameEvent.NaveImpactada)
172 }
173 }
174
175 override fun postSolve(contact: Contact, impulse: ContactImpulse) {}
176 }
177
178 override fun dispose() { world.dispose() }
179}Vídeo Detección de contactos y eventos del juego Descargar
8. GestorParticulas.kt#
Ver GestorParticulas.kt
1package edu.apellido1Nombre.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#
Ver HUD.kt
1package edu.apellido1Nombre.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:
Ver GameScreen.kt
1package edu.apellido1Nombre.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 edu.apellido1Nombre.kotlinasteroids.KotlinAsteroidsGame
15import edu.apellido1Nombre.kotlinasteroids.entity.Asteroide
16import edu.apellido1Nombre.kotlinasteroids.entity.Bala
17import edu.apellido1Nombre.kotlinasteroids.entity.Nave
18import edu.apellido1Nombre.kotlinasteroids.entity.TamanyoAsteroide
19import edu.apellido1Nombre.kotlinasteroids.particles.GestorParticulas
20import edu.apellido1Nombre.kotlinasteroids.ui.HUD
21import edu.apellido1Nombre.kotlinasteroids.world.GameEvent
22import edu.apellido1Nombre.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 // Tras perder la última vida (transicionGameOver) se sigue llamando a update()
109 // mientras la nave esté destruida: así avanza tiempoRespawn y respawnNave()
110 // puede navegar a GameOverScreen.
111 if (!transicionGameOver || naveDestruida) {
112 update(delta)
113 }
114 draw()
115 }
116
117 // ── Lógica de actualización ───────────────────────────────────────────────
118
119 private fun update(delta: Float) {
120 // 1. Si la nave está destruida, esperar el retardo antes de respawnear
121 if (naveDestruida) {
122 tiempoRespawn -= delta
123 if (tiempoRespawn <= 0f) respawnNave()
124 return
125 }
126
127 // 2. Leer entrada y aplicarla a la nave
128 val (rotDir, empujar, disparar) = leerEntrada()
129 nave.update(rotDir, empujar, delta)
130
131 // 3. Disparar si procede
132 if (disparar && nave.puedeDisparar()) {
133 crearBala()
134 sfxDisparo.play(0.7f)
135 nave.registrarDisparo()
136 }
137
138 // 4. Actualizar balas y retirar las caducadas
139 val balasAEliminar = mutableListOf<Bala>()
140 for (bala in balas) {
141 if (bala.update(delta)) balasAEliminar.add(bala)
142 }
143 balasAEliminar.forEach { eliminarBala(it) }
144
145 // 5. Avanzar la simulación de física + wrapping
146 val todosLosBodies = mutableListOf<Body>(nave.body)
147 asteroides.forEach { todosLosBodies.add(it.body) }
148 balas.forEach { todosLosBodies.add(it.body) }
149 worldManager.step(delta, todosLosBodies)
150
151 // 6. Procesar eventos de colisión generados durante step()
152 procesarEventos()
153
154 // 7. Actualizar partículas del motor
155 gestorPart.actualizarMotor(nave.motorPos, empujar && !naveDestruida)
156
157 // 8. Comprobar si la oleada está completa
158 if (asteroides.isEmpty() && balas.isEmpty()) {
159 game.oleadaActual++
160 iniciarOleada(game.oleadaActual)
161 }
162
163 // 9. Actualizar HUD
164 hud.actualizar(game.puntuacion, game.oleadaActual)
165 }
166
167 // ── Entrada del usuario ───────────────────────────────────────────────────
168
169 /**
170 * Lee todos los puntos de toque activos y determina qué controles están activos.
171 *
172 * Distribución táctil de la zona inferior (25% inferior de pantalla):
173 * - x < 25%: Rotar izquierda
174 * - 25% ≤ x < 50%: Empuje
175 * - 50% ≤ x < 75%: Rotar derecha
176 * - x ≥ 75%: Disparar
177 *
178 * Se comprueban hasta 5 puntos de toque simultáneos para permitir
179 * accionar varios controles a la vez (mover + disparar, etc.).
180 */
181 private fun leerEntrada(): Triple<Float, Boolean, Boolean> {
182 var rotDir = 0f
183 var empujar = false
184 var disparar = false
185
186 val sw = Gdx.graphics.width.toFloat()
187 val sh = Gdx.graphics.height.toFloat()
188
189 for (pointer in 0..4) {
190 if (!Gdx.input.isTouched(pointer)) continue
191
192 val tx = Gdx.input.getX(pointer).toFloat()
193 val ty = Gdx.input.getY(pointer).toFloat()
194
195 // Solo procesar toques en la zona de controles (parte inferior)
196 if (ty < sh * ZONA_CONTROLES_Y) continue
197
198 when {
199 tx < sw * 0.25f -> rotDir = -1f // rotar en sentido horario
200 tx < sw * 0.50f -> empujar = true
201 tx < sw * 0.75f -> rotDir = 1f // rotar en sentido antihorario
202 else -> disparar = true
203 }
204 }
205
206 // Teclado para pruebas en Desktop
207 if (Gdx.input.isKeyPressed(Input.Keys.LEFT) || Gdx.input.isKeyPressed(Input.Keys.A)) rotDir = -1f
208 if (Gdx.input.isKeyPressed(Input.Keys.RIGHT) || Gdx.input.isKeyPressed(Input.Keys.D)) rotDir = 1f
209 if (Gdx.input.isKeyPressed(Input.Keys.UP) || Gdx.input.isKeyPressed(Input.Keys.W)) empujar = true
210 if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE)) disparar = true
211 if (Gdx.input.isKeyJustPressed(Input.Keys.F1)) modoDebug = !modoDebug
212
213 return Triple(rotDir, empujar, disparar)
214 }
215
216 // ── Procesado de eventos de colisión ──────────────────────────────────────
217
218 /**
219 * Procesa la lista de GameEvent generados por el ContactListener.
220 * Aquí sí podemos crear/destruir bodies porque estamos FUERA de world.step().
221 */
222 private fun procesarEventos() {
223 for (evento in worldManager.eventos) {
224 when (evento) {
225
226 is GameEvent.AsteroidImpactado -> {
227 // Marcar bala como destruida (puede haber llegado el mismo evento dos veces)
228 val bala = balas.firstOrNull { it.body == evento.bodyBala } ?: continue
229 if (bala.destruida) continue
230 bala.destruida = true
231
232 // Destruir la bala (encolar para ejecutar en el siguiente step)
233 worldManager.encolarDestruccion(evento.bodyBala)
234 balas.removeAll { it.body == evento.bodyBala }
235
236 // Si otra bala ya ha destruido este asteroide en el mismo paso,
237 // no se vuelve a puntuar ni a fragmentar
238 if (asteroides.none { it.body == evento.bodyAsteroide }) continue
239
240 worldManager.encolarDestruccion(evento.bodyAsteroide)
241 asteroides.removeAll { it.body == evento.bodyAsteroide }
242
243 // Puntuación
244 game.puntuacion += evento.tamaño.puntos
245
246 // Efectos audiovisuales
247 gestorPart.emitirExplosion(evento.posicion)
248 if (evento.tamaño == TamanyoAsteroide.GRANDE) sfxExplosionG.play(0.9f)
249 else sfxExplosionP.play(0.8f)
250
251 // Fragmentación: crear hijos si el tamaño lo permite
252 evento.tamaño.hijos?.let { tamañoHijo ->
253 repeat(2) {
254 val anguloHijo = MathUtils.random(MathUtils.PI2)
255 val velHijo = Vector2(
256 MathUtils.cos(anguloHijo) * MathUtils.random(1.5f, 3.5f),
257 MathUtils.sin(anguloHijo) * MathUtils.random(1.5f, 3.5f)
258 )
259 asteroides.add(Asteroide(worldManager.world, evento.posicion.cpy(), velHijo, tamañoHijo))
260 }
261 }
262 }
263
264 is GameEvent.NaveImpactada -> {
265 // preSolve() puede generar varios NaveImpactada en el mismo fotograma
266 // (un evento por subpaso y por contacto): solo cuenta el primero
267 if (!nave.invulnerable && !naveDestruida) {
268 procesarMuerteNave()
269 }
270 }
271 }
272 }
273 }
274
275 // ── Gestión de vidas y respawn ────────────────────────────────────────────
276
277 private fun procesarMuerteNave() {
278 sfxMuerte.play()
279 game.vidasRestantes--
280 gestorPart.emitirExplosion(nave.body.position.cpy())
281
282 if (game.vidasRestantes > 0) {
283 // Destruir el body actual y programar respawn
284 worldManager.encolarDestruccion(nave.body)
285 naveDestruida = true
286 tiempoRespawn = 2.0f // 2 segundos de pausa antes de respawnear
287 } else {
288 game.actualizarRecord()
289 transicionGameOver = true
290 musica.stop()
291 // Pequeño retardo para que se vea la explosión antes de navegar
292 worldManager.encolarDestruccion(nave.body)
293 naveDestruida = true
294 tiempoRespawn = 1.5f
295 }
296 }
297
298 private fun respawnNave() {
299 naveDestruida = false
300 if (transicionGameOver) {
301 game.addScreen(GameOverScreen(game))
302 game.setScreen<GameOverScreen>()
303 } else {
304 crearNave() // nueva nave en el centro con invulnerabilidad
305 }
306 }
307
308 // ── Helpers de creación de entidades ─────────────────────────────────────
309
310 private fun crearNave() {
311 nave = Nave(
312 worldManager.world,
313 KotlinAsteroidsGame.WORLD_WIDTH / 2f,
314 KotlinAsteroidsGame.WORLD_HEIGHT / 2f
315 )
316 }
317
318 private fun crearBala() {
319 val velocidadBala = Vector2(
320 MathUtils.cos(nave.body.angle) * Bala.VELOCIDAD,
321 MathUtils.sin(nave.body.angle) * Bala.VELOCIDAD
322 )
323 // Añadir la velocidad de la nave para que la bala no quede rezagada
324 velocidadBala.add(nave.body.linearVelocity)
325
326 balas.add(Bala(worldManager.world, nave.puntaNave.cpy(), velocidadBala))
327 }
328
329 private fun eliminarBala(bala: Bala) {
330 worldManager.encolarDestruccion(bala.body)
331 balas.remove(bala)
332 }
333
334 /**
335 * Genera los asteroides iniciales de una oleada.
336 * La oleada N contiene 2+N asteroides grandes, apareciendo en los bordes.
337 * Se evita que spawnen cerca del centro (donde está la nave).
338 */
339 private fun iniciarOleada(oleada: Int) {
340 val numAsteroides = 2 + oleada
341 val factorVelocidad = 1f + oleada * 0.15f // cada oleada un 15% más rápido
342
343 repeat(numAsteroides) {
344 // Spawnear en un borde aleatorio
345 val pos = spawnarEnBorde()
346 val angulo = MathUtils.random(MathUtils.PI2)
347 val vel = Vector2(
348 MathUtils.cos(angulo) * MathUtils.random(1.0f, 2.5f) * factorVelocidad,
349 MathUtils.sin(angulo) * MathUtils.random(1.0f, 2.5f) * factorVelocidad
350 )
351 asteroides.add(Asteroide(worldManager.world, pos, vel, TamanyoAsteroide.GRANDE))
352 }
353 }
354
355 /** Genera una posición aleatoria en uno de los cuatro bordes del mundo. */
356 private fun spawnarEnBorde(): Vector2 {
357 val W = KotlinAsteroidsGame.WORLD_WIDTH
358 val H = KotlinAsteroidsGame.WORLD_HEIGHT
359 return when (MathUtils.random(3)) {
360 0 -> Vector2(MathUtils.random(W), -1f) // borde inferior
361 1 -> Vector2(MathUtils.random(W), H + 1f) // borde superior
362 2 -> Vector2(-1f, MathUtils.random(H)) // borde izquierdo
363 else -> Vector2(W + 1f, MathUtils.random(H)) // borde derecho
364 }
365 }
366
367 // ── Renderizado ───────────────────────────────────────────────────────────
368
369 private fun draw() {
370 clearScreen(0f, 0f, 0f) // fondo negro
371 game.viewport.apply()
372
373 shapeRenderer.projectionMatrix = game.viewport.camera.combined
374
375 // 1. Campo de estrellas (fondo, sin transformación)
376 shapeRenderer.begin(ShapeRenderer.ShapeType.Filled)
377 for (e in estrellas) {
378 shapeRenderer.setColor(e.b, e.b, e.b * 0.9f, 1f)
379 shapeRenderer.rect(e.x, e.y, e.tam, e.tam)
380 }
381 shapeRenderer.end()
382
383 // 2. Entidades del juego (líneas vectoriales)
384 shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
385 // Asteroides
386 for (asteroide in asteroides) asteroide.draw(shapeRenderer)
387 // Balas
388 for (bala in balas) bala.draw(shapeRenderer)
389 shapeRenderer.end()
390
391 // 3. Nave (triángulo relleno, tratamiento especial por el parpadeo)
392 shapeRenderer.begin(ShapeRenderer.ShapeType.Filled)
393 if (!naveDestruida) nave.draw(shapeRenderer, tiempoTotal)
394 shapeRenderer.end()
395
396 // 4. Partículas (dentro de batch.use)
397 game.batch.projectionMatrix = game.viewport.camera.combined
398 game.batch.use { batch ->
399 gestorPart.render(batch, Gdx.graphics.deltaTime)
400 }
401
402 // 5. Controles táctiles en pantalla (en coordenadas de pantalla)
403 dibujarControles()
404
405 // 6. Box2D debug (opcional, activar con F1)
406 if (modoDebug) {
407 debugRenderer.render(worldManager.world, game.viewport.camera.combined)
408 }
409
410 // 7. HUD (siempre encima de todo)
411 hud.draw(shapeRenderer, game.vidasRestantes)
412 }
413
414 /**
415 * Dibuja los 4 botones táctiles en la parte inferior de la pantalla.
416 * Usa coordenadas de pantalla (sin proyección de cámara).
417 */
418 private fun dibujarControles() {
419 val sw = Gdx.graphics.width.toFloat()
420 val sh = Gdx.graphics.height.toFloat()
421 val radio = sh * 0.08f
422 val cy = sh * 0.12f
423
424 // Proyección ortográfica de pantalla sin transformación de cámara
425 shapeRenderer.projectionMatrix = shapeRenderer.projectionMatrix.also {
426 // Usar proyección simple de pantalla
427 }
428
429 // Reutilizamos ShapeRenderer con proyección de pantalla
430 val ortho = com.badlogic.gdx.math.Matrix4().setToOrtho2D(0f, 0f, sw, sh)
431 shapeRenderer.projectionMatrix = ortho
432
433 shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
434 shapeRenderer.setColor(0.5f, 0.5f, 0.5f, 0.6f)
435 shapeRenderer.circle(sw * 0.125f, cy, radio, 20) // Rotar izquierda
436 shapeRenderer.circle(sw * 0.375f, cy, radio, 20) // Empuje
437 shapeRenderer.circle(sw * 0.625f, cy, radio, 20) // Rotar derecha
438 shapeRenderer.circle(sw * 0.875f, cy, radio, 20) // Disparar
439 shapeRenderer.end()
440
441 // Etiquetas de botones
442 game.batch.projectionMatrix = ortho
443 game.batch.use {
444 fuente.color = Color.DARK_GRAY
445 fuente.data.setScale(0.7f)
446 fuente.draw(it, "◄", sw * 0.115f, cy + radio * 0.4f)
447 fuente.draw(it, "Δ", sw * 0.365f, cy + radio * 0.4f)
448 fuente.draw(it, "►", sw * 0.615f, cy + radio * 0.4f)
449 fuente.draw(it, "●", sw * 0.865f, cy + radio * 0.4f)
450 }
451
452 // Restaurar proyección de cámara del juego
453 game.batch.projectionMatrix = game.viewport.camera.combined
454 shapeRenderer.projectionMatrix = game.viewport.camera.combined
455 }
456
457 // ── Resize / Pause / Resume / Dispose ────────────────────────────────────
458
459 override fun resize(width: Int, height: Int) {
460 game.viewport.update(width, height, true)
461 hud.resize(width, height)
462 }
463
464 override fun pause() { musica.pause() }
465 override fun resume() { if (!musica.isPlaying) musica.play() }
466
467 override fun dispose() {
468 worldManager.dispose()
469 gestorPart.dispose()
470 hud.dispose()
471 shapeRenderer.dispose()
472 fuente.dispose()
473 debugRenderer.dispose()
474 // No se hace dispose() de musica/sfx: son gestionados por AssetManager del juego
475 }
476}11. GameOverScreen.kt#
Ver GameOverScreen.kt
1package edu.apellido1Nombre.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 edu.apellido1Nombre.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 edu.apellido1Nombre.kotlinasteroids.android
2
3import android.os.Bundle
4import com.badlogic.gdx.backends.android.AndroidApplication
5import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
6import edu.apellido1Nombre.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>