tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

Matiz: persistir partidas a medias por nivel + marca "en curso" en el mapa

Guarda la disposición de cada nivel sin resolver y la reanuda al reabrirlo;
el mapa muestra los empezados a medio brillo con etiqueta "en curso". Barajar
descarta el parcial; resolver lo limpia.

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

5c80ec5d46f3465ee3d68b28949f9be1c72f80b4 · ruwaq Sergio · (4 killa ñawpaq) · tayta e9d35ce8d · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

3 qillqakuna · +105 −15

diff --git a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
index 95e5a05..c45414a 100644
--- a/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/MatizViewModel.kt
@@ -35,10 +35,14 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
     // Niveles extra generados por dificultad (índice de tier → cuenta).
     var generados by mutableStateOf<Map<Int, Int>>(emptyMap()); private set
 
+    // Niveles con partida a medias (sin resolver), para marcarlos en el mapa.
+    var enProgreso by mutableStateOf<Set<Int>>(emptySet()); private set
+
     init {
         viewModelScope.launch { progreso.resueltos.collect { resueltos = it } }
         viewModelScope.launch { progreso.mejores.collect { mejores = it } }
         viewModelScope.launch { progreso.generados.collect { generados = it } }
+        viewModelScope.launch { progreso.enProgreso.collect { enProgreso = it } }
     }
 
     /** Etiqueta del nivel en juego: número para los base, ✦ para los generados. */
@@ -68,14 +72,18 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
         val t = p.arrangement[a]; p.arrangement[a] = p.arrangement[b]; p.arrangement[b] = t
         moves++
         epoch++
+        val m = moves
         if (p.solved) {
             solved = true
-            val m = moves
             viewModelScope.launch {
-                progreso.marcarResuelto(p.level, m)
+                progreso.marcarResuelto(p.level, m) // también descarta el parcial
                 // El progreso lineal (nivel/máximo) solo lo mueven los niveles base.
                 if (!Levels.isGenerated(p.level)) progreso.setNivel(p.level + 1)
             }
+        } else {
+            // Partida a medias: persiste la disposición para reanudarla luego.
+            val arr = p.arrangement.copyOf()
+            viewModelScope.launch { progreso.guardarParcial(p.level, arr, m) }
         }
     }
 
@@ -99,15 +107,35 @@ class MatizViewModel(app: Application) : AndroidViewModel(app) {
         if (puedeAnterior) load(puzzle.level - 1)
     }
 
-    fun retry() = load(puzzle.level)
+    /** "Barajar": descarta la partida a medias y empieza el nivel de cero. */
+    fun retry() {
+        val lvl = puzzle.level
+        viewModelScope.launch { progreso.limpiarParcial(lvl) }
+        load(lvl, restaurar = false)
+    }
 
-    private fun load(level: Int) {
-        puzzle = Levels.generate(level.coerceAtLeast(1))
+    private fun load(level: Int, restaurar: Boolean = true) {
+        val lvl = level.coerceAtLeast(1)
+        val p = Levels.generate(lvl)
+        puzzle = p
         moves = 0
         solved = false
         epoch++
-        if (!Levels.isGenerated(puzzle.level)) {
-            viewModelScope.launch { progreso.setNivel(puzzle.level) }
+        if (!Levels.isGenerated(lvl)) {
+            viewModelScope.launch { progreso.setNivel(lvl) }
+        }
+        // Reanuda la disposición a medias si la hay. Asíncrono: si encaja y
+        // seguimos en el mismo puzzle, se aplica sobre el recién generado.
+        if (restaurar) {
+            viewModelScope.launch {
+                val parc = progreso.cargarParcial(lvl)
+                if (parc != null && puzzle === p && parc.arrangement.size == p.size) {
+                    parc.arrangement.copyInto(p.arrangement)
+                    moves = parc.moves
+                    solved = p.solved
+                    epoch++
+                }
+            }
         }
     }
 }
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 c7008fd..912ff0c 100644
--- a/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/data/Progreso.kt
@@ -5,9 +5,11 @@ 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.stringPreferencesKey
 import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 
 private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "matiz")
@@ -38,6 +40,17 @@ class Progreso(context: Context) {
         }
     }
 
