gioser

Matiz: mapa de niveles por dificultad + logros persistentes

La pantalla principal pasa a ser un MAPA del viaje completo (48 niveles)
agrupado por dificultad (fácil/ameno/difícil/experto/maestro): cada nivel se
dibuja como su propio degradado en miniatura, los resueltos brillan con anillo y
su mejor marca, los pendientes quedan atenuados. Todos caben en filas de cuatro.

- Progreso persiste ahora los logros: conjunto de niveles resueltos y mejor marca
  (mínimo de movimientos) por nivel, en DataStore. El ViewModel los observa para
  que el mapa los refleje al instante; al resolver se sella resuelto+mejor.
- Navegación Mapa↔Juego; "‹ mapa" y atrás vuelven al mapa.
- El tablero ya encaja en pantalla (aspectRatio con matchHeightConstraintsFirst).

Escrito NO compilado aquí.

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

52115fe33604a9b651e0707b92f3cb9f6654fdfd · por sergio · (hace 4 meses) · padre 4e0b02885 · ver el árbol en este commit

Delta semántico

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

4 archivos · +292 −29

diff --git a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
index 8dcdb20..92f2c76 100644
--- a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
@@ -10,12 +10,16 @@ import androidx.lifecycle.viewModelScope
 import com.rizoma.matiz.data.Progreso
 import com.rizoma.matiz.game.Levels
 import com.rizoma.matiz.game.Puzzle
-import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 
+/** El mapa de niveles, o un puzzle en juego. */
+enum class Pantalla { Mapa, Juego }
+
 class MatizViewModel(app: Application) : AndroidViewModel(app) {
     private val progreso = Progreso(app)
 
+    var pantalla by mutableStateOf(Pantalla.Mapa); private set
+
     var puzzle by mutableStateOf(Levels.generate(1)); private set
     var moves by mutableIntStateOf(0); private set
     var solved by mutableStateOf(false); private set
@@ -24,11 +28,23 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
     // mutado in situ, este contador es lo que dispara el redibujado en Compose.
     var epoch by mutableIntStateOf(0); private set
 
+    // Logros, observados desde DataStore para que el mapa los refleje al instante.
+    var resueltos by mutableStateOf<Set<Int>>(emptySet()); private set
+    var mejores by mutableStateOf<Map<Int, Int>>(emptyMap()); private set
+
     init {
-        viewModelScope.launch {
-            val n = progreso.nivel.first()
-            if (n != puzzle.level) load(n)
-        }
+        viewModelScope.launch { progreso.resueltos.collect { resueltos = it } }
+        viewModelScope.launch { progreso.mejores.collect { mejores = it } }
+    }
+
+    /** Abre un nivel desde el mapa. */
+    fun abrir(level: Int) {
+        load(level)
+        pantalla = Pantalla.Juego
+    }
+
+    fun volverAlMapa() {
+        pantalla = Pantalla.Mapa
     }
 
     /** Intercambia dos baldosas si ninguna está fija. */
@@ -41,7 +57,11 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
         epoch++
         if (p.solved) {
             solved = true
-            viewModelScope.launch { progreso.setNivel(p.level + 1) }
+            val m = moves
+            viewModelScope.launch {
+                progreso.marcarResuelto(p.level, m)
+                progreso.setNivel(p.level + 1)
+            }
         }
     }
 
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 40b929c..32907b1 100644
--- a/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
@@ -5,19 +5,39 @@ import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
 private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "matiz")
 
