tawasuyu

Matiz: botón generador por dificultad — +4 niveles nuevos cada toque

Cada cabecera de dificultad lleva un chip "✦ +4" que genera 4 niveles
extra de esa misma banda, persistentes (DataStore, prefijo "g:"). La
generación se desacopla de la semilla: los ids generados viven en un
espacio propio por dificultad (GEN_BASE + tier*STRIDE + k) y heredan la
forma/fijas de un nivel representativo del tramo, con semilla nueva →
puzzle distinto pero de la misma dificultad. Niveles generados marcados
con ✦, navegables dentro de su tramo, sin tocar el progreso lineal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

803a42987756015c1d691e43b0dfc5d1e7ef6fba · por sergio · (hace 4 meses) · padre cc26c1354 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

5 archivos · +167 −38

diff --git a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
index 92f2c76..95e5a05 100644
--- a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
@@ -32,9 +32,22 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
     var resueltos by mutableStateOf<Set<Int>>(emptySet()); private set
     var mejores by mutableStateOf<Map<Int, Int>>(emptyMap()); private set
 
+    // Niveles extra generados por dificultad (índice de tier → cuenta).
+    var generados by mutableStateOf<Map<Int, Int>>(emptyMap()); private set
+
     init {
         viewModelScope.launch { progreso.resueltos.collect { resueltos = it } }
         viewModelScope.launch { progreso.mejores.collect { mejores = it } }
+        viewModelScope.launch { progreso.generados.collect { generados = it } }
+    }
+
+    /** Etiqueta del nivel en juego: número para los base, ✦ para los generados. */
+    val etiquetaNivel: String
+        get() = if (Levels.isGenerated(puzzle.level)) "✦" else "${puzzle.level}"
+
+    /** Genera 4 niveles más de la dificultad [tier]. */
+    fun generar(tier: Int) {
+        viewModelScope.launch { progreso.generarMas(tier, 4) }
     }
 
     /** Abre un nivel desde el mapa. */
@@ -60,13 +73,32 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
             val m = moves
             viewModelScope.launch {
                 progreso.marcarResuelto(p.level, m)
-                progreso.setNivel(p.level + 1)
+                // El progreso lineal (nivel/máximo) solo lo mueven los niveles base.
+                if (!Levels.isGenerated(p.level)) progreso.setNivel(p.level + 1)
             }
         }
     }
 
-    fun next() = load(puzzle.level + 1)
-    fun prev() = load(puzzle.level - 1)
+    // Navegación: los base avanzan por número; los generados se mueven dentro de
+    // su propia dificultad, sin salirse de los que existen.
+    val puedeAnterior: Boolean
+        get() = if (Levels.isGenerated(puzzle.level)) Levels.localIndex(puzzle.level) > 0
+        else puzzle.level > 1
+
+    val puedeSiguiente: Boolean
+        get() = if (Levels.isGenerated(puzzle.level)) {
+            val tier = Levels.tierOfGenerated(puzzle.level)
+            Levels.localIndex(puzzle.level) < (generados[tier] ?: 0) - 1
+        } else true
+
+    fun next() {
+        if (puedeSiguiente) load(puzzle.level + 1)
+    }
+
+    fun prev() {
+        if (puedeAnterior) load(puzzle.level - 1)
+    }
+
     fun retry() = load(puzzle.level)
 
     private fun load(level: Int) {
@@ -74,6 +106,8 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
         moves = 0
         solved = false
         epoch++
-        viewModelScope.launch { progreso.setNivel(puzzle.level) }
+        if (!Levels.isGenerated(puzzle.level)) {
+            viewModelScope.launch { progreso.setNivel(puzzle.level) }
+        }
     }
 }
diff --git a/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
index 32907b1..c7008fd 100644
--- a/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
@@ -38,6 +38,25 @@ class Progreso(context: Context) {
         }
     }
 
