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