gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

⎇ 6d5368c0b298161a61a974eb0c2905c0ec0d183f nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt 412 siq'ikuna · 17.6 KB Ñawpa willay ch'uya

Nonet: 6 variantes nuevas (jigsaw, par/impar, consecutivo, XV, mayor-que, killer) 6d5368c0b · 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 de jaula).
122    val paint = remember {
123        Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
124    }
125
126    Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
127        Column(Modifier.fillMaxSize()) {
128            for (r in 0 until 9) {
129                Row(Modifier.fillMaxWidth().weight(1f)) {
130                    for (c in 0 until 9) {
131                        val i = r * 9 + c
132                        Celda(
133                            modifier = Modifier.weight(1f).fillMaxHeight(),
134                            valor = vm.entradas[i],
135                            notas = vm.notas[i],
136                            activo = activo,
137                            fija = vm.esFija(i),
138                            foco = i == foco,
139                            relacionada = i in relacionadas,
140                            mismoDigito = activo in 1..9 && vm.entradas[i] == activo,
141                            errorRojo = i in conflictos || i in errores,
142                            sombra = sombraDe(i, p, variante, sombreadas),
143                            onClick = { vm.tocarCelda(i) },
144                        )
145                    }
146                }
147            }
148        }
149        // Overlay: líneas de regiones/cajas, diagonales, marcas de aristas y jaulas.
150        Canvas(Modifier.fillMaxSize()) {
151            val cell = size.width / 9f
152            fun cx(c: Int) = (c + 0.5f) * cell
153            fun cy(r: Int) = (r + 0.5f) * cell
154
155            if (variante == Variant.X) {
156                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(0f, 0f), Offset(size.width, size.height), 3f)
157                drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, size.height), 3f)
158            }
159
160            // Líneas finas de todas las celdas.
161            for (k in 0..9) {
162                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
163                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
164            }
165            // Bordes gruesos: regiones del jigsaw, o cajas 3×3.
166            val reg = p.regiones
167            if (reg != null) {
168                for (r in 0 until 9) for (c in 0 until 9) {
169                    val i = r * 9 + c
170                    if (c < 8 && reg[i] != reg[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
171                    if (r < 8 && reg[i] != reg[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
172                }
173                drawLine(Tenue, Offset(0f, 0f), Offset(size.width, 0f), 3f)
174                drawLine(Tenue, Offset(0f, size.height), Offset(size.width, size.height), 3f)
175                drawLine(Tenue, Offset(0f, 0f), Offset(0f, size.height), 3f)
176                drawLine(Tenue, Offset(size.width, 0f), Offset(size.width, size.height), 3f)
177            } else {
178                for (k in 0..9 step 3) {
179                    drawLine(Tenue, Offset(k * cell, 0f), Offset(k * cell, size.height), 3f)
180                    drawLine(Tenue, Offset(0f, k * cell), Offset(size.width, k * cell), 3f)
181                }
182            }
183
184            // Jaulas (Killer): borde punteado + suma en la celda superior izquierda.
185            if (p.reglas.cages.isNotEmpty()) {
186                val cageId = IntArray(81) { -1 }
187                p.reglas.cages.forEachIndexed { id, cg -> for (cl in cg.cells) cageId[cl] = id }
188                val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
189                val ins = cell * 0.10f
190                for (rr in 0 until 9) for (cc in 0 until 9) {
191                    val i = rr * 9 + cc
192                    val id = cageId[i]
193                    val l = cc * cell + ins; val rt = (cc + 1) * cell - ins
194                    val t = rr * cell + ins; val b = (rr + 1) * cell - ins
195                    if (cc == 0 || cageId[i - 1] != id) drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
196                    if (cc == 8 || cageId[i + 1] != id) drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
197                    if (rr == 0 || cageId[i - 9] != id) drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
198                    if (rr == 8 || cageId[i + 9] != id) drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
199                }
200                paint.color = NeonAmbar.toArgb()
201                paint.textAlign = Paint.Align.LEFT
202                paint.textSize = cell * 0.26f
203                for (cg in p.reglas.cages) {
204                    val top = cg.cells.min()
205                    drawIntoCanvas2(this) { nc ->
206                        nc.drawText("${cg.sum}", (top % 9) * cell + ins + 2f, (top / 9) * cell + ins + cell * 0.24f, paint)
207                    }
208                }
209                paint.textAlign = Paint.Align.CENTER
210            }
211
212            // Marcas de aristas (consecutivo / XV / mayor-que).
213            if (p.aristas.isNotEmpty()) {
214                paint.textSize = cell * 0.42f
215                for (a in p.aristas) {
216                    val ra = a.a / 9; val ca = a.a % 9
217                    val horizontal = a.b == a.a + 1
218                    val mx = if (horizontal) (ca + 1) * cell else cx(ca)
219                    val my = if (horizontal) cy(ra) else (ra + 1) * cell
220                    when (a.rel) {
221                        Rel.CONSEC -> {
222                            // Barra blanca perpendicular a la arista.
223                            val h = cell * 0.16f
224                            if (horizontal) drawLine(Color.White, Offset(mx, my - h), Offset(mx, my + h), 4f)
225                            else drawLine(Color.White, Offset(mx - h, my), Offset(mx + h, my), 4f)
226                        }
227                        Rel.SUM5, Rel.SUM10 -> {
228                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
229                            paint.color = NeonCian.toArgb()
230                            glifo(this, paint, if (a.rel == Rel.SUM10) "X" else "V", mx, my)
231                        }
232                        Rel.LT, Rel.GT -> {
233                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
234                            paint.color = NeonVerde.toArgb()
235                            val s = when {
236                                horizontal && a.rel == Rel.LT -> "‹"
237                                horizontal -> "›"
238                                a.rel == Rel.LT -> "˄"
239                                else -> "˅"
240                            }
241                            glifo(this, paint, s, mx, my)
242                        }
243                        else -> {}
244                    }
245                }
246            }
247        }
248    }
249}
250
251/** Dibuja [s] centrado verticalmente en (x,y) con el [paint] dado. */
252private fun glifo(scope: androidx.compose.ui.graphics.drawscope.DrawScope, paint: Paint, s: String, x: Float, y: Float) {
253    drawIntoCanvas2(scope) { nc ->
254        val dy = (paint.descent() + paint.ascent()) / 2f
255        nc.drawText(s, x, y - dy, paint)
256    }
257}
258
259private inline fun drawIntoCanvas2(
260    scope: androidx.compose.ui.graphics.drawscope.DrawScope,
261    block: (android.graphics.Canvas) -> Unit,
262) {
263    block(scope.drawContext.canvas.nativeCanvas)
264}
265
266@Composable
267private fun Celda(
268    modifier: Modifier,
269    valor: Int,
270    notas: Int,
271    activo: Int,
272    fija: Boolean,
273    foco: Boolean,
274    relacionada: Boolean,
275    mismoDigito: Boolean,
276    errorRojo: Boolean,
277    sombra: Color,
278    onClick: () -> Unit,
279) {
280    val bg = when {
281        foco -> NeonCian.copy(alpha = 0.32f)
282        mismoDigito -> NeonCian.copy(alpha = 0.16f)
283        relacionada -> Color.White.copy(alpha = 0.05f)
284        else -> sombra
285    }
286    Box(modifier.background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
287        when {
288            valor != 0 -> Text(
289                "$valor",
290                color = when { errorRojo -> NeonRosa; fija -> Color.White; else -> NeonCian },
291                fontSize = 18.sp,
292                fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
293            )
294            notas != 0 -> NotasMini(notas, activo)
295        }
296    }
297}
298
299@Composable
300private fun NotasMini(notas: Int, activo: Int) {
301    Column(Modifier.fillMaxSize().padding(1.dp)) {
302        for (fila in 0 until 3) {
303            Row(Modifier.fillMaxWidth().weight(1f)) {
304                for (col in 0 until 3) {
305                    val d = fila * 3 + col + 1
306                    Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
307                        if (notas and (1 shl d) != 0) {
308                            Text(
309                                "$d",
310                                color = if (d == activo) NeonCian else MuyTenue,
311                                fontSize = 8.sp,
312                                fontWeight = if (d == activo) FontWeight.Bold else FontWeight.Normal,
313                            )
314                        }
315                    }
316                }
317            }
318        }
319    }
320}
321
322private fun sombraDe(i: Int, p: Puzzle, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
323    Variant.Realce.CELDAS -> if (i in sombreadas) NeonAmbar.copy(alpha = 0.13f) else Color.Transparent
324    Variant.Realce.POSICION -> {
325        val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
326        Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
327    }
328    // Par/impar: las celdas "par" (cuadradas) van sombreadas; las impar, limpias.
329    Variant.Realce.PARIDAD -> if (p.reglas.allowed[i] == PARES) Color.White.copy(alpha = 0.08f) else Color.Transparent
330    else -> Color.Transparent
331}
332
333@Composable
334private fun Teclado(vm: NonetViewModel) {
335    val restantes = IntArray(10)
336    for (v in vm.entradas) if (v in 1..9) restantes[v]++
337
338    // Columnas: [undo/redo/comprobar]  ·  3×3 números  ·  [edit/borrar/pista]
339    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
340        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
341            Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer) { vm.deshacer() }
342            Accion("↷", "rehacer", NeonCian, vm.puedeRehacer) { vm.rehacerJugada() }
343            Accion("♥", "comprobar", NeonVerde, !vm.completado) { vm.comprobar() }
344        }
345        Column(Modifier.weight(3f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
346            for (fila in 0 until 3) {
347                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
348                    for (col in 0 until 3) {
349                        val d = fila * 3 + col + 1
350                        DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
351                    }
352                }
353            }
354        }
355        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
356            Accion(
357                "✎", if (vm.modoEdicion) "edit ✓" else "edit",
358                if (vm.modoEdicion) NeonAmbar else Tenue, true, resaltado = vm.modoEdicion,
359            ) { vm.alternarEdicion() }
360            Accion("⌫", "borrar", NeonRosa, true, resaltado = vm.activo == NonetViewModel.BORRAR) { vm.activarBorrar() }
361            Accion("◌", "pista", NeonAmbar, !vm.completado) { vm.pista() }
362        }
363    }
364}
365
366@Composable
367private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifier, onClick: () -> Unit) {
368    val agotado = restantes <= 0
369    Box(
370        modifier
371            .height(48.dp)
372            .clip(RoundedCornerShape(12.dp))
373            .background(if (activo) NeonCian.copy(alpha = 0.20f) else SuperficieAlta)
374            .border(1.dp, if (activo) NeonCian else Color.Transparent, RoundedCornerShape(12.dp))
375            .clickable(onClick = onClick),
376        contentAlignment = Alignment.Center,
377    ) {
378        Text("$d", color = if (agotado) MuyTenue else NeonCian, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
379        Text(
380            "$restantes", color = MuyTenue, fontSize = 9.sp,
381            modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
382        )
383    }
384}
385
386@Composable
387private fun Accion(
388    icono: String,
389    label: String,
390    accent: Color,
391    habilitado: Boolean,
392    resaltado: Boolean = false,
393    onClick: () -> Unit,
394) {
395    Box(
396        Modifier
397            .fillMaxWidth()
398            .height(48.dp)
399            .clip(RoundedCornerShape(12.dp))
400            .background(if (resaltado) accent.copy(alpha = 0.18f) else SuperficieAlta)
401            .border(1.dp, if (resaltado) accent else Color.Transparent, RoundedCornerShape(12.dp))
402            .alpha(if (habilitado) 1f else 0.35f)
403            .clickable(enabled = habilitado, onClick = onClick),
404        contentAlignment = Alignment.Center,
405    ) {
406        Text(icono, color = accent, fontSize = 16.sp)
407        Text(
408            label, color = accent, fontSize = 8.sp,
409            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
410        )
411    }
412}