-/** Persiste el progreso: nivel actual y nivel máximo alcanzado. */
+/**
+ * Persiste el progreso y los logros: nivel actual/máximo, qué niveles se han
+ * resuelto y la mejor marca (mínimo de movimientos) de cada uno.
+ */
 class Progreso(context: Context) {
     private val store = context.applicationContext.dataStore
 
     val nivel: Flow<Int> = store.data.map { (it[KEY_NIVEL] ?: 1).coerceAtLeast(1) }
     val maximo: Flow<Int> = store.data.map { (it[KEY_MAX] ?: 1).coerceAtLeast(1) }
 
+    /** Niveles ya resueltos. */
+    val resueltos: Flow<Set<Int>> = store.data.map { p ->
+        (p[KEY_RESUELTOS] ?: emptySet()).mapNotNull { it.toIntOrNull() }.toSet()
+    }
+
+    /** Mejor marca (mínimo de movimientos) por nivel. */
+    val mejores: Flow<Map<Int, Int>> = store.data.map { prefs ->
+        buildMap {
+            prefs.asMap().forEach { (k, v) ->
+                if (k.name.startsWith(BEST_PREFIX) && v is Int) {
+                    k.name.removePrefix(BEST_PREFIX).toIntOrNull()?.let { put(it, v) }
+                }
+            }
+        }
+    }
+
     suspend fun setNivel(n: Int) {
         val nivel = n.coerceAtLeast(1)
         store.edit { p ->
@@ -26,8 +46,22 @@ class Progreso(context: Context) {
         }
     }
 
+    /** Sella un nivel como resuelto y guarda su mejor marca de movimientos. */
+    suspend fun marcarResuelto(level: Int, moves: Int) {
+        store.edit { p ->
+            val s = (p[KEY_RESUELTOS] ?: emptySet()).toMutableSet()
+            s.add(level.toString())
+            p[KEY_RESUELTOS] = s
+            val mk = intPreferencesKey(BEST_PREFIX + level)
+            val prev = p[mk]
+            if (prev == null || moves < prev) p[mk] = moves
+        }
+    }
+
     private companion object {
+        const val BEST_PREFIX = "m:"
         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/ui/MapaScreen.kt b/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
new file mode 100644
index 0000000..64ba596
--- /dev/null
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
@@ -0,0 +1,181 @@
+package com.rizoma.matiz.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+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.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.matiz.MatizViewModel
+import com.rizoma.matiz.game.Levels
+import com.rizoma.matiz.ui.theme.MuyTenue
+import com.rizoma.matiz.ui.theme.NeonCian
+import com.rizoma.matiz.ui.theme.NeonRosa
+import com.rizoma.matiz.ui.theme.NeonVerde
+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)
+
+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),
+)
+
+/** Miniatura cacheada del degradado resuelto de un nivel. */
+private class Preview(val level: Int, val cols: Int, val rows: Int, val colors: List<Color>)
+
+/**
+ * El mapa: el viaje completo de niveles agrupado por dificultad. Cada nivel se
+ * dibuja como su propio degradado en miniatura; los resueltos brillan con anillo,
+ * los pendientes quedan atenuados. Todos caben en filas de cuatro.
+ */
+@Composable
+fun MapaScreen(vm: MatizViewModel) {
+    val previews = remember {
+        (1..MAP_LEVELS).map { lvl ->
+            val p = Levels.generate(lvl)
+            Preview(lvl, p.cols, p.rows, p.targets)
+        }
+    }
+    val resueltos = vm.resueltos
+    val mejores = vm.mejores
+
+    Column(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio)
+            .windowInsetsPadding(WindowInsets.systemBars)
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 24.dp),
+    ) {
+        Spacer(Modifier.height(28.dp))
+        Row(
+            Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Column {
+                Text("matiz", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
+                Text("mapa de matices", color = MuyTenue, fontSize = 12.sp)
+            }
+            Text(
+                "${resueltos.count { it in 1..MAP_LEVELS }}/$MAP_LEVELS",
+                color = NeonVerde, fontSize = 18.sp, fontWeight = FontWeight.SemiBold,
+            )
+        }
+        Spacer(Modifier.height(20.dp))
+
+        TIERS.forEach { tier ->
+            val list = previews.filter { it.level in tier.range }
+            if (list.isEmpty()) return@forEach
+            val done = list.count { it.level in resueltos }
+
+            Row(
+                Modifier.fillMaxWidth().padding(top = 14.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                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.weight(1f))
+                Text("$done/${list.size}", color = MuyTenue, fontSize = 12.sp)
+            }
+            Spacer(Modifier.height(12.dp))
+
+            list.chunked(4).forEach { fila ->
+                Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
+                    fila.forEach { pv ->
+                        Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+                            LevelNode(
+                                pv = pv,
+                                solved = pv.level in resueltos,
+                                best = mejores[pv.level],
+                                accent = tier.color,
+                            ) { vm.abrir(pv.level) }
+                        }
+                    }
+                    repeat(4 - fila.size) { Spacer(Modifier.weight(1f)) }
+                }
+            }
+        }
+        Spacer(Modifier.height(48.dp))
+    }
+}
+
+@Composable
+private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, onClick: () -> Unit) {
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+        modifier = Modifier.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)).clickable(onClick = onClick).padding(4.dp),
+    ) {
+        Canvas(Modifier.size(58.dp)) {
+            val a = if (solved) 1f else 0.30f
+            val gap = 1.5.dp.toPx()
+            val radius = CornerRadius(2.dp.toPx())
+            val cw = size.width / pv.cols
+            val ch = size.height / pv.rows
+            for (r in 0 until pv.rows) {
+                for (c in 0 until pv.cols) {
+                    drawRoundRect(
+                        color = pv.colors[r * pv.cols + c].copy(alpha = a),
+                        topLeft = Offset(c * cw + gap / 2f, r * ch + gap / 2f),
+                        size = Size(cw - gap, ch - gap),
+                        cornerRadius = radius,
+                    )
+                }
+            }
+            if (solved) {
+                drawRoundRect(
+                    color = accent,
+                    topLeft = Offset(-1.5.dp.toPx(), -1.5.dp.toPx()),
+                    size = Size(size.width + 3.dp.toPx(), size.height + 3.dp.toPx()),
+                    cornerRadius = CornerRadius(6.dp.toPx()),
+                    style = Stroke(width = 2.dp.toPx()),
+                )
+            }
+        }
+        Spacer(Modifier.height(6.dp))
+        Text(
+            "${pv.level}",
+            color = if (solved) Color.White else MuyTenue,
+            fontSize = 12.sp,
+            fontWeight = if (solved) FontWeight.SemiBold else FontWeight.Normal,
+        )
+        if (best != null) {
+            Text("$best mov", color = accent.copy(alpha = 0.8f), fontSize = 9.sp)
+        }
+    }
+}
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 7df2cb5..5f0d8d1 100644
--- a/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MatizApp.kt
@@ -1,5 +1,6 @@
 package com.rizoma.matiz.ui
 
