Seguridad contra nulos en Kotlin: Despídete del error del billón de dólares

Introducción

Si has llegado hasta aquí, ya sabes cómo crear tus propias clases, agrupar objetos y organizar tu código de maravilla. ¡Enhorabuena! Pero hay un enemigo invisible que acecha a casi todos los programadores cuando empiezan a desarrollar aplicaciones reales: la ausencia de datos o, como se le conoce técnicamente, el valor null.

En lenguajes más antiguos como Java, si intentas acceder a una propiedad de un objeto (por ejemplo, el correo electrónico de un usuario) y resulta que ese usuario no existe en la base de datos (es null), tu programa se cuelga abruptamente lanzando un fatídico NullPointerException. Es un fallo tan común, molesto y destructivo que a su propio inventor le gusta llamarlo «el error del billón de dólares».

Aquí es donde Kotlin brilla con luz propia. Para evitar que tu aplicación explote en las manos de tus usuarios, Kotlin implementa en su núcleo la seguridad contra nulos (Null Safety). Este sistema es como un escudo protector que detecta los posibles problemas mientras escribes tu código (en tiempo de compilación), obligándote a manejarlos de forma elegante mucho antes de que el código llegue a ejecutarse. ¡Vamos a descubrir su magia!

¿Qué son los tipos anulables (Nullable types)?

Por defecto, en Kotlin está estrictamente prohibido que una variable normal guarde un valor nulo. El compilador, simplemente, no te dejará hacerlo. Y esto es fantástico para dormir tranquilos.

Sin embargo, a veces es necesario representar que algo «falta», está vacío o aún no se ha configurado (como un segundo apellido opcional en un formulario). Para decirle a Kotlin que una variable tiene permiso para ser nula, debes declarar un tipo anulable añadiendo explícitamente el símbolo de interrogación ? justo después de su tipo de dato.

fun main() {
    // neverNull es un String normal, jamás aceptará nulos
    var neverNull: String = "Esto no puede ser nulo"
    
    // Si intentas hacer esto, el compilador te lanzará un error
    // neverNull = null

    // nullable es un String? (String anulable)
    var nullable: String? = "Aquí puedes guardar texto"
    
    // Esto es perfectamente válido
    nullable = null
}

Si intentas acceder directamente a una propiedad de una variable que podría ser nula, por ejemplo nullable.length, Kotlin te detendrá y te dará un error. ¿Por qué? Porque podría ser peligroso. Necesitas utilizar herramientas seguras.

Comprobación tradicional de nulos

La forma más básica de manejar un tipo anulable es usar una clásica estructura condicional if para asegurarte de que hay algo ahí dentro antes de usarlo.

fun describirTexto(texto: String?): String {
    // Kotlin comprueba primero que la variable no esté vacía
    if (texto != null && texto.length > 0) {
        return "El texto tiene ${texto.length} caracteres"
    } else {
        return "El texto está vacío o es nulo"
    }
}

El dato: Cuando realizas una comprobación manual como if (texto != null), el compilador de Kotlin es lo bastante inteligente como para aplicar un «smart cast» (conversión inteligente). Dentro de ese bloque if, Kotlin tratará automáticamente a tu variable anulable como si fuera totalmente segura, permitiéndote acceder a .length sin que te salte ningún error.

Llamadas seguras (?.)

Si tuviéramos que escribir un if cada vez que usamos algo que puede ser nulo, nuestro código sería un tostón larguísimo y muy difícil de leer. Afortunadamente, a Kotlin le encanta el código conciso.

Para acceder de forma rápida a las propiedades de un objeto que podría contener un valor nulo, utilizamos el operador de llamada segura ?.

Este operador lo hace todo por detrás: intenta leer la propiedad que le pides, y si resulta que el objeto original es nulo, en lugar de crashear tu aplicación, simplemente devuelve null de forma pacífica y sigue ejecutando el resto de tu código.

fun longitudDelTexto(texto: String?): Int? = texto?.length

fun main() {
    val miTextoNulo: String? = null
    println(longitudDelTexto(miTextoNulo)) 
    // Salida por consola: null
}

Una de las características más potentes de este operador es que las llamadas seguras se pueden encadenar. Si quieres acceder a un dato que se encuentra escondido muy profundamente en tu estructura de clases, puedes hacerlo así:

val paisDelJefe = empleado.empresa?.departamento?.jefe?.pais

Si el empleado no tiene empresa, o la empresa no tiene departamento, el viaje se detiene ahí mismo y la variable paisDelJefe guardará simplemente un null, sin pánicos ni errores de ejecución.

El operador Elvis (?:)

Muchas veces no queremos que el resultado final de nuestra llamada sea null. Es muy común querer proporcionar un valor por defecto si las cosas fallan. Para esto tenemos al espectacular operador Elvis ?: (se llama así porque si giras la cabeza a la izquierda, el símbolo recuerda al mítico tupé de Elvis Presley).

La estructura es muy simple: escribes a la izquierda del Elvis lo que quieres intentar leer, y a la derecha escribes el valor de respaldo que quieres devolver en caso de que lo de la izquierda haya resultado ser nulo.

fun main() {
    val texto: String? = null
    
    // Si texto?.length es nulo, usamos el 0 como comodín
    val longitud = texto?.length ?: 0
    
    println("La longitud es: $longitud")
    // Salida por consola: La longitud es: 0
}

La ejecución condicionada con let (?.let)

Una herramienta de uso diario muy habitual en Kotlin. ¿Y si quieres ejecutar un bloque entero de código únicamente si tu variable no es nula? Combinando la llamada segura ?. con la función let, construimos un entorno hermético y totalmente blindado.

fun main() {
    val correo: String? = "[email protected]"
    
    // Todo lo que hay dentro de las llaves solo se ejecuta si hay correo
    correo?.let { correoSeguro ->
        println("Enviando mensaje de bienvenida a $correoSeguro")
    }
}

La regla de oro: Acostúmbrate a usar siempre llamadas seguras ?. o el operador Elvis ?: para lidiar con tipos anulables. Únicamente hay un escenario en el que podrías forzar la máquina: usando el operador de aserción !! (la doble exclamación). Este operador fuerza la lectura de la variable ignorando la seguridad; si el valor resulta ser nulo, tu aplicación se cerrará inmediatamente. Úsalo solo si pondrías la mano en el fuego de que ese dato existe, o mejor aún, ¡evítalo a toda costa en tu día a día!

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (el primero es una traducción adaptada de la documentación oficial de Kotlin y el resto son extras para asentar los conceptos que hemos ampliado). Abre tu Kotlin Playground y vamos a mancharnos las manos.

El empleado sin sueldo

Tienes la función employeeById que te da acceso a la base de datos de los empleados de una compañía según su ID. Por desgracia, la función devuelve un tipo Employee?, por lo que el resultado puede ser null si el empleado no existe. Tu objetivo es escribir el código de la función salaryById(id: Int) para que devuelva el salario del empleado si lo encuentra, o el valor 0 si el empleado ha desaparecido de los registros.

data class Employee(val name: String, var salary: Int)

fun employeeById(id: Int) = when(id) {
    1 -> Employee("Mary", 20)
    2 -> null
    3 -> Employee("John", 21)
    4 -> Employee("Ann", 23)
    else -> null
}

fun salaryById(id: Int): Int {
    // Escribe tu código aquí (Pista: usa llamada segura + Elvis)
}

fun main() {
    // Esto debería imprimir la suma total de los salarios existentes (64)
    println((1..5).sumOf { id -> salaryById(id) })
}

El formulario de contacto

Imagina que estás programando un formulario de registro. Crea una función llamada obtenerLongitudTelefono que reciba un número de teléfono (String?) que puede ser nulo. Utiliza una llamada segura y el operador Elvis en una sola línea para devolver el número de caracteres del teléfono, o 0 si el usuario decidió no teclear ninguno.

// Escribe aquí tu función obtenerLongitudTelefono

fun main() {
    val telefono1: String? = "654321987"
    val telefono2: String? = null

    println(obtenerLongitudTelefono(telefono1)) // Debería imprimir 9
    println(obtenerLongitudTelefono(telefono2)) // Debería imprimir 0
}

La cadena de mando

A veces los objetos contienen a otros objetos que, a su vez, también pueden ser nulos. Declara tres data classes:

  1. Jefe (con una propiedad nombre de tipo String).
  2. Departamento (con una propiedad jefe de tipo Jefe?).
  3. Empresa (con una propiedad departamento de tipo Departamento?).

Luego, en tu función main(), crea una instancia de Empresa donde el departamento sea null. Usando llamadas seguras encadenadas, intenta recuperar el nombre del jefe. Si en algún momento la cadena se rompe por un nulo, haz que se asigne automáticamente el texto «Sin jefe asignado» usando el operador Elvis. Imprime el resultado final.

// Escribe aquí tus clases

fun main() {
    // 1. Crea una Empresa con departamento = null
    // 2. Encadena las llamadas e imprime el resultado
}

El sistema de alertas

Tienes un mensaje de alerta opcional. Usando la llamada segura ?. junto a la función let, haz que el sistema imprima en mayúsculas (usando el método .uppercase()) el texto de la alerta solo y exclusivamente si este no es nulo.

fun main() {
    val alertaActiva: String? = "Peligro de sobrecalentamiento en el núcleo"
    val alertaApagada: String? = null

    // Escribe aquí tu código usando let para procesar alertaActiva
    
    // Y luego repite exactamente lo mismo para alertaApagada 
    // (este segundo bloque no debería imprimir nada por consola)
}

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con tu editor de código y el compilador te haya gritado un par de veces.

El empleado sin sueldo

data class Employee(val name: String, var salary: Int)

fun employeeById(id: Int) = when(id) {
    1 -> Employee("Mary", 20)
    2 -> null
    3 -> Employee("John", 21)
    4 -> Employee("Ann", 23)
    else -> null
}

// Usamos la llamada segura '?.salary' acoplada al valor de respaldo '?: 0'
fun salaryById(id: Int): Int = employeeById(id)?.salary ?: 0

fun main() {
    println((1..5).sumOf { id -> salaryById(id) })
}

El formulario de contacto

fun obtenerLongitudTelefono(telefono: String?): Int {
    return telefono?.length ?: 0
}

fun main() {
    val telefono1: String? = "654321987"
    val telefono2: String? = null

    println(obtenerLongitudTelefono(telefono1)) 
    println(obtenerLongitudTelefono(telefono2)) 
}

La cadena de mando

data class Jefe(val nombre: String)
data class Departamento(val jefe: Jefe?)
data class Empresa(val departamento: Departamento?)

fun main() {
    // Creamos nuestra empresa pasándole un departamento nulo
    val miEmpresa = Empresa(departamento = null)

    // Encadenamos con '?.'. Como el departamento es null, salta directamente al Elvis
    val nombreDelJefe = miEmpresa.departamento?.jefe?.nombre ?: "Sin jefe asignado"
    
    println(nombreDelJefe)
}

El sistema de alertas

fun main() {
    val alertaActiva: String? = "Peligro de sobrecalentamiento en el núcleo"
    val alertaApagada: String? = null

    alertaActiva?.let { mensaje ->
        println(mensaje.uppercase())
    }

    // Como esta variable es null, la ejecución ignorará todo el bloque let
    alertaApagada?.let { mensaje ->
        println(mensaje.uppercase())
    }
}

Clases y objetos en Kotlin: Modela tus datos con elegancia

Introducción

Si has llegado hasta aquí, ya sabes cómo manejar variables, colecciones, controlar el flujo de tu programa y crear funciones o lambdas para organizar tus acciones. ¡Enhorabuena! Tienes unas bases de programación fabulosas. Pero ahora toca dar el siguiente paso lógico y entrar de lleno en la Programación Orientada a Objetos (POO).

Imagina que en tu aplicación tienes que gestionar la información de decenas de usuarios: su nombre, su identificador, su correo y su edad. ¿Vas a usar variables sueltas por todo el código para cada cosita de cada usuario? Sería un absoluto caos y muy propenso a errores. Para eso están las clases y los objetos. En esta unidad, vamos a ver cómo Kotlin hace que crear y agrupar estructuras de datos sea un proceso muy limpio, y descubriremos la magia de las data classes (clases de datos), una de las características estrella del lenguaje que te ahorrará horas de escribir código.

¿Qué son las clases y cómo se declaran?

En Kotlin, la programación orientada a objetos es muy directa. Una clase te permite declarar un conjunto de características compartidas para un concepto. Piensa en la clase como si fuera el «molde» (por ejemplo, el concepto de Cliente) y en los objetos como las «galletas» reales que salen de ese molde (el cliente Juan, la clienta María).

Para declarar una clase, simplemente utilizamos la palabra reservada class:

class Cliente

Propiedades de una clase

Las características concretas de ese molde se llaman propiedades. Puedes declarar las propiedades de una clase directamente entre paréntesis () justo después del nombre de la misma. A esta parte se le conoce como el encabezado de la clase (class header).

class Contacto(val id: Int, var email: String)

También puedes definir propiedades adicionales dentro del cuerpo de la clase, abriendo unas llaves {}:

class Contacto(val id: Int, var email: String) {
    val categoria: String = "trabajo"
}

La regla de oro: Te recomendamos encarecidamente que declares tus propiedades como de solo lectura (val) siempre que sea posible. Usa variables mutables (var) únicamente si tienes la absoluta certeza de que ese dato necesitará cambiar después de haber creado el objeto.

Al igual que ocurría con las funciones, las propiedades de una clase pueden tener valores por defecto:

class Contacto(val id: Int, var email: String = "[email protected]") {
    val categoria: String = "trabajo"
}

El dato: Si pones parámetros en el encabezado sin escribir val o var delante, Kotlin los tratará como simples parámetros de configuración y no como propiedades reales de la clase; por lo tanto, no podrás acceder a ellos después. ¡Acuérdate de poner siempre val o var!

Nota extra: Kotlin te permite poner una coma al final de la última propiedad (conocido como trailing comma) para que te sea más fácil reordenar el código copiando y pegando líneas.

Crear instancias (objetos)

Para poder usar esa clase en la vida real, necesitas instanciarla (crear un objeto) a través de un constructor. En lenguajes más antiguos como Java tendrías que usar la palabra new a la fuerza, pero a Kotlin le gusta el código conciso, así que no hace falta.

Por defecto, el lenguaje te crea un constructor automáticamente exigiendo los parámetros que hayas puesto en el encabezado.

class Contacto(val id: Int, var email: String)

fun main() {
    // Creamos la instancia y la guardamos en una variable
    val contacto1 = Contacto(1, "[email protected]")
}

En este ejemplo:

  • Contacto es la clase (el molde).
  • contacto1 es la instancia (el objeto real en la memoria).

Acceder a las propiedades

Para leer o modificar una propiedad de un objeto que ya has creado, la sintaxis es facilísima: solo tienes que escribir el nombre de la instancia, seguido de un punto ., y el nombre de la propiedad.

fun main() {
    val contacto = Contacto(1, "[email protected]")
    
    // Imprimimos el valor de la propiedad
    println(contacto.email) 
    // Salida: [email protected]

    // Actualizamos el valor (es posible porque lo definimos como 'var')
    contacto.email = "[email protected]"
    
    // Imprimimos el nuevo valor
    println(contacto.email) 
    // Salida: [email protected]
}

