gioser

sergio/humanoid

HTML 37Markdown 1TOML 10 Definiciones

⎇ c02fd3a4d4a9b6e3a51ddd2f59112f3584e6ea12 nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt 466 líneas · 20.2 KB Historia crudo

Nonet: 3 variantes de pista externa, dificultad real y menú en páginas c02fd3a4d · Sergio ·

1package com.rizoma.nonet.ui
2
3import android.graphics.Paint
4import android.graphics.Typeface
5import androidx.compose.foundation.Canvas
6import androidx.compose.foundation.background
7import androidx.compose.foundation.border
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Spacer
14import androidx.compose.foundation.layout.WindowInsets
15import androidx.compose.foundation.layout.aspectRatio
16import androidx.compose.foundation.layout.fillMaxHeight
17import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.systemBars
22import androidx.compose.foundation.layout.windowInsetsPadding
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.shape.RoundedCornerShape
25import androidx.compose.foundation.verticalScroll
26import androidx.compose.material3.Text
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.remember
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.draw.alpha
32import androidx.compose.ui.draw.clip
33import androidx.compose.ui.geometry.Offset
34import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.PathEffect
36import androidx.compose.ui.graphics.nativeCanvas
37import androidx.compose.ui.graphics.toArgb
38import androidx.compose.ui.text.font.FontWeight
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.unit.sp
41import com.rizoma.nonet.NonetViewModel
42import com.rizoma.nonet.game.Puzzle
43import com.rizoma.nonet.game.Rel
44import com.rizoma.nonet.model.Variant
45import com.rizoma.nonet.ui.theme.MuyTenue
46import com.rizoma.nonet.ui.theme.NeonAmbar
47import com.rizoma.nonet.ui.theme.NeonCian
48import com.rizoma.nonet.ui.theme.NeonRosa
49import com.rizoma.nonet.ui.theme.NeonVerde
50import com.rizoma.nonet.ui.theme.Superficie
51import com.rizoma.nonet.ui.theme.SuperficieAlta
52import com.rizoma.nonet.ui.theme.Tenue
53import com.rizoma.nonet.ui.theme.Vacio
54
55private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
56
57@Composable
58fun BoardScreen(vm: NonetViewModel) {
59    Column(
60        Modifier
61            .fillMaxSize()
62            .background(Vacio)
63            .windowInsetsPadding(WindowInsets.systemBars)
64            .verticalScroll(rememberScrollState())
65            .padding(14.dp),
66    ) {
67        Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
68            Text(
69                "‹  menú",
70                color = Tenue, fontSize = 14.sp, fontWeight = FontWeight.Medium,
71                modifier = Modifier.clip(RoundedCornerShape(50)).clickable { vm.volverAlMenu() }
72                    .padding(vertical = 6.dp, horizontal = 2.dp),
73            )
74            Spacer(Modifier.weight(1f))
75            Text(vm.variante.titulo, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
76            Text("  ·  ${vm.dificultad.titulo}", color = MuyTenue, fontSize = 13.sp)
77        }
78
79        Spacer(Modifier.height(10.dp))
80        Text(
81            when {
82                vm.completado -> "¡resuelto!"
83                vm.errores.isNotEmpty() -> "${vm.errores.size} por revisar"
84                else -> "elige un número y toca las celdas"
85            },
86            color = when {
87                vm.completado -> NeonVerde
88                vm.errores.isNotEmpty() -> NeonRosa
89                else -> MuyTenue
90            },
91            fontSize = 13.sp,
92            fontWeight = if (vm.completado) FontWeight.Bold else FontWeight.Normal,
93        )
94        Spacer(Modifier.height(10.dp))
95
96        if (vm.generando || vm.puzzle == null) {
97            Box(Modifier.fillMaxWidth().height(300.dp), contentAlignment = Alignment.Center) {
98                Text("generando…", color = NeonCian, fontSize = 16.sp)
99            }
100            return@Column
101        }
102
103        Tablero(vm)
104        Spacer(Modifier.height(14.dp))
105        Teclado(vm)
106        Spacer(Modifier.height(16.dp))
107    }
108}
109
110@Composable
111private fun Tablero(vm: NonetViewModel) {
112    val p = vm.puzzle ?: return
113    val variante = vm.variante
114    val sombreadas = remember(variante) { variante.highlightCells() }
115    val activo = vm.activo
116    val foco = vm.celdaFoco
117    val relacionadas = if (foco in 0..80) p.peers[foco].toHashSet() else emptySet<Int>()
118    val conflictos = vm.conflictos()
119    val errores = vm.errores
120
121    // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas, pistas de borde).
122    val paint = remember {
123        Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
124    }
125    // Las variantes con pistas externas reservan un margen alrededor del tablero.
126    val mg = if (p.borde != null) 24.dp else 0.dp
127
128    Box(Modifier.fillMaxWidth().aspectRatio(1f)) {
129      Box(Modifier.fillMaxSize().padding(mg).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
130        Column(Modifier.fillMaxSize()) {
131            for (r in 0 until 9) {
132                Row(Modifier.fillMaxWidth().weight(1f)) {
133                    for (c in 0 until 9) {
134                        val i = r * 9 + c
135                        Celda(
136                            modifier = Modifier.weight(1f).fillMaxHeight(),
137                            valor = vm.entradas[i],
138                            notas = vm.notas[i],
139                            activo = activo,
140                            fija = vm.esFija(i),
141                            foco = i == foco,
142                            relacionada = i in relacionadas,
143                            mismoDigito = activo in 1..9 && vm.entradas[i] == activo,
144                            errorRojo = i in conflictos || i in errores,
145                            sombra = sombraDe(i, p, variante, sombreadas),
146                            onClick = { vm.tocarCelda(i) },
147                        )
148                    }
149                }
150            }
151        }
152        // Overlay: líneas de regiones/cajas, diagonales, marcas de aristas y jaulas.
153        Canvas(Modifier.fillMaxSize()) {
154            val cell = size.width / 9f
155            fun cx(c: Int) = (c + 0.5f) * cell
156            fun cy(r: Int) = (r + 0.5f) * cell
157
158            if (variante == Variant.X) {
159                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(0f, 0f), Offset(size.width, size.height), 3f)
160                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, size.height), 3f)
161            }
162
163            // Líneas finas de todas las celdas.
164            for (k in 0..9) {
165                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
166                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
167            }
168            // Bordes gruesos: regiones del jigsaw, o cajas 3×3.
169            val reg = p.regiones
170            if (reg != null) {
171                for (r in 0 until 9) for (c in 0 until 9) {
172                    val i = r * 9 + c
173                    if (c < 8 && reg[i] != reg[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
174                    if (r < 8 && reg[i] != reg[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
175                }
176                drawLine(Tenue, Offset(0f, 0f), Offset(size.width, 0f), 3f)
177                drawLine(Tenue, Offset(0f, size.height), Offset(size.width, size.height), 3f)
178                drawLine(Tenue, Offset(0f, 0f), Offset(0f, size.height), 3f)
179                drawLine(Tenue, Offset(size.width, 0f), Offset(size.width, size.height), 3f)
180            } else {
181                for (k in 0..9 step 3) {
182                    drawLine(Tenue, Offset(k * cell, 0f), Offset(k * cell, size.height), 3f)
183                    drawLine(Tenue, Offset(0f, k * cell), Offset(size.width, k * cell), 3f)
184                }
185            }
186
187            // Jaulas (Killer): borde punteado + suma en la celda superior izquierda.
188            if (p.reglas.cages.isNotEmpty()) {
189                val cageId = IntArray(81) { -1 }
190                p.reglas.cages.forEachIndexed { id, cg -> for (cl in cg.cells) cageId[cl] = id }
191                val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
192                val ins = cell * 0.10f
193                for (rr in 0 until 9) for (cc in 0 until 9) {
194                    val i = rr * 9 + cc
195                    val id = cageId[i]
196                    val l = cc * cell + ins; val rt = (cc + 1) * cell - ins
197                    val t = rr * cell + ins; val b = (rr + 1) * cell - ins
198                    if (cc == 0 || cageId[i - 1] != id) drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
199                    if (cc == 8 || cageId[i + 1] != id) drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
200                    if (rr == 0 || cageId[i - 9] != id) drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
201                    if (rr == 8 || cageId[i + 9] != id) drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
202                }
203                paint.color = NeonAmbar.toArgb()
204                paint.textAlign = Paint.Align.LEFT
205                paint.textSize = cell * 0.26f
206                for (cg in p.reglas.cages) {
207                    val top = cg.cells.min()
208                    drawIntoCanvas2(this) { nc ->
209                        nc.drawText("${cg.sum}", (top % 9) * cell + ins + 2f, (top / 9) * cell + ins + cell * 0.24f, paint)
210                    }
211                }
212                paint.textAlign = Paint.Align.CENTER
213            }
214
215            // Marcas de aristas (consecutivo / XV / mayor-que).
216            if (p.aristas.isNotEmpty()) {
217                paint.textSize = cell * 0.42f
218                for (a in p.aristas) {
219                    val ra = a.a / 9; val ca = a.a % 9
220                    val horizontal = a.b == a.a + 1
221                    val mx = if (horizontal) (ca + 1) * cell else cx(ca)
222                    val my = if (horizontal) cy(ra) else (ra + 1) * cell
223                    when (a.rel) {
224                        Rel.CONSEC -> {
225                            // Barra blanca perpendicular a la arista.
226                            val h = cell * 0.16f
227                            if (horizontal) drawLine(Color.White, Offset(mx, my - h), Offset(mx, my + h), 4f)
228                            else drawLine(Color.White, Offset(mx - h, my), Offset(mx + h, my), 4f)
229                        }
230                        Rel.SUM5, Rel.SUM10 -> {
231                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
232                            paint.color = NeonCian.toArgb()
233                            glifo(this, paint, if (a.rel == Rel.SUM10) "X" else "V", mx, my)
234                        }
235                        Rel.LT, Rel.GT -> {
236                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
237                            paint.color = NeonVerde.toArgb()
238                            val s = when {
239                                horizontal && a.rel == Rel.LT -> "‹"
240                                horizontal -> "›"
241                                a.rel == Rel.LT -> "˄"
242                                else -> "˅"
243                            }
244                            glifo(this, paint, s, mx, my)
245                        }
246                        else -> {}
247                    }
248                }
249            }
250        }
251      }
252      // Marco de pistas externas: rascacielos (números), little killer y rossini (flechas).
253      if (p.borde != null) {
254          Canvas(Modifier.fillMaxSize()) {
255              val m = mg.toPx()
256              val inner = size.width - 2 * m
257              val cell = inner / 9f
258              fun gx(c: Int) = m + (c + 0.5f) * cell
259              fun gy(r: Int) = m + (r + 0.5f) * cell
260              val b = p.borde ?: return@Canvas
261              b.sky?.let { sky ->
262                  paint.color = NeonCian.toArgb(); paint.textSize = cell * 0.40f
263                  for (i in 0 until 9) {
264                      if (sky[0][i] > 0) glifo(this, paint, "${sky[0][i]}", gx(i), m * 0.5f)
265                      if (sky[1][i] > 0) glifo(this, paint, "${sky[1][i]}", gx(i), size.height - m * 0.5f)
266                      if (sky[2][i] > 0) glifo(this, paint, "${sky[2][i]}", m * 0.5f, gy(i))
267                      if (sky[3][i] > 0) glifo(this, paint, "${sky[3][i]}", size.width - m * 0.5f, gy(i))
268                  }
269              }
270              b.rossini?.let { ros ->
271                  paint.color = NeonVerde.toArgb(); paint.textSize = cell * 0.5f
272                  for (i in 0 until 9) {
273                      flecha(ros[0][i], 0)?.let { glifo(this, paint, it, gx(i), m * 0.5f) }
274                      flecha(ros[1][i], 1)?.let { glifo(this, paint, it, gx(i), size.height - m * 0.5f) }
275                      flecha(ros[2][i], 2)?.let { glifo(this, paint, it, m * 0.5f, gy(i)) }
276                      flecha(ros[3][i], 3)?.let { glifo(this, paint, it, size.width - m * 0.5f, gy(i)) }
277                  }
278              }
279              if (b.littleKiller.isNotEmpty()) {
280                  paint.color = NeonAmbar.toArgb()
281                  for (lk in b.littleKiller) {
282                      paint.textSize = cell * 0.5f
283                      glifo(this, paint, if (lk.dir == 0) "↘" else "↗", gx(lk.col), m * 0.34f)
284                      paint.textSize = cell * 0.30f
285                      glifo(this, paint, "${lk.sum}", gx(lk.col), m * 0.74f)
286                  }
287              }
288          }
289      }
290    }
291}
292
293/** Flecha de Rossini para un lado (0=arr,1=ab,2=izq,3=der): apunta hacia el orden creciente. */
294private fun flecha(estado: Int, lado: Int): String? {
295    if (estado == 0) return null
296    val creciente = estado == 1
297    return when (lado) {
298        0 -> if (creciente) "↓" else "↑"
299        1 -> if (creciente) "↑" else "↓"
300        2 -> if (creciente) "→" else "←"
301        else -> if (creciente) "←" else "→"
302    }
303}
304
305/** Dibuja [s] centrado verticalmente en (x,y) con el [paint] dado. */
306private fun glifo(scope: androidx.compose.ui.graphics.drawscope.DrawScope, paint: Paint, s: String, x: Float, y: Float) {
307    drawIntoCanvas2(scope) { nc ->
308        val dy = (paint.descent() + paint.ascent()) / 2f
309        nc.drawText(s, x, y - dy, paint)
310    }
311}
312
313private inline fun drawIntoCanvas2(
314    scope: androidx.compose.ui.graphics.drawscope.DrawScope,
315    block: (android.graphics.Canvas) -> Unit,
316) {
317    block(scope.drawContext.canvas.nativeCanvas)
318}
319
320@Composable
321private fun Celda(
322    modifier: Modifier,
323    valor: Int,
324    notas: Int,
325    activo: Int,
326    fija: Boolean,
327    foco: Boolean,
328    relacionada: Boolean,
329    mismoDigito: Boolean,
330    errorRojo: Boolean,
331    sombra: Color,
332    onClick: () -> Unit,
333) {
334    val bg = when {
335        foco -> NeonCian.copy(alpha = 0.32f)
336        mismoDigito -> NeonCian.copy(alpha = 0.16f)
337        relacionada -> Color.White.copy(alpha = 0.05f)
338        else -> sombra
339    }
340    Box(modifier.background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
341        when {
342            valor != 0 -> Text(
343                "$valor",
344                color = when { errorRojo -> NeonRosa; fija -> Color.White; else -> NeonCian },
345                fontSize = 18.sp,
346                fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
347            )
348            notas != 0 -> NotasMini(notas, activo)
349        }
350    }
351}
352
353@Composable
354private fun NotasMini(notas: Int, activo: Int) {
355    Column(Modifier.fillMaxSize().padding(1.dp)) {
356        for (fila in 0 until 3) {
357            Row(Modifier.fillMaxWidth().weight(1f)) {
358                for (col in 0 until 3) {
359                    val d = fila * 3 + col + 1
360                    Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
361                        if (notas and (1 shl d) != 0) {
362                            Text(
363                                "$d",
364                                color = if (d == activo) NeonCian else MuyTenue,
365                                fontSize = 8.sp,
366                                fontWeight = if (d == activo) FontWeight.Bold else FontWeight.Normal,
367                            )
368                        }
369                    }
370                }
371            }
372        }
373    }
374}
375
376private fun sombraDe(i: Int, p: Puzzle, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
377    Variant.Realce.CELDAS -> if (i in sombreadas) NeonAmbar.copy(alpha = 0.13f) else Color.Transparent
378    Variant.Realce.POSICION -> {
379        val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
380        Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
381    }
382    // Par/impar: las celdas "par" (cuadradas) van sombreadas; las impar, limpias.
383    Variant.Realce.PARIDAD -> if (p.reglas.allowed[i] == PARES) Color.White.copy(alpha = 0.08f) else Color.Transparent
384    else -> Color.Transparent
385}
386
387@Composable
388private fun Teclado(vm: NonetViewModel) {
389    val restantes = IntArray(10)
390    for (v in vm.entradas) if (v in 1..9) restantes[v]++
391
392    // Columnas: [undo/redo/comprobar]  ·  3×3 números  ·  [edit/borrar/pista]
393    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
394        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
395            Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer) { vm.deshacer() }
396            Accion("↷", "rehacer", NeonCian, vm.puedeRehacer) { vm.rehacerJugada() }
397            Accion("♥", "comprobar", NeonVerde, !vm.completado) { vm.comprobar() }
398        }
399        Column(Modifier.weight(3f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
400            for (fila in 0 until 3) {
401                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
402                    for (col in 0 until 3) {
403                        val d = fila * 3 + col + 1
404                        DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
405                    }
406                }
407            }
408        }
409        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
410            Accion(
411                "✎", if (vm.modoEdicion) "edit ✓" else "edit",
412                if (vm.modoEdicion) NeonAmbar else Tenue, true, resaltado = vm.modoEdicion,
413            ) { vm.alternarEdicion() }
414            Accion("⌫", "borrar", NeonRosa, true, resaltado = vm.activo == NonetViewModel.BORRAR) { vm.activarBorrar() }
415            Accion("◌", "pista", NeonAmbar, !vm.completado) { vm.pista() }
416        }
417    }
418}
419
420@Composable
421private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifier, onClick: () -> Unit) {
422    val agotado = restantes <= 0
423    Box(
424        modifier
425            .height(48.dp)
426            .clip(RoundedCornerShape(12.dp))
427            .background(if (activo) NeonCian.copy(alpha = 0.20f) else SuperficieAlta)
428            .border(1.dp, if (activo) NeonCian else Color.Transparent, RoundedCornerShape(12.dp))
429            .clickable(onClick = onClick),
430        contentAlignment = Alignment.Center,
431    ) {
432        Text("$d", color = if (agotado) MuyTenue else NeonCian, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
433        Text(
434            "$restantes", color = MuyTenue, fontSize = 9.sp,
435            modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
436        )
437    }
438}
439
440@Composable
441private fun Accion(
442    icono: String,
443    label: String,
444    accent: Color,
445    habilitado: Boolean,
446    resaltado: Boolean = false,
447    onClick: () -> Unit,
448) {
449    Box(
450        Modifier
451            .fillMaxWidth()
452            .height(48.dp)
453            .clip(RoundedCornerShape(12.dp))
454            .background(if (resaltado) accent.copy(alpha = 0.18f) else SuperficieAlta)
455            .border(1.dp, if (resaltado) accent else Color.Transparent, RoundedCornerShape(12.dp))
456            .alpha(if (habilitado) 1f else 0.35f)
457            .clickable(enabled = habilitado, onClick = onClick),
458        contentAlignment = Alignment.Center,
459    ) {
460        Text(icono, color = accent, fontSize = 16.sp)
461        Text(
462            label, color = accent, fontSize = 8.sp,
463            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
464        )
465    }
466}