+import androidx.activity.compose.BackHandler
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
@@ -11,9 +12,11 @@ import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Surface
@@ -27,6 +30,7 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.lifecycle.viewmodel.compose.viewModel
 import com.rizoma.matiz.MatizViewModel
+import com.rizoma.matiz.Pantalla
 import com.rizoma.matiz.ui.theme.MuyTenue
 import com.rizoma.matiz.ui.theme.NeonVerde
 import com.rizoma.matiz.ui.theme.SuperficieAlta
@@ -39,6 +43,16 @@ import androidx.compose.foundation.layout.windowInsetsPadding
 
 @Composable
 fun MatizApp(vm: MatizViewModel = viewModel()) {
+    when (vm.pantalla) {
+        Pantalla.Mapa -> MapaScreen(vm)
+        Pantalla.Juego -> JuegoScreen(vm)
+    }
+}
+
+@Composable
+private fun JuegoScreen(vm: MatizViewModel) {
+    BackHandler(enabled = true) { vm.volverAlMapa() }
+
     val ratio = vm.puzzle.cols.toFloat() / vm.puzzle.rows
 
     Box(
@@ -76,27 +90,37 @@ fun MatizApp(vm: MatizViewModel = viewModel()) {
 
 @Composable
 private fun Header(vm: MatizViewModel) {
-    Row(
-        Modifier.fillMaxWidth().padding(bottom = 8.dp),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        Column(Modifier.weight(1f)) {
-            Text("NIVEL", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
-            Text(
-                "${vm.puzzle.level}",
-                color = Color.White,
-                fontSize = 36.sp,
-                fontWeight = FontWeight.Bold,
-            )
-        }
-        Column(horizontalAlignment = Alignment.End) {
-            Text("MOVIMIENTOS", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
-            Text(
-                "${vm.moves}",
-                color = if (vm.solved) NeonVerde else Color.White,
-                fontSize = 24.sp,
-                fontWeight = FontWeight.SemiBold,
-            )
+    Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
+        Text(
+            "‹  mapa",
+            color = Tenue,
+            fontSize = 14.sp,
+            fontWeight = FontWeight.Medium,
+            modifier = Modifier
+                .clip(RoundedCornerShape(50))
+                .clickable { vm.volverAlMapa() }
+                .padding(vertical = 6.dp, horizontal = 2.dp),
+        )
+        Spacer(Modifier.height(6.dp))
+        Row(verticalAlignment = Alignment.CenterVertically) {
+            Column(Modifier.weight(1f)) {
+                Text("NIVEL", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+                Text(
+                    "${vm.puzzle.level}",
+                    color = Color.White,
+                    fontSize = 36.sp,
+                    fontWeight = FontWeight.Bold,
+                )
+            }
+            Column(horizontalAlignment = Alignment.End) {
+                Text("MOVIMIENTOS", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
+                Text(
+                    "${vm.moves}",
+                    color = if (vm.solved) NeonVerde else Color.White,
+                    fontSize = 24.sp,
+                    fontWeight = FontWeight.SemiBold,
+                )
+            }
         }
     }
 }
@@ -135,6 +159,7 @@ private fun Pill(text: String, enabled: Boolean = true, onClick: () -> Unit) {
 
 @Composable
 private fun SolvedBanner(vm: MatizViewModel) {
+    val best = vm.mejores[vm.puzzle.level]
     Surface(
         color = SuperficieAlta,
         shape = RoundedCornerShape(18.dp),
@@ -147,7 +172,10 @@ private fun SolvedBanner(vm: MatizViewModel) {
         ) {
             Column {
                 Text("resuelto", color = NeonVerde, fontSize = 20.sp, fontWeight = FontWeight.Bold)
-                Text("${vm.moves} movimientos", color = Tenue, fontSize = 13.sp)
+                Text(
+                    if (best != null) "${vm.moves} movimientos · mejor $best" else "${vm.moves} movimientos",
+                    color = Tenue, fontSize = 13.sp,
+                )
             }
             Text(
                 "siguiente  →",