Si necesitas concatenar el valor de esa propiedad dentro de un texto, recuerda usar las plantillas de cadenas con el símbolo del dólar. Como estás accediendo a una propiedad y no a una variable simple, es obligatorio envolverlo todo entre llaves ${}:

println("Su correo electrónico de empresa es: ${contacto.email}")

Funciones miembro

Las clases no son simples «bolsas» para guardar datos estáticos. También pueden tener comportamiento propio. A las funciones que declaramos dentro del cuerpo de una clase se las denomina funciones miembro (o métodos).

Para llamar a estas funciones, volvemos a usar la sintaxis del punto .:

class Contacto(val id: Int, var email: String) {
    // Esta es una función miembro
    fun imprimirId() {
        println("El identificador secreto es: $id")
    }
}

fun main() {
    val contacto = Contacto(1, "[email protected]")
    // Llamamos a la función
    contacto.imprimirId() 
    // Salida: El identificador secreto es: 1
}

El superpoder de Kotlin: data classes

Aquí es donde Kotlin brilla y demuestra por qué es un lenguaje tan popular. En el día a día, muchas veces vas a crear clases cuyo único propósito sea almacenar información de forma pasiva. Para estos casos concretos, Kotlin inventó las data classes (clases de datos).

Tienen exactamente la misma funcionalidad que las clases normales que acabamos de ver, pero el compilador de Kotlin les inyecta automáticamente por detrás un montón de funciones extra para que no tengas que escribir código repetitivo o boilerplate.

Para declararlas, solo tienes que poner la palabra data antes del class:

data class Usuario(val nombre: String, val id: Int)

Al hacer esto, ganas acceso automático a tres funciones vitales que analizamos en los siguientes puntos.

Imprimir como texto (toString)

Si imprimes una clase normal de Kotlin por la consola, te escupirá un nombre incomprensible con su referencia en memoria (algo tipo Usuario@2f92e0f4). Pero las data classes sobrescriben la función toString() de forma nativa para que la salida sea humana y legible.

fun main() {
    val usuario = Usuario("Alex", 1)
    
    println(usuario) 
    // Salida automática y bonita: Usuario(nombre=Alex, id=1)
}

Esto es indispensable cuando estás haciendo pruebas o guardando logs en tu servidor.

Comparar instancias (==)

Si tú comparas dos instancias distintas de una clase normal usando ==, Kotlin te dirá que son diferentes false, porque mirará si ocupan el mismo espacio físico en la memoria del ordenador.

Sin embargo, en las data classes, el operador == (que llama a la función equals()) es inteligente: compara los datos reales.

fun main() {
    val user1 = Usuario("Alex", 1)
    val user2 = Usuario("Alex", 1)
    val user3 = Usuario("Max", 2)

    println("user1 y user2: ${user1 == user2}") // true (¡Tienen los mismos datos!)
    println("user1 y user3: ${user1 == user3}") // false
}

Copiar instancias (copy)

Imagina que quieres crear un objeto nuevo partiendo de uno existente, pero alterando únicamente uno de sus datos. Modificar el objeto original usando un var puede causar fallos encadenados si otra parte de tu código estaba usando a ese mismo usuario.

La función copy() te clona el objeto a la perfección, y como parámetros opcionales le puedes pasar el nuevo valor para las propiedades que quieras alterar:

fun main() {
    val usuario = Usuario("Alex", 1)

    // Clonación exacta
    println(usuario.copy()) 
    // Usuario(nombre=Alex, id=1)

    // Clonación con el nombre alterado
    println(usuario.copy(nombre = "Max")) 
    // Usuario(nombre=Max, id=1)
}

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (los tres primeros son traducciones de la documentación oficial de Kotlin y los dos últimos son extras para asentar los conceptos). Abre tu Kotlin Playground y vamos a mancharnos las manos.

El empleado

Define una data class llamada Employee con dos propiedades: name (para el nombre en formato texto) y salary (para el salario en enteros). Asegúrate de que la propiedad del salario sea mutable, ¡o de lo contrario el empleado jamás podrá recibir un aumento a final de año!

El main a continuación te muestra cómo lo vamos a usar.

// Escribe tu código aquí

fun main() {
    val emp = Employee("Mary", 20)
    println(emp)
    emp.salary += 10
    println(emp)
}

Estructuras anidadas

A veces los objetos contienen a otros objetos. Declara las data classes adicionales que se necesitan para que el siguiente código compile sin errores. Fíjate bien en la llamada dentro de main() para adivinar qué propiedades y de qué tipo necesita cada clase.

data class Person(val name: Name, val address: Address, val ownsAPet: Boolean = true)
// Escribe tu código aquí:
// data class Name(...)

fun main() {
    val person = Person(
        Name("John", "Smith"),
        Address("123 Fake Street", City("Springfield", "US")),
        ownsAPet = false
    )
    println(person)
}

Generador aleatorio de empleados

Para probar tu código en el futuro, vas a necesitar un generador que cree empleados al azar. Define una clase normal RandomEmployeeGenerator.

El constructor de la clase debe recibir el minSalary y el maxSalary (ambos enteros y mutables).

Dentro del cuerpo de la clase, guarda una lista fija de nombres potenciales. Y, por último, crea una función miembro llamada generateEmployee() que devuelva una nueva instancia de tu data class Employee usando un nombre aleatorio de tu lista y un salario aleatorio dentro de los márgenes dados.

Pista: Las listas de Kotlin tienen una extensión muy útil llamada .random() que te devuelve un ítem al azar. Usa Random.nextInt(from = minSalary, until = maxSalary) para generar el número.

import kotlin.random.Random

data class Employee(val name: String, var salary: Int)

// Escribe tu clase RandomEmployeeGenerator aquí

fun main() {
    val empGen = RandomEmployeeGenerator(10, 30)
    println(empGen.generateEmployee())
    println(empGen.generateEmployee())
    
    empGen.minSalary = 50
    empGen.maxSalary = 100
    println(empGen.generateEmployee())
}

El inventario de la tienda

Crea una clase (¡normal, no data class!) llamada Producto que reciba en su constructor un nombre de tipo String y un precio de tipo Double (ambos inmutables).

Añade una propiedad dentro del cuerpo de la clase llamada stock de tipo entero, inicializada en 0, que por supuesto sea mutable.

Crea una función miembro llamada añadirStock(cantidad: Int) que le sume esa cantidad al stock actual.

En tu función main, crea un producto, añádele 50 unidades de stock, e imprime un texto final diciendo: «Tenemos [stock] unidades de [nombre]».

Clonando clientes

Tienes una data class Cliente(val id: Int, val nombre: String, val email: String). Fíjate en que todo está blindado y es inmutable (val).

Crea una instancia para «Ana» con ID 1 y correo «[email protected]». De repente, Ana te avisa de que ha cambiado su correo corporativo a «[email protected]».

Como no puedes hacer ana.email = ... porque daría error, usa la función de las data classes que hemos aprendido para crear un nuevo objeto llamado anaActualizada basado en Ana, pero con su correo modificado. Imprime por consola a anaActualizada.

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con tu editor de código y el compilador te haya gritado un par de veces.

El empleado

data class Employee(val name: String, var salary: Int)

fun main() {
    val emp = Employee("Mary", 20)
    println(emp)
    emp.salary += 10
    println(emp)
}

Estructuras anidadas

data class Person(val name: Name, val address: Address, val ownsAPet: Boolean = true)
data class Name(val first: String, val last: String)
data class Address(val street: String, val city: City)
data class City(val name: String, val countryCode: String)

fun main() {
    val person = Person(
        Name("John", "Smith"),
        Address("123 Fake Street", City("Springfield", "US")),
        ownsAPet = false
    )
    println(person)
}

Generador aleatorio de empleados

import kotlin.random.Random

data class Employee(val name: String, var salary: Int)

class RandomEmployeeGenerator(var minSalary: Int, var maxSalary: Int) {
    // Definimos la lista en el cuerpo de la clase
    val names = listOf("John", "Mary", "Ann", "Paul", "Jack", "Elizabeth")
    
    // Función miembro que devuelve un Employee
    fun generateEmployee() = Employee(
        names.random(), 
        Random.nextInt(from = minSalary, until = maxSalary)
    )
}

fun main() {
    val empGen = RandomEmployeeGenerator(10, 30)
    println(empGen.generateEmployee())
    println(empGen.generateEmployee())
    
    empGen.minSalary = 50
    empGen.maxSalary = 100
    println(empGen.generateEmployee())
}

El inventario de la tienda

class Producto(val nombre: String, val precio: Double) {
    // Propiedad en el cuerpo inicializada a 0
    var stock: Int = 0
    
    fun añadirStock(cantidad: Int) {
        stock += cantidad
    }
}

fun main() {
    val miProducto = Producto("Teclado Mecánico", 45.99)
    miProducto.añadirStock(50)
    
    println("Tenemos ${miProducto.stock} unidades de ${miProducto.nombre}")
}

Clonando clientes

data class Cliente(val id: Int, val nombre: String, val email: String)

fun main() {
    // Creamos nuestra instancia original
    val ana = Cliente(1, "Ana", "[email protected]")
    
    // Clonamos el objeto modificando únicamente la propiedad email
    val anaActualizada = ana.copy(email = "[email protected]")
    
    // Comprobamos la magia
    println(anaActualizada)
    // Salida: Cliente(id=1, nombre=Ana, [email protected])
}

Funciones y lambdas en Kotlin: Crea código reutilizable y elegante

Introducción

Si has llegado hasta aquí, ya sabes cómo crear variables, manejar distintos tipos de datos, organizar información en colecciones y controlar el flujo de tu programa con condicionales y bucles. ¡Enhorabuena! Tienes las bases. Pero ahora toca dar el siguiente paso lógico en la programación: las funciones.

Imagina que en tu código tienes que hacer un cálculo complejo o mostrar un mensaje específico docenas de veces a lo largo del programa. ¿Vas a copiar y pegar el mismo bloque de código una y otra vez? Para eso están las funciones. En esta unidad, vamos a ver cómo Kotlin hace que declarar y usar funciones sea un proceso muy limpio, adentrándonos por último en el fascinante mundo de las expresiones lambda, una de las características más apreciadas por los desarrolladores modernos.

¿Qué son las funciones y cómo se declaran?

En Kotlin, puedes declarar tus propias funciones utilizando la palabra reservada fun (muy apropiado, ¿verdad? ¡Programar en Kotlin es divertido!). Una función no es más que un bloque de código al que le damos un nombre y que realiza una tarea concreta.

fun sumar(x: Int, y: Int): Int {
    return x + y
}

fun main() {
    println(sumar(1, 2))
    // Salida: 3
}

Analizando el código paso a paso:

  • fun: La palabra clave mágica para decirle a Kotlin que vamos a crear una función.
  • Los parámetros: Se escriben siempre entre paréntesis (). Cada parámetro debe tener un tipo de dato explícito (en este caso x e y son de tipo Int), y si hay varios, se separan por comas. Son los datos que la función necesita para poder trabajar.
  • El tipo de retorno: Después de los paréntesis de los parámetros y separado por dos puntos :, indicamos qué tipo de dato va a devolver la función como resultado final. En este caso, devolverá un número entero (Int).
  • El cuerpo de la función: Es todo lo que va dentro de las llaves {}.
  • return: Usamos esta palabra para escupir o devolver el resultado de nuestro cálculo y dar por terminada la ejecución de la función.

El dato: Las convenciones de código oficiales de Kotlin recomiendan nombrar las funciones empezando con minúscula y usando camelCase (por ejemplo, calcularPrecioTotal, imprimirUsuario), sin usar guiones bajos.

Parámetros nombrados

A veces, cuando llamas a una función que tiene muchísimos parámetros, el código puede volverse un poco lioso a simple vista. ¿Qué era ese true o ese 5 que le pasábamos a la función en la tercera posición? Kotlin nos permite usar parámetros nombrados.

Si incluyes el nombre del parámetro explícitamente al llamar a la función, no solo haces que tu código sea muchísimo más fácil de leer, sino que además puedes poner los parámetros en el orden que quieras.

fun imprimirMensaje(mensaje: String, prefijo: String) {
    println("[$prefijo] $mensaje")
}

fun main() {
    // Usamos argumentos nombrados y además invertimos el orden
    imprimirMensaje(prefijo = "LOG", mensaje = "Sistema iniciado correctamente")
    // Resultado: [LOG] Sistema iniciado correctamente
}

Valores por defecto

En lenguajes más antiguos, si a veces querías llamar a una función pasándole tres parámetros y otras veces solo dos, tenías que crear varias versiones de la misma función (esto se conoce como sobrecarga de métodos).

En Kotlin, te ahorras todo ese trabajo. Puedes definir valores por defecto para los parámetros utilizando el operador de asignación =. Si al llamar a la función omites ese parámetro, Kotlin utilizará automáticamente el valor por defecto que configuraste.

fun imprimirMensaje(mensaje: String, prefijo: String = "INFO") {
    println("[$prefijo] $mensaje")
}

fun main() {
    // Llamamos a la función pasándole ambos parámetros
    imprimirMensaje("Usuario conectado", "LOG") 
    // Resultado: [LOG] Usuario conectado
    
    // Llamamos a la función pasándole SOLO el mensaje. ¡Usa el prefijo por defecto!
    imprimirMensaje("El proceso ha finalizado") 
    // Resultado: [INFO] El proceso ha finalizado
}

La regla de oro: Puedes saltarte parámetros concretos que tengan valores por defecto, pero si omites uno y quieres pasar el siguiente, tendrás que usar obligatoriamente parámetros nombrados para los que pongas a continuación.

Funciones que no devuelven nada

Si tu función simplemente realiza una acción (como imprimir un texto por la consola, reproducir un sonido o guardar un dato en la base de datos) pero no necesita devolverte ningún resultado matemático o lógico útil, su tipo de retorno en Kotlin es Unit (el equivalente al void en Java o C).

La maravilla de Kotlin es que no hace falta que escribas Unit ni que pongas un return. El compilador ya lo sabe.

fun saludar(nombre: String) {
    println("¡Hola, $nombre!")
    // Escribir `: Unit` arriba junto a los paréntesis o `return Unit` aquí abajo es totalmente opcional (y casi nadie lo hace).
}

Funciones de una sola expresión

Para hacer tu código aún más conciso y espectacular, si tu función consta de una única instrucción que devuelve un valor, puedes ahorrarte de un plumazo las llaves {} y la palabra return. Solo tienes que usar el signo igual =.

Tomemos la función sumar del principio:

fun sumar(x: Int, y: Int): Int {
    return x + y
}

Se puede transformar en esta maravilla de una sola línea:

fun sumar(x: Int, y: Int) = x + y

Fíjate bien, ¡ni siquiera hemos puesto : Int! Como Kotlin cuenta con una característica llamada inferencia de tipos, deduce inmediatamente que si sumas dos Int, el resultado será forzosamente un Int.

(No obstante, si estás trabajando en equipo y quieres que otros programadores lean tu código rápidamente, nunca está de más indicar explícitamente el tipo de retorno: fun sumar(x: Int, y: Int): Int = x + y).

Retornos tempranos en funciones

En ocasiones, quieres salir de una función inmediatamente si se cumple (o no) una condición, sin necesidad de seguir ejecutando y procesando el resto del código. Para eso usamos la palabra return dentro de un condicional if.