+    /** Niveles con una partida a medias guardada (disposición sin resolver). */
+    val enProgreso: Flow<Set<Int>> = store.data.map { prefs ->
+        buildSet {
+            prefs.asMap().forEach { (k, _) ->
+                if (k.name.startsWith(PARC_PREFIX)) {
+                    k.name.removePrefix(PARC_PREFIX).toIntOrNull()?.let { add(it) }
+                }
+            }
+        }
+    }
+
     /** Cuántos niveles extra se han generado por dificultad (índice de tier → cuenta). */
     val generados: Flow<Map<Int, Int>> = store.data.map { prefs ->
         buildMap {
@@ -74,12 +87,44 @@ class Progreso(context: Context) {
             val mk = intPreferencesKey(BEST_PREFIX + level)
             val prev = p[mk]
             if (prev == null || moves < prev) p[mk] = moves
+            // Resuelto ⇒ ya no es una partida a medias.
+            p.remove(stringPreferencesKey(PARC_PREFIX + level))
+            p.remove(intPreferencesKey(MOV_PREFIX + level))
+        }
+    }
+
+    /** Disposición a medias de un nivel, o null si no hay (o no encaja). */
+    data class Parcial(val arrangement: IntArray, val moves: Int)
+
+    suspend fun cargarParcial(level: Int): Parcial? {
+        val p = store.data.first()
+        val csv = p[stringPreferencesKey(PARC_PREFIX + level)] ?: return null
+        val arr = csv.split(',').mapNotNull { it.toIntOrNull() }.toIntArray()
+        if (arr.isEmpty()) return null
+        return Parcial(arr, p[intPreferencesKey(MOV_PREFIX + level)] ?: 0)
+    }
+
+    /** Guarda la disposición a medias de un nivel (tras cada intercambio). */
+    suspend fun guardarParcial(level: Int, arrangement: IntArray, moves: Int) {
+        store.edit { p ->
+            p[stringPreferencesKey(PARC_PREFIX + level)] = arrangement.joinToString(",")
+            p[intPreferencesKey(MOV_PREFIX + level)] = moves
+        }
+    }
+
+    /** Descarta la partida a medias de un nivel (al resolver o al barajar). */
+    suspend fun limpiarParcial(level: Int) {
+        store.edit { p ->
+            p.remove(stringPreferencesKey(PARC_PREFIX + level))
+            p.remove(intPreferencesKey(MOV_PREFIX + level))
         }
     }
 
     private companion object {
         const val BEST_PREFIX = "m:"
         const val GEN_PREFIX = "g:"
+        const val PARC_PREFIX = "p:" // disposición a medias por nivel
+        const val MOV_PREFIX = "pm:" // movimientos de esa partida a medias
         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
index 2133f42..c765b2c 100644
--- a/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
+++ b/matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
@@ -77,6 +77,7 @@ fun MapaScreen(vm: MatizViewModel) {
     val resueltos = vm.resueltos
     val mejores = vm.mejores
     val generados = vm.generados
+    val enProgreso = vm.enProgreso
 
     // Miniaturas de los niveles generados, recalculadas solo al cambiar la cuenta.
     val genPreviews = remember(generados) {
@@ -141,6 +142,7 @@ fun MapaScreen(vm: MatizViewModel) {
                             LevelNode(
                                 pv = pv,
                                 solved = pv.level in resueltos,
+                                enProgreso = pv.level in enProgreso,
                                 best = mejores[pv.level],
                                 accent = tier.color,
                                 label = if (Levels.isGenerated(pv.level)) "✦" else "${pv.level}",
@@ -172,13 +174,26 @@ private fun GenerarChip(accent: Color, onClick: () -> Unit) {
 }
 
 @Composable
-private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, label: String, onClick: () -> Unit) {
+private fun LevelNode(
+    pv: Preview,
+    solved: Boolean,
+    enProgreso: Boolean,
+    best: Int?,
+    accent: Color,
+    label: String,
+    onClick: () -> Unit,
+) {
     Column(
         horizontalAlignment = Alignment.CenterHorizontally,
         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
+            // Empezado-sin-resolver queda a medio brillo: ni apagado ni resuelto.
+            val a = when {
+                solved -> 1f
+                enProgreso -> 0.62f
+                else -> 0.30f
+            }
             val gap = 1.5.dp.toPx()
             val radius = CornerRadius(2.dp.toPx())
             val cw = size.width / pv.cols
@@ -193,25 +208,27 @@ private fun LevelNode(pv: Preview, solved: Boolean, best: Int?, accent: Color, l
                     )
                 }
             }
-            if (solved) {
+            // Anillo: lleno si resuelto, tenue si hay partida a medias.
+            if (solved || enProgreso) {
                 drawRoundRect(
-                    color = accent,
+                    color = if (solved) accent else accent.copy(alpha = 0.5f),
                     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()),
+                    style = Stroke(width = if (solved) 2.dp.toPx() else 1.dp.toPx()),
                 )
             }
         }
         Spacer(Modifier.height(6.dp))
         Text(
             label,
-            color = if (solved) Color.White else MuyTenue,
+            color = if (solved || enProgreso) 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)
+        when {
+            best != null -> Text("$best mov", color = accent.copy(alpha = 0.8f), fontSize = 9.sp)
+            enProgreso -> Text("en curso", color = accent.copy(alpha = 0.7f), fontSize = 9.sp)
         }
     }
 }