+    /** Cuántos niveles extra se han generado por dificultad (índice de tier → cuenta). */
+    val generados: Flow<Map<Int, Int>> = store.data.map { prefs ->
+        buildMap {
+            prefs.asMap().forEach { (k, v) ->
+                if (k.name.startsWith(GEN_PREFIX) && v is Int) {
+                    k.name.removePrefix(GEN_PREFIX).toIntOrNull()?.let { put(it, v) }
+                }
+            }
+        }
+    }
+
+    /** Añade [cuantos] niveles generados a una dificultad. */
+    suspend fun generarMas(tier: Int, cuantos: Int) {
+        store.edit { p ->
+            val k = intPreferencesKey(GEN_PREFIX + tier)
+            p[k] = (p[k] ?: 0) + cuantos
+        }
+    }
+
     suspend fun setNivel(n: Int) {
         val nivel = n.coerceAtLeast(1)
         store.edit { p ->
@@ -60,6 +79,7 @@ class Progreso(context: Context) {
 
     private companion object {
         const val BEST_PREFIX = "m:"
+        const val GEN_PREFIX = "g:"
         val KEY_NIVEL = intPreferencesKey("nivel")
         val KEY_MAX = intPreferencesKey("maximo")
         val KEY_RESUELTOS = stringSetPreferencesKey("resueltos")
diff --git a/matiz/src/main/java/com/rizoma/matiz/game/Level.kt b/matiz/src/main/java/com/rizoma/matiz/game/Level.kt
index e907a17..a4793c2 100644
--- a/matiz/src/main/java/com/rizoma/matiz/game/Level.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/game/Level.kt
@@ -53,14 +53,60 @@ object Levels {
 
     private const val BASE = 0x5249_5A4F_4D41_0001L // "RIZOMA"
 
-    /** Seed estable para un nivel: mismo número → mismo puzzle en todas partes. */
+    // Tramos de dificultad del mapa, por índice (fácil..maestro). Definen tanto
+    // qué niveles base pertenecen a cada dificultad como, para los niveles
+    // generados, la "banda" de la que heredan forma de rejilla y fijas.
+    val TIER_RANGES = listOf(1..6, 7..14, 15..26, 27..40, 41..48)
+
+    // Espacio de ids para niveles generados: cada dificultad tiene su propio
+    // tramo, lejos de los niveles base (1..48), para no chocar con ellos ni
+    // entre sí. id = GEN_BASE + tier*GEN_STRIDE + k.
+    const val GEN_BASE = 1_000_000
+    const val GEN_STRIDE = 100_000
+
+    fun isGenerated(id: Int): Boolean = id >= GEN_BASE
+    fun generatedId(tier: Int, k: Int): Int = GEN_BASE + tier * GEN_STRIDE + k
+    fun tierOfGenerated(id: Int): Int = (id - GEN_BASE) / GEN_STRIDE
+    /** Posición (0-based) de un id generado dentro de su dificultad. */
+    fun localIndex(id: Int): Int = id - generatedId(tierOfGenerated(id), 0)
+
+    /** Seed estable para un nivel: mismo id → mismo puzzle en todas partes. */
     fun seedFor(level: Int): Long = BASE xor (level.toLong() * -0x61c8864680b583ebL)
 
-    fun generate(level: Int): Puzzle {
-        val n = level.coerceAtLeast(1)
-        val rng = Rng(seedFor(n))
-        val (cols, rows) = SHAPES[(n - 1).coerceIn(0, SHAPES.lastIndex)]
+    private fun shapeFor(level: Int): Pair<Int, Int> =
+        SHAPES[(level - 1).coerceIn(0, SHAPES.lastIndex)]
 
+    /** Proporción de celdas fijas (además de las esquinas); decrece con el nivel. */
+    private fun ratioFor(level: Int): Double = when {
+        level <= 2 -> 0.45
+        level <= 4 -> 0.30
+        level <= 6 -> 0.18
+        level <= 9 -> 0.08
+        else -> 0.0
+    }
+
+    /**
+     * Genera el puzzle de un id. Para los ids base (1..) la dificultad sale del
+     * propio número; para los generados (≥[GEN_BASE]) sale de un nivel
+     * representativo muestreado del tramo de su dificultad, con semilla nueva
+     * → puzzle distinto pero de la misma banda.
+     */
+    fun generate(id: Int): Puzzle {
+        if (id < GEN_BASE) {
+            val n = id.coerceAtLeast(1)
+            val (cols, rows) = shapeFor(n)
+            return build(n, cols, rows, ratioFor(n), seedFor(n))
+        }
+        val tier = tierOfGenerated(id).coerceIn(0, TIER_RANGES.lastIndex)
+        val range = TIER_RANGES[tier]
+        val rep = range.first + ((seedFor(id) ushr 1) % range.count()).toInt()
+        val (cols, rows) = shapeFor(rep)
+        return build(id, cols, rows, ratioFor(rep), seedFor(id))
+    }
+
+    /** Construye un puzzle concreto: degradado + fijas + barajado, todo por [seed]. */
+    private fun build(id: Int, cols: Int, rows: Int, ratio: Double, seed: Long): Puzzle {
+        val rng = Rng(seed)
         val (tl, tr, bl, br) = corners(rng)
         val targets = ArrayList<Color>(cols * rows)
         for (r in 0 until rows) {
@@ -72,10 +118,9 @@ object Levels {
                 targets.add(lerp(left, right, tx).toColor())
             }
         }
-
-        val locked = lockMask(n, cols, rows, rng)
+        val locked = lockMask(cols, rows, ratio, rng)
         val arrangement = shuffle(targets, locked, rng)
-        return Puzzle(n, cols, rows, targets, locked, arrangement)
+        return Puzzle(id, cols, rows, targets, locked, arrangement)
     }
 
     /**
@@ -102,8 +147,8 @@ object Levels {
         )
     }
 
-    /** Esquinas siempre fijas; el resto de fijas decrece con el nivel (más difícil). */
-    private fun lockMask(level: Int, cols: Int, rows: Int, rng: Rng): BooleanArray {
+    /** Esquinas siempre fijas; el resto según [ratio] (cuanto menor, más difícil). */
+    private fun lockMask(cols: Int, rows: Int, ratio: Double, rng: Rng): BooleanArray {
         val n = cols * rows
         val locked = BooleanArray(n)
         locked[0] = true
@@ -111,13 +156,6 @@ object Levels {
         locked[n - cols] = true
         locked[n - 1] = true
 
-        val ratio = when {
-            level <= 2 -> 0.45
-            level <= 4 -> 0.30
-            level <= 6 -> 0.18
-            level <= 9 -> 0.08
-            else -> 0.0
-        }
         val pool = (0 until n).filter { !locked[it] }.toMutableList()
         val extra = (pool.size * ratio).toInt()
         repeat(extra) {
diff --git a/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt b/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
index 64ba596..2133f42 100644
--- a/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
@@ -2,6 +2,7 @@ package com.rizoma.matiz.ui
 
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
+import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -44,14 +46,16 @@ import com.rizoma.matiz.ui.theme.Vacio
 
 private const val MAP_LEVELS = 48
 
-private data class Tier(val range: IntRange, val nombre: String, val color: Color)
+// Nombre + color por dificultad; el rango de niveles base sale de
+// Levels.TIER_RANGES (fuente única, compartida con la generación).
+private data class Tier(val nombre: String, val color: Color)
 
 private val TIERS = listOf(
-    Tier(1..6, "fácil", NeonVerde),
-    Tier(7..14, "ameno", NeonCian),
-    Tier(15..26, "difícil", NeonRosa),
-    Tier(27..40, "experto", NeonVerde),
-    Tier(41..MAP_LEVELS, "maestro", NeonCian),
+    Tier("fácil", NeonVerde),
+    Tier("ameno", NeonCian),
+    Tier("difícil", NeonRosa),
+    Tier("experto", NeonVerde),
+    Tier("maestro", NeonCian),
 )
 
 /** Miniatura cacheada del degradado resuelto de un nivel. */
@@ -72,6 +76,18 @@ fun MapaScreen(vm: MatizViewModel) {
     }
     val resueltos = vm.resueltos
     val mejores = vm.mejores
+    val generados = vm.generados
+
+    // Miniaturas de los niveles generados, recalculadas solo al cambiar la cuenta.
+    val genPreviews = remember(generados) {
+        Levels.TIER_RANGES.indices.associateWith { t ->
+            (0 until (generados[t] ?: 0)).map { k ->
+                val id = Levels.generatedId(t, k)
+                val p = Levels.generate(id)
+                Preview(id, p.cols, p.rows, p.targets)
+            }
+        }
+    }
 
     Column(
         Modifier
@@ -98,9 +114,10 @@ fun MapaScreen(vm: MatizViewModel) {
         }
         Spacer(Modifier.height(20.dp))
 
-        TIERS.forEach { tier ->
-            val list = previews.filter { it.level in tier.range }
-            if (list.isEmpty()) return@forEach
+        TIERS.forEachIndexed { idx, tier ->
+            val base = previews.filter { it.level in Levels.TIER_RANGES[idx] }
+            val list = base + genPreviews[idx].orEmpty()
+            if (list.isEmpty()) return@forEachIndexed
             val done = list.count { it.level in resueltos }
 
             Row(
@@ -110,6 +127,8 @@ fun MapaScreen(vm: MatizViewModel) {
                 Box(Modifier.size(7.dp).clip(CircleShape).background(tier.color))
                 Spacer(Modifier.width(10.dp))
                 Text(tier.nombre, color = tier.color, fontSize = 14.sp, letterSpacing = 3.sp, fontWeight = FontWeight.Medium)
+                Spacer(Modifier.width(12.dp))
+                GenerarChip(tier.color) { vm.generar(idx) }
                 Spacer(Modifier.weight(1f))
                 Text("$done/${list.size}", color = MuyTenue, fontSize = 12.sp)
             }
@@ -124,6 +143,7 @@ fun MapaScreen(vm: MatizViewModel) {
                                 solved = pv.level in resueltos,
                                 best = mejores[pv.level],
                                 accent = tier.color,
+                                label = if (Levels.isGenerated(pv.level)) "✦" else "${pv.level}",
                             ) { vm.abrir(pv.level) }
                         }
                     }
@@ -135,11 +155,27 @@ fun MapaScreen(vm: MatizViewModel) {
     }
 }
 
+/** Botón generador de una dificultad: añade 4 niveles de su misma banda. */
+@Composable
+private fun GenerarChip(accent: Color, onClick: () -> Unit) {
+    Text(
+        "✦ +4",
+        color = accent,
+        fontSize = 11.sp,
+        fontWeight = FontWeight.SemiBold,
+        modifier = Modifier
+            .clip(RoundedCornerShape(50))
+            .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(50))
+            .clickable(onClick = onClick)
+            .padding(horizontal = 10.dp, vertical = 4.dp),
+    )
+}
+
 @Composable
-private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, onClick: () -> Unit) {
+private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, label: String, onClick: () -> Unit) {
     Column(
         horizontalAlignment = Alignment.CenterHorizontally,
-        modifier = Modifier.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)).clickable(onClick = onClick).padding(4.dp),
+        modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(onClick = onClick).padding(4.dp),
     ) {
         Canvas(Modifier.size(58.dp)) {
             val a = if (solved) 1f else 0.30f
@@ -169,7 +205,7 @@ private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, o
         }
         Spacer(Modifier.height(6.dp))
         Text(
-            "${pv.level}",
+            label,
             color = if (solved) Color.White else MuyTenue,
             fontSize = 12.sp,
             fontWeight = if (solved) FontWeight.SemiBold else FontWeight.Normal,
diff --git a/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt b/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
index 5f0d8d1..5621dd8 100644
--- a/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
@@ -106,7 +106,7 @@ private fun Header(vm: MatizViewModel) {
             Column(Modifier.weight(1f)) {
                 Text("NIVEL", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
                 Text(
-                    "${vm.puzzle.level}",
+                    vm.etiquetaNivel,
                     color = Color.White,
                     fontSize = 36.sp,
                     fontWeight = FontWeight.Bold,
@@ -132,9 +132,9 @@ private fun Controls(vm: MatizViewModel) {
         horizontalArrangement = Arrangement.SpaceBetween,
         verticalAlignment = Alignment.CenterVertically,
     ) {
-        Pill("‹  anterior", enabled = vm.puzzle.level > 1) { vm.prev() }
+        Pill("‹  anterior", enabled = vm.puedeAnterior) { vm.prev() }
         Pill("barajar") { vm.retry() }
-        Pill("siguiente  ›") { vm.next() }
+        Pill("siguiente  ›", enabled = vm.puedeSiguiente) { vm.next() }
     }
 }
 
@@ -177,14 +177,15 @@ private fun SolvedBanner(vm: MatizViewModel) {
                     color = Tenue, fontSize = 13.sp,
                 )
             }
+            val hayMas = vm.puedeSiguiente
             Text(
-                "siguiente  →",
+                if (hayMas) "siguiente  →" else "‹  mapa",
                 color = NeonVerde,
                 fontSize = 16.sp,
                 fontWeight = FontWeight.Bold,
                 modifier = Modifier
                     .clip(RoundedCornerShape(50))
-                    .clickable { vm.next() }
+                    .clickable { if (hayMas) vm.next() else vm.volverAlMapa() }
                     .padding(horizontal = 14.dp, vertical = 8.dp),
             )
         }