val usuariosRegistrados = mutableListOf("juan_perez", "maria_lopez")

fun registrarUsuario(usuario: String): String {
    // Retorno temprano si el usuario ya existe en nuestra base de datos (lista)
    if (usuario in usuariosRegistrados) {
        return "Error: El nombre de usuario ya está pillado. Elige otro."
    }

    // Si no hemos entrado en el if anterior, la función continúa de forma normal
    usuariosRegistrados.add(usuario)
    return "¡Usuario $usuario registrado con éxito!"
}

fun main() {
    println(registrarUsuario("juan_perez"))
    // Salida: Error: El nombre de usuario ya está pillado. Elige otro.
    
    println(registrarUsuario("nuevo_user"))
    // Salida: ¡Usuario nuevo_user registrado con éxito!
}

Expresiones lambda

Kotlin te permite escribir funciones de forma todavía más compacta usando lo que conocemos como expresiones lambda. Básicamente, una lambda es una función anónima (sin nombre) que puedes guardar directamente en una variable o pasarla como si fuera un parámetro más a otras funciones.

Mira esta función normal:

fun aMayusculas(texto: String): String {
    return texto.uppercase()
}

Podemos escribir exactamente lo mismo como una expresión lambda y guardarla en una variable:

val aMayusculasLambda = { texto: String -> texto.uppercase() }

fun main() {
    println(aMayusculasLambda("hola"))
    // Resultado: HOLA
}

Estructura de una lambda:

  • Se encapsula siempre entre llaves { }.
  • Primero van los parámetros (texto: String).
  • Luego una flecha ->.
  • Y finalmente el cuerpo de la función (lo que hace y lo que se devuelve de forma automática: texto.uppercase()).

Pasando lambdas a otras funciones

Esto resulta muy útil cuando trabajamos con colecciones (listas, sets, mapas…). Multitud de funciones nativas de colecciones reciben una lambda para saber exactamente qué deben hacer con cada uno de los elementos.

El mejor ejemplo es usar .filter() (para filtrar elementos en base a una condición) o .map() (para transformar los elementos de una lista en otros):

fun main() {
    val numeros = listOf(1, -2, 3, -4, 5, -6)
    
    // Quedarnos solo con los positivos. La lambda comprueba si el número es mayor que 0.
    val positivos = numeros.filter({ x -> x > 0 })
    println(positivos) // [1, 3, 5]
    
    // Multiplicar todos los elementos de la lista por 2 usando map
    val dobles = numeros.map({ x -> x * 2 })
    println(dobles) // [2, -4, 6, -8, 10, -12]
}

Si la lambda es el último o el único parámetro que le pasas a una función (como pasa arriba con filter y map), Kotlin te permite sacarla fuera de los paréntesis (). E incluso puedes omitir los paréntesis por completo si es el único argumento.

Reescribiendo lo anterior de forma idiomática:

val positivos = numeros.filter { x -> x > 0 }

Tipos de funciones

Al igual que una variable puede ser de tipo String o Int, ¡las funciones también tienen su propio tipo! La sintaxis para definir el tipo de una función consiste en poner los parámetros de entrada entre paréntesis y, tras una flecha ->, el tipo que va a devolver.

  • (String) -> String (recibe un texto, devuelve un texto)
  • (Int, Int) -> Int (recibe dos enteros, devuelve un entero)
  • () -> Unit (no recibe absolutamente nada, no devuelve nada útil)

Ejemplo declarando el tipo de forma explícita en una variable:

val sumarLambda: (Int, Int) -> Int = { a, b -> a + b }

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (algunos adaptados de la documentación oficial y otros extra para asentar todo lo aprendido). Abre tu Kotlin Playground y vamos a practicar lo aprendido.

El área del círculo

Escribe una función normal llamada areaCirculo que reciba el radio de un círculo en formato entero (Int) y devuelva el área de ese círculo (con decimales).

Pista: Vas a necesitar importar la constante Pi desde kotlin.math.PI. La fórmula es PI * radio * radio.

import kotlin.math.PI
// Escribe tu función aquí

fun main() {
    println(areaCirculo(2)) // Debería dar aprox 12.566...
}

El área del círculo en una sola línea

Reescribe la función areaCirculo del ejercicio anterior pero simplifícala para convertirla en una función de una sola expresión (Single-expression function), eliminando las llaves y el return.

Pasando el tiempo a segundos

Tienes una función que traduce un intervalo de tiempo (horas, minutos y segundos) a segundos totales. La inmensa mayoría de las veces, solo vas a querer pasarle un parámetro y que el resto sean cero por defecto.

Mejora la función y las llamadas en el main utilizando valores por defecto y argumentos nombrados para que el código quede impecable y súper legible.

// Mejora esta función
fun intervaloEnSegundos(horas: Int, minutos: Int, segundos: Int) =
    ((horas * 60) + minutos) * 60 + segundos

fun main() {
    // Mejora estas llamadas
    println(intervaloEnSegundos(1, 20, 15))
    println(intervaloEnSegundos(0, 1, 25))
    println(intervaloEnSegundos(2, 0, 0))
    println(intervaloEnSegundos(0, 10, 0))
    println(intervaloEnSegundos(1, 0, 1))
}

Generador de URLs

