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:
co-authored by
Claude Opus 4.8
parent
e9d35ce8dd
commit
5c80ec5d46
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user