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>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
4 qillqakuna · +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 →",