Tienes una lista de acciones de una API, un prefijo de una web y el ID de un recurso. Utilizando la función de colección .map() y una expresión lambda, crea una nueva lista de URLs que combinen los tres elementos para formar rutas completas (ejemplo esperado: https://ejemplo.com/libro/5/titulo).

fun main() {
    val acciones = listOf("titulo", "anyo", "autor")
    val prefijo = "https://ejemplo.com/libro"
    val id = 5
    
    val urls = // ¡Escribe aquí tu map con lambda!
    
    println(urls)
}

Repetidor de acciones

Escribe una función llamada repetirN que reciba un número entero n y una acción (una función de tipo () -> Unit). La función debe repetir esa acción el número de veces indicado utilizando un bucle clásico. Luego, en el main, úsala (usando una trailing lambda) para imprimir "¡Me encanta Kotlin!" 5 veces.

El saludo personalizado

Crea una función llamada saludoPersonalizado que reciba un nombre (String), una edad (Int) y una ciudad (String). El parámetro ciudad debe tener el valor por defecto "Madrid".

La función debe usar un println() para mostrar: "Hola, me llamo [nombre], tengo [edad] años y vivo en [ciudad].".

Luego, en el main, llama a la función de dos formas distintas:

  1. Pasando solo nombre y edad.
  2. Pasando nombre, edad y ciudad, pero utilizando argumentos nombrados en un orden distinto al original.

Filtrando palabras largas

Dada la siguiente lista de palabras: val palabras = listOf("sol", "murciélago", "pan", "ornitorrinco", "luz", "desarrollador")

Utiliza la función .filter() y una trailing lambda para crear una nueva lista que solo contenga las palabras que tengan estrictamente más de 5 letras. (Pista: Los textos o Strings en Kotlin tienen una propiedad llamada .length que te dice lo largos que son). Imprime el resultado final.

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con el código.

El área del círculo

import kotlin.math.PI

fun areaCirculo(radio: Int): Double {
    return PI * radio * radio
}

fun main() {
    println(areaCirculo(2)) 
    // 12.566370614359172
}

El área del círculo en una sola línea

import kotlin.math.PI

// Quitamos llaves y return, y usamos el signo '='. Mantener el ': Double' es buena práctica.
fun areaCirculo(radio: Int): Double = PI * radio * radio

fun main() {
    println(areaCirculo(2))
}

Pasando el tiempo a segundos

// Asignamos '= 0' a todos los parámetros para darles un valor por defecto
fun intervaloEnSegundos(horas: Int = 0, minutos: Int = 0, segundos: Int = 0) =
    ((horas * 60) + minutos) * 60 + segundos

fun main() {
    println(intervaloEnSegundos(1, 20, 15)) // Este lo pasamos normal
    println(intervaloEnSegundos(minutos = 1, segundos = 25)) // Omitimos las horas
    println(intervaloEnSegundos(horas = 2)) // Omitimos minutos y segundos
    println(intervaloEnSegundos(minutos = 10))
    println(intervaloEnSegundos(horas = 1, segundos = 1))
}

Generador de URLs

fun main() {
    val acciones = listOf("titulo", "anyo", "autor")
    val prefijo = "https://ejemplo.com/libro"
    val id = 5
    
    // Usamos map para transformar cada elemento. Recuerda usar plantillas de texto ($) para fusionarlo todo.
    val urls = acciones.map { accion -> "$prefijo/$id/$accion" }
    
    println(urls)
    // [https://ejemplo.com/libro/5/titulo, https://ejemplo.com/libro/5/anyo, https://ejemplo.com/libro/5/autor]
}

Repetidor de acciones

// El tipo de la acción es () -> Unit porque es un bloque de código que no recibe nada y no devuelve nada útil.
fun repetirN(n: Int, accion: () -> Unit) {
    for (i in 1..n) {
        accion()
    }
}

fun main() {
    // Como la acción es el último parámetro, podemos sacar las llaves (trailing lambda)
    repetirN(5) {
        println("¡Me encanta Kotlin!")
    }
}

El saludo personalizado

fun saludoPersonalizado(nombre: String, edad: Int, ciudad: String = "Madrid") {
    println("Hola, me llamo $nombre, tengo $edad años y vivo en $ciudad.")
}

fun main() {
    // Llamada 1: usamos el valor por defecto para ciudad
    saludoPersonalizado("Carlos", 28)
    
    // Llamada 2: usamos argumentos nombrados alterando el orden natural
    saludoPersonalizado(ciudad = "Valencia", edad = 32, nombre = "Laura")
}

Filtrando palabras largas

fun main() {
    val palabras = listOf("sol", "murciélago", "pan", "ornitorrinco", "luz", "desarrollador")
    
    // filter con trailing lambda. Solo conservamos la palabra si su longitud es mayor a 5.
    val palabrasLargas = palabras.filter { palabra -> palabra.length > 5 }
    
    println(palabrasLargas)
    // [murciélago, ornitorrinco, desarrollador]
}

Control de flujo en Kotlin: Condicionales (if, when) y bucles (for, while)

Introducción

Hasta ahora, todos los programas que hemos escrito se ejecutaban en línea recta: el ordenador leía la línea 1, luego la 2, luego la 3… y terminaba. Pero en el mundo real, las aplicaciones necesitan tomar decisiones (si el usuario tiene saldo, haz la compra; si no, muestra un error) y repetir tareas (mostrar los 50 mensajes de un chat uno por uno).

Para esto sirve el Control de Flujo. En Kotlin, contamos con herramientas modernizadas y súper potentes para dirigir el tráfico de nuestro código. ¡Vamos a descubrirlas!

Tomando decisiones: el clásico if / else

La forma más básica de tomar una decisión en programación es usar la estructura if (si ocurre esto…) y else (si no, haz esto otro…).

La condición a evaluar siempre va entre paréntesis (), y el bloque de código que se ejecutará va entre llaves {}.

fun main() {
    val edad = 18

    if (edad >= 18) {
        println("Puedes entrar a la discoteca.")
    } else {
        println("Lo siento, vuelve a casa.")
    }
}

El súper-poder de Kotlin: el if como expresión

Si vienes de lenguajes como Java o JavaScript, conocerás el famoso operador ternario (condicion ? valor1 : valor2) para asignar variables en una sola línea. En Kotlin, el operador ternario no existe porque no hace falta.

En Kotlin, un if puede devolver un valor directamente. Si tu if y tu else solo tienen una línea, puedes quitar las llaves y hacerlo así de elegante:

fun main() { 
    val a = 10
    val b = 20

    // El resultado del if se guarda directamente en la variable 'mayor'
    val mayor = if (a > b) a else b 
    
    println("El número mayor es $mayor") // Imprime: 20
}

El condicional when: el switch con esteroides

Cuando tienes que evaluar muchísimas opciones distintas, usar decenas de if / else if / else encadenados hace que el código sea ilegible.

Otros lenguajes usan la palabra switch. Kotlin usa when (cuando), y es una de las herramientas más queridas por los desarrolladores.

when como instrucción

Colocamos la variable que queremos evaluar entre paréntesis. Luego usamos una «flechita» -> para indicar qué hacer en cada caso. El else actúa como la opción por defecto si no se cumple ninguna de las anteriores.

fun main() {
    val boton = "X"

    when (boton) {
        "A" -> println("Saltar")
        "B" -> println("Atacar")
        "X" -> println("Abrir Inventario")
        "Y" -> println("Magia")
        else -> println("Botón no reconocido") 
    }
}

Nota: Kotlin evalúa de arriba a abajo. En cuanto encuentra una coincidencia, ejecuta esa línea y sale del when automáticamente. (¡Adiós a la pesadilla de olvidar poner los break de otros lenguajes!)

when como expresión (devolviendo un valor)

Al igual que el if, podemos usar when para asignar un valor directamente a una variable:

val estadoSemaforo = "Rojo" 
 
val accion = when (estadoSemaforo) {
    "Verde" -> "Acelerar"
    "Ambar" -> "Frenar poco a poco"
    "Rojo" -> "Detenerse"
    else -> "Llamar al mecánico" // Al devolver valor, el 'else' es OBLIGATORIO
}
println("Debes: $accion")

Rangos: preparando el terreno para los bucles

Antes de aprender a repetir tareas, necesitamos saber cómo crear Rangos (intervalos de valores) en Kotlin. Es facilísimo:

  • .. (Punto punto): Crea un rango que incluye el último número. 1..4 equivale a 1, 2, 3, 4.
  • ..< (Punto punto menor): Crea un rango que excluye el último número. 1..<4 equivale a 1, 2, 3.
  • downTo: Cuenta hacia atrás. 4 downTo 1 equivale a 4, 3, 2, 1.
  • step: Cambia el tamaño del salto. 1..5 step 2 equivale a 1, 3, 5.

(¡También funciona con letras del abecedario! Ej: 'a'..'d')

Bucles: repitiendo tareas sin cansarse

El bucle for (para cada…)

Se usa cuando sabes exactamente cuántas veces quieres repetir algo, o cuando quieres recorrer una Colección (como las Listas que vimos en el artículo anterior).

fun main() {
    // Repetir un código un número exacto de veces usando un rango
    for (numero in 1..5) { 
        print(numero) // Imprime: 12345
    }

    println() // Salto de línea

    // Recorrer una lista
    val pasteles = listOf("Zanahoria", "Queso", "Chocolate")
    for (pastel in pasteles) {
        println("¡Qué rico, un pastel de $pastel!")
    }
}

Los bucles while y do-while (mientras que…)

Se usan cuando no sabes cuántas veces se va a repetir algo, pero sabes que debe repetirse «mientras» se cumpla una condición.

  • while: Primero comprueba la condición. Si es falsa desde el principio, nunca se ejecuta.
  • do-while: Primero ejecuta el código una vez, y luego comprueba la condición. Te asegura que el bloque de código se va a ejecutar como mínimo una vez.
fun main() {
    var porcionesComidas = 0
    
    // Bucle while normal
    while (porcionesComidas < 3) {
        println("Me como una porción")
        porcionesComidas++ // Esto suma 1 a la variable (es lo mismo que porcionesComidas = porcionesComidas + 1)
    }
}

Ejercicios

Abre tu Kotlin Playground y vamos a machacar lo aprendido.

Los dados

Crea un minijuego donde ganas si al lanzar dos dados sacas el mismo número. Si son iguales, imprime "¡Has ganado :)". Si no, "Has perdido :(".

(Nota: Para generar números aleatorios usaremos una librería nativa de Kotlin llamada Random).

import kotlin.random.Random

fun main() {
    // Genera un número aleatorio entre 0 y 5.
    val dado1 = Random.nextInt(6) 
    val dado2 = Random.nextInt(6)
    
    println("Dado 1: $dado1 | Dado 2: $dado2")
    // Escribe tu código (if/else) a partir de aquí:
    
}

Botones de consola

Usando un when que actúe como expresión (asignando su resultado o metiéndolo directo en un println), haz que el programa imprima la acción correspondiente al botón pulsado:

  • A -> «Sí»
  • B -> «No»
  • X -> «Menú»
  • Y -> «Nada»
  • Cualquier otro -> «No existe ese botón»
fun main() {
    val boton = "A"
    
    // Escribe tu when aquí dentro del println
    println(
        // ...
    )
}

Comiendo Pizza

Tienes un código muy feo que cuenta porciones de pizza repitiendo líneas a mano. Conviértelo en un bucle while que cuente automáticamente hasta llegar a las 8 porciones.

fun main() {
    var porciones = 0
    // ¡Borra este desastre y usa un bucle while!
    porciones++
    println("Solo hay $porciones porción/es de pizza :(")
    porciones++
    println("Solo hay $porciones porción/es de pizza :(")
    // ... así hasta 7 ...
    
    // Al salir del bucle debe imprimir esto (y la variable debe valer 8):
    println("¡Tenemos $porciones porciones! ¡Una pizza entera! :D")
}

El clásico reto FizzBuzz

Este es uno de los ejercicios más famosos en las entrevistas de programación junior.

Escribe un programa que imprima los números del 1 al 100, pero:

  • Si el número es divisible por 3, imprime la palabra "fizz" en lugar del número.
  • Si el número es divisible por 5, imprime "buzz".
  • Si el número es divisible por 3 Y por 5 (es decir, divisible por 15), imprime "fizzbuzz".

Pista: Usa un bucle for del 1 al 100. Dentro, usa un when sin argumento para evaluar condiciones usando el operador Módulo % (que te da el resto de una división. Si numero % 3 == 0, es que es divisible por 3).

fun main() {
    // Escribe tu código aquí (for + when)
}

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con el código.

Los dados

import kotlin.random.Random

fun main() {
    val dado1 = Random.nextInt(6)
    val dado2 = Random.nextInt(6)
    println("Dado 1: $dado1 | Dado 2: $dado2")
    
    // Comprobamos la igualdad con ==
    if (dado1 == dado2) {
        println("¡Has ganado :)")
    } else {
        println("Has perdido :(")
    }
}

Botones de consola

fun main() {
    val boton = "A"
    
    println(
        when (boton) {
            "A" -> "Sí"
            "B" -> "No"
            "X" -> "Menú"
            "Y" -> "Nada"
            else -> "No existe ese botón"
        }
    )
}

La pizza

fun main() {
    var porciones = 0
    
    while (porciones < 7) {
        porciones++
        println("Solo hay $porciones porción/es de pizza :(")
    }
    
    porciones++ // Sumamos la octava porción al salir
    println("¡Tenemos $porciones porciones! ¡Una pizza entera! :D")
}

El clásico reto FizzBuzz

fun main() {
    for (numero in 1..100) {
        println(
            // Al usar when SIN variable, podemos evaluar condiciones booleanas libres
            // ¡El orden importa! Hay que comprobar el 15 primero.
            when {
                numero % 15 == 0 -> "fizzbuzz"
                numero % 3 == 0 -> "fizz"
                numero % 5 == 0 -> "buzz"
                else -> numero // Si no cumple ninguna, imprime el número normal
            }
        )
    }
}

Guía Definitiva de colecciones en Kotlin: listas, conjuntos y mapas explicados desde cero

Introducción

En las unidades anteriores aprendimos a guardar un dato individual en una variable (como un número o un texto). Pero, ¿qué pasa si estás creando una app y necesitas guardar los nombres de 100 usuarios? ¿Vas a crear 100 variables distintas? ¡Por supuesto que no!

Para eso existen las colecciones. Son estructuras que nos permiten agrupar múltiples datos bajo un mismo nombre para procesarlos más tarde.

En Kotlin, la regla de seguridad que vimos con val y var se mantiene: las colecciones pueden ser de solo lectura (inmutables) o mutables (modificables). Kotlin nos ofrece tres tipos principales. ¡Vamos a destriparlas!

Listas (Lists): El cajón ordenado

Una Lista (List) es exactamente lo que imaginas: una colección de elementos ordenados uno detrás de otro.

  • Tienen un orden estricto (el primero, el segundo, el tercero…).
  • Permiten duplicados (puedes tener el mismo elemento varias veces).

Crear listas

Para crear una lista de solo lectura usamos listOf(). Si queremos una lista a la que podamos añadir o quitar cosas en el futuro, usamos mutableListOf().

fun main() { 
    // Lista de solo lectura (Kotlin deduce que es de tipo String)
    val formas = listOf("triángulo", "cuadrado", "círculo")
    println(formas) // [triángulo, cuadrado, círculo]
    
    // Lista mutable (Aquí le decimos explícitamente el tipo <String>)
    val formasMutables: MutableList<String> = mutableListOf("triángulo", "cuadrado", "círculo")
}

Profundizando: El índice cero y los errores comunes

Como las listas están ordenadas, cada elemento tiene una posición o «índice». ¡Ojo! En programación, siempre empezamos a contar desde el cero.

Piensa en los ascensores en España: cuando entras al edificio desde la calle, estás en la Planta Baja (índice 0). Si subes un piso, llegas a la Planta 1 (el índice 1, que en realidad es el segundo nivel). El índice te dice cuántos saltos das desde el principio.

fun main() { 
    val formas = listOf("triángulo", "cuadrado", "círculo")
    
    // Acceder por su posición (índice)
    println("El primer elemento es: ${formas[0]}") // triángulo
    
    // ¡CUIDADO! El terror de los novatos: IndexOutOfBoundsException
    // println(formas[3]) -> ¡El programa explotará porque no hay un 4º elemento!
}

Kotlin también nos regala funciones súper útiles para no tener que lidiar siempre con los números:

fun main() {
    val formas = listOf("triángulo", "cuadrado", "círculo")
    
    println("El primer elemento: ${formas.first()}") // triángulo
    println("El último elemento: ${formas.last()}") // círculo
    println("Total de elementos: ${formas.count()}") // 3
    
    // Comprobar si algo existe con la palabra 'in'
    println("círculo" in formas) // true
}

Modificar listas mutables y el «truco del candado»

Si tu lista es MutableList, usas .add() para añadir y .remove() para borrar.

val carrito: MutableList<String> = mutableListOf("Manzanas", "Pan")
carrito.add("Leche") // [Manzanas, Pan, Leche]
carrito.remove("Pan") // [Manzanas, Leche]

A veces tienes una lista mutable, pero se la vas a pasar a otra parte de tu código y no quieres que se modifique por accidente. Puedes «disfrazarla» de solo lectura:

val inventarioMutable: MutableList<String> = mutableListOf("Espada", "Poción")
val inventarioBloqueado: List<String> = inventarioMutable // ¡Candado puesto! Ahora no se puede modificar.

Conjuntos (Sets): El VIP de la exclusividad

Un conjunto (Set) es parecido a una lista, pero con dos diferencias vitales:

  • No tienen orden (no puedes pedir el elemento [0]).
  • NO permiten elementos duplicados. Son únicos y exclusivos.

Se crean con setOf() y mutableSetOf().

fun main() {
    // Fíjate que ponemos "cereza" dos veces
    val frutas = setOf("manzana", "plátano", "cereza", "cereza")
    
    // Al imprimir, ¡la segunda cereza ha desaparecido mágicamente!
    println(frutas) // [manzana, plátano, cereza]
    println("Hay ${frutas.count()} frutas únicas") // 3
}

Profundizando: ¿Por qué usar Sets? ¡Por la velocidad!

Si quieres saber si el email «[email protected]» está en una Lista de 1 millón de usuarios, el ordenador mirará uno a uno. Es lentísimo.

Si usas un Set, Kotlin usa matemáticas internas (una función Hash) para saber exactamente dónde está guardado. Lo encuentra al instante. ¡Usa Sets cuando trabajes con muchos datos únicos!

val millonesDeUsuarios = setOf("[email protected]", "[email protected]")
if ("[email protected]" in millonesDeUsuarios) {
    println("¡El usuario ya existe!") // Comprobación súper rápida
}

Mapas (Maps): El diccionario de datos

Un Mapa (Map) almacena datos en pares Clave-Valor (Key-Value).

Piénsalo como la carta de un restaurante: el nombre del plato es la clave y su precio es el valor.

  • Las claves (keys) deben ser únicas.
  • Los valores (values) sí pueden repetirse.

Se crean con mapOf() y mutableMapOf(). Usamos la palabrita to para unir la pareja.

fun main() {
    // Especificamos explícitamente: Claves String, Valores Int
    val menuZumos: MutableMap<String, Int> = mutableMapOf("manzana" to 3, "kiwi" to 4)
    
    // Leer un valor usando su clave entre corchetes
    println("El zumo de manzana cuesta: ${menuZumos["manzana"]}€") // 3€
}

Profundizando: Nulos y sobreescritura

¿Qué pasa si buscas algo que no existe en el menú? Kotlin te devuelve null (nulo, vacío). Así evita que tu aplicación se cuelgue por un error.

println(menuZumos["piña"]) // Imprime: null

¿Y si añado una clave que ya existe? Se sobreescribe el valor antiguo. Es la forma oficial de actualizar datos:

menuZumos["manzana"] = 5 // ¡Inflación! Actualizamos el precio a 5€
menuZumos["coco"] = 6    // Como "coco" no existe, lo añade nuevo al mapa
menuZumos.remove("kiwi") // Eliminamos el kiwi

println(menuZumos.keys)   // [manzana, coco]
println(menuZumos.values) // [5, 6]

Ejercicios

Abre el Kotlin Playground e intenta resolver estos ejercicios para asentar lo aprendido.

Sumando listas

Tienes una lista de números «verdes» y otra de «rojos». Imprime cuántos números hay en total sumando el tamaño de ambas listas.

fun main() {
    val numerosVerdes = listOf(1, 4, 23)
    val numerosRojos = listOf(17, 2)
    // Escribe tu código aquí:
}

El protocolo de red

Tienes un Set con protocolos en mayúsculas. Un usuario pide «smtp» en minúsculas. Comprueba si está soportado (debe devolver un Boolean).

Pista: Usa .uppercase() en la petición para pasarla a mayúsculas antes de buscar en el Set con in.

fun main() {
    val SOPORTADOS = setOf("HTTP", "HTTPS", "FTP")
    val peticion = "smtp"
    val estaSoportado = // Escribe tu código aquí 
    println("Soporte para $peticion: $estaSoportado")
}

Diccionario de números

Crea un Map que relacione los números del 1 al 3 con su nombre escrito («uno», «dos», «tres»). Imprime cómo se escribe el número 2 buscándolo en el mapa.

fun main() {
    val numeroAPalabra = // Escribe tu código aquí
    val n = 2
    // Imprime el resultado
}

El inventario del héroe

Crea una lista mutable llamada inventario con: «Espada», «Escudo», «Poción».

Añade un «Arco». Elimina la «Poción». Finalmente, imprime el inventario y cuántos objetos tiene usando .count().

Soluciones a los ejercicios

¡No mires hasta haberlo intentado!

Sumando listas

fun main() {
    val numerosVerdes = listOf(1, 4, 23)
    val numerosRojos = listOf(17, 2)
    val total = numerosVerdes.count() + numerosRojos.count()
    println("Total: $total números")
}

El protocolo de red

fun main() {
    val SOPORTADOS = setOf("HTTP", "HTTPS", "FTP")
    val peticion = "smtp"
    val estaSoportado = peticion.uppercase() in SOPORTADOS 
    println("Soporte para $peticion: $estaSoportado") // false
}

Diccionario de números

fun main() {
    val numeroAPalabra = mapOf(1 to "uno", 2 to "dos", 3 to "tres")
    val n = 2
    println("El número $n se escribe como '${numeroAPalabra[n]}'")
}

El inventario del héroe

fun main() {
    val inventario = mutableListOf("Espada", "Escudo", "Poción")
    inventario.add("Arco")
    inventario.remove("Poción")
    println("Inventario: $inventario")
    println("Tienes ${inventario.count()} objetos.")
}

Tipos básicos en Kotlin: Inferencia, números, textos y operaciones

Introducción

En la unidad anterior vimos cómo hacer nuestro primer «Hola Mundo» y cómo usar variables (val y var). Pero nos dejamos un detalle importantísimo en el tintero: ¿qué tipo de información estamos guardando exactamente en esas variables?

En Kotlin, absolutamente todo tiene un tipo. Los tipos son fundamentales porque le dicen al compilador (el «cerebro» que lee tu código) qué puedes y qué no puedes hacer con una variable. Por ejemplo, puedes multiplicar dos números, pero no puedes multiplicar dos palabras, ¿verdad?

Ahora vamos a desgranar los tipos básicos de Kotlin, a aprender a operar con ellos y a descubrir cómo Kotlin nos facilita la vida con su «magia» deductiva.

La inferencia de tipos: Kotlin es muy listo

Si recuerdas la unidad anterior, declarábamos variables así:

var clientes = 10

En ningún momento le dijimos a Kotlin: «Oye, que sepas que clientes es un número entero». Sin embargo, Kotlin lo supo al instante. Esta capacidad se llama Inferencia de Tipos (Type Inference).

Como a la variable clientes le asignamos un 10, Kotlin deduce inmediatamente que su tipo es numérico, concretamente un Int (entero). Gracias a esto, el compilador sabe que puedes realizar operaciones matemáticas con esta variable.

Mira este ejemplo de asignaciones compuestas (una forma abreviada de hacer matemáticas):

fun main() {
    var clientes = 10

    // Vienen 3 clientes más (clientes = clientes + 3)
    clientes += 3  // Ahora hay 13
    
    // Se van 5 clientes
    clientes -= 5  // Ahora hay 8
    
    // El negocio explota y multiplicamos los clientes por 2
    clientes *= 2  // Ahora hay 16
    
    // Dividimos a los clientes en 4 grupos
    clientes /= 4  // Ahora hay 4 en cada grupo

    println(clientes) 
    // Resultado: 4
}

Los tipos básicos de Kotlin: La lista completa

Aunque Kotlin infiere los tipos, a veces querrás (o necesitarás) ser explícito. Para declarar un tipo manualmente, se usan los dos puntos : después del nombre de la variable.

A continuación mostramos los tipos fundamentales.

Números enteros (sin decimales)

  • Int: Es el estándar para números sin decimales. (Ej: val año: Int = 2024)
  • Long: Se usa para números ridículamente grandes. Si un número es demasiado grande para un Int, Kotlin lo convertirá en Long automáticamente. También puedes forzarlo añadiendo una L mayúscula al final. (Ej: val estrellas: Long = 9876543210L)
  • Byte y Short: Se usan en casos muy específicos para ahorrar memoria con números pequeños. Raramente los usarás al empezar.
  • Nota: Kotlin también tiene versiones «Unsigned» (sin signo, es decir, solo positivos) como UInt o ULong marcados con una u (Ej: val puntos: UInt = 100u).

Números con decimales (coma flotante)

  • Double: Es el estándar de Kotlin para números con decimales. Tiene una precisión doble, es decir, admite muchísimos decimales. (Ej: val precio: Double = 19.99)
  • Float: Ocupa menos memoria pero es menos preciso. Para indicarle a Kotlin que quieres un Float y no un Double, debes añadir una f o F al final del número. (Ej: val temperatura: Float = 24.5f)

Booleanos (verdadero o falso)

  • Boolean: Solo puede tener dos valores: true (verdadero) o false (falso). Es la base de la lógica en programación (Ej: val estaEncendido: Boolean = true).

Caracteres y cadenas de texto

  • Char: Representa un único carácter (una sola letra, número o símbolo). Se escribe entre comillas simples ' '. (Ej: val inicial: Char = 'J')
  • String: Representa una cadena de texto (muchos caracteres juntos). Se escribe entre comillas dobles " ". (Ej: val mensaje: String = "¡Hola, mundo!")

Declarar ahora, inicializar después

A veces sabes qué tipo de dato vas a guardar, pero aún no tienes el valor exacto. Kotlin te permite declarar una variable y asignarle su valor más tarde.

Eso sí, en estos casos es obligatorio especificar el tipo explícitamente, porque Kotlin no tiene un valor inicial del que deducirlo:

fun main() {
    // Declaramos la variable especificando el tipo explícitamente, pero sin darle valor
    val d: Int 
    
    // Más adelante en el código, la inicializamos
    d = 3 

    println(d) // Imprime: 3
}

La seguridad de Kotlin frente a errores

¿Qué pasa si intentas imprimir la variable d antes de darle un valor? En otros lenguajes tu programa explotaría (el temido NullPointerException o imprimiría basura de la memoria). Kotlin no te deja hacerlo. El código directamente se pondrá en rojo y no compilará, mostrándote el error: «Variable ‘d’ must be initialized». ¡Un salvavidas enorme!

Ejercicios

Abre tu editor o el Kotlin Playground y pon a prueba lo que acabas de aprender.

El tipado explícito

Kotlin puede inferir los tipos, pero en este ejercicio queremos que seas tú quien los escriba. Modifica el siguiente código para añadir explícitamente (: Tipo) el tipo de dato correcto a cada variable:

fun main() {
    val a = 1000 
    val b = "mensaje de registro"
    val c = 3.14
    val d = 100_000_000_000_000 // Fíjate, los guiones bajos sirven para leer mejor los números grandes
    val e = false
    val f = '\n' // Esto es un carácter especial que representa un "salto de línea"
}

El cofre del tesoro

Crea un programa que simule el oro de un jugador en un videojuego usando una variable mutable (var) llamada monedas:

  1. El jugador empieza con 50 monedas.
  2. Encuentra un cofre mágico y su oro se multiplica por 3. (Usa asignación compuesta *=).
  3. Compra una espada que cuesta 80 monedas. (Usa -=).
  4. Imprime el resultado final: "Tras la aventura, te quedan X monedas".

Detecta el error

El siguiente código tiene un problema y Kotlin se quejará si intentas ejecutarlo. Corrígelo para que funcione y se imprima la edad correctamente.

fun main() {
    val edadUsuario: Int
    println("La edad del usuario es $edadUsuario")
    edadUsuario = 25
}

Cuidado con las divisiones

Si divides dos variables Int (ej: 10 / 3), Kotlin devuelve un número entero (3) y se come los decimales.

Crea dos variables llamadas dividendo (valor 10) y divisor (valor 3). Haz que sean de tipo Double explícitamente para que, al imprimirlas usando una plantilla de cadena, el resultado sea con decimales (3.3333333333333335).

Soluciones a los ejercicios

¡No mires hasta que no te hayas peleado un rato con el código!

El tipado explícito

fun main() {
    val a: Int = 1000 
    val b: String = "mensaje de registro"
    val c: Double = 3.14  // Al tener decimales y no tener 'f', es Double
    val d: Long = 100_000_000_000_000 // Es demasiado grande para ser Int
    val e: Boolean = false // Verdadero o falso
    val f: Char = '\n' // Comillas simples indican que es un solo Char
}

El cofre del tesoro

fun main() {
    var monedas = 50
    monedas *= 3
    monedas -= 80
    println("Tras la aventura, te quedan $monedas monedas") 
    // Imprimirá 70
}

Detecta el error

fun main() {
    val edadUsuario: Int
    edadUsuario = 25 // ¡Había que inicializarla ANTES de leerla!
    println("La edad del usuario es $edadUsuario")
}

Cuidado con las divisiones

fun main() {
    // Para forzar que un número entero se trate como decimal, ponemos .0
    val dividendo: Double = 10.0 
    val divisor: Double = 3.0
    
    println("El resultado exacto es ${dividendo / divisor}")
}

Primeros pasos con Kotlin: Tu primer «Hola mundo», variables y plantillas de texto

Introducción

Si estás empezando en el mundo del desarrollo (ya sea para Android o backend), Kotlin es uno de los lenguajes más modernos, limpios y seguros que puedes aprender hoy en día.

En esta unidad, vamos a dar los primeros pasos. Entenderemos cómo funciona la estructura básica de un programa, cómo almacenar información usando variables y cómo imprimir mensajes por pantalla de forma elegante. Al final, tendrás ejercicios prácticos para asentar lo aprendido.

El clásico «Hola mundo»

Por tradición, el primer programa que todo desarrollador escribe al aprender un lenguaje nuevo es el que imprime las palabras «¡Hola, mundo!» en la pantalla.

En Kotlin, se hace así de fácil:

fun main() {
    println("¡Hola, mundo!")
    // ¡Hola, mundo!
}

Analizando el código línea a línea:

  • fun: Es la palabra reservada (abreviatura de function) que usamos en Kotlin para declarar una función. Una función no es más que un bloque de código que realiza una tarea específica.
  • main(): No es una función cualquiera. Es el punto de entrada (entry point) de tu programa. Siempre que ejecutes una aplicación en Kotlin, el ordenador buscará esta función y empezará a ejecutar las instrucciones que haya dentro de ella.
  • { } (Las llaves): Todo lo que esté dentro de las llaves es el «cuerpo» de la función, es decir, las instrucciones que se van a ejecutar.
  • println(): Es una función nativa de Kotlin que coge lo que le pongas entre paréntesis (los «argumentos») y lo imprime por la salida estándar (la consola de tu pantalla). Al terminar de imprimir, añade un salto de línea (por eso termina en ln, de line). Si usas print() (sin el ln), el texto se imprimirá, pero el siguiente mensaje aparecerá pegado justo a la derecha, en la misma línea.

El dato: ¿Te has dado cuenta de algo? ¡No hay punto y coma (;) al final de la línea! En lenguajes más antiguos como Java o C++, olvidar el punto y coma era un dolor de cabeza. Kotlin es un lenguaje moderno y no los necesita.

Variables: Guardando información en memoria

Cualquier programa informático necesita almacenar datos (nombres de usuario, puntuaciones de un juego, precios…). Para eso utilizamos las variables.

En Kotlin, tenemos dos formas principales de crear variables, y esto es muy importante:

  1. Variables de solo lectura (inmutables) con val (de value).
  2. Variables mutables con var (de variable).

Para asignarle un valor a una variable, simplemente usamos el operador de asignación =. Fíjate en este ejemplo:

fun main() { 
    val palomitas = 5   // Tenemos 5 cajas de palomitas
    val perritos = 7    // Tenemos 7 perritos calientes
    var clientes = 10   // Hay 10 clientes en la cola
    
    // De repente, un par de clientes se cansan de esperar y se van de la cola
    clientes = 8
    println(clientes) 
    // Resultado en pantalla: 8
}

val vs var: ¿Cuál debería usar?

Como clientes se declaró con var, pudimos reasignarle un nuevo valor (8) más adelante en el programa. Sin embargo, si intentáramos hacer palomitas = 6, el compilador de Kotlin nos daría un error, porque palomitas es un val y no puede cambiar una vez se le ha dado un valor inicial.

La regla de oro en Kotlin: Utiliza siempre val por defecto. Usa var única y exclusivamente cuando sepas seguro que ese valor va a tener que cambiar en el futuro (como un contador, o la vida de un personaje en un juego). Esto hace que tu código sea mucho más seguro, predecible y libre de errores accidentales (los temidos bugs).

Plantillas de cadenas (String Templates): Texto inteligente

En programación, es súper común querer imprimir un texto que contenga el valor de nuestras variables. En otros lenguajes tendrías que «sumar» o concatenar trozos de texto con el símbolo +, lo cual queda feo y es fácil equivocarse.

Kotlin lo soluciona de forma magistral con las String Templates (plantillas de cadenas).

Una cadena de texto (String) se escribe siempre entre comillas dobles " ". Para inyectar el valor de una variable dentro de ese texto, solo tienes que poner el símbolo del dólar $ seguido del nombre de la variable.

Y si lo que quieres es hacer una operación matemática u otro código más complejo dentro del texto, lo metes entre llaves ${ }. Mira por ejemplo el siguiente código:

fun main() { 
    val clientes = 10
    
    // Imprimiendo una variable directamente
    println("Actualmente hay $clientes clientes esperando.")
    // Salida: Actualmente hay 10 clientes esperando.
    
    // Haciendo una operación matemática dentro del texto
    println("Si llega uno más, habrá ${clientes + 1} clientes en total.")
    // Salida: Si llega uno más, habrá 11 clientes en total.
}

La magia oculta: La inferencia de tipos

Si prestas atención, en ningún momento le hemos dicho a Kotlin que clientes es un número. Kotlin es muy listo: al ver que le asignamos un 10, él automáticamente infiere (deduce) que el tipo de dato es un número entero (Int).

Ejercicios

La programación solo se aprende tecleando. Aquí tienes varios ejercicios de menos a más dificultad. Intenta resolverlos por tu cuenta en un editor o en el Kotlin Playground antes de mirar las soluciones más abajo.

Presentando a Mary

Completa el siguiente código para que el programa imprima por consola el mensaje exacto: "Mary tiene 20 años". Debes usar obligatoriamente las variables dadas y las plantillas de cadenas ($).

fun main() {
    val nombre = "Mary"
    val edad = 20
    // Escribe tu código debajo de esta línea:
    
}

Batería del móvil

Crea un pequeño programa que simule la batería de tu móvil.

  1. Declara una variable llamada bateria que empiece al 100%. (Piensa si debe ser val o var).
  2. Imprime el texto: "La batería inicial es del 100%".
  3. Simula que juegas a un videojuego y la batería baja a 75. Actualiza el valor de la variable.
  4. Vuelve a imprimir el texto: "Tras jugar, la batería es del 75%".

Calculadora de la compra inteligente

Declara dos variables inmutables: precioZapatillas con un valor de 50, y cantidadCajas con un valor de 2.

Utilizando un único println() y las llaves de expresión ${}, imprime el siguiente mensaje calculando el total sobre la marcha:

"Has comprado 2 pares de zapatillas. El precio total es de 100 euros."

¿print o println?

Imagina que quieres que la consola muestre exactamente esto en dos líneas:

Hola Mundo
¡Amo Kotlin!

Escribe el código usando tres instrucciones de impresión para conseguir esa salida exacta. Haz que la palabra «Hola» y «Mundo» se impriman en instrucciones separadas pero se queden en la misma línea.

Soluciones a los ejercicios

¡No hagas trampas! Solo mira esto si ya has intentado resolverlos.

Presentando a Mary

fun main() {
    val nombre = "Mary"
    val edad = 20
    println("$nombre tiene $edad años")
}

Batería del móvil

fun main() {
    // Usamos 'var' porque la batería va a cambiar a lo largo del tiempo
    var bateria = 100
    println("La batería inicial es del $bateria%")
    
    bateria = 75
    println("Tras jugar, la batería es del $bateria%")
}

Calculadora de la compra inteligente

fun main() {
    val precioZapatillas = 50
    val cantidadCajas = 2
    
    // Usamos ${} para multiplicar variables dentro del mismo texto
    println("Has comprado $cantidadCajas pares de zapatillas. El precio total es de ${precioZapatillas * cantidadCajas} euros.")
}

¿print o println?

fun main() {
    print("Hola ")       // Usa print, por lo que la siguiente palabra se pega a esta. (Fíjate en el espacio al final)
    println("Mundo")     // Se pega al 'Hola ', y luego hace un salto de línea por el 'ln'
    println("¡Amo Kotlin!") // Se imprime en la nueva línea
}

Programación con Kotlin nivel básico

Primeros pasos

Tipos de datos básicos

Colecciones (listas, conjuntos y mapas)

Control de flujo

Funciones y lambdas

Clases y objetos

Seguridad contra nulos

Programando el juego Space Invaders en Java paso a paso

Introducción

¿Alguna vez te has preguntado cómo funcionan las entrañas de un videojuego clásico? Hoy no vamos a usar librerías gráficas pesadas como Unity o Unreal. Vamos a bajar al metal, al código puro. Vamos a construir un juego funcional para Space Invaders utilizando únicamente la consola de Java.

Aprenderemos sobre Herencia, Polimorfismo, Ciclos de Juego (Game Loops) y Detección de Colisiones en una rejilla.

En este enlace puedes encontrar una presentación explicando el código, y a continuación dispones de una infografía y un vídeo explicativo.

Control de entrada con Enum

En lugar de esparcir caracteres mágicos ('a', 'd', 'f') por todo el código, vamos a profesionalizar la entrada del usuario. Usaremos un Enum.

¿Y por qué un Enum? Un enumerado nos permite definir un tipo de dato que tiene un conjunto fijo de constantes. Esto hace que el código sea seguro (no puedes pasar una tecla que no exista) y legible. Además, encapsulamos la lógica: cada tecla sabe cuál es su descripción y su carácter asociado. Fíjate por ejemplo en el método detectar. Recorre todos los valores posibles y compara. Si el usuario escribe «I» o «i», el juego entenderá Input.IZQUIERDA independientemente de lo que pase después.

import java.util.Scanner;

/**
 * Enumeración que gestiona el control de entrada del usuario.
 * Define las teclas válidas y asocia cada una con una acción del juego.
 */
enum Input {
    IZQUIERDA("i", "Izquierda"), 
    DERECHA("d", "Derecha"), 
    FUEGO("f", "FUEGO"), 
    SALIR("x", "Salir"), 
    NADA("", ""); 

    private String tecla;
    private String descripcion;

    /**
     * Constructor del enum Input.
     * @param tecla Carácter que activa la acción.
     * @param descripcion Nombre legible de la acción.
     */
    Input(String tecla, String descripcion) { 
        this.tecla = tecla; 
        this.descripcion = descripcion;
    }

    /**
     * Imprime en la consola la lista de controles disponibles para el jugador.
     */
    public static void imprimirControles() {
        for (Input i : Input.values()) {
            if (!i.tecla.isEmpty()) System.out.print("[" + i.tecla + "]" + i.descripcion + "  ");
        }
        System.out.print("\nAcción > ");
    }

    /**
     * Detecta qué comando corresponde al texto introducido por el usuario.
     * @param texto Entrada del teclado.
     * @return La instancia de Input correspondiente o NADA si no coincide.
     */
    public static Input detectar(String texto) {
        for (Input i : Input.values()) {
            if (i.tecla.equalsIgnoreCase(texto)) return i;
        }
        return NADA;
    }
}

Herencia y polimorfismo

Aplicamos el principio DRY (Don’t Repeat Yourself). Una Nave, un Alien y una Bala son cosas muy distintas, pero comparten cierta información: todos tienen una coordenada X, una coordenada Y y un icono para dibujarse.

La clase Entidad

Esta clase define lo que es un objeto en nuestro juego. Además, incluye una lógica de protección: el método mover base se asegura de que ningún objeto se salga del mapa usando Math.max y Math.min.

/**
 * Clase base para todos los objetos del juego (Nave, Alien, Bala).
 * Contiene la posición y el icono representativo.
 */
class Entidad {
    protected int x, y;
    protected char icono;

    public Entidad(int x, int y, char icono) {
        this.x = x; 
        this.y = y; 
        this.icono = icono;
    }

    /**
     * Actualiza la posición de la entidad y aplica restricciones de los límites del mapa.
     * @param accion La acción de entrada del usuario.
     */
    public void mover(Input accion) {
        // Restringir X entre 0 y ANCHO - 1
        x = Math.max(0, Math.min(x, JuegoInvasores.ANCHO - 1));
        // Restringir Y entre -1 y ALTO
        y = Math.max(-1, Math.min(y, JuegoInvasores.ALTO));
    }
}

Las especializaciones: Nave, Alien y Bala

Aquí es donde brilla la Orientación a Objetos. Cada clase sobrescribe (@Override) el método mover para comportarse de forma distinta, pero el motor del juego las tratará a todas igual (como Entidad).

  1. Nave: Solo reacciona si el jugador pulsa Izquierda o Derecha.
  2. Alien: Ignora al jugador y siempre baja (y++).
  3. Bala: Ignora al jugador y siempre sube (y--).
class Nave extends Entidad {
    public Nave(int x, int y) { super(x, y, 'A'); }
    
    @Override
    public void mover(Input accion) {
        if (accion == Input.IZQUIERDA) x--;
        if (accion == Input.DERECHA)   x++;
        super.mover(accion); // Importante: Llamamos al padre para que verifique los bordes
    }
}

class Alien extends Entidad {
    public Alien(int x, int y) { super(x, y, 'V'); }
    
    @Override
    public void mover(Input accion) {
        y++; // El alien cae por gravedad
        super.mover(accion);
    }
}

class Bala extends Entidad {
    public Bala(int x, int y) { super(x, y, '|'); }
    
    @Override
    public void mover(Input accion) {
        y--; // La bala desafía la gravedad
        super.mover(accion);
    }
}

Gestión de memoria y bucle principal

En Java, especialmente en juegos simples, un Array es la estructura más eficiente. Definimos un array Entidad[] objetos que contendrá todo lo que existe en el universo del juego.

El Bucle de Juego (Game Loop) es infinito (while(jugando)) y sigue estos pasos:

  1. Render: Dibujar el mundo.
  2. Input: Leer al usuario.
  3. Update: Mover cosas y disparar.
  4. Collision: Verificar reglas del juego.

Inicialización y disparo

Observa cómo manejamos el disparo. No creamos listas dinámicas (ArrayList) para simplificar. Buscamos un hueco null en el array y metemos la bala ahí. Es una técnica de «Object Pooling» primitiva pero eficaz.

public class JuegoInvasores {
    static final int ANCHO = 10, ALTO = 12;
    static final int MAX_ALIENS = 2, MAX_ENTIDADES = 30;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Entidad[] objetos = new Entidad[MAX_ENTIDADES];

        // Inicializamos la Nave y los Aliens
        objetos[0] = new Nave(ANCHO / 2, ALTO - 1);
        for (int i = 1; i <= MAX_ALIENS; i++) {
            objetos[i] = new Alien((int)(Math.random() * ANCHO), 0);
        }

        boolean jugando = true;
        while (jugando) {
            dibujarJuego(objetos);
            Input.imprimirControles(); 
            Input accion = Input.detectar(scanner.nextLine());

            if (accion == Input.SALIR) {
                jugando = false;
            } else {
                // LOGICA DE DISPARO
                if (accion == Input.FUEGO) {
                    boolean balaDisparada = false; 
                    // Buscamos un hueco vacío en el array para crear la bala
                    for (int i = 0; i < objetos.length && !balaDisparada; i++) {
                        if (objetos[i] == null && objetos[0] != null) {
                            objetos[i] = new Bala(objetos[0].x, objetos[0].y);
                            balaDisparada = true; 
                        }
                    }
                }

                // ACTUALIZACION DE POSICIONES (POLIMORFISMO)
                for (Entidad e : objetos) {
                    if (e != null) e.mover(accion);
                }
                
                // ... (Ver siguiente sección)

Detección de Colisiones

Esta es la parte más compleja matemáticamente. En un juego por turnos en consola, una bala se mueve de la casilla 5 a la 4, y un alien de la 3 a la 4. ¿Pero qué pasa si se cruzan? Hemos implementado una verificación predictiva. Comprobamos si la bala está en la misma casilla que el alien (posibleBala.y == alien.y) O si acaba de cruzarlo (posibleBala.y == alien.y - 1).

    // ... dentro del bucle main
    int estadoJuego = procesarColisiones(objetos);
    if (estadoJuego == -1) {
        dibujarJuego(objetos);
        System.out.println("\n¡GAME OVER!");
        jugando = false;
    } else if (estadoJuego == 0) {
        dibujarJuego(objetos);
        System.out.println("\n¡VICTORIA!");
        jugando = false;
    }

Funciones de lógica del juego

Aquí se decide quién vive y quién muere. Usamos instanceof para saber qué tipo de entidad estamos comprobando.

    static int procesarColisiones(Entidad[] objetos) {
        int aliensVivos = 0;
        boolean invasion = false;

        for (int i = 0; i < objetos.length; i++) {
            Entidad ent = objetos[i];
            if (ent != null) {
                
                // Limpieza: Si algo se sale del mapa, lo borramos (ponemos a null)
                if (ent.y < 0 || ent.y >= ALTO) {
                    if (!(ent instanceof Nave)) objetos[i] = null;
                } else {
                    if (ent instanceof Alien) {
                        // Si un alien toca el fondo, perdemos
                        if (ent.y >= ALTO - 1) invasion = true;
                        
                        // Verificamos si este alien ha chocado con alguna bala
                        boolean impactado = verificarImpacto(ent, objetos);

                        if (impactado) {
                            objetos[i] = null; // Eliminamos el Alien
                            System.out.println("¡ALIEN DESTRUIDO!");
                        } else {
                            aliensVivos++;
                        }
                    }
                }
            }
        }
        if (invasion) return -1;
        return aliensVivos;
    }

    // Algoritmo de colisión cruzada
    static boolean verificarImpacto(Entidad alien, Entidad[] todosLosObjetos) {
        boolean impacto = false;
        for (int j = 0; j < todosLosObjetos.length; j++) {
            if (!impacto) {
                 Entidad posibleBala = todosLosObjetos[j];
                 if (posibleBala instanceof Bala 
                     && posibleBala.x == alien.x 
                     && (posibleBala.y == alien.y || posibleBala.y == alien.y - 1)) {
                    
                     todosLosObjetos[j] = null; // Eliminamos la Bala
                     impacto = true;
                 }
            }
        }
        return impacto;
    }

Dibujando la matriz

Finalmente, necesitamos ver lo que ocurre. La técnica usada aquí es crear un «Lienzo en blanco» (matriz de puntos), pintar las entidades encima y luego imprimirlo todo de golpe. Esto evita parpadeos y asegura que si dos objetos están en la misma casilla, uno se dibuje sobre el otro (el último en procesarse gana).

    static void dibujarJuego(Entidad[] lista) {
        // 1. Crear lienzo vacío
        char[][] matriz = new char[ALTO][ANCHO];
        for(int y=0; y<ALTO; y++) {
            for(int x=0; x<ANCHO; x++) {
                matriz[y][x] = '.';
            }
        }

        // 2. Pintar entidades
        for(Entidad e : lista) {
            if (e != null && e.y >= 0 && e.y < ALTO) {
                matriz[e.y][e.x] = e.icono;
            }
        }

        // 3. Imprimir en consola
        System.out.print("\n\n");
        for(int y=0; y<ALTO; y++) {
            for(int x=0; x<ANCHO; x++) {
                System.out.print(matriz[y][x] + " ");
            }
            System.out.println();
        }
    }
}

Todo el código

import java.util.Scanner;

/**
 * Enumeración que gestiona el control de entrada del usuario.
 * Define las teclas válidas y asocia cada una con una acción del juego.
 */
enum Input {
    IZQUIERDA("i", "Izquierda"), 
    DERECHA("d", "Derecha"), 
    FUEGO("f", "FUEGO"), 
    SALIR("x", "Salir"), 
    NADA("", ""); 

    private String tecla;
    private String descripcion;

    /**
     * Constructor del enum Input.
     * @param tecla Carácter que activa la acción.
     * @param descripcion Nombre legible de la acción.
     */
    Input(String tecla, String descripcion) { 
        this.tecla = tecla; 
        this.descripcion = descripcion;
    }

    /**
     * Imprime en la consola la lista de controles disponibles para el jugador.
     */
    public static void imprimirControles() {
        for (Input i : Input.values()) {
            if (!i.tecla.isEmpty()) System.out.print("[" + i.tecla + "]" + i.descripcion + "  ");
        }
        System.out.print("\nAcción > ");
    }

    /**
     * Detecta qué comando corresponde al texto introducido por el usuario.
     * @param texto Entrada del teclado.
     * @return La instancia de Input correspondiente o NADA si no coincide.
     */
    public static Input detectar(String texto) {
        for (Input i : Input.values()) {
            if (i.tecla.equalsIgnoreCase(texto)) return i;
        }
        return NADA;
    }
}

// ========================================================
// 2. CLASES (HERENCIA Y COMPORTAMIENTO BASE)
// ========================================================

/**
 * Clase base para todos los objetos del juego (Nave, Alien, Bala).
 * Contiene la posición y el icono representativo.
 */
class Entidad {
    protected int x, y;
    protected char icono;

    /**
     * Constructor de una Entidad.
     * @param x Posición horizontal inicial.
     * @param y Posición vertical inicial.
     * @param icono Carácter que representa a la entidad en el mapa.
     */
    public Entidad(int x, int y, char icono) {
        this.x = x; 
        this.y = y; 
        this.icono = icono;
    }

    /**
     * Actualiza la posición de la entidad y aplica restricciones de los límites del mapa.
     * @param accion La acción de entrada del usuario (opcional según el tipo de entidad).
     */
    public void mover(Input accion) {
        // Restringir X entre 0 y ANCHO - 1
        x = Math.max(0, Math.min(x, JuegoInvasores.ANCHO - 1));

        // Restringir Y entre -1 (para que las balas salgan) y ALTO
        y = Math.max(-1, Math.min(y, JuegoInvasores.ALTO));
    }
}

/**
 * Representa la nave controlada por el jugador.
 */
class Nave extends Entidad {
    /**
     * Crea una nave en una posición específica con el icono 'A'.
     * @param x Posición horizontal.
     * @param y Posición vertical.
     */
    public Nave(int x, int y) { 
        super(x, y, 'A'); 
    }
    
    /**
     * Mueve la nave horizontalmente según el input.
     * @param accion Dirección del movimiento (IZQUIERDA o DERECHA).
     */
    @Override
    public void mover(Input accion) {
        if (accion == Input.IZQUIERDA) x--;
        if (accion == Input.DERECHA)   x++;
        super.mover(accion); // Ejecuta el control de límites de la clase Entidad
    }
}

/**
 * Representa a los enemigos que descienden por la pantalla.
 */
class Alien extends Entidad {
    /**
     * Crea un alien con el icono 'V'.
     * @param x Posición horizontal.
     * @param y Posición vertical.
     */
    public Alien(int x, int y) { 
        super(x, y, 'V'); 
    }
    
    /**
     * Mueve al alien una posición hacia abajo en cada turno.
     * @param accion No se utiliza para el alien, ya que su movimiento es automático.
     */
    @Override
    public void mover(Input accion) {
        y++; // El alien siempre baja
        super.mover(accion);
    }
}

/**
 * Representa el proyectil disparado por la nave.
 */
class Bala extends Entidad {
    /**
     * Crea una bala con el icono '|'.
     * @param x Posición horizontal.
     * @param y Posición vertical.
     */
    public Bala(int x, int y) { 
        super(x, y, '|'); 
    }
    
    /**
     * Mueve la bala una posición hacia arriba en cada turno.
     * @param accion No se utiliza para la bala.
     */
    @Override
    public void mover(Input accion) {
        y--; // La bala siempre sube
        super.mover(accion);
    }
}

// ========================================================
// 3. MOTOR DEL JUEGO
// ========================================================

/**
 * Clase principal que contiene el bucle del juego y la lógica de renderizado.
 */
public class JuegoInvasores {
    static final int ANCHO = 12, ALTO = 12;
    static final int MAX_ALIENS = 3, MAX_ENTIDADES = 30; // Limite total de entidades

    /**
     * Punto de entrada principal del programa.
     * @param args Argumentos de línea de comandos.
     */
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Entidad[] objetos = new Entidad[MAX_ENTIDADES];

        // --- INICIO ---
        objetos[0] = new Nave(ANCHO / 2, ALTO - 1);
        for (int i = 1; i <= MAX_ALIENS; i++) {
            objetos[i] = new Alien((int)(Math.random() * ANCHO), 0);
        }

        boolean jugando = true;
        while (jugando) {
            dibujarJuego(objetos);
            Input.imprimirControles(); 
            Input accion = Input.detectar(scanner.nextLine());

            if (accion == Input.SALIR) {
                jugando = false;
            } else {
                // --- DISPARAR (Crea una bala en la posición de la nave) ---
                if (accion == Input.FUEGO) {
                    boolean balaDisparada = false; 
                    for (int i = 0; i < objetos.length && !balaDisparada; i++) {
                        if (objetos[i] == null) {
                            if (objetos[0] != null) {
                                objetos[i] = new Bala(objetos[0].x, objetos[0].y);
                                balaDisparada = true; // Evita seguir buscando huecos
                            }
                        }
                    }
                }

                // --- ACTUALIZAR POSICIONES ---
                for (Entidad e : objetos) {
                    if (e != null) e.mover(accion);
                }

                // --- GESTIÓN DE COLISIONES Y ESTADO ---
                int estadoJuego = procesarColisiones(objetos);

                if (estadoJuego == -1) {
                    dibujarJuego(objetos);
                    System.out.println("\n¡GAME OVER!");
                    jugando = false;
                } else if (estadoJuego == 0) {
                    dibujarJuego(objetos);
                    System.out.println("\n¡VICTORIA!");
                    jugando = false;
                }
            }
        }
        scanner.close();
    }

    /**
     * Procesa la limpieza de proyectiles fuera del mapa, detecta invasiones y gestiona colisiones.
     * @param objetos Array que contiene todas las entidades activas del juego.
     * @return El número de aliens vivos, o -1 si el jugador ha perdido (invasión).
     */
    static int procesarColisiones(Entidad[] objetos) {
        int aliensVivos = 0;
        boolean invasion = false;

        for (int i = 0; i < objetos.length; i++) {
            Entidad ent = objetos[i];

            if (ent != null) {
                // 1. Limpieza de proyectiles o aliens fuera de rango
                if (ent.y < 0 || ent.y >= ALTO) {
                    if (!(ent instanceof Nave)) objetos[i] = null;
                } else {
                    // 2. Lógica específica para Aliens
                    if (ent instanceof Alien) {
                        
                        // Comprobar si ha llegado al final (invasión)
                        if (ent.y >= ALTO - 1) { 
                             invasion = true;
                        }
                        
                        // 3. Colisión Bala-Alien
                        // Pasamos el alien actual (ent) y la lista completa para buscar balas
                        boolean impactado = verificarImpacto(ent, objetos);

                        if (impactado) {
                            objetos[i] = null; // Borra Alien
                            System.out.println("¡ALIEN DESTRUIDO!");
                        } else {
                            // Solo contamos el alien si NO ha sido destruido
                            aliensVivos++;
                        }
                    }
                }
            }
        }

        if (invasion) return -1; // Código de derrota
        return aliensVivos;      // Retorna cuántos quedan
    }

    /**
     * Verifica si una bala ha impactado en un alien específico.
     * @param alien El alien que se va a comprobar.
     * @param todosLosObjetos Array completo para buscar balas activas.
     * @return true si el alien fue impactado por una bala, false en caso contrario.
     */
    static boolean verificarImpacto(Entidad alien, Entidad[] todosLosObjetos) {
        boolean impacto = false;
        
        for (int j = 0; j < todosLosObjetos.length; j++) {
            // Comprobamos solo si no hemos impactado ya en este ciclo (sin break)
            if (!impacto) {
                 Entidad posibleBala = todosLosObjetos[j];
                 
                 // Verificamos si es Bala y si las coordenadas coinciden
                 // Nota: (alien.y - 1) arregla el problema de colisiones cruzadas
                 if (posibleBala instanceof Bala 
                     && posibleBala.x == alien.x 
                     && (posibleBala.y == alien.y || posibleBala.y == alien.y - 1)) {
                    
                     todosLosObjetos[j] = null; // Borra Bala
                     impacto = true;   // Marca que hubo impacto
                 }
            }
        }
        return impacto;
    }

    /**
     * Dibuja el estado actual del juego en la consola mediante una matriz de caracteres.
     * @param lista Array de entidades a dibujar sobre el mapa.
     */
    static void dibujarJuego(Entidad[] lista) {
        char[][] matriz = new char[ALTO][ANCHO];
        for(int y=0; y<ALTO; y++) {
            for(int x=0; x<ANCHO; x++) {
                matriz[y][x] = '.';
            }
        }

        for(Entidad e : lista) {
            if (e != null && e.y >= 0 && e.y < ALTO) {
                matriz[e.y][e.x] = e.icono;
            }
        }

        System.out.print("\n\n");
        for(int y=0; y<ALTO; y++) {
            for(int x=0; x<ANCHO; x++) {
                System.out.print(matriz[y][x] + " ");
            }
            System.out.println();
        }
    }
}

Conclusión

Hemos construido un juego completo en unas pocas clases. Lo importante de este ejercicio no es el juego en sí, sino cómo hemos estructurado los datos:

  1. Entidades autónomas: La clase Nave no necesita saber cómo funciona el Alien.
  2. Control centralizado: El Main orquesta todo, pero delega el movimiento a cada objeto.
  3. Tipado fuerte: El uso de Enum previene errores tontos de teclado.

Programando el juego Hundir la flota en Java paso a paso

Introducción

En esta unidad, vamos a desglosar una implementación avanzada del juego clásico de estrategia naval. A diferencia de otros ejemplos que pueden ser más simples, este código introduce los siguientes conceptos a nivel más avanzado:

  1. Motor de renderizado ANSI: Manipulación de la consola del sistema operativo para mostrar colores.
  2. Modelo de datos robusto: Uso de enumerados (enum) para encapsular comportamiento y configuración.
  3. Algoritmos de colocación espacial: Lógica matemática para gestionar colisiones y perímetros de seguridad en una matriz.
  4. Niebla de guerra: Separación entre el estado interno del programa y lo que se muestra al usuario.

En este enlace puedes encontrar una presentación explicando el código, y a continuación dispones de una infografía y un vídeo explicativo.

Estructuras de datos: El uso de enum frente a enteros

Implementación en el enum TipoBarco

El objetivo principal de este enum es actuar como bloque de definición de datos para la configuración de la flota, persiguiendo los siguientes objetivos:

  • Centralización de reglas: Imagina que quieres cambiar las reglas del juego para que el submarino sea más grande (4 casillas en vez de 3). Si usaras variables sueltas dispersas por el código, tendrías que buscar y cambiar ese número en varios sitios. Con este enum, cambias el número en una sola línea (SUBMARINO(4)) y todo el juego (condiciones de victoria, lógica de colocación, etc.) se actualiza automáticamente.
  • Eliminación de «números mágicos»: En programación, encontrar un 5 suelto en el código es confuso (¿es la munición? ¿es el tamaño del tablero?). Al usar TipoBarco.PORTAAVIONES.getLongitud(), el código se explica por sí mismo.
  • Agrupación lógica: Vincula inseparablemente el nombre de la entidad (portaaviones) con su propiedad (tamaño 5). No puedes tener un portaaviones sin tamaño, ni un tamaño sin barco.

Cuando el programa arranca, Java crea automáticamente una única instancia para cada uno de estos nombres. Por ejemplo, al escribir PORTAAVIONES(5), Java está haciendo internamente algo similar a: new TipoBarco(5). Le está pasando el valor 5 al constructor. De esta forma, cada barco tiene su propia variable interna llamada longitud, con las siguientes características:

  • Es private: Nadie desde fuera puede modificarla directamente. Para acceder a su valor, utilizaremos un método público para que el resto del programa pueda preguntar: «¿Cuánto mide este barco?»
  • Es final: Una vez asignada, no puede cambiar. Un destructor siempre medirá 2 durante toda la ejecución del programa. Esto garantiza la integridad de las reglas.
    /**
     * Define los tipos de barcos y sus longitudes.
     * Centraliza la configuración: si cambiamos un número aquí, el juego se adapta.
     */
    enum TipoBarco {
        PORTAAVIONES(5),
        ACORAZADO(4),
        CRUCERO(3),
        SUBMARINO(3),
        DESTRUCTOR(2);

        private final int longitud;

        TipoBarco(int longitud) {
            this.longitud = longitud;
        }

        public int getLongitud() {
            return longitud;
        }
    }

Estructura de colores RGB en consola

Esta es una de las partes más interesantes del código. Java, por defecto, imprime texto plano en la consola. Sin embargo, los terminales modernos (CMD, PowerShell, Bash) soportan Secuencias de Escape ANSI.

Cuando imprimimos un carácter especial llamado ESCAPE (código ASCII 27, representado en Java como \u001B), le estamos diciendo a la consola: «Atención, lo que viene a continuación no es texto para leer, es una orden de configuración».

El código implementa el estándar RGB TrueColor con la estructura «\u001B[38;2;R;G;Bm«:

  • \u001B[: Inicio de la secuencia de control (CSI).
  • 38: Indica que vamos a cambiar el color del texto (foreground).
  • 2: Indica que usaremos el modo RGB (Red, Green, Blue).
  • R;G;B: Son tres números del 0 al 255 que definen la mezcla de color.
  • m: Indica el fin de la instrucción.

Implementación en el enum EstadoCasilla

En lugar de escribir estos códigos crípticos cada vez que queremos imprimir algo, los encapsulamos en el constructor del enum:

  1. Constructor: Recibe los valores enteros R, G y B.
  2. String.format: Construye la secuencia ANSI dinámica.
  3. toString: Sobrescribe el método estándar para devolver: COLOR + SÍMBOLO + RESET. El código de RESET (\u001B[0m) es vital; si no lo pusiéramos, toda la consola se quedaría pintada de ese color indefinidamente.
    /**
     * Motor de colores RGB.
     * Transforma códigos numéricos en secuencias de escape ANSI.
     */
    enum EstadoCasilla {
        // Definición semántica con sus valores visuales (Símbolo, R, G, B)
        AGUA     ( "~",      0, 180, 255),   // Agua (azul claro)
        BARCO    ( "B",    160,  32, 240),   // Púrpura (visible al acabar)
        TOCADO   ( "X",    255,   0,   0),   // Rojo puro (impacto)
        FALLO    ( "o",    180, 180, 180);   // Gris (agua con impacto)

        private final String simbolo;
        private final String codigoColor;
        // Secuencia para restablecer el color por defecto de la terminal
        private static final String RESET = "\u001B[0m";

        /**
         * Constructor que inyecta la lógica de color.
         * La secuencia mágica es: \u001B[38;2;R;G;Bm
         */
        EstadoCasilla(String simbolo, int r, int g, int b) {
            this.simbolo = simbolo;
            this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
        }

        @Override
        public String toString() {
            // Devuelve: [Instrucción Color] + [Carácter] + [Instrucción Reset]
            return codigoColor + simbolo + RESET;
        }
    }

Gestión de memoria y estado global

Para gestionar el tablero, utilizamos una matriz bidimensional de objetos EstadoCasilla.

  • static EstadoCasilla[][] oceano: Es la representación en memoria del mar. Cada celda [fila][columna] contiene una referencia a una de las constantes del Enum (AGUA, BARCO, etc.).
  • Cálculo dinámico de victoria: La constante IMPACTOS_NECESARIOS no se fija a mano (ej: 17). Se calcula llamando a una función que suma las longitudes de todos los barcos definidos. Esto hace el código extremadamente robusto: si mañana añadimos un nuevo barco al enum, la condición de victoria se recalcula sola sin tocar el resto del código.
    // CONSTANTES Y ESTADO GLOBAL

    static final int DIMENSION = 10; // Tamaño del tablero 10x10
    static final int NO_ENCONTRADO = -1; // Centinela para búsquedas fallidas

    // Configuración de dificultad
    static final int MUNICION_MAXIMA = 50;
    // Cálculo dinámico de la condición de victoria
    static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();

    // La Matriz Principal (El "Tablero")
    static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
    
    // Herramientas de entrada y aleatoriedad
    static Scanner teclado = new Scanner(System.in);
    static Random radar = new Random();

El ciclo de vida del juego

El método main se encarga de la ejecución temporal del programa. Podemos distinguir tres fases claras:

  1. Fase de inicialización:
    • Limpia el tablero (inicializarOceano).
    • Coloca los barcos sin que el jugador sepa dónde están (colocarFlotaCompleta).
  2. Fase de ejecución (bucle while):
    • Renderizado: Llama a imprimirOceano(false). El parámetro false activa la «niebla de guerra», ocultando los barcos.
    • Input: Solicita coordenadas y valida que no se repitan.
    • Lógica: Actualiza el estado de la matriz (de AGUA/BARCO a FALLO/TOCADO).
  3. Fase de clausura:
    • Al terminar (victoria o derrota), llama a imprimirOceano(true). El parámetro true desactiva la niebla y revela la ubicación de todos los barcos.
    public static void main(String[] args) {
        System.out.println("--- HUNDIR LA FLOTA ---");
        System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
        System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
        System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");

        // 1. Fase de Preparación
        inicializarOceano();
        colocarFlotaCompleta();

        // Variables de control de flujo
        int aciertos = 0;
        int misilesRestantes = MUNICION_MAXIMA;
        boolean juegoTerminado = false;

        // 2. Bucle Principal (Game Loop)
        while (!juegoTerminado) {
            // Renderizamos con Niebla de Guerra (false)
            imprimirOceano(false); 

            System.out.println("------------------------------------------------");
            System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);

            // Turno del jugador
            boolean impacto = realizarDisparo();
            misilesRestantes--;

            // Feedback visual inmediato
            if (impacto) {
                aciertos++;
                System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
            } else {
                System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
            }

            // 3. Comprobación de condiciones de fin
            if (aciertos == IMPACTOS_NECESARIOS) {
                juegoTerminado = true;
                imprimirOceano(true); // Revelamos el mapa (Cheat mode activado legalmente)
                System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");

            } else if (misilesRestantes == 0) {
                juegoTerminado = true;
                imprimirOceano(true);
                System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
            }
        }
    }

La meta del juego: calcularPuntosTotales()

Esta función es muy inteligente porque no usa un número «fijo» (como decir que se gana con 10 puntos). En su lugar, pregunta a los barcos cuánto miden.

  • TipoBarco.values(): Esto obtiene una lista de todos los tipos de barcos que has definido (Portaaviones, Submarino, etc.).
  • El bucle for: Recorre cada barco, mira su longitud (getLongitud()) y la suma a la variable suma.
  • ¿Por qué es útil?: Si mañana decides añadir un barco más o cambiar el tamaño de uno, no tienes que tocar el código de victoria; el programa calculará automáticamente que ahora hacen falta, por ejemplo, 17 puntos en lugar de 15.
    /**
     * Calcula dinámicamente cuántos puntos hacen falta para ganar.
     */
    static int calcularPuntosTotales() {
        int suma = 0;
        for (TipoBarco barco : TipoBarco.values()) {
            suma += barco.getLongitud();
        }
        return suma;
    }

El turno del jugador: realizarDisparo()

Esta función debe validar los datos introducidos por el usuario y actualizar el mapa. Se divide en dos bloques:

El bucle de validación (while)

El objetivo de este bloque es que el jugador no pierda el turno por un error en la introducción de datos o en la elección de la casilla donde quiere efectuar el disparo.

  1. Entrada de datos: Pide fila y columna.
  2. Comprobación de repetición: Mira en la matriz oceano[fila][col].
    • Si el valor es TOCADO o FALLO, significa que ya hubo un proyectil ahí.
    • Importante: Mientras el disparo no sea «nuevo», disparoValido sigue siendo false y el bucle se repite indefinidamente. El jugador no saldrá de aquí hasta que dé una coordenada válida donde no se haya disparado todavía.

Resolución del impacto (if-else)

Una vez que tenemos una coordenada válida, el programa decide qué hay debajo:

  • Si hay un BARCO:
    • Cambia el estado a TOCADO (para que el dibujo del mapa cambie).
    • return true: Esto le dice al resto del programa: «¡Oye, suma un punto al marcador!».
  • Si no hay barco (es decir, hay agua):
    • Cambia el estado a FALLO.
    • return false: El contador de impactos no cambia.

Conceptos clave de Java en este código

  • Enums (EstadoCasilla y TipoBarco): El código no usa números sueltos (como 0 o 1), sino nombres claros. Es mucho más fácil leer EstadoCasilla.TOCADO que leer un 2 y tener que recordar qué significaba.
  • Booleans como control: El uso de disparoValido actúa como un «cerrojo» para asegurar que la gestión del impacto se realice con datos correctos.
    /**
     * Procesa el turno de disparo.
     * Devuelve true si acertamos a un barco, false si damos en agua.
     */
    static boolean realizarDisparo() {
        boolean disparoValido = false;
        int fila = NO_ENCONTRADO;
        int col = NO_ENCONTRADO;

        // Bucle de validación de entrada lógica
        while (!disparoValido) {
            fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
            col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");

            // Evitar disparar dos veces al mismo sitio
            if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
                System.out.println("Ya has disparado en esa zona. Elige otra.");
            } else {
                disparoValido = true;
            }
        }

        // Resolución del impacto en la matriz
        if (oceano[fila][col] == EstadoCasilla.BARCO) {
            oceano[fila][col] = EstadoCasilla.TOCADO;
            return true;
        } else {
            oceano[fila][col] = EstadoCasilla.FALLO;
            return false;
        }
    }

Distribución de barcos: geometría y «regla del aire»

Esta es la sección más compleja algorítmicamente. El objetivo es colocar barcos aleatoriamente pero cumpliendo dos reglas estrictas:

  1. El barco no debe salirse del tablero.
  2. Regla del aire: El barco no puede tocar a otro, ni siquiera en diagonal. Debe haber al menos una casilla de agua de separación.

El algoritmo de «bounding box» (caja delimitadora)

En la función esPosicionValida, calculamos un rectángulo de seguridad. Por ejemplo, si el barco va de la fila F a la fila F+3 (tamaño 4), revisamos desde F-1 hasta F+4.

Para evitar errores de «índice fuera de rango» (por ejemplo, si intentáramos revisar la fila -1), utilizamos las funciones matemáticas Math.max(0, ...) y Math.min(DIMENSION-1, ...). Esto «recorta» el área de búsqueda a los límites reales del tablero.

    // ALGORITMOS DE COLOCACIÓN

    static void colocarFlotaCompleta() {
        for (TipoBarco barco : TipoBarco.values()) {
            colocarBarcoAleatorio(barco);
        }
    }

    /**
     * Algoritmo de Fuerza Bruta (Trial & Error).
     * Intenta coordenadas al azar hasta encontrar una válida.
     */
    static void colocarBarcoAleatorio(TipoBarco barco) {
        boolean colocado = false;

        while (!colocado) {
            int fila = radar.nextInt(DIMENSION);
            int col = radar.nextInt(DIMENSION);
            boolean horizontal = radar.nextBoolean();

            if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
                pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
                colocado = true;
            }
        }
    }

    /**
     * Valida si un barco cabe y respeta el perímetro de seguridad.
     */
    static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
        // 1. Determinar dimensiones del barco
        int anchoBarco = horizontal ? longitud : 1;
        int altoBarco  = horizontal ? 1 : longitud;

        // 2. Verificar límites del tablero
        if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
            return false;
        }

        // 3. Definir el "Marco de Seguridad" (Bounding Box)
        // Usamos Math.max y min para no salirnos de los índices 0-9
        int filaInicio = Math.max(0, f - 1);
        int colInicio  = Math.max(0, c - 1);

        int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
        int colFin  = Math.min(DIMENSION - 1, c + anchoBarco);

        // 4. Escaneo de Área
        for (int i = filaInicio; i <= filaFin; i++) {
            for (int j = colInicio; j <= colFin; j++) {
                // Si encontramos CUALQUIER COSA que no sea agua pura, la posición es inválida
                if (oceano[i][j] != EstadoCasilla.AGUA) {
                    return false; 
                }
            }
        }
        return true; // Zona despejada
    }

    static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
        for (int i = 0; i < longitud; i++) {
            if (horizontal) {
                oceano[f][c + i] = EstadoCasilla.BARCO;
            } else {
                oceano[f + i][c] = EstadoCasilla.BARCO;
            }
        }
    }

