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>
This commit is contained in:
Sergio
2026-06-18 16:44:45 +00:00
co-authored by Claude Opus 4.8
parent e9d35ce8dd
commit 5c80ec5d46
3 changed files with 105 additions and 15 deletions
@@ -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++
}
}
}
}
}
@@ -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")
@@ -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)
}
}
}