⎇ a4998fe44b630e7fbb929875d300c82de41ca981
launcher/src/main/java/com/rizoma/orbita/ui/Nucleo.kt
Historia
crudo
Órbita: el núcleo es una figura viva, no un punto
1package com.rizoma.orbita.ui
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.size
7import androidx.compose.foundation.shape.CircleShape
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.LaunchedEffect
10import androidx.compose.runtime.State
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.withFrameNanos
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.draw.clip
16import androidx.compose.ui.draw.drawBehind
17import androidx.compose.ui.geometry.Offset
18import androidx.compose.ui.graphics.BlendMode
19import androidx.compose.ui.graphics.Brush
20import androidx.compose.ui.graphics.Color
21import androidx.compose.ui.graphics.Path
22import androidx.compose.ui.graphics.drawscope.Stroke
23import androidx.compose.ui.graphics.lerp
24import androidx.compose.ui.unit.dp
25import com.rizoma.orbita.ui.theme.NucleoPaleta
26import kotlin.math.PI
27import kotlin.math.abs
28import kotlin.math.cos
29import kotlin.math.floor
30import kotlin.math.max
31import kotlin.math.pow
32import kotlin.math.sin
33import kotlin.random.Random
34
35/**
36 * El núcleo del átomo: una figura que nunca es dos veces la misma.
37 *
38 * No hay dibujo guardado. La silueta es una curva polar: el radio en cada ángulo
39 * es uno más la suma de tres armónicos —n, 2n y 3n puntas—. Como los tres son
40 * múltiplos del mismo orden n, la figura sale siempre con simetría exacta de n
41 * puntas: estrella cuando manda el primer armónico y va afilado, mandala cuando
42 * los otros dos tienen peso y el pétalo es redondo. Moviendo solo las amplitudes
43 * se pasa de una a otra sin un salto.
44 *
45 * Cambiar n sí sería un salto, así que hay **dos voces** sonando a la vez y una
46 * mezcla que las reparte: mientras una manda, la otra está callada y es entonces
47 * —invisible— cuando estrena orden. El relevo tarda unos minutos.
48 *
49 * Todo lo demás (amplitudes, fases, tamaño, filo, brillo y color) arranca en un
50 * punto al azar y de ahí deriva: cada dos segundos cada parámetro se pone una meta
51 * a una micra de donde está y se acerca a ella suavemente. En un minuto no se ve
52 * cambiar nada; en media hora es otra figura, y no vuelve atrás.
53 */
54
55/** Órdenes de simetría que puede tomar el núcleo. */
56private val ORDENES = intArrayOf(4, 5, 6, 8, 10, 12)
57
58private const val MUTACION_MS = 2_000f // cada cuánto se replantea la meta
59private const val SUAVIZADO_MS = 2_600f // lo que tarda en llegar a ella
60private const val REFRESCO_MS = 32f // 30 fps bastan para algo tan lento
61private const val RELEVO_MS = 900_000f // un cuarto de hora en pasar de una voz a la otra
62private const val TAU = (2 * PI).toFloat()
63
64/** Una voz: un orden de simetría con sus tres armónicos. */
65class VozNucleo internal constructor(
66 internal val orden: Int,
67 internal val amp: FloatArray,
68 internal val fase: FloatArray,
69) {
70 internal val suma: Float = amp.sum()
71}
72
73/** Una foto del núcleo en un instante: lo único que necesita el dibujo. */
74class FormaNucleo internal constructor(
75 internal val voz1: VozNucleo,
76 internal val voz2: VozNucleo,
77 /** Quién manda: 0 la primera voz, 1 la segunda. */
78 internal val mezcla: Float,
79 /** Tamaño, como fracción del hueco disponible. */
80 internal val radio: Float,
81 /** 0 = pétalo redondo (mandala) · 1 = punta afilada (estrella). */
82 internal val punta: Float,
83 /** Cuánta luz da ahora mismo. */
84 val brillo: Float,
85 /** Dónde cae en la paleta del rizoma. */
86 val tinte: Float,
87) {
88 /** El orden que se está viendo: manda el de la voz con más peso. */
89 internal val orden: Int get() = if (mezcla < 0.5f) voz1.orden else voz2.orden
90}
91
92/** Un parámetro a la deriva: nunca salta, siempre va hacia una meta cercana. */
93private class Deriva(
94 var valor: Float,
95 private val min: Float,
96 private val max: Float,
97 /** La micra: lo más que puede alejarse la meta en cada mutación. */
98 private val micra: Float,
99) {
100 private var meta = valor
101
102 fun mutar(rnd: Random) {
103 meta = (valor + (rnd.nextFloat() * 2f - 1f) * micra).coerceIn(min, max)
104 }
105
106 fun avanzar(dt: Float) {
107 valor += (meta - valor) * (dt / SUAVIZADO_MS).coerceIn(0f, 1f)
108 }
109
110 /** Solo para lo que está callado: ahí sí se puede saltar sin que se vea. */
111 fun saltar(v: Float) {
112 valor = v
113 meta = v
114 }
115}
116
117/** Una voz a la deriva: su orden, el peso de cada armónico y lo que giran. */
118private class Voz(rnd: Random, pariente: Int) {
119
120 var orden = 6
121 private set
122
123 private val peso = Array(3) { Deriva(0f, 0f, 1f, 0.05f) }
124 private val fase = FloatArray(3)
125 private val giro = Array(3) { Deriva(0f, -VUELTA, VUELTA, VUELTA * 0.15f) }
126
127 init {
128 renacer(rnd, pariente)
129 }
130
131 /**
132 * Estrena orden y figura. Solo se llama con la voz callada, y el orden nuevo se
133 * escoge emparentado con el que manda —que compartan divisor— para que mientras
134 * las dos suenan a la vez la figura conserve simetría en vez de volverse un borrón.
135 */
136 fun renacer(rnd: Random, pariente: Int) {
137 val posibles = ORDENES.filter { it != pariente && mcd(it, pariente) > 1 }
138 orden = if (posibles.isEmpty()) ORDENES[rnd.nextInt(ORDENES.size)]
139 else posibles[rnd.nextInt(posibles.size)]
140 peso[0].saltar(0.35f + rnd.nextFloat() * 0.65f)
141 peso[1].saltar(rnd.nextFloat())
142 peso[2].saltar(rnd.nextFloat())
143 for (i in fase.indices) {
144 fase[i] = rnd.nextFloat() * TAU
145 giro[i].saltar((rnd.nextFloat() * 2f - 1f) * VUELTA)
146 }
147 }
148
149 fun mutar(rnd: Random) {
150 peso.forEach { it.mutar(rnd) }
151 giro.forEach { it.mutar(rnd) }
152 }
153
154 fun avanzar(dt: Float) {
155 peso.forEach { it.avanzar(dt) }
156 giro.forEach { it.avanzar(dt) }
157 for (i in fase.indices) fase[i] = (fase[i] + giro[i].valor * dt).mod(TAU)
158 }
159
160 fun foto(): VozNucleo {
161 // Cuantas más puntas, menos hondas: si no, el núcleo se vuelve una pelusa.
162 val techo = (0.34f * (6f / orden).pow(0.45f)).coerceIn(0.15f, 0.34f)
163 return VozNucleo(
164 orden = orden,
165 amp = floatArrayOf(
166 peso[0].valor * techo,
167 peso[1].valor * techo * 0.50f,
168 peso[2].valor * techo * 0.30f,
169 ),
170 fase = fase.copyOf(),
171 )
172 }
173
174 private companion object {
175 /** Giro máximo de un armónico: una vuelta cada media hora. */
176 const val VUELTA = TAU / 1_800_000f
177 }
178}
179
180/**
181 * La deriva entera del núcleo. Arranca al azar —cada vez que Órbita nace, nace
182 * también una figura distinta— y a partir de ahí solo se deja llevar.
183 */
184private class DerivaNucleo(private val rnd: Random) {
185
186 private val voz1 = Voz(rnd, 0)
187 private val voz2 = Voz(rnd, voz1.orden)
188 private val radio = Deriva(0.60f + rnd.nextFloat() * 0.32f, 0.55f, 0.95f, 0.012f)
189 private val punta = Deriva(rnd.nextFloat(), 0f, 1f, 0.03f)
190 private val brillo = Deriva(0.55f + rnd.nextFloat() * 0.45f, 0.45f, 1f, 0.035f)
191 // La deriva de color mantiene el rumbo del rizoma: una vuelta a la paleta en
192 // un cuarto de hora, con el paso un poco vivo o un poco lento según el momento.
193 private var tinte = rnd.nextFloat() * NucleoPaleta.size
194 private val ritmo = Deriva(1f, 0.5f, 1.6f, 0.05f)
195
196 private var mezcla = if (rnd.nextBoolean()) 0f else 1f
197 private var hacia = 1f - mezcla
198 private var descanso = reposo()
199
200 private var desdeMutacion = 0f
201
202 fun avanzar(dt: Float) {
203 desdeMutacion += dt
204 if (desdeMutacion >= MUTACION_MS) {
205 desdeMutacion = 0f
206 voz1.mutar(rnd)
207 voz2.mutar(rnd)
208 radio.mutar(rnd)
209 punta.mutar(rnd)
210 brillo.mutar(rnd)
211 ritmo.mutar(rnd)
212 }
213 voz1.avanzar(dt)
214 voz2.avanzar(dt)
215 radio.avanzar(dt)
216 punta.avanzar(dt)
217 brillo.avanzar(dt)
218 ritmo.avanzar(dt)
219 tinte = (tinte + ritmo.valor * dt / TINTE_MS * NucleoPaleta.size).mod(NucleoPaleta.size.toFloat())
220 relevo(dt)
221 }
222
223 /** El paso de una voz a la otra, y el estreno de orden de la que se calla. */
224 private fun relevo(dt: Float) {
225 if (descanso > 0f) {
226 descanso -= dt
227 return
228 }
229 val paso = dt / RELEVO_MS
230 mezcla = if (hacia > mezcla) (mezcla + paso).coerceAtMost(1f) else (mezcla - paso).coerceAtLeast(0f)
231 if (mezcla == hacia) {
232 // Ya manda una sola voz: la otra, muda, puede estrenar orden sin que se note.
233 if (mezcla == 1f) voz1.renacer(rnd, voz2.orden) else voz2.renacer(rnd, voz1.orden)
234 hacia = 1f - mezcla
235 descanso = reposo()
236 }
237 }
238
239 private fun reposo() = 120_000f + rnd.nextFloat() * 360_000f
240
241 fun foto() = FormaNucleo(
242 voz1 = voz1.foto(),
243 voz2 = voz2.foto(),
244 mezcla = mezcla,
245 radio = radio.valor,
246 punta = punta.valor,
247 brillo = brillo.valor,
248 tinte = tinte,
249 )
250
251 private companion object {
252 /** Una vuelta entera a la paleta, a ritmo normal. */
253 const val TINTE_MS = 900_000f
254 }
255}
256
257/**
258 * Mantiene la deriva viva mientras Órbita esté a la vista. El estado se lee solo
259 * en la fase de dibujo, así que mutar el núcleo repinta pero no recompone nada.
260 */
261@Composable
262fun rememberNucleo(): State<FormaNucleo> {
263 val deriva = remember { DerivaNucleo(Random(Random.nextInt())) }
264 val estado = remember { mutableStateOf(deriva.foto()) }
265 LaunchedEffect(Unit) {
266 var previo = 0L
267 var desdeRefresco = 0f
268 while (true) {
269 withFrameNanos { ahora ->
270 val dt = if (previo == 0L) 0f else ((ahora - previo) / 1_000_000f).coerceIn(0f, 64f)
271 previo = ahora
272 deriva.avanzar(dt)
273 desdeRefresco += dt
274 if (desdeRefresco >= REFRESCO_MS) {
275 desdeRefresco = 0f
276 estado.value = deriva.foto()
277 }
278 }
279 }
280 }
281 return estado
282}
283
284/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
285internal fun colorDelCiclo(t: Float): Color {
286 val n = NucleoPaleta.size
287 val base = floor(t).toInt()
288 val f = t - base
289 return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
290}
291
292/**
293 * El corazón del átomo, y el botón de buscar: la figura a la deriva, rellena de
294 * un degradado que va del blanco del centro al color del ciclo, ribeteada por un
295 * barrido de la paleta, y repetida hacia dentro en dos siluetas más pequeñas —la
296 * primera girada media punta, para que se entrelacen como un mandala.
297 */
298@Composable
299fun Nucleo(
300 modifier: Modifier,
301 forma: () -> FormaNucleo,
302 onTap: () -> Unit,
303) {
304 val silueta = remember { Path() }
305 val dentro = remember { Path() }
306 Box(
307 modifier
308 .size(LADO)
309 .clip(CircleShape)
310 .clickable(
311 interactionSource = remember { MutableInteractionSource() },
312 indication = null,
313 onClick = onTap,
314 )
315 .drawBehind {
316 val f = forma()
317 val c = colorDelCiclo(f.tinte)
318 val b = f.brillo
319 val r = size.minDimension / 2f * f.radio
320 trazar(silueta, f, center, r, 0f)
321
322 drawPath(
323 silueta,
324 brush = Brush.radialGradient(
325 0.00f to Color.White.copy(alpha = 0.34f * b),
326 0.16f to c.copy(alpha = 0.50f * b),
327 0.52f to c.copy(alpha = 0.21f * b),
328 1.00f to c.copy(alpha = 0.05f * b),
329 center = center,
330 radius = r,
331 ),
332 blendMode = BlendMode.Plus,
333 )
334 drawPath(
335 silueta,
336 brush = Brush.sweepGradient(barrido(f.tinte, 0.55f * b), center),
337 style = Stroke(width = 1.3f.dp.toPx()),
338 blendMode = BlendMode.Plus,
339 )
340 // Los ecos hacia dentro: el de en medio, girado media punta.
341 trazar(dentro, f, center, r * 0.62f, PI.toFloat() / f.orden)
342 drawPath(
343 dentro,
344 brush = Brush.sweepGradient(barrido(f.tinte + 1.5f, 0.34f * b), center),
345 style = Stroke(width = 1f.dp.toPx()),
346 blendMode = BlendMode.Plus,
347 )
348 trazar(dentro, f, center, r * 0.34f, 0f)
349 drawPath(
350 dentro,
351 brush = Brush.sweepGradient(barrido(f.tinte + 3f, 0.26f * b), center),
352 style = Stroke(width = 1f.dp.toPx()),
353 blendMode = BlendMode.Plus,
354 )
355 // El punto vivo: siempre blanco, siempre en el centro.
356 val corazon = r * 0.17f
357 drawCircle(
358 brush = Brush.radialGradient(
359 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * b),
360 1.0f to Color.Transparent,
361 center = center,
362 radius = corazon,
363 ),
364 radius = corazon,
365 center = center,
366 blendMode = BlendMode.Plus,
367 )
368 }
369 )
370}
371
372/** Divisor común: dos órdenes emparentados componen una figura que sigue siendo simétrica. */
373private tailrec fun mcd(a: Int, b: Int): Int = if (b == 0) a else mcd(b, a % b)
374
375/** Los colores del ribete: un tramo de la paleta que cierra sobre sí mismo. */
376private fun barrido(tinte: Float, alpha: Float): List<Color> =
377 List(5) { colorDelCiclo(tinte + it * 0.5f).copy(alpha = alpha) } +
378 colorDelCiclo(tinte).copy(alpha = alpha)
379
380/**
381 * Levanta la silueta: radio = 1 + Σ amplitud·armónico, con las dos voces mezcladas
382 * y normalizado para que la punta más larga mida justo [radio]. [rot] gira la figura
383 * (media punta, para los ecos de dentro).
384 */
385private fun trazar(path: Path, f: FormaNucleo, centro: Offset, radio: Float, rot: Float) {
386 val escala = radio / (1f + max(f.voz1.suma, f.voz2.suma))
387 val filo = 1f + 3f * f.punta
388 path.reset()
389 for (i in 0..PASOS) {
390 val th = TAU * i / PASOS
391 val a = th + rot
392 val rr = 1f + (1f - f.mezcla) * vozEn(f.voz1, a, filo) + f.mezcla * vozEn(f.voz2, a, filo)
393 val x = centro.x + escala * rr * cos(th)
394 val y = centro.y + escala * rr * sin(th)
395 if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
396 }
397 path.close()
398}
399
400/** Lo que aporta una voz en un ángulo: sus tres armónicos, afilados por [filo]. */
401private fun vozEn(v: VozNucleo, th: Float, filo: Float): Float {
402 var r = 0f
403 for (j in 0..2) {
404 val c = cos((j + 1) * v.orden * th + v.fase[j])
405 r += v.amp[j] * if (c < 0f) -abs(c).pow(filo) else c.pow(filo)
406 }
407 return r
408}
409
410private const val PASOS = 300
411private val LADO = 168.dp