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>
Semantic delta
No definition changed: this change is text, documentation or data.
5 files · +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),
)
}