package com.rizoma.orbita.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.dp import com.rizoma.orbita.ui.theme.NucleoPaleta import kotlin.math.PI import kotlin.math.abs import kotlin.math.cos import kotlin.math.floor import kotlin.math.max import kotlin.math.pow import kotlin.math.sin import kotlin.random.Random /** * El núcleo del átomo: una figura que nunca es dos veces la misma. * * No hay dibujo guardado. La silueta es una curva polar: el radio en cada ángulo * es uno más la suma de tres armónicos —n, 2n y 3n puntas—. Como los tres son * múltiplos del mismo orden n, la figura sale siempre con simetría exacta de n * puntas: estrella cuando manda el primer armónico y va afilado, mandala cuando * los otros dos tienen peso y el pétalo es redondo. Moviendo solo las amplitudes * se pasa de una a otra sin un salto. * * Cambiar n sí sería un salto, así que hay **dos voces** sonando a la vez y una * mezcla que las reparte: mientras una manda, la otra está callada y es entonces * —invisible— cuando estrena orden. El relevo tarda unos minutos. * * Todo lo demás (amplitudes, fases, tamaño, filo, brillo y color) arranca en un * punto al azar y de ahí deriva: cada dos segundos cada parámetro se pone una meta * a una micra de donde está y se acerca a ella suavemente. En un minuto no se ve * cambiar nada; en media hora es otra figura, y no vuelve atrás. */ /** Órdenes de simetría que puede tomar el núcleo. */ private val ORDENES = intArrayOf(4, 5, 6, 8, 10, 12) private const val MUTACION_MS = 2_000f // cada cuánto se replantea la meta private const val SUAVIZADO_MS = 2_600f // lo que tarda en llegar a ella private const val REFRESCO_MS = 32f // 30 fps bastan para algo tan lento private const val RELEVO_MS = 900_000f // un cuarto de hora en pasar de una voz a la otra private const val TAU = (2 * PI).toFloat() /** Una voz: un orden de simetría con sus tres armónicos. */ class VozNucleo internal constructor( internal val orden: Int, internal val amp: FloatArray, internal val fase: FloatArray, ) { internal val suma: Float = amp.sum() } /** Una foto del núcleo en un instante: lo único que necesita el dibujo. */ class FormaNucleo internal constructor( internal val voz1: VozNucleo, internal val voz2: VozNucleo, /** Quién manda: 0 la primera voz, 1 la segunda. */ internal val mezcla: Float, /** Tamaño, como fracción del hueco disponible. */ internal val radio: Float, /** 0 = pétalo redondo (mandala) · 1 = punta afilada (estrella). */ internal val punta: Float, /** Cuánta luz da ahora mismo. */ val brillo: Float, /** Dónde cae en la paleta del rizoma. */ val tinte: Float, ) { /** El orden que se está viendo: manda el de la voz con más peso. */ internal val orden: Int get() = if (mezcla < 0.5f) voz1.orden else voz2.orden } /** Un parámetro a la deriva: nunca salta, siempre va hacia una meta cercana. */ private class Deriva( var valor: Float, private val min: Float, private val max: Float, /** La micra: lo más que puede alejarse la meta en cada mutación. */ private val micra: Float, ) { private var meta = valor fun mutar(rnd: Random) { meta = (valor + (rnd.nextFloat() * 2f - 1f) * micra).coerceIn(min, max) } fun avanzar(dt: Float) { valor += (meta - valor) * (dt / SUAVIZADO_MS).coerceIn(0f, 1f) } /** Solo para lo que está callado: ahí sí se puede saltar sin que se vea. */ fun saltar(v: Float) { valor = v meta = v } } /** Una voz a la deriva: su orden, el peso de cada armónico y lo que giran. */ private class Voz(rnd: Random, pariente: Int) { var orden = 6 private set private val peso = Array(3) { Deriva(0f, 0f, 1f, 0.05f) } private val fase = FloatArray(3) private val giro = Array(3) { Deriva(0f, -VUELTA, VUELTA, VUELTA * 0.15f) } init { renacer(rnd, pariente) } /** * Estrena orden y figura. Solo se llama con la voz callada, y el orden nuevo se * escoge emparentado con el que manda —que compartan divisor— para que mientras * las dos suenan a la vez la figura conserve simetría en vez de volverse un borrón. */ fun renacer(rnd: Random, pariente: Int) { val posibles = ORDENES.filter { it != pariente && mcd(it, pariente) > 1 } orden = if (posibles.isEmpty()) ORDENES[rnd.nextInt(ORDENES.size)] else posibles[rnd.nextInt(posibles.size)] peso[0].saltar(0.35f + rnd.nextFloat() * 0.65f) peso[1].saltar(rnd.nextFloat()) peso[2].saltar(rnd.nextFloat()) for (i in fase.indices) { fase[i] = rnd.nextFloat() * TAU giro[i].saltar((rnd.nextFloat() * 2f - 1f) * VUELTA) } } fun mutar(rnd: Random) { peso.forEach { it.mutar(rnd) } giro.forEach { it.mutar(rnd) } } fun avanzar(dt: Float) { peso.forEach { it.avanzar(dt) } giro.forEach { it.avanzar(dt) } for (i in fase.indices) fase[i] = (fase[i] + giro[i].valor * dt).mod(TAU) } fun foto(): VozNucleo { // Cuantas más puntas, menos hondas: si no, el núcleo se vuelve una pelusa. val techo = (0.34f * (6f / orden).pow(0.45f)).coerceIn(0.15f, 0.34f) return VozNucleo( orden = orden, amp = floatArrayOf( peso[0].valor * techo, peso[1].valor * techo * 0.50f, peso[2].valor * techo * 0.30f, ), fase = fase.copyOf(), ) } private companion object { /** Giro máximo de un armónico: una vuelta cada media hora. */ const val VUELTA = TAU / 1_800_000f } } /** * La deriva entera del núcleo. Arranca al azar —cada vez que Órbita nace, nace * también una figura distinta— y a partir de ahí solo se deja llevar. */ private class DerivaNucleo(private val rnd: Random) { private val voz1 = Voz(rnd, 0) private val voz2 = Voz(rnd, voz1.orden) private val radio = Deriva(0.60f + rnd.nextFloat() * 0.32f, 0.55f, 0.95f, 0.012f) private val punta = Deriva(rnd.nextFloat(), 0f, 1f, 0.03f) private val brillo = Deriva(0.55f + rnd.nextFloat() * 0.45f, 0.45f, 1f, 0.035f) // La deriva de color mantiene el rumbo del rizoma: una vuelta a la paleta en // un cuarto de hora, con el paso un poco vivo o un poco lento según el momento. private var tinte = rnd.nextFloat() * NucleoPaleta.size private val ritmo = Deriva(1f, 0.5f, 1.6f, 0.05f) private var mezcla = if (rnd.nextBoolean()) 0f else 1f private var hacia = 1f - mezcla private var descanso = reposo() private var desdeMutacion = 0f fun avanzar(dt: Float) { desdeMutacion += dt if (desdeMutacion >= MUTACION_MS) { desdeMutacion = 0f voz1.mutar(rnd) voz2.mutar(rnd) radio.mutar(rnd) punta.mutar(rnd) brillo.mutar(rnd) ritmo.mutar(rnd) } voz1.avanzar(dt) voz2.avanzar(dt) radio.avanzar(dt) punta.avanzar(dt) brillo.avanzar(dt) ritmo.avanzar(dt) tinte = (tinte + ritmo.valor * dt / TINTE_MS * NucleoPaleta.size).mod(NucleoPaleta.size.toFloat()) relevo(dt) } /** El paso de una voz a la otra, y el estreno de orden de la que se calla. */ private fun relevo(dt: Float) { if (descanso > 0f) { descanso -= dt return } val paso = dt / RELEVO_MS mezcla = if (hacia > mezcla) (mezcla + paso).coerceAtMost(1f) else (mezcla - paso).coerceAtLeast(0f) if (mezcla == hacia) { // Ya manda una sola voz: la otra, muda, puede estrenar orden sin que se note. if (mezcla == 1f) voz1.renacer(rnd, voz2.orden) else voz2.renacer(rnd, voz1.orden) hacia = 1f - mezcla descanso = reposo() } } private fun reposo() = 120_000f + rnd.nextFloat() * 360_000f fun foto() = FormaNucleo( voz1 = voz1.foto(), voz2 = voz2.foto(), mezcla = mezcla, radio = radio.valor, punta = punta.valor, brillo = brillo.valor, tinte = tinte, ) private companion object { /** Una vuelta entera a la paleta, a ritmo normal. */ const val TINTE_MS = 900_000f } } /** * Mantiene la deriva viva mientras Órbita esté a la vista. El estado se lee solo * en la fase de dibujo, así que mutar el núcleo repinta pero no recompone nada. */ @Composable fun rememberNucleo(): State { val deriva = remember { DerivaNucleo(Random(Random.nextInt())) } val estado = remember { mutableStateOf(deriva.foto()) } LaunchedEffect(Unit) { var previo = 0L var desdeRefresco = 0f while (true) { withFrameNanos { ahora -> val dt = if (previo == 0L) 0f else ((ahora - previo) / 1_000_000f).coerceIn(0f, 64f) previo = ahora deriva.avanzar(dt) desdeRefresco += dt if (desdeRefresco >= REFRESCO_MS) { desdeRefresco = 0f estado.value = deriva.foto() } } } } return estado } /** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */ internal fun colorDelCiclo(t: Float): Color { val n = NucleoPaleta.size val base = floor(t).toInt() val f = t - base return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f) } /** * El corazón del átomo, y el botón de buscar: la figura a la deriva, rellena de * un degradado que va del blanco del centro al color del ciclo, ribeteada por un * barrido de la paleta, y repetida hacia dentro en dos siluetas más pequeñas —la * primera girada media punta, para que se entrelacen como un mandala. */ @Composable fun Nucleo( modifier: Modifier, forma: () -> FormaNucleo, onTap: () -> Unit, ) { val silueta = remember { Path() } val dentro = remember { Path() } Box( modifier .size(LADO) .clip(CircleShape) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onTap, ) .drawBehind { val f = forma() val c = colorDelCiclo(f.tinte) val b = f.brillo val r = size.minDimension / 2f * f.radio trazar(silueta, f, center, r, 0f) drawPath( silueta, brush = Brush.radialGradient( 0.00f to Color.White.copy(alpha = 0.34f * b), 0.16f to c.copy(alpha = 0.50f * b), 0.52f to c.copy(alpha = 0.21f * b), 1.00f to c.copy(alpha = 0.05f * b), center = center, radius = r, ), blendMode = BlendMode.Plus, ) drawPath( silueta, brush = Brush.sweepGradient(barrido(f.tinte, 0.55f * b), center), style = Stroke(width = 1.3f.dp.toPx()), blendMode = BlendMode.Plus, ) // Los ecos hacia dentro: el de en medio, girado media punta. trazar(dentro, f, center, r * 0.62f, PI.toFloat() / f.orden) drawPath( dentro, brush = Brush.sweepGradient(barrido(f.tinte + 1.5f, 0.34f * b), center), style = Stroke(width = 1f.dp.toPx()), blendMode = BlendMode.Plus, ) trazar(dentro, f, center, r * 0.34f, 0f) drawPath( dentro, brush = Brush.sweepGradient(barrido(f.tinte + 3f, 0.26f * b), center), style = Stroke(width = 1f.dp.toPx()), blendMode = BlendMode.Plus, ) // El punto vivo: siempre blanco, siempre en el centro. val corazon = r * 0.17f drawCircle( brush = Brush.radialGradient( 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * b), 1.0f to Color.Transparent, center = center, radius = corazon, ), radius = corazon, center = center, blendMode = BlendMode.Plus, ) } ) } /** Divisor común: dos órdenes emparentados componen una figura que sigue siendo simétrica. */ private tailrec fun mcd(a: Int, b: Int): Int = if (b == 0) a else mcd(b, a % b) /** Los colores del ribete: un tramo de la paleta que cierra sobre sí mismo. */ private fun barrido(tinte: Float, alpha: Float): List = List(5) { colorDelCiclo(tinte + it * 0.5f).copy(alpha = alpha) } + colorDelCiclo(tinte).copy(alpha = alpha) /** * Levanta la silueta: radio = 1 + Σ amplitud·armónico, con las dos voces mezcladas * y normalizado para que la punta más larga mida justo [radio]. [rot] gira la figura * (media punta, para los ecos de dentro). */ private fun trazar(path: Path, f: FormaNucleo, centro: Offset, radio: Float, rot: Float) { val escala = radio / (1f + max(f.voz1.suma, f.voz2.suma)) val filo = 1f + 3f * f.punta path.reset() for (i in 0..PASOS) { val th = TAU * i / PASOS val a = th + rot val rr = 1f + (1f - f.mezcla) * vozEn(f.voz1, a, filo) + f.mezcla * vozEn(f.voz2, a, filo) val x = centro.x + escala * rr * cos(th) val y = centro.y + escala * rr * sin(th) if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) } path.close() } /** Lo que aporta una voz en un ángulo: sus tres armónicos, afilados por [filo]. */ private fun vozEn(v: VozNucleo, th: Float, filo: Float): Float { var r = 0f for (j in 0..2) { val c = cos((j + 1) * v.orden * th + v.fase[j]) r += v.amp[j] * if (c < 0f) -abs(c).pow(filo) else c.pow(filo) } return r } private const val PASOS = 300 private val LADO = 168.dp