gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ a4998fe44b630e7fbb929875d300c82de41ca981 launcher/src/main/java/com/rizoma/orbita/ui/Nucleo.kt 411 líneas · 15.0 KB Historia crudo

Órbita: el núcleo es una figura viva, no un punto a4998fe44 · Sergio ·

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