⎇ 5c80ec5d46f3465ee3d68b28949f9be1c72f80b4
matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt
History
raw
Matiz: persistir partidas a medias por nivel + marca "en curso" en el mapa
1package com.rizoma.matiz.ui
2
3import androidx.compose.foundation.Canvas
4import androidx.compose.foundation.background
5import androidx.compose.foundation.border
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.WindowInsets
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.systemBars
19import androidx.compose.foundation.layout.width
20import androidx.compose.foundation.layout.windowInsetsPadding
21import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.shape.CircleShape
23import androidx.compose.foundation.shape.RoundedCornerShape
24import androidx.compose.foundation.verticalScroll
25import androidx.compose.material3.Text
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.remember
28import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
31import androidx.compose.ui.geometry.CornerRadius
32import androidx.compose.ui.geometry.Offset
33import androidx.compose.ui.geometry.Size
34import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.drawscope.Stroke
36import androidx.compose.ui.text.font.FontWeight
37import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.sp
39import com.rizoma.matiz.MatizViewModel
40import com.rizoma.matiz.game.Levels
41import com.rizoma.matiz.ui.theme.MuyTenue
42import com.rizoma.matiz.ui.theme.NeonCian
43import com.rizoma.matiz.ui.theme.NeonRosa
44import com.rizoma.matiz.ui.theme.NeonVerde
45import com.rizoma.matiz.ui.theme.Vacio
46
47private const val MAP_LEVELS = 48
48
49// Nombre + color por dificultad; el rango de niveles base sale de
50// Levels.TIER_RANGES (fuente única, compartida con la generación).
51private data class Tier(val nombre: String, val color: Color)
52
53private val TIERS = listOf(
54 Tier("fácil", NeonVerde),
55 Tier("ameno", NeonCian),
56 Tier("difícil", NeonRosa),
57 Tier("experto", NeonVerde),
58 Tier("maestro", NeonCian),
59)
60
61/** Miniatura cacheada del degradado resuelto de un nivel. */
62private class Preview(val level: Int, val cols: Int, val rows: Int, val colors: List<Color>)
63
64/**
65 * El mapa: el viaje completo de niveles agrupado por dificultad. Cada nivel se
66 * dibuja como su propio degradado en miniatura; los resueltos brillan con anillo,
67 * los pendientes quedan atenuados. Todos caben en filas de cuatro.
68 */
69@Composable
70fun MapaScreen(vm: MatizViewModel) {
71 val previews = remember {
72 (1..MAP_LEVELS).map { lvl ->
73 val p = Levels.generate(lvl)
74 Preview(lvl, p.cols, p.rows, p.targets)
75 }
76 }
77 val resueltos = vm.resueltos
78 val mejores = vm.mejores
79 val generados = vm.generados
80 val enProgreso = vm.enProgreso
81
82 // Miniaturas de los niveles generados, recalculadas solo al cambiar la cuenta.
83 val genPreviews = remember(generados) {
84 Levels.TIER_RANGES.indices.associateWith { t ->
85 (0 until (generados[t] ?: 0)).map { k ->
86 val id = Levels.generatedId(t, k)
87 val p = Levels.generate(id)
88 Preview(id, p.cols, p.rows, p.targets)
89 }
90 }
91 }
92
93 Column(
94 Modifier
95 .fillMaxSize()
96 .background(Vacio)
97 .windowInsetsPadding(WindowInsets.systemBars)
98 .verticalScroll(rememberScrollState())
99 .padding(horizontal = 24.dp),
100 ) {
101 Spacer(Modifier.height(28.dp))
102 Row(
103 Modifier.fillMaxWidth(),
104 horizontalArrangement = Arrangement.SpaceBetween,
105 verticalAlignment = Alignment.CenterVertically,
106 ) {
107 Column {
108 Text("matiz", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
109 Text("mapa de matices", color = MuyTenue, fontSize = 12.sp)
110 }
111 Text(
112 "${resueltos.count { it in 1..MAP_LEVELS }}/$MAP_LEVELS",
113 color = NeonVerde, fontSize = 18.sp, fontWeight = FontWeight.SemiBold,
114 )
115 }
116 Spacer(Modifier.height(20.dp))
117
118 TIERS.forEachIndexed { idx, tier ->
119 val base = previews.filter { it.level in Levels.TIER_RANGES[idx] }
120 val list = base + genPreviews[idx].orEmpty()
121 if (list.isEmpty()) return@forEachIndexed
122 val done = list.count { it.level in resueltos }
123
124 Row(
125 Modifier.fillMaxWidth().padding(top = 14.dp),
126 verticalAlignment = Alignment.CenterVertically,
127 ) {
128 Box(Modifier.size(7.dp).clip(CircleShape).background(tier.color))
129 Spacer(Modifier.width(10.dp))
130 Text(tier.nombre, color = tier.color, fontSize = 14.sp, letterSpacing = 3.sp, fontWeight = FontWeight.Medium)
131 Spacer(Modifier.width(12.dp))
132 GenerarChip(tier.color) { vm.generar(idx) }
133 Spacer(Modifier.weight(1f))
134 Text("$done/${list.size}", color = MuyTenue, fontSize = 12.sp)
135 }
136 Spacer(Modifier.height(12.dp))
137
138 list.chunked(4).forEach { fila ->
139 Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
140 fila.forEach { pv ->
141 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
142 LevelNode(
143 pv = pv,
144 solved = pv.level in resueltos,
145 enProgreso = pv.level in enProgreso,
146 best = mejores[pv.level],
147 accent = tier.color,
148 label = if (Levels.isGenerated(pv.level)) "✦" else "${pv.level}",
149 ) { vm.abrir(pv.level) }
150 }
151 }
152 repeat(4 - fila.size) { Spacer(Modifier.weight(1f)) }
153 }
154 }
155 }
156 Spacer(Modifier.height(48.dp))
157 }
158}
159
160/** Botón generador de una dificultad: añade 4 niveles de su misma banda. */
161@Composable
162private fun GenerarChip(accent: Color, onClick: () -> Unit) {
163 Text(
164 "✦ +4",
165 color = accent,
166 fontSize = 11.sp,
167 fontWeight = FontWeight.SemiBold,
168 modifier = Modifier
169 .clip(RoundedCornerShape(50))
170 .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(50))
171 .clickable(onClick = onClick)
172 .padding(horizontal = 10.dp, vertical = 4.dp),
173 )
174}
175
176@Composable
177private fun LevelNode(
178 pv: Preview,
179 solved: Boolean,
180 enProgreso: Boolean,
181 best: Int?,
182 accent: Color,
183 label: String,
184 onClick: () -> Unit,
185) {
186 Column(
187 horizontalAlignment = Alignment.CenterHorizontally,
188 modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(onClick = onClick).padding(4.dp),
189 ) {
190 Canvas(Modifier.size(58.dp)) {
191 // Empezado-sin-resolver queda a medio brillo: ni apagado ni resuelto.
192 val a = when {
193 solved -> 1f
194 enProgreso -> 0.62f
195 else -> 0.30f
196 }
197 val gap = 1.5.dp.toPx()
198 val radius = CornerRadius(2.dp.toPx())
199 val cw = size.width / pv.cols
200 val ch = size.height / pv.rows
201 for (r in 0 until pv.rows) {
202 for (c in 0 until pv.cols) {
203 drawRoundRect(
204 color = pv.colors[r * pv.cols + c].copy(alpha = a),
205 topLeft = Offset(c * cw + gap / 2f, r * ch + gap / 2f),
206 size = Size(cw - gap, ch - gap),
207 cornerRadius = radius,
208 )
209 }
210 }
211 // Anillo: lleno si resuelto, tenue si hay partida a medias.
212 if (solved || enProgreso) {
213 drawRoundRect(
214 color = if (solved) accent else accent.copy(alpha = 0.5f),
215 topLeft = Offset(-1.5.dp.toPx(), -1.5.dp.toPx()),
216 size = Size(size.width + 3.dp.toPx(), size.height + 3.dp.toPx()),
217 cornerRadius = CornerRadius(6.dp.toPx()),
218 style = Stroke(width = if (solved) 2.dp.toPx() else 1.dp.toPx()),
219 )
220 }
221 }
222 Spacer(Modifier.height(6.dp))
223 Text(
224 label,
225 color = if (solved || enProgreso) Color.White else MuyTenue,
226 fontSize = 12.sp,
227 fontWeight = if (solved) FontWeight.SemiBold else FontWeight.Normal,
228 )
229 when {
230 best != null -> Text("$best mov", color = accent.copy(alpha = 0.8f), fontSize = 9.sp)
231 enProgreso -> Text("en curso", color = accent.copy(alpha = 0.7f), fontSize = 9.sp)
232 }
233 }
234}