Renderizado y utilidades

Finalmente, necesitamos dibujar el tablero. La función imprimirOceano aplica el concepto de niebla de guerra.

  • Recorre la matriz celda por celda.
  • Si la celda contiene un BARCO y estamos en modo juego (revelarTodo == false), el programa miente al usuario e imprime el símbolo de AGUA.
  • Esto demuestra cómo separar los datos (lo que hay en memoria) de la vista (lo que ve el usuario).

También incluimos pedirCoordenada con hasNextInt() para evitar que el programa falle si el usuario introduce letras.

    // UTILIDADES Y VISTA

    static void inicializarOceano() {
        for (int f = 0; f < DIMENSION; f++) {
            for (int c = 0; c < DIMENSION; c++) {
                oceano[f][c] = EstadoCasilla.AGUA;
            }
        }
    }

    /**
     * Dibuja el tablero aplicando la lógica de ocultación.
     * @param revelarTodo Si es true, muestra los barcos (Game Over).
     */
    static void imprimirOceano(boolean revelarTodo) {
        System.out.println();

        // Eje de coordenadas X (Columnas)
        System.out.print("   ");
        for (int c = 0; c < DIMENSION; c++) {
            System.out.print(c + " ");
        }
        System.out.println();

        for (int f = 0; f < DIMENSION; f++) {
            System.out.print(f + "| "); // Eje de coordenadas Y (Filas)
            for (int c = 0; c < DIMENSION; c++) {
                EstadoCasilla actual = oceano[f][c];

                // LÓGICA DE NIEBLA DE GUERRA
                if (actual == EstadoCasilla.BARCO && !revelarTodo) {
                    // Si hay barco pero el juego sigue, ocultamos con agua
                    System.out.print(EstadoCasilla.AGUA + " ");
                } else {
                    // En cualquier otro caso, mostramos la realidad
                    System.out.print(actual + " ");
                }
            }
            System.out.println("|");
        }
    }

    /**
     * Lectura segura de enteros desde teclado.
     */
    static int pedirCoordenada(String mensaje) {
        int valor = NO_ENCONTRADO;
        boolean valido = false;

        while (!valido) {
            System.out.print(mensaje);
            if (teclado.hasNextInt()) {
                valor = teclado.nextInt();
                if (valor >= 0 && valor < DIMENSION) {
                    valido = true;
                } else {
                    System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
                }
            } else {
                teclado.next(); // Limpiar buffer de entrada errónea
                System.out.println("Error: Debes introducir un número entero.");
            }
        }
        return valor;
    }
}

