tawasuyu

sergio/humanoid

HTML 44Markdown 1TOML 10 Definitions

⎇ 5c80ec5d46f3465ee3d68b28949f9be1c72f80b4 matiz/src/main/java/com/rizoma/matiz/ui/MapaScreen.kt 234 lines · 9.0 KB History raw

Matiz: persistir partidas a medias por nivel + marca "en curso" en el mapa 5c80ec5d4 · Sergio ·

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}