⎇ c02fd3a4d4a9b6e3a51ddd2f59112f3584e6ea12
nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
Ñawpa willay
ch'uya
Nonet: 3 variantes de pista externa, dificultad real y menú en páginas
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}