Todo el código

import java.util.Random;
import java.util.Scanner;

/**
 * Juego de "Hundir la Flota" (Versión un jugador).
 * El objetivo es encontrar todos los barcos enemigos ocultos en el tablero
 * antes de que se agoten los intentos (misiles) disponibles.
 *
 * Este ejemplo refuerza el uso de matrices para ocultar información (niebla de guerra)
 * y la gestión de estados mediante Enums.
 */
public class Main {

    // --- ENUMS: CONFIGURACIÓN Y ESTÉTICA ---

    /**
     * Define los tipos de barcos y sus longitudes.
     */
    enum TipoBarco {
        PORTAAVIONES(5),
        ACORAZADO(4),
        CRUCERO(3),
        SUBMARINO(3),
        DESTRUCTOR(2);

        private final int longitud;

        TipoBarco(int longitud) {
            this.longitud = longitud;
        }

        public int getLongitud() {
            return longitud;
        }
    }

    /**
     * Enum que define los colores usando formato RGB (Red, Green, Blue).
     * Esto permite usar cualquier color de los 16 millones disponibles.
     */
    enum EstadoCasilla {
        // Símbolo y color en formato RGB
        AGUA     ( "~",      0, 180, 255),   // Azul cielo (agua)
        BARCO    ( "B",    160,  32, 240),   // Púrpura (barco sin impacto)
        TOCADO   ( "X",    255,   0,   0),   // Rojo puro (impacto)
        FALLO    ( "o",    180, 180, 180);   // Gris claro (fallo)

        private final String simbolo;
        private final String codigoColor;
        private static final String RESET = "\u001B[0m";

        /**
         * Constructor que acepta valores RGB (0-255).
         * Convierte los números a la secuencia ANSI TrueColor automáticamente.
         */
        EstadoCasilla(String simbolo, int r, int g, int b) {
            this.simbolo = simbolo;
            // La secuencia mágica para RGB es: \u001B[38;2;R;G;Bm
            this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
        }

        @Override
        public String toString() {
            return codigoColor + simbolo + RESET;
        }
    }

    // --- CONSTANTES Y ESTADO GLOBAL ---

    static final int DIMENSION = 10;
    static final int NO_ENCONTRADO = -1;

    // Configuración de dificultad
    static final int MUNICION_MAXIMA = 50;
    static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();

    // Matriz del tablero y herramientas
    static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
    static Scanner teclado = new Scanner(System.in);
    static Random radar = new Random();

    /**
     * Hilo principal de ejecución.
     * Gestiona el bucle de juego, el control de turnos y las condiciones de victoria/derrota.
     * @param args Argumentos de consola (no utilizados).
     */
    public static void main(String[] args) {
        System.out.println("--- HUNDIR LA FLOTA ---");
        System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
        System.out.println("Regla especial: Los barcos no pueden tocarse entre sí.");
        System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
        System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");

        // 1. Preparación
        inicializarOceano();
        colocarFlotaCompleta();

        // 2. Variables de estado
        int aciertos = 0;
        int misilesRestantes = MUNICION_MAXIMA;
        boolean juegoTerminado = false;

        // 3. Bucle Principal
        while (!juegoTerminado) {
            imprimirOceano(false); // false = Modo Niebla de Guerra

            System.out.println("------------------------------------------------");
            System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);

            // Turno de juego
            boolean impacto = realizarDisparo();
            misilesRestantes--;

            // Feedback inmediato
            if (impacto) {
                aciertos++;
                System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
            } else {
                System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
            }

            // Comprobación de fin de partida
            if (aciertos == IMPACTOS_NECESARIOS) {
                juegoTerminado = true;
                imprimirOceano(true); // Revelamos el mapa
                System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");

            } else if (misilesRestantes == 0) {
                juegoTerminado = true;
                imprimirOceano(true);
                System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
            }
        }
    }

    // --- LÓGICA DE JUEGO ---

    /**
     * Calcula dinámicamente cuántos aciertos hacen falta para ganar.
     * @return Total de casillas ocupadas por barcos.
     */
    static int calcularPuntosTotales() {
        int suma = 0;
        for (TipoBarco barco : TipoBarco.values()) {
            suma += barco.getLongitud();
        }
        return suma;
    }

    /**
     * Gestiona la interacción con el usuario para realizar un disparo.
     * Verifica que la coordenada sea válida y no se haya disparado antes allí.
     * @return true si el disparo acierta en un barco, false si falla.
     */
    static boolean realizarDisparo() {
        boolean disparoValido = false;
        int fila = NO_ENCONTRADO;
        int col = NO_ENCONTRADO;

        while (!disparoValido) {
            fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
            col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");

            if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
                System.out.println("Ya has disparado en esa zona. Elige otra.");
            } else {
                disparoValido = true;
            }
        }

        if (oceano[fila][col] == EstadoCasilla.BARCO) {
            oceano[fila][col] = EstadoCasilla.TOCADO;
            return true;
        } else {
            oceano[fila][col] = EstadoCasilla.FALLO;
            return false;
        }
    }

    // --- ALGORITMOS DE COLOCACIÓN (IA) ---

    /**
     * Recorre el catálogo de barcos y delega la colocación de cada uno.
     */
    static void colocarFlotaCompleta() {
        for (TipoBarco barco : TipoBarco.values()) {
            colocarBarcoAleatorio(barco);
        }
    }

    /**
     * Intenta colocar un barco en una posición aleatoria.
     * Si la posición elegida no es válida (choca, se toca con otro o se sale), repite el intento.
     * @param barco El tipo de barco a colocar.
     */
    static void colocarBarcoAleatorio(TipoBarco barco) {
        boolean colocado = false;

        while (!colocado) {
            int fila = radar.nextInt(DIMENSION);
            int col = radar.nextInt(DIMENSION);
            boolean horizontal = radar.nextBoolean();

            if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
                pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
                colocado = true;
            }
        }
    }

    /**
     * Verifica si el barco cabe y cumple la "Regla del Aire".
     * La regla del aire implica que no solo las casillas del barco deben estar libres,
     * sino también todas las casillas adyacentes (incluyendo diagonales).
     * @param f Fila inicial.
     * @param c Columna inicial.
     * @param longitud Tamaño del barco.
     * @param horizontal Orientación (true = horizontal, false = vertical).
     * @return true si el barco y su perímetro están libres.
     */
    static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
        // 1. Calculamos las dimensiones que ocupará el barco
        int anchoBarco = horizontal ? longitud : 1;
        int altoBarco  = horizontal ? 1 : longitud;

        // 2. Validar límites del tablero (Si se sale, devolvemos false)
        if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
            return false;
        }

        // 3. Definir el "Marco de Seguridad" (Barco + 1 casilla alrededor)
        // Usamos Math.max/min para no salirnos de los bordes (0 y 9)
        int filaInicio = Math.max(0, f - 1);
        int colInicio  = Math.max(0, c - 1);

        int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
        int colFin  = Math.min(DIMENSION - 1, c + anchoBarco);

        // 4. Escanear esa área buscando obstáculos
        for (int i = filaInicio; i <= filaFin; i++) {
            for (int j = colInicio; j <= colFin; j++) {
                if (oceano[i][j] != EstadoCasilla.AGUA) {
                    return false; // Colisión detectada (barco o vecino)
                }
            }
        }

        return true; // Todo limpio
    }

    /**
     * Escribe el barco en la matriz una vez validada la posición.
     */
    static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
        for (int i = 0; i < longitud; i++) {
            if (horizontal) {
                oceano[f][c + i] = EstadoCasilla.BARCO;
            } else {
                oceano[f + i][c] = EstadoCasilla.BARCO;
            }
        }
    }

    // --- UTILIDADES Y VISTA ---

    /**
     * Limpia el tablero llenándolo de agua.
     */
    static void inicializarOceano() {
        for (int f = 0; f < DIMENSION; f++) {
            for (int c = 0; c < DIMENSION; c++) {
                oceano[f][c] = EstadoCasilla.AGUA;
            }
        }
    }

    /**
     * Dibuja el tablero en consola.
     * Utiliza la lógica de "Niebla de Guerra".
     * @param revelarTodo true para mostrar la ubicación de los barcos (Game Over).
     */
    static void imprimirOceano(boolean revelarTodo) {
        System.out.println();

        // Cabecera de columnas
        System.out.print("   ");
        for (int c = 0; c < DIMENSION; c++) {
            System.out.print(c + " ");
        }
        System.out.println();

        for (int f = 0; f < DIMENSION; f++) {
            System.out.print(f + "| "); // Índice de fila
            for (int c = 0; c < DIMENSION; c++) {
                EstadoCasilla actual = oceano[f][c];

                // Si es un barco y estamos jugando, lo mostramos como agua
                if (actual == EstadoCasilla.BARCO && !revelarTodo) {
                    System.out.print(EstadoCasilla.AGUA + " ");
                } else {
                    System.out.print(actual + " ");
                }
            }
            System.out.println("|");
        }
    }

    /**
     * Pide un número entero al usuario de forma segura.
     * @param mensaje Texto a mostrar.
     * @return Un entero validado dentro del rango del tablero.
     */
    static int pedirCoordenada(String mensaje) {
        int valor = NO_ENCONTRADO;
        boolean valido = false;

        while (!valido) {
            System.out.print(mensaje);
            if (teclado.hasNextInt()) {
                valor = teclado.nextInt();
                if (valor >= 0 && valor < DIMENSION) {
                    valido = true;
                } else {
                    System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
                }
            } else {
                teclado.next(); // Limpiar el buffer
                System.out.println("Error: Debes introducir un número entero.");
            }
        }
        return